From c0834cdbce74fff8ff904ef2836419786716bf31 Mon Sep 17 00:00:00 2001 From: Edwin van den Houdt Date: Tue, 21 Jul 2026 20:43:01 +0200 Subject: [PATCH] fix(beheer): send X-Role to /api/v1/stamdata (admin reads were 403) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The stamdata admin editor's adapter calls /api/v1/stamdata via the generated ApiClient → roleInterceptor, but ROLE_AWARE omitted /api/v1/stamdata, so no X-Role was sent and the backend StamdataAdmin gate resolved Drafter → 403 on every read (confirmed: 403 without X-Role, 200 with X-Role: admin). Added /api/v1/stamdata to the allow-list (same class of gap WP-23 fixed for /me) + a roleInterceptor spec so the next admin endpoint isn't forgotten. Note: a separate issue still blocks the page in the browser — capabilityGuard redirects both admin routes to /login because it checks can() before /me resolves; tracked separately. Co-Authored-By: Claude Opus 4.8 --- .../infrastructure/role.interceptor.spec.ts | 49 +++++++++++++++++++ .../shared/infrastructure/role.interceptor.ts | 14 ++++-- 2 files changed, 59 insertions(+), 4 deletions(-) create mode 100644 src/app/shared/infrastructure/role.interceptor.spec.ts diff --git a/src/app/shared/infrastructure/role.interceptor.spec.ts b/src/app/shared/infrastructure/role.interceptor.spec.ts new file mode 100644 index 0000000..38cf215 --- /dev/null +++ b/src/app/shared/infrastructure/role.interceptor.spec.ts @@ -0,0 +1,49 @@ +import { describe, it, expect, vi } from 'vitest'; +import { roleInterceptor } from './role.interceptor'; + +// currentRole() reads window.location; pin it so the test is about routing, not the shim. +vi.mock('./role', () => ({ currentRole: () => 'admin' })); + +// Minimal stand-in for HttpRequest — the interceptor only reads `url` and calls +// `clone({ setHeaders })`. Avoids importing @angular/common/http at runtime (its XHR +// chunk needs the JIT compiler under vitest). +function fakeReq(url: string) { + const make = (headers: Map) => ({ + url, + headers, + clone(opts: { setHeaders: Record }) { + 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; + const next = (r: ReturnType) => { + seen = r; + return undefined; + }; + // Cast: the fake matches the shape the interceptor actually touches. + (roleInterceptor as unknown as (req: unknown, next: unknown) => unknown)(fakeReq(url), next); + return seen; +} + +describe('roleInterceptor', () => { + it.each([ + '/api/v1/brief', + '/api/v1/admin/org-template', + '/api/v1/stamdata', // WP-29: the admin stamdata reads 403 without X-Role + '/api/v1/stamdata/professions?peildatum=1999-01-01', + '/api/v1/me', + ])('stamps X-Role on the role-aware endpoint %s', (url) => { + expect(forward(url).headers.get('X-Role')).toBe('admin'); + }); + + it('leaves an unrelated endpoint untouched', () => { + expect(forward('/api/v1/duo/diplomas').headers.has('X-Role')).toBe(false); + }); +}); diff --git a/src/app/shared/infrastructure/role.interceptor.ts b/src/app/shared/infrastructure/role.interceptor.ts index 123ed4a..e518348 100644 --- a/src/app/shared/infrastructure/role.interceptor.ts +++ b/src/app/shared/infrastructure/role.interceptor.ts @@ -4,11 +4,17 @@ import { currentRole } from './role'; /** * Dev-only: stamps role-aware requests with the current `?role=` as an `X-Role` * header so the backend can enforce the drafter/approver/admin rules. Only the - * brief, org-template and /me endpoints carry it (WP-23 widened the set — /me must - * see the role or `AccessStore` could never learn a capability); everything else - * is untouched. + * brief, org-template, stamdata and /me endpoints carry it (WP-23 widened the set — + * /me must see the role or `AccessStore` could never learn a capability; WP-29 added + * /stamdata, whose admin-only reads 403 without it); everything else is untouched. + * A new admin-gated endpoint MUST be added here or its page silently 403s. */ -const ROLE_AWARE = ['/api/v1/brief', '/api/v1/admin/org-template', '/api/v1/me']; +const ROLE_AWARE = [ + '/api/v1/brief', + '/api/v1/admin/org-template', + '/api/v1/stamdata', + '/api/v1/me', +]; export const roleInterceptor: HttpInterceptorFn = (req, next) => { if (!ROLE_AWARE.some((prefix) => req.url.includes(prefix))) return next(req);