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/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. 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, "" + }) +} 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..17fcfd22d --- /dev/null +++ b/web/e2e/specs/live/admin-notifications.spec.ts @@ -0,0 +1,111 @@ +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. + // 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 }); + + 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..85d2aa347 --- /dev/null +++ b/web/e2e/specs/live/backup-restore.spec.ts @@ -0,0 +1,258 @@ +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" }); + // 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/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..4c1ee1d37 --- /dev/null +++ b/web/e2e/specs/live/role-bindings.spec.ts @@ -0,0 +1,137 @@ +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); + // 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 }); + 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. 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 }); + 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..2485a9504 --- /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 }); + // 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(); + 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 }); + }); +});