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:
@@ -46,11 +46,28 @@ public sealed record DuoLookupDto(IReadOnlyList<DuoDiplomaDto> Diplomas, ManualD
|
||||
|
||||
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
|
||||
// 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 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 ReferentieResponse(string Referentie);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
101
backend/src/BigRegister.Api/Data/DocumentStore.cs
Normal file
101
backend/src/BigRegister.Api/Data/DocumentStore.cs
Normal 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(); }
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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<ReferentieResponse>()
|
||||
.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<ReferentieResponse>()
|
||||
.ProducesProblem(StatusCodes.Status422UnprocessableEntity);
|
||||
|
||||
@@ -84,12 +85,75 @@ api.MapPost("/change-requests", (ChangeRequestRequest req, HttpContext ctx) =>
|
||||
.Produces<ReferentieResponse>()
|
||||
.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<DocumentRefDto>? 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}",
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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<Program> 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<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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -446,6 +446,177 @@ export class ApiClient {
|
||||
}
|
||||
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 {
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user