/** * Tests integration des routes /api/webhooks/{baserow,docmost} (R1). * Pas de vrai Redis : on injecte un fake qui implemente l'API minimale. */ import { createHmac } from 'node:crypto'; import { Hono } from 'hono'; import { afterEach, describe, expect, it } from 'vitest'; import type { RedisCache } from '../../src/adapters/redis-cache.js'; import { setContainer } from '../../src/lib/container.js'; import { logger } from '../../src/lib/logger.js'; import { errorHandler } from '../../src/middleware/error-handler.js'; import { webhooksRoutes } from '../../src/routes/webhooks.js'; const BASEROW_SECRET = 'baserow-test-secret-32chars-long-ok'; const DOCMOST_SECRET = 'docmost-test-secret-32chars-long-ok'; class FakeRedis { public seen = new Set(); public invalidated: string[] = []; checkAndStoreEventId(id: string): Promise { if (this.seen.has(id)) return Promise.resolve(true); this.seen.add(id); return Promise.resolve(false); } invalidatePattern(pattern: string): Promise { this.invalidated.push(pattern); return Promise.resolve(1); } } function installContainer(redis: FakeRedis, withDocmostSecret = true) { setContainer({ config: { nodeEnv: 'test', port: 0, logLevel: 'fatal', baserowApiUrl: 'http://localhost', baserowApiToken: 'fake', redisUrl: 'redis://localhost', baserowWebhookSecret: BASEROW_SECRET, docmostWebhookSecret: withDocmostSecret ? DOCMOST_SECRET : undefined, bridgeApiTokens: undefined, rateLimitGlobalMax: 10000, rateLimitGlobalWindow: 60, rateLimitMutationMax: 10000, rateLimitMutationWindow: 60, }, // biome-ignore lint/suspicious/noExplicitAny: fake injection baserow: {} as any, redis: redis as unknown as RedisCache, // biome-ignore lint/suspicious/noExplicitAny: fake injection repos: {} as any, tokens: new Map(), oidc: null, groupsScopesMap: {}, logger, }); } function buildApp() { const app = new Hono(); app.onError(errorHandler); app.route('/api/webhooks', webhooksRoutes); return app; } function sign(body: string, secret: string): string { return createHmac('sha256', secret).update(body, 'utf8').digest('hex'); } afterEach(() => { setContainer(null); }); describe('POST /api/webhooks/baserow', () => { it('200 si HMAC valide + payload connu', async () => { const redis = new FakeRedis(); installContainer(redis); const app = buildApp(); const body = JSON.stringify({ event_id: 'evt-baserow-1', event_type: 'rows.created', table_id: 42, items: [{ id: 100 }], }); const res = await app.request('/api/webhooks/baserow', { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Baserow-Signature': sign(body, BASEROW_SECRET), }, body, }); expect(res.status).toBe(200); const json = (await res.json()) as { status: string; tableId: number }; expect(json.status).toBe('processed'); expect(json.tableId).toBe(42); expect(redis.invalidated).toContain('bridge:tables:42:list:*'); expect(redis.invalidated).toContain('bridge:tables:42:views:*'); }); it('401 AUTH_REQUIRED si header absent', async () => { const redis = new FakeRedis(); installContainer(redis); const app = buildApp(); const res = await app.request('/api/webhooks/baserow', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({}), }); expect(res.status).toBe(401); const json = (await res.json()) as { error: { code: string } }; expect(json.error.code).toBe('AUTH_REQUIRED'); }); it('401 AUTH_INVALID si HMAC mismatch', async () => { const redis = new FakeRedis(); installContainer(redis); const app = buildApp(); const body = JSON.stringify({ event_id: 'evt', event_type: 'rows.created', table_id: 1, }); const res = await app.request('/api/webhooks/baserow', { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Baserow-Signature': 'a'.repeat(64), }, body, }); expect(res.status).toBe(401); const json = (await res.json()) as { error: { code: string } }; expect(json.error.code).toBe('AUTH_INVALID'); }); it('replay meme event_id -> 200 + status duplicate', async () => { const redis = new FakeRedis(); installContainer(redis); const app = buildApp(); const body = JSON.stringify({ event_id: 'evt-dup', event_type: 'rows.created', table_id: 1, items: [{ id: 1 }], }); const headers = { 'Content-Type': 'application/json', 'X-Baserow-Signature': sign(body, BASEROW_SECRET), }; const res1 = await app.request('/api/webhooks/baserow', { method: 'POST', headers, body }); expect(res1.status).toBe(200); const json1 = (await res1.json()) as { status: string }; expect(json1.status).toBe('processed'); const res2 = await app.request('/api/webhooks/baserow', { method: 'POST', headers, body }); expect(res2.status).toBe(200); const json2 = (await res2.json()) as { status: string; eventId: string }; expect(json2.status).toBe('duplicate'); expect(json2.eventId).toBe('evt-dup'); }); it('table_id 0 -> 400 (validation zod : table_id positif)', async () => { const redis = new FakeRedis(); installContainer(redis); const app = buildApp(); const body = JSON.stringify({ event_id: 'evt', event_type: 'rows.created', table_id: 0, items: [], }); const res = await app.request('/api/webhooks/baserow', { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Baserow-Signature': sign(body, BASEROW_SECRET), }, body, }); expect(res.status).toBe(400); }); it('payload malforme (event_id manquant) -> 400 VALIDATION_ERROR', async () => { const redis = new FakeRedis(); installContainer(redis); const app = buildApp(); const body = JSON.stringify({ event_type: 'rows.created', table_id: 1 }); const res = await app.request('/api/webhooks/baserow', { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Baserow-Signature': sign(body, BASEROW_SECRET), }, body, }); expect(res.status).toBe(400); const json = (await res.json()) as { error: { code: string } }; expect(json.error.code).toBe('VALIDATION_ERROR'); }); it('body JSON invalide -> 400 VALIDATION_ERROR', async () => { const redis = new FakeRedis(); installContainer(redis); const app = buildApp(); const body = 'not-json{'; const res = await app.request('/api/webhooks/baserow', { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Baserow-Signature': sign(body, BASEROW_SECRET), }, body, }); expect(res.status).toBe(400); const json = (await res.json()) as { error: { code: string } }; expect(json.error.code).toBe('VALIDATION_ERROR'); }); it('event_type non supporte -> 400 VALIDATION_ERROR', async () => { const redis = new FakeRedis(); installContainer(redis); const app = buildApp(); const body = JSON.stringify({ event_id: 'evt', event_type: 'rows.weird', table_id: 1, items: [], }); const res = await app.request('/api/webhooks/baserow', { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Baserow-Signature': sign(body, BASEROW_SECRET), }, body, }); expect(res.status).toBe(400); }); }); describe('POST /api/webhooks/docmost', () => { it('200 si HMAC valide + payload minimal', async () => { const redis = new FakeRedis(); installContainer(redis); const app = buildApp(); const body = JSON.stringify({ event_id: 'doc-1', event_type: 'page.updated', page_id: 'p-42', }); const res = await app.request('/api/webhooks/docmost', { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Docmost-Signature': sign(body, DOCMOST_SECRET), }, body, }); expect(res.status).toBe(200); const json = (await res.json()) as { status: string; eventType: string }; expect(json.status).toBe('logged'); expect(json.eventType).toBe('page.updated'); }); it('200 sans event_id (skip idempotence)', async () => { const redis = new FakeRedis(); installContainer(redis); const app = buildApp(); const body = JSON.stringify({ event_type: 'page.created' }); const res = await app.request('/api/webhooks/docmost', { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Docmost-Signature': sign(body, DOCMOST_SECRET), }, body, }); expect(res.status).toBe(200); const json = (await res.json()) as { status: string }; expect(json.status).toBe('logged'); }); it('401 si header absent', async () => { const redis = new FakeRedis(); installContainer(redis); const app = buildApp(); const res = await app.request('/api/webhooks/docmost', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}', }); expect(res.status).toBe(401); }); it('401 si HMAC mismatch', async () => { const redis = new FakeRedis(); installContainer(redis); const app = buildApp(); const body = JSON.stringify({ event_type: 'page.updated' }); const res = await app.request('/api/webhooks/docmost', { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Docmost-Signature': 'a'.repeat(64), }, body, }); expect(res.status).toBe(401); const json = (await res.json()) as { error: { code: string } }; expect(json.error.code).toBe('AUTH_INVALID'); }); it('replay meme event_id -> duplicate', async () => { const redis = new FakeRedis(); installContainer(redis); const app = buildApp(); const body = JSON.stringify({ event_id: 'doc-dup', event_type: 'page.updated' }); const headers = { 'Content-Type': 'application/json', 'X-Docmost-Signature': sign(body, DOCMOST_SECRET), }; const res1 = await app.request('/api/webhooks/docmost', { method: 'POST', headers, body }); expect(res1.status).toBe(200); const json1 = (await res1.json()) as { status: string }; expect(json1.status).toBe('logged'); const res2 = await app.request('/api/webhooks/docmost', { method: 'POST', headers, body }); expect(res2.status).toBe(200); const json2 = (await res2.json()) as { status: string }; expect(json2.status).toBe('duplicate'); }); it('401 si docmostWebhookSecret absent (stub bloque)', async () => { const redis = new FakeRedis(); installContainer(redis, false); const app = buildApp(); const body = JSON.stringify({ event_type: 'page.updated' }); const res = await app.request('/api/webhooks/docmost', { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Docmost-Signature': sign(body, DOCMOST_SECRET), }, body, }); expect(res.status).toBe(401); }); it('payload sans event_type -> 400', async () => { const redis = new FakeRedis(); installContainer(redis); const app = buildApp(); const body = JSON.stringify({ foo: 'bar' }); const res = await app.request('/api/webhooks/docmost', { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Docmost-Signature': sign(body, DOCMOST_SECRET), }, body, }); expect(res.status).toBe(400); }); });