From 0b3ea218567c812a41712527edcb35d428b3321a Mon Sep 17 00:00:00 2001 From: John Pruitt Date: Sun, 9 Aug 2026 14:49:36 -0500 Subject: [PATCH] fix(cli): let group admins resolve group names --- e2e/cli.e2e.test.ts | 28 ++++++++++++++++++++++ packages/cli/commands/group.test.ts | 37 +++++++++++++++++++++++++++++ packages/cli/commands/group.ts | 17 ++++++------- 3 files changed, 72 insertions(+), 10 deletions(-) create mode 100644 packages/cli/commands/group.test.ts diff --git a/e2e/cli.e2e.test.ts b/e2e/cli.e2e.test.ts index 78ac866d..3eda3a19 100644 --- a/e2e/cli.e2e.test.ts +++ b/e2e/cli.e2e.test.ts @@ -878,6 +878,34 @@ describe.skipIf( ); }); + test("7a2a. a group admin can manage its group by name without space-admin access (TNT-261)", async () => { + const groupName = `group-admins-${rand()}`; + await meJson(["group", "create", groupName]); + const groupAdmin = await seedSecondMember(); + const member = await seedSecondMember(); + await meJson(["group", "add", groupName, groupAdmin.email, "--admin"]); + + // The group admin must not gain roster enumeration authority. + const listed = await me(["group", "list"], groupAdmin.env2); + expect(listed.code).not.toBe(0); + expect(`${listed.stdout}${listed.stderr}`).toContain("space admin"); + + // But it can resolve this group by name and manage its membership. + await meJson(["group", "add", groupName, member.email], groupAdmin.env2); + const members = await meJson<{ members: { memberId: string }[] }>( + ["group", "members", groupName], + groupAdmin.env2, + ); + expect(members.members.some((m) => m.memberId === member.userId)).toBe( + true, + ); + const removed = await meJson<{ removed: boolean }>( + ["group", "remove", groupName, member.email], + groupAdmin.env2, + ); + expect(removed.removed).toBe(true); + }); + test("7a3. admin groups: create --space-admin, list shows space-admin, set-space-admin toggles", async () => { // create as an admin group const adminName = `leads-${rand()}`; diff --git a/packages/cli/commands/group.test.ts b/packages/cli/commands/group.test.ts new file mode 100644 index 00000000..efab7d37 --- /dev/null +++ b/packages/cli/commands/group.test.ts @@ -0,0 +1,37 @@ +/** Unit tests for group CLI command helpers. */ +import { describe, expect, mock, test } from "bun:test"; +import type { MemoryClient } from "@memory.build/client"; +import { resolveGroupId } from "./group.ts"; + +const UUID = "019d694f-79f6-7595-8faf-b70b01c11f98"; + +describe("resolveGroupId", () => { + test("resolves a group name through member-accessible principal.resolve", async () => { + const resolve = mock(() => + Promise.resolve({ + principals: [{ id: UUID, kind: "g" as const, name: "engineering" }], + }), + ); + const list = mock(() => Promise.reject(new Error("unused"))); + const memory = { + principal: { resolve }, + group: { list }, + } as unknown as MemoryClient; + + await expect(resolveGroupId(memory, "engineering", "text")).resolves.toBe( + UUID, + ); + expect(resolve).toHaveBeenCalledWith({ name: "engineering", kind: "g" }); + expect(list).not.toHaveBeenCalled(); + }); + + test("returns a UUIDv7 without resolving", async () => { + const resolve = mock(() => Promise.reject(new Error("unused"))); + const memory = { + principal: { resolve }, + } as unknown as MemoryClient; + + await expect(resolveGroupId(memory, UUID, "text")).resolves.toBe(UUID); + expect(resolve).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/cli/commands/group.ts b/packages/cli/commands/group.ts index 96ecb758..3855ba2e 100644 --- a/packages/cli/commands/group.ts +++ b/packages/cli/commands/group.ts @@ -16,7 +16,7 @@ * - me group members : list a group's members * * is a group id or name; is a user or service-account id or name (a UUID is - * always accepted; name resolution requires space-manager authority). + * always accepted; group-name resolution is available to any space member). */ import * as clack from "@clack/prompts"; import { Command } from "commander"; @@ -35,6 +35,7 @@ import { requireAuth, requireSpace, resolveSpaceMemberId, + resolveSpacePrincipalId, } from "../util.ts"; const UUIDV7_RE = @@ -67,23 +68,19 @@ function resolveGroupInList( process.exit(1); } -/** Resolve a group id from a UUID or name (via group.list). */ +/** Resolve a group id from a UUID or name via member-accessible principal.resolve. */ export async function resolveGroupId( memory: MemoryClient, input: string, fmt: OutputFormat, ): Promise { - // Only hit the network for a name; a bare UUID needs no lookup. - const groups = UUIDV7_RE.test(input) - ? [] - : (await memory.group.list()).groups; - return resolveGroupInList(input, groups, fmt); + return resolveSpacePrincipalId(memory, input, fmt, "g"); } /** - * Resolve several group ids/names in a single `group.list` round-trip (used by - * `me space invite --group … --group …`). Fetches the list only when at least - * one input is a name. + * Resolve several group ids/names in a single admin-only `group.list` round-trip + * (used by `me space invite --group … --group …`). Fetches the list only when at + * least one input is a name. */ export async function resolveGroupIds( memory: MemoryClient,