177 lines
7.2 KiB
TypeScript
177 lines
7.2 KiB
TypeScript
/* eslint-disable @typescript-eslint/no-unsafe-call */
|
|
/** Stage 8 — A/B staging, activation, rollback, and user-state isolation. */
|
|
import { beforeEach, describe, expect, it } from "vitest";
|
|
// @ts-expect-error fake-indexeddb types via exports fallback
|
|
import FDBFactory from "fake-indexeddb/lib/FDBFactory";
|
|
import { buildPackage } from "../../pipeline/package.js";
|
|
import { generateTestKeyPair } from "../../pipeline/sign.js";
|
|
import { makeValidInput } from "../../pipeline/fixtures.js";
|
|
import { canonicalJson } from "../../pipeline/canonical-json.js";
|
|
import { publicKeyFromDerBase64 } from "../../src/sync/verifier/ed25519.js";
|
|
import { verifyPackage } from "../../src/sync/verifier/package.js";
|
|
import type { VerifiedPackage } from "../../src/sync/verifier/types.js";
|
|
import {
|
|
activateStagedPackage,
|
|
restorePreviousSlot,
|
|
stageVerifiedPackage,
|
|
} from "../../src/sync/activation.js";
|
|
import { openSystemDB, readSystemMeta } from "../../src/data/system-meta/store.js";
|
|
import { openSlotDB, readStagingProgress } from "../../src/data/slot/store.js";
|
|
import { openUserDB, addFavorite, listFavorites } from "../../src/data/user/store.js";
|
|
import { DB, SLOT_FILES } from "../../src/platform/idb/names.js";
|
|
import { withTx } from "../../src/platform/idb/wrapper.js";
|
|
import { activateSlot } from "../../src/data/system-meta/store.js";
|
|
|
|
const g = globalThis as unknown as Record<string, unknown>;
|
|
|
|
function deleteDb(name: string): Promise<void> {
|
|
return new Promise((resolve, reject) => {
|
|
const request = (g.indexedDB as IDBFactory).deleteDatabase(name);
|
|
request.onsuccess = () => {
|
|
resolve();
|
|
};
|
|
request.onerror = () => {
|
|
reject(request.error ?? new Error("delete database failed"));
|
|
};
|
|
request.onblocked = () => {
|
|
resolve();
|
|
};
|
|
});
|
|
}
|
|
|
|
async function fixture(version = 1): Promise<VerifiedPackage> {
|
|
const keyPair = generateTestKeyPair();
|
|
const built = buildPackage(makeValidInput({ packageVersion: version }), { signWith: keyPair });
|
|
if (!built.ok || !built.pkg.signature) throw new Error("fixture build failed");
|
|
const manifestBytes = new TextEncoder().encode(canonicalJson(built.pkg.manifest));
|
|
const files = new Map<string, Uint8Array>();
|
|
for (const [name, file] of built.pkg.files) files.set(name, file.canonicalBytes);
|
|
for (const asset of built.pkg.assets) files.set(asset.file, asset.bytesContent);
|
|
const result = await verifyPackage(
|
|
{
|
|
manifestBytes,
|
|
signature: built.pkg.signature,
|
|
files: { getFile: (name) => Promise.resolve(files.get(name)) },
|
|
emergencyFloor: built.pkg.emergencyFloor,
|
|
},
|
|
{
|
|
trustedKeys: new Map([
|
|
[keyPair.fingerprint, publicKeyFromDerBase64(keyPair.publicKeyDerBase64)],
|
|
]),
|
|
appVersion: "1.0.0",
|
|
supportedSchemaRange: [1],
|
|
},
|
|
);
|
|
if (!result.ok) throw new Error(result.reason);
|
|
return result;
|
|
}
|
|
|
|
describe("Stage 8 A/B activation", () => {
|
|
beforeEach(async () => {
|
|
g.indexedDB = new FDBFactory() as unknown;
|
|
await Promise.all(Object.values(DB).map((name) => deleteDb(name)));
|
|
});
|
|
|
|
it("stages into A and activates atomically, leaving user favorites intact", async () => {
|
|
const user = await openUserDB();
|
|
await addFavorite(user, { eventId: "event-1", addedAt: 1 });
|
|
user.close();
|
|
const pkg = await fixture();
|
|
const staged = await stageVerifiedPackage(pkg);
|
|
expect(staged.slot).toBe("A");
|
|
expect(staged.journal.complete).toBe(true);
|
|
expect((await activateStagedPackage(pkg, staged, "1.0.0")).ok).toBe(true);
|
|
const system = await openSystemDB();
|
|
expect((await readSystemMeta(system)).activeSlot).toBe("A");
|
|
expect((await readSystemMeta(system)).readbackPending).toBe(false);
|
|
system.close();
|
|
const userAfter = await openUserDB();
|
|
expect(await listFavorites(userAfter)).toEqual([{ eventId: "event-1", addedAt: 1 }]);
|
|
userAfter.close();
|
|
});
|
|
|
|
it("resumes matching progress and discards stale progress before restaging", async () => {
|
|
const pkg = await fixture();
|
|
const first = await stageVerifiedPackage(pkg);
|
|
const slot = await openSlotDB(first.slot);
|
|
const journal = await readStagingProgress(slot);
|
|
expect(journal?.complete).toBe(true);
|
|
slot.close();
|
|
const resumed = await stageVerifiedPackage(pkg);
|
|
expect(resumed.journal.stagedFiles).toHaveLength(5);
|
|
expect(resumed.journal.startedAt).toBe(first.journal.startedAt);
|
|
});
|
|
|
|
it("restores the retained complete slot without changing user data", async () => {
|
|
const first = await fixture(1);
|
|
const stagedFirst = await stageVerifiedPackage(first);
|
|
expect((await activateStagedPackage(first, stagedFirst, "1.0.0")).ok).toBe(true);
|
|
const second = await fixture(2);
|
|
const stagedSecond = await stageVerifiedPackage(second);
|
|
expect((await activateStagedPackage(second, stagedSecond, "1.0.0")).ok).toBe(true);
|
|
expect(await restorePreviousSlot()).toBe(true);
|
|
const system = await openSystemDB();
|
|
const meta = await readSystemMeta(system);
|
|
expect(meta.activeSlot).toBe("A");
|
|
expect(meta.activePackageVersion).toBe(1);
|
|
expect(meta.verification?.manifestSha256).toBe(first.manifestSha256);
|
|
expect(meta.readbackPending).toBe(false);
|
|
system.close();
|
|
});
|
|
|
|
it("does not activate an incomplete target slot", async () => {
|
|
const pkg = await fixture();
|
|
const staged = await stageVerifiedPackage(pkg);
|
|
const slot = await openSlotDB(staged.slot);
|
|
await withTx(slot, SLOT_FILES, "readwrite", (tx) => {
|
|
tx.objectStore(SLOT_FILES).delete("schedule");
|
|
});
|
|
slot.close();
|
|
const result = await activateStagedPackage(pkg, staged, "1.0.0");
|
|
expect(result).toMatchObject({ ok: false, rolledBack: false });
|
|
const system = await openSystemDB();
|
|
expect((await readSystemMeta(system)).activeSlot).toBeNull();
|
|
system.close();
|
|
});
|
|
|
|
it("cannot persist a structurally forged package without a verifier witness", async () => {
|
|
const verified = await fixture();
|
|
const forged = { ...verified } as VerifiedPackage;
|
|
await expect(stageVerifiedPackage(forged)).rejects.toThrow(/Stage 7/);
|
|
});
|
|
|
|
it("retries activation once and rolls back corruption after the flip", async () => {
|
|
const pkg = await fixture();
|
|
const staged = await stageVerifiedPackage(pkg);
|
|
let attempts = 0;
|
|
const retried = await activateStagedPackage(pkg, staged, "1.0.0", Date.now, {
|
|
activate: async (db, next) => {
|
|
attempts++;
|
|
if (attempts === 1) throw new Error("injected activation failure");
|
|
return activateSlot(db, next);
|
|
},
|
|
});
|
|
expect(retried.ok).toBe(true);
|
|
expect(attempts).toBe(2);
|
|
|
|
const second = await fixture(2);
|
|
const secondStaged = await stageVerifiedPackage(second);
|
|
const failed = await activateStagedPackage(second, secondStaged, "1.0.0", Date.now, {
|
|
afterFlip: async (slotId) => {
|
|
const db = await openSlotDB(slotId);
|
|
await withTx(db, SLOT_FILES, "readwrite", (tx) => {
|
|
tx.objectStore(SLOT_FILES).delete("schedule");
|
|
});
|
|
db.close();
|
|
},
|
|
});
|
|
expect(failed).toMatchObject({ ok: false, rolledBack: true });
|
|
const system = await openSystemDB();
|
|
expect(await readSystemMeta(system)).toMatchObject({
|
|
activeSlot: "A",
|
|
activePackageVersion: 1,
|
|
readbackPending: false,
|
|
});
|
|
system.close();
|
|
});
|
|
});
|