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
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:
parent
c4f087b697
commit
022b1ee926
15 changed files with 1079 additions and 13 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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<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/*
|
||||
const { tokens } = getContainer();
|
||||
const v1 = new Hono<{ Variables: AuthVariables }>();
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
|
||||
|
|
|
|||
127
bridge/src/routes/webhooks.ts
Normal file
127
bridge/src/routes/webhooks.ts
Normal 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,
|
||||
);
|
||||
});
|
||||
125
bridge/src/webhooks/baserow-handler.ts
Normal file
125
bridge/src/webhooks/baserow-handler.ts
Normal 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 };
|
||||
}
|
||||
42
bridge/src/webhooks/docmost-handler.ts
Normal file
42
bridge/src/webhooks/docmost-handler.ts
Normal 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 };
|
||||
}
|
||||
45
bridge/src/webhooks/signature.ts
Normal file
45
bridge/src/webhooks/signature.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
31
bridge/src/webhooks/types.ts
Normal file
31
bridge/src/webhooks/types.ts
Normal 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>;
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
173
bridge/tests/webhooks/baserow-handler.test.ts
Normal file
173
bridge/tests/webhooks/baserow-handler.test.ts
Normal 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:*');
|
||||
});
|
||||
});
|
||||
21
bridge/tests/webhooks/docmost-handler.test.ts
Normal file
21
bridge/tests/webhooks/docmost-handler.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
395
bridge/tests/webhooks/routes.test.ts
Normal file
395
bridge/tests/webhooks/routes.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
69
bridge/tests/webhooks/signature.test.ts
Normal file
69
bridge/tests/webhooks/signature.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
|
|
@ -24,6 +24,12 @@ export default defineConfig({
|
|||
branches: 70,
|
||||
statements: 70,
|
||||
},
|
||||
'src/webhooks/**': {
|
||||
lines: 80,
|
||||
functions: 80,
|
||||
branches: 80,
|
||||
statements: 80,
|
||||
},
|
||||
},
|
||||
},
|
||||
passWithNoTests: true,
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue