Files
openclaw/extensions/slack/src/send.upload.test.ts
Heming Zeng 2555e9fa4e fix: stop retry storms after definitive channel rejections (#127353)
* fix(outbound): terminalize definitive channel rejections

* refactor(outbound): rethrow unclassified Slack rejections by identity

The Slack send boundary replaced every non-Error rejection with a synthetic
Error before classifying. That changed the propagated value at all four send
call sites, contradicting the stated contract that unlisted rejections keep
their previous path, and forced a compensating one-level `cause` walk in
`isSlackInvalidBlocksError` so the downstream `invalid_blocks` fallback could
still match. The guard's second operand was also dead: `isRecord` accepts Error
instances, so `!(err instanceof Error) || !isRecord(err)` never reached its
right side for a plain object.

Classify off the raw value and rethrow unclassified rejections by identity;
the `cause`-walking compensator and its test go away with it. Distill the
Telegram migration classifier's three-state result object into a
message-or-nothing, and document the description-first and 52-bit id contracts
inline.

Production surface for the PR drops from +71/-11 to +62/-8.

* test(agents): remove clock-tick race from workspace bootstrap ctime coverage

The in-place-edit case added in #127769 assumes restoring mtime leaves ctime as
the only changed stat field, and that it therefore differs. Linux and macOS
stamp ctime from a coarse per-tick clock, so an edit landing in the same tick as
the cached stat leaves ctimeMs equal: the ctime-only scenario never occurs, the
cache correctly serves its entry, and the assertion fails. Measured 199/200
identical ctimes in a tight loop; the test failed 1 in 8 local runs and broke
checks-node-compact-large-14 on CI.

Re-touch until the kernel advances ctimeMs, then assert mtimeMs and size are
unchanged so the scenario is provably ctime-only. Stripping ctimeMs from the
cache identity still fails the test.

* fix(slack): keep post-dispatch upload completion rejections ambiguous

PlatformMessageNotDispatchedError is a provider assertion that no
recipient-visible send began, and its contract says never use it after an
ambiguous send. files.completeUploadExternal runs after onPlatformSendDispatch
and is the one-time share operation, so a rejection there cannot prove the file
was never shared however definitive its code reads.

Drop the permanent-rejection classifier from that call and keep it on the
pre-dispatch calls only (chat.postMessage, files.getUploadURLExternal,
resolveChannelId). The upload test that pinned the old behavior asserted
onPlatformSendDispatch had already fired, which is exactly the condition that
forbids the claim; it now pins ambiguity instead.

Also widens the workspace bootstrap ctime wait to a 1s deadline and reshapes it
as a while loop, so a coarser filesystem tick cannot exhaust the bound.

* test(slack): prove permanent rejection recovery

* fix(test): stabilize Slack channel action routing

* fix(test): retain channel parity for precise targets

* refactor(outbound): drop unrelated test-routing changes

* fix(telegram): require Bot API error code for migration rejection

* test: repair Telegram tuples and preserve Slack test routing

---------

Co-authored-by: Ayaan Zaidi <hi@obviy.us>
Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-08-26 13:16:48 -07:00

1105 lines
39 KiB
TypeScript

import type { WebClient } from "@slack/web-api";
import {
formatErrorMessage,
PlatformMessageNotDispatchedError,
} from "openclaw/plugin-sdk/error-runtime";
import type { LookupFn } from "openclaw/plugin-sdk/ssrf-runtime";
import { withServer } from "openclaw/plugin-sdk/test-env";
import type { WebMediaResult } from "openclaw/plugin-sdk/web-media";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import "./blocks.test-helpers.js";
import {
clearSlackThreadParticipationCache,
hasSlackThreadParticipation,
} from "./sent-thread-cache.js";
// --- Module mocks (must precede dynamic import) ---
const loadOutboundMediaFromUrlMock = vi.hoisted(() =>
vi.fn(
async (_mediaUrl: string, _options?: unknown): Promise<WebMediaResult> => ({
buffer: Buffer.from("fake-image"),
contentType: "image/png",
kind: "image",
fileName: "screenshot.png",
}),
),
);
const cleanupUploadTimeout = vi.hoisted(() => vi.fn());
const uploadTimeoutControllers = vi.hoisted(() => [] as AbortController[]);
const buildTimeoutAbortSignal = vi.hoisted(() =>
vi.fn((params: { timeoutMs?: number }) => {
if (!Number.isFinite(params.timeoutMs) || (params.timeoutMs ?? 0) <= 0) {
throw new Error("Slack upload timeout requires a finite budget");
}
const controller = new AbortController();
uploadTimeoutControllers.push(controller);
return {
signal: controller.signal,
cleanup: () => {
const index = uploadTimeoutControllers.indexOf(controller);
if (index >= 0) {
uploadTimeoutControllers.splice(index, 1);
}
cleanupUploadTimeout();
},
refresh: () => {},
};
}),
);
const fetchWithSsrFGuard = vi.fn(
async (
params: Parameters<typeof import("openclaw/plugin-sdk/ssrf-runtime").fetchWithSsrFGuard>[0],
) => {
const signal = params.signal;
if (!signal) {
throw new Error("guarded Slack upload fetch requires a finite timeout signal");
}
return {
response: await fetch(params.url, {
...params.init,
signal,
}),
finalUrl: params.url,
release: async () => {},
} as const;
},
);
vi.mock("openclaw/plugin-sdk/ssrf-runtime", async () => {
const actual = await vi.importActual<typeof import("openclaw/plugin-sdk/ssrf-runtime")>(
"openclaw/plugin-sdk/ssrf-runtime",
);
return {
...actual,
fetchWithSsrFGuard: (...args: unknown[]) =>
fetchWithSsrFGuard(...(args as [params: Parameters<typeof actual.fetchWithSsrFGuard>[0]])),
};
});
vi.mock("openclaw/plugin-sdk/extension-shared", async () => {
const actual = await vi.importActual<typeof import("openclaw/plugin-sdk/extension-shared")>(
"openclaw/plugin-sdk/extension-shared",
);
return {
...actual,
buildTimeoutAbortSignal: (...args: unknown[]) =>
buildTimeoutAbortSignal(...(args as [params: { timeoutMs?: number }])),
};
});
vi.mock("openclaw/plugin-sdk/fetch-runtime", async () => {
const actual = await vi.importActual<typeof import("openclaw/plugin-sdk/fetch-runtime")>(
"openclaw/plugin-sdk/fetch-runtime",
);
return {
...actual,
withTrustedEnvProxyGuardedFetchMode: (params: Record<string, unknown>) => ({
...params,
mode: "trusted_env_proxy",
}),
};
});
vi.mock("openclaw/plugin-sdk/outbound-media", async () => {
const actual = await vi.importActual<typeof import("openclaw/plugin-sdk/outbound-media")>(
"openclaw/plugin-sdk/outbound-media",
);
const mockedLoadOutboundMediaFromUrl =
loadOutboundMediaFromUrlMock as unknown as typeof actual.loadOutboundMediaFromUrl;
return {
...actual,
loadOutboundMediaFromUrl: (...args: Parameters<typeof actual.loadOutboundMediaFromUrl>) =>
mockedLoadOutboundMediaFromUrl(...args),
};
});
const { sendMessageSlack } = await import("./send.js");
const SLACK_TEST_CFG = { channels: { slack: { botToken: "xoxb-test" } } };
type UploadTestClient = WebClient & {
conversations: { open: ReturnType<typeof vi.fn<(...args: unknown[]) => Promise<unknown>>> };
chat: { postMessage: ReturnType<typeof vi.fn<(...args: unknown[]) => Promise<unknown>>> };
files: {
getUploadURLExternal: ReturnType<typeof vi.fn<(...args: unknown[]) => Promise<unknown>>>;
completeUploadExternal: ReturnType<typeof vi.fn<(...args: unknown[]) => Promise<unknown>>>;
};
};
type MockCalls = { mock: { calls: unknown[][] } };
function requireRecord(value: unknown, label: string): Record<string, unknown> {
const isObjectRecord = typeof value === "object" && value !== null && !Array.isArray(value);
expect(isObjectRecord, `${label} should be an object`).toBe(true);
if (!isObjectRecord) {
throw new Error(`${label} should be an object`);
}
return value as Record<string, unknown>;
}
function requireArray(value: unknown, label: string): unknown[] {
const values = Array.isArray(value) ? value : null;
expect(values, `${label} should be an array`).not.toBeNull();
if (!values) {
throw new Error(`${label} should be an array`);
}
return values;
}
function expectFields(record: Record<string, unknown>, expected: Record<string, unknown>) {
expect(record).toMatchObject(expected);
}
function expectCallFirstArg(
mock: MockCalls,
callNumber: number,
expected: Record<string, unknown>,
label = "mock first argument",
): Record<string, unknown> {
expect(mock.mock.calls.length).toBeGreaterThanOrEqual(callNumber);
const [firstArg] = mock.mock.calls[callNumber - 1] ?? [];
const record = requireRecord(firstArg, label);
expectFields(record, expected);
return record;
}
function expectOnlyCallFirstArg(
mock: MockCalls,
expected: Record<string, unknown>,
label?: string,
): Record<string, unknown> {
expect(mock.mock.calls).toHaveLength(1);
return expectCallFirstArg(mock, 1, expected, label);
}
function expectCompletedUpload(params: {
client: UploadTestClient;
expected: Record<string, unknown>;
file?: Record<string, unknown>;
}) {
const payload = expectOnlyCallFirstArg(
params.client.files.completeUploadExternal,
params.expected,
"complete upload payload",
);
if (params.file) {
const [file] = requireArray(payload.files, "complete upload files");
expectFields(requireRecord(file, "complete upload file"), params.file);
}
return payload;
}
function createUploadTestClient(slackApiUrl = "https://slack.com/api/"): UploadTestClient {
return {
slackApiUrl,
conversations: {
open: vi.fn<(...args: unknown[]) => Promise<unknown>>(async () => ({
channel: { id: "D99RESOLVED" },
})),
},
chat: {
postMessage: vi.fn<(...args: unknown[]) => Promise<unknown>>(async () => ({
ts: "171234.567",
})),
},
files: {
getUploadURLExternal: vi.fn<(...args: unknown[]) => Promise<unknown>>(async () => ({
ok: true,
upload_url: "https://files.slack.com/upload",
file_id: "F001",
})),
completeUploadExternal: vi.fn<(...args: unknown[]) => Promise<unknown>>(async () => ({
ok: true,
})),
},
} as unknown as UploadTestClient;
}
function slackPlatformError(code: string): Error {
return Object.assign(new Error(`An API error occurred: ${code}`), {
code: "slack_webapi_platform_error",
data: { ok: false, error: code },
});
}
type UploadOverrides = Omit<Partial<Parameters<typeof sendMessageSlack>[2]>, "cfg" | "client">;
type UploadParams = UploadOverrides & { mediaUrl: string; target?: string; message?: string };
function sendUpload(client: UploadTestClient, params: UploadParams) {
const { target = "channel:C123CHAN", message = "caption", ...options } = params;
return sendMessageSlack(target, message, {
token: "xoxb-test",
cfg: SLACK_TEST_CFG,
client,
...options,
});
}
function sendText(client: UploadTestClient, target: string, message: string) {
return sendMessageSlack(target, message, { token: "xoxb-test", cfg: SLACK_TEST_CFG, client });
}
function mockUploadDestination(client: UploadTestClient, uploadUrl: string) {
client.files.getUploadURLExternal.mockResolvedValueOnce({
ok: true,
upload_url: uploadUrl,
file_id: "F001",
});
}
async function useRealUploadGuard(networkFetch: typeof fetch, lookupAddress?: string) {
const actual = await vi.importActual<typeof import("openclaw/plugin-sdk/ssrf-runtime")>(
"openclaw/plugin-sdk/ssrf-runtime",
);
const lookupFn = lookupAddress
? ((async () => [{ address: lookupAddress, family: 4 }]) as unknown as LookupFn)
: undefined;
fetchWithSsrFGuard.mockImplementationOnce(async (params) =>
actual.fetchWithSsrFGuard({
...params,
fetchImpl: networkFetch,
...(lookupFn ? { lookupFn } : {}),
}),
);
}
describe("sendMessageSlack file upload with user IDs", () => {
const originalFetch = globalThis.fetch;
let client: UploadTestClient;
beforeEach(() => {
client = createUploadTestClient();
globalThis.fetch = vi.fn(
async () => new Response("ok", { status: 200 }),
) as unknown as typeof fetch;
fetchWithSsrFGuard.mockClear();
buildTimeoutAbortSignal.mockClear();
cleanupUploadTimeout.mockClear();
uploadTimeoutControllers.length = 0;
loadOutboundMediaFromUrlMock.mockClear();
clearSlackThreadParticipationCache();
});
afterEach(() => {
globalThis.fetch = originalFetch;
vi.unstubAllEnvs();
vi.restoreAllMocks();
});
it.each(["first", "batched"] as const)(
"records an accepted %s upload when its remaining caption post fails",
async (replyToMode) => {
const { handleSlackAction, slackActionRuntime } = await import("./action-runtime.js");
const { sendSlackMessage: sendSlackMessageThroughPublicOwner } = await import("./actions.js");
const originalSender = slackActionRuntime.sendSlackMessage;
const hasRepliedRef = { value: false };
client.chat.postMessage.mockRejectedValueOnce(new Error("Remaining Slack caption failed"));
slackActionRuntime.sendSlackMessage = async (target, content, options) =>
await sendSlackMessageThroughPublicOwner(target, content, { ...options, client });
try {
await expect(
handleSlackAction(
{
action: "uploadFile",
to: "channel:C123CHAN",
filePath: "/tmp/report.txt",
initialComment: "a".repeat(8500),
},
SLACK_TEST_CFG,
{
currentChannelId: "C123CHAN",
currentThreadTs: "1111111111.111111",
replyToMode,
hasRepliedRef,
},
),
).rejects.toThrow("Remaining Slack caption failed");
expect(client.files.completeUploadExternal).toHaveBeenCalledOnce();
expect(client.chat.postMessage).toHaveBeenCalledOnce();
expect(hasRepliedRef.value).toBe(true);
} finally {
slackActionRuntime.sendSlackMessage = originalSender;
}
},
);
it("marks account_inactive from files.getUploadURLExternal as a permanent non-dispatch", async () => {
const rejection = slackPlatformError("account_inactive");
const onPlatformSendDispatch = vi.fn();
client.files.getUploadURLExternal.mockRejectedValueOnce(rejection);
const caught = await sendUpload(client, {
mediaUrl: "/tmp/account-inactive.png",
onPlatformSendDispatch,
}).catch((error: unknown) => error);
expect(caught).toBeInstanceOf(PlatformMessageNotDispatchedError);
expect(caught).toMatchObject({ retryable: false, cause: rejection });
expect(globalThis.fetch).not.toHaveBeenCalled();
expect(onPlatformSendDispatch).not.toHaveBeenCalled();
expect(client.files.completeUploadExternal).not.toHaveBeenCalled();
});
it("keeps a definitive completeUploadExternal rejection ambiguous", async () => {
// Dispatch is recorded before this call, so even a code that reads as a final
// verdict cannot prove the file was never shared; it must not become a
// non-dispatch assertion. Pairs with the pre-dispatch getUploadURLExternal case.
const rejection = slackPlatformError("messages_tab_disabled");
const onPlatformSendDispatch = vi.fn();
client.files.completeUploadExternal.mockRejectedValueOnce(rejection);
const caught = await sendUpload(client, {
mediaUrl: "/tmp/messages-tab-disabled.png",
onPlatformSendDispatch,
}).catch((error: unknown) => error);
expect(onPlatformSendDispatch).toHaveBeenCalledOnce();
expect(caught).toBe(rejection);
expect(caught).not.toBeInstanceOf(PlatformMessageNotDispatchedError);
});
it("keeps getUploadURLExternal network failures ambiguous", async () => {
const rejection = Object.assign(new Error("read ECONNRESET"), {
code: "slack_webapi_request_error",
});
client.files.getUploadURLExternal.mockRejectedValueOnce(rejection);
const caught = await sendUpload(client, {
mediaUrl: "/tmp/network-failure.png",
}).catch((error: unknown) => error);
expect(caught).toBe(rejection);
expect(caught).not.toBeInstanceOf(PlatformMessageNotDispatchedError);
});
it("keeps completeUploadExternal HTTP failures ambiguous", async () => {
const rejection = Object.assign(new Error("Slack HTTP 500"), {
code: "slack_webapi_http_error",
statusCode: 500,
});
client.files.completeUploadExternal.mockRejectedValueOnce(rejection);
const caught = await sendUpload(client, {
mediaUrl: "/tmp/http-failure.png",
}).catch((error: unknown) => error);
expect(caught).toBe(rejection);
expect(caught).not.toBeInstanceOf(PlatformMessageNotDispatchedError);
});
it("disables image optimization for forced-media uploads", async () => {
await sendUpload(client, {
mediaUrl: "/tmp/original.png",
forceDocument: true,
});
expect(loadOutboundMediaFromUrlMock).toHaveBeenCalledWith(
"/tmp/original.png",
expect.objectContaining({ optimizeImages: false }),
);
});
it.each([
["absent", undefined],
["false", false],
] as const)(
"keeps default image optimization when forced-media intent is %s",
async (_name, forceDocument) => {
await sendUpload(client, {
mediaUrl: "/tmp/optimized.png",
...(forceDocument !== undefined ? { forceDocument } : {}),
});
const loadOptions = loadOutboundMediaFromUrlMock.mock.calls[0]?.[1] as
| { optimizeImages?: boolean }
| undefined;
expect(loadOptions?.optimizeImages).toBeUndefined();
},
);
it.each([
{
name: "resolves bare user ID to DM channel before completing upload",
target: "U2ZH3MFSR",
message: "screenshot",
mediaUrl: "/tmp/screenshot.png",
userId: "U2ZH3MFSR",
file: { id: "F001", title: "screenshot.png" },
},
{
name: "resolves prefixed user ID to DM channel before completing upload",
target: "user:UABC123",
message: "image",
mediaUrl: "/tmp/photo.png",
userId: "UABC123",
},
{
name: "resolves mention-style user ID before file upload",
target: "<@U777TEST>",
message: "report",
mediaUrl: "/tmp/report.png",
userId: "U777TEST",
},
])("$name", async ({ target, message, mediaUrl, userId, file }) => {
await sendUpload(client, { target, message, mediaUrl });
expect(client.conversations.open).toHaveBeenCalledWith({ users: userId });
expectCompletedUpload({ client, expected: { channel_id: "D99RESOLVED" }, file });
});
it("posts text-only user-target DMs directly without conversations.open", async () => {
client.conversations.open.mockRejectedValueOnce(new Error("missing_scope"));
await sendText(client, "user:UABC123", "first");
await sendText(client, "user:UABC123", "second");
expect(client.conversations.open).not.toHaveBeenCalled();
expect(client.chat.postMessage).toHaveBeenCalledTimes(2);
expectCallFirstArg(client.chat.postMessage, 2, {
channel: "UABC123",
text: "second",
});
});
it("serializes concurrent sends to the same Slack target", async () => {
let resolveFirst: (() => void) | undefined;
client.chat.postMessage.mockImplementation(async (payload: unknown) => {
const text =
typeof payload === "object" && payload !== null && "text" in payload
? payload.text
: undefined;
if (text === "first") {
await new Promise<void>((resolve) => {
resolveFirst = resolve;
});
return { ts: "1.000" };
}
return { ts: "2.000" };
});
const first = sendText(client, "channel:C123CHAN", "first");
await vi.waitFor(() => expect(client.chat.postMessage).toHaveBeenCalledTimes(1));
const second = sendText(client, "channel:C123CHAN", "second");
await Promise.resolve();
expect(client.chat.postMessage).toHaveBeenCalledTimes(1);
if (!resolveFirst) {
throw new Error("Expected first Slack send release callback to be initialized");
}
resolveFirst();
const firstResult = await first;
expectFields(requireRecord(firstResult, "first send result"), {
channelId: "C123CHAN",
messageId: "1.000",
});
expectFields(requireRecord(firstResult.receipt, "first receipt"), {
primaryPlatformMessageId: "1.000",
platformMessageIds: ["1.000"],
});
const secondResult = await second;
expectFields(requireRecord(secondResult, "second send result"), {
channelId: "C123CHAN",
messageId: "2.000",
});
expectFields(requireRecord(secondResult.receipt, "second receipt"), {
primaryPlatformMessageId: "2.000",
platformMessageIds: ["2.000"],
});
expectCallFirstArg(client.chat.postMessage, 2, { text: "second" });
});
it("scopes DM channel resolution cache by token identity", async () => {
await sendUpload(client, {
target: "user:UABC123",
message: "first",
token: "xoxb-test-a",
mediaUrl: "/tmp/first.png",
});
await sendUpload(client, {
target: "user:UABC123",
message: "second",
token: "xoxb-test-b",
mediaUrl: "/tmp/second.png",
});
expect(client.conversations.open).toHaveBeenCalledTimes(2);
});
it("sends file directly to channel without conversations.open", async () => {
const result = await sendUpload(client, { message: "chart", mediaUrl: "/tmp/chart.png" });
expect(client.conversations.open).not.toHaveBeenCalled();
expectCompletedUpload({ client, expected: { channel_id: "C123CHAN" } });
expectFields(requireRecord(result.receipt, "receipt"), {
primaryPlatformMessageId: "F001",
platformMessageIds: ["F001"],
});
const [part] = requireArray(result.receipt.parts, "receipt parts");
const partRecord = requireRecord(part, "receipt part");
expectFields(partRecord, {
platformMessageId: "F001",
kind: "media",
});
expectFields(requireRecord(partRecord.raw, "receipt raw"), {
channel: "slack",
channelId: "C123CHAN",
});
});
it("uploads bytes to the presigned URL and completes with thread+caption", async () => {
const events: string[] = [];
globalThis.fetch = vi.fn(async () => {
events.push("byte-upload");
return new Response("ok", { status: 200 });
}) as unknown as typeof fetch;
client.files.completeUploadExternal.mockImplementationOnce(async () => {
events.push("completion");
return { ok: true };
});
let finishDispatch: () => void = () => {};
const dispatchFinished = new Promise<void>((resolve) => {
finishDispatch = resolve;
});
const onPlatformSendDispatch = vi.fn(async () => {
events.push("dispatch-start");
await dispatchFinished;
events.push("dispatch-end");
});
const sendPromise = sendUpload(client, {
mediaUrl: "/tmp/threaded.png",
threadTs: "171.222",
onPlatformSendDispatch,
});
await vi.waitFor(() => expect(onPlatformSendDispatch).toHaveBeenCalledOnce());
expect(client.files.completeUploadExternal).not.toHaveBeenCalled();
finishDispatch();
const result = await sendPromise;
expect(client.files.getUploadURLExternal).toHaveBeenCalledWith({
filename: "screenshot.png",
length: Buffer.from("fake-image").length,
});
const fetchCalls = (globalThis.fetch as unknown as MockCalls).mock.calls;
expect(fetchCalls).toHaveLength(1);
const [fetchUrl, fetchInit] = fetchCalls[0] ?? [];
expect(fetchUrl).toBe("https://files.slack.com/upload");
expectFields(requireRecord(fetchInit, "fetch init"), { method: "POST" });
expectOnlyCallFirstArg(buildTimeoutAbortSignal, {
timeoutMs: 120_000,
operation: "slack-upload-file",
url: "https://files.slack.com",
});
expectOnlyCallFirstArg(fetchWithSsrFGuard, {
url: "https://files.slack.com/upload",
mode: "trusted_env_proxy",
timeoutMs: 120_000,
signal: expect.any(AbortSignal),
requireHttps: true,
policy: {
hostnameAllowlist: ["files.slack.com"],
allowRfc2544BenchmarkRange: true,
},
capture: false,
auditContext: "slack-upload-file",
});
expect(cleanupUploadTimeout).toHaveBeenCalledOnce();
expect(uploadTimeoutControllers).toHaveLength(0);
expect(onPlatformSendDispatch).toHaveBeenCalledOnce();
expect(events).toEqual(["byte-upload", "dispatch-start", "dispatch-end", "completion"]);
expectCompletedUpload({
client,
expected: {
channel_id: "C123CHAN",
initial_comment: "caption",
thread_ts: "171.222",
},
});
expect(hasSlackThreadParticipation("default", "C123CHAN", "171.222")).toBe(true);
expect(result.receipt.threadId).toBe("171.222");
});
it("keeps the presigned upload capability out of timeout logging", async () => {
mockUploadDestination(client, "https://files.slack.com/upload/v1/secret-capability");
await sendUpload(client, { mediaUrl: "/tmp/secret.png" });
expectOnlyCallFirstArg(buildTimeoutAbortSignal, {
timeoutMs: 120_000,
operation: "slack-upload-file",
url: "https://files.slack.com",
});
expectOnlyCallFirstArg(fetchWithSsrFGuard, {
url: "https://files.slack.com/upload/v1/secret-capability",
});
});
it("preserves HTTP upload URLs on an alternate Slack API origin", async () => {
const actual = await vi.importActual<typeof import("openclaw/plugin-sdk/ssrf-runtime")>(
"openclaw/plugin-sdk/ssrf-runtime",
);
await withServer(
(req, res) => {
expect(req.method).toBe("POST");
expect(req.url).toBe("/upload/v1/capability");
req.resume();
res.end("ok");
},
async (baseUrl) => {
vi.stubEnv("NO_PROXY", "127.0.0.1,localhost");
vi.stubEnv("no_proxy", "127.0.0.1,localhost");
const alternateClient = createUploadTestClient(`${baseUrl}/api/`);
const onDeliveryResult = vi.fn();
mockUploadDestination(alternateClient, `${baseUrl}/upload/v1/capability`);
fetchWithSsrFGuard.mockImplementationOnce(async (params) => {
const mockedFetch = globalThis.fetch;
globalThis.fetch = originalFetch;
try {
return await actual.fetchWithSsrFGuard(params);
} finally {
globalThis.fetch = mockedFetch;
}
});
const result = await sendUpload(alternateClient, {
mediaUrl: "/tmp/alternate-root.png",
message: "a".repeat(8_500),
threadTs: "171.222",
onDeliveryResult,
});
expectCompletedUpload({ client: alternateClient, expected: { channel_id: "C123CHAN" } });
expect(alternateClient.chat.postMessage).toHaveBeenCalledOnce();
expect(
onDeliveryResult.mock.calls.map(([delivery]) => delivery.receipt.parts[0]?.kind),
).toEqual(["media", "text"]);
expect(
result.receipt.parts.map(({ platformMessageId, kind, index, threadId }) => ({
platformMessageId,
kind,
index,
threadId,
})),
).toEqual([
{ platformMessageId: "F001", kind: "media", index: 0, threadId: "171.222" },
{ platformMessageId: "171234.567", kind: "text", index: 1, threadId: "171.222" },
]);
expect(result.receipt.threadId).toBe("171.222");
expect(cleanupUploadTimeout).toHaveBeenCalledOnce();
expect(uploadTimeoutControllers).toHaveLength(0);
},
);
});
it.each([
{
name: "allows an exact Slack upload host returned by a custom API root",
apiUrl: "https://slack-relay.example/api/",
uploadUrl: "https://files.slack.com/upload/v1/relayed-capability",
lookupAddress: "93.184.216.34",
mediaUrl: "/tmp/relayed-upload.png",
},
{
name: "allows GovSlack upload destinations through the real hostname guard",
apiUrl: "https://slack-gov.com/api/",
uploadUrl: "https://files.slack-gov.com/upload/v1/gov-capability",
lookupAddress: undefined,
mediaUrl: "/tmp/gov-slack.png",
},
{
name: "retains the shipped RFC2544 fake-IP path for an exact Slack upload host",
apiUrl: undefined,
uploadUrl: undefined,
lookupAddress: "198.18.0.10",
mediaUrl: "/tmp/fake-ip.png",
},
])("$name", async ({ apiUrl, uploadUrl, lookupAddress, mediaUrl }) => {
const caseClient = createUploadTestClient(apiUrl);
if (uploadUrl) {
mockUploadDestination(caseClient, uploadUrl);
}
const networkFetch = vi.fn(async () => new Response("ok", { status: 200 }));
await useRealUploadGuard(networkFetch, lookupAddress);
await sendUpload(caseClient, { mediaUrl });
expect(networkFetch).toHaveBeenCalledOnce();
expectCompletedUpload({ client: caseClient, expected: { channel_id: "C123CHAN" } });
});
it.each(["10.0.0.1", "169.254.1.1"])(
"rejects exact Slack upload hosts resolving to blocked address %s",
async (address) => {
const networkFetch = vi.fn(async () => new Response("unexpected"));
await useRealUploadGuard(networkFetch, address);
await expect(sendUpload(client, { mediaUrl: "/tmp/private-address.png" })).rejects.toThrow();
expect(networkFetch).not.toHaveBeenCalled();
expect(client.files.completeUploadExternal).not.toHaveBeenCalled();
},
);
it.each([
["public non-Slack", "https://slack.com/api/", "https://example.com/upload/v1/not-slack"],
["plaintext Slack", "https://slack.com/api/", "http://files.slack.com/upload/v1/plaintext"],
[
"commercial Slack to GovSlack",
"https://slack.com/api/",
"https://files.slack-gov.com/upload/v1/cross-plane",
],
[
"GovSlack to commercial Slack",
"https://slack-gov.com/api/",
"https://files.slack.com/upload/v1/cross-plane",
],
[
"trailing-dot commercial Slack to GovSlack",
"https://slack.com./api/",
"https://files.slack-gov.com/upload/v1/cross-plane",
],
[
"trailing-dot GovSlack to commercial Slack",
"https://slack-gov.com./api/",
"https://files.slack.com/upload/v1/cross-plane",
],
[
"undocumented commercial subdomain",
"https://slack.com/api/",
"https://future-upload.slack.com/upload/v1/capability",
],
[
"undocumented GovSlack subdomain",
"https://slack-gov.com/api/",
"https://future-upload.slack-gov.com/upload/v1/capability",
],
])("rejects %s upload destinations before network access", async (label, apiUrl, uploadUrl) => {
const rejectedClient = createUploadTestClient(apiUrl);
mockUploadDestination(rejectedClient, uploadUrl);
const networkFetch = vi.fn(async () => new Response("unexpected"));
const errorName = uploadUrl.startsWith("http:") ? "Error" : "SsrFBlockedError";
await useRealUploadGuard(networkFetch);
const rejection = await sendUpload(rejectedClient, {
mediaUrl: "/tmp/rejected-upload.png",
}).catch((cause: unknown) => cause);
expect(rejection).toBeInstanceOf(PlatformMessageNotDispatchedError);
expect(rejection).toMatchObject({ cause: expect.objectContaining({ name: errorName }) });
expect(networkFetch).not.toHaveBeenCalled();
expect(rejectedClient.files.completeUploadExternal).not.toHaveBeenCalled();
});
it("rejects upload destinations outside an explicitly configured API origin", async () => {
const originClient = createUploadTestClient("http://slack-compatible.example/api/");
mockUploadDestination(originClient, "http://other-compatible.example/upload/v1/capability");
await expect(sendUpload(originClient, { mediaUrl: "/tmp/wrong-origin.png" })).rejects.toThrow(
"must match the configured Slack API origin",
);
expect(fetchWithSsrFGuard).not.toHaveBeenCalled();
expect(originClient.files.completeUploadExternal).not.toHaveBeenCalled();
});
it("times out a hanging presigned URL upload", async () => {
const closedResponses = vi.fn();
await withServer(
(req, res) => {
req.resume();
const route = `${req.method ?? "GET"} ${req.url ?? "/"}`;
res.on("close", () => closedResponses(route));
if (route === "POST /upload") {
// Fire the mocked deadline only after the request reaches the server,
// keeping the cancellation regression deterministic under CI load.
const controller = uploadTimeoutControllers.at(-1);
if (!controller) {
throw new Error("missing Slack upload timeout controller");
}
const error = new Error("request timed out");
error.name = "TimeoutError";
controller.abort(error);
return;
}
res.statusCode = 500;
res.end(`unexpected ${route}`);
},
async (baseUrl) => {
globalThis.fetch = originalFetch;
mockUploadDestination(client, `${baseUrl}/upload`);
const onPlatformSendDispatch = vi.fn();
const error = await sendUpload(client, {
mediaUrl: "/tmp/hanging.png",
onPlatformSendDispatch,
}).catch((cause: unknown) => cause);
expect(error).toBeInstanceOf(PlatformMessageNotDispatchedError);
expect(error).toMatchObject({
name: "PlatformMessageNotDispatchedError",
code: "OPENCLAW_PLATFORM_MESSAGE_NOT_DISPATCHED",
cause: expect.objectContaining({ name: "TimeoutError" }),
});
await vi.waitFor(() => expect(closedResponses).toHaveBeenCalledWith("POST /upload"));
expectOnlyCallFirstArg(buildTimeoutAbortSignal, {
timeoutMs: 120_000,
operation: "slack-upload-file",
url: baseUrl,
});
expectOnlyCallFirstArg(fetchWithSsrFGuard, {
timeoutMs: 120_000,
signal: expect.any(AbortSignal),
});
expect(cleanupUploadTimeout).toHaveBeenCalledOnce();
expect(uploadTimeoutControllers).toHaveLength(0);
expect(onPlatformSendDispatch).not.toHaveBeenCalled();
expect(client.files.completeUploadExternal).not.toHaveBeenCalled();
},
);
});
it.each([201, 204, 500])("rejects a non-200 byte-upload response (%s)", async (status) => {
const onPlatformSendDispatch = vi.fn();
globalThis.fetch = vi.fn(async () => new Response(null, { status })) as unknown as typeof fetch;
const error = await sendUpload(client, {
mediaUrl: "/tmp/non-200.png",
onPlatformSendDispatch,
}).catch((cause: unknown) => cause);
expect(error).toBeInstanceOf(PlatformMessageNotDispatchedError);
expect(error).toMatchObject({
message: "Slack external upload failed before completion dispatch",
cause: expect.objectContaining({
code: `HTTP_${status}`,
message: `Slack external upload returned HTTP ${status}`,
}),
});
expect(onPlatformSendDispatch).not.toHaveBeenCalled();
expect(client.files.completeUploadExternal).not.toHaveBeenCalled();
});
it("marks a non-timeout byte-upload transport failure as not dispatched", async () => {
const onPlatformSendDispatch = vi.fn();
const transportError = Object.assign(
new Error(
"socket closed at https://files.slack.com/upload/v1/CAPABILITY_SENTINEL?token=QUERY_SENTINEL",
),
{ code: "ECONNRESET" },
);
globalThis.fetch = vi.fn(async () => {
throw transportError;
}) as unknown as typeof fetch;
const error = await sendUpload(client, {
mediaUrl: "/tmp/transport-failure.png",
onPlatformSendDispatch,
}).catch((cause: unknown) => cause);
expect(error).toBeInstanceOf(PlatformMessageNotDispatchedError);
expect(error).toMatchObject({
message: "Slack external upload failed before completion dispatch",
cause: expect.objectContaining({
code: "ECONNRESET",
message: "Slack external upload transfer failed",
}),
});
expect(formatErrorMessage(error)).not.toContain("CAPABILITY_SENTINEL");
expect(formatErrorMessage(error)).not.toContain("QUERY_SENTINEL");
expect(cleanupUploadTimeout).toHaveBeenCalledOnce();
expect(onPlatformSendDispatch).not.toHaveBeenCalled();
expect(client.files.completeUploadExternal).not.toHaveBeenCalled();
});
it("disposes the byte-upload response and timeout before waiting for completion", async () => {
const uploadResponse = new Response("ok", { status: 200 });
const cancelUploadBody = vi.spyOn(uploadResponse.body!, "cancel");
cancelUploadBody.mockRejectedValueOnce(new Error("response body cleanup failed"));
globalThis.fetch = vi.fn(async () => uploadResponse) as unknown as typeof fetch;
let markCompletionStarted: () => void = () => {};
const completionStarted = new Promise<void>((resolve) => {
markCompletionStarted = resolve;
});
let finishCompletion: (value: { ok: true }) => void = () => {};
const completionResult = new Promise<{ ok: true }>((resolve) => {
finishCompletion = resolve;
});
client.files.completeUploadExternal.mockImplementationOnce(() => {
markCompletionStarted();
return completionResult;
});
const sendPromise = sendUpload(client, { mediaUrl: "/tmp/completion-pending.png" });
await completionStarted;
expect(cancelUploadBody).toHaveBeenCalledOnce();
expect(cleanupUploadTimeout).toHaveBeenCalledOnce();
expect(uploadTimeoutControllers).toHaveLength(0);
await expect(
Promise.race([
sendPromise.then(
() => "settled",
() => "settled",
),
Promise.resolve("pending"),
]),
).resolves.toBe("pending");
finishCompletion({ ok: true });
await expect(sendPromise).resolves.toMatchObject({ messageId: "F001" });
});
it.each([
{
name: "keeps completion failures unmarked because Slack may have finalized the upload",
completion: new Error("completion unavailable"),
mediaUrl: "/tmp/completion-failure.png",
message: "completion unavailable",
},
{
name: "keeps completion error responses unmarked because Slack may have finalized the upload",
completion: { ok: false, error: "completion_failed" },
mediaUrl: "/tmp/completion-error-response.png",
message: "Failed to complete upload: completion_failed",
},
])("$name", async ({ completion, mediaUrl, message }) => {
const onPlatformSendDispatch = vi.fn();
if (completion instanceof Error) {
client.files.completeUploadExternal.mockRejectedValueOnce(completion);
} else {
client.files.completeUploadExternal.mockResolvedValueOnce(completion);
}
const error = await sendUpload(client, { mediaUrl, onPlatformSendDispatch }).catch(
(cause: unknown) => cause,
);
expect(error).toMatchObject({ message });
expect(error).not.toBeInstanceOf(PlatformMessageNotDispatchedError);
expect(onPlatformSendDispatch).toHaveBeenCalledOnce();
});
it.each([
["uses explicit upload filename and title overrides when provided", "Custom Title"],
["uses uploadFileName as the title fallback when uploadTitle is omitted", undefined],
] as const)("%s", async (_name, uploadTitle) => {
const uploadFileName = "custom-name.bin";
await sendUpload(client, {
mediaUrl: "/tmp/threaded.png",
uploadFileName,
...(uploadTitle ? { uploadTitle } : {}),
});
expect(client.files.getUploadURLExternal).toHaveBeenCalledWith({
filename: uploadFileName,
length: Buffer.from("fake-image").length,
});
expectCompletedUpload({
client,
expected: {},
file: { id: "F001", title: uploadTitle ?? uploadFileName },
});
});
it.each<{
name: string;
contentType: string | undefined;
fileName?: string;
uploadFileName?: string;
uploadTitle?: string;
expectedFileName: string;
expectedTitle?: string;
}>([
{
name: "infers a PDF filename for unnamed document media",
contentType: "application/pdf",
expectedFileName: "upload.pdf",
},
{
name: "infers a PNG filename for unnamed image media",
contentType: "image/png",
expectedFileName: "upload.png",
},
{
name: "infers an MP3 filename for unnamed audio media",
contentType: "audio/mpeg",
expectedFileName: "upload.mp3",
},
{
name: "normalizes MIME aliases and parameters for unnamed media",
contentType: "IMAGE/APNG; charset=binary",
expectedFileName: "upload.png",
},
{
name: "preserves a loader-provided filename over detected MIME",
contentType: "application/pdf",
fileName: "named-export.bin",
expectedFileName: "named-export.bin",
},
{
name: "preserves an explicit filename over loaded metadata",
contentType: "image/png",
fileName: "source.png",
uploadFileName: "operator-name.bin",
expectedFileName: "operator-name.bin",
},
{
name: "preserves an explicit title with an inferred filename",
contentType: "application/pdf",
uploadTitle: "Quarterly Report",
expectedFileName: "upload.pdf",
expectedTitle: "Quarterly Report",
},
{
name: "retains the existing fallback for unknown MIME types",
contentType: "application/x-unknown",
expectedFileName: "upload",
},
{
name: "retains the existing fallback when MIME metadata is absent",
contentType: undefined,
expectedFileName: "upload",
},
])(
"$name",
async ({
contentType,
fileName,
uploadFileName,
uploadTitle,
expectedFileName,
expectedTitle,
}) => {
loadOutboundMediaFromUrlMock.mockResolvedValueOnce({
buffer: Buffer.from("fake-image"),
contentType,
kind: "document",
...(fileName ? { fileName } : {}),
});
await sendUpload(client, {
mediaUrl: "https://example.com/?attachment=artifact",
...(uploadFileName ? { uploadFileName } : {}),
...(uploadTitle ? { uploadTitle } : {}),
});
expect(client.files.getUploadURLExternal).toHaveBeenCalledWith({
filename: expectedFileName,
length: Buffer.from("fake-image").length,
});
expectCompletedUpload({
client,
expected: {},
file: { id: "F001", title: expectedTitle ?? expectedFileName },
});
},
);
});