From 48acfcd71503d618bd6283119aa6dca8f7757dc5 Mon Sep 17 00:00:00 2001 From: jhodgkin Date: Sun, 12 Jul 2026 22:38:10 -0600 Subject: [PATCH] Broaden e2e coverage: invalid credentials, session persistence, API auth Added: wrong-password rejection (local + OIDC), session survives a page reload, and API-level checks that /api/hosts, /api/devices, /api/auth/me all reject unauthenticated requests regardless of what the UI does. No new app bugs found this round -- one test assertion was itself wrong (expected no session cookie on failed login; @fastify/session issues an anonymous cookie on any response by design, that's normal). Fixed to assert the property that actually matters: the cookie grants no access. 9/9 tests green across 4 consecutive full-suite runs with parallel workers, no flakiness. Co-Authored-By: Claude Sonnet 5 --- e2e/tests/api-auth.spec.ts | 29 +++++++++++++++++++++++++++++ e2e/tests/local-login.spec.ts | 29 +++++++++++++++++++++++++++++ e2e/tests/oidc-login.spec.ts | 18 ++++++++++++++++++ 3 files changed, 76 insertions(+) create mode 100644 e2e/tests/api-auth.spec.ts diff --git a/e2e/tests/api-auth.spec.ts b/e2e/tests/api-auth.spec.ts new file mode 100644 index 0000000..c43df45 --- /dev/null +++ b/e2e/tests/api-auth.spec.ts @@ -0,0 +1,29 @@ +import { test, expect } from "@playwright/test"; + +// No page/cookies involved -- confirms protected routes actually reject +// unauthenticated requests, independent of whatever the UI does. +test.describe("unauthenticated API access is rejected", () => { + for (const path of ["/api/hosts", "/api/devices", "/api/auth/me"]) { + test(`GET ${path} without a session returns 401`, async ({ request }) => { + const res = await request.get(path); + expect(res.status()).toBe(401); + }); + } + + test("POST /api/auth/login with bad credentials returns 401 and grants no access", async ({ + request, + }) => { + const res = await request.post("/api/auth/login", { + data: { username: "admin", password: "definitely-not-the-password" }, + }); + expect(res.status()).toBe(401); + + // @fastify/session issues an anonymous session cookie on any response by + // design (regardless of login success) -- that alone doesn't mean anything. + // What matters is that cookie doesn't carry authentication: the same + // request context (Playwright persists cookies across calls) must still + // be rejected from a protected route. + const hostsRes = await request.get("/api/hosts"); + expect(hostsRes.status()).toBe(401); + }); +}); diff --git a/e2e/tests/local-login.spec.ts b/e2e/tests/local-login.spec.ts index 0f6ceea..490e437 100644 --- a/e2e/tests/local-login.spec.ts +++ b/e2e/tests/local-login.spec.ts @@ -19,3 +19,32 @@ test("local username/password login and logout", async ({ page }) => { await page.getByRole("button", { name: "Sign out" }).click(); await expect(page.getByPlaceholder("Username")).toBeVisible(); }); + +test("wrong password is rejected with an error, no session", async ({ page }) => { + await page.goto("/"); + await page.getByPlaceholder("Username").fill(USERNAME); + await page.getByPlaceholder("Password").fill("definitely-not-the-password"); + await page.getByRole("button", { name: "Sign in" }).click(); + + await expect(page.getByText(/invalid credentials/i)).toBeVisible(); + // Still on the login form, not the dashboard. + await expect(page.getByPlaceholder("Username")).toBeVisible(); + await expect(page.locator(".host-card")).toHaveCount(0); +}); + +test("session persists across a page reload", async ({ page }) => { + test.skip(!PASSWORD, "LOCAL_PASSWORD not set"); + + await page.goto("/"); + await page.getByPlaceholder("Username").fill(USERNAME); + await page.getByPlaceholder("Password").fill(PASSWORD); + await page.getByRole("button", { name: "Sign in" }).click(); + await expect(page.locator(".host-card").first()).toBeVisible({ timeout: 15_000 }); + + await page.reload(); + + await expect(page.getByText(USERNAME)).toBeVisible(); + await expect(page.locator(".host-card").first()).toBeVisible({ timeout: 15_000 }); + + await page.getByRole("button", { name: "Sign out" }).click(); +}); diff --git a/e2e/tests/oidc-login.spec.ts b/e2e/tests/oidc-login.spec.ts index 1594cea..606f16b 100644 --- a/e2e/tests/oidc-login.spec.ts +++ b/e2e/tests/oidc-login.spec.ts @@ -33,3 +33,21 @@ test("OIDC login via Authentik stays on public domains throughout", async ({ pag await page.getByRole("button", { name: "Sign out" }).click(); await expect(page.getByPlaceholder("Username")).toBeVisible(); }); + +test("wrong Authentik password does not reach the dashboard", async ({ page }) => { + test.skip(!PASSWORD, "OIDC_PASSWORD not set"); + + await page.goto("/"); + await page.getByRole("link", { name: "Sign in with Authentik" }).click(); + await page.waitForURL(/^https:\/\/auth\.jerodrigged\.com\//, { timeout: 10_000 }); + + await page.getByPlaceholder(/email or username/i).fill(USERNAME); + await page.getByRole("button", { name: /log in|continue|next/i }).click(); + + await page.getByPlaceholder(/password/i).fill("definitely-not-the-password"); + await page.getByRole("button", { name: /log in|continue|sign in/i }).click(); + + // Authentik re-shows the password stage with an error, never redirects back. + await expect(page.getByText(/failed|invalid|incorrect/i)).toBeVisible({ timeout: 10_000 }); + expect(page.url()).toMatch(/^https:\/\/auth\.jerodrigged\.com\//); +});