import { randomBytes } from 'node:crypto'; import { Global, Module } from '@nestjs/common'; import { Test } from '@nestjs/testing'; import { ClsService } from 'nestjs-cls'; import { LoggerModule } from 'nestjs-pino'; import { PrismaService } from 'nestjs-prisma'; import { AuditModule } from '../audit/audit.module'; import { SessionModule } from './session.module'; import { SESSION_MIDDLEWARE, type RequestHandler } from './session.token'; @Global() @Module({ providers: [ { provide: PrismaService, useValue: {} }, { provide: ClsService, useValue: { get: () => undefined } }, ], exports: [PrismaService, ClsService], }) class TestStubsModule {} const STRONG_KEY = randomBytes(32).toString('base64url'); const STRONG_SECRET = randomBytes(32).toString('base64url'); interface OriginalEnv { REDIS_URL: string | undefined; SESSION_SECRET: string | undefined; SESSION_ENCRYPTION_KEY: string | undefined; LOG_USER_ID_SALT: string | undefined; NODE_ENV: string | undefined; } // SessionModule's absolute-timeout middleware factory now depends // on `AuditWriter` (provided by AuditModule via @Global() in // production). The spec compiles a slice of the graph so we import // AuditModule explicitly and stub its DB-touching deps. async function compile() { return Test.createTestingModule({ imports: [ LoggerModule.forRoot({ pinoHttp: { level: 'silent' } }), TestStubsModule, AuditModule, SessionModule, ], }).compile(); } describe('SessionModule', () => { const original: OriginalEnv = { REDIS_URL: process.env['REDIS_URL'], SESSION_SECRET: process.env['SESSION_SECRET'], SESSION_ENCRYPTION_KEY: process.env['SESSION_ENCRYPTION_KEY'], LOG_USER_ID_SALT: process.env['LOG_USER_ID_SALT'], NODE_ENV: process.env['NODE_ENV'], }; beforeEach(() => { // Well-formed but unreachable URL — `ioredis` opens its socket // lazily so the module compiles without any network access. process.env['REDIS_URL'] = 'redis://default:test-pass@127.0.0.1:65535/0'; process.env['SESSION_SECRET'] = STRONG_SECRET; process.env['SESSION_ENCRYPTION_KEY'] = STRONG_KEY; process.env['LOG_USER_ID_SALT'] = randomBytes(32).toString('base64url'); process.env['NODE_ENV'] = 'development'; }); afterEach(() => { restore('REDIS_URL', original.REDIS_URL); restore('SESSION_SECRET', original.SESSION_SECRET); restore('SESSION_ENCRYPTION_KEY', original.SESSION_ENCRYPTION_KEY); restore('LOG_USER_ID_SALT', original.LOG_USER_ID_SALT); restore('NODE_ENV', original.NODE_ENV); }); it('provides a request handler via SESSION_MIDDLEWARE', async () => { const ref = await compile(); try { const middleware = ref.get(SESSION_MIDDLEWARE); expect(typeof middleware).toBe('function'); // Express request handlers always declare 3 named params // (req, res, next) — useful smoke check that the factory // returned the expected shape rather than a `Store` or // a configured options object. expect(middleware.length).toBe(3); } finally { await disposeRedis(ref); await ref.close(); } }); it('fails to compile when SESSION_ENCRYPTION_KEY is missing', async () => { delete process.env['SESSION_ENCRYPTION_KEY']; await expect(compile()).rejects.toThrow(/SESSION_ENCRYPTION_KEY is not set/); }); it('fails to compile when SESSION_SECRET is missing', async () => { delete process.env['SESSION_SECRET']; await expect(compile()).rejects.toThrow(/SESSION_SECRET is not set/); }); }); function restore(name: string, value: string | undefined): void { if (value === undefined) { delete process.env[name]; } else { process.env[name] = value; } } async function disposeRedis(ref: Awaited>): Promise { // `RedisModule` keeps an `ioredis` client open; explicitly // disconnect so Jest doesn't hang on its reconnect timer. const redisToken = 'REDIS_CLIENT'; const client = ref.get<{ disconnect: () => void }>(redisToken); client.disconnect(); }