Public: anyone can read this; the crew can change it.
Roles, part 3b-i: a member cannot change another account, refused the same way quotas and accounts already refuse #2428
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/2421-roles-part-3b-i-a-member-cannot-change-a → main 4 commits fork
1 file
changed +151
−0
test/member-others.test.ts added +151
| @@ -0,0 +1,151 @@ | ||
| 1 | /** | |
| 2 | * Roles, part 3b-i (#2421): a member cannot change another account, and is | |
| 3 | * refused as quotas and accounts refuse a non-admin: the admin gate's 403 under | |
| 4 | * /api/v1/admin, and 404 (the door is not there) on the account pages and routes. | |
| 5 | * A member still changes their own account. | |
| 6 | */ | |
| 7 | ||
| 8 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 9 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 10 | import { tmpdir } from "node:os"; | |
| 11 | import { join } from "node:path"; | |
| 12 | import { createApp, type App } from "../src/app.ts"; | |
| 13 | import { signUp } from "../src/actions/users.ts"; | |
| 14 | import { NOT_AN_ADMIN } from "../src/core/roles.ts"; | |
| 15 | import { createToken } from "../src/core/tokens.ts"; | |
| 16 | import { createSession, listSessions } from "../src/core/sessions.ts"; | |
| 17 | import { getUserByName } from "../src/core/users.ts"; | |
| 18 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 19 | import { quietLogs } from "./helpers/globals.ts"; | |
| 20 | import { promote } from "./helpers/members.ts"; | |
| 21 | ||
| 22 | quietLogs(); | |
| 23 | ||
| 24 | const PASS = "a-long-member-passphrase"; | |
| 25 | ||
| 26 | describe("a member and another account", () => { | |
| 27 | let dataDir = ""; | |
| 28 | let app: App; | |
| 29 | let servers: Servers; | |
| 30 | let bob: NonNullable<ReturnType<typeof getUserByName>>; | |
| 31 | let bobToken = ""; | |
| 32 | let carolBefore = ""; | |
| 33 | ||
| 34 | const call = (path: string, token: string, method = "GET", body?: unknown) => | |
| 35 | fetch(`${servers.url}${path}`, { | |
| 36 | method, | |
| 37 | headers: { authorization: `Bearer ${token}`, "content-type": "application/json" }, | |
| 38 | ...(body === undefined ? {} : { body: JSON.stringify(body) }), | |
| 39 | }); | |
| 40 | ||
| 41 | const person = async (username: string) => { | |
| 42 | const made = await signUp(app, { username, email: `${username}@f.dev`, password: PASS }); | |
| 43 | if (!made.ok) throw new Error(made.error); | |
| 44 | return made.value; | |
| 45 | }; | |
| 46 | ||
| 47 | beforeAll(async () => { | |
| 48 | dataDir = await mkdtemp(join(tmpdir(), "forge-member-others-")); | |
| 49 | app = createApp({ dataDir, port: 0, url: "https://forge.example" }); | |
| 50 | servers = await serve(app); | |
| 51 | await person("olive"); | |
| 52 | const b = await person("bob"); | |
| 53 | const c = await person("carol"); | |
| 54 | promote(app.db, b, c); | |
| 55 | bob = getUserByName(app.db, "bob")!; | |
| 56 | bobToken = createToken(app.db, b.id, "t", "admin").token; | |
| 57 | carolBefore = JSON.stringify(getUserByName(app.db, "carol")); | |
| 58 | }); | |
| 59 | ||
| 60 | afterAll(async () => { | |
| 61 | await servers.stop(); | |
| 62 | await rm(dataDir, { recursive: true, force: true }); | |
| 63 | }); | |
| 64 | ||
| 65 | test("a member is refused on another account, by every API route", async () => { | |
| 66 | const carol = getUserByName(app.db, "carol")!; | |
| 67 | createSession(app.db, carol.id); | |
| 68 | const sid = listSessions(app.db, carol.id)[0]!.id; | |
| 69 | const laptop = createToken(app.db, carol.id, "laptop", "rw"); | |
| 70 | const routes: [string, string, unknown?][] = [ | |
| 71 | ["GET", "/api/v1/admin/accounts"], | |
| 72 | ["GET", "/api/v1/admin/people"], | |
| 73 | ["POST", "/api/v1/admin/accounts/carol/suspend"], | |
| 74 | ["POST", "/api/v1/admin/accounts/carol/unsuspend"], | |
| 75 | ["POST", "/api/v1/admin/accounts/carol/verify"], | |
| 76 | ["POST", "/api/v1/admin/accounts/carol/role", { role: "guest" }], | |
| 77 | ["POST", "/api/v1/admin/accounts/carol/model-grant", { usdPerMonth: 5 }], | |
| 78 | ["POST", "/api/v1/admin/accounts/carol/ceiling", { dailyUsd: 1, perRunUsd: 1 }], | |
| 79 | ["DELETE", "/api/v1/admin/accounts/carol", {}], | |
| 80 | ["GET", "/api/v1/users/carol/signins"], | |
| 81 | ["DELETE", `/api/v1/users/carol/sessions/${sid}`], | |
| 82 | ]; | |
| 83 | // Under /api/v1/admin the admin gate speaks (403, its words); an account's | |
| 84 | // own sign-ins are not an admin surface, so the door is simply not there. | |
| 85 | for (const [method, path, body] of routes) { | |
| 86 | const res = await call(path, bobToken, method, body); | |
| 87 | const gated = path.startsWith("/api/v1/admin/"); | |
| 88 | expect([method, path, res.status]).toEqual([method, path, gated ? 403 : 404]); | |
| 89 | if (gated) expect(await res.json()).toEqual({ error: "not_admin", message: NOT_AN_ADMIN }); | |
| 90 | } | |
| 91 | expect(listSessions(app.db, carol.id).some((s) => s.id === sid)).toBe(true); | |
| 92 | // Carol's token: not listed, not revoked through bob's own token routes. | |
| 93 | expect(await (await call("/api/v1/tokens", bobToken)).text()).not.toContain(laptop.id); | |
| 94 | expect((await call(`/api/v1/tokens/${laptop.id}`, bobToken, "DELETE")).status).not.toBe(204); | |
| 95 | expect((await call("/api/v1/user", laptop.token)).status).toBe(200); | |
| 96 | expect(JSON.stringify(getUserByName(app.db, "carol"))).toBe(carolBefore); | |
| 97 | }); | |
| 98 | ||
| 99 | test("a member is refused on the web forms too, and nothing changes", async () => { | |
| 100 | const carol = getUserByName(app.db, "carol")!; | |
| 101 | const laptop = createToken(app.db, carol.id, "phone", "rw"); | |
| 102 | createSession(app.db, carol.id); | |
| 103 | const sid = listSessions(app.db, carol.id)[0]!.id; | |
| 104 | const cookie = `forge_session=${createSession(app.db, bob.id).token}`; | |
| 105 | const post = (path: string, form: Record<string, string> = {}) => | |
| 106 | fetch(`${servers.url}${path}`, { | |
| 107 | method: "POST", | |
| 108 | headers: { cookie, origin: servers.url }, | |
| 109 | body: new URLSearchParams(form), | |
| 110 | redirect: "manual", | |
| 111 | }); | |
| 112 | const forms: [string, Record<string, string>][] = [ | |
| 113 | ["/settings/people/carol/role", { role: "guest" }], | |
| 114 | ["/settings/accounts/carol/model", { usd: "5" }], | |
| 115 | ["/settings/accounts/carol/ceiling", { daily: "1", perRun: "1" }], | |
| 116 | ["/settings/accounts/carol/suspend", {}], | |
| 117 | ["/settings/accounts/carol/unsuspend", {}], | |
| 118 | ["/settings/accounts/carol/verify", {}], | |
| 119 | ["/settings/accounts/carol/remove", {}], | |
| 120 | [`/settings/accounts/carol/tokens/${laptop.id}/delete`, {}], | |
| 121 | [`/settings/accounts/carol/sessions/${sid}/delete`, {}], | |
| 122 | ]; | |
| 123 | for (const [path, form] of forms) | |
| 124 | expect([path, (await post(path, form)).status]).toEqual([path, 404]); | |
| 125 | const page = await fetch(`${servers.url}/settings/accounts/carol`, { headers: { cookie } }); | |
| 126 | expect(page.status).toBe(404); | |
| 127 | expect((await call("/api/v1/user", laptop.token)).status).toBe(200); | |
| 128 | expect(listSessions(app.db, carol.id).some((s) => s.id === sid)).toBe(true); | |
| 129 | expect(JSON.stringify(getUserByName(app.db, "carol"))).toBe(carolBefore); | |
| 130 | }); | |
| 131 | ||
| 132 | test("a member's profile and password calls change only their own account", async () => { | |
| 133 | // Naming another account in the body does not reach it. | |
| 134 | const res = await call("/api/v1/user", bobToken, "PATCH", { displayName: "Bobby", username: "carol" }); | |
| 135 | expect(res.status).toBe(200); | |
| 136 | expect(getUserByName(app.db, "bob")!.displayName).toBe("Bobby"); | |
| 137 | expect(getUserByName(app.db, "carol")!.displayName).not.toBe("Bobby"); | |
| 138 | const pw = await call("/api/v1/user/password", bobToken, "POST", { current: "wrong-password-1", next: "another-long-passphrase", username: "carol" }); | |
| 139 | expect(pw.status).toBe(403); | |
| 140 | expect(JSON.stringify(getUserByName(app.db, "carol"))).toBe(carolBefore); | |
| 141 | }); | |
| 142 | ||
| 143 | test("a member succeeds on their own account", async () => { | |
| 144 | expect((await call("/api/v1/users/bob/signins", bobToken)).status).toBe(200); | |
| 145 | createSession(app.db, bob.id); | |
| 146 | const sid = listSessions(app.db, bob.id)[0]!.id; | |
| 147 | expect((await call(`/api/v1/users/bob/sessions/${sid}`, bobToken, "DELETE")).status).toBe(204); | |
| 148 | const pw = await call("/api/v1/user/password", bobToken, "POST", { current: PASS, next: "another-long-passphrase" }); | |
| 149 | expect(pw.status).toBe(200); | |
| 150 | }); | |
| 151 | }); |
new above
offline