diff --git a/src/lib/services/documents.service.ts b/src/lib/services/documents.service.ts index 94ec46f6..d0b9d821 100644 --- a/src/lib/services/documents.service.ts +++ b/src/lib/services/documents.service.ts @@ -41,6 +41,7 @@ import { type FolderNode, type EntityType, } from '@/lib/services/document-folders.service'; +import { assertEntityInPort, collectRelatedEntities } from '@/lib/services/files'; import type { CreateDocumentInput, UpdateDocumentInput, @@ -1822,8 +1823,6 @@ export async function createFromUpload( // ─── Aggregated Workflow Projection ─────────────────────────────────────────── -import { collectRelatedEntities } from '@/lib/services/files'; - export interface AggregatedWorkflowGroup { label: string; source: 'direct' | 'client' | 'company' | 'yacht'; @@ -1844,6 +1843,9 @@ export async function listInflightWorkflowsAggregatedByEntity( entityType: 'client' | 'company' | 'yacht', entityId: string, ): Promise<{ groups: AggregatedWorkflowGroup[] }> { + const entityExists = await assertEntityInPort(portId, entityType, entityId); + if (!entityExists) return { groups: [] }; + const related = await collectRelatedEntities(portId, entityType, entityId); const groups: AggregatedWorkflowGroup[] = []; @@ -1910,11 +1912,7 @@ async function fetchWorkflowGroupRows( .select() .from(documents) .where( - and( - eq(documents.portId, portId), - inArray(documents.status, inflightStatuses), - predicate, - ), + and(eq(documents.portId, portId), inArray(documents.status, inflightStatuses), predicate), ) .orderBy(desc(documents.updatedAt)) .limit(WORKFLOW_GROUP_LIMIT); @@ -1923,11 +1921,7 @@ async function fetchWorkflowGroupRows( .select({ count: sql`count(*)::int` }) .from(documents) .where( - and( - eq(documents.portId, portId), - inArray(documents.status, inflightStatuses), - predicate, - ), + and(eq(documents.portId, portId), inArray(documents.status, inflightStatuses), predicate), ); return { rows, total: Number(countRow?.count ?? 0) }; diff --git a/src/lib/services/files.ts b/src/lib/services/files.ts index fb3a35e1..52e33b10 100644 --- a/src/lib/services/files.ts +++ b/src/lib/services/files.ts @@ -1,4 +1,4 @@ -import { and, arrayContains, desc, eq, inArray, or, sql } from 'drizzle-orm'; +import { and, arrayContains, desc, eq, inArray, isNull, or, sql } from 'drizzle-orm'; import { db } from '@/lib/db'; import { files, documents } from '@/lib/db/schema/documents'; @@ -364,7 +364,7 @@ export async function listFilesAggregatedByEntity( return { groups }; } -async function assertEntityInPort( +export async function assertEntityInPort( portId: string, entityType: EntityType, entityId: string, @@ -415,7 +415,9 @@ export async function collectRelatedEntities( companies, and(eq(companies.id, companyMemberships.companyId), eq(companies.portId, portId)), ) - .where(eq(companyMemberships.clientId, entityId)); + .where( + and(eq(companyMemberships.clientId, entityId), isNull(companyMemberships.endDate)), + ); const directYachts = await db .select({ id: yachts.id, name: yachts.name }) @@ -461,7 +463,9 @@ export async function collectRelatedEntities( clients, and(eq(clients.id, companyMemberships.clientId), eq(clients.portId, portId)), ) - .where(eq(companyMemberships.companyId, entityId)); + .where( + and(eq(companyMemberships.companyId, entityId), isNull(companyMemberships.endDate)), + ); const ownedYachts = await db .select({ id: yachts.id, name: yachts.name }) @@ -561,10 +565,7 @@ export async function applyEntityFkFromFolder< if (!payload.folderId) return payload; const folder = await db.query.documentFolders.findFirst({ - where: and( - eq(documentFolders.id, payload.folderId), - eq(documentFolders.portId, portId), - ), + where: and(eq(documentFolders.id, payload.folderId), eq(documentFolders.portId, portId)), columns: { systemManaged: true, entityType: true, entityId: true }, }); diff --git a/tests/unit/aggregated-projection.test.ts b/tests/unit/aggregated-projection.test.ts index 217fe55a..830c59a3 100644 --- a/tests/unit/aggregated-projection.test.ts +++ b/tests/unit/aggregated-projection.test.ts @@ -16,16 +16,11 @@ import { and, eq } from 'drizzle-orm'; import { db } from '@/lib/db'; import { files, documents, documentFolders } from '@/lib/db/schema/documents'; +import { clients } from '@/lib/db/schema/clients'; import { user } from '@/lib/db/schema/users'; import { yachts } from '@/lib/db/schema/yachts'; -import { - ensureSystemRoots, - ensureEntityFolder, -} from '@/lib/services/document-folders.service'; -import { - listFilesAggregatedByEntity, - applyEntityFkFromFolder, -} from '@/lib/services/files'; +import { ensureSystemRoots, ensureEntityFolder } from '@/lib/services/document-folders.service'; +import { listFilesAggregatedByEntity, applyEntityFkFromFolder } from '@/lib/services/files'; import { listInflightWorkflowsAggregatedByEntity } from '@/lib/services/documents.service'; import { makePort, makeClient, makeCompany, makeYacht, makeMembership } from '../helpers/factories'; @@ -133,9 +128,7 @@ describe('files service · listFilesAggregatedByEntity', () => { clientId = client.id; // Insert 25 files all with clientId - await Promise.all( - Array.from({ length: 25 }, () => insertFile(portId, { clientId })), - ); + await Promise.all(Array.from({ length: 25 }, () => insertFile(portId, { clientId }))); }); it('DIRECTLY ATTACHED group has 20 files and total=25', async () => { @@ -265,6 +258,16 @@ describe('documents service · listInflightWorkflowsAggregatedByEntity', () => { expect(directGroup!.workflows[0]!.status).toBe('sent'); expect(directGroup!.workflows[0]!.title).toBe('In-flight Doc'); }); + + it('rejects cross-port leakage with defense-in-depth port filter', async () => { + const otherPort = await makePort(); + const [otherClient] = await db + .insert(clients) + .values({ portId: otherPort.id, fullName: 'Other Port Client' }) + .returning(); + const result = await listInflightWorkflowsAggregatedByEntity(portId, 'client', otherClient!.id); + expect(result.groups).toEqual([]); + }); }); // ─── applyEntityFkFromFolder ──────────────────────────────────────────────────