From 22da4d74b8e8d6830192a29b2d2d9cdccf875ac3 Mon Sep 17 00:00:00 2001 From: naiba Date: Tue, 26 May 2026 04:34:43 +0000 Subject: [PATCH] test(e2e): add Playwright suite for auth + CSRF + visibility fixes Covers the security fixes that landed across both repos: - auth.spec.ts: login persists nz-jwt cookie and getProfile succeeds; password change bumps TokenVersion + revokes the old cookie so the pre-change JWT can no longer auth (regression guard for the keyId+session backend rewrite). - cron-csrf.spec.ts: POST /api/v1/cron/:id/manual succeeds while GET is no longer routable (regression guard for the cron CSRF fix). - fm-csrf.spec.ts: POST /api/v1/file is reachable while GET is no longer routable (regression guard for the FM CSRF fix). - visibility.spec.ts: an anonymous caller cannot see a server-group that contains zero guest-visible servers (regression guard for the server-group leak fix). Fixtures wrap the noisy login + cleanup boilerplate. tsconfig is scoped to tests/e2e so the suite stays out of the production tsc project graph. Playwright config starts the Vite dev server (npm run dev) and expects a backend reachable at the URL Vite proxies to. CI workflow follow-up commit wires the backend up. Co-authored-by: cloudcode --- .gitignore | 6 ++++ package-lock.json | 64 ++++++++++++++++++++++++++++++++++++ package.json | 5 ++- playwright.config.ts | 32 ++++++++++++++++++ tests/e2e/auth.spec.ts | 57 ++++++++++++++++++++++++++++++++ tests/e2e/cron-csrf.spec.ts | 38 +++++++++++++++++++++ tests/e2e/fixtures.ts | 56 +++++++++++++++++++++++++++++++ tests/e2e/fm-csrf.spec.ts | 21 ++++++++++++ tests/e2e/tsconfig.json | 14 ++++++++ tests/e2e/visibility.spec.ts | 53 +++++++++++++++++++++++++++++ 10 files changed, 345 insertions(+), 1 deletion(-) create mode 100644 playwright.config.ts create mode 100644 tests/e2e/auth.spec.ts create mode 100644 tests/e2e/cron-csrf.spec.ts create mode 100644 tests/e2e/fixtures.ts create mode 100644 tests/e2e/fm-csrf.spec.ts create mode 100644 tests/e2e/tsconfig.json create mode 100644 tests/e2e/visibility.spec.ts diff --git a/.gitignore b/.gitignore index 4e29aac..c4d42e0 100644 --- a/.gitignore +++ b/.gitignore @@ -24,3 +24,9 @@ dist-ssr *.sw? bun.lock pnpm-lock.yaml + +# Playwright +/playwright-report +/test-results +/blob-report +/playwright/.cache diff --git a/package-lock.json b/package-lock.json index 7c134d2..64deb49 100644 --- a/package-lock.json +++ b/package-lock.json @@ -60,6 +60,7 @@ }, "devDependencies": { "@eslint/js": "^9.37.0", + "@playwright/test": "^1.60.0", "@testing-library/dom": "^10.4.1", "@testing-library/react": "^16.3.2", "@types/node": "^24.7.0", @@ -1351,6 +1352,22 @@ "node": ">= 8" } }, + "node_modules/@playwright/test": { + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.60.0.tgz", + "integrity": "sha512-O71yZIbAh/PxDMNGns37GHBIfrVkEVyn+AXyIa5dOTfb4/xNvRWV+Vv/NMbNCtODB/pO7vLlF2OTmMVLhmr7Ag==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.60.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@radix-ui/number": { "version": "1.1.1", "license": "MIT" @@ -6005,6 +6022,53 @@ "pathe": "^2.0.3" } }, + "node_modules/playwright": { + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.60.0.tgz", + "integrity": "sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.60.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.60.0.tgz", + "integrity": "sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/postcss": { "version": "8.4.24", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.24.tgz", diff --git a/package.json b/package.json index 6d74462..12b480f 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,9 @@ "lint:fix": "eslint --fix .", "format": "prettier --write .", "test": "vitest run", - "preview": "vite preview" + "preview": "vite preview", + "e2e": "playwright test", + "e2e:install": "playwright install --with-deps chromium" }, "dependencies": { "@hookform/resolvers": "^5.2.2", @@ -66,6 +68,7 @@ }, "devDependencies": { "@eslint/js": "^9.37.0", + "@playwright/test": "^1.60.0", "@testing-library/dom": "^10.4.1", "@testing-library/react": "^16.3.2", "@types/node": "^24.7.0", diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 0000000..d69a023 --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,32 @@ +import { defineConfig, devices } from "@playwright/test" + +const baseURL = process.env.E2E_BASE_URL || "http://localhost:5173" + +export default defineConfig({ + testDir: "./tests/e2e", + fullyParallel: false, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 1 : 0, + workers: 1, + reporter: process.env.CI ? [["github"], ["html", { open: "never" }]] : "list", + use: { + baseURL, + trace: "on-first-retry", + screenshot: "only-on-failure", + video: process.env.CI ? "retain-on-failure" : "off", + }, + projects: [ + { + name: "chromium", + use: { ...devices["Desktop Chrome"] }, + }, + ], + webServer: process.env.E2E_SKIP_WEBSERVER + ? undefined + : { + command: "npm run dev", + url: baseURL + "/dashboard/login", + reuseExistingServer: !process.env.CI, + timeout: 120_000, + }, +}) diff --git a/tests/e2e/auth.spec.ts b/tests/e2e/auth.spec.ts new file mode 100644 index 0000000..2a7da46 --- /dev/null +++ b/tests/e2e/auth.spec.ts @@ -0,0 +1,57 @@ +import { expect } from "@playwright/test" + +import { defaultAdmin, expectAuthenticated, expectUnauthenticated, loginAs, test } from "./fixtures" + +test("login persists session via cookie and getProfile succeeds", async ({ page }) => { + await loginAs(page, defaultAdmin) + await expectAuthenticated(page) +}) + +test("password change rotates TokenVersion and revokes existing session", async ({ page }) => { + await loginAs(page, defaultAdmin) + + const originalCookies = await page.context().cookies() + const originalJWT = originalCookies.find((c) => c.name === "nz-jwt")?.value + expect(originalJWT, "login must set nz-jwt cookie").toBeTruthy() + + const newPassword = `e2e-${Date.now().toString(36)}` + let isOnNewPassword = false + try { + const resp = await page.request.post("/api/v1/profile", { + data: { + original_password: defaultAdmin.password, + new_password: newPassword, + new_username: defaultAdmin.username, + reject_password: false, + }, + }) + expect(resp.ok(), "profile update must succeed").toBeTruthy() + isOnNewPassword = true + + await page.context().clearCookies() + if (originalJWT) { + await page.context().addCookies([ + { + name: "nz-jwt", + value: originalJWT, + url: page.url() || "http://localhost:5173", + }, + ]) + } + await expectUnauthenticated(page) + } finally { + if (isOnNewPassword) { + await page.context().clearCookies() + await loginAs(page, { username: defaultAdmin.username, password: newPassword }) + const restoreResp = await page.request.post("/api/v1/profile", { + data: { + original_password: newPassword, + new_password: defaultAdmin.password, + new_username: defaultAdmin.username, + reject_password: false, + }, + }) + expect(restoreResp.ok(), "password restore must succeed so other suites can still log in").toBeTruthy() + } + } +}) diff --git a/tests/e2e/cron-csrf.spec.ts b/tests/e2e/cron-csrf.spec.ts new file mode 100644 index 0000000..28bfe9f --- /dev/null +++ b/tests/e2e/cron-csrf.spec.ts @@ -0,0 +1,38 @@ +import { expect } from "@playwright/test" + +import { test } from "./fixtures" + +test("manual cron trigger goes through POST, not GET", async ({ adminPage: page }) => { + const created = await page.request.post("/api/v1/cron", { + data: { + name: "e2e-cron-csrf", + task_type: 0, + scheduler: "@every 1h", + command: "true", + servers: [], + cover: 0, + push_successful: false, + notification_group_id: 0, + }, + }) + expect(created.ok(), "create cron via POST must succeed").toBeTruthy() + const { data: cronID } = (await created.json()) as { data: number } + expect(typeof cronID).toBe("number") + + try { + const getResp = await page.request.get(`/api/v1/cron/${cronID}/manual`, { + failOnStatusCode: false, + }) + expect( + getResp.status() === 404 || getResp.status() === 405, + `GET must no longer be routable (got ${getResp.status()})`, + ).toBeTruthy() + + const postResp = await page.request.post(`/api/v1/cron/${cronID}/manual`) + 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] }) + } +}) diff --git a/tests/e2e/fixtures.ts b/tests/e2e/fixtures.ts new file mode 100644 index 0000000..42a7084 --- /dev/null +++ b/tests/e2e/fixtures.ts @@ -0,0 +1,56 @@ +import { Page, Request, expect, test as base } from "@playwright/test" + +export type LoginContext = { + username: string + password: string +} + +export const defaultAdmin: LoginContext = { + username: process.env.E2E_ADMIN_USER || "admin", + password: process.env.E2E_ADMIN_PASS || "admin", +} + +export async function loginAs(page: Page, creds: LoginContext) { + await page.goto("/dashboard/login") + await page.locator('input[autocomplete="username"]').fill(creds.username) + await page.locator('input[autocomplete="current-password"]').fill(creds.password) + await page.locator('button[type="submit"]').click() + await page.waitForURL(/\/dashboard\/?(?:$|\?|#)/, { timeout: 10_000 }) +} + +export async function logout(page: Page) { + await page.context().clearCookies() +} + +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) + const body = await resp.json() + expect(body.success, "profile.success must be true").toBe(true) + expect(body.data?.id, "profile.data.id must be present").toBeTruthy() +} + +export async function expectUnauthenticated(page: Page) { + const resp = await page.request.get("/api/v1/profile") + const body = await resp.json() + expect(body.success, "profile must NOT be authorized after revoke").not.toBe(true) + expect(body.error, "profile must surface an error after revoke").toBeTruthy() +} + +export async function findRequest( + page: Page, + matcher: (req: Request) => boolean, + trigger: () => Promise, + timeoutMs = 5000, +): Promise { + const waiter = page.waitForRequest(matcher, { timeout: timeoutMs }) + await trigger() + return await waiter +} + +export const test = base.extend<{ adminPage: Page }>({ + adminPage: async ({ page }, use) => { + await loginAs(page, defaultAdmin) + await use(page) + }, +}) diff --git a/tests/e2e/fm-csrf.spec.ts b/tests/e2e/fm-csrf.spec.ts new file mode 100644 index 0000000..c07aa4f --- /dev/null +++ b/tests/e2e/fm-csrf.spec.ts @@ -0,0 +1,21 @@ +import { expect } from "@playwright/test" + +import { test } from "./fixtures" + +test("file manager creation only accepts POST", async ({ adminPage: page }) => { + const getResp = await page.request.get("/api/v1/file?id=1", { + failOnStatusCode: false, + }) + expect( + getResp.status() === 404 || getResp.status() === 405, + `GET /api/v1/file must no longer be routable (got ${getResp.status()})`, + ).toBeTruthy() + + const postResp = await page.request.post("/api/v1/file?id=1", { + failOnStatusCode: false, + }) + expect(postResp.status()).toBe(200) + const body = await postResp.json() + expect(body.success, "without a connected agent server the POST surfaces a Service error, but the route is reachable").not.toBe(true) + expect(body.error).toBeTruthy() +}) diff --git a/tests/e2e/tsconfig.json b/tests/e2e/tsconfig.json new file mode 100644 index 0000000..2700d6d --- /dev/null +++ b/tests/e2e/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2023", "DOM"], + "module": "ESNext", + "moduleResolution": "Bundler", + "isolatedModules": true, + "strict": true, + "skipLibCheck": true, + "noEmit": true, + "types": ["@playwright/test"] + }, + "include": ["**/*.ts"] +} diff --git a/tests/e2e/visibility.spec.ts b/tests/e2e/visibility.spec.ts new file mode 100644 index 0000000..7be5183 --- /dev/null +++ b/tests/e2e/visibility.spec.ts @@ -0,0 +1,53 @@ +import { expect } from "@playwright/test" + +import { test } from "./fixtures" + +test("server-group hides guest-empty groups from anonymous callers", async ({ adminPage: page, browser }) => { + const tag = Date.now().toString(36) + const visibleName = `e2e-visible-${tag}` + const hiddenName = `e2e-hidden-${tag}` + + // Admin creates one group with no servers (will be guest-empty) and one with a public server. + // First make sure there's at least one public server visible to guests; if not, this whole + // scenario boils down to "no group is guest-visible", which the assertion below still covers. + const serversResp = await page.request.get("/api/v1/server") + expect(serversResp.ok()).toBeTruthy() + const serversBody = (await serversResp.json()) as { data: Array<{ id: number; hide_for_guest?: boolean }> } + const publicServer = serversBody.data?.find((s) => !s.hide_for_guest) + + const createdGroupIDs: number[] = [] + if (publicServer) { + const visibleResp = await page.request.post("/api/v1/server-group", { + 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", { + data: { name: hiddenName, servers: [] }, + }) + expect(hiddenResp.ok()).toBeTruthy() + createdGroupIDs.push(((await hiddenResp.json()) as { data: number }).data) + + try { + const guestCtx = await browser.newContext() + try { + const guestResp = await guestCtx.request.get("/api/v1/server-group") + expect(guestResp.ok()).toBeTruthy() + const guestBody = (await guestResp.json()) as { + data: Array<{ group: { name: string }; servers: number[] }> + } + const names = (guestBody.data || []).map((it) => it.group.name) + expect(names, "guest must NOT see groups with zero visible servers").not.toContain(hiddenName) + if (publicServer) { + expect(names, "guest still sees groups that contain a guest-visible server").toContain(visibleName) + } + } finally { + await guestCtx.close() + } + } finally { + if (createdGroupIDs.length > 0) { + await page.request.post("/api/v1/batch-delete/server-group", { data: createdGroupIDs }) + } + } +})