Files
mangabaka-seeder/test/server-app.test.ts
T

167 lines
8.7 KiB
TypeScript

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<ServerEnv> = {}, routes: Record<string, () => Response | Promise<Response>> = {}) {
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("<!doctype html>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("<html>", { 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("<html>");
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<ArrayBuffer>) });
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<ArrayBuffer>) });
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<ArrayBuffer>),
"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("<html>image not found</html>") });
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("<!doctype html>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);
});
});