# 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

1. **Test factory pattern** — Creating `src/test/helpers/factories.ts` with per-entity factory functions is the right approach for test data.
2. **External service mocks** — Listing all six external services (MinIO, Documenso, SMTP, Google Calendar, OpenAI, Frankfurter) that need mocks is complete.
3. **Business rule test structure** — Organizing tests by BR-number makes it easy to verify coverage against `09-BUSINESS-RULES.md`.
4. **Dead letter alerting verification** — Testing the dead letter → notification flow end-to-end is important.

### What Needs Fixing

5. **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.

6. **Wrong route paths (again)** — Uses `src/app/(crm)/admin/system/page.tsx` instead of `(dashboard)/[portSlug]/admin/...`.

7. **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.

8. **X-XSS-Protection header value wrong** — Baseline uses `"1; mode=block"` but `SECURITY-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.

9. **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.

10. **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`.

11. **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.

12. **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.

13. **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.

14. **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.

15. **Coverage enforcement missing** — Mentions "80%+ on lib/services" as a target but doesn't configure Vitest coverage thresholds to fail the build below that.

16. **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):

```yaml
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`

```typescript
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`

```typescript
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`:

```typescript
/**
 * 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`:

```typescript
/**
 * 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`:

```typescript
/**
 * 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`:

```typescript
/**
 * 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`

```typescript
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`

```typescript
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`

```typescript
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`

```typescript
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`

```typescript
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`

```typescript
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:

```typescript
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`:

```typescript
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`:

```typescript
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`:

```typescript
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`:

```typescript
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`

```typescript
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`:

```typescript
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`:

```typescript
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`:

```typescript
describe('Email System', () => {
  it('compose and send email → thread created → IMAP sync finds reply', async () => {
    /* ... */
  });
});
```

`src/test/integration/search.test.ts`:

```typescript
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`

```typescript
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`:

```typescript
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`:

```typescript
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`:

```typescript
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`:

```typescript
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`:

```typescript
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`:

```typescript
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 `globalSetup` in 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`:

```typescript
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:**

```typescript
// 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:**

```typescript
// 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):**

1. **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 is `0`, not `1; mode=block`
   - `Referrer-Policy: strict-origin-when-cross-origin` ✓
   - CSP matches the spec ✓
   - `Strict-Transport-Security: max-age=31536000; includeSubDomains` ✓
   - `Permissions-Policy: camera=(), microphone=(), geolocation=()` ✓

2. **CORS audit** — Verify only CRM domain and public website domain are allowed origins. No wildcards.

3. **Session security audit:**
   - Cookie flags: `httpOnly`, `secure`, `sameSite=strict` ✓
   - Session duration: 24 hours with refresh in last 25% ✓
   - Logout destroys server session ✓

4. **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 `dangerouslySetInnerHTML` without DOMPurify ✓

**DOMPurify config verification** (must match `SECURITY-GUIDELINES.md` §3.3 exactly):

```typescript
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:

```typescript
// 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`:

```typescript
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`

```typescript
/**
 * 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`:

```typescript
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 ANALYZE` on 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`

```yaml
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:**

```json
{
  "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)

1. Test database provisioned via Docker Compose with tmpfs for fast teardown
2. `beforeEach` truncates all tables — each test starts with clean state
3. Factory functions exist for all entity types with realistic defaults
4. Mock services exist for all 6 external services (MinIO, Documenso, SMTP, Google Calendar, OpenAI, Frankfurter)
5. `callApiRoute` helper enables testing API handlers with simulated auth context
6. Vitest coverage thresholds configured: 80% statements on `lib/services/`, 90% on `lib/validators/`

### Unit Tests (AC-L5-07 through AC-L5-11)

7. All berth status rules (BR-001 through BR-009) tested for auto/suggest/off modes
8. All pipeline stage rules (BR-010+) tested including milestone timestamps
9. EOI rules (BR-020+) tested: prerequisites, signing order, expiry
10. Duplicate detection (BR-030+) tested: exact match, fuzzy match, merge cascade
11. Invoice numbering (BR-041) tested including concurrent creation with advisory lock

### Integration Tests (AC-L5-12 through AC-L5-18)

12. Client lifecycle integration test: create → update → note → archive → restore
13. Interest pipeline integration test: full EOI flow from creation through signing
14. Expense-to-invoice integration test: create expenses → generate invoice → send → pay
15. Auth integration tests: login, failed login, rate limiting, password set, session expiry
16. Port scoping test systematically verifies every entity type cannot be accessed cross-port
17. Webhook delivery integration test: HMAC signature, retry, dead letter
18. Search integration test: tsvector + pg_trgm across entity types

### E2E Tests (AC-L5-19 through AC-L5-24)

19. Login → dashboard → navigate to clients
20. Create client → link interest → generate EOI → verify document
21. Create expense → upload receipt → generate invoice
22. Email compose → send → verify in thread
23. Admin: create user → assign role → verify permission boundaries
24. Berth management: view → assign client → verify status transition

### Security (AC-L5-25 through AC-L5-33)

25. Application-level rate limiter uses Redis sliding window per user
26. Auth endpoint rate limit: 5 attempts per 15 min per email
27. Password reset rate limit: 3 per hour per email
28. Rate limit headers present on all API responses (X-RateLimit-\*)
29. Security headers verified against SECURITY-GUIDELINES.md §6.2 exactly (X-XSS-Protection: 0)
30. DOMPurify allowlist matches SECURITY-GUIDELINES.md §3.3 exactly (all 21 tags)
31. Error responses never contain stack traces, SQL, or internal paths
32. Error format matches §13 for all status codes (400, 401, 403, 404, 429, 500)
33. Global error handler catches unhandled exceptions and returns safe 500

### Performance (AC-L5-34 through AC-L5-38)

34. List queries complete under 100ms with 500+ records
35. Search queries complete under 200ms
36. Dashboard aggregation completes under 500ms
37. All list queries confirmed using appropriate indexes via EXPLAIN
38. No N+1 queries on entity detail endpoints

### CI Pipeline (AC-L5-39 through AC-L5-43)

39. CI runs lint + typecheck on every push and PR
40. CI runs Vitest with coverage, fails if thresholds not met
41. CI runs Playwright E2E tests with Chromium
42. CI runs `pnpm audit` and fails on high/critical vulnerabilities
43. 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` (not `1; 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 audit` fails 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**:

1. The **domain rules** hold (berth status, stage transitions, duplicates, EOI lifecycle, invoices, reminders, file deletion guards)
2. The **contracts** hold (response shapes, pagination limits, error formats)
3. The **security boundaries** hold (cross-port leaks, credential encryption, presigned URL misuse, webhook signatures, rate limits)
4. 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:

```ts
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):

1. Low-value UI-path duplication tests
2. Happy-path API tests (already covered by integration)
3. **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.
