diff --git a/.github/workflows/ci-admin.yml b/.github/workflows/ci-admin.yml index 8aeec6b..8442fe8 100644 --- a/.github/workflows/ci-admin.yml +++ b/.github/workflows/ci-admin.yml @@ -15,6 +15,7 @@ on: pull_request: paths: - 'admin/**' + - 'test/mock-chaperone/**' - 'Makefile' - '.github/workflows/ci-admin.yml' @@ -120,6 +121,47 @@ jobs: working-directory: admin/ui run: pnpm test + e2e: + name: E2E + runs-on: ubuntu-latest + needs: [lint-go, lint-ui, test-go, test-ui] + steps: + - name: Checkout code + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Set up Go + uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0 + with: + go-version-file: admin/go.mod + cache-dependency-path: admin/go.sum + + - name: Set up Node.js + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version: 24 + + - name: Install pnpm + run: corepack enable && corepack prepare pnpm@${{ env.PNPM_VERSION }} --activate + + - name: Install dependencies + working-directory: admin/ui + run: pnpm install --frozen-lockfile + + - name: Install Playwright browsers + working-directory: admin/ui + run: pnpm exec playwright install chromium firefox webkit --with-deps + + - name: Run E2E tests + run: make e2e-admin + + - name: Upload Playwright report + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + if: ${{ !cancelled() }} + with: + name: playwright-report + path: admin/ui/e2e/playwright-report/ + retention-days: 14 + build: name: Build runs-on: ubuntu-latest diff --git a/.gitignore b/.gitignore index a4b5c95..5f0e496 100644 --- a/.gitignore +++ b/.gitignore @@ -78,6 +78,14 @@ admin/ui/node_modules/ admin/ui/dist/ admin/ui/.vite/ +# Admin build artifacts +admin/seed-user + +# Admin E2E tests +admin/ui/e2e/.auth/ +admin/ui/e2e/results/ +admin/ui/e2e/playwright-report/ + # Playwright MCP output .playwright-mcp/ diff --git a/Makefile b/Makefile index 8e78b5a..1eced7d 100644 --- a/Makefile +++ b/Makefile @@ -130,6 +130,25 @@ build-admin-ui: ## Build the admin portal SPA @echo "Building admin UI..." cd $(ADMIN_UI_DIR) && pnpm install && pnpm build +.PHONY: build-seed-user +build-seed-user: ## Build the seed-user test helper + @echo "Building seed-user..." + @mkdir -p $(BUILD_DIR) + cd $(ADMIN_MODULE_DIR) && go build -o ../$(BUILD_DIR)/seed-user ./cmd/seed-user + +.PHONY: e2e-admin-setup +e2e-admin-setup: ## Install Playwright browsers for E2E tests (one-time setup) + cd $(ADMIN_UI_DIR) && pnpm install --frozen-lockfile + cd $(ADMIN_UI_DIR) && pnpm exec playwright install chromium firefox webkit + +.PHONY: e2e-admin +e2e-admin: ## Run admin portal E2E tests — all browsers (run e2e-admin-setup first) + cd $(ADMIN_UI_DIR) && pnpm e2e + +.PHONY: e2e-admin-chromium +e2e-admin-chromium: ## Run E2E tests on Chromium only + cd $(ADMIN_UI_DIR) && pnpm e2e --project=setup --project=chromium --project=auth + .PHONY: run-admin run-admin: build-admin-dev ## Build and run admin portal @$(BUILD_DIR)/$(ADMIN_BINARY_NAME) diff --git a/admin/api/auth.go b/admin/api/auth.go index 2ef0271..e716741 100644 --- a/admin/api/auth.go +++ b/admin/api/auth.go @@ -158,7 +158,7 @@ func (h *AuthHandler) changePassword(w http.ResponseWriter, r *http.Request) { err = h.auth.ChangePassword(r.Context(), user.ID, cookie.Value, req.CurrentPassword, req.NewPassword) if errors.Is(err, auth.ErrInvalidCredentials) { - respondError(w, http.StatusUnauthorized, "UNAUTHORIZED", "Current password is incorrect") + respondError(w, http.StatusForbidden, "INVALID_PASSWORD", "Current password is incorrect") return } if errors.Is(err, auth.ErrPasswordTooShort) { diff --git a/admin/api/auth_test.go b/admin/api/auth_test.go index 190bcb7..fbaa1b6 100644 --- a/admin/api/auth_test.go +++ b/admin/api/auth_test.go @@ -194,7 +194,7 @@ func TestChangePassword_Success_Returns204(t *testing.T) { } } -func TestChangePassword_WrongCurrent_Returns401(t *testing.T) { +func TestChangePassword_WrongCurrent_Returns403(t *testing.T) { t.Parallel() mux, svc := newTestAuthMux(t) createTestUser(t, svc) @@ -210,8 +210,8 @@ func TestChangePassword_WrongCurrent_Returns401(t *testing.T) { rec := httptest.NewRecorder() mux.ServeHTTP(rec, req) - if rec.Code != http.StatusUnauthorized { - t.Errorf("status = %d, want %d", rec.Code, http.StatusUnauthorized) + if rec.Code != http.StatusForbidden { + t.Errorf("status = %d, want %d", rec.Code, http.StatusForbidden) } } diff --git a/admin/cmd/seed-user/main.go b/admin/cmd/seed-user/main.go new file mode 100644 index 0000000..755ac87 --- /dev/null +++ b/admin/cmd/seed-user/main.go @@ -0,0 +1,52 @@ +// Copyright 2026 CloudBlue LLC +// SPDX-License-Identifier: Apache-2.0 + +// seed-user is a test-only tool that creates a user in the admin portal +// database without interactive terminal input. Used by E2E tests. +package main + +import ( + "context" + "flag" + "fmt" + "os" + "time" + + "github.com/cloudblue/chaperone/admin/auth" + "github.com/cloudblue/chaperone/admin/store" +) + +func main() { + if err := run(); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} + +func run() error { + dbPath := flag.String("db", "", "Path to SQLite database") + username := flag.String("username", "", "Username to create") + password := flag.String("password", "", "Password for the user") + flag.Parse() + + if *dbPath == "" || *username == "" || *password == "" { + return fmt.Errorf("usage: seed-user --db --username --password ") + } + + ctx := context.Background() + + st, err := store.Open(ctx, *dbPath) + if err != nil { + return fmt.Errorf("opening database: %w", err) + } + defer st.Close() + + svc := auth.NewService(st, 24*time.Hour, 2*time.Hour) + + if err := svc.CreateUser(ctx, *username, *password); err != nil { + return fmt.Errorf("creating user: %w", err) + } + + fmt.Printf("User %q created successfully\n", *username) + return nil +} diff --git a/admin/ui/e2e/auth.setup.js b/admin/ui/e2e/auth.setup.js new file mode 100644 index 0000000..ab7114d --- /dev/null +++ b/admin/ui/e2e/auth.setup.js @@ -0,0 +1,17 @@ +import { test as setup, expect } from '@playwright/test'; +import path from 'node:path'; +import { TEST_USER, TEST_PASSWORD } from './helpers/constants.js'; + +const authFile = path.join(import.meta.dirname, '.auth', 'user.json'); + +setup('authenticate', async ({ page }) => { + await page.goto('/login'); + await page.getByTestId('login-username').fill(TEST_USER); + await page.getByTestId('login-password').fill(TEST_PASSWORD); + await page.getByTestId('login-submit').click(); + + // Wait until redirected to dashboard + await expect(page.getByTestId('dashboard-title')).toBeVisible(); + + await page.context().storageState({ path: authFile }); +}); diff --git a/admin/ui/e2e/global-setup.js b/admin/ui/e2e/global-setup.js new file mode 100644 index 0000000..4ec4b6f --- /dev/null +++ b/admin/ui/e2e/global-setup.js @@ -0,0 +1,95 @@ +import { spawn, execSync, execFileSync } from 'node:child_process'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { waitForHealth } from './helpers/services.js'; +import { TEST_USER, PW_CHANGE_USER, TEST_PASSWORD } from './helpers/constants.js'; + +const ROOT = path.resolve(import.meta.dirname, '..', '..', '..'); + +function killPid(envVar) { + const pid = process.env[envVar]; + if (pid) { + try { + process.kill(Number(pid), 'SIGTERM'); + } catch { + // Process may have already exited + } + } +} + +export default async function globalSetup() { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'chaperone-e2e-')); + const dbPath = path.join(tmpDir, 'test.db'); + const binDir = path.join(ROOT, 'bin'); + const authDir = path.join(import.meta.dirname, '.auth'); + + fs.mkdirSync(authDir, { recursive: true }); + + // Store paths for teardown + process.env.E2E_TMP_DIR = tmpDir; + process.env.E2E_DB_PATH = dbPath; + + try { + // 1. Build admin binary + seed-user + console.log('[e2e] Building admin binary...'); + execSync('make build-admin', { cwd: ROOT, stdio: 'pipe' }); + console.log('[e2e] Building seed-user...'); + execSync( + `cd admin && go build -o ../bin/seed-user ./cmd/seed-user`, + { cwd: ROOT, stdio: 'pipe' }, + ); + + // 2. Start mock chaperone fleet + console.log('[e2e] Starting mock chaperone fleet...'); + const mockProc = spawn( + 'node', + [path.join(ROOT, 'test', 'mock-chaperone', 'mock-chaperone.js')], + { stdio: 'ignore' }, + ); + process.env.E2E_MOCK_PID = String(mockProc.pid); + + await waitForHealth('http://127.0.0.1:19091/_ops/health', 15_000); + console.log('[e2e] Mock fleet ready'); + + // 3. Seed test users + console.log('[e2e] Seeding test users...'); + const seedBin = path.join(binDir, 'seed-user'); + execFileSync(seedBin, ['--db', dbPath, '--username', TEST_USER, '--password', TEST_PASSWORD], { + cwd: ROOT, + stdio: 'pipe', + }); + execFileSync(seedBin, ['--db', dbPath, '--username', PW_CHANGE_USER, '--password', TEST_PASSWORD], { + cwd: ROOT, + stdio: 'pipe', + }); + + // 4. Start admin server + console.log('[e2e] Starting admin server...'); + const adminProc = spawn( + path.join(binDir, 'chaperone-admin'), + [], + { + stdio: 'ignore', + env: { + ...process.env, + CHAPERONE_ADMIN_SERVER_ADDR: '127.0.0.1:8080', + CHAPERONE_ADMIN_DATABASE_PATH: dbPath, + CHAPERONE_ADMIN_SERVER_SECURE_COOKIES: 'false', + CHAPERONE_ADMIN_SCRAPER_INTERVAL: '3s', + CHAPERONE_ADMIN_SCRAPER_TIMEOUT: '2s', + CHAPERONE_ADMIN_LOG_LEVEL: 'warn', + }, + }, + ); + process.env.E2E_ADMIN_PID = String(adminProc.pid); + + await waitForHealth('http://127.0.0.1:8080/api/health', 15_000); + console.log('[e2e] Admin server ready'); + } catch (err) { + // Kill any processes we spawned before Playwright skips globalTeardown + killPid('E2E_ADMIN_PID'); + killPid('E2E_MOCK_PID'); + throw err; + } +} diff --git a/admin/ui/e2e/global-teardown.js b/admin/ui/e2e/global-teardown.js new file mode 100644 index 0000000..414ac46 --- /dev/null +++ b/admin/ui/e2e/global-teardown.js @@ -0,0 +1,33 @@ +import fs from 'node:fs'; + +export default async function globalTeardown() { + // Kill admin server + const adminPid = process.env.E2E_ADMIN_PID; + if (adminPid) { + try { + process.kill(Number(adminPid), 'SIGTERM'); + } catch { + // Process may have already exited + } + } + + // Kill mock chaperone fleet + const mockPid = process.env.E2E_MOCK_PID; + if (mockPid) { + try { + process.kill(Number(mockPid), 'SIGTERM'); + } catch { + // Process may have already exited + } + } + + // Remove temp directory + const tmpDir = process.env.E2E_TMP_DIR; + if (tmpDir) { + try { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } catch { + // Best-effort cleanup + } + } +} diff --git a/admin/ui/e2e/helpers/constants.js b/admin/ui/e2e/helpers/constants.js new file mode 100644 index 0000000..20a09a3 --- /dev/null +++ b/admin/ui/e2e/helpers/constants.js @@ -0,0 +1,3 @@ +export const TEST_USER = 'admin'; +export const PW_CHANGE_USER = 'admin-pw-test'; +export const TEST_PASSWORD = 'testpassword12'; diff --git a/admin/ui/e2e/helpers/fixtures.js b/admin/ui/e2e/helpers/fixtures.js new file mode 100644 index 0000000..93e5ca1 --- /dev/null +++ b/admin/ui/e2e/helpers/fixtures.js @@ -0,0 +1,56 @@ +import { test as base, expect } from '@playwright/test'; +import { TEST_USER, TEST_PASSWORD } from './constants.js'; + +/** + * Custom fixtures for E2E tests. + * Provides an authenticated API context with CSRF handling for seeding data. + */ +export const test = base.extend({ + /** + * An authenticated API request context with CSRF support. + * Use for seeding instances via the REST API in beforeAll hooks. + */ + authedAPI: async ({ playwright }, use) => { + const ctx = await playwright.request.newContext({ + baseURL: 'http://127.0.0.1:8080', + }); + + // Login to get session + CSRF cookies + const loginRes = await ctx.post('/api/login', { + data: { username: TEST_USER, password: TEST_PASSWORD }, + }); + expect(loginRes.ok()).toBeTruthy(); + + // Extract CSRF token from cookies + const cookies = await ctx.storageState(); + const csrfCookie = cookies.cookies.find((c) => c.name === 'csrf_token'); + if (!csrfCookie) throw new Error('expected csrf_token cookie after login'); + const csrfToken = csrfCookie.value; + + // Wrap context to auto-include CSRF header on writes + const originalPost = ctx.post.bind(ctx); + const originalPut = ctx.put.bind(ctx); + const originalDelete = ctx.delete.bind(ctx); + + ctx.post = (url, options = {}) => + originalPost(url, { + ...options, + headers: { ...options.headers, 'X-CSRF-Token': csrfToken }, + }); + ctx.put = (url, options = {}) => + originalPut(url, { + ...options, + headers: { ...options.headers, 'X-CSRF-Token': csrfToken }, + }); + ctx.delete = (url, options = {}) => + originalDelete(url, { + ...options, + headers: { ...options.headers, 'X-CSRF-Token': csrfToken }, + }); + + await use(ctx); + await ctx.dispose(); + }, +}); + +export { expect }; diff --git a/admin/ui/e2e/helpers/services.js b/admin/ui/e2e/helpers/services.js new file mode 100644 index 0000000..ada4355 --- /dev/null +++ b/admin/ui/e2e/helpers/services.js @@ -0,0 +1,33 @@ +import http from 'node:http'; + +/** + * Wait for an HTTP endpoint to return 200. + * @param {string} url + * @param {number} timeoutMs + */ +export function waitForHealth(url, timeoutMs = 15_000) { + const start = Date.now(); + return new Promise((resolve, reject) => { + function attempt() { + if (Date.now() - start > timeoutMs) { + reject(new Error(`Timed out waiting for ${url}`)); + return; + } + const req = http.get(url, (res) => { + if (res.statusCode === 200) { + res.resume(); + resolve(); + } else { + res.resume(); + setTimeout(attempt, 250); + } + }); + req.on('error', () => setTimeout(attempt, 250)); + req.setTimeout(2000, () => { + req.destroy(); + setTimeout(attempt, 250); + }); + } + attempt(); + }); +} diff --git a/admin/ui/e2e/playwright.config.js b/admin/ui/e2e/playwright.config.js new file mode 100644 index 0000000..8ec0c82 --- /dev/null +++ b/admin/ui/e2e/playwright.config.js @@ -0,0 +1,90 @@ +// @ts-check +import { defineConfig } from '@playwright/test'; +import path from 'node:path'; + +const baseURL = 'http://127.0.0.1:8080'; +const storageState = path.join(import.meta.dirname, '.auth', 'user.json'); + +// Specs that are self-contained (no cross-test state dependencies) and safe +// to run on any browser after the Chromium full suite has seeded state. +const crossBrowserSpecs = [ + 'specs/smoke.spec.js', + 'specs/settings.spec.js', + 'specs/instance-detail.spec.js', +]; + +export default defineConfig({ + testDir: '.', + testMatch: ['specs/**/*.spec.js', 'auth.setup.js'], + fullyParallel: false, + workers: 1, + retries: 0, + timeout: 30_000, + expect: { + timeout: 10_000, + }, + use: { + baseURL, + trace: 'retain-on-failure', + screenshot: 'only-on-failure', + testIdAttribute: 'data-testid', + }, + projects: [ + // --- Chromium (primary): full suite --- + { + name: 'setup', + testMatch: 'auth.setup.js', + }, + { + name: 'chromium', + use: { browserName: 'chromium', storageState }, + dependencies: ['setup'], + testMatch: 'specs/**/*.spec.js', + testIgnore: ['specs/auth.spec.js', 'specs/accessibility.spec.js'], + }, + { + name: 'auth', + use: { browserName: 'chromium' }, + testMatch: 'specs/auth.spec.js', + }, + + // --- Accessibility: runs after main suite to avoid state interference --- + { + name: 'a11y', + use: { browserName: 'chromium', storageState }, + dependencies: ['chromium'], + testMatch: 'specs/accessibility.spec.js', + }, + + // --- Firefox: cross-browser subset --- + { + name: 'firefox', + use: { browserName: 'firefox', storageState }, + dependencies: ['chromium'], + testMatch: crossBrowserSpecs, + }, + { + name: 'auth-firefox', + use: { browserName: 'firefox' }, + dependencies: ['auth'], + testMatch: 'specs/auth.spec.js', + }, + + // --- WebKit: cross-browser subset --- + { + name: 'webkit', + use: { browserName: 'webkit', storageState }, + dependencies: ['chromium'], + testMatch: crossBrowserSpecs, + }, + { + name: 'auth-webkit', + use: { browserName: 'webkit' }, + dependencies: ['auth'], + testMatch: 'specs/auth.spec.js', + }, + ], + outputDir: './results', + globalSetup: './global-setup.js', + globalTeardown: './global-teardown.js', +}); diff --git a/admin/ui/e2e/specs/accessibility.spec.js b/admin/ui/e2e/specs/accessibility.spec.js new file mode 100644 index 0000000..1a58160 --- /dev/null +++ b/admin/ui/e2e/specs/accessibility.spec.js @@ -0,0 +1,128 @@ +import AxeBuilder from '@axe-core/playwright'; +import { test, expect } from '../helpers/fixtures.js'; + +const axeTags = ['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa']; + +test.describe('Accessibility — authenticated pages', () => { + test('dashboard', async ({ page }) => { + await page.goto('/'); + // Accept whatever state the dashboard is in (welcome screen or instances) + await expect( + page.getByTestId('dashboard-title'), + ).toBeVisible({ timeout: 10_000 }); + + const results = await new AxeBuilder({ page }) + .withTags(axeTags) + .analyze(); + + expect(results.violations).toEqual([]); + }); + + test('dashboard — table view', async ({ page, authedAPI }) => { + // Ensure at least one instance exists for table view + const res = await authedAPI.get('/api/instances'); + const instances = await res.json(); + if (instances.length === 0) { + await authedAPI.post('/api/instances', { + data: { name: 'a11y-proxy-1', address: '127.0.0.1:19091' }, + }); + } + + await page.goto('/'); + await expect(page.getByTestId('instance-card').first()).toBeVisible({ timeout: 15_000 }); + + await page.getByTestId('view-toggle-table').click(); + await expect(page.getByTestId('instance-table')).toBeVisible(); + + const results = await new AxeBuilder({ page }) + .withTags(axeTags) + .analyze(); + + expect(results.violations).toEqual([]); + }); + + test('instance detail — overview tab', async ({ page, authedAPI }) => { + const res = await authedAPI.get('/api/instances'); + const instances = await res.json(); + const inst = instances[0]; + + await page.goto(`/instances/${inst.id}`); + await expect(page.getByTestId('overview-tab')).toBeVisible({ timeout: 15_000 }); + + const results = await new AxeBuilder({ page }) + .withTags(axeTags) + .analyze(); + + expect(results.violations).toEqual([]); + }); + + test('instance detail — traffic tab', async ({ page, authedAPI }) => { + const res = await authedAPI.get('/api/instances'); + const instances = await res.json(); + const inst = instances[0]; + + await page.goto(`/instances/${inst.id}`); + await page.getByTestId('tab-traffic').click(); + await expect(page.getByTestId('traffic-tab')).toBeVisible({ timeout: 15_000 }); + + const results = await new AxeBuilder({ page }) + .withTags(axeTags) + .analyze(); + + expect(results.violations).toEqual([]); + }); + + test('audit log', async ({ page }) => { + await page.goto('/audit-log'); + await expect(page.getByTestId('audit-table')).toBeVisible({ timeout: 10_000 }); + + const results = await new AxeBuilder({ page }) + .withTags(axeTags) + .analyze(); + + expect(results.violations).toEqual([]); + }); + + test('settings page', async ({ page }) => { + await page.goto('/settings'); + await expect(page.getByTestId('settings-submit')).toBeVisible(); + + const results = await new AxeBuilder({ page }) + .withTags(axeTags) + .analyze(); + + expect(results.violations).toEqual([]); + }); + + test('add instance modal', async ({ page }) => { + await page.goto('/'); + await expect(page.getByTestId('dashboard-title')).toBeVisible({ timeout: 10_000 }); + + const addBtn = page.getByTestId('add-instance-btn').or( + page.getByTestId('add-first-instance'), + ); + await addBtn.first().click(); + await expect(page.getByTestId('instance-name')).toBeVisible(); + + const results = await new AxeBuilder({ page }) + .withTags(axeTags) + .analyze(); + + expect(results.violations).toEqual([]); + }); +}); + +test.describe('Accessibility — login page', () => { + test.use({ storageState: { cookies: [], origins: [] } }); + + test('login page', async ({ page }) => { + await page.goto('/login'); + await expect(page.getByTestId('login-submit')).toBeVisible(); + + const results = await new AxeBuilder({ page }) + .withTags(axeTags) + .analyze(); + + expect(results.violations).toEqual([]); + }); +}); diff --git a/admin/ui/e2e/specs/audit-log.spec.js b/admin/ui/e2e/specs/audit-log.spec.js new file mode 100644 index 0000000..dba3512 --- /dev/null +++ b/admin/ui/e2e/specs/audit-log.spec.js @@ -0,0 +1,56 @@ +import { test, expect } from '@playwright/test'; + +test.describe('Audit Log', () => { + test('shows audit entries for previous actions', async ({ page }) => { + await page.goto('/audit-log'); + + // Should have entries from dashboard tests (instance.create, user.login, etc.) + await expect(page.getByTestId('audit-table')).toBeVisible({ timeout: 10_000 }); + await expect(page.getByTestId('audit-row').first()).toBeVisible(); + }); + + test('search filters results', async ({ page }) => { + await page.goto('/audit-log'); + await expect(page.getByTestId('audit-table')).toBeVisible(); + + const responsePromise = page.waitForResponse((resp) => resp.url().includes('/api/audit')); + await page.getByTestId('audit-search').fill('proxy'); + await responsePromise; + + // Results may match or be empty; accept either the table or the empty state + await expect( + page.getByTestId('audit-table').or(page.getByText('No matching entries')), + ).toBeVisible(); + }); + + test('action type dropdown filters', async ({ page }) => { + await page.goto('/audit-log'); + await expect(page.getByTestId('audit-table')).toBeVisible(); + + await page.getByTestId('audit-action-filter').selectOption('user.login'); + + // All visible rows should contain the login action label + const rows = page.getByTestId('audit-row'); + const count = await rows.count(); + if (count > 0) { + for (let i = 0; i < Math.min(count, 5); i++) { + await expect(rows.nth(i)).toContainText('logged in'); + } + } + }); + + test('pagination controls work', async ({ page }) => { + await page.goto('/audit-log'); + + const pagination = page.getByTestId('audit-pagination'); + // Pagination may or may not be visible depending on entry count + // If visible, clicking next page should work + if (await pagination.isVisible()) { + const nextBtn = page.getByTestId('audit-next-page'); + if (await nextBtn.isEnabled()) { + await nextBtn.click(); + await expect(page.getByTestId('audit-table')).toBeVisible(); + } + } + }); +}); diff --git a/admin/ui/e2e/specs/auth.spec.js b/admin/ui/e2e/specs/auth.spec.js new file mode 100644 index 0000000..5ca4e95 --- /dev/null +++ b/admin/ui/e2e/specs/auth.spec.js @@ -0,0 +1,60 @@ +import { test, expect } from '@playwright/test'; +import { TEST_USER, TEST_PASSWORD } from '../helpers/constants.js'; + +test.describe('Authentication', () => { + test('login with valid credentials redirects to dashboard', async ({ page }) => { + await page.goto('/login'); + await page.getByTestId('login-username').fill(TEST_USER); + await page.getByTestId('login-password').fill(TEST_PASSWORD); + await page.getByTestId('login-submit').click(); + + await expect(page.getByTestId('dashboard-title')).toBeVisible(); + await expect(page.getByTestId('sidebar-username')).toHaveText(TEST_USER); + }); + + test('login with invalid credentials shows error', async ({ page }) => { + await page.goto('/login'); + await page.getByTestId('login-username').fill(TEST_USER); + await page.getByTestId('login-password').fill('wrongpassword1'); + await page.getByTestId('login-submit').click(); + + await expect(page.getByTestId('login-error')).toBeVisible(); + await expect(page.getByTestId('login-error')).toContainText('Invalid username or password'); + await expect(page).toHaveURL(/\/login/); + }); + + test('unauthenticated user is redirected to login', async ({ page }) => { + await page.goto('/'); + await expect(page).toHaveURL(/\/login/); + }); + + test('redirect back after login', async ({ page }) => { + await page.goto('/audit-log'); + await expect(page).toHaveURL(/\/login\?redirect=/); + + await page.getByTestId('login-username').fill(TEST_USER); + await page.getByTestId('login-password').fill(TEST_PASSWORD); + await page.getByTestId('login-submit').click(); + + await expect(page).toHaveURL(/\/audit-log/); + }); + + test('logout redirects to login', async ({ page }) => { + // First login + await page.goto('/login'); + await page.getByTestId('login-username').fill(TEST_USER); + await page.getByTestId('login-password').fill(TEST_PASSWORD); + await page.getByTestId('login-submit').click(); + await expect(page.getByTestId('dashboard-title')).toBeVisible(); + + // Then logout + await page.getByTestId('sidebar-logout').click(); + await expect(page).toHaveURL(/\/login/); + + // Session is invalidated — going back to / should redirect to login. + // The SPA's auth guard may redirect before the navigation even commits + // (especially in WebKit), so tolerate the "interrupted" error. + await page.goto('/').catch(() => {}); + await expect(page).toHaveURL(/\/login/); + }); +}); diff --git a/admin/ui/e2e/specs/dashboard.spec.js b/admin/ui/e2e/specs/dashboard.spec.js new file mode 100644 index 0000000..3b6bc0c --- /dev/null +++ b/admin/ui/e2e/specs/dashboard.spec.js @@ -0,0 +1,112 @@ +import { test, expect } from '../helpers/fixtures.js'; + +// Tests in this suite are intentionally ordered and sequentially dependent. +// Each test builds on state created by earlier tests (add → verify health → +// seed more → toggle view → edit → delete → navigate). This mirrors the +// real CRUD flow. Requires: fullyParallel: false, workers: 1. +test.describe('Fleet Dashboard', () => { + test('shows welcome screen when no instances registered', async ({ page }) => { + await page.goto('/'); + await expect(page.getByTestId('welcome-screen')).toBeVisible(); + await expect(page.getByTestId('add-first-instance')).toBeVisible(); + }); + + test('add instance via modal', async ({ page }) => { + await page.goto('/'); + + // Open add modal (welcome screen button or header button) + const addBtn = page.getByTestId('add-first-instance').or( + page.getByTestId('add-instance-btn'), + ); + await addBtn.first().click(); + + // Fill form + await page.getByTestId('instance-name').fill('proxy-us-east-1'); + await page.getByTestId('instance-address').fill('127.0.0.1:19091'); + + // Test connection + await page.getByTestId('test-connection').click(); + await expect(page.getByTestId('test-result')).toContainText('Connected successfully'); + + // Save + await page.getByTestId('save-instance').click(); + + // Card should appear + await expect(page.getByTestId('instance-card')).toBeVisible(); + }); + + test('instance becomes healthy after polling', async ({ page }) => { + await page.goto('/'); + + // Wait for status to show healthy (after scrape cycle) + await expect( + page.getByTestId('instance-card').getByTestId('status-healthy'), + ).toBeVisible({ timeout: 15_000 }); + }); + + test('add multiple instances shows KPI panel', async ({ page, authedAPI }) => { + // Seed second and third instances via API + await authedAPI.post('/api/instances', { + data: { name: 'proxy-eu-west-1', address: '127.0.0.1:19092' }, + }); + await authedAPI.post('/api/instances', { + data: { name: 'proxy-ap-south-1', address: '127.0.0.1:19093' }, + }); + + await page.goto('/'); + await expect(page.getByTestId('kpi-panel')).toBeVisible({ timeout: 15_000 }); + }); + + test('view toggle switches between card and table', async ({ page }) => { + await page.goto('/'); + await expect(page.getByTestId('instance-card').first()).toBeVisible(); + + // Switch to table + await page.getByTestId('view-toggle-table').click(); + await expect(page.getByTestId('instance-table')).toBeVisible(); + + // Switch back to cards + await page.getByTestId('view-toggle-card').click(); + await expect(page.getByTestId('instance-card').first()).toBeVisible(); + }); + + test('edit instance', async ({ page }) => { + await page.goto('/'); + + // Find a specific card by name and click its edit button + const card = page.getByTestId('instance-card').filter({ hasText: 'proxy-us-east-1' }); + await card.getByTestId('instance-edit').click(); + + // Modal should be pre-filled + await expect(page.getByTestId('instance-name')).toHaveValue('proxy-us-east-1'); + + // Change name + await page.getByTestId('instance-name').fill('proxy-renamed'); + await page.getByTestId('save-instance').click(); + + // Updated name should appear + await expect(page.getByText('proxy-renamed')).toBeVisible(); + }); + + test('delete instance with confirmation', async ({ page }) => { + await page.goto('/'); + + const cardCount = await page.getByTestId('instance-card').count(); + + // Click remove on last card + await page.getByTestId('instance-card').last().getByTestId('instance-delete').click(); + + // Confirm dialog + await expect(page.getByTestId('confirm-ok')).toBeVisible(); + await page.getByTestId('confirm-ok').click(); + + // One fewer card + await expect(page.getByTestId('instance-card')).toHaveCount(cardCount - 1); + }); + + test('click instance navigates to detail', async ({ page }) => { + await page.goto('/'); + await page.getByTestId('instance-card').first().click(); + await expect(page).toHaveURL(/\/instances\/\d+/); + }); +}); diff --git a/admin/ui/e2e/specs/instance-detail.spec.js b/admin/ui/e2e/specs/instance-detail.spec.js new file mode 100644 index 0000000..a3ef6ca --- /dev/null +++ b/admin/ui/e2e/specs/instance-detail.spec.js @@ -0,0 +1,85 @@ +import { test, expect } from '../helpers/fixtures.js'; + +test.describe('Instance Detail', () => { + // Seed instances before each test (idempotent — skips if already registered). + test.beforeEach(async ({ authedAPI }) => { + const res = await authedAPI.get('/api/instances'); + const instances = await res.json(); + if (!instances.find((i) => i.address === '127.0.0.1:19091')) { + await authedAPI.post('/api/instances', { + data: { name: 'proxy-us-east-1', address: '127.0.0.1:19091' }, + }); + } + if (!instances.find((i) => i.address === '127.0.0.1:19092')) { + await authedAPI.post('/api/instances', { + data: { name: 'proxy-eu-west-1', address: '127.0.0.1:19092' }, + }); + } + if (!instances.find((i) => i.address === '127.0.0.1:19093')) { + await authedAPI.post('/api/instances', { + data: { name: 'proxy-ap-south-1', address: '127.0.0.1:19093' }, + }); + } + }); + + test('overview tab shows metrics after polling', async ({ page, authedAPI }) => { + // Get first instance ID + const res = await authedAPI.get('/api/instances'); + const instances = await res.json(); + const inst = instances[0]; + + await page.goto(`/instances/${inst.id}`); + + // Wait for metrics to appear (scraper polls every 3s, may need multiple cycles) + await expect(page.getByTestId('overview-tab')).toBeVisible({ timeout: 15_000 }); + await expect(page.getByTestId('kpi-rps')).toBeVisible({ timeout: 15_000 }); + }); + + test('traffic tab shows vendor breakdown', async ({ page, authedAPI }) => { + const res = await authedAPI.get('/api/instances'); + const instances = await res.json(); + const inst = instances[0]; + + await page.goto(`/instances/${inst.id}`); + + // Switch to traffic tab + await page.getByTestId('tab-traffic').click(); + await expect(page.getByTestId('traffic-tab')).toBeVisible(); + + // Vendor names should appear + await expect(page.getByText('acme-corp')).toBeVisible({ timeout: 15_000 }); + }); + + test('tab keyboard navigation', async ({ page, authedAPI }) => { + const res = await authedAPI.get('/api/instances'); + const instances = await res.json(); + const inst = instances[0]; + + await page.goto(`/instances/${inst.id}`); + + // Focus overview tab and press arrow right + await page.getByTestId('tab-overview').focus(); + await page.keyboard.press('ArrowRight'); + + // Traffic tab should now be active + await expect(page.getByTestId('tab-traffic')).toHaveAttribute('aria-selected', 'true'); + }); + + test('breadcrumb Fleet link navigates back', async ({ page, authedAPI }) => { + const res = await authedAPI.get('/api/instances'); + const instances = await res.json(); + const inst = instances[0]; + + await page.goto(`/instances/${inst.id}`); + await page.getByTestId('breadcrumb-fleet').click(); + await expect(page.getByTestId('dashboard-title')).toBeVisible(); + }); + + test('non-existent instance shows not found', async ({ page }) => { + await page.goto('/instances/99999'); + // May show "Instance not found" or "Cannot load metrics" depending on race + await expect( + page.getByText('Instance not found').or(page.getByText('Cannot load metrics')), + ).toBeVisible(); + }); +}); diff --git a/admin/ui/e2e/specs/settings.spec.js b/admin/ui/e2e/specs/settings.spec.js new file mode 100644 index 0000000..e6157ae --- /dev/null +++ b/admin/ui/e2e/specs/settings.spec.js @@ -0,0 +1,61 @@ +import { test, expect } from '@playwright/test'; +import { PW_CHANGE_USER, TEST_PASSWORD } from '../helpers/constants.js'; + +// Use a separate browser context (no saved state) for the password-change user +test.use({ storageState: { cookies: [], origins: [] } }); + +test.describe('Settings — Password Change', () => { + test.beforeEach(async ({ page }) => { + // Login as the dedicated password-change test user + await page.goto('/login'); + await page.getByTestId('login-username').fill(PW_CHANGE_USER); + await page.getByTestId('login-password').fill(TEST_PASSWORD); + await page.getByTestId('login-submit').click(); + await expect(page.getByTestId('dashboard-title')).toBeVisible(); + }); + + test('change password successfully', async ({ page }) => { + await page.goto('/settings'); + + await page.getByTestId('settings-current-password').fill(TEST_PASSWORD); + await page.getByTestId('settings-new-password').fill('newpassword1234'); + await page.getByTestId('settings-confirm-password').fill('newpassword1234'); + await page.getByTestId('settings-submit').click(); + + await expect(page.getByTestId('settings-success')).toBeVisible(); + await expect(page.getByTestId('settings-success')).toContainText('Password changed'); + + // Change it back so tests remain idempotent + await page.getByTestId('settings-current-password').fill('newpassword1234'); + await page.getByTestId('settings-new-password').fill(TEST_PASSWORD); + await page.getByTestId('settings-confirm-password').fill(TEST_PASSWORD); + await page.getByTestId('settings-submit').click(); + await expect(page.getByTestId('settings-success')).toBeVisible(); + }); + + test('wrong current password shows error', async ({ page }) => { + await page.goto('/settings'); + + await page.getByTestId('settings-current-password').fill('wrongpassword1'); + await page.getByTestId('settings-new-password').fill('newpassword1234'); + await page.getByTestId('settings-confirm-password').fill('newpassword1234'); + await page.getByTestId('settings-submit').click(); + + // Backend returns 403 (not 401) for wrong current password, so the + // global 401 interceptor does NOT trigger — user stays on settings page. + await expect(page.getByTestId('settings-error')).toBeVisible(); + await expect(page.getByTestId('settings-error')).toContainText('Current password is incorrect'); + }); + + test('password too short shows validation error', async ({ page }) => { + await page.goto('/settings'); + + await page.getByTestId('settings-current-password').fill(TEST_PASSWORD); + await page.getByTestId('settings-new-password').fill('short'); + await page.getByTestId('settings-confirm-password').fill('short'); + await page.getByTestId('settings-submit').click(); + + // Should show client-side validation error (not server error) + await expect(page.getByTestId('settings-new-password-error')).toBeVisible(); + }); +}); diff --git a/admin/ui/e2e/specs/smoke.spec.js b/admin/ui/e2e/specs/smoke.spec.js new file mode 100644 index 0000000..0520cc6 --- /dev/null +++ b/admin/ui/e2e/specs/smoke.spec.js @@ -0,0 +1,7 @@ +import { test, expect } from '@playwright/test'; + +test('authenticated user sees fleet dashboard', async ({ page }) => { + await page.goto('/'); + await expect(page.getByTestId('dashboard-title')).toBeVisible(); + await expect(page.getByTestId('dashboard-title')).toHaveText('Fleet Dashboard'); +}); diff --git a/admin/ui/package.json b/admin/ui/package.json index f2b5bb6..bfab9c2 100644 --- a/admin/ui/package.json +++ b/admin/ui/package.json @@ -11,7 +11,9 @@ "lint:fix": "eslint . --fix", "format": "prettier --write \"src/**/*.{js,vue,css}\"", "test": "vitest run", - "test:watch": "vitest" + "test:watch": "vitest", + "e2e": "playwright test --config e2e/playwright.config.js", + "e2e:panel": "pnpm e2e --ui" }, "dependencies": { "echarts": "^6.0.0", @@ -26,6 +28,8 @@ ] }, "devDependencies": { + "@axe-core/playwright": "^4.11.1", + "@playwright/test": "^1.58.2", "@vitejs/plugin-vue": "^6.0.4", "eslint": "^10.0.2", "eslint-config-prettier": "^10.1.8", diff --git a/admin/ui/pnpm-lock.yaml b/admin/ui/pnpm-lock.yaml index 3c06d2a..aa9a00f 100644 --- a/admin/ui/pnpm-lock.yaml +++ b/admin/ui/pnpm-lock.yaml @@ -24,6 +24,12 @@ importers: specifier: ^5.0.3 version: 5.0.3(@vue/compiler-sfc@3.5.29)(pinia@3.0.4(vue@3.5.29))(vue@3.5.29) devDependencies: + '@axe-core/playwright': + specifier: ^4.11.1 + version: 4.11.1(playwright-core@1.58.2) + '@playwright/test': + specifier: ^1.58.2 + version: 1.58.2 '@vitejs/plugin-vue': specifier: ^6.0.4 version: 6.0.4(vite@7.3.1(yaml@2.8.2))(vue@3.5.29) @@ -54,6 +60,11 @@ packages: '@asamuzakjp/css-color@3.2.0': resolution: {integrity: sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==} + '@axe-core/playwright@4.11.1': + resolution: {integrity: sha512-mKEfoUIB1MkVTht0BGZFXtSAEKXMJoDkyV5YZ9jbBmZCcWDz71tegNsdTkIN8zc/yMi5Gm2kx7Z5YQ9PfWNAWw==} + peerDependencies: + playwright-core: '>= 1.0.0' + '@babel/generator@7.29.1': resolution: {integrity: sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==} engines: {node: '>=6.9.0'} @@ -321,6 +332,11 @@ packages: '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + '@playwright/test@1.58.2': + resolution: {integrity: sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==} + engines: {node: '>=18'} + hasBin: true + '@rolldown/pluginutils@1.0.0-rc.2': resolution: {integrity: sha512-izyXV/v+cHiRfozX62W9htOAvwMo4/bXKDrQ+vom1L1qRuexPock/7VZDAhnpHCLNejd3NJ6hiab+tO0D44Rgw==} @@ -598,6 +614,10 @@ packages: resolution: {integrity: sha512-cbdCP0PGOBq0ASG+sjnKIoYkWMKhhz+F/h9pRexUdX2Hd38+WOlBkRKlqkGOSm0YQpcFMQBJeK4WspUAkwsEdg==} engines: {node: '>=20.19.0'} + axe-core@4.11.1: + resolution: {integrity: sha512-BASOg+YwO2C+346x3LZOeoovTIoTrRqEsqMa6fmfAV0P+U9mFr9NsyOEpiYvFjbc64NMrSswhV50WdXzdb/Z5A==} + engines: {node: '>=4'} + balanced-match@4.0.4: resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} engines: {node: 18 || 20 || >=22} @@ -808,6 +828,11 @@ packages: flatted@3.3.4: resolution: {integrity: sha512-3+mMldrTAPdta5kjX2G2J7iX4zxtnwpdA8Tr2ZSjkyPSanvbZAcy6flmtnXbEybHrDcU9641lxrMfFuUxVz9vA==} + fsevents@2.3.2: + resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -1009,6 +1034,16 @@ packages: pkg-types@2.3.0: resolution: {integrity: sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig==} + playwright-core@1.58.2: + resolution: {integrity: sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==} + engines: {node: '>=18'} + hasBin: true + + playwright@1.58.2: + resolution: {integrity: sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==} + engines: {node: '>=18'} + hasBin: true + postcss-selector-parser@7.1.1: resolution: {integrity: sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==} engines: {node: '>=4'} @@ -1350,6 +1385,11 @@ snapshots: '@csstools/css-tokenizer': 3.0.4 lru-cache: 10.4.3 + '@axe-core/playwright@4.11.1(playwright-core@1.58.2)': + dependencies: + axe-core: 4.11.1 + playwright-core: 1.58.2 + '@babel/generator@7.29.1': dependencies: '@babel/parser': 7.29.0 @@ -1529,6 +1569,10 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 + '@playwright/test@1.58.2': + dependencies: + playwright: 1.58.2 + '@rolldown/pluginutils@1.0.0-rc.2': {} '@rollup/rollup-android-arm-eabi@4.59.0': @@ -1789,6 +1833,8 @@ snapshots: '@babel/parser': 7.29.0 ast-kit: 2.2.0 + axe-core@4.11.1: {} + balanced-match@4.0.4: {} birpc@2.9.0: {} @@ -2010,6 +2056,9 @@ snapshots: flatted@3.3.4: {} + fsevents@2.3.2: + optional: true + fsevents@2.3.3: optional: true @@ -2208,6 +2257,14 @@ snapshots: exsolve: 1.0.8 pathe: 2.0.3 + playwright-core@1.58.2: {} + + playwright@1.58.2: + dependencies: + playwright-core: 1.58.2 + optionalDependencies: + fsevents: 2.3.2 + postcss-selector-parser@7.1.1: dependencies: cssesc: 3.0.0 diff --git a/admin/ui/src/assets/variables.css b/admin/ui/src/assets/variables.css index 2d4f22f..9d85650 100644 --- a/admin/ui/src/assets/variables.css +++ b/admin/ui/src/assets/variables.css @@ -6,21 +6,21 @@ --color-bg-sidebar-hover: #1a2340; --color-bg-sidebar-active: rgba(59, 130, 246, 0.15); - /* Colors — Accent */ - --color-accent: #3b82f6; + /* Colors — Accent (AA contrast on both --color-bg-surface and --color-bg-primary) */ + --color-accent: #2563eb; --color-accent-light: #dbeafe; - --color-accent-hover: #2563eb; + --color-accent-hover: #1d4ed8; - /* Colors — Text */ + /* Colors — Text (all meet WCAG AA 4.5:1 on --color-bg-surface) */ --color-text-primary: #111827; - --color-text-secondary: #6b7280; - --color-text-tertiary: #9ca3af; + --color-text-secondary: #4b5563; + --color-text-tertiary: #6b7280; --color-text-sidebar: #94a3b8; --color-text-sidebar-active: #ffffff; --color-text-inverse: #ffffff; /* Colors — Status */ - --color-success: #059669; + --color-success: #047857; --color-success-bg: #ecfdf5; --color-success-border: #a7f3d0; --color-warning: #f59e0b; diff --git a/admin/ui/src/components/AddInstanceModal.vue b/admin/ui/src/components/AddInstanceModal.vue index 8e5963c..664bc97 100644 --- a/admin/ui/src/components/AddInstanceModal.vue +++ b/admin/ui/src/components/AddInstanceModal.vue @@ -6,6 +6,7 @@ role="dialog" aria-labelledby="modal-title" aria-modal="true" + tabindex="-1" >