feat(backend): enforce the scholing threshold server-side (WP-69)
ADR-0001's own canonical "config value" example was unenforced: GET
/intake/policy echoed ScholingThreshold, but no request DTO carried a
scholing answer, so the server had nothing to re-validate. A crafted
POST could skip a requirement the wizard presents as mandatory.
IntakePolicy.RejectIncompleteScholing is the authority — three-valued
completeness (below threshold an answer is required; "nee" is legal and
still submits; punten only belong to a followed scholing), living in the
class that owns the constant so scripts/check-seam.sh keeps guarding the
FE/BE literal pair. Both submit paths call it; a violation 400s with
ProblemDetails and leaves the aanvraag a Concept. Gated on
Type == "intake" (the endpoint's switch lumps herregistratie with
intake, which has no scholing question), and guarded by `reject is null`
so a zero-uren submission is still decided on its merits.
Also fixes a live FE bug in the same rule: validateStep required punten
whenever scholingGevolgd was 'ja' regardless of lageUren, while the
template renders those fields only when lageUren — so answering 'ja'
then raising uren either blocked the user on an invisible field or
emitted aanvullendeScholing: undefined alongside punten. punten now
derives from aanvullendeScholing, so that combination is unrepresentable
in ValidIntake.
Note: EndpointTests' Worked_hours_submission_succeeds was itself
asserting the vulnerable payload ({ uren: 40 }, no answer) and needed a
complete answer added; the zero-hours rows are the ordering regression
net and are unmodified.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -75,7 +75,12 @@ public sealed record DocumentRefDto(string CategoryId, string Channel, string? D
|
||||
// Submit requests carry only the fields the server re-validates (UX-only fields
|
||||
// stay on the client). ponytail: a real submit would carry the full application.
|
||||
public sealed record RegistratieRequest(string DiplomaHerkomst, IReadOnlyList<DocumentRefDto>? Documents = null);
|
||||
public sealed record IntakeRequest(int Uren);
|
||||
|
||||
// AanvullendeScholing/ScholingPunten (WP-69): the wizard's scholing answer, re-validated
|
||||
// server-side as the authority by IntakePolicy.RejectIncompleteScholing. Named
|
||||
// ScholingPunten (not Punten) — the sibling SubmitApplicationRequest is shared by all three
|
||||
// wizard types and the herregistratie wizard has its own unrelated `punten`.
|
||||
public sealed record IntakeRequest(int Uren, bool? AanvullendeScholing = null, int? ScholingPunten = null);
|
||||
public sealed record HerregistratieRequest(int Uren, IReadOnlyList<DocumentRefDto>? Documents = null);
|
||||
public sealed record ChangeRequestRequest(string Telefoon);
|
||||
|
||||
@@ -120,9 +125,12 @@ public sealed record DraftSyncRequest(
|
||||
IReadOnlyList<string>? DocumentIds = null);
|
||||
|
||||
// Submit carries only the fields the server re-validates per wizard type.
|
||||
// AanvullendeScholing/ScholingPunten (WP-69) — see IntakeRequest; intake-typed aanvragen
|
||||
// only (gated by IntakePolicy.RejectIncompleteScholing's caller), null for the others.
|
||||
public sealed record SubmitApplicationRequest(
|
||||
string? DiplomaHerkomst = null, int? Uren = null,
|
||||
IReadOnlyList<DocumentRefDto>? Documents = null);
|
||||
IReadOnlyList<DocumentRefDto>? Documents = null,
|
||||
bool? AanvullendeScholing = null, int? ScholingPunten = null);
|
||||
|
||||
public sealed record SubmitApplicationResponse(string Referentie, AanvraagStatusDto Status);
|
||||
|
||||
|
||||
@@ -4,17 +4,39 @@ namespace BigRegister.Domain.Intake;
|
||||
/// 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.
|
||||
/// (<c>intake.machine.ts</c>'s <c>lageUren</c>); <see cref="RejectIncompleteScholing"/> is the
|
||||
/// backend re-validating it as the authority on submit (WP-69) — both
|
||||
/// <c>POST /applications/{id}/submit</c> (intake-typed aanvragen only) and the legacy
|
||||
/// <c>POST /intakes</c> call it before writing anything, and a violation 400s
|
||||
/// (<c>ProblemDetails</c>), never silently accepts an incomplete answer.
|
||||
/// </summary>
|
||||
public static class IntakePolicy
|
||||
{
|
||||
public const int ScholingThreshold = 1000;
|
||||
|
||||
/// <summary>
|
||||
/// Completeness rule for the scholing question (WP-69) — not merit: below
|
||||
/// <see cref="ScholingThreshold"/> an answer must be present, but "nee" is a legal answer
|
||||
/// that still submits (turning "few uren + no scholing" into a rejection is out of scope,
|
||||
/// see the WP). Three-valued, so two parameters (uren, punten) couldn't express it:
|
||||
/// <list type="bullet">
|
||||
/// <item>below threshold and no answer at all ⇒ incomplete;</item>
|
||||
/// <item>answered <c>true</c> (scholing gevolgd) ⇒ punten required and non-negative
|
||||
/// (mirrors <c>parseUren</c>);</item>
|
||||
/// <item>answered anything but <c>true</c> ⇒ punten must be absent (a stale answer left
|
||||
/// behind by raising <c>uren</c> is not a legal payload).</item>
|
||||
/// </list>
|
||||
/// Returns the Dutch detail message for a <c>400 ProblemDetails</c>, or null when complete.
|
||||
/// Boundary is <c><</c>, not <c><=</c> — mirrors <c>lageUren</c>.
|
||||
/// </summary>
|
||||
public static string? RejectIncompleteScholing(int uren, bool? aanvullendeScholing, int? scholingPunten)
|
||||
{
|
||||
if (uren < ScholingThreshold && aanvullendeScholing is null)
|
||||
return $"Beantwoord de vraag over aanvullende scholing: bij minder dan {ScholingThreshold} gewerkte uren is dit verplicht.";
|
||||
if (aanvullendeScholing == true && (scholingPunten is null || scholingPunten < 0))
|
||||
return "Vul het aantal behaalde nascholingspunten in.";
|
||||
if (aanvullendeScholing != true && scholingPunten is not null)
|
||||
return "Nascholingspunten horen alleen bij een gevolgde aanvullende scholing.";
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -195,8 +195,16 @@ api.MapPost("/herregistraties", (HerregistratieRequest req, HttpContext ctx) =>
|
||||
.ProducesProblem(StatusCodes.Status422UnprocessableEntity);
|
||||
|
||||
api.MapPost("/intakes", (IntakeRequest req, HttpContext ctx) =>
|
||||
Submit(ctx, "intake", SubmissionRules.RejectZeroUren(req.Uren)))
|
||||
{
|
||||
// WP-69: completeness check outside Submit(...) — deliberately not folded into `reject`,
|
||||
// so this 400 is never cached in IdempotencyStore the way a 422 rejection would be.
|
||||
var reject = SubmissionRules.RejectZeroUren(req.Uren);
|
||||
if (reject is null && IntakePolicy.RejectIncompleteScholing(req.Uren, req.AanvullendeScholing, req.ScholingPunten) is { } incomplete)
|
||||
return Results.Problem(detail: incomplete, statusCode: StatusCodes.Status400BadRequest);
|
||||
return Submit(ctx, "intake", reject);
|
||||
})
|
||||
.Produces<ReferentieResponse>()
|
||||
.ProducesProblem(StatusCodes.Status400BadRequest)
|
||||
.ProducesProblem(StatusCodes.Status422UnprocessableEntity);
|
||||
|
||||
api.MapPost("/change-requests", (ChangeRequestRequest req, HttpContext ctx) =>
|
||||
@@ -361,6 +369,14 @@ api.MapPost("/applications/{id}/submit", (string id, SubmitApplicationRequest re
|
||||
_ /* herregistratie | intake */ => (SubmissionRules.RejectZeroUren(req.Uren ?? 0), true),
|
||||
};
|
||||
|
||||
// WP-69: intake-only (herregistratie has no scholing question) — guarded by `reject is
|
||||
// null` so a { uren: 0 } submission is still decided on merit (RejectZeroUren) and
|
||||
// completeness is moot; placed before the document-ownership check and
|
||||
// ApplicationStore.Submit so a rejected submit leaves the aanvraag a Concept (retryable).
|
||||
if (existing.Type == "intake" && reject is null &&
|
||||
IntakePolicy.RejectIncompleteScholing(req.Uren ?? 0, req.AanvullendeScholing, req.ScholingPunten) is { } incompleteScholing)
|
||||
return Results.Problem(detail: incompleteScholing, statusCode: StatusCodes.Status400BadRequest);
|
||||
|
||||
var docs = req.Documents;
|
||||
var documentIds = docs?.Where(d => d.Channel == "digital" && d.DocumentId is not null).Select(d => d.DocumentId!).ToList();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user