3c892d02ed
Jenkins Production Deployment
Reviewed-on: #13 Co-authored-by: Patrick Müller <mail@pmueller.me> Co-committed-by: Patrick Müller <mail@pmueller.me>
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');
|
|
});
|
|
});
|