Some checks failed
Build and Push Docker Images (Trusted) / build-and-push (., docker/caddy/Dockerfile, caddy) (push) Has been cancelled
Build and Push Docker Images (Trusted) / build-and-push (., docker/l4-port-manager/Dockerfile, l4-port-manager) (push) Has been cancelled
Build and Push Docker Images (Trusted) / build-and-push (., docker/web/Dockerfile, web) (push) Has been cancelled
Tests / test (push) Has been cancelled
355 lines
13 KiB
TypeScript
Executable File
355 lines
13 KiB
TypeScript
Executable File
/**
|
|
* E2E tests: Role-based access control.
|
|
*
|
|
* Verifies that:
|
|
* - Non-admin users (user, viewer) CAN access / and /profile
|
|
* - Non-admin users CANNOT access admin-only pages
|
|
* - Unauthenticated users are redirected to /login everywhere
|
|
* - Admin users can access all pages
|
|
*
|
|
* Test setup:
|
|
* - Creates "testuser" (role=user) and "testviewer" (role=viewer) in the database
|
|
* via `docker compose exec` + bun script inside the web container.
|
|
* - Logs in as each role in separate browser contexts.
|
|
*/
|
|
import { test, expect, type BrowserContext } from '@playwright/test';
|
|
import { execFileSync } from 'node:child_process';
|
|
|
|
const COMPOSE_ARGS = [
|
|
'compose',
|
|
'-f', 'docker-compose.yml',
|
|
'-f', 'tests/docker-compose.test.yml',
|
|
];
|
|
|
|
// Pages that require admin role (via requireAdmin in their own page.tsx)
|
|
const ADMIN_ONLY_PAGES = [
|
|
'/proxy-hosts',
|
|
'/l4-proxy-hosts',
|
|
'/certificates',
|
|
'/access-lists',
|
|
'/analytics',
|
|
'/waf',
|
|
'/audit-log',
|
|
'/settings',
|
|
'/users',
|
|
'/groups',
|
|
'/api-docs',
|
|
];
|
|
|
|
// Pages accessible to any authenticated user
|
|
const USER_ACCESSIBLE_PAGES = [
|
|
'/',
|
|
'/profile',
|
|
];
|
|
|
|
// All dashboard pages (union of both sets)
|
|
const ALL_DASHBOARD_PAGES = [...USER_ACCESSIBLE_PAGES, ...ADMIN_ONLY_PAGES];
|
|
|
|
/**
|
|
* Create a test user inside the running web container using bun.
|
|
* Uses Bun's built-in Bun.password.hash (bcrypt) — no npm deps needed.
|
|
*/
|
|
function ensureTestUser(username: string, password: string, role: string) {
|
|
const script = `
|
|
import { Database } from "bun:sqlite";
|
|
const db = new Database("./data/caddy-proxy-manager.db");
|
|
const email = "${username}@localhost";
|
|
const hash = await Bun.password.hash("${password}", { algorithm: "bcrypt", cost: 12 });
|
|
const now = new Date().toISOString();
|
|
const existing = db.query("SELECT id FROM users WHERE email = ?").get(email);
|
|
if (existing) {
|
|
db.run("UPDATE users SET passwordHash = ?, role = ?, status = 'active', updatedAt = ? WHERE email = ?",
|
|
[hash, "${role}", now, email]);
|
|
// Update or create credential account for Better Auth
|
|
const acc = db.query("SELECT id FROM accounts WHERE userId = ? AND providerId = 'credential'").get(existing.id);
|
|
if (acc) {
|
|
db.run("UPDATE accounts SET password = ?, updatedAt = ? WHERE id = ?", [hash, now, acc.id]);
|
|
} else {
|
|
db.run("INSERT INTO accounts (userId, accountId, providerId, password, createdAt, updatedAt) VALUES (?, ?, 'credential', ?, ?, ?)",
|
|
[existing.id, String(existing.id), hash, now, now]);
|
|
}
|
|
} else {
|
|
db.run(
|
|
"INSERT INTO users (email, name, passwordHash, role, provider, subject, username, status, createdAt, updatedAt) VALUES (?, ?, ?, ?, 'credentials', ?, ?, 'active', ?, ?)",
|
|
[email, "${username}", hash, "${role}", "${username}", "${username}", now, now]
|
|
);
|
|
const user = db.query("SELECT id FROM users WHERE email = ?").get(email);
|
|
// Create credential account for Better Auth
|
|
db.run("INSERT INTO accounts (userId, accountId, providerId, password, createdAt, updatedAt) VALUES (?, ?, 'credential', ?, ?, ?)",
|
|
[user.id, String(user.id), hash, now, now]);
|
|
}
|
|
`;
|
|
execFileSync('docker', [...COMPOSE_ARGS, 'exec', '-T', 'web', 'bun', '-e', script], {
|
|
cwd: process.cwd(),
|
|
stdio: 'pipe',
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Log in as the given user and return an authenticated browser context.
|
|
*/
|
|
async function loginAs(
|
|
browser: import('@playwright/test').Browser,
|
|
username: string,
|
|
password: string
|
|
): Promise<BrowserContext> {
|
|
const context = await browser.newContext();
|
|
const page = await context.newPage();
|
|
|
|
await page.goto('http://localhost:3000/login');
|
|
await page.getByLabel('Username').fill(username);
|
|
await page.getByLabel('Password').fill(password);
|
|
await page.getByRole('button', { name: 'Sign in', exact: true }).click();
|
|
|
|
// The login client does router.replace('/') on success — wait for that
|
|
await page.waitForURL((url) => !url.pathname.includes('/login'), { timeout: 60_000 });
|
|
await page.close();
|
|
return context;
|
|
}
|
|
|
|
// ── Unauthenticated access ────────────────────────────────────────────────
|
|
|
|
test.describe('Unauthenticated access', () => {
|
|
test.use({ storageState: { cookies: [], origins: [] } });
|
|
|
|
for (const path of ALL_DASHBOARD_PAGES) {
|
|
test(`${path} redirects to /login`, async ({ page }) => {
|
|
await page.goto(path);
|
|
await expect(page).toHaveURL(/\/login/, { timeout: 10_000 });
|
|
});
|
|
}
|
|
});
|
|
|
|
// ── Role-based access ─────────────────────────────────────────────────────
|
|
|
|
test.describe('Role-based access control', () => {
|
|
test.use({ storageState: { cookies: [], origins: [] } });
|
|
|
|
let userContext: BrowserContext;
|
|
let viewerContext: BrowserContext;
|
|
|
|
test.beforeAll(async ({ browser }) => {
|
|
// Create test users with non-admin roles
|
|
ensureTestUser('testuser', 'TestUserPass2026!', 'user');
|
|
ensureTestUser('testviewer', 'TestViewerPass2026!', 'viewer');
|
|
|
|
// Log in as each role
|
|
userContext = await loginAs(browser, 'testuser', 'TestUserPass2026!');
|
|
viewerContext = await loginAs(browser, 'testviewer', 'TestViewerPass2026!');
|
|
});
|
|
|
|
test.afterAll(async () => {
|
|
await userContext?.close();
|
|
await viewerContext?.close();
|
|
});
|
|
|
|
// ── "user" role — can access / and /profile ─────────────────────────
|
|
|
|
test('user role: / loads with welcome message', async () => {
|
|
const page = await userContext.newPage();
|
|
try {
|
|
await page.goto('/');
|
|
await expect(page).not.toHaveURL(/\/login/, { timeout: 10_000 });
|
|
await expect(page.getByText(/welcome back/i)).toBeVisible({ timeout: 5_000 });
|
|
} finally {
|
|
await page.close();
|
|
}
|
|
});
|
|
|
|
test('user role: / does not show admin stat cards', async () => {
|
|
const page = await userContext.newPage();
|
|
try {
|
|
await page.goto('/');
|
|
await expect(page.getByText(/welcome back/i)).toBeVisible({ timeout: 5_000 });
|
|
// Non-admin gets empty stats — no Proxy Hosts / Certificates / Access Lists cards
|
|
await expect(page.getByRole('link', { name: /proxy hosts/i })).not.toBeVisible({ timeout: 3_000 });
|
|
} finally {
|
|
await page.close();
|
|
}
|
|
});
|
|
|
|
test('user role: sidebar only shows Overview', async () => {
|
|
const page = await userContext.newPage();
|
|
try {
|
|
await page.goto('/');
|
|
await expect(page.getByText(/welcome back/i)).toBeVisible({ timeout: 5_000 });
|
|
// Overview should be in the nav
|
|
await expect(page.getByRole('link', { name: 'Overview' })).toBeVisible();
|
|
// Admin-only nav items should not be visible
|
|
await expect(page.getByRole('link', { name: 'Proxy Hosts' })).not.toBeVisible();
|
|
await expect(page.getByRole('link', { name: 'Settings' })).not.toBeVisible();
|
|
await expect(page.getByRole('link', { name: 'Users' })).not.toBeVisible();
|
|
} finally {
|
|
await page.close();
|
|
}
|
|
});
|
|
|
|
test('user role: /profile loads successfully', async () => {
|
|
const page = await userContext.newPage();
|
|
try {
|
|
await page.goto('/profile');
|
|
await expect(page).not.toHaveURL(/\/login/, { timeout: 10_000 });
|
|
await expect(page.getByText(/profile|password/i).first()).toBeVisible({ timeout: 5_000 });
|
|
} finally {
|
|
await page.close();
|
|
}
|
|
});
|
|
|
|
// ── "viewer" role — can access / and /profile ───────────────────────
|
|
|
|
test('viewer role: / loads with welcome message', async () => {
|
|
const page = await viewerContext.newPage();
|
|
try {
|
|
await page.goto('/');
|
|
await expect(page).not.toHaveURL(/\/login/, { timeout: 10_000 });
|
|
await expect(page.getByText(/welcome back/i)).toBeVisible({ timeout: 5_000 });
|
|
} finally {
|
|
await page.close();
|
|
}
|
|
});
|
|
|
|
test('viewer role: / does not show admin stat cards', async () => {
|
|
const page = await viewerContext.newPage();
|
|
try {
|
|
await page.goto('/');
|
|
await expect(page.getByText(/welcome back/i)).toBeVisible({ timeout: 5_000 });
|
|
await expect(page.getByRole('link', { name: /proxy hosts/i })).not.toBeVisible({ timeout: 3_000 });
|
|
} finally {
|
|
await page.close();
|
|
}
|
|
});
|
|
|
|
test('viewer role: sidebar only shows Overview', async () => {
|
|
const page = await viewerContext.newPage();
|
|
try {
|
|
await page.goto('/');
|
|
await expect(page.getByText(/welcome back/i)).toBeVisible({ timeout: 5_000 });
|
|
await expect(page.getByRole('link', { name: 'Overview' })).toBeVisible();
|
|
await expect(page.getByRole('link', { name: 'Proxy Hosts' })).not.toBeVisible();
|
|
await expect(page.getByRole('link', { name: 'Settings' })).not.toBeVisible();
|
|
} finally {
|
|
await page.close();
|
|
}
|
|
});
|
|
|
|
test('viewer role: /profile loads successfully', async () => {
|
|
const page = await viewerContext.newPage();
|
|
try {
|
|
await page.goto('/profile');
|
|
await expect(page).not.toHaveURL(/\/login/, { timeout: 10_000 });
|
|
await expect(page.getByText(/profile|password/i).first()).toBeVisible({ timeout: 5_000 });
|
|
} finally {
|
|
await page.close();
|
|
}
|
|
});
|
|
|
|
// ── "user" role — blocked from admin-only pages ─────────────────────
|
|
|
|
for (const path of ADMIN_ONLY_PAGES) {
|
|
test(`user role: ${path} is blocked`, async () => {
|
|
const page = await userContext.newPage();
|
|
try {
|
|
const response = await page.goto(path);
|
|
// requireAdmin() throws "Administrator privileges required".
|
|
// Next.js renders the error boundary or returns 500.
|
|
const status = response?.status() ?? 0;
|
|
const url = page.url();
|
|
|
|
const isBlocked =
|
|
status >= 400 ||
|
|
url.includes('/login') ||
|
|
await page.getByText(/administrator privileges|error|forbidden|not authorized/i)
|
|
.isVisible({ timeout: 3_000 }).catch(() => false);
|
|
|
|
expect(isBlocked).toBe(true);
|
|
} finally {
|
|
await page.close();
|
|
}
|
|
});
|
|
}
|
|
|
|
// ── "viewer" role — blocked from admin-only pages ───────────────────
|
|
|
|
for (const path of ADMIN_ONLY_PAGES) {
|
|
test(`viewer role: ${path} is blocked`, async () => {
|
|
const page = await viewerContext.newPage();
|
|
try {
|
|
const response = await page.goto(path);
|
|
const status = response?.status() ?? 0;
|
|
const url = page.url();
|
|
|
|
const isBlocked =
|
|
status >= 400 ||
|
|
url.includes('/login') ||
|
|
await page.getByText(/administrator privileges|error|forbidden|not authorized/i)
|
|
.isVisible({ timeout: 3_000 }).catch(() => false);
|
|
|
|
expect(isBlocked).toBe(true);
|
|
} finally {
|
|
await page.close();
|
|
}
|
|
});
|
|
}
|
|
|
|
// ── Admin user — can access all pages ───────────────────────────────
|
|
|
|
test('admin role: all dashboard pages are accessible', async ({ browser }, testInfo) => {
|
|
testInfo.setTimeout(90_000);
|
|
// Use the pre-authenticated admin state from global-setup
|
|
const adminContext = await browser.newContext({
|
|
storageState: require('path').resolve(__dirname, '../.auth/admin.json'),
|
|
});
|
|
try {
|
|
for (const path of ALL_DASHBOARD_PAGES) {
|
|
const page = await adminContext.newPage();
|
|
const response = await page.goto(path);
|
|
const status = response?.status() ?? 0;
|
|
expect(status).toBeLessThan(400);
|
|
expect(page.url()).not.toContain('/login');
|
|
await page.close();
|
|
}
|
|
} finally {
|
|
await adminContext.close();
|
|
}
|
|
});
|
|
|
|
test('admin role: sidebar shows all nav items', async ({ browser }) => {
|
|
const adminContext = await browser.newContext({
|
|
storageState: require('path').resolve(__dirname, '../.auth/admin.json'),
|
|
});
|
|
try {
|
|
const page = await adminContext.newPage();
|
|
await page.goto('/');
|
|
await expect(page.getByRole('link', { name: 'Overview' })).toBeVisible();
|
|
await expect(page.getByRole('link', { name: 'Proxy Hosts', exact: true })).toBeVisible();
|
|
await expect(page.getByRole('link', { name: 'Settings' })).toBeVisible();
|
|
await expect(page.getByRole('link', { name: 'Users' })).toBeVisible();
|
|
await page.close();
|
|
} finally {
|
|
await adminContext.close();
|
|
}
|
|
});
|
|
|
|
// ── API endpoints — non-admin should be blocked ───────────────────────
|
|
|
|
test('user role: API v1 endpoints return 401/403', async () => {
|
|
const page = await userContext.newPage();
|
|
try {
|
|
const response = await page.request.get('/api/v1/proxy-hosts');
|
|
expect(response.status()).toBeGreaterThanOrEqual(400);
|
|
} finally {
|
|
await page.close();
|
|
}
|
|
});
|
|
|
|
test('viewer role: API v1 endpoints return 401/403', async () => {
|
|
const page = await viewerContext.newPage();
|
|
try {
|
|
const response = await page.request.get('/api/v1/proxy-hosts');
|
|
expect(response.status()).toBeGreaterThanOrEqual(400);
|
|
} finally {
|
|
await page.close();
|
|
}
|
|
});
|
|
});
|