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>
327 lines
12 KiB
TypeScript
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);
|
|
});
|
|
});
|