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

A test pins that an organization never loses its last owner: `personOwners`, `isLastOwner`, `soleOwnedOrgs` #6800

Merged opened this

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

Merged

scout/forge:scout/6796-a-test-pins-that-an-organization-never-l → main 2 commits fork 3 threads · 3 unresolved

  • Merged. scout/6796-a-test-pins-that-an-organization-never-l was merged into main as 4b0ed5b.

Review

Sign in to review this change.
1 file changed +192 −0
test/org-last-owner.test.ts added +192
@@ -0,0 +1,192 @@
1 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
2 import { mkdtemp, rm } from "node:fs/promises";
3 import { tmpdir } from "node:os";
4 import { join } from "node:path";
5 import { createApp, type App } from "../src/app.ts";
6 import {
7 addTeamMember,
8 createOrg,
9 createTeam,
10 isLastOwner,
11 isOrgMember,
12 lastOwnerSays,
13 listOrgsForUser,
14 listTeams,
15 OWNERS_TEAM,
16 ownerlessOrgs,
17 personOwners,
18 soleOwnedOrgs,
19 } from "../src/core/orgs.ts";
20 import { createUser, getUserById, type User } from "../src/core/users.ts";
21
22 describe("an organization never loses its last owner", () => {
23 let dataDir: string;
24 let app: App;
25 let alice: User;
26 let bob: User;
27 let carol: User;
28 let bot: User;
29 let solo: User; // alice alone
30 let duo: User; // alice and bob
31 let mixed: User; // bob, plus the agent on Owners
32 let pair: User; // carol and alice
33
34 const person = async (name: string): Promise<User> => {
35 const made = await createUser(app.db, {
36 origin: "seed",
37 trusted: true,
38 username: name,
39 email: `${name}@forge.local`,
40 password: "a-good-passphrase",
41 adminIfFirst: false,
42 });
43 if (!made.ok) throw new Error(made.error);
44 return made.value;
45 };
46 const ownersOf = (org: User) =>
47 listTeams(app.db, org.id).find((t) => t.name === OWNERS_TEAM)!;
48 const org = (name: string, founder: User): User => {
49 const made = createOrg(app.db, name, founder.id);
50 if (!made.ok) throw new Error(made.error);
51 return made.value;
52 };
53
54 beforeAll(async () => {
55 dataDir = await mkdtemp(join(tmpdir(), "forge-org-last-owner-"));
56 app = createApp({ dataDir, port: 0 });
57 alice = await person("alice");
58 bob = await person("bob");
59 carol = await person("carol");
60 bot = await person("robot");
61 app.db.query("UPDATE user SET is_agent = 1 WHERE id = ?1").run(bot.id);
62 bot = getUserById(app.db, bot.id)!;
63 expect(bot.kind).toBe("agent");
64
65 solo = org("solo", alice);
66 duo = org("duo", alice);
67 addTeamMember(app.db, ownersOf(duo), bob.id);
68 mixed = org("mixed", bob);
69 // addTeamMember refuses an agent on an admin team; a legacy row is
70 // written directly, which is what the migration cleans up.
71 app.db
72 .query("INSERT INTO team_member (team_id, user_id) VALUES (?1, ?2)")
73 .run(ownersOf(mixed).id, bot.id);
74 pair = org("pair", carol);
75 addTeamMember(app.db, ownersOf(pair), alice.id);
76 });
77 afterAll(async () => {
78 app.close();
79 await rm(dataDir, { recursive: true, force: true });
80 });
81
82 test("addTeamMember keeps an agent off the owners team", () => {
83 expect(addTeamMember(app.db, ownersOf(solo), bot.id)).toBe(false);
84 expect(personOwners(app.db, solo.id)).toEqual([alice.id]);
outdated

Add a suspended/removed owner case here (or in a new test). Without it the suspended_at/removed_at filter in personOwners is unpinned.

85 });
86
87 test("personOwners lists people only, never an agent", () => {
88 expect(personOwners(app.db, mixed.id)).toEqual([bob.id]);
89 expect(personOwners(app.db, solo.id)).toEqual([alice.id]);
90 expect(personOwners(app.db, duo.id).sort()).toEqual([alice.id, bob.id].sort());
91 });
92
93 test("isLastOwner is true for the sole person owner, false once a second owns", () => {
94 expect(isLastOwner(app.db, solo.id, alice)).toBe(true);
95 expect(isLastOwner(app.db, duo.id, alice)).toBe(false);
96 expect(isLastOwner(app.db, duo.id, bob)).toBe(false);
97 // An agent beside them does not make them any less the last person.
98 expect(isLastOwner(app.db, mixed.id, bob)).toBe(true);
99 // The agent is never the last owner, and a non-owner never is.
100 expect(isLastOwner(app.db, mixed.id, bot)).toBe(false);
101 expect(isLastOwner(app.db, solo.id, carol)).toBe(false);
102 });
103
104 test("a second owner ends the sole ownership", async () => {
105 const dave = await person("dave");
106 const ring = org("ring", dave);
107 expect(isLastOwner(app.db, ring.id, dave)).toBe(true);
108 addTeamMember(app.db, ownersOf(ring), alice.id);
109 expect(isLastOwner(app.db, ring.id, dave)).toBe(false);
110 expect(soleOwnedOrgs(app.db, dave.id)).toEqual([]);
111 expect(soleOwnedOrgs(app.db, carol.id)).toEqual([]);
112 });
113
114 test("soleOwnedOrgs lists exactly the orgs the user alone owns", () => {
115 const alices = soleOwnedOrgs(app.db, alice.id).map((o) => o.username);
116 expect(alices).toContain("solo");
117 expect(alices).not.toContain("duo");
118 expect(alices).not.toContain("mixed");
119 expect(alices).not.toContain("pair");
120 expect(soleOwnedOrgs(app.db, bob.id).map((o) => o.username)).toEqual(["mixed"]);
121 expect(soleOwnedOrgs(app.db, bot.id)).toEqual([]);
122 expect(soleOwnedOrgs(app.db, "nobody")).toEqual([]);
123 });
124
125 test("lastOwnerSays names them in words", () => {
126 const said = lastOwnerSays(app.db, "bob");
127 expect(said).toBe(
128 "bob is the only active person owning mixed: suspending leaves no one to administer it. Make someone else an owner first",
129 );
130 expect(lastOwnerSays(app.db, "alice")).toContain("owning solo");
131 });
132
133 test("isOrgMember and listOrgsForUser agree on membership through any team", () => {
134 const devs = createTeam(app.db, duo.id, "devs", "write");
135 if (!devs.ok) throw new Error(devs.error);
outdated

This relies on pair having been created in an earlier test. Move pair into beforeAll.

136 addTeamMember(app.db, devs.value, carol.id);
137 const everyone = [alice, bob, carol, bot];
138 const orgs = [solo, duo, mixed];
139 for (const u of everyone) {
140 const listed = listOrgsForUser(app.db, u.id).map((o) => o.id);
141 for (const o of orgs)
142 expect(isOrgMember(app.db, o.id, u.id)).toBe(listed.includes(o.id));
143 }
144 expect(isOrgMember(app.db, duo.id, carol.id)).toBe(true);
145 expect(listOrgsForUser(app.db, carol.id).map((o) => o.username).sort()).toEqual(
146 ["duo", "pair"],
147 );
148 expect(isOrgMember(app.db, solo.id, carol.id)).toBe(false);
149 // The legacy agent row still counts as membership, not ownership.
150 expect(isOrgMember(app.db, mixed.id, bot.id)).toBe(true);
151 expect(listOrgsForUser(app.db, bot.id).map((o) => o.username)).toEqual(["mixed"]);
152 });
153
154 // The guard against orphaning an org when the other owner leaves: a
155 // suspended or removed owner no longer counts.
156 for (const [column, label] of [
157 ["suspended_at", "suspended"],
158 ["removed_at", "removed"],
159 ] as const) {
160 test(`a ${label} owner does not count as an owner`, async () => {
161 const keep = await person(`keep-${label}`);
162 const gone = await person(`gone-${label}`);
163 const shared = org(`shared-${label}`, keep);
164 addTeamMember(app.db, ownersOf(shared), gone.id);
165 expect(personOwners(app.db, shared.id).sort()).toEqual(
166 [keep.id, gone.id].sort(),
167 );
168 expect(isLastOwner(app.db, shared.id, keep)).toBe(false);
169 expect(soleOwnedOrgs(app.db, keep.id)).toEqual([]);
170
171 app.db
172 .query(`UPDATE user SET ${column} = '2026-01-01T00:00:00Z' WHERE id = ?1`)
173 .run(gone.id);
174 expect(personOwners(app.db, shared.id)).toEqual([keep.id]);
175 expect(isLastOwner(app.db, shared.id, keep)).toBe(true);
176 expect(soleOwnedOrgs(app.db, keep.id).map((o) => o.username)).toEqual([
177 `shared-${label}`,
178 ]);
179 expect(lastOwnerSays(app.db, `keep-${label}`)).toContain(
180 `only active person owning shared-${label}:`,
181 );
182 expect(ownerlessOrgs(app.db).map((o) => o.id)).not.toContain(shared.id);
183
184 // With the last one gone too, nobody is left: the org is ownerless.
185 app.db
186 .query(`UPDATE user SET ${column} = '2026-01-01T00:00:00Z' WHERE id = ?1`)

Non-blocking: here the org has no active person owner, so isLastOwner(app.db, shared.id, carol) should be false. Only the onOwners check in isLastOwner makes it false, and right now nothing pins that check: deleting it keeps every test green.

187 .run(keep.id);
188 expect(personOwners(app.db, shared.id)).toEqual([]);
189 expect(ownerlessOrgs(app.db).map((o) => o.id)).toContain(shared.id);
190 });
191 }
192 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs