Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions .github/workflows/ci-admin.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ on:
pull_request:
paths:
- 'admin/**'
- 'test/mock-chaperone/**'
- 'Makefile'
- '.github/workflows/ci-admin.yml'

Expand Down Expand Up @@ -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
Comment thread
arnaugiralt marked this conversation as resolved.
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
Expand Down
8 changes: 8 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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/

Expand Down
19 changes: 19 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion admin/api/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
6 changes: 3 additions & 3 deletions admin/api/auth_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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)
}
}

Expand Down
52 changes: 52 additions & 0 deletions admin/cmd/seed-user/main.go
Original file line number Diff line number Diff line change
@@ -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 <path> --username <name> --password <pass>")
}

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
}
17 changes: 17 additions & 0 deletions admin/ui/e2e/auth.setup.js
Original file line number Diff line number Diff line change
@@ -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 });
});
95 changes: 95 additions & 0 deletions admin/ui/e2e/global-setup.js
Original file line number Diff line number Diff line change
@@ -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() {
Comment thread
arnaugiralt marked this conversation as resolved.
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;
}
}
33 changes: 33 additions & 0 deletions admin/ui/e2e/global-teardown.js
Original file line number Diff line number Diff line change
@@ -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
}
}
}
3 changes: 3 additions & 0 deletions admin/ui/e2e/helpers/constants.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export const TEST_USER = 'admin';
export const PW_CHANGE_USER = 'admin-pw-test';
export const TEST_PASSWORD = 'testpassword12';
56 changes: 56 additions & 0 deletions admin/ui/e2e/helpers/fixtures.js
Original file line number Diff line number Diff line change
@@ -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 };
Loading
Loading