diff --git a/_byan-output/fast-app/formation-hub/SESSION-RESUME.md b/_byan-output/fast-app/formation-hub/SESSION-RESUME.md index f184fca..232e751 100644 --- a/_byan-output/fast-app/formation-hub/SESSION-RESUME.md +++ b/_byan-output/fast-app/formation-hub/SESSION-RESUME.md @@ -1,4 +1,18 @@ -# SESSION RESUME — formation-hub Acadenice (last update 2026-05-07 nuit) +# SESSION RESUME — formation-hub Acadenice (last update 2026-05-07 nuit Bloc 7) + +## CHANGELOG depuis derniere update (Bloc 7 — webhooks) + +- **Bloc 7a livre (Baserow webhooks complet)** + **Bloc 7b stub (Docmost)** : + - Nouveau module `src/webhooks/` : `signature.ts` (HMAC-SHA256 hex constant-time + format `sha256=` accepte), `types.ts` (zod payloads Baserow + Docmost), `baserow-handler.ts` (mapping table_id -> entite + invalidation cache + cascade rollups parents), `docmost-handler.ts` (stub log-only avec TODO Bloc 8). + - Route `POST /api/webhooks/baserow` (HMAC `X-Baserow-Signature`, idempotence Redis 24h, dispatch invalidation par event_type, table inconnue -> 200 ignored). + - Route `POST /api/webhooks/docmost` stub (HMAC `X-Docmost-Signature`, idempotence si event_id present, log + 200). + - Body brut via `c.req.text()` puis `JSON.parse` manuel (stream consomme une seule fois). + - Config zod : `docmostWebhookSecret` ajoute (optional, min 16 chars). + - 40 tests Vitest ajoutes (220 -> 260) : signature 13 / handler baserow 10 / handler docmost 2 / routes 15. + - Coverage `src/webhooks/**` = **100% lines/branches/funcs**, `src/routes/webhooks.ts` = **97.77% lines / 96.42% branches**. + - vitest.config.ts thresholds : ajout `src/webhooks/**` a 80%. + +# SESSION RESUME — formation-hub Acadenice (Bloc 6, conserve pour reference) > Document de reference pour reprendre le travail apres restart Claude Code OU /compact. > Lis-moi avant de commencer la prochaine session. @@ -55,28 +69,32 @@ Stack live + bridge testes : | 4 — Auth middleware | DONE (en partie) | inclus dans Bloc 3 (Bearer brg_*, scopes JSON-encoded, admin:* wildcard) | | 5 — Rate limit + cache invalidation | TODO | RedisCache.checkRateLimit existe deja, faut middleware Hono qui l'appelle | | 6 — Tests integration adapters | DONE | `1528017`, 59 tests, redis-cache 100% / baserow 99% / docmost 97.7% lines | -| 7 — Webhook handlers Baserow + sync bidirec | TODO | gros bloc (~2-3h) — coeur du projet | -| 8 — Tiptap node-views Docmost | TODO | docmost-fork-dev, Phase 2.3+ | +| 7a — Webhook Baserow (HMAC + idempotence + invalidation cache) | DONE | webhooks/* + routes/webhooks.ts, 100% coverage | +| 7b — Webhook Docmost (stub) | STUB | log-only, handlers metier en Bloc 8 | +| 7 — Sync bidirec (write-back Baserow apres event Docmost) | TODO | depend de Bloc 8 (parser node-views) | +| 8 — Tiptap node-views Docmost | TODO | docmost-fork-dev, Phase 2.3+ — PROCHAIN | | 9 — Bidirec backlinks | TODO | docmost-fork-dev, Phase 3 | | 10 — Doc utilisateur + release v0.1.0 | TODO | tech-writer + acadenice-devops | -## Coverage globale (post-Bloc 6) +## Coverage globale (post-Bloc 7) -- **All files** : 85.7% lines / 85.06% branches -- **adapters/** : 98.73% lines / 95.04% branches (cible 70% largement depassee) +- **All files** : 86.91% lines / 86.48% branches +- **adapters/** : 98.73% lines / 95.04% branches - **domain/** : 97.86% lines / 98.16% branches -- **routes/** : 96.29% lines / 68.83% branches (a couvrir 70% branches → Bloc 3.2) +- **routes/** : 96.58% lines / 76.19% branches (incluant webhooks.ts 97.77%) +- **webhooks/** : **100% lines / 100% branches** (signature, baserow-handler, docmost-handler, types) - **middleware/** : 86.41% lines / 88.88% branches -- **lib/** : 49.72% lines (config.ts non couvert — c'est normal, bootstrap) -- **repos/** : 59.53% lines (BaseRepo abstract — couvert via repos concrets, sera ameliore Bloc 7) +- **lib/** : 49.18% lines (config.ts/container.ts non couverts — bootstrap) +- **repos/** : 59.53% lines (BaseRepo abstract — couvert via repos concrets) ## Vote pour la prochaine session Recommandation pour la reprise : -- **Option A (recommandee)** : Bloc 7 — webhooks Baserow + premier sync bidirec auto. C'est ce qui rend le bridge utile au-dela de "REST sur Baserow". Gros bloc 2-3h. Les adapters sont maintenant solides (Bloc 6 done) → bonne fondation pour les handlers webhook qui consommeront RedisCache.checkAndStoreEventId pour l'idempotence. -- **Option B** : Bloc 5 — rate limit + cache invalidation. Court (~1h), prerequis prod, prepare Bloc 7. Note : `checkRateLimit` a un bug latent (collision Date.now() ms) — a fixer en passant (utiliser `${now}-${randomUUID}` comme membre ZSET). -- **Option C** : Bloc 3.2 — refactor erreurs domain typees + routes restantes (/blocs, /clients, /taches). Pas urgent. +- **Option A (recommandee)** : Bloc 8 — Tiptap node-views Docmost (docmost-fork-dev). Forke Docmost AGPL, ajoute les nodes custom `baserow-row` / `baserow-list` qui font des reads via le bridge `/api/v1/*` et des writes via webhooks Docmost (handler stub deja en place — il restera a parser le payload reel et appeler les repos Baserow). Cest la piece UI manquante qui rend le bridge visible cote utilisateur. +- **Option B** : Bloc 9 — tests E2E Playwright contre la stack live (bridge-tester) pour figer le comportement actuel des routes + webhooks avant que le fork Docmost ne bouge. +- **Option C** : Bloc 5 — rate limit + cache invalidation middleware. Court (~1h). RedisCache.checkRateLimit existe deja, faut le wire dans Hono. Pas bloquant pour Bloc 8. +- **Option D** : Bloc 3.2 — refactor erreurs domain typees + routes restantes (/blocs, /clients, /taches). Pas urgent. ## Vision projet en 3 lignes diff --git a/bridge/.env.example b/bridge/.env.example index 9f8ae06..fe68937 100644 --- a/bridge/.env.example +++ b/bridge/.env.example @@ -17,9 +17,13 @@ DOCMOST_API_TOKEN= # Redis (cache + idempotence webhooks) REDIS_URL=redis://docmost-redis:6379 -# Webhooks Baserow signature secret (HMAC-SHA256) +# Webhooks Baserow signature secret (HMAC-SHA256, header X-Baserow-Signature) BASEROW_WEBHOOK_SECRET= +# Webhooks Docmost signature secret (HMAC-SHA256, header X-Docmost-Signature) +# Stub Bloc 7b — handlers metier viennent en Bloc 8 (Tiptap node-views) +DOCMOST_WEBHOOK_SECRET= + # Auth tokens bridge (CSV des tokens valides + scopes — Phase 2 simple) # Format: token1:scope1,scope2;token2:scope3 # Phase 3 : migration vers DB dediee diff --git a/bridge/src/index.ts b/bridge/src/index.ts index f2c8eb4..233387d 100644 --- a/bridge/src/index.ts +++ b/bridge/src/index.ts @@ -19,6 +19,7 @@ import { interventionsRoutes } from './routes/interventions.js'; import { modulesRoutes } from './routes/modules.js'; import { personnesRoutes } from './routes/personnes.js'; import { projetsRoutes } from './routes/projets.js'; +import { webhooksRoutes } from './routes/webhooks.js'; export async function buildApp(): Promise> { const app = new Hono<{ Variables: AuthVariables }>(); @@ -38,6 +39,9 @@ export async function buildApp(): Promise> { ); }); + // Webhooks (HMAC auth) montes AVANT v1 (Bearer auth) — pas de double auth. + app.route('/api/webhooks', webhooksRoutes); + // Auth middleware applique sur tout /api/v1/* const { tokens } = getContainer(); const v1 = new Hono<{ Variables: AuthVariables }>(); diff --git a/bridge/src/lib/config.ts b/bridge/src/lib/config.ts index d1c9735..2c43793 100644 --- a/bridge/src/lib/config.ts +++ b/bridge/src/lib/config.ts @@ -13,6 +13,7 @@ const ConfigSchema = z.object({ docmostApiToken: z.string().optional(), redisUrl: z.string().url(), baserowWebhookSecret: z.string().min(16, 'webhook secret must be >= 16 chars'), + docmostWebhookSecret: z.string().min(16, 'docmost webhook secret must be >= 16 chars').optional(), bridgeApiTokens: z.string().optional(), }); @@ -29,6 +30,7 @@ export function loadConfig(): Config { docmostApiToken: process.env.DOCMOST_API_TOKEN, redisUrl: process.env.REDIS_URL, baserowWebhookSecret: process.env.BASEROW_WEBHOOK_SECRET, + docmostWebhookSecret: process.env.DOCMOST_WEBHOOK_SECRET, bridgeApiTokens: process.env.BRIDGE_API_TOKENS, }); diff --git a/bridge/src/routes/webhooks.ts b/bridge/src/routes/webhooks.ts new file mode 100644 index 0000000..a7c7da1 --- /dev/null +++ b/bridge/src/routes/webhooks.ts @@ -0,0 +1,127 @@ +/** + * Routes webhooks — montees sur /api/webhooks/* (hors /api/v1 car auth differente). + * + * Securite : HMAC-SHA256 sur le body brut, header `X-Baserow-Signature` (Baserow) + * et `X-Docmost-Signature` (Docmost — assume meme schema, cf signature.ts). + * Body lu via `c.req.text()` PUIS parse JSON manuel — on ne peut pas appeler + * `c.req.json()` apres `text()` (stream consomme une seule fois) donc on garde + * le controle complet du parsing. + * + * Idempotence : event_id stocke en Redis 24h. Replay -> 200 avec status duplicate. + */ + +import { Hono } from 'hono'; +import { getContainer } from '../lib/container.js'; +import { errors } from '../lib/errors.js'; +import { handleBaserowEvent } from '../webhooks/baserow-handler.js'; +import { handleDocmostEvent } from '../webhooks/docmost-handler.js'; +import { verifyHmacSha256 } from '../webhooks/signature.js'; +import { BaserowWebhookPayloadSchema, DocmostWebhookPayloadSchema } from '../webhooks/types.js'; + +export const BASEROW_SIGNATURE_HEADER = 'X-Baserow-Signature'; +export const DOCMOST_SIGNATURE_HEADER = 'X-Docmost-Signature'; + +export const webhooksRoutes = new Hono(); + +webhooksRoutes.post('/baserow', async (c) => { + const { config, redis, tableIds, logger } = getContainer(); + + const signature = c.req.header(BASEROW_SIGNATURE_HEADER); + if (!signature) { + throw errors.authRequired(); + } + + const rawBody = await c.req.text(); + if (!verifyHmacSha256(rawBody, config.baserowWebhookSecret, signature)) { + throw errors.authInvalid(); + } + + let json: unknown; + try { + json = JSON.parse(rawBody); + } catch { + throw errors.validation({ message: 'Body JSON invalide' }); + } + + const parsed = BaserowWebhookPayloadSchema.safeParse(json); + if (!parsed.success) { + throw errors.validation(parsed.error.issues); + } + const payload = parsed.data; + + const isDuplicate = await redis.checkAndStoreEventId(payload.event_id, 86400); + if (isDuplicate) { + logger.info( + { eventId: payload.event_id, source: 'baserow' }, + 'webhook duplicate, skip processing', + ); + return c.json({ status: 'duplicate', eventId: payload.event_id }, 200); + } + + const result = await handleBaserowEvent(payload, { redis, tableIds, logger }); + return c.json( + { + status: result.status, + eventId: payload.event_id, + entity: result.entity, + invalidatedKeys: result.invalidatedKeys, + }, + 200, + ); +}); + +webhooksRoutes.post('/docmost', async (c) => { + const { config, redis, logger } = getContainer(); + + if (!config.docmostWebhookSecret) { + // Stub : si pas de secret configure on refuse plutot que d'accepter tout. + throw errors.authRequired(); + } + + const signature = c.req.header(DOCMOST_SIGNATURE_HEADER); + if (!signature) { + throw errors.authRequired(); + } + + const rawBody = await c.req.text(); + if (!verifyHmacSha256(rawBody, config.docmostWebhookSecret, signature)) { + throw errors.authInvalid(); + } + + let json: unknown; + try { + json = JSON.parse(rawBody); + } catch { + throw errors.validation({ message: 'Body JSON invalide' }); + } + + const parsed = DocmostWebhookPayloadSchema.safeParse(json); + if (!parsed.success) { + throw errors.validation(parsed.error.issues); + } + const payload = parsed.data; + + // Docmost event_id optionnel : skip idempotence si absent (log info). + if (payload.event_id) { + const isDuplicate = await redis.checkAndStoreEventId(payload.event_id, 86400); + if (isDuplicate) { + logger.info( + { eventId: payload.event_id, source: 'docmost' }, + 'webhook duplicate, skip processing', + ); + return c.json({ status: 'duplicate', eventId: payload.event_id }, 200); + } + } else { + logger.info({ source: 'docmost' }, 'docmost webhook sans event_id, skip idempotence'); + } + + const result = handleDocmostEvent(payload, { logger }); + return c.json( + { + status: result.status, + eventType: result.eventType, + ...(payload.event_id ? { eventId: payload.event_id } : {}), + }, + 200, + ); +}); diff --git a/bridge/src/webhooks/baserow-handler.ts b/bridge/src/webhooks/baserow-handler.ts new file mode 100644 index 0000000..ffc8ee9 --- /dev/null +++ b/bridge/src/webhooks/baserow-handler.ts @@ -0,0 +1,125 @@ +/** + * Handler webhooks Baserow. + * + * Pipeline : payload deja valide (zod) + idempotence verifiee en amont (route). + * Ici on mappe table_id -> entite domain et on invalide les caches Redis + * pertinents. Le recalcul des agregations parent (modules, formations, projets) + * est differe au prochain GET (cache miss -> repo -> fresh data) — voir doc 19 + * §8 cache strategy. + */ + +import type { Logger } from 'pino'; +import type { RedisCache } from '../adapters/redis-cache.js'; +import type { TableIds, TableName } from '../repos/baserow-repo.js'; +import type { BaserowEventType, BaserowWebhookPayload } from './types.js'; + +export interface BaserowHandlerDeps { + redis: RedisCache; + tableIds: TableIds; + logger: Logger; +} + +export interface BaserowHandleResult { + status: 'processed' | 'ignored'; + entity: TableName | null; + invalidatedKeys: number; +} + +/** + * Reverse map id -> table name (mappe juste les tables qu'on suit). + * Construit une fois par appel — taille fixe (9 entries), cout negligeable. + */ +function findEntityByTableId(tableIds: TableIds, tableId: number): TableName | null { + for (const [name, id] of Object.entries(tableIds) as [TableName, number][]) { + if (id === tableId) return name; + } + return null; +} + +/** + * Patterns d'invalidation cache. Hierarchie : + * - cache row precis (`bridge::row:`) si update/delete + * - cache liste (`bridge::list:*`) toujours + * - cache parent (rollups) si l'entite est enfant d'un agregat + */ +function buildInvalidationPatterns( + entity: TableName, + eventType: BaserowEventType, + itemIds: number[], +): string[] { + const patterns: string[] = [`bridge:${entity}:list:*`]; + + if (eventType === 'rows.updated' || eventType === 'rows.deleted') { + for (const id of itemIds) { + patterns.push(`bridge:${entity}:row:${id}`); + } + } + + // Cascade rollups parent. Les rollups sont calcules cote Baserow (formules) + // mais notre cache row du parent peut contenir des heures aggregees stale. + switch (entity) { + case 'attribution': + patterns.push('bridge:module:row:*', 'bridge:module:list:*'); + patterns.push('bridge:personne:row:*', 'bridge:personne:list:*'); + break; + case 'intervention': + patterns.push('bridge:tache:row:*', 'bridge:tache:list:*'); + patterns.push('bridge:personne:row:*', 'bridge:personne:list:*'); + break; + case 'module': + patterns.push('bridge:bloc:row:*', 'bridge:bloc:list:*'); + patterns.push('bridge:formation:row:*', 'bridge:formation:list:*'); + break; + case 'bloc': + patterns.push('bridge:formation:row:*', 'bridge:formation:list:*'); + break; + case 'tache': + patterns.push('bridge:projet:row:*', 'bridge:projet:list:*'); + break; + case 'projet': + patterns.push('bridge:client:row:*', 'bridge:client:list:*'); + break; + default: + break; + } + + return patterns; +} + +export async function handleBaserowEvent( + payload: BaserowWebhookPayload, + deps: BaserowHandlerDeps, +): Promise { + const entity = findEntityByTableId(deps.tableIds, payload.table_id); + + if (!entity) { + deps.logger.warn( + { tableId: payload.table_id, eventId: payload.event_id, eventType: payload.event_type }, + 'baserow webhook: table_id inconnu, ignore', + ); + return { status: 'ignored', entity: null, invalidatedKeys: 0 }; + } + + const itemIds = payload.items.map((i) => i.id); + const patterns = buildInvalidationPatterns(entity, payload.event_type, itemIds); + + let total = 0; + for (const pattern of patterns) { + const n = await deps.redis.invalidatePattern(pattern); + total += n; + } + + deps.logger.info( + { + eventId: payload.event_id, + eventType: payload.event_type, + entity, + itemIds, + patternsApplied: patterns.length, + keysInvalidated: total, + }, + 'baserow webhook processed', + ); + + return { status: 'processed', entity, invalidatedKeys: total }; +} diff --git a/bridge/src/webhooks/docmost-handler.ts b/bridge/src/webhooks/docmost-handler.ts new file mode 100644 index 0000000..ea9b741 --- /dev/null +++ b/bridge/src/webhooks/docmost-handler.ts @@ -0,0 +1,42 @@ +/** + * Handler webhooks Docmost — STUB Bloc 7b. + * + * TODO Bloc 8 : implementer les handlers metier + * - parser node-views Tiptap custom (baserow-row, baserow-list) cote Docmost + * - extraire mentions @formateur: -> resoudre via PersonneRepo + * - synchroniser updates pages -> rows Baserow (ex: page compte-rendu cree + * -> row dans table comptes_rendus) + * - integrer anti-loop via header X-Bridge-Origin (ignore si present) + * + * Ici on log juste l'event recu apres verif HMAC + idempotence optionnelle. + * Docmost open source ne fournit pas (encore) de spec webhook publique + * (Enterprise paye) — le format reel sera fige avec le fork docmost-fork-dev. + */ + +import type { Logger } from 'pino'; +import type { DocmostWebhookPayload } from './types.js'; + +export interface DocmostHandlerDeps { + logger: Logger; +} + +export interface DocmostHandleResult { + status: 'logged'; + eventType: string; +} + +export function handleDocmostEvent( + payload: DocmostWebhookPayload, + deps: DocmostHandlerDeps, +): DocmostHandleResult { + deps.logger.info( + { + eventId: payload.event_id ?? null, + eventType: payload.event_type, + keys: Object.keys(payload).filter((k) => k !== 'event_id' && k !== 'event_type'), + }, + 'docmost webhook stub received (Bloc 7b)', + ); + + return { status: 'logged', eventType: payload.event_type }; +} diff --git a/bridge/src/webhooks/signature.ts b/bridge/src/webhooks/signature.ts new file mode 100644 index 0000000..ac0d3b1 --- /dev/null +++ b/bridge/src/webhooks/signature.ts @@ -0,0 +1,45 @@ +/** + * Verification HMAC partagee Baserow + Docmost. + * + * Choix: HMAC-SHA256 hex sur le body brut, comparaison constant-time via + * `crypto.timingSafeEqual`. Doc Baserow officielle (cf doc 19 §7.3) : header + * `X-Baserow-Signature` avec hex digest brut (pas de prefixe `sha256=`). + * Docmost n'a pas de spec webhook publique (Enterprise paye) — on suppose + * meme schema HMAC-SHA256 hex avec header dedie. A confirmer si on bascule + * sur autre chose : c'est isole dans ce module. + * + * Le compute(body, secret) renvoie le hex pur. Verify accepte aussi un format + * `sha256=` au cas ou le provider l'envoie ainsi (defensif). + */ + +import { createHmac, timingSafeEqual } from 'node:crypto'; + +export function computeHmacSha256Hex(rawBody: string, secret: string): string { + return createHmac('sha256', secret).update(rawBody, 'utf8').digest('hex'); +} + +/** + * Verifie la signature recue contre le HMAC calcule sur `rawBody`. + * Accepte hex pur (`abc123...`) ou format prefixe (`sha256=abc123...`). + * Renvoie false sur mismatch, longueur differente, ou valeur absente. + */ +export function verifyHmacSha256( + rawBody: string, + secret: string, + received: string | null, +): boolean { + if (!received) return false; + const provided = received.startsWith('sha256=') ? received.slice(7) : received; + const expected = computeHmacSha256Hex(rawBody, secret); + + if (provided.length !== expected.length) return false; + + // timingSafeEqual exige des Buffers de meme longueur. On a deja verifie length. + const a = Buffer.from(expected, 'utf8'); + const b = Buffer.from(provided, 'utf8'); + try { + return timingSafeEqual(a, b); + } catch { + return false; + } +} diff --git a/bridge/src/webhooks/types.ts b/bridge/src/webhooks/types.ts new file mode 100644 index 0000000..01ccc79 --- /dev/null +++ b/bridge/src/webhooks/types.ts @@ -0,0 +1,31 @@ +/** + * Schemas zod des payloads webhook (Baserow + Docmost). + * Restent permissifs sur les champs row (passthrough) — on ne contraint que les + * champs structurants (event_id, event_type, table_id). + */ + +import { z } from 'zod'; + +export const BaserowEventTypeSchema = z.enum(['rows.created', 'rows.updated', 'rows.deleted']); +export type BaserowEventType = z.infer; + +export const BaserowWebhookPayloadSchema = z.object({ + event_id: z.string().min(1), + event_type: BaserowEventTypeSchema, + table_id: z.number().int().positive(), + // items optionnel : Baserow peut envoyer un test ping sans items. + items: z + .array(z.object({ id: z.number().int().positive() }).passthrough()) + .optional() + .default([]), +}); +export type BaserowWebhookPayload = z.infer; + +export const DocmostWebhookPayloadSchema = z + .object({ + // event_id facultatif cote Docmost — pas de spec officielle. + event_id: z.string().min(1).optional(), + event_type: z.string().min(1), + }) + .passthrough(); +export type DocmostWebhookPayload = z.infer; diff --git a/bridge/tests/helpers/test-app.ts b/bridge/tests/helpers/test-app.ts index d56c80c..8894e35 100644 --- a/bridge/tests/helpers/test-app.ts +++ b/bridge/tests/helpers/test-app.ts @@ -23,6 +23,7 @@ import { interventionsRoutes } from '../../src/routes/interventions.js'; import { modulesRoutes } from '../../src/routes/modules.js'; import { personnesRoutes } from '../../src/routes/personnes.js'; import { projetsRoutes } from '../../src/routes/projets.js'; +import { webhooksRoutes } from '../../src/routes/webhooks.js'; const FAKE_TABLE_IDS: TableIds = { personne: 1, @@ -77,6 +78,7 @@ export function installTestContainer(over: TestContainerOverrides): Container { baserowApiToken: 'fake', redisUrl: 'redis://localhost', baserowWebhookSecret: 'fake_secret_at_least_16_chars', + docmostWebhookSecret: 'fake_docmost_secret_at_least_16_chars', bridgeApiTokens: undefined, }, baserow: fakeBaserow, @@ -101,6 +103,8 @@ export function buildTestApp(container: Container): Hono<{ Variables: AuthVariab app.get('/api/health', (c) => c.json({ status: 'ok' })); + app.route('/api/webhooks', webhooksRoutes); + const v1 = new Hono<{ Variables: AuthVariables }>(); v1.use('*', authMiddleware(container.tokens)); v1.route('/personnes', personnesRoutes); diff --git a/bridge/tests/webhooks/baserow-handler.test.ts b/bridge/tests/webhooks/baserow-handler.test.ts new file mode 100644 index 0000000..d6a6ea8 --- /dev/null +++ b/bridge/tests/webhooks/baserow-handler.test.ts @@ -0,0 +1,173 @@ +import pino from 'pino'; +import { describe, expect, it } from 'vitest'; +import type { RedisCache } from '../../src/adapters/redis-cache.js'; +import type { TableIds } from '../../src/repos/baserow-repo.js'; +import { handleBaserowEvent } from '../../src/webhooks/baserow-handler.js'; +import type { BaserowWebhookPayload } from '../../src/webhooks/types.js'; + +const FAKE_TABLE_IDS: TableIds = { + personne: 1, + formation: 2, + bloc: 3, + module: 4, + attribution: 5, + client: 6, + projet: 7, + tache: 8, + intervention: 9, +}; + +class FakeRedis { + public calls: string[] = []; + invalidatePattern(pattern: string): Promise { + this.calls.push(pattern); + return Promise.resolve(1); + } +} + +const silentLogger = () => pino({ level: 'silent' }); + +function makePayload(over: Partial = {}): BaserowWebhookPayload { + return { + event_id: 'evt-1', + event_type: 'rows.created', + table_id: 1, + items: [{ id: 42 }], + ...over, + } as BaserowWebhookPayload; +} + +describe('handleBaserowEvent', () => { + it('rows.created sur personne -> invalide list (pas row id)', async () => { + const redis = new FakeRedis(); + const res = await handleBaserowEvent(makePayload(), { + redis: redis as unknown as RedisCache, + tableIds: FAKE_TABLE_IDS, + logger: silentLogger(), + }); + expect(res.status).toBe('processed'); + expect(res.entity).toBe('personne'); + expect(redis.calls).toContain('bridge:personne:list:*'); + expect(redis.calls).not.toContain('bridge:personne:row:42'); + }); + + it('rows.updated sur personne -> invalide list + row precis', async () => { + const redis = new FakeRedis(); + await handleBaserowEvent( + makePayload({ event_type: 'rows.updated', items: [{ id: 42 }, { id: 43 }] }), + { + redis: redis as unknown as RedisCache, + tableIds: FAKE_TABLE_IDS, + logger: silentLogger(), + }, + ); + expect(redis.calls).toContain('bridge:personne:row:42'); + expect(redis.calls).toContain('bridge:personne:row:43'); + expect(redis.calls).toContain('bridge:personne:list:*'); + }); + + it('rows.deleted sur attribution -> cascade module + personne', async () => { + const redis = new FakeRedis(); + await handleBaserowEvent( + makePayload({ event_type: 'rows.deleted', table_id: 5, items: [{ id: 100 }] }), + { + redis: redis as unknown as RedisCache, + tableIds: FAKE_TABLE_IDS, + logger: silentLogger(), + }, + ); + expect(redis.calls).toContain('bridge:attribution:row:100'); + expect(redis.calls).toContain('bridge:module:row:*'); + expect(redis.calls).toContain('bridge:personne:row:*'); + expect(redis.calls).toContain('bridge:personne:list:*'); + }); + + it('intervention -> cascade tache + personne', async () => { + const redis = new FakeRedis(); + await handleBaserowEvent( + makePayload({ event_type: 'rows.updated', table_id: 9, items: [{ id: 1 }] }), + { + redis: redis as unknown as RedisCache, + tableIds: FAKE_TABLE_IDS, + logger: silentLogger(), + }, + ); + expect(redis.calls).toContain('bridge:tache:row:*'); + expect(redis.calls).toContain('bridge:personne:row:*'); + }); + + it('module -> cascade bloc + formation', async () => { + const redis = new FakeRedis(); + await handleBaserowEvent( + makePayload({ event_type: 'rows.updated', table_id: 4, items: [{ id: 1 }] }), + { + redis: redis as unknown as RedisCache, + tableIds: FAKE_TABLE_IDS, + logger: silentLogger(), + }, + ); + expect(redis.calls).toContain('bridge:bloc:row:*'); + expect(redis.calls).toContain('bridge:formation:row:*'); + }); + + it('bloc -> cascade formation', async () => { + const redis = new FakeRedis(); + await handleBaserowEvent( + makePayload({ event_type: 'rows.updated', table_id: 3, items: [{ id: 1 }] }), + { + redis: redis as unknown as RedisCache, + tableIds: FAKE_TABLE_IDS, + logger: silentLogger(), + }, + ); + expect(redis.calls).toContain('bridge:formation:row:*'); + }); + + it('tache -> cascade projet', async () => { + const redis = new FakeRedis(); + await handleBaserowEvent( + makePayload({ event_type: 'rows.updated', table_id: 8, items: [{ id: 1 }] }), + { + redis: redis as unknown as RedisCache, + tableIds: FAKE_TABLE_IDS, + logger: silentLogger(), + }, + ); + expect(redis.calls).toContain('bridge:projet:row:*'); + }); + + it('projet -> cascade client', async () => { + const redis = new FakeRedis(); + await handleBaserowEvent( + makePayload({ event_type: 'rows.updated', table_id: 7, items: [{ id: 1 }] }), + { + redis: redis as unknown as RedisCache, + tableIds: FAKE_TABLE_IDS, + logger: silentLogger(), + }, + ); + expect(redis.calls).toContain('bridge:client:row:*'); + }); + + it('table_id inconnu -> ignored, aucune invalidation', async () => { + const redis = new FakeRedis(); + const res = await handleBaserowEvent(makePayload({ table_id: 99999 }), { + redis: redis as unknown as RedisCache, + tableIds: FAKE_TABLE_IDS, + logger: silentLogger(), + }); + expect(res.status).toBe('ignored'); + expect(res.entity).toBeNull(); + expect(redis.calls).toHaveLength(0); + }); + + it('rows.created sans items -> invalide list, pas de row precis', async () => { + const redis = new FakeRedis(); + await handleBaserowEvent(makePayload({ event_type: 'rows.created', items: [] }), { + redis: redis as unknown as RedisCache, + tableIds: FAKE_TABLE_IDS, + logger: silentLogger(), + }); + expect(redis.calls).toContain('bridge:personne:list:*'); + }); +}); diff --git a/bridge/tests/webhooks/docmost-handler.test.ts b/bridge/tests/webhooks/docmost-handler.test.ts new file mode 100644 index 0000000..b2f6523 --- /dev/null +++ b/bridge/tests/webhooks/docmost-handler.test.ts @@ -0,0 +1,21 @@ +import pino from 'pino'; +import { describe, expect, it } from 'vitest'; +import { handleDocmostEvent } from '../../src/webhooks/docmost-handler.js'; + +const silentLogger = () => pino({ level: 'silent' }); + +describe('handleDocmostEvent (stub Bloc 7b)', () => { + it('log + retourne logged avec eventType', () => { + const res = handleDocmostEvent( + { event_id: 'doc-1', event_type: 'page.updated', page_id: 'p-1' }, + { logger: silentLogger() }, + ); + expect(res).toEqual({ status: 'logged', eventType: 'page.updated' }); + }); + + it('event sans event_id ne crash pas', () => { + const res = handleDocmostEvent({ event_type: 'page.created' }, { logger: silentLogger() }); + expect(res.status).toBe('logged'); + expect(res.eventType).toBe('page.created'); + }); +}); diff --git a/bridge/tests/webhooks/routes.test.ts b/bridge/tests/webhooks/routes.test.ts new file mode 100644 index 0000000..5dda71c --- /dev/null +++ b/bridge/tests/webhooks/routes.test.ts @@ -0,0 +1,395 @@ +/** + * Tests integration des routes /api/webhooks/{baserow,docmost}. + * Pas de vrai Redis : on injecte un fake qui implemente l'API minimale necessaire. + */ + +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'; + +const TABLE_IDS = { + personne: 1, + formation: 2, + bloc: 3, + module: 4, + attribution: 5, + client: 6, + projet: 7, + tache: 8, + intervention: 9, +} as const; + +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, + }, + // 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(), + tableIds: TABLE_IDS, + 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: 1, + items: [{ id: 42 }], + }); + 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; entity: string }; + expect(json.status).toBe('processed'); + expect(json.entity).toBe('personne'); + expect(redis.invalidated).toContain('bridge:personne:list:*'); + }); + + 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 inconnue -> 200 status ignored, aucune invalidation', async () => { + const redis = new FakeRedis(); + installContainer(redis); + const app = buildApp(); + + const body = JSON.stringify({ + event_id: 'evt-ignore', + event_type: 'rows.created', + table_id: 99999, + 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(200); + const json = (await res.json()) as { status: string; entity: string | null }; + expect(json.status).toBe('ignored'); + expect(json.entity).toBeNull(); + expect(redis.invalidated).toHaveLength(0); + }); + + 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); + }); +}); diff --git a/bridge/tests/webhooks/signature.test.ts b/bridge/tests/webhooks/signature.test.ts new file mode 100644 index 0000000..efcedee --- /dev/null +++ b/bridge/tests/webhooks/signature.test.ts @@ -0,0 +1,69 @@ +import { createHmac } from 'node:crypto'; +import { describe, expect, it } from 'vitest'; +import { computeHmacSha256Hex, verifyHmacSha256 } from '../../src/webhooks/signature.js'; + +const SECRET = 'super-secret-key-32chars-min-len'; +const BODY = JSON.stringify({ event_id: 'abc', event_type: 'rows.created' }); + +function refSig(body: string, secret: string): string { + return createHmac('sha256', secret).update(body, 'utf8').digest('hex'); +} + +describe('computeHmacSha256Hex', () => { + it('match Node crypto reference', () => { + expect(computeHmacSha256Hex(BODY, SECRET)).toBe(refSig(BODY, SECRET)); + }); + + it('different secrets -> different output', () => { + expect(computeHmacSha256Hex(BODY, SECRET)).not.toBe( + computeHmacSha256Hex(BODY, `${SECRET}-other`), + ); + }); + + it('different body -> different output', () => { + expect(computeHmacSha256Hex(BODY, SECRET)).not.toBe(computeHmacSha256Hex(`${BODY}x`, SECRET)); + }); + + it('hex length is 64 (sha256)', () => { + expect(computeHmacSha256Hex(BODY, SECRET)).toHaveLength(64); + }); +}); + +describe('verifyHmacSha256', () => { + it('accepte hex pur valide', () => { + const sig = refSig(BODY, SECRET); + expect(verifyHmacSha256(BODY, SECRET, sig)).toBe(true); + }); + + it('accepte format prefixe sha256=', () => { + const sig = `sha256=${refSig(BODY, SECRET)}`; + expect(verifyHmacSha256(BODY, SECRET, sig)).toBe(true); + }); + + it('rejette signature null', () => { + expect(verifyHmacSha256(BODY, SECRET, null)).toBe(false); + }); + + it('rejette signature longueur differente', () => { + expect(verifyHmacSha256(BODY, SECRET, 'tooshort')).toBe(false); + }); + + it('rejette signature meme longueur mais wrong digest', () => { + const wrong = 'a'.repeat(64); + expect(verifyHmacSha256(BODY, SECRET, wrong)).toBe(false); + }); + + it('rejette si body modifie', () => { + const sig = refSig(BODY, SECRET); + expect(verifyHmacSha256(`${BODY}x`, SECRET, sig)).toBe(false); + }); + + it('rejette si secret different', () => { + const sig = refSig(BODY, SECRET); + expect(verifyHmacSha256(BODY, `${SECRET}-other`, sig)).toBe(false); + }); + + it('signatures non-ascii ne crashent pas', () => { + expect(verifyHmacSha256(BODY, SECRET, 'é'.repeat(64))).toBe(false); + }); +}); diff --git a/bridge/vitest.config.ts b/bridge/vitest.config.ts index 1674fe2..9ff7d55 100644 --- a/bridge/vitest.config.ts +++ b/bridge/vitest.config.ts @@ -24,6 +24,12 @@ export default defineConfig({ branches: 70, statements: 70, }, + 'src/webhooks/**': { + lines: 80, + functions: 80, + branches: 80, + statements: 80, + }, }, }, passWithNoTests: true,