fix(brief): keep the dev hatches out of production builds (RB-11)
BIO-012: roleInterceptor/subjectInterceptor are correctly registered only under isDevMode(), but three hand-written fetch adapters (reveal-bignummer, letter-preview, org-template's proefbrief) bypass HttpClient and set X-Role/X-Subject themselves with no guard. The readers underneath, role.ts and subject.ts, were ungated too: they read ?role=/?subject= and wrote it into sessionStorage on any navigation, in any build -- for ?subject= that value is a BSN, which is exactly what SessionStore's G1 comment promises never happens. Gate both layers: currentRole()/currentSubject() return their safe default immediately outside isDevMode() (no query-param read, no sessionStorage write), and the three adapters additionally wrap their headers in isDevMode() so a production request carries neither header at all, matching what an HttpClient request already does once the interceptors aren't registered. TE-002: reveal-bignummer's response-shape validation was a "Trust boundary" a spec could only reach by stubbing globalThis.fetch. Exported it as parseRevealed(body), matching the other 30 parse* boundaries in the repo. Same treatment for letter-preview's errorMessage and org-template's proefbrief error mapping (extracted from an inline try/catch into a named, exported function first, since it wasn't already separate). BIO-006(a): reveal-bignummer sent X-Step-Up: 'true' unconditionally, so the backend's step-up precondition constrained nothing. reveal() now takes a stepUp flag; BriefStore.revealBigNummer() -- reachable only after the UI's confirm() gesture -- is the one that supplies it, so the literal no longer lives in the transport adapter. BIO-006(b): documented in roles-and-access.md that drafter is also the backend's fallback identity (StubIdentityProvider's catch-all arm), not just the dev switcher's initial choice -- so the least-privilege consequence of it also being the only role that may reveal a BSN is visible. Doc correction, same diff: roles-and-access.md's "wired only under isDevMode()" claim was false for the three hand-written fetch paths; it now says where the gate lives (interceptor registration and the reader functions) so it doesn't go stale the same way again. CLAUDE.md's dev-only claims needed no correction -- they already noted these three calls bypass the interceptor. Every fix has a test confirmed red by temporarily reverting the source change and rerunning the suite before restoring it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -233,9 +233,12 @@ export class BriefStore implements PendingSave {
|
|||||||
/** Reveal the masked case BIG-nummer (PRD-0002 §5c). Server re-checks the capability
|
/** Reveal the masked case BIG-nummer (PRD-0002 §5c). Server re-checks the capability
|
||||||
+ step-up and audits the attempt; on success we swap the masked value in the
|
+ step-up and audits the attempt; on success we swap the masked value in the
|
||||||
already-loaded caseContext (a field update, not a reload). The step-up gesture
|
already-loaded caseContext (a field update, not a reload). The step-up gesture
|
||||||
itself is the UI's concern — this command just runs the audited server call. */
|
itself is the UI's concern (`behandel-scherm.component.ts`'s `onReveal()` confirm)
|
||||||
|
— this command is only reachable once that gesture has happened, so it is the one
|
||||||
|
that tells the adapter to send `X-Step-Up` (BIO-006a: the adapter itself no longer
|
||||||
|
hardcodes the header). */
|
||||||
async revealBigNummer() {
|
async revealBigNummer() {
|
||||||
const r = await this.revealAdapter.reveal();
|
const r = await this.revealAdapter.reveal(true);
|
||||||
if (!r.ok) {
|
if (!r.ok) {
|
||||||
this.actionState.set({ tag: 'Failed', error: r.error });
|
this.actionState.set({ tag: 'Failed', error: r.error });
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||||
|
import { errorMessage, PREVIEW_FAILED, LetterPreviewAdapter } from './letter-preview.adapter';
|
||||||
|
|
||||||
|
// Minimal Response stand-in — errorMessage only calls `.json()`. Avoids stubbing
|
||||||
|
// globalThis.fetch to reach this trust boundary (TE-002).
|
||||||
|
const fakeResponse = (body: unknown): Response =>
|
||||||
|
({ json: () => Promise.resolve(body) }) as unknown as Response;
|
||||||
|
|
||||||
|
describe('errorMessage (TE-002 trust boundary)', () => {
|
||||||
|
it('surfaces the ProblemDetails detail when present', async () => {
|
||||||
|
expect(await errorMessage(fakeResponse({ detail: 'Geen toegang.', status: 403 }))).toBe(
|
||||||
|
'Geen toegang.',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to PREVIEW_FAILED when the body has no detail', async () => {
|
||||||
|
expect(await errorMessage(fakeResponse({ status: 500 }))).toBe(PREVIEW_FAILED);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to PREVIEW_FAILED when the body is not JSON', async () => {
|
||||||
|
const res = { json: () => Promise.reject(new Error('not json')) } as unknown as Response;
|
||||||
|
expect(await errorMessage(res)).toBe(PREVIEW_FAILED);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// isDevMode() reads the `ngDevMode` global the Angular CLI defines away in a
|
||||||
|
// production build. There is no ambient type for it in app code, so this is
|
||||||
|
// accessed through an untyped bag rather than a `declare const`.
|
||||||
|
const globals = globalThis as Record<string, unknown>;
|
||||||
|
const originalNgDevMode = globals['ngDevMode'];
|
||||||
|
const setDevMode = (on: boolean) => {
|
||||||
|
globals['ngDevMode'] = on;
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('LetterPreviewAdapter.preview (BIO-012)', () => {
|
||||||
|
const okResponse = () =>
|
||||||
|
({ ok: true, blob: () => Promise.resolve(new Blob()) }) as unknown as Response;
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
globals['ngDevMode'] = originalNgDevMode;
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
history.pushState({}, '', '/');
|
||||||
|
sessionStorage.clear();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sends no X-Role/X-Subject headers outside isDevMode()', async () => {
|
||||||
|
setDevMode(false);
|
||||||
|
history.pushState({}, '', '/?subject=111222333');
|
||||||
|
const fetchSpy = vi.fn().mockResolvedValue(okResponse());
|
||||||
|
vi.stubGlobal('fetch', fetchSpy);
|
||||||
|
|
||||||
|
await new LetterPreviewAdapter().preview();
|
||||||
|
|
||||||
|
expect(fetchSpy.mock.calls[0][1].headers).toEqual({});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sends X-Role (and X-Subject when known) under isDevMode()', async () => {
|
||||||
|
setDevMode(true);
|
||||||
|
history.pushState({}, '', '/?subject=111222333');
|
||||||
|
const fetchSpy = vi.fn().mockResolvedValue(okResponse());
|
||||||
|
vi.stubGlobal('fetch', fetchSpy);
|
||||||
|
|
||||||
|
await new LetterPreviewAdapter().preview();
|
||||||
|
|
||||||
|
const headers = fetchSpy.mock.calls[0][1].headers as Record<string, string>;
|
||||||
|
expect(headers['X-Role']).toBeDefined();
|
||||||
|
expect(headers['X-Subject']).toBe('111222333');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Injectable } from '@angular/core';
|
import { Injectable, isDevMode } from '@angular/core';
|
||||||
import { Result, ok, err } from '@shared/kernel/fp';
|
import { Result, ok, err } from '@shared/kernel/fp';
|
||||||
import { currentRole } from '@shared/infrastructure/role';
|
import { currentRole } from '@shared/infrastructure/role';
|
||||||
import { currentSubject } from '@shared/infrastructure/subject';
|
import { currentSubject } from '@shared/infrastructure/subject';
|
||||||
@@ -15,7 +15,10 @@ export const PREVIEW_FAILED = $localize`:@@brief.preview.failed:De voorvertoning
|
|||||||
* hand-written fetch, not the `ApiClient`. That also means it bypasses `HttpClient`'s
|
* hand-written fetch, not the `ApiClient`. That also means it bypasses `HttpClient`'s
|
||||||
* `roleInterceptor` AND `subjectInterceptor`, so both `X-Role` and `X-Subject` are set
|
* `roleInterceptor` AND `subjectInterceptor`, so both `X-Role` and `X-Subject` are set
|
||||||
* here explicitly (WP-74 — without `X-Subject` this always previewed
|
* here explicitly (WP-74 — without `X-Subject` this always previewed
|
||||||
* `DocumentStore.DemoOwner`'s letter regardless of who was actually logged in).
|
* `DocumentStore.DemoOwner`'s letter regardless of who was actually logged in). Both are
|
||||||
|
* dev-only identity stand-ins (`role.ts`/`subject.ts`) and are sent only under
|
||||||
|
* `isDevMode()`, mirroring how the interceptors themselves are only registered in dev
|
||||||
|
* (`app.config.ts`) — a production build sends neither header from this call (BIO-012).
|
||||||
*
|
*
|
||||||
* `cache: 'no-store'` (WP-74): the endpoint has no `Cache-Control`, only a CORS-driven
|
* `cache: 'no-store'` (WP-74): the endpoint has no `Cache-Control`, only a CORS-driven
|
||||||
* `Vary: Origin`, and its content changes at the SAME URL as the letter moves
|
* `Vary: Origin`, and its content changes at the SAME URL as the letter moves
|
||||||
@@ -43,7 +46,9 @@ export class LetterPreviewAdapter {
|
|||||||
const subject = currentSubject();
|
const subject = currentSubject();
|
||||||
res = await fetch(`${environment.apiBaseUrl}/api/v1/brief/preview`, {
|
res = await fetch(`${environment.apiBaseUrl}/api/v1/brief/preview`, {
|
||||||
cache: 'no-store',
|
cache: 'no-store',
|
||||||
headers: { 'X-Role': currentRole(), ...(subject ? { 'X-Subject': subject } : {}) },
|
headers: isDevMode()
|
||||||
|
? { 'X-Role': currentRole(), ...(subject ? { 'X-Subject': subject } : {}) }
|
||||||
|
: {},
|
||||||
});
|
});
|
||||||
} catch {
|
} catch {
|
||||||
return err(PREVIEW_FAILED);
|
return err(PREVIEW_FAILED);
|
||||||
@@ -53,7 +58,9 @@ export class LetterPreviewAdapter {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function errorMessage(res: Response): Promise<string> {
|
/** Trust boundary (TE-002): maps a non-OK response to a message. Exported so a spec
|
||||||
|
can call it directly instead of stubbing `globalThis.fetch`. */
|
||||||
|
export async function errorMessage(res: Response): Promise<string> {
|
||||||
try {
|
try {
|
||||||
return problemDetail(await res.json(), PREVIEW_FAILED);
|
return problemDetail(await res.json(), PREVIEW_FAILED);
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
import { describe, it, expect } from 'vitest';
|
import { describe, it, expect } from 'vitest';
|
||||||
import { OrgTemplateAdminViewDto, OrgTemplateDto } from '@shared/infrastructure/api-client';
|
import { OrgTemplateAdminViewDto, OrgTemplateDto } from '@shared/infrastructure/api-client';
|
||||||
import { parseOrgTemplateAdminView } from './org-template.adapter';
|
import {
|
||||||
|
parseOrgTemplateAdminView,
|
||||||
|
proefbriefErrorMessage,
|
||||||
|
PROEFBRIEF_FAILED,
|
||||||
|
} from './org-template.adapter';
|
||||||
|
|
||||||
const draft: OrgTemplateDto = {
|
const draft: OrgTemplateDto = {
|
||||||
subOrgId: 'cibg-registers',
|
subOrgId: 'cibg-registers',
|
||||||
@@ -54,3 +58,25 @@ describe('parseOrgTemplateAdminView', () => {
|
|||||||
expect(r.ok).toBe(false);
|
expect(r.ok).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Minimal Response stand-in — proefbriefErrorMessage only calls `.json()`. Avoids
|
||||||
|
// stubbing globalThis.fetch to reach this trust boundary (TE-002).
|
||||||
|
const fakeResponse = (body: unknown): Response =>
|
||||||
|
({ json: () => Promise.resolve(body) }) as unknown as Response;
|
||||||
|
|
||||||
|
describe('proefbriefErrorMessage (TE-002 trust boundary)', () => {
|
||||||
|
it('surfaces the ProblemDetails detail when present', async () => {
|
||||||
|
expect(
|
||||||
|
await proefbriefErrorMessage(fakeResponse({ detail: 'Niet gevonden.', status: 404 })),
|
||||||
|
).toBe('Niet gevonden.');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to PROEFBRIEF_FAILED when the body has no detail', async () => {
|
||||||
|
expect(await proefbriefErrorMessage(fakeResponse({ status: 500 }))).toBe(PROEFBRIEF_FAILED);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to PROEFBRIEF_FAILED when the body is not JSON', async () => {
|
||||||
|
const res = { json: () => Promise.reject(new Error('not json')) } as unknown as Response;
|
||||||
|
expect(await proefbriefErrorMessage(res)).toBe(PROEFBRIEF_FAILED);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Injectable, inject } from '@angular/core';
|
import { Injectable, inject, isDevMode } from '@angular/core';
|
||||||
import { Result, ok, err } from '@shared/kernel/fp';
|
import { Result, ok, err } from '@shared/kernel/fp';
|
||||||
import { runSubmit } from '@shared/application/submit';
|
import { runSubmit } from '@shared/application/submit';
|
||||||
import { currentRole } from '@shared/infrastructure/role';
|
import { currentRole } from '@shared/infrastructure/role';
|
||||||
@@ -26,10 +26,13 @@ import { parseOrgTemplate } from '@brief/infrastructure/brief.adapter';
|
|||||||
* rollback go through the generated client (X-Role added by `roleInterceptor`);
|
* rollback go through the generated client (X-Role added by `roleInterceptor`);
|
||||||
* `parse*` narrows the untrusted wire shape. The proefbrief is `text/html` and
|
* `parse*` narrows the untrusted wire shape. The proefbrief is `text/html` and
|
||||||
* `ExcludeFromDescription`'d — a hand-written fetch, same seam as `letter-preview.adapter`.
|
* `ExcludeFromDescription`'d — a hand-written fetch, same seam as `letter-preview.adapter`.
|
||||||
|
* `X-Role` there is a dev-only identity stand-in (`role.ts`) and is sent only under
|
||||||
|
* `isDevMode()`, mirroring `roleInterceptor`'s own dev-only registration — a production
|
||||||
|
* build never sends it from this hand-written call either (BIO-012).
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const FAILED = $localize`:@@orgTemplate.action.failed:De actie is niet gelukt. Probeer het later opnieuw.`;
|
const FAILED = $localize`:@@orgTemplate.action.failed:De actie is niet gelukt. Probeer het later opnieuw.`;
|
||||||
const PROEFBRIEF_FAILED = $localize`:@@orgTemplate.proefbrief.failed:De proefbrief kon niet worden geopend.`;
|
export const PROEFBRIEF_FAILED = $localize`:@@orgTemplate.proefbrief.failed:De proefbrief kon niet worden geopend.`;
|
||||||
|
|
||||||
@Injectable({ providedIn: 'root' })
|
@Injectable({ providedIn: 'root' })
|
||||||
export class OrgTemplateAdapter {
|
export class OrgTemplateAdapter {
|
||||||
@@ -76,22 +79,26 @@ export class OrgTemplateAdapter {
|
|||||||
try {
|
try {
|
||||||
res = await fetch(
|
res = await fetch(
|
||||||
`${environment.apiBaseUrl}/api/v1/admin/org-template/${encodeURIComponent(subOrgId)}/preview`,
|
`${environment.apiBaseUrl}/api/v1/admin/org-template/${encodeURIComponent(subOrgId)}/preview`,
|
||||||
{ headers: { 'X-Role': currentRole() } },
|
{ headers: isDevMode() ? { 'X-Role': currentRole() } : {} },
|
||||||
);
|
);
|
||||||
} catch {
|
} catch {
|
||||||
return err(PROEFBRIEF_FAILED);
|
return err(PROEFBRIEF_FAILED);
|
||||||
}
|
}
|
||||||
if (!res.ok) {
|
if (!res.ok) return err(await proefbriefErrorMessage(res));
|
||||||
try {
|
|
||||||
return err(problemDetail(await res.json(), PROEFBRIEF_FAILED));
|
|
||||||
} catch {
|
|
||||||
return err(PROEFBRIEF_FAILED);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return ok(await res.blob());
|
return ok(await res.blob());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Trust boundary (TE-002): maps a non-OK proefbrief response to a message. Exported
|
||||||
|
so a spec can call it directly instead of stubbing `globalThis.fetch`. */
|
||||||
|
export async function proefbriefErrorMessage(res: Response): Promise<string> {
|
||||||
|
try {
|
||||||
|
return problemDetail(await res.json(), PROEFBRIEF_FAILED);
|
||||||
|
} catch {
|
||||||
|
return PROEFBRIEF_FAILED;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// --- parse: wire → domain, validating at the boundary ---
|
// --- parse: wire → domain, validating at the boundary ---
|
||||||
|
|
||||||
function parseSubOrg(dto: SubOrgSummaryDto): Result<string, SubOrgSummary> {
|
function parseSubOrg(dto: SubOrgSummaryDto): Result<string, SubOrgSummary> {
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||||
|
import { parseRevealed, REVEAL_FAILED, RevealBigNummerAdapter } from './reveal-bignummer.adapter';
|
||||||
|
|
||||||
|
describe('parseRevealed (TE-002 trust boundary)', () => {
|
||||||
|
it('accepts a well-formed body', () => {
|
||||||
|
const r = parseRevealed({ bigNummer: '12345678' });
|
||||||
|
expect(r.ok).toBe(true);
|
||||||
|
if (r.ok) expect(r.value).toBe('12345678');
|
||||||
|
});
|
||||||
|
|
||||||
|
// The finding's own named case: a numeric bigNummer must be rejected, not
|
||||||
|
// coerced — this is a PII reveal, not a display formatter.
|
||||||
|
it('rejects a bigNummer sent as a number', () => {
|
||||||
|
const r = parseRevealed({ bigNummer: 42 });
|
||||||
|
expect(r).toEqual({ ok: false, error: REVEAL_FAILED });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a missing bigNummer field', () => {
|
||||||
|
expect(parseRevealed({}).ok).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects null and non-object bodies', () => {
|
||||||
|
expect(parseRevealed(null).ok).toBe(false);
|
||||||
|
expect(parseRevealed(undefined).ok).toBe(false);
|
||||||
|
expect(parseRevealed('12345678').ok).toBe(false);
|
||||||
|
expect(parseRevealed(42).ok).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// isDevMode() reads the `ngDevMode` global the Angular CLI defines away in a
|
||||||
|
// production build. There is no ambient type for it in app code, so this is
|
||||||
|
// accessed through an untyped bag rather than a `declare const`.
|
||||||
|
const globals = globalThis as Record<string, unknown>;
|
||||||
|
const originalNgDevMode = globals['ngDevMode'];
|
||||||
|
const setDevMode = (on: boolean) => {
|
||||||
|
globals['ngDevMode'] = on;
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('RevealBigNummerAdapter.reveal (BIO-006a + BIO-012)', () => {
|
||||||
|
const okResponse = () =>
|
||||||
|
({ ok: true, json: () => Promise.resolve({ bigNummer: '12345678' }) }) as unknown as Response;
|
||||||
|
|
||||||
|
beforeEach(() => setDevMode(true));
|
||||||
|
afterEach(() => {
|
||||||
|
globals['ngDevMode'] = originalNgDevMode;
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sends X-Step-Up only when the caller passes stepUp: true', async () => {
|
||||||
|
const fetchSpy = vi.fn().mockResolvedValue(okResponse());
|
||||||
|
vi.stubGlobal('fetch', fetchSpy);
|
||||||
|
|
||||||
|
await new RevealBigNummerAdapter().reveal(false);
|
||||||
|
const headersWithoutStepUp = fetchSpy.mock.calls[0][1].headers as Record<string, string>;
|
||||||
|
expect(headersWithoutStepUp['X-Step-Up']).toBeUndefined();
|
||||||
|
|
||||||
|
await new RevealBigNummerAdapter().reveal(true);
|
||||||
|
const headersWithStepUp = fetchSpy.mock.calls[1][1].headers as Record<string, string>;
|
||||||
|
expect(headersWithStepUp['X-Step-Up']).toBe('true');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sends X-Role only under isDevMode()', async () => {
|
||||||
|
const fetchSpy = vi.fn().mockResolvedValue(okResponse());
|
||||||
|
vi.stubGlobal('fetch', fetchSpy);
|
||||||
|
|
||||||
|
setDevMode(false);
|
||||||
|
await new RevealBigNummerAdapter().reveal(true);
|
||||||
|
const prodHeaders = fetchSpy.mock.calls[0][1].headers as Record<string, string>;
|
||||||
|
expect(prodHeaders['X-Role']).toBeUndefined();
|
||||||
|
expect(prodHeaders['X-Step-Up']).toBe('true'); // step-up is not a dev-only hatch
|
||||||
|
|
||||||
|
setDevMode(true);
|
||||||
|
await new RevealBigNummerAdapter().reveal(true);
|
||||||
|
const devHeaders = fetchSpy.mock.calls[1][1].headers as Record<string, string>;
|
||||||
|
expect(devHeaders['X-Role']).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,47 +1,62 @@
|
|||||||
import { Injectable } from '@angular/core';
|
import { Injectable, isDevMode } from '@angular/core';
|
||||||
import { Result, ok, err } from '@shared/kernel/fp';
|
import { Result, ok, err } from '@shared/kernel/fp';
|
||||||
import { currentRole } from '@shared/infrastructure/role';
|
import { currentRole } from '@shared/infrastructure/role';
|
||||||
import { problemDetail } from '@shared/infrastructure/api-error';
|
import { problemDetail } from '@shared/infrastructure/api-error';
|
||||||
import { environment } from '@shared/environments/environment';
|
import { environment } from '@shared/environments/environment';
|
||||||
|
|
||||||
const REVEAL_FAILED = $localize`:@@brief.reveal.failed:Het BIG-nummer kon niet worden getoond.`;
|
/** Exported so specs can assert against the same message id instead of retyping the
|
||||||
|
Dutch sentence (matches `letter-preview.adapter.ts`'s `PREVIEW_FAILED`). */
|
||||||
|
export const REVEAL_FAILED = $localize`:@@brief.reveal.failed:Het BIG-nummer kon niet worden getoond.`;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Field-level PII reveal (PRD-0002 §5c). The case screen ships the BIG-nummer masked;
|
* Field-level PII reveal (PRD-0002 §5c). The case screen ships the BIG-nummer masked;
|
||||||
* this unmasks it, gated server-side by the reveal capability AND a step-up. The
|
* this unmasks it, gated server-side by the reveal capability AND a step-up. The
|
||||||
* step-up is stubbed as the `X-Step-Up` header — the caller sends it only after the
|
* step-up is stubbed as the `X-Step-Up` header, sent only when the caller passes
|
||||||
* user's confirm gesture, so a plain call (or a role without the capability) 403s.
|
* `stepUp: true` — `BriefStore.revealBigNummer()` is the only caller and it is only
|
||||||
|
* ever reachable after `behandel-scherm.component.ts`'s `onReveal()` confirm gesture,
|
||||||
|
* so the header now reflects that gesture instead of being a constant baked into this
|
||||||
|
* adapter (BIO-006a — a call that skips confirmation sends no step-up at all).
|
||||||
*
|
*
|
||||||
* Hand-written fetch (not the `ApiClient`) because the call needs a per-request header;
|
* Hand-written fetch (not the `ApiClient`) because the call needs a per-request header;
|
||||||
* `.ExcludeFromDescription()` on the endpoint keeps the generated client JSON-only, the
|
* `.ExcludeFromDescription()` on the endpoint keeps the generated client JSON-only, the
|
||||||
* same seam as `/brief/preview` and uploads — which also means `X-Role` is set here.
|
* same seam as `/brief/preview` and uploads. `X-Role` is a dev-only identity stand-in
|
||||||
|
* (see `role.ts`) and is therefore only sent under `isDevMode()`, mirroring the
|
||||||
|
* `roleInterceptor` registration in `app.config.ts` — a production build never sends it
|
||||||
|
* from this hand-written call either (BIO-012).
|
||||||
*/
|
*/
|
||||||
@Injectable({ providedIn: 'root' })
|
@Injectable({ providedIn: 'root' })
|
||||||
export class RevealBigNummerAdapter {
|
export class RevealBigNummerAdapter {
|
||||||
async reveal(): Promise<Result<string, string>> {
|
async reveal(stepUp: boolean): Promise<Result<string, string>> {
|
||||||
let res: Response;
|
let res: Response;
|
||||||
try {
|
try {
|
||||||
res = await fetch(`${environment.apiBaseUrl}/api/v1/brief/reveal-bignummer`, {
|
res = await fetch(`${environment.apiBaseUrl}/api/v1/brief/reveal-bignummer`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'X-Role': currentRole(), 'X-Step-Up': 'true' },
|
headers: {
|
||||||
|
...(isDevMode() ? { 'X-Role': currentRole() } : {}),
|
||||||
|
...(stepUp ? { 'X-Step-Up': 'true' } : {}),
|
||||||
|
},
|
||||||
});
|
});
|
||||||
} catch {
|
} catch {
|
||||||
return err(REVEAL_FAILED);
|
return err(REVEAL_FAILED);
|
||||||
}
|
}
|
||||||
if (!res.ok) return err(await errorMessage(res));
|
if (!res.ok) return err(await errorMessage(res));
|
||||||
const body: unknown = await res.json().catch(() => null);
|
return parseRevealed(await res.json().catch(() => null));
|
||||||
// Trust boundary: validate the shape before handing back a plain string.
|
|
||||||
if (
|
|
||||||
typeof body === 'object' &&
|
|
||||||
body !== null &&
|
|
||||||
typeof (body as { bigNummer?: unknown }).bigNummer === 'string'
|
|
||||||
) {
|
|
||||||
return ok((body as { bigNummer: string }).bigNummer);
|
|
||||||
}
|
|
||||||
return err(REVEAL_FAILED);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Trust boundary: validate the untrusted response shape before handing back a plain
|
||||||
|
string (TE-002) — exported so a spec can call it without stubbing `globalThis.fetch`. */
|
||||||
|
export function parseRevealed(body: unknown): Result<string, string> {
|
||||||
|
if (
|
||||||
|
typeof body === 'object' &&
|
||||||
|
body !== null &&
|
||||||
|
typeof (body as { bigNummer?: unknown }).bigNummer === 'string'
|
||||||
|
) {
|
||||||
|
return ok((body as { bigNummer: string }).bigNummer);
|
||||||
|
}
|
||||||
|
return err(REVEAL_FAILED);
|
||||||
|
}
|
||||||
|
|
||||||
async function errorMessage(res: Response): Promise<string> {
|
async function errorMessage(res: Response): Promise<string> {
|
||||||
try {
|
try {
|
||||||
return problemDetail(await res.json(), REVEAL_FAILED);
|
return problemDetail(await res.json(), REVEAL_FAILED);
|
||||||
|
|||||||
@@ -0,0 +1,179 @@
|
|||||||
|
# RB-11 — dev hatches out of prod, trust boundaries exported, doc corrected
|
||||||
|
|
||||||
|
Status: **implemented** · 2026-08-27 · Source findings: `07-bio2-compliance.md` BIO-012,
|
||||||
|
TE-002, BIO-006(a)+(b) · `99-backlog.md` RB-11
|
||||||
|
|
||||||
|
## What was wrong
|
||||||
|
|
||||||
|
**BIO-012 — the dev hatches were not actually dev-only.** The `roleInterceptor` /
|
||||||
|
`subjectInterceptor` chain is correctly registered only under `isDevMode()`
|
||||||
|
(`app.config.ts`), but three adapters bypass `HttpClient` entirely and set headers
|
||||||
|
themselves with no guard at all:
|
||||||
|
|
||||||
|
- `reveal-bignummer.adapter.ts:26` — `'X-Role': currentRole(), 'X-Step-Up': 'true'`
|
||||||
|
- `letter-preview.adapter.ts:46` — `'X-Role': currentRole()`, plus `'X-Subject'` when present
|
||||||
|
- `org-template.adapter.ts:79` — `'X-Role': currentRole()`
|
||||||
|
|
||||||
|
The readers underneath were ungated too: `role.ts:24` and `subject.ts:24` both read the
|
||||||
|
`?role=`/`?subject=` query param and **wrote it into `sessionStorage`** on any
|
||||||
|
navigation, in any build. For `?subject=` that value is a BSN — `subject.ts`'s own doc
|
||||||
|
comment argued at length that the BSN must never leave `SessionStore` and then routed it
|
||||||
|
through `sessionStorage` anyway. `docs/reference/roles-and-access.md:23` claimed "Both
|
||||||
|
are wired only under `isDevMode()` — they do not exist in a production build", which was
|
||||||
|
false for exactly these three call sites.
|
||||||
|
|
||||||
|
**TE-002 — the reveal's trust boundary was not callable.** The response-shape validation
|
||||||
|
in `reveal-bignummer.adapter.ts` (the code's own comment called it a "Trust boundary")
|
||||||
|
lived inline inside `async reveal()`, after `await fetch(...)` on the global `fetch`. A
|
||||||
|
spec could not reach it without stubbing `globalThis.fetch`. The same shape recurred,
|
||||||
|
un-exported, in `letter-preview.adapter.ts`'s `errorMessage` and — contrary to the
|
||||||
|
finding's text, see "Judgement calls" below — as an inline `try/catch` (not yet a
|
||||||
|
function) in `org-template.adapter.ts`'s `proefbrief()`.
|
||||||
|
|
||||||
|
**BIO-006(a) — the step-up stub was a constant.** `reveal-bignummer.adapter.ts` sent
|
||||||
|
`'X-Step-Up': 'true'` unconditionally, as a literal, so the backend's
|
||||||
|
`canReveal && X-Step-Up == "true"` precondition was satisfied by every call that reached
|
||||||
|
the endpoint and constrained nothing.
|
||||||
|
|
||||||
|
**BIO-006(b) — the default role holds the PII-reveal capability, undocumented.**
|
||||||
|
`StubIdentityProvider`'s `_ =>` role-switch arm resolves any request with no (or an
|
||||||
|
unrecognised) `X-Role` header to `drafter` — the one role `Authz.CanRevealBigNummer`
|
||||||
|
grants. `roles-and-access.md` documented `drafter` as "the only role that may reveal a
|
||||||
|
BSN" without noting that it is also the fallback identity, so the least-privilege
|
||||||
|
consequence was invisible.
|
||||||
|
|
||||||
|
## What changed
|
||||||
|
|
||||||
|
| File | Change |
|
||||||
|
| --------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| `libs/shared/src/infrastructure/role.ts` | `currentRole()` returns `'drafter'` immediately when `!isDevMode()` — no query-param read, no `sessionStorage` write |
|
||||||
|
| `libs/shared/src/infrastructure/subject.ts` | `currentSubject()` returns `undefined` immediately when `!isDevMode()` — same treatment for the BSN |
|
||||||
|
| `reveal-bignummer.adapter.ts` | `reveal(stepUp: boolean)`; `X-Role` sent only under `isDevMode()`, `X-Step-Up` sent only when `stepUp`; inline shape check moved to exported `parseRevealed(body)`; `REVEAL_FAILED` exported |
|
||||||
|
| `letter-preview.adapter.ts` | headers wrapped in `isDevMode() ? {...} : {}`; `errorMessage` exported |
|
||||||
|
| `org-template.adapter.ts` | `X-Role` sent only under `isDevMode()`; the inline `proefbrief()` error `try/catch` extracted to an exported `proefbriefErrorMessage`; `PROEFBRIEF_FAILED` exported |
|
||||||
|
| `apps/ssp/src/app/brief/application/brief.store.ts` | `revealBigNummer()` calls `this.revealAdapter.reveal(true)` — the literal now lives at the one call site reachable only after the UI's confirm gesture, not inside the adapter |
|
||||||
|
| `docs/reference/roles-and-access.md` | records which two places the `isDevMode()` gate now lives (interceptor registration **and** the reader functions) and why; adds the BIO-006(b) note that `drafter` is also the backend's fallback identity |
|
||||||
|
| 6 new/extended `*.spec.ts` | see "Verification" below |
|
||||||
|
|
||||||
|
**The fix is two layers, not one, because the finding named both.** Gating only
|
||||||
|
`currentRole()`/`currentSubject()` would already stop the query param and the
|
||||||
|
`sessionStorage` write from working outside `isDevMode()` — the three adapters would
|
||||||
|
then send the safe default (`'X-Role': 'drafter'`, no `X-Subject`) even in production.
|
||||||
|
The adapters are _also_ wrapped in `isDevMode()` so a production request from any of the
|
||||||
|
three hand-written `fetch` calls carries no `X-Role`/`X-Subject` header at all, exactly
|
||||||
|
matching what a `HttpClient` request already does once `roleInterceptor` is not
|
||||||
|
registered — the two paths now agree on production behaviour instead of merely agreeing
|
||||||
|
on the resulting header value.
|
||||||
|
|
||||||
|
**`X-Step-Up` is deliberately not folded into the same `isDevMode()` gate.** It is not a
|
||||||
|
`?role=`/`?subject=`-style dev override; it is the stub for a control BIO-006 says must
|
||||||
|
survive into production (in stubbed form) until a real step-up exists. Nesting it inside
|
||||||
|
`isDevMode()` would make the reveal endpoint permanently unreachable in a production
|
||||||
|
build. Instead it is gated on the `stepUp` parameter alone, which is `true` only when
|
||||||
|
`BriefStore.revealBigNummer()` — reachable only via `behandel-scherm.component.ts`'s
|
||||||
|
`onReveal()` confirm — calls it.
|
||||||
|
|
||||||
|
## Judgement calls
|
||||||
|
|
||||||
|
- **`org-template.adapter.ts`'s proefbrief error mapping was not "already a separate
|
||||||
|
function".** The finding's remediation text says "the proefbrief error mapping in
|
||||||
|
`org-template.adapter.ts` — both are already separate functions and only need
|
||||||
|
`export` and a spec", matching `letter-preview.adapter.ts`'s `errorMessage`. Reading
|
||||||
|
the file: the other two adapters do have a standalone `errorMessage`/similar function,
|
||||||
|
but `org-template.adapter.ts`'s proefbrief error handling was inlined directly in the
|
||||||
|
`try { … } catch { … }` block, not a named function. This is a minor factual
|
||||||
|
imprecision in the finding, not a blocker — I extracted the same inline logic into a
|
||||||
|
named `proefbriefErrorMessage`, exported it, and added the same spec shape as its two
|
||||||
|
siblings. The result matches the finding's intent (a callable, spec'd trust boundary)
|
||||||
|
even though the starting shape needed one extra step the finding didn't mention.
|
||||||
|
- **The BIO-006(a) literal moved to `BriefStore.revealBigNummer()`, not to the UI.**
|
||||||
|
`behandel-scherm.component.ts`'s `onReveal()` already gates the _only_ path that can
|
||||||
|
reach `store.revealBigNummer()` behind a `confirm()` dialog, and the store's own
|
||||||
|
docstring says the step-up gesture "is the UI's concern". Threading a boolean through
|
||||||
|
the component's `output<void>()` and the page's template binding would touch three more
|
||||||
|
files for no behavioural change, since the call graph already guarantees confirmation
|
||||||
|
happened first. I moved the literal one layer up instead — out of the adapter (the
|
||||||
|
transport) and into the store (the command that is exclusively reachable via the
|
||||||
|
confirmed gesture) — which is the smallest change consistent with "not from the
|
||||||
|
adapter's literal" and with this repo's ui → application → infrastructure layering (ui
|
||||||
|
cannot call infrastructure directly to pass the flag down any other way).
|
||||||
|
- **Redundant-looking `isDevMode()` guards, kept anyway.** After gating
|
||||||
|
`currentRole()`/`currentSubject()`, the three adapters' own `isDevMode()` wrap around
|
||||||
|
the headers object is not strictly load-bearing for `X-Subject` (already `undefined`
|
||||||
|
outside dev) and only changes the _value sent_ for `X-Role` (a hardcoded `'drafter'`
|
||||||
|
vs. no header) rather than any security outcome (the backend treats both identically).
|
||||||
|
I kept the adapter-level gate anyway so the security posture is visible by inspection
|
||||||
|
at the fetch call site — matching `app.config.ts`'s `isDevMode() ? [...] : []` pattern
|
||||||
|
— rather than requiring a reviewer to trace into `role.ts`/`subject.ts` to confirm it.
|
||||||
|
- **No `proefbrief()`-level header spec.** `OrgTemplateAdapter` injects `ApiClient` via
|
||||||
|
`inject()`, so exercising `proefbrief()` itself needs a `TestBed` + a mock `ApiClient`
|
||||||
|
purely to reach a method that doesn't use either. I judged that disproportionate to the
|
||||||
|
marginal coverage gained, since the identical `isDevMode()` pattern is already
|
||||||
|
exercised end-to-end (via `fetch` stubbing) on the other two adapters
|
||||||
|
(`reveal-bignummer.adapter.spec.ts`, `letter-preview.adapter.spec.ts`), and the
|
||||||
|
underlying reader-level fix is covered directly in `role.spec.ts`. Noted here as a
|
||||||
|
residual rather than silently skipped.
|
||||||
|
- **`setRole()` (the dev-switcher writer) was left ungated.** BIO-012's evidence names
|
||||||
|
the two _readers_ (`currentRole`/`currentSubject`); `setRole()` is only ever invoked
|
||||||
|
from `debug-state.component.ts`, which is itself rendered only under
|
||||||
|
`shell.component.ts`'s `@if (isDev && debugPanel)`. Gating it too would be harmless but
|
||||||
|
wasn't asked for and has no reachable production call site to protect — left alone to
|
||||||
|
keep the diff to what the finding actually named.
|
||||||
|
|
||||||
|
## Consequences worth knowing
|
||||||
|
|
||||||
|
- **Doc correction, same diff.** `roles-and-access.md`'s "Both are wired only under
|
||||||
|
`isDevMode()`" line is accurate as of this commit — the gate now lives in the
|
||||||
|
interceptor registration **and** inside `currentRole()`/`currentSubject()` themselves.
|
||||||
|
Before this commit, the sentence was false for the three hand-written `fetch` paths; the
|
||||||
|
doc has been extended, not merely left as-is, to say _where_ the gate lives so a future
|
||||||
|
reader doesn't have to rediscover why the interceptor site alone wasn't sufficient.
|
||||||
|
- **CLAUDE.md needed no correction.** Its "Scenario toggle (dev-only, not wired in prod
|
||||||
|
builds)" and "Dev role stand-in (dev-only)" lines don't claim anything about the three
|
||||||
|
hand-written `fetch` adapters specifically (the accompanying sentence already says they
|
||||||
|
"bypass the interceptor", which stays true — they still don't go through
|
||||||
|
`HttpClient`). Those claims were already compatible with a fix landing here; they made
|
||||||
|
no false statement that needed walking back.
|
||||||
|
- **`?subject=` is still undocumented by name in `roles-and-access.md`.** The BIO-012
|
||||||
|
evidence and this ticket's brief both discuss it, but the doc file never named
|
||||||
|
`?subject=`/`X-Subject` before this change and still doesn't get a dedicated section —
|
||||||
|
only the new paragraph under "How to switch role" mentions it in passing. A full
|
||||||
|
`?subject=` write-up (its own e2e-only purpose, `X-Medewerker`/`X-Rollen` parallel) is
|
||||||
|
arguably worth a follow-up doc pass, but out of scope for a security-focused ticket
|
||||||
|
about production leakage.
|
||||||
|
- **Behaviour spec regenerated.** `libs/shared/docs/behaviour-spec.mdx` is generated from
|
||||||
|
the suite (`npm run gen:behaviour-spec`) and is included in this diff — the CI gate's
|
||||||
|
drift check would otherwise fail on the 6 new `describe` blocks this ticket adds.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
Every fix below was confirmed **red without it** by temporarily reverting the source
|
||||||
|
change (tests unchanged) and re-running the affected suite, then restoring the fix:
|
||||||
|
|
||||||
|
- `libs/shared/src/infrastructure/role.spec.ts` — removing the `if (!isDevMode())` guard
|
||||||
|
from `currentRole()` turned 3 "outside isDevMode()" tests red (`?role=` still honoured,
|
||||||
|
still written to `sessionStorage`).
|
||||||
|
- `libs/shared/src/infrastructure/subject.spec.ts` — same removal on `currentSubject()`
|
||||||
|
turned its 3 "outside isDevMode()" tests red (a BSN still read from the URL and written
|
||||||
|
to `sessionStorage`).
|
||||||
|
- `apps/ssp/src/app/brief/infrastructure/reveal-bignummer.adapter.spec.ts` — reverting
|
||||||
|
`reveal()` to the original unconditional `{ 'X-Role': currentRole(), 'X-Step-Up': 'true' }`
|
||||||
|
turned both `RevealBigNummerAdapter.reveal` tests red (`X-Step-Up` sent regardless of the
|
||||||
|
`stepUp` argument; `X-Role` sent regardless of `isDevMode()`).
|
||||||
|
|
||||||
|
New specs, all pure/exported-boundary tests per house convention (no `TestBed`, no
|
||||||
|
`globalThis.fetch` stub needed for the pure halves):
|
||||||
|
|
||||||
|
| File | Covers |
|
||||||
|
| ----------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| `reveal-bignummer.adapter.spec.ts` | `parseRevealed` (incl. the finding's own `{ bigNummer: 42 }` rejection case); `reveal()`'s `X-Step-Up`/`X-Role` gating via a stubbed `fetch` |
|
||||||
|
| `letter-preview.adapter.spec.ts` | `errorMessage`; `preview()`'s header gating via a stubbed `fetch` |
|
||||||
|
| `org-template.adapter.spec.ts` (extended) | `proefbriefErrorMessage` |
|
||||||
|
| `role.spec.ts` (new) | `currentRole()` dev behaviour + the `isDevMode()`-gated production behaviour |
|
||||||
|
| `subject.spec.ts` (new) | `currentSubject()` dev behaviour + the `isDevMode()`-gated production behaviour |
|
||||||
|
|
||||||
|
`npm run ci` (lint, typecheck, `dep:check`, `format:check`, `check:tokens`, `check:seam`,
|
||||||
|
full test suite with coverage, `ng build --localize` for both apps, `npm audit`, backend
|
||||||
|
`dotnet format` + `dotnet test`, showcase-snippets/behaviour-spec/api-client drift
|
||||||
|
checks): **green**, including all 4 vitest projects (ssp/behandelportal/shared/beheer) and
|
||||||
|
`dotnet test` (241 passed).
|
||||||
@@ -20,7 +20,15 @@ acting role to exercise the drafter/approver/admin flows.
|
|||||||
|
|
||||||
## How to switch role (dev only)
|
## How to switch role (dev only)
|
||||||
|
|
||||||
Both are wired only under `isDevMode()` — they do not exist in a production build.
|
Both are wired only under `isDevMode()` — they do not exist in a production build. That
|
||||||
|
gate lives in two places: the `roleInterceptor` registration (`app.config.ts`) for every
|
||||||
|
`HttpClient` request, **and** inside `role.ts`'s `currentRole()` itself, because three
|
||||||
|
hand-written `fetch` calls (`reveal-bignummer.adapter.ts`, `letter-preview.adapter.ts`,
|
||||||
|
`org-template.adapter.ts`'s proefbrief) read the role directly and bypass the
|
||||||
|
interceptor entirely (RB-11/BIO-012). Before RB-11, `currentRole()` had no such gate, so
|
||||||
|
`?role=` kept working through those three calls in a production build even though this
|
||||||
|
page said otherwise; the same defect applied to `?subject=` and `subject.ts`, which is
|
||||||
|
how a BSN reached `sessionStorage` in any build.
|
||||||
|
|
||||||
- **Dev switcher (easiest):** open the `⚙ state` panel (bottom-right in a dev build) and pick a
|
- **Dev switcher (easiest):** open the `⚙ state` panel (bottom-right in a dev build) and pick a
|
||||||
role from the **role** dropdown. The page reloads with the new role.
|
role from the **role** dropdown. The page reloads with the new role.
|
||||||
@@ -66,6 +74,16 @@ The admin pages appear in the header nav and in the dashboard **"Beheer"** secti
|
|||||||
matching capability is present — otherwise they are reachable only by URL (and the route guard
|
matching capability is present — otherwise they are reachable only by URL (and the route guard
|
||||||
redirects a user who lacks the capability back to `/dashboard`).
|
redirects a user who lacks the capability back to `/dashboard`).
|
||||||
|
|
||||||
|
**`drafter` is also the backend's fallback identity (BIO-006).** It is not only the dev
|
||||||
|
switcher's initial selection — `StubIdentityProvider`'s role switch resolves **any**
|
||||||
|
request with no `X-Role` header at all (or an unrecognised one) to `drafter` too. Because
|
||||||
|
`drafter` is also the _only_ role that may reveal a BIG-nummer, the least-privilege
|
||||||
|
consequence is real: an unauthenticated or misconfigured caller inherits the PII-reveal
|
||||||
|
capability by default, rather than the weakest one. This is acceptable only because the
|
||||||
|
POC has no real identity or step-up yet (see the pre-production compliance checklist —
|
||||||
|
binding the reveal to an app-overlay attribute instead of the coarse role is a named,
|
||||||
|
not-yet-built item); it must not survive real identity and step-up.
|
||||||
|
|
||||||
## The one principle
|
## The one principle
|
||||||
|
|
||||||
Identity (AD/OIDC, faked here) supplies **coarse roles**; the app owns a **fine-grained capability**
|
Identity (AD/OIDC, faked here) supplies **coarse roles**; the app owns a **fine-grained capability**
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ tested where._
|
|||||||
|
|
||||||
Every bullet below is a real test name from the suite — an `it()` title (frontend) or a test
|
Every bullet below is a real test name from the suite — an `it()` title (frontend) or a test
|
||||||
method name (backend), read as a sentence. Nothing here is hand-written prose: this page
|
method name (backend), read as a sentence. Nothing here is hand-written prose: this page
|
||||||
**is** the suite, reshaped for a business reader. 406 frontend behaviours across
|
**is** the suite, reshaped for a business reader. 431 frontend behaviours across
|
||||||
8 contexts; 217 backend behaviours across 36 test
|
8 contexts; 217 backend behaviours across 36 test
|
||||||
classes.
|
classes.
|
||||||
|
|
||||||
@@ -186,6 +186,16 @@ classes.
|
|||||||
- swaps the masked value for the revealed one on success
|
- swaps the masked value for the revealed one on success
|
||||||
- keeps the value masked and surfaces the error on failure
|
- keeps the value masked and surfaces the error on failure
|
||||||
|
|
||||||
|
#### LetterPreviewAdapter.preview (BIO-012)
|
||||||
|
|
||||||
|
- sends no X-Role/X-Subject headers outside isDevMode()
|
||||||
|
- sends X-Role (and X-Subject when known) under isDevMode()
|
||||||
|
|
||||||
|
#### RevealBigNummerAdapter.reveal (BIO-006a + BIO-012)
|
||||||
|
|
||||||
|
- sends X-Step-Up only when the caller passes stepUp: true
|
||||||
|
- sends X-Role only under isDevMode()
|
||||||
|
|
||||||
#### besluitGuidance
|
#### besluitGuidance
|
||||||
|
|
||||||
- positief: counts inserted passages, no reden needed (positief has no redenen)
|
- positief: counts inserted passages, no reden needed (positief has no redenen)
|
||||||
@@ -237,6 +247,12 @@ classes.
|
|||||||
- marks added, removed, changed and unchanged by blockId
|
- marks added, removed, changed and unchanged by blockId
|
||||||
- changedBlocks drops unchanged and keeps added/removed/changed
|
- changedBlocks drops unchanged and keeps added/removed/changed
|
||||||
|
|
||||||
|
#### errorMessage (TE-002 trust boundary)
|
||||||
|
|
||||||
|
- surfaces the ProblemDetails detail when present
|
||||||
|
- falls back to PREVIEW_FAILED when the body has no detail
|
||||||
|
- falls back to PREVIEW_FAILED when the body is not JSON
|
||||||
|
|
||||||
#### inferSelection
|
#### inferSelection
|
||||||
|
|
||||||
- round-trips a positief selection
|
- round-trips a positief selection
|
||||||
@@ -275,6 +291,13 @@ classes.
|
|||||||
- rejects a missing count field
|
- rejects a missing count field
|
||||||
- rejects a malformed history entry
|
- rejects a malformed history entry
|
||||||
|
|
||||||
|
#### parseRevealed (TE-002 trust boundary)
|
||||||
|
|
||||||
|
- accepts a well-formed body
|
||||||
|
- rejects a bigNummer sent as a number
|
||||||
|
- rejects a missing bigNummer field
|
||||||
|
- rejects null and non-object bodies
|
||||||
|
|
||||||
#### passagesForBesluit
|
#### passagesForBesluit
|
||||||
|
|
||||||
- positief = shared intro + the positief passage, no negatief/reason passages
|
- positief = shared intro + the positief passage, no negatief/reason passages
|
||||||
@@ -283,6 +306,12 @@ classes.
|
|||||||
- preserves library order (= reading order)
|
- preserves library order (= reading order)
|
||||||
- never offers non-kern passages
|
- never offers non-kern passages
|
||||||
|
|
||||||
|
#### proefbriefErrorMessage (TE-002 trust boundary)
|
||||||
|
|
||||||
|
- surfaces the ProblemDetails detail when present
|
||||||
|
- falls back to PROEFBRIEF_FAILED when the body has no detail
|
||||||
|
- falls back to PROEFBRIEF_FAILED when the body is not JSON
|
||||||
|
|
||||||
#### redenenFor
|
#### redenenFor
|
||||||
|
|
||||||
- derives reason checkboxes (code + label) from the negatief reason passages
|
- derives reason checkboxes (code + label) from the negatief reason passages
|
||||||
@@ -635,6 +664,29 @@ classes.
|
|||||||
- applies the pure update on dispatch
|
- applies the pure update on dispatch
|
||||||
- dispatch from inside an effect does not self-loop
|
- dispatch from inside an effect does not self-loop
|
||||||
|
|
||||||
|
#### currentRole (dev mechanism)
|
||||||
|
|
||||||
|
- lists the three roles
|
||||||
|
- reads a valid ?role= from the URL and persists it for the tab
|
||||||
|
- falls back to drafter when nothing is set or the value is invalid
|
||||||
|
|
||||||
|
#### currentRole (dev mechanism) › outside isDevMode() (production build)
|
||||||
|
|
||||||
|
- ignores a ?role= in the URL and returns the default
|
||||||
|
- never touches sessionStorage
|
||||||
|
- ignores a role already sitting in sessionStorage from a prior dev session
|
||||||
|
|
||||||
|
#### currentSubject (dev mechanism)
|
||||||
|
|
||||||
|
- reads a ?subject= from the URL and persists it for the tab
|
||||||
|
- returns undefined when nothing has ever been set
|
||||||
|
|
||||||
|
#### currentSubject (dev mechanism) › outside isDevMode() (production build)
|
||||||
|
|
||||||
|
- ignores a ?subject= (a BSN) in the URL and returns undefined
|
||||||
|
- never writes the BSN into sessionStorage
|
||||||
|
- ignores a subject already sitting in sessionStorage from a prior dev session
|
||||||
|
|
||||||
#### delete flow (optimistic, revertible)
|
#### delete flow (optimistic, revertible)
|
||||||
|
|
||||||
- UploadDeleteRequested keeps the documentId for revert
|
- UploadDeleteRequested keeps the documentId for revert
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||||
|
import { currentRole, ROLES } from './role';
|
||||||
|
|
||||||
|
const setUrl = (search: string) => history.pushState({}, '', search || '/');
|
||||||
|
|
||||||
|
// isDevMode() reads the `ngDevMode` global the Angular CLI defines away in a
|
||||||
|
// production build. There is no ambient type for it in app code, so this is
|
||||||
|
// accessed through an untyped bag rather than a `declare const`.
|
||||||
|
const globals = globalThis as Record<string, unknown>;
|
||||||
|
const originalNgDevMode = globals['ngDevMode'];
|
||||||
|
const setDevMode = (on: boolean) => {
|
||||||
|
globals['ngDevMode'] = on;
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('currentRole (dev mechanism)', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
sessionStorage.clear();
|
||||||
|
setUrl('/');
|
||||||
|
setDevMode(true);
|
||||||
|
});
|
||||||
|
afterEach(() => {
|
||||||
|
globals['ngDevMode'] = originalNgDevMode;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lists the three roles', () => {
|
||||||
|
expect(ROLES).toEqual(['drafter', 'approver', 'admin']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reads a valid ?role= from the URL and persists it for the tab', () => {
|
||||||
|
setUrl('?role=admin');
|
||||||
|
expect(currentRole()).toBe('admin');
|
||||||
|
setUrl('/'); // navigation drops the query param — value stays sticky
|
||||||
|
expect(currentRole()).toBe('admin');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to drafter when nothing is set or the value is invalid', () => {
|
||||||
|
expect(currentRole()).toBe('drafter');
|
||||||
|
setUrl('?role=nonsense');
|
||||||
|
expect(currentRole()).toBe('drafter');
|
||||||
|
});
|
||||||
|
|
||||||
|
// BIO-012: the three hand-written `fetch` adapters call this function directly,
|
||||||
|
// bypassing `roleInterceptor`'s own isDevMode()-gated registration — so the gate
|
||||||
|
// has to hold here, not just at the interceptor, or `?role=` keeps working in a
|
||||||
|
// production build through that side door.
|
||||||
|
describe('outside isDevMode() (production build)', () => {
|
||||||
|
beforeEach(() => setDevMode(false));
|
||||||
|
|
||||||
|
it('ignores a ?role= in the URL and returns the default', () => {
|
||||||
|
setUrl('?role=admin');
|
||||||
|
expect(currentRole()).toBe('drafter');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('never touches sessionStorage', () => {
|
||||||
|
setUrl('?role=admin');
|
||||||
|
currentRole();
|
||||||
|
expect(sessionStorage.getItem('dev-role')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores a role already sitting in sessionStorage from a prior dev session', () => {
|
||||||
|
sessionStorage.setItem('dev-role', 'admin');
|
||||||
|
expect(currentRole()).toBe('drafter');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { isDevMode } from '@angular/core';
|
||||||
import { Role } from '@shared/domain/role';
|
import { Role } from '@shared/domain/role';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -14,13 +15,22 @@ import { Role } from '@shared/domain/role';
|
|||||||
* don't carry it), which would silently revert an admin to drafter mid-session and
|
* don't carry it), which would silently revert an admin to drafter mid-session and
|
||||||
* 403 the admin endpoints. So a `?role=` seen in the URL is remembered for the tab;
|
* 403 the admin endpoints. So a `?role=` seen in the URL is remembered for the tab;
|
||||||
* later requests use the remembered value. Set `?role=drafter` (or a fresh tab) to
|
* later requests use the remembered value. Set `?role=drafter` (or a fresh tab) to
|
||||||
* reset. Dev-only — the interceptor itself is only wired under `isDevMode()`.
|
* reset.
|
||||||
|
*
|
||||||
|
* **Gated here, not only at the interceptor (BIO-012):** the `roleInterceptor` that
|
||||||
|
* consumes this for `HttpClient` traffic is only registered under `isDevMode()`
|
||||||
|
* (`app.config.ts`), but `brief`'s three hand-written `fetch` adapters call this
|
||||||
|
* function directly, bypassing that interceptor entirely. Reading `?role=` and
|
||||||
|
* writing it to `sessionStorage` is therefore gated in the function itself — outside
|
||||||
|
* `isDevMode()` the query param is never read, `sessionStorage` is never touched, and
|
||||||
|
* the fixed default (`drafter`, the least-privileged role) is returned every time.
|
||||||
*/
|
*/
|
||||||
const STORAGE_KEY = 'dev-role';
|
const STORAGE_KEY = 'dev-role';
|
||||||
export const ROLES: readonly Role[] = ['drafter', 'approver', 'admin'];
|
export const ROLES: readonly Role[] = ['drafter', 'approver', 'admin'];
|
||||||
const isRole = (v: string | null): v is Role => !!v && ROLES.includes(v as Role);
|
const isRole = (v: string | null): v is Role => !!v && ROLES.includes(v as Role);
|
||||||
|
|
||||||
export function currentRole(): Role {
|
export function currentRole(): Role {
|
||||||
|
if (!isDevMode()) return 'drafter';
|
||||||
const fromUrl = new URLSearchParams(window.location.search).get('role');
|
const fromUrl = new URLSearchParams(window.location.search).get('role');
|
||||||
if (isRole(fromUrl)) {
|
if (isRole(fromUrl)) {
|
||||||
sessionStorage.setItem(STORAGE_KEY, fromUrl);
|
sessionStorage.setItem(STORAGE_KEY, fromUrl);
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||||
|
import { currentSubject } from './subject';
|
||||||
|
|
||||||
|
const setUrl = (search: string) => history.pushState({}, '', search || '/');
|
||||||
|
|
||||||
|
// isDevMode() reads the `ngDevMode` global the Angular CLI defines away in a
|
||||||
|
// production build. There is no ambient type for it in app code, so this is
|
||||||
|
// accessed through an untyped bag rather than a `declare const`.
|
||||||
|
const globals = globalThis as Record<string, unknown>;
|
||||||
|
const originalNgDevMode = globals['ngDevMode'];
|
||||||
|
const setDevMode = (on: boolean) => {
|
||||||
|
globals['ngDevMode'] = on;
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('currentSubject (dev mechanism)', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
sessionStorage.clear();
|
||||||
|
setUrl('/');
|
||||||
|
setDevMode(true);
|
||||||
|
});
|
||||||
|
afterEach(() => {
|
||||||
|
globals['ngDevMode'] = originalNgDevMode;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reads a ?subject= from the URL and persists it for the tab', () => {
|
||||||
|
setUrl('?subject=111222333');
|
||||||
|
expect(currentSubject()).toBe('111222333');
|
||||||
|
setUrl('/'); // navigation drops the query param — value stays sticky
|
||||||
|
expect(currentSubject()).toBe('111222333');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns undefined when nothing has ever been set', () => {
|
||||||
|
expect(currentSubject()).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
// BIO-012: a BSN is art. 9 GDPR special-category data. Prior to this fix this
|
||||||
|
// function wrote it into sessionStorage on any navigation, in any build.
|
||||||
|
describe('outside isDevMode() (production build)', () => {
|
||||||
|
beforeEach(() => setDevMode(false));
|
||||||
|
|
||||||
|
it('ignores a ?subject= (a BSN) in the URL and returns undefined', () => {
|
||||||
|
setUrl('?subject=111222333');
|
||||||
|
expect(currentSubject()).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('never writes the BSN into sessionStorage', () => {
|
||||||
|
setUrl('?subject=111222333');
|
||||||
|
currentSubject();
|
||||||
|
expect(sessionStorage.getItem('dev-subject')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores a subject already sitting in sessionStorage from a prior dev session', () => {
|
||||||
|
sessionStorage.setItem('dev-subject', '111222333');
|
||||||
|
expect(currentSubject()).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { isDevMode } from '@angular/core';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Dev-only role stand-in's sibling (the reading MECHANISM for `X-Subject`; see
|
* 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
|
* `role.ts`'s own doc comment for the twin `X-Role` mechanism this mirrors). This
|
||||||
@@ -13,6 +15,13 @@
|
|||||||
* hand-written `fetch`, which bypasses every `HttpInterceptorFn` — the same reason
|
* hand-written `fetch`, which bypasses every `HttpInterceptorFn` — the same reason
|
||||||
* that adapter already sets `X-Role` explicitly via `currentRole()`).
|
* that adapter already sets `X-Role` explicitly via `currentRole()`).
|
||||||
*
|
*
|
||||||
|
* **Gated here, not only at the interceptor (BIO-012):** `subjectInterceptor` is only
|
||||||
|
* registered under `isDevMode()`, but `letter-preview.adapter.ts` calls this function
|
||||||
|
* directly and bypasses that interceptor. The value read here is a **BSN** — a GDPR
|
||||||
|
* special-category identifier — so outside `isDevMode()` the query param is never
|
||||||
|
* read and `sessionStorage` is never written; `undefined` is returned unconditionally,
|
||||||
|
* exactly as if no `?subject=` had ever been seen.
|
||||||
|
*
|
||||||
* `undefined` (not a default BSN) when nothing has ever set `?subject=`: unlike
|
* `undefined` (not a default BSN) when nothing has ever set `?subject=`: unlike
|
||||||
* `currentRole()` (a closed enum with a sensible default), there is no "default
|
* `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
|
* subject" to fall back to here — omitting the header entirely lets the backend's
|
||||||
@@ -21,6 +30,7 @@
|
|||||||
const STORAGE_KEY = 'dev-subject';
|
const STORAGE_KEY = 'dev-subject';
|
||||||
|
|
||||||
export function currentSubject(): string | undefined {
|
export function currentSubject(): string | undefined {
|
||||||
|
if (!isDevMode()) return undefined;
|
||||||
const fromUrl = new URLSearchParams(window.location.search).get('subject');
|
const fromUrl = new URLSearchParams(window.location.search).get('subject');
|
||||||
if (fromUrl) {
|
if (fromUrl) {
|
||||||
sessionStorage.setItem(STORAGE_KEY, fromUrl);
|
sessionStorage.setItem(STORAGE_KEY, fromUrl);
|
||||||
|
|||||||
Reference in New Issue
Block a user