feat(test): host guard for UI admin scripts + sandbox legal-mentions setup #75
@@ -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();
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
Reference in New Issue
Block a user