Some checks failed
Docker Compose interpolates all variables in the full file even when only a subset of services is requested. The backend service uses IMPORT_HOST_DIR with :? (hard-required), causing the idempotency job to abort before any container starts. A dummy path satisfies the parser; the backend service is never started in this job so the path need not exist. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
313 lines
14 KiB
YAML
313 lines
14 KiB
YAML
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(<arg>, 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: Assert no (upload|download)-artifact past v3
|
|
shell: bash
|
|
run: |
|
|
# Self-test: verify the regex catches v4+ and does not catch v3.
|
|
tmp=$(mktemp)
|
|
printf ' uses: actions/upload-artifact@v5\n' > "$tmp"
|
|
grep -qP '^\s+uses:\s+actions/(upload|download)-artifact@v[4-9]' "$tmp" \
|
|
|| { echo "FAIL: guard self-test — regex missed upload-artifact@v5"; rm "$tmp"; exit 1; }
|
|
printf ' uses: actions/upload-artifact@v3\n' > "$tmp"
|
|
grep -qvP '^\s+uses:\s+actions/(upload|download)-artifact@v[4-9]' "$tmp" \
|
|
|| { echo "FAIL: guard self-test — regex incorrectly flagged upload-artifact@v3"; rm "$tmp"; exit 1; }
|
|
rm "$tmp"
|
|
# Guard: Gitea Actions (act_runner) does not implement the v4 artifact protocol.
|
|
# Both upload-artifact and download-artifact share the same incompatibility.
|
|
# Pin to @v3. See ADR-014 / #557.
|
|
if grep -RPn '^\s+uses:\s+actions/(upload|download)-artifact@v[4-9]' .gitea/workflows/; then
|
|
echo "::error::actions/(upload|download)-artifact@v4+ is unsupported on Gitea Actions (act_runner). Pin to @v3. See ADR-014 / #557."
|
|
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
|
|
|
|
# Gitea Actions (act_runner) does not implement upload-artifact v4 protocol — pinned per ADR-014. Do NOT upgrade. See #557.
|
|
- name: Upload coverage reports
|
|
if: always()
|
|
uses: actions/upload-artifact@v3
|
|
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."
|
|
|
|
# Gitea Actions (act_runner) does not implement upload-artifact v4 protocol — pinned per ADR-014. Do NOT upgrade. See #557.
|
|
- name: Upload screenshots
|
|
if: always()
|
|
uses: actions/upload-artifact@v3
|
|
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 |