Compare commits
13
Commits
main
...
254b1796dc
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
254b1796dc | ||
|
|
940aac7d25 | ||
|
|
4e7e0526b2 | ||
|
|
c245182c87 | ||
|
|
38026879c7 | ||
|
|
5ceace718a | ||
|
|
e15253487e | ||
|
|
da7813a84e | ||
|
|
77de63bf8b | ||
|
|
d2c035679b | ||
|
|
64dd5420d7 | ||
|
|
764ae6e1be | ||
|
|
eaef19747b |
@@ -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
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -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).
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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) =>
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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: <signature> FOUND</c>. Anything else (an ERROR reply, a refused
|
||||||
|
/// connection, a timeout) is <see cref="ScanVerdict.Unavailable"/>, so the caller fails closed.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class ClamdDocumentScanner(ClamAvOptions options) : IDocumentScanner
|
||||||
|
{
|
||||||
|
public async Task<ScanVerdict> ScanAsync(byte[] content, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(content);
|
||||||
|
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||||
|
timeout.CancelAfter(options.Timeout);
|
||||||
|
|
||||||
|
string reply;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var client = new TcpClient();
|
||||||
|
await client.ConnectAsync(options.Host, options.Port, timeout.Token);
|
||||||
|
var stream = client.GetStream();
|
||||||
|
|
||||||
|
var length = new byte[4];
|
||||||
|
BinaryPrimitives.WriteInt32BigEndian(length, content.Length);
|
||||||
|
await stream.WriteAsync("zINSTREAM\0"u8.ToArray(), timeout.Token);
|
||||||
|
await stream.WriteAsync(length, timeout.Token);
|
||||||
|
await stream.WriteAsync(content, timeout.Token);
|
||||||
|
await stream.WriteAsync(new byte[4], timeout.Token);
|
||||||
|
|
||||||
|
using var reader = new StreamReader(stream, Encoding.ASCII);
|
||||||
|
reply = (await reader.ReadToEndAsync(timeout.Token)).TrimEnd('\0', '\n');
|
||||||
|
}
|
||||||
|
catch (Exception e) when (e is SocketException or IOException
|
||||||
|
|| (e is OperationCanceledException && !ct.IsCancellationRequested))
|
||||||
|
{
|
||||||
|
return ScanVerdict.Unavailable;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (reply == "stream: OK") return ScanVerdict.Clean;
|
||||||
|
return reply.EndsWith(" FOUND", StringComparison.Ordinal) ? ScanVerdict.Infected : ScanVerdict.Unavailable;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -27,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));
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user