From 673f63ebba4ed3532183ed7177f4d3c37bc82d4f Mon Sep 17 00:00:00 2001
From: DysektAI <33468668+DysektAI@users.noreply.github.com>
Date: Wed, 14 Oct 2026 22:14:56 -0400
Subject: [PATCH 01/17] fix(web): share report limits and surface submission
errors
---
README.md | 62 ++++++++++
src/web/report-app.js | 126 +++++++++++++++++++++
src/web/report-policy.js | 92 +++++++++++++++
test/report-app.test.js | 207 +++++++++++++++++++++++++++++++++
test/report-form.test.js | 135 ++++++++++++++++++++++
test/report-policy.test.js | 123 ++++++++++++++++++++
web/data/index.html | 38 ++-----
web/issue/index.html | 39 ++-----
web/report-form.js | 82 ++++++++++++++
webserver.js | 226 ++-----------------------------------
10 files changed, 852 insertions(+), 278 deletions(-)
create mode 100644 src/web/report-app.js
create mode 100644 src/web/report-policy.js
create mode 100644 test/report-app.test.js
create mode 100644 test/report-form.test.js
create mode 100644 test/report-policy.test.js
create mode 100644 web/report-form.js
diff --git a/README.md b/README.md
index 39301f6..2f9048f 100755
--- a/README.md
+++ b/README.md
@@ -22,6 +22,68 @@ TrackerBot runs two services side by side:
| **Discord bot** | `bot.js` | Slash commands, reaction roles, welcome automation, and ticket creation. |
| **Bug intake web server** | `webserver.js` | Serves the public issue forms and forwards submissions to GitHub. |
+### Reporting limits and errors
+
+`POST /data` and `POST /issue` share one reporting policy for each client IP
+(with the existing IPv6 `/56` grouping). The allowance is **20 delivered reports
+in a rolling 12 hours**, with **at least 10 minutes between deliveries**. A
+delivery exactly 12 hours old leaves the allowance; a new report is admitted
+exactly at the 10-minute boundary. IPv4-mapped addresses use the same key as IPv4,
+including dotted and hexadecimal IPv6 representations. Switching forms does not
+reset either limit. Users sharing an IP or IPv6 prefix share these limits.
+
+The server reserves a slot before awaiting GitHub, so concurrent requests cannot
+both pass. Confirmed GitHub issue creation (`201`) consumes the allowance and
+starts the cooldown at delivery completion. Validation, origin/parser errors,
+honeypot submissions and proven delivery rejections do not count as delivered
+reports. A separate shared **five-attempt rolling minute** budget runs before
+body parsing and includes rejected/invalid requests. Denials return the longest
+active attempt/window/cooldown wait rather than an earlier partial reset.
+
+Timeouts, connection resets, unexpected provider responses and GitHub `5xx`
+responses are **unconfirmed delivery**, not proof that no issue was created.
+They conservatively reserve allowance and cooldown. A hash of the same report is
+held against automatic repetition for 12 hours, bounded by the same retention
+window; this is not a durable idempotency guarantee. Check the published issue
+list or contact support before repeating it. Different reports may be sent after
+the ordinary cooldown if allowance remains. No automatic GitHub retry occurs.
+
+Both forms preserve their entered fields on failure, disable double submission,
+and display safe typed server errors. Unknown network/proxy/success responses
+keep the draft and stop automatic repetition in that page. The displayed wait is
+advisory; every new request is checked by the server. Length limits are visible
+and enforced without silently truncating evidence: title 200, Discord handle 64,
+category 64, reference 500, details 8,000 characters; request bodies are capped at
+32 KiB. Successful submission resets the form. Drafts remain in the open page,
+not persistent browser storage; copy them before reloading.
+
+Errors use `{ error: { code, message }, retryAfterSeconds?, resetAt? }`.
+Admission errors use HTTP `429` (`report_cooldown`, `report_window_limit`,
+`attempt_limit`, `report_in_progress`), capacity uses `503`, and uncertain repeat
+delivery uses `409`. Wait errors include numeric `Retry-After` seconds and an ISO
+`resetAt`. Validation/parser/origin errors use `400`/`413`/`403`. Proven provider
+rejections/connectivity failures use `502`/`503`; unconfirmed delivery uses `504`.
+Provider throttling supplies safe retry guidance. Provider bodies, tokens, IPs
+and report descriptions are never included in error output or delivery logs.
+
+**Rollout limitation: these controls are process-local memory, not durable or
+shared across workers/hosts.** A restart clears allowances, cooldowns and
+uncertain-report holds. There is no existing persistent limiter store or hosting
+manifest in this repository. State is capped at 10,000 clients and stale entries
+are removed; at capacity, new clients receive a safe busy response instead of
+evicting active limits. Strict enforcement across restarts or multiple processes
+needs an operator-approved persistent/shared store after hosting is established.
+This change adds no infrastructure or production settings and is not evidence
+that the deployed topology is ready for strict enforcement.
+
+`trust proxy = 1` remains unchanged. Before rollout, operators must verify that
+the origin is reachable only through the intended trusted proxy and that it
+overwrites client forwarding headers, and confirm the actual process topology.
+Otherwise clients may spoof the limiter key or unrelated users may share one.
+Do not fix that by trusting arbitrary forwarded headers. The deployed form origin
+must also match the existing `ALLOWED_ORIGINS` setting. Credential, proxy-trust and
+production configuration changes require separate approval.
+
Requires **Node.js 18.17+**.
## Features
diff --git a/src/web/report-app.js b/src/web/report-app.js
new file mode 100644
index 0000000..30ab0ab
--- /dev/null
+++ b/src/web/report-app.js
@@ -0,0 +1,126 @@
+import express from "express";
+import { ipKeyGenerator } from "express-rate-limit";
+import axios from "axios";
+import path from "node:path";
+import { fileURLToPath } from "node:url";
+import { createHash } from "node:crypto";
+import { createReportPolicy } from "./report-policy.js";
+
+const webDirectory = fileURLToPath(new URL("../../web", import.meta.url));
+export const FIELD_LIMITS = { title: 200, discord: 64, category: 64, reference: 500, description: 8000 };
+
+function clientKey(ip) {
+ // Normalize hexadecimal mapped IPv4 before the existing IPv6 /56 grouping.
+ const mapped = ipKeyGenerator(ip, 128).match(/^::ffff:([\da-f]+):([\da-f]+)\/128$/i);
+ if (!mapped) return ipKeyGenerator(ip);
+ const high = Number.parseInt(mapped[1], 16);
+ const low = Number.parseInt(mapped[2], 16);
+ return [high >> 8, high & 255, low >> 8, low & 255].join(".");
+}
+
+function sendError(res, status, code, message, details = {}) {
+ if (details.retryAfterSeconds) res.set("Retry-After", String(details.retryAfterSeconds));
+ return res.status(status).json({ error: { code, message }, ...details });
+}
+
+function sendAdmissionError(res, admission) {
+ const status = admission.code === "delivery_uncertain" ? 409 : admission.code === "report_capacity" ? 503 : 429;
+ return sendError(res, status, admission.code, admission.message, { retryAfterSeconds: admission.retryAfterSeconds, resetAt: admission.resetAt });
+}
+
+function validateFields(body, kind) {
+ const required = kind === "data" ? ["title", "discord", "category", "description"] : ["title", "discord", "description"];
+ const fields = {};
+ for (const [name, max] of Object.entries(FIELD_LIMITS)) {
+ const value = body?.[name];
+ if (value != null && typeof value !== "string") return { error: `${name} must be text.` };
+ fields[name] = (value ?? "").trim();
+ if (fields[name].length > max) return { error: `${name} must be ${max} characters or fewer.` };
+ if (required.includes(name) && !fields[name]) return { error: `Please provide ${name}.` };
+ }
+ return { fields };
+}
+
+function buildIssue(kind, fields) {
+ if (kind === "issue") {
+ return { title: fields.title, body: `**Discord handle:** ${fields.discord}\n\n**Description:**\n${fields.description}` };
+ }
+ const lines = [`**Discord handle:** ${fields.discord}`, `**Category:** ${fields.category}`];
+ if (fields.reference) lines.push(`**Reference:** ${fields.reference}`);
+ lines.push("", "**Details:**", fields.description);
+ return { title: `[${fields.category}] ${fields.title}`, body: lines.join("\n") };
+}
+
+function deliveryError(err) {
+ const status = err?.response?.status;
+ const headers = err?.response?.headers ?? {};
+ if (status === 429 || (status === 403 && (headers["retry-after"] || headers["x-ratelimit-remaining"] === "0"))) {
+ const seconds = Number(headers["retry-after"]);
+ return { status: 503, code: "provider_rate_limit", message: "GitHub is temporarily limiting reports. Your report was not created. Keep your draft and wait before retrying.", retryAfterSeconds: Number.isFinite(seconds) && seconds > 0 ? Math.min(3600, Math.ceil(seconds)) : 60, failed: true };
+ }
+ if ([400, 401, 403, 404, 405, 410, 422].includes(status)) {
+ return { status: 502, code: "delivery_rejected", message: "The report service could not deliver this report. It was not created. Keep your draft and contact support.", failed: true };
+ }
+ if (["ENOTFOUND", "EAI_AGAIN", "ECONNREFUSED"].includes(err?.code)) {
+ return { status: 503, code: "delivery_unavailable", message: "The report service could not connect to GitHub. Your report was not created. Keep your draft and wait one minute before retrying.", retryAfterSeconds: 60, failed: true };
+ }
+ return { status: 504, code: "delivery_uncertain", message: "Delivery could not be confirmed. Check the issue list or contact support before repeating this report; it may already have been created.", failed: false };
+}
+
+export function createReportApp({ env = process.env, githubPost = axios.post, now = Date.now, logger = console } = {}) {
+ const app = express();
+ // Preserve existing trust. Operators must verify a single trusted proxy and
+ // no direct origin access; do not trust a client-supplied IP header here.
+ app.set("trust proxy", 1);
+ const policy = createReportPolicy({ now });
+ const origins = new Set((env.ALLOWED_ORIGINS || "").split(",").map(origin => origin.trim().replace(/\/+$/, "")).filter(Boolean));
+ const githubHeaders = { Authorization: `token ${env.GITHUB_TOKEN}`, Accept: "application/vnd.github+json", "User-Agent": "tarkovtracker-webserver" };
+ // This separate budget counts attempts, including invalid/rejected requests.
+ // It is shared across routes and runs before decoding the body.
+ app.post(["/data", "/issue"], (req, res, next) => {
+ const admission = policy.attempt(clientKey(req.ip));
+ if (!admission.ok) return sendAdmissionError(res, admission);
+ next();
+ });
+ app.use(express.urlencoded({ extended: true, limit: "32kb" }));
+ app.use(express.json({ limit: "32kb" }));
+ app.use(express.static(path.join(webDirectory)));
+ app.get("/health", (req, res) => res.json({ status: "ok" }));
+
+ async function submit(req, res, kind) {
+ const origin = req.headers.origin || req.headers.referer || "";
+ if (origins.size && ![...origins].some(allowed => origin === allowed || origin.startsWith(`${allowed}/`))) {
+ return sendError(res, 403, "forbidden_origin", "This report form is not allowed to submit from this address. Keep your draft and contact support.");
+ }
+ if ((req.body?.company ?? "").toString().trim()) return res.json({ ok: true });
+ const validated = validateFields(req.body, kind);
+ if (validated.error) return sendError(res, 400, "invalid_report", validated.error);
+ const issue = buildIssue(kind, validated.fields);
+ const fingerprint = createHash("sha256").update(JSON.stringify([kind, issue])).digest("hex");
+ const admission = policy.reserve(clientKey(req.ip), fingerprint);
+ if (!admission.ok) return sendAdmissionError(res, admission);
+ try {
+ const repo = kind === "data" ? env.REPO_DATA_REPORT : env.REPO_DEV;
+ const response = await githubPost(`https://api.github.com/repos/${repo}/issues`, issue, { headers: githubHeaders, timeout: 10_000 });
+ if (response?.status !== 201) throw new Error("Unexpected issue creation status");
+ admission.settle("accepted");
+ return res.json({ ok: true });
+ } catch (err) {
+ const failure = deliveryError(err);
+ admission.settle(failure.failed ? "failed" : "uncertain");
+ logger.error("Report delivery failed", { route: kind, status: Number.isInteger(err?.response?.status) ? err.response.status : "no-response", code: failure.code });
+ const details = failure.retryAfterSeconds ? { retryAfterSeconds: failure.retryAfterSeconds } : {};
+ return sendError(res, failure.status, failure.code, failure.message, details);
+ }
+ }
+ app.post("/data", (req, res) => submit(req, res, "data"));
+ app.post("/issue", (req, res) => submit(req, res, "issue"));
+ app.use((err, req, res, next) => {
+ if (res.headersSent) return next(err);
+ if (err.type === "entity.too.large") return sendError(res, 413, "report_too_large", "This report exceeds the 32 KiB request limit. Shorten the details while keeping the key evidence.");
+ if (err.type === "entity.parse.failed") return sendError(res, 400, "invalid_request", "The report request could not be read. Keep your draft and reload the form before retrying.");
+ logger.error("Report request failed", { type: "internal_error" });
+ return sendError(res, 500, "internal_error", "The report service encountered an error. Keep your draft and contact support before repeating a submitted report.");
+ });
+ return app;
+}
diff --git a/src/web/report-policy.js b/src/web/report-policy.js
new file mode 100644
index 0000000..57b5ff4
--- /dev/null
+++ b/src/web/report-policy.js
@@ -0,0 +1,92 @@
+export const REPORT_WINDOW_MS = 12 * 60 * 60 * 1000;
+export const REPORT_INTERVAL_MS = 10 * 60 * 1000;
+export const REPORT_LIMIT = 20;
+const ATTEMPT_WINDOW_MS = 60_000;
+const ATTEMPT_LIMIT = 5;
+
+function denied(code, message, resetAt, now) {
+ return {
+ ok: false, code, message,
+ retryAfterSeconds: Math.max(1, Math.ceil((resetAt - now) / 1000)),
+ resetAt: new Date(resetAt).toISOString()
+ };
+}
+
+// One Node process only. Reservation and settlement are synchronous, including
+// reservation before asynchronous delivery; there is no persistent store.
+export function createReportPolicy({ now = Date.now, maxClients = 10_000 } = {}) {
+ const clients = new Map();
+ let lastSweep = -Infinity;
+ function prune(state, time) {
+ state.events = state.events.filter(event => event.time > time - REPORT_WINDOW_MS);
+ state.attempts = state.attempts.filter(attempt => attempt > time - ATTEMPT_WINDOW_MS);
+ }
+ function sweep(time) {
+ if (time - lastSweep < 60_000) return;
+ lastSweep = time;
+ for (const [key, state] of clients) {
+ prune(state, time);
+ if (!state.pending && !state.events.length && !state.attempts.length) clients.delete(key);
+ }
+ }
+ function stateFor(key, time) {
+ sweep(time);
+ let state = clients.get(key);
+ if (!state) {
+ if (clients.size >= maxClients) {
+ return denied("report_capacity", "The report service is busy. Keep your draft and wait one minute.", time + 60_000, time);
+ }
+ state = { events: [], attempts: [], pending: null };
+ clients.set(key, state);
+ }
+ prune(state, time);
+ return state;
+ }
+ function blocked(state, time, fingerprint) {
+ if (state.pending) {
+ return denied("report_in_progress", "A report is already being submitted from this connection. Wait for its result.", time + 10_000, time);
+ }
+ const previous = state.events.at(-1);
+ const cooldownEnd = previous ? previous.time + REPORT_INTERVAL_MS : time;
+ const windowEnd = state.events.length >= REPORT_LIMIT ? state.events[0].time + REPORT_WINDOW_MS : time;
+ const uncertain = fingerprint && state.events.find(event => event.outcome === "uncertain" && event.fingerprint === fingerprint);
+ if (uncertain) {
+ return denied("delivery_uncertain", "Delivery of this report could not be confirmed. Check the issue list or contact support before repeating it; automatic retries could create duplicates.", Math.max(uncertain.time + REPORT_WINDOW_MS, cooldownEnd, windowEnd), time);
+ }
+ if (state.events.length >= REPORT_LIMIT) {
+ const reset = Math.max(windowEnd, cooldownEnd);
+ return denied("report_window_limit", "The shared limit of 20 reports in a rolling 12-hour window has been reached. The displayed wait also includes any remaining 10-minute cooldown.", reset, time);
+ }
+ if (previous && time < previous.time + REPORT_INTERVAL_MS) {
+ return denied("report_cooldown", "Wait 10 minutes between reports. Both report forms share this cooldown.", previous.time + REPORT_INTERVAL_MS, time);
+ }
+ return null;
+ }
+ function attempt(key) {
+ const time = now();
+ const state = stateFor(key, time);
+ if (state.ok === false) return state;
+ const reportingBlock = blocked(state, time);
+ if (state.attempts.length >= ATTEMPT_LIMIT) {
+ const attemptBlock = denied("attempt_limit", "Too many submission attempts. Keep your draft and wait one minute.", state.attempts[0] + ATTEMPT_WINDOW_MS, time);
+ return reportingBlock && Date.parse(reportingBlock.resetAt) > Date.parse(attemptBlock.resetAt) ? reportingBlock : attemptBlock;
+ }
+ state.attempts.push(time);
+ return reportingBlock ?? { ok: true };
+ }
+ function reserve(key, fingerprint) {
+ const time = now();
+ const state = stateFor(key, time);
+ if (state.ok === false) return state;
+ const rejection = blocked(state, time, fingerprint);
+ if (rejection) return rejection;
+ const reservation = { time, fingerprint };
+ state.pending = reservation;
+ return { ok: true, settle(outcome) {
+ if (state.pending !== reservation) return;
+ state.pending = null;
+ if (outcome !== "failed") state.events.push({ ...reservation, time: now(), outcome });
+ } };
+ }
+ return { attempt, reserve };
+}
diff --git a/test/report-app.test.js b/test/report-app.test.js
new file mode 100644
index 0000000..a1d7cf3
--- /dev/null
+++ b/test/report-app.test.js
@@ -0,0 +1,207 @@
+import test from "node:test";
+import assert from "node:assert/strict";
+import { once } from "node:events";
+import { createReportApp } from "../src/web/report-app.js";
+
+const fields = { title: "Test report", discord: "Test reporter", category: "Other", description: "Test observation", reference: "" };
+async function fixture(t, githubPost = async () => ({ status: 201 })) {
+ let time = 0;
+ const calls = [];
+ const logs = [];
+ const app = createReportApp({
+ env: { GITHUB_TOKEN: "test-only-key", REPO_DEV: "example/dev", REPO_DATA_REPORT: "example/data", ALLOWED_ORIGINS: "https://example.test" },
+ now: () => time,
+ logger: { error(...args) { logs.push(args); } },
+ githubPost(...args) { calls.push(args); return githubPost(...args); }
+ });
+ const server = app.listen(0, "127.0.0.1");
+ await once(server, "listening");
+ t.after(async () => {
+ server.closeAllConnections();
+ await new Promise(resolve => server.close(resolve));
+ });
+ const base = `http://127.0.0.1:${server.address().port}`;
+ return {
+ app, calls, logs, setTime(value) { time = value; },
+ async post(route = "data", body = fields, extra = {}) {
+ const response = await fetch(`${base}/${route}`, {
+ method: "POST", headers: { "Content-Type": "application/json", Origin: "https://example.test", ...extra },
+ body: typeof body === "string" ? body : JSON.stringify(body)
+ });
+ return { status: response.status, headers: response.headers, body: await response.json() };
+ }
+ };
+}
+
+test("both routes share delivered-report policy and keep destination routing", async t => {
+ const api = await fixture(t);
+ assert.equal((await api.post()).status, 200);
+ const blocked = await api.post("issue");
+ assert.equal(blocked.body.error.code, "report_cooldown");
+ assert.equal(blocked.headers.get("Retry-After"), "600");
+ api.setTime(600000);
+ assert.equal((await api.post("issue")).status, 200);
+ assert.equal(api.calls[0][0], "https://api.github.com/repos/example/data/issues");
+ assert.equal(api.calls[1][0], "https://api.github.com/repos/example/dev/issues");
+});
+
+test("HTTP 20th/21st and rolling boundary across mixed routes", async t => {
+ const api = await fixture(t);
+ for (let index = 0; index < 20; index++) {
+ api.setTime(index * 600000);
+ assert.equal((await api.post(index % 2 ? "issue" : "data")).status, 200);
+ }
+ api.setTime(12000000);
+ const rejected = await api.post();
+ assert.equal(rejected.status, 429);
+ assert.equal(rejected.body.error.code, "report_window_limit");
+ assert.equal(rejected.headers.get("Retry-After"), "31200");
+ assert.equal(api.calls.length, 20);
+ api.setTime(43200000);
+ assert.equal((await api.post()).status, 200);
+});
+
+test("all three budgets overlap: header and reset use the longest wait", async t => {
+ const api = await fixture(t);
+ for (let index = 0; index < 19; index++) {
+ api.setTime(index * 600000);
+ await api.post();
+ }
+ api.setTime(42900000);
+ await api.post();
+ for (let index = 0; index < 6; index++) {
+ const result = await api.post("issue");
+ assert.equal(result.body.error.code, "report_window_limit");
+ assert.equal(result.headers.get("Retry-After"), "600");
+ assert.equal(Date.parse(result.body.resetAt), 43500000);
+ }
+});
+
+test("a pending GitHub call admits only one concurrent report", async t => {
+ let release;
+ let started;
+ const waiting = new Promise(resolve => { started = resolve; });
+ const api = await fixture(t, () => { started(); return new Promise(resolve => { release = resolve; }); });
+ const first = api.post();
+ await waiting;
+ const second = await api.post("issue");
+ assert.equal(second.body.error.code, "report_in_progress");
+ assert.equal(api.calls.length, 1);
+ release({ status: 201 });
+ assert.equal((await first).status, 200);
+});
+
+test("validation, parser and honeypot rejections consume no report allowance", async t => {
+ const api = await fixture(t);
+ assert.equal((await api.post("data", { ...fields, title: "" })).body.error.code, "invalid_report");
+ assert.equal((await api.post("issue", { ...fields, title: { unsafe: true } })).status, 400);
+ assert.equal((await api.post("issue", "{invalid")).body.error.code, "invalid_request");
+ assert.equal((await api.post("data", { ...fields, company: "bot" })).status, 200);
+ assert.equal((await api.post()).status, 200);
+ assert.equal(api.calls.length, 1);
+});
+
+test("invalid attempts are bounded across both routes", async t => {
+ const api = await fixture(t);
+ for (let index = 0; index < 5; index++) {
+ assert.equal((await api.post(index % 2 ? "issue" : "data", {})).status, 400);
+ }
+ const blocked = await api.post();
+ assert.equal(blocked.body.error.code, "attempt_limit");
+ assert.equal(blocked.headers.get("Retry-After"), "60");
+ api.setTime(60000);
+ assert.equal((await api.post()).status, 200);
+});
+
+test("length errors replace truncation and oversized parser errors are safe", async t => {
+ const api = await fixture(t);
+ const long = await api.post("data", { ...fields, description: "x".repeat(8001) });
+ assert.equal(long.body.error.code, "invalid_report");
+ assert.match(long.body.error.message, /8000/);
+ assert.equal((await api.post("data", { ...fields, description: "x".repeat(33000) })).body.error.code, "report_too_large");
+ assert.equal((await api.post("data", { ...fields, description: "x".repeat(8000) })).status, 200);
+});
+
+test("origin checks and single-hop trust remain unchanged", async t => {
+ const api = await fixture(t);
+ assert.equal(api.app.get("trust proxy"), 1);
+ assert.equal((await api.post("data", fields, { Origin: "https://example.test.evil.invalid" })).body.error.code, "forbidden_origin");
+ assert.equal(api.calls.length, 0);
+ assert.equal((await api.post()).status, 200);
+});
+
+test("IP normalization covers mapped IPv4, IPv6 notation and existing /56 grouping", async t => {
+ const api = await fixture(t);
+ assert.equal((await api.post("data", fields, { "X-Forwarded-For": "192.0.2.1" })).status, 200);
+ assert.equal((await api.post("issue", fields, { "X-Forwarded-For": "::ffff:c000:201" })).body.error.code, "report_cooldown");
+ assert.equal((await api.post("data", fields, { "X-Forwarded-For": "2001:db8:abcd:1200::1" })).status, 200);
+ assert.equal((await api.post("issue", fields, { "X-Forwarded-For": "2001:0db8:abcd:12ff:0:0:0:2" })).body.error.code, "report_cooldown");
+});
+
+for (const status of [401, 403, 404, 422, 429]) {
+ test(`confirmed GitHub ${status} rejection releases report allowance`, async t => {
+ let reject = true;
+ const api = await fixture(t, async () => {
+ if (reject) throw { response: { status, headers: { "retry-after": "120" }, data: "PRIVATE_PROVIDER_BODY" }, message: "PRIVATE_PROVIDER_MESSAGE" };
+ return { status: 201 };
+ });
+ const failure = await api.post();
+ assert.notEqual(failure.body.error.code, "delivery_uncertain");
+ if (status === 429 || status === 403) assert.equal(failure.headers.get("Retry-After"), "120");
+ reject = false;
+ assert.equal((await api.post()).status, 200);
+ assert.doesNotMatch(JSON.stringify([failure, api.logs]), /PRIVATE_PROVIDER/);
+ });
+}
+
+for (const failure of [{ code: "ECONNABORTED" }, { code: "ECONNRESET" }, ...[500, 502, 503, 504].map(status => ({ response: { status } }))]) {
+ test(`ambiguous ${failure.code ?? failure.response.status} cannot automatically repeat a report`, async t => {
+ const api = await fixture(t, async () => { throw { ...failure, message: "PRIVATE_DESCRIPTION", config: { headers: { Authorization: "PRIVATE_SECRET" } } }; });
+ const result = await api.post();
+ assert.equal(result.body.error.code, "delivery_uncertain");
+ api.setTime(600000);
+ assert.equal((await api.post()).status, 409);
+ assert.equal(api.calls.length, 1);
+ assert.doesNotMatch(JSON.stringify([result, api.logs]), /PRIVATE_DESCRIPTION|PRIVATE_SECRET|Test observation|127\.0\.0\.1/);
+ });
+}
+
+test("proven connection failure releases quota and supplies safe retry guidance", async t => {
+ let fail = true;
+ const api = await fixture(t, async () => {
+ if (fail) throw { code: "ENOTFOUND" };
+ return { status: 201 };
+ });
+ assert.equal((await api.post()).headers.get("Retry-After"), "60");
+ fail = false;
+ assert.equal((await api.post()).status, 200);
+});
+
+test("unexpected successful provider status is not acknowledged as issue creation", async t => {
+ const api = await fixture(t, async () => ({ status: 200 }));
+ assert.equal((await api.post()).body.error.code, "delivery_uncertain");
+});
+
+test("null provider errors release pending state into uncertainty, not a permanent lock", async t => {
+ const api = await fixture(t, async () => { throw null; });
+ assert.equal((await api.post()).body.error.code, "delivery_uncertain");
+ api.setTime(600000);
+ assert.equal((await api.post()).status, 409);
+});
+
+test("case and trailing slash aliases preserve their matched route destination", async t => {
+ const api = await fixture(t);
+ assert.equal((await api.post("DATA/")).status, 200);
+ assert.equal(api.calls[0][0], "https://api.github.com/repos/example/data/issues");
+ api.setTime(600000);
+ assert.equal((await api.post("ISSUE/")).status, 200);
+ assert.equal(api.calls[1][0], "https://api.github.com/repos/example/dev/issues");
+});
+
+test("uncertain dedupe also covers equivalent route spellings", async t => {
+ const api = await fixture(t, async () => { throw { code: "ECONNABORTED" }; });
+ await api.post();
+ api.setTime(600000);
+ assert.equal((await api.post("DATA/")).status, 409);
+ assert.equal(api.calls.length, 1);
+});
diff --git a/test/report-form.test.js b/test/report-form.test.js
new file mode 100644
index 0000000..287e5fd
--- /dev/null
+++ b/test/report-form.test.js
@@ -0,0 +1,135 @@
+import test from "node:test";
+import assert from "node:assert/strict";
+import { readFile } from "node:fs/promises";
+import vm from "node:vm";
+
+async function loadForm(kind, fetch, search = "") {
+ const html = await readFile(new URL(`../web/${kind}/index.html`, import.meta.url), "utf8");
+ const button = { disabled: false };
+ const status = { textContent: "", className: "" };
+ const form = {
+ resetCount: 0,
+ addEventListener(name, callback) { this.submit = callback; },
+ reset() { this.resetCount++; },
+ querySelector() { return button; },
+ querySelectorAll() { return Array.from(fields.values()).filter(field => field.maxLength); },
+ dataset: { endpoint: `/${kind}` }
+ };
+ const fields = new Map();
+ let time = 0;
+ class Clock extends Date { static now() { return time; } }
+ const context = vm.createContext({
+ document: { getElementById(id) {
+ if (id.endsWith("Form")) return form;
+ if (id.endsWith("Status")) return status;
+ if (!fields.has(id)) fields.set(id, { value: "", options: [] });
+ return fields.get(id);
+ } },
+ window: { location: { search } },
+ URLSearchParams, Date: Clock, console: { error() {} },
+ FormData: class { entries() { return [["title", "Retained draft"]]; } },
+ fetch
+ });
+ for (const script of html.matchAll(/