Files
erp/test/sandboxLegalSetup.ts
T
arcodangeandClaude Opus 5 dbe4b36c62 feat(write-skill): chronology guard + explicit production opt-in
Two guards, both from real incidents in the same session.

1. invoice-create.sh — chronology (CGI art. 289). Dolibarr assigns the number at
   validation, in creation order, so issuing a document dated BEFORE the last one
   already issued gives a higher number to an earlier date. The July plan walked
   straight into it: the M3 deferred part is due 2026-10-23 and must be issued at
   D-60 (24/08) to stay under the L.441-10 I ceiling, while the M4 fixed part is
   dated 23/08 — issue them in the wrong order and the numbering breaks. The
   guard reads the last issued document of the same kind and refuses an earlier
   date, with ARCO_ALLOW_BACKDATE as a loud, documented override.
   Verified: refuses a 01/07 invoice against FAC008 (23/07), accepts 23/08.

2. test/scripts/guard.ts — production opt-in. The sandbox-only guard had no way
   to express a deliberate production run, so any prod work meant bypassing it
   entirely (which is how guards die). Production now requires BOTH
   ARCO_ALLOW_PRODUCTION=<exact host> and
   ARCO_PROD_CONFIRM=I-UNDERSTAND-THIS-WRITES-PROD, and prints a banner. Nothing
   reaches prod by inheriting an ambient variable.
   Also fixes a misleading "(sandbox verified)" log that printed even on prod.

grantAgentRight.ts joins the repo (it was never committed) and gains --revoke,
so a temporarily elevated right can be handed back — used today to attach a
payment in production and revoked immediately after.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01VRShc4QhLLU73FLHx9vskh
2026-07-26 01:26:12 +02:00

102 lines
4.1 KiB
TypeScript

/*
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} (guard passed)`);
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();
}