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
@@ -147,6 +147,35 @@ describe('intake acceptance journeys', () => {
});
});
it('raising uren above the threshold after answering scholing drops both fields (WP-69 §6)', () => {
// Given a journey that answered the scholing question while uren was low.
const atReview = givenIntake(
{ tag: 'SetAnswer', key: 'buitenlandGewerkt', value: 'nee' },
{ tag: 'Next' },
{ tag: 'SetAnswer', key: 'uren', value: '500' },
{ tag: 'SetAnswer', key: 'scholingGevolgd', value: 'ja' },
{ tag: 'SetAnswer', key: 'punten', value: '150' },
{ tag: 'Next' },
);
expect(atReview.tag).toBe('Answering');
expect(atReview.tag === 'Answering' && atReview.cursor).toBe(2); // review
// When the user goes back and raises uren above the threshold, then submits...
const done = given(reduce, atReview)(
{ tag: 'GaNaarStap', cursor: 1 },
{ tag: 'SetAnswer', key: 'uren', value: '1500' },
{ tag: 'Next' }, // the now-hidden scholing question no longer blocks
{ tag: 'Submit' },
{ tag: 'SubmitConfirmed' },
);
// Then the submission succeeds, and BOTH the stale answer and its punten are gone —
// exactly the crafted-POST-shaped payload WP-69's server rule rejects.
expect(done.tag).toBe('Submitted');
expect(done.tag === 'Submitted' && done.data.aanvullendeScholing).toBeUndefined();
expect(done.tag === 'Submitted' && done.data.punten).toBeUndefined();
});
it('SetPolicy (server-owned threshold) can turn an already-answered uren into one that now requires scholing', () => {
const atWerkStep = givenIntake(
{ tag: 'SetAnswer', key: 'buitenlandGewerkt', value: 'nee' },
@@ -142,6 +142,34 @@ describe('submit', () => {
expect(withScholing.data.punten).toBe(200);
});
it('does not require punten for a hidden question (WP-69 §6)', () => {
// scholingGevolgd is a stale 'ja' from when uren was low, but uren is now above
// threshold — the template hides the question, so punten must not be required either.
const good = expectTag(
submit(answering({ buitenlandGewerkt: 'nee', uren: '1500', scholingGevolgd: 'ja' })),
'Submitting',
);
expect(good.data.aanvullendeScholing).toBeUndefined();
});
it('drops punten when raising uren hides the question (WP-69 §6)', () => {
// Same stale answer, but this time punten was also filled in while uren was low.
const good = expectTag(
submit(
answering({
buitenlandGewerkt: 'nee',
uren: '1500',
scholingGevolgd: 'ja',
punten: '150',
}),
),
'Submitting',
);
// ValidIntake stays honest: neither the stale 'ja' nor its punten leak through.
expect(good.data.aanvullendeScholing).toBeUndefined();
expect(good.data.punten).toBeUndefined();
});
it('resolve maps Submitting to Submitted on a successful submit', () => {
const submitting = submit(answering(complete));
expect(resolve(submitting, ok(undefined)).tag).toBe('Submitted');
@@ -112,8 +112,12 @@ function validateStep(step: StepId, a: Answers, scholingThreshold: number): Resu
if (!u.ok) errors.uren = u.error;
if (lageUren(a, scholingThreshold) && !a.scholingGevolgd)
errors.scholingGevolgd = $localize`:@@validation.maakKeuze:Maak een keuze.`;
// Nascholingspunten are only asked (and required) when scholing was followed.
if (a.scholingGevolgd === 'ja') {
// Nascholingspunten are only asked (and required) when the question is actually
// visible (lageUren) AND scholing was followed — matching the template's
// `@if (scholingZichtbaar())`. Without the `lageUren` guard, answering 'ja' and then
// raising uren above the threshold left an error on a field the template no longer
// renders (WP-69 §6).
if (lageUren(a, scholingThreshold) && a.scholingGevolgd === 'ja') {
const p = parseUren(a.punten ?? '');
if (!p.ok) errors.punten = p.error;
}
@@ -142,14 +146,19 @@ function validateAll(a: Answers, scholingThreshold: number): Result<Errors, Vali
const werktBuitenland = a.buitenlandGewerkt === 'ja';
const buitenland = parseUren(a.buitenlandseUren ?? '');
// Punten are only collected when aanvullende scholing was gevolgd.
const punten = a.scholingGevolgd === 'ja' ? parseUren(a.punten ?? '') : undefined;
const aanvullendeScholing = lageUren(a, scholingThreshold)
? a.scholingGevolgd === 'ja'
: undefined;
// Punten are derived from aanvullendeScholing, NOT the raw scholingGevolgd answer (WP-69
// §6) — a stale 'ja' left over from when uren was low, after uren was raised above the
// threshold, must not leak a punten value into the parsed, submitted ValidIntake.
const punten = aanvullendeScholing === true ? parseUren(a.punten ?? '') : undefined;
return ok({
werktBuitenland,
land: werktBuitenland ? a.land : undefined,
buitenlandseUren: werktBuitenland && buitenland.ok ? buitenland.value : undefined,
uren: uren.value,
aanvullendeScholing: lageUren(a, scholingThreshold) ? a.scholingGevolgd === 'ja' : undefined,
aanvullendeScholing,
punten: punten?.ok ? punten.value : undefined,
});
}
@@ -387,7 +387,14 @@ export class IntakeWizardComponent {
const s = this.state();
if (s.tag !== 'Submitting') return;
this.profile.beginHerregistratie();
const r = await this.draftSync.submit({ uren: s.data.uren });
// WP-69: the scholing answer rides along so the server can re-validate it as the
// authority (IntakePolicy.RejectIncompleteScholing) — undefined members are dropped by
// JSON.stringify, so a wizard above the threshold sends neither field.
const r = await this.draftSync.submit({
uren: s.data.uren,
aanvullendeScholing: s.data.aanvullendeScholing,
scholingPunten: s.data.punten,
});
if (r.ok) {
this.dispatch({ tag: 'SubmitConfirmed' });
this.profile.confirmHerregistratie();