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