evOo-local/packages/db/src/media-isolation.postgres.test.ts
2026-10-03 17:55:49 +00:00

429 lines
17 KiB
TypeScript

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<unknown>]> = [
["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<string, number> = {};
for (const table of tables) {
const rows = await db.prisma.$queryRawUnsafe<Array<{ count: number }>>(
`SELECT count(*)::int AS count FROM ${table} WHERE "spaceId" = $1`,
spaceId,
);
out[table] = rows[0]?.count ?? -1;
}
const items = await db.prisma.$queryRawUnsafe<Array<{ count: number }>>(
`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);
});
});