@mycadet/assistant 0.12.11 → 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 (176) hide show
  1. package/node_modules/@mycadet/ces-client/node_modules/@mycadet/service-contracts/src/__tests__/ingress.test.ts +2 -2
  2. package/node_modules/@mycadet/ces-client/node_modules/@mycadet/service-contracts/src/ingress.ts +2 -2
  3. package/node_modules/@mycadet/gateway-client/node_modules/@mycadet/service-contracts/src/__tests__/ingress.test.ts +2 -2
  4. package/node_modules/@mycadet/gateway-client/node_modules/@mycadet/service-contracts/src/ingress.ts +2 -2
  5. package/node_modules/@mycadet/gateway-client/src/gateway-ipc-contracts.ts +0 -20
  6. package/node_modules/@mycadet/service-contracts/src/__tests__/ingress.test.ts +2 -2
  7. package/node_modules/@mycadet/service-contracts/src/ingress.ts +2 -2
  8. package/openapi.yaml +2 -708
  9. package/package.json +2 -3
  10. package/scripts/sync-web-search-catalog.ts +1 -2
  11. package/src/__tests__/cloud-profile-guard.test.ts +2 -2
  12. package/src/__tests__/config-schema.test.ts +6 -38
  13. package/src/__tests__/conversation-queue.test.ts +45 -6
  14. package/src/__tests__/db-readiness-http-gate.test.ts +0 -6
  15. package/src/__tests__/persist-onboarding-artifacts.test.ts +0 -1
  16. package/src/__tests__/persona-resolver.test.ts +0 -51
  17. package/src/__tests__/provider-cloud-proxy-integration.test.ts +2 -110
  18. package/src/__tests__/run-due-schedules.test.ts +0 -4
  19. package/src/__tests__/schedule-store.test.ts +0 -12
  20. package/src/__tests__/stt-stream-session.test.ts +4 -4
  21. package/src/__tests__/telephony-tts-guard.test.ts +2 -50
  22. package/src/__tests__/voice-config-update.test.ts +12 -167
  23. package/src/__tests__/web-search-catalog-parity.test.ts +0 -2
  24. package/src/__tests__/workspace-migration-130-speech-mode-to-provider.test.ts +9 -7
  25. package/src/__tests__/workspace-migration-132-web-search-mode-to-provider.test.ts +6 -6
  26. package/src/calls/__tests__/voice-session-bridge.test.ts +1 -4
  27. package/src/calls/resolve-call-tts-provider.ts +6 -25
  28. package/src/calls/telephony-credential-preflight.ts +0 -10
  29. package/src/calls/telephony-tts-capability.ts +1 -17
  30. package/src/cli/commands/tts.ts +1 -2
  31. package/src/cli/index.help.ts +0 -2
  32. package/src/cli/program.ts +0 -2
  33. package/src/config/bundled-skills/settings/tools/voice-config-update.ts +4 -42
  34. package/src/config/schemas/__tests__/stt.test.ts +10 -14
  35. package/src/config/schemas/services.ts +5 -6
  36. package/src/config/schemas/stt.ts +1 -3
  37. package/src/config/schemas/tts.ts +1 -30
  38. package/src/daemon/daemon-readiness.ts +4 -29
  39. package/src/daemon/lifecycle.ts +0 -31
  40. package/src/daemon/providers-setup.ts +0 -8
  41. package/src/heartbeat/__tests__/heartbeat-service.test.ts +0 -1
  42. package/src/heartbeat/heartbeat-service.ts +1 -66
  43. package/src/ipc/__tests__/db-readiness-ipc-gate.test.ts +1 -9
  44. package/src/live-voice/__tests__/live-voice-credential-preflight.test.ts +0 -44
  45. package/src/live-voice/__tests__/live-voice-flux-turn-end.test.ts +4 -6
  46. package/src/live-voice/__tests__/live-voice-stt.test.ts +1 -1
  47. package/src/live-voice/live-voice-credential-preflight.ts +9 -33
  48. package/src/live-voice/live-voice-session.ts +10 -6
  49. package/src/live-voice/live-voice-tts.ts +1 -8
  50. package/src/plugins/defaults/memory/worker.ts +3 -3
  51. package/src/prompts/persona-resolver.ts +0 -35
  52. package/src/providers/__tests__/search-provider-catalog.test.ts +5 -8
  53. package/src/providers/__tests__/voice-error-copy.test.ts +9 -10
  54. package/src/providers/search-provider-catalog.ts +7 -39
  55. package/src/providers/speech-to-text/__tests__/provider-catalog.test.ts +3 -47
  56. package/src/providers/speech-to-text/__tests__/resolve.test.ts +6 -372
  57. package/src/providers/speech-to-text/provider-catalog.ts +2 -73
  58. package/src/providers/speech-to-text/resolve.ts +3 -120
  59. package/src/runtime/http-router-types.ts +1 -3
  60. package/src/runtime/http-server.ts +1 -1
  61. package/src/runtime/routes/__tests__/tts-routes.test.ts +2 -6
  62. package/src/runtime/routes/inbound-stages/transcribe-audio.test.ts +7 -7
  63. package/src/runtime/routes/index.ts +0 -4
  64. package/src/runtime/routes/live-voice-routes.test.ts +4 -11
  65. package/src/runtime/routes/live-voice-routes.ts +8 -11
  66. package/src/runtime/routes/secret-routes.ts +0 -6
  67. package/src/runtime/routes/tts-routes.ts +0 -38
  68. package/src/schedule/__tests__/plugin-schedule-reconciler.test.ts +1 -7
  69. package/src/schedule/__tests__/worker-mcp-readiness.test.ts +0 -4
  70. package/src/schedule/schedule-store.ts +0 -7
  71. package/src/schedule/scheduler.ts +0 -5
  72. package/src/schedule/worker.ts +1 -1
  73. package/src/stt/__tests__/daemon-batch-transcriber.test.ts +2 -10
  74. package/src/stt/daemon-batch-transcriber.ts +4 -38
  75. package/src/stt/types.ts +1 -9
  76. package/src/tools/network/__tests__/web-search.test.ts +3 -250
  77. package/src/tools/network/web-search.ts +4 -165
  78. package/src/tts/__tests__/provider-resolution.test.ts +0 -5
  79. package/src/tts/provider-catalog.ts +0 -4
  80. package/src/tts/types.ts +0 -1
  81. package/src/__tests__/cloud-speech-defaults.test.ts +0 -330
  82. package/src/__tests__/migration-cross-version-compatibility.test.ts +0 -1708
  83. package/src/__tests__/migration-export-http.test.ts +0 -557
  84. package/src/__tests__/migration-export-streaming.test.ts +0 -350
  85. package/src/__tests__/migration-export-to-gcs.test.ts +0 -706
  86. package/src/__tests__/migration-import-commit-http.test.ts +0 -1176
  87. package/src/__tests__/migration-import-from-gcs.test.ts +0 -534
  88. package/src/__tests__/migration-import-from-path.test.ts +0 -324
  89. package/src/__tests__/migration-import-from-url.test.ts +0 -588
  90. package/src/__tests__/migration-import-preflight-http.test.ts +0 -739
  91. package/src/__tests__/migration-jobs-status.test.ts +0 -139
  92. package/src/__tests__/migration-parity-persistence.test.ts +0 -1891
  93. package/src/__tests__/migration-transport.test.ts +0 -1089
  94. package/src/__tests__/migration-validate-http.test.ts +0 -634
  95. package/src/__tests__/migration-wizard.test.ts +0 -1395
  96. package/src/__tests__/rebind-secrets-screen.test.ts +0 -876
  97. package/src/__tests__/sanitize-config-for-transfer.test.ts +0 -259
  98. package/src/__tests__/transfer-progress-screen.test.ts +0 -1217
  99. package/src/__tests__/validation-results-screen.test.ts +0 -1130
  100. package/src/__tests__/vbundle-pax-and-symlink.test.ts +0 -147
  101. package/src/background-wake/background-wake-routes.test.ts +0 -906
  102. package/src/background-wake/cloud-client.test.ts +0 -328
  103. package/src/background-wake/cloud-client.ts +0 -196
  104. package/src/background-wake/next-wake.test.ts +0 -335
  105. package/src/background-wake/next-wake.ts +0 -188
  106. package/src/background-wake/publisher.ts +0 -93
  107. package/src/background-wake/wake-intent-hooks.test.ts +0 -234
  108. package/src/cli/commands/__tests__/backup.test.ts +0 -363
  109. package/src/cli/commands/backup.help.ts +0 -180
  110. package/src/cli/commands/backup.ts +0 -398
  111. package/src/cloud/cloud-speech.test.ts +0 -335
  112. package/src/cloud/cloud-speech.ts +0 -300
  113. package/src/config/cloud-speech-defaults.ts +0 -300
  114. package/src/config/sanitize-for-transfer.ts +0 -111
  115. package/src/live-voice/__tests__/live-voice-cloud-speech-fallback.test.ts +0 -250
  116. package/src/providers/speech-to-text/__tests__/cadet-cloud-realtime.test.ts +0 -457
  117. package/src/providers/speech-to-text/__tests__/cadet-cloud.test.ts +0 -271
  118. package/src/providers/speech-to-text/__tests__/cadet-speech-relay-connection.test.ts +0 -126
  119. package/src/providers/speech-to-text/cadet-cloud-flux-realtime.ts +0 -309
  120. package/src/providers/speech-to-text/cadet-cloud-realtime.ts +0 -315
  121. package/src/providers/speech-to-text/cadet-cloud.ts +0 -108
  122. package/src/providers/speech-to-text/cadet-speech-relay-connection.ts +0 -176
  123. package/src/runtime/migrations/__tests__/gcs-signed-url.test.ts +0 -254
  124. package/src/runtime/migrations/__tests__/job-registry.test.ts +0 -346
  125. package/src/runtime/migrations/__tests__/origin-mode.test.ts +0 -62
  126. package/src/runtime/migrations/__tests__/rebind-secrets-credentials.test.ts +0 -184
  127. package/src/runtime/migrations/__tests__/staged-import-path.test.ts +0 -110
  128. package/src/runtime/migrations/__tests__/v1-test-helpers.ts +0 -112
  129. package/src/runtime/migrations/__tests__/vbundle-builder-credentials.test.ts +0 -285
  130. package/src/runtime/migrations/__tests__/vbundle-builder-fd-leak.test.ts +0 -126
  131. package/src/runtime/migrations/__tests__/vbundle-builder-v1-shape.test.ts +0 -253
  132. package/src/runtime/migrations/__tests__/vbundle-import-credentials.test.ts +0 -208
  133. package/src/runtime/migrations/__tests__/vbundle-import-parity.test.ts +0 -485
  134. package/src/runtime/migrations/__tests__/vbundle-import-policy.test.ts +0 -349
  135. package/src/runtime/migrations/__tests__/vbundle-import-version-compat.test.ts +0 -196
  136. package/src/runtime/migrations/__tests__/vbundle-legacy-user-md.test.ts +0 -551
  137. package/src/runtime/migrations/__tests__/vbundle-metadata-merge-integration.test.ts +0 -428
  138. package/src/runtime/migrations/__tests__/vbundle-metadata-merge.test.ts +0 -220
  139. package/src/runtime/migrations/__tests__/vbundle-streaming-importer.test.ts +0 -1823
  140. package/src/runtime/migrations/__tests__/vbundle-streaming-validator.test.ts +0 -598
  141. package/src/runtime/migrations/__tests__/vbundle-symlink-importer.test.ts +0 -463
  142. package/src/runtime/migrations/__tests__/vbundle-symlink-streaming-importer.test.ts +0 -538
  143. package/src/runtime/migrations/__tests__/vbundle-symlink-streaming.test.ts +0 -515
  144. package/src/runtime/migrations/__tests__/vbundle-symlink-tar.test.ts +0 -453
  145. package/src/runtime/migrations/__tests__/vbundle-symlink-walker.test.ts +0 -319
  146. package/src/runtime/migrations/__tests__/vbundle-tar-stream.test.ts +0 -240
  147. package/src/runtime/migrations/__tests__/vbundle-validator-v1-schema.test.ts +0 -473
  148. package/src/runtime/migrations/gcs-signed-url.ts +0 -190
  149. package/src/runtime/migrations/job-registry.ts +0 -297
  150. package/src/runtime/migrations/migration-transport.ts +0 -612
  151. package/src/runtime/migrations/migration-wizard.ts +0 -747
  152. package/src/runtime/migrations/origin-mode.ts +0 -42
  153. package/src/runtime/migrations/rebind-secrets-screen.ts +0 -412
  154. package/src/runtime/migrations/staged-import-path.ts +0 -116
  155. package/src/runtime/migrations/transfer-progress-screen.ts +0 -323
  156. package/src/runtime/migrations/validation-results-screen.ts +0 -469
  157. package/src/runtime/migrations/vbundle-builder.ts +0 -1382
  158. package/src/runtime/migrations/vbundle-import-analyzer.ts +0 -405
  159. package/src/runtime/migrations/vbundle-import-policy.ts +0 -241
  160. package/src/runtime/migrations/vbundle-importer.ts +0 -826
  161. package/src/runtime/migrations/vbundle-metadata-merge.ts +0 -113
  162. package/src/runtime/migrations/vbundle-streaming-importer.ts +0 -2844
  163. package/src/runtime/migrations/vbundle-streaming-validator.ts +0 -426
  164. package/src/runtime/migrations/vbundle-tar-stream.ts +0 -244
  165. package/src/runtime/migrations/vbundle-validator.ts +0 -724
  166. package/src/runtime/routes/__tests__/migration-cadet-metadata-reconcile.test.ts +0 -227
  167. package/src/runtime/routes/__tests__/migration-export-secrets-redacted.test.ts +0 -54
  168. package/src/runtime/routes/__tests__/migration-import-allowed-hosts-env.test.ts +0 -70
  169. package/src/runtime/routes/__tests__/migration-import-credential-filter.test.ts +0 -257
  170. package/src/runtime/routes/background-wake-routes.ts +0 -387
  171. package/src/runtime/routes/migration-routes.ts +0 -2584
  172. package/src/tools/network/__tests__/cloud-search-proxy.test.ts +0 -282
  173. package/src/tools/network/cloud-search-proxy.ts +0 -211
  174. package/src/tts/__tests__/cadet-provider.test.ts +0 -253
  175. package/src/tts/providers/cadet-provider.ts +0 -244
  176. 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
- });