From 0d37cc097af25cb41d7a65b7fde210d459385e1e Mon Sep 17 00:00:00 2001 From: Edwin van den Houdt Date: Sat, 27 Jun 2026 20:15:40 +0200 Subject: [PATCH] Upload feature (a): BFF endpoints + category config + tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Domain/Documents: server-owned category config per wizard + authoritative type/size validation (DocumentRules). - Data/DocumentStore: in-memory metadata store (no file bytes/PII) + audit log; user delete (owner-scoped, 409 once linked), admin delete (role seam via X-Admin header), link-on-submit, poll-by-localId status. - Program.cs: GET /uploads/categories, POST /uploads (multipart, excluded from OpenAPI — hand-written on FE), GET /uploads/status, DELETE /uploads/{id}, DELETE /admin/uploads/{id}. Submit links digital docs + records post-delivery. - Contracts extended (DocumentRefDto on registratie/herregistratie submit); regenerated NSwag client + swagger.json (drift check stays green). - Tests: 16 new (endpoints + DocumentRules); dotnet test 44/44. Co-Authored-By: Claude Opus 4.8 --- backend/src/BigRegister.Api/Contracts/Dtos.cs | 21 +- .../src/BigRegister.Api/Contracts/Mappers.cs | 4 + .../src/BigRegister.Api/Data/DocumentStore.cs | 101 ++++++++ .../Domain/Documents/DocumentCategory.cs | 59 +++++ backend/src/BigRegister.Api/Program.cs | 79 +++++- backend/swagger.json | 234 ++++++++++++++++++ .../tests/BigRegister.Tests/EndpointTests.cs | 87 +++++++ backend/tests/BigRegister.Tests/RuleTests.cs | 29 +++ src/app/shared/infrastructure/api-client.ts | 204 +++++++++++++++ 9 files changed, 813 insertions(+), 5 deletions(-) create mode 100644 backend/src/BigRegister.Api/Data/DocumentStore.cs create mode 100644 backend/src/BigRegister.Api/Domain/Documents/DocumentCategory.cs diff --git a/backend/src/BigRegister.Api/Contracts/Dtos.cs b/backend/src/BigRegister.Api/Contracts/Dtos.cs index dd30bf7..99ba29d 100644 --- a/backend/src/BigRegister.Api/Contracts/Dtos.cs +++ b/backend/src/BigRegister.Api/Contracts/Dtos.cs @@ -46,11 +46,28 @@ public sealed record DuoLookupDto(IReadOnlyList Diplomas, ManualD public sealed record IntakePolicyDto(int ScholingThreshold); +// --- Document upload contracts --- + +public sealed record DocumentCategoryDto( + string CategoryId, string Label, string Description, bool Required, + IReadOnlyList AcceptedTypes, int MaxSizeMb, bool Multiple, bool AllowPostDelivery); + +public sealed record UploadCategoriesDto(IReadOnlyList Categories); + +public sealed record UploadResponse(string DocumentId, string LocalId); + +public sealed record UploadStatusItemDto(string LocalId, string Status, string? DocumentId); +public sealed record UploadStatusDto(IReadOnlyList Results); + +// Per-category delivery intent on submit: digital categories carry their DocumentId, +// post-delivery categories carry Channel="post". +public sealed record DocumentRefDto(string CategoryId, string Channel, string? DocumentId = null); + // Submit requests carry only the fields the server re-validates (UX-only fields // stay on the client). ponytail: a real submit would carry the full application. -public sealed record RegistratieRequest(string DiplomaHerkomst); +public sealed record RegistratieRequest(string DiplomaHerkomst, IReadOnlyList? Documents = null); public sealed record IntakeRequest(int Uren); -public sealed record HerregistratieRequest(int Uren); +public sealed record HerregistratieRequest(int Uren, IReadOnlyList? Documents = null); public sealed record ChangeRequestRequest(string Straat, string Postcode, string Woonplaats); public sealed record ReferentieResponse(string Referentie); diff --git a/backend/src/BigRegister.Api/Contracts/Mappers.cs b/backend/src/BigRegister.Api/Contracts/Mappers.cs index 44e4883..2fb139d 100644 --- a/backend/src/BigRegister.Api/Contracts/Mappers.cs +++ b/backend/src/BigRegister.Api/Contracts/Mappers.cs @@ -1,4 +1,5 @@ using BigRegister.Domain.Diplomas; +using BigRegister.Domain.Documents; using BigRegister.Domain.People; using BigRegister.Domain.Registrations; @@ -30,4 +31,7 @@ public static class Mappers d.Id, d.Naam, d.Instelling, d.Jaar, DiplomaRules.ProfessionFor(d), DiplomaRules.QuestionsFor(d).Select(q => q.ToDto()).ToList()); + + public static DocumentCategoryDto ToDto(this DocumentCategory c) => new( + c.CategoryId, c.Label, c.Description, c.Required, c.AcceptedTypes, c.MaxSizeMb, c.Multiple, c.AllowPostDelivery); } diff --git a/backend/src/BigRegister.Api/Data/DocumentStore.cs b/backend/src/BigRegister.Api/Data/DocumentStore.cs new file mode 100644 index 0000000..876afe6 --- /dev/null +++ b/backend/src/BigRegister.Api/Data/DocumentStore.cs @@ -0,0 +1,101 @@ +namespace BigRegister.Api.Data; + +/// +/// Stored document metadata. The demo deliberately keeps NO file content — only +/// metadata — which is enough for status/delete/link and avoids holding PII bytes +/// in memory. A real backend persists the bytes to blob storage keyed by DocumentId. +/// +public sealed record StoredDocument( + string DocumentId, string LocalId, string CategoryId, string WizardId, + string FileName, long SizeBytes, string Owner, DateTimeOffset UploadedAt) +{ + public bool Linked { get; set; } +} + +public sealed record AuditEntry(DateTimeOffset At, string Action, string DocumentId, string CategoryId, string Actor); + +/// +/// In-memory document store + audit log (no DB). ponytail: one global lock — fine +/// for a single-process demo store; swap for per-key locks if it ever serves load. +/// The audit log holds metadata only (never file content or other PII). +/// +public static class DocumentStore +{ + /// The single seeded user (the demo has no real auth; ownership = this id). + public const string DemoOwner = "19012345601"; + + private static readonly Dictionary _docs = new(); + private static readonly List _audit = new(); + private static readonly object _gate = new(); + + public static StoredDocument Add(string localId, string categoryId, string wizardId, string fileName, long sizeBytes, string owner) + { + var doc = new StoredDocument(Guid.NewGuid().ToString(), localId, categoryId, wizardId, fileName, sizeBytes, owner, DateTimeOffset.UtcNow); + lock (_gate) _docs[doc.DocumentId] = doc; + Audit("upload", doc.DocumentId, categoryId, owner); + return doc; + } + + public static StoredDocument? Get(string documentId) + { + lock (_gate) return _docs.TryGetValue(documentId, out var d) ? d : null; + } + + /// Status for the poll-on-return pattern: a known localId is "complete" (it + /// arrived), an unknown one is still in flight / never started. + public static IReadOnlyList ByLocalIds(IEnumerable localIds) + { + var set = localIds.ToHashSet(); + lock (_gate) return _docs.Values.Where(d => set.Contains(d.LocalId)).ToList(); + } + + /// Mark digital documents as linked to a finalised submission (blocks user delete). + public static void Link(IEnumerable documentIds) + { + lock (_gate) + foreach (var id in documentIds) + if (_docs.TryGetValue(id, out var d)) d.Linked = true; + } + + public enum DeleteResult { Ok, NotFound, Linked } + + /// User delete: owner-scoped; blocked once linked to a finalised submission. + public static DeleteResult DeleteOwned(string documentId, string owner) + { + string categoryId; + lock (_gate) + { + if (!_docs.TryGetValue(documentId, out var d) || d.Owner != owner) return DeleteResult.NotFound; + if (d.Linked) return DeleteResult.Linked; + categoryId = d.CategoryId; + _docs.Remove(documentId); + } + Audit("delete-user", documentId, categoryId, owner); + return DeleteResult.Ok; + } + + /// Admin delete: bypasses ownership, unlinks, and (seam) flags the submission for + /// review so a caseworker is notified. Returns false if the document is unknown. + public static bool AdminDelete(string documentId, string actor) + { + string categoryId; + lock (_gate) + { + if (!_docs.TryGetValue(documentId, out var d)) return false; + categoryId = d.CategoryId; + _docs.Remove(documentId); + } + Audit("delete-admin", documentId, categoryId, actor); + return true; + } + + public static void Audit(string action, string documentId, string categoryId, string actor) + { + lock (_gate) _audit.Add(new AuditEntry(DateTimeOffset.UtcNow, action, documentId, categoryId, actor)); + } + + public static IReadOnlyList AuditLog + { + get { lock (_gate) return _audit.ToList(); } + } +} diff --git a/backend/src/BigRegister.Api/Domain/Documents/DocumentCategory.cs b/backend/src/BigRegister.Api/Domain/Documents/DocumentCategory.cs new file mode 100644 index 0000000..1ba9aa4 --- /dev/null +++ b/backend/src/BigRegister.Api/Domain/Documents/DocumentCategory.cs @@ -0,0 +1,59 @@ +namespace BigRegister.Domain.Documents; + +/// +/// SERVER-OWNED document category config. The frontend renders these; it never +/// hardcodes file types, size limits, labels, required-ness or the post-delivery +/// flag. Adding a new category or changing a rule is a backend-only change. +/// +public sealed record DocumentCategory( + string CategoryId, + string Label, + string Description, + bool Required, + IReadOnlyList AcceptedTypes, + int MaxSizeMb, + bool Multiple, + bool AllowPostDelivery); + +public static class DocumentRules +{ + private static readonly string[] Pdf = { "application/pdf" }; + private static readonly string[] PdfImage = { "application/pdf", "image/jpeg", "image/png" }; + + /// The categories that apply to a given wizard/step. + public static IReadOnlyList CategoriesFor(string wizardId) => wizardId switch + { + "registratie" => new[] + { + new DocumentCategory("diploma", "Diplomabewijs", + "Upload uw diploma als PDF-bestand.", true, Pdf, 10, false, false), + new DocumentCategory("identiteit", "Identiteitsbewijs", + "Upload een kopie van uw paspoort of ID-kaart.", true, PdfImage, 10, false, true), + }, + "herregistratie" => new[] + { + new DocumentCategory("werkervaring", "Bewijs van werkervaring", + "Upload bewijs van uw gewerkte uren (bijv. een werkgeversverklaring).", true, Pdf, 10, true, true), + new DocumentCategory("nascholing", "Nascholingscertificaten", + "Upload uw nascholingscertificaten (optioneel).", false, PdfImage, 10, true, true), + }, + _ => Array.Empty(), + }; + + public static DocumentCategory? Find(string wizardId, string categoryId) => + CategoriesFor(wizardId).FirstOrDefault(c => c.CategoryId == categoryId); + + /// + /// Authoritative upload validation (the client check is UX-only). Returns a + /// rejection reason, or null when the file is acceptable. + /// + public static string? RejectUpload(DocumentCategory? category, string contentType, long sizeBytes) + { + if (category is null) return "Onbekende documentcategorie."; + if (!category.AcceptedTypes.Contains(contentType)) + return $"Bestandstype niet toegestaan voor {category.Label}."; + if (sizeBytes > (long)category.MaxSizeMb * 1024 * 1024) + return $"Bestand is groter dan {category.MaxSizeMb} MB."; + return null; + } +} diff --git a/backend/src/BigRegister.Api/Program.cs b/backend/src/BigRegister.Api/Program.cs index 0549c30..e70572b 100644 --- a/backend/src/BigRegister.Api/Program.cs +++ b/backend/src/BigRegister.Api/Program.cs @@ -3,6 +3,7 @@ using System.Text.Json.Serialization; using BigRegister.Api.Contracts; using BigRegister.Api.Data; using BigRegister.Domain.Diplomas; +using BigRegister.Domain.Documents; using BigRegister.Domain.Intake; using BigRegister.Domain.Registrations; using BigRegister.Domain.Submissions; @@ -65,12 +66,12 @@ api.MapGet("/intake/policy", () => new IntakePolicyDto(IntakePolicy.ScholingThre // --- POST: submits. The server is the authority; it re-validates and decides. --- api.MapPost("/registrations", (RegistratieRequest req, HttpContext ctx) => - Submit(ctx, "registratie", SubmissionRules.RejectRegistratie(req.DiplomaHerkomst))) + Submit(ctx, "registratie", SubmissionRules.RejectRegistratie(req.DiplomaHerkomst), req.Documents)) .Produces() .ProducesProblem(StatusCodes.Status422UnprocessableEntity); api.MapPost("/herregistraties", (HerregistratieRequest req, HttpContext ctx) => - Submit(ctx, "herregistratie", SubmissionRules.RejectZeroUren(req.Uren))) + Submit(ctx, "herregistratie", SubmissionRules.RejectZeroUren(req.Uren), req.Documents)) .Produces() .ProducesProblem(StatusCodes.Status422UnprocessableEntity); @@ -84,12 +85,75 @@ api.MapPost("/change-requests", (ChangeRequestRequest req, HttpContext ctx) => .Produces() .ProducesProblem(StatusCodes.Status422UnprocessableEntity); +// --- Document upload --- + +// Server-owned category config per wizard. The FE renders these; it never hardcodes. +api.MapGet("/uploads/categories", (string wizardId) => + new UploadCategoriesDto(DocumentRules.CategoriesFor(wizardId).Select(c => c.ToDto()).ToList())); + +// Multipart upload. Hand-written on the FE (XHR for progress), so it is excluded +// from the OpenAPI doc to keep the NSwag-generated client JSON-only. Validates type +// and size authoritatively; stores metadata only (no file bytes / PII held). +api.MapPost("/uploads", async (HttpRequest request) => +{ + if (!request.HasFormContentType) return Results.Problem(detail: "Verwacht multipart/form-data.", statusCode: 400); + var form = await request.ReadFormAsync(); + var file = form.Files.GetFile("file"); + string categoryId = form["categoryId"].ToString(), localId = form["localId"].ToString(), wizardId = form["wizardId"].ToString(); + if (file is null || categoryId == "" || localId == "" || wizardId == "") + return Results.Problem(detail: "Onvolledige upload.", statusCode: 400); + + var category = DocumentRules.Find(wizardId, categoryId); + var reject = DocumentRules.RejectUpload(category, file.ContentType, file.Length); + if (reject is not null) return Results.Problem(detail: reject, statusCode: 400); + + var doc = DocumentStore.Add(localId, categoryId, wizardId, file.FileName, file.Length, DocumentStore.DemoOwner); + return Results.Created($"/api/v1/uploads/{doc.DocumentId}", new UploadResponse(doc.DocumentId, localId)); +}) +.ExcludeFromDescription(); + +// Poll-on-return: which of these client localIds have arrived at the BFF. +api.MapGet("/uploads/status", (string? localIds) => +{ + var ids = (localIds ?? "").Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + var found = DocumentStore.ByLocalIds(ids).ToDictionary(d => d.LocalId); + var results = ids.Select(id => found.TryGetValue(id, out var d) + ? new UploadStatusItemDto(id, "complete", d.DocumentId) + : new UploadStatusItemDto(id, "unknown", null)).ToList(); + return new UploadStatusDto(results); +}); + +// User delete: owner-scoped; 409 once linked to a finalised submission. +api.MapDelete("/uploads/{documentId}", (string documentId) => + DocumentStore.DeleteOwned(documentId, DocumentStore.DemoOwner) switch + { + DocumentStore.DeleteResult.Ok => Results.NoContent(), + DocumentStore.DeleteResult.Linked => Results.Problem( + detail: "Dit document is al gekoppeld aan een ingediende aanvraag en kan niet meer worden verwijderd.", + statusCode: StatusCodes.Status409Conflict), + _ => Results.NotFound(), + }) +.Produces(StatusCodes.Status204NoContent) +.ProducesProblem(StatusCodes.Status409Conflict) +.Produces(StatusCodes.Status404NotFound); + +// Admin delete (seam): a real system requires an admin role; here an X-Admin header +// stands in. Bypasses ownership, unlinks, and flags the submission for review. +api.MapDelete("/admin/uploads/{documentId}", (string documentId, HttpContext ctx) => + !IsAdmin(ctx) ? Results.StatusCode(StatusCodes.Status403Forbidden) + : DocumentStore.AdminDelete(documentId, "admin") ? Results.NoContent() : Results.NotFound()) +.Produces(StatusCodes.Status204NoContent) +.Produces(StatusCodes.Status403Forbidden) +.Produces(StatusCodes.Status404NotFound); + app.Run(); +static bool IsAdmin(HttpContext ctx) => ctx.Request.Headers["X-Admin"] == "true"; + // Audit + outcome for a submit, with NO personal data: only kind, outcome, // generated reference and the caller's correlation id (the observability seam — a // real system ships this to structured logging / an audit store). -IResult Submit(HttpContext ctx, string kind, string? reject) +IResult Submit(HttpContext ctx, string kind, string? reject, IReadOnlyList? documents = null) { var cid = ctx.Request.Headers.TryGetValue("X-Correlation-Id", out var v) && !string.IsNullOrEmpty(v) ? v.ToString() @@ -101,6 +165,15 @@ IResult Submit(HttpContext ctx, string kind, string? reject) return Results.Problem(detail: reject, statusCode: StatusCodes.Status422UnprocessableEntity); } + if (documents is not null) + { + // Link digital documents (blocks later user delete) and record post-delivery + // intent so a caseworker knows to expect the physical document. + DocumentStore.Link(documents.Where(d => d.Channel == "digital" && d.DocumentId is not null).Select(d => d.DocumentId!)); + foreach (var d in documents.Where(d => d.Channel == "post")) + DocumentStore.Audit("post-delivery", d.DocumentId ?? "-", d.CategoryId, cid); + } + var reference = SubmissionRules.NewReference(); app.Logger.LogInformation( "submit kind={Kind} outcome=accepted reference={Reference} correlationId={Cid} at={At:o}", diff --git a/backend/swagger.json b/backend/swagger.json index e30611d..ed155cf 100644 --- a/backend/swagger.json +++ b/backend/swagger.json @@ -282,6 +282,126 @@ } } } + }, + "/api/v1/uploads/categories": { + "get": { + "tags": [ + "BigRegister.Api, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null" + ], + "parameters": [ + { + "name": "wizardId", + "in": "query", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UploadCategoriesDto" + } + } + } + } + } + } + }, + "/api/v1/uploads/status": { + "get": { + "tags": [ + "BigRegister.Api, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null" + ], + "parameters": [ + { + "name": "localIds", + "in": "query", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UploadStatusDto" + } + } + } + } + } + } + }, + "/api/v1/uploads/{documentId}": { + "delete": { + "tags": [ + "BigRegister.Api, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null" + ], + "parameters": [ + { + "name": "documentId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "No Content" + }, + "409": { + "description": "Conflict", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "404": { + "description": "Not Found" + } + } + } + }, + "/api/v1/admin/uploads/{documentId}": { + "delete": { + "tags": [ + "BigRegister.Api, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null" + ], + "parameters": [ + { + "name": "documentId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "No Content" + }, + "403": { + "description": "Forbidden" + }, + "404": { + "description": "Not Found" + } + } + } } }, "components": { @@ -367,6 +487,62 @@ }, "additionalProperties": false }, + "DocumentCategoryDto": { + "type": "object", + "properties": { + "categoryId": { + "type": "string", + "nullable": true + }, + "label": { + "type": "string", + "nullable": true + }, + "description": { + "type": "string", + "nullable": true + }, + "required": { + "type": "boolean" + }, + "acceptedTypes": { + "type": "array", + "items": { + "type": "string" + }, + "nullable": true + }, + "maxSizeMb": { + "type": "integer", + "format": "int32" + }, + "multiple": { + "type": "boolean" + }, + "allowPostDelivery": { + "type": "boolean" + } + }, + "additionalProperties": false + }, + "DocumentRefDto": { + "type": "object", + "properties": { + "categoryId": { + "type": "string", + "nullable": true + }, + "channel": { + "type": "string", + "nullable": true + }, + "documentId": { + "type": "string", + "nullable": true + } + }, + "additionalProperties": false + }, "DuoDiplomaDto": { "type": "object", "properties": { @@ -435,6 +611,13 @@ "uren": { "type": "integer", "format": "int32" + }, + "documents": { + "type": "array", + "items": { + "$ref": "#/components/schemas/DocumentRefDto" + }, + "nullable": true } }, "additionalProperties": false @@ -557,6 +740,13 @@ "diplomaHerkomst": { "type": "string", "nullable": true + }, + "documents": { + "type": "array", + "items": { + "$ref": "#/components/schemas/DocumentRefDto" + }, + "nullable": true } }, "additionalProperties": false @@ -615,6 +805,50 @@ } }, "additionalProperties": false + }, + "UploadCategoriesDto": { + "type": "object", + "properties": { + "categories": { + "type": "array", + "items": { + "$ref": "#/components/schemas/DocumentCategoryDto" + }, + "nullable": true + } + }, + "additionalProperties": false + }, + "UploadStatusDto": { + "type": "object", + "properties": { + "results": { + "type": "array", + "items": { + "$ref": "#/components/schemas/UploadStatusItemDto" + }, + "nullable": true + } + }, + "additionalProperties": false + }, + "UploadStatusItemDto": { + "type": "object", + "properties": { + "localId": { + "type": "string", + "nullable": true + }, + "status": { + "type": "string", + "nullable": true + }, + "documentId": { + "type": "string", + "nullable": true + } + }, + "additionalProperties": false } } }, diff --git a/backend/tests/BigRegister.Tests/EndpointTests.cs b/backend/tests/BigRegister.Tests/EndpointTests.cs index 973ed87..ed37877 100644 --- a/backend/tests/BigRegister.Tests/EndpointTests.cs +++ b/backend/tests/BigRegister.Tests/EndpointTests.cs @@ -1,6 +1,8 @@ using System.Net; +using System.Net.Http.Headers; using System.Net.Http.Json; using BigRegister.Api.Contracts; +using BigRegister.Api.Data; using Microsoft.AspNetCore.Mvc.Testing; namespace BigRegister.Tests; @@ -122,4 +124,89 @@ public class EndpointTests(WebApplicationFactory factory) : IClassFixtu var res = await _client.GetAsync("/health"); res.EnsureSuccessStatusCode(); } + + // --- Document upload --- + + private static MultipartFormDataContent UploadForm(string localId, string categoryId, string wizardId, string fileName, string contentType) + { + var content = new MultipartFormDataContent(); + var file = new ByteArrayContent(new byte[] { 1, 2, 3 }); + file.Headers.ContentType = new MediaTypeHeaderValue(contentType); + content.Add(file, "file", fileName); + content.Add(new StringContent(categoryId), "categoryId"); + content.Add(new StringContent(localId), "localId"); + content.Add(new StringContent(wizardId), "wizardId"); + return content; + } + + private async Task Upload(string localId, string categoryId = "diploma", string type = "application/pdf", string file = "d.pdf") + { + var res = await _client.PostAsync("/api/v1/uploads", UploadForm(localId, categoryId, "registratie", file, type)); + Assert.Equal(HttpStatusCode.Created, res.StatusCode); + return (await res.Content.ReadFromJsonAsync())!; + } + + [Fact] + public async Task Categories_are_server_owned_config() + { + var dto = await _client.GetFromJsonAsync("/api/v1/uploads/categories?wizardId=registratie"); + Assert.Contains(dto!.Categories, c => c.CategoryId == "diploma" && c.Required && !c.AllowPostDelivery); + Assert.Contains(dto.Categories, c => c.CategoryId == "identiteit" && c.AllowPostDelivery); + } + + [Fact] + public async Task Upload_then_status_reports_complete_for_known_localId() + { + var localId = Guid.NewGuid().ToString(); + var doc = await Upload(localId); + var status = await _client.GetFromJsonAsync($"/api/v1/uploads/status?localIds={localId},onbekend"); + Assert.Contains(status!.Results, r => r.LocalId == localId && r.Status == "complete" && r.DocumentId == doc.DocumentId); + Assert.Contains(status.Results, r => r.LocalId == "onbekend" && r.Status == "unknown"); + } + + [Fact] + public async Task Upload_rejects_wrong_type() + { + var res = await _client.PostAsync("/api/v1/uploads", + UploadForm(Guid.NewGuid().ToString(), "diploma", "registratie", "d.txt", "text/plain")); + Assert.Equal(HttpStatusCode.BadRequest, res.StatusCode); + } + + [Fact] + public async Task User_delete_succeeds_then_404() + { + var doc = await Upload(Guid.NewGuid().ToString()); + Assert.Equal(HttpStatusCode.NoContent, (await _client.DeleteAsync($"/api/v1/uploads/{doc.DocumentId}")).StatusCode); + Assert.Equal(HttpStatusCode.NotFound, (await _client.DeleteAsync($"/api/v1/uploads/{doc.DocumentId}")).StatusCode); + } + + [Fact] + public async Task User_delete_blocked_with_409_once_linked_to_submission() + { + var doc = await Upload(Guid.NewGuid().ToString()); + var submit = await _client.PostAsJsonAsync("/api/v1/registrations", + new RegistratieRequest("duo", new[] { new DocumentRefDto("diploma", "digital", doc.DocumentId) })); + submit.EnsureSuccessStatusCode(); + Assert.Equal(HttpStatusCode.Conflict, (await _client.DeleteAsync($"/api/v1/uploads/{doc.DocumentId}")).StatusCode); + } + + [Fact] + public async Task Admin_delete_requires_admin_role() + { + var doc = await Upload(Guid.NewGuid().ToString()); + Assert.Equal(HttpStatusCode.Forbidden, (await _client.DeleteAsync($"/api/v1/admin/uploads/{doc.DocumentId}")).StatusCode); + + var req = new HttpRequestMessage(HttpMethod.Delete, $"/api/v1/admin/uploads/{doc.DocumentId}"); + req.Headers.Add("X-Admin", "true"); + Assert.Equal(HttpStatusCode.NoContent, (await _client.SendAsync(req)).StatusCode); + } + + [Fact] + public async Task Audit_log_records_upload_and_delete_metadata_only() + { + var doc = await Upload(Guid.NewGuid().ToString()); + await _client.DeleteAsync($"/api/v1/uploads/{doc.DocumentId}"); + Assert.Contains(DocumentStore.AuditLog, a => a.DocumentId == doc.DocumentId && a.Action == "upload"); + Assert.Contains(DocumentStore.AuditLog, a => a.DocumentId == doc.DocumentId && a.Action == "delete-user"); + } } diff --git a/backend/tests/BigRegister.Tests/RuleTests.cs b/backend/tests/BigRegister.Tests/RuleTests.cs index c7408b0..006c59e 100644 --- a/backend/tests/BigRegister.Tests/RuleTests.cs +++ b/backend/tests/BigRegister.Tests/RuleTests.cs @@ -1,9 +1,38 @@ using BigRegister.Domain.Diplomas; +using BigRegister.Domain.Documents; using BigRegister.Domain.Registrations; using BigRegister.Domain.Submissions; namespace BigRegister.Tests; +public class DocumentRuleTests +{ + [Fact] + public void Rejects_unknown_category() => + Assert.NotNull(DocumentRules.RejectUpload(null, "application/pdf", 1)); + + [Fact] + public void Rejects_disallowed_type() + { + var c = DocumentRules.Find("registratie", "diploma"); + Assert.NotNull(DocumentRules.RejectUpload(c, "text/plain", 1)); + } + + [Fact] + public void Rejects_oversized_file() + { + var c = DocumentRules.Find("registratie", "diploma"); + Assert.NotNull(DocumentRules.RejectUpload(c, "application/pdf", 11L * 1024 * 1024)); + } + + [Fact] + public void Accepts_valid_file() + { + var c = DocumentRules.Find("registratie", "diploma"); + Assert.Null(DocumentRules.RejectUpload(c, "application/pdf", 5L * 1024 * 1024)); + } +} + public class HerregistratieRuleTests { private static Registration Active(DateOnly deadline) => new( diff --git a/src/app/shared/infrastructure/api-client.ts b/src/app/shared/infrastructure/api-client.ts index 4029faa..96af1ef 100644 --- a/src/app/shared/infrastructure/api-client.ts +++ b/src/app/shared/infrastructure/api-client.ts @@ -446,6 +446,177 @@ export class ApiClient { } return Promise.resolve(null as any); } + + /** + * @return OK + */ + categories(wizardId: string): Promise { + let url_ = this.baseUrl + "/api/v1/uploads/categories?"; + if (wizardId === undefined || wizardId === null) + throw new globalThis.Error("The parameter 'wizardId' must be defined and cannot be null."); + else + url_ += "wizardId=" + encodeURIComponent("" + wizardId) + "&"; + url_ = url_.replace(/[?&]$/, ""); + + let options_: RequestInit = { + method: "GET", + headers: { + "Accept": "application/json" + } + }; + + return this.http.fetch(url_, options_).then((_response: Response) => { + return this.processCategories(_response); + }); + } + + protected processCategories(response: Response): Promise { + const status = response.status; + let _headers: any = {}; if (response.headers && response.headers.forEach) { response.headers.forEach((v: any, k: any) => _headers[k] = v); }; + if (status === 200) { + return response.text().then((_responseText) => { + let result200: any = null; + result200 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as UploadCategoriesDto; + return result200; + }); + } else if (status !== 200 && status !== 204) { + return response.text().then((_responseText) => { + return throwException("An unexpected server error occurred.", status, _responseText, _headers); + }); + } + return Promise.resolve(null as any); + } + + /** + * @param localIds (optional) + * @return OK + */ + status(localIds?: string | undefined): Promise { + let url_ = this.baseUrl + "/api/v1/uploads/status?"; + if (localIds === null) + throw new globalThis.Error("The parameter 'localIds' cannot be null."); + else if (localIds !== undefined) + url_ += "localIds=" + encodeURIComponent("" + localIds) + "&"; + url_ = url_.replace(/[?&]$/, ""); + + let options_: RequestInit = { + method: "GET", + headers: { + "Accept": "application/json" + } + }; + + return this.http.fetch(url_, options_).then((_response: Response) => { + return this.processStatus(_response); + }); + } + + protected processStatus(response: Response): Promise { + const status = response.status; + let _headers: any = {}; if (response.headers && response.headers.forEach) { response.headers.forEach((v: any, k: any) => _headers[k] = v); }; + if (status === 200) { + return response.text().then((_responseText) => { + let result200: any = null; + result200 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as UploadStatusDto; + return result200; + }); + } else if (status !== 200 && status !== 204) { + return response.text().then((_responseText) => { + return throwException("An unexpected server error occurred.", status, _responseText, _headers); + }); + } + return Promise.resolve(null as any); + } + + /** + * @return No Content + */ + uploads(documentId: string): Promise { + let url_ = this.baseUrl + "/api/v1/uploads/{documentId}"; + if (documentId === undefined || documentId === null) + throw new globalThis.Error("The parameter 'documentId' must be defined."); + url_ = url_.replace("{documentId}", encodeURIComponent("" + documentId)); + url_ = url_.replace(/[?&]$/, ""); + + let options_: RequestInit = { + method: "DELETE", + headers: { + } + }; + + return this.http.fetch(url_, options_).then((_response: Response) => { + return this.processUploads(_response); + }); + } + + protected processUploads(response: Response): Promise { + const status = response.status; + let _headers: any = {}; if (response.headers && response.headers.forEach) { response.headers.forEach((v: any, k: any) => _headers[k] = v); }; + if (status === 204) { + return response.text().then((_responseText) => { + return; + }); + } else if (status === 404) { + return response.text().then((_responseText) => { + return throwException("Not Found", status, _responseText, _headers); + }); + } else if (status === 409) { + return response.text().then((_responseText) => { + let result409: any = null; + result409 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as ProblemDetails; + return throwException("Conflict", status, _responseText, _headers, result409); + }); + } else if (status !== 200 && status !== 204) { + return response.text().then((_responseText) => { + return throwException("An unexpected server error occurred.", status, _responseText, _headers); + }); + } + return Promise.resolve(null as any); + } + + /** + * @return No Content + */ + uploads2(documentId: string): Promise { + let url_ = this.baseUrl + "/api/v1/admin/uploads/{documentId}"; + if (documentId === undefined || documentId === null) + throw new globalThis.Error("The parameter 'documentId' must be defined."); + url_ = url_.replace("{documentId}", encodeURIComponent("" + documentId)); + url_ = url_.replace(/[?&]$/, ""); + + let options_: RequestInit = { + method: "DELETE", + headers: { + } + }; + + return this.http.fetch(url_, options_).then((_response: Response) => { + return this.processUploads2(_response); + }); + } + + protected processUploads2(response: Response): Promise { + const status = response.status; + let _headers: any = {}; if (response.headers && response.headers.forEach) { response.headers.forEach((v: any, k: any) => _headers[k] = v); }; + if (status === 204) { + return response.text().then((_responseText) => { + return; + }); + } else if (status === 403) { + return response.text().then((_responseText) => { + return throwException("Forbidden", status, _responseText, _headers); + }); + } else if (status === 404) { + return response.text().then((_responseText) => { + return throwException("Not Found", status, _responseText, _headers); + }); + } else if (status !== 200 && status !== 204) { + return response.text().then((_responseText) => { + return throwException("An unexpected server error occurred.", status, _responseText, _headers); + }); + } + return Promise.resolve(null as any); + } } export interface AantekeningDto { @@ -477,6 +648,23 @@ export interface DashboardViewDto { decisions?: HerregistratieDecisionsDto; } +export interface DocumentCategoryDto { + categoryId?: string | undefined; + label?: string | undefined; + description?: string | undefined; + required?: boolean; + acceptedTypes?: string[] | undefined; + maxSizeMb?: number; + multiple?: boolean; + allowPostDelivery?: boolean; +} + +export interface DocumentRefDto { + categoryId?: string | undefined; + channel?: string | undefined; + documentId?: string | undefined; +} + export interface DuoDiplomaDto { id?: string | undefined; naam?: string | undefined; @@ -498,6 +686,7 @@ export interface HerregistratieDecisionsDto { export interface HerregistratieRequest { uren?: number; + documents?: DocumentRefDto[] | undefined; } export interface IntakePolicyDto { @@ -541,6 +730,7 @@ export interface ReferentieResponse { export interface RegistratieRequest { diplomaHerkomst?: string | undefined; + documents?: DocumentRefDto[] | undefined; } export interface RegistrationDto { @@ -560,6 +750,20 @@ export interface RegistrationStatusDto { doorgehaaldOp?: string | undefined; } +export interface UploadCategoriesDto { + categories?: DocumentCategoryDto[] | undefined; +} + +export interface UploadStatusDto { + results?: UploadStatusItemDto[] | undefined; +} + +export interface UploadStatusItemDto { + localId?: string | undefined; + status?: string | undefined; + documentId?: string | undefined; +} + export class SwaggerException extends Error { override message: string; status: number;