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>
This commit is contained in:
@@ -0,0 +1,272 @@
|
||||
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');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,255 @@
|
||||
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([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
import {expect} from 'vitest';
|
||||
import type {Application} from 'express';
|
||||
import request from 'supertest';
|
||||
import {NachklangAdminDB} from '../../src/models/admin/Admin.db.js';
|
||||
import * as InvitationsService from '../../src/models/admin/invitations/invitations.service.js';
|
||||
import {AppName} from '../../src/models/admin/admin.schema.js';
|
||||
|
||||
const db = NachklangAdminDB.db;
|
||||
|
||||
// Dev/test cookie name: advanced.cookiePrefix is 'nachklang', and the __Secure-
|
||||
// prefix is only added over https.
|
||||
export const SESSION_COOKIE = 'nachklang.session_token';
|
||||
|
||||
/** Wipes every table between test files. Child tables first - the FKs to
|
||||
* `user` are ON DELETE CASCADE, but rateLimit and invitations are not. */
|
||||
export const resetDatabase = async (): Promise<void> => {
|
||||
await db.deleteFrom('session').execute();
|
||||
await db.deleteFrom('user_app_permissions').execute();
|
||||
await db.deleteFrom('passkey').execute();
|
||||
await db.deleteFrom('invitations').execute();
|
||||
await db.deleteFrom('user').execute();
|
||||
};
|
||||
|
||||
export const closeDatabase = async (): Promise<void> => {
|
||||
await db.destroy();
|
||||
};
|
||||
|
||||
/**
|
||||
* Creates an invitation straight through the service (so the test gets the raw
|
||||
* token, which the API deliberately never returns) and redeems it through the
|
||||
* public endpoint. Returns an agent that carries the resulting session cookie.
|
||||
*/
|
||||
export const createAndAcceptInvitation = async (
|
||||
app: Application,
|
||||
email: string,
|
||||
name: string,
|
||||
apps: AppName[],
|
||||
password = 'devpassword123'
|
||||
) => {
|
||||
const invitation = await InvitationsService.createInvitation(email, name, apps, null);
|
||||
|
||||
const agent = request.agent(app);
|
||||
const res = await agent
|
||||
.post('/admin/auth/invitations/accept')
|
||||
.send({token: invitation.token, password});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
return {agent, userId: res.body.user.id, token: invitation.token};
|
||||
};
|
||||
|
||||
export const cookieHeader = (res: request.Response): string[] => {
|
||||
const raw = res.headers['set-cookie'];
|
||||
return Array.isArray(raw) ? raw : raw ? [raw] : [];
|
||||
};
|
||||
|
||||
export const sessionCookieFrom = (res: request.Response): string | undefined => {
|
||||
return cookieHeader(res).find(cookie => cookie.startsWith(SESSION_COOKIE));
|
||||
};
|
||||
@@ -0,0 +1,107 @@
|
||||
import {execFile} from 'child_process';
|
||||
import {promisify} from 'util';
|
||||
import {createRequire} from 'module';
|
||||
|
||||
const run = promisify(execFile);
|
||||
const require = createRequire(import.meta.url);
|
||||
|
||||
/**
|
||||
* vitest globalSetup for the admin integration tests: starts a throwaway
|
||||
* MariaDB before the suite and removes it afterwards, so a run leaves nothing
|
||||
* behind and never touches a shared database.
|
||||
*
|
||||
* The container is started directly rather than through compose, because
|
||||
* `podman compose` needs a separate compose provider that neither podman nor
|
||||
* docker ships. One container needs no orchestration, and this works with
|
||||
* whichever of the two runtimes is installed.
|
||||
*/
|
||||
|
||||
export const CONTAINER_NAME = 'nachklang-admin-test-db';
|
||||
export const TEST_DB_PORT = 3307;
|
||||
|
||||
const IMAGE = 'docker.io/library/mariadb:11';
|
||||
|
||||
const runtime = async (): Promise<string> => {
|
||||
for (const candidate of ['docker', 'podman']) {
|
||||
try {
|
||||
await run(candidate, ['info'], {timeout: 60_000});
|
||||
return candidate;
|
||||
} catch {
|
||||
// Not installed, or its daemon/machine is not running - try the next.
|
||||
}
|
||||
}
|
||||
throw new Error(
|
||||
'The admin integration tests need a container runtime. Install docker or podman ' +
|
||||
'(with podman: `podman machine start`), then re-run npm run test:integration.'
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Ready means "the entrypoint has applied 001_init.sql", not just "the port
|
||||
* answers": MariaDB accepts connections while it is still running its init
|
||||
* scripts, and a test that started then would fail on a missing table.
|
||||
*/
|
||||
const waitForSchema = async (): Promise<void> => {
|
||||
const mysql = require('mysql2/promise');
|
||||
const deadline = Date.now() + 120_000;
|
||||
let lastError: unknown;
|
||||
|
||||
while (Date.now() < deadline) {
|
||||
try {
|
||||
const connection = await mysql.createConnection({
|
||||
host: '127.0.0.1',
|
||||
port: TEST_DB_PORT,
|
||||
user: 'nachklang',
|
||||
password: 'testpassword',
|
||||
database: 'nachklang_admin',
|
||||
connectTimeout: 5_000
|
||||
});
|
||||
const [rows] = await connection.query(
|
||||
"SELECT COUNT(*) AS n FROM information_schema.tables " +
|
||||
"WHERE table_schema = 'nachklang_admin' AND table_name IN ('user', 'user_app_permissions', 'invitations')"
|
||||
);
|
||||
await connection.end();
|
||||
if (Number((rows as any[])[0]?.n) === 3) {
|
||||
return;
|
||||
}
|
||||
lastError = new Error('schema not applied yet');
|
||||
} catch (e) {
|
||||
lastError = e;
|
||||
}
|
||||
await new Promise(resolve => setTimeout(resolve, 1_000));
|
||||
}
|
||||
|
||||
throw new Error(`Test database never became ready: ${(lastError as any)?.message}`);
|
||||
};
|
||||
|
||||
export const setup = async () => {
|
||||
const engine = await runtime();
|
||||
|
||||
// A container left behind by an interrupted run would still hold the old
|
||||
// schema and rows, so always start from scratch.
|
||||
await run(engine, ['rm', '-f', CONTAINER_NAME], {timeout: 60_000}).catch(() => undefined);
|
||||
|
||||
await run(engine, [
|
||||
'run', '-d',
|
||||
'--name', CONTAINER_NAME,
|
||||
'-e', 'MARIADB_ROOT_PASSWORD=roottestpassword',
|
||||
'-e', 'MARIADB_DATABASE=nachklang_admin',
|
||||
'-e', 'MARIADB_USER=nachklang',
|
||||
'-e', 'MARIADB_PASSWORD=testpassword',
|
||||
'-p', `${TEST_DB_PORT}:3306`,
|
||||
// The very migration production runs, applied by the entrypoint on first
|
||||
// boot - so a mistake in it fails the test run rather than the deploy.
|
||||
'-v', `${process.cwd()}/sql/admin/001_init.sql:/docker-entrypoint-initdb.d/001_init.sql:ro`,
|
||||
// Data lives in the container layer and dies with it.
|
||||
IMAGE
|
||||
], {timeout: 300_000});
|
||||
|
||||
await waitForSchema();
|
||||
};
|
||||
|
||||
export const teardown = async () => {
|
||||
const engine = await runtime().catch(() => null);
|
||||
if (engine) {
|
||||
await run(engine, ['rm', '-f', CONTAINER_NAME], {timeout: 60_000}).catch(() => undefined);
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user