vellum 0.2.13 → 0.2.14
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +32 -0
- package/bun.lock +2 -2
- package/docs/skills.md +4 -4
- package/package.json +2 -2
- package/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap +213 -3
- package/src/__tests__/app-git-history.test.ts +176 -0
- package/src/__tests__/app-git-service.test.ts +169 -0
- package/src/__tests__/assistant-events-sse-hardening.test.ts +315 -0
- package/src/__tests__/browser-skill-baseline-tool-payload.test.ts +8 -8
- package/src/__tests__/browser-skill-endstate.test.ts +6 -6
- package/src/__tests__/call-bridge.test.ts +105 -13
- package/src/__tests__/call-domain.test.ts +163 -0
- package/src/__tests__/call-orchestrator.test.ts +113 -0
- package/src/__tests__/call-routes-http.test.ts +246 -6
- package/src/__tests__/channel-approval-routes.test.ts +438 -0
- package/src/__tests__/channel-approval.test.ts +266 -0
- package/src/__tests__/channel-approvals.test.ts +393 -0
- package/src/__tests__/channel-delivery-store.test.ts +447 -0
- package/src/__tests__/checker.test.ts +607 -1048
- package/src/__tests__/cli.test.ts +1 -56
- package/src/__tests__/config-schema.test.ts +137 -18
- package/src/__tests__/conflict-intent-tokenization.test.ts +141 -0
- package/src/__tests__/conflict-policy.test.ts +121 -0
- package/src/__tests__/conflict-store.test.ts +2 -0
- package/src/__tests__/contacts-tools.test.ts +3 -3
- package/src/__tests__/contradiction-checker.test.ts +99 -1
- package/src/__tests__/credential-security-invariants.test.ts +22 -6
- package/src/__tests__/credential-vault-unit.test.ts +780 -0
- package/src/__tests__/elevenlabs-client.test.ts +62 -0
- package/src/__tests__/ephemeral-permissions.test.ts +73 -23
- package/src/__tests__/filesystem-tools.test.ts +579 -0
- package/src/__tests__/gateway-only-enforcement.test.ts +114 -4
- package/src/__tests__/handlers-add-trust-rule-metadata.test.ts +202 -0
- package/src/__tests__/handlers-cu-observation-blob.test.ts +2 -1
- package/src/__tests__/handlers-ipc-blob-probe.test.ts +2 -1
- package/src/__tests__/handlers-slack-config.test.ts +2 -1
- package/src/__tests__/handlers-telegram-config.test.ts +855 -0
- package/src/__tests__/handlers-twitter-config.test.ts +141 -1
- package/src/__tests__/hooks-runner.test.ts +6 -2
- package/src/__tests__/host-file-edit-tool.test.ts +124 -0
- package/src/__tests__/host-file-read-tool.test.ts +62 -0
- package/src/__tests__/host-file-write-tool.test.ts +59 -0
- package/src/__tests__/host-shell-tool.test.ts +251 -0
- package/src/__tests__/ingress-reconcile.test.ts +581 -0
- package/src/__tests__/ipc-snapshot.test.ts +100 -41
- package/src/__tests__/ipc-validate.test.ts +50 -0
- package/src/__tests__/key-migration.test.ts +23 -0
- package/src/__tests__/memory-regressions.test.ts +99 -0
- package/src/__tests__/memory-retrieval.benchmark.test.ts +1 -1
- package/src/__tests__/oauth-callback-registry.test.ts +11 -4
- package/src/__tests__/playbook-execution.test.ts +502 -0
- package/src/__tests__/playbook-tools.test.ts +4 -6
- package/src/__tests__/public-ingress-urls.test.ts +34 -0
- package/src/__tests__/qdrant-manager.test.ts +267 -0
- package/src/__tests__/recurrence-engine-rruleset.test.ts +97 -0
- package/src/__tests__/recurrence-engine.test.ts +9 -0
- package/src/__tests__/recurrence-types.test.ts +8 -0
- package/src/__tests__/registry.test.ts +1 -1
- package/src/__tests__/runtime-runs.test.ts +1 -25
- package/src/__tests__/schedule-store.test.ts +16 -14
- package/src/__tests__/schedule-tools.test.ts +83 -0
- package/src/__tests__/scheduler-recurrence.test.ts +111 -10
- package/src/__tests__/secret-allowlist.test.ts +18 -17
- package/src/__tests__/secret-ingress-handler.test.ts +11 -0
- package/src/__tests__/secret-scanner.test.ts +43 -0
- package/src/__tests__/session-conflict-gate.test.ts +442 -6
- package/src/__tests__/session-init.benchmark.test.ts +3 -0
- package/src/__tests__/session-process-bridge.test.ts +242 -0
- package/src/__tests__/session-skill-tools.test.ts +1 -1
- package/src/__tests__/shell-identity.test.ts +256 -0
- package/src/__tests__/skill-projection.benchmark.test.ts +11 -1
- package/src/__tests__/subagent-tools.test.ts +637 -54
- package/src/__tests__/task-management-tools.test.ts +936 -0
- package/src/__tests__/task-runner.test.ts +2 -2
- package/src/__tests__/terminal-tools.test.ts +840 -0
- package/src/__tests__/tool-executor-shell-integration.test.ts +301 -0
- package/src/__tests__/tool-executor.test.ts +85 -151
- package/src/__tests__/tool-permission-simulate-handler.test.ts +336 -0
- package/src/__tests__/trust-store.test.ts +27 -453
- package/src/__tests__/twilio-provider.test.ts +153 -3
- package/src/__tests__/twilio-routes-elevenlabs.test.ts +375 -0
- package/src/__tests__/twilio-routes-twiml.test.ts +4 -4
- package/src/__tests__/twilio-routes.test.ts +17 -262
- package/src/__tests__/twitter-auth-handler.test.ts +2 -1
- package/src/__tests__/twitter-cli-error-shaping.test.ts +208 -0
- package/src/__tests__/twitter-cli-routing.test.ts +252 -0
- package/src/__tests__/twitter-oauth-client.test.ts +209 -0
- package/src/__tests__/workspace-policy.test.ts +213 -0
- package/src/calls/call-bridge.ts +92 -19
- package/src/calls/call-domain.ts +157 -5
- package/src/calls/call-orchestrator.ts +93 -7
- package/src/calls/call-store.ts +6 -0
- package/src/calls/elevenlabs-client.ts +8 -0
- package/src/calls/elevenlabs-config.ts +7 -5
- package/src/calls/twilio-provider.ts +91 -0
- package/src/calls/twilio-routes.ts +32 -37
- package/src/calls/types.ts +3 -1
- package/src/calls/voice-quality.ts +29 -7
- package/src/cli/twitter.ts +200 -21
- package/src/cli.ts +1 -20
- package/src/config/bundled-skills/contacts/tools/contact-merge.ts +52 -4
- package/src/config/bundled-skills/contacts/tools/contact-search.ts +55 -4
- package/src/config/bundled-skills/contacts/tools/contact-upsert.ts +61 -4
- package/src/config/bundled-skills/messaging/SKILL.md +17 -2
- package/src/config/bundled-skills/messaging/tools/messaging-reply.ts +4 -1
- package/src/config/bundled-skills/messaging/tools/messaging-send.ts +5 -1
- package/src/config/bundled-skills/messaging/tools/shared.ts +5 -0
- package/src/config/bundled-skills/phone-calls/SKILL.md +142 -34
- package/src/config/bundled-skills/playbooks/tools/playbook-create.ts +95 -6
- package/src/config/bundled-skills/playbooks/tools/playbook-delete.ts +51 -6
- package/src/config/bundled-skills/playbooks/tools/playbook-list.ts +73 -6
- package/src/config/bundled-skills/playbooks/tools/playbook-update.ts +110 -6
- package/src/config/bundled-skills/public-ingress/SKILL.md +22 -5
- package/src/config/bundled-skills/twitter/SKILL.md +103 -17
- package/src/config/defaults.ts +10 -4
- package/src/config/schema.ts +80 -21
- package/src/config/types.ts +1 -0
- package/src/config/vellum-skills/telegram-setup/SKILL.md +56 -61
- package/src/daemon/assistant-attachments.ts +4 -2
- package/src/daemon/handlers/apps.ts +69 -0
- package/src/daemon/handlers/config.ts +543 -24
- package/src/daemon/handlers/index.ts +1 -0
- package/src/daemon/handlers/sessions.ts +22 -6
- package/src/daemon/handlers/shared.ts +2 -1
- package/src/daemon/handlers/skills.ts +5 -20
- package/src/daemon/ipc-contract-inventory.json +28 -0
- package/src/daemon/ipc-contract.ts +168 -10
- package/src/daemon/ipc-validate.ts +17 -0
- package/src/daemon/lifecycle.ts +2 -0
- package/src/daemon/server.ts +78 -72
- package/src/daemon/session-attachments.ts +1 -1
- package/src/daemon/session-conflict-gate.ts +62 -6
- package/src/daemon/session-notifiers.ts +1 -1
- package/src/daemon/session-process.ts +62 -3
- package/src/daemon/session-tool-setup.ts +1 -2
- package/src/daemon/tls-certs.ts +189 -0
- package/src/daemon/video-thumbnail.ts +5 -3
- package/src/hooks/manager.ts +5 -9
- package/src/memory/app-git-service.ts +295 -0
- package/src/memory/app-store.ts +21 -0
- package/src/memory/conflict-intent.ts +47 -4
- package/src/memory/conflict-policy.ts +73 -0
- package/src/memory/conflict-store.ts +9 -1
- package/src/memory/contradiction-checker.ts +28 -0
- package/src/memory/conversation-key-store.ts +15 -0
- package/src/memory/db.ts +81 -0
- package/src/memory/embedding-local.ts +3 -13
- package/src/memory/external-conversation-store.ts +234 -0
- package/src/memory/job-handlers/conflict.ts +22 -2
- package/src/memory/jobs-worker.ts +67 -28
- package/src/memory/runs-store.ts +54 -7
- package/src/memory/schema.ts +20 -0
- package/src/messaging/provider.ts +9 -0
- package/src/messaging/providers/telegram-bot/adapter.ts +162 -0
- package/src/messaging/providers/telegram-bot/client.ts +104 -0
- package/src/messaging/providers/telegram-bot/types.ts +15 -0
- package/src/messaging/registry.ts +1 -0
- package/src/permissions/checker.ts +48 -44
- package/src/permissions/prompter.ts +0 -4
- package/src/permissions/shell-identity.ts +227 -0
- package/src/permissions/trust-store.ts +76 -53
- package/src/permissions/types.ts +0 -19
- package/src/permissions/workspace-policy.ts +114 -0
- package/src/providers/retry.ts +12 -37
- package/src/runtime/assistant-event-hub.ts +41 -4
- package/src/runtime/channel-approval-parser.ts +60 -0
- package/src/runtime/channel-approval-types.ts +71 -0
- package/src/runtime/channel-approvals.ts +145 -0
- package/src/runtime/gateway-client.ts +16 -0
- package/src/runtime/http-server.ts +29 -9
- package/src/runtime/routes/call-routes.ts +52 -2
- package/src/runtime/routes/channel-routes.ts +296 -16
- package/src/runtime/routes/events-routes.ts +97 -28
- package/src/runtime/routes/run-routes.ts +2 -7
- package/src/runtime/run-orchestrator.ts +0 -3
- package/src/schedule/recurrence-engine.ts +26 -2
- package/src/schedule/recurrence-types.ts +1 -1
- package/src/schedule/schedule-store.ts +12 -3
- package/src/security/secret-scanner.ts +7 -0
- package/src/tasks/ephemeral-permissions.ts +0 -2
- package/src/tasks/task-scheduler.ts +2 -1
- package/src/tools/calls/call-start.ts +8 -0
- package/src/tools/execution-target.ts +21 -0
- package/src/tools/execution-timeout.ts +49 -0
- package/src/tools/executor.ts +6 -135
- package/src/tools/network/web-search.ts +9 -32
- package/src/tools/policy-context.ts +29 -0
- package/src/tools/schedule/update.ts +8 -1
- package/src/tools/terminal/parser.ts +16 -18
- package/src/tools/types.ts +4 -11
- package/src/twitter/oauth-client.ts +102 -0
- package/src/twitter/router.ts +101 -0
- package/src/util/debounce.ts +88 -0
- package/src/util/network-info.ts +47 -0
- package/src/util/platform.ts +29 -4
- package/src/util/promise-guard.ts +37 -0
- package/src/util/retry.ts +98 -0
- package/src/util/truncate.ts +1 -1
- package/src/workspace/git-service.ts +129 -112
- package/src/tools/contacts/contact-merge.ts +0 -55
- package/src/tools/contacts/contact-search.ts +0 -58
- package/src/tools/contacts/contact-upsert.ts +0 -64
- package/src/tools/playbooks/index.ts +0 -4
- package/src/tools/playbooks/playbook-create.ts +0 -96
- package/src/tools/playbooks/playbook-delete.ts +0 -52
- package/src/tools/playbooks/playbook-list.ts +0 -74
- package/src/tools/playbooks/playbook-update.ts +0 -111
|
@@ -0,0 +1,855 @@
|
|
|
1
|
+
import { describe, test, expect, mock, beforeEach } from 'bun:test';
|
|
2
|
+
import { mkdtempSync } from 'node:fs';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import * as net from 'node:net';
|
|
6
|
+
|
|
7
|
+
const testDir = mkdtempSync(join(tmpdir(), 'handlers-telegram-cfg-test-'));
|
|
8
|
+
|
|
9
|
+
// Track loadRawConfig / saveRawConfig calls
|
|
10
|
+
let rawConfigStore: Record<string, unknown> = {};
|
|
11
|
+
|
|
12
|
+
mock.module('../config/loader.js', () => ({
|
|
13
|
+
getConfig: () => ({}),
|
|
14
|
+
loadConfig: () => ({}),
|
|
15
|
+
loadRawConfig: () => ({ ...rawConfigStore }),
|
|
16
|
+
saveRawConfig: (cfg: Record<string, unknown>) => {
|
|
17
|
+
rawConfigStore = { ...cfg };
|
|
18
|
+
},
|
|
19
|
+
saveConfig: () => {},
|
|
20
|
+
invalidateConfigCache: () => {},
|
|
21
|
+
}));
|
|
22
|
+
|
|
23
|
+
mock.module('../util/platform.js', () => ({
|
|
24
|
+
getRootDir: () => testDir,
|
|
25
|
+
getDataDir: () => testDir,
|
|
26
|
+
getIpcBlobDir: () => join(testDir, 'ipc-blobs'),
|
|
27
|
+
isMacOS: () => process.platform === 'darwin',
|
|
28
|
+
isLinux: () => process.platform === 'linux',
|
|
29
|
+
isWindows: () => process.platform === 'win32',
|
|
30
|
+
getSocketPath: () => join(testDir, 'test.sock'),
|
|
31
|
+
getPidPath: () => join(testDir, 'test.pid'),
|
|
32
|
+
getDbPath: () => join(testDir, 'test.db'),
|
|
33
|
+
getLogPath: () => join(testDir, 'test.log'),
|
|
34
|
+
ensureDataDir: () => {},
|
|
35
|
+
readHttpToken: () => undefined,
|
|
36
|
+
}));
|
|
37
|
+
|
|
38
|
+
mock.module('../util/logger.js', () => ({
|
|
39
|
+
getLogger: () => ({
|
|
40
|
+
info: () => {},
|
|
41
|
+
warn: () => {},
|
|
42
|
+
error: () => {},
|
|
43
|
+
debug: () => {},
|
|
44
|
+
trace: () => {},
|
|
45
|
+
fatal: () => {},
|
|
46
|
+
isDebug: () => false,
|
|
47
|
+
child: () => ({
|
|
48
|
+
info: () => {},
|
|
49
|
+
warn: () => {},
|
|
50
|
+
error: () => {},
|
|
51
|
+
debug: () => {},
|
|
52
|
+
isDebug: () => false,
|
|
53
|
+
}),
|
|
54
|
+
}),
|
|
55
|
+
}));
|
|
56
|
+
|
|
57
|
+
// Mock secure key storage
|
|
58
|
+
let secureKeyStore: Record<string, string> = {};
|
|
59
|
+
let setSecureKeyOverride: ((account: string, value: string) => boolean) | null = null;
|
|
60
|
+
|
|
61
|
+
mock.module('../security/secure-keys.js', () => ({
|
|
62
|
+
getSecureKey: (account: string) => secureKeyStore[account] ?? undefined,
|
|
63
|
+
setSecureKey: (account: string, value: string) => {
|
|
64
|
+
if (setSecureKeyOverride) return setSecureKeyOverride(account, value);
|
|
65
|
+
secureKeyStore[account] = value;
|
|
66
|
+
return true;
|
|
67
|
+
},
|
|
68
|
+
deleteSecureKey: (account: string) => {
|
|
69
|
+
if (account in secureKeyStore) {
|
|
70
|
+
delete secureKeyStore[account];
|
|
71
|
+
return true;
|
|
72
|
+
}
|
|
73
|
+
return false;
|
|
74
|
+
},
|
|
75
|
+
listSecureKeys: () => Object.keys(secureKeyStore),
|
|
76
|
+
getBackendType: () => 'encrypted',
|
|
77
|
+
isDowngradedFromKeychain: () => false,
|
|
78
|
+
_resetBackend: () => {},
|
|
79
|
+
_setBackend: () => {},
|
|
80
|
+
}));
|
|
81
|
+
|
|
82
|
+
// Mock credential metadata store
|
|
83
|
+
let credentialMetadataStore: Array<{ service: string; field: string; accountInfo?: string }> = [];
|
|
84
|
+
const deletedMetadata: Array<{ service: string; field: string }> = [];
|
|
85
|
+
|
|
86
|
+
mock.module('../tools/credentials/metadata-store.js', () => ({
|
|
87
|
+
getCredentialMetadata: (service: string, field: string) =>
|
|
88
|
+
credentialMetadataStore.find((m) => m.service === service && m.field === field) ?? undefined,
|
|
89
|
+
upsertCredentialMetadata: (service: string, field: string, policy?: Record<string, unknown>) => {
|
|
90
|
+
const existing = credentialMetadataStore.find((m) => m.service === service && m.field === field);
|
|
91
|
+
if (existing) {
|
|
92
|
+
if (policy?.accountInfo !== undefined) existing.accountInfo = policy.accountInfo as string;
|
|
93
|
+
return existing;
|
|
94
|
+
}
|
|
95
|
+
const record = { service, field, accountInfo: policy?.accountInfo as string | undefined };
|
|
96
|
+
credentialMetadataStore.push(record);
|
|
97
|
+
return record;
|
|
98
|
+
},
|
|
99
|
+
deleteCredentialMetadata: (service: string, field: string) => {
|
|
100
|
+
deletedMetadata.push({ service, field });
|
|
101
|
+
const idx = credentialMetadataStore.findIndex((m) => m.service === service && m.field === field);
|
|
102
|
+
if (idx !== -1) {
|
|
103
|
+
credentialMetadataStore.splice(idx, 1);
|
|
104
|
+
return true;
|
|
105
|
+
}
|
|
106
|
+
return false;
|
|
107
|
+
},
|
|
108
|
+
listCredentialMetadata: () => credentialMetadataStore,
|
|
109
|
+
assertMetadataWritable: () => {},
|
|
110
|
+
_setMetadataPath: () => {},
|
|
111
|
+
}));
|
|
112
|
+
|
|
113
|
+
// Mock fetch for Telegram getMe API validation
|
|
114
|
+
let _fetchMock: ((url: string | URL | Request) => Promise<Response>) | null = null;
|
|
115
|
+
const originalFetch = globalThis.fetch;
|
|
116
|
+
|
|
117
|
+
import { handleTelegramConfig } from '../daemon/handlers/config.js';
|
|
118
|
+
import type { HandlerContext } from '../daemon/handlers.js';
|
|
119
|
+
import type {
|
|
120
|
+
TelegramConfigRequest,
|
|
121
|
+
ServerMessage,
|
|
122
|
+
} from '../daemon/ipc-contract.js';
|
|
123
|
+
import { DebouncerMap } from '../util/debounce.js';
|
|
124
|
+
|
|
125
|
+
function createTestContext(): { ctx: HandlerContext; sent: ServerMessage[] } {
|
|
126
|
+
const sent: ServerMessage[] = [];
|
|
127
|
+
const ctx: HandlerContext = {
|
|
128
|
+
sessions: new Map(),
|
|
129
|
+
socketToSession: new Map(),
|
|
130
|
+
cuSessions: new Map(),
|
|
131
|
+
socketToCuSession: new Map(),
|
|
132
|
+
cuObservationParseSequence: new Map(),
|
|
133
|
+
socketSandboxOverride: new Map(),
|
|
134
|
+
sharedRequestTimestamps: [],
|
|
135
|
+
debounceTimers: new DebouncerMap({ defaultDelayMs: 200 }),
|
|
136
|
+
suppressConfigReload: false,
|
|
137
|
+
setSuppressConfigReload: () => {},
|
|
138
|
+
updateConfigFingerprint: () => {},
|
|
139
|
+
send: (_socket, msg) => { sent.push(msg); },
|
|
140
|
+
broadcast: () => {},
|
|
141
|
+
clearAllSessions: () => 0,
|
|
142
|
+
getOrCreateSession: () => { throw new Error('not implemented'); },
|
|
143
|
+
touchSession: () => {},
|
|
144
|
+
};
|
|
145
|
+
return { ctx, sent };
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
describe('Telegram config handler', () => {
|
|
149
|
+
beforeEach(() => {
|
|
150
|
+
rawConfigStore = {};
|
|
151
|
+
secureKeyStore = {};
|
|
152
|
+
setSecureKeyOverride = null;
|
|
153
|
+
credentialMetadataStore = [];
|
|
154
|
+
deletedMetadata.length = 0;
|
|
155
|
+
_fetchMock = null;
|
|
156
|
+
globalThis.fetch = originalFetch;
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
test('get action returns correct state when not configured', async () => {
|
|
160
|
+
const msg: TelegramConfigRequest = {
|
|
161
|
+
type: 'telegram_config',
|
|
162
|
+
action: 'get',
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
const { ctx, sent } = createTestContext();
|
|
166
|
+
await handleTelegramConfig(msg, {} as net.Socket, ctx);
|
|
167
|
+
|
|
168
|
+
expect(sent).toHaveLength(1);
|
|
169
|
+
const res = sent[0] as { type: string; success: boolean; hasBotToken: boolean; connected: boolean; hasWebhookSecret: boolean };
|
|
170
|
+
expect(res.type).toBe('telegram_config_response');
|
|
171
|
+
expect(res.success).toBe(true);
|
|
172
|
+
expect(res.hasBotToken).toBe(false);
|
|
173
|
+
expect(res.connected).toBe(false);
|
|
174
|
+
expect(res.hasWebhookSecret).toBe(false);
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
test('get action returns correct state when configured', async () => {
|
|
178
|
+
secureKeyStore['credential:telegram:bot_token'] = 'test-bot-token';
|
|
179
|
+
secureKeyStore['credential:telegram:webhook_secret'] = 'test-webhook-secret';
|
|
180
|
+
credentialMetadataStore.push({
|
|
181
|
+
service: 'telegram',
|
|
182
|
+
field: 'bot_token',
|
|
183
|
+
accountInfo: 'my_test_bot',
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
const msg: TelegramConfigRequest = {
|
|
187
|
+
type: 'telegram_config',
|
|
188
|
+
action: 'get',
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
const { ctx, sent } = createTestContext();
|
|
192
|
+
await handleTelegramConfig(msg, {} as net.Socket, ctx);
|
|
193
|
+
|
|
194
|
+
expect(sent).toHaveLength(1);
|
|
195
|
+
const res = sent[0] as { type: string; success: boolean; hasBotToken: boolean; botUsername: string; connected: boolean; hasWebhookSecret: boolean };
|
|
196
|
+
expect(res.type).toBe('telegram_config_response');
|
|
197
|
+
expect(res.success).toBe(true);
|
|
198
|
+
expect(res.hasBotToken).toBe(true);
|
|
199
|
+
expect(res.botUsername).toBe('my_test_bot');
|
|
200
|
+
expect(res.connected).toBe(true);
|
|
201
|
+
expect(res.hasWebhookSecret).toBe(true);
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
test('set action validates token, stores credentials, returns success', async () => {
|
|
205
|
+
// Mock successful Telegram getMe response
|
|
206
|
+
globalThis.fetch = (async (url: string | URL | Request) => {
|
|
207
|
+
const urlStr = typeof url === 'string' ? url : url instanceof URL ? url.toString() : url.url;
|
|
208
|
+
if (urlStr.includes('api.telegram.org') && urlStr.includes('/getMe')) {
|
|
209
|
+
return new Response(JSON.stringify({
|
|
210
|
+
ok: true,
|
|
211
|
+
result: { id: 123456, is_bot: true, first_name: 'TestBot', username: 'test_bot' },
|
|
212
|
+
}), { status: 200 });
|
|
213
|
+
}
|
|
214
|
+
return originalFetch(url);
|
|
215
|
+
}) as typeof fetch;
|
|
216
|
+
|
|
217
|
+
const msg: TelegramConfigRequest = {
|
|
218
|
+
type: 'telegram_config',
|
|
219
|
+
action: 'set',
|
|
220
|
+
botToken: '123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11',
|
|
221
|
+
};
|
|
222
|
+
|
|
223
|
+
const { ctx, sent } = createTestContext();
|
|
224
|
+
await handleTelegramConfig(msg, {} as net.Socket, ctx);
|
|
225
|
+
|
|
226
|
+
expect(sent).toHaveLength(1);
|
|
227
|
+
const res = sent[0] as { type: string; success: boolean; hasBotToken: boolean; botUsername: string; connected: boolean; hasWebhookSecret: boolean };
|
|
228
|
+
expect(res.type).toBe('telegram_config_response');
|
|
229
|
+
expect(res.success).toBe(true);
|
|
230
|
+
expect(res.hasBotToken).toBe(true);
|
|
231
|
+
expect(res.botUsername).toBe('test_bot');
|
|
232
|
+
expect(res.connected).toBe(true);
|
|
233
|
+
expect(res.hasWebhookSecret).toBe(true);
|
|
234
|
+
|
|
235
|
+
// Verify token was stored
|
|
236
|
+
expect(secureKeyStore['credential:telegram:bot_token']).toBe('123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11');
|
|
237
|
+
// Verify webhook secret was generated
|
|
238
|
+
expect(secureKeyStore['credential:telegram:webhook_secret']).toBeDefined();
|
|
239
|
+
// Verify metadata was stored
|
|
240
|
+
expect(credentialMetadataStore.find((m) => m.service === 'telegram' && m.field === 'bot_token')?.accountInfo).toBe('test_bot');
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
test('set action with invalid token returns error', async () => {
|
|
244
|
+
// Mock failed Telegram getMe response
|
|
245
|
+
globalThis.fetch = (async (url: string | URL | Request) => {
|
|
246
|
+
const urlStr = typeof url === 'string' ? url : url instanceof URL ? url.toString() : url.url;
|
|
247
|
+
if (urlStr.includes('api.telegram.org') && urlStr.includes('/getMe')) {
|
|
248
|
+
return new Response(JSON.stringify({
|
|
249
|
+
ok: false,
|
|
250
|
+
error_code: 401,
|
|
251
|
+
description: 'Unauthorized',
|
|
252
|
+
}), { status: 401 });
|
|
253
|
+
}
|
|
254
|
+
return originalFetch(url);
|
|
255
|
+
}) as typeof fetch;
|
|
256
|
+
|
|
257
|
+
const msg: TelegramConfigRequest = {
|
|
258
|
+
type: 'telegram_config',
|
|
259
|
+
action: 'set',
|
|
260
|
+
botToken: 'invalid-token',
|
|
261
|
+
};
|
|
262
|
+
|
|
263
|
+
const { ctx, sent } = createTestContext();
|
|
264
|
+
await handleTelegramConfig(msg, {} as net.Socket, ctx);
|
|
265
|
+
|
|
266
|
+
expect(sent).toHaveLength(1);
|
|
267
|
+
const res = sent[0] as { type: string; success: boolean; error?: string };
|
|
268
|
+
expect(res.type).toBe('telegram_config_response');
|
|
269
|
+
expect(res.success).toBe(false);
|
|
270
|
+
expect(res.error).toContain('Telegram API validation failed');
|
|
271
|
+
|
|
272
|
+
// Verify token was NOT stored
|
|
273
|
+
expect(secureKeyStore['credential:telegram:bot_token']).toBeUndefined();
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
test('set action without botToken returns error when no token in secure storage', async () => {
|
|
277
|
+
const msg: TelegramConfigRequest = {
|
|
278
|
+
type: 'telegram_config',
|
|
279
|
+
action: 'set',
|
|
280
|
+
};
|
|
281
|
+
|
|
282
|
+
const { ctx, sent } = createTestContext();
|
|
283
|
+
await handleTelegramConfig(msg, {} as net.Socket, ctx);
|
|
284
|
+
|
|
285
|
+
expect(sent).toHaveLength(1);
|
|
286
|
+
const res = sent[0] as { type: string; success: boolean; error?: string };
|
|
287
|
+
expect(res.success).toBe(false);
|
|
288
|
+
expect(res.error).toContain('botToken is required');
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
test('set action without botToken falls back to secure storage', async () => {
|
|
292
|
+
// Pre-populate token in secure storage (as credential_store prompt would)
|
|
293
|
+
secureKeyStore['credential:telegram:bot_token'] = '123456:stored-token';
|
|
294
|
+
|
|
295
|
+
globalThis.fetch = (async (url: string | URL | Request) => {
|
|
296
|
+
const urlStr = typeof url === 'string' ? url : url instanceof URL ? url.toString() : url.url;
|
|
297
|
+
if (urlStr.includes('api.telegram.org') && urlStr.includes('/getMe')) {
|
|
298
|
+
// Verify the stored token is being used
|
|
299
|
+
expect(urlStr).toContain('123456:stored-token');
|
|
300
|
+
return new Response(JSON.stringify({
|
|
301
|
+
ok: true,
|
|
302
|
+
result: { id: 123456, is_bot: true, first_name: 'TestBot', username: 'stored_bot' },
|
|
303
|
+
}), { status: 200 });
|
|
304
|
+
}
|
|
305
|
+
return originalFetch(url);
|
|
306
|
+
}) as typeof fetch;
|
|
307
|
+
|
|
308
|
+
const msg: TelegramConfigRequest = {
|
|
309
|
+
type: 'telegram_config',
|
|
310
|
+
action: 'set',
|
|
311
|
+
// No botToken provided — should fall back to secure storage
|
|
312
|
+
};
|
|
313
|
+
|
|
314
|
+
const { ctx, sent } = createTestContext();
|
|
315
|
+
await handleTelegramConfig(msg, {} as net.Socket, ctx);
|
|
316
|
+
|
|
317
|
+
expect(sent).toHaveLength(1);
|
|
318
|
+
const res = sent[0] as { type: string; success: boolean; hasBotToken: boolean; botUsername: string; connected: boolean };
|
|
319
|
+
expect(res.success).toBe(true);
|
|
320
|
+
expect(res.hasBotToken).toBe(true);
|
|
321
|
+
expect(res.botUsername).toBe('stored_bot');
|
|
322
|
+
expect(res.connected).toBe(true);
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
test('clear action removes credentials', async () => {
|
|
326
|
+
secureKeyStore['credential:telegram:bot_token'] = 'test-bot-token';
|
|
327
|
+
secureKeyStore['credential:telegram:webhook_secret'] = 'test-webhook-secret';
|
|
328
|
+
credentialMetadataStore.push({
|
|
329
|
+
service: 'telegram',
|
|
330
|
+
field: 'bot_token',
|
|
331
|
+
accountInfo: 'my_test_bot',
|
|
332
|
+
});
|
|
333
|
+
credentialMetadataStore.push({
|
|
334
|
+
service: 'telegram',
|
|
335
|
+
field: 'webhook_secret',
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
const msg: TelegramConfigRequest = {
|
|
339
|
+
type: 'telegram_config',
|
|
340
|
+
action: 'clear',
|
|
341
|
+
};
|
|
342
|
+
|
|
343
|
+
const { ctx, sent } = createTestContext();
|
|
344
|
+
await handleTelegramConfig(msg, {} as net.Socket, ctx);
|
|
345
|
+
|
|
346
|
+
expect(sent).toHaveLength(1);
|
|
347
|
+
const res = sent[0] as { type: string; success: boolean; hasBotToken: boolean; connected: boolean; hasWebhookSecret: boolean };
|
|
348
|
+
expect(res.type).toBe('telegram_config_response');
|
|
349
|
+
expect(res.success).toBe(true);
|
|
350
|
+
expect(res.hasBotToken).toBe(false);
|
|
351
|
+
expect(res.connected).toBe(false);
|
|
352
|
+
expect(res.hasWebhookSecret).toBe(false);
|
|
353
|
+
|
|
354
|
+
// Verify everything was cleaned up
|
|
355
|
+
expect(secureKeyStore['credential:telegram:bot_token']).toBeUndefined();
|
|
356
|
+
expect(secureKeyStore['credential:telegram:webhook_secret']).toBeUndefined();
|
|
357
|
+
expect(deletedMetadata).toContainEqual({ service: 'telegram', field: 'bot_token' });
|
|
358
|
+
expect(deletedMetadata).toContainEqual({ service: 'telegram', field: 'webhook_secret' });
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
test('clear action is idempotent when no credentials exist', async () => {
|
|
362
|
+
const msg: TelegramConfigRequest = {
|
|
363
|
+
type: 'telegram_config',
|
|
364
|
+
action: 'clear',
|
|
365
|
+
};
|
|
366
|
+
|
|
367
|
+
const { ctx, sent } = createTestContext();
|
|
368
|
+
await handleTelegramConfig(msg, {} as net.Socket, ctx);
|
|
369
|
+
|
|
370
|
+
expect(sent).toHaveLength(1);
|
|
371
|
+
const res = sent[0] as { type: string; success: boolean; hasBotToken: boolean; connected: boolean; hasWebhookSecret: boolean };
|
|
372
|
+
expect(res.success).toBe(true);
|
|
373
|
+
expect(res.hasBotToken).toBe(false);
|
|
374
|
+
expect(res.connected).toBe(false);
|
|
375
|
+
expect(res.hasWebhookSecret).toBe(false);
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
test('set action preserves existing webhook secret', async () => {
|
|
379
|
+
// Pre-populate webhook secret
|
|
380
|
+
secureKeyStore['credential:telegram:webhook_secret'] = 'existing-secret';
|
|
381
|
+
|
|
382
|
+
globalThis.fetch = (async (url: string | URL | Request) => {
|
|
383
|
+
const urlStr = typeof url === 'string' ? url : url instanceof URL ? url.toString() : url.url;
|
|
384
|
+
if (urlStr.includes('api.telegram.org') && urlStr.includes('/getMe')) {
|
|
385
|
+
return new Response(JSON.stringify({
|
|
386
|
+
ok: true,
|
|
387
|
+
result: { id: 123456, is_bot: true, first_name: 'TestBot', username: 'test_bot' },
|
|
388
|
+
}), { status: 200 });
|
|
389
|
+
}
|
|
390
|
+
return originalFetch(url);
|
|
391
|
+
}) as typeof fetch;
|
|
392
|
+
|
|
393
|
+
const msg: TelegramConfigRequest = {
|
|
394
|
+
type: 'telegram_config',
|
|
395
|
+
action: 'set',
|
|
396
|
+
botToken: '123456:valid-token',
|
|
397
|
+
};
|
|
398
|
+
|
|
399
|
+
const { ctx, sent } = createTestContext();
|
|
400
|
+
await handleTelegramConfig(msg, {} as net.Socket, ctx);
|
|
401
|
+
|
|
402
|
+
expect(sent).toHaveLength(1);
|
|
403
|
+
const res = sent[0] as { type: string; success: boolean; hasWebhookSecret: boolean };
|
|
404
|
+
expect(res.success).toBe(true);
|
|
405
|
+
expect(res.hasWebhookSecret).toBe(true);
|
|
406
|
+
|
|
407
|
+
// Existing webhook secret should not be overwritten
|
|
408
|
+
expect(secureKeyStore['credential:telegram:webhook_secret']).toBe('existing-secret');
|
|
409
|
+
});
|
|
410
|
+
|
|
411
|
+
test('set action upserts webhook_secret metadata even when secret already exists', async () => {
|
|
412
|
+
// Pre-populate webhook secret WITHOUT metadata to simulate lost/corrupted metadata
|
|
413
|
+
secureKeyStore['credential:telegram:webhook_secret'] = 'existing-secret';
|
|
414
|
+
|
|
415
|
+
globalThis.fetch = (async (url: string | URL | Request) => {
|
|
416
|
+
const urlStr = typeof url === 'string' ? url : url instanceof URL ? url.toString() : url.url;
|
|
417
|
+
if (urlStr.includes('api.telegram.org') && urlStr.includes('/getMe')) {
|
|
418
|
+
return new Response(JSON.stringify({
|
|
419
|
+
ok: true,
|
|
420
|
+
result: { id: 123456, is_bot: true, first_name: 'TestBot', username: 'test_bot' },
|
|
421
|
+
}), { status: 200 });
|
|
422
|
+
}
|
|
423
|
+
return originalFetch(url);
|
|
424
|
+
}) as typeof fetch;
|
|
425
|
+
|
|
426
|
+
const msg: TelegramConfigRequest = {
|
|
427
|
+
type: 'telegram_config',
|
|
428
|
+
action: 'set',
|
|
429
|
+
botToken: '123456:valid-token',
|
|
430
|
+
};
|
|
431
|
+
|
|
432
|
+
const { ctx, sent } = createTestContext();
|
|
433
|
+
await handleTelegramConfig(msg, {} as net.Socket, ctx);
|
|
434
|
+
|
|
435
|
+
expect(sent).toHaveLength(1);
|
|
436
|
+
const res = sent[0] as { type: string; success: boolean };
|
|
437
|
+
expect(res.success).toBe(true);
|
|
438
|
+
|
|
439
|
+
// Metadata for webhook_secret should have been upserted even though the
|
|
440
|
+
// secret already existed (self-heal for lost/corrupted metadata)
|
|
441
|
+
const webhookMeta = credentialMetadataStore.find(
|
|
442
|
+
(m) => m.service === 'telegram' && m.field === 'webhook_secret',
|
|
443
|
+
);
|
|
444
|
+
expect(webhookMeta).toBeDefined();
|
|
445
|
+
});
|
|
446
|
+
|
|
447
|
+
test('set action fails when secure storage fails', async () => {
|
|
448
|
+
setSecureKeyOverride = () => false;
|
|
449
|
+
|
|
450
|
+
globalThis.fetch = (async (url: string | URL | Request) => {
|
|
451
|
+
const urlStr = typeof url === 'string' ? url : url instanceof URL ? url.toString() : url.url;
|
|
452
|
+
if (urlStr.includes('api.telegram.org') && urlStr.includes('/getMe')) {
|
|
453
|
+
return new Response(JSON.stringify({
|
|
454
|
+
ok: true,
|
|
455
|
+
result: { id: 123456, is_bot: true, first_name: 'TestBot', username: 'test_bot' },
|
|
456
|
+
}), { status: 200 });
|
|
457
|
+
}
|
|
458
|
+
return originalFetch(url);
|
|
459
|
+
}) as typeof fetch;
|
|
460
|
+
|
|
461
|
+
const msg: TelegramConfigRequest = {
|
|
462
|
+
type: 'telegram_config',
|
|
463
|
+
action: 'set',
|
|
464
|
+
botToken: '123456:valid-token',
|
|
465
|
+
};
|
|
466
|
+
|
|
467
|
+
const { ctx, sent } = createTestContext();
|
|
468
|
+
await handleTelegramConfig(msg, {} as net.Socket, ctx);
|
|
469
|
+
|
|
470
|
+
expect(sent).toHaveLength(1);
|
|
471
|
+
const res = sent[0] as { type: string; success: boolean; error?: string };
|
|
472
|
+
expect(res.success).toBe(false);
|
|
473
|
+
expect(res.error).toContain('Failed to store bot token');
|
|
474
|
+
});
|
|
475
|
+
|
|
476
|
+
test('unrecognized action returns error response', async () => {
|
|
477
|
+
const msg = {
|
|
478
|
+
type: 'telegram_config',
|
|
479
|
+
action: 'nonexistent_action',
|
|
480
|
+
} as unknown as TelegramConfigRequest;
|
|
481
|
+
|
|
482
|
+
const { ctx, sent } = createTestContext();
|
|
483
|
+
await handleTelegramConfig(msg, {} as net.Socket, ctx);
|
|
484
|
+
|
|
485
|
+
expect(sent).toHaveLength(1);
|
|
486
|
+
const res = sent[0] as { type: string; success: boolean; error?: string };
|
|
487
|
+
expect(res.type).toBe('telegram_config_response');
|
|
488
|
+
expect(res.success).toBe(false);
|
|
489
|
+
expect(res.error).toContain('Unknown action');
|
|
490
|
+
expect(res.error).toContain('nonexistent_action');
|
|
491
|
+
});
|
|
492
|
+
|
|
493
|
+
test('response messages never contain raw bot token values', async () => {
|
|
494
|
+
secureKeyStore['credential:telegram:bot_token'] = 'secret-bot-token-abc123';
|
|
495
|
+
secureKeyStore['credential:telegram:webhook_secret'] = 'secret-webhook-xyz789';
|
|
496
|
+
credentialMetadataStore.push({
|
|
497
|
+
service: 'telegram',
|
|
498
|
+
field: 'bot_token',
|
|
499
|
+
accountInfo: 'my_test_bot',
|
|
500
|
+
});
|
|
501
|
+
|
|
502
|
+
const msg: TelegramConfigRequest = {
|
|
503
|
+
type: 'telegram_config',
|
|
504
|
+
action: 'get',
|
|
505
|
+
};
|
|
506
|
+
|
|
507
|
+
const { ctx, sent } = createTestContext();
|
|
508
|
+
await handleTelegramConfig(msg, {} as net.Socket, ctx);
|
|
509
|
+
|
|
510
|
+
expect(sent).toHaveLength(1);
|
|
511
|
+
const responseStr = JSON.stringify(sent[0]);
|
|
512
|
+
// No raw credential values should leak into the response
|
|
513
|
+
expect(responseStr).not.toContain('secret-bot-token-abc123');
|
|
514
|
+
expect(responseStr).not.toContain('secret-webhook-xyz789');
|
|
515
|
+
});
|
|
516
|
+
|
|
517
|
+
test('set action handles getMe returning unexpected response', async () => {
|
|
518
|
+
globalThis.fetch = (async (url: string | URL | Request) => {
|
|
519
|
+
const urlStr = typeof url === 'string' ? url : url instanceof URL ? url.toString() : url.url;
|
|
520
|
+
if (urlStr.includes('api.telegram.org') && urlStr.includes('/getMe')) {
|
|
521
|
+
return new Response(JSON.stringify({
|
|
522
|
+
ok: true,
|
|
523
|
+
result: { id: 123456, is_bot: true, first_name: 'TestBot' },
|
|
524
|
+
// username is missing
|
|
525
|
+
}), { status: 200 });
|
|
526
|
+
}
|
|
527
|
+
return originalFetch(url);
|
|
528
|
+
}) as typeof fetch;
|
|
529
|
+
|
|
530
|
+
const msg: TelegramConfigRequest = {
|
|
531
|
+
type: 'telegram_config',
|
|
532
|
+
action: 'set',
|
|
533
|
+
botToken: '123456:valid-token',
|
|
534
|
+
};
|
|
535
|
+
|
|
536
|
+
const { ctx, sent } = createTestContext();
|
|
537
|
+
await handleTelegramConfig(msg, {} as net.Socket, ctx);
|
|
538
|
+
|
|
539
|
+
expect(sent).toHaveLength(1);
|
|
540
|
+
const res = sent[0] as { type: string; success: boolean; error?: string };
|
|
541
|
+
expect(res.success).toBe(false);
|
|
542
|
+
expect(res.error).toContain('unexpected response');
|
|
543
|
+
});
|
|
544
|
+
|
|
545
|
+
test('set action handles network error during getMe', async () => {
|
|
546
|
+
const tgToken = ['123456789', ':', 'ABCDefGHIJklmnopQRSTuvwxyz012345678'].join('');
|
|
547
|
+
globalThis.fetch = (async () => {
|
|
548
|
+
const err = new Error('Network error: ECONNREFUSED') as Error & { path?: string; code?: string };
|
|
549
|
+
err.path = `https://api.telegram.org/bot${tgToken}/getMe`;
|
|
550
|
+
err.code = 'ConnectionRefused';
|
|
551
|
+
throw err;
|
|
552
|
+
}) as unknown as typeof fetch;
|
|
553
|
+
|
|
554
|
+
const msg: TelegramConfigRequest = {
|
|
555
|
+
type: 'telegram_config',
|
|
556
|
+
action: 'set',
|
|
557
|
+
botToken: '123456:valid-token',
|
|
558
|
+
};
|
|
559
|
+
|
|
560
|
+
const { ctx, sent } = createTestContext();
|
|
561
|
+
await handleTelegramConfig(msg, {} as net.Socket, ctx);
|
|
562
|
+
|
|
563
|
+
expect(sent).toHaveLength(1);
|
|
564
|
+
const res = sent[0] as { type: string; success: boolean; error?: string };
|
|
565
|
+
expect(res.success).toBe(false);
|
|
566
|
+
expect(res.error).toContain('Failed to validate bot token');
|
|
567
|
+
expect(res.error).toContain('ECONNREFUSED');
|
|
568
|
+
expect(res.error).toContain('[REDACTED]');
|
|
569
|
+
expect(res.error).not.toContain(tgToken);
|
|
570
|
+
});
|
|
571
|
+
|
|
572
|
+
test('get action reports connected only when both bot_token and webhook_secret exist', async () => {
|
|
573
|
+
// Only bot_token, no webhook_secret — should NOT be connected
|
|
574
|
+
secureKeyStore['credential:telegram:bot_token'] = 'test-bot-token';
|
|
575
|
+
|
|
576
|
+
const { ctx, sent } = createTestContext();
|
|
577
|
+
await handleTelegramConfig(
|
|
578
|
+
{ type: 'telegram_config', action: 'get' },
|
|
579
|
+
{} as net.Socket,
|
|
580
|
+
ctx,
|
|
581
|
+
);
|
|
582
|
+
|
|
583
|
+
expect(sent).toHaveLength(1);
|
|
584
|
+
const res = sent[0] as { type: string; success: boolean; hasBotToken: boolean; connected: boolean; hasWebhookSecret: boolean };
|
|
585
|
+
expect(res.success).toBe(true);
|
|
586
|
+
expect(res.hasBotToken).toBe(true);
|
|
587
|
+
expect(res.hasWebhookSecret).toBe(false);
|
|
588
|
+
expect(res.connected).toBe(false);
|
|
589
|
+
});
|
|
590
|
+
|
|
591
|
+
test('set action rolls back bot token when webhook secret storage fails', async () => {
|
|
592
|
+
// Let bot token storage succeed but webhook secret storage fail
|
|
593
|
+
setSecureKeyOverride = (account: string, value: string) => {
|
|
594
|
+
if (account === 'credential:telegram:webhook_secret') return false;
|
|
595
|
+
secureKeyStore[account] = value;
|
|
596
|
+
return true;
|
|
597
|
+
};
|
|
598
|
+
|
|
599
|
+
globalThis.fetch = (async (url: string | URL | Request) => {
|
|
600
|
+
const urlStr = typeof url === 'string' ? url : url instanceof URL ? url.toString() : url.url;
|
|
601
|
+
if (urlStr.includes('api.telegram.org') && urlStr.includes('/getMe')) {
|
|
602
|
+
return new Response(JSON.stringify({
|
|
603
|
+
ok: true,
|
|
604
|
+
result: { id: 123456, is_bot: true, first_name: 'TestBot', username: 'test_bot' },
|
|
605
|
+
}), { status: 200 });
|
|
606
|
+
}
|
|
607
|
+
return originalFetch(url);
|
|
608
|
+
}) as typeof fetch;
|
|
609
|
+
|
|
610
|
+
const msg: TelegramConfigRequest = {
|
|
611
|
+
type: 'telegram_config',
|
|
612
|
+
action: 'set',
|
|
613
|
+
botToken: '123456:valid-token',
|
|
614
|
+
};
|
|
615
|
+
|
|
616
|
+
const { ctx, sent } = createTestContext();
|
|
617
|
+
await handleTelegramConfig(msg, {} as net.Socket, ctx);
|
|
618
|
+
|
|
619
|
+
expect(sent).toHaveLength(1);
|
|
620
|
+
const res = sent[0] as { type: string; success: boolean; hasBotToken: boolean; connected: boolean; hasWebhookSecret: boolean; error?: string };
|
|
621
|
+
expect(res.success).toBe(false);
|
|
622
|
+
expect(res.error).toBe('Failed to store webhook secret');
|
|
623
|
+
expect(res.hasBotToken).toBe(false);
|
|
624
|
+
expect(res.connected).toBe(false);
|
|
625
|
+
expect(res.hasWebhookSecret).toBe(false);
|
|
626
|
+
|
|
627
|
+
// Bot token should have been rolled back
|
|
628
|
+
expect(secureKeyStore['credential:telegram:bot_token']).toBeUndefined();
|
|
629
|
+
// Metadata should have been cleaned up
|
|
630
|
+
expect(credentialMetadataStore.find((m) => m.service === 'telegram' && m.field === 'bot_token')).toBeUndefined();
|
|
631
|
+
});
|
|
632
|
+
|
|
633
|
+
test('set action preserves storage-fallback token when webhook secret storage fails', async () => {
|
|
634
|
+
// Pre-populate token in secure storage (simulating credential_store prompt)
|
|
635
|
+
secureKeyStore['credential:telegram:bot_token'] = '123456:stored-token';
|
|
636
|
+
|
|
637
|
+
// Let bot token storage succeed but webhook secret storage fail
|
|
638
|
+
setSecureKeyOverride = (account: string, value: string) => {
|
|
639
|
+
if (account === 'credential:telegram:webhook_secret') return false;
|
|
640
|
+
secureKeyStore[account] = value;
|
|
641
|
+
return true;
|
|
642
|
+
};
|
|
643
|
+
|
|
644
|
+
globalThis.fetch = (async (url: string | URL | Request) => {
|
|
645
|
+
const urlStr = typeof url === 'string' ? url : url instanceof URL ? url.toString() : url.url;
|
|
646
|
+
if (urlStr.includes('api.telegram.org') && urlStr.includes('/getMe')) {
|
|
647
|
+
return new Response(JSON.stringify({
|
|
648
|
+
ok: true,
|
|
649
|
+
result: { id: 123456, is_bot: true, first_name: 'TestBot', username: 'stored_bot' },
|
|
650
|
+
}), { status: 200 });
|
|
651
|
+
}
|
|
652
|
+
return originalFetch(url);
|
|
653
|
+
}) as typeof fetch;
|
|
654
|
+
|
|
655
|
+
const msg: TelegramConfigRequest = {
|
|
656
|
+
type: 'telegram_config',
|
|
657
|
+
action: 'set',
|
|
658
|
+
// No botToken — falls back to secure storage
|
|
659
|
+
};
|
|
660
|
+
|
|
661
|
+
const { ctx, sent } = createTestContext();
|
|
662
|
+
await handleTelegramConfig(msg, {} as net.Socket, ctx);
|
|
663
|
+
|
|
664
|
+
expect(sent).toHaveLength(1);
|
|
665
|
+
const res = sent[0] as { type: string; success: boolean; hasBotToken: boolean; connected: boolean; hasWebhookSecret: boolean; error?: string };
|
|
666
|
+
expect(res.success).toBe(false);
|
|
667
|
+
expect(res.error).toBe('Failed to store webhook secret');
|
|
668
|
+
// The pre-existing token from storage should NOT be deleted
|
|
669
|
+
expect(res.hasBotToken).toBe(true);
|
|
670
|
+
expect(res.connected).toBe(false);
|
|
671
|
+
expect(res.hasWebhookSecret).toBe(false);
|
|
672
|
+
|
|
673
|
+
// Token should still exist in secure storage
|
|
674
|
+
expect(secureKeyStore['credential:telegram:bot_token']).toBe('123456:stored-token');
|
|
675
|
+
});
|
|
676
|
+
|
|
677
|
+
test('clear action deregisters webhook before deleting credentials', async () => {
|
|
678
|
+
secureKeyStore['credential:telegram:bot_token'] = 'test-bot-token';
|
|
679
|
+
secureKeyStore['credential:telegram:webhook_secret'] = 'test-webhook-secret';
|
|
680
|
+
credentialMetadataStore.push({ service: 'telegram', field: 'bot_token', accountInfo: 'my_test_bot' });
|
|
681
|
+
credentialMetadataStore.push({ service: 'telegram', field: 'webhook_secret' });
|
|
682
|
+
|
|
683
|
+
let deleteWebhookCalled = false;
|
|
684
|
+
let deleteWebhookUrl = '';
|
|
685
|
+
globalThis.fetch = (async (url: string | URL | Request) => {
|
|
686
|
+
const urlStr = typeof url === 'string' ? url : url instanceof URL ? url.toString() : url.url;
|
|
687
|
+
if (urlStr.includes('/deleteWebhook')) {
|
|
688
|
+
deleteWebhookCalled = true;
|
|
689
|
+
deleteWebhookUrl = urlStr;
|
|
690
|
+
return new Response(JSON.stringify({ ok: true, result: true }), { status: 200 });
|
|
691
|
+
}
|
|
692
|
+
return originalFetch(url);
|
|
693
|
+
}) as typeof fetch;
|
|
694
|
+
|
|
695
|
+
const { ctx, sent } = createTestContext();
|
|
696
|
+
await handleTelegramConfig(
|
|
697
|
+
{ type: 'telegram_config', action: 'clear' },
|
|
698
|
+
{} as net.Socket,
|
|
699
|
+
ctx,
|
|
700
|
+
);
|
|
701
|
+
|
|
702
|
+
expect(sent).toHaveLength(1);
|
|
703
|
+
const res = sent[0] as { type: string; success: boolean };
|
|
704
|
+
expect(res.success).toBe(true);
|
|
705
|
+
|
|
706
|
+
// deleteWebhook should have been called with the bot token
|
|
707
|
+
expect(deleteWebhookCalled).toBe(true);
|
|
708
|
+
expect(deleteWebhookUrl).toContain('test-bot-token');
|
|
709
|
+
|
|
710
|
+
// Credentials should still be cleaned up
|
|
711
|
+
expect(secureKeyStore['credential:telegram:bot_token']).toBeUndefined();
|
|
712
|
+
expect(secureKeyStore['credential:telegram:webhook_secret']).toBeUndefined();
|
|
713
|
+
});
|
|
714
|
+
|
|
715
|
+
test('clear action proceeds even when webhook deregistration fails', async () => {
|
|
716
|
+
const tgToken = ['123456789', ':', 'ABCDefGHIJklmnopQRSTuvwxyz012345678'].join('');
|
|
717
|
+
secureKeyStore['credential:telegram:bot_token'] = tgToken;
|
|
718
|
+
secureKeyStore['credential:telegram:webhook_secret'] = 'test-webhook-secret';
|
|
719
|
+
|
|
720
|
+
globalThis.fetch = (async () => {
|
|
721
|
+
const err = new Error('Network error') as Error & { path?: string; code?: string };
|
|
722
|
+
err.path = `https://api.telegram.org/bot${tgToken}/deleteWebhook`;
|
|
723
|
+
err.code = 'ConnectionRefused';
|
|
724
|
+
throw err;
|
|
725
|
+
}) as unknown as typeof fetch;
|
|
726
|
+
|
|
727
|
+
const { ctx, sent } = createTestContext();
|
|
728
|
+
await handleTelegramConfig(
|
|
729
|
+
{ type: 'telegram_config', action: 'clear' },
|
|
730
|
+
{} as net.Socket,
|
|
731
|
+
ctx,
|
|
732
|
+
);
|
|
733
|
+
|
|
734
|
+
expect(sent).toHaveLength(1);
|
|
735
|
+
const res = sent[0] as { type: string; success: boolean; hasBotToken: boolean; connected: boolean };
|
|
736
|
+
expect(res.success).toBe(true);
|
|
737
|
+
expect(res.hasBotToken).toBe(false);
|
|
738
|
+
expect(res.connected).toBe(false);
|
|
739
|
+
|
|
740
|
+
// Credentials should still be cleaned up despite webhook deregistration failure
|
|
741
|
+
expect(secureKeyStore['credential:telegram:bot_token']).toBeUndefined();
|
|
742
|
+
expect(secureKeyStore['credential:telegram:webhook_secret']).toBeUndefined();
|
|
743
|
+
});
|
|
744
|
+
|
|
745
|
+
test('set_commands action registers default commands', async () => {
|
|
746
|
+
secureKeyStore['credential:telegram:bot_token'] = 'test-bot-token';
|
|
747
|
+
secureKeyStore['credential:telegram:webhook_secret'] = 'test-webhook-secret';
|
|
748
|
+
|
|
749
|
+
let setCommandsCalled = false;
|
|
750
|
+
let setCommandsBody: unknown = null;
|
|
751
|
+
globalThis.fetch = (async (url: string | URL | Request, init?: RequestInit) => {
|
|
752
|
+
const urlStr = typeof url === 'string' ? url : url instanceof URL ? url.toString() : url.url;
|
|
753
|
+
if (urlStr.includes('/setMyCommands')) {
|
|
754
|
+
setCommandsCalled = true;
|
|
755
|
+
setCommandsBody = JSON.parse(init?.body as string);
|
|
756
|
+
return new Response(JSON.stringify({ ok: true, result: true }), { status: 200 });
|
|
757
|
+
}
|
|
758
|
+
return originalFetch(url);
|
|
759
|
+
}) as typeof fetch;
|
|
760
|
+
|
|
761
|
+
const msg: TelegramConfigRequest = {
|
|
762
|
+
type: 'telegram_config',
|
|
763
|
+
action: 'set_commands',
|
|
764
|
+
};
|
|
765
|
+
|
|
766
|
+
const { ctx, sent } = createTestContext();
|
|
767
|
+
await handleTelegramConfig(msg, {} as net.Socket, ctx);
|
|
768
|
+
|
|
769
|
+
expect(sent).toHaveLength(1);
|
|
770
|
+
const res = sent[0] as { type: string; success: boolean; hasBotToken: boolean; connected: boolean };
|
|
771
|
+
expect(res.success).toBe(true);
|
|
772
|
+
expect(res.hasBotToken).toBe(true);
|
|
773
|
+
expect(res.connected).toBe(true);
|
|
774
|
+
expect(setCommandsCalled).toBe(true);
|
|
775
|
+
expect((setCommandsBody as { commands: Array<{ command: string; description: string }> }).commands).toEqual([
|
|
776
|
+
{ command: 'new', description: 'Start a new conversation' },
|
|
777
|
+
]);
|
|
778
|
+
});
|
|
779
|
+
|
|
780
|
+
test('set_commands action with custom commands', async () => {
|
|
781
|
+
secureKeyStore['credential:telegram:bot_token'] = 'test-bot-token';
|
|
782
|
+
secureKeyStore['credential:telegram:webhook_secret'] = 'test-webhook-secret';
|
|
783
|
+
|
|
784
|
+
let setCommandsBody: unknown = null;
|
|
785
|
+
globalThis.fetch = (async (url: string | URL | Request, init?: RequestInit) => {
|
|
786
|
+
const urlStr = typeof url === 'string' ? url : url instanceof URL ? url.toString() : url.url;
|
|
787
|
+
if (urlStr.includes('/setMyCommands')) {
|
|
788
|
+
setCommandsBody = JSON.parse(init?.body as string);
|
|
789
|
+
return new Response(JSON.stringify({ ok: true, result: true }), { status: 200 });
|
|
790
|
+
}
|
|
791
|
+
return originalFetch(url);
|
|
792
|
+
}) as typeof fetch;
|
|
793
|
+
|
|
794
|
+
const msg: TelegramConfigRequest = {
|
|
795
|
+
type: 'telegram_config',
|
|
796
|
+
action: 'set_commands',
|
|
797
|
+
commands: [
|
|
798
|
+
{ command: 'new', description: 'Start a new conversation' },
|
|
799
|
+
{ command: 'help', description: 'Show help' },
|
|
800
|
+
],
|
|
801
|
+
};
|
|
802
|
+
|
|
803
|
+
const { ctx, sent } = createTestContext();
|
|
804
|
+
await handleTelegramConfig(msg, {} as net.Socket, ctx);
|
|
805
|
+
|
|
806
|
+
expect(sent).toHaveLength(1);
|
|
807
|
+
const res = sent[0] as { type: string; success: boolean };
|
|
808
|
+
expect(res.success).toBe(true);
|
|
809
|
+
expect((setCommandsBody as { commands: Array<{ command: string; description: string }> }).commands).toEqual([
|
|
810
|
+
{ command: 'new', description: 'Start a new conversation' },
|
|
811
|
+
{ command: 'help', description: 'Show help' },
|
|
812
|
+
]);
|
|
813
|
+
});
|
|
814
|
+
|
|
815
|
+
test('set_commands action fails when no bot token is configured', async () => {
|
|
816
|
+
const msg: TelegramConfigRequest = {
|
|
817
|
+
type: 'telegram_config',
|
|
818
|
+
action: 'set_commands',
|
|
819
|
+
};
|
|
820
|
+
|
|
821
|
+
const { ctx, sent } = createTestContext();
|
|
822
|
+
await handleTelegramConfig(msg, {} as net.Socket, ctx);
|
|
823
|
+
|
|
824
|
+
expect(sent).toHaveLength(1);
|
|
825
|
+
const res = sent[0] as { type: string; success: boolean; error?: string };
|
|
826
|
+
expect(res.success).toBe(false);
|
|
827
|
+
expect(res.error).toContain('Bot token not configured');
|
|
828
|
+
});
|
|
829
|
+
|
|
830
|
+
test('set_commands action handles Telegram API error', async () => {
|
|
831
|
+
secureKeyStore['credential:telegram:bot_token'] = 'test-bot-token';
|
|
832
|
+
secureKeyStore['credential:telegram:webhook_secret'] = 'test-webhook-secret';
|
|
833
|
+
|
|
834
|
+
globalThis.fetch = (async (url: string | URL | Request) => {
|
|
835
|
+
const urlStr = typeof url === 'string' ? url : url instanceof URL ? url.toString() : url.url;
|
|
836
|
+
if (urlStr.includes('/setMyCommands')) {
|
|
837
|
+
return new Response(JSON.stringify({ ok: false, description: 'Bad Request' }), { status: 400 });
|
|
838
|
+
}
|
|
839
|
+
return originalFetch(url);
|
|
840
|
+
}) as typeof fetch;
|
|
841
|
+
|
|
842
|
+
const msg: TelegramConfigRequest = {
|
|
843
|
+
type: 'telegram_config',
|
|
844
|
+
action: 'set_commands',
|
|
845
|
+
};
|
|
846
|
+
|
|
847
|
+
const { ctx, sent } = createTestContext();
|
|
848
|
+
await handleTelegramConfig(msg, {} as net.Socket, ctx);
|
|
849
|
+
|
|
850
|
+
expect(sent).toHaveLength(1);
|
|
851
|
+
const res = sent[0] as { type: string; success: boolean; error?: string };
|
|
852
|
+
expect(res.success).toBe(false);
|
|
853
|
+
expect(res.error).toContain('Failed to set bot commands');
|
|
854
|
+
});
|
|
855
|
+
});
|