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