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:
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user