POST /api/studio/courses/:id/convert (owner only, CSRF-guarded by core) queues studio.convert-import; GET /api/studio/jobs/:id reports progress. Read-only responses say whether the caller may convert. Resolves TODO(studio) (ruling R9). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
129 lines
8.0 KiB
TypeScript
129 lines
8.0 KiB
TypeScript
import { afterEach, describe, expect, test } from "bun:test";
|
|
import { existsSync, readFileSync } from "node:fs";
|
|
import { join, resolve } from "node:path";
|
|
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("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);
|
|
});
|
|
});
|