@vellumai/assistant 0.5.9 → 0.5.10
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/AGENTS.md +1 -1
- package/ARCHITECTURE.md +8 -8
- package/README.md +1 -1
- package/docs/architecture/integrations.md +4 -4
- package/docs/architecture/keychain-broker.md +17 -18
- package/docs/architecture/security.md +5 -5
- package/package.json +1 -1
- package/src/__tests__/credentials-cli.test.ts +3 -3
- package/src/__tests__/workspace-migration-015-migrate-credentials-to-keychain.test.ts +5 -238
- package/src/__tests__/workspace-migration-016-migrate-credentials-from-keychain.test.ts +5 -206
- package/src/__tests__/workspace-migrations-runner.test.ts +15 -7
- package/src/cli/commands/credentials.ts +4 -4
- package/src/cli/commands/oauth/apps.ts +3 -3
- package/src/daemon/lifecycle.ts +2 -3
- package/src/memory/migrations/validate-migration-state.ts +14 -1
- package/src/runtime/routes/settings-routes.ts +1 -1
- package/src/workspace/migrations/015-migrate-credentials-to-keychain.ts +13 -148
- package/src/workspace/migrations/016-migrate-credentials-from-keychain.ts +7 -145
- package/src/workspace/migrations/AGENTS.md +11 -0
- package/src/workspace/migrations/runner.ts +16 -6
- package/src/workspace/migrations/types.ts +7 -0
- package/src/__tests__/keychain-broker-client.test.ts +0 -800
- package/src/security/keychain-broker-client.ts +0 -446
|
@@ -1,800 +0,0 @@
|
|
|
1
|
-
import { randomBytes } from "node:crypto";
|
|
2
|
-
import { mkdirSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
-
import type { Server } from "node:net";
|
|
4
|
-
import { createServer } from "node:net";
|
|
5
|
-
import { tmpdir } from "node:os";
|
|
6
|
-
import { join } from "node:path";
|
|
7
|
-
import {
|
|
8
|
-
afterAll,
|
|
9
|
-
afterEach,
|
|
10
|
-
beforeAll,
|
|
11
|
-
beforeEach,
|
|
12
|
-
describe,
|
|
13
|
-
expect,
|
|
14
|
-
mock,
|
|
15
|
-
test,
|
|
16
|
-
} from "bun:test";
|
|
17
|
-
|
|
18
|
-
// ---------------------------------------------------------------------------
|
|
19
|
-
// Mock logger to silence output
|
|
20
|
-
// ---------------------------------------------------------------------------
|
|
21
|
-
|
|
22
|
-
mock.module("../util/logger.js", () => ({
|
|
23
|
-
getLogger: () =>
|
|
24
|
-
new Proxy({} as Record<string, unknown>, {
|
|
25
|
-
get: () => () => {},
|
|
26
|
-
}),
|
|
27
|
-
}));
|
|
28
|
-
|
|
29
|
-
// ---------------------------------------------------------------------------
|
|
30
|
-
// Test fixtures
|
|
31
|
-
// ---------------------------------------------------------------------------
|
|
32
|
-
|
|
33
|
-
const TEST_DIR = join(
|
|
34
|
-
tmpdir(),
|
|
35
|
-
`vellum-broker-test-${randomBytes(4).toString("hex")}`,
|
|
36
|
-
);
|
|
37
|
-
const TOKEN_DIR = join(TEST_DIR, ".vellum", "protected");
|
|
38
|
-
const TOKEN_PATH = join(TOKEN_DIR, "keychain-broker.token");
|
|
39
|
-
const SOCKET_PATH = join(TEST_DIR, ".vellum", "keychain-broker.sock");
|
|
40
|
-
const TEST_TOKEN = "test-auth-token-abc123";
|
|
41
|
-
|
|
42
|
-
// ---------------------------------------------------------------------------
|
|
43
|
-
// Helpers
|
|
44
|
-
// ---------------------------------------------------------------------------
|
|
45
|
-
|
|
46
|
-
/**
|
|
47
|
-
* Create a mock UDS server that speaks the broker protocol.
|
|
48
|
-
* Returns the server and a handler setter for customizing responses.
|
|
49
|
-
*/
|
|
50
|
-
function createMockBroker(): {
|
|
51
|
-
server: Server;
|
|
52
|
-
setHandler: (
|
|
53
|
-
fn: (request: Record<string, unknown>) => Record<string, unknown>,
|
|
54
|
-
) => void;
|
|
55
|
-
start: () => Promise<void>;
|
|
56
|
-
stop: () => Promise<void>;
|
|
57
|
-
} {
|
|
58
|
-
let handler: (
|
|
59
|
-
request: Record<string, unknown>,
|
|
60
|
-
) => Record<string, unknown> = () => ({ ok: true });
|
|
61
|
-
|
|
62
|
-
const connections = new Set<import("node:net").Socket>();
|
|
63
|
-
|
|
64
|
-
const server = createServer((conn) => {
|
|
65
|
-
connections.add(conn);
|
|
66
|
-
conn.on("close", () => connections.delete(conn));
|
|
67
|
-
let buffer = "";
|
|
68
|
-
conn.on("data", (chunk) => {
|
|
69
|
-
buffer += chunk.toString();
|
|
70
|
-
let idx: number;
|
|
71
|
-
while ((idx = buffer.indexOf("\n")) !== -1) {
|
|
72
|
-
const line = buffer.slice(0, idx).trim();
|
|
73
|
-
buffer = buffer.slice(idx + 1);
|
|
74
|
-
if (!line) continue;
|
|
75
|
-
try {
|
|
76
|
-
const request = JSON.parse(line);
|
|
77
|
-
const response = handler(request);
|
|
78
|
-
conn.write(JSON.stringify({ id: request.id, ...response }) + "\n");
|
|
79
|
-
} catch {
|
|
80
|
-
// Malformed request — ignore
|
|
81
|
-
}
|
|
82
|
-
}
|
|
83
|
-
});
|
|
84
|
-
});
|
|
85
|
-
|
|
86
|
-
return {
|
|
87
|
-
server,
|
|
88
|
-
setHandler: (fn) => {
|
|
89
|
-
handler = fn;
|
|
90
|
-
},
|
|
91
|
-
start: () =>
|
|
92
|
-
new Promise<void>((resolve) => {
|
|
93
|
-
server.listen(SOCKET_PATH, () => resolve());
|
|
94
|
-
}),
|
|
95
|
-
stop: () =>
|
|
96
|
-
new Promise<void>((resolve) => {
|
|
97
|
-
// Destroy active connections so server.close() can complete
|
|
98
|
-
for (const conn of connections) conn.destroy();
|
|
99
|
-
connections.clear();
|
|
100
|
-
server.close(() => resolve());
|
|
101
|
-
}),
|
|
102
|
-
};
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
// ---------------------------------------------------------------------------
|
|
106
|
-
// Setup / teardown
|
|
107
|
-
// ---------------------------------------------------------------------------
|
|
108
|
-
|
|
109
|
-
beforeAll(() => {
|
|
110
|
-
mkdirSync(TOKEN_DIR, { recursive: true });
|
|
111
|
-
});
|
|
112
|
-
|
|
113
|
-
beforeEach(() => {
|
|
114
|
-
// Clean up socket file from prior test
|
|
115
|
-
try {
|
|
116
|
-
rmSync(SOCKET_PATH, { force: true });
|
|
117
|
-
} catch {
|
|
118
|
-
/* ignore */
|
|
119
|
-
}
|
|
120
|
-
});
|
|
121
|
-
|
|
122
|
-
afterAll(() => {
|
|
123
|
-
rmSync(TEST_DIR, { recursive: true, force: true });
|
|
124
|
-
});
|
|
125
|
-
|
|
126
|
-
// ---------------------------------------------------------------------------
|
|
127
|
-
// Mock platform to point getRootDir at our test directory
|
|
128
|
-
// ---------------------------------------------------------------------------
|
|
129
|
-
|
|
130
|
-
mock.module("../util/platform.js", () => ({
|
|
131
|
-
getRootDir: () => join(TEST_DIR, ".vellum"),
|
|
132
|
-
isMacOS: () => true,
|
|
133
|
-
getPlatformName: () => "darwin",
|
|
134
|
-
}));
|
|
135
|
-
|
|
136
|
-
// Import after mocks are set up
|
|
137
|
-
const { createBrokerClient } =
|
|
138
|
-
await import("../security/keychain-broker-client.js");
|
|
139
|
-
|
|
140
|
-
// ---------------------------------------------------------------------------
|
|
141
|
-
// Tests
|
|
142
|
-
// ---------------------------------------------------------------------------
|
|
143
|
-
|
|
144
|
-
describe("keychain-broker-client", () => {
|
|
145
|
-
// -----------------------------------------------------------------------
|
|
146
|
-
// isAvailable()
|
|
147
|
-
// -----------------------------------------------------------------------
|
|
148
|
-
describe("isAvailable", () => {
|
|
149
|
-
test("returns false when socket file does not exist", () => {
|
|
150
|
-
writeFileSync(TOKEN_PATH, TEST_TOKEN);
|
|
151
|
-
const client = createBrokerClient();
|
|
152
|
-
expect(client.isAvailable()).toBe(false);
|
|
153
|
-
});
|
|
154
|
-
|
|
155
|
-
test("returns false when token file does not exist", () => {
|
|
156
|
-
// Create the socket file so that check passes
|
|
157
|
-
writeFileSync(SOCKET_PATH, "");
|
|
158
|
-
try {
|
|
159
|
-
rmSync(TOKEN_PATH, { force: true });
|
|
160
|
-
} catch {
|
|
161
|
-
/* ignore */
|
|
162
|
-
}
|
|
163
|
-
const client = createBrokerClient();
|
|
164
|
-
expect(client.isAvailable()).toBe(false);
|
|
165
|
-
});
|
|
166
|
-
|
|
167
|
-
test("returns true when both socket file and token file exist", () => {
|
|
168
|
-
writeFileSync(SOCKET_PATH, "");
|
|
169
|
-
writeFileSync(TOKEN_PATH, TEST_TOKEN);
|
|
170
|
-
const client = createBrokerClient();
|
|
171
|
-
expect(client.isAvailable()).toBe(true);
|
|
172
|
-
});
|
|
173
|
-
});
|
|
174
|
-
|
|
175
|
-
// -----------------------------------------------------------------------
|
|
176
|
-
// Request/response serialization
|
|
177
|
-
// -----------------------------------------------------------------------
|
|
178
|
-
describe("request/response", () => {
|
|
179
|
-
let broker: ReturnType<typeof createMockBroker>;
|
|
180
|
-
|
|
181
|
-
beforeEach(async () => {
|
|
182
|
-
writeFileSync(TOKEN_PATH, TEST_TOKEN);
|
|
183
|
-
broker = createMockBroker();
|
|
184
|
-
});
|
|
185
|
-
|
|
186
|
-
afterEach(async () => {
|
|
187
|
-
await broker.stop();
|
|
188
|
-
});
|
|
189
|
-
|
|
190
|
-
test("ping returns pong from broker", async () => {
|
|
191
|
-
broker.setHandler((req) => {
|
|
192
|
-
expect(req.v).toBe(1);
|
|
193
|
-
if (req.method === "broker.ping") {
|
|
194
|
-
return { ok: true, result: { pong: true } };
|
|
195
|
-
}
|
|
196
|
-
return {
|
|
197
|
-
ok: false,
|
|
198
|
-
error: { code: "INVALID_REQUEST", message: "unknown method" },
|
|
199
|
-
};
|
|
200
|
-
});
|
|
201
|
-
await broker.start();
|
|
202
|
-
|
|
203
|
-
const client = createBrokerClient();
|
|
204
|
-
const result = await client.ping();
|
|
205
|
-
expect(result).toEqual({ pong: true });
|
|
206
|
-
});
|
|
207
|
-
|
|
208
|
-
test("get returns found result from broker", async () => {
|
|
209
|
-
broker.setHandler((req) => {
|
|
210
|
-
expect(req.v).toBe(1);
|
|
211
|
-
const params = req.params as { account?: string } | undefined;
|
|
212
|
-
if (req.method === "key.get" && params?.account === "my-key") {
|
|
213
|
-
return { ok: true, result: { found: true, value: "secret-value" } };
|
|
214
|
-
}
|
|
215
|
-
return {
|
|
216
|
-
ok: false,
|
|
217
|
-
error: { code: "INVALID_REQUEST", message: "not found" },
|
|
218
|
-
};
|
|
219
|
-
});
|
|
220
|
-
await broker.start();
|
|
221
|
-
|
|
222
|
-
const client = createBrokerClient();
|
|
223
|
-
const result = await client.get("my-key");
|
|
224
|
-
expect(result).toEqual({ found: true, value: "secret-value" });
|
|
225
|
-
});
|
|
226
|
-
|
|
227
|
-
test("get returns not-found result from broker", async () => {
|
|
228
|
-
broker.setHandler((req) => {
|
|
229
|
-
expect(req.v).toBe(1);
|
|
230
|
-
if (req.method === "key.get") {
|
|
231
|
-
return { ok: true, result: { found: false } };
|
|
232
|
-
}
|
|
233
|
-
return {
|
|
234
|
-
ok: false,
|
|
235
|
-
error: { code: "INVALID_REQUEST", message: "bad" },
|
|
236
|
-
};
|
|
237
|
-
});
|
|
238
|
-
await broker.start();
|
|
239
|
-
|
|
240
|
-
const client = createBrokerClient();
|
|
241
|
-
const result = await client.get("missing-key");
|
|
242
|
-
expect(result).toEqual({ found: false, value: undefined });
|
|
243
|
-
});
|
|
244
|
-
|
|
245
|
-
test("set returns true on success", async () => {
|
|
246
|
-
broker.setHandler((req) => {
|
|
247
|
-
expect(req.v).toBe(1);
|
|
248
|
-
const params = req.params as
|
|
249
|
-
| { account?: string; value?: string }
|
|
250
|
-
| undefined;
|
|
251
|
-
if (
|
|
252
|
-
req.method === "key.set" &&
|
|
253
|
-
params?.account === "my-key" &&
|
|
254
|
-
params?.value === "new-value"
|
|
255
|
-
) {
|
|
256
|
-
return { ok: true, result: { stored: true } };
|
|
257
|
-
}
|
|
258
|
-
return {
|
|
259
|
-
ok: false,
|
|
260
|
-
error: { code: "INVALID_REQUEST", message: "failed" },
|
|
261
|
-
};
|
|
262
|
-
});
|
|
263
|
-
await broker.start();
|
|
264
|
-
|
|
265
|
-
const client = createBrokerClient();
|
|
266
|
-
const result = await client.set("my-key", "new-value");
|
|
267
|
-
expect(result).toEqual({ status: "ok" });
|
|
268
|
-
});
|
|
269
|
-
|
|
270
|
-
test("del returns true on success", async () => {
|
|
271
|
-
broker.setHandler((req) => {
|
|
272
|
-
expect(req.v).toBe(1);
|
|
273
|
-
const params = req.params as { account?: string } | undefined;
|
|
274
|
-
if (req.method === "key.delete" && params?.account === "my-key") {
|
|
275
|
-
return { ok: true, result: { deleted: true } };
|
|
276
|
-
}
|
|
277
|
-
return {
|
|
278
|
-
ok: false,
|
|
279
|
-
error: { code: "INVALID_REQUEST", message: "not found" },
|
|
280
|
-
};
|
|
281
|
-
});
|
|
282
|
-
await broker.start();
|
|
283
|
-
|
|
284
|
-
const client = createBrokerClient();
|
|
285
|
-
const result = await client.del("my-key");
|
|
286
|
-
expect(result).toBe(true);
|
|
287
|
-
});
|
|
288
|
-
|
|
289
|
-
test("list returns account names", async () => {
|
|
290
|
-
broker.setHandler((req) => {
|
|
291
|
-
expect(req.v).toBe(1);
|
|
292
|
-
if (req.method === "key.list") {
|
|
293
|
-
return {
|
|
294
|
-
ok: true,
|
|
295
|
-
result: { accounts: ["key-a", "key-b", "key-c"] },
|
|
296
|
-
};
|
|
297
|
-
}
|
|
298
|
-
return {
|
|
299
|
-
ok: false,
|
|
300
|
-
error: { code: "INVALID_REQUEST", message: "failed" },
|
|
301
|
-
};
|
|
302
|
-
});
|
|
303
|
-
await broker.start();
|
|
304
|
-
|
|
305
|
-
const client = createBrokerClient();
|
|
306
|
-
const result = await client.list();
|
|
307
|
-
expect(result).toEqual(["key-a", "key-b", "key-c"]);
|
|
308
|
-
});
|
|
309
|
-
|
|
310
|
-
test("sends auth token and v:1 with every request", async () => {
|
|
311
|
-
let receivedToken: unknown;
|
|
312
|
-
let receivedVersion: unknown;
|
|
313
|
-
broker.setHandler((req) => {
|
|
314
|
-
receivedToken = req.token;
|
|
315
|
-
receivedVersion = req.v;
|
|
316
|
-
return { ok: true, result: { pong: true } };
|
|
317
|
-
});
|
|
318
|
-
await broker.start();
|
|
319
|
-
|
|
320
|
-
const client = createBrokerClient();
|
|
321
|
-
await client.ping();
|
|
322
|
-
expect(receivedToken).toBe(TEST_TOKEN);
|
|
323
|
-
expect(receivedVersion).toBe(1);
|
|
324
|
-
});
|
|
325
|
-
});
|
|
326
|
-
|
|
327
|
-
// -----------------------------------------------------------------------
|
|
328
|
-
// Timeout handling
|
|
329
|
-
// -----------------------------------------------------------------------
|
|
330
|
-
describe("timeout", () => {
|
|
331
|
-
let broker: ReturnType<typeof createMockBroker>;
|
|
332
|
-
|
|
333
|
-
beforeEach(async () => {
|
|
334
|
-
writeFileSync(TOKEN_PATH, TEST_TOKEN);
|
|
335
|
-
broker = createMockBroker();
|
|
336
|
-
});
|
|
337
|
-
|
|
338
|
-
afterEach(async () => {
|
|
339
|
-
await broker.stop();
|
|
340
|
-
});
|
|
341
|
-
|
|
342
|
-
test("returns graceful fallback when broker does not respond within timeout", async () => {
|
|
343
|
-
// Handler that never responds
|
|
344
|
-
broker.setHandler(() => {
|
|
345
|
-
// Intentionally do not return a response — the broker mock won't send anything
|
|
346
|
-
return undefined as unknown as Record<string, unknown>;
|
|
347
|
-
});
|
|
348
|
-
|
|
349
|
-
// Override handler at the server level to swallow requests
|
|
350
|
-
broker.server.removeAllListeners("connection");
|
|
351
|
-
broker.server.on("connection", (_conn) => {
|
|
352
|
-
// Accept connection but never respond
|
|
353
|
-
});
|
|
354
|
-
await broker.start();
|
|
355
|
-
|
|
356
|
-
const client = createBrokerClient();
|
|
357
|
-
|
|
358
|
-
// get should return null on timeout (broker error)
|
|
359
|
-
const result = await client.get("test-key");
|
|
360
|
-
expect(result).toBeNull();
|
|
361
|
-
}, 10_000);
|
|
362
|
-
});
|
|
363
|
-
|
|
364
|
-
// -----------------------------------------------------------------------
|
|
365
|
-
// UNAUTHORIZED -> token re-read -> retry
|
|
366
|
-
// -----------------------------------------------------------------------
|
|
367
|
-
describe("UNAUTHORIZED retry", () => {
|
|
368
|
-
let broker: ReturnType<typeof createMockBroker>;
|
|
369
|
-
|
|
370
|
-
beforeEach(async () => {
|
|
371
|
-
writeFileSync(TOKEN_PATH, "old-token");
|
|
372
|
-
broker = createMockBroker();
|
|
373
|
-
});
|
|
374
|
-
|
|
375
|
-
afterEach(async () => {
|
|
376
|
-
await broker.stop();
|
|
377
|
-
});
|
|
378
|
-
|
|
379
|
-
test("re-reads token and retries on UNAUTHORIZED", async () => {
|
|
380
|
-
let callCount = 0;
|
|
381
|
-
broker.setHandler((req) => {
|
|
382
|
-
callCount++;
|
|
383
|
-
if (req.token === "new-token") {
|
|
384
|
-
return { ok: true, result: { pong: true } };
|
|
385
|
-
}
|
|
386
|
-
return {
|
|
387
|
-
ok: false,
|
|
388
|
-
error: { code: "UNAUTHORIZED", message: "Invalid auth token" },
|
|
389
|
-
};
|
|
390
|
-
});
|
|
391
|
-
await broker.start();
|
|
392
|
-
|
|
393
|
-
const client = createBrokerClient();
|
|
394
|
-
|
|
395
|
-
// First call will use "old-token" and get UNAUTHORIZED.
|
|
396
|
-
// Simulate the token file being updated before the retry.
|
|
397
|
-
// We need to update it after the first request but before the retry.
|
|
398
|
-
// Since the handler runs synchronously, update the file in the handler.
|
|
399
|
-
broker.setHandler((req) => {
|
|
400
|
-
callCount++;
|
|
401
|
-
if (callCount === 1) {
|
|
402
|
-
// First request with old token — write new token file and return UNAUTHORIZED
|
|
403
|
-
writeFileSync(TOKEN_PATH, "new-token");
|
|
404
|
-
return {
|
|
405
|
-
ok: false,
|
|
406
|
-
error: { code: "UNAUTHORIZED", message: "Invalid auth token" },
|
|
407
|
-
};
|
|
408
|
-
}
|
|
409
|
-
// Retry with re-read token
|
|
410
|
-
if (req.token === "new-token") {
|
|
411
|
-
return { ok: true, result: { pong: true } };
|
|
412
|
-
}
|
|
413
|
-
return {
|
|
414
|
-
ok: false,
|
|
415
|
-
error: { code: "UNAUTHORIZED", message: "Invalid auth token" },
|
|
416
|
-
};
|
|
417
|
-
});
|
|
418
|
-
callCount = 0;
|
|
419
|
-
|
|
420
|
-
const result = await client.ping();
|
|
421
|
-
expect(result).toEqual({ pong: true });
|
|
422
|
-
expect(callCount).toBe(2);
|
|
423
|
-
});
|
|
424
|
-
});
|
|
425
|
-
|
|
426
|
-
// -----------------------------------------------------------------------
|
|
427
|
-
// Graceful degradation
|
|
428
|
-
// -----------------------------------------------------------------------
|
|
429
|
-
describe("graceful degradation", () => {
|
|
430
|
-
test("get returns null when socket file does not exist", async () => {
|
|
431
|
-
writeFileSync(TOKEN_PATH, TEST_TOKEN);
|
|
432
|
-
const client = createBrokerClient();
|
|
433
|
-
const result = await client.get("test-key");
|
|
434
|
-
expect(result).toBeNull();
|
|
435
|
-
});
|
|
436
|
-
|
|
437
|
-
test("set returns unreachable when socket file does not exist", async () => {
|
|
438
|
-
writeFileSync(TOKEN_PATH, TEST_TOKEN);
|
|
439
|
-
const client = createBrokerClient();
|
|
440
|
-
const result = await client.set("test-key", "value");
|
|
441
|
-
expect(result).toEqual({ status: "unreachable" });
|
|
442
|
-
});
|
|
443
|
-
|
|
444
|
-
test("del returns false when socket file does not exist", async () => {
|
|
445
|
-
writeFileSync(TOKEN_PATH, TEST_TOKEN);
|
|
446
|
-
const client = createBrokerClient();
|
|
447
|
-
const result = await client.del("test-key");
|
|
448
|
-
expect(result).toBe(false);
|
|
449
|
-
});
|
|
450
|
-
|
|
451
|
-
test("list returns empty array when socket file does not exist", async () => {
|
|
452
|
-
writeFileSync(TOKEN_PATH, TEST_TOKEN);
|
|
453
|
-
const client = createBrokerClient();
|
|
454
|
-
const result = await client.list();
|
|
455
|
-
expect(result).toEqual([]);
|
|
456
|
-
});
|
|
457
|
-
|
|
458
|
-
test("ping returns null when socket file does not exist", async () => {
|
|
459
|
-
writeFileSync(TOKEN_PATH, TEST_TOKEN);
|
|
460
|
-
const client = createBrokerClient();
|
|
461
|
-
const result = await client.ping();
|
|
462
|
-
expect(result).toBeNull();
|
|
463
|
-
});
|
|
464
|
-
|
|
465
|
-
test("returns fallbacks when token file is missing", async () => {
|
|
466
|
-
try {
|
|
467
|
-
rmSync(TOKEN_PATH, { force: true });
|
|
468
|
-
} catch {
|
|
469
|
-
/* ignore */
|
|
470
|
-
}
|
|
471
|
-
const client = createBrokerClient();
|
|
472
|
-
expect(await client.get("key")).toBeNull();
|
|
473
|
-
expect(await client.set("key", "val")).toEqual({ status: "unreachable" });
|
|
474
|
-
expect(await client.del("key")).toBe(false);
|
|
475
|
-
expect(await client.list()).toEqual([]);
|
|
476
|
-
expect(await client.ping()).toBeNull();
|
|
477
|
-
});
|
|
478
|
-
});
|
|
479
|
-
|
|
480
|
-
// -----------------------------------------------------------------------
|
|
481
|
-
// Connection persistence
|
|
482
|
-
// -----------------------------------------------------------------------
|
|
483
|
-
describe("connection persistence", () => {
|
|
484
|
-
let broker: ReturnType<typeof createMockBroker>;
|
|
485
|
-
|
|
486
|
-
beforeEach(async () => {
|
|
487
|
-
writeFileSync(TOKEN_PATH, TEST_TOKEN);
|
|
488
|
-
broker = createMockBroker();
|
|
489
|
-
});
|
|
490
|
-
|
|
491
|
-
afterEach(async () => {
|
|
492
|
-
await broker.stop();
|
|
493
|
-
});
|
|
494
|
-
|
|
495
|
-
test("reuses the same connection across multiple requests", async () => {
|
|
496
|
-
let connectionCount = 0;
|
|
497
|
-
broker.server.on("connection", () => {
|
|
498
|
-
connectionCount++;
|
|
499
|
-
});
|
|
500
|
-
broker.setHandler(() => ({ ok: true, result: { pong: true } }));
|
|
501
|
-
await broker.start();
|
|
502
|
-
|
|
503
|
-
const client = createBrokerClient();
|
|
504
|
-
await client.ping();
|
|
505
|
-
await client.ping();
|
|
506
|
-
await client.ping();
|
|
507
|
-
|
|
508
|
-
expect(connectionCount).toBe(1);
|
|
509
|
-
});
|
|
510
|
-
});
|
|
511
|
-
|
|
512
|
-
// -----------------------------------------------------------------------
|
|
513
|
-
// Cooldown-based retry
|
|
514
|
-
// -----------------------------------------------------------------------
|
|
515
|
-
describe("cooldown-based retry", () => {
|
|
516
|
-
const originalDateNow = Date.now;
|
|
517
|
-
let fakeNow: number;
|
|
518
|
-
|
|
519
|
-
beforeEach(() => {
|
|
520
|
-
fakeNow = originalDateNow.call(Date);
|
|
521
|
-
Date.now = () => fakeNow;
|
|
522
|
-
writeFileSync(TOKEN_PATH, TEST_TOKEN);
|
|
523
|
-
});
|
|
524
|
-
|
|
525
|
-
afterEach(() => {
|
|
526
|
-
Date.now = originalDateNow;
|
|
527
|
-
});
|
|
528
|
-
|
|
529
|
-
test("retries connection after cooldown period elapses", async () => {
|
|
530
|
-
// No socket file — two connection failures (first + immediate retry)
|
|
531
|
-
const client = createBrokerClient();
|
|
532
|
-
const result = await client.ping();
|
|
533
|
-
expect(result).toBeNull();
|
|
534
|
-
|
|
535
|
-
// Client should be in cooldown — isAvailable() returns false even with
|
|
536
|
-
// socket + token files present.
|
|
537
|
-
writeFileSync(SOCKET_PATH, "");
|
|
538
|
-
expect(client.isAvailable()).toBe(false);
|
|
539
|
-
|
|
540
|
-
// Advance time past the first cooldown (5s)
|
|
541
|
-
fakeNow += 5_001;
|
|
542
|
-
expect(client.isAvailable()).toBe(true);
|
|
543
|
-
|
|
544
|
-
// Now start a real broker and verify the client reconnects
|
|
545
|
-
rmSync(SOCKET_PATH, { force: true });
|
|
546
|
-
const broker = createMockBroker();
|
|
547
|
-
broker.setHandler(() => ({ ok: true, result: { pong: true } }));
|
|
548
|
-
await broker.start();
|
|
549
|
-
|
|
550
|
-
const retryResult = await client.ping();
|
|
551
|
-
expect(retryResult).toEqual({ pong: true });
|
|
552
|
-
|
|
553
|
-
await broker.stop();
|
|
554
|
-
});
|
|
555
|
-
|
|
556
|
-
test("resets failure count after successful reconnection", async () => {
|
|
557
|
-
// No socket file — two connection failures
|
|
558
|
-
const client = createBrokerClient();
|
|
559
|
-
await client.ping();
|
|
560
|
-
|
|
561
|
-
// Advance past first cooldown (5s)
|
|
562
|
-
fakeNow += 5_001;
|
|
563
|
-
|
|
564
|
-
// Start broker — reconnection should succeed and reset counters
|
|
565
|
-
const broker = createMockBroker();
|
|
566
|
-
broker.setHandler(() => ({ ok: true, result: { pong: true } }));
|
|
567
|
-
await broker.start();
|
|
568
|
-
|
|
569
|
-
const result = await client.ping();
|
|
570
|
-
expect(result).toEqual({ pong: true });
|
|
571
|
-
|
|
572
|
-
// Stop broker and remove socket — simulate another disconnection.
|
|
573
|
-
// Yield after stop so the client socket receives the close event
|
|
574
|
-
// before the next ping (otherwise ensureConnected returns the stale
|
|
575
|
-
// socket and sendRequest waits REQUEST_TIMEOUT_MS for a response).
|
|
576
|
-
await broker.stop();
|
|
577
|
-
await new Promise((r) => setTimeout(r, 50));
|
|
578
|
-
rmSync(SOCKET_PATH, { force: true });
|
|
579
|
-
|
|
580
|
-
// This new failure should start from the beginning of the cooldown
|
|
581
|
-
// schedule (5s), not escalated.
|
|
582
|
-
await client.ping();
|
|
583
|
-
|
|
584
|
-
// Verify cooldown is back to 5s (not 15s)
|
|
585
|
-
writeFileSync(SOCKET_PATH, "");
|
|
586
|
-
expect(client.isAvailable()).toBe(false);
|
|
587
|
-
|
|
588
|
-
// 5s should be enough to clear cooldown
|
|
589
|
-
fakeNow += 5_001;
|
|
590
|
-
expect(client.isAvailable()).toBe(true);
|
|
591
|
-
}, 15_000);
|
|
592
|
-
|
|
593
|
-
test("escalates cooldown on repeated failures", async () => {
|
|
594
|
-
const client = createBrokerClient();
|
|
595
|
-
|
|
596
|
-
// First failure round: two attempts (first + immediate retry) ->
|
|
597
|
-
// consecutiveFailures=2, cooldown index = max(2-2,0) = 0 -> 5s.
|
|
598
|
-
await client.ping();
|
|
599
|
-
|
|
600
|
-
writeFileSync(SOCKET_PATH, "");
|
|
601
|
-
expect(client.isAvailable()).toBe(false);
|
|
602
|
-
|
|
603
|
-
// 5s should clear the first cooldown
|
|
604
|
-
fakeNow += 5_001;
|
|
605
|
-
expect(client.isAvailable()).toBe(true);
|
|
606
|
-
|
|
607
|
-
// Remove socket to trigger another failure. After cooldown elapses,
|
|
608
|
-
// ensureConnected clears unavailableSince and tries connect().
|
|
609
|
-
// This failure increments consecutiveFailures to 3 (no immediate retry
|
|
610
|
-
// since consecutiveFailures > 1 after increment).
|
|
611
|
-
// Cooldown index = max(3-2,0) = 1 -> 15s.
|
|
612
|
-
rmSync(SOCKET_PATH, { force: true });
|
|
613
|
-
await client.ping();
|
|
614
|
-
|
|
615
|
-
writeFileSync(SOCKET_PATH, "");
|
|
616
|
-
expect(client.isAvailable()).toBe(false);
|
|
617
|
-
|
|
618
|
-
fakeNow += 5_001;
|
|
619
|
-
expect(client.isAvailable()).toBe(false); // 5s not enough
|
|
620
|
-
|
|
621
|
-
fakeNow += 10_000; // total 15_001ms since this cooldown started
|
|
622
|
-
expect(client.isAvailable()).toBe(true);
|
|
623
|
-
|
|
624
|
-
// Another failure -> consecutiveFailures=4, index = max(4-2,0) = 2 -> 30s
|
|
625
|
-
rmSync(SOCKET_PATH, { force: true });
|
|
626
|
-
await client.ping();
|
|
627
|
-
|
|
628
|
-
writeFileSync(SOCKET_PATH, "");
|
|
629
|
-
expect(client.isAvailable()).toBe(false);
|
|
630
|
-
|
|
631
|
-
fakeNow += 15_001;
|
|
632
|
-
expect(client.isAvailable()).toBe(false);
|
|
633
|
-
|
|
634
|
-
fakeNow += 15_000; // total 30_001ms
|
|
635
|
-
expect(client.isAvailable()).toBe(true);
|
|
636
|
-
|
|
637
|
-
// Another failure -> consecutiveFailures=5, index = max(5-2,0) = 3 -> 60s
|
|
638
|
-
rmSync(SOCKET_PATH, { force: true });
|
|
639
|
-
await client.ping();
|
|
640
|
-
|
|
641
|
-
writeFileSync(SOCKET_PATH, "");
|
|
642
|
-
expect(client.isAvailable()).toBe(false);
|
|
643
|
-
|
|
644
|
-
fakeNow += 30_001;
|
|
645
|
-
expect(client.isAvailable()).toBe(false);
|
|
646
|
-
|
|
647
|
-
fakeNow += 30_000; // total 60_001ms
|
|
648
|
-
expect(client.isAvailable()).toBe(true);
|
|
649
|
-
|
|
650
|
-
// Another failure -> consecutiveFailures=6, index = min(max(6-2,0), 4) = 4 -> 300s (5min)
|
|
651
|
-
rmSync(SOCKET_PATH, { force: true });
|
|
652
|
-
await client.ping();
|
|
653
|
-
|
|
654
|
-
writeFileSync(SOCKET_PATH, "");
|
|
655
|
-
expect(client.isAvailable()).toBe(false);
|
|
656
|
-
|
|
657
|
-
fakeNow += 60_001;
|
|
658
|
-
expect(client.isAvailable()).toBe(false);
|
|
659
|
-
|
|
660
|
-
fakeNow += 240_000; // total 300_001ms
|
|
661
|
-
expect(client.isAvailable()).toBe(true);
|
|
662
|
-
|
|
663
|
-
// Another failure -> consecutiveFailures=7, index = min(max(7-2,0), 4) = 4 -> 300s (capped)
|
|
664
|
-
rmSync(SOCKET_PATH, { force: true });
|
|
665
|
-
await client.ping();
|
|
666
|
-
|
|
667
|
-
writeFileSync(SOCKET_PATH, "");
|
|
668
|
-
expect(client.isAvailable()).toBe(false);
|
|
669
|
-
|
|
670
|
-
fakeNow += 300_001;
|
|
671
|
-
expect(client.isAvailable()).toBe(true);
|
|
672
|
-
});
|
|
673
|
-
});
|
|
674
|
-
|
|
675
|
-
// -----------------------------------------------------------------------
|
|
676
|
-
// Connect timeout
|
|
677
|
-
// -----------------------------------------------------------------------
|
|
678
|
-
describe("connect timeout", () => {
|
|
679
|
-
let stopFn: (() => Promise<void>) | null = null;
|
|
680
|
-
|
|
681
|
-
beforeEach(() => {
|
|
682
|
-
writeFileSync(TOKEN_PATH, TEST_TOKEN);
|
|
683
|
-
});
|
|
684
|
-
|
|
685
|
-
afterEach(async () => {
|
|
686
|
-
if (stopFn) {
|
|
687
|
-
await stopFn();
|
|
688
|
-
stopFn = null;
|
|
689
|
-
}
|
|
690
|
-
});
|
|
691
|
-
|
|
692
|
-
test("rejects connect within 3 seconds when broker is unresponsive", async () => {
|
|
693
|
-
// Create a server that accepts connections but never responds
|
|
694
|
-
// (simulates an unresponsive broker process).
|
|
695
|
-
const activeConns = new Set<import("node:net").Socket>();
|
|
696
|
-
const server = createServer((conn) => {
|
|
697
|
-
activeConns.add(conn);
|
|
698
|
-
conn.on("close", () => activeConns.delete(conn));
|
|
699
|
-
// Accept connection but do nothing — no data, no close
|
|
700
|
-
});
|
|
701
|
-
await new Promise<void>((resolve) => {
|
|
702
|
-
server.listen(SOCKET_PATH, () => resolve());
|
|
703
|
-
});
|
|
704
|
-
stopFn = () =>
|
|
705
|
-
new Promise<void>((resolve) => {
|
|
706
|
-
for (const conn of activeConns) conn.destroy();
|
|
707
|
-
activeConns.clear();
|
|
708
|
-
server.close(() => resolve());
|
|
709
|
-
});
|
|
710
|
-
|
|
711
|
-
const client = createBrokerClient();
|
|
712
|
-
const start = Date.now();
|
|
713
|
-
const result = await client.ping();
|
|
714
|
-
const elapsed = Date.now() - start;
|
|
715
|
-
|
|
716
|
-
// Should return null (graceful fallback) and not hang indefinitely.
|
|
717
|
-
// The connect timeout is 3s; allow some slack but it should be well
|
|
718
|
-
// under 10s (the old behavior would hang for REQUEST_TIMEOUT_MS * retries).
|
|
719
|
-
expect(result).toBeNull();
|
|
720
|
-
expect(elapsed).toBeLessThan(10_000);
|
|
721
|
-
}, 15_000);
|
|
722
|
-
|
|
723
|
-
test("successful connect clears the connect timer", async () => {
|
|
724
|
-
// Normal broker that responds to pings — verifies the timer is cleared
|
|
725
|
-
// and doesn't fire after a successful connection.
|
|
726
|
-
const broker = createMockBroker();
|
|
727
|
-
broker.setHandler(() => ({ ok: true, result: { pong: true } }));
|
|
728
|
-
await broker.start();
|
|
729
|
-
stopFn = () => broker.stop();
|
|
730
|
-
|
|
731
|
-
const client = createBrokerClient();
|
|
732
|
-
const result = await client.ping();
|
|
733
|
-
expect(result).toEqual({ pong: true });
|
|
734
|
-
|
|
735
|
-
// Wait a bit past the connect timeout to ensure no stale timer fires
|
|
736
|
-
await new Promise((r) => setTimeout(r, 100));
|
|
737
|
-
|
|
738
|
-
// Client should still work fine
|
|
739
|
-
const result2 = await client.ping();
|
|
740
|
-
expect(result2).toEqual({ pong: true });
|
|
741
|
-
});
|
|
742
|
-
});
|
|
743
|
-
|
|
744
|
-
// -----------------------------------------------------------------------
|
|
745
|
-
// Reduced initial cooldown
|
|
746
|
-
// -----------------------------------------------------------------------
|
|
747
|
-
describe("reduced initial cooldown", () => {
|
|
748
|
-
const originalDateNow = Date.now;
|
|
749
|
-
let fakeNow: number;
|
|
750
|
-
|
|
751
|
-
beforeEach(() => {
|
|
752
|
-
fakeNow = originalDateNow.call(Date);
|
|
753
|
-
Date.now = () => fakeNow;
|
|
754
|
-
writeFileSync(TOKEN_PATH, TEST_TOKEN);
|
|
755
|
-
});
|
|
756
|
-
|
|
757
|
-
afterEach(() => {
|
|
758
|
-
Date.now = originalDateNow;
|
|
759
|
-
});
|
|
760
|
-
|
|
761
|
-
test("first cooldown is 5 seconds, not 30 seconds", async () => {
|
|
762
|
-
const client = createBrokerClient();
|
|
763
|
-
|
|
764
|
-
// Trigger two connection failures (first + immediate retry)
|
|
765
|
-
await client.ping();
|
|
766
|
-
|
|
767
|
-
writeFileSync(SOCKET_PATH, "");
|
|
768
|
-
|
|
769
|
-
// Should still be in cooldown at 4 seconds
|
|
770
|
-
fakeNow += 4_000;
|
|
771
|
-
expect(client.isAvailable()).toBe(false);
|
|
772
|
-
|
|
773
|
-
// Should be available after 5 seconds
|
|
774
|
-
fakeNow += 1_001;
|
|
775
|
-
expect(client.isAvailable()).toBe(true);
|
|
776
|
-
});
|
|
777
|
-
|
|
778
|
-
test("second cooldown is 15 seconds", async () => {
|
|
779
|
-
const client = createBrokerClient();
|
|
780
|
-
|
|
781
|
-
// First failure round -> cooldown 5s
|
|
782
|
-
await client.ping();
|
|
783
|
-
|
|
784
|
-
// Clear first cooldown
|
|
785
|
-
fakeNow += 5_001;
|
|
786
|
-
|
|
787
|
-
// Second failure -> cooldown 15s
|
|
788
|
-
await client.ping();
|
|
789
|
-
|
|
790
|
-
writeFileSync(SOCKET_PATH, "");
|
|
791
|
-
expect(client.isAvailable()).toBe(false);
|
|
792
|
-
|
|
793
|
-
fakeNow += 14_000;
|
|
794
|
-
expect(client.isAvailable()).toBe(false);
|
|
795
|
-
|
|
796
|
-
fakeNow += 1_001;
|
|
797
|
-
expect(client.isAvailable()).toBe(true);
|
|
798
|
-
});
|
|
799
|
-
});
|
|
800
|
-
});
|