mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-18 11:31:41 +00:00
* fix(gateway): approval registry hardening and protocol-surface follow-ups
Follow-up delta to the merged #103579 head, rebased onto current main:
- gateway-protocol wire types derive from owner-module schema consts
(types.ts tombstone) and ProtocolSchemas leaves the package index so the
public plugin-sdk d.ts graph tree-shakes the registry declaration
- approval access authority follows the operator.approvals scope tier with
reviewerDeviceIds as the opt-in restriction (cross-surface
first-answer-wins; requester identity gates only legacy adapters)
- plugin node.invoke approvals register directly so unrenderable
presentations fail closed before request routing
- exec-approval manager reconciliation with #103515 revocation hardening
(resolution source attribution, one-shot ask-fallback consumption)
- surface-report pins and plugin-sdk API baseline refreshed; Swift models
regenerated
* feat(channels): add typed operator approval actions
Squash-rebased #103679 segment onto the durable-approval-registry tip on
current main. Typed approval/command/select presentation actions replace
raw-string inference across slack/telegram/discord/matrix/imessage/whatsapp,
approval.resolve carries an explicit kind, and channel adapters map native
callback envelopes through the typed action registry.
Drift reconciliation: deprecated buildExecApprovalInteractiveReply assertions
dropped (#104650 removed the shims); worker_environments bootstrap-column
migration kept alongside the approval resolution_ref backfill; plugin-sdk API
baseline regenerated.
(cherry picked from commit 68765a5d39d2118c88a7a54d00387337912d4494)
(cherry picked from commit 8642ac12af142e4b751f4f30d4b114615e7e5f66)
(cherry picked from commit 036c4bc39499925fc03de16ec9302e346769350a)
(cherry picked from commit 19dc350d6bc34e29a5169c6bc80971b0ad12adde)
(cherry picked from commit fc978b0bad86aef421c79f6a211b25cc1b743c01)
(cherry picked from commit 10de4d1ed5071f9be6ad1ee5d1e32c0fa8c9d11c)
(cherry picked from commit 9a664ced1b1fa740172b258f355f1a82925ae41c)
(cherry picked from commit c5ff69abbf444139e9e007bfa45beb0f00ffea54)
(cherry picked from commit d466a80795)
(cherry picked from commit f5b4fe40dd5c961322f8553cc80b2fdfb3f6503e)
(cherry picked from commit 7340b4749a4cc4c72f7a41cce1bc9cb550cae038)
(cherry picked from commit a151f41808f23ae60b10305ccd2bc959b9169a86)
* fix(approvals): preserve typed transport ownership
* test(imessage): narrow chunked approval text
* refactor(protocol): remove retired type tombstone
* fix(plugin-sdk): align surface budgets after rebase
* docs(changelog): note typed operator approvals
* docs(changelog): defer typed approval release note
706 lines
22 KiB
TypeScript
706 lines
22 KiB
TypeScript
// Tests execution approval reply text and decision formatting.
|
|
import { describe, expect, it, vi } from "vitest";
|
|
import type { ReplyPayload } from "../auto-reply/types.js";
|
|
|
|
vi.mock("./exec-approval-surface.js", () => ({
|
|
describeNativeExecApprovalClientSetup: vi.fn(
|
|
(params: {
|
|
channel?: string | null;
|
|
channelLabel?: string | null;
|
|
accountId?: string | null;
|
|
}) => {
|
|
const channel = (params.channel ?? "").trim().toLowerCase();
|
|
const label = params.channelLabel ?? channel;
|
|
const accountId = params.accountId?.trim();
|
|
const accountPrefix =
|
|
accountId && accountId !== "default"
|
|
? `channels.${channel}.accounts.${accountId}`
|
|
: `channels.${channel}`;
|
|
if (channel === "matrix") {
|
|
return `Approve it from the Web UI or terminal UI for now. ${label} supports native exec approvals for this account. Configure \`${accountPrefix}.execApprovals.approvers\` or \`${accountPrefix}.dm.allowFrom\`; leave \`${accountPrefix}.execApprovals.enabled\` unset/\`auto\` or set it to \`true\`.`;
|
|
}
|
|
if (channel === "discord") {
|
|
return `Approve it from the Web UI or terminal UI for now. ${label} supports native exec approvals for this account. Configure \`${accountPrefix}.execApprovals.approvers\` or \`commands.ownerAllowFrom\`; leave \`${accountPrefix}.execApprovals.enabled\` unset/\`auto\` or set it to \`true\`.`;
|
|
}
|
|
if (channel === "slack") {
|
|
return `Approve it from the Web UI or terminal UI for now. ${label} supports native exec approvals for this account. Configure \`${accountPrefix}.execApprovals.approvers\` or \`commands.ownerAllowFrom\`; leave \`${accountPrefix}.execApprovals.enabled\` unset/\`auto\` or set it to \`true\`.`;
|
|
}
|
|
if (channel === "telegram") {
|
|
return `Approve it from the Web UI or terminal UI for now. ${label} supports native exec approvals for this account. Configure \`${accountPrefix}.execApprovals.approvers\`; if you leave it unset, OpenClaw can infer numeric owner IDs from \`${accountPrefix}.allowFrom\` or direct-message \`${accountPrefix}.defaultTo\` when possible. Leave \`${accountPrefix}.execApprovals.enabled\` unset/\`auto\` or set it to \`true\`.`;
|
|
}
|
|
return null;
|
|
},
|
|
),
|
|
listNativeExecApprovalClientLabels: vi.fn(() => ["Discord", "Matrix", "Slack", "Telegram"]),
|
|
supportsNativeExecApprovalClient: vi.fn((channel?: string | null) =>
|
|
["discord", "matrix", "slack", "telegram"].includes((channel ?? "").trim().toLowerCase()),
|
|
),
|
|
}));
|
|
|
|
import {
|
|
buildApprovalPresentation,
|
|
buildApprovalPresentationFromActionDescriptors,
|
|
buildExecApprovalActionDescriptors,
|
|
buildExecApprovalCommandText,
|
|
buildExecApprovalPendingReplyPayload,
|
|
buildExecApprovalUnavailableReplyPayload,
|
|
buildTypedApprovalActionDescriptors,
|
|
buildTypedApprovalPresentation,
|
|
buildTypedExecApprovalPendingReplyPayload,
|
|
getExecApprovalApproverDmNoticeText,
|
|
getExecApprovalReplyMetadata,
|
|
parseExecApprovalCommandText,
|
|
} from "./exec-approval-reply.js";
|
|
|
|
describe("exec approval reply helpers", () => {
|
|
const invalidReplyMetadataCases = [
|
|
{ name: "empty object", payload: {} },
|
|
{ name: "null channelData", payload: { channelData: null } },
|
|
{ name: "array channelData", payload: { channelData: [] } },
|
|
{ name: "null execApproval", payload: { channelData: { execApproval: null } } },
|
|
{ name: "array execApproval", payload: { channelData: { execApproval: [] } } },
|
|
{
|
|
name: "blank approval slug",
|
|
payload: { channelData: { execApproval: { approvalId: "req-1", approvalSlug: " " } } },
|
|
},
|
|
{
|
|
name: "blank approval id",
|
|
payload: { channelData: { execApproval: { approvalId: " ", approvalSlug: "slug-1" } } },
|
|
},
|
|
] as const;
|
|
|
|
const unavailableReasonCases = [
|
|
{
|
|
reason: "initiating-platform-disabled" as const,
|
|
channelLabel: "Slack",
|
|
expected:
|
|
"Exec approval is required, but native chat exec approvals are not configured on Slack.",
|
|
},
|
|
{
|
|
reason: "initiating-platform-unsupported" as const,
|
|
channelLabel: undefined,
|
|
expected:
|
|
"Exec approval is required, but this platform does not support chat exec approvals.",
|
|
},
|
|
{
|
|
reason: "no-approval-route" as const,
|
|
channelLabel: undefined,
|
|
expected:
|
|
"Exec approval is required, but no interactive approval client is currently available.",
|
|
},
|
|
] as const;
|
|
|
|
it("returns the approver DM notice text", () => {
|
|
expect(getExecApprovalApproverDmNoticeText()).toBe(
|
|
"Approval required. I sent approval DMs to the approvers for this account.",
|
|
);
|
|
});
|
|
|
|
it("mentions Matrix in the fallback native approval guidance", () => {
|
|
const text = buildExecApprovalUnavailableReplyPayload({
|
|
reason: "no-approval-route",
|
|
}).text;
|
|
expect(text).toContain("native chat approval client such as");
|
|
expect(text).toContain("Discord");
|
|
expect(text).toContain("Matrix");
|
|
expect(text).toContain("Slack");
|
|
expect(text).toContain("Telegram");
|
|
});
|
|
|
|
it("avoids repeating allowFrom guidance in the no-route fallback", () => {
|
|
const text = buildExecApprovalUnavailableReplyPayload({
|
|
reason: "no-approval-route",
|
|
}).text;
|
|
|
|
expect(text).not.toContain(
|
|
"Then retry the command. If those accounts already know your owner ID via allowFrom or owner config",
|
|
);
|
|
expect(text).toContain(
|
|
"You can usually leave execApprovals.approvers unset when owner config already identifies the approvers.",
|
|
);
|
|
});
|
|
|
|
it("distinguishes node approval-inbox access from policy inspection", () => {
|
|
const text = buildExecApprovalUnavailableReplyPayload({
|
|
reason: "no-approval-route",
|
|
host: "node",
|
|
nodeId: "mac-1",
|
|
}).text;
|
|
|
|
expect(text).toContain(
|
|
"Print the Control UI URL with `openclaw dashboard --no-open`, open it in a browser, then use the approval inbox.",
|
|
);
|
|
expect(text).toContain(
|
|
"Inspect the node's effective exec policy with `openclaw approvals get --node mac-1`.",
|
|
);
|
|
expect(text).not.toContain("`openclaw dashboard --no-open` or `openclaw approvals get");
|
|
expect(text).not.toContain("Open the approval inbox with");
|
|
expect(text).not.toContain("exec-approvals list");
|
|
});
|
|
|
|
it("explains how to enable Matrix native approvals when Matrix is the initiating platform", () => {
|
|
const text = buildExecApprovalUnavailableReplyPayload({
|
|
reason: "initiating-platform-disabled",
|
|
channel: "matrix",
|
|
channelLabel: "Matrix",
|
|
}).text;
|
|
|
|
expect(text).toContain("native chat exec approvals are not configured on Matrix");
|
|
expect(text).toContain("Matrix supports native exec approvals for this account");
|
|
expect(text).toContain("`channels.matrix.execApprovals.approvers`");
|
|
expect(text).toContain("`channels.matrix.dm.allowFrom`");
|
|
});
|
|
|
|
it.each([
|
|
{
|
|
channel: "discord",
|
|
channelLabel: "Discord",
|
|
expected: "`commands.ownerAllowFrom`",
|
|
unexpected: "`channels.discord.dm.allowFrom`",
|
|
},
|
|
{
|
|
channel: "slack",
|
|
channelLabel: "Slack",
|
|
expected: "`commands.ownerAllowFrom`",
|
|
unexpected: "`channels.slack.dm.allowFrom`",
|
|
},
|
|
{
|
|
channel: "telegram",
|
|
channelLabel: "Telegram",
|
|
expected: "`channels.telegram.allowFrom`",
|
|
unexpected: "`channels.telegram.dm.allowFrom`",
|
|
},
|
|
])(
|
|
"uses channel-specific disabled setup guidance for $channelLabel",
|
|
({ channel, channelLabel, expected, unexpected }) => {
|
|
const text = buildExecApprovalUnavailableReplyPayload({
|
|
reason: "initiating-platform-disabled",
|
|
channel,
|
|
channelLabel,
|
|
}).text;
|
|
|
|
expect(text).toContain(expected);
|
|
expect(text).not.toContain(unexpected);
|
|
},
|
|
);
|
|
|
|
it.each([
|
|
{
|
|
channel: "discord",
|
|
channelLabel: "Discord",
|
|
accountId: "work",
|
|
expected: "`channels.discord.accounts.work.execApprovals.approvers`",
|
|
unexpected: "`channels.discord.execApprovals.approvers`",
|
|
},
|
|
{
|
|
channel: "slack",
|
|
channelLabel: "Slack",
|
|
accountId: "work",
|
|
expected: "`channels.slack.accounts.work.execApprovals.approvers`",
|
|
unexpected: "`channels.slack.execApprovals.approvers`",
|
|
},
|
|
{
|
|
channel: "telegram",
|
|
channelLabel: "Telegram",
|
|
accountId: "work",
|
|
expected: "`channels.telegram.accounts.work.allowFrom`",
|
|
unexpected: "`channels.telegram.allowFrom`",
|
|
},
|
|
{
|
|
channel: "matrix",
|
|
channelLabel: "Matrix",
|
|
accountId: "work",
|
|
expected: "`channels.matrix.accounts.work.dm.allowFrom`",
|
|
unexpected: "`channels.matrix.dm.allowFrom`",
|
|
},
|
|
])(
|
|
"uses account-scoped disabled setup guidance for $channelLabel named account",
|
|
({ channel, channelLabel, accountId, expected, unexpected }) => {
|
|
const text = buildExecApprovalUnavailableReplyPayload({
|
|
reason: "initiating-platform-disabled",
|
|
channel,
|
|
channelLabel,
|
|
accountId,
|
|
}).text;
|
|
|
|
expect(text).toContain(expected);
|
|
expect(text).not.toContain(unexpected);
|
|
},
|
|
);
|
|
|
|
it.each(invalidReplyMetadataCases)(
|
|
"returns null for invalid reply metadata payload: $name",
|
|
({ payload }) => {
|
|
expect(getExecApprovalReplyMetadata(payload as ReplyPayload)).toBeNull();
|
|
},
|
|
);
|
|
|
|
it("normalizes reply metadata and filters invalid decisions", () => {
|
|
expect(
|
|
getExecApprovalReplyMetadata({
|
|
channelData: {
|
|
execApproval: {
|
|
approvalId: " req-1 ",
|
|
approvalSlug: " slug-1 ",
|
|
agentId: " agent-1 ",
|
|
allowedDecisions: ["allow-once", "bad", "deny", "allow-always", 3],
|
|
sessionKey: " session-1 ",
|
|
},
|
|
},
|
|
}),
|
|
).toEqual({
|
|
approvalId: "req-1",
|
|
approvalSlug: "slug-1",
|
|
approvalKind: "exec",
|
|
agentId: "agent-1",
|
|
allowedDecisions: ["allow-once", "deny", "allow-always"],
|
|
sessionKey: "session-1",
|
|
});
|
|
});
|
|
|
|
it("builds pending reply payloads with trimmed warning text and slug fallback", () => {
|
|
const payload = buildTypedExecApprovalPendingReplyPayload({
|
|
warningText: " Heads up. ",
|
|
approvalId: "req-1",
|
|
approvalSlug: "slug-1",
|
|
command: "echo ok",
|
|
cwd: "/tmp/work",
|
|
host: "gateway",
|
|
nodeId: "node-1",
|
|
expiresAtMs: 2500,
|
|
nowMs: 1000,
|
|
});
|
|
|
|
expect(payload.channelData).toEqual({
|
|
execApproval: {
|
|
approvalId: "req-1",
|
|
approvalSlug: "slug-1",
|
|
approvalKind: "exec",
|
|
agentId: undefined,
|
|
allowedDecisions: ["allow-once", "allow-always", "deny"],
|
|
sessionKey: undefined,
|
|
},
|
|
});
|
|
expect(payload.presentation).toEqual({
|
|
blocks: [
|
|
{
|
|
type: "buttons",
|
|
buttons: [
|
|
{
|
|
label: "Allow Once",
|
|
action: {
|
|
type: "approval",
|
|
approvalId: "req-1",
|
|
approvalKind: "exec",
|
|
decision: "allow-once",
|
|
},
|
|
style: "success",
|
|
},
|
|
{
|
|
label: "Allow Always",
|
|
action: {
|
|
type: "approval",
|
|
approvalId: "req-1",
|
|
approvalKind: "exec",
|
|
decision: "allow-always",
|
|
},
|
|
style: "primary",
|
|
},
|
|
{
|
|
label: "Deny",
|
|
action: {
|
|
type: "approval",
|
|
approvalId: "req-1",
|
|
approvalKind: "exec",
|
|
decision: "deny",
|
|
},
|
|
style: "danger",
|
|
},
|
|
],
|
|
},
|
|
],
|
|
});
|
|
expect(payload.interactive).toBeUndefined();
|
|
expect(payload.text).toContain("Heads up.");
|
|
expect(payload.text).toContain("```txt\n/approve slug-1 allow-once\n```");
|
|
expect(payload.text).toContain("```sh\necho ok\n```");
|
|
expect(payload.text).toContain("Host: gateway\nNode: node-1\nCWD: /tmp/work\nExpires in: 2s");
|
|
expect(payload.text).toContain("Full id: `req-1`");
|
|
});
|
|
|
|
it("preserves shipped command/value controls in the legacy pending builder", () => {
|
|
const payload = buildExecApprovalPendingReplyPayload({
|
|
approvalId: "req-legacy",
|
|
approvalSlug: "legacy",
|
|
allowedDecisions: ["deny"],
|
|
command: "echo legacy",
|
|
host: "gateway",
|
|
});
|
|
|
|
expect(payload.presentation).toEqual({
|
|
blocks: [
|
|
{
|
|
type: "buttons",
|
|
buttons: [
|
|
{
|
|
label: "Deny",
|
|
action: { type: "command", command: "/approve req-legacy deny" },
|
|
value: "/approve req-legacy deny",
|
|
style: "danger",
|
|
},
|
|
],
|
|
},
|
|
],
|
|
});
|
|
});
|
|
|
|
it("compacts structured cwd paths in pending reply payloads", () => {
|
|
const payload = buildExecApprovalPendingReplyPayload({
|
|
approvalId: "req-home",
|
|
approvalSlug: "slug-home",
|
|
command: "pwd",
|
|
cwd: "C:\\Users\\alice\\project",
|
|
host: "gateway",
|
|
});
|
|
|
|
expect(payload.text).toContain("CWD: ~/project");
|
|
expect(payload.text).not.toContain("C:\\Users\\alice");
|
|
});
|
|
|
|
it("omits allow-always actions when the effective policy requires approval every time", () => {
|
|
const payload = buildTypedExecApprovalPendingReplyPayload({
|
|
approvalId: "req-ask-always",
|
|
approvalSlug: "slug-always",
|
|
ask: "always",
|
|
command: "echo ok",
|
|
host: "gateway",
|
|
});
|
|
|
|
expect(payload.channelData).toEqual({
|
|
execApproval: {
|
|
approvalId: "req-ask-always",
|
|
approvalSlug: "slug-always",
|
|
approvalKind: "exec",
|
|
allowedDecisions: ["allow-once", "deny"],
|
|
},
|
|
});
|
|
expect(payload.text).toContain("```txt\n/approve slug-always allow-once\n```");
|
|
expect(payload.text).not.toContain("allow-always");
|
|
expect(payload.text).toContain("Allow Always is unavailable for this command.");
|
|
expect(payload.presentation).toEqual({
|
|
blocks: [
|
|
{
|
|
type: "buttons",
|
|
buttons: [
|
|
{
|
|
label: "Allow Once",
|
|
action: {
|
|
type: "approval",
|
|
approvalId: "req-ask-always",
|
|
approvalKind: "exec",
|
|
decision: "allow-once",
|
|
},
|
|
style: "success",
|
|
},
|
|
{
|
|
label: "Deny",
|
|
action: {
|
|
type: "approval",
|
|
approvalId: "req-ask-always",
|
|
approvalKind: "exec",
|
|
decision: "deny",
|
|
},
|
|
style: "danger",
|
|
},
|
|
],
|
|
},
|
|
],
|
|
});
|
|
expect(payload.interactive).toBeUndefined();
|
|
});
|
|
|
|
it("stores agent and session metadata for downstream suppression checks", () => {
|
|
const payload = buildExecApprovalPendingReplyPayload({
|
|
approvalId: "req-meta",
|
|
approvalSlug: "slug-meta",
|
|
agentId: "ops-agent",
|
|
sessionKey: "agent:ops-agent:matrix:channel:!room:example.org",
|
|
command: "echo ok",
|
|
host: "gateway",
|
|
});
|
|
|
|
expect(payload.channelData).toEqual({
|
|
execApproval: {
|
|
approvalId: "req-meta",
|
|
approvalSlug: "slug-meta",
|
|
approvalKind: "exec",
|
|
agentId: "ops-agent",
|
|
allowedDecisions: ["allow-once", "allow-always", "deny"],
|
|
sessionKey: "agent:ops-agent:matrix:channel:!room:example.org",
|
|
},
|
|
});
|
|
});
|
|
|
|
it("uses a longer fence for commands containing triple backticks", () => {
|
|
const payload = buildExecApprovalPendingReplyPayload({
|
|
approvalId: "req-2",
|
|
approvalSlug: "slug-2",
|
|
approvalCommandId: " req-cmd-2 ",
|
|
command: "echo ```danger```",
|
|
host: "sandbox",
|
|
});
|
|
|
|
expect(payload.text).toContain("```txt\n/approve req-cmd-2 allow-once\n```");
|
|
expect(payload.text).toContain("````sh\necho ```danger```\n````");
|
|
expect(payload.text).not.toContain("Expires in:");
|
|
});
|
|
|
|
it("clamps pending reply expiration to zero seconds", () => {
|
|
const payload = buildExecApprovalPendingReplyPayload({
|
|
approvalId: "req-3",
|
|
approvalSlug: "slug-3",
|
|
command: "echo later",
|
|
host: "gateway",
|
|
expiresAtMs: 1000,
|
|
nowMs: 3000,
|
|
});
|
|
|
|
expect(payload.text).toContain("Expires in: 0s");
|
|
});
|
|
|
|
it("formats longer approval windows in minutes", () => {
|
|
const payload = buildExecApprovalPendingReplyPayload({
|
|
approvalId: "req-30m",
|
|
approvalSlug: "slug-30m",
|
|
command: "echo later",
|
|
host: "gateway",
|
|
expiresAtMs: 1_801_000,
|
|
nowMs: 1_000,
|
|
});
|
|
|
|
expect(payload.text).toContain("Expires in: 30m");
|
|
});
|
|
|
|
it("builds shared exec approval action descriptors and interactive replies", () => {
|
|
expect(
|
|
buildExecApprovalActionDescriptors({
|
|
approvalCommandId: "req-1",
|
|
}),
|
|
).toEqual([
|
|
{
|
|
decision: "allow-once",
|
|
label: "Allow Once",
|
|
style: "success",
|
|
command: "/approve req-1 allow-once",
|
|
},
|
|
{
|
|
decision: "allow-always",
|
|
label: "Allow Always",
|
|
style: "primary",
|
|
command: "/approve req-1 allow-always",
|
|
},
|
|
{
|
|
decision: "deny",
|
|
label: "Deny",
|
|
style: "danger",
|
|
command: "/approve req-1 deny",
|
|
},
|
|
]);
|
|
|
|
expect(
|
|
buildApprovalPresentation({
|
|
approvalId: "req-1",
|
|
allowedDecisions: ["deny"],
|
|
}),
|
|
).toEqual({
|
|
blocks: [
|
|
{
|
|
type: "buttons",
|
|
buttons: [
|
|
{
|
|
label: "Deny",
|
|
action: { type: "command", command: "/approve req-1 deny" },
|
|
value: "/approve req-1 deny",
|
|
style: "danger",
|
|
},
|
|
],
|
|
},
|
|
],
|
|
});
|
|
|
|
expect(
|
|
buildApprovalPresentationFromActionDescriptors([
|
|
{
|
|
decision: "deny",
|
|
label: "Deny",
|
|
style: "danger",
|
|
command: "/approve legacy-id deny",
|
|
},
|
|
]),
|
|
).toEqual({
|
|
blocks: [
|
|
{
|
|
type: "buttons",
|
|
buttons: [
|
|
{
|
|
label: "Deny",
|
|
action: { type: "command", command: "/approve legacy-id deny" },
|
|
value: "/approve legacy-id deny",
|
|
style: "danger",
|
|
},
|
|
],
|
|
},
|
|
],
|
|
});
|
|
});
|
|
|
|
it("builds typed descriptors and presentations only through named typed builders", () => {
|
|
expect(
|
|
buildTypedApprovalActionDescriptors({
|
|
approvalCommandId: "opaque-id",
|
|
approvalKind: "plugin",
|
|
allowedDecisions: ["deny"],
|
|
}),
|
|
).toEqual([
|
|
{
|
|
decision: "deny",
|
|
label: "Deny",
|
|
style: "danger",
|
|
action: {
|
|
type: "approval",
|
|
approvalId: "opaque-id",
|
|
approvalKind: "plugin",
|
|
decision: "deny",
|
|
},
|
|
command: "/approve opaque-id deny",
|
|
},
|
|
]);
|
|
|
|
expect(
|
|
buildTypedApprovalPresentation({
|
|
approvalId: "opaque-id",
|
|
approvalKind: "plugin",
|
|
allowedDecisions: ["deny"],
|
|
}),
|
|
).toEqual({
|
|
blocks: [
|
|
{
|
|
type: "buttons",
|
|
buttons: [
|
|
{
|
|
label: "Deny",
|
|
action: {
|
|
type: "approval",
|
|
approvalId: "opaque-id",
|
|
approvalKind: "plugin",
|
|
decision: "deny",
|
|
},
|
|
style: "danger",
|
|
},
|
|
],
|
|
},
|
|
],
|
|
});
|
|
});
|
|
|
|
it.each([".", "..", "\uD800", "\uDC00", "broken-\uD800"])(
|
|
"refuses malformed typed approval identity %j",
|
|
(approvalId) => {
|
|
expect(
|
|
buildTypedApprovalActionDescriptors({
|
|
approvalCommandId: approvalId,
|
|
approvalKind: "exec",
|
|
allowedDecisions: ["deny"],
|
|
}),
|
|
).toEqual([]);
|
|
expect(
|
|
buildTypedApprovalPresentation({
|
|
approvalId,
|
|
approvalKind: "exec",
|
|
allowedDecisions: ["deny"],
|
|
}),
|
|
).toBeUndefined();
|
|
expect(
|
|
buildTypedExecApprovalPendingReplyPayload({
|
|
approvalId,
|
|
approvalSlug: "safe-slug",
|
|
allowedDecisions: ["deny"],
|
|
command: "echo safe",
|
|
host: "gateway",
|
|
}).presentation,
|
|
).toBeUndefined();
|
|
},
|
|
);
|
|
|
|
it("preserves protocol-valid boundary whitespace in typed approval actions", () => {
|
|
const approvalId = "\uFEFF";
|
|
|
|
expect(
|
|
buildTypedApprovalActionDescriptors({
|
|
approvalCommandId: approvalId,
|
|
approvalKind: "exec",
|
|
allowedDecisions: ["deny"],
|
|
}),
|
|
).toMatchObject([
|
|
{
|
|
action: { type: "approval", approvalId, approvalKind: "exec", decision: "deny" },
|
|
},
|
|
]);
|
|
});
|
|
|
|
it("builds and parses shared exec approval command text", () => {
|
|
expect(
|
|
buildExecApprovalCommandText({
|
|
approvalCommandId: "req-1",
|
|
decision: "allow-always",
|
|
}),
|
|
).toBe("/approve req-1 allow-always");
|
|
|
|
expect(parseExecApprovalCommandText("/approve req-1 deny")).toEqual({
|
|
approvalId: "req-1",
|
|
decision: "deny",
|
|
});
|
|
expect(parseExecApprovalCommandText("approve req-1 allow-once")).toEqual({
|
|
approvalId: "req-1",
|
|
decision: "allow-once",
|
|
});
|
|
expect(parseExecApprovalCommandText("/approve@clover req-1 allow-once")).toEqual({
|
|
approvalId: "req-1",
|
|
decision: "allow-once",
|
|
});
|
|
expect(parseExecApprovalCommandText(" /approve req-1 always")).toEqual({
|
|
approvalId: "req-1",
|
|
decision: "allow-always",
|
|
});
|
|
expect(parseExecApprovalCommandText("/approve req-1 allow-always")).toEqual({
|
|
approvalId: "req-1",
|
|
decision: "allow-always",
|
|
});
|
|
expect(parseExecApprovalCommandText("/approve req-1 maybe")).toBeNull();
|
|
});
|
|
|
|
it("builds unavailable payloads for approver DMs", () => {
|
|
expect(
|
|
buildExecApprovalUnavailableReplyPayload({
|
|
warningText: " Careful. ",
|
|
reason: "no-approval-route",
|
|
sentApproverDms: true,
|
|
}),
|
|
).toEqual({
|
|
text: "Careful.\n\nApproval required. I sent approval DMs to the approvers for this account.",
|
|
});
|
|
});
|
|
|
|
it.each(unavailableReasonCases)(
|
|
"builds unavailable payload for reason $reason",
|
|
({ reason, channelLabel, expected }) => {
|
|
expect(
|
|
buildExecApprovalUnavailableReplyPayload({
|
|
reason,
|
|
channelLabel,
|
|
}).text,
|
|
).toContain(expected);
|
|
},
|
|
);
|
|
});
|