import { describe, expect, test } from "bun:test"; import { sha256Hex } from "../src/core/images"; import { TOOL_USER_AGENT } from "../src/core/transport"; import { createApp, type ServerEnv, type Upstream } from "../src/server/app"; import { RateLimiter } from "../src/server/rate-limit"; import { MemoryStorage } from "../src/server/storage"; import { fixtureBytes } from "./helpers/fixtures"; const ORIGIN = "https://mbseed.example.org"; const POSTER = "https://swebtoon-phinf.pstatic.net/20250204_46/17386458444547o1b2_JPEG/95.jpg"; function setup(over: Partial = {}, routes: Record Response | Promise> = {}) { const calls: { url: string; init?: RequestInit }[] = []; const upstream: Upstream = async (url, init) => { calls.push({ url, init }); const route = routes[url]; if (!route) throw new Error(`unexpected upstream ${url}`); return route(); }; const app = createApp({ storage: new MemoryStorage(10 * 1024 * 1024), publicUrl: ORIGIN, assets: async (path) => (path === "index.html" ? new Response("page", { headers: { "content-type": "text/html" } }) : null), clientIp: () => "203.0.113.9", upstream, ...over, }); const call = (path: string, init?: RequestInit) => app(new Request(`${ORIGIN}${path}`, init)); return { app, call, calls }; } const fetchPath = (url: string) => `/api/fetch?url=${encodeURIComponent(url)}`; const uploadInit = (body: unknown): RequestInit => ({ method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body) }); describe("/api/config", () => { test("reports storage and whether it is full", async () => { expect(await (await setup().call("/api/config")).json()).toEqual({ storage: true, storageFull: false }); expect(await (await setup({ storage: null }).call("/api/config")).json()).toEqual({ storage: false, storageFull: false }); expect(await (await setup({ storage: new MemoryStorage(10) }).call("/api/config")).json()).toEqual({ storage: true, storageFull: true }); }); }); describe("/api/fetch", () => { test("fetches allowlisted hosts with the right User-Agent and passes status and type through", async () => { const { call, calls } = setup({}, { "https://www.webtoons.com/-/-/-/list?title_no=95": () => new Response("", { headers: { "content-type": "text/html" } }), "https://web.archive.org/cdx/search/cdx?url=x": () => new Response("", { status: 503 }), }); const page = await call(fetchPath("https://www.webtoons.com/-/-/-/list?title_no=95")); expect(page.status).toBe(200); expect(page.headers.get("content-type")).toBe("text/html"); expect(await page.text()).toBe(""); expect(new Headers(calls[0]!.init!.headers).get("user-agent")).toMatch(/^Mozilla/); expect((await call(fetchPath("https://web.archive.org/cdx/search/cdx?url=x"))).status).toBe(503); expect(new Headers(calls[1]!.init!.headers).get("user-agent")).toBe(TOOL_USER_AGENT); }); test.each(["https://evil.example/x", "http://169.254.169.254/latest", "ftp://www.webtoons.com/x", "https://www.webtoons.com:8443/x", "not a url"])( "%p is refused without being fetched", async (url) => { const { call, calls } = setup(); expect([400, 403]).toContain((await call(fetchPath(url))).status); expect(calls).toHaveLength(0); }); test("follows redirects within the allowlist", async () => { const { call } = setup({}, { "https://www.webtoons.com/-/-/-/list?title_no=95": () => new Response(null, { status: 301, headers: { location: "/en/fantasy/tower-of-god/list?title_no=95" } }), "https://www.webtoons.com/en/fantasy/tower-of-god/list?title_no=95": () => new Response("canonical"), }); expect(await (await call(fetchPath("https://www.webtoons.com/-/-/-/list?title_no=95"))).text()).toBe("canonical"); }); test("redirect to a non-allowlisted host is refused", async () => { // Review Focus 2 const { call, calls } = setup({}, { "https://www.webtoons.com/x": () => new Response(null, { status: 302, headers: { location: "https://evil.example/" } }), }); expect((await call(fetchPath("https://www.webtoons.com/x"))).status).toBe(403); expect(calls.map((c) => c.url)).toEqual(["https://www.webtoons.com/x"]); }); test("responses over 20 MB are refused", async () => { const { call } = setup({}, { [POSTER]: () => new Response("x", { headers: { "content-length": String(21 * 1024 * 1024) } }) }); expect((await call(fetchPath(POSTER))).status).toBe(413); }); test("rejects other origins; allows ours, mangabaka.org and none", async () => { const { call } = setup({}, { [POSTER]: () => new Response("x") }); expect((await call(fetchPath(POSTER), { headers: { origin: "https://evil.example" } })).status).toBe(403); expect((await call(fetchPath(POSTER), { headers: { origin: ORIGIN } })).status).toBe(200); expect((await call(fetchPath(POSTER), { headers: { origin: "https://mangabaka.org" } })).status).toBe(200); expect((await call(fetchPath(POSTER))).status).toBe(200); }); test("rate-limited per IP", async () => { const { call } = setup({ limits: { fetch: new RateLimiter(1, 60_000), upload: new RateLimiter(1, 60_000) } }, { [POSTER]: () => new Response("x") }); expect((await call(fetchPath(POSTER))).status).toBe(200); expect((await call(fetchPath(POSTER))).status).toBe(429); }); test("proxy errors are marked so the page can tell them from upstream answers", async () => { const res = await setup().call(fetchPath("https://evil.example/")); expect(res.headers.get("x-mbseed-error")).toBe("1"); expect(await res.json()).toEqual({ error: expect.stringContaining("evil.example") }); }); }); describe("/api/upload", () => { test("store: fetches the source itself, keeps it by hash, returns its public URL", async () => { const poster = await fixtureBytes("images/poster-95.jpg"); const sha = await sha256Hex(poster); const { call } = setup({}, { [POSTER]: () => new Response(poster as Uint8Array) }); const res = await call("/api/upload", uploadInit({ url: POSTER, host: "store" })); expect(await res.json()).toEqual({ url: `${ORIGIN}/covers/${sha}.jpg`, sha256: sha }); const cover = await call(`/covers/${sha}.jpg`); expect(cover.headers.get("content-type")).toBe("image/jpeg"); expect((await cover.arrayBuffer()).byteLength).toBe(poster.byteLength); }); test("store full: 507", async () => { const poster = await fixtureBytes("images/poster-95.jpg"); const { call } = setup({ storage: new MemoryStorage(1000) }, { [POSTER]: () => new Response(poster as Uint8Array) }); expect((await call("/api/upload", uploadInit({ url: POSTER, host: "store" }))).status).toBe(507); }); test("catbox: forwards the userhash to catbox only", async () => { const poster = await fixtureBytes("images/poster-95.jpg"); const { call, calls } = setup({}, { [POSTER]: () => new Response(poster as Uint8Array), "https://catbox.moe/user/api.php": () => new Response("https://files.catbox.moe/abc.jpg"), }); const res = await call("/api/upload", uploadInit({ url: POSTER, host: "catbox", userhash: "abc123" })); expect((await res.json()).url).toBe("https://files.catbox.moe/abc.jpg"); expect((calls[1]!.init!.body as FormData).get("userhash")).toBe("abc123"); }); test.each([ [{ url: POSTER, host: "imgur" }, 400], [{ host: "store" }, 400], [{ url: "https://evil.example/x.jpg", host: "store" }, 403], [{ url: POSTER, host: "catbox", userhash: "../../x" }, 400], ])("%o → %i", async (body, status) => { expect((await setup().call("/api/upload", uploadInit(body))).status).toBe(status); }); test("no storage configured: 404 for store", async () => { expect((await setup({ storage: null }).call("/api/upload", uploadInit({ url: POSTER, host: "store" }))).status).toBe(404); }); test("not an image: 415", async () => { const { call } = setup({}, { [POSTER]: () => new Response("image not found") }); expect((await call("/api/upload", uploadInit({ url: POSTER, host: "store" }))).status).toBe(415); }); }); describe("covers and assets", () => { test.each(["/covers/../../x", `/covers/${"A".repeat(64)}.jpg`, `/covers/${"a".repeat(64)}.svg`, "/covers/"])("%p → 404", async (path) => { expect((await setup().call(path)).status).toBe(404); }); test("static assets via env.assets; unknown paths 404; writes 405", async () => { const { call } = setup(); expect(await (await call("/")).text()).toBe("page"); expect((await call("/nope.js")).status).toBe(404); expect((await call("/", { method: "POST" })).status).toBe(405); expect((await call("/..%2f.env")).status).toBe(404); }); });