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.

326 lines · 20.4 KBRaw History
1import { describe, expect, it } from "vitest";
2import type { Env } from "../../src/env.js";
3import type { Account, Directory } from "../../src/lib/directory.js";
4import {
5 EMAILS_PER_ADDRESS_PER_DAY,
6 EMAILS_PER_DAY,
7 EarlyAccess,
8 MAX_COMPANY,
9 MAX_NAME,
10 MAX_USE_CASE,
11 MIN_FILL_MS,
12 MAX_FORM_AGE_MS,
13 SUBMISSIONS_PER_IP_PER_DAY,
14 checkStamp,
15 confirmRequest,
16 declineRequest,
17 deleteRequest,
18 formStamp,
19 inviteRequest,
20 removeByToken,
21 requestJson,
22 requestRemoval,
23 submitRequest,
24 validateRequest,
25 type RequestInput,
26} from "../../src/lib/early-access.js";
27import { pruneExpired } from "../../src/lib/retention.js";
28import { HttpError } from "../../src/lib/util.js";
29import type { MailJob } from "../../src/mail/types.js";
30import { testD1 } from "./stubs/d1.js";
31
32const at = "2026-10-01T00:00:00.000Z";
33const PUB = "https://diffinery.example";
34const MASTER_KEY = btoa(String.fromCharCode(...new Uint8Array(32).fill(7)));
35
36/** A D1 with two site administrators (one disabled), a person, a disabled person, and a mail queue that records what it gets. */
37function setup() {
38 const DB = testD1();
39 const add = DB.sqlite.prepare("INSERT INTO accounts (id, login, kind, email, status, site_admin, created_at) VALUES (?, ?, 'user', ?, ?, ?, ?)");
40 add.run("u-root", "root", "root@example.com", "active", 1, at);
41 add.run("u-old", "old", "old@example.com", "disabled", 1, at);
42 add.run("u-ann", "ann", "ann@example.com", "active", 0, at);
43 add.run("u-gone", "gone", "gone@example.com", "disabled", 0, at);
44 const queued: MailJob[] = [];
45 const MAIL = { sendBatch: async (b: Array<{ body: MailJob }>) => void queued.push(...b.map((m) => m.body)) };
46 const env = { DB, MAIL, PUBLIC_URL: PUB, MASTER_KEY } as unknown as Env;
47 const audits: Array<{ action: string; target?: string; actorLogin?: string; detail?: unknown }> = [];
48 const audit = async (e: (typeof audits)[number]) => void audits.push(e);
49 const root = DB.sqlite.prepare("SELECT * FROM accounts WHERE id = 'u-root'").get() as unknown as Account;
50 const invites: Array<{ email: string; role: string }> = [];
51 const dir = {
52 accountByEmail: async (email: string) => (DB.sqlite.prepare("SELECT * FROM accounts WHERE email = ? AND kind = 'user'").get(email) as unknown as Account) ?? null,
53 createInvite: async (_inviter: Account, input: { email: string; role: string }) => {
54 invites.push(input);
55 const id = `inv-${invites.length}`;
56 DB.sqlite.prepare("INSERT INTO invites (id, email, role, token_hash, invited_by, created_at, expires_at) VALUES (?, ?, ?, ?, ?, ?, ?)").run(id, input.email, input.role, `h-${id}`, "u-root", at, "2999-01-01T00:00:00.000Z");
57 return { id, url: `${PUB}/invite/x`, token: "x" };
58 },
59 } as unknown as Directory;
60 return { env, DB, queued, audits, audit, root, dir, invites };
61}
62
63const refused = (f: () => unknown, status: number, msg: RegExp) => {
64 try {
65 f();
66 } catch (e) {
67 expect(e).toBeInstanceOf(HttpError);
68 expect((e as HttpError).status).toBe(status);
69 expect((e as HttpError).detail).toMatch(msg);
70 return;
71 }
72 throw new Error("expected it to be refused");
73};
74
75const rejects = async (p: Promise<unknown>, status: number, msg: RegExp) => {
76 const e = await p.then(
77 () => null,
78 (x: unknown) => x,
79 );
80 expect(e).toBeInstanceOf(HttpError);
81 expect((e as HttpError).status).toBe(status);
82 expect((e as HttpError).detail).toMatch(msg);
83};
84
85const input = (email: string, over: Partial<RequestInput> = {}): RequestInput => ({ email, name: "Ada", company: "Acme", useCase: "Move our CI", hosting: "github", ...over });
86const tokenOf = (job: MailJob | undefined) => /#([A-Za-z0-9_-]+)$/.exec((job as { data: { url: string } }).data.url)![1]!;
87
88describe("request validation", () => {
89 it("needs only a valid email address, lower-cases it and tidies the other fields", () => {
90 expect(validateRequest({ email: " Ada@Example.COM " })).toEqual({ email: "ada@example.com", name: null, company: null, useCase: null, hosting: null });
91 expect(validateRequest({ email: "a@b.co", name: " Ada \n Lovelace ", company: " Acme\tLabs ", useCase: "\r\nLine one\r\n\r\n\r\n\r\nLine two ", hosting: "gitlab" })).toEqual({ email: "a@b.co", name: "Ada Lovelace", company: "Acme Labs", useCase: "Line one\n\nLine two", hosting: "gitlab" });
92 });
93
94 it("strips control and text-direction characters that could disguise text", () => {
95 expect(validateRequest({ email: "a@b.co", name: "Ad‮a\u0000⁦x", useCase: "ok\u0007‏" })).toMatchObject({ name: "Adax", useCase: "ok" });
96 });
97
98 it("refuses a missing or bad address, too much text (never cut) and an unknown hosting answer", () => {
99 refused(() => validateRequest({ email: " " }), 422, /Enter your email address/);
100 refused(() => validateRequest({ email: "not-an-address" }), 422, /valid email address/);
101 refused(() => validateRequest({ email: "<script>@x.y" }), 422, /valid email address/);
102 refused(() => validateRequest({ email: `${"a".repeat(250)}@b.co` }), 422, /valid email address/);
103 refused(() => validateRequest({ email: "a@b.co", name: "x".repeat(MAX_NAME + 1) }), 422, /100 characters/);
104 refused(() => validateRequest({ email: "a@b.co", company: "x".repeat(MAX_COMPANY + 1) }), 422, /120 characters/);
105 refused(() => validateRequest({ email: "a@b.co", useCase: "x".repeat(MAX_USE_CASE + 1) }), 422, /1,000 characters/);
106 expect(validateRequest({ email: "a@b.co", useCase: "x".repeat(MAX_USE_CASE) }).useCase).toHaveLength(MAX_USE_CASE);
107 refused(() => validateRequest({ email: "a@b.co", hosting: "sourceforge" }), 422, /from the list/);
108 refused(() => validateRequest({ email: ["a@b.co"] }), 422, /Enter your email address/);
109 });
110});
111
112describe("the form's timing stamp", () => {
113 const env = { MASTER_KEY, PUBLIC_URL: PUB } as unknown as Env;
114
115 it("takes a genuine stamp between 3 seconds and 24 hours old", async () => {
116 const t = Date.now();
117 const stamp = await formStamp(env, t);
118 expect(await checkStamp(env, stamp, t + MIN_FILL_MS + 1)).toBe("ok");
119 expect(await checkStamp(env, stamp, t + 500)).toBe("fast");
120 expect(await checkStamp(env, stamp, t + MAX_FORM_AGE_MS + 1)).toBe("stale");
121 });
122
123 it("refuses a missing, forged or future stamp, and one signed with another key", async () => {
124 const t = Date.now();
125 const stamp = await formStamp(env, t);
126 expect(await checkStamp(env, "", t)).toBe("bad");
127 expect(await checkStamp(env, `${t - 60_000}.${stamp.split(".")[1]}`, t)).toBe("bad");
128 expect(await checkStamp(env, await formStamp(env, t + 3600_000), t)).toBe("bad");
129 const other = { MASTER_KEY: btoa("x".repeat(32)), PUBLIC_URL: PUB } as unknown as Env;
130 expect(await checkStamp(other, stamp, t + 5000)).toBe("bad");
131 // A key rotation still takes stamps made with the previous key.
132 expect(await checkStamp({ ...other, MASTER_KEY_PREV: MASTER_KEY } as Env, stamp, t + 5000)).toBe("ok");
133 });
134});
135
136describe("requesting and confirming", () => {
137 it("stores a new request unconfirmed and emails only a confirmation link, with nothing typed in it", async () => {
138 const { env, queued } = setup();
139 expect(await submitRequest(env, input("new@example.net", { name: "<b>Ada</b>" }), "203.0.113.9")).toBe("confirm");
140 const r = (await new EarlyAccess(env.DB).byEmail("new@example.net"))!;
141 expect(r).toMatchObject({ state: "unconfirmed", name: "<b>Ada</b>", company: "Acme", use_case: "Move our CI", hosting: "github", confirmed_at: null });
142 expect(r.confirm_hash).toMatch(/^[0-9a-f]{64}$/);
143 expect(queued).toHaveLength(1);
144 expect(queued[0]).toMatchObject({ kind: "early_access_confirm", to: { email: "new@example.net" }, reason: "early_access" });
145 expect((queued[0] as { data: { url: string } }).data.url).toMatch(new RegExp(`^${PUB}/early-access/confirm#[A-Za-z0-9_-]{32}$`));
146 expect(JSON.stringify(queued)).not.toMatch(/Ada|Acme|Move our CI/);
147 // Only hashes of the IP address and the address are kept for the limits.
148 const attempts = env.DB.sqlite.prepare("SELECT * FROM early_access_attempts").all() as Array<Record<string, string>>;
149 expect(JSON.stringify(attempts)).not.toMatch(/203\.0\.113\.9|new@example/);
150 });
151
152 it("confirms once with the newest link, and tells every active site administrator once", async () => {
153 const { env, queued, audits, audit, DB } = setup();
154 await submitRequest(env, input("new@example.net"), "203.0.113.9");
155 const first = tokenOf(queued[0]);
156 await submitRequest(env, input("new@example.net", { name: "Ada L." }), "203.0.113.9");
157 const second = tokenOf(queued[1]);
158 expect(await confirmRequest(env, audit, first)).toBeNull();
159 const r = await confirmRequest(env, audit, second);
160 expect(r).toMatchObject({ state: "confirmed", name: "Ada L.", confirm_hash: null });
161 expect(await confirmRequest(env, audit, second)).toBeNull();
162 expect(audits).toEqual([{ action: "early_access.confirm", target: r!.id }]);
163 const notes = () => DB.sqlite.prepare("SELECT account_id, kind, url FROM notifications").all();
164 expect(notes()).toEqual([{ account_id: "u-root", kind: "early_access", url: "/settings/admin/early-access" }]);
165 // Another confirmation while that one is unread adds nothing.
166 await submitRequest(env, input("two@example.net"), "203.0.113.10");
167 await confirmRequest(env, audit, tokenOf(queued[2]));
168 expect(notes()).toHaveLength(1);
169 expect(await new EarlyAccess(env.DB).waiting()).toBe(2);
170 });
171
172 it("refuses an expired, malformed or unknown token", async () => {
173 const { env, queued, audit, DB } = setup();
174 await submitRequest(env, input("new@example.net"), "203.0.113.9");
175 DB.sqlite.prepare("UPDATE early_access SET confirm_expires_at = ?").run(at);
176 expect(await confirmRequest(env, audit, tokenOf(queued[0]))).toBeNull();
177 expect(await confirmRequest(env, audit, "short")).toBeNull();
178 expect(await confirmRequest(env, audit, "x".repeat(40))).toBeNull();
179 });
180
181 it("answers an address that already asked, was invited or has an account only in its own inbox, and changes nothing", async () => {
182 const { env, queued, audit } = setup();
183 await submitRequest(env, input("new@example.net"), "203.0.113.9");
184 await confirmRequest(env, audit, tokenOf(queued[0]));
185 expect(await submitRequest(env, input("new@example.net", { name: "Mallory" }), "198.51.100.1")).toBe("known");
186 expect(queued[1]).toMatchObject({ kind: "early_access_known", data: { variant: "waiting" }, to: { email: "new@example.net" } });
187 expect((await new EarlyAccess(env.DB).byEmail("new@example.net"))!.name).toBe("Ada");
188 expect(await submitRequest(env, input("ANN@example.com"), "198.51.100.1")).toBe("account");
189 expect(queued[2]).toMatchObject({ kind: "early_access_known", data: { variant: "account" }, to: { accountId: "u-ann" } });
190 expect(await new EarlyAccess(env.DB).byEmail("ann@example.com")).toBeNull();
191 expect(await submitRequest(env, input("gone@example.com"), "198.51.100.1")).toBe("quiet");
192 expect(queued).toHaveLength(3);
193 });
194
195 it("limits submissions per IP address (a visible 429) and emails per address and in total (silently)", async () => {
196 const { env, queued, DB } = setup();
197 for (let i = 0; i < EMAILS_PER_ADDRESS_PER_DAY; i++) expect(await submitRequest(env, input("flood@example.net"), `10.0.0.${i}`)).toBe("confirm");
198 expect(await submitRequest(env, input("flood@example.net"), "10.0.1.1")).toBe("limited");
199 expect(queued).toHaveLength(EMAILS_PER_ADDRESS_PER_DAY);
200 for (let i = 0; i < SUBMISSIONS_PER_IP_PER_DAY; i++) await submitRequest(env, input(`p${i}@example.net`), "192.0.2.1");
201 await rejects(submitRequest(env, input("one-more@example.net"), "192.0.2.1"), 429, /Too many requests from your network today/);
202 // Everyone together: the form stops emailing at EMAILS_PER_DAY.
203 const add = DB.sqlite.prepare("INSERT INTO early_access_attempts (id, ip_hash, email_hash, emailed, created_at) VALUES (?, 'x', 'y', 1, ?)");
204 for (let i = 0; i < EMAILS_PER_DAY; i++) add.run(`a${i}`, new Date().toISOString());
205 const before = queued.length;
206 expect(await submitRequest(env, input("late@example.net"), "192.0.2.200")).toBe("limited");
207 expect(queued).toHaveLength(before);
208 expect(await new EarlyAccess(env.DB).byEmail("late@example.net")).toBeNull();
209 });
210});
211
212describe("site administrators' decisions", () => {
213 async function confirmed(s: ReturnType<typeof setup>, email: string) {
214 await submitRequest(s.env, input(email), "203.0.113.9");
215 return (await confirmRequest(s.env, s.audit, tokenOf(s.queued.at(-1))))!;
216 }
217
218 it("invites with the instance invitation, audited, and won't invite twice or invite an account", async () => {
219 const s = setup();
220 const r = await confirmed(s, "new@example.net");
221 const done = await inviteRequest(s.env, s.dir, s.audit, s.root, r.id, "web");
222 expect(done).toMatchObject({ state: "invited", invite_id: "inv-1", decided_by: "root" });
223 expect(s.invites).toEqual([{ email: "new@example.net", role: "read" }]);
224 expect(s.audits.slice(-2)).toEqual([
225 { actorId: "u-root", actorLogin: "root", action: "invite.create", target: "new@example.net" },
226 { actorId: "u-root", actorLogin: "root", action: "early_access.invite", target: r.id, detail: { invite: "inv-1", from: "confirmed", via: "web" } },
227 ]);
228 await rejects(inviteRequest(s.env, s.dir, s.audit, s.root, r.id, "web"), 422, /already invited/);
229 // A request whose address then got an account.
230 s.DB.sqlite.prepare("INSERT INTO early_access (id, email, state, created_at, updated_at) VALUES ('0123456789abcdef0123456789abcdef', 'ann@example.com', 'confirmed', ?, ?)").run(at, at);
231 await rejects(inviteRequest(s.env, s.dir, s.audit, s.root, "0123456789abcdef0123456789abcdef", "api"), 422, /already has an account \(ann\)/);
232 await rejects(inviteRequest(s.env, s.dir, s.audit, s.root, "ffffffffffffffffffffffffffffffff", "api"), 404, /no early access request/);
233 });
234
235 it("keeps a pending instance invitation instead of sending another", async () => {
236 const s = setup();
237 const r = await confirmed(s, "pending@example.net");
238 s.DB.sqlite.prepare("INSERT INTO invites (id, email, role, token_hash, invited_by, created_at, expires_at) VALUES ('inv-old', 'pending@example.net', 'read', 'h', 'u-root', ?, '2999-01-01T00:00:00.000Z')").run(at);
239 expect(await inviteRequest(s.env, s.dir, s.audit, s.root, r.id, "api")).toMatchObject({ state: "invited", invite_id: "inv-old" });
240 expect(s.invites).toEqual([]);
241 expect(s.audits.at(-1)).toMatchObject({ action: "early_access.invite", detail: { invite: "inv-old", existing_invitation: true } });
242 });
243
244 it("declines without email, puts back where it was, and deletes without keeping the content", async () => {
245 const s = setup();
246 const r = await confirmed(s, "new@example.net");
247 const mails = s.queued.length;
248 expect(await declineRequest(s.env.DB, s.audit, s.root, r.id, false, "web")).toMatchObject({ state: "declined", decided_by: "root" });
249 await rejects(declineRequest(s.env.DB, s.audit, s.root, r.id, false, "web"), 422, /already declined/);
250 expect(await declineRequest(s.env.DB, s.audit, s.root, r.id, true, "api")).toMatchObject({ state: "confirmed", decided_by: null });
251 await rejects(declineRequest(s.env.DB, s.audit, s.root, r.id, true, "api"), 422, /Only a declined request/);
252 expect(s.queued).toHaveLength(mails);
253 await deleteRequest(s.env.DB, s.audit, s.root, r.id, "web");
254 expect(await new EarlyAccess(s.env.DB).get(r.id)).toBeNull();
255 expect(s.audits.at(-1)).toEqual({ actorId: "u-root", actorLogin: "root", action: "early_access.delete", target: r.id, detail: { state: "confirmed", via: "web" } });
256 expect(JSON.stringify(s.audits)).not.toMatch(/new@example\.net/);
257 // An unconfirmed request that is declined then put back is unconfirmed again.
258 await submitRequest(s.env, input("u@example.net"), "203.0.113.9");
259 const u = (await new EarlyAccess(s.env.DB).byEmail("u@example.net"))!;
260 await declineRequest(s.env.DB, s.audit, s.root, u.id, false, "web");
261 expect((await declineRequest(s.env.DB, s.audit, s.root, u.id, true, "web")).state).toBe("unconfirmed");
262 });
263
264 it("lists newest first with filters, search (LIKE characters taken literally), pages and counts", async () => {
265 const s = setup();
266 const ins = s.DB.sqlite.prepare("INSERT INTO early_access (id, email, name, company, use_case, state, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)");
267 ins.run("a".repeat(32), "a@x.io", "Ann", "Acme", "CI for 100% of repos", "confirmed", "2026-10-01T00:00:00Z", at);
268 ins.run("b".repeat(32), "b@x.io", "Bob", "Beta_Co", null, "invited", "2026-10-02T00:00:00Z", at);
269 ins.run("c".repeat(32), "c@x.io", null, null, null, "confirmed", "2026-10-03T00:00:00Z", at);
270 const store = new EarlyAccess(s.env.DB);
271 expect((await store.list()).items.map((r) => r.email)).toEqual(["c@x.io", "b@x.io", "a@x.io"]);
272 expect((await store.list({ state: "confirmed" })).items.map((r) => r.email)).toEqual(["c@x.io", "a@x.io"]);
273 expect((await store.list({ q: "beta_" })).items.map((r) => r.email)).toEqual(["b@x.io"]);
274 expect((await store.list({ q: "100%" })).items.map((r) => r.email)).toEqual(["a@x.io"]);
275 expect((await store.list({ q: "%" })).items.map((r) => r.email)).toEqual(["a@x.io"]);
276 expect((await store.list({ q: "_" })).items.map((r) => r.email)).toEqual(["b@x.io"]);
277 expect(await store.list({ limit: 2 })).toMatchObject({ more: true });
278 expect((await store.list({ limit: 2, offset: 2 })).items.map((r) => r.email)).toEqual(["a@x.io"]);
279 expect(await store.counts()).toEqual({ confirmed: 2, unconfirmed: 0, invited: 1, declined: 0 });
280 expect(requestJson(s.env, (await store.get("a".repeat(32)))!)).toMatchObject({ id: "a".repeat(32), state: "confirmed", email: "a@x.io", html_url: `${PUB}/settings/admin/early-access/${"a".repeat(32)}` });
281 expect(requestJson(s.env, (await store.get("a".repeat(32)))!)).not.toHaveProperty("confirm_hash");
282 });
283});
284
285describe("removing your own request", () => {
286 it("emails a one-time removal link only when there is a request, and deletes it with the link", async () => {
287 const s = setup();
288 expect(await requestRemoval(s.env, "nobody@example.net", "203.0.113.9")).toBe("quiet");
289 expect(s.queued).toHaveLength(0);
290 await submitRequest(s.env, input("new@example.net"), "203.0.113.9");
291 const r = (await new EarlyAccess(s.env.DB).byEmail("new@example.net"))!;
292 expect(await requestRemoval(s.env, "new@example.net", "203.0.113.9")).toBe("confirm");
293 const job = s.queued.at(-1)!;
294 expect(job).toMatchObject({ kind: "early_access_remove", to: { email: "new@example.net" } });
295 expect((job as { data: { url: string } }).data.url).toMatch(/\/early-access\/remove\/confirm#/);
296 // The removal token can't confirm the request.
297 expect(await confirmRequest(s.env, s.audit, tokenOf(job))).toBeNull();
298 expect(await removeByToken(s.env, s.audit, tokenOf(job))).toBe(true);
299 expect(await new EarlyAccess(s.env.DB).get(r.id)).toBeNull();
300 expect(await removeByToken(s.env, s.audit, tokenOf(job))).toBe(false);
301 expect(s.audits.at(-1)).toEqual({ action: "early_access.delete", target: r.id, detail: { state: "unconfirmed", via: "self" } });
302 });
303
304 it("counts towards the same daily limits", async () => {
305 const s = setup();
306 await submitRequest(s.env, input("new@example.net"), "203.0.113.9");
307 for (let i = 1; i < EMAILS_PER_ADDRESS_PER_DAY; i++) await requestRemoval(s.env, "new@example.net", "203.0.113.9");
308 expect(await requestRemoval(s.env, "new@example.net", "203.0.113.9")).toBe("limited");
309 expect(s.queued).toHaveLength(EMAILS_PER_ADDRESS_PER_DAY);
310 });
311});
312
313describe("retention", () => {
314 it("deletes unconfirmed requests a week after their last submission, and limit rows after a day", async () => {
315 const s = setup();
316 const old = "2026-09-01T00:00:00.000Z";
317 const ins = s.DB.sqlite.prepare("INSERT INTO early_access (id, email, state, created_at, updated_at) VALUES (?, ?, ?, ?, ?)");
318 ins.run("a".repeat(32), "old@x.io", "unconfirmed", old, old);
319 ins.run("b".repeat(32), "kept@x.io", "confirmed", old, old);
320 ins.run("c".repeat(32), "fresh@x.io", "unconfirmed", old, "2026-09-28T00:00:00.000Z");
321 s.DB.sqlite.prepare("INSERT INTO early_access_attempts (id, ip_hash, email_hash, created_at) VALUES ('t1', 'i', 'e', ?), ('t2', 'i', 'e', '2026-09-30T12:00:00.000Z')").run(old);
322 const r = await pruneExpired(s.env.DB, new Date("2026-10-01T00:00:00.000Z"));
323 expect(r).toMatchObject({ early_access: 1, early_access_attempts: 1 });
324 expect((s.DB.sqlite.prepare("SELECT email FROM early_access ORDER BY email").all() as Array<{ email: string }>).map((x) => x.email)).toEqual(["fresh@x.io", "kept@x.io"]);
325 });
326});