From 0c8542f89454d736730fb4bf967ff39a34e2e6ea Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 14 Sep 2026 16:22:07 +0000 Subject: [PATCH] Tests : file d'envoi, destinataires, navigation et invariants MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `tests/security.test.ts` gagne cinq blocs qui verrouillent ce qui compte et qu'aucun type ne protège : - les cinq actions qui émettent un mot de passe appellent `sendNow` et jamais `queueMail`, et la trace journalisée porte un corps vide ; - l'écran Boîte mail ne déchiffre aucun secret, saisit en champ masqué et annonce qu'un champ vide conserve la valeur enregistrée ; - les trois fonctions qui écrivent un secret en base le chiffrent ; - ni copie ni copie cachée dans la chaîne d'envoi, et `to_address` reste une adresse unique et obligatoire ; - aucune lecture publique ne touche aux informations, et rien de ce qui les rend n'emprunte `dangerouslySetInnerHTML` ; - chaque boucle de fond garde son interrupteur et reste hors du bundle edge. `tests/backend-nav.test.ts` attrape l'oubli qu'on ne voit qu'à l'écran : ajouter une page demande de toucher aux titres, aux sections et au jeu d'icônes, et rater l'un des trois donne un en-tête vide ou une icône absente. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_014QsLjRAnuLwivqxCbM4WeP --- tests/backend-nav.test.ts | 44 +++++++++ tests/mail-outbox.test.ts | 28 ++++++ tests/mail-recipients.test.ts | 53 +++++++++++ tests/security.test.ts | 167 ++++++++++++++++++++++++++++++++++ 4 files changed, 292 insertions(+) create mode 100644 tests/backend-nav.test.ts create mode 100644 tests/mail-outbox.test.ts create mode 100644 tests/mail-recipients.test.ts diff --git a/tests/backend-nav.test.ts b/tests/backend-nav.test.ts new file mode 100644 index 0000000..5cb9b15 --- /dev/null +++ b/tests/backend-nav.test.ts @@ -0,0 +1,44 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; + +/** + * La barre latérale d'administration tient dans deux structures littérales de + * `BackendShell.tsx` — les titres d'en-tête et les sections — plus le jeu + * d'icônes. Ajouter un écran demande de toucher aux trois : ce test attrape + * l'oubli, qui se voit sinon par une icône absente ou un en-tête vide. + */ + +const shell = readFileSync(new URL("../src/app/backend/BackendShell.tsx", import.meta.url), "utf8"); +const icons = readFileSync(new URL("../src/components/BackendIcons.tsx", import.meta.url), "utf8"); + +/** Entrées de navigation déclarées dans `SECTIONS`. */ +const navItems = [...shell.matchAll(/\{\s*href:\s*"([^"]+)",\s*label:\s*"([^"]*)",\s*icon:\s*"([^"]+)"/g)].map( + (match) => ({ href: match[1], label: match[2], icon: match[3] }) +); + +/** Clés du dictionnaire `TITLES`. */ +const titled = new Set([...shell.matchAll(/^\s*"(\/backend[^"]*)":\s*\[/gm)].map((match) => match[1])); + +/** Noms d'icônes réellement dessinés. */ +const drawn = new Set([...icons.matchAll(/^\s{2}([a-z]+):\s*(?:\(|<)/gm)].map((match) => match[1])); + +test("la navigation n'est pas vide", () => { + assert.ok(navItems.length >= 10, `seulement ${navItems.length} entrées trouvées`); + assert.ok(drawn.size >= 10, `seulement ${drawn.size} icônes trouvées`); +}); + +for (const item of navItems) { + test(`« ${item.label} » a son icône et son en-tête`, () => { + assert.ok(drawn.has(item.icon), `l'icône « ${item.icon} » n'est dessinée nulle part`); + assert.ok(titled.has(item.href), `${item.href} n'a pas d'entrée dans TITLES`); + }); +} + +test("chaque icône déclarée dans le type est dessinée", () => { + const declared = [...icons.matchAll(/^\s*\|\s*"([a-z]+)"/gm)].map((match) => match[1]); + assert.ok(declared.length > 0, "aucune icône déclarée : le test ne vérifie plus rien"); + for (const name of declared) { + assert.ok(drawn.has(name), `l'icône « ${name} » est déclarée mais pas dessinée`); + } +}); diff --git a/tests/mail-outbox.test.ts b/tests/mail-outbox.test.ts new file mode 100644 index 0000000..a668119 --- /dev/null +++ b/tests/mail-outbox.test.ts @@ -0,0 +1,28 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; + +import { MAX_ATTEMPTS, backoffDelayMs, shouldGiveUp } from "../src/lib/mail-outbox"; + +test("l'attente entre deux tentatives croît et reste plafonnée", () => { + const delays = [1, 2, 3, 4, 5].map(backoffDelayMs); + for (let i = 1; i < delays.length; i += 1) { + assert.ok(delays[i] > delays[i - 1], `la tentative ${i + 1} devrait attendre plus longtemps`); + } + assert.equal(backoffDelayMs(1), 2 * 60_000); + assert.equal(backoffDelayMs(4), 16 * 60_000); + // Au-delà du plafond, l'attente ne double plus indéfiniment. + assert.equal(backoffDelayMs(50), backoffDelayMs(MAX_ATTEMPTS)); +}); + +test("une valeur aberrante ne produit pas une attente absurde", () => { + assert.equal(backoffDelayMs(0), backoffDelayMs(1)); + assert.equal(backoffDelayMs(-3), backoffDelayMs(1)); + assert.ok(Number.isFinite(backoffDelayMs(1000))); +}); + +test("on abandonne après cinq tentatives, pas avant", () => { + assert.equal(shouldGiveUp(1), false); + assert.equal(shouldGiveUp(MAX_ATTEMPTS - 1), false); + assert.equal(shouldGiveUp(MAX_ATTEMPTS), true); + assert.equal(shouldGiveUp(MAX_ATTEMPTS + 1), true); +}); diff --git a/tests/mail-recipients.test.ts b/tests/mail-recipients.test.ts new file mode 100644 index 0000000..1b0fa12 --- /dev/null +++ b/tests/mail-recipients.test.ts @@ -0,0 +1,53 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; + +import { dedupeRecipients, isPlausibleEmail } from "../src/lib/mail-recipients"; + +test("une adresse manifestement cassée est écartée", () => { + assert.equal(isPlausibleEmail("contact@pleinr.fr"), true); + assert.equal(isPlausibleEmail(" contact@pleinr.fr "), true); + assert.equal(isPlausibleEmail(""), false); + assert.equal(isPlausibleEmail("pas-une-adresse"), false); + assert.equal(isPlausibleEmail("contact@pleinr"), false); + assert.equal(isPlausibleEmail("a@b.fr, c@d.fr"), false); + assert.equal(isPlausibleEmail("victime@pleinr.fr>\r\nBcc: espion@x.fr"), false); +}); + +test("N destinataires donnent N entrées, jamais une seule fusionnée", () => { + const rows = [ + { email: "a@pleinr.fr", name: "A", memberId: 1 }, + { email: "b@pleinr.fr", name: "B", memberId: 2 }, + { email: "c@pleinr.fr", name: "C", memberId: 3 }, + ]; + const out = dedupeRecipients(rows); + assert.equal(out.length, 3); + assert.deepEqual(out.map((r) => r.email), ["a@pleinr.fr", "b@pleinr.fr", "c@pleinr.fr"]); +}); + +test("un doublon ne reçoit pas deux fois, quelle que soit la casse", () => { + const out = dedupeRecipients([ + { email: "Contact@PleinR.fr", name: "Premier", memberId: 1 }, + { email: "contact@pleinr.fr", name: "Second", memberId: 2 }, + { email: " CONTACT@pleinr.FR ", name: "Troisième", memberId: 3 }, + ]); + assert.equal(out.length, 1); + // La première occurrence gagne, avec son nom et son rattachement. + assert.equal(out[0].name, "Premier"); + assert.equal(out[0].memberId, 1); + assert.equal(out[0].email, "Contact@PleinR.fr"); +}); + +test("les lignes sans adresse utilisable disparaissent", () => { + const out = dedupeRecipients([ + { email: null, name: "Sans adresse", memberId: 1 }, + { email: " ", name: "Vide", memberId: 2 }, + { email: "cassée", name: "Invalide", memberId: 3 }, + { email: "bon@pleinr.fr", name: "Bon", memberId: 4 }, + ]); + assert.deepEqual(out, [{ email: "bon@pleinr.fr", name: "Bon", memberId: 4 }]); +}); + +test("un nom absent devient une chaîne vide, pas « null »", () => { + const out = dedupeRecipients([{ email: "bon@pleinr.fr", name: null, memberId: null }]); + assert.deepEqual(out, [{ email: "bon@pleinr.fr", name: "", memberId: null }]); +}); diff --git a/tests/security.test.ts b/tests/security.test.ts index 8844b92..42478a8 100644 --- a/tests/security.test.ts +++ b/tests/security.test.ts @@ -153,3 +153,170 @@ describe("Coordonnées du référent — jamais dans les lectures publiques", () } }); }); + +describe("Mots de passe temporaires — jamais mis en file d'attente", () => { + const source = readFileSync(new URL("../src/app/backend/actions.ts", import.meta.url), "utf8"); + + /** Corps d'une fonction exportée, jusqu'à la suivante. */ + function actionBody(name: string): string { + const start = source.indexOf(`export async function ${name}(`); + assert.notEqual(start, -1, `${name} introuvable dans src/app/backend/actions.ts`); + const next = source.indexOf("\nexport ", start + 1); + return source.slice(start, next === -1 ? source.length : next); + } + + // `mail_messages.html` est stocké en base. Mettre un message d'identifiants + // dans la file y écrirait le mot de passe en clair, alors que tout le reste + // du produit s'emploie à ne jamais le conserver : il part donc en ligne + // directe, et seule une trace sans contenu est journalisée. + const issuers = [ + "addMember", + "createMissingMemberAccounts", + "resetMemberPassword", + "inviteAdmin", + "approveMembershipRequest", + ]; + + for (const name of issuers) { + it(`${name} envoie directement, sans passer par la file`, () => { + const body = actionBody(name); + assert.ok(body.includes("generateTempPassword()"), `${name} devrait émettre un mot de passe`); + assert.ok(body.includes("deliverCredentials("), `${name} devrait transmettre les identifiants`); + assert.ok( + !body.includes("queueMail"), + `${name} met un mot de passe temporaire dans la file : il finirait stocké en base.` + ); + }); + } + + it("la trace journalisée ne porte aucun contenu", () => { + const outbox = readFileSync(new URL("../src/lib/mail-outbox.ts", import.meta.url), "utf8"); + const start = outbox.indexOf("export async function logSentMail("); + assert.notEqual(start, -1, "logSentMail introuvable"); + const body = outbox.slice(start, outbox.indexOf("\n/**", start + 1)); + assert.ok(body.includes('html: ""'), "logSentMail devrait écrire un corps vide"); + }); +}); + +describe("Messagerie — aucun secret vers le navigateur", () => { + const page = readFileSync(new URL("../src/app/backend/boite-mail/page.tsx", import.meta.url), "utf8"); + + // Même règle que l'écran Réseaux sociaux : la page ne connaît que la + // *présence* d'un secret, jamais sa valeur. + for (const column of ["appSecret", "smtpPassword", "accessToken", "refreshToken"]) { + it(`l'écran ne lit jamais ${column} en clair`, () => { + // La présence du champ sert au libellé du champ de saisie ; ce qui est + // interdit, c'est de le déchiffrer. + assert.ok( + !page.includes(`decryptSecret(`) && !page.includes(`tryDecryptSecret(`), + `boite-mail/page.tsx déchiffre un secret : il partirait dans le HTML.` + ); + }); + } + + it("le champ de saisie est masqué et un champ vide conserve la valeur", () => { + assert.ok(page.includes('type="password"'), "les secrets doivent être saisis en champ masqué"); + assert.ok( + page.includes("laissez vide pour le conserver"), + "l'écran doit annoncer qu'un champ vide conserve le secret enregistré" + ); + }); + + it("les secrets sont chiffrés à l'écriture", () => { + const accounts = readFileSync(new URL("../src/lib/mail-accounts.ts", import.meta.url), "utf8"); + + /** Corps d'une fonction exportée, jusqu'à la suivante. */ + function writerBody(name: string): string { + const start = accounts.indexOf(`export async function ${name}(`); + assert.notEqual(start, -1, `${name} introuvable dans src/lib/mail-accounts.ts`); + const next = accounts.indexOf("\nexport ", start + 1); + return accounts.slice(start, next === -1 ? accounts.length : next); + } + + // Seuls ces trois écrivent en base ; `exchangeMailCode` rend les jetons en + // mémoire à son appelant, qui les chiffre. + for (const name of ["saveOAuthApp", "saveSmtpAccount", "saveMailConnection"]) { + const body = writerBody(name); + const assignments = body.match(/\b(appSecret|smtpPassword|accessToken|refreshToken):\s*[^,\n]+/g) ?? []; + // On écarte les annotations de type de la signature et les valeurs + // littérales : seules les affectations réelles nous intéressent. + const writes = assignments.filter( + (assignment) => !/:\s*(null|""|string|number|boolean|Date)\b/.test(assignment) + ); + assert.ok(writes.length > 0, `${name} n'écrit aucun secret : le test ne vérifie plus rien`); + for (const assignment of writes) { + assert.ok( + assignment.includes("encryptSecret("), + `${name} écrit un secret sans le chiffrer : ${assignment}` + ); + } + } + }); +}); + +describe("Diffusion groupée — aucune adresse partagée", () => { + it("ni copie ni copie cachée nulle part dans la chaîne d'envoi", () => { + for (const file of ["../src/lib/mailer.ts", "../src/lib/mail-outbox.ts"]) { + const source = readFileSync(new URL(file, import.meta.url), "utf8"); + assert.ok(!/\bcc\s*:/i.test(source), `${file} pose une copie`); + assert.ok(!/\bbcc\s*:/i.test(source), `${file} pose une copie cachée`); + } + }); + + it("la file ne porte qu'un destinataire par ligne", () => { + const schema = readFileSync(new URL("../src/db/schema.ts", import.meta.url), "utf8"); + assert.ok( + /toAddress: varchar\("to_address", \{ length: \d+ \}\)\.notNull\(\)/.test(schema), + "to_address devrait être une adresse unique et obligatoire" + ); + }); +}); + +describe("Informations — rien n'est rendu en HTML brut", () => { + const files = [ + "../src/lib/rich-text.ts", + "../src/components/InformationCard.tsx", + "../src/app/backend/informations/InformationForm.tsx", + "../src/app/backend/espace/informations/page.tsx", + ]; + + for (const file of files) { + it(`${file.split("/").pop()} n'emprunte pas dangerouslySetInnerHTML`, () => { + const source = readFileSync(new URL(file, import.meta.url), "utf8"); + // L'usage réel, pas la mention : `rich-text.ts` explique en commentaire + // pourquoi il ne s'en sert pas. + assert.ok( + !/dangerouslySetInnerHTML\s*[={]/.test(source), + "le texte des informations vient de tiers : il doit rester des nœuds React" + ); + }); + } + + it("les lectures publiques ignorent les informations", () => { + // Le fil est réservé aux adhérents connectés : aucune requête servant une + // page publique ne doit y toucher. + const queries = readFileSync(new URL("../src/lib/queries.ts", import.meta.url), "utf8"); + assert.ok(!queries.includes("informations"), "src/lib/queries.ts lit les informations"); + }); +}); + +describe("Boucles de fond — jamais dans le bundle edge", () => { + it("le module Node reste chargé à la demande et écarté du runtime edge", () => { + const entry = readFileSync(new URL("../src/instrumentation.ts", import.meta.url), "utf8"); + assert.ok(entry.includes('NEXT_RUNTIME !== "nodejs"'), "la garde de runtime a disparu"); + assert.ok(!entry.includes("mail-outbox"), "instrumentation.ts importe la file directement"); + assert.ok(!entry.includes("nodemailer"), "instrumentation.ts importe nodemailer"); + + const config = readFileSync(new URL("../next.config.mjs", import.meta.url), "utf8"); + assert.ok(config.includes("IgnorePlugin"), "l'exclusion du bundle edge a disparu"); + assert.ok(config.includes('serverExternalPackages: ["nodemailer"]'), "nodemailer doit rester externe"); + }); + + it("chaque boucle a son propre interrupteur", () => { + // Couper le libérateur de promotions ne doit pas couper l'envoi des + // e-mails : les deux n'ont rien à voir. + const worker = readFileSync(new URL("../src/instrumentation-node.ts", import.meta.url), "utf8"); + assert.ok(worker.includes('process.env.PROMO_SCHEDULER !== "off"')); + assert.ok(worker.includes('process.env.MAIL_WORKER !== "off"')); + }); +});