- Media exclusions move from the editable root .gitignore to the repo's info/exclude, and dirtyFiles never counts files listed in media.json, so an author's or remote's .gitignore cannot block publish or Sync. Publish and Sync refuse commits that track a file over referenced media. - Importer jobs (code-repo, pdf-deck, transcribe) and the cover route run under the course lock now shared with the studio (core withCourseLock) and re-check the course inside it; after conversion they fail with "this course is now edited in the studio". Convert answers 409 while an import job for the course is queued or running, and the job re-checks. - Crash-safe conversion: the import folder is hidden (.converting-<id>) before the published worktree exists; cross-filesystem moves copy into a staging dir, fsync, verify size and sha256, then rename; the source is retired as .converted-<id>.<n>. Recovery runs at server start and before every convert; a re-run of a finished conversion reports success. The loader skips dot-folders. - Minor: no raw NUL bytes in source, link fallback only on EXDEV/EPERM/EMLINK, referenced media are not read whole by the editor, missing media are logged, mediaDir JSDoc, docs on per-author copies across filesystems. Co-Authored-By: Claude Opus 5.5 <[email protected]>
175 lines
11 KiB
TypeScript
175 lines
11 KiB
TypeScript
import { afterEach, describe, expect, test } from "bun:test";
|
|
import { existsSync, readFileSync } from "node:fs";
|
|
import { join, resolve } from "node:path";
|
|
import { studioFeature } from "../src/server/feature";
|
|
import { SimulatedCrash } from "../src/server/convert";
|
|
import { createStudioStore } from "../src/server/store";
|
|
import { REPO, setupApp } from "./app-helpers";
|
|
import { makeImportedCourse, VIDEO, videoBytes } from "./import-fixture";
|
|
|
|
let env: Awaited<ReturnType<typeof setupApp>>;
|
|
afterEach(() => env?.stop());
|
|
|
|
const PG = process.env.STC_TEST_PG_URL;
|
|
const dbs: [string, string][] = [["sqlite", "sqlite://:memory:"], ...(PG ? [["postgres", PG] as [string, string]] : [])];
|
|
|
|
async function convert(as = "owner", id = "imp") {
|
|
const r = await env.call(as, "POST", `/api/studio/courses/${id}/convert`);
|
|
expect(r.status).toBe(202);
|
|
const job = await env.waitJob(r.json.jobId);
|
|
expect(job.error).toBeNull();
|
|
expect(job.status).toBe("done");
|
|
return job;
|
|
}
|
|
|
|
describe.each(dbs)("convert routes [%s]", (_name, dbUrl) => {
|
|
test("the owner converts; the course is then served from the published worktree, still restricted, ACL kept", async () => {
|
|
env = await setupApp([], dbUrl, (d) => makeImportedCourse(d));
|
|
const { call, ctx, db } = env;
|
|
await db.sql`INSERT INTO course_acl (course_id, principal_type, principal_id) VALUES ('imp', 'user', 'u-lea')`;
|
|
expect(ctx.courses.get("imp")?.root).toBe(join(env.dataDir, "courses/imp"));
|
|
|
|
const ro = (await call("owner", "GET", "/api/studio/courses/imp")).json;
|
|
expect(ro).toMatchObject({ readOnly: true, convertible: true });
|
|
expect((await call("ada", "GET", "/api/studio/courses/imp")).json).toMatchObject({ readOnly: true, convertible: false });
|
|
expect((await call("owner", "GET", "/api/studio/courses")).json.courses).toContainEqual(expect.objectContaining({ id: "imp", managed: false, imported: true, convertible: true }));
|
|
|
|
const job = await convert();
|
|
expect(job.result).toMatchObject({ courseId: "imp", media: 3, resumed: false });
|
|
const c = ctx.courses.get("imp")!;
|
|
expect(c.root).toBe(join(env.dataDir, "worktrees/imp/published"));
|
|
expect(c.status).toBe("ok");
|
|
expect(c.manifest?.visibility).toBe("restricted");
|
|
expect(ctx.courses.list().filter((x) => x.id === "imp")).toHaveLength(1);
|
|
expect(await db.sql`SELECT principal_id FROM course_acl WHERE course_id = 'imp'`).toEqual([{ principal_id: "u-lea" }]);
|
|
|
|
// media through core's route, Range included
|
|
const r = await env.app.fetch(new Request(`http://x/api/media/imp/${VIDEO.split("/").map(encodeURIComponent).join("/")}`, { headers: { "x-user": "lea", range: "bytes=10-19" } }));
|
|
expect(r.status).toBe(206);
|
|
expect(new Uint8Array(await r.arrayBuffer())).toEqual(videoBytes(4096, 7).slice(10, 20));
|
|
// anonymous users still see nothing
|
|
expect((await env.app.fetch(new Request(`http://x/api/media/imp/${encodeURI(VIDEO)}`))).status).toBe(404);
|
|
|
|
// the studio now edits it
|
|
const st = (await call("owner", "GET", "/api/studio/courses/imp")).json;
|
|
expect(st.readOnly).toBe(false);
|
|
expect(st.files).toContain(VIDEO);
|
|
expect((await call("owner", "GET", "/api/studio/courses")).json.courses).toContainEqual(expect.objectContaining({ id: "imp", managed: true }));
|
|
});
|
|
|
|
test("an author edits and publishes a lesson title but cannot make the course public; the owner can", async () => {
|
|
env = await setupApp([], dbUrl, (d) => makeImportedCourse(d));
|
|
const { call, ctx } = env;
|
|
await convert();
|
|
let st = (await call("ada", "GET", "/api/studio/courses/imp")).json;
|
|
const m = structuredClone(st.manifestRaw);
|
|
m.modules[0].lessons[0].title = "Welcome aboard";
|
|
expect((await call("ada", "PUT", "/api/studio/courses/imp/files", { message: "Rename", manifest: m, manifestBaseHash: st.manifestHash })).status).toBe(200);
|
|
expect((await call("ada", "POST", "/api/studio/courses/imp/publish")).json).toMatchObject({ ok: true, published: true });
|
|
expect(ctx.courses.get("imp")?.lessonIndex.get("l1")?.lesson.title).toBe("Welcome aboard");
|
|
|
|
st = (await call("ada", "GET", "/api/studio/courses/imp")).json;
|
|
const pub = structuredClone(st.manifestRaw);
|
|
pub.visibility = "public";
|
|
expect((await call("ada", "PUT", "/api/studio/courses/imp/files", { message: "Go public", manifest: pub, manifestBaseHash: st.manifestHash })).status).toBe(403);
|
|
st = (await call("owner", "GET", "/api/studio/courses/imp")).json;
|
|
expect((await call("owner", "PUT", "/api/studio/courses/imp/files", { message: "Go public", manifest: { ...st.manifestRaw, visibility: "public" }, manifestBaseHash: st.manifestHash })).status).toBe(200);
|
|
expect((await call("owner", "POST", "/api/studio/courses/imp/publish")).status).toBe(200);
|
|
expect(ctx.courses.get("imp")?.manifest?.visibility).toBe("public");
|
|
});
|
|
});
|
|
|
|
describe.each(dbs)("conversion and importer jobs [%s]", (_name, dbUrl) => {
|
|
test("409 while an import job for the course is queued; the job itself re-checks", async () => {
|
|
env = await setupApp([], dbUrl, (d) => makeImportedCourse(d));
|
|
// no importer feature in this app: its jobs stay queued
|
|
await env.ctx.jobs.enqueue("import.code-repo", { kind: "code-repo", path: "/x", options: { courseId: "imp" } });
|
|
const r = await env.call("owner", "POST", "/api/studio/courses/imp/convert");
|
|
expect(r.status).toBe(409);
|
|
expect(r.json.error).toContain("an import job for this course is still running");
|
|
// a convert job that was queued anyway (before the import was) fails cleanly and changes nothing
|
|
const id = await env.ctx.jobs.enqueue("studio.convert-import", { courseId: "imp", actor: { id: "u-own", name: "O", email: "o@x" } });
|
|
const j = await env.waitJob(id);
|
|
expect(j.status).toBe("failed");
|
|
expect(j.error).toContain("an import job for this course is still running");
|
|
expect(existsSync(join(env.dataDir, "courses/imp/stcourse.yaml"))).toBe(true);
|
|
expect(existsSync(join(env.dataDir, "studio/repos/imp.git"))).toBe(false);
|
|
// an import job for another course does not block
|
|
await env.db.sql`DELETE FROM jobs WHERE kind = 'import.code-repo'`;
|
|
await env.ctx.jobs.enqueue("import.transcribe", { courseId: "other" });
|
|
expect((await env.call("owner", "POST", "/api/studio/courses/imp/convert")).status).toBe(202);
|
|
});
|
|
|
|
test("server start recovers a conversion that crashed mid-move; learners see the course once", async () => {
|
|
env = await setupApp([], dbUrl, (d) => makeImportedCourse(d));
|
|
const crashing = createStudioStore({ dataDir: env.dataDir, testHooks: { crashAt: "staging-copied", forceExdev: true } });
|
|
await expect(crashing.convertImported("imp", { name: "O", email: "o@x" })).rejects.toBeInstanceOf(SimulatedCrash);
|
|
await env.ctx.courses.reload();
|
|
expect(env.ctx.courses.list().filter((c) => c.id === "imp").length).toBeLessThanOrEqual(1);
|
|
// a restart: a new studio feature recovers before serving
|
|
const restarted = studioFeature(env.ctx, { watch: false });
|
|
await restarted.ready;
|
|
restarted.stop();
|
|
const loaded = env.ctx.courses.list().filter((c) => c.id === "imp");
|
|
expect(loaded).toHaveLength(1);
|
|
expect(loaded[0]!.root).toBe(join(env.dataDir, "worktrees/imp/published"));
|
|
expect(existsSync(join(env.dataDir, "courses/imp"))).toBe(false);
|
|
const r = await env.app.fetch(new Request(`http://x/api/media/imp/${VIDEO.split("/").map(encodeURIComponent).join("/")}`, { headers: { "x-user": "owner", range: "bytes=0-9" } }));
|
|
expect(r.status).toBe(206);
|
|
// the requeued job (or the owner) retrying reports success
|
|
expect((await env.call("owner", "POST", "/api/studio/courses/imp/convert")).status).toBe(409);
|
|
expect(await env.feature.store.convertImported("imp", { name: "O", email: "o@x" })).toMatchObject({ resumed: true });
|
|
});
|
|
});
|
|
|
|
describe("convert route refusals", () => {
|
|
test("only the owner; cross-site requests are refused; nothing changes", async () => {
|
|
env = await setupApp([], undefined, (d) => makeImportedCourse(d));
|
|
for (const who of ["ada", "lea"]) expect((await env.call(who, "POST", "/api/studio/courses/imp/convert")).status).toBe(403);
|
|
expect((await env.call(null, "POST", "/api/studio/courses/imp/convert")).status).toBe(401);
|
|
const forged = await env.app.fetch(new Request("http://x/api/studio/courses/imp/convert", { method: "POST", headers: { "x-user": "owner", origin: "http://evil.example" } }));
|
|
expect(forged.status).toBe(403);
|
|
const noOrigin = await env.app.fetch(new Request("http://x/api/studio/courses/imp/convert", { method: "POST", headers: { "x-user": "owner" } }));
|
|
expect(noOrigin.status).toBe(403);
|
|
expect(existsSync(join(env.dataDir, "courses/imp/stcourse.yaml"))).toBe(true);
|
|
expect(existsSync(join(env.dataDir, "studio/repos/imp.git"))).toBe(false);
|
|
expect(await env.ctx.jobs.list("studio.convert-import")).toEqual([]);
|
|
});
|
|
|
|
test("overlay, studio and unknown courses; a second request while one is pending", async () => {
|
|
env = await setupApp([resolve(REPO, "fixtures/courses")], undefined, (d) => makeImportedCourse(d));
|
|
expect((await env.call("owner", "POST", "/api/studio/courses/sample/convert")).status).toBe(409);
|
|
expect((await env.call("owner", "POST", "/api/studio/courses/nope/convert")).status).toBe(404);
|
|
await env.call("owner", "POST", "/api/studio/courses", { id: "c1", title: "C" });
|
|
expect((await env.call("owner", "POST", "/api/studio/courses/c1/convert")).status).toBe(409);
|
|
|
|
await env.ctx.jobs.enqueue("studio.convert-import", { courseId: "imp", actor: { id: "u-own", name: "O", email: "o@x" } });
|
|
// whether the first job is still queued, running or already done, a second request is refused
|
|
const r = await env.call("owner", "POST", "/api/studio/courses/imp/convert");
|
|
expect(r.status).toBe(409);
|
|
});
|
|
|
|
test("job status is owner-only and limited to studio jobs", async () => {
|
|
env = await setupApp([], undefined, (d) => makeImportedCourse(d));
|
|
const { jobId } = (await env.call("owner", "POST", "/api/studio/courses/imp/convert")).json;
|
|
expect((await env.call("ada", "GET", `/api/studio/jobs/${jobId}`)).status).toBe(403);
|
|
await env.waitJob(jobId);
|
|
const j = (await env.call("owner", "GET", `/api/studio/jobs/${jobId}`)).json;
|
|
expect(j).toMatchObject({ id: jobId, status: "done", progress: 1, error: null });
|
|
const other = await env.ctx.jobs.enqueue("import.transcribe", { courseId: "x" });
|
|
expect((await env.call("owner", "GET", `/api/studio/jobs/${other}`)).status).toBe(404);
|
|
});
|
|
|
|
test("a failed conversion reports the error and leaves the import as it was", async () => {
|
|
env = await setupApp([], undefined, (d) => makeImportedCourse(d));
|
|
const manifest = join(env.dataDir, "courses/imp/stcourse.yaml");
|
|
// the route only checks that this is an imported course; the job validates the manifest fully
|
|
await Bun.write(manifest, readFileSync(manifest, "utf8").replace("id: imp", "id: other"));
|
|
const { jobId } = (await env.call("owner", "POST", "/api/studio/courses/imp/convert")).json;
|
|
const j = await env.waitJob(jobId);
|
|
expect(j.status).toBe("failed");
|
|
expect(j.error).toContain('"other"');
|
|
expect(existsSync(join(env.dataDir, "studio/repos/imp.git"))).toBe(false);
|
|
});
|
|
});
|