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

A test pins usernames at sign-up: unsafe names are refused before any account is made #7137

Merged opened this

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

Merged

scout/forge:scout/7135-a-test-pins-usernames-at-sign-up-unsafe → main 1 commit fork 1 thread · 1 unresolved

  • Merged. scout/7135-a-test-pins-usernames-at-sign-up-unsafe was merged into main as 738592c.

Review

Sign in to review this change.
1 file changed +108 −0
test/username-refusals.test.ts added +108
@@ -0,0 +1,108 @@
1 /**
2 * Unsafe usernames are refused at sign-up, through the page and the API,
3 * before any account is made (#7135): `createUser` answers `invalid_username`
4 * when `isSafeName` rejects a name. A reserved word is a different refusal
5 * (`username_reserved`), also made before any row exists.
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 { NAME_RESERVED } from "../src/actions/signup_words.ts";
13 import { setLimit } from "../src/settings/limits.ts";
14 import { serve, type Servers } from "../src/http/server.ts";
15
16 const UNSAFE: [string, string][] = [
17 ["dot-dot", ".."],
18 ["a slash", "a/b"],
19 ["markup", "<b>"],
20 ["a NUL", "al\u0000ice"],
21 ["200 characters", "a".repeat(200)],
22 ];
23 const RESERVED = ["settings", "api"];
24 const PASSWORD = "a-long-passphrase";
25
26 describe("username refusals at sign-up", () => {
27 let dataDir: string;
28 let app: App;
29 let servers: Servers;
30 let base: string;
31
32 const users = () => app.db.query<{ n: number }, []>("SELECT count(*) AS n FROM user").get()!.n;
33 const api = (username: string) =>
34 fetch(`${base}/api/v1/users`, {
35 method: "POST",
36 headers: { "content-type": "application/json" },
37 body: JSON.stringify({ username, password: PASSWORD }),
38 });
39 const form = (username: string) =>
40 fetch(`${base}/signup`, {
41 method: "POST",
42 redirect: "manual",
43 headers: { "content-type": "application/x-www-form-urlencoded" },
44 body: new URLSearchParams({ username, password: PASSWORD }).toString(),
45 });
46
47 beforeAll(async () => {
48 dataDir = await mkdtemp(join(tmpdir(), "forge-username-refusals-"));
49 delete process.env.FORGE_EDGE_TOKEN;
50 app = createApp({ dataDir, port: 0 });
51 servers = await serve(app);
52 base = servers.url;
53 setLimit(app.db, "signup.per_source", 1000, "system");
54 });
55 afterAll(async () => {
56 servers.stop();
57 app.close();
58 await rm(dataDir, { recursive: true, force: true });
59 });
60
61 for (const [what, name] of UNSAFE) {
62 test(`the API refuses ${what} with invalid_username and makes no user`, async () => {
63 const before = users();
64 const res = await api(name);
65 expect(res.status).toBe(422);

This literal differs from INVALID_USERNAME_MESSAGE in signup_words.ts ("dots and dashes"). Importing the constant would not work today, so the drift is real. Unify the wording in a follow-up, then import the constant here.

66 expect(((await res.json()) as { error: string }).error).toBe("invalid_username");
67 expect(users()).toBe(before);
68 });
69
70 test(`the page refuses ${what} and makes no user`, async () => {
71 const before = users();
72 const res = await form(name);
73 expect(res.status).toBe(422);
74 expect(res.headers.get("set-cookie")).toBeNull();
75 const html = await res.text();
76 expect(html).toContain("Usernames are letters, digits, dots, dashes.");
77 expect(html).not.toContain("<b>");
78 expect(users()).toBe(before);
79 });
80 }
81
82 for (const name of RESERVED) {
83 test(`the API refuses the reserved word ${name} and makes no user`, async () => {
84 const before = users();
85 const res = await api(name);
86 expect(res.status).toBe(422);
87 expect(((await res.json()) as { error: string }).error).toBe("username_reserved");
88 expect(users()).toBe(before);
89 });
90
91 test(`the page refuses the reserved word ${name} and makes no user`, async () => {
92 const before = users();
93 const res = await form(name);
94 expect(res.status).toBe(422);
95 expect(await res.text()).toContain(NAME_RESERVED);
96 expect(users()).toBe(before);
97 });
98 }
99
100 test("a plain name signs up through the API and through the page", async () => {
101 const res = await api("alice-2");
102 expect(res.status).toBe(201);
103 expect(users()).toBe(1);
104 const page = await form("alice-3");
105 expect(page.status).toBe(303);
106 expect(users()).toBe(2);
107 });
108 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs