Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -1912,6 +1912,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:
Expand Down
14 changes: 7 additions & 7 deletions specs/013-expand-test-coverage/coverage-gaps.md
Original file line number Diff line number Diff line change
@@ -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**: 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 |
|---|---|---|---|---|---|---|---|---|
Expand All @@ -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 | 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. |
| 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 | | |
Expand Down
22 changes: 14 additions & 8 deletions specs/013-expand-test-coverage/tasks.md
Original file line number Diff line number Diff line change
Expand Up @@ -233,41 +233,47 @@ 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`.
- [ ] T040 [P] [US2] Write `web/e2e/specs/live/users-and-roles.spec.ts`:
*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".
- [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.
- [ ] T041 [P] [US2] Write `web/e2e/specs/live/role-bindings.spec.ts`:
*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.
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`.
- [ ] 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-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.
- [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.

Expand Down
1 change: 1 addition & 0 deletions test/e2e/buckets.sh
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,7 @@ TestAddressPool_ExplicitAddressRequest
TestAddressPool_AddressInUseConflict
TestAddressPool_PoolExhausted
TestAPI_EventStreamAndRoleEdits_FollowCallerPermissions
TestE2E_ResticRepoReady
EOF
}

Expand Down
21 changes: 21 additions & 0 deletions test/e2e/test_helpers_e2e_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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, ""
})
}
116 changes: 115 additions & 1 deletion web/e2e/specs/live/_seed.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Record<string, string>> {
export async function seedHeaders(request: APIRequestContext): Promise<Record<string, string>> {
const state = await request.storageState();
const token = state.cookies.find((c) => c.name === "gameplane_csrf")?.value ?? "";
return { "X-Gameplane-CSRF": token, Accept: "application/json" };
Expand Down Expand Up @@ -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<APIResponse> {
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<SeededUser> {
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<void> {
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<Seeded> {
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<Seeded> {
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<void> {
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);
}
}
}
}
Loading
Loading