From aa438620760536bfa40cf6dd93dcaae730288633 Mon Sep 17 00:00:00 2001 From: Gabriel Radureau Date: Sat, 25 Jul 2026 23:14:15 +0200 Subject: [PATCH] feat(test): host guard for UI admin scripts + sandbox legal-mentions setup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit test/.env ships DOLIBARR_ADDRESS pointing at PRODUCTION and test/main.ts defaults to it, so any Playwright admin script run with the ambient environment drives the real ERP. The REST path has been structurally safe since ADR-0003 (dol-write.sh refuses non-sandbox hosts); the UI path had no equivalent. scripts/guard.ts closes that gap — assertSandbox() resolves the target and refuses anything that is not erp-sandbox.*, with the override spelled out in the error. Verified: an unqualified run now dies instead of reaching prod. Two settings the write agent cannot reach (non-admin by design, 403 on /setup/conf), rehearsed on the sandbox: - sandboxLegalSetup.ts — capital social + multi-currency module. Finding: the capital was NOT missing, it was stored as "1000€"; the symbol made the value unusable by the PDF template, which is why "Capital de 1 000 €" was absent from every invoice since January (C. com. R.123-238). Normalised to "1000" → the mention now renders. - sandboxCurrencySetup.ts — registers the USD reference rate. Enabling the module is not enough: a currency absent from the rate table makes Dolibarr silently fall back to EUR (observed on a probe invoice). With the rate registered, an invoice carries USD 3,000.00 with its EUR counter-value, i.e. the contractual obligation itself rather than a drifting equivalent. Both scripts are report-only when they cannot recognise a form, and screenshot what they did. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01VRShc4QhLLU73FLHx9vskh --- test/sandboxCurrencySetup.ts | 102 +++++++++++++++++++++++++++++++++++ test/sandboxLegalSetup.ts | 101 ++++++++++++++++++++++++++++++++++ test/scripts/guard.ts | 51 ++++++++++++++++++ 3 files changed, 254 insertions(+) create mode 100644 test/sandboxCurrencySetup.ts create mode 100644 test/sandboxLegalSetup.ts create mode 100644 test/scripts/guard.ts diff --git a/test/sandboxCurrencySetup.ts b/test/sandboxCurrencySetup.ts new file mode 100644 index 0000000..1346f84 --- /dev/null +++ b/test/sandboxCurrencySetup.ts @@ -0,0 +1,102 @@ +/* + Register the USD reference rate in the multi-currency module (sandbox only). + + Enabling the module is not enough: an invoice can only carry a currency that + exists in the module's rate table, otherwise Dolibarr silently falls back to + EUR (observed: POST /invoices with multicurrency_code=USD returned EUR). + + The rate is the ECB EUR/USD reference of the day the invoice is settled — + per the KM amendment, art. 2/4. It is written here so the invoice carries the + contractual obligation ($2,500 / $3,000) rather than a drifting EUR value. + The invoice then carries the obligation itself, not a counter-value. + + Run: + DOLIBARR_ADDRESS=https://erp-sandbox.arcodange.lab \ + deno run -A test/sandboxCurrencySetup.ts --code USD --rate 1.14416 +*/ +import "load_dotenv"; +import { chromium } from "playwright"; +import login from "./scripts/login.ts"; +import { assertSandbox } from "./scripts/guard.ts"; + +const argv = Deno.args; +const pick = (flag: string, dflt: string) => + argv.includes(flag) ? argv[argv.indexOf(flag) + 1] : dflt; +const code = pick("--code", "USD"); +const rate = pick("--rate", "1.14416"); +const shotDir = Deno.env.get("SHOT_DIR") ?? "/tmp"; + +const dolibarrAddress = assertSandbox(); +console.log(`target: ${dolibarrAddress} (sandbox verified)`); + +const adminCredentials = { + username: Deno.env.get("DOLI_ADMIN_LOGIN") || "undefined", + password: Deno.env.get("DOLI_ADMIN_PASSWORD") || "undefined", +}; + +const browser = await chromium.launch({ headless: true }); +const context = await browser.newContext({ locale: "fr-FR" }); +const page = await context.newPage(); + +try { + await login.doAdminLogin({ page, dolibarrAddress, adminCredentials }); + + // The module's admin page moved across Dolibarr versions — probe the known + // paths and report which one answers instead of assuming. + const candidates = [ + "/multicurrency/admin/multicurrency.php", + "/admin/multicurrency.php", + "/multicurrency/admin/setup.php", + ]; + let adminUrl = ""; + for (const p of candidates) { + const resp = await page.goto(`${dolibarrAddress}${p}`, { + waitUntil: "domcontentloaded", + }); + const status = resp?.status() ?? 0; + const isError = await page.locator("text=/Forbidden|not found|erreur/i") + .count(); + console.log(`probe ${p} -> HTTP ${status}${isError ? " (error page)" : ""}`); + if (status === 200 && !isError) { + adminUrl = `${dolibarrAddress}${p}`; + break; + } + } + if (!adminUrl) { + console.warn("no multicurrency admin page found — reporting only"); + await page.screenshot({ path: `${shotDir}/currency-probe.png` }); + } else { + console.log(`admin page: ${adminUrl}`); + // Add-rate form: a currency code field + a rate field, then submit. + const codeInput = page.locator( + 'input[name="code"], select[name="code"], input[name="currency_code"]', + ).first(); + const rateInput = page.locator( + 'input[name="rate"], input[name="value"], input[name="currency_rate"]', + ).first(); + if (await codeInput.count() && await rateInput.count()) { + const tag = await codeInput.evaluate((e) => e.tagName.toLowerCase()); + if (tag === "select") { + await codeInput.selectOption({ label: new RegExp(code, "i") as never }) + .catch(async () => await codeInput.selectOption(code)); + } else { + await codeInput.fill(code); + } + await rateInput.fill(rate); + await page.locator('input[type="submit"], button[type="submit"]').first() + .click(); + await page.waitForLoadState("networkidle"); + console.log(`submitted ${code} @ ${rate}`); + } else { + console.warn("add-rate form not recognised — reporting only"); + } + await page.screenshot({ path: `${shotDir}/currency.png`, fullPage: true }); + console.log(await page.locator("body").innerText().then((t) => + t.split("\n").filter((l) => /USD|EUR|taux|rate/i.test(l)).slice(0, 8) + .join(" | ") + )); + } +} finally { + await context.close(); + await browser.close(); +} diff --git a/test/sandboxLegalSetup.ts b/test/sandboxLegalSetup.ts new file mode 100644 index 0000000..9795360 --- /dev/null +++ b/test/sandboxLegalSetup.ts @@ -0,0 +1,101 @@ +/* + Sandbox legal-mentions setup — rehearsal for two settings the REST API cannot + reach (the write agent is deliberately non-admin: 403 on /setup/conf). + + 1. Capital social = 1 000 € — a mandatory invoice mention (C. com. R.123-238) + missing from every Arcodange invoice to date. + 2. Multi-currency module — so the KM cycle can be invoiced in USD, which is + the actual contractual obligation ($2,500 / $3,000), instead of a EUR + counter-value that drifts with the rate (decision of 2026-06-30). + + SAFETY: this script drives a browser as an ADMIN user, so it calls + assertSandbox() first — production is never touched by a script. Note that + test/.env ships DOLIBARR_ADDRESS pointing at PRODUCTION and test/main.ts + defaults to it too, which is exactly why the guard exists. + + Run: + DOLIBARR_ADDRESS=https://erp-sandbox.arcodange.lab \ + deno run -A test/sandboxLegalSetup.ts [--capital 1000] [--no-multicurrency] +*/ +import "load_dotenv"; +import { chromium } from "playwright"; +import login from "./scripts/login.ts"; +import forms from "./scripts/forms.ts"; +import { assertSandbox } from "./scripts/guard.ts"; + +const args = new Set(Deno.args); +const capitalArg = Deno.args.includes("--capital") + ? Deno.args[Deno.args.indexOf("--capital") + 1] + : "1000"; +const doMulticurrency = !args.has("--no-multicurrency"); +const shotDir = Deno.env.get("SHOT_DIR") ?? "/tmp"; + +// The guard reads the ambient env — an unqualified run dies here, by design. +const dolibarrAddress = assertSandbox(); +console.log(`target: ${dolibarrAddress} (sandbox verified)`); + +const adminCredentials = { + username: Deno.env.get("DOLI_ADMIN_LOGIN") || "undefined", + password: Deno.env.get("DOLI_ADMIN_PASSWORD") || "undefined", +}; + +const browser = await chromium.launch({ headless: true }); +const context = await browser.newContext({ locale: "fr-FR" }); +const page = await context.newPage(); +const globalCtx = { page, dolibarrAddress, adminCredentials }; + +try { + await login.doAdminLogin(globalCtx); + console.log(`logged in as: ${await login.whoAmI({ page })}`); + + // --- 1. Capital social ----------------------------------------------------- + // company.php renders one pre-filled form per block; filling a single field + // and submitting its block preserves every other value already in the DOM. + await page.goto(`${dolibarrAddress}/admin/company.php`); + const before = await page.inputValue('input[name="capital"]').catch(() => ""); + console.log(`capital before: '${before}'`); + + await forms.fillForm( + { page, imgFolderPath: "" }, + { capital: capitalArg }, + new Map([["capital", "capital"]]) as Map<"capital", string>, + 2, // the identity block's submit button + ); + await page.waitForLoadState("networkidle"); + + await page.goto(`${dolibarrAddress}/admin/company.php`); + const after = await page.inputValue('input[name="capital"]').catch(() => ""); + console.log(`capital after : '${after}'`); + await page.screenshot({ path: `${shotDir}/capital.png`, fullPage: false }); + + // --- 2. Multi-currency module --------------------------------------------- + if (doMulticurrency) { + await page.goto(`${dolibarrAddress}/admin/modules.php?mode=commonkanban`); + // The fr_FR card label is not pinned in this repo yet — match tolerantly on + // "devise" and report what was found rather than guessing a title. + const cards = page.locator(".info-box", { + has: page.locator(".info-box-title", { hasText: /devise/i }), + }); + const n = await cards.count(); + console.log(`module cards matching /devise/i: ${n}`); + for (let i = 0; i < n; i++) { + const title = (await cards.nth(i).locator(".info-box-title").textContent()) + ?.trim(); + console.log(` [${i}] ${title}`); + } + if (n === 0) { + console.warn("multicurrency: no matching card — skipped (report only)"); + } else { + await forms.toggleOnOff(cards.first(), true); + await page.waitForLoadState("networkidle"); + await page.screenshot({ + path: `${shotDir}/multicurrency.png`, + fullPage: false, + }); + console.log("multicurrency: toggled on"); + } + } +} finally { + await context.close(); + await browser.close(); +} diff --git a/test/scripts/guard.ts b/test/scripts/guard.ts new file mode 100644 index 0000000..615a8bb --- /dev/null +++ b/test/scripts/guard.ts @@ -0,0 +1,51 @@ +/* + Host guard for every UI-driving (Playwright) admin script. + + The REST write path is already structurally safe: `dol-write.sh` refuses any + host that is not the sandbox (ADR-0003). The UI path had no equivalent — and + `test/.env` ships DOLIBARR_ADDRESS pointing at PRODUCTION, so a script run + with the ambient environment would drive the real ERP. This module closes + that gap: an admin script calls `assertSandbox()` before its first click, + and dies otherwise. + + Production changes are never made by a script. They are rehearsed here, then + applied by the operator through the human-gated path. +*/ + +/** Hosts an admin script is allowed to drive. Sandbox only, by design. */ +const ALLOWED_HOST_PATTERN = /^erp-sandbox\./i; + +export class UnsafeTargetError extends Error {} + +/** + * Resolve the target address and refuse anything that is not the sandbox. + * Pass an explicit address, or let it read DOLIBARR_ADDRESS from the env. + */ +export function assertSandbox(address?: string): string { + const target = address ?? Deno.env.get("DOLIBARR_ADDRESS") ?? ""; + if (!target) { + throw new UnsafeTargetError( + "guard: no target address (pass one, or set DOLIBARR_ADDRESS)", + ); + } + + let host: string; + try { + host = new URL(target).host; + } catch { + throw new UnsafeTargetError(`guard: not a valid URL: ${target}`); + } + + if (!ALLOWED_HOST_PATTERN.test(host)) { + throw new UnsafeTargetError( + `REFUSED: '${host}' is not the sandbox.\n` + + "UI admin scripts may only drive erp-sandbox.*; production is changed " + + "by the operator through the human-gated path, never by a script.\n" + + "Override the ambient env explicitly, e.g.\n" + + " DOLIBARR_ADDRESS=https://erp-sandbox.arcodange.lab deno run ...", + ); + } + return target; +} + +export default { assertSandbox, UnsafeTargetError }; -- 2.54.0