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
+24 -2
View File
@@ -20,8 +20,8 @@ tested where._
Every bullet below is a real test name from the suite — an `it()` title (frontend) or a test
method name (backend), read as a sentence. Nothing here is hand-written prose: this page
**is** the suite, reshaped for a business reader. 401 frontend behaviours across
8 contexts; 206 backend behaviours across 33 test
**is** the suite, reshaped for a business reader. 404 frontend behaviours across
8 contexts; 219 backend behaviours across 35 test
classes.
## Frontend (by context)
@@ -312,6 +312,7 @@ classes.
- low uren requires the scholing question, and punten only once scholing is followed
- buitenland gewerkt requires land and hours abroad before advancing
- gaNaarStap corrects an earlier answer without losing later ones
- raising uren above the threshold after answering scholing drops both fields (WP-69 §6)
- SetPolicy (server-owned threshold) can turn an already-answered uren into one that now requires scholing
#### intake hasProgress
@@ -349,6 +350,8 @@ classes.
- reaches Submitting ONLY with valid answers
- punten is required only when aanvullende scholing was gevolgd
- low hours requires the scholing answer before submit
- does not require punten for a hidden question (WP-69 §6)
- drops punten when raising uren hides the question (WP-69 §6)
- resolve maps Submitting to Submitted on a successful submit
- resolve maps Submitting to Failed on a failed submit
@@ -957,6 +960,25 @@ classes.
- Different idempotency keys are independent submissions
- A rejected submission replays the same rejection not a retry
### IntakeRuleTests
- Below threshold with no answer is incomplete
- At the threshold no answer is required
- Niet gevolgd is a complete answer below threshold
- Gevolgd without punten is incomplete
- Gevolgd with zero punten is valid
- Gevolgd with negative punten is refused
### IntakeSubmissionTests
- Below threshold without an answer is rejected and stays a concept
- Below threshold with an answer succeeds
- Above threshold needs no answer
- Punten without gevolgd is rejected
- Herregistratie is unaffected by the intake only gate
- Zero uren is still afgewezen not a 400
- Legacy intakes endpoint enforces it too
### LetterHtmlTests
- Render matches the golden file
@@ -483,6 +483,12 @@ export class ApiClient {
result200 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as ReferentieResponse;
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 === 422) {
return response.text().then((_responseText) => {
let result422: any = null;
@@ -2210,6 +2216,8 @@ export interface IntakePolicyDto {
export interface IntakeRequest {
uren?: number;
aanvullendeScholing?: boolean | undefined;
scholingPunten?: number | undefined;
}
export interface LetterBlockDto {
@@ -2419,6 +2427,8 @@ export interface SubmitApplicationRequest {
diplomaHerkomst?: string | undefined;
uren?: number | undefined;
documents?: DocumentRefDto[] | undefined;
aanvullendeScholing?: boolean | undefined;
scholingPunten?: number | undefined;
}
export interface SubmitApplicationResponse {