@mostajs/wagon-engine 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,592 @@
1
+ // @mostajs/wagon-engine — LA SUITE DE CONFORMITÉ : elle dit si une greffe tient.
2
+ // La greffe jugée est celle de WAGON_GREFFE, sinon le dossier courant : sa configuration (wagon.config.mjs), sa
3
+ // charge (charge/index.mjs) et ses textes légaux (legal/). Lancer depuis la greffe :
4
+ // mjs-unit node_modules/@mostajs/wagon-engine/src/conformite/conformite.test.mjs
5
+ // Author: Dr Hamid MADANI <drmdh@msn.com> · AGPL-3.0-or-later
6
+ //
7
+ // Le gabarit (plan B de l'audit du 01/10/2026) : le wagon de mdseal — comptes, connexion, paliers,
8
+ // quotas, paiement, espace client, administration, pages légales, fr/en/ar — prêt à recevoir une
9
+ // charge utile. Cette suite tourne sur la CHARGE D'EXEMPLE (charge/) ; après une greffe, elle tourne
10
+ // sur la vôtre. Base sqlite réelle ; signatures Stripe réelles, Stripe simulé pour le reste.
11
+ // 08/10/2026 : elle ne suppose plus la charge d'exemple (`/outil`, palier `pro`, `transformationsParJour`) :
12
+ // elle lit le palier payant et les limites dans wagon.config.mjs, et l'essai de quota dans
13
+ // `charge.conformite` — sans cette description, T-WAG-3 est ignoré (« sans objet »), jamais faussement vert.
14
+ // Écrit AVANT le code.
15
+ import { test, expect } from '@mostajs/mjs-unit';
16
+ import { createServer, request } from 'node:http';
17
+ import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
18
+ import { tmpdir } from 'node:os';
19
+ import { join, resolve } from 'node:path';
20
+ import { pathToFileURL } from 'node:url';
21
+ import Stripe from 'stripe';
22
+ import { creerWagon } from '../wagon.mjs';
23
+
24
+ const GREFFE = resolve(process.env.WAGON_GREFFE || process.cwd());
25
+ if (!existsSync(join(GREFFE, 'wagon.config.mjs')) || !existsSync(join(GREFFE, 'charge', 'index.mjs'))) {
26
+ throw new Error(`conformité : greffe introuvable dans ${GREFFE} (wagon.config.mjs et charge/index.mjs attendus ; WAGON_GREFFE la désigne)`);
27
+ }
28
+ const config = (await import(pathToFileURL(join(GREFFE, 'wagon.config.mjs')).href)).default;
29
+ const charge = (await import(pathToFileURL(join(GREFFE, 'charge', 'index.mjs')).href)).default;
30
+
31
+ /** Ce que la suite lit de la GREFFE au lieu de le supposer. */
32
+ const ACTIFS = config.paliers.filter((p) => p.active !== false).sort((a, b) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0));
33
+ const PAYANT = ACTIFS.find((p) => Number(p.price) > 0) ?? null;
34
+ const GRATUIT = ACTIFS.find((p) => p.slug === (config.planGratuit ?? 'gratuit'));
35
+ const RESS = charge.ressources ?? [];
36
+ const LIMITE = (RESS.find((r) => r.parPeriode) ?? RESS[0])?.limite ?? null;
37
+ /** { route, formulaire, limite, attendu, nonCompris?: { route, json } } — l'essai de quota de la charge. */
38
+ const ESSAI = charge.conformite?.route ? charge.conformite : null;
39
+ /** `conformite.tarifs: false` : la charge remplace /tarifs (ex. les prix de ses voyages) — les paliers n'y sont pas. */
40
+ const TARIFS = charge.conformite?.tarifs !== false;
41
+ const MAIL_PAYANT = 'payant@exemple.test';
42
+ /** Un essai sans objet pour cette greffe : ignoré AVEC son motif (mjs-unit ≥ 0.8 : il entre au rapport et au compte rendu). */
43
+ const sansObjet = (motif) => (nom, fn) => test.skip(nom, fn, `sans objet : ${motif}`);
44
+
45
+ const atelier = mkdtempSync(join(tmpdir(), 'wagon-'));
46
+ const SECRET = 'whsec_essai';
47
+ const vrai = new Stripe('sk_test_hors_reseau');
48
+ const appels = { sessions: [], portails: [] };
49
+ let n = 0;
50
+ const stripe = {
51
+ webhooks: vrai.webhooks,
52
+ products: { create: async () => ({ id: 'prod_x' }) }, prices: { create: async () => ({ id: `price_${++n}` }) },
53
+ customers: { create: async () => ({ id: `cus_${++n}` }) },
54
+ checkout: { sessions: { create: async (p) => { appels.sessions.push(p); return { id: `cs_${++n}`, url: `https://checkout.stripe.test/${n}` }; } } },
55
+ billingPortal: { sessions: { create: async (p) => { appels.portails.push(p); return { url: 'https://billing.stripe.test/portail' }; } } },
56
+ };
57
+ const BOITE = join(atelier, 'courriels.jsonl');
58
+
59
+ // 0.4 (audit du 09/10/2026) — un document légal incomplet ne se sert pas (SAAS-APP §7.2), et l'on ne vend pas sans
60
+ // CGV complètes (DEVRULES §11.10). L'instance principale reçoit donc des documents COMPLETS (dossier d'essai) ; les
61
+ // modèles du gabarit, qui portent encore leurs « À FAIRE TRANCHER », sont éprouvés à part (T-WAG-17).
62
+ const LEGAL_COMPLET = join(mkdtempSync(join(tmpdir(), 'wagon-legal-')), 'legal'); // hors de l'atelier, que T-WAG-9 efface
63
+ mkdirSync(LEGAL_COMPLET);
64
+ for (const doc of ['mentions-legales', 'cgu', 'cgv', 'confidentialite']) {
65
+ writeFileSync(join(LEGAL_COMPLET, `${doc}.fr.md`), `# ${doc}\n\n{{SERVICE}} est édité par {{EDITEUR_NOM}}. Contact : {{CONTACT_EMAIL}}. Paiement : {{PAIEMENT}}.\n`);
66
+ }
67
+ const VALEURS_LEGALES = { EDITEUR_NOM: 'Éditeur Essai SARL' };
68
+
69
+ /** 0.3 — le port de paiement simulé des banques prépayées : ce que @mostajs/payment fait, sans banque. */
70
+ const ordres = new Map();
71
+ const portPaiement = {
72
+ async createPaymentCheckout(input) {
73
+ ordres.set(input.orderId, { orderId: input.orderId, amount: input.amount, currency: input.currency, status: 'pending', metadata: input.metadata });
74
+ return { checkout: { url: `https://pay.chargily.net/test/checkouts/${input.orderId}` }, payment: ordres.get(input.orderId) };
75
+ },
76
+ async settlePaymentFromWebhook({ body }) {
77
+ const e = JSON.parse(body);
78
+ if (e.sig !== 'ok') return { ok: false, reason: 'invalid_signature' };
79
+ const payment = ordres.get(e.orderId) ?? null;
80
+ return { ok: true, orderId: e.orderId, status: e.status, payment };
81
+ },
82
+ };
83
+ const BANQUES = config.paiement?.prepaye ?? {};
84
+ const OFFRE = PAYANT && Object.entries(BANQUES).map(([b, dev]) => [b, dev, (PAYANT.prepaid ?? {})[dev]]).find(([, , p]) => p?.month);
85
+
86
+ async function demarrer(plus = {}) {
87
+ const w = await creerWagon({
88
+ config: { ...config, admins: ['admin@exemple.test'], dossierLegal: LEGAL_COMPLET, legal: { ...(config.legal ?? {}), ...VALEURS_LEGALES }, freinInscription: { capacity: 1000, refillPerSec: 100 }, ...plus },
89
+ charge: plus.charge ?? charge, sgbd: plus.sgbd ?? join(atelier, `w${Math.random()}.db`), baseUrl: 'http://127.0.0.1', racine: GREFFE, stripe, webhookSecret: SECRET,
90
+ transport: { envoyer: async (m) => { const { appendFileSync } = await import('node:fs'); appendFileSync(BOITE, `${JSON.stringify(m)}\n`); } },
91
+ paiement: { banques: plus.banques ?? BANQUES, port: plus.port ?? portPaiement },
92
+ });
93
+ await w.catalogue(); await w.catalogueStripe();
94
+ const s = createServer(w.handler);
95
+ await new Promise((ok) => s.listen(0, '127.0.0.1', ok)); s.unref();
96
+ return { w, port: s.address().port, fermer: async () => { s.closeAllConnections?.(); s.close(); await w.fermer(); } };
97
+ }
98
+ const A = await demarrer();
99
+ const B = `http://127.0.0.1:${A.port}`;
100
+
101
+ /** Une requête : node:http, pour poser les en-têtes qu'un navigateur poserait (Host, Sec-Fetch-Site). */
102
+ const req = (base, chemin, { method = 'GET', form, json, headers = {}, cookie } = {}) => new Promise((ok, ko) => {
103
+ const corps = form ? new URLSearchParams(form).toString() : json ?? null;
104
+ const h = { ...(form ? { 'content-type': 'application/x-www-form-urlencoded' } : {}), ...(cookie ? { cookie } : {}), ...headers };
105
+ const r = request(`${base}${chemin}`, { method, headers: h }, (res) => {
106
+ const c = []; res.on('data', (x) => c.push(x)).on('end', () => ok({ status: res.statusCode, headers: res.headers, texte: Buffer.concat(c).toString(), cookie: (res.headers['set-cookie'] || []).map((x) => x.split(';')[0]).find((x) => /^wagon_session=./.test(x)) || '' }));
107
+ });
108
+ r.on('error', ko); if (corps) r.write(corps); r.end();
109
+ });
110
+ const courriels = () => (existsSync(BOITE) ? readFileSync(BOITE, 'utf8').trim().split('\n').filter(Boolean).map((l) => JSON.parse(l)) : []);
111
+ const MDP = 'Une-Phrase-Longue-2026';
112
+ async function inscrire(base, courriel, plan = 'gratuit') {
113
+ const r = await req(base, '/inscription', { method: 'POST', form: { plan, nom: `Société ${courriel}`, courriel, motdepasse: MDP } });
114
+ return r;
115
+ }
116
+ const connecter = async (base, courriel) => (await req(base, '/connexion', { method: 'POST', form: { courriel, motdepasse: MDP } })).cookie;
117
+
118
+ test('T-WAG-1 — la greffe démarre ; identité et charge configurées', async () => {
119
+ const h = (await req(B, '/')).texte;
120
+ expect(h.includes(config.marque.nom)).toBe(true);
121
+ for (const l of charge.liens) expect(h.includes(`href="${l.href}"`)).toBe(true);
122
+ expect(/mdseal|docme/i.test(h)).toBe(false);
123
+ if (TARIFS) {
124
+ const t = (await req(B, '/tarifs')).texte;
125
+ // Le prix tel que wagon-ui ≥ 0.4 l'écrit (Intl, langue par défaut) : « 9,99 € », plus « 9.99 € ».
126
+ const prix = (c) => new Intl.NumberFormat(config.langues?.[0] ?? 'fr', { style: 'currency', currency: 'EUR' }).format(c / 100);
127
+ for (const p of config.paliers.filter((x) => x.active !== false && x.price)) expect(t.includes(prix(p.price))).toBe(true);
128
+ }
129
+ });
130
+
131
+ let cleApi = '';
132
+ test('T-WAG-2 — inscription, connexion, espace, déconnexion', async () => {
133
+ const r = await inscrire(B, 'anis@exemple.test');
134
+ expect(r.status).toBe(200);
135
+ const m = r.texte.match(/sk_(?:test|live)_[0-9a-f]{64}/);
136
+ expect(m !== null).toBe(true);
137
+ cleApi = m[0];
138
+ expect(r.cookie !== '').toBe(true);
139
+ const deja = await inscrire(B, 'anis@exemple.test');
140
+ expect(/existe déjà/i.test(deja.texte)).toBe(true);
141
+ expect((await req(B, '/connexion', { method: 'POST', form: { courriel: 'anis@exemple.test', motdepasse: 'faux-faux-faux-faux' } })).cookie).toBe('');
142
+ const c = await connecter(B, 'anis@exemple.test');
143
+ expect(c !== '').toBe(true);
144
+ const e = (await req(B, '/compte', { cookie: c })).texte;
145
+ expect(e.includes('Gratuit')).toBe(true);
146
+ expect(e.includes(cleApi.slice(0, 12))).toBe(true);
147
+ expect(e.includes(cleApi)).toBe(false);
148
+ const d = await req(B, '/deconnexion', { method: 'POST', cookie: c, form: {} }); // 0.5 : un POST
149
+ expect(d.status).toBe(303);
150
+ expect((await req(B, '/compte', { cookie: c })).status).toBe(303);
151
+ });
152
+
153
+ (ESSAI ? test : sansObjet('la charge ne déclare pas charge.conformite'))(`T-WAG-3 — quotas : anonyme, palier, 429, 403${ESSAI ? '' : ' (sans objet : la charge ne déclare pas charge.conformite)'}`, async () => {
154
+ const outil = ESSAI.route;
155
+ // anonyme : la part par adresse (palier « anonyme »), puis 429
156
+ const anon = config.paliers.find((p) => p.slug === (config.planAnonyme ?? 'anonyme')).limits[ESSAI.limite];
157
+ for (let i = 0; i < anon; i++) expect((await req(B, outil, { method: 'POST', form: ESSAI.formulaire })).status).toBe(200);
158
+ const trop = await req(B, outil, { method: 'POST', form: ESSAI.formulaire });
159
+ expect(trop.status).toBe(429);
160
+ expect(Number(trop.headers['retry-after']) > 0).toBe(true);
161
+ // un compte : la part de son palier (plus large que l'anonyme)
162
+ const c = await connecter(B, 'anis@exemple.test');
163
+ const ok = await req(B, outil, { method: 'POST', form: ESSAI.formulaire, cookie: c });
164
+ expect(ok.status).toBe(200);
165
+ if (ESSAI.attendu) expect(ok.texte.includes(ESSAI.attendu)).toBe(true);
166
+ // une ressource que le palier ne comprend pas : 403, définitif
167
+ if (ESSAI.nonCompris) {
168
+ const api = await req(B, ESSAI.nonCompris.route, { method: 'POST', json: ESSAI.nonCompris.json, headers: { 'content-type': 'application/json', authorization: `Bearer ${cleApi}` } });
169
+ expect(api.status).toBe(403);
170
+ }
171
+ });
172
+
173
+ (PAYANT ? test : sansObjet('aucun palier payant'))(`T-WAG-4 — paiement : session, webhook, rejeu, facture${PAYANT ? '' : ' (sans objet : aucun palier payant)'}`, async () => {
174
+ const r = await inscrire(B, MAIL_PAYANT, PAYANT.slug);
175
+ expect(r.texte.includes(`name="plan" value="${PAYANT.slug}"`)).toBe(true);
176
+ const p = await req(B, '/paiement', { method: 'POST', cookie: r.cookie, form: { plan: PAYANT.slug, banque: 'stripe' } });
177
+ expect(p.status).toBe(303);
178
+ expect(p.headers.location.startsWith('https://checkout.stripe.test/')).toBe(true);
179
+ const s = appels.sessions.at(-1);
180
+ const meta = s.metadata;
181
+ expect(meta.wagon_plan).toBe(PAYANT.slug);
182
+ const avant = (await req(B, '/compte', { cookie: r.cookie })).texte;
183
+ expect(avant.includes(GRATUIT.name)).toBe(true);
184
+ const envoyer = async (type, object, id) => {
185
+ const payload = JSON.stringify({ id, object: 'event', type, created: Math.floor(Date.now() / 1000), data: { object } });
186
+ return req(B, '/api/stripe/webhook', { method: 'POST', json: payload, headers: { 'content-type': 'application/json', 'stripe-signature': vrai.webhooks.generateTestHeaderString({ payload, secret: SECRET }) } });
187
+ };
188
+ expect((await envoyer('checkout.session.completed', { id: 'cs_p', subscription: 'sub_p', customer: s.customer, metadata: meta }, 'evt_1')).status).toBe(200);
189
+ expect((await envoyer('checkout.session.completed', { id: 'cs_p', subscription: 'sub_p', customer: s.customer, metadata: meta }, 'evt_1')).status).toBe(200);
190
+ await envoyer('invoice.paid', { id: 'in_1', subscription: 'sub_p', customer: s.customer, amount_paid: 15000, currency: 'eur', hosted_invoice_url: 'https://pay.stripe.test/in_1', subscription_details: { metadata: meta } }, 'evt_2');
191
+ const apres = (await req(B, '/compte', { cookie: r.cookie })).texte;
192
+ expect(apres.includes(PAYANT.name)).toBe(true);
193
+ expect(apres.includes('https://pay.stripe.test/in_1')).toBe(true);
194
+ expect(apres.includes('action="/compte/portail"')).toBe(true);
195
+ const portail = await req(B, '/compte/portail', { method: 'POST', cookie: r.cookie, form: {} });
196
+ expect(portail.headers.location).toBe('https://billing.stripe.test/portail');
197
+ expect((await req(B, '/api/stripe/webhook', { method: 'POST', json: '{}', headers: { 'stripe-signature': 't=1,v1=faux' } })).status).toBe(400);
198
+ });
199
+
200
+ // 0.4 (08/10/2026) — repris de qatrax.dev : l'avis avant prélèvement, et le retour du portail Stripe malgré le
201
+ // cookie SameSite=Strict (une navigation venue de billing.stripe.com n'envoie pas la session).
202
+ (PAYANT ? test : sansObjet('aucun palier payant'))(`T-WAG-14 — paiement : avis avant prélèvement (une fois), retour du portail sans perdre la session${PAYANT ? '' : ' (sans objet : aucun palier payant)'}`, async () => {
203
+ const retour = await req(B, '/compte/retour');
204
+ expect(retour.status).toBe(200);
205
+ expect(retour.texte.includes('http-equiv="refresh" content="0;url=/compte"')).toBe(true);
206
+ expect(String(retour.headers['cache-control'])).toBe('no-store');
207
+ expect(appels.portails.at(-1).return_url).toBe('http://127.0.0.1/compte/retour');
208
+ const client = appels.sessions.find((x) => x.metadata?.wagon_plan === PAYANT.slug)?.customer;
209
+ expect(!!client).toBe(true);
210
+ const envoyer = async (object, id) => {
211
+ const payload = JSON.stringify({ id, object: 'event', type: 'invoice.upcoming', created: Math.floor(Date.now() / 1000), data: { object } });
212
+ return req(B, '/api/stripe/webhook', { method: 'POST', json: payload, headers: { 'content-type': 'application/json', 'stripe-signature': vrai.webhooks.generateTestHeaderString({ payload, secret: SECRET }) } });
213
+ };
214
+ const avant = courriels().filter((m) => m.to === MAIL_PAYANT).length;
215
+ const echeance = Math.floor(Date.UTC(2030, 0, 15) / 1000);
216
+ expect((await envoyer({ customer: client, amount_due: 999, currency: 'eur', next_payment_attempt: echeance }, 'evt_avis_1')).status).toBe(200);
217
+ expect((await envoyer({ customer: client, amount_due: 999, currency: 'eur', next_payment_attempt: echeance }, 'evt_avis_1')).status).toBe(200); // rejeu
218
+ const avis = courriels().filter((m) => m.to === MAIL_PAYANT).slice(avant);
219
+ expect(avis.length).toBe(1);
220
+ expect(avis[0].text.includes('15/01/2030') && avis[0].text.includes('9,99')).toBe(true);
221
+ expect(avis[0].text.includes('/compte')).toBe(true);
222
+ const total = courriels().length;
223
+ expect((await envoyer({ customer: 'cus_inconnu', amount_due: 999, currency: 'eur' }, 'evt_avis_2')).status).toBe(200);
224
+ expect(courriels().length).toBe(total); // client inconnu : aucun courriel
225
+ });
226
+
227
+ // 0.4 (09/10/2026) — audit : un palier inactif ou gratuit ne se vend pas ; un abonné Stripe ne s'abonne pas une
228
+ // seconde fois (Stripe prélèverait les deux) ; il est renvoyé vers « Gérer mon abonnement ».
229
+ (PAYANT ? test : sansObjet('aucun palier payant'))(`T-WAG-15 — paiement : ni palier inactif, ni second abonnement${PAYANT ? '' : ' (sans objet : aucun palier payant)'}`, async () => {
230
+ const r = await req(B, '/connexion', { method: 'POST', form: { courriel: MAIL_PAYANT, motdepasse: MDP } });
231
+ const avant = appels.sessions.length;
232
+ const inactif = await req(B, '/paiement', { method: 'POST', cookie: r.cookie, form: { plan: 'anonyme', banque: 'stripe' } });
233
+ expect(inactif.status).toBe(200);
234
+ expect(inactif.texte.includes('class="msg"')).toBe(true);
235
+ const second = await req(B, '/paiement', { method: 'POST', cookie: r.cookie, form: { plan: PAYANT.slug, banque: 'stripe' } });
236
+ expect(second.status).toBe(200);
237
+ expect(second.texte.includes('action="/compte/portail"')).toBe(true);
238
+ expect(appels.sessions.length).toBe(avant); // aucune session Stripe ouverte
239
+ });
240
+
241
+ test('T-WAG-5 — API par clé, révocation bornée', async () => {
242
+ const moi = await req(B, '/api/v1/moi', { headers: { authorization: `Bearer ${cleApi}` } });
243
+ expect(moi.status).toBe(200);
244
+ expect(JSON.parse(moi.texte).compte.nom).toBe('Société anis@exemple.test');
245
+ expect((await req(B, '/api/v1/moi')).status).toBe(401);
246
+ const autre = await connecter(B, MAIL_PAYANT);
247
+ await req(B, '/compte/cles/revoquer', { method: 'POST', cookie: autre, form: { prefix: cleApi.slice(0, 16) } });
248
+ expect((await req(B, '/api/v1/moi', { headers: { authorization: `Bearer ${cleApi}` } })).status).toBe(200);
249
+ const mien = await connecter(B, 'anis@exemple.test');
250
+ await req(B, '/compte/cles/revoquer', { method: 'POST', cookie: mien, form: { prefix: cleApi.slice(0, 16) } });
251
+ expect((await req(B, '/api/v1/moi', { headers: { authorization: `Bearer ${cleApi}` } })).status).toBe(401);
252
+ });
253
+
254
+ (PAYANT && LIMITE ? test : sansObjet('pas de palier payant ou pas de limite'))(`T-WAG-6 — administration : accès, paliers, échelle, usage${PAYANT && LIMITE ? '' : ' (sans objet : pas de palier payant ou pas de limite)'}`, async () => {
255
+ const client = await connecter(B, 'anis@exemple.test');
256
+ expect((await req(B, '/admin/plans', { cookie: client })).status).toBe(404);
257
+ await inscrire(B, 'admin@exemple.test');
258
+ const adm = await connecter(B, 'admin@exemple.test');
259
+ expect((await req(B, '/admin/plans', { cookie: adm })).status).toBe(404); // déclaré, pas encore confirmé
260
+ await A.w.confirmerAdmin('admin@exemple.test');
261
+ const page = await req(B, '/admin/plans', { cookie: adm });
262
+ expect(page.status).toBe(200);
263
+ expect(page.texte.includes(`name="${PAYANT.slug}.${LIMITE}"`)).toBe(true);
264
+ const valeurs = Object.fromEntries(config.paliers.flatMap((p) => Object.entries(p.limits).map(([k, v]) => [`${p.slug}.${k}`, String(v)])));
265
+ const ok = await req(B, '/admin/plans', { method: 'POST', cookie: adm, form: { ...valeurs, [`${PAYANT.slug}.${LIMITE}`]: '777' } });
266
+ expect(ok.status).toBe(200);
267
+ if (TARIFS) expect((await req(B, '/tarifs')).texte.includes('777')).toBe(true);
268
+ else expect((await req(B, '/admin/plans', { cookie: adm })).texte.includes('value="777"')).toBe(true); // la valeur tient, vue de l'administration
269
+ // L'échelle inversée : le gratuit donnerait PLUS que le payant.
270
+ const inverse = await req(B, '/admin/plans', { method: 'POST', cookie: adm, form: { ...valeurs, [`${GRATUIT.slug}.${LIMITE}`]: '2', [`${PAYANT.slug}.${LIMITE}`]: '1' } });
271
+ expect(/inversée|moins/i.test(inverse.texte)).toBe(true);
272
+ const u = await req(B, '/admin/usage', { cookie: adm });
273
+ expect(u.status).toBe(200);
274
+ expect(u.texte.includes('Société anis@exemple.test')).toBe(true);
275
+ });
276
+
277
+ test('T-WAG-7 — langues, pages légales, introuvable', async () => {
278
+ const ar = await req(B, '/tarifs?lang=ar');
279
+ expect(ar.texte.includes('<html lang="ar" dir="rtl"')).toBe(true);
280
+ const en = await req(B, '/', { headers: { 'accept-language': 'en-GB,en;q=0.9' } });
281
+ expect(en.texte.includes('<html lang="en"')).toBe(true);
282
+ const m = await req(B, '/mentions-legales');
283
+ expect(m.status).toBe(200);
284
+ expect(m.texte.includes('<h1')).toBe(true);
285
+ expect(m.texte.includes(config.marque.nom) && m.texte.includes('Éditeur Essai SARL')).toBe(true); // {{SERVICE}}, {{EDITEUR_NOM}}
286
+ expect(/\{\{[A-Z_]+\}\}|\[object Object\]|à compléter|À FAIRE/.test(m.texte)).toBe(false);
287
+ // Sept adresses, quatre documents : les trois alias en 301 (SAAS-APP §7.1).
288
+ for (const [alias, cible] of [['/legal', '/mentions-legales'], ['/conditions', '/cgv'], ['/privacy', '/confidentialite']]) {
289
+ const r = await req(B, `${alias}?lang=en`);
290
+ expect([alias, r.status, r.headers.location]).toEqual([alias, 301, `${cible}?lang=en`]);
291
+ }
292
+ const aide = await req(B, '/aide');
293
+ expect(aide.texte.includes('<h1')).toBe(true);
294
+ expect(aide.texte.includes('[object Object]')).toBe(false);
295
+ const cgv = await req(B, '/cgv?lang=ar');
296
+ expect(cgv.status === 200 || cgv.status === 404).toBe(true);
297
+ expect((await req(B, '/nulle-part')).status).toBe(404);
298
+ });
299
+
300
+ // 0.4 (audit du 09/10/2026) — les modèles légaux du gabarit portent des « À FAIRE TRANCHER » et des jetons vides :
301
+ // servis tels quels, ils AURAIENT L'AIR d'exister. Ils répondent 404, et la vente est fermée.
302
+ test('T-WAG-17 — document légal incomplet : 404, et pas de vente sans CGV complètes ; paliers resynchronisés', async () => {
303
+ const sgbd = join(atelier, `incomplet${Math.random()}.db`);
304
+ const R = await demarrer({ dossierLegal: undefined, legal: {}, sgbd });
305
+ const br = `http://127.0.0.1:${R.port}`;
306
+ const m = await req(br, '/mentions-legales');
307
+ expect(m.status).toBe(404);
308
+ expect(/à compléter|\{\{|À FAIRE/.test(m.texte)).toBe(false);
309
+ if (PAYANT) {
310
+ const r = await inscrire(br, 'vente@exemple.test');
311
+ const p = await req(br, '/paiement', { method: 'POST', cookie: r.cookie, form: { plan: PAYANT.slug, banque: 'stripe' } });
312
+ expect(p.status).toBe(200);
313
+ expect(p.headers.location ?? '').toBe('');
314
+ }
315
+ await R.fermer();
316
+ // Les paliers suivent la configuration au démarrage : un palier retiré de la vente le devient en base.
317
+ if (PAYANT) {
318
+ const paliers = config.paliers.map((x) => (x.slug === PAYANT.slug ? { ...x, active: false } : x));
319
+ const R2 = await demarrer({ paliers, sgbd });
320
+ expect((await R2.w.dialect.findOne(R2.w.schemas.Plan, { slug: PAYANT.slug })).active).toBe(false);
321
+ await R2.fermer();
322
+ }
323
+ });
324
+
325
+ // 0.5 (audit du 09/10/2026) — la santé du service : le déployeur vérifie que la version EN SERVICE est celle qu'il
326
+ // vient de copier (fichier REVISION), sans rien révéler d'autre.
327
+ test('T-WAG-18 — /health : ok, application, commit en service, démarrage ; aucun secret, pas de cache', async () => {
328
+ const h = await req(B, '/health');
329
+ expect(h.status).toBe(200);
330
+ const j = JSON.parse(h.texte);
331
+ expect(j.ok).toBe(true);
332
+ expect(j.app).toBe(config.marque.nom);
333
+ expect('commit' in j && typeof j.demarreLe === 'string').toBe(true);
334
+ expect(Object.keys(j).sort()).toEqual(['app', 'commit', 'demarreLe', 'ok']);
335
+ expect(String(h.headers['cache-control'])).toBe('no-store');
336
+ });
337
+
338
+ // 0.8 (audit du 09/10/2026, référencement) — robots.txt, sitemap.xml (pages publiques, et celles que la charge déclare
339
+ // par `sitemap()`), icône, image de partage. Une instance de RECETTE (`indexer: false`) ne s'indexe pas : en-tête
340
+ // X-Robots-Tag sur toute réponse, robots.txt qui ferme tout, pas de sitemap.
341
+ test('T-WAG-26 — référencement : robots.txt, sitemap.xml, icône, image de partage ; une recette ne s’indexe pas', async () => {
342
+ const r = await req(B, '/robots.txt');
343
+ expect([r.status, String(r.headers['content-type']).startsWith('text/plain')]).toEqual([200, true]);
344
+ expect(r.texte.includes('Sitemap: http://127.0.0.1/sitemap.xml') && /Disallow: \/compte/.test(r.texte)).toBe(true);
345
+ const s = await req(B, '/sitemap.xml');
346
+ expect([s.status, s.texte.includes('<loc>http://127.0.0.1/</loc>'), s.texte.includes('/compte')]).toEqual([200, true, false]);
347
+ for (const p of (typeof charge.sitemap === 'function' ? await charge.sitemap() : [])) expect([p, s.texte.includes(`<loc>http://127.0.0.1${p}</loc>`)]).toEqual([p, true]);
348
+ const f = await req(B, '/favicon.svg');
349
+ expect([f.status, String(f.headers['content-type'])]).toEqual([200, 'image/svg+xml']);
350
+ const accueil = await req(B, '/');
351
+ expect(accueil.texte.includes('rel="icon"')).toBe(true);
352
+ if (config.marque.image) expect(accueil.texte.includes('property="og:image"')).toBe(true);
353
+ expect(accueil.headers['x-robots-tag']).toBe(undefined);
354
+ const R = await demarrer({ indexer: false });
355
+ const b = `http://127.0.0.1:${R.port}`;
356
+ expect((await req(b, '/robots.txt')).texte).toBe('User-agent: *\nDisallow: /\n');
357
+ expect((await req(b, '/sitemap.xml')).status).toBe(404);
358
+ expect(String((await req(b, '/')).headers['x-robots-tag'])).toBe('noindex, nofollow');
359
+ await R.fermer();
360
+ });
361
+
362
+ // 0.9 — la feuille de style de l'application (`config.css`, wagon-ui ≥ 0.6) restyle aussi les pages du wagon.
363
+ test('T-WAG-27 — `config.css` : la feuille de l’application est posée sur les pages du wagon (connexion, aide)', async () => {
364
+ const R = await demarrer({ css: 'main>h1:first-child{letter-spacing:.01em}' });
365
+ const b = `http://127.0.0.1:${R.port}`;
366
+ for (const c of ['/connexion', '/aide']) expect([c, (await req(b, c)).texte.includes('<style id="feuille-app">main>h1:first-child{letter-spacing:.01em}</style>')]).toEqual([c, true]);
367
+ await R.fermer();
368
+ });
369
+
370
+ // 0.5 (audit du 09/10/2026) — la base du wagon n'est plus figée en sqlite : `sgbd: { dialect, uri }` (le .env :
371
+ // WAGON_DB_DIALECT, WAGON_SGBD), comme la base de la charge. La chaîne seule reste un chemin sqlite.
372
+ test('T-WAG-19 — la base du wagon se choisit par { dialect, uri } ; la chaîne reste un chemin sqlite', async () => {
373
+ const W2 = await creerWagon({ config: { ...config, dossierLegal: LEGAL_COMPLET }, charge, sgbd: { dialect: 'sqlite', uri: join(atelier, `objet${Math.random()}.db`) }, baseUrl: 'http://127.0.0.1', racine: GREFFE });
374
+ await W2.catalogue();
375
+ expect((await W2.dialect.find(W2.schemas.Plan, {})).length > 0).toBe(true);
376
+ await W2.fermer();
377
+ });
378
+
379
+ // 0.5 (audit du 09/10/2026) — le compte du client : changer son mot de passe, le retrouver, partir avec ses
380
+ // données, supprimer son compte (RGPD) ; la déconnexion est un POST ; les inscriptions sont freinées.
381
+ test('T-WAG-20 — changer son mot de passe ferme les AUTRES sessions', async () => {
382
+ await inscrire(B, 'mdp@exemple.test');
383
+ const ailleurs = await connecter(B, 'mdp@exemple.test');
384
+ const ici = await connecter(B, 'mdp@exemple.test');
385
+ expect((await req(B, '/compte/mot-de-passe', { cookie: ici })).texte.includes('name="nouveau"')).toBe(true);
386
+ const faux = await req(B, '/compte/mot-de-passe', { method: 'POST', cookie: ici, form: { actuel: 'pas-le-bon-du-tout', nouveau: 'Une-Nouvelle-Phrase-26', confirmer: 'Une-Nouvelle-Phrase-26' } });
387
+ expect(faux.status).toBe(400);
388
+ const ok = await req(B, '/compte/mot-de-passe', { method: 'POST', cookie: ici, form: { actuel: MDP, nouveau: 'Une-Nouvelle-Phrase-26', confirmer: 'Une-Nouvelle-Phrase-26' } });
389
+ expect(ok.status).toBe(303);
390
+ expect((await req(B, '/compte', { cookie: ailleurs })).status).toBe(303); // l'autre session est fermée
391
+ expect((await req(B, '/compte', { cookie: ok.cookie })).status).toBe(200); // celle-ci est rouverte
392
+ expect((await req(B, '/connexion', { method: 'POST', form: { courriel: 'mdp@exemple.test', motdepasse: 'Une-Nouvelle-Phrase-26' } })).cookie !== '').toBe(true);
393
+ });
394
+
395
+ test('T-WAG-21 — mot de passe oublié : lien reçu par courriel ; un courriel inconnu reçoit la même réponse', async () => {
396
+ await inscrire(B, 'oubli@exemple.test');
397
+ expect((await req(B, '/connexion')).texte.includes('href="/connexion/oubli"')).toBe(true);
398
+ const n0 = courriels().length;
399
+ const connu = await req(B, '/connexion/oubli', { method: 'POST', form: { courriel: 'oubli@exemple.test' } });
400
+ const inconnu = await req(B, '/connexion/oubli', { method: 'POST', form: { courriel: 'personne@exemple.test' } });
401
+ expect(connu.status).toBe(200);
402
+ expect(connu.texte.replace(/oubli@exemple\.test/g, 'X')).toBe(inconnu.texte.replace(/personne@exemple\.test/g, 'X'));
403
+ let m = null;
404
+ for (let k = 0; k < 40 && !m; k++) { m = courriels().slice(n0).find((x) => x.to === 'oubli@exemple.test'); if (!m) await new Promise((r) => setTimeout(r, 50)); }
405
+ expect(courriels().slice(n0).some((x) => x.to === 'personne@exemple.test')).toBe(false);
406
+ const jeton = m.text.match(/reinitialiser\?t=([\w-]+)/)[1];
407
+ expect((await req(B, `/connexion/reinitialiser?t=${jeton}`)).texte.includes('name="nouveau"')).toBe(true);
408
+ const r = await req(B, '/connexion/reinitialiser', { method: 'POST', form: { t: jeton, nouveau: 'Retrouve-La-Phrase-2026', confirmer: 'Retrouve-La-Phrase-2026' } });
409
+ expect(r.status).toBe(303);
410
+ expect((await req(B, '/connexion', { method: 'POST', form: { courriel: 'oubli@exemple.test', motdepasse: 'Retrouve-La-Phrase-2026' } })).cookie !== '').toBe(true);
411
+ expect((await req(B, '/connexion/reinitialiser', { method: 'POST', form: { t: jeton, nouveau: 'Encore-Une-Phrase-2026', confirmer: 'Encore-Une-Phrase-2026' } })).status).toBe(400); // un lien ne sert qu'une fois
412
+ });
413
+
414
+ test('T-WAG-22 — partir avec ses données (export), puis supprimer son compte', async () => {
415
+ await inscrire(B, 'depart@exemple.test');
416
+ const c = await connecter(B, 'depart@exemple.test');
417
+ const ex = await req(B, '/compte/export', { cookie: c });
418
+ expect(ex.status).toBe(200);
419
+ expect(String(ex.headers['content-disposition'])).toContain('attachment');
420
+ const j = JSON.parse(ex.texte);
421
+ expect(j.utilisateur.courriel).toBe('depart@exemple.test');
422
+ expect(Array.isArray(j.factures) && Array.isArray(j.cles)).toBe(true);
423
+ expect((await req(B, '/compte/supprimer', { cookie: c })).texte.includes('name="confirmation"')).toBe(true);
424
+ expect((await req(B, '/compte/supprimer', { method: 'POST', cookie: c, form: { confirmation: 'autre@exemple.test' } })).status).toBe(400);
425
+ const sup = await req(B, '/compte/supprimer', { method: 'POST', cookie: c, form: { confirmation: 'depart@exemple.test' } });
426
+ expect(sup.status).toBe(303);
427
+ expect((await req(B, '/compte', { cookie: c })).status).toBe(303); // plus de session
428
+ expect(await A.w.dialect.findOne(A.w.schemas.User, { email: 'depart@exemple.test' })).toBe(null);
429
+ expect((await req(B, '/connexion', { method: 'POST', form: { courriel: 'depart@exemple.test', motdepasse: MDP } })).cookie).toBe('');
430
+ });
431
+
432
+ test('T-WAG-23 — la déconnexion est un POST ; un GET (lien piégé, prélecture) ne déconnecte pas', async () => {
433
+ await inscrire(B, 'sortie@exemple.test');
434
+ const c = await connecter(B, 'sortie@exemple.test');
435
+ expect((await req(B, '/compte', { cookie: c })).texte.includes('action="/deconnexion"')).toBe(true);
436
+ await req(B, '/deconnexion', { cookie: c });
437
+ expect((await req(B, '/compte', { cookie: c })).status).toBe(200);
438
+ expect((await req(B, '/deconnexion', { method: 'POST', cookie: c, form: {} })).status).toBe(303);
439
+ expect((await req(B, '/compte', { cookie: c })).status).toBe(303);
440
+ });
441
+
442
+ test('T-WAG-24 — les inscriptions sont freinées par adresse (429)', async () => {
443
+ const F = await demarrer({ freinInscription: { capacity: 2, refillPerSec: 0.001 } });
444
+ const bf = `http://127.0.0.1:${F.port}`;
445
+ const statuts = [];
446
+ for (const k of [1, 2, 3]) statuts.push((await req(bf, '/inscription', { method: 'POST', form: { plan: 'gratuit', nom: `F${k}`, courriel: `frein${k}@exemple.test`, motdepasse: MDP } })).status);
447
+ expect(statuts[2]).toBe(429);
448
+ await F.fermer();
449
+ });
450
+
451
+ test('T-WAG-8 — sécurité : CSRF, en-têtes, cookie', async () => {
452
+ const x = await req(B, '/inscription', { method: 'POST', form: { plan: 'gratuit', nom: 'X', courriel: 'x@exemple.test', motdepasse: MDP }, headers: { 'sec-fetch-site': 'cross-site', origin: 'https://evil.example' } });
453
+ expect(x.status).toBe(403);
454
+ const h = await req(B, '/');
455
+ expect(/default-src/.test(h.headers['content-security-policy'] || '')).toBe(true);
456
+ expect(/frame-ancestors 'none'/.test(h.headers['content-security-policy'] || '')).toBe(true);
457
+ expect(h.headers['x-content-type-options']).toBe('nosniff');
458
+ const r = await inscrire(B, 'cookie@exemple.test');
459
+ const brut = (r.headers['set-cookie'] || []).find((c) => c.startsWith('wagon_session='));
460
+ expect(/HttpOnly/.test(brut) && /SameSite=Strict/.test(brut)).toBe(true);
461
+ });
462
+
463
+ // 0.11 (SaaS Prononciation, B-03 bis, 25/10/2026) — une page de la charge peut demander le micro (ou la caméra) ;
464
+ // toutes les autres continuent de le refuser. La charge DÉCLARE ses pages, le wagon choisit les en-têtes (garde-http ≥ 0.2).
465
+ test('T-WAG-29 — permissions par page : le micro s’ouvre aux pages déclarées par la charge, et à elles seules', async () => {
466
+ const W = await demarrer({ charge: { ...charge, permissions: { '/outil': { microphone: 'self' }, '/exercice/*': { microphone: 'self', camera: null } } } });
467
+ const base = `http://127.0.0.1:${W.port}`;
468
+ const pp = async (chemin) => (await req(base, chemin)).headers['permissions-policy'];
469
+ try {
470
+ for (const ferme of ['/', '/connexion', '/outil/autre', '/exercice', '/exercicex']) expect(await pp(ferme)).toBe('camera=(), microphone=(), geolocation=(), payment=()');
471
+ expect(await pp('/outil')).toBe('camera=(), microphone=(self), geolocation=(), payment=()');
472
+ expect(await pp('/exercice/12')).toBe('microphone=(self), geolocation=(), payment=()');
473
+ // le reste des en-têtes ne bouge pas sur une page ouverte
474
+ const h = (await req(base, '/outil')).headers;
475
+ expect(/frame-ancestors 'none'/.test(h['content-security-policy'] || '')).toBe(true);
476
+ expect(h['x-frame-options']).toBe('DENY');
477
+ } finally { await W.fermer(); }
478
+ // une déclaration fautive arrête le démarrage : jamais un en-tête cassé en service
479
+ for (const permissions of [{ 'exercice': { microphone: 'self' } }, { '/exercice': { microphone: 'self, camera=*' } }]) {
480
+ let err = null;
481
+ try { const X = await demarrer({ charge: { ...charge, permissions } }); await X.fermer(); } catch (e) { err = e; }
482
+ expect(err instanceof TypeError).toBe(true);
483
+ }
484
+ });
485
+
486
+ // 0.4 (audit du 09/10/2026) — un courriel DÉCLARÉ administrateur ne suffit pas : quiconque l'inscrirait avant son
487
+ // titulaire prendrait l'administration. Il faut la PREUVE : une connexion par code reçu à ce courriel, ou la
488
+ // confirmation faite sur le serveur (scripts/admin.mjs).
489
+ test('T-WAG-16 — administration : un courriel déclaré ne vaut qu’une fois confirmé (code reçu, ou serveur)', async () => {
490
+ const P = await demarrer({ admins: ['chef@exemple.test'] });
491
+ const bp = `http://127.0.0.1:${P.port}`;
492
+ await inscrire(bp, 'chef@exemple.test');
493
+ const usurpateur = await connecter(bp, 'chef@exemple.test');
494
+ expect((await req(bp, '/admin/plans', { cookie: usurpateur })).status).toBe(404);
495
+ expect(await P.w.confirmerAdmin('inconnu@exemple.test')).toBe(false); // aucun compte : rien à confirmer
496
+ expect(await P.w.confirmerAdmin('chef@exemple.test')).toBe(true);
497
+ expect((await req(bp, '/admin/plans', { cookie: usurpateur })).status).toBe(200);
498
+ // Le mot de passe se fixe aussi depuis le serveur (mot de passe perdu, aucun courriel configuré).
499
+ expect(await P.w.fixerMotDePasse('chef@exemple.test', 'Une-Autre-Phrase-2026')).toBe(true);
500
+ expect((await req(bp, '/connexion', { method: 'POST', form: { courriel: 'chef@exemple.test', motdepasse: 'Une-Autre-Phrase-2026' } })).cookie !== '').toBe(true);
501
+ await P.fermer();
502
+ // Par la connexion par code : recevoir le code prouve qu'on détient le courriel.
503
+ const Q = await demarrer({ admins: ['code-admin@exemple.test'], connexion: 'code' });
504
+ const bq = `http://127.0.0.1:${Q.port}`;
505
+ await req(bq, '/inscription', { method: 'POST', form: { plan: 'gratuit', nom: 'Code Admin', courriel: 'code-admin@exemple.test' } });
506
+ const n0 = courriels().length;
507
+ await req(bq, '/connexion', { method: 'POST', form: { courriel: 'code-admin@exemple.test' } });
508
+ let m = null;
509
+ for (let k = 0; k < 40 && !m; k++) { m = courriels().slice(n0).find((x) => x.to === 'code-admin@exemple.test'); if (!m) await new Promise((r) => setTimeout(r, 50)); }
510
+ const s = await req(bq, '/connexion/code', { method: 'POST', form: { email: 'code-admin@exemple.test', code: m.text.match(/\b(\d{6})\b/)[1] } });
511
+ expect((await req(bq, '/admin/plans', { cookie: s.cookie })).status).toBe(200);
512
+ await Q.fermer();
513
+ });
514
+
515
+ test('T-WAG-9 — connexion par code', async () => {
516
+ const C = await demarrer({ connexion: 'code' });
517
+ const base = `http://127.0.0.1:${C.port}`;
518
+ const i = await req(base, '/inscription', { method: 'GET' });
519
+ expect(i.texte.includes('name="motdepasse"')).toBe(false);
520
+ await req(base, '/inscription', { method: 'POST', form: { plan: 'gratuit', nom: 'Code SA', courriel: 'code@exemple.test' } });
521
+ const n0 = courriels().length;
522
+ await req(base, '/connexion', { method: 'POST', form: { courriel: 'code@exemple.test' } });
523
+ let m = null;
524
+ for (let k = 0; k < 40 && !m; k++) { m = courriels().slice(n0).find((x) => x.to === 'code@exemple.test'); if (!m) await new Promise((r) => setTimeout(r, 50)); }
525
+ expect(m !== null).toBe(true);
526
+ const code = m.text.match(/\b(\d{6})\b/)[1];
527
+ const s = await req(base, '/connexion/code', { method: 'POST', form: { email: 'code@exemple.test', code } });
528
+ expect(s.cookie !== '').toBe(true);
529
+ expect((await req(base, '/compte', { cookie: s.cookie })).status).toBe(200);
530
+ await C.fermer(); await A.fermer();
531
+ rmSync(atelier, { recursive: true, force: true });
532
+ });
533
+
534
+ // 0.10 (25/10/2026) — SATIM : pas de webhook, l'ACHETEUR revient. Le retour constate le paiement puis le renvoie vers
535
+ // /paiement/merci ou /tarifs ; une commande abandonnée se rattrape par `reprendrePaiements`. Écrit AVANT le code.
536
+ const PRIX_DZD = PAYANT && (PAYANT.prepaid ?? {}).DZD?.month;
537
+ (PRIX_DZD ? test : sansObjet('aucun prix prépayé en DZD'))(`T-WAG-28 — SATIM : retour de l’acheteur constaté, renvoi vers merci ou tarifs, reprise des abandons${PRIX_DZD ? '' : ' (sans objet : aucun prix prépayé en DZD)'}`, async () => {
538
+ const etats = new Map(); const ouverts = [];
539
+ const portSatim = {
540
+ async createPaymentCheckout(input) { ouverts.push(input); const ref = `MD-${ouverts.length}`; ordres.set(ref, { orderId: input.orderId, amount: input.amount, currency: input.currency, status: 'pending', metadata: input.metadata }); return { checkout: { url: `https://test.satim.dz/payment/merchants/x/payment.html?mdOrder=${ref}`, sessionId: ref }, payment: ordres.get(ref) }; },
541
+ async settlePaymentFromWebhook({ body }) { const ref = new URLSearchParams(body).get('orderId'); const payment = ordres.get(ref); if (!payment) return { ok: false, reason: 'malformed' }; return { ok: true, orderId: payment.orderId, status: etats.get(ref), payment }; },
542
+ };
543
+ const C = await demarrer({ banques: { satim: 'DZD' }, port: portSatim });
544
+ const base = `http://127.0.0.1:${C.port}`;
545
+ await inscrire(base, 'satim@exemple.test');
546
+ const c = await connecter(base, 'satim@exemple.test');
547
+ const ouvrir = async () => { const va = await req(base, '/paiement', { method: 'POST', cookie: c, form: { plan: PAYANT.slug, banque: 'satim', periode: 'month' } }); expect(va.status).toBe(303); expect(va.headers.location.startsWith('https://test.satim.dz/')).toBe(true); return `MD-${ouverts.length}`; };
548
+ const echec = await ouvrir();
549
+ expect(ouverts.at(-1).returnUrl).toBe('http://127.0.0.1/api/paiement/retour/{provider}');
550
+ etats.set(echec, 'failed');
551
+ const r1 = await req(base, `/api/paiement/retour/satim?orderId=${echec}&lang=fr`);
552
+ expect(r1.status).toBe(303); expect(r1.headers.location).toBe('/tarifs');
553
+ const paye = await ouvrir();
554
+ etats.set(paye, 'paid');
555
+ const r2 = await req(base, `/api/paiement/retour/satim?orderId=${paye}&lang=fr`);
556
+ expect(r2.status).toBe(303); expect(r2.headers.location).toBe('/paiement/merci');
557
+ expect((await req(base, '/compte', { cookie: c })).texte.includes(PAYANT.name)).toBe(true);
558
+ expect((await req(base, '/api/paiement/retour/inconnue?orderId=x')).headers.location).toBe('/tarifs');
559
+ expect(typeof C.w.reprendrePaiements).toBe('function');
560
+ const rep = await C.w.reprendrePaiements();
561
+ expect(Array.isArray(rep)).toBe(true);
562
+ expect(rep.map((x) => x.banque)).toEqual(['satim']);
563
+ await C.fermer();
564
+ });
565
+
566
+ (OFFRE ? test : sansObjet('aucune banque prépayée ni prix prépayé'))(`T-WAG-13 — vente prépayée : choix du moyen, banque, webhook, période, rejeu, CSP${OFFRE ? '' : ' (sans objet : aucune banque prépayée ni prix prépayé)'}`, async () => {
567
+ const [banque] = OFFRE;
568
+ const C = await demarrer();
569
+ const base = `http://127.0.0.1:${C.port}`;
570
+ await inscrire(base, 'prepaye@exemple.test');
571
+ const c = await connecter(base, 'prepaye@exemple.test');
572
+ const choix = await req(base, '/paiement', { method: 'POST', cookie: c, form: { plan: PAYANT.slug } });
573
+ expect(choix.status).toBe(200); // le choix, pas une redirection
574
+ expect(choix.texte.includes(`name="banque" value="${banque}"`) && choix.texte.includes('name="banque" value="stripe"')).toBe(true);
575
+ const va = await req(base, '/paiement', { method: 'POST', cookie: c, form: { plan: PAYANT.slug, banque, periode: 'month' } });
576
+ expect(va.status).toBe(303);
577
+ expect(va.headers.location.startsWith('https://pay.chargily.net/')).toBe(true);
578
+ expect(/form-action[^;]*pay\.chargily\.net/.test(va.headers['content-security-policy'] || '')).toBe(true);
579
+ expect((await req(base, '/compte', { cookie: c })).texte.includes(GRATUIT.name)).toBe(true); // pas avant le webhook
580
+ const ordre = [...ordres.keys()].at(-1);
581
+ const webhook = (sig, status = 'paid') => req(base, `/api/paiement/webhook/${banque}`, { method: 'POST', json: JSON.stringify({ orderId: ordre, status, sig }), headers: { 'content-type': 'application/json' } });
582
+ expect((await webhook('faux')).status).toBe(400);
583
+ expect((await webhook('ok')).status).toBe(200); // sans session ni CSRF : signé
584
+ const compte = (await req(base, '/compte', { cookie: c })).texte;
585
+ expect(compte.includes(PAYANT.name)).toBe(true);
586
+ expect(/payé jusqu’au/.test(compte)).toBe(true);
587
+ expect((await webhook('ok')).status).toBe(200); // rejeu sans effet
588
+ expect((await req(base, '/api/paiement/webhook/inconnue', { method: 'POST', json: '{}', headers: { 'content-type': 'application/json' } })).status).toBe(404);
589
+ await C.fermer();
590
+ rmSync(atelier, { recursive: true, force: true });
591
+ rmSync(join(LEGAL_COMPLET, '..'), { recursive: true, force: true });
592
+ });