Public: anyone can read this; the crew can change it.
A test pins the sign-up window: a source over its count waits, a refused try is not counted, and the address is the socket's #6880
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/6851-a-test-pins-the-sign-up-window-a-source → main 1 commit fork
1 file
changed +185
−0
test/signup-window.test.ts added +185
| @@ -0,0 +1,185 @@ | ||
| 1 | import { afterAll, afterEach, describe, expect, setSystemTime, test } from "bun:test"; | |
| 2 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 3 | import { tmpdir } from "node:os"; | |
| 4 | import { join } from "node:path"; | |
| 5 | import { createApp, type App } from "../src/app.ts"; | |
| 6 | import { SignupSlot, signupWindow, waitText } from "../src/actions/signup-window.ts"; | |
| 7 | import { createUser, getUserByName } from "../src/core/users.ts"; | |
| 8 | import { serve } from "../src/http/server.ts"; | |
| 9 | import { setLimit } from "../src/settings/limits.ts"; | |
| 10 | ||
| 11 | const T0 = Date.UTC(2031, 0, 1); | |
| 12 | const dirs: string[] = []; | |
| 13 | ||
| 14 | async function fresh(options: Partial<Parameters<typeof createApp>[0]> = {}): Promise<App> { | |
| 15 | const dir = await mkdtemp(join(tmpdir(), "forge-signup-window-")); | |
| 16 | dirs.push(dir); | |
| 17 | return createApp({ dataDir: dir, port: 0, ...options }); | |
| 18 | } | |
| 19 | ||
| 20 | /** An admin to sign the limit changes. */ | |
| 21 | async function actorOf(app: App): Promise<string> { | |
| 22 | const made = await createUser(app.db, { | |
| 23 | origin: "seed", | |
| 24 | username: "olive", | |
| 25 | email: "[email protected]", | |
| 26 | password: "a-long-family-passphrase", | |
| 27 | trusted: true, | |
| 28 | adminIfFirst: true, | |
| 29 | }); | |
| 30 | if (!made.ok) throw new Error(made.error); | |
| 31 | return made.value.id; | |
| 32 | } | |
| 33 | ||
| 34 | async function limits(app: App, source: number, forge: number): Promise<void> { | |
| 35 | const actor = await actorOf(app); | |
| 36 | for (const [key, value] of [ | |
| 37 | ["signup.per_source", source], | |
| 38 | ["signup.per_forge", forge], | |
| 39 | ] as const) { | |
| 40 | const set = setLimit(app.db, key, value, actor); | |
| 41 | if (!set.ok) throw new Error(set.error.message); | |
| 42 | } | |
| 43 | } | |
| 44 | ||
| 45 | afterEach(() => setSystemTime()); | |
| 46 | afterAll(async () => { | |
| 47 | for (const dir of dirs) await rm(dir, { recursive: true, force: true }); | |
| 48 | }); | |
| 49 | ||
| 50 | describe("the window", () => { | |
| 51 | test("a source over its count waits; another source is let in; a refused try is not counted", async () => { | |
| 52 | const app = await fresh(); | |
| 53 | await limits(app, 3, 100); | |
| 54 | setSystemTime(new Date(T0)); | |
| 55 | for (let i = 0; i < 3; i++) expect(signupWindow(app, "203.0.113.5")).toBeUndefined(); | |
| 56 | ||
| 57 | setSystemTime(new Date(T0 + 10_000)); | |
| 58 | const wait = signupWindow(app, "203.0.113.5"); | |
| 59 | expect(wait).toBe(50); | |
| 60 | expect(waitText(wait!)).toBe("Too many attempts. Try again in 50 seconds."); | |
| 61 | expect(waitText(1)).toBe("Too many attempts. Try again in a second."); | |
| 62 | expect(signupWindow(app, "203.0.113.6")).toBeUndefined(); | |
| 63 | ||
| 64 | // Hammering the closed door neither lengthens the wait nor is counted. | |
| 65 | for (let t = 11_000; t < 40_000; t += 1_000) { | |
| 66 | setSystemTime(new Date(T0 + t)); | |
| 67 | expect(signupWindow(app, "203.0.113.5")).toBeDefined(); | |
| 68 | } | |
| 69 | setSystemTime(new Date(T0 + 60_001)); | |
| 70 | for (let i = 0; i < 3; i++) expect(signupWindow(app, "203.0.113.5")).toBeUndefined(); | |
| 71 | expect(signupWindow(app, "203.0.113.5")).toBeDefined(); | |
| 72 | }); | |
| 73 | ||
| 74 | test("the forge-wide window refuses once it is spent, whoever asks", async () => { | |
| 75 | const app = await fresh(); | |
| 76 | await limits(app, 5, 2); | |
| 77 | setSystemTime(new Date(T0)); | |
| 78 | expect(signupWindow(app, "198.51.100.1")).toBeUndefined(); | |
| 79 | expect(signupWindow(app, "198.51.100.2")).toBeUndefined(); | |
| 80 | expect(signupWindow(app, "198.51.100.3")).toBe(60); | |
| 81 | setSystemTime(new Date(T0 + 60_000)); | |
| 82 | expect(signupWindow(app, "198.51.100.3")).toBeUndefined(); | |
| 83 | }); | |
| 84 | ||
| 85 | test("an IPv6 source is counted by its /64", async () => { | |
| 86 | const app = await fresh(); | |
| 87 | await limits(app, 1, 100); | |
| 88 | setSystemTime(new Date(T0)); | |
| 89 | expect(signupWindow(app, "2001:db8:1:2::1")).toBeUndefined(); | |
| 90 | expect(signupWindow(app, "2001:db8:1:2:ffff::9")).toBeDefined(); | |
| 91 | expect(signupWindow(app, "2001:db8:1:3::1")).toBeUndefined(); | |
| 92 | }); | |
| 93 | }); | |
| 94 | ||
| 95 | describe("a real sign-up route", () => { | |
| 96 | test("an X-Forwarded-For header the client typed does not change the window counted", async () => { | |
| 97 | // Only 10.42/16 is a proxy here; the test client on loopback is not. | |
| 98 | const app = await fresh({ trustedProxies: ["10.42.0.0/16"] }); | |
| 99 | await limits(app, 1, 100); | |
| 100 | const servers = await serve(app); | |
| 101 | try { | |
| 102 | setSystemTime(new Date(T0)); | |
| 103 | const attempt = (name: string, forwarded: string) => | |
| 104 | fetch(`${servers.url}/signup`, { | |
| 105 | method: "POST", | |
| 106 | redirect: "manual", | |
| 107 | headers: { | |
| 108 | "content-type": "application/x-www-form-urlencoded", | |
| 109 | origin: servers.url, | |
| 110 | "x-forwarded-for": forwarded, | |
| 111 | }, | |
| 112 | body: new URLSearchParams({ | |
| 113 | username: name, | |
| 114 | email: `${name}@f.dev`, | |
| 115 | password: "a-long-family-passphrase", | |
| 116 | }), | |
| 117 | }); | |
| 118 | const first = await attempt("amy", "198.51.100.1"); | |
| 119 | expect(first.status).not.toBe(429); | |
| 120 | expect(getUserByName(app.db, "amy")).toBeDefined(); | |
| 121 | // Another typed address is still the same socket: refused, no account. | |
| 122 | const second = await attempt("ben", "198.51.100.2"); | |
| 123 | expect(second.status).toBe(429); | |
| 124 | expect(Number(second.headers.get("retry-after"))).toBeGreaterThan(0); | |
| 125 | expect(getUserByName(app.db, "ben")).toBeUndefined(); | |
| 126 | } finally { | |
| 127 | servers.stop(); | |
| 128 | } | |
| 129 | }); | |
| 130 | }); | |
| 131 | ||
| 132 | describe("the slot", () => { | |
| 133 | test("createUser from a stranger's door without a slot makes no account", async () => { | |
| 134 | const app = await fresh(); | |
| 135 | const count = () => app.db.query<{ n: number }, []>("SELECT COUNT(*) AS n FROM user").get()!.n; | |
| 136 | const before = count(); | |
| 137 | for (const origin of ["signup", "passkey"] as const) { | |
| 138 | await expect( | |
| 139 | createUser(app.db, { | |
| 140 | origin, | |
| 141 | username: "mallory", | |
| 142 | email: "[email protected]", | |
| 143 | password: "a-long-family-passphrase", | |
| 144 | } as never), | |
| 145 | ).rejects.toThrow(); | |
| 146 | } | |
| 147 | expect(count()).toBe(before); | |
| 148 | expect(getUserByName(app.db, "mallory")).toBeUndefined(); | |
| 149 | }); | |
| 150 | ||
| 151 | test("a slot makes one account and cannot be reused", async () => { | |
| 152 | const app = await fresh(); | |
| 153 | setSystemTime(new Date(T0)); | |
| 154 | const slot = SignupSlot.take(app, "203.0.113.9"); | |
| 155 | if (typeof slot === "number") throw new Error("window spent"); | |
| 156 | expect(slot.spent).toBe(false); | |
| 157 | const made = await createUser(app.db, { | |
| 158 | origin: "signup", | |
| 159 | slot, | |
| 160 | username: "amy", | |
| 161 | email: "[email protected]", | |
| 162 | password: "a-long-family-passphrase", | |
| 163 | }); | |
| 164 | expect(made.ok).toBe(true); | |
| 165 | expect(slot.spent).toBe(true); | |
| 166 | await expect( | |
| 167 | createUser(app.db, { | |
| 168 | origin: "signup", | |
| 169 | slot, | |
| 170 | username: "ben", | |
| 171 | email: "[email protected]", | |
| 172 | password: "a-long-family-passphrase", | |
| 173 | }), | |
| 174 | ).rejects.toThrow("one account"); | |
| 175 | expect(getUserByName(app.db, "ben")).toBeUndefined(); | |
| 176 | }); | |
| 177 | ||
| 178 | test("a spent window gives the seconds to wait instead of a slot", async () => { | |
| 179 | const app = await fresh(); | |
| 180 | await limits(app, 1, 100); | |
| 181 | setSystemTime(new Date(T0)); | |
| 182 | expect(SignupSlot.take(app, "203.0.113.10")).toBeInstanceOf(SignupSlot); | |
| 183 | expect(SignupSlot.take(app, "203.0.113.10")).toBe(60); | |
| 184 | }); | |
| 185 | }); |
new above
offline