251 lines
8.7 KiB
TypeScript
251 lines
8.7 KiB
TypeScript
/* 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");
|
|
});
|
|
|
|
it("returns an error outcome (never throws/hangs) for a plain-HTTP origin", async () => {
|
|
const origin = buildOrigin();
|
|
const httpConfig = { ...config(origin), origin: "http://10.144.0.221:8080/origin" };
|
|
const result = await runSync({
|
|
fetchConfig: async () => httpConfig,
|
|
fetchImpl: fakeFetch(origin),
|
|
appVersion: "1.0.0",
|
|
});
|
|
expect(result.status).toBe("error");
|
|
expect(result.status === "error" && result.detail).toContain("HTTPS");
|
|
});
|
|
});
|