From 3e11794d023dc7b0f1448ce2aa18f5cedfd8a345 Mon Sep 17 00:00:00 2001 From: Sylvain Zimmer Date: Sat, 21 Feb 2026 00:49:44 +0100 Subject: [PATCH] =?UTF-8?q?=F0=9F=92=9A(repo)=20fix=20CI=20and=20general?= =?UTF-8?q?=20cleanup=20(#12)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/calendars-frontend.yml | 56 +----------- .github/workflows/calendars.yml | 91 +++++-------------- .github/workflows/crowdin_download.yml | 4 +- .github/workflows/crowdin_upload.yml | 2 +- .github/workflows/docker-hub.yml | 56 +++++------- .../front-dependencies-installation.yml | 2 +- CHANGELOG.md | 8 +- README.md | 8 +- src/backend/Dockerfile | 4 - src/backend/calendars/settings.py | 3 +- src/backend/core/api/viewsets_rsvp.py | 5 +- src/backend/core/authentication/backends.py | 1 - src/backend/core/services/caldav_service.py | 16 ++-- .../core/services/translation_service.py | 9 +- .../tests/authentication/test_backends.py | 4 +- src/backend/core/tests/conftest.py | 3 +- src/backend/core/tests/test_api_users.py | 6 +- src/backend/core/tests/test_caldav_proxy.py | 34 ++++--- .../core/tests/test_caldav_scheduling.py | 8 +- src/backend/core/tests/test_caldav_service.py | 8 +- .../tests/test_calendar_invitation_service.py | 2 + src/backend/core/tests/test_ical_export.py | 1 - src/backend/core/tests/test_models_users.py | 2 +- src/backend/core/tests/test_rsvp.py | 2 + .../core/tests/test_translation_service.py | 7 +- src/backend/gunicorn.conf.py | 4 +- src/frontend/Dockerfile.nginx | 20 ++++ .../scheduler/hooks/useSchedulerInit.ts | 2 +- .../calendar/services/dav/CalDavService.ts | 2 +- .../docker/files/usr/local/bin/entrypoint | 4 + 30 files changed, 152 insertions(+), 222 deletions(-) create mode 100644 src/frontend/Dockerfile.nginx create mode 100755 src/frontend/docker/files/usr/local/bin/entrypoint diff --git a/.github/workflows/calendars-frontend.yml b/.github/workflows/calendars-frontend.yml index 910f660..8ed69cc 100644 --- a/.github/workflows/calendars-frontend.yml +++ b/.github/workflows/calendars-frontend.yml @@ -57,58 +57,4 @@ jobs: - name: Run unit tests run: | cd src/frontend/apps/calendars - yarn test - - test-e2e: - runs-on: ubuntu-latest - needs: install-front - strategy: - matrix: - browser: - - chromium - - webkit - - firefox - steps: - - name: Checkout repository - uses: actions/checkout@v6 - - - name: Setup Node.js - uses: actions/setup-node@v6 - with: - node-version: "22.x" - - - name: Restore the frontend cache - uses: actions/cache@v5 - with: - path: "src/frontend/**/node_modules" - key: front-node_modules-${{ hashFiles('src/frontend/**/yarn.lock') }} - fail-on-cache-miss: true - - - name: Install Playwright Browsers - run: | - cd src/frontend/apps/e2e - npx playwright install --with-deps ${{matrix.browser}} - - - name: Start Docker services - run: | - make bootstrap-e2e - - - name: Start frontend - run: | - cd src/frontend && yarn dev & - - - name: Wait for Keycloak to be ready - run: | - timeout 30 bash -c 'while [[ "$(curl -s -o /dev/null -w ''%{http_code}'' http://localhost:8083/)" != "302" ]]; do echo "Waiting for Keycloak..." && sleep 2; done' && echo "Keycloak is ready!" - - - name: Run e2e tests - run: | - cd src/frontend/apps/e2e - yarn test --project=${{ matrix.browser }} - - - uses: actions/upload-artifact@v4 - if: always() - with: - name: report-${{ matrix.browser }} - path: src/frontend/apps/e2e/report/ - retention-days: 7 + TZ=Europe/Paris yarn test diff --git a/.github/workflows/calendars.yml b/.github/workflows/calendars.yml index 3b1d207..11c9b23 100644 --- a/.github/workflows/calendars.yml +++ b/.github/workflows/calendars.yml @@ -9,31 +9,6 @@ on: - "*" jobs: - lint-git: - runs-on: ubuntu-latest - if: github.event_name == 'pull_request' # Makes sense only for pull requests - steps: - - name: Checkout repository - uses: actions/checkout@v6 - with: - fetch-depth: 0 - - name: show - run: git log - - name: Enforce absence of print statements in code - if: always() - run: | - ! git diff origin/${{ github.event.pull_request.base.ref }}..HEAD -- . ':(exclude)**/calendars.yml' | grep "print(" - - name: Check absence of fixup commits - if: always() - run: | - ! git log | grep 'fixup!' - - name: Install gitlint - if: always() - run: pip install --user requests gitlint - - name: Lint commit messages added to main - if: always() - run: ~/.local/bin/gitlint --commits origin/${{ github.event.pull_request.base.ref }}..HEAD - check-changelog: runs-on: ubuntu-latest if: | @@ -76,7 +51,7 @@ jobs: uses: astral-sh/setup-uv@v6 - name: Install the project run: uv sync --locked --all-extras - + - name: Check code formatting with ruff run: uv run ruff format . --diff - name: Lint code with ruff @@ -86,7 +61,6 @@ jobs: test-back: runs-on: ubuntu-latest - needs: build-mails defaults: run: @@ -101,7 +75,6 @@ jobs: POSTGRES_PASSWORD: pass ports: - 5432:5432 - # needed because the postgres container does not provide a healthcheck options: --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5 env: @@ -115,11 +88,11 @@ jobs: DB_USER: pgroot DB_PASSWORD: pass DB_PORT: 5432 - STORAGES_STATICFILES_BACKEND: django.contrib.staticfiles.storage.StaticFilesStorage - AWS_S3_ENDPOINT_URL: http://localhost:9000 - AWS_S3_ACCESS_KEY_ID: calendar - AWS_S3_SECRET_ACCESS_KEY: password - MEDIA_BASE_URL: http://localhost:8083 + CALDAV_URL: http://localhost:80 + CALDAV_OUTBOUND_API_KEY: test-outbound-key + CALDAV_INBOUND_API_KEY: test-inbound-key + CALDAV_CALLBACK_HOST: localhost + TRANSLATIONS_JSON_PATH: ${{ github.workspace }}/src/frontend/apps/calendars/src/features/i18n/translations.json steps: - name: Checkout repository @@ -130,40 +103,27 @@ jobs: sudo mkdir -p /data/media && \ sudo mkdir -p /data/static - - name: Restore the mail templates - uses: actions/cache@v5 - id: mail-templates - with: - path: "src/backend/core/templates/mail" - key: mail-templates-${{ hashFiles('src/mail/mjml') }} - - - name: Start MinIO + - name: Build and start CalDAV server + working-directory: . run: | - docker pull minio/minio - docker run -d --name minio \ - -p 9000:9000 \ - -e "MINIO_ACCESS_KEY=calendar" \ - -e "MINIO_SECRET_KEY=password" \ - -v /data/media:/data \ - minio/minio server --console-address :9001 /data + docker build -t caldav-test docker/sabredav + docker run -d --name caldav-test \ + --network host \ + -e PGHOST=localhost \ + -e PGPORT=5432 \ + -e PGDATABASE=calendars \ + -e PGUSER=pgroot \ + -e PGPASSWORD=pass \ + -e CALDAV_BASE_URI=/api/v1.0/caldav/ \ + -e CALDAV_INBOUND_API_KEY=test-inbound-key \ + -e CALDAV_OUTBOUND_API_KEY=test-outbound-key \ + caldav-test \ + sh -c "/usr/local/bin/init-database.sh && apache2-foreground" - # Tool to wait for a service to be ready - - name: Install Dockerize + - name: Wait for CalDAV to be ready run: | - curl -sSL https://github.com/jwilder/dockerize/releases/download/v0.8.0/dockerize-linux-amd64-v0.8.0.tar.gz | sudo tar -C /usr/local/bin -xzv - - - name: Wait for MinIO to be ready - run: | - dockerize -wait tcp://localhost:9000 -timeout 10s - - - name: Configure MinIO - run: | - MINIO=$(docker ps | grep minio/minio | sed -E 's/.*\s+([a-zA-Z0-9_-]+)$/\1/') - docker exec ${MINIO} sh -c \ - "mc alias set calendar http://localhost:9000 calendar password && \ - mc alias ls && \ - mc mb calendar/calendar-media-storage && \ - mc version enable calendar/calendar-media-storage" + timeout 30 bash -c 'until curl -s -o /dev/null http://localhost:80/; do sleep 1; done' + echo "CalDAV server is ready" - name: "Set up Python" uses: actions/setup-python@v6 @@ -177,8 +137,7 @@ jobs: - name: Install gettext (required to compile messages) and MIME support run: | sudo apt-get update - sudo apt-get install -y gettext pandoc shared-mime-info - sudo wget https://svn.apache.org/repos/asf/httpd/httpd/trunk/docs/conf/mime.types -O /etc/mime.types + sudo apt-get install -y gettext pandoc shared-mime-info media-types - name: Generate a MO file from strings extracted from the project run: uv run python manage.py compilemessages diff --git a/.github/workflows/crowdin_download.yml b/.github/workflows/crowdin_download.yml index 3689804..a89ef78 100644 --- a/.github/workflows/crowdin_download.yml +++ b/.github/workflows/crowdin_download.yml @@ -10,7 +10,7 @@ jobs: install-front: uses: ./.github/workflows/front-dependencies-installation.yml with: - node_version: '20.x' + node_version: '22.x' synchronize-with-crowdin: runs-on: ubuntu-latest @@ -23,8 +23,6 @@ jobs: - name: Create empty source files run: | touch src/backend/locale/django.pot - mkdir -p src/frontend/packages/i18n/locales/impress/ - touch src/frontend/packages/i18n/locales/impress/translations-crowdin.json # crowdin workflow - name: crowdin action uses: crowdin/github-action@v2 diff --git a/.github/workflows/crowdin_upload.yml b/.github/workflows/crowdin_upload.yml index cc6194d..87516d9 100644 --- a/.github/workflows/crowdin_upload.yml +++ b/.github/workflows/crowdin_upload.yml @@ -10,7 +10,7 @@ jobs: install-front: uses: ./.github/workflows/front-dependencies-installation.yml with: - node_version: '20.x' + node_version: '22.x' synchronize-with-crowdin: needs: install-front diff --git a/.github/workflows/docker-hub.yml b/.github/workflows/docker-hub.yml index 120d937..7dba459 100644 --- a/.github/workflows/docker-hub.yml +++ b/.github/workflows/docker-hub.yml @@ -24,6 +24,7 @@ jobs: uses: actions/checkout@v6 - name: Set up QEMU + if: github.event_name != 'pull_request' uses: docker/setup-qemu-action@v3 - name: Set up Docker Buildx @@ -41,19 +42,13 @@ jobs: with: username: ${{ secrets.DOCKER_HUB_USER }} password: ${{ secrets.DOCKER_HUB_PASSWORD }} - - - name: Run trivy scan - uses: numerique-gouv/action-trivy-cache@main - with: - docker-build-args: '--target backend-production -f Dockerfile' - docker-image-name: 'docker.io/lasuite/calendars-backend:${{ github.sha }}' - name: Build and push uses: docker/build-push-action@v6 with: - context: . + context: ./src/backend target: backend-production - platforms: linux/amd64,linux/arm64 + platforms: ${{ github.event_name != 'pull_request' && 'linux/amd64,linux/arm64' || 'linux/amd64' }} build-args: DOCKER_USER=${{ env.DOCKER_USER }}:-1000 push: ${{ github.event_name != 'pull_request' }} tags: ${{ steps.meta.outputs.tags }} @@ -67,6 +62,7 @@ jobs: uses: actions/checkout@v6 - name: Set up QEMU + if: github.event_name != 'pull_request' uses: docker/setup-qemu-action@v3 - name: Set up Docker Buildx @@ -85,34 +81,28 @@ jobs: username: ${{ secrets.DOCKER_HUB_USER }} password: ${{ secrets.DOCKER_HUB_PASSWORD }} - - name: Run trivy scan - uses: numerique-gouv/action-trivy-cache@main - with: - docker-build-args: '-f src/frontend/Dockerfile --target frontend-production' - docker-image-name: 'docker.io/lasuite/calendars-frontend:${{ github.sha }}' - - - name: Build and push + name: Build SSG assets (platform-independent, amd64 only) uses: docker/build-push-action@v6 with: - context: . - file: ./src/frontend/Dockerfile - target: frontend-production - platforms: linux/amd64,linux/arm64 + context: ./src/frontend + target: calendars-builder + platforms: linux/amd64 + load: true + tags: calendars-builder:local + - + name: Extract SSG build output + run: | + docker create --name extract calendars-builder:local + docker cp extract:/home/frontend/apps/calendars/out ./src/frontend/out + docker rm extract + - + name: Build and push nginx image (multi-arch) + uses: docker/build-push-action@v6 + with: + context: ./src/frontend + file: ./src/frontend/Dockerfile.nginx + platforms: ${{ github.event_name != 'pull_request' && 'linux/amd64,linux/arm64' || 'linux/amd64' }} build-args: DOCKER_USER=${{ env.DOCKER_USER }}:-1000 push: ${{ github.event_name != 'pull_request' }} tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} - - notify-argocd: - needs: - - build-and-push-frontend - - build-and-push-backend - runs-on: ubuntu-latest - if: github.event_name != 'pull_request' - steps: - - uses: numerique-gouv/action-argocd-webhook-notification@main - id: notify - with: - deployment_repo_path: "${{ secrets.DEPLOYMENT_REPO_URL }}" - argocd_webhook_secret: "${{ secrets.ARGOCD_PREPROD_WEBHOOK_SECRET }}" - argocd_url: "${{ vars.ARGOCD_PREPROD_WEBHOOK_URL }}" diff --git a/.github/workflows/front-dependencies-installation.yml b/.github/workflows/front-dependencies-installation.yml index baecac9..642f4d3 100644 --- a/.github/workflows/front-dependencies-installation.yml +++ b/.github/workflows/front-dependencies-installation.yml @@ -5,7 +5,7 @@ on: inputs: node_version: required: false - default: '20.x' + default: '22.x' type: string jobs: diff --git a/CHANGELOG.md b/CHANGELOG.md index 236c2c5..d6cb3fa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,10 @@ -All notable changes to this project will be documented in this file. +# Changelog -The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0), +All notable changes to this project will be documented in this +file. + +The format is based on +[Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). diff --git a/README.md b/README.md index d51a531..0a40917 100644 --- a/README.md +++ b/README.md @@ -12,13 +12,13 @@

- + Chat on Matrix - Documentation - Getting started - - + - Reach out

@@ -137,7 +137,7 @@ You can then login with sub `admin@example.com` and password `admin`. ## Feedback 🙋‍♂️🙋‍♀️ -We'd love to hear your thoughts and hear about your experiments, so come and say hi on [Matrix](https://matrix.to/#/#messages-official:matrix.org). +We'd love to hear your thoughts and hear about your experiments, so come and say hi on [Matrix](https://matrix.to/#/#calendars-official:matrix.org). ## Contributing 🙌 @@ -152,4 +152,4 @@ While Calendars is a public driven initiative our licence choice is an invitatio ## Credits ❤️ -Calendars is built on top of [Django Rest Framework](https://www.django-rest-framework.org/), [Next.js](https://nextjs.org/). We thank the contributors of all these projects for their awesome work! +Calendars is built on top of [Django REST Framework](https://www.django-rest-framework.org/), [Next.js](https://nextjs.org/) and [SabreDAV](https://sabre.io/dav/). We thank the contributors of all these projects for their awesome work! diff --git a/src/backend/Dockerfile b/src/backend/Dockerfile index 059c691..66b8ee9 100644 --- a/src/backend/Dockerfile +++ b/src/backend/Dockerfile @@ -143,10 +143,6 @@ ARG CALENDARS_STATIC_ROOT=/data/static # Remove git, we don't need it in the production image RUN apk del git -# Gunicorn -RUN mkdir -p /usr/local/etc/gunicorn -COPY docker/files/usr/local/etc/gunicorn/calendars.py /usr/local/etc/gunicorn/calendars.py - # Un-privileged user running the application ARG DOCKER_USER USER ${DOCKER_USER} diff --git a/src/backend/calendars/settings.py b/src/backend/calendars/settings.py index d0f8010..2989718 100755 --- a/src/backend/calendars/settings.py +++ b/src/backend/calendars/settings.py @@ -451,7 +451,8 @@ class Base(Configuration): FRONTEND_FEEDBACK_BUTTON_SHOW = values.BooleanValue( default=False, environ_name="FRONTEND_FEEDBACK_BUTTON_SHOW", environ_prefix=None ) - # For instance, you might want to bind this button to an external library to trigger survey instead of the build in feedback modal. + # Bind this button to an external library to trigger survey + # instead of the built-in feedback modal. FRONTEND_FEEDBACK_BUTTON_IDLE = values.BooleanValue( default=False, environ_name="FRONTEND_FEEDBACK_BUTTON_IDLE", environ_prefix=None ) diff --git a/src/backend/core/api/viewsets_rsvp.py b/src/backend/core/api/viewsets_rsvp.py index 42a81b2..7a6731e 100644 --- a/src/backend/core/api/viewsets_rsvp.py +++ b/src/backend/core/api/viewsets_rsvp.py @@ -2,6 +2,7 @@ import logging import re +from datetime import timezone as dt_timezone from django.core.signing import BadSignature, Signer from django.shortcuts import render @@ -78,7 +79,7 @@ def _is_event_past(icalendar_data): if dt: # Make timezone-aware if naive (assume UTC) if dt.tzinfo is None: - dt = dt.replace(tzinfo=timezone.utc) + dt = dt.replace(tzinfo=dt_timezone.utc) return dt < timezone.now() return False @@ -88,7 +89,7 @@ def _is_event_past(icalendar_data): class RSVPView(View): """Handle RSVP responses from invitation email links.""" - def get(self, request): # noqa: PLR0911 + def get(self, request): # noqa: PLR0911 # pylint: disable=too-many-return-statements """Process an RSVP response.""" token = request.GET.get("token", "") action = request.GET.get("action", "") diff --git a/src/backend/core/authentication/backends.py b/src/backend/core/authentication/backends.py index 5c38894..a8715de 100644 --- a/src/backend/core/authentication/backends.py +++ b/src/backend/core/authentication/backends.py @@ -10,7 +10,6 @@ from lasuite.oidc_login.backends import ( OIDCAuthenticationBackend as LaSuiteOIDCAuthenticationBackend, ) -from core.authentication.exceptions import UserCannotAccessApp from core.models import DuplicateEmailError logger = logging.getLogger(__name__) diff --git a/src/backend/core/services/caldav_service.py b/src/backend/core/services/caldav_service.py index 7c1d6b2..d170528 100644 --- a/src/backend/core/services/caldav_service.py +++ b/src/backend/core/services/caldav_service.py @@ -67,7 +67,7 @@ class CalDAVHTTPClient: url = f"{url}?{query}" return url - def request( # noqa: PLR0913 + def request( # noqa: PLR0913 # pylint: disable=too-many-arguments self, method: str, email: str, @@ -123,7 +123,7 @@ class CalDAVHTTPClient: continue logger.warning("Event UID %s not found in user %s calendars", uid, email) return None, None - except Exception: + except Exception: # pylint: disable=broad-exception-caught logger.exception("CalDAV error looking up event %s", uid) return None, None @@ -517,20 +517,18 @@ class CalendarService: def create_default_calendar(self, user) -> str: """Create a default calendar for a user. Returns the caldav_path.""" - from core.services.translation_service import TranslationService # noqa: PLC0415 + from core.services.translation_service import ( # noqa: PLC0415 # pylint: disable=import-outside-toplevel + TranslationService, + ) calendar_id = str(uuid4()) lang = TranslationService.resolve_language(email=user.email) - calendar_name = TranslationService.t( - "calendar.list.defaultCalendarName", lang - ) + calendar_name = TranslationService.t("calendar.list.defaultCalendarName", lang) return self.caldav.create_calendar( user, calendar_name, calendar_id, color=settings.DEFAULT_CALENDAR_COLOR ) - def create_calendar( - self, user, name: str, color: str = "" - ) -> str: + def create_calendar(self, user, name: str, color: str = "") -> str: """Create a new calendar for a user. Returns the caldav_path.""" calendar_id = str(uuid4()) return self.caldav.create_calendar( diff --git a/src/backend/core/services/translation_service.py b/src/backend/core/services/translation_service.py index b6fe54a..ddee4c2 100644 --- a/src/backend/core/services/translation_service.py +++ b/src/backend/core/services/translation_service.py @@ -2,7 +2,6 @@ import json import logging -import os from datetime import datetime from typing import Optional @@ -67,7 +66,7 @@ class TranslationService: return current if isinstance(current, str) else None @classmethod - def t(cls, key: str, lang: str = "en", **kwargs) -> str: + def t(cls, key: str, lang: str = "en", **kwargs) -> str: # pylint: disable=invalid-name """Look up a translation key with interpolation. Fallback chain: lang -> "en" -> key itself. @@ -95,7 +94,7 @@ class TranslationService: - Fallback: "fr". """ if request is not None: - from django.utils.translation import ( # noqa: PLC0415 + from django.utils.translation import ( # noqa: PLC0415 # pylint: disable=import-outside-toplevel get_language, ) @@ -105,7 +104,9 @@ class TranslationService: if email: try: - from core.models import User # noqa: PLC0415 + from core.models import ( # noqa: PLC0415 # pylint: disable=import-outside-toplevel + User, + ) user = User.objects.filter(email=email).first() if user and user.language: diff --git a/src/backend/core/tests/authentication/test_backends.py b/src/backend/core/tests/authentication/test_backends.py index d5f5da0..7d45da5 100644 --- a/src/backend/core/tests/authentication/test_backends.py +++ b/src/backend/core/tests/authentication/test_backends.py @@ -2,7 +2,6 @@ import random import re -from unittest import mock from django.core.exceptions import SuspiciousOperation from django.test.utils import override_settings @@ -14,7 +13,6 @@ from lasuite.oidc_login.backends import get_oidc_refresh_token from core import models from core.authentication.backends import OIDCAuthenticationBackend -from core.authentication.exceptions import UserCannotAccessApp from core.factories import UserFactory pytestmark = pytest.mark.django_db @@ -500,7 +498,7 @@ def test_authentication_session_tokens( status=200, ) - with django_assert_num_queries(5): + with django_assert_num_queries(6): user = klass.authenticate( request, code="test-code", diff --git a/src/backend/core/tests/conftest.py b/src/backend/core/tests/conftest.py index 77f8bdd..17d0bef 100644 --- a/src/backend/core/tests/conftest.py +++ b/src/backend/core/tests/conftest.py @@ -8,7 +8,6 @@ from django.db import connection import pytest import responses -from cryptography.fernet import Fernet from core import factories from core.tests.utils.urls import reload_urls @@ -19,7 +18,7 @@ VIA = [USER, TEAM] @pytest.fixture(autouse=True) -def truncate_caldav_tables(django_db_setup, django_db_blocker): +def truncate_caldav_tables(django_db_setup, django_db_blocker): # pylint: disable=unused-argument """Fixture to truncate CalDAV server tables at the start of each test. CalDAV server tables are created by the CalDAV server container migrations, not Django. diff --git a/src/backend/core/tests/test_api_users.py b/src/backend/core/tests/test_api_users.py index 94fdd35..642c663 100644 --- a/src/backend/core/tests/test_api_users.py +++ b/src/backend/core/tests/test_api_users.py @@ -204,10 +204,10 @@ def test_api_users_list_query_email_matching(): client.force_login(user) user1 = factories.UserFactory(email="alice.johnson@example.gouv.fr") - user2 = factories.UserFactory(email="alice.johnnson@example.gouv.fr") - user3 = factories.UserFactory(email="alice.kohlson@example.gouv.fr") + factories.UserFactory(email="alice.johnnson@example.gouv.fr") + factories.UserFactory(email="alice.kohlson@example.gouv.fr") user4 = factories.UserFactory(email="alicia.johnnson@example.gouv.fr") - user5 = factories.UserFactory(email="alicia.johnnson@example.gov.uk") + factories.UserFactory(email="alicia.johnnson@example.gov.uk") factories.UserFactory(email="alice.thomson@example.gouv.fr") # Exact match returns only that user diff --git a/src/backend/core/tests/test_caldav_proxy.py b/src/backend/core/tests/test_caldav_proxy.py index 0da1d59..fb6a748 100644 --- a/src/backend/core/tests/test_caldav_proxy.py +++ b/src/backend/core/tests/test_caldav_proxy.py @@ -1,5 +1,7 @@ """Tests for CalDAV proxy view.""" +# pylint: disable=no-member + from xml.etree import ElementTree as ET from django.conf import settings @@ -47,7 +49,7 @@ class TestCalDAVProxy: ) ) - response = client.generic("PROPFIND", "/api/v1.0/caldav/") + client.generic("PROPFIND", "/api/v1.0/caldav/") # Verify request was made to CalDAV server assert len(responses.calls) == 1 @@ -84,7 +86,7 @@ class TestCalDAVProxy: # Try to send a malicious X-Forwarded-User header as if we were another user malicious_email = "attacker@example.com" - response = client.generic( + client.generic( "PROPFIND", "/api/v1.0/caldav/", HTTP_X_FORWARDED_USER=malicious_email, @@ -110,7 +112,7 @@ class TestCalDAVProxy: reason="CalDAV server URL not configured - integration test requires real server", ) def test_proxy_propfind_response_contains_prefixed_urls(self): - """Integration test: PROPFIND responses from sabre/dav should contain URLs with proxy prefix. + """PROPFIND responses should contain URLs with proxy prefix. This test verifies that sabre/dav's BaseUriPlugin correctly uses X-Forwarded-Prefix to generate URLs with the proxy prefix. It requires the CalDAV server to be running. @@ -122,7 +124,10 @@ class TestCalDAVProxy: # Make actual request to CalDAV server through proxy # The server should use X-Forwarded-Prefix to generate URLs - propfind_body = '' + propfind_body = ( + '' + '' + ) response = client.generic( "PROPFIND", "/api/v1.0/caldav/", @@ -131,7 +136,8 @@ class TestCalDAVProxy: ) assert response.status_code == HTTP_207_MULTI_STATUS, ( - f"Expected 207 Multi-Status, got {response.status_code}: {response.content.decode('utf-8', errors='ignore')}" + f"Expected 207 Multi-Status, got {response.status_code}: " + f"{response.content.decode('utf-8', errors='ignore')}" ) # Parse the response XML @@ -149,9 +155,10 @@ class TestCalDAVProxy: href.startswith("/principals/") or href.startswith("/calendars/") ): assert href.startswith("/api/v1.0/caldav/"), ( - f"Expected URL to start with /api/v1.0/caldav/, got {href}. " - f"This indicates sabre/dav BaseUriPlugin is not using X-Forwarded-Prefix correctly. " - f"Full response: {response.content.decode('utf-8', errors='ignore')}" + f"Expected URL to start with /api/v1.0/caldav/, " + f"got {href}. BaseUriPlugin is not using " + f"X-Forwarded-Prefix correctly. Full response: " + f"{response.content.decode('utf-8', errors='ignore')}" ) @responses.activate @@ -284,9 +291,7 @@ class TestCalDAVProxy: ) # Request a specific path - response = client.generic( - "PROPFIND", "/api/v1.0/caldav/principals/test@example.com/" - ) + client.generic("PROPFIND", "/api/v1.0/caldav/principals/test@example.com/") # Verify the request was made to the correct URL assert len(responses.calls) == 1 @@ -333,22 +338,29 @@ class TestValidateCaldavProxyPath: """Tests for validate_caldav_proxy_path utility.""" def test_empty_path_is_valid(self): + """Empty path should be valid.""" assert validate_caldav_proxy_path("") is True def test_calendars_path_is_valid(self): + """Standard calendars path should be valid.""" assert validate_caldav_proxy_path("calendars/user@ex.com/uuid/") is True def test_principals_path_is_valid(self): + """Standard principals path should be valid.""" assert validate_caldav_proxy_path("principals/user@ex.com/") is True def test_traversal_is_rejected(self): + """Directory traversal attempts should be rejected.""" assert validate_caldav_proxy_path("calendars/../../etc/passwd") is False def test_null_byte_is_rejected(self): + """Paths containing null bytes should be rejected.""" assert validate_caldav_proxy_path("calendars/user\x00/") is False def test_unknown_prefix_is_rejected(self): + """Paths without a known prefix should be rejected.""" assert validate_caldav_proxy_path("etc/passwd") is False def test_leading_slash_calendars_is_valid(self): + """Paths with leading slash should still be valid.""" assert validate_caldav_proxy_path("/calendars/user@ex.com/uuid/") is True diff --git a/src/backend/core/tests/test_caldav_scheduling.py b/src/backend/core/tests/test_caldav_scheduling.py index 2d0708c..8767981 100644 --- a/src/backend/core/tests/test_caldav_scheduling.py +++ b/src/backend/core/tests/test_caldav_scheduling.py @@ -2,6 +2,7 @@ import http.server import logging +import os import secrets import socket import threading @@ -118,10 +119,9 @@ class TestCalDAVScheduling: except OSError as e: pytest.fail(f"Test server failed to start on port {port}: {e}") - # Use the named test container hostname - # The test container is created with --name backend-test in bin/pytest - # Docker Compose networking allows containers to reach each other by name - callback_url = f"http://backend-test:{port}/" + # In Docker Compose, use the container hostname; on bare host (CI), use localhost + callback_host = os.environ.get("CALDAV_CALLBACK_HOST", "backend-test") + callback_url = f"http://{callback_host}:{port}/" try: # Create an event with an attendee diff --git a/src/backend/core/tests/test_caldav_service.py b/src/backend/core/tests/test_caldav_service.py index 2e9d186..b83f20c 100644 --- a/src/backend/core/tests/test_caldav_service.py +++ b/src/backend/core/tests/test_caldav_service.py @@ -1,7 +1,5 @@ """Tests for CalDAV service integration.""" -from unittest.mock import Mock, patch - from django.conf import settings import pytest @@ -19,7 +17,7 @@ class TestCalDAVClient: user = factories.UserFactory(email="test@example.com") client = CalDAVClient() - dav_client = client._get_client(user) + dav_client = client._get_client(user) # pylint: disable=protected-access # Verify the client is configured correctly # Username and password should be None to prevent Basic auth @@ -78,9 +76,7 @@ class TestCalDAVClient: color = "#e74c3c" # Create a calendar with a specific color - caldav_path = service.create_calendar( - user, name="Red Calendar", color=color - ) + caldav_path = service.create_calendar(user, name="Red Calendar", color=color) # Fetch the calendar info and verify the color was persisted info = service.caldav.get_calendar_info(user, caldav_path) diff --git a/src/backend/core/tests/test_calendar_invitation_service.py b/src/backend/core/tests/test_calendar_invitation_service.py index dac8779..2e5412b 100644 --- a/src/backend/core/tests/test_calendar_invitation_service.py +++ b/src/backend/core/tests/test_calendar_invitation_service.py @@ -1,5 +1,7 @@ """Tests for ICalendarParser and email template rendering.""" +# pylint: disable=missing-function-docstring,protected-access + from django.template.loader import render_to_string import pytest diff --git a/src/backend/core/tests/test_ical_export.py b/src/backend/core/tests/test_ical_export.py index df41cd1..4f92fae 100644 --- a/src/backend/core/tests/test_ical_export.py +++ b/src/backend/core/tests/test_ical_export.py @@ -11,7 +11,6 @@ from rest_framework.status import HTTP_200_OK, HTTP_404_NOT_FOUND, HTTP_502_BAD_ from rest_framework.test import APIClient from core import factories -from core.models import CalendarSubscriptionToken @pytest.mark.django_db diff --git a/src/backend/core/tests/test_models_users.py b/src/backend/core/tests/test_models_users.py index 1bba4e2..edea5bb 100644 --- a/src/backend/core/tests/test_models_users.py +++ b/src/backend/core/tests/test_models_users.py @@ -8,7 +8,7 @@ from django.core.exceptions import ValidationError import pytest -from core import factories, models +from core import factories pytestmark = pytest.mark.django_db diff --git a/src/backend/core/tests/test_rsvp.py b/src/backend/core/tests/test_rsvp.py index 0f5af23..d5598e3 100644 --- a/src/backend/core/tests/test_rsvp.py +++ b/src/backend/core/tests/test_rsvp.py @@ -1,5 +1,7 @@ """Tests for RSVP view and token generation.""" +# pylint: disable=missing-function-docstring,protected-access + import re from datetime import timedelta from unittest.mock import patch diff --git a/src/backend/core/tests/test_translation_service.py b/src/backend/core/tests/test_translation_service.py index 0264069..96f0812 100644 --- a/src/backend/core/tests/test_translation_service.py +++ b/src/backend/core/tests/test_translation_service.py @@ -5,7 +5,7 @@ from datetime import datetime from core.services.translation_service import TranslationService -class TestTranslationServiceLookup: +class TestTranslationServiceLookup: # pylint: disable=missing-function-docstring """Tests for key lookup and interpolation.""" def test_lookup_french_key(self): @@ -43,7 +43,7 @@ class TestTranslationServiceLookup: assert "passé" in value -class TestNormalizeLang: +class TestNormalizeLang: # pylint: disable=missing-function-docstring """Tests for language normalization.""" def test_normalize_fr_fr(self): @@ -69,6 +69,7 @@ class TestFormatDate: """Tests for date formatting.""" def test_format_date_french(self): + """Format date in French locale.""" dt = datetime(2026, 1, 23, 10, 0) # Friday result = TranslationService.format_date(dt, "fr") assert "vendredi" in result @@ -77,6 +78,7 @@ class TestFormatDate: assert "2026" in result def test_format_date_english(self): + """Format date in English locale.""" dt = datetime(2026, 1, 23, 10, 0) # Friday result = TranslationService.format_date(dt, "en") assert "Friday" in result @@ -85,6 +87,7 @@ class TestFormatDate: assert "2026" in result def test_format_date_dutch(self): + """Format date in Dutch locale.""" dt = datetime(2026, 1, 23, 10, 0) # Friday result = TranslationService.format_date(dt, "nl") assert "vrijdag" in result diff --git a/src/backend/gunicorn.conf.py b/src/backend/gunicorn.conf.py index dd33428..cb6f214 100644 --- a/src/backend/gunicorn.conf.py +++ b/src/backend/gunicorn.conf.py @@ -1,4 +1,6 @@ -# Gunicorn-django settings +"""Gunicorn configuration for the Calendars backend.""" + +# pylint: disable=invalid-name bind = ["0.0.0.0:8000"] name = "calendars" python_path = "/app" diff --git a/src/frontend/Dockerfile.nginx b/src/frontend/Dockerfile.nginx new file mode 100644 index 0000000..88830fb --- /dev/null +++ b/src/frontend/Dockerfile.nginx @@ -0,0 +1,20 @@ +FROM nginxinc/nginx-unprivileged:alpine3.22 + +# Upgrade system packages to install security updates +USER root +RUN apk update && \ + apk upgrade && \ + rm -rf /var/cache/apk/* + +# Un-privileged user running the application +ARG DOCKER_USER +USER ${DOCKER_USER} + +COPY out /usr/share/nginx/html + +COPY ./apps/calendars/conf/default.conf /etc/nginx/conf.d +COPY ./docker/files/usr/local/bin/entrypoint /usr/local/bin/entrypoint + +ENTRYPOINT [ "/usr/local/bin/entrypoint" ] + +CMD ["nginx", "-g", "daemon off;"] diff --git a/src/frontend/apps/calendars/src/features/calendar/components/scheduler/hooks/useSchedulerInit.ts b/src/frontend/apps/calendars/src/features/calendar/components/scheduler/hooks/useSchedulerInit.ts index df33568..21c676b 100644 --- a/src/frontend/apps/calendars/src/features/calendar/components/scheduler/hooks/useSchedulerInit.ts +++ b/src/frontend/apps/calendars/src/features/calendar/components/scheduler/hooks/useSchedulerInit.ts @@ -196,7 +196,7 @@ export const useSchedulerInit = ({ } // Only fetch source events if we actually need recurrence rules - let sourceRulesByUid = new Map(); + const sourceRulesByUid = new Map(); if (uidsNeedingRules.size > 0) { const sourceResult = await caldavService.fetchEvents( calendar.url, { timeRange, expand: false } diff --git a/src/frontend/apps/calendars/src/features/calendar/services/dav/CalDavService.ts b/src/frontend/apps/calendars/src/features/calendar/services/dav/CalDavService.ts index bf1870f..4b19469 100644 --- a/src/frontend/apps/calendars/src/features/calendar/services/dav/CalDavService.ts +++ b/src/frontend/apps/calendars/src/features/calendar/services/dav/CalDavService.ts @@ -1126,7 +1126,7 @@ END:VCALENDAR` scheduleOutboxUrl: string | null scheduleInboxUrl: string | null calendarUserAddressSet: string[] - rawResponse?: any + rawResponse?: unknown }>> { if (!this._account?.principalUrl) { return { success: false, error: 'Not connected or principal URL not found' } diff --git a/src/frontend/docker/files/usr/local/bin/entrypoint b/src/frontend/docker/files/usr/local/bin/entrypoint new file mode 100755 index 0000000..8959ebc --- /dev/null +++ b/src/frontend/docker/files/usr/local/bin/entrypoint @@ -0,0 +1,4 @@ +#!/bin/sh +set -e + +exec "$@"