name: CI on: push: pull_request: jobs: # ─── Unit & Browser Component Tests ────────────────────────────────────────── # Runs inside the official Playwright Docker image — Chromium and all system # deps are pre-installed, so no install or cache step is needed for the browser. unit-tests: name: Unit & Component Tests runs-on: ubuntu-latest container: image: mcr.microsoft.com/playwright:v1.58.2-noble steps: - uses: actions/checkout@v4 - name: Cache node_modules id: node-modules-cache uses: actions/cache@v4 with: path: frontend/node_modules key: node-modules-${{ hashFiles('frontend/package-lock.json') }} - name: Install dependencies if: steps.node-modules-cache.outputs.cache-hit != 'true' run: npm ci working-directory: frontend - name: Compile Paraglide i18n run: npx @inlang/paraglide-js compile --project ./project.inlang --outdir ./src/lib/paraglide working-directory: frontend - name: Lint run: npm run lint working-directory: frontend - name: Assert no banned vi.mock patterns shell: bash run: | # Literal pdfjs-dist (libLoader pattern — ADR 012) if grep -rF "vi.mock('pdfjs-dist'" frontend/src/; then echo "FAIL: banned vi.mock('pdfjs-dist') pattern found — see ADR 012. Use the libLoader prop injection pattern instead." exit 1 fi # Async factory with dynamic import in body (named mechanism — ADR 012 / #553). # Multiline PCRE matches `vi.mock(, async ... { ... await import(...) ... })` # across line breaks. __meta__ is excluded because it contains fixture strings # demonstrating the very pattern this check is meant to forbid. if grep -rPzln 'vi\.mock\([^)]+,\s*async[^{]*\{[\s\S]*?await\s+import\s*\(' \ --include='*.spec.ts' --include='*.test.ts' \ --exclude-dir='__meta__' \ frontend/src/; then echo "FAIL: banned async vi.mock factory with dynamic import in body — see ADR 012 / #553. Use a synchronous factory + vi.hoisted instead." exit 1 fi - name: Run unit and component tests with coverage shell: bash run: | set -eo pipefail npm run test:coverage 2>&1 | tee /tmp/coverage-test-${{ github.run_id }}.log working-directory: frontend env: TZ: Europe/Berlin # Diagnostic guard: covers the coverage run only. If `npm test` (above) # exits 1 with a birpc error, the named pattern appears here — not there. - name: Assert no birpc teardown race in coverage run shell: bash if: always() run: | if grep -qF "[birpc] rpc is closed" /tmp/coverage-test-${{ github.run_id }}.log 2>/dev/null; then echo "FAIL: [birpc] rpc is closed teardown race detected in coverage run" grep -F "[birpc] rpc is closed" /tmp/coverage-test-${{ github.run_id }}.log exit 1 fi - name: Upload coverage reports if: always() uses: actions/upload-artifact@v4 with: name: coverage-reports path: | frontend/coverage/ /tmp/coverage-test-${{ github.run_id }}.log - name: Build frontend run: npm run build working-directory: frontend # ── Prerender output is exactly the public help page ─────────────────── # SvelteKit prerender + crawl follows nav links and bakes "redirect to # /login" HTML for every protected route, served BEFORE runtime hooks # (see #514). With `crawl: false` only the explicit entry should land # in build/prerendered/. Anything else is a regression — fail the build. - name: Assert prerender output is only /hilfe/transkription run: | cd frontend set -e extra=$(find build/prerendered -type f \ -not -path 'build/prerendered/hilfe/*' \ -not -name '*.br' -not -name '*.gz' \ || true) if [ -n "$extra" ]; then echo "FAIL: unexpected prerendered files (would shadow runtime hooks):" echo "$extra" exit 1 fi # And the help page must still be there. test -f build/prerendered/hilfe/transkription.html \ || { echo "FAIL: /hilfe/transkription.html missing from prerender output"; exit 1; } echo "PASS: only /hilfe/transkription.html prerendered." - name: Upload screenshots if: always() uses: actions/upload-artifact@v4 with: name: unit-test-screenshots path: frontend/test-results/screenshots/ # ─── OCR Service Unit Tests ─────────────────────────────────────────────────── # Only spell_check.py, test_confidence.py, test_sender_registry.py — no ML stack required. ocr-tests: name: OCR Service Tests runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-python@v5 with: python-version: '3.11' - name: Install test dependencies run: pip install "pyspellchecker==0.9.0" pytest pytest-asyncio working-directory: ocr-service - name: Run OCR unit tests (no ML stack required) run: python -m pytest test_spell_check.py test_confidence.py test_sender_registry.py -v working-directory: ocr-service # ─── Backend Unit & Slice Tests ─────────────────────────────────────────────── # Pure Mockito + WebMvcTest — no DB or S3 needed. backend-unit-tests: name: Backend Unit Tests runs-on: ubuntu-latest env: DOCKER_API_VERSION: "1.43" # NAS runner runs Docker 24.x (max API 1.43); Testcontainers 2.x defaults to 1.44 DOCKER_HOST: unix:///var/run/docker.sock TESTCONTAINERS_RYUK_DISABLED: "true" steps: - uses: actions/checkout@v4 - uses: actions/setup-java@v4 with: java-version: '21' distribution: temurin - name: Cache Maven repository uses: actions/cache@v4 with: path: ~/.m2/repository key: maven-${{ hashFiles('backend/pom.xml') }} restore-keys: maven- - name: Run backend tests run: | chmod +x mvnw ./mvnw clean test working-directory: backend # ─── fail2ban Regex Regression ──────────────────────────────────────────────── # The filter parses Caddy's JSON access log; a Caddy upgrade that reorders # the JSON keys would silently break it (fail2ban-regex would return # "0 matches", fail2ban would stop banning, no error surface). This job # pins the contract against a deterministic sample line. fail2ban-regex: name: fail2ban Regex runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Install fail2ban run: | sudo apt-get update sudo apt-get install -y fail2ban - name: Matches /api/auth/login 401 run: | echo '{"level":"info","ts":1700000000.12,"logger":"http.log.access","msg":"handled request","request":{"remote_ip":"203.0.113.42","method":"POST","host":"archiv.raddatz.cloud","uri":"/api/auth/login"},"status":401}' > /tmp/sample.log out=$(fail2ban-regex /tmp/sample.log infra/fail2ban/filter.d/familienarchiv-auth.conf) echo "$out" echo "$out" | grep -qE '1 matched' \ || { echo "expected 1 match for /api/auth/login 401"; exit 1; } - name: Matches /api/auth/login 429 run: | echo '{"level":"info","ts":1700000000.12,"logger":"http.log.access","msg":"handled request","request":{"remote_ip":"203.0.113.42","method":"POST","host":"archiv.raddatz.cloud","uri":"/api/auth/login"},"status":429}' > /tmp/sample.log out=$(fail2ban-regex /tmp/sample.log infra/fail2ban/filter.d/familienarchiv-auth.conf) echo "$out" echo "$out" | grep -qE '1 matched' \ || { echo "expected 1 match for /api/auth/login 429"; exit 1; } - name: Matches /api/auth/forgot-password 401 run: | echo '{"level":"info","ts":1700000000.12,"logger":"http.log.access","msg":"handled request","request":{"remote_ip":"203.0.113.42","method":"POST","host":"archiv.raddatz.cloud","uri":"/api/auth/forgot-password"},"status":401}' > /tmp/sample.log out=$(fail2ban-regex /tmp/sample.log infra/fail2ban/filter.d/familienarchiv-auth.conf) echo "$out" echo "$out" | grep -qE '1 matched' \ || { echo "expected 1 match for /api/auth/forgot-password 401"; exit 1; } - name: Does not match /api/auth/login 200 run: | echo '{"level":"info","ts":1700000000.12,"logger":"http.log.access","msg":"handled request","request":{"remote_ip":"203.0.113.42","method":"POST","host":"archiv.raddatz.cloud","uri":"/api/auth/login"},"status":200}' > /tmp/sample.log out=$(fail2ban-regex /tmp/sample.log infra/fail2ban/filter.d/familienarchiv-auth.conf) echo "$out" echo "$out" | grep -qE '0 matched' \ || { echo "expected 0 matches for /api/auth/login 200"; exit 1; } - name: Does not match /api/documents (unrelated 401) run: | echo '{"level":"info","ts":1700000000.12,"logger":"http.log.access","msg":"handled request","request":{"remote_ip":"203.0.113.42","method":"GET","host":"archiv.raddatz.cloud","uri":"/api/documents"},"status":401}' > /tmp/sample.log out=$(fail2ban-regex /tmp/sample.log infra/fail2ban/filter.d/familienarchiv-auth.conf) echo "$out" echo "$out" | grep -qE '0 matched' \ || { echo "expected 0 matches for /api/documents 401"; exit 1; } # ── Backend resolves to file-polling, not systemd ───────────────────── # The Debian/Ubuntu fail2ban package ships defaults-debian.conf with # `[DEFAULT] backend = systemd`. Without `backend = polling` in our # jail, the daemon loads the jail but reads from journald and never # touches /var/log/caddy/access.log — i.e. the regex above passes in # isolation while the live jail is inert. See issue #503. - name: Jail resolves with polling backend (not inherited systemd) run: | sudo ln -sfn "$PWD/infra/fail2ban/jail.d/familienarchiv.conf" /etc/fail2ban/jail.d/familienarchiv.conf sudo ln -sfn "$PWD/infra/fail2ban/filter.d/familienarchiv-auth.conf" /etc/fail2ban/filter.d/familienarchiv-auth.conf dump=$(sudo fail2ban-client -d 2>&1) echo "$dump" | grep -E "add.*familienarchiv-auth" || true echo "$dump" | grep -qE "\['add', 'familienarchiv-auth', 'polling'\]" \ || { echo "FAIL: familienarchiv-auth jail did not resolve to 'polling' backend"; exit 1; } # ─── Compose Bucket-Bootstrap Idempotency ───────────────────────────────────── # docker-compose.prod.yml's create-buckets service runs on every # `docker compose up` (one-shot, no restart). Must be idempotent — a # re-deploy must not fail just because the bucket / user / policy # already exists. Validated by running create-buckets twice against a # throwaway minio stack and asserting both invocations exit 0. compose-idempotency: name: Compose Bucket Idempotency runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Write stub env file run: | cat > .env.test <<'EOF' TAG=test PORT_BACKEND=18080 PORT_FRONTEND=13000 APP_DOMAIN=localhost POSTGRES_PASSWORD=stub MINIO_PASSWORD=stubrootpassword MINIO_APP_PASSWORD=stubapppassword OCR_TRAINING_TOKEN=stub APP_ADMIN_USERNAME=admin@local APP_ADMIN_PASSWORD=stub MAIL_HOST=mailpit MAIL_PORT=1025 APP_MAIL_FROM=noreply@local IMPORT_HOST_DIR=/tmp/dummy-import EOF - name: Bring up minio run: | docker compose -f docker-compose.prod.yml -p test-idem --env-file .env.test up -d --wait minio - name: First create-buckets run run: | docker compose -f docker-compose.prod.yml -p test-idem --env-file .env.test run --rm create-buckets - name: Second create-buckets run (idempotency check) run: | docker compose -f docker-compose.prod.yml -p test-idem --env-file .env.test run --rm create-buckets - name: Teardown if: always() run: | docker compose -f docker-compose.prod.yml -p test-idem --env-file .env.test down -v rm -f .env.test