The test:coverage step runs the full suite under Istanbul; running `npm test` first executes every test twice for no extra signal. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
253 lines
11 KiB
YAML
253 lines
11 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: Run unit and component tests with coverage
|
|
run: npm run test:coverage
|
|
working-directory: frontend
|
|
env:
|
|
TZ: Europe/Berlin
|
|
|
|
- name: Upload coverage reports
|
|
if: always()
|
|
uses: actions/upload-artifact@v4
|
|
with:
|
|
name: coverage-reports
|
|
path: frontend/coverage/
|
|
|
|
- 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
|
|
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 |