From 1317bbcc09359ba8aed56861213b72f35abda8c7 Mon Sep 17 00:00:00 2001 From: Nelson Silva Date: Fri, 16 Oct 2026 10:59:15 +0100 Subject: [PATCH] Add group list, create, update and delete commands Manage permission groups (user groups): list them with their member counts, create and rename them, and delete them with confirmation. --- README.md | 48 +++++++ packages/cli/skill-data/core/SKILL.md | 2 +- .../cli/src/commands/user-group/create.ts | 29 ++++ .../cli/src/commands/user-group/delete.ts | 33 +++++ packages/cli/src/commands/user-group/index.ts | 13 ++ packages/cli/src/commands/user-group/list.ts | 29 ++++ .../cli/src/commands/user-group/name-flags.ts | 34 +++++ .../cli/src/commands/user-group/update.ts | 32 +++++ packages/cli/src/main.ts | 1 + packages/cli/src/output/views/group.ts | 12 ++ packages/cli/src/runtime/command-help.test.ts | 6 + packages/client/src/client.ts | 2 + packages/client/src/domain/group.ts | 24 ++++ packages/client/src/resources/group.test.ts | 116 +++++++++++++++ packages/client/src/resources/group.ts | 52 +++++++ packages/client/src/version/requirements.ts | 4 + tests/e2e/group.e2e.test.ts | 132 ++++++++++++++++++ 17 files changed, 568 insertions(+), 1 deletion(-) create mode 100644 packages/cli/src/commands/user-group/create.ts create mode 100644 packages/cli/src/commands/user-group/delete.ts create mode 100644 packages/cli/src/commands/user-group/index.ts create mode 100644 packages/cli/src/commands/user-group/list.ts create mode 100644 packages/cli/src/commands/user-group/name-flags.ts create mode 100644 packages/cli/src/commands/user-group/update.ts create mode 100644 packages/cli/src/output/views/group.ts create mode 100644 packages/client/src/domain/group.ts create mode 100644 packages/client/src/resources/group.test.ts create mode 100644 packages/client/src/resources/group.ts create mode 100644 tests/e2e/group.e2e.test.ts diff --git a/README.md b/README.md index 1dadbf64..1a3c57ba 100644 --- a/README.md +++ b/README.md @@ -2026,6 +2026,54 @@ mb glossary delete 3 --yes | ------- | ------------------ | | `--yes` | Skip confirmation. | +## Groups + +Permission groups (user groups), from `/api/permissions/group`. Every verb needs an admin. A group's members come from Metabase's people admin or an identity provider (SSO group mappings, SCIM); these commands manage the groups themselves. The built-in All Users and Administrators groups cannot be renamed or deleted. + +### `mb group list` + +Lists every group with its `id`, `name` and `member_count`. A group's `id` is the key it is listed under in the data permissions graph. + +```sh +mb group list +mb group list --json +``` + +### `mb group create` + +Create a group from `--name`, or from a JSON body that holds exactly a `name` key. The flag cannot be combined with `--body` or `--file`. A blank name is refused. The new group has no members and no permissions. + +```sh +mb group create --name "Analysts" +mb group create --body '{"name":"Analysts"}' +``` + +| Flag | Description | +| --------------- | ----------------------- | +| `--name ` | The group name. | +| `--body ` | Inline JSON body. | +| `--file ` | Path to JSON body file. | + +### `mb group update ` + +Rename a group. Takes the same flags and body as `create`. + +```sh +mb group update 3 --name "Data analysts" +``` + +### `mb group delete ` + +Delete a group with its memberships and permissions. Its members keep their other groups. Prompts for confirmation on a TTY; requires `--yes` otherwise. + +```sh +mb group delete 3 --yes +``` + +| Flag | Description | +| ------- | ------------------ | +| `--yes` | Skip confirmation. | + ## Settings Read and write Metabase instance settings via `/api/setting`. Listing all settings requires admin privileges; per-key reads/writes additionally enforce per-setting access. Setting values are always JSON — `"main"` is the string `main`, `42` is a number, `null` deletes the override and resets the value to its default. diff --git a/packages/cli/skill-data/core/SKILL.md b/packages/cli/skill-data/core/SKILL.md index cf84549e..0f3eb404 100644 --- a/packages/cli/skill-data/core/SKILL.md +++ b/packages/cli/skill-data/core/SKILL.md @@ -12,7 +12,7 @@ Top-level command groups (run `mb --help` to discover verbs): ``` auth | db | table | field | upload | content-translation | query | card | dashboard | snippet | segment | measure | collection | library -document | glossary | timeline | timeline-event | transform | transform-job | transform-tag | transform-test | alert | subscription | setting +document | glossary | group | timeline | timeline-event | transform | transform-job | transform-tag | transform-test | alert | subscription | setting search | dependency | git-sync | data-action | setup | eid | uuid | upgrade | skills ``` diff --git a/packages/cli/src/commands/user-group/create.ts b/packages/cli/src/commands/user-group/create.ts new file mode 100644 index 00000000..2f4ff7ff --- /dev/null +++ b/packages/cli/src/commands/user-group/create.ts @@ -0,0 +1,29 @@ +import { Group, GroupCreateInput } from "@metabase/client/domain/group"; +import { renderSummary } from "../../output/render"; +import { groupView } from "../../output/views/group"; +import { connectionFlags, outputFlags, profileFlag } from "../flags"; +import { defineMetabaseCommand } from "../runtime"; + +import { nameFlags, readGroupInput } from "./name-flags"; + +export default defineMetabaseCommand({ + meta: { name: "create", description: "Create a permission group" }, + details: + "Pass `--name`, or a JSON body with exactly a `name` key. A body piped on stdin is not read while the flag is given. The name may not be blank. The group starts with no members and no permissions. Admins only.", + requires: ["group.create"], + args: { + ...outputFlags, + ...profileFlag, + ...connectionFlags, + ...nameFlags, + }, + inputSchema: GroupCreateInput, + outputSchema: Group, + examples: ['mb group create --name "Analysts"', 'mb group create --body \'{"name":"Analysts"}\''], + async run({ args, ctx, getClient }) { + const body = await readGroupInput(args); + const client = await getClient(); + const created = await client.group.create(body); + renderSummary(created, groupView, `Created group ${created.id} "${created.name}".`, ctx); + }, +}); diff --git a/packages/cli/src/commands/user-group/delete.ts b/packages/cli/src/commands/user-group/delete.ts new file mode 100644 index 00000000..03ffd3e1 --- /dev/null +++ b/packages/cli/src/commands/user-group/delete.ts @@ -0,0 +1,33 @@ +import { confirmAndDelete, DeleteResult } from "../delete-runtime"; +import { connectionFlags, outputFlags, profileFlag } from "../flags"; +import { parseId } from "../parse-id"; +import { defineMetabaseCommand } from "../runtime"; + +export default defineMetabaseCommand({ + meta: { name: "delete", description: "Delete a permission group by id" }, + details: + "Deletes the group with its memberships and permissions; its members keep their other groups. The built-in All Users and Administrators groups cannot be deleted. Admins only.", + requires: ["group.delete"], + args: { + ...outputFlags, + ...profileFlag, + ...connectionFlags, + yes: { type: "boolean", description: "Skip confirmation", default: false }, + id: { type: "positional", description: "Group id", required: true }, + }, + outputSchema: DeleteResult, + examples: ["mb group delete 3 --yes", "mb group delete 3"], + async run({ args, ctx, getClient }) { + const id = parseId(args.id); + const client = await getClient(); + await confirmAndDelete({ + id, + yes: args.yes, + promptMessage: `Delete group ${id}?`, + successMessage: `Deleted group ${id}.`, + abortMessage: `Aborted; group ${id} was not deleted.`, + deleteResource: () => client.group.delete(id), + ctx, + }); + }, +}); diff --git a/packages/cli/src/commands/user-group/index.ts b/packages/cli/src/commands/user-group/index.ts new file mode 100644 index 00000000..9ba4d051 --- /dev/null +++ b/packages/cli/src/commands/user-group/index.ts @@ -0,0 +1,13 @@ +import { defineCommandGroup } from "../group"; + +// The folder is `user-group` because `commands/group.ts` is the command-group helper. +export default defineCommandGroup({ + name: "group", + description: "Manage permission groups (user groups)", + subCommands: { + list: () => import("./list").then((mod) => mod.default), + create: () => import("./create").then((mod) => mod.default), + update: () => import("./update").then((mod) => mod.default), + delete: () => import("./delete").then((mod) => mod.default), + }, +}); diff --git a/packages/cli/src/commands/user-group/list.ts b/packages/cli/src/commands/user-group/list.ts new file mode 100644 index 00000000..9ad211b9 --- /dev/null +++ b/packages/cli/src/commands/user-group/list.ts @@ -0,0 +1,29 @@ +import { GroupCompact } from "@metabase/client/domain/group"; +import { renderList } from "../../output/render"; +import { listEnvelopeSchema } from "../../output/types"; +import { groupView } from "../../output/views/group"; +import { windowList } from "../../output/window"; +import { connectionFlags, listFlags, outputFlags, profileFlag } from "../flags"; +import { defineMetabaseCommand } from "../runtime"; + +export const GroupListEnvelope = listEnvelopeSchema(GroupCompact); + +export default defineMetabaseCommand({ + meta: { name: "list", description: "List permission groups" }, + details: + "Lists every group, including the built-in All Users and Administrators, with its member count. Admins only. A group's id is the key it is listed under in the data permissions graph.", + requires: ["group.list"], + args: { + ...outputFlags, + ...listFlags, + ...profileFlag, + ...connectionFlags, + }, + outputSchema: GroupListEnvelope, + examples: ["mb group list", "mb group list --json"], + async run({ ctx, getClient }) { + const client = await getClient(); + const { data } = await client.group.list(); + renderList(windowList(data, ctx.range), groupView, ctx); + }, +}); diff --git a/packages/cli/src/commands/user-group/name-flags.ts b/packages/cli/src/commands/user-group/name-flags.ts new file mode 100644 index 00000000..1ab3b1fb --- /dev/null +++ b/packages/cli/src/commands/user-group/name-flags.ts @@ -0,0 +1,34 @@ +import type { FlagValues } from "../flag-values"; + +import { GroupCreateInput } from "@metabase/client/domain/group"; +import { ConfigError } from "@metabase/client/errors"; + +import { readBody } from "../../runtime/body"; +import { givenValue } from "../../runtime/input"; +import { bodyInputFlags } from "../body-flags"; + +export const nameFlags = { + ...bodyInputFlags, + name: { type: "string", description: "The group name" }, +} as const; + +/** + * `--name` and a body are alternatives, so a `--body` or `--file` beside the flag is refused rather + * than dropped. A body piped on stdin is not read while the flag is given. + */ +export async function readGroupInput( + args: FlagValues, +): Promise { + if (args.name === undefined) { + return readBody({ flag: args.body, file: args.file }, GroupCreateInput); + } + if (givenValue(args.body) !== null || givenValue(args.file) !== null) { + throw new ConfigError("--name cannot be combined with --body or --file"); + } + // The flag runs through the body's own schema, so both paths refuse the same values. + const parsed = GroupCreateInput.shape.name.safeParse(args.name); + if (!parsed.success) { + throw new ConfigError(`--name ${parsed.error.issues.map((issue) => issue.message).join("; ")}`); + } + return { name: parsed.data }; +} diff --git a/packages/cli/src/commands/user-group/update.ts b/packages/cli/src/commands/user-group/update.ts new file mode 100644 index 00000000..020bb3df --- /dev/null +++ b/packages/cli/src/commands/user-group/update.ts @@ -0,0 +1,32 @@ +import { Group, GroupUpdateInput } from "@metabase/client/domain/group"; +import { renderSummary } from "../../output/render"; +import { groupView } from "../../output/views/group"; +import { connectionFlags, outputFlags, profileFlag } from "../flags"; +import { parseId } from "../parse-id"; +import { defineMetabaseCommand } from "../runtime"; + +import { nameFlags, readGroupInput } from "./name-flags"; + +export default defineMetabaseCommand({ + meta: { name: "update", description: "Rename a permission group" }, + details: + "Pass `--name`, or a JSON body with exactly a `name` key. The built-in All Users and Administrators groups cannot be renamed. Admins only.", + requires: ["group.update"], + args: { + ...outputFlags, + ...profileFlag, + ...connectionFlags, + ...nameFlags, + id: { type: "positional", description: "Group id", required: true }, + }, + inputSchema: GroupUpdateInput, + outputSchema: Group, + examples: ['mb group update 3 --name "Data analysts"', "mb group update 3 --file group.json"], + async run({ args, ctx, getClient }) { + const id = parseId(args.id); + const body = await readGroupInput(args); + const client = await getClient(); + const updated = await client.group.update(id, body); + renderSummary(updated, groupView, `Renamed group ${updated.id} to "${updated.name}".`, ctx); + }, +}); diff --git a/packages/cli/src/main.ts b/packages/cli/src/main.ts index 8636fb12..7fe8db44 100644 --- a/packages/cli/src/main.ts +++ b/packages/cli/src/main.ts @@ -28,6 +28,7 @@ const main: CommandDef = defineCommand({ library: () => import("./commands/library").then((mod) => mod.default), document: () => import("./commands/document").then((mod) => mod.default), glossary: () => import("./commands/glossary").then((mod) => mod.default), + group: () => import("./commands/user-group").then((mod) => mod.default), transform: () => import("./commands/transform").then((mod) => mod.default), "transform-job": () => import("./commands/transform-job").then((mod) => mod.default), "transform-tag": () => import("./commands/transform-tag").then((mod) => mod.default), diff --git a/packages/cli/src/output/views/group.ts b/packages/cli/src/output/views/group.ts new file mode 100644 index 00000000..550c9cfa --- /dev/null +++ b/packages/cli/src/output/views/group.ts @@ -0,0 +1,12 @@ +import { type Group, GroupCompact } from "@metabase/client/domain/group"; + +import type { ResourceView } from "../view"; + +export const groupView: ResourceView = { + compactPick: GroupCompact, + tableColumns: [ + { key: "id", label: "ID" }, + { key: "name", label: "Name" }, + { key: "member_count", label: "Members" }, + ], +}; diff --git a/packages/cli/src/runtime/command-help.test.ts b/packages/cli/src/runtime/command-help.test.ts index e723ff24..22895c71 100644 --- a/packages/cli/src/runtime/command-help.test.ts +++ b/packages/cli/src/runtime/command-help.test.ts @@ -395,6 +395,10 @@ const ALL_COMMANDS = [ "glossary create", "glossary update", "glossary delete", + "group list", + "group create", + "group update", + "group delete", "transform list", "transform get", "transform dependencies", @@ -507,6 +511,8 @@ const BODY_COMMANDS = [ "document update", "glossary create", "glossary update", + "group create", + "group update", "transform create", "transform update", "transform-job create", diff --git a/packages/client/src/client.ts b/packages/client/src/client.ts index 032ce881..74423b0a 100644 --- a/packages/client/src/client.ts +++ b/packages/client/src/client.ts @@ -13,6 +13,7 @@ import { erdResource } from "./resources/erd"; import { fieldResource } from "./resources/field"; import { gitSyncResource } from "./resources/git-sync"; import { glossaryResource } from "./resources/glossary"; +import { groupResource } from "./resources/group"; import { libraryResource } from "./resources/library"; import { measureResource } from "./resources/measure"; import { metricResource } from "./resources/metric"; @@ -60,6 +61,7 @@ export function createClient(config: ClientCredentials, options: ClientOptions) field: fieldResource(transport), gitSync: gitSyncResource(transport), glossary: glossaryResource(transport), + group: groupResource(transport), library: libraryResource(transport), measure: measureResource(transport), metric: metricResource(transport), diff --git a/packages/client/src/domain/group.ts b/packages/client/src/domain/group.ts new file mode 100644 index 00000000..85617d29 --- /dev/null +++ b/packages/client/src/domain/group.ts @@ -0,0 +1,24 @@ +import { z } from "zod"; + +import { NonBlankText } from "./text"; + +// A permission group: the id a permissions graph is keyed by, and its name. `member_count`, the +// number of users in it, rides the listing only; a created or renamed group answers without it. +export const Group = z + .object({ + id: z.number().int(), + name: z.string(), + member_count: z.number().int().optional(), + }) + .loose(); +export type Group = z.infer; + +export const GroupCompact = Group.pick({ id: true, name: true, member_count: true }).strip(); +export type GroupCompact = z.infer; + +// The server stores only the name, so refuse any other key rather than let it vanish. +export const GroupCreateInput = z.object({ name: NonBlankText }).strict(); +export type GroupCreateInput = z.infer; + +export const GroupUpdateInput = GroupCreateInput; +export type GroupUpdateInput = GroupCreateInput; diff --git a/packages/client/src/resources/group.test.ts b/packages/client/src/resources/group.test.ts new file mode 100644 index 00000000..3cbd3818 --- /dev/null +++ b/packages/client/src/resources/group.test.ts @@ -0,0 +1,116 @@ +import { describe, expect, it } from "vitest"; + +import { createClient } from "../client"; +import type { ClientCredentials } from "../http/transport"; +import { + captureFetch, + type FetchScript, + jsonResponse, + TEST_USER_AGENT, +} from "../testing/fetch-capture"; + +const CREDENTIALS: ClientCredentials = { + url: "https://mb.example.com/metabase", + credential: { kind: "apiKey", apiKey: "mb_wire_test_key" }, +}; + +const GROUPS = [ + { id: 1, name: "All Users", member_count: 4 }, + { id: 2, name: "Administrators", member_count: 1 }, + { id: 3, name: "Analysts", member_count: 0 }, +]; + +const JSON_REQUEST_HEADERS = { + accept: "application/json", + "content-type": "application/json", + "user-agent": TEST_USER_AGENT, + "x-api-key": "mb_wire_test_key", +}; + +const JSON_READ_HEADERS = { + accept: "application/json", + "user-agent": TEST_USER_AGENT, + "x-api-key": "mb_wire_test_key", +}; + +const BINARY_READ_HEADERS = { + accept: "*/*", + "user-agent": TEST_USER_AGENT, + "x-api-key": "mb_wire_test_key", +}; + +function clientOver(responses: FetchScript) { + const capture = captureFetch(responses); + const mb = createClient(CREDENTIALS, { + userAgent: TEST_USER_AGENT, + fetchImpl: capture.fetch, + }); + return { mb, capture }; +} + +describe("group resource wire requests", () => { + it("sends the list request", async () => { + const { mb, capture } = clientOver([jsonResponse(GROUPS)]); + + await mb.group.list(); + + expect(capture.calls).toEqual([ + { + url: "https://mb.example.com/metabase/api/permissions/group", + method: "GET", + headers: JSON_READ_HEADERS, + body: null, + }, + ]); + }); + + it("answers every group with no server total", async () => { + const { mb } = clientOver([jsonResponse(GROUPS)]); + + expect(await mb.group.list()).toEqual({ data: GROUPS, total: null }); + }); + + it("sends the create request with the name", async () => { + const { mb, capture } = clientOver([jsonResponse({ id: 3, name: "Analysts" })]); + + expect(await mb.group.create({ name: "Analysts" })).toEqual({ id: 3, name: "Analysts" }); + expect(capture.calls).toEqual([ + { + url: "https://mb.example.com/metabase/api/permissions/group", + method: "POST", + headers: JSON_REQUEST_HEADERS, + body: JSON.stringify({ name: "Analysts" }), + }, + ]); + }); + + it("sends the id on the path of the update request", async () => { + const { mb, capture } = clientOver([jsonResponse({ id: 3, name: "Data analysts" })]); + + await mb.group.update(3, { name: "Data analysts" }); + + expect(capture.calls).toEqual([ + { + url: "https://mb.example.com/metabase/api/permissions/group/3", + method: "PUT", + headers: JSON_REQUEST_HEADERS, + body: JSON.stringify({ name: "Data analysts" }), + }, + ]); + }); + + it("sends the delete request as a bodiless DELETE accepting any content type", async () => { + const { mb, capture } = clientOver([new Response(null, { status: 204 })]); + + await mb.group.delete(3); + + expect(capture.calls).toEqual([ + { + url: "https://mb.example.com/metabase/api/permissions/group/3", + method: "DELETE", + headers: BINARY_READ_HEADERS, + body: null, + }, + ]); + }); +}); diff --git a/packages/client/src/resources/group.ts b/packages/client/src/resources/group.ts new file mode 100644 index 00000000..f9060ab4 --- /dev/null +++ b/packages/client/src/resources/group.ts @@ -0,0 +1,52 @@ +import { z } from "zod"; + +import { Group, type GroupCreateInput, type GroupUpdateInput } from "../domain/group"; +import type { RequestOptions, Transport } from "../http/transport"; +import type { ListResult } from "../list"; + +// Every path parameter here is a numeric id, so no fragment needs `encodeURIComponent`. +export function groupResource(transport: Transport) { + /** List every permission group, including the built-in All Users and Administrators. Admins only. */ + async function list(options: RequestOptions = {}): Promise> { + const data = await transport.requestParsed(z.array(Group), "/api/permissions/group", { + ...options, + }); + return { data, total: null }; + } + + /** Create a permission group with no members. Admins only. */ + async function create(params: GroupCreateInput, options: RequestOptions = {}): Promise { + return transport.requestParsed(Group, "/api/permissions/group", { + ...options, + method: "POST", + body: params, + }); + } + + /** Rename a permission group by id. Admins only; the built-in groups cannot be renamed. */ + async function update( + id: number, + params: GroupUpdateInput, + options: RequestOptions = {}, + ): Promise { + return transport.requestParsed(Group, `/api/permissions/group/${id}`, { + ...options, + method: "PUT", + body: params, + }); + } + + /** + * Delete a permission group by id, with its memberships and permissions. Admins only; the + * built-in groups cannot be deleted. + */ + async function remove(id: number, options: RequestOptions = {}): Promise { + await transport.requestRaw(`/api/permissions/group/${id}`, { + ...options, + method: "DELETE", + expectContentType: "binary", + }); + } + + return { list, create, update, delete: remove }; +} diff --git a/packages/client/src/version/requirements.ts b/packages/client/src/version/requirements.ts index f13d865b..d823e263 100644 --- a/packages/client/src/version/requirements.ts +++ b/packages/client/src/version/requirements.ts @@ -91,6 +91,10 @@ export const METHOD_REQUIREMENTS = { "glossary.create": [], "glossary.update": [], "glossary.delete": [], + "group.list": [], + "group.create": [], + "group.update": [], + "group.delete": [], "library.get": ["library"], "library.create": ["library"], "library.ensureDataCollectionId": ["library"], diff --git a/tests/e2e/group.e2e.test.ts b/tests/e2e/group.e2e.test.ts new file mode 100644 index 00000000..3d9f3ccd --- /dev/null +++ b/tests/e2e/group.e2e.test.ts @@ -0,0 +1,132 @@ +import { afterEach, beforeAll, describe, expect, it } from "vitest"; + +import { Group } from "@metabase/client/domain/group"; +import { parseJson } from "@metabase/client/json"; + +import { DeleteResult } from "../../packages/cli/src/commands/delete-runtime"; +import { GroupListEnvelope } from "../../packages/cli/src/commands/user-group/list"; +import { readBootstrap, type E2EBootstrap } from "./bootstrap-data"; +import { cliErrorCategory, cliErrorMessage } from "./cli-error"; +import { cleanupConfigHome, mkTempConfigHome, runCli } from "./run-cli"; +import { E2E_GROUPS } from "./seed/ids"; + +// Names no server creates for itself (some ship a "Data Analysts" group; names are unique +// regardless of case). +const ANALYSTS = "e2e analysts"; +const RENAMED = "e2e analysts (renamed)"; + +describe("group e2e", () => { + let bootstrap: E2EBootstrap; + const tempDirs: string[] = []; + + beforeAll(async () => { + bootstrap = await readBootstrap(); + }); + + afterEach(async () => { + await Promise.all(tempDirs.splice(0).map(cleanupConfigHome)); + }); + + async function makeIsolatedConfigHome(): Promise { + const dir = await mkTempConfigHome(); + tempDirs.push(dir); + return dir; + } + + function authEnv(): Record { + return { + MB_URL: bootstrap.baseUrl, + MB_API_KEY: bootstrap.adminApiKey, + }; + } + + async function mb(args: string[]) { + return runCli({ args, configHome: await makeIsolatedConfigHome(), env: authEnv() }); + } + + async function listGroups() { + const result = await mb(["group", "list", "--json", "--max-bytes", "0"]); + expect(result.exitCode, result.stderr).toBe(0); + return parseJson(result.stdout, GroupListEnvelope).data; + } + + async function createAnalysts(): Promise { + const result = await mb(["group", "create", "--name", ANALYSTS, "--json"]); + expect(result.exitCode, result.stderr).toBe(0); + return parseJson(result.stdout, Group); + } + + // Other groups a server version creates for itself are not pinned, only the two every server has. + it("list shows All Users and Administrators by their fixed ids", async () => { + const ids = (await listGroups()).map((group) => [group.id, group.name]); + + expect(ids).toEqual( + expect.arrayContaining([ + [E2E_GROUPS.ALL_USERS, "All Users"], + [E2E_GROUPS.ADMIN, "Administrators"], + ]), + ); + }); + + it("create adds an empty group that list then shows", async () => { + const created = await createAnalysts(); + + expect(created).toEqual({ id: created.id, name: ANALYSTS }); + expect((await listGroups()).find((group) => group.id === created.id)).toEqual({ + id: created.id, + name: ANALYSTS, + member_count: 0, + }); + }); + + it("update renames the group", async () => { + const created = await createAnalysts(); + + const result = await mb(["group", "update", String(created.id), "--name", RENAMED, "--json"]); + + expect(result.exitCode, result.stderr).toBe(0); + expect(parseJson(result.stdout, Group).name).toBe(RENAMED); + expect((await listGroups()).find((group) => group.id === created.id)?.name).toBe(RENAMED); + }); + + it("delete --yes removes the group", async () => { + const created = await createAnalysts(); + + const result = await mb(["group", "delete", String(created.id), "--yes", "--json"]); + + expect(result.exitCode, result.stderr).toBe(0); + expect(parseJson(result.stdout, DeleteResult)).toEqual({ + deleted: true, + aborted: false, + id: created.id, + }); + expect((await listGroups()).some((group) => group.id === created.id)).toBe(false); + }); + + it("delete without --yes refuses in non-TTY and exits 2", async () => { + const created = await createAnalysts(); + + const result = await mb(["group", "delete", String(created.id), "--json"]); + + expect(result.exitCode).toBe(2); + expect(cliErrorCategory(result.stderr)).toBe("config"); + expect(cliErrorMessage(result.stderr)).toBe( + `refusing to delete ${created.id} without confirmation — pass --yes to proceed non-interactively`, + ); + expect(result.stdout).toBe(""); + expect((await listGroups()).some((group) => group.id === created.id)).toBe(true); + }); + + it("create with --name beside --body refuses before any request", async () => { + const result = await runCli({ + args: ["group", "create", "--name", ANALYSTS, "--body", '{"name":"x"}', "--json"], + configHome: await makeIsolatedConfigHome(), + env: { MB_URL: bootstrap.baseUrl }, + }); + + expect(result.exitCode).toBe(2); + expect(cliErrorCategory(result.stderr)).toBe("config"); + expect(cliErrorMessage(result.stderr)).toBe("--name cannot be combined with --body or --file"); + expect(result.stdout).toBe(""); + }); +});