Files
API/test/integration/admin.users.test.ts
T
Paddy bf7be65b03
Jenkins Production Deployment
Add admin identity module: better-auth, per-app permissions, invitations (#12)
Reviewed-on: #12
Co-authored-by: Patrick Müller <mail@pmueller.me>
Co-committed-by: Patrick Müller <mail@pmueller.me>
2026-09-06 10:41:54 +00:00

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([]);
});
});