feat(core): config, job queue, range media serving, feature composition

Adds aclRepo to @stc/db for canView.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Lucas WintherandClaude Opus 5.5 committed 2026-10-30 03:04:46 +01:00
1 parent ea18f18b4f
commit 7f33169487
12 files changed
+533

No files matched your search

+101
View File
@@ -0,0 +1,101 @@
import { expect, test } from "bun:test";
import { cpSync, mkdirSync, mkdtempSync, symlinkSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
import type { AuthUser, Feature, ServerContext } from "@stc/contracts";
import { createCourseRepository } from "@stc/course-format";
import { connect, migrate } from "@stc/db";
import { canView, createApp } from "../src";
const SAMPLE = resolve(import.meta.dir, "../../../fixtures/courses/sample");
async function setup(user: AuthUser | null = null) {
const dir = mkdtempSync(join(tmpdir(), "stc-app-"));
cpSync(SAMPLE, join(dir, "pub"), { recursive: true });
const manifest = await Bun.file(join(SAMPLE, "stcourse.yaml")).text();
mkdirSync(join(dir, "priv"));
cpSync(SAMPLE, join(dir, "priv"), { recursive: true });
writeFileSync(join(dir, "priv/stcourse.yaml"), manifest.replace("id: sample", "id: secret").replace("visibility: public", "visibility: restricted"));
mkdirSync(join(dir, "broken"));
writeFileSync(join(dir, "broken/stcourse.yaml"), "schema: 1\nid: broken\ntitle: Broken\nroot: /does/not/exist\nmodules: []\n");
symlinkSync("/etc", join(dir, "pub/escape"));
const db = await connect("sqlite://:memory:");
await migrate(db);
const courses = await createCourseRepository([dir]);
const ctx = { db, courses, auth: { userFromRequest: async () => user } } as unknown as ServerContext;
return { ctx, db, dir };
}
const feat = (name: string): Feature => ({ name, routes: { "/api/x": { GET: () => new Response(name) } } });
const user = (over: Partial<AuthUser> = {}): AuthUser => ({ id: "u1", email: "u@x", name: "U", roles: ["learner"], groups: [], ...over });
test("health ok, and an error course does not break it", async () => {
const { ctx, db } = await setup();
expect(ctx.courses.get("broken")?.status).toBe("error");
const app = createApp(ctx, []);
const r = await app.fetch(new Request("http://x/api/health"));
expect(r.status).toBe(200);
expect(await r.json()).toEqual({ ok: true, courses: ctx.courses.list().length, db: "sqlite" });
expect(ctx.courses.list().length).toBe(3);
await db.close();
});
test("media serves a file with Range, and a missing/broken course is 404", async () => {
const { ctx, db } = await setup();
const app = createApp(ctx, []);
const r = await app.fetch(new Request("http://x/api/media/sample/media/clip.mp4", { headers: { Range: "bytes=0-9" } }));
expect(r.status).toBe(206);
expect((await app.fetch(new Request("http://x/api/media/broken/anything"))).status).toBe(404);
expect((await app.fetch(new Request("http://x/api/media/nope/a"))).status).toBe(404);
await db.close();
});
test("traversal, absolute and symlink escapes are 404", async () => {
const { ctx, db } = await setup();
const app = createApp(ctx, []);
for (const p of ["..%2F..%2Fetc%2Fpasswd", "%2Fetc%2Fpasswd", "escape/passwd", "../priv/stcourse.yaml", "%E0%A4%A"]) {
const r = await app.fetch(new Request(`http://x/api/media/sample/${p}`));
expect(r.status).toBe(404);
}
await db.close();
});
test("restricted course: 404 anonymous, visible to owner, author, ACL user and group", async () => {
const url = "http://x/api/media/secret/media/clip.mp4";
const run = async (u: AuthUser | null, seed?: (db: Awaited<ReturnType<typeof setup>>["db"]) => Promise<unknown>) => {
const { ctx, db } = await setup(u);
await seed?.(db);
const s = (await createApp(ctx, []).fetch(new Request(url))).status;
await db.close();
return s;
};
expect(await run(null)).toBe(404);
expect(await run(user())).toBe(404);
expect(await run(user({ roles: ["owner"] }))).toBe(200);
expect(await run(user(), (db) => db.sql`INSERT INTO course_authors (course_id, user_id) VALUES ('secret', 'u1')`)).toBe(200);
expect(await run(user(), (db) => db.sql`INSERT INTO course_acl VALUES ('secret', 'user', 'u1')`)).toBe(200);
expect(await run(user({ groups: ["g"] }), (db) => db.sql`INSERT INTO course_acl VALUES ('secret', 'group', 'g')`)).toBe(200);
expect(await run(user({ groups: ["h"] }), (db) => db.sql`INSERT INTO course_acl VALUES ('secret', 'group', 'g')`)).toBe(404);
});
test("duplicate method+path across features throws", async () => {
const { ctx, db } = await setup();
expect(() => createApp(ctx, [feat("a"), feat("b")])).toThrow(/Duplicate route GET \/api\/x/);
await db.close();
});
test("feature routes receive RequestContext with the user", async () => {
const u = user();
const { ctx, db } = await setup(u);
const f: Feature = { name: "a", routes: { "/api/who/:id": { GET: (_r, rc) => Response.json({ u: rc.user?.id, id: rc.params.id }) } } };
const app = createApp(ctx, [f]);
expect(await (await app.fetch(new Request("http://x/api/who/42"))).json()).toEqual({ u: "u1", id: "42" });
await db.close();
});
test("canView matrix", async () => {
const { ctx, db } = await setup();
const pub = ctx.courses.get("sample")!, priv = ctx.courses.get("secret")!;
expect(canView(null, pub, [])).toBe(true);
expect(canView(null, priv, [])).toBe(false);
await db.close();
});
+27
View File
@@ -0,0 +1,27 @@
import { expect, test, spyOn } from "bun:test";
import { loadConfig } from "../src";
test("defaults", () => {
const warn = spyOn(console, "warn").mockImplementation(() => {});
const c = loadConfig({});
expect(c.port).toBe(3000);
expect(c.databaseUrl).toBe("sqlite://./data/stcourses.db");
expect(c.dataDir).toBe("./data");
expect(c.courseDirs).toEqual(["./overlays", "./courses"]);
expect(c.publicUrl).toBe("http://localhost:3000");
expect(warn).toHaveBeenCalledTimes(1);
warn.mockRestore();
});
test("colon splitting and overrides", () => {
const c = loadConfig({ STC_PORT: "8080", STC_COURSE_DIRS: "/a b:/c", STC_SECRET: "s", STC_PUBLIC_URL: "https://x.test" });
expect(c.port).toBe(8080);
expect(c.courseDirs).toEqual(["/a b", "/c"]);
expect(c.secret).toBe("s");
expect(c.publicUrl).toBe("https://x.test");
});
test("production without a secret throws", () => {
expect(() => loadConfig({ NODE_ENV: "production" })).toThrow(/STC_SECRET/);
expect(loadConfig({ NODE_ENV: "production", STC_SECRET: "x" }).secret).toBe("x");
});
+61
View File
@@ -0,0 +1,61 @@
import { expect } from "bun:test";
import { forEachDb } from "../../db/test/helpers";
import { jobsRepo } from "@stc/db";
import { createJobQueue } from "../src";
const until = async (f: () => Promise<boolean>) => {
for (let i = 0; i < 200; i++) { if (await f()) return; await Bun.sleep(10); }
throw new Error("timeout");
};
forEachDb("job runs to done with result and persisted progress", async (db) => {
const q = createJobQueue(db, {
ok: async (p, jc) => { await jc.reportProgress(0.5, "half"); return { echoed: p }; },
}, { pollMs: 10 });
q.start();
try {
const id = await q.enqueue("ok", { a: 1 });
await until(async () => (await q.get(id))?.status === "done");
const j = (await q.get(id))!;
expect(j.result).toEqual({ echoed: { a: 1 } });
expect(j.message).toBe("half");
expect(j.progress).toBe(1);
} finally { await q.stop(); }
});
forEachDb("throwing handler gives failed with message", async (db) => {
const q = createJobQueue(db, { bad: async () => { throw new Error("boom"); } }, { pollMs: 10 });
q.start();
try {
const id = await q.enqueue("bad", null);
await until(async () => (await q.get(id))?.status === "failed");
expect((await q.get(id))!.error).toBe("boom");
} finally { await q.stop(); }
});
forEachDb("progress updates persist while running", async (db) => {
let release!: () => void;
const gate = new Promise<void>((r) => (release = r));
const q = createJobQueue(db, { slow: async (_p, jc) => { await jc.reportProgress(0.3, "working"); await gate; return 1; } }, { pollMs: 10 });
q.start();
try {
const id = await q.enqueue("slow", null);
await until(async () => (await q.get(id))?.progress === 0.3);
expect((await q.get(id))!.status).toBe("running");
expect((await q.get(id))!.message).toBe("working");
release();
await until(async () => (await q.get(id))?.status === "done");
} finally { release(); await q.stop(); }
});
forEachDb("stale running job is requeued at start", async (db) => {
const id = await jobsRepo(db).insert("ok", { z: 1 });
await jobsRepo(db).claimNext(["ok"]);
expect((await jobsRepo(db).get(id))!.status).toBe("running");
const q = createJobQueue(db, { ok: async () => "again" }, { pollMs: 10 });
q.start();
try {
await until(async () => (await q.get(id))?.status === "done");
expect((await q.get(id))!.result).toBe("again");
} finally { await q.stop(); }
});
+59
View File
@@ -0,0 +1,59 @@
import { expect, test } from "bun:test";
import { mkdtempSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { serveMedia } from "../src";
const dir = mkdtempSync(join(tmpdir(), "stc-media-"));
const file = join(dir, "clip.mp4");
const bytes = new Uint8Array(1000).map((_, i) => i % 251);
writeFileSync(file, bytes);
const req = (range?: string) => new Request("http://x/m", { headers: range ? { Range: range } : {} });
test("full file gives 200", async () => {
const r = await serveMedia(req(), file);
expect(r.status).toBe(200);
expect(r.headers.get("content-type")).toBe("video/mp4");
expect(r.headers.get("accept-ranges")).toBe("bytes");
expect(r.headers.get("content-length")).toBe("1000");
expect((await r.arrayBuffer()).byteLength).toBe(1000);
});
test("bytes=0-99 gives 206 with Content-Range", async () => {
const r = await serveMedia(req("bytes=0-99"), file);
expect(r.status).toBe(206);
expect(r.headers.get("content-range")).toBe("bytes 0-99/1000");
expect(r.headers.get("content-length")).toBe("100");
expect(new Uint8Array(await r.arrayBuffer())).toEqual(bytes.slice(0, 100));
});
test("open-ended, suffix and clamped ranges", async () => {
let r = await serveMedia(req("bytes=900-"), file);
expect(r.status).toBe(206);
expect(r.headers.get("content-range")).toBe("bytes 900-999/1000");
r = await serveMedia(req("bytes=-10"), file);
expect(r.headers.get("content-range")).toBe("bytes 990-999/1000");
expect(new Uint8Array(await r.arrayBuffer())).toEqual(bytes.slice(990));
r = await serveMedia(req("bytes=990-5000"), file);
expect(r.headers.get("content-range")).toBe("bytes 990-999/1000");
});
test("unsatisfiable range gives 416", async () => {
const r = await serveMedia(req("bytes=999999999-"), file);
expect(r.status).toBe(416);
expect(r.headers.get("content-range")).toBe("bytes */1000");
});
test("non-ASCII filename gets a valid filename*", async () => {
const f = join(dir, "Æble notes.pdf");
writeFileSync(f, "pdf");
const r = await serveMedia(req(), f, { download: true });
expect(r.headers.get("content-type")).toBe("application/pdf");
const cd = r.headers.get("content-disposition")!;
expect(cd).toContain("filename*=UTF-8''%C3%86ble%20notes.pdf");
expect(cd).toContain('filename="_ble notes.pdf"');
});
test("missing file is 404", async () => {
expect((await serveMedia(req(), join(dir, "nope.mp4"))).status).toBe(404);
});