@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,1708 +0,0 @@
1
- /**
2
- * Cross-version compatibility tests and failure diagnostics for migration
3
- * endpoints.
4
- *
5
- * Tests cover:
6
- * - Schema version compatibility: bundles with different schema_version values
7
- * can be validated and imported when otherwise structurally valid
8
- * - Future/unknown schema versions: produce clear, actionable errors when
9
- * structural changes break parsing
10
- * - Missing or malformed version fields: fail with descriptive diagnostics
11
- * - Round-trip: export -> validate -> import-preflight -> import produces
12
- * consistent results
13
- * - Partial failure scenarios: import fails mid-write, write errors surface
14
- * partial reports
15
- * - Edge cases: empty bundles, very large file entries, duplicate paths,
16
- * corrupted checksums, empty file entries
17
- * - Diagnostic quality: every failure mode includes a machine-readable error
18
- * code and a human-readable message
19
- */
20
- import { createHash } from "node:crypto";
21
- import {
22
- mkdirSync,
23
- readdirSync,
24
- readFileSync,
25
- rmSync,
26
- unlinkSync,
27
- writeFileSync,
28
- } from "node:fs";
29
- import { join } from "node:path";
30
- import { gzipSync } from "node:zlib";
31
- import {
32
- afterEach,
33
- beforeAll,
34
- beforeEach,
35
- describe,
36
- expect,
37
- test,
38
- } from "bun:test";
39
-
40
- /** Convert a Uint8Array to an ArrayBuffer for BodyInit compatibility. */
41
- function toArrayBuffer(data: Uint8Array): ArrayBuffer {
42
- return data.buffer.slice(
43
- data.byteOffset,
44
- data.byteOffset + data.byteLength,
45
- ) as ArrayBuffer;
46
- }
47
-
48
- const testDir = process.env.CADET_WORKSPACE_DIR!;
49
- const testDbDir = join(testDir, "data", "db");
50
- const testDbPath = join(testDbDir, "assistant.db");
51
- const testConfigPath = join(testDir, "config.json");
52
-
53
- process.env.DISABLE_HTTP_AUTH = "true";
54
-
55
- import {
56
- buildTestManifest,
57
- defaultV1Options,
58
- } from "../runtime/migrations/__tests__/v1-test-helpers.js";
59
- import { buildVBundle } from "../runtime/migrations/vbundle-builder.js";
60
- import {
61
- analyzeImport,
62
- DefaultPathResolver,
63
- } from "../runtime/migrations/vbundle-import-analyzer.js";
64
- import { commitImport } from "../runtime/migrations/vbundle-importer.js";
65
- import { validateVBundle } from "../runtime/migrations/vbundle-validator.js";
66
- import {
67
- handleMigrationExport,
68
- handleMigrationImport,
69
- handleMigrationImportPreflight,
70
- handleMigrationValidate,
71
- } from "../runtime/routes/migration-routes.js";
72
- import { callHandler } from "./helpers/call-route-handler.js";
73
- // ---------------------------------------------------------------------------
74
- // Test fixture data
75
- // ---------------------------------------------------------------------------
76
-
77
- const EXISTING_DB_DATA = new Uint8Array([
78
- 0x53, 0x51, 0x4c, 0x69, 0x74, 0x65, 0x20, 0x66, 0x6f, 0x72, 0x6d, 0x61, 0x74,
79
- 0x20, 0x33, 0x00,
80
- ]);
81
- const EXISTING_CONFIG = { provider: "anthropic", model: "test-model" };
82
-
83
- beforeAll(() => {
84
- mkdirSync(testDbDir, { recursive: true });
85
- writeFileSync(testDbPath, EXISTING_DB_DATA);
86
- writeFileSync(testConfigPath, JSON.stringify(EXISTING_CONFIG, null, 2));
87
- });
88
-
89
- // Restore test files before each test
90
- beforeEach(() => {
91
- mkdirSync(testDbDir, { recursive: true });
92
- writeFileSync(testDbPath, EXISTING_DB_DATA);
93
- writeFileSync(testConfigPath, JSON.stringify(EXISTING_CONFIG, null, 2));
94
- });
95
-
96
- // Clean up backup files after each test
97
- afterEach(() => {
98
- try {
99
- for (const dir of [testDbDir, testDir]) {
100
- const entries = readdirSync(dir);
101
- for (const entry of entries) {
102
- if (entry.includes(".backup-")) {
103
- unlinkSync(join(dir, entry));
104
- }
105
- }
106
- }
107
- } catch {
108
- /* best effort */
109
- }
110
- });
111
-
112
- // ---------------------------------------------------------------------------
113
- // Tar archive builder helpers
114
- // ---------------------------------------------------------------------------
115
-
116
- const BLOCK_SIZE = 512;
117
-
118
- function padToBlock(data: Uint8Array): Uint8Array {
119
- const remainder = data.length % BLOCK_SIZE;
120
- if (remainder === 0) {
121
- return data;
122
- }
123
- const padded = new Uint8Array(data.length + (BLOCK_SIZE - remainder));
124
- padded.set(data);
125
- return padded;
126
- }
127
-
128
- function writeOctal(
129
- buf: Uint8Array,
130
- offset: number,
131
- length: number,
132
- value: number,
133
- ): void {
134
- const str = value.toString(8).padStart(length - 1, "0");
135
- for (let i = 0; i < str.length; i++) {
136
- buf[offset + i] = str.charCodeAt(i);
137
- }
138
- buf[offset + length - 1] = 0;
139
- }
140
-
141
- function computeHeaderChecksum(header: Uint8Array): number {
142
- let sum = 0;
143
- for (let i = 0; i < 512; i++) {
144
- if (i >= 148 && i < 156) {
145
- sum += 0x20;
146
- } else {
147
- sum += header[i];
148
- }
149
- }
150
- return sum;
151
- }
152
-
153
- function createTarEntry(name: string, data: Uint8Array): Uint8Array {
154
- const header = new Uint8Array(BLOCK_SIZE);
155
- const encoder = new TextEncoder();
156
-
157
- const nameBytes = encoder.encode(name);
158
- header.set(nameBytes.subarray(0, 100), 0);
159
-
160
- writeOctal(header, 100, 8, 0o644);
161
- writeOctal(header, 108, 8, 0);
162
- writeOctal(header, 116, 8, 0);
163
- writeOctal(header, 124, 12, data.length);
164
- writeOctal(header, 136, 12, Math.floor(Date.now() / 1000));
165
- header[156] = "0".charCodeAt(0);
166
-
167
- const magic = encoder.encode("ustar\0");
168
- header.set(magic, 257);
169
- header[263] = "0".charCodeAt(0);
170
- header[264] = "0".charCodeAt(0);
171
-
172
- const checksum = computeHeaderChecksum(header);
173
- writeOctal(header, 148, 7, checksum);
174
- header[155] = 0x20;
175
-
176
- const paddedData = padToBlock(data);
177
- const result = new Uint8Array(header.length + paddedData.length);
178
- result.set(header, 0);
179
- result.set(paddedData, header.length);
180
- return result;
181
- }
182
-
183
- function createTarArchive(
184
- entries: Array<{ name: string; data: Uint8Array }>,
185
- ): Uint8Array {
186
- const parts: Uint8Array[] = [];
187
- for (const entry of entries) {
188
- parts.push(createTarEntry(entry.name, entry.data));
189
- }
190
- parts.push(new Uint8Array(BLOCK_SIZE * 2));
191
-
192
- const totalLength = parts.reduce((sum, p) => sum + p.length, 0);
193
- const result = new Uint8Array(totalLength);
194
- let offset = 0;
195
- for (const part of parts) {
196
- result.set(part, offset);
197
- offset += part.length;
198
- }
199
- return result;
200
- }
201
-
202
- function sha256Hex(data: Uint8Array | string): string {
203
- return createHash("sha256").update(data).digest("hex");
204
- }
205
-
206
- interface VBundleFile {
207
- path: string;
208
- data: Uint8Array;
209
- }
210
-
211
- function createValidVBundle(
212
- files?: VBundleFile[],
213
- overrides?: Partial<{ schema_version: number }>,
214
- ): Uint8Array {
215
- const dbData = new Uint8Array([0x53, 0x51, 0x4c, 0x69, 0x74, 0x65]);
216
- const bundleFiles = files ?? [{ path: "data/db/assistant.db", data: dbData }];
217
-
218
- const contents = bundleFiles.map((f) => ({
219
- path: f.path,
220
- sha256: sha256Hex(f.data),
221
- size_bytes: f.data.length,
222
- }));
223
-
224
- const manifest = buildTestManifest({
225
- contents,
226
- overrides: {
227
- bundle_id: "00000000-0000-4000-8000-000000000000",
228
- ...(overrides?.schema_version !== undefined
229
- ? { schema_version: overrides.schema_version }
230
- : {}),
231
- },
232
- });
233
- const manifestData = new TextEncoder().encode(JSON.stringify(manifest));
234
-
235
- const tarEntries = [
236
- { name: "manifest.json", data: manifestData },
237
- ...bundleFiles.map((f) => ({ name: f.path, data: f.data })),
238
- ];
239
-
240
- const tar = createTarArchive(tarEntries);
241
- return gzipSync(tar);
242
- }
243
-
244
- /**
245
- * Create a vbundle with a raw manifest object (bypassing normal checksumming).
246
- * Useful for testing malformed/unusual manifests.
247
- */
248
- function createVBundleWithRawManifest(
249
- manifestObj: Record<string, unknown>,
250
- archiveFiles: Array<{ name: string; data: Uint8Array }>,
251
- ): Uint8Array {
252
- const manifestData = new TextEncoder().encode(JSON.stringify(manifestObj));
253
- const tarEntries = [
254
- { name: "manifest.json", data: manifestData },
255
- ...archiveFiles,
256
- ];
257
- const tar = createTarArchive(tarEntries);
258
- return gzipSync(tar);
259
- }
260
-
261
- // ---------------------------------------------------------------------------
262
- // Response types
263
- // ---------------------------------------------------------------------------
264
-
265
- interface ValidationResponse {
266
- is_valid: boolean;
267
- errors: Array<{ code: string; message: string; path?: string }>;
268
- manifest?: Record<string, unknown>;
269
- }
270
-
271
- interface ImportDryRunResponse {
272
- can_import: boolean;
273
- summary?: {
274
- total_files: number;
275
- files_to_create: number;
276
- files_to_overwrite: number;
277
- files_unchanged: number;
278
- files_to_skip: number;
279
- };
280
- files?: Array<{
281
- path: string;
282
- action: string;
283
- bundle_size: number;
284
- current_size: number | null;
285
- bundle_sha256: string;
286
- current_sha256: string | null;
287
- }>;
288
- conflicts?: Array<{ code: string; message: string; path?: string }>;
289
- manifest?: Record<string, unknown>;
290
- validation?: {
291
- is_valid: boolean;
292
- errors: Array<{ code: string; message: string; path?: string }>;
293
- };
294
- }
295
-
296
- interface ImportCommitResponse {
297
- success: boolean;
298
- reason?: string;
299
- message?: string;
300
- summary?: {
301
- total_files: number;
302
- files_created: number;
303
- files_overwritten: number;
304
- files_skipped: number;
305
- backups_created: number;
306
- };
307
- files?: Array<{
308
- path: string;
309
- disk_path: string;
310
- action: string;
311
- size: number;
312
- sha256: string;
313
- backup_path: string | null;
314
- }>;
315
- manifest?: Record<string, unknown>;
316
- warnings?: string[];
317
- errors?: Array<{ code: string; message: string; path?: string }>;
318
- partial_report?: ImportCommitResponse;
319
- }
320
-
321
- // ═══════════════════════════════════════════════════════════════════════════
322
- // 1. Schema version compatibility
323
- // ═══════════════════════════════════════════════════════════════════════════
324
-
325
- describe("schema version compatibility", () => {
326
- test("bundle with schema_version 1 validates successfully", () => {
327
- const vbundle = createValidVBundle();
328
- const result = validateVBundle(vbundle);
329
-
330
- expect(result.is_valid).toBe(true);
331
- expect(result.manifest?.schema_version).toBe(1);
332
- });
333
-
334
- test("bundle with schema_version != 1 is rejected by the v1 validator", () => {
335
- // The v1 validator pins `schema_version: z.literal(1)`. Bundles that
336
- // claim a different schema version are rejected outright; this used to
337
- // be permissive when schema_version was a free-form string.
338
- const vbundle = createValidVBundle(undefined, { schema_version: 2 });
339
- const result = validateVBundle(vbundle);
340
-
341
- expect(result.is_valid).toBe(false);
342
- });
343
- });
344
-
345
- // ═══════════════════════════════════════════════════════════════════════════
346
- // 2. Missing or malformed version fields
347
- // ═══════════════════════════════════════════════════════════════════════════
348
-
349
- describe("missing or malformed version fields", () => {
350
- test("missing schema_version produces MANIFEST_SCHEMA_ERROR", () => {
351
- const dbData = new Uint8Array([0x53, 0x51, 0x4c, 0x69, 0x74, 0x65]);
352
- const manifestObj = {
353
- // schema_version intentionally omitted
354
- created_at: new Date().toISOString(),
355
- files: [
356
- {
357
- path: "data/db/assistant.db",
358
- sha256: sha256Hex(dbData),
359
- size: dbData.length,
360
- },
361
- ],
362
- manifest_sha256: "placeholder",
363
- };
364
-
365
- const vbundle = createVBundleWithRawManifest(manifestObj, [
366
- { name: "data/db/assistant.db", data: dbData },
367
- ]);
368
- const result = validateVBundle(vbundle);
369
-
370
- expect(result.is_valid).toBe(false);
371
- const schemaError = result.errors.find(
372
- (e) => e.code === "MANIFEST_SCHEMA_ERROR",
373
- );
374
- expect(schemaError).toBeDefined();
375
- expect(schemaError!.message).toContain("schema_version");
376
- });
377
-
378
- test("numeric schema_version (instead of string) produces schema error", () => {
379
- const dbData = new Uint8Array([0x53, 0x51, 0x4c, 0x69, 0x74, 0x65]);
380
- const manifestObj = {
381
- schema_version: 1, // should be a string
382
- created_at: new Date().toISOString(),
383
- files: [
384
- {
385
- path: "data/db/assistant.db",
386
- sha256: sha256Hex(dbData),
387
- size: dbData.length,
388
- },
389
- ],
390
- manifest_sha256: "placeholder",
391
- };
392
-
393
- const vbundle = createVBundleWithRawManifest(manifestObj, [
394
- { name: "data/db/assistant.db", data: dbData },
395
- ]);
396
- const result = validateVBundle(vbundle);
397
-
398
- expect(result.is_valid).toBe(false);
399
- expect(result.errors.some((e) => e.code === "MANIFEST_SCHEMA_ERROR")).toBe(
400
- true,
401
- );
402
- });
403
-
404
- test("null schema_version produces schema error", () => {
405
- const dbData = new Uint8Array([0x53, 0x51, 0x4c, 0x69, 0x74, 0x65]);
406
- const manifestObj = {
407
- schema_version: null,
408
- created_at: new Date().toISOString(),
409
- files: [
410
- {
411
- path: "data/db/assistant.db",
412
- sha256: sha256Hex(dbData),
413
- size: dbData.length,
414
- },
415
- ],
416
- manifest_sha256: "placeholder",
417
- };
418
-
419
- const vbundle = createVBundleWithRawManifest(manifestObj, [
420
- { name: "data/db/assistant.db", data: dbData },
421
- ]);
422
- const result = validateVBundle(vbundle);
423
-
424
- expect(result.is_valid).toBe(false);
425
- expect(result.errors.some((e) => e.code === "MANIFEST_SCHEMA_ERROR")).toBe(
426
- true,
427
- );
428
- });
429
-
430
- test("zero schema_version is rejected by the v1 validator", () => {
431
- // The v1 schema pins schema_version to the literal `1`; any other
432
- // numeric value (including 0) is rejected.
433
- const vbundle = createValidVBundle(undefined, { schema_version: 0 });
434
- const result = validateVBundle(vbundle);
435
-
436
- expect(result.is_valid).toBe(false);
437
- });
438
-
439
- test("missing created_at produces MANIFEST_SCHEMA_ERROR", () => {
440
- const dbData = new Uint8Array([0x53, 0x51, 0x4c, 0x69, 0x74, 0x65]);
441
- const manifestObj = {
442
- schema_version: 1,
443
- // created_at intentionally omitted
444
- files: [
445
- {
446
- path: "data/db/assistant.db",
447
- sha256: sha256Hex(dbData),
448
- size: dbData.length,
449
- },
450
- ],
451
- manifest_sha256: "placeholder",
452
- };
453
-
454
- const vbundle = createVBundleWithRawManifest(manifestObj, [
455
- { name: "data/db/assistant.db", data: dbData },
456
- ]);
457
- const result = validateVBundle(vbundle);
458
-
459
- expect(result.is_valid).toBe(false);
460
- // The v1 schema reports errors per missing required field; we just
461
- // assert at least one MANIFEST_SCHEMA_ERROR fires for any of the
462
- // newly-required fields. The legacy assertion looking for "created_at"
463
- // in the message specifically is no longer meaningful — the validator
464
- // surfaces the first missing field it finds, which under the v1
465
- // schema may be `bundle_id`, `assistant`, `origin`, etc.
466
- const error = result.errors.find((e) => e.code === "MANIFEST_SCHEMA_ERROR");
467
- expect(error).toBeDefined();
468
- });
469
-
470
- test("missing files array produces MANIFEST_SCHEMA_ERROR", () => {
471
- const dbData = new Uint8Array([0x53, 0x51, 0x4c, 0x69, 0x74, 0x65]);
472
- const manifestObj = {
473
- schema_version: 1,
474
- created_at: new Date().toISOString(),
475
- // files intentionally omitted
476
- manifest_sha256: "placeholder",
477
- };
478
-
479
- const vbundle = createVBundleWithRawManifest(manifestObj, [
480
- { name: "data/db/assistant.db", data: dbData },
481
- ]);
482
- const result = validateVBundle(vbundle);
483
-
484
- expect(result.is_valid).toBe(false);
485
- expect(result.errors.some((e) => e.code === "MANIFEST_SCHEMA_ERROR")).toBe(
486
- true,
487
- );
488
- });
489
-
490
- test("missing manifest_sha256 produces MANIFEST_SCHEMA_ERROR", () => {
491
- const dbData = new Uint8Array([0x53, 0x51, 0x4c, 0x69, 0x74, 0x65]);
492
- const manifestObj = {
493
- schema_version: 1,
494
- created_at: new Date().toISOString(),
495
- files: [
496
- {
497
- path: "data/db/assistant.db",
498
- sha256: sha256Hex(dbData),
499
- size: dbData.length,
500
- },
501
- ],
502
- // manifest_sha256 intentionally omitted
503
- };
504
-
505
- const vbundle = createVBundleWithRawManifest(manifestObj, [
506
- { name: "data/db/assistant.db", data: dbData },
507
- ]);
508
- const result = validateVBundle(vbundle);
509
-
510
- expect(result.is_valid).toBe(false);
511
- expect(result.errors.some((e) => e.code === "MANIFEST_SCHEMA_ERROR")).toBe(
512
- true,
513
- );
514
- });
515
-
516
- test("file entry missing sha256 produces MANIFEST_SCHEMA_ERROR", () => {
517
- const dbData = new Uint8Array([0x53, 0x51, 0x4c, 0x69, 0x74, 0x65]);
518
- const manifestObj = {
519
- schema_version: 1,
520
- created_at: new Date().toISOString(),
521
- files: [
522
- {
523
- path: "data/db/assistant.db",
524
- // sha256 intentionally omitted
525
- size: dbData.length,
526
- },
527
- ],
528
- manifest_sha256: "placeholder",
529
- };
530
-
531
- const vbundle = createVBundleWithRawManifest(manifestObj, [
532
- { name: "data/db/assistant.db", data: dbData },
533
- ]);
534
- const result = validateVBundle(vbundle);
535
-
536
- expect(result.is_valid).toBe(false);
537
- expect(result.errors.some((e) => e.code === "MANIFEST_SCHEMA_ERROR")).toBe(
538
- true,
539
- );
540
- });
541
-
542
- test("file entry with negative size produces MANIFEST_SCHEMA_ERROR", () => {
543
- const dbData = new Uint8Array([0x53, 0x51, 0x4c, 0x69, 0x74, 0x65]);
544
- const manifestObj = {
545
- schema_version: 1,
546
- created_at: new Date().toISOString(),
547
- files: [
548
- {
549
- path: "data/db/assistant.db",
550
- sha256: sha256Hex(dbData),
551
- size: -1,
552
- },
553
- ],
554
- manifest_sha256: "placeholder",
555
- };
556
-
557
- const vbundle = createVBundleWithRawManifest(manifestObj, [
558
- { name: "data/db/assistant.db", data: dbData },
559
- ]);
560
- const result = validateVBundle(vbundle);
561
-
562
- expect(result.is_valid).toBe(false);
563
- expect(result.errors.some((e) => e.code === "MANIFEST_SCHEMA_ERROR")).toBe(
564
- true,
565
- );
566
- });
567
-
568
- test("version field error includes descriptive path in diagnostic", () => {
569
- const dbData = new Uint8Array([0x53, 0x51, 0x4c, 0x69, 0x74, 0x65]);
570
- const manifestObj = {
571
- schema_version: 42, // wrong type
572
- created_at: new Date().toISOString(),
573
- files: [
574
- {
575
- path: "data/db/assistant.db",
576
- sha256: sha256Hex(dbData),
577
- size: dbData.length,
578
- },
579
- ],
580
- manifest_sha256: "placeholder",
581
- };
582
-
583
- const vbundle = createVBundleWithRawManifest(manifestObj, [
584
- { name: "data/db/assistant.db", data: dbData },
585
- ]);
586
- const result = validateVBundle(vbundle);
587
-
588
- expect(result.is_valid).toBe(false);
589
- const error = result.errors.find((e) => e.code === "MANIFEST_SCHEMA_ERROR");
590
- expect(error).toBeDefined();
591
- // The error path should indicate where in the manifest the issue is
592
- expect(error!.path).toBeDefined();
593
- expect(error!.path).toContain("manifest.json");
594
- });
595
- });
596
-
597
- // ═══════════════════════════════════════════════════════════════════════════
598
- // 3. Round-trip: export -> validate -> import-preflight -> import
599
- // ═══════════════════════════════════════════════════════════════════════════
600
-
601
- describe("round-trip: export -> validate -> preflight -> import", () => {
602
- test("full round-trip produces consistent results via HTTP handlers", async () => {
603
- // Step 1: Export
604
- const exportReq = new Request("http://localhost/v1/migrations/export", {
605
- method: "POST",
606
- });
607
- const exportRes = await callHandler(handleMigrationExport, exportReq);
608
- expect(exportRes.status).toBe(200);
609
-
610
- const archiveBuffer = await exportRes.arrayBuffer();
611
- const archiveData = new Uint8Array(archiveBuffer);
612
- expect(archiveData.length).toBeGreaterThan(0);
613
-
614
- // Step 2: Validate the exported archive
615
- const validateReq = new Request("http://localhost/v1/migrations/validate", {
616
- method: "POST",
617
- headers: { "Content-Type": "application/octet-stream" },
618
- body: toArrayBuffer(archiveData),
619
- });
620
- const validateRes = await callHandler(handleMigrationValidate, validateReq);
621
- const validateBody = (await validateRes.json()) as ValidationResponse;
622
-
623
- expect(validateRes.status).toBe(200);
624
- expect(validateBody.is_valid).toBe(true);
625
- expect(validateBody.errors).toHaveLength(0);
626
- expect(validateBody.manifest).toBeDefined();
627
-
628
- // Step 3: Import preflight (dry-run)
629
- const preflightReq = new Request(
630
- "http://localhost/v1/migrations/import-preflight",
631
- {
632
- method: "POST",
633
- headers: { "Content-Type": "application/octet-stream" },
634
- body: toArrayBuffer(archiveData),
635
- },
636
- );
637
- const preflightRes = await callHandler(
638
- handleMigrationImportPreflight,
639
- preflightReq,
640
- );
641
- const preflightBody = (await preflightRes.json()) as ImportDryRunResponse;
642
-
643
- expect(preflightRes.status).toBe(200);
644
- expect(preflightBody.can_import).toBe(true);
645
- expect(preflightBody.summary).toBeDefined();
646
- expect(preflightBody.files).toBeDefined();
647
- expect(preflightBody.manifest).toBeDefined();
648
-
649
- // The files should show "unchanged" since we exported from the same disk.
650
- // New format uses workspace/ prefix for archive paths.
651
- const dbFile = preflightBody.files!.find(
652
- (f) => f.path === "workspace/data/db/assistant.db",
653
- );
654
- expect(dbFile).toBeDefined();
655
- expect(dbFile!.action).toBe("unchanged");
656
-
657
- // Step 4: Import (commit)
658
- const importReq = new Request("http://localhost/v1/migrations/import", {
659
- method: "POST",
660
- headers: { "Content-Type": "application/octet-stream" },
661
- body: toArrayBuffer(archiveData),
662
- });
663
- const importRes = await callHandler(handleMigrationImport, importReq);
664
- const importBody = (await importRes.json()) as ImportCommitResponse;
665
-
666
- expect(importRes.status).toBe(200);
667
- expect(importBody.success).toBe(true);
668
- expect(importBody.manifest).toBeDefined();
669
-
670
- // Schema version should be consistent across all steps
671
- expect(validateBody.manifest!.schema_version).toBe(
672
- preflightBody.manifest!.schema_version,
673
- );
674
- expect(validateBody.manifest!.schema_version).toBe(
675
- importBody.manifest!.schema_version,
676
- );
677
- });
678
-
679
- test("round-trip via builder: build -> validate -> analyze -> commit", () => {
680
- const dbData = new Uint8Array([0xca, 0xfe, 0xba, 0xbe]);
681
- const configData = new TextEncoder().encode('{"model":"test-round-trip"}');
682
-
683
- // Step 1: Build
684
- const { archive, manifest } = buildVBundle({
685
- files: [
686
- { path: "data/db/assistant.db", data: dbData },
687
- { path: "config/settings.json", data: configData },
688
- ],
689
- ...defaultV1Options(),
690
- });
691
-
692
- expect(manifest.schema_version).toBe(1);
693
- expect(manifest.contents).toHaveLength(2);
694
-
695
- // Step 2: Validate
696
- const validationResult = validateVBundle(archive);
697
- expect(validationResult.is_valid).toBe(true);
698
- expect(validationResult.manifest?.checksum).toBe(manifest.checksum);
699
-
700
- // Step 3: Analyze (preflight)
701
- const resolver = new DefaultPathResolver(testDir);
702
- const report = analyzeImport({
703
- manifest: validationResult.manifest!,
704
- pathResolver: resolver,
705
- });
706
-
707
- expect(report.can_import).toBe(true);
708
- expect(report.summary.total_files).toBe(2);
709
-
710
- // Step 4: Commit
711
- const commitResult = commitImport({
712
- archiveData: archive,
713
- pathResolver: resolver,
714
- });
715
-
716
- expect(commitResult.ok).toBe(true);
717
- if (commitResult.ok) {
718
- expect(commitResult.report.success).toBe(true);
719
- expect(commitResult.report.manifest.schema_version).toBe(1);
720
- expect(commitResult.report.summary.total_files).toBe(2);
721
-
722
- // Verify data on disk matches what we built
723
- const writtenDb = new Uint8Array(readFileSync(testDbPath));
724
- expect(sha256Hex(writtenDb)).toBe(sha256Hex(dbData));
725
-
726
- const writtenConfig = readFileSync(testConfigPath, "utf8");
727
- expect(writtenConfig).toBe(
728
- JSON.stringify({ model: "test-round-trip" }, null, 2) + "\n",
729
- );
730
- }
731
- });
732
-
733
- test("export manifest checksum remains valid through validate step", async () => {
734
- const exportReq = new Request("http://localhost/v1/migrations/export", {
735
- method: "POST",
736
- });
737
- const exportRes = await callHandler(handleMigrationExport, exportReq);
738
- const archiveData = new Uint8Array(await exportRes.arrayBuffer());
739
-
740
- // The X-Vbundle-Manifest-Sha256 response header value should match
741
- // the manifest's `checksum` field inside the archive (the legacy
742
- // header name is preserved across the v1 rename).
743
- const headerSha = exportRes.headers.get("X-Vbundle-Manifest-Sha256");
744
- expect(headerSha).toBeDefined();
745
-
746
- const validationResult = validateVBundle(archiveData);
747
- expect(validationResult.is_valid).toBe(true);
748
- expect(validationResult.manifest?.checksum).toBe(headerSha!);
749
- });
750
- });
751
-
752
- // ═══════════════════════════════════════════════════════════════════════════
753
- // 4. Partial failure scenarios
754
- // ═══════════════════════════════════════════════════════════════════════════
755
-
756
- describe("partial failure scenarios", () => {
757
- test("commitImport to read-only path returns write_failed with partial report", () => {
758
- const newDbData = new Uint8Array([0xde, 0xad]);
759
- const vbundle = createValidVBundle([
760
- { path: "data/db/assistant.db", data: newDbData },
761
- ]);
762
-
763
- // Point to a workspace path where data/db cannot be created
764
- // (a regular file blocks directory creation)
765
- const blockerWorkspace = join(testDir, "blocker-workspace");
766
- mkdirSync(blockerWorkspace, { recursive: true });
767
- // Create "data" as a regular file — mkdirSync("data/db") will fail
768
- writeFileSync(
769
- join(blockerWorkspace, "data"),
770
- "I am a file, not a directory",
771
- );
772
-
773
- const resolver = new DefaultPathResolver(blockerWorkspace);
774
- const result = commitImport({
775
- archiveData: vbundle,
776
- pathResolver: resolver,
777
- });
778
-
779
- expect(result.ok).toBe(false);
780
- if (!result.ok) {
781
- expect(result.reason).toBe("write_failed");
782
- expect((result as { message: string }).message).toBeDefined();
783
- expect((result as { message: string }).message.length).toBeGreaterThan(0);
784
- }
785
-
786
- // Clean up
787
- rmSync(blockerWorkspace, { recursive: true, force: true });
788
- });
789
-
790
- test("commitImport with invalid archive returns validation_failed", () => {
791
- const resolver = new DefaultPathResolver(testDir);
792
- const result = commitImport({
793
- archiveData: new Uint8Array([0xba, 0xad, 0xf0, 0x0d]),
794
- pathResolver: resolver,
795
- });
796
-
797
- expect(result.ok).toBe(false);
798
- if (!result.ok) {
799
- expect(result.reason).toBe("validation_failed");
800
- const errors = (
801
- result as {
802
- errors: Array<{ code: string; message: string }>;
803
- }
804
- ).errors;
805
- expect(errors).toBeDefined();
806
- expect(errors.length).toBeGreaterThan(0);
807
- expect(errors[0].code).toBe("INVALID_GZIP");
808
- }
809
- });
810
-
811
- test("import HTTP endpoint returns structured error for invalid archives", async () => {
812
- const req = new Request("http://localhost/v1/migrations/import", {
813
- method: "POST",
814
- headers: { "Content-Type": "application/octet-stream" },
815
- body: toArrayBuffer(new Uint8Array([0xff, 0xfe, 0xfd])),
816
- });
817
-
818
- const res = await callHandler(handleMigrationImport, req);
819
- const body = (await res.json()) as ImportCommitResponse;
820
-
821
- expect(body.success).toBe(false);
822
- expect(body.reason).toBe("validation_failed");
823
- expect(body.errors).toBeDefined();
824
- expect(body.errors!.length).toBeGreaterThan(0);
825
-
826
- // Each error should have both a code and a message
827
- for (const err of body.errors!) {
828
- expect(err.code).toBeDefined();
829
- expect(err.code.length).toBeGreaterThan(0);
830
- expect(err.message).toBeDefined();
831
- expect(err.message.length).toBeGreaterThan(0);
832
- }
833
- });
834
-
835
- test("preflight returns validation errors for corrupted bundle", async () => {
836
- const req = new Request("http://localhost/v1/migrations/import-preflight", {
837
- method: "POST",
838
- headers: { "Content-Type": "application/octet-stream" },
839
- body: toArrayBuffer(new Uint8Array([0xba, 0xdc, 0x0d, 0xe5])),
840
- });
841
-
842
- const res = await callHandler(handleMigrationImportPreflight, req);
843
- const body = (await res.json()) as ImportDryRunResponse;
844
-
845
- expect(res.status).toBe(200);
846
- expect(body.can_import).toBe(false);
847
- expect(body.validation).toBeDefined();
848
- expect(body.validation!.is_valid).toBe(false);
849
- expect(body.validation!.errors.length).toBeGreaterThan(0);
850
- });
851
-
852
- test("import does not modify disk when bundle has checksum mismatch", () => {
853
- // Save original disk state
854
- const originalDb = new Uint8Array(readFileSync(testDbPath));
855
- const originalConfig = readFileSync(testConfigPath, "utf8");
856
-
857
- // Create bundle with corrupted checksum
858
- const dbData = new Uint8Array([0x53, 0x51, 0x4c, 0x69, 0x74, 0x65]);
859
- const manifest = {
860
- schema_version: 1,
861
- created_at: new Date().toISOString(),
862
- files: [
863
- {
864
- path: "data/db/assistant.db",
865
- sha256: sha256Hex(dbData),
866
- size: dbData.length,
867
- },
868
- ],
869
- manifest_sha256:
870
- "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
871
- };
872
- const manifestData = new TextEncoder().encode(JSON.stringify(manifest));
873
- const tar = createTarArchive([
874
- { name: "manifest.json", data: manifestData },
875
- { name: "data/db/assistant.db", data: dbData },
876
- ]);
877
- const corruptVBundle = gzipSync(tar);
878
-
879
- const resolver = new DefaultPathResolver(testDir);
880
- const result = commitImport({
881
- archiveData: corruptVBundle,
882
- pathResolver: resolver,
883
- });
884
-
885
- expect(result.ok).toBe(false);
886
-
887
- // Disk should be unchanged
888
- const currentDb = new Uint8Array(readFileSync(testDbPath));
889
- const currentConfig = readFileSync(testConfigPath, "utf8");
890
- expect(currentDb).toEqual(originalDb);
891
- expect(currentConfig).toBe(originalConfig);
892
- });
893
- });
894
-
895
- // ═══════════════════════════════════════════════════════════════════════════
896
- // 5. Edge cases
897
- // ═══════════════════════════════════════════════════════════════════════════
898
-
899
- describe("edge cases", () => {
900
- test("bundle with empty database file validates and imports", () => {
901
- const emptyDb = new Uint8Array(0);
902
- const vbundle = createValidVBundle([
903
- { path: "data/db/assistant.db", data: emptyDb },
904
- ]);
905
-
906
- const result = validateVBundle(vbundle);
907
- expect(result.is_valid).toBe(true);
908
- expect(result.manifest?.contents[0].size_bytes).toBe(0);
909
-
910
- // Import should also succeed
911
- const resolver = new DefaultPathResolver(testDir);
912
- const importResult = commitImport({
913
- archiveData: vbundle,
914
- pathResolver: resolver,
915
- });
916
- expect(importResult.ok).toBe(true);
917
- if (importResult.ok) {
918
- expect(importResult.report.success).toBe(true);
919
- }
920
- });
921
-
922
- test("bundle with large file entry validates correctly", () => {
923
- // Create a 100KB file to test larger payloads
924
- const largeData = new Uint8Array(100 * 1024);
925
- for (let i = 0; i < largeData.length; i++) {
926
- largeData[i] = i % 256;
927
- }
928
-
929
- const vbundle = createValidVBundle([
930
- { path: "data/db/assistant.db", data: largeData },
931
- ]);
932
-
933
- const result = validateVBundle(vbundle);
934
- expect(result.is_valid).toBe(true);
935
- expect(result.manifest?.contents[0].size_bytes).toBe(100 * 1024);
936
- });
937
-
938
- test("bundle with duplicate archive paths uses last occurrence in tar", () => {
939
- // A tar archive with duplicate paths: the tar parser will keep the
940
- // last one it sees. Create manually to have duplicates.
941
- const dbData1 = new Uint8Array([0x01, 0x02, 0x03]);
942
- const dbData2 = new Uint8Array([0x04, 0x05, 0x06]);
943
-
944
- // Build a valid v1 manifest that references the second data.
945
- const manifest = buildTestManifest({
946
- contents: [
947
- {
948
- path: "data/db/assistant.db",
949
- sha256: sha256Hex(dbData2),
950
- size_bytes: dbData2.length,
951
- },
952
- ],
953
- overrides: { bundle_id: "00000000-0000-4000-8000-000000000000" },
954
- });
955
- const manifestData = new TextEncoder().encode(JSON.stringify(manifest));
956
-
957
- // Build tar with duplicate data/db/assistant.db entries
958
- const tar = createTarArchive([
959
- { name: "manifest.json", data: manifestData },
960
- { name: "data/db/assistant.db", data: dbData1 },
961
- { name: "data/db/assistant.db", data: dbData2 },
962
- ]);
963
- const vbundle = gzipSync(tar);
964
-
965
- // The tar parser's Map will keep the last entry for a duplicate key.
966
- // Since the manifest references dbData2's checksum and the Map
967
- // stores the last occurrence, validation should pass.
968
- const result = validateVBundle(vbundle);
969
- expect(result.is_valid).toBe(true);
970
- });
971
-
972
- test("corrupted file checksum is detected with clear diagnostic", () => {
973
- const dbData = new Uint8Array([0x53, 0x51, 0x4c, 0x69, 0x74, 0x65]);
974
- const wrongChecksum =
975
- "0000000000000000000000000000000000000000000000000000000000000000";
976
-
977
- const manifest = buildTestManifest({
978
- contents: [
979
- {
980
- path: "data/db/assistant.db",
981
- sha256: wrongChecksum,
982
- size_bytes: dbData.length,
983
- },
984
- ],
985
- overrides: { bundle_id: "00000000-0000-4000-8000-000000000000" },
986
- });
987
- const manifestData = new TextEncoder().encode(JSON.stringify(manifest));
988
-
989
- const tar = createTarArchive([
990
- { name: "manifest.json", data: manifestData },
991
- { name: "data/db/assistant.db", data: dbData },
992
- ]);
993
- const vbundle = gzipSync(tar);
994
- const result = validateVBundle(vbundle);
995
-
996
- expect(result.is_valid).toBe(false);
997
- const checksumError = result.errors.find(
998
- (e) => e.code === "FILE_CHECKSUM_MISMATCH",
999
- );
1000
- expect(checksumError).toBeDefined();
1001
- expect(checksumError!.message).toContain("data/db/assistant.db");
1002
- expect(checksumError!.message).toContain(wrongChecksum);
1003
- expect(checksumError!.path).toBe("data/db/assistant.db");
1004
- });
1005
-
1006
- test("manifest declaring non-existent file produces MISSING_DECLARED_FILE", () => {
1007
- const dbData = new Uint8Array([0x53, 0x51, 0x4c, 0x69, 0x74, 0x65]);
1008
- const ghostSha = sha256Hex(new TextEncoder().encode("ghost-content"));
1009
-
1010
- const manifest = buildTestManifest({
1011
- contents: [
1012
- {
1013
- path: "data/db/assistant.db",
1014
- sha256: sha256Hex(dbData),
1015
- size_bytes: dbData.length,
1016
- },
1017
- {
1018
- path: "data/extra/ghost.bin",
1019
- sha256: ghostSha,
1020
- size_bytes: 13,
1021
- },
1022
- ],
1023
- overrides: { bundle_id: "00000000-0000-4000-8000-000000000000" },
1024
- });
1025
- const manifestData = new TextEncoder().encode(JSON.stringify(manifest));
1026
-
1027
- const tar = createTarArchive([
1028
- { name: "manifest.json", data: manifestData },
1029
- { name: "data/db/assistant.db", data: dbData },
1030
- // data/extra/ghost.bin intentionally NOT included
1031
- ]);
1032
- const vbundle = gzipSync(tar);
1033
- const result = validateVBundle(vbundle);
1034
-
1035
- expect(result.is_valid).toBe(false);
1036
- const missingError = result.errors.find(
1037
- (e) => e.code === "MISSING_DECLARED_FILE",
1038
- );
1039
- expect(missingError).toBeDefined();
1040
- expect(missingError!.path).toBe("data/extra/ghost.bin");
1041
- expect(missingError!.message).toContain("data/extra/ghost.bin");
1042
- });
1043
-
1044
- test("bundle with empty contents is rejected by the v1 contents refine", () => {
1045
- // The v1 schema's `.refine()` requires `data/db/assistant.db` (legacy)
1046
- // or its `workspace/`-prefixed counterpart. A manifest declaring an
1047
- // empty contents array is valid at the field level but rejected by
1048
- // the refine.
1049
- const dbBytes = new Uint8Array([0x53, 0x51, 0x4c, 0x69, 0x74, 0x65]);
1050
- const manifest = buildTestManifest({
1051
- contents: [],
1052
- overrides: { bundle_id: "00000000-0000-4000-8000-000000000000" },
1053
- });
1054
- const manifestData = new TextEncoder().encode(JSON.stringify(manifest));
1055
-
1056
- const tar = createTarArchive([
1057
- { name: "manifest.json", data: manifestData },
1058
- ]);
1059
- const vbundle = gzipSync(tar);
1060
- const result = validateVBundle(vbundle);
1061
-
1062
- expect(result.is_valid).toBe(false);
1063
- // Sanity: this manifest WOULD be valid if it included the DB entry.
1064
- expect(dbBytes).toBeDefined();
1065
- });
1066
-
1067
- test("completely empty gzip content (no tar entries) fails gracefully", () => {
1068
- // A gzip of an empty buffer
1069
- const emptyGzip = gzipSync(new Uint8Array(0));
1070
- const result = validateVBundle(emptyGzip);
1071
-
1072
- expect(result.is_valid).toBe(false);
1073
- // Should fail with missing entries since the tar is empty
1074
- expect(result.errors.length).toBeGreaterThan(0);
1075
- });
1076
-
1077
- test("non-gzip data (random bytes) produces INVALID_GZIP", () => {
1078
- const randomBytes = new Uint8Array(256);
1079
- for (let i = 0; i < randomBytes.length; i++) {
1080
- randomBytes[i] = Math.floor(Math.random() * 256);
1081
- }
1082
-
1083
- const result = validateVBundle(randomBytes);
1084
-
1085
- expect(result.is_valid).toBe(false);
1086
- expect(result.errors).toHaveLength(1);
1087
- expect(result.errors[0].code).toBe("INVALID_GZIP");
1088
- expect(result.errors[0].message).toContain("gzip");
1089
- });
1090
-
1091
- test("truncated gzip data produces INVALID_GZIP", () => {
1092
- // Create a valid gzip and truncate it
1093
- const validGzip = gzipSync(new Uint8Array([1, 2, 3, 4, 5]));
1094
- const truncated = validGzip.subarray(0, Math.floor(validGzip.length / 2));
1095
-
1096
- const result = validateVBundle(truncated);
1097
-
1098
- expect(result.is_valid).toBe(false);
1099
- expect(result.errors[0].code).toBe("INVALID_GZIP");
1100
- });
1101
-
1102
- test("single byte input produces INVALID_GZIP", () => {
1103
- const result = validateVBundle(new Uint8Array([0x00]));
1104
-
1105
- expect(result.is_valid).toBe(false);
1106
- expect(result.errors[0].code).toBe("INVALID_GZIP");
1107
- });
1108
-
1109
- test("bundle with extra undeclared files in archive still validates (extra files are ignored)", () => {
1110
- const dbData = new Uint8Array([0x53, 0x51, 0x4c, 0x69, 0x74, 0x65]);
1111
- const extraData = new TextEncoder().encode("bonus content");
1112
-
1113
- // Manifest only declares the db file
1114
- const manifest = buildTestManifest({
1115
- contents: [
1116
- {
1117
- path: "data/db/assistant.db",
1118
- sha256: sha256Hex(dbData),
1119
- size_bytes: dbData.length,
1120
- },
1121
- ],
1122
- overrides: { bundle_id: "00000000-0000-4000-8000-000000000000" },
1123
- });
1124
- const manifestData = new TextEncoder().encode(JSON.stringify(manifest));
1125
-
1126
- // Archive has an extra file not in the manifest
1127
- const tar = createTarArchive([
1128
- { name: "manifest.json", data: manifestData },
1129
- { name: "data/db/assistant.db", data: dbData },
1130
- { name: "bonus/extra.txt", data: extraData },
1131
- ]);
1132
- const vbundle = gzipSync(tar);
1133
-
1134
- const result = validateVBundle(vbundle);
1135
- // Extra files in the archive (not declared in manifest) are not
1136
- // an error — the validator only checks declared files.
1137
- expect(result.is_valid).toBe(true);
1138
- });
1139
- });
1140
-
1141
- // ═══════════════════════════════════════════════════════════════════════════
1142
- // 6. Diagnostic quality — every error has a code and descriptive message
1143
- // ═══════════════════════════════════════════════════════════════════════════
1144
-
1145
- describe("diagnostic quality", () => {
1146
- test("INVALID_GZIP error message mentions gzip", () => {
1147
- const result = validateVBundle(new Uint8Array([0xff, 0xfe]));
1148
- const err = result.errors.find((e) => e.code === "INVALID_GZIP");
1149
- expect(err).toBeDefined();
1150
- expect(err!.message.toLowerCase()).toContain("gzip");
1151
- });
1152
-
1153
- test("MISSING_ENTRY error identifies which entry is missing", () => {
1154
- const tar = createTarArchive([
1155
- { name: "something-else.txt", data: new TextEncoder().encode("hi") },
1156
- ]);
1157
- const vbundle = gzipSync(tar);
1158
- const result = validateVBundle(vbundle);
1159
-
1160
- const manifestMissing = result.errors.find(
1161
- (e) => e.code === "MISSING_ENTRY" && e.path === "manifest.json",
1162
- );
1163
- expect(manifestMissing).toBeDefined();
1164
- expect(manifestMissing!.message).toContain("manifest.json");
1165
- });
1166
-
1167
- test("MANIFEST_CHECKSUM_MISMATCH includes expected and computed hashes", () => {
1168
- const dbData = new Uint8Array([0x53, 0x51, 0x4c, 0x69, 0x74, 0x65]);
1169
- const badHash =
1170
- "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc";
1171
- const manifest = {
1172
- schema_version: 1,
1173
- bundle_id: "00000000-0000-4000-8000-000000000000",
1174
- created_at: new Date().toISOString(),
1175
- assistant: { id: "self", name: "Test", runtime_version: "0.0.0-test" },
1176
- origin: { mode: "self-hosted-local" as const },
1177
- compatibility: {
1178
- min_runtime_version: "0.0.0-test",
1179
- max_runtime_version: null,
1180
- },
1181
- contents: [
1182
- {
1183
- path: "data/db/assistant.db",
1184
- sha256: sha256Hex(dbData),
1185
- size_bytes: dbData.length,
1186
- },
1187
- ],
1188
- checksum: badHash,
1189
- secrets_redacted: false,
1190
- export_options: {
1191
- include_logs: false,
1192
- include_browser_state: false,
1193
- include_memory_vectors: false,
1194
- },
1195
- };
1196
- const manifestData = new TextEncoder().encode(JSON.stringify(manifest));
1197
- const tar = createTarArchive([
1198
- { name: "manifest.json", data: manifestData },
1199
- { name: "data/db/assistant.db", data: dbData },
1200
- ]);
1201
- const vbundle = gzipSync(tar);
1202
- const result = validateVBundle(vbundle);
1203
-
1204
- const err = result.errors.find(
1205
- (e) => e.code === "MANIFEST_CHECKSUM_MISMATCH",
1206
- );
1207
- expect(err).toBeDefined();
1208
- expect(err!.message).toContain(badHash); // expected hash
1209
- expect(err!.message).toContain("computed"); // shows computed hash
1210
- });
1211
-
1212
- test("FILE_CHECKSUM_MISMATCH includes file path and both hashes", () => {
1213
- const dbData = new Uint8Array([0x53, 0x51, 0x4c, 0x69, 0x74, 0x65]);
1214
- const wrongSha =
1215
- "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd";
1216
-
1217
- const manifest = buildTestManifest({
1218
- contents: [
1219
- {
1220
- path: "data/db/assistant.db",
1221
- sha256: wrongSha,
1222
- size_bytes: dbData.length,
1223
- },
1224
- ],
1225
- overrides: { bundle_id: "00000000-0000-4000-8000-000000000000" },
1226
- });
1227
- const manifestData = new TextEncoder().encode(JSON.stringify(manifest));
1228
- const tar = createTarArchive([
1229
- { name: "manifest.json", data: manifestData },
1230
- { name: "data/db/assistant.db", data: dbData },
1231
- ]);
1232
- const vbundle = gzipSync(tar);
1233
- const result = validateVBundle(vbundle);
1234
-
1235
- const err = result.errors.find((e) => e.code === "FILE_CHECKSUM_MISMATCH");
1236
- expect(err).toBeDefined();
1237
- expect(err!.path).toBe("data/db/assistant.db");
1238
- expect(err!.message).toContain(wrongSha);
1239
- expect(err!.message).toContain("data/db/assistant.db");
1240
- });
1241
-
1242
- test("FILE_SIZE_MISMATCH includes file path and both sizes", () => {
1243
- const dbData = new Uint8Array([0x53, 0x51, 0x4c, 0x69, 0x74, 0x65]);
1244
-
1245
- const manifest = buildTestManifest({
1246
- contents: [
1247
- {
1248
- path: "data/db/assistant.db",
1249
- sha256: sha256Hex(dbData),
1250
- size_bytes: 99999,
1251
- },
1252
- ],
1253
- overrides: { bundle_id: "00000000-0000-4000-8000-000000000000" },
1254
- });
1255
- const manifestData = new TextEncoder().encode(JSON.stringify(manifest));
1256
- const tar = createTarArchive([
1257
- { name: "manifest.json", data: manifestData },
1258
- { name: "data/db/assistant.db", data: dbData },
1259
- ]);
1260
- const vbundle = gzipSync(tar);
1261
- const result = validateVBundle(vbundle);
1262
-
1263
- const err = result.errors.find((e) => e.code === "FILE_SIZE_MISMATCH");
1264
- expect(err).toBeDefined();
1265
- expect(err!.path).toBe("data/db/assistant.db");
1266
- expect(err!.message).toContain("99999");
1267
- expect(err!.message).toContain(String(dbData.length));
1268
- });
1269
-
1270
- test("import commit validation_failed response includes error codes and messages", () => {
1271
- const resolver = new DefaultPathResolver(testDir);
1272
- const result = commitImport({
1273
- archiveData: new Uint8Array([0x00]),
1274
- pathResolver: resolver,
1275
- });
1276
-
1277
- expect(result.ok).toBe(false);
1278
- if (!result.ok && result.reason === "validation_failed") {
1279
- for (const err of result.errors) {
1280
- expect(typeof err.code).toBe("string");
1281
- expect(err.code.length).toBeGreaterThan(0);
1282
- expect(typeof err.message).toBe("string");
1283
- expect(err.message.length).toBeGreaterThan(0);
1284
- }
1285
- }
1286
- });
1287
-
1288
- test("preflight validation_failed response includes structured errors", async () => {
1289
- const req = new Request("http://localhost/v1/migrations/import-preflight", {
1290
- method: "POST",
1291
- headers: { "Content-Type": "application/octet-stream" },
1292
- body: toArrayBuffer(new Uint8Array([0x00])),
1293
- });
1294
-
1295
- const res = await callHandler(handleMigrationImportPreflight, req);
1296
- const body = (await res.json()) as ImportDryRunResponse;
1297
-
1298
- expect(body.can_import).toBe(false);
1299
- expect(body.validation).toBeDefined();
1300
- expect(body.validation!.errors.length).toBeGreaterThan(0);
1301
-
1302
- for (const err of body.validation!.errors) {
1303
- expect(typeof err.code).toBe("string");
1304
- expect(err.code.length).toBeGreaterThan(0);
1305
- expect(typeof err.message).toBe("string");
1306
- expect(err.message.length).toBeGreaterThan(0);
1307
- }
1308
- });
1309
- });
1310
-
1311
- // ═══════════════════════════════════════════════════════════════════════════
1312
- // 7. Multiple errors accumulation
1313
- // ═══════════════════════════════════════════════════════════════════════════
1314
-
1315
- describe("multiple error accumulation", () => {
1316
- test("bundle with multiple file-level errors reports all of them", () => {
1317
- const dbData = new Uint8Array([0x53, 0x51, 0x4c, 0x69, 0x74, 0x65]);
1318
- const configData = new TextEncoder().encode('{"key":"value"}');
1319
- const wrongSha =
1320
- "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee";
1321
-
1322
- // Manifest declares correct db checksum but wrong config checksum and
1323
- // also wrong config size
1324
- const manifest = buildTestManifest({
1325
- contents: [
1326
- {
1327
- path: "data/db/assistant.db",
1328
- sha256: sha256Hex(dbData),
1329
- size_bytes: dbData.length,
1330
- },
1331
- {
1332
- path: "config/settings.json",
1333
- sha256: wrongSha,
1334
- size_bytes: 999,
1335
- },
1336
- ],
1337
- overrides: { bundle_id: "00000000-0000-4000-8000-000000000000" },
1338
- });
1339
- const manifestData = new TextEncoder().encode(JSON.stringify(manifest));
1340
-
1341
- const tar = createTarArchive([
1342
- { name: "manifest.json", data: manifestData },
1343
- { name: "data/db/assistant.db", data: dbData },
1344
- { name: "config/settings.json", data: configData },
1345
- ]);
1346
- const vbundle = gzipSync(tar);
1347
- const result = validateVBundle(vbundle);
1348
-
1349
- expect(result.is_valid).toBe(false);
1350
-
1351
- // Should have both a checksum mismatch and a size mismatch for config
1352
- const checksumError = result.errors.find(
1353
- (e) =>
1354
- e.code === "FILE_CHECKSUM_MISMATCH" &&
1355
- e.path === "config/settings.json",
1356
- );
1357
- const sizeError = result.errors.find(
1358
- (e) =>
1359
- e.code === "FILE_SIZE_MISMATCH" && e.path === "config/settings.json",
1360
- );
1361
-
1362
- expect(checksumError).toBeDefined();
1363
- expect(sizeError).toBeDefined();
1364
- // At least 2 errors for the config file
1365
- expect(result.errors.length).toBeGreaterThanOrEqual(2);
1366
- });
1367
-
1368
- test("manifest with multiple missing required fields reports all issues", () => {
1369
- const dbData = new Uint8Array([0x53, 0x51, 0x4c, 0x69, 0x74, 0x65]);
1370
- // Completely empty manifest (has none of the required fields)
1371
- const manifestObj = {};
1372
-
1373
- const vbundle = createVBundleWithRawManifest(manifestObj, [
1374
- { name: "data/db/assistant.db", data: dbData },
1375
- ]);
1376
- const result = validateVBundle(vbundle);
1377
-
1378
- expect(result.is_valid).toBe(false);
1379
- // Should report multiple schema errors for missing fields
1380
- const schemaErrors = result.errors.filter(
1381
- (e) => e.code === "MANIFEST_SCHEMA_ERROR",
1382
- );
1383
- expect(schemaErrors.length).toBeGreaterThanOrEqual(3);
1384
- });
1385
- });
1386
-
1387
- // ═══════════════════════════════════════════════════════════════════════════
1388
- // 8. Builder -> validator consistency
1389
- // ═══════════════════════════════════════════════════════════════════════════
1390
-
1391
- describe("builder -> validator consistency", () => {
1392
- test("buildVBundle archives across content shapes pass validation", () => {
1393
- const testCases: Array<{ files: VBundleFile[] }> = [
1394
- {
1395
- files: [
1396
- {
1397
- path: "data/db/assistant.db",
1398
- data: new Uint8Array([0x01]),
1399
- },
1400
- ],
1401
- },
1402
- {
1403
- files: [
1404
- {
1405
- path: "data/db/assistant.db",
1406
- data: new Uint8Array(10000), // 10KB of zeroes
1407
- },
1408
- {
1409
- path: "config/settings.json",
1410
- data: new TextEncoder().encode('{"big":"config","keys":[1,2,3]}'),
1411
- },
1412
- ],
1413
- },
1414
- {
1415
- files: [
1416
- {
1417
- path: "data/db/assistant.db",
1418
- data: new Uint8Array(0), // empty
1419
- },
1420
- ],
1421
- },
1422
- ];
1423
-
1424
- for (const tc of testCases) {
1425
- const { archive } = buildVBundle({
1426
- files: tc.files,
1427
- ...defaultV1Options(),
1428
- });
1429
-
1430
- const result = validateVBundle(archive);
1431
- expect(result.is_valid).toBe(true);
1432
- expect(result.manifest?.schema_version).toBe(1);
1433
- }
1434
- });
1435
-
1436
- test("builder output checksum matches validator computation", () => {
1437
- const { archive, manifest } = buildVBundle({
1438
- files: [
1439
- {
1440
- path: "data/db/assistant.db",
1441
- data: new Uint8Array([0xca, 0xfe]),
1442
- },
1443
- ],
1444
- ...defaultV1Options(),
1445
- });
1446
-
1447
- const result = validateVBundle(archive);
1448
- expect(result.is_valid).toBe(true);
1449
- expect(result.manifest?.checksum).toBe(manifest.checksum);
1450
- });
1451
-
1452
- test("builder output file checksums match validator computation", () => {
1453
- const fileData = new Uint8Array([0xde, 0xad, 0xbe, 0xef]);
1454
- const { archive, manifest } = buildVBundle({
1455
- files: [{ path: "data/db/assistant.db", data: fileData }],
1456
- ...defaultV1Options(),
1457
- });
1458
-
1459
- const result = validateVBundle(archive);
1460
- expect(result.is_valid).toBe(true);
1461
- expect(result.manifest?.contents[0].sha256).toBe(
1462
- manifest.contents[0].sha256,
1463
- );
1464
- expect(result.manifest?.contents[0].sha256).toBe(sha256Hex(fileData));
1465
- });
1466
- });
1467
-
1468
- // ═══════════════════════════════════════════════════════════════════════════
1469
- // 9. Import analyzer edge cases
1470
- // ═══════════════════════════════════════════════════════════════════════════
1471
-
1472
- describe("import analyzer edge cases", () => {
1473
- test("all-unchanged files produce correct summary", () => {
1474
- const resolver = new DefaultPathResolver(testDir);
1475
- const existingConfig = new Uint8Array(readFileSync(testConfigPath));
1476
-
1477
- const report = analyzeImport({
1478
- manifest: {
1479
- schema_version: 1,
1480
- bundle_id: "00000000-0000-4000-8000-000000000000",
1481
- created_at: "2026-03-01T00:00:00Z",
1482
- assistant: { id: "self", name: "Test", runtime_version: "0.0.0-test" },
1483
- origin: { mode: "self-hosted-local" },
1484
- compatibility: {
1485
- min_runtime_version: "0.0.0-test",
1486
- max_runtime_version: null,
1487
- },
1488
- contents: [
1489
- {
1490
- path: "data/db/assistant.db",
1491
- sha256: sha256Hex(EXISTING_DB_DATA),
1492
- size_bytes: EXISTING_DB_DATA.length,
1493
- },
1494
- {
1495
- path: "config/settings.json",
1496
- sha256: sha256Hex(existingConfig),
1497
- size_bytes: existingConfig.length,
1498
- },
1499
- ],
1500
- checksum: "test",
1501
- secrets_redacted: false,
1502
- export_options: {
1503
- include_logs: false,
1504
- include_browser_state: false,
1505
- include_memory_vectors: false,
1506
- },
1507
- },
1508
- pathResolver: resolver,
1509
- });
1510
-
1511
- expect(report.can_import).toBe(true);
1512
- expect(report.summary.files_unchanged).toBe(2);
1513
- expect(report.summary.files_to_create).toBe(0);
1514
- expect(report.summary.files_to_overwrite).toBe(0);
1515
- expect(report.conflicts).toHaveLength(0);
1516
- });
1517
-
1518
- test("all-create scenario (fresh install) produces correct summary", () => {
1519
- const resolver = new DefaultPathResolver(
1520
- join(testDir, "nonexistent-workspace"),
1521
- );
1522
-
1523
- const report = analyzeImport({
1524
- manifest: buildTestManifest({
1525
- contents: [
1526
- {
1527
- path: "data/db/assistant.db",
1528
- sha256: sha256Hex(new Uint8Array([1])),
1529
- size_bytes: 1,
1530
- },
1531
- {
1532
- path: "config/settings.json",
1533
- sha256: sha256Hex(new Uint8Array([2])),
1534
- size_bytes: 1,
1535
- },
1536
- ],
1537
- overrides: {
1538
- bundle_id: "00000000-0000-4000-8000-000000000000",
1539
- created_at: "2026-03-01T00:00:00Z",
1540
- },
1541
- }),
1542
- pathResolver: resolver,
1543
- });
1544
-
1545
- expect(report.can_import).toBe(true);
1546
- expect(report.summary.files_to_create).toBe(2);
1547
- expect(report.summary.files_unchanged).toBe(0);
1548
- expect(report.summary.files_to_overwrite).toBe(0);
1549
- });
1550
-
1551
- test("mixed create/overwrite/unchanged produces accurate counts", () => {
1552
- const resolver = new DefaultPathResolver(testDir);
1553
-
1554
- // db: different from disk -> overwrite
1555
- // config: same as disk -> unchanged
1556
- const existingConfig = new Uint8Array(readFileSync(testConfigPath));
1557
-
1558
- const report = analyzeImport({
1559
- manifest: buildTestManifest({
1560
- contents: [
1561
- {
1562
- path: "data/db/assistant.db",
1563
- sha256: sha256Hex(new Uint8Array([0xff])),
1564
- size_bytes: 1,
1565
- },
1566
- {
1567
- path: "config/settings.json",
1568
- sha256: sha256Hex(existingConfig),
1569
- size_bytes: existingConfig.length,
1570
- },
1571
- ],
1572
- overrides: {
1573
- bundle_id: "00000000-0000-4000-8000-000000000000",
1574
- created_at: "2026-03-01T00:00:00Z",
1575
- },
1576
- }),
1577
- pathResolver: resolver,
1578
- });
1579
-
1580
- expect(report.summary.files_to_overwrite).toBe(1);
1581
- expect(report.summary.files_unchanged).toBe(1);
1582
- expect(report.summary.files_to_create).toBe(0);
1583
- });
1584
-
1585
- test("unknown archive path produces UNKNOWN_ARCHIVE_PATH conflict", () => {
1586
- const resolver = new DefaultPathResolver(testDir);
1587
-
1588
- const report = analyzeImport({
1589
- manifest: buildTestManifest({
1590
- contents: [
1591
- {
1592
- path: "data/db/assistant.db",
1593
- sha256: sha256Hex(EXISTING_DB_DATA),
1594
- size_bytes: EXISTING_DB_DATA.length,
1595
- },
1596
- {
1597
- path: "future/unknown-file.dat",
1598
- sha256: sha256Hex(new Uint8Array([0])),
1599
- size_bytes: 1,
1600
- },
1601
- ],
1602
- overrides: {
1603
- bundle_id: "00000000-0000-4000-8000-000000000000",
1604
- created_at: "2026-03-01T00:00:00Z",
1605
- },
1606
- }),
1607
- pathResolver: resolver,
1608
- });
1609
-
1610
- expect(report.conflicts.length).toBe(1);
1611
- expect(report.conflicts[0].code).toBe("UNKNOWN_ARCHIVE_PATH");
1612
- expect(report.conflicts[0].message).toContain("future/unknown-file.dat");
1613
- });
1614
- });
1615
-
1616
- // ═══════════════════════════════════════════════════════════════════════════
1617
- // 10. HTTP endpoint error consistency
1618
- // ═══════════════════════════════════════════════════════════════════════════
1619
-
1620
- describe("HTTP endpoint error consistency", () => {
1621
- test("validate and import agree on invalid bundle diagnosis", async () => {
1622
- const invalidData = new Uint8Array([0xba, 0xad, 0xca, 0xfe]);
1623
-
1624
- const validateReq = new Request("http://localhost/v1/migrations/validate", {
1625
- method: "POST",
1626
- headers: { "Content-Type": "application/octet-stream" },
1627
- body: toArrayBuffer(invalidData),
1628
- });
1629
- const validateRes = await callHandler(handleMigrationValidate, validateReq);
1630
- const validateBody = (await validateRes.json()) as ValidationResponse;
1631
-
1632
- const importReq = new Request("http://localhost/v1/migrations/import", {
1633
- method: "POST",
1634
- headers: { "Content-Type": "application/octet-stream" },
1635
- body: toArrayBuffer(invalidData),
1636
- });
1637
- const importRes = await callHandler(handleMigrationImport, importReq);
1638
- const importBody = (await importRes.json()) as ImportCommitResponse;
1639
-
1640
- // Both should identify the same error type
1641
- expect(validateBody.is_valid).toBe(false);
1642
- expect(importBody.success).toBe(false);
1643
- expect(validateBody.errors[0].code).toBe(importBody.errors![0].code);
1644
- });
1645
-
1646
- test("validate and preflight agree on invalid bundle diagnosis", async () => {
1647
- const invalidData = new Uint8Array([0xba, 0xad, 0xca, 0xfe]);
1648
-
1649
- const validateReq = new Request("http://localhost/v1/migrations/validate", {
1650
- method: "POST",
1651
- headers: { "Content-Type": "application/octet-stream" },
1652
- body: toArrayBuffer(invalidData),
1653
- });
1654
- const validateRes = await callHandler(handleMigrationValidate, validateReq);
1655
- const validateBody = (await validateRes.json()) as ValidationResponse;
1656
-
1657
- const preflightReq = new Request(
1658
- "http://localhost/v1/migrations/import-preflight",
1659
- {
1660
- method: "POST",
1661
- headers: { "Content-Type": "application/octet-stream" },
1662
- body: toArrayBuffer(invalidData),
1663
- },
1664
- );
1665
- const preflightRes = await callHandler(
1666
- handleMigrationImportPreflight,
1667
- preflightReq,
1668
- );
1669
- const preflightBody = (await preflightRes.json()) as ImportDryRunResponse;
1670
-
1671
- expect(validateBody.is_valid).toBe(false);
1672
- expect(preflightBody.can_import).toBe(false);
1673
- expect(validateBody.errors[0].code).toBe(
1674
- preflightBody.validation!.errors[0].code,
1675
- );
1676
- });
1677
-
1678
- test("all migration endpoints return 400 for empty body", async () => {
1679
- const endpoints = [
1680
- "http://localhost/v1/migrations/validate",
1681
- "http://localhost/v1/migrations/import-preflight",
1682
- "http://localhost/v1/migrations/import",
1683
- ];
1684
- const handlers = [
1685
- handleMigrationValidate,
1686
- handleMigrationImportPreflight,
1687
- handleMigrationImport,
1688
- ];
1689
-
1690
- for (let i = 0; i < endpoints.length; i++) {
1691
- const req = new Request(endpoints[i], {
1692
- method: "POST",
1693
- headers: { "Content-Type": "application/octet-stream" },
1694
- body: toArrayBuffer(new Uint8Array(0)),
1695
- });
1696
-
1697
- const res = await callHandler(handlers[i], req);
1698
- expect(res.status).toBe(400);
1699
-
1700
- const body = (await res.json()) as {
1701
- error: { code: string; message: string };
1702
- };
1703
- expect(body.error.code).toBe("BAD_REQUEST");
1704
- expect(body.error.message).toBeDefined();
1705
- expect(body.error.message.length).toBeGreaterThan(0);
1706
- }
1707
- });
1708
- });