@mycadet/assistant 0.12.10 → 0.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/node_modules/@mycadet/ces-client/node_modules/@mycadet/service-contracts/src/__tests__/ingress.test.ts +10 -10
- package/node_modules/@mycadet/ces-client/node_modules/@mycadet/service-contracts/src/ingress.ts +3 -3
- package/node_modules/@mycadet/ces-client/src/http-credentials.ts +18 -1
- package/node_modules/@mycadet/gateway-client/node_modules/@mycadet/service-contracts/src/__tests__/ingress.test.ts +10 -10
- package/node_modules/@mycadet/gateway-client/node_modules/@mycadet/service-contracts/src/ingress.ts +3 -3
- package/node_modules/@mycadet/gateway-client/src/gateway-ipc-contracts.ts +0 -20
- package/node_modules/@mycadet/service-contracts/src/__tests__/ingress.test.ts +10 -10
- package/node_modules/@mycadet/service-contracts/src/ingress.ts +3 -3
- package/openapi.yaml +2 -708
- package/package.json +2 -3
- package/scripts/sync-web-search-catalog.ts +1 -2
- package/src/__tests__/cloud-profile-guard.test.ts +41 -5
- package/src/__tests__/config-schema.test.ts +6 -38
- package/src/__tests__/conversation-queue.test.ts +45 -6
- package/src/__tests__/db-readiness-http-gate.test.ts +0 -6
- package/src/__tests__/persist-onboarding-artifacts.test.ts +0 -1
- package/src/__tests__/persona-resolver.test.ts +0 -51
- package/src/__tests__/provider-cloud-proxy-integration.test.ts +2 -110
- package/src/__tests__/run-due-schedules.test.ts +0 -4
- package/src/__tests__/schedule-store.test.ts +0 -12
- package/src/__tests__/stt-stream-session.test.ts +4 -4
- package/src/__tests__/telephony-tts-guard.test.ts +2 -50
- package/src/__tests__/voice-config-update.test.ts +12 -167
- package/src/__tests__/web-search-catalog-parity.test.ts +0 -2
- package/src/__tests__/workspace-migration-130-speech-mode-to-provider.test.ts +9 -7
- package/src/__tests__/workspace-migration-132-web-search-mode-to-provider.test.ts +6 -6
- package/src/calls/__tests__/voice-session-bridge.test.ts +1 -4
- package/src/calls/resolve-call-tts-provider.ts +6 -25
- package/src/calls/telephony-credential-preflight.ts +0 -10
- package/src/calls/telephony-tts-capability.ts +1 -17
- package/src/cli/commands/tts.ts +1 -2
- package/src/cli/index.help.ts +0 -2
- package/src/cli/program.ts +0 -2
- package/src/config/bundled-skills/settings/tools/voice-config-update.ts +4 -42
- package/src/config/schemas/__tests__/stt.test.ts +10 -14
- package/src/config/schemas/llm.ts +3 -5
- package/src/config/schemas/services.ts +5 -6
- package/src/config/schemas/stt.ts +1 -3
- package/src/config/schemas/tts.ts +1 -30
- package/src/daemon/daemon-readiness.ts +4 -29
- package/src/daemon/lifecycle.ts +0 -31
- package/src/daemon/providers-setup.ts +0 -8
- package/src/heartbeat/__tests__/heartbeat-service.test.ts +0 -1
- package/src/heartbeat/heartbeat-service.ts +1 -66
- package/src/ipc/__tests__/db-readiness-ipc-gate.test.ts +1 -9
- package/src/live-voice/__tests__/live-voice-credential-preflight.test.ts +0 -44
- package/src/live-voice/__tests__/live-voice-flux-turn-end.test.ts +4 -6
- package/src/live-voice/__tests__/live-voice-stt.test.ts +1 -1
- package/src/live-voice/live-voice-credential-preflight.ts +9 -33
- package/src/live-voice/live-voice-session.ts +10 -6
- package/src/live-voice/live-voice-tts.ts +1 -8
- package/src/plugins/defaults/memory/worker.ts +3 -3
- package/src/prompts/persona-resolver.ts +0 -35
- package/src/providers/__tests__/search-provider-catalog.test.ts +5 -8
- package/src/providers/__tests__/voice-error-copy.test.ts +9 -10
- package/src/providers/search-provider-catalog.ts +7 -39
- package/src/providers/speech-to-text/__tests__/provider-catalog.test.ts +3 -47
- package/src/providers/speech-to-text/__tests__/resolve.test.ts +6 -372
- package/src/providers/speech-to-text/provider-catalog.ts +2 -73
- package/src/providers/speech-to-text/resolve.ts +3 -120
- package/src/runtime/http-router-types.ts +1 -3
- package/src/runtime/http-server.ts +1 -1
- package/src/runtime/routes/__tests__/conversation-query-routes.test.ts +13 -11
- package/src/runtime/routes/__tests__/tts-routes.test.ts +2 -6
- package/src/runtime/routes/conversation-query-routes.ts +0 -96
- package/src/runtime/routes/inbound-stages/transcribe-audio.test.ts +7 -7
- package/src/runtime/routes/index.ts +0 -4
- package/src/runtime/routes/live-voice-routes.test.ts +4 -11
- package/src/runtime/routes/live-voice-routes.ts +8 -11
- package/src/runtime/routes/secret-routes.ts +0 -6
- package/src/runtime/routes/tts-routes.ts +0 -38
- package/src/schedule/__tests__/plugin-schedule-reconciler.test.ts +1 -7
- package/src/schedule/__tests__/worker-mcp-readiness.test.ts +0 -4
- package/src/schedule/schedule-store.ts +0 -7
- package/src/schedule/scheduler.ts +0 -5
- package/src/schedule/worker.ts +1 -1
- package/src/stt/__tests__/daemon-batch-transcriber.test.ts +2 -10
- package/src/stt/daemon-batch-transcriber.ts +4 -38
- package/src/stt/types.ts +1 -9
- package/src/tools/network/__tests__/web-search.test.ts +3 -250
- package/src/tools/network/web-search.ts +4 -165
- package/src/tts/__tests__/provider-resolution.test.ts +0 -5
- package/src/tts/provider-catalog.ts +0 -4
- package/src/tts/types.ts +0 -1
- package/src/__tests__/cloud-speech-defaults.test.ts +0 -330
- package/src/__tests__/migration-cross-version-compatibility.test.ts +0 -1708
- package/src/__tests__/migration-export-http.test.ts +0 -557
- package/src/__tests__/migration-export-streaming.test.ts +0 -350
- package/src/__tests__/migration-export-to-gcs.test.ts +0 -706
- package/src/__tests__/migration-import-commit-http.test.ts +0 -1176
- package/src/__tests__/migration-import-from-gcs.test.ts +0 -534
- package/src/__tests__/migration-import-from-path.test.ts +0 -324
- package/src/__tests__/migration-import-from-url.test.ts +0 -588
- package/src/__tests__/migration-import-preflight-http.test.ts +0 -739
- package/src/__tests__/migration-jobs-status.test.ts +0 -139
- package/src/__tests__/migration-parity-persistence.test.ts +0 -1891
- package/src/__tests__/migration-transport.test.ts +0 -1089
- package/src/__tests__/migration-validate-http.test.ts +0 -634
- package/src/__tests__/migration-wizard.test.ts +0 -1395
- package/src/__tests__/rebind-secrets-screen.test.ts +0 -876
- package/src/__tests__/sanitize-config-for-transfer.test.ts +0 -259
- package/src/__tests__/transfer-progress-screen.test.ts +0 -1217
- package/src/__tests__/validation-results-screen.test.ts +0 -1130
- package/src/__tests__/vbundle-pax-and-symlink.test.ts +0 -147
- package/src/background-wake/background-wake-routes.test.ts +0 -906
- package/src/background-wake/cloud-client.test.ts +0 -328
- package/src/background-wake/cloud-client.ts +0 -196
- package/src/background-wake/next-wake.test.ts +0 -335
- package/src/background-wake/next-wake.ts +0 -188
- package/src/background-wake/publisher.ts +0 -93
- package/src/background-wake/wake-intent-hooks.test.ts +0 -234
- package/src/cli/commands/__tests__/backup.test.ts +0 -363
- package/src/cli/commands/backup.help.ts +0 -180
- package/src/cli/commands/backup.ts +0 -398
- package/src/cloud/cloud-speech.test.ts +0 -335
- package/src/cloud/cloud-speech.ts +0 -300
- package/src/config/cloud-speech-defaults.ts +0 -300
- package/src/config/sanitize-for-transfer.ts +0 -111
- package/src/live-voice/__tests__/live-voice-cloud-speech-fallback.test.ts +0 -250
- package/src/providers/speech-to-text/__tests__/cadet-cloud-realtime.test.ts +0 -457
- package/src/providers/speech-to-text/__tests__/cadet-cloud.test.ts +0 -271
- package/src/providers/speech-to-text/__tests__/cadet-speech-relay-connection.test.ts +0 -126
- package/src/providers/speech-to-text/cadet-cloud-flux-realtime.ts +0 -309
- package/src/providers/speech-to-text/cadet-cloud-realtime.ts +0 -315
- package/src/providers/speech-to-text/cadet-cloud.ts +0 -108
- package/src/providers/speech-to-text/cadet-speech-relay-connection.ts +0 -176
- package/src/runtime/migrations/__tests__/gcs-signed-url.test.ts +0 -254
- package/src/runtime/migrations/__tests__/job-registry.test.ts +0 -346
- package/src/runtime/migrations/__tests__/origin-mode.test.ts +0 -62
- package/src/runtime/migrations/__tests__/rebind-secrets-credentials.test.ts +0 -184
- package/src/runtime/migrations/__tests__/staged-import-path.test.ts +0 -110
- package/src/runtime/migrations/__tests__/v1-test-helpers.ts +0 -112
- package/src/runtime/migrations/__tests__/vbundle-builder-credentials.test.ts +0 -285
- package/src/runtime/migrations/__tests__/vbundle-builder-fd-leak.test.ts +0 -126
- package/src/runtime/migrations/__tests__/vbundle-builder-v1-shape.test.ts +0 -253
- package/src/runtime/migrations/__tests__/vbundle-import-credentials.test.ts +0 -208
- package/src/runtime/migrations/__tests__/vbundle-import-parity.test.ts +0 -485
- package/src/runtime/migrations/__tests__/vbundle-import-policy.test.ts +0 -349
- package/src/runtime/migrations/__tests__/vbundle-import-version-compat.test.ts +0 -196
- package/src/runtime/migrations/__tests__/vbundle-legacy-user-md.test.ts +0 -551
- package/src/runtime/migrations/__tests__/vbundle-metadata-merge-integration.test.ts +0 -428
- package/src/runtime/migrations/__tests__/vbundle-metadata-merge.test.ts +0 -220
- package/src/runtime/migrations/__tests__/vbundle-streaming-importer.test.ts +0 -1823
- package/src/runtime/migrations/__tests__/vbundle-streaming-validator.test.ts +0 -598
- package/src/runtime/migrations/__tests__/vbundle-symlink-importer.test.ts +0 -463
- package/src/runtime/migrations/__tests__/vbundle-symlink-streaming-importer.test.ts +0 -538
- package/src/runtime/migrations/__tests__/vbundle-symlink-streaming.test.ts +0 -515
- package/src/runtime/migrations/__tests__/vbundle-symlink-tar.test.ts +0 -453
- package/src/runtime/migrations/__tests__/vbundle-symlink-walker.test.ts +0 -319
- package/src/runtime/migrations/__tests__/vbundle-tar-stream.test.ts +0 -240
- package/src/runtime/migrations/__tests__/vbundle-validator-v1-schema.test.ts +0 -473
- package/src/runtime/migrations/gcs-signed-url.ts +0 -190
- package/src/runtime/migrations/job-registry.ts +0 -297
- package/src/runtime/migrations/migration-transport.ts +0 -612
- package/src/runtime/migrations/migration-wizard.ts +0 -747
- package/src/runtime/migrations/origin-mode.ts +0 -42
- package/src/runtime/migrations/rebind-secrets-screen.ts +0 -412
- package/src/runtime/migrations/staged-import-path.ts +0 -116
- package/src/runtime/migrations/transfer-progress-screen.ts +0 -323
- package/src/runtime/migrations/validation-results-screen.ts +0 -469
- package/src/runtime/migrations/vbundle-builder.ts +0 -1382
- package/src/runtime/migrations/vbundle-import-analyzer.ts +0 -405
- package/src/runtime/migrations/vbundle-import-policy.ts +0 -241
- package/src/runtime/migrations/vbundle-importer.ts +0 -826
- package/src/runtime/migrations/vbundle-metadata-merge.ts +0 -113
- package/src/runtime/migrations/vbundle-streaming-importer.ts +0 -2844
- package/src/runtime/migrations/vbundle-streaming-validator.ts +0 -426
- package/src/runtime/migrations/vbundle-tar-stream.ts +0 -244
- package/src/runtime/migrations/vbundle-validator.ts +0 -724
- package/src/runtime/routes/__tests__/migration-cadet-metadata-reconcile.test.ts +0 -227
- package/src/runtime/routes/__tests__/migration-export-secrets-redacted.test.ts +0 -54
- package/src/runtime/routes/__tests__/migration-import-allowed-hosts-env.test.ts +0 -70
- package/src/runtime/routes/__tests__/migration-import-credential-filter.test.ts +0 -257
- package/src/runtime/routes/background-wake-routes.ts +0 -387
- package/src/runtime/routes/migration-routes.ts +0 -2584
- package/src/tools/network/__tests__/cloud-search-proxy.test.ts +0 -282
- package/src/tools/network/cloud-search-proxy.ts +0 -211
- package/src/tts/__tests__/cadet-provider.test.ts +0 -253
- package/src/tts/providers/cadet-provider.ts +0 -244
- 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
|
-
});
|