Create disabled-user.spec.ts
This commit is contained in:
246
tests/e2e/disabled-user.spec.ts
Normal file
246
tests/e2e/disabled-user.spec.ts
Normal file
@@ -0,0 +1,246 @@
|
||||
/**
|
||||
* 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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user