import nock from 'nock'; process.env.KEYCLOAK_BASE_URL = 'http://keycloak.test'; process.env.KEYCLOAK_REALM = 'scouthub'; process.env.KEYCLOAK_ORG_SERVICE_CLIENT_ID = 'test-client'; process.env.KEYCLOAK_ORG_SERVICE_CLIENT_SECRET = 'test-secret'; process.env.DATABASE_URL = 'postgresql://user:pass@localhost:5432/scouthub_home_test'; process.env.FRONTEND_BASE_URL = 'http://localhost:4200'; import { getAccessToken, resetTokenCache } from '../../../src/keycloak-admin/tokenManager'; const KEYCLOAK_HOST = 'http://keycloak.test'; const TOKEN_PATH = '/realms/scouthub/protocol/openid-connect/token'; function mockTokenEndpoint(accessToken: string, expiresInSeconds: number) { return nock(KEYCLOAK_HOST) .post(TOKEN_PATH) .reply(200, { access_token: accessToken, expires_in: expiresInSeconds, token_type: 'Bearer' }); } // Il modulo calcola la scadenza con Date.now(): per simulare il passare del // tempo mockiamo solo Date.now (non i timer), così le richieste HTTP mockate // da nock continuano a risolversi normalmente sull'event loop reale. let nowSpy: jest.SpyInstance | undefined; function advanceTimeBy(ms: number): void { const current = nowSpy ? (nowSpy.getMockImplementation()?.() ?? Date.now()) : Date.now(); nowSpy = jest.spyOn(Date, 'now').mockReturnValue(current + ms); } beforeEach(() => { resetTokenCache(); }); afterEach(() => { nowSpy?.mockRestore(); nowSpy = undefined; nock.cleanAll(); }); describe('keycloak-admin tokenManager', () => { test("ottiene un access token dall'endpoint client_credentials", async () => { const scope = mockTokenEndpoint('token-1', 300); const token = await getAccessToken(); expect(token).toBe('token-1'); expect(scope.isDone()).toBe(true); }); test('riutilizza il token in cache invece di richiederne uno nuovo ad ogni chiamata', async () => { const scope = mockTokenEndpoint('token-1', 300); const first = await getAccessToken(); const second = await getAccessToken(); const third = await getAccessToken(); expect(first).toBe('token-1'); expect(second).toBe('token-1'); expect(third).toBe('token-1'); // Un solo interceptor registrato (consumato una volta sola): se il codice // avesse richiesto un nuovo token per ogni chiamata, la seconda/terza // sarebbero fallite per mancanza di un match su nock. expect(scope.isDone()).toBe(true); }); test('rinnova il token quando è vicino alla scadenza (entro il margine di sicurezza)', async () => { mockTokenEndpoint('token-1', 300); await getAccessToken(); // 295s dopo: mancano 5s alla scadenza reale, sotto al margine di sicurezza di 10s. advanceTimeBy(295_000); const renewalScope = mockTokenEndpoint('token-2', 300); const renewed = await getAccessToken(); expect(renewed).toBe('token-2'); expect(renewalScope.isDone()).toBe(true); }); test('non rinnova il token se la scadenza è ancora lontana', async () => { mockTokenEndpoint('token-1', 300); await getAccessToken(); advanceTimeBy(60_000); // ben dentro la validità dei 300s const token = await getAccessToken(); expect(token).toBe('token-1'); }); test('richieste concorrenti senza token in cache generano una sola chiamata HTTP', async () => { const scope = mockTokenEndpoint('token-1', 300); const [a, b, c] = await Promise.all([getAccessToken(), getAccessToken(), getAccessToken()]); expect(a).toBe('token-1'); expect(b).toBe('token-1'); expect(c).toBe('token-1'); expect(scope.isDone()).toBe(true); }); });