Files
computer-playbook/roles/web-app-odoo/files/playwright/playwright.spec.js
kevinveenbirkenbachandClaude Opus 5.5 373d5c9dc7 fix(web-app-odoo): run the onion suite on full Chromium so its origin is secure
The shared Playwright config passes --unsafely-treat-insecure-origin-as-secure
for every .onion origin in the environment, but Playwright's default
chrome-headless-shell ignores that switch. On the tor axis the odoo origin
therefore stayed an insecure context: navigator.clipboard was undefined and
Odoo 20's onboarding tour crashed with "Cannot read properties of undefined
(reading 'writeText')" and "Cannot destructure property 'TourInteractive'",
opening error dialogs that the addon specs read as a missing module (odoo#2,
swarm tor, main run 38038541631).

When ODOO_BASE_URL is an onion URL the odoo specs now use channel "chromium",
the full Chromium build in the Playwright image, and block service workers;
clearnet runs keep every option unchanged. The worker is blocked because a
secure context lets Odoo register /web/service-worker.js, whose fetch handler
answers a failed navigation with a cached page or /odoo/offline: gotoOnion
would get a 200 instead of the transport error it retries on. The switch stays
in the role: a use.channel in the shared config would also reach the Firefox
launch of onion-test.js, which rejects it with "Unsupported firefox channel",
and would move every other role's onion run to a browser none of them was
tested on.

Verified with Playwright 1.63 in the runner's staging layout and the real
odoo specs: an onion ODOO_BASE_URL resolves channel "chromium" and
serviceWorkers "block", also for the contexts _shared.js creates itself, and
launches chromium-<rev>/chrome-linux64/chrome; a clearnet one, and HEAD's
specs on onion, keep the headless shell and "allow". All spec files stay on
one worker, which the suite's one-worker test requires. Against Odoo 20's
service worker with a dropped connection, full Chromium returned the cached
/odoo page with status 200, while with the worker blocked page.goto threw
net::ERR_CONNECTION_FAILED as the headless shell does. In the v1.64.0 image
full Chromium made no :443 attempt to an onion host and the onion origin
reported isSecureContext with crypto.subtle, which the headless shell did not.
A local deploy of web-app-odoo variant 2 over Tor ran the odoo suite on
/ms-playwright/chromium-1248/chrome-linux64/chrome and passed all seven addon
specs, the one-worker test and the guest flow without a retry; in the variant
0 round the suites of the other roles kept chrome-headless-shell.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-12 18:35:22 +02:00

250 lines
10 KiB
JavaScript

const { test, expect } = require("@playwright/test");
const { resolveTimeout } = require("./timeouts");
const { decodeDotenvQuotedValue, isVisible, performKeycloakLoginForm, runAdminFlow, runBiberFlow, runGuestFlow, gotoOnion } = require("./personas");
const { isServiceEnabled } = require("./service-gating");
const { onionOptions } = require("./_shared");
test.use({
ignoreHTTPSErrors: true,
...onionOptions
});
const oidcEnabled = isServiceEnabled("sso");
// `docker --env-file` preserves the quotes emitted by `dotenv_quote`,
// so normalize these values before building URLs or typing credentials.
const oidcIssuerUrl = decodeDotenvQuotedValue(process.env.OIDC_ISSUER_URL);
const odooBaseUrl = decodeDotenvQuotedValue(process.env.ODOO_BASE_URL);
const adminUsername = decodeDotenvQuotedValue(process.env.ADMIN_USERNAME);
const adminPassword = decodeDotenvQuotedValue(process.env.ADMIN_PASSWORD);
const biberUsername = decodeDotenvQuotedValue(process.env.BIBER_USERNAME);
const biberPassword = decodeDotenvQuotedValue(process.env.BIBER_PASSWORD);
// Click the "Login with SSO" button on Odoo's login page.
// Odoo renders OAuth provider links inside a ".o_login_auth" container (modern
// layout; older variants used ".o_auth_oauth_providers"). The oe_login_form has
// class "d-none" when OAuth is enabled, so we wait for either container OR the
// SSO link itself directly.
async function clickOdooSsoButton(locator) {
const providerList = locator.locator(".o_login_auth, .o_auth_oauth_providers");
const ssoButton = locator
.locator('a[href*="/auth_oauth/signin"], a[href*="auth_oauth/signin"]')
.filter({ hasText: /login with sso|sign in with|continue with/i })
.first();
const ssoButtonByText = locator.getByRole("link", { name: /login with sso/i }).first();
await Promise.any([
providerList.first().waitFor({ state: "visible", timeout: resolveTimeout(60_000) }),
ssoButton.waitFor({ state: "visible", timeout: resolveTimeout(60_000) }),
ssoButtonByText.waitFor({ state: "visible", timeout: resolveTimeout(60_000) })
]);
if (await ssoButtonByText.isVisible().catch(() => false)) {
await ssoButtonByText.click({ timeout: resolveTimeout(30_000) });
} else {
await ssoButton.click({ timeout: resolveTimeout(30_000) });
}
}
// Check if user is authenticated in Odoo
// Odoo is a SPA - after login, the web client loads asynchronously.
// We check multiple indicators: the web client container, navbar elements,
// user menu, or the URL not being on the login page.
async function isOdooAuthenticated(locator) {
try {
// Primary indicators: web client root container that appears after successful load
const webClient = locator.locator(".o_web_client");
const actionManager = locator.locator(".o_action_manager");
const mainNavbar = locator.locator(".o_main_navbar");
// Secondary indicators: specific UI elements for logged-in users
const userMenu = locator.locator(".o_user_menu");
const appsIcon = locator.locator(".o_navbar_apps_menu, .o_menu_toggle");
const homeMenu = locator.locator(".o_home_menu");
// Any of these indicates successful authentication
return (
await webClient.first().isVisible().catch(() => false) ||
await actionManager.first().isVisible().catch(() => false) ||
await mainNavbar.first().isVisible().catch(() => false) ||
await userMenu.first().isVisible().catch(() => false) ||
await appsIcon.first().isVisible().catch(() => false) ||
await homeMenu.first().isVisible().catch(() => false)
);
} catch {
return false;
}
}
async function logOutThroughTheUserMenu(page) {
await page.locator("button.o_user_menu").first().click({ timeout: resolveTimeout(30_000) });
await page.locator('[data-menu="logout"]').first().click({ timeout: resolveTimeout(30_000) });
}
test.beforeEach(() => {
expect(oidcIssuerUrl, "OIDC_ISSUER_URL must be set in the Playwright env file").toBeTruthy();
expect(odooBaseUrl, "ODOO_BASE_URL must be set in the Playwright env file").toBeTruthy();
expect(adminUsername, "ADMIN_USERNAME must be set in the Playwright env file").toBeTruthy();
expect(adminPassword, "ADMIN_PASSWORD must be set in the Playwright env file").toBeTruthy();
expect(biberUsername, "BIBER_USERNAME must be set in the Playwright env file").toBeTruthy();
expect(biberPassword, "BIBER_PASSWORD must be set in the Playwright env file").toBeTruthy();
});
test("odoo: the suite runs on one worker", async () => {
expect(
test.info().config.workers,
"an OAuth sign-in ends every other session of the same account, so PLAYWRIGHT_MAX_WORKERS must keep the spec files that share the administrator on one worker"
).toBe(1);
});
// Scenario I: Odoo → SSO login as admin → verify authenticated → logout
// (dashboard-iframe mechanics covered by web-app-dashboard SPOT)
test("odoo: admin sso login, verify ui, logout", async ({ page }) => {
test.skip(!oidcEnabled, "OIDC shared service disabled");
const expectedOdooBaseUrl = odooBaseUrl.replace(/\/$/, "");
const odooLoginUrl = `${expectedOdooBaseUrl}/web/login`;
// 1. Navigate directly to Odoo login
await gotoOnion(page, odooLoginUrl);
// 2. Click the "Login with SSO" button (waits internally for provider list)
await clickOdooSsoButton(page);
// 3. After clicking SSO, the page navigates to Keycloak.
await expect
.poll(() => page.url(), {
timeout: resolveTimeout(60_000),
message: "Expected page to navigate to Keycloak for authentication"
})
.toContain(oidcIssuerUrl.replace(/\/$/, ""));
// 4. Perform OIDC login with admin credentials
await performKeycloakLoginForm(page, adminUsername, adminPassword);
// 5. Wait for navigation back to Odoo after authentication.
// The URL must contain the Odoo base URL but NOT /web/login (which would mean auth failed).
await expect
.poll(() => page.url(), {
timeout: resolveTimeout(60_000),
message: "Expected page to navigate back to Odoo authenticated area (not login page)"
})
.toMatch(new RegExp(`^${expectedOdooBaseUrl.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}(?!/web/login)`));
// 6. Verify the user is authenticated (Odoo shows apps/user menu)
await expect
.poll(
async () => await isOdooAuthenticated(page),
{
timeout: resolveTimeout(60_000),
message: "Expected Odoo to show authenticated user interface"
}
)
.toBe(true);
// 7. Log out through the user menu
await logOutThroughTheUserMenu(page);
// 8. Verify we're back on the login page (provider list visible again)
await expect
.poll(
async () => await isVisible(page.locator(".o_login_auth, .o_auth_oauth_providers")),
{
timeout: resolveTimeout(60_000),
message: "Expected Odoo to return to login page after logout"
}
)
.toBe(true);
});
// Scenario II: Odoo → SSO login as biber (regular user) → verify authenticated → logout
//
// Similar to admin test but verifies regular (non-admin) user SSO flow works.
// Biber is a standard user without admin privileges - this confirms OIDC works
// for all Keycloak users, not just the administrator.
test("odoo: biber sso login, verify ui, logout", async ({ page }) => {
test.skip(!oidcEnabled, "OIDC shared service disabled");
const expectedOdooBaseUrl = odooBaseUrl.replace(/\/$/, "");
const odooLoginUrl = `${expectedOdooBaseUrl}/web/login`;
// 1. Navigate directly to Odoo login
await gotoOnion(page, odooLoginUrl);
// 2. Click the "Login with SSO" button
await clickOdooSsoButton(page);
// 3. Wait for navigation to Keycloak
await expect
.poll(() => page.url(), {
timeout: resolveTimeout(60_000),
message: "Expected page to navigate to Keycloak for authentication"
})
.toContain(oidcIssuerUrl.replace(/\/$/, ""));
// 4. Perform OIDC login with biber credentials
await performKeycloakLoginForm(page, biberUsername, biberPassword);
// 5. Wait for navigation back to Odoo after authentication.
await expect
.poll(() => page.url(), {
timeout: resolveTimeout(60_000),
message: "Expected page to navigate back to Odoo authenticated area (not login page)"
})
.toMatch(new RegExp(`^${expectedOdooBaseUrl.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}(?!/web/login)`));
// 6. Verify the user is authenticated
await expect
.poll(
async () => await isOdooAuthenticated(page),
{
timeout: resolveTimeout(60_000),
message: "Expected Odoo to show authenticated user interface for biber"
}
)
.toBe(true);
// 7. Log out through the user menu
await logOutThroughTheUserMenu(page);
// 8. Verify we're back on the login page
await expect
.poll(
async () => await isVisible(page.locator(".o_login_auth, .o_auth_oauth_providers")),
{
timeout: resolveTimeout(60_000),
message: "Expected Odoo to return to login page after logout"
}
)
.toBe(true);
});
// Persona scenarios.
// Bodies live in the shared helper roles/test-e2e-playwright/files/personas.js
// so every role's persona flow stays consistent.
test("guest: public-landing → auth chain → never authenticated", async ({ page }) => {
await runGuestFlow(page);
});
test("biber: app → universal logout", async ({ page }) => {
await runBiberFlow(page);
});
test("administrator: app → universal logout", async ({ page }) => {
await runAdminFlow(page, {
adminInteraction: async (interactivePage) => {
// web-app-odoo admin-only interaction: open a management surface.
const link = interactivePage
.getByRole("link", { name: /^(settings|users|companies|apps|administrator)$/i })
.first();
if (await link.isVisible().catch(() => false)) {
await link.click({ timeout: resolveTimeout(30_000) }).catch(() => {});
await interactivePage.waitForLoadState("domcontentloaded", { timeout: resolveTimeout(30_000) }).catch(() => {});
await expect(interactivePage.locator("body")).toContainText(
/settings|users|companies|apps|technical|developer/i,
{ timeout: resolveTimeout(30_000) },
);
}
},
});
});