7aac07a013
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>
166 lines
5.7 KiB
TypeScript
166 lines
5.7 KiB
TypeScript
import {vi, describe, it, expect, beforeEach, type Mock} from 'vitest';
|
|
import express from 'express';
|
|
import request from 'supertest';
|
|
|
|
vi.mock('../../src/models/admin/users/users.admin.service.js', () => ({
|
|
listUsers: vi.fn(),
|
|
getUserDetail: vi.fn(),
|
|
loadAccess: vi.fn(),
|
|
setPermissions: vi.fn(),
|
|
disableUser: vi.fn(),
|
|
enableUser: vi.fn(),
|
|
revokeSession: vi.fn(),
|
|
countActiveAdmins: vi.fn(),
|
|
userExists: vi.fn()
|
|
}));
|
|
|
|
import * as UsersService from '../../src/models/admin/users/users.admin.service.js';
|
|
import {usersAdminRouter} from '../../src/models/admin/users/users.admin.router.js';
|
|
|
|
const service = UsersService as unknown as Record<string, Mock>;
|
|
|
|
// The router always runs behind requireAppAccess('admin'), which is what puts
|
|
// res.locals.admin there; this stands in for it.
|
|
const makeApp = (callerId = 'me') => {
|
|
const app = express();
|
|
app.use(express.json());
|
|
app.use((req, res, next) => {
|
|
res.locals.admin = {id: callerId, email: 'me@nachklang.art', displayName: 'Me', apps: ['admin']};
|
|
next();
|
|
});
|
|
app.use('/admin/users', usersAdminRouter);
|
|
return app;
|
|
};
|
|
|
|
beforeEach(() => {
|
|
for (const fn of Object.values(service)) {
|
|
if (typeof fn?.mockReset === 'function') {
|
|
fn.mockReset();
|
|
}
|
|
}
|
|
service.getUserDetail.mockResolvedValue({id: 'other', apps: []});
|
|
service.userExists.mockResolvedValue(true);
|
|
});
|
|
|
|
describe('PUT /admin/users/:id/permissions', () => {
|
|
it('rejects an unknown app name', async () => {
|
|
const res = await request(makeApp()).put('/admin/users/other/permissions').send({apps: ['calendar', 'nope']});
|
|
|
|
expect(res.status).toBe(400);
|
|
expect(service.setPermissions).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('rejects a non-array body', async () => {
|
|
const res = await request(makeApp()).put('/admin/users/other/permissions').send({apps: 'admin'});
|
|
|
|
expect(res.status).toBe(400);
|
|
});
|
|
|
|
it('404s for an unknown user', async () => {
|
|
service.userExists.mockResolvedValue(false);
|
|
|
|
const res = await request(makeApp()).put('/admin/users/ghost/permissions').send({apps: []});
|
|
|
|
expect(res.status).toBe(404);
|
|
expect(service.setPermissions).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('refuses to remove the caller\'s own admin permission', async () => {
|
|
service.loadAccess.mockResolvedValue({id: 'me', disabled: false, apps: ['admin']});
|
|
service.countActiveAdmins.mockResolvedValue(5);
|
|
|
|
const res = await request(makeApp('me')).put('/admin/users/me/permissions').send({apps: ['feedback']});
|
|
|
|
expect(res.status).toBe(409);
|
|
expect(service.setPermissions).not.toHaveBeenCalled();
|
|
});
|
|
|
|
// Defence in depth: with the caller themselves being an active admin this
|
|
// count cannot actually reach 1 in production, but the guard is what makes
|
|
// that safe to rely on rather than to reason about.
|
|
it('refuses to remove the last remaining active admin', async () => {
|
|
service.loadAccess.mockResolvedValue({id: 'other', disabled: false, apps: ['admin']});
|
|
service.countActiveAdmins.mockResolvedValue(1);
|
|
|
|
const res = await request(makeApp('me')).put('/admin/users/other/permissions').send({apps: []});
|
|
|
|
expect(res.status).toBe(409);
|
|
expect(service.setPermissions).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('allows removing an admin while another active admin remains', async () => {
|
|
service.loadAccess.mockResolvedValue({id: 'other', disabled: false, apps: ['admin']});
|
|
service.countActiveAdmins.mockResolvedValue(2);
|
|
|
|
const res = await request(makeApp('me')).put('/admin/users/other/permissions').send({apps: ['tickets']});
|
|
|
|
expect(res.status).toBe(200);
|
|
expect(service.setPermissions).toHaveBeenCalledWith('other', ['tickets'], 'me');
|
|
});
|
|
|
|
it('allows granting permissions to someone who has none', async () => {
|
|
service.loadAccess.mockResolvedValue({id: 'other', disabled: false, apps: []});
|
|
|
|
const res = await request(makeApp('me')).put('/admin/users/other/permissions').send({apps: ['feedback', 'tickets']});
|
|
|
|
expect(res.status).toBe(200);
|
|
// Nothing is being taken away, so the last-admin count is not consulted.
|
|
expect(service.countActiveAdmins).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe('POST /admin/users/:id/disable', () => {
|
|
it('refuses to disable the caller', async () => {
|
|
const res = await request(makeApp('me')).post('/admin/users/me/disable');
|
|
|
|
expect(res.status).toBe(409);
|
|
expect(service.disableUser).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('refuses to disable the last active admin', async () => {
|
|
service.loadAccess.mockResolvedValue({id: 'other', disabled: false, apps: ['admin']});
|
|
service.countActiveAdmins.mockResolvedValue(1);
|
|
|
|
const res = await request(makeApp('me')).post('/admin/users/other/disable');
|
|
|
|
expect(res.status).toBe(409);
|
|
expect(service.disableUser).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('disables a non-admin user', async () => {
|
|
service.loadAccess.mockResolvedValue({id: 'other', disabled: false, apps: ['feedback']});
|
|
|
|
const res = await request(makeApp('me')).post('/admin/users/other/disable');
|
|
|
|
expect(res.status).toBe(200);
|
|
expect(service.disableUser).toHaveBeenCalledWith('other');
|
|
});
|
|
|
|
it('404s for an unknown user', async () => {
|
|
service.loadAccess.mockResolvedValue(null);
|
|
|
|
const res = await request(makeApp('me')).post('/admin/users/ghost/disable');
|
|
|
|
expect(res.status).toBe(404);
|
|
});
|
|
});
|
|
|
|
describe('DELETE /admin/users/:id/sessions/:sid', () => {
|
|
it('404s when the session does not belong to that user', async () => {
|
|
service.revokeSession.mockResolvedValue(false);
|
|
|
|
const res = await request(makeApp()).delete('/admin/users/other/sessions/s1');
|
|
|
|
expect(res.status).toBe(404);
|
|
});
|
|
|
|
it('204s on a successful revoke', async () => {
|
|
service.revokeSession.mockResolvedValue(true);
|
|
|
|
const res = await request(makeApp()).delete('/admin/users/other/sessions/s1');
|
|
|
|
expect(res.status).toBe(204);
|
|
expect(service.revokeSession).toHaveBeenCalledWith('other', 's1');
|
|
});
|
|
});
|