517 lines
21 KiB
TypeScript
517 lines
21 KiB
TypeScript
import { createHash } from "node:crypto";
|
|
import { mkdir, mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises";
|
|
import { tmpdir } from "node:os";
|
|
import path from "node:path";
|
|
import { createRouterClient, ORPCError } from "@orpc/server";
|
|
import type { Actor } from "@rakazo/contracts";
|
|
import type { MediaRepos } from "@rakazo/db";
|
|
import { createMediaRepos } from "@rakazo/db";
|
|
import { Hono } from "hono";
|
|
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
|
import type { MediaTestDb, SeededMediaSpace } from "../../../packages/db/src/media-fixtures.js";
|
|
import {
|
|
openMediaTestDb,
|
|
seedMediaSpace,
|
|
sourceItem,
|
|
testBrandKit,
|
|
} from "../../../packages/db/src/media-fixtures.js";
|
|
import { testProfile } from "../../../packages/media/src/test-fixtures.js";
|
|
import { createMediaRouter } from "./media.js";
|
|
import { mountMediaHttpRoutes } from "./media-http.js";
|
|
import type { MediaServiceCall } from "./media-test-fakes.js";
|
|
import { fakeMediaServices, sampleMediaInputs } from "./media-test-fakes.js";
|
|
import { MEDIA_UPLOAD_IDLE_MS } from "./media-uploads.js";
|
|
|
|
const databaseUrl = process.env.DATABASE_URL;
|
|
const describePostgres =
|
|
process.env.VERIFY_DATABASE && databaseUrl ? describe.sequential : describe.skip;
|
|
|
|
type Space = { userId: string; spaceId: string };
|
|
|
|
function actorOf(space: Space, isDeploymentOwner = false): Actor {
|
|
return {
|
|
userId: space.userId,
|
|
spaceId: space.spaceId,
|
|
email: `${space.userId}@rakazo.test`,
|
|
isDeploymentOwner,
|
|
};
|
|
}
|
|
|
|
function procedure(client: unknown, rpcPath: string) {
|
|
let node = client;
|
|
for (const key of rpcPath.split("/")) node = (node as Record<string, unknown>)[key];
|
|
return node as (input: unknown) => Promise<unknown>;
|
|
}
|
|
|
|
async function rpcCode(promise: Promise<unknown>): Promise<string> {
|
|
try {
|
|
await promise;
|
|
} catch (error) {
|
|
if (error instanceof ORPCError) return error.code;
|
|
throw error;
|
|
}
|
|
return "OK";
|
|
}
|
|
|
|
function sha256(bytes: Uint8Array): string {
|
|
return createHash("sha256").update(bytes).digest("hex");
|
|
}
|
|
|
|
describePostgres("media API authorization and uploads (PostgreSQL)", () => {
|
|
let db: MediaTestDb;
|
|
let repos: MediaRepos;
|
|
let mediaRoot: string;
|
|
let a: Space;
|
|
let b: Space;
|
|
let seededA: SeededMediaSpace;
|
|
let seededB: SeededMediaSpace;
|
|
let calls: MediaServiceCall[];
|
|
let renderOutputs: Map<string, string>;
|
|
let rpc: (actor: Actor) => unknown;
|
|
let app: Hono;
|
|
let currentActor: Actor | null;
|
|
|
|
function setup(options: { now?: () => Date; chunkTimeoutMs?: number } = {}) {
|
|
const fake = fakeMediaServices({
|
|
repos,
|
|
mediaRoot,
|
|
prisma: db.prisma,
|
|
profiles: [testProfile()],
|
|
renderOutputs,
|
|
...(options.now ? { now: options.now } : {}),
|
|
});
|
|
calls = fake.calls;
|
|
const router = createMediaRouter({
|
|
prisma: db.prisma,
|
|
services: fake.services,
|
|
dataDir: path.join(mediaRoot, ".."),
|
|
});
|
|
rpc = (actor) => createRouterClient(router, { context: { actor } });
|
|
app = new Hono();
|
|
mountMediaHttpRoutes(
|
|
app,
|
|
{
|
|
services: fake.services,
|
|
...(options.chunkTimeoutMs ? { chunkTimeoutMs: options.chunkTimeoutMs } : {}),
|
|
},
|
|
async () => currentActor,
|
|
);
|
|
}
|
|
|
|
async function snapshot(spaceId: string) {
|
|
const p = db.prisma;
|
|
const where = { spaceId };
|
|
const orderBy = { id: "asc" as const };
|
|
return JSON.stringify(
|
|
[
|
|
await p.mediaSource.findMany({ where, orderBy }),
|
|
await p.mediaAsset.findMany({ where, orderBy }),
|
|
await p.mediaUpload.findMany({ where, orderBy }),
|
|
await p.mediaSegment.findMany({ where, orderBy }),
|
|
await p.mediaAnalysis.findMany({ where, orderBy }),
|
|
await p.mediaCollection.findMany({ where, orderBy }),
|
|
await p.mediaCollectionItem.findMany({
|
|
where: { collection: where },
|
|
orderBy: [{ collectionId: "asc" }, { assetId: "asc" }],
|
|
}),
|
|
await p.mediaPerson.findMany({ where, orderBy }),
|
|
await p.mediaFaceTrack.findMany({ where, orderBy }),
|
|
await p.mediaMark.findMany({ where, orderBy }),
|
|
await p.mediaBrandKit.findMany({ where, orderBy }),
|
|
await p.mediaProject.findMany({ where, orderBy }),
|
|
await p.mediaEditPlan.findMany({ where, orderBy }),
|
|
await p.mediaRender.findMany({ where, orderBy }),
|
|
await p.mediaPublication.findMany({ where, orderBy }),
|
|
await p.mediaMetric.findMany({ where, orderBy }),
|
|
await p.mediaMetricMapping.findMany({ where, orderBy }),
|
|
],
|
|
(_key, value) => (typeof value === "bigint" ? value.toString() : value),
|
|
);
|
|
}
|
|
|
|
async function artifactIn(space: Space): Promise<string> {
|
|
const artifact = await db.prisma.artifact.create({
|
|
data: {
|
|
spaceId: space.spaceId,
|
|
userId: space.userId,
|
|
name: "consent.pdf",
|
|
mimeType: "application/pdf",
|
|
size: 1,
|
|
hash: "0".repeat(64),
|
|
storageKey: `test/${space.spaceId}/consent.pdf`,
|
|
},
|
|
});
|
|
return artifact.id;
|
|
}
|
|
|
|
/** A device item in `space` that waits for its original. */
|
|
async function remoteAsset(space: Space, index: number): Promise<string> {
|
|
const source = await procedure(
|
|
rpc(actorOf(space)),
|
|
"sources/registerClient",
|
|
)({ kind: "ios_library", label: "Phone", deviceId: `device-${index}` });
|
|
const synced = await repos.upsertSourceItems(space.spaceId, (source as { id: string }).id, [
|
|
sourceItem(index),
|
|
]);
|
|
const assetId = synced.changedAssetIds[0] as string;
|
|
await repos.requestUpload(space.spaceId, [assetId]);
|
|
return assetId;
|
|
}
|
|
|
|
function putChunk(uploadId: string, index: number, body: BodyInit, actor: Actor | null) {
|
|
currentActor = actor;
|
|
return app.request(`/api/media/uploads/${uploadId}/chunks/${index}`, { method: "PUT", body });
|
|
}
|
|
|
|
beforeAll(async () => {
|
|
db = openMediaTestDb(databaseUrl!);
|
|
repos = createMediaRepos(db.prisma);
|
|
const root = await mkdtemp(path.join(tmpdir(), "media-api-pg-"));
|
|
mediaRoot = path.join(root, "media");
|
|
await mkdir(mediaRoot);
|
|
renderOutputs = new Map();
|
|
a = await db.newUser();
|
|
b = await db.newUser();
|
|
seededA = await seedMediaSpace(repos, a.spaceId, a.userId);
|
|
seededB = await seedMediaSpace(repos, b.spaceId, b.userId);
|
|
setup();
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await db?.cleanup();
|
|
if (mediaRoot) await rm(path.dirname(mediaRoot), { recursive: true, force: true });
|
|
});
|
|
|
|
it("refuses another space's ids in every media procedure and leaves that space untouched", async () => {
|
|
const before = await snapshot(a.spaceId);
|
|
const foreignIds = Object.values(seededA);
|
|
const intruder = rpc(actorOf(b));
|
|
const inputs = sampleMediaInputs(seededA, testBrandKit);
|
|
for (const [rpcPath, sample] of Object.entries(inputs)) {
|
|
if (sample.foreign === "dedicated") continue;
|
|
const call = procedure(intruder, rpcPath)(sample.input);
|
|
if (sample.foreign === "not_found") {
|
|
expect(await rpcCode(call), rpcPath).toBe("NOT_FOUND");
|
|
continue;
|
|
}
|
|
const result = JSON.stringify(await call);
|
|
for (const id of foreignIds) expect(result, rpcPath).not.toContain(id);
|
|
}
|
|
expect(await snapshot(a.spaceId)).toBe(before);
|
|
// No service ever received the other space's ids.
|
|
const leaked = calls.filter((call) =>
|
|
foreignIds.some((id) => JSON.stringify(call.input ?? null).includes(id)),
|
|
);
|
|
expect(leaked).toEqual([]);
|
|
expect(calls.every((call) => call.actor.spaceId === b.spaceId)).toBe(true);
|
|
});
|
|
|
|
it("serves the same inputs inside the caller's own space", async () => {
|
|
// Positive control: NOT_FOUND above comes from isolation, not from bad samples.
|
|
const fabricated = new Set(["collections/merge", "people/merge"]);
|
|
for (const [rpcPath, sample] of Object.entries(sampleMediaInputs(seededA, testBrandKit))) {
|
|
if (sample.foreign !== "not_found" || fabricated.has(rpcPath)) continue;
|
|
const space = await db.newUser();
|
|
const seeded = await seedMediaSpace(repos, space.spaceId, space.userId);
|
|
const input = sampleMediaInputs(seeded, testBrandKit)[rpcPath]?.input;
|
|
const code = await rpcCode(procedure(rpc(actorOf(space)), rpcPath)(input));
|
|
expect(code, rpcPath).not.toBe("NOT_FOUND");
|
|
}
|
|
}, 120_000);
|
|
|
|
it("refuses another space's records referenced from the caller's own ones", async () => {
|
|
const own = rpc(actorOf(b));
|
|
const foreignArtifact = await artifactIn(a);
|
|
const attempts: Array<[string, unknown]> = [
|
|
["projects/update", { projectId: seededB.projectId, collectionIds: [seededA.collectionId] }],
|
|
["projects/update", { projectId: seededB.projectId, brandKitId: seededA.brandKitId }],
|
|
[
|
|
"projects/create",
|
|
{
|
|
title: "Mixed",
|
|
goal: "teaser",
|
|
assetIds: [seededA.assetId],
|
|
profileIds: ["example-short-vertical"],
|
|
},
|
|
],
|
|
["people/assignTrack", { trackId: seededB.trackId, personId: seededA.personId }],
|
|
[
|
|
"marks/create",
|
|
{
|
|
kind: "tattoo",
|
|
assetId: seededB.assetId,
|
|
personId: seededA.personId,
|
|
prompt: { tMs: 0 },
|
|
treatment: "blur",
|
|
},
|
|
],
|
|
["marks/update", { markId: seededB.markId, personId: seededA.personId }],
|
|
[
|
|
"assets/setPaidOnly",
|
|
{ segmentIds: [seededB.segmentId, seededA.segmentId], paidOnly: true },
|
|
],
|
|
["collections/merge", { collectionIds: [seededB.collectionId, seededA.collectionId] }],
|
|
["people/merge", { personIds: [seededB.personId, seededA.personId] }],
|
|
["brandKits/upsert", { kit: { ...testBrandKit, logoArtifactIds: [foreignArtifact] } }],
|
|
[
|
|
"people/recordConsent",
|
|
{
|
|
personId: seededB.personId,
|
|
consentArtifactId: foreignArtifact,
|
|
consentRecordedAt: "2026-09-01T10:00:00.000Z",
|
|
ageVerificationMethod: "document_on_file",
|
|
ageVerifiedAt: "2026-09-01T10:00:00.000Z",
|
|
},
|
|
],
|
|
];
|
|
const before = await snapshot(a.spaceId);
|
|
for (const [rpcPath, input] of attempts) {
|
|
expect(await rpcCode(procedure(own, rpcPath)(input)), rpcPath).toBe("NOT_FOUND");
|
|
}
|
|
expect(await snapshot(a.spaceId)).toBe(before);
|
|
const person = await db.prisma.mediaPerson.findUniqueOrThrow({
|
|
where: { id: seededB.personId },
|
|
});
|
|
expect(person.consentRecordedAt).toBeNull();
|
|
});
|
|
|
|
it("records consent only as the signed-in member and ignores it on updates", async () => {
|
|
const space = await db.newUser();
|
|
const seeded = await seedMediaSpace(repos, space.spaceId, space.userId);
|
|
const client = rpc(actorOf(space));
|
|
await procedure(
|
|
client,
|
|
"people/update",
|
|
)({
|
|
personId: seeded.personId,
|
|
displayName: "Person 1",
|
|
consentRecordedAt: "2026-09-01T10:00:00.000Z",
|
|
ageVerificationMethod: "platform_verified",
|
|
ageVerifiedAt: "2026-09-01T10:00:00.000Z",
|
|
});
|
|
let person = await db.prisma.mediaPerson.findUniqueOrThrow({ where: { id: seeded.personId } });
|
|
expect(person.consentRecordedAt).toBeNull();
|
|
expect(person.ageVerifiedAt).toBeNull();
|
|
|
|
const artifactId = await artifactIn(space);
|
|
await procedure(
|
|
client,
|
|
"people/recordConsent",
|
|
)({
|
|
personId: seeded.personId,
|
|
consentArtifactId: artifactId,
|
|
consentRecordedAt: "2026-09-01T10:00:00.000Z",
|
|
ageVerificationMethod: "platform_verified",
|
|
ageVerifiedAt: "2026-09-01T10:00:00.000Z",
|
|
});
|
|
person = await db.prisma.mediaPerson.findUniqueOrThrow({ where: { id: seeded.personId } });
|
|
expect(person.consentRecordedBy).toBe(space.userId);
|
|
expect(person.consentArtifactId).toBe(artifactId);
|
|
expect(person.ageVerificationMethod).toBe("platform_verified");
|
|
});
|
|
|
|
it("assembles a resumable chunked upload and hands it to ingest", async () => {
|
|
const assetId = await remoteAsset(b, 101);
|
|
const bytes = new TextEncoder().encode("0123456789");
|
|
const upload = await repos.beginUpload(b.spaceId, {
|
|
assetId,
|
|
userId: b.userId,
|
|
sizeBytes: bytes.length,
|
|
sha256: sha256(bytes),
|
|
fileName: "clip.mp4",
|
|
chunkBytes: 4,
|
|
});
|
|
const owner = actorOf(b);
|
|
|
|
expect((await putChunk(upload.id, 0, bytes.slice(0, 4), actorOf(a))).status).toBe(404);
|
|
expect((await putChunk(upload.id, 0, bytes.slice(0, 4), null)).status).toBe(401);
|
|
expect((await putChunk(upload.id, 3, bytes.slice(0, 2), owner)).status).toBe(400);
|
|
expect((await putChunk(upload.id, 0, bytes.slice(0, 5), owner)).status).toBe(413);
|
|
expect((await putChunk(upload.id, 0, bytes.slice(0, 3), owner)).status).toBe(400);
|
|
|
|
// Out of order and repeated chunks are fine; the client resumes from status.
|
|
expect((await putChunk(upload.id, 2, bytes.slice(8), owner)).status).toBe(200);
|
|
expect((await putChunk(upload.id, 0, bytes.slice(0, 4), owner)).status).toBe(200);
|
|
expect((await putChunk(upload.id, 0, bytes.slice(0, 4), owner)).status).toBe(200);
|
|
const client = rpc(owner);
|
|
const partial = (await procedure(client, "uploads/status")({ uploadId: upload.id })) as {
|
|
receivedChunks: number[];
|
|
};
|
|
expect([...partial.receivedChunks].sort()).toEqual([0, 2]);
|
|
expect(await rpcCode(procedure(client, "uploads/complete")({ uploadId: upload.id }))).toBe(
|
|
"CONFLICT",
|
|
);
|
|
|
|
expect((await putChunk(upload.id, 1, bytes.slice(4, 8), owner)).status).toBe(200);
|
|
calls.length = 0;
|
|
const done = (await procedure(client, "uploads/complete")({ uploadId: upload.id })) as {
|
|
status: string;
|
|
};
|
|
expect(done.status).toBe("complete");
|
|
const handed = calls.filter((call) => call.method === "uploadCompleted");
|
|
expect(handed).toHaveLength(1);
|
|
const input = handed[0]?.input as { tempPath: string; sha256: string; assetId: string };
|
|
expect(input.assetId).toBe(assetId);
|
|
expect(input.sha256).toBe(sha256(bytes));
|
|
expect(input.tempPath.startsWith(path.join(mediaRoot, b.spaceId, "uploads"))).toBe(true);
|
|
expect(await readFile(input.tempPath, "utf8")).toBe("0123456789");
|
|
const asset = await repos.getAssetRecord(b.spaceId, assetId);
|
|
expect(asset.storage).toBe("uploaded");
|
|
expect(asset.storagePath).toBe(`${b.spaceId}/uploads/${upload.id}/file`);
|
|
// Completing again is idempotent and does not hand the file over twice.
|
|
await procedure(client, "uploads/complete")({ uploadId: upload.id });
|
|
expect(calls.filter((call) => call.method === "uploadCompleted")).toHaveLength(1);
|
|
expect((await putChunk(upload.id, 0, bytes.slice(0, 4), owner)).status).toBe(409);
|
|
});
|
|
|
|
it("opens uploads through the RPC only for items that wait for their original", async () => {
|
|
const client = rpc(actorOf(b));
|
|
const assetId = await remoteAsset(b, 102);
|
|
const input = { assetId, sizeBytes: 10, sha256: "a".repeat(64), fileName: "clip.mp4" };
|
|
const first = (await procedure(client, "uploads/begin")(input)) as { id: string };
|
|
const resumed = (await procedure(client, "uploads/begin")(input)) as { id: string };
|
|
expect(resumed.id).toBe(first.id);
|
|
const referenced = { ...input, assetId: seededB.assetId };
|
|
expect(await rpcCode(procedure(client, "uploads/begin")(referenced))).toBe("CONFLICT");
|
|
const tooLarge = { ...input, sizeBytes: 21 * 1024 * 1024 * 1024 };
|
|
expect(await rpcCode(procedure(client, "uploads/begin")(tooLarge))).toBe("BAD_REQUEST");
|
|
});
|
|
|
|
it("fails an upload whose bytes do not match the announced checksum", async () => {
|
|
const assetId = await remoteAsset(b, 103);
|
|
const bytes = new TextEncoder().encode("abcdef");
|
|
const upload = await repos.beginUpload(b.spaceId, {
|
|
assetId,
|
|
userId: b.userId,
|
|
sizeBytes: bytes.length,
|
|
sha256: "f".repeat(64),
|
|
fileName: "clip.mp4",
|
|
chunkBytes: 4,
|
|
});
|
|
const owner = actorOf(b);
|
|
await putChunk(upload.id, 0, bytes.slice(0, 4), owner);
|
|
await putChunk(upload.id, 1, bytes.slice(4), owner);
|
|
const client = rpc(owner);
|
|
expect(await rpcCode(procedure(client, "uploads/complete")({ uploadId: upload.id }))).toBe(
|
|
"BAD_REQUEST",
|
|
);
|
|
const status = (await procedure(client, "uploads/status")({ uploadId: upload.id })) as {
|
|
status: string;
|
|
};
|
|
expect(status.status).toBe("failed");
|
|
const dir = path.join(mediaRoot, b.spaceId, "uploads", upload.id);
|
|
await expect(stat(dir)).rejects.toMatchObject({ code: "ENOENT" });
|
|
});
|
|
|
|
it("expires an idle upload and starts a fresh one", async () => {
|
|
const assetId = await remoteAsset(b, 104);
|
|
const input = { assetId, sizeBytes: 10, sha256: "b".repeat(64), fileName: "clip.mp4" };
|
|
const first = (await procedure(rpc(actorOf(b)), "uploads/begin")(input)) as { id: string };
|
|
setup({ now: () => new Date(Date.now() + MEDIA_UPLOAD_IDLE_MS + 60_000) });
|
|
try {
|
|
const response = await putChunk(first.id, 0, new Uint8Array(10), actorOf(b));
|
|
expect(response.status).toBe(409);
|
|
const status = (await procedure(
|
|
rpc(actorOf(b)),
|
|
"uploads/status",
|
|
)({
|
|
uploadId: first.id,
|
|
})) as { status: string };
|
|
expect(status.status).toBe("failed");
|
|
const fresh = (await procedure(rpc(actorOf(b)), "uploads/begin")(input)) as { id: string };
|
|
expect(fresh.id).not.toBe(first.id);
|
|
} finally {
|
|
setup();
|
|
}
|
|
});
|
|
|
|
it("drops a chunk that does not arrive in time", async () => {
|
|
const assetId = await remoteAsset(b, 105);
|
|
const upload = await repos.beginUpload(b.spaceId, {
|
|
assetId,
|
|
userId: b.userId,
|
|
sizeBytes: 10,
|
|
sha256: "c".repeat(64),
|
|
fileName: "clip.mp4",
|
|
});
|
|
setup({ chunkTimeoutMs: 50 });
|
|
try {
|
|
const stalled = new ReadableStream<Uint8Array>({
|
|
start(controller) {
|
|
controller.enqueue(new Uint8Array(2));
|
|
},
|
|
});
|
|
currentActor = actorOf(b);
|
|
const response = await app.request(`/api/media/uploads/${upload.id}/chunks/0`, {
|
|
method: "PUT",
|
|
body: stalled,
|
|
duplex: "half",
|
|
} as RequestInit);
|
|
expect(response.status).toBe(408);
|
|
const status = await repos.uploadStatus(b.spaceId, upload.id);
|
|
expect(status.receivedChunks).toEqual([]);
|
|
} finally {
|
|
setup();
|
|
}
|
|
});
|
|
|
|
it("streams render files only inside the caller's space and render folder", async () => {
|
|
const renderDir = path.join(mediaRoot, a.spaceId, "renders", seededA.renderId);
|
|
await mkdir(renderDir, { recursive: true });
|
|
const video = path.join(renderDir, "video.mp4");
|
|
await writeFile(video, "0123456789");
|
|
renderOutputs.set(seededA.renderId, video);
|
|
const url = `/api/media/renders/${seededA.renderId}/files/video.mp4`;
|
|
|
|
currentActor = actorOf(a);
|
|
const full = await app.request(url);
|
|
expect(full.status).toBe(200);
|
|
expect(await full.text()).toBe("0123456789");
|
|
const ranged = await app.request(url, { headers: { range: "bytes=2-4" } });
|
|
expect(ranged.status).toBe(206);
|
|
expect(ranged.headers.get("content-range")).toBe("bytes 2-4/10");
|
|
expect(await ranged.text()).toBe("234");
|
|
const download = await app.request(`${url}?download=1`);
|
|
expect(download.headers.get("content-disposition")).toContain("attachment");
|
|
|
|
// A render that has not passed its gate never leaves as a download.
|
|
const queuedVideo = path.join(mediaRoot, a.spaceId, "renders", seededA.queuedRenderId, "v.mp4");
|
|
await mkdir(path.dirname(queuedVideo), { recursive: true });
|
|
await writeFile(queuedVideo, "x");
|
|
renderOutputs.set(seededA.queuedRenderId, queuedVideo);
|
|
const queuedUrl = `/api/media/renders/${seededA.queuedRenderId}/files/v.mp4`;
|
|
expect((await app.request(queuedUrl)).status).toBe(200);
|
|
expect((await app.request(`${queuedUrl}?download=1`)).status).toBe(404);
|
|
|
|
// A path the service reports outside the render folder is refused.
|
|
const outside = path.join(mediaRoot, a.spaceId, "video.mp4");
|
|
await writeFile(outside, "secret");
|
|
renderOutputs.set(seededA.renderId, outside);
|
|
expect((await app.request(url)).status).toBe(404);
|
|
renderOutputs.set(seededA.renderId, video);
|
|
|
|
currentActor = actorOf(b);
|
|
expect((await app.request(url)).status).toBe(404);
|
|
expect((await app.request(`/api/media/assets/${seededA.assetId}/thumb`)).status).toBe(404);
|
|
expect((await app.request(`/api/media/bundles/${seededA.publicationId}`)).status).toBe(404);
|
|
});
|
|
|
|
it("refuses a folder source to members who do not own the deployment", async () => {
|
|
const folder = await mkdtemp(path.join(tmpdir(), "media-footage-"));
|
|
try {
|
|
const input = { label: "Footage", path: folder };
|
|
const member = procedure(rpc(actorOf(b)), "sources/createFolder")(input);
|
|
expect(await rpcCode(member)).toBe("FORBIDDEN");
|
|
const created = (await procedure(rpc(actorOf(b, true)), "sources/createFolder")(input)) as {
|
|
id: string;
|
|
kind: string;
|
|
};
|
|
expect(created.kind).toBe("folder");
|
|
const row = await db.prisma.mediaSource.findUniqueOrThrow({ where: { id: created.id } });
|
|
expect(row.ownerUserId).toBe(b.userId);
|
|
} finally {
|
|
await rm(folder, { recursive: true, force: true });
|
|
}
|
|
});
|
|
});
|