Files
API/test/admin/auth-binding.ts
Paddy 3c892d02ed
Jenkins Production Deployment
Put the feedback and tickets admin areas behind the shared identity (#13)
Reviewed-on: #13
Co-authored-by: Patrick Müller <mail@pmueller.me>
Co-committed-by: Patrick Müller <mail@pmueller.me>
2026-09-06 19:06:30 +00:00

119 lines
3.7 KiB
TypeScript

import {vi, describe, it, expect, beforeEach, type Mock} from 'vitest';
import express, {Request, Response} from 'express';
/**
* Shared body for the two cutover tests (2026-09-06). feedback.auth.ts and
* tickets.auth.ts used to carry their own header-session authenticator against
* the calendar users table; both are now one binding to the shared admin gate.
*
* What is worth asserting is not how that gate works - admin.middleware.test.ts
* owns that - but that each module is bound to *its own* app, and that neither
* consults the calendar users service any more. The mocks live in the calling
* file because vi.mock is per-module-graph; only the assertions are shared.
*/
export interface BindingMocks {
/** auth.api.getSession from the mocked admin.auth.js */
getSession: Mock;
/** loadAccess from the mocked users.admin.service.js */
loadAccess: Mock;
/** checkSession from the mocked calendar users.service.js */
checkSession: Mock;
}
const makeReq = (): Request => ({headers: {cookie: 'nachklang.session_token=abc'}} as unknown as Request);
const makeRes = (): Response => {
const res: any = {};
res.status = vi.fn().mockReturnValue(res);
res.send = vi.fn().mockReturnValue(res);
res.locals = {};
return res as Response;
};
const userWith = (...apps: string[]) => ({
id: 'u1',
email: 'a@nachklang.art',
displayName: 'Anna Admin',
disabled: false,
permissions: apps.map(app => ({app, role: 'access'})),
apps
});
export const describeAdminBinding = (
app: string,
otherApp: string,
middleware: express.RequestHandler,
mocks: () => BindingMocks
): void => {
describe(`${app} requireAdminAuth`, () => {
let m: BindingMocks;
const run = async () => {
const res = makeRes();
const next = vi.fn();
await middleware(makeReq(), res, next);
return {res, next};
};
beforeEach(() => {
m = mocks();
m.getSession.mockReset();
m.loadAccess.mockReset();
m.checkSession.mockReset();
});
it('responds 401 and does not call next() without a session', async () => {
m.getSession.mockResolvedValue(null);
const {res, next} = await run();
expect(res.status).toHaveBeenCalledWith(401);
expect(next).not.toHaveBeenCalled();
});
it(`responds 403 for a signed-in user who only has ${otherApp}`, async () => {
m.getSession.mockResolvedValue({user: {id: 'u1'}});
m.loadAccess.mockResolvedValue(userWith(otherApp));
const {res, next} = await run();
expect(res.status).toHaveBeenCalledWith(403);
expect(next).not.toHaveBeenCalled();
});
it('responds 403 for a disabled user who still holds the permission', async () => {
m.getSession.mockResolvedValue({user: {id: 'u1'}});
m.loadAccess.mockResolvedValue({...userWith(app), disabled: true});
const {res, next} = await run();
expect(res.status).toHaveBeenCalledWith(403);
expect(next).not.toHaveBeenCalled();
});
it('sets res.locals.admin and calls next() with the permission', async () => {
m.getSession.mockResolvedValue({user: {id: 'u1'}});
m.loadAccess.mockResolvedValue(userWith(app));
const {res, next} = await run();
expect(next).toHaveBeenCalled();
expect(res.locals.admin).toMatchObject({id: 'u1', email: 'a@nachklang.art', displayName: 'Anna Admin'});
});
// Weaker than it looks and kept deliberately: neither module imports
// checkSession any more, so this cannot fail today. It is a tripwire for
// the change that would matter - someone reintroducing a header-session
// fallback "just for the calendar users who have not been invited yet",
// which is exactly the shortcut the cutover exists to close.
it('never falls back to a calendar header session', async () => {
m.getSession.mockResolvedValue(null);
await run();
expect(m.checkSession).not.toHaveBeenCalled();
});
});
};