First real run against the sandbox revealed three issues in userSetup.ts: 1. generateApiKey generated the key client-side and read it into the file but never submitted the edit form, so Dolibarr never persisted api_key (DB stayed NULL → the key could not authenticate). Now it clicks Save after generating. 2. assignRights matched `rights=<id>` as an href substring, so a short id like 12 (facture creer) also matched rights=121 / rights=1232 and .first() clicked the wrong link — facture creer was never granted. Anchored with a trailing "&" (rights=<id>&) for an exact match. 3. createUser was not idempotent: a re-run hit the existing login and failed to parse a new id. Added findUserId (look up by login via the user list) and return the existing id instead of creating a duplicate. Verified the symptoms live: ai_agent_sandbox (rowid 4) had api_key NULL and was missing only facture/creer among the 11 intended rights. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
298 lines
11 KiB
TypeScript
298 lines
11 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);
|
|
|
|
// Idempotent: if the login already exists (e.g. a re-run), return its id rather
|
|
// than submitting a duplicate create (which Dolibarr rejects).
|
|
const existingId = await findUserId(globalCtx, userLogin);
|
|
if (existingId !== undefined) return existingId;
|
|
|
|
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;
|
|
}
|
|
|
|
/*
|
|
Find an existing user's numeric id by login via the user list, so createUser is
|
|
idempotent across re-runs. Matches the table row containing the login, then reads
|
|
the id from that row's user-card link.
|
|
*/
|
|
async function findUserId(
|
|
globalCtx: UserCtx,
|
|
userLogin: string,
|
|
): Promise<number | undefined> {
|
|
const { page, dolibarrAddress } = globalCtx;
|
|
await page.goto(
|
|
`${dolibarrAddress}/user/list.php?search_login=${encodeURIComponent(userLogin)}`,
|
|
);
|
|
await page.waitForLoadState("domcontentloaded");
|
|
const cardLink = page
|
|
.locator("tr", { hasText: userLogin })
|
|
.locator('a[href*="/user/card.php?id="]')
|
|
.first();
|
|
if (await cardLink.count() === 0) return undefined;
|
|
const href = (await cardLink.getAttribute("href")) ?? "";
|
|
const m = href.match(/[?&]id=(\d+)/);
|
|
return m ? Number(m[1]) : 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}`);
|
|
|
|
// The grant control is an <a> linking to action=addrights&rights=<id>. The
|
|
// trailing "&" anchors the id so rights=12 does NOT substring-match rights=121
|
|
// / rights=1232 etc. (Dolibarr hrefs are ...&rights=<id>&confirm=yes...).
|
|
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}.`,
|
|
);
|
|
}
|
|
|
|
// Persist: the generate control only fills the field client-side. Submit the
|
|
// edit form so Dolibarr saves api_key to the DB — otherwise it stays NULL and
|
|
// the generated key never authenticates.
|
|
const saveBtn = page.locator('input[name="save"], button[name="save"]');
|
|
if (await saveBtn.count() > 0) {
|
|
await saveBtn.first().click();
|
|
} else {
|
|
await page.click(':nth-match(input[type="submit"], 1)');
|
|
}
|
|
await page.waitForLoadState("domcontentloaded");
|
|
|
|
return key;
|
|
}
|
|
|
|
export default {
|
|
createUser,
|
|
assignRights,
|
|
generateApiKey,
|
|
};
|