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:
@@ -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 } }));
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
} from '@shared/infrastructure/api-client';
|
||||
import { problemDetail } from '@shared/infrastructure/api-error';
|
||||
import { currentScenario } from '@shared/infrastructure/scenario';
|
||||
import { currentSubject } from '@shared/infrastructure/subject';
|
||||
import { environment } from '@shared/environments/environment';
|
||||
import { DocumentCategory } from './upload.machine';
|
||||
|
||||
@@ -144,6 +145,13 @@ export class UploadAdapter {
|
||||
});
|
||||
|
||||
xhr.open('POST', `${environment.apiBaseUrl}/api/v1/uploads`);
|
||||
// WP-74: this XHR bypasses `HttpClient`'s `subjectInterceptor` (the same reason
|
||||
// `letter-preview.adapter.ts` sets `X-Role` explicitly) — without `X-Subject` a
|
||||
// document always uploaded under `DocumentStore.DemoOwner` regardless of who was
|
||||
// actually logged in, so a submission attempted under any other BSN would find
|
||||
// its own required document "missing" (owned by someone else).
|
||||
const subject = currentSubject();
|
||||
if (subject) xhr.setRequestHeader('X-Subject', subject);
|
||||
xhr.send(form);
|
||||
return { done, cancel: () => ((aborted = true), xhr.abort()) };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user