import { BadRequestException } from '@nestjs/common'; import { TokenExchangeService } from './token-exchange.service'; import type { AppEnvironment } from '../../configuration/src'; describe('TokenExchangeService.exchangeAuthorizationCode', () => { const environment: AppEnvironment = { databaseUrl: 'postgresql://u:p@postgres:5432/db', redisUrl: 'redis://redis:6379', oidcIssuer: 'https://idp.example.test', oidcAudience: 'client-1', oidcClientId: 'client-1', oidcClientSecret: 'super-secret', appBaseUrl: 'http://localhost:4200', appVersion: 'dev', teamCityBuildNumber: 'local', sourceRevision: 'local', }; function fakeDiscovery( tokenEndpoint = 'https://idp.example.test/oidc/token', ) { return { getTokenEndpoint: jest.fn().mockReturnValue(tokenEndpoint) }; } it('posts a client-secret-authenticated request and returns the access + id token', async () => { const fetchMock = jest.fn().mockResolvedValue({ ok: true, json: () => Promise.resolve({ access_token: 'at-1', expires_in: 3600, refresh_token: 'rt-1', id_token: 'idt-1', }), }); (globalThis as { fetch: typeof fetch }).fetch = fetchMock as never; const service = new TokenExchangeService( environment, fakeDiscovery() as never, ); const result = await service.exchangeAuthorizationCode({ code: 'auth-code-1', codeVerifier: 'verifier-1', redirectUri: 'http://localhost:4200/api/v1/auth/callback', }); expect(result).toEqual({ accessToken: 'at-1', expiresIn: 3600, idToken: 'idt-1', }); const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit]; expect(url).toBe('https://idp.example.test/oidc/token'); const body = new URLSearchParams(init.body as string); expect(body.get('grant_type')).toBe('authorization_code'); expect(body.get('client_id')).toBe('client-1'); expect(body.get('client_secret')).toBe('super-secret'); expect(body.get('code')).toBe('auth-code-1'); expect(body.get('code_verifier')).toBe('verifier-1'); expect(body.get('redirect_uri')).toBe( 'http://localhost:4200/api/v1/auth/callback', ); }); it('never exposes the refresh_token, which is not needed by this MVP (no silent refresh)', async () => { const fetchMock = jest.fn().mockResolvedValue({ ok: true, json: () => Promise.resolve({ access_token: 'at-1', expires_in: 3600, refresh_token: 'rt-1', id_token: 'idt-1', }), }); (globalThis as { fetch: typeof fetch }).fetch = fetchMock as never; const service = new TokenExchangeService( environment, fakeDiscovery() as never, ); const result = await service.exchangeAuthorizationCode({ code: 'auth-code-1', codeVerifier: 'verifier-1', redirectUri: 'http://localhost:4200/api/v1/auth/callback', }); expect(result).not.toHaveProperty('refreshToken'); }); it('rejects with BadRequestException when the identity provider rejects the code', async () => { const fetchMock = jest.fn().mockResolvedValue({ ok: false, status: 400, json: () => Promise.resolve({ error: 'invalid_grant' }), }); (globalThis as { fetch: typeof fetch }).fetch = fetchMock as never; const service = new TokenExchangeService( environment, fakeDiscovery() as never, ); await expect( service.exchangeAuthorizationCode({ code: 'bad-code', codeVerifier: 'verifier-1', redirectUri: 'http://localhost:4200/api/v1/auth/callback', }), ).rejects.toThrow(BadRequestException); }); });