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
Check · Review · Land · Live done — Landed and live in production.
torque/forge:torque/7513-a-test-proves-invite-links-only-an-admin → main 1 commit fork
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 | }); |
new above
offline