fix(beheer): send X-Role to /api/v1/stamdata (admin reads were 403)
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 <noreply@anthropic.com>
This commit is contained in:
@@ -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<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.
|
||||||
|
(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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -4,11 +4,17 @@ import { currentRole } from './role';
|
|||||||
/**
|
/**
|
||||||
* Dev-only: stamps role-aware requests with the current `?role=` as an `X-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
|
* 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
|
* brief, org-template, stamdata and /me endpoints carry it (WP-23 widened the set —
|
||||||
* see the role or `AccessStore` could never learn a capability); everything else
|
* /me must see the role or `AccessStore` could never learn a capability; WP-29 added
|
||||||
* is untouched.
|
* /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) => {
|
export const roleInterceptor: HttpInterceptorFn = (req, next) => {
|
||||||
if (!ROLE_AWARE.some((prefix) => req.url.includes(prefix))) return next(req);
|
if (!ROLE_AWARE.some((prefix) => req.url.includes(prefix))) return next(req);
|
||||||
|
|||||||
Reference in New Issue
Block a user