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); }); // Step 2 deliberately does NOT swap the feedback and tickets authenticators: // they still authenticate against the legacy calendar sessions, so an admin // cookie means nothing to them yet. This asserts that boundary rather than // the end state - when step 4 lands, these two expectations become 200/403 // and this comment goes away. it('leaves the feedback and tickets admin areas on their legacy authenticator', async () => { const user = await createAndAcceptInvitation(app, 'o@nachklang.art', 'O', ['feedback', 'tickets']); expect((await user.agent.get('/feedback/admin/me')).status).toBe(401); expect((await user.agent.get('/tickets/admin/me')).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'); }); });