From 700efa9028297ea994c26bab31abd469ca7f1212 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Salavisa?= Date: Tue, 25 Aug 2026 20:10:02 +0100 Subject: [PATCH] test(e2e): cover the Docker build gate at phone viewport (#3041) Co-authored-by: Kandev Agent Co-authored-by: Claude Opus 5 (1M context) --- apps/web/e2e/helpers/session-store.ts | 50 +++++++++++++++ .../docker-profile-persistence.spec.ts | 19 +----- .../mobile-docker-build-permissions.spec.ts | 63 +++++++++++++++++++ 3 files changed, 115 insertions(+), 17 deletions(-) create mode 100644 apps/web/e2e/tests/settings/mobile-docker-build-permissions.spec.ts diff --git a/apps/web/e2e/helpers/session-store.ts b/apps/web/e2e/helpers/session-store.ts index 1ca1c8db50..ab5595a6ba 100644 --- a/apps/web/e2e/helpers/session-store.ts +++ b/apps/web/e2e/helpers/session-store.ts @@ -7,6 +7,12 @@ type E2EStoreWindow = Window & { tasks: { activeSessionId: string | null }; sessionAgentctl: { itemsBySessionId: Record }; setAvailableCommands: (sessionId: string, commands: AvailableCommand[]) => void; + setAuthState: (state: { + mode: string; + authenticated: boolean; + user: StoreUser; + ssoProviders: unknown[]; + }) => void; }; setState: ( updater: (state: { @@ -16,12 +22,56 @@ type E2EStoreWindow = Window & { }; }; +type StoreUser = { + id: string; + email: string; + display_name: string; + role: string; + status: string; +}; + type AvailableCommand = { name: string; description?: string; input_hint?: string; }; +/** + * Inject an authenticated identity through the store bridge. + * + * E2E runs with authentication disabled, which leaves the role undefined and + * every role-gated control in its permissive single-user state. Specs that + * need to prove a member/admin difference set the identity directly rather + * than standing up a real login, matching what the system settings specs do. + */ +export async function setStoreRole( + page: Page, + role: "member" | "admin", + overrides: Partial = {}, +): Promise { + await page.waitForFunction(() => Boolean((window as E2EStoreWindow).__KANDEV_E2E_STORE__)); + await page.evaluate( + ({ role, overrides }) => { + const store = (window as E2EStoreWindow).__KANDEV_E2E_STORE__; + if (!store) throw new Error("E2E store bridge is unavailable"); + store.getState().setAuthState({ + mode: "enabled", + authenticated: true, + user: { + id: `e2e-${role}`, + email: `${role}@e2e.dev`, + display_name: role === "admin" ? "E2E Admin" : "E2E Member", + role, + status: "active", + ...overrides, + }, + ssoProviders: [], + }); + }, + { role, overrides }, + ); +} + /** Wait until the session agentctl is ready for controls that require it. */ export async function waitForSessionAgentctlReady( page: Page, diff --git a/apps/web/e2e/tests/settings/docker-profile-persistence.spec.ts b/apps/web/e2e/tests/settings/docker-profile-persistence.spec.ts index 0f2a5c811a..28e367024b 100644 --- a/apps/web/e2e/tests/settings/docker-profile-persistence.spec.ts +++ b/apps/web/e2e/tests/settings/docker-profile-persistence.spec.ts @@ -1,4 +1,5 @@ import { test, expect } from "../../fixtures/test-base"; +import { setStoreRole } from "../../helpers/session-store"; /** * Regression test for the Docker executor profile UI persisting Dockerfile @@ -220,23 +221,7 @@ test.describe("Docker executor profile persistence", () => { const buildButton = testPage.getByRole("button", { name: "Build Image" }); await expect(buildButton).toBeEnabled(); - await testPage.waitForFunction(() => Boolean(window.__KANDEV_E2E_STORE__)); - await testPage.evaluate(() => { - const store = window.__KANDEV_E2E_STORE__; - if (!store) throw new Error("E2E store bridge is unavailable"); - store.getState().setAuthState({ - mode: "enabled", - authenticated: true, - user: { - id: "e2e-member", - email: "member@e2e.dev", - display_name: "E2E Member", - role: "member", - status: "active", - }, - ssoProviders: [], - }); - }); + await setStoreRole(testPage, "member"); await expect(buildButton).toBeDisabled(); await expect(testPage.getByText("Only administrators can build images.")).toBeVisible(); diff --git a/apps/web/e2e/tests/settings/mobile-docker-build-permissions.spec.ts b/apps/web/e2e/tests/settings/mobile-docker-build-permissions.spec.ts new file mode 100644 index 0000000000..4a8c076486 --- /dev/null +++ b/apps/web/e2e/tests/settings/mobile-docker-build-permissions.spec.ts @@ -0,0 +1,63 @@ +import { test, expect } from "../../fixtures/test-base"; +import { setStoreRole } from "../../helpers/session-store"; + +/** + * Mobile parity for the admin gating on the Dockerfile build control. + * + * The desktop coverage lives in docker-profile-persistence.spec.ts, which the + * `chromium` project runs at desktop viewport; only `mobile-*.spec.ts` files + * reach the Pixel 5 `mobile-chrome` project. The gating swaps the build status + * badge for a sentence of explanation inside a horizontal flex row, so the + * narrow viewport is where that row can overflow, and it is asserted here + * rather than assumed from the desktop run. + * + * The security boundary is the backend's authn.RequireAdmin() on + * POST /api/v1/docker/build; this covers the UI not offering a control that + * can only 403. + */ +test.describe("Docker build permissions on mobile", () => { + test("member sees the build control disabled and readable at phone width", async ({ + testPage, + }) => { + await testPage.goto("/settings/executors/new/local_docker"); + await expect(testPage.locator("#profile-name")).toHaveValue("Docker", { timeout: 10_000 }); + await testPage.getByRole("button", { name: "Use defaults" }).click(); + + const buildButton = testPage.getByRole("button", { name: "Build Image" }); + await expect(buildButton).toBeEnabled(); + + await setStoreRole(testPage, "member"); + + await expect(buildButton).toBeDisabled(); + const explanation = testPage.getByText("Only administrators can build images."); + await expect(explanation).toBeVisible(); + + // The explanation must sit inside the viewport, not run off the side of + // the flex row it shares with the button. + const viewportWidth = testPage.viewportSize()?.width ?? 0; + expect(viewportWidth).toBeGreaterThan(0); + const explanationBox = await explanation.boundingBox(); + expect(explanationBox).not.toBeNull(); + expect(explanationBox!.x).toBeGreaterThanOrEqual(0); + expect(explanationBox!.x + explanationBox!.width).toBeLessThanOrEqual(viewportWidth); + + // And it must not introduce document-level horizontal scrolling. The DOM + // guarantees scrollWidth >= clientWidth, so the only passing value is 0; + // asserting that exactly keeps the failure message readable. + const horizontalOverflow = await testPage.evaluate( + () => document.documentElement.scrollWidth - document.documentElement.clientWidth, + ); + expect(horizontalOverflow).toBe(0); + }); + + test("admin keeps the build control usable at phone width", async ({ testPage }) => { + await testPage.goto("/settings/executors/new/local_docker"); + await expect(testPage.locator("#profile-name")).toHaveValue("Docker", { timeout: 10_000 }); + await testPage.getByRole("button", { name: "Use defaults" }).click(); + + await setStoreRole(testPage, "admin"); + + await expect(testPage.getByRole("button", { name: "Build Image" })).toBeEnabled(); + await expect(testPage.getByText("Only administrators can build images.")).toHaveCount(0); + }); +});