import { afterAll, beforeAll, describe, expect, it } from "vitest"; import type { MediaRepos } from "./media.js"; import { createMediaRepos } from "./media.js"; import type { MediaTestDb, SeededMediaSpace } from "./media-fixtures.js"; import { openMediaTestDb, seedMediaSpace, sourceItem, testBrandKit, testEdl, vectorBytes, } from "./media-fixtures.js"; import { IsolationError } from "./scope.js"; const databaseUrl = process.env.DATABASE_URL; const describePostgres = process.env.VERIFY_DATABASE && databaseUrl ? describe.sequential : describe.skip; describePostgres("media repositories: space isolation (PostgreSQL)", () => { let db: MediaTestDb; let media: MediaRepos; let a: SeededMediaSpace; let owner: { userId: string; spaceId: string }; let intruder: { userId: string; spaceId: string }; beforeAll(async () => { db = openMediaTestDb(databaseUrl!); media = createMediaRepos(db.prisma); owner = await db.newUser(); intruder = await db.newUser(); a = await seedMediaSpace(media, owner.spaceId, owner.userId); }); afterAll(async () => { await db?.cleanup(); }); async function snapshot() { const where = { spaceId: owner.spaceId }; const p = db.prisma; return { sources: await p.mediaSource.findMany({ where, orderBy: { id: "asc" } }), assets: await p.mediaAsset.findMany({ where, orderBy: { id: "asc" } }), segments: await p.mediaSegment.findMany({ where, orderBy: { id: "asc" } }), analyses: await p.mediaAnalysis.findMany({ where, orderBy: { id: "asc" } }), collections: await p.mediaCollection.findMany({ where, orderBy: { id: "asc" } }), items: await p.mediaCollectionItem.findMany({ where: { collection: where }, orderBy: [{ collectionId: "asc" }, { assetId: "asc" }], }), people: await p.mediaPerson.findMany({ where, orderBy: { id: "asc" } }), tracks: await p.mediaFaceTrack.findMany({ where, orderBy: { id: "asc" } }), marks: await p.mediaMark.findMany({ where, orderBy: { id: "asc" } }), kits: await p.mediaBrandKit.findMany({ where, orderBy: { id: "asc" } }), projects: await p.mediaProject.findMany({ where, orderBy: { id: "asc" } }), plans: await p.mediaEditPlan.findMany({ where, orderBy: { id: "asc" } }), renders: await p.mediaRender.findMany({ where, orderBy: { id: "asc" } }), publications: await p.mediaPublication.findMany({ where, orderBy: { id: "asc" } }), metrics: await p.mediaMetric.findMany({ where, orderBy: { id: "asc" } }), mappings: await p.mediaMetricMapping.findMany({ where, orderBy: { id: "asc" } }), teaser: await p.mediaTeaserModel.findMany({ where }), uploads: await p.mediaUpload.findMany({ where, orderBy: { id: "asc" } }), }; } it("rejects every read and write of another space's ids and leaves its rows untouched", async () => { const before = await snapshot(); const b = intruder.spaceId; const denied: Array<[string, () => Promise]> = [ ["getSource", () => media.getSource(b, a.sourceId)], ["updateSourceSync", () => media.updateSourceSync(b, a.sourceId, { status: "error" })], ["removeSource", () => media.removeSource(b, a.sourceId)], ["upsertSourceItems", () => media.upsertSourceItems(b, a.sourceId, [sourceItem(99)])], ["getAsset", () => media.getAsset(b, a.assetId)], ["getAssetRecord", () => media.getAssetRecord(b, a.assetId)], [ "transitionIngestState", () => media.transitionIngestState(b, a.assetId, { from: ["indexed"], to: "proxying" }), ], [ "recordProxy", () => media.recordProxy(b, a.assetId, { contentHash: "x", proxyPath: "p", thumbPath: "t" }), ], ["recordAnalysisVersion", () => media.recordAnalysisVersion(b, a.assetId, 9, 1)], ["requestUpload", () => media.requestUpload(b, [a.assetId])], [ "upsertAnalysis", () => media.upsertAnalysis(b, a.assetId, { kind: "embeddings", modelId: "m", modelVersion: "1", vectors: vectorBytes([9]), }), ], ["latestAnalysis", () => media.latestAnalysis(b, a.assetId, "embeddings")], [ "replaceSegments", () => media.replaceSegments(b, a.assetId, 2, [{ kind: "shot", startMs: 0, endMs: 1 }]), ], ["setSegmentsPaidOnly", () => media.setSegmentsPaidOnly(b, [a.segmentId], true)], [ "beginUpload", () => media.beginUpload(b, { assetId: a.assetId, userId: intruder.userId, sizeBytes: 1, sha256: "b".repeat(64), fileName: "x", }), ], ["uploadStatus", () => media.uploadStatus(b, a.uploadId)], ["getUploadRecord", () => media.getUploadRecord(b, a.uploadId)], ["markChunkReceived", () => media.markChunkReceived(b, a.uploadId, 0)], ["completeUpload", () => media.completeUpload(b, a.uploadId, { storagePath: "x" })], ["failUpload", () => media.failUpload(b, a.uploadId)], [ "replaceClusters", () => media.replaceClusters(b, "session", [ { key: "s1", title: "Stolen", labelSource: "rule", startAt: null, endAt: null, centroid: null, coverAssetId: null, assetIds: [a.assetId], }, ]), ], ["getCollection", () => media.getCollection(b, a.collectionId)], ["collectionAssetIds", () => media.collectionAssetIds(b, [a.collectionId])], ["renameCollection", () => media.renameCollection(b, a.collectionId, "Mine")], ["mergeCollections", () => media.mergeCollections(b, [a.collectionId, `${a.collectionId}x`])], ["splitCollection", () => media.splitCollection(b, a.collectionId, a.secondAssetId)], ["createManualCollection", () => media.createManualCollection(b, "Mine", [a.assetId])], ["getPerson", () => media.getPerson(b, a.personId)], [ "createPersonSuggestions", () => media.createPersonSuggestions(b, [{ centroid: vectorBytes([1]), trackIds: [a.trackId] }]), ], ["updatePerson", () => media.updatePerson(b, a.personId, { displayName: "Mine" })], ["mergePeople", () => media.mergePeople(b, [a.personId, `${a.personId}x`])], ["assignTrack", () => media.assignTrack(b, a.trackId, null)], [ "recordConsent (foreign person)", () => media.recordConsent( { spaceId: b, recordedByUserId: intruder.userId }, { personId: a.personId, consentArtifactId: null, consentRecordedAt: new Date().toISOString(), ageVerificationMethod: "none", ageVerifiedAt: null, }, ), ], [ "recordConsent (foreign recorder)", () => media.recordConsent( { spaceId: owner.spaceId, recordedByUserId: intruder.userId }, { personId: a.personId, consentArtifactId: null, consentRecordedAt: new Date().toISOString(), ageVerificationMethod: "none", ageVerifiedAt: null, }, ), ], ["removePersonCompletely", () => media.removePersonCompletely(b, a.personId)], ["replaceFaceTracks", () => media.replaceFaceTracks(b, a.assetId, "faces-2", [])], [ "createMark", () => media.createMark(b, { kind: "scar", label: null, personId: null, assetId: a.assetId, prompt: { tMs: 0 }, treatment: "blur", }), ], ["getMark", () => media.getMark(b, a.markId)], ["updateMark", () => media.updateMark(b, a.markId, { treatment: "cover" })], [ "recordMarkResult", () => media.recordMarkResult(b, a.markId, { qualityScore: 0, suggestedFallback: "cover", maskDir: "x", }), ], ["removeMark", () => media.removeMark(b, a.markId)], ["upsertBrandKit", () => media.upsertBrandKit(b, { id: a.brandKitId, kit: testBrandKit })], ["getBrandKit", () => media.getBrandKit(b, a.brandKitId)], ["removeBrandKit", () => media.removeBrandKit(b, a.brandKitId)], [ "createProject (foreign brand kit)", () => media.createProject(b, intruder.userId, { title: "x", goal: "teaser", profileIds: ["example-short-vertical"], brandKitId: a.brandKitId, }), ], [ "createProject (foreign collection)", () => media.createProject(b, intruder.userId, { title: "x", goal: "teaser", profileIds: ["example-short-vertical"], collectionIds: [a.collectionId], }), ], ["updateProject", () => media.updateProject(b, a.projectId, { title: "Mine" })], ["getProject", () => media.getProject(b, a.projectId)], ["getProjectRecord", () => media.getProjectRecord(b, a.projectId)], ["removeProject", () => media.removeProject(b, a.projectId)], [ "createPlanVersion", () => media.createPlanVersion(b, { projectId: a.projectId, profileId: "example-short-vertical", edl: testEdl(a.assetId), createdBy: "agent", rationale: null, }), ], ["getPlan", () => media.getPlan(b, a.planId)], ["latestPlan", () => media.latestPlan(b, a.projectId, "example-short-vertical")], ["listPlanVersions", () => media.listPlanVersions(b, a.projectId, "example-short-vertical")], [ "createRender", () => media.createRender(b, { planId: a.planId, quality: "draft", createdBy: "x" }), ], ["getRender", () => media.getRender(b, a.renderId)], ["getRenderRecord", () => media.getRenderRecord(b, a.renderId)], ["claimRender", () => media.claimRender(b, a.queuedRenderId)], ["updateRender", () => media.updateRender(b, a.queuedRenderId, { progress: 0.5 })], ["cancelRender", () => media.cancelRender(b, a.queuedRenderId)], [ "createPublication", () => media.createPublication(b, intruder.userId, { renderId: a.renderId, destination: "bundle", targetUrl: null, trackingUrl: null, utm: null, title: null, caption: null, tags: [], thumbnailVariant: null, scheduledAt: null, }), ], ["getPublication", () => media.getPublication(b, a.publicationId)], ["getPublicationRecord", () => media.getPublicationRecord(b, a.publicationId)], ["schedulePublication", () => media.schedulePublication(b, a.publicationId, new Date())], ["cancelPublication", () => media.cancelPublication(b, a.publicationId)], ["markPublicationBundled", () => media.markPublicationBundled(b, a.publicationId, null)], ["markPublicationPublished", () => media.markPublicationPublished(b, a.publicationId, null)], ["markPublicationFailed", () => media.markPublicationFailed(b, a.publicationId)], [ "recordMetric", () => media.recordMetric(b, { publicationId: a.publicationId, observedAt: new Date().toISOString(), views: 1, clicks: 0, conversions: 0, revenueCents: 0, currency: "EUR", source: "manual", }), ], [ "recordMetrics", () => media.recordMetrics(b, [ { publicationId: a.publicationId, observedAt: new Date().toISOString(), views: 1, clicks: 0, conversions: 0, revenueCents: 0, currency: "EUR", source: "csv_import", }, ]), ], ["listMetrics", () => media.listMetrics(b, a.publicationId)], [ "saveMetricMapping (foreign id)", () => media.saveMetricMapping(b, { id: a.mappingId, name: "Stolen", delimiter: ",", columns: { publication: "id", observedAt: "date" }, publicationKey: "id", revenueUnit: "major", defaultCurrency: "EUR", }), ], ["getMetricMapping", () => media.getMetricMapping(b, a.mappingId)], ["funnel (foreign project)", () => media.funnel(b, a.projectId)], ]; for (const [name, call] of denied) { await expect(call(), name).rejects.toBeInstanceOf(IsolationError); } // Reads without ids return nothing of the other space. expect(await media.listSources(b)).toEqual([]); expect((await media.searchAssets(b, {})).items).toEqual([]); expect((await media.searchAssets(b, { text: "clip" })).items).toEqual([]); expect((await media.searchAssets(b, { personId: a.personId })).items).toEqual([]); expect((await media.searchAssets(b, { collectionId: a.collectionId })).items).toEqual([]); expect((await media.searchAssets(b, { sourceId: a.sourceId })).items).toEqual([]); expect(await media.listAssetRecords(b, [a.assetId])).toEqual([]); expect(await media.listAssetsForClustering(b)).toEqual([]); expect(await media.assetsNeedingWork(b, "indexed", 10)).toEqual([]); expect((await media.latestAnalysesByKind(b, "embeddings")).size).toBe(0); expect(await media.listSegments(b, [a.assetId])).toEqual([]); expect((await media.listCollections(b, {})).items).toEqual([]); expect(await media.listPeople(b)).toEqual([]); expect(await media.listPersonRecords(b, [a.personId])).toEqual([]); expect(await media.personCentroids(b)).toEqual([]); expect(await media.listFaceTracks(b, { assetId: a.assetId })).toEqual([]); expect(await media.faceTrackRecords(b, [a.assetId])).toEqual([]); expect(await media.faceTrackEmbeddings(b)).toEqual([]); expect(await media.listMarks(b, { assetId: a.assetId })).toEqual([]); expect(await media.marksForAsset(b, a.assetId)).toEqual([]); expect(await media.listBrandKits(b)).toEqual([]); expect((await media.listProjects(b)).items).toEqual([]); expect(await media.latestPlans(b, a.projectId)).toEqual([]); expect(await media.listRenders(b, a.projectId)).toEqual([]); expect((await media.listPublications(b, { projectId: a.projectId })).items).toEqual([]); expect((await media.resolvePublicationKeys(b, "id", [a.publicationId])).size).toBe(0); expect(await media.listMetricMappings(b)).toEqual([]); expect(await media.funnel(b)).toEqual([]); expect(await media.publicationsWithMetricsCount(b)).toBe(0); expect(await media.getTeaserModel(b)).toBeNull(); await media.resetTeaserModel(b); expect(await snapshot()).toEqual(before); // The owner still sees everything. expect((await media.searchAssets(owner.spaceId, {})).items).toHaveLength(2); expect(await media.getTeaserModel(owner.spaceId)).not.toBeNull(); }); it("resolves the owning space for worker jobs", async () => { expect(await media.mediaSpaceOf("source", a.sourceId)).toBe(owner.spaceId); expect(await media.mediaSpaceOf("asset", a.assetId)).toBe(owner.spaceId); expect(await media.mediaSpaceOf("render", a.renderId)).toBe(owner.spaceId); expect(await media.mediaSpaceOf("publication", a.publicationId)).toBe(owner.spaceId); expect(await media.mediaSpaceOf("asset", "missing")).toBeNull(); }); it("deleting a space removes every media row of it and nothing else", async () => { const other = await db.newUser(); const seeded = await seedMediaSpace(media, other.spaceId, other.userId); expect(seeded.assetId).toBeTruthy(); const tables = [ "media_sources", "media_assets", "media_uploads", "media_segments", "media_analyses", "media_collections", "media_people", "media_face_tracks", "media_marks", "media_brand_kits", "media_projects", "media_edit_plans", "media_renders", "media_publications", "media_metrics", "media_metric_mappings", "media_teaser_models", ]; async function counts(spaceId: string) { const out: Record = {}; for (const table of tables) { const rows = await db.prisma.$queryRawUnsafe>( `SELECT count(*)::int AS count FROM ${table} WHERE "spaceId" = $1`, spaceId, ); out[table] = rows[0]?.count ?? -1; } const items = await db.prisma.$queryRawUnsafe>( `SELECT count(*)::int AS count FROM media_collection_items ci JOIN media_assets a ON a.id = ci."assetId" WHERE a."spaceId" = $1`, spaceId, ); out.media_collection_items = items[0]?.count ?? -1; return out; } const populated = await counts(other.spaceId); for (const table of tables) expect(populated[table], table).toBeGreaterThan(0); const ownerBefore = await counts(owner.spaceId); const itemCountBefore = await db.prisma.mediaCollectionItem.count(); await db.prisma.space.delete({ where: { id: other.spaceId } }); const after = await counts(other.spaceId); for (const [table, count] of Object.entries(after)) expect(count, table).toBe(0); expect(await db.prisma.mediaCollectionItem.count()).toBe( itemCountBefore - (populated.media_collection_items ?? 0), ); expect(await counts(owner.spaceId)).toEqual(ownerBefore); }); });