This commit is contained in:
parent
8e71537d57
commit
fcc18ddcc8
96 changed files with 8074 additions and 214 deletions
177
tests/unit/activation.test.ts
Normal file
177
tests/unit/activation.test.ts
Normal file
|
|
@ -0,0 +1,177 @@
|
|||
/* 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();
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue