Files
TREK/server/tests/e2e/session-revocation.e2e.test.ts
T
Maurice fa0edfd714 test(server): suites record broadcasts on a FakeRealtimeService instead of mocking src/websocket
A hand-built service gets a FakeRealtimeService where it took a RealtimeService, a Nest testing module overrides the provider, and a suite over buildApp() spies on the container's instance (spyOnRealtime). The 167 vi.mock factories on src/websocket are gone, RealtimeService reads the socket registry in ws-state.ts directly, and the re-export stub src/websocket.ts is deleted. The facade's own suite now checks what reaches a socket instead of which module function it called.
2026-10-10 20:16:10 +02:00

133 lines
6.0 KiB
TypeScript

/**
* Session revocation e2e: the admin actions that sign another account out.
*
* sessions.e2e.test.ts covers what a user does to their own sessions on the
* auth routes alone. An admin setting a password, clearing two-factor or
* deleting an account goes through AdminModule, which pulls in most of the
* app, so this suite boots the whole thing with `buildApp()` against the
* migrated snapshot, every global guard included. The member signs in twice
* for real (two tracked sessions), the admin acts, and both of the member's
* cookies must stop working while the admin's own session carries on.
*/
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
import request from 'supertest';
import type { Application } from 'express';
import type { INestApplication } from '@nestjs/common';
import jwt from 'jsonwebtoken';
import { MikroORM } from '@mikro-orm/core';
vi.mock('../../src/db/database', async () => {
const { createSnapshotTestDb, buildDbMock } = await import('../helpers/db-mock');
return buildDbMock(createSnapshotTestDb());
});
import { db as testDb } from '../../src/db/database';
import { buildApp } from '../../src/bootstrap';
import { resetRateLimits } from '../helpers/test-db';
import { createAdmin, createUser } from '../helpers/factories';
import { sessionRows } from '../helpers/sessions';
import { findRow, updateRows } from '../helpers/factories/rows';
import { Users } from '../../src/db/entities/Users.entity';
let nestApp: INestApplication;
let app: Application;
beforeAll(async () => {
nestApp = await buildApp();
app = nestApp.getHttpAdapter().getInstance();
});
beforeEach(async () => {
await resetRateLimits(nestApp);
});
afterAll(async () => {
await nestApp.close();
});
/** Sign in from a device; returns the `trek_session=…` cookie pair. */
async function signIn(email: string, password: string, device: string): Promise<string> {
const res = await request(app).post('/api/auth/login').set('User-Agent', device).send({ email, password });
expect(res.status).toBe(200);
const cookie = (res.headers['set-cookie'] as unknown as string[]).find((c) => c.startsWith('trek_session='))!;
return /^(trek_session=[^;]+)/.exec(cookie)![1];
}
const sessionIdOf = (cookie: string) => (jwt.decode(cookie.slice('trek_session='.length)) as { jti: string }).jti;
const me = (cookie: string) => request(app).get('/api/auth/me').set('Cookie', cookie);
const liveRows = async (userId: number) => (await sessionRows(testDb as never, userId)).filter((row) => row.revoked_at === null);
/** An admin and a member, each signed in; the member on two devices. */
async function signedInPair(name: string) {
const admin = createAdmin(testDb as never, { username: `${name}-admin`, email: `${name}-admin@example.test` });
const member = createUser(testDb as never, { username: `${name}-member`, email: `${name}-member@example.test` });
return {
adminCookie: await signIn(admin.user.email, admin.password, 'Admin browser'),
member: member.user,
laptop: await signIn(member.user.email, member.password, 'Laptop'),
phone: await signIn(member.user.email, member.password, 'Phone'),
};
}
describe('Admin actions that end another account\'s sessions', () => {
it('SESS-E2E-ADMIN-001: setting a new password for a member ends every session of that member', async () => {
const { adminCookie, member, laptop, phone } = await signedInPair('sess-admin-pw');
expect((await me(laptop)).status).toBe(200);
const res = await request(app)
.put(`/api/admin/users/${member.id}`)
.set('Cookie', adminCookie)
.send({ password: 'Admin1234!x' });
expect(res.status).toBe(200);
expect((await me(laptop)).status).toBe(401);
expect((await me(phone)).status).toBe(401);
expect((await sessionRows(testDb as never, member.id)).map((row) => row.id).sort()).toEqual([sessionIdOf(laptop), sessionIdOf(phone)].sort());
expect(await liveRows(member.id)).toEqual([]);
expect((await me(adminCookie)).status).toBe(200);
}, 30000);
it('SESS-E2E-ADMIN-002: clearing a member\'s two-factor ends every session of that member', async () => {
const { adminCookie, member, laptop, phone } = await signedInPair('sess-admin-mfa');
await updateRows(nestApp.get(MikroORM), Users, { id: member.id }, { mfa_enabled: 1, mfa_secret: 'x' });
const res = await request(app).delete(`/api/admin/users/${member.id}/mfa`).set('Cookie', adminCookie);
expect(res.status).toBe(200);
expect(res.body).toEqual({ success: true });
expect((await me(laptop)).status).toBe(401);
expect((await me(phone)).status).toBe(401);
expect(await liveRows(member.id)).toEqual([]);
expect((await findRow(nestApp.get(MikroORM), Users, { id: member.id }))?.mfa_enabled).toBe(0);
expect((await me(adminCookie)).status).toBe(200);
}, 30000);
it('SESS-E2E-ADMIN-003: deleting a member takes the member\'s session rows with the account', async () => {
const { adminCookie, member, laptop, phone } = await signedInPair('sess-admin-delete');
expect(await sessionRows(testDb as never, member.id)).toHaveLength(2);
const res = await request(app).delete(`/api/admin/users/${member.id}`).set('Cookie', adminCookie);
expect(res.status).toBe(200);
expect(res.body).toEqual({ success: true });
expect(await sessionRows(testDb as never, member.id)).toEqual([]);
expect((await me(laptop)).status).toBe(401);
expect((await me(phone)).status).toBe(401);
expect((await me(adminCookie)).status).toBe(200);
}, 30000);
it('SESS-E2E-ADMIN-004: an admin edit that leaves the password alone signs nobody out', async () => {
const { adminCookie, member, laptop, phone } = await signedInPair('sess-admin-rename');
const res = await request(app)
.put(`/api/admin/users/${member.id}`)
.set('Cookie', adminCookie)
.send({ username: 'sess-admin-renamed' });
expect(res.status).toBe(200);
expect((await me(laptop)).status).toBe(200);
expect((await me(phone)).status).toBe(200);
expect(await liveRows(member.id)).toHaveLength(2);
}, 30000);
});