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:
Lumen Stage1 2026-10-02 12:40:17 -05:00
commit 5b69b87394
5 changed files with 514 additions and 8 deletions

239
tests/unit/app-sync.test.ts Normal file
View 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");
});
});