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 });