Add admin identity module: better-auth, per-app permissions, invitations (#12)
Jenkins Production Deployment

Reviewed-on: #12
Co-authored-by: Patrick Müller <mail@pmueller.me>
Co-committed-by: Patrick Müller <mail@pmueller.me>
This commit was merged in pull request #12.
This commit is contained in:
2026-09-06 10:41:54 +00:00
committed by Patrick Müller
parent ce9b173c71
commit bf7be65b03
39 changed files with 6551 additions and 320 deletions
+96
View File
@@ -0,0 +1,96 @@
import {vi, describe, it, expect, beforeEach, type Mock} from 'vitest';
vi.mock('../../src/models/admin/users/users.admin.service.js', () => ({
countActiveAdmins: vi.fn(),
findUserByEmail: vi.fn(),
grantPermission: vi.fn()
}));
vi.mock('../../src/models/admin/invitations/invitations.service.js', () => ({
hasOpenInvitationFor: vi.fn(),
createInvitation: vi.fn()
}));
vi.mock('../../src/models/admin/admin.mail.js', () => ({
sendInvitationMail: vi.fn()
}));
vi.mock('../../src/models/admin/admin.config.js', () => ({
ADMIN_BOOTSTRAP_EMAIL: 'boss@nachklang.art',
ADMIN_APP_URL: 'http://localhost:3002',
isProd: false
}));
import * as UsersService from '../../src/models/admin/users/users.admin.service.js';
import * as InvitationsService from '../../src/models/admin/invitations/invitations.service.js';
import {sendInvitationMail} from '../../src/models/admin/admin.mail.js';
import {bootstrapAdmin} from '../../src/models/admin/admin.bootstrap.js';
const countActiveAdmins = UsersService.countActiveAdmins as Mock;
const findUserByEmail = UsersService.findUserByEmail as Mock;
const grantPermission = UsersService.grantPermission as Mock;
const hasOpenInvitationFor = InvitationsService.hasOpenInvitationFor as Mock;
const createInvitation = InvitationsService.createInvitation as Mock;
const mockMail = sendInvitationMail as Mock;
beforeEach(() => {
countActiveAdmins.mockReset();
findUserByEmail.mockReset();
grantPermission.mockReset();
hasOpenInvitationFor.mockReset();
createInvitation.mockReset();
mockMail.mockReset();
mockMail.mockResolvedValue(true);
createInvitation.mockResolvedValue({id: 1, token: 'raw-token', expiresAt: new Date()});
});
describe('bootstrapAdmin', () => {
it('does nothing when an active admin already exists', async () => {
countActiveAdmins.mockResolvedValue(1);
await bootstrapAdmin();
expect(createInvitation).not.toHaveBeenCalled();
expect(grantPermission).not.toHaveBeenCalled();
});
it('grants admin directly when the bootstrap address is already a user', async () => {
countActiveAdmins.mockResolvedValue(0);
findUserByEmail.mockResolvedValue({id: 'u9', email: 'boss@nachklang.art'});
await bootstrapAdmin();
expect(grantPermission).toHaveBeenCalledWith('u9', 'admin', null);
expect(createInvitation).not.toHaveBeenCalled();
});
it('does not re-invite (or re-mail) while an open invitation exists', async () => {
countActiveAdmins.mockResolvedValue(0);
findUserByEmail.mockResolvedValue(null);
hasOpenInvitationFor.mockResolvedValue(true);
await bootstrapAdmin();
expect(createInvitation).not.toHaveBeenCalled();
expect(mockMail).not.toHaveBeenCalled();
});
it('invites with the admin permission when there is nothing to work with', async () => {
countActiveAdmins.mockResolvedValue(0);
findUserByEmail.mockResolvedValue(null);
hasOpenInvitationFor.mockResolvedValue(false);
await bootstrapAdmin();
expect(createInvitation).toHaveBeenCalledWith(
'boss@nachklang.art',
'Nachklang Admin',
[{app: 'admin', role: 'access'}],
null
);
expect(mockMail).toHaveBeenCalled();
});
it('never throws when the database is unreachable at boot', async () => {
countActiveAdmins.mockRejectedValue(new Error('connect ECONNREFUSED'));
await expect(bootstrapAdmin()).resolves.toBeUndefined();
});
});
+109
View File
@@ -0,0 +1,109 @@
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
// admin.config calls dotenv.config(), which would read the repo's own .env and
// quietly reintroduce NODE_ENV=development - the exact value several of these
// cases exist to remove. Stub it so the tests see only what they set.
vi.mock('dotenv', () => ({config: vi.fn()}));
/**
* admin.config reads the environment once at import, so every case here has to
* reset the module registry and re-import it. The two things worth pinning are
* the ones that are silent when wrong: which client-IP header is trusted, and
* whether an unset NODE_ENV counts as production.
*/
const ORIGINAL_ENV = {...process.env};
const loadConfig = async () => {
vi.resetModules();
return import('../../src/models/admin/admin.config.js');
};
beforeEach(() => {
process.env = {...ORIGINAL_ENV};
// dotenv.config() in admin.config does not overwrite what is already set,
// so setting these here is enough to keep the local .env out of the test.
process.env.NODE_ENV = 'test';
delete process.env.CLIENT_IP_HEADERS;
delete process.env.TRUSTED_PROXY_IPS;
});
afterEach(() => {
process.env = {...ORIGINAL_ENV};
});
describe('CLIENT_IP_HEADERS', () => {
it('defaults to the single header Plesk nginx sets', async () => {
const config = await loadConfig();
expect(config.CLIENT_IP_HEADERS).toEqual(['x-real-ip']);
expect(config.TRUST_NO_CLIENT_IP_HEADER).toBe(false);
});
it('reads a comma-separated list', async () => {
process.env.CLIENT_IP_HEADERS = 'x-real-ip, cf-connecting-ip';
const config = await loadConfig();
expect(config.CLIENT_IP_HEADERS).toEqual(['x-real-ip', 'cf-connecting-ip']);
});
it('trusts nothing when set to "none"', async () => {
// The escape hatch. An empty list is what better-auth reads as "no
// headers" - it only falls back to its own default when the option is
// absent - so this really does stop any header being believed.
process.env.CLIENT_IP_HEADERS = 'none';
const config = await loadConfig();
expect(config.CLIENT_IP_HEADERS).toEqual([]);
expect(config.TRUST_NO_CLIENT_IP_HEADER).toBe(true);
});
it('accepts the hatch case-insensitively and with stray whitespace', async () => {
process.env.CLIENT_IP_HEADERS = ' NONE ';
const config = await loadConfig();
expect(config.CLIENT_IP_HEADERS).toEqual([]);
});
it('treats an empty value as "use the default", not as the hatch', async () => {
// A blank line in a .env must not silently change how requests are
// bucketed - only the explicit word does that.
process.env.CLIENT_IP_HEADERS = '';
const config = await loadConfig();
expect(config.CLIENT_IP_HEADERS).toEqual(['x-real-ip']);
expect(config.TRUST_NO_CLIENT_IP_HEADER).toBe(false);
});
it('does not mistake a header actually named none-ish for the hatch', async () => {
process.env.CLIENT_IP_HEADERS = 'x-none';
const config = await loadConfig();
expect(config.CLIENT_IP_HEADERS).toEqual(['x-none']);
expect(config.TRUST_NO_CLIENT_IP_HEADER).toBe(false);
});
});
describe('isProd', () => {
it('is false only for the explicit relaxed environments', async () => {
process.env.NODE_ENV = 'development';
expect((await loadConfig()).isProd).toBe(false);
process.env.NODE_ENV = 'test';
expect((await loadConfig()).isProd).toBe(false);
});
it('treats an unset NODE_ENV as production, which is what a bare vhost gives', async () => {
delete process.env.NODE_ENV;
// Strict mode refuses to boot without these; supply them so the import
// gets far enough to answer the question being asked.
process.env.BETTER_AUTH_SECRET = 'x'.repeat(48);
process.env.API_BASE_URL = 'https://api.nachklang.art';
process.env.ADMIN_APP_URL = 'https://admin.nachklang.art';
expect((await loadConfig()).isProd).toBe(true);
});
it('refuses to start without a signing key outside development', async () => {
delete process.env.NODE_ENV;
delete process.env.BETTER_AUTH_SECRET;
process.env.API_BASE_URL = 'https://api.nachklang.art';
process.env.ADMIN_APP_URL = 'https://admin.nachklang.art';
await expect(loadConfig()).rejects.toThrow(/BETTER_AUTH_SECRET/);
});
});
+71
View File
@@ -0,0 +1,71 @@
import {vi, describe, it, expect, beforeEach, type Mock} from 'vitest';
vi.mock('../../src/common/common.mail.js', () => ({
MailService: {sendMail: vi.fn()}
}));
vi.mock('../../src/models/admin/admin.config.js', () => ({
ADMIN_APP_URL: 'https://admin.nachklang.art'
}));
import {MailService} from '../../src/common/common.mail.js';
import {sendInvitationMail, sendPasswordResetMail} from '../../src/models/admin/admin.mail.js';
const sendMail = MailService.sendMail as Mock;
beforeEach(() => {
sendMail.mockReset();
sendMail.mockResolvedValue(true);
});
describe('sendInvitationMail', () => {
it('points at the admin app and carries the token in the query string', async () => {
await sendInvitationMail('a@nachklang.art', 'Anna', 'tok-en_123', new Date('2026-09-12T10:00:00Z'));
const [to, subject, text, options] = sendMail.mock.calls[0];
expect(to).toBe('a@nachklang.art');
expect(subject).toBeTruthy();
expect(text).toContain('https://admin.nachklang.art/accept-invite?token=tok-en_123');
expect(options.html).toContain('https://admin.nachklang.art/accept-invite?token=tok-en_123');
});
it('url-encodes a token containing url-significant characters', async () => {
await sendInvitationMail('a@nachklang.art', 'Anna', 'a+b/c=d', new Date());
const [, , text] = sendMail.mock.calls[0];
expect(text).toContain('token=a%2Bb%2Fc%3Dd');
});
it('sends both a text and an html part', async () => {
await sendInvitationMail('a@nachklang.art', 'Anna', 'tok', new Date());
const [, , text, options] = sendMail.mock.calls[0];
expect(text.length).toBeGreaterThan(0);
expect(options.html).toContain('<html');
});
it('escapes a name that contains html', async () => {
await sendInvitationMail('a@nachklang.art', '<script>alert(1)</script>', 'tok', new Date());
const [, , , options] = sendMail.mock.calls[0];
expect(options.html).not.toContain('<script>');
expect(options.html).toContain('&lt;script&gt;');
});
it('reports a delivery failure to the caller rather than throwing', async () => {
sendMail.mockResolvedValue(false);
await expect(sendInvitationMail('a@nachklang.art', 'Anna', 'tok', new Date())).resolves.toBe(false);
});
});
describe('sendPasswordResetMail', () => {
it('uses the url better-auth generated, unchanged', async () => {
const url = 'https://api.nachklang.art/admin/auth/reset-password/abc?callbackURL=x';
await sendPasswordResetMail('a@nachklang.art', 'Anna', url);
const [, , text, options] = sendMail.mock.calls[0];
expect(text).toContain(url);
expect(options.html).toContain('https://api.nachklang.art/admin/auth/reset-password/abc');
});
});
+222
View File
@@ -0,0 +1,222 @@
import {vi, describe, it, expect, beforeEach, type Mock} from 'vitest';
import {Request, Response} from 'express';
vi.mock('../../src/models/admin/admin.auth.js', () => ({
auth: {api: {getSession: vi.fn()}}
}));
vi.mock('../../src/models/admin/users/users.admin.service.js', () => ({
loadAccess: vi.fn()
}));
import {auth} from '../../src/models/admin/admin.auth.js';
import * as UsersService from '../../src/models/admin/users/users.admin.service.js';
import {requireAppAccess, requireSignedIn, resolveAccess} from '../../src/models/admin/admin.middleware.js';
const mockGetSession = auth.api.getSession as unknown as Mock;
const mockLoadAccess = UsersService.loadAccess as 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 activeUser = {
id: 'u1',
email: 'a@nachklang.art',
displayName: 'A',
disabled: false,
permissions: [
{app: 'feedback', role: 'access'},
{app: 'admin', role: 'access'}
],
apps: ['feedback', 'admin']
};
describe('resolveAccess', () => {
beforeEach(() => {
mockGetSession.mockReset();
mockLoadAccess.mockReset();
});
it('returns null without a valid session', async () => {
mockGetSession.mockResolvedValue(null);
expect(await resolveAccess(makeReq())).toBeNull();
expect(mockLoadAccess).not.toHaveBeenCalled();
});
it('returns null when the session points at a user row that is gone', async () => {
mockGetSession.mockResolvedValue({user: {id: 'u1'}});
mockLoadAccess.mockResolvedValue(null);
expect(await resolveAccess(makeReq())).toBeNull();
});
it('resolves identity and permissions in a single permission query', async () => {
mockGetSession.mockResolvedValue({user: {id: 'u1'}});
mockLoadAccess.mockResolvedValue(activeUser);
expect(await resolveAccess(makeReq())).toEqual({
id: 'u1',
email: 'a@nachklang.art',
displayName: 'A',
disabled: false,
permissions: [
{app: 'feedback', role: 'access'},
{app: 'admin', role: 'access'}
],
apps: ['feedback', 'admin']
});
// No cookieCache: exactly one lookup per request, never zero.
expect(mockLoadAccess).toHaveBeenCalledTimes(1);
});
});
describe('requireSignedIn', () => {
beforeEach(() => {
mockGetSession.mockReset();
mockLoadAccess.mockReset();
});
it('401s without a session', async () => {
mockGetSession.mockResolvedValue(null);
const res = makeRes();
const next = vi.fn();
await requireSignedIn(makeReq(), res, next);
expect(res.status).toHaveBeenCalledWith(401);
expect(next).not.toHaveBeenCalled();
});
it('403s a disabled user that still holds a valid cookie', async () => {
mockGetSession.mockResolvedValue({user: {id: 'u1'}});
mockLoadAccess.mockResolvedValue({...activeUser, disabled: true});
const res = makeRes();
const next = vi.fn();
await requireSignedIn(makeReq(), res, next);
expect(res.status).toHaveBeenCalledWith(403);
expect(next).not.toHaveBeenCalled();
});
it('admits a signed-in user with no app permissions at all', async () => {
mockGetSession.mockResolvedValue({user: {id: 'u1'}});
mockLoadAccess.mockResolvedValue({...activeUser, apps: []});
const res = makeRes();
const next = vi.fn();
await requireSignedIn(makeReq(), res, next);
expect(next).toHaveBeenCalled();
expect(res.locals.admin.apps).toEqual([]);
});
});
describe('requireAppAccess', () => {
beforeEach(() => {
mockGetSession.mockReset();
mockLoadAccess.mockReset();
});
it('401s without a session', async () => {
mockGetSession.mockResolvedValue(null);
const res = makeRes();
const next = vi.fn();
await requireAppAccess('feedback')(makeReq(), res, next);
expect(res.status).toHaveBeenCalledWith(401);
});
it('403s a signed-in user without that app permission', async () => {
mockGetSession.mockResolvedValue({user: {id: 'u1'}});
mockLoadAccess.mockResolvedValue({
...activeUser,
permissions: [{app: 'feedback', role: 'access'}],
apps: ['feedback']
});
const res = makeRes();
const next = vi.fn();
await requireAppAccess('tickets')(makeReq(), res, next);
expect(res.status).toHaveBeenCalledWith(403);
expect(next).not.toHaveBeenCalled();
});
it('403s a disabled user even when they hold the permission', async () => {
mockGetSession.mockResolvedValue({user: {id: 'u1'}});
mockLoadAccess.mockResolvedValue({...activeUser, disabled: true});
const res = makeRes();
const next = vi.fn();
await requireAppAccess('feedback')(makeReq(), res, next);
expect(res.status).toHaveBeenCalledWith(403);
});
it('passes through and exposes the identity the feedback/tickets services expect', async () => {
mockGetSession.mockResolvedValue({user: {id: 'u1'}});
mockLoadAccess.mockResolvedValue(activeUser);
const res = makeRes();
const next = vi.fn();
await requireAppAccess('feedback')(makeReq(), res, next);
expect(next).toHaveBeenCalled();
expect(res.locals.admin).toMatchObject({id: 'u1', email: 'a@nachklang.art', displayName: 'A'});
});
it('500s (never allows through) when the permission query throws', async () => {
mockGetSession.mockResolvedValue({user: {id: 'u1'}});
mockLoadAccess.mockRejectedValue(new Error('db down'));
const res = makeRes();
const next = vi.fn();
await requireAppAccess('feedback')(makeReq(), res, next);
expect(res.status).toHaveBeenCalledWith(500);
expect(next).not.toHaveBeenCalled();
});
// The seam a finer per-app permission arrives through. Nothing passes a role
// today, so these two pin the behaviour before there is anything to break.
it('403s when a specific role is required and the user only holds another', async () => {
mockGetSession.mockResolvedValue({user: {id: 'u1'}});
mockLoadAccess.mockResolvedValue({
...activeUser,
permissions: [{app: 'tickets', role: 'access'}],
apps: ['tickets']
});
const res = makeRes();
const next = vi.fn();
await requireAppAccess('tickets', 'refund')(makeReq(), res, next);
expect(res.status).toHaveBeenCalledWith(403);
expect(next).not.toHaveBeenCalled();
});
it('passes when the user holds exactly the required role', async () => {
mockGetSession.mockResolvedValue({user: {id: 'u1'}});
mockLoadAccess.mockResolvedValue({
...activeUser,
permissions: [
{app: 'tickets', role: 'access'},
{app: 'tickets', role: 'refund'}
],
apps: ['tickets']
});
const res = makeRes();
const next = vi.fn();
await requireAppAccess('tickets', 'refund')(makeReq(), res, next);
expect(next).toHaveBeenCalled();
});
});
+99
View File
@@ -0,0 +1,99 @@
import {describe, expect, it} from 'vitest';
import {
ACCESS_ROLE,
appsOf,
isAppPermission,
isAppRole,
toPermissions
} from '../../src/models/admin/admin.schema.js';
/**
* The permission model is (app, role). These tests pin the two properties the
* rest of the module leans on: that the older `['tickets']` shape still means
* "tickets at the access role", and that nothing outside APP_ROLES gets in.
*/
describe('toPermissions', () => {
it('reads the full (app, role) form', () => {
expect(toPermissions([{app: 'tickets', role: 'access'}])).toEqual([
{app: 'tickets', role: 'access'}
]);
});
it('reads a plain app list as that app at the access role', () => {
expect(toPermissions(['feedback', 'admin'])).toEqual([
{app: 'feedback', role: ACCESS_ROLE},
{app: 'admin', role: ACCESS_ROLE}
]);
});
it('accepts the two forms mixed, which is what a half-migrated caller sends', () => {
expect(toPermissions(['feedback', {app: 'tickets', role: 'access'}])).toEqual([
{app: 'feedback', role: ACCESS_ROLE},
{app: 'tickets', role: ACCESS_ROLE}
]);
});
it('drops duplicates of the same (app, role)', () => {
expect(toPermissions(['tickets', {app: 'tickets', role: 'access'}])).toEqual([
{app: 'tickets', role: ACCESS_ROLE}
]);
});
it('rejects rather than silently dropping an unknown app', () => {
// Silently ignoring it would let "grant calendar + nonsense" look like a
// success while granting less than the caller asked for.
expect(toPermissions(['calendar', 'nonsense'])).toBeNull();
});
it('rejects an unknown role', () => {
expect(toPermissions([{app: 'tickets', role: 'refund'}])).toBeNull();
});
it('rejects anything that is not a list', () => {
expect(toPermissions('admin')).toBeNull();
expect(toPermissions(null)).toBeNull();
expect(toPermissions({app: 'admin', role: 'access'})).toBeNull();
});
it('reads an empty list as "no permissions", not as invalid', () => {
expect(toPermissions([])).toEqual([]);
});
});
describe('isAppRole', () => {
it('accepts the access role for every app', () => {
expect(isAppRole('admin', ACCESS_ROLE)).toBe(true);
expect(isAppRole('calendar', ACCESS_ROLE)).toBe(true);
});
it('rejects a role that does not exist yet', () => {
expect(isAppRole('tickets', 'refund')).toBe(false);
});
});
describe('isAppPermission', () => {
it('needs both halves to be valid', () => {
expect(isAppPermission({app: 'tickets', role: ACCESS_ROLE})).toBe(true);
expect(isAppPermission({app: 'tickets'})).toBe(false);
expect(isAppPermission({role: ACCESS_ROLE})).toBe(false);
expect(isAppPermission(null)).toBe(false);
});
});
describe('appsOf', () => {
it('collapses several roles on one app to a single entry', () => {
// The point of the derived list: a user with two roles on tickets has
// access to tickets once, not twice.
const apps = appsOf([
{app: 'tickets', role: ACCESS_ROLE},
{app: 'tickets', role: 'future-role'},
{app: 'admin', role: ACCESS_ROLE}
]);
expect(apps).toEqual(['tickets', 'admin']);
});
it('is empty for no permissions', () => {
expect(appsOf([])).toEqual([]);
});
});
+199
View File
@@ -0,0 +1,199 @@
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(),
setPermissionsGuarded: vi.fn(),
disableUser: vi.fn(),
disableUserGuarded: 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);
service.setPermissionsGuarded.mockResolvedValue('ok');
service.disableUserGuarded.mockResolvedValue('ok');
});
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.setPermissionsGuarded).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.setPermissionsGuarded).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.setPermissionsGuarded).not.toHaveBeenCalled();
});
// The last-admin decision is made inside the write transaction (so two
// admins acting at once cannot both pass a check-then-act); the router's
// job is only to turn that verdict into a 409.
it('answers 409 when the service reports the last admin would be removed', async () => {
service.loadAccess.mockResolvedValue({id: 'other', disabled: false, apps: ['admin']});
service.setPermissionsGuarded.mockResolvedValue('last-admin');
const res = await request(makeApp('me')).put('/admin/users/other/permissions').send({apps: []});
expect(res.status).toBe(409);
});
it('allows removing an admin while another active admin remains', async () => {
service.loadAccess.mockResolvedValue({id: 'other', disabled: false, apps: ['admin']});
const res = await request(makeApp('me')).put('/admin/users/other/permissions').send({apps: ['tickets']});
expect(res.status).toBe(200);
expect(service.setPermissionsGuarded).toHaveBeenCalledWith(
'other',
[{app: 'tickets', role: 'access'}],
'me'
);
});
it('accepts the richer {permissions} body', async () => {
service.loadAccess.mockResolvedValue({id: 'other', disabled: false, apps: []});
const res = await request(makeApp('me'))
.put('/admin/users/other/permissions')
.send({permissions: [{app: 'tickets', role: 'access'}]});
expect(res.status).toBe(200);
expect(service.setPermissionsGuarded).toHaveBeenCalledWith(
'other',
[{app: 'tickets', role: 'access'}],
'me'
);
});
it('rejects a role that does not exist', async () => {
service.loadAccess.mockResolvedValue({id: 'other', disabled: false, apps: []});
const res = await request(makeApp('me'))
.put('/admin/users/other/permissions')
.send({permissions: [{app: 'tickets', role: 'refund'}]});
expect(res.status).toBe(400);
expect(service.setPermissionsGuarded).not.toHaveBeenCalled();
});
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);
expect(service.setPermissionsGuarded).toHaveBeenCalledWith(
'other',
[{app: 'feedback', role: 'access'}, {app: 'tickets', role: 'access'}],
'me'
);
});
});
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.disableUserGuarded).not.toHaveBeenCalled();
});
it('answers 409 when the service reports the last active admin would be disabled', async () => {
service.loadAccess.mockResolvedValue({id: 'other', disabled: false, apps: ['admin']});
service.disableUserGuarded.mockResolvedValue('last-admin');
const res = await request(makeApp('me')).post('/admin/users/other/disable');
expect(res.status).toBe(409);
});
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.disableUserGuarded).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');
});
});
+273
View File
@@ -0,0 +1,273 @@
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,
accessTo
} 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', accessTo('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', accessTo('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', accessTo('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', accessTo('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', accessTo('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', accessTo('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', accessTo('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');
});
});
+255
View File
@@ -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 {accessTo, 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', accessTo('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', accessTo('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([]);
});
});
+77
View File
@@ -0,0 +1,77 @@
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 {ACCESS_ROLE, AppName, AppPermission, toPermissions} 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 the rest are not.
*
* `rateLimit` matters more than it looks: the limiter is enabled during the
* suite, and better-auth caps /sign-in/* at 3 requests per 10 seconds. All
* tests resolve to the same client IP, so they share one bucket - without this
* reset the suite would start failing with 429s that look like auth bugs as
* soon as a third sign-in assertion is added.
*/
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('verification').execute();
await db.deleteFrom('rateLimit').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,
// Takes the shorthand as well as the full form: most tests only care that
// someone can open an app, and `['tickets']` says that with less noise.
grants: (AppName | AppPermission)[],
password = 'devpassword123'
) => {
const permissions = toPermissions(grants) ?? [];
const invitation = await InvitationsService.createInvitation(email, name, permissions, 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));
};
/** `accessTo('feedback')` reads better than the (app, role) literal in tests
* that only care that someone can open an app. */
export const accessTo = (...apps: AppName[]): AppPermission[] => {
return apps.map(app => ({app, role: ACCESS_ROLE}));
};
+107
View File
@@ -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);
}
};