Files
SeedPass/js/packages/core/test/migrations.test.ts
T
thePR0M3TH3ANandClaude Fable 5 7407d65294 parity: schema migrations 0->4, so legacy Python vaults open in TypeScript
This was the one remaining gap that could strand a real user: TS refused
any index below schema_version 4, which is every profile written before
tags were added.

- core/schema/migrations.ts ports seedpass.core.migrations exactly,
  including the quirks: v1->v2 copies `website` to `label` and drops it
  only for password entries; `kind` is NOT backfilled (Python leaves
  pre-v2 entries `type`-only and falls back on read). Migration works on a
  copy, so a failure can never leave a half-converted vault.
- parseVaultIndex now migrates on open and still refuses future versions;
  pass {migrate:false} where silent upgrades are unwanted.
- Two bugs this surfaced: the version probe could not read a v0 index (no
  schema_version field at all), and migrated legacy entries failed schema
  validation because the discriminator `kind` was missing. The parser now
  fills `kind` from `type` at validation time -- the same shape Python
  writes for new entries, documented in the matrix.

Evidence: fixtures generated by running Python's own apply_migrations over
v0/v1/v2/v3 payloads, plus cross-impl phase E, which writes a real legacy
v2 index into a Python profile, opens it with the TS CLI, reveals a
migrated secret, and confirms Python still reads the profile afterwards.

162 core + 46 CLI tests green; 16/16 cross-implementation checks green.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-08-14 16:19:30 -04:00

72 lines
2.5 KiB
TypeScript

/**
* Schema migration parity: TS must migrate legacy indexes to exactly what
* Python's apply_migrations produces, so an old Python profile opens here.
*/
import { describe, expect, it } from "vitest";
import { migrationCases, migrationLatestVersion } from "@seedpass/test-vectors";
import {
applyMigrations,
needsMigration,
parseVaultIndex,
SchemaMigrationError,
CURRENT_SCHEMA_VERSION,
UnsupportedSchemaVersionError,
} from "@seedpass/core";
describe("schema migrations", () => {
it("agrees with Python on the current version", () => {
expect(CURRENT_SCHEMA_VERSION).toBe(migrationLatestVersion);
});
it.each(migrationCases)("migrates $name exactly as Python does", (c) => {
expect(applyMigrations(c.input)).toEqual(c.migrated);
});
it("does not mutate the caller's payload", () => {
const input = migrationCases[0]!.input;
const snapshot = JSON.stringify(input);
applyMigrations(input);
expect(JSON.stringify(input)).toBe(snapshot);
});
it("is idempotent on an already-current index", () => {
const migrated = applyMigrations(migrationCases[0]!.input);
expect(applyMigrations(migrated)).toEqual(migrated);
expect(needsMigration(migrated)).toBe(false);
});
it("parseVaultIndex opens legacy indexes by migrating them", () => {
for (const c of migrationCases) {
const index = parseVaultIndex(c.input);
expect(index.schema_version).toBe(CURRENT_SCHEMA_VERSION);
expect(Object.keys(index.entries).length).toBeGreaterThan(0);
for (const entry of Object.values(index.entries)) {
expect(entry.tags).toBeDefined();
}
}
});
it("parseVaultIndex can refuse to migrate when asked", () => {
expect(() => parseVaultIndex(migrationCases[0]!.input, { migrate: false })).toThrow(
/needs migration/,
);
});
it("refuses future schema versions in both paths", () => {
const future = { schema_version: 99, entries: {} };
expect(() => applyMigrations(future)).toThrow(SchemaMigrationError);
expect(() => parseVaultIndex(future)).toThrow(UnsupportedSchemaVersionError);
});
it("recovers a v0 password entry's label from its website field", () => {
const v0 = migrationCases.find((c) => c.name === "v0")!;
const index = parseVaultIndex(v0.input);
const labels = Object.values(index.entries).map((e) => e.label);
expect(labels).toContain("legacy-site.example");
for (const entry of Object.values(index.entries)) {
expect(entry).not.toHaveProperty("website");
}
});
});