From 8bce588f4bf75866e41d91ae73bc104d537ea801 Mon Sep 17 00:00:00 2001 From: Darwin Wu Date: Thu, 23 Jul 2026 19:00:43 -0700 Subject: [PATCH 1/3] feat(users): verify mention targets directly Slack IDs were accepted without checking whether they identify an active human. Add user resolve for IDs and emails. It uses direct Slack lookups and emits mentions only when the whole batch passes. Names and handles are rejected; no directory scan is used. --- README.md | 6 + skills/agent-slack/SKILL.md | 3 +- src/cli/user-command.ts | 56 +++++++++ src/slack/strict-user-resolution.ts | 122 ++++++++++++++++++ test/strict-user-resolution.test.ts | 173 ++++++++++++++++++++++++++ test/user-command.test.ts | 185 ++++++++++++++++++++++++++++ 6 files changed, 544 insertions(+), 1 deletion(-) create mode 100644 src/slack/strict-user-resolution.ts create mode 100644 test/strict-user-resolution.test.ts create mode 100644 test/user-command.test.ts diff --git a/README.md b/README.md index 7fb9c07..bc42991 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 only canonical U/W user IDs and email addresses. It 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..ae15077 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 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..053c788 100644 --- a/src/cli/user-command.ts +++ b/src/cli/user-command.ts @@ -2,6 +2,14 @@ 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, +} from "../slack/strict-user-resolution.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 +49,37 @@ 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 { + 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 workspace = requireSlackWorkspaceOrigin(workspace_url); + const resolution = await resolveStrictUserIdentities({ client, identities }); + return { 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 +129,20 @@ 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; +} + +function printUserResolution(workspace: string, resolution: UserResolution): void { + console.log(JSON.stringify(pruneEmpty({ workspace, ...resolution }), null, 2)); +} diff --git a/src/slack/strict-user-resolution.ts b/src/slack/strict-user-resolution.ts new file mode 100644 index 0000000..052f24a --- /dev/null +++ b/src/slack/strict-user-resolution.ts @@ -0,0 +1,122 @@ +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 } | { kind: "email"; value: 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 async function resolveStrictUserIdentities(input: { + client: SlackApiClient; + identities: string[]; +}): Promise { + if (input.identities.length === 0) { + throw new Error("At least one user identity is required"); + } + + const identities = input.identities.map(parseIdentity); + const results: InternalResult[] = []; + + for (const [index, identity] of identities.entries()) { + let response: Record; + try { + response = + identity.kind === "id" + ? await input.client.api("users.info", { user: identity.value }) + : await input.client.api("users.lookupByEmail", { email: identity.value }); + } catch (error) { + if (isNotFoundError(error, identity.kind)) { + results.push({ index, status: "unresolved" }); + continue; + } + throw error; + } + + const userId = parseVerifiedUserId(response.user); + const matchesInput = userId && (identity.kind === "email" || userId === identity.value); + results.push( + matchesInput ? { index, status: "resolved", userId } : { index, status: "unresolved" }, + ); + } + + 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 parseIdentity(input: string, index: number): Identity { + const value = input.trim(); + if (isUserId(value)) { + return { kind: "id", value }; + } + if (EMAIL_PATTERN.test(value)) { + return { kind: "email", value: value.toLowerCase() }; + } + 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, + 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 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/strict-user-resolution.test.ts b/test/strict-user-resolution.test.ts new file mode 100644 index 0000000..5839d11 --- /dev/null +++ b/test/strict-user-resolution.test.ts @@ -0,0 +1,173 @@ +import { describe, expect, test } from "bun:test"; +import type { SlackApiClient } from "../src/slack/client.ts"; +import { 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 } 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("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" }), + ]; + 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("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..5351045 --- /dev/null +++ b/test/user-command.test.ts @@ -0,0 +1,185 @@ +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 { + return { + 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; + }, + } 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( + [ + { user: user("U11111111") }, + new Error("invalid_auth"), + { 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: "users.lookupByEmail", params: { email: "alice@example.com" } }, + { method: "users.info", params: { user: "W22222222" } }, + { 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([{ 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 the workspace before resolving and uses a fixed generic error", async () => { + let apiCalls = 0; + const client = { api: async () => apiCalls++ } as unknown as SlackApiClient; + const badWorkspaces = [ + undefined, + "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 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); + }); +}); From 612ee49a96412344336a8b43542fa9e018a5c9ba Mon Sep 17 00:00:00 2001 From: Darwin Wu Date: Fri, 11 Sep 2026 18:23:58 -0700 Subject: [PATCH 2/3] fix(users): harden direct identity resolution --- README.md | 2 +- skills/agent-slack/SKILL.md | 2 +- src/cli/user-command.ts | 22 ++++++++- src/slack/strict-user-resolution.ts | 54 +++++++++++++++++----- test/strict-user-resolution.test.ts | 48 ++++++++++++++++++- test/user-command.test.ts | 71 +++++++++++++++++++++++++++-- 6 files changed, 180 insertions(+), 19 deletions(-) diff --git a/README.md b/README.md index bc42991..f1f3589 100644 --- a/README.md +++ b/README.md @@ -490,7 +490,7 @@ agent-slack user resolve U12345678 bob@example.com \ agent-slack user dm-open "@alice" "@bob" --workspace "https://workspace.slack.com" | jq . ``` -`user resolve` accepts only canonical U/W user IDs and email addresses. It uses direct lookups and emits mentions only when every input resolves to an active human; otherwise it exits nonzero and emits none. +`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) diff --git a/skills/agent-slack/SKILL.md b/skills/agent-slack/SKILL.md index ae15077..30402c4 100644 --- a/skills/agent-slack/SKILL.md +++ b/skills/agent-slack/SKILL.md @@ -20,7 +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 canonical user IDs and emails; use its mentions only when `safe_to_mention` is true. +- 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 053c788..0d6419e 100644 --- a/src/cli/user-command.ts +++ b/src/cli/user-command.ts @@ -5,7 +5,9 @@ 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 = @@ -60,12 +62,13 @@ export function registerUserCommand(input: { program: Command; ctx: CliContext } .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 workspace = requireSlackWorkspaceOrigin(workspace_url); + const workspace = await requireAuthenticatedSlackWorkspace(client, workspace_url); const resolution = await resolveStrictUserIdentities({ client, identities }); return { workspace, resolution }; }, @@ -143,6 +146,23 @@ function requireSlackWorkspaceOrigin(workspaceUrl: string | undefined): string { return workspaceUrl; } +async function requireAuthenticatedSlackWorkspace( + client: SlackApiClient, + configuredWorkspaceUrl: string | undefined, +): Promise { + 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"); + } + return authenticatedWorkspace; +} + function printUserResolution(workspace: string, resolution: UserResolution): void { console.log(JSON.stringify(pruneEmpty({ workspace, ...resolution }), null, 2)); } diff --git a/src/slack/strict-user-resolution.ts b/src/slack/strict-user-resolution.ts index 052f24a..fd9eb9d 100644 --- a/src/slack/strict-user-resolution.ts +++ b/src/slack/strict-user-resolution.ts @@ -2,7 +2,9 @@ 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 } | { kind: "email"; value: string }; +type Identity = + | { kind: "id"; value: string; key: string } + | { kind: "email"; value: string; key: string }; type ResolutionResult = { index: number; @@ -18,19 +20,24 @@ export type UserResolution = { }; 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[]; }): Promise { - if (input.identities.length === 0) { - throw new Error("At least one user identity is required"); + const identities = prepareIdentities(input.identities); + const uniqueIdentities = new Map(); + for (const identity of identities) { + uniqueIdentities.set(identity.key, identity); } + const resultByIdentity = new Map>(); - const identities = input.identities.map(parseIdentity); - const results: InternalResult[] = []; - - for (const [index, identity] of identities.entries()) { + for (const identity of uniqueIdentities.values()) { let response: Record; try { response = @@ -39,7 +46,7 @@ export async function resolveStrictUserIdentities(input: { : await input.client.api("users.lookupByEmail", { email: identity.value }); } catch (error) { if (isNotFoundError(error, identity.kind)) { - results.push({ index, status: "unresolved" }); + resultByIdentity.set(identity.key, { status: "unresolved" }); continue; } throw error; @@ -47,11 +54,20 @@ export async function resolveStrictUserIdentities(input: { const userId = parseVerifiedUserId(response.user); const matchesInput = userId && (identity.kind === "email" || userId === identity.value); - results.push( - matchesInput ? { index, status: "resolved", userId } : { index, status: "unresolved" }, + resultByIdentity.set( + identity.key, + matchesInput ? { status: "resolved", userId } : { 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, @@ -69,13 +85,26 @@ export async function resolveStrictUserIdentities(input: { }; } +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 }; + return { kind: "id", value, key: `id:${value}` }; } if (EMAIL_PATTERN.test(value)) { - return { kind: "email", value: value.toLowerCase() }; + 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`); } @@ -97,6 +126,7 @@ function parseVerifiedUserId(value: unknown): string | null { value.is_invited_user, value.suspended, value.is_forgotten, + value.is_profile_only_user, profile.is_agentforce_bot, profile.is_sidekick_bot, ]; diff --git a/test/strict-user-resolution.test.ts b/test/strict-user-resolution.test.ts index 5839d11..fb6b376 100644 --- a/test/strict-user-resolution.test.ts +++ b/test/strict-user-resolution.test.ts @@ -1,6 +1,9 @@ import { describe, expect, test } from "bun:test"; import type { SlackApiClient } from "../src/slack/client.ts"; -import { resolveStrictUserIdentities } from "../src/slack/strict-user-resolution.ts"; +import { + MAX_USER_RESOLUTION_IDENTITIES, + resolveStrictUserIdentities, +} from "../src/slack/strict-user-resolution.ts"; type ApiCall = { method: string; params: Record }; @@ -56,6 +59,31 @@ describe("strict batch user resolution", () => { }); }); + 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("withholds every mention when one direct lookup is not found", async () => { const result = await resolveStrictUserIdentities({ client: client(async (method, params) => { @@ -109,6 +137,7 @@ describe("strict batch user resolution", () => { 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({ @@ -160,6 +189,23 @@ describe("strict batch user resolution", () => { 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({ diff --git a/test/user-command.test.ts b/test/user-command.test.ts index 5351045..945477f 100644 --- a/test/user-command.test.ts +++ b/test/user-command.test.ts @@ -79,8 +79,10 @@ describe("user resolve command", () => { const calls: { method: string; params: Record }[] = []; const client = clientFor( [ + { url: "https://agency.slack-gov.com/" }, { user: user("U11111111") }, new Error("invalid_auth"), + { url: "https://agency.slack-gov.com/" }, { user: user("U33333333") }, { user: user("W22222222") }, ], @@ -104,8 +106,10 @@ describe("user resolve command", () => { 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" } }, ]); @@ -128,7 +132,12 @@ describe("user resolve command", () => { test("withholds mentions for an unsafe direct result", async () => { await runResolve( - context(clientFor([{ user: user("U11111111", { deleted: true }) }])), + context( + clientFor([ + { url: "https://workspace.slack.com/" }, + { user: user("U11111111", { deleted: true }) }, + ]), + ), "U11111111", ); @@ -142,11 +151,10 @@ describe("user resolve command", () => { expect(process.exitCode).toBe(1); }); - test("validates the workspace before resolving and uses a fixed generic error", async () => { + test("validates a configured workspace before calling Slack", async () => { let apiCalls = 0; const client = { api: async () => apiCalls++ } as unknown as SlackApiClient; const badWorkspaces = [ - undefined, "http://workspace.slack.com", "https://collector.example", "https://workspace.slack.com/<@U99999999>", @@ -171,6 +179,48 @@ describe("user resolve command", () => { 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/" }], 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("uses the workspace proven by auth.test when none was configured", async () => { + const client = clientFor([{ url: "https://actual.slack.com/" }, { 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; @@ -182,4 +232,19 @@ describe("user resolve command", () => { 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); + }); }); From 78b856235d9f41fbf4c560393b9195db3cb3136f Mon Sep 17 00:00:00 2001 From: Darwin Wu Date: Fri, 11 Sep 2026 18:50:38 -0700 Subject: [PATCH 3/3] fix(users): support browser email lookup directly --- src/cli/user-command.ts | 26 +++++++-- src/slack/client.ts | 84 +++++++++++++++++++++++++++++ src/slack/strict-user-resolution.ts | 48 ++++++++++++++--- test/client.test.ts | 64 ++++++++++++++++++++++ test/strict-user-resolution.test.ts | 43 ++++++++++++++- test/user-command.test.ts | 43 ++++++++++----- 6 files changed, 282 insertions(+), 26 deletions(-) diff --git a/src/cli/user-command.ts b/src/cli/user-command.ts index 0d6419e..ba38ab3 100644 --- a/src/cli/user-command.ts +++ b/src/cli/user-command.ts @@ -68,9 +68,13 @@ export function registerUserCommand(input: { program: Command; ctx: CliContext } workspaceUrl, work: async () => { const { client, workspace_url } = await input.ctx.getClientForWorkspace(workspaceUrl); - const workspace = await requireAuthenticatedSlackWorkspace(client, workspace_url); - const resolution = await resolveStrictUserIdentities({ client, identities }); - return { workspace, resolution }; + 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); @@ -149,7 +153,7 @@ function requireSlackWorkspaceOrigin(workspaceUrl: string | undefined): string { async function requireAuthenticatedSlackWorkspace( client: SlackApiClient, configuredWorkspaceUrl: string | undefined, -): Promise { +): Promise<{ workspace: string; edgeCacheId: string }> { const configuredWorkspace = configuredWorkspaceUrl ? requireSlackWorkspaceOrigin(configuredWorkspaceUrl) : undefined; @@ -160,7 +164,19 @@ async function requireAuthenticatedSlackWorkspace( if (configuredWorkspace && configuredWorkspace !== authenticatedWorkspace) { throw new Error("Authenticated Slack workspace does not match the selected workspace"); } - return authenticatedWorkspace; + 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 { 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 index fd9eb9d..b787ead 100644 --- a/src/slack/strict-user-resolution.ts +++ b/src/slack/strict-user-resolution.ts @@ -29,6 +29,7 @@ export function validateStrictUserIdentityBatch(identities: string[]): void { export async function resolveStrictUserIdentities(input: { client: SlackApiClient; identities: string[]; + edgeCacheId?: string; }): Promise { const identities = prepareIdentities(input.identities); const uniqueIdentities = new Map(); @@ -43,7 +44,7 @@ export async function resolveStrictUserIdentities(input: { response = identity.kind === "id" ? await input.client.api("users.info", { user: identity.value }) - : await input.client.api("users.lookupByEmail", { email: identity.value }); + : await input.client.lookupUserByEmail(identity.value, input.edgeCacheId); } catch (error) { if (isNotFoundError(error, identity.kind)) { resultByIdentity.set(identity.key, { status: "unresolved" }); @@ -52,12 +53,23 @@ export async function resolveStrictUserIdentities(input: { throw error; } - const userId = parseVerifiedUserId(response.user); - const matchesInput = userId && (identity.kind === "email" || userId === identity.value); - resultByIdentity.set( - identity.key, - matchesInput ? { status: "resolved", userId } : { status: "unresolved" }, - ); + 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 => { @@ -140,6 +152,28 @@ function parseVerifiedUserId(value: unknown): string | 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) { 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 index fb6b376..4dd30b7 100644 --- a/test/strict-user-resolution.test.ts +++ b/test/strict-user-resolution.test.ts @@ -24,7 +24,10 @@ function user(id: string, fields: Record = {}): Record) => Promise>, ): SlackApiClient { - return { api: handler } as unknown as SlackApiClient; + return { + api: handler, + lookupUserByEmail: (email: string) => handler("users.lookupByEmail", { email }), + } as unknown as SlackApiClient; } describe("strict batch user resolution", () => { @@ -84,6 +87,44 @@ describe("strict batch user resolution", () => { }); }); + 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) => { diff --git a/test/user-command.test.ts b/test/user-command.test.ts index 945477f..c9765c0 100644 --- a/test/user-command.test.ts +++ b/test/user-command.test.ts @@ -43,15 +43,17 @@ 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: 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; - }, + api, + lookupUserByEmail: (email: string) => api("users.lookupByEmail", { email }), } as unknown as SlackApiClient; } @@ -79,10 +81,10 @@ describe("user resolve command", () => { const calls: { method: string; params: Record }[] = []; const client = clientFor( [ - { url: "https://agency.slack-gov.com/" }, + { url: "https://agency.slack-gov.com/", team_id: "T11111111" }, { user: user("U11111111") }, new Error("invalid_auth"), - { url: "https://agency.slack-gov.com/" }, + { url: "https://agency.slack-gov.com/", team_id: "T11111111" }, { user: user("U33333333") }, { user: user("W22222222") }, ], @@ -134,7 +136,7 @@ describe("user resolve command", () => { await runResolve( context( clientFor([ - { url: "https://workspace.slack.com/" }, + { url: "https://workspace.slack.com/", team_id: "T11111111" }, { user: user("U11111111", { deleted: true }) }, ]), ), @@ -181,7 +183,7 @@ describe("user resolve command", () => { 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/" }], calls); + const client = clientFor([{ url: "https://actual.slack.com/", team_id: "T11111111" }], calls); await runResolve( context(client, { @@ -200,8 +202,23 @@ describe("user resolve command", () => { 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/" }, { user: user("U11111111") }]); + const client = clientFor([ + { url: "https://actual.slack.com/", team_id: "T11111111" }, + { user: user("U11111111") }, + ]); await runResolve( context(client, {