Upload feature (a): BFF endpoints + category config + tests

- 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 <noreply@anthropic.com>
This commit is contained in:
2026-06-27 20:15:40 +02:00
parent a079d3259e
commit 0d37cc097a
9 changed files with 813 additions and 5 deletions

View File

@@ -46,11 +46,28 @@ public sealed record DuoLookupDto(IReadOnlyList<DuoDiplomaDto> Diplomas, ManualD
public sealed record IntakePolicyDto(int ScholingThreshold); public sealed record IntakePolicyDto(int ScholingThreshold);
// --- Document upload contracts ---
public sealed record DocumentCategoryDto(
string CategoryId, string Label, string Description, bool Required,
IReadOnlyList<string> AcceptedTypes, int MaxSizeMb, bool Multiple, bool AllowPostDelivery);
public sealed record UploadCategoriesDto(IReadOnlyList<DocumentCategoryDto> Categories);
public sealed record UploadResponse(string DocumentId, string LocalId);
public sealed record UploadStatusItemDto(string LocalId, string Status, string? DocumentId);
public sealed record UploadStatusDto(IReadOnlyList<UploadStatusItemDto> 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 // 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. // 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<DocumentRefDto>? Documents = null);
public sealed record IntakeRequest(int Uren); public sealed record IntakeRequest(int Uren);
public sealed record HerregistratieRequest(int Uren); public sealed record HerregistratieRequest(int Uren, IReadOnlyList<DocumentRefDto>? Documents = null);
public sealed record ChangeRequestRequest(string Straat, string Postcode, string Woonplaats); public sealed record ChangeRequestRequest(string Straat, string Postcode, string Woonplaats);
public sealed record ReferentieResponse(string Referentie); public sealed record ReferentieResponse(string Referentie);

View File

@@ -1,4 +1,5 @@
using BigRegister.Domain.Diplomas; using BigRegister.Domain.Diplomas;
using BigRegister.Domain.Documents;
using BigRegister.Domain.People; using BigRegister.Domain.People;
using BigRegister.Domain.Registrations; using BigRegister.Domain.Registrations;
@@ -30,4 +31,7 @@ public static class Mappers
d.Id, d.Naam, d.Instelling, d.Jaar, d.Id, d.Naam, d.Instelling, d.Jaar,
DiplomaRules.ProfessionFor(d), DiplomaRules.ProfessionFor(d),
DiplomaRules.QuestionsFor(d).Select(q => q.ToDto()).ToList()); 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);
} }

View File

@@ -0,0 +1,101 @@
namespace BigRegister.Api.Data;
/// <summary>
/// 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.
/// </summary>
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);
/// <summary>
/// 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).
/// </summary>
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<string, StoredDocument> _docs = new();
private static readonly List<AuditEntry> _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<StoredDocument> ByLocalIds(IEnumerable<string> 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<string> 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<AuditEntry> AuditLog
{
get { lock (_gate) return _audit.ToList(); }
}
}

View File

@@ -0,0 +1,59 @@
namespace BigRegister.Domain.Documents;
/// <summary>
/// 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.
/// </summary>
public sealed record DocumentCategory(
string CategoryId,
string Label,
string Description,
bool Required,
IReadOnlyList<string> 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" };
/// <summary>The categories that apply to a given wizard/step.</summary>
public static IReadOnlyList<DocumentCategory> 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<DocumentCategory>(),
};
public static DocumentCategory? Find(string wizardId, string categoryId) =>
CategoriesFor(wizardId).FirstOrDefault(c => c.CategoryId == categoryId);
/// <summary>
/// Authoritative upload validation (the client check is UX-only). Returns a
/// rejection reason, or null when the file is acceptable.
/// </summary>
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;
}
}

View File

@@ -3,6 +3,7 @@ using System.Text.Json.Serialization;
using BigRegister.Api.Contracts; using BigRegister.Api.Contracts;
using BigRegister.Api.Data; using BigRegister.Api.Data;
using BigRegister.Domain.Diplomas; using BigRegister.Domain.Diplomas;
using BigRegister.Domain.Documents;
using BigRegister.Domain.Intake; using BigRegister.Domain.Intake;
using BigRegister.Domain.Registrations; using BigRegister.Domain.Registrations;
using BigRegister.Domain.Submissions; 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. --- // --- POST: submits. The server is the authority; it re-validates and decides. ---
api.MapPost("/registrations", (RegistratieRequest req, HttpContext ctx) => api.MapPost("/registrations", (RegistratieRequest req, HttpContext ctx) =>
Submit(ctx, "registratie", SubmissionRules.RejectRegistratie(req.DiplomaHerkomst))) Submit(ctx, "registratie", SubmissionRules.RejectRegistratie(req.DiplomaHerkomst), req.Documents))
.Produces<ReferentieResponse>() .Produces<ReferentieResponse>()
.ProducesProblem(StatusCodes.Status422UnprocessableEntity); .ProducesProblem(StatusCodes.Status422UnprocessableEntity);
api.MapPost("/herregistraties", (HerregistratieRequest req, HttpContext ctx) => api.MapPost("/herregistraties", (HerregistratieRequest req, HttpContext ctx) =>
Submit(ctx, "herregistratie", SubmissionRules.RejectZeroUren(req.Uren))) Submit(ctx, "herregistratie", SubmissionRules.RejectZeroUren(req.Uren), req.Documents))
.Produces<ReferentieResponse>() .Produces<ReferentieResponse>()
.ProducesProblem(StatusCodes.Status422UnprocessableEntity); .ProducesProblem(StatusCodes.Status422UnprocessableEntity);
@@ -84,12 +85,75 @@ api.MapPost("/change-requests", (ChangeRequestRequest req, HttpContext ctx) =>
.Produces<ReferentieResponse>() .Produces<ReferentieResponse>()
.ProducesProblem(StatusCodes.Status422UnprocessableEntity); .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(); 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, // Audit + outcome for a submit, with NO personal data: only kind, outcome,
// generated reference and the caller's correlation id (the observability seam — a // generated reference and the caller's correlation id (the observability seam — a
// real system ships this to structured logging / an audit store). // 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<DocumentRefDto>? documents = null)
{ {
var cid = ctx.Request.Headers.TryGetValue("X-Correlation-Id", out var v) && !string.IsNullOrEmpty(v) var cid = ctx.Request.Headers.TryGetValue("X-Correlation-Id", out var v) && !string.IsNullOrEmpty(v)
? v.ToString() ? v.ToString()
@@ -101,6 +165,15 @@ IResult Submit(HttpContext ctx, string kind, string? reject)
return Results.Problem(detail: reject, statusCode: StatusCodes.Status422UnprocessableEntity); 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(); var reference = SubmissionRules.NewReference();
app.Logger.LogInformation( app.Logger.LogInformation(
"submit kind={Kind} outcome=accepted reference={Reference} correlationId={Cid} at={At:o}", "submit kind={Kind} outcome=accepted reference={Reference} correlationId={Cid} at={At:o}",

View File

@@ -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": { "components": {
@@ -367,6 +487,62 @@
}, },
"additionalProperties": false "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": { "DuoDiplomaDto": {
"type": "object", "type": "object",
"properties": { "properties": {
@@ -435,6 +611,13 @@
"uren": { "uren": {
"type": "integer", "type": "integer",
"format": "int32" "format": "int32"
},
"documents": {
"type": "array",
"items": {
"$ref": "#/components/schemas/DocumentRefDto"
},
"nullable": true
} }
}, },
"additionalProperties": false "additionalProperties": false
@@ -557,6 +740,13 @@
"diplomaHerkomst": { "diplomaHerkomst": {
"type": "string", "type": "string",
"nullable": true "nullable": true
},
"documents": {
"type": "array",
"items": {
"$ref": "#/components/schemas/DocumentRefDto"
},
"nullable": true
} }
}, },
"additionalProperties": false "additionalProperties": false
@@ -615,6 +805,50 @@
} }
}, },
"additionalProperties": false "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
} }
} }
}, },

View File

@@ -1,6 +1,8 @@
using System.Net; using System.Net;
using System.Net.Http.Headers;
using System.Net.Http.Json; using System.Net.Http.Json;
using BigRegister.Api.Contracts; using BigRegister.Api.Contracts;
using BigRegister.Api.Data;
using Microsoft.AspNetCore.Mvc.Testing; using Microsoft.AspNetCore.Mvc.Testing;
namespace BigRegister.Tests; namespace BigRegister.Tests;
@@ -122,4 +124,89 @@ public class EndpointTests(WebApplicationFactory<Program> factory) : IClassFixtu
var res = await _client.GetAsync("/health"); var res = await _client.GetAsync("/health");
res.EnsureSuccessStatusCode(); 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<UploadResponse> 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<UploadResponse>())!;
}
[Fact]
public async Task Categories_are_server_owned_config()
{
var dto = await _client.GetFromJsonAsync<UploadCategoriesDto>("/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<UploadStatusDto>($"/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");
}
} }

View File

@@ -1,9 +1,38 @@
using BigRegister.Domain.Diplomas; using BigRegister.Domain.Diplomas;
using BigRegister.Domain.Documents;
using BigRegister.Domain.Registrations; using BigRegister.Domain.Registrations;
using BigRegister.Domain.Submissions; using BigRegister.Domain.Submissions;
namespace BigRegister.Tests; 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 public class HerregistratieRuleTests
{ {
private static Registration Active(DateOnly deadline) => new( private static Registration Active(DateOnly deadline) => new(

View File

@@ -446,6 +446,177 @@ export class ApiClient {
} }
return Promise.resolve<ReferentieResponse>(null as any); return Promise.resolve<ReferentieResponse>(null as any);
} }
/**
* @return OK
*/
categories(wizardId: string): Promise<UploadCategoriesDto> {
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<UploadCategoriesDto> {
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<UploadCategoriesDto>(null as any);
}
/**
* @param localIds (optional)
* @return OK
*/
status(localIds?: string | undefined): Promise<UploadStatusDto> {
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<UploadStatusDto> {
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<UploadStatusDto>(null as any);
}
/**
* @return No Content
*/
uploads(documentId: string): Promise<void> {
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<void> {
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<void>(null as any);
}
/**
* @return No Content
*/
uploads2(documentId: string): Promise<void> {
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<void> {
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<void>(null as any);
}
} }
export interface AantekeningDto { export interface AantekeningDto {
@@ -477,6 +648,23 @@ export interface DashboardViewDto {
decisions?: HerregistratieDecisionsDto; 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 { export interface DuoDiplomaDto {
id?: string | undefined; id?: string | undefined;
naam?: string | undefined; naam?: string | undefined;
@@ -498,6 +686,7 @@ export interface HerregistratieDecisionsDto {
export interface HerregistratieRequest { export interface HerregistratieRequest {
uren?: number; uren?: number;
documents?: DocumentRefDto[] | undefined;
} }
export interface IntakePolicyDto { export interface IntakePolicyDto {
@@ -541,6 +730,7 @@ export interface ReferentieResponse {
export interface RegistratieRequest { export interface RegistratieRequest {
diplomaHerkomst?: string | undefined; diplomaHerkomst?: string | undefined;
documents?: DocumentRefDto[] | undefined;
} }
export interface RegistrationDto { export interface RegistrationDto {
@@ -560,6 +750,20 @@ export interface RegistrationStatusDto {
doorgehaaldOp?: string | undefined; 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 { export class SwaggerException extends Error {
override message: string; override message: string;
status: number; status: number;