2405625f99
feedback.auth.ts and tickets.auth.ts each become one binding to requireAppAccess. Everything downstream was already written against requireAdminAuth and res.locals.admin, and both still mean what they meant, so no router or service changed. What changed is the policy: an activated @nachklang.art account is no longer sufficient, an explicit per-app permission is. Three things followed from that and are not obvious from the diff: - APP_ORIGINS gets a production default. It feeds better-auth's trustedOrigins, and this is the first time the tickets and feedback origins matter there - before, the only browser origin that ever reached /admin/auth was the admin app itself. An origin missing from that list fails in a way that is easy to misread: sign-in works, the app works, and only sign-out returns an origin error. - Nothing reads X-Session-* any more; these two files were the last readers, and the calendar module passes its session in query parameters. The headers stay in the CORS allowedHeaders only so a browser still running a pre-cutover bundle gets a clean 401 rather than a preflight failure, and can come out once both frontends are deployed. - 40 admin operations documented a required X-Session-Id/X-Session-Key in swagger. They now declare the AdminSessionCookie scheme the admin module already defined, and each documents a 403 next to its 401. The integration assertions flip as their own comment predicted: one admin cookie opens both /feedback/admin/me and /tickets/admin/me, a user holding only feedback gets 200 and 403 respectively, and a legacy header session gets 401. The unit test that covered the old header authenticator is replaced by one asserting each module is bound to its own app and that neither consults the calendar users service. 163 unit tests and 43 integration tests green. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
290 lines
10 KiB
TypeScript
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');
|
|
});
|
|
});
|