tests/pages_inbox.test.mjs 6.2 KiB 182 lines Raw
1 // Cloudflare Pages inbox parity tests (f0rge #79). Run:2 //   node --test tests/pages_inbox.test.mjs3 // No network, no wrangler: a fake R2 bucket exercises validation,4 // origin gate, ack, storage shape, rate and quota.5 import test from "node:test";6 import assert from "node:assert/strict";7 import { onRequestGet, onRequestPost } from "../functions/inbox/[kind].js";8 9 class FakeBucket {10   constructor() {11     this.objects = new Map();12   }13   async list({ limit = 1000, cursor } = {}) {14     const keys = [...this.objects.keys()];15     const start = cursor === undefined ? 0 : Number(cursor);16     const objects = keys.slice(start, start + limit).map((key) => ({ key }));17     const next = start + objects.length;18     const truncated = next < keys.length;19     return {20       objects,21       truncated,22       cursor: truncated ? String(next) : undefined,23     };24   }25   async put(key, value) {26     this.objects.set(key, String(value));27   }28 }29 30 let seq = 0;31 function env(extra = {}) {32   return {33     INBOX: new FakeBucket(),34     INBOX_SALT: "test-salt",35     ...extra,36   };37 }38 39 function post(kind, body, headers = {}, e = env()) {40   const request = new Request(`https://hu13.test/inbox/${kind}`, {41     method: "POST",42     headers: {43       "content-type": "application/x-www-form-urlencoded",44       "cf-connecting-ip": `203.0.113.${++seq}`,45       ...headers,46     },47     body,48   });49   return onRequestPost({ request, env: e, params: { kind } }).then(async (res) => ({50     res,51     text: await res.text(),52     env: e,53   }));54 }55 56 const validIssue = "title=Hello&body=World&license=agree";57 const validPrompt = "title=Prompt&prompt=Do+the+thing&license=on";58 59 test("GET is 405 with POST allowed and no-store/nosniff", async () => {60   const res = await onRequestGet();61   assert.equal(res.status, 405);62   assert.equal(res.headers.get("allow"), "POST");63   assert.equal(res.headers.get("x-content-type-options"), "nosniff");64   assert.equal(res.headers.get("cache-control"), "no-store");65 });66 67 test("valid issue stores one JSON object and returns an ack", async () => {68   const e = env();69   const { res, text } = await post("issue", validIssue, {}, e);70   assert.equal(res.status, 200);71   assert.match(text, /ack: <code>[0-9a-f-]{36}<\/code>/);72   assert.equal(e.INBOX.objects.size, 1);73   const [key, value] = [...e.INBOX.objects.entries()][0];74   assert.match(key, /^[0-9a-f-]{36}\.json$/);75   const doc = JSON.parse(value);76   assert.equal(doc.type, "issue");77   assert.equal(doc.title, "Hello");78   assert.equal(doc.body, "World");79   assert.equal(doc.license_version, "v1");80   assert.match(doc.received_at, /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/);81   assert.match(doc.salted_ip_hash, /^[0-9a-f]{64}$/);82   assert.ok(!text.includes("World"), "ack never reflects input");83 });84 85 test("valid pr-prompt uses prompt (and accepts body alias)", async () => {86   for (const body of [validPrompt, "title=P&body=alias&license=yes"]) {87     const e = env();88     const { res } = await post("pr-prompt", body, {}, e);89     assert.equal(res.status, 200);90     const doc = JSON.parse([...e.INBOX.objects.values()][0]);91     assert.equal(doc.type, "pr-prompt");92     assert.equal(doc.body, body.includes("prompt") ? "Do the thing" : "alias");93   }94 });95 96 test("missing license, empty fields and oversize are 400", async () => {97   for (const body of [98     "title=Hello&body=World",99     "title=&body=World&license=agree",100     "title=Hello&body=&license=agree",101     `title=${"x".repeat(301)}&body=W&license=agree`,102   ]) {103     const { res } = await post("issue", body);104     assert.equal(res.status, 400, body);105   }106 });107 108 test("honeypot fakes success without storing", async () => {109   const e = env();110   const { res, text } = await post(111     "issue",112     "title=Hello&body=World&license=agree&website=bot",113     {},114     e,115   );116   assert.equal(res.status, 200);117   assert.match(text, /ack: <code>/);118   assert.equal(e.INBOX.objects.size, 0);119 });120 121 test("malformed escapes, non-UTF-8 and NUL are 400", async () => {122   for (const body of [123     "title=%ZZ&body=W&license=agree",124     "title=%FF&body=W&license=agree",125     "title=He%00llo&body=W&license=agree",126   ]) {127     const { res } = await post("issue", body);128     assert.equal(res.status, 400, body);129   }130 });131 132 test("cross-origin browser POST is rejected; curl (no Origin) passes", async () => {133   const bad = await post("issue", validIssue, { origin: "https://evil.test" });134   assert.equal(bad.res.status, 403);135   const nullOrigin = await post("issue", validIssue, { origin: "null" });136   assert.equal(nullOrigin.res.status, 403);137   const badPort = await post("issue", validIssue, {138     origin: "https://hu13.test:444",139   });140   assert.equal(badPort.res.status, 403);141   const badScheme = await post("issue", validIssue, {142     origin: "http://hu13.test",143   });144   assert.equal(badScheme.res.status, 403);145   const same = await post("issue", validIssue, { origin: "https://hu13.test" });146   assert.equal(same.res.status, 200);147 });148 149 test("wrong content type and oversized bodies are rejected", async () => {150   const json = await post("issue", validIssue, {151     "content-type": "application/json",152   });153   assert.equal(json.res.status, 400);154   const big = `title=Hello&body=${"x".repeat(33 * 1024)}&license=agree`;155   const over = await post("issue", big);156   assert.equal(over.res.status, 413);157 });158 159 test("quota is enforced before storing", async () => {160   const e = env({ INBOX_QUOTA: "1" });161   const first = await post("issue", validIssue, {}, e);162   assert.equal(first.res.status, 200);163   const second = await post("issue", validIssue, {}, e);164   assert.equal(second.res.status, 503);165   assert.equal(e.INBOX.objects.size, 1);166 });167 168 test("quota above 1000 paginates R2 listings", async () => {169   const bucket = new FakeBucket();170   for (let i = 0; i < 1500; i++) bucket.objects.set(`old-${i}.json`, "x");171   const e = env({ INBOX: bucket, INBOX_QUOTA: "1500" });172   const { res } = await post("issue", validIssue, {}, e);173   assert.equal(res.status, 503);174   assert.equal(bucket.objects.size, 1500, "full inbox did not store");175 });176 177 test("missing R2 binding or salt fails closed", async () => {178   const noBucket = await post("issue", validIssue, {}, { INBOX_SALT: "x" });179   assert.equal(noBucket.res.status, 503);180   const noSalt = await post("issue", validIssue, {}, { INBOX: new FakeBucket() });181   assert.equal(noSalt.res.status, 503);182 });