bf7be65b03
Jenkins Production Deployment
Reviewed-on: #12 Co-authored-by: Patrick Müller <mail@pmueller.me> Co-committed-by: Patrick Müller <mail@pmueller.me>
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([]);
|
|
});
|
|
});
|