import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; import { KEYCLOAK_LOGIN_STATE_KEY, KEYCLOAK_LOGOUT_IN_PROGRESS_KEY, buildKeycloakAuthorizeUrl, buildKeycloakLogoutUrl, clearKeycloakLogoutInProgress, createCodeChallenge, isKeycloakLogoutInProgress, markKeycloakLogoutInProgress, parseAuthCallbackUrl, } from './keycloak-oidc'; function createStorage() { const data = new Map(); return { getItem: (key: string) => data.get(key) ?? null, setItem: (key: string, value: string) => data.set(key, value), removeItem: (key: string) => data.delete(key), clear: () => data.clear(), }; } const testSessionStorage = createStorage(); const testWindow = { location: new URL('http://172.19.0.245:18080/login'), sessionStorage: testSessionStorage, crypto: globalThis.crypto, }; Object.defineProperty(globalThis, 'window', { configurable: true, value: testWindow, }); Object.defineProperty(globalThis, 'sessionStorage', { configurable: true, value: testSessionStorage, }); function setWindowLocation(url: string) { testWindow.location = new URL(url); } describe('keycloak oidc helpers', () => { beforeEach(() => { vi.useFakeTimers(); vi.setSystemTime(new Date('2026-06-15T08:00:00.000Z')); testSessionStorage.clear(); setWindowLocation('http://172.19.0.245:18080/login'); }); afterEach(() => { vi.useRealTimers(); testSessionStorage.clear(); }); test('builds a Keycloak authorization URL using code flow with PKCE and nonce', () => { const url = new URL(buildKeycloakAuthorizeUrl({ state: 'state-1', nonce: 'nonce-1', codeChallenge: 'challenge-1', nextPath: '/files', })); expect(url.origin + url.pathname).toBe('https://keycloak.bwgdi.com/realms/beaver/protocol/openid-connect/auth'); expect(url.searchParams.get('client_id')).toBe('beaver-agnet'); expect(url.searchParams.get('response_type')).toBe('code'); expect(url.searchParams.get('scope')).toBe('openid profile email'); expect(url.searchParams.get('redirect_uri')).toBe('http://172.19.0.245:18080/auth/callback'); expect(url.searchParams.get('state')).toBe('state-1'); expect(url.searchParams.get('nonce')).toBe('nonce-1'); expect(url.searchParams.get('code_challenge')).toBe('challenge-1'); expect(url.searchParams.get('code_challenge_method')).toBe('S256'); }); test('creates the RFC 7636 S256 challenge without requiring https WebCrypto', async () => { const challenge = await createCodeChallenge('dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk'); expect(challenge).toBe('E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM'); }); test('builds a logout URL with post_logout_redirect_uri and id_token_hint', () => { const url = new URL(buildKeycloakLogoutUrl('id-token-1')); expect(url.origin + url.pathname).toBe('https://keycloak.bwgdi.com/realms/beaver/protocol/openid-connect/logout'); expect(url.searchParams.get('client_id')).toBe('beaver-agnet'); expect(url.searchParams.get('id_token_hint')).toBe('id-token-1'); expect(url.searchParams.get('post_logout_redirect_uri')).toBe('http://172.19.0.245:18080/logout/callback'); }); test('tracks logout progress briefly to prevent immediate login restart', () => { expect(isKeycloakLogoutInProgress()).toBe(false); markKeycloakLogoutInProgress(); expect(testSessionStorage.getItem(KEYCLOAK_LOGOUT_IN_PROGRESS_KEY)).toBe('1781510400000'); expect(isKeycloakLogoutInProgress()).toBe(true); vi.advanceTimersByTime(121_000); expect(isKeycloakLogoutInProgress()).toBe(false); }); test('parses callback code and state from the current URL', () => { setWindowLocation('http://172.19.0.245:18080/auth/callback?code=abc&state=xyz'); expect(parseAuthCallbackUrl()).toEqual({ code: 'abc', state: 'xyz', error: '', errorDescription: '' }); }); test('exports stable session storage keys', () => { expect(KEYCLOAK_LOGIN_STATE_KEY).toBe('beaver_keycloak_login_state'); clearKeycloakLogoutInProgress(); expect(testSessionStorage.getItem(KEYCLOAK_LOGOUT_IN_PROGRESS_KEY)).toBeNull(); }); });