Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c7f06b35fa |
@@ -98,17 +98,6 @@ jobs:
|
|||||||
[ -n "${GITHUB_STEP_SUMMARY:-}" ] || exit 0
|
[ -n "${GITHUB_STEP_SUMMARY:-}" ] || exit 0
|
||||||
python3 infra/trx-summary.py TestResults >> "$GITHUB_STEP_SUMMARY"
|
python3 infra/trx-summary.py TestResults >> "$GITHUB_STEP_SUMMARY"
|
||||||
|
|
||||||
# The docs site must build with --strict (#173). setup-python so `make docs` can
|
|
||||||
# create its venv regardless of what the runner image ships.
|
|
||||||
docs:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: https://github.com/actions/checkout@v4
|
|
||||||
- uses: https://github.com/actions/setup-python@v5
|
|
||||||
with:
|
|
||||||
python-version: '3.12'
|
|
||||||
- run: make docs
|
|
||||||
|
|
||||||
# Frontend (Nx/Angular) lane: install with pnpm, then Nx lint + test + build.
|
# Frontend (Nx/Angular) lane: install with pnpm, then Nx lint + test + build.
|
||||||
frontend:
|
frontend:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
@@ -218,14 +207,8 @@ jobs:
|
|||||||
# dispatched (gitea-actions-gotchas.md §7). Default `if: success()` dispatches normally. Cost: a
|
# dispatched (gitea-actions-gotchas.md §7). Default `if: success()` dispatches normally. Cost: a
|
||||||
# failing mutation ratchet now skips verify-stack instead of running it anyway; the fix-and-re-push
|
# failing mutation ratchet now skips verify-stack instead of running it anyway; the fix-and-re-push
|
||||||
# re-run exercises verify-stack, so we still get the signal.
|
# re-run exercises verify-stack, so we still get the signal.
|
||||||
#
|
|
||||||
# Main only, not on PRs: the runner shares the lab node with the deployed stack, and a second
|
|
||||||
# full stack per PR was what got the runner OOM-killed (#182). PRs still gate on every job above;
|
|
||||||
# the live-stack check runs once per merge. A plain event `if` keeps the implicit success(), so it
|
|
||||||
# is not the status-function case from gotchas §7.
|
|
||||||
verify-stack:
|
verify-stack:
|
||||||
needs: [mutation]
|
needs: [mutation]
|
||||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: https://github.com/actions/checkout@v4
|
- uses: https://github.com/actions/checkout@v4
|
||||||
@@ -248,9 +231,6 @@ jobs:
|
|||||||
- name: RegisterRecord objecttype registered + published
|
- name: RegisterRecord objecttype registered + published
|
||||||
id: registerrecord
|
id: registerrecord
|
||||||
run: REGISTERRECORD_TIMEOUT=120 make verify-registerrecord
|
run: REGISTERRECORD_TIMEOUT=120 make verify-registerrecord
|
||||||
- name: ClamAV scans a stream (EICAR found, clean OK)
|
|
||||||
id: clamav
|
|
||||||
run: CLAMAV_TIMEOUT=120 make verify-clamav
|
|
||||||
- name: ACL ↔ OpenZaak integration tests
|
- name: ACL ↔ OpenZaak integration tests
|
||||||
id: acl
|
id: acl
|
||||||
run: make verify-acl
|
run: make verify-acl
|
||||||
@@ -290,7 +270,6 @@ jobs:
|
|||||||
OBJECTEN: ${{ steps.objecten.outcome }}
|
OBJECTEN: ${{ steps.objecten.outcome }}
|
||||||
REGISTERRECORD: ${{ steps.registerrecord.outcome }}
|
REGISTERRECORD: ${{ steps.registerrecord.outcome }}
|
||||||
OBJECTEN_NOTIFICATIONS: ${{ steps.objecten_nrc.outcome }}
|
OBJECTEN_NOTIFICATIONS: ${{ steps.objecten_nrc.outcome }}
|
||||||
CLAMAV: ${{ steps.clamav.outcome }}
|
|
||||||
ACL: ${{ steps.acl.outcome }}
|
ACL: ${{ steps.acl.outcome }}
|
||||||
NRC: ${{ steps.nrc.outcome }}
|
NRC: ${{ steps.nrc.outcome }}
|
||||||
PROJECTION: ${{ steps.projection.outcome }}
|
PROJECTION: ${{ steps.projection.outcome }}
|
||||||
@@ -313,7 +292,6 @@ jobs:
|
|||||||
echo "| Objecten API + token | $(icon "$OBJECTEN") |"
|
echo "| Objecten API + token | $(icon "$OBJECTEN") |"
|
||||||
echo "| RegisterRecord objecttype | $(icon "$REGISTERRECORD") |"
|
echo "| RegisterRecord objecttype | $(icon "$REGISTERRECORD") |"
|
||||||
echo "| Objecten → NRC | $(icon "$OBJECTEN_NOTIFICATIONS") |"
|
echo "| Objecten → NRC | $(icon "$OBJECTEN_NOTIFICATIONS") |"
|
||||||
echo "| ClamAV INSTREAM scan | $(icon "$CLAMAV") |"
|
|
||||||
echo "| ACL ↔ OpenZaak | $(icon "$ACL") |"
|
echo "| ACL ↔ OpenZaak | $(icon "$ACL") |"
|
||||||
echo "| OpenZaak → NRC | $(icon "$NRC") |"
|
echo "| OpenZaak → NRC | $(icon "$NRC") |"
|
||||||
echo "| NRC → Event Subscriber → projection | $(icon "$PROJECTION") |"
|
echo "| NRC → Event Subscriber → projection | $(icon "$PROJECTION") |"
|
||||||
@@ -333,7 +311,7 @@ jobs:
|
|||||||
# Log dump must precede teardown (which removes the containers).
|
# Log dump must precede teardown (which removes the containers).
|
||||||
- name: Dump container logs on failure
|
- name: Dump container logs on failure
|
||||||
if: failure()
|
if: failure()
|
||||||
run: docker compose -f infra/docker-compose.yml logs --no-color --tail=100 oz-init openzaak nrc-init nrc-web nrc-celery nrc-beat flowable-db flowable-rest flowable-init keycloak acl bff domain projection-db event-subscriber projection-api self-service openbaar behandel beheer objecttypen-db objecttypen-redis objecttypen-init objecttypen objecten-db objecten-redis objecten-init objecten objecten-celery registerrecord-init clamav tempo prometheus grafana 2>&1 || true
|
run: docker compose -f infra/docker-compose.yml logs --no-color --tail=100 oz-init openzaak nrc-init nrc-web nrc-celery nrc-beat flowable-db flowable-rest flowable-init keycloak acl bff domain projection-db event-subscriber projection-api self-service openbaar behandel beheer objecttypen-db objecttypen-redis objecttypen-init objecttypen objecten-db objecten-redis objecten-init objecten objecten-celery registerrecord-init tempo prometheus grafana 2>&1 || true
|
||||||
- name: Tear down
|
- name: Tear down
|
||||||
if: always()
|
if: always()
|
||||||
run: make down
|
run: make down
|
||||||
|
|||||||
@@ -31,12 +31,6 @@ jobs:
|
|||||||
# origin, e.g. https://big-auth.labs.respellion.tech (runbook, "Publishing
|
# origin, e.g. https://big-auth.labs.respellion.tech (runbook, "Publishing
|
||||||
# through the labs Caddy").
|
# through the labs Caddy").
|
||||||
KEYCLOAK_URL: ${{ vars.KEYCLOAK_URL }}
|
KEYCLOAK_URL: ${{ vars.KEYCLOAK_URL }}
|
||||||
# `true` fills in the medewerker OTP step for the public demo (chart value
|
|
||||||
# demo.otpAutofill). The fixture secret is committed: demo only.
|
|
||||||
OTP_AUTOFILL: ${{ vars.OTP_AUTOFILL }}
|
|
||||||
# Tempo for the services' traces, e.g. http://tempo.monitoring.svc:4317 (the
|
|
||||||
# cluster monitoring stack, Infra repo). Empty = the chart default.
|
|
||||||
OTEL_ENDPOINT: ${{ vars.OTEL_ENDPOINT }}
|
|
||||||
steps:
|
steps:
|
||||||
- uses: https://github.com/actions/checkout@v4
|
- uses: https://github.com/actions/checkout@v4
|
||||||
|
|
||||||
@@ -103,7 +97,7 @@ jobs:
|
|||||||
make k8s-reseed \
|
make k8s-reseed \
|
||||||
TALOS_HOST=${TALOS_HOST:-localhost} \
|
TALOS_HOST=${TALOS_HOST:-localhost} \
|
||||||
K8S_REGISTRY=${TALOS_VM_IP:-192.168.122.173}:30500 \
|
K8S_REGISTRY=${TALOS_VM_IP:-192.168.122.173}:30500 \
|
||||||
K8S_SET="${KEYCLOAK_URL:+--set keycloakUrl=$KEYCLOAK_URL} --set demo.otpAutofill=${OTP_AUTOFILL:-false}${OTEL_ENDPOINT:+ --set otelEndpoint=$OTEL_ENDPOINT}"
|
K8S_SET="${KEYCLOAK_URL:+--set keycloakUrl=$KEYCLOAK_URL}"
|
||||||
|
|
||||||
# `dev` is a mutable tag and helm sees an unchanged pod template, so the
|
# `dev` is a mutable tag and helm sees an unchanged pod template, so the
|
||||||
# new images only land on a restart (pullPolicy is already Always).
|
# new images only land on a restart (pullPolicy is already Always).
|
||||||
|
|||||||
@@ -61,7 +61,3 @@ __pycache__/
|
|||||||
TestResults/
|
TestResults/
|
||||||
test-output/
|
test-output/
|
||||||
tests/e2e/playwright-report.json
|
tests/e2e/playwright-report.json
|
||||||
|
|
||||||
# MkDocs build (`make docs`)
|
|
||||||
.venv-docs/
|
|
||||||
site/
|
|
||||||
|
|||||||
@@ -334,14 +334,6 @@ Split into independently deployable sub-slices (CLAUDE.md §13):
|
|||||||
|
|
||||||
**Outcome:** All runbooks complete: startup, seed, common failures, upgrade upstream modules, restore from backup, rotate secrets, Gitea Actions gotchas.
|
**Outcome:** All runbooks complete: startup, seed, common failures, upgrade upstream modules, restore from backup, rotate secrets, Gitea Actions gotchas.
|
||||||
|
|
||||||
|
|
||||||
### S-28 · ClamAV (clamd) runs in compose and on the cluster — #191
|
|
||||||
|
|
||||||
**Outcome:** a clamd service with current signatures runs alongside the stack (compose + Helm), health-gated, with a `verify-clamav` check (EICAR → FOUND). ADR-0036 (#190).
|
|
||||||
|
|
||||||
### S-29 · Uploaded diplomas are virus-scanned and PDF-checked before storage — #192
|
|
||||||
|
|
||||||
**Outcome:** the domain stores a diploma only when it starts with `%PDF-` and clamd scans it clean; infected → 422 "infected", non-PDF → 422 "not-a-pdf", scanner down → 503. The portal explains each one.
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## How to add a new slice
|
## How to add a new slice
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ COMPOSE := infra/docker-compose.yml
|
|||||||
# Long-running services with a healthcheck — the smoke polls these for readiness
|
# Long-running services with a healthcheck — the smoke polls these for readiness
|
||||||
# (infra/wait-healthy.sh). One-shot init jobs (oz-init, nrc-init, flowable-init)
|
# (infra/wait-healthy.sh). One-shot init jobs (oz-init, nrc-init, flowable-init)
|
||||||
# are not polled; they only need to have run. See docs/runbooks/gitea-actions-gotchas.md.
|
# are not polled; they only need to have run. See docs/runbooks/gitea-actions-gotchas.md.
|
||||||
WAIT_SVCS := openzaak nrc-web acl bff domain event-subscriber projection-api self-service openbaar behandel beheer objecttypen objecten clamav
|
WAIT_SVCS := openzaak nrc-web acl bff domain event-subscriber projection-api self-service openbaar behandel beheer objecttypen objecten
|
||||||
# Config files (OpenZaak data.yaml, Keycloak realms, Flowable BPMN) are streamed
|
# Config files (OpenZaak data.yaml, Keycloak realms, Flowable BPMN) are streamed
|
||||||
# into external named volumes via `docker cp` (infra/seed-config.sh) instead of
|
# into external named volumes via `docker cp` (infra/seed-config.sh) instead of
|
||||||
# bind-mounted, because bind mounts don't reach sibling containers on the
|
# bind-mounted, because bind mounts don't reach sibling containers on the
|
||||||
@@ -43,11 +43,11 @@ export DOCKER_HOST := unix://$(PODMAN_SOCK)
|
|||||||
endif
|
endif
|
||||||
endif
|
endif
|
||||||
|
|
||||||
.PHONY: ci lint build unit mutation frontend docs integration verify verify-up verify-acl verify-nrc verify-projection verify-bff verify-domain verify-observability verify-tracing verify-metrics verify-objecttypen verify-objecten verify-registerrecord verify-objecten-notifications verify-clamav verify-notifications smoke up down local verify-local local-down changelog openzaak-up openzaak-smoke openzaak-seed openzaak-down stack-up stack-smoke stack-down keycloak-up keycloak-smoke keycloak-down flowable-up flowable-smoke flowable-down k8s-lint k8s-drift k8s-registry k8s-images k8s-seed k8s-up k8s-reseed k8s-portals k8s-down k8s-purge help
|
.PHONY: ci lint build unit mutation frontend integration verify verify-up verify-acl verify-nrc verify-projection verify-bff verify-domain verify-observability verify-tracing verify-metrics verify-objecttypen verify-objecten verify-registerrecord verify-objecten-notifications verify-notifications smoke up down local verify-local local-down changelog openzaak-up openzaak-smoke openzaak-seed openzaak-down stack-up stack-smoke stack-down keycloak-up keycloak-smoke keycloak-down flowable-up flowable-smoke flowable-down k8s-lint k8s-drift k8s-registry k8s-images k8s-seed k8s-up k8s-reseed k8s-portals k8s-down k8s-purge help
|
||||||
|
|
||||||
## ci: run the full pipeline — lint, build, unit, mutation, frontend, verify (mirrors Gitea Actions)
|
## ci: run the full pipeline — lint, build, unit, mutation, frontend, verify (mirrors Gitea Actions)
|
||||||
## `verify` is the live-stack stage (full stack up once → ACL + notification checks).
|
## `verify` is the live-stack stage (full stack up once → ACL + notification checks).
|
||||||
ci: lint build unit mutation frontend docs verify
|
ci: lint build unit mutation frontend verify
|
||||||
|
|
||||||
## frontend: install deps and run the Nx lint/test/build for the portals (pnpm + Node required)
|
## frontend: install deps and run the Nx lint/test/build for the portals (pnpm + Node required)
|
||||||
# Tests run in their own phase, ahead of the build. The @angular/build:unit-test
|
# Tests run in their own phase, ahead of the build. The @angular/build:unit-test
|
||||||
@@ -81,15 +81,6 @@ unit:
|
|||||||
python3 infra/test_playwright_summary.py
|
python3 infra/test_playwright_summary.py
|
||||||
python3 infra/test_portal_caddyfiles.py
|
python3 infra/test_portal_caddyfiles.py
|
||||||
|
|
||||||
## docs: build the MkDocs site with --strict (a broken link or nav entry fails)
|
|
||||||
# Pinned in a throwaway venv: Material 9.7 is the last line on MkDocs 1.x, and MkDocs
|
|
||||||
# 2.0 drops the plugin/theme system this site relies on. Publishing is a separate
|
|
||||||
# decision (#173); this only proves the site builds.
|
|
||||||
docs:
|
|
||||||
python3 -m venv .venv-docs
|
|
||||||
.venv-docs/bin/pip install --quiet mkdocs==1.6.1 mkdocs-material==9.7.7
|
|
||||||
.venv-docs/bin/mkdocs build --strict
|
|
||||||
|
|
||||||
## mutation: run the Stryker.NET ratchet on each service with branching logic (fails below baseline)
|
## mutation: run the Stryker.NET ratchet on each service with branching logic (fails below baseline)
|
||||||
# Stryker is pinned as a local dotnet tool (.config/dotnet-tools.json); `tool restore`
|
# Stryker is pinned as a local dotnet tool (.config/dotnet-tools.json); `tool restore`
|
||||||
# makes `make mutation` work from a fresh clone. Each service owns its config + break
|
# makes `make mutation` work from a fresh clone. Each service owns its config + break
|
||||||
@@ -222,11 +213,6 @@ verify-registerrecord:
|
|||||||
verify-objecten-notifications:
|
verify-objecten-notifications:
|
||||||
bash infra/run-objecten-notifications-check.sh
|
bash infra/run-objecten-notifications-check.sh
|
||||||
|
|
||||||
## verify-clamav: assert clamd detects EICAR and passes a clean stream over INSTREAM (S-28),
|
|
||||||
## against the already-running stack.
|
|
||||||
verify-clamav:
|
|
||||||
bash infra/run-clamav-check.sh
|
|
||||||
|
|
||||||
## verify: local mirror of the CI verify-stack job — full stack up once, all checks,
|
## verify: local mirror of the CI verify-stack job — full stack up once, all checks,
|
||||||
## tear down (always). For fast single-concern local iteration use `integration`
|
## tear down (always). For fast single-concern local iteration use `integration`
|
||||||
## (oz-only) or `verify-notifications` (oz+nrc) instead.
|
## (oz-only) or `verify-notifications` (oz+nrc) instead.
|
||||||
@@ -235,7 +221,6 @@ verify:
|
|||||||
docker compose -f $(COMPOSE) up -d --build
|
docker compose -f $(COMPOSE) up -d --build
|
||||||
@bash -c 'set -e; rc=0; \
|
@bash -c 'set -e; rc=0; \
|
||||||
WAIT_TIMEOUT=420 bash infra/wait-healthy.sh $(WAIT_SVCS) \
|
WAIT_TIMEOUT=420 bash infra/wait-healthy.sh $(WAIT_SVCS) \
|
||||||
&& bash infra/run-clamav-check.sh \
|
|
||||||
&& bash infra/run-acl-integration.sh \
|
&& bash infra/run-acl-integration.sh \
|
||||||
&& bash infra/run-notification-check.sh \
|
&& bash infra/run-notification-check.sh \
|
||||||
&& bash infra/run-projection-check.sh \
|
&& bash infra/run-projection-check.sh \
|
||||||
|
|||||||
@@ -14,8 +14,10 @@
|
|||||||
@if (documentsProvided()) {
|
@if (documentsProvided()) {
|
||||||
<p utrecht-paragraph role="status">Uw documenten zijn aangeleverd.</p>
|
<p utrecht-paragraph role="status">Uw documenten zijn aangeleverd.</p>
|
||||||
} @else {
|
} @else {
|
||||||
@if (provideDocumentsError(); as error) {
|
@if (provideDocumentsFailed()) {
|
||||||
<p utrecht-paragraph role="alert">{{ error }}</p>
|
<p utrecht-paragraph role="alert">
|
||||||
|
Het aanleveren van uw documenten is niet gelukt. Probeer het opnieuw.
|
||||||
|
</p>
|
||||||
}
|
}
|
||||||
<p utrecht-paragraph>Lever uw diploma aan (PDF).</p>
|
<p utrecht-paragraph>Lever uw diploma aan (PDF).</p>
|
||||||
<label utrecht-form-label for="diploma">Diploma</label>
|
<label utrecht-form-label for="diploma">Diploma</label>
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { signal } from '@angular/core';
|
import { signal } from '@angular/core';
|
||||||
import { fireEvent, render, screen } from '@testing-library/angular';
|
import { fireEvent, render, screen } from '@testing-library/angular';
|
||||||
import { HttpErrorResponse } from '@angular/common/http';
|
|
||||||
import { of, throwError } from 'rxjs';
|
import { of, throwError } from 'rxjs';
|
||||||
import { AuthService } from 'auth';
|
import { AuthService } from 'auth';
|
||||||
import { BffApiV1Service } from 'api-client';
|
import { BffApiV1Service } from 'api-client';
|
||||||
@@ -143,28 +142,6 @@ describe('RegistrationPage', () => {
|
|||||||
expect(screen.getByRole('button', { name: /documenten aanleveren/i })).toBeTruthy();
|
expect(screen.getByRole('button', { name: /documenten aanleveren/i })).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
// S-29 (#192): the domain refuses an infected or non-PDF file (422 + reason) or cannot scan it (503);
|
|
||||||
// the citizen is told which, so they know whether to pick another file or simply retry.
|
|
||||||
it.each([
|
|
||||||
[422, { reason: 'infected' }, /virus/i],
|
|
||||||
[422, { reason: 'not-a-pdf' }, /geen PDF/i],
|
|
||||||
[503, null, /tijdelijk/i],
|
|
||||||
])('explains a refused diploma upload (%i %o)', async (status, error, message) => {
|
|
||||||
const { providers: p } = providers(
|
|
||||||
vi.fn().mockReturnValue(of({ registrationId: 'reg-9', status: 'Ingediend' })),
|
|
||||||
vi.fn().mockReturnValue(of(undefined)),
|
|
||||||
vi.fn().mockReturnValue(throwError(() => new HttpErrorResponse({ status, error }))),
|
|
||||||
);
|
|
||||||
await render(RegistrationPage, { providers: p });
|
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole('button', { name: /indienen/i }));
|
|
||||||
await screen.findByText(/ontvangen/i);
|
|
||||||
fireEvent.change(screen.getByLabelText(/diploma/i), { target: { files: [diploma()] } });
|
|
||||||
fireEvent.click(await screen.findByRole('button', { name: /documenten aanleveren/i }));
|
|
||||||
|
|
||||||
expect((await screen.findByRole('alert')).textContent).toMatch(message);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('surfaces a withdraw failure and keeps the action available', async () => {
|
it('surfaces a withdraw failure and keeps the action available', async () => {
|
||||||
const { providers: p } = providers(
|
const { providers: p } = providers(
|
||||||
vi.fn().mockReturnValue(of({ registrationId: 'reg-9', status: 'Ingediend' })),
|
vi.fn().mockReturnValue(of({ registrationId: 'reg-9', status: 'Ingediend' })),
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { Component, inject, type OnInit, signal } from '@angular/core';
|
import { Component, inject, type OnInit, signal } from '@angular/core';
|
||||||
import { HttpErrorResponse } from '@angular/common/http';
|
import { BffApiV1Service, type CurrentRegistration, type SubmitAccepted } from 'api-client';
|
||||||
import { BffApiV1Service, type CurrentRegistration, type Refusal, type SubmitAccepted } from 'api-client';
|
|
||||||
import { AuthService } from 'auth';
|
import { AuthService } from 'auth';
|
||||||
import { UtrechtComponentsModule } from 'ui';
|
import { UtrechtComponentsModule } from 'ui';
|
||||||
|
|
||||||
@@ -32,8 +31,7 @@ export class RegistrationPage implements OnInit {
|
|||||||
protected readonly withdrawFailed = signal(false);
|
protected readonly withdrawFailed = signal(false);
|
||||||
protected readonly providingDocuments = signal(false);
|
protected readonly providingDocuments = signal(false);
|
||||||
protected readonly documentsProvided = signal(false);
|
protected readonly documentsProvided = signal(false);
|
||||||
/** Why the last upload failed, worded for the citizen; undefined while there is nothing to report. */
|
protected readonly provideDocumentsFailed = signal(false);
|
||||||
protected readonly provideDocumentsError = signal<string | undefined>(undefined);
|
|
||||||
protected readonly selectedFile = signal<File | undefined>(undefined);
|
protected readonly selectedFile = signal<File | undefined>(undefined);
|
||||||
|
|
||||||
/** Resume an existing in-flight registration after a refresh (S-26): the BFF returns the caller's
|
/** Resume an existing in-flight registration after a refresh (S-26): the BFF returns the caller's
|
||||||
@@ -82,12 +80,12 @@ export class RegistrationPage implements OnInit {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.providingDocuments.set(true);
|
this.providingDocuments.set(true);
|
||||||
this.provideDocumentsError.set(undefined);
|
this.provideDocumentsFailed.set(false);
|
||||||
let contentBase64: string;
|
let contentBase64: string;
|
||||||
try {
|
try {
|
||||||
contentBase64 = await readAsBase64(file);
|
contentBase64 = await readAsBase64(file);
|
||||||
} catch {
|
} catch {
|
||||||
this.provideDocumentsError.set(uploadFailure());
|
this.provideDocumentsFailed.set(true);
|
||||||
this.providingDocuments.set(false);
|
this.providingDocuments.set(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -103,8 +101,8 @@ export class RegistrationPage implements OnInit {
|
|||||||
this.providingDocuments.set(false);
|
this.providingDocuments.set(false);
|
||||||
},
|
},
|
||||||
// Surface the failure instead of swallowing it: keep the action so the user can retry.
|
// Surface the failure instead of swallowing it: keep the action so the user can retry.
|
||||||
error: (err: unknown) => {
|
error: () => {
|
||||||
this.provideDocumentsError.set(uploadFailure(err));
|
this.provideDocumentsFailed.set(true);
|
||||||
this.providingDocuments.set(false);
|
this.providingDocuments.set(false);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -131,20 +129,6 @@ export class RegistrationPage implements OnInit {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Word a failed upload for the citizen: the BFF says why a file was refused (422 + reason) or that
|
|
||||||
* the virus scanner was unreachable (503, S-29); anything else is a generic retry. */
|
|
||||||
function uploadFailure(err?: unknown): string {
|
|
||||||
if (err instanceof HttpErrorResponse && err.status === 422) {
|
|
||||||
return (err.error as Refusal | null)?.reason === 'infected'
|
|
||||||
? 'Er is een virus gevonden in dit bestand. Het is niet opgeslagen; lever een ander bestand aan.'
|
|
||||||
: 'Dit bestand is geen PDF. Lever uw diploma aan als PDF-bestand.';
|
|
||||||
}
|
|
||||||
if (err instanceof HttpErrorResponse && err.status === 503) {
|
|
||||||
return 'Uw bestand kan tijdelijk niet worden gecontroleerd. Probeer het later opnieuw.';
|
|
||||||
}
|
|
||||||
return 'Het aanleveren van uw documenten is niet gelukt. Probeer het opnieuw.';
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Read a file's bytes as a base64 string (without the `data:...;base64,` prefix). */
|
/** Read a file's bytes as a base64 string (without the `data:...;base64,` prefix). */
|
||||||
function readAsBase64(file: File): Promise<string> {
|
function readAsBase64(file: File): Promise<string> {
|
||||||
return new Promise<string>((resolve, reject) => {
|
return new Promise<string>((resolve, reject) => {
|
||||||
|
|||||||
@@ -1,108 +0,0 @@
|
|||||||
# ADR-0035: The deployed stack is published through the existing labs Caddy
|
|
||||||
|
|
||||||
- **Status:** Accepted
|
|
||||||
- **Date:** 2026-09-25
|
|
||||||
- **Deciders:** Respellion engineering
|
|
||||||
- **Slice:** [#177](https://git.labs.respellion.tech/eho/register-referentie/issues/177) —
|
|
||||||
that issue proposed the opposite (an in-cluster Caddy edge); this ADR records why the
|
|
||||||
host-side option won. Implemented in #179, #180 and #181.
|
|
||||||
|
|
||||||
## Context
|
|
||||||
|
|
||||||
The stack deploys to a single-node Talos VM (ADR-0033, #175). Until now it was only usable
|
|
||||||
through five SSH port-forwards: the portals' OIDC flow uses PKCE, PKCE needs
|
|
||||||
`crypto.subtle`, and browsers expose that only in a **secure context**, meaning HTTPS or a
|
|
||||||
`localhost` origin. A NodePort on the VM's address is neither. We want a URL a demo
|
|
||||||
audience can simply open.
|
|
||||||
|
|
||||||
Three facts about where things run shape the answer:
|
|
||||||
|
|
||||||
- The Talos VM is a libvirt guest on a **Fedora hypervisor in the office**, behind NAT
|
|
||||||
with no public address. The only way in from outside is an existing reverse SSH tunnel
|
|
||||||
(`autossh-reverse-tunnel.service`) into an `openssh-server` container on the labs
|
|
||||||
server.
|
|
||||||
- The **labs server** (public IP) already runs Caddy for `*.labs.respellion.tech`, with
|
|
||||||
the wildcard certificate (DNS-01 via Cloudflare) and ports 80/443. Every other labs
|
|
||||||
service is published there (repo `Infra`, `infra/development/`).
|
|
||||||
- #177 proposed a Caddy **inside the cluster**, fed by a layer-4 forward on the host, so
|
|
||||||
that routing and certificates would be cluster state. That assumes the public IP is on
|
|
||||||
the hypervisor. It isn't: the hypervisor has no inbound path, and 80/443 on the labs
|
|
||||||
server are already taken by the labs Caddy.
|
|
||||||
|
|
||||||
## Decision
|
|
||||||
|
|
||||||
**Publish the portals and Keycloak through the existing labs Caddy. Carry the traffic to
|
|
||||||
the cluster over a second reverse SSH tunnel from the hypervisor.**
|
|
||||||
|
|
||||||
```
|
|
||||||
browser ─https─▶ labs Caddy ─▶ openssh-server:3014x/30180
|
|
||||||
─reverse SSH tunnel─▶ Fedora hypervisor ─▶ Talos NodePorts
|
|
||||||
```
|
|
||||||
|
|
||||||
- **Hostnames** under the existing wildcard: `big-register` (openbaar), `big-mijn`
|
|
||||||
(self-service), `big-behandel`, `big-beheer`, and `big-auth` (Keycloak, with `/admin*`
|
|
||||||
answered 404).
|
|
||||||
- **Tunnel:** `big-portals-tunnel.service` on the hypervisor (repo `Infra`)
|
|
||||||
reverse-forwards the five browser-facing NodePorts into `openssh-server`. It is
|
|
||||||
separate from the access tunnel on `:6667`, so a failed forward can't cut SSH access.
|
|
||||||
Caddy joins the `openssh_default` network to reach the tunnel ends.
|
|
||||||
- **Keycloak's issuer** is the public origin. The chart value `keycloakUrl` replaces
|
|
||||||
`host` + NodePort in one helper, `big.keycloakUrl`, which feeds both `KC_HOSTNAME` and
|
|
||||||
the portals' `config.json` authority, so the two cannot drift (ADR-0010). The deploy
|
|
||||||
workflow sets it from the `KEYCLOAK_URL` repository variable.
|
|
||||||
- **`KC_PROXY_HEADERS=xforwarded`:** `KC_HOSTNAME_BACKCHANNEL_DYNAMIC` builds the token,
|
|
||||||
userinfo and certs URLs from the request. That request reaches Keycloak as plain HTTP,
|
|
||||||
so the URLs came out `http://` and browsers blocked them as mixed content. Trusting
|
|
||||||
Caddy's `X-Forwarded-Proto` keeps them HTTPS. In-cluster calls send no such header and
|
|
||||||
still use `keycloak:8080`.
|
|
||||||
- **Demo MFA (optional):** `demo.otpAutofill` (`OTP_AUTOFILL`) makes the `big-demo` theme
|
|
||||||
(`infra/keycloak/themes/big-demo`) Keycloak's default. Its script fills in and submits
|
|
||||||
the medewerker OTP from the fixture secret (ADR-0031), so the step is visibly enforced
|
|
||||||
without an authenticator. It is off by default.
|
|
||||||
|
|
||||||
### Alternatives considered
|
|
||||||
|
|
||||||
- **In-cluster Caddy edge (#177, PR #178).** It would keep routes and certificates in
|
|
||||||
cluster state. But it needs a public inbound path to the hypervisor that doesn't exist,
|
|
||||||
plus a second certificate authority beside the labs Caddy, which already holds the
|
|
||||||
wildcard. Closed unmerged.
|
|
||||||
- **Port-forward on the office router to the hypervisor.** This opens the office network
|
|
||||||
itself to the internet. Rejected.
|
|
||||||
- **Move the cluster to a host with a public IP.** It would remove the tunnel, but it's a
|
|
||||||
bigger change than publishing one demo. It remains the natural step if the stack
|
|
||||||
outgrows a lab VM.
|
|
||||||
- **Keep the SSH port-forwards.** Fine for one developer, but not something you can send
|
|
||||||
to someone.
|
|
||||||
|
|
||||||
## Consequences
|
|
||||||
|
|
||||||
**Positive**
|
|
||||||
|
|
||||||
- Real hostnames and HTTPS, so PKCE works in any browser with no client-side setup.
|
|
||||||
- No new certificate handling: the labs Caddy's wildcard covers the new hosts.
|
|
||||||
- The chart stays edge-agnostic. With `keycloakUrl` empty it renders exactly as before,
|
|
||||||
so compose, CI and the `localhost` workflow are untouched.
|
|
||||||
|
|
||||||
**Negative / costs**
|
|
||||||
|
|
||||||
- **Routing lives outside the cluster**, in the Infra repo's Caddyfile. That is exactly
|
|
||||||
what #177 wanted to avoid. Adding a portal means changing three places: a NodePort in
|
|
||||||
the chart, a forward in the tunnel unit, and a host in the Caddyfile.
|
|
||||||
- **Two SSH hops in the data path.** If the hypervisor or the tunnel is down, the
|
|
||||||
portals return 502 even though the cluster is healthy.
|
|
||||||
- **One issuer string.** With `keycloakUrl` set, the `localhost` port-forward workflow
|
|
||||||
(runbook §5) can no longer log in.
|
|
||||||
- **Keycloak trusts `X-Forwarded-*`** from anything that reaches it. Today that is only
|
|
||||||
in-cluster callers and the tunnel. `KC_PROXY_TRUSTED_ADDRESSES` can narrow it if the
|
|
||||||
NodePort is ever exposed more widely.
|
|
||||||
- **The portals are public.** Anyone with the link can log in with the committed test
|
|
||||||
credentials, and with `OTP_AUTOFILL` on, no second factor stands in the way. That is
|
|
||||||
acceptable for synthetic data. Put the labs Caddy's Azure `authorize` in front of the
|
|
||||||
`big-*` hosts if the audience must be restricted.
|
|
||||||
|
|
||||||
**Follow-up**
|
|
||||||
|
|
||||||
- Runbook: `docs/runbooks/kubernetes-talos.md`, "Publishing through the labs Caddy".
|
|
||||||
- Dev-mode Keycloak generates new signing keys on every restart, and the BFF re-fetches
|
|
||||||
them at most every 5 minutes, so expect a few minutes of 401s after a Keycloak restart.
|
|
||||||
Persisting Keycloak's database (runbook §6) would remove that.
|
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
# ADR-0036: Uploaded documents are scanned by ClamAV in the Domain Service, fail closed
|
|
||||||
|
|
||||||
- **Status:** Accepted
|
|
||||||
- **Date:** 2026-10-02
|
|
||||||
- **Deciders:** Respellion engineering
|
|
||||||
- **Slice:** proposed in [#190](https://git.labs.respellion.tech/eho/register-referentie/issues/190);
|
|
||||||
clamd deployed in [#191](https://git.labs.respellion.tech/eho/register-referentie/issues/191) (S-28),
|
|
||||||
scanning wired in [#192](https://git.labs.respellion.tech/eho/register-referentie/issues/192) (S-29).
|
|
||||||
|
|
||||||
## Context
|
|
||||||
|
|
||||||
A zorgprofessional's diploma upload goes portal → BFF → Domain (`ProvideDocuments`) →
|
|
||||||
ACL → OpenZaak. Nothing on that path looks at the file. It is not checked for malware,
|
|
||||||
and nobody checks that it is a PDF. Behandelaars open these files later, so the
|
|
||||||
register stores, and then serves, whatever a citizen sends.
|
|
||||||
|
|
||||||
Scanning needs a signature engine that stays up to date. That means a new peer service,
|
|
||||||
and that makes it an ADR (CLAUDE.md §14).
|
|
||||||
|
|
||||||
## Decision
|
|
||||||
|
|
||||||
1. **Engine:** the ClamAV daemon (`clamd`), official image `clamav/clamav`, pinned tag,
|
|
||||||
as its own service in compose and in the Helm chart. `freshclam` in the same container
|
|
||||||
keeps the signatures current, and they live on a volume.
|
|
||||||
2. **Where the check lives:** in the **Domain Service**, behind an `IDocumentScanner` port
|
|
||||||
in `Big.Application`. "Only a clean PDF is stored and unblocks beoordeling" is a rule
|
|
||||||
of the provide-documents use case. The BFF is a thin proxy (§8.3), and the ACL
|
|
||||||
translates ZGW and nothing else (§8.1). A check in the domain also covers every
|
|
||||||
entry point, not just the portal.
|
|
||||||
3. **Protocol:** the adapter in `Big.Infrastructure` speaks clamd's INSTREAM protocol over
|
|
||||||
`TcpClient`: `zINSTREAM\0`, length-prefixed chunks, a zero-length terminator, then a
|
|
||||||
`stream: OK` or `stream: <name> FOUND` reply. That is a few lines of code, so we add
|
|
||||||
**no NuGet package** for it (nClam and similar).
|
|
||||||
4. **Fail closed:** if clamd can't be reached, the upload is refused (503). Nothing is
|
|
||||||
stored and the document wait stays open. We never store an unscanned file.
|
|
||||||
5. **Type check:** content must also start with `%PDF-`, checked **after** the scan.
|
|
||||||
clamd matches EICAR (and many real signatures) only at the start of a file, so a type
|
|
||||||
check in front of the scan would report malware as merely "not a PDF". The check also
|
|
||||||
refuses a renamed non-PDF that is clean.
|
|
||||||
|
|
||||||
## Consequences
|
|
||||||
|
|
||||||
- One more long-running service. clamd holds its signatures in memory (about 1 GB idle).
|
|
||||||
`ConcurrentDatabaseReload no` stops a signature reload from holding a second copy,
|
|
||||||
but clamd pauses scans for the few seconds a reload takes. Compose caps it at
|
|
||||||
`mem_limit: 2g`, and the chart requests 1200Mi. This counts against the verify-stack
|
|
||||||
runner's memory ceiling (#182).
|
|
||||||
- The first start downloads about 300 MB of signatures from the ClamAV CDN, so the
|
|
||||||
runner and the cluster node need outbound internet (as `seed-zaaktype` already does).
|
|
||||||
The CDN rate-limits by IP. A CI runner that starts fresh often can get throttled, and
|
|
||||||
then the health check doesn't go green. If that happens, mirror the signatures
|
|
||||||
(`cvdupdate`) rather than retrying.
|
|
||||||
- Tests use the EICAR test string, built from two halves so the repo itself does not trip
|
|
||||||
an on-access scanner. No real malware is ever committed.
|
|
||||||
- Infected or non-PDF uploads are refused with 422 and a business message. We don't keep
|
|
||||||
a quarantine copy: a refused file is simply not stored.
|
|
||||||
@@ -878,28 +878,3 @@ Keycloak's stock conditional-OTP subflow — no custom browser flow. The fixture
|
|||||||
committed on purpose so the checks can compute codes; a real deployment enrols per-user authenticators
|
committed on purpose so the checks can compute codes; a real deployment enrols per-user authenticators
|
||||||
(ADR-0031).
|
(ADR-0031).
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## S-29 — Uploaded diplomas are virus-scanned (#192, ADR-0036)
|
|
||||||
|
|
||||||
**Outcome:** a diploma upload is stored only when it is a PDF that **ClamAV** scans clean. If the file
|
|
||||||
is infected, or not a PDF, the citizen is told why and the registration keeps waiting for a valid
|
|
||||||
diploma. If the scanner is down the upload is refused (fail closed) and the citizen is asked to retry.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 1. Manual: submit a registration in the self-service portal, then upload as the diploma:
|
|
||||||
# - any real PDF → "Uw documenten zijn aangeleverd."
|
|
||||||
# - the EICAR test file (below) → "Er is een virus gevonden in dit bestand…"
|
|
||||||
# - a renamed .png → "Dit bestand is geen PDF…"
|
|
||||||
printf '%s%s' 'X5O!P%@AP[4\PZX54(P^)7CC)7}$' 'EICAR-STANDARD-ANTIVIRUS-TEST-FILE!$H+H*' > /tmp/eicar.pdf
|
|
||||||
#
|
|
||||||
# 2. Automated: clamd itself (EICAR → FOUND, clean → OK) and the use case at every refusal:
|
|
||||||
make verify-clamav
|
|
||||||
dotnet test tests/acceptance --filter "FullyQualifiedName~EenDiplomaAanleveren"
|
|
||||||
```
|
|
||||||
|
|
||||||
**The path:** portal → BFF → Domain `ProvideDocuments`. The domain asks `IDocumentScanner`
|
|
||||||
(clamd over INSTREAM) first and checks `%PDF-` second, because clamd only spots EICAR at the start of
|
|
||||||
a file. Only a clean PDF goes on to the ACL and into ZGW. Refusals come back as 422 with a reason, a
|
|
||||||
scanner outage as 503 (ADR-0036).
|
|
||||||
|
|
||||||
|
|||||||
+2
-6
@@ -19,10 +19,9 @@ and CI cannot drift:
|
|||||||
| `build` | `make build` → `dotnet build … -c Release` | .NET 10 SDK |
|
| `build` | `make build` → `dotnet build … -c Release` | .NET 10 SDK |
|
||||||
| `unit` | `make unit` → `dotnet test … -c Release --filter "Category!=Integration"` | .NET 10 SDK |
|
| `unit` | `make unit` → `dotnet test … -c Release --filter "Category!=Integration"` | .NET 10 SDK |
|
||||||
| `frontend` | `make frontend` → Nx lint/test/build for the four portals | pnpm + Node |
|
| `frontend` | `make frontend` → Nx lint/test/build for the four portals | pnpm + Node |
|
||||||
| `docs` | `make docs` → `mkdocs build --strict` in a pinned venv (fails on a broken link or nav entry; the site is not published yet, #173) | Python 3 |
|
|
||||||
| `k8s` | `make k8s-lint` (render + schema-check the Helm chart) → `make k8s-drift` (chart still describes the same stack as `infra/docker-compose.yml`) | pinned `helm` binary + `docker compose` |
|
| `k8s` | `make k8s-lint` (render + schema-check the Helm chart) → `make k8s-drift` (chart still describes the same stack as `infra/docker-compose.yml`) | pinned `helm` binary + `docker compose` |
|
||||||
| `mutation` | `make mutation` → `dotnet tool restore` → `dotnet stryker` (ACL); uploads the HTML report as an artifact | .NET 10 SDK |
|
| `mutation` | `make mutation` → `dotnet tool restore` → `dotnet stryker` (ACL); uploads the HTML report as an artifact | .NET 10 SDK |
|
||||||
| `verify-stack` | **push to `main` only, skipped on PRs** (#182) — the single live-stack stage — steps: `make verify-up` (full stack up + health, the DoD smoke) → `make verify-acl` (ACL ↔ OpenZaak) → `make verify-nrc` (OpenZaak → NRC delivery) → `make down` | container engine + egress (base images, nuget, `selectielijst.openzaak.nl`) |
|
| `verify-stack` | the single live-stack stage — steps: `make verify-up` (full stack up + health, the DoD smoke) → `make verify-acl` (ACL ↔ OpenZaak) → `make verify-nrc` (OpenZaak → NRC delivery) → `make down` | container engine + egress (base images, nuget, `selectielijst.openzaak.nl`) |
|
||||||
|
|
||||||
> **Why one `verify-stack` job, not three.** The single self-hosted runner runs jobs
|
> **Why one `verify-stack` job, not three.** The single self-hosted runner runs jobs
|
||||||
> **sequentially**, so booting OpenZaak once (instead of once per check) is the
|
> **sequentially**, so booting OpenZaak once (instead of once per check) is the
|
||||||
@@ -58,12 +57,9 @@ dotnet tool (`.config/dotnet-tools.json`), so it runs identically locally and in
|
|||||||
make mutation # dotnet tool restore + dotnet stryker on the ACL
|
make mutation # dotnet tool restore + dotnet stryker on the ACL
|
||||||
```
|
```
|
||||||
|
|
||||||
Config lives in `services/acl/stryker-config.json`.
|
Config lives in [`services/acl/stryker-config.json`](../../services/acl/stryker-config.json).
|
||||||
It runs in **solution mode** against `Acl.slnx`, mutating the two projects under test
|
It runs in **solution mode** against `Acl.slnx`, mutating the two projects under test
|
||||||
(`Acl.Application`, `Acl.Infrastructure`); `Acl.Api` has no tests and is skipped.
|
(`Acl.Application`, `Acl.Infrastructure`); `Acl.Api` has no tests and is skipped.
|
||||||
`Acl.slnx` leaves out `Acl.IntegrationTests`: it needs a live OpenZaak, and Stryker
|
|
||||||
runs every test project in the solution, so keeping it in makes 8 tests fail in the
|
|
||||||
initial run (#174).
|
|
||||||
|
|
||||||
**Baseline (the ratchet):** the ACL is the first service with branching logic, so it
|
**Baseline (the ratchet):** the ACL is the first service with branching logic, so it
|
||||||
sets the repo-wide baseline. Observed score **95%**; enforced `break` threshold **90%**
|
sets the repo-wide baseline. Observed score **95%**; enforced `break` threshold **90%**
|
||||||
|
|||||||
@@ -356,7 +356,6 @@ immutable, so `helm upgrade` is rejected with `cannot patch "…" with kind Job`
|
|||||||
| Login redirects but the portal stays logged out, or the BFF answers 401 | `TALOS_HOST` doesn't match the address in the browser's URL bar — issuer mismatch. Re-run `make k8s-up` with the right value |
|
| Login redirects but the portal stays logged out, or the BFF answers 401 | `TALOS_HOST` doesn't match the address in the browser's URL bar — issuer mismatch. Re-run `make k8s-up` with the right value |
|
||||||
| A portal returns 502 on `/self-service/…` | the BFF is unreachable from the portal pod: check `kubectl -n big get svc bff` and the BFF's own readiness |
|
| A portal returns 502 on `/self-service/…` | the BFF is unreachable from the portal pod: check `kubectl -n big get svc bff` and the BFF's own readiness |
|
||||||
| Public register empty after a submit | usually a wiped `emptyDir` database (§6): `make k8s-reseed`. Confirm with `kubectl -n big logs deploy/event-subscriber \| grep 42P01` |
|
| Public register empty after a submit | usually a wiped `emptyDir` database (§6): `make k8s-reseed`. Confirm with `kubectl -n big logs deploy/event-subscriber \| grep 42P01` |
|
||||||
| `clamav` not Ready for minutes | first start downloads ~300 MB of signatures, which needs outbound internet. A `429`/`cool-down` in `kubectl -n big logs deploy/clamav` means the ClamAV CDN is throttling this IP (ADR-0036) |
|
|
||||||
| `helm upgrade` fails with `cannot patch … with kind Job` | see §7 — use `make k8s-reseed` |
|
| `helm upgrade` fails with `cannot patch … with kind Job` | see §7 — use `make k8s-reseed` |
|
||||||
| Pods `Evicted` / `OOMKilled` | the VM is too small (§0) |
|
| Pods `Evicted` / `OOMKilled` | the VM is too small (§0) |
|
||||||
| A Job shows `BackoffLimitExceeded` | read it: `kubectl -n big logs job/<name>` |
|
| A Job shows `BackoffLimitExceeded` | read it: `kubectl -n big logs job/<name>` |
|
||||||
@@ -404,8 +403,6 @@ upgrade path.
|
|||||||
|
|
||||||
## Publishing through the labs Caddy
|
## Publishing through the labs Caddy
|
||||||
|
|
||||||
Why this route and not an in-cluster edge: [ADR-0035](../architecture/adr-0035-public-access-through-the-labs-caddy.md).
|
|
||||||
|
|
||||||
The portals can be reached on real hostnames through the Caddy that already fronts
|
The portals can be reached on real hostnames through the Caddy that already fronts
|
||||||
`*.labs.respellion.tech` (repo `Infra`, `infra/development/`). The chain:
|
`*.labs.respellion.tech` (repo `Infra`, `infra/development/`). The chain:
|
||||||
|
|
||||||
@@ -433,18 +430,6 @@ make k8s-up TALOS_HOST=localhost K8S_REGISTRY=<TALOS_HOST>:30500 \
|
|||||||
For deploy-on-merge, set the repository variable `KEYCLOAK_URL` to the same value.
|
For deploy-on-merge, set the repository variable `KEYCLOAK_URL` to the same value.
|
||||||
With it set, the `localhost` port-forwards (§5) no longer log in: the issuer is one string.
|
With it set, the `localhost` port-forwards (§5) no longer log in: the issuer is one string.
|
||||||
|
|
||||||
Staff logins still hit the enforced OTP step. For a demo, set the repository variable
|
|
||||||
`OTP_AUTOFILL=true` (chart value `demo.otpAutofill`): Keycloak then uses the `big-demo`
|
|
||||||
theme, which fills in and submits the code from the fixture secret, so the step is visible
|
|
||||||
but needs no authenticator. Keycloak restarts when the value flips. Demo only — the secret
|
|
||||||
is committed.
|
|
||||||
|
|
||||||
The theme lives in `infra/keycloak/themes/big-demo/` and is seeded as the `rr-kc-theme`
|
|
||||||
ConfigMap by `infra/helm/seed-configmaps.sh` on every deploy. Keycloak runs `start-dev`,
|
|
||||||
which doesn't cache themes, so an edit shows up about a minute after the ConfigMap changes.
|
|
||||||
A *new* theme file also needs a key in the seed script and a path in the keycloak `files`
|
|
||||||
in `values.yaml`.
|
|
||||||
|
|
||||||
One-time setup:
|
One-time setup:
|
||||||
|
|
||||||
1. Fedora host: install `infra/development/big-portals-tunnel.service` from the Infra repo
|
1. Fedora host: install `infra/development/big-portals-tunnel.service` from the Infra repo
|
||||||
|
|||||||
@@ -1,40 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""S-28 (#191): prove clamd is up, has signatures loaded, and scans a stream over INSTREAM.
|
|
||||||
|
|
||||||
The EICAR test file must come back FOUND and a clean payload OK — the same protocol the domain's
|
|
||||||
scanner adapter will speak (ADR-0036). EICAR is assembled from two halves so this file itself is
|
|
||||||
not flagged by an on-access scanner on a developer laptop. Stdlib only (python:3-slim).
|
|
||||||
"""
|
|
||||||
import os
|
|
||||||
import socket
|
|
||||||
import struct
|
|
||||||
import sys
|
|
||||||
import time
|
|
||||||
|
|
||||||
HOST = os.environ["CLAMAV"]
|
|
||||||
TIMEOUT = int(os.environ.get("CLAMAV_TIMEOUT", "60"))
|
|
||||||
EICAR = (r"X5O!P%@AP[4\PZX54(P^)7CC)7}$" + r"EICAR-STANDARD-ANTIVIRUS-TEST-FILE!$H+H*").encode()
|
|
||||||
|
|
||||||
|
|
||||||
def instream(payload):
|
|
||||||
with socket.create_connection((HOST, 3310), timeout=30) as s:
|
|
||||||
s.sendall(b"zINSTREAM\0" + struct.pack(">I", len(payload)) + payload + struct.pack(">I", 0))
|
|
||||||
return s.recv(4096).rstrip(b"\0").decode()
|
|
||||||
|
|
||||||
|
|
||||||
deadline = time.time() + TIMEOUT
|
|
||||||
while True:
|
|
||||||
try:
|
|
||||||
clean, infected = instream(b"%PDF-1.4 clean"), instream(EICAR)
|
|
||||||
break
|
|
||||||
except OSError as e:
|
|
||||||
if time.time() > deadline:
|
|
||||||
sys.exit(f"FAIL: clamd at {HOST}:3310 unreachable: {e}")
|
|
||||||
time.sleep(3)
|
|
||||||
|
|
||||||
print(f"clean → {clean!r}; eicar → {infected!r}")
|
|
||||||
if clean != "stream: OK":
|
|
||||||
sys.exit("FAIL: clean payload was not reported OK")
|
|
||||||
if not infected.endswith("FOUND"):
|
|
||||||
sys.exit("FAIL: EICAR was not detected")
|
|
||||||
print("OK: clamd detects EICAR and passes a clean stream")
|
|
||||||
@@ -751,26 +751,6 @@ services:
|
|||||||
condition: service_completed_successfully
|
condition: service_completed_successfully
|
||||||
networks: [cg]
|
networks: [cg]
|
||||||
|
|
||||||
# ClamAV daemon (S-28, ADR-0036): the domain scans uploaded diplomas over clamd's INSTREAM
|
|
||||||
# protocol on :3310 before they reach OpenZaak (S-29). The first start downloads ~300 MB of
|
|
||||||
# signatures with freshclam; the volume keeps them across restarts. clamd holds them in memory
|
|
||||||
# (~1 GB), and a reload would briefly hold two copies — ConcurrentDatabaseReload off prevents
|
|
||||||
# that, at the cost of clamd pausing scans during a signature reload.
|
|
||||||
clamav:
|
|
||||||
image: docker.io/clamav/clamav:1.4.6
|
|
||||||
environment:
|
|
||||||
CLAMD_CONF_ConcurrentDatabaseReload: "no"
|
|
||||||
# The image's own healthcheck (clamdcheck.sh: PING → PONG) polls every 30s; poll faster so
|
|
||||||
# wait-healthy sees it as soon as the signatures are loaded.
|
|
||||||
healthcheck:
|
|
||||||
test: ["CMD-SHELL", "clamdcheck.sh"]
|
|
||||||
interval: 5s
|
|
||||||
start_period: 360s
|
|
||||||
mem_limit: 2g
|
|
||||||
volumes:
|
|
||||||
- clamav-db:/var/lib/clamav
|
|
||||||
networks: [cg]
|
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
oz-db:
|
oz-db:
|
||||||
nrc-db:
|
nrc-db:
|
||||||
@@ -778,7 +758,6 @@ volumes:
|
|||||||
projection-db:
|
projection-db:
|
||||||
objecttypen-db:
|
objecttypen-db:
|
||||||
objecten-db:
|
objecten-db:
|
||||||
clamav-db:
|
|
||||||
# Carries the seed-generated acl.env (server-assigned zaaktype URLs) from local-seed to the ACL.
|
# Carries the seed-generated acl.env (server-assigned zaaktype URLs) from local-seed to the ACL.
|
||||||
seed-env:
|
seed-env:
|
||||||
|
|
||||||
|
|||||||
@@ -785,26 +785,6 @@ services:
|
|||||||
condition: service_completed_successfully
|
condition: service_completed_successfully
|
||||||
networks: [cg]
|
networks: [cg]
|
||||||
|
|
||||||
# ClamAV daemon (S-28, ADR-0036): the domain scans uploaded diplomas over clamd's INSTREAM
|
|
||||||
# protocol on :3310 before they reach OpenZaak (S-29). The first start downloads ~300 MB of
|
|
||||||
# signatures with freshclam; the volume keeps them across restarts. clamd holds them in memory
|
|
||||||
# (~1 GB), and a reload would briefly hold two copies — ConcurrentDatabaseReload off prevents
|
|
||||||
# that, at the cost of clamd pausing scans during a signature reload.
|
|
||||||
clamav:
|
|
||||||
image: docker.io/clamav/clamav:1.4.6
|
|
||||||
environment:
|
|
||||||
CLAMD_CONF_ConcurrentDatabaseReload: "no"
|
|
||||||
# The image's own healthcheck (clamdcheck.sh: PING → PONG) polls every 30s; poll faster so
|
|
||||||
# wait-healthy sees it as soon as the signatures are loaded.
|
|
||||||
healthcheck:
|
|
||||||
test: ["CMD-SHELL", "clamdcheck.sh"]
|
|
||||||
interval: 5s
|
|
||||||
start_period: 360s
|
|
||||||
mem_limit: 2g
|
|
||||||
volumes:
|
|
||||||
- clamav-db:/var/lib/clamav
|
|
||||||
networks: [cg]
|
|
||||||
|
|
||||||
# ── Observability backplane (S-16a, ADR-0023) ──────────────────────────────
|
# ── Observability backplane (S-16a, ADR-0023) ──────────────────────────────
|
||||||
# Grafana-native stack: Tempo ingests OTLP traces (the .NET services export
|
# Grafana-native stack: Tempo ingests OTLP traces (the .NET services export
|
||||||
# straight to it — no collector hop, S-16b), Prometheus scrapes service
|
# straight to it — no collector hop, S-16b), Prometheus scrapes service
|
||||||
@@ -856,7 +836,6 @@ volumes:
|
|||||||
projection-db:
|
projection-db:
|
||||||
objecttypen-db:
|
objecttypen-db:
|
||||||
objecten-db:
|
objecten-db:
|
||||||
clamav-db:
|
|
||||||
# Config volumes — created and populated out-of-band by infra/seed-config.sh
|
# Config volumes — created and populated out-of-band by infra/seed-config.sh
|
||||||
# (docker cp), because bind mounts don't reach sibling containers on the CI
|
# (docker cp), because bind mounts don't reach sibling containers on the CI
|
||||||
# runner. `external` keeps the names deterministic; the seed step manages them.
|
# runner. `external` keeps the names deterministic; the seed step manages them.
|
||||||
|
|||||||
@@ -94,10 +94,6 @@ volumes:
|
|||||||
{{- with .defaultMode }}
|
{{- with .defaultMode }}
|
||||||
defaultMode: {{ . }}
|
defaultMode: {{ . }}
|
||||||
{{- end }}
|
{{- end }}
|
||||||
{{- with .items }}
|
|
||||||
items:
|
|
||||||
{{- toYaml . | nindent 8 }}
|
|
||||||
{{- end }}
|
|
||||||
{{- end }}
|
{{- end }}
|
||||||
{{- with $w.data }}
|
{{- with $w.data }}
|
||||||
- name: data
|
- name: data
|
||||||
@@ -129,17 +125,13 @@ volumes:
|
|||||||
{{/*
|
{{/*
|
||||||
Env list from a map. Every value is run through `tpl`, so values.yaml can name
|
Env list from a map. Every value is run through `tpl`, so values.yaml can name
|
||||||
cluster-internal hosts ({{ .Release.Namespace }}) and the node address
|
cluster-internal hosts ({{ .Release.Namespace }}) and the node address
|
||||||
({{ .Values.host }}) without the chart hard-coding either. A value that renders
|
({{ .Values.host }}) without the chart hard-coding either.
|
||||||
empty is left out, which is how a setting is made conditional on a chart value.
|
|
||||||
*/}}
|
*/}}
|
||||||
{{- define "big.env" -}}
|
{{- define "big.env" -}}
|
||||||
{{- $root := index . 0 -}}
|
{{- $root := index . 0 -}}
|
||||||
{{- range $k, $v := index . 1 }}
|
{{- range $k, $v := index . 1 }}
|
||||||
{{- $val := tpl (toString $v) $root }}
|
|
||||||
{{- if $val }}
|
|
||||||
- name: {{ $k }}
|
- name: {{ $k }}
|
||||||
value: {{ $val | quote }}
|
value: {{ tpl (toString $v) $root | quote }}
|
||||||
{{- end }}
|
|
||||||
{{- end }}
|
{{- end }}
|
||||||
{{- end -}}
|
{{- end -}}
|
||||||
|
|
||||||
|
|||||||
@@ -30,18 +30,6 @@ host: 192.168.122.100
|
|||||||
# portals' authority (runbook, "Publishing through the labs Caddy").
|
# portals' authority (runbook, "Publishing through the labs Caddy").
|
||||||
keycloakUrl: ""
|
keycloakUrl: ""
|
||||||
|
|
||||||
# Where the .NET services send traces (OTLP gRPC). The default is the chart's own
|
|
||||||
# `tempo` workload (off by default, like compose). Point it at a Tempo outside the
|
|
||||||
# release, e.g. the cluster monitoring stack's http://tempo.monitoring.svc:4317 —
|
|
||||||
# with no Tempo at all, every export fails and is counted as a .NET exception.
|
|
||||||
otelEndpoint: http://tempo:4317
|
|
||||||
|
|
||||||
demo:
|
|
||||||
# Fill in and submit the medewerker OTP step from the fixture secret, so a public
|
|
||||||
# demo shows MFA enforced without an authenticator: makes the big-demo theme
|
|
||||||
# (infra/keycloak/themes/big-demo) Keycloak's default. Demo only: the secret is committed.
|
|
||||||
otpAutofill: false
|
|
||||||
|
|
||||||
# Set when pulling from a private registry (e.g. the Gitea Container Registry).
|
# Set when pulling from a private registry (e.g. the Gitea Container Registry).
|
||||||
imagePullSecrets: []
|
imagePullSecrets: []
|
||||||
|
|
||||||
@@ -166,11 +154,10 @@ envGroups:
|
|||||||
NOTIFICATIONS_DISABLED: "false"
|
NOTIFICATIONS_DISABLED: "false"
|
||||||
RUN_SETUP_CONFIG: "true"
|
RUN_SETUP_CONFIG: "true"
|
||||||
|
|
||||||
# Traces for the .NET services. Always set, like compose. With no Tempo behind
|
# Traces for the .NET services. Always set, like compose: the exporter fails
|
||||||
# `otelEndpoint` the exporter fails quietly but throws on every batch, which
|
# harmlessly when Tempo is absent (services/*/Program.cs).
|
||||||
# shows up as HttpRequestException/SocketException in dotnet_exceptions_total.
|
|
||||||
otel:
|
otel:
|
||||||
OTEL_EXPORTER_OTLP_ENDPOINT: '{{ .Values.otelEndpoint }}'
|
OTEL_EXPORTER_OTLP_ENDPOINT: http://tempo:4317
|
||||||
OTEL_EXPORTER_OTLP_PROTOCOL: grpc
|
OTEL_EXPORTER_OTLP_PROTOCOL: grpc
|
||||||
|
|
||||||
# ── Workloads ──────────────────────────────────────────────────────────────────
|
# ── Workloads ──────────────────────────────────────────────────────────────────
|
||||||
@@ -290,29 +277,11 @@ workloads:
|
|||||||
# this issuer back, which is what browser tokens carry (infra/host-browser.yml).
|
# this issuer back, which is what browser tokens carry (infra/host-browser.yml).
|
||||||
KC_HOSTNAME: '{{ include "big.keycloakUrl" . }}'
|
KC_HOSTNAME: '{{ include "big.keycloakUrl" . }}'
|
||||||
KC_HOSTNAME_BACKCHANNEL_DYNAMIC: "true"
|
KC_HOSTNAME_BACKCHANNEL_DYNAMIC: "true"
|
||||||
# Only rendered with demo.otpAutofill (big.env skips empty values); off, Keycloak
|
|
||||||
# keeps its stock theme and the mounted big-demo theme is unused.
|
|
||||||
KC_SPI_THEME_DEFAULT: '{{ if .Values.demo.otpAutofill }}big-demo{{ end }}'
|
|
||||||
# Behind a TLS proxy (keycloakUrl) the dynamic backchannel URLs — token,
|
|
||||||
# userinfo, certs — take their scheme from the request, which reaches Keycloak
|
|
||||||
# as plain http; trusting X-Forwarded-Proto keeps them https so the browser
|
|
||||||
# doesn't block them as mixed content. In-cluster calls send no such header.
|
|
||||||
KC_PROXY_HEADERS: xforwarded
|
|
||||||
ports: [{ name: http, port: 8080 }]
|
ports: [{ name: http, port: 8080 }]
|
||||||
# TCP, not /health/ready on the management port: nothing here gates on realm
|
# TCP, not /health/ready on the management port: nothing here gates on realm
|
||||||
# import, and a wrong health path would leave the Service with no endpoints.
|
# import, and a wrong health path would leave the Service with no endpoints.
|
||||||
probe: { tcpSocket: { port: 8080 }, initialDelaySeconds: 15 }
|
probe: { tcpSocket: { port: 8080 }, initialDelaySeconds: 15 }
|
||||||
files:
|
files: [{ configMap: rr-kc-realms, mountPath: /opt/keycloak/data/import }]
|
||||||
- { configMap: rr-kc-realms, mountPath: /opt/keycloak/data/import }
|
|
||||||
# infra/keycloak/themes/big-demo, seeded by infra/helm/seed-configmaps.sh.
|
|
||||||
- configMap: rr-kc-theme
|
|
||||||
mountPath: /opt/keycloak/themes/big-demo
|
|
||||||
items:
|
|
||||||
- { key: login.properties, path: login/theme.properties }
|
|
||||||
- { key: otp-autofill.js, path: login/resources/js/otp-autofill.js }
|
|
||||||
- { key: account.properties, path: account/theme.properties }
|
|
||||||
- { key: admin.properties, path: admin/theme.properties }
|
|
||||||
- { key: email.properties, path: email/theme.properties }
|
|
||||||
|
|
||||||
# ── Flowable (S-03) ─────────────────────────────────────────────────────────
|
# ── Flowable (S-03) ─────────────────────────────────────────────────────────
|
||||||
flowable-db:
|
flowable-db:
|
||||||
@@ -588,24 +557,6 @@ workloads:
|
|||||||
envFrom: [objecten]
|
envFrom: [objecten]
|
||||||
waitFor: [objecten-db:5432, objecten-redis:6379]
|
waitFor: [objecten-db:5432, objecten-redis:6379]
|
||||||
|
|
||||||
# ── ClamAV (S-28, ADR-0036) ─────────────────────────────────────────────────
|
|
||||||
# The domain scans uploaded diplomas over clamd's INSTREAM protocol (S-29).
|
|
||||||
# First start pulls ~300 MB of signatures, so the node needs outbound internet
|
|
||||||
# (like seed-zaaktype); the data volume keeps them when persistence is on.
|
|
||||||
clamav:
|
|
||||||
image: docker.io/clamav/clamav:1.4.6
|
|
||||||
env:
|
|
||||||
CLAMD_CONF_ConcurrentDatabaseReload: "no"
|
|
||||||
ports: [{ name: clamd, port: 3310 }]
|
|
||||||
data: { mountPath: /var/lib/clamav, size: 1Gi }
|
|
||||||
probe:
|
|
||||||
exec: { command: [clamdcheck.sh] }
|
|
||||||
periodSeconds: 5
|
|
||||||
failureThreshold: 72
|
|
||||||
resources:
|
|
||||||
requests: { memory: 1200Mi }
|
|
||||||
limits: { memory: 2Gi }
|
|
||||||
|
|
||||||
# ── Bootstrap the flow, like the local compose stack does (S-B04, ADR-0020) ──
|
# ── Bootstrap the flow, like the local compose stack does (S-B04, ADR-0020) ──
|
||||||
# Seeds + publishes the BIG zaaktype through the same FQDN the ACL uses, so the
|
# Seeds + publishes the BIG zaaktype through the same FQDN the ACL uses, so the
|
||||||
# server-assigned URLs are host-consistent. The ACL then resolves them by
|
# server-assigned URLs are host-consistent. The ACL then resolves them by
|
||||||
|
|||||||
@@ -30,15 +30,6 @@ seed() { # name <kubectl --from-file args...>
|
|||||||
seed rr-oz-config --from-file="$repo/infra/openzaak/setup_configuration/"
|
seed rr-oz-config --from-file="$repo/infra/openzaak/setup_configuration/"
|
||||||
seed rr-nrc-config --from-file="$repo/infra/opennotificaties/setup_configuration/"
|
seed rr-nrc-config --from-file="$repo/infra/opennotificaties/setup_configuration/"
|
||||||
seed rr-kc-realms --from-file="$repo/infra/keycloak/realms/"
|
seed rr-kc-realms --from-file="$repo/infra/keycloak/realms/"
|
||||||
# The big-demo login theme (demo.otpAutofill). ConfigMap keys are flat, so each
|
|
||||||
# file gets a key here and its path back in the keycloak `files` in values.yaml.
|
|
||||||
theme="$repo/infra/keycloak/themes/big-demo"
|
|
||||||
seed rr-kc-theme \
|
|
||||||
--from-file=login.properties="$theme/login/theme.properties" \
|
|
||||||
--from-file=otp-autofill.js="$theme/login/resources/js/otp-autofill.js" \
|
|
||||||
--from-file=account.properties="$theme/account/theme.properties" \
|
|
||||||
--from-file=admin.properties="$theme/admin/theme.properties" \
|
|
||||||
--from-file=email.properties="$theme/email/theme.properties"
|
|
||||||
seed rr-objecttypen-config --from-file="$repo/infra/objecttypen/setup_configuration/"
|
seed rr-objecttypen-config --from-file="$repo/infra/objecttypen/setup_configuration/"
|
||||||
seed rr-objecten-config --from-file="$repo/infra/objecten/setup_configuration/"
|
seed rr-objecten-config --from-file="$repo/infra/objecten/setup_configuration/"
|
||||||
# register.py + the RegisterRecord JSON schema (the __pycache__ dir is skipped:
|
# register.py + the RegisterRecord JSON schema (the __pycache__ dir is skipped:
|
||||||
|
|||||||
@@ -1,4 +0,0 @@
|
|||||||
# The chart makes big-demo the default for every theme type, and Keycloak does not
|
|
||||||
# fall back for a type a theme lacks (the account page then fails), so each type is
|
|
||||||
# declared as a plain child of Keycloak 26's own default.
|
|
||||||
parent=keycloak.v3
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
# The chart makes big-demo the default for every theme type, and Keycloak does not
|
|
||||||
# fall back for a type a theme lacks (the admin page then fails), so each type is
|
|
||||||
# declared as a plain child of Keycloak 26's own default.
|
|
||||||
parent=keycloak.v2
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
# The chart makes big-demo the default for every theme type, and Keycloak does not
|
|
||||||
# fall back for a type a theme lacks (the email page then fails), so each type is
|
|
||||||
# declared as a plain child of Keycloak 26's own default.
|
|
||||||
parent=keycloak
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
// RFC 6238 with Keycloak's default policy (HmacSHA1, 6 digits, 30 s) over the
|
|
||||||
// raw bytes of the medewerker fixture secret — same as tests/e2e/keycloak-login.ts.
|
|
||||||
document.addEventListener('DOMContentLoaded', async () => {
|
|
||||||
const input = document.querySelector('input[name="otp"]');
|
|
||||||
if (!input || !input.form) return;
|
|
||||||
const key = await crypto.subtle.importKey('raw',
|
|
||||||
new TextEncoder().encode('BIGMEDEWERKEROTPSEED'), { name: 'HMAC', hash: 'SHA-1' }, false, ['sign']);
|
|
||||||
// A code is single-use, so a second login in the same window spends the next
|
|
||||||
// counter (Keycloak's look-ahead accepts it). Past that, fill but don't submit,
|
|
||||||
// so a rejected code can't turn into a submit loop.
|
|
||||||
const now = Math.floor(Date.now() / 30000);
|
|
||||||
let last = -1;
|
|
||||||
try { last = Number(sessionStorage.getItem('big-otp-counter')) || -1; } catch {}
|
|
||||||
const counter = Math.max(now, last + 1);
|
|
||||||
const msg = new DataView(new ArrayBuffer(8));
|
|
||||||
msg.setBigUint64(0, BigInt(counter));
|
|
||||||
const mac = new Uint8Array(await crypto.subtle.sign('HMAC', key, msg.buffer));
|
|
||||||
const o = mac[19] & 0x0f;
|
|
||||||
const n = ((mac[o] & 0x7f) << 24 | mac[o + 1] << 16 | mac[o + 2] << 8 | mac[o + 3]) % 1e6;
|
|
||||||
input.value = String(n).padStart(6, '0');
|
|
||||||
if (counter > now + 1) return;
|
|
||||||
try { sessionStorage.setItem('big-otp-counter', String(counter)); } catch {}
|
|
||||||
input.form.requestSubmit();
|
|
||||||
});
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
# Demo login theme for the public Talos deployment: keycloak.v2 plus a script that
|
|
||||||
# fills in and submits the medewerker OTP step from the committed fixture secret
|
|
||||||
# (docs/runbooks/keycloak.md). Only used when the chart's demo.otpAutofill is on —
|
|
||||||
# it then becomes Keycloak's default theme. Never enable it anywhere real.
|
|
||||||
#
|
|
||||||
# Add styles, messages or template overrides here as in any Keycloak theme
|
|
||||||
# (https://www.keycloak.org/ui-customization/themes); new files must also be
|
|
||||||
# listed in infra/helm/seed-configmaps.sh and the keycloak `files` in values.yaml.
|
|
||||||
parent=keycloak.v2
|
|
||||||
import=common/keycloak
|
|
||||||
scripts=js/otp-autofill.js
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
#
|
|
||||||
# S-28 (#191): assert clamd scans over INSTREAM (EICAR → FOUND, clean → OK), against an
|
|
||||||
# ALREADY-RUNNING stack. Runs the check in a python:3-slim container on the stack network (the
|
|
||||||
# runner can't reach published ports — gitea-actions-gotchas.md §5/§6).
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
||||||
|
|
||||||
av="$(docker ps -q --filter 'name=[-_]clamav[-_][0-9]+$' | head -1)"
|
|
||||||
[ -n "$av" ] || { echo "ERROR: no running clamav container — bring the stack up first" >&2; exit 1; }
|
|
||||||
net="$(docker inspect -f '{{range $k,$_ := .NetworkSettings.Networks}}{{$k}}{{"\n"}}{{end}}' "$av" | head -1)"
|
|
||||||
ip="$(docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' "$av")"
|
|
||||||
echo ">> network=$net clamav=$ip"
|
|
||||||
|
|
||||||
cid="$(docker create --network "$net" -e "CLAMAV=$ip" -e "CLAMAV_TIMEOUT=${CLAMAV_TIMEOUT:-60}" \
|
|
||||||
python:3-slim python /clamav-check.py)"
|
|
||||||
docker cp "$here/clamav-check.py" "$cid:/clamav-check.py" >/dev/null
|
|
||||||
rc=0; docker start -a "$cid" || rc=$?
|
|
||||||
docker rm -f "$cid" >/dev/null
|
|
||||||
exit $rc
|
|
||||||
@@ -59,10 +59,6 @@ export interface ProvideDocumentsRequest {
|
|||||||
contentType?: string | null;
|
contentType?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Refusal {
|
|
||||||
reason: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SubmitAccepted {
|
export interface SubmitAccepted {
|
||||||
registrationId: string;
|
registrationId: string;
|
||||||
status: string;
|
status: string;
|
||||||
|
|||||||
@@ -56,8 +56,6 @@ nav:
|
|||||||
- "ADR-0032: Werkbak live refresh": architecture/adr-0032-werkbak-live-refresh.md
|
- "ADR-0032: Werkbak live refresh": architecture/adr-0032-werkbak-live-refresh.md
|
||||||
- "ADR-0033: Kubernetes via one Helm chart": architecture/adr-0033-kubernetes-via-one-helm-chart.md
|
- "ADR-0033: Kubernetes via one Helm chart": architecture/adr-0033-kubernetes-via-one-helm-chart.md
|
||||||
- "ADR-0034: Caddy serves the portals": architecture/adr-0034-caddy-serves-the-portals.md
|
- "ADR-0034: Caddy serves the portals": architecture/adr-0034-caddy-serves-the-portals.md
|
||||||
- "ADR-0035: Public access through the labs Caddy": architecture/adr-0035-public-access-through-the-labs-caddy.md
|
|
||||||
- "ADR-0036: Scan uploads with ClamAV": architecture/adr-0036-scan-uploads-with-clamav.md
|
|
||||||
- FDS-architectuur:
|
- FDS-architectuur:
|
||||||
- Overzicht: architecture/fds/README.md
|
- Overzicht: architecture/fds/README.md
|
||||||
- Componentview (L3): architecture/fds/c4-component-view.md
|
- Componentview (L3): architecture/fds/c4-component-view.md
|
||||||
|
|||||||
@@ -1,9 +1,7 @@
|
|||||||
<Solution>
|
<Solution>
|
||||||
<!-- Stryker-only. Acl.IntegrationTests is left out on purpose: it needs a live
|
|
||||||
OpenZaak, so in the mutation job it fails its initial run (#174). The root
|
|
||||||
register-referentie.slnx still builds and lints it. -->
|
|
||||||
<Project Path="Acl.Api/Acl.Api.csproj" />
|
<Project Path="Acl.Api/Acl.Api.csproj" />
|
||||||
<Project Path="Acl.Application/Acl.Application.csproj" />
|
<Project Path="Acl.Application/Acl.Application.csproj" />
|
||||||
<Project Path="Acl.Infrastructure/Acl.Infrastructure.csproj" />
|
<Project Path="Acl.Infrastructure/Acl.Infrastructure.csproj" />
|
||||||
|
<Project Path="Acl.IntegrationTests/Acl.IntegrationTests.csproj" />
|
||||||
<Project Path="Acl.Tests/Acl.Tests.csproj" />
|
<Project Path="Acl.Tests/Acl.Tests.csproj" />
|
||||||
</Solution>
|
</Solution>
|
||||||
|
|||||||
@@ -36,9 +36,9 @@ public interface IDomainClient
|
|||||||
Task<bool> WithdrawRegistrationAsync(string registrationId, string bsn, CancellationToken ct = default);
|
Task<bool> WithdrawRegistrationAsync(string registrationId, string bsn, CancellationToken ct = default);
|
||||||
|
|
||||||
/// <summary>Provide (upload) the diploma the caller's own registration is waiting for ("documenten
|
/// <summary>Provide (upload) the diploma the caller's own registration is waiting for ("documenten
|
||||||
/// aanleveren"). The file is carried base64-encoded. Owner-scoped by <paramref name="bsn"/>. The domain
|
/// aanleveren"). The file is carried base64-encoded. Owner-scoped by <paramref name="bsn"/>. Returns
|
||||||
/// refuses a non-PDF or infected file, and an upload it could not scan (S-29, ADR-0036).</summary>
|
/// <c>false</c> when the domain reports the registration is unknown or not the caller's (404).</summary>
|
||||||
Task<ProvideDocumentsResult> ProvideDocumentsAsync(
|
Task<bool> ProvideDocumentsAsync(
|
||||||
string registrationId, string bsn, string contentBase64, string? fileName, string? contentType, CancellationToken ct = default);
|
string registrationId, string bsn, string contentBase64, string? fileName, string? contentType, CancellationToken ct = default);
|
||||||
|
|
||||||
/// <summary>The behandelaar's werkbak — registrations awaiting beoordeling.</summary>
|
/// <summary>The behandelaar's werkbak — registrations awaiting beoordeling.</summary>
|
||||||
@@ -48,12 +48,6 @@ public interface IDomainClient
|
|||||||
Task DecideAsync(string registrationId, string besluit, CancellationToken ct = default);
|
Task DecideAsync(string registrationId, string besluit, CancellationToken ct = default);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>How the domain answered a provide-documents request (S-29).</summary>
|
|
||||||
public enum ProvideDocumentsResult { Provided, NotFound, NotAPdf, Infected, ScannerUnavailable }
|
|
||||||
|
|
||||||
/// <summary>The body of a 422 provide-documents answer: why the file was refused.</summary>
|
|
||||||
public sealed record Refusal(string Reason);
|
|
||||||
|
|
||||||
/// <summary>Port to the read projection.</summary>
|
/// <summary>Port to the read projection.</summary>
|
||||||
public interface IProjectionClient
|
public interface IProjectionClient
|
||||||
{
|
{
|
||||||
@@ -122,26 +116,17 @@ public sealed class DomainClient(HttpClient http) : IDomainClient
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<ProvideDocumentsResult> ProvideDocumentsAsync(
|
public async Task<bool> ProvideDocumentsAsync(
|
||||||
string registrationId, string bsn, string contentBase64, string? fileName, string? contentType, CancellationToken ct = default)
|
string registrationId, string bsn, string contentBase64, string? fileName, string? contentType, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
using var response = await http.PostAsJsonAsync(
|
using var response = await http.PostAsJsonAsync(
|
||||||
$"registrations/{registrationId}/documents",
|
$"registrations/{registrationId}/documents",
|
||||||
new { bsn, contentBase64, fileName, contentType }, ct);
|
new { bsn, contentBase64, fileName, contentType }, ct);
|
||||||
// The domain 404s an unknown or not-owned registration, 422s a refused file with its reason and
|
// The domain 404s an unknown or not-owned registration; relay that rather than fail hard.
|
||||||
// 503s when its scanner is down (S-29); relay those rather than fail hard.
|
if (response.StatusCode == System.Net.HttpStatusCode.NotFound)
|
||||||
switch (response.StatusCode)
|
return false;
|
||||||
{
|
|
||||||
case System.Net.HttpStatusCode.NotFound:
|
|
||||||
return ProvideDocumentsResult.NotFound;
|
|
||||||
case System.Net.HttpStatusCode.ServiceUnavailable:
|
|
||||||
return ProvideDocumentsResult.ScannerUnavailable;
|
|
||||||
case System.Net.HttpStatusCode.UnprocessableEntity:
|
|
||||||
var refusal = await response.Content.ReadFromJsonAsync<Refusal>(ct);
|
|
||||||
return refusal?.Reason == "infected" ? ProvideDocumentsResult.Infected : ProvideDocumentsResult.NotAPdf;
|
|
||||||
}
|
|
||||||
response.EnsureSuccessStatusCode();
|
response.EnsureSuccessStatusCode();
|
||||||
return ProvideDocumentsResult.Provided;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<IReadOnlyList<WerkbakItem>> GetWerkbakAsync(CancellationToken ct = default)
|
public async Task<IReadOnlyList<WerkbakItem>> GetWerkbakAsync(CancellationToken ct = default)
|
||||||
|
|||||||
@@ -166,8 +166,8 @@ app.MapPost("/self-service/registrations/{id}/withdraw", async (string id, Claim
|
|||||||
// Self-service provide-documents (S-10a): the signed-in zorgprofessional supplies the documents their
|
// Self-service provide-documents (S-10a): the signed-in zorgprofessional supplies the documents their
|
||||||
// registration is waiting for ("documenten aanleveren"). The bsn comes from the DigiD token and is
|
// registration is waiting for ("documenten aanleveren"). The bsn comes from the DigiD token and is
|
||||||
// forwarded to the domain, which owner-scopes the action and completes the WachtOpDocumenten task; a
|
// forwarded to the domain, which owner-scopes the action and completes the WachtOpDocumenten task; a
|
||||||
// registration that is unknown or not the caller's comes back 404. A non-PDF or infected file is 422
|
// registration that is unknown or not the caller's comes back 404. The real file upload + ZGW storage
|
||||||
// with the reason; an unreachable scanner is 503 (S-29, ADR-0036).
|
// is S-10b — this is the trigger that unblocks the process.
|
||||||
app.MapPost("/self-service/registrations/{id}/documents", async (string id, ProvideDocumentsRequest body, ClaimsPrincipal user, IDomainClient domain, CancellationToken ct) =>
|
app.MapPost("/self-service/registrations/{id}/documents", async (string id, ProvideDocumentsRequest body, ClaimsPrincipal user, IDomainClient domain, CancellationToken ct) =>
|
||||||
{
|
{
|
||||||
var bsn = user.FindFirstValue("bsn");
|
var bsn = user.FindFirstValue("bsn");
|
||||||
@@ -177,22 +177,13 @@ app.MapPost("/self-service/registrations/{id}/documents", async (string id, Prov
|
|||||||
return Results.BadRequest("A document is required.");
|
return Results.BadRequest("A document is required.");
|
||||||
|
|
||||||
var provided = await domain.ProvideDocumentsAsync(id, bsn, body.ContentBase64, body.FileName, body.ContentType, ct);
|
var provided = await domain.ProvideDocumentsAsync(id, bsn, body.ContentBase64, body.FileName, body.ContentType, ct);
|
||||||
return provided switch
|
return provided ? Results.NoContent() : Results.NotFound();
|
||||||
{
|
|
||||||
ProvideDocumentsResult.Provided => Results.NoContent(),
|
|
||||||
ProvideDocumentsResult.NotAPdf => Results.UnprocessableEntity(new Refusal("not-a-pdf")),
|
|
||||||
ProvideDocumentsResult.Infected => Results.UnprocessableEntity(new Refusal("infected")),
|
|
||||||
ProvideDocumentsResult.ScannerUnavailable => Results.StatusCode(StatusCodes.Status503ServiceUnavailable),
|
|
||||||
_ => Results.NotFound(),
|
|
||||||
};
|
|
||||||
})
|
})
|
||||||
.RequireAuthorization()
|
.RequireAuthorization()
|
||||||
.Produces(StatusCodes.Status204NoContent)
|
.Produces(StatusCodes.Status204NoContent)
|
||||||
.Produces(StatusCodes.Status400BadRequest)
|
.Produces(StatusCodes.Status400BadRequest)
|
||||||
.Produces(StatusCodes.Status401Unauthorized)
|
.Produces(StatusCodes.Status401Unauthorized)
|
||||||
.Produces(StatusCodes.Status404NotFound)
|
.Produces(StatusCodes.Status404NotFound);
|
||||||
.Produces<Refusal>(StatusCodes.Status422UnprocessableEntity)
|
|
||||||
.Produces(StatusCodes.Status503ServiceUnavailable);
|
|
||||||
|
|
||||||
// Openbaar register: an anonymous public lookup that exposes only public-safe fields (S-09).
|
// Openbaar register: an anonymous public lookup that exposes only public-safe fields (S-09).
|
||||||
app.MapGet("/openbaar/register", async (string? q, IProjectionClient projection, CancellationToken ct) =>
|
app.MapGet("/openbaar/register", async (string? q, IProjectionClient projection, CancellationToken ct) =>
|
||||||
|
|||||||
@@ -111,13 +111,14 @@ internal sealed class FakeDomainClient : IDomainClient
|
|||||||
|
|
||||||
public (string RegistrationId, string Bsn, string ContentBase64, string? FileName, string? ContentType)? DocumentsProvidedFor { get; private set; }
|
public (string RegistrationId, string Bsn, string ContentBase64, string? FileName, string? ContentType)? DocumentsProvidedFor { get; private set; }
|
||||||
|
|
||||||
/// <summary>How the fake domain answers provide-documents. Tests set this to exercise the relay.</summary>
|
/// <summary>Whether the fake domain reports the provide-documents as done (true → 204) or
|
||||||
public ProvideDocumentsResult ProvideDocumentsResult { get; set; } = ProvideDocumentsResult.Provided;
|
/// not-found/not-owned (false → 404). Tests set this to exercise the relay.</summary>
|
||||||
|
public bool ProvideDocumentsSucceeds { get; set; } = true;
|
||||||
|
|
||||||
public Task<ProvideDocumentsResult> ProvideDocumentsAsync(string registrationId, string bsn, string contentBase64, string? fileName, string? contentType, CancellationToken ct = default)
|
public Task<bool> ProvideDocumentsAsync(string registrationId, string bsn, string contentBase64, string? fileName, string? contentType, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
DocumentsProvidedFor = (registrationId, bsn, contentBase64, fileName, contentType);
|
DocumentsProvidedFor = (registrationId, bsn, contentBase64, fileName, contentType);
|
||||||
return Task.FromResult(ProvideDocumentsResult);
|
return Task.FromResult(ProvideDocumentsSucceeds);
|
||||||
}
|
}
|
||||||
|
|
||||||
public (string RegistrationId, string Besluit)? Decided { get; private set; }
|
public (string RegistrationId, string Besluit)? Decided { get; private set; }
|
||||||
|
|||||||
@@ -1,32 +0,0 @@
|
|||||||
using System.Net;
|
|
||||||
using System.Text;
|
|
||||||
using Bff.Api;
|
|
||||||
|
|
||||||
namespace Bff.Tests;
|
|
||||||
|
|
||||||
// S-29 (#192): the BFF reads the domain's provide-documents answer — 422 carries the refusal reason,
|
|
||||||
// 503 means the scanner was down — into a result the endpoint relays.
|
|
||||||
public class DomainClientProvideDocumentsTests
|
|
||||||
{
|
|
||||||
private sealed class Reply(HttpStatusCode status, string? json) : HttpMessageHandler
|
|
||||||
{
|
|
||||||
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken ct)
|
|
||||||
=> Task.FromResult(new HttpResponseMessage(status)
|
|
||||||
{
|
|
||||||
Content = new StringContent(json ?? "", Encoding.UTF8, "application/json"),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
[Theory]
|
|
||||||
[InlineData(HttpStatusCode.NoContent, null, ProvideDocumentsResult.Provided)]
|
|
||||||
[InlineData(HttpStatusCode.NotFound, null, ProvideDocumentsResult.NotFound)]
|
|
||||||
[InlineData(HttpStatusCode.UnprocessableEntity, """{"reason":"not-a-pdf"}""", ProvideDocumentsResult.NotAPdf)]
|
|
||||||
[InlineData(HttpStatusCode.UnprocessableEntity, """{"reason":"infected"}""", ProvideDocumentsResult.Infected)]
|
|
||||||
[InlineData(HttpStatusCode.ServiceUnavailable, null, ProvideDocumentsResult.ScannerUnavailable)]
|
|
||||||
public async Task Maps_the_domain_answer_to_a_result(HttpStatusCode status, string? body, ProvideDocumentsResult expected)
|
|
||||||
{
|
|
||||||
var client = new DomainClient(new HttpClient(new Reply(status, body)) { BaseAddress = new Uri("http://domain/") });
|
|
||||||
|
|
||||||
Assert.Equal(expected, await client.ProvideDocumentsAsync("reg-1", "123456782", "JVBERi0=", null, null));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
using System.Net;
|
using System.Net;
|
||||||
using System.Net.Http.Headers;
|
using System.Net.Http.Headers;
|
||||||
using System.Net.Http.Json;
|
using System.Net.Http.Json;
|
||||||
using System.Text.Json;
|
|
||||||
using Bff.Api;
|
using Bff.Api;
|
||||||
|
|
||||||
namespace Bff.Tests;
|
namespace Bff.Tests;
|
||||||
@@ -163,39 +162,13 @@ public class SelfServiceEndpointTests
|
|||||||
public async Task Relays_not_found_providing_documents_for_an_unknown_or_not_owned_registration()
|
public async Task Relays_not_found_providing_documents_for_an_unknown_or_not_owned_registration()
|
||||||
{
|
{
|
||||||
using var factory = new BffFactory();
|
using var factory = new BffFactory();
|
||||||
factory.Domain.ProvideDocumentsResult = ProvideDocumentsResult.NotFound;
|
factory.Domain.ProvideDocumentsSucceeds = false;
|
||||||
|
|
||||||
var response = await factory.CreateClient().SendAsync(ProvideDocuments(TestTokens.Valid("123456782")));
|
var response = await factory.CreateClient().SendAsync(ProvideDocuments(TestTokens.Valid("123456782")));
|
||||||
|
|
||||||
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
|
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
|
||||||
}
|
}
|
||||||
|
|
||||||
// S-29 (#192): the domain's refusal reaches the portal — 422 with the reason it words for the citizen.
|
|
||||||
[Theory]
|
|
||||||
[InlineData(ProvideDocumentsResult.NotAPdf, "not-a-pdf")]
|
|
||||||
[InlineData(ProvideDocumentsResult.Infected, "infected")]
|
|
||||||
public async Task Relays_a_refused_document_as_unprocessable_with_its_reason(ProvideDocumentsResult result, string reason)
|
|
||||||
{
|
|
||||||
using var factory = new BffFactory();
|
|
||||||
factory.Domain.ProvideDocumentsResult = result;
|
|
||||||
|
|
||||||
var response = await factory.CreateClient().SendAsync(ProvideDocuments(TestTokens.Valid("123456782")));
|
|
||||||
|
|
||||||
Assert.Equal(HttpStatusCode.UnprocessableEntity, response.StatusCode);
|
|
||||||
Assert.Equal(reason, (await response.Content.ReadFromJsonAsync<JsonElement>()).GetProperty("reason").GetString());
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task Relays_an_unavailable_scanner_as_service_unavailable()
|
|
||||||
{
|
|
||||||
using var factory = new BffFactory();
|
|
||||||
factory.Domain.ProvideDocumentsResult = ProvideDocumentsResult.ScannerUnavailable;
|
|
||||||
|
|
||||||
var response = await factory.CreateClient().SendAsync(ProvideDocuments(TestTokens.Valid("123456782")));
|
|
||||||
|
|
||||||
Assert.Equal(HttpStatusCode.ServiceUnavailable, response.StatusCode);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static HttpRequestMessage Current(string? bearer)
|
private static HttpRequestMessage Current(string? bearer)
|
||||||
{
|
{
|
||||||
var request = new HttpRequestMessage(HttpMethod.Get, "/self-service/registrations");
|
var request = new HttpRequestMessage(HttpMethod.Get, "/self-service/registrations");
|
||||||
|
|||||||
@@ -124,19 +124,6 @@
|
|||||||
},
|
},
|
||||||
"404": {
|
"404": {
|
||||||
"description": "Not Found"
|
"description": "Not Found"
|
||||||
},
|
|
||||||
"422": {
|
|
||||||
"description": "Unprocessable Entity",
|
|
||||||
"content": {
|
|
||||||
"application/json": {
|
|
||||||
"schema": {
|
|
||||||
"$ref": "#/components/schemas/Refusal"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"503": {
|
|
||||||
"description": "Service Unavailable"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -428,17 +415,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"Refusal": {
|
|
||||||
"required": [
|
|
||||||
"reason"
|
|
||||||
],
|
|
||||||
"type": "object",
|
|
||||||
"properties": {
|
|
||||||
"reason": {
|
|
||||||
"type": "string"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"SubmitAccepted": {
|
"SubmitAccepted": {
|
||||||
"required": [
|
"required": [
|
||||||
"registrationId",
|
"registrationId",
|
||||||
|
|||||||
@@ -37,10 +37,6 @@ builder.Services.AddSingleton(sp => sp.GetRequiredService<IConfiguration>()
|
|||||||
builder.Services.AddSingleton(sp => sp.GetRequiredService<IConfiguration>()
|
builder.Services.AddSingleton(sp => sp.GetRequiredService<IConfiguration>()
|
||||||
.GetSection("Acl").Get<AclOptions>()
|
.GetSection("Acl").Get<AclOptions>()
|
||||||
?? throw new InvalidOperationException("Missing configuration section 'Acl'"));
|
?? throw new InvalidOperationException("Missing configuration section 'Acl'"));
|
||||||
// clamd defaults to the compose/chart service name; ClamAv__* overrides it (S-29, ADR-0036).
|
|
||||||
builder.Services.AddSingleton(sp => sp.GetRequiredService<IConfiguration>()
|
|
||||||
.GetSection("ClamAv").Get<ClamAvOptions>() ?? new ClamAvOptions());
|
|
||||||
builder.Services.AddSingleton<IDocumentScanner, ClamdDocumentScanner>();
|
|
||||||
|
|
||||||
// The in-memory registration store is shared between the submit endpoint and the worker (ADR-0009).
|
// The in-memory registration store is shared between the submit endpoint and the worker (ADR-0009).
|
||||||
builder.Services.AddSingleton<IRegistrationStore, InMemoryRegistrationStore>();
|
builder.Services.AddSingleton<IRegistrationStore, InMemoryRegistrationStore>();
|
||||||
@@ -162,8 +158,8 @@ app.MapPost("/registrations/{id}/withdraw", async (string id, WithdrawRequest bo
|
|||||||
// Provide documents (S-10a): the zorgprofessional supplies the documents their registration is parked
|
// Provide documents (S-10a): the zorgprofessional supplies the documents their registration is parked
|
||||||
// waiting for, completing the WachtOpDocumenten task so the process advances to beoordeling (ADR-0017).
|
// waiting for, completing the WachtOpDocumenten task so the process advances to beoordeling (ADR-0017).
|
||||||
// Owner-scoped by the caller's bsn (the BFF forwards it from the DigiD token); unknown or not-the-
|
// Owner-scoped by the caller's bsn (the BFF forwards it from the DigiD token); unknown or not-the-
|
||||||
// caller's is 404 (indistinguishable). Idempotent — completing an already-left wait is a no-op. Only a
|
// caller's is 404 (indistinguishable). Idempotent — completing an already-left wait is a no-op. The
|
||||||
// PDF that clamd scans clean is stored and unblocks the process (S-29).
|
// real file upload + ZGW storage is S-10b; this endpoint is the trigger that unblocks the process.
|
||||||
app.MapPost("/registrations/{id}/documents", async (string id, ProvideDocumentsRequest body, ProvideDocuments provide, CancellationToken ct) =>
|
app.MapPost("/registrations/{id}/documents", async (string id, ProvideDocumentsRequest body, ProvideDocuments provide, CancellationToken ct) =>
|
||||||
{
|
{
|
||||||
if (!Guid.TryParse(id, out var guid))
|
if (!Guid.TryParse(id, out var guid))
|
||||||
@@ -181,16 +177,8 @@ app.MapPost("/registrations/{id}/documents", async (string id, ProvideDocumentsR
|
|||||||
var command = new ProvideDocumentsCommand(
|
var command = new ProvideDocumentsCommand(
|
||||||
new RegistrationId(guid), body.Bsn, content,
|
new RegistrationId(guid), body.Bsn, content,
|
||||||
body.FileName ?? "diploma.pdf", body.ContentType ?? "application/pdf");
|
body.FileName ?? "diploma.pdf", body.ContentType ?? "application/pdf");
|
||||||
// A refused file is 422 with a machine-readable reason the portal words for the citizen; an
|
var outcome = await provide.HandleAsync(command, ct);
|
||||||
// unreachable scanner is 503 — retryable, and nothing was stored (S-29, ADR-0036).
|
return outcome == ProvideDocumentsOutcome.Accepted ? Results.NoContent() : Results.NotFound();
|
||||||
return await provide.HandleAsync(command, ct) switch
|
|
||||||
{
|
|
||||||
ProvideDocumentsOutcome.Accepted => Results.NoContent(),
|
|
||||||
ProvideDocumentsOutcome.NotAPdf => Results.UnprocessableEntity(new { reason = "not-a-pdf" }),
|
|
||||||
ProvideDocumentsOutcome.Infected => Results.UnprocessableEntity(new { reason = "infected" }),
|
|
||||||
ProvideDocumentsOutcome.ScannerUnavailable => Results.StatusCode(StatusCodes.Status503ServiceUnavailable),
|
|
||||||
_ => Results.NotFound(),
|
|
||||||
};
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// The behandelaar's werkbak (S-12): the registrations awaiting beoordeling, read from the open
|
// The behandelaar's werkbak (S-12): the registrations awaiting beoordeling, read from the open
|
||||||
|
|||||||
@@ -136,22 +136,3 @@ public sealed record EscalatieJob(string JobId, string ProcessInstanceId);
|
|||||||
/// cancels the case (ADR-0017).
|
/// cancels the case (ADR-0017).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed record RegistratieVerlopenJob(string JobId, RegistrationId RegistrationId);
|
public sealed record RegistratieVerlopenJob(string JobId, RegistrationId RegistrationId);
|
||||||
|
|
||||||
/// <summary>What a malware scan of an uploaded document found (S-29, ADR-0036).</summary>
|
|
||||||
public enum ScanVerdict
|
|
||||||
{
|
|
||||||
Clean,
|
|
||||||
Infected,
|
|
||||||
|
|
||||||
/// <summary>The scanner could not be reached or did not answer — the upload is refused (fail closed).</summary>
|
|
||||||
Unavailable,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// The port to the malware scanner (S-29, ADR-0036). Implemented in Infrastructure over clamd's INSTREAM
|
|
||||||
/// protocol. Never throws for a scanner outage: an unreachable scanner is <see cref="ScanVerdict.Unavailable"/>.
|
|
||||||
/// </summary>
|
|
||||||
public interface IDocumentScanner
|
|
||||||
{
|
|
||||||
Task<ScanVerdict> ScanAsync(byte[] content, CancellationToken ct = default);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -18,26 +18,17 @@ public enum ProvideDocumentsOutcome
|
|||||||
/// <summary>No registration with that id belongs to the caller — unknown, or owned by someone else
|
/// <summary>No registration with that id belongs to the caller — unknown, or owned by someone else
|
||||||
/// (the two are deliberately indistinguishable, so the endpoint reveals neither).</summary>
|
/// (the two are deliberately indistinguishable, so the endpoint reveals neither).</summary>
|
||||||
NotFound,
|
NotFound,
|
||||||
|
|
||||||
/// <summary>The file does not start with the PDF signature (<c>%PDF-</c>); nothing was stored.</summary>
|
|
||||||
NotAPdf,
|
|
||||||
|
|
||||||
/// <summary>The malware scan found something; nothing was stored and the wait stays open.</summary>
|
|
||||||
Infected,
|
|
||||||
|
|
||||||
/// <summary>The scanner could not be reached — refused rather than storing an unscanned file.</summary>
|
|
||||||
ScannerUnavailable,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The provide-documents use case (S-10a/S-10b): a zorgprofessional uploads the diploma their
|
/// The provide-documents use case (S-10a/S-10b): a zorgprofessional uploads the diploma their
|
||||||
/// registration is parked waiting for. Only a PDF that scans clean is accepted (S-29, ADR-0036). The document is stored in ZGW via the ACL (§8.1), then the
|
/// registration is parked waiting for. The document is stored in ZGW via the ACL (§8.1), then the
|
||||||
/// WachtOpDocumenten task is completed so the registratie process leaves the 30-day wait and continues
|
/// WachtOpDocumenten task is completed so the registratie process leaves the 30-day wait and continues
|
||||||
/// to beoordeling (ADR-0017). Owner-scoped by bsn. Both steps are best-effort about missing preconditions
|
/// to beoordeling (ADR-0017). Owner-scoped by bsn. Both steps are best-effort about missing preconditions
|
||||||
/// (mirroring <see cref="WithdrawRegistration"/>): storage needs an opened zaak, and completion needs a
|
/// (mirroring <see cref="WithdrawRegistration"/>): storage needs an opened zaak, and completion needs a
|
||||||
/// running process — a request that arrives before either still stands, storing/completing what it can.
|
/// running process — a request that arrives before either still stands, storing/completing what it can.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class ProvideDocuments(IRegistrationStore store, IWorkflowClient workflow, IAclClient acl, IDocumentScanner scanner)
|
public sealed class ProvideDocuments(IRegistrationStore store, IWorkflowClient workflow, IAclClient acl)
|
||||||
{
|
{
|
||||||
public async Task<ProvideDocumentsOutcome> HandleAsync(ProvideDocumentsCommand command, CancellationToken ct = default)
|
public async Task<ProvideDocumentsOutcome> HandleAsync(ProvideDocumentsCommand command, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
@@ -49,18 +40,6 @@ public sealed class ProvideDocuments(IRegistrationStore store, IWorkflowClient w
|
|||||||
if (registration is null || registration.Bsn != command.Bsn)
|
if (registration is null || registration.Bsn != command.Bsn)
|
||||||
return ProvideDocumentsOutcome.NotFound;
|
return ProvideDocumentsOutcome.NotFound;
|
||||||
|
|
||||||
// Only a clean PDF goes any further (S-29, ADR-0036): checked after ownership, so a stranger
|
|
||||||
// learns nothing about the file, and before anything is stored or the wait is completed. Scan
|
|
||||||
// before the PDF check, so malware is reported as malware whatever it claims to be.
|
|
||||||
switch (await scanner.ScanAsync(command.Content, ct))
|
|
||||||
{
|
|
||||||
case ScanVerdict.Infected: return ProvideDocumentsOutcome.Infected;
|
|
||||||
case ScanVerdict.Unavailable: return ProvideDocumentsOutcome.ScannerUnavailable;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!command.Content.AsSpan().StartsWith("%PDF-"u8))
|
|
||||||
return ProvideDocumentsOutcome.NotAPdf;
|
|
||||||
|
|
||||||
// Store the diploma against the zaak (once it is opened) — the ACL is the only ZGW caller (§8.1).
|
// Store the diploma against the zaak (once it is opened) — the ACL is the only ZGW caller (§8.1).
|
||||||
if (registration.ZaakUrl is not null)
|
if (registration.ZaakUrl is not null)
|
||||||
await acl.StoreDiplomaAsync(
|
await acl.StoreDiplomaAsync(
|
||||||
|
|||||||
@@ -1,48 +0,0 @@
|
|||||||
using System.Buffers.Binary;
|
|
||||||
using System.Net.Sockets;
|
|
||||||
using System.Text;
|
|
||||||
using Big.Application;
|
|
||||||
|
|
||||||
namespace Big.Infrastructure;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Scans a document with clamd over its INSTREAM protocol (S-29, ADR-0036): <c>zINSTREAM\0</c>, the
|
|
||||||
/// document as one big-endian length-prefixed chunk, a zero-length terminator, then one reply —
|
|
||||||
/// <c>stream: OK</c> or <c>stream: <signature> FOUND</c>. Anything else (an ERROR reply, a refused
|
|
||||||
/// connection, a timeout) is <see cref="ScanVerdict.Unavailable"/>, so the caller fails closed.
|
|
||||||
/// </summary>
|
|
||||||
public sealed class ClamdDocumentScanner(ClamAvOptions options) : IDocumentScanner
|
|
||||||
{
|
|
||||||
public async Task<ScanVerdict> ScanAsync(byte[] content, CancellationToken ct = default)
|
|
||||||
{
|
|
||||||
ArgumentNullException.ThrowIfNull(content);
|
|
||||||
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
|
||||||
timeout.CancelAfter(options.Timeout);
|
|
||||||
|
|
||||||
string reply;
|
|
||||||
try
|
|
||||||
{
|
|
||||||
using var client = new TcpClient();
|
|
||||||
await client.ConnectAsync(options.Host, options.Port, timeout.Token);
|
|
||||||
var stream = client.GetStream();
|
|
||||||
|
|
||||||
var length = new byte[4];
|
|
||||||
BinaryPrimitives.WriteInt32BigEndian(length, content.Length);
|
|
||||||
await stream.WriteAsync("zINSTREAM\0"u8.ToArray(), timeout.Token);
|
|
||||||
await stream.WriteAsync(length, timeout.Token);
|
|
||||||
await stream.WriteAsync(content, timeout.Token);
|
|
||||||
await stream.WriteAsync(new byte[4], timeout.Token);
|
|
||||||
|
|
||||||
using var reader = new StreamReader(stream, Encoding.ASCII);
|
|
||||||
reply = (await reader.ReadToEndAsync(timeout.Token)).TrimEnd('\0', '\n');
|
|
||||||
}
|
|
||||||
catch (Exception e) when (e is SocketException or IOException
|
|
||||||
|| (e is OperationCanceledException && !ct.IsCancellationRequested))
|
|
||||||
{
|
|
||||||
return ScanVerdict.Unavailable;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (reply == "stream: OK") return ScanVerdict.Clean;
|
|
||||||
return reply.EndsWith(" FOUND", StringComparison.Ordinal) ? ScanVerdict.Infected : ScanVerdict.Unavailable;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -27,12 +27,3 @@ public sealed class AclOptions
|
|||||||
{
|
{
|
||||||
public Uri BaseUrl { get; set; } = null!;
|
public Uri BaseUrl { get; set; } = null!;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Where clamd listens (S-29, ADR-0036). <see cref="Timeout"/> bounds one whole scan; a scan that
|
|
||||||
/// takes longer counts as the scanner being unavailable.</summary>
|
|
||||||
public sealed class ClamAvOptions
|
|
||||||
{
|
|
||||||
public string Host { get; set; } = "clamav";
|
|
||||||
public int Port { get; set; } = 3310;
|
|
||||||
public TimeSpan Timeout { get; set; } = TimeSpan.FromSeconds(30);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,117 +0,0 @@
|
|||||||
using System.Net;
|
|
||||||
using System.Net.Sockets;
|
|
||||||
using Big.Application;
|
|
||||||
using Big.Infrastructure;
|
|
||||||
|
|
||||||
namespace Big.Tests;
|
|
||||||
|
|
||||||
// S-29 (#192, ADR-0036): the clamd INSTREAM adapter, against a fake clamd on a loopback socket. The live
|
|
||||||
// engine (EICAR → FOUND) is verified by verify-clamav.
|
|
||||||
public class ClamdDocumentScannerTests
|
|
||||||
{
|
|
||||||
/// <summary>A one-shot fake clamd: reads one INSTREAM request to its zero-length terminator,
|
|
||||||
/// records it, and answers <paramref name="reply"/>.</summary>
|
|
||||||
private sealed class FakeClamd : IDisposable
|
|
||||||
{
|
|
||||||
private readonly TcpListener _listener = new(IPAddress.Loopback, 0);
|
|
||||||
public Task<byte[]> Received { get; }
|
|
||||||
public int Port => ((IPEndPoint)_listener.LocalEndpoint).Port;
|
|
||||||
|
|
||||||
/// <param name="reply">The answer, or null to accept the request and never answer (a hung clamd).</param>
|
|
||||||
public FakeClamd(string? reply)
|
|
||||||
{
|
|
||||||
_listener.Start();
|
|
||||||
Received = Serve(reply);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task<byte[]> Serve(string? reply)
|
|
||||||
{
|
|
||||||
using var client = await _listener.AcceptTcpClientAsync();
|
|
||||||
var stream = client.GetStream();
|
|
||||||
var received = new MemoryStream();
|
|
||||||
var buffer = new byte[4096];
|
|
||||||
while (!EndsWithTerminator(received))
|
|
||||||
{
|
|
||||||
var n = await stream.ReadAsync(buffer);
|
|
||||||
if (n == 0) break;
|
|
||||||
received.Write(buffer, 0, n);
|
|
||||||
}
|
|
||||||
if (reply is null)
|
|
||||||
await Task.Delay(TimeSpan.FromSeconds(10)); // long past any test timeout
|
|
||||||
else
|
|
||||||
await stream.WriteAsync(System.Text.Encoding.ASCII.GetBytes(reply + "\0"));
|
|
||||||
return received.ToArray();
|
|
||||||
}
|
|
||||||
|
|
||||||
// The request is "zINSTREAM\0" + chunks + a 4-byte zero length; it is complete once it ends in it.
|
|
||||||
private static bool EndsWithTerminator(MemoryStream s)
|
|
||||||
=> s.Length > 14 && s.ToArray()[^4..].All(b => b == 0);
|
|
||||||
|
|
||||||
public void Dispose() => _listener.Stop();
|
|
||||||
}
|
|
||||||
|
|
||||||
private static ClamdDocumentScanner ScannerFor(int port) =>
|
|
||||||
new(new ClamAvOptions { Host = "127.0.0.1", Port = port, Timeout = TimeSpan.FromSeconds(5) });
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task Sends_the_document_as_one_length_prefixed_instream_chunk()
|
|
||||||
{
|
|
||||||
using var clamd = new FakeClamd("stream: OK");
|
|
||||||
|
|
||||||
await ScannerFor(clamd.Port).ScanAsync([1, 2, 3]);
|
|
||||||
|
|
||||||
Assert.Equal("zINSTREAM\0"u8.ToArray().Concat(new byte[] { 0, 0, 0, 3, 1, 2, 3, 0, 0, 0, 0 }),
|
|
||||||
await clamd.Received.WaitAsync(TimeSpan.FromSeconds(5)));
|
|
||||||
}
|
|
||||||
|
|
||||||
[Theory]
|
|
||||||
[InlineData("stream: OK", ScanVerdict.Clean)]
|
|
||||||
[InlineData("stream: Eicar-Test-Signature FOUND", ScanVerdict.Infected)]
|
|
||||||
[InlineData("INSTREAM size limit exceeded. ERROR", ScanVerdict.Unavailable)]
|
|
||||||
public async Task Maps_the_clamd_reply_to_a_verdict(string reply, ScanVerdict expected)
|
|
||||||
{
|
|
||||||
using var clamd = new FakeClamd(reply);
|
|
||||||
|
|
||||||
Assert.Equal(expected, await ScannerFor(clamd.Port).ScanAsync([1, 2, 3]));
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task An_unreachable_clamd_is_unavailable_not_an_exception()
|
|
||||||
{
|
|
||||||
// Grab a free port, then close it, so nothing listens there.
|
|
||||||
var listener = new TcpListener(IPAddress.Loopback, 0);
|
|
||||||
listener.Start();
|
|
||||||
var port = ((IPEndPoint)listener.LocalEndpoint).Port;
|
|
||||||
listener.Stop();
|
|
||||||
|
|
||||||
Assert.Equal(ScanVerdict.Unavailable, await ScannerFor(port).ScanAsync([1, 2, 3]));
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task A_clamd_that_never_answers_is_unavailable_after_the_timeout()
|
|
||||||
{
|
|
||||||
using var clamd = new FakeClamd(reply: null);
|
|
||||||
var scanner = new ClamdDocumentScanner(
|
|
||||||
new ClamAvOptions { Host = "127.0.0.1", Port = clamd.Port, Timeout = TimeSpan.FromMilliseconds(300) });
|
|
||||||
|
|
||||||
Assert.Equal(ScanVerdict.Unavailable, await scanner.ScanAsync([1, 2, 3]));
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task A_cancelled_request_is_cancelled_not_reported_unavailable()
|
|
||||||
{
|
|
||||||
// The caller giving up is not a scanner outage: it propagates instead of becoming a 503.
|
|
||||||
using var clamd = new FakeClamd(reply: null);
|
|
||||||
using var cts = new CancellationTokenSource(TimeSpan.FromMilliseconds(300));
|
|
||||||
|
|
||||||
await Assert.ThrowsAnyAsync<OperationCanceledException>(() => ScannerFor(clamd.Port).ScanAsync([1, 2, 3], cts.Token));
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task Rejects_null_content()
|
|
||||||
=> await Assert.ThrowsAsync<ArgumentNullException>(() => ScannerFor(1).ScanAsync(null!));
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void Defaults_to_the_clamav_service_on_the_clamd_port()
|
|
||||||
=> Assert.Equal(("clamav", 3310), (new ClamAvOptions().Host, new ClamAvOptions().Port));
|
|
||||||
}
|
|
||||||
@@ -146,14 +146,3 @@ internal sealed class FakeAclClient(Uri? zaakUrl = null) : IAclClient
|
|||||||
return Task.CompletedTask;
|
return Task.CompletedTask;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
internal sealed class FakeDocumentScanner(ScanVerdict verdict = ScanVerdict.Clean) : IDocumentScanner
|
|
||||||
{
|
|
||||||
public byte[]? Scanned { get; private set; }
|
|
||||||
|
|
||||||
public Task<ScanVerdict> ScanAsync(byte[] content, CancellationToken ct = default)
|
|
||||||
{
|
|
||||||
Scanned = content;
|
|
||||||
return Task.FromResult(verdict);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -20,10 +20,8 @@ public class ProvideDocumentsTests
|
|||||||
return registration;
|
return registration;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static readonly byte[] Pdf = "%PDF-1.4 diploma"u8.ToArray();
|
private static ProvideDocumentsCommand Command(RegistrationId id, string bsn = Bsn) =>
|
||||||
|
new(id, bsn, [1, 2, 3], "diploma.pdf", "application/pdf");
|
||||||
private static ProvideDocumentsCommand Command(RegistrationId id, string bsn = Bsn, byte[]? content = null) =>
|
|
||||||
new(id, bsn, content ?? Pdf, "diploma.pdf", "application/pdf");
|
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Providing_documents_stores_the_diploma_and_completes_the_wait()
|
public async Task Providing_documents_stores_the_diploma_and_completes_the_wait()
|
||||||
@@ -33,13 +31,13 @@ public class ProvideDocumentsTests
|
|||||||
store.Seed(registration);
|
store.Seed(registration);
|
||||||
var workflow = new FakeWorkflowClient();
|
var workflow = new FakeWorkflowClient();
|
||||||
var acl = new FakeAclClient();
|
var acl = new FakeAclClient();
|
||||||
var handler = new ProvideDocuments(store, workflow, acl, new FakeDocumentScanner());
|
var handler = new ProvideDocuments(store, workflow, acl);
|
||||||
|
|
||||||
var outcome = await handler.HandleAsync(Command(registration.Id));
|
var outcome = await handler.HandleAsync(Command(registration.Id));
|
||||||
|
|
||||||
Assert.Equal(ProvideDocumentsOutcome.Accepted, outcome);
|
Assert.Equal(ProvideDocumentsOutcome.Accepted, outcome);
|
||||||
// Stored against the registration's zaak, carrying the uploaded bytes + file metadata.
|
// Stored against the registration's zaak, carrying the uploaded bytes + file metadata.
|
||||||
Assert.Equal((Zaak, Pdf, "diploma.pdf", "application/pdf"), acl.StoredDiploma);
|
Assert.Equal((Zaak, new byte[] { 1, 2, 3 }, "diploma.pdf", "application/pdf"), acl.StoredDiploma);
|
||||||
// …and the wait is completed so beoordeling can proceed.
|
// …and the wait is completed so beoordeling can proceed.
|
||||||
Assert.Equal("proc-42", workflow.CompletedDocumentWaitFor);
|
Assert.Equal("proc-42", workflow.CompletedDocumentWaitFor);
|
||||||
}
|
}
|
||||||
@@ -53,7 +51,7 @@ public class ProvideDocumentsTests
|
|||||||
store.Seed(registration);
|
store.Seed(registration);
|
||||||
var workflow = new FakeWorkflowClient();
|
var workflow = new FakeWorkflowClient();
|
||||||
var acl = new FakeAclClient();
|
var acl = new FakeAclClient();
|
||||||
var handler = new ProvideDocuments(store, workflow, acl, new FakeDocumentScanner());
|
var handler = new ProvideDocuments(store, workflow, acl);
|
||||||
|
|
||||||
var outcome = await handler.HandleAsync(Command(registration.Id, bsn: "999999990"));
|
var outcome = await handler.HandleAsync(Command(registration.Id, bsn: "999999990"));
|
||||||
|
|
||||||
@@ -66,7 +64,7 @@ public class ProvideDocumentsTests
|
|||||||
public async Task Providing_for_an_unknown_registration_is_not_found()
|
public async Task Providing_for_an_unknown_registration_is_not_found()
|
||||||
{
|
{
|
||||||
var store = new FakeRegistrationStore();
|
var store = new FakeRegistrationStore();
|
||||||
var handler = new ProvideDocuments(store, new FakeWorkflowClient(), new FakeAclClient(), new FakeDocumentScanner());
|
var handler = new ProvideDocuments(store, new FakeWorkflowClient(), new FakeAclClient());
|
||||||
|
|
||||||
Assert.Equal(ProvideDocumentsOutcome.NotFound, await handler.HandleAsync(Command(RegistrationId.New())));
|
Assert.Equal(ProvideDocumentsOutcome.NotFound, await handler.HandleAsync(Command(RegistrationId.New())));
|
||||||
}
|
}
|
||||||
@@ -82,7 +80,7 @@ public class ProvideDocumentsTests
|
|||||||
store.Seed(registration);
|
store.Seed(registration);
|
||||||
var workflow = new FakeWorkflowClient();
|
var workflow = new FakeWorkflowClient();
|
||||||
var acl = new FakeAclClient();
|
var acl = new FakeAclClient();
|
||||||
var handler = new ProvideDocuments(store, workflow, acl, new FakeDocumentScanner());
|
var handler = new ProvideDocuments(store, workflow, acl);
|
||||||
|
|
||||||
var outcome = await handler.HandleAsync(Command(registration.Id));
|
var outcome = await handler.HandleAsync(Command(registration.Id));
|
||||||
|
|
||||||
@@ -91,92 +89,8 @@ public class ProvideDocumentsTests
|
|||||||
Assert.Equal("proc-9", workflow.CompletedDocumentWaitFor);
|
Assert.Equal("proc-9", workflow.CompletedDocumentWaitFor);
|
||||||
}
|
}
|
||||||
|
|
||||||
// S-29 (#192, ADR-0036): only a clean PDF is stored and unblocks beoordeling.
|
|
||||||
[Theory]
|
|
||||||
[InlineData(ScanVerdict.Infected, ProvideDocumentsOutcome.Infected)]
|
|
||||||
[InlineData(ScanVerdict.Unavailable, ProvideDocumentsOutcome.ScannerUnavailable)]
|
|
||||||
public async Task A_document_that_does_not_scan_clean_is_refused_and_the_wait_stays_open(
|
|
||||||
ScanVerdict verdict, ProvideDocumentsOutcome expected)
|
|
||||||
{
|
|
||||||
var store = new FakeRegistrationStore();
|
|
||||||
var registration = Submitted();
|
|
||||||
store.Seed(registration);
|
|
||||||
var workflow = new FakeWorkflowClient();
|
|
||||||
var acl = new FakeAclClient();
|
|
||||||
var handler = new ProvideDocuments(store, workflow, acl, new FakeDocumentScanner(verdict));
|
|
||||||
|
|
||||||
var outcome = await handler.HandleAsync(Command(registration.Id));
|
|
||||||
|
|
||||||
Assert.Equal(expected, outcome);
|
|
||||||
Assert.Null(acl.StoredDiploma);
|
|
||||||
Assert.Null(workflow.CompletedDocumentWaitFor);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task A_clean_file_that_is_not_a_pdf_is_refused()
|
|
||||||
{
|
|
||||||
var store = new FakeRegistrationStore();
|
|
||||||
var registration = Submitted();
|
|
||||||
store.Seed(registration);
|
|
||||||
var workflow = new FakeWorkflowClient();
|
|
||||||
var acl = new FakeAclClient();
|
|
||||||
var handler = new ProvideDocuments(store, workflow, acl, new FakeDocumentScanner());
|
|
||||||
|
|
||||||
var outcome = await handler.HandleAsync(Command(registration.Id, content: "MZ not a pdf"u8.ToArray()));
|
|
||||||
|
|
||||||
Assert.Equal(ProvideDocumentsOutcome.NotAPdf, outcome);
|
|
||||||
Assert.Null(acl.StoredDiploma);
|
|
||||||
Assert.Null(workflow.CompletedDocumentWaitFor);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task Malware_is_reported_as_infected_even_when_it_is_not_a_pdf()
|
|
||||||
{
|
|
||||||
// Scan first: clamd matches EICAR (and much real malware) only at the start of a file, so a file
|
|
||||||
// that fails the PDF check must still be scanned, and the citizen told it is infected.
|
|
||||||
var store = new FakeRegistrationStore();
|
|
||||||
var registration = Submitted();
|
|
||||||
store.Seed(registration);
|
|
||||||
var handler = new ProvideDocuments(store, new FakeWorkflowClient(), new FakeAclClient(),
|
|
||||||
new FakeDocumentScanner(ScanVerdict.Infected));
|
|
||||||
|
|
||||||
var outcome = await handler.HandleAsync(Command(registration.Id, content: "X5O!P not a pdf"u8.ToArray()));
|
|
||||||
|
|
||||||
Assert.Equal(ProvideDocumentsOutcome.Infected, outcome);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task A_clean_pdf_is_scanned_before_it_is_stored()
|
|
||||||
{
|
|
||||||
var store = new FakeRegistrationStore();
|
|
||||||
var registration = Submitted();
|
|
||||||
store.Seed(registration);
|
|
||||||
var scanner = new FakeDocumentScanner();
|
|
||||||
var handler = new ProvideDocuments(store, new FakeWorkflowClient(), new FakeAclClient(), scanner);
|
|
||||||
|
|
||||||
await handler.HandleAsync(Command(registration.Id));
|
|
||||||
|
|
||||||
Assert.Equal(Pdf, scanner.Scanned);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task A_different_bsn_learns_nothing_about_the_scan()
|
|
||||||
{
|
|
||||||
// Ownership is checked first: someone else's registration is NotFound even for an infected file.
|
|
||||||
var store = new FakeRegistrationStore();
|
|
||||||
var registration = Submitted();
|
|
||||||
store.Seed(registration);
|
|
||||||
var scanner = new FakeDocumentScanner(ScanVerdict.Infected);
|
|
||||||
var handler = new ProvideDocuments(store, new FakeWorkflowClient(), new FakeAclClient(), scanner);
|
|
||||||
|
|
||||||
var outcome = await handler.HandleAsync(Command(registration.Id, bsn: "999999990"));
|
|
||||||
|
|
||||||
Assert.Equal(ProvideDocumentsOutcome.NotFound, outcome);
|
|
||||||
Assert.Null(scanner.Scanned);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Rejects_a_null_command()
|
public async Task Rejects_a_null_command()
|
||||||
=> await Assert.ThrowsAsync<ArgumentNullException>(() =>
|
=> await Assert.ThrowsAsync<ArgumentNullException>(() =>
|
||||||
new ProvideDocuments(new FakeRegistrationStore(), new FakeWorkflowClient(), new FakeAclClient(), new FakeDocumentScanner()).HandleAsync(null!));
|
new ProvideDocuments(new FakeRegistrationStore(), new FakeWorkflowClient(), new FakeAclClient()).HandleAsync(null!));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,36 +0,0 @@
|
|||||||
# language: en
|
|
||||||
# Drives S-29 (#192, ADR-0036). A zorgprofessional uploads the diploma their registration waits for
|
|
||||||
# ("documenten aanleveren"). Only a clean PDF is stored against the zaak and unblocks beoordeling; an
|
|
||||||
# infected file, a non-PDF, or an unreachable scanner is refused and the registration keeps waiting.
|
|
||||||
# Exercised against in-memory ports; the live clamd scan is verified by verify-clamav.
|
|
||||||
Feature: Een diploma aanleveren
|
|
||||||
Als BIG-register wil ik alleen veilige PDF-diploma's opslaan
|
|
||||||
zodat een behandelaar nooit een besmet bestand opent.
|
|
||||||
|
|
||||||
Scenario: Een schoon PDF-diploma wordt opgeslagen
|
|
||||||
Given a registration waiting for documents
|
|
||||||
When the zorgprofessional uploads a clean PDF diploma
|
|
||||||
Then the upload is accepted
|
|
||||||
And the diploma is stored against the zaak
|
|
||||||
And the registration no longer waits for documents
|
|
||||||
|
|
||||||
Scenario: Een besmet diploma wordt geweigerd
|
|
||||||
Given a registration waiting for documents
|
|
||||||
When the zorgprofessional uploads a PDF that the scanner reports infected
|
|
||||||
Then the upload is refused as "Infected"
|
|
||||||
And no document is stored against the zaak
|
|
||||||
And the registration still waits for documents
|
|
||||||
|
|
||||||
Scenario: Een bestand dat geen PDF is wordt geweigerd
|
|
||||||
Given a registration waiting for documents
|
|
||||||
When the zorgprofessional uploads a file that is not a PDF
|
|
||||||
Then the upload is refused as "NotAPdf"
|
|
||||||
And no document is stored against the zaak
|
|
||||||
And the registration still waits for documents
|
|
||||||
|
|
||||||
Scenario: De virusscanner is niet bereikbaar
|
|
||||||
Given a registration waiting for documents
|
|
||||||
When the zorgprofessional uploads a PDF while the scanner is unavailable
|
|
||||||
Then the upload is refused as "ScannerUnavailable"
|
|
||||||
And no document is stored against the zaak
|
|
||||||
And the registration still waits for documents
|
|
||||||
@@ -1,68 +0,0 @@
|
|||||||
using Acceptance.Support;
|
|
||||||
using Big.Application;
|
|
||||||
using Big.Domain;
|
|
||||||
using Reqnroll;
|
|
||||||
using Xunit;
|
|
||||||
|
|
||||||
namespace Acceptance.Steps;
|
|
||||||
|
|
||||||
/// <summary>Bindings for <c>EenDiplomaAanleveren.feature</c> (S-29). Submits a registration, attaches
|
|
||||||
/// its zaak, then applies the ProvideDocuments use case with a scanner stand-in that returns the verdict
|
|
||||||
/// the scenario names; one instance per scenario.</summary>
|
|
||||||
[Binding]
|
|
||||||
[Scope(Feature = "Een diploma aanleveren")]
|
|
||||||
public sealed class EenDiplomaAanleverenSteps
|
|
||||||
{
|
|
||||||
private const string OwnerBsn = "123456782";
|
|
||||||
private static readonly byte[] Pdf = "%PDF-1.4 diploma"u8.ToArray();
|
|
||||||
|
|
||||||
private readonly InMemoryRegistrationStore _store = new();
|
|
||||||
private readonly InMemoryWorkflowClient _workflow = new();
|
|
||||||
private readonly InMemoryAclClient _acl = new();
|
|
||||||
private RegistrationId _id;
|
|
||||||
private ProvideDocumentsOutcome _outcome;
|
|
||||||
|
|
||||||
[Given("a registration waiting for documents")]
|
|
||||||
public async Task GivenARegistrationWaitingForDocuments()
|
|
||||||
{
|
|
||||||
_id = await new SubmitRegistration(_store, _workflow).HandleAsync(new SubmitRegistrationCommand(OwnerBsn));
|
|
||||||
var registration = (await _store.GetAsync(_id))!;
|
|
||||||
registration.AttachZaak(InMemoryAclClient.OpenedZaakUrl);
|
|
||||||
await _store.SaveAsync(registration);
|
|
||||||
}
|
|
||||||
|
|
||||||
[When("the zorgprofessional uploads a clean PDF diploma")]
|
|
||||||
public Task WhenCleanPdf() => Upload(Pdf, ScanVerdict.Clean);
|
|
||||||
|
|
||||||
[When("the zorgprofessional uploads a PDF that the scanner reports infected")]
|
|
||||||
public Task WhenInfected() => Upload(Pdf, ScanVerdict.Infected);
|
|
||||||
|
|
||||||
[When("the zorgprofessional uploads a file that is not a PDF")]
|
|
||||||
public Task WhenNotAPdf() => Upload("MZ not a pdf"u8.ToArray(), ScanVerdict.Clean);
|
|
||||||
|
|
||||||
[When("the zorgprofessional uploads a PDF while the scanner is unavailable")]
|
|
||||||
public Task WhenScannerUnavailable() => Upload(Pdf, ScanVerdict.Unavailable);
|
|
||||||
|
|
||||||
private async Task Upload(byte[] content, ScanVerdict verdict)
|
|
||||||
=> _outcome = await new ProvideDocuments(_store, _workflow, _acl, new InMemoryDocumentScanner(verdict))
|
|
||||||
.HandleAsync(new ProvideDocumentsCommand(_id, OwnerBsn, content, "diploma.pdf", "application/pdf"));
|
|
||||||
|
|
||||||
[Then("the upload is accepted")]
|
|
||||||
public void ThenAccepted() => Assert.Equal(ProvideDocumentsOutcome.Accepted, _outcome);
|
|
||||||
|
|
||||||
[Then("the upload is refused as \"(.*)\"")]
|
|
||||||
public void ThenRefusedAs(string expected) => Assert.Equal(expected, _outcome.ToString());
|
|
||||||
|
|
||||||
[Then("the diploma is stored against the zaak")]
|
|
||||||
public void ThenStored() => Assert.Equal(InMemoryAclClient.OpenedZaakUrl, _acl.StoredDiploma?.ZaakUrl);
|
|
||||||
|
|
||||||
[Then("no document is stored against the zaak")]
|
|
||||||
public void ThenNotStored() => Assert.Null(_acl.StoredDiploma);
|
|
||||||
|
|
||||||
[Then("the registration no longer waits for documents")]
|
|
||||||
public void ThenWaitCompleted()
|
|
||||||
=> Assert.Equal(InMemoryWorkflowClient.StartedProcessInstanceId, _workflow.CompletedDocumentWaitFor);
|
|
||||||
|
|
||||||
[Then("the registration still waits for documents")]
|
|
||||||
public void ThenStillWaiting() => Assert.Null(_workflow.CompletedDocumentWaitFor);
|
|
||||||
}
|
|
||||||
@@ -75,9 +75,9 @@ public sealed class CapturingDomainClient : IDomainClient
|
|||||||
public Task<bool> WithdrawRegistrationAsync(string registrationId, string bsn, CancellationToken ct = default)
|
public Task<bool> WithdrawRegistrationAsync(string registrationId, string bsn, CancellationToken ct = default)
|
||||||
=> Task.FromResult(true);
|
=> Task.FromResult(true);
|
||||||
|
|
||||||
public Task<ProvideDocumentsResult> ProvideDocumentsAsync(
|
public Task<bool> ProvideDocumentsAsync(
|
||||||
string registrationId, string bsn, string contentBase64, string? fileName, string? contentType, CancellationToken ct = default)
|
string registrationId, string bsn, string contentBase64, string? fileName, string? contentType, CancellationToken ct = default)
|
||||||
=> Task.FromResult(ProvideDocumentsResult.Provided);
|
=> Task.FromResult(true);
|
||||||
|
|
||||||
public Task<IReadOnlyList<WerkbakItem>> GetWerkbakAsync(CancellationToken ct = default)
|
public Task<IReadOnlyList<WerkbakItem>> GetWerkbakAsync(CancellationToken ct = default)
|
||||||
=> Task.FromResult<IReadOnlyList<WerkbakItem>>([]);
|
=> Task.FromResult<IReadOnlyList<WerkbakItem>>([]);
|
||||||
|
|||||||
@@ -77,13 +77,6 @@ public sealed class InMemoryAclClient : IAclClient
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>A scanner stand-in that returns the verdict the scenario names (S-29) — the live clamd
|
|
||||||
/// INSTREAM scan is verified by verify-clamav.</summary>
|
|
||||||
public sealed class InMemoryDocumentScanner(ScanVerdict verdict) : IDocumentScanner
|
|
||||||
{
|
|
||||||
public Task<ScanVerdict> ScanAsync(byte[] content, CancellationToken ct = default) => Task.FromResult(verdict);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>An in-memory user-task client for the beoordeling acceptance scenario: it holds one open
|
/// <summary>An in-memory user-task client for the beoordeling acceptance scenario: it holds one open
|
||||||
/// Beoordelen task per registration and records the besluit each is completed with.</summary>
|
/// Beoordelen task per registration and records the besluit each is completed with.</summary>
|
||||||
public sealed class InMemoryUserTaskClient : IUserTaskClient
|
public sealed class InMemoryUserTaskClient : IUserTaskClient
|
||||||
|
|||||||
Reference in New Issue
Block a user