added tests
This commit is contained in:
5
.gitignore
vendored
5
.gitignore
vendored
@@ -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
1633
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
14
package.json
14
package.json
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
23
tests/docker-compose.test.yml
Normal file
23
tests/docker-compose.test.yml
Normal 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
|
||||
51
tests/e2e/access-lists.spec.ts
Normal file
51
tests/e2e/access-lists.spec.ts
Normal 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 });
|
||||
});
|
||||
});
|
||||
32
tests/e2e/analytics.spec.ts
Normal file
32
tests/e2e/analytics.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
48
tests/e2e/audit-log.spec.ts
Normal file
48
tests/e2e/audit-log.spec.ts
Normal 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
42
tests/e2e/auth.spec.ts
Normal 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 });
|
||||
});
|
||||
});
|
||||
25
tests/e2e/certificates.spec.ts
Normal file
25
tests/e2e/certificates.spec.ts
Normal 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
52
tests/e2e/profile.spec.ts
Normal 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();
|
||||
}
|
||||
});
|
||||
});
|
||||
64
tests/e2e/proxy-hosts.spec.ts
Normal file
64
tests/e2e/proxy-hosts.spec.ts
Normal 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 });
|
||||
});
|
||||
});
|
||||
41
tests/e2e/settings.spec.ts
Normal file
41
tests/e2e/settings.spec.ts
Normal 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
23
tests/e2e/waf.spec.ts
Normal 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
118
tests/global-setup.ts
Normal 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
29
tests/global-teardown.ts
Normal 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
20
tests/helpers/db.ts
Normal 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;
|
||||
}
|
||||
99
tests/integration/access-lists.test.ts
Normal file
99
tests/integration/access-lists.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
106
tests/integration/audit-log.test.ts
Normal file
106
tests/integration/audit-log.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
74
tests/integration/certificates.test.ts
Normal file
74
tests/integration/certificates.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
83
tests/integration/proxy-hosts.test.ts
Normal file
83
tests/integration/proxy-hosts.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
90
tests/integration/settings.test.ts
Normal file
90
tests/integration/settings.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
78
tests/integration/users.test.ts
Normal file
78
tests/integration/users.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
23
tests/playwright.config.ts
Normal file
23
tests/playwright.config.ts
Normal 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
18
tests/setup.vitest.ts
Normal 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(),
|
||||
}));
|
||||
181
tests/unit/log-parser.test.ts
Normal file
181
tests/unit/log-parser.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
108
tests/unit/rate-limit.test.ts
Normal file
108
tests/unit/rate-limit.test.ts
Normal 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
64
tests/unit/secret.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
63
tests/unit/waf-log-parser.test.ts
Normal file
63
tests/unit/waf-log-parser.test.ts
Normal 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
22
tests/vitest.config.ts
Normal 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'),
|
||||
],
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user