diff --git a/BACKLOG.md b/BACKLOG.md index f707d0f..a0a19f7 100644 --- a/BACKLOG.md +++ b/BACKLOG.md @@ -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. + +### 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 diff --git a/apps/self-service/src/app/registration/registration-page.html b/apps/self-service/src/app/registration/registration-page.html index 255dba4..e91cc80 100644 --- a/apps/self-service/src/app/registration/registration-page.html +++ b/apps/self-service/src/app/registration/registration-page.html @@ -14,10 +14,8 @@ @if (documentsProvided()) {

Uw documenten zijn aangeleverd.

} @else { - @if (provideDocumentsFailed()) { -

- Het aanleveren van uw documenten is niet gelukt. Probeer het opnieuw. -

+ @if (provideDocumentsError(); as error) { +

{{ error }}

}

Lever uw diploma aan (PDF).

diff --git a/apps/self-service/src/app/registration/registration-page.spec.ts b/apps/self-service/src/app/registration/registration-page.spec.ts index 765302c..215f921 100644 --- a/apps/self-service/src/app/registration/registration-page.spec.ts +++ b/apps/self-service/src/app/registration/registration-page.spec.ts @@ -1,5 +1,6 @@ import { signal } from '@angular/core'; import { fireEvent, render, screen } from '@testing-library/angular'; +import { HttpErrorResponse } from '@angular/common/http'; import { of, throwError } from 'rxjs'; import { AuthService } from 'auth'; import { BffApiV1Service } from 'api-client'; @@ -142,6 +143,28 @@ describe('RegistrationPage', () => { 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 () => { const { providers: p } = providers( vi.fn().mockReturnValue(of({ registrationId: 'reg-9', status: 'Ingediend' })), diff --git a/apps/self-service/src/app/registration/registration-page.ts b/apps/self-service/src/app/registration/registration-page.ts index b225ba3..dfb0670 100644 --- a/apps/self-service/src/app/registration/registration-page.ts +++ b/apps/self-service/src/app/registration/registration-page.ts @@ -1,5 +1,6 @@ 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 { UtrechtComponentsModule } from 'ui'; @@ -31,7 +32,8 @@ export class RegistrationPage implements OnInit { protected readonly withdrawFailed = signal(false); protected readonly providingDocuments = 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(undefined); protected readonly selectedFile = signal(undefined); /** 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; } this.providingDocuments.set(true); - this.provideDocumentsFailed.set(false); + this.provideDocumentsError.set(undefined); let contentBase64: string; try { contentBase64 = await readAsBase64(file); } catch { - this.provideDocumentsFailed.set(true); + this.provideDocumentsError.set(uploadFailure()); this.providingDocuments.set(false); return; } @@ -101,8 +103,8 @@ export class RegistrationPage implements OnInit { this.providingDocuments.set(false); }, // Surface the failure instead of swallowing it: keep the action so the user can retry. - error: () => { - this.provideDocumentsFailed.set(true); + error: (err: unknown) => { + this.provideDocumentsError.set(uploadFailure(err)); 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). */ function readAsBase64(file: File): Promise { return new Promise((resolve, reject) => { diff --git a/docs/demo-script.md b/docs/demo-script.md index ee0adf8..6673056 100644 --- a/docs/demo-script.md +++ b/docs/demo-script.md @@ -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 (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). + diff --git a/libs/api-client/src/lib/generated/bff-api.ts b/libs/api-client/src/lib/generated/bff-api.ts index e806978..a51924e 100644 --- a/libs/api-client/src/lib/generated/bff-api.ts +++ b/libs/api-client/src/lib/generated/bff-api.ts @@ -59,6 +59,10 @@ export interface ProvideDocumentsRequest { contentType?: string | null; } +export interface Refusal { + reason: string; +} + export interface SubmitAccepted { registrationId: string; status: string; diff --git a/services/bff/Bff.Api/DownstreamClients.cs b/services/bff/Bff.Api/DownstreamClients.cs index 8696b66..e89ac15 100644 --- a/services/bff/Bff.Api/DownstreamClients.cs +++ b/services/bff/Bff.Api/DownstreamClients.cs @@ -36,9 +36,9 @@ public interface IDomainClient Task WithdrawRegistrationAsync(string registrationId, string bsn, CancellationToken ct = default); /// Provide (upload) the diploma the caller's own registration is waiting for ("documenten - /// aanleveren"). The file is carried base64-encoded. Owner-scoped by . Returns - /// false when the domain reports the registration is unknown or not the caller's (404). - Task ProvideDocumentsAsync( + /// aanleveren"). The file is carried base64-encoded. Owner-scoped by . The domain + /// refuses a non-PDF or infected file, and an upload it could not scan (S-29, ADR-0036). + Task ProvideDocumentsAsync( string registrationId, string bsn, string contentBase64, string? fileName, string? contentType, CancellationToken ct = default); /// The behandelaar's werkbak — registrations awaiting beoordeling. @@ -48,6 +48,12 @@ public interface IDomainClient Task DecideAsync(string registrationId, string besluit, CancellationToken ct = default); } +/// How the domain answered a provide-documents request (S-29). +public enum ProvideDocumentsResult { Provided, NotFound, NotAPdf, Infected, ScannerUnavailable } + +/// The body of a 422 provide-documents answer: why the file was refused. +public sealed record Refusal(string Reason); + /// Port to the read projection. public interface IProjectionClient { @@ -116,17 +122,26 @@ public sealed class DomainClient(HttpClient http) : IDomainClient return true; } - public async Task ProvideDocumentsAsync( + public async Task ProvideDocumentsAsync( string registrationId, string bsn, string contentBase64, string? fileName, string? contentType, CancellationToken ct = default) { using var response = await http.PostAsJsonAsync( $"registrations/{registrationId}/documents", new { bsn, contentBase64, fileName, contentType }, ct); - // The domain 404s an unknown or not-owned registration; relay that rather than fail hard. - if (response.StatusCode == System.Net.HttpStatusCode.NotFound) - return false; + // The domain 404s an unknown or not-owned registration, 422s a refused file with its reason and + // 503s when its scanner is down (S-29); relay those rather than fail hard. + 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(ct); + return refusal?.Reason == "infected" ? ProvideDocumentsResult.Infected : ProvideDocumentsResult.NotAPdf; + } response.EnsureSuccessStatusCode(); - return true; + return ProvideDocumentsResult.Provided; } public async Task> GetWerkbakAsync(CancellationToken ct = default) diff --git a/services/bff/Bff.Api/Program.cs b/services/bff/Bff.Api/Program.cs index ee2a36e..26240d6 100644 --- a/services/bff/Bff.Api/Program.cs +++ b/services/bff/Bff.Api/Program.cs @@ -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 // 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 -// registration that is unknown or not the caller's comes back 404. The real file upload + ZGW storage -// is S-10b — this is the trigger that unblocks the process. +// registration that is unknown or not the caller's comes back 404. A non-PDF or infected file is 422 +// 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) => { 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."); 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() .Produces(StatusCodes.Status204NoContent) .Produces(StatusCodes.Status400BadRequest) .Produces(StatusCodes.Status401Unauthorized) - .Produces(StatusCodes.Status404NotFound); + .Produces(StatusCodes.Status404NotFound) + .Produces(StatusCodes.Status422UnprocessableEntity) + .Produces(StatusCodes.Status503ServiceUnavailable); // 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) => diff --git a/services/bff/Bff.Tests/BffFactory.cs b/services/bff/Bff.Tests/BffFactory.cs index 278a61c..29942cc 100644 --- a/services/bff/Bff.Tests/BffFactory.cs +++ b/services/bff/Bff.Tests/BffFactory.cs @@ -111,14 +111,13 @@ internal sealed class FakeDomainClient : IDomainClient public (string RegistrationId, string Bsn, string ContentBase64, string? FileName, string? ContentType)? DocumentsProvidedFor { get; private set; } - /// Whether the fake domain reports the provide-documents as done (true → 204) or - /// not-found/not-owned (false → 404). Tests set this to exercise the relay. - public bool ProvideDocumentsSucceeds { get; set; } = true; + /// How the fake domain answers provide-documents. Tests set this to exercise the relay. + public ProvideDocumentsResult ProvideDocumentsResult { get; set; } = ProvideDocumentsResult.Provided; - public Task ProvideDocumentsAsync(string registrationId, string bsn, string contentBase64, string? fileName, string? contentType, CancellationToken ct = default) + public Task ProvideDocumentsAsync(string registrationId, string bsn, string contentBase64, string? fileName, string? contentType, CancellationToken ct = default) { DocumentsProvidedFor = (registrationId, bsn, contentBase64, fileName, contentType); - return Task.FromResult(ProvideDocumentsSucceeds); + return Task.FromResult(ProvideDocumentsResult); } public (string RegistrationId, string Besluit)? Decided { get; private set; } diff --git a/services/bff/Bff.Tests/DomainClientProvideDocumentsTests.cs b/services/bff/Bff.Tests/DomainClientProvideDocumentsTests.cs new file mode 100644 index 0000000..8836aed --- /dev/null +++ b/services/bff/Bff.Tests/DomainClientProvideDocumentsTests.cs @@ -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 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)); + } +} diff --git a/services/bff/Bff.Tests/SelfServiceEndpointTests.cs b/services/bff/Bff.Tests/SelfServiceEndpointTests.cs index 9b855ba..137012d 100644 --- a/services/bff/Bff.Tests/SelfServiceEndpointTests.cs +++ b/services/bff/Bff.Tests/SelfServiceEndpointTests.cs @@ -1,6 +1,7 @@ using System.Net; using System.Net.Http.Headers; using System.Net.Http.Json; +using System.Text.Json; using Bff.Api; 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() { using var factory = new BffFactory(); - factory.Domain.ProvideDocumentsSucceeds = false; + factory.Domain.ProvideDocumentsResult = ProvideDocumentsResult.NotFound; var response = await factory.CreateClient().SendAsync(ProvideDocuments(TestTokens.Valid("123456782"))); 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()).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) { var request = new HttpRequestMessage(HttpMethod.Get, "/self-service/registrations"); diff --git a/services/bff/openapi.json b/services/bff/openapi.json index a9a985c..cf7984d 100644 --- a/services/bff/openapi.json +++ b/services/bff/openapi.json @@ -124,6 +124,19 @@ }, "404": { "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": { "required": [ "registrationId", diff --git a/services/domain/Big.Api/Program.cs b/services/domain/Big.Api/Program.cs index 466ce1d..f4a21f0 100644 --- a/services/domain/Big.Api/Program.cs +++ b/services/domain/Big.Api/Program.cs @@ -37,6 +37,10 @@ builder.Services.AddSingleton(sp => sp.GetRequiredService() builder.Services.AddSingleton(sp => sp.GetRequiredService() .GetSection("Acl").Get() ?? 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() + .GetSection("ClamAv").Get() ?? new ClamAvOptions()); +builder.Services.AddSingleton(); // The in-memory registration store is shared between the submit endpoint and the worker (ADR-0009). builder.Services.AddSingleton(); @@ -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 // 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- -// caller's is 404 (indistinguishable). Idempotent — completing an already-left wait is a no-op. The -// real file upload + ZGW storage is S-10b; this endpoint is the trigger that unblocks the process. +// caller's is 404 (indistinguishable). Idempotent — completing an already-left wait is a no-op. Only a +// 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) => { if (!Guid.TryParse(id, out var guid)) @@ -177,8 +181,16 @@ app.MapPost("/registrations/{id}/documents", async (string id, ProvideDocumentsR var command = new ProvideDocumentsCommand( new RegistrationId(guid), body.Bsn, content, body.FileName ?? "diploma.pdf", body.ContentType ?? "application/pdf"); - var outcome = await provide.HandleAsync(command, ct); - return outcome == ProvideDocumentsOutcome.Accepted ? Results.NoContent() : Results.NotFound(); + // A refused file is 422 with a machine-readable reason the portal words for the citizen; an + // 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 diff --git a/services/domain/Big.Application/Ports.cs b/services/domain/Big.Application/Ports.cs index 70ec270..bf9c0f8 100644 --- a/services/domain/Big.Application/Ports.cs +++ b/services/domain/Big.Application/Ports.cs @@ -136,3 +136,22 @@ public sealed record EscalatieJob(string JobId, string ProcessInstanceId); /// cancels the case (ADR-0017). /// public sealed record RegistratieVerlopenJob(string JobId, RegistrationId RegistrationId); + +/// What a malware scan of an uploaded document found (S-29, ADR-0036). +public enum ScanVerdict +{ + Clean, + Infected, + + /// The scanner could not be reached or did not answer — the upload is refused (fail closed). + Unavailable, +} + +/// +/// 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 . +/// +public interface IDocumentScanner +{ + Task ScanAsync(byte[] content, CancellationToken ct = default); +} diff --git a/services/domain/Big.Application/ProvideDocuments.cs b/services/domain/Big.Application/ProvideDocuments.cs index 59d997f..95e07fe 100644 --- a/services/domain/Big.Application/ProvideDocuments.cs +++ b/services/domain/Big.Application/ProvideDocuments.cs @@ -18,17 +18,26 @@ public enum ProvideDocumentsOutcome /// 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). NotFound, + + /// The file does not start with the PDF signature (%PDF-); nothing was stored. + NotAPdf, + + /// The malware scan found something; nothing was stored and the wait stays open. + Infected, + + /// The scanner could not be reached — refused rather than storing an unscanned file. + ScannerUnavailable, } /// /// 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 /// to beoordeling (ADR-0017). Owner-scoped by bsn. Both steps are best-effort about missing preconditions /// (mirroring ): storage needs an opened zaak, and completion needs a /// running process — a request that arrives before either still stands, storing/completing what it can. /// -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 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) 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). if (registration.ZaakUrl is not null) await acl.StoreDiplomaAsync( diff --git a/services/domain/Big.Infrastructure/ClamdDocumentScanner.cs b/services/domain/Big.Infrastructure/ClamdDocumentScanner.cs new file mode 100644 index 0000000..fe7666a --- /dev/null +++ b/services/domain/Big.Infrastructure/ClamdDocumentScanner.cs @@ -0,0 +1,48 @@ +using System.Buffers.Binary; +using System.Net.Sockets; +using System.Text; +using Big.Application; + +namespace Big.Infrastructure; + +/// +/// Scans a document with clamd over its INSTREAM protocol (S-29, ADR-0036): zINSTREAM\0, the +/// document as one big-endian length-prefixed chunk, a zero-length terminator, then one reply — +/// stream: OK or stream: <signature> FOUND. Anything else (an ERROR reply, a refused +/// connection, a timeout) is , so the caller fails closed. +/// +public sealed class ClamdDocumentScanner(ClamAvOptions options) : IDocumentScanner +{ + public async Task 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; + } +} diff --git a/services/domain/Big.Infrastructure/Options.cs b/services/domain/Big.Infrastructure/Options.cs index ccbe7b1..73a1cfc 100644 --- a/services/domain/Big.Infrastructure/Options.cs +++ b/services/domain/Big.Infrastructure/Options.cs @@ -27,3 +27,12 @@ public sealed class AclOptions { public Uri BaseUrl { get; set; } = null!; } + +/// Where clamd listens (S-29, ADR-0036). bounds one whole scan; a scan that +/// takes longer counts as the scanner being unavailable. +public sealed class ClamAvOptions +{ + public string Host { get; set; } = "clamav"; + public int Port { get; set; } = 3310; + public TimeSpan Timeout { get; set; } = TimeSpan.FromSeconds(30); +} diff --git a/services/domain/Big.Tests/ClamdDocumentScannerTests.cs b/services/domain/Big.Tests/ClamdDocumentScannerTests.cs new file mode 100644 index 0000000..8739226 --- /dev/null +++ b/services/domain/Big.Tests/ClamdDocumentScannerTests.cs @@ -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 +{ + /// A one-shot fake clamd: reads one INSTREAM request to its zero-length terminator, + /// records it, and answers . + private sealed class FakeClamd : IDisposable + { + private readonly TcpListener _listener = new(IPAddress.Loopback, 0); + public Task Received { get; } + public int Port => ((IPEndPoint)_listener.LocalEndpoint).Port; + + /// The answer, or null to accept the request and never answer (a hung clamd). + public FakeClamd(string? reply) + { + _listener.Start(); + Received = Serve(reply); + } + + private async Task 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(() => ScannerFor(clamd.Port).ScanAsync([1, 2, 3], cts.Token)); + } + + [Fact] + public async Task Rejects_null_content() + => await Assert.ThrowsAsync(() => 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)); +} diff --git a/services/domain/Big.Tests/Fakes.cs b/services/domain/Big.Tests/Fakes.cs index f740b96..ed913f9 100644 --- a/services/domain/Big.Tests/Fakes.cs +++ b/services/domain/Big.Tests/Fakes.cs @@ -146,3 +146,14 @@ internal sealed class FakeAclClient(Uri? zaakUrl = null) : IAclClient return Task.CompletedTask; } } + +internal sealed class FakeDocumentScanner(ScanVerdict verdict = ScanVerdict.Clean) : IDocumentScanner +{ + public byte[]? Scanned { get; private set; } + + public Task ScanAsync(byte[] content, CancellationToken ct = default) + { + Scanned = content; + return Task.FromResult(verdict); + } +} diff --git a/services/domain/Big.Tests/ProvideDocumentsTests.cs b/services/domain/Big.Tests/ProvideDocumentsTests.cs index ed956e6..700854f 100644 --- a/services/domain/Big.Tests/ProvideDocumentsTests.cs +++ b/services/domain/Big.Tests/ProvideDocumentsTests.cs @@ -20,8 +20,10 @@ public class ProvideDocumentsTests return registration; } - private static ProvideDocumentsCommand Command(RegistrationId id, string bsn = Bsn) => - new(id, bsn, [1, 2, 3], "diploma.pdf", "application/pdf"); + private static readonly byte[] Pdf = "%PDF-1.4 diploma"u8.ToArray(); + + private static ProvideDocumentsCommand Command(RegistrationId id, string bsn = Bsn, byte[]? content = null) => + new(id, bsn, content ?? Pdf, "diploma.pdf", "application/pdf"); [Fact] public async Task Providing_documents_stores_the_diploma_and_completes_the_wait() @@ -31,13 +33,13 @@ public class ProvideDocumentsTests store.Seed(registration); var workflow = new FakeWorkflowClient(); 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)); Assert.Equal(ProvideDocumentsOutcome.Accepted, outcome); // 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. Assert.Equal("proc-42", workflow.CompletedDocumentWaitFor); } @@ -51,7 +53,7 @@ public class ProvideDocumentsTests store.Seed(registration); var workflow = new FakeWorkflowClient(); 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")); @@ -64,7 +66,7 @@ public class ProvideDocumentsTests public async Task Providing_for_an_unknown_registration_is_not_found() { 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()))); } @@ -80,7 +82,7 @@ public class ProvideDocumentsTests store.Seed(registration); var workflow = new FakeWorkflowClient(); 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)); @@ -89,8 +91,92 @@ public class ProvideDocumentsTests 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] public async Task Rejects_a_null_command() => await Assert.ThrowsAsync(() => - new ProvideDocuments(new FakeRegistrationStore(), new FakeWorkflowClient(), new FakeAclClient()).HandleAsync(null!)); + new ProvideDocuments(new FakeRegistrationStore(), new FakeWorkflowClient(), new FakeAclClient(), new FakeDocumentScanner()).HandleAsync(null!)); } diff --git a/tests/acceptance/Features/EenDiplomaAanleveren.feature b/tests/acceptance/Features/EenDiplomaAanleveren.feature new file mode 100644 index 0000000..9a740d5 --- /dev/null +++ b/tests/acceptance/Features/EenDiplomaAanleveren.feature @@ -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 diff --git a/tests/acceptance/Steps/EenDiplomaAanleverenSteps.cs b/tests/acceptance/Steps/EenDiplomaAanleverenSteps.cs new file mode 100644 index 0000000..d3147a8 --- /dev/null +++ b/tests/acceptance/Steps/EenDiplomaAanleverenSteps.cs @@ -0,0 +1,68 @@ +using Acceptance.Support; +using Big.Application; +using Big.Domain; +using Reqnroll; +using Xunit; + +namespace Acceptance.Steps; + +/// Bindings for EenDiplomaAanleveren.feature (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. +[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); +} diff --git a/tests/acceptance/Support/BffAcceptanceHost.cs b/tests/acceptance/Support/BffAcceptanceHost.cs index f6d95b0..58d1bea 100644 --- a/tests/acceptance/Support/BffAcceptanceHost.cs +++ b/tests/acceptance/Support/BffAcceptanceHost.cs @@ -75,9 +75,9 @@ public sealed class CapturingDomainClient : IDomainClient public Task WithdrawRegistrationAsync(string registrationId, string bsn, CancellationToken ct = default) => Task.FromResult(true); - public Task ProvideDocumentsAsync( + public Task ProvideDocumentsAsync( string registrationId, string bsn, string contentBase64, string? fileName, string? contentType, CancellationToken ct = default) - => Task.FromResult(true); + => Task.FromResult(ProvideDocumentsResult.Provided); public Task> GetWerkbakAsync(CancellationToken ct = default) => Task.FromResult>([]); diff --git a/tests/acceptance/Support/InMemoryDomainPorts.cs b/tests/acceptance/Support/InMemoryDomainPorts.cs index 5475964..415a448 100644 --- a/tests/acceptance/Support/InMemoryDomainPorts.cs +++ b/tests/acceptance/Support/InMemoryDomainPorts.cs @@ -77,6 +77,13 @@ public sealed class InMemoryAclClient : IAclClient } } +/// A scanner stand-in that returns the verdict the scenario names (S-29) — the live clamd +/// INSTREAM scan is verified by verify-clamav. +public sealed class InMemoryDocumentScanner(ScanVerdict verdict) : IDocumentScanner +{ + public Task ScanAsync(byte[] content, CancellationToken ct = default) => Task.FromResult(verdict); +} + /// 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. public sealed class InMemoryUserTaskClient : IUserTaskClient