| 1 | import { describe, expect, it } from "vitest"; |
| 2 | import type { Env } from "../../src/env.js"; |
| 3 | import type { Account, Directory } from "../../src/lib/directory.js"; |
| 4 | import { |
| 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"; |
| 27 | import { pruneExpired } from "../../src/lib/retention.js"; |
| 28 | import { HttpError } from "../../src/lib/util.js"; |
| 29 | import type { MailJob } from "../../src/mail/types.js"; |
| 30 | import { testD1 } from "./stubs/d1.js"; |
| 31 | |
| 32 | const at = "2026-10-01T00:00:00.000Z"; |
| 33 | const PUB = "https://diffinery.example"; |
| 34 | const 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. */ |
| 37 | function 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 | |
| 63 | const 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 | |
| 75 | const 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 | |
| 85 | const input = (email: string, over: Partial<RequestInput> = {}): RequestInput => ({ email, name: "Ada", company: "Acme", useCase: "Move our CI", hosting: "github", ...over }); |
| 86 | const tokenOf = (job: MailJob | undefined) => /#([A-Za-z0-9_-]+)$/.exec((job as { data: { url: string } }).data.url)![1]!; |
| 87 | |
| 88 | describe("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: "Ada\u0000x", 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 | |
| 112 | describe("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 | |
| 136 | describe("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 | |
| 212 | describe("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 | |
| 285 | describe("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 | |
| 313 | describe("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 | }); |