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:
eho
2026-08-18 22:42:14 +02:00
co-authored by Claude Sonnet 5
parent 9da385311d
commit 5d73ca21f6
16 changed files with 560 additions and 36 deletions
+3 -2
View File
@@ -59,7 +59,7 @@ cd backend && dotnet test # rule unit tests + endpoint integration tests
| GET | `/api/intake/policy` | scholing threshold (config value) |
| POST | `/api/registrations` | submit registration → reference, or 422 (manual diploma) |
| POST | `/api/herregistraties` | submit re-registration → reference, or 422 (0 hours) |
| POST | `/api/intakes` | submit intake → reference, or 422 (0 hours) |
| POST | `/api/intakes` | submit intake → reference, or 422 (0 hours) / 400 (incomplete scholing answer) |
Rejections use **ProblemDetails (RFC 7807)** with status **422**. Every request
carries an `X-Correlation-Id` (set by the FE fetch adapter); the backend echoes it
@@ -77,7 +77,8 @@ semantics) is introduced as **`/api/v2`** served alongside v1 until clients migr
- `Diplomas/DiplomaRules.cs` — profession derivation + which policy questions apply.
- `Registrations/HerregistratieRule.cs` — eligibility + reason + status invariant.
- `Intake/IntakePolicy.cs` — scholing threshold.
- `Intake/IntakePolicy.cs` — scholing threshold + completeness re-validation on submit
(`RejectIncompleteScholing`, WP-69).
- `Submissions/SubmissionRules.cs` — submit rejections + reference generation.
## Typed client (NSwag)
+10 -2
View File
@@ -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>&lt;</c>, not <c>&lt;=</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;
}
}
+17 -1
View File
@@ -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();
+28
View File
@@ -314,6 +314,16 @@
}
}
},
"400": {
"description": "Bad Request",
"content": {
"application/problem+json": {
"schema": {
"$ref": "#/components/schemas/ProblemDetails"
}
}
}
},
"422": {
"description": "Unprocessable Content",
"content": {
@@ -2172,6 +2182,15 @@
"uren": {
"type": "integer",
"format": "int32"
},
"aanvullendeScholing": {
"type": "boolean",
"nullable": true
},
"scholingPunten": {
"type": "integer",
"format": "int32",
"nullable": true
}
},
"additionalProperties": false
@@ -2837,6 +2856,15 @@
"$ref": "#/components/schemas/DocumentRefDto"
},
"nullable": true
},
"aanvullendeScholing": {
"type": "boolean",
"nullable": true
},
"scholingPunten": {
"type": "integer",
"format": "int32",
"nullable": true
}
},
"additionalProperties": false
@@ -0,0 +1,136 @@
using System.Net;
using System.Net.Http.Json;
using BigRegister.Api.Contracts;
using BigRegister.Api.Data;
using BigRegister.Domain.Applications;
using BigRegister.Tests.Builders;
namespace BigRegister.Tests.Acceptance;
/// <summary>
/// Behaviour-level tests for the scholing-threshold enforcement (WP-69) over both live HTTP
/// paths — <c>POST /applications/{id}/submit</c> (the wizard's real path) and the legacy
/// <c>POST /intakes</c> (dead from the UI, still a live crafted-POST surface). Built through
/// the <see cref="Given"/> type-state builder, mirroring <see cref="BesluitLifecycleTests"/>
/// rather than the full wizard/upload dance — the builder's default owner IS
/// <see cref="BigRegister.Api.Domain.Authorization.StubIdentityProvider"/>'s default caller,
/// so no header juggling.
/// </summary>
public class IntakeSubmissionTests(TestWebApplicationFactory factory) : IClassFixture<TestWebApplicationFactory>
{
private readonly HttpClient _client = factory.CreateClient();
private static void Persist(Aanvraag aanvraag)
{
using var db = Db.Create();
db.Applications.Add(aanvraag);
db.SaveChanges();
}
private Task<HttpResponseMessage> Submit(string id, object body) =>
_client.PostAsJsonAsync($"/api/v1/applications/{id}/submit", body);
[Fact]
public async Task Below_threshold_without_an_answer_is_rejected_and_stays_a_concept()
{
// Given an intake-typed Concept aanvraag (not yet submitted).
var aanvraag = Given.Concept(type: "intake").Build();
Persist(aanvraag);
// When it is submitted with uren below the threshold and no scholing answer at all...
var res = await Submit(aanvraag.Id, new { uren = 500 });
// Then the request is rejected as a contract violation (400, not a merit rejection)...
Assert.Equal(HttpStatusCode.BadRequest, res.StatusCode);
// ...and the aanvraag is left a retryable Concept, never marked Submitted.
var stillConcept = ApplicationStore.GetAny(aanvraag.Id)!;
Assert.False(stillConcept.Submitted);
}
[Fact]
public async Task Below_threshold_with_an_answer_succeeds()
{
// Given an intake-typed Concept.
var aanvraag = Given.Concept(type: "intake").Build();
Persist(aanvraag);
// When submitted below the threshold with "niet gevolgd" — a complete, legal answer...
var res = await Submit(aanvraag.Id, new { uren = 500, aanvullendeScholing = false });
// Then the submission succeeds.
res.EnsureSuccessStatusCode();
}
[Fact]
public async Task Above_threshold_needs_no_answer()
{
// Given an intake-typed Concept.
var aanvraag = Given.Concept(type: "intake").Build();
Persist(aanvraag);
// When submitted with uren at/above the threshold and no scholing answer...
var res = await Submit(aanvraag.Id, new { uren = 1000 });
// Then it succeeds — the question is moot above the threshold.
res.EnsureSuccessStatusCode();
}
[Fact]
public async Task Punten_without_gevolgd_is_rejected()
{
// Given an intake-typed Concept.
var aanvraag = Given.Concept(type: "intake").Build();
Persist(aanvraag);
// When submitted above the threshold with punten but no "gevolgd" answer — the stale
// shape §6 fixes on the frontend, still reachable as a crafted POST...
var res = await Submit(aanvraag.Id, new { uren = 1500, scholingPunten = 150 });
// Then it is rejected.
Assert.Equal(HttpStatusCode.BadRequest, res.StatusCode);
}
[Fact]
public async Task Herregistratie_is_unaffected_by_the_intake_only_gate()
{
// Given a herregistratie-typed Concept (no scholing question in that wizard).
var aanvraag = Given.Concept(type: "herregistratie").Build();
Persist(aanvraag);
// When submitted below the intake threshold with no scholing answer at all...
var res = await Submit(aanvraag.Id, new { uren = 500 });
// Then it still succeeds — the gate is intake-only.
res.EnsureSuccessStatusCode();
}
[Fact]
public async Task Zero_uren_is_still_afgewezen_not_a_400()
{
// Given an intake-typed Concept.
var aanvraag = Given.Concept(type: "intake").Build();
Persist(aanvraag);
// When submitted with zero uren and no scholing answer — completeness would also
// reject this, but the merit rejection (RejectZeroUren) must win (the ordering guard)...
var res = await Submit(aanvraag.Id, new { uren = 0 });
// Then the submission is accepted and resolves to Afgewezen — not a 400.
res.EnsureSuccessStatusCode();
var body = (await res.Content.ReadFromJsonAsync<SubmitApplicationResponse>())!;
Assert.Equal("Afgewezen", body.Status.Tag);
}
[Fact]
public async Task Legacy_intakes_endpoint_enforces_it_too()
{
// Given no aanvraag needed — the legacy endpoint mints its own reference.
// When a crafted POST hits the dead-from-the-UI /intakes endpoint below threshold,
// with no scholing answer...
var res = await _client.PostAsJsonAsync("/api/v1/intakes", new { uren = 500 });
// Then it is rejected too — the crafted-POST surface this WP closes.
Assert.Equal(HttpStatusCode.BadRequest, res.StatusCode);
}
}
@@ -0,0 +1,43 @@
using BigRegister.Domain.Intake;
namespace BigRegister.Tests.Domain;
public class IntakeRuleTests
{
// The arguments ARE the Given (WP-69/bdd.mdx) — these degenerate to When/Then.
[Fact]
public void Below_threshold_with_no_answer_is_incomplete() =>
Assert.NotNull(IntakePolicy.RejectIncompleteScholing(999, aanvullendeScholing: null, scholingPunten: null));
[Fact]
public void At_the_threshold_no_answer_is_required() =>
// Pins `<` vs `<=` — lageUren's own boundary.
Assert.Null(IntakePolicy.RejectIncompleteScholing(1000, aanvullendeScholing: null, scholingPunten: null));
[Fact]
public void Niet_gevolgd_is_a_complete_answer_below_threshold() =>
// "nee" is legal — this WP is completeness, not merit (§1's scope).
Assert.Null(IntakePolicy.RejectIncompleteScholing(500, aanvullendeScholing: false, scholingPunten: null));
[Fact]
public void Gevolgd_without_punten_is_incomplete() =>
Assert.NotNull(IntakePolicy.RejectIncompleteScholing(500, aanvullendeScholing: true, scholingPunten: null));
[Fact]
public void Gevolgd_with_zero_punten_is_valid() =>
Assert.Null(IntakePolicy.RejectIncompleteScholing(500, aanvullendeScholing: true, scholingPunten: 0));
[Fact]
public void Gevolgd_with_negative_punten_is_refused() =>
Assert.NotNull(IntakePolicy.RejectIncompleteScholing(500, aanvullendeScholing: true, scholingPunten: -1));
[Theory]
[InlineData(null)] // stale-punten shape (§6): raising uren above threshold left an unanswered
// question but punten still set from when it was visible
[InlineData(false)]
public void Punten_without_gevolgd_is_refused(bool? aanvullendeScholing) =>
// uren ABOVE threshold so the "missing answer" branch can't also explain the rejection —
// this row isolates the "stale punten" rule on its own.
Assert.NotNull(IntakePolicy.RejectIncompleteScholing(1500, aanvullendeScholing, scholingPunten: 150));
}
@@ -103,7 +103,10 @@ public class EndpointTests(TestWebApplicationFactory factory) : IClassFixture<Te
[InlineData("/api/v1/herregistraties")]
public async Task Worked_hours_submission_succeeds(string route)
{
var res = await _client.PostAsJsonAsync(route, new { uren = 40 });
// WP-69: 40 is below IntakePolicy.ScholingThreshold, so /intakes now requires the
// scholing question answered — `aanvullendeScholing` is unknown to (and ignored by)
// HerregistratieRequest, so this one extra field keeps serving both rows unchanged.
var res = await _client.PostAsJsonAsync(route, new { uren = 40, aanvullendeScholing = false });
res.EnsureSuccessStatusCode();
}