Files
erp/test/scripts/admin/userSetup.ts
Gabriel Radureau 523f0cf001 feat(test): provision erp-sandbox via Playwright (REST API + write-scoped ai_agent_sandbox user)
Extend the Deno + Playwright UI-automation POC to provision the erp-sandbox
Dolibarr for the AI agent:

- moduleSetup.ts: add enableApiModule(ctx) — toggles the REST API / Web services
  module on /admin/modules.php (kanban). Resilient: tries the fr_FR card label
  "API/Web services REST (serveur)" first, falls back to a /API.*REST|REST.*API/i
  title match if the exact label is absent.
- userSetup.ts (new): createUser (returns the new numeric id), assignRights
  (clicks each addrights link on /user/perms.php, idempotent), generateApiKey
  (triggers Dolibarr's generate control on the user card and reads the value back).
- provisionSandbox.ts (new entrypoint, main.ts untouched): login → enable API →
  create ai_agent_sandbox (non-admin) → grant write rights → generate API key,
  then write the key to test/.ai_agent_sandbox.key (gitignored) instead of
  printing it.
- .gitignore (new), .env.example + README.md: sandbox vars, the
  deno run --allow-all provisionSandbox.ts command, and kubectl one-liners to
  pull DOLI_ADMIN_PASSWORD (secretkv) / DOLI_DB_PASSWORD (vso-db-credentials)
  from the erp-sandbox namespace.

Why UI not SQL: API keys are encrypted with the instance's DOLI_INSTANCE_UNIQUE_ID,
so the key must be generated by the sandbox itself, not INSERTed raw.

deno check passes for provisionSandbox.ts and scripts/admin/userSetup.ts.
NOT run end-to-end: the sandbox Dolibarr is not installed yet (empty DB / fresh
install wizard), so the selectors are best-effort Dolibarr 22 conventions and
must be confirmed on the first real run.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-29 07:34:21 +02:00

256 lines
9.0 KiB
TypeScript

/*
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<keyof UserFormFields, string>([
["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=<n> — 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<number> {
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 <select> instead.
const adminCheckbox = page.locator('input[name="admin"]');
if (await adminCheckbox.count() > 0) {
if (admin) {
await adminCheckbox.check();
} else {
// Leave unchecked (default), but be explicit/defensive.
if (await adminCheckbox.isChecked()) await adminCheckbox.uncheck();
}
}
// Submit the create form. The create page has a single primary submit button
// ("Créer utilisateur"); :nth-match index 1 mirrors the fillForm convention.
await page.click(':nth-match(input[type="submit"], 1)');
// After creation Dolibarr lands on the user card. Parse the id.
await page.waitForLoadState("domcontentloaded");
const id = await readUserIdFromPage(page);
if (id === undefined) {
throw new Error(
`Could not determine the new user id for login "${userLogin}" after ` +
`submitting the create form (current URL: ${page.url()}).`,
);
}
return id;
}
/*
Read the numeric user id from the current user card: first from the URL
(?id=<n>), then from a hidden input[name="id"] as a fallback.
*/
async function readUserIdFromPage(page: Page): Promise<number | undefined> {
const fromUrl = new URL(page.url()).searchParams.get("id");
if (fromUrl && /^\d+$/.test(fromUrl)) return Number(fromUrl);
// GUESS: a hidden input carrying the id (common on Dolibarr card pages).
const hidden = page.locator('input[name="id"]');
if (await hidden.count() > 0) {
const val = await hidden.first().getAttribute("value");
if (val && /^\d+$/.test(val)) return Number(val);
}
return undefined;
}
/*
Grant a set of rights to a user on /user/perms.php?id=<userId>.
Dolibarr renders each grantable permission as a link whose href carries
action=addrights&rights=<id> (and a matching action=delrights to revoke).
A right that is already granted shows the delrights link instead, so we treat
the presence of an addrights link as "not yet granted" and click it; if it's
absent we assume the right is already on and skip it (defensive/idempotent).
*/
async function assignRights(
globalCtx: UserCtx,
userId: number,
rightIds: number[],
): Promise<void> {
const { page, dolibarrAddress } = globalCtx;
await login.doAdminLogin(globalCtx);
for (const rightId of rightIds) {
// Re-navigate per right: Dolibarr reloads the perms table after each grant,
// which would otherwise stale the locators.
await page.goto(`${dolibarrAddress}/user/perms.php?id=${userId}`);
// GUESS: the grant control is an <a> linking to action=addrights&rights=<id>.
// We match on the href substring to avoid depending on row layout / icons.
const addLink = page.locator(
`a[href*="action=addrights"][href*="rights=${rightId}"]`,
);
if (await addLink.count() === 0) {
// Either already granted (only a delrights link is shown) or the row is
// not visible for this user. Skip rather than fail the whole run.
continue;
}
await addLink.first().click();
await page.waitForLoadState("domcontentloaded");
}
}
/*
Generate (or read) the user's API key on /user/card.php?id=<userId> in edit
mode and RETURN it as a string.
With the API module enabled the user card edit form shows an api_key text
input plus a "generate" control (a dice/refresh icon-link, typically
id="generate_api_key" or an <a> with action=...generate...apikey). We trigger
generation, then read the value back out of the api_key input. If a key is
already present we read and return it without regenerating (idempotent).
The caller is responsible for handling the returned secret safely — DO NOT log
it at debug verbosity.
*/
async function generateApiKey(
globalCtx: UserCtx,
userId: number,
): Promise<string> {
const { page, dolibarrAddress } = globalCtx;
await login.doAdminLogin(globalCtx);
await page.goto(`${dolibarrAddress}/user/card.php?id=${userId}&action=edit`);
// GUESS: the API key field is input[name="api_key"].
const apiKeyInput = page.locator('input[name="api_key"]');
if (await apiKeyInput.count() === 0) {
throw new Error(
`API key input (input[name="api_key"]) not found on the user card for ` +
`id=${userId}. Is the REST API module enabled? Confirm the field name ` +
`on the running sandbox.`,
);
}
// If a key is already set, reuse it.
const existing = (await apiKeyInput.first().inputValue()).trim();
if (existing.length > 0) return existing;
// Trigger generation. GUESS: a generate control next to the field. We try a
// few likely selectors in order and click the first that exists.
const generateSelectors = [
"#generate_api_key",
'a[href*="action=generateapikey"]',
'a[href*="generate"][href*="apikey"]',
// Icon-only fallbacks commonly used by Dolibarr "generate" buttons.
"span.fa-dice",
"a.fa-refresh",
];
let clicked = false;
for (const sel of generateSelectors) {
const ctrl = page.locator(sel);
if (await ctrl.count() > 0) {
await ctrl.first().click();
clicked = true;
break;
}
}
if (!clicked) {
throw new Error(
`No API-key generate control found near input[name="api_key"] for ` +
`id=${userId} (tried ${generateSelectors.join(", ")}). Confirm the ` +
`generate control on the running sandbox.`,
);
}
// The generate control fills the input client-side; poll the input value
// (via the locator, no browser globals) until it is non-empty or we time out.
let key = "";
const deadline = Date.now() + 10_000;
while (Date.now() < deadline) {
key = (await apiKeyInput.first().inputValue()).trim();
if (key.length > 0) break;
await page.waitForTimeout(200);
}
if (key.length === 0) {
throw new Error(
`API key field was still empty after triggering generation for ` +
`id=${userId}.`,
);
}
return key;
}
export default {
createUser,
assignRights,
generateApiKey,
};