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'}); }); it('never falls back to a calendar header session', async () => { m.getSession.mockResolvedValue(null); await run(); expect(m.checkSession).not.toHaveBeenCalled(); }); }); };