Files
apf_portal/apps/portal-bff/src/auth/auth.module.spec.ts
T
julien 77343e3113
CI / check (push) Successful in 3m6s
CI / commits (push) Has been skipped
CI / scan (push) Successful in 2m11s
CI / a11y (push) Successful in 1m5s
CI / perf (push) Successful in 3m59s
fix(portal-bff): use the real portal-admin dev port (4300) in admin-flow references (#131)
## Summary

PR #129 (`feat(portal-bff): distinct admin session + /api/admin/auth flow`) baked `4201` into a handful of comments, test fixtures, and the `.env.example` as the portal-admin dev port. The actual port wired in [apps/portal-admin/project.json](apps/portal-admin/project.json#L87) `serve.options.port` is **4300** — that's what `pnpm nx serve portal-admin` listens on.

This PR aligns the references so a contributor copying values from `.env.example` (or reading the test fixtures) sees the same port their browser is going to hit.

It also drops `http://localhost:4300` into `CORS_ALLOWED_ORIGINS` — the portal-admin SPA will hit the BFF with credentials as soon as the admin auth flow is exercised end-to-end, and without the origin in the allowlist the browser blocks the call. Better to set the right example now than have the next contributor chase a CORS error.

## Touched

- [apps/portal-bff/.env.example](apps/portal-bff/.env.example):
  - `ENTRA_ADMIN_POST_LOGOUT_REDIRECT_URI` default + the surrounding comment now point at `http://localhost:4300/`.
  - `CORS_ALLOWED_ORIGINS` example lists both `:4200` (portal-shell) and `:4300` (portal-admin).
  - Both sections cite `apps/<app>/project.json` `serve.options.port` as the source of truth so a future reader doesn't have to grep.
- [apps/portal-bff/src/config/check-cors-allowlist.ts](apps/portal-bff/src/config/check-cors-allowlist.ts) — stale doc-comment that pre-dated the portal-admin scaffolding, now matches reality.
- Test-fixture `adminPostLogoutRedirectUri` values in `auth.module.spec.ts`, `auth.controller.spec.ts`, `auth.service.spec.ts`, `admin-auth.controller.spec.ts`, `check-entra-config.spec.ts` — tests don't depend on the port; aligned for clarity only.

## Test plan

- [x] `grep -rn 4201 apps/ libs/` → empty.
- [x] `pnpm nx test portal-bff` — **278 specs pass** (unchanged from #129; this PR only touches strings).
- [x] No behaviour change in the BFF; only the example values shift. Developers must update their local `.env` to pick up the new port + origin.

## Notes for the reviewer

The two new env vars from #129 (`ENTRA_ADMIN_REDIRECT_URI`, `ENTRA_ADMIN_POST_LOGOUT_REDIRECT_URI`) plus the existing `CORS_ALLOWED_ORIGINS` are mandatory at boot. If your local `apps/portal-bff/.env` still has the `4201` value, the BFF will still start (any valid URL passes the validators) — but admin logout will 302 you to a port nothing is listening on, and the admin SPA's BFF calls will fail CORS. Update to `4300` to match the actual portal-admin dev server.

---------

Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr>
Reviewed-on: #131
2026-05-14 15:40:07 +02:00

124 lines
5.0 KiB
TypeScript

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/',
ENTRA_ADMIN_REDIRECT_URI: 'http://localhost:3000/api/admin/auth/callback',
ENTRA_ADMIN_POST_LOGOUT_REDIRECT_URI: 'http://localhost:4300/',
// 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<string, string | undefined> = {};
let ref: Awaited<ReturnType<typeof compile>> | undefined;
beforeEach(() => {
for (const key of Object.keys(VALID) as Array<keyof typeof VALID>) {
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<keyof typeof VALID>) {
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<EntraConfig>(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<ConfidentialClientApplication>(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/);
});
});