@mycadet/gateway 0.12.10 → 0.13.0
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/node_modules/@mycadet/ces-client/node_modules/@mycadet/service-contracts/src/__tests__/ingress.test.ts +10 -10
- package/node_modules/@mycadet/ces-client/node_modules/@mycadet/service-contracts/src/ingress.ts +3 -3
- package/node_modules/@mycadet/ces-client/src/http-credentials.ts +18 -1
- package/node_modules/@mycadet/gateway-client/node_modules/@mycadet/service-contracts/src/__tests__/ingress.test.ts +10 -10
- package/node_modules/@mycadet/gateway-client/node_modules/@mycadet/service-contracts/src/ingress.ts +3 -3
- package/node_modules/@mycadet/gateway-client/src/gateway-ipc-contracts.ts +0 -20
- package/node_modules/@mycadet/service-contracts/src/__tests__/ingress.test.ts +10 -10
- package/node_modules/@mycadet/service-contracts/src/ingress.ts +3 -3
- package/openapi.json +1 -1
- package/package.json +1 -1
- package/src/__tests__/actor-token-revocation.test.ts +41 -34
- package/src/__tests__/contact-store-mirror-op-missing.test.ts +33 -70
- package/src/__tests__/contacts-mirror-op-reporter.test.ts +33 -89
- package/src/__tests__/credential-requests.test.ts +4 -4
- package/src/__tests__/devices-mine.test.ts +187 -0
- package/src/__tests__/edge-auth.test.ts +1 -20
- package/src/__tests__/edge-guardian-auth.test.ts +1 -17
- package/src/__tests__/feature-flag-resolver.test.ts +2 -30
- package/src/__tests__/feature-flag-watcher-callback.test.ts +0 -22
- package/src/__tests__/feature-flags-route.test.ts +2 -191
- package/src/__tests__/guardian-bootstrap-lookups.test.ts +22 -29
- package/src/__tests__/guardian-init-lockfile.test.ts +2 -2
- package/src/__tests__/guardian-integrity-reporter.test.ts +51 -82
- package/src/__tests__/guardian-integrity.test.ts +16 -20
- package/src/__tests__/handle-inbound-trust-verdict.test.ts +1 -14
- package/src/__tests__/ipc-feature-flag-routes.test.ts +0 -56
- package/src/__tests__/lum-2783-recovery-e2e.test.ts +0 -12
- package/src/__tests__/post-assistant-ready-deferred.test.ts +0 -18
- package/src/__tests__/refresh-device-binding.test.ts +0 -12
- package/src/__tests__/remote-web-pairing-token.test.ts +5 -15
- package/src/__tests__/runtime-client.test.ts +7 -7
- package/src/__tests__/speech-relay-websocket.test.ts +4 -4
- package/src/__tests__/trust-verdict-resolver-guardian-integrity.test.ts +30 -36
- package/src/__tests__/twilio-webhooks.test.ts +2 -2
- package/src/auth/actor-token-revocation.ts +34 -9
- package/src/auth/guardian-bootstrap.ts +1 -1
- package/src/cli/__tests__/pairing.test.ts +95 -0
- package/src/cli/index.ts +1 -1
- package/src/cli/pairing.ts +81 -6
- package/src/contacts-mirror-op-reporter.ts +19 -36
- package/src/db/connection.ts +0 -10
- package/src/feature-flag-resolver.ts +2 -7
- package/src/feature-flag-watcher.ts +1 -14
- package/src/guardian-integrity-reporter.ts +19 -47
- package/src/http/middleware/auth.ts +33 -27
- package/src/http/routes/auth-token.ts +1 -1
- package/src/http/routes/devices-mine.ts +103 -0
- package/src/http/routes/feature-flags.ts +1 -8
- package/src/http/routes/guardian-pin.ts +2 -11
- package/src/http/routes/ipc-runtime-proxy.ts +1 -1
- package/src/http/routes/live-voice-websocket.ts +1 -1
- package/src/http/routes/runtime-audio-stream.ts +1 -1
- package/src/http/routes/runtime-proxy.ts +2 -2
- package/src/http/routes/speech-relay-websocket.ts +2 -2
- package/src/http/routes/twilio-voice-webhook.test.ts +13 -13
- package/src/index.ts +11 -198
- package/src/ipc/feature-flag-handlers.ts +2 -9
- package/src/remote-web/pairing-challenge-store.ts +15 -0
- package/src/risk/command-registry/commands/assistant.ts +0 -24
- package/src/risk/command-registry.test.ts +2 -4
- package/src/schema.ts +44 -372
- package/src/__tests__/auth-fallback-count-tracker.test.ts +0 -92
- package/src/__tests__/auth-fallback-reporter.test.ts +0 -101
- package/src/__tests__/backup-worker-cloud-mode.test.ts +0 -44
- package/src/__tests__/backup-worker-export-timeout.test.ts +0 -164
- package/src/__tests__/migration-teleport-gcs-proxy.test.ts +0 -216
- package/src/__tests__/remote-feature-flag-sync.test.ts +0 -961
- package/src/auth-fallback-count-tracker.ts +0 -113
- package/src/auth-fallback-reporter.ts +0 -127
- package/src/backup/backup-key.ts +0 -138
- package/src/backup/backup-routes.ts +0 -161
- package/src/backup/backup-worker.ts +0 -425
- package/src/backup/list-snapshots.ts +0 -97
- package/src/backup/local-writer.ts +0 -87
- package/src/backup/offsite-writer.ts +0 -182
- package/src/backup/paths.ts +0 -113
- package/src/backup/stream-crypt.ts +0 -258
- package/src/device-id.ts +0 -18
- package/src/feature-flag-remote-store.ts +0 -166
- package/src/http/routes/migration-proxy.ts +0 -636
- package/src/internal-telemetry-client.ts +0 -39
- package/src/ipc/debug-export-handlers.test.ts +0 -102
- package/src/ipc/debug-export-handlers.ts +0 -81
- package/src/remote-feature-flag-sync.ts +0 -440
- package/src/watchdog-reporter.ts +0 -110
|
@@ -1,961 +0,0 @@
|
|
|
1
|
-
import { describe, test, expect, mock, beforeEach, afterEach } from "bun:test";
|
|
2
|
-
import { mkdirSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
-
import { join } from "node:path";
|
|
4
|
-
|
|
5
|
-
import type { CredentialCache } from "../credential-cache.js";
|
|
6
|
-
|
|
7
|
-
// ---------------------------------------------------------------------------
|
|
8
|
-
// Isolated temp directory (mirrors feature-flags-route.test.ts pattern)
|
|
9
|
-
// ---------------------------------------------------------------------------
|
|
10
|
-
import { testSecurityDir } from "./test-preload.js";
|
|
11
|
-
|
|
12
|
-
const protectedDir = testSecurityDir;
|
|
13
|
-
|
|
14
|
-
// ---------------------------------------------------------------------------
|
|
15
|
-
// Mock fetchImpl
|
|
16
|
-
// ---------------------------------------------------------------------------
|
|
17
|
-
type FetchFn = (
|
|
18
|
-
input: string | URL | Request,
|
|
19
|
-
init?: RequestInit,
|
|
20
|
-
) => Promise<Response>;
|
|
21
|
-
let fetchMock: ReturnType<typeof mock<FetchFn>> = mock(
|
|
22
|
-
async () => new Response(),
|
|
23
|
-
);
|
|
24
|
-
|
|
25
|
-
mock.module("../fetch.js", () => ({
|
|
26
|
-
fetchImpl: (...args: Parameters<FetchFn>) => fetchMock(...args),
|
|
27
|
-
}));
|
|
28
|
-
|
|
29
|
-
// ---------------------------------------------------------------------------
|
|
30
|
-
// Dynamic imports (after mock.module)
|
|
31
|
-
// ---------------------------------------------------------------------------
|
|
32
|
-
const { RemoteFeatureFlagSync } =
|
|
33
|
-
await import("../remote-feature-flag-sync.js");
|
|
34
|
-
const { readRemoteFeatureFlags, clearRemoteFeatureFlagStoreCache } =
|
|
35
|
-
await import("../feature-flag-remote-store.js");
|
|
36
|
-
const { resetFeatureFlagDefaultsCache, _setRegistryCandidateOverrides } =
|
|
37
|
-
await import("../feature-flag-defaults.js");
|
|
38
|
-
const { resetEnvOverridesCache } =
|
|
39
|
-
await import("../feature-flag-env-overrides.js");
|
|
40
|
-
|
|
41
|
-
// ---------------------------------------------------------------------------
|
|
42
|
-
// Test-local registry with a GA flag (defaultEnabled: true) for the
|
|
43
|
-
// "normalizes remote false for GA flags" test. Written to an isolated temp path
|
|
44
|
-
// so we never touch the committed registry file.
|
|
45
|
-
// ---------------------------------------------------------------------------
|
|
46
|
-
const testRegistryPath = join(protectedDir, "feature-flag-registry.json");
|
|
47
|
-
|
|
48
|
-
const TEST_REGISTRY = {
|
|
49
|
-
version: 1,
|
|
50
|
-
flags: [
|
|
51
|
-
{
|
|
52
|
-
id: "test-ga-flag",
|
|
53
|
-
scope: "assistant",
|
|
54
|
-
key: "test-ga-flag",
|
|
55
|
-
label: "Test GA Flag",
|
|
56
|
-
description: "A test flag that is GA (defaultEnabled: true)",
|
|
57
|
-
defaultEnabled: true,
|
|
58
|
-
},
|
|
59
|
-
{
|
|
60
|
-
id: "a2a-channel",
|
|
61
|
-
scope: "assistant",
|
|
62
|
-
key: "a2a-channel",
|
|
63
|
-
label: "A2A Channel",
|
|
64
|
-
description: "A2A channel integration",
|
|
65
|
-
defaultEnabled: false,
|
|
66
|
-
},
|
|
67
|
-
],
|
|
68
|
-
};
|
|
69
|
-
|
|
70
|
-
// ---------------------------------------------------------------------------
|
|
71
|
-
// Helpers
|
|
72
|
-
// ---------------------------------------------------------------------------
|
|
73
|
-
|
|
74
|
-
type FakeCredentialCacheExt = CredentialCache & {
|
|
75
|
-
_invalidate(): void;
|
|
76
|
-
_setValues(v: Record<string, string | undefined>): void;
|
|
77
|
-
};
|
|
78
|
-
|
|
79
|
-
/**
|
|
80
|
-
* Build a fake CredentialCache that resolves credential keys from an
|
|
81
|
-
* in-memory map. Keys follow the `credential/{service}/{field}` format
|
|
82
|
-
* produced by `credentialKey()`.
|
|
83
|
-
*
|
|
84
|
-
* Includes `onInvalidate` support and test helpers:
|
|
85
|
-
* - `_invalidate()` — fire all registered invalidation listeners
|
|
86
|
-
* - `_setValues(v)` — replace the in-memory credential map
|
|
87
|
-
*/
|
|
88
|
-
function fakeCredentialCache(
|
|
89
|
-
initialValues: Record<string, string | undefined> = {},
|
|
90
|
-
): FakeCredentialCacheExt {
|
|
91
|
-
let values = { ...initialValues };
|
|
92
|
-
const invalidateListeners = new Set<() => void>();
|
|
93
|
-
return {
|
|
94
|
-
get: async (key: string) => values[key],
|
|
95
|
-
onInvalidate: (cb: () => void) => {
|
|
96
|
-
invalidateListeners.add(cb);
|
|
97
|
-
return () => {
|
|
98
|
-
invalidateListeners.delete(cb);
|
|
99
|
-
};
|
|
100
|
-
},
|
|
101
|
-
_invalidate: () => {
|
|
102
|
-
for (const cb of invalidateListeners) cb();
|
|
103
|
-
},
|
|
104
|
-
_setValues: (v: Record<string, string | undefined>) => {
|
|
105
|
-
values = { ...v };
|
|
106
|
-
},
|
|
107
|
-
} as unknown as FakeCredentialCacheExt;
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
function defaultCredentials(): Record<string, string> {
|
|
111
|
-
return {
|
|
112
|
-
"credential/cadet/assistant_api_key": "test-api-key",
|
|
113
|
-
};
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
// ---------------------------------------------------------------------------
|
|
117
|
-
// Setup / teardown
|
|
118
|
-
// ---------------------------------------------------------------------------
|
|
119
|
-
const savedCadetCloudUrl = process.env.CADET_CLOUD_URL;
|
|
120
|
-
const savedAssistantCredential = process.env.ASSISTANT_API_KEY;
|
|
121
|
-
const savedDisablePlatform = process.env.CADET_DISABLE_CLOUD;
|
|
122
|
-
|
|
123
|
-
beforeEach(() => {
|
|
124
|
-
// Clear env vars that the production code falls back to, so tests remain
|
|
125
|
-
// deterministic unless they explicitly set them.
|
|
126
|
-
process.env.CADET_CLOUD_URL = "https://test-cloud.mycadet.ai";
|
|
127
|
-
delete process.env.ASSISTANT_API_KEY;
|
|
128
|
-
delete process.env.CADET_DISABLE_CLOUD;
|
|
129
|
-
mkdirSync(protectedDir, { recursive: true });
|
|
130
|
-
// Write the test registry and point resolution at it
|
|
131
|
-
writeFileSync(testRegistryPath, JSON.stringify(TEST_REGISTRY, null, 2));
|
|
132
|
-
_setRegistryCandidateOverrides([testRegistryPath]);
|
|
133
|
-
resetFeatureFlagDefaultsCache();
|
|
134
|
-
resetEnvOverridesCache();
|
|
135
|
-
clearRemoteFeatureFlagStoreCache();
|
|
136
|
-
fetchMock = mock(async () => new Response());
|
|
137
|
-
});
|
|
138
|
-
|
|
139
|
-
afterEach(() => {
|
|
140
|
-
// Restore env vars
|
|
141
|
-
const restoreEnv = (key: string, saved: string | undefined): void => {
|
|
142
|
-
if (saved === undefined) {
|
|
143
|
-
delete process.env[key];
|
|
144
|
-
} else {
|
|
145
|
-
process.env[key] = saved;
|
|
146
|
-
}
|
|
147
|
-
};
|
|
148
|
-
restoreEnv("CADET_CLOUD_URL", savedCadetCloudUrl);
|
|
149
|
-
restoreEnv("ASSISTANT_API_KEY", savedAssistantCredential);
|
|
150
|
-
restoreEnv("CADET_DISABLE_CLOUD", savedDisablePlatform);
|
|
151
|
-
try {
|
|
152
|
-
rmSync(protectedDir, { recursive: true, force: true });
|
|
153
|
-
mkdirSync(protectedDir, { recursive: true });
|
|
154
|
-
} catch {
|
|
155
|
-
// best effort cleanup
|
|
156
|
-
}
|
|
157
|
-
_setRegistryCandidateOverrides(null);
|
|
158
|
-
resetFeatureFlagDefaultsCache();
|
|
159
|
-
resetEnvOverridesCache();
|
|
160
|
-
clearRemoteFeatureFlagStoreCache();
|
|
161
|
-
});
|
|
162
|
-
|
|
163
|
-
// ---------------------------------------------------------------------------
|
|
164
|
-
// Tests
|
|
165
|
-
// ---------------------------------------------------------------------------
|
|
166
|
-
describe("RemoteFeatureFlagSync", () => {
|
|
167
|
-
test("skips sync when platform features are disabled", async () => {
|
|
168
|
-
fetchMock = mock(async () => Response.json({ flags: { ff1: true } }));
|
|
169
|
-
process.env.CADET_DISABLE_CLOUD = "true";
|
|
170
|
-
|
|
171
|
-
const sync = new RemoteFeatureFlagSync({
|
|
172
|
-
credentials: fakeCredentialCache(defaultCredentials()),
|
|
173
|
-
});
|
|
174
|
-
await sync.start();
|
|
175
|
-
sync.stop();
|
|
176
|
-
|
|
177
|
-
expect(fetchMock).not.toHaveBeenCalled();
|
|
178
|
-
});
|
|
179
|
-
|
|
180
|
-
test("skips sync when no platform URL is available from env", async () => {
|
|
181
|
-
fetchMock = mock(async () => Response.json({ flags: { ff1: true } }));
|
|
182
|
-
|
|
183
|
-
const creds = defaultCredentials();
|
|
184
|
-
delete process.env.CADET_CLOUD_URL;
|
|
185
|
-
|
|
186
|
-
const sync = new RemoteFeatureFlagSync({
|
|
187
|
-
credentials: fakeCredentialCache(creds),
|
|
188
|
-
});
|
|
189
|
-
await sync.start();
|
|
190
|
-
sync.stop();
|
|
191
|
-
|
|
192
|
-
// No fetch calls — sync is skipped when platform URL is unavailable
|
|
193
|
-
expect(fetchMock).not.toHaveBeenCalled();
|
|
194
|
-
});
|
|
195
|
-
|
|
196
|
-
test("uses the CADET_CLOUD_URL env var", async () => {
|
|
197
|
-
fetchMock = mock(async () => Response.json({ flags: {} }));
|
|
198
|
-
process.env.CADET_CLOUD_URL = "https://env-platform.example.com";
|
|
199
|
-
|
|
200
|
-
const creds = defaultCredentials();
|
|
201
|
-
|
|
202
|
-
const sync = new RemoteFeatureFlagSync({
|
|
203
|
-
credentials: fakeCredentialCache(creds),
|
|
204
|
-
});
|
|
205
|
-
await sync.start();
|
|
206
|
-
sync.stop();
|
|
207
|
-
|
|
208
|
-
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
209
|
-
const [url] = fetchMock.mock.calls[0];
|
|
210
|
-
expect(url).toBe(
|
|
211
|
-
"https://env-platform.example.com/v1/feature-flags/assistant-flag-values/",
|
|
212
|
-
);
|
|
213
|
-
});
|
|
214
|
-
|
|
215
|
-
test("fetches without auth header when assistant_api_key is missing", async () => {
|
|
216
|
-
fetchMock = mock(async () => Response.json({ flags: { ff1: true } }));
|
|
217
|
-
|
|
218
|
-
const creds = defaultCredentials();
|
|
219
|
-
delete creds["credential/cadet/assistant_api_key"];
|
|
220
|
-
|
|
221
|
-
const sync = new RemoteFeatureFlagSync({
|
|
222
|
-
credentials: fakeCredentialCache(creds),
|
|
223
|
-
});
|
|
224
|
-
await sync.start();
|
|
225
|
-
sync.stop();
|
|
226
|
-
|
|
227
|
-
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
228
|
-
const [, init] = fetchMock.mock.calls[0];
|
|
229
|
-
const headers = init?.headers as Record<string, string>;
|
|
230
|
-
expect(headers.Authorization).toBeUndefined();
|
|
231
|
-
expect(headers.Accept).toBe("application/json");
|
|
232
|
-
});
|
|
233
|
-
|
|
234
|
-
test("syncs when only cloudUrl and assistantApiKey are present", async () => {
|
|
235
|
-
fetchMock = mock(async () => Response.json({ flags: { ff1: true } }));
|
|
236
|
-
|
|
237
|
-
process.env.CADET_CLOUD_URL = "https://test-cloud.mycadet.ai";
|
|
238
|
-
const creds = {
|
|
239
|
-
"credential/cadet/assistant_api_key": "test-api-key",
|
|
240
|
-
};
|
|
241
|
-
|
|
242
|
-
const sync = new RemoteFeatureFlagSync({
|
|
243
|
-
credentials: fakeCredentialCache(creds),
|
|
244
|
-
});
|
|
245
|
-
await sync.start();
|
|
246
|
-
sync.stop();
|
|
247
|
-
|
|
248
|
-
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
249
|
-
});
|
|
250
|
-
|
|
251
|
-
test("falls back to ASSISTANT_API_KEY env var when credential key is missing", async () => {
|
|
252
|
-
fetchMock = mock(async () => Response.json({ flags: { ff1: true } }));
|
|
253
|
-
process.env.ASSISTANT_API_KEY = "env-key";
|
|
254
|
-
|
|
255
|
-
process.env.CADET_CLOUD_URL = "https://test-cloud.mycadet.ai";
|
|
256
|
-
const creds = {};
|
|
257
|
-
|
|
258
|
-
const sync = new RemoteFeatureFlagSync({
|
|
259
|
-
credentials: fakeCredentialCache(creds),
|
|
260
|
-
});
|
|
261
|
-
await sync.start();
|
|
262
|
-
sync.stop();
|
|
263
|
-
|
|
264
|
-
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
265
|
-
const [, init] = fetchMock.mock.calls[0];
|
|
266
|
-
const headers = init?.headers as Record<string, string>;
|
|
267
|
-
expect(headers.Authorization).toBe("Api-Key env-key");
|
|
268
|
-
});
|
|
269
|
-
|
|
270
|
-
test("fetches and caches flags on successful response", async () => {
|
|
271
|
-
fetchMock = mock(async () =>
|
|
272
|
-
Response.json({
|
|
273
|
-
flags: { browser: true },
|
|
274
|
-
}),
|
|
275
|
-
);
|
|
276
|
-
|
|
277
|
-
const sync = new RemoteFeatureFlagSync({
|
|
278
|
-
credentials: fakeCredentialCache(defaultCredentials()),
|
|
279
|
-
});
|
|
280
|
-
await sync.start();
|
|
281
|
-
sync.stop();
|
|
282
|
-
|
|
283
|
-
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
284
|
-
|
|
285
|
-
clearRemoteFeatureFlagStoreCache();
|
|
286
|
-
const cached = readRemoteFeatureFlags();
|
|
287
|
-
expect(cached).toEqual({ browser: true });
|
|
288
|
-
});
|
|
289
|
-
|
|
290
|
-
test("syncNow re-fetches with current credentials after an identity change (warm-pool claim)", async () => {
|
|
291
|
-
// Regression for JARVIS-1018: a warm-pool pod that already synced flags
|
|
292
|
-
// for a previous identity gets reassigned to a new assistant. The
|
|
293
|
-
// credential-change wiring in index.ts calls syncNow(); it must re-fetch
|
|
294
|
-
// with the NEW api key and overwrite the cached per-assistant flag values
|
|
295
|
-
// immediately, rather than serving the previous identity's (or registry
|
|
296
|
-
// default) values until the next ~5-min poll.
|
|
297
|
-
process.env.CADET_CLOUD_URL = "https://test-cloud.mycadet.ai";
|
|
298
|
-
const cache = fakeCredentialCache({
|
|
299
|
-
"credential/cadet/assistant_api_key": "old-assistant-key",
|
|
300
|
-
});
|
|
301
|
-
|
|
302
|
-
// Previous identity: self-intro-greeting OFF.
|
|
303
|
-
fetchMock = mock(async () =>
|
|
304
|
-
Response.json({ flags: { "self-intro-greeting": false } }),
|
|
305
|
-
);
|
|
306
|
-
|
|
307
|
-
const sync = new RemoteFeatureFlagSync({ credentials: cache });
|
|
308
|
-
await sync.start();
|
|
309
|
-
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
310
|
-
clearRemoteFeatureFlagStoreCache();
|
|
311
|
-
expect(readRemoteFeatureFlags()).toEqual({ "self-intro-greeting": false });
|
|
312
|
-
|
|
313
|
-
// Warm-pool claim: credentials now belong to the newly-assigned assistant,
|
|
314
|
-
// whose flag evaluation returns self-intro-greeting ON.
|
|
315
|
-
process.env.CADET_CLOUD_URL = "https://test-cloud.mycadet.ai";
|
|
316
|
-
cache._setValues({
|
|
317
|
-
"credential/cadet/assistant_api_key": "new-assistant-key",
|
|
318
|
-
});
|
|
319
|
-
fetchMock = mock(async () =>
|
|
320
|
-
Response.json({ flags: { "self-intro-greeting": true } }),
|
|
321
|
-
);
|
|
322
|
-
|
|
323
|
-
await sync.syncNow();
|
|
324
|
-
sync.stop();
|
|
325
|
-
|
|
326
|
-
// Re-fetched immediately, authenticated as the new identity...
|
|
327
|
-
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
328
|
-
const [, init] = fetchMock.mock.calls[0];
|
|
329
|
-
const headers = init?.headers as Record<string, string>;
|
|
330
|
-
expect(headers.Authorization).toBe("Api-Key new-assistant-key");
|
|
331
|
-
|
|
332
|
-
// ...and the cached value now reflects the new assistant.
|
|
333
|
-
clearRemoteFeatureFlagStoreCache();
|
|
334
|
-
expect(readRemoteFeatureFlags()).toEqual({ "self-intro-greeting": true });
|
|
335
|
-
});
|
|
336
|
-
|
|
337
|
-
test("syncNow coalesces a follow-up fetch when an identity change lands mid-sync", async () => {
|
|
338
|
-
// JARVIS-1018 hardening: during a warm-pool claim several credential files
|
|
339
|
-
// are written in quick succession, each firing the change handler's
|
|
340
|
-
// syncNow(). The first fetch may be authenticated with a stale key; a
|
|
341
|
-
// second syncNow() arriving while it is in flight must NOT be dropped by
|
|
342
|
-
// the re-entrancy guard — it must trigger one more fetch so the final
|
|
343
|
-
// identity's flags are cached, not the stale ones.
|
|
344
|
-
process.env.CADET_CLOUD_URL = "https://test-cloud.mycadet.ai";
|
|
345
|
-
const cache = fakeCredentialCache({
|
|
346
|
-
"credential/cadet/assistant_api_key": "old-assistant-key",
|
|
347
|
-
});
|
|
348
|
-
|
|
349
|
-
let fetchCount = 0;
|
|
350
|
-
const seenKeys: string[] = [];
|
|
351
|
-
fetchMock = mock(async (_input: unknown, init?: RequestInit) => {
|
|
352
|
-
fetchCount++;
|
|
353
|
-
const headers = (init?.headers ?? {}) as Record<string, string>;
|
|
354
|
-
seenKeys.push(headers.Authorization ?? "");
|
|
355
|
-
// Slow fetch so the second syncNow lands while the first is in flight.
|
|
356
|
-
await new Promise((r) => setTimeout(r, 100));
|
|
357
|
-
const isNew = headers.Authorization === "Api-Key new-assistant-key";
|
|
358
|
-
return Response.json({ flags: { "self-intro-greeting": isNew } });
|
|
359
|
-
});
|
|
360
|
-
|
|
361
|
-
const sync = new RemoteFeatureFlagSync({ credentials: cache });
|
|
362
|
-
await sync.start(); // initial fetch with the old key
|
|
363
|
-
// Reset counters so we only measure the overlapping-syncNow window.
|
|
364
|
-
fetchCount = 0;
|
|
365
|
-
seenKeys.length = 0;
|
|
366
|
-
|
|
367
|
-
// First syncNow starts a slow fetch authenticated with the old key.
|
|
368
|
-
const first = sync.syncNow();
|
|
369
|
-
await new Promise((r) => setTimeout(r, 20)); // let that fetch begin
|
|
370
|
-
|
|
371
|
-
// Warm-pool claim: identity changes, then a second syncNow arrives while
|
|
372
|
-
// the first fetch is still in flight.
|
|
373
|
-
process.env.CADET_CLOUD_URL = "https://test-cloud.mycadet.ai";
|
|
374
|
-
cache._setValues({
|
|
375
|
-
"credential/cadet/assistant_api_key": "new-assistant-key",
|
|
376
|
-
});
|
|
377
|
-
await sync.syncNow(); // returns immediately, records pendingResync
|
|
378
|
-
await first; // first loop settles, then runs the coalesced follow-up
|
|
379
|
-
sync.stop();
|
|
380
|
-
|
|
381
|
-
// Two fetches: the original (old key) plus the coalesced follow-up (new key).
|
|
382
|
-
expect(fetchCount).toBe(2);
|
|
383
|
-
expect(seenKeys[0]).toBe("Api-Key old-assistant-key");
|
|
384
|
-
expect(seenKeys[1]).toBe("Api-Key new-assistant-key");
|
|
385
|
-
|
|
386
|
-
// Final cached value reflects the new identity, not the stale fetch.
|
|
387
|
-
clearRemoteFeatureFlagStoreCache();
|
|
388
|
-
expect(readRemoteFeatureFlags()).toEqual({ "self-intro-greeting": true });
|
|
389
|
-
});
|
|
390
|
-
|
|
391
|
-
test("preserves cached flags on non-OK response", async () => {
|
|
392
|
-
// First, seed cached flags with a successful fetch
|
|
393
|
-
fetchMock = mock(async () =>
|
|
394
|
-
Response.json({
|
|
395
|
-
flags: { browser: true },
|
|
396
|
-
}),
|
|
397
|
-
);
|
|
398
|
-
|
|
399
|
-
const sync1 = new RemoteFeatureFlagSync({
|
|
400
|
-
credentials: fakeCredentialCache(defaultCredentials()),
|
|
401
|
-
});
|
|
402
|
-
await sync1.start();
|
|
403
|
-
sync1.stop();
|
|
404
|
-
|
|
405
|
-
clearRemoteFeatureFlagStoreCache();
|
|
406
|
-
expect(readRemoteFeatureFlags()).toEqual({
|
|
407
|
-
browser: true,
|
|
408
|
-
});
|
|
409
|
-
|
|
410
|
-
// Now simulate a non-OK response — cached flags should be preserved
|
|
411
|
-
fetchMock = mock(
|
|
412
|
-
async () => new Response("Internal Server Error", { status: 500 }),
|
|
413
|
-
);
|
|
414
|
-
|
|
415
|
-
const sync2 = new RemoteFeatureFlagSync({
|
|
416
|
-
credentials: fakeCredentialCache(defaultCredentials()),
|
|
417
|
-
});
|
|
418
|
-
await sync2.start();
|
|
419
|
-
sync2.stop();
|
|
420
|
-
|
|
421
|
-
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
422
|
-
|
|
423
|
-
clearRemoteFeatureFlagStoreCache();
|
|
424
|
-
const cached = readRemoteFeatureFlags();
|
|
425
|
-
expect(cached).toEqual({ browser: true });
|
|
426
|
-
});
|
|
427
|
-
|
|
428
|
-
test("preserves cached flags on network error", async () => {
|
|
429
|
-
// First, seed cached flags with a successful fetch
|
|
430
|
-
fetchMock = mock(async () =>
|
|
431
|
-
Response.json({
|
|
432
|
-
flags: { browser: true },
|
|
433
|
-
}),
|
|
434
|
-
);
|
|
435
|
-
|
|
436
|
-
const sync1 = new RemoteFeatureFlagSync({
|
|
437
|
-
credentials: fakeCredentialCache(defaultCredentials()),
|
|
438
|
-
});
|
|
439
|
-
await sync1.start();
|
|
440
|
-
sync1.stop();
|
|
441
|
-
|
|
442
|
-
clearRemoteFeatureFlagStoreCache();
|
|
443
|
-
expect(readRemoteFeatureFlags()).toEqual({
|
|
444
|
-
browser: true,
|
|
445
|
-
});
|
|
446
|
-
|
|
447
|
-
// Now simulate a network error — cached flags should be preserved
|
|
448
|
-
fetchMock = mock(async () => {
|
|
449
|
-
throw new Error("Network failure");
|
|
450
|
-
});
|
|
451
|
-
|
|
452
|
-
const sync2 = new RemoteFeatureFlagSync({
|
|
453
|
-
credentials: fakeCredentialCache(defaultCredentials()),
|
|
454
|
-
});
|
|
455
|
-
// Should not throw — errors are caught and logged
|
|
456
|
-
await sync2.start();
|
|
457
|
-
sync2.stop();
|
|
458
|
-
|
|
459
|
-
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
460
|
-
|
|
461
|
-
clearRemoteFeatureFlagStoreCache();
|
|
462
|
-
const cached = readRemoteFeatureFlags();
|
|
463
|
-
expect(cached).toEqual({ browser: true });
|
|
464
|
-
});
|
|
465
|
-
|
|
466
|
-
test("sends correct auth header", async () => {
|
|
467
|
-
fetchMock = mock(async () => Response.json({ flags: {} }));
|
|
468
|
-
|
|
469
|
-
const apiKey = "my-secret-key-42";
|
|
470
|
-
const creds = {
|
|
471
|
-
...defaultCredentials(),
|
|
472
|
-
"credential/cadet/assistant_api_key": apiKey,
|
|
473
|
-
};
|
|
474
|
-
const sync = new RemoteFeatureFlagSync({
|
|
475
|
-
credentials: fakeCredentialCache(creds),
|
|
476
|
-
});
|
|
477
|
-
await sync.start();
|
|
478
|
-
sync.stop();
|
|
479
|
-
|
|
480
|
-
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
481
|
-
const [, init] = fetchMock.mock.calls[0];
|
|
482
|
-
const headers = init?.headers as Record<string, string>;
|
|
483
|
-
expect(headers.Authorization).toBe(`Api-Key ${apiKey}`);
|
|
484
|
-
});
|
|
485
|
-
|
|
486
|
-
test("constructs correct URL from platform base URL", async () => {
|
|
487
|
-
fetchMock = mock(async () => Response.json({ flags: {} }));
|
|
488
|
-
|
|
489
|
-
process.env.CADET_CLOUD_URL = "https://test-cloud.mycadet.ai";
|
|
490
|
-
const creds = { ...defaultCredentials() };
|
|
491
|
-
const sync = new RemoteFeatureFlagSync({
|
|
492
|
-
credentials: fakeCredentialCache(creds),
|
|
493
|
-
});
|
|
494
|
-
await sync.start();
|
|
495
|
-
sync.stop();
|
|
496
|
-
|
|
497
|
-
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
498
|
-
const [url] = fetchMock.mock.calls[0];
|
|
499
|
-
expect(url).toBe(
|
|
500
|
-
"https://test-cloud.mycadet.ai/v1/feature-flags/assistant-flag-values/",
|
|
501
|
-
);
|
|
502
|
-
});
|
|
503
|
-
|
|
504
|
-
test("accepts boolean and string values, filters other types from response", async () => {
|
|
505
|
-
fetchMock = mock(async () =>
|
|
506
|
-
Response.json({
|
|
507
|
-
flags: {
|
|
508
|
-
browser: true,
|
|
509
|
-
"default-model": "claude-sonnet-4-6",
|
|
510
|
-
other: 1 as unknown,
|
|
511
|
-
valid: false,
|
|
512
|
-
nullVal: null as unknown,
|
|
513
|
-
},
|
|
514
|
-
}),
|
|
515
|
-
);
|
|
516
|
-
|
|
517
|
-
const sync = new RemoteFeatureFlagSync({
|
|
518
|
-
credentials: fakeCredentialCache(defaultCredentials()),
|
|
519
|
-
});
|
|
520
|
-
await sync.start();
|
|
521
|
-
sync.stop();
|
|
522
|
-
|
|
523
|
-
clearRemoteFeatureFlagStoreCache();
|
|
524
|
-
const cached = readRemoteFeatureFlags();
|
|
525
|
-
expect(cached).toEqual({
|
|
526
|
-
browser: true,
|
|
527
|
-
"default-model": "claude-sonnet-4-6",
|
|
528
|
-
valid: false,
|
|
529
|
-
});
|
|
530
|
-
});
|
|
531
|
-
|
|
532
|
-
test("preserves cached flags when response is missing flags field", async () => {
|
|
533
|
-
// First, seed cached flags with a successful fetch
|
|
534
|
-
fetchMock = mock(async () =>
|
|
535
|
-
Response.json({
|
|
536
|
-
flags: { browser: true },
|
|
537
|
-
}),
|
|
538
|
-
);
|
|
539
|
-
|
|
540
|
-
const sync1 = new RemoteFeatureFlagSync({
|
|
541
|
-
credentials: fakeCredentialCache(defaultCredentials()),
|
|
542
|
-
});
|
|
543
|
-
await sync1.start();
|
|
544
|
-
sync1.stop();
|
|
545
|
-
|
|
546
|
-
clearRemoteFeatureFlagStoreCache();
|
|
547
|
-
expect(readRemoteFeatureFlags()).toEqual({
|
|
548
|
-
browser: true,
|
|
549
|
-
});
|
|
550
|
-
|
|
551
|
-
// Now simulate a response with missing flags field — cached flags should be preserved
|
|
552
|
-
fetchMock = mock(async () => Response.json({ data: "unexpected" }));
|
|
553
|
-
|
|
554
|
-
const sync2 = new RemoteFeatureFlagSync({
|
|
555
|
-
credentials: fakeCredentialCache(defaultCredentials()),
|
|
556
|
-
});
|
|
557
|
-
await sync2.start();
|
|
558
|
-
sync2.stop();
|
|
559
|
-
|
|
560
|
-
clearRemoteFeatureFlagStoreCache();
|
|
561
|
-
const cached = readRemoteFeatureFlags();
|
|
562
|
-
expect(cached).toEqual({ browser: true });
|
|
563
|
-
});
|
|
564
|
-
|
|
565
|
-
test("strips trailing slashes from platform URL", async () => {
|
|
566
|
-
fetchMock = mock(async () => Response.json({ flags: {} }));
|
|
567
|
-
|
|
568
|
-
process.env.CADET_CLOUD_URL = "https://test-cloud.mycadet.ai///";
|
|
569
|
-
const creds = { ...defaultCredentials() };
|
|
570
|
-
const sync = new RemoteFeatureFlagSync({
|
|
571
|
-
credentials: fakeCredentialCache(creds),
|
|
572
|
-
});
|
|
573
|
-
await sync.start();
|
|
574
|
-
sync.stop();
|
|
575
|
-
|
|
576
|
-
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
577
|
-
const [url] = fetchMock.mock.calls[0];
|
|
578
|
-
expect(url).toBe(
|
|
579
|
-
"https://test-cloud.mycadet.ai/v1/feature-flags/assistant-flag-values/",
|
|
580
|
-
);
|
|
581
|
-
});
|
|
582
|
-
|
|
583
|
-
test("normalizes remote false for GA flags (defaultEnabled: true in registry)", async () => {
|
|
584
|
-
// The cloud sends false for all flags it knows about (blanket-deny).
|
|
585
|
-
// GA flags (defaultEnabled: true in the registry) should not be disabled
|
|
586
|
-
// by remote overrides — only local persisted overrides can do that.
|
|
587
|
-
// Uses the test-local registry which defines test-ga-flag as GA
|
|
588
|
-
// (defaultEnabled: true) and a2a-channel as gated (defaultEnabled: false).
|
|
589
|
-
fetchMock = mock(async () =>
|
|
590
|
-
Response.json({
|
|
591
|
-
flags: {
|
|
592
|
-
// GA flag (defaultEnabled: true) — remote false should be normalized
|
|
593
|
-
// to true so the missing-key fallback does not disable it.
|
|
594
|
-
"test-ga-flag": false,
|
|
595
|
-
// Gated flag (defaultEnabled: false) — remote false is kept
|
|
596
|
-
"a2a-channel": false,
|
|
597
|
-
// GA flag set to true — should be kept (redundant but harmless)
|
|
598
|
-
"test-ga-flag-true": true,
|
|
599
|
-
// Unknown flag — remote false is kept (not in registry)
|
|
600
|
-
"unknown-flag": false,
|
|
601
|
-
},
|
|
602
|
-
}),
|
|
603
|
-
);
|
|
604
|
-
|
|
605
|
-
const sync = new RemoteFeatureFlagSync({
|
|
606
|
-
credentials: fakeCredentialCache(defaultCredentials()),
|
|
607
|
-
});
|
|
608
|
-
await sync.start();
|
|
609
|
-
sync.stop();
|
|
610
|
-
|
|
611
|
-
clearRemoteFeatureFlagStoreCache();
|
|
612
|
-
const cached = readRemoteFeatureFlags();
|
|
613
|
-
// test-ga-flag (GA, remote false) should be normalized to true
|
|
614
|
-
expect(cached["test-ga-flag"]).toBe(true);
|
|
615
|
-
// a2a-channel (gated, remote false) should be present
|
|
616
|
-
expect(cached["a2a-channel"]).toBe(false);
|
|
617
|
-
// test-ga-flag-true (unknown but true) should be present
|
|
618
|
-
expect(cached["test-ga-flag-true"]).toBe(true);
|
|
619
|
-
// unknown-flag (not in registry, remote false) should be present
|
|
620
|
-
expect(cached["unknown-flag"]).toBe(false);
|
|
621
|
-
});
|
|
622
|
-
|
|
623
|
-
test("GA normalization does not affect string flag values", async () => {
|
|
624
|
-
fetchMock = mock(async () =>
|
|
625
|
-
Response.json({
|
|
626
|
-
flags: {
|
|
627
|
-
"test-ga-flag": false,
|
|
628
|
-
"a2a-channel": "custom-value",
|
|
629
|
-
},
|
|
630
|
-
}),
|
|
631
|
-
);
|
|
632
|
-
|
|
633
|
-
const sync = new RemoteFeatureFlagSync({
|
|
634
|
-
credentials: fakeCredentialCache(defaultCredentials()),
|
|
635
|
-
});
|
|
636
|
-
await sync.start();
|
|
637
|
-
sync.stop();
|
|
638
|
-
|
|
639
|
-
clearRemoteFeatureFlagStoreCache();
|
|
640
|
-
const cached = readRemoteFeatureFlags();
|
|
641
|
-
// Boolean false for GA flag is normalized to true
|
|
642
|
-
expect(cached["test-ga-flag"]).toBe(true);
|
|
643
|
-
// String values are never subject to GA normalization
|
|
644
|
-
expect(cached["a2a-channel"]).toBe("custom-value");
|
|
645
|
-
});
|
|
646
|
-
|
|
647
|
-
test("calls onChanged when remote flags change", async () => {
|
|
648
|
-
fetchMock = mock(async () =>
|
|
649
|
-
Response.json({
|
|
650
|
-
flags: { "new-flag": true },
|
|
651
|
-
}),
|
|
652
|
-
);
|
|
653
|
-
|
|
654
|
-
const onChanged = mock(() => {});
|
|
655
|
-
const sync = new RemoteFeatureFlagSync({
|
|
656
|
-
credentials: fakeCredentialCache(defaultCredentials()),
|
|
657
|
-
onChanged,
|
|
658
|
-
});
|
|
659
|
-
await sync.start();
|
|
660
|
-
sync.stop();
|
|
661
|
-
|
|
662
|
-
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
663
|
-
expect(onChanged).toHaveBeenCalledTimes(1);
|
|
664
|
-
});
|
|
665
|
-
|
|
666
|
-
test("does not call onChanged when remote flags have not changed", async () => {
|
|
667
|
-
// First sync to seed the file
|
|
668
|
-
fetchMock = mock(async () =>
|
|
669
|
-
Response.json({
|
|
670
|
-
flags: { "stable-flag": true },
|
|
671
|
-
}),
|
|
672
|
-
);
|
|
673
|
-
|
|
674
|
-
const onChanged1 = mock(() => {});
|
|
675
|
-
const sync1 = new RemoteFeatureFlagSync({
|
|
676
|
-
credentials: fakeCredentialCache(defaultCredentials()),
|
|
677
|
-
onChanged: onChanged1,
|
|
678
|
-
});
|
|
679
|
-
await sync1.start();
|
|
680
|
-
sync1.stop();
|
|
681
|
-
expect(onChanged1).toHaveBeenCalledTimes(1);
|
|
682
|
-
|
|
683
|
-
// Second sync with same data — onChanged should NOT fire
|
|
684
|
-
fetchMock = mock(async () =>
|
|
685
|
-
Response.json({
|
|
686
|
-
flags: { "stable-flag": true },
|
|
687
|
-
}),
|
|
688
|
-
);
|
|
689
|
-
|
|
690
|
-
const onChanged2 = mock(() => {});
|
|
691
|
-
const sync2 = new RemoteFeatureFlagSync({
|
|
692
|
-
credentials: fakeCredentialCache(defaultCredentials()),
|
|
693
|
-
onChanged: onChanged2,
|
|
694
|
-
});
|
|
695
|
-
await sync2.start();
|
|
696
|
-
sync2.stop();
|
|
697
|
-
expect(onChanged2).not.toHaveBeenCalled();
|
|
698
|
-
});
|
|
699
|
-
|
|
700
|
-
test("does not call onChanged on fetch failure", async () => {
|
|
701
|
-
fetchMock = mock(
|
|
702
|
-
async () => new Response("Internal Server Error", { status: 500 }),
|
|
703
|
-
);
|
|
704
|
-
|
|
705
|
-
const onChanged = mock(() => {});
|
|
706
|
-
const sync = new RemoteFeatureFlagSync({
|
|
707
|
-
credentials: fakeCredentialCache(defaultCredentials()),
|
|
708
|
-
onChanged,
|
|
709
|
-
});
|
|
710
|
-
await sync.start();
|
|
711
|
-
sync.stop();
|
|
712
|
-
|
|
713
|
-
expect(onChanged).not.toHaveBeenCalled();
|
|
714
|
-
});
|
|
715
|
-
|
|
716
|
-
test("trims whitespace from credential values", async () => {
|
|
717
|
-
fetchMock = mock(async () => Response.json({ flags: {} }));
|
|
718
|
-
|
|
719
|
-
process.env.CADET_CLOUD_URL = " https://test-cloud.mycadet.ai ";
|
|
720
|
-
const creds = {
|
|
721
|
-
"credential/cadet/assistant_api_key": " trimmed-key ",
|
|
722
|
-
};
|
|
723
|
-
const sync = new RemoteFeatureFlagSync({
|
|
724
|
-
credentials: fakeCredentialCache(creds),
|
|
725
|
-
});
|
|
726
|
-
await sync.start();
|
|
727
|
-
sync.stop();
|
|
728
|
-
|
|
729
|
-
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
730
|
-
const [url, init] = fetchMock.mock.calls[0];
|
|
731
|
-
expect(url).toBe(
|
|
732
|
-
"https://test-cloud.mycadet.ai/v1/feature-flags/assistant-flag-values/",
|
|
733
|
-
);
|
|
734
|
-
const headers = init?.headers as Record<string, string>;
|
|
735
|
-
expect(headers.Authorization).toBe("Api-Key trimmed-key");
|
|
736
|
-
});
|
|
737
|
-
|
|
738
|
-
test("polls with backoff when initial fetch fails, then snaps to steady-state on success", async () => {
|
|
739
|
-
// Simulate: first two fetches return 500, third succeeds.
|
|
740
|
-
let fetchCallCount = 0;
|
|
741
|
-
fetchMock = mock(async () => {
|
|
742
|
-
fetchCallCount++;
|
|
743
|
-
if (fetchCallCount <= 2) {
|
|
744
|
-
return new Response("Internal Server Error", { status: 500 });
|
|
745
|
-
}
|
|
746
|
-
return Response.json({ flags: { "backoff-flag": true } });
|
|
747
|
-
});
|
|
748
|
-
|
|
749
|
-
const sync = new RemoteFeatureFlagSync({
|
|
750
|
-
credentials: fakeCredentialCache(defaultCredentials()),
|
|
751
|
-
initialPollIntervalMs: 50,
|
|
752
|
-
});
|
|
753
|
-
await sync.start();
|
|
754
|
-
|
|
755
|
-
// Initial fetch failed (500) — 1 call so far
|
|
756
|
-
expect(fetchCallCount).toBe(1);
|
|
757
|
-
|
|
758
|
-
// Wait for first poll (50ms) — still fails (500)
|
|
759
|
-
await new Promise((r) => setTimeout(r, 80));
|
|
760
|
-
expect(fetchCallCount).toBe(2);
|
|
761
|
-
|
|
762
|
-
// Wait for second poll (100ms = 50ms doubled) — succeeds
|
|
763
|
-
await new Promise((r) => setTimeout(r, 130));
|
|
764
|
-
expect(fetchCallCount).toBe(3);
|
|
765
|
-
|
|
766
|
-
clearRemoteFeatureFlagStoreCache();
|
|
767
|
-
expect(readRemoteFeatureFlags()).toEqual({ "backoff-flag": true });
|
|
768
|
-
|
|
769
|
-
sync.stop();
|
|
770
|
-
});
|
|
771
|
-
|
|
772
|
-
test("snaps to steady-state interval immediately when initial fetch succeeds", async () => {
|
|
773
|
-
fetchMock = mock(async () => Response.json({ flags: { "ok-flag": true } }));
|
|
774
|
-
|
|
775
|
-
const sync = new RemoteFeatureFlagSync({
|
|
776
|
-
credentials: fakeCredentialCache(defaultCredentials()),
|
|
777
|
-
initialPollIntervalMs: 50,
|
|
778
|
-
});
|
|
779
|
-
await sync.start();
|
|
780
|
-
|
|
781
|
-
// Initial fetch succeeded — 1 call
|
|
782
|
-
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
783
|
-
|
|
784
|
-
// Wait past what would be the initial poll interval — should NOT poll
|
|
785
|
-
// again because the interval snapped to steady-state (5 min)
|
|
786
|
-
await new Promise((r) => setTimeout(r, 100));
|
|
787
|
-
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
788
|
-
|
|
789
|
-
sync.stop();
|
|
790
|
-
});
|
|
791
|
-
|
|
792
|
-
test("syncNow during in-flight poll does not create duplicate poll chains", async () => {
|
|
793
|
-
// Simulate a slow fetch that takes 200ms to resolve.
|
|
794
|
-
let fetchCallCount = 0;
|
|
795
|
-
fetchMock = mock(async () => {
|
|
796
|
-
fetchCallCount++;
|
|
797
|
-
await new Promise((r) => setTimeout(r, 200));
|
|
798
|
-
return Response.json({ flags: { ok: true } });
|
|
799
|
-
});
|
|
800
|
-
|
|
801
|
-
const sync = new RemoteFeatureFlagSync({
|
|
802
|
-
credentials: fakeCredentialCache(defaultCredentials()),
|
|
803
|
-
initialPollIntervalMs: 50,
|
|
804
|
-
});
|
|
805
|
-
await sync.start();
|
|
806
|
-
// start() awaits its own fetchAndCache, so fetchCallCount is 1 now.
|
|
807
|
-
expect(fetchCallCount).toBe(1);
|
|
808
|
-
|
|
809
|
-
// Wait for the first poll timer to fire (50ms would be initial, but
|
|
810
|
-
// start succeeded so it snapped to steady-state). Instead, we'll
|
|
811
|
-
// call syncNow() directly — the interesting case is when poll() is
|
|
812
|
-
// already in-flight. To trigger that, we use a short interval.
|
|
813
|
-
sync.stop();
|
|
814
|
-
|
|
815
|
-
// Reset with short interval to create the race:
|
|
816
|
-
fetchCallCount = 0;
|
|
817
|
-
fetchMock = mock(async () => {
|
|
818
|
-
fetchCallCount++;
|
|
819
|
-
// Slow fetch — 150ms
|
|
820
|
-
await new Promise((r) => setTimeout(r, 150));
|
|
821
|
-
return Response.json({ flags: { ok: true } });
|
|
822
|
-
});
|
|
823
|
-
|
|
824
|
-
const sync2 = new RemoteFeatureFlagSync({
|
|
825
|
-
credentials: fakeCredentialCache(defaultCredentials()),
|
|
826
|
-
initialPollIntervalMs: 30,
|
|
827
|
-
});
|
|
828
|
-
await sync2.start(); // 1 fetch (slow, 150ms)
|
|
829
|
-
expect(fetchCallCount).toBe(1);
|
|
830
|
-
|
|
831
|
-
// Wait for poll timer to fire and start its fetch (30ms after start)
|
|
832
|
-
await new Promise((r) => setTimeout(r, 50));
|
|
833
|
-
// poll() has fired and its fetchAndCache() is now in-flight
|
|
834
|
-
|
|
835
|
-
// Call syncNow() while poll's fetch is in-flight
|
|
836
|
-
const syncNowPromise = sync2.syncNow();
|
|
837
|
-
|
|
838
|
-
// Wait for everything to settle
|
|
839
|
-
await syncNowPromise;
|
|
840
|
-
await new Promise((r) => setTimeout(r, 300));
|
|
841
|
-
|
|
842
|
-
// Count how many fetches happened after the race window
|
|
843
|
-
const fetchesDuringRace = fetchCallCount;
|
|
844
|
-
|
|
845
|
-
// Now wait a bit more — if duplicate poll chains exist, we'd see
|
|
846
|
-
// extra fetches firing at the short interval
|
|
847
|
-
await new Promise((r) => setTimeout(r, 200));
|
|
848
|
-
|
|
849
|
-
// Should NOT have extra fetches from a leaked poll chain
|
|
850
|
-
// At most: 1 (start) + 1 (poll) + 1 (syncNow) + 1 (next scheduled poll)
|
|
851
|
-
expect(fetchCallCount).toBeLessThanOrEqual(fetchesDuringRace + 1);
|
|
852
|
-
|
|
853
|
-
sync2.stop();
|
|
854
|
-
});
|
|
855
|
-
|
|
856
|
-
test("doubles poll interval on consecutive failures", async () => {
|
|
857
|
-
// Always fail with 500
|
|
858
|
-
fetchMock = mock(
|
|
859
|
-
async () => new Response("Internal Server Error", { status: 500 }),
|
|
860
|
-
);
|
|
861
|
-
|
|
862
|
-
const sync = new RemoteFeatureFlagSync({
|
|
863
|
-
credentials: fakeCredentialCache(defaultCredentials()),
|
|
864
|
-
initialPollIntervalMs: 50,
|
|
865
|
-
});
|
|
866
|
-
await sync.start();
|
|
867
|
-
|
|
868
|
-
// Initial fetch failed (500) — 1 call
|
|
869
|
-
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
870
|
-
|
|
871
|
-
// After 50ms: first poll fires, still fails → interval doubles to 100ms
|
|
872
|
-
await new Promise((r) => setTimeout(r, 80));
|
|
873
|
-
expect(fetchMock).toHaveBeenCalledTimes(2);
|
|
874
|
-
|
|
875
|
-
// After another 100ms: second poll fires, still fails → interval doubles to 200ms
|
|
876
|
-
await new Promise((r) => setTimeout(r, 130));
|
|
877
|
-
expect(fetchMock).toHaveBeenCalledTimes(3);
|
|
878
|
-
|
|
879
|
-
// After another 200ms: third poll fires
|
|
880
|
-
await new Promise((r) => setTimeout(r, 230));
|
|
881
|
-
expect(fetchMock).toHaveBeenCalledTimes(4);
|
|
882
|
-
|
|
883
|
-
sync.stop();
|
|
884
|
-
});
|
|
885
|
-
|
|
886
|
-
test("pauses polling when platform URL is missing and resumes on invalidation", async () => {
|
|
887
|
-
fetchMock = mock(async () =>
|
|
888
|
-
Response.json({ flags: { "resumed-flag": true } }),
|
|
889
|
-
);
|
|
890
|
-
|
|
891
|
-
const creds = fakeCredentialCache({
|
|
892
|
-
"credential/cadet/assistant_api_key": "test-api-key",
|
|
893
|
-
});
|
|
894
|
-
|
|
895
|
-
const sync = new RemoteFeatureFlagSync({
|
|
896
|
-
credentials: creds,
|
|
897
|
-
initialPollIntervalMs: 50,
|
|
898
|
-
});
|
|
899
|
-
// Missing platform URL from the start — polling should pause
|
|
900
|
-
delete process.env.CADET_CLOUD_URL;
|
|
901
|
-
await sync.start();
|
|
902
|
-
|
|
903
|
-
expect(fetchMock).not.toHaveBeenCalled();
|
|
904
|
-
|
|
905
|
-
// Wait well past the initial poll interval — should still not poll
|
|
906
|
-
await new Promise((r) => setTimeout(r, 200));
|
|
907
|
-
expect(fetchMock).not.toHaveBeenCalled();
|
|
908
|
-
|
|
909
|
-
// Simulate the cloud URL becoming available
|
|
910
|
-
process.env.CADET_CLOUD_URL = "https://test-cloud.mycadet.ai";
|
|
911
|
-
creds._invalidate();
|
|
912
|
-
|
|
913
|
-
// Wait for syncNow to complete (async, fire-and-forget from callback)
|
|
914
|
-
await new Promise((r) => setTimeout(r, 50));
|
|
915
|
-
|
|
916
|
-
// Should have fetched once after credential invalidation
|
|
917
|
-
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
918
|
-
|
|
919
|
-
clearRemoteFeatureFlagStoreCache();
|
|
920
|
-
expect(readRemoteFeatureFlags()).toEqual({ "resumed-flag": true });
|
|
921
|
-
|
|
922
|
-
sync.stop();
|
|
923
|
-
});
|
|
924
|
-
|
|
925
|
-
test("does not downgrade to anonymous fetch when API key is transiently lost", async () => {
|
|
926
|
-
const cache = fakeCredentialCache(defaultCredentials());
|
|
927
|
-
|
|
928
|
-
// Initial authenticated fetch succeeds
|
|
929
|
-
fetchMock = mock(async () =>
|
|
930
|
-
Response.json({ flags: { "per-assistant-flag": true } }),
|
|
931
|
-
);
|
|
932
|
-
|
|
933
|
-
const sync = new RemoteFeatureFlagSync({
|
|
934
|
-
credentials: cache,
|
|
935
|
-
initialPollIntervalMs: 50,
|
|
936
|
-
});
|
|
937
|
-
await sync.start();
|
|
938
|
-
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
939
|
-
|
|
940
|
-
clearRemoteFeatureFlagStoreCache();
|
|
941
|
-
expect(readRemoteFeatureFlags()).toEqual({ "per-assistant-flag": true });
|
|
942
|
-
|
|
943
|
-
// Simulate transient API key loss (CES hiccup) — platform URL still present
|
|
944
|
-
cache._setValues({});
|
|
945
|
-
fetchMock = mock(async () =>
|
|
946
|
-
Response.json({ flags: { "per-assistant-flag": false } }),
|
|
947
|
-
);
|
|
948
|
-
|
|
949
|
-
await sync.syncNow();
|
|
950
|
-
|
|
951
|
-
// Should NOT have fetched — transient key loss triggers error/backoff,
|
|
952
|
-
// not an anonymous fetch that would overwrite per-assistant values.
|
|
953
|
-
expect(fetchMock).not.toHaveBeenCalled();
|
|
954
|
-
|
|
955
|
-
// Cached values from the previous authenticated fetch are preserved.
|
|
956
|
-
clearRemoteFeatureFlagStoreCache();
|
|
957
|
-
expect(readRemoteFeatureFlags()).toEqual({ "per-assistant-flag": true });
|
|
958
|
-
|
|
959
|
-
sync.stop();
|
|
960
|
-
});
|
|
961
|
-
});
|