diff --git a/tests/e2e/api-tokens.spec.ts b/tests/e2e/api-tokens.spec.ts index 4097dba..b3b2d78 100644 --- a/tests/e2e/api-tokens.spec.ts +++ b/tests/e2e/api-tokens.spec.ts @@ -1,6 +1,6 @@ import { expect } from "@playwright/test" -import { defaultAdmin, loginAs, test } from "./fixtures" +import { csrfHeaders, defaultAdmin, loginAs, test } from "./fixtures" test("admin can create and reveal an API token via UI", async ({ page }) => { await loginAs(page, defaultAdmin) @@ -15,7 +15,7 @@ test("admin can create and reveal an API token via UI", async ({ page }) => { await dialog.getByLabel(/^Name|^名称/).fill(name) await dialog.getByText("nezha:server:read").click() - await page.getByRole("button", { name: /^Create$|^创建$/ }).click() + await dialog.getByRole("button", { name: /Create API token|创建 API 令牌/ }).click() const codeBlock = page.locator("code").filter({ hasText: /^nzp_/ }) await expect(codeBlock).toBeVisible({ timeout: 10_000 }) @@ -31,7 +31,9 @@ test("admin can create and reveal an API token via UI", async ({ page }) => { const tokens = await page.request.get("/api/v1/api-tokens").then((r) => r.json()) const target = tokens.data.find((t: { name: string }) => t.name === name) if (target) { - const del = await page.request.delete(`/api/v1/api-tokens/${target.id}`) + const del = await page.request.delete(`/api/v1/api-tokens/${target.id}`, { + headers: await csrfHeaders(page), + }) expect(del.ok()).toBeTruthy() } }) @@ -41,6 +43,7 @@ test("admin can revoke an API token via UI revoke button", async ({ page }) => { const name = `e2e-revoke-${Date.now().toString(36)}` const created = await page.request.post("/api/v1/api-tokens", { + headers: await csrfHeaders(page), data: { name, scopes: ["nezha:server:read"] }, }) expect(created.ok()).toBeTruthy() @@ -76,12 +79,14 @@ test("an API token can authenticate /mcp", async ({ page }) => { user_template: settingBefore?.user_template || "user-dist", } const enableResp = await page.request.patch("/api/v1/setting", { + headers: await csrfHeaders(page), data: { ...baseSettings, enable_mcp: true }, }) expect(enableResp.ok(), "PATCH /api/v1/setting must succeed to enable MCP for the test").toBeTruthy() try { const apiResp = await page.request.post("/api/v1/api-tokens", { + headers: await csrfHeaders(page), data: { name: `e2e-mcp-${Date.now().toString(36)}`, scopes: ["nezha:server:read"], @@ -115,10 +120,13 @@ test("an API token can authenticate /mcp", async ({ page }) => { expect(whoamiBody.result?.isError).toBeFalsy() expect(whoamiBody.result?.structuredContent?.scopes).toContain("nezha:server:read") - const delResp = await page.request.delete(`/api/v1/api-tokens/${tokenID}`) + const delResp = await page.request.delete(`/api/v1/api-tokens/${tokenID}`, { + headers: await csrfHeaders(page), + }) expect(delResp.ok()).toBeTruthy() } finally { await page.request.patch("/api/v1/setting", { + headers: await csrfHeaders(page), data: { ...baseSettings, enable_mcp: !!settingBefore?.enable_mcp }, }) } diff --git a/tests/e2e/auth.spec.ts b/tests/e2e/auth.spec.ts index 2a7da46..c07cbfd 100644 --- a/tests/e2e/auth.spec.ts +++ b/tests/e2e/auth.spec.ts @@ -1,6 +1,6 @@ import { expect } from "@playwright/test" -import { defaultAdmin, expectAuthenticated, expectUnauthenticated, loginAs, test } from "./fixtures" +import { csrfHeaders, defaultAdmin, expectAuthenticated, expectUnauthenticated, loginAs, test } from "./fixtures" test("login persists session via cookie and getProfile succeeds", async ({ page }) => { await loginAs(page, defaultAdmin) @@ -18,6 +18,7 @@ test("password change rotates TokenVersion and revokes existing session", async let isOnNewPassword = false try { const resp = await page.request.post("/api/v1/profile", { + headers: await csrfHeaders(page), data: { original_password: defaultAdmin.password, new_password: newPassword, @@ -44,6 +45,7 @@ test("password change rotates TokenVersion and revokes existing session", async await page.context().clearCookies() await loginAs(page, { username: defaultAdmin.username, password: newPassword }) const restoreResp = await page.request.post("/api/v1/profile", { + headers: await csrfHeaders(page), data: { original_password: newPassword, new_password: defaultAdmin.password, diff --git a/tests/e2e/cron-csrf.spec.ts b/tests/e2e/cron-csrf.spec.ts index 28bfe9f..3d7dc3a 100644 --- a/tests/e2e/cron-csrf.spec.ts +++ b/tests/e2e/cron-csrf.spec.ts @@ -1,9 +1,10 @@ import { expect } from "@playwright/test" -import { test } from "./fixtures" +import { csrfHeaders, test } from "./fixtures" test("manual cron trigger goes through POST, not GET", async ({ adminPage: page }) => { const created = await page.request.post("/api/v1/cron", { + headers: await csrfHeaders(page), data: { name: "e2e-cron-csrf", task_type: 0, @@ -28,11 +29,16 @@ test("manual cron trigger goes through POST, not GET", async ({ adminPage: page `GET must no longer be routable (got ${getResp.status()})`, ).toBeTruthy() - const postResp = await page.request.post(`/api/v1/cron/${cronID}/manual`) + const postResp = await page.request.post(`/api/v1/cron/${cronID}/manual`, { + headers: await csrfHeaders(page), + }) expect(postResp.ok(), `POST must succeed (got ${postResp.status()})`).toBeTruthy() const body = await postResp.json() expect(body.success).toBe(true) } finally { - await page.request.post("/api/v1/batch-delete/cron", { data: [cronID] }) + await page.request.post("/api/v1/batch-delete/cron", { + headers: await csrfHeaders(page), + data: [cronID], + }) } }) diff --git a/tests/e2e/fixtures.ts b/tests/e2e/fixtures.ts index 42a7084..bc1cc16 100644 --- a/tests/e2e/fixtures.ts +++ b/tests/e2e/fixtures.ts @@ -16,12 +16,38 @@ export async function loginAs(page: Page, creds: LoginContext) { await page.locator('input[autocomplete="current-password"]').fill(creds.password) await page.locator('button[type="submit"]').click() await page.waitForURL(/\/dashboard\/?(?:$|\?|#)/, { timeout: 10_000 }) + // Block until the signed nz-csrf cookie is readable. csrfHeaders() reads it + // synchronously; without this wait a mutating request fired right after + // login can race the Set-Cookie and send no token, getting a 403. + await expect + .poll(async () => (await page.context().cookies()).some((c) => c.name === "nz-csrf"), { + timeout: 10_000, + }) + .toBe(true) } export async function logout(page: Page) { await page.context().clearCookies() } +// csrfHeaders mirrors the signed nz-csrf cookie into the X-CSRF-Token header. +// The backend's double-submit CSRF gate rejects unsafe methods unless the two +// match; the SPA does this in api.ts, but page.request bypasses that JS, so +// E2E mutating calls must replicate it or every POST/PATCH/DELETE gets 403. +export async function csrfHeaders(page: Page): Promise> { + let value = "" + await expect + .poll( + async () => { + value = (await page.context().cookies()).find((c) => c.name === "nz-csrf")?.value ?? "" + return value + }, + { timeout: 10_000 }, + ) + .not.toBe("") + return { "X-CSRF-Token": value } +} + export async function expectAuthenticated(page: Page) { const resp = await page.request.get("/api/v1/profile") expect(resp.status(), "profile must respond 2xx while authenticated").toBeLessThan(400) diff --git a/tests/e2e/fm-csrf.spec.ts b/tests/e2e/fm-csrf.spec.ts index c07aa4f..a0e569e 100644 --- a/tests/e2e/fm-csrf.spec.ts +++ b/tests/e2e/fm-csrf.spec.ts @@ -1,6 +1,6 @@ import { expect } from "@playwright/test" -import { test } from "./fixtures" +import { csrfHeaders, test } from "./fixtures" test("file manager creation only accepts POST", async ({ adminPage: page }) => { const getResp = await page.request.get("/api/v1/file?id=1", { @@ -13,6 +13,7 @@ test("file manager creation only accepts POST", async ({ adminPage: page }) => { const postResp = await page.request.post("/api/v1/file?id=1", { failOnStatusCode: false, + headers: await csrfHeaders(page), }) expect(postResp.status()).toBe(200) const body = await postResp.json() diff --git a/tests/e2e/visibility.spec.ts b/tests/e2e/visibility.spec.ts index 7be5183..3c51339 100644 --- a/tests/e2e/visibility.spec.ts +++ b/tests/e2e/visibility.spec.ts @@ -1,6 +1,6 @@ import { expect } from "@playwright/test" -import { test } from "./fixtures" +import { csrfHeaders, test } from "./fixtures" test("server-group hides guest-empty groups from anonymous callers", async ({ adminPage: page, browser }) => { const tag = Date.now().toString(36) @@ -18,12 +18,14 @@ test("server-group hides guest-empty groups from anonymous callers", async ({ ad const createdGroupIDs: number[] = [] if (publicServer) { const visibleResp = await page.request.post("/api/v1/server-group", { + headers: await csrfHeaders(page), data: { name: visibleName, servers: [publicServer.id] }, }) expect(visibleResp.ok()).toBeTruthy() createdGroupIDs.push(((await visibleResp.json()) as { data: number }).data) } const hiddenResp = await page.request.post("/api/v1/server-group", { + headers: await csrfHeaders(page), data: { name: hiddenName, servers: [] }, }) expect(hiddenResp.ok()).toBeTruthy() @@ -47,7 +49,10 @@ test("server-group hides guest-empty groups from anonymous callers", async ({ ad } } finally { if (createdGroupIDs.length > 0) { - await page.request.post("/api/v1/batch-delete/server-group", { data: createdGroupIDs }) + await page.request.post("/api/v1/batch-delete/server-group", { + headers: await csrfHeaders(page), + data: createdGroupIDs, + }) } } })