334 lines
12 KiB
TypeScript
334 lines
12 KiB
TypeScript
import { mkdir, mkdtemp, rm, symlink } 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 { MediaToolInputSchemas, mediaRpcShapes } from "@rakazo/contracts";
|
|
import type { MediaRepos, PrismaClient } from "@rakazo/db";
|
|
import { InvalidMediaInputError, IsolationError, MediaStateConflictError } from "@rakazo/db";
|
|
import { PathOutsideRootError } from "@rakazo/media/node";
|
|
import { Hono } from "hono";
|
|
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
|
import {
|
|
createMediaRouter,
|
|
MEDIA_NOT_CONFIGURED,
|
|
mediaRpcError,
|
|
unconfiguredCapabilities,
|
|
validateMediaFolder,
|
|
} from "./media.js";
|
|
import { mountMediaHttpRoutes, parseByteRange } from "./media-http.js";
|
|
import type { MediaSampleIds } from "./media-test-fakes.js";
|
|
import { fakeMediaServices, mediaProcedurePaths, sampleMediaInputs } from "./media-test-fakes.js";
|
|
import { expectedChunkBytes, uploadChunkCount } from "./media-uploads.js";
|
|
|
|
const member: Actor = {
|
|
userId: "user-member",
|
|
spaceId: "space-a",
|
|
email: "member@rakazo.test",
|
|
isDeploymentOwner: false,
|
|
};
|
|
const owner: Actor = { ...member, userId: "user-owner", isDeploymentOwner: true };
|
|
|
|
const ids = Object.fromEntries(
|
|
[
|
|
"sourceId",
|
|
"assetId",
|
|
"secondAssetId",
|
|
"segmentId",
|
|
"trackId",
|
|
"personId",
|
|
"markId",
|
|
"collectionId",
|
|
"brandKitId",
|
|
"projectId",
|
|
"planId",
|
|
"renderId",
|
|
"publicationId",
|
|
"mappingId",
|
|
"uploadId",
|
|
].map((key) => [key, `id-${key.toLowerCase()}`]),
|
|
) as unknown as MediaSampleIds;
|
|
const kit = {
|
|
name: "Kit",
|
|
colors: {
|
|
primary: "#101010",
|
|
secondary: "#202020",
|
|
accent: "#303030",
|
|
text: "#f0f0f0",
|
|
background: "#000000",
|
|
},
|
|
};
|
|
|
|
function client(router: ReturnType<typeof createMediaRouter>, actor: Actor | null) {
|
|
return createRouterClient(router, { context: { actor } });
|
|
}
|
|
|
|
function procedure(target: unknown, rpcPath: string) {
|
|
let node: unknown = target;
|
|
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";
|
|
}
|
|
|
|
/** Repositories that fail the test when reached. */
|
|
function untouchedRepos(): MediaRepos {
|
|
return new Proxy({} as MediaRepos, {
|
|
get: (_target, key) => () => {
|
|
throw new Error(`repository ${String(key)} must not be called`);
|
|
},
|
|
});
|
|
}
|
|
|
|
const prismaStub = {} as PrismaClient;
|
|
|
|
describe("media router without a configured sidecar", () => {
|
|
const router = createMediaRouter({ prisma: prismaStub, services: null, dataDir: "/data" });
|
|
const inputs = sampleMediaInputs(ids, kit);
|
|
|
|
it("has a sample input for every media procedure", () => {
|
|
expect(Object.keys(inputs).sort()).toEqual(mediaProcedurePaths().sort());
|
|
});
|
|
|
|
it("reports media as not configured so every surface keeps it hidden", async () => {
|
|
const capabilities = await client(router, member).capabilities({});
|
|
expect(capabilities).toEqual(unconfiguredCapabilities());
|
|
expect(capabilities.configured).toBe(false);
|
|
expect(
|
|
Object.values(capabilities.operations).every(
|
|
(op) => !(op as { available: boolean }).available,
|
|
),
|
|
).toBe(true);
|
|
});
|
|
|
|
it("refuses every other procedure", async () => {
|
|
for (const [rpcPath, sample] of Object.entries(inputs)) {
|
|
if (rpcPath === "capabilities") continue;
|
|
const promise = procedure(client(router, member), rpcPath)(sample.input);
|
|
await expect(promise, rpcPath).rejects.toMatchObject({
|
|
code: "PRECONDITION_FAILED",
|
|
message: MEDIA_NOT_CONFIGURED,
|
|
});
|
|
}
|
|
});
|
|
|
|
it("requires a signed-in member for every procedure", async () => {
|
|
for (const [rpcPath, sample] of Object.entries(inputs)) {
|
|
const code = await rpcCode(procedure(client(router, null), rpcPath)(sample.input));
|
|
expect(code, rpcPath).toBe("UNAUTHORIZED");
|
|
}
|
|
});
|
|
|
|
it("answers the byte routes with 404 and anonymous callers with 401", async () => {
|
|
const app = new Hono();
|
|
let actor: Actor | null = member;
|
|
mountMediaHttpRoutes(app, { services: null }, async () => actor);
|
|
const requests = [
|
|
new Request("http://api/api/media/uploads/u1/chunks/0", { method: "PUT", body: "abcd" }),
|
|
new Request("http://api/api/media/renders/r1/files/video.mp4"),
|
|
new Request("http://api/api/media/assets/a1/thumb"),
|
|
new Request("http://api/api/media/assets/a1/proxy"),
|
|
new Request("http://api/api/media/bundles/p1"),
|
|
];
|
|
for (const request of requests) {
|
|
expect((await app.request(request.clone())).status, request.url).toBe(404);
|
|
}
|
|
actor = null;
|
|
for (const request of requests) {
|
|
expect((await app.request(request.clone())).status, request.url).toBe(401);
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("media router permissions", () => {
|
|
let root: string;
|
|
|
|
beforeAll(async () => {
|
|
root = await mkdtemp(path.join(tmpdir(), "media-api-"));
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await rm(root, { recursive: true, force: true });
|
|
});
|
|
|
|
it("lets only the deployment owner add a folder source", async () => {
|
|
const created: unknown[] = [];
|
|
const repos = new Proxy({} as MediaRepos, {
|
|
get: (_target, key) => {
|
|
if (key === "createSource") {
|
|
return async (_spaceId: string, input: unknown) => {
|
|
created.push(input);
|
|
return {
|
|
id: "src-1",
|
|
kind: "folder",
|
|
label: "Footage",
|
|
status: "idle",
|
|
assetCount: 0,
|
|
lastSyncedAt: null,
|
|
error: null,
|
|
createdAt: "2026-10-01T00:00:00.000Z",
|
|
};
|
|
};
|
|
}
|
|
return () => {
|
|
throw new Error(`repository ${String(key)} must not be called`);
|
|
};
|
|
},
|
|
});
|
|
const { services, calls } = fakeMediaServices({ repos, mediaRoot: path.join(root, "media") });
|
|
const footage = path.join(root, "footage");
|
|
await mkdir(footage);
|
|
const router = createMediaRouter({
|
|
prisma: prismaStub,
|
|
services,
|
|
dataDir: path.join(root, "data"),
|
|
appRoot: path.join(root, "app"),
|
|
});
|
|
|
|
const denied = client(router, member).sources.createFolder({ label: "Footage", path: footage });
|
|
await expect(denied).rejects.toMatchObject({ code: "FORBIDDEN" });
|
|
expect(created).toEqual([]);
|
|
expect(calls).toEqual([]);
|
|
|
|
await client(router, owner).sources.createFolder({ label: "Footage", path: footage });
|
|
expect(created).toEqual([
|
|
{ kind: "folder", label: "Footage", ownerUserId: owner.userId, config: { path: footage } },
|
|
]);
|
|
expect(calls.map((call) => `${call.service}.${call.method}`)).toEqual(["ingest.requestSync"]);
|
|
});
|
|
|
|
it("accepts only a real, specific folder apart from evOo's own directories", async () => {
|
|
const dataDir = path.join(root, "data");
|
|
const appRoot = path.join(root, "app");
|
|
const folder = path.join(root, "clips");
|
|
await mkdir(path.join(dataDir, "media"), { recursive: true });
|
|
await mkdir(path.join(appRoot, "packages"), { recursive: true });
|
|
await mkdir(folder, { recursive: true });
|
|
await symlink(folder, path.join(root, "clips-link"));
|
|
const forbidden = { dataDir, appRoot };
|
|
|
|
await expect(validateMediaFolder(folder, forbidden)).resolves.toBe(folder);
|
|
const rejected = [
|
|
"clips",
|
|
path.join(root, "missing"),
|
|
path.join(root, "clips-link"),
|
|
"/",
|
|
dataDir,
|
|
path.join(dataDir, "media"),
|
|
root,
|
|
path.join(appRoot, "packages"),
|
|
];
|
|
for (const candidate of rejected) {
|
|
await expect(validateMediaFolder(candidate, forbidden), candidate).rejects.toBeInstanceOf(
|
|
InvalidMediaInputError,
|
|
);
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("consent stays with the signed-in user", () => {
|
|
it("never accepts consent or age fields from the agent's people tool", () => {
|
|
const schema = MediaToolInputSchemas.media_people;
|
|
expect(schema.shape.action.options).toEqual(["list", "update", "merge"]);
|
|
const parsed = schema.parse({
|
|
action: "update",
|
|
personId: "person-1",
|
|
consentArtifactId: "artifact-1",
|
|
consentRecordedAt: "2026-09-01T00:00:00.000Z",
|
|
ageVerificationMethod: "platform_verified",
|
|
ageVerifiedAt: "2026-09-01T00:00:00.000Z",
|
|
});
|
|
expect(Object.keys(parsed).sort()).toEqual(["action", "personId"]);
|
|
});
|
|
|
|
it("drops consent fields sent to people.update", async () => {
|
|
const patches: unknown[] = [];
|
|
const repos = new Proxy({} as MediaRepos, {
|
|
get: (_target, key) => {
|
|
if (key === "updatePerson") {
|
|
return async (_spaceId: string, _personId: string, patch: unknown) => {
|
|
patches.push(patch);
|
|
return {};
|
|
};
|
|
}
|
|
return () => {
|
|
throw new Error(`repository ${String(key)} must not be called`);
|
|
};
|
|
},
|
|
});
|
|
const { services } = fakeMediaServices({ repos, mediaRoot: "/unused" });
|
|
const router = createMediaRouter({ prisma: prismaStub, services, dataDir: "/data" });
|
|
await procedure(
|
|
client(router, member),
|
|
"people/update",
|
|
)({
|
|
personId: "person-1",
|
|
displayName: "Person 1",
|
|
consentRecordedAt: "2026-09-01T00:00:00.000Z",
|
|
ageVerificationMethod: "platform_verified",
|
|
ageVerifiedAt: "2026-09-01T00:00:00.000Z",
|
|
}).catch(() => undefined);
|
|
expect(patches).toHaveLength(1);
|
|
expect(Object.entries(patches[0] as object).filter(([, value]) => value !== undefined)).toEqual(
|
|
[["displayName", "Person 1"]],
|
|
);
|
|
});
|
|
|
|
it("marks recordConsent as the only user-only write and keeps it out of the services", () => {
|
|
const people = mediaRpcShapes.people as Record<string, unknown>;
|
|
expect(Object.keys(people)).toContain("recordConsent");
|
|
const { services } = fakeMediaServices({ repos: untouchedRepos(), mediaRoot: "/unused" });
|
|
const serviceMethods = (["ingest", "privacy", "render", "marketing"] as const).flatMap((name) =>
|
|
Object.keys(services[name] as unknown as Record<string, unknown>),
|
|
);
|
|
expect(serviceMethods.filter((method) => /consent|age/i.test(method))).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe("media error mapping", () => {
|
|
it("never reveals whether a foreign id exists", () => {
|
|
expect((mediaRpcError(new IsolationError()) as ORPCError<string, unknown>).code).toBe(
|
|
"NOT_FOUND",
|
|
);
|
|
expect(
|
|
(mediaRpcError(new PathOutsideRootError("../x")) as ORPCError<string, unknown>).code,
|
|
).toBe("BAD_REQUEST");
|
|
expect(
|
|
(mediaRpcError(new MediaStateConflictError("busy")) as ORPCError<string, unknown>).code,
|
|
).toBe("CONFLICT");
|
|
const other = new Error("boom");
|
|
expect(mediaRpcError(other)).toBe(other);
|
|
});
|
|
});
|
|
|
|
describe("upload and streaming helpers", () => {
|
|
it("splits uploads into full chunks plus a shorter last one", () => {
|
|
const row = { sizeBytes: 10n, chunkBytes: 4 };
|
|
expect(uploadChunkCount(row)).toBe(3);
|
|
expect([0, 1, 2].map((index) => expectedChunkBytes(row, index))).toEqual([4, 4, 2]);
|
|
expect(uploadChunkCount({ sizeBytes: 8n, chunkBytes: 4 })).toBe(2);
|
|
expect(expectedChunkBytes({ sizeBytes: 8n, chunkBytes: 4 }, 1)).toBe(4);
|
|
});
|
|
|
|
it("parses single byte ranges and refuses unsatisfiable ones", () => {
|
|
expect(parseByteRange(undefined, 100)).toBeNull();
|
|
expect(parseByteRange("bytes=0-9", 100)).toEqual({ start: 0, end: 9 });
|
|
expect(parseByteRange("bytes=90-", 100)).toEqual({ start: 90, end: 99 });
|
|
expect(parseByteRange("bytes=-10", 100)).toEqual({ start: 90, end: 99 });
|
|
expect(parseByteRange("bytes=50-500", 100)).toEqual({ start: 50, end: 99 });
|
|
expect(parseByteRange("bytes=0-1,5-6", 100)).toBeNull();
|
|
expect(parseByteRange("bytes=100-", 100)).toBe("unsatisfiable");
|
|
expect(parseByteRange("bytes=9-3", 100)).toBe("unsatisfiable");
|
|
expect(parseByteRange("items=0-1", 100)).toBe("unsatisfiable");
|
|
expect(parseByteRange("bytes=-0", 100)).toBe("unsatisfiable");
|
|
});
|
|
});
|