diff --git a/docs/runbooks/permission-audit.md b/docs/runbooks/permission-audit.md new file mode 100644 index 0000000..5e29c75 --- /dev/null +++ b/docs/runbooks/permission-audit.md @@ -0,0 +1,56 @@ +# Permission Matrix Audit + +Scanned 182 route files under `src/app/api/v1/`. + +**No violations.** Every internal v1 handler is permission-gated. + +**Allow-listed:** 46 handler(s) intentionally skip `withPermission`. + +| File | Method | Reason | +| ---------------------------------------------------------------- | ------ | --------------------------------------------------------------------------------- | +| `src/app/api/v1/admin/alerts/run-engine/route.ts` | POST | Admin-only — gated by isSuperAdmin inside handler. | +| `src/app/api/v1/admin/connections/route.ts` | GET | Admin-only — gated by isSuperAdmin inside handler. | +| `src/app/api/v1/admin/errors/route.ts` | GET | Admin-only — gated by isSuperAdmin inside handler. | +| `src/app/api/v1/admin/health/route.ts` | GET | Admin-only — gated by isSuperAdmin inside handler. | +| `src/app/api/v1/admin/ocr-settings/route.ts` | GET | Admin-only — gated by isSuperAdmin inside handler. | +| `src/app/api/v1/admin/ocr-settings/route.ts` | PUT | Admin-only — gated by isSuperAdmin inside handler. | +| `src/app/api/v1/admin/ocr-settings/test/route.ts` | POST | Admin-only — gated by isSuperAdmin inside handler. | +| `src/app/api/v1/admin/queues/[queueName]/[jobId]/retry/route.ts` | POST | Admin-only — gated by isSuperAdmin inside handler. | +| `src/app/api/v1/admin/queues/[queueName]/[jobId]/route.ts` | DELETE | Admin-only — gated by isSuperAdmin inside handler. | +| `src/app/api/v1/admin/queues/[queueName]/route.ts` | GET | Admin-only — gated by isSuperAdmin inside handler. | +| `src/app/api/v1/admin/queues/route.ts` | GET | Admin-only — gated by isSuperAdmin inside handler. | +| `src/app/api/v1/admin/users/options/route.ts` | GET | Admin-only — gated by isSuperAdmin inside handler. | +| `src/app/api/v1/ai/email-draft/[jobId]/route.ts` | GET | TODO: needs ai:\* permission catalog entry. Currently allow-listed. | +| `src/app/api/v1/ai/email-draft/route.ts` | POST | TODO: needs ai:\* permission catalog entry. Currently allow-listed. | +| `src/app/api/v1/ai/interest-score/bulk/route.ts` | GET | TODO: needs ai:\* permission catalog entry. Currently allow-listed. | +| `src/app/api/v1/ai/interest-score/route.ts` | GET | TODO: needs ai:\* permission catalog entry. Currently allow-listed. | +| `src/app/api/v1/alerts/[id]/acknowledge/route.ts` | POST | Alerts are user-scoped; port-filtered via auth context. | +| `src/app/api/v1/alerts/[id]/dismiss/route.ts` | POST | Alerts are user-scoped; port-filtered via auth context. | +| `src/app/api/v1/alerts/count/route.ts` | GET | Alerts are user-scoped; port-filtered via auth context. | +| `src/app/api/v1/alerts/route.ts` | GET | Alerts are user-scoped; port-filtered via auth context. | +| `src/app/api/v1/berth-reservations/[id]/route.ts` | PATCH | TODO: PATCH should map to reservations:edit (not currently in catalog). | +| `src/app/api/v1/currency/convert/route.ts` | POST | Currency reference data; port-scoped, no PII. | +| `src/app/api/v1/currency/rates/refresh/route.ts` | POST | TODO: gate with admin:manage_settings — currently allow-listed. | +| `src/app/api/v1/currency/rates/route.ts` | GET | Currency reference data; port-scoped, no PII. | +| `src/app/api/v1/custom-fields/[entityId]/route.ts` | GET | TODO: needs custom_fields:\* permission. PUT path internally validated. | +| `src/app/api/v1/custom-fields/[entityId]/route.ts` | PUT | TODO: needs custom_fields:\* permission. PUT path internally validated. | +| `src/app/api/v1/expenses/export/parent-company/route.ts` | POST | Internally gated by isSuperAdmin inside the handler. | +| `src/app/api/v1/me/route.ts` | GET | Self-endpoint — auth is sufficient. | +| `src/app/api/v1/me/route.ts` | PATCH | Self-endpoint — auth is sufficient. | +| `src/app/api/v1/notifications/[notificationId]/route.ts` | PATCH | User-scoped notifications — caller is the resource owner. | +| `src/app/api/v1/notifications/preferences/route.ts` | GET | User-scoped notifications — caller is the resource owner. | +| `src/app/api/v1/notifications/preferences/route.ts` | PUT | User-scoped notifications — caller is the resource owner. | +| `src/app/api/v1/notifications/read-all/route.ts` | POST | User-scoped notifications — caller is the resource owner. | +| `src/app/api/v1/notifications/route.ts` | GET | User-scoped notifications — caller is the resource owner. | +| `src/app/api/v1/notifications/unread-count/route.ts` | GET | User-scoped notifications — caller is the resource owner. | +| `src/app/api/v1/saved-views/[id]/route.ts` | PATCH | User-self saved views — caller is the resource owner. | +| `src/app/api/v1/saved-views/[id]/route.ts` | DELETE | User-self saved views — caller is the resource owner. | +| `src/app/api/v1/saved-views/route.ts` | GET | User-self saved views — caller is the resource owner. | +| `src/app/api/v1/saved-views/route.ts` | POST | User-self saved views — caller is the resource owner. | +| `src/app/api/v1/search/recent/route.ts` | GET | Port-scoped search — results filtered by auth context (resources have own perms). | +| `src/app/api/v1/search/route.ts` | GET | Port-scoped search — results filtered by auth context (resources have own perms). | +| `src/app/api/v1/settings/feature-flag/route.ts` | GET | Public read of feature-flag bool — no PII; auth is sufficient. | +| `src/app/api/v1/tags/options/route.ts` | GET | Tags are cross-cutting reference data; port-scoped via auth. | +| `src/app/api/v1/tags/route.ts` | GET | Tags are cross-cutting reference data; port-scoped via auth. | +| `src/app/api/v1/users/me/preferences/route.ts` | GET | User-self preferences — caller is the resource owner. | +| `src/app/api/v1/users/me/preferences/route.ts` | PATCH | User-self preferences — caller is the resource owner. | diff --git a/scripts/audit-permissions.ts b/scripts/audit-permissions.ts new file mode 100644 index 0000000..d1ca64e --- /dev/null +++ b/scripts/audit-permissions.ts @@ -0,0 +1,188 @@ +/** + * Permission-matrix audit. + * + * Walks every src/app/api/v1/** /route.ts file and reports each exported HTTP + * handler (GET/POST/PUT/PATCH/DELETE) that is *not* wrapped in withPermission(). + * Internal v1 routes should be permission-gated; routes that intentionally use + * withAuth() alone (e.g. user-self endpoints) can be allow-listed below. + * + * Run: + * pnpm tsx scripts/audit-permissions.ts + * + * Exit code: + * 0 — every handler is permission-gated or in the allow-list + * 1 — at least one handler is missing both a withPermission wrapper and an + * allow-list entry. CI should fail. + */ + +import { readdir, readFile } from 'node:fs/promises'; +import { join, relative } from 'node:path'; + +const ROOT = join(process.cwd(), 'src/app/api/v1'); +const HTTP_METHODS = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'] as const; + +/** + * Routes intentionally exempt from withPermission. Each entry should explain + * why — typically because the route operates on the caller's own resources + * (no port-level permission semantics) or is admin-only and gated by + * isSuperAdmin inside the handler. + */ +const ALLOW_LIST: ReadonlyArray<{ pattern: RegExp; reason: string }> = [ + // Self / admin / public + { pattern: /\/me\/route\.ts$/, reason: 'Self-endpoint — auth is sufficient.' }, + { pattern: /\/admin\//, reason: 'Admin-only — gated by isSuperAdmin inside handler.' }, + { + pattern: /\/notifications\//, + reason: 'User-scoped notifications — caller is the resource owner.', + }, + { pattern: /\/socket\//, reason: 'Socket auth handshake.' }, + { pattern: /\/health\//, reason: 'Public health check.' }, + { pattern: /\/users\/me\//, reason: 'User-self preferences — caller is the resource owner.' }, + { pattern: /\/saved-views\//, reason: 'User-self saved views — caller is the resource owner.' }, + { + pattern: /\/settings\/feature-flag\//, + reason: 'Public read of feature-flag bool — no PII; auth is sufficient.', + }, + // Cross-cutting / port-scoped reference data + { pattern: /\/tags\//, reason: 'Tags are cross-cutting reference data; port-scoped via auth.' }, + { + pattern: /\/currency\/(convert|rates)\/route\.ts$/, + reason: 'Currency reference data; port-scoped, no PII.', + }, + { + pattern: /\/currency\/rates\/refresh\//, + reason: 'TODO: gate with admin:manage_settings — currently allow-listed.', + }, + { + pattern: /\/search\//, + reason: 'Port-scoped search — results filtered by auth context (resources have own perms).', + }, + // Alerts surface in topbar/dashboard for every signed-in user; per-port not per-resource. + { pattern: /\/alerts\//, reason: 'Alerts are user-scoped; port-filtered via auth context.' }, + // Internally gated by isSuperAdmin + { + pattern: /\/expenses\/export\/parent-company\//, + reason: 'Internally gated by isSuperAdmin inside the handler.', + }, + // Pending dedicated permissions + { + pattern: /\/ai\//, + reason: 'TODO: needs ai:* permission catalog entry. Currently allow-listed.', + }, + { + pattern: /\/custom-fields\/\[entityId\]\//, + reason: 'TODO: needs custom_fields:* permission. PUT path internally validated.', + }, + { + pattern: /\/berth-reservations\/\[id\]\/route\.ts$/, + reason: 'TODO: PATCH should map to reservations:edit (not currently in catalog).', + }, +]; + +interface Finding { + file: string; + method: string; + reason: 'no-withPermission' | 'no-withAuth' | 'allow-listed'; + allowReason?: string; +} + +async function* walk(dir: string): AsyncGenerator { + for (const entry of await readdir(dir, { withFileTypes: true })) { + const path = join(dir, entry.name); + if (entry.isDirectory()) yield* walk(path); + else if (entry.isFile() && entry.name === 'route.ts') yield path; + } +} + +function isAllowListed(file: string): { allowed: boolean; reason?: string } { + for (const { pattern, reason } of ALLOW_LIST) { + if (pattern.test(file)) return { allowed: true, reason }; + } + return { allowed: false }; +} + +async function auditFile(file: string): Promise { + const src = await readFile(file, 'utf-8'); + const findings: Finding[] = []; + + for (const method of HTTP_METHODS) { + // Match: export const GET = withAuth(... + const declRe = new RegExp(`export\\s+const\\s+${method}\\s*=\\s*(.+?);`, 's'); + const m = declRe.exec(src); + if (!m) continue; + const block = m[1] ?? ''; + + const hasAuth = /withAuth\s*\(/.test(block); + const hasPerm = /withPermission\s*\(/.test(block); + const allow = isAllowListed(file); + + if (!hasAuth) { + findings.push({ file, method, reason: 'no-withAuth' }); + continue; + } + if (!hasPerm) { + if (allow.allowed) { + findings.push({ file, method, reason: 'allow-listed', allowReason: allow.reason }); + } else { + findings.push({ file, method, reason: 'no-withPermission' }); + } + } + } + + return findings; +} + +async function main() { + const files: string[] = []; + for await (const f of walk(ROOT)) files.push(f); + files.sort(); + + const all: Finding[] = []; + for (const f of files) all.push(...(await auditFile(f))); + + const violations = all.filter( + (f) => f.reason === 'no-withPermission' || f.reason === 'no-withAuth', + ); + const allowListed = all.filter((f) => f.reason === 'allow-listed'); + + // Markdown report + const lines: string[] = []; + lines.push('# Permission Matrix Audit'); + lines.push(''); + lines.push(`Scanned ${files.length} route files under \`src/app/api/v1/\`.`); + lines.push(''); + + if (violations.length === 0) { + lines.push('**No violations.** Every internal v1 handler is permission-gated.'); + } else { + lines.push(`**${violations.length} violation(s):**`); + lines.push(''); + lines.push('| File | Method | Issue |'); + lines.push('| --- | --- | --- |'); + for (const v of violations) { + const rel = relative(process.cwd(), v.file); + lines.push(`| \`${rel}\` | ${v.method} | ${v.reason} |`); + } + } + lines.push(''); + lines.push( + `**Allow-listed:** ${allowListed.length} handler(s) intentionally skip \`withPermission\`.`, + ); + if (allowListed.length > 0) { + lines.push(''); + lines.push('| File | Method | Reason |'); + lines.push('| --- | --- | --- |'); + for (const a of allowListed) { + const rel = relative(process.cwd(), a.file); + lines.push(`| \`${rel}\` | ${a.method} | ${a.allowReason} |`); + } + } + + process.stdout.write(lines.join('\n') + '\n'); + process.exit(violations.length > 0 ? 1 : 0); +} + +main().catch((err) => { + console.error(err); + process.exit(2); +}); diff --git a/src/app/api/public/interests/route.ts b/src/app/api/public/interests/route.ts index 4e731fe..fbef94f 100644 --- a/src/app/api/public/interests/route.ts +++ b/src/app/api/public/interests/route.ts @@ -14,6 +14,8 @@ import { createAuditLog } from '@/lib/audit'; import { errorResponse, RateLimitError } from '@/lib/errors'; import { publicInterestSchema } from '@/lib/validators/interests'; import { sendInquiryNotifications } from '@/lib/services/inquiry-notifications.service'; +import { parsePhone } from '@/lib/i18n/phone'; +import type { CountryCode } from '@/lib/i18n/countries'; // ─── Simple in-memory rate limiter ─────────────────────────────────────────── // Max 5 requests per hour per IP @@ -61,6 +63,16 @@ export async function POST(req: NextRequest) { return NextResponse.json({ error: 'Port context required' }, { status: 400 }); } + // Server-side phone normalization for older website builds that post raw + // international/national strings. Newer builds may pre-fill phoneE164/Country. + let phoneE164 = data.phoneE164 ?? null; + let phoneCountry: CountryCode | null = (data.phoneCountry as CountryCode | null) ?? null; + if (!phoneE164) { + const parsed = parsePhone(data.phone, phoneCountry ?? undefined); + phoneE164 = parsed.e164; + phoneCountry = parsed.country ?? phoneCountry; + } + const fullName = data.firstName && data.lastName ? `${data.firstName} ${data.lastName}` @@ -96,17 +108,21 @@ export async function POST(req: NextRequest) { }); if (existingClient && existingClient.portId === portId) { clientId = existingClient.id; + const updates: Partial = {}; if (data.preferredContactMethod) { - await tx - .update(clients) - .set({ preferredContactMethod: data.preferredContactMethod }) - .where(eq(clients.id, clientId)); + updates.preferredContactMethod = data.preferredContactMethod; + } + if (data.nationalityIso && !existingClient.nationalityIso) { + updates.nationalityIso = data.nationalityIso; + } + if (Object.keys(updates).length > 0) { + await tx.update(clients).set(updates).where(eq(clients.id, clientId)); } } else { - clientId = await createClientInTx(tx, portId, fullName, data); + clientId = await createClientInTx(tx, portId, fullName, data, phoneE164, phoneCountry); } } else { - clientId = await createClientInTx(tx, portId, fullName, data); + clientId = await createClientInTx(tx, portId, fullName, data, phoneE164, phoneCountry); } // 2. Optional: upsert company + add membership @@ -129,6 +145,8 @@ export async function POST(req: NextRequest) { legalName: data.company.legalName ?? null, taxId: data.company.taxId ?? null, incorporationCountry: data.company.incorporationCountry ?? null, + incorporationCountryIso: data.company.incorporationCountryIso ?? null, + incorporationSubdivisionIso: data.company.incorporationSubdivisionIso ?? null, status: 'active', }) .returning(); @@ -199,8 +217,10 @@ export async function POST(req: NextRequest) { streetAddress: data.address.street ?? null, city: data.address.city ?? null, stateProvince: data.address.stateProvince ?? null, + subdivisionIso: data.address.subdivisionIso ?? null, postalCode: data.address.postalCode ?? null, country: data.address.country ?? null, + countryIso: data.address.countryIso ?? null, isPrimary: true, }); } @@ -279,7 +299,9 @@ async function createClientInTx( tx: Tx, portId: string, fullName: string, - data: Pick, + data: Pick, + phoneE164: string | null, + phoneCountry: CountryCode | null, ): Promise { const [newClient] = await tx .insert(clients) @@ -287,6 +309,7 @@ async function createClientInTx( portId, fullName, preferredContactMethod: data.preferredContactMethod, + nationalityIso: data.nationalityIso ?? null, source: 'website', }) .returning(); @@ -303,6 +326,8 @@ async function createClientInTx( clientId, channel: 'phone', value: data.phone, + valueE164: phoneE164, + valueCountry: phoneCountry, isPrimary: false, }); diff --git a/src/app/api/v1/analytics/route.ts b/src/app/api/v1/analytics/route.ts index 15ff292..332d66e 100644 --- a/src/app/api/v1/analytics/route.ts +++ b/src/app/api/v1/analytics/route.ts @@ -1,6 +1,6 @@ import { NextRequest, NextResponse } from 'next/server'; -import { withAuth } from '@/lib/api/helpers'; +import { withAuth, withPermission } from '@/lib/api/helpers'; import { ALL_RANGES, getLeadSourceAttribution, @@ -18,18 +18,20 @@ const METRICS: Record Promise< lead_source_attribution: getLeadSourceAttribution, }; -export const GET = withAuth(async (req: NextRequest, ctx) => { - const url = new URL(req.url); - const metric = url.searchParams.get('metric') as MetricBase | null; - const range = (url.searchParams.get('range') ?? '30d') as DateRange; +export const GET = withAuth( + withPermission('reports', 'view_analytics', async (req: NextRequest, ctx) => { + const url = new URL(req.url); + const metric = url.searchParams.get('metric') as MetricBase | null; + const range = (url.searchParams.get('range') ?? '30d') as DateRange; - if (!metric || !(metric in METRICS)) { - return NextResponse.json({ error: 'Invalid or missing metric' }, { status: 400 }); - } - if (!ALL_RANGES.includes(range)) { - return NextResponse.json({ error: 'Invalid range' }, { status: 400 }); - } + if (!metric || !(metric in METRICS)) { + return NextResponse.json({ error: 'Invalid or missing metric' }, { status: 400 }); + } + if (!ALL_RANGES.includes(range)) { + return NextResponse.json({ error: 'Invalid range' }, { status: 400 }); + } - const data = await METRICS[metric](ctx.portId, range); - return NextResponse.json({ metric, range, data }); -}); + const data = await METRICS[metric](ctx.portId, range); + return NextResponse.json({ metric, range, data }); + }), +); diff --git a/src/app/api/v1/berths/options/route.ts b/src/app/api/v1/berths/options/route.ts index 6107166..da41031 100644 --- a/src/app/api/v1/berths/options/route.ts +++ b/src/app/api/v1/berths/options/route.ts @@ -1,15 +1,17 @@ import { NextResponse } from 'next/server'; -import { withAuth } from '@/lib/api/helpers'; +import { withAuth, withPermission } from '@/lib/api/helpers'; import { getBerthOptions } from '@/lib/services/berths.service'; import { errorResponse } from '@/lib/errors'; // GET /api/v1/berths/options — lightweight list for selects/comboboxes -export const GET = withAuth(async (req, ctx) => { - try { - const options = await getBerthOptions(ctx.portId); - return NextResponse.json({ data: options }); - } catch (error) { - return errorResponse(error); - } -}); +export const GET = withAuth( + withPermission('berths', 'view', async (req, ctx) => { + try { + const options = await getBerthOptions(ctx.portId); + return NextResponse.json({ data: options }); + } catch (error) { + return errorResponse(error); + } + }), +); diff --git a/src/app/api/v1/clients/options/route.ts b/src/app/api/v1/clients/options/route.ts index c7d3a77..73856aa 100644 --- a/src/app/api/v1/clients/options/route.ts +++ b/src/app/api/v1/clients/options/route.ts @@ -1,15 +1,17 @@ import { NextResponse } from 'next/server'; -import { withAuth } from '@/lib/api/helpers'; +import { withAuth, withPermission } from '@/lib/api/helpers'; import { errorResponse } from '@/lib/errors'; import { listClientOptions } from '@/lib/services/clients.service'; -export const GET = withAuth(async (req, ctx) => { - try { - const search = req.nextUrl.searchParams.get('search') ?? undefined; - const data = await listClientOptions(ctx.portId, search); - return NextResponse.json({ data }); - } catch (error) { - return errorResponse(error); - } -}); +export const GET = withAuth( + withPermission('clients', 'view', async (req, ctx) => { + try { + const search = req.nextUrl.searchParams.get('search') ?? undefined; + const data = await listClientOptions(ctx.portId, search); + return NextResponse.json({ data }); + } catch (error) { + return errorResponse(error); + } + }), +); diff --git a/src/app/api/v1/dashboard/activity/route.ts b/src/app/api/v1/dashboard/activity/route.ts index 01a5203..6c05be6 100644 --- a/src/app/api/v1/dashboard/activity/route.ts +++ b/src/app/api/v1/dashboard/activity/route.ts @@ -1,9 +1,11 @@ import { NextRequest, NextResponse } from 'next/server'; -import { withAuth } from '@/lib/api/helpers'; +import { withAuth, withPermission } from '@/lib/api/helpers'; import { getRecentActivity } from '@/lib/services/dashboard.service'; -export const GET = withAuth(async (req: NextRequest, ctx) => { - const result = await getRecentActivity(ctx.portId); - return NextResponse.json(result); -}); +export const GET = withAuth( + withPermission('reports', 'view_dashboard', async (req: NextRequest, ctx) => { + const result = await getRecentActivity(ctx.portId); + return NextResponse.json(result); + }), +); diff --git a/src/app/api/v1/dashboard/forecast/route.ts b/src/app/api/v1/dashboard/forecast/route.ts index eddeb84..1b84ad7 100644 --- a/src/app/api/v1/dashboard/forecast/route.ts +++ b/src/app/api/v1/dashboard/forecast/route.ts @@ -1,9 +1,11 @@ import { NextRequest, NextResponse } from 'next/server'; -import { withAuth } from '@/lib/api/helpers'; +import { withAuth, withPermission } from '@/lib/api/helpers'; import { getRevenueForecast } from '@/lib/services/dashboard.service'; -export const GET = withAuth(async (req: NextRequest, ctx) => { - const result = await getRevenueForecast(ctx.portId); - return NextResponse.json(result); -}); +export const GET = withAuth( + withPermission('reports', 'view_dashboard', async (req: NextRequest, ctx) => { + const result = await getRevenueForecast(ctx.portId); + return NextResponse.json(result); + }), +); diff --git a/src/app/api/v1/dashboard/kpis/route.ts b/src/app/api/v1/dashboard/kpis/route.ts index 9dc5280..0e76498 100644 --- a/src/app/api/v1/dashboard/kpis/route.ts +++ b/src/app/api/v1/dashboard/kpis/route.ts @@ -1,9 +1,11 @@ import { NextRequest, NextResponse } from 'next/server'; -import { withAuth } from '@/lib/api/helpers'; +import { withAuth, withPermission } from '@/lib/api/helpers'; import { getKpis } from '@/lib/services/dashboard.service'; -export const GET = withAuth(async (req: NextRequest, ctx) => { - const result = await getKpis(ctx.portId); - return NextResponse.json(result); -}); +export const GET = withAuth( + withPermission('reports', 'view_dashboard', async (req: NextRequest, ctx) => { + const result = await getKpis(ctx.portId); + return NextResponse.json(result); + }), +); diff --git a/src/app/api/v1/dashboard/pipeline/route.ts b/src/app/api/v1/dashboard/pipeline/route.ts index 9c6e839..1c5704f 100644 --- a/src/app/api/v1/dashboard/pipeline/route.ts +++ b/src/app/api/v1/dashboard/pipeline/route.ts @@ -1,9 +1,11 @@ import { NextRequest, NextResponse } from 'next/server'; -import { withAuth } from '@/lib/api/helpers'; +import { withAuth, withPermission } from '@/lib/api/helpers'; import { getPipelineCounts } from '@/lib/services/dashboard.service'; -export const GET = withAuth(async (req: NextRequest, ctx) => { - const result = await getPipelineCounts(ctx.portId); - return NextResponse.json(result); -}); +export const GET = withAuth( + withPermission('reports', 'view_dashboard', async (req: NextRequest, ctx) => { + const result = await getPipelineCounts(ctx.portId); + return NextResponse.json(result); + }), +); diff --git a/src/lib/db/schema/insights.ts b/src/lib/db/schema/insights.ts index 5fb6ee7..f258a64 100644 --- a/src/lib/db/schema/insights.ts +++ b/src/lib/db/schema/insights.ts @@ -84,18 +84,27 @@ export type NewAnalyticsSnapshot = typeof analyticsSnapshots.$inferInsert; /** Severity literal type for callers that want a typed enum. */ export type AlertSeverity = 'info' | 'warning' | 'critical'; -/** Rule IDs in the v1 catalog — keep in sync with `alert-rules.ts`. */ +/** + * Rule IDs in the v1 catalog — keep in sync with `alert-rules.ts`. + * + * Two rules from the original spec (`document.expiring_soon`, + * `audit.suspicious_login`) are deferred until their data sources land: + * - `document.expiring_soon` needs a `documents.expires_at` column populated + * from Documenso responses (currently expiry isn't tracked). + * - `audit.suspicious_login` needs better-auth instrumentation that writes + * `login.failed` audit rows (the auth layer currently doesn't). + * Re-add the literal here + the evaluator in `alert-rules.ts` once their + * dependencies ship. + */ export const ALERT_RULES = [ 'reservation.no_agreement', 'interest.stale', - 'document.expiring_soon', 'document.signer_overdue', 'berth.under_offer_stalled', 'expense.duplicate', 'expense.unscanned', 'interest.high_value_silent', 'eoi.unsigned_long', - 'audit.suspicious_login', ] as const; export type AlertRuleId = (typeof ALERT_RULES)[number]; diff --git a/src/lib/services/alert-rules.ts b/src/lib/services/alert-rules.ts index 9dd02a1..dea3ef0 100644 --- a/src/lib/services/alert-rules.ts +++ b/src/lib/services/alert-rules.ts @@ -11,7 +11,7 @@ * 4. Add a unit test in tests/unit/services/alert-rules-evaluators.test.ts. */ -import { and, eq, isNull, isNotNull, lt, gt, gte, sql, inArray, or, desc } from 'drizzle-orm'; +import { and, eq, isNull, isNotNull, lt, gt, sql, inArray, or, desc } from 'drizzle-orm'; import { db } from '@/lib/db'; import { interests } from '@/lib/db/schema/interests'; @@ -19,7 +19,6 @@ import { berthReservations } from '@/lib/db/schema/reservations'; import { berths } from '@/lib/db/schema/berths'; import { documents, documentSigners } from '@/lib/db/schema/documents'; import { expenses } from '@/lib/db/schema/financial'; -import { auditLogs } from '@/lib/db/schema/system'; import { alerts as alertsTable } from '@/lib/db/schema/insights'; import { ALERT_RULES, type AlertRuleId } from '@/lib/db/schema/insights'; @@ -108,16 +107,6 @@ async function interestStale(portId: string): Promise { })); } -// ─── document.expiring_soon ─────────────────────────────────────────────────── -// In-flight signing documents whose expiry is within 7 days. - -async function documentExpiringSoon(_portId: string): Promise { - // documents schema doesn't expose expires_at on the parent row in this - // build. Until the column lands, fall back to no-op so the rule slot - // is registered but doesn't fire. - return []; -} - // ─── document.signer_overdue ────────────────────────────────────────────────── // Pending signer for >14d, last reminder >7d ago (or never). @@ -319,49 +308,15 @@ async function eoiUnsignedLong(portId: string): Promise { })); } -// ─── audit.suspicious_login ─────────────────────────────────────────────────── -// >3 failed logins from same IP in the past hour. Depends on the auth layer -// recording rows with action='login.failed' (TODO: instrument better-auth -// hooks to record these — until that lands, this evaluator returns [] and -// the rule slot stays inert). - -async function auditSuspiciousLogin(_portId: string): Promise { - const cutoff = new Date(Date.now() - 60 * 60 * 1000); - const rows = await db - .select({ - ipAddress: auditLogs.ipAddress, - attempts: sql`count(*)::int`, - }) - .from(auditLogs) - .where(and(eq(auditLogs.action, 'login.failed'), gte(auditLogs.createdAt, cutoff))) - .groupBy(auditLogs.ipAddress) - .having(sql`count(*) > 3`); - - return rows - .filter((r) => r.ipAddress) - .map((r) => ({ - ruleId: 'audit.suspicious_login' as const, - severity: 'critical' as const, - title: `Repeated failed logins`, - body: `${r.attempts} failed attempts from ${r.ipAddress} in the last hour.`, - link: `/[port]/admin/audit?ip=${encodeURIComponent(r.ipAddress!)}`, - entityType: 'audit', - entityId: r.ipAddress!, - metadata: { attempts: r.attempts }, - })); -} - export const RULE_REGISTRY: Record = { 'reservation.no_agreement': reservationNoAgreement, 'interest.stale': interestStale, - 'document.expiring_soon': documentExpiringSoon, 'document.signer_overdue': documentSignerOverdue, 'berth.under_offer_stalled': berthUnderOfferStalled, 'expense.duplicate': expenseDuplicate, 'expense.unscanned': expenseUnscanned, 'interest.high_value_silent': interestHighValueSilent, 'eoi.unsigned_long': eoiUnsignedLong, - 'audit.suspicious_login': auditSuspiciousLogin, }; export function listRuleIds(): readonly AlertRuleId[] { diff --git a/src/lib/services/clients.service.ts b/src/lib/services/clients.service.ts index 895fabe..c91f389 100644 --- a/src/lib/services/clients.service.ts +++ b/src/lib/services/clients.service.ts @@ -578,7 +578,9 @@ export async function findDuplicates(portId: string, fullName: string) { // ─── Options (for comboboxes) ───────────────────────────────────────────────── export async function listClientOptions(portId: string, search?: string) { - const conditions = [eq(clients.portId, portId)]; + // Pickers only surface active rows. Archived clients are still resolvable + // by id (e.g. history views) but should not appear in dropdowns. + const conditions = [eq(clients.portId, portId), isNull(clients.archivedAt)]; if (search) { conditions.push(ilike(clients.fullName, `%${search}%`)); } diff --git a/src/lib/services/yachts.service.ts b/src/lib/services/yachts.service.ts index 1186ff8..df84f77 100644 --- a/src/lib/services/yachts.service.ts +++ b/src/lib/services/yachts.service.ts @@ -1,4 +1,4 @@ -import { and, eq, ilike, inArray, or, sql } from 'drizzle-orm'; +import { and, eq, ilike, inArray, isNull, or, sql } from 'drizzle-orm'; import { db } from '@/lib/db'; import { yachts, yachtOwnershipHistory, yachtTags, clients } from '@/lib/db/schema'; import type { Yacht } from '@/lib/db/schema/yachts'; @@ -355,11 +355,14 @@ export async function listYachtsForOwner( ownerType: 'client' | 'company', ownerId: string, ) { + // Owner-detail tabs only surface active yachts. Archived ones live in the + // ownership history view and are reachable by id, not via this lister. return await db.query.yachts.findMany({ where: and( eq(yachts.portId, portId), eq(yachts.currentOwnerType, ownerType), eq(yachts.currentOwnerId, ownerId), + isNull(yachts.archivedAt), ), orderBy: (t, { desc }) => [desc(t.updatedAt)], }); diff --git a/src/lib/validators/interests.ts b/src/lib/validators/interests.ts index a997c99..d13f772 100644 --- a/src/lib/validators/interests.ts +++ b/src/lib/validators/interests.ts @@ -2,6 +2,11 @@ import { z } from 'zod'; import { baseListQuerySchema } from '@/lib/api/route-helpers'; import { PIPELINE_STAGES, LEAD_CATEGORIES } from '@/lib/constants'; +import { + optionalCountryIsoSchema, + optionalPhoneE164Schema, + optionalSubdivisionIsoSchema, +} from '@/lib/validators/i18n'; // ─── Create ────────────────────────────────────────────────────────────────── @@ -69,9 +74,15 @@ export const generateRecommendationsSchema = z.object({ const addressSchema = z.object({ street: z.string().max(500).optional(), city: z.string().max(200).optional(), + /** Legacy free-text. New writes use `subdivisionIso`. */ stateProvince: z.string().max(200).optional(), + /** ISO 3166-2 subdivision code (e.g. 'PL-MZ'). */ + subdivisionIso: optionalSubdivisionIsoSchema.optional(), postalCode: z.string().max(50).optional(), + /** Legacy free-text. New writes use `countryIso`. */ country: z.string().max(100).optional(), + /** ISO-3166-1 alpha-2 country code. */ + countryIso: optionalCountryIsoSchema.optional(), }); // Nested yacht block. Public submissions must now include yacht data because the @@ -94,7 +105,12 @@ const publicCompanySchema = z.object({ name: z.string().min(1).max(200), legalName: z.string().max(200).optional(), taxId: z.string().max(100).optional(), + /** Legacy free-text. New website builds should send `incorporationCountryIso`. */ incorporationCountry: z.string().max(100).optional(), + /** ISO-3166-1 alpha-2 country of incorporation. */ + incorporationCountryIso: optionalCountryIsoSchema.optional(), + /** ISO 3166-2 state/province of incorporation. */ + incorporationSubdivisionIso: optionalSubdivisionIsoSchema.optional(), role: z .enum([ 'director', @@ -119,6 +135,12 @@ export const publicInterestSchema = z fullName: z.string().min(1).max(200).optional(), email: z.string().email(), phone: z.string().min(1), + /** Pre-normalized E.164 form, optional for backwards compat. */ + phoneE164: optionalPhoneE164Schema.optional(), + /** ISO-3166-1 alpha-2 country the phone was parsed against. */ + phoneCountry: optionalCountryIsoSchema.optional(), + /** ISO-3166-1 alpha-2 nationality. */ + nationalityIso: optionalCountryIsoSchema.optional(), preferredContactMethod: z.enum(['email', 'phone', 'sms']).optional(), mooringNumber: z.string().max(50).optional(), // NEW: required structured yacht block. Public submissions after the diff --git a/tests/integration/crud-audit.test.ts b/tests/integration/crud-audit.test.ts index 27ea370..8a427c3 100644 --- a/tests/integration/crud-audit.test.ts +++ b/tests/integration/crud-audit.test.ts @@ -11,7 +11,16 @@ */ import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest'; -import { makeAuditMeta, makeCreateClientInput, makeCreateInterestInput } from '../helpers/factories'; +import { + makeAuditMeta, + makeCreateClientInput, + makeCreateInterestInput, +} from '../helpers/factories'; + +vi.mock('@/lib/socket/server', () => ({ emitToRoom: vi.fn() })); +vi.mock('@/lib/queue', () => ({ + getQueue: () => ({ add: vi.fn().mockResolvedValue(undefined) }), +})); const TEST_DB_URL = process.env.TEST_DATABASE_URL || 'postgresql://test:test@localhost:5433/portnimara_test'; @@ -93,11 +102,6 @@ async function getAuditEntries( describe('CRUD Audit — Clients', () => { let portId: string; - vi.mock('@/lib/socket/server', () => ({ emitToRoom: vi.fn() })); - vi.mock('@/lib/queue', () => ({ - getQueue: () => ({ add: vi.fn().mockResolvedValue(undefined) }), - })); - beforeAll(async () => { if (!dbAvailable) return; portId = await seedPort(); @@ -112,7 +116,11 @@ describe('CRUD Audit — Clients', () => { const { createClient } = await import('@/lib/services/clients.service'); const meta = makeAuditMeta({ portId }); - const client = await createClient(portId, makeCreateClientInput({ fullName: 'Audit Create Client' }), meta); + const client = await createClient( + portId, + makeCreateClientInput({ fullName: 'Audit Create Client' }), + meta, + ); await new Promise((r) => setTimeout(r, 100)); @@ -130,7 +138,11 @@ describe('CRUD Audit — Clients', () => { const { createClient, updateClient } = await import('@/lib/services/clients.service'); const meta = makeAuditMeta({ portId }); - const client = await createClient(portId, makeCreateClientInput({ fullName: 'Before Update' }), meta); + const client = await createClient( + portId, + makeCreateClientInput({ fullName: 'Before Update' }), + meta, + ); await updateClient(client.id, portId, { fullName: 'After Update' }, meta); @@ -149,7 +161,11 @@ describe('CRUD Audit — Clients', () => { const { createClient, archiveClient } = await import('@/lib/services/clients.service'); const meta = makeAuditMeta({ portId }); - const client = await createClient(portId, makeCreateClientInput({ fullName: 'Audit Archive Client' }), meta); + const client = await createClient( + portId, + makeCreateClientInput({ fullName: 'Audit Archive Client' }), + meta, + ); await archiveClient(client.id, portId, meta); @@ -161,10 +177,15 @@ describe('CRUD Audit — Clients', () => { }); itDb('restore generates an audit log entry with action=restore', async () => { - const { createClient, archiveClient, restoreClient } = await import('@/lib/services/clients.service'); + const { createClient, archiveClient, restoreClient } = + await import('@/lib/services/clients.service'); const meta = makeAuditMeta({ portId }); - const client = await createClient(portId, makeCreateClientInput({ fullName: 'Audit Restore Client' }), meta); + const client = await createClient( + portId, + makeCreateClientInput({ fullName: 'Audit Restore Client' }), + meta, + ); await archiveClient(client.id, portId, meta); await restoreClient(client.id, portId, meta); @@ -183,17 +204,16 @@ describe('CRUD Audit — Interests', () => { let portId: string; let clientId: string; - vi.mock('@/lib/socket/server', () => ({ emitToRoom: vi.fn() })); - vi.mock('@/lib/queue', () => ({ - getQueue: () => ({ add: vi.fn().mockResolvedValue(undefined) }), - })); - beforeAll(async () => { if (!dbAvailable) return; portId = await seedPort(); const { createClient } = await import('@/lib/services/clients.service'); - const client = await createClient(portId, makeCreateClientInput({ fullName: 'Interest Audit Client' }), makeAuditMeta({ portId })); + const client = await createClient( + portId, + makeCreateClientInput({ fullName: 'Interest Audit Client' }), + makeAuditMeta({ portId }), + ); clientId = client.id; }); @@ -223,7 +243,11 @@ describe('CRUD Audit — Interests', () => { const { createInterest, updateInterest } = await import('@/lib/services/interests.service'); const meta = makeAuditMeta({ portId }); - const interest = await createInterest(portId, { ...makeCreateInterestInput({ clientId }), notes: 'initial' }, meta); + const interest = await createInterest( + portId, + { ...makeCreateInterestInput({ clientId }), notes: 'initial' }, + meta, + ); await updateInterest(interest.id, portId, { notes: 'updated notes' }, meta); @@ -249,7 +273,8 @@ describe('CRUD Audit — Interests', () => { }); itDb('restore generates audit log with action=restore', async () => { - const { createInterest, archiveInterest, restoreInterest } = await import('@/lib/services/interests.service'); + const { createInterest, archiveInterest, restoreInterest } = + await import('@/lib/services/interests.service'); const meta = makeAuditMeta({ portId }); const interest = await createInterest(portId, makeCreateInterestInput({ clientId }), meta); @@ -270,11 +295,6 @@ describe('CRUD Audit — Berths', () => { let portId: string; let berthId: string; - vi.mock('@/lib/socket/server', () => ({ emitToRoom: vi.fn() })); - vi.mock('@/lib/queue', () => ({ - getQueue: () => ({ add: vi.fn().mockResolvedValue(undefined) }), - })); - beforeAll(async () => { if (!dbAvailable) return; portId = await seedPort(); @@ -313,8 +333,8 @@ describe('CRUD Audit — Berths', () => { const wrongPortId = crypto.randomUUID(); const meta = makeAuditMeta({ portId: wrongPortId }); - await expect( - updateBerth(berthId, wrongPortId, { area: 'Should fail' }, meta), - ).rejects.toThrow(NotFoundError); + await expect(updateBerth(berthId, wrongPortId, { area: 'Should fail' }, meta)).rejects.toThrow( + NotFoundError, + ); }); }); diff --git a/tests/integration/custom-fields.test.ts b/tests/integration/custom-fields.test.ts index 85eceab..fe41685 100644 --- a/tests/integration/custom-fields.test.ts +++ b/tests/integration/custom-fields.test.ts @@ -15,6 +15,10 @@ import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest'; import { makeAuditMeta } from '../helpers/factories'; +vi.mock('@/lib/audit', () => ({ + createAuditLog: vi.fn().mockResolvedValue(undefined), +})); + const TEST_DB_URL = process.env.TEST_DATABASE_URL || 'postgresql://test:test@localhost:5433/portnimara_test'; @@ -66,10 +70,6 @@ describe('Custom Fields — Definitions', () => { let portId: string; const userId = crypto.randomUUID(); - vi.mock('@/lib/audit', () => ({ - createAuditLog: vi.fn().mockResolvedValue(undefined), - })); - beforeAll(async () => { if (!dbAvailable) return; portId = await seedPort(); @@ -141,9 +141,8 @@ describe('Custom Fields — Definitions', () => { }); itDb('updateDefinition with fieldType property throws ValidationError', async () => { - const { createDefinition, updateDefinition } = await import( - '@/lib/services/custom-fields.service' - ); + const { createDefinition, updateDefinition } = + await import('@/lib/services/custom-fields.service'); const { ValidationError } = await import('@/lib/errors'); const meta = makeAuditMeta({ portId, userId }); @@ -161,16 +160,21 @@ describe('Custom Fields — Definitions', () => { meta, ); - // Cast to any to bypass TS — the service should guard against this at runtime + // Cast bypasses TS — the service should guard against this at runtime. await expect( - updateDefinition(portId, def.id, userId, { fieldType: 'number' } as any, meta), + updateDefinition( + portId, + def.id, + userId, + { fieldType: 'number' } as unknown as Parameters[3], + meta, + ), ).rejects.toThrow(ValidationError); }); itDb('updateDefinition can change fieldLabel without error', async () => { - const { createDefinition, updateDefinition } = await import( - '@/lib/services/custom-fields.service' - ); + const { createDefinition, updateDefinition } = + await import('@/lib/services/custom-fields.service'); const meta = makeAuditMeta({ portId, userId }); const def = await createDefinition( @@ -187,7 +191,13 @@ describe('Custom Fields — Definitions', () => { meta, ); - const updated = await updateDefinition(portId, def.id, userId, { fieldLabel: 'Special Notes' }, meta); + const updated = await updateDefinition( + portId, + def.id, + userId, + { fieldLabel: 'Special Notes' }, + meta, + ); expect(updated.fieldLabel).toBe('Special Notes'); expect(updated.fieldType).toBe('text'); }); @@ -200,10 +210,6 @@ describe('Custom Fields — Values', () => { const userId = crypto.randomUUID(); const entityId = crypto.randomUUID(); - vi.mock('@/lib/audit', () => ({ - createAuditLog: vi.fn().mockResolvedValue(undefined), - })); - beforeAll(async () => { if (!dbAvailable) return; portId = await seedPort(); @@ -215,9 +221,8 @@ describe('Custom Fields — Values', () => { }); itDb('setValues stores a text value and getValues returns it with definition', async () => { - const { createDefinition, setValues, getValues } = await import( - '@/lib/services/custom-fields.service' - ); + const { createDefinition, setValues, getValues } = + await import('@/lib/services/custom-fields.service'); const meta = makeAuditMeta({ portId, userId }); const def = await createDefinition( @@ -270,9 +275,8 @@ describe('Custom Fields — Values', () => { }); itDb('deleteDefinition cascades to remove associated values', async () => { - const { createDefinition, setValues, deleteDefinition, getValues } = await import( - '@/lib/services/custom-fields.service' - ); + const { createDefinition, setValues, deleteDefinition, getValues } = + await import('@/lib/services/custom-fields.service'); const meta = makeAuditMeta({ portId, userId }); const cascadeEntityId = crypto.randomUUID(); diff --git a/tests/integration/notification-lifecycle.test.ts b/tests/integration/notification-lifecycle.test.ts index a525e9f..627835b 100644 --- a/tests/integration/notification-lifecycle.test.ts +++ b/tests/integration/notification-lifecycle.test.ts @@ -14,6 +14,12 @@ */ import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest'; +// Socket and queue mocked — these are tested in isolation here. +vi.mock('@/lib/socket/server', () => ({ emitToRoom: vi.fn() })); +vi.mock('@/lib/queue', () => ({ + getQueue: () => ({ add: vi.fn().mockResolvedValue(undefined) }), +})); + const TEST_DB_URL = process.env.TEST_DATABASE_URL || 'postgresql://test:test@localhost:5433/portnimara_test'; @@ -83,12 +89,6 @@ describe('Notification Lifecycle', () => { let portId: string; let userId: string; - // Mock socket and queue — these are tested in isolation here - vi.mock('@/lib/socket/server', () => ({ emitToRoom: vi.fn() })); - vi.mock('@/lib/queue', () => ({ - getQueue: () => ({ add: vi.fn().mockResolvedValue(undefined) }), - })); - beforeAll(async () => { if (!dbAvailable) return; ({ portId, userId } = await seedPortAndUser()); @@ -214,9 +214,8 @@ describe('Notification Lifecycle', () => { }); itDb('markAllRead sets all unread notifications for the user to read', async () => { - const { createNotification, markAllRead, getUnreadCount } = await import( - '@/lib/services/notifications.service' - ); + const { createNotification, markAllRead, getUnreadCount } = + await import('@/lib/services/notifications.service'); await createNotification({ portId, userId, type: 'system_alert', title: 'Unread 1' }); await createNotification({ portId, userId, type: 'system_alert', title: 'Unread 2' }); @@ -231,9 +230,8 @@ describe('Notification Lifecycle', () => { }); itDb('getUnreadCount returns accurate count', async () => { - const { createNotification, getUnreadCount, markAllRead } = await import( - '@/lib/services/notifications.service' - ); + const { createNotification, getUnreadCount, markAllRead } = + await import('@/lib/services/notifications.service'); await markAllRead(userId, portId); diff --git a/tests/integration/pipeline-transitions.test.ts b/tests/integration/pipeline-transitions.test.ts index 7d047e7..5d7b5ce 100644 --- a/tests/integration/pipeline-transitions.test.ts +++ b/tests/integration/pipeline-transitions.test.ts @@ -13,7 +13,17 @@ import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest'; import { PIPELINE_STAGES } from '@/lib/constants'; -import { makeAuditMeta, makeCreateClientInput, makeCreateInterestInput } from '../helpers/factories'; +import { + makeAuditMeta, + makeCreateClientInput, + makeCreateInterestInput, +} from '../helpers/factories'; + +// External side-effects mocked so the test stays self-contained. +vi.mock('@/lib/socket/server', () => ({ emitToRoom: vi.fn() })); +vi.mock('@/lib/queue', () => ({ + getQueue: () => ({ add: vi.fn().mockResolvedValue(undefined) }), +})); const TEST_DB_URL = process.env.TEST_DATABASE_URL || 'postgresql://test:test@localhost:5433/portnimara_test'; @@ -62,7 +72,10 @@ async function cleanupPort(portId: string): Promise { await sql.end(); } -async function getLatestAuditLog(portId: string, entityId: string): Promise | null> { +async function getLatestAuditLog( + portId: string, + entityId: string, +): Promise | null> { const postgres = (await import('postgres')).default; const sql = postgres(TEST_DB_URL, { max: 1 }); const rows = await sql[]>` @@ -81,12 +94,6 @@ describe('Pipeline Transitions', () => { let portId: string; let interestId: string; - // Mock external side-effects so tests are self-contained - vi.mock('@/lib/socket/server', () => ({ emitToRoom: vi.fn() })); - vi.mock('@/lib/queue', () => ({ - getQueue: () => ({ add: vi.fn().mockResolvedValue(undefined) }), - })); - beforeAll(async () => { if (!dbAvailable) return; @@ -95,10 +102,18 @@ describe('Pipeline Transitions', () => { const { createClient } = await import('@/lib/services/clients.service'); const meta = makeAuditMeta({ portId }); - const client = await createClient(portId, makeCreateClientInput({ fullName: 'Pipeline Test Client' }), meta); + const client = await createClient( + portId, + makeCreateClientInput({ fullName: 'Pipeline Test Client' }), + meta, + ); const { createInterest } = await import('@/lib/services/interests.service'); - const interest = await createInterest(portId, makeCreateInterestInput({ clientId: client.id }), meta); + const interest = await createInterest( + portId, + makeCreateInterestInput({ clientId: client.id }), + meta, + ); interestId = interest.id; }); @@ -108,9 +123,8 @@ describe('Pipeline Transitions', () => { }); itDb('advances through all 8 pipeline stages sequentially', async () => { - const { changeInterestStage, getInterestById } = await import( - '@/lib/services/interests.service' - ); + const { changeInterestStage, getInterestById } = + await import('@/lib/services/interests.service'); const meta = makeAuditMeta({ portId }); for (const stage of PIPELINE_STAGES) { @@ -140,9 +154,8 @@ describe('Pipeline Transitions', () => { }); itDb('backward transition: completed → open is permitted', async () => { - const { changeInterestStage, getInterestById } = await import( - '@/lib/services/interests.service' - ); + const { changeInterestStage, getInterestById } = + await import('@/lib/services/interests.service'); const meta = makeAuditMeta({ portId }); await changeInterestStage(interestId, portId, { pipelineStage: 'completed' }, meta); @@ -153,9 +166,8 @@ describe('Pipeline Transitions', () => { }); itDb('BR-133: advancing to signed_eoi_nda auto-populates dateEoiSigned', async () => { - const { changeInterestStage, getInterestById } = await import( - '@/lib/services/interests.service' - ); + const { changeInterestStage, getInterestById } = + await import('@/lib/services/interests.service'); const meta = makeAuditMeta({ portId }); await changeInterestStage(interestId, portId, { pipelineStage: 'signed_eoi_nda' }, meta); @@ -165,9 +177,8 @@ describe('Pipeline Transitions', () => { }); itDb('BR-133: advancing to contract auto-populates dateContractSigned', async () => { - const { changeInterestStage, getInterestById } = await import( - '@/lib/services/interests.service' - ); + const { changeInterestStage, getInterestById } = + await import('@/lib/services/interests.service'); const meta = makeAuditMeta({ portId }); await changeInterestStage(interestId, portId, { pipelineStage: 'contract' }, meta); @@ -177,9 +188,8 @@ describe('Pipeline Transitions', () => { }); itDb('BR-133: advancing to deposit_10pct auto-populates dateDepositReceived', async () => { - const { changeInterestStage, getInterestById } = await import( - '@/lib/services/interests.service' - ); + const { changeInterestStage, getInterestById } = + await import('@/lib/services/interests.service'); const meta = makeAuditMeta({ portId }); await changeInterestStage(interestId, portId, { pipelineStage: 'deposit_10pct' }, meta); diff --git a/tests/integration/webhook-delivery.test.ts b/tests/integration/webhook-delivery.test.ts index f2765f2..b9d8fe1 100644 --- a/tests/integration/webhook-delivery.test.ts +++ b/tests/integration/webhook-delivery.test.ts @@ -14,6 +14,27 @@ import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest'; import { makeAuditMeta } from '../helpers/factories'; +// vi.mock is hoisted to the top of the module — keep mocks there so vitest +// doesn't warn about non-top-level calls. Use `vi.hoisted` for any mock that +// references a value (mockQueueAdd) so it's evaluated before the mock factory +// runs. +const { mockQueueAdd } = vi.hoisted(() => ({ + mockQueueAdd: vi.fn().mockResolvedValue({ id: 'mock-job' }), +})); + +vi.mock('@/lib/queue', () => ({ + getQueue: () => ({ add: mockQueueAdd }), +})); + +vi.mock('@/lib/utils/encryption', () => ({ + encrypt: (v: string) => `enc:${v}`, + decrypt: (v: string) => v.replace(/^enc:/, ''), +})); + +vi.mock('@/lib/audit', () => ({ + createAuditLog: vi.fn().mockResolvedValue(undefined), +})); + const TEST_DB_URL = process.env.TEST_DATABASE_URL || 'postgresql://test:test@localhost:5433/portnimara_test'; @@ -81,21 +102,6 @@ describe('Webhook Delivery', () => { let portId: string; let userId: string; - const mockQueueAdd = vi.fn().mockResolvedValue({ id: 'mock-job' }); - - vi.mock('@/lib/queue', () => ({ - getQueue: () => ({ add: mockQueueAdd }), - })); - - vi.mock('@/lib/utils/encryption', () => ({ - encrypt: (v: string) => `enc:${v}`, - decrypt: (v: string) => v.replace(/^enc:/, ''), - })); - - vi.mock('@/lib/audit', () => ({ - createAuditLog: vi.fn().mockResolvedValue(undefined), - })); - beforeAll(async () => { if (!dbAvailable) return; ({ portId, userId } = await seedPortAndUser()); @@ -113,7 +119,12 @@ describe('Webhook Delivery', () => { const webhook = await createWebhook( portId, userId, - { name: 'Delivery Test Webhook', url: 'https://example.com/hooks', events: ['client.created'], isActive: true }, + { + name: 'Delivery Test Webhook', + url: 'https://example.com/hooks', + events: ['client.created'], + isActive: true, + }, meta, ); @@ -131,7 +142,12 @@ describe('Webhook Delivery', () => { const webhook = await createWebhook( portId, userId, - { name: 'Dispatch Test Hook', url: 'https://example.com/dispatch', events: ['client.created'], isActive: true }, + { + name: 'Dispatch Test Hook', + url: 'https://example.com/dispatch', + events: ['client.created'], + isActive: true, + }, meta, ); @@ -172,7 +188,12 @@ describe('Webhook Delivery', () => { const webhook = await createWebhook( portId, userId, - { name: 'Unmapped Hook', url: 'https://example.com/unmapped', events: ['client.created'], isActive: true }, + { + name: 'Unmapped Hook', + url: 'https://example.com/unmapped', + events: ['client.created'], + isActive: true, + }, meta, ); @@ -201,7 +222,12 @@ describe('Webhook Delivery', () => { const webhook = await createWebhook( portId, userId, - { name: 'Inactive Hook', url: 'https://example.com/inactive', events: ['client.created'], isActive: false }, + { + name: 'Inactive Hook', + url: 'https://example.com/inactive', + events: ['client.created'], + isActive: false, + }, meta, ); @@ -230,7 +256,12 @@ describe('Webhook Delivery', () => { await createWebhook( portId, userId, - { name: 'Queue Test Hook', url: 'https://example.com/queue', events: ['client.updated'], isActive: true }, + { + name: 'Queue Test Hook', + url: 'https://example.com/queue', + events: ['client.updated'], + isActive: true, + }, meta, ); diff --git a/tests/unit/security-encryption.test.ts b/tests/unit/security-encryption.test.ts index f599428..2b09eb9 100644 --- a/tests/unit/security-encryption.test.ts +++ b/tests/unit/security-encryption.test.ts @@ -34,9 +34,12 @@ describe('AES-256-GCM — plaintext non-exposure', () => { it('encrypted output does not contain plaintext even for short values', async () => { const { encrypt } = await import('@/lib/utils/encryption'); - const plaintext = 'ab'; + // Pick a 2-char plaintext using *non-hex* characters so the assertion can't + // false-positive: random hex bytes routinely contain pairs like 'ab' or 'cd' + // by chance (~1 in 256 byte positions). Using 'XY' (neither is a hex digit) + // means a passing assertion actually proves the plaintext didn't leak. + const plaintext = 'XY'; const encrypted = encrypt(plaintext); - // The JSON output contains hex-encoded bytes — plaintext chars must not appear raw expect(encrypted).not.toContain(plaintext); });