Files
API/test/integration/admin.auth.test.ts
T
Paddy 3c892d02ed
Jenkins Production Deployment
Put the feedback and tickets admin areas behind the shared identity (#13)
Reviewed-on: #13
Co-authored-by: Patrick Müller <mail@pmueller.me>
Co-committed-by: Patrick Müller <mail@pmueller.me>
2026-09-06 19:06:30 +00:00

290 lines
10 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);
});
// The step 4 cutover (2026-09-06): the feedback and tickets admin areas now
// sit behind this same gate, so one sign-in reaches every app the user has a
// permission for - and reaches no further. Until step 4 these two returned
// 401 for an admin cookie, because each module still ran its own header
// session against the calendar users table.
it('lets an admin cookie into the feedback and tickets admin areas', async () => {
const user = await createAndAcceptInvitation(app, 'o@nachklang.art', 'O', ['feedback', 'tickets']);
expect((await user.agent.get('/feedback/admin/me')).status).toBe(200);
expect((await user.agent.get('/tickets/admin/me')).status).toBe(200);
});
it('403s each app separately for a user who only holds the other one', async () => {
const user = await createAndAcceptInvitation(app, 'q@nachklang.art', 'Q', ['feedback']);
expect((await user.agent.get('/feedback/admin/me')).status).toBe(200);
expect((await user.agent.get('/tickets/admin/me')).status).toBe(403);
});
it('401s the feedback and tickets admin areas for a legacy header session', async () => {
const res = await request(app)
.get('/feedback/admin/me')
.set('X-Session-Id', '1')
.set('X-Session-Key', 'whatever');
expect(res.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');
});
});