Showcase: user-initiated sync coordinator wired into Status screen
runSync() composes transport -> verifier -> quarantine no-loop -> staging -> activation as one user action (the phone tap). Trust comes from same-origin /sync-config.json (edition + origin + pinned fingerprint->SPKI map). Prep guidance view gains a real Download button with phase messages and outcome notes (ok/no-update/offline/rejected/not-configured). Pipeline now writes the per-edition pointer /editions/<ed>/latest.json the HttpTransport consumes, and takes --min-app-version so staging packages pass shell compatibility. 6 new end-to-end tests (fake fetch + fake-indexedDB): activate, no-update, untrusted-key rejection keeps v1 + quarantines v2, no-loop fetches ONLY latest.json on a quarantined pointer. Full CI green: 291 tests.
This commit is contained in:
parent
853c19fbac
commit
5b69b87394
5 changed files with 514 additions and 8 deletions
239
tests/unit/app-sync.test.ts
Normal file
239
tests/unit/app-sync.test.ts
Normal file
|
|
@ -0,0 +1,239 @@
|
|||
/* eslint-disable @typescript-eslint/require-await, @typescript-eslint/no-unsafe-call */
|
||||
/**
|
||||
* App-layer sync coordinator — end-to-end with fake fetch: pointer →
|
||||
* verifier → quarantine → staging → activation, exactly the phone path.
|
||||
*/
|
||||
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 { runSync, type SyncConfig } from "../../src/app/sync.js";
|
||||
import { openSystemDB, readSystemMeta } from "../../src/data/system-meta/store.js";
|
||||
import { openUserDB } from "../../src/data/user/store.js";
|
||||
import { isQuarantined, listQuarantined } from "../../src/data/user/quarantine.js";
|
||||
import { DB } from "../../src/platform/idb/names.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();
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
const EDITION = "lumen-2026";
|
||||
|
||||
interface Origin {
|
||||
readonly files: Map<string, Uint8Array>;
|
||||
readonly fingerprint: string;
|
||||
readonly derB64: string;
|
||||
}
|
||||
|
||||
function buildOrigin(
|
||||
version = 1,
|
||||
signKeyOverride?: ReturnType<typeof generateTestKeyPair>,
|
||||
): Origin {
|
||||
const keyPair = signKeyOverride ?? generateTestKeyPair();
|
||||
const built = buildPackage(makeValidInput({ packageVersion: version }), { signWith: keyPair });
|
||||
if (!built.ok || !built.pkg.signature) throw new Error("fixture build failed");
|
||||
const pkgDir = `/editions/${EDITION}/packages/${String(version)}`;
|
||||
const files = new Map<string, Uint8Array>();
|
||||
for (const [, f] of built.pkg.files) files.set(`${pkgDir}/${f.file}`, f.canonicalBytes);
|
||||
for (const a of built.pkg.assets) files.set(`${pkgDir}/${a.file}`, a.bytesContent);
|
||||
const manifestBytes = new TextEncoder().encode(canonicalJson(built.pkg.manifest));
|
||||
files.set(`${pkgDir}/manifest.json`, manifestBytes);
|
||||
files.set(
|
||||
`${pkgDir}/signature.json`,
|
||||
new TextEncoder().encode(JSON.stringify(built.pkg.signature)),
|
||||
);
|
||||
files.set(
|
||||
`/latest.json`,
|
||||
new TextEncoder().encode(
|
||||
JSON.stringify({
|
||||
edition: EDITION,
|
||||
packageVersion: version,
|
||||
manifestUrl: `${pkgDir}/manifest.json`,
|
||||
generatedAt: new Date(0).toISOString(),
|
||||
}),
|
||||
),
|
||||
);
|
||||
files.set(
|
||||
`/editions/${EDITION}/latest.json`,
|
||||
new TextEncoder().encode(
|
||||
JSON.stringify({
|
||||
edition: EDITION,
|
||||
packageVersion: version,
|
||||
manifestUrl: `${pkgDir}/manifest.json`,
|
||||
generatedAt: new Date(0).toISOString(),
|
||||
}),
|
||||
),
|
||||
);
|
||||
return { files, fingerprint: keyPair.fingerprint, derB64: keyPair.publicKeyDerBase64 };
|
||||
}
|
||||
|
||||
function fakeFetch(origin: Origin, counters: { fetches: string[] } = { fetches: [] }) {
|
||||
const handler = async (input: string | URL): Promise<Response> => {
|
||||
const href = typeof input === "string" ? input : input.href;
|
||||
const url = new URL(href);
|
||||
const path = decodeURIComponent(url.pathname);
|
||||
const bytes = origin.files.get(path);
|
||||
if (bytes === undefined) return new Response("not found", { status: 404 });
|
||||
counters.fetches.push(path);
|
||||
const copy = bytes.slice();
|
||||
return new Response(copy, { status: 200 });
|
||||
};
|
||||
return handler as unknown as typeof fetch;
|
||||
}
|
||||
|
||||
function config(origin: Origin): SyncConfig {
|
||||
return {
|
||||
edition: EDITION,
|
||||
origin: "https://origin.test",
|
||||
trustedKeys: { [origin.fingerprint]: origin.derB64 },
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
g.indexedDB = new FDBFactory() as unknown;
|
||||
// Node's navigator has no onLine — the transport treats undefined as offline.
|
||||
Object.defineProperty(globalThis, "navigator", {
|
||||
value: { onLine: true },
|
||||
configurable: true,
|
||||
writable: true,
|
||||
});
|
||||
await Promise.all(Object.values(DB).map((name) => deleteDb(name)));
|
||||
});
|
||||
|
||||
describe("app sync coordinator", () => {
|
||||
it("downloads, verifies, activates, and reports OK with the new version", async () => {
|
||||
const origin = buildOrigin();
|
||||
const result = await runSync({
|
||||
fetchConfig: async () => config(origin),
|
||||
fetchImpl: fakeFetch(origin),
|
||||
appVersion: "1.0.0",
|
||||
});
|
||||
expect(result).toEqual({ status: "ok", version: 1 });
|
||||
const system = await openSystemDB();
|
||||
const meta = await readSystemMeta(system);
|
||||
system.close();
|
||||
expect(meta.activeSlot).not.toBeNull();
|
||||
expect(meta.activePackageVersion).toBe(1);
|
||||
});
|
||||
|
||||
it("second run reports no-update without restaging", async () => {
|
||||
const origin = buildOrigin();
|
||||
expect(
|
||||
(
|
||||
await runSync({
|
||||
fetchConfig: async () => config(origin),
|
||||
fetchImpl: fakeFetch(origin),
|
||||
appVersion: "1.0.0",
|
||||
})
|
||||
).status,
|
||||
).toBe("ok");
|
||||
const again = await runSync({
|
||||
fetchConfig: async () => config(origin),
|
||||
fetchImpl: fakeFetch(origin),
|
||||
appVersion: "1.0.0",
|
||||
});
|
||||
expect(again).toEqual({ status: "no-update" });
|
||||
});
|
||||
|
||||
it("rejects a package signed by an untrusted key, quarantines it, and keeps active state", async () => {
|
||||
const good = buildOrigin(1);
|
||||
expect(
|
||||
(
|
||||
await runSync({
|
||||
fetchConfig: async () => config(good),
|
||||
fetchImpl: fakeFetch(good),
|
||||
appVersion: "1.0.0",
|
||||
})
|
||||
).status,
|
||||
).toBe("ok");
|
||||
|
||||
const attacker = generateTestKeyPair();
|
||||
const tampered = buildOrigin(2, attacker);
|
||||
const result = await runSync({
|
||||
fetchConfig: async () => config(good),
|
||||
fetchImpl: fakeFetch(tampered),
|
||||
appVersion: "1.0.0",
|
||||
});
|
||||
expect(result.status).toBe("rejected");
|
||||
|
||||
const system = await openSystemDB();
|
||||
const meta = await readSystemMeta(system);
|
||||
system.close();
|
||||
expect(meta.activePackageVersion).toBe(1);
|
||||
|
||||
const user = await openUserDB();
|
||||
expect(await isQuarantined(user, EDITION, 2)).toBe(true);
|
||||
expect(await listQuarantined(user, EDITION)).toHaveLength(1);
|
||||
user.close();
|
||||
});
|
||||
|
||||
it("never re-fetches files for a quarantined version (no-loop)", async () => {
|
||||
const attacker = generateTestKeyPair();
|
||||
const trusted = generateTestKeyPair();
|
||||
const origin = buildOrigin(3, attacker);
|
||||
const conf = {
|
||||
edition: EDITION,
|
||||
origin: "https://origin.test",
|
||||
trustedKeys: { [trusted.fingerprint]: trusted.publicKeyDerBase64 },
|
||||
} satisfies SyncConfig;
|
||||
const counters = { fetches: [] as string[] };
|
||||
const first = await runSync({
|
||||
fetchConfig: async () => conf,
|
||||
fetchImpl: fakeFetch(origin, counters),
|
||||
appVersion: "1.0.0",
|
||||
});
|
||||
expect(first.status).toBe("rejected");
|
||||
const afterFirst = counters.fetches.length;
|
||||
|
||||
const counters2 = { fetches: [] as string[] };
|
||||
const second = await runSync({
|
||||
fetchConfig: async () => conf,
|
||||
fetchImpl: fakeFetch(origin, counters2),
|
||||
appVersion: "1.0.0",
|
||||
});
|
||||
expect(second.status).toBe("rejected");
|
||||
expect(second.status === "rejected" && second.detail).toContain("quarantined");
|
||||
// only latest.json — manifest and files are never fetched again
|
||||
expect(counters2.fetches).toEqual([`/editions/${EDITION}/latest.json`]);
|
||||
expect(afterFirst).toBeGreaterThan(1);
|
||||
});
|
||||
|
||||
it("reports offline when transport is unavailable", async () => {
|
||||
const origin = buildOrigin();
|
||||
const offlineFetch = (async () => {
|
||||
throw new TypeError("fetch failed");
|
||||
}) as unknown as typeof fetch;
|
||||
const result = await runSync({
|
||||
fetchConfig: async () => config(origin),
|
||||
fetchImpl: offlineFetch,
|
||||
appVersion: "1.0.0",
|
||||
});
|
||||
// navigator.onLine is true under vitest; a failed fetch is an error path.
|
||||
expect(["offline", "error"]).toContain(result.status);
|
||||
});
|
||||
|
||||
it("reports not-configured when sync-config is absent", async () => {
|
||||
const result = await runSync({
|
||||
fetchConfig: async () => null,
|
||||
fetchImpl: fakeFetch(buildOrigin()),
|
||||
appVersion: "1.0.0",
|
||||
});
|
||||
expect(result.status).toBe("not-configured");
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue