feat(learner): section code listing and zip download

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Lucas Winther
2026-10-30 03:25:18 +01:00
co-authored by Claude Opus 5.5
parent 478ebeb255
commit 34826762a8
4 changed files with 211 additions and 0 deletions
@@ -0,0 +1,58 @@
import { expect, test } from "bun:test";
import { mkdtempSync, symlinkSync, writeFileSync, mkdirSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { zip } from "../src/server/zip";
import { forEachDb } from "./helpers";
import { FIXTURES, makeApp } from "./app";
async function unzipList(bytes: Uint8Array): Promise<string> {
const dir = mkdtempSync(join(tmpdir(), "stc-zip-"));
await Bun.write(join(dir, "a.zip"), bytes);
const out = await Bun.$`unzip -l ${join(dir, "a.zip")}`.text();
await Bun.$`unzip -o -q ${join(dir, "a.zip")} -d ${join(dir, "x")}`;
return out + "\n" + (await Bun.$`cd ${join(dir, "x")} && find . -type f -exec cat {} +`.text());
}
test("zip round-trips through unzip with non-ASCII names", async () => {
const big = new TextEncoder().encode("abc".repeat(1000));
const listing = await unzipList(zip([
{ name: "S08 - Containerizing Applications/Æble notes.txt", data: new TextEncoder().encode("hej") },
{ name: "S08 - Containerizing Applications/big.txt", data: big },
]));
expect(listing).toContain("S08 - Containerizing Applications/Æble notes.txt");
expect(listing).toContain("hej");
expect(listing).toContain("abcabc");
});
forEachDb("code listing and zip for a module, skipping escaping symlinks", async (db) => {
const root = mkdtempSync(join(tmpdir(), "stc-code-"));
const outside = mkdtempSync(join(tmpdir(), "stc-outside-"));
writeFileSync(join(outside, "secret.txt"), "TOP SECRET");
mkdirSync(join(root, "c/S01 - Ünïcode/sub"), { recursive: true });
mkdirSync(join(root, "c/S01 - Ünïcode/node_modules"), { recursive: true });
writeFileSync(join(root, "c/S01 - Ünïcode/run.sh"), "echo hi");
writeFileSync(join(root, "c/S01 - Ünïcode/sub/app.py"), "print(1)");
writeFileSync(join(root, "c/S01 - Ünïcode/node_modules/x.js"), "x");
symlinkSync(join(outside, "secret.txt"), join(root, "c/S01 - Ünïcode/leak.txt"));
symlinkSync(join(root, "c/S01 - Ünïcode/run.sh"), join(root, "c/S01 - Ünïcode/alias.sh"));
writeFileSync(join(root, "stcourse.yaml"), `schema: 1\nid: codey\ntitle: Codey\nvisibility: public\nmodules:\n - id: m1\n title: M\n code: { dir: "c/S01 - Ünïcode", command: "sh run.sh" }\n lessons: [{ id: l1, title: L, type: exercise }]\n`);
const t = await makeApp(db, [FIXTURES, root]);
const list = await (await t.call("GET", "/api/courses/codey/code/m1")).json();
expect(list.files.map((f: { path: string }) => f.path)).toEqual(["alias.sh", "run.sh", "sub/app.py"]);
expect(list.command).toBe("sh run.sh");
const r = await t.call("GET", "/api/courses/codey/code/m1/zip");
expect(r.status).toBe(200);
expect(r.headers.get("content-type")).toBe("application/zip");
expect(r.headers.get("content-disposition")).toContain("filename*=UTF-8''S01%20-%20%C3%9Cn%C3%AFcode.zip");
const listing = await unzipList(new Uint8Array(await r.arrayBuffer()));
expect(listing).toContain("S01 - Ünïcode/sub/app.py");
expect(listing).not.toContain("TOP SECRET");
expect(listing).not.toContain("node_modules");
expect((await t.call("GET", "/api/courses/codey/code/nope")).status).toBe(404);
expect((await t.call("GET", "/api/courses/sample/code/m1")).status).toBe(200);
expect((await t.call("GET", "/api/courses/sample/code/m2")).status).toBe(404);
}, 30_000);