Le test recommande Django mais accepte Node/TS si mieux maîtrisé, donc j'ai pris AdonisJS.
Je viens de PHP/Laravel, et Adonis est clairement inspiré de Laravel : Lucid ORM ressemble à Eloquent, la structure du projet (models, controllers, validators) est presque la même, les migrations aussi. Du coup j'ai pu avancer vite sans passer du temps à apprendre un nouveau framework, et me concentrer sur la vraie difficulté du test : l'idempotence et la sécurité du webhook.
Ceci dit, je fais du Python depuis un moment de mon côté (systèmes embarqués, et en ce moment j'apprends le ML avec scikit-learn, pandas, matplotlib entre autres). J'ai aussi un peu touché à FastAPI. Donc Django ne me serait pas étranger longtemps : les concepts génériques comme les decorators, l'injection de dépendances, le repository pattern, le service layer ou l'action pattern, ce sont des patterns qu'on retrouve dans tous les frameworks, juste avec une syntaxe différente. Je m'intéresse aussi pas mal aux architectures logicielles type DDD en ce moment, donc apprendre les conventions Django (serializers DRF, ORM Django) serait plus une question de syntaxe que de logique pour moi.
Bref, si besoin je suis capable de recoder l'équivalent en Django assez rapidement, mais pour ce test j'ai privilégié la vitesse d'exécution avec la stack que je maîtrise déjà bien.
- AdonisJS 6 + TypeScript
- Lucid ORM
- PostgreSQL (SQLite possible en fallback, voir plus bas)
- Redis (pour le rate limiting de la Partie B2)
- Tests avec Japa (fourni par Adonis)
git clone https://github.com/nosleepman1/secure_webhook_Xarala
cd secure_webhook_Xarala
npm installcp .env.example .envnode ace generate:keySi vous avez PostgreSQL installé, mettez vos infos de connexion dans .env :
DB_HOST=127.0.0.1
DB_PORT=5432
DB_USER=postgres
DB_PASSWORD=votre_mdp
DB_DATABASE=xarala_webhookPuis créez la base :
createdb xarala_webhookSi vous n'avez pas Postgres sous la main, le plus simple est d'installer SQLite le temps du test :
npm install better-sqlite3Et dans config/database.ts, basculer la connexion par défaut sur sqlite (config déjà présente dans Adonis, juste à activer et pointer vers un fichier local, ex: tmp/db.sqlite3).
Le plus simple si vous êtes sous Windows : passer par WSL.
Dans votre terminal WSL :
sudo apt update
sudo apt install redis-server
sudo service redis-server startVérifiez que ça tourne :
redis-cli pingÇa doit répondre PONG.
Redis écoute par défaut sur 127.0.0.1:6379, ce qui est accessible depuis Windows tant que WSL est en mode réseau par défaut (NAT/mirrored selon la version de WSL). Mettez dans .env :
REDIS_HOST=127.0.0.1
REDIS_PORT=6379Alternative si vous préférez Docker plutôt que WSL :
docker run -d --name redis-xarala -p 6379:6379 redisnode ace migration:runnode ace testnode ace serve --watchLe webhook est disponible sur POST http://localhost:3333/webhooks/payment.
Le but c'est de recevoir un webhook de paiement (transaction_id, order_id, status, amount, signature, timestamp) et de le traiter correctement, même si l'agrégateur l'envoie plusieurs fois.
1. Vérification de la signature
On partage un secret avec l'agrégateur (PAYMENT_WEBHOOK_SECRET). On recalcule un HMAC-SHA256 du payload avec ce secret et on le compare à la signature reçue. Si ça ne correspond pas, on rejette direct avec un 401, avant même de toucher à la base.
J'utilise crypto.timingSafeEqual plutôt qu'une simple comparaison de string, pour éviter les attaques par timing (deviner la signature en mesurant le temps de comparaison).
2. Idempotence
C'est le cœur du sujet. La table payment_events a une contrainte unique sur transaction_id. Du coup si le même webhook arrive 10 fois, la 1ère création réussit, et toutes les suivantes se cassent la figure sur la contrainte unique en base. On attrape cette erreur spécifique (code Postgres 23505) et on répond juste "déjà traité" sans rien refaire.
J'ai choisi cette approche plutôt qu'un check "if not exists, insert" applicatif, parce que ce dernier a un problème de concurrence : si deux requêtes arrivent en même temps, les deux peuvent passer le check avant que l'une des deux n'insère. La contrainte unique en base est atomique, donc ce problème n'existe pas.
3. Cohérence du montant
Si le montant reçu ne correspond pas au montant de la commande, on enregistre quand même l'événement (avec un flag amount_mismatch), mais on ne marque pas la commande comme payée. On log l'anomalie pour investigation manuelle plus tard.
4. Codes de réponse HTTP
L'énoncé précise que l'agrégateur rejoue le webhook si on ne répond pas en 2xx. Du coup :
- Signature invalide → 401 (erreur définitive, pas la peine de rejouer)
- Doublon / montant incohérent / commande introuvable → 200 (le webhook est bien traité de notre côté, même si le résultat n'est pas "payé")
- Erreur inattendue (bug, DB down) → 500 (là on veut que l'agrégateur retente plus tard)
5. Livraison
Simulée via un service séparé (app/services/delivery_service.ts) qui log juste le déblocage de l'accès. Séparé du contrôleur pour que ce soit plus simple à remplacer par un vrai appel HTTP plus tard, sans toucher à la logique du webhook.
- Pas de vrai retry/queue pour la livraison : si
triggerCourseDeliveryéchoue, rien ne le retente actuellement. Avec plus de temps, je passerais par une queue (Bull + Redis par exemple) pour découpler ça du webhook. - Pas de rate limiting sur cet endpoint. Ça pourrait valoir le coup si on craint un flood de faux webhooks.
- Le payload brut (
raw_payload) est stocké tel quel en JSON, utile pour débugger un litige plus tard, mais pas encore exploité ailleurs. - Pas de vérification du
timestamppour rejeter des webhooks trop vieux (protection contre le rejeu d'un vieux payload volé, même avec signature valide). C'est un axe de sécurité en plus que je n'ai pas eu le temps de creuser.
POST /registrations avec {name, email, phone_whatsapp, city}.
Deux contraintes UNIQUE séparées en base, une sur email, une sur phone_whatsapp
(pas une contrainte composite, car la règle c'est "même email OU même téléphone",
pas "les deux à la fois"). Si l'insertion échoue sur l'une des deux, on attrape
l'erreur Postgres (code 23505) et on répond 409 Conflict proprement.
Format téléphone sénégalais vérifié par regex : ^\+221[0-9]{9}$. Email validé
par le validator standard de VineJS.
Rate limiting via @adonisjs/limiter + Redis : 5 requêtes par minute par IP,
blocage de 5 minutes en cas de dépassement. C'est volontairement simple, pensé
pour un pic de trafic légitime (lancement de pub) plutôt que pour bloquer un
attaquant déterminé.
Pas de protection anti-bot avancée implémentée (captcha, honeypot) faute de temps. Piste réaliste avec plus de temps : un champ honeypot caché dans le formulaire front (un bot remplit tous les champs, un humain ne voit pas ce champ) plutôt qu'un captcha, moins intrusif pour l'utilisateur.
À chaque inscription réussie, notification envoyée vers AUTOMATION_WEBHOOK_URL
(testé avec webhook.site). Retry simple : 3 tentatives, backoff exponentiel
(1s, 2s, 4s). Si toutes les tentatives échouent, on logue l'échec mais on ne fait
pas échouer la réponse au client : l'inscription reste valide même si le système
d'automatisation externe est en panne.
- Le webhook sortant est envoyé de façon synchrone (le client attend la fin des tentatives de retry avant d'avoir sa réponse). Avec plus de temps, ce serait découplé via une queue pour ne pas ralentir la réponse HTTP en cas de lenteur du système tiers.
- Pas de test de charge réel (juste 6 requêtes séquentielles dans les tests automatisés) pour valider le comportement sous un vrai pic de trafic.
Preuve que la notification d'inscription part bien vers le système d'automatisation :
J'ai utilisé Claude comme assistant pendant ce test, sur les points suivants :
- Structurer l'approche générale (ordre signature → recherche commande → idempotence → cohérence montant → livraison).
- Un bug de transaction Postgres pendant les tests : quand la contrainte unique se déclenchait sur le replay (2ème insertion du même
transaction_id), toute la transaction globale des tests se retrouvait bloquée, même après avoir catché l'erreur côté JS. Postgres considère la transaction "invalide" tant qu'il n'y a pas eu de rollback, donc les requêtes suivantes dans le même test plantaient aussi. Ça m'a pris du temps à comprendre pourquoi le test échouait alors que la logique semblait correcte. La solution proposée (isoler la création duPaymentEventdans sa propre sous-transaction viadb.transaction(), ce qui crée un savepoint) a réglé le problème. - Aide à la rédaction de la structure des tests automatisés (Japa), que j'ai ensuite adaptés et fait tourner moi-même.
Je peux expliquer et justifier chaque choix technique du code, y compris pourquoi ce problème de transaction arrivait et comment le savepoint le résout.


