33489585a0
Two changes to the admin module, both made now because it is not deployed yet
and neither is free later.
Permissions were "an app", with a `role` column reserved for a future
fine-grained model. Reviewing whether that reservation was enough found three
problems:
- Every row was written with role = 'admin', hardcoded, and the column
defaulted to it. On a `tickets` row that reads as "tickets administrator"
when it only ever meant "has access", and once real roles existed there
would have been no way to tell an old plain grant from a deliberate one.
- The role never left the database. /admin/me, the user list, the user detail
and both write endpoints all spoke apps: AppName[]. Adding roles would have
been a breaking change to /admin/me - and after the cutover that endpoint
has two more consumers, turning a local edit into a coordinated deploy of
three apps.
- The key (user_id, app) allowed one role per app, i.e. a tier rather than a
set of capabilities. Choosing later means an ALTER on a live table.
So: the key is now (user_id, app, role), the role is `access`, and APP_ROLES
in admin.schema.ts is the contract - a role not listed there is rejected with
400 rather than written. permissions: [{app, role}] is on the wire alongside
the derived apps: AppName[], which is kept because the three frontends only
ever ask "may I show this app?". Both write endpoints accept either shape, and
the invitation column (now `permissions`) is parsed leniently: invitations live
seven days, so a deploy that changes the shape has in-flight rows in the old
one. requireAppAccess(app, role?) takes an optional role; nothing passes one
yet.
countActiveAdminsForUpdate now counts DISTINCT users rather than rows. With
several roles per app, counting rows would make a single admin holding two
roles look like two admins and defeat the last-admin guard at exactly the
moment it matters.
Separately, passkey registration now fills `name` from the authenticator's
AAGUID via registration.afterVerification and better-auth's own
getAuthenticatorName, yielding "1Password", "iCloud Keychain", "Windows Hello".
Without it the column stayed NULL and the account page could only label every
passkey "Passkey" - useless when someone has to remove the one on the device
they just lost. A client-supplied name still wins; an unknown AAGUID still
leaves it blank.
148 unit tests (up from 131, including the new admin.schema.test.ts) and 41
integration tests pass. The integration suite applies sql/admin/001_init.sql,
so the new key is exercised rather than trusted.
No production migration is needed - the module is not deployed. An existing dev
database needs three statements: set role = 'access', drop and re-add the
primary key, rename invitations.apps to permissions.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
274 lines
9.9 KiB
TypeScript
274 lines
9.9 KiB
TypeScript
import {describe, it, expect, beforeAll, beforeEach, afterAll} from 'vitest';
|
|
import request from 'supertest';
|
|
import type {Application} from 'express';
|
|
import {createApp} from '../../src/app.factory.js';
|
|
import * as InvitationsService from '../../src/models/admin/invitations/invitations.service.js';
|
|
import * as UsersService from '../../src/models/admin/users/users.admin.service.js';
|
|
import {
|
|
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');
|
|
});
|
|
});
|