mirror of
https://github.com/hansjone/dsh-im-ops.git
synced 2026-10-09 05:20:46 +08:00
640 lines
21 KiB
JavaScript
640 lines
21 KiB
JavaScript
import assert from 'node:assert/strict';
|
|
import { mkdtemp, readFile, rm } from 'node:fs/promises';
|
|
import { tmpdir } from 'node:os';
|
|
import { join } from 'node:path';
|
|
import test from 'node:test';
|
|
|
|
import {
|
|
SlackConfigStore,
|
|
deriveSlackBotIdentity,
|
|
} from '../../../src/channels/slack/config-store.mjs';
|
|
import { SlackController } from '../../../src/channels/slack/slack-controller.mjs';
|
|
import {
|
|
SlackApi,
|
|
inspectSlackCredentials,
|
|
validSlackAppToken,
|
|
validSlackBotToken,
|
|
} from '../../../src/channels/slack/slack-api.mjs';
|
|
import {
|
|
SlackRuntime,
|
|
normalizeSlackEvent,
|
|
} from '../../../src/channels/slack/slack-runtime.mjs';
|
|
import { SLACK_APP_MANIFEST_YAML } from '../../../src/channels/slack/manifest.mjs';
|
|
import {
|
|
SLACK_ENDPOINTS,
|
|
createSlackRpcHandler,
|
|
} from '../../../plugin-src/host/channels/slack/rpc.mjs';
|
|
|
|
const BOT_TOKEN = `xoxb-${'0'.repeat(24)}-not-a-real-token`;
|
|
const APP_TOKEN = `xapp-${'0'.repeat(24)}-not-a-real-token`;
|
|
|
|
function jsonResponse(body, status = 200, headers = {}) {
|
|
return new Response(JSON.stringify(body), {
|
|
status,
|
|
headers: { 'content-type': 'application/json', ...headers },
|
|
});
|
|
}
|
|
|
|
function credentials() {
|
|
const values = new Map();
|
|
return {
|
|
values,
|
|
async resolve(ref) {
|
|
return values.has(ref) ? { value: values.get(ref), source: 'test' } : undefined;
|
|
},
|
|
async set(ref, value) { values.set(ref, value); },
|
|
async unset(ref) { values.delete(ref); },
|
|
};
|
|
}
|
|
|
|
function deferred() {
|
|
let resolve;
|
|
let reject;
|
|
const promise = new Promise((resolvePromise, rejectPromise) => {
|
|
resolve = resolvePromise;
|
|
reject = rejectPromise;
|
|
});
|
|
return { promise, resolve, reject };
|
|
}
|
|
|
|
async function eventually(predicate, timeoutMs = 1_000) {
|
|
const deadline = Date.now() + timeoutMs;
|
|
while (Date.now() < deadline) {
|
|
if (predicate()) return;
|
|
await new Promise((resolve) => setTimeout(resolve, 5));
|
|
}
|
|
assert.fail('condition was not met before timeout');
|
|
}
|
|
|
|
test('Slack validates both tokens and inspects Bot and Socket Mode credentials', async () => {
|
|
assert.equal(validSlackBotToken(BOT_TOKEN), true);
|
|
assert.equal(validSlackAppToken(APP_TOKEN), true);
|
|
assert.equal(validSlackBotToken(APP_TOKEN), false);
|
|
assert.equal(validSlackAppToken(BOT_TOKEN), false);
|
|
const calls = [];
|
|
const identity = await inspectSlackCredentials({ botToken: BOT_TOKEN, appToken: APP_TOKEN }, {
|
|
fetchImpl: async (url, options) => {
|
|
calls.push({ url, options });
|
|
if (url.pathname.endsWith('/auth.test')) {
|
|
return jsonResponse({
|
|
ok: true,
|
|
team: 'Harness Lab',
|
|
team_id: 'T12345678',
|
|
user: 'deepseek-harness',
|
|
user_id: 'U12345678',
|
|
bot_id: 'B12345678',
|
|
});
|
|
}
|
|
return jsonResponse({ ok: true, url: 'wss://wss-primary.slack.com/link/?ticket=test' });
|
|
},
|
|
});
|
|
assert.deepEqual(identity, {
|
|
platformId: 'T12345678:U12345678',
|
|
name: 'deepseek-harness',
|
|
username: 'deepseek-harness',
|
|
teamId: 'T12345678',
|
|
teamName: 'Harness Lab',
|
|
});
|
|
assert.equal(calls.find((call) => call.url.pathname.endsWith('/auth.test')).options.headers.authorization, `Bearer ${BOT_TOKEN}`);
|
|
assert.equal(calls.find((call) => call.url.pathname.endsWith('/apps.connections.open')).options.headers.authorization, `Bearer ${APP_TOKEN}`);
|
|
});
|
|
|
|
test('Slack API uses native streaming methods and suppresses generated mass mentions', async () => {
|
|
const calls = [];
|
|
const api = new SlackApi({
|
|
botToken: BOT_TOKEN,
|
|
appToken: APP_TOKEN,
|
|
fetchImpl: async (url, options) => {
|
|
const body = options.body ? JSON.parse(options.body) : null;
|
|
calls.push({ method: url.pathname.split('/').pop(), body });
|
|
return jsonResponse({ ok: true, ts: '1700000000.100' });
|
|
},
|
|
});
|
|
await api.startStream({
|
|
channelId: 'C12345678',
|
|
threadTs: '1700000000.001',
|
|
recipientTeamId: 'T12345678',
|
|
recipientUserId: 'U12345678',
|
|
});
|
|
await api.appendStream({
|
|
channelId: 'C12345678',
|
|
ts: '1700000000.100',
|
|
markdownText: 'hello ',
|
|
});
|
|
await api.stopStream({ channelId: 'C12345678', ts: '1700000000.100' });
|
|
await api.postMessage({
|
|
channelId: 'C12345678',
|
|
threadTs: '1700000000.001',
|
|
text: '请通知 <!channel> 和 <@U99999999>',
|
|
});
|
|
assert.deepEqual(calls.slice(0, 3).map((call) => call.method), [
|
|
'chat.startStream', 'chat.appendStream', 'chat.stopStream',
|
|
]);
|
|
assert.equal(calls[1].body.markdown_text, 'hello ');
|
|
assert.equal(calls[3].body.text, '请通知 @channel 和 @U99999999');
|
|
});
|
|
|
|
test('Slack downloads private files with the bot token from Slack hosts only', async () => {
|
|
const png = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
|
const calls = [];
|
|
const api = new SlackApi({
|
|
botToken: BOT_TOKEN,
|
|
fetchImpl: async (url, options) => {
|
|
calls.push({ url, options });
|
|
return new Response(png, { status: 200, headers: { 'content-length': String(png.length) } });
|
|
},
|
|
});
|
|
assert.deepEqual(await api.downloadFile({
|
|
url: 'https://files.slack.com/files-pri/T123-F123/download/image.png',
|
|
maxBytes: 100,
|
|
}), png);
|
|
assert.equal(calls[0].options.headers.authorization, `Bearer ${BOT_TOKEN}`);
|
|
assert.equal(calls[0].options.redirect, 'manual');
|
|
assert.deepEqual(await api.downloadFile({
|
|
url: 'https://slack.com/files-pri/T123-F124/download/image.png',
|
|
maxBytes: 100,
|
|
}), png);
|
|
assert.equal(calls[1].options.headers.authorization, `Bearer ${BOT_TOKEN}`);
|
|
assert.equal(calls[1].url.hostname, 'files.slack.com');
|
|
await assert.rejects(() => api.downloadFile({
|
|
url: 'https://example.com/internal.png', maxBytes: 100,
|
|
}), /messaging platform/);
|
|
assert.equal(calls.length, 2);
|
|
|
|
const redirectCalls = [];
|
|
const redirectingApi = new SlackApi({
|
|
botToken: BOT_TOKEN,
|
|
fetchImpl: async (url, options) => {
|
|
redirectCalls.push({ url, options });
|
|
return new Response(null, {
|
|
status: 302,
|
|
headers: { location: 'https://example.com/leak-token' },
|
|
});
|
|
},
|
|
});
|
|
await assert.rejects(() => redirectingApi.downloadFile({
|
|
url: 'https://slack.com/files-pri/T123-F125/download/image.png',
|
|
maxBytes: 100,
|
|
}), (error) => {
|
|
assert.equal(error.code, 'image-redirect-blocked');
|
|
return true;
|
|
});
|
|
assert.equal(redirectCalls.length, 1);
|
|
assert.equal(redirectCalls[0].url.hostname, 'files.slack.com');
|
|
assert.equal(redirectCalls[0].options.headers.authorization, `Bearer ${BOT_TOKEN}`);
|
|
assert.match(SLACK_APP_MANIFEST_YAML, /\n\s+- files:read\n/);
|
|
});
|
|
|
|
test('Slack refuses unsafe file redirects and explains stale files:read authorization', async () => {
|
|
const workspaceCalls = [];
|
|
const workspaceApi = new SlackApi({
|
|
botToken: BOT_TOKEN,
|
|
fetchImpl: async (url, options) => {
|
|
workspaceCalls.push({ url, options });
|
|
return new Response(null, {
|
|
status: 302,
|
|
headers: { location: 'https://workspace-name.slack.com/?redir=%2Ffiles-pri%2Fsecret' },
|
|
});
|
|
},
|
|
});
|
|
await assert.rejects(() => workspaceApi.downloadFile({
|
|
url: 'https://files.slack.com/files-pri/T123-F126/download/image.png',
|
|
maxBytes: 100,
|
|
}), (error) => {
|
|
assert.equal(error.code, 'slack-file-access-required');
|
|
assert.match(error.userMessage, /files:read/);
|
|
return true;
|
|
});
|
|
assert.equal(workspaceCalls.length, 1);
|
|
|
|
const missingScopeCalls = [];
|
|
const missingScopeApi = new SlackApi({
|
|
botToken: BOT_TOKEN,
|
|
fetchImpl: async (url, options) => {
|
|
missingScopeCalls.push({ url, options });
|
|
if (url.pathname.endsWith('/auth.test')) {
|
|
return new Response(JSON.stringify({
|
|
ok: true,
|
|
team_id: 'T12345678',
|
|
user_id: 'U12345678',
|
|
bot_id: 'B12345678',
|
|
}), {
|
|
status: 200,
|
|
headers: {
|
|
'content-type': 'application/json',
|
|
'x-oauth-scopes': 'app_mentions:read,chat:write,im:history',
|
|
},
|
|
});
|
|
}
|
|
return new Response(null, {
|
|
status: 302,
|
|
headers: {
|
|
location: 'https://files-origin.slack.com/files-pri/T123-F126/download/image.png',
|
|
},
|
|
});
|
|
},
|
|
});
|
|
await missingScopeApi.authTest();
|
|
await assert.rejects(() => missingScopeApi.downloadFile({
|
|
url: 'https://files.slack.com/files-pri/T123-F126/download/image.png',
|
|
maxBytes: 100,
|
|
}), (error) => {
|
|
assert.equal(error.code, 'slack-file-access-required');
|
|
assert.match(error.userMessage, /files:read/);
|
|
return true;
|
|
});
|
|
assert.equal(missingScopeCalls.length, 2);
|
|
assert.deepEqual(missingScopeCalls.map((call) => call.url.hostname), [
|
|
'slack.com', 'files.slack.com',
|
|
]);
|
|
|
|
for (const location of [
|
|
'https://example.com/leak-token',
|
|
'http://files-origin.slack.com/files-pri/T123-F126/download/image.png',
|
|
'https://files-origin.slack.com/files-pri/T123-F126/download/image.png',
|
|
'https://files-origin.slack.com/files-pri/T123-F999/download/image.png',
|
|
'https://files-origin.slack.com/files-pri/T123-F126/download/image.png?changed=1',
|
|
]) {
|
|
const unsafeCalls = [];
|
|
const unsafeApi = new SlackApi({
|
|
botToken: BOT_TOKEN,
|
|
fetchImpl: async (url, options) => {
|
|
unsafeCalls.push({ url, options });
|
|
return new Response(null, { status: 302, headers: { location } });
|
|
},
|
|
});
|
|
await assert.rejects(() => unsafeApi.downloadFile({
|
|
url: 'https://files.slack.com/files-pri/T123-F126/download/image.png',
|
|
maxBytes: 100,
|
|
}), (error) => {
|
|
assert.equal(error.code, 'image-redirect-blocked');
|
|
return true;
|
|
});
|
|
assert.equal(unsafeCalls.length, 1);
|
|
}
|
|
});
|
|
|
|
test('Slack controller stores two protected credential references and exposes neither token', async (t) => {
|
|
const directory = await mkdtemp(join(tmpdir(), 'dsh-im-slack-'));
|
|
t.after(() => rm(directory, { recursive: true, force: true }));
|
|
const configPath = join(directory, 'config.json');
|
|
const configStore = await new SlackConfigStore(configPath).load();
|
|
const credentialStore = credentials();
|
|
const connectionTests = [];
|
|
const controller = new SlackController({
|
|
credentials: credentialStore,
|
|
configStore,
|
|
inspectCredentials: async () => ({
|
|
platformId: 'T12345678:U12345678',
|
|
name: 'DeepSeek Harness',
|
|
username: 'deepseek-harness',
|
|
teamId: 'T12345678',
|
|
teamName: 'Harness Lab',
|
|
}),
|
|
createRuntime: async () => ({
|
|
status: {
|
|
ready: true,
|
|
connectionState: 'connected',
|
|
harnessReachable: true,
|
|
lastCheckedAt: 20,
|
|
},
|
|
async start() {},
|
|
async stop() {},
|
|
async sendConnectionTest(text) { connectionTests.push(text); },
|
|
}),
|
|
});
|
|
const status = await controller.bindCredentials({ botToken: BOT_TOKEN, appToken: APP_TOKEN });
|
|
assert.equal(status.totals.connected, 1);
|
|
assert.equal(status.bots[0].bot.name, 'DeepSeek Harness');
|
|
assert.equal(status.bots[0].bot.teamName, 'Harness Lab');
|
|
const identity = deriveSlackBotIdentity('T12345678:U12345678');
|
|
assert.equal(credentialStore.values.get(identity.botTokenRef), BOT_TOKEN);
|
|
assert.equal(credentialStore.values.get(identity.appTokenRef), APP_TOKEN);
|
|
const stored = await readFile(configPath, 'utf8');
|
|
assert.doesNotMatch(stored, new RegExp(BOT_TOKEN));
|
|
assert.doesNotMatch(stored, new RegExp(APP_TOKEN));
|
|
await controller.sendConnectionTest(identity.botId);
|
|
assert.match(connectionTests[0], /DeepSeek Harness/);
|
|
assert.match(connectionTests[0], /T1234••• · U1234•••/);
|
|
await controller.deleteBot(identity.botId);
|
|
assert.equal(credentialStore.values.has(identity.botTokenRef), false);
|
|
assert.equal(credentialStore.values.has(identity.appTokenRef), false);
|
|
});
|
|
|
|
test('Slack RPC requires exactly two tokens and strips all credential internals', async () => {
|
|
const connectionTests = [];
|
|
const controller = {
|
|
status: () => ({ bots: [], totals: { configured: 0, connected: 0 } }),
|
|
bindCredentials: async () => ({
|
|
bots: [{
|
|
botId: 'slack_1234567890abcdef12345678',
|
|
botToken: BOT_TOKEN,
|
|
appToken: APP_TOKEN,
|
|
botTokenRef: 'DSH_SLACK_BOT_TOKEN_ABC',
|
|
appTokenRef: 'DSH_SLACK_APP_TOKEN_ABC',
|
|
platformId: 'T123:U123',
|
|
bot: { name: 'Slack机器人', idMasked: 'T123•••' },
|
|
}],
|
|
totals: { configured: 1, connected: 0 },
|
|
}),
|
|
reconnectBot: async (botId) => ({
|
|
bots: [{ botId, connected: true }],
|
|
totals: { configured: 1, connected: 1 },
|
|
}),
|
|
sendConnectionTest: async (botId) => { connectionTests.push(botId); },
|
|
deleteBot: async () => ({ bots: [], totals: { configured: 0, connected: 0 } }),
|
|
};
|
|
const handler = createSlackRpcHandler(controller);
|
|
const result = await handler(SLACK_ENDPOINTS.bindCredentials, {
|
|
botToken: BOT_TOKEN,
|
|
appToken: APP_TOKEN,
|
|
});
|
|
assert.equal(result.ok, true);
|
|
assert.equal(result.value.bots[0].botToken, undefined);
|
|
assert.equal(result.value.bots[0].appToken, undefined);
|
|
assert.equal(result.value.bots[0].botTokenRef, undefined);
|
|
assert.equal(result.value.bots[0].appTokenRef, undefined);
|
|
assert.equal(result.value.bots[0].platformId, undefined);
|
|
const rejected = await handler(SLACK_ENDPOINTS.bindCredentials, {
|
|
botToken: BOT_TOKEN,
|
|
appToken: APP_TOKEN,
|
|
extra: true,
|
|
});
|
|
assert.equal(rejected.ok, false);
|
|
assert.equal(rejected.error.code, 'bad-request');
|
|
|
|
const tested = await handler(SLACK_ENDPOINTS.reconnectBot, {
|
|
botId: 'slack_1234567890abcdef12345678',
|
|
sendTest: true,
|
|
});
|
|
assert.equal(tested.ok, true);
|
|
assert.deepEqual(tested.value.testMessage, { sent: true });
|
|
assert.deepEqual(connectionTests, ['slack_1234567890abcdef12345678']);
|
|
});
|
|
|
|
test('Slack RPC never sends a connection test after reconnect is cancelled', async () => {
|
|
let resolveReconnect;
|
|
let sendCalls = 0;
|
|
const reconnect = new Promise((resolve) => { resolveReconnect = resolve; });
|
|
const botId = 'slack_1234567890abcdef12345678';
|
|
const controller = {
|
|
status: async () => ({ bots: [] }),
|
|
bindCredentials: async () => ({ bots: [] }),
|
|
reconnectBot: async () => reconnect,
|
|
sendConnectionTest: async () => { sendCalls += 1; },
|
|
deleteBot: async () => ({ bots: [] }),
|
|
};
|
|
const abort = new AbortController();
|
|
const result = createSlackRpcHandler(controller)(SLACK_ENDPOINTS.reconnectBot, {
|
|
botId,
|
|
sendTest: true,
|
|
}, abort.signal);
|
|
|
|
abort.abort();
|
|
resolveReconnect({ bots: [{ botId, connected: true }] });
|
|
|
|
assert.deepEqual(await result, {
|
|
ok: false,
|
|
error: { code: 'cancelled', message: 'The request was cancelled.' },
|
|
});
|
|
assert.equal(sendCalls, 0);
|
|
});
|
|
|
|
test('Slack normalizes direct messages and addressed channel events', () => {
|
|
const direct = normalizeSlackEvent({
|
|
event_id: 'Ev001',
|
|
team_id: 'T12345678',
|
|
event: {
|
|
type: 'message',
|
|
channel_type: 'im',
|
|
channel: 'D12345678',
|
|
user: 'U87654321',
|
|
ts: '1700000000.001',
|
|
text: 'hello & welcome',
|
|
},
|
|
}, 'U12345678');
|
|
assert.equal(direct.kind, 'direct');
|
|
assert.equal(direct.addressed, true);
|
|
assert.equal(direct.content, 'hello & welcome');
|
|
assert.equal(direct.conversationId, 'D12345678');
|
|
assert.equal(direct.replyTarget.threadTs, '1700000000.001');
|
|
assert.deepEqual(direct.connectionTestTarget, { channelId: 'D12345678' });
|
|
|
|
const group = normalizeSlackEvent({
|
|
event_id: 'Ev002',
|
|
team_id: 'T12345678',
|
|
event: {
|
|
type: 'app_mention',
|
|
channel: 'C12345678',
|
|
user: 'U87654321',
|
|
user_team: 'T12345678',
|
|
ts: '1700000000.002',
|
|
text: '<@U12345678> run this',
|
|
},
|
|
}, 'U12345678');
|
|
assert.equal(group.kind, 'group');
|
|
assert.equal(group.addressed, true);
|
|
assert.equal(group.content, 'run this');
|
|
assert.equal(group.conversationId, 'C12345678:1700000000.002');
|
|
assert.equal(group.replyTarget.threadTs, '1700000000.002');
|
|
|
|
const botMessage = normalizeSlackEvent({
|
|
event_id: 'Ev003',
|
|
event: {
|
|
type: 'message', channel_type: 'im', channel: 'D12345678', user: 'U12345678',
|
|
bot_id: 'B12345678', ts: '1700000000.003', text: 'ignore me',
|
|
},
|
|
}, 'U12345678');
|
|
assert.equal(botMessage, null);
|
|
});
|
|
|
|
test('Slack accepts image file shares and keeps other files out of image prompts', async () => {
|
|
const loads = [];
|
|
const direct = normalizeSlackEvent({
|
|
event_id: 'Ev010',
|
|
team_id: 'T12345678',
|
|
event: {
|
|
type: 'message',
|
|
subtype: 'file_share',
|
|
channel_type: 'im',
|
|
channel: 'D12345678',
|
|
user: 'U87654321',
|
|
ts: '1700000000.010',
|
|
text: '识别一下',
|
|
files: [{
|
|
id: 'F12345678', name: 'screen.png', mimetype: 'image/png', size: 2_000,
|
|
url_private_download: 'https://files.slack.com/files-pri/T123-F123/download/screen.png',
|
|
}, {
|
|
id: 'F12345679', name: 'notes.txt', mimetype: 'text/plain', size: 20,
|
|
url_private: 'https://files.slack.com/files-pri/T123-F124/notes.txt',
|
|
}],
|
|
},
|
|
}, 'U12345678', {
|
|
loadFile: async (url, options) => {
|
|
loads.push({ url, options });
|
|
return Buffer.from('image');
|
|
},
|
|
});
|
|
assert.equal(direct.images.length, 1);
|
|
assert.equal(direct.images[0].name, 'screen.png');
|
|
await direct.images[0].load({ maxBytes: 5_000 });
|
|
assert.match(loads[0].url, /screen\.png$/);
|
|
|
|
const group = normalizeSlackEvent({
|
|
event_id: 'Ev011',
|
|
team_id: 'T12345678',
|
|
event: {
|
|
type: 'app_mention', channel: 'C12345678', user: 'U87654321', ts: '1700000000.011',
|
|
text: '<@U12345678>',
|
|
files: [{
|
|
id: 'F12345680', name: 'photo.jpg', mimetype: 'image/jpeg', size: 1_000,
|
|
url_private: 'https://files.slack.com/files-pri/T123-F125/photo.jpg',
|
|
}],
|
|
},
|
|
}, 'U12345678');
|
|
assert.equal(group.addressed, true);
|
|
assert.equal(group.images.length, 1);
|
|
|
|
assert.equal(normalizeSlackEvent({
|
|
event_id: 'Ev012',
|
|
event: {
|
|
type: 'message', subtype: 'message_changed', channel_type: 'im', channel: 'D12345678',
|
|
user: 'U87654321', ts: '1700000000.012', text: '', files: [],
|
|
},
|
|
}, 'U12345678'), null);
|
|
});
|
|
|
|
class FakeSocket {
|
|
#listeners = new Map();
|
|
sent = [];
|
|
readyState = 1;
|
|
|
|
addEventListener(name, listener) {
|
|
const listeners = this.#listeners.get(name) ?? [];
|
|
listeners.push(listener);
|
|
this.#listeners.set(name, listeners);
|
|
}
|
|
|
|
send(value) {
|
|
this.sent.push(JSON.parse(value));
|
|
}
|
|
|
|
close(code = 1000) {
|
|
if (this.readyState >= 2) return;
|
|
this.readyState = 3;
|
|
this.emit('close', { code });
|
|
}
|
|
|
|
emit(name, event) {
|
|
for (const listener of this.#listeners.get(name) ?? []) listener(event);
|
|
}
|
|
}
|
|
|
|
test('Slack runtime opens Socket Mode, acknowledges envelopes, and becomes ready', async () => {
|
|
let socket;
|
|
const abortMark = deferred();
|
|
let abortMarkStarted = false;
|
|
const errors = [];
|
|
const runtime = new SlackRuntime({
|
|
config: {
|
|
botId: 'slack_test',
|
|
platformId: 'T12345678:U12345678',
|
|
name: 'DeepSeek Harness',
|
|
},
|
|
botToken: BOT_TOKEN,
|
|
appToken: APP_TOKEN,
|
|
harness: { ensureRunning: async () => true },
|
|
state: {
|
|
sessionFor: () => null,
|
|
setSession: async () => {},
|
|
clearSession: async () => {},
|
|
hasSeen: () => false,
|
|
markSeen: async (messageId) => {
|
|
if (messageId === 'Ev-abort') {
|
|
abortMarkStarted = true;
|
|
return abortMark.promise;
|
|
}
|
|
throw new Error(`Slack state write failed for ${messageId}`);
|
|
},
|
|
},
|
|
createApi: () => ({
|
|
authTest: async () => ({ team_id: 'T12345678', user_id: 'U12345678' }),
|
|
openConnection: async () => ({ url: 'wss://wss-primary.slack.com/link/?ticket=test' }),
|
|
}),
|
|
createWebSocket: () => {
|
|
socket = new FakeSocket();
|
|
queueMicrotask(() => socket.emit('message', {
|
|
data: JSON.stringify({
|
|
type: 'hello',
|
|
connection_info: { app_id: 'A12345678' },
|
|
}),
|
|
}));
|
|
return socket;
|
|
},
|
|
logger: {
|
|
warn() {},
|
|
error(...args) { errors.push(args); },
|
|
},
|
|
});
|
|
await runtime.start();
|
|
assert.equal(runtime.status.ready, true);
|
|
socket.emit('message', {
|
|
data: JSON.stringify({
|
|
envelope_id: 'env-1',
|
|
type: 'events_api',
|
|
payload: { type: 'event_callback', api_app_id: 'A12345678', event_id: 'Ev-noop', event: {} },
|
|
}),
|
|
});
|
|
assert.deepEqual(socket.sent.at(-1), { envelope_id: 'env-1' });
|
|
|
|
for (const eventId of ['Ev-failed-first', 'Ev-failed-queued']) {
|
|
socket.emit('message', {
|
|
data: JSON.stringify({
|
|
envelope_id: `env-${eventId}`,
|
|
type: 'events_api',
|
|
payload: {
|
|
type: 'event_callback',
|
|
api_app_id: 'A12345678',
|
|
event_id: eventId,
|
|
event: {
|
|
type: 'message',
|
|
channel_type: 'im',
|
|
channel: 'D12345678',
|
|
user: 'U87654321',
|
|
ts: eventId === 'Ev-failed-first' ? '1700000000.010' : '1700000000.011',
|
|
text: 'trigger state failure',
|
|
},
|
|
},
|
|
}),
|
|
});
|
|
}
|
|
await eventually(() => errors.length === 2);
|
|
assert.equal(errors.every((args) => args[0].includes('message handling failed')), true);
|
|
|
|
socket.emit('message', {
|
|
data: JSON.stringify({
|
|
envelope_id: 'env-abort',
|
|
type: 'events_api',
|
|
payload: {
|
|
type: 'event_callback',
|
|
api_app_id: 'A12345678',
|
|
event_id: 'Ev-abort',
|
|
event: {
|
|
type: 'message',
|
|
channel_type: 'im',
|
|
channel: 'D12345678',
|
|
user: 'U87654321',
|
|
ts: '1700000000.012',
|
|
text: 'abort state write',
|
|
},
|
|
},
|
|
}),
|
|
});
|
|
await eventually(() => abortMarkStarted);
|
|
const stopping = runtime.stop();
|
|
abortMark.reject(new Error('Slack state write aborted'));
|
|
await stopping;
|
|
await new Promise((resolve) => setImmediate(resolve));
|
|
assert.equal(errors.length, 2);
|
|
assert.equal(runtime.status.ready, false);
|
|
});
|