From 96074dee25a8f71afc240cf4b72fc6013a5b57fb Mon Sep 17 00:00:00 2001 From: CJFWeatherhead Date: Thu, 16 Apr 2026 12:24:37 +0100 Subject: [PATCH 1/8] fix: replace settings redirect with API key prompt modal Replace the onMount redirect to /settings with a modal overlay that prompts for API credentials on any page when they are missing. This fixes: - Browser refresh losing the current page (redirect to /settings) - Back button creating redirect loops - Direct URL access to routes showing blank pages Changes: - Add ApiKeyPrompt.svelte modal that validates credentials inline - Remove goto(/settings) redirect from +layout.svelte - Add adapter-static fallback (200.html) for unknown routes - Update Caddyfile try_files to serve index.html and SPA fallback - Replace placeholder test with 32 unit tests for funcs.ts utilities - Add CI workflow (lint, type-check, test, build, Docker smoke test) - Document authentication flow in README --- .github/workflows/ci.yml | 93 ++++++++++++ Caddyfile | 2 +- README.md | 12 ++ src/index.test.ts | 228 +++++++++++++++++++++++++++++- src/lib/parts/ApiKeyPrompt.svelte | 132 +++++++++++++++++ src/routes/+layout.svelte | 8 +- svelte.config.js | 4 +- 7 files changed, 468 insertions(+), 11 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 src/lib/parts/ApiKeyPrompt.svelte diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..9bc11c5 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,93 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +permissions: + contents: read + +jobs: + lint-and-check: + name: Lint & type-check + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: '20' + cache: npm + - run: npm ci + - run: npx svelte-kit sync + - run: npx svelte-check --tsconfig ./tsconfig.json + + test: + name: Unit tests + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: '20' + cache: npm + - run: npm ci + - run: npx vitest run + + build: + name: Build (static) + runs-on: ubuntu-latest + needs: [lint-and-check, test] + strategy: + matrix: + endpoint: ['', '/admin'] + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: '20' + cache: npm + - run: npm ci + - name: Build with ENDPOINT=${{ matrix.endpoint }} + env: + ENDPOINT: ${{ matrix.endpoint }} + run: npx vite build + - name: Verify pre-rendered routes exist + run: | + for route in index.html settings/index.html nodes/index.html users/index.html; do + path="build/${{ matrix.endpoint }}${route}" + # strip leading slash if ENDPOINT is empty + path="${path#/}" + test -f "$path" || { echo "MISSING: $path"; exit 1; } + echo "OK: $path" + done + - name: Verify SPA fallback exists + run: | + fallback="build/${{ matrix.endpoint }}200.html" + fallback="${fallback#/}" + test -f "$fallback" || { echo "MISSING: $fallback"; exit 1; } + echo "OK: $fallback" + + docker-build: + name: Docker build (smoke test) + runs-on: ubuntu-latest + needs: [lint-and-check, test] + steps: + - uses: actions/checkout@v4 + - name: Build Docker image + run: docker build -t headscale-admin:ci . + - name: Verify Caddy serves routes + run: | + docker run -d --name ha-ci -p 8080:80 headscale-admin:ci + sleep 2 + for path in /admin/ /admin/nodes/ /admin/settings/ /admin/users/; do + status=$(curl -s -o /dev/null -w '%{http_code}' "http://localhost:8080${path}") + if [ "$status" != "200" ]; then + echo "FAIL: $path returned $status" + docker logs ha-ci + exit 1 + fi + echo "OK: $path → $status" + done + docker rm -f ha-ci diff --git a/Caddyfile b/Caddyfile index 0dae9f3..f940458 100644 --- a/Caddyfile +++ b/Caddyfile @@ -1,5 +1,5 @@ :{$PORT:80} root * /app encode gzip zstd -try_files {path}.html {path} +try_files {path}.html {path} {path}/index.html /200.html file_server diff --git a/README.md b/README.md index ae91dab..06f70d8 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,18 @@ headscale-admin is still in active development and will evolve in tandem with he - No known issues at this time. +### Authentication Flow + +When headscale-admin is opened without stored API credentials, a login +prompt is shown as a modal overlay on top of whichever page was requested. +After entering valid credentials the modal dismisses and the user remains on +the original page — there is no redirect. + +Credentials are stored in the browser's `localStorage` and can be changed at +any time from the **Settings** page. If the stored key becomes invalid +during a session, a toast notification appears and the user can navigate to +Settings to correct it. + ### Securing headscale-admin Please note that headscale-admin is an entirely stateless application. The static files hosted on a server do not perform any interaction with the headscale API or backend. headscale-admin only provides the application scaffolding which facilitates interactions from the client's browser to the headscale API. There are no sessions, API tokens, or any other sensitive information passed to or from the web server hosting headscale-admin. For this reason, security beyond SSL certificates is unnecessary, though you may choose to do so simply for the sake of hiding the application. Any static headscale UI offers functionality that can be trivially replicated with cURL or other web request utilities. **The security of headscale lies in your API token being securely preserved.** That said, here are some common recommendations: diff --git a/src/index.test.ts b/src/index.test.ts index e07cbbd..2708efc 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -1,7 +1,229 @@ import { describe, it, expect } from 'vitest'; +import { + arraysEqual, + clone, + isExpired, + isValidTag, + isValidCIDR, + setsEqual, + getTimeDifference, + getTime, + getTimeDifferenceColor, + deduplicate, + dateToStr, + getSortedUsers, + getSortedNodes, +} from '$lib/common/funcs'; +import type { User, Node } from '$lib/common/types'; -describe('sum test', () => { - it('adds 1 + 2 to equal 3', () => { - expect(1 + 2).toBe(3); +// ---- clone ---- + +describe('clone', () => { + it('produces a deep copy', () => { + const original = { a: 1, nested: { b: 2 } }; + const copy = clone(original); + expect(copy).toEqual(original); + copy.nested.b = 99; + expect(original.nested.b).toBe(2); + }); +}); + +// ---- arraysEqual ---- + +describe('arraysEqual', () => { + it('returns true for identical primitive arrays', () => { + expect(arraysEqual([1, 2, 3], [1, 2, 3])).toBe(true); + }); + + it('returns false when lengths differ', () => { + expect(arraysEqual([1], [1, 2])).toBe(false); + }); + + it('returns false when values differ', () => { + expect(arraysEqual([1, 2], [1, 3])).toBe(false); + }); + + it('returns true for empty arrays', () => { + expect(arraysEqual([], [])).toBe(true); + }); +}); + +// ---- setsEqual ---- + +describe('setsEqual', () => { + it('returns true for identical sets', () => { + expect(setsEqual(new Set([1, 2]), new Set([2, 1]))).toBe(true); + }); + + it('returns false when sizes differ', () => { + expect(setsEqual(new Set([1]), new Set([1, 2]))).toBe(false); + }); + + it('returns false when values differ', () => { + expect(setsEqual(new Set([1, 3]), new Set([1, 2]))).toBe(false); + }); +}); + +// ---- isExpired ---- + +describe('isExpired', () => { + it('treats a past date as expired', () => { + expect(isExpired('2000-01-01T00:00:00Z')).toBe(true); + }); + + it('treats a future date as not expired', () => { + const future = new Date(Date.now() + 86_400_000).toISOString(); + expect(isExpired(future)).toBe(false); + }); + + it('treats the zero date as never-expiring', () => { + expect(isExpired('0001-01-01T00:00:00Z')).toBe(false); + }); +}); + +// ---- isValidTag ---- + +describe('isValidTag', () => { + it('accepts lowercase alphanumeric with dashes/underscores', () => { + expect(isValidTag('my-tag_1')).toBe(true); + }); + + it('rejects uppercase', () => { + expect(isValidTag('MyTag')).toBe(false); + }); + + it('rejects spaces', () => { + expect(isValidTag('my tag')).toBe(false); + }); + + it('rejects empty string', () => { + expect(isValidTag('')).toBe(false); + }); +}); + +// ---- isValidCIDR ---- + +describe('isValidCIDR', () => { + it('accepts a valid IPv4 CIDR', () => { + expect(isValidCIDR('10.0.0.0/8')).toBe(true); + }); + + it('rejects an address with host bits set', () => { + expect(isValidCIDR('10.0.0.1/8')).toBe(false); + }); + + it('rejects non-CIDR strings', () => { + expect(isValidCIDR('not-a-cidr')).toBe(false); + }); +}); + +// ---- getTimeDifference ---- + +describe('getTimeDifference', () => { + it('reports a future time correctly', () => { + const future = Date.now() + 3_600_000; // +1 hour + const result = getTimeDifference(future); + expect(result.future).toBe(true); + expect(result.finite).toBe(true); + expect(result.message).toContain('hour'); + }); + + it('reports a past time correctly', () => { + const past = Date.now() - 86_400_000 * 3; // -3 days + const result = getTimeDifference(past); + expect(result.future).toBe(false); + expect(result.finite).toBe(true); + expect(result.message).toContain('day'); + }); + + it('treats the infinite date as non-finite', () => { + const result = getTimeDifference(new Date('0001-01-01T00:00:00Z').getTime()); + expect(result.finite).toBe(false); + }); +}); + +// ---- getTimeDifferenceColor ---- + +describe('getTimeDifferenceColor', () => { + it('returns success colour for future finite times', () => { + const color = getTimeDifferenceColor({ future: true, finite: true, message: '' }); + expect(color).toContain('success'); + }); + + it('returns error colour for past finite times', () => { + const color = getTimeDifferenceColor({ future: false, finite: true, message: '' }); + expect(color).toContain('error'); + }); +}); + +// ---- deduplicate ---- + +describe('deduplicate', () => { + it('removes duplicate values', () => { + expect(deduplicate([1, 2, 2, 3, 3, 3])).toEqual([1, 2, 3]); + }); + + it('returns empty for empty', () => { + expect(deduplicate([])).toEqual([]); + }); +}); + +// ---- dateToStr ---- + +describe('dateToStr', () => { + it('formats a Date object to a string', () => { + const result = dateToStr(new Date('2024-06-15T14:30:00Z')); + expect(typeof result).toBe('string'); + expect(result.length).toBeGreaterThan(0); + }); + + it('accepts an ISO string', () => { + const result = dateToStr('2024-06-15T14:30:00Z'); + expect(typeof result).toBe('string'); + }); +}); + +// ---- getSortedUsers ---- + +describe('getSortedUsers', () => { + const users = [ + { id: '2', name: 'bravo' }, + { id: '1', name: 'alpha' }, + { id: '3', name: 'charlie' }, + ] as User[]; + + it('sorts by id ascending', () => { + const sorted = getSortedUsers([...users], 'id', 'up'); + expect(sorted.map((u) => u.id)).toEqual(['1', '2', '3']); + }); + + it('sorts by name ascending', () => { + const sorted = getSortedUsers([...users], 'name', 'up'); + expect(sorted.map((u) => u.name)).toEqual(['alpha', 'bravo', 'charlie']); + }); + + it('reverses when direction is down', () => { + const sorted = getSortedUsers([...users], 'id', 'down'); + expect(sorted.map((u) => u.id)).toEqual(['3', '2', '1']); + }); +}); + +// ---- getSortedNodes ---- + +describe('getSortedNodes', () => { + const nodes = [ + { id: '2', givenName: 'beta', user: { name: 'b' } }, + { id: '1', givenName: 'alpha', user: { name: 'a' } }, + { id: '3', givenName: 'gamma', user: { name: 'c' } }, + ] as unknown as Node[]; + + it('sorts by id ascending', () => { + const sorted = getSortedNodes([...nodes], 'id', 'up'); + expect(sorted.map((n) => n.id)).toEqual(['1', '2', '3']); + }); + + it('sorts by givenName ascending', () => { + const sorted = getSortedNodes([...nodes], 'name', 'up'); + expect(sorted.map((n) => n.givenName)).toEqual(['alpha', 'beta', 'gamma']); }); }); diff --git a/src/lib/parts/ApiKeyPrompt.svelte b/src/lib/parts/ApiKeyPrompt.svelte new file mode 100644 index 0000000..d7fa1ce --- /dev/null +++ b/src/lib/parts/ApiKeyPrompt.svelte @@ -0,0 +1,132 @@ + + +{#if !App.hasApi} +
+
+
+

Headscale-Admin

+

{version}

+

Enter your Headscale API credentials to continue.

+
+ +
+ + + + + {#if error} + + {/if} + + +
+
+
+{/if} diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index 7970906..9c82255 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -14,8 +14,7 @@ type DrawerSettings, } from '@skeletonlabs/skeleton'; - import { base } from '$app/paths'; - import { goto } from '$app/navigation'; + import ApiKeyPrompt from '$lib/parts/ApiKeyPrompt.svelte'; initializeStores(); @@ -54,16 +53,13 @@ onMount(() => { setTheme(App.theme.value || 'skeleton') App.populateAll(createPopulateErrorHandler(ToastStore), true) - - if (!App.hasValidApi) { - goto(`${base}/settings`); - } }); + diff --git a/svelte.config.js b/svelte.config.js index 7e92e3d..240a654 100644 --- a/svelte.config.js +++ b/svelte.config.js @@ -14,7 +14,9 @@ const config = { // adapter-auto only supports some environments, see https://kit.svelte.dev/docs/adapter-auto for a list. // If your environment is not supported or you settled on a specific environment, switch out the adapter. // See https://kit.svelte.dev/docs/adapters for more information about adapters. - adapter: adapter(), + adapter: adapter({ + fallback: '200.html', + }), csrf: { checkOrigin: false, }, From 9764997ede7e2be28a70ce8255bdde62b09a8992 Mon Sep 17 00:00:00 2001 From: CJFWeatherhead Date: Thu, 16 Apr 2026 12:39:48 +0100 Subject: [PATCH 2/8] fix(ci): correct path construction for matrix endpoint build verification --- .github/workflows/ci.yml | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9bc11c5..fc379e8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -55,17 +55,19 @@ jobs: run: npx vite build - name: Verify pre-rendered routes exist run: | + endpoint="${{ matrix.endpoint }}" + endpoint="${endpoint#/}" + prefix="build/${endpoint:+${endpoint}/}" for route in index.html settings/index.html nodes/index.html users/index.html; do - path="build/${{ matrix.endpoint }}${route}" - # strip leading slash if ENDPOINT is empty - path="${path#/}" + path="${prefix}${route}" test -f "$path" || { echo "MISSING: $path"; exit 1; } echo "OK: $path" done - name: Verify SPA fallback exists run: | - fallback="build/${{ matrix.endpoint }}200.html" - fallback="${fallback#/}" + endpoint="${{ matrix.endpoint }}" + endpoint="${endpoint#/}" + fallback="build/${endpoint:+${endpoint}/}200.html" test -f "$fallback" || { echo "MISSING: $fallback"; exit 1; } echo "OK: $fallback" From 71095e43ad37e64c3b6656dd23787d4af452d8fb Mon Sep 17 00:00:00 2001 From: CJFWeatherhead Date: Thu, 16 Apr 2026 13:24:52 +0100 Subject: [PATCH 3/8] fix(ci): always check build/ root for pre-rendered routes regardless of ENDPOINT --- .github/workflows/ci.yml | 15 ++++----------- src/lib/common/debug.ts | 2 +- 2 files changed, 5 insertions(+), 12 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fc379e8..d950f60 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -55,21 +55,14 @@ jobs: run: npx vite build - name: Verify pre-rendered routes exist run: | - endpoint="${{ matrix.endpoint }}" - endpoint="${endpoint#/}" - prefix="build/${endpoint:+${endpoint}/}" for route in index.html settings/index.html nodes/index.html users/index.html; do - path="${prefix}${route}" - test -f "$path" || { echo "MISSING: $path"; exit 1; } - echo "OK: $path" + test -f "build/${route}" || { echo "MISSING: build/${route}"; exit 1; } + echo "OK: build/${route}" done - name: Verify SPA fallback exists run: | - endpoint="${{ matrix.endpoint }}" - endpoint="${endpoint#/}" - fallback="build/${endpoint:+${endpoint}/}200.html" - test -f "$fallback" || { echo "MISSING: $fallback"; exit 1; } - echo "OK: $fallback" + test -f "build/200.html" || { echo "MISSING: build/200.html"; exit 1; } + echo "OK: build/200.html" docker-build: name: Docker build (smoke test) diff --git a/src/lib/common/debug.ts b/src/lib/common/debug.ts index 866af14..e07f65a 100644 --- a/src/lib/common/debug.ts +++ b/src/lib/common/debug.ts @@ -1,6 +1,6 @@ import { App } from "$lib/States.svelte"; -export const version = '0.28.0-pi-yolobuild-alpha1'; +export const version = '0.28.0/update3'; export function debug(...data: unknown[]) { // output if console debugging is enabled From 31321b3e289d628fad9e208bf32de2468eec24bd Mon Sep 17 00:00:00 2001 From: CJFWeatherhead Date: Thu, 16 Apr 2026 13:27:22 +0100 Subject: [PATCH 4/8] fix(ci): remove pointless endpoint matrix from build job --- .github/workflows/ci.yml | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d950f60..8190bf0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -39,9 +39,6 @@ jobs: name: Build (static) runs-on: ubuntu-latest needs: [lint-and-check, test] - strategy: - matrix: - endpoint: ['', '/admin'] steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 @@ -49,10 +46,7 @@ jobs: node-version: '20' cache: npm - run: npm ci - - name: Build with ENDPOINT=${{ matrix.endpoint }} - env: - ENDPOINT: ${{ matrix.endpoint }} - run: npx vite build + - run: npx vite build - name: Verify pre-rendered routes exist run: | for route in index.html settings/index.html nodes/index.html users/index.html; do From c0ff23dc14dd5ad7f8b20fa2d8d4dcb639157ba6 Mon Sep 17 00:00:00 2001 From: CJFWeatherhead Date: Thu, 16 Apr 2026 15:50:40 +0100 Subject: [PATCH 5/8] feat: add mock API server, E2E tests, and fix Docker SPA fallback - Add e2e/mock-api.mjs: lightweight mock Headscale API server returning fixture data matching openapiv2.json types (users, nodes, preauth keys, API keys, policy). Validates Bearer token auth. - Add e2e/navigation.spec.ts: 25 Playwright E2E tests covering: - Unauthenticated: prompt appears on all pages, invalid key rejected - Auth flow: credentials dismiss modal, user stays on requested page - Authenticated navigation: all routes render real content - Refresh: preserves current page - Back button: forward/back navigation works correctly - Direct URL access: no white screen on any route - Fix Caddyfile: use {ENDPOINT}/200.html for SPA fallback so it resolves correctly when build output is inside /app/admin/ - Fix Dockerfile: export ENDPOINT env var to Caddy stage so Caddyfile can reference it - Add Playwright to devDependencies and test:e2e script - Add E2E job to CI workflow - Add Docker smoke test for /admin/nonexistent/ (fallback) - Add Playwright artifacts to .gitignore --- .github/workflows/ci.yml | 16 ++- .gitignore | 4 + Caddyfile | 2 +- Dockerfile | 1 + e2e/mock-api.mjs | 248 +++++++++++++++++++++++++++++++++++++ e2e/navigation.spec.ts | 257 +++++++++++++++++++++++++++++++++++++++ package-lock.json | 64 ++++++++++ package.json | 2 + playwright.config.ts | 24 ++++ 9 files changed, 616 insertions(+), 2 deletions(-) create mode 100644 e2e/mock-api.mjs create mode 100644 e2e/navigation.spec.ts create mode 100644 playwright.config.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8190bf0..03a48a4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -70,7 +70,7 @@ jobs: run: | docker run -d --name ha-ci -p 8080:80 headscale-admin:ci sleep 2 - for path in /admin/ /admin/nodes/ /admin/settings/ /admin/users/; do + for path in /admin/ /admin/nodes/ /admin/settings/ /admin/users/ /admin/nonexistent/; do status=$(curl -s -o /dev/null -w '%{http_code}' "http://localhost:8080${path}") if [ "$status" != "200" ]; then echo "FAIL: $path returned $status" @@ -80,3 +80,17 @@ jobs: echo "OK: $path → $status" done docker rm -f ha-ci + + e2e: + name: E2E tests + runs-on: ubuntu-latest + needs: [lint-and-check, test] + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: '20' + cache: npm + - run: npm ci + - run: npx playwright install chromium --with-deps + - run: npx playwright test diff --git a/.gitignore b/.gitignore index a329fe3..c807dd6 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,10 @@ TRAEFIK .svelte-kit build/ +# Playwright +test-results/ +playwright-report/ + # Logs logs *.log diff --git a/Caddyfile b/Caddyfile index f940458..9aa1796 100644 --- a/Caddyfile +++ b/Caddyfile @@ -1,5 +1,5 @@ :{$PORT:80} root * /app encode gzip zstd -try_files {path}.html {path} {path}/index.html /200.html +try_files {path}.html {path} {path}/index.html {$ENDPOINT:/}/200.html file_server diff --git a/Dockerfile b/Dockerfile index 9ab71dd..499933f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -37,6 +37,7 @@ FROM caddy:latest ARG ENDPOINT ARG PORT ENV PORT=${PORT} +ENV ENDPOINT=${ENDPOINT} WORKDIR /app diff --git a/e2e/mock-api.mjs b/e2e/mock-api.mjs new file mode 100644 index 0000000..d2a8b88 --- /dev/null +++ b/e2e/mock-api.mjs @@ -0,0 +1,248 @@ +/** + * Lightweight mock Headscale API server. + * + * Responds to the endpoints the headscale-admin frontend actually calls, + * returning realistic fixture data that matches the openapiv2.json schema + * and the TypeScript types in src/lib/common/types.ts. + * + * Usage: + * node e2e/mock-api.mjs # listens on :8081 + * MOCK_PORT=9999 node e2e/mock-api.mjs + * + * Auth: + * Any request with `Authorization: Bearer test-api-key` is authorised. + * Anything else gets 401 "Unauthorized". + */ + +import { createServer } from 'node:http'; + +const PORT = parseInt(process.env.MOCK_PORT ?? '8081', 10); +const VALID_KEY = 'test-api-key'; + +// ── Fixture data ──────────────────────────────────────────────────────────── + +const users = [ + { + id: '1', + name: 'alice', + createdAt: '2025-01-01T00:00:00Z', + displayName: 'Alice', + email: '', + providerId: '', + provider: 'local', + profilePicUrl: '', + }, + { + id: '2', + name: 'bob', + createdAt: '2025-02-01T00:00:00Z', + displayName: 'Bob', + email: '', + providerId: '', + provider: 'local', + profilePicUrl: '', + }, +]; + +const nodes = [ + { + id: '1', + machineKey: 'mkey:abc123', + nodeKey: 'nodekey:def456', + discoKey: 'discokey:ghi789', + ipAddresses: ['100.64.0.1', 'fd7a:115c:a1e0::1'], + name: 'alice-laptop', + user: users[0], + lastSeen: new Date().toISOString(), + expiry: '0001-01-01T00:00:00Z', + preAuthKey: null, + createdAt: '2025-01-10T00:00:00Z', + registerMethod: 'REGISTER_METHOD_CLI', + givenName: 'alice-laptop', + online: true, + approvedRoutes: [], + availableRoutes: ['10.0.0.0/24'], + subnetRoutes: [], + tags: [], + }, + { + id: '2', + machineKey: 'mkey:xyz321', + nodeKey: 'nodekey:uvw654', + discoKey: 'discokey:rst987', + ipAddresses: ['100.64.0.2', 'fd7a:115c:a1e0::2'], + name: 'bob-server', + user: users[1], + lastSeen: new Date(Date.now() - 86_400_000).toISOString(), + expiry: '0001-01-01T00:00:00Z', + preAuthKey: null, + createdAt: '2025-02-10T00:00:00Z', + registerMethod: 'REGISTER_METHOD_AUTH_KEY', + givenName: 'bob-server', + online: false, + approvedRoutes: ['10.1.0.0/24'], + availableRoutes: ['10.1.0.0/24', '10.2.0.0/24'], + subnetRoutes: [], + tags: ['tag:server'], + }, +]; + +const preAuthKeys = [ + { + user: users[0], + id: '1', + key: 'pak_alice_0001', + reusable: false, + ephemeral: false, + used: false, + expiration: new Date(Date.now() + 86_400_000 * 30).toISOString(), + createdAt: '2025-03-01T00:00:00Z', + aclTags: [], + }, +]; + +const apiKeys = [ + { + id: '1', + createdAt: '2025-01-01T00:00:00Z', + prefix: 'test-api-key'.slice(0, 10), + expiration: new Date(Date.now() + 86_400_000 * 90).toISOString(), + lastSeen: new Date().toISOString(), + }, +]; + +const policy = JSON.stringify({ + groups: { 'group:admin': ['alice'] }, + hosts: {}, + acls: [{ action: 'accept', src: ['group:admin'], dst: ['*:*'] }], + tagOwners: {}, + ssh: [], +}); + +// ── Router ────────────────────────────────────────────────────────────────── + +function isAuthorised(req) { + const auth = req.headers['authorization'] ?? ''; + return auth === `Bearer ${VALID_KEY}`; +} + +function json(res, data, status = 200) { + res.writeHead(status, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' }); + res.end(JSON.stringify(data)); +} + +function unauthorized(res) { + res.writeHead(401, { 'Content-Type': 'text/plain', 'Access-Control-Allow-Origin': '*' }); + res.end('Unauthorized'); +} + +function notFound(res) { + res.writeHead(404, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' }); + res.end(JSON.stringify({ code: 5, message: 'Not Found', details: [] })); +} + +function cors(res) { + res.writeHead(204, { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Methods': 'GET, POST, PUT, PATCH, DELETE, OPTIONS', + 'Access-Control-Allow-Headers': 'Authorization, Content-Type, Accept', + 'Access-Control-Max-Age': '3600', + }); + res.end(); +} + +const server = createServer((req, res) => { + const url = new URL(req.url, `http://localhost:${PORT}`); + const path = url.pathname.replace(/\/+$/, '') || '/'; + const method = req.method.toUpperCase(); + + // CORS preflight + if (method === 'OPTIONS') { + return cors(res); + } + + // Auth check + if (!isAuthorised(req)) { + return unauthorized(res); + } + + // ── GET endpoints ─────────────────────────────────────────────────────── + if (method === 'GET') { + if (path === '/api/v1/user') return json(res, { users }); + if (path === '/api/v1/node') return json(res, { nodes }); + if (path === '/api/v1/preauthkey') return json(res, { preAuthKeys }); + if (path === '/api/v1/apikey') return json(res, { apiKeys }); + if (path === '/api/v1/policy') return json(res, { policy, updatedAt: new Date().toISOString() }); + + // Single node GET: /api/v1/node/{id} + const nodeMatch = path.match(/^\/api\/v1\/node\/(\d+)$/); + if (nodeMatch) { + const node = nodes.find((n) => n.id === nodeMatch[1]); + return node ? json(res, { node }) : notFound(res); + } + } + + // ── POST endpoints ────────────────────────────────────────────────────── + if (method === 'POST') { + if (path === '/api/v1/user') return json(res, { user: { ...users[0], id: '99', name: 'new-user' } }); + if (path === '/api/v1/preauthkey') return json(res, { preAuthKey: preAuthKeys[0] }); + if (path === '/api/v1/preauthkey/expire') return json(res, {}); + if (path === '/api/v1/apikey') return json(res, { apiKey: 'new-api-key-value' }); + if (path === '/api/v1/apikey/expire') return json(res, {}); + + // Node routes, rename, expire, tags + const approveMatch = path.match(/^\/api\/v1\/node\/(\d+)\/approve_routes$/); + if (approveMatch) { + const node = nodes.find((n) => n.id === approveMatch[1]); + if (node) return json(res, { node: { ...node } }); + return notFound(res); + } + const expireMatch = path.match(/^\/api\/v1\/node\/(\d+)\/expire$/); + if (expireMatch) { + const node = nodes.find((n) => n.id === expireMatch[1]); + if (node) return json(res, { node: { ...node, online: false } }); + return notFound(res); + } + const renameNodeMatch = path.match(/^\/api\/v1\/node\/(\d+)\/rename\/(.+)$/); + if (renameNodeMatch) { + const node = nodes.find((n) => n.id === renameNodeMatch[1]); + if (node) return json(res, { node: { ...node, givenName: renameNodeMatch[2] } }); + return notFound(res); + } + const tagsMatch = path.match(/^\/api\/v1\/node\/(\d+)\/tags$/); + if (tagsMatch) { + const node = nodes.find((n) => n.id === tagsMatch[1]); + if (node) return json(res, { node: { ...node } }); + return notFound(res); + } + const renameUserMatch = path.match(/^\/api\/v1\/user\/(\d+)\/rename\/(.+)$/); + if (renameUserMatch) { + const user = users.find((u) => u.id === renameUserMatch[1]); + if (user) return json(res, { user: { ...user, name: renameUserMatch[2] } }); + return notFound(res); + } + // Node register: /api/v1/node/register?user=...&key=... + if (path === '/api/v1/node/register') { + return json(res, { node: { ...nodes[0], id: '99', givenName: 'new-node' } }); + } + } + + // ── PUT endpoints ─────────────────────────────────────────────────────── + if (method === 'PUT') { + if (path === '/api/v1/policy') return json(res, { policy, updatedAt: new Date().toISOString() }); + } + + // ── DELETE endpoints ──────────────────────────────────────────────────── + if (method === 'DELETE') { + const deleteUserMatch = path.match(/^\/api\/v1\/user\/(\d+)$/); + if (deleteUserMatch) return json(res, {}); + const deleteNodeMatch = path.match(/^\/api\/v1\/node\/(\d+)$/); + if (deleteNodeMatch) return json(res, {}); + } + + return notFound(res); +}); + +server.listen(PORT, '0.0.0.0', () => { + console.log(`Mock Headscale API running on http://0.0.0.0:${PORT}`); +}); diff --git a/e2e/navigation.spec.ts b/e2e/navigation.spec.ts new file mode 100644 index 0000000..65e0f1e --- /dev/null +++ b/e2e/navigation.spec.ts @@ -0,0 +1,257 @@ +import { test, expect, type Page } from '@playwright/test'; + +const MOCK_URL = 'http://localhost:8081'; +const API_KEY = 'test-api-key'; + +// ── Helpers ────────────────────────────────────────────────────────────────── + +/** Clear localStorage so we start from an unauthenticated state. */ +async function clearAuth(page: Page) { + await page.evaluate(() => { + localStorage.removeItem('apiUrl'); + localStorage.removeItem('apiKey'); + localStorage.removeItem('apiKeyInfo'); + }); +} + +/** Fill in the API key prompt modal and submit. */ +async function authenticate(page: Page) { + await page.getByRole('textbox', { name: 'API URL' }).fill(MOCK_URL); + await page.getByRole('textbox', { name: 'API Key' }).fill(API_KEY); + await page.getByRole('button', { name: /connect/i }).click(); +} + +/** Pre-seed localStorage so the page loads already authenticated. */ +async function seedAuth(page: Page) { + // Visit a page first so we can inject into its storage origin + await page.goto('/'); + await page.evaluate( + ([url, key]) => { + localStorage.setItem('apiUrl', JSON.stringify(url)); + localStorage.setItem('apiKey', JSON.stringify(key)); + localStorage.setItem( + 'apiKeyInfo', + JSON.stringify({ + authorized: true, + expires: new Date(Date.now() + 86_400_000 * 90).toISOString(), + informedUnauthorized: false, + informedExpiringSoon: false, + }), + ); + }, + [MOCK_URL, API_KEY], + ); + // Reload so StateLocal reads the seeded values + await page.reload(); + // Wait for the app shell to be ready + await page.locator('[data-testid="app-shell"]').waitFor({ timeout: 10000 }); +} + +// Collect console errors for debugging +test.beforeEach(async ({ page }) => { + page.on('console', (msg) => { + if (msg.type() === 'error') { + console.log(`[BROWSER ERROR] ${msg.text()}`); + } + }); + page.on('pageerror', (err) => { + console.log(`[PAGE ERROR] ${err.message}`); + }); +}); + +// ── 1. Unauthenticated: prompt appears ─────────────────────────────────────── + +test.describe('unauthenticated', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/'); + await clearAuth(page); + await page.reload(); + }); + + test('root shows API key prompt modal', async ({ page }) => { + await expect(page.getByRole('textbox', { name: 'API Key' })).toBeVisible(); + await expect(page.getByRole('button', { name: /connect/i })).toBeVisible(); + }); + + test('prompt appears on /nodes/ too', async ({ page }) => { + await page.goto('/nodes/'); + await expect(page.getByRole('textbox', { name: 'API Key' })).toBeVisible(); + }); + + test('prompt appears on /users/', async ({ page }) => { + await page.goto('/users/'); + await expect(page.getByRole('textbox', { name: 'API Key' })).toBeVisible(); + }); + + test('prompt appears on /settings/', async ({ page }) => { + await page.goto('/settings/'); + // Settings page also has an API Key input, so scope to the modal + await expect(page.getByRole('button', { name: /connect/i })).toBeVisible(); + }); + + test('invalid API key shows error', async ({ page }) => { + await page.goto('/'); + await page.getByRole('textbox', { name: 'API URL' }).fill(MOCK_URL); + await page.getByRole('textbox', { name: 'API Key' }).fill('wrong-key'); + await page.getByRole('button', { name: /connect/i }).click(); + await expect(page.getByText(/invalid api key/i)).toBeVisible({ timeout: 5000 }); + }); +}); + +// ── 2. Authentication flow ──────────────────────────────────────────────────── + +test.describe('authentication flow', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/'); + await clearAuth(page); + await page.reload(); + }); + + test('valid credentials dismiss the modal and show dashboard', async ({ page }) => { + await authenticate(page); + // Modal should disappear + await expect(page.getByRole('textbox', { name: 'API Key' })).not.toBeVisible({ timeout: 10000 }); + // Dashboard summary tiles should appear + await expect(page.getByText(/total users/i)).toBeVisible({ timeout: 10000 }); + }); + + test('authenticating on /nodes/ stays on nodes page', async ({ page }) => { + await page.goto('/nodes/'); + await authenticate(page); + await expect(page.getByRole('textbox', { name: 'API Key' })).not.toBeVisible({ timeout: 10000 }); + // Should still be on /nodes/, not redirected elsewhere + await expect(page).toHaveURL(/\/nodes\/?/); + }); + + test('authenticating on /users/ stays on users page', async ({ page }) => { + await page.goto('/users/'); + await authenticate(page); + await expect(page.getByRole('textbox', { name: 'API Key' })).not.toBeVisible({ timeout: 10000 }); + await expect(page).toHaveURL(/\/users\/?/); + }); +}); + +// ── 3. Authenticated navigation ────────────────────────────────────────────── + +test.describe('authenticated navigation', () => { + test.beforeEach(async ({ page }) => { + await seedAuth(page); + }); + + test('/ loads dashboard with summary data', async ({ page }) => { + await page.goto('/'); + await expect(page.getByText(/total users/i)).toBeVisible({ timeout: 10000 }); + await expect(page.getByText(/total nodes/i)).toBeVisible(); + }); + + test('/nodes/ renders node list', async ({ page }) => { + await page.goto('/nodes/'); + // Node from fixture data + await expect(page.getByText('alice-laptop')).toBeVisible({ timeout: 10000 }); + }); + + test('/users/ renders user list', async ({ page }) => { + await page.goto('/users/'); + await expect(page.getByText('alice')).toBeVisible({ timeout: 10000 }); + }); + + test('/settings/ renders settings form', async ({ page }) => { + await page.goto('/settings/'); + await expect(page.getByText(/api url/i)).toBeVisible({ timeout: 10000 }); + }); + + test('/deploy/ renders deploy page', async ({ page }) => { + await page.goto('/deploy/'); + await expect(page.locator('[data-testid="app-shell"]')).toBeVisible({ timeout: 10000 }); + }); + + test('/routes/ renders routes page', async ({ page }) => { + await page.goto('/routes/'); + await expect(page.locator('[data-testid="app-shell"]')).toBeVisible({ timeout: 10000 }); + }); +}); + +// ── 4. Refresh behaviour ───────────────────────────────────────────────────── + +test.describe('refresh', () => { + test.beforeEach(async ({ page }) => { + await seedAuth(page); + }); + + test('refreshing /nodes/ keeps you on nodes page', async ({ page }) => { + await page.goto('/nodes/'); + await expect(page.getByText('alice-laptop')).toBeVisible({ timeout: 10000 }); + await page.reload(); + await expect(page).toHaveURL(/\/nodes\/?/); + await expect(page.getByText('alice-laptop')).toBeVisible({ timeout: 10000 }); + }); + + test('refreshing /users/ keeps you on users page', async ({ page }) => { + await page.goto('/users/'); + await expect(page.getByText('alice')).toBeVisible({ timeout: 10000 }); + await page.reload(); + await expect(page).toHaveURL(/\/users\/?/); + await expect(page.getByText('alice')).toBeVisible({ timeout: 10000 }); + }); + + test('refreshing / keeps you on dashboard', async ({ page }) => { + await page.goto('/'); + await expect(page.getByText(/total users/i)).toBeVisible({ timeout: 10000 }); + await page.reload(); + await expect(page.getByText(/total users/i)).toBeVisible({ timeout: 10000 }); + }); +}); + +// ── 5. Back button behaviour ───────────────────────────────────────────────── + +test.describe('back button', () => { + test.beforeEach(async ({ page }) => { + await seedAuth(page); + }); + + test('navigating forward and back preserves pages', async ({ page }) => { + await page.goto('/'); + await expect(page.getByText(/total users/i)).toBeVisible({ timeout: 10000 }); + + // Navigate to nodes via sidebar link + await page.getByRole('link', { name: /nodes/i }).first().click(); + await expect(page).toHaveURL(/\/nodes\/?/); + await expect(page.getByText('alice-laptop')).toBeVisible({ timeout: 10000 }); + + // Navigate to users + await page.getByRole('link', { name: /users/i }).first().click(); + await expect(page).toHaveURL(/\/users\/?/); + await expect(page.getByText('alice (Alice)')).toBeVisible({ timeout: 10000 }); + + // Go back to nodes + await page.goBack(); + await expect(page).toHaveURL(/\/nodes\/?/); + await expect(page.getByText('alice-laptop').first()).toBeVisible({ timeout: 10000 }); + + // Go back to dashboard + await page.goBack(); + await expect(page).toHaveURL(/\/$/); + await expect(page.getByText(/total users/i)).toBeVisible({ timeout: 10000 }); + }); +}); + +// ── 6. No white screen on direct URL access ────────────────────────────────── + +test.describe('direct URL access (no white screen)', () => { + test.beforeEach(async ({ page }) => { + await seedAuth(page); + }); + + const routes = ['/', '/nodes/', '/users/', '/settings/', '/deploy/', '/routes/', '/acls/']; + + for (const route of routes) { + test(`${route} renders content (not blank)`, async ({ page }) => { + await page.goto(route); + // The AppShell should be visible — if we get a white screen this fails + await expect(page.locator('[data-testid="app-shell"]')).toBeVisible({ timeout: 10000 }); + // Body should have meaningful content + const bodyText = await page.locator('body').innerText(); + expect(bodyText.trim().length).toBeGreaterThan(0); + }); + } +}); diff --git a/package-lock.json b/package-lock.json index 9d7e9e4..1b7d371 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,6 +20,7 @@ }, "devDependencies": { "@iconify/json": "^2.2.311", + "@playwright/test": "^1.59.1", "@skeletonlabs/skeleton": "^2.11.0", "@skeletonlabs/tw-plugin": "^0.4.1", "@sveltejs/adapter-auto": "^4.0.0", @@ -1428,6 +1429,22 @@ "node": ">=14" } }, + "node_modules/@playwright/test": { + "version": "1.59.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.59.1.tgz", + "integrity": "sha512-PG6q63nQg5c9rIi4/Z5lR5IVF7yU5MqmKaPOe0HSc0O2cX1fPi96sUQu5j7eo4gKCkB2AnNGoWt7y4/Xx3Kcqg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.59.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@polka/url": { "version": "1.0.0-next.28", "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.28.tgz", @@ -4414,6 +4431,53 @@ "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", "license": "MIT" }, + "node_modules/playwright": { + "version": "1.59.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.59.1.tgz", + "integrity": "sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.59.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.59.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.59.1.tgz", + "integrity": "sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg==", + "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.5.3", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.3.tgz", diff --git a/package.json b/package.json index 6540639..89ece44 100644 --- a/package.json +++ b/package.json @@ -9,11 +9,13 @@ "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", "test": "vitest", + "test:e2e": "npx playwright test", "lint": "prettier --plugin-search-dir . --check . && eslint .", "format": "prettier --plugin-search-dir . --write ." }, "devDependencies": { "@iconify/json": "^2.2.311", + "@playwright/test": "^1.59.1", "@skeletonlabs/skeleton": "^2.11.0", "@skeletonlabs/tw-plugin": "^0.4.1", "@sveltejs/adapter-auto": "^4.0.0", diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 0000000..c65015b --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,24 @@ +import { defineConfig } from '@playwright/test'; + +export default defineConfig({ + testDir: 'e2e', + testMatch: '**/*.spec.ts', + timeout: 30_000, + retries: 0, + use: { + baseURL: 'http://localhost:5173', + headless: true, + }, + webServer: [ + { + command: 'node e2e/mock-api.mjs', + port: 8081, + reuseExistingServer: !process.env.CI, + }, + { + command: 'npx vite dev --port 5173', + port: 5173, + reuseExistingServer: !process.env.CI, + }, + ], +}); From 78023c6f8614008d14fbcf2fb1d1fab49c50bc36 Mon Sep 17 00:00:00 2001 From: CJFWeatherhead Date: Thu, 16 Apr 2026 15:55:46 +0100 Subject: [PATCH 6/8] fix: exclude e2e/ from vitest test discovery --- vite.config.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/vite.config.ts b/vite.config.ts index 617b55b..2babd1e 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -12,4 +12,7 @@ export default defineConfig({ compiler: 'svelte', }), ], + test: { + exclude: ['e2e/**', 'node_modules/**'], + }, }); From d141d59c8b948c8322ed70db8d7814a25a09c018 Mon Sep 17 00:00:00 2001 From: CJFWeatherhead Date: Thu, 16 Apr 2026 16:43:28 +0100 Subject: [PATCH 7/8] fix: add +error.svelte, Docker E2E tests, and /healthz on mock API - Add src/routes/+error.svelte: replaces SvelteKit's blank default error page with a styled fallback (status code + message + 'Go to dashboard' button) so any routing error produces a visible screen, not a blank page. - Add e2e/docker.spec.ts: 10 Playwright tests that run against the production Docker build (Caddy + /admin base path). Covers refresh, direct URL access, unauthenticated modal, and post-auth refresh. - Add playwright.docker.config.ts: separate Playwright config for the Docker suite; baseURL set to http://localhost:8080/admin/ (with trailing slash) so relative paths like './nodes/' resolve to /admin/nodes/. - Add e2e-docker CI job: builds the Docker image, starts the container + mock API, waits for both to be ready via curl retry, runs the Docker E2E suite, then tears down. - Add /healthz endpoint to mock-api.mjs for CI readiness checks. --- .github/workflows/ci.yml | 42 +++++++++++- e2e/docker.spec.ts | 123 ++++++++++++++++++++++++++++++++++++ e2e/mock-api.mjs | 5 ++ playwright.docker.config.ts | 23 +++++++ src/routes/+error.svelte | 13 ++++ 5 files changed, 205 insertions(+), 1 deletion(-) create mode 100644 e2e/docker.spec.ts create mode 100644 playwright.docker.config.ts create mode 100644 src/routes/+error.svelte diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 03a48a4..6d81b3f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -82,7 +82,7 @@ jobs: docker rm -f ha-ci e2e: - name: E2E tests + name: E2E tests (dev server) runs-on: ubuntu-latest needs: [lint-and-check, test] steps: @@ -94,3 +94,43 @@ jobs: - run: npm ci - run: npx playwright install chromium --with-deps - run: npx playwright test + + e2e-docker: + name: E2E tests (production Docker) + runs-on: ubuntu-latest + needs: [docker-build] + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: '20' + cache: npm + - run: npm ci + - run: npx playwright install chromium --with-deps + - name: Build Docker image + run: docker build -t headscale-admin:e2e . + - name: Start container and mock API + run: | + docker run -d --name ha-e2e -p 8080:80 headscale-admin:e2e + node e2e/mock-api.mjs & + echo $! > /tmp/mock-api.pid + # Wait for both services to be ready (up to 30s) + for i in $(seq 1 30); do + if curl -sf http://localhost:8080/admin/ > /dev/null && \ + curl -sf http://localhost:8081/healthz > /dev/null 2>&1; then + echo "Services ready after ${i}s" + break + fi + sleep 1 + done + # Verify the container is up even without a /healthz on mock + curl -sf http://localhost:8080/admin/ || { echo "Container not ready"; docker logs ha-e2e; exit 1; } + - name: Run E2E tests against production build + run: npx playwright test --config playwright.docker.config.ts + env: + CI: true + - name: Cleanup + if: always() + run: | + kill $(cat /tmp/mock-api.pid) 2>/dev/null || true + docker rm -f ha-e2e 2>/dev/null || true diff --git a/e2e/docker.spec.ts b/e2e/docker.spec.ts new file mode 100644 index 0000000..7b06d20 --- /dev/null +++ b/e2e/docker.spec.ts @@ -0,0 +1,123 @@ +/** + * E2E tests that run against the production Docker build (Caddy + /admin base path). + * Used by the `e2e-docker` CI job via playwright.docker.config.ts. + * + * All page.goto() calls use relative paths (e.g. './nodes/') so they resolve + * correctly against the baseURL 'http://localhost:8080/admin/'. + */ +import { test, expect, type Page } from '@playwright/test'; + +const MOCK_URL = 'http://localhost:8081'; +const API_KEY = 'test-api-key'; + +async function seedAuth(page: Page) { + await page.goto('./'); + await page.evaluate( + ([url, key]) => { + localStorage.setItem('apiUrl', JSON.stringify(url)); + localStorage.setItem('apiKey', JSON.stringify(key)); + localStorage.setItem( + 'apiKeyInfo', + JSON.stringify({ + authorized: true, + expires: new Date(Date.now() + 86_400_000 * 90).toISOString(), + informedUnauthorized: false, + informedExpiringSoon: false, + }), + ); + }, + [MOCK_URL, API_KEY], + ); + await page.reload(); + await page.locator('[data-testid="app-shell"]').waitFor({ timeout: 10000 }); +} + +test.describe('production routing (Docker / Caddy)', () => { + test.beforeEach(async ({ page }) => { + await seedAuth(page); + }); + + test('root /admin/ renders dashboard', async ({ page }) => { + await page.goto('./'); + await expect(page.getByText(/total users/i)).toBeVisible({ timeout: 10000 }); + }); + + test('/admin/nodes/ renders node list', async ({ page }) => { + await page.goto('./nodes/'); + await expect(page.getByText('alice-laptop')).toBeVisible({ timeout: 10000 }); + }); + + test('/admin/users/ renders user list', async ({ page }) => { + await page.goto('./users/'); + await expect(page.getByText('alice')).toBeVisible({ timeout: 10000 }); + }); + + test('refresh on /admin/nodes/ keeps page and content', async ({ page }) => { + await page.goto('./nodes/'); + await expect(page.getByText('alice-laptop')).toBeVisible({ timeout: 10000 }); + await page.reload(); + await expect(page).toHaveURL(/\/admin\/nodes\/?/); + await expect(page.getByText('alice-laptop')).toBeVisible({ timeout: 10000 }); + }); + + test('refresh on /admin/users/ keeps page and content', async ({ page }) => { + await page.goto('./users/'); + await expect(page.getByText('alice')).toBeVisible({ timeout: 10000 }); + await page.reload(); + await expect(page).toHaveURL(/\/admin\/users\/?/); + await expect(page.getByText('alice')).toBeVisible({ timeout: 10000 }); + }); + + test('direct URL access /admin/nodes/ does not give blank page', async ({ page }) => { + // Opens a fresh tab directly at the path (simulates typing URL or bookmark) + await page.goto('./nodes/'); + await expect(page.locator('[data-testid="app-shell"]')).toBeVisible({ timeout: 10000 }); + await expect(page.getByText('alice-laptop')).toBeVisible({ timeout: 10000 }); + }); + + test('unknown route /admin/nonexistent/ serves 200 (SPA fallback, no white screen)', async ({ page }) => { + // Caddy should serve 200.html as the SPA fallback; the app should mount + const response = await page.goto('./nonexistent/'); + expect(response?.status()).toBe(200); + await expect(page.locator('[data-testid="app-shell"]')).toBeVisible({ timeout: 10000 }); + }); +}); + +test.describe('authentication flow (Docker)', () => { + test.beforeEach(async ({ page }) => { + // Clear credentials so we start unauthenticated + await page.goto('./'); + await page.evaluate(() => { + localStorage.removeItem('apiUrl'); + localStorage.removeItem('apiKey'); + localStorage.removeItem('apiKeyInfo'); + }); + await page.reload(); + }); + + test('ApiKeyPrompt appears on /admin/ when unauthenticated', async ({ page }) => { + await expect(page.getByRole('button', { name: /connect/i })).toBeVisible({ timeout: 5000 }); + }); + + test('entering credentials dismisses modal and shows dashboard', async ({ page }) => { + await page.getByRole('textbox', { name: 'API URL' }).fill(MOCK_URL); + await page.getByRole('textbox', { name: 'API Key' }).fill(API_KEY); + await page.getByRole('button', { name: /connect/i }).click(); + await expect(page.getByRole('button', { name: /connect/i })).not.toBeVisible({ timeout: 10000 }); + await expect(page.getByText(/total users/i)).toBeVisible({ timeout: 10000 }); + }); + + test('refresh after auth stays on current page with content', async ({ page }) => { + // Authenticate on the nodes page + await page.goto('./nodes/'); + await page.getByRole('textbox', { name: 'API URL' }).fill(MOCK_URL); + await page.getByRole('textbox', { name: 'API Key' }).fill(API_KEY); + await page.getByRole('button', { name: /connect/i }).click(); + await expect(page.getByText('alice-laptop')).toBeVisible({ timeout: 10000 }); + + // Refresh — should stay on nodes page, no white screen + await page.reload(); + await expect(page).toHaveURL(/\/admin\/nodes\/?/); + await expect(page.getByText('alice-laptop')).toBeVisible({ timeout: 10000 }); + }); +}); diff --git a/e2e/mock-api.mjs b/e2e/mock-api.mjs index d2a8b88..024af57 100644 --- a/e2e/mock-api.mjs +++ b/e2e/mock-api.mjs @@ -161,6 +161,11 @@ const server = createServer((req, res) => { return cors(res); } + // Health check — used by CI readiness probe + if (path === '/healthz') { + return json(res, { status: 'ok' }); + } + // Auth check if (!isAuthorised(req)) { return unauthorized(res); diff --git a/playwright.docker.config.ts b/playwright.docker.config.ts new file mode 100644 index 0000000..b290e13 --- /dev/null +++ b/playwright.docker.config.ts @@ -0,0 +1,23 @@ +/** + * Playwright config for running E2E tests against the production Docker build. + * The container must be running on port 8080 before this config is used. + * The mock API must be running on port 8081. + * + * Routes use SvelteKit base path /admin, so baseURL must include the trailing + * slash so that relative paths (e.g. `./nodes/`) resolve correctly. + */ +import { defineConfig } from '@playwright/test'; + +export default defineConfig({ + testDir: 'e2e', + testMatch: '**/docker.spec.ts', + timeout: 30_000, + retries: process.env.CI ? 2 : 0, + use: { + // Production build uses /admin base path — trailing slash is required so + // that `page.goto('./nodes/')` resolves to /admin/nodes/ not /nodes/ + baseURL: 'http://localhost:8080/admin/', + headless: true, + }, + // No webServer — the container and mock API are started separately by CI +}); diff --git a/src/routes/+error.svelte b/src/routes/+error.svelte new file mode 100644 index 0000000..e06bac1 --- /dev/null +++ b/src/routes/+error.svelte @@ -0,0 +1,13 @@ + + +
+

{page.status}

+

{page.error?.message ?? 'An unexpected error occurred.'}

+ +
From 0c33e259b56bd29a6af05d09ec239232ab8afc76 Mon Sep 17 00:00:00 2001 From: CJFWeatherhead Date: Fri, 17 Apr 2026 11:52:46 +0100 Subject: [PATCH 8/8] fix: exclude docker.spec.ts from default Playwright config The default playwright.config.ts was matching all *.spec.ts files, causing docker.spec.ts to run against the Vite dev server (port 5173) instead of the Docker container (port 8080/admin). Lock the default config to navigation.spec.ts only; docker.spec.ts is handled exclusively by playwright.docker.config.ts. --- playwright.config.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/playwright.config.ts b/playwright.config.ts index c65015b..b2a7000 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -2,7 +2,7 @@ import { defineConfig } from '@playwright/test'; export default defineConfig({ testDir: 'e2e', - testMatch: '**/*.spec.ts', + testMatch: '**/navigation.spec.ts', timeout: 30_000, retries: 0, use: {