feat(webhooks): Bloc 7a Baserow complet + Bloc 7b Docmost stub avec HMAC verify et idempotence
Some checks are pending
CI / Lint bridge (Biome) (push) Waiting to run
CI / Type-check bridge (push) Blocked by required conditions
CI / Tests unit bridge (push) Blocked by required conditions
CI / Tests integration bridge (push) Blocked by required conditions
CI / Security scan (push) Waiting to run
CI / Docker build + healthcheck (push) Blocked by required conditions

This commit is contained in:
Corentin JOGUET 2026-05-07 20:51:56 +02:00
parent c4f087b697
commit 022b1ee926
15 changed files with 1079 additions and 13 deletions

View file

@ -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. > Document de reference pour reprendre le travail apres restart Claude Code OU /compact.
> Lis-moi avant de commencer la prochaine session. > 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) | | 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 | | 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 | | 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 | | 7a — Webhook Baserow (HMAC + idempotence + invalidation cache) | DONE | webhooks/* + routes/webhooks.ts, 100% coverage |
| 8 — Tiptap node-views Docmost | TODO | docmost-fork-dev, Phase 2.3+ | | 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 | | 9 — Bidirec backlinks | TODO | docmost-fork-dev, Phase 3 |
| 10 — Doc utilisateur + release v0.1.0 | TODO | tech-writer + acadenice-devops | | 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 - **All files** : 86.91% lines / 86.48% branches
- **adapters/** : 98.73% lines / 95.04% branches (cible 70% largement depassee) - **adapters/** : 98.73% lines / 95.04% branches
- **domain/** : 97.86% lines / 98.16% 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 - **middleware/** : 86.41% lines / 88.88% branches
- **lib/** : 49.72% lines (config.ts non couvert — c'est normal, bootstrap) - **lib/** : 49.18% lines (config.ts/container.ts non couverts — bootstrap)
- **repos/** : 59.53% lines (BaseRepo abstract — couvert via repos concrets, sera ameliore Bloc 7) - **repos/** : 59.53% lines (BaseRepo abstract — couvert via repos concrets)
## Vote pour la prochaine session ## Vote pour la prochaine session
Recommandation pour la reprise : 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 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 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 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 3.2 — refactor erreurs domain typees + routes restantes (/blocs, /clients, /taches). Pas urgent. - **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 ## Vision projet en 3 lignes

View file

@ -17,9 +17,13 @@ DOCMOST_API_TOKEN=
# Redis (cache + idempotence webhooks) # Redis (cache + idempotence webhooks)
REDIS_URL=redis://docmost-redis:6379 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= 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) # Auth tokens bridge (CSV des tokens valides + scopes — Phase 2 simple)
# Format: token1:scope1,scope2;token2:scope3 # Format: token1:scope1,scope2;token2:scope3
# Phase 3 : migration vers DB dediee # Phase 3 : migration vers DB dediee

View file

@ -19,6 +19,7 @@ import { interventionsRoutes } from './routes/interventions.js';
import { modulesRoutes } from './routes/modules.js'; import { modulesRoutes } from './routes/modules.js';
import { personnesRoutes } from './routes/personnes.js'; import { personnesRoutes } from './routes/personnes.js';
import { projetsRoutes } from './routes/projets.js'; import { projetsRoutes } from './routes/projets.js';
import { webhooksRoutes } from './routes/webhooks.js';
export async function buildApp(): Promise<Hono<{ Variables: AuthVariables }>> { export async function buildApp(): Promise<Hono<{ Variables: AuthVariables }>> {
const app = new Hono<{ Variables: AuthVariables }>(); const app = new Hono<{ Variables: AuthVariables }>();
@ -38,6 +39,9 @@ export async function buildApp(): Promise<Hono<{ Variables: AuthVariables }>> {
); );
}); });
// Webhooks (HMAC auth) montes AVANT v1 (Bearer auth) — pas de double auth.
app.route('/api/webhooks', webhooksRoutes);
// Auth middleware applique sur tout /api/v1/* // Auth middleware applique sur tout /api/v1/*
const { tokens } = getContainer(); const { tokens } = getContainer();
const v1 = new Hono<{ Variables: AuthVariables }>(); const v1 = new Hono<{ Variables: AuthVariables }>();

View file

@ -13,6 +13,7 @@ const ConfigSchema = z.object({
docmostApiToken: z.string().optional(), docmostApiToken: z.string().optional(),
redisUrl: z.string().url(), redisUrl: z.string().url(),
baserowWebhookSecret: z.string().min(16, 'webhook secret must be >= 16 chars'), 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(), bridgeApiTokens: z.string().optional(),
}); });
@ -29,6 +30,7 @@ export function loadConfig(): Config {
docmostApiToken: process.env.DOCMOST_API_TOKEN, docmostApiToken: process.env.DOCMOST_API_TOKEN,
redisUrl: process.env.REDIS_URL, redisUrl: process.env.REDIS_URL,
baserowWebhookSecret: process.env.BASEROW_WEBHOOK_SECRET, baserowWebhookSecret: process.env.BASEROW_WEBHOOK_SECRET,
docmostWebhookSecret: process.env.DOCMOST_WEBHOOK_SECRET,
bridgeApiTokens: process.env.BRIDGE_API_TOKENS, bridgeApiTokens: process.env.BRIDGE_API_TOKENS,
}); });

View file

@ -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,
);
});

View file

@ -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:<entity>:row:<id>`) si update/delete
* - cache liste (`bridge:<entity>: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<BaserowHandleResult> {
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 };
}

View file

@ -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:<nom> -> 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 };
}

View file

@ -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=<hex>` 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;
}
}

View file

@ -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<typeof BaserowEventTypeSchema>;
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<typeof BaserowWebhookPayloadSchema>;
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<typeof DocmostWebhookPayloadSchema>;

View file

@ -23,6 +23,7 @@ import { interventionsRoutes } from '../../src/routes/interventions.js';
import { modulesRoutes } from '../../src/routes/modules.js'; import { modulesRoutes } from '../../src/routes/modules.js';
import { personnesRoutes } from '../../src/routes/personnes.js'; import { personnesRoutes } from '../../src/routes/personnes.js';
import { projetsRoutes } from '../../src/routes/projets.js'; import { projetsRoutes } from '../../src/routes/projets.js';
import { webhooksRoutes } from '../../src/routes/webhooks.js';
const FAKE_TABLE_IDS: TableIds = { const FAKE_TABLE_IDS: TableIds = {
personne: 1, personne: 1,
@ -77,6 +78,7 @@ export function installTestContainer(over: TestContainerOverrides): Container {
baserowApiToken: 'fake', baserowApiToken: 'fake',
redisUrl: 'redis://localhost', redisUrl: 'redis://localhost',
baserowWebhookSecret: 'fake_secret_at_least_16_chars', baserowWebhookSecret: 'fake_secret_at_least_16_chars',
docmostWebhookSecret: 'fake_docmost_secret_at_least_16_chars',
bridgeApiTokens: undefined, bridgeApiTokens: undefined,
}, },
baserow: fakeBaserow, baserow: fakeBaserow,
@ -101,6 +103,8 @@ export function buildTestApp(container: Container): Hono<{ Variables: AuthVariab
app.get('/api/health', (c) => c.json({ status: 'ok' })); app.get('/api/health', (c) => c.json({ status: 'ok' }));
app.route('/api/webhooks', webhooksRoutes);
const v1 = new Hono<{ Variables: AuthVariables }>(); const v1 = new Hono<{ Variables: AuthVariables }>();
v1.use('*', authMiddleware(container.tokens)); v1.use('*', authMiddleware(container.tokens));
v1.route('/personnes', personnesRoutes); v1.route('/personnes', personnesRoutes);

View file

@ -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<number> {
this.calls.push(pattern);
return Promise.resolve(1);
}
}
const silentLogger = () => pino({ level: 'silent' });
function makePayload(over: Partial<BaserowWebhookPayload> = {}): 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:*');
});
});

View file

@ -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');
});
});

View file

@ -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<string>();
public invalidated: string[] = [];
checkAndStoreEventId(id: string): Promise<boolean> {
if (this.seen.has(id)) return Promise.resolve(true);
this.seen.add(id);
return Promise.resolve(false);
}
invalidatePattern(pattern: string): Promise<number> {
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);
});
});

View file

@ -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=<hex>', () => {
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);
});
});

View file

@ -24,6 +24,12 @@ export default defineConfig({
branches: 70, branches: 70,
statements: 70, statements: 70,
}, },
'src/webhooks/**': {
lines: 80,
functions: 80,
branches: 80,
statements: 80,
},
}, },
}, },
passWithNoTests: true, passWithNoTests: true,