Contracts and core: - Feature gains auth and stop; FeatureFactory; core composeServer builds the context, runs the factories, then the job queue from the union of their jobs (R8/R14). startServer uses it; main.ts composes learner, import, studio, AI. - AppConfig/loadConfig own the layout and settings: courseDirs include <dataDir>/courses and <dataDir>/worktrees, studioReposDir, cacheDir, importRoots, uploadMaxMb, aiAllowedBaseUrls, transcribeUrl, python. resolveConfig derives the same for tests; cachePaths shares the importer's cache layout. - canView: the global author role views every course (R14); the learner, studio and AI packages all call it. - migrate(db, packages): core first, then packages in name order. - Media: nosniff on every response, a sandbox CSP on svg/html/js; a Range on an empty file is 416. - courseUrl/lessonUrl and the lesson route patterns live in @stc/contracts. Packages: - import, studio and AI read AppConfig instead of process.env; the importer refuses ids used by studio/repos. - learner migrations run through core migrate; library covers fall back to the importer's cached candidate behind a canView-checked route. - studio preview renders through the learner LessonView (anchors honoured, draft URLs); AI drafts go to the studio draft branch for studio courses (as the requesting user) and to <dataDir>/drafts otherwise, and the generator page says which. Web: tsconfig paths for @stc/<feature>/web browser entries; features.ts imports them by package name; nav.roles hides links by role. Tests: compose, config, canView, media headers, migrations order, covers, preview, nav, SPA bundle without server code, and an end-to-end flow on SQLite and Postgres. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
121 lines
6.9 KiB
TypeScript
121 lines
6.9 KiB
TypeScript
import { expect } from "bun:test";
|
|
import { tmpdir } from "node:os";
|
|
import { join } from "node:path";
|
|
import type { AuthUser, ServerContext } from "@stc/contracts";
|
|
import { createApp, createJobQueue, resolveConfig } from "@stc/core";
|
|
import { createCourseRepository } from "@stc/course-format";
|
|
import { forEachDb } from "../../db/test/helpers";
|
|
import { importFeature } from "../src";
|
|
import { mkdir, stat, symlink } from "node:fs/promises";
|
|
import { makeVideo, tmp } from "./fixtures";
|
|
|
|
const owner: AuthUser = { id: "o", email: "o@x", name: "O", roles: ["owner"], groups: [] };
|
|
const learner: AuthUser = { id: "l", email: "l@x", name: "L", roles: ["learner"], groups: [] };
|
|
|
|
async function setup(db: any, env: Record<string, string | undefined> = {}) {
|
|
const dataDir = await tmp("stc-data-");
|
|
const courses = await createCourseRepository([join(dataDir, "courses")]);
|
|
const ctx = {
|
|
config: resolveConfig({
|
|
databaseUrl: "", dataDir, courseDirs: [join(dataDir, "courses")], secret: "t", publicUrl: "",
|
|
python: process.env.STC_PYTHON || "python3",
|
|
importRoots: "STC_IMPORT_ROOTS" in env ? (env.STC_IMPORT_ROOTS ? [env.STC_IMPORT_ROOTS] : undefined) : [tmpdir()],
|
|
transcribeUrl: env.STC_TRANSCRIBE_URL ?? null,
|
|
}),
|
|
db, courses,
|
|
auth: { userFromRequest: async (req: Request) => ({ owner, learner }[req.headers.get("x-user") ?? ""] ?? null) },
|
|
} as unknown as ServerContext;
|
|
const feature = importFeature(ctx);
|
|
const queue = createJobQueue(db, feature.jobs!, { pollMs: 10 });
|
|
ctx.jobs = queue;
|
|
const app = createApp(ctx, [feature]);
|
|
const call = (path: string, init: RequestInit & { as?: string; json?: unknown } = {}) =>
|
|
app.fetch(new Request(`http://x${path}`, {
|
|
method: init.method, headers: { ...(init.as ? { "x-user": init.as } : {}), ...(init.json !== undefined ? { "content-type": "application/json" } : {}) },
|
|
body: init.json !== undefined ? JSON.stringify(init.json) : undefined,
|
|
}));
|
|
return { ctx, queue, call, dataDir };
|
|
}
|
|
const until = async (f: () => Promise<boolean>) => { for (let i = 0; i < 600; i++) { if (await f()) return; await Bun.sleep(20); } throw new Error("timeout"); };
|
|
|
|
forEachDb("only the owner reaches the import API", async (db) => {
|
|
const { call } = await setup(db);
|
|
expect((await call("/api/import/info")).status).toBe(401);
|
|
expect((await call("/api/import/info", { as: "learner" })).status).toBe(403);
|
|
expect((await call("/api/import/jobs", { as: "learner", method: "POST", json: {} })).status).toBe(403);
|
|
const r = await call("/api/import/info", { as: "owner" });
|
|
expect(await r.json()).toMatchObject({ transcribe: false, coursesDirServed: true });
|
|
});
|
|
|
|
forEachDb("job creation validates input", async (db) => {
|
|
const { call } = await setup(db);
|
|
const post = async (json: unknown) => call("/api/import/jobs", { as: "owner", method: "POST", json });
|
|
expect((await post({ kind: "nope", path: "/tmp" })).status).toBe(400);
|
|
expect((await post({ kind: "video-folder" })).status).toBe(400);
|
|
expect((await post({ kind: "video-folder", path: "/definitely/not/here" })).status).toBe(400);
|
|
expect((await post({ kind: "video-folder", path: "relative" })).status).toBe(400);
|
|
expect((await post({ kind: "pdf-deck", path: "/tmp", options: { courseId: "x" } })).status).toBe(400);
|
|
expect((await post({ kind: "code-repo", path: "/tmp" })).status).toBe(400);
|
|
expect((await post({ kind: "transcribe", options: { courseId: "x" } })).status).toBe(409);
|
|
});
|
|
|
|
forEachDb("a folder import runs through the real queue, then cover candidates can be chosen", async (db) => {
|
|
const { call, queue, dataDir } = await setup(db);
|
|
queue.start();
|
|
try {
|
|
const src = join(await tmp(), "Queue Videos");
|
|
await makeVideo(join(src, "1 - a.mp4"), 1);
|
|
await makeVideo(join(src, "2 - b.mp4"), 1);
|
|
const r = await call("/api/import/jobs", { as: "owner", method: "POST", json: { kind: "video-folder", path: src, options: { title: "Queued", bogus: 1 } } });
|
|
expect(r.status).toBe(202);
|
|
const { id } = (await r.json()) as { id: string };
|
|
await until(async () => ["done", "failed"].includes((await queue.get(id))!.status));
|
|
const job = (await queue.get(id))!;
|
|
expect(job.error).toBeNull();
|
|
expect(job.status).toBe("done");
|
|
expect(job.result).toMatchObject({ courseId: "queued", warnings: [] });
|
|
expect(job.payload).toEqual({ kind: "video-folder", path: src, options: { title: "Queued" } });
|
|
|
|
const list = (await (await call("/api/import/jobs", { as: "owner" })).json()) as any[];
|
|
expect(list.map((j) => j.id)).toContain(id);
|
|
const courses = (await (await call("/api/import/courses", { as: "owner" })).json()) as any[];
|
|
expect(courses).toEqual([{ id: "queued", title: "Queued", modules: [{ id: "m1", title: "Videos", lessons: [{ id: "l1", title: "a" }, { id: "l2", title: "b" }] }] }]);
|
|
|
|
const covers = (await (await call("/api/import/covers/queued", { as: "owner" })).json()) as any;
|
|
expect(covers.current).toBe("media/cover.jpg");
|
|
expect(covers.candidates).toEqual(["frame-1.jpg", "frame-2.jpg"]);
|
|
const img = await call("/api/import/covers/queued/frame-2.jpg", { as: "owner" });
|
|
expect(img.headers.get("content-type")).toBe("image/jpeg");
|
|
expect((await call("/api/import/covers/queued/frame-2.jpg")).status).toBe(401);
|
|
expect((await call("/api/import/covers/queued/..%2F..%2Fx.jpg", { as: "owner" })).status).toBe(404);
|
|
const set = await call("/api/import/covers/queued", { as: "owner", method: "POST", json: { name: "frame-2.jpg" } });
|
|
expect(set.status).toBe(200);
|
|
expect((await call("/api/import/covers/queued", { as: "owner", method: "POST", json: { name: "../evil.jpg" } })).status).toBe(400);
|
|
expect(await Bun.file(join(dataDir, "courses/queued/media/cover.jpg")).exists()).toBe(true);
|
|
} finally { await queue.stop(); }
|
|
});
|
|
|
|
forEachDb("paths must stay inside the import roots", async (db) => {
|
|
const inbox = await tmp("stc-inbox-");
|
|
const outside = await tmp("stc-outside-");
|
|
await mkdir(join(inbox, "ok"));
|
|
await symlink(outside, join(inbox, "escape"));
|
|
const { call, ctx } = await setup(db, { STC_IMPORT_ROOTS: inbox });
|
|
const post = (path: string) => call("/api/import/jobs", { as: "owner", method: "POST", json: { kind: "video-folder", path } });
|
|
const bad = await post(outside);
|
|
expect(bad.status).toBe(400);
|
|
expect(await bad.json()).toEqual({ error: "path is outside the allowed import folders" });
|
|
expect((await post(join(inbox, "..", "x"))).status).toBe(400);
|
|
expect((await post(join(inbox, "escape"))).status).toBe(400); // symlink out of the root
|
|
expect((await post("/etc")).status).toBe(400);
|
|
expect((await post(join(inbox, "ok"))).status).toBe(202);
|
|
const info = (await (await call("/api/import/info", { as: "owner" })).json()) as any;
|
|
expect(info.importRoots).toEqual([inbox]);
|
|
void ctx;
|
|
});
|
|
|
|
forEachDb("the default root is created under dataDir", async (db) => {
|
|
const { dataDir } = await setup(db, { STC_IMPORT_ROOTS: undefined });
|
|
expect((await stat(join(dataDir, "import-inbox"))).isDirectory()).toBe(true);
|
|
});
|