/** * Tests unitaires BaserowJwtManager — Patch 031. * * Strategie : mock global.fetch pour controler les reponses Baserow. * Pas d'appel reseau reel. */ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { BaserowAuthError, BaserowJwtManagerDisabled, BaserowJwtManagerImpl, createBaserowJwtManager, } from '../../src/lib/baserow-jwt-manager.js'; import { logger } from '../../src/lib/logger.js'; // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- /** * Builds a minimal JWT with a given `exp` unix timestamp. * Signature is fake — we only need the payload to be parseable. */ function buildFakeJwt(expOffsetSeconds: number): string { const exp = Math.floor(Date.now() / 1000) + expOffsetSeconds; const payload = Buffer.from(JSON.stringify({ sub: 'bridge-svc', exp })).toString('base64url'); return `header.${payload}.signature`; } function buildFakeJwtNoExp(): string { const payload = Buffer.from(JSON.stringify({ sub: 'bridge-svc' })).toString('base64url'); return `header.${payload}.signature`; } function mockFetch(responses: Array<{ ok: boolean; status: number; body: unknown }>) { let call = 0; return vi.fn(async () => { const spec = responses[call++] ?? responses[responses.length - 1]; return { ok: spec!.ok, status: spec!.status, json: async () => spec!.body, text: async () => JSON.stringify(spec!.body), }; }); } const silentLogger = logger.child({ test: true }); // --------------------------------------------------------------------------- // BaserowJwtManagerImpl // --------------------------------------------------------------------------- describe('BaserowJwtManagerImpl', () => { let originalFetch: typeof global.fetch; beforeEach(() => { originalFetch = global.fetch; }); afterEach(() => { global.fetch = originalFetch; }); function buildManager(overrides?: { refreshMarginSeconds?: number }) { return new BaserowJwtManagerImpl({ baseUrl: 'http://baserow.test', email: 'bridge@acadenice.com', password: 'secret', refreshMarginSeconds: overrides?.refreshMarginSeconds ?? 60, logger: silentLogger, }); } // 1. isEnabled returns true it('isEnabled returns true', () => { const mgr = buildManager(); expect(mgr.isEnabled()).toBe(true); }); // 2. initial login — token returned it('initial login returns token', async () => { const token = buildFakeJwt(3600); global.fetch = mockFetch([{ ok: true, status: 200, body: { token } }]) as typeof global.fetch; const mgr = buildManager(); const result = await mgr.getToken(); expect(result).toBe(token); }); // 3. token is cached after first call it('token is cached — only one fetch call on repeated getToken()', async () => { const token = buildFakeJwt(3600); const fetchMock = mockFetch([{ ok: true, status: 200, body: { token } }]); global.fetch = fetchMock as typeof global.fetch; const mgr = buildManager(); await mgr.getToken(); await mgr.getToken(); await mgr.getToken(); expect(fetchMock).toHaveBeenCalledTimes(1); }); // 4. refresh called when token close to expiry it('refresh is called when token is within refresh margin', async () => { const initialToken = buildFakeJwt(30); // expires in 30s, margin=60 → needs refresh const refreshedToken = buildFakeJwt(3600); const fetchMock = mockFetch([ { ok: true, status: 200, body: { token: initialToken } }, // login { ok: true, status: 200, body: { token: refreshedToken } }, // refresh ]); global.fetch = fetchMock as typeof global.fetch; const mgr = buildManager({ refreshMarginSeconds: 60 }); const first = await mgr.getToken(); expect(first).toBe(initialToken); // Now token exp is within margin — next call should trigger refresh const second = await mgr.getToken(); expect(second).toBe(refreshedToken); expect(fetchMock).toHaveBeenCalledTimes(2); }); // 5. refresh uses correct endpoint it('refresh POSTs to /api/user/token-refresh/', async () => { const initialToken = buildFakeJwt(30); const refreshedToken = buildFakeJwt(3600); const fetchMock = vi.fn() .mockResolvedValueOnce({ ok: true, status: 200, json: async () => ({ token: initialToken }), text: async () => '' }) .mockResolvedValueOnce({ ok: true, status: 200, json: async () => ({ token: refreshedToken }), text: async () => '' }); global.fetch = fetchMock as unknown as typeof global.fetch; const mgr = buildManager({ refreshMarginSeconds: 60 }); await mgr.getToken(); await mgr.getToken(); const refreshCall = fetchMock.mock.calls[1] as [string, RequestInit]; expect(refreshCall[0]).toContain('/api/user/token-refresh/'); expect(JSON.parse(refreshCall[1].body as string)).toMatchObject({ token: initialToken }); }); // 6. login uses correct endpoint and body it('login POSTs to /api/user/token-auth/ with email and password', async () => { const token = buildFakeJwt(3600); const fetchMock = vi.fn().mockResolvedValue({ ok: true, status: 200, json: async () => ({ token }), text: async () => '', }); global.fetch = fetchMock as unknown as typeof global.fetch; const mgr = buildManager(); await mgr.getToken(); const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit]; expect(url).toBe('http://baserow.test/api/user/token-auth/'); const body = JSON.parse(init.body as string) as Record; expect(body.username).toBe('bridge@acadenice.com'); expect(body.password).toBe('secret'); }); // 7. login error — throws BaserowAuthError it('login failure throws BaserowAuthError with correct statusCode', async () => { global.fetch = mockFetch([{ ok: false, status: 401, body: { error: 'ERROR_INVALID_CREDENTIALS' } }]) as typeof global.fetch; const mgr = buildManager(); await expect(mgr.getToken()).rejects.toThrow(BaserowAuthError); await expect(mgr.getToken()).rejects.toMatchObject({ statusCode: 401 }); }); // 8. login error does not cache bad state it('failed login clears cached token so next call retries', async () => { const token = buildFakeJwt(3600); const fetchMock = mockFetch([ { ok: false, status: 401, body: {} }, // first call fails { ok: true, status: 200, body: { token } }, // second call succeeds ]); global.fetch = fetchMock as typeof global.fetch; const mgr = buildManager(); await expect(mgr.getToken()).rejects.toThrow(); const result = await mgr.getToken(); expect(result).toBe(token); }); // 9. network error — throws propagated error it('network failure propagates as-is', async () => { const fetchMock = vi.fn().mockRejectedValue(new Error('ECONNREFUSED')); global.fetch = fetchMock as unknown as typeof global.fetch; const mgr = buildManager(); await expect(mgr.getToken()).rejects.toThrow('ECONNREFUSED'); }); // 10. concurrent getToken() calls deduplicated it('concurrent getToken() calls result in a single login fetch', async () => { const token = buildFakeJwt(3600); const fetchMock = vi.fn().mockResolvedValue({ ok: true, status: 200, json: async () => ({ token }), text: async () => '', }); global.fetch = fetchMock as unknown as typeof global.fetch; const mgr = buildManager(); const [r1, r2, r3] = await Promise.all([mgr.getToken(), mgr.getToken(), mgr.getToken()]); expect(r1).toBe(token); expect(r2).toBe(token); expect(r3).toBe(token); expect(fetchMock).toHaveBeenCalledTimes(1); }); // 11. JWT without exp claim — defaults to 55 min TTL it('token without exp claim is accepted and cached with 55min default', async () => { const token = buildFakeJwtNoExp(); const fetchMock = mockFetch([{ ok: true, status: 200, body: { token } }]); global.fetch = fetchMock as typeof global.fetch; const mgr = buildManager({ refreshMarginSeconds: 60 }); const result = await mgr.getToken(); expect(result).toBe(token); // Second call should use cache (not trigger another fetch) await mgr.getToken(); expect(fetchMock).toHaveBeenCalledTimes(1); }); // 12. refresh failure falls back to full login it('refresh failure triggers fallback full login', async () => { const staleToken = buildFakeJwt(30); // expires soon → needs refresh const freshToken = buildFakeJwt(3600); const fetchMock = vi.fn() .mockResolvedValueOnce({ ok: true, status: 200, json: async () => ({ token: staleToken }), text: async () => '' }) .mockResolvedValueOnce({ ok: false, status: 401, json: async () => ({}), text: async () => '' }) // refresh fails .mockResolvedValueOnce({ ok: true, status: 200, json: async () => ({ token: freshToken }), text: async () => '' }); // fallback login global.fetch = fetchMock as unknown as typeof global.fetch; const mgr = buildManager({ refreshMarginSeconds: 60 }); const first = await mgr.getToken(); // login expect(first).toBe(staleToken); const second = await mgr.getToken(); // refresh fails → login expect(second).toBe(freshToken); expect(fetchMock).toHaveBeenCalledTimes(3); }); // 13. Authorization header uses JWT prefix not Bearer it('JWT Authorization header uses "JWT" prefix when calling Baserow endpoints', async () => { // This test validates the contract expected by fetchWithUserJwt — tested here // via the BaserowJwtManagerImpl itself by checking login endpoint headers. const token = buildFakeJwt(3600); const fetchMock = vi.fn().mockResolvedValue({ ok: true, status: 200, json: async () => ({ token }), text: async () => '', }); global.fetch = fetchMock as unknown as typeof global.fetch; const mgr = buildManager(); const result = await mgr.getToken(); // The manager returns the raw JWT; the caller (BaserowClient.fetchWithUserJwt) // is responsible for prefixing "JWT ". Assert we get a valid token string. expect(result).toBe(token); expect(result.split('.')).toHaveLength(3); }); }); // --------------------------------------------------------------------------- // BaserowJwtManagerDisabled // --------------------------------------------------------------------------- describe('BaserowJwtManagerDisabled', () => { it('isEnabled returns false', () => { const mgr = new BaserowJwtManagerDisabled(); expect(mgr.isEnabled()).toBe(false); }); it('getToken rejects with BaserowAuthError statusCode 503', async () => { const mgr = new BaserowJwtManagerDisabled(); await expect(mgr.getToken()).rejects.toThrow(BaserowAuthError); await expect(mgr.getToken()).rejects.toMatchObject({ statusCode: 503 }); }); it('getToken error message contains BASEROW_USER_AUTH_NOT_CONFIGURED', async () => { const mgr = new BaserowJwtManagerDisabled(); try { await mgr.getToken(); expect.fail('should have thrown'); } catch (err) { expect((err as Error).message).toContain('BASEROW_USER_AUTH_NOT_CONFIGURED'); } }); }); // --------------------------------------------------------------------------- // createBaserowJwtManager factory // --------------------------------------------------------------------------- describe('createBaserowJwtManager', () => { it('returns enabled manager when email and password provided', () => { const mgr = createBaserowJwtManager({ baseUrl: 'http://baserow.test', email: 'svc@acadenice.com', password: 'secret123', refreshMarginSeconds: 60, logger: silentLogger, }); expect(mgr.isEnabled()).toBe(true); expect(mgr).toBeInstanceOf(BaserowJwtManagerImpl); }); it('returns disabled manager when email is absent', () => { const mgr = createBaserowJwtManager({ baseUrl: 'http://baserow.test', email: undefined, password: 'secret123', refreshMarginSeconds: 60, logger: silentLogger, }); expect(mgr.isEnabled()).toBe(false); expect(mgr).toBeInstanceOf(BaserowJwtManagerDisabled); }); it('returns disabled manager when password is absent', () => { const mgr = createBaserowJwtManager({ baseUrl: 'http://baserow.test', email: 'svc@acadenice.com', password: undefined, refreshMarginSeconds: 60, logger: silentLogger, }); expect(mgr.isEnabled()).toBe(false); }); it('returns disabled manager when both absent', () => { const mgr = createBaserowJwtManager({ baseUrl: 'http://baserow.test', refreshMarginSeconds: 60, logger: silentLogger, }); expect(mgr.isEnabled()).toBe(false); }); });