Skip to content
Draft
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 @@ -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:
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);
}
}
}
}
111 changes: 111 additions & 0 deletions web/e2e/specs/live/admin-notifications.spec.ts
Original file line number Diff line number Diff line change
@@ -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 });
});
});
Loading
Loading