From a821481bccaba0a22d2c988bc7dceb5e32e4f148 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=ADctor=20Cuevas?= Date: Wed, 15 Apr 2026 18:20:00 +0200 Subject: [PATCH 1/7] feat(admin): add E2E test infrastructure with Playwright MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mock chaperone fleet, seed-user CLI, six test specs, data-testid attributes, and fix wrong-password 401→403 status code. --- .gitignore | 8 + Makefile | 14 + admin/api/auth.go | 2 +- admin/api/auth_test.go | 6 +- admin/cmd/seed-user/main.go | 52 +++ admin/ui/e2e/auth.setup.js | 17 + admin/ui/e2e/global-setup.js | 79 ++++ admin/ui/e2e/global-teardown.js | 33 ++ admin/ui/e2e/helpers/constants.js | 3 + admin/ui/e2e/helpers/fixtures.js | 55 +++ admin/ui/e2e/helpers/services.js | 33 ++ admin/ui/e2e/playwright.config.js | 49 +++ admin/ui/e2e/specs/audit-log.spec.js | 56 +++ admin/ui/e2e/specs/auth.spec.js | 58 +++ admin/ui/e2e/specs/dashboard.spec.js | 112 +++++ admin/ui/e2e/specs/instance-detail.spec.js | 85 ++++ admin/ui/e2e/specs/settings.spec.js | 61 +++ admin/ui/e2e/specs/smoke.spec.js | 7 + admin/ui/package.json | 5 +- admin/ui/pnpm-lock.yaml | 38 ++ admin/ui/src/components/AddInstanceModal.vue | 1 + admin/ui/src/components/BaseInput.vue | 11 +- admin/ui/src/components/FleetKpiPanel.vue | 2 +- admin/ui/src/components/InstanceCard.vue | 3 + admin/ui/src/components/KpiCard.vue | 2 +- admin/ui/src/components/OverviewTab.vue | 3 +- admin/ui/src/components/StatusIndicator.vue | 1 + admin/ui/src/components/TrafficTab.vue | 2 +- admin/ui/src/layouts/AppLayout.vue | 5 +- admin/ui/src/views/AuditLogView.vue | 11 +- admin/ui/src/views/DashboardView.vue | 13 +- admin/ui/src/views/InstanceDetailView.vue | 8 +- admin/ui/src/views/LoginView.vue | 15 +- admin/ui/src/views/SettingsView.vue | 25 +- admin/ui/vite.config.js | 1 + test/mock-chaperone/mock-chaperone.js | 429 +++++++++++++++++++ 36 files changed, 1284 insertions(+), 21 deletions(-) create mode 100644 admin/cmd/seed-user/main.go create mode 100644 admin/ui/e2e/auth.setup.js create mode 100644 admin/ui/e2e/global-setup.js create mode 100644 admin/ui/e2e/global-teardown.js create mode 100644 admin/ui/e2e/helpers/constants.js create mode 100644 admin/ui/e2e/helpers/fixtures.js create mode 100644 admin/ui/e2e/helpers/services.js create mode 100644 admin/ui/e2e/playwright.config.js create mode 100644 admin/ui/e2e/specs/audit-log.spec.js create mode 100644 admin/ui/e2e/specs/auth.spec.js create mode 100644 admin/ui/e2e/specs/dashboard.spec.js create mode 100644 admin/ui/e2e/specs/instance-detail.spec.js create mode 100644 admin/ui/e2e/specs/settings.spec.js create mode 100644 admin/ui/e2e/specs/smoke.spec.js create mode 100644 test/mock-chaperone/mock-chaperone.js 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..0bd1a84 100644 --- a/Makefile +++ b/Makefile @@ -130,6 +130,20 @@ 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 exec playwright install chromium + +.PHONY: e2e-admin +e2e-admin: ## Run admin portal E2E tests (run e2e-admin-setup first) + cd $(ADMIN_UI_DIR) && pnpm e2e + .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..7c3f33c --- /dev/null +++ b/admin/ui/e2e/global-setup.js @@ -0,0 +1,79 @@ +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, '..', '..', '..'); + +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; + + // 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: 'pipe' }, + ); + process.env.E2E_MOCK_PID = String(mockProc.pid); + mockProc.on('error', (err) => console.error('[e2e] mock-chaperone error:', err)); + + 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: 'pipe', + 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); + adminProc.on('error', (err) => console.error('[e2e] admin server error:', err)); + + await waitForHealth('http://127.0.0.1:8080/api/health', 15_000); + console.log('[e2e] Admin server ready'); +} 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..aee153c --- /dev/null +++ b/admin/ui/e2e/helpers/fixtures.js @@ -0,0 +1,55 @@ +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'); + 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..9bcef21 --- /dev/null +++ b/admin/ui/e2e/playwright.config.js @@ -0,0 +1,49 @@ +// @ts-check +import { defineConfig } from '@playwright/test'; +import path from 'node:path'; + +const baseURL = 'http://127.0.0.1:8080'; + +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: [ + { + name: 'setup', + testMatch: 'auth.setup.js', + }, + { + name: 'chromium', + use: { + browserName: 'chromium', + storageState: path.join(import.meta.dirname, '.auth', 'user.json'), + }, + dependencies: ['setup'], + testMatch: 'specs/**/*.spec.js', + testIgnore: 'specs/auth.spec.js', + }, + { + name: 'auth', + use: { + browserName: 'chromium', + }, + testMatch: 'specs/auth.spec.js', + }, + ], + outputDir: './results', + globalSetup: './global-setup.js', + globalTeardown: './global-teardown.js', +}); 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..dd50328 --- /dev/null +++ b/admin/ui/e2e/specs/auth.spec.js @@ -0,0 +1,58 @@ +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 + await page.goto('/'); + 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..200f3e0 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,7 @@ ] }, "devDependencies": { + "@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..e8b1b61 100644 --- a/admin/ui/pnpm-lock.yaml +++ b/admin/ui/pnpm-lock.yaml @@ -24,6 +24,9 @@ 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: + '@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) @@ -321,6 +324,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==} @@ -808,6 +816,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 +1022,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'} @@ -1529,6 +1552,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': @@ -2010,6 +2037,9 @@ snapshots: flatted@3.3.4: {} + fsevents@2.3.2: + optional: true + fsevents@2.3.3: optional: true @@ -2208,6 +2238,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/components/AddInstanceModal.vue b/admin/ui/src/components/AddInstanceModal.vue index 8e5963c..7ab389c 100644 --- a/admin/ui/src/components/AddInstanceModal.vue +++ b/admin/ui/src/components/AddInstanceModal.vue @@ -33,6 +33,7 @@ $style.testResult, testResult.ok ? $style.testOk : $style.testFail, ]" + data-testid="test-result" > Connected successfully — version {{ testResult.version }} diff --git a/admin/ui/src/components/BaseInput.vue b/admin/ui/src/components/BaseInput.vue index 3adc0b8..730f89c 100644 --- a/admin/ui/src/components/BaseInput.vue +++ b/admin/ui/src/components/BaseInput.vue @@ -13,7 +13,16 @@ v-bind="$attrs" @input="$emit('update:modelValue', $event.target.value)" /> -

{{ error }}

+

+ {{ error }} +

diff --git a/admin/ui/src/components/FleetKpiPanel.vue b/admin/ui/src/components/FleetKpiPanel.vue index 51898cf..d0f38e7 100644 --- a/admin/ui/src/components/FleetKpiPanel.vue +++ b/admin/ui/src/components/FleetKpiPanel.vue @@ -1,5 +1,5 @@