1import { beforeAll, describe, expect, test } from "bun:test";2import { join } from "node:path";3import { BucketWriteError, openBucket } from "../src/bucket/reader.ts";4import { listRepos } from "../src/bucket/repo.ts";5import { Browser, type Instance, instance } from "./browser.ts";6import { makeRepos } from "./fixture.ts";78const PNG = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 1, 2, 3]);9const MP4 = new Uint8Array([0, 0, 0, 0x18, ...new TextEncoder().encode("ftypisom"), 1, 2, 3, 4]);1011describe("editing a repo's description and details while signed in", () => {12 let inst: Instance;13 let browser: Browser;14 let bucketDir: string;15 const page = async (path: string) => (await inst.app.request(path)).text();16 const save = (field: string, value: string) =>17 browser.post("/admin/repos/app/text", { field, value });18 const upload = (bytes: Uint8Array, name: string) => {19 const data = new FormData();20 data.append("file", new File([bytes], name));21 return browser.post("/admin/repos/app/media", data);22 };2324 beforeAll(async () => {25 const repos = await makeRepos(["app", "shy"]);26 bucketDir = repos.bucketDir;27 inst = await instance(bucketDir);28 inst.kepr.run("UPDATE repos SET public = 1 WHERE name = 'app'");29 browser = new Browser(inst.app);30 });3132 test("signing in leaves a script-readable mark, the form token, beside the session", async () => {33 const response = await browser.signIn();34 const cookies = response.headers.getSetCookie();35 expect(cookies.find((line) => line.startsWith("kepr_editor="))).toMatch(36 /^kepr_editor=[0-9a-f]{64};/,37 );38 expect(cookies.find((line) => line.startsWith("kepr_editor="))).not.toMatch(/HttpOnly/i);39 expect(cookies.find((line) => line.startsWith("kepr_session="))).toMatch(/HttpOnly/i);40 41 expect(await (await browser.request("/app")).text()).toBe(await page("/app"));42 });4344 test("starts from the README's words", async () => {45 const response = await browser.request("/admin/repos/app/text");46 const texts = (await response.json()) as Record<"form" | "description" | "details", string>;47 expect(texts.description).toBe("The app project.");48 expect(texts.details).toContain("# app");49 50 expect(texts.form).toBe(browser.form);51 });5253 test("the operator's description replaces the README's on the page, in cards and previews", async () => {54 expect((await save("description", "My **own** words.")).status).toBe(204);55 const repo = await page("/app");56 expect(repo).toContain('<div class="repo-desc"><p>My <strong>own</strong> words.</p></div>');57 expect(repo).toContain('<meta name="description" content="My own words.">');58 expect(await page("/browse")).toContain('<p class="repo-card-desc">My own words.</p>');59 });6061 test("details size and place pictures and videos from their options, and keep uploads' URLs", async () => {62 const details = [63 '',64 '',65 "",66 '',67 ].join("\n\n");68 expect((await save("details", details)).status).toBe(204);69 const repo = await page("/app");70 expect(repo).toContain(71 '<img src="/static/assets/media/app/a.png" alt="shot" class="media media-w33 media-right">',72 );73 expect(repo).toContain(74 '<video class="media media-w50 media-left" src="/static/assets/media/app/b.mp4" autoplay muted loop playsinline aria-label="demo"></video>',75 );76 expect(repo).toContain(77 '<video class="media media-w100 media-center" src="/static/assets/media/app/c.webm" controls preload="metadata" aria-label="talk"></video>',78 );79 80 expect(repo).toContain('<img src="/app/raw/pic.png" alt="plain" title="a real title">');81 expect(repo).not.toContain("# app");82 });8384 test("the preview renders as the page will", async () => {85 const response = await browser.post("/admin/repos/app/preview", {86 field: "details",87 value: "**hi** <script>x</script>",88 });89 expect(await response.text()).toBe(90 "<p><strong>hi</strong> <script>x</script></p>\n",91 );92 });9394 test("clearing a text brings the README's back", async () => {95 await save("description", "");96 await save("details", "");97 const repo = await page("/app");98 expect(repo).toContain("<p>The app project.</p>");99 expect(repo).toContain("<h1>app</h1>");100 });101102 test("refuses an unknown field, an overlong text and an unknown repo", async () => {103 expect((await save("title", "x")).status).toBe(400);104 expect((await save("description", "x".repeat(2001))).status).toBe(400);105 expect((await browser.post("/admin/repos/nope/text", { field: "details" })).status).toBe(404);106 });107108 test("uploads pictures and videos by what their bytes are, served as privately as the repo", async () => {109 const png = await upload(PNG, "shot.txt");110 expect(png.status).toBe(200);111 const { url } = (await png.json()) as { url: string };112 expect(url).toMatch(/^\/static\/assets\/media\/app\/[0-9a-f]{64}\.png$/);113 114 const key = `.kepr/media/app/${url.split("/").pop()}`;115 expect(await Bun.file(join(bucketDir, key)).exists()).toBe(true);116 expect(await listRepos(openBucket({ kind: "folder", path: bucketDir }))).toEqual([117 "app",118 "shy",119 ]);120 const served = await inst.app.request(url);121 expect([served.status, served.headers.get("content-type")]).toEqual([200, "image/png"]);122 const video = ((await (await upload(MP4, "demo.mp4")).json()) as { url: string }).url;123 expect(video).toEndWith(".mp4");124 125 const server = Bun.serve({ port: 0, fetch: (request) => inst.app.fetch(request) });126 const range = await fetch(new URL(video, server.url), { headers: { range: "bytes=0-3" } });127 expect([range.status, (await range.arrayBuffer()).byteLength]).toEqual([206, 4]);128 server.stop();129 expect((await upload(new TextEncoder().encode("<svg onload=x>"), "a.svg")).status).toBe(400);130 inst.kepr.run("UPDATE repos SET public = 0 WHERE name = 'app'");131 expect((await inst.app.request(url)).status).toBe(404);132 inst.kepr.run("UPDATE repos SET public = 1 WHERE name = 'app'");133 });134135 test("signed out, none of it answers", async () => {136 const stranger = new Browser(inst.app);137 expect((await stranger.request("/admin/repos/app/text")).status).toBe(401);138 expect((await stranger.post("/admin/repos/app/media", new FormData())).status).toBe(401);139 });140});141142test("Kepr writes only its own folder and builds, and to S3 only with the write key", async () => {143 const { bucketDir } = await makeRepos(["app"]);144 const folder = openBucket({ kind: "folder", path: bucketDir });145 const refused = ["app/head", ".kepr/../app/head", ".kepr//x", "x/.kepr/y", "app/releases/.."];146 for (const key of [...refused, "app/releases/1.0.0/app", "app/releases/1.0.0/x/release.json"]) {147 await expect(folder.put(key, new Uint8Array([1]), "image/png")).rejects.toBeInstanceOf(148 BucketWriteError,149 );150 }151 for (const key of [...refused, "app/saves/x", "app/releases/1.0.0/"]) {152 await expect(folder.remove(key)).rejects.toBeInstanceOf(BucketWriteError);153 }154 await folder.put("app/releases/latest", new TextEncoder().encode("1.0.0"), "text/plain");155 expect(await Bun.file(`${bucketDir}/app/releases/latest`).text()).toBe("1.0.0");156 await folder.put("app/releases/1.0.0/release.json", new Uint8Array([1]), "application/json");157 await folder.put(".kepr/x", new Uint8Array([1]), "text/plain");158 await folder.remove(".kepr/x");159 expect(await Bun.file(`${bucketDir}/.kepr/x`).exists()).toBe(false);160 const readOnly = openBucket({161 kind: "s3",162 endpoint: "http://127.0.0.1:9",163 name: "b",164 region: "r",165 accessKeyId: "k",166 secretAccessKey: "s",167 });168 await expect(169 readOnly.put(".kepr/media/app/x.png", new Uint8Array([1]), "image/png"),170 ).rejects.toThrow("[bucket.write]");171 await expect(readOnly.remove(".kepr/media/app/x.png")).rejects.toThrow("[bucket.write]");172});