feat(learner): section code listing and zip download
Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
478ebeb255
commit
34826762a8
@@ -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);
|
||||
Reference in New Issue
Block a user