Files
API/test/integration/admin.auth.test.ts
T
Paddy 7aac07a013 Add admin identity module: better-auth, per-app permissions, invitations
Introduces src/models/admin/, a dedicated identity and permissions module on
its own nachklang_admin database, and the shared authenticator that feedback
and tickets will move onto in the cutover step. Nothing swaps over yet:
feedback.auth.ts and tickets.auth.ts still authenticate against the legacy
calendar sessions, so production behaviour is unchanged.

- better-auth 1.7 mounted at /admin/auth/*, sessions as httpOnly cookies
  scoped to .nachklang.art so one sign-in covers every *.nachklang.art app.
- Accounts are invite-only: public sign-up is disabled, and the invitations
  plugin is the only code that creates users. Tokens are stored as SHA-256
  hashes and travel in the request body, never in a URL.
- Per-app permissions in user_app_permissions; requireAppAccess(app) queries
  the database on every request (no cookie cache) so disabling a user or
  revoking a session takes effect immediately.
- ADMIN_BOOTSTRAP_EMAIL guarantees a way in on an empty database, idempotently
  and without crashing the API if the database is unreachable at boot.
- Guards prevent an admin from removing their own admin permission, disabling
  themselves, or stripping the last active admin.

The admin pool uses the callback-style mysql2, not mysql2/promise: Kysely's
MysqlDialect drives the pool with callbacks, and the promise wrapper ignores
them, so every query hangs silently. Only the integration tests caught this.

Schema in sql/admin/001_init.sql, derived from getAuthTables() on the
installed better-auth rather than the published CLI, which lags the library
and omits account.issuer.

app.ts is split into src/app.factory.ts so the integration tests drive the
real middleware order rather than a copy of it.

Tests: 131 unit, plus 41 integration tests against a throwaway MariaDB
started by test/integration/setup.ts (docker or podman).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-05 18:03:08 +02:00

273 lines
9.8 KiB
TypeScript

import {describe, it, expect, beforeAll, beforeEach, afterAll} from 'vitest';
import request from 'supertest';
import type {Application} from 'express';
import {createApp} from '../../src/app.factory.js';
import * as InvitationsService from '../../src/models/admin/invitations/invitations.service.js';
import * as UsersService from '../../src/models/admin/users/users.admin.service.js';
import {
closeDatabase,
createAndAcceptInvitation,
resetDatabase,
sessionCookieFrom,
SESSION_COOKIE
} from './helpers.js';
/**
* End-to-end against a real MariaDB (docker-compose.test.yml) and the real
* Express wiring from app.factory.ts. Mocks would not catch what this module
* can actually get wrong: the Kysely MySQL dialect, cookie attributes, and the
* middleware order that lets better-auth read the raw request body.
*/
let app: Application;
beforeAll(() => {
app = createApp();
});
beforeEach(async () => {
await resetDatabase();
});
afterAll(async () => {
await closeDatabase();
});
describe('sign-up is closed', () => {
it('refuses the public sign-up endpoint', async () => {
const res = await request(app)
.post('/admin/auth/sign-up/email')
.send({email: 'stranger@example.com', password: 'password123', name: 'Stranger'});
expect(res.status).toBeGreaterThanOrEqual(400);
expect(await UsersService.findUserByEmail('stranger@example.com')).toBeNull();
});
});
describe('invitation acceptance', () => {
it('creates the user, its permissions and a session cookie', async () => {
const {agent, userId} = await createAndAcceptInvitation(
app,
'anna@nachklang.art',
'Anna',
['feedback', 'tickets']
);
const access = await UsersService.loadAccess(userId);
expect(access?.email).toBe('anna@nachklang.art');
expect(access?.apps.sort()).toEqual(['feedback', 'tickets']);
expect(access?.disabled).toBe(false);
// The cookie works on a subsequent request.
const me = await agent.get('/admin/me');
expect(me.status).toBe(200);
expect(me.body.email).toBe('anna@nachklang.art');
expect(me.body.apps.sort()).toEqual(['feedback', 'tickets']);
});
it('sets the session cookie under the configured prefix', async () => {
const invitation = await InvitationsService.createInvitation('b@nachklang.art', 'B', ['feedback'], null);
const res = await request(app)
.post('/admin/auth/invitations/accept')
.send({token: invitation.token, password: 'devpassword123'});
const cookie = sessionCookieFrom(res);
expect(cookie).toBeDefined();
expect(cookie).toContain('HttpOnly');
});
it('lets the new account sign in with the password it just set', async () => {
await createAndAcceptInvitation(app, 'c@nachklang.art', 'C', ['feedback'], 'my-password-1');
const res = await request(app)
.post('/admin/auth/sign-in/email')
.send({email: 'c@nachklang.art', password: 'my-password-1'});
expect(res.status).toBe(200);
expect(sessionCookieFrom(res)).toBeDefined();
});
it('previews an invitation without revealing the granted apps', async () => {
const invitation = await InvitationsService.createInvitation('d@nachklang.art', 'D', ['admin'], null);
const res = await request(app)
.post('/admin/auth/invitations/preview')
.send({token: invitation.token});
expect(res.status).toBe(200);
expect(res.body).toEqual({email: 'd@nachklang.art', name: 'D'});
});
it('answers an unknown token exactly like an expired one', async () => {
const invitation = await InvitationsService.createInvitation('e@nachklang.art', 'E', ['feedback'], null);
await InvitationsService.revokeInvitation(invitation.id);
const unknown = await request(app).post('/admin/auth/invitations/preview').send({token: 'no-such-token'});
const revoked = await request(app).post('/admin/auth/invitations/preview').send({token: invitation.token});
expect(unknown.status).toBe(revoked.status);
expect(unknown.body).toEqual(revoked.body);
});
it('cannot be redeemed twice', async () => {
const invitation = await InvitationsService.createInvitation('f@nachklang.art', 'F', ['feedback'], null);
const first = await request(app)
.post('/admin/auth/invitations/accept')
.send({token: invitation.token, password: 'devpassword123'});
const second = await request(app)
.post('/admin/auth/invitations/accept')
.send({token: invitation.token, password: 'devpassword123'});
expect(first.status).toBe(200);
expect(second.status).toBeGreaterThanOrEqual(400);
});
it('rejects an expired invitation', async () => {
const invitation = await InvitationsService.createInvitation('g@nachklang.art', 'G', ['feedback'], null);
// Reach past the service to age it: there is deliberately no API for this.
const {NachklangAdminDB} = await import('../../src/models/admin/Admin.db.js');
await NachklangAdminDB.db
.updateTable('invitations')
.set({expires_at: new Date(Date.now() - 1000)})
.where('id', '=', invitation.id)
.execute();
const res = await request(app)
.post('/admin/auth/invitations/accept')
.send({token: invitation.token, password: 'devpassword123'});
expect(res.status).toBeGreaterThanOrEqual(400);
});
it('rejects a password below the minimum length', async () => {
const invitation = await InvitationsService.createInvitation('h@nachklang.art', 'H', ['feedback'], null);
const res = await request(app)
.post('/admin/auth/invitations/accept')
.send({token: invitation.token, password: 'short'});
expect(res.status).toBeGreaterThanOrEqual(400);
expect(await UsersService.findUserByEmail('h@nachklang.art')).toBeNull();
});
});
describe('sessions', () => {
it('signs out and stops accepting the cookie', async () => {
const {agent} = await createAndAcceptInvitation(app, 'i@nachklang.art', 'I', ['feedback']);
expect((await agent.get('/admin/me')).status).toBe(200);
const signOut = await agent.post('/admin/auth/sign-out').send({});
expect(signOut.status).toBe(200);
expect((await agent.get('/admin/me')).status).toBe(401);
});
it('rejects a disabled user who still holds a valid cookie', async () => {
const {agent, userId} = await createAndAcceptInvitation(app, 'j@nachklang.art', 'J', ['feedback']);
// Strip the permission check out of the picture: disable without going
// through disableUser's session revocation, so the cookie stays live.
const {NachklangAdminDB} = await import('../../src/models/admin/Admin.db.js');
await NachklangAdminDB.db.updateTable('user').set({disabled: true}).where('id', '=', userId).execute();
const res = await agent.get('/admin/me');
expect(res.status).toBe(403);
});
it('disabling a user revokes their sessions immediately', async () => {
const {agent, userId} = await createAndAcceptInvitation(app, 'k@nachklang.art', 'K', ['feedback']);
await UsersService.disableUser(userId);
const res = await agent.get('/admin/me');
expect(res.status).toBe(401);
});
it('refuses to sign a disabled user back in', async () => {
const {userId} = await createAndAcceptInvitation(app, 'l@nachklang.art', 'L', ['feedback'], 'my-password-1');
await UsersService.disableUser(userId);
const res = await request(app)
.post('/admin/auth/sign-in/email')
.send({email: 'l@nachklang.art', password: 'my-password-1'});
expect(res.status).toBeGreaterThanOrEqual(400);
expect(sessionCookieFrom(res)).toBeUndefined();
});
});
describe('requireAppAccess', () => {
it('401s an anonymous request', async () => {
expect((await request(app).get('/admin/me')).status).toBe(401);
expect((await request(app).get('/admin/users')).status).toBe(401);
});
it('403s a signed-in user without the admin permission', async () => {
const {agent} = await createAndAcceptInvitation(app, 'm@nachklang.art', 'M', ['feedback']);
const res = await agent.get('/admin/users');
expect(res.status).toBe(403);
});
it('lets an admin through', async () => {
const {agent} = await createAndAcceptInvitation(app, 'n@nachklang.art', 'N', ['admin']);
const res = await agent.get('/admin/users');
expect(res.status).toBe(200);
expect(Array.isArray(res.body)).toBe(true);
});
// Step 2 deliberately does NOT swap the feedback and tickets authenticators:
// they still authenticate against the legacy calendar sessions, so an admin
// cookie means nothing to them yet. This asserts that boundary rather than
// the end state - when step 4 lands, these two expectations become 200/403
// and this comment goes away.
it('leaves the feedback and tickets admin areas on their legacy authenticator', async () => {
const user = await createAndAcceptInvitation(app, 'o@nachklang.art', 'O', ['feedback', 'tickets']);
expect((await user.agent.get('/feedback/admin/me')).status).toBe(401);
expect((await user.agent.get('/tickets/admin/me')).status).toBe(401);
});
});
describe('origin checks', () => {
it('rejects a cookie-bearing request from an untrusted origin', async () => {
const {agent} = await createAndAcceptInvitation(app, 'p@nachklang.art', 'P', ['admin']);
const res = await agent
.post('/admin/auth/sign-out')
.set('Origin', 'https://evil.example')
.send({});
expect(res.status).toBeGreaterThanOrEqual(400);
});
it('accepts the admin app origin', async () => {
const {agent} = await createAndAcceptInvitation(app, 'q@nachklang.art', 'Q', ['admin']);
const res = await agent
.post('/admin/auth/sign-out')
.set('Origin', 'http://localhost:3002')
.send({});
expect(res.status).toBe(200);
});
});
describe('the session cookie is not readable by scripts', () => {
it('is HttpOnly and SameSite=Lax', async () => {
const invitation = await InvitationsService.createInvitation('r@nachklang.art', 'R', ['feedback'], null);
const res = await request(app)
.post('/admin/auth/invitations/accept')
.send({token: invitation.token, password: 'devpassword123'});
const cookie = sessionCookieFrom(res) || '';
expect(cookie).toContain(SESSION_COOKIE);
expect(cookie).toContain('HttpOnly');
expect(cookie.toLowerCase()).toContain('samesite=lax');
});
});