From 9b5051d3ac7dda563cb64d43dd737596b6683164 Mon Sep 17 00:00:00 2001 From: jhodgkin Date: Sun, 12 Jul 2026 22:22:34 -0600 Subject: [PATCH] Add Playwright e2e tests; fix two real bugs they caught fix: OIDC_ISSUER_URL used Authentik's LAN IP (192.168.1.208:9443). Authentik's discovery doc echoes back whichever host you query it through, so that LAN IP got baked into authorization_endpoint -- the URL the *browser* gets redirected to. Anyone off the LAN got sent to an address they couldn't reach. Authentik was already publicly exposed at auth.jerodrigged.com (pre-existing NPM proxy host); switched to that, which also has a real cert so OIDC_ALLOW_INSECURE_TLS could go back to false. Reported as "signed in via Authentik, redirected to the local IP, failed." fix: frontend's request() helper always sent Content-Type: application/json, even for logout's bodyless POST. Fastify's default JSON parser rejects an empty body under that content-type (400) -- sign-out silently failed to log the user out. curl-based testing missed this because curl doesn't set that header without -d. Caught immediately by the new Playwright local-login test. e2e/: Playwright suite for local auth and OIDC login. OIDC test uses a dedicated Authentik test user (blueprint-provisioned, never a real personal login) so the whole flow can run unattended and repeatedly. Co-Authored-By: Claude Sonnet 5 --- .env.example | 12 +++-- .gitignore | 3 ++ apps/web/src/api.ts | 9 ++-- docs/oidc-setup.md | 25 ++++++++-- e2e/.env.example | 13 +++++ e2e/package-lock.json | 93 +++++++++++++++++++++++++++++++++++ e2e/package.json | 16 ++++++ e2e/playwright.config.ts | 14 ++++++ e2e/tests/local-login.spec.ts | 21 ++++++++ e2e/tests/oidc-login.spec.ts | 33 +++++++++++++ 10 files changed, 229 insertions(+), 10 deletions(-) create mode 100644 e2e/.env.example create mode 100644 e2e/package-lock.json create mode 100644 e2e/package.json create mode 100644 e2e/playwright.config.ts create mode 100644 e2e/tests/local-login.spec.ts create mode 100644 e2e/tests/oidc-login.spec.ts diff --git a/.env.example b/.env.example index 55d9af7..d2c5253 100644 --- a/.env.example +++ b/.env.example @@ -17,14 +17,20 @@ ADMIN_PASSWORD= # additionally show a "Sign in with Authentik" button — see docs/oidc-setup.md # for how the Authentik provider was set up. OIDC_ENABLED=false -OIDC_ISSUER_URL=https://192.168.1.208:9443/application/o/homelab-monitor/ +# MUST be the public hostname, not the LAN IP (192.168.1.208:9443) -- Authentik's +# discovery doc echoes back whichever host you query it through, and that value +# gets baked into authorization_endpoint, which the *browser* is redirected to. +# Using the LAN IP here sends anyone off-LAN to an address they can't reach. +# See docs/oidc-setup.md for the bug this caused. +OIDC_ISSUER_URL=https://auth.jerodrigged.com/application/o/homelab-monitor/ OIDC_CLIENT_ID= OIDC_CLIENT_SECRET= # Public URL is primary since issue #13 shipped; the LAN URL is also registered # in Authentik as a fallback (see docs/oidc-setup.md) if you need to switch back. OIDC_REDIRECT_URI=https://monitor.jerodrigged.com/api/auth/oidc/callback -# Authentik's cert is self-signed on the LAN, same situation as Proxmox's API. -OIDC_ALLOW_INSECURE_TLS=true +# auth.jerodrigged.com has a real Let's Encrypt cert (unlike the LAN IP's +# self-signed one), so this can stay false when OIDC_ISSUER_URL is the public URL. +OIDC_ALLOW_INSECURE_TLS=false # Set to true only once a TLS-terminating reverse proxy sits in front (see # issue #13). Leave false for plain-HTTP LAN access, otherwise the session diff --git a/.gitignore b/.gitignore index f3b7389..087afbe 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,6 @@ data/ ssh/ *.log .DS_Store +test-results/ +playwright-report/ +blob-report/ diff --git a/apps/web/src/api.ts b/apps/web/src/api.ts index 564aee6..f49b128 100644 --- a/apps/web/src/api.ts +++ b/apps/web/src/api.ts @@ -21,10 +21,11 @@ export interface HostSummary { } async function request(path: string, init?: RequestInit): Promise { - const res = await fetch(path, { - ...init, - headers: { "Content-Type": "application/json", ...init?.headers }, - }); + // Only set Content-Type when there's actually a body -- Fastify's default + // JSON parser rejects an empty body sent with application/json (400), + // which broke logout (no body) once real requests, not curl, sent it. + const headers = init?.body ? { "Content-Type": "application/json", ...init?.headers } : init?.headers; + const res = await fetch(path, { ...init, headers }); if (!res.ok) { const body = await res.json().catch(() => ({})); throw new Error(body.error ?? `Request to ${path} failed with ${res.status}`); diff --git a/docs/oidc-setup.md b/docs/oidc-setup.md index b8eb024..60607bd 100644 --- a/docs/oidc-setup.md +++ b/docs/oidc-setup.md @@ -26,11 +26,30 @@ To change anything (redirect URI, flow, scopes), edit the blueprint file on CT12 either wait for Authentik's file-watcher or `docker restart authentik-worker-1` — it re-applies on any change to the file. +## Bug: LAN-IP issuer sent browsers somewhere they couldn't reach + +First deploy used `OIDC_ISSUER_URL=https://192.168.1.208:9443/application/o/homelab-monitor/` +(Authentik's LAN IP) for discovery. Authentik's discovery document **echoes back whichever +host you queried it through** — so `authorization_endpoint` came back as that same LAN IP. +That value gets handed straight to the *browser* as a redirect target. Anyone off the LAN +(or just not on wifi) got redirected to an address they couldn't reach at all — reported as +"signed in via Authentik, got sent to the local IP, and it failed." + +Fix: Authentik was **already** publicly exposed at `https://auth.jerodrigged.com` (NPM proxy +host id 9, predates this project) with a real Let's Encrypt cert — just wasn't the one used +for `OIDC_ISSUER_URL`. Switched to it; `OIDC_ALLOW_INSECURE_TLS` could then go back to `false` +too, since it's not a self-signed cert. **Always use the public issuer URL for anything a +browser is ever redirected through**, even though the app's own server-to-server calls +(token exchange, userinfo, jwks — all made directly by the Node process, never by a browser) +would have worked fine against the LAN IP too. + ## API-side implementation -`apps/api/src/auth/oidc.ts` uses `openid-client` v6 with PKCE + state, same self-signed -TLS handling pattern as `collectors/proxmox.ts` (Authentik's cert is self-signed on the -LAN too). Routes in `apps/api/src/routes/oidc.ts`: +`apps/api/src/auth/oidc.ts` uses `openid-client` v6 with PKCE + state, with an optional +insecure-TLS fetch override (`OIDC_ALLOW_INSECURE_TLS`) for the rare case the issuer is +LAN-only with a self-signed cert — not needed now that the issuer is the public URL, but +kept since Authentik's LAN address still works as a fallback if `auth.jerodrigged.com` is +ever unreachable. Routes in `apps/api/src/routes/oidc.ts`: - `GET /api/auth/oidc/login` — redirects to Authentik's authorization endpoint - `GET /api/auth/oidc/callback` — exchanges the code, sets `req.session.username` from diff --git a/e2e/.env.example b/e2e/.env.example new file mode 100644 index 0000000..40b89ac --- /dev/null +++ b/e2e/.env.example @@ -0,0 +1,13 @@ +# Copy to .env, fill in, never commit the real one. + +BASE_URL=https://monitor.jerodrigged.com + +# Local auth (bcrypt) admin login. +LOCAL_USERNAME=admin +LOCAL_PASSWORD= + +# Dedicated Authentik test account (authentik_core.user blueprint on CT121, +# path users/service-accounts, not a real person's login) — see +# docs/oidc-setup.md. Never use a real personal Authentik password here. +OIDC_USERNAME=playwright-test +OIDC_PASSWORD= diff --git a/e2e/package-lock.json b/e2e/package-lock.json new file mode 100644 index 0000000..22a33dc --- /dev/null +++ b/e2e/package-lock.json @@ -0,0 +1,93 @@ +{ + "name": "@homelab-monitor/e2e", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@homelab-monitor/e2e", + "version": "0.1.0", + "dependencies": { + "dotenv": "^16.4.7" + }, + "devDependencies": { + "@playwright/test": "^1.49.1" + } + }, + "node_modules/@playwright/test": { + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.61.1.tgz", + "integrity": "sha512-8nKv6+0RJSL9FE4jYOEGXnPeM/Hg12qZpmqzZjRh3qM0Y7c3z1mrOTfFLids72RDQYVh9WpLEfR5WdpNX4fkig==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.61.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/dotenv": { + "version": "16.6.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", + "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "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/playwright": { + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.1.tgz", + "integrity": "sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.61.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.1.tgz", + "integrity": "sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + } + } +} diff --git a/e2e/package.json b/e2e/package.json new file mode 100644 index 0000000..7c8dd19 --- /dev/null +++ b/e2e/package.json @@ -0,0 +1,16 @@ +{ + "name": "@homelab-monitor/e2e", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "test": "playwright test", + "test:headed": "playwright test --headed" + }, + "dependencies": { + "dotenv": "^16.4.7" + }, + "devDependencies": { + "@playwright/test": "^1.49.1" + } +} diff --git a/e2e/playwright.config.ts b/e2e/playwright.config.ts new file mode 100644 index 0000000..b55a174 --- /dev/null +++ b/e2e/playwright.config.ts @@ -0,0 +1,14 @@ +import { defineConfig } from "@playwright/test"; +import "dotenv/config"; + +export default defineConfig({ + testDir: "./tests", + timeout: 30_000, + retries: 1, + reporter: [["list"]], + use: { + baseURL: process.env.BASE_URL ?? "https://monitor.jerodrigged.com", + trace: "retain-on-failure", + screenshot: "only-on-failure", + }, +}); diff --git a/e2e/tests/local-login.spec.ts b/e2e/tests/local-login.spec.ts new file mode 100644 index 0000000..0f6ceea --- /dev/null +++ b/e2e/tests/local-login.spec.ts @@ -0,0 +1,21 @@ +import { test, expect } from "@playwright/test"; + +const USERNAME = process.env.LOCAL_USERNAME ?? "admin"; +const PASSWORD = process.env.LOCAL_PASSWORD ?? ""; + +test("local username/password login and logout", async ({ page }) => { + test.skip(!PASSWORD, "LOCAL_PASSWORD not set"); + + await page.goto("/"); + await expect(page.getByRole("heading", { name: "Homelab Monitor" })).toBeVisible(); + + await page.getByPlaceholder("Username").fill(USERNAME); + await page.getByPlaceholder("Password").fill(PASSWORD); + await page.getByRole("button", { name: "Sign in" }).click(); + + 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(); + await expect(page.getByPlaceholder("Username")).toBeVisible(); +}); diff --git a/e2e/tests/oidc-login.spec.ts b/e2e/tests/oidc-login.spec.ts new file mode 100644 index 0000000..6b7c8d5 --- /dev/null +++ b/e2e/tests/oidc-login.spec.ts @@ -0,0 +1,33 @@ +import { test, expect } from "@playwright/test"; + +const USERNAME = process.env.OIDC_USERNAME ?? "playwright-test"; +const PASSWORD = process.env.OIDC_PASSWORD ?? ""; + +test("OIDC login via Authentik stays on public domains throughout", async ({ page }) => { + test.skip(!PASSWORD, "OIDC_PASSWORD not set"); + + await page.goto("/"); + const oidcButton = page.getByRole("link", { name: "Sign in with Authentik" }); + await expect(oidcButton).toBeVisible(); + await oidcButton.click(); + + // Regression check for the actual bug reported: the authorization endpoint + // must be the public auth.jerodrigged.com, never a LAN IP a browser off the + // LAN can't reach. + await page.waitForURL(/^https:\/\/auth\.jerodrigged\.com\//, { timeout: 10_000 }); + expect(page.url()).not.toMatch(/192\.168\./); + + await page.getByPlaceholder(/email or username/i).fill(USERNAME); + await page.getByRole("button", { name: /log in|continue|next/i }).click(); + + await page.getByLabel(/password/i).fill(PASSWORD); + await page.getByRole("button", { name: /log in|continue|sign in/i }).click(); + + await page.waitForURL(/^https:\/\/monitor\.jerodrigged\.com\//, { timeout: 15_000 }); + expect(page.url()).not.toMatch(/192\.168\./); + + await expect(page.locator(".host-card").first()).toBeVisible({ timeout: 15_000 }); + + await page.getByRole("button", { name: "Sign out" }).click(); + await expect(page.getByPlaceholder("Username")).toBeVisible(); +});