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,581 @@
|
|
|
1
|
+
import { describe, test, expect, mock, beforeEach, afterEach } 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(), 'ingress-reconcile-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
|
+
// readHttpToken return value — controlled per test
|
|
24
|
+
let httpTokenValue: string | null = null;
|
|
25
|
+
|
|
26
|
+
mock.module('../util/platform.js', () => ({
|
|
27
|
+
getRootDir: () => testDir,
|
|
28
|
+
getDataDir: () => testDir,
|
|
29
|
+
getIpcBlobDir: () => join(testDir, 'ipc-blobs'),
|
|
30
|
+
isMacOS: () => process.platform === 'darwin',
|
|
31
|
+
isLinux: () => process.platform === 'linux',
|
|
32
|
+
isWindows: () => process.platform === 'win32',
|
|
33
|
+
getSocketPath: () => join(testDir, 'test.sock'),
|
|
34
|
+
getPidPath: () => join(testDir, 'test.pid'),
|
|
35
|
+
getDbPath: () => join(testDir, 'test.db'),
|
|
36
|
+
getLogPath: () => join(testDir, 'test.log'),
|
|
37
|
+
ensureDataDir: () => {},
|
|
38
|
+
readHttpToken: () => httpTokenValue,
|
|
39
|
+
}));
|
|
40
|
+
|
|
41
|
+
mock.module('../util/logger.js', () => ({
|
|
42
|
+
getLogger: () => ({
|
|
43
|
+
info: () => {},
|
|
44
|
+
warn: () => {},
|
|
45
|
+
error: () => {},
|
|
46
|
+
debug: () => {},
|
|
47
|
+
trace: () => {},
|
|
48
|
+
fatal: () => {},
|
|
49
|
+
isDebug: () => false,
|
|
50
|
+
child: () => ({
|
|
51
|
+
info: () => {},
|
|
52
|
+
warn: () => {},
|
|
53
|
+
error: () => {},
|
|
54
|
+
debug: () => {},
|
|
55
|
+
isDebug: () => false,
|
|
56
|
+
}),
|
|
57
|
+
}),
|
|
58
|
+
}));
|
|
59
|
+
|
|
60
|
+
// Mock providers registry to avoid side effects
|
|
61
|
+
mock.module('../providers/registry.js', () => ({
|
|
62
|
+
initializeProviders: () => {},
|
|
63
|
+
}));
|
|
64
|
+
|
|
65
|
+
import { handleIngressConfig } from '../daemon/handlers/config.js';
|
|
66
|
+
import type { HandlerContext } from '../daemon/handlers/shared.js';
|
|
67
|
+
import type {
|
|
68
|
+
IngressConfigRequest,
|
|
69
|
+
ServerMessage,
|
|
70
|
+
} from '../daemon/ipc-contract.js';
|
|
71
|
+
import { DebouncerMap } from '../util/debounce.js';
|
|
72
|
+
|
|
73
|
+
// Capture fetch calls for reconcile trigger verification
|
|
74
|
+
interface ReconcileCall {
|
|
75
|
+
url: string;
|
|
76
|
+
method: string;
|
|
77
|
+
headers: Record<string, string>;
|
|
78
|
+
body: string;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
let reconcileCalls: ReconcileCall[] = [];
|
|
82
|
+
let fetchShouldFail = false;
|
|
83
|
+
const originalFetch = globalThis.fetch;
|
|
84
|
+
|
|
85
|
+
function createTestContext(): { ctx: HandlerContext; sent: ServerMessage[] } {
|
|
86
|
+
const sent: ServerMessage[] = [];
|
|
87
|
+
const ctx: HandlerContext = {
|
|
88
|
+
sessions: new Map(),
|
|
89
|
+
socketToSession: new Map(),
|
|
90
|
+
cuSessions: new Map(),
|
|
91
|
+
socketToCuSession: new Map(),
|
|
92
|
+
cuObservationParseSequence: new Map(),
|
|
93
|
+
socketSandboxOverride: new Map(),
|
|
94
|
+
sharedRequestTimestamps: [],
|
|
95
|
+
debounceTimers: new DebouncerMap({ defaultDelayMs: 200 }),
|
|
96
|
+
suppressConfigReload: false,
|
|
97
|
+
setSuppressConfigReload: () => {},
|
|
98
|
+
updateConfigFingerprint: () => {},
|
|
99
|
+
send: (_socket, msg) => { sent.push(msg); },
|
|
100
|
+
broadcast: () => {},
|
|
101
|
+
clearAllSessions: () => 0,
|
|
102
|
+
getOrCreateSession: () => { throw new Error('not implemented'); },
|
|
103
|
+
touchSession: () => {},
|
|
104
|
+
};
|
|
105
|
+
return { ctx, sent };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
describe('Ingress reconcile trigger in handleIngressConfig', () => {
|
|
109
|
+
let savedIngressEnv: string | undefined;
|
|
110
|
+
let savedGatewayBaseEnv: string | undefined;
|
|
111
|
+
let savedGatewayPortEnv: string | undefined;
|
|
112
|
+
|
|
113
|
+
beforeEach(() => {
|
|
114
|
+
rawConfigStore = {};
|
|
115
|
+
httpTokenValue = null;
|
|
116
|
+
reconcileCalls = [];
|
|
117
|
+
fetchShouldFail = false;
|
|
118
|
+
|
|
119
|
+
savedIngressEnv = process.env.INGRESS_PUBLIC_BASE_URL;
|
|
120
|
+
savedGatewayBaseEnv = process.env.GATEWAY_INTERNAL_BASE_URL;
|
|
121
|
+
savedGatewayPortEnv = process.env.GATEWAY_PORT;
|
|
122
|
+
delete process.env.INGRESS_PUBLIC_BASE_URL;
|
|
123
|
+
delete process.env.GATEWAY_INTERNAL_BASE_URL;
|
|
124
|
+
delete process.env.GATEWAY_PORT;
|
|
125
|
+
|
|
126
|
+
// Install fetch interceptor
|
|
127
|
+
globalThis.fetch = (async (url: string | URL | Request, init?: RequestInit) => {
|
|
128
|
+
const urlStr = typeof url === 'string' ? url : url instanceof URL ? url.toString() : url.url;
|
|
129
|
+
if (urlStr.includes('/internal/telegram/reconcile')) {
|
|
130
|
+
const headers: Record<string, string> = {};
|
|
131
|
+
if (init?.headers) {
|
|
132
|
+
const h = init.headers as Record<string, string>;
|
|
133
|
+
for (const [k, v] of Object.entries(h)) {
|
|
134
|
+
headers[k] = v;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
reconcileCalls.push({
|
|
138
|
+
url: urlStr,
|
|
139
|
+
method: init?.method ?? 'GET',
|
|
140
|
+
headers,
|
|
141
|
+
body: (init?.body as string) ?? '',
|
|
142
|
+
});
|
|
143
|
+
if (fetchShouldFail) {
|
|
144
|
+
throw new Error('ECONNREFUSED: gateway unavailable');
|
|
145
|
+
}
|
|
146
|
+
return new Response(JSON.stringify({ ok: true }), { status: 200 });
|
|
147
|
+
}
|
|
148
|
+
return originalFetch(url, init);
|
|
149
|
+
}) as typeof fetch;
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
afterEach(() => {
|
|
153
|
+
globalThis.fetch = originalFetch;
|
|
154
|
+
if (savedIngressEnv !== undefined) {
|
|
155
|
+
process.env.INGRESS_PUBLIC_BASE_URL = savedIngressEnv;
|
|
156
|
+
} else {
|
|
157
|
+
delete process.env.INGRESS_PUBLIC_BASE_URL;
|
|
158
|
+
}
|
|
159
|
+
if (savedGatewayBaseEnv !== undefined) {
|
|
160
|
+
process.env.GATEWAY_INTERNAL_BASE_URL = savedGatewayBaseEnv;
|
|
161
|
+
} else {
|
|
162
|
+
delete process.env.GATEWAY_INTERNAL_BASE_URL;
|
|
163
|
+
}
|
|
164
|
+
if (savedGatewayPortEnv !== undefined) {
|
|
165
|
+
process.env.GATEWAY_PORT = savedGatewayPortEnv;
|
|
166
|
+
} else {
|
|
167
|
+
delete process.env.GATEWAY_PORT;
|
|
168
|
+
}
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
// ── Token present/missing behavior ──────────────────────────────────────
|
|
172
|
+
|
|
173
|
+
test('skips reconcile trigger when no HTTP bearer token is available', async () => {
|
|
174
|
+
httpTokenValue = null;
|
|
175
|
+
|
|
176
|
+
const msg: IngressConfigRequest = {
|
|
177
|
+
type: 'ingress_config',
|
|
178
|
+
action: 'set',
|
|
179
|
+
publicBaseUrl: 'https://my-tunnel.example.com',
|
|
180
|
+
enabled: true,
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
const { ctx, sent } = createTestContext();
|
|
184
|
+
handleIngressConfig(msg, {} as net.Socket, ctx);
|
|
185
|
+
|
|
186
|
+
// Allow any pending microtasks to flush
|
|
187
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
188
|
+
|
|
189
|
+
expect(sent).toHaveLength(1);
|
|
190
|
+
const res = sent[0] as { type: string; success: boolean };
|
|
191
|
+
expect(res.success).toBe(true);
|
|
192
|
+
|
|
193
|
+
// No reconcile call should have been made
|
|
194
|
+
expect(reconcileCalls).toHaveLength(0);
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
test('triggers reconcile when HTTP bearer token is available', async () => {
|
|
198
|
+
httpTokenValue = 'test-bearer-token';
|
|
199
|
+
|
|
200
|
+
const msg: IngressConfigRequest = {
|
|
201
|
+
type: 'ingress_config',
|
|
202
|
+
action: 'set',
|
|
203
|
+
publicBaseUrl: 'https://my-tunnel.example.com',
|
|
204
|
+
enabled: true,
|
|
205
|
+
};
|
|
206
|
+
|
|
207
|
+
const { ctx, sent } = createTestContext();
|
|
208
|
+
handleIngressConfig(msg, {} as net.Socket, ctx);
|
|
209
|
+
|
|
210
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
211
|
+
|
|
212
|
+
expect(sent).toHaveLength(1);
|
|
213
|
+
expect(reconcileCalls).toHaveLength(1);
|
|
214
|
+
expect(reconcileCalls[0]!.headers['Authorization']).toBe('Bearer test-bearer-token');
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
// ── Request payload normalization ───────────────────────────────────────
|
|
218
|
+
|
|
219
|
+
test('sends ingressPublicBaseUrl in reconcile body when URL is set', async () => {
|
|
220
|
+
httpTokenValue = 'test-token';
|
|
221
|
+
|
|
222
|
+
const msg: IngressConfigRequest = {
|
|
223
|
+
type: 'ingress_config',
|
|
224
|
+
action: 'set',
|
|
225
|
+
publicBaseUrl: 'https://my-tunnel.example.com',
|
|
226
|
+
enabled: true,
|
|
227
|
+
};
|
|
228
|
+
|
|
229
|
+
const { ctx } = createTestContext();
|
|
230
|
+
handleIngressConfig(msg, {} as net.Socket, ctx);
|
|
231
|
+
|
|
232
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
233
|
+
|
|
234
|
+
expect(reconcileCalls).toHaveLength(1);
|
|
235
|
+
const body = JSON.parse(reconcileCalls[0]!.body);
|
|
236
|
+
expect(body.ingressPublicBaseUrl).toBe('https://my-tunnel.example.com');
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
test('sends POST to /internal/telegram/reconcile with correct content type', async () => {
|
|
240
|
+
httpTokenValue = 'test-token';
|
|
241
|
+
|
|
242
|
+
const msg: IngressConfigRequest = {
|
|
243
|
+
type: 'ingress_config',
|
|
244
|
+
action: 'set',
|
|
245
|
+
publicBaseUrl: 'https://example.com',
|
|
246
|
+
enabled: true,
|
|
247
|
+
};
|
|
248
|
+
|
|
249
|
+
const { ctx } = createTestContext();
|
|
250
|
+
handleIngressConfig(msg, {} as net.Socket, ctx);
|
|
251
|
+
|
|
252
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
253
|
+
|
|
254
|
+
expect(reconcileCalls).toHaveLength(1);
|
|
255
|
+
expect(reconcileCalls[0]!.method).toBe('POST');
|
|
256
|
+
expect(reconcileCalls[0]!.headers['Content-Type']).toBe('application/json');
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
test('normalizes trailing slashes in publicBaseUrl before sending reconcile', async () => {
|
|
260
|
+
httpTokenValue = 'test-token';
|
|
261
|
+
|
|
262
|
+
const msg: IngressConfigRequest = {
|
|
263
|
+
type: 'ingress_config',
|
|
264
|
+
action: 'set',
|
|
265
|
+
publicBaseUrl: 'https://my-tunnel.example.com///',
|
|
266
|
+
enabled: true,
|
|
267
|
+
};
|
|
268
|
+
|
|
269
|
+
const { ctx } = createTestContext();
|
|
270
|
+
handleIngressConfig(msg, {} as net.Socket, ctx);
|
|
271
|
+
|
|
272
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
273
|
+
|
|
274
|
+
expect(reconcileCalls).toHaveLength(1);
|
|
275
|
+
const body = JSON.parse(reconcileCalls[0]!.body);
|
|
276
|
+
// The handler trims trailing slashes before storing and propagating
|
|
277
|
+
expect(body.ingressPublicBaseUrl).toBe('https://my-tunnel.example.com');
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
test('uses GATEWAY_INTERNAL_BASE_URL when set', async () => {
|
|
281
|
+
httpTokenValue = 'test-token';
|
|
282
|
+
process.env.GATEWAY_INTERNAL_BASE_URL = 'http://custom-gateway:9999';
|
|
283
|
+
|
|
284
|
+
const msg: IngressConfigRequest = {
|
|
285
|
+
type: 'ingress_config',
|
|
286
|
+
action: 'set',
|
|
287
|
+
publicBaseUrl: 'https://example.com',
|
|
288
|
+
enabled: true,
|
|
289
|
+
};
|
|
290
|
+
|
|
291
|
+
const { ctx } = createTestContext();
|
|
292
|
+
handleIngressConfig(msg, {} as net.Socket, ctx);
|
|
293
|
+
|
|
294
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
295
|
+
|
|
296
|
+
expect(reconcileCalls).toHaveLength(1);
|
|
297
|
+
expect(reconcileCalls[0]!.url).toBe('http://custom-gateway:9999/internal/telegram/reconcile');
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
test('defaults to localhost:7830 when no GATEWAY env vars set', async () => {
|
|
301
|
+
httpTokenValue = 'test-token';
|
|
302
|
+
|
|
303
|
+
const msg: IngressConfigRequest = {
|
|
304
|
+
type: 'ingress_config',
|
|
305
|
+
action: 'set',
|
|
306
|
+
publicBaseUrl: 'https://example.com',
|
|
307
|
+
enabled: true,
|
|
308
|
+
};
|
|
309
|
+
|
|
310
|
+
const { ctx } = createTestContext();
|
|
311
|
+
handleIngressConfig(msg, {} as net.Socket, ctx);
|
|
312
|
+
|
|
313
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
314
|
+
|
|
315
|
+
expect(reconcileCalls).toHaveLength(1);
|
|
316
|
+
expect(reconcileCalls[0]!.url).toBe('http://127.0.0.1:7830/internal/telegram/reconcile');
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
test('uses GATEWAY_PORT when GATEWAY_INTERNAL_BASE_URL is not set', async () => {
|
|
320
|
+
httpTokenValue = 'test-token';
|
|
321
|
+
process.env.GATEWAY_PORT = '8888';
|
|
322
|
+
|
|
323
|
+
const msg: IngressConfigRequest = {
|
|
324
|
+
type: 'ingress_config',
|
|
325
|
+
action: 'set',
|
|
326
|
+
publicBaseUrl: 'https://example.com',
|
|
327
|
+
enabled: true,
|
|
328
|
+
};
|
|
329
|
+
|
|
330
|
+
const { ctx } = createTestContext();
|
|
331
|
+
handleIngressConfig(msg, {} as net.Socket, ctx);
|
|
332
|
+
|
|
333
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
334
|
+
|
|
335
|
+
expect(reconcileCalls).toHaveLength(1);
|
|
336
|
+
expect(reconcileCalls[0]!.url).toBe('http://127.0.0.1:8888/internal/telegram/reconcile');
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
// ── Non-fatal failure behavior ──────────────────────────────────────────
|
|
340
|
+
|
|
341
|
+
test('reconcile failure does not cause handleIngressConfig to fail', async () => {
|
|
342
|
+
httpTokenValue = 'test-token';
|
|
343
|
+
fetchShouldFail = true;
|
|
344
|
+
|
|
345
|
+
const msg: IngressConfigRequest = {
|
|
346
|
+
type: 'ingress_config',
|
|
347
|
+
action: 'set',
|
|
348
|
+
publicBaseUrl: 'https://my-tunnel.example.com',
|
|
349
|
+
enabled: true,
|
|
350
|
+
};
|
|
351
|
+
|
|
352
|
+
const { ctx, sent } = createTestContext();
|
|
353
|
+
handleIngressConfig(msg, {} as net.Socket, ctx);
|
|
354
|
+
|
|
355
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
356
|
+
|
|
357
|
+
// The handler should still succeed even though reconcile fetch threw
|
|
358
|
+
expect(sent).toHaveLength(1);
|
|
359
|
+
const res = sent[0] as { type: string; success: boolean; enabled: boolean; publicBaseUrl: string };
|
|
360
|
+
expect(res.type).toBe('ingress_config_response');
|
|
361
|
+
expect(res.success).toBe(true);
|
|
362
|
+
expect(res.enabled).toBe(true);
|
|
363
|
+
expect(res.publicBaseUrl).toBe('https://my-tunnel.example.com');
|
|
364
|
+
|
|
365
|
+
// The reconcile attempt was still made (it just failed gracefully)
|
|
366
|
+
expect(reconcileCalls).toHaveLength(1);
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
test('response is sent before reconcile fetch completes', async () => {
|
|
370
|
+
httpTokenValue = 'test-token';
|
|
371
|
+
|
|
372
|
+
// Track timing: response should be sent before fetch resolves
|
|
373
|
+
let fetchResolved = false;
|
|
374
|
+
const originalMockFetch = globalThis.fetch;
|
|
375
|
+
globalThis.fetch = (async (url: string | URL | Request, init?: RequestInit) => {
|
|
376
|
+
const urlStr = typeof url === 'string' ? url : url instanceof URL ? url.toString() : url.url;
|
|
377
|
+
if (urlStr.includes('/internal/telegram/reconcile')) {
|
|
378
|
+
// Delay the response to simulate network latency
|
|
379
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
380
|
+
fetchResolved = true;
|
|
381
|
+
reconcileCalls.push({
|
|
382
|
+
url: urlStr,
|
|
383
|
+
method: init?.method ?? 'GET',
|
|
384
|
+
headers: {},
|
|
385
|
+
body: (init?.body as string) ?? '',
|
|
386
|
+
});
|
|
387
|
+
return new Response(JSON.stringify({ ok: true }), { status: 200 });
|
|
388
|
+
}
|
|
389
|
+
return originalFetch(url, init);
|
|
390
|
+
}) as typeof fetch;
|
|
391
|
+
|
|
392
|
+
const msg: IngressConfigRequest = {
|
|
393
|
+
type: 'ingress_config',
|
|
394
|
+
action: 'set',
|
|
395
|
+
publicBaseUrl: 'https://example.com',
|
|
396
|
+
enabled: true,
|
|
397
|
+
};
|
|
398
|
+
|
|
399
|
+
const { ctx, sent } = createTestContext();
|
|
400
|
+
handleIngressConfig(msg, {} as net.Socket, ctx);
|
|
401
|
+
|
|
402
|
+
// Response should be available immediately (before fetch resolves)
|
|
403
|
+
expect(sent).toHaveLength(1);
|
|
404
|
+
expect(fetchResolved).toBe(false);
|
|
405
|
+
|
|
406
|
+
// Clean up: wait for the delayed fetch to complete
|
|
407
|
+
await new Promise((r) => setTimeout(r, 150));
|
|
408
|
+
globalThis.fetch = originalMockFetch;
|
|
409
|
+
});
|
|
410
|
+
|
|
411
|
+
// ── Set flow ────────────────────────────────────────────────────────────
|
|
412
|
+
|
|
413
|
+
test('set action with enabled=true and URL triggers reconcile with the URL', async () => {
|
|
414
|
+
httpTokenValue = 'test-token';
|
|
415
|
+
|
|
416
|
+
const msg: IngressConfigRequest = {
|
|
417
|
+
type: 'ingress_config',
|
|
418
|
+
action: 'set',
|
|
419
|
+
publicBaseUrl: 'https://set-test.example.com',
|
|
420
|
+
enabled: true,
|
|
421
|
+
};
|
|
422
|
+
|
|
423
|
+
const { ctx, sent } = createTestContext();
|
|
424
|
+
handleIngressConfig(msg, {} as net.Socket, ctx);
|
|
425
|
+
|
|
426
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
427
|
+
|
|
428
|
+
expect(sent).toHaveLength(1);
|
|
429
|
+
const res = sent[0] as { type: string; success: boolean; enabled: boolean };
|
|
430
|
+
expect(res.success).toBe(true);
|
|
431
|
+
expect(res.enabled).toBe(true);
|
|
432
|
+
|
|
433
|
+
expect(reconcileCalls).toHaveLength(1);
|
|
434
|
+
const body = JSON.parse(reconcileCalls[0]!.body);
|
|
435
|
+
expect(body.ingressPublicBaseUrl).toBe('https://set-test.example.com');
|
|
436
|
+
});
|
|
437
|
+
|
|
438
|
+
// ── Clear flow ──────────────────────────────────────────────────────────
|
|
439
|
+
|
|
440
|
+
test('set action with empty URL and enabled=true (clear URL) still triggers reconcile', async () => {
|
|
441
|
+
httpTokenValue = 'test-token';
|
|
442
|
+
|
|
443
|
+
const msg: IngressConfigRequest = {
|
|
444
|
+
type: 'ingress_config',
|
|
445
|
+
action: 'set',
|
|
446
|
+
publicBaseUrl: '',
|
|
447
|
+
enabled: true,
|
|
448
|
+
};
|
|
449
|
+
|
|
450
|
+
const { ctx, sent } = createTestContext();
|
|
451
|
+
handleIngressConfig(msg, {} as net.Socket, ctx);
|
|
452
|
+
|
|
453
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
454
|
+
|
|
455
|
+
expect(sent).toHaveLength(1);
|
|
456
|
+
const res = sent[0] as { type: string; success: boolean };
|
|
457
|
+
expect(res.success).toBe(true);
|
|
458
|
+
|
|
459
|
+
// Reconcile is called unconditionally on set action
|
|
460
|
+
// When no URL and no env fallback, effectiveUrl is undefined so
|
|
461
|
+
// the reconcile body should send empty string (clears the gateway's URL)
|
|
462
|
+
expect(reconcileCalls).toHaveLength(1);
|
|
463
|
+
const body = JSON.parse(reconcileCalls[0]!.body);
|
|
464
|
+
expect(body.ingressPublicBaseUrl).toBe('');
|
|
465
|
+
});
|
|
466
|
+
|
|
467
|
+
// ── Disable flow ────────────────────────────────────────────────────────
|
|
468
|
+
|
|
469
|
+
test('set action with enabled=false triggers reconcile with empty URL', async () => {
|
|
470
|
+
httpTokenValue = 'test-token';
|
|
471
|
+
|
|
472
|
+
const msg: IngressConfigRequest = {
|
|
473
|
+
type: 'ingress_config',
|
|
474
|
+
action: 'set',
|
|
475
|
+
publicBaseUrl: 'https://disabled-test.example.com',
|
|
476
|
+
enabled: false,
|
|
477
|
+
};
|
|
478
|
+
|
|
479
|
+
const { ctx, sent } = createTestContext();
|
|
480
|
+
handleIngressConfig(msg, {} as net.Socket, ctx);
|
|
481
|
+
|
|
482
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
483
|
+
|
|
484
|
+
expect(sent).toHaveLength(1);
|
|
485
|
+
const res = sent[0] as { type: string; success: boolean; enabled: boolean };
|
|
486
|
+
expect(res.success).toBe(true);
|
|
487
|
+
expect(res.enabled).toBe(false);
|
|
488
|
+
|
|
489
|
+
// Reconcile should still fire (to clear gateway's in-memory URL)
|
|
490
|
+
expect(reconcileCalls).toHaveLength(1);
|
|
491
|
+
const body = JSON.parse(reconcileCalls[0]!.body);
|
|
492
|
+
// When disabled, effectiveUrl is undefined, so the body sends empty string
|
|
493
|
+
expect(body.ingressPublicBaseUrl).toBe('');
|
|
494
|
+
});
|
|
495
|
+
|
|
496
|
+
test('disabling ingress removes INGRESS_PUBLIC_BASE_URL env var', () => {
|
|
497
|
+
httpTokenValue = 'test-token';
|
|
498
|
+
|
|
499
|
+
// First set ingress to populate env var
|
|
500
|
+
process.env.INGRESS_PUBLIC_BASE_URL = 'https://should-be-removed.example.com';
|
|
501
|
+
|
|
502
|
+
const msg: IngressConfigRequest = {
|
|
503
|
+
type: 'ingress_config',
|
|
504
|
+
action: 'set',
|
|
505
|
+
publicBaseUrl: 'https://disabled-test.example.com',
|
|
506
|
+
enabled: false,
|
|
507
|
+
};
|
|
508
|
+
|
|
509
|
+
const { ctx } = createTestContext();
|
|
510
|
+
handleIngressConfig(msg, {} as net.Socket, ctx);
|
|
511
|
+
|
|
512
|
+
// Env var should be cleared
|
|
513
|
+
expect(process.env.INGRESS_PUBLIC_BASE_URL).toBeUndefined();
|
|
514
|
+
});
|
|
515
|
+
|
|
516
|
+
// ── Get action does not trigger reconcile ───────────────────────────────
|
|
517
|
+
|
|
518
|
+
test('get action does not trigger reconcile', async () => {
|
|
519
|
+
httpTokenValue = 'test-token';
|
|
520
|
+
rawConfigStore = {
|
|
521
|
+
ingress: { publicBaseUrl: 'https://existing.example.com', enabled: true },
|
|
522
|
+
};
|
|
523
|
+
|
|
524
|
+
const msg: IngressConfigRequest = {
|
|
525
|
+
type: 'ingress_config',
|
|
526
|
+
action: 'get',
|
|
527
|
+
};
|
|
528
|
+
|
|
529
|
+
const { ctx, sent } = createTestContext();
|
|
530
|
+
handleIngressConfig(msg, {} as net.Socket, ctx);
|
|
531
|
+
|
|
532
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
533
|
+
|
|
534
|
+
expect(sent).toHaveLength(1);
|
|
535
|
+
const res = sent[0] as { type: string; success: boolean; publicBaseUrl: string };
|
|
536
|
+
expect(res.success).toBe(true);
|
|
537
|
+
expect(res.publicBaseUrl).toBe('https://existing.example.com');
|
|
538
|
+
|
|
539
|
+
// No reconcile should have been triggered for a get action
|
|
540
|
+
expect(reconcileCalls).toHaveLength(0);
|
|
541
|
+
});
|
|
542
|
+
|
|
543
|
+
// ── Env var propagation ─────────────────────────────────────────────────
|
|
544
|
+
|
|
545
|
+
test('set action propagates URL to process.env when enabled', () => {
|
|
546
|
+
httpTokenValue = 'test-token';
|
|
547
|
+
|
|
548
|
+
const msg: IngressConfigRequest = {
|
|
549
|
+
type: 'ingress_config',
|
|
550
|
+
action: 'set',
|
|
551
|
+
publicBaseUrl: 'https://env-propagation.example.com',
|
|
552
|
+
enabled: true,
|
|
553
|
+
};
|
|
554
|
+
|
|
555
|
+
const { ctx } = createTestContext();
|
|
556
|
+
handleIngressConfig(msg, {} as net.Socket, ctx);
|
|
557
|
+
|
|
558
|
+
expect(process.env.INGRESS_PUBLIC_BASE_URL).toBe('https://env-propagation.example.com');
|
|
559
|
+
});
|
|
560
|
+
|
|
561
|
+
test('reconcile uses effective URL from process.env (not raw value)', async () => {
|
|
562
|
+
httpTokenValue = 'test-token';
|
|
563
|
+
|
|
564
|
+
const msg: IngressConfigRequest = {
|
|
565
|
+
type: 'ingress_config',
|
|
566
|
+
action: 'set',
|
|
567
|
+
publicBaseUrl: 'https://effective-url.example.com',
|
|
568
|
+
enabled: true,
|
|
569
|
+
};
|
|
570
|
+
|
|
571
|
+
const { ctx } = createTestContext();
|
|
572
|
+
handleIngressConfig(msg, {} as net.Socket, ctx);
|
|
573
|
+
|
|
574
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
575
|
+
|
|
576
|
+
expect(reconcileCalls).toHaveLength(1);
|
|
577
|
+
const body = JSON.parse(reconcileCalls[0]!.body);
|
|
578
|
+
// The URL in the reconcile body should match the effective env var
|
|
579
|
+
expect(body.ingressPublicBaseUrl).toBe('https://effective-url.example.com');
|
|
580
|
+
});
|
|
581
|
+
});
|