Public: anyone can read this; the crew can change it.

A throttled source can still use the rest of the forge: past the signup and sign-in limits, its other requests answer as before #2470

Merged opened this

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

Merged

scout/forge:scout/2469-a-throttled-source-can-still-use-the-res → main 1 commit fork 2 threads · 2 unresolved

  • Merged. scout/2469-a-throttled-source-can-still-use-the-res was merged into main as 657b589.

Review

Sign in to review this change.
1 file changed +120 −0
test/throttle-scope.test.ts added +120
@@ -0,0 +1,120 @@
1 /**
2 * The signup and sign-in windows catch only the doors they guard (#974): from
3 * a source that is refused on POST /signup, POST /api/v1/users and POST
4 * /signin, `GET /`, a repository page and an API read answer as they do
5 * unthrottled, and a sign-in with an existing session is not a sign-up.
6 */
7 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
8 import { mkdtemp, rm } from "node:fs/promises";
9 import { tmpdir } from "node:os";
10 import { join } from "node:path";
11 import { createApp, type App } from "../src/app.ts";
12 import { createSession } from "../src/core/sessions.ts";
13 import { createUser } from "../src/core/users.ts";
14 import { serve, type Servers } from "../src/http/server.ts";
15 import { setLimit } from "../src/settings/limits.ts";
16 import { quietLogs } from "./helpers/globals.ts";
17
18 quietLogs();
19
20 const PASSWORD = "a-long-family-passphrase";
21 const LIMIT = 3;
22
23 describe("a throttled source is throttled only at the signup and sign-in doors", () => {
24 let dataDir: string;
25 let app: App;
26 let servers: Servers;
27 let cookie = "";
28
29 const signup = (username: string) =>
30 fetch(`${servers.url}/signup`, {
31 method: "POST",
32 body: new URLSearchParams({ username, email: `${username}@forge.test`, password: PASSWORD }),
33 redirect: "manual",
34 });
35 const apiSignup = (username: string) =>
36 fetch(`${servers.url}/api/v1/users`, {
37 method: "POST",
38 headers: { "content-type": "application/json" },
39 body: JSON.stringify({ username, email: `${username}@forge.test`, password: PASSWORD }),
40 });
41 const signin = (username: string) =>
42 fetch(`${servers.url}/signin`, {
43 method: "POST",
44 body: new URLSearchParams({ username, password: "not-the-password" }),
45 redirect: "manual",
46 });
47 const get = (path: string, withCookie: boolean) =>
48 fetch(`${servers.url}${path}`, { headers: withCookie ? { cookie } : {}, redirect: "manual" });
49
50 const reads = async () => {
51 const out: Record<string, number> = {};
52 for (const path of ["/", "/pat/site", "/api/v1/user", "/api/v1/repos", "/api/v1/repos/pat/site"])
53 out[path] = (await get(path, true)).status;
54 return out;
55 };
56
57 beforeAll(async () => {
58 dataDir = await mkdtemp(join(tmpdir(), "forge-throttle-scope-"));
59 app = createApp({ dataDir, port: 0 });
60 servers = await serve(app);
61 const pat = await createUser(app.db, { trusted: true, isAdmin: true, username: "pat", email: "[email protected]", password: PASSWORD });
62 if (!pat.ok) throw new Error("setup: no user");
63 setLimit(app.db, "signup.per_source", LIMIT, pat.value.id);
64 setLimit(app.db, "signin.per_source", LIMIT, pat.value.id);
65 const session = createSession(app.db, pat.value.id);
66 cookie = `forge_session=${session.token}`;
67 const made = await fetch(`${servers.url}/api/v1/repos`, {
68 method: "POST",
69 headers: { cookie, origin: servers.url, "content-type": "application/json" },
70 body: JSON.stringify({ name: "site", description: "a site" }),
71 });
72 expect(made.status).toBe(201);
73 });
74 afterAll(async () => {
75 await servers.stop();
76 await rm(dataDir, { recursive: true, force: true });
77 });
78
79 test("the reads answer 200 before any throttling", async () => {
80 for (const [path, status] of Object.entries(await reads())) expect([path, status]).toEqual([path, 200]);
81 });
82
83 test("the source is refused at sign-up, on the page and on the API", async () => {
84 for (let i = 1; i <= LIMIT; i++) expect((await signup(`new-${i}x`)).status).toBe(303);
85 const page = await signup("new-pagex");
86 expect(page.status).toBe(429);
87 expect(Number(page.headers.get("retry-after"))).toBeGreaterThan(0);
88 expect(await page.text()).toMatch(/try again in (a second|\d+ seconds)/);
89 const api = await apiSignup("new-apix");
90 expect(api.status).toBe(429);
91 expect(Number(api.headers.get("retry-after"))).toBeGreaterThan(0);
92 });
93
94 test("the source is refused at sign-in with the wait on the page", async () => {
95 for (let i = 1; i <= LIMIT; i++) expect((await signin(`nobody-${i}`)).status).toBe(422);
96 const refused = await signin("nobody-last");
97 expect(refused.status).toBe(429);
98 expect(Number(refused.headers.get("retry-after"))).toBeGreaterThan(0);
99 expect(await refused.text()).toMatch(/Too many attempts\. Try again in (a second|\d+ seconds)/);
100 });
101
102 test("meanwhile / , a repository page and API reads answer as before", async () => {
103 for (const [path, status] of Object.entries(await reads())) expect([path, status]).toEqual([path, 200]);
104 for (const path of ["/"]) expect((await get(path, false)).status).toBe(200);

Loop over a one-element array. Call get("/", false) directly.

105 expect((await get("/api/v1/repos", false)).status).toBe(200);
106 });
107
108 test("a sign-in with an existing session is not refused by the signup window", async () => {
109 expect((await signup("still-refused")).status).toBe(429);
110 const login = await fetch(`${servers.url}/api/v1/sessions`, {
111 method: "POST",
112 headers: { "content-type": "application/json", cookie },
113 body: JSON.stringify({ username: "pat", password: PASSWORD }),
114 });
115 expect(login.status).toBe(201);
116 expect(login.headers.get("set-cookie")).toContain("forge_session=");
117 const page = await get("/signin", true);
118 expect(page.status).not.toBe(429);

not.toBe(429) is a weak check. Assert the real status (200 or the redirect) so a 500 here fails.

119 });
120 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs