From bb86336017f418aa2a897a238b221aad961cd44e Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 00:14:10 +0000 Subject: [PATCH 1/8] test(e2e): add TestE2E_ResticRepoReady and warm the repo before live specs Spec 013 T037/T038: the operator bucket and the e2e-web-live job both call ensureResticRepo and assert the warm-up Job succeeded, so the live backup spec starts against a ready restic repo. Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: Claude --- .github/workflows/ci.yaml | 8 ++++++++ test/e2e/buckets.sh | 1 + test/e2e/test_helpers_e2e_test.go | 21 +++++++++++++++++++++ 3 files changed, 30 insertions(+) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index d5018cbe6..33c3a7d39 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -1848,6 +1848,14 @@ jobs: GAMEPLANE_E2E_REUSE_CLUSTER=1 \ GAMEPLANE_E2E_CLUSTER="$CLUSTER" \ go test -tags=e2e -timeout 5m -v -run '^TestAPI_BootstrapAndLogin$' ./... + - name: restic repo ready + # backup-restic-secret.yaml + restic-server.yaml + warm-up Job persist in the + # cluster (no cleanup), so backup-restore.spec.ts can back up to the shared repo. + run: | + cd test/e2e && \ + GAMEPLANE_E2E_REUSE_CLUSTER=1 \ + GAMEPLANE_E2E_CLUSTER="$CLUSTER" \ + go test -tags=e2e -timeout 10m -v -run '^TestE2E_ResticRepoReady$' ./... - name: playwright browser cache uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: diff --git a/test/e2e/buckets.sh b/test/e2e/buckets.sh index 28fcd35a2..ec1c855c0 100755 --- a/test/e2e/buckets.sh +++ b/test/e2e/buckets.sh @@ -105,6 +105,7 @@ TestAddressPool_ExplicitAddressRequest TestAddressPool_AddressInUseConflict TestAddressPool_PoolExhausted TestAPI_EventStreamAndRoleEdits_FollowCallerPermissions +TestE2E_ResticRepoReady EOF } diff --git a/test/e2e/test_helpers_e2e_test.go b/test/e2e/test_helpers_e2e_test.go index a744a433c..66056840f 100644 --- a/test/e2e/test_helpers_e2e_test.go +++ b/test/e2e/test_helpers_e2e_test.go @@ -548,3 +548,24 @@ func runResticWarmupJob(e *Env, jobAbs string) error { time.Sleep(2 * time.Second) } } + +// TestE2E_ResticRepoReady warms the shared restic repository and asserts the +// warm-up Job succeeded. It is idempotent with every other ensureResticRepo +// caller and is also run by the e2e-web-live CI job before Playwright +// (backup-restore.spec.ts needs the restic server + initialised repo). +func TestE2E_ResticRepoReady(t *testing.T) { + t.Parallel() + ensureResticRepo(t) + + envInstance.Eventually(t, 30*time.Second, func() (bool, string) { + j, err := envInstance.K8s.BatchV1().Jobs("gameplane-games"). + Get(context.Background(), "e2e-restic-warmup", metav1.GetOptions{}) + if err != nil { + return false, "get warm-up job: " + err.Error() + } + if j.Status.Succeeded < 1 { + return false, fmt.Sprintf("job e2e-restic-warmup succeeded=%d, want >= 1", j.Status.Succeeded) + } + return true, "" + }) +} From 2add547f28a64f951a6b1a458164c25fa205d17b Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 00:14:10 +0000 Subject: [PATCH 2/8] test(web): live e2e for admin notifications, users, roles, backups, registries Spec 013 T039-T043: five live Playwright specs, each with a reload persistence check and a real-API error path, plus the _seed.ts helpers they need (users, roles, backup destination, cleanup). Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: Claude --- web/e2e/specs/live/_seed.ts | 116 +++++++- .../specs/live/admin-notifications.spec.ts | 109 ++++++++ web/e2e/specs/live/backup-restore.spec.ts | 254 ++++++++++++++++++ web/e2e/specs/live/mod-registries.spec.ts | 119 ++++++++ web/e2e/specs/live/role-bindings.spec.ts | 134 +++++++++ web/e2e/specs/live/users-and-roles.spec.ts | 137 ++++++++++ 6 files changed, 868 insertions(+), 1 deletion(-) create mode 100644 web/e2e/specs/live/admin-notifications.spec.ts create mode 100644 web/e2e/specs/live/backup-restore.spec.ts create mode 100644 web/e2e/specs/live/mod-registries.spec.ts create mode 100644 web/e2e/specs/live/role-bindings.spec.ts create mode 100644 web/e2e/specs/live/users-and-roles.spec.ts diff --git a/web/e2e/specs/live/_seed.ts b/web/e2e/specs/live/_seed.ts index a75e6ed30..5c3e80130 100644 --- a/web/e2e/specs/live/_seed.ts +++ b/web/e2e/specs/live/_seed.ts @@ -25,7 +25,7 @@ export { loginIfNeeded } from "../../pages/LoginPage"; // text/html navigations on collision paths like /servers, and forwards // everything else to the API. Pinning Accept keeps the seed request on // the API side of that bypass regardless of Playwright's default. -async function seedHeaders(request: APIRequestContext): Promise> { +export async function seedHeaders(request: APIRequestContext): Promise> { const state = await request.storageState(); const token = state.cookies.find((c) => c.name === "gameplane_csrf")?.value ?? ""; return { "X-Gameplane-CSRF": token, Accept: "application/json" }; @@ -122,3 +122,117 @@ export async function seedServer(request: APIRequestContext, opts: SeedServerOpt }, }; } + +// Generic mutating call with the CSRF header. Path is relative to the request's baseURL. +export async function apiSend( + request: APIRequestContext, + method: "POST" | "PUT" | "PATCH" | "DELETE", + path: string, + data?: unknown, +): Promise { + return request.fetch(path, { + method, + headers: await seedHeaders(request), + ...(data === undefined ? {} : { data }), + }); +} + +export interface SeededUser extends Seeded { + id: number; + username: string; + password: string; +} + +// POST /users (users.go:113). password must be >= 12 chars (auth.MinPasswordLen, password.go:61); +// username must match ^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,63}$ (validate.go:17); role must exist +// (default "viewer"). Response JSON includes numeric `id`. +export async function seedUser( + request: APIRequestContext, + opts: { username: string; password?: string; role?: string; displayName?: string }, +): Promise { + const password = opts.password ?? `E2e-${Math.random().toString(36).slice(2, 12)}-Pw1`; + const res = await apiSend(request, "POST", "/users", { + username: opts.username, + password, + role: opts.role ?? "viewer", + ...(opts.displayName ? { displayName: opts.displayName } : {}), + }); + if (!res.ok()) { + throw new Error(`seed user ${opts.username} failed: ${res.status()} ${await res.text().catch(() => "")}`); + } + const { id } = (await res.json()) as { id: number }; + return { + name: opts.username, + username: opts.username, + password, + id, + cleanup: (req) => deleteUser(req, id), + }; +} + +// DELETE /users/{id}; best-effort, safe if already gone. Also removes the user's bindings. +export async function deleteUser(request: APIRequestContext, id: number): Promise { + await apiSend(request, "DELETE", `/users/${id}`).catch(() => undefined); +} + +// POST /roles {name, description, permissions} (roles.go:116). Permission keys must exist in +// api/internal/rbac/catalog.go (e.g. "servers:read"); "*" is rejected. +export async function seedRole( + request: APIRequestContext, + opts: { name: string; description?: string; permissions: string[] }, +): Promise { + const res = await apiSend(request, "POST", "/roles", { + name: opts.name, + description: opts.description ?? "", + permissions: opts.permissions, + }); + await expectSeedOk(res, `seed role ${opts.name}`); + return { + name: opts.name, + cleanup: async (req) => { + await apiSend(req, "DELETE", `/roles/${encodeURIComponent(opts.name)}`).catch(() => undefined); + }, + }; +} + +export const RESTIC_REPO_URL = "rest:http://gameplane-test-restic.gameplane-system.svc:8000/"; +export const RESTIC_REPO_PASSWORD = "e2e-test-password-not-secret"; +// Same values as test/e2e/fixtures/backup-restic-secret.yaml (stringData repo/password), so the +// repository initialised by TestE2E_ResticRepoReady is reused. + +// POST /backup-destinations {name,url,password} (destinations.go:97-). Creates a Secret in +// gameplane-games labelled gameplane.local/backup-destination=true with keys repo+password. +// 400 unless name is a DNS label. +export async function seedBackupDestination( + request: APIRequestContext, + name: string, +): Promise { + const res = await apiSend(request, "POST", "/backup-destinations", { + name, + url: RESTIC_REPO_URL, + password: RESTIC_REPO_PASSWORD, + }); + await expectSeedOk(res, `seed backup destination ${name}`); + return { + name, + cleanup: async (req) => { + await apiSend(req, "DELETE", `/backup-destinations/${name}`).catch(() => undefined); + }, + }; +} + +// Best-effort: delete every Backup and Restore (default namespace) whose spec.serverRef.name === serverName. +export async function cleanupServerBackups(request: APIRequestContext, serverName: string): Promise { + for (const kind of ["backups", "restores"] as const) { + const res = await request.get(`/${kind}`, { headers: { Accept: "application/json" } }).catch(() => null); + if (!res || !res.ok()) continue; + const list = (await res.json()) as { + items?: Array<{ metadata: { name: string }; spec: { serverRef: { name: string } } }>; + }; + for (const it of list.items ?? []) { + if (it.spec.serverRef.name === serverName) { + await apiSend(request, "DELETE", `/${kind}/${it.metadata.name}`).catch(() => undefined); + } + } + } +} diff --git a/web/e2e/specs/live/admin-notifications.spec.ts b/web/e2e/specs/live/admin-notifications.spec.ts new file mode 100644 index 000000000..f7ea93a54 --- /dev/null +++ b/web/e2e/specs/live/admin-notifications.spec.ts @@ -0,0 +1,109 @@ +import { test, expect } from "@playwright/test"; +import type { Page } from "@playwright/test"; +import { loginIfNeeded, apiSend, seedHeaders } from "./_seed"; + +// Live coverage for the admin Notifications section (web/src/routes/AdminSettings.tsx, +// NotificationsSection). Drives the real UI against the real API: sink add/edit/delete +// persist through PUT /admin/config/notifications and the labelled Secret, and the SSRF +// guard is observed at delivery time through "Send test". +// +// PREMISE NOTE (edit step): no UI edits a sink's URL, and the URL is never returned to the +// browser (it is written only into a labelled Secret). The "edit" step therefore toggles the +// sink's enabled switch, saves, reloads and asserts the persisted state flipped. +// +// PREMISE NOTE (error path): saving does NOT reject http://169.254.169.254/. The API only +// checks scheme and host on save (api/internal/handlers/notifications.go requireURL). The +// SSRF block happens at delivery (netguard.IsAllowed in notify.HTTPClient), so the error +// surfaces only after "Send test", as the row's inline result text. + +test.describe("live: admin notifications", () => { + test.skip( + process.env.GAMEPLANE_E2E_TARGET !== "live", + "the mock spec covers notification states against a scripted fetch shim", + ); + + let sink = ""; + + test.beforeEach(async ({ page }) => { + // Lowercase DNS label, well under the 46-character limit. + sink = `e2e-notif-${Date.now().toString(36)}`; + await page.goto("/"); + await page.waitForLoadState("domcontentloaded"); + await loginIfNeeded(page); + await page.goto("/admin?section=notifications"); + await expect(page.getByText("Notifications", { exact: true }).first()).toBeVisible({ + timeout: 15_000, + }); + }); + + test.afterEach(async ({ request }) => { + if (!sink) return; + // Best-effort cleanup: drop only this test's sink from the list, then its Secret. + const res = await request + .get("/admin/config", { headers: await seedHeaders(request) }) + .catch(() => undefined); + if (res?.ok()) { + const cfg = (await res.json()) as { notifications?: { sinks?: { name: string }[] } }; + const sinks = (cfg.notifications?.sinks ?? []).filter((s) => s.name !== sink); + await apiSend(request, "PUT", "/admin/config/notifications", { + ...cfg.notifications, + sinks, + }).catch(() => undefined); + } + await apiSend(request, "DELETE", `/admin/notifications/sinks/${sink}/secret`).catch( + () => undefined, + ); + }); + + async function addWebhookSink(page: Page, url: string) { + await page.getByRole("button", { name: "Add sink", exact: true }).click(); + await page.getByPlaceholder("team-alerts").fill(sink); + await page.getByRole("button", { name: /Sink kind/i }).click(); + await page.getByRole("option", { name: /webhook/i }).click(); + await page.getByPlaceholder("https://example.com/hook").fill(url); + // Form submit. The header "Add sink" button is replaced by the form while adding. + await page.getByRole("button", { name: "Add sink", exact: true }).click(); + await page.getByRole("button", { name: "Save changes" }).click(); + await expect(page.getByText("Saved", { exact: true })).toBeVisible({ timeout: 15_000 }); + } + + test("webhook sink create, edit and delete persist across reload", async ({ page }) => { + await addWebhookSink(page, "http://gameplane-api.gameplane-system.svc/healthz"); + + await page.reload(); + const row = page.getByRole("listitem").filter({ hasText: sink }); + await expect(row).toBeVisible({ timeout: 15_000 }); + await expect(row).toContainText(`webhook · Secret: gameplane-notify-${sink}`); + + // Edit substitute: disable the sink (see PREMISE NOTE above), save, reload. + await row.getByRole("switch", { name: `Disable sink ${sink}` }).click(); + await page.getByRole("button", { name: "Save changes" }).click(); + await expect(page.getByText("Saved", { exact: true })).toBeVisible({ timeout: 15_000 }); + + await page.reload(); + await expect(page.getByRole("switch", { name: `Enable sink ${sink}` })).toBeVisible({ + timeout: 15_000, + }); + + // Delete, save, reload. + await page.getByRole("button", { name: `Delete sink ${sink}` }).click(); + await page.getByRole("button", { name: "Save changes" }).click(); + await expect(page.getByText("Saved", { exact: true })).toBeVisible({ timeout: 15_000 }); + + await page.reload(); + await expect(page.getByText("No notification sinks configured.")).toBeVisible({ + timeout: 15_000, + }); + await expect(page.getByRole("listitem").filter({ hasText: sink })).toHaveCount(0); + }); + + test("SSRF-blocked webhook URL is rejected at delivery and rendered inline", async ({ page }) => { + // Save accepts the link-local address; the block happens when the test is delivered. + await addWebhookSink(page, "http://169.254.169.254/"); + + const row = page.getByRole("listitem").filter({ hasText: sink }); + await expect(row).toBeVisible({ timeout: 15_000 }); + await row.getByRole("button", { name: "Send test" }).click(); + await expect(row.getByText(/not an allowed destination/i)).toBeVisible({ timeout: 20_000 }); + }); +}); diff --git a/web/e2e/specs/live/backup-restore.spec.ts b/web/e2e/specs/live/backup-restore.spec.ts new file mode 100644 index 000000000..9c7a944ee --- /dev/null +++ b/web/e2e/specs/live/backup-restore.spec.ts @@ -0,0 +1,254 @@ +import { test, expect } from "@playwright/test"; +import type { APIRequestContext, Locator, Page } from "@playwright/test"; +import { execFileSync } from "node:child_process"; +import { + loginIfNeeded, + seedTemplate, + seedServer, + seedBackupDestination, + cleanupServerBackups, + apiSend, +} from "./_seed"; +import type { Seeded } from "./_seed"; +import { ServerDetailPage } from "../../pages/ServerDetailPage"; + +// Live: backup and restore a real GameServer through the dashboard against the +// shared restic repository. The happy path backs up a running busybox server, +// writes a marker file, restores the backup and asserts the marker is gone. +// The error path deletes the target server while the Restore dialog is open and +// asserts the operator marks the Restore Failed with "target server ... not found". +// +// PREMISE NOTE (destination): the GameServer does not reference the restic Secret. +// The Backup references it through spec.repoRef, and the Backups tab only enables +// "Back up now" when exactly one labelled backup destination exists. The fixture +// Secret e2e-restic-creds is not labelled, so this spec seeds its own destination +// pointing at the same restic repository. +// +// PREMISE NOTE (error path): restoring onto a deleted server shows no toast. POST +// /restores succeeds and the operator reports the failure on the Restore status, +// visible on /backups?tab=restores. +// +// The shared restic repository and its warm-up Job are created by +// TestE2E_ResticRepoReady, which the e2e-web-live CI job runs before Playwright. +// When the warm-up Job has not succeeded, the whole spec is skipped. + +const LIVE = process.env.GAMEPLANE_E2E_TARGET === "live"; + +// Reads the warm-up Job status from the cluster. Returns "" when kubectl or the +// Job is unavailable, which makes the spec skip rather than fail. +function resticWarmupSucceeded(): string { + const ctxName = `kind-${process.env.GAMEPLANE_E2E_CLUSTER ?? "gameplane-e2e"}`; + try { + return execFileSync( + "kubectl", + [ + "--context", + ctxName, + "get", + "job", + "e2e-restic-warmup", + "-n", + "gameplane-games", + "-o", + "jsonpath={.status.succeeded}", + ], + { encoding: "utf8" }, + ).trim(); + } catch { + return ""; + } +} + +const warm = LIVE ? resticWarmupSucceeded() : ""; + +test.describe.configure({ mode: "serial" }); + +test.describe("live: backup and restore a running server", () => { + test.skip(!LIVE, "live-only — needs a real pod, agent sidecar and restic repository"); + test.skip(LIVE && warm !== "1", "restic repo not warmed (run TestE2E_ResticRepoReady first)"); + + const stamp = Date.now().toString(36); + const tmplName = `e2e-pw-br-tmpl-${stamp}`; + const serverName = `e2e-pw-br-${stamp}`; + const destName = `e2e-pw-restic-${stamp}`; + const markerName = `e2e-marker-${stamp}.txt`; + + let cleanups: Array<(request: APIRequestContext) => Promise> = []; + + test.beforeAll(async ({ request }) => { + const tmpl: Seeded = await seedTemplate(request, tmplName); + const server: Seeded = await seedServer(request, { name: serverName, template: tmplName }); + const dest: Seeded = await seedBackupDestination(request, destName); + cleanups = [server.cleanup, dest.cleanup, tmpl.cleanup]; + + // The Backups tab only enables "Back up now" for exactly one destination, so a + // second destination already in the cluster would make the spec silently wrong. + const res = await request.get("/backup-destinations", { headers: { Accept: "application/json" } }); + if (!res.ok()) { + throw new Error(`list backup destinations failed: ${res.status()}`); + } + const body = (await res.json()) as { items?: unknown[] }; + const count = body.items?.length ?? 0; + if (count !== 1) { + throw new Error(`expected exactly 1 backup destination, found ${count}`); + } + }); + + // afterAll gets its own live `request`; beforeAll's is disposed by now. + test.afterAll(async ({ request }) => { + await cleanupServerBackups(request, serverName); + for (const c of cleanups) await c(request); + }); + + test.beforeEach(async ({ page }) => { + await page.goto("/"); + await page.waitForLoadState("domcontentloaded"); + await loginIfNeeded(page); + }); + + // Waits for the server header to show a running phase. Returns false when the + // pod does not reach Running within budget on this runner. + async function waitForRunning(page: Page): Promise { + try { + await expect(page.locator("header").getByText(/running/i).first()).toBeVisible({ + timeout: 150_000, + }); + return true; + } catch { + return false; + } + } + + // The Backups tab on the server page holds the backup and restore controls. + function backupsGrid(page: Page): Locator { + return page.getByRole("grid", { name: "Backups table" }); + } + + test("backs up a running server, then restores it and the marker file is gone", async ({ + page, + }) => { + test.setTimeout(480_000); + + const detail = new ServerDetailPage(page); + await detail.goto(serverName); + await expect(page.getByRole("heading", { name: serverName })).toBeVisible({ timeout: 30_000 }); + + const running = await waitForRunning(page); + test.skip(!running, "pod did not reach Running within budget on this runner"); + + // Back up now: the server must be running and the lone destination must be + // selected, which makes the button enabled. + await detail.clickTab("Backups"); + const backUp = page.getByRole("button", { name: "Back up now", exact: true }); + await expect(backUp).toBeEnabled({ timeout: 20_000 }); + await backUp.click(); + + // The backup phase text is "Succeeded". The list polls every 5 s. + await expect(backupsGrid(page).getByRole("row").filter({ hasText: "Succeeded" }).first()).toBeVisible({ + timeout: 240_000, + }); + + // Positive control: create a marker file after the backup was taken. + await detail.clickTab("Files"); + await page.getByRole("button", { name: "New file", exact: true }).click(); + const newFileDialog = page.getByRole("dialog"); + await newFileDialog.getByPlaceholder("config.yaml").fill(markerName); + await newFileDialog.getByRole("button", { name: "Create", exact: true }).click(); + await expect(page.locator("aside li").filter({ hasText: markerName })).toBeVisible({ + timeout: 30_000, + }); + + // Restore the Succeeded backup onto this server. The operator suspends the + // server, runs the restic restore Job and resumes it. + await detail.clickTab("Backups"); + const succeededRow = backupsGrid(page).getByRole("row").filter({ hasText: "Succeeded" }).first(); + await succeededRow.getByRole("button", { name: "Restore", exact: true }).click(); + + const restoreDialog = page.getByRole("dialog"); + await expect(restoreDialog.getByRole("heading", { name: "Restore backup" })).toBeVisible(); + await restoreDialog.getByRole("button", { name: "Restore", exact: true }).click(); + await expect(restoreDialog).toBeHidden({ timeout: 30_000 }); + + const recentRestores = page + .locator("section") + .filter({ has: page.getByRole("heading", { name: "Recent restores" }) }); + await expect(recentRestores.getByText("Succeeded", { exact: true })).toBeVisible({ + timeout: 240_000, + }); + + // The restore suspends and resumes the pod, so reload and wait for it to run again. + await page.reload(); + const runningAgain = await waitForRunning(page); + test.skip(!runningAgain, "pod did not return to Running after restore within budget"); + + // The marker file was created after the backup, so the restore must remove it. + // The file list can error while the agent restarts, so retry the refresh. + await detail.clickTab("Files"); + await expect(async () => { + await page.getByRole("button", { name: "Refresh" }).click(); + await expect(page.locator("aside li").filter({ hasText: markerName })).toHaveCount(0, { + timeout: 5_000, + }); + }).toPass({ timeout: 60_000 }); + }); + + test("restoring onto a server deleted mid-dialog is marked Failed on the Restores tab", async ({ + page, + request, + }) => { + test.setTimeout(360_000); + + // This test needs the Succeeded backup from the happy path. Skip when that + // backup does not exist, which means the happy path was skipped. + const backupsRes = await request.get("/backups", { headers: { Accept: "application/json" } }); + const backups = backupsRes.ok() + ? ((await backupsRes.json()) as { + items?: Array<{ spec: { serverRef: { name: string } }; status?: { phase?: string } }>; + }) + : { items: [] }; + const hasSucceeded = (backups.items ?? []).some( + (b) => b.spec.serverRef.name === serverName && b.status?.phase === "Succeeded", + ); + test.skip(!hasSucceeded, "no Succeeded backup for this server (happy path did not run)"); + + // Open the Restore dialog from the global Backups page and pick the target + // server explicitly, since the dialog has no default target outside a server tab. + await page.goto("/backups"); + const backupsTable = page.getByRole("grid", { name: "Backups" }); + const row = backupsTable.getByRole("row").filter({ hasText: serverName }).filter({ hasText: "Succeeded" }).first(); + await expect(row).toBeVisible({ timeout: 30_000 }); + await row.getByRole("button", { name: "Restore", exact: true }).click(); + + const dialog = page.getByRole("dialog"); + await expect(dialog.getByRole("heading", { name: "Restore backup" })).toBeVisible(); + await dialog.locator("#target-server").click(); + await page + .getByRole("listbox", { name: "Select target server" }) + .getByRole("option") + .filter({ hasText: serverName }) + .click(); + + // Delete the target while the dialog is still open. Restoring onto a server + // that is being deleted reports a different message, so wait until the server + // is fully gone. + const del = await apiSend(request, "DELETE", `/servers/${serverName}`); + expect(del.ok()).toBeTruthy(); + await expect + .poll( + async () => + (await request.get(`/servers/${serverName}`, { headers: { Accept: "application/json" } })).status(), + { timeout: 120_000 }, + ) + .toBe(404); + + // POST /restores succeeds even though the target is gone. The operator fails it later. + await dialog.getByRole("button", { name: "Restore", exact: true }).click(); + await expect(dialog).toBeHidden({ timeout: 30_000 }); + + await page.goto("/backups?tab=restores"); + const restoresTable = page.getByRole("grid", { name: "Restores" }); + const failedRow = restoresTable.getByRole("row").filter({ hasText: serverName }).first(); + await expect(failedRow.getByText("Failed", { exact: true })).toBeVisible({ timeout: 90_000 }); + await expect(failedRow.getByText(/target server .* not found/)).toBeVisible({ timeout: 10_000 }); + }); +}); diff --git a/web/e2e/specs/live/mod-registries.spec.ts b/web/e2e/specs/live/mod-registries.spec.ts new file mode 100644 index 000000000..aeb7ced30 --- /dev/null +++ b/web/e2e/specs/live/mod-registries.spec.ts @@ -0,0 +1,119 @@ +import { test, expect } from "@playwright/test"; +import { execFileSync } from "node:child_process"; +import { loginIfNeeded, apiSend } from "./_seed"; + +// Spec 013 US2 (T043): live coverage of the Admin > Mod registries section. +// +// The section lists three fixed keyed providers (AdminSettings.tsx +// keyedRegistryProviders): CurseForge, Steam Workshop and Nexus Mods. There is +// no free-form add, so every test drives the "Nexus Mods" row. Its API key is +// written to the labelled Secret gameplane-modreg-nexus in the control +// namespace (registry_secret.go, registry.DefaultKeySecretName). +// +// The tests share one registry row, so they run serially. + +test.describe.configure({ mode: "serial" }); + +const NEXUS_SECRET = "gameplane-modreg-nexus"; +const CONTROL_NS = "gameplane-system"; +const CTX = `kind-${process.env.GAMEPLANE_E2E_CLUSTER ?? "gameplane-e2e"}`; + +test.describe("live: mod registries", () => { + test.skip( + process.env.GAMEPLANE_E2E_TARGET !== "live", + "the mock spec (web/e2e/specs/adminSettings.spec.ts) covers the registries section against a scripted fetch shim", + ); + + test.beforeEach(async ({ page }) => { + await page.goto("/"); + await page.waitForLoadState("domcontentloaded"); + await loginIfNeeded(page); + await page.goto("/admin?section=modRegistries"); + // The settings nav also carries a "Mod registries" label, so wait on the section's own row. + await expect(page.getByRole("listitem").filter({ hasText: "Nexus Mods" })).toBeVisible({ + timeout: 15_000, + }); + }); + + test.afterEach(async ({ request }) => { + // Best-effort teardown: clear the config, drop the managed key Secret, then + // remove any unmanaged Secret the error-path test seeded. Runs last so the + // kubectl delete also removes a leftover unmanaged Secret. + await apiSend(request, "PUT", "/admin/config/modRegistries", { registries: [] }).catch(() => undefined); + await apiSend(request, "DELETE", "/admin/registries/nexus/secret").catch(() => undefined); + try { + execFileSync("kubectl", [ + "--context", CTX, + "delete", "secret", NEXUS_SECRET, + "-n", CONTROL_NS, + "--ignore-not-found", + ]); + } catch { + // best effort: kubectl may be unavailable in this runner + } + }); + + test("set, persist, then remove the Nexus Mods API key", async ({ page }) => { + const stamp = Date.now().toString(36); + const row = page.getByRole("listitem").filter({ hasText: "Nexus Mods" }); + + await expect(row.getByText("Not configured", { exact: true })).toBeVisible(); + await row.getByRole("button", { name: "Set API key" }).click(); + await page.getByLabel(/API key/i).fill(`e2e-nexus-key-${stamp}`); + await page.getByRole("button", { name: /^Save$/ }).click(); + await expect(row.getByText("Configured", { exact: true })).toBeVisible(); + + await page.getByRole("button", { name: /^Save changes$/ }).click(); + await expect(page.getByText("Saved", { exact: true })).toBeVisible({ timeout: 15_000 }); + + // Persistence: a reload shows the saved state from the API. + await page.reload(); + await expect(row.getByText("Configured", { exact: true })).toBeVisible(); + await expect(row.getByRole("button", { name: "Replace" })).toBeVisible(); + await expect(row.getByRole("button", { name: "Remove" })).toBeVisible(); + + // Remove the key and save; the reload shows it gone again. + await row.getByRole("button", { name: "Remove" }).click(); + await page.getByRole("button", { name: /^Save changes$/ }).click(); + await expect(page.getByText("Saved", { exact: true })).toBeVisible({ timeout: 15_000 }); + + await page.reload(); + await expect(row.getByText("Not configured", { exact: true })).toBeVisible(); + await expect(row.getByRole("button", { name: "Set API key" })).toBeVisible(); + }); + + test("an unmanaged Secret with the same name fails the save inline", async ({ page }) => { + // Seed a Secret with the name the API will write, but without the + // Gameplane managed label. The API refuses to overwrite it (409). + execFileSync("kubectl", [ + "--context", CTX, + "delete", "secret", NEXUS_SECRET, + "-n", CONTROL_NS, + "--ignore-not-found", + ]); + execFileSync("kubectl", [ + "--context", CTX, + "create", "secret", "generic", NEXUS_SECRET, + "-n", CONTROL_NS, + "--from-literal=apiKey=preexisting", + ]); + + const stamp = Date.now().toString(36); + const row = page.getByRole("listitem").filter({ hasText: "Nexus Mods" }); + + await row.getByRole("button", { name: "Set API key" }).click(); + await page.getByLabel(/API key/i).fill(`e2e-nexus-key-${stamp}`); + await page.getByRole("button", { name: /^Save$/ }).click(); + await page.getByRole("button", { name: /^Save changes$/ }).click(); + + await expect( + page.getByText(/a secret with that name exists but is not managed by Gameplane/i), + ).toBeVisible(); + + // The config row is written only after the Secret write succeeds, so a + // reload must still show the registry as not configured. + await page.reload(); + await expect(row.getByText("Not configured", { exact: true })).toBeVisible(); + await expect(row.getByRole("button", { name: "Set API key" })).toBeVisible(); + }); +}); diff --git a/web/e2e/specs/live/role-bindings.spec.ts b/web/e2e/specs/live/role-bindings.spec.ts new file mode 100644 index 000000000..8a99478cd --- /dev/null +++ b/web/e2e/specs/live/role-bindings.spec.ts @@ -0,0 +1,134 @@ +import { test, expect } from "@playwright/test"; +import type { Page } from "@playwright/test"; +import { loginIfNeeded, seedUser, deleteUser, apiSend } from "./_seed"; +import { LoginPage } from "../../pages/LoginPage"; + +// Live role-bindings coverage: custom roles and namespace grants on the Users & +// RBAC page (web/src/routes/Users.tsx) driven against the REAL API. Covers +// creating a namespaced custom role in the Roles tab, binding it to a user in +// gameplane-games, proving the binding persists across reload and gates the +// user's access (the signed-in user sees "Access denied" on /users and the API +// answers 403), then revoking the binding through the UI. +// +// Login budget: loginIfNeeded (the shared admin session from storageState) +// covers the page under test; the only extra login is the fresh context that +// signs in as the bound user. + +test.describe("live: role bindings", () => { + test.skip( + process.env.GAMEPLANE_E2E_TARGET !== "live", + "live-only: the mock suite covers the Users page against a scripted fetch shim", + ); + + const stamp = Date.now().toString(36); + // Names are derived per test so retries never reuse leaked objects. + let uname = ""; + let roleName = ""; + // Id of the user seeded by the test; deleted in afterEach (also removes its bindings). + let userId: number | undefined; + + test.beforeEach(async ({ page }) => { + const suffix = Math.random().toString(36).slice(2, 6); + uname = `e2e-rb-${stamp}-${suffix}`; + roleName = `e2e-rb-role-${stamp}-${suffix}`; + userId = undefined; + await page.goto("/"); + await page.waitForLoadState("domcontentloaded"); + await loginIfNeeded(page); + await page.goto("/users"); + await expect(page.getByRole("heading", { name: "Users & RBAC" })).toBeVisible({ timeout: 15_000 }); + }); + + test.afterEach(async ({ request }) => { + // Order matters: the user goes first (its bindings are removed with it), + // because a role that is still assigned to a user cannot be deleted (409). + if (userId !== undefined) { + await deleteUser(request, userId); + } + await apiSend(request, "DELETE", `/roles/${encodeURIComponent(roleName)}`).catch(() => undefined); + }); + + // Opens the Edit dialog for the given username via its row actions menu. + async function openEditDialog(page: Page, username: string): Promise { + await page.getByRole("button", { name: `Actions for ${username}` }).click(); + await page.getByRole("menuitem", { name: "Edit user" }).click(); + await expect(page.getByRole("heading", { name: "Edit user" })).toBeVisible(); + } + + test("bind a namespaced custom role, prove it gates access, then revoke it", async ({ page, browser, request }) => { + // Seed the target user with the viewer primary role: viewer is cluster-wide + // read-only and has no users:manage, so /users is forbidden for it. + const u = await seedUser(request, { username: uname, role: "viewer" }); + userId = u.id; + + // 1. Create a custom role with one namespaced permission in the Roles tab. + await page.getByRole("tab", { name: /^Roles/ }).click(); + await page.getByRole("button", { name: "New role", exact: true }).click(); + await page.locator("#role-name").fill(roleName); + await page.getByRole("checkbox", { name: /servers:read/ }).check(); + await page.getByRole("button", { name: "Create role", exact: true }).click(); + + await expect(page.getByText(roleName, { exact: true })).toBeVisible({ timeout: 15_000 }); + const roleCard = page + .getByText(roleName, { exact: true }) + .locator("xpath=ancestor::div[contains(concat(' ', normalize-space(@class), ' '), ' flex-col ')][1]"); + await expect(roleCard.getByText("1 permission", { exact: true })).toBeVisible(); + + // 2. Grant the role to the user in gameplane-games from the Edit dialog. + await page.getByRole("tab", { name: /^Users/ }).click(); + const grid = page.getByRole("grid", { name: "Users list" }); + await expect(grid.getByRole("row").filter({ hasText: uname })).toBeVisible({ timeout: 15_000 }); + await openEditDialog(page, uname); + + await page.getByRole("button", { name: /Grant role/ }).click(); + await page.getByRole("option", { name: roleName, exact: true }).click(); + await page.getByPlaceholder("namespace").fill("gameplane-games"); + await page.getByRole("button", { name: "Add", exact: true }).click(); + + const removeButton = () => + page.getByRole("button", { name: `Remove ${roleName} in gameplane-games on local` }); + await expect(removeButton()).toBeVisible({ timeout: 15_000 }); + await page.getByRole("button", { name: "Cancel", exact: true }).click(); + + // 3. The binding survives a reload. + await page.reload(); + await openEditDialog(page, uname); + await expect(removeButton()).toBeVisible({ timeout: 15_000 }); + await page.getByRole("button", { name: "Cancel", exact: true }).click(); + + // 4. Signed in as the bound user, /users is forbidden client-side + // (RequirePermission renders "Access denied" without users:manage), and + // the API refuses the same request with 403 from the same session. + const ctx = await browser.newContext({ + baseURL: "http://localhost:5173", + storageState: { cookies: [], origins: [] }, + }); + try { + const p = await ctx.newPage(); + await p.goto("/login"); + await p.waitForLoadState("domcontentloaded"); + await new LoginPage(p).login(uname, u.password); + await p.waitForURL((x) => !x.pathname.startsWith("/login"), { timeout: 15_000 }); + + await p.goto("/users"); + await expect(p.getByText("Access denied", { exact: true })).toBeVisible({ timeout: 15_000 }); + await expect(p.getByText(/doesn.t permit access to this page/)).toBeVisible(); + + const denied = await ctx.request.get("/users", { headers: { Accept: "application/json" } }); + expect(denied.status()).toBe(403); + } finally { + await ctx.close(); + } + + // 5. Revoke the binding from the admin session; it disappears and stays gone after reload. + await openEditDialog(page, uname); + await removeButton().click(); + await expect(removeButton()).toHaveCount(0, { timeout: 15_000 }); + await page.getByRole("button", { name: "Cancel", exact: true }).click(); + + await page.reload(); + await openEditDialog(page, uname); + await expect(removeButton()).toHaveCount(0); + await page.getByRole("button", { name: "Cancel", exact: true }).click(); + }); +}); diff --git a/web/e2e/specs/live/users-and-roles.spec.ts b/web/e2e/specs/live/users-and-roles.spec.ts new file mode 100644 index 000000000..dcb8407b9 --- /dev/null +++ b/web/e2e/specs/live/users-and-roles.spec.ts @@ -0,0 +1,137 @@ +import { test, expect } from "@playwright/test"; +import type { Page } from "@playwright/test"; +import { loginIfNeeded, seedUser, deleteUser } from "./_seed"; +import { LoginPage } from "../../pages/LoginPage"; + +// Live users-and-roles coverage: the Users & RBAC page (web/src/routes/Users.tsx) +// driven against the REAL API. Covers invite -> role change -> persisted +// reload -> sign-in as the new user with the operator permission set, plus the +// grant-binding error path for a user that no longer exists. +// +// Login budget: loginIfNeeded (the shared admin session from storageState) +// covers the page under test; the only extra login is the fresh context that +// signs in as the newly created user (see the first test). + +test.describe("live: users and roles", () => { + test.skip( + process.env.GAMEPLANE_E2E_TARGET !== "live", + "live-only: the mock suite covers the Users page against a scripted fetch shim", + ); + + const stamp = Date.now().toString(36); + const pw = `E2e-Users-Pw-${stamp}`; + let uname = ""; + // Ids of users created by a test; removed in afterEach (best-effort). + let cleanupUsernames: string[] = []; + + test.beforeEach(async ({ page }) => { + uname = `e2e-users-${stamp}-${Math.random().toString(36).slice(2, 6)}`; + cleanupUsernames = [uname]; + await page.goto("/"); + await page.waitForLoadState("domcontentloaded"); + await loginIfNeeded(page); + await page.goto("/users"); + await expect(page.getByRole("heading", { name: "Users & RBAC" })).toBeVisible({ timeout: 15_000 }); + }); + + test.afterEach(async ({ request }) => { + // Best-effort teardown: resolve each leaked username to an id and delete it. + // Deleting an already-removed user is a no-op for deleteUser. + const res = await request + .get("/users", { headers: { Accept: "application/json" } }) + .catch(() => null); + if (!res || !res.ok()) return; + const users = (await res.json().catch(() => [])) as Array<{ id: number; username: string }>; + for (const u of users) { + if (cleanupUsernames.includes(u.username)) { + await deleteUser(request, u.id); + } + } + }); + + // Opens the Edit dialog for the given username via its row actions menu. + async function openEditDialog(page: Page, username: string): Promise { + await page.getByRole("button", { name: `Actions for ${username}` }).click(); + await page.getByRole("menuitem", { name: "Edit user" }).click(); + await expect(page.getByRole("heading", { name: "Edit user" })).toBeVisible(); + } + + test("invite, promote to operator, persist, and sign in with the new role", async ({ page, browser }) => { + // 1. Invite a user with the default role (viewer). + await page.getByRole("button", { name: "Invite user", exact: true }).click(); + await page.locator("#invite-username").fill(uname); + await page.locator("#invite-password").fill(pw); + await page.getByRole("button", { name: "Create user", exact: true }).click(); + + const grid = page.getByRole("grid", { name: "Users list" }); + const row = grid.getByRole("row").filter({ hasText: uname }); + await expect(row).toBeVisible({ timeout: 15_000 }); + await expect(row.getByText("viewer", { exact: true })).toBeVisible(); + + // 2. Promote to operator through the Edit dialog's primary role. + await openEditDialog(page, uname); + await page.locator("#edit-role").click(); + await page.getByRole("option", { name: "operator", exact: true }).click(); + await page.getByRole("button", { name: "Save changes", exact: true }).click(); + await expect(row.getByText("operator", { exact: true })).toBeVisible({ timeout: 15_000 }); + // Save only invalidates the list; the dialog stays open, so close it. + await page.getByRole("button", { name: "Cancel", exact: true }).click(); + + // 3. The change survives a reload. + await page.reload(); + await expect(grid.getByRole("row").filter({ hasText: uname }).getByText("operator", { exact: true })).toBeVisible({ + timeout: 15_000, + }); + + // 4. Sign in as the new user in a clean context. An operator has no + // users:manage, audit:read, config:manage, or "*", so those links must be + // absent from the primary navigation while the workload links remain. + const ctx = await browser.newContext({ + baseURL: "http://localhost:5173", + storageState: { cookies: [], origins: [] }, + }); + try { + const p = await ctx.newPage(); + await p.goto("/login"); + await p.waitForLoadState("domcontentloaded"); + await new LoginPage(p).login(uname, pw); + await p.waitForURL((u) => !u.pathname.startsWith("/login"), { timeout: 15_000 }); + + const nav = p.getByRole("navigation", { name: "Primary" }); + await expect(nav).toBeVisible({ timeout: 15_000 }); + for (const l of ["Servers", "Modules", "Backups"]) { + await expect(nav.getByRole("link", { name: l, exact: true })).toBeVisible(); + } + for (const l of ["Users & RBAC", "Audit log", "System logs", "Settings"]) { + await expect(nav.getByRole("link", { name: l, exact: true })).toHaveCount(0); + } + } finally { + await ctx.close(); + } + }); + + test("granting a namespace role to a deleted user surfaces the API 404 inline", async ({ page, request }) => { + // Error path. A primary-role PATCH on a deleted user returns a 500 rather + // than a clean 404, so this test uses the grants path, where addBinding + // checks user existence and returns 404 "user not found". + const ghostName = `e2e-ghost-${stamp}-${Math.random().toString(36).slice(2, 6)}`; + const ghost = await seedUser(request, { username: ghostName, password: pw }); + cleanupUsernames.push(ghostName); + + // The Edit dialog is opened while the user still exists, so the list + // already contains the row when the dialog loads. + await page.reload(); + await expect(page.getByRole("grid", { name: "Users list" }).getByRole("row").filter({ hasText: ghostName })).toBeVisible({ + timeout: 15_000, + }); + await openEditDialog(page, ghostName); + + // Delete the user behind the open dialog, then try to grant a binding. + await deleteUser(request, ghost.id); + + await page.getByPlaceholder("namespace").fill("gameplane-games"); + await page.getByRole("button", { name: "Add", exact: true }).click(); + + await expect(page.getByRole("alert").filter({ hasText: "user not found" })).toBeVisible({ timeout: 15_000 }); + }); +}); From ab742893f1a8d71e1404e2763192454285afa682 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 02:00:45 +0000 Subject: [PATCH 3/8] test(web): fix HeroUI control handling in live specs The first live run showed three selector problems: the sink switch and the permission checkbox inputs sit under HeroUI's decorative spans, so pointer clicks were intercepted (toggle with Space / check with force, as theme-customization and servers-core do), and a successful user save already closes the Edit dialog, so there is no Cancel to click. Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: Claude --- web/e2e/specs/live/admin-notifications.spec.ts | 4 +++- web/e2e/specs/live/role-bindings.spec.ts | 3 ++- web/e2e/specs/live/users-and-roles.spec.ts | 4 ++-- 3 files changed, 7 insertions(+), 4 deletions(-) diff --git a/web/e2e/specs/live/admin-notifications.spec.ts b/web/e2e/specs/live/admin-notifications.spec.ts index f7ea93a54..17fcfd22d 100644 --- a/web/e2e/specs/live/admin-notifications.spec.ts +++ b/web/e2e/specs/live/admin-notifications.spec.ts @@ -76,7 +76,9 @@ test.describe("live: admin notifications", () => { await expect(row).toContainText(`webhook · Secret: gameplane-notify-${sink}`); // Edit substitute: disable the sink (see PREMISE NOTE above), save, reload. - await row.getByRole("switch", { name: `Disable sink ${sink}` }).click(); + // HeroUI's Switch hides the role=switch input under decorative spans, so a pointer + // click is intercepted; toggle it the way a keyboard user does (as theme-customization does). + await row.getByRole("switch", { name: `Disable sink ${sink}` }).press("Space"); await page.getByRole("button", { name: "Save changes" }).click(); await expect(page.getByText("Saved", { exact: true })).toBeVisible({ timeout: 15_000 }); diff --git a/web/e2e/specs/live/role-bindings.spec.ts b/web/e2e/specs/live/role-bindings.spec.ts index 8a99478cd..0d9f3c4fa 100644 --- a/web/e2e/specs/live/role-bindings.spec.ts +++ b/web/e2e/specs/live/role-bindings.spec.ts @@ -65,7 +65,8 @@ test.describe("live: role bindings", () => { await page.getByRole("tab", { name: /^Roles/ }).click(); await page.getByRole("button", { name: "New role", exact: true }).click(); await page.locator("#role-name").fill(roleName); - await page.getByRole("checkbox", { name: /servers:read/ }).check(); + // HeroUI hides the checkbox input under its own spans; force it, as servers-core does. + await page.getByRole("checkbox", { name: /servers:read/ }).check({ force: true }); await page.getByRole("button", { name: "Create role", exact: true }).click(); await expect(page.getByText(roleName, { exact: true })).toBeVisible({ timeout: 15_000 }); diff --git a/web/e2e/specs/live/users-and-roles.spec.ts b/web/e2e/specs/live/users-and-roles.spec.ts index dcb8407b9..2485a9504 100644 --- a/web/e2e/specs/live/users-and-roles.spec.ts +++ b/web/e2e/specs/live/users-and-roles.spec.ts @@ -74,8 +74,8 @@ test.describe("live: users and roles", () => { await page.getByRole("option", { name: "operator", exact: true }).click(); await page.getByRole("button", { name: "Save changes", exact: true }).click(); await expect(row.getByText("operator", { exact: true })).toBeVisible({ timeout: 15_000 }); - // Save only invalidates the list; the dialog stays open, so close it. - await page.getByRole("button", { name: "Cancel", exact: true }).click(); + // A successful save closes the dialog (Users.tsx save.onSuccess -> setEditing(null)). + await expect(page.getByRole("dialog")).toBeHidden({ timeout: 15_000 }); // 3. The change survives a reload. await page.reload(); From 367e53c9f6c717b511c6a7b24c338aea73f4b5be Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 02:25:33 +0000 Subject: [PATCH 4/8] test(web): fix stale users list and restore row match in live specs role-bindings seeds its user through the API after the Users list has loaded, so reload before looking for it. backup-restore has two restore rows for the same server (the happy path's Succeeded one first), so match the Failed row instead of the first row. Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: Claude --- web/e2e/specs/live/backup-restore.spec.ts | 8 ++++++-- web/e2e/specs/live/role-bindings.spec.ts | 4 +++- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/web/e2e/specs/live/backup-restore.spec.ts b/web/e2e/specs/live/backup-restore.spec.ts index 9c7a944ee..85d2aa347 100644 --- a/web/e2e/specs/live/backup-restore.spec.ts +++ b/web/e2e/specs/live/backup-restore.spec.ts @@ -247,8 +247,12 @@ test.describe("live: backup and restore a running server", () => { await page.goto("/backups?tab=restores"); const restoresTable = page.getByRole("grid", { name: "Restores" }); - const failedRow = restoresTable.getByRole("row").filter({ hasText: serverName }).first(); - await expect(failedRow.getByText("Failed", { exact: true })).toBeVisible({ timeout: 90_000 }); + // The happy path's Succeeded restore targets the same server, so match the Failed row itself. + const failedRow = restoresTable + .getByRole("row") + .filter({ hasText: serverName }) + .filter({ has: page.getByText("Failed", { exact: true }) }); + await expect(failedRow).toBeVisible({ timeout: 90_000 }); await expect(failedRow.getByText(/target server .* not found/)).toBeVisible({ timeout: 10_000 }); }); }); diff --git a/web/e2e/specs/live/role-bindings.spec.ts b/web/e2e/specs/live/role-bindings.spec.ts index 0d9f3c4fa..4c1ee1d37 100644 --- a/web/e2e/specs/live/role-bindings.spec.ts +++ b/web/e2e/specs/live/role-bindings.spec.ts @@ -75,7 +75,9 @@ test.describe("live: role bindings", () => { .locator("xpath=ancestor::div[contains(concat(' ', normalize-space(@class), ' '), ' flex-col ')][1]"); await expect(roleCard.getByText("1 permission", { exact: true })).toBeVisible(); - // 2. Grant the role to the user in gameplane-games from the Edit dialog. + // 2. Grant the role to the user in gameplane-games from the Edit dialog. The user + // was seeded through the API after the list loaded, so reload to refetch it. + await page.reload(); await page.getByRole("tab", { name: /^Users/ }).click(); const grid = page.getByRole("grid", { name: "Users list" }); await expect(grid.getByRole("row").filter({ hasText: uname })).toBeVisible({ timeout: 15_000 }); From a3c220d7fcded2aa3c837c49b3711cb6da3b5103 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 17:09:34 +0000 Subject: [PATCH 5/8] docs(specs): record US2 live specs in 013 tasks and coverage gaps Tick T037-T039 and T041-T043, rewording T039, T041, T042 and T043 to the current behaviour valgul approved on the cards (2026-10-14). Close G-07, G-09, G-10 and G-11 with the green live run; G-08 stays in progress until the deleted-user role 404 fix lands and T040 asserts it. Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: Claude --- specs/013-expand-test-coverage/coverage-gaps.md | 14 +++++++------- specs/013-expand-test-coverage/tasks.md | 17 +++++++++++------ 2 files changed, 18 insertions(+), 13 deletions(-) diff --git a/specs/013-expand-test-coverage/coverage-gaps.md b/specs/013-expand-test-coverage/coverage-gaps.md index 4984fc2c3..1bdd74f99 100644 --- a/specs/013-expand-test-coverage/coverage-gaps.md +++ b/specs/013-expand-test-coverage/coverage-gaps.md @@ -1,7 +1,7 @@ # Coverage Gap Record -**Summary**: 10/24 closed · 1 closed unproven · 1 in progress · 11 open · 1 unit-only by design -**Last updated**: 2026-10-13 +**Summary**: 14/24 closed · 1 closed unproven · 2 in progress · 6 open · 1 unit-only by design +**Last updated**: 2026-10-14 | ID | Category | Gap | Story | FR | Priority | Status | Evidence | Notes | |---|---|---|---|---|---|---|---|---| @@ -11,11 +11,11 @@ | G-04 | static | dependency-review gate | US1 | FR-001 | P1 | closed-unproven | `dependency review` job, added in https://github.com/GameplanePanel/Gameplane/pull/676 | OD-4: proven by configuration review only (`fail-on-severity: high`, PR-only); no throwaway PR, no fixture branch, no local act run. | | G-05 | static | codegen / tidy drift gate | US1 | FR-001 | P1 | closed | `codegen drift` https://github.com/GameplanePanel/Gameplane/actions/runs/38189397259 | First run on #672 found no drift; the proof shows the job fails on injected drift. | | G-06 | static | submodule pointer freshness gate | US1 | FR-001 | P1 | closed | `submodule freshness` https://github.com/GameplanePanel/Gameplane/actions/runs/38189409720 | First run on #675 green; the proof covers a stale and a fresh pointer. | -| G-07 | e2e-dashboard | live: notification webhook config | US2 | FR-002 | P1 | open | | | -| G-08 | e2e-dashboard | live: user create + role assignment | US2 | FR-002 | P1 | open | | | -| G-09 | e2e-dashboard | live: custom role + binding | US2 | FR-002 | P1 | open | | | -| G-10 | e2e-dashboard | live: backup restore | US2 | FR-002 | P1 | open | | | -| G-11 | e2e-dashboard | live: mod registry config | US2 | FR-002 | P1 | open | | | +| G-07 | e2e-dashboard | live: notification webhook config | US2 | FR-002 | P1 | closed | `e2e web live / arm64 (kind)` https://github.com/GameplanePanel/Gameplane/actions/runs/38500956044 | admin-notifications.spec.ts on #693: sink create, enable toggle and delete persist across reload; the SSRF URL is rejected at delivery (Send test). T039 reworded to current behaviour, valgul card 2026-10-14. | +| G-08 | e2e-dashboard | live: user create + role assignment | US2 | FR-002 | P1 | in-progress | `e2e web live / arm64 (kind)` https://github.com/GameplanePanel/Gameplane/actions/runs/38500956044 | users-and-roles.spec.ts on #693: create, role, reload and sign-in as the new user pass. The error path waits on the fix PR that returns 404 (not 500) for a role change on a deleted user (valgul card 2026-10-14, T040). | +| G-09 | e2e-dashboard | live: custom role + binding | US2 | FR-002 | P1 | closed | `e2e web live / arm64 (kind)` https://github.com/GameplanePanel/Gameplane/actions/runs/38500956044 | role-bindings.spec.ts on #693: custom role bound in gameplane-games, persists across reload, gates /users (Access denied plus a direct GET /users 403), then revoked. T041 reworded, valgul card 2026-10-14. | +| G-10 | e2e-dashboard | live: backup restore | US2 | FR-002 | P1 | closed | `e2e web live / arm64 (kind)` https://github.com/GameplanePanel/Gameplane/actions/runs/38500956044 | backup-restore.spec.ts on #693: backup Succeeded, marker removed by restore; restoring onto a deleted server shows a Failed row on the Restores tab (no toast). T042 reworded, valgul card 2026-10-14. | +| G-11 | e2e-dashboard | live: mod registry config | US2 | FR-002 | P1 | closed | `e2e web live / arm64 (kind)` https://github.com/GameplanePanel/Gameplane/actions/runs/38500956044 | mod-registries.spec.ts on #693: Nexus Mods key set, persists, removed; an unmanaged Secret with the same name gives 409 inline. T043 reworded, valgul card 2026-10-14. Live job: 9 logins (limit 10), arm64 job about 8.6 min, Playwright 5.2 min. | | G-12 | e2e-dashboard | live: login, create/delete server, settings | US2 | FR-002 | P1 | closed | `web/e2e/specs/live/*` specs (`login-and-shell`, `createAndCleanupServer`, `servers-core`, `server-settings`) in job `e2e web live`: https://github.com/GameplanePanel/Gameplane/tree/master/web/e2e/specs/live | | | G-13 | e2e-optional | MCP server | US3 | FR-003 | P2 | open | | | | G-14 | e2e-optional | audit syslog bridge | US3 | FR-003 | P2 | open | | | diff --git a/specs/013-expand-test-coverage/tasks.md b/specs/013-expand-test-coverage/tasks.md index 28ffe867f..a4893074a 100644 --- a/specs/013-expand-test-coverage/tasks.md +++ b/specs/013-expand-test-coverage/tasks.md @@ -233,40 +233,45 @@ description: "Task list for 013-expand-test-coverage" **PR**: `test(web): live e2e for admin, users, roles, backups, registries`. -- [ ] T037 [US2] Add `TestE2E_ResticRepoReady` to `test/e2e/test_helpers_e2e_test.go`. It calls `t.Parallel()` and `ensureResticRepo(t)`, then asserts that the warm-up Job from `fixtures/restic-warmup-job.yaml` reports `succeeded >= 1`. Register it in `bucket_operator` in `test/e2e/buckets.sh`. Use the `e2e-test-authoring` skill. -- [ ] T038 [US2] In `.github/workflows/ci.yaml`, in the `e2e-web-live` steps anchor (`&e2e-web-live-steps`), add a step after "bootstrap admin" that runs `go test -tags=e2e -timeout 10m -v -run '^TestE2E_ResticRepoReady$' ./...` with the same env as the bootstrap step. Depends on T037. -- [ ] T039 [P] [US2] Write `web/e2e/specs/live/admin-notifications.spec.ts`: +- [X] T037 [US2] Add `TestE2E_ResticRepoReady` to `test/e2e/test_helpers_e2e_test.go`. It calls `t.Parallel()` and `ensureResticRepo(t)`, then asserts that the warm-up Job from `fixtures/restic-warmup-job.yaml` reports `succeeded >= 1`. Register it in `bucket_operator` in `test/e2e/buckets.sh`. Use the `e2e-test-authoring` skill. +- [X] T038 [US2] In `.github/workflows/ci.yaml`, in the `e2e-web-live` steps anchor (`&e2e-web-live-steps`), add a step after "bootstrap admin" that runs `go test -tags=e2e -timeout 10m -v -run '^TestE2E_ResticRepoReady$' ./...` with the same env as the bootstrap step. Depends on T037. +- [X] T039 [P] [US2] Write `web/e2e/specs/live/admin-notifications.spec.ts`: 1. Seed nothing, then use the UI to add a webhook notification target with a unique name and an in-cluster URL. 2. Reload and assert the target is listed. 3. Edit the URL, reload and assert the new URL. 4. Delete the target. Error path: saving `http://169.254.169.254/` renders the API's SSRF rejection inline. Clean up in `afterEach`. Selectors come from `web/src/routes/AdminSettings.tsx` and the existing `AdminSettings_notifications.test.tsx`. + *Implementation note (2026-10-14)*: Reworded to current behaviour (valgul card 2026-10-14, "Test current"): the UI cannot edit a sink URL, so the spec toggles the sink's enabled switch instead of editing the URL. Save accepts http://169.254.169.254/; the SSRF guard (netguard in notify.HTTPClient) rejects it at delivery, so the error path asserts the inline result after "Send test". - [ ] T040 [P] [US2] Write `web/e2e/specs/live/users-and-roles.spec.ts`: 1. Create a user with a unique name through the Users page, and assign it a built-in non-admin role. 2. Reload and assert the role. 3. In a fresh `browser.newContext()` with no `storageState`, sign in as that user, which is the spec's one extra login, and assert the sidebar shows only the role's nav entries. Error path: assigning a role to a user deleted through `_seed.ts` surfaces the 404 message. `afterEach` deletes the user. -- [ ] T041 [P] [US2] Write `web/e2e/specs/live/role-bindings.spec.ts`: + *Implementation note (2026-10-14)*: spec written on #693; open until the API returns 404 (today 500) for a role change on a deleted user. valgul chose "Fix in separate PR" on the card; after that PR merges, the error path switches from the namespace-grant 404 to the role-change 404 and this task is ticked. +- [X] T041 [P] [US2] Write `web/e2e/specs/live/role-bindings.spec.ts`: 1. Create a custom role with `RoleEditorModal` and bind it to a namespace-scoped user seeded through `_seed.ts`. 2. Reload and assert the binding. 3. Revoke the binding and reload. Error path: in a fresh context signed in as the restricted user (one login), opening `/users` shows the forbidden state, backed by a 403 response. Clean up the role and the user. -- [ ] T042 [P] [US2] Write `web/e2e/specs/live/backup-restore.spec.ts`: + *Implementation note (2026-10-14)*: Reworded (valgul card 2026-10-14, "Test current"): the dashboard draws Access denied from the user's permissions without calling the API, so the spec asserts Access denied on /users and then proves the 403 with a direct GET /users from the same session. +- [X] T042 [P] [US2] Write `web/e2e/specs/live/backup-restore.spec.ts`: 1. Seed a busybox GameServer through `_seed.ts`, pointing at the restic repo Secret from `test/e2e/fixtures/backup-restic-secret.yaml`. 2. Take a backup from the Backups tab and wait for `Completed`. 3. Write a marker file through the Files tab. 4. Restore the backup, reload, and assert the marker is gone. Error path: restoring a backup whose server was deleted through the API shows the error toast. Clean up the server and the backups. Depends on T038 at runtime only. -- [ ] T043 [P] [US2] Write `web/e2e/specs/live/mod-registries.spec.ts`: + *Implementation note (2026-10-14)*: Reworded (valgul card 2026-10-14, "Test current"): the backup phase is `Succeeded`, not `Completed`. Restoring onto a deleted server is accepted and fails in the background, so the error path asserts the Failed row ("target server ... not found") on /backups?tab=restores instead of a toast. The spec seeds its own labelled backup destination because the Backups tab needs exactly one. +- [X] T043 [P] [US2] Write `web/e2e/specs/live/mod-registries.spec.ts`: 1. Add a mod registry with a unique name in Admin Settings. 2. Reload and assert it is listed. 3. Remove it. Error path: adding the same name twice shows the 409 message inline. Selectors come from `AdminSettings_modregistries.test.tsx`. + *Implementation note (2026-10-14)*: Reworded (valgul card 2026-10-14, "Test current"): mod registries are fixed keyed providers with no add-by-name, so the spec sets, reloads and removes the Nexus Mods key. The 409 path is an unmanaged Secret that already has the provider's Secret name. - [ ] T044 [US2] Run `npm run typecheck:e2e` in `web/`, then push. After the first green live run, record the job's login count and duration in `coverage-gaps.md` (contracts/e2e-additions.md requires ≤ 10 logins), and flip G-07 to G-11. **Checkpoint**: SC-002 is met. Every major dashboard flow has a live spec. From 9326c65cf15ffcaed6545315b9cb24f00910c857 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 22:40:35 +0000 Subject: [PATCH 6/8] test(web): assert the role-change 404 in the live users spec (T040) With #699 merged, changing a deleted user's role returns 404 "user not found". The error path now changes the role in the Edit dialog and asserts the inline alert, as T040 specifies, instead of using the namespace-grant 404. Tick T040. Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: Claude --- specs/013-expand-test-coverage/tasks.md | 4 ++-- web/e2e/specs/live/users-and-roles.spec.ts | 21 ++++++++++++--------- 2 files changed, 14 insertions(+), 11 deletions(-) diff --git a/specs/013-expand-test-coverage/tasks.md b/specs/013-expand-test-coverage/tasks.md index a4893074a..ede97bd99 100644 --- a/specs/013-expand-test-coverage/tasks.md +++ b/specs/013-expand-test-coverage/tasks.md @@ -243,13 +243,13 @@ description: "Task list for 013-expand-test-coverage" Error path: saving `http://169.254.169.254/` renders the API's SSRF rejection inline. Clean up in `afterEach`. Selectors come from `web/src/routes/AdminSettings.tsx` and the existing `AdminSettings_notifications.test.tsx`. *Implementation note (2026-10-14)*: Reworded to current behaviour (valgul card 2026-10-14, "Test current"): the UI cannot edit a sink URL, so the spec toggles the sink's enabled switch instead of editing the URL. Save accepts http://169.254.169.254/; the SSRF guard (netguard in notify.HTTPClient) rejects it at delivery, so the error path asserts the inline result after "Send test". -- [ ] T040 [P] [US2] Write `web/e2e/specs/live/users-and-roles.spec.ts`: +- [X] T040 [P] [US2] Write `web/e2e/specs/live/users-and-roles.spec.ts`: 1. Create a user with a unique name through the Users page, and assign it a built-in non-admin role. 2. Reload and assert the role. 3. In a fresh `browser.newContext()` with no `storageState`, sign in as that user, which is the spec's one extra login, and assert the sidebar shows only the role's nav entries. Error path: assigning a role to a user deleted through `_seed.ts` surfaces the 404 message. `afterEach` deletes the user. - *Implementation note (2026-10-14)*: spec written on #693; open until the API returns 404 (today 500) for a role change on a deleted user. valgul chose "Fix in separate PR" on the card; after that PR merges, the error path switches from the namespace-grant 404 to the role-change 404 and this task is ticked. + *Implementation note (2026-10-14)*: the role PATCH on a deleted user returned 500; valgul chose "Fix in separate PR" on the card, and #699 made it 404 "user not found" (test change signed off on its own card). The spec's error path changes a deleted user's role in the Edit dialog and asserts the inline 404 alert, as written. - [X] T041 [P] [US2] Write `web/e2e/specs/live/role-bindings.spec.ts`: 1. Create a custom role with `RoleEditorModal` and bind it to a namespace-scoped user seeded through `_seed.ts`. 2. Reload and assert the binding. diff --git a/web/e2e/specs/live/users-and-roles.spec.ts b/web/e2e/specs/live/users-and-roles.spec.ts index 2485a9504..961e6c0e3 100644 --- a/web/e2e/specs/live/users-and-roles.spec.ts +++ b/web/e2e/specs/live/users-and-roles.spec.ts @@ -6,7 +6,7 @@ import { LoginPage } from "../../pages/LoginPage"; // Live users-and-roles coverage: the Users & RBAC page (web/src/routes/Users.tsx) // driven against the REAL API. Covers invite -> role change -> persisted // reload -> sign-in as the new user with the operator permission set, plus the -// grant-binding error path for a user that no longer exists. +// role-change error path (404) for a user that no longer exists. // // Login budget: loginIfNeeded (the shared admin session from storageState) // covers the page under test; the only extra login is the fresh context that @@ -110,10 +110,10 @@ test.describe("live: users and roles", () => { } }); - test("granting a namespace role to a deleted user surfaces the API 404 inline", async ({ page, request }) => { - // Error path. A primary-role PATCH on a deleted user returns a 500 rather - // than a clean 404, so this test uses the grants path, where addBinding - // checks user existence and returns 404 "user not found". + test("changing the role of a deleted user surfaces the API 404 inline", async ({ page, request }) => { + // Error path (T040). PATCH /users/{id} with a role on a deleted user + // returns 404 "user not found" (users.go update, #699), which the Edit + // dialog renders as its inline alert. const ghostName = `e2e-ghost-${stamp}-${Math.random().toString(36).slice(2, 6)}`; const ghost = await seedUser(request, { username: ghostName, password: pw }); cleanupUsernames.push(ghostName); @@ -126,12 +126,15 @@ test.describe("live: users and roles", () => { }); await openEditDialog(page, ghostName); - // Delete the user behind the open dialog, then try to grant a binding. + // Delete the user behind the open dialog, then try to change its role. await deleteUser(request, ghost.id); - await page.getByPlaceholder("namespace").fill("gameplane-games"); - await page.getByRole("button", { name: "Add", exact: true }).click(); + await page.locator("#edit-role").click(); + await page.getByRole("option", { name: "operator", exact: true }).click(); + await page.getByRole("button", { name: "Save changes", exact: true }).click(); - await expect(page.getByRole("alert").filter({ hasText: "user not found" })).toBeVisible({ timeout: 15_000 }); + await expect(page.getByRole("dialog").getByRole("alert").filter({ hasText: "user not found" })).toBeVisible({ + timeout: 15_000, + }); }); }); From becb6e4076d864212b86de750c1bdb32f20bda58 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 01:03:19 +0000 Subject: [PATCH 7/8] docs(specs): close G-08 and tick T044 for 013 US2 G-08 closes on live run 38649266488, where the users spec asserts the role-change 404 from #699. T044 is done: typecheck:e2e passes and the login count and duration are recorded. Summary recounted to 15/24 closed. Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: Claude --- specs/013-expand-test-coverage/coverage-gaps.md | 4 ++-- specs/013-expand-test-coverage/tasks.md | 3 ++- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/specs/013-expand-test-coverage/coverage-gaps.md b/specs/013-expand-test-coverage/coverage-gaps.md index 1bdd74f99..4e7f91d8b 100644 --- a/specs/013-expand-test-coverage/coverage-gaps.md +++ b/specs/013-expand-test-coverage/coverage-gaps.md @@ -1,6 +1,6 @@ # Coverage Gap Record -**Summary**: 14/24 closed · 1 closed unproven · 2 in progress · 6 open · 1 unit-only by design +**Summary**: 15/24 closed · 1 closed unproven · 1 in progress · 6 open · 1 unit-only by design **Last updated**: 2026-10-14 | ID | Category | Gap | Story | FR | Priority | Status | Evidence | Notes | @@ -12,7 +12,7 @@ | G-05 | static | codegen / tidy drift gate | US1 | FR-001 | P1 | closed | `codegen drift` https://github.com/GameplanePanel/Gameplane/actions/runs/38189397259 | First run on #672 found no drift; the proof shows the job fails on injected drift. | | G-06 | static | submodule pointer freshness gate | US1 | FR-001 | P1 | closed | `submodule freshness` https://github.com/GameplanePanel/Gameplane/actions/runs/38189409720 | First run on #675 green; the proof covers a stale and a fresh pointer. | | G-07 | e2e-dashboard | live: notification webhook config | US2 | FR-002 | P1 | closed | `e2e web live / arm64 (kind)` https://github.com/GameplanePanel/Gameplane/actions/runs/38500956044 | admin-notifications.spec.ts on #693: sink create, enable toggle and delete persist across reload; the SSRF URL is rejected at delivery (Send test). T039 reworded to current behaviour, valgul card 2026-10-14. | -| G-08 | e2e-dashboard | live: user create + role assignment | US2 | FR-002 | P1 | in-progress | `e2e web live / arm64 (kind)` https://github.com/GameplanePanel/Gameplane/actions/runs/38500956044 | users-and-roles.spec.ts on #693: create, role, reload and sign-in as the new user pass. The error path waits on the fix PR that returns 404 (not 500) for a role change on a deleted user (valgul card 2026-10-14, T040). | +| G-08 | e2e-dashboard | live: user create + role assignment | US2 | FR-002 | P1 | closed | `e2e web live / arm64 (kind)` https://github.com/GameplanePanel/Gameplane/actions/runs/38649266488 | users-and-roles.spec.ts on #693: invite, promote to operator, persist across reload, sign in as the new user and check its nav; changing a deleted user's role shows the inline 404 "user not found" (fixed from 500 in #699). valgul cards 2026-10-14. | | G-09 | e2e-dashboard | live: custom role + binding | US2 | FR-002 | P1 | closed | `e2e web live / arm64 (kind)` https://github.com/GameplanePanel/Gameplane/actions/runs/38500956044 | role-bindings.spec.ts on #693: custom role bound in gameplane-games, persists across reload, gates /users (Access denied plus a direct GET /users 403), then revoked. T041 reworded, valgul card 2026-10-14. | | G-10 | e2e-dashboard | live: backup restore | US2 | FR-002 | P1 | closed | `e2e web live / arm64 (kind)` https://github.com/GameplanePanel/Gameplane/actions/runs/38500956044 | backup-restore.spec.ts on #693: backup Succeeded, marker removed by restore; restoring onto a deleted server shows a Failed row on the Restores tab (no toast). T042 reworded, valgul card 2026-10-14. | | G-11 | e2e-dashboard | live: mod registry config | US2 | FR-002 | P1 | closed | `e2e web live / arm64 (kind)` https://github.com/GameplanePanel/Gameplane/actions/runs/38500956044 | mod-registries.spec.ts on #693: Nexus Mods key set, persists, removed; an unmanaged Secret with the same name gives 409 inline. T043 reworded, valgul card 2026-10-14. Live job: 9 logins (limit 10), arm64 job about 8.6 min, Playwright 5.2 min. | diff --git a/specs/013-expand-test-coverage/tasks.md b/specs/013-expand-test-coverage/tasks.md index ede97bd99..88e35809c 100644 --- a/specs/013-expand-test-coverage/tasks.md +++ b/specs/013-expand-test-coverage/tasks.md @@ -272,7 +272,8 @@ description: "Task list for 013-expand-test-coverage" Error path: adding the same name twice shows the 409 message inline. Selectors come from `AdminSettings_modregistries.test.tsx`. *Implementation note (2026-10-14)*: Reworded (valgul card 2026-10-14, "Test current"): mod registries are fixed keyed providers with no add-by-name, so the spec sets, reloads and removes the Nexus Mods key. The 409 path is an unmanaged Secret that already has the provider's Secret name. -- [ ] T044 [US2] Run `npm run typecheck:e2e` in `web/`, then push. After the first green live run, record the job's login count and duration in `coverage-gaps.md` (contracts/e2e-additions.md requires ≤ 10 logins), and flip G-07 to G-11. +- [X] T044 [US2] Run `npm run typecheck:e2e` in `web/`, then push. After the first green live run, record the job's login count and duration in `coverage-gaps.md` (contracts/e2e-additions.md requires ≤ 10 logins), and flip G-07 to G-11. + *Implementation note (2026-10-15)*: `npm run typecheck:e2e` passes once #696 (fleet.ts) merged. First green live run 38500956044: 9 logins (limit 10), arm64 live job about 8.6 min, Playwright 5.2 min (recorded on G-11). G-07 to G-11 closed; G-08 closed on run 38649266488 after #699. **Checkpoint**: SC-002 is met. Every major dashboard flow has a live spec. From 651e0a1ff40c4edcee82b4e8dbee0ac971199e73 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 01:13:27 +0000 Subject: [PATCH 8/8] test(web): fail, not skip, when the pod stays down after restore The server was Running before the backup, so not returning to Running after a successful restore is a regression. Assert it instead of skipping, so the marker-file check always runs (CodeRabbit review). Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: Claude --- web/e2e/specs/live/backup-restore.spec.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/web/e2e/specs/live/backup-restore.spec.ts b/web/e2e/specs/live/backup-restore.spec.ts index 85d2aa347..a58b41d81 100644 --- a/web/e2e/specs/live/backup-restore.spec.ts +++ b/web/e2e/specs/live/backup-restore.spec.ts @@ -179,7 +179,9 @@ test.describe("live: backup and restore a running server", () => { // The restore suspends and resumes the pod, so reload and wait for it to run again. await page.reload(); const runningAgain = await waitForRunning(page); - test.skip(!runningAgain, "pod did not return to Running after restore within budget"); + // The pod was Running before the backup, so not coming back after a + // successful restore is a regression, not a slow runner: fail, don't skip. + expect(runningAgain, "pod did not return to Running after restore within budget").toBe(true); // The marker file was created after the backup, so the restore must remove it. // The file list can error while the agent restarts, so retry the refresh.