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

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

scout/forge:scout/6851-a-test-pins-the-sign-up-window-a-source → main 1 commit fork

  • Merged. scout/6851-a-test-pins-the-sign-up-window-a-source was merged into main as bc929a3.

Review

Sign in to review this change.
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 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs