💚(repo) fix CI and general cleanup (#12)

This commit is contained in:
Sylvain Zimmer
2026-02-21 00:49:44 +01:00
committed by GitHub
parent 4f4eccd9c8
commit 3e11794d02
30 changed files with 152 additions and 222 deletions

View File

@@ -57,58 +57,4 @@ jobs:
- name: Run unit tests - name: Run unit tests
run: | run: |
cd src/frontend/apps/calendars cd src/frontend/apps/calendars
yarn test TZ=Europe/Paris 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

View File

@@ -9,31 +9,6 @@ on:
- "*" - "*"
jobs: 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: check-changelog:
runs-on: ubuntu-latest runs-on: ubuntu-latest
if: | if: |
@@ -86,7 +61,6 @@ jobs:
test-back: test-back:
runs-on: ubuntu-latest runs-on: ubuntu-latest
needs: build-mails
defaults: defaults:
run: run:
@@ -101,7 +75,6 @@ jobs:
POSTGRES_PASSWORD: pass POSTGRES_PASSWORD: pass
ports: ports:
- 5432:5432 - 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 options: --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5
env: env:
@@ -115,11 +88,11 @@ jobs:
DB_USER: pgroot DB_USER: pgroot
DB_PASSWORD: pass DB_PASSWORD: pass
DB_PORT: 5432 DB_PORT: 5432
STORAGES_STATICFILES_BACKEND: django.contrib.staticfiles.storage.StaticFilesStorage CALDAV_URL: http://localhost:80
AWS_S3_ENDPOINT_URL: http://localhost:9000 CALDAV_OUTBOUND_API_KEY: test-outbound-key
AWS_S3_ACCESS_KEY_ID: calendar CALDAV_INBOUND_API_KEY: test-inbound-key
AWS_S3_SECRET_ACCESS_KEY: password CALDAV_CALLBACK_HOST: localhost
MEDIA_BASE_URL: http://localhost:8083 TRANSLATIONS_JSON_PATH: ${{ github.workspace }}/src/frontend/apps/calendars/src/features/i18n/translations.json
steps: steps:
- name: Checkout repository - name: Checkout repository
@@ -130,40 +103,27 @@ jobs:
sudo mkdir -p /data/media && \ sudo mkdir -p /data/media && \
sudo mkdir -p /data/static sudo mkdir -p /data/static
- name: Restore the mail templates - name: Build and start CalDAV server
uses: actions/cache@v5 working-directory: .
id: mail-templates
with:
path: "src/backend/core/templates/mail"
key: mail-templates-${{ hashFiles('src/mail/mjml') }}
- name: Start MinIO
run: | run: |
docker pull minio/minio docker build -t caldav-test docker/sabredav
docker run -d --name minio \ docker run -d --name caldav-test \
-p 9000:9000 \ --network host \
-e "MINIO_ACCESS_KEY=calendar" \ -e PGHOST=localhost \
-e "MINIO_SECRET_KEY=password" \ -e PGPORT=5432 \
-v /data/media:/data \ -e PGDATABASE=calendars \
minio/minio server --console-address :9001 /data -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: Wait for CalDAV to be ready
- name: Install Dockerize
run: | 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 timeout 30 bash -c 'until curl -s -o /dev/null http://localhost:80/; do sleep 1; done'
echo "CalDAV server is ready"
- 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"
- name: "Set up Python" - name: "Set up Python"
uses: actions/setup-python@v6 uses: actions/setup-python@v6
@@ -177,8 +137,7 @@ jobs:
- name: Install gettext (required to compile messages) and MIME support - name: Install gettext (required to compile messages) and MIME support
run: | run: |
sudo apt-get update sudo apt-get update
sudo apt-get install -y gettext pandoc shared-mime-info sudo apt-get install -y gettext pandoc shared-mime-info media-types
sudo wget https://svn.apache.org/repos/asf/httpd/httpd/trunk/docs/conf/mime.types -O /etc/mime.types
- name: Generate a MO file from strings extracted from the project - name: Generate a MO file from strings extracted from the project
run: uv run python manage.py compilemessages run: uv run python manage.py compilemessages

View File

@@ -10,7 +10,7 @@ jobs:
install-front: install-front:
uses: ./.github/workflows/front-dependencies-installation.yml uses: ./.github/workflows/front-dependencies-installation.yml
with: with:
node_version: '20.x' node_version: '22.x'
synchronize-with-crowdin: synchronize-with-crowdin:
runs-on: ubuntu-latest runs-on: ubuntu-latest
@@ -23,8 +23,6 @@ jobs:
- name: Create empty source files - name: Create empty source files
run: | run: |
touch src/backend/locale/django.pot 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 # crowdin workflow
- name: crowdin action - name: crowdin action
uses: crowdin/github-action@v2 uses: crowdin/github-action@v2

View File

@@ -10,7 +10,7 @@ jobs:
install-front: install-front:
uses: ./.github/workflows/front-dependencies-installation.yml uses: ./.github/workflows/front-dependencies-installation.yml
with: with:
node_version: '20.x' node_version: '22.x'
synchronize-with-crowdin: synchronize-with-crowdin:
needs: install-front needs: install-front

View File

@@ -24,6 +24,7 @@ jobs:
uses: actions/checkout@v6 uses: actions/checkout@v6
- -
name: Set up QEMU name: Set up QEMU
if: github.event_name != 'pull_request'
uses: docker/setup-qemu-action@v3 uses: docker/setup-qemu-action@v3
- -
name: Set up Docker Buildx name: Set up Docker Buildx
@@ -41,19 +42,13 @@ jobs:
with: with:
username: ${{ secrets.DOCKER_HUB_USER }} username: ${{ secrets.DOCKER_HUB_USER }}
password: ${{ secrets.DOCKER_HUB_PASSWORD }} 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 name: Build and push
uses: docker/build-push-action@v6 uses: docker/build-push-action@v6
with: with:
context: . context: ./src/backend
target: backend-production 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 build-args: DOCKER_USER=${{ env.DOCKER_USER }}:-1000
push: ${{ github.event_name != 'pull_request' }} push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }} tags: ${{ steps.meta.outputs.tags }}
@@ -67,6 +62,7 @@ jobs:
uses: actions/checkout@v6 uses: actions/checkout@v6
- -
name: Set up QEMU name: Set up QEMU
if: github.event_name != 'pull_request'
uses: docker/setup-qemu-action@v3 uses: docker/setup-qemu-action@v3
- -
name: Set up Docker Buildx name: Set up Docker Buildx
@@ -85,34 +81,28 @@ jobs:
username: ${{ secrets.DOCKER_HUB_USER }} username: ${{ secrets.DOCKER_HUB_USER }}
password: ${{ secrets.DOCKER_HUB_PASSWORD }} password: ${{ secrets.DOCKER_HUB_PASSWORD }}
- -
name: Run trivy scan name: Build SSG assets (platform-independent, amd64 only)
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
uses: docker/build-push-action@v6 uses: docker/build-push-action@v6
with: with:
context: . context: ./src/frontend
file: ./src/frontend/Dockerfile target: calendars-builder
target: frontend-production platforms: linux/amd64
platforms: linux/amd64,linux/arm64 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 build-args: DOCKER_USER=${{ env.DOCKER_USER }}:-1000
push: ${{ github.event_name != 'pull_request' }} push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }} tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }} 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 }}"

View File

@@ -5,7 +5,7 @@ on:
inputs: inputs:
node_version: node_version:
required: false required: false
default: '20.x' default: '22.x'
type: string type: string
jobs: jobs:

View File

@@ -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 and this project adheres to
[Semantic Versioning](https://semver.org/spec/v2.0.0.html). [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

View File

@@ -12,13 +12,13 @@
</p> </p>
<p align="center"> <p align="center">
<a href="https://matrix.to/#/#messages-official:matrix.org"> <a href="https://matrix.to/#/#calendars-official:matrix.org">
Chat on Matrix Chat on Matrix
</a> - <a href="/docs/"> </a> - <a href="/docs/">
Documentation Documentation
</a> - <a href="#getting-started-"> </a> - <a href="#getting-started-">
Getting started Getting started
</a> - <a href="contact@suite.anct.gouv.fr"> </a> - <a href="mailto:contact@suite.anct.gouv.fr">
Reach out Reach out
</a> </a>
</p> </p>
@@ -137,7 +137,7 @@ You can then login with sub `admin@example.com` and password `admin`.
## Feedback 🙋‍♂️🙋‍♀️ ## 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 🙌 ## Contributing 🙌
@@ -152,4 +152,4 @@ While Calendars is a public driven initiative our licence choice is an invitatio
## Credits ❤️ ## 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!

View File

@@ -143,10 +143,6 @@ ARG CALENDARS_STATIC_ROOT=/data/static
# Remove git, we don't need it in the production image # Remove git, we don't need it in the production image
RUN apk del git 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 # Un-privileged user running the application
ARG DOCKER_USER ARG DOCKER_USER
USER ${DOCKER_USER} USER ${DOCKER_USER}

View File

@@ -451,7 +451,8 @@ class Base(Configuration):
FRONTEND_FEEDBACK_BUTTON_SHOW = values.BooleanValue( FRONTEND_FEEDBACK_BUTTON_SHOW = values.BooleanValue(
default=False, environ_name="FRONTEND_FEEDBACK_BUTTON_SHOW", environ_prefix=None 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( FRONTEND_FEEDBACK_BUTTON_IDLE = values.BooleanValue(
default=False, environ_name="FRONTEND_FEEDBACK_BUTTON_IDLE", environ_prefix=None default=False, environ_name="FRONTEND_FEEDBACK_BUTTON_IDLE", environ_prefix=None
) )

View File

@@ -2,6 +2,7 @@
import logging import logging
import re import re
from datetime import timezone as dt_timezone
from django.core.signing import BadSignature, Signer from django.core.signing import BadSignature, Signer
from django.shortcuts import render from django.shortcuts import render
@@ -78,7 +79,7 @@ def _is_event_past(icalendar_data):
if dt: if dt:
# Make timezone-aware if naive (assume UTC) # Make timezone-aware if naive (assume UTC)
if dt.tzinfo is None: if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc) dt = dt.replace(tzinfo=dt_timezone.utc)
return dt < timezone.now() return dt < timezone.now()
return False return False
@@ -88,7 +89,7 @@ def _is_event_past(icalendar_data):
class RSVPView(View): class RSVPView(View):
"""Handle RSVP responses from invitation email links.""" """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.""" """Process an RSVP response."""
token = request.GET.get("token", "") token = request.GET.get("token", "")
action = request.GET.get("action", "") action = request.GET.get("action", "")

View File

@@ -10,7 +10,6 @@ from lasuite.oidc_login.backends import (
OIDCAuthenticationBackend as LaSuiteOIDCAuthenticationBackend, OIDCAuthenticationBackend as LaSuiteOIDCAuthenticationBackend,
) )
from core.authentication.exceptions import UserCannotAccessApp
from core.models import DuplicateEmailError from core.models import DuplicateEmailError
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)

View File

@@ -67,7 +67,7 @@ class CalDAVHTTPClient:
url = f"{url}?{query}" url = f"{url}?{query}"
return url return url
def request( # noqa: PLR0913 def request( # noqa: PLR0913 # pylint: disable=too-many-arguments
self, self,
method: str, method: str,
email: str, email: str,
@@ -123,7 +123,7 @@ class CalDAVHTTPClient:
continue continue
logger.warning("Event UID %s not found in user %s calendars", uid, email) logger.warning("Event UID %s not found in user %s calendars", uid, email)
return None, None return None, None
except Exception: except Exception: # pylint: disable=broad-exception-caught
logger.exception("CalDAV error looking up event %s", uid) logger.exception("CalDAV error looking up event %s", uid)
return None, None return None, None
@@ -517,20 +517,18 @@ class CalendarService:
def create_default_calendar(self, user) -> str: def create_default_calendar(self, user) -> str:
"""Create a default calendar for a user. Returns the caldav_path.""" """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()) calendar_id = str(uuid4())
lang = TranslationService.resolve_language(email=user.email) lang = TranslationService.resolve_language(email=user.email)
calendar_name = TranslationService.t( calendar_name = TranslationService.t("calendar.list.defaultCalendarName", lang)
"calendar.list.defaultCalendarName", lang
)
return self.caldav.create_calendar( return self.caldav.create_calendar(
user, calendar_name, calendar_id, color=settings.DEFAULT_CALENDAR_COLOR user, calendar_name, calendar_id, color=settings.DEFAULT_CALENDAR_COLOR
) )
def create_calendar( def create_calendar(self, user, name: str, color: str = "") -> str:
self, user, name: str, color: str = ""
) -> str:
"""Create a new calendar for a user. Returns the caldav_path.""" """Create a new calendar for a user. Returns the caldav_path."""
calendar_id = str(uuid4()) calendar_id = str(uuid4())
return self.caldav.create_calendar( return self.caldav.create_calendar(

View File

@@ -2,7 +2,6 @@
import json import json
import logging import logging
import os
from datetime import datetime from datetime import datetime
from typing import Optional from typing import Optional
@@ -67,7 +66,7 @@ class TranslationService:
return current if isinstance(current, str) else None return current if isinstance(current, str) else None
@classmethod @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. """Look up a translation key with interpolation.
Fallback chain: lang -> "en" -> key itself. Fallback chain: lang -> "en" -> key itself.
@@ -95,7 +94,7 @@ class TranslationService:
- Fallback: "fr". - Fallback: "fr".
""" """
if request is not None: 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, get_language,
) )
@@ -105,7 +104,9 @@ class TranslationService:
if email: if email:
try: 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() user = User.objects.filter(email=email).first()
if user and user.language: if user and user.language:

View File

@@ -2,7 +2,6 @@
import random import random
import re import re
from unittest import mock
from django.core.exceptions import SuspiciousOperation from django.core.exceptions import SuspiciousOperation
from django.test.utils import override_settings 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 import models
from core.authentication.backends import OIDCAuthenticationBackend from core.authentication.backends import OIDCAuthenticationBackend
from core.authentication.exceptions import UserCannotAccessApp
from core.factories import UserFactory from core.factories import UserFactory
pytestmark = pytest.mark.django_db pytestmark = pytest.mark.django_db
@@ -500,7 +498,7 @@ def test_authentication_session_tokens(
status=200, status=200,
) )
with django_assert_num_queries(5): with django_assert_num_queries(6):
user = klass.authenticate( user = klass.authenticate(
request, request,
code="test-code", code="test-code",

View File

@@ -8,7 +8,6 @@ from django.db import connection
import pytest import pytest
import responses import responses
from cryptography.fernet import Fernet
from core import factories from core import factories
from core.tests.utils.urls import reload_urls from core.tests.utils.urls import reload_urls
@@ -19,7 +18,7 @@ VIA = [USER, TEAM]
@pytest.fixture(autouse=True) @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. """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. CalDAV server tables are created by the CalDAV server container migrations, not Django.

View File

@@ -204,10 +204,10 @@ def test_api_users_list_query_email_matching():
client.force_login(user) client.force_login(user)
user1 = factories.UserFactory(email="alice.johnson@example.gouv.fr") user1 = factories.UserFactory(email="alice.johnson@example.gouv.fr")
user2 = factories.UserFactory(email="alice.johnnson@example.gouv.fr") factories.UserFactory(email="alice.johnnson@example.gouv.fr")
user3 = factories.UserFactory(email="alice.kohlson@example.gouv.fr") factories.UserFactory(email="alice.kohlson@example.gouv.fr")
user4 = factories.UserFactory(email="alicia.johnnson@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") factories.UserFactory(email="alice.thomson@example.gouv.fr")
# Exact match returns only that user # Exact match returns only that user

View File

@@ -1,5 +1,7 @@
"""Tests for CalDAV proxy view.""" """Tests for CalDAV proxy view."""
# pylint: disable=no-member
from xml.etree import ElementTree as ET from xml.etree import ElementTree as ET
from django.conf import settings 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 # Verify request was made to CalDAV server
assert len(responses.calls) == 1 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 # Try to send a malicious X-Forwarded-User header as if we were another user
malicious_email = "attacker@example.com" malicious_email = "attacker@example.com"
response = client.generic( client.generic(
"PROPFIND", "PROPFIND",
"/api/v1.0/caldav/", "/api/v1.0/caldav/",
HTTP_X_FORWARDED_USER=malicious_email, HTTP_X_FORWARDED_USER=malicious_email,
@@ -110,7 +112,7 @@ class TestCalDAVProxy:
reason="CalDAV server URL not configured - integration test requires real server", reason="CalDAV server URL not configured - integration test requires real server",
) )
def test_proxy_propfind_response_contains_prefixed_urls(self): 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 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. 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 # Make actual request to CalDAV server through proxy
# The server should use X-Forwarded-Prefix to generate URLs # The server should use X-Forwarded-Prefix to generate URLs
propfind_body = '<?xml version="1.0"?><propfind xmlns="DAV:"><prop><resourcetype/></prop></propfind>' propfind_body = (
'<?xml version="1.0"?>'
'<propfind xmlns="DAV:"><prop><resourcetype/></prop></propfind>'
)
response = client.generic( response = client.generic(
"PROPFIND", "PROPFIND",
"/api/v1.0/caldav/", "/api/v1.0/caldav/",
@@ -131,7 +136,8 @@ class TestCalDAVProxy:
) )
assert response.status_code == HTTP_207_MULTI_STATUS, ( 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 # Parse the response XML
@@ -149,9 +155,10 @@ class TestCalDAVProxy:
href.startswith("/principals/") or href.startswith("/calendars/") href.startswith("/principals/") or href.startswith("/calendars/")
): ):
assert href.startswith("/api/v1.0/caldav/"), ( assert href.startswith("/api/v1.0/caldav/"), (
f"Expected URL to start with /api/v1.0/caldav/, got {href}. " f"Expected URL to start with /api/v1.0/caldav/, "
f"This indicates sabre/dav BaseUriPlugin is not using X-Forwarded-Prefix correctly. " f"got {href}. BaseUriPlugin is not using "
f"Full response: {response.content.decode('utf-8', errors='ignore')}" f"X-Forwarded-Prefix correctly. Full response: "
f"{response.content.decode('utf-8', errors='ignore')}"
) )
@responses.activate @responses.activate
@@ -284,9 +291,7 @@ class TestCalDAVProxy:
) )
# Request a specific path # Request a specific path
response = client.generic( client.generic("PROPFIND", "/api/v1.0/caldav/principals/test@example.com/")
"PROPFIND", "/api/v1.0/caldav/principals/test@example.com/"
)
# Verify the request was made to the correct URL # Verify the request was made to the correct URL
assert len(responses.calls) == 1 assert len(responses.calls) == 1
@@ -333,22 +338,29 @@ class TestValidateCaldavProxyPath:
"""Tests for validate_caldav_proxy_path utility.""" """Tests for validate_caldav_proxy_path utility."""
def test_empty_path_is_valid(self): def test_empty_path_is_valid(self):
"""Empty path should be valid."""
assert validate_caldav_proxy_path("") is True assert validate_caldav_proxy_path("") is True
def test_calendars_path_is_valid(self): 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 assert validate_caldav_proxy_path("calendars/user@ex.com/uuid/") is True
def test_principals_path_is_valid(self): def test_principals_path_is_valid(self):
"""Standard principals path should be valid."""
assert validate_caldav_proxy_path("principals/user@ex.com/") is True assert validate_caldav_proxy_path("principals/user@ex.com/") is True
def test_traversal_is_rejected(self): def test_traversal_is_rejected(self):
"""Directory traversal attempts should be rejected."""
assert validate_caldav_proxy_path("calendars/../../etc/passwd") is False assert validate_caldav_proxy_path("calendars/../../etc/passwd") is False
def test_null_byte_is_rejected(self): def test_null_byte_is_rejected(self):
"""Paths containing null bytes should be rejected."""
assert validate_caldav_proxy_path("calendars/user\x00/") is False assert validate_caldav_proxy_path("calendars/user\x00/") is False
def test_unknown_prefix_is_rejected(self): def test_unknown_prefix_is_rejected(self):
"""Paths without a known prefix should be rejected."""
assert validate_caldav_proxy_path("etc/passwd") is False assert validate_caldav_proxy_path("etc/passwd") is False
def test_leading_slash_calendars_is_valid(self): 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 assert validate_caldav_proxy_path("/calendars/user@ex.com/uuid/") is True

View File

@@ -2,6 +2,7 @@
import http.server import http.server
import logging import logging
import os
import secrets import secrets
import socket import socket
import threading import threading
@@ -118,10 +119,9 @@ class TestCalDAVScheduling:
except OSError as e: except OSError as e:
pytest.fail(f"Test server failed to start on port {port}: {e}") pytest.fail(f"Test server failed to start on port {port}: {e}")
# Use the named test container hostname # In Docker Compose, use the container hostname; on bare host (CI), use localhost
# The test container is created with --name backend-test in bin/pytest callback_host = os.environ.get("CALDAV_CALLBACK_HOST", "backend-test")
# Docker Compose networking allows containers to reach each other by name callback_url = f"http://{callback_host}:{port}/"
callback_url = f"http://backend-test:{port}/"
try: try:
# Create an event with an attendee # Create an event with an attendee

View File

@@ -1,7 +1,5 @@
"""Tests for CalDAV service integration.""" """Tests for CalDAV service integration."""
from unittest.mock import Mock, patch
from django.conf import settings from django.conf import settings
import pytest import pytest
@@ -19,7 +17,7 @@ class TestCalDAVClient:
user = factories.UserFactory(email="test@example.com") user = factories.UserFactory(email="test@example.com")
client = CalDAVClient() client = CalDAVClient()
dav_client = client._get_client(user) dav_client = client._get_client(user) # pylint: disable=protected-access
# Verify the client is configured correctly # Verify the client is configured correctly
# Username and password should be None to prevent Basic auth # Username and password should be None to prevent Basic auth
@@ -78,9 +76,7 @@ class TestCalDAVClient:
color = "#e74c3c" color = "#e74c3c"
# Create a calendar with a specific color # Create a calendar with a specific color
caldav_path = service.create_calendar( caldav_path = service.create_calendar(user, name="Red Calendar", color=color)
user, name="Red Calendar", color=color
)
# Fetch the calendar info and verify the color was persisted # Fetch the calendar info and verify the color was persisted
info = service.caldav.get_calendar_info(user, caldav_path) info = service.caldav.get_calendar_info(user, caldav_path)

View File

@@ -1,5 +1,7 @@
"""Tests for ICalendarParser and email template rendering.""" """Tests for ICalendarParser and email template rendering."""
# pylint: disable=missing-function-docstring,protected-access
from django.template.loader import render_to_string from django.template.loader import render_to_string
import pytest import pytest

View File

@@ -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 rest_framework.test import APIClient
from core import factories from core import factories
from core.models import CalendarSubscriptionToken
@pytest.mark.django_db @pytest.mark.django_db

View File

@@ -8,7 +8,7 @@ from django.core.exceptions import ValidationError
import pytest import pytest
from core import factories, models from core import factories
pytestmark = pytest.mark.django_db pytestmark = pytest.mark.django_db

View File

@@ -1,5 +1,7 @@
"""Tests for RSVP view and token generation.""" """Tests for RSVP view and token generation."""
# pylint: disable=missing-function-docstring,protected-access
import re import re
from datetime import timedelta from datetime import timedelta
from unittest.mock import patch from unittest.mock import patch

View File

@@ -5,7 +5,7 @@ from datetime import datetime
from core.services.translation_service import TranslationService from core.services.translation_service import TranslationService
class TestTranslationServiceLookup: class TestTranslationServiceLookup: # pylint: disable=missing-function-docstring
"""Tests for key lookup and interpolation.""" """Tests for key lookup and interpolation."""
def test_lookup_french_key(self): def test_lookup_french_key(self):
@@ -43,7 +43,7 @@ class TestTranslationServiceLookup:
assert "passé" in value assert "passé" in value
class TestNormalizeLang: class TestNormalizeLang: # pylint: disable=missing-function-docstring
"""Tests for language normalization.""" """Tests for language normalization."""
def test_normalize_fr_fr(self): def test_normalize_fr_fr(self):
@@ -69,6 +69,7 @@ class TestFormatDate:
"""Tests for date formatting.""" """Tests for date formatting."""
def test_format_date_french(self): def test_format_date_french(self):
"""Format date in French locale."""
dt = datetime(2026, 1, 23, 10, 0) # Friday dt = datetime(2026, 1, 23, 10, 0) # Friday
result = TranslationService.format_date(dt, "fr") result = TranslationService.format_date(dt, "fr")
assert "vendredi" in result assert "vendredi" in result
@@ -77,6 +78,7 @@ class TestFormatDate:
assert "2026" in result assert "2026" in result
def test_format_date_english(self): def test_format_date_english(self):
"""Format date in English locale."""
dt = datetime(2026, 1, 23, 10, 0) # Friday dt = datetime(2026, 1, 23, 10, 0) # Friday
result = TranslationService.format_date(dt, "en") result = TranslationService.format_date(dt, "en")
assert "Friday" in result assert "Friday" in result
@@ -85,6 +87,7 @@ class TestFormatDate:
assert "2026" in result assert "2026" in result
def test_format_date_dutch(self): def test_format_date_dutch(self):
"""Format date in Dutch locale."""
dt = datetime(2026, 1, 23, 10, 0) # Friday dt = datetime(2026, 1, 23, 10, 0) # Friday
result = TranslationService.format_date(dt, "nl") result = TranslationService.format_date(dt, "nl")
assert "vrijdag" in result assert "vrijdag" in result

View File

@@ -1,4 +1,6 @@
# Gunicorn-django settings """Gunicorn configuration for the Calendars backend."""
# pylint: disable=invalid-name
bind = ["0.0.0.0:8000"] bind = ["0.0.0.0:8000"]
name = "calendars" name = "calendars"
python_path = "/app" python_path = "/app"

View File

@@ -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;"]

View File

@@ -196,7 +196,7 @@ export const useSchedulerInit = ({
} }
// Only fetch source events if we actually need recurrence rules // Only fetch source events if we actually need recurrence rules
let sourceRulesByUid = new Map<string, unknown>(); const sourceRulesByUid = new Map<string, unknown>();
if (uidsNeedingRules.size > 0) { if (uidsNeedingRules.size > 0) {
const sourceResult = await caldavService.fetchEvents( const sourceResult = await caldavService.fetchEvents(
calendar.url, { timeRange, expand: false } calendar.url, { timeRange, expand: false }

View File

@@ -1126,7 +1126,7 @@ END:VCALENDAR`
scheduleOutboxUrl: string | null scheduleOutboxUrl: string | null
scheduleInboxUrl: string | null scheduleInboxUrl: string | null
calendarUserAddressSet: string[] calendarUserAddressSet: string[]
rawResponse?: any rawResponse?: unknown
}>> { }>> {
if (!this._account?.principalUrl) { if (!this._account?.principalUrl) {
return { success: false, error: 'Not connected or principal URL not found' } return { success: false, error: 'Not connected or principal URL not found' }

View File

@@ -0,0 +1,4 @@
#!/bin/sh
set -e
exec "$@"