/** * HTTP-layer integration tests for POST /v1/migrations/import. * * Tests cover: * - Success: valid .vbundle writes files to disk and returns import report * - Validation failures: invalid bundle returns success: false with errors * - Request errors: empty body, invalid multipart * - Backup creation: existing files are backed up before overwriting * - Post-write integrity: written files match expected checksums * - Skipped files: unknown archive paths are reported as skipped * - Auth: route policy enforcement (settings.write scope required) * - Integration: existing routes are unaffected by the new endpoint */ import { createHash, randomUUID } from "node:crypto"; import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, unlinkSync, writeFileSync, } from "node:fs"; import { join } from "node:path"; import { gzipSync } from "node:zlib"; import { afterEach, beforeAll, beforeEach, describe, expect, mock, test, } from "bun:test"; /** Convert a Uint8Array to an ArrayBuffer for BodyInit compatibility. */ function toArrayBuffer(data: Uint8Array): ArrayBuffer { return data.buffer.slice( data.byteOffset, data.byteOffset + data.byteLength, ) as ArrayBuffer; } const testDir = process.env.VELLUM_WORKSPACE_DIR!; const testDbDir = join(testDir, "data", "db"); const testDbPath = join(testDbDir, "assistant.db"); const testConfigPath = join(testDir, "config.json"); mock.module("../permissions/trust-store.js", () => ({ getAllRules: () => [], isStarterBundleAccepted: () => false, clearCache: () => {}, })); const realEnv = await import("../config/env.js"); mock.module("../config/env.js", () => ({ ...realEnv, isHttpAuthDisabled: () => true, hasUngatedHttpAuthDisabled: () => false, getGatewayInternalBaseUrl: () => "http://127.0.0.1:7830", getRuntimeHttpPort: () => 7821, getRuntimeHttpHost: () => "127.0.0.1", getRuntimeGatewayOriginSecret: () => undefined, })); import { DefaultPathResolver } from "../runtime/migrations/vbundle-import-analyzer.js"; import { commitImport } from "../runtime/migrations/vbundle-importer.js"; import { handleMigrationImport } from "../runtime/routes/migration-routes.js"; import { callHandler } from "./helpers/call-route-handler.js"; // Test fixture data const EXISTING_DB_DATA = new Uint8Array([ 0x53, 0x51, 0x4c, 0x69, 0x74, 0x65, 0x20, 0x66, 0x6f, 0x72, 0x6d, 0x61, 0x74, 0x20, 0x33, 0x00, ]); const EXISTING_CONFIG = { provider: "anthropic", model: "test-model" }; beforeAll(() => { mkdirSync(testDbDir, { recursive: true }); writeFileSync(testDbPath, EXISTING_DB_DATA); writeFileSync(testConfigPath, JSON.stringify(EXISTING_CONFIG, null, 2)); }); // Restore test files before each test so mutations from previous tests // do not leak across test cases. beforeEach(() => { mkdirSync(testDbDir, { recursive: true }); writeFileSync(testDbPath, EXISTING_DB_DATA); writeFileSync(testConfigPath, JSON.stringify(EXISTING_CONFIG, null, 2)); }); // Clean up backup files after each test afterEach(() => { try { const entries = readdirSync(testDbDir); for (const entry of entries) { if (entry.includes(".backup-")) { unlinkSync(join(testDbDir, entry)); } } const parentEntries = readdirSync(testDir); for (const entry of parentEntries) { if (entry.includes(".backup-")) { unlinkSync(join(testDir, entry)); } } } catch { /* best effort */ } }); // --------------------------------------------------------------------------- // Tar archive builder helpers (mirrors validate/preflight tests) // --------------------------------------------------------------------------- const BLOCK_SIZE = 512; function padToBlock(data: Uint8Array): Uint8Array { const remainder = data.length % BLOCK_SIZE; if (remainder === 0) { return data; } const padded = new Uint8Array(data.length + (BLOCK_SIZE - remainder)); padded.set(data); return padded; } function writeOctal( buf: Uint8Array, offset: number, length: number, value: number, ): void { const str = value.toString(8).padStart(length - 1, "0"); for (let i = 0; i < str.length; i++) { buf[offset + i] = str.charCodeAt(i); } buf[offset + length - 1] = 0; } function computeHeaderChecksum(header: Uint8Array): number { let sum = 0; for (let i = 0; i < 512; i++) { if (i >= 148 && i < 156) { sum += 0x20; } else { sum += header[i]; } } return sum; } function createTarEntry(name: string, data: Uint8Array): Uint8Array { const header = new Uint8Array(BLOCK_SIZE); const encoder = new TextEncoder(); const nameBytes = encoder.encode(name); header.set(nameBytes.subarray(0, 100), 0); writeOctal(header, 100, 8, 0o644); writeOctal(header, 108, 8, 0); writeOctal(header, 116, 8, 0); writeOctal(header, 124, 12, data.length); writeOctal(header, 136, 12, Math.floor(Date.now() / 1000)); header[156] = "0".charCodeAt(0); const magic = encoder.encode("ustar\0"); header.set(magic, 257); header[263] = "0".charCodeAt(0); header[264] = "0".charCodeAt(0); const checksum = computeHeaderChecksum(header); writeOctal(header, 148, 7, checksum); header[155] = 0x20; const paddedData = padToBlock(data); const result = new Uint8Array(header.length + paddedData.length); result.set(header, 0); result.set(paddedData, header.length); return result; } function createTarArchive( entries: Array<{ name: string; data: Uint8Array }>, ): Uint8Array { const parts: Uint8Array[] = []; for (const entry of entries) { parts.push(createTarEntry(entry.name, entry.data)); } parts.push(new Uint8Array(BLOCK_SIZE * 2)); const totalLength = parts.reduce((sum, p) => sum + p.length, 0); const result = new Uint8Array(totalLength); let offset = 0; for (const part of parts) { result.set(part, offset); offset += part.length; } return result; } function sha256Hex(data: Uint8Array | string): string { return createHash("sha256").update(data).digest("hex"); } function canonicalizeJson(obj: unknown): string { return JSON.stringify(obj, (_key, value) => { if (value && typeof value === "object" && !Array.isArray(value)) { const sorted: Record = {}; for (const k of Object.keys(value as Record).sort()) { sorted[k] = (value as Record)[k]; } return sorted; } return value; }); } interface VBundleFile { path: string; data: Uint8Array; } /** * Build a v1-shape vbundle archive for HTTP/import tests. * * Mirrors the v1 ten-field manifest that `buildVBundle()` produces, so the * fixtures here pass the same `ManifestSchema` zod validation the real * importer applies. The manifest's `checksum` is computed against the * canonical JSON with `checksum` set to "" (matches `computeManifestChecksum` * in vbundle-validator.ts). * * Adds a synthetic `data/db/assistant.db` entry to `contents` when the caller * doesn't supply one, satisfying the schema's `.refine()` constraint that * every bundle must reference an assistant.db file. */ function createValidVBundle( files?: VBundleFile[], overrides?: Partial<{ bundle_id: string; origin_mode: "managed" | "self-hosted-remote" | "self-hosted-local"; secrets_redacted: boolean; min_runtime_version: string; max_runtime_version: string | null; }>, ): Uint8Array { const dbData = new Uint8Array([0x53, 0x51, 0x4c, 0x69, 0x74, 0x65]); const bundleFiles = files ?? [{ path: "data/db/assistant.db", data: dbData }]; // v1 schema requires contents to include data/db/assistant.db (legacy) or // workspace/data/db/assistant.db (current). If the caller's files don't // satisfy that refine, inject a synthetic legacy-path db entry — this // matches the pattern used in vbundle-streaming-importer.test.ts. const hasDbEntry = bundleFiles.some( (f) => f.path === "data/db/assistant.db" || f.path === "workspace/data/db/assistant.db", ); const allFiles: VBundleFile[] = hasDbEntry ? bundleFiles : [ { path: "data/db/assistant.db", data: new Uint8Array() }, ...bundleFiles, ]; const contents = allFiles.map((f) => ({ path: f.path, sha256: sha256Hex(f.data), size_bytes: f.data.length, })); const manifestWithoutChecksum = { schema_version: 1 as const, bundle_id: overrides?.bundle_id ?? randomUUID(), created_at: new Date().toISOString(), assistant: { id: "self", name: "Test", runtime_version: "0.0.0-test", }, origin: { mode: overrides?.origin_mode ?? "self-hosted-local", }, compatibility: { min_runtime_version: overrides?.min_runtime_version ?? "0.0.0-test", max_runtime_version: overrides?.max_runtime_version === undefined ? null : overrides.max_runtime_version, }, contents, checksum: "", secrets_redacted: overrides?.secrets_redacted ?? false, export_options: { include_logs: false, include_browser_state: false, include_memory_vectors: false, }, }; const checksum = sha256Hex(canonicalizeJson(manifestWithoutChecksum)); const manifest = { ...manifestWithoutChecksum, checksum }; const manifestData = new TextEncoder().encode(JSON.stringify(manifest)); const tarEntries = [ { name: "manifest.json", data: manifestData }, ...allFiles.map((f) => ({ name: f.path, data: f.data })), ]; const tar = createTarArchive(tarEntries); return gzipSync(tar); } // --------------------------------------------------------------------------- // Import commit report types (for type-safe assertions) // --------------------------------------------------------------------------- interface ImportCommitResponse { success: boolean; summary: { total_files: number; files_created: number; files_overwritten: number; files_skipped: number; backups_created: number; }; files: Array<{ path: string; disk_path: string; action: string; size: number; sha256: string; backup_path: string | null; }>; manifest: Record; warnings: string[]; } interface ImportValidationFailureResponse { success: boolean; reason: string; errors: Array<{ code: string; message: string; path?: string }>; } // --------------------------------------------------------------------------- // HTTP handler tests: success cases // --------------------------------------------------------------------------- describe("handleMigrationImport", () => { test("POST with valid vbundle returns 200 with import report", async () => { const newDbData = new Uint8Array([0xde, 0xad, 0xbe, 0xef]); const vbundle = createValidVBundle([ { path: "data/db/assistant.db", data: newDbData }, ]); const req = new Request("http://localhost/v1/migrations/import", { method: "POST", headers: { "Content-Type": "application/octet-stream" }, body: toArrayBuffer(vbundle), }); const res = await callHandler(handleMigrationImport, req); const body = (await res.json()) as ImportCommitResponse; expect(res.status).toBe(200); expect(body.success).toBe(true); expect(body.summary).toBeDefined(); expect(body.summary.total_files).toBeGreaterThan(0); expect(body.files).toBeDefined(); expect(body.files.length).toBeGreaterThan(0); expect(body.manifest).toBeDefined(); }); test("writes bundle data to disk", async () => { const newDbData = new Uint8Array([0xca, 0xfe, 0xba, 0xbe, 0xde, 0xad]); const vbundle = createValidVBundle([ { path: "data/db/assistant.db", data: newDbData }, ]); const req = new Request("http://localhost/v1/migrations/import", { method: "POST", headers: { "Content-Type": "application/octet-stream" }, body: toArrayBuffer(vbundle), }); await callHandler(handleMigrationImport, req); // Verify the file was actually written to disk const writtenData = new Uint8Array(readFileSync(testDbPath)); expect(writtenData).toEqual(newDbData); }); test("workspace is cleared before restore — preserved dirs are overwritten", async () => { // New-format bundles (workspace/ prefix) trigger workspace clearing, but // data/db/ is preserved during clearing to avoid destroying the database // if the import fails partway. The DB file is overwritten (with backup). const newDbData = new Uint8Array([0x01, 0x02, 0x03]); const vbundle = createValidVBundle([ { path: "workspace/data/db/assistant.db", data: newDbData }, ]); const req = new Request("http://localhost/v1/migrations/import", { method: "POST", headers: { "Content-Type": "application/octet-stream" }, body: toArrayBuffer(vbundle), }); const res = await callHandler(handleMigrationImport, req); const body = (await res.json()) as ImportCommitResponse; expect(body.success).toBe(true); expect(body.summary.backups_created).toBe(1); const dbFile = body.files.find( (f) => f.path === "workspace/data/db/assistant.db", ); expect(dbFile).toBeDefined(); expect(dbFile!.action).toBe("overwritten"); }); test("workspace clear: preserved dirs overwritten, cleared dirs created", async () => { // data/db/ is preserved during workspace clearing → DB is "overwritten". // config.json lives at the workspace root which IS cleared → "created". const newDbData = new Uint8Array([0xaa, 0xbb]); const newConfigData = new TextEncoder().encode('{"provider":"openai"}'); const vbundle = createValidVBundle([ { path: "workspace/data/db/assistant.db", data: newDbData }, { path: "workspace/config.json", data: newConfigData }, ]); const req = new Request("http://localhost/v1/migrations/import", { method: "POST", headers: { "Content-Type": "application/octet-stream" }, body: toArrayBuffer(vbundle), }); const res = await callHandler(handleMigrationImport, req); const body = (await res.json()) as ImportCommitResponse; expect(body.success).toBe(true); const dbFile = body.files.find( (f) => f.path === "workspace/data/db/assistant.db", ); const configFile = body.files.find( (f) => f.path === "workspace/config.json", ); // data/db/ preserved during clearing → overwritten; config.json cleared → created expect(dbFile!.action).toBe("overwritten"); expect(configFile!.action).toBe("created"); }); test("summary counts match file details", async () => { const newDbData = new Uint8Array([0xdd, 0xee, 0xff]); const newConfigData = new TextEncoder().encode('{"provider":"test"}'); const vbundle = createValidVBundle([ { path: "data/db/assistant.db", data: newDbData }, { path: "config/settings.json", data: newConfigData }, ]); const req = new Request("http://localhost/v1/migrations/import", { method: "POST", headers: { "Content-Type": "application/octet-stream" }, body: toArrayBuffer(vbundle), }); const res = await callHandler(handleMigrationImport, req); const body = (await res.json()) as ImportCommitResponse; expect(body.success).toBe(true); expect(body.summary.total_files).toBe(body.files.length); const created = body.files.filter((f) => f.action === "created").length; const overwritten = body.files.filter( (f) => f.action === "overwritten", ).length; const skipped = body.files.filter((f) => f.action === "skipped").length; expect(body.summary.files_created).toBe(created); expect(body.summary.files_overwritten).toBe(overwritten); expect(body.summary.files_skipped).toBe(skipped); }); test("includes manifest in response", async () => { const fixedBundleId = "11111111-2222-4333-8444-555555555555"; const vbundle = createValidVBundle(undefined, { bundle_id: fixedBundleId, }); const req = new Request("http://localhost/v1/migrations/import", { method: "POST", headers: { "Content-Type": "application/octet-stream" }, body: toArrayBuffer(vbundle), }); const res = await callHandler(handleMigrationImport, req); const body = (await res.json()) as ImportCommitResponse; expect(body.manifest).toBeDefined(); expect(body.manifest.schema_version).toBe(1); expect(body.manifest.bundle_id).toBe(fixedBundleId); expect(body.manifest.assistant).toBeDefined(); expect(body.manifest.origin).toEqual({ mode: "self-hosted-local" }); }); test("POST with multipart form data works", async () => { const vbundle = createValidVBundle(); const formData = new FormData(); formData.append("file", new Blob([toArrayBuffer(vbundle)]), "test.vbundle"); const req = new Request("http://localhost/v1/migrations/import", { method: "POST", body: formData, }); const res = await callHandler(handleMigrationImport, req); const body = (await res.json()) as ImportCommitResponse; expect(res.status).toBe(200); expect(body.success).toBe(true); }); test("skips files with unknown archive paths", async () => { const dbData = new Uint8Array([0x53, 0x51, 0x4c, 0x69, 0x74, 0x65]); const extraData = new Uint8Array([0x01, 0x02]); const vbundle = createValidVBundle([ { path: "data/db/assistant.db", data: dbData }, { path: "unknown/extra-file.bin", data: extraData }, ]); const req = new Request("http://localhost/v1/migrations/import", { method: "POST", headers: { "Content-Type": "application/octet-stream" }, body: toArrayBuffer(vbundle), }); const res = await callHandler(handleMigrationImport, req); const body = (await res.json()) as ImportCommitResponse; expect(body.success).toBe(true); expect(body.summary.files_skipped).toBe(1); const skippedFile = body.files.find( (f) => f.path === "unknown/extra-file.bin", ); expect(skippedFile).toBeDefined(); expect(skippedFile!.action).toBe("skipped"); expect(body.warnings.length).toBeGreaterThan(0); }); }); // --------------------------------------------------------------------------- // HTTP handler tests: validation failure cases // --------------------------------------------------------------------------- describe("handleMigrationImport — validation failures", () => { test("invalid gzip returns success: false with validation errors", async () => { const req = new Request("http://localhost/v1/migrations/import", { method: "POST", headers: { "Content-Type": "application/octet-stream" }, body: toArrayBuffer(new Uint8Array([0xde, 0xad, 0xbe, 0xef])), }); const res = await callHandler(handleMigrationImport, req); const body = (await res.json()) as ImportValidationFailureResponse; expect(res.status).toBe(200); expect(body.success).toBe(false); expect(body.reason).toBe("validation_failed"); expect(body.errors.length).toBeGreaterThan(0); expect(body.errors[0].code).toBe("INVALID_GZIP"); }); test("missing manifest returns success: false", async () => { const dbData = new Uint8Array([0x53, 0x51, 0x4c, 0x69, 0x74, 0x65]); const tar = createTarArchive([ { name: "data/db/assistant.db", data: dbData }, ]); const vbundle = gzipSync(tar); const req = new Request("http://localhost/v1/migrations/import", { method: "POST", headers: { "Content-Type": "application/octet-stream" }, body: toArrayBuffer(vbundle), }); const res = await callHandler(handleMigrationImport, req); const body = (await res.json()) as ImportValidationFailureResponse; expect(res.status).toBe(200); expect(body.success).toBe(false); expect(body.reason).toBe("validation_failed"); }); test("empty body returns 400", async () => { const req = new Request("http://localhost/v1/migrations/import", { method: "POST", headers: { "Content-Type": "application/octet-stream" }, body: toArrayBuffer(new Uint8Array(0)), }); const res = await callHandler(handleMigrationImport, req); const body = (await res.json()) as { error: { code: string; message: string }; }; expect(res.status).toBe(400); expect(body.error.code).toBe("BAD_REQUEST"); }); test("multipart without file field returns 400", async () => { const formData = new FormData(); formData.append("notfile", "some text"); const req = new Request("http://localhost/v1/migrations/import", { method: "POST", body: formData, }); const res = await callHandler(handleMigrationImport, req); const body = (await res.json()) as { error: { code: string } }; expect(res.status).toBe(400); expect(body.error.code).toBe("BAD_REQUEST"); }); test("does not modify disk when validation fails", async () => { // Save original data const originalDb = new Uint8Array(readFileSync(testDbPath)); const originalConfig = readFileSync(testConfigPath, "utf8"); // Send invalid bundle const req = new Request("http://localhost/v1/migrations/import", { method: "POST", headers: { "Content-Type": "application/octet-stream" }, body: toArrayBuffer(new Uint8Array([0xde, 0xad, 0xbe, 0xef])), }); await callHandler(handleMigrationImport, req); // Verify disk was not modified const currentDb = new Uint8Array(readFileSync(testDbPath)); const currentConfig = readFileSync(testConfigPath, "utf8"); expect(currentDb).toEqual(originalDb); expect(currentConfig).toBe(originalConfig); }); }); // --------------------------------------------------------------------------- // HTTP handler tests: version_incompatible (Codex P2 regression) // // Pin: a bundle whose min_runtime_version exceeds APP_VERSION must surface as // a 4xx user-actionable response (422 Unprocessable Entity), not a 500 // InternalError. Body mirrors the platform's PR #5470 response shape so // clients can render the same UX regardless of which gate (platform or // runtime) rejected the bundle. // --------------------------------------------------------------------------- describe("handleMigrationImport — version_incompatible", () => { test("incompatible bundle returns 422 with structured body, not 500", async () => { const vbundle = createValidVBundle(undefined, { min_runtime_version: "99.0.0", }); const req = new Request("http://localhost/v1/migrations/import", { method: "POST", headers: { "Content-Type": "application/octet-stream" }, body: toArrayBuffer(vbundle), }); const res = await callHandler(handleMigrationImport, req); const body = (await res.json()) as { error: { code: string; message: string; details?: { reason: string; bundle_compat: { min_runtime_version: string; max_runtime_version: string | null; }; runtime_version: string; }; }; }; expect(res.status).toBe(422); expect(body.error.code).toBe("UNPROCESSABLE_ENTITY"); expect(body.error.message).toContain("99.0.0"); expect(body.error.details).toBeDefined(); expect(body.error.details!.reason).toBe("version_incompatible"); expect(body.error.details!.bundle_compat.min_runtime_version).toBe( "99.0.0", ); expect(body.error.details!.bundle_compat.max_runtime_version).toBeNull(); expect(typeof body.error.details!.runtime_version).toBe("string"); }); test("incompatible bundle does not modify disk", async () => { const originalDb = new Uint8Array(readFileSync(testDbPath)); const originalConfig = readFileSync(testConfigPath, "utf8"); const vbundle = createValidVBundle(undefined, { min_runtime_version: "99.0.0", }); const req = new Request("http://localhost/v1/migrations/import", { method: "POST", headers: { "Content-Type": "application/octet-stream" }, body: toArrayBuffer(vbundle), }); await callHandler(handleMigrationImport, req); const currentDb = new Uint8Array(readFileSync(testDbPath)); const currentConfig = readFileSync(testConfigPath, "utf8"); expect(currentDb).toEqual(originalDb); expect(currentConfig).toBe(originalConfig); }); // Regression: the route handler pre-checks runtime-version compat using // `validation.manifest.compatibility` before calling `resetDb()` and // `commitImport()`. If a future refactor reorders the close to come // before the gate, an incompatible bundle would still 422 (commitImport // has its own defense-in-depth gate) but it would unnecessarily close // and reopen the live SQLite singleton on every rejected import. // // We can't easily spy on `resetDb` here without mocking `db-connection.js` // module-wide (which other tests in this file rely on for real). The // semantic regression — disk unchanged + 422 — is covered by the two // tests above. This test is a marker so future readers know the pre- // check intent; if it ever starts failing, recheck `handleMigrationImport` // ordering against the comment at line 861-862 ("Validate the bundle // before closing the DB to avoid an unnecessary close/reopen cycle when // the bundle is invalid"). test("incompatible bundle short-circuits before resetDb (intent marker)", async () => { const vbundle = createValidVBundle(undefined, { min_runtime_version: "99.0.0", }); const req = new Request("http://localhost/v1/migrations/import", { method: "POST", headers: { "Content-Type": "application/octet-stream" }, body: toArrayBuffer(vbundle), }); const res = await callHandler(handleMigrationImport, req); expect(res.status).toBe(422); }); }); // --------------------------------------------------------------------------- // commitImport unit tests // --------------------------------------------------------------------------- describe("commitImport", () => { test("returns ok: true with report on success", () => { const newDbData = new Uint8Array([0xfa, 0xce]); const vbundle = createValidVBundle([ { path: "data/db/assistant.db", data: newDbData }, ]); const resolver = new DefaultPathResolver(testDir); const result = commitImport({ archiveData: vbundle, pathResolver: resolver, }); expect(result.ok).toBe(true); if (result.ok) { expect(result.report.success).toBe(true); expect(result.report.files.length).toBeGreaterThan(0); } }); test("returns validation_failed for invalid bundles", () => { const resolver = new DefaultPathResolver(testDir); const result = commitImport({ archiveData: new Uint8Array([0xba, 0xad]), pathResolver: resolver, }); expect(result.ok).toBe(false); if (!result.ok) { expect(result.reason).toBe("validation_failed"); if (result.reason === "validation_failed") { expect(result.errors.length).toBeGreaterThan(0); } } }); test("creates parent directories if they do not exist", () => { // Use a workspace that does not exist yet const nonexistentWorkspace = join(testDir, "new-workspace"); const expectedDbPath = join( nonexistentWorkspace, "data", "db", "assistant.db", ); const dbData = new Uint8Array([0x01, 0x02, 0x03]); const vbundle = createValidVBundle([ { path: "data/db/assistant.db", data: dbData }, ]); const resolver = new DefaultPathResolver(nonexistentWorkspace); const result = commitImport({ archiveData: vbundle, pathResolver: resolver, }); expect(result.ok).toBe(true); if (result.ok) { expect(result.report.files[0].action).toBe("created"); expect(existsSync(expectedDbPath)).toBe(true); } // Clean up rmSync(nonexistentWorkspace, { recursive: true, force: true }); }); test("post-write integrity: written file SHA-256 matches expected", () => { const newDbData = new Uint8Array([0xab, 0xcd, 0xef]); const expectedSha256 = sha256Hex(newDbData); const vbundle = createValidVBundle([ { path: "data/db/assistant.db", data: newDbData }, ]); const resolver = new DefaultPathResolver(testDir); const result = commitImport({ archiveData: vbundle, pathResolver: resolver, }); expect(result.ok).toBe(true); if (result.ok) { // No integrity warnings should be present const integrityWarnings = result.report.warnings.filter((w) => w.includes("integrity"), ); expect(integrityWarnings).toHaveLength(0); // Verify the file on disk matches const writtenData = new Uint8Array(readFileSync(testDbPath)); const writtenSha256 = sha256Hex(writtenData); expect(writtenSha256).toBe(expectedSha256); } }); test("handles multiple files (db + config)", () => { const newDbData = new Uint8Array([0x11, 0x22, 0x33]); const newConfigData = new TextEncoder().encode('{"model":"claude"}'); const vbundle = createValidVBundle([ { path: "data/db/assistant.db", data: newDbData }, { path: "config/settings.json", data: newConfigData }, ]); const resolver = new DefaultPathResolver(testDir); const result = commitImport({ archiveData: vbundle, pathResolver: resolver, }); expect(result.ok).toBe(true); if (result.ok) { expect(result.report.summary.total_files).toBe(2); // Verify both files were written const writtenDb = new Uint8Array(readFileSync(testDbPath)); expect(writtenDb).toEqual(newDbData); const writtenConfig = readFileSync(testConfigPath, "utf8"); expect(writtenConfig).toBe( JSON.stringify({ model: "claude" }, null, 2) + "\n", ); } }); }); // --------------------------------------------------------------------------- // Workspace clearing tests // --------------------------------------------------------------------------- describe("commitImport — workspace clearing", () => { const skillsDir = join(testDir, "skills"); const hooksDir = join(testDir, "hooks"); afterEach(() => { // Restore test fixture files for subsequent tests mkdirSync(testDbDir, { recursive: true }); writeFileSync(testDbPath, EXISTING_DB_DATA); writeFileSync(testConfigPath, JSON.stringify(EXISTING_CONFIG, null, 2)); // Clean up skills/hooks dirs for (const dir of [skillsDir, hooksDir]) { if (existsSync(dir)) { rmSync(dir, { recursive: true, force: true }); } } }); test("clears stale files via workspace clearing (new-format workspace/ entries)", () => { mkdirSync(join(skillsDir, "stale-skill"), { recursive: true }); writeFileSync(join(skillsDir, "stale-skill", "SKILL.md"), "stale"); const skillData = new TextEncoder().encode("# New Skill"); const vbundle = createValidVBundle([ { path: "workspace/skills/new-skill/SKILL.md", data: skillData }, ]); const resolver = new DefaultPathResolver(testDir); const result = commitImport({ archiveData: vbundle, pathResolver: resolver, workspaceDir: testDir, }); expect(result.ok).toBe(true); if (!result.ok) { return; } // New skill written expect(existsSync(join(skillsDir, "new-skill", "SKILL.md"))).toBe(true); expect(readFileSync(join(skillsDir, "new-skill", "SKILL.md"), "utf8")).toBe( "# New Skill", ); // Stale skill removed by workspace clearing expect(existsSync(join(skillsDir, "stale-skill"))).toBe(false); }); test("old-format skills/ entries do not trigger workspace clearing", () => { mkdirSync(join(skillsDir, "stale-skill"), { recursive: true }); writeFileSync(join(skillsDir, "stale-skill", "SKILL.md"), "stale"); const skillData = new TextEncoder().encode("# New Skill"); const vbundle = createValidVBundle([ { path: "skills/new-skill/SKILL.md", data: skillData }, ]); const resolver = new DefaultPathResolver(testDir); const result = commitImport({ archiveData: vbundle, pathResolver: resolver, workspaceDir: testDir, }); expect(result.ok).toBe(true); if (!result.ok) { return; } // New skill written expect(existsSync(join(skillsDir, "new-skill", "SKILL.md"))).toBe(true); // Stale skill survives — old-format bundles don't trigger workspace clearing expect(existsSync(join(skillsDir, "stale-skill", "SKILL.md"))).toBe(true); }); test("hooks/ entries import to hooksDir (not workspace/hooks/)", () => { // Use a separate hooks dir outside the workspace, like production layout const externalHooksDir = join(testDir, ".hooks-external"); mkdirSync(externalHooksDir, { recursive: true }); const hookData = new TextEncoder().encode("#!/bin/sh\necho new"); const vbundle = createValidVBundle([ { path: "hooks/new-hook/hook.sh", data: hookData }, ]); const resolver = new DefaultPathResolver(testDir, externalHooksDir); const result = commitImport({ archiveData: vbundle, pathResolver: resolver, workspaceDir: testDir, }); expect(result.ok).toBe(true); if (!result.ok) { return; } // Hook written to the external hooks dir, not workspace/hooks/ expect(existsSync(join(externalHooksDir, "new-hook", "hook.sh"))).toBe( true, ); expect( readFileSync(join(externalHooksDir, "new-hook", "hook.sh"), "utf8"), ).toBe("#!/bin/sh\necho new"); // Cleanup rmSync(externalHooksDir, { recursive: true, force: true }); }); test("without workspaceDir, no clearing happens", () => { mkdirSync(join(skillsDir, "existing-skill"), { recursive: true }); writeFileSync( join(skillsDir, "existing-skill", "SKILL.md"), "should survive", ); const vbundle = createValidVBundle([ { path: "skills/new-skill/SKILL.md", data: new TextEncoder().encode("new"), }, ]); const resolver = new DefaultPathResolver(testDir); // No workspaceDir — no clearing const result = commitImport({ archiveData: vbundle, pathResolver: resolver, }); expect(result.ok).toBe(true); // Existing skill survives (no workspace clearing without workspaceDir) expect(existsSync(join(skillsDir, "existing-skill", "SKILL.md"))).toBe( true, ); }); }); // --------------------------------------------------------------------------- // Config sanitization tests // --------------------------------------------------------------------------- describe("commitImport — config sanitization", () => { test("imported workspace/config.json has environment-specific fields stripped", () => { const configWithEnvFields = { provider: "anthropic", model: "test-model", ingress: { publicBaseUrl: "https://my-tunnel.example.com", enabled: true, port: 8080, }, daemon: { autoStart: true, logLevel: "debug", }, skills: { load: { extraDirs: ["/home/user/custom-skills"], autoReload: true, }, }, memory: { enabled: true }, }; const configData = new TextEncoder().encode( JSON.stringify(configWithEnvFields, null, 2), ); const vbundle = createValidVBundle([ { path: "workspace/config.json", data: configData }, ]); const resolver = new DefaultPathResolver(testDir); const result = commitImport({ archiveData: vbundle, pathResolver: resolver, workspaceDir: testDir, }); expect(result.ok).toBe(true); if (!result.ok) { return; } // Read the written config from disk and verify sanitization const writtenConfig = JSON.parse(readFileSync(testConfigPath, "utf8")); // Environment-specific fields should be stripped/reset expect(writtenConfig.ingress.publicBaseUrl).toBe(""); expect(writtenConfig.ingress.enabled).toBeUndefined(); expect(writtenConfig.daemon).toBeUndefined(); expect(writtenConfig.skills.load.extraDirs).toEqual([]); // Non-environment-specific fields should be preserved expect(writtenConfig.provider).toBe("anthropic"); expect(writtenConfig.model).toBe("test-model"); expect(writtenConfig.ingress.port).toBe(8080); expect(writtenConfig.skills.load.autoReload).toBe(true); expect(writtenConfig.memory.enabled).toBe(true); }); test("imported config/settings.json (legacy) has environment-specific fields stripped", () => { const configWithEnvFields = { provider: "openai", daemon: { autoStart: false }, ingress: { publicBaseUrl: "https://old-tunnel.example.com", enabled: false, }, skills: { load: { extraDirs: ["/legacy/skills"] } }, }; const configData = new TextEncoder().encode( JSON.stringify(configWithEnvFields, null, 2), ); const vbundle = createValidVBundle([ { path: "config/settings.json", data: configData }, ]); const resolver = new DefaultPathResolver(testDir); const result = commitImport({ archiveData: vbundle, pathResolver: resolver, }); expect(result.ok).toBe(true); if (!result.ok) { return; } const writtenConfig = JSON.parse(readFileSync(testConfigPath, "utf8")); expect(writtenConfig.ingress.publicBaseUrl).toBe(""); expect(writtenConfig.ingress.enabled).toBeUndefined(); expect(writtenConfig.daemon).toBeUndefined(); expect(writtenConfig.skills.load.extraDirs).toEqual([]); expect(writtenConfig.provider).toBe("openai"); }); }); // --------------------------------------------------------------------------- // Auth policy registration tests // --------------------------------------------------------------------------- describe("route policy registration", () => { test("migrations/import policy requires settings.write scope", async () => { const { ROUTES } = await import("../runtime/routes/index.js"); const policy = ROUTES.find((r) => r.endpoint === "migrations/import")?.policy ?? null; expect(policy).not.toBeNull(); expect(policy?.requiredScopes).toContain("settings.write"); expect(policy?.allowedPrincipalTypes).toContain("actor"); expect(policy?.allowedPrincipalTypes).toContain("svc_gateway"); expect(policy?.allowedPrincipalTypes).toContain("local"); }); test("import policy matches export/preflight but differs from validate on scopes", async () => { const { ROUTES } = await import("../runtime/routes/index.js"); const importPolicy = ROUTES.find((r) => r.endpoint === "migrations/import")?.policy ?? null; const validatePolicy = ROUTES.find((r) => r.endpoint === "migrations/validate")?.policy ?? null; const exportPolicy = ROUTES.find((r) => r.endpoint === "migrations/export")?.policy ?? null; const preflightPolicy = ROUTES.find((r) => r.endpoint === "migrations/import-preflight") ?.policy ?? null; // import, export, and preflight all require settings.write expect(importPolicy!.requiredScopes).toEqual(exportPolicy!.requiredScopes); expect(importPolicy!.requiredScopes).toEqual( preflightPolicy!.requiredScopes, ); expect(importPolicy!.requiredScopes).toEqual(["settings.write"]); // validate is read-only so requires settings.read expect(validatePolicy!.requiredScopes).toEqual(["settings.read"]); // all share the same principal types expect(importPolicy!.allowedPrincipalTypes).toEqual( validatePolicy!.allowedPrincipalTypes, ); }); }); // --------------------------------------------------------------------------- // Integration: existing routes unaffected // --------------------------------------------------------------------------- describe("integration: existing routes unaffected", () => { test("GET /v1/health still works", async () => { const { handleDetailedHealth } = await import("../runtime/routes/identity-routes.js"); const res = await handleDetailedHealth(); const body = (await res.json()) as Record; expect(res.status).toBe(200); expect(body.status).toBe("healthy"); }); test("validate, export, and import-preflight handlers still importable", async () => { const { handleMigrationValidate, handleMigrationExport, handleMigrationImportPreflight, } = await import("../runtime/routes/migration-routes.js"); expect(typeof handleMigrationValidate).toBe("function"); expect(typeof handleMigrationExport).toBe("function"); expect(typeof handleMigrationImportPreflight).toBe("function"); }); });