Wiki/bridge/tests/lib/baserow-jwt-manager.test.ts
Corentin JOGUET 445dda260a feat(bridge): add Baserow user JWT auto-login for metadata endpoints — Patch 031
Service account pattern resolves 401 PERMISSION_DENIED on Baserow metadata
endpoints (/api/database/views/table/:id/, /api/database/tables/:id/) which
reject DB tokens. A dedicated Baserow user account logs in via token-auth,
JWT cached in memory with mutex-protected refresh before expiry.

Fallback graceful: if BASEROW_USER_EMAIL/PASSWORD absent, CRUD rows still work,
metadata endpoints return 500 BASEROW_USER_AUTH_NOT_CONFIGURED.

417 tests pass (was 392, +25). 0 TS errors.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 14:44:55 +02:00

327 lines
12 KiB
TypeScript

/**
* 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<string, string>;
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);
});
});