diff --git a/CHANGELOG.md b/CHANGELOG.md index 38ad28be..3716838d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,11 @@ product overview. ## Unreleased +- Let organization admins use another requester's Slack menu buttons and question forms. Menu + views open under the clicking admin's own identity; question answers continue the original + requester's session and record which admin responded. Current access, membership and stale-form + checks still apply. The same admin access applies to busy-thread and engine-switch/retry cards. + - Restore Composio Remote Workbench for remote data processing and bulk app calls, preserving workflow session and file references. Explicit Slack membership actions still require human approval; Python inside Workbench is outside that structured gate. Other generic connector diff --git a/FEATURES.md b/FEATURES.md index af1e38df..99849d3f 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -1,5 +1,22 @@ # ChannelGate — Features +## Admin access to Slack controls + +- Current organization admins can open Files, Variables, Settings and legacy menu Resume controls + on another requester's message or `/menu` card. Each new settings/file/variable view belongs to + the admin who clicked; personal credentials, connections and skills use that viewer's identity. + Current channel authorization and membership are checked, and ordinary members remain bound to + their own controls. Private modal navigation and submissions remain bound to their viewer. +- Admins can open, answer, page, submit and cancel another requester's pending question cards and + forms. Both people must retain channel access and membership, and the responder must remain an + admin. Revision checks and single submission still apply. Continuations retain the original + requester's runtime identity and credentials; audit records identify the responding admin + without storing their answer text. +- Admins can also resolve shared busy-thread Steer/Queue/Cancel and engine-switch/retry cards + after current rank, membership and channel access checks. The stored incoming event retains its + author, credentials and runtime permissions; accepted decisions audit the responding admin. + Expiration, duplicate claims and the cross-author automatic steering guard still apply. + ## Registered inventory limits and Enterprise harness approvals - Non-Enterprise installs allow at most 20 registered channels (including DMs and the API diff --git a/TEST-PLAN.md b/TEST-PLAN.md index 530c7c32..824bbbaa 100644 --- a/TEST-PLAN.md +++ b/TEST-PLAN.md @@ -1,5 +1,57 @@ # ChannelGate — Test Plan +## Admin access to Slack buttons and forms (2026-10-23) + +- [x] Automated: `node --test test/admin-menu-controls.test.js test/file-button-actions.test.js + test/menu.test.js test/question-admin-access.test.js test/question-interactions.test.js + test/question-continuation.test.js test/scheduled-questions.test.js + test/run-choice-admin-access.test.js test/busy-thread-choice.test.js + test/engine-switch-choice.test.js`. + Real scratch stores and captured Slack calls verify admin overrides for all three menu controls, + actual viewer ownership/personal variable isolation, legacy Resume, question choices, custom text, + multi-select, paged forms, Back, cancel and submit. Foreign ordinary members, demoted admins, + revoked requester access, missing membership, wrong cards/channels and stale revisions fail closed. + Admin answers preserve the original continuation author and audit the responder without answers. + Busy-thread/engine-switch fixtures verify admin access, original incoming-event identity and + responder audit, plus rejection for ordinary foreign members, demotion, removed membership, + dormant access, a different channel and duplicate decisions. +- [x] Final development validation: `npm run test:coverage` — 4,025 passed, 34 skipped, + zero failures; coverage floors passed (lines 94.30%, branches 86.65%, functions 90.28%). + `npm run check:static`, `npm run secret-scan`, `npm run test:security-coverage` and DCO passed. + Corresponding private QA definitions are updated; live acceptance remains unexecuted. +- [ ] Live menu acceptance (engine-independent; no engine turn for these controls): in a disposable + Slack channel, admit an approved member, a second ordinary member and a current organization + admin. Give the first member and admin distinct disposable personal variables. As first member, + post `@agent /menu`. As admin, open Files, Variables and Settings from that card and from an + existing member-authored reply. Require every modal to open; Variables/Settings → You must show + only the admin's personal entries. Open and save a disposable channel-variable form and remove + the variable afterwards. Require audit attribution to the admin and no raw secret in a view. + As second ordinary member, require foreign owned buttons denied. Demote the admin or remove + channel membership and retry: deny the foreign button; also deny stale personal modal actions + after membership is removed. Record Slack view payloads, actor ids and audit event ids. +- [ ] Live question acceptance (repeat with Claude AND Codex): pin each engine in its disposable + Worker fixture. As approved member ask: “Use ask_questions to collect audience and project name + before replying; allow custom text.” As a current member admin, select an option, open custom + text, save and submit. Repeat with six questions to exercise Back/Next; cancel a separate request. + Require one continuation in the original thread/session as the original requester, their personal + scope and project runtime lane, plus `question_admin_action` naming the actual admin with no + answer text. A second ordinary member must be refused. Repeat after admin demotion, requester + access revocation and either participant leaving: no continuation. Stale/replayed submission + must never duplicate a run. Capture question id, Slack payloads, run author/session/runtime, + credential names only and audit events. No live result is claimed by the automated fixtures. + +- [ ] Live shared run-choice acceptance (Claude AND Codex fixtures): as a member start a harmless + long turn, then send a second message to obtain the busy-thread card. As member admin click Queue, + then repeat separate fixtures for Steer and Cancel Request. Require one original-author message + queued/accepted (or durably discarded for Cancel), preserved personal scope/runtime lane and + `run_choice_admin_action` naming the admin with no original message text. The existing + cross-author automatic steering guard must remain intact. With ask-before-switch enabled and a + disposable replay-safe fake-provider failure, have the admin click Switch and Try again in + separate fixtures; require the original author to continue once on the selected harness. + Ordinary foreign members, demoted/departed admins, dormant channels and wrong-channel cards + must fail without consuming the pending choice. Repeat a successful click: no duplicate run. + Capture Slack card, pending-choice id, run identity and audit events; no live pass claimed here. + ## Email accounts (2026-10-22) ### Regression @@ -3393,8 +3445,9 @@ the originating thread under the original author, with no continuation before fi useful validation response and no continuation. Leave optional notes empty, complete required fields and submit; require all pages' answers, including the written project name. - **QST-04 — requester and revision isolation.** With an approved member's pending card, have - another approved member and the admin try to choose, open custom text, and submit. Require - rejection without modifying the request. As requester, open two modal views, change a draft + another approved member try to choose, open custom text, and submit. Require rejection without + modifying the request. A current member admin may answer and submit under the original + requester continuation identity, with the responding admin recorded in audit. As requester, open two modal views, change a draft through the newer view, then submit the stale view. Require stale-view protection, preservation of the current draft, and successful submission from refreshed controls. Replay final Submit and click an answered card: require no duplicate continuation. Revoke the requester's channel @@ -3846,7 +3899,8 @@ The skipped/live cases below remain unverified; this branch is not a release can without changing data. Settings → Resume Session shows existing sessions with their owning harness and container cwd, including Clean cwd; fresh cases show guidance. On a card posted before this change, clear the thread and click its old Resume button: no old session command. A channel message `/menu` without mention is ignored; - a revoked/nonmember or different author cannot open an old menu's controls. Capture Slack + a revoked/nonmember or different non-admin author cannot open an old menu's controls; a current + member admin can, under their own viewer identity. Capture Slack payloads/screenshots, session identity and event evidence. Card delivery is engine-independent; resume ownership/cwd requires both harness fixtures. Live cases remain unexecuted at development check time and are required before stable release. diff --git a/src/slack/app.js b/src/slack/app.js index 7b4acb8c..263f73c8 100644 --- a/src/slack/app.js +++ b/src/slack/app.js @@ -359,7 +359,8 @@ export async function handleMenuResumeAction({ ack, body, action, client }) { const userId = body?.user?.id; try { const value = JSON.parse(action?.value || "{}"); - if (!userId || value.u !== userId || value.c !== channelId || typeof value.t !== "string") { + if (!canOpenMenuButton({ ownerId: value.u, clickerId: userId, clickerIsAdmin: userId ? await isAdmin(userId) : false }) + || value.c !== channelId || typeof value.t !== "string") { throw new Error("This menu isn't yours. Open your own with `/menu`."); } const context = await fileExplorerContext(client, { channelId, userId, verifyMembership: true }); @@ -370,7 +371,7 @@ export async function handleMenuResumeAction({ ack, body, action, client }) { } } -async function openFileExplorer(client, triggerId, { channelId, userId, threadTs = "", file = "" } = {}) { +async function openFileExplorer(client, triggerId, { channelId, userId, threadTs = "", file = "", verifyMembership = false } = {}) { if (!(await getChannelEntry(channelId))) { await ensureRegistered(client, { channel: channelId, @@ -379,7 +380,7 @@ async function openFileExplorer(client, triggerId, { channelId, userId, threadTs }); } await ensureUserKnown(client, userId); - const { entry, meta, userIsAdmin, root } = await fileExplorerContext(client, { channelId, userId }); + const { entry, meta, userIsAdmin, root } = await fileExplorerContext(client, { channelId, userId, verifyMembership }); const relativeFile = normalizeRelativePath(file || ""); const parent = relativeFile ? path.posix.dirname(relativeFile) : ""; const state = { channelId, slug: entry.slug, threadTs, ownerId: userId, relative: parent === "." ? "" : parent, page: 0 }; @@ -429,9 +430,9 @@ export async function secretScopeLists(meta, viewerId) { }; } -async function openSecretsManager(client, triggerId, { channelId, userId, threadTs = "" } = {}) { +async function openSecretsManager(client, triggerId, { channelId, userId, threadTs = "", verifyMembership = false } = {}) { await ensureUserKnown(client, userId); - const { entry, meta, mayEdit, userIsAdmin } = await secretsContext(client, { channelId, userId }); + const { entry, meta, mayEdit, userIsAdmin } = await secretsContext(client, { channelId, userId, verifyMembership }); const state = { channelId, slug: entry.slug, threadTs, ownerId: userId }; await client.views.open({ trigger_id: triggerId, @@ -457,8 +458,7 @@ export async function handleSecretsAction({ ack, body, action, client }, { conte const command = parseSecretActionValue(action?.value); try { if (command.o === "open") { - if (!canOpenMenuButton({ ownerId: command.u, clickerId: clicker }) || !body?.trigger_id) throw new Error("This secrets button isn't for you."); - await openSecretsManager(client, body.trigger_id, { channelId: command.c, userId: clicker, threadTs: command.t || "" }); + await openChannelMenuControl({ body, command, client, kind: "variables" }); return; } const state = parseSecretsMetadata(body?.view?.private_metadata); @@ -1981,17 +1981,32 @@ async function notifyAdminsScopes(client, missing) { await logEvent("scope_check_notified", { admins: sent, missing: missing.join(",") }); } -// A reply footer belongs to the requester who caused that reply, but gateway admins must still -// be able to inspect its workspace files. Everyone else remains bound to their own controls. +// A reply footer belongs to the requester who caused that reply, but current gateway admins +// can open every shared menu control. Everyone else remains bound to their own controls. // An automation post's menu is unbound (no owner): it opens for whoever clicks, under that // clicker's own live authorization (footer.js explains the binding). export function canOpenMessageFileButton({ ownerId = "", clickerId = "", clickerIsAdmin = false } = {}) { + return canOpenMenuButton({ ownerId, clickerId, clickerIsAdmin }); +} + +export function canOpenMenuButton({ ownerId = "", clickerId = "", clickerIsAdmin = false } = {}) { return Boolean(clickerId && (!ownerId || clickerId === ownerId || clickerIsAdmin)); } -// The same rule for the other menu buttons, which have no admin override. -export function canOpenMenuButton({ ownerId = "", clickerId = "" } = {}) { - return Boolean(clickerId && (!ownerId || clickerId === ownerId)); +// Always open a new view as the actual clicker. Admin access to someone else's shared button +// must never turn its original requester into the owner of personal settings or credentials. +export async function openChannelMenuControl({ body, command, client, kind }) { + const clicker = body?.user?.id; + const channelId = body?.channel?.id || body?.container?.channel_id; + const open = { files: openFileExplorer, variables: openSecretsManager, settings: openChannelSettings }[kind]; + const clickerIsAdmin = clicker && command.u && command.u !== clicker ? await isAdmin(clicker) : false; + if (!open || !command.c || (channelId && command.c !== channelId) || !body?.trigger_id + || !canOpenMenuButton({ ownerId: command.u, clickerId: clicker, clickerIsAdmin })) { + throw new Error("This menu button isn't for you. Open your own with `/menu`."); + } + // Recheck membership on every shared message button, just as on later modal interactions. + await open(client, body.trigger_id, { channelId: command.c, userId: clicker, threadTs: command.t || "", + file: command.o === "open_file" ? command.p || "" : "", verifyMembership: true }); } // Slack does not infer the source thread for an ephemeral posted from a Block Kit action. Carry @@ -2044,16 +2059,7 @@ async function connectAndWire(app) { const command = parseFileActionValue(action?.value); try { if (command.o === "open" || command.o === "open_file") { - const clickerIsAdmin = clicker && command.u && command.u !== clicker ? await isAdmin(clicker) : false; - if (!canOpenMessageFileButton({ ownerId: command.u, clickerId: clicker, clickerIsAdmin }) || !body?.trigger_id) { - throw new Error("This file explorer button isn't for you."); - } - await openFileExplorer(client, body.trigger_id, { - channelId: command.c, - userId: clicker, - threadTs: command.t || "", - file: command.o === "open_file" ? command.p || "" : "", - }); + await openChannelMenuControl({ body, command, client, kind: "files" }); return; } const state = parseExplorerMetadata(body?.view?.private_metadata); @@ -2210,12 +2216,7 @@ async function connectAndWire(app) { const actionId = String(action?.action_id || ""); try { if (command.o === "open") { - if (!canOpenMenuButton({ ownerId: command.u, clickerId: clicker }) || !body?.trigger_id) throw new Error("This settings button isn't for you."); - await openChannelSettings(client, body.trigger_id, { - channelId: command.c, - userId: clicker, - threadTs: command.t || "", - }); + await openChannelMenuControl({ body, command, client, kind: "settings" }); return; } diff --git a/src/slack/busy-thread-choice.js b/src/slack/busy-thread-choice.js index fbd00911..44f4c2f5 100644 --- a/src/slack/busy-thread-choice.js +++ b/src/slack/busy-thread-choice.js @@ -3,6 +3,10 @@ import { retireApprovalLinkTokens } from "../gateway/approval-link-tokens.js"; import { approvalLinkBase, approvalLinksMessage, buildApprovalLinks } from "../web/approval-links.js"; import { slackAdapter } from "../platforms/slack.js"; import { postPrivately } from "../platforms/notify.js"; +import { getChannelEntry, getChannelMeta, isAdmin } from "../config/store.js"; +import { isAuthorized } from "../gateway/modes.js"; +import { listConversationMemberIds } from "./members.js"; +import { logEvent } from "../util/logger.js"; import { acceptPendingRunChoice, clearActiveRun, @@ -79,9 +83,9 @@ export function createBusyThreadChoiceStore({ ttlMs = 10 * 60 * 1000, maxEntries return mine(); }, // Claim a card on behalf of the person who raised it. The admin approvals API resolves the - // same card an authorized Slack user could click; the ownership rule above is a chat-UI guard - // (only the sender of THAT message may click), not an authority boundary the admin session - // has to pass a second time. + // same card an authorized Slack user could click. The store's default ownership guard is + // widened only after the Slack handler verifies a current admin, or an admin API session + // has already established that authority. takeAsAdmin(id) { const record = getPendingRunChoice(String(id || "")); if (!record || kindOf(record) !== kind) return { ok: false, reason: "expired" }; @@ -124,6 +128,33 @@ export function createBusyThreadChoiceStore({ ttlMs = 10 * 60 * 1000, maxEntries export const busyThreadChoices = createBusyThreadChoiceStore(); +// The original Slack event remains the execution identity. An admin click only resolves the +// waiting decision; it never lends the admin's personal connections or runtime permissions. +export async function takeSlackRunChoice(store, id, userId, channelId, client) { + const outcome = store.take(id, userId, channelId); + if (outcome.ok || outcome.reason !== "owner" || !await isAdmin(userId)) return outcome; + try { + const record = getPendingRunChoice(id); + if (!channelId || record?.event?.channel !== channelId) return { ok: false, reason: "channel" }; + const entry = await getChannelEntry(channelId); + if (!entry) return { ok: false, reason: "access" }; + const meta = await getChannelMeta(entry.slug); + const members = await listConversationMemberIds(client, channelId); + // Membership lookup is asynchronous: rank is checked again immediately before claiming. + const admin = await isAdmin(userId); + if (!admin || !members.includes(userId) || !isAuthorized(meta, userId, Boolean(entry.isDM), { isAdminUser: admin })) return { ok: false, reason: "access" }; + return store.takeAsAdmin(id); + } catch { + return { ok: false, reason: "access" }; + } +} + +export function auditRunChoiceAdmin(record, responderId, choiceId, choice) { + if (!responderId || responderId === record.event?.user) return; + return logEvent("run_choice_admin_action", { channel: record.channelId, + author: responderId, requesterId: record.event?.user, choiceId, kind: kindOf(record), choice }); +} + export function busyThreadChoiceBlocks(id) { return [ { @@ -211,10 +242,12 @@ export async function handleBusyThreadChoice({ ack, body, action, client }, { pr if (!choice) return { ok: false, reason: "action" }; const choiceId = String(action?.value || ""); - const outcome = busyThreadChoices.take(choiceId, userId, channel); + const outcome = await takeSlackRunChoice(busyThreadChoices, choiceId, userId, channel, client); if (!outcome.ok) { const text = outcome.reason === "owner" ? "This choice belongs to the person who sent that message." + : outcome.reason === "access" + ? "Only a current organization admin with access to this conversation can answer another person's choice." : outcome.reason === "claimed" ? "This message choice is already being handled." : "This choice has already been used, stopped, or expired. Send the message again if it still needs attention."; @@ -230,14 +263,14 @@ export async function handleBusyThreadChoice({ ack, body, action, client }, { pr } const messageTs = body?.message?.ts || outcome.record?.choiceMessageTs || ""; - return applyBusyThreadChoice({ choiceId, record: outcome.record, choice, client, channel, messageTs, processMessage }); + return applyBusyThreadChoice({ choiceId, record: outcome.record, choice, client, channel, messageTs, processMessage, responderId: userId }); } // The half of a busy-thread decision that is NOT Slack-shaped, shared by the button handler and // the admin approvals API: cancel drops the waiting message durably, steer/queue re-enter the // pipeline with the exact stored event. Whoever calls this has already claimed the card through // the store (take / takeAsAdmin), which is what makes a double resolution impossible. -export async function applyBusyThreadChoice({ choiceId, record, choice, client, channel = "", messageTs = "", processMessage }) { +export async function applyBusyThreadChoice({ choiceId, record, choice, client, channel = "", messageTs = "", processMessage, responderId = "" }) { // The card is being answered, so every unused LINK for it dies with it — whichever surface // answered (a button, the admin API, or one of the links themselves). try { @@ -249,6 +282,7 @@ export async function applyBusyThreadChoice({ choiceId, record, choice, client, // stale click can resurrect it) and the run in progress is deliberately left untouched. if (choice === "cancel") { busyThreadChoices.discard(choiceId); + await auditRunChoiceAdmin(record, responderId, choiceId, choice); if (client?.chat?.update && channel && messageTs) { await client.chat.update({ channel, @@ -268,6 +302,7 @@ export async function applyBusyThreadChoice({ choiceId, record, choice, client, busyChoiceId: choiceId, onBusyChoiceAccepted: ({ runId, rec }) => { accepted = busyThreadChoices.accept(choiceId, runId, rec); + if (accepted) void auditRunChoiceAdmin(record, responderId, choiceId, choice); if (accepted && client?.chat?.delete && channel && messageTs) { // The user's follow-up already remains visible in Slack. Once its choice is accepted, // remove the temporary bot card instead of turning it into a second persistent message. diff --git a/src/slack/engine-switch-choice.js b/src/slack/engine-switch-choice.js index 2490a799..b8d2347e 100644 --- a/src/slack/engine-switch-choice.js +++ b/src/slack/engine-switch-choice.js @@ -1,6 +1,6 @@ import { plainFailureText } from "../util/process-outcome.js"; import { engineLabel } from "../engines/registry.js"; -import { createBusyThreadChoiceStore } from "./busy-thread-choice.js"; +import { createBusyThreadChoiceStore, takeSlackRunChoice, auditRunChoiceAdmin } from "./busy-thread-choice.js"; // "Ask before switching harness" (Settings → engineFallbackMode = "ask"): when a turn dies on a // replay-safe provider failure that automatic failover WOULD have covered — a usage limit, a lost @@ -66,10 +66,12 @@ export async function handleEngineSwitchChoice({ ack, body, action, client }, { if (!choice) return { ok: false, reason: "action" }; const choiceId = String(action?.value || ""); - const outcome = engineSwitchChoices.take(choiceId, userId, channel); + const outcome = await takeSlackRunChoice(engineSwitchChoices, choiceId, userId, channel, client); if (!outcome.ok) { const text = outcome.reason === "owner" ? "This choice belongs to the person who sent that message." + : outcome.reason === "access" + ? "Only a current organization admin with access to this conversation can answer another person's choice." : outcome.reason === "claimed" ? "This choice is already being handled." : "This choice has already been used, stopped, or expired. Send the message again if it still needs attention."; @@ -101,6 +103,7 @@ export async function handleEngineSwitchChoice({ ack, body, action, client }, { engineChoiceId: choiceId, onEngineChoiceAccepted: ({ runId, rec }) => { accepted = engineSwitchChoices.accept(choiceId, runId, rec); + if (accepted) void auditRunChoiceAdmin(outcome.record, userId, choiceId, choice); // The original message stays visible in Slack; once the choice is accepted the card has // done its job — remove it instead of leaving a second persistent bot message. if (accepted && channel && messageTs) client.chat.delete({ channel, ts: messageTs }).catch(() => {}); diff --git a/src/slack/footer.js b/src/slack/footer.js index 6d3d5585..8b41d3b7 100644 --- a/src/slack/footer.js +++ b/src/slack/footer.js @@ -35,7 +35,8 @@ export function buildResumeCommand(cwd, sessionId, engine, target = null) { // in Channel Settings → Resume Session and `/resume`. // // Binding: a button carries the requester (`u`) when the post has one, and the handlers refuse a -// different clicker. An automation post (schedule, background job, API run, restart recovery) has +// different non-admin clicker. Admins open every menu button under their own identity. +// An automation post (schedule, background job, API run, restart recovery) has // no Slack requester, so its menu is UNBOUND (`u: ""`) and opens for whoever clicks. That grants // nothing: every open re-applies the clicker's own live channel authorization, and the modal it // opens is bound to that clicker. diff --git a/src/slack/questions.js b/src/slack/questions.js index 781c75f0..d49cb487 100644 --- a/src/slack/questions.js +++ b/src/slack/questions.js @@ -1,7 +1,9 @@ import { resolveSlackConfig } from "../config/settings.js"; +import { isAdmin } from "../config/store.js"; import { assertQuestionAccess } from "../gateway/question-access.js"; import { createQuestion, getQuestion, bindQuestionMessage, updateQuestion, saveQuestionAnswers, validateAnswer, missingQuestionAnswers, acceptQuestionSubmission, formatQuestionAnswers } from "../gateway/questions.js"; import { acquireKeyedLock } from "../util/keyed-lock.js"; +import { logEvent } from "../util/logger.js"; import { buildQuestionCard, buildQuestionModal, buildCustomAnswerModal, parseQuestionMetadata, parseQuestionPageAnswers, questionPage, QUESTION_FORM_CALLBACK, QUESTION_CUSTOM_CALLBACK } from "./question-views.js"; // The MCP connection lives in the daemon; no bot credential crosses into the engine container. @@ -63,10 +65,11 @@ function actionMetadata(action) { return parseQuestionMetadata(action?.value); } -function ownQuestion(metadata, body, { modal = false, allowStale = false } = {}) { +async function ownQuestion(metadata, body, { modal = false, allowStale = false } = {}) { if (!metadata) throw new Error("Invalid question action."); const record = getQuestion(metadata.id); - if (!record || record.authorId !== body?.user?.id) throw new Error("Only the person who requested these questions can answer them."); + const responderId = body?.user?.id; + if (!record || !responderId || (record.authorId !== responderId && !await isAdmin(responderId))) throw new Error("Only the person who requested these questions or an organization admin can answer them."); if (!modal) { const channel = body?.channel?.id || body?.container?.channel_id; const ts = body?.message?.ts || body?.container?.message_ts; @@ -77,6 +80,24 @@ function ownQuestion(metadata, body, { modal = false, allowStale = false } = {}) return record; } +async function assertResponderAccess(record, responderId, client) { + await Promise.all([ + assertQuestionAccess(record, client, { timeoutMs: 1500 }), + ...(responderId !== record.authorId ? [assertQuestionAccess({ ...record, authorId: responderId }, client, { timeoutMs: 1500 })] : []), + ]); + if (responderId !== record.authorId) { + // An admin can operate another member's form, but cannot reuse a stale modal after leaving + // the conversation or losing rank. The requester must also remain eligible to continue. + if (!await isAdmin(responderId)) throw new Error("Only the person who requested these questions or an organization admin can answer them."); + } +} + +function auditAdminAction(record, responderId, action) { + if (responderId === record.authorId) return; + return logEvent("question_admin_action", { channel: record.channelId, slug: record.slug, + author: responderId, requesterId: record.authorId, questionId: record.id, action }); +} + async function notice(client, record, userId, message) { if (!record?.channelId || !userId) return; await client.chat.postEphemeral({ channel: record.channelId, thread_ts: record.threadKey, user: userId, text: message }).catch(() => {}); @@ -86,7 +107,7 @@ const submitting = new Set(); // Queue a synthetic human answer through the normal authorization, licensing and session path. // Nothing executes on selection. The atomic callback alone makes submission terminal. Keep the // promise observed, but never make Slack wait for an engine turn to finish before acknowledging. -export function startQuestionContinuation(record, client, processMessage, { onError = () => {} } = {}) { +export function startQuestionContinuation(record, client, processMessage, { onError = () => {}, responderId = record.authorId } = {}) { if (submitting.has(record.id)) return false; submitting.add(record.id); const event = { @@ -95,18 +116,26 @@ export function startQuestionContinuation(record, client, processMessage, { onEr ts: `question-${record.id}`, text: formatQuestionAnswers(record), }; let accepted = false; - const promise = Promise.resolve().then(() => processMessage(event, client, { - bypassMention: true, busyChoice: "queue", questionSubmissionId: record.id, - onQuestionSubmissionAccepted: ({ runId, rec }) => { - accepted = acceptQuestionSubmission(record.id, record.revision, runId, rec); - if (accepted) void refreshQuestionCard(getQuestion(record.id), client).catch(() => {}); - return accepted; - }, - })).then(() => { + const promise = Promise.resolve().then(async () => { + // Keep the original execution identity and its personal credentials. Admin form access is + // permission to supply answers, never permission to promote the requester's runtime lane. + if (responderId !== record.authorId) await assertResponderAccess(record, responderId, client); + return processMessage(event, client, { + bypassMention: true, busyChoice: "queue", questionSubmissionId: record.id, + onQuestionSubmissionAccepted: ({ runId, rec }) => { + accepted = acceptQuestionSubmission(record.id, record.revision, runId, rec); + if (accepted) { + void auditAdminAction(record, responderId, "submit"); + void refreshQuestionCard(getQuestion(record.id), client).catch(() => {}); + } + return accepted; + }, + }); + }).then(() => { if (!accepted && getQuestion(record.id)?.status === "pending") throw new Error("Your answers are saved, but the continuation could not be queued. Reopen the card and submit again."); }).catch(async (error) => { onError(error); - await notice(client, record, record.authorId, error.message); + await notice(client, record, responderId, error.message); }).finally(() => submitting.delete(record.id)); return promise; } @@ -117,15 +146,16 @@ export async function handleQuestionAction({ ack, body, action, client }, { proc try { const metadata = actionMetadata(action); const inModal = Boolean(body.view); - record = ownQuestion(metadata, body, { modal: inModal, allowStale: true }); + record = await ownQuestion(metadata, body, { modal: inModal, allowStale: true }); // Leave time within Slack's three-second interaction window to acknowledge errors/open views. - await assertQuestionAccess(record, client, { timeoutMs: 1500 }); + await assertResponderAccess(record, body.user.id, client); const actionId = action.action_id; // Opening is read-only and also repairs a card whose last Slack update failed. Stale writes // are never applied; their catch path refreshes the visible controls from the durable draft. if (record.revision !== metadata.revision && actionId !== "cg_question_open" && !actionId.startsWith("cg_question_custom:")) throw new Error("These answers changed. The card has been refreshed; please try again."); if (actionId === "cg_question_open") { await client.views.open({ trigger_id: body.trigger_id, view: buildQuestionModal(record) }); + await auditAdminAction(record, body.user.id, "open"); return; } if (actionId === "cg_question_back") { @@ -136,6 +166,7 @@ export async function handleQuestionAction({ ack, body, action, client }, { proc } else if (actionId.startsWith("cg_question_custom:")) { const qid = actionId.slice("cg_question_custom:".length); await client.views.open({ trigger_id: body.trigger_id, view: buildCustomAnswerModal(record, qid) }); + await auditAdminAction(record, body.user.id, "custom_open"); return; } else if (actionId.startsWith("cg_question_choose:")) { const [, qid, index] = actionId.split(":"); @@ -151,9 +182,10 @@ export async function handleQuestionAction({ ack, body, action, client }, { proc record = updateQuestion(record.id, record.revision, { status: "cancelled" }); } else if (actionId === "cg_question_submit") { if (missingQuestionAnswers(record).length) throw new Error("Please answer every required question before submitting."); - startQuestionContinuation(record, client, processMessage); + startQuestionContinuation(record, client, processMessage, { responderId: body.user.id }); return; } else throw new Error("Unknown question action."); + await auditAdminAction(record, body.user.id, actionId === "cg_question_cancel" ? "cancel" : "save"); await refreshQuestionCard(record, client); } catch (error) { if (record) await refreshQuestionCard(record, client).catch(() => {}); @@ -168,14 +200,15 @@ export async function handleQuestionView({ ack, body, view = body?.view, client let record; try { const metadata = parseQuestionMetadata(view?.private_metadata); - record = ownQuestion(metadata, body, { modal: true }); - await assertQuestionAccess(record, client, { timeoutMs: 1500 }); + record = await ownQuestion(metadata, body, { modal: true }); + await assertResponderAccess(record, body.user.id, client); if (view.callback_id === QUESTION_CUSTOM_CALLBACK) { const qid = metadata.questionId; const q = record.questions.find((q) => q.id === qid); if (!q || !q.allowCustom) throw new Error("Custom answers are not allowed."); const custom = view.state?.values?.[`q:${qid}`]?.custom?.value || ""; record = saveQuestionAnswers(record.id, record.revision, { [qid]: { values: record.answers[qid]?.values || [], custom } }); + await auditAdminAction(record, body.user.id, "custom_save"); await respond(); } else if (view.callback_id === QUESTION_FORM_CALLBACK) { const page = metadata.page; @@ -189,6 +222,7 @@ export async function handleQuestionView({ ack, body, view = body?.view, client return; } record = saveQuestionAnswers(record.id, record.revision, patch); + await auditAdminAction(record, body.user.id, "save"); if (page + 1 < slice.totalPages) await respond({ response_action: "update", view: buildQuestionModal(record, page + 1) }); else if (missingQuestionAnswers(record).length) { // Back permits incomplete drafts. Return to the first missing page instead of dropping it. @@ -196,7 +230,7 @@ export async function handleQuestionView({ ack, body, view = body?.view, client await respond({ response_action: "update", view: buildQuestionModal(record, Math.floor(first / 3)) }); } else { await respond(); - startQuestionContinuation(record, client, processMessage); + startQuestionContinuation(record, client, processMessage, { responderId: body.user.id }); } } else throw new Error("Unknown question form."); await refreshQuestionCard(getQuestion(record.id), client); diff --git a/test/admin-menu-controls.test.js b/test/admin-menu-controls.test.js new file mode 100644 index 00000000..c16bed15 --- /dev/null +++ b/test/admin-menu-controls.test.js @@ -0,0 +1,100 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { mkdir, writeFile } from "node:fs/promises"; +import path from "node:path"; +import { ensureTestEnv } from "./helpers.js"; +ensureTestEnv(); +const { useFakeRuntime } = await import("./runtime-fake.js"); +await useFakeRuntime(); +const store = await import("../src/config/store.js"); +const { patchUserEnv } = await import("../src/config/scoped-env.js"); +const { effectiveWorkDir } = await import("../src/gateway/folders.js"); +const { openChannelMenuControl, handleSecretsAction, handleMenuResumeAction } = await import("../src/slack/app.js"); +let sequence = 0; +async function fixture() { + const channel = `CADMINMENU${++sequence}`; + const owner = `UOWNER${sequence}`, admin = `UADMIN${sequence}`, member = `UMEMBER${sequence}`; + const entry = await store.upsertChannelEntry(channel, { name: `admin-menu-${sequence}`, type: "channel" }); + await store.saveChannelMeta(entry.slug, { ...await store.getChannelMeta(entry.slug), access: "approved", isDM: false }); + await mkdir(effectiveWorkDir(entry.slug, await store.getChannelMeta(entry.slug)), { recursive: true }); + await store.setUser(owner, { name: "Owner", approved: true }); + await store.setUser(member, { name: "Member", approved: true }); + await store.setUser(admin, { name: "Admin", approved: true, isAdmin: true }); + await patchUserEnv(owner, { set: { name: "OWNER_PRIVATE", value: "dummy-owner-1111" } }); + await patchUserEnv(admin, { set: { name: "ADMIN_PRIVATE", value: "dummy-admin-2222" } }); + const control = { members: [owner, member, admin] }, opened = [], updates = [], notices = []; + const client = { + conversations: { members: async () => ({ members: control.members, response_metadata: {} }) }, + users: { info: async () => ({ user: { real_name: "Menu test" } }) }, + views: { + open: async (args) => { opened.push(args); return { view: { id: "VADMINMENU" } }; }, + update: async (args) => { updates.push(args); return { ok: true }; }, + }, + chat: { postEphemeral: async (args) => { notices.push(args); return { ok: true }; } }, + }; + const body = { user: { id: admin }, channel: { id: channel }, trigger_id: "trigger" }; + const command = { o: "open", c: channel, t: "100.1", u: owner }; + return { channel, owner, admin, member, entry, control, opened, updates, notices, client, body, command }; +} +for (const kind of ["files", "variables", "settings"]) { + test(`admin opens another requester's ${kind} as their own view`, async () => { + const f = await fixture(); + await openChannelMenuControl({ ...f, kind }); + assert.equal(f.opened.length, 1); + const view = f.updates.at(-1)?.view || f.opened[0].view; + const metadata = JSON.parse(view.private_metadata); + assert.equal(metadata.u, f.admin); + assert.equal(metadata.c, f.channel); + assert.equal(metadata.t, "100.1"); + if (kind === "variables") { + assert.match(JSON.stringify(view), /ADMIN_PRIVATE/); + assert.doesNotMatch(JSON.stringify(view), /OWNER_PRIVATE/); + } + assert.doesNotMatch(JSON.stringify(view), /dummy-owner|dummy-admin/); + }); + test(`${kind} rejects foreign members, demoted admins, nonmembers and a different channel`, async () => { + for (const variant of ["member", "demoted", "membership", "channel", "missing-user"]) { + const f = await fixture(); + if (variant === "member") f.body.user.id = f.member; + if (variant === "demoted") await store.setUser(f.admin, { isAdmin: false }); + if (variant === "membership") f.control.members = [f.owner]; + if (variant === "channel") f.body.channel.id = "COTHER"; + if (variant === "missing-user") delete f.body.user; + await assert.rejects(() => openChannelMenuControl({ ...f, kind })); + assert.equal(f.opened.length, 0, variant); + } + }); + test(`${kind} still opens owned and unbound controls for ordinary members`, async () => { + for (const owner of ["self", "unbound"]) { + const f = await fixture(); + f.body.user.id = f.member; + f.command.u = owner === "self" ? f.member : ""; + await openChannelMenuControl({ ...f, kind }); + assert.equal(f.opened.length, 1); + assert.equal(JSON.parse(f.opened[0].view.private_metadata).u, f.member); + } + }); +} +test("admin direct file button preserves the requested preview path", async () => { + const f = await fixture(); + const root = effectiveWorkDir(f.entry.slug, await store.getChannelMeta(f.entry.slug)); + await writeFile(path.join(root, "menu-fixture.txt"), "Admin preview fixture"); + f.command = { ...f.command, o: "open_file", p: "menu-fixture.txt" }; + await openChannelMenuControl({ ...f, kind: "files" }); + assert.match(JSON.stringify(f.updates.at(-1).view), /Admin preview fixture/); + assert.equal(JSON.parse(f.updates.at(-1).view.private_metadata).u, f.admin); +}); +test("Variables action uses the admin override and opens the admin's own form", async () => { + const f = await fixture(); + await handleSecretsAction({ ...f, ack: async () => {}, action: { value: JSON.stringify(f.command) } }); + assert.equal(f.opened.length, 1); + assert.equal(JSON.parse(f.opened[0].view.private_metadata).u, f.admin); + assert.match(JSON.stringify(f.opened[0].view), /ADMIN_PRIVATE/); + assert.doesNotMatch(JSON.stringify(f.opened[0].view), /OWNER_PRIVATE/); +}); +test("admin can open a legacy Resume menu owned by another requester", async () => { + const f = await fixture(); + await handleMenuResumeAction({ ...f, ack: async () => {}, action: { value: JSON.stringify(f.command) } }); + assert.equal(f.opened.length, 1); + assert.match(JSON.stringify(f.opened[0].view), /No session/); +}); diff --git a/test/file-button-actions.test.js b/test/file-button-actions.test.js index c0739039..8ce9c168 100644 --- a/test/file-button-actions.test.js +++ b/test/file-button-actions.test.js @@ -19,6 +19,11 @@ test("an automation post's unbound menu opens for any clicker (their own authori assert.equal(canOpenMenuButton({ ownerId: "", clickerId: "" }), false); }); +test("all shared menu buttons allow a current admin override with an actual clicker", () => { + assert.equal(canOpenMenuButton({ ownerId: "U_OWNER", clickerId: "U_ADMIN", clickerIsAdmin: true }), true); + assert.equal(canOpenMenuButton({ ownerId: "U_OWNER", clickerId: "", clickerIsAdmin: true }), false); +}); + test("file-button notices stay in the thread encoded by the clicked button", () => { assert.deepEqual(fileButtonNoticePayload( { channel: { id: "C1" }, message: { ts: "reply.2", thread_ts: "root.1" } }, diff --git a/test/question-admin-access.test.js b/test/question-admin-access.test.js new file mode 100644 index 00000000..394cff5d --- /dev/null +++ b/test/question-admin-access.test.js @@ -0,0 +1,170 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { randomUUID } from "node:crypto"; +import { ensureTestEnv } from "./helpers.js"; +ensureTestEnv(); + +const { setUser, upsertChannelEntry, defaultChannelMeta, saveChannelMeta } = await import("../src/config/store.js"); +const { getQuestion, saveQuestionAnswers } = await import("../src/gateway/questions.js"); +const { postQuestions, handleQuestionAction, handleQuestionView, startQuestionContinuation } = await import("../src/slack/questions.js"); +const { buildQuestionCard, buildQuestionModal, buildCustomAnswerModal } = await import("../src/slack/question-views.js"); +const { readEvents } = await import("../src/util/logger.js"); + +const OWNER = "UQUESTION_REQUESTER"; +const ADMIN = "UQUESTION_ADMIN"; +const MEMBER = "UQUESTION_MEMBER"; +const CHANNEL = "CQUESTION_ADMIN_ACCESS"; +const entry = await upsertChannelEntry(CHANNEL, { name: "question-admin-access", type: "channel", isDM: false }); +await saveChannelMeta(entry.slug, defaultChannelMeta({ channelId: CHANNEL, name: entry.name, type: "channel", isDM: false, platform: "slack" })); +const elements = (view) => view.blocks.flatMap((b) => (b.elements || []).map((el) => ({ ...el, block_id: b.block_id }))); +const settle = async () => { for (let i = 0; i < 20; i++) await new Promise((resolve) => setImmediate(resolve)); }; +const choice = (id = "access", extra = {}) => ({ id, prompt: "Choose access", type: "single", required: true, + allowCustom: true, options: [{ label: "Team", value: "team" }, { label: "Everyone", value: "all" }], ...extra }); + +async function fixture(questions = [choice()]) { + await setUser(OWNER, { approved: true, isAdmin: false }); + await setUser(ADMIN, { approved: true, isAdmin: true }); + await setUser(MEMBER, { approved: true, isAdmin: false }); + const context = { channelId: CHANNEL, authorId: OWNER, slug: entry.slug, threadKey: randomUUID() }; + const log = { notices: [], opens: [], viewUpdates: [], continuations: [] }; + const control = { members: [OWNER, ADMIN, MEMBER] }; + const client = { + conversations: { members: async () => ({ members: control.members }) }, + chat: { postMessage: async () => ({ ts: randomUUID() }), update: async () => ({}), + postEphemeral: async (payload) => { log.notices.push(payload); return {}; } }, + views: { open: async (payload) => { log.opens.push(payload); }, update: async (payload) => { log.viewUpdates.push(payload); } }, + }; + const record = await postQuestions(context, { title: "Shared draft", questions }, { client }); + const current = () => getQuestion(record.id); + const processMessage = async (event, receivedClient, options) => { + assert.equal(receivedClient, client); + log.continuations.push({ event, options }); + assert.equal(options.onQuestionSubmissionAccepted({ runId: randomUUID(), rec: { ...context, text: event.text } }), true); + }; + const act = async (actionId, { user = ADMIN, data = current(), channel = CHANNEL, ts = data.messageTs, view, selected_options } = {}) => { + const action = elements(view || buildQuestionCard(data)).find((el) => el.action_id === actionId); + assert.ok(action, actionId); + if (selected_options) action.selected_options = selected_options; + let acknowledgments = 0; + await handleQuestionAction({ ack: async () => { acknowledgments++; }, client, action, + body: { user: { id: user }, channel: { id: channel }, message: { ts }, trigger_id: "trigger", ...(view ? { view } : {}) } }, { processMessage }); + assert.equal(acknowledgments, 1); + }; + const submitView = async (view, values, user = ADMIN) => { + const acks = []; + view = { ...view, id: "VADMIN", hash: "admin-hash", state: { values } }; + await handleQuestionView({ ack: async (value) => acks.push(value), client, view, body: { user: { id: user }, view } }, { processMessage }); + assert.equal(acks.length, 1); + return acks[0]; + }; + const audit = () => readEvents().filter((event) => event.event === "question_admin_action" && event.questionId === record.id); + return { context, record, current, client, control, log, processMessage, act, submitView, audit }; +} + +test("admin card answers resume the requester once and audit the actual responder without answers", async () => { + const f = await fixture(); + await f.act("cg_question_choose:access:0"); + const answered = f.current(); + await Promise.all([f.act("cg_question_submit", { data: answered }), f.act("cg_question_submit", { data: answered })]); + await settle(); + assert.deepEqual(f.current().answers.access, { values: ["team"], custom: "" }); + assert.equal(f.current().authorId, OWNER); + assert.equal(f.current().status, "submitted"); + assert.equal(f.log.continuations.length, 1); + assert.equal(f.log.continuations[0].event.user, OWNER); + assert.equal(f.log.continuations[0].options.busyChoice, "queue"); + assert.deepEqual(f.audit().map((event) => event.action).sort(), ["save", "submit"]); + for (const event of f.audit()) { + assert.equal(event.author, ADMIN); + assert.equal(event.requesterId, OWNER); + assert.deepEqual(Object.keys(event).sort(), ["action", "author", "channel", "event", "questionId", "requesterId", "slug", "ts"]); + } +}); + +test("admin multi-choice and custom forms edit the same durable draft", async () => { + const f = await fixture([choice("features", { type: "multi" })]); + await f.act("cg_question_multi:features", { selected_options: [{ value: "all" }] }); + await f.act("cg_question_custom:features"); + const modal = f.log.opens.at(-1).view; + assert.equal(await f.submitView(modal, { "q:features": { custom: { value: "Private custom answer" } } }), undefined); + assert.deepEqual(f.current().answers.features, { values: ["all"], custom: "Private custom answer" }); + assert.equal(f.log.continuations.length, 0); + assert.equal(JSON.stringify(f.audit()).includes("Private custom answer"), false); +}); + +test("admin paginated form opens, saves Back drafts and submits as original requester", async () => { + const f = await fixture(Array.from({ length: 4 }, (_, i) => choice(`q${i}`, { allowCustom: false }))); + await f.act("cg_question_open"); + const first = f.log.opens.at(-1).view; + const firstValues = Object.fromEntries([0, 1, 2].map((i) => [`q:q${i}`, { choice: { selected_option: { value: "team" } } }])); + const next = await f.submitView(first, firstValues); + assert.equal(next.response_action, "update"); + await f.act("cg_question_back", { view: { ...next.view, id: "VADMIN", hash: "hash", state: { values: {} } } }); + assert.equal(f.current().answers.q3.values.length, 0); + const again = await f.submitView(f.log.viewUpdates.at(-1).view, firstValues); + await f.submitView(again.view, { "q:q3": { choice: { selected_option: { value: "all" } } } }); + await settle(); + assert.equal(f.current().status, "submitted"); + assert.equal(f.log.continuations[0].event.user, OWNER); +}); + +test("current admin may cancel another requester's form", async () => { + const f = await fixture(); + await f.act("cg_question_cancel"); + assert.equal(f.current().status, "cancelled"); + assert.equal(f.audit()[0].action, "cancel"); + assert.equal(f.log.continuations.length, 0); +}); + +test("admin access retains card binding and shared revision replay protection", async () => { + const f = await fixture(); + const initial = f.current(); + for (const mismatch of [{ channel: "CWRONG" }, { ts: "wrong" }, { user: MEMBER }]) { + await f.act("cg_question_choose:access:0", mismatch); + assert.equal(f.current().revision, initial.revision); + } + const staleModal = buildCustomAnswerModal(initial, "access"); + await f.act("cg_question_choose:access:0", { user: OWNER }); + await f.act("cg_question_choose:access:1", { data: initial }); + const denied = await f.submitView(staleModal, { "q:access": { custom: { value: "stale" } } }); + assert.equal(denied.response_action, "errors"); + assert.deepEqual(f.current().answers.access.values, ["team"]); + assert.equal(f.audit().length, 0); +}); + +test("departed admin or requester cannot mutate another requester's draft", async () => { + for (const members of [[OWNER, MEMBER], [ADMIN, MEMBER]]) { + const f = await fixture(); + f.control.members = members; + await f.act("cg_question_choose:access:0"); + assert.deepEqual(f.current().answers, {}); + assert.match(f.log.notices.at(-1).text, /current conversation members/); + assert.equal(f.audit().length, 0); + } +}); + +test("admin role and requester authorization are rechecked on existing modals", async () => { + const f = await fixture(); + const modal = buildQuestionModal(f.current()); + await setUser(ADMIN, { isAdmin: false }); + const denied = await f.submitView(modal, { "q:access": { choice: { selected_option: { value: "team" } } } }); + assert.equal(denied.response_action, "errors"); + assert.deepEqual(f.current().answers, {}); + await setUser(ADMIN, { isAdmin: true }); + await setUser(OWNER, { approved: false }); + await f.act("cg_question_choose:access:0"); + assert.deepEqual(f.current().answers, {}); + assert.match(f.log.notices.at(-1).text, /no longer have access/); +}); + +test("admin demotion before continuation launch preserves pending draft and reports to actual responder", async () => { + const f = await fixture(); + const answered = saveQuestionAnswers(f.record.id, 0, { access: { values: ["team"] } }); + const continuation = startQuestionContinuation(answered, f.client, f.processMessage, { responderId: ADMIN }); + await setUser(ADMIN, { isAdmin: false }); + await continuation; + assert.equal(f.current().status, "pending"); + assert.equal(f.log.continuations.length, 0); + assert.equal(f.log.notices.at(-1).user, ADMIN); + assert.equal(f.audit().length, 0); +}); diff --git a/test/run-choice-admin-access.test.js b/test/run-choice-admin-access.test.js new file mode 100644 index 00000000..aa569984 --- /dev/null +++ b/test/run-choice-admin-access.test.js @@ -0,0 +1,121 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { randomUUID } from "node:crypto"; +import { ensureTestEnv } from "./helpers.js"; +ensureTestEnv(); + +const { setUser, upsertChannelEntry, saveChannelMeta, defaultChannelMeta } = await import("../src/config/store.js"); +const { getPendingRunChoice } = await import("../src/gateway/active-runs.js"); +const { readEvents } = await import("../src/util/logger.js"); +const { busyThreadChoices, handleBusyThreadChoice, BUSY_THREAD_QUEUE_ACTION, BUSY_THREAD_STEER_ACTION, BUSY_THREAD_CANCEL_ACTION } = await import("../src/slack/busy-thread-choice.js"); +const { engineSwitchChoices, handleEngineSwitchChoice, ENGINE_SWITCH_ACTION, ENGINE_RETRY_ACTION } = await import("../src/slack/engine-switch-choice.js"); + +const OWNER = "U_RUN_CHOICE_OWNER"; +const ADMIN = "U_RUN_CHOICE_ADMIN"; +const MEMBER = "U_RUN_CHOICE_MEMBER"; +const CHANNEL = "C_RUN_CHOICE_ADMIN"; +const entry = await upsertChannelEntry(CHANNEL, { name: "run-choice-admin", type: "channel", isDM: false }); + +async function fixture(kind, actionId) { + await setUser(OWNER, { approved: true, isAdmin: false }); + await setUser(ADMIN, { approved: true, isAdmin: true }); + await setUser(MEMBER, { approved: true, isAdmin: false }); + const meta = defaultChannelMeta({ channelId: CHANNEL, name: entry.name, type: "channel", isDM: false, platform: "slack" }); + await saveChannelMeta(entry.slug, meta); + const event = { type: "message", user: OWNER, channel: CHANNEL, channel_type: "channel", thread_ts: randomUUID(), ts: randomUUID(), text: "Private pending message" }; + const options = { botUserId: "U_RUN_CHOICE_BOT", teamId: "T_RUN_CHOICE", bypassMention: true, busyTargetRunId: "original-run" }; + const store = kind === "busy_thread" ? busyThreadChoices : engineSwitchChoices; + const handler = kind === "busy_thread" ? handleBusyThreadChoice : handleEngineSwitchChoice; + const id = store.create({ event, options, ask: { failedEngine: "claude", otherEngine: "codex", kind: "transient" } }); + store.noteCard(id, "card.ts"); + const log = { calls: [], notices: [], updates: [], deletes: [] }; + const control = { members: [OWNER, ADMIN, MEMBER] }; + const client = { + conversations: { members: async () => ({ members: control.members }) }, + chat: { + update: async (payload) => { log.updates.push(payload); }, + delete: async (payload) => { log.deletes.push(payload); }, + postEphemeral: async (payload) => { log.notices.push(payload); }, + }, + }; + const click = async ({ user = ADMIN, channel = CHANNEL } = {}) => { + let acked = 0; + const result = await handler({ ack: async () => { acked++; }, client, + action: { action_id: actionId, value: id }, body: { user: { id: user }, channel: { id: channel }, message: { ts: "card.ts", thread_ts: event.thread_ts } } }, { + processMessage: async (receivedEvent, receivedClient, receivedOptions) => { + assert.equal(receivedClient, client); + log.calls.push({ event: receivedEvent, options: receivedOptions }); + const accept = receivedOptions.onBusyChoiceAccepted || receivedOptions.onEngineChoiceAccepted; + assert.equal(accept({ runId: randomUUID(), rec: { channelId: CHANNEL, authorId: OWNER, slug: entry.slug, threadKey: event.thread_ts, text: event.text } }), true); + }, + }); + assert.equal(acked, 1); + return result; + }; + const audit = () => readEvents().filter((row) => row.event === "run_choice_admin_action" && row.choiceId === id); + return { id, kind, event, options, meta, store, control, log, client, click, audit }; +} + +for (const [kind, action, choice] of [ + ["busy_thread", BUSY_THREAD_STEER_ACTION, "steer"], + ["busy_thread", BUSY_THREAD_QUEUE_ACTION, "queue"], + ["busy_thread", BUSY_THREAD_CANCEL_ACTION, "cancel"], + ["engine_switch", ENGINE_SWITCH_ACTION, "switch"], + ["engine_switch", ENGINE_RETRY_ACTION, "retry"], +]) { + test(`admin can choose ${kind} ${choice} while preserving the original request identity`, async () => { + const f = await fixture(kind, action); + const result = await f.click(); + assert.equal(result.ok, true); + assert.equal(result.choice, choice); + assert.equal(getPendingRunChoice(f.id), null); + assert.equal(f.log.calls.length, choice === "cancel" ? 0 : 1); + if (choice !== "cancel") { + const call = f.log.calls[0]; + assert.deepEqual(call.event, f.event); + assert.equal(call.event.user, OWNER); + assert.equal(call.options.botUserId, f.options.botUserId); + assert.equal(call.options.busyTargetRunId, "original-run"); + assert.equal(call.options.responderId, undefined); + } + assert.equal(f.audit().length, 1); + assert.equal(f.audit()[0].author, ADMIN); + assert.equal(f.audit()[0].requesterId, OWNER); + assert.equal(f.audit()[0].choice, choice); + assert.equal(JSON.stringify(f.audit()).includes(f.event.text), false); + const stale = await f.click(); + assert.equal(stale.ok, false); + assert.equal(f.audit().length, 1); + }); +} + +for (const [kind, action] of [["busy_thread", BUSY_THREAD_QUEUE_ACTION], ["engine_switch", ENGINE_SWITCH_ACTION]]) { + test(`${kind}: admin override rejects nonadmins, departed members, wrong conversations and denied access`, async () => { + for (const mode of ["nonadmin", "departed", "wrong-channel", "denied", "lookup-failed"]) { + const f = await fixture(kind, action); + if (mode === "departed") f.control.members = [OWNER, MEMBER]; + if (mode === "denied") await saveChannelMeta(entry.slug, { ...f.meta, access: "none" }); + if (mode === "lookup-failed") f.client.conversations.members = async () => { throw new Error("membership unavailable"); }; + const result = await f.click({ user: mode === "nonadmin" ? MEMBER : ADMIN, channel: mode === "wrong-channel" ? "CWRONG" : CHANNEL }); + assert.equal(result.ok, false, mode); + assert.equal(f.log.calls.length, 0, mode); + assert.equal(f.audit().length, 0, mode); + assert.equal(f.store.take(f.id, OWNER, CHANNEL).ok, true, `${mode}: the owner can still resolve the card`); + f.store.discard(f.id); + } + }); + + test(`${kind}: demotion during membership verification cannot claim another user's card`, async () => { + const f = await fixture(kind, action); + f.client.conversations.members = async () => { + await setUser(ADMIN, { isAdmin: false }); + return { members: [OWNER, ADMIN] }; + }; + const result = await f.click(); + assert.equal(result.ok, false); + assert.equal(result.reason, "access"); + assert.equal(f.log.calls.length, 0); + assert.equal(f.store.take(f.id, OWNER, CHANNEL).ok, true); + f.store.discard(f.id); + }); +}