Diffinery

codician/diffinerypublic

Open-source, agent-first GitHub and GitHub Actions alternative that runs on your own Cloudflare account: git hosting, pull requests, code review and drop-in GitHub Actions YAML on Workers, Durable Objects, R2 and Containers.

392 lines · 25.1 KBRaw History
1// End-to-end contact form (lib/contact.ts, web/contact.tsx) on a real `wrangler dev` Worker: the
2// footer, sitemap, robots.txt and llms.txt lead to /contact; a visitor's message is confirmed with the
3// link from its email and only then reaches the site administrators, by email (Reply-To the sender) and
4// in Diffinery; a signed-in person's message arrives at once with a copy for them; bots, bursts,
5// oversized and hostile input get nowhere; only site administrators see messages, on the web and
6// through the API; marking handled, spam, reopening, noting and deleting work and are audited without
7// the message's content, and an address with a message marked spam gets nothing more through.
8//
9// The Worker keeps env.dev's DEV_AUTH so it keeps whole copies of the emails it sends (for reading the
10// links). Visitors who aren't signed in are requests with another Host header: local development's fixed
11// identity only applies to requests addressed to this machine.
12//
13// pnpm --filter @diffinery/app exec node --test test/e2e/contact.test.mjs
14import "./fresh-connections.mjs";
15import { spawn, execFileSync } from "node:child_process";
16import { createHash, randomBytes } from "node:crypto";
17import { mkdtempSync, rmSync } from "node:fs";
18import { tmpdir } from "node:os";
19import { join, dirname, resolve } from "node:path";
20import { fileURLToPath } from "node:url";
21import { request as httpRequest } from "node:http";
22import { test, after } from "node:test";
23import assert from "node:assert/strict";
24import { migratedState } from "./d1-state.mjs";
25import { freePort } from "./ports.mjs";
26
27const APP = resolve(dirname(fileURLToPath(import.meta.url)), "../..");
28const work = mkdtempSync(join(tmpdir(), "diffinery-contact-e2e-"));
29const running = [];
30
31const hash = (s) => createHash("sha256").update(s).digest("hex");
32const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
33
34/**
35 * A fetch-like request over node:http, which (unlike fetch) sends the Host header it is given: the
36 * Worker sees a visitor's requests addressed to VISITOR. Redirects are never followed.
37 */
38function send(url, { method = "GET", headers = {}, body } = {}) {
39 const data = body === undefined ? undefined : Buffer.from(typeof body === "string" ? body : body.toString());
40 return new Promise((res, rej) => {
41 const r = httpRequest(url, { method, headers: { ...headers, ...(data ? { "content-length": String(data.length) } : {}) } }, (resp) => {
42 const chunks = [];
43 resp.on("data", (c) => chunks.push(c));
44 resp.on("end", () => {
45 const text = Buffer.concat(chunks).toString("utf8");
46 const h = resp.headers;
47 res({
48 status: resp.statusCode,
49 headers: { get: (k) => (Array.isArray(h[k.toLowerCase()]) ? h[k.toLowerCase()].join(", ") : (h[k.toLowerCase()] ?? null)) },
50 text: async () => text,
51 json: async () => JSON.parse(text),
52 });
53 });
54 });
55 r.on("error", rej);
56 if (data) r.write(data);
57 r.end();
58 });
59}
60
61function stopWorker(dev) {
62 try {
63 process.kill(-dev.pid, "SIGTERM");
64 } catch {}
65}
66
67after(() => {
68 for (const dev of running) stopWorker(dev);
69 if (process.env.E2E_KEEP !== "1") rmSync(work, { recursive: true, force: true });
70});
71
72const T = { root: `dfy_root_${randomBytes(12).toString("hex")}`, ann: `dfy_ann_${randomBytes(12).toString("hex")}` };
73const S = { root: `root-session-${randomBytes(12).toString("hex")}`, ann: `ann-session-${randomBytes(12).toString("hex")}` };
74/** The Host a visitor who isn't signed in uses (see the header comment). */
75const VISITOR = "visitor.example";
76
77let w;
78async function worker() {
79 if (w) return w;
80 const port = await freePort();
81 const base = `http://127.0.0.1:${port}`;
82 const persist = join(work, "state");
83 const at = new Date().toISOString();
84 const later = new Date(Date.now() + 30 * 86400_000).toISOString();
85 const person = (id, login, email, admin) => `INSERT INTO accounts (id, login, kind, name, email, status, site_admin, created_at) VALUES ('${id}', '${login}', 'user', '${login}', '${email}', 'active', ${admin}, '${at}')`;
86 const token = (id, account, t) => `INSERT INTO tokens (id, account_id, name, token_hash, prefix, scopes, expires_at, created_at) VALUES ('${id}', '${account}', 'e2e', '${hash(t)}', '${t.slice(0, 10)}', '["admin"]', '${later}', '${at}')`;
87 const session = (account, cookie) => `INSERT INTO sessions (id, account_id, method, created_at, last_seen_at, expires_at) VALUES ('${hash(cookie)}', '${account}', 'passkey', '${at}', '${at}', '${later}')`;
88 const seed = [person("u-root", "root", "root@example.com", 1), person("u-ann", "ann", "ann@example.com", 0), token("t-root", "u-root", T.root), token("t-ann", "u-ann", T.ann), session("u-root", S.root), session("u-ann", S.ann)];
89 const wrangler = (...args) => execFileSync("pnpm", ["exec", "wrangler", ...args], { cwd: APP, stdio: "pipe", env: { ...process.env, CI: "1" } });
90 migratedState(APP, persist);
91 wrangler("d1", "execute", "diffinery-dev", "--local", "-e", "dev", "--persist-to", persist, "--command", seed.join("; "));
92 const args = ["exec", "wrangler", "dev", "-e", "dev", "--ip", "127.0.0.1", "--port", String(port), "--inspector-port", String(await freePort()), "--persist-to", persist, "--var", `PUBLIC_URL:${base}`, "--show-interactive-dev-session=false"];
93 const dev = spawn("pnpm", args, { cwd: APP, env: { ...process.env, CI: "1", WRANGLER_SEND_METRICS: "false" }, stdio: ["ignore", "pipe", "pipe"], detached: true });
94 let log = "";
95 dev.stdout.on("data", (d) => (log += d));
96 dev.stderr.on("data", (d) => (log += d));
97 running.push(dev);
98 const end = Date.now() + 120_000;
99 while (Date.now() < end) {
100 try {
101 if ((await fetch(`${base}/healthz`)).ok) break;
102 } catch {}
103 await sleep(500);
104 }
105 assert.ok((await fetch(`${base}/healthz`).catch(() => ({ ok: false }))).ok, `wrangler dev did not start\n${log.slice(-4000)}`);
106
107 /** Headers for a visitor (no `who`), or a signed-in person's browser. */
108 const headers = (who, extra = {}) => ({ ...(who ? { cookie: `__Host-dfy_session=${S[who]}` } : { host: VISITOR }), ...extra });
109 const page = (path, who) => send(`${base}${path}`, { headers: headers(who) });
110 const api = async (method, path, body, tok) => {
111 const r = await send(`${base}/api/v3${path}`, { method, headers: { "content-type": "application/json", ...(tok ? { authorization: `Bearer ${tok}` } : { host: VISITOR }) }, body: body === undefined ? undefined : JSON.stringify(body) });
112 const text = await r.text();
113 let json = null;
114 try {
115 json = text ? JSON.parse(text) : null;
116 } catch {
117 json = text;
118 }
119 return { status: r.status, json };
120 };
121 /** A form's CSRF pair and timing stamp, read from `path` the way a browser does. */
122 const formOf = async (path, who) => {
123 const res = await page(path, who);
124 assert.equal(res.status, 200, `GET ${path} -> ${res.status}`);
125 const html = await res.text();
126 const csrf = /name="_csrf" value="([^"]+)"/.exec(html)?.[1];
127 const pair = /dfy_csrf=([^;]+)/.exec(res.headers.get("set-cookie") ?? "")?.[1];
128 assert.ok(csrf && pair, `no CSRF pair on ${path}`);
129 return { csrf, pair, stamp: /name="stamp" value="([^"]*)"/.exec(html)?.[1] ?? "", html };
130 };
131 /** POST a form; `form` is from formOf. `ip` sets the address the Worker sees. */
132 const post = async (action, form, fields, who, ip) => {
133 const body = new URLSearchParams({ _csrf: form.csrf, ...(form.stamp ? { stamp: form.stamp } : {}), ...fields });
134 const cookie = [who ? `__Host-dfy_session=${S[who]}` : "", `dfy_csrf=${form.pair}`].filter(Boolean).join("; ");
135 const r = await send(`${base}${action}`, { method: "POST", body, headers: { ...headers(who), cookie, "content-type": "application/x-www-form-urlencoded", "sec-fetch-site": "same-origin", ...(ip ? { "cf-connecting-ip": ip } : {}) } });
136 // Apostrophes come back as &#39;; nothing else is unescaped, so the escaping checks still hold.
137 return { status: r.status, location: r.headers.get("location"), html: (await r.text()).replace(/&#39;/g, "'") };
138 };
139 /** Every email the Worker sent, newest first, with the whole message (DEV_AUTH keeps it). */
140 const emails = async () => {
141 const r = await api("GET", "/diffinery/emails?all=1&per_page=200", undefined, T.root);
142 assert.equal(r.status, 200, JSON.stringify(r.json));
143 return r.json;
144 };
145 const email = async (what, pred) => {
146 const end = Date.now() + 60_000;
147 while (Date.now() < end) {
148 const m = (await emails()).find(pred);
149 if (m) return m;
150 await sleep(500);
151 }
152 assert.fail(`timed out waiting for the email: ${what}\n${log.slice(-3000)}`);
153 };
154 const linkIn = (m, path) => {
155 const text = m.dev_copy?.text ?? "";
156 const found = new RegExp(`${base.replace(/[.]/g, "\\.")}${path}#([A-Za-z0-9_-]{20,100})`).exec(text);
157 assert.ok(found, `no ${path} link in:\n${text}`);
158 return found[1];
159 };
160 /** A form whose stamp is old enough to count as a person's (3 seconds). */
161 const ready = async (path = "/contact", who) => {
162 const f = await formOf(path, who);
163 await sleep(3200);
164 return f;
165 };
166 w = { base, page, api, formOf, post, emails, email, linkIn, ready, log: () => log };
167 return w;
168}
169
170const message = (email, over = {}) => ({ email, name: "Ada", topic: "question", message: "Does Diffinery run my workflows as they are?", website: "", ...over });
171const byEmail = async (api, address) => (await api("GET", `/diffinery/admin/contact?q=${encodeURIComponent(address)}`, undefined, T.root)).json;
172let nextIp = 1;
173/** A fresh address for the Worker to see each request from, so the daily limit per network doesn't mix tests. */
174const ip = () => `198.51.100.${nextIp++}`;
175
176test("contact: the footer, sitemap, robots.txt and llms.txt lead to the form", async () => {
177 const { page } = await worker();
178 assert.match(await (await page("/")).text(), /href="\/contact">Contact</, "the footer links it");
179 assert.match(await (await page("/about")).text(), /href="https:\/\/diffinery\.com\/contact"/, "the About page links it");
180 const form = await page("/contact");
181 assert.equal(form.status, 200);
182 const html = await form.text();
183 assert.match(html, /<h1>Contact us<\/h1>/);
184 for (const name of ["name", "email", "message", "website", "stamp"]) assert.match(html, new RegExp(`name="${name}"`), name);
185 assert.match(html, /data-k="select" data-p="[^"]*&quot;name&quot;:&quot;topic&quot;/, "the topic is a Kumo select");
186 assert.doesNotMatch(html, /<select|type="checkbox"/, "Kumo controls only");
187 assert.match(html, /"@type":"ContactPage"/, "structured data for search engines");
188 assert.match(html, /href="\/security\/report"/, "security problems go to the private form");
189 assert.match(await (await page("/contact?topic=press")).text(), /name="topic" value="press"/, "a link can choose the topic");
190 assert.doesNotMatch(html, /name="topic" value=/, "no topic until one is chosen");
191 // Signed in: no email field, the account's address is used.
192 const ann = (await (await page("/contact", "ann")).text()).replace(/&#39;/g, "'");
193 assert.doesNotMatch(ann, /name="email"/);
194 assert.match(ann, /Signed in as ann\. We'll reply to ann@example\.com\./);
195 assert.match(ann, /href="\/contact">Contact</, "the signed-in footer links it too");
196 // Search engines find the form but not the confirmation page.
197 const robots = await (await page("/robots.txt")).text();
198 assert.match(robots, /Disallow: \/contact\/confirm/);
199 assert.doesNotMatch(robots, /Disallow: \/contact\n/);
200 assert.match(await (await page("/sitemap.xml")).text(), /:\d+\/contact<\/loc>/);
201 assert.match(await (await page("/llms.txt")).text(), /Contact the Diffinery team: http:\/\/[^ ]+\/contact/);
202 assert.match(await (await page("/contact/confirm")).text(), /name="robots" content="noindex/);
203});
204
205test("contact: a visitor's message is confirmed with the emailed link, then reaches the site administrators", async () => {
206 const { page, api, post, email, linkIn, ready } = await worker();
207 const f = await ready();
208 const hostile = { name: "Ada <script>alert(1)</script>", topic: "partnership", message: "Hello team,\n<img src=x onerror=alert(1)>\nLet's talk." };
209 const sent = await post("/contact", f, message("Ada@Example.net", hostile), undefined, ip());
210 assert.equal(sent.status, 200, sent.html.slice(0, 600));
211 assert.match(sent.html, /Check your inbox/);
212 assert.match(sent.html, /ada@example\.net/);
213 const m = await email("the confirmation", (e) => e.kind === "contact_confirm" && e.to_addr === "ada@example.net");
214 assert.equal(m.subject, "[Diffinery] Confirm your message to the Diffinery team");
215 assert.match(m.dev_copy.text, /Partnerships and integrations/);
216 assert.doesNotMatch(JSON.stringify(m.dev_copy), /Ada|onerror|Let's talk/, "nothing typed in the form goes into the email");
217 const token = linkIn(m, "/contact/confirm");
218
219 // Unconfirmed: nobody is told yet.
220 const [before] = await byEmail(api, "ada@example.net");
221 assert.equal(before.state, "unconfirmed");
222 assert.doesNotMatch(await (await page("/settings/admin/contact", "root")).text(), /ada@example\.net/);
223
224 const cf = { ...(await w.formOf("/contact/confirm")), stamp: "" };
225 const wrong = await post("/contact/confirm", cf, { token: "x".repeat(40) });
226 assert.equal(wrong.status, 410);
227 assert.match(wrong.html, /This link doesn't work any more/);
228 const ok = await post("/contact/confirm", cf, { token });
229 assert.equal(ok.status, 200, ok.html.slice(0, 600));
230 assert.match(ok.html, /Your message is with the team/);
231 assert.ok(ok.html.includes("Ada &lt;script&gt;alert(1)&lt;/script&gt;"), "the name is escaped");
232 assert.equal((await post("/contact/confirm", cf, { token })).status, 410, "a link works once");
233
234 const [after] = await byEmail(api, "ada@example.net");
235 assert.equal(after.state, "new");
236 assert.deepEqual({ name: after.name, topic: after.topic, message: after.message, sender: after.sender }, { name: hostile.name, topic: "partnership", message: hostile.message, sender: null });
237 assert.ok(!("confirm_hash" in after));
238 // The site administrators get an email to answer from their mailbox, and a notification.
239 const note = await email("the site administrators' email", (e) => e.kind === "contact_message" && e.to_addr === "root@example.com");
240 assert.equal(note.subject, "[Diffinery] Contact: Partnerships and integrations from Ada <script>alert(1)</script>");
241 assert.deepEqual(note.dev_copy.replyTo, { name: hostile.name, email: "ada@example.net" });
242 assert.match(note.dev_copy.text, /> Hello team,\n> <img src=x onerror=alert\(1\)>\n> Let's talk\./);
243 assert.ok(!note.dev_copy.html.includes("<img src=x"), "the message is escaped in the HTML email");
244 assert.match(await (await page("/notifications", "root")).text(), /New contact messages are waiting for you/);
245 const admin = await (await page("/settings/admin/contact", "root")).text();
246 assert.match(admin, /ada@example\.net/);
247 assert.ok(!admin.includes("<script>alert(1)") && !admin.includes("<img src=x"), "everything is escaped");
248 assert.match(admin, /Contact messages\s*<span class="badge blue"[^>]*>\s*1\s*</, "the sidebar counts what waits");
249 const detail = (await (await page(`/settings/admin/contact/${after.id}`, "root")).text()).replace(/&#39;/g, "'");
250 assert.ok(detail.includes("Hello team,\n&lt;img src=x onerror=alert(1)&gt;\nLet's talk."), "the message is plain text");
251 assert.match(detail, /href="mailto:ada@example\.net\?subject=Re%3A%20Partnerships%20and%20integrations"/);
252});
253
254test("contact: a signed-in person's message arrives at once, with a copy for them", async () => {
255 const { api, post, email, formOf } = await worker();
256 const f = await formOf("/contact", "ann");
257 assert.equal(f.stamp, "", "no timing stamp for a signed-in person");
258 const sent = await post("/contact", f, { name: "Ann", topic: "account", message: "My invitation link expired, can you resend?", email: "someone-else@example.net" }, "ann");
259 assert.equal(sent.status, 200, sent.html.slice(0, 600));
260 assert.match(sent.html, /Message sent/);
261 const [m] = await byEmail(api, "ann@example.com");
262 assert.equal(m.state, "new");
263 assert.equal(m.sender, "ann");
264 assert.ok(m.confirmed_at);
265 assert.deepEqual(await byEmail(api, "someone-else@example.net"), [], "the address typed is ignored");
266 const copy = await email("the copy", (e) => e.kind === "contact_received" && e.to_addr === "ann@example.com");
267 assert.equal(copy.subject, "[Diffinery] We got your message");
268 assert.match(copy.dev_copy.text, /My invitation link expired/);
269 const note = await email("the site administrators' email", (e) => e.kind === "contact_message" && e.dev_copy?.replyTo?.email === "ann@example.com");
270 assert.match(note.dev_copy.text, /From: Ann \(@ann\)/);
271});
272
273test("contact: bots, bad input and big bodies get nowhere", async () => {
274 const { api, post, emails, formOf, ready } = await worker();
275 const fresh = await formOf("/contact");
276 // Too fast after the page was shown, a filled honeypot, or a forged or missing stamp: the usual page, nothing kept or sent.
277 const fast = await post("/contact", fresh, message("bot1@example.net"), undefined, ip());
278 assert.equal(fast.status, 200);
279 assert.match(fast.html, /Check your inbox/);
280 const f = await ready();
281 const honey = await post("/contact", f, message("bot2@example.net", { website: "https://spam.example" }), undefined, ip());
282 assert.match(honey.html, /Check your inbox/);
283 const forged = await post("/contact", { ...f, stamp: `${Date.now() - 10_000}.${"A".repeat(43)}` }, message("bot3@example.net"), undefined, ip());
284 assert.match(forged.html, /Check your inbox/);
285 const missing = await post("/contact", { ...f, stamp: "" }, message("bot4@example.net"), undefined, ip());
286 assert.match(missing.html, /Check your inbox/);
287 for (const a of ["bot1", "bot2", "bot3", "bot4"]) assert.deepEqual(await byEmail(api, `${a}@example.net`), [], a);
288
289 // Bad input is refused with the reason, keeping what was typed.
290 const long = await post("/contact", f, message("long@example.net", { message: "x".repeat(5001), name: "Keep me" }), undefined, ip());
291 assert.equal(long.status, 422);
292 assert.match(long.html, /Keep your message to 5,000 characters/);
293 assert.match(long.html, /value="Keep me"/);
294 const links = await post("/contact", f, message("links@example.net", { message: Array.from({ length: 6 }, (_, i) => `https://s${i}.example`).join(" ") }), undefined, ip());
295 assert.equal(links.status, 422);
296 assert.match(links.html, /5 links or fewer/);
297 const bad = await post("/contact", f, message("not-an-address"), undefined, ip());
298 assert.equal(bad.status, 422);
299 assert.match(bad.html, /Enter a valid email address/);
300 const topic = await post("/contact", f, message("t@example.net", { topic: "<script>" }), undefined, ip());
301 assert.equal(topic.status, 422);
302 assert.ok(!topic.html.includes('value="<script>"'));
303 const huge = await post("/contact", f, message("huge@example.net", { message: "y".repeat(80_000) }), undefined, ip());
304 assert.equal(huge.status, 413);
305 await sleep(1500);
306 const tried = new Set(["bot1", "bot2", "bot3", "bot4", "long", "links", "t", "huge"].map((n) => `${n}@example.net`));
307 assert.deepEqual((await emails()).filter((e) => tried.has(e.to_addr)), [], "nothing was emailed");
308});
309
310test("contact: only site administrators see messages (404 for anyone else)", async () => {
311 const { page, api, post, formOf } = await worker();
312 const [ada] = await byEmail(api, "ada@example.net");
313 assert.ok(ada, "the message from the tests above");
314 for (const path of ["/settings/admin/contact", `/settings/admin/contact/${ada.id}`]) {
315 assert.equal((await page(path, "ann")).status, 404, `${path} for a person who isn't a site administrator`);
316 const anon = await page(path);
317 assert.equal(anon.status, 302, `${path} sends a visitor to sign in`);
318 assert.match(anon.headers.get("location") ?? "", /^\/login\?next=/);
319 }
320 const annForm = await formOf("/settings", "ann");
321 for (const action of ["state", "note", "delete"]) assert.equal((await post(`/settings/admin/contact/${ada.id}/${action}`, annForm, { state: "spam", note: "x" }, "ann")).status, 404, action);
322 assert.equal((await api("GET", "/diffinery/admin/contact", undefined, T.ann)).status, 404);
323 assert.equal((await api("GET", `/diffinery/admin/contact/${ada.id}`, undefined, T.ann)).status, 404);
324 assert.equal((await api("PATCH", `/diffinery/admin/contact/${ada.id}`, { state: "spam" }, T.ann)).status, 404);
325 assert.equal((await api("DELETE", `/diffinery/admin/contact/${ada.id}`, undefined, T.ann)).status, 404);
326 assert.equal((await api("GET", "/diffinery/admin/contact")).status, 401);
327 assert.doesNotMatch(await (await page("/settings", "ann")).text(), /href="\/settings\/admin\/contact"/);
328 assert.equal((await byEmail(api, "ada@example.net"))[0].state, "new", "nothing changed");
329});
330
331test("contact: handle, note, spam, reopen and delete, on the web and through the API, audited", async () => {
332 const { page, api, post, formOf, email, emails, linkIn, ready } = await worker();
333 const [ada] = await byEmail(api, "ada@example.net");
334 // Mark handled from the web, with a note.
335 const handled = await post(`/settings/admin/contact/${ada.id}/state`, await formOf("/settings/admin/contact", "root"), { state: "handled", back: "/settings/admin/contact" }, "root");
336 assert.equal(handled.status, 303);
337 assert.equal(handled.location, "/settings/admin/contact");
338 const noted = await post(`/settings/admin/contact/${ada.id}/note`, await formOf(`/settings/admin/contact/${ada.id}`, "root"), { note: "Replied with the partner guide." }, "root");
339 assert.equal(noted.status, 303);
340 const [now] = await byEmail(api, "ada@example.net");
341 assert.deepEqual({ state: now.state, note: now.note, updated_by: now.updated_by }, { state: "handled", note: "Replied with the partner guide.", updated_by: "root" });
342 assert.match(await (await page("/settings/admin/contact?state=handled", "root")).text(), /handled by root/);
343 assert.match(await (await page(`/settings/admin/contact/${ada.id}`, "root")).text(), />Replied with the partner guide\.</);
344
345 // A second message: spam through the API, after which its address gets nothing through; reopen; then delete on the web.
346 const f = await ready();
347 await post("/contact", f, message("bob@example.net", { message: "Buy cheap watches, secret plans." }), undefined, ip());
348 const token = linkIn(await email("bob's confirmation", (e) => e.kind === "contact_confirm" && e.to_addr === "bob@example.net"), "/contact/confirm");
349 await post("/contact/confirm", { ...(await formOf("/contact/confirm")), stamp: "" }, { token });
350 const [bob] = await byEmail(api, "bob@example.net");
351 assert.equal(bob.state, "new");
352 const spam = await api("PATCH", `/diffinery/admin/contact/${bob.id}`, { state: "spam" }, T.root);
353 assert.equal(spam.status, 200, JSON.stringify(spam.json));
354 assert.equal(spam.json.state, "spam");
355 assert.equal((await api("PATCH", `/diffinery/admin/contact/${bob.id}`, { state: "deleted" }, T.root)).status, 422);
356 const toBob = async () => (await emails()).filter((e) => e.to_addr === "bob@example.net").length;
357 const sent = await toBob();
358 const again = await post("/contact", f, message("bob@example.net"), undefined, ip());
359 assert.match(again.html, /Check your inbox/, "the same page");
360 await sleep(1500);
361 assert.equal(await toBob(), sent, "nothing is emailed to an address marked spam");
362 assert.equal((await byEmail(api, "bob@example.net")).length, 1, "and nothing is stored");
363 assert.equal((await api("PATCH", `/diffinery/admin/contact/${bob.id}`, { state: "new", note: "Not spam after all" }, T.root)).json.state, "new");
364 const del = await post(`/settings/admin/contact/${bob.id}/delete`, await formOf(`/settings/admin/contact/${bob.id}`, "root"), {}, "root");
365 assert.equal(del.status, 303);
366 assert.equal(del.location, "/settings/admin/contact");
367 assert.equal((await api("GET", `/diffinery/admin/contact/${bob.id}`, undefined, T.root)).status, 404);
368
369 const audit = await (await page("/settings/audit", "root")).text();
370 for (const action of ["contact.create", "contact.confirm", "contact.handle", "contact.note", "contact.spam", "contact.reopen", "contact.delete"]) assert.ok(audit.includes(action), `${action} is audited`);
371 assert.ok(!audit.includes("secret plans") && !audit.includes("partner guide") && !audit.includes("onerror"), "the audit log holds no message content");
372});
373
374test("contact: daily limits per network and per address", async () => {
375 const { api, post, emails, ready } = await worker();
376 const f = await ready();
377 // One address: three emails a day, then the page stays the same and nothing more is sent.
378 const flood = "flood@example.net";
379 for (let i = 0; i < 4; i++) assert.equal((await post("/contact", f, message(flood), undefined, ip())).status, 200);
380 await sleep(1500);
381 assert.equal((await emails()).filter((e) => e.to_addr === flood).length, 3);
382 assert.equal((await byEmail(api, flood)).length, 3);
383 // One network: ten submissions a day, then a visible refusal.
384 const net = "203.0.113.77";
385 for (let i = 0; i < 10; i++) assert.equal((await post("/contact", f, message(`n${i}@example.net`), undefined, net)).status, 200, `submission ${i + 1}`);
386 const over = await post("/contact", f, message("n10@example.net"), undefined, net);
387 assert.equal(over.status, 429);
388 assert.match(over.html, /Too many messages from your network today/);
389 assert.deepEqual(await byEmail(api, "n10@example.net"), []);
390 // Another network still gets through.
391 assert.equal((await post("/contact", f, message("other@example.net"), undefined, ip())).status, 200);
392});