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

A test proves invite links: only an admin mints, lists or revokes, and the token is in no event, log line or listing #7522

Merged opened this

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

Merged

torque/forge:torque/7513-a-test-proves-invite-links-only-an-admin → main 1 commit fork

  • Merged. torque/7513-a-test-proves-invite-links-only-an-admin was merged into main as 51d3aa1.

Review

Sign in to review this change.
1 file changed +172 −0
test/invites-action.test.ts added +172
@@ -0,0 +1,172 @@
1 /**
2 * `actions/invites.ts` pinned directly (#7513): only an admin mints, lists or
3 * revokes, and the token appears once, in the mint's return, and nowhere
4 * else — not in a listing, not in an event the bus appended, not in a log
5 * line written while it happened. A revoked link opens no door.
6 */
7 import { afterAll, beforeAll, describe, expect, spyOn, 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 { inviteLink, list, mint, revoke } from "../src/actions/invites.ts";
13 import { signUp } from "../src/actions/users.ts";
14 import { MAX_DAYS } from "../src/core/invites.ts";
15 import { listEvents } from "../src/core/events.ts";
16 import { createUser, getUserByName, type User } from "../src/core/users.ts";
17 import { sha256 } from "../src/kernel/hash.ts";
18 import { logLevel, setLogLevel, type LogLevel } from "../src/kernel/log.ts";
19 import { setSignup } from "../src/settings/signup.ts";
20 import { seedAdmin } from "./helpers/admin.ts";
21 import { promote } from "./helpers/members.ts";
22 import { fixtureSlot } from "./helpers/slot.ts";
23
24 const PASSWORD = "an-invited-passphrase";
25
26 describe("invite links through the action", () => {
27 let dir = "";
28 let app: App;
29 let admin: User;
30 let member: User;
31 let guest: User;
32 let found: LogLevel;
33 const lines: string[] = [];
34 const spies: { mockRestore(): void }[] = [];
35
36 /** Every console line written while `fn` runs, at the debug floor. */
37 const logged = <T>(fn: () => T): T => {
38 lines.length = 0;
39 setLogLevel("debug");
40 const record = (...args: unknown[]) => void lines.push(args.map(String).join(" "));
41 for (const level of ["log", "info", "warn", "error", "debug"] as const)
42 spies.push(spyOn(console, level).mockImplementation(record));
43 try {
44 return fn();
45 } finally {
46 for (const s of spies.splice(0)) s.mockRestore();
47 setLogLevel("error");
48 }
49 };
50
51 const person = async (username: string) => {
52 const made = await createUser(app.db, { username, email: `${username}@f.dev`, password: PASSWORD, origin: "seed" });
53 if (!made.ok) throw new Error(made.error);
54 return made.value;
55 };
56
57 beforeAll(async () => {
58 found = logLevel();
59 setLogLevel("error");
60 dir = await mkdtemp(join(tmpdir(), "forge-invites-action-"));
61 app = createApp({ dataDir: dir, port: 0 });
62 const made = await seedAdmin(app, { username: "olive", email: "[email protected]", password: PASSWORD });
63 if (!made.ok) throw new Error(made.error);
64 admin = made.value;
65 member = await person("mel");
66 promote(app.db, member);
67 guest = await person("gus");
68 setSignup(app.db, "invite", admin.id);
69 });
70 afterAll(async () => {
71 setLogLevel(found);
72 app.close();
73 await rm(dir, { recursive: true, force: true });
74 });
75
76 test("an admin's mint returns the link once; no listing, event or log line carries it", () => {
77 const since = listEvents(app.db, { limit: 1 })[0]?.id;
78 const made = logged(() => mint(app, admin, 3));
79 expect(made.ok).toBe(true);
80 if (!made.ok) return;
81 const { invite, token } = made.value;
82 expect(token).toStartWith("invite_");
83 expect(invite.state).toBe("open");
84 expect(inviteLink("https://f.dev/", token)).toBe(`https://f.dev/signup?invite=${token}`);
85 // 3 days from creation, to the millisecond.
86 expect(Date.parse(invite.expiresAt) - Date.parse(invite.createdAt)).toBe(3 * 86_400_000);
87 const mintLines = [...lines];
88
89 const listed = logged(() => list(app, admin));
90 expect(listed.ok).toBe(true);
91 if (!listed.ok) return;
92 expect(listed.value.map((i) => i.id)).toContain(invite.id);
93 const listLines = [...lines];
94
95 const revoked = logged(() => revoke(app, admin, invite.id));
96 expect(revoked.ok).toBe(true);
97 if (revoked.ok) expect(revoked.value.state).toBe("revoked");
98 const revokeLines = [...lines];
99
100 const secrets = [token, sha256(token), token.slice("invite_".length)];
101 const events = listEvents(app.db, { limit: 50, ...(since === undefined ? {} : { afterId: since }) });
102 const kinds = events.map((e) => e.kind);
103 expect(kinds).toContain("invite.minted");
104 expect(kinds).toContain("invite.revoked");
105 for (const e of events) {
106 const text = JSON.stringify(e);
107 for (const s of secrets) expect(text).not.toContain(s);
108 }
109 const minted = events.find((e) => e.kind === "invite.minted")!;
110 expect(minted.payload).toEqual({ id: invite.id, expiresAt: invite.expiresAt });
111 expect(events.find((e) => e.kind === "invite.revoked")!.payload).toEqual({ id: invite.id });
112
113 const listing = JSON.stringify(list(app, admin));
114 for (const s of secrets) expect(listing).not.toContain(s);
115 for (const line of [...mintLines, ...listLines, ...revokeLines])
116 for (const s of secrets) expect(line).not.toContain(s);
117 });
118
119 test("a revoked link signs nobody up; an open one does", async () => {
120 const made = mint(app, admin, undefined);
121 if (!made.ok) throw new Error(made.error);
122 expect(revoke(app, admin, made.value.invite.id).ok).toBe(true);
123 const refused = await signUp(
124 app,
125 { username: "rex", email: "[email protected]", password: PASSWORD },
126 { slot: fixtureSlot(app), invite: made.value.token },
127 );
128 expect(refused).toEqual({ ok: false, error: "signup_closed" });
129 expect(getUserByName(app.db, "rex")).toBeUndefined();
130 // A second revoke of the same link is not found: it is already shut.
131 expect(revoke(app, admin, made.value.invite.id)).toEqual({ ok: false, error: "not_found" });
132
133 const open = mint(app, admin, 1);
134 if (!open.ok) throw new Error(open.error);
135 const let_in = await signUp(
136 app,
137 { username: "ivy", email: "[email protected]", password: PASSWORD },
138 { slot: fixtureSlot(app), invite: open.value.token },
139 );
140 expect(let_in.ok).toBe(true);
141 });
142
143 test("a member, a guest and nobody get not_found for all three, and nothing changes", () => {
144 const made = mint(app, admin, 7);
145 if (!made.ok) throw new Error(made.error);
146 const before = list(app, admin);
147 const eventsBefore = listEvents(app.db, { limit: 500 }).length;
148 for (const actor of [member, guest, undefined]) {
149 expect(mint(app, actor, 7)).toEqual({ ok: false, error: "not_found" });
150 expect(list(app, actor)).toEqual({ ok: false, error: "not_found" });
151 expect(revoke(app, actor, made.value.invite.id)).toEqual({ ok: false, error: "not_found" });
152 }
153 // An agent account flagged admin is still not a person who may mint.
154 const agent = { ...admin, kind: "agent" as const };
155 expect(mint(app, agent, 7)).toEqual({ ok: false, error: "not_found" });
156 expect(list(app, agent)).toEqual({ ok: false, error: "not_found" });
157 expect(revoke(app, agent, made.value.invite.id)).toEqual({ ok: false, error: "not_found" });
158
159 expect(list(app, admin)).toEqual(before);
160 expect(listEvents(app.db, { limit: 500 }).length).toBe(eventsBefore);
161 });
162
163 test("days outside 1 to 30, or not a whole number, is refused and mints nothing", () => {
164 const before = list(app, admin);
165 for (const days of [0, -1, MAX_DAYS + 1, 1.5, "7", Number.NaN, Infinity, {}]) {
166 expect(mint(app, admin, days)).toEqual({ ok: false, error: "invalid_days" });
167 }
168 expect(list(app, admin)).toEqual(before);
169 expect(mint(app, admin, 1).ok).toBe(true);
170 expect(mint(app, admin, MAX_DAYS).ok).toBe(true);
171 });
172 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs