CI / build (pull_request) Successful in 1m4s
CI / lint (pull_request) Successful in 1m23s
CI / unit (pull_request) Successful in 1m24s
CI / frontend (pull_request) Successful in 3m8s
CI / mutation (pull_request) Successful in 6m18s
CI / verify-stack (pull_request) Successful in 10m15s
The happy path visited the behandel portal only after the documents were supplied, so the row was already in the werkbak at page load — dropping the reload proved nothing. Now the behandelaar logs in first, asserts the row is NOT there yet, and only then does the citizen supply the documents that route it to Beoordelen. The row can therefore only reach that already-open, never-reloaded page by the werkbak refreshing itself. Verified both ways against a live stack: with the interval stubbed out the spec fails at "Goedkeuren <ref> … element(s) not found" after 30s; with it, the behandel nginx logs the poll that delivers the row. The werkbak page is foregrounded before the assertion — Chromium throttles timers in a hidden tab. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
172 lines
9.7 KiB
TypeScript
172 lines
9.7 KiB
TypeScript
import { expect, request, test } from '@playwright/test';
|
|
import { loginMedewerker } from './medewerker-login';
|
|
|
|
// Walking-skeleton happy path (S-08d + S-09 + S-09b + S-12 + S-10a + S-19b-2): a zorgprofessional
|
|
// logs in via mock DigiD and submits through the self-service portal → BFF → domain; the entry
|
|
// appears in the openbaar register as INGEDIEND; the citizen supplies the documents the process is
|
|
// waiting for (S-10a); a behandelaar then logs in to the behandel portal, finds the registration in
|
|
// the werkbak, and approves it (goedkeuren); the decision completes the Flowable Beoordelen task and
|
|
// flows via the ACL → Objecten → NRC → event-subscriber → projection, and the openbaar register
|
|
// shows INGESCHREVEN.
|
|
//
|
|
// Since ADR-0030 both public statuses come from the register in Objecten, not from ZGW zaak events:
|
|
// the ACL writes the record on submit (INGEDIEND) and upserts it on approval (INGESCHREVEN), so the
|
|
// INGEDIEND assertion below is itself proof of the re-sourced path.
|
|
test('DigiD submit → public INGEDIEND → documenten → behandelaar goedkeurt → public INGESCHREVEN', async ({
|
|
page,
|
|
context,
|
|
}) => {
|
|
// Visiting the guarded page redirects to the Keycloak (mock DigiD) login.
|
|
await page.goto('/');
|
|
|
|
// Keycloak's default login form (stable ids across themes). Its own DigiD user: the verify-* API
|
|
// checks submit as jan-burger (bsn 123456782) before the e2e runs on the shared stack, and
|
|
// resume-on-load (S-26) would otherwise restore one of those on login — so each self-service spec
|
|
// uses a dedicated citizen no other actor touches.
|
|
await page.locator('#username').fill('emma-burger');
|
|
await page.locator('#password').fill('test123');
|
|
await page.locator('#kc-login').click();
|
|
|
|
// Back on the portal, authenticated.
|
|
await expect(page.getByRole('heading', { name: /Zelfservice/i })).toBeVisible();
|
|
|
|
await page.getByRole('button', { name: /indienen/i }).click();
|
|
|
|
// The BFF accepted it and the page shows the confirmation with the reference.
|
|
const confirmation = page.getByText(/ontvangen/i);
|
|
await expect(confirmation).toBeVisible();
|
|
const reference = (await confirmation.textContent())?.match(/Referentie:\s*([0-9a-fA-F-]+)/)?.[1];
|
|
expect(reference, 'the confirmation shows a registration reference').toBeTruthy();
|
|
|
|
// The openbaar register (anonymous, its own origin) shows the submitted entry once the projection
|
|
// catches up. We check it on a SEPARATE page so the self-service tab keeps its (in-memory) submitted
|
|
// state — the "Documenten aanleveren" action below acts on that same session. The projection updates
|
|
// asynchronously (NRC → event-subscriber), so reload until *this* submission's row appears. We poll
|
|
// on the reference cell (not a generic INGEDIEND cell): the shared verify stack already holds
|
|
// INGEDIEND rows from earlier checks, so a status-only poll would short-circuit on a stale row.
|
|
const staff = await context.newPage();
|
|
await staff.goto('http://openbaar/');
|
|
await expect(staff.getByRole('heading', { name: /Openbaar BIG-register/i })).toBeVisible();
|
|
|
|
// #78: the reference shown in the public register must be the exact one the citizen saw on the
|
|
// submit confirmation — no mismatch between the two portals.
|
|
await expect
|
|
.poll(async () => {
|
|
await staff.reload();
|
|
return staff.getByRole('cell', { name: reference }).count();
|
|
}, { timeout: 30_000, intervals: [1_000, 2_000, 3_000, 5_000] })
|
|
.toBeGreaterThan(0);
|
|
await expect(staff.getByRole('row', { name: reference }).getByRole('cell', { name: 'INGEDIEND' }))
|
|
.toBeVisible();
|
|
|
|
// A behandelaar opens the behandel-portal werkbak and approves the registration (goedkeuren) — the
|
|
// S-12 flow that replaces the temporary admin endpoint. The staff tab switches to the medewerker
|
|
// realm (a different Keycloak realm than the citizen's digid session).
|
|
//
|
|
// The werkbak is opened BEFORE the citizen supplies the documents that route the registration to
|
|
// Beoordelen, so its row cannot be there at page load: the only thing that can deliver it to this
|
|
// already-open page is the werkbak refreshing itself (S-26/#162, ADR-0032). This spec used to
|
|
// `staff.reload()` in a poll loop here; the absence of that reload is the live-refresh assertion.
|
|
await staff.goto('http://behandel/');
|
|
// That realm enforces MFA (S-15c), so the behandelaar logs in with password + TOTP.
|
|
await loginMedewerker(staff, 'merel-behandelaar');
|
|
|
|
await expect(staff.getByRole('heading', { name: /Werkbak/i })).toBeVisible();
|
|
|
|
// Target the decide button by reference (not a generic "Goedkeuren"): the shared verify stack holds
|
|
// other open tasks, so a positional match could act on someone else's registration.
|
|
const goedkeuren = staff.getByRole('button', { name: `Goedkeuren ${reference}` });
|
|
await expect(goedkeuren, 'the registration is not awaiting beoordeling yet').toBeHidden();
|
|
|
|
// Provide the documents the registration is waiting for (S-10a), on the still-open self-service tab.
|
|
// The process parks at WachtOpDocumenten only after the zaak is opened; the INGEDIEND row above proves
|
|
// the zaak exists — so the OpenZaak worker has completed and the process is now at the wait — which is
|
|
// why we supply the documents here rather than right after submit, when the trigger would race the
|
|
// wait and no-op. (S-10b turns this into a real file upload; here it is the trigger that unblocks
|
|
// beoordeling.)
|
|
await page.bringToFront();
|
|
await page.setInputFiles('#diploma', {
|
|
name: 'diploma.pdf',
|
|
mimeType: 'application/pdf',
|
|
buffer: Buffer.from('%PDF-1.4 synthetic diploma\n'),
|
|
});
|
|
await page.getByRole('button', { name: /documenten aanleveren/i }).click();
|
|
await expect(page.getByText(/documenten zijn aangeleverd/i)).toBeVisible();
|
|
|
|
// Back to the werkbak — untouched since login, never reloaded. The row arrives on its own once the
|
|
// DMN routes the registration to Beoordelen. (Foregrounded so Chromium doesn't throttle the page's
|
|
// refresh timer as a hidden tab.)
|
|
await staff.bringToFront();
|
|
await expect(goedkeuren).toBeVisible({ timeout: 30_000 });
|
|
|
|
// Click and wait for the decide POST to finish (204) BEFORE leaving the page. `click()` only
|
|
// dispatches the request; navigating away immediately cancels it in flight (nginx logs a 499) and
|
|
// the decision never reaches the domain — so the registration would stay INGEDIEND.
|
|
const decided = staff.waitForResponse(
|
|
(r) =>
|
|
r.url().includes(`/behandel/registrations/${reference}/decide`) &&
|
|
r.request().method() === 'POST',
|
|
);
|
|
await goedkeuren.click();
|
|
expect((await decided).status()).toBe(204);
|
|
|
|
// The approval flows back to the projection; back on the openbaar register *our* row (matched by
|
|
// its reference) now shows INGESCHREVEN.
|
|
await staff.goto('http://openbaar/');
|
|
await expect
|
|
.poll(async () => {
|
|
await staff.reload();
|
|
return staff.getByRole('row', { name: reference }).getByRole('cell', { name: 'INGESCHREVEN' }).count();
|
|
}, { timeout: 30_000, intervals: [1_000, 2_000, 3_000, 5_000] })
|
|
.toBeGreaterThan(0);
|
|
|
|
// S-19a: the same approval also wrote the canonical register record to Objecten (ADR-0028).
|
|
// Asserted here rather than in verify-domain because this is the only check that drives a *real*
|
|
// approval — verify-domain completes the Beoordelen task straight through Flowable REST, which
|
|
// bypasses the domain `decide` path that calls the ACL.
|
|
const records = await registerRecordsFor(reference);
|
|
// Matched on OUR reference: the verify stack is shared and holds records from earlier checks.
|
|
expect(records, `expected exactly one RegisterRecord for ${reference}`).toHaveLength(1);
|
|
expect(records[0].status).toBe('INGESCHREVEN');
|
|
// The register is world-readable, so the record must carry nothing but the public-safe fields
|
|
// (ADR-0027) — Objecten's own schema validation enforces this, and this proves it end to end.
|
|
expect(Object.keys(records[0]).sort()).toEqual(['id', 'reference', 'status']);
|
|
});
|
|
|
|
const OBJECTEN = process.env.OBJECTEN_URL ?? 'http://objecten:8000';
|
|
const OBJECTTYPEN = process.env.OBJECTTYPEN_URL ?? 'http://objecttypen:8000';
|
|
const OBJECTEN_TOKEN = process.env.OBJECTEN_TOKEN ?? '1234567890abcdef1234567890abcdef12345678';
|
|
const OBJECTTYPEN_TOKEN = process.env.OBJECTTYPEN_TOKEN ?? '0123456789abcdef0123456789abcdef01234567';
|
|
|
|
/**
|
|
* The RegisterRecord objects Objecten holds for a registration reference.
|
|
*
|
|
* The objecttype is resolved by name rather than pinned: Objecttypen echoes the request Host into
|
|
* the objecttype `url`, and Objecten only accepts the one matching its configured api_root — so
|
|
* both must be reached by service name, exactly as the ACL reaches them (ADR-0028).
|
|
*/
|
|
async function registerRecordsFor(reference: string): Promise<Record<string, string>[]> {
|
|
const api = await request.newContext();
|
|
try {
|
|
const types = await api.get(`${OBJECTTYPEN}/api/v2/objecttypes`, {
|
|
headers: { Authorization: `Token ${OBJECTTYPEN_TOKEN}` },
|
|
});
|
|
expect(types.ok(), `Objecttypen returned ${types.status()}`).toBeTruthy();
|
|
const objecttype = ((await types.json()).results as { url: string; name: string }[]).find(
|
|
(o) => o.name === 'RegisterRecord',
|
|
);
|
|
if (!objecttype) throw new Error('the RegisterRecord objecttype is not registered in Objecttypen');
|
|
|
|
const objects = await api.get(`${OBJECTEN}/api/v2/objects`, {
|
|
headers: { Authorization: `Token ${OBJECTEN_TOKEN}`, 'Accept-Crs': 'EPSG:4326' },
|
|
params: { type: objecttype.url, data_attrs: `reference__exact__${reference}` },
|
|
});
|
|
expect(objects.ok(), `Objecten returned ${objects.status()}: ${await objects.text()}`).toBeTruthy();
|
|
return ((await objects.json()).results as { record: { data: Record<string, string> } }[]).map(
|
|
(o) => o.record.data,
|
|
);
|
|
} finally {
|
|
await api.dispose();
|
|
}
|
|
}
|