clone

a deno framework for local-first web apps that open instantly, work offline and sync when users choose

main
lofi / package / runtime / data-sink_test.ts
data-sink_test.ts 23.1K
1// Package contract tests for the runtime-declared data sink: sealed-record2// round-trip, cleartext-record migration, ticket parsing (the lofi-node3// `lofisync1.` format contract), declaration guards, the no-plaintext-bearer4// conformance check, and how the active sync location resolves into the5// session election and database configuration.6import {7 anchorAppId,8 clearDeclaredSink,9 declareDataSink,10 declareSinkFromTicket,11 ensureDeclaredSinkRestored,12 isDataSinkError,13 parseSyncTicket,14 readDeclaredSink,15 readSinkRestoreOutcome,16 restoreDeclaredSink,17 splitTicketForEnrollment,18} from "./data-sink.ts";19import { memoryDeviceKeyStore, parseSealedEnvelope } from "./envelope.ts";20import {21 activeAppId,22 activeServerUrl,23 activeSink,24 appId,25 databaseConfig,26 setSyncElected,27 syncAvailable,28 syncElected,29 syncing,30} from "./config.ts";31import { defineLofiApp } from "./app.ts";32import { assert } from "./test-assert.ts";3334defineLofiApp({35 name: "lofi-test",36 databaseName: "lofi-test",37 schema: {},38 storage: "durable",39 credentialOrigins: [],40 sync: { adapter: "jazz" },41});4243const test = (globalThis as unknown as {44 Deno: { test(name: string, body: () => void | Promise<void>): void };45}).Deno.test;4647const sinkKey = `lofi:data-sink:${anchorAppId}`;48const electionKey = `lofi:sync-elected:${appId}`;4950function withCleanState(body: () => void | Promise<void>): () => Promise<void> {51 return async () => {52 localStorage.removeItem(sinkKey);53 localStorage.removeItem(electionKey);54 clearDeclaredSink();55 try {56 await body();57 } finally {58 localStorage.removeItem(sinkKey);59 localStorage.removeItem(electionKey);60 clearDeclaredSink();61 }62 };63}6465// A ticket encoded exactly as lofi-node's encodeAppTicket does — this fixture66// doubles as the cross-repo format contract.67const ticketSecret = "s".repeat(43);68const ticketAppId = "cfe52e44-7a59-4232-8dbb-bf53f27aeed6";69function encodeTicket(payload: unknown): string {70 const json = JSON.stringify(payload);71 return "lofisync1." +72 btoa(json).replaceAll("+", "-").replaceAll("/", "_").replaceAll("=", "");73}74const validTicket = encodeTicket({75 v: 1,76 appId: ticketAppId,77 url: `http://192.168.1.10:4802/t/${ticketSecret}`,78 label: "phone",79});8081test(82 "a declared sink round-trips through the sealed record and a restore",83 withCleanState(async () => {84 assert(readDeclaredSink() === null, "a clean device must have no declared sink");85 await declareDataSink({86 appId: ticketAppId,87 serverUrl: "https://node.example:4802",88 label: "home",89 });90 const sink = readDeclaredSink();91 assert(92 sink !== null && sink.appId === ticketAppId &&93 sink.serverUrl === "https://node.example:4802" && sink.label === "home",94 `declared sink read back as ${JSON.stringify(sink)}`,95 );96 // A fresh boot re-reads storage: the sealed record must open back to the97 // same declaration.98 assert(await restoreDeclaredSink() === "restored", "the sealed record must restore");99 assert(100 readDeclaredSink()?.serverUrl === "https://node.example:4802",101 "the restored sink must match the declaration",102 );103 clearDeclaredSink();104 assert(readDeclaredSink() === null, "clearing must remove the declaration");105 assert(await restoreDeclaredSink() === "none", "clearing must remove the stored record");106 }),107);108109test(110 "boot and app islands single-flight the sealed sink restore",111 withCleanState(async () => {112 const keyStore = memoryDeviceKeyStore();113 await declareDataSink({114 appId: ticketAppId,115 serverUrl: "https://node.example:4802",116 label: "boot race",117 }, keyStore);118 const stored = localStorage.getItem(sinkKey);119 assert(stored !== null, "the test sink must persist before simulating a fresh document");120 clearDeclaredSink();121 localStorage.setItem(sinkKey, stored);122123 let release!: () => void;124 const gate = new Promise<void>((resolve) => {125 release = resolve;126 });127 let reads = 0;128 const delayedStore = {129 getOrCreate: (keyId: string) => keyStore.getOrCreate(keyId),130 async get(keyId: string) {131 reads++;132 await gate;133 return await keyStore.get(keyId);134 },135 };136 const boot = ensureDeclaredSinkRestored(delayedStore);137 const island = (await import("./data-sink.ts?island=early-runtime"))138 .ensureDeclaredSinkRestored(delayedStore);139 assert(boot === island, "boot and an eager island must await the same restore promise");140 assert(141 readDeclaredSink() === null,142 "the sink must remain unavailable while restore is pending",143 );144 release();145 assert(await boot === "restored", "the shared restore must open the sealed sink");146 assert(reads === 1, `the device key was read ${reads} times instead of once`);147 assert(148 readDeclaredSink()?.label === "boot race",149 "the restored sink must precede runtime open",150 );151 }),152);153154test(155 "duplicate app-island module instances share the document sink cache",156 withCleanState(async () => {157 const accountIsland = await import("./data-sink.ts?island=account");158 const deviceIsland = await import("./data-sink.ts?island=device");159 await accountIsland.declareDataSink({160 appId: ticketAppId,161 serverUrl: "https://node.example:4802",162 label: "shared island sink",163 });164 assert(165 deviceIsland.readDeclaredSink()?.label === "shared island sink",166 "a sibling island must observe the sink declared through another module instance",167 );168 deviceIsland.clearDeclaredSink();169 assert(170 accountIsland.readDeclaredSink() === null,171 "clearing through one island must update every module instance",172 );173 }),174);175176test(177 "an unreadable or unversioned sink record restores as absent",178 withCleanState(async () => {179 localStorage.setItem(sinkKey, "not json");180 assert(await restoreDeclaredSink() === "none", "malformed record must restore as absent");181 assert(readDeclaredSink() === null, "malformed record must read as absent");182 localStorage.setItem(sinkKey, JSON.stringify({ v: 99, sink: { appId: "x", serverUrl: "y" } }));183 assert(await restoreDeclaredSink() === "none", "unknown record version must restore as absent");184 assert(readDeclaredSink() === null, "unknown record version must read as absent");185 }),186);187188test(189 "a pre-envelope cleartext record migrates to a sealed one on restore",190 withCleanState(async () => {191 const bearerUrl = `http://192.168.1.10:4802/t/${ticketSecret}`;192 localStorage.setItem(193 sinkKey,194 JSON.stringify({ v: 1, sink: { appId: ticketAppId, serverUrl: bearerUrl, label: "phone" } }),195 );196 assert(await restoreDeclaredSink() === "migrated", "a v1 record must migrate");197 assert(readDeclaredSink()?.serverUrl === bearerUrl, "the migrated sink must carry over");198 const raw = localStorage.getItem(sinkKey) ?? "";199 assert(!raw.includes(ticketSecret), "migration must remove the bearer secret from storage");200 const record = JSON.parse(raw) as { v: number; sealed: unknown };201 assert(202 record.v === 2 && parseSealedEnvelope(record.sealed) !== null,203 "migration must persist a sealed envelope",204 );205 assert(await restoreDeclaredSink() === "restored", "the migrated record must reopen");206 assert(readDeclaredSink()?.serverUrl === bearerUrl, "the resealed sink must round-trip");207 }),208);209210test(211 "a sealed record without its device key is unopenable, not destroyed",212 withCleanState(async () => {213 await declareDataSink({ appId: ticketAppId, serverUrl: "https://node.example:4802" });214 const stored = localStorage.getItem(sinkKey);215 assert(stored !== null, "the declaration must persist");216 // A store that never held the wrapping key models cleared IndexedDB with217 // surviving localStorage.218 assert(219 await restoreDeclaredSink(memoryDeviceKeyStore()) === "unopenable",220 "a missing device key must report unopenable",221 );222 assert(readDeclaredSink() === null, "an unopenable record must read as absent");223 assert(localStorage.getItem(sinkKey) === stored, "an unopenable record must be left intact");224 assert(225 readSinkRestoreOutcome() === "unopenable",226 "status surfaces must be able to distinguish unopenable from no sink at all",227 );228 assert(await restoreDeclaredSink() === "restored", "the right key must still open it");229 assert(readSinkRestoreOutcome() === "restored", "the reader must track the latest restore");230 await restoreDeclaredSink(memoryDeviceKeyStore());231 clearDeclaredSink();232 assert(233 readSinkRestoreOutcome() === "none",234 "clearing the sink must retire the unopenable outcome — re-enrollment starts clean",235 );236 await declareDataSink({ appId: ticketAppId, serverUrl: "https://node.example:4802" });237 await restoreDeclaredSink(memoryDeviceKeyStore());238 await declareDataSink({ appId: ticketAppId, serverUrl: "https://node.example:4802" });239 assert(240 readSinkRestoreOutcome() === "restored",241 "a successful re-declaration must supersede an unopenable outcome",242 );243 }),244);245246test(247 "no bearer material reaches storage in cleartext or a trivial encoding",248 withCleanState(async () => {249 const bearerUrl = `http://192.168.1.10:4802/t/${ticketSecret}`;250 await declareSinkFromTicket(251 encodeTicket({ v: 1, appId: ticketAppId, url: bearerUrl, scope: "provision" }),252 );253 const base64Url = (text: string) =>254 btoa(text).replaceAll("+", "-").replaceAll("/", "_").replaceAll("=", "");255 const encodings = [256 ticketSecret,257 bearerUrl,258 btoa(ticketSecret),259 base64Url(ticketSecret),260 encodeURIComponent(bearerUrl),261 ];262 for (let index = 0; index < localStorage.length; index++) {263 const key = localStorage.key(index);264 const value = key === null ? null : localStorage.getItem(key);265 if (!value) continue;266 for (const encoding of encodings) {267 assert(268 !value.includes(encoding),269 `bearer material found at rest under "${key}" as ${encoding.slice(0, 16)}…`,270 );271 }272 }273 const record = JSON.parse(localStorage.getItem(sinkKey) ?? "null") as {274 v: number;275 sealed: unknown;276 };277 assert(278 record?.v === 2 && parseSealedEnvelope(record.sealed) !== null,279 "the sink at rest must be a sealed envelope",280 );281 assert(await restoreDeclaredSink() === "restored", "the sealed ticket must restore");282 assert(283 readDeclaredSink()?.serverUrl === bearerUrl && readDeclaredSink()?.scope === "provision",284 "the restored declaration must return the exact ticket URL and scope",285 );286 }),287);288289test(290 "sink declarations validate the server URL",291 withCleanState(async () => {292 for (const serverUrl of ["ws://node.example/t/x", "not a url", "ftp://node.example"]) {293 try {294 await declareDataSink({ appId: ticketAppId, serverUrl });295 throw new Error(`declaration accepted invalid server URL: ${serverUrl}`);296 } catch (error) {297 assert(298 isDataSinkError(error) && error.code === "invalid-server-url",299 `expected invalid-server-url for ${serverUrl}, received ${String(error)}`,300 );301 }302 }303 assert(readDeclaredSink() === null, "no rejected declaration may persist");304 }),305);306307test(308 "declaring over a different sink is refused; the same store updates its label",309 withCleanState(async () => {310 await declareDataSink({ appId: ticketAppId, serverUrl: "https://node.example:4802" });311 try {312 await declareDataSink({ appId: ticketAppId, serverUrl: "https://other.example:4802" });313 throw new Error("a different sink must not silently replace the declared one");314 } catch (error) {315 assert(316 isDataSinkError(error) && error.code === "sink-already-declared",317 `expected sink-already-declared, received ${String(error)}`,318 );319 }320 await declareDataSink({321 appId: ticketAppId,322 serverUrl: "https://node.example:4802",323 label: "renamed",324 });325 assert(326 readDeclaredSink()?.label === "renamed",327 "re-declaring the same store must update the label",328 );329 }),330);331332// The machine-readable format contract shared with lofi-node333// (docs/fixtures/app-ticket-fixtures.json there; vendored copy here). Either334// side changing the format updates the fixtures, and this test is what fails335// on drift.336test("the vendored lofi-node ticket fixtures parse exactly as the contract expects", async () => {337 const fixtures = JSON.parse(338 await Deno.readTextFile(339 new URL("../testdata/app-ticket-fixtures.json", import.meta.url),340 ),341 ) as {342 valid: { name: string; ticket: string; expect: Record<string, string> }[];343 invalid: { name: string; ticket: string }[];344 };345 for (const { name, ticket, expect } of fixtures.valid) {346 const parsed = parseSyncTicket(ticket);347 assert(parsed !== null, `valid fixture rejected: ${name}`);348 assert(349 parsed.appId === expect.appId && parsed.url === expect.url &&350 (parsed.scope ?? "sync") === expect.scope,351 `fixture "${name}" parsed as ${JSON.stringify(parsed)}, expected ${JSON.stringify(expect)}`,352 );353 }354 for (const { name, ticket } of fixtures.invalid) {355 assert(parseSyncTicket(ticket) === null, `invalid fixture accepted: ${name}`);356 }357});358359test("ticket parsing accepts the lofi-node format and rejects everything else", () => {360 const parsed = parseSyncTicket(` ${validTicket} `);361 assert(362 parsed !== null && parsed.appId === ticketAppId && parsed.label === "phone" &&363 parsed.url.endsWith(`/t/${ticketSecret}`),364 `valid ticket parsed as ${JSON.stringify(parsed)}`,365 );366 const provision = parseSyncTicket(367 encodeTicket({368 v: 1,369 appId: ticketAppId,370 url: `http://h/t/${ticketSecret}`,371 scope: "provision",372 }),373 );374 assert(provision?.scope === "provision", "provision scope must parse");375 const rejected = [376 "lofisync2.abc",377 "endpointAAAA",378 encodeTicket({ v: 2, appId: ticketAppId, url: `http://h/t/${ticketSecret}` }),379 encodeTicket({ v: 1, appId: ticketAppId, url: `ws://h/t/${ticketSecret}` }),380 encodeTicket({ v: 1, appId: ticketAppId, url: "http://h/t/short" }),381 encodeTicket({ v: 1, appId: ticketAppId, url: "http://h/apps/x" }),382 encodeTicket({ v: 1, url: `http://h/t/${ticketSecret}` }),383 encodeTicket({ v: 1, appId: ticketAppId, url: `http://h/t/${ticketSecret}`, scope: "root" }),384 "lofisync1.%%%not-base64%%%",385 ];386 for (const ticket of rejected) {387 assert(parseSyncTicket(ticket) === null, `malformed ticket accepted: ${ticket.slice(0, 40)}`);388 }389});390391test(392 "enrolling a ticket declares its URL verbatim as the sink",393 withCleanState(async () => {394 const declared = await declareSinkFromTicket(validTicket);395 assert(396 declared.serverUrl === `http://192.168.1.10:4802/t/${ticketSecret}`,397 "the ticket URL must be used verbatim — the secret path is the credential",398 );399 const sink = readDeclaredSink();400 assert(sink?.appId === ticketAppId && sink.label === "phone", "ticket fields must carry over");401 assert(sink.scope === undefined, "a scopeless ticket declares no explicit scope");402 try {403 await declareSinkFromTicket("lofisync1.nope");404 throw new Error("a malformed ticket must not enroll");405 } catch (error) {406 assert(407 isDataSinkError(error) && error.code === "invalid-ticket",408 `expected invalid-ticket, received ${String(error)}`,409 );410 }411 }),412);413414test(415 "the active sink resolves declared over default and gates the election",416 withCleanState(async () => {417 // This test build compiles no managed app, so the default sink is absent.418 assert(activeSink() === null && !syncAvailable(), "local build must start with no sink");419 assert(activeAppId() === appId, "without a sink the active app id is the anchor");420 setSyncElected(true);421 assert(!syncElected(), "election must be a no-op without a sync location");422423 await declareDataSink({ appId: ticketAppId, serverUrl: "https://node.example:4802" });424 const sink = activeSink();425 assert(426 sink?.source === "declared" && sink.appId === ticketAppId && syncAvailable(),427 `declared sink resolved as ${JSON.stringify(sink)}`,428 );429 assert(activeAppId() === ticketAppId, "the active app id must be the sink's");430 assert(activeServerUrl() === "https://node.example:4802", "the active URL must be the sink's");431432 setSyncElected(true);433 assert(syncElected() && syncing(), "election must stand once a sink is declared");434 setSyncElected(false);435 assert(!syncing(), "clearing the election must stop replication");436 }),437);438439test(440 "databaseConfig keeps the local tier on the anchor and gives managed the sink",441 withCleanState(async () => {442 await declareDataSink({ appId: ticketAppId, serverUrl: "https://node.example:4802" });443 setSyncElected(true);444445 const local = databaseConfig("secret", "ns", "local", false);446 assert(local.appId === appId, "the local tier must stay on the anchor app id");447 assert(!("serverUrl" in local), "the local tier must not carry a server URL");448 const localDriver = local.driver as { dbName: string };449 assert(450 localDriver.dbName === `lofi-test-${appId}-ns-local`,451 `local dbName was ${localDriver.dbName} — first-boot data must remain findable`,452 );453454 const managed = databaseConfig("secret", "ns");455 assert(managed.appId === ticketAppId, "the managed tier must use the sink's app id");456 assert(457 (managed as { serverUrl?: string }).serverUrl === "https://node.example:4802",458 "a connecting managed config must carry the sink URL",459 );460 const managedDriver = managed.driver as { dbName: string };461 assert(462 managedDriver.dbName === `lofi-test-${ticketAppId}-ns-managed`,463 `managed dbName was ${managedDriver.dbName} — each store gets its own namespace`,464 );465466 const tokenUrl = "https://node.example:4802/c/token";467 const overridden = databaseConfig("secret", "ns", "managed", true, () => {}, tokenUrl);468 assert(469 (overridden as { serverUrl?: string }).serverUrl === tokenUrl,470 "a possession-bound boot must connect through the exchange's token URL",471 );472 }),473);474475// The scope-down exchange: a provision ticket splits into a derived sync476// ticket (declared) and the provision URL (held in memory by session.ts).477const provisionTicket = encodeTicket({478 v: 1,479 appId: ticketAppId,480 url: `http://192.168.1.10:4802/t/${ticketSecret}`,481 scope: "provision",482 label: "phone",483});484const derivedSecret = "d".repeat(43);485const derivedTicket = encodeTicket({486 v: 1,487 appId: ticketAppId,488 url: `http://192.168.1.10:4802/t/${derivedSecret}`,489 scope: "sync",490 label: "phone (sync)",491});492493function fetcherReturning(status: number, body: unknown): typeof fetch {494 return () => Promise.resolve(new Response(JSON.stringify(body), { status }));495}496497test("a provision ticket splits into the derived sync ticket and the held URL", async () => {498 const requests: string[] = [];499 const fetcher: typeof fetch = (input, init) => {500 requests.push(`${init?.method} ${String(input)}`);501 return Promise.resolve(502 new Response(JSON.stringify({ v: 1, id: "abc", ticket: derivedTicket }), { status: 200 }),503 );504 };505 const split = await splitTicketForEnrollment(provisionTicket, fetcher);506 assert(split.sinkTicket === derivedTicket, "the derived sync ticket must become the sink");507 assert(508 split.provisionUrl === `http://192.168.1.10:4802/t/${ticketSecret}`,509 "the provision URL must be returned for in-memory custody",510 );511 assert(512 requests.length === 1 &&513 requests[0] === `POST http://192.168.1.10:4802/t/${ticketSecret}/derive-sync-ticket`,514 `the exchange must POST the derive endpoint once, sent: ${requests.join("; ")}`,515 );516});517518test("exchange failures fall back to enrolling the ticket as pasted", async () => {519 const fallbacks: Array<typeof fetch> = [520 fetcherReturning(401, { error: "invalid_ticket" }),521 fetcherReturning(404, "not found"),522 fetcherReturning(200, { ticket: 7 }),523 fetcherReturning(200, { v: 1, id: "abc", ticket: "lofisync1.nope" }),524 fetcherReturning(200, {525 v: 1,526 id: "abc",527 ticket: encodeTicket({528 v: 1,529 appId: "11111111-2222-3333-4444-555555555555",530 url: `http://h/t/${derivedSecret}`,531 scope: "sync",532 }),533 }),534 fetcherReturning(200, {535 v: 1,536 id: "abc",537 ticket: encodeTicket({538 v: 1,539 appId: ticketAppId,540 url: `http://h/t/${derivedSecret}`,541 scope: "provision",542 }),543 }),544 () => Promise.reject(new Error("network down")),545 ];546 for (const fetcher of fallbacks) {547 const split = await splitTicketForEnrollment(provisionTicket, fetcher);548 assert(549 split.sinkTicket === provisionTicket && split.provisionUrl === null,550 "an unusable exchange must fall back to the pasted ticket",551 );552 }553});554555test("sync-scoped and malformed tickets never touch the exchange", async () => {556 let calls = 0;557 const fetcher: typeof fetch = () => {558 calls++;559 return Promise.resolve(new Response("{}", { status: 200 }));560 };561 for (const ticket of [validTicket, "lofisync1.nope", "not a ticket"]) {562 const split = await splitTicketForEnrollment(ticket, fetcher);563 assert(564 split.sinkTicket === ticket && split.provisionUrl === null,565 "a non-provision paste must pass through unchanged",566 );567 }568 assert(calls === 0, "the exchange endpoint must not be called for non-provision tickets");569});570571test("offering a device key binds the derived ticket; old nodes fall back to bearer", async () => {572 const bodies: string[] = [];573 const bindingFetcher: typeof fetch = (_input, init) => {574 bodies.push(String(init?.body));575 return Promise.resolve(576 new Response(577 JSON.stringify({ v: 1, id: "bound-id", ticket: derivedTicket, pop: true }),578 { status: 200 },579 ),580 );581 };582 const deviceKey = { alg: "ES256" as const, spki: "test-spki" };583 const bound = await splitTicketForEnrollment(provisionTicket, bindingFetcher, deviceKey);584 assert(585 bound.pop !== null && bound.pop.ticketId === "bound-id",586 "a pop:true response must record the binding with its ticket id",587 );588 assert(589 bodies.length === 1 && JSON.parse(bodies[0]).devicePublicKey.spki === "test-spki",590 "the derive request must carry the offered device key",591 );592593 // A node predating the field answers without `pop`: bearer fallback.594 const legacyFetcher: typeof fetch = () =>595 Promise.resolve(596 new Response(JSON.stringify({ v: 1, id: "abc", ticket: derivedTicket }), { status: 200 }),597 );598 const legacy = await splitTicketForEnrollment(provisionTicket, legacyFetcher, deviceKey);599 assert(600 legacy.pop === null && legacy.sinkTicket === derivedTicket,601 "a keyless response must enroll the derived ticket as pure bearer",602 );603});604605test(606 "a possession binding declared with the ticket survives the sealed round-trip",607 withCleanState(async () => {608 await declareSinkFromTicket(derivedTicket, undefined, { ticketId: "bound-id" });609 assert(610 readDeclaredSink()?.pop?.ticketId === "bound-id",611 "the declaration must carry the binding",612 );613 assert(await restoreDeclaredSink() === "restored", "the sealed record must restore");614 assert(615 readDeclaredSink()?.pop?.ticketId === "bound-id",616 "the restored declaration must keep the binding",617 );618 assert(619 activeSink()?.pop?.ticketId === "bound-id",620 "the active sink must expose the binding so boot runs the exchange",621 );622 }),623);