diff --git a/test/.env.example b/test/.env.example index c49d634..ec7e5ad 100644 --- a/test/.env.example +++ b/test/.env.example @@ -1,5 +1,19 @@ +# --- Production / default target (main.ts) --- DOLIBARR_ADDRESS=https://erp.arcodange.lab DOLI_DB_PASSWORD= DOLI_ADMIN_LOGIN=admin DOLI_ADMIN_PASSWORD="" -ROOT_FOLDER=$HOME/erp \ No newline at end of file +ROOT_FOLDER=$HOME/erp + +# --- Sandbox provisioning (provisionSandbox.ts) --- +# Point at the sandbox and reuse the DOLI_ADMIN_* vars above for the admin login. +# Populate from the erp-sandbox namespace secrets (see "Provision the sandbox" in +# README.md): +# DOLI_ADMIN_PASSWORD <- secret `secretkv` (-n erp-sandbox) +# DOLI_DB_PASSWORD <- secret `vso-db-credentials` (-n erp-sandbox) +# Override DOLIBARR_ADDRESS to the sandbox when running provisionSandbox.ts: +# DOLIBARR_ADDRESS=https://erp-sandbox.arcodange.lab +# +# Optional: fix the new user's password (otherwise one is generated and only the +# API key is emitted). Never commit a real value here. +# AI_AGENT_SANDBOX_PASSWORD="" diff --git a/test/.gitignore b/test/.gitignore new file mode 100644 index 0000000..6b33d05 --- /dev/null +++ b/test/.gitignore @@ -0,0 +1,5 @@ +# Secrets — never commit. The root .gitignore already covers .env and *.key; +# this is defense-in-depth for the provisioning POC. +.env +.ai_agent_sandbox.key +*.key diff --git a/test/README.md b/test/README.md new file mode 100644 index 0000000..02c09d7 --- /dev/null +++ b/test/README.md @@ -0,0 +1,88 @@ +# test — Dolibarr UI automation (Deno + Playwright) + +A small Deno + Playwright POC that drives the Dolibarr admin UI in the `fr-FR` +locale. Playwright fills the same forms a human admin would, so the automation +works even where the REST API can't (e.g. generating an API key, which is +encrypted with the instance's own `DOLI_INSTANCE_UNIQUE_ID`). + +## Layout + +- `main.ts` — original entrypoint (first install, company/display/module setup). +- `provisionSandbox.ts` — entrypoint that provisions the `erp-sandbox` instance + for the AI agent (enable REST API, create a write-scoped user, generate its + API key). +- `scripts/login.ts` — admin login / logout / whoami helpers. +- `scripts/forms.ts` — `fillForm`, `toggleOnOff`, CKEditor/ACE helpers. +- `scripts/admin/moduleSetup.ts` — `configureModule`, `enableApiModule`. +- `scripts/admin/userSetup.ts` — `createUser`, `assignRights`, `generateApiKey`. + +## Configure + +Copy `.env.example` to `.env` and fill it in. `.env`, `*.key`, and +`.ai_agent_sandbox.key` are gitignored — never commit secrets. + +```sh +cp .env.example .env +``` + +## Provision the sandbox + +Provisions `erp-sandbox.arcodange.lab`: enables the REST API module, creates the +write-scoped `ai_agent_sandbox` user, grants it its write rights, and has +Dolibarr generate the user's API key. The key is written to +`test/.ai_agent_sandbox.key` (gitignored) — it is never printed. + +```sh +cd test +deno run --allow-all provisionSandbox.ts +``` + +Populate `.env` from the `erp-sandbox` namespace secrets first. `secretkv` +carries the app env (including `DOLI_ADMIN_PASSWORD`); `vso-db-credentials` +carries the database password: + +```sh +# Admin password (key DOLI_ADMIN_PASSWORD inside the secretkv secret) +kubectl get secret secretkv -n erp-sandbox \ + -o jsonpath='{.data.DOLI_ADMIN_PASSWORD}' | base64 -d + +# Database password (key `password` inside vso-db-credentials) +kubectl get secret vso-db-credentials -n erp-sandbox \ + -o jsonpath='{.data.password}' | base64 -d +``` + +Set in `.env`: + +```sh +DOLIBARR_ADDRESS=https://erp-sandbox.arcodange.lab +DOLI_ADMIN_LOGIN=admin +DOLI_ADMIN_PASSWORD="" +DOLI_DB_PASSWORD="" +# Optional — otherwise a random password is generated and only the API key emitted: +# AI_AGENT_SANDBOX_PASSWORD="" +``` + +### After it runs + +The generated API key lands in `test/.ai_agent_sandbox.key`. Next step (not +automated by this POC): load it into the `dolibarr` skill's sandbox config / +Vault at `kvv2/erp-sandbox/ai_agent`. + +> [!IMPORTANT] +> The sandbox Dolibarr is not installed/provisioned yet (empty DB, fresh install +> wizard). Until the install wizard has been completed against the sandbox, +> `provisionSandbox.ts` will not have a UI to drive, and the selectors in +> `moduleSetup.ts` / `userSetup.ts` are best-effort (Dolibarr 22 conventions, +> not verified live). Confirm them on the first real run. + +### Write rights granted + +The `ai_agent_sandbox` user is created non-admin and granted read + create on: + +| Module | rights ids | +| ---------------- | ---------------------------------- | +| facture | lire=11, creer=12 | +| societe | lire=121, creer=122 | +| societe contact | lire=281, creer=282 | +| fournisseur | lire=1181, facture lire=1231, facture creer=1232 | +| produit | lire=31, creer=32 | diff --git a/test/deno.lock b/test/deno.lock index 4e21a19..24113ff 100644 --- a/test/deno.lock +++ b/test/deno.lock @@ -1,7 +1,8 @@ { - "version": "4", + "version": "5", "specifiers": { "jsr:@std/dotenv@*": "0.225.2", + "npm:@types/node@*": "24.2.0", "npm:playwright@^1.48.2": "1.48.2" }, "jsr": { @@ -10,18 +11,33 @@ } }, "npm": { + "@types/node@24.2.0": { + "integrity": "sha512-3xyG3pMCq3oYCNg7/ZP+E1ooTaGB4cG8JWRsqqOYQdbWNY4zbaV0Ennrd7stjiJEFZCaybcIgpTjJWHRfBSIDw==", + "dependencies": [ + "undici-types" + ] + }, "fsevents@2.3.2": { - "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==" + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "os": ["darwin"], + "scripts": true }, "playwright-core@1.48.2": { - "integrity": "sha512-sjjw+qrLFlriJo64du+EK0kJgZzoQPsabGF4lBvsid+3CNIZIYLgnMj9V6JY5VhM2Peh20DJWIVpVljLLnlawA==" + "integrity": "sha512-sjjw+qrLFlriJo64du+EK0kJgZzoQPsabGF4lBvsid+3CNIZIYLgnMj9V6JY5VhM2Peh20DJWIVpVljLLnlawA==", + "bin": true }, "playwright@1.48.2": { "integrity": "sha512-NjYvYgp4BPmiwfe31j4gHLa3J7bD2WiBz8Lk2RoSsmX38SVIARZ18VYjxLjAcDsAhA+F4iSEXTSGgjua0rrlgQ==", "dependencies": [ - "fsevents", "playwright-core" - ] + ], + "optionalDependencies": [ + "fsevents" + ], + "bin": true + }, + "undici-types@7.10.0": { + "integrity": "sha512-t5Fy/nfn+14LuOc2KNYg75vZqClpAiqscVvMygNnlsHBFpSXdJaYtXMcdNLpl/Qvc3P2cB3s6lOV51nqsFq4ag==" } }, "workspace": { diff --git a/test/provisionSandbox.ts b/test/provisionSandbox.ts new file mode 100644 index 0000000..ef24327 --- /dev/null +++ b/test/provisionSandbox.ts @@ -0,0 +1,147 @@ +import "load_dotenv"; +import { chromium } from "playwright"; +import path from "node:path"; +import login from "./scripts/login.ts"; +import moduleSetup from "./scripts/admin/moduleSetup.ts"; +import userSetup from "./scripts/admin/userSetup.ts"; + +/* + provisionSandbox.ts — separate entrypoint (does NOT touch main.ts). + + Provisions the erp-sandbox Dolibarr so the AI agent can write to it: + 1. enable the REST API / Web services module, + 2. create a write-scoped `ai_agent_sandbox` user (non-admin), + 3. grant it the write rights it needs, + 4. have Dolibarr generate its API key and emit it safely to + test/.ai_agent_sandbox.key (gitignored). + + Run: + cd test && deno run --allow-all provisionSandbox.ts + + CANNOT be run end-to-end yet: the sandbox Dolibarr is not installed/provisioned + (empty DB, fresh install wizard). Selector correctness in moduleSetup.ts / + userSetup.ts is therefore best-effort and must be verified on the first real + run, AFTER the install wizard has been completed against the sandbox. + + NEXT STEP (not implemented here): load the generated key into the dolibarr + skill's sandbox config / Vault at kvv2/erp-sandbox/ai_agent. We intentionally + do NOT write to Vault from this POC. +*/ + +/* + Write rights to grant `ai_agent_sandbox` (stable Dolibarr rights ids, verified + against prod Dolibarr 22.0.4). Each module's read (lire) + create (creer): + facture: lire=11, creer=12 + societe: lire=121, creer=122 + societe contact: lire=281, creer=282 + fournisseur: lire=1181, facture lire=1231, facture creer=1232 + produit: lire=31, creer=32 +*/ +const WRITE_IDS = [ + 11, // facture lire + 12, // facture creer + 121, // societe lire + 122, // societe creer + 281, // societe contact lire + 282, // societe contact creer + 1181, // fournisseur lire + 1231, // fournisseur facture lire + 1232, // fournisseur facture creer + 31, // produit lire + 32, // produit creer +]; + +const KEY_FILE = ".ai_agent_sandbox.key"; + +/* + Initialisation — mirrors main.ts but targeted at the sandbox. The default + address points at the sandbox; admin creds come from env (the same + DOLI_ADMIN_LOGIN / DOLI_ADMIN_PASSWORD vars main.ts uses, populated from the + erp-sandbox namespace secrets — see test/README.md "Provision the sandbox"). +*/ +const dolibarrAddress = Deno.env.get("DOLIBARR_ADDRESS") || + "https://erp-sandbox.arcodange.lab"; +const debug = true; +const DBpassword = Deno.env.get("DOLI_DB_PASSWORD") || "undefined"; +const adminCredentials = { + username: Deno.env.get("DOLI_ADMIN_LOGIN") || "admin", + password: Deno.env.get("DOLI_ADMIN_PASSWORD") || "undefined", +}; + +// The new user's login is fixed; its password comes from env or is generated. +const AI_AGENT_LOGIN = "ai_agent_sandbox"; +const aiAgentPassword = Deno.env.get("AI_AGENT_SANDBOX_PASSWORD") || + generatePassword(); + +const rootFolderPath = Deno.env.get("ROOT_FOLDER") || + path.join(Deno.cwd(), ".."); +const imgFolderPath = Deno.env.get("IMG_FOLDER") || + path.join(rootFolderPath, "static/img"); +const configFolderPath = Deno.env.get("CONFIG_FOLDER") || + path.join(rootFolderPath, "static/config"); + +/* Generate a reasonably strong random password (used only if none provided). */ +function generatePassword(): string { + const bytes = new Uint8Array(18); + crypto.getRandomValues(bytes); + // URL-safe base64 → no shell-hostile characters. + return btoa(String.fromCharCode(...bytes)) + .replace(/\+/g, "-") + .replace(/\//g, "_") + .replace(/=+$/, ""); +} + +const browser = await chromium.launch({ + headless: false, + logger: { + isEnabled: (_name, _severity) => debug, + log: (name, severity, message, args) => + console.warn(`${severity}| ${name} :: ${message} __ ${args}`), + }, +}); +const context = await browser.newContext({ locale: "fr-FR" }); +const page = await context.newPage(); + +const globalCtx = { + dolibarrAddress, + DBpassword, + adminCredentials, + + debug, + rootFolderPath, + imgFolderPath, + configFolderPath, + + browser, + page, + context, +}; + +try { + await login.doAdminLogin(globalCtx); + console.log(`connected as ${await login.whoAmI(globalCtx)}`); + + await moduleSetup.enableApiModule(globalCtx); + console.log("REST API module enabled"); + + const userId = await userSetup.createUser(globalCtx, { + login: AI_AGENT_LOGIN, + password: aiAgentPassword, + lastname: "AI Agent (sandbox)", + admin: false, + }); + console.log(`created user '${AI_AGENT_LOGIN}' (id=${userId})`); + + await userSetup.assignRights(globalCtx, userId, WRITE_IDS); + console.log(`granted ${WRITE_IDS.length} write rights to id=${userId}`); + + const apiKey = await userSetup.generateApiKey(globalCtx, userId); + + // Emit the key safely: write it to a gitignored file rather than printing it. + await Deno.writeTextFile(KEY_FILE, apiKey + "\n"); + console.log(`AI_AGENT_SANDBOX API KEY WRITTEN TO test/${KEY_FILE}`); +} catch (error) { + console.error(error); +} finally { + await browser.close(); +} diff --git a/test/scripts/admin/moduleSetup.ts b/test/scripts/admin/moduleSetup.ts index d4a4533..99925a8 100644 --- a/test/scripts/admin/moduleSetup.ts +++ b/test/scripts/admin/moduleSetup.ts @@ -1,19 +1,20 @@ - // info-box -import { Page } from "playwright"; +import { Locator, Page } from "playwright"; import forms from "../forms.ts"; import login from "../login.ts"; // span.info-box-title has-text /moduleName/ // span.fa-cog[title="Configuration"] +type ModuleCtx = { + page: Page; + dolibarrAddress: string; + adminCredentials: { username: string; password: string }; +}; + async function configureModule( - globalCtx: { - page: Page; - dolibarrAddress: string; - adminCredentials: { username: string; password: string }; - }, + globalCtx: ModuleCtx, { moduleName, enabled, @@ -28,7 +29,7 @@ async function configureModule( await login.doAdminLogin(globalCtx); await page.goto(`${dolibarrAddress}/admin/modules.php?mode=commonkanban`); - + const moduleDiv = page.locator('.info-box', { has: page.locator('.info-box-title',{ hasText: new RegExp('^'+moduleName+'\n?$','i') @@ -42,6 +43,61 @@ async function configureModule( } } +/* + Enable Dolibarr's REST API / Web services module. + + Activation maps to llx_const.MAIN_MODULE_API=1; in the UI this is the module + card on /admin/modules.php (kanban mode). In fr_FR the card title is + "API/Web services REST (serveur)" (family "Interfaces"). + + NOTE: confirm the exact card title on the first real run against an installed + sandbox — the label has not been verified live here. To stay resilient we try + the known fr_FR label first (anchored exact match, same as configureModule), + and if that card is not present we fall back to matching ANY module card whose + title contains "API ... REST" / "REST ... API" in either order. +*/ +async function enableApiModule(globalCtx: ModuleCtx): Promise { + const {page, dolibarrAddress}= globalCtx; + + // fr_FR label as observed in the module catalogue. Confirm on first real run. + const knownLabel = "API/Web services REST (serveur)"; + + await login.doAdminLogin(globalCtx); + await page.goto(`${dolibarrAddress}/admin/modules.php?mode=commonkanban`); + + const exactCard = page.locator('.info-box', { + has: page.locator('.info-box-title',{ + hasText: new RegExp('^'+knownLabel+'\n?$','i') + }) + }); + + if(await exactCard.count() > 0) { + await forms.toggleOnOff(exactCard, true); + return; + } + + // Fallback: the exact fr_FR label was not found (different Dolibarr version, + // locale, or wording). Match any card whose title looks like the REST API + // module regardless of word order. + const fuzzyCard = page.locator('.info-box', { + has: page.locator('.info-box-title',{ + hasText: /API.*REST|REST.*API/i + }) + }); + + if(await fuzzyCard.count() === 0) { + throw new Error( + `REST API module card not found on ${dolibarrAddress}/admin/modules.php `+ + `(tried exact label "${knownLabel}" and /API.*REST|REST.*API/i). `+ + `Confirm the card title in the running sandbox.`, + ); + } + + // If several cards match the fuzzy pattern, toggle the first one only. + await forms.toggleOnOff(fuzzyCard.first() as Locator, true); +} + export default { configureModule, + enableApiModule, } diff --git a/test/scripts/admin/userSetup.ts b/test/scripts/admin/userSetup.ts new file mode 100644 index 0000000..58604d7 --- /dev/null +++ b/test/scripts/admin/userSetup.ts @@ -0,0 +1,255 @@ +/* + User provisioning for the Dolibarr admin UI, driven by Playwright. + + Why the UI and not raw SQL: + - Passwords use MAIN_SECURITY_HASH_ALGO=password_hash (bcrypt), so we let + Dolibarr hash them by submitting the create form. + - API keys are stored ENCRYPTED with the instance key DOLI_INSTANCE_UNIQUE_ID. + Each instance (incl. the sandbox) has its own uuid, so a key can only be + produced correctly by that instance — we MUST generate it via the UI and + read back the resulting value, never INSERT a raw key. + + IMPORTANT — selectors below are best-effort and have NOT been verified against + a live, installed sandbox (the sandbox Dolibarr is not provisioned yet). Field + names / icon controls are taken from Dolibarr 22 conventions and must be + confirmed on the first real run. Spots that are guessed are marked GUESS:. +*/ +import type { Page } from "playwright"; +import forms from "../forms.ts"; +import login from "../login.ts"; + +type UserCtx = { + page: Page; + dolibarrAddress: string; + adminCredentials: { username: string; password: string }; + // imgFolderPath is required by forms.fillForm's signature even though no + // file input is used here; provisionSandbox.ts supplies it from globalCtx. + imgFolderPath: string; +}; + +type NewUser = { + login: string; + password: string; + lastname?: string; + admin?: boolean; +}; + +// The text fields filled via forms.fillForm (the admin flag is a checkbox we +// handle separately, so it is intentionally not part of this type). +type UserFormFields = { + login: string; + password: string; + lastname?: string; +}; + +// fr_FR Dolibarr user create form (/user/card.php?action=create&mode=create). +// GUESS: confirm these input names on first real run. `login` and `lastname` +// are stable across Dolibarr versions; the password field has been `password` +// for the manual-entry case (the "generate automatically" option is a separate +// radio/checkbox we leave untouched so our explicit value is used). +const newUserInputNames = new Map([ + ["login", "login"], + ["lastname", "lastname"], + ["password", "password"], +]); + +/* + Create a user via /user/card.php?action=create and return its numeric id. + + The admin flag is a checkbox (name="admin"); fillForm has no checkbox handler + so we toggle it explicitly. After submit Dolibarr redirects to the user card + whose URL carries ?id= — we parse the id from there (falling back to a + hidden input on the page). +*/ +async function createUser( + globalCtx: UserCtx, + { login: userLogin, password, lastname, admin = false }: NewUser, +): Promise { + const { page, dolibarrAddress, imgFolderPath } = globalCtx; + await login.doAdminLogin(globalCtx); + + await page.goto(`${dolibarrAddress}/user/card.php?action=create&mode=create`); + + // Fill text inputs (login, lastname, password) without submitting yet. + const formFields: UserFormFields = { + login: userLogin, + password, + lastname, + }; + await forms.fillForm( + { page, imgFolderPath }, + formFields, + newUserInputNames, + 0, + ); + + // Admin checkbox — only present when the logged-in user is themselves admin. + // GUESS: input[name="admin"]; on some setups it's a