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
+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}));
};