// Robustness tests — input validation & side-effect isolation // // Chaque test documente le comportement réel du handler face à des entrées invalides. // Les tests marqués [BUG] représentent le comportement ATTENDU — ils échouent // intentionnellement pour exposer un bug dans le handler ciblé. import { assertEquals } from "@std/assert"; import { makeContextWithAffiliation, makeEmployeeContext, makeGetRequest, makeJsonRequest, } from "../helpers/handler.ts"; import { seedModules, seedPromotions, seedStudents, seedUes, truncateAll, } from "../helpers/db_integration.ts"; import { handler as modulesHandler } from "$apps/admin/api/modules.ts"; import { handler as moduleHandler } from "$apps/admin/api/modules/[idModule].ts"; import { handler as notesHandler } from "$apps/notes/api/notes.ts"; import { handler as uesHandler } from "$apps/admin/api/ues.ts"; import { handler as ueModulesHandler } from "$apps/admin/api/ue-modules.ts"; import { handler as ajustementsHandler } from "$apps/notes/api/ajustements.ts"; import { handler as enseignementsHandler } from "$apps/admin/api/enseignements.ts"; import { handler as usersHandler } from "$apps/admin/api/users.ts"; // Helper : request POST avec un body JSON invalide function makeMalformedRequest(path: string): Request { return new Request(`http://localhost${path}`, { method: "POST", headers: { "content-type": "application/json" }, body: "{ ceci n'est pas du json }", }); } // Helper : request POST sans body du tout function makeEmptyBodyRequest(path: string, method = "POST"): Request { return new Request(`http://localhost${path}`, { method }); } // ============================================================================= // JSON MALFORMÉ // ============================================================================= // Handlers AVEC try/catch → retournent 500 // Handlers SANS try/catch → throwent (assertRejects) Deno.test({ name: "robustness: POST /notes malformed JSON → 500 (try/catch présent)", async fn() { await truncateAll(); const res = await notesHandler.POST!( makeMalformedRequest("/notes"), makeEmployeeContext(), ); assertEquals(res.status, 500); }, sanitizeResources: false, sanitizeOps: false, }); Deno.test({ name: "robustness: POST /ues malformed JSON → 500 (try/catch présent)", async fn() { await truncateAll(); const res = await uesHandler.POST!( makeMalformedRequest("/ues"), makeEmployeeContext(), ); assertEquals(res.status, 500); }, sanitizeResources: false, sanitizeOps: false, }); Deno.test({ name: "robustness: POST /ue-modules malformed JSON → 500 (try/catch présent)", async fn() { await truncateAll(); const res = await ueModulesHandler.POST!( makeMalformedRequest("/ue-modules"), makeEmployeeContext(), ); assertEquals(res.status, 500); }, sanitizeResources: false, sanitizeOps: false, }); Deno.test({ name: "robustness: POST /ajustements malformed JSON → 500 (try/catch présent)", async fn() { await truncateAll(); const res = await ajustementsHandler.POST!( makeMalformedRequest("/ajustements"), makeEmployeeContext(), ); assertEquals(res.status, 500); }, sanitizeResources: false, sanitizeOps: false, }); Deno.test({ name: "robustness: POST /modules malformed JSON → 500", async fn() { await truncateAll(); const res = await modulesHandler.POST!( makeMalformedRequest("/modules"), makeEmployeeContext(), ); assertEquals(res.status, 500); }, sanitizeResources: false, sanitizeOps: false, }); Deno.test({ name: "robustness: POST /enseignements malformed JSON → 500", async fn() { await truncateAll(); const res = await enseignementsHandler.POST!( makeMalformedRequest("/enseignements"), makeEmployeeContext(), ); assertEquals(res.status, 500); }, sanitizeResources: false, sanitizeOps: false, }); Deno.test({ name: "robustness: POST /users malformed JSON → 500", async fn() { await truncateAll(); const res = await usersHandler.POST!( makeMalformedRequest("/users"), makeEmployeeContext(), ); assertEquals(res.status, 500); }, sanitizeResources: false, sanitizeOps: false, }); // ============================================================================= // BODY ABSENT // ============================================================================= Deno.test({ name: "robustness: POST /notes sans body → 500", async fn() { await truncateAll(); const res = await notesHandler.POST!( makeEmptyBodyRequest("/notes"), makeEmployeeContext(), ); assertEquals(res.status, 500); }, sanitizeResources: false, sanitizeOps: false, }); Deno.test({ name: "robustness: POST /modules sans body → 500", async fn() { await truncateAll(); const res = await modulesHandler.POST!( makeEmptyBodyRequest("/modules"), makeEmployeeContext(), ); assertEquals(res.status, 500); }, sanitizeResources: false, sanitizeOps: false, }); // ============================================================================= // CHAÎNES VIDES — comportement correct ✓ // ============================================================================= Deno.test({ name: "robustness: POST /modules id vide → 400", async fn() { await truncateAll(); const res = await modulesHandler.POST!( makeJsonRequest("/modules", "POST", { id: "", nom: "Test" }), makeEmployeeContext(), ); assertEquals(res.status, 400); }, sanitizeResources: false, sanitizeOps: false, }); Deno.test({ name: "robustness: POST /modules nom vide → 400", async fn() { await truncateAll(); const res = await modulesHandler.POST!( makeJsonRequest("/modules", "POST", { id: "M1", nom: "" }), makeEmployeeContext(), ); assertEquals(res.status, 400); }, sanitizeResources: false, sanitizeOps: false, }); Deno.test({ name: "robustness: POST /ues nom vide → 400", async fn() { await truncateAll(); const res = await uesHandler.POST!( makeJsonRequest("/ues", "POST", { nom: "" }), makeEmployeeContext(), ); assertEquals(res.status, 400); }, sanitizeResources: false, sanitizeOps: false, }); // ============================================================================= // CHAÎNES AVEC ESPACES SEULS — [BUG] passent !field et s'insèrent en DB // ============================================================================= Deno.test({ name: "robustness: POST /modules id=espaces → 400", async fn() { await truncateAll(); const res = await modulesHandler.POST!( makeJsonRequest("/modules", "POST", { id: " ", nom: "Test" }), makeEmployeeContext(), ); assertEquals(res.status, 400); }, sanitizeResources: false, sanitizeOps: false, }); Deno.test({ name: "robustness: POST /ues nom=espaces → 400", async fn() { await truncateAll(); const res = await uesHandler.POST!( makeJsonRequest("/ues", "POST", { nom: " " }), makeEmployeeContext(), ); assertEquals(res.status, 400); }, sanitizeResources: false, sanitizeOps: false, }); Deno.test({ name: "robustness: POST /users id=espaces → 400", async fn() { await truncateAll(); const res = await usersHandler.POST!( makeJsonRequest("/users", "POST", { id: " ", nom: "X", prenom: "Y" }), makeEmployeeContext(), ); assertEquals(res.status, 400); }, sanitizeResources: false, sanitizeOps: false, }); // ============================================================================= // MAUVAIS TYPES // ============================================================================= Deno.test({ name: "robustness: POST /notes note=string → 400", async fn() { await truncateAll(); await seedPromotions([{ id: "P1" }]); const [s] = await seedStudents([{ nom: "Test", prenom: "User", idPromo: "P1", }]); await seedModules([{ id: "M1", nom: "Mod" }]); const res = await notesHandler.POST!( makeJsonRequest("/notes", "POST", { note: "pas-un-nombre", numEtud: s.numEtud, idModule: "M1", }), makeEmployeeContext(), ); assertEquals(res.status, 400); }, sanitizeResources: false, sanitizeOps: false, }); Deno.test({ name: "robustness: PUT /modules/:id nom=number → 400", async fn() { await truncateAll(); await seedModules([{ id: "M1", nom: "Mod" }]); const res = await moduleHandler.PUT!( makeJsonRequest("/modules/M1", "PUT", { nom: 42 }), makeEmployeeContext({ idModule: "M1" }), ); assertEquals(res.status, 400); }, sanitizeResources: false, sanitizeOps: false, }); // ============================================================================= // VALEUR ZÉRO — falsy bug sur numEtud/idUE // ============================================================================= Deno.test({ name: "robustness [BUG]: POST /ajustements numEtud=0 → 400 pour mauvaise raison", async fn() { await truncateAll(); const [ue] = await seedUes([{ nom: "UE Info" }]); const res = await ajustementsHandler.POST!( makeJsonRequest("/ajustements", "POST", { numEtud: 0, idUE: ue.id, valeur: 10.0, }), makeEmployeeContext(), ); // !0 === true → retourne 400 à cause du falsy check, pas d'une vraie validation // Comportement attendu : 422 ou message d'erreur explicite sur numEtud invalide // Comportement réel : 400 générique "champs requis" assertEquals(res.status, 400); // passe, mais pour la mauvaise raison — le message est trompeur }, sanitizeResources: false, sanitizeOps: false, }); Deno.test({ name: "robustness [BUG]: POST /ajustements idUE=0 → 400 pour mauvaise raison", async fn() { await truncateAll(); await seedPromotions([{ id: "P1" }]); const [s] = await seedStudents([{ nom: "Test", prenom: "User", idPromo: "P1", }]); const res = await ajustementsHandler.POST!( makeJsonRequest("/ajustements", "POST", { numEtud: s.numEtud, idUE: 0, valeur: 10.0, }), makeEmployeeContext(), ); assertEquals(res.status, 400); // !0 → 400, message trompeur }, sanitizeResources: false, sanitizeOps: false, }); // ============================================================================= // VALEUR ZÉRO CORRECTEMENT GÉRÉE — coeff=0 est valide // ============================================================================= Deno.test({ name: "robustness: POST /ue-modules coeff=0 → 201 (zéro est une valeur valide)", async fn() { await truncateAll(); await seedPromotions([{ id: "P1" }]); await seedModules([{ id: "M1", nom: "Mod" }]); const [ue] = await seedUes([{ nom: "UE Info" }]); const res = await ueModulesHandler.POST!( makeJsonRequest("/ue-modules", "POST", { idModule: "M1", idUE: ue.id, idPromo: "P1", coeff: 0, }), makeEmployeeContext(), ); // coeff === undefined → false pour 0 → passe ✓ assertEquals(res.status, 201); }, sanitizeResources: false, sanitizeOps: false, }); // ============================================================================= // INJECTION SQL DANS LES PARAMÈTRES D'URL // Drizzle utilise des requêtes paramétrées → les injections sont neutralisées // ============================================================================= Deno.test({ name: "robustness: GET /modules avec SQL injection dans id → 404 (Drizzle paramètre)", async fn() { await truncateAll(); const injectionId = "'; DROP TABLE modules; --"; const res = await moduleHandler.GET!( makeGetRequest(`/modules/${encodeURIComponent(injectionId)}`), makeEmployeeContext({ idModule: injectionId }), ); // Drizzle génère WHERE id = $1 avec $1 = "'; DROP TABLE modules; --" // Aucune injection possible → module non trouvé → 404 assertEquals(res.status, 404); }, sanitizeResources: false, sanitizeOps: false, }); Deno.test({ name: "robustness: POST /modules avec SQL injection dans id → s'insère littéralement (safe)", async fn() { await truncateAll(); const injectionId = "'; DROP TABLE modules; --"; const res = await modulesHandler.POST!( makeJsonRequest("/modules", "POST", { id: injectionId, nom: "Test" }), makeEmployeeContext(), ); // Drizzle paramètre la valeur → s'insère comme une chaîne ordinaire → 201 assertEquals(res.status, 201); }, sanitizeResources: false, sanitizeOps: false, }); // ============================================================================= // ABSENCE DE VALIDATION MÉTIER — valeurs hors limites acceptées // ============================================================================= Deno.test({ name: "robustness: POST /notes note > 20 → 400", async fn() { await truncateAll(); await seedPromotions([{ id: "P1" }]); const [s] = await seedStudents([{ nom: "Test", prenom: "User", idPromo: "P1", }]); await seedModules([{ id: "M1", nom: "Mod" }]); const res = await notesHandler.POST!( makeJsonRequest("/notes", "POST", { note: 999, numEtud: s.numEtud, idModule: "M1", }), makeEmployeeContext(), ); assertEquals(res.status, 400); }, sanitizeResources: false, sanitizeOps: false, }); Deno.test({ name: "robustness: POST /notes note < 0 → 400", async fn() { await truncateAll(); await seedPromotions([{ id: "P1" }]); const [s] = await seedStudents([{ nom: "Test", prenom: "User", idPromo: "P1", }]); await seedModules([{ id: "M1", nom: "Mod" }]); const res = await notesHandler.POST!( makeJsonRequest("/notes", "POST", { note: -5, numEtud: s.numEtud, idModule: "M1", }), makeEmployeeContext(), ); assertEquals(res.status, 400); }, sanitizeResources: false, sanitizeOps: false, }); Deno.test({ name: "robustness: POST /ue-modules coeff négatif → 400", async fn() { await truncateAll(); await seedPromotions([{ id: "P1" }]); await seedModules([{ id: "M1", nom: "Mod" }]); const [ue] = await seedUes([{ nom: "UE Info" }]); const res = await ueModulesHandler.POST!( makeJsonRequest("/ue-modules", "POST", { idModule: "M1", idUE: ue.id, idPromo: "P1", coeff: -3, }), makeEmployeeContext(), ); assertEquals(res.status, 400); }, sanitizeResources: false, sanitizeOps: false, }); // ============================================================================= // ISOLATION DES EFFETS DE BORD // Vérification que truncateAll() isole correctement chaque test // ============================================================================= Deno.test({ name: "robustness: isolation — données du test précédent non visibles", async fn() { // Ce test crée un module await truncateAll(); await modulesHandler.POST!( makeJsonRequest("/modules", "POST", { id: "ISOLATION-TEST", nom: "Test", }), makeEmployeeContext(), ); }, sanitizeResources: false, sanitizeOps: false, }); Deno.test({ name: "robustness: isolation — truncateAll efface bien les données du test précédent", async fn() { await truncateAll(); // Le module créé dans le test précédent ne doit plus exister const res = await moduleHandler.GET!( makeGetRequest("/modules/ISOLATION-TEST"), makeEmployeeContext({ idModule: "ISOLATION-TEST" }), ); assertEquals(res.status, 404); }, sanitizeResources: false, sanitizeOps: false, }); // ============================================================================= // CHAMPS SUPPLÉMENTAIRES INCONNUS — doivent être ignorés silencieusement // ============================================================================= Deno.test({ name: "robustness: POST /modules avec champs inconnus → 201 (champs ignorés)", async fn() { await truncateAll(); const res = await modulesHandler.POST!( makeJsonRequest("/modules", "POST", { id: "M-EXTRA", nom: "Test", champInconnu: "valeur", _admin: true, __proto__: { polluted: true }, }), makeEmployeeContext(), ); assertEquals(res.status, 201); }, sanitizeResources: false, sanitizeOps: false, }); // ============================================================================= // ACCÈS NON AUTHENTIFIÉ — vérification que l'état auth est bien contrôlé // ============================================================================= Deno.test({ name: "robustness: POST /modules sans affiliation employee → 403", async fn() { await truncateAll(); for (const role of ["student", "alumni", "", "EMPLOYEE", "admin"]) { const res = await modulesHandler.POST!( makeJsonRequest("/modules", "POST", { id: `M-${role}`, nom: "Test" }), makeContextWithAffiliation(role), ); assertEquals(res.status, 403, `role "${role}" devrait être 403`); } }, sanitizeResources: false, sanitizeOps: false, });