diff --git a/README.md b/README.md index 7fb9c07..f1f3589 100644 --- a/README.md +++ b/README.md @@ -482,10 +482,16 @@ agent-slack user list --workspace "https://workspace.slack.com" --limit 200 | jq agent-slack user get U12345678 --workspace "https://workspace.slack.com" | jq . agent-slack user get "@alice" --workspace "https://workspace.slack.com" | jq . +# Verify active humans before constructing Slack mentions +agent-slack user resolve U12345678 bob@example.com \ + --workspace "https://workspace.slack.com" + # Open a DM or group DM with one to eight other users (the caller is implicit) agent-slack user dm-open "@alice" "@bob" --workspace "https://workspace.slack.com" | jq . ``` +`user resolve` accepts at most 20 canonical U/W user IDs or email addresses. It verifies the authenticated workspace, deduplicates repeated identities, uses direct lookups, and emits mentions only when every input resolves to an active human; otherwise it exits nonzero and emits none. + ### Unreads (inbox view) See all unread messages across channels, DMs, and threads in one place: diff --git a/skills/agent-slack/SKILL.md b/skills/agent-slack/SKILL.md index c1143e1..30402c4 100644 --- a/skills/agent-slack/SKILL.md +++ b/skills/agent-slack/SKILL.md @@ -1,6 +1,6 @@ --- name: agent-slack -description: "Slack CLI for agents: read URLs/threads/history/unreads/later/canvases/workflows, create/edit canvases from Markdown, search messages/files, download attachments, lookup users, list/create/invite channels, open DMs, compose messages, manage Slack-native drafts, schedule sends, and explicit sends/edits/deletes/reactions/mark-read/uploads." +description: "Slack CLI for agents: read URLs/threads/history/unreads/later/canvases/workflows, create/edit canvases from Markdown, search messages/files, download attachments, lookup users, resolve verified human mentions, list/create/invite channels, open DMs, compose messages, manage Slack-native drafts, schedule sends, and explicit sends/edits/deletes/reactions/mark-read/uploads." --- # agent-slack @@ -20,6 +20,7 @@ If a capability named here is absent from installed help, report version skew in - Read and search freely. - Perform write actions only when explicitly requested: sends, edits, deletes, reactions, invitations, channel or canvas creation/editing, mark-read operations, scheduling or canceling delivery, uploads, Later state/reminder changes, DM/group-DM creation, and `workflow run`. Workflow runs can execute downstream actions. +- Never scan the full user directory to resolve a mention. `user resolve` accepts batches of at most 20 canonical user IDs and emails; use its mentions only when `safe_to_mention` is true. - For compose- or review-only requests, return proposed text without invoking Slack, or use `message draft create` to add a Slack-native draft the user can review and send (nothing is posted). `message compose` is send-capable; use it only when the user explicitly asks to open the interactive editor. In CI or another noninteractive environment, do not invoke it without separate authorization to send immediately: CI skips the editor and sends supplied text. - With `AGENT_SLACK_SAFE_MODE=1` (or the global `--safe-mode` flag) set, safe mode is enforced at the tool level: `message send` is redirected to the draft editor and `message edit`/`message delete` are blocked. Use it when nothing should post without human review. diff --git a/src/cli/user-command.ts b/src/cli/user-command.ts index a47f71b..ba38ab3 100644 --- a/src/cli/user-command.ts +++ b/src/cli/user-command.ts @@ -2,6 +2,16 @@ import type { Command } from "commander"; import type { CliContext } from "./context.ts"; import { pruneEmpty } from "../lib/compact-json.ts"; import { getDmChannelForUsers, getUser, listUsers } from "../slack/users.ts"; +import { + resolveStrictUserIdentities, + type UserResolution, + validateStrictUserIdentityBatch, +} from "../slack/strict-user-resolution.ts"; +import type { SlackApiClient } from "../slack/client.ts"; + +const USER_RESOLUTION_ERROR = "Unable to resolve users safely."; +const SLACK_WORKSPACE_HOST = + /^(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+(?:slack\.com|slack-gov\.com)$/; export function registerUserCommand(input: { program: Command; ctx: CliContext }): void { const userCmd = input.program.command("user").description("Workspace user directory"); @@ -41,6 +51,42 @@ export function registerUserCommand(input: { program: Command; ctx: CliContext } } }); + userCmd + .command("resolve") + .description("Verify active humans by Slack user ID or email") + .argument("", "Canonical U/W user IDs or email addresses") + .option( + "--workspace ", + "Workspace selector (full URL or unique substring; required if you have multiple workspaces)", + ) + .action(async (...args) => { + const [identities, options] = args as [string[], { workspace?: string }]; + try { + validateStrictUserIdentityBatch(identities); + const workspaceUrl = input.ctx.effectiveWorkspaceUrl(options.workspace); + const output = await input.ctx.withAutoRefresh({ + workspaceUrl, + work: async () => { + const { client, workspace_url } = await input.ctx.getClientForWorkspace(workspaceUrl); + const authenticated = await requireAuthenticatedSlackWorkspace(client, workspace_url); + const resolution = await resolveStrictUserIdentities({ + client, + identities, + edgeCacheId: authenticated.edgeCacheId, + }); + return { workspace: authenticated.workspace, resolution }; + }, + }); + printUserResolution(output.workspace, output.resolution); + if (!output.resolution.safe_to_mention) { + process.exitCode = 1; + } + } catch { + console.error(USER_RESOLUTION_ERROR); + process.exitCode = 1; + } + }); + userCmd .command("get") .description("Get a single workspace user") @@ -90,3 +136,49 @@ export function registerUserCommand(input: { program: Command; ctx: CliContext } } }); } + +function requireSlackWorkspaceOrigin(workspaceUrl: string | undefined): string { + const url = workspaceUrl && URL.canParse(workspaceUrl) ? new URL(workspaceUrl) : null; + if ( + !url || + url.protocol !== "https:" || + url.origin !== workspaceUrl || + !SLACK_WORKSPACE_HOST.test(url.hostname) + ) { + throw new Error("Resolved workspace is not a canonical Slack origin"); + } + return workspaceUrl; +} + +async function requireAuthenticatedSlackWorkspace( + client: SlackApiClient, + configuredWorkspaceUrl: string | undefined, +): Promise<{ workspace: string; edgeCacheId: string }> { + const configuredWorkspace = configuredWorkspaceUrl + ? requireSlackWorkspaceOrigin(configuredWorkspaceUrl) + : undefined; + const auth = await client.api("auth.test", {}); + const authenticatedWorkspace = requireSlackWorkspaceOrigin( + typeof auth.url === "string" ? auth.url.replace(/\/$/, "") : undefined, + ); + if (configuredWorkspace && configuredWorkspace !== authenticatedWorkspace) { + throw new Error("Authenticated Slack workspace does not match the selected workspace"); + } + const teamId = + typeof auth.team_id === "string" && /^T[A-Z0-9]{8,}$/.test(auth.team_id) + ? auth.team_id + : undefined; + const enterpriseId = + typeof auth.enterprise_id === "string" && /^E[A-Z0-9]{8,}$/.test(auth.enterprise_id) + ? auth.enterprise_id + : undefined; + const edgeCacheId = enterpriseId ?? teamId; + if (!edgeCacheId) { + throw new Error("Slack auth.test returned no valid team or enterprise ID"); + } + return { workspace: authenticatedWorkspace, edgeCacheId }; +} + +function printUserResolution(workspace: string, resolution: UserResolution): void { + console.log(JSON.stringify(pruneEmpty({ workspace, ...resolution }), null, 2)); +} diff --git a/src/slack/client.ts b/src/slack/client.ts index fad8cbd..6808e69 100644 --- a/src/slack/client.ts +++ b/src/slack/client.ts @@ -7,6 +7,7 @@ export type SlackAuth = const DEFAULT_SLACK_API_TIMEOUT_MS = 20_000; const DEFAULT_SLACK_RATE_LIMIT_MAX_WAIT_MS = 0; +const SLACK_EDGE_CACHE_ID = /^[ET][A-Z0-9]{8,}$/; function getSlackApiTimeoutMs(): number { const raw = @@ -86,6 +87,27 @@ export class SlackApiClient { } } + async lookupUserByEmail(email: string, edgeCacheId?: string): Promise> { + if (this.auth.auth_type === "standard") { + return this.api("users.lookupByEmail", { email }); + } + if (!this.workspaceUrl) { + throw new Error("Browser email lookup requires a Slack workspace URL"); + } + if (!edgeCacheId || !SLACK_EDGE_CACHE_ID.test(edgeCacheId)) { + throw new Error("Browser email lookup requires an authenticated Slack team or enterprise ID"); + } + const atIndex = email.lastIndexOf("@"); + if (atIndex <= 0) { + throw new Error("Browser email lookup requires a valid email address"); + } + return this.browserEdgeUserSearch({ + edgeCacheId, + auth: this.auth, + searchQuery: email.slice(0, atIndex), + }); + } + /** * Call a Slack API method using multipart/form-data encoding. * Some internal Slack APIs (e.g. saved.update) require multipart encoding @@ -261,6 +283,68 @@ export class SlackApiClient { } return data; } + + private async browserEdgeUserSearch(input: { + edgeCacheId: string; + auth: Extract; + searchQuery: string; + attempt?: number; + }): Promise> { + const attempt = input.attempt ?? 0; + const method = "users/search"; + const govSlack = new URL(this.workspaceUrl!).hostname.endsWith(".slack-gov.com"); + const url = `https://edgeapi.${govSlack ? "slack-gov.com" : "slack.com"}/cache/${input.edgeCacheId}/${method}`; + const timeoutMs = getSlackApiTimeoutMs(); + let response: Response; + try { + response = await fetch(url, { + method: "POST", + redirect: "error", + headers: { + Authorization: `Bearer ${input.auth.xoxc_token}`, + Cookie: `d=${encodeURIComponent(input.auth.xoxd_cookie)}`, + "Content-Type": "application/json", + Origin: `https://app.${govSlack ? "slack-gov.com" : "slack.com"}`, + "User-Agent": getUserAgent(), + }, + body: JSON.stringify({ + query: input.searchQuery, + count: 25, + include_profile_only_users: false, + fuzz: 0, + uax29_tokenizer: false, + filter: "NOT deactivated", + }), + signal: timeoutSignal(timeoutMs), + }); + } catch (error) { + if (isAbortOrTimeoutError(error)) { + throw slackApiTimeoutError(method, timeoutMs); + } + throw error; + } + + if (response.status === 429 && attempt < 3) { + const retryAfter = Number(response.headers.get("Retry-After") ?? "5"); + const delayMs = Math.min(Math.max(retryAfter, 1) * 1000, 30000); + const maxWaitMs = getSlackRateLimitMaxWaitMs(); + if (delayMs > maxWaitMs) { + throw slackRateLimitError({ method, retryAfterSec: retryAfter, maxWaitMs }); + } + await new Promise((resolve) => setTimeout(resolve, delayMs)); + return this.browserEdgeUserSearch({ ...input, attempt: attempt + 1 }); + } + + const data: unknown = await response.json().catch(() => ({})); + if (!response.ok) { + throw new Error(`Slack HTTP ${response.status} calling ${method}`); + } + if (!isRecord(data) || data.ok !== true) { + const error = isRecord(data) && typeof data.error === "string" ? data.error : null; + throw new Error(error || `Slack API error calling ${method}`); + } + return data; + } } function isRecord(value: unknown): value is Record { diff --git a/src/slack/strict-user-resolution.ts b/src/slack/strict-user-resolution.ts new file mode 100644 index 0000000..b787ead --- /dev/null +++ b/src/slack/strict-user-resolution.ts @@ -0,0 +1,186 @@ +import { isRecord } from "../lib/object-type-guards.ts"; +import type { SlackApiClient } from "./client.ts"; +import { isUserId } from "./user-id.ts"; + +type Identity = + | { kind: "id"; value: string; key: string } + | { kind: "email"; value: string; key: string }; + +type ResolutionResult = { + index: number; + status: "resolved" | "unresolved"; + mention?: `<@${string}>`; +}; + +type InternalResult = ResolutionResult & { userId?: string }; + +export type UserResolution = { + safe_to_mention: boolean; + results: ResolutionResult[]; +}; + +const EMAIL_PATTERN = /^[^@\s]+@[^@\s]+\.[^@\s]+$/; +export const MAX_USER_RESOLUTION_IDENTITIES = 20; + +export function validateStrictUserIdentityBatch(identities: string[]): void { + prepareIdentities(identities); +} + +export async function resolveStrictUserIdentities(input: { + client: SlackApiClient; + identities: string[]; + edgeCacheId?: string; +}): Promise { + const identities = prepareIdentities(input.identities); + const uniqueIdentities = new Map(); + for (const identity of identities) { + uniqueIdentities.set(identity.key, identity); + } + const resultByIdentity = new Map>(); + + for (const identity of uniqueIdentities.values()) { + let response: Record; + try { + response = + identity.kind === "id" + ? await input.client.api("users.info", { user: identity.value }) + : await input.client.lookupUserByEmail(identity.value, input.edgeCacheId); + } catch (error) { + if (isNotFoundError(error, identity.kind)) { + resultByIdentity.set(identity.key, { status: "unresolved" }); + continue; + } + throw error; + } + + let userId: string | null; + if (identity.kind === "email" && Array.isArray(response.results)) { + const candidates = parseExactEmailCandidates(response.results, identity.value); + if (candidates.length !== 1) { + resultByIdentity.set(identity.key, { status: "unresolved" }); + continue; + } + const verified = await input.client.api("users.info", { user: candidates[0] }); + userId = parseVerifiedUserId(verified.user); + } else { + userId = parseVerifiedUserId(response.user); + } + if (typeof userId === "string" && (identity.kind === "email" || userId === identity.value)) { + resultByIdentity.set(identity.key, { status: "resolved", userId }); + } else { + resultByIdentity.set(identity.key, { status: "unresolved" }); + } + } + + const results = identities.map((identity, index): InternalResult => { + const result = resultByIdentity.get(identity.key); + if (!result) { + throw new Error("User identity resolution result is missing"); + } + return { index, ...result }; + }); + + if (results.every((result) => result.status === "resolved")) { + return { + safe_to_mention: true, + results: results.map((result) => ({ + index: result.index, + status: "resolved", + mention: `<@${result.userId!}>`, + })), + }; + } + + return { + safe_to_mention: false, + results: results.map(({ index, status }) => ({ index, status })), + }; +} + +function prepareIdentities(inputs: string[]): Identity[] { + if (inputs.length === 0) { + throw new Error("At least one user identity is required"); + } + if (inputs.length > MAX_USER_RESOLUTION_IDENTITIES) { + throw new Error( + `At most ${MAX_USER_RESOLUTION_IDENTITIES} user identities may be resolved at once`, + ); + } + return inputs.map(parseIdentity); +} + +function parseIdentity(input: string, index: number): Identity { + const value = input.trim(); + if (isUserId(value)) { + return { kind: "id", value, key: `id:${value}` }; + } + if (EMAIL_PATTERN.test(value)) { + const email = value.toLowerCase(); + return { kind: "email", value: email, key: `email:${email}` }; + } + throw new Error(`User identity at index ${index} must be a canonical U/W ID or email`); +} + +function parseVerifiedUserId(value: unknown): string | null { + if (!isRecord(value) || Array.isArray(value)) { + return null; + } + const id = typeof value.id === "string" && isUserId(value.id) ? value.id : null; + const profile = isRecord(value.profile) && !Array.isArray(value.profile) ? value.profile : null; + if (!id || id === "USLACKBOT" || !profile || value.deleted !== false || value.is_bot !== false) { + return null; + } + + const inactiveOrBotSignals = [ + value.is_connector_bot, + value.is_workflow_bot, + value.is_agentforce_bot, + value.is_invited_user, + value.suspended, + value.is_forgotten, + value.is_profile_only_user, + profile.is_agentforce_bot, + profile.is_sidekick_bot, + ]; + if (inactiveOrBotSignals.some((signal) => signal != null && signal !== false)) { + return null; + } + if (profile.bot_id != null && profile.bot_id !== "") { + return null; + } + + return id; +} + +function parseExactEmailCandidates(results: unknown[], email: string): string[] { + const candidates = new Set(); + for (const result of results) { + if (!isRecord(result) || Array.isArray(result)) { + throw new Error("Slack users/search returned a malformed result"); + } + const profile = + isRecord(result.profile) && !Array.isArray(result.profile) ? result.profile : null; + const resultEmail = + profile && typeof profile.email === "string" ? profile.email.trim().toLowerCase() : undefined; + if (resultEmail !== email) { + continue; + } + const id = typeof result.id === "string" && isUserId(result.id) ? result.id : null; + if (!id) { + throw new Error("Slack users/search returned an exact email match without a valid user ID"); + } + candidates.add(id); + } + return [...candidates]; +} + +function isNotFoundError(error: unknown, kind: Identity["kind"]): boolean { + const expected = kind === "id" ? "user_not_found" : "users_not_found"; + if (error instanceof Error && error.message === expected) { + return true; + } + if (!isRecord(error) || !isRecord(error.data) || Array.isArray(error.data)) { + return false; + } + return error.data.error === expected; +} diff --git a/test/client.test.ts b/test/client.test.ts index c7e395d..b52d97c 100644 --- a/test/client.test.ts +++ b/test/client.test.ts @@ -10,6 +10,70 @@ afterEach(() => { delete process.env.AGENT_SLACK_RATE_LIMIT_MAX_WAIT_MS; }); +describe("SlackApiClient browser email lookup", () => { + test("uses a fixed Slack edge origin and a bounded local-part search", async () => { + const fetchMock = mock( + async (_input: string | URL | Request, _init?: RequestInit) => + new Response(JSON.stringify({ ok: true, results: [] }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }), + ); + globalThis.fetch = fetchMock as unknown as typeof fetch; + const client = new SlackApiClient( + { + auth_type: "browser", + xoxc_token: "xoxc-test", + xoxd_cookie: "xoxd-test", + }, + { workspaceUrl: "https://workspace.slack.com" }, + ); + + await expect(client.lookupUserByEmail("person@example.com", "E12345678")).resolves.toEqual({ + ok: true, + results: [], + }); + + const [url, init] = fetchMock.mock.calls[0]!; + expect(url).toBe("https://edgeapi.slack.com/cache/E12345678/users/search"); + expect(init).toMatchObject({ + method: "POST", + redirect: "error", + headers: { + Authorization: "Bearer xoxc-test", + Origin: "https://app.slack.com", + "Content-Type": "application/json", + }, + }); + expect(JSON.parse(String(init?.body))).toEqual({ + query: "person", + count: 25, + include_profile_only_users: false, + fuzz: 0, + uax29_tokenizer: false, + filter: "NOT deactivated", + }); + }); + + test("rejects malformed edge cache IDs before sending browser credentials", async () => { + const fetchMock = mock(async () => new Response(JSON.stringify({ ok: true }))); + globalThis.fetch = fetchMock as unknown as typeof fetch; + const client = new SlackApiClient( + { + auth_type: "browser", + xoxc_token: "xoxc-test", + xoxd_cookie: "xoxd-test", + }, + { workspaceUrl: "https://workspace.slack.com" }, + ); + + await expect( + client.lookupUserByEmail("person@example.com", "../../collector.example"), + ).rejects.toThrow("authenticated Slack team or enterprise ID"); + expect(fetchMock).not.toHaveBeenCalled(); + }); +}); + describe("SlackApiClient browser multipart transport", () => { test("retries HTTP 429 responses using Retry-After", async () => { // Fail-fast defaults to 0ms; opt in to waiting so the retry path runs. diff --git a/test/strict-user-resolution.test.ts b/test/strict-user-resolution.test.ts new file mode 100644 index 0000000..4dd30b7 --- /dev/null +++ b/test/strict-user-resolution.test.ts @@ -0,0 +1,260 @@ +import { describe, expect, test } from "bun:test"; +import type { SlackApiClient } from "../src/slack/client.ts"; +import { + MAX_USER_RESOLUTION_IDENTITIES, + resolveStrictUserIdentities, +} from "../src/slack/strict-user-resolution.ts"; + +type ApiCall = { method: string; params: Record }; + +function user(id: string, fields: Record = {}): Record { + const profile = + fields.profile && typeof fields.profile === "object" && !Array.isArray(fields.profile) + ? fields.profile + : {}; + return { + id, + deleted: false, + is_bot: false, + ...fields, + profile: { ...profile }, + }; +} + +function client( + handler: (method: string, params: Record) => Promise>, +): SlackApiClient { + return { + api: handler, + lookupUserByEmail: (email: string) => handler("users.lookupByEmail", { email }), + } as unknown as SlackApiClient; +} + +describe("strict batch user resolution", () => { + test("uses direct ID and email lookups and preserves input order", async () => { + const calls: ApiCall[] = []; + const result = await resolveStrictUserIdentities({ + client: client(async (method, params) => { + calls.push({ method, params }); + if (method === "users.info") { + return { user: user(String(params.user)) }; + } + if (method === "users.lookupByEmail") { + return { user: user("U33333333") }; + } + throw new Error(`Unexpected method: ${method}`); + }), + identities: ["U11111111", "W22222222", "Alice@Example.com"], + }); + + expect(calls).toEqual([ + { method: "users.info", params: { user: "U11111111" } }, + { method: "users.info", params: { user: "W22222222" } }, + { method: "users.lookupByEmail", params: { email: "alice@example.com" } }, + ]); + expect(result).toEqual({ + safe_to_mention: true, + results: [ + { index: 0, status: "resolved", mention: "<@U11111111>" }, + { index: 1, status: "resolved", mention: "<@W22222222>" }, + { index: 2, status: "resolved", mention: "<@U33333333>" }, + ], + }); + }); + + test("deduplicates canonical identities while preserving repeated results", async () => { + const calls: ApiCall[] = []; + const result = await resolveStrictUserIdentities({ + client: client(async (method, params) => { + calls.push({ method, params }); + return { user: user("U33333333") }; + }), + identities: ["Alice@Example.com", "alice@example.com", "U33333333", "U33333333"], + }); + + expect(calls).toEqual([ + { method: "users.lookupByEmail", params: { email: "alice@example.com" } }, + { method: "users.info", params: { user: "U33333333" } }, + ]); + expect(result).toEqual({ + safe_to_mention: true, + results: [ + { index: 0, status: "resolved", mention: "<@U33333333>" }, + { index: 1, status: "resolved", mention: "<@U33333333>" }, + { index: 2, status: "resolved", mention: "<@U33333333>" }, + { index: 3, status: "resolved", mention: "<@U33333333>" }, + ], + }); + }); + + test("verifies an exact browser-search email candidate with users.info", async () => { + const calls: ApiCall[] = []; + const apiClient = { + lookupUserByEmail: async (email: string, edgeCacheId?: string) => { + calls.push({ method: "users/search", params: { email, edgeCacheId } }); + return { + ok: true, + results: [ + user("U33333333", { profile: { email: "alice@example.com" } }), + user("U44444444", { profile: { email: "someone@example.com" } }), + ], + }; + }, + api: async (method: string, params: Record) => { + calls.push({ method, params }); + return { user: user(String(params.user)) }; + }, + } as unknown as SlackApiClient; + + const result = await resolveStrictUserIdentities({ + client: apiClient, + identities: ["Alice@Example.com"], + edgeCacheId: "E12345678", + }); + + expect(calls).toEqual([ + { + method: "users/search", + params: { email: "alice@example.com", edgeCacheId: "E12345678" }, + }, + { method: "users.info", params: { user: "U33333333" } }, + ]); + expect(result).toEqual({ + safe_to_mention: true, + results: [{ index: 0, status: "resolved", mention: "<@U33333333>" }], + }); + }); + + test("withholds every mention when one direct lookup is not found", async () => { + const result = await resolveStrictUserIdentities({ + client: client(async (method, params) => { + if (method === "users.info") { + return { user: user(String(params.user)) }; + } + throw new Error("users_not_found"); + }), + identities: ["U11111111", "missing@example.com"], + }); + + expect(result).toEqual({ + safe_to_mention: false, + results: [ + { index: 0, status: "resolved" }, + { index: 1, status: "unresolved" }, + ], + }); + expect(JSON.stringify(result)).not.toContain("<@"); + }); + + test("recognizes standard-token not-found errors", async () => { + const error = Object.assign(new Error("An API error occurred: user_not_found"), { + data: { error: "user_not_found" }, + }); + const result = await resolveStrictUserIdentities({ + client: client(async () => { + throw error; + }), + identities: ["U11111111"], + }); + + expect(result).toEqual({ + safe_to_mention: false, + results: [{ index: 0, status: "unresolved" }], + }); + }); + + test("rejects inactive users and bot signals", async () => { + const unsafeUsers = [ + user("U40000001", { deleted: true }), + user("U40000002", { is_bot: true }), + user("USLACKBOT"), + user("U40000003", { profile: { bot_id: "B12345678" } }), + user("U40000004", { is_connector_bot: true }), + user("U40000005", { is_workflow_bot: true }), + user("U40000006", { is_agentforce_bot: true }), + user("U40000007", { is_invited_user: true }), + user("U40000008", { suspended: true }), + user("U40000009", { is_forgotten: true }), + user("U40000010", { profile: { is_agentforce_bot: true } }), + user("U40000011", { profile: { is_sidekick_bot: true } }), + user("U40000012", { suspended: "false" }), + user("U40000013", { is_profile_only_user: true }), + ]; + let index = 0; + const result = await resolveStrictUserIdentities({ + client: client(async () => ({ user: unsafeUsers[index++] })), + identities: unsafeUsers.map((item) => String(item.id)), + }); + + expect(result.safe_to_mention).toBe(false); + expect(result.results.every((item) => item.status === "unresolved")).toBe(true); + expect(JSON.stringify(result)).not.toContain("<@"); + }); + + test("fails closed on malformed or mismatched users", async () => { + const cases: { identity: string; response: Record }[] = [ + { identity: "U11111111", response: {} }, + { identity: "U11111111", response: { user: [] } }, + { identity: "U11111111", response: { user: user("U22222222") } }, + { + identity: "U11111111", + response: { user: { id: "U11111111", deleted: false, is_bot: false } }, + }, + { identity: "alice@example.com", response: { user: user("not-a-user-id") } }, + ]; + + for (const { identity, response } of cases) { + await expect( + resolveStrictUserIdentities({ + client: client(async () => response), + identities: [identity], + }), + ).resolves.toEqual({ + safe_to_mention: false, + results: [{ index: 0, status: "unresolved" }], + }); + } + }); + + test("rejects unsupported identities before any API call", async () => { + let calls = 0; + const apiClient = client(async () => { + calls += 1; + return {}; + }); + for (const identity of ["@alice", "Alice Smith", "alice", "u12345678", "<@U12345678>"]) { + await expect( + resolveStrictUserIdentities({ client: apiClient, identities: [identity] }), + ).rejects.toThrow("canonical U/W ID or email"); + } + expect(calls).toBe(0); + }); + + test("rejects an oversized batch before any API call", async () => { + let calls = 0; + const apiClient = client(async () => { + calls += 1; + return {}; + }); + const identities = Array.from( + { length: MAX_USER_RESOLUTION_IDENTITIES + 1 }, + (_, index) => `person-${index}@example.com`, + ); + + await expect(resolveStrictUserIdentities({ client: apiClient, identities })).rejects.toThrow( + `At most ${MAX_USER_RESOLUTION_IDENTITIES}`, + ); + expect(calls).toBe(0); + }); + + test("propagates non-definitive request errors", async () => { + await expect( + resolveStrictUserIdentities({ + client: client(async () => { + throw new Error("rate_limited"); + }), + identities: ["U11111111"], + }), + ).rejects.toThrow("rate_limited"); + }); +}); diff --git a/test/user-command.test.ts b/test/user-command.test.ts new file mode 100644 index 0000000..c9765c0 --- /dev/null +++ b/test/user-command.test.ts @@ -0,0 +1,267 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { Command } from "commander"; +import type { CliContext } from "../src/cli/context.ts"; +import { registerUserCommand } from "../src/cli/user-command.ts"; +import type { SlackApiClient } from "../src/slack/client.ts"; + +const originalLog = console.log; +const originalError = console.error; +let logs: string[]; +let errors: string[]; + +beforeEach(() => { + logs = []; + errors = []; + process.exitCode = 0; + console.log = (...args: unknown[]) => logs.push(args.map(String).join(" ")); + console.error = (...args: unknown[]) => errors.push(args.map(String).join(" ")); +}); + +afterEach(() => { + console.log = originalLog; + console.error = originalError; + process.exitCode = 0; +}); + +type Response = Error | Record; + +function user(id: string, fields: Record = {}): Record { + const profile = + fields.profile && typeof fields.profile === "object" && !Array.isArray(fields.profile) + ? fields.profile + : {}; + return { + id, + deleted: false, + is_bot: false, + ...fields, + profile: { ...profile }, + }; +} + +function clientFor( + responses: Response[], + calls: { method: string; params: Record }[] = [], +): SlackApiClient { + const api = async (method: string, params: Record) => { + calls.push({ method, params }); + const response = responses.shift(); + if (!response || response instanceof Error) { + throw response ?? new Error("Missing response"); + } + return response; + }; + return { + api, + lookupUserByEmail: (email: string) => api("users.lookupByEmail", { email }), + } as unknown as SlackApiClient; +} + +function context(client: SlackApiClient, overrides: Partial = {}): CliContext { + return { + effectiveWorkspaceUrl: (workspace) => workspace, + withAutoRefresh: async (input: { work: () => Promise }) => input.work(), + getClientForWorkspace: async () => ({ + client, + auth: { auth_type: "standard", token: "x" }, + workspace_url: "https://workspace.slack.com", + }), + ...overrides, + } as CliContext; +} + +async function runResolve(ctx: CliContext, ...args: string[]): Promise { + const program = new Command(); + registerUserCommand({ program, ctx }); + await program.parseAsync(["user", "resolve", ...args], { from: "user" }); +} + +describe("user resolve command", () => { + test("auth refresh restarts the entire direct batch", async () => { + const calls: { method: string; params: Record }[] = []; + const client = clientFor( + [ + { url: "https://agency.slack-gov.com/", team_id: "T11111111" }, + { user: user("U11111111") }, + new Error("invalid_auth"), + { url: "https://agency.slack-gov.com/", team_id: "T11111111" }, + { user: user("U33333333") }, + { user: user("W22222222") }, + ], + calls, + ); + const ctx = context(client, { + withAutoRefresh: async (input: { work: () => Promise }) => { + try { + return await input.work(); + } catch { + return await input.work(); + } + }, + getClientForWorkspace: async () => ({ + client, + auth: { auth_type: "standard", token: "x" }, + workspace_url: "https://agency.slack-gov.com", + }), + }); + + await runResolve(ctx, "alice@example.com", "W22222222"); + + expect(calls).toEqual([ + { method: "auth.test", params: {} }, + { method: "users.lookupByEmail", params: { email: "alice@example.com" } }, + { method: "users.info", params: { user: "W22222222" } }, + { method: "auth.test", params: {} }, + { method: "users.lookupByEmail", params: { email: "alice@example.com" } }, + { method: "users.info", params: { user: "W22222222" } }, + ]); + expect(JSON.parse(logs[0]!)).toMatchObject({ + workspace: "https://agency.slack-gov.com", + safe_to_mention: true, + results: [{ mention: "<@U33333333>" }, { mention: "<@W22222222>" }], + }); + expect(logs[0]).not.toContain("U11111111"); + }); + + test("uses a fixed generic error for request failures", async () => { + await runResolve(context(clientFor([new Error("timeout <@U99999999>")])), "U11111111"); + + expect(logs).toEqual([]); + expect(errors).toEqual(["Unable to resolve users safely."]); + expect(errors[0]).not.toContain("U99999999"); + expect(process.exitCode).toBe(1); + }); + + test("withholds mentions for an unsafe direct result", async () => { + await runResolve( + context( + clientFor([ + { url: "https://workspace.slack.com/", team_id: "T11111111" }, + { user: user("U11111111", { deleted: true }) }, + ]), + ), + "U11111111", + ); + + expect(errors).toEqual([]); + expect(JSON.parse(logs[0]!)).toEqual({ + workspace: "https://workspace.slack.com", + safe_to_mention: false, + results: [{ index: 0, status: "unresolved" }], + }); + expect(logs[0]).not.toContain("<@"); + expect(process.exitCode).toBe(1); + }); + + test("validates a configured workspace before calling Slack", async () => { + let apiCalls = 0; + const client = { api: async () => apiCalls++ } as unknown as SlackApiClient; + const badWorkspaces = [ + "http://workspace.slack.com", + "https://collector.example", + "https://workspace.slack.com/<@U99999999>", + ]; + + for (const workspace_url of badWorkspaces) { + logs = []; + errors = []; + await runResolve( + context(client, { + getClientForWorkspace: async () => ({ + client, + auth: { auth_type: "standard", token: "x" }, + workspace_url, + }), + }), + "U11111111", + ); + expect(logs).toEqual([]); + expect(errors).toEqual(["Unable to resolve users safely."]); + } + expect(apiCalls).toBe(0); + }); + + test("rejects a selected workspace that does not match auth.test", async () => { + const calls: { method: string; params: Record }[] = []; + const client = clientFor([{ url: "https://actual.slack.com/", team_id: "T11111111" }], calls); + + await runResolve( + context(client, { + getClientForWorkspace: async () => ({ + client, + auth: { auth_type: "standard", token: "x" }, + workspace_url: "https://selected.slack.com", + }), + }), + "U11111111", + ); + + expect(calls).toEqual([{ method: "auth.test", params: {} }]); + expect(logs).toEqual([]); + expect(errors).toEqual(["Unable to resolve users safely."]); + expect(process.exitCode).toBe(1); + }); + + test("rejects auth.test responses without a valid team or enterprise ID", async () => { + const calls: { method: string; params: Record }[] = []; + const client = clientFor([{ url: "https://workspace.slack.com/", team_id: "bad" }], calls); + + await runResolve(context(client), "U11111111"); + + expect(calls).toEqual([{ method: "auth.test", params: {} }]); + expect(logs).toEqual([]); + expect(errors).toEqual(["Unable to resolve users safely."]); + expect(process.exitCode).toBe(1); + }); + + test("uses the workspace proven by auth.test when none was configured", async () => { + const client = clientFor([ + { url: "https://actual.slack.com/", team_id: "T11111111" }, + { user: user("U11111111") }, + ]); + + await runResolve( + context(client, { + getClientForWorkspace: async () => ({ + client, + auth: { auth_type: "standard", token: "x" }, + workspace_url: undefined, + }), + }), + "U11111111", + ); + + expect(JSON.parse(logs[0]!)).toMatchObject({ + workspace: "https://actual.slack.com", + safe_to_mention: true, + }); + expect(errors).toEqual([]); + }); + + test("rejects names without calling Slack", async () => { + let apiCalls = 0; + const client = { api: async () => apiCalls++ } as unknown as SlackApiClient; + + await runResolve(context(client), "Alice Smith"); + + expect(apiCalls).toBe(0); + expect(logs).toEqual([]); + expect(errors).toEqual(["Unable to resolve users safely."]); + expect(process.exitCode).toBe(1); + }); + + test("rejects an oversized batch before calling auth.test", async () => { + let apiCalls = 0; + const client = { api: async () => apiCalls++ } as unknown as SlackApiClient; + + await runResolve( + context(client), + ...Array.from({ length: 21 }, (_, index) => `person-${index}@example.com`), + ); + + expect(apiCalls).toBe(0); + expect(logs).toEqual([]); + expect(errors).toEqual(["Unable to resolve users safely."]); + expect(process.exitCode).toBe(1); + }); +});