feat(test): host guard for UI admin scripts + sandbox legal-mentions setup #75

Merged
arcodange merged 1 commits from arcodange/sandbox-legal-setup into main 2026-07-25 23:14:40 +02:00
3 changed files with 254 additions and 0 deletions
+102
View File
@@ -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();
}
+101
View File
@@ -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();
}
+51
View File
@@ -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 };