Compare commits
8
Commits
d2c2cffc1f
...
868fb55783
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
868fb55783 | ||
|
|
472a49f19f | ||
|
|
31d4aa1848 | ||
|
|
fc6e73806a | ||
|
|
fd04221d2f | ||
|
|
a394950a1d | ||
|
|
6a4a0ad435 | ||
|
|
6cfd70eeeb |
@@ -50,7 +50,7 @@ dotnet test --filter Category=Integration
|
||||
`OpenZaakIntegrationTests.cs` points a `WebApplicationFactory<Program>` at
|
||||
`Zgw:Enabled=true` + `http://localhost:8000` with the harness's credentials, hits
|
||||
`GET /api/v1/admin/cases`, and asserts the seeded zaak comes back — through the real HTTP +
|
||||
JWT + Catalogi-label-resolution path, not a mock. This test is tagged `Category=Integration`
|
||||
JWT + zaaktype→aanvraag-type mapping path, not a mock. This test is tagged `Category=Integration`
|
||||
and is **excluded** from the default `dotnet test` run and from CI (`ci.yml`,
|
||||
`scripts/ci-local.sh` both filter `Category!=Integration`) — it only passes with this harness
|
||||
up, so it never runs where the harness doesn't exist.
|
||||
|
||||
@@ -168,8 +168,8 @@ print(json.dumps({
|
||||
echo " created: $zaaktype_url"
|
||||
fi
|
||||
|
||||
echo "Granting zrc scopes (zaken.aanmaken, zaken.bijwerken, zaken.lezen), scoped to $zaaktype_url — the one zaaktype this harness (and the BFF's Zgw:ZaaktypeUrls config) ever uses..."
|
||||
grant_scopes zrc '["zaken.aanmaken", "zaken.bijwerken", "zaken.lezen"]' \
|
||||
echo "Granting zrc scopes (zaken.aanmaken, zaken.bijwerken, zaken.lezen, zaken.statussen.toevoegen), scoped to $zaaktype_url — the one zaaktype this harness (and the BFF's Zgw:ZaaktypeUrls config) ever uses. zaken.statussen.toevoegen is needed for WP-66's besluit write: zaken.aanmaken only covers the ONE status set at zaak creation, a later status (the besluit's eindstatus) needs this scope or OpenZaak 403s ('mag je slechts 1 status zetten')..."
|
||||
grant_scopes zrc '["zaken.aanmaken", "zaken.bijwerken", "zaken.lezen", "zaken.statussen.toevoegen"]' \
|
||||
"zaaktype=\"$zaaktype_url\"" \
|
||||
'max_vertrouwelijkheidaanduiding="openbaar"'
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using BigRegister.Api.Data;
|
||||
using BigRegister.Domain.Applications;
|
||||
using BigRegister.Domain.Diplomas;
|
||||
using BigRegister.Domain.Documents;
|
||||
using BigRegister.Domain.People;
|
||||
@@ -36,31 +37,17 @@ public static class Mappers
|
||||
public static DocumentCategoryDto ToDto(this DocumentCategory c) => new(
|
||||
c.CategoryId, c.Label, c.Description, c.Required, c.AcceptedTypes, c.MaxSizeMb, c.Multiple, c.AllowPostDelivery);
|
||||
|
||||
// Aanvraag status is COMPUTED ON READ: an auto-approvable submission reports
|
||||
// Goedgekeurd once past the processing window, else In behandeling; a manual case
|
||||
// stays In behandeling until a behandelaar records a decision (WP-65b — before that
|
||||
// WP, it stayed In behandeling forever, awaiting the then-unbuilt backoffice). Pure —
|
||||
// testable by passing different `now` values without waiting for the wall clock.
|
||||
public static AanvraagStatusDto ToStatusDto(this Aanvraag a, DateTimeOffset now)
|
||||
{
|
||||
if (!a.Submitted)
|
||||
return new("Concept", StepIndex: a.StepIndex, StepCount: a.StepCount);
|
||||
if (a.Reden is not null)
|
||||
return new(AanvraagStatusTag.Afgewezen.ToString(), Referentie: a.Referentie, Reden: a.Reden);
|
||||
// A recorded decision (WP-65b) wins over the auto-approve computation below — a
|
||||
// behandelaar's explicit besluit is authoritative once made.
|
||||
if (a.BesluitStatus is { } besluit)
|
||||
return besluit switch
|
||||
{
|
||||
Besluit.Goedkeuren => new(AanvraagStatusTag.Goedgekeurd.ToString(), Referentie: a.Referentie),
|
||||
Besluit.Afwijzen => new(AanvraagStatusTag.Afgewezen.ToString(), Referentie: a.Referentie, Reden: a.BesluitToelichting),
|
||||
Besluit.MeerInfoOpvragen => new(AanvraagStatusTag.MeerInfoGevraagd.ToString(), Referentie: a.Referentie, Reden: a.BesluitToelichting),
|
||||
_ => throw new InvalidOperationException($"Unknown besluit {besluit}"),
|
||||
};
|
||||
if (a.AutoApprovable && now > a.SubmittedAt!.Value + ApplicationStore.ProcessingWindow)
|
||||
return new(AanvraagStatusTag.Goedgekeurd.ToString(), Referentie: a.Referentie);
|
||||
return new(AanvraagStatusTag.InBehandeling.ToString(), Referentie: a.Referentie, Manual: !a.AutoApprovable);
|
||||
}
|
||||
/// <summary>Wire projection of an <see cref="AanvraagStatus"/> — the null "Concept" case
|
||||
/// is the one place a status has no <see cref="AanvraagStatusTag"/>, so it becomes the wire
|
||||
/// convention's magic string here at the boundary rather than living inside the domain type.
|
||||
/// Shared by <see cref="ToStatusDto"/> and <c>ZgwZaakMapper</c>, so both status producers
|
||||
/// agree on the projection (WP-68 F3).</summary>
|
||||
public static AanvraagStatusDto ToDto(this AanvraagStatus s) => new(
|
||||
s.Tag?.ToString() ?? "Concept", s.StepIndex, s.StepCount, s.Referentie, s.Manual, s.Reden);
|
||||
|
||||
// Aanvraag status is COMPUTED ON READ (see Aanvraag.StatusAt) — this is now a one-line
|
||||
// projection of that domain method onto the wire DTO (WP-68 F3).
|
||||
public static AanvraagStatusDto ToStatusDto(this Aanvraag a, DateTimeOffset now) => a.StatusAt(now).ToDto();
|
||||
|
||||
public static ApplicationSummaryDto ToSummaryDto(this Aanvraag a, DateTimeOffset now) => new(
|
||||
a.Id, a.Type, a.ToStatusDto(now), a.DocumentIds,
|
||||
|
||||
@@ -1,31 +1,15 @@
|
||||
using System.Text.Json;
|
||||
using BigRegister.Domain.Applications;
|
||||
using BigRegister.Domain.Beoordeling;
|
||||
using BigRegister.Domain.Submissions;
|
||||
|
||||
namespace BigRegister.Api.Data;
|
||||
|
||||
/// <summary>
|
||||
/// The post-submission aanvraag status lifecycle (ADR-0002, WP-63): Ingediend → In
|
||||
/// behandeling → (Meer info gevraagd ⇄) → Goedgekeurd/Afgewezen. Concept (pre-submission,
|
||||
/// the wizard draft) isn't part of this enum — see <see cref="Aanvraag.Submitted"/>.
|
||||
/// <see cref="Ingediend"/> and <see cref="MeerInfoGevraagd"/> are not reachable yet: no
|
||||
/// endpoint sets them (that's WP-65's behandelaar-facing mutation) — modelled here so the
|
||||
/// contract is ready when it does.
|
||||
/// </summary>
|
||||
public enum AanvraagStatusTag { Ingediend, InBehandeling, MeerInfoGevraagd, Goedgekeurd, Afgewezen }
|
||||
|
||||
/// <summary>
|
||||
/// A behandelaar's recorded decision (WP-65b) — the three actions the beoordeling screen
|
||||
/// offers, each advancing <see cref="Aanvraag.BesluitStatus"/> and (via
|
||||
/// <see cref="BigRegister.Api.Contracts.Mappers.ToStatusDto"/>) the published
|
||||
/// <see cref="AanvraagStatusTag"/> the FE renders.
|
||||
/// </summary>
|
||||
public enum Besluit { Goedkeuren, Afwijzen, MeerInfoOpvragen }
|
||||
|
||||
/// <summary>
|
||||
/// An application (aanvraag) — the system of record the dashboard reads. A wizard
|
||||
/// creates one as a Concept on its first step, syncs its draft snapshot per step,
|
||||
/// then submits it into the Concept → In behandeling → Goedgekeurd/Afgewezen
|
||||
/// lifecycle (ADR-0002). Status is COMPUTED ON READ (see Mappers.ToStatusDto) so
|
||||
/// lifecycle (ADR-0002). Status is COMPUTED ON READ (see <see cref="StatusAt"/>) so
|
||||
/// auto-approval is purely a function of stored timestamps — no timers, no jobs.
|
||||
/// </summary>
|
||||
public sealed class Aanvraag
|
||||
@@ -69,6 +53,26 @@ public sealed class Aanvraag
|
||||
/// <summary>The behandelaar's toelichting — required for Afwijzen/MeerInfoOpvragen (becomes
|
||||
/// the published status's Reden), optional for Goedkeuren.</summary>
|
||||
public string? BesluitToelichting { get; set; }
|
||||
|
||||
/// <summary>The status at a point in time (WP-68 F3) — moved here from
|
||||
/// <c>Contracts.Mappers.ToStatusDto</c>, which is now a one-line projection of this. A
|
||||
/// recorded decision wins over the auto-approve computation below.</summary>
|
||||
public AanvraagStatus StatusAt(DateTimeOffset now)
|
||||
{
|
||||
if (!Submitted) return AanvraagStatus.Concept(StepIndex, StepCount);
|
||||
if (Reden is not null) return AanvraagStatus.Afgewezen(Referentie!, Reden);
|
||||
if (BesluitStatus is { } besluit)
|
||||
return besluit switch
|
||||
{
|
||||
Besluit.Goedkeuren => AanvraagStatus.Goedgekeurd(Referentie!),
|
||||
Besluit.Afwijzen => AanvraagStatus.Afgewezen(Referentie!, BesluitToelichting),
|
||||
Besluit.MeerInfoOpvragen => AanvraagStatus.MeerInfoGevraagd(Referentie!, BesluitToelichting),
|
||||
_ => throw new InvalidOperationException($"Unknown besluit {besluit}"),
|
||||
};
|
||||
if (AutoApprovable && now > SubmittedAt!.Value + ApplicationStore.ProcessingWindow)
|
||||
return AanvraagStatus.Goedgekeurd(Referentie!);
|
||||
return AanvraagStatus.InBehandeling(Referentie!, manual: !AutoApprovable);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -134,6 +138,21 @@ public static class ApplicationStore
|
||||
}
|
||||
}
|
||||
|
||||
/// Cross-owner lookup by Referentie — real bug fix (WP-66): the behandelaar besluit
|
||||
/// endpoint receives the FE-facing case id from <c>IZaakSource.ListCases</c>, which under
|
||||
/// <c>OpenZaakZaakSource</c> is the ZGW zaak's own uuid, NOT this store's primary key (only
|
||||
/// <c>LocalZaakSource</c>'s id happens to already be the Aanvraag.Id — every besluit 404'd
|
||||
/// against a real OpenZaak). Referentie is the one identifier stable across both sources —
|
||||
/// it's also what <c>CreateZaak</c> sent OpenZaak as <c>identificatie</c>.
|
||||
public static Aanvraag? GetByReferentie(string referentie)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
using var db = Db.Create();
|
||||
return db.Applications.FirstOrDefault(a => a.Referentie == referentie);
|
||||
}
|
||||
}
|
||||
|
||||
/// Admin: every case across all owners (WP-36). The per-owner List is the norm; this
|
||||
/// is the deliberate cross-owner read behind the admin-only /admin/cases endpoint.
|
||||
public static IReadOnlyList<Aanvraag> ListAll()
|
||||
@@ -255,23 +274,30 @@ public static class ApplicationStore
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Record a behandelaar's decision (WP-65b). The endpoint has already checked
|
||||
/// <see cref="BigRegister.Domain.Beoordeling.BeoordelingRules.CanDecide"/> against the
|
||||
/// freshly-read status before calling this — cross-owner like <see cref="DeleteAny"/>,
|
||||
/// since a behandelaar decides on any citizen's case. Returns null only if the aanvraag
|
||||
/// is gone (shouldn't happen — this runs right after the endpoint's own read found it).</summary>
|
||||
public static Aanvraag? RecordBesluit(string id, Besluit besluit, string? toelichting)
|
||||
public enum RecordBesluitOutcome { Ok, NotFound, Conflict }
|
||||
|
||||
/// <summary>Record a behandelaar's decision (WP-65b) — cross-owner like
|
||||
/// <see cref="DeleteAny"/>, since a behandelaar decides on any citizen's case.
|
||||
/// WP-68 (F2): the transition-legality check (<see cref="BeoordelingRules.CanDecide"/>)
|
||||
/// now runs INSIDE this lock, against a status read fresh under the lock, rather than in
|
||||
/// the endpoint beforehand — two concurrent besluiten used to both pass the endpoint's
|
||||
/// check before either wrote, letting the second silently overwrite a terminal decision.
|
||||
/// </summary>
|
||||
public static (RecordBesluitOutcome Outcome, Aanvraag? Aanvraag) RecordBesluit(string id, Besluit besluit, string? toelichting, DateTimeOffset now)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
using var db = Db.Create();
|
||||
var a = db.Applications.Find(id);
|
||||
if (a is null) return null;
|
||||
if (a is null) return (RecordBesluitOutcome.NotFound, null);
|
||||
var current = a.StatusAt(now).Tag;
|
||||
if (current is null || !BeoordelingRules.CanDecide(current.Value))
|
||||
return (RecordBesluitOutcome.Conflict, null);
|
||||
a.BesluitStatus = besluit;
|
||||
a.BesluitToelichting = toelichting;
|
||||
a.UpdatedAt = DateTimeOffset.UtcNow;
|
||||
db.SaveChanges();
|
||||
return a;
|
||||
return (RecordBesluitOutcome.Ok, a);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -96,6 +96,22 @@ public static class DocumentStore
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Which of the given ids do NOT resolve to a document owned by <paramref name="owner"/>
|
||||
/// (unknown id or owned by someone else) — named for what it returns (the offending ids), so a
|
||||
/// caller can 400 with the specific ids rather than a bare boolean. Guards submit/draft-sync
|
||||
/// against a citizen attaching another citizen's upload to their own aanvraag.</summary>
|
||||
public static IReadOnlyList<string> ForeignIds(IEnumerable<string> documentIds, string owner)
|
||||
{
|
||||
var ids = documentIds.ToList();
|
||||
lock (_gate)
|
||||
{
|
||||
using var db = Db.Create();
|
||||
var owned = db.Documents.Where(d => ids.Contains(d.DocumentId) && d.Owner == owner)
|
||||
.Select(d => d.DocumentId).ToHashSet();
|
||||
return ids.Where(id => !owned.Contains(id)).ToList();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Persist the DRC url an OpenZaak upload (WP-51) registered for a document.</summary>
|
||||
public static void SetDrcUrl(string documentId, string drcUrl)
|
||||
{
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using BigRegister.Api.Contracts;
|
||||
using BigRegister.Domain.Applications;
|
||||
using BigRegister.Domain.Authorization;
|
||||
|
||||
namespace BigRegister.Api.Data;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using BigRegister.Api.Contracts;
|
||||
using BigRegister.Domain.Applications;
|
||||
using BigRegister.Domain.Authorization;
|
||||
|
||||
namespace BigRegister.Api.Data;
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
namespace BigRegister.Domain.Applications;
|
||||
|
||||
/// <summary>
|
||||
/// The post-submission aanvraag status lifecycle (ADR-0002, WP-63): Ingediend → In
|
||||
/// behandeling → (Meer info gevraagd ⇄) → Goedgekeurd/Afgewezen. Concept (pre-submission,
|
||||
/// the wizard draft) is deliberately NOT a member here — see <see cref="AanvraagStatus.Tag"/>,
|
||||
/// which is null exactly when the aanvraag hasn't been submitted yet, instead of a sixth
|
||||
/// "magic string" tag with no enum member to match it (WP-68 F3).
|
||||
/// <see cref="Ingediend"/> is reserved: no endpoint sets it yet (there is no state between
|
||||
/// "just submitted" and "in behandeling" in this POC) — kept because the FE's status union
|
||||
/// and $localize catalogue already declare it, and removing it would ripple into both.
|
||||
/// </summary>
|
||||
public enum AanvraagStatusTag { Ingediend, InBehandeling, MeerInfoGevraagd, Goedgekeurd, Afgewezen }
|
||||
|
||||
/// <summary>
|
||||
/// A behandelaar's recorded decision (WP-65b) — the three actions the beoordeling screen
|
||||
/// offers, each advancing an aanvraag's <see cref="AanvraagStatus"/>.
|
||||
/// </summary>
|
||||
public enum Besluit { Goedkeuren, Afwijzen, MeerInfoOpvragen }
|
||||
|
||||
/// <summary>
|
||||
/// The domain projection of an aanvraag's status at a point in time (WP-68 F3) — the type
|
||||
/// <c>Aanvraag.StatusAt(now)</c> returns, replacing the logic that used to live directly in
|
||||
/// <c>Contracts.Mappers.ToStatusDto</c>. Constructible only via the factories below, so a
|
||||
/// caller can never build e.g. a Referentie-less Goedgekeurd. <see cref="Tag"/> is null only
|
||||
/// for <see cref="Concept"/> — the one construction path that used to be a bare "Concept"
|
||||
/// string with no corresponding <see cref="AanvraagStatusTag"/> member.
|
||||
/// </summary>
|
||||
public sealed class AanvraagStatus
|
||||
{
|
||||
public AanvraagStatusTag? Tag { get; }
|
||||
public int? StepIndex { get; }
|
||||
public int? StepCount { get; }
|
||||
public string? Referentie { get; }
|
||||
public bool? Manual { get; }
|
||||
public string? Reden { get; }
|
||||
|
||||
private AanvraagStatus(AanvraagStatusTag? tag, int? stepIndex, int? stepCount, string? referentie, bool? manual, string? reden)
|
||||
{
|
||||
Tag = tag;
|
||||
StepIndex = stepIndex;
|
||||
StepCount = stepCount;
|
||||
Referentie = referentie;
|
||||
Manual = manual;
|
||||
Reden = reden;
|
||||
}
|
||||
|
||||
public static AanvraagStatus Concept(int stepIndex, int stepCount) =>
|
||||
new(null, stepIndex, stepCount, null, null, null);
|
||||
|
||||
public static AanvraagStatus InBehandeling(string referentie, bool manual) =>
|
||||
new(AanvraagStatusTag.InBehandeling, null, null, referentie, manual, null);
|
||||
|
||||
public static AanvraagStatus Goedgekeurd(string referentie) =>
|
||||
new(AanvraagStatusTag.Goedgekeurd, null, null, referentie, null, null);
|
||||
|
||||
public static AanvraagStatus Afgewezen(string referentie, string? reden) =>
|
||||
new(AanvraagStatusTag.Afgewezen, null, null, referentie, null, reden);
|
||||
|
||||
public static AanvraagStatus MeerInfoGevraagd(string referentie, string? reden) =>
|
||||
new(AanvraagStatusTag.MeerInfoGevraagd, null, null, referentie, null, reden);
|
||||
}
|
||||
@@ -1,19 +1,26 @@
|
||||
using BigRegister.Api.Data;
|
||||
using BigRegister.Domain.Applications;
|
||||
|
||||
namespace BigRegister.Domain.Beoordeling;
|
||||
|
||||
/// <summary>
|
||||
/// SERVER-OWNED rules for the behandelportal's case-treatment decision (WP-65). Read-side
|
||||
/// today (<see cref="CanDecide"/> only, backing the beoordeling detail screen's decision
|
||||
/// flag) — the decision-recording rules (which besluit is legal, whether it needs a
|
||||
/// toelichting) land alongside the mutation endpoint in this WP's second half.
|
||||
/// SERVER-OWNED rules for the behandelportal's case-treatment decision (WP-65). Used from
|
||||
/// both the beoordeling read side (<see cref="CanDecide"/> backs the `canBesluiten` decision
|
||||
/// flag) and the besluit write side (the SAME `CanDecide` gates the mutation, and — since
|
||||
/// WP-68 F2 — runs inside the write lock, so the two can never drift and a concurrent besluit
|
||||
/// can't race past the check).
|
||||
/// </summary>
|
||||
public static class BeoordelingRules
|
||||
{
|
||||
/// A behandelaar may record a decision while the aanvraag is in an open, non-terminal
|
||||
/// status. Concept never reaches here (the endpoint 404s it before calling this); a case
|
||||
/// already `Goedgekeurd`/`Afgewezen` is final.
|
||||
/// status. Concept never reaches here (a null <see cref="AanvraagStatus.Tag"/> is checked
|
||||
/// separately by callers); a case already `Goedgekeurd`/`Afgewezen` is final.
|
||||
public static bool CanDecide(AanvraagStatusTag current) =>
|
||||
current is AanvraagStatusTag.Ingediend or AanvraagStatusTag.InBehandeling
|
||||
or AanvraagStatusTag.MeerInfoGevraagd;
|
||||
|
||||
/// <summary>WP-68 F6: moved here from an inline check in the besluit endpoint. The
|
||||
/// toelichting (behandelaar's explanation) is required for every besluit except an
|
||||
/// approval — Afwijzen/MeerInfoOpvragen must justify why (becomes the published status's
|
||||
/// Reden).</summary>
|
||||
public static bool RequiresToelichting(Besluit besluit) => besluit != Besluit.Goedkeuren;
|
||||
}
|
||||
|
||||
@@ -1,9 +1,18 @@
|
||||
namespace BigRegister.Domain.Intake;
|
||||
|
||||
/// <summary>
|
||||
/// SERVER-OWNED config value. Below this many NL work-hours the scholing question
|
||||
/// is required. The frontend receives this value and applies it for instant UX
|
||||
/// feedback, but the backend re-validates on submit as the authority.
|
||||
/// Config value (ADR-0001's "config value" shape). Below this many NL work-hours the
|
||||
/// scholing question is required. The frontend receives this value
|
||||
/// (<c>GET /intake/policy</c>) and applies it for instant UX feedback
|
||||
/// (<c>intake.machine.ts</c>'s <c>lageUren</c>).
|
||||
///
|
||||
/// WP-68 (F5): the class doc used to claim "the backend re-validates on submit as the
|
||||
/// authority" — it doesn't. Neither <c>SubmitApplicationRequest</c> nor <c>IntakeRequest</c>
|
||||
/// carries a scholing answer at all, so there is nothing for the server to re-validate;
|
||||
/// both submit paths only apply <c>SubmissionRules.RejectZeroUren</c>. A crafted POST can
|
||||
/// bypass the scholing requirement entirely. Enforcing this needs a wire change (the
|
||||
/// request DTOs must carry the wizard's scholing answer) and is deferred to WP-69 — this
|
||||
/// comment states the gap rather than a false guarantee.
|
||||
/// </summary>
|
||||
public static class IntakePolicy
|
||||
{
|
||||
|
||||
@@ -4,6 +4,7 @@ using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using BigRegister.Api.Contracts;
|
||||
using BigRegister.Api.Data;
|
||||
using BigRegister.Domain.Applications;
|
||||
using BigRegister.Domain.Authorization;
|
||||
using BigRegister.Domain.Beoordeling;
|
||||
using BigRegister.Domain.Diplomas;
|
||||
@@ -314,9 +315,19 @@ api.MapPost("/applications", (CreateApplicationRequest req, HttpContext ctx) =>
|
||||
|
||||
// Draft sync per step — idempotent; keep it debounced on the client (it is chatty).
|
||||
api.MapPut("/applications/{id}", (string id, DraftSyncRequest req, HttpContext ctx) =>
|
||||
ApplicationStore.SyncDraft(id, ctx.Zorgverlener().Bsn, req.Draft, req.StepIndex, req.StepCount, req.DocumentIds)
|
||||
? Results.NoContent() : Results.NotFound())
|
||||
{
|
||||
var owner = ctx.Zorgverlener().Bsn;
|
||||
// A citizen may only reference their own uploads in a draft — reject before the sync
|
||||
// writes a foreign document id into the aanvraag (ADR-0001: the FE holds no authority).
|
||||
if (req.DocumentIds is { } ids && DocumentStore.ForeignIds(ids, owner) is { Count: > 0 } foreign)
|
||||
return Results.Problem(
|
||||
detail: $"Onbekend of niet-eigen document(en): {string.Join(", ", foreign)}.",
|
||||
statusCode: StatusCodes.Status400BadRequest);
|
||||
return ApplicationStore.SyncDraft(id, owner, req.Draft, req.StepIndex, req.StepCount, req.DocumentIds)
|
||||
? Results.NoContent() : Results.NotFound();
|
||||
})
|
||||
.Produces(StatusCodes.Status204NoContent)
|
||||
.ProducesProblem(StatusCodes.Status400BadRequest)
|
||||
.Produces(StatusCodes.Status404NotFound);
|
||||
|
||||
// Cancel a Concept (cascades to its unlinked documents). Submitted aanvragen cannot
|
||||
@@ -353,6 +364,13 @@ api.MapPost("/applications/{id}/submit", (string id, SubmitApplicationRequest re
|
||||
var docs = req.Documents;
|
||||
var documentIds = docs?.Where(d => d.Channel == "digital" && d.DocumentId is not null).Select(d => d.DocumentId!).ToList();
|
||||
|
||||
// A citizen may only submit their own uploads — reject before the submit writes a
|
||||
// foreign document id onto the aanvraag (ADR-0001: the FE holds no authority).
|
||||
if (documentIds is { Count: > 0 } && DocumentStore.ForeignIds(documentIds, ctx.Zorgverlener().Bsn) is { Count: > 0 } foreignIds)
|
||||
return Results.Problem(
|
||||
detail: $"Onbekend of niet-eigen document(en): {string.Join(", ", foreignIds)}.",
|
||||
statusCode: StatusCodes.Status400BadRequest);
|
||||
|
||||
var submitted = ApplicationStore.Submit(id, ctx.Zorgverlener().Bsn, reject, autoApprovable, documentIds);
|
||||
if (submitted is null) return Results.Conflict();
|
||||
|
||||
@@ -401,6 +419,7 @@ api.MapPost("/applications/{id}/submit", (string id, SubmitApplicationRequest re
|
||||
return Results.Ok(new SubmitApplicationResponse(referentie, status));
|
||||
})
|
||||
.Produces<SubmitApplicationResponse>()
|
||||
.ProducesProblem(StatusCodes.Status400BadRequest)
|
||||
.ProducesProblem(StatusCodes.Status409Conflict)
|
||||
.Produces(StatusCodes.Status404NotFound);
|
||||
|
||||
@@ -434,8 +453,10 @@ api.MapGet("/beoordeling/{id}", (string id, HttpContext ctx, IZaakSource zaken)
|
||||
var docs = DocumentStore.ByIds(c.DocumentIds)
|
||||
.Select(d => new BeoordelingDocumentDto(d.DocumentId, d.CategoryId, d.FileName)).ToList();
|
||||
var masked = c with { Owner = MaskTail(c.Owner!, 3) };
|
||||
var decisions = new BeoordelingDecisionsDto(
|
||||
BeoordelingRules.CanDecide(Enum.Parse<AanvraagStatusTag>(c.Status.Tag)));
|
||||
// WP-68 (F3): non-throwing — c.Status.Tag crosses the IZaakSource wire boundary, so an
|
||||
// unrecognised tag degrades to "cannot decide" instead of a 500.
|
||||
var canBesluiten = Enum.TryParse<AanvraagStatusTag>(c.Status.Tag, out var tag) && BeoordelingRules.CanDecide(tag);
|
||||
var decisions = new BeoordelingDecisionsDto(canBesluiten);
|
||||
return Results.Ok(new BeoordelingViewDto(masked, docs, decisions));
|
||||
}))
|
||||
.Produces<BeoordelingViewDto>()
|
||||
@@ -446,43 +467,52 @@ api.MapGet("/beoordeling/{id}", (string id, HttpContext ctx, IZaakSource zaken)
|
||||
// lifecycle. The local write runs against ApplicationStore directly (not the IZaakSource
|
||||
// seam) — same reasoning as the GET above. The transition-legality check
|
||||
// (BeoordelingRules.CanDecide) is the SAME function the GET's canBesluiten flag uses,
|
||||
// so the two can never drift. WP-66: once the local decision has committed, IZaakSource
|
||||
// also gets a chance to advance the ZGW-side zaak status — LocalZaakSource no-ops,
|
||||
// OpenZaakZaakSource POSTs a new Statussen entry (see its RecordBesluit).
|
||||
// so the two can never drift — and (WP-68 F2) it now runs inside ApplicationStore.RecordBesluit's
|
||||
// write lock rather than here, so two concurrent besluiten can't both pass it before either
|
||||
// writes. WP-66: once the local decision has committed, IZaakSource also gets a chance to
|
||||
// advance the ZGW-side zaak status — LocalZaakSource no-ops, OpenZaakZaakSource POSTs a new
|
||||
// Statussen entry (see its RecordBesluit).
|
||||
api.MapPost("/beoordeling/{id}/besluit", (string id, RecordBesluitRequest req, HttpContext ctx, IZaakSource zaken) =>
|
||||
Beoordelen(ctx, $"aanvraag/{id}/besluit", () =>
|
||||
{
|
||||
if (!Enum.TryParse<Besluit>(req.Besluit, out var besluit))
|
||||
return Results.Problem(detail: $"Onbekend besluit '{req.Besluit}'.", statusCode: StatusCodes.Status400BadRequest);
|
||||
// WP-68 (F6): moved to BeoordelingRules.RequiresToelichting — same rule, now unit-testable.
|
||||
if (BeoordelingRules.RequiresToelichting(besluit) && string.IsNullOrWhiteSpace(req.Toelichting))
|
||||
return Results.Problem(detail: "Toelichting is verplicht bij dit besluit.", statusCode: StatusCodes.Status400BadRequest);
|
||||
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var a = ApplicationStore.GetAny(id);
|
||||
var statusTag = a?.ToStatusDto(now).Tag;
|
||||
if (a is null || statusTag == "Concept") return Results.NotFound();
|
||||
var current = Enum.Parse<AanvraagStatusTag>(statusTag!);
|
||||
if (!BeoordelingRules.CanDecide(current))
|
||||
// Real bug fix (WP-66): `id` is the FE-facing case id from IZaakSource.ListCases — under
|
||||
// OpenZaakZaakSource that's the ZGW zaak's own uuid, not this store's primary key (a
|
||||
// ListCases lookup, not ApplicationStore.GetAny(id), same seam the GET sibling above
|
||||
// uses), so resolve the case first and go to the local Aanvraag via its Referentie
|
||||
// (see ApplicationStore.GetByReferentie).
|
||||
var c = zaken.ListCases(now).FirstOrDefault(x => x.Id == id);
|
||||
var a = c?.Status.Referentie is { } referentie ? ApplicationStore.GetByReferentie(referentie) : null;
|
||||
if (a is null) return Results.NotFound();
|
||||
|
||||
var (outcome, updated) = ApplicationStore.RecordBesluit(a.Id, besluit, req.Toelichting, now);
|
||||
if (outcome == ApplicationStore.RecordBesluitOutcome.NotFound) return Results.NotFound();
|
||||
if (outcome == ApplicationStore.RecordBesluitOutcome.Conflict)
|
||||
return Results.Problem(
|
||||
detail: "Deze aanvraag staat geen besluit meer toe in de huidige status.",
|
||||
statusCode: StatusCodes.Status409Conflict);
|
||||
if (besluit != Besluit.Goedkeuren && string.IsNullOrWhiteSpace(req.Toelichting))
|
||||
return Results.Problem(detail: "Toelichting is verplicht bij dit besluit.", statusCode: StatusCodes.Status400BadRequest);
|
||||
|
||||
var updated = ApplicationStore.RecordBesluit(id, besluit, req.Toelichting)!;
|
||||
app.Logger.LogInformation("aanvraag besluit id={Id} besluit={Besluit}", id, besluit);
|
||||
app.Logger.LogInformation("aanvraag besluit id={Id} besluit={Besluit}", a.Id, besluit);
|
||||
|
||||
// WP-60: the local decision above already committed — a ZGW failure here is caught and
|
||||
// flagged rather than allowed to diverge silently, same handling as submit's create-zaak
|
||||
// and document-link writes.
|
||||
try
|
||||
{
|
||||
zaken.RecordBesluit(updated, besluit, req.Toelichting, now, ctx.Caller());
|
||||
zaken.RecordBesluit(updated!, besluit, req.Toelichting, now, ctx.Caller());
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
RecordZgwDivergence(ctx, id, updated.Referentie ?? id, ex);
|
||||
RecordZgwDivergence(ctx, a.Id, updated!.Referentie ?? a.Id, ex);
|
||||
}
|
||||
|
||||
return Results.Ok(new RecordBesluitResponse(updated.ToStatusDto(now)));
|
||||
return Results.Ok(new RecordBesluitResponse(updated!.ToStatusDto(now)));
|
||||
}))
|
||||
.Produces<RecordBesluitResponse>()
|
||||
.ProducesProblem(StatusCodes.Status400BadRequest)
|
||||
|
||||
@@ -2,6 +2,7 @@ using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using BigRegister.Api.Contracts;
|
||||
using BigRegister.Api.Data;
|
||||
using BigRegister.Domain.Applications;
|
||||
using BigRegister.Domain.Authorization;
|
||||
|
||||
namespace BigRegister.Api.Zgw;
|
||||
@@ -15,14 +16,17 @@ public sealed record ZgwPage<T>(
|
||||
|
||||
/// <summary>
|
||||
/// The <see cref="IZaakSource"/> backed by a real OpenZaak / ZGW Zaken API (WP-49 read, WP-50
|
||||
/// write). Reads zaken (following pagination), resolves each zaaktype's human label from the
|
||||
/// Catalogi API (cached), and maps into <see cref="ApplicationSummaryDto"/> via
|
||||
/// <see cref="ZgwZaakMapper"/>. Creates a zaak + status + rol for a just-submitted aanvraag.
|
||||
/// Selected only when <c>Zgw:Enabled=true</c>; the default stays <see cref="LocalZaakSource"/>.
|
||||
/// write). Reads zaken (following pagination), maps each zaak's zaaktype URL back to the
|
||||
/// internal aanvraag-type key via <c>Zgw:ZaaktypeUrls</c> (a local lookup — NOT OpenZaak's
|
||||
/// human zaaktype label, which isn't a value <see cref="ApplicationSummaryDto.Type"/>'s
|
||||
/// contract accepts; see <see cref="AanvraagTypeFor"/>), and maps into
|
||||
/// <see cref="ApplicationSummaryDto"/> via <see cref="ZgwZaakMapper"/>. Creates a zaak +
|
||||
/// status + rol for a just-submitted aanvraag. Selected only when <c>Zgw:Enabled=true</c>;
|
||||
/// the default stays <see cref="LocalZaakSource"/>.
|
||||
///
|
||||
/// Auth: a fresh HS256 JWT per request (<see cref="ZgwTokenProvider"/>) on the Authorization
|
||||
/// header. Reading a zaak needs read scope on BOTH Zaken and Catalogi (zaaktype resolution);
|
||||
/// creating one additionally needs write scope on Zaken.
|
||||
/// header. Creating/deciding a zaak needs read scope on Catalogi too (statustype/resultaattype/
|
||||
/// roltype resolution) in addition to write scope on Zaken; a plain read does not.
|
||||
/// </summary>
|
||||
public sealed class OpenZaakZaakSource(HttpClient http, ZgwTokenProvider tokens, ZgwOptions options) : IZaakSource
|
||||
{
|
||||
@@ -47,17 +51,22 @@ public sealed class OpenZaakZaakSource(HttpClient http, ZgwTokenProvider tokens,
|
||||
if (bsn is not null)
|
||||
url += $"?rol__betrokkeneIdentificatie__natuurlijkPersoon__inpBsn={Uri.EscapeDataString(bsn)}";
|
||||
var zaken = await GetAllAsync<ZgwZaak>(url, caller);
|
||||
var labels = new Dictionary<string, string>();
|
||||
var result = new List<ApplicationSummaryDto>(zaken.Count);
|
||||
foreach (var z in zaken)
|
||||
{
|
||||
if (!labels.TryGetValue(z.Zaaktype, out var label))
|
||||
labels[z.Zaaktype] = label = await ZaaktypeLabelAsync(z.Zaaktype);
|
||||
result.Add(ZgwZaakMapper.ToSummaryDto(z, label));
|
||||
}
|
||||
return result;
|
||||
return zaken.Select(z => ZgwZaakMapper.ToSummaryDto(z, AanvraagTypeFor(z.Zaaktype))).ToList();
|
||||
}
|
||||
|
||||
/// <summary>Real, live-repro'd bug (behandelportal's werkvoorraad always failed to parse):
|
||||
/// <c>ApplicationSummaryDto.Type</c>'s contract is the internal aanvraag-type key (e.g.
|
||||
/// "herregistratie" — what <see cref="LocalZaakSource"/>/<c>Mappers.ToSummaryDto</c> send,
|
||||
/// and what the FE's <c>AANVRAAG_TYPES</c> trust boundary accepts), NOT OpenZaak's human
|
||||
/// zaaktype label ("Herregistratie arts") this used to resolve via an extra Catalogi round
|
||||
/// trip — every case failed the FE's parse boundary as soon as a real OpenZaak backed this
|
||||
/// seam. A zaak's zaaktype URL round-trips back to that key via the same
|
||||
/// <c>Zgw:ZaaktypeUrls</c> config <see cref="CreateZaakAsync"/> goes the other way with —
|
||||
/// no Catalogi call needed, and no label cache either.</summary>
|
||||
private string AanvraagTypeFor(string zaaktypeUrl) =>
|
||||
options.ZaaktypeUrls.FirstOrDefault(kv => kv.Value == zaaktypeUrl).Key
|
||||
?? throw new InvalidOperationException($"No aanvraag type configured for zaaktype {zaaktypeUrl}.");
|
||||
|
||||
/// <summary>Follow the <c>next</c> links, accumulating every page's results.</summary>
|
||||
private async Task<IReadOnlyList<T>> GetAllAsync<T>(string url, CallerIdentity? caller = null)
|
||||
{
|
||||
@@ -72,13 +81,6 @@ public sealed class OpenZaakZaakSource(HttpClient http, ZgwTokenProvider tokens,
|
||||
return all;
|
||||
}
|
||||
|
||||
/// <summary>A zaaktype's human label (<c>omschrijving</c>) from the Catalogi API.</summary>
|
||||
private async Task<string> ZaaktypeLabelAsync(string zaaktypeUrl)
|
||||
{
|
||||
var zt = await zgw.GetAsync<Zaaktype>(zaaktypeUrl);
|
||||
return zt.Omschrijving;
|
||||
}
|
||||
|
||||
// --- Write path (WP-50): create a Zaak, then a Status, then a Rol ------------------------
|
||||
|
||||
/// <summary>Create a zaak for a just-submitted aanvraag: POST zaak → resolve + POST the
|
||||
@@ -152,7 +154,13 @@ public sealed class OpenZaakZaakSource(HttpClient http, ZgwTokenProvider tokens,
|
||||
/// WP-60: no compensating transaction here either — the local decision already committed
|
||||
/// (<c>ApplicationStore.RecordBesluit</c>, called by the endpoint before this). A failure here
|
||||
/// is caught by the endpoint and recorded as a flagged divergence (<c>Aanvraag.ZgwError</c>),
|
||||
/// the same way the submit endpoint's create-zaak/document writes are.</summary>
|
||||
/// the same way the submit endpoint's create-zaak/document writes are.
|
||||
///
|
||||
/// ZGW requires a zaak to have a Resultaat before it can reach an eindstatus (OpenZaak 400s
|
||||
/// "Zaak has no resultaat" otherwise — confirmed against a real instance) — so this posts one
|
||||
/// first, same "existence-only, take the first" resolution as the statustype above (the
|
||||
/// harness's catalogus provisions exactly one resultaattype per zaaktype, not one per besluit
|
||||
/// outcome; a real deployment mapping besluit → resultaattype is future work).</summary>
|
||||
public void RecordBesluit(Aanvraag aanvraag, Besluit besluit, string? toelichting, DateTimeOffset now, CallerIdentity caller) =>
|
||||
RecordBesluitAsync(aanvraag, besluit, toelichting, now, caller).GetAwaiter().GetResult();
|
||||
|
||||
@@ -163,12 +171,25 @@ public sealed class OpenZaakZaakSource(HttpClient http, ZgwTokenProvider tokens,
|
||||
throw new InvalidOperationException(
|
||||
$"Zgw:ZaaktypeUrls has no entry for aanvraag type '{aanvraag.Type}'.");
|
||||
|
||||
var resultaattypeUrl = await FirstResultaattypeUrlAsync(zaaktypeUrl);
|
||||
await zgw.PostAsync<JsonElement>($"{options.ZrcBaseUrl}/resultaten",
|
||||
new CreateResultaatRequest(aanvraag.ZaakUrl, resultaattypeUrl), caller);
|
||||
|
||||
var statustypeUrl = await LastStatustypeUrlAsync(zaaktypeUrl);
|
||||
var toelichtingText = string.IsNullOrWhiteSpace(toelichting) ? $"{besluit}" : $"{besluit}: {toelichting}";
|
||||
await zgw.PostAsync<JsonElement>($"{options.ZrcBaseUrl}/statussen", new CreateStatusRequest(
|
||||
aanvraag.ZaakUrl, statustypeUrl, now, toelichtingText), caller);
|
||||
}
|
||||
|
||||
private async Task<string> FirstResultaattypeUrlAsync(string zaaktypeUrl)
|
||||
{
|
||||
var page = await zgw.GetAsync<ZgwPage<Resultaattype>>(
|
||||
$"{options.ZtcBaseUrl}/resultaattypen?zaaktype={Uri.EscapeDataString(zaaktypeUrl)}");
|
||||
var first = page.Results.FirstOrDefault()
|
||||
?? throw new InvalidOperationException($"No resultaattype found for zaaktype {zaaktypeUrl}.");
|
||||
return first.Url;
|
||||
}
|
||||
|
||||
/// <summary>The counterpart to <see cref="FirstStatustypeUrlAsync"/> — highest volgnummer
|
||||
/// (the eind status) rather than lowest.</summary>
|
||||
private async Task<string> LastStatustypeUrlAsync(string zaaktypeUrl)
|
||||
@@ -189,14 +210,14 @@ public sealed class OpenZaakZaakSource(HttpClient http, ZgwTokenProvider tokens,
|
||||
return first.Url;
|
||||
}
|
||||
|
||||
private sealed record Zaaktype([property: JsonPropertyName("omschrijving")] string Omschrijving);
|
||||
|
||||
private sealed record Statustype(
|
||||
[property: JsonPropertyName("url")] string Url,
|
||||
[property: JsonPropertyName("volgnummer")] int Volgnummer);
|
||||
|
||||
private sealed record Roltype([property: JsonPropertyName("url")] string Url);
|
||||
|
||||
private sealed record Resultaattype([property: JsonPropertyName("url")] string Url);
|
||||
|
||||
private sealed record CreateZaakRequest(
|
||||
[property: JsonPropertyName("zaaktype")] string Zaaktype,
|
||||
[property: JsonPropertyName("bronorganisatie")] string Bronorganisatie,
|
||||
@@ -210,6 +231,10 @@ public sealed class OpenZaakZaakSource(HttpClient http, ZgwTokenProvider tokens,
|
||||
[property: JsonPropertyName("datumStatusGezet")] DateTimeOffset DatumStatusGezet,
|
||||
[property: JsonPropertyName("statustoelichting")] string Statustoelichting = "");
|
||||
|
||||
private sealed record CreateResultaatRequest(
|
||||
[property: JsonPropertyName("zaak")] string Zaak,
|
||||
[property: JsonPropertyName("resultaattype")] string Resultaattype);
|
||||
|
||||
private sealed record CreateRolRequest(
|
||||
[property: JsonPropertyName("zaak")] string Zaak,
|
||||
[property: JsonPropertyName("betrokkeneType")] string BetrokkeneType,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using System.Text.Json.Serialization;
|
||||
using BigRegister.Api.Contracts;
|
||||
using BigRegister.Domain.Applications;
|
||||
|
||||
namespace BigRegister.Api.Zgw;
|
||||
|
||||
@@ -33,9 +34,9 @@ public static class ZgwZaakMapper
|
||||
// ponytail: coarse status map — an open zaak (no einddatum) is In behandeling, a closed
|
||||
// one is Goedgekeurd. Real fidelity (statustype/resultaat lookups) is a later slice; the
|
||||
// Afgewezen path needs the resultaat resource. Enough to prove the seam end-to-end.
|
||||
var status = z.Einddatum is null
|
||||
? new AanvraagStatusDto("InBehandeling", Referentie: z.Identificatie, Manual: true)
|
||||
: new AanvraagStatusDto("Goedgekeurd", Referentie: z.Identificatie);
|
||||
var status = (z.Einddatum is null
|
||||
? AanvraagStatus.InBehandeling(z.Identificatie, manual: true)
|
||||
: AanvraagStatus.Goedgekeurd(z.Identificatie)).ToDto();
|
||||
|
||||
var created = Iso(z.Registratiedatum ?? z.Startdatum);
|
||||
var updated = Iso(z.Einddatum ?? z.Registratiedatum ?? z.Startdatum);
|
||||
@@ -59,5 +60,5 @@ public static class ZgwZaakMapper
|
||||
/// <summary>Status for a zaak that was JUST created (WP-50) — always the open/InBehandeling
|
||||
/// coarse status (no einddatum yet), same convention as <see cref="ToSummaryDto"/>.</summary>
|
||||
public static AanvraagStatusDto ToCreatedStatusDto(string identificatie) =>
|
||||
new("InBehandeling", Referentie: identificatie, Manual: true);
|
||||
AanvraagStatus.InBehandeling(identificatie, manual: true).ToDto();
|
||||
}
|
||||
|
||||
@@ -643,6 +643,16 @@
|
||||
"204": {
|
||||
"description": "No Content"
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad Request",
|
||||
"content": {
|
||||
"application/problem+json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetails"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "Not Found"
|
||||
}
|
||||
@@ -718,6 +728,16 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad Request",
|
||||
"content": {
|
||||
"application/problem+json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetails"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"409": {
|
||||
"description": "Conflict",
|
||||
"content": {
|
||||
|
||||
@@ -2,6 +2,7 @@ using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using BigRegister.Api.Contracts;
|
||||
using BigRegister.Api.Data;
|
||||
using BigRegister.Domain.Applications;
|
||||
using Microsoft.AspNetCore.Mvc.Testing;
|
||||
|
||||
namespace BigRegister.Tests;
|
||||
@@ -172,6 +173,51 @@ public class ApplicationTests(TestWebApplicationFactory factory) : IClassFixture
|
||||
}
|
||||
}
|
||||
|
||||
// --- WP-68 (F1): a citizen may only reference their own uploads — submit/draft-sync must
|
||||
// reject a foreign documentId rather than silently attaching it. ---
|
||||
|
||||
private static async Task<UploadResponse> UploadAs(HttpClient client, string owner, string localId)
|
||||
{
|
||||
var content = new MultipartFormDataContent();
|
||||
var file = new ByteArrayContent(new byte[] { 1, 2, 3 });
|
||||
file.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("application/pdf");
|
||||
content.Add(file, "file", "d.pdf");
|
||||
content.Add(new StringContent("diploma"), "categoryId");
|
||||
content.Add(new StringContent(localId), "localId");
|
||||
content.Add(new StringContent("registratie"), "wizardId");
|
||||
var req = new HttpRequestMessage(HttpMethod.Post, "/api/v1/uploads") { Content = content, Headers = { { "X-Subject", owner } } };
|
||||
var res = await client.SendAsync(req);
|
||||
Assert.Equal(HttpStatusCode.Created, res.StatusCode);
|
||||
return (await res.Content.ReadFromJsonAsync<UploadResponse>())!;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Submitting_a_foreign_documentId_is_rejected_and_leaves_it_deletable_by_its_owner()
|
||||
{
|
||||
var foreignDoc = await UploadAs(_client, "999888777", Guid.NewGuid().ToString());
|
||||
var a = await Create("registratie");
|
||||
|
||||
var res = await _client.PostAsJsonAsync($"/api/v1/applications/{a.Id}/submit",
|
||||
new { diplomaHerkomst = "duo", documents = new[] { new { categoryId = "diploma", channel = "digital", documentId = foreignDoc.DocumentId } } });
|
||||
Assert.Equal(HttpStatusCode.BadRequest, res.StatusCode);
|
||||
|
||||
// The rejected submit must not have flipped the foreign document's Linked flag — its
|
||||
// owner can still delete it.
|
||||
var deleteReq = new HttpRequestMessage(HttpMethod.Delete, $"/api/v1/uploads/{foreignDoc.DocumentId}") { Headers = { { "X-Subject", "999888777" } } };
|
||||
Assert.Equal(HttpStatusCode.NoContent, (await _client.SendAsync(deleteReq)).StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Draft_sync_with_a_foreign_documentId_is_rejected()
|
||||
{
|
||||
var foreignDoc = await UploadAs(_client, "999888777", Guid.NewGuid().ToString());
|
||||
var a = await Create("registratie");
|
||||
|
||||
var res = await _client.PutAsJsonAsync($"/api/v1/applications/{a.Id}",
|
||||
new { draft = new { }, stepIndex = 0, stepCount = 1, documentIds = new[] { foreignDoc.DocumentId } });
|
||||
Assert.Equal(HttpStatusCode.BadRequest, res.StatusCode);
|
||||
}
|
||||
|
||||
// --- Auto-approval is computed on read: exercise the window boundary without waiting. ---
|
||||
|
||||
private static Aanvraag Accepted(bool autoApprovable) => new()
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
using System.Net.Http.Json;
|
||||
using BigRegister.Api.Contracts;
|
||||
using BigRegister.Api.Data;
|
||||
using BigRegister.Domain.Applications;
|
||||
using BigRegister.Domain.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc.Testing;
|
||||
using Microsoft.AspNetCore.TestHost;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace BigRegister.Tests;
|
||||
|
||||
/// <summary>Wraps <see cref="LocalZaakSource"/> but returns a DIFFERENT case id than the
|
||||
/// underlying Aanvraag.Id — reproduces exactly what <c>OpenZaakZaakSource</c> does in
|
||||
/// production (the FE-facing case id from <c>ListCases</c> is the ZGW zaak's own uuid, not
|
||||
/// <c>ApplicationStore</c>'s primary key) without needing a live OpenZaak, so the besluit
|
||||
/// endpoint's Referentie-based resolution (the fix below) gets coverage on every push.</summary>
|
||||
file sealed class IdMismatchZaakSource : IZaakSource
|
||||
{
|
||||
private readonly LocalZaakSource inner = new();
|
||||
private static ApplicationSummaryDto Rekey(ApplicationSummaryDto dto) => dto with { Id = $"zaak-{dto.Id}" };
|
||||
|
||||
public IReadOnlyList<ApplicationSummaryDto> ListCases(DateTimeOffset now) =>
|
||||
inner.ListCases(now).Select(Rekey).ToList();
|
||||
|
||||
public IReadOnlyList<ApplicationSummaryDto> ListMyCases(ZorgverlenerCaller caller, DateTimeOffset now) =>
|
||||
inner.ListMyCases(caller, now).Select(Rekey).ToList();
|
||||
|
||||
public (string Referentie, AanvraagStatusDto Status, string? ZaakUrl) CreateZaak(
|
||||
Aanvraag aanvraag, DateTimeOffset now, CallerIdentity caller) => inner.CreateZaak(aanvraag, now, caller);
|
||||
|
||||
public void RecordBesluit(Aanvraag aanvraag, Besluit besluit, string? toelichting, DateTimeOffset now, CallerIdentity caller) =>
|
||||
inner.RecordBesluit(aanvraag, besluit, toelichting, now, caller);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Regression for a real, live-repro'd bug: recording a besluit from the behandelportal always
|
||||
/// 404'd against a real OpenZaak. Root cause — <c>POST /beoordeling/{id}/besluit</c> looked
|
||||
/// <c>id</c> up directly in <c>ApplicationStore</c> (its own primary key), but <c>id</c> is
|
||||
/// whatever <c>IZaakSource.ListCases</c> handed the FE; under <c>OpenZaakZaakSource</c> that's
|
||||
/// the ZGW zaak's own uuid, a different value entirely. Fixed by resolving the case through
|
||||
/// the same <c>ListCases</c> seak the GET sibling (<see cref="BeoordelingTests"/>) already uses,
|
||||
/// then to the local <c>Aanvraag</c> via its Referentie (<c>ApplicationStore.GetByReferentie</c>)
|
||||
/// — the one identifier stable across both sources. <see cref="IdMismatchZaakSource"/>
|
||||
/// reproduces the id divergence without a live OpenZaak.
|
||||
/// </summary>
|
||||
public class BeoordelingIdMismatchTests
|
||||
{
|
||||
private static WebApplicationFactory<Program> Factory()
|
||||
{
|
||||
var dbPath = Path.Combine(Path.GetTempPath(), $"bigregister-id-mismatch-{Guid.NewGuid():N}.db");
|
||||
return new WebApplicationFactory<Program>().WithWebHostBuilder(builder => builder
|
||||
.UseSetting("ConnectionStrings:AppDb", $"Data Source={dbPath}")
|
||||
.ConfigureTestServices(services => services.AddSingleton<IZaakSource, IdMismatchZaakSource>()));
|
||||
}
|
||||
|
||||
private static HttpRequestMessage Behandelaar(HttpMethod method, string path, object? body = null)
|
||||
{
|
||||
var req = new HttpRequestMessage(method, path);
|
||||
req.Headers.Add("X-Medewerker", "medewerker-1");
|
||||
if (body is not null) req.Content = JsonContent.Create(body);
|
||||
return req;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Besluit_resolves_by_referentie_when_the_case_id_differs_from_the_local_aanvraag_id()
|
||||
{
|
||||
using var factory = Factory();
|
||||
using var client = factory.CreateClient();
|
||||
|
||||
var created = await client.PostAsJsonAsync("/api/v1/applications", new { type = "registratie" });
|
||||
var app = (await created.Content.ReadFromJsonAsync<ApplicationDetailDto>())!;
|
||||
var submit = await client.PostAsJsonAsync($"/api/v1/applications/{app.Id}/submit", new { diplomaHerkomst = "handmatig" });
|
||||
submit.EnsureSuccessStatusCode();
|
||||
|
||||
var werkvoorraad = await client.SendAsync(Behandelaar(HttpMethod.Get, "/api/v1/werkvoorraad"));
|
||||
var items = (await werkvoorraad.Content.ReadFromJsonAsync<List<ApplicationSummaryDto>>())!;
|
||||
var caseId = Assert.Single(items).Id;
|
||||
// Sanity: the id divergence this test exists for is real, not accidentally absent.
|
||||
Assert.NotEqual(app.Id, caseId);
|
||||
|
||||
var res = await client.SendAsync(Behandelaar(HttpMethod.Post, $"/api/v1/beoordeling/{caseId}/besluit",
|
||||
new { besluit = "Afwijzen", toelichting = "onvolledig" }));
|
||||
|
||||
res.EnsureSuccessStatusCode();
|
||||
var body = (await res.Content.ReadFromJsonAsync<RecordBesluitResponse>())!;
|
||||
Assert.Equal("Afgewezen", body.Status.Tag);
|
||||
}
|
||||
}
|
||||
@@ -215,6 +215,35 @@ public class BeoordelingTests(TestWebApplicationFactory factory) : IClassFixture
|
||||
}
|
||||
}
|
||||
|
||||
// WP-68 (F2): the transition-legality check now runs inside RecordBesluit's write lock, so
|
||||
// two besluiten racing on the same still-open aanvraag can't both pass the check before
|
||||
// either writes — exactly one commits, the other sees the now-terminal status.
|
||||
[Fact]
|
||||
public async Task Concurrent_besluiten_on_the_same_aanvraag_yield_exactly_one_success()
|
||||
{
|
||||
var (a, _) = await CreateManualCaseWithDocument();
|
||||
try
|
||||
{
|
||||
var results = await Task.WhenAll(
|
||||
PostBesluit(a.Id, new { besluit = "Goedkeuren" }),
|
||||
PostBesluit(a.Id, new { besluit = "Afwijzen", toelichting = "race" }));
|
||||
|
||||
var winner = Assert.Single(results, r => r.StatusCode == HttpStatusCode.OK);
|
||||
Assert.Single(results, r => r.StatusCode == HttpStatusCode.Conflict);
|
||||
|
||||
// The persisted outcome must match whichever request actually won the race, not just
|
||||
// "some" besluit — the loser's write must never have landed.
|
||||
var winningTag = (await winner.Content.ReadFromJsonAsync<RecordBesluitResponse>())!.Status.Tag;
|
||||
var detail = await _client.SendAsync(AsBehandelaar(HttpMethod.Get, $"/api/v1/beoordeling/{a.Id}"));
|
||||
var finalTag = (await detail.Content.ReadFromJsonAsync<BeoordelingViewDto>())!.Aanvraag.Status.Tag;
|
||||
Assert.Equal(winningTag, finalTag);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await DeleteAsAdmin(a.Id);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Unknown_id_404s_and_zorgverlener_is_forbidden()
|
||||
{
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
using System.Net.Http.Headers;
|
||||
using System.Net.Http.Json;
|
||||
using BigRegister.Api.Contracts;
|
||||
using BigRegister.Api.Zgw;
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.AspNetCore.Mvc.Testing;
|
||||
|
||||
@@ -7,7 +9,7 @@ namespace BigRegister.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// WP-54: the one test that proves the BFF actually talks to a REAL OpenZaak — auth accepted,
|
||||
/// real response shapes, real pagination/zaaktype resolution — rather than the stub
|
||||
/// real response shapes, real pagination/zaaktype→aanvraag-type mapping — rather than the stub
|
||||
/// HttpMessageHandler every other Zgw test (<see cref="ZgwZaakMapperTests"/>,
|
||||
/// <see cref="OpenZaakZaakSourceTests"/>) uses. Requires the harness in <c>backend/openzaak/</c>
|
||||
/// to be up and seeded first (see its README); tagged Category=Integration so it's excluded
|
||||
@@ -23,7 +25,7 @@ namespace BigRegister.Tests;
|
||||
[Trait("Category", "Integration")]
|
||||
public class OpenZaakIntegrationTests
|
||||
{
|
||||
private static WebApplicationFactory<Program> Factory()
|
||||
private static WebApplicationFactory<Program> Factory(string zaaktypeUrl)
|
||||
{
|
||||
var dbPath = Path.Combine(Path.GetTempPath(), $"bigregister-oz-integration-{Guid.NewGuid():N}.db");
|
||||
return new WebApplicationFactory<Program>().WithWebHostBuilder(builder => builder
|
||||
@@ -34,23 +36,39 @@ public class OpenZaakIntegrationTests
|
||||
.UseSetting("Zgw:ClientId", "bigregister-test")
|
||||
.UseSetting("Zgw:Secret", "bigregister-test-secret")
|
||||
.UseSetting("Zgw:UserId", "bigregister-test")
|
||||
.UseSetting("Zgw:UserRepresentation", "WP-54 integration test"));
|
||||
.UseSetting("Zgw:UserRepresentation", "WP-54 integration test")
|
||||
.UseSetting("Zgw:ZaaktypeUrls:herregistratie", zaaktypeUrl));
|
||||
}
|
||||
|
||||
/// <summary>bootstrap-catalogus.sh mints the seeded zaaktype's uuid fresh per harness
|
||||
/// instance, so unlike every other setting <c>Factory</c> hardcodes, this one has to be
|
||||
/// discovered live — the same real HTTP + JWT this test is meant to exercise, done once up
|
||||
/// front to learn the URL <c>Zgw:ZaaktypeUrls</c> needs (see <see cref="OpenZaakZaakSource.AanvraagTypeFor"/>).</summary>
|
||||
private static async Task<string> SeededZaaktypeUrlAsync()
|
||||
{
|
||||
var tokenOptions = new ZgwOptions { ClientId = "bigregister-test", Secret = "bigregister-test-secret" };
|
||||
using var client = new HttpClient();
|
||||
client.DefaultRequestHeaders.Authorization =
|
||||
new AuthenticationHeaderValue("Bearer", new ZgwTokenProvider(tokenOptions).Mint());
|
||||
client.DefaultRequestHeaders.Add("Accept-Crs", "EPSG:4326"); // else OpenZaak 412s
|
||||
var page = await client.GetFromJsonAsync<ZgwPage<ZgwZaak>>(
|
||||
"http://localhost:8000/zaken/api/v1/zaken?identificatie=BIG-2026-000123");
|
||||
return Assert.Single(page!.Results).Zaaktype;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Admin_cases_returns_the_seeded_zaak_mapped_through_real_HTTP_and_JWT()
|
||||
{
|
||||
using var factory = Factory();
|
||||
using var factory = Factory(await SeededZaaktypeUrlAsync());
|
||||
using var client = factory.CreateClient();
|
||||
client.DefaultRequestHeaders.Add("X-Role", "admin"); // CasesAdmin gate (cases:manage)
|
||||
|
||||
var cases = await client.GetFromJsonAsync<List<ApplicationSummaryDto>>("/api/v1/admin/cases");
|
||||
|
||||
Assert.NotNull(cases);
|
||||
// bootstrap-catalogus.sh seeds exactly one zaak, identificatie BIG-2026-000123, under a
|
||||
// zaaktype whose omschrijving is "Herregistratie arts" — see backend/openzaak/README.md.
|
||||
// bootstrap-catalogus.sh seeds exactly one zaak, identificatie BIG-2026-000123.
|
||||
var seeded = Assert.Single(cases!, c => c.Status.Referentie == "BIG-2026-000123");
|
||||
Assert.Equal("Herregistratie arts", seeded.Type);
|
||||
Assert.Equal("herregistratie", seeded.Type);
|
||||
Assert.Equal("InBehandeling", seeded.Status.Tag);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,48 +1,58 @@
|
||||
using System.Net;
|
||||
using BigRegister.Api.Data;
|
||||
using BigRegister.Api.Zgw;
|
||||
using BigRegister.Domain.Applications;
|
||||
using BigRegister.Domain.Authorization;
|
||||
|
||||
namespace BigRegister.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Exercises the OpenZaak read source against a stub HttpMessageHandler (no live server, no
|
||||
/// mocking library) — the guarantee that it follows ZGW pagination, resolves + caches
|
||||
/// zaaktype labels, and always sends a Bearer token.
|
||||
/// mocking library) — the guarantee that it follows ZGW pagination, maps a zaak's zaaktype
|
||||
/// back to the internal aanvraag-type key, and always sends a Bearer token.
|
||||
/// </summary>
|
||||
public class OpenZaakZaakSourceTests
|
||||
{
|
||||
private const string ZrcBase = "https://oz.example/zaken/api/v1";
|
||||
private const string ZtBase = "https://oz.example/catalogi/api/v1";
|
||||
private const string ZaaktypeUrl = $"{ZtBase}/zaaktypen/zt-1";
|
||||
|
||||
private static string Page1 => $$"""
|
||||
{ "count": 2, "next": "{{ZrcBase}}/zaken?page=2", "results": [
|
||||
{ "url": "{{ZrcBase}}/zaken/uuid-1", "identificatie": "ZAAK-1",
|
||||
"zaaktype": "{{ZtBase}}/zaaktypen/zt-1", "startdatum": "2026-03-01",
|
||||
"zaaktype": "{{ZaaktypeUrl}}", "startdatum": "2026-03-01",
|
||||
"einddatum": null, "registratiedatum": "2026-03-01" } ] }
|
||||
""";
|
||||
|
||||
private static string Page2 => $$"""
|
||||
{ "count": 2, "next": null, "results": [
|
||||
{ "url": "{{ZrcBase}}/zaken/uuid-2", "identificatie": "ZAAK-2",
|
||||
"zaaktype": "{{ZtBase}}/zaaktypen/zt-1", "startdatum": "2026-01-01",
|
||||
"zaaktype": "{{ZaaktypeUrl}}", "startdatum": "2026-01-01",
|
||||
"einddatum": "2026-02-01", "registratiedatum": "2026-01-01" } ] }
|
||||
""";
|
||||
|
||||
private const string Zaaktype = """{ "omschrijving": "Herregistratie arts" }""";
|
||||
|
||||
[Fact]
|
||||
public void Follows_pagination_caches_zaaktype_and_sends_bearer_token()
|
||||
public void Follows_pagination_maps_the_internal_aanvraag_type_and_sends_bearer_token()
|
||||
{
|
||||
// Regression for a real bug found via a live behandelportal walkthrough: this used to
|
||||
// return OpenZaak's human zaaktype label ("Herregistratie arts") as Type, which the FE's
|
||||
// AANVRAAG_TYPES trust boundary always rejects (it only accepts the internal key, the
|
||||
// same contract LocalZaakSource honors) — every werkvoorraad load failed to parse.
|
||||
var handler = new ZgwStubHandler(url => url switch
|
||||
{
|
||||
_ when url == $"{ZrcBase}/zaken" => Page1,
|
||||
_ when url == $"{ZrcBase}/zaken?page=2" => Page2,
|
||||
_ when url == $"{ZtBase}/zaaktypen/zt-1" => Zaaktype,
|
||||
_ => throw new InvalidOperationException($"unexpected ZGW GET {url}"),
|
||||
});
|
||||
|
||||
var options = new ZgwOptions { ZrcBaseUrl = ZrcBase, ZtcBaseUrl = ZtBase, ClientId = "c", Secret = "s" };
|
||||
var options = new ZgwOptions
|
||||
{
|
||||
ZrcBaseUrl = ZrcBase,
|
||||
ZtcBaseUrl = ZtBase,
|
||||
ClientId = "c",
|
||||
Secret = "s",
|
||||
ZaaktypeUrls = new() { ["herregistratie"] = ZaaktypeUrl },
|
||||
};
|
||||
var source = new OpenZaakZaakSource(new HttpClient(handler), new ZgwTokenProvider(options), options);
|
||||
|
||||
var cases = source.ListCases(DateTimeOffset.UtcNow);
|
||||
@@ -50,16 +60,31 @@ public class OpenZaakZaakSourceTests
|
||||
// Both pages accumulated.
|
||||
Assert.Equal(2, cases.Count);
|
||||
Assert.Equal(new[] { "uuid-1", "uuid-2" }, cases.Select(c => c.Id));
|
||||
Assert.All(cases, c => Assert.Equal("Herregistratie arts", c.Type));
|
||||
Assert.All(cases, c => Assert.Equal("herregistratie", c.Type));
|
||||
Assert.Equal("InBehandeling", cases[0].Status.Tag); // open
|
||||
Assert.Equal("Goedgekeurd", cases[1].Status.Tag); // closed
|
||||
|
||||
// Zaaktype resolved once despite two zaken sharing it (cache).
|
||||
Assert.Single(handler.Requests, r => r.Contains("zaaktypen"));
|
||||
// No Catalogi round-trip needed — the type maps back via the local Zgw:ZaaktypeUrls config.
|
||||
Assert.DoesNotContain(handler.Requests, r => r.Contains("zaaktypen"));
|
||||
// Every outbound request carried a Bearer token.
|
||||
Assert.All(handler.AuthSchemes, s => Assert.Equal("Bearer", s));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ListCases_throws_when_a_zaak_zaaktype_has_no_configured_aanvraag_type()
|
||||
{
|
||||
var handler = new ZgwStubHandler(url => url switch
|
||||
{
|
||||
_ when url == $"{ZrcBase}/zaken" => Page1,
|
||||
_ when url == $"{ZrcBase}/zaken?page=2" => Page2,
|
||||
_ => throw new InvalidOperationException($"unexpected ZGW GET {url}"),
|
||||
});
|
||||
var options = new ZgwOptions { ZrcBaseUrl = ZrcBase, ZtcBaseUrl = ZtBase, ClientId = "c", Secret = "s" };
|
||||
var source = new OpenZaakZaakSource(new HttpClient(handler), new ZgwTokenProvider(options), options);
|
||||
|
||||
Assert.Throws<InvalidOperationException>(() => source.ListCases(DateTimeOffset.UtcNow));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ListMyCases_filters_by_the_callers_bsn()
|
||||
{
|
||||
@@ -174,6 +199,11 @@ public class OpenZaakZaakSourceTests
|
||||
{ "url": "https://oz.example/catalogi/api/v1/statustypen/st-1", "volgnummer": 1 },
|
||||
{ "url": "https://oz.example/catalogi/api/v1/statustypen/st-2", "volgnummer": 2 } ] }
|
||||
""",
|
||||
_ when url.StartsWith($"{ZtBase}/resultaattypen") => """
|
||||
{ "count": 1, "next": null,
|
||||
"results": [ { "url": "https://oz.example/catalogi/api/v1/resultaattypen/rst-1" } ] }
|
||||
""",
|
||||
_ when url == $"{ZrcBase}/resultaten" => "{}",
|
||||
_ when url == $"{ZrcBase}/statussen" => "{}",
|
||||
_ => throw new InvalidOperationException($"unexpected ZGW call {url}"),
|
||||
});
|
||||
@@ -207,6 +237,59 @@ public class OpenZaakZaakSourceTests
|
||||
Assert.Contains("onvolledig", statusBody);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RecordBesluit_creates_a_resultaat_before_posting_the_eindstatus()
|
||||
{
|
||||
// Regression for a real bug found against a live OpenZaak: posting straight to the eind
|
||||
// statustype without a Resultaat first gets rejected with 400 "Zaak has no resultaat" —
|
||||
// ZGW requires the Resultaat to exist before a zaak can reach its eindstatus.
|
||||
const string zaaktypeUrl = $"{ZtBase}/zaaktypen/zt-registratie";
|
||||
var handler = new ZgwStubHandler(url => url switch
|
||||
{
|
||||
_ when url.StartsWith($"{ZtBase}/statustypen") => """
|
||||
{ "count": 1, "next": null,
|
||||
"results": [ { "url": "https://oz.example/catalogi/api/v1/statustypen/st-1", "volgnummer": 1 } ] }
|
||||
""",
|
||||
_ when url.StartsWith($"{ZtBase}/resultaattypen") => """
|
||||
{ "count": 1, "next": null,
|
||||
"results": [ { "url": "https://oz.example/catalogi/api/v1/resultaattypen/rst-1" } ] }
|
||||
""",
|
||||
_ when url == $"{ZrcBase}/resultaten" => "{}",
|
||||
_ when url == $"{ZrcBase}/statussen" => "{}",
|
||||
_ => throw new InvalidOperationException($"unexpected ZGW call {url}"),
|
||||
});
|
||||
|
||||
var options = new ZgwOptions
|
||||
{
|
||||
ZrcBaseUrl = ZrcBase,
|
||||
ZtcBaseUrl = ZtBase,
|
||||
ClientId = "c",
|
||||
Secret = "s",
|
||||
ZaaktypeUrls = new() { ["registratie"] = zaaktypeUrl },
|
||||
};
|
||||
var source = new OpenZaakZaakSource(new HttpClient(handler), new ZgwTokenProvider(options), options);
|
||||
var aanvraag = new Aanvraag
|
||||
{
|
||||
Id = "a1",
|
||||
Type = "registratie",
|
||||
Owner = "111222333",
|
||||
Referentie = "BIG-2026-000123",
|
||||
ZaakUrl = $"{ZrcBase}/zaken/uuid-existing",
|
||||
};
|
||||
var caller = new MedewerkerCaller("m1", new[] { MedewerkerRol.Behandelaar }, "Medewerker Test", PrincipalRole.Drafter);
|
||||
|
||||
source.RecordBesluit(aanvraag, Besluit.Goedkeuren, null, DateTimeOffset.UtcNow, caller);
|
||||
|
||||
var resultaatBody = handler.BodyOf($"{ZrcBase}/resultaten");
|
||||
Assert.Contains($"{ZrcBase}/zaken/uuid-existing", resultaatBody);
|
||||
Assert.Contains("resultaattypen/rst-1", resultaatBody);
|
||||
|
||||
// The Resultaat must exist BEFORE the eindstatus is posted, not after.
|
||||
Assert.True(
|
||||
handler.Requests.IndexOf($"{ZrcBase}/resultaten") < handler.Requests.IndexOf($"{ZrcBase}/statussen"),
|
||||
"expected /resultaten to be posted before /statussen");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RecordBesluit_does_nothing_when_the_aanvraag_has_no_zaak()
|
||||
{
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using BigRegister.Api.Data;
|
||||
using BigRegister.Domain.Applications;
|
||||
using BigRegister.Domain.Beoordeling;
|
||||
using BigRegister.Domain.Diplomas;
|
||||
using BigRegister.Domain.Documents;
|
||||
@@ -192,4 +193,49 @@ public class BeoordelingRuleTests
|
||||
[InlineData(AanvraagStatusTag.Afgewezen, false)]
|
||||
public void Only_open_statuses_are_decidable(AanvraagStatusTag tag, bool expected) =>
|
||||
Assert.Equal(expected, BeoordelingRules.CanDecide(tag));
|
||||
|
||||
// WP-68 (F6): the toelichting rule, moved here from an inline endpoint check.
|
||||
[Theory]
|
||||
[InlineData(Besluit.Goedkeuren, false)]
|
||||
[InlineData(Besluit.Afwijzen, true)]
|
||||
[InlineData(Besluit.MeerInfoOpvragen, true)]
|
||||
public void Only_a_non_approval_requires_a_toelichting(Besluit besluit, bool expected) =>
|
||||
Assert.Equal(expected, BeoordelingRules.RequiresToelichting(besluit));
|
||||
|
||||
// WP-68 (T3): the transition table at the AGGREGATE level, not just against a bare tag —
|
||||
// an Aanvraag whose BesluitStatus already records a terminal decision computes a terminal
|
||||
// StatusAt, and CanDecide refuses a further besluit regardless of which one. Pins the
|
||||
// domain statement "Afgewezen/Goedgekeurd → no further besluit" independent of the
|
||||
// endpoint's own (integration-level) Already_decided_case_rejects_a_further_besluit.
|
||||
private static Aanvraag Decided(Besluit besluit) => new()
|
||||
{
|
||||
Id = "x",
|
||||
Type = "registratie",
|
||||
Owner = "test",
|
||||
Submitted = true,
|
||||
Referentie = "BIG-2026-1",
|
||||
SubmittedAt = DateTimeOffset.UtcNow,
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
UpdatedAt = DateTimeOffset.UtcNow,
|
||||
BesluitStatus = besluit,
|
||||
BesluitToelichting = besluit == Besluit.Goedkeuren ? null : "toelichting",
|
||||
};
|
||||
|
||||
[Theory]
|
||||
[InlineData(Besluit.Goedkeuren)]
|
||||
[InlineData(Besluit.Afwijzen)]
|
||||
public void A_terminal_decision_refuses_any_further_besluit(Besluit recorded)
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var tag = Decided(recorded).StatusAt(now).Tag!.Value;
|
||||
Assert.False(BeoordelingRules.CanDecide(tag));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MeerInfoOpvragen_is_not_terminal_a_further_besluit_is_still_legal()
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var tag = Decided(Besluit.MeerInfoOpvragen).StatusAt(now).Tag!.Value;
|
||||
Assert.True(BeoordelingRules.CanDecide(tag));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,75 +49,77 @@ WP-19's own file), so it's a separate manual/CI step, not chained into the other
|
||||
Gates land before the work they cover; each lint rule lands in the same WP as the fixes
|
||||
for its existing violations, so every WP ends green.
|
||||
|
||||
| WP | Title | Phase | Status |
|
||||
| ------------------------------------------------------ | ---------------------------------------------------------------------------------- | --------------------------- | ------ |
|
||||
| [WP-01](WP-01-axe-ci-gate.md) | Axe-on-every-story CI gate | 0 · gates | done |
|
||||
| [WP-02](WP-02-check-tokens.md) | Harden `check:tokens` + fix what it catches | 0 · gates | done |
|
||||
| [WP-03](WP-03-contracts-purity.md) | Boundaries I: contracts purity + ApiClient confinement | 0 · gates | done |
|
||||
| [WP-04](WP-04-ui-not-infrastructure.md) | Boundaries II: `ui ↛ infrastructure` + showcase sanction | 0 · gates | done |
|
||||
| [WP-05](WP-05-parse-boundaries.md) | Parse-don't-validate closure + MDX | 1 · FP/DDD | done |
|
||||
| [WP-06](WP-06-typed-async.md) | Generic async template contexts — kill `$any()` | 1 · FP/DDD | done |
|
||||
| [WP-07](WP-07-brief-idioms.md) | Brief on the shared idioms + RemoteData MDX | 1 · FP/DDD | done |
|
||||
| [WP-08](WP-08-store-idiom.md) | One store idiom + machine naming + TEA MDX | 1 · FP/DDD | done |
|
||||
| [WP-09](WP-09-pure-logic.md) | Pure-logic closure: dates + missing command specs | 1 · FP/DDD | done |
|
||||
| [WP-10](WP-10-button-fidelity.md) | CIBG button fidelity | 2 · CIBG | done |
|
||||
| [WP-11](WP-11-markup-fidelity.md) | CIBG markup fidelity: application-link + absent-class triage | 2 · CIBG | done |
|
||||
| [WP-12](WP-12-datablock.md) | CIBG Datablock for application data | 2 · CIBG | done |
|
||||
| [WP-13](WP-13-cibg-gap-register.md) | CIBG-gap register + hygiene + MDX | 2 · CIBG | done |
|
||||
| [WP-14](WP-14-storybook-taxonomy.md) | Storybook taxonomy reorg + Layers MDX | 3 · Storybook | done |
|
||||
| [WP-15](WP-15-missing-stories.md) | Missing stories: shell + brief components | 3 · Storybook | done |
|
||||
| [WP-16](WP-16-component-a11y.md) | Component a11y: description wiring + alert role | 4 · a11y | done |
|
||||
| [WP-17](WP-17-app-a11y.md) | App-level a11y: route focus, template lint, WCAG checklist | 4 · a11y | done |
|
||||
| [WP-18](WP-18-abac-capability-spine.md) | ABAC capability spine (Principal + capabilities, phase P1) | 5 · productie-volwassenheid | done |
|
||||
| [WP-19](WP-19-e2e-smoke.md) | Playwright e2e smoke | 5 · productie-volwassenheid | done |
|
||||
| [WP-20](WP-20-second-locale.md) | Second locale proof | 5 · productie-volwassenheid | done |
|
||||
| [WP-21](WP-21-resilience-seams.md) | Resilience seams (correlation-id, idempotency, retry) | 5 · productie-volwassenheid | done |
|
||||
| [WP-22](WP-22-durable-persistence.md) | Durable persistence (optional tier) | 5 · productie-volwassenheid | done |
|
||||
| [WP-23](WP-23-org-template-backend.md) | Org-template backend + admin role | 6 · Brief v2 | done |
|
||||
| [WP-24](WP-24-letter-canvas.md) | Letter canvas (edit on the letter) | 6 · Brief v2 | done |
|
||||
| [WP-25](WP-25-letter-preview-html.md) | Server-rendered letter preview (HTML; PDF deferred) | 6 · Brief v2 | done |
|
||||
| [WP-26](WP-26-org-template-editor.md) | Admin org-template editor | 6 · Brief v2 | done |
|
||||
| [WP-27](WP-27-brief-ux-layer.md) | Brief UX layer (undo/redo, standaardbrief, diff) | 6 · Brief v2 | done |
|
||||
| [WP-28](WP-28-brief-v2-demo-polish.md) | Brief v2 demo polish (scenarios, e2e, docs) | 6 · Brief v2 | done |
|
||||
| [WP-29](WP-29-stamdata-beheer-editor.md) | Stamdata beheer editor (low-code, PR-emitting) | follow-on · ADR-0004 | done |
|
||||
| [WP-30](WP-30-ci-perf-followups.md) | CI performance follow-ups (node_modules cache, runner image, path filters) | follow-on · CI/infra | done |
|
||||
| [WP-31](WP-31-shared-store-helpers.md) | Shared store helpers (ActionState/SaveState, history, debounced-save, RemoteData) | 7 · refinements | done |
|
||||
| WP-32 | Undo/redo in the stamdata editor (folded into WP-31 — no separate file) | 7 · refinements | done |
|
||||
| [WP-33](WP-33-dev-switchers.md) | In-app dev switchers (scenario + role) | 7 · refinements | done |
|
||||
| [WP-34](WP-34-adres-phone-brp-readonly.md) | Adres: phone field + BRP address read-only | 7 · refinements | done |
|
||||
| [WP-35](WP-35-one-concept-per-type.md) | One Concept per case type (server-enforced) | 7 · refinements | done |
|
||||
| [WP-36](WP-36-admin-cases.md) | Admin cases page + admin delete | 7 · refinements | done |
|
||||
| [WP-37](WP-37-dev-switcher-reset.md) | Dev-switcher reset fix (scenario/role URL param) | 8 · platform/DX/showcase | done |
|
||||
| [WP-38](WP-38-dependency-graph-boundaries.md) | Dependency graph + declarative boundaries (visualize + enforce) | 8 · platform/DX/showcase | done |
|
||||
| [WP-39](WP-39-showcase-snippets-animations.md) | Showcase: linked code snippets + teaching animations | 8 · platform/DX/showcase | done |
|
||||
| [WP-40](WP-40-pii-kernel.md) | PII kernel: branded `Bsn` VO (elfproef) + masked-value atom | 8 · platform/DX/showcase | done |
|
||||
| [WP-41](WP-41-persisted-authz-audit.md) | Persisted, queryable authz/PII-reveal audit (no PII) | 8 · platform/DX/showcase | done |
|
||||
| [WP-42](WP-42-privacy-security-showcase.md) | Privacy & security showcase page (mask + no-PII log) | 8 · platform/DX/showcase | done |
|
||||
| [WP-43](WP-43-scaffold-generators.md) | Runnable generators: value-object / form-machine (plop; ui-component/bff = skills) | 8 · platform/DX/showcase | done |
|
||||
| [WP-44](WP-44-context-generator.md) | Runnable generator: `gen:context` | 8 · platform/DX/showcase | done |
|
||||
| [WP-45](WP-45-create-frontend-generator.md) | `create-frontend` bootstrap generator (mechanise new-ssp) | 8 · platform/DX/showcase | done |
|
||||
| [WP-46](WP-46-vitest-coverage.md) | Vitest coverage (report + report-only thresholds) | 8 · platform/DX/showcase | done |
|
||||
| [WP-47](WP-47-feature-flags.md) | Runtime feature flags (catalog-in-code, admin toggle, FE+backend) | 8 · platform/DX/showcase | done |
|
||||
| [WP-48](WP-48-stamdata-deletion-protection.md) | Stamdata deletion protection (CI referential gate + editor expire/warn) | 8 · platform/DX/showcase | done |
|
||||
| [WP-49](WP-49-openzaak-zaken-read-seam.md) | OpenZaak zaken read seam (IZaakSource + ZGW client, config-gated, offline default) | 9 · OpenZaak/ZGW | done |
|
||||
| [WP-50](WP-50-openzaak-create-zaak.md) | OpenZaak create-zaak (first write slice) | 9 · OpenZaak/ZGW | done |
|
||||
| [WP-51](WP-51-openzaak-documenten.md) | OpenZaak Documenten (DRC) upload + zaak link | 9 · OpenZaak/ZGW | done |
|
||||
| [WP-52](WP-52-openzaak-notificaties.md) | OpenZaak Notificaties (NRC) live status via webhook | 9 · OpenZaak/ZGW | done |
|
||||
| [WP-53](WP-53-inbound-identity-and-citizen-scoping.md) | Inbound identity seam + citizen-scoping (per-request BSN, ZGW audit claims) | 9 · OpenZaak/ZGW | done |
|
||||
| [WP-54](WP-54-openzaak-integration-harness.md) | Docker OpenZaak integration-test harness (opt-in, live round-trip) | 9 · OpenZaak/ZGW | done |
|
||||
| [WP-55](WP-55-openzaak-secrets-tls.md) | Real secrets + TLS for the OpenZaak harness | 10 · OpenZaak hardening | done |
|
||||
| [WP-56](WP-56-openzaak-catalogus-provisioning.md) | Idempotent catalogus provisioning | 10 · OpenZaak hardening | done |
|
||||
| [WP-57](WP-57-openzaak-least-privilege-scopes.md) | Least-privilege client scopes | 10 · OpenZaak hardening | done |
|
||||
| [WP-58](WP-58-openzaak-notifications.md) | Real notifications (celery + scripted abonnement) | 10 · OpenZaak hardening | done |
|
||||
| [WP-59](WP-59-document-confidentialiteit-config.md) | Per-document-type confidentialiteit config | 10 · OpenZaak hardening | done |
|
||||
| [WP-60](WP-60-write-divergence-resilience.md) | Write-divergence resilience (local + ZGW writes) | 10 · OpenZaak hardening | done |
|
||||
| [WP-61](WP-61-behandelportal-bootstrap.md) | Bootstrap the behandelportal app | 11 · Behandelportal | done |
|
||||
| [WP-62](WP-62-medewerker-identity-authz.md) | Backend: medewerker caller identity + authz seam | 11 · Behandelportal | done |
|
||||
| [WP-63](WP-63-aanvraag-status-lifecycle.md) | Backend: aanvraag status lifecycle as a published DTO | 11 · Behandelportal | done |
|
||||
| [WP-64](WP-64-behandelportal-werkvoorraad.md) | Behandelportal: werkvoorraad (queue) screen | 11 · Behandelportal | done |
|
||||
| [WP-65](WP-65-behandelportal-beoordeling.md) | Behandelportal: zaak detail + beoordeling (decision) screen | 11 · Behandelportal | done |
|
||||
| [WP-66](WP-66-behandelportal-openzaak-write.md) | Wire the decision into OpenZaak | 11 · Behandelportal | done |
|
||||
| [WP-67](WP-67-monorepo-behandelportal.md) | Merge behandelportal into this repo as a monorepo | 11 · Behandelportal | done |
|
||||
| WP | Title | Phase | Status |
|
||||
| ------------------------------------------------------- | ---------------------------------------------------------------------------------- | --------------------------- | ------ |
|
||||
| [WP-01](WP-01-axe-ci-gate.md) | Axe-on-every-story CI gate | 0 · gates | done |
|
||||
| [WP-02](WP-02-check-tokens.md) | Harden `check:tokens` + fix what it catches | 0 · gates | done |
|
||||
| [WP-03](WP-03-contracts-purity.md) | Boundaries I: contracts purity + ApiClient confinement | 0 · gates | done |
|
||||
| [WP-04](WP-04-ui-not-infrastructure.md) | Boundaries II: `ui ↛ infrastructure` + showcase sanction | 0 · gates | done |
|
||||
| [WP-05](WP-05-parse-boundaries.md) | Parse-don't-validate closure + MDX | 1 · FP/DDD | done |
|
||||
| [WP-06](WP-06-typed-async.md) | Generic async template contexts — kill `$any()` | 1 · FP/DDD | done |
|
||||
| [WP-07](WP-07-brief-idioms.md) | Brief on the shared idioms + RemoteData MDX | 1 · FP/DDD | done |
|
||||
| [WP-08](WP-08-store-idiom.md) | One store idiom + machine naming + TEA MDX | 1 · FP/DDD | done |
|
||||
| [WP-09](WP-09-pure-logic.md) | Pure-logic closure: dates + missing command specs | 1 · FP/DDD | done |
|
||||
| [WP-10](WP-10-button-fidelity.md) | CIBG button fidelity | 2 · CIBG | done |
|
||||
| [WP-11](WP-11-markup-fidelity.md) | CIBG markup fidelity: application-link + absent-class triage | 2 · CIBG | done |
|
||||
| [WP-12](WP-12-datablock.md) | CIBG Datablock for application data | 2 · CIBG | done |
|
||||
| [WP-13](WP-13-cibg-gap-register.md) | CIBG-gap register + hygiene + MDX | 2 · CIBG | done |
|
||||
| [WP-14](WP-14-storybook-taxonomy.md) | Storybook taxonomy reorg + Layers MDX | 3 · Storybook | done |
|
||||
| [WP-15](WP-15-missing-stories.md) | Missing stories: shell + brief components | 3 · Storybook | done |
|
||||
| [WP-16](WP-16-component-a11y.md) | Component a11y: description wiring + alert role | 4 · a11y | done |
|
||||
| [WP-17](WP-17-app-a11y.md) | App-level a11y: route focus, template lint, WCAG checklist | 4 · a11y | done |
|
||||
| [WP-18](WP-18-abac-capability-spine.md) | ABAC capability spine (Principal + capabilities, phase P1) | 5 · productie-volwassenheid | done |
|
||||
| [WP-19](WP-19-e2e-smoke.md) | Playwright e2e smoke | 5 · productie-volwassenheid | done |
|
||||
| [WP-20](WP-20-second-locale.md) | Second locale proof | 5 · productie-volwassenheid | done |
|
||||
| [WP-21](WP-21-resilience-seams.md) | Resilience seams (correlation-id, idempotency, retry) | 5 · productie-volwassenheid | done |
|
||||
| [WP-22](WP-22-durable-persistence.md) | Durable persistence (optional tier) | 5 · productie-volwassenheid | done |
|
||||
| [WP-23](WP-23-org-template-backend.md) | Org-template backend + admin role | 6 · Brief v2 | done |
|
||||
| [WP-24](WP-24-letter-canvas.md) | Letter canvas (edit on the letter) | 6 · Brief v2 | done |
|
||||
| [WP-25](WP-25-letter-preview-html.md) | Server-rendered letter preview (HTML; PDF deferred) | 6 · Brief v2 | done |
|
||||
| [WP-26](WP-26-org-template-editor.md) | Admin org-template editor | 6 · Brief v2 | done |
|
||||
| [WP-27](WP-27-brief-ux-layer.md) | Brief UX layer (undo/redo, standaardbrief, diff) | 6 · Brief v2 | done |
|
||||
| [WP-28](WP-28-brief-v2-demo-polish.md) | Brief v2 demo polish (scenarios, e2e, docs) | 6 · Brief v2 | done |
|
||||
| [WP-29](WP-29-stamdata-beheer-editor.md) | Stamdata beheer editor (low-code, PR-emitting) | follow-on · ADR-0004 | done |
|
||||
| [WP-30](WP-30-ci-perf-followups.md) | CI performance follow-ups (node_modules cache, runner image, path filters) | follow-on · CI/infra | done |
|
||||
| [WP-31](WP-31-shared-store-helpers.md) | Shared store helpers (ActionState/SaveState, history, debounced-save, RemoteData) | 7 · refinements | done |
|
||||
| WP-32 | Undo/redo in the stamdata editor (folded into WP-31 — no separate file) | 7 · refinements | done |
|
||||
| [WP-33](WP-33-dev-switchers.md) | In-app dev switchers (scenario + role) | 7 · refinements | done |
|
||||
| [WP-34](WP-34-adres-phone-brp-readonly.md) | Adres: phone field + BRP address read-only | 7 · refinements | done |
|
||||
| [WP-35](WP-35-one-concept-per-type.md) | One Concept per case type (server-enforced) | 7 · refinements | done |
|
||||
| [WP-36](WP-36-admin-cases.md) | Admin cases page + admin delete | 7 · refinements | done |
|
||||
| [WP-37](WP-37-dev-switcher-reset.md) | Dev-switcher reset fix (scenario/role URL param) | 8 · platform/DX/showcase | done |
|
||||
| [WP-38](WP-38-dependency-graph-boundaries.md) | Dependency graph + declarative boundaries (visualize + enforce) | 8 · platform/DX/showcase | done |
|
||||
| [WP-39](WP-39-showcase-snippets-animations.md) | Showcase: linked code snippets + teaching animations | 8 · platform/DX/showcase | done |
|
||||
| [WP-40](WP-40-pii-kernel.md) | PII kernel: branded `Bsn` VO (elfproef) + masked-value atom | 8 · platform/DX/showcase | done |
|
||||
| [WP-41](WP-41-persisted-authz-audit.md) | Persisted, queryable authz/PII-reveal audit (no PII) | 8 · platform/DX/showcase | done |
|
||||
| [WP-42](WP-42-privacy-security-showcase.md) | Privacy & security showcase page (mask + no-PII log) | 8 · platform/DX/showcase | done |
|
||||
| [WP-43](WP-43-scaffold-generators.md) | Runnable generators: value-object / form-machine (plop; ui-component/bff = skills) | 8 · platform/DX/showcase | done |
|
||||
| [WP-44](WP-44-context-generator.md) | Runnable generator: `gen:context` | 8 · platform/DX/showcase | done |
|
||||
| [WP-45](WP-45-create-frontend-generator.md) | `create-frontend` bootstrap generator (mechanise new-ssp) | 8 · platform/DX/showcase | done |
|
||||
| [WP-46](WP-46-vitest-coverage.md) | Vitest coverage (report + report-only thresholds) | 8 · platform/DX/showcase | done |
|
||||
| [WP-47](WP-47-feature-flags.md) | Runtime feature flags (catalog-in-code, admin toggle, FE+backend) | 8 · platform/DX/showcase | done |
|
||||
| [WP-48](WP-48-stamdata-deletion-protection.md) | Stamdata deletion protection (CI referential gate + editor expire/warn) | 8 · platform/DX/showcase | done |
|
||||
| [WP-49](WP-49-openzaak-zaken-read-seam.md) | OpenZaak zaken read seam (IZaakSource + ZGW client, config-gated, offline default) | 9 · OpenZaak/ZGW | done |
|
||||
| [WP-50](WP-50-openzaak-create-zaak.md) | OpenZaak create-zaak (first write slice) | 9 · OpenZaak/ZGW | done |
|
||||
| [WP-51](WP-51-openzaak-documenten.md) | OpenZaak Documenten (DRC) upload + zaak link | 9 · OpenZaak/ZGW | done |
|
||||
| [WP-52](WP-52-openzaak-notificaties.md) | OpenZaak Notificaties (NRC) live status via webhook | 9 · OpenZaak/ZGW | done |
|
||||
| [WP-53](WP-53-inbound-identity-and-citizen-scoping.md) | Inbound identity seam + citizen-scoping (per-request BSN, ZGW audit claims) | 9 · OpenZaak/ZGW | done |
|
||||
| [WP-54](WP-54-openzaak-integration-harness.md) | Docker OpenZaak integration-test harness (opt-in, live round-trip) | 9 · OpenZaak/ZGW | done |
|
||||
| [WP-55](WP-55-openzaak-secrets-tls.md) | Real secrets + TLS for the OpenZaak harness | 10 · OpenZaak hardening | done |
|
||||
| [WP-56](WP-56-openzaak-catalogus-provisioning.md) | Idempotent catalogus provisioning | 10 · OpenZaak hardening | done |
|
||||
| [WP-57](WP-57-openzaak-least-privilege-scopes.md) | Least-privilege client scopes | 10 · OpenZaak hardening | done |
|
||||
| [WP-58](WP-58-openzaak-notifications.md) | Real notifications (celery + scripted abonnement) | 10 · OpenZaak hardening | done |
|
||||
| [WP-59](WP-59-document-confidentialiteit-config.md) | Per-document-type confidentialiteit config | 10 · OpenZaak hardening | done |
|
||||
| [WP-60](WP-60-write-divergence-resilience.md) | Write-divergence resilience (local + ZGW writes) | 10 · OpenZaak hardening | done |
|
||||
| [WP-61](WP-61-behandelportal-bootstrap.md) | Bootstrap the behandelportal app | 11 · Behandelportal | done |
|
||||
| [WP-62](WP-62-medewerker-identity-authz.md) | Backend: medewerker caller identity + authz seam | 11 · Behandelportal | done |
|
||||
| [WP-63](WP-63-aanvraag-status-lifecycle.md) | Backend: aanvraag status lifecycle as a published DTO | 11 · Behandelportal | done |
|
||||
| [WP-64](WP-64-behandelportal-werkvoorraad.md) | Behandelportal: werkvoorraad (queue) screen | 11 · Behandelportal | done |
|
||||
| [WP-65](WP-65-behandelportal-beoordeling.md) | Behandelportal: zaak detail + beoordeling (decision) screen | 11 · Behandelportal | done |
|
||||
| [WP-66](WP-66-behandelportal-openzaak-write.md) | Wire the decision into OpenZaak | 11 · Behandelportal | done |
|
||||
| [WP-67](WP-67-monorepo-behandelportal.md) | Merge behandelportal into this repo as a monorepo | 11 · Behandelportal | done |
|
||||
| [WP-68](WP-68-ddd-aggregate-hardening.md) | Aggregate invariants + status modelling (architecture review) | 12 · DDD hardening | done |
|
||||
| [WP-69](WP-69-intake-scholing-threshold-enforcement.md) | Enforce the scholing threshold server-side | 12 · DDD hardening | todo |
|
||||
|
||||
Sequencing dependencies (stated in the WPs too): 01 before 10–15 (axe covers story churn);
|
||||
03/04 before 05–09 (boundaries stop new violations during refactors); 06 before 07 (typed
|
||||
|
||||
@@ -0,0 +1,337 @@
|
||||
# WP-68 — Aggregate invariants + status modelling (architecture review remediation)
|
||||
|
||||
Status: done (a394950..472a49f)
|
||||
Phase: 12 — DDD hardening
|
||||
|
||||
## Why
|
||||
|
||||
An architecture review on 2026-08-05 (bounded contexts, aggregates, CQRS, DDD/BDD test
|
||||
alignment, measured against this repo's own documented pattern) found the context boundaries,
|
||||
the FP/TEA idioms and the read/write separation to be sound — and found four defects clustered
|
||||
in one place: **the backend's aggregate roots do not guard their own invariants, and the
|
||||
aanvraag status lifecycle is a computed string living in the contracts layer.**
|
||||
|
||||
The four in this WP, in dependency order:
|
||||
|
||||
- **F1 — `submit` links client-supplied `documentId`s with no ownership check.**
|
||||
`Program.cs:353-356` takes document ids straight from the request body and hands them to
|
||||
`ApplicationStore.Submit` and `documents.LinkToZaak`; `DocumentStore.Link` has no `owner`
|
||||
parameter and performs no check (`DocumentStore.cs:113-125`). Same for `SyncDraft`
|
||||
(`Program.cs:317`). A caller who knows a foreign document GUID can attach another citizen's
|
||||
upload to their own aanvraag — where it appears on the behandelaar's beoordeling screen with
|
||||
its filename (`Program.cs:434`) and is POSTed to OpenZaak as a zaakinformatieobject on
|
||||
_their_ zaak — and flips the victim's `Linked = true`, which permanently blocks the victim's
|
||||
own delete (`DeleteOwned` → `DeleteResult.Linked`). ADR-0001 is explicit that the FE holds
|
||||
no authority; this trusts it anyway.
|
||||
|
||||
- **F3 — the aanvraag status lifecycle is a computed string in `Contracts/`.** Three
|
||||
compounding facts: the status is derived in `Contracts/Mappers.ToStatusDto`
|
||||
(`Mappers.cs:44-63`), not in the domain; `Concept` is **not** a member of
|
||||
`AanvraagStatusTag` (`ApplicationStore.cs:14`) but a magic string the mapper emits; and the
|
||||
write path reads its own guard back out of the read DTO —
|
||||
`a.ToStatusDto(now).Tag` → compare `"Concept"` → `Enum.Parse<AanvraagStatusTag>`
|
||||
(`Program.cs:466-468`). This violates the repo's non-negotiable #3 ("make illegal states
|
||||
unrepresentable") on the backend's most important type: the status is
|
||||
`enum + one string that is not in the enum`, so `Enum.Parse` is a runtime throw waiting for
|
||||
a new tag. It is also the one genuine CQRS symptom in the codebase — a command deriving its
|
||||
invariant from a read projection — and it is _why_ F2 exists: there is no domain object that
|
||||
could have owned the guard.
|
||||
|
||||
- **F2 — the besluit invariant is checked outside the write transaction.**
|
||||
`Program.cs:469` calls `BeoordelingRules.CanDecide`; the write happens later in
|
||||
`ApplicationStore.RecordBesluit` (`ApplicationStore.cs:278-291`), which takes the lock and
|
||||
assigns unconditionally. Two concurrent besluiten both pass the check and both write, so the
|
||||
second silently overwrites a terminal decision the rule exists to freeze. The codebase
|
||||
already documents the correct pattern three methods earlier — `CreateConcept`: _"Race-free:
|
||||
the existence check and the insert share the single write gate."_ This is an internal
|
||||
inconsistency, not a missing concept.
|
||||
|
||||
- **F6 — a besluit rule with no home in `Domain/`.** "Toelichting verplicht bij Afwijzen /
|
||||
MeerInfoOpvragen" lives inline at `Program.cs:473`, although `BeoordelingRules`' own
|
||||
doc-comment says the decision-recording rules were meant to land there. It therefore has no
|
||||
unit test, only the endpoint test `Afwijzen_requires_a_toelichting`.
|
||||
|
||||
Plus one documentation correction (**F5**, see Decisions — the enforcement itself is deferred
|
||||
to WP-69, because it needs a wire change).
|
||||
|
||||
The review's remaining findings are listed under "Follow-ups" and are **not** this WP's scope.
|
||||
|
||||
## Read first
|
||||
|
||||
- `CLAUDE.md` §"The decisions" #3 (make illegal states unrepresentable) and #4 (BFF-lite)
|
||||
- [ADR-0001 — BFF-lite + decision DTOs](../../reference/architecture/0001-bff-lite-decision-dtos.md)
|
||||
- `backend/src/BigRegister.Api/Data/ApplicationStore.cs` (the `Aanvraag` entity, the store's
|
||||
lock discipline, `AanvraagStatusTag`, `RecordBesluit`)
|
||||
- `backend/src/BigRegister.Api/Contracts/Mappers.cs` (`ToStatusDto` — the logic to move)
|
||||
- `backend/src/BigRegister.Api/Program.cs` lines 300-500 (draft sync, submit, beoordeling GET,
|
||||
besluit POST)
|
||||
- `backend/src/BigRegister.Api/Zgw/ZgwZaakMapper.cs` (**the second producer of the status
|
||||
DTO** — easy to miss)
|
||||
- `backend/src/BigRegister.Api/Data/DocumentStore.cs` (`Link`, `DeleteOwned`, the existing
|
||||
`DeleteResult` enum this WP copies)
|
||||
- `backend/src/BigRegister.Api/Domain/Beoordeling/BeoordelingRules.cs`
|
||||
|
||||
## Prerequisite
|
||||
|
||||
**Commit or stash the working tree first.** At review time it carried the WP-66 id-mismatch fix
|
||||
across 11 modified files plus the untracked `backend/tests/BigRegister.Tests/BeoordelingIdMismatchTests.cs`.
|
||||
Do not start a cross-cutting refactor on top of uncommitted work.
|
||||
|
||||
## Decisions
|
||||
|
||||
Pre-made — do not relitigate.
|
||||
|
||||
### F3 — the status type
|
||||
|
||||
1. **Move `AanvraagStatusTag` and `Besluit`** out of `Data/ApplicationStore.cs` into
|
||||
`Domain/Applications/` (namespace `BigRegister.Domain.Applications`).
|
||||
**`ApplicationStore.ProcessingWindow` stays where it is.** The original text here said to
|
||||
move it too "because `StatusAt` needs it" — but `StatusAt` is an instance method on
|
||||
`Aanvraag`, itself defined in `ApplicationStore.cs`, so it already sits in the same file/
|
||||
namespace as `ProcessingWindow` and can reference it directly with no cross-namespace
|
||||
issue. Moving it would have been motion without a reason, and — found only once
|
||||
implementation started — `ApplicationTests.cs` references `ApplicationStore.ProcessingWindow`
|
||||
directly in two tests this WP's own acceptance criteria require to stay **unmodified**;
|
||||
moving the constant would have forced a choice between breaking that criterion or adding a
|
||||
forwarding shim for no gain. Leave it.
|
||||
2. **`AanvraagStatusTag` is NOT given a `Concept` member — implemented differently, deliberately.**
|
||||
The original text said to add `Concept` as the first member. That directly conflicts with
|
||||
this WP's own acceptance criterion that `AanvraagStatusTag_covers_the_published_lifecycle`
|
||||
(which asserts `Enum.GetNames<AanvraagStatusTag>()` equals exactly the five published-lifecycle
|
||||
names) passes **unmodified** — adding a sixth name breaks it. Found only once implementation
|
||||
started; resolved in favor of the harder constraint (the regression-net test) and a cleaner
|
||||
design: **`AanvraagStatus.Tag` is `AanvraagStatusTag?`, null exactly for Concept.** This
|
||||
still closes the actual finding (a magic string with no corresponding enum member,
|
||||
round-tripped through the DTO and `Enum.Parse`d) without touching the enum the test pins,
|
||||
and without the reduce-only "boolean + tag" shape rule #3 warns against — a nullable
|
||||
discriminator is the standard two-case union, not a second boolean bolted on. `Ingediend`
|
||||
is unaffected by this and is still kept reserved (see below).
|
||||
Keep `Ingediend` even though nothing produces it today (verified: neither `ToStatusDto` nor
|
||||
`ZgwZaakMapper` emits it) — `BeoordelingRules.CanDecide` accepts it, the FE's
|
||||
`BeoordelingStatus` union declares it, `statusLabel` has a `$localize` id for it, and
|
||||
`Only_open_statuses_are_decidable` tests it. Deleting it would ripple into
|
||||
`messages.en.xlf`. Mark it reserved with a comment instead.
|
||||
3. **New `Domain/Applications/AanvraagStatus.cs`**: a `sealed class` (not a `record` — no
|
||||
external mutation via `with` is wanted, and record value-equality/`ToString` boilerplate
|
||||
buys nothing for a short-lived read model) carrying `AanvraagStatusTag? Tag` (null =
|
||||
Concept) plus the same optional payload fields the DTO has (`StepIndex`, `StepCount`,
|
||||
`Referentie`, `Manual`, `Reden`), constructed **only** via static factories —
|
||||
`Concept(stepIndex, stepCount)`, `InBehandeling(referentie, manual)`,
|
||||
`Goedgekeurd(referentie)`, `Afgewezen(referentie, reden)`,
|
||||
`MeerInfoGevraagd(referentie, reden)`.
|
||||
**Rejected: a full abstract-record union** (one subrecord per tag). It is the purer
|
||||
modelling, but it forces exhaustive switches at four call sites and a per-case mapper for a
|
||||
marginal gain over "the factories are the only construction path". Not worth the diff here.
|
||||
4. **`Aanvraag.StatusAt(DateTimeOffset now)`** — an instance method on the entity carrying the
|
||||
logic currently in `ToStatusDto` **verbatim**, including the "a recorded decision wins over
|
||||
the auto-approve computation" ordering.
|
||||
5. **`Mappers.ToStatusDto` becomes a one-line projection** of `a.StatusAt(now)`, via a shared
|
||||
`Mappers.ToDto(this AanvraagStatus s)` extension (also used by `ZgwZaakMapper` — see below,
|
||||
point 7 — so both status producers agree on one projection):
|
||||
`new(s.Tag?.ToString() ?? "Concept", s.StepIndex, s.StepCount, s.Referentie, s.Manual, s.Reden)`.
|
||||
6. **`AanvraagStatusDto` is unchanged — `Tag` stays a `string`.** This is the safety property
|
||||
that makes F3 an internal refactor: **no wire change, no `gen:api` drift, no frontend
|
||||
change, no `messages.en.xlf` change.** Do not "improve" the DTO in this WP.
|
||||
7. **`ZgwZaakMapper` is the second producer** and must be converted too, or the string literals
|
||||
survive: `ToSummaryDto` and `ToCreatedStatusDto` build `AanvraagStatus` via the factories and
|
||||
project through the same one-liner. Its coarse behaviour must not change (open/no einddatum →
|
||||
`InBehandeling` with `Manual: true`; closed → `Goedgekeurd`) — `ZgwZaakMapperTests` is the net.
|
||||
8. **The besluit endpoint stops going through the DTO**: `var status = a.StatusAt(now);`
|
||||
compare `status.Tag == AanvraagStatusTag.Concept`, pass `status.Tag` to `CanDecide`. The
|
||||
`Enum.Parse` at `Program.cs:468` is deleted.
|
||||
9. **One `Enum.Parse` may remain** — the beoordeling GET at `Program.cs:438`, which parses a tag
|
||||
off a DTO returned by the `IZaakSource` seam. That is a genuine wire→domain trust boundary,
|
||||
not a smell. Keep exactly one, make it non-throwing for an unknown tag, and comment it as the
|
||||
seam boundary. **Changing `IZaakSource` to return domain types is out of scope.**
|
||||
|
||||
### F2 — the besluit guard
|
||||
|
||||
`ApplicationStore.RecordBesluit(string id, Besluit besluit, string? toelichting, DateTimeOffset now)`
|
||||
returns `(RecordBesluitOutcome Outcome, Aanvraag? Aanvraag)` with
|
||||
`enum RecordBesluitOutcome { Ok, NotFound, Conflict }` — mirroring the existing
|
||||
`DocumentStore.DeleteResult` precedent rather than inventing a new result idiom. Inside the
|
||||
lock: find, `StatusAt(now)`, `CanDecide` → `Conflict` if refused, then write. **The endpoint
|
||||
drops its own pre-check** and maps the outcome to 200/404/409, so there is one source of truth
|
||||
for the transition. The endpoint keeps its id-resolution and its `Concept` → 404 (both need the
|
||||
`IZaakSource` lookup the store cannot see).
|
||||
|
||||
### F1 — document ownership
|
||||
|
||||
New `DocumentStore.ForeignIds(IEnumerable<string> ids, string owner)` returning the ids that do
|
||||
**not** resolve to a document owned by `owner` (returning the offending ids, not a bool, so the
|
||||
ProblemDetails can name them). Called in `POST /applications/{id}/submit` **before** any write,
|
||||
and in the draft-sync endpoint (`Program.cs:317`); non-empty → 400 ProblemDetails.
|
||||
|
||||
Endpoint-level check only. `IDocumentSource.LinkToZaak` keeps its current signature (two
|
||||
implementations, and the endpoint has now validated its input) — add a comment saying so.
|
||||
"A document already linked to a different aanvraag of the same owner" is **not** covered here;
|
||||
note it as a follow-up, do not build it.
|
||||
|
||||
### F5 — narrowed to a doc fix
|
||||
|
||||
`IntakePolicy`'s XML doc-comment claims _"the backend re-validates on submit as the
|
||||
authority"_. It does not: the constant's only consumer is `Program.cs:155`, which echoes it, and
|
||||
both submit paths apply `SubmissionRules.RejectZeroUren` only. Verified cause: **neither
|
||||
`SubmitApplicationRequest(DiplomaHerkomst, Uren, Documents)` nor `IntakeRequest(int Uren)`
|
||||
carries a scholing answer at all**, so the server cannot re-validate without a contract change,
|
||||
and the wizard's answers (`scholingGevolgd`, `punten` — `intake.machine.ts:26,37`) never reach
|
||||
it. Reading them out of the opaque `Draft` JSON is rejected: the backend's documented posture is
|
||||
that the draft is opaque (`AppDbContext` header comment).
|
||||
|
||||
**In this WP: correct the doc-comment to state the gap, and nothing else.** The enforcement is
|
||||
WP-69 (a real FE+BE slice: request fields, `IntakePolicy.RejectMissingScholing`, wizard payload,
|
||||
`gen:api`).
|
||||
|
||||
## Files
|
||||
|
||||
- `Domain/Applications/AanvraagStatus.cs` (new — tag enum, `Besluit`, `ProcessingWindow`, the
|
||||
status record + factories)
|
||||
- `Data/ApplicationStore.cs` (`Aanvraag.StatusAt`, `RecordBesluit` signature + in-lock guard,
|
||||
enums moved out)
|
||||
- `Contracts/Mappers.cs` (`ToStatusDto` reduced to a projection)
|
||||
- `Zgw/ZgwZaakMapper.cs` (both producers converted)
|
||||
- `Data/DocumentStore.cs` (`ForeignIds`)
|
||||
- `Domain/Beoordeling/BeoordelingRules.cs` (`RequiresToelichting`)
|
||||
- `Domain/Intake/IntakePolicy.cs` (doc-comment only)
|
||||
- `Program.cs` (submit + draft-sync ownership checks; besluit endpoint simplified)
|
||||
- `tests/BigRegister.Tests/` — `RuleTests.cs` (new `AanvraagStatusTests` nested class +
|
||||
`RequiresToelichting`), `ApplicationTests.cs` (ownership), `BeoordelingTests.cs` (concurrency)
|
||||
|
||||
No migration: no persisted column changes (`BesluitStatus` already stores `Besluit`, whose
|
||||
member names are unchanged).
|
||||
|
||||
## Steps
|
||||
|
||||
1. Commit/stash the WP-66 working tree (see Prerequisite).
|
||||
2. **F1** — `DocumentStore.ForeignIds` + the two endpoint checks + tests. Independent of the
|
||||
rest; land it first so the correctness fix is not blocked by the refactor.
|
||||
3. **F3** — the status type, in Decisions order 1→9. `dotnet test` green with
|
||||
`AanvraagStatusTag_covers_the_published_lifecycle`,
|
||||
`AutoApprovable_flips_to_goedgekeurd_after_the_window` and `ZgwZaakMapperTests` **unchanged**
|
||||
— those three are the regression net for the refactor.
|
||||
4. **F2** — `RecordBesluitOutcome`, guard moved inside the lock, endpoint maps the outcome.
|
||||
5. **F6** — `BeoordelingRules.RequiresToelichting` + unit test; endpoint calls it.
|
||||
6. **T3** — the lifecycle spec that F3 makes expressible: one `[Theory]` over
|
||||
(status × besluit) → allowed/denied, asserting among others that Afgewezen → Goedgekeurd is
|
||||
refused as a _domain_ statement, not only at the endpoint.
|
||||
7. **F5** — correct the `IntakePolicy` doc-comment; open WP-69 for the enforcement.
|
||||
8. Run the full gate (see Verification).
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [x] Submitting (or draft-syncing) an aanvraag with a `documentId` owned by another citizen is
|
||||
rejected with 400, and the other citizen's document remains deletable
|
||||
(`DeleteResult.Ok`). (`Submitting_a_foreign_documentId_is_rejected_and_leaves_it_deletable_by_its_owner`,
|
||||
`Draft_sync_with_a_foreign_documentId_is_rejected`.)
|
||||
- [x] `AanvraagStatusTag` does NOT contain `Concept` — implemented instead as
|
||||
`AanvraagStatus.Tag` being `AanvraagStatusTag?`, null exactly for Concept (see Decisions
|
||||
§F3.2 for why this replaced the original "add Concept to the enum" instruction). No
|
||||
_internal domain_ code compares a status against the `"Concept"` string; the one
|
||||
remaining comparison (`Program.cs`'s beoordeling GET, against `IZaakSource`'s wire DTO)
|
||||
is the deliberate wire-boundary exception, paired with the one allowed `Enum.TryParse`
|
||||
below.
|
||||
- [x] `Enum.Parse`/`TryParse<AanvraagStatusTag>` appears **at most once** in `backend/src`, at
|
||||
the `IZaakSource` seam (`Program.cs` beoordeling GET), and does not throw on an unknown
|
||||
tag (`Enum.TryParse` there, not `Enum.Parse`).
|
||||
- [x] `Mappers.ToStatusDto` contains no lifecycle logic — it projects `Aanvraag.StatusAt(now)`.
|
||||
- [x] `ZgwZaakMapper` constructs no `AanvraagStatusDto` from string literals.
|
||||
- [x] `npm run gen:api` leaves **no diff** in `backend/swagger.json` or
|
||||
`libs/shared/src/infrastructure/api-client.ts` beyond F1's new 400 responses (verified —
|
||||
the only diff after F3 is the two `.ProducesProblem(400)` blocks F1 added; proof F3
|
||||
changed no wire shape).
|
||||
- [x] Two concurrent `POST /beoordeling/{id}/besluit` racing on the same still-open aanvraag
|
||||
yield exactly one 200 and one 409; the persisted status matches whichever request won
|
||||
(`Concurrent_besluiten_on_the_same_aanvraag_yield_exactly_one_success`, stable across 5
|
||||
repeated runs).
|
||||
- [x] `BeoordelingRules.RequiresToelichting` exists, is unit-tested
|
||||
(`Only_a_non_approval_requires_a_toelichting`), and is the only place the rule lives.
|
||||
- [x] A `[Theory]`/aggregate-level test covers the transition table
|
||||
(`A_terminal_decision_refuses_any_further_besluit`,
|
||||
`MeerInfoOpvragen_is_not_terminal_a_further_besluit_is_still_legal` — via
|
||||
`Aanvraag.StatusAt` + `BeoordelingRules.CanDecide`, not just a bare-tag `[Theory]`, since
|
||||
`CanDecide` doesn't vary by which besluit is attempted — see Decisions for why a literal
|
||||
status×besluit cross-product theory would have been redundant with
|
||||
`Only_open_statuses_are_decidable`).
|
||||
- [x] `IntakePolicy`'s doc-comment no longer claims server-side re-validation; WP-69 exists
|
||||
(`docs/project/backlog/WP-69-intake-scholing-threshold-enforcement.md`).
|
||||
|
||||
## Verification
|
||||
|
||||
```bash
|
||||
cd backend && dotnet test # while iterating
|
||||
npm run gen:api && git diff --exit-code backend/swagger.json libs/shared/src/infrastructure/api-client.ts
|
||||
npm run ci # the full gate before pushing
|
||||
npm run e2e # after F1/F2/F3 — needs the backend + `npm start` running
|
||||
```
|
||||
|
||||
The three existing tests named in step 3 must pass **unmodified**; if a refactor step needs one
|
||||
of them changed, the refactor changed behaviour and is wrong.
|
||||
|
||||
**Result:** `npm run ci` passed fully green — lint, format:check, check:tokens, all four test
|
||||
suites, both localized builds, `npm audit`, backend `dotnet format`+`dotnet test` (216 passing,
|
||||
up from 207 at the start of this WP), snippet-generator drift, and API-client drift (only F1's
|
||||
new 400 responses; F3 shows zero additional wire diff, per acceptance criteria). `npm run e2e`
|
||||
could **not** be verified in this session: port 4200 was already occupied by an unrelated
|
||||
container (`team-monitor-web-1`, a different repo) that Playwright's local `reuseExistingServer`
|
||||
reused as if it were this app, so every test timed out waiting for a `BSN` field that container
|
||||
doesn't have — a pre-existing local port collision, not a regression (nothing in this WP touches
|
||||
ports/docker), and per CLAUDE.md's GREEN definition `npm run e2e` isn't part of the local GREEN
|
||||
gate regardless. Free port 4200 (or set `E2E_BASE_URL`) and re-run `npm run e2e` to close this
|
||||
out if end-to-end confirmation is wanted.
|
||||
|
||||
## Out of scope
|
||||
|
||||
Deliberately excluded — each is a separate WP if wanted:
|
||||
|
||||
- **F4** — backend layer enforcement. `Domain/Beoordeling/BeoordelingRules.cs` and
|
||||
`Domain/Authorization/Authz.cs` import `BigRegister.Api.Data` (and `Authz` also
|
||||
`.Contracts`, returning `BriefDecisionsDto`), with nothing in CI checking direction — the FE
|
||||
has `dep:check`, the backend has only `dotnet format` + `dotnet test`. This WP's step 3
|
||||
removes the `BeoordelingRules` violation as a side effect; the `Authz` one and the ~6-line
|
||||
reflection convention test are WP-70.
|
||||
- **F5 enforcement** → WP-69 (see Decisions).
|
||||
- **F7** — `ApplicationStore.Submit` and `DocumentStore.Link` take separate locks with no
|
||||
transaction and no compensation; a link failure leaves a submitted aanvraag whose documents
|
||||
are still deletable. Same failure class WP-60 closed for ZGW and left open locally. Fix is to
|
||||
route it through the existing divergence flag + audit row, not to merge the aggregates.
|
||||
- **F8** — pushing invariants from the static stores onto `Aanvraag` as instance methods
|
||||
(`TryRecordBesluit`). This WP does the two that matter; the general move can wait.
|
||||
- **F9** — `Authz` spans five contexts and its four admin gates are byte-identical
|
||||
`role == Admin` checks with **no direct unit test** and no test denying `Approver`.
|
||||
- **F10** — splitting `Program.cs` (917 lines, 50 endpoints). **Deliberately deferred and
|
||||
flagged as risky:** `OrgAdmin`, `StamdataAdmin`, `Beoordelen`, `Submit` and `AuditAuthz` are
|
||||
non-static **local functions** (`Program.cs:756+`) that every endpoint lambda closes over, so
|
||||
splitting means converting all of them to static helpers with explicit dependencies across
|
||||
all 50 registrations — with the deliberate authz ordering (Forbidden before Conflict) as the
|
||||
thing that breaks silently. Lowest value of the review's findings; do it alone, with tests as
|
||||
the net, or not at all.
|
||||
- **F11** — three FE adapter fetch idioms; two loaders `throw` instead of returning `Result`;
|
||||
`runSubmit` (which mints an `Idempotency-Key`) is used for **reads** in `brief.adapter.ts:56`,
|
||||
`org-template.adapter.ts:39,51`, `stamdata.adapter.ts:27,42`. Fix is `runQuery`/`runCommand`
|
||||
over one shared try/catch, ~10 lines.
|
||||
- **T2** — ~54 FE `it()` titles are named after `Msg` tags (`'SetField updates the draft'`,
|
||||
`'SubmitConfirmed maps Submitting to Submitted'`), against `bdd.mdx` rule 3. Titles only.
|
||||
- **T5** — named coverage gaps: `OrgTemplateRules.RejectDraft` (both identity branches, no
|
||||
margin boundary test), the four `Authz` admin gates, `DocumentRules.CategoriesFor`'s
|
||||
`herregistratie`/`org-template` branches, `SubmissionRules.NewReference`, FE
|
||||
`isStatusConsistent` (tested on the backend, never on the FE), the FE herregistratie window
|
||||
boundary, and the FE/BE margin constants which mirror each other with no contract test.
|
||||
- **T6** — trust-boundary `describe` naming has three dialects; 7 `parse*` specs use none.
|
||||
- **ADR-0006 "CQS without CQRS"** — the review's learning deliverable: the read/write
|
||||
separation already present, why the emit-and-enforce rule (one function feeding both the
|
||||
decision flag and the enforcement) makes a read/write stack split actively harmful here, and
|
||||
WP-60's deferred outbox as the documented trigger that would change the answer. Prose only,
|
||||
no runtime code.
|
||||
- Anything CQRS-mechanical: MediatR, handler classes, a separate read store, event sourcing,
|
||||
repositories/unit-of-work, Gherkin/Reqnroll. All explicitly rejected by the review.
|
||||
|
||||
## Risks
|
||||
|
||||
- **Scope creep on F3.** The temptation is to "fix" `AanvraagStatusDto` into a proper wire union
|
||||
while in there. That turns a zero-diff internal refactor into an FE + `messages.en.xlf` +
|
||||
`gen:api` change. The acceptance criterion "`gen:api` leaves no diff" exists to catch it.
|
||||
- **Missing the second producer.** `ZgwZaakMapper` is easy to overlook because it lives under
|
||||
`Zgw/`, not `Contracts/`. If it is missed, the string literals survive and the finding is only
|
||||
half fixed.
|
||||
- **Over-modelling.** A full abstract-record status union, or a repository/unit-of-work layer to
|
||||
"properly" own the aggregate, would be a bigger diff than the defects justify — see Decisions.
|
||||
@@ -0,0 +1,48 @@
|
||||
# WP-69 — Enforce the scholing threshold server-side
|
||||
|
||||
Status: todo
|
||||
Phase: 12 — DDD hardening
|
||||
|
||||
## Why
|
||||
|
||||
WP-68 (F5) found that `IntakePolicy`'s doc-comment claimed _"the backend re-validates on
|
||||
submit as the authority"_ — it doesn't. `GET /intake/policy` only echoes `ScholingThreshold`;
|
||||
neither `SubmitApplicationRequest` (`DiplomaHerkomst`, `Uren`, `Documents`) nor `IntakeRequest`
|
||||
(`Uren`) carries a scholing answer at all, so there's nothing for the server to re-validate.
|
||||
Both submit paths apply only `SubmissionRules.RejectZeroUren`. A crafted POST — bypassing the
|
||||
wizard entirely — can skip the scholing requirement (`scholingGevolgd`/`punten` in
|
||||
`intake.machine.ts`) even though it's presented as mandatory in the UI. ADR-0001's canonical
|
||||
"config value" example (the FE applies the threshold for instant feedback, the backend
|
||||
re-validates as authority) is unenforced for the one rule it was written to illustrate.
|
||||
|
||||
## Read first
|
||||
|
||||
- `backend/src/BigRegister.Api/Domain/Intake/IntakePolicy.cs` (the corrected doc-comment,
|
||||
WP-68)
|
||||
- [ADR-0001 — BFF-lite + decision DTOs](../../reference/architecture/0001-bff-lite-decision-dtos.md)
|
||||
§"config value"
|
||||
- `apps/ssp/src/app/herregistratie/domain/intake.machine.ts` (`lageUren`, `scholingGevolgd`,
|
||||
`punten` — the wizard's existing FE-side rule and its answers)
|
||||
- `backend/src/BigRegister.Api/Contracts/Dtos.cs` (`SubmitApplicationRequest`,
|
||||
`IntakeRequest`, `DocumentRefDto`)
|
||||
- `backend/src/BigRegister.Api/Program.cs` — the `intakes` and `applications/{id}/submit`
|
||||
endpoints
|
||||
|
||||
## Decisions
|
||||
|
||||
Not yet made — this is a placeholder WP opened by WP-68, not a ready-to-implement one. Needs
|
||||
a `planner` pass before work starts. Open questions to resolve then:
|
||||
|
||||
- The request DTOs need a scholing answer field (likely mirroring `intake.machine.ts`'s
|
||||
`ValidIntake.aanvullendeScholing`/`punten`) — this is a wire change, so it touches
|
||||
`contracts/`, the wizard's submit payload, and `npm run gen:api`.
|
||||
- Whether to add the rule to `SubmissionRules` (alongside `RejectZeroUren`) or give
|
||||
`IntakePolicy` its own `RejectMissingScholing(uren, scholing)`, matching the class that
|
||||
already owns the threshold.
|
||||
- Reading the answer out of the wizard's `Draft` JSON was rejected in WP-68 — the backend's
|
||||
documented posture is that the draft is opaque (`AppDbContext`'s header comment) — so the
|
||||
answer must arrive as an explicit request field, not be extracted from the opaque snapshot.
|
||||
|
||||
## Out of scope (for now)
|
||||
|
||||
Implementation — this WP exists to track the gap; do not implement without a Decisions block.
|
||||
@@ -175,10 +175,13 @@ JWT's audit claims reflect the behandelaar, not a static identity.
|
||||
ontbreekt"). This was missing until WP-54's live harness caught it — the stub-handler tests
|
||||
never modelled the header, so it had shipped silently since WP-49/50.
|
||||
- `ZgwZaakMapper.cs` — the anti-corruption map: ZGW Zaak → `ApplicationSummaryDto`. This is
|
||||
where **URL identity** becomes the trailing uuid and the **zaaktype URL** is resolved to a
|
||||
human label (the cross-service join).
|
||||
- `OpenZaakZaakSource.cs` — follows `{count,next,previous,results}` pagination, resolves +
|
||||
caches zaaktype labels, attaches `Authorization: Bearer <jwt>`.
|
||||
where **URL identity** becomes the trailing uuid; `Type` takes the internal aanvraag-type
|
||||
key (`AanvraagTypeFor`, below) — a real bug (found via a live behandelportal walkthrough,
|
||||
fixed post-WP-66) had this carrying OpenZaak's human zaaktype label instead, which the FE's
|
||||
`AANVRAAG_TYPES` trust boundary always rejected.
|
||||
- `OpenZaakZaakSource.cs` — follows `{count,next,previous,results}` pagination, maps each
|
||||
zaak's zaaktype URL back to the internal key via `Zgw:ZaaktypeUrls` (`AanvraagTypeFor` — a
|
||||
local lookup, no Catalogi round-trip), attaches `Authorization: Bearer <jwt>`.
|
||||
- `OpenZaakDocumentSource.cs` — DRC upload + zaak-link (WP-51), same auth/JSON pattern.
|
||||
- `NotificatieDto.cs` + the `POST /api/v1/zgw/notificaties` endpoint (`Program.cs`, WP-52) — the
|
||||
**inbound** NRC webhook, not a source/mapper: see the dedicated section below.
|
||||
@@ -272,13 +275,13 @@ flag, never a rollen matrix.
|
||||
|
||||
## The five ZGW APIs (context for later slices)
|
||||
|
||||
| API | Component | Used by |
|
||||
| ------------ | --------- | --------------------------------------------------- |
|
||||
| Zaken | ZRC | slice 1 (read), WP-50 (create) |
|
||||
| Catalogi | ZTC | slice 1 (zaaktype label; also type URLs for create) |
|
||||
| Documenten | DRC | WP-51 (upload + zaak↔document link) |
|
||||
| Besluiten | BRC | later (formal decisions) |
|
||||
| Notificaties | NRC | WP-52 (live status via webhooks, not polling) |
|
||||
| API | Component | Used by |
|
||||
| ------------ | --------- | ---------------------------------------------------------------- |
|
||||
| Zaken | ZRC | slice 1 (read), WP-50 (create) |
|
||||
| Catalogi | ZTC | WP-50/66 (statustype/roltype/resultaattype for create + besluit) |
|
||||
| Documenten | DRC | WP-51 (upload + zaak↔document link) |
|
||||
| Besluiten | BRC | later (formal decisions) |
|
||||
| Notificaties | NRC | WP-52 (live status via webhooks, not polling) |
|
||||
|
||||
## How to add the next slice
|
||||
|
||||
|
||||
@@ -921,6 +921,12 @@ export class ApiClient {
|
||||
return response.text().then((_responseText) => {
|
||||
return;
|
||||
});
|
||||
} else if (status === 400) {
|
||||
return response.text().then((_responseText) => {
|
||||
let result400: any = null;
|
||||
result400 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as ProblemDetails;
|
||||
return throwException("Bad Request", status, _responseText, _headers, result400);
|
||||
});
|
||||
} else if (status === 404) {
|
||||
return response.text().then((_responseText) => {
|
||||
return throwException("Not Found", status, _responseText, _headers);
|
||||
@@ -1014,6 +1020,12 @@ export class ApiClient {
|
||||
result200 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as SubmitApplicationResponse;
|
||||
return result200;
|
||||
});
|
||||
} else if (status === 400) {
|
||||
return response.text().then((_responseText) => {
|
||||
let result400: any = null;
|
||||
result400 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as ProblemDetails;
|
||||
return throwException("Bad Request", status, _responseText, _headers, result400);
|
||||
});
|
||||
} else if (status === 404) {
|
||||
return response.text().then((_responseText) => {
|
||||
return throwException("Not Found", status, _responseText, _headers);
|
||||
|
||||
@@ -18,6 +18,17 @@ describe('parseMe (trust boundary)', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// Regression: WP-66's `aanvraag:beoordelen` (behandelportal) shipped on the `Capability`
|
||||
// type but was never added to this trust-boundary's runtime KNOWN list, so a real
|
||||
// behandelaar's `/me` response had the capability silently dropped and the werkvoorraad
|
||||
// page always denied — every `Capability` union member belongs in KNOWN too.
|
||||
it('recognizes the behandelportal besluit capability (WP-66)', () => {
|
||||
expect(parseMe({ capabilities: ['aanvraag:beoordelen'] })).toEqual({
|
||||
ok: true,
|
||||
value: ['aanvraag:beoordelen'],
|
||||
});
|
||||
});
|
||||
|
||||
it('drops unrecognized capability strings instead of rejecting the response', () => {
|
||||
const r = parseMe({ capabilities: ['brief:approve', 'unknown:future-thing'] });
|
||||
expect(r).toEqual({ ok: true, value: ['brief:approve'] });
|
||||
|
||||
@@ -11,6 +11,7 @@ const KNOWN: readonly Capability[] = [
|
||||
'stamdata:edit',
|
||||
'cases:manage',
|
||||
'flags:manage',
|
||||
'aanvraag:beoordelen',
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@@ -71,7 +71,7 @@ app = Applicatie.objects.get(client_ids__contains=["bigregister-test"])
|
||||
app.autorisaties.filter(component="zrc").delete()
|
||||
app.autorisaties.create(
|
||||
component="zrc",
|
||||
scopes=["zaken.aanmaken", "zaken.bijwerken", "zaken.lezen"],
|
||||
scopes=["zaken.aanmaken", "zaken.bijwerken", "zaken.lezen", "zaken.statussen.toevoegen"],
|
||||
zaaktype="$container_zaaktype_url",
|
||||
max_vertrouwelijkheidaanduiding="openbaar",
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user