Files
API/test/admin/auth-binding.ts
T
Paddy aa95ab2745 Drop the calendar's legacy authentication path
Step 5, the last one, of docs/calendar-auth-migration.md. Step 4 is deployed
and verified, which is what this was waiting on: it removes the fallbacks that
step 4 still leaned on.

Gone: src/models/calendar/users/ entirely - registration, login, activation,
both password-reset routes, and the session checking that the feedback and
tickets admin areas used to authenticate against - along with its mount. That
was the API's last unauthenticated account-creation and mail-sending endpoint.
A survey confirmed nothing outside that directory imported it and nothing else
touched its tables.

Also gone: the two joins against the calendar users table in events.service.ts
and the created_by_id / version_created_by_id columns they read, from the SQL,
the row mapper, the Event interface and the swagger schema; and X-Session-Id /
X-Session-Key from the CORS allowedHeaders, which nothing has read since the
first cutover and nothing has sent since the second.

An event's author still renders, because migration 002 snapshotted the names
before this could erase them. That was brought forward from this step on
purpose, and it is the reason 004 can rename the accounts aside at all.

The accounts are renamed rather than dropped - they still hold e-mail addresses
and password hashes, and a rename makes them unreachable without destroying
anything. InnoDB rewires the sessions foreign key to the new name; verified on
MariaDB 11, along with the whole 001-004 chain from the pre-cutover production
schema, which lands byte-identical to a fresh dev database.

Migration 004 must be applied AFTER deploying, not before - the reverse of step
4, whose migration only added things. Its own header and the runbook both say
so, since getting it wrong by analogy is the obvious mistake.

DEFERRED_SECURITY.md items 3 and 4 close with it: the activation and reset
tokens that never expired are gone along with the code that issued them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 23:32:59 +02:00

108 lines
3.3 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. The mocks live in
* the calling file because vi.mock is per-module-graph; only the assertions are
* shared.
*
* There used to be a tripwire here asserting neither module fell back to the
* calendar's header sessions. It went with step 5: the calendar users service
* no longer exists, so there is nothing left to fall back to and nothing to
* assert against.
*/
export interface BindingMocks {
/** auth.api.getSession from the mocked admin.auth.js */
getSession: Mock;
/** loadAccess from the mocked users.admin.service.js */
loadAccess: 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();
});
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'});
});
});
};