mirror of
https://github.com/kevinveenbirkenbach/computer-playbook.git
synced 2026-11-06 10:59:59 +00:00
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>
250 lines
10 KiB
JavaScript
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) },
|
|
);
|
|
}
|
|
},
|
|
});
|
|
});
|