mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 21:46:49 +00:00
be016f180e
The specs written alongside the session, transaction and lifecycle work still seeded and read with raw statements. They go through the ORM factories now; the few that test SQL below the ORM carry a test-sql-allow reason. The raw SQL baseline holds 177 statements in 6 files.
403 lines
20 KiB
TypeScript
403 lines
20 KiB
TypeScript
/**
|
|
* Sessions e2e: session tokens that can be ended before they expire.
|
|
*
|
|
* Boots AuthModule (which brings SessionsModule) against the snapshot schema,
|
|
* with the real JwtAuthGuard, the real cookie service and the real
|
|
* renewal interceptor, then drives the flows a browser would: sign in on two
|
|
* devices, list the sessions, end one, end the others, log out, change the
|
|
* password, reset it by email, turn two-factor off, delete the account, and
|
|
* run the nightly purge against the real table. A token from before sessions
|
|
* were tracked (the harness's `sessionCookie`, which carries no `jti`) must
|
|
* keep working throughout. The admin paths that end sessions boot the whole
|
|
* app and live in session-revocation.e2e.test.ts.
|
|
*/
|
|
import { describe, it, expect, beforeAll, beforeEach, afterAll, vi } from 'vitest';
|
|
import request from 'supertest';
|
|
import cookieParser from 'cookie-parser';
|
|
import type { Server } from 'http';
|
|
import { Test } from '@nestjs/testing';
|
|
import jwt from 'jsonwebtoken';
|
|
import { authenticator } from 'otplib';
|
|
import { MikroORM } from '@mikro-orm/core';
|
|
import { sessionCookie } from './harness';
|
|
|
|
vi.mock('../../src/db/database', async () => {
|
|
const { createSnapshotTestDb } = await import('../helpers/db-mock');
|
|
const db = createSnapshotTestDb();
|
|
return {
|
|
db,
|
|
closeDb: () => {},
|
|
reinitialize: () => {},
|
|
getPlaceWithTags: () => null,
|
|
canAccessTrip: () => undefined,
|
|
isOwner: () => false,
|
|
};
|
|
});
|
|
|
|
vi.mock('../../src/websocket', () => ({ broadcastToUser: vi.fn(), broadcast: vi.fn() }));
|
|
vi.mock('../../src/nest/audit/audit-log.logger', () => ({ LOG_LEVEL: 'error', logInfo: vi.fn(), logDebug: vi.fn(), logError: vi.fn(), logWarn: vi.fn() }));
|
|
|
|
import { MailerService } from '../../src/nest/notifications/mailer/mailer.service';
|
|
import { db } from '../../src/db/database';
|
|
import { createUser } from '../helpers/factories';
|
|
import { resetRateLimits } from '../helpers/test-db';
|
|
import { AuthModule } from '../../src/nest/auth/auth.module';
|
|
import { SessionsService } from '../../src/nest/sessions/sessions.service';
|
|
import { SessionPurgeJob } from '../../src/nest/sessions/session-purge.job';
|
|
import { withRequestContext } from '../../src/nest/database/request-context';
|
|
import { encryptMfaSecret } from '../../src/nest/common/crypto/mfaCrypto';
|
|
import { sessionRows } from '../helpers/sessions';
|
|
import { insertRow, updateRows } from '../helpers/factories/rows';
|
|
import { Users } from '../../src/db/entities/Users.entity';
|
|
import { UserSessions } from '../../src/db/entities/UserSessions.entity';
|
|
import { SessionRenewalInterceptor } from '../../src/nest/auth/session-renewal.interceptor';
|
|
import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter';
|
|
import { ZodValidationPipe } from '../../src/nest/common/zod-validation.pipe';
|
|
import { TestUnitOfWorkModule } from '../helpers/test-uow';
|
|
import { createTestMikroOrmModule } from '../helpers/test-orm';
|
|
|
|
/** The reset mail the forgot-password route sends; the link in it carries the token. */
|
|
const sendPasswordResetEmail = vi.fn().mockResolvedValue({ delivered: 'email' });
|
|
|
|
describe('Sessions e2e (sign-in sessions that can be ended)', () => {
|
|
let server: Server;
|
|
let app: Awaited<ReturnType<typeof build>>;
|
|
let moduleRef: Awaited<ReturnType<ReturnType<typeof Test.createTestingModule>['compile']>>;
|
|
|
|
async function build() {
|
|
moduleRef = await Test.createTestingModule({
|
|
imports: [await TestUnitOfWorkModule.forRoot(db), await createTestMikroOrmModule(db), AuthModule],
|
|
})
|
|
.overrideProvider(MailerService)
|
|
.useValue({ sendPasswordResetEmail })
|
|
.compile();
|
|
const nest = moduleRef.createNestApplication();
|
|
nest.use(cookieParser());
|
|
nest.useGlobalFilters(new TrekExceptionFilter());
|
|
nest.useGlobalPipes(new ZodValidationPipe());
|
|
nest.useGlobalInterceptors(new SessionRenewalInterceptor(moduleRef.get(SessionsService)));
|
|
await nest.init();
|
|
return nest;
|
|
}
|
|
|
|
beforeAll(async () => {
|
|
app = await build();
|
|
server = app.getHttpServer();
|
|
});
|
|
|
|
beforeEach(() => resetRateLimits(app));
|
|
|
|
afterAll(async () => {
|
|
await app.close();
|
|
});
|
|
|
|
/** Sign in as the user from a device; returns the `trek_session=…` cookie pair. */
|
|
async function signIn(email: string, password: string, device: string): Promise<string> {
|
|
const res = await request(server).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(server).get('/api/auth/me').set('Cookie', cookie);
|
|
|
|
function freshUser(name: string) {
|
|
return createUser(db as never, { username: name, email: `${name}@example.test` });
|
|
}
|
|
|
|
it('a login is listed as the current session, with its device', async () => {
|
|
const { user, password } = freshUser('sess-list');
|
|
const laptop = await signIn(user.email, password, 'Laptop Browser');
|
|
const phone = await signIn(user.email, password, 'Phone Browser');
|
|
|
|
const res = await request(server).get('/api/auth/sessions').set('Cookie', laptop);
|
|
expect(res.status).toBe(200);
|
|
expect(res.body.current_tracked).toBe(true);
|
|
const byId = Object.fromEntries((res.body.sessions as { id: string; user_agent: string; current: boolean }[]).map((s) => [s.id, s]));
|
|
expect(Object.keys(byId).sort()).toEqual([sessionIdOf(laptop), sessionIdOf(phone)].sort());
|
|
expect(byId[sessionIdOf(laptop)]).toEqual(expect.objectContaining({ user_agent: 'Laptop Browser', current: true }));
|
|
expect(byId[sessionIdOf(phone)]).toEqual(expect.objectContaining({ user_agent: 'Phone Browser', current: false }));
|
|
}, 15000);
|
|
|
|
it('the session routes need a session', async () => {
|
|
expect((await request(server).get('/api/auth/sessions')).status).toBe(401);
|
|
expect((await request(server).post('/api/auth/sessions/revoke-others')).status).toBe(401);
|
|
});
|
|
|
|
it('logout ends the session itself, so a copy of the cookie stops working', async () => {
|
|
const { user, password } = freshUser('sess-logout');
|
|
const cookie = await signIn(user.email, password, 'Browser');
|
|
expect((await me(cookie)).status).toBe(200);
|
|
|
|
const out = await request(server).post('/api/auth/logout').set('Cookie', cookie);
|
|
expect(out.status).toBe(200);
|
|
expect(out.body).toEqual({ success: true });
|
|
|
|
expect((await me(cookie)).status).toBe(401);
|
|
}, 10000);
|
|
|
|
it('ending another session signs that device out and leaves this one signed in', async () => {
|
|
const { user, password } = freshUser('sess-revoke-one');
|
|
const laptop = await signIn(user.email, password, 'Laptop');
|
|
const phone = await signIn(user.email, password, 'Phone');
|
|
|
|
const res = await request(server).delete(`/api/auth/sessions/${sessionIdOf(phone)}`).set('Cookie', laptop);
|
|
expect(res.status).toBe(200);
|
|
expect(res.body).toEqual({ success: true });
|
|
expect(((res.headers['set-cookie'] ?? []) as unknown as string[]).some((c) => c.startsWith('trek_session='))).toBe(false);
|
|
|
|
expect((await me(phone)).status).toBe(401);
|
|
expect((await me(laptop)).status).toBe(200);
|
|
// Ended already: the same id now answers like one that never existed.
|
|
expect((await request(server).delete(`/api/auth/sessions/${sessionIdOf(phone)}`).set('Cookie', laptop)).status).toBe(404);
|
|
}, 15000);
|
|
|
|
it('ending the current session is a logout: the cookie is cleared and stops working', async () => {
|
|
const { user, password } = freshUser('sess-revoke-self');
|
|
const cookie = await signIn(user.email, password, 'Browser');
|
|
|
|
const res = await request(server).delete(`/api/auth/sessions/${sessionIdOf(cookie)}`).set('Cookie', cookie);
|
|
expect(res.status).toBe(200);
|
|
expect(((res.headers['set-cookie'] ?? []) as unknown as string[]).some((c) => c.startsWith('trek_session=;'))).toBe(true);
|
|
expect((await me(cookie)).status).toBe(401);
|
|
}, 10000);
|
|
|
|
it('another user\'s session id answers 404 and stays signed in; a malformed id answers 400', async () => {
|
|
const owner = freshUser('sess-owner');
|
|
const intruder = freshUser('sess-intruder');
|
|
const ownerCookie = await signIn(owner.user.email, owner.password, 'Owner');
|
|
const intruderCookie = await signIn(intruder.user.email, intruder.password, 'Intruder');
|
|
|
|
const res = await request(server).delete(`/api/auth/sessions/${sessionIdOf(ownerCookie)}`).set('Cookie', intruderCookie);
|
|
expect(res.status).toBe(404);
|
|
expect(res.body).toEqual({ error: 'Session not found' });
|
|
expect((await me(ownerCookie)).status).toBe(200);
|
|
|
|
const bad = await request(server).delete('/api/auth/sessions/not-a-session').set('Cookie', intruderCookie);
|
|
expect(bad.status).toBe(400);
|
|
}, 15000);
|
|
|
|
it('signing out the other sessions keeps only this one', async () => {
|
|
const { user, password } = freshUser('sess-others');
|
|
const here = await signIn(user.email, password, 'Here');
|
|
const there = await signIn(user.email, password, 'There');
|
|
const elsewhere = await signIn(user.email, password, 'Elsewhere');
|
|
|
|
const res = await request(server).post('/api/auth/sessions/revoke-others').set('Cookie', here);
|
|
expect(res.status).toBe(200);
|
|
expect(res.body).toEqual({ success: true, revoked: 2 });
|
|
|
|
expect((await me(here)).status).toBe(200);
|
|
expect((await me(there)).status).toBe(401);
|
|
expect((await me(elsewhere)).status).toBe(401);
|
|
const list = await request(server).get('/api/auth/sessions').set('Cookie', here);
|
|
expect((list.body.sessions as { id: string }[]).map((s) => s.id)).toEqual([sessionIdOf(here)]);
|
|
}, 20000);
|
|
|
|
it('a password change ends every other session; the re-issued cookie is a new session', async () => {
|
|
const { user, password } = freshUser('sess-password');
|
|
const here = await signIn(user.email, password, 'Here');
|
|
const there = await signIn(user.email, password, 'There');
|
|
|
|
const change = await request(server)
|
|
.put('/api/auth/me/password')
|
|
.set('Cookie', here)
|
|
.set('User-Agent', 'Here')
|
|
.send({ current_password: password, new_password: 'New1234!x' });
|
|
expect(change.status).toBe(200);
|
|
const reissued = ((change.headers['set-cookie'] ?? []) as unknown as string[]).filter((c) => c.startsWith('trek_session=')).pop()!;
|
|
const next = /^(trek_session=[^;]+)/.exec(reissued)![1];
|
|
|
|
expect((await me(there)).status).toBe(401);
|
|
expect((await me(here)).status).toBe(401);
|
|
expect((await me(next)).status).toBe(200);
|
|
const list = await request(server).get('/api/auth/sessions').set('Cookie', next);
|
|
expect(list.body.sessions).toEqual([expect.objectContaining({ id: sessionIdOf(next), user_agent: 'Here', current: true })]);
|
|
}, 15000);
|
|
|
|
it('a password change over a Bearer token ends every session and starts none, since no cookie carries it', async () => {
|
|
const { user, password } = freshUser('sess-password-bearer');
|
|
const cookie = await signIn(user.email, password, 'Api client');
|
|
const bearer = cookie.slice('trek_session='.length);
|
|
|
|
const change = await request(server)
|
|
.put('/api/auth/me/password')
|
|
.set('Authorization', `Bearer ${bearer}`)
|
|
.send({ current_password: password, new_password: 'New1234!x' });
|
|
expect(change.status).toBe(200);
|
|
expect(change.body).toEqual({ success: true });
|
|
expect(((change.headers['set-cookie'] ?? []) as unknown as string[]).some((c) => c.startsWith('trek_session='))).toBe(false);
|
|
|
|
const rows = await sessionRows(db as never, user.id);
|
|
expect(rows).toEqual([expect.objectContaining({ id: sessionIdOf(cookie), revoked_at: expect.any(String) })]);
|
|
}, 15000);
|
|
|
|
it('a token from before sessions were tracked keeps working, is listed as untracked, and survives "sign out others"', async () => {
|
|
const { user, password } = freshUser('sess-legacy');
|
|
const legacy = sessionCookie(user.id);
|
|
const tracked = await signIn(user.email, password, 'Tracked');
|
|
|
|
const list = await request(server).get('/api/auth/sessions').set('Cookie', legacy);
|
|
expect(list.status).toBe(200);
|
|
expect(list.body.current_tracked).toBe(false);
|
|
expect((list.body.sessions as { current: boolean }[]).every((s) => !s.current)).toBe(true);
|
|
|
|
const res = await request(server).post('/api/auth/sessions/revoke-others').set('Cookie', legacy);
|
|
expect(res.body).toEqual({ success: true, revoked: 1 });
|
|
expect((await me(tracked)).status).toBe(401);
|
|
expect((await me(legacy)).status).toBe(200);
|
|
}, 10000);
|
|
|
|
it('sliding renewal keeps the session id, and a renewed token from before tracking becomes a session', async () => {
|
|
const { user } = freshUser('sess-renew');
|
|
const old = sessionCookie(user.id, 0, { lifetime: 86400, consumed: 60000 });
|
|
|
|
const first = await request(server).get('/api/auth/me').set('Cookie', old).set('User-Agent', 'Renewed');
|
|
expect(first.status).toBe(200);
|
|
const renewedCookie = ((first.headers['set-cookie'] ?? []) as unknown as string[]).find((c) => c.startsWith('trek_session='))!;
|
|
const renewed = /^(trek_session=[^;]+)/.exec(renewedCookie)![1];
|
|
|
|
const list = await request(server).get('/api/auth/sessions').set('Cookie', renewed);
|
|
expect(list.body.current_tracked).toBe(true);
|
|
expect(list.body.sessions).toEqual([expect.objectContaining({ id: sessionIdOf(renewed), user_agent: 'Renewed', current: true })]);
|
|
}, 10000);
|
|
|
|
it("on a demo instance, visitors of the shared demo account neither see nor end each other's sessions", async () => {
|
|
createUser(db as never, { username: 'demo', email: 'demo@trek.app' });
|
|
vi.stubEnv('DEMO_MODE', 'true');
|
|
try {
|
|
const visit = async (device: string) => {
|
|
const res = await request(server).post('/api/auth/demo-login').set('User-Agent', device);
|
|
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 first = await visit('First visitor');
|
|
const second = await visit('Second visitor');
|
|
|
|
const list = await request(server).get('/api/auth/sessions').set('Cookie', first);
|
|
expect(list.status).toBe(200);
|
|
expect(list.body.sessions).toEqual([expect.objectContaining({ id: sessionIdOf(first), user_agent: 'First visitor', current: true })]);
|
|
|
|
const one = await request(server).delete(`/api/auth/sessions/${sessionIdOf(second)}`).set('Cookie', first);
|
|
expect(one.status).toBe(403);
|
|
expect(one.body).toEqual({ error: 'Sessions cannot be ended in demo mode.' });
|
|
const others = await request(server).post('/api/auth/sessions/revoke-others').set('Cookie', first);
|
|
expect(others.status).toBe(403);
|
|
expect(others.body).toEqual({ error: 'Sessions cannot be ended in demo mode.' });
|
|
|
|
expect((await me(second)).status).toBe(200);
|
|
expect((await me(first)).status).toBe(200);
|
|
} finally {
|
|
vi.unstubAllEnvs();
|
|
}
|
|
}, 15000);
|
|
|
|
/** The rows of the user's sessions that still let a token in. */
|
|
const liveRows = async (userId: number) => (await sessionRows(db as never, userId)).filter((row) => row.revoked_at === null);
|
|
|
|
it('a password reset by email ends every session of the account', async () => {
|
|
const { user, password } = freshUser('sess-reset');
|
|
const laptop = await signIn(user.email, password, 'Laptop');
|
|
const phone = await signIn(user.email, password, 'Phone');
|
|
sendPasswordResetEmail.mockClear();
|
|
|
|
const forgot = await request(server).post('/api/auth/forgot-password').send({ email: user.email });
|
|
expect(forgot.status).toBe(200);
|
|
expect(sendPasswordResetEmail).toHaveBeenCalledTimes(1);
|
|
const link = sendPasswordResetEmail.mock.calls[0][1] as string;
|
|
const resetToken = decodeURIComponent(/[?&]token=([^&]+)/.exec(link)![1]);
|
|
|
|
const reset = await request(server).post('/api/auth/reset-password').send({ token: resetToken, new_password: 'Reset1234!x' });
|
|
expect(reset.status).toBe(200);
|
|
expect(reset.body).toEqual({ success: true });
|
|
|
|
expect((await me(laptop)).status).toBe(401);
|
|
expect((await me(phone)).status).toBe(401);
|
|
expect((await sessionRows(db as never, user.id)).map((row) => row.id).sort()).toEqual([sessionIdOf(laptop), sessionIdOf(phone)].sort());
|
|
expect(await liveRows(user.id)).toEqual([]);
|
|
// The new password signs in again, as a new session.
|
|
const after = await signIn(user.email, 'Reset1234!x', 'Laptop');
|
|
expect((await me(after)).status).toBe(200);
|
|
}, 20000);
|
|
|
|
it('turning two-factor off ends every other session and keeps this one', async () => {
|
|
const { user, password } = freshUser('sess-mfa-off');
|
|
const here = await signIn(user.email, password, 'Here');
|
|
const there = await signIn(user.email, password, 'There');
|
|
// Two-factor turned on after both sign-ins, as the factory does it.
|
|
const secret = 'JBSWY3DPEHPK3PXP';
|
|
await updateRows(moduleRef.get(MikroORM), Users, { id: user.id }, { mfa_enabled: 1, mfa_secret: encryptMfaSecret(secret) });
|
|
|
|
const off = await request(server)
|
|
.post('/api/auth/mfa/disable')
|
|
.set('Cookie', here)
|
|
.send({ password, code: authenticator.generate(secret) });
|
|
expect(off.status).toBe(200);
|
|
expect(off.body).toEqual({ success: true, mfa_enabled: false });
|
|
|
|
expect((await me(there)).status).toBe(401);
|
|
expect((await me(here)).status).toBe(200);
|
|
expect((await liveRows(user.id)).map((row) => row.id)).toEqual([sessionIdOf(here)]);
|
|
}, 15000);
|
|
|
|
it('deleting the account takes its sessions with it', async () => {
|
|
const { user, password } = freshUser('sess-delete');
|
|
const here = await signIn(user.email, password, 'Here');
|
|
const there = await signIn(user.email, password, 'There');
|
|
expect(await sessionRows(db as never, user.id)).toHaveLength(2);
|
|
|
|
const res = await request(server).delete('/api/auth/me').set('Cookie', here);
|
|
expect(res.status).toBe(200);
|
|
|
|
expect(await sessionRows(db as never, user.id)).toEqual([]);
|
|
expect((await me(here)).status).toBe(401);
|
|
expect((await me(there)).status).toBe(401);
|
|
}, 15000);
|
|
|
|
it('the nightly purge removes the expired rows, keeps an ended one until it expires, and leaves the live session working', async () => {
|
|
const { user, password } = freshUser('sess-purge');
|
|
const live = await signIn(user.email, password, 'Live');
|
|
const ended = await signIn(user.email, password, 'Ended');
|
|
expect((await request(server).delete(`/api/auth/sessions/${sessionIdOf(ended)}`).set('Cookie', live)).status).toBe(200);
|
|
await insertRow(moduleRef.get(MikroORM), UserSessions, {
|
|
id: '0b7c6f3e-2a51-4c8e-9d43-5f1e2b7a9c10',
|
|
user: user.id,
|
|
created_at: '2020-01-01 00:00:00',
|
|
last_seen_at: '2020-01-01 00:00:00',
|
|
expires_at: '2020-01-02 00:00:00',
|
|
});
|
|
expect(await sessionRows(db as never, user.id)).toHaveLength(3);
|
|
|
|
const job = moduleRef.get(SessionPurgeJob);
|
|
await withRequestContext(moduleRef.get(MikroORM), () => job.tick());
|
|
|
|
expect((await sessionRows(db as never, user.id)).map((row) => row.id).sort()).toEqual([sessionIdOf(live), sessionIdOf(ended)].sort());
|
|
expect((await me(live)).status).toBe(200);
|
|
expect((await me(ended)).status).toBe(401);
|
|
}, 15000);
|
|
|
|
it('a session renewed from a token from before tracking stays ended through the nightly purge', async () => {
|
|
const { user } = freshUser('sess-legacy-purge');
|
|
const old = sessionCookie(user.id, 0, { lifetime: 86400, consumed: 60000 });
|
|
|
|
const first = await request(server).get('/api/auth/me').set('Cookie', old).set('User-Agent', 'Old');
|
|
const renewedCookie = ((first.headers['set-cookie'] ?? []) as unknown as string[]).find((c) => c.startsWith('trek_session='))!;
|
|
const renewed = /^(trek_session=[^;]+)/.exec(renewedCookie)![1];
|
|
expect((await request(server).post('/api/auth/logout').set('Cookie', renewed)).status).toBe(200);
|
|
expect((await me(renewed)).status).toBe(401);
|
|
|
|
const job = moduleRef.get(SessionPurgeJob);
|
|
await withRequestContext(moduleRef.get(MikroORM), () => job.tick());
|
|
|
|
// The old token itself lives out its own expiry, but it is not renewed into the ended session again.
|
|
const again = await request(server).get('/api/auth/me').set('Cookie', old).set('User-Agent', 'Old');
|
|
expect(again.status).toBe(200);
|
|
expect(((again.headers['set-cookie'] ?? []) as unknown as string[]).some((c) => c.startsWith('trek_session='))).toBe(false);
|
|
expect(await sessionRows(db as never, user.id)).toEqual([expect.objectContaining({ id: sessionIdOf(renewed), revoked_at: expect.any(String) })]);
|
|
const list = await request(server).get('/api/auth/sessions').set('Cookie', old);
|
|
expect(list.body.sessions).toEqual([]);
|
|
}, 15000);
|
|
});
|