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..6b5b8f0 100644 --- a/services/domain/Big.Application/ProvideDocuments.cs +++ b/services/domain/Big.Application/ProvideDocuments.cs @@ -18,6 +18,15 @@ 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, } /// @@ -28,7 +37,7 @@ public enum ProvideDocumentsOutcome /// (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) { 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..3d2fd21 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,78 @@ 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_file_that_is_not_a_pdf_is_refused_without_being_scanned() + { + var store = new FakeRegistrationStore(); + var registration = Submitted(); + store.Seed(registration); + var workflow = new FakeWorkflowClient(); + var acl = new FakeAclClient(); + var scanner = new FakeDocumentScanner(); + var handler = new ProvideDocuments(store, workflow, acl, scanner); + + var outcome = await handler.HandleAsync(Command(registration.Id, content: "MZ not a pdf"u8.ToArray())); + + Assert.Equal(ProvideDocumentsOutcome.NotAPdf, outcome); + Assert.Null(scanner.Scanned); + Assert.Null(acl.StoredDiploma); + Assert.Null(workflow.CompletedDocumentWaitFor); + } + + [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/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