Full CRM rebuild with Next.js 15, TypeScript, Tailwind, Drizzle ORM, PostgreSQL, Redis, BullMQ, MinIO, and Socket.io. Includes 461 source files covering clients, berths, interests/pipeline, documents/EOI, expenses/invoices, email, notifications, dashboard, admin, and client portal. CI/CD via Gitea Actions with Docker builds. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
48 KiB
Layer 5: Testing & Hardening — Competing Plan (Claude Code)
Scope: Test infrastructure, unit tests for all business rules, integration tests for critical workflows, Playwright E2E tests (5-6 per locked spec), application-level rate limiting, security audit, input sanitization audit, error response format audit, query performance validation, CI pipeline.
Duration: 5 days across 2 parallel streams
Depends on: L0–L4 complete. Testing can begin as L4 streams merge.
1. Baseline Critique
What's Good
- Test factory pattern — Creating
src/test/helpers/factories.tswith per-entity factory functions is the right approach for test data. - External service mocks — Listing all six external services (MinIO, Documenso, SMTP, Google Calendar, OpenAI, Frankfurter) that need mocks is complete.
- Business rule test structure — Organizing tests by BR-number makes it easy to verify coverage against
09-BUSINESS-RULES.md. - Dead letter alerting verification — Testing the dead letter → notification flow end-to-end is important.
What Needs Fixing
-
System monitoring is in L5, not L4 — The baseline puts BullMQ monitoring, system health dashboard, and alert management in L5. These are operational features, not testing artifacts. They belong in L4 (my L4 plan includes them). L5 should be purely testing, security hardening, and performance validation — no new features.
-
Wrong route paths (again) — Uses
src/app/(crm)/admin/system/page.tsxinstead of(dashboard)/[portSlug]/admin/.... -
E2E test count contradicts locked decisions — Baseline says "15+ E2E tests" but
14-TECHNICAL-DECISIONS.md§9.2 explicitly specifies "5-6 critical workflows" for Playwright. This is a locked decision. The baseline violates it. -
X-XSS-Protection header value wrong — Baseline uses
"1; mode=block"butSECURITY-GUIDELINES.md§6.2 correctly specifies"0". The modern best practice is to disable XSS Auditor (it can introduce vulnerabilities) and rely on CSP instead. The security guidelines got this right; the baseline did not. -
Security headers belong in L0, not L5 — If security headers are only added in L5, the entire app runs without HSTS, CSP, and X-Frame-Options for 24+ development days. My L0 plan includes the nginx config with all security headers from day 1. L5 should audit that they're correct, not add them for the first time.
-
DOMPurify allowlist doesn't match SECURITY-GUIDELINES.md — Baseline allows
p, br, strong, em, u, a, ul, ol, li, h1, h2, h3, blockquote, code. Security guidelines §3.3 specifies:b, i, u, em, strong, p, br, ul, ol, li, a, h1-h6, table, tr, td, th, blockquote, code, pre. Baseline is missing:b,i,table,tr,td,th,pre,h4,h5,h6. -
No test database setup detail — How is the test DB created? Docker Compose profile? Separate container? The baseline says "separate PostgreSQL database" but doesn't show how it's provisioned, migrated, or torn down.
-
No systematic port-scoping test — The single most important security property of this system is that no endpoint leaks cross-port data. The baseline mentions this once but doesn't create a systematic test harness that verifies every endpoint respects port scoping.
-
Missing error response format audit —
SECURITY-GUIDELINES.md§13 defines exact error response shapes (400, 401, 403, 404, 429, 500). L5 should verify every endpoint returns the correct format and never leaks stack traces, SQL errors, or internal paths. -
No CI pipeline detail — A 4-line YAML snippet is not a real CI pipeline. Missing: dependency caching, test database provisioning, Playwright browser setup, test parallelism, artifact collection, coverage thresholds.
-
Coverage enforcement missing — Mentions "80%+ on lib/services" as a target but doesn't configure Vitest coverage thresholds to fail the build below that.
-
nginx rate limiting duplicates L0 — My L0 already configures nginx rate limit zones. L5 should add application-level (Redis-backed) rate limiting, not reconfigure nginx from scratch.
2. Implementation Plan
Stream A: Test Infrastructure & Test Suite (Days 1–4)
Day 1: Test Infrastructure
Test database provisioning:
docker-compose.test.yml (extends base compose):
services:
postgres-test:
image: postgres:16-alpine
environment:
POSTGRES_DB: portnimara_test
POSTGRES_USER: test
POSTGRES_PASSWORD: test
ports:
- '5433:5432'
tmpfs:
- /var/lib/postgresql/data # RAM disk for speed
redis-test:
image: redis:7-alpine
ports:
- '6380:6379'
Using tmpfs for the test database means it's destroyed on container stop — clean state guaranteed, fast writes.
Vitest configuration: vitest.config.ts
import { defineConfig } from 'vitest/config';
import path from 'path';
export default defineConfig({
test: {
globals: true,
environment: 'node',
setupFiles: ['./src/test/setup.ts'],
include: ['src/**/*.test.ts'],
exclude: ['e2e/**'],
pool: 'forks', // Separate processes for isolation
poolOptions: {
forks: { maxForks: 4 },
},
coverage: {
provider: 'v8',
reporter: ['text', 'lcov', 'json-summary'],
include: ['src/lib/**'],
exclude: [
'src/lib/db/migrations/**',
'src/test/**',
'src/lib/db/schema/**', // Schema definitions don't need coverage
'src/**/*.d.ts',
],
thresholds: {
'src/lib/services/**': { statements: 80, branches: 75 },
'src/lib/validators/**': { statements: 90, branches: 85 },
},
},
},
resolve: {
alias: { '@': path.resolve(__dirname, './src') },
},
});
Test setup: src/test/setup.ts
import { drizzle } from 'drizzle-orm/node-postgres';
import { migrate } from 'drizzle-orm/node-postgres/migrator';
import { Pool } from 'pg';
import Redis from 'ioredis';
let testPool: Pool;
let testRedis: Redis;
beforeAll(async () => {
testPool = new Pool({
connectionString:
process.env.TEST_DATABASE_URL || 'postgresql://test:test@localhost:5433/portnimara_test',
});
testRedis = new Redis(process.env.TEST_REDIS_URL || 'redis://localhost:6380');
// Run migrations on test DB
const db = drizzle(testPool);
await migrate(db, { migrationsFolder: './src/lib/db/migrations' });
});
afterAll(async () => {
await testPool.end();
testRedis.disconnect();
});
beforeEach(async () => {
// Truncate all tables (fast, preserves schema)
await testPool.query(`
DO $$ DECLARE
r RECORD;
BEGIN
FOR r IN (SELECT tablename FROM pg_tables WHERE schemaname = 'public') LOOP
EXECUTE 'TRUNCATE TABLE ' || quote_ident(r.tablename) || ' CASCADE';
END LOOP;
END $$;
`);
await testRedis.flushdb();
});
Test helpers:
src/test/helpers/auth.ts:
/**
* Create a test user with profile, optionally assigned to a port with a role.
* @returns User object with session cookie for API testing
*/
export async function createTestUser(options?: {
email?: string;
role?: string;
portId?: string;
}): Promise<TestUser>;
/**
* Get an authenticated request context for API route testing.
* Mimics the middleware chain: auth → extractPort → permission check.
*/
export function createAuthContext(userId: string, portId: string): AuthContext;
/**
* Create a test user for each role to verify permission boundaries.
*/
export async function createUsersForAllRoles(portId: string): Promise<Record<string, TestUser>>;
src/test/helpers/factories.ts:
/**
* Entity factories with realistic defaults.
* Each factory creates a record in the test database and returns the full entity.
* All factories require portId to enforce port scoping from the start.
*/
export async function createTestPort(overrides?: Partial<Port>): Promise<Port>;
export async function createTestClient(
portId: string,
overrides?: Partial<Client>,
): Promise<Client>;
export async function createTestBerth(portId: string, overrides?: Partial<Berth>): Promise<Berth>;
export async function createTestInterest(
portId: string,
clientId: string,
overrides?: Partial<Interest>,
): Promise<Interest>;
export async function createTestExpense(
portId: string,
overrides?: Partial<Expense>,
): Promise<Expense>;
export async function createTestInvoice(
portId: string,
clientId: string,
overrides?: Partial<Invoice>,
): Promise<Invoice>;
export async function createTestReminder(
portId: string,
userId: string,
overrides?: Partial<Reminder>,
): Promise<Reminder>;
export async function createTestTag(portId: string, overrides?: Partial<Tag>): Promise<Tag>;
export async function createTestWebhook(
portId: string,
overrides?: Partial<Webhook>,
): Promise<Webhook>;
export async function createTestReport(
portId: string,
overrides?: Partial<ScheduledReport>,
): Promise<ScheduledReport>;
src/test/helpers/mocks.ts:
/**
* Mock external services with realistic responses.
* Each mock captures calls for assertion and returns configurable responses.
*/
export function mockMinIO(): {
upload: vi.Mock;
download: vi.Mock;
presignedUrl: vi.Mock;
reset: () => void;
};
export function mockDocumenso(): {
createDocument: vi.Mock;
getDocument: vi.Mock;
webhookPayload: (event: string, data: Record<string, unknown>) => Record<string, unknown>;
reset: () => void;
};
export function mockSMTP(): {
sendMail: vi.Mock;
getSentEmails: () => SentEmail[];
reset: () => void;
};
export function mockGoogleCalendar(): {
authorize: vi.Mock;
listEvents: vi.Mock;
createEvent: vi.Mock;
reset: () => void;
};
export function mockOpenAI(): {
chat: vi.Mock;
vision: vi.Mock;
reset: () => void;
};
export function mockFrankfurter(): {
getLatest: vi.Mock;
reset: () => void;
};
src/test/helpers/api.ts:
/**
* Helper for testing API routes with proper auth context.
* Simulates Next.js API route handler invocation.
*/
export async function callApiRoute(
handler: (req: NextRequest) => Promise<Response>,
options: {
method?: string;
body?: unknown;
query?: Record<string, string>;
user?: TestUser;
portId?: string;
},
): Promise<{ status: number; body: unknown; headers: Headers }>;
Day 2: Unit Tests — Business Rules
Test every business rule from 09-BUSINESS-RULES.md. Organized by rule number:
Berth status rules (BR-001 through BR-009):
src/lib/services/__tests__/berth-status-rules.test.ts
describe('Berth Status Rules Engine', () => {
describe('BR-001: interest_linked trigger', () => {
it('auto mode: changes available → under_offer when first interest linked', async () => {
/* ... */
});
it('suggest mode: returns suggestion without changing status', async () => {
/* ... */
});
it('off mode: no status change', async () => {
/* ... */
});
it('does not trigger when berth already under_offer or sold', async () => {
/* ... */
});
});
describe('BR-002: interest_unlinked trigger', () => {
it('resets to available when last interest unlinked', async () => {
/* ... */
});
it('stays under_offer when other interests remain linked', async () => {
/* ... */
});
});
describe('BR-003: eoi_sent trigger', () => {
/* ... */
});
describe('BR-004: eoi_all_signed trigger', () => {
/* ... */
});
describe('BR-005: deposit_received trigger', () => {
/* ... */
});
describe('BR-006: contract_signed trigger', () => {
/* ... */
});
describe('BR-007: interest_lost_or_expired trigger', () => {
/* ... */
});
describe('BR-008: manual override always works', () => {
/* ... */
});
describe('BR-009: rule configuration storage', () => {
/* ... */
});
});
Pipeline stages (BR-010 through BR-019):
src/lib/services/__tests__/pipeline-stages.test.ts
describe('Pipeline Stage Management', () => {
describe('BR-010: stage values and ordering', () => {
it('enforces valid stage transitions', async () => {
/* ... */
});
it('allows backward transitions (manual override)', async () => {
/* ... */
});
it('records stage change timestamp in milestones', async () => {
/* ... */
});
});
describe('BR-015: lead category auto-promotion', () => {
it('hot lead: recent activity + docs sent = auto-promote', async () => {
/* ... */
});
it('cold lead: no activity 30+ days = auto-demote', async () => {
/* ... */
});
});
});
EOI & document rules (BR-020 through BR-029):
src/lib/services/__tests__/eoi-rules.test.ts
describe('EOI Business Rules', () => {
describe('BR-020: EOI prerequisites', () => {
it('requires berth linked before EOI generation', async () => {
/* ... */
});
it('requires client email before EOI generation', async () => {
/* ... */
});
});
describe('BR-021: signing order', () => {
it('enforces client → developer → sales/approver sequential signing', async () => {
/* ... */
});
});
describe('BR-022: EOI expiry', () => {
it('marks expired after configured days', async () => {
/* ... */
});
});
});
Duplicate detection (BR-030 through BR-032):
src/lib/services/__tests__/duplicate-detection.test.ts
describe('Client Duplicate Detection', () => {
describe('BR-030: exact email match', () => {
it('flags exact email match as definite duplicate', async () => {
/* ... */
});
it('case-insensitive email matching', async () => {
/* ... */
});
});
describe('BR-031: fuzzy name + phone match', () => {
it('flags similar names with matching phone as potential duplicate', async () => {
/* ... */
});
it('does not flag dissimilar names', async () => {
/* ... */
});
});
describe('BR-032: merge re-parents all child records', () => {
it('transfers interests, notes, files, tags to surviving client', async () => {
/* ... */
});
it('creates merge_log entry with full detail', async () => {
/* ... */
});
it('audit logs the merge operation', async () => {
/* ... */
});
});
});
Invoice rules (BR-040 through BR-049):
src/lib/services/__tests__/invoice-rules.test.ts
describe('Invoice Business Rules', () => {
describe('BR-041: invoice numbering', () => {
it('auto-generates INV-YYYYMM-NNN format', async () => {
/* ... */
});
it('uses advisory lock to prevent gaps', async () => {
/* ... */
});
it('handles concurrent invoice creation', async () => {
/* ... */
});
});
describe('BR-042: invoice line items from expenses', () => {
/* ... */
});
describe('BR-043: payment recording', () => {
/* ... */
});
});
Reminder rules, notification rules, permission resolution:
src/lib/services/__tests__/reminders.test.ts
src/lib/services/__tests__/notifications.test.ts
src/lib/services/__tests__/permissions.test.ts
describe('Permission Resolution', () => {
it('resolves global role permissions correctly', async () => {
/* ... */
});
it('applies port-level overrides (add permission)', async () => {
/* ... */
});
it('applies port-level overrides (remove permission)', async () => {
/* ... */
});
it('super_admin bypasses all checks but is still audited', async () => {
/* ... */
});
it('viewer role can read but not write', async () => {
/* ... */
});
it('denies access when permission not granted', async () => {
/* ... */
});
});
Zod schema tests:
src/lib/validators/__tests__/ — One test file per validator:
describe('Client Validators', () => {
describe('createClientSchema', () => {
it('accepts valid client data', () => {
/* ... */
});
it('rejects missing required fields', () => {
/* ... */
});
it('rejects invalid email format', () => {
/* ... */
});
it('rejects phone number exceeding max length', () => {
/* ... */
});
it('strips unknown fields', () => {
/* ... */
});
});
});
Target: 120+ unit tests organized by business rule and validator.
Day 3: Integration Tests — Critical Workflows
API integration tests use the callApiRoute helper to invoke route handlers with proper auth context. Each test creates its own data via factories and asserts on both the response and database state.
src/test/integration/client-lifecycle.test.ts:
describe('Client Lifecycle', () => {
it('create → update → add note → archive → restore', async () => {
const port = await createTestPort();
const user = await createTestUser({ role: 'sales_manager', portId: port.id });
// Create
const { status, body } = await callApiRoute(POST_clients, {
user,
portId: port.id,
body: { first_name: 'John', last_name: 'Doe', email: 'john@example.com' },
});
expect(status).toBe(201);
expect(body.data.id).toBeDefined();
// Verify audit log
const audit = await db.query.auditLogs.findFirst({
where: eq(auditLogs.entityId, body.data.id),
});
expect(audit?.action).toBe('create');
// Update, archive, restore...
});
});
src/test/integration/interest-pipeline.test.ts:
describe('Interest Pipeline Flow', () => {
it('create interest → link berth → advance stages → generate EOI → signing flow', async () => {
// 1. Create client + berth
// 2. Create interest → verify stage = 'open'
// 3. Link berth → verify berth status suggestion returned
// 4. Advance to 'details_sent' → verify milestone timestamp
// 5. Generate EOI → verify Documenso mock called, document created
// 6. Simulate webhook: client signed → verify signer status updated
// 7. Simulate webhook: all signed → verify document completed, PDF stored
// 8. Verify audit log has entries for each step
});
});
src/test/integration/expense-invoice.test.ts:
describe('Expense to Invoice Flow', () => {
it('create expenses → generate invoice → verify numbering → send → record payment', async () => {
// 1. Create 3 expenses with different categories
// 2. Create invoice from expense IDs
// 3. Verify INV-YYYYMM-001 format, line items match expenses
// 4. Generate PDF → verify @pdfme mock called
// 5. Send invoice → verify email mock called
// 6. Record payment → verify status = 'paid'
// 7. Verify audit log entries
});
});
src/test/integration/auth-and-permissions.test.ts:
describe('Authentication & Authorization', () => {
it('login with valid credentials → receive session', async () => {
/* ... */
});
it('login with wrong password → 401 generic error', async () => {
/* ... */
});
it('login with non-existent email → same 401 (no user enumeration)', async () => {
/* ... */
});
it('rate limit: 6th failed login within 15min → 429', async () => {
/* ... */
});
it('password set flow via token', async () => {
/* ... */
});
it('session expires after 24 hours', async () => {
/* ... */
});
it('CSRF token required on state-mutating requests', async () => {
/* ... */
});
});
Port scoping tests — systematic cross-port isolation:
src/test/integration/port-scoping.test.ts
describe('Port Scoping Isolation', () => {
let portA: Port, portB: Port;
let userA: TestUser, userB: TestUser;
beforeEach(async () => {
portA = await createTestPort({ name: 'Port A' });
portB = await createTestPort({ name: 'Port B' });
userA = await createTestUser({ role: 'sales_manager', portId: portA.id });
userB = await createTestUser({ role: 'sales_manager', portId: portB.id });
});
// Test every entity type
const entityTests = [
{ name: 'clients', create: () => createTestClient(portA.id), listEndpoint: GET_clients },
{
name: 'interests',
create: async () => {
const c = await createTestClient(portA.id);
return createTestInterest(portA.id, c.id);
},
listEndpoint: GET_interests,
},
{ name: 'berths', create: () => createTestBerth(portA.id), listEndpoint: GET_berths },
{ name: 'expenses', create: () => createTestExpense(portA.id), listEndpoint: GET_expenses },
{
name: 'invoices',
create: async () => {
const c = await createTestClient(portA.id);
return createTestInvoice(portA.id, c.id);
},
listEndpoint: GET_invoices,
},
{
name: 'reminders',
create: () => createTestReminder(portA.id, userA.userId),
listEndpoint: GET_reminders,
},
];
for (const { name, create, listEndpoint } of entityTests) {
describe(`${name}`, () => {
it(`user at Port B cannot list ${name} from Port A`, async () => {
await create();
const { status, body } = await callApiRoute(listEndpoint, {
user: userB,
portId: portB.id,
});
expect(status).toBe(200);
expect(body.data).toHaveLength(0); // Port B has no data
});
it(`user at Port B cannot access single ${name} from Port A`, async () => {
const entity = await create();
const { status } = await callApiRoute(GET_entity, {
user: userB,
portId: portB.id,
params: { id: entity.id },
});
expect(status).toBe(404); // Not found (not 403, to prevent enumeration)
});
});
}
});
This is the single most important test file in the entire suite. It systematically verifies that every entity type respects port scoping.
src/test/integration/webhook-delivery.test.ts:
describe('Outbound Webhook Delivery', () => {
it('delivers payload with correct HMAC signature on client.created event', async () => {
/* ... */
});
it('retries on non-2xx response', async () => {
/* ... */
});
it('dead-letters after 3 failed attempts and creates alert', async () => {
/* ... */
});
it('skips delivery if webhook is deactivated', async () => {
/* ... */
});
});
src/test/integration/import-export.test.ts:
describe('Data Import/Export', () => {
it('CSV import: upload → auto-map → preview → execute → history', async () => {
/* ... */
});
it('import detects duplicate clients by email', async () => {
/* ... */
});
it('export respects filters and includes custom fields', async () => {
/* ... */
});
});
src/test/integration/email-system.test.ts:
describe('Email System', () => {
it('compose and send email → thread created → IMAP sync finds reply', async () => {
/* ... */
});
});
src/test/integration/search.test.ts:
describe('Global Search', () => {
it('finds clients, interests, and berths across all entity types', async () => {
/* ... */
});
it('tsvector search handles partial words with pg_trgm', async () => {
/* ... */
});
it('results are port-scoped', async () => {
/* ... */
});
});
Target: 35+ integration tests covering every critical workflow and cross-cutting concern (auth, permissions, port scoping, audit logging).
Day 4: Playwright E2E Tests
Playwright configuration: playwright.config.ts
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './e2e',
fullyParallel: false, // Sequential — tests share server state
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: 1, // Single worker — consistent state
reporter: [['html'], ['json', { outputFile: 'test-results/results.json' }]],
use: {
baseURL: process.env.E2E_BASE_URL || 'http://localhost:3000',
trace: 'on-first-retry',
screenshot: 'only-on-failure',
video: 'on-first-retry',
},
projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }],
webServer: process.env.CI
? undefined
: {
command: 'pnpm dev',
port: 3000,
reuseExistingServer: true,
},
});
E2E test suites — 6 tests per 14-TECHNICAL-DECISIONS.md §9.2:
e2e/01-auth-dashboard.spec.ts:
test('Login → dashboard loads → navigate to clients', async ({ page }) => {
// 1. Navigate to /login
// 2. Enter test credentials
// 3. Verify redirect to /{portSlug} (dashboard)
// 4. Verify dashboard widgets render (pipeline summary, occupancy, recent activity)
// 5. Click "Clients" in sidebar → verify client list loads
// 6. Verify data matches (at least the seeded test data)
});
e2e/02-client-interest-eoi.spec.ts:
test('Create client → link interest → generate EOI → verify PDF', async ({ page }) => {
// 1. Navigate to clients → click "New Client"
// 2. Fill client form (name, email, phone, country)
// 3. Submit → verify toast "Client created"
// 4. On client detail → click "New Interest"
// 5. Link berth in interest form
// 6. Click "Generate EOI"
// 7. Verify document appears in interest detail with "Pending" status
// 8. Verify PDF download link works
});
e2e/03-expense-receipt-invoice.spec.ts:
test('Create expense → upload receipt → verify in expense list', async ({ page }) => {
// 1. Navigate to expenses → click "New Expense"
// 2. Fill expense form (amount, category, vendor, date)
// 3. Upload receipt image
// 4. Submit → verify toast + expense in list
// 5. Select expense → create invoice
// 6. Verify invoice generated with correct total
});
e2e/04-email-compose-send.spec.ts:
test('Email compose → send → verify in thread', async ({ page }) => {
// 1. Navigate to client detail → click "Send Email"
// 2. Fill subject, compose body in TipTap editor
// 3. Click "Send" → verify toast "Email sent"
// 4. Navigate to email section → verify thread appears
// 5. Open thread → verify sent message content
});
e2e/05-admin-user-roles.spec.ts:
test('Admin: create user → assign role → verify permissions', async ({ page }) => {
// 1. Navigate to admin → users → "Add User"
// 2. Fill user details + select role
// 3. Submit → verify user appears in list
// 4. Log out → log in as new user
// 5. Verify sidebar shows/hides sections based on role
// 6. Verify restricted page returns 403
});
e2e/06-berth-management.spec.ts:
test('Berth management: view berth → assign client → verify status transitions', async ({
page,
}) => {
// 1. Navigate to berths → select a berth
// 2. Verify berth detail shows specs, status, history
// 3. Link a client's interest to berth
// 4. Verify berth status changes (or suggestion appears)
// 5. Navigate to berth map → verify visual update
// 6. Change berth status manually → verify audit log entry
});
E2E test data seeding: e2e/seed.ts
- Runs before E2E suite (via
globalSetupin Playwright config) - Seeds: 1 port, 1 admin user, 1 agent user, 5 clients, 10 berths, 5 interests at various stages, 3 expenses, 1 invoice
- Uses direct database inserts (not API) for speed
Stream B: Security Audit & Performance Validation (Days 3–5)
Day 3 (of stream): Application-Level Rate Limiting + Security Audit
Application-level rate limiting (Redis-backed, per-user):
src/lib/middleware/rate-limit.ts:
import Redis from 'ioredis';
interface RateLimitConfig {
/** Window size in milliseconds */
windowMs: number;
/** Max requests per window */
max: number;
/** Redis key prefix */
prefix: string;
/** How to identify the requester */
keyGenerator: (req: NextRequest, ctx: AuthContext) => string;
}
/**
* Sliding window rate limiter using Redis sorted sets.
* More accurate than fixed-window counters — prevents burst at window boundaries.
*/
export function createRateLimiter(config: RateLimitConfig) {
return async function rateLimitMiddleware(
req: NextRequest,
ctx: AuthContext,
): Promise<{ limited: boolean; remaining: number; resetAt: number }> {
const key = `rl:${config.prefix}:${config.keyGenerator(req, ctx)}`;
const now = Date.now();
const windowStart = now - config.windowMs;
// Redis pipeline: remove old entries, add current, count window
const pipe = redis.pipeline();
pipe.zremrangebyscore(key, 0, windowStart);
pipe.zadd(key, now, `${now}:${crypto.randomUUID()}`);
pipe.zcard(key);
pipe.expire(key, Math.ceil(config.windowMs / 1000));
const results = await pipe.exec();
const count = results![2][1] as number;
const limited = count > config.max;
const remaining = Math.max(0, config.max - count);
const resetAt = now + config.windowMs;
return { limited, remaining, resetAt };
};
}
Rate limit configs per endpoint group:
// Auth endpoints: 5 per 15 minutes per email (BR-login)
export const authRateLimit = createRateLimiter({
windowMs: 15 * 60 * 1000,
max: 5,
prefix: 'auth',
keyGenerator: (req) => extractEmailFromBody(req) || getClientIp(req),
});
// Password reset: 3 per hour per email
export const resetRateLimit = createRateLimiter({
windowMs: 60 * 60 * 1000,
max: 3,
prefix: 'reset',
keyGenerator: (req) => extractEmailFromBody(req) || getClientIp(req),
});
// API: 60 per minute per authenticated user (configurable)
export const apiRateLimit = createRateLimiter({
windowMs: 60 * 1000,
max: 60,
prefix: 'api',
keyGenerator: (_req, ctx) => ctx.userId,
});
// File upload: 10 per minute per user
export const uploadRateLimit = createRateLimiter({
windowMs: 60 * 1000,
max: 10,
prefix: 'upload',
keyGenerator: (_req, ctx) => ctx.userId,
});
Response headers:
// Add to all API responses:
headers.set('X-RateLimit-Limit', String(config.max));
headers.set('X-RateLimit-Remaining', String(remaining));
headers.set('X-RateLimit-Reset', String(resetAt));
// On 429:
headers.set('Retry-After', String(Math.ceil(config.windowMs / 1000)));
Security audit checklist (verify, don't implement — these should exist from L0-L4):
-
Security headers audit — Verify nginx config matches
SECURITY-GUIDELINES.md§6.2 exactly:X-Content-Type-Options: nosniff✓X-Frame-Options: DENY✓X-XSS-Protection: 0← verify this is0, not1; mode=blockReferrer-Policy: strict-origin-when-cross-origin✓- CSP matches the spec ✓
Strict-Transport-Security: max-age=31536000; includeSubDomains✓Permissions-Policy: camera=(), microphone=(), geolocation=()✓
-
CORS audit — Verify only CRM domain and public website domain are allowed origins. No wildcards.
-
Session security audit:
- Cookie flags:
httpOnly,secure,sameSite=strict✓ - Session duration: 24 hours with refresh in last 25% ✓
- Logout destroys server session ✓
- Cookie flags:
-
Input sanitization audit — Walk through every API endpoint:
- All inputs validated through Zod ✓
- Rich text sanitized with DOMPurify (correct allowlist from
SECURITY-GUIDELINES.md§3.3) ✓ - File uploads: MIME type allowlist, size limit, filename sanitization ✓
- No raw
dangerouslySetInnerHTMLwithout DOMPurify ✓
DOMPurify config verification (must match SECURITY-GUIDELINES.md §3.3 exactly):
const DOMPURIFY_CONFIG = {
ALLOWED_TAGS: [
'b',
'i',
'u',
'em',
'strong',
'p',
'br',
'ul',
'ol',
'li',
'a',
'h1',
'h2',
'h3',
'h4',
'h5',
'h6',
'table',
'tr',
'td',
'th',
'blockquote',
'code',
'pre',
],
ALLOWED_ATTR: ['href', 'target', 'rel'],
ADD_ATTR: ['rel'],
FORCE_BODY: true,
};
// All links must have rel="noopener noreferrer" — enforce via DOMPurify hook
Day 4 (of stream): Error Response Format Audit + Input Validation Hardening
Error response format audit:
Walk through every API route handler and verify error responses match SECURITY-GUIDELINES.md §13:
// Create a test utility that verifies error format
function assertErrorFormat(response: Response, expectedStatus: number) {
const body = await response.json();
switch (expectedStatus) {
case 400:
expect(body).toMatchObject({ error: 'Validation failed', details: expect.any(Array) });
// details should have { field, message } pairs
break;
case 401:
expect(body).toMatchObject({ error: 'Authentication required' });
break;
case 403:
expect(body).toMatchObject({ error: 'Insufficient permissions' });
break;
case 404:
expect(body).toMatchObject({ error: 'Resource not found' });
break;
case 429:
expect(body).toMatchObject({ error: 'Too many requests', retryAfter: expect.any(Number) });
break;
case 500:
expect(body).toMatchObject({ error: 'Internal server error' });
// Must NOT contain: stack trace, SQL error, internal path, env var value
break;
}
// Universal checks:
expect(body).not.toHaveProperty('stack');
expect(JSON.stringify(body)).not.toMatch(/node_modules|src\//);
expect(JSON.stringify(body)).not.toMatch(/SELECT|INSERT|UPDATE|DELETE|FROM/);
}
Create comprehensive error format tests:
src/test/integration/error-responses.test.ts:
describe('Error Response Security', () => {
it('400: validation error has field-level details without internal info', async () => {
/* ... */
});
it('401: generic message, no user enumeration', async () => {
/* ... */
});
it('403: generic permission denied, no resource details', async () => {
/* ... */
});
it('404: returned for both missing and unauthorized resources', async () => {
/* ... */
});
it('429: includes retryAfter', async () => {
/* ... */
});
it('500: catches database errors, returns generic message', async () => {
/* ... */
});
it('500: catches unhandled exceptions, no stack trace', async () => {
/* ... */
});
});
Global error handler: src/lib/middleware/error-handler.ts
/**
* Wraps API route handlers to catch unhandled errors and return safe responses.
* Logs the full error server-side but returns only safe information to clients.
*/
export function withErrorHandler(handler: RouteHandler): RouteHandler {
return async (req: NextRequest, ctx: RouteContext) => {
try {
return await handler(req, ctx);
} catch (error) {
// Log full error server-side (with entity IDs, not PII)
logger.error('Unhandled error', {
method: req.method,
path: req.nextUrl.pathname,
error: error instanceof Error ? error.message : 'Unknown error',
// Never log: stack trace, request body, user PII
});
if (error instanceof ValidationError) {
return NextResponse.json(
{ error: 'Validation failed', details: error.details },
{ status: 400 },
);
}
if (error instanceof NotFoundError) {
return NextResponse.json({ error: 'Resource not found' }, { status: 404 });
}
if (error instanceof ForbiddenError) {
return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 });
}
// Default: generic 500 — never leak details
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
};
}
Day 5 (of stream): Query Performance Validation + CI Pipeline
Query performance validation:
Create a performance test script src/test/performance/query-performance.test.ts:
describe('Query Performance', () => {
// Seed realistic data volume: 500 clients, 1000 interests, 100 berths, 2000 expenses
beforeAll(async () => {
await seedRealisticVolume();
});
const MAX_LIST_QUERY_MS = 100;
const MAX_SEARCH_QUERY_MS = 200;
const MAX_DASHBOARD_QUERY_MS = 500;
it('client list query completes under 100ms', async () => {
const start = performance.now();
await listClients(portId, { page: 1, limit: 25, filters: {} });
expect(performance.now() - start).toBeLessThan(MAX_LIST_QUERY_MS);
});
it('interest list with stage filter completes under 100ms', async () => {
/* ... */
});
it('global search completes under 200ms', async () => {
/* ... */
});
it('dashboard aggregation completes under 500ms', async () => {
/* ... */
});
// Verify EXPLAIN plans
it('client list uses idx_clients_port index', async () => {
const plan = await db.execute(
sql`EXPLAIN SELECT * FROM clients WHERE port_id = ${portId} ORDER BY updated_at DESC LIMIT 25`,
);
expect(plan.rows[0].QUERY_PLAN).toContain('idx_clients_port');
});
it('search query uses GIN index on search_vector', async () => {
/* ... */
});
it('no N+1 queries on entity detail endpoints', async () => {
/* ... */
});
});
Missing index audit:
- Run
EXPLAIN ANALYZEon every list query with the seeded data - Add composite indexes for common filter patterns:
(port_id, pipeline_stage, created_at DESC)on interests(port_id, category, created_at DESC)on expenses(port_id, status, due_date)on invoices(port_id, assigned_to, is_completed, due_at)on reminders
CI pipeline: .github/workflows/ci.yml
name: CI
on:
push:
branches: [main, develop]
pull_request:
branches: [main]
env:
NODE_VERSION: '20'
PNPM_VERSION: '9'
jobs:
lint-typecheck:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with: { version: '${{ env.PNPM_VERSION }}' }
- uses: actions/setup-node@v4
with:
node-version: '${{ env.NODE_VERSION }}'
cache: 'pnpm'
- run: pnpm install --frozen-lockfile
- run: pnpm lint
- run: pnpm typecheck
unit-integration-tests:
runs-on: ubuntu-latest
needs: lint-typecheck
services:
postgres:
image: postgres:16-alpine
env:
POSTGRES_DB: portnimara_test
POSTGRES_USER: test
POSTGRES_PASSWORD: test
ports: ['5433:5432']
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
redis:
image: redis:7-alpine
ports: ['6380:6379']
options: >-
--health-cmd "redis-cli ping"
--health-interval 10s
--health-timeout 5s
--health-retries 5
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with: { version: '${{ env.PNPM_VERSION }}' }
- uses: actions/setup-node@v4
with:
node-version: '${{ env.NODE_VERSION }}'
cache: 'pnpm'
- run: pnpm install --frozen-lockfile
- name: Run migrations on test DB
run: pnpm db:migrate
env:
DATABASE_URL: postgresql://test:test@localhost:5433/portnimara_test
- name: Run Vitest with coverage
run: pnpm test:ci
env:
TEST_DATABASE_URL: postgresql://test:test@localhost:5433/portnimara_test
TEST_REDIS_URL: redis://localhost:6380
NODE_ENV: test
- name: Upload coverage
uses: actions/upload-artifact@v4
with:
name: coverage
path: coverage/
e2e-tests:
runs-on: ubuntu-latest
needs: unit-integration-tests
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with: { version: '${{ env.PNPM_VERSION }}' }
- uses: actions/setup-node@v4
with:
node-version: '${{ env.NODE_VERSION }}'
cache: 'pnpm'
- run: pnpm install --frozen-lockfile
- name: Install Playwright browsers
run: pnpm exec playwright install --with-deps chromium
- name: Start services
run: docker compose -f docker-compose.test.yml up -d
- name: Wait for services
run: pnpm wait-for-services
- name: Build app
run: pnpm build
env:
DATABASE_URL: postgresql://test:test@localhost:5433/portnimara_test
REDIS_URL: redis://localhost:6380
- name: Run E2E tests
run: pnpm test:e2e
env:
E2E_BASE_URL: http://localhost:3000
- name: Upload Playwright report
if: always()
uses: actions/upload-artifact@v4
with:
name: playwright-report
path: playwright-report/
security-audit:
runs-on: ubuntu-latest
needs: lint-typecheck
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with: { version: '${{ env.PNPM_VERSION }}' }
- uses: actions/setup-node@v4
with:
node-version: '${{ env.NODE_VERSION }}'
cache: 'pnpm'
- run: pnpm install --frozen-lockfile
- name: Dependency audit
run: pnpm audit --audit-level=high
- name: Check for leaked secrets
run: npx secretlint "**/*"
Package.json scripts:
{
"scripts": {
"test": "vitest",
"test:ci": "vitest run --coverage",
"test:e2e": "playwright test",
"typecheck": "tsc --noEmit",
"lint": "eslint src/ --ext .ts,.tsx",
"db:migrate": "drizzle-kit migrate"
}
}
3. Acceptance Criteria
Test Infrastructure (AC-L5-01 through AC-L5-06)
- Test database provisioned via Docker Compose with tmpfs for fast teardown
beforeEachtruncates all tables — each test starts with clean state- Factory functions exist for all entity types with realistic defaults
- Mock services exist for all 6 external services (MinIO, Documenso, SMTP, Google Calendar, OpenAI, Frankfurter)
callApiRoutehelper enables testing API handlers with simulated auth context- Vitest coverage thresholds configured: 80% statements on
lib/services/, 90% onlib/validators/
Unit Tests (AC-L5-07 through AC-L5-11)
- All berth status rules (BR-001 through BR-009) tested for auto/suggest/off modes
- All pipeline stage rules (BR-010+) tested including milestone timestamps
- EOI rules (BR-020+) tested: prerequisites, signing order, expiry
- Duplicate detection (BR-030+) tested: exact match, fuzzy match, merge cascade
- Invoice numbering (BR-041) tested including concurrent creation with advisory lock
Integration Tests (AC-L5-12 through AC-L5-18)
- Client lifecycle integration test: create → update → note → archive → restore
- Interest pipeline integration test: full EOI flow from creation through signing
- Expense-to-invoice integration test: create expenses → generate invoice → send → pay
- Auth integration tests: login, failed login, rate limiting, password set, session expiry
- Port scoping test systematically verifies every entity type cannot be accessed cross-port
- Webhook delivery integration test: HMAC signature, retry, dead letter
- Search integration test: tsvector + pg_trgm across entity types
E2E Tests (AC-L5-19 through AC-L5-24)
- Login → dashboard → navigate to clients
- Create client → link interest → generate EOI → verify document
- Create expense → upload receipt → generate invoice
- Email compose → send → verify in thread
- Admin: create user → assign role → verify permission boundaries
- Berth management: view → assign client → verify status transition
Security (AC-L5-25 through AC-L5-33)
- Application-level rate limiter uses Redis sliding window per user
- Auth endpoint rate limit: 5 attempts per 15 min per email
- Password reset rate limit: 3 per hour per email
- Rate limit headers present on all API responses (X-RateLimit-*)
- Security headers verified against SECURITY-GUIDELINES.md §6.2 exactly (X-XSS-Protection: 0)
- DOMPurify allowlist matches SECURITY-GUIDELINES.md §3.3 exactly (all 21 tags)
- Error responses never contain stack traces, SQL, or internal paths
- Error format matches §13 for all status codes (400, 401, 403, 404, 429, 500)
- Global error handler catches unhandled exceptions and returns safe 500
Performance (AC-L5-34 through AC-L5-38)
- List queries complete under 100ms with 500+ records
- Search queries complete under 200ms
- Dashboard aggregation completes under 500ms
- All list queries confirmed using appropriate indexes via EXPLAIN
- No N+1 queries on entity detail endpoints
CI Pipeline (AC-L5-39 through AC-L5-43)
- CI runs lint + typecheck on every push and PR
- CI runs Vitest with coverage, fails if thresholds not met
- CI runs Playwright E2E tests with Chromium
- CI runs
pnpm auditand fails on high/critical vulnerabilities - CI artifacts include coverage report and Playwright report
4. Self-Review Checklist
Test Quality
- Every business rule from BR-001 through BR-152 has at least one test
- Tests use factories for data setup, not raw SQL
- Tests clean up after themselves (beforeEach truncate handles this)
- Integration tests verify both response AND database state
- E2E tests match exactly the 6 workflows from
14-TECHNICAL-DECISIONS.md§9.2 - Port scoping tests cover every entity type systematically
- Error format tests verify no information leakage
Security
- DOMPurify config matches
SECURITY-GUIDELINES.md§3.3 tag-for-tag - X-XSS-Protection is
0(not1; mode=block) - Rate limiting uses sliding window (not fixed window) for accuracy
- Global error handler catches ALL unhandled errors
- Error responses verified to never contain: stack traces, SQL, internal paths, env vars
- Audit log completeness verified for all entity × action combinations
Performance
- Performance tests use realistic data volume (500+ clients, 100+ berths)
- EXPLAIN output verified for all list queries
- Missing indexes identified and added
- Dashboard queries cached in Redis (5-min TTL)
- N+1 detection utility available in dev mode
CI/CD
- CI runs on both push and PR
- Coverage thresholds enforced (build fails below 80%)
- Playwright browser installed in CI with
--with-deps pnpm auditfails build on high/critical vulnerabilities- Lockfile (
pnpm-lock.yaml) is committed and used in CI (--frozen-lockfile) - Artifacts collected: coverage report, Playwright HTML report
Codex Addenda — Merged from Competing Plan Review
1. Verification Matrix Over Raw Test Counts
Do not optimize for impressive test counts. Layer 5 should prove four things:
- The domain rules hold (berth status, stage transitions, duplicates, EOI lifecycle, invoices, reminders, file deletion guards)
- The contracts hold (response shapes, pagination limits, error formats)
- The security boundaries hold (cross-port leaks, credential encryption, presigned URL misuse, webhook signatures, rate limits)
- The production controls hold (health checks, monitoring, feature flags, admin-only routes)
Raw test count is secondary to this matrix.
2. Contract Tests Using Production Zod Schemas
Use the actual production Zod schemas as the test oracle for contract tests. Add response schemas for key routes:
export const paginatedResponseSchema = z.object({
data: z.array(z.unknown()),
meta: z.object({
total: z.number().int().nonnegative(),
page: z.number().int().positive(),
limit: z.number().int().positive(),
pages: z.number().int().nonnegative(),
}),
});
export const apiErrorSchema = z.object({
error: z.object({
code: z.string(),
message: z.string(),
details: z.unknown().optional(),
}),
});
Contract tests should verify list endpoints never return more than 100 rows regardless of requested limit.
3. EXPLAIN ANALYZE Capture
Capture EXPLAIN ANALYZE output for core queries before release:
- Clients list
- Interests list by stage
- Berths explorer
- Expenses list by date range
- Global search on a common client term
- Dashboard aggregate queries
Add only proven missing indexes. Do not speculative-index every column.
4. False Confidence Warning
The biggest testing risk is false confidence from mocked repository tests. Keep domain logic close enough to the database that integration tests still matter. Tests should run against the full migrated schema, not a reduced in-memory substitute.
5. Priority-Based Test Cutting
If schedule pressure hits, cut in this order (least valuable first):
- Low-value UI-path duplication tests
- Happy-path API tests (already covered by integration)
- Never cut: Security tests, cross-port leak tests, domain rule tests
6. External Service Isolation
Any external-service call that escapes the fake layer should fail the test immediately. Queue jobs in tests run synchronously via fake workers unless the test explicitly covers queue retry behavior.
7. Two-Port Testing
Use one seeded port_nimara record and a second port for cross-port leakage tests. Test helpers should create both super-admin and ordinary users with distinct port access.