test(auth): extract and spec the stored-session parse boundary (RB-10)

SessionStore.restore() — identical in both apps — read localStorage itself
and did the parse plus shape validation in the same module-private function,
invoked from a field initializer, so the storage read happened the instant
the singleton was constructed and no spec could feed it a raw string. The
logic it guards is a trust boundary, not incidental validation: the comment
above it names G1 (never persist the BSN) and G2 (validate the shape before
trusting it), and CLAUDE.md mandates a spec for boundary parse* adapters.
ssp/auth and bhp/auth were jointly the worst-covered frontend modules.

parseStoredSession(raw) moves into each app's auth/domain/session.ts, which
is pure TS and already had a spec, so no new scaffolding was needed;
restore() collapses to one line. Four cases: absent, non-JSON, wrong shape,
and — BIO-017's addition — a stored {"bsn":…,"naam":…} restoring with bsn
'', which makes the G1 guarantee executable rather than merely commented.
Verified red without the fix.

Landed twice, once per app, deliberately. TE-001 and BL-002 both say an
extract-to-shared here would contradict ADR-0002, which models the two
actors as different Principal variants and expects the two auth contexts to
diverge; RB-13 is what differentiates them.

Also specs redactProfile (BIO-017's second half) — a pure exported
PII-redaction function that had none.

behaviour-spec.mdx is regenerated, which also picks up the test names RB-07
added; that commit should have carried them and did not.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
eho
2026-08-27 14:08:06 +02:00
co-authored by Claude Opus 5
parent e89525eef6
commit de349e702e
9 changed files with 257 additions and 28 deletions
@@ -1,23 +1,16 @@
import { Injectable, computed, effect, inject, signal } from '@angular/core';
import { Result } from '@shared/kernel/fp';
import { Session } from '../domain/session';
import { Session, parseStoredSession } from '../domain/session';
import { DigidAdapter } from '../infrastructure/digid.adapter';
const STORAGE_KEY = 'session-v1';
/** Restore a persisted session (best-effort; corrupt entry → logged out).
G2: validate the shape before trusting it. G1: the BSN is never persisted
(see the effect below), so a restored session carries an empty one — it is
unused after login; only `naam` is shown in the chrome. */
The parse + shape validation (G1/G2) lives in `parseStoredSession`
(`../domain/session`) — pure, spec'd, and testable without stubbing
`localStorage`; this just supplies the raw value. */
function restore(): Session | null {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return null;
const parsed = JSON.parse(raw) as Partial<Session>;
return typeof parsed?.naam === 'string' ? { bsn: '', naam: parsed.naam } : null;
} catch {
return null;
}
return parseStoredSession(localStorage.getItem(STORAGE_KEY));
}
/**
@@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest';
import { isAuthenticated, Session } from './session';
import { isAuthenticated, parseStoredSession, Session } from './session';
const session: Session = { bsn: '19012345601', naam: 'Test' };
@@ -12,3 +12,22 @@ describe('isAuthenticated', () => {
expect(isAuthenticated(null)).toBe(false);
});
});
describe('parseStoredSession', () => {
it('returns null when nothing is stored', () => {
expect(parseStoredSession(null)).toBeNull();
});
it('returns null for a non-JSON string', () => {
expect(parseStoredSession('not json')).toBeNull();
});
it('returns null when the stored shape is wrong (no naam)', () => {
expect(parseStoredSession(JSON.stringify({ bsn: '19012345601' }))).toBeNull();
});
it('G1: a stored bsn is never restored, even if present in the raw value', () => {
const restored = parseStoredSession(JSON.stringify({ bsn: '19012345601', naam: 'Test' }));
expect(restored).toEqual({ bsn: '', naam: 'Test' });
});
});
@@ -7,3 +7,21 @@ export interface Session {
export function isAuthenticated(s: Session | null): s is Session {
return s !== null;
}
/**
* Parse a persisted session out of a raw `localStorage` string (best-effort;
* anything that isn't a well-shaped record → logged out). G2: validate the
* shape before trusting it. G1: even if a stored entry carries a `bsn`, the
* restored session's `bsn` is always `''` — the BSN is never persisted (see
* the `SessionStore` effect that writes it), so a legacy or tampered entry
* cannot resurrect one.
*/
export function parseStoredSession(raw: string | null): Session | null {
try {
if (!raw) return null;
const parsed = JSON.parse(raw) as Partial<Session>;
return typeof parsed?.naam === 'string' ? { bsn: '', naam: parsed.naam } : null;
} catch {
return null;
}
}
@@ -1,23 +1,16 @@
import { Injectable, computed, effect, inject, signal } from '@angular/core';
import { Result } from '@shared/kernel/fp';
import { Session } from '../domain/session';
import { Session, parseStoredSession } from '../domain/session';
import { DigidAdapter } from '../infrastructure/digid.adapter';
const STORAGE_KEY = 'session-v1';
/** Restore a persisted session (best-effort; corrupt entry → logged out).
G2: validate the shape before trusting it. G1: the BSN is never persisted
(see the effect below), so a restored session carries an empty one — it is
unused after login; only `naam` is shown in the chrome. */
The parse + shape validation (G1/G2) lives in `parseStoredSession`
(`../domain/session`) — pure, spec'd, and testable without stubbing
`localStorage`; this just supplies the raw value. */
function restore(): Session | null {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return null;
const parsed = JSON.parse(raw) as Partial<Session>;
return typeof parsed?.naam === 'string' ? { bsn: '', naam: parsed.naam } : null;
} catch {
return null;
}
return parseStoredSession(localStorage.getItem(STORAGE_KEY));
}
/**
+20 -1
View File
@@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest';
import { isAuthenticated, Session } from './session';
import { isAuthenticated, parseStoredSession, Session } from './session';
const session: Session = { bsn: '19012345601', naam: 'Test' };
@@ -12,3 +12,22 @@ describe('isAuthenticated', () => {
expect(isAuthenticated(null)).toBe(false);
});
});
describe('parseStoredSession', () => {
it('returns null when nothing is stored', () => {
expect(parseStoredSession(null)).toBeNull();
});
it('returns null for a non-JSON string', () => {
expect(parseStoredSession('not json')).toBeNull();
});
it('returns null when the stored shape is wrong (no naam)', () => {
expect(parseStoredSession(JSON.stringify({ bsn: '19012345601' }))).toBeNull();
});
it('G1: a stored bsn is never restored, even if present in the raw value', () => {
const restored = parseStoredSession(JSON.stringify({ bsn: '19012345601', naam: 'Test' }));
expect(restored).toEqual({ bsn: '', naam: 'Test' });
});
});
+18
View File
@@ -7,3 +7,21 @@ export interface Session {
export function isAuthenticated(s: Session | null): s is Session {
return s !== null;
}
/**
* Parse a persisted session out of a raw `localStorage` string (best-effort;
* anything that isn't a well-shaped record → logged out). G2: validate the
* shape before trusting it. G1: even if a stored entry carries a `bsn`, the
* restored session's `bsn` is always `''` — the BSN is never persisted (see
* the `SessionStore` effect that writes it), so a legacy or tampered entry
* cannot resurrect one.
*/
export function parseStoredSession(raw: string | null): Session | null {
try {
if (!raw) return null;
const parsed = JSON.parse(raw) as Partial<Session>;
return typeof parsed?.naam === 'string' ? { bsn: '', naam: parsed.naam } : null;
} catch {
return null;
}
}
@@ -0,0 +1,58 @@
import { describe, it, expect } from 'vitest';
import { BigProfile } from '@registratie/domain/big-profile';
import { REDACTED } from '@shared/kernel/pii';
import { redactProfile } from './mask';
const profile: BigProfile = {
registration: {
bigNummer: '12345678901',
naam: 'J. Jansen',
beroep: 'arts',
registratiedatum: '2015-03-01',
geboortedatum: '1980-06-12',
status: { tag: 'Geregistreerd', herregistratieDatum: '2027-03-01' },
},
person: {
naam: 'J. Jansen',
geboortedatum: '1980-06-12',
adres: { straat: 'Hoofdstraat 1', postcode: '1234AB', woonplaats: 'Utrecht' },
},
};
describe('redactProfile', () => {
const redacted = redactProfile(profile) as {
registration: {
bigNummer: string;
naam: string;
beroep: string;
registratiedatum: string;
geboortedatum: string;
status: unknown;
};
person: { naam: string; geboortedatum: string; adres: string };
};
it('masks the BIG-nummer to its last 3 digits', () => {
expect(redacted.registration.bigNummer).toBe('********901');
});
it('redacts the name on both the registration and the person', () => {
expect(redacted.registration.naam).toBe(REDACTED);
expect(redacted.person.naam).toBe(REDACTED);
});
it('redacts every date of birth', () => {
expect(redacted.registration.geboortedatum).toBe(REDACTED);
expect(redacted.person.geboortedatum).toBe(REDACTED);
});
it('redacts the address', () => {
expect(redacted.person.adres).toBe(REDACTED);
});
it('keeps structural/decision-relevant fields untouched', () => {
expect(redacted.registration.beroep).toBe('arts');
expect(redacted.registration.registratiedatum).toBe('2015-03-01');
expect(redacted.registration.status).toEqual(profile.registration.status);
});
});