Compare commits

...
Author SHA1 Message Date
notandClaude Opus 5.5 e39a46febc test(domain): cover clamd timeout, caller cancellation, null content and defaults (refs #192)
CI / k8s (pull_request) Successful in 18s
CI / build (pull_request) Successful in 5m23s
CI / lint (pull_request) Successful in 5m55s
CI / docs (pull_request) Successful in 1m3s
CI / unit (pull_request) Successful in 1m33s
CI / frontend (pull_request) Successful in 5m31s
CI / mutation (pull_request) Successful in 8m11s
CI / verify-stack (pull_request) Skipped
Domain mutation score 89.87% → 91.98%, back above the 90% break.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-02 09:53:23 +02:00
notandClaude Opus 5.5 940aac7d25 docs: demo note and backlog mirror for S-28/S-29 (refs #192)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-02 09:53:23 +02:00
notandClaude Opus 5.5 4e7e0526b2 feat(domain): scan before the PDF check so malware is always reported as infected (refs #192)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-02 09:53:23 +02:00
notandClaude Opus 5.5 c245182c87 test(domain): malware is reported infected even when the file is not a PDF (refs #192)
clamd matches EICAR only at the start of a file, so a %PDF- check ahead of the
scan made the infected path unreachable for the EICAR test file.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-02 09:53:23 +02:00
notandClaude Opus 5.5 38026879c7 fix(test): acceptance BFF fake returns the provide-documents result (refs #192)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-02 09:53:23 +02:00
notandClaude Opus 5.5 5ceace718a feat(portal-self-service): tell the citizen why a diploma upload was refused (refs #192)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-02 09:53:23 +02:00
notandClaude Opus 5.5 e15253487e test(portal-self-service): explain a refused or unscannable diploma upload (refs #192)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-02 09:53:23 +02:00
notandClaude Opus 5.5 da7813a84e feat(bff): relay refused diplomas as 422 with reason, scanner down as 503 (refs #192)
openapi.json and the generated portal client regenerated from the served spec.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-02 09:53:23 +02:00
notandClaude Opus 5.5 77de63bf8b test(bff): relay a refused diploma as 422 with its reason, a down scanner as 503 (refs #192)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-02 09:53:23 +02:00
notandClaude Opus 5.5 d2c035679b feat(domain): scan uploads with clamd over INSTREAM; 422 refused, 503 scanner down (refs #192)
Verified against a real clamd 1.4.6: clean → Clean, EICAR → Infected,
closed port → Unavailable.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-02 09:53:23 +02:00
notandClaude Opus 5.5 64dd5420d7 test(domain): clamd INSTREAM adapter maps replies to scan verdicts (refs #192)
Red: the stub adapter never connects and always answers Clean.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-02 09:53:23 +02:00
notandClaude Opus 5.5 764ae6e1be feat(domain): refuse non-PDF, infected or unscannable diplomas before storing (refs #192)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-02 09:53:23 +02:00
notandClaude Opus 5.5 eaef19747b test(domain): only a clean PDF diploma is stored and unblocks beoordeling (refs #192)
Red: ProvideDocuments takes the new IDocumentScanner port but ignores it, so
infected, non-PDF and scanner-unavailable uploads are still Accepted.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-02 09:53:23 +02:00
notandClaude Opus 5.5 5fdcbd27c0 build(infra): run ClamAV in compose and on the cluster (closes #191) (#193)
Deploy to Talos / deploy (push) Successful in 2m40s
CI / k8s (push) Successful in 1m23s
CI / build (push) Successful in 4m55s
CI / lint (push) Successful in 6m25s
CI / unit (push) Successful in 1m6s
CI / docs (push) Successful in 1m22s
CI / frontend (push) Successful in 2m44s
CI / mutation (push) Successful in 4m22s
CI / verify-stack (push) Successful in 21m56s
Runs a ClamAV daemon (clamav/clamav:1.4.6) in both compose stacks and the Helm chart, health-gated, with a verify-clamav check (EICAR found, clean OK) in verify-stack. ADR-0036 records the scan-in-domain, fail-closed decision (#190).

closes #191

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-02 07:53:16 +00:00
34 changed files with 851 additions and 47 deletions
+6 -1
View File
@@ -248,6 +248,9 @@ 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
@@ -287,6 +290,7 @@ 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 }}
@@ -309,6 +313,7 @@ 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") |"
@@ -328,7 +333,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 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 clamav tempo prometheus grafana 2>&1 || true
- name: Tear down - name: Tear down
if: always() if: always()
run: make down run: make down
+8
View File
@@ -334,6 +334,14 @@ 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
+8 -2
View File
@@ -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 WAIT_SVCS := openzaak nrc-web acl bff domain event-subscriber projection-api self-service openbaar behandel beheer objecttypen objecten clamav
# 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,7 +43,7 @@ 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-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 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
## 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).
@@ -222,6 +222,11 @@ 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.
@@ -230,6 +235,7 @@ 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,10 +14,8 @@
@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 (provideDocumentsFailed()) { @if (provideDocumentsError(); as error) {
<p utrecht-paragraph role="alert"> <p utrecht-paragraph role="alert">{{ error }}</p>
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,5 +1,6 @@
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';
@@ -142,6 +143,28 @@ 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,5 +1,6 @@
import { Component, inject, type OnInit, signal } from '@angular/core'; import { Component, inject, type OnInit, signal } from '@angular/core';
import { BffApiV1Service, type CurrentRegistration, type SubmitAccepted } from 'api-client'; import { HttpErrorResponse } from '@angular/common/http';
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';
@@ -31,7 +32,8 @@ 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);
protected readonly provideDocumentsFailed = signal(false); /** Why the last upload failed, worded for the citizen; undefined while there is nothing to report. */
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
@@ -80,12 +82,12 @@ export class RegistrationPage implements OnInit {
return; return;
} }
this.providingDocuments.set(true); this.providingDocuments.set(true);
this.provideDocumentsFailed.set(false); this.provideDocumentsError.set(undefined);
let contentBase64: string; let contentBase64: string;
try { try {
contentBase64 = await readAsBase64(file); contentBase64 = await readAsBase64(file);
} catch { } catch {
this.provideDocumentsFailed.set(true); this.provideDocumentsError.set(uploadFailure());
this.providingDocuments.set(false); this.providingDocuments.set(false);
return; return;
} }
@@ -101,8 +103,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: () => { error: (err: unknown) => {
this.provideDocumentsFailed.set(true); this.provideDocumentsError.set(uploadFailure(err));
this.providingDocuments.set(false); this.providingDocuments.set(false);
}, },
}); });
@@ -129,6 +131,20 @@ 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) => {
@@ -0,0 +1,56 @@
# 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.
+25
View File
@@ -878,3 +878,28 @@ 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).
+1
View File
@@ -356,6 +356,7 @@ 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>` |
+40
View File
@@ -0,0 +1,40 @@
#!/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")
+21
View File
@@ -751,6 +751,26 @@ 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:
@@ -758,6 +778,7 @@ 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:
+21
View File
@@ -785,6 +785,26 @@ 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
@@ -836,6 +856,7 @@ 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.
+18
View File
@@ -588,6 +588,24 @@ 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
+21
View File
@@ -0,0 +1,21 @@
#!/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,6 +59,10 @@ 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;
+1
View File
@@ -57,6 +57,7 @@ nav:
- "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-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
+23 -8
View File
@@ -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"/>. Returns /// aanleveren"). The file is carried base64-encoded. Owner-scoped by <paramref name="bsn"/>. The domain
/// <c>false</c> when the domain reports the registration is unknown or not the caller's (404).</summary> /// refuses a non-PDF or infected file, and an upload it could not scan (S-29, ADR-0036).</summary>
Task<bool> ProvideDocumentsAsync( Task<ProvideDocumentsResult> 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,6 +48,12 @@ 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
{ {
@@ -116,17 +122,26 @@ public sealed class DomainClient(HttpClient http) : IDomainClient
return true; return true;
} }
public async Task<bool> ProvideDocumentsAsync( public async Task<ProvideDocumentsResult> 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; relay that rather than fail hard. // The domain 404s an unknown or not-owned registration, 422s a refused file with its reason and
if (response.StatusCode == System.Net.HttpStatusCode.NotFound) // 503s when its scanner is down (S-29); relay those rather than fail hard.
return false; switch (response.StatusCode)
{
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 true; return ProvideDocumentsResult.Provided;
} }
public async Task<IReadOnlyList<WerkbakItem>> GetWerkbakAsync(CancellationToken ct = default) public async Task<IReadOnlyList<WerkbakItem>> GetWerkbakAsync(CancellationToken ct = default)
+13 -4
View File
@@ -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. The real file upload + ZGW storage // registration that is unknown or not the caller's comes back 404. A non-PDF or infected file is 422
// is S-10b — this is the trigger that unblocks the process. // with the reason; an unreachable scanner is 503 (S-29, ADR-0036).
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,13 +177,22 @@ 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 ? Results.NoContent() : Results.NotFound(); return provided switch
{
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) =>
+4 -5
View File
@@ -111,14 +111,13 @@ 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>Whether the fake domain reports the provide-documents as done (true → 204) or /// <summary>How the fake domain answers provide-documents. Tests set this to exercise the relay.</summary>
/// not-found/not-owned (false → 404). Tests set this to exercise the relay.</summary> public ProvideDocumentsResult ProvideDocumentsResult { get; set; } = ProvideDocumentsResult.Provided;
public bool ProvideDocumentsSucceeds { get; set; } = true;
public Task<bool> ProvideDocumentsAsync(string registrationId, string bsn, string contentBase64, string? fileName, string? contentType, CancellationToken ct = default) public Task<ProvideDocumentsResult> 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(ProvideDocumentsSucceeds); return Task.FromResult(ProvideDocumentsResult);
} }
public (string RegistrationId, string Besluit)? Decided { get; private set; } public (string RegistrationId, string Besluit)? Decided { get; private set; }
@@ -0,0 +1,32 @@
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,6 +1,7 @@
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;
@@ -162,13 +163,39 @@ 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.ProvideDocumentsSucceeds = false; factory.Domain.ProvideDocumentsResult = ProvideDocumentsResult.NotFound;
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");
+24
View File
@@ -124,6 +124,19 @@
}, },
"404": { "404": {
"description": "Not Found" "description": "Not Found"
},
"422": {
"description": "Unprocessable Entity",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Refusal"
}
}
}
},
"503": {
"description": "Service Unavailable"
} }
} }
} }
@@ -415,6 +428,17 @@
} }
} }
}, },
"Refusal": {
"required": [
"reason"
],
"type": "object",
"properties": {
"reason": {
"type": "string"
}
}
},
"SubmitAccepted": { "SubmitAccepted": {
"required": [ "required": [
"registrationId", "registrationId",
+16 -4
View File
@@ -37,6 +37,10 @@ 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>();
@@ -158,8 +162,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. The // caller's is 404 (indistinguishable). Idempotent — completing an already-left wait is a no-op. Only a
// real file upload + ZGW storage is S-10b; this endpoint is the trigger that unblocks the process. // PDF that clamd scans clean is stored and unblocks the process (S-29).
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))
@@ -177,8 +181,16 @@ 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");
var outcome = await provide.HandleAsync(command, ct); // A refused file is 422 with a machine-readable reason the portal words for the citizen; an
return outcome == ProvideDocumentsOutcome.Accepted ? Results.NoContent() : Results.NotFound(); // unreachable scanner is 503 — retryable, and nothing was stored (S-29, ADR-0036).
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
+19
View File
@@ -136,3 +136,22 @@ 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,17 +18,26 @@ 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. The document is stored in ZGW via the ACL (§8.1), then the /// 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
/// 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) public sealed class ProvideDocuments(IRegistrationStore store, IWorkflowClient workflow, IAclClient acl, IDocumentScanner scanner)
{ {
public async Task<ProvideDocumentsOutcome> HandleAsync(ProvideDocumentsCommand command, CancellationToken ct = default) public async Task<ProvideDocumentsOutcome> HandleAsync(ProvideDocumentsCommand command, CancellationToken ct = default)
{ {
@@ -40,6 +49,18 @@ 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(
@@ -0,0 +1,48 @@
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: &lt;signature&gt; 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,3 +27,12 @@ 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);
}
@@ -0,0 +1,117 @@
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));
}
+11
View File
@@ -146,3 +146,14 @@ 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,8 +20,10 @@ public class ProvideDocumentsTests
return registration; return registration;
} }
private static ProvideDocumentsCommand Command(RegistrationId id, string bsn = Bsn) => private static readonly byte[] Pdf = "%PDF-1.4 diploma"u8.ToArray();
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()
@@ -31,13 +33,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); var handler = new ProvideDocuments(store, workflow, acl, new FakeDocumentScanner());
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, new byte[] { 1, 2, 3 }, "diploma.pdf", "application/pdf"), acl.StoredDiploma); Assert.Equal((Zaak, Pdf, "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);
} }
@@ -51,7 +53,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); var handler = new ProvideDocuments(store, workflow, acl, new FakeDocumentScanner());
var outcome = await handler.HandleAsync(Command(registration.Id, bsn: "999999990")); var outcome = await handler.HandleAsync(Command(registration.Id, bsn: "999999990"));
@@ -64,7 +66,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()); var handler = new ProvideDocuments(store, new FakeWorkflowClient(), new FakeAclClient(), new FakeDocumentScanner());
Assert.Equal(ProvideDocumentsOutcome.NotFound, await handler.HandleAsync(Command(RegistrationId.New()))); Assert.Equal(ProvideDocumentsOutcome.NotFound, await handler.HandleAsync(Command(RegistrationId.New())));
} }
@@ -80,7 +82,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); var handler = new ProvideDocuments(store, workflow, acl, new FakeDocumentScanner());
var outcome = await handler.HandleAsync(Command(registration.Id)); var outcome = await handler.HandleAsync(Command(registration.Id));
@@ -89,8 +91,92 @@ 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()).HandleAsync(null!)); new ProvideDocuments(new FakeRegistrationStore(), new FakeWorkflowClient(), new FakeAclClient(), new FakeDocumentScanner()).HandleAsync(null!));
} }
@@ -0,0 +1,36 @@
# 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
@@ -0,0 +1,68 @@
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<bool> ProvideDocumentsAsync( public Task<ProvideDocumentsResult> 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(true); => Task.FromResult(ProvideDocumentsResult.Provided);
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,6 +77,13 @@ 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