test(e2e): isolate runs and identities without a new backend endpoint (WP-74)

The three specs shared one mutable backend and said so in their own
comments ("Restart the backend between CI runs"). WP-70 recorded the fix as
a dev-only seed endpoint; it isn't needed. The DB path already routes
through IConfiguration, so playwright.config's webServer hands the backend a
throwaway SQLite file per invocation — the same trick TestWebApplicationFactory
already uses, with zero backend change. And StubIdentityProvider already
honoured X-Subject; the only gap was that nothing sent it. That matters
because the backend has no IsDevelopment() gate anywhere, so a seed endpoint
would have had to invent the codebase's first environment gate.

subjectInterceptor mirrors the existing roleInterceptor and is wired into the
same isDevMode()-only list. Interceptors alone were not enough: the raw XHR
upload and the hand-written letter-preview fetch bypass Angular's chain (as
CLAUDE.md documents), so both now stamp X-Subject explicitly — without that,
every uploaded document still landed under DemoOwner.

reuseExistingServer stays on: flipping it would break local runs for anyone
already serving the docker stack. Each run gets a unique DB filename and
global-setup sweeps only prior runs' leftovers — deleting a fixed path
mid-run risks SQLite silently recreating an empty, unmigrated file under
fullyParallel.

Verified: e2e passes twice back-to-back with no backend restart, and
X-Subject was observed on a real request, not merely wired.

brief-v2.spec.ts keeps the shared identity for now — see the KNOWN GAP note;
a backend staleness bug makes /brief/preview return a sent letter with the
draft watermark for any non-DemoOwner BSN. actors.ts reserves the actor for
whoever fixes it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
eho
2026-08-19 16:32:07 +02:00
co-authored by Claude Sonnet 5
parent 6bc00a917c
commit 42f7bd651d
12 changed files with 319 additions and 21 deletions
@@ -0,0 +1,60 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { subjectInterceptor } from './subject.interceptor';
// currentSubject() reads window.location.search; set it via the real URL rather than
// vi.mock (the Angular unit-test system forbids mocking relative imports).
afterEach(() => {
window.history.replaceState({}, '', '/');
sessionStorage.clear(); // currentSubject() persists across calls; don't leak across tests
});
// Minimal stand-in for HttpRequest — the interceptor only reads `url` and calls
// `clone({ setHeaders })`. Avoids importing @angular/common/http (its XHR chunk needs
// the JIT compiler under vitest).
function fakeReq(url: string) {
const make = (headers: Map<string, string>) => ({
url,
headers,
clone(opts: { setHeaders: Record<string, string> }) {
const next = new Map(headers);
for (const [k, v] of Object.entries(opts.setHeaders)) next.set(k, v);
return make(next);
},
});
return make(new Map());
}
/** Run the interceptor and return the request it forwarded to `next`. */
function forward(url: string) {
let seen!: ReturnType<typeof fakeReq>;
const next = (r: ReturnType<typeof fakeReq>) => {
seen = r;
return undefined;
};
// Cast: the fake matches the shape the interceptor actually touches.
(subjectInterceptor as unknown as (req: unknown, next: unknown) => unknown)(fakeReq(url), next);
return seen;
}
describe('subjectInterceptor', () => {
it('stamps X-Subject on an /api/v1/ request once ?subject= has been seen', () => {
window.history.replaceState({}, '', '/?subject=111222333');
expect(forward('/api/v1/registratie/concept').headers.get('X-Subject')).toBe('111222333');
});
it('keeps stamping later requests on the same tab after the query param is gone (WP-33-style stickiness)', () => {
window.history.replaceState({}, '', '/?subject=111222333');
forward('/api/v1/me');
window.history.replaceState({}, '', '/dashboard'); // navigation drops the query param
expect(forward('/api/v1/me').headers.get('X-Subject')).toBe('111222333');
});
it('leaves a non-API request untouched even when a subject is known', () => {
window.history.replaceState({}, '', '/?subject=111222333');
expect(forward('/assets/logo.svg').headers.has('X-Subject')).toBe(false);
});
it('sends no header at all when no subject has ever been seen', () => {
expect(forward('/api/v1/me').headers.has('X-Subject')).toBe(false);
});
});
@@ -0,0 +1,35 @@
import { HttpInterceptorFn } from '@angular/common/http';
import { currentSubject } from './subject';
/**
* Dev-only (WP-74): stamps every API request with `X-Subject`, the BSN
* `StubIdentityProvider` (backend) resolves the caller's `ZorgverlenerCaller` from —
* every owner-keyed store (`ApplicationStore`, `DocumentStore`, `BriefStore`) reads
* off that resolved identity, so this is the seam that lets e2e specs log in as
* distinct citizens and mutate independent rows instead of all colliding on
* `DocumentStore.DemoOwner`. Scoped like `medewerker.interceptor.ts` (every
* `/api/v1/*` request, not an allow-list like `roleInterceptor`) — the identity
* middleware resolves a `CallerIdentity` for every request, not just some endpoints.
*
* **BSN source — a deliberate compromise, read before changing:** the "obvious"
* source would be the authenticated `Session.bsn` held by each app's own
* `SessionStore`, but `libs/shared` may not depend on an app-local `auth` context
* (the import-direction rule), and the one sanctioned cross-context seam —
* `SessionPort` (`@shared/application/session.port`) — deliberately exposes only
* `{ naam }`: `SessionStore`'s G1 comment is explicit that the BSN (a GDPR
* special-category identifier) is never persisted or otherwise handed outward, by
* design. Extending that port (or injecting `SessionStore` itself) would undo that
* boundary just to serve a dev/e2e convenience. So instead this reuses
* `role.interceptor.ts`'s own trick (see `subject.ts`, mirroring `role.ts`'s
* `currentRole()`): a `?subject=` seen in the URL is remembered in sessionStorage
* for the tab, and every later request reuses it. `e2e/support/actors.ts`'s
* `loginAs` sets it once per spec by navigating to `/login?subject=<bsn>` before
* filling in the login form. Outside e2e nothing ever sets `?subject=`, so no
* header is sent and the backend falls back to `DocumentStore.DemoOwner` exactly as
* before this WP.
*/
export const subjectInterceptor: HttpInterceptorFn = (req, next) => {
const subject = currentSubject();
if (!subject || !req.url.includes('/api/v1/')) return next(req);
return next(req.clone({ setHeaders: { 'X-Subject': subject } }));
};
+30
View File
@@ -0,0 +1,30 @@
/**
* Dev-only role stand-in's sibling (the reading MECHANISM for `X-Subject`; see
* `role.ts`'s own doc comment for the twin `X-Role` mechanism this mirrors). This
* POC has no real DigiD identity — `Session.bsn` lives only in each app's own
* in-memory `SessionStore` and is deliberately never persisted (see that store's G1
* comment) — so `subject.interceptor.ts` can't reach it without a layering
* violation (`libs/shared` may not depend on an app-local `auth` context). Instead a
* `?subject=<bsn>` query param, seen once on any navigation, is remembered for the
* tab in sessionStorage — the exact `?role=` trick `role.ts` already uses (WP-33).
*
* Two consumers read this, both dev/e2e-only: `subject.interceptor.ts` (every
* `HttpClient` request) and `letter-preview.adapter.ts` (`/brief/preview`'s
* hand-written `fetch`, which bypasses every `HttpInterceptorFn` — the same reason
* that adapter already sets `X-Role` explicitly via `currentRole()`).
*
* `undefined` (not a default BSN) when nothing has ever set `?subject=`: unlike
* `currentRole()` (a closed enum with a sensible default), there is no "default
* subject" to fall back to here — omitting the header entirely lets the backend's
* own default (`DocumentStore.DemoOwner`) apply, exactly as if this WP didn't exist.
*/
const STORAGE_KEY = 'dev-subject';
export function currentSubject(): string | undefined {
const fromUrl = new URLSearchParams(window.location.search).get('subject');
if (fromUrl) {
sessionStorage.setItem(STORAGE_KEY, fromUrl);
return fromUrl;
}
return sessionStorage.getItem(STORAGE_KEY) ?? undefined;
}