Files
caddy-proxy-manager/tests/e2e/disabled-user.spec.ts
2026-04-12 23:25:28 +02:00

247 lines
8.4 KiB
TypeScript

/**
* E2E tests: Disabled user enforcement.
*
* Verifies that disabling a user actually blocks them:
*
* 1. Disabled user's existing UI session is rejected (pages redirect to /login)
* 2. Disabled user cannot log in with credentials
* 3. Disabled user's API token returns 401
* 4. Re-enabling the user restores access
*/
import { test, expect, type BrowserContext } from '@playwright/test';
import { execFileSync } from 'node:child_process';
const BASE = 'http://localhost:3000';
const API_BASE = `${BASE}/api/v1`;
const COMPOSE_ARGS = [
'compose',
'-f', 'docker-compose.yml',
'-f', 'tests/docker-compose.test.yml',
];
const TEST_USERNAME = 'disabletest';
const TEST_EMAIL = `${TEST_USERNAME}@localhost`;
const TEST_PASSWORD = 'DisableTest2026!';
// ── Helpers ─────────────────────────────────────────────────────────────
function execInContainer(script: string) {
execFileSync('docker', [...COMPOSE_ARGS, 'exec', '-T', 'web', 'bun', '-e', script], {
cwd: process.cwd(),
stdio: 'pipe',
});
}
function ensureTestUser() {
const script = `
import { Database } from "bun:sqlite";
const db = new Database("./data/caddy-proxy-manager.db");
const email = "${TEST_EMAIL}";
const hash = await Bun.password.hash("${TEST_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 = 'user', status = 'active', updatedAt = ? WHERE email = ?",
[hash, now, email]);
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 (?, ?, ?, 'user', 'credentials', ?, ?, 'active', ?, ?)",
[email, "${TEST_USERNAME}", hash, "${TEST_USERNAME}", "${TEST_USERNAME}", now, now]
);
const user = db.query("SELECT id FROM users WHERE email = ?").get(email);
db.run("INSERT INTO accounts (userId, accountId, providerId, password, createdAt, updatedAt) VALUES (?, ?, 'credential', ?, ?, ?)",
[user.id, String(user.id), hash, now, now]);
}
`;
execInContainer(script);
}
function setUserStatus(status: 'active' | 'disabled') {
const script = `
import { Database } from "bun:sqlite";
const db = new Database("./data/caddy-proxy-manager.db");
const now = new Date().toISOString();
db.run("UPDATE users SET status = ?, updatedAt = ? WHERE email = ?",
["${status}", now, "${TEST_EMAIL}"]);
`;
execInContainer(script);
}
function createApiToken(): string {
const token = `test-disabled-token-${Date.now()}`;
const script = `
import { Database } from "bun:sqlite";
import { createHash } from "crypto";
const db = new Database("./data/caddy-proxy-manager.db");
const user = db.query("SELECT id FROM users WHERE email = ?").get("${TEST_EMAIL}");
if (!user) { console.error("User not found"); process.exit(1); }
const hash = createHash("sha256").update("${token}").digest("hex");
const now = new Date().toISOString();
db.run("INSERT INTO api_tokens (name, tokenHash, createdBy, createdAt) VALUES (?, ?, ?, ?)",
["e2e-disabled-test", hash, user.id, now]);
`;
execInContainer(script);
return token;
}
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(`${BASE}/login`);
await page.getByLabel('Username').fill(username);
await page.getByLabel('Password').fill(password);
await page.getByRole('button', { name: 'Sign in', exact: true }).click();
await page.waitForURL((url) => !url.pathname.includes('/login'), { timeout: 60_000 });
await page.close();
return context;
}
// ── Tests ───────────────────────────────────────────────────────────────
test.use({ storageState: { cookies: [], origins: [] } });
test.describe('Disabled user enforcement', () => {
test.beforeAll(async () => {
for (let i = 0; i < 3; i++) {
try {
ensureTestUser();
break;
} catch (e) {
if (i === 2) throw e;
await new Promise(r => setTimeout(r, 2000));
}
}
});
test.afterAll(async () => {
// Re-enable user so it doesn't affect other tests
try { setUserStatus('active'); } catch { /* best effort */ }
});
test('disabled user UI session is rejected', async ({ browser }) => {
// Log in while active
const context = await loginAs(browser, TEST_USERNAME, TEST_PASSWORD);
// Verify session works
const page = await context.newPage();
await page.goto(BASE);
await expect(page).not.toHaveURL(/\/login/, { timeout: 10_000 });
await page.close();
// Disable user
setUserStatus('disabled');
// Existing session should now be rejected — page should redirect to /login
const page2 = await context.newPage();
await page2.goto(BASE);
await expect(page2).toHaveURL(/\/login/, { timeout: 15_000 });
await page2.close();
await context.close();
// Re-enable for subsequent tests
setUserStatus('active');
});
test('disabled user cannot log in', async ({ page }) => {
// Disable first
setUserStatus('disabled');
await page.goto(`${BASE}/login`);
await page.getByLabel('Username').fill(TEST_USERNAME);
await page.getByLabel('Password').fill(TEST_PASSWORD);
await page.getByRole('button', { name: 'Sign in', exact: true }).click();
// Should stay on login page or show an error
await expect(async () => {
const url = page.url();
const hasError = await page.getByText(/invalid|disabled|error|failed|incorrect/i)
.isVisible({ timeout: 1_000 }).catch(() => false);
expect(url.includes('/login') || hasError).toBe(true);
}).toPass({ timeout: 15_000 });
// Re-enable for subsequent tests
setUserStatus('active');
});
test('disabled user API token returns 401', async ({ request }) => {
const token = createApiToken();
// Token should work while active
const res1 = await request.get(`${API_BASE}/tokens`, {
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json',
},
});
expect(res1.status()).toBe(200);
// Disable user
setUserStatus('disabled');
// Token should now be rejected
const res2 = await request.get(`${API_BASE}/tokens`, {
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json',
},
});
expect(res2.status()).toBe(401);
// Re-enable for subsequent tests
setUserStatus('active');
});
test('re-enabling user restores API access', async ({ request }) => {
const token = createApiToken();
// Disable
setUserStatus('disabled');
const res1 = await request.get(`${API_BASE}/tokens`, {
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json',
},
});
expect(res1.status()).toBe(401);
// Re-enable
setUserStatus('active');
const res2 = await request.get(`${API_BASE}/tokens`, {
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json',
},
});
expect(res2.status()).toBe(200);
});
test('re-enabling user restores UI login', async ({ browser }) => {
// Disable then re-enable
setUserStatus('disabled');
setUserStatus('active');
// Should be able to log in again
const context = await loginAs(browser, TEST_USERNAME, TEST_PASSWORD);
const page = await context.newPage();
await page.goto(BASE);
await expect(page).not.toHaveURL(/\/login/, { timeout: 10_000 });
await page.close();
await context.close();
});
});