Files
scouthub/scouthub-home-be/tests/unit/keycloak-admin/tokenManager.test.ts
T
2026-07-23 19:36:55 +02:00

103 lines
3.5 KiB
TypeScript

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<number, []> | 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);
});
});