diff --git a/src/app/api/v1/admin/ports/[id]/route.ts b/src/app/api/v1/admin/ports/[id]/route.ts index db221de..94d4e0f 100644 --- a/src/app/api/v1/admin/ports/[id]/route.ts +++ b/src/app/api/v1/admin/ports/[id]/route.ts @@ -4,11 +4,25 @@ import { withAuth, withPermission } from '@/lib/api/helpers'; import { parseBody } from '@/lib/api/route-helpers'; import { getPort, updatePort } from '@/lib/services/ports.service'; import { updatePortSchema } from '@/lib/validators/ports'; -import { errorResponse } from '@/lib/errors'; +import { errorResponse, ForbiddenError } from '@/lib/errors'; + +/** + * Non-super-admin callers (e.g. port directors holding admin.manage_settings) + * may only read/mutate THEIR OWN port row. The path id is therefore + * compared against ctx.portId and a foreign target is rejected before the + * service is touched. Super-admins retain unrestricted access. + */ +function assertPortInScope(targetPortId: string, ctx: { portId: string; isSuperAdmin: boolean }) { + if (ctx.isSuperAdmin) return; + if (targetPortId !== ctx.portId) { + throw new ForbiddenError('Cross-tenant port access denied'); + } +} export const GET = withAuth( - withPermission('admin', 'manage_settings', async (_req, _ctx, params) => { + withPermission('admin', 'manage_settings', async (_req, ctx, params) => { try { + assertPortInScope(params.id!, ctx); const data = await getPort(params.id!); return NextResponse.json({ data }); } catch (error) { @@ -20,6 +34,7 @@ export const GET = withAuth( export const PATCH = withAuth( withPermission('admin', 'manage_settings', async (req, ctx, params) => { try { + assertPortInScope(params.id!, ctx); const body = await parseBody(req, updatePortSchema); const data = await updatePort(params.id!, body, { userId: ctx.userId, diff --git a/src/app/api/v1/admin/ports/route.ts b/src/app/api/v1/admin/ports/route.ts index 881f5f9..45448da 100644 --- a/src/app/api/v1/admin/ports/route.ts +++ b/src/app/api/v1/admin/ports/route.ts @@ -4,11 +4,18 @@ import { withAuth, withPermission } from '@/lib/api/helpers'; import { parseBody } from '@/lib/api/route-helpers'; import { listPorts, createPort } from '@/lib/services/ports.service'; import { createPortSchema } from '@/lib/validators/ports'; -import { errorResponse } from '@/lib/errors'; +import { errorResponse, ForbiddenError } from '@/lib/errors'; +// Listing every tenant and creating new tenants are super-admin operations: +// a port director must not be able to enumerate other ports (target +// discovery for cross-tenant attacks) or spin up new tenants whose admin +// they implicitly become. export const GET = withAuth( - withPermission('admin', 'manage_settings', async () => { + withPermission('admin', 'manage_settings', async (_req, ctx) => { try { + if (!ctx.isSuperAdmin) { + throw new ForbiddenError('Listing all ports requires super-admin'); + } const data = await listPorts(); return NextResponse.json({ data }); } catch (error) { @@ -20,6 +27,9 @@ export const GET = withAuth( export const POST = withAuth( withPermission('admin', 'manage_settings', async (req, ctx) => { try { + if (!ctx.isSuperAdmin) { + throw new ForbiddenError('Creating ports requires super-admin'); + } const body = await parseBody(req, createPortSchema); const data = await createPort(body, { userId: ctx.userId, diff --git a/src/app/api/v1/ai/email-draft/[jobId]/route.ts b/src/app/api/v1/ai/email-draft/[jobId]/route.ts index 80c95a0..c958363 100644 --- a/src/app/api/v1/ai/email-draft/[jobId]/route.ts +++ b/src/app/api/v1/ai/email-draft/[jobId]/route.ts @@ -4,14 +4,17 @@ import { withAuth } from '@/lib/api/helpers'; import { getEmailDraftResult } from '@/lib/services/email-draft.service'; import { errorResponse } from '@/lib/errors'; -export const GET = withAuth(async (_req, _ctx, params) => { +export const GET = withAuth(async (_req, ctx, params) => { try { const { jobId } = params; if (!jobId) { return NextResponse.json({ error: 'jobId is required' }, { status: 400 }); } - const result = await getEmailDraftResult(jobId); + const result = await getEmailDraftResult(jobId, { + userId: ctx.userId, + portId: ctx.portId, + }); if (result === null) { return NextResponse.json({ status: 'processing' }); diff --git a/src/lib/queue/workers/ai.ts b/src/lib/queue/workers/ai.ts index dc6d63a..d0ebc91 100644 --- a/src/lib/queue/workers/ai.ts +++ b/src/lib/queue/workers/ai.ts @@ -36,12 +36,15 @@ async function generateEmailDraft(payload: GenerateEmailDraftPayload): Promise { + const checks: Array> = []; + if (fks.clientId) { + checks.push( + db.query.clients + .findFirst({ where: and(eq(clients.id, fks.clientId), eq(clients.portId, portId)) }) + .then((row) => { + if (!row) throw new ValidationError('clientId not found in this port'); + }), + ); + } + if (fks.interestId) { + checks.push( + db.query.interests + .findFirst({ + where: and(eq(interests.id, fks.interestId), eq(interests.portId, portId)), + }) + .then((row) => { + if (!row) throw new ValidationError('interestId not found in this port'); + }), + ); + } + if (fks.companyId) { + checks.push( + db.query.companies + .findFirst({ + where: and(eq(companies.id, fks.companyId), eq(companies.portId, portId)), + }) + .then((row) => { + if (!row) throw new ValidationError('companyId not found in this port'); + }), + ); + } + if (fks.yachtId) { + checks.push( + db.query.yachts + .findFirst({ where: and(eq(yachts.id, fks.yachtId), eq(yachts.portId, portId)) }) + .then((row) => { + if (!row) throw new ValidationError('yachtId not found in this port'); + }), + ); + } + if (fks.reservationId) { + checks.push( + db.query.berthReservations + .findFirst({ + where: and( + eq(berthReservations.id, fks.reservationId), + eq(berthReservations.portId, portId), + ), + }) + .then((row) => { + if (!row) throw new ValidationError('reservationId not found in this port'); + }), + ); + } + await Promise.all(checks); +} + // ─── Create ─────────────────────────────────────────────────────────────────── export async function createDocument(portId: string, data: CreateDocumentInput, meta: AuditMeta) { + await assertSubjectFksInPort(portId, { + clientId: data.clientId, + interestId: data.interestId, + }); + const [doc] = await db .insert(documents) .values({ @@ -364,14 +448,20 @@ export async function sendForSigning(documentId: string, portId: string, meta: A if (!doc.fileId) throw new ValidationError('Document has no associated file'); if (doc.status !== 'draft') throw new ConflictError('Document is not in draft status'); - // Fetch interest + client to build signers + // Fetch interest + client to build signers. Filter by portId in addition + // to the FK so that even if a stale or maliciously-set subject FK on the + // document points at a foreign-port row, this signing flow refuses to + // hydrate (and therefore refuses to ship to Documenso) data from outside + // the caller's tenant. const interest = doc.interestId - ? await db.query.interests.findFirst({ where: eq(interests.id, doc.interestId) }) + ? await db.query.interests.findFirst({ + where: and(eq(interests.id, doc.interestId), eq(interests.portId, portId)), + }) : null; const client = doc.clientId ? await db.query.clients.findFirst({ - where: eq(clients.id, doc.clientId), + where: and(eq(clients.id, doc.clientId), eq(clients.portId, portId)), with: { contacts: true }, }) : null; @@ -1198,6 +1288,14 @@ export async function createFromWizard( throw new ValidationError('templateId is required for template source'); } + await assertSubjectFksInPort(portId, { + clientId: data.clientId, + interestId: data.interestId, + companyId: data.companyId, + yachtId: data.yachtId, + reservationId: data.reservationId, + }); + const [doc] = await db .insert(documents) .values({ @@ -1275,6 +1373,14 @@ export async function createFromUpload( throw new NotFoundError('File'); } + await assertSubjectFksInPort(portId, { + clientId: data.clientId, + interestId: data.interestId, + companyId: data.companyId, + yachtId: data.yachtId, + reservationId: data.reservationId, + }); + const [doc] = await db .insert(documents) .values({ diff --git a/src/lib/services/email-draft.service.ts b/src/lib/services/email-draft.service.ts index 8b7a116..d41fefd 100644 --- a/src/lib/services/email-draft.service.ts +++ b/src/lib/services/email-draft.service.ts @@ -1,3 +1,10 @@ +import { randomUUID } from 'crypto'; +import { and, eq } from 'drizzle-orm'; + +import { db } from '@/lib/db'; +import { interests } from '@/lib/db/schema/interests'; +import { clients } from '@/lib/db/schema/clients'; +import { ValidationError, ForbiddenError } from '@/lib/errors'; import { getQueue } from '@/lib/queue'; // ─── Types ──────────────────────────────────────────────────────────────────── @@ -20,26 +27,50 @@ export interface DraftResult { /** * Request an AI-generated email draft. - * Enqueues a job on the 'ai' queue. Returns jobId for polling. - * Job payload contains ONLY entity IDs (no PII). + * + * Generates an opaque random jobId rather than relying on BullMQ's default + * sequential ids — the jobId is the access token for polling, so it must + * not be enumerable. The job payload also captures the requesting user + * + port so the poll endpoint can refuse cross-tenant / cross-user reads. + * + * The interestId and clientId are validated against portId before enqueue + * so a port-A caller cannot trigger a draft built from port-B data. */ export async function requestEmailDraft( userId: string, request: DraftRequest, ): Promise<{ jobId: string }> { - const aiQueue = getQueue('ai'); - - const job = await aiQueue.add('generate-email-draft', { - // No PII — only IDs and context parameters - interestId: request.interestId, - clientId: request.clientId, - portId: request.portId, - context: request.context, - additionalInstructions: request.additionalInstructions, - requestedBy: userId, + const interest = await db.query.interests.findFirst({ + where: and(eq(interests.id, request.interestId), eq(interests.portId, request.portId)), }); + if (!interest) { + throw new ValidationError('interestId not found in this port'); + } + const client = await db.query.clients.findFirst({ + where: and(eq(clients.id, request.clientId), eq(clients.portId, request.portId)), + }); + if (!client) { + throw new ValidationError('clientId not found in this port'); + } - return { jobId: job.id! }; + const aiQueue = getQueue('ai'); + const jobId = randomUUID(); + + await aiQueue.add( + 'generate-email-draft', + { + // No PII — only IDs and context parameters + interestId: request.interestId, + clientId: request.clientId, + portId: request.portId, + context: request.context, + additionalInstructions: request.additionalInstructions, + requestedBy: userId, + }, + { jobId }, + ); + + return { jobId }; } // ─── Poll for result ────────────────────────────────────────────────────────── @@ -47,13 +78,26 @@ export async function requestEmailDraft( /** * Get the result of an email draft generation job. * Returns null if still processing. + * + * Verifies the caller (userId + portId) matches the job's original + * requester before returning the drafted subject/body. A foreign caller + * who happens to know the jobId (or stumbles on it) sees null, not the + * drafted content. */ -export async function getEmailDraftResult(jobId: string): Promise { +export async function getEmailDraftResult( + jobId: string, + caller: { userId: string; portId: string }, +): Promise { const aiQueue = getQueue('ai'); const job = await aiQueue.getJob(jobId); if (!job) return null; + const data = job.data as { requestedBy?: string; portId?: string } | undefined | null; + if (!data || data.requestedBy !== caller.userId || data.portId !== caller.portId) { + throw new ForbiddenError('Email draft not accessible'); + } + const state = await job.getState(); if (state !== 'completed') return null; diff --git a/src/lib/services/interest-scoring.service.ts b/src/lib/services/interest-scoring.service.ts index 697c5db..4c8d99d 100644 --- a/src/lib/services/interest-scoring.service.ts +++ b/src/lib/services/interest-scoring.service.ts @@ -12,18 +12,22 @@ import { logger } from '@/lib/logger'; export interface InterestScore { totalScore: number; // 0-100 (normalised) breakdown: { - pipelineAge: number; // 0-100 - stageSpeed: number; // 0-100 + pipelineAge: number; // 0-100 + stageSpeed: number; // 0-100 documentCompleteness: number; // 0-100 - engagement: number; // 0-100 - berthLinked: number; // 0 or 25 + engagement: number; // 0-100 + berthLinked: number; // 0 or 25 }; calculatedAt: Date; } // ─── Redis cache ────────────────────────────────────────────────────────────── -const SCORE_KEY = (interestId: string) => `interest-score:${interestId}`; +// Cache key includes portId so a foreign-port caller hitting the same +// interestId never sees a port-A cached value. (Even if interestId is +// already globally unique, baking portId into the key means a stale or +// hostile caller cannot reuse cached entries across tenants.) +const SCORE_KEY = (portId: string, interestId: string) => `interest-score:${portId}:${interestId}`; const SCORE_TTL = 3600; // 1 hour // ─── Scoring helpers ────────────────────────────────────────────────────────── @@ -56,10 +60,7 @@ function scoreStageSpeed(createdAt: Date, pipelineStage: string): number { return 0; } - const daysSinceCreation = Math.max( - 1, - (Date.now() - createdAt.getTime()) / (1000 * 60 * 60 * 24), - ); + const daysSinceCreation = Math.max(1, (Date.now() - createdAt.getTime()) / (1000 * 60 * 60 * 24)); // Average days per stage transition const avgDaysPerStage = daysSinceCreation / stageIndex; @@ -108,18 +109,10 @@ export async function calculateInterestScore( interestId: string, portId: string, ): Promise { - // Try cache first - try { - const cached = await redis.get(SCORE_KEY(interestId)); - if (cached) { - const parsed = JSON.parse(cached) as InterestScore & { calculatedAt: string }; - return { ...parsed, calculatedAt: new Date(parsed.calculatedAt) }; - } - } catch (err) { - logger.warn({ err, interestId }, 'Redis cache read failed for interest score'); - } - - // Fetch interest + // Verify the interest belongs to the caller's port BEFORE returning a + // cached value. The cache key now includes portId, but defense-in-depth: + // a port-B caller passing a port-A interestId still gets NotFound + // instead of a leaked score. const interest = await db.query.interests.findFirst({ where: and(eq(interests.id, interestId), eq(interests.portId, portId)), }); @@ -128,6 +121,17 @@ export async function calculateInterestScore( throw new Error(`Interest not found: ${interestId}`); } + // Try cache (port-scoped key) + try { + const cached = await redis.get(SCORE_KEY(portId, interestId)); + if (cached) { + const parsed = JSON.parse(cached) as InterestScore & { calculatedAt: string }; + return { ...parsed, calculatedAt: new Date(parsed.calculatedAt) }; + } + } catch (err) { + logger.warn({ err, interestId }, 'Redis cache read failed for interest score'); + } + // 1. Pipeline age const pipelineAge = scorePipelineAge(interest.createdAt); @@ -145,10 +149,7 @@ export async function calculateInterestScore( .select({ value: count() }) .from(interestNotes) .where( - and( - eq(interestNotes.interestId, interestId), - gte(interestNotes.createdAt, thirtyDaysAgo), - ), + and(eq(interestNotes.interestId, interestId), gte(interestNotes.createdAt, thirtyDaysAgo)), ), db .select({ value: count() }) @@ -203,8 +204,10 @@ export async function calculateInterestScore( // Write to cache (fire-and-forget) redis - .setex(SCORE_KEY(interestId), SCORE_TTL, JSON.stringify(result)) - .catch((err) => logger.warn({ err, interestId }, 'Redis cache write failed for interest score')); + .setex(SCORE_KEY(portId, interestId), SCORE_TTL, JSON.stringify(result)) + .catch((err) => + logger.warn({ err, interestId }, 'Redis cache write failed for interest score'), + ); return result; } @@ -227,8 +230,9 @@ export async function calculateBulkScores( ); return results - .filter((r): r is PromiseFulfilledResult<{ interestId: string; score: InterestScore }> => - r.status === 'fulfilled', + .filter( + (r): r is PromiseFulfilledResult<{ interestId: string; score: InterestScore }> => + r.status === 'fulfilled', ) .map((r) => r.value); } diff --git a/src/lib/services/invoices.ts b/src/lib/services/invoices.ts index 309c61a..52714f1 100644 --- a/src/lib/services/invoices.ts +++ b/src/lib/services/invoices.ts @@ -135,6 +135,27 @@ async function resolveBillingEntity( }; } +/** + * Verify every supplied expense ID belongs to the caller's port. Without + * this gate, a caller could link foreign-port expenses into their own + * draft invoice and read those expenses back via getInvoiceById's + * `linkedExpenses` join — a cross-tenant data leak. + */ +async function assertExpensesInPort( + tx: typeof db, + portId: string, + expenseIds: string[], +): Promise { + if (expenseIds.length === 0) return; + const rows = await tx + .select({ id: expenses.id }) + .from(expenses) + .where(and(inArray(expenses.id, expenseIds), eq(expenses.portId, portId))); + if (rows.length !== expenseIds.length) { + throw new ValidationError('One or more expenses not found in this port'); + } +} + // ─── List ───────────────────────────────────────────────────────────────── export async function listInvoices(portId: string, query: ListInvoicesInput) { @@ -195,11 +216,14 @@ export async function getInvoiceById(id: string, portId: string) { .where(eq(invoiceLineItems.invoiceId, id)) .orderBy(invoiceLineItems.sortOrder); + // Defense-in-depth: even if a join row somehow points at a foreign-tenant + // expense, the WHERE clause filters by expenses.portId so cross-tenant data + // can't leak through this read. const linkedExpenses = await db .select({ expense: expenses }) .from(invoiceExpenses) .innerJoin(expenses, eq(expenses.id, invoiceExpenses.expenseId)) - .where(eq(invoiceExpenses.invoiceId, id)); + .where(and(eq(invoiceExpenses.invoiceId, id), eq(expenses.portId, portId))); return { ...invoice, @@ -250,8 +274,11 @@ export async function createInvoice(portId: string, data: CreateInvoiceInput, me const feePct = 0; const total = subtotal - discountAmount + feeAmount; - // BR-045: Verify expenses aren't already linked to a non-draft invoice + // BR-045: Verify expenses aren't already linked to a non-draft invoice. + // Tenancy guard precedes BR-045 so a foreign-port expense fails with + // ValidationError before any further checks (or any join-side leak). const expenseIds = data.expenseIds ?? []; + await assertExpensesInPort(tx, portId, expenseIds); if (expenseIds.length > 0) { const alreadyLinked = await tx .select({ expenseId: invoiceExpenses.expenseId }) @@ -418,6 +445,9 @@ export async function updateInvoice( // Replace expense links if provided if (data.expenseIds !== undefined) { + // Tenancy gate first — reject foreign-port expense IDs before + // running BR-045 or doing any writes. + await assertExpensesInPort(tx, portId, data.expenseIds); // BR-045 if (data.expenseIds.length > 0) { const alreadyLinked = await tx diff --git a/tests/integration/email-draft-job-isolation.test.ts b/tests/integration/email-draft-job-isolation.test.ts new file mode 100644 index 0000000..210b88c --- /dev/null +++ b/tests/integration/email-draft-job-isolation.test.ts @@ -0,0 +1,146 @@ +/** + * Security regression: AI email-draft jobs are bound to the requesting + * user + port. A foreign caller who knows the jobId must NOT receive the + * drafted subject/body. + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +import { ForbiddenError, ValidationError } from '@/lib/errors'; + +// Mock the queue. Each test sets up a fresh per-test job map. +const fakeJobs = new Map(); + +vi.mock('@/lib/queue', () => ({ + getQueue: () => ({ + add: vi.fn(async (_name: string, data: unknown, opts: { jobId: string }) => { + fakeJobs.set(opts.jobId, { data, returnvalue: null, state: 'completed' }); + return { id: opts.jobId }; + }), + getJob: vi.fn(async (id: string) => { + const j = fakeJobs.get(id); + if (!j) return null; + return { + data: j.data, + returnvalue: j.returnvalue, + getState: async () => j.state, + }; + }), + }), +})); + +// Mock interest/client lookups so requestEmailDraft doesn't hit the DB. +vi.mock('@/lib/db', () => ({ + db: { + query: { + interests: { + findFirst: vi.fn(async ({ where: _w }) => ({ id: 'iA', portId: 'pA' })), + }, + clients: { + findFirst: vi.fn(async ({ where: _w }) => ({ id: 'cA', portId: 'pA' })), + }, + }, + }, +})); + +beforeEach(() => { + fakeJobs.clear(); + vi.clearAllMocks(); +}); + +describe('email-draft job binding', () => { + it('rejects readers with a different userId', async () => { + const { requestEmailDraft, getEmailDraftResult } = + await import('@/lib/services/email-draft.service'); + + const { jobId } = await requestEmailDraft('user-A', { + interestId: 'iA', + clientId: 'cA', + portId: 'pA', + context: 'follow_up', + }); + + // Wire in a completed return value so a successful path would otherwise + // produce a result. + fakeJobs.get(jobId)!.returnvalue = { + subject: 'leak', + body: 'leak', + generatedAt: new Date().toISOString(), + }; + + await expect(getEmailDraftResult(jobId, { userId: 'user-B', portId: 'pA' })).rejects.toThrow( + ForbiddenError, + ); + }); + + it('rejects readers with a different portId', async () => { + const { requestEmailDraft, getEmailDraftResult } = + await import('@/lib/services/email-draft.service'); + + const { jobId } = await requestEmailDraft('user-A', { + interestId: 'iA', + clientId: 'cA', + portId: 'pA', + context: 'follow_up', + }); + fakeJobs.get(jobId)!.returnvalue = { + subject: 'leak', + body: 'leak', + generatedAt: new Date().toISOString(), + }; + + await expect(getEmailDraftResult(jobId, { userId: 'user-A', portId: 'pB' })).rejects.toThrow( + ForbiddenError, + ); + }); + + it('returns drafted content to the original requester', async () => { + const { requestEmailDraft, getEmailDraftResult } = + await import('@/lib/services/email-draft.service'); + + const { jobId } = await requestEmailDraft('user-A', { + interestId: 'iA', + clientId: 'cA', + portId: 'pA', + context: 'follow_up', + }); + fakeJobs.get(jobId)!.returnvalue = { + subject: 'subject-A', + body: 'body-A', + generatedAt: new Date().toISOString(), + }; + + const result = await getEmailDraftResult(jobId, { userId: 'user-A', portId: 'pA' }); + expect(result?.subject).toBe('subject-A'); + expect(result?.body).toBe('body-A'); + }); + + it('jobId is a UUID, not a sequential integer', async () => { + const { requestEmailDraft } = await import('@/lib/services/email-draft.service'); + + const { jobId } = await requestEmailDraft('user-A', { + interestId: 'iA', + clientId: 'cA', + portId: 'pA', + context: 'follow_up', + }); + // Crude UUID-shape check: 8-4-4-4-12 hex. + expect(jobId).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i); + }); + + it('rejects requests whose interest is not in the supplied port', async () => { + const { db } = await import('@/lib/db'); + (db.query.interests.findFirst as ReturnType).mockResolvedValueOnce(null); + + const { requestEmailDraft } = await import('@/lib/services/email-draft.service'); + + await expect( + requestEmailDraft('user-A', { + interestId: 'foreign-interest', + clientId: 'cA', + portId: 'pA', + context: 'follow_up', + }), + ).rejects.toThrow(ValidationError); + }); +}); diff --git a/tests/unit/interest-scoring.test.ts b/tests/unit/interest-scoring.test.ts index 469ac76..e3322bc 100644 --- a/tests/unit/interest-scoring.test.ts +++ b/tests/unit/interest-scoring.test.ts @@ -155,9 +155,10 @@ describe('calculateInterestScore', () => { // High engagement: 5 notes, 3 emails, 2 reminders const selectChain = { from: vi.fn().mockReturnThis(), - where: vi.fn() - .mockResolvedValueOnce([{ value: 5 }]) // notes - .mockResolvedValueOnce([{ value: 2 }]) // reminders + where: vi + .fn() + .mockResolvedValueOnce([{ value: 5 }]) // notes + .mockResolvedValueOnce([{ value: 2 }]) // reminders .mockResolvedValueOnce([{ value: 3 }]), // emails }; (db.select as ReturnType).mockReturnValue(selectChain); @@ -254,12 +255,20 @@ describe('calculateInterestScore', () => { const selectChain = makeSelectChain(0); (db.select as ReturnType).mockReturnValue(selectChain); - (db.query.interests.findFirst as ReturnType).mockResolvedValue({ ...base, id: 'i6', berthId: 'b1' }); + (db.query.interests.findFirst as ReturnType).mockResolvedValue({ + ...base, + id: 'i6', + berthId: 'b1', + }); const withBerth = await calculateInterestScore('i6', 'p1'); expect(withBerth.breakdown.berthLinked).toBe(25); (redis.get as ReturnType).mockResolvedValue(null); - (db.query.interests.findFirst as ReturnType).mockResolvedValue({ ...base, id: 'i7', berthId: null }); + (db.query.interests.findFirst as ReturnType).mockResolvedValue({ + ...base, + id: 'i7', + berthId: null, + }); const withoutBerth = await calculateInterestScore('i7', 'p1'); expect(withoutBerth.breakdown.berthLinked).toBe(0); }); @@ -269,7 +278,11 @@ describe('calculateInterestScore', () => { await expect(calculateInterestScore('missing', 'p1')).rejects.toThrow('Interest not found'); }); - it('returns cached result when redis has a hit', async () => { + it('returns cached result when redis has a hit (after port-scope DB check)', async () => { + // Security fix: the DB lookup runs FIRST to confirm the interest is + // in the caller's port. Only then is the (port-scoped) cache key read. + // A test that asserts the DB is bypassed would be asserting the + // pre-fix bug; this test asserts the new ordering. const cachedScore = { totalScore: 42, breakdown: { @@ -281,11 +294,26 @@ describe('calculateInterestScore', () => { }, calculatedAt: new Date().toISOString(), }; + (db.query.interests.findFirst as ReturnType).mockResolvedValue({ + id: 'cached-id', + portId: 'p1', + clientId: 'c1', + createdAt: daysAgo(10), + pipelineStage: 'open', + eoiStatus: null, + contractStatus: null, + depositStatus: null, + dateEoiSigned: null, + dateContractSigned: null, + dateDepositReceived: null, + berthId: null, + }); (redis.get as ReturnType).mockResolvedValue(JSON.stringify(cachedScore)); const result = await calculateInterestScore('cached-id', 'p1'); expect(result.totalScore).toBe(42); - // Should NOT hit the database - expect(db.query.interests.findFirst).not.toHaveBeenCalled(); + // Port-scope check: the DB IS hit, but no other queries (notes/threads) + // are needed since the cache served the score body. + expect(db.query.interests.findFirst).toHaveBeenCalled(); }); });