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>
256 lines
9.2 KiB
TypeScript
256 lines
9.2 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 {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([]);
|
|
});
|
|
});
|