added tests

This commit is contained in:
fuomag9
2026-03-07 02:02:14 +01:00
parent 7e134fe6b5
commit 3572b482e8
31 changed files with 3221 additions and 14 deletions

5
.gitignore vendored
View File

@@ -17,3 +17,8 @@ docs/plans
/.playwright-mcp
/.worktrees
docker-compose.override.yml
# Playwright auth state
tests/.auth/
test-results/
playwright-report/

1633
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -10,7 +10,13 @@
"lint": "eslint .",
"typecheck": "tsc --noEmit",
"db:generate": "drizzle-kit generate",
"db:migrate": "drizzle-kit migrate"
"db:migrate": "drizzle-kit migrate",
"test": "vitest run --config tests/vitest.config.ts",
"test:watch": "vitest --config tests/vitest.config.ts",
"test:ui": "vitest --ui --config tests/vitest.config.ts",
"test:e2e": "playwright test --config tests/playwright.config.ts",
"test:e2e:ui": "playwright test --ui --config tests/playwright.config.ts",
"test:e2e:headed": "playwright test --headed --config tests/playwright.config.ts"
},
"dependencies": {
"@emotion/react": "^11.14.0",
@@ -39,16 +45,20 @@
"devDependencies": {
"@eslint/js": "^10.0.1",
"@next/eslint-plugin-next": "^16.1.6",
"@playwright/test": "^1.58.2",
"@types/d3-geo": "^3.1.0",
"@types/node": "^25.3.3",
"@types/node-forge": "^1.3.14",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@types/topojson-client": "^3.1.5",
"@vitest/ui": "^4.0.18",
"drizzle-kit": "^0.31.9",
"eslint": "^10.0.2",
"typescript-eslint": "^8.56.1",
"typescript": "^5.9.3",
"typescript-eslint": "^8.56.1",
"vite-tsconfig-paths": "^6.1.1",
"vitest": "^4.0.18",
"world-atlas": "^2.0.2"
}
}

View File

@@ -84,7 +84,7 @@ interface CaddyLogEntry {
// Build a set of signatures from caddy-blocker's "request blocked" entries so we
// can mark the corresponding "handled request" rows correctly instead of using
// status === 403 (which would also catch legitimate upstream 403s).
function collectBlockedSignatures(lines: string[]): Set<string> {
export function collectBlockedSignatures(lines: string[]): Set<string> {
const blocked = new Set<string>();
for (const line of lines) {
let entry: CaddyLogEntry;
@@ -97,7 +97,7 @@ function collectBlockedSignatures(lines: string[]): Set<string> {
return blocked;
}
function parseLine(line: string, blocked: Set<string>): typeof trafficEvents.$inferInsert | null {
export function parseLine(line: string, blocked: Set<string>): typeof trafficEvents.$inferInsert | null {
let entry: CaddyLogEntry;
try {
entry = JSON.parse(line);

View File

@@ -65,7 +65,7 @@ interface RuleInfo {
severity: string | null;
}
function extractBracketField(msg: string, field: string): string | null {
export function extractBracketField(msg: string, field: string): string | null {
const m = msg.match(new RegExp(`\\[${field} "([^"]*)"\\]`));
return m ? m[1] : null;
}

View File

@@ -0,0 +1,23 @@
services:
web:
environment:
SESSION_SECRET: "test-session-secret-32chars!xxx"
ADMIN_USERNAME: testadmin
ADMIN_PASSWORD: "TestPassword2026!"
BASE_URL: http://localhost:3000
NEXTAUTH_URL: http://localhost:3000
caddy:
ports:
- "80:80"
- "443:443"
volumes:
caddy-manager-data:
name: caddy-manager-data-test
caddy-data:
name: caddy-data-test
caddy-config:
name: caddy-config-test
caddy-logs:
name: caddy-logs-test
geoip-data:
name: geoip-data-test

View File

@@ -0,0 +1,51 @@
import { test, expect } from '@playwright/test';
test.describe('Access Lists', () => {
test('page loads without redirecting to login', async ({ page }) => {
await page.goto('/access-lists');
await expect(page).not.toHaveURL(/login/);
await expect(page.locator('body')).toBeVisible();
});
test('page has an Add button', async ({ page }) => {
await page.goto('/access-lists');
await expect(page.getByRole('button', { name: /add/i })).toBeVisible();
});
test('create access list — appears in the list', async ({ page }) => {
await page.goto('/access-lists');
await page.getByRole('button', { name: /add/i }).click();
// Fill in the name
const nameInput = page.getByLabel(/name/i).first();
await nameInput.fill('E2E Test List');
// Save
await page.getByRole('button', { name: /save|create|add/i }).last().click();
// Should appear in the list
await expect(page.getByText('E2E Test List')).toBeVisible({ timeout: 10000 });
});
test('delete access list removes it', async ({ page }) => {
await page.goto('/access-lists');
// Create one to delete
await page.getByRole('button', { name: /add/i }).click();
const nameInput = page.getByLabel(/name/i).first();
await nameInput.fill('Delete This List');
await page.getByRole('button', { name: /save|create|add/i }).last().click();
await expect(page.getByText('Delete This List')).toBeVisible({ timeout: 10000 });
// Delete it
const row = page.locator('tr', { hasText: 'Delete This List' });
await row.getByRole('button', { name: /delete/i }).click();
const confirmBtn = page.getByRole('button', { name: /confirm|yes|delete/i });
if (await confirmBtn.isVisible({ timeout: 2000 }).catch(() => false)) {
await confirmBtn.click();
}
await expect(page.getByText('Delete This List')).not.toBeVisible({ timeout: 10000 });
});
});

View File

@@ -0,0 +1,32 @@
import { test, expect } from '@playwright/test';
test.describe('Analytics', () => {
test('analytics page loads without redirecting to login', async ({ page }) => {
await page.goto('/analytics');
await expect(page).not.toHaveURL(/login/);
await expect(page.locator('body')).toBeVisible();
});
test('analytics page renders content', async ({ page }) => {
await page.goto('/analytics');
// Should have analytics-related content
const hasContent = await page.locator('text=/analytics|traffic|requests|blocked/i').count() > 0;
expect(hasContent).toBe(true);
});
test('analytics page shows summary stats section', async ({ page }) => {
await page.goto('/analytics');
// Stats or metrics are visible
await expect(page.locator('body')).toBeVisible();
// The page should have some numeric or stat display
const hasStats = await page.locator('[class*="stat"], [class*="metric"], [class*="card"], [class*="summary"]').count() > 0;
// Just verify it doesn't error out — the content may vary
expect(await page.title()).toBeTruthy();
});
test('analytics page does not show error content', async ({ page }) => {
await page.goto('/analytics');
// Should not show error states
await expect(page.locator('text=/500|internal server error/i')).not.toBeVisible();
});
});

View File

@@ -0,0 +1,48 @@
import { test, expect } from '@playwright/test';
test.describe('Audit Log', () => {
test('audit log page loads without redirecting to login', async ({ page }) => {
await page.goto('/audit-log');
await expect(page).not.toHaveURL(/login/);
await expect(page.locator('body')).toBeVisible();
});
test('audit log page has a table or list', async ({ page }) => {
await page.goto('/audit-log');
// Should have table or list structure
const hasTable = await page.locator('table, [role="grid"], [role="table"]').count() > 0;
const hasList = await page.locator('ul, ol').count() > 0;
const hasRows = await page.locator('tr').count() > 0;
expect(hasTable || hasList || hasRows).toBe(true);
});
test('creating a proxy host creates audit log entry', async ({ page }) => {
// Create a proxy host
await page.goto('/proxy-hosts');
await page.getByRole('button', { name: /add/i }).click();
await expect(page.getByRole('dialog')).toBeVisible();
const domainInput = page.getByLabel(/domain/i).first();
await domainInput.fill('audit-test.local');
const upstreamInput = page.getByLabel(/upstream/i).first();
await upstreamInput.fill('localhost:8888');
await page.getByRole('button', { name: /save|create|add/i }).last().click();
await expect(page.getByText('audit-test.local')).toBeVisible({ timeout: 10000 });
// Check audit log
await page.goto('/audit-log');
// Should show some entry related to proxy_host or create
await expect(page.locator('body')).toBeVisible();
});
test('audit log page has search functionality', async ({ page }) => {
await page.goto('/audit-log');
// Should have a search input
const hasSearch = await page.getByRole('searchbox').count() > 0
|| await page.getByPlaceholder(/search/i).count() > 0
|| await page.getByLabel(/search/i).count() > 0;
expect(hasSearch).toBe(true);
});
});

42
tests/e2e/auth.spec.ts Normal file
View File

@@ -0,0 +1,42 @@
import { test, expect } from '@playwright/test';
// Auth tests run WITHOUT pre-authenticated state
test.use({ storageState: { cookies: [], origins: [] } });
test.describe('Authentication', () => {
test('unauthenticated access to / redirects to /login', async ({ page }) => {
await page.goto('/');
await expect(page).toHaveURL(/\/login/);
});
test('unauthenticated access to /proxy-hosts redirects to /login', async ({ page }) => {
await page.goto('/proxy-hosts');
await expect(page).toHaveURL(/\/login/);
});
test('/login page renders the login form', async ({ page }) => {
await page.goto('/login');
await expect(page.getByRole('textbox', { name: /username/i })).toBeVisible();
await expect(page.getByRole('textbox', { name: /password/i })).toBeVisible();
await expect(page.getByRole('button', { name: /sign in/i })).toBeVisible();
});
test('/login with wrong password shows an error message', async ({ page }) => {
await page.goto('/login');
await page.getByRole('textbox', { name: /username/i }).fill('testadmin');
await page.getByRole('textbox', { name: /password/i }).fill('WrongPassword!');
await page.getByRole('button', { name: /sign in/i }).click();
// Should show an error and stay on login
await expect(page).toHaveURL(/\/login/);
await expect(page.locator('text=/invalid|error|incorrect/i')).toBeVisible({ timeout: 5000 });
});
test('/login with correct credentials lands on dashboard', async ({ page }) => {
await page.goto('/login');
await page.getByRole('textbox', { name: /username/i }).fill('testadmin');
await page.getByRole('textbox', { name: /password/i }).fill('TestPassword2026!');
await page.getByRole('button', { name: /sign in/i }).click();
// Should redirect away from login
await expect(page).not.toHaveURL(/\/login/, { timeout: 10000 });
});
});

View File

@@ -0,0 +1,25 @@
import { test, expect } from '@playwright/test';
test.describe('Certificates', () => {
test('page loads with tabs visible', async ({ page }) => {
await page.goto('/certificates');
// At minimum the page should load without error
await expect(page).not.toHaveURL(/error|login/);
await expect(page.locator('body')).toBeVisible();
});
test('certificates page has certificate management UI', async ({ page }) => {
await page.goto('/certificates');
// Should have some kind of Add button or tab UI
await expect(page.locator('body')).toBeVisible();
// Look for tabs or buttons
const hasAddButton = await page.getByRole('button', { name: /add|new|create/i }).count() > 0;
const hasTab = await page.getByRole('tab').count() > 0;
expect(hasAddButton || hasTab).toBe(true);
});
test('navigating to certificates does not redirect to login', async ({ page }) => {
await page.goto('/certificates');
await expect(page).not.toHaveURL(/login/);
});
});

52
tests/e2e/profile.spec.ts Normal file
View File

@@ -0,0 +1,52 @@
import { test, expect } from '@playwright/test';
test.describe('Profile', () => {
test('profile page loads without redirecting to login', async ({ page }) => {
await page.goto('/profile');
await expect(page).not.toHaveURL(/login/);
await expect(page.locator('body')).toBeVisible();
});
test('profile page shows username or email', async ({ page }) => {
await page.goto('/profile');
// Should show the user's email or username (testadmin)
await expect(page.locator('text=/testadmin|testadmin@/i')).toBeVisible({ timeout: 5000 });
});
test('change password: wrong current password shows error', async ({ page }) => {
await page.goto('/profile');
const currentPasswordInput = page.getByLabel(/current password/i).first();
if (await currentPasswordInput.isVisible({ timeout: 3000 }).catch(() => false)) {
await currentPasswordInput.fill('WrongCurrentPassword!');
const newPasswordInput = page.getByLabel(/new password/i).first();
await newPasswordInput.fill('NewPassword2026!');
const confirmInput = page.getByLabel(/confirm/i).first();
if (await confirmInput.isVisible({ timeout: 1000 }).catch(() => false)) {
await confirmInput.fill('NewPassword2026!');
}
await page.getByRole('button', { name: /change|update|save.*password/i }).click();
await expect(page.locator('text=/incorrect|wrong|invalid|error/i')).toBeVisible({ timeout: 5000 });
} else {
// UI may be different
test.skip();
}
});
test('change password: new password too short shows validation error', async ({ page }) => {
await page.goto('/profile');
const newPasswordInput = page.getByLabel(/new password/i).first();
if (await newPasswordInput.isVisible({ timeout: 3000 }).catch(() => false)) {
await newPasswordInput.fill('short');
await newPasswordInput.blur();
// Should show validation error about length
await expect(page.locator('text=/least.*char|minimum|too short/i')).toBeVisible({ timeout: 3000 });
} else {
test.skip();
}
});
});

View File

@@ -0,0 +1,64 @@
import { test, expect } from '@playwright/test';
test.describe('Proxy Hosts', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/proxy-hosts');
});
test('page loads with Add button visible', async ({ page }) => {
await expect(page.getByRole('button', { name: /add/i })).toBeVisible();
});
test('clicking Add opens a dialog with form fields', async ({ page }) => {
await page.getByRole('button', { name: /add/i }).click();
// Dialog should open with domain and upstream fields
await expect(page.getByRole('dialog')).toBeVisible();
await expect(page.getByLabel(/domain/i)).toBeVisible();
});
test('create a proxy host — appears in the table', async ({ page }) => {
await page.getByRole('button', { name: /add/i }).click();
await expect(page.getByRole('dialog')).toBeVisible();
// Fill in the domain field
const domainInput = page.getByLabel(/domain/i).first();
await domainInput.fill('e2etest.local');
// Fill upstream
const upstreamInput = page.getByLabel(/upstream/i).first();
await upstreamInput.fill('localhost:9999');
// Submit
await page.getByRole('button', { name: /save|create|add/i }).last().click();
// Should appear in the table
await expect(page.getByText('e2etest.local')).toBeVisible({ timeout: 10000 });
});
test('delete proxy host removes it from table', async ({ page }) => {
// First create one
await page.getByRole('button', { name: /add/i }).click();
await expect(page.getByRole('dialog')).toBeVisible();
const domainInput = page.getByLabel(/domain/i).first();
await domainInput.fill('delete-me.local');
const upstreamInput = page.getByLabel(/upstream/i).first();
await upstreamInput.fill('localhost:7777');
await page.getByRole('button', { name: /save|create|add/i }).last().click();
await expect(page.getByText('delete-me.local')).toBeVisible({ timeout: 10000 });
// Find and click the delete button for this row
const row = page.locator('tr', { hasText: 'delete-me.local' });
await row.getByRole('button', { name: /delete/i }).click();
// Confirm dialog if present
const confirmBtn = page.getByRole('button', { name: /confirm|yes|delete/i });
if (await confirmBtn.isVisible({ timeout: 2000 }).catch(() => false)) {
await confirmBtn.click();
}
await expect(page.getByText('delete-me.local')).not.toBeVisible({ timeout: 10000 });
});
});

View File

@@ -0,0 +1,41 @@
import { test, expect } from '@playwright/test';
test.describe('Settings', () => {
test('settings page loads without redirecting to login', async ({ page }) => {
await page.goto('/settings');
await expect(page).not.toHaveURL(/login/);
await expect(page.locator('body')).toBeVisible();
});
test('settings page renders content', async ({ page }) => {
await page.goto('/settings');
// Settings page should have some sections
await expect(page.locator('body')).toBeVisible();
// Check for settings-related text
const hasContent = await page.locator('text=/settings|general|cloudflare|dns|logging/i').count() > 0;
expect(hasContent).toBe(true);
});
test('settings page has save buttons', async ({ page }) => {
await page.goto('/settings');
const saveButtons = page.getByRole('button', { name: /save/i });
await expect(saveButtons.first()).toBeVisible();
});
test('general settings section: can fill and save primary domain', async ({ page }) => {
await page.goto('/settings');
// Look for the primary domain or general settings input
const domainInput = page.getByLabel(/primary domain/i).first();
if (await domainInput.isVisible({ timeout: 3000 }).catch(() => false)) {
await domainInput.fill('test.local');
const saveBtn = page.getByRole('button', { name: /save/i }).first();
await saveBtn.click();
// Toast or success indicator should appear
await expect(page.locator('text=/saved|success/i')).toBeVisible({ timeout: 5000 });
} else {
// If the UI is different, just verify the page loaded
test.skip();
}
});
});

23
tests/e2e/waf.spec.ts Normal file
View File

@@ -0,0 +1,23 @@
import { test, expect } from '@playwright/test';
test.describe('WAF', () => {
test('WAF page loads without redirecting to login', async ({ page }) => {
await page.goto('/waf');
await expect(page).not.toHaveURL(/login/);
await expect(page.locator('body')).toBeVisible();
});
test('WAF page has global settings visible', async ({ page }) => {
await page.goto('/waf');
// Should have some WAF-related content
await expect(page.locator('body')).toBeVisible();
// Look for WAF, mode, or enable controls
const hasWafContent = await page.locator('text=/waf|mode|enabled|owasp/i').count() > 0;
expect(hasWafContent).toBe(true);
});
test('WAF page has save button', async ({ page }) => {
await page.goto('/waf');
await expect(page.getByRole('button', { name: /save/i })).toBeVisible();
});
});

118
tests/global-setup.ts Normal file
View File

@@ -0,0 +1,118 @@
import { execFileSync } from 'node:child_process';
import { mkdirSync, writeFileSync } from 'node:fs';
import { resolve } from 'node:path';
const COMPOSE_ARGS = [
'compose',
'-f', 'docker-compose.yml',
'-f', 'tests/docker-compose.test.yml',
];
const HEALTH_URL = 'http://localhost:3000/api/health';
const AUTH_DIR = resolve(process.cwd(), 'tests/.auth');
const AUTH_FILE = resolve(AUTH_DIR, 'admin.json');
const MAX_WAIT_MS = 120_000;
const POLL_INTERVAL_MS = 2_000;
async function waitForHealth(): Promise<void> {
const start = Date.now();
while (Date.now() - start < MAX_WAIT_MS) {
try {
const res = await fetch(HEALTH_URL);
if (res.status === 200) {
console.log('[global-setup] App is healthy');
return;
}
} catch {
// not ready yet
}
await new Promise((r) => setTimeout(r, POLL_INTERVAL_MS));
}
throw new Error(`App did not become healthy within ${MAX_WAIT_MS}ms`);
}
async function seedAuthState(): Promise<void> {
// Navigate via the web login form to get a real session cookie.
// The app uses credentials-based NextAuth signin.
// We POST to the credentials callback directly.
const callbackUrl = 'http://localhost:3000';
// First, get CSRF token from NextAuth
const csrfRes = await fetch('http://localhost:3000/api/auth/csrf');
const csrfData = await csrfRes.json() as { csrfToken: string };
const params = new URLSearchParams({
csrfToken: csrfData.csrfToken,
username: 'testadmin',
password: 'TestPassword2026!',
callbackUrl,
json: 'true',
});
const signinRes = await fetch('http://localhost:3000/api/auth/callback/credentials', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
Cookie: csrfRes.headers.get('set-cookie') ?? '',
},
body: params.toString(),
redirect: 'manual',
});
// Collect all cookies from both responses
const allCookieHeaders: string[] = [];
for (const [k, v] of csrfRes.headers.entries()) {
if (k === 'set-cookie') allCookieHeaders.push(v);
}
for (const [k, v] of signinRes.headers.entries()) {
if (k === 'set-cookie') allCookieHeaders.push(v);
}
const cookies = allCookieHeaders.flatMap((header) =>
header.split(/,(?=[^ ])/).map((cookie) => {
const parts = cookie.split(';').map((p) => p.trim());
const [nameVal, ...attrs] = parts;
const eqIdx = nameVal.indexOf('=');
if (eqIdx === -1) return null;
const name = nameVal.slice(0, eqIdx);
const value = nameVal.slice(eqIdx + 1);
const attrMap: Record<string, string | boolean> = {};
for (const attr of attrs) {
const [k, v] = attr.split('=').map((s) => s.trim());
attrMap[k.toLowerCase()] = v ?? true;
}
return {
name,
value,
domain: 'localhost',
path: typeof attrMap['path'] === 'string' ? attrMap['path'] : '/',
httpOnly: attrMap['httponly'] === true,
secure: attrMap['secure'] === true,
sameSite: typeof attrMap['samesite'] === 'string'
? attrMap['samesite'].charAt(0).toUpperCase() + attrMap['samesite'].slice(1).toLowerCase()
: 'Lax',
};
}).filter(Boolean)
);
const storageState = {
cookies,
origins: [],
};
mkdirSync(AUTH_DIR, { recursive: true });
writeFileSync(AUTH_FILE, JSON.stringify(storageState, null, 2));
console.log('[global-setup] Auth state seeded at', AUTH_FILE);
}
export default async function globalSetup() {
console.log('[global-setup] Starting Docker Compose test stack...');
execFileSync('docker', [...COMPOSE_ARGS, 'up', '-d', '--build'], {
stdio: 'inherit',
cwd: process.cwd(),
});
await waitForHealth();
await seedAuthState();
console.log('[global-setup] Done.');
}

29
tests/global-teardown.ts Normal file
View File

@@ -0,0 +1,29 @@
import { execFileSync } from 'node:child_process';
import { rmSync, existsSync } from 'node:fs';
import { resolve } from 'node:path';
const COMPOSE_ARGS = [
'compose',
'-f', 'docker-compose.yml',
'-f', 'tests/docker-compose.test.yml',
];
export default async function globalTeardown() {
console.log('[global-teardown] Stopping Docker Compose test stack...');
try {
execFileSync('docker', [...COMPOSE_ARGS, 'down', '-v', '--remove-orphans'], {
stdio: 'inherit',
cwd: process.cwd(),
});
} catch (err) {
console.warn('[global-teardown] docker compose down failed:', err);
}
const authDir = resolve(process.cwd(), 'tests/.auth');
if (existsSync(authDir)) {
rmSync(authDir, { recursive: true, force: true });
console.log('[global-teardown] Removed', authDir);
}
console.log('[global-teardown] Done.');
}

20
tests/helpers/db.ts Normal file
View File

@@ -0,0 +1,20 @@
import Database from 'better-sqlite3';
import { drizzle } from 'drizzle-orm/better-sqlite3';
import { migrate } from 'drizzle-orm/better-sqlite3/migrator';
import { resolve } from 'node:path';
import * as schema from '@/src/lib/db/schema';
const migrationsFolder = resolve(process.cwd(), 'drizzle');
export type TestDb = ReturnType<typeof drizzle<typeof schema>>;
/**
* Creates a fresh in-memory SQLite database with all migrations applied.
* Each call returns a completely isolated database instance.
*/
export function createTestDb(): TestDb {
const sqlite = new Database(':memory:');
const db = drizzle(sqlite, { schema, casing: 'snake_case' });
migrate(db, { migrationsFolder });
return db;
}

View File

@@ -0,0 +1,99 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { createTestDb, type TestDb } from '../helpers/db';
import { accessLists, accessListEntries } from '@/src/lib/db/schema';
import { eq } from 'drizzle-orm';
let db: TestDb;
beforeEach(() => {
db = createTestDb();
});
function nowIso() {
return new Date().toISOString();
}
async function insertAccessList(overrides: Partial<typeof accessLists.$inferInsert> = {}) {
const now = nowIso();
const [list] = await db.insert(accessLists).values({
name: 'Test List',
description: null,
createdAt: now,
updatedAt: now,
...overrides,
}).returning();
return list;
}
async function insertEntry(accessListId: number, overrides: Partial<typeof accessListEntries.$inferInsert> = {}) {
const now = nowIso();
const [entry] = await db.insert(accessListEntries).values({
accessListId,
username: 'testuser',
passwordHash: '$2b$10$hashedpassword',
createdAt: now,
updatedAt: now,
...overrides,
}).returning();
return entry;
}
describe('access-lists integration', () => {
it('creates an access list and stores it', async () => {
const list = await insertAccessList({ name: 'Private Area' });
const row = await db.query.accessLists.findFirst({ where: (t, { eq }) => eq(t.id, list.id) });
expect(row).toBeDefined();
expect(row!.name).toBe('Private Area');
});
it('creates access list entry with username and hash', async () => {
const list = await insertAccessList();
const entry = await insertEntry(list.id, { username: 'alice', passwordHash: '$2b$10$abc' });
const row = await db.query.accessListEntries.findFirst({ where: (t, { eq }) => eq(t.id, entry.id) });
expect(row!.username).toBe('alice');
expect(row!.passwordHash).toBe('$2b$10$abc');
});
it('queries entries for a list and returns correct count', async () => {
const list = await insertAccessList();
await insertEntry(list.id, { username: 'user1' });
await insertEntry(list.id, { username: 'user2' });
await insertEntry(list.id, { username: 'user3' });
const entries = await db.select().from(accessListEntries).where(eq(accessListEntries.accessListId, list.id));
expect(entries.length).toBe(3);
});
it('deletes an entry and it is removed', async () => {
const list = await insertAccessList();
const entry = await insertEntry(list.id);
await db.delete(accessListEntries).where(eq(accessListEntries.id, entry.id));
const row = await db.query.accessListEntries.findFirst({ where: (t, { eq }) => eq(t.id, entry.id) });
expect(row).toBeUndefined();
});
it('deletes a list and cascades to entries', async () => {
const list = await insertAccessList();
await insertEntry(list.id, { username: 'user1' });
await insertEntry(list.id, { username: 'user2' });
await db.delete(accessLists).where(eq(accessLists.id, list.id));
const listRow = await db.query.accessLists.findFirst({ where: (t, { eq }) => eq(t.id, list.id) });
expect(listRow).toBeUndefined();
const entryRows = await db.select().from(accessListEntries).where(eq(accessListEntries.accessListId, list.id));
expect(entryRows.length).toBe(0);
});
it('entries for different lists do not mix', async () => {
const list1 = await insertAccessList({ name: 'List 1' });
const list2 = await insertAccessList({ name: 'List 2' });
await insertEntry(list1.id, { username: 'user-in-list1' });
await insertEntry(list2.id, { username: 'user-in-list2' });
const list1Entries = await db.select().from(accessListEntries).where(eq(accessListEntries.accessListId, list1.id));
expect(list1Entries.length).toBe(1);
expect(list1Entries[0].username).toBe('user-in-list1');
});
});

View File

@@ -0,0 +1,106 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { createTestDb, type TestDb } from '../helpers/db';
import { auditEvents, users } from '@/src/lib/db/schema';
import { desc, eq, like } from 'drizzle-orm';
let db: TestDb;
beforeEach(() => {
db = createTestDb();
});
function nowIso(offsetMs = 0) {
return new Date(Date.now() + offsetMs).toISOString();
}
async function insertEvent(overrides: Partial<typeof auditEvents.$inferInsert> = {}) {
const [event] = await db.insert(auditEvents).values({
action: 'create',
entityType: 'proxy_host',
entityId: 1,
summary: 'Created proxy host example.com',
data: null,
userId: null,
createdAt: nowIso(),
...overrides,
}).returning();
return event;
}
describe('audit-log integration', () => {
it('inserts audit event and retrieves it', async () => {
const event = await insertEvent({ action: 'update', entityType: 'certificate', summary: 'Updated cert' });
const row = await db.query.auditEvents.findFirst({ where: (t, { eq }) => eq(t.id, event.id) });
expect(row).toBeDefined();
expect(row!.action).toBe('update');
expect(row!.entityType).toBe('certificate');
expect(row!.summary).toBe('Updated cert');
});
it('multiple events ordered by createdAt descending', async () => {
await insertEvent({ summary: 'First', createdAt: nowIso(0) });
await insertEvent({ summary: 'Second', createdAt: nowIso(1000) });
await insertEvent({ summary: 'Third', createdAt: nowIso(2000) });
const rows = await db.select().from(auditEvents).orderBy(desc(auditEvents.createdAt));
expect(rows[0].summary).toBe('Third');
expect(rows[1].summary).toBe('Second');
expect(rows[2].summary).toBe('First');
});
it('event data JSON is stored and retrieved correctly', async () => {
const payload = { key: 'value', nested: { num: 42 } };
const event = await insertEvent({ data: JSON.stringify(payload) });
const row = await db.query.auditEvents.findFirst({ where: (t, { eq }) => eq(t.id, event.id) });
expect(JSON.parse(row!.data!)).toEqual(payload);
});
it('filter by action returns correct results', async () => {
await insertEvent({ action: 'create', summary: 'create event' });
await insertEvent({ action: 'delete', summary: 'delete event' });
await insertEvent({ action: 'create', summary: 'another create event' });
const rows = await db.select().from(auditEvents).where(eq(auditEvents.action, 'create'));
expect(rows.length).toBe(2);
expect(rows.every((r) => r.action === 'create')).toBe(true);
});
it('filter by entityType returns correct results', async () => {
await insertEvent({ entityType: 'proxy_host' });
await insertEvent({ entityType: 'certificate' });
await insertEvent({ entityType: 'proxy_host' });
const rows = await db.select().from(auditEvents).where(eq(auditEvents.entityType, 'certificate'));
expect(rows.length).toBe(1);
expect(rows[0].entityType).toBe('certificate');
});
it('search by summary text works', async () => {
await insertEvent({ summary: 'Created host foo.com' });
await insertEvent({ summary: 'Deleted access list Bar' });
await insertEvent({ summary: 'Updated host baz.com' });
const rows = await db.select().from(auditEvents).where(like(auditEvents.summary, '%host%'));
expect(rows.length).toBe(2);
});
it('event with userId stores reference correctly', async () => {
// Insert a user first (needed for FK)
const now = nowIso();
const [user] = await db.insert(users).values({
email: 'admin@test.com',
name: 'Admin',
passwordHash: 'hash',
role: 'admin',
provider: 'credentials',
subject: 'admin@test.com',
status: 'active',
createdAt: now,
updatedAt: now,
}).returning();
const event = await insertEvent({ userId: user.id });
const row = await db.query.auditEvents.findFirst({ where: (t, { eq }) => eq(t.id, event.id) });
expect(row!.userId).toBe(user.id);
});
});

View File

@@ -0,0 +1,74 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { createTestDb, type TestDb } from '../helpers/db';
import { certificates } from '@/src/lib/db/schema';
import { eq } from 'drizzle-orm';
let db: TestDb;
beforeEach(() => {
db = createTestDb();
});
function nowIso() {
return new Date().toISOString();
}
async function insertCertificate(overrides: Partial<typeof certificates.$inferInsert> = {}) {
const now = nowIso();
const [cert] = await db.insert(certificates).values({
name: 'Test Cert',
type: 'managed',
domainNames: JSON.stringify(['example.com']),
autoRenew: true,
createdAt: now,
updatedAt: now,
...overrides,
}).returning();
return cert;
}
describe('certificates integration', () => {
it('inserts managed certificate with domainNames array — retrieved correctly', async () => {
const domains = ['example.com', '*.example.com'];
const cert = await insertCertificate({ domainNames: JSON.stringify(domains) });
const row = await db.query.certificates.findFirst({ where: (t, { eq }) => eq(t.id, cert.id) });
expect(JSON.parse(row!.domainNames)).toEqual(domains);
});
it('inserts imported certificate with PEM fields', async () => {
const cert = await insertCertificate({
type: 'imported',
certificatePem: '-----BEGIN CERTIFICATE-----\nMIIBtest\n-----END CERTIFICATE-----',
privateKeyPem: '-----BEGIN PRIVATE KEY-----\nMIIBtest\n-----END PRIVATE KEY-----',
});
const row = await db.query.certificates.findFirst({ where: (t, { eq }) => eq(t.id, cert.id) });
expect(row!.type).toBe('imported');
expect(row!.certificatePem).toContain('BEGIN CERTIFICATE');
expect(row!.privateKeyPem).toContain('BEGIN PRIVATE KEY');
});
it('delete certificate removes it', async () => {
const cert = await insertCertificate();
await db.delete(certificates).where(eq(certificates.id, cert.id));
const row = await db.query.certificates.findFirst({ where: (t, { eq }) => eq(t.id, cert.id) });
expect(row).toBeUndefined();
});
it('list all certificates returns correct count', async () => {
await insertCertificate({ name: 'Cert A', domainNames: JSON.stringify(['a.com']) });
await insertCertificate({ name: 'Cert B', domainNames: JSON.stringify(['b.com']) });
const rows = await db.select().from(certificates);
expect(rows.length).toBe(2);
});
it('autoRenew defaults to true', async () => {
const cert = await insertCertificate();
expect(cert.autoRenew).toBe(true);
});
it('autoRenew can be set to false', async () => {
const cert = await insertCertificate({ autoRenew: false });
const row = await db.query.certificates.findFirst({ where: (t, { eq }) => eq(t.id, cert.id) });
expect(row!.autoRenew).toBe(false);
});
});

View File

@@ -0,0 +1,83 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { createTestDb, type TestDb } from '../helpers/db';
import { proxyHosts } from '@/src/lib/db/schema';
import { eq } from 'drizzle-orm';
let db: TestDb;
beforeEach(() => {
db = createTestDb();
});
function nowIso() {
return new Date().toISOString();
}
async function insertProxyHost(overrides: Partial<typeof proxyHosts.$inferInsert> = {}) {
const now = nowIso();
const [host] = await db.insert(proxyHosts).values({
name: 'Test Host',
domains: JSON.stringify(['example.com']),
upstreams: JSON.stringify(['localhost:8080']),
sslForced: true,
hstsEnabled: true,
hstsSubdomains: false,
allowWebsocket: true,
preserveHostHeader: true,
skipHttpsHostnameValidation: false,
enabled: true,
createdAt: now,
updatedAt: now,
...overrides,
}).returning();
return host;
}
describe('proxy-hosts integration', () => {
it('inserts proxy host with domains array — retrieved correctly via JSON parse', async () => {
const domains = ['example.com', 'www.example.com'];
const host = await insertProxyHost({ domains: JSON.stringify(domains), name: 'Multi Domain' });
const row = await db.query.proxyHosts.findFirst({ where: (t, { eq }) => eq(t.id, host.id) });
expect(JSON.parse(row!.domains)).toEqual(domains);
});
it('inserts proxy host with upstreams array — retrieved correctly', async () => {
const upstreams = ['app1:8080', 'app2:8080'];
const host = await insertProxyHost({ upstreams: JSON.stringify(upstreams), name: 'Load Balanced' });
const row = await db.query.proxyHosts.findFirst({ where: (t, { eq }) => eq(t.id, host.id) });
expect(JSON.parse(row!.upstreams)).toEqual(upstreams);
});
it('enabled field defaults to true', async () => {
const host = await insertProxyHost();
expect(host.enabled).toBe(true);
});
it('insert and query all returns at least one result', async () => {
await insertProxyHost();
const rows = await db.select().from(proxyHosts);
expect(rows.length).toBeGreaterThanOrEqual(1);
});
it('delete by id removes the host', async () => {
const host = await insertProxyHost();
await db.delete(proxyHosts).where(eq(proxyHosts.id, host.id));
const row = await db.query.proxyHosts.findFirst({ where: (t, { eq }) => eq(t.id, host.id) });
expect(row).toBeUndefined();
});
it('multiple proxy hosts — count is correct', async () => {
await insertProxyHost({ name: 'Host 1', domains: JSON.stringify(['a.com']) });
await insertProxyHost({ name: 'Host 2', domains: JSON.stringify(['b.com']) });
await insertProxyHost({ name: 'Host 3', domains: JSON.stringify(['c.com']) });
const rows = await db.select().from(proxyHosts);
expect(rows.length).toBe(3);
});
it('hsts and websocket booleans are stored and retrieved correctly', async () => {
const host = await insertProxyHost({ hstsEnabled: false, allowWebsocket: false });
const row = await db.query.proxyHosts.findFirst({ where: (t, { eq }) => eq(t.id, host.id) });
expect(row!.hstsEnabled).toBe(false);
expect(row!.allowWebsocket).toBe(false);
});
});

View File

@@ -0,0 +1,90 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { createTestDb, type TestDb } from '../helpers/db';
import { settings } from '@/src/lib/db/schema';
import { eq } from 'drizzle-orm';
let db: TestDb;
beforeEach(() => {
db = createTestDb();
});
function nowIso() {
return new Date().toISOString();
}
async function setSetting(key: string, value: unknown) {
const payload = JSON.stringify(value);
const now = nowIso();
await db.insert(settings).values({ key, value: payload, updatedAt: now })
.onConflictDoUpdate({ target: settings.key, set: { value: payload, updatedAt: now } });
}
async function getSetting<T>(key: string): Promise<T | null> {
const row = await db.query.settings.findFirst({ where: (t, { eq }) => eq(t.key, key) });
if (!row) return null;
try {
return JSON.parse(row.value) as T;
} catch {
return null;
}
}
describe('settings integration', () => {
it('get non-existent key returns null', async () => {
const value = await getSetting('nonexistent');
expect(value).toBeNull();
});
it('set key — stored in db', async () => {
await setSetting('test-key', 'test-value');
const row = await db.query.settings.findFirst({ where: (t, { eq }) => eq(t.key, 'test-key') });
expect(row).toBeDefined();
});
it('get key returns same value that was set', async () => {
await setSetting('my-key', 'hello world');
const value = await getSetting<string>('my-key');
expect(value).toBe('hello world');
});
it('update existing key overwrites value', async () => {
await setSetting('update-key', 'initial');
await setSetting('update-key', 'updated');
const value = await getSetting<string>('update-key');
expect(value).toBe('updated');
});
it('stores object and retrieves it correctly', async () => {
const obj = { enabled: true, resolvers: ['1.1.1.1', '8.8.8.8'], timeout: '5s' };
await setSetting('dns', obj);
const retrieved = await getSetting<typeof obj>('dns');
expect(retrieved).toEqual(obj);
});
it('stores boolean true correctly', async () => {
await setSetting('bool-key', true);
const value = await getSetting<boolean>('bool-key');
expect(value).toBe(true);
});
it('stores number correctly', async () => {
await setSetting('num-key', 42);
const value = await getSetting<number>('num-key');
expect(value).toBe(42);
});
it('multiple keys are independent', async () => {
await setSetting('key-a', 'value-a');
await setSetting('key-b', 'value-b');
expect(await getSetting<string>('key-a')).toBe('value-a');
expect(await getSetting<string>('key-b')).toBe('value-b');
});
it('delete setting removes it', async () => {
await setSetting('delete-me', 'value');
await db.delete(settings).where(eq(settings.key, 'delete-me'));
const value = await getSetting('delete-me');
expect(value).toBeNull();
});
});

View File

@@ -0,0 +1,78 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { createTestDb, type TestDb } from '../helpers/db';
import { users } from '@/src/lib/db/schema';
import { eq } from 'drizzle-orm';
let db: TestDb;
beforeEach(() => {
db = createTestDb();
});
function nowIso() {
return new Date().toISOString();
}
async function insertUser(overrides: Partial<typeof users.$inferInsert> = {}) {
const now = nowIso();
const [user] = await db.insert(users).values({
email: 'user@example.com',
name: 'Test User',
passwordHash: 'hash123',
role: 'user',
provider: 'credentials',
subject: 'user@example.com',
status: 'active',
createdAt: now,
updatedAt: now,
...overrides,
}).returning();
return user;
}
describe('users integration', () => {
it('inserts a user and retrieves it by email', async () => {
await insertUser({ email: 'alice@example.com', subject: 'alice@example.com' });
const row = await db.query.users.findFirst({ where: (t, { eq }) => eq(t.email, 'alice@example.com') });
expect(row).toBeDefined();
expect(row!.email).toBe('alice@example.com');
});
it('duplicate email throws unique constraint error', async () => {
await insertUser({ email: 'dup@example.com', subject: 'dup@example.com' });
await expect(
insertUser({ email: 'dup@example.com', subject: 'dup2@example.com' })
).rejects.toThrow();
});
it('user has correct default role', async () => {
const user = await insertUser();
expect(user.role).toBe('user');
});
it('find by non-existent email returns undefined', async () => {
const row = await db.query.users.findFirst({ where: (t, { eq }) => eq(t.email, 'nobody@example.com') });
expect(row).toBeUndefined();
});
it('user insert stores ISO timestamps in createdAt/updatedAt', async () => {
const now = nowIso();
const user = await insertUser({ createdAt: now, updatedAt: now });
expect(user.createdAt).toBe(now);
expect(user.updatedAt).toBe(now);
});
it('list users returns all inserted users', async () => {
await insertUser({ email: 'a@example.com', subject: 'a' });
await insertUser({ email: 'b@example.com', subject: 'b' });
const rows = await db.select().from(users);
expect(rows.length).toBe(2);
});
it('delete user by id removes it', async () => {
const user = await insertUser();
await db.delete(users).where(eq(users.id, user.id));
const row = await db.query.users.findFirst({ where: (t, { eq }) => eq(t.id, user.id) });
expect(row).toBeUndefined();
});
});

View File

@@ -0,0 +1,23 @@
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './e2e',
globalSetup: './global-setup.ts',
globalTeardown: './global-teardown.ts',
fullyParallel: false,
workers: 2,
retries: 0,
timeout: 30_000,
reporter: 'list',
use: {
baseURL: 'http://localhost:3000',
storageState: './.auth/admin.json',
trace: 'on-first-retry',
},
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
],
});

18
tests/setup.vitest.ts Normal file
View File

@@ -0,0 +1,18 @@
import { vi } from 'vitest';
// Mock the Caddy config apply so no real HTTP calls go out during tests
vi.mock('@/src/lib/caddy', () => ({
applyCaddyConfig: vi.fn().mockResolvedValue({ ok: true }),
}));
// Mock NextAuth so API route tests can control session state
vi.mock('@/src/lib/auth', () => ({
auth: vi.fn().mockResolvedValue({
user: { id: 1, email: 'test@example.com', name: 'Test User', role: 'admin' },
}),
}));
// Mock audit logging to be a no-op
vi.mock('@/src/lib/audit', () => ({
logAuditEvent: vi.fn(),
}));

View File

@@ -0,0 +1,181 @@
import { describe, it, expect, vi } from 'vitest';
// Mock heavy dependencies before importing the module under test
vi.mock('@/src/lib/db', () => ({
default: {
select: vi.fn().mockReturnValue({ from: vi.fn().mockReturnValue({ where: vi.fn().mockReturnValue({ get: vi.fn().mockReturnValue(null) }) }) }),
insert: vi.fn().mockReturnValue({ values: vi.fn().mockReturnValue({ onConflictDoUpdate: vi.fn().mockReturnValue({ run: vi.fn() }) }) }),
delete: vi.fn().mockReturnValue({ where: vi.fn().mockReturnValue({ run: vi.fn() }) }),
run: vi.fn(),
},
nowIso: () => new Date().toISOString(),
toIso: (v: string | Date | null | undefined) => v ? new Date(v as string).toISOString() : null,
}));
vi.mock('maxmind', () => ({
default: {
open: vi.fn().mockResolvedValue(null),
},
}));
vi.mock('node:fs', () => ({
existsSync: vi.fn().mockReturnValue(false),
statSync: vi.fn().mockReturnValue({ size: 0 }),
createReadStream: vi.fn(),
}));
import { parseLine, collectBlockedSignatures } from '@/src/lib/log-parser';
describe('log-parser', () => {
describe('collectBlockedSignatures', () => {
it('returns empty set for empty lines array', () => {
const result = collectBlockedSignatures([]);
expect(result.size).toBe(0);
});
it('picks up caddy-blocker "request blocked" entries', () => {
const entry = JSON.stringify({
ts: 1700000000.123,
msg: 'request blocked',
plugin: 'caddy-blocker',
client_ip: '1.2.3.4',
method: 'GET',
uri: '/evil',
});
const result = collectBlockedSignatures([entry]);
expect(result.size).toBe(1);
// key format: ${ts}|${clientIp}|${method}|${uri}
const key = `1700000000|1.2.3.4|GET|/evil`;
expect(result.has(key)).toBe(true);
});
it('ignores entries without msg "request blocked"', () => {
const entry = JSON.stringify({
ts: 1700000000,
msg: 'handled request',
plugin: 'caddy-blocker',
client_ip: '1.2.3.4',
method: 'GET',
uri: '/normal',
});
const result = collectBlockedSignatures([entry]);
expect(result.size).toBe(0);
});
it('ignores entries without plugin "caddy-blocker"', () => {
const entry = JSON.stringify({
ts: 1700000000,
msg: 'request blocked',
plugin: 'other-plugin',
client_ip: '1.2.3.4',
method: 'GET',
uri: '/path',
});
const result = collectBlockedSignatures([entry]);
expect(result.size).toBe(0);
});
it('ignores malformed JSON lines', () => {
const result = collectBlockedSignatures(['{not valid json}', '']);
expect(result.size).toBe(0);
});
it('collects multiple blocked signatures', () => {
const lines = [
JSON.stringify({ ts: 1700000001, msg: 'request blocked', plugin: 'caddy-blocker', client_ip: '1.2.3.4', method: 'POST', uri: '/a' }),
JSON.stringify({ ts: 1700000002, msg: 'request blocked', plugin: 'caddy-blocker', client_ip: '5.6.7.8', method: 'GET', uri: '/b' }),
];
const result = collectBlockedSignatures(lines);
expect(result.size).toBe(2);
});
});
describe('parseLine', () => {
const emptyBlocked = new Set<string>();
it('parses a valid "handled request" entry into a traffic event row', () => {
const entry = JSON.stringify({
ts: 1700000100.5,
msg: 'handled request',
status: 200,
size: 1234,
request: {
client_ip: '10.0.0.1',
host: 'example.com',
method: 'GET',
uri: '/path',
proto: 'HTTP/1.1',
headers: { 'User-Agent': ['Mozilla/5.0'] },
},
});
const result = parseLine(entry, emptyBlocked);
expect(result).not.toBeNull();
expect(result!.ts).toBe(1700000100);
expect(result!.clientIp).toBe('10.0.0.1');
expect(result!.host).toBe('example.com');
expect(result!.method).toBe('GET');
expect(result!.uri).toBe('/path');
expect(result!.status).toBe(200);
expect(result!.proto).toBe('HTTP/1.1');
expect(result!.bytesSent).toBe(1234);
expect(result!.userAgent).toBe('Mozilla/5.0');
expect(result!.isBlocked).toBe(false);
});
it('returns null for entries with wrong msg field', () => {
const entry = JSON.stringify({ ts: 1700000100, msg: 'request blocked', plugin: 'caddy-blocker', client_ip: '1.2.3.4', method: 'GET', uri: '/' });
expect(parseLine(entry, emptyBlocked)).toBeNull();
});
it('returns null for malformed JSON', () => {
expect(parseLine('{bad json', emptyBlocked)).toBeNull();
});
it('uses fallback empty strings for missing request fields', () => {
const entry = JSON.stringify({ ts: 1700000100, msg: 'handled request', status: 200 });
const result = parseLine(entry, emptyBlocked);
expect(result).not.toBeNull();
expect(result!.clientIp).toBe('');
expect(result!.host).toBe('');
expect(result!.method).toBe('');
expect(result!.uri).toBe('');
expect(result!.userAgent).toBe('');
});
it('marks isBlocked true when signature matches blocked set', () => {
const ts = 1700000200;
const entry = JSON.stringify({
ts,
msg: 'handled request',
status: 403,
request: { client_ip: '1.2.3.4', method: 'GET', uri: '/evil', host: 'x.com' },
});
const blocked = new Set([`${ts}|1.2.3.4|GET|/evil`]);
const result = parseLine(entry, blocked);
expect(result!.isBlocked).toBe(true);
});
it('uses remote_ip as fallback when client_ip is missing', () => {
const entry = JSON.stringify({
ts: 1700000300,
msg: 'handled request',
status: 200,
request: { remote_ip: '9.8.7.6', host: 'test.com', method: 'GET', uri: '/' },
});
const result = parseLine(entry, emptyBlocked);
expect(result!.clientIp).toBe('9.8.7.6');
});
it('countryCode is null when GeoIP reader is not initialized', () => {
const entry = JSON.stringify({
ts: 1700000400,
msg: 'handled request',
status: 200,
request: { client_ip: '8.8.8.8', host: 'test.com', method: 'GET', uri: '/' },
});
const result = parseLine(entry, emptyBlocked);
expect(result!.countryCode).toBeNull();
});
});
});

View File

@@ -0,0 +1,108 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
// Reset the module between tests so the in-memory Map is cleared
let registerFailedAttempt: typeof import('@/src/lib/rate-limit').registerFailedAttempt;
let isRateLimited: typeof import('@/src/lib/rate-limit').isRateLimited;
let resetAttempts: typeof import('@/src/lib/rate-limit').resetAttempts;
beforeEach(async () => {
vi.resetModules();
const mod = await import('@/src/lib/rate-limit');
registerFailedAttempt = mod.registerFailedAttempt;
isRateLimited = mod.isRateLimited;
resetAttempts = mod.resetAttempts;
});
afterEach(() => {
vi.restoreAllMocks();
});
describe('rate-limit', () => {
const KEY = 'test-ip-1';
it('first attempt is not blocked', () => {
const result = registerFailedAttempt(KEY);
expect(result.blocked).toBe(false);
});
it('4 failed attempts are not blocked (below threshold of 5)', () => {
for (let i = 0; i < 4; i++) {
const result = registerFailedAttempt(KEY);
expect(result.blocked).toBe(false);
}
});
it('5th failed attempt triggers block', () => {
for (let i = 0; i < 4; i++) {
registerFailedAttempt(KEY);
}
const result = registerFailedAttempt(KEY);
expect(result.blocked).toBe(true);
expect(result.retryAfterMs).toBeGreaterThan(0);
});
it('isRateLimited returns blocked after 5 failures', () => {
for (let i = 0; i < 5; i++) {
registerFailedAttempt(KEY);
}
const result = isRateLimited(KEY);
expect(result.blocked).toBe(true);
expect(result.retryAfterMs).toBeGreaterThan(0);
});
it('isRateLimited returns not blocked for unknown key', () => {
const result = isRateLimited('unknown-key-xyz');
expect(result.blocked).toBe(false);
});
it('blocked entry unblocks after blockedUntil passes', () => {
// Trigger block
for (let i = 0; i < 5; i++) {
registerFailedAttempt(KEY);
}
// Mock Date.now to be far in the future (past block window)
const future = Date.now() + 16 * 60 * 1000; // 16 minutes
vi.spyOn(Date, 'now').mockReturnValue(future);
const result = isRateLimited(KEY);
expect(result.blocked).toBe(false);
});
it('window expires without max attempts resets attempts', () => {
// Make a few attempts
for (let i = 0; i < 3; i++) {
registerFailedAttempt(KEY);
}
// Jump past the window (default 5 minutes)
const future = Date.now() + 6 * 60 * 1000;
vi.spyOn(Date, 'now').mockReturnValue(future);
// Now should be treated as first attempt
const result = registerFailedAttempt(KEY);
expect(result.blocked).toBe(false);
});
it('resetAttempts immediately unblocks a key', () => {
for (let i = 0; i < 5; i++) {
registerFailedAttempt(KEY);
}
expect(isRateLimited(KEY).blocked).toBe(true);
resetAttempts(KEY);
expect(isRateLimited(KEY).blocked).toBe(false);
});
it('different keys do not interfere', () => {
const KEY_A = 'ip-a';
const KEY_B = 'ip-b';
for (let i = 0; i < 5; i++) {
registerFailedAttempt(KEY_A);
}
expect(isRateLimited(KEY_A).blocked).toBe(true);
expect(isRateLimited(KEY_B).blocked).toBe(false);
});
});

64
tests/unit/secret.test.ts Normal file
View File

@@ -0,0 +1,64 @@
import { describe, it, expect } from 'vitest';
import { encryptSecret, decryptSecret, isEncryptedSecret } from '@/src/lib/secret';
describe('secret', () => {
it('encrypts a value (output is non-empty string)', () => {
const encrypted = encryptSecret('my-api-token');
expect(typeof encrypted).toBe('string');
expect(encrypted.length).toBeGreaterThan(0);
});
it('encrypted value starts with "enc:v1:" prefix', () => {
const encrypted = encryptSecret('hello-world');
expect(encrypted.startsWith('enc:v1:')).toBe(true);
});
it('same input produces different output each time (random IV)', () => {
const a = encryptSecret('same-value');
const b = encryptSecret('same-value');
// Different because IV is random
expect(a).not.toBe(b);
});
it('different inputs produce different outputs', () => {
const a = encryptSecret('value-one');
const b = encryptSecret('value-two');
expect(a).not.toBe(b);
});
it('decrypts back to original value', () => {
const original = 'super-secret-token-12345';
const encrypted = encryptSecret(original);
const decrypted = decryptSecret(encrypted);
expect(decrypted).toBe(original);
});
it('decryptSecret with plain text (non-encrypted) returns input unchanged', () => {
const plain = 'not-encrypted-value';
expect(decryptSecret(plain)).toBe(plain);
});
it('isEncryptedSecret returns true for encrypted values', () => {
const encrypted = encryptSecret('test');
expect(isEncryptedSecret(encrypted)).toBe(true);
});
it('isEncryptedSecret returns false for plain text', () => {
expect(isEncryptedSecret('plain-text')).toBe(false);
});
it('encrypting empty string returns empty string', () => {
expect(encryptSecret('')).toBe('');
});
it('decrypting empty string returns empty string', () => {
expect(decryptSecret('')).toBe('');
});
it('already-encrypted value is not double-encrypted', () => {
const encrypted = encryptSecret('value');
const encrypted2 = encryptSecret(encrypted);
// Should return the same value (idempotent)
expect(encrypted2).toBe(encrypted);
});
});

View File

@@ -0,0 +1,63 @@
import { describe, it, expect, vi } from 'vitest';
// Mock heavy dependencies before importing
vi.mock('@/src/lib/db', () => ({
default: {
select: vi.fn().mockReturnValue({ from: vi.fn().mockReturnValue({ where: vi.fn().mockReturnValue({ get: vi.fn().mockReturnValue(null) }) }) }),
insert: vi.fn().mockReturnValue({ values: vi.fn().mockReturnValue({ onConflictDoUpdate: vi.fn().mockReturnValue({ run: vi.fn() }) }) }),
delete: vi.fn().mockReturnValue({ where: vi.fn().mockReturnValue({ run: vi.fn() }) }),
run: vi.fn(),
},
nowIso: () => new Date().toISOString(),
}));
vi.mock('maxmind', () => ({
default: { open: vi.fn().mockResolvedValue(null) },
}));
vi.mock('node:fs', () => ({
existsSync: vi.fn().mockReturnValue(false),
statSync: vi.fn().mockReturnValue({ size: 0 }),
createReadStream: vi.fn(),
}));
import { extractBracketField } from '@/src/lib/waf-log-parser';
describe('extractBracketField', () => {
it('extracts id from [id "941100"]', () => {
expect(extractBracketField('[id "941100"]', 'id')).toBe('941100');
});
it('extracts msg from [msg "XSS Attack Detected"]', () => {
expect(extractBracketField('[msg "XSS Attack Detected"]', 'msg')).toBe('XSS Attack Detected');
});
it('extracts severity from [severity "critical"]', () => {
expect(extractBracketField('[severity "critical"]', 'severity')).toBe('critical');
});
it('extracts unique_id from [unique_id "abc123"]', () => {
expect(extractBracketField('[unique_id "abc123"]', 'unique_id')).toBe('abc123');
});
it('returns null for field not present', () => {
expect(extractBracketField('[msg "something"]', 'id')).toBeNull();
});
it('works when multiple fields are present in one string', () => {
const msg = '[id "941100"] [msg "XSS Attack"] [severity "critical"] [unique_id "abc123"]';
expect(extractBracketField(msg, 'id')).toBe('941100');
expect(extractBracketField(msg, 'msg')).toBe('XSS Attack');
expect(extractBracketField(msg, 'severity')).toBe('critical');
expect(extractBracketField(msg, 'unique_id')).toBe('abc123');
});
it('handles special characters in field values', () => {
const msg = '[msg "SQL Injection: SELECT * FROM users WHERE id=1"]';
expect(extractBracketField(msg, 'msg')).toBe('SQL Injection: SELECT * FROM users WHERE id=1');
});
it('returns null for empty string input', () => {
expect(extractBracketField('', 'id')).toBeNull();
});
});

22
tests/vitest.config.ts Normal file
View File

@@ -0,0 +1,22 @@
import { defineConfig } from 'vitest/config';
import tsconfigPaths from 'vite-tsconfig-paths';
import { resolve } from 'node:path';
const root = resolve(__dirname, '..');
export default defineConfig({
plugins: [tsconfigPaths({ root })],
test: {
environment: 'node',
setupFiles: [resolve(__dirname, 'setup.vitest.ts')],
env: {
DATABASE_URL: ':memory:',
SESSION_SECRET: 'test-session-secret-for-vitest-unit-tests-12345',
NODE_ENV: 'test',
},
include: [
resolve(__dirname, 'unit/**/*.test.ts'),
resolve(__dirname, 'integration/**/*.test.ts'),
],
},
});