Files
API/test/integration/admin.users.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

256 lines
9.1 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 {bootstrapAdmin} from '../../src/models/admin/admin.bootstrap.js';
import {closeDatabase, createAndAcceptInvitation, resetDatabase} from './helpers.js';
let app: Application;
beforeAll(() => {
app = createApp();
});
beforeEach(async () => {
await resetDatabase();
});
afterAll(async () => {
await closeDatabase();
});
/** Most tests here need somebody who may administer. */
const signedInAdmin = async (email = 'admin@nachklang.art') => {
return createAndAcceptInvitation(app, email, 'Admin', ['admin']);
};
describe('GET /admin/users', () => {
it('lists users with their permissions and derived status', async () => {
const {agent} = await signedInAdmin();
await createAndAcceptInvitation(app, 'user@nachklang.art', 'User', ['feedback']);
const res = await agent.get('/admin/users');
expect(res.status).toBe(200);
const listed = res.body.find((u: any) => u.email === 'user@nachklang.art');
expect(listed.apps).toEqual(['feedback']);
expect(listed.status).toBe('aktiv');
expect(listed.lastSignInAt).not.toBeNull();
});
it('shows a disabled user as deaktiviert', async () => {
const {agent} = await signedInAdmin();
const other = await createAndAcceptInvitation(app, 'user@nachklang.art', 'User', ['feedback']);
await UsersService.disableUser(other.userId);
const res = await agent.get('/admin/users');
const listed = res.body.find((u: any) => u.email === 'user@nachklang.art');
expect(listed.status).toBe('deaktiviert');
});
});
describe('GET /admin/users/:id', () => {
it('returns active sessions and the passkey count', async () => {
const {agent} = await signedInAdmin();
const other = await createAndAcceptInvitation(app, 'user@nachklang.art', 'User', ['feedback']);
const res = await agent.get(`/admin/users/${other.userId}`);
expect(res.status).toBe(200);
expect(res.body.sessions.length).toBe(1);
expect(res.body.passkeyCount).toBe(0);
});
it('404s for an unknown id', async () => {
const {agent} = await signedInAdmin();
expect((await agent.get('/admin/users/does-not-exist')).status).toBe(404);
});
});
describe('permission changes', () => {
it('replaces the permission set', async () => {
const {agent} = await signedInAdmin();
const other = await createAndAcceptInvitation(app, 'user@nachklang.art', 'User', ['feedback']);
const res = await agent
.put(`/admin/users/${other.userId}/permissions`)
.send({apps: ['tickets', 'calendar']});
expect(res.status).toBe(200);
const access = await UsersService.loadAccess(other.userId);
expect(access?.apps.sort()).toEqual(['calendar', 'tickets']);
});
it('takes effect on the next request the affected user makes', async () => {
const {agent} = await signedInAdmin();
const other = await createAndAcceptInvitation(app, 'user@nachklang.art', 'User', ['admin']);
expect((await other.agent.get('/admin/users')).status).toBe(200);
await agent.put(`/admin/users/${other.userId}/permissions`).send({apps: ['feedback']});
// No cookie cache: the very next request is already denied, on the same
// still-valid session cookie.
expect((await other.agent.get('/admin/users')).status).toBe(403);
});
it('refuses to strip the last admin', async () => {
const {agent, userId} = await signedInAdmin();
const res = await agent.put(`/admin/users/${userId}/permissions`).send({apps: ['feedback']});
expect(res.status).toBe(409);
expect((await UsersService.loadAccess(userId))?.apps).toContain('admin');
});
it('refuses to disable the caller themselves', async () => {
const {agent, userId} = await signedInAdmin();
const res = await agent.post(`/admin/users/${userId}/disable`);
expect(res.status).toBe(409);
expect((await UsersService.loadAccess(userId))?.disabled).toBe(false);
});
it('allows disabling a second admin', async () => {
const {agent} = await signedInAdmin();
const second = await createAndAcceptInvitation(app, 'admin2@nachklang.art', 'Admin2', ['admin']);
expect((await agent.post(`/admin/users/${second.userId}/disable`)).status).toBe(200);
expect((await second.agent.get('/admin/me')).status).toBe(401);
});
it('re-enables a disabled user without restoring their old sessions', async () => {
const {agent} = await signedInAdmin();
const other = await createAndAcceptInvitation(app, 'user@nachklang.art', 'User', ['feedback']);
await agent.post(`/admin/users/${other.userId}/disable`);
expect((await agent.post(`/admin/users/${other.userId}/enable`)).status).toBe(200);
expect((await UsersService.loadAccess(other.userId))?.disabled).toBe(false);
// The revoked session stays revoked; they sign in again.
expect((await other.agent.get('/admin/me')).status).toBe(401);
});
});
describe('session revocation', () => {
it('revokes one session of another user', async () => {
const {agent} = await signedInAdmin();
const other = await createAndAcceptInvitation(app, 'user@nachklang.art', 'User', ['feedback']);
const detail = await agent.get(`/admin/users/${other.userId}`);
const sessionId = detail.body.sessions[0].id;
const res = await agent.delete(`/admin/users/${other.userId}/sessions/${sessionId}`);
expect(res.status).toBe(204);
expect((await other.agent.get('/admin/me')).status).toBe(401);
});
});
describe('invitations', () => {
it('creates one and lists it as open', async () => {
const {agent} = await signedInAdmin();
const created = await agent
.post('/admin/invitations')
.send({email: 'new@nachklang.art', name: 'New', apps: ['feedback']});
expect(created.status).toBe(201);
// Mail is disabled in tests, and the token must never be returned.
expect(created.body.token).toBeUndefined();
const list = await agent.get('/admin/invitations');
expect(list.body.map((i: any) => i.email)).toContain('new@nachklang.art');
});
it('refuses to invite an address that already has an account', async () => {
const {agent} = await signedInAdmin();
await createAndAcceptInvitation(app, 'user@nachklang.art', 'User', ['feedback']);
const res = await agent
.post('/admin/invitations')
.send({email: 'user@nachklang.art', name: 'User', apps: ['feedback']});
expect(res.status).toBe(409);
});
it('rejects an invalid email or app name', async () => {
const {agent} = await signedInAdmin();
expect((await agent.post('/admin/invitations').send({email: 'nope', name: 'X', apps: []})).status).toBe(400);
expect((await agent.post('/admin/invitations').send({email: 'a@b.de', name: 'X', apps: ['nope']})).status).toBe(400);
});
it('invalidates the previous link on resend', async () => {
const {agent} = await signedInAdmin();
const original = await InvitationsService.createInvitation('new@nachklang.art', 'New', ['feedback'], null);
const resent = await agent.post(`/admin/invitations/${original.id}/resend`);
expect(resent.status).toBe(200);
const oldLink = await request(app)
.post('/admin/auth/invitations/preview')
.send({token: original.token});
expect(oldLink.status).toBeGreaterThanOrEqual(400);
});
it('revokes an invitation', async () => {
const {agent} = await signedInAdmin();
const invitation = await InvitationsService.createInvitation('new@nachklang.art', 'New', ['feedback'], null);
expect((await agent.delete(`/admin/invitations/${invitation.id}`)).status).toBe(204);
expect((await agent.delete(`/admin/invitations/${invitation.id}`)).status).toBe(404);
const preview = await request(app)
.post('/admin/auth/invitations/preview')
.send({token: invitation.token});
expect(preview.status).toBeGreaterThanOrEqual(400);
});
});
describe('bootstrap', () => {
it('creates an admin invitation on an empty database', async () => {
await bootstrapAdmin();
expect(await InvitationsService.hasOpenInvitationFor('boot@nachklang.art')).toBe(true);
});
it('is idempotent across restarts', async () => {
await bootstrapAdmin();
await bootstrapAdmin();
const open = await InvitationsService.listOpenInvitations();
expect(open.filter(i => i.email === 'boot@nachklang.art').length).toBe(1);
});
it('grants admin to an address that already has an account', async () => {
const user = await createAndAcceptInvitation(app, 'boot@nachklang.art', 'Boot', ['feedback']);
await bootstrapAdmin();
expect((await UsersService.loadAccess(user.userId))?.apps).toContain('admin');
});
it('does nothing once an active admin exists', async () => {
await signedInAdmin();
await bootstrapAdmin();
expect(await InvitationsService.hasOpenInvitationFor('boot@nachklang.art')).toBe(false);
});
});
describe('passkey endpoints', () => {
it('requires a session to list passkeys', async () => {
const anonymous = await request(app).get('/admin/auth/passkey/list-user-passkeys');
expect(anonymous.status).toBeGreaterThanOrEqual(400);
const {agent} = await signedInAdmin();
const res = await agent.get('/admin/auth/passkey/list-user-passkeys');
expect(res.status).toBe(200);
expect(res.body).toEqual([]);
});
});