Skip to content

nosleepman1/secure_webhook_Xarala

Repository files navigation

Xarala - Test Technique Full-Stack

Sommaire

Pourquoi AdonisJS et pas Django

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.

Stack

  • 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)

Installation

1. Cloner le repo :

git clone https://github.com/nosleepman1/secure_webhook_Xarala
cd secure_webhook_Xarala
npm install

2. Copier le fichier d'environnement :

cp .env.example .env

3. Générer une clé d'app si besoin :

node ace generate:key

4. Base de données :

Si 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_webhook

Puis créez la base :

createdb xarala_webhook

Si vous n'avez pas Postgres sous la main, le plus simple est d'installer SQLite le temps du test :

npm install better-sqlite3

Et 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).

5. Redis (nécessaire pour le rate limiting de la Partie B2) :

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 start

Vé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=6379

Alternative si vous préférez Docker plutôt que WSL :

docker run -d --name redis-xarala -p 6379:6379 redis

6. Lancer les migrations :

node ace migration:run

7. Lancer les tests :

node ace test

8. Lancer le serveur :

node ace serve --watch

Le webhook est disponible sur POST http://localhost:3333/webhooks/payment.


Partie A — Webhook Paiement

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.

Comment ça marche

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.

Limites assumées / ce que je ferais avec plus de temps

  • 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 timestamp pour 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.

Partie B2 — API d'inscription anti-doublons

Endpoint

POST /registrations avec {name, email, phone_whatsapp, city}.

Anti-doublons

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.

Validation

Format téléphone sénégalais vérifié par regex : ^\+221[0-9]{9}$. Email validé par le validator standard de VineJS.

Anti-abus

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.

Webhook sortant

À 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.

Limites assumées

  • 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.

Preuves de fonctionnement

Tests automatisés (11/11 passent)

Tests qui passent 1 Tests qui passent 2

Webhook sortant reçu sur webhook.site (Partie B2)

Preuve que la notification d'inscription part bien vers le système d'automatisation :

Requête reçue sur webhook.site


Utilisation de l'IA

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 du PaymentEvent dans sa propre sous-transaction via db.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.


Partie C - Lien documents de Reponses aux questions

About

Full-stack technical assessment for Xarala featuring a resilient payment webhook, anti-duplicate registration API, system design reflections, automated tests, and production-oriented architecture built with AdonisJS & TypeScript.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

No releases published

Packages

 
 
 

Contributors