import { randomBytes } from 'node:crypto'; import { ConfidentialClientApplication } from '@azure/msal-node'; 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 { AuthModule } from './auth.module'; import { ENTRA_CONFIG, type EntraConfig } from './entra-config.token'; import { MSAL_CLIENT } from './msal-client.token'; // Production app makes PrismaService + ClsService globally available // via `PrismaModule.forRoot({ isGlobal: true })` and ObservabilityModule. // In a slice-of-graph spec we stub both via a tiny @Global() module // so AuditModule's transitive resolution of AuditWriter succeeds // without booting Prisma or nestjs-cls for real. @Global() @Module({ providers: [ { provide: PrismaService, useValue: {} }, { provide: ClsService, useValue: { get: () => undefined } }, ], exports: [PrismaService, ClsService], }) class TestStubsModule {} // AuthModule imports SessionModule + (transitively, via AuditModule // being @Global) needs the AuditWriter providers. compile() walks // the full import graph so the spec has to satisfy every validator // at boot time, including: // - SessionModule's RedisModule (REDIS_URL) // - SessionModule's middleware factory (SESSION_SECRET + // SESSION_ENCRYPTION_KEY) // - AuditModule's HashUserIdService (LOG_USER_ID_SALT) // Lesson from PR #115/#116: CI starts with a clean env, masking the // failure that nx-loaded apps/portal-bff/.env hides locally. const VALID = { ENTRA_INSTANCE_URL: 'https://login.microsoftonline.com/', ENTRA_TENANT_ID: 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee', ENTRA_CLIENT_ID: '11111111-2222-3333-4444-555555555555', ENTRA_CLIENT_SECRET: 's3cret-value-from-entra', ENTRA_REDIRECT_URI: 'http://localhost:3000/api/auth/callback', ENTRA_POST_LOGOUT_REDIRECT_URI: 'http://localhost:4200/', // Well-formed but unreachable Redis URL — `ioredis` opens the // socket lazily so the module compiles without any network access. REDIS_URL: 'redis://default:test-pass@127.0.0.1:65535/0', SESSION_SECRET: randomBytes(32).toString('base64url'), SESSION_ENCRYPTION_KEY: randomBytes(32).toString('base64url'), LOG_USER_ID_SALT: randomBytes(32).toString('base64url'), // PrismaModule reads this at boot; an unreachable URL is fine in // tests because we never issue queries. DATABASE_URL: 'postgresql://test:test@127.0.0.1:65535/test', }; // AuthModule's MSAL_CLIENT factory injects the Pino-backed `Logger` // (which the production app supplies through `ObservabilityModule`). // `AuditModule` is `@Global()` in production via AppModule; here we // import it explicitly because we compile a slice of the graph. // AuditWriter's deps (PrismaService, ClsService) are stubbed — the // spec doesn't need a working DB, only that the wiring resolves. async function compile() { return Test.createTestingModule({ imports: [ LoggerModule.forRoot({ pinoHttp: { level: 'silent' } }), TestStubsModule, AuditModule, AuthModule, ], }).compile(); } describe('AuthModule', () => { const originalEnv: Record = {}; let ref: Awaited> | undefined; beforeEach(() => { for (const key of Object.keys(VALID) as Array) { originalEnv[key] = process.env[key]; process.env[key] = VALID[key]; } }); afterEach(async () => { // SessionModule (transitive) opens an `ioredis` client at module // init; explicitly disconnect so Jest doesn't hang on its // reconnect timer between tests. if (ref) { const redis = ref.get<{ disconnect: () => void }>('REDIS_CLIENT'); redis.disconnect(); await ref.close(); ref = undefined; } for (const key of Object.keys(VALID) as Array) { const saved = originalEnv[key]; if (saved === undefined) { delete process.env[key]; } else { process.env[key] = saved; } } }); it('provides EntraConfig via the ENTRA_CONFIG token', async () => { ref = await compile(); const config = ref.get(ENTRA_CONFIG); expect(config.clientId).toBe(VALID.ENTRA_CLIENT_ID); expect(config.authority).toBe(`${VALID.ENTRA_INSTANCE_URL}${VALID.ENTRA_TENANT_ID}`); }); it('provides a ConfidentialClientApplication via the MSAL_CLIENT token', async () => { ref = await compile(); const client = ref.get(MSAL_CLIENT); expect(client).toBeInstanceOf(ConfidentialClientApplication); }); it('fails to compile when an env var is missing', async () => { delete process.env['ENTRA_CLIENT_SECRET']; await expect(compile()).rejects.toThrow(/ENTRA_CLIENT_SECRET/); }); });