diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..6d81b3f --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,136 @@ +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] + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: '20' + cache: npm + - run: npm ci + - 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 + test -f "build/${route}" || { echo "MISSING: build/${route}"; exit 1; } + echo "OK: build/${route}" + done + - name: Verify SPA fallback exists + run: | + test -f "build/200.html" || { echo "MISSING: build/200.html"; exit 1; } + echo "OK: build/200.html" + + 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/ /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" + docker logs ha-ci + exit 1 + fi + echo "OK: $path → $status" + done + docker rm -f ha-ci + + e2e: + name: E2E tests (dev server) + 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 + + 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/.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 0dae9f3..9aa1796 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 {$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/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/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 new file mode 100644 index 0000000..024af57 --- /dev/null +++ b/e2e/mock-api.mjs @@ -0,0 +1,253 @@ +/** + * 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); + } + + // Health check — used by CI readiness probe + if (path === '/healthz') { + return json(res, { status: 'ok' }); + } + + // 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..b2a7000 --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,24 @@ +import { defineConfig } from '@playwright/test'; + +export default defineConfig({ + testDir: 'e2e', + testMatch: '**/navigation.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, + }, + ], +}); 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/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/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 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/+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.'}

+ +
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, }, 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/**'], + }, });