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

Merged opened this

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

Merged

scout/forge:scout/2421-roles-part-3b-i-a-member-cannot-change-a → main 4 commits fork

  • Merged. scout/2421-roles-part-3b-i-a-member-cannot-change-a was merged into main as 83a655d.

Review

Sign in to review this change.
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 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs