@mycadet/assistant 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.
Files changed (180) hide show
  1. package/node_modules/@mycadet/ces-client/node_modules/@mycadet/service-contracts/src/__tests__/ingress.test.ts +10 -10
  2. package/node_modules/@mycadet/ces-client/node_modules/@mycadet/service-contracts/src/ingress.ts +3 -3
  3. package/node_modules/@mycadet/ces-client/src/http-credentials.ts +18 -1
  4. package/node_modules/@mycadet/gateway-client/node_modules/@mycadet/service-contracts/src/__tests__/ingress.test.ts +10 -10
  5. package/node_modules/@mycadet/gateway-client/node_modules/@mycadet/service-contracts/src/ingress.ts +3 -3
  6. package/node_modules/@mycadet/gateway-client/src/gateway-ipc-contracts.ts +0 -20
  7. package/node_modules/@mycadet/service-contracts/src/__tests__/ingress.test.ts +10 -10
  8. package/node_modules/@mycadet/service-contracts/src/ingress.ts +3 -3
  9. package/openapi.yaml +2 -708
  10. package/package.json +2 -3
  11. package/scripts/sync-web-search-catalog.ts +1 -2
  12. package/src/__tests__/cloud-profile-guard.test.ts +41 -5
  13. package/src/__tests__/config-schema.test.ts +6 -38
  14. package/src/__tests__/conversation-queue.test.ts +45 -6
  15. package/src/__tests__/db-readiness-http-gate.test.ts +0 -6
  16. package/src/__tests__/persist-onboarding-artifacts.test.ts +0 -1
  17. package/src/__tests__/persona-resolver.test.ts +0 -51
  18. package/src/__tests__/provider-cloud-proxy-integration.test.ts +2 -110
  19. package/src/__tests__/run-due-schedules.test.ts +0 -4
  20. package/src/__tests__/schedule-store.test.ts +0 -12
  21. package/src/__tests__/stt-stream-session.test.ts +4 -4
  22. package/src/__tests__/telephony-tts-guard.test.ts +2 -50
  23. package/src/__tests__/voice-config-update.test.ts +12 -167
  24. package/src/__tests__/web-search-catalog-parity.test.ts +0 -2
  25. package/src/__tests__/workspace-migration-130-speech-mode-to-provider.test.ts +9 -7
  26. package/src/__tests__/workspace-migration-132-web-search-mode-to-provider.test.ts +6 -6
  27. package/src/calls/__tests__/voice-session-bridge.test.ts +1 -4
  28. package/src/calls/resolve-call-tts-provider.ts +6 -25
  29. package/src/calls/telephony-credential-preflight.ts +0 -10
  30. package/src/calls/telephony-tts-capability.ts +1 -17
  31. package/src/cli/commands/tts.ts +1 -2
  32. package/src/cli/index.help.ts +0 -2
  33. package/src/cli/program.ts +0 -2
  34. package/src/config/bundled-skills/settings/tools/voice-config-update.ts +4 -42
  35. package/src/config/schemas/__tests__/stt.test.ts +10 -14
  36. package/src/config/schemas/llm.ts +3 -5
  37. package/src/config/schemas/services.ts +5 -6
  38. package/src/config/schemas/stt.ts +1 -3
  39. package/src/config/schemas/tts.ts +1 -30
  40. package/src/daemon/daemon-readiness.ts +4 -29
  41. package/src/daemon/lifecycle.ts +0 -31
  42. package/src/daemon/providers-setup.ts +0 -8
  43. package/src/heartbeat/__tests__/heartbeat-service.test.ts +0 -1
  44. package/src/heartbeat/heartbeat-service.ts +1 -66
  45. package/src/ipc/__tests__/db-readiness-ipc-gate.test.ts +1 -9
  46. package/src/live-voice/__tests__/live-voice-credential-preflight.test.ts +0 -44
  47. package/src/live-voice/__tests__/live-voice-flux-turn-end.test.ts +4 -6
  48. package/src/live-voice/__tests__/live-voice-stt.test.ts +1 -1
  49. package/src/live-voice/live-voice-credential-preflight.ts +9 -33
  50. package/src/live-voice/live-voice-session.ts +10 -6
  51. package/src/live-voice/live-voice-tts.ts +1 -8
  52. package/src/plugins/defaults/memory/worker.ts +3 -3
  53. package/src/prompts/persona-resolver.ts +0 -35
  54. package/src/providers/__tests__/search-provider-catalog.test.ts +5 -8
  55. package/src/providers/__tests__/voice-error-copy.test.ts +9 -10
  56. package/src/providers/search-provider-catalog.ts +7 -39
  57. package/src/providers/speech-to-text/__tests__/provider-catalog.test.ts +3 -47
  58. package/src/providers/speech-to-text/__tests__/resolve.test.ts +6 -372
  59. package/src/providers/speech-to-text/provider-catalog.ts +2 -73
  60. package/src/providers/speech-to-text/resolve.ts +3 -120
  61. package/src/runtime/http-router-types.ts +1 -3
  62. package/src/runtime/http-server.ts +1 -1
  63. package/src/runtime/routes/__tests__/conversation-query-routes.test.ts +13 -11
  64. package/src/runtime/routes/__tests__/tts-routes.test.ts +2 -6
  65. package/src/runtime/routes/conversation-query-routes.ts +0 -96
  66. package/src/runtime/routes/inbound-stages/transcribe-audio.test.ts +7 -7
  67. package/src/runtime/routes/index.ts +0 -4
  68. package/src/runtime/routes/live-voice-routes.test.ts +4 -11
  69. package/src/runtime/routes/live-voice-routes.ts +8 -11
  70. package/src/runtime/routes/secret-routes.ts +0 -6
  71. package/src/runtime/routes/tts-routes.ts +0 -38
  72. package/src/schedule/__tests__/plugin-schedule-reconciler.test.ts +1 -7
  73. package/src/schedule/__tests__/worker-mcp-readiness.test.ts +0 -4
  74. package/src/schedule/schedule-store.ts +0 -7
  75. package/src/schedule/scheduler.ts +0 -5
  76. package/src/schedule/worker.ts +1 -1
  77. package/src/stt/__tests__/daemon-batch-transcriber.test.ts +2 -10
  78. package/src/stt/daemon-batch-transcriber.ts +4 -38
  79. package/src/stt/types.ts +1 -9
  80. package/src/tools/network/__tests__/web-search.test.ts +3 -250
  81. package/src/tools/network/web-search.ts +4 -165
  82. package/src/tts/__tests__/provider-resolution.test.ts +0 -5
  83. package/src/tts/provider-catalog.ts +0 -4
  84. package/src/tts/types.ts +0 -1
  85. package/src/__tests__/cloud-speech-defaults.test.ts +0 -330
  86. package/src/__tests__/migration-cross-version-compatibility.test.ts +0 -1708
  87. package/src/__tests__/migration-export-http.test.ts +0 -557
  88. package/src/__tests__/migration-export-streaming.test.ts +0 -350
  89. package/src/__tests__/migration-export-to-gcs.test.ts +0 -706
  90. package/src/__tests__/migration-import-commit-http.test.ts +0 -1176
  91. package/src/__tests__/migration-import-from-gcs.test.ts +0 -534
  92. package/src/__tests__/migration-import-from-path.test.ts +0 -324
  93. package/src/__tests__/migration-import-from-url.test.ts +0 -588
  94. package/src/__tests__/migration-import-preflight-http.test.ts +0 -739
  95. package/src/__tests__/migration-jobs-status.test.ts +0 -139
  96. package/src/__tests__/migration-parity-persistence.test.ts +0 -1891
  97. package/src/__tests__/migration-transport.test.ts +0 -1089
  98. package/src/__tests__/migration-validate-http.test.ts +0 -634
  99. package/src/__tests__/migration-wizard.test.ts +0 -1395
  100. package/src/__tests__/rebind-secrets-screen.test.ts +0 -876
  101. package/src/__tests__/sanitize-config-for-transfer.test.ts +0 -259
  102. package/src/__tests__/transfer-progress-screen.test.ts +0 -1217
  103. package/src/__tests__/validation-results-screen.test.ts +0 -1130
  104. package/src/__tests__/vbundle-pax-and-symlink.test.ts +0 -147
  105. package/src/background-wake/background-wake-routes.test.ts +0 -906
  106. package/src/background-wake/cloud-client.test.ts +0 -328
  107. package/src/background-wake/cloud-client.ts +0 -196
  108. package/src/background-wake/next-wake.test.ts +0 -335
  109. package/src/background-wake/next-wake.ts +0 -188
  110. package/src/background-wake/publisher.ts +0 -93
  111. package/src/background-wake/wake-intent-hooks.test.ts +0 -234
  112. package/src/cli/commands/__tests__/backup.test.ts +0 -363
  113. package/src/cli/commands/backup.help.ts +0 -180
  114. package/src/cli/commands/backup.ts +0 -398
  115. package/src/cloud/cloud-speech.test.ts +0 -335
  116. package/src/cloud/cloud-speech.ts +0 -300
  117. package/src/config/cloud-speech-defaults.ts +0 -300
  118. package/src/config/sanitize-for-transfer.ts +0 -111
  119. package/src/live-voice/__tests__/live-voice-cloud-speech-fallback.test.ts +0 -250
  120. package/src/providers/speech-to-text/__tests__/cadet-cloud-realtime.test.ts +0 -457
  121. package/src/providers/speech-to-text/__tests__/cadet-cloud.test.ts +0 -271
  122. package/src/providers/speech-to-text/__tests__/cadet-speech-relay-connection.test.ts +0 -126
  123. package/src/providers/speech-to-text/cadet-cloud-flux-realtime.ts +0 -309
  124. package/src/providers/speech-to-text/cadet-cloud-realtime.ts +0 -315
  125. package/src/providers/speech-to-text/cadet-cloud.ts +0 -108
  126. package/src/providers/speech-to-text/cadet-speech-relay-connection.ts +0 -176
  127. package/src/runtime/migrations/__tests__/gcs-signed-url.test.ts +0 -254
  128. package/src/runtime/migrations/__tests__/job-registry.test.ts +0 -346
  129. package/src/runtime/migrations/__tests__/origin-mode.test.ts +0 -62
  130. package/src/runtime/migrations/__tests__/rebind-secrets-credentials.test.ts +0 -184
  131. package/src/runtime/migrations/__tests__/staged-import-path.test.ts +0 -110
  132. package/src/runtime/migrations/__tests__/v1-test-helpers.ts +0 -112
  133. package/src/runtime/migrations/__tests__/vbundle-builder-credentials.test.ts +0 -285
  134. package/src/runtime/migrations/__tests__/vbundle-builder-fd-leak.test.ts +0 -126
  135. package/src/runtime/migrations/__tests__/vbundle-builder-v1-shape.test.ts +0 -253
  136. package/src/runtime/migrations/__tests__/vbundle-import-credentials.test.ts +0 -208
  137. package/src/runtime/migrations/__tests__/vbundle-import-parity.test.ts +0 -485
  138. package/src/runtime/migrations/__tests__/vbundle-import-policy.test.ts +0 -349
  139. package/src/runtime/migrations/__tests__/vbundle-import-version-compat.test.ts +0 -196
  140. package/src/runtime/migrations/__tests__/vbundle-legacy-user-md.test.ts +0 -551
  141. package/src/runtime/migrations/__tests__/vbundle-metadata-merge-integration.test.ts +0 -428
  142. package/src/runtime/migrations/__tests__/vbundle-metadata-merge.test.ts +0 -220
  143. package/src/runtime/migrations/__tests__/vbundle-streaming-importer.test.ts +0 -1823
  144. package/src/runtime/migrations/__tests__/vbundle-streaming-validator.test.ts +0 -598
  145. package/src/runtime/migrations/__tests__/vbundle-symlink-importer.test.ts +0 -463
  146. package/src/runtime/migrations/__tests__/vbundle-symlink-streaming-importer.test.ts +0 -538
  147. package/src/runtime/migrations/__tests__/vbundle-symlink-streaming.test.ts +0 -515
  148. package/src/runtime/migrations/__tests__/vbundle-symlink-tar.test.ts +0 -453
  149. package/src/runtime/migrations/__tests__/vbundle-symlink-walker.test.ts +0 -319
  150. package/src/runtime/migrations/__tests__/vbundle-tar-stream.test.ts +0 -240
  151. package/src/runtime/migrations/__tests__/vbundle-validator-v1-schema.test.ts +0 -473
  152. package/src/runtime/migrations/gcs-signed-url.ts +0 -190
  153. package/src/runtime/migrations/job-registry.ts +0 -297
  154. package/src/runtime/migrations/migration-transport.ts +0 -612
  155. package/src/runtime/migrations/migration-wizard.ts +0 -747
  156. package/src/runtime/migrations/origin-mode.ts +0 -42
  157. package/src/runtime/migrations/rebind-secrets-screen.ts +0 -412
  158. package/src/runtime/migrations/staged-import-path.ts +0 -116
  159. package/src/runtime/migrations/transfer-progress-screen.ts +0 -323
  160. package/src/runtime/migrations/validation-results-screen.ts +0 -469
  161. package/src/runtime/migrations/vbundle-builder.ts +0 -1382
  162. package/src/runtime/migrations/vbundle-import-analyzer.ts +0 -405
  163. package/src/runtime/migrations/vbundle-import-policy.ts +0 -241
  164. package/src/runtime/migrations/vbundle-importer.ts +0 -826
  165. package/src/runtime/migrations/vbundle-metadata-merge.ts +0 -113
  166. package/src/runtime/migrations/vbundle-streaming-importer.ts +0 -2844
  167. package/src/runtime/migrations/vbundle-streaming-validator.ts +0 -426
  168. package/src/runtime/migrations/vbundle-tar-stream.ts +0 -244
  169. package/src/runtime/migrations/vbundle-validator.ts +0 -724
  170. package/src/runtime/routes/__tests__/migration-cadet-metadata-reconcile.test.ts +0 -227
  171. package/src/runtime/routes/__tests__/migration-export-secrets-redacted.test.ts +0 -54
  172. package/src/runtime/routes/__tests__/migration-import-allowed-hosts-env.test.ts +0 -70
  173. package/src/runtime/routes/__tests__/migration-import-credential-filter.test.ts +0 -257
  174. package/src/runtime/routes/background-wake-routes.ts +0 -387
  175. package/src/runtime/routes/migration-routes.ts +0 -2584
  176. package/src/tools/network/__tests__/cloud-search-proxy.test.ts +0 -282
  177. package/src/tools/network/cloud-search-proxy.ts +0 -211
  178. package/src/tts/__tests__/cadet-provider.test.ts +0 -253
  179. package/src/tts/providers/cadet-provider.ts +0 -244
  180. package/src/types/tar-stream.d.ts +0 -66
@@ -1,706 +0,0 @@
1
- /**
2
- * Integration tests for POST /v1/migrations/export-to-gcs.
3
- *
4
- * Covered:
5
- * - Happy path: the handler returns 202 + job_id, the background job
6
- * PUTs a valid .vbundle to the fixture server, and the final job
7
- * record carries a matching result payload.
8
- * - Concurrency: a second export-to-gcs while the first is still
9
- * pending/running returns 409 `export_in_progress`.
10
- * - Upload failure: when the PUT server responds 500, the job ends
11
- * `failed` with `error.code === "upload_failed"` and
12
- * `upstreamStatus: 500`.
13
- * - Invalid URL: non-https, wrong host, and path-traversal URLs are
14
- * rejected at the handler (400) with the validator's reason code
15
- * in `error.reason`.
16
- *
17
- * The .vbundle bytes observed by the fixture server are cross-checked
18
- * against `validateVBundle` to prove the upload body matches what
19
- * `streamExportVBundle()` would produce.
20
- */
21
-
22
- import { mkdirSync, writeFileSync } from "node:fs";
23
- import { createServer, type Server } from "node:http";
24
- import { join } from "node:path";
25
- import {
26
- afterAll,
27
- afterEach,
28
- beforeAll,
29
- describe,
30
- expect,
31
- mock,
32
- test,
33
- } from "bun:test";
34
-
35
- const testDir = process.env.CADET_WORKSPACE_DIR!;
36
- const testDbDir = join(testDir, "data", "db");
37
- const testDbPath = join(testDbDir, "assistant.db");
38
- const testConfigPath = join(testDir, "config.json");
39
-
40
- // The gateway's debug export arrives over IPC as a base64 tar.gz. Stand in
41
- // for the socket so the debug profile can be exercised without a gateway.
42
- const FAKE_GATEWAY_ARCHIVE = Buffer.from("not really a tar.gz");
43
- const gatewayDebugExportMock = mock(async () => ({
44
- ok: true as const,
45
- archive_base64: FAKE_GATEWAY_ARCHIVE.toString("base64"),
46
- size_bytes: FAKE_GATEWAY_ARCHIVE.length,
47
- }));
48
- mock.module("../ipc/gateway-client.js", () => ({
49
- ipcCallPersistent: (method: string) => {
50
- if (method !== "gateway_debug_export") {
51
- throw new Error(`unexpected IPC method ${method}`);
52
- }
53
- return gatewayDebugExportMock();
54
- },
55
- }));
56
-
57
- // Force the credential collector onto the unreachable branch so the
58
- // handler treats it as "export without credentials" (fast, no CES).
59
- // The REAL secure-keys module pinned to a faultable memory backend with the
60
- // whole vault faulted: the handler treats it as "export without credentials"
61
- // (fast, no CES).
62
- import { useFaultableSecureKeyMap } from "./helpers/secure-keys.js";
63
-
64
- const { setAllUnreachable } = useFaultableSecureKeyMap();
65
- setAllUnreachable(true);
66
-
67
- // ---------------------------------------------------------------------------
68
- // Imports (after mocks so module-level code picks up the stubs)
69
- // ---------------------------------------------------------------------------
70
-
71
- import { migrationJobs } from "../runtime/migrations/job-registry.js";
72
- import { validateVBundle } from "../runtime/migrations/vbundle-validator.js";
73
- import {
74
- _setUrlImportValidatorOptionsForTests,
75
- handleMigrationExportToGcs,
76
- } from "../runtime/routes/migration-routes.js";
77
- import { callHandler } from "./helpers/call-route-handler.js";
78
-
79
- // ---------------------------------------------------------------------------
80
- // Fixture workspace: write a minimal SQLite file + config so the exporter
81
- // has real data to walk.
82
- // ---------------------------------------------------------------------------
83
-
84
- const SQLITE_HEADER = new Uint8Array([
85
- 0x53, 0x51, 0x4c, 0x69, 0x74, 0x65, 0x20, 0x66, 0x6f, 0x72, 0x6d, 0x61, 0x74,
86
- 0x20, 0x33, 0x00,
87
- ]);
88
- const TEST_CONFIG = { provider: "anthropic", model: "test-model" };
89
-
90
- beforeAll(() => {
91
- mkdirSync(testDbDir, { recursive: true });
92
- writeFileSync(testDbPath, SQLITE_HEADER);
93
- writeFileSync(testConfigPath, JSON.stringify(TEST_CONFIG, null, 2));
94
-
95
- // Widen the URL validator allowlist so the handler accepts a URL
96
- // pointing at the local fixture server.
97
- _setUrlImportValidatorOptionsForTests({
98
- allowedHosts: ["127.0.0.1", "storage.googleapis.com"],
99
- });
100
- });
101
-
102
- afterAll(() => {
103
- _setUrlImportValidatorOptionsForTests(undefined);
104
- });
105
-
106
- // Drop stale job records between tests so concurrency assertions are
107
- // deterministic (a `failed` job from a previous test must not collide
108
- // with the next test's `startJob("export", …)` call — the registry only
109
- // rejects while the prior job is still pending/running, but evicting the
110
- // entry defensively keeps log output clean too).
111
- afterEach(async () => {
112
- // Wait a tick so any in-flight runners can finalize their state.
113
- await new Promise((resolve) => setTimeout(resolve, 0));
114
- // Force-evict any remaining terminal records by tightening the TTL.
115
- const savedTtl = migrationJobs.completedJobTtlMs;
116
- migrationJobs.completedJobTtlMs = 0;
117
- migrationJobs.sweep();
118
- migrationJobs.completedJobTtlMs = savedTtl;
119
- });
120
-
121
- // ---------------------------------------------------------------------------
122
- // Local http fixture server that captures PUT bodies
123
- // ---------------------------------------------------------------------------
124
-
125
- interface FixtureServer {
126
- server: Server;
127
- port: number;
128
- close: () => Promise<void>;
129
- }
130
-
131
- async function startFixtureServer(
132
- handler: (
133
- req: import("node:http").IncomingMessage,
134
- res: import("node:http").ServerResponse,
135
- ) => void,
136
- ): Promise<FixtureServer> {
137
- const server = createServer(handler);
138
- await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
139
- const address = server.address();
140
- if (!address || typeof address === "string") {
141
- throw new Error("fixture server bound to unexpected address");
142
- }
143
- return {
144
- server,
145
- port: address.port,
146
- close: async () => {
147
- await new Promise<void>((resolve) => server.close(() => resolve()));
148
- },
149
- };
150
- }
151
-
152
- function makeFakeSignedUploadUrl(port: number): string {
153
- // `validateGcsSignedUrl` requires a signature query param; pin a dummy.
154
- return `http://127.0.0.1:${port}/upload/bundle?X-Goog-Signature=fake`;
155
- }
156
-
157
- /** Read the full body of an incoming request into a Buffer. */
158
- function collectBody(
159
- req: import("node:http").IncomingMessage,
160
- ): Promise<Buffer> {
161
- return new Promise((resolve, reject) => {
162
- const chunks: Buffer[] = [];
163
- req.on("data", (chunk: Buffer) => chunks.push(chunk));
164
- req.on("end", () => resolve(Buffer.concat(chunks)));
165
- req.on("error", reject);
166
- });
167
- }
168
-
169
- /** Poll `migrationJobs.getJob(id)` until the job reaches a terminal state. */
170
- async function waitForJobTerminal(
171
- id: string,
172
- { timeoutMs = 10_000 }: { timeoutMs?: number } = {},
173
- ): Promise<NonNullable<ReturnType<typeof migrationJobs.getJob>>> {
174
- const deadline = Date.now() + timeoutMs;
175
- while (Date.now() < deadline) {
176
- const job = migrationJobs.getJob(id);
177
- if (job && (job.status === "complete" || job.status === "failed")) {
178
- return job;
179
- }
180
- await new Promise((resolve) => setTimeout(resolve, 10));
181
- }
182
- throw new Error(`Job ${id} did not reach a terminal state in time`);
183
- }
184
-
185
- // ---------------------------------------------------------------------------
186
- // Response shape types
187
- // ---------------------------------------------------------------------------
188
-
189
- interface AcceptedResponse {
190
- job_id: string;
191
- status: "pending";
192
- type: "export";
193
- }
194
-
195
- interface ErrorEnvelope {
196
- error: {
197
- code: string;
198
- reason?: string;
199
- job_id?: string;
200
- message?: string;
201
- };
202
- }
203
-
204
- // ---------------------------------------------------------------------------
205
- // Tests
206
- // ---------------------------------------------------------------------------
207
-
208
- describe("handleMigrationExportToGcs — happy path", () => {
209
- test("202 + job completes with a valid .vbundle PUT to the upload URL", async () => {
210
- let capturedBody: Buffer | undefined;
211
- let capturedContentType: string | undefined;
212
-
213
- const fixture = await startFixtureServer(async (req, res) => {
214
- if (req.method !== "PUT") {
215
- res.writeHead(405);
216
- res.end();
217
- return;
218
- }
219
- capturedContentType = req.headers["content-type"];
220
- capturedBody = await collectBody(req);
221
- res.writeHead(200);
222
- res.end();
223
- });
224
-
225
- try {
226
- const req = new Request("http://localhost/v1/migrations/export-to-gcs", {
227
- method: "POST",
228
- headers: { "Content-Type": "application/json" },
229
- body: JSON.stringify({
230
- upload_url: makeFakeSignedUploadUrl(fixture.port),
231
- description: "happy-path export",
232
- }),
233
- });
234
-
235
- const res = await callHandler(
236
- handleMigrationExportToGcs,
237
- req,
238
- undefined,
239
- 202,
240
- );
241
- expect(res.status).toBe(202);
242
-
243
- const body = (await res.json()) as AcceptedResponse;
244
- expect(body.status).toBe("pending");
245
- expect(body.type).toBe("export");
246
- expect(body.job_id).toBeDefined();
247
- expect(typeof body.job_id).toBe("string");
248
-
249
- const terminal = await waitForJobTerminal(body.job_id);
250
- expect(terminal.status).toBe("complete");
251
- expect(terminal.error).toBeUndefined();
252
-
253
- const result = terminal.result as {
254
- size: number;
255
- sha256: string;
256
- schemaVersion: string;
257
- credentialsIncluded: number;
258
- };
259
- expect(result.size).toBeGreaterThan(0);
260
- expect(typeof result.sha256).toBe("string");
261
- expect(result.sha256.length).toBeGreaterThan(0);
262
- expect(result.schemaVersion).toBeDefined();
263
- expect(result.credentialsIncluded).toBe(0);
264
-
265
- expect(capturedContentType).toBe("application/octet-stream");
266
- expect(capturedBody).toBeDefined();
267
- expect(capturedBody!.byteLength).toBe(result.size);
268
-
269
- // The uploaded body is a real .vbundle — validate it.
270
- const validation = validateVBundle(new Uint8Array(capturedBody!));
271
- expect(validation.is_valid).toBe(true);
272
- expect(validation.errors).toHaveLength(0);
273
- expect(validation.manifest?.checksum).toBe(result.sha256);
274
- } finally {
275
- await fixture.close();
276
- }
277
- }, 15_000);
278
-
279
- test("managed origin skips credential collection and redacts secrets", async () => {
280
- // The secure-keys mock reports the store unreachable, which normally
281
- // forces `secrets_redacted: false` (we can't prove the bundle is clean).
282
- // In managed mode the handler must skip collection entirely — the
283
- // manifest schema refuses managed bundles carrying secrets — so the
284
- // manifest claims a truthful `secrets_redacted: true` regardless of
285
- // credential-store state, and the bundle passes validation with
286
- // `origin.mode: "managed"`.
287
- const originalIsPlatform = process.env.IS_CLOUD;
288
- process.env.IS_CLOUD = "true";
289
-
290
- let capturedBody: Buffer | undefined;
291
- const fixture = await startFixtureServer(async (req, res) => {
292
- if (req.method !== "PUT") {
293
- res.writeHead(405);
294
- res.end();
295
- return;
296
- }
297
- capturedBody = await collectBody(req);
298
- res.writeHead(200);
299
- res.end();
300
- });
301
-
302
- try {
303
- const req = new Request("http://localhost/v1/migrations/export-to-gcs", {
304
- method: "POST",
305
- headers: { "Content-Type": "application/json" },
306
- body: JSON.stringify({
307
- upload_url: makeFakeSignedUploadUrl(fixture.port),
308
- description: "managed export",
309
- }),
310
- });
311
-
312
- const res = await callHandler(
313
- handleMigrationExportToGcs,
314
- req,
315
- undefined,
316
- 202,
317
- );
318
- const body = (await res.json()) as AcceptedResponse;
319
- const terminal = await waitForJobTerminal(body.job_id);
320
- expect(terminal.status).toBe("complete");
321
-
322
- const result = terminal.result as { credentialsIncluded: number };
323
- expect(result.credentialsIncluded).toBe(0);
324
-
325
- const validation = validateVBundle(new Uint8Array(capturedBody!));
326
- expect(validation.is_valid).toBe(true);
327
- expect(validation.manifest?.origin.mode).toBe("managed");
328
- expect(validation.manifest?.secrets_redacted).toBe(true);
329
- } finally {
330
- if (originalIsPlatform === undefined) {
331
- delete process.env.IS_CLOUD;
332
- } else {
333
- process.env.IS_CLOUD = originalIsPlatform;
334
- }
335
- await fixture.close();
336
- }
337
- }, 15_000);
338
- });
339
-
340
- describe("handleMigrationExportToGcs — debug profile", () => {
341
- test("adds the gateway's archive under gateway/ and marks the manifest", async () => {
342
- gatewayDebugExportMock.mockClear();
343
- let capturedBody: Buffer | undefined;
344
- const fixture = await startFixtureServer(async (req, res) => {
345
- capturedBody = await collectBody(req);
346
- res.writeHead(200);
347
- res.end();
348
- });
349
-
350
- try {
351
- const req = new Request("http://localhost/v1/migrations/export-to-gcs", {
352
- method: "POST",
353
- headers: { "Content-Type": "application/json" },
354
- body: JSON.stringify({
355
- upload_url: makeFakeSignedUploadUrl(fixture.port),
356
- profile: "debug",
357
- }),
358
- });
359
- const res = await callHandler(
360
- handleMigrationExportToGcs,
361
- req,
362
- undefined,
363
- 202,
364
- );
365
- const body = (await res.json()) as AcceptedResponse;
366
- const terminal = await waitForJobTerminal(body.job_id);
367
- expect(terminal.status).toBe("complete");
368
-
369
- expect(gatewayDebugExportMock).toHaveBeenCalledTimes(1);
370
- const validation = validateVBundle(new Uint8Array(capturedBody!));
371
- expect(validation.is_valid).toBe(true);
372
- const manifest = validation.manifest!;
373
- expect(manifest.export_options.include_gateway).toBe(true);
374
- const gatewayEntry = manifest.contents.find(
375
- (f) => f.path === "gateway/export.tar.gz",
376
- );
377
- expect(gatewayEntry?.size_bytes).toBe(FAKE_GATEWAY_ARCHIVE.length);
378
- // Staff never receive credentials. The store mock reports itself
379
- // unreachable, which would force `secrets_redacted: false` had the
380
- // handler tried to collect them.
381
- expect(manifest.secrets_redacted).toBe(true);
382
- expect(
383
- manifest.contents.some((f) => f.path.startsWith("credentials/")),
384
- ).toBe(false);
385
- } finally {
386
- await fixture.close();
387
- }
388
- }, 15_000);
389
-
390
- test("the default profile never asks the gateway", async () => {
391
- gatewayDebugExportMock.mockClear();
392
- let capturedBody: Buffer | undefined;
393
- const fixture = await startFixtureServer(async (req, res) => {
394
- capturedBody = await collectBody(req);
395
- res.writeHead(200);
396
- res.end();
397
- });
398
-
399
- try {
400
- const req = new Request("http://localhost/v1/migrations/export-to-gcs", {
401
- method: "POST",
402
- headers: { "Content-Type": "application/json" },
403
- body: JSON.stringify({
404
- upload_url: makeFakeSignedUploadUrl(fixture.port),
405
- }),
406
- });
407
- const res = await callHandler(
408
- handleMigrationExportToGcs,
409
- req,
410
- undefined,
411
- 202,
412
- );
413
- const body = (await res.json()) as AcceptedResponse;
414
- const terminal = await waitForJobTerminal(body.job_id);
415
- expect(terminal.status).toBe("complete");
416
-
417
- expect(gatewayDebugExportMock).not.toHaveBeenCalled();
418
- const manifest = validateVBundle(new Uint8Array(capturedBody!)).manifest!;
419
- expect(manifest.export_options.include_gateway).toBeUndefined();
420
- expect(manifest.contents.some((f) => f.path.startsWith("gateway/"))).toBe(
421
- false,
422
- );
423
- } finally {
424
- await fixture.close();
425
- }
426
- }, 15_000);
427
-
428
- test("a debug bundle is not sent at all if the gateway cannot export", async () => {
429
- gatewayDebugExportMock.mockImplementationOnce(async () => {
430
- throw new Error("gateway socket missing");
431
- });
432
- let putCount = 0;
433
- const fixture = await startFixtureServer(async (_req, res) => {
434
- putCount += 1;
435
- res.writeHead(200);
436
- res.end();
437
- });
438
-
439
- try {
440
- const req = new Request("http://localhost/v1/migrations/export-to-gcs", {
441
- method: "POST",
442
- headers: { "Content-Type": "application/json" },
443
- body: JSON.stringify({
444
- upload_url: makeFakeSignedUploadUrl(fixture.port),
445
- profile: "debug",
446
- }),
447
- });
448
- const res = await callHandler(
449
- handleMigrationExportToGcs,
450
- req,
451
- undefined,
452
- 202,
453
- );
454
- const body = (await res.json()) as { job_id: string };
455
- const terminal = await waitForJobTerminal(body.job_id);
456
- expect(terminal.status).toBe("failed");
457
- expect(terminal.error?.code).toBe("gateway_debug_export_failed");
458
- expect(putCount).toBe(0);
459
- } finally {
460
- await fixture.close();
461
- }
462
- }, 15_000);
463
- });
464
-
465
- describe("handleMigrationExportToGcs — concurrency", () => {
466
- test("second export while first is running returns 409 export_in_progress", async () => {
467
- // First fixture: deliberately slow so the first job stays pending long
468
- // enough for the second request to collide with it.
469
- let release: () => void = () => {};
470
- const released = new Promise<void>((resolve) => {
471
- release = resolve;
472
- });
473
-
474
- const slow = await startFixtureServer(async (req, res) => {
475
- if (req.method !== "PUT") {
476
- res.writeHead(405);
477
- res.end();
478
- return;
479
- }
480
- // Consume the body but defer the response until the test says so.
481
- await collectBody(req);
482
- await released;
483
- res.writeHead(200);
484
- res.end();
485
- });
486
-
487
- // Second fixture: never hit in this test — but we still need a valid
488
- // URL shape so the handler gets past URL validation on the second
489
- // call. We point the second request at the same fixture URL; the
490
- // handler rejects before the URL is fetched.
491
- try {
492
- const firstReq = new Request(
493
- "http://localhost/v1/migrations/export-to-gcs",
494
- {
495
- method: "POST",
496
- headers: { "Content-Type": "application/json" },
497
- body: JSON.stringify({
498
- upload_url: makeFakeSignedUploadUrl(slow.port),
499
- }),
500
- },
501
- );
502
- const firstRes = await callHandler(
503
- handleMigrationExportToGcs,
504
- firstReq,
505
- undefined,
506
- 202,
507
- );
508
- expect(firstRes.status).toBe(202);
509
- const firstBody = (await firstRes.json()) as AcceptedResponse;
510
-
511
- // Yield to the microtask queue so the first job's runner actually
512
- // transitions from pending → running before we try to start a
513
- // second one.
514
- await new Promise((resolve) => setTimeout(resolve, 50));
515
-
516
- const secondReq = new Request(
517
- "http://localhost/v1/migrations/export-to-gcs",
518
- {
519
- method: "POST",
520
- headers: { "Content-Type": "application/json" },
521
- body: JSON.stringify({
522
- upload_url: makeFakeSignedUploadUrl(slow.port),
523
- }),
524
- },
525
- );
526
- const secondRes = await callHandler(
527
- handleMigrationExportToGcs,
528
- secondReq,
529
- undefined,
530
- 202,
531
- );
532
- expect(secondRes.status).toBe(409);
533
- const secondBody = (await secondRes.json()) as ErrorEnvelope;
534
- expect(secondBody.error.code).toBe("export_in_progress");
535
- expect(secondBody.error.message).toContain(firstBody.job_id);
536
-
537
- // Unblock the first fixture so the first job can finalize and the
538
- // afterEach cleanup sweeps it out.
539
- release();
540
- await waitForJobTerminal(firstBody.job_id);
541
- } finally {
542
- release();
543
- await slow.close();
544
- }
545
- }, 20_000);
546
- });
547
-
548
- describe("handleMigrationExportToGcs — upload failure", () => {
549
- test("PUT server responds 500 → job failed with upload_failed + upstreamStatus", async () => {
550
- const fixture = await startFixtureServer(async (req, res) => {
551
- // Drain the body so the client side completes its write before we
552
- // send the 500 response. Without this, fetch may surface the 500 as
553
- // a transport-level error instead of a non-2xx response.
554
- await collectBody(req);
555
- res.writeHead(500);
556
- res.end("upload rejected");
557
- });
558
-
559
- try {
560
- const req = new Request("http://localhost/v1/migrations/export-to-gcs", {
561
- method: "POST",
562
- headers: { "Content-Type": "application/json" },
563
- body: JSON.stringify({
564
- upload_url: makeFakeSignedUploadUrl(fixture.port),
565
- }),
566
- });
567
-
568
- const res = await callHandler(
569
- handleMigrationExportToGcs,
570
- req,
571
- undefined,
572
- 202,
573
- );
574
- expect(res.status).toBe(202);
575
- const body = (await res.json()) as AcceptedResponse;
576
-
577
- const terminal = await waitForJobTerminal(body.job_id);
578
- expect(terminal.status).toBe("failed");
579
- expect(terminal.error).toBeDefined();
580
- expect(terminal.error?.code).toBe("upload_failed");
581
- expect(terminal.error?.upstreamStatus).toBe(500);
582
- } finally {
583
- await fixture.close();
584
- }
585
- }, 15_000);
586
- });
587
-
588
- describe("handleMigrationExportToGcs — redirect handling", () => {
589
- test("PUT server responds with a 302 redirect → job failed (no SSRF follow)", async () => {
590
- // If the upstream "storage.googleapis.com" responded with a 3xx to an
591
- // attacker-controlled host, default fetch would follow and PUT bytes
592
- // there. `redirect: "error"` on the fetch must surface the redirect
593
- // as a transport failure instead — the job must end `failed` rather
594
- // than `complete`, and no bytes must reach the redirect target.
595
- let redirectTargetHit = false;
596
- const redirectTarget = await startFixtureServer(async (_req, res) => {
597
- redirectTargetHit = true;
598
- res.writeHead(200);
599
- res.end();
600
- });
601
- const fixture = await startFixtureServer(async (_req, res) => {
602
- res.writeHead(302, {
603
- Location: `http://127.0.0.1:${redirectTarget.port}/attacker`,
604
- });
605
- res.end();
606
- });
607
-
608
- try {
609
- const req = new Request("http://localhost/v1/migrations/export-to-gcs", {
610
- method: "POST",
611
- headers: { "Content-Type": "application/json" },
612
- body: JSON.stringify({
613
- upload_url: makeFakeSignedUploadUrl(fixture.port),
614
- }),
615
- });
616
-
617
- const res = await callHandler(
618
- handleMigrationExportToGcs,
619
- req,
620
- undefined,
621
- 202,
622
- );
623
- expect(res.status).toBe(202);
624
- const body = (await res.json()) as AcceptedResponse;
625
-
626
- const terminal = await waitForJobTerminal(body.job_id);
627
- expect(terminal.status).toBe("failed");
628
- expect(redirectTargetHit).toBe(false);
629
- } finally {
630
- await fixture.close();
631
- await redirectTarget.close();
632
- }
633
- }, 15_000);
634
- });
635
-
636
- describe("handleMigrationExportToGcs — URL validation", () => {
637
- test("rejects non-https scheme when allowlist is default (400)", async () => {
638
- // Temporarily reset the validator to production defaults so this
639
- // assertion exercises the strict scheme check.
640
- _setUrlImportValidatorOptionsForTests(undefined);
641
- try {
642
- const req = new Request("http://localhost/v1/migrations/export-to-gcs", {
643
- method: "POST",
644
- headers: { "Content-Type": "application/json" },
645
- body: JSON.stringify({
646
- upload_url: "http://storage.googleapis.com/b/o?X-Goog-Signature=fake",
647
- }),
648
- });
649
- const res = await callHandler(
650
- handleMigrationExportToGcs,
651
- req,
652
- undefined,
653
- 202,
654
- );
655
- expect(res.status).toBe(400);
656
- const body = (await res.json()) as ErrorEnvelope;
657
- expect(body.error.code).toBe("invalid_upload_url");
658
- expect(body.error.message).toContain("scheme");
659
- } finally {
660
- _setUrlImportValidatorOptionsForTests({
661
- allowedHosts: ["127.0.0.1", "storage.googleapis.com"],
662
- });
663
- }
664
- });
665
-
666
- test("rejects wrong host (400)", async () => {
667
- const req = new Request("http://localhost/v1/migrations/export-to-gcs", {
668
- method: "POST",
669
- headers: { "Content-Type": "application/json" },
670
- body: JSON.stringify({
671
- upload_url: "https://evil.example.com/bucket/obj?X-Goog-Signature=fake",
672
- }),
673
- });
674
- const res = await callHandler(
675
- handleMigrationExportToGcs,
676
- req,
677
- undefined,
678
- 202,
679
- );
680
- expect(res.status).toBe(400);
681
- const body = (await res.json()) as ErrorEnvelope;
682
- expect(body.error.code).toBe("invalid_upload_url");
683
- expect(body.error.message).toContain("host");
684
- });
685
-
686
- test("rejects path traversal (400)", async () => {
687
- const req = new Request("http://localhost/v1/migrations/export-to-gcs", {
688
- method: "POST",
689
- headers: { "Content-Type": "application/json" },
690
- body: JSON.stringify({
691
- upload_url:
692
- "https://storage.googleapis.com/bucket/..%2Fother?X-Goog-Signature=fake",
693
- }),
694
- });
695
- const res = await callHandler(
696
- handleMigrationExportToGcs,
697
- req,
698
- undefined,
699
- 202,
700
- );
701
- expect(res.status).toBe(400);
702
- const body = (await res.json()) as ErrorEnvelope;
703
- expect(body.error.code).toBe("invalid_upload_url");
704
- expect(body.error.message).toContain("path_traversal");
705
- });
706
- });