import type { NextFunction, Request, Response } from 'express'; import type { Logger } from 'nestjs-pino'; import type { AuditWriter } from '../audit/audit.service'; import { createAbsoluteTimeoutMiddleware } from './absolute-timeout.middleware'; import type { UserSessionIndexService } from './user-session-index.service'; const NOW = 1_000_000_000_000; // arbitrary stable epoch ms interface SessionStub { user?: { oid: string; tid: string; username: string; displayName: string; amr: readonly string[]; }; createdAt?: number; absoluteExpiresAt?: number; destroy: jest.Mock; } function makeSession(opts?: Partial): SessionStub { return { destroy: jest.fn((cb: (err?: Error) => void) => cb()), ...opts, }; } function makeReq(session?: SessionStub, sessionID = 'session-abc'): Request { return { session, sessionID } as unknown as Request; } function makeRes() { return { clearCookie: jest.fn().mockReturnThis(), } as unknown as Response & { clearCookie: jest.Mock }; } function makeIndex(): UserSessionIndexService & { add: jest.Mock; remove: jest.Mock } { return { add: jest.fn().mockResolvedValue(undefined), remove: jest.fn().mockResolvedValue(undefined), } as unknown as UserSessionIndexService & { add: jest.Mock; remove: jest.Mock }; } function makeAudit(): AuditWriter & { sessionExpired: jest.Mock } { return { sessionExpired: jest.fn().mockResolvedValue(undefined), } as unknown as AuditWriter & { sessionExpired: jest.Mock }; } function makeLogger() { return { log: jest.fn(), warn: jest.fn(), error: jest.fn(), debug: jest.fn(), } as unknown as Logger & { log: jest.Mock; warn: jest.Mock; error: jest.Mock; }; } const USER = { oid: 'user-oid', tid: 'tenant-id', username: 'jane@apf.example', displayName: 'Jane Doe', amr: ['pwd', 'mfa'], }; describe('absoluteTimeout middleware', () => { it('passes through when there is no session at all', async () => { const middleware = createAbsoluteTimeoutMiddleware( makeIndex(), makeLogger(), makeAudit(), () => NOW, ); const req = makeReq(undefined); const res = makeRes(); const next: NextFunction = jest.fn(); await middleware(req, res, next); expect(next).toHaveBeenCalledTimes(1); expect(res.clearCookie).not.toHaveBeenCalled(); }); it('passes through when the session has no user (anonymous request hit a session-touching route)', async () => { const session = makeSession(); const middleware = createAbsoluteTimeoutMiddleware( makeIndex(), makeLogger(), makeAudit(), () => NOW, ); const res = makeRes(); const next: NextFunction = jest.fn(); await middleware(makeReq(session), res, next); expect(next).toHaveBeenCalledTimes(1); expect(session.destroy).not.toHaveBeenCalled(); }); it('passes through when absoluteExpiresAt is unset (pre-hardening session)', async () => { const session = makeSession({ user: USER }); const middleware = createAbsoluteTimeoutMiddleware( makeIndex(), makeLogger(), makeAudit(), () => NOW, ); const res = makeRes(); const next: NextFunction = jest.fn(); await middleware(makeReq(session), res, next); expect(next).toHaveBeenCalledTimes(1); expect(session.destroy).not.toHaveBeenCalled(); }); it('passes through when the session is still within the ceiling', async () => { const session = makeSession({ user: USER, createdAt: NOW - 60_000, absoluteExpiresAt: NOW + 60_000, }); const middleware = createAbsoluteTimeoutMiddleware( makeIndex(), makeLogger(), makeAudit(), () => NOW, ); const res = makeRes(); const next: NextFunction = jest.fn(); await middleware(makeReq(session), res, next); expect(next).toHaveBeenCalledTimes(1); expect(session.destroy).not.toHaveBeenCalled(); expect(res.clearCookie).not.toHaveBeenCalled(); }); it('destroys the session, removes from the index, clears the cookie, audits expiry, and still calls next() when past the ceiling', async () => { const session = makeSession({ user: USER, createdAt: NOW - 13 * 60 * 60 * 1000, absoluteExpiresAt: NOW - 60 * 60 * 1000, }); const index = makeIndex(); const logger = makeLogger(); const audit = makeAudit(); const middleware = createAbsoluteTimeoutMiddleware(index, logger, audit, () => NOW); const res = makeRes(); const next: NextFunction = jest.fn(); await middleware(makeReq(session, 'session-abc'), res, next); expect(session.destroy).toHaveBeenCalledTimes(1); expect(index.remove).toHaveBeenCalledWith(USER.oid, 'session-abc'); expect(res.clearCookie).toHaveBeenCalledWith('portal_session', { path: '/' }); expect(audit.sessionExpired).toHaveBeenCalledWith({ actor: { oid: USER.oid }, sessionId: 'session-abc', reason: 'absolute', ageMs: 13 * 60 * 60 * 1000, }); expect(logger.log).toHaveBeenCalledWith( expect.objectContaining({ event: 'session.absolute_timeout', userId: USER.oid, sessionId: 'session-abc', }), 'AbsoluteTimeout', ); expect(next).toHaveBeenCalledTimes(1); }); it('logs but does not throw when destroy() fails — still clears cookie + index + audits + calls next()', async () => { const session = makeSession({ user: USER, createdAt: NOW - 13 * 60 * 60 * 1000, absoluteExpiresAt: NOW - 60 * 60 * 1000, }); session.destroy.mockImplementationOnce((cb: (err?: Error) => void) => cb(new Error('redis down')), ); const index = makeIndex(); const logger = makeLogger(); const audit = makeAudit(); const middleware = createAbsoluteTimeoutMiddleware(index, logger, audit, () => NOW); const res = makeRes(); const next: NextFunction = jest.fn(); await middleware(makeReq(session, 'session-abc'), res, next); expect(logger.error).toHaveBeenCalledWith( expect.objectContaining({ event: 'session.absolute_timeout.destroy_failed', sessionId: 'session-abc', }), 'AbsoluteTimeout', ); expect(index.remove).toHaveBeenCalledWith(USER.oid, 'session-abc'); expect(audit.sessionExpired).toHaveBeenCalled(); expect(res.clearCookie).toHaveBeenCalled(); expect(next).toHaveBeenCalledTimes(1); }); });