clone

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

feat/141-effect-library
lofi / package / runtime / data-sink_test.ts
data-sink_test.ts 20.8K
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 isDataSinkError,12 parseSyncTicket,13 readDeclaredSink,14 readSinkRestoreOutcome,15 restoreDeclaredSink,16 splitTicketForEnrollment,17} from "./data-sink.ts";18import { memoryDeviceKeyStore, parseSealedEnvelope } from "./envelope.ts";19import {20 activeAppId,21 activeServerUrl,22 activeSink,23 appId,24 databaseConfig,25 setSyncElected,26 syncAvailable,27 syncElected,28 syncing,29} from "./config.ts";30import { defineLofiApp } from "./app.ts";31import { assert } from "./test-assert.ts";3233defineLofiApp({34 name: "lofi-test",35 databaseName: "lofi-test",36 schema: {},37 storage: "durable",38 credentialOrigins: [],39 sync: { adapter: "jazz" },40});4142const test = (globalThis as unknown as {43 Deno: { test(name: string, body: () => void | Promise<void>): void };44}).Deno.test;4546const sinkKey = `lofi:data-sink:${anchorAppId}`;47const electionKey = `lofi:sync-elected:${appId}`;4849function withCleanState(body: () => void | Promise<void>): () => Promise<void> {50 return async () => {51 localStorage.removeItem(sinkKey);52 localStorage.removeItem(electionKey);53 clearDeclaredSink();54 try {55 await body();56 } finally {57 localStorage.removeItem(sinkKey);58 localStorage.removeItem(electionKey);59 clearDeclaredSink();60 }61 };62}6364// A ticket encoded exactly as lofi-node's encodeAppTicket does — this fixture65// doubles as the cross-repo format contract.66const ticketSecret = "s".repeat(43);67const ticketAppId = "cfe52e44-7a59-4232-8dbb-bf53f27aeed6";68function encodeTicket(payload: unknown): string {69 const json = JSON.stringify(payload);70 return "lofisync1." +71 btoa(json).replaceAll("+", "-").replaceAll("/", "_").replaceAll("=", "");72}73const validTicket = encodeTicket({74 v: 1,75 appId: ticketAppId,76 url: `http://192.168.1.10:4802/t/${ticketSecret}`,77 label: "phone",78});7980test(81 "a declared sink round-trips through the sealed record and a restore",82 withCleanState(async () => {83 assert(readDeclaredSink() === null, "a clean device must have no declared sink");84 await declareDataSink({85 appId: ticketAppId,86 serverUrl: "https://node.example:4802",87 label: "home",88 });89 const sink = readDeclaredSink();90 assert(91 sink !== null && sink.appId === ticketAppId &&92 sink.serverUrl === "https://node.example:4802" && sink.label === "home",93 `declared sink read back as ${JSON.stringify(sink)}`,94 );95 // A fresh boot re-reads storage: the sealed record must open back to the96 // same declaration.97 assert(await restoreDeclaredSink() === "restored", "the sealed record must restore");98 assert(99 readDeclaredSink()?.serverUrl === "https://node.example:4802",100 "the restored sink must match the declaration",101 );102 clearDeclaredSink();103 assert(readDeclaredSink() === null, "clearing must remove the declaration");104 assert(await restoreDeclaredSink() === "none", "clearing must remove the stored record");105 }),106);107108test(109 "an unreadable or unversioned sink record restores as absent",110 withCleanState(async () => {111 localStorage.setItem(sinkKey, "not json");112 assert(await restoreDeclaredSink() === "none", "malformed record must restore as absent");113 assert(readDeclaredSink() === null, "malformed record must read as absent");114 localStorage.setItem(sinkKey, JSON.stringify({ v: 99, sink: { appId: "x", serverUrl: "y" } }));115 assert(await restoreDeclaredSink() === "none", "unknown record version must restore as absent");116 assert(readDeclaredSink() === null, "unknown record version must read as absent");117 }),118);119120test(121 "a pre-envelope cleartext record migrates to a sealed one on restore",122 withCleanState(async () => {123 const bearerUrl = `http://192.168.1.10:4802/t/${ticketSecret}`;124 localStorage.setItem(125 sinkKey,126 JSON.stringify({ v: 1, sink: { appId: ticketAppId, serverUrl: bearerUrl, label: "phone" } }),127 );128 assert(await restoreDeclaredSink() === "migrated", "a v1 record must migrate");129 assert(readDeclaredSink()?.serverUrl === bearerUrl, "the migrated sink must carry over");130 const raw = localStorage.getItem(sinkKey) ?? "";131 assert(!raw.includes(ticketSecret), "migration must remove the bearer secret from storage");132 const record = JSON.parse(raw) as { v: number; sealed: unknown };133 assert(134 record.v === 2 && parseSealedEnvelope(record.sealed) !== null,135 "migration must persist a sealed envelope",136 );137 assert(await restoreDeclaredSink() === "restored", "the migrated record must reopen");138 assert(readDeclaredSink()?.serverUrl === bearerUrl, "the resealed sink must round-trip");139 }),140);141142test(143 "a sealed record without its device key is unopenable, not destroyed",144 withCleanState(async () => {145 await declareDataSink({ appId: ticketAppId, serverUrl: "https://node.example:4802" });146 const stored = localStorage.getItem(sinkKey);147 assert(stored !== null, "the declaration must persist");148 // A store that never held the wrapping key models cleared IndexedDB with149 // surviving localStorage.150 assert(151 await restoreDeclaredSink(memoryDeviceKeyStore()) === "unopenable",152 "a missing device key must report unopenable",153 );154 assert(readDeclaredSink() === null, "an unopenable record must read as absent");155 assert(localStorage.getItem(sinkKey) === stored, "an unopenable record must be left intact");156 assert(157 readSinkRestoreOutcome() === "unopenable",158 "status surfaces must be able to distinguish unopenable from no sink at all",159 );160 assert(await restoreDeclaredSink() === "restored", "the right key must still open it");161 assert(readSinkRestoreOutcome() === "restored", "the reader must track the latest restore");162 await restoreDeclaredSink(memoryDeviceKeyStore());163 clearDeclaredSink();164 assert(165 readSinkRestoreOutcome() === "none",166 "clearing the sink must retire the unopenable outcome — re-enrollment starts clean",167 );168 await declareDataSink({ appId: ticketAppId, serverUrl: "https://node.example:4802" });169 await restoreDeclaredSink(memoryDeviceKeyStore());170 await declareDataSink({ appId: ticketAppId, serverUrl: "https://node.example:4802" });171 assert(172 readSinkRestoreOutcome() === "restored",173 "a successful re-declaration must supersede an unopenable outcome",174 );175 }),176);177178test(179 "no bearer material reaches storage in cleartext or a trivial encoding",180 withCleanState(async () => {181 const bearerUrl = `http://192.168.1.10:4802/t/${ticketSecret}`;182 await declareSinkFromTicket(183 encodeTicket({ v: 1, appId: ticketAppId, url: bearerUrl, scope: "provision" }),184 );185 const base64Url = (text: string) =>186 btoa(text).replaceAll("+", "-").replaceAll("/", "_").replaceAll("=", "");187 const encodings = [188 ticketSecret,189 bearerUrl,190 btoa(ticketSecret),191 base64Url(ticketSecret),192 encodeURIComponent(bearerUrl),193 ];194 for (let index = 0; index < localStorage.length; index++) {195 const key = localStorage.key(index);196 const value = key === null ? null : localStorage.getItem(key);197 if (!value) continue;198 for (const encoding of encodings) {199 assert(200 !value.includes(encoding),201 `bearer material found at rest under "${key}" as ${encoding.slice(0, 16)}…`,202 );203 }204 }205 const record = JSON.parse(localStorage.getItem(sinkKey) ?? "null") as {206 v: number;207 sealed: unknown;208 };209 assert(210 record?.v === 2 && parseSealedEnvelope(record.sealed) !== null,211 "the sink at rest must be a sealed envelope",212 );213 assert(await restoreDeclaredSink() === "restored", "the sealed ticket must restore");214 assert(215 readDeclaredSink()?.serverUrl === bearerUrl && readDeclaredSink()?.scope === "provision",216 "the restored declaration must return the exact ticket URL and scope",217 );218 }),219);220221test(222 "sink declarations validate the server URL",223 withCleanState(async () => {224 for (const serverUrl of ["ws://node.example/t/x", "not a url", "ftp://node.example"]) {225 try {226 await declareDataSink({ appId: ticketAppId, serverUrl });227 throw new Error(`declaration accepted invalid server URL: ${serverUrl}`);228 } catch (error) {229 assert(230 isDataSinkError(error) && error.code === "invalid-server-url",231 `expected invalid-server-url for ${serverUrl}, received ${String(error)}`,232 );233 }234 }235 assert(readDeclaredSink() === null, "no rejected declaration may persist");236 }),237);238239test(240 "declaring over a different sink is refused; the same store updates its label",241 withCleanState(async () => {242 await declareDataSink({ appId: ticketAppId, serverUrl: "https://node.example:4802" });243 try {244 await declareDataSink({ appId: ticketAppId, serverUrl: "https://other.example:4802" });245 throw new Error("a different sink must not silently replace the declared one");246 } catch (error) {247 assert(248 isDataSinkError(error) && error.code === "sink-already-declared",249 `expected sink-already-declared, received ${String(error)}`,250 );251 }252 await declareDataSink({253 appId: ticketAppId,254 serverUrl: "https://node.example:4802",255 label: "renamed",256 });257 assert(258 readDeclaredSink()?.label === "renamed",259 "re-declaring the same store must update the label",260 );261 }),262);263264// The machine-readable format contract shared with lofi-node265// (docs/fixtures/app-ticket-fixtures.json there; vendored copy here). Either266// side changing the format updates the fixtures, and this test is what fails267// on drift.268test("the vendored lofi-node ticket fixtures parse exactly as the contract expects", async () => {269 const fixtures = JSON.parse(270 await Deno.readTextFile(271 new URL("../testdata/app-ticket-fixtures.json", import.meta.url),272 ),273 ) as {274 valid: { name: string; ticket: string; expect: Record<string, string> }[];275 invalid: { name: string; ticket: string }[];276 };277 for (const { name, ticket, expect } of fixtures.valid) {278 const parsed = parseSyncTicket(ticket);279 assert(parsed !== null, `valid fixture rejected: ${name}`);280 assert(281 parsed.appId === expect.appId && parsed.url === expect.url &&282 (parsed.scope ?? "sync") === expect.scope,283 `fixture "${name}" parsed as ${JSON.stringify(parsed)}, expected ${JSON.stringify(expect)}`,284 );285 }286 for (const { name, ticket } of fixtures.invalid) {287 assert(parseSyncTicket(ticket) === null, `invalid fixture accepted: ${name}`);288 }289});290291test("ticket parsing accepts the lofi-node format and rejects everything else", () => {292 const parsed = parseSyncTicket(` ${validTicket} `);293 assert(294 parsed !== null && parsed.appId === ticketAppId && parsed.label === "phone" &&295 parsed.url.endsWith(`/t/${ticketSecret}`),296 `valid ticket parsed as ${JSON.stringify(parsed)}`,297 );298 const provision = parseSyncTicket(299 encodeTicket({300 v: 1,301 appId: ticketAppId,302 url: `http://h/t/${ticketSecret}`,303 scope: "provision",304 }),305 );306 assert(provision?.scope === "provision", "provision scope must parse");307 const rejected = [308 "lofisync2.abc",309 "endpointAAAA",310 encodeTicket({ v: 2, appId: ticketAppId, url: `http://h/t/${ticketSecret}` }),311 encodeTicket({ v: 1, appId: ticketAppId, url: `ws://h/t/${ticketSecret}` }),312 encodeTicket({ v: 1, appId: ticketAppId, url: "http://h/t/short" }),313 encodeTicket({ v: 1, appId: ticketAppId, url: "http://h/apps/x" }),314 encodeTicket({ v: 1, url: `http://h/t/${ticketSecret}` }),315 encodeTicket({ v: 1, appId: ticketAppId, url: `http://h/t/${ticketSecret}`, scope: "root" }),316 "lofisync1.%%%not-base64%%%",317 ];318 for (const ticket of rejected) {319 assert(parseSyncTicket(ticket) === null, `malformed ticket accepted: ${ticket.slice(0, 40)}`);320 }321});322323test(324 "enrolling a ticket declares its URL verbatim as the sink",325 withCleanState(async () => {326 const declared = await declareSinkFromTicket(validTicket);327 assert(328 declared.serverUrl === `http://192.168.1.10:4802/t/${ticketSecret}`,329 "the ticket URL must be used verbatim — the secret path is the credential",330 );331 const sink = readDeclaredSink();332 assert(sink?.appId === ticketAppId && sink.label === "phone", "ticket fields must carry over");333 assert(sink.scope === undefined, "a scopeless ticket declares no explicit scope");334 try {335 await declareSinkFromTicket("lofisync1.nope");336 throw new Error("a malformed ticket must not enroll");337 } catch (error) {338 assert(339 isDataSinkError(error) && error.code === "invalid-ticket",340 `expected invalid-ticket, received ${String(error)}`,341 );342 }343 }),344);345346test(347 "the active sink resolves declared over default and gates the election",348 withCleanState(async () => {349 // This test build compiles no managed app, so the default sink is absent.350 assert(activeSink() === null && !syncAvailable(), "local build must start with no sink");351 assert(activeAppId() === appId, "without a sink the active app id is the anchor");352 setSyncElected(true);353 assert(!syncElected(), "election must be a no-op without a sync location");354355 await declareDataSink({ appId: ticketAppId, serverUrl: "https://node.example:4802" });356 const sink = activeSink();357 assert(358 sink?.source === "declared" && sink.appId === ticketAppId && syncAvailable(),359 `declared sink resolved as ${JSON.stringify(sink)}`,360 );361 assert(activeAppId() === ticketAppId, "the active app id must be the sink's");362 assert(activeServerUrl() === "https://node.example:4802", "the active URL must be the sink's");363364 setSyncElected(true);365 assert(syncElected() && syncing(), "election must stand once a sink is declared");366 setSyncElected(false);367 assert(!syncing(), "clearing the election must stop replication");368 }),369);370371test(372 "databaseConfig keeps the local tier on the anchor and gives managed the sink",373 withCleanState(async () => {374 await declareDataSink({ appId: ticketAppId, serverUrl: "https://node.example:4802" });375 setSyncElected(true);376377 const local = databaseConfig("secret", "ns", "local", false);378 assert(local.appId === appId, "the local tier must stay on the anchor app id");379 assert(!("serverUrl" in local), "the local tier must not carry a server URL");380 const localDriver = local.driver as { dbName: string };381 assert(382 localDriver.dbName === `lofi-test-${appId}-ns-local`,383 `local dbName was ${localDriver.dbName} — first-boot data must remain findable`,384 );385386 const managed = databaseConfig("secret", "ns");387 assert(managed.appId === ticketAppId, "the managed tier must use the sink's app id");388 assert(389 (managed as { serverUrl?: string }).serverUrl === "https://node.example:4802",390 "a connecting managed config must carry the sink URL",391 );392 const managedDriver = managed.driver as { dbName: string };393 assert(394 managedDriver.dbName === `lofi-test-${ticketAppId}-ns-managed`,395 `managed dbName was ${managedDriver.dbName} — each store gets its own namespace`,396 );397398 const tokenUrl = "https://node.example:4802/c/token";399 const overridden = databaseConfig("secret", "ns", "managed", true, () => {}, tokenUrl);400 assert(401 (overridden as { serverUrl?: string }).serverUrl === tokenUrl,402 "a possession-bound boot must connect through the exchange's token URL",403 );404 }),405);406407// The scope-down exchange: a provision ticket splits into a derived sync408// ticket (declared) and the provision URL (held in memory by session.ts).409const provisionTicket = encodeTicket({410 v: 1,411 appId: ticketAppId,412 url: `http://192.168.1.10:4802/t/${ticketSecret}`,413 scope: "provision",414 label: "phone",415});416const derivedSecret = "d".repeat(43);417const derivedTicket = encodeTicket({418 v: 1,419 appId: ticketAppId,420 url: `http://192.168.1.10:4802/t/${derivedSecret}`,421 scope: "sync",422 label: "phone (sync)",423});424425function fetcherReturning(status: number, body: unknown): typeof fetch {426 return () => Promise.resolve(new Response(JSON.stringify(body), { status }));427}428429test("a provision ticket splits into the derived sync ticket and the held URL", async () => {430 const requests: string[] = [];431 const fetcher: typeof fetch = (input, init) => {432 requests.push(`${init?.method} ${String(input)}`);433 return Promise.resolve(434 new Response(JSON.stringify({ v: 1, id: "abc", ticket: derivedTicket }), { status: 200 }),435 );436 };437 const split = await splitTicketForEnrollment(provisionTicket, fetcher);438 assert(split.sinkTicket === derivedTicket, "the derived sync ticket must become the sink");439 assert(440 split.provisionUrl === `http://192.168.1.10:4802/t/${ticketSecret}`,441 "the provision URL must be returned for in-memory custody",442 );443 assert(444 requests.length === 1 &&445 requests[0] === `POST http://192.168.1.10:4802/t/${ticketSecret}/derive-sync-ticket`,446 `the exchange must POST the derive endpoint once, sent: ${requests.join("; ")}`,447 );448});449450test("exchange failures fall back to enrolling the ticket as pasted", async () => {451 const fallbacks: Array<typeof fetch> = [452 fetcherReturning(401, { error: "invalid_ticket" }),453 fetcherReturning(404, "not found"),454 fetcherReturning(200, { ticket: 7 }),455 fetcherReturning(200, { v: 1, id: "abc", ticket: "lofisync1.nope" }),456 fetcherReturning(200, {457 v: 1,458 id: "abc",459 ticket: encodeTicket({460 v: 1,461 appId: "11111111-2222-3333-4444-555555555555",462 url: `http://h/t/${derivedSecret}`,463 scope: "sync",464 }),465 }),466 fetcherReturning(200, {467 v: 1,468 id: "abc",469 ticket: encodeTicket({470 v: 1,471 appId: ticketAppId,472 url: `http://h/t/${derivedSecret}`,473 scope: "provision",474 }),475 }),476 () => Promise.reject(new Error("network down")),477 ];478 for (const fetcher of fallbacks) {479 const split = await splitTicketForEnrollment(provisionTicket, fetcher);480 assert(481 split.sinkTicket === provisionTicket && split.provisionUrl === null,482 "an unusable exchange must fall back to the pasted ticket",483 );484 }485});486487test("sync-scoped and malformed tickets never touch the exchange", async () => {488 let calls = 0;489 const fetcher: typeof fetch = () => {490 calls++;491 return Promise.resolve(new Response("{}", { status: 200 }));492 };493 for (const ticket of [validTicket, "lofisync1.nope", "not a ticket"]) {494 const split = await splitTicketForEnrollment(ticket, fetcher);495 assert(496 split.sinkTicket === ticket && split.provisionUrl === null,497 "a non-provision paste must pass through unchanged",498 );499 }500 assert(calls === 0, "the exchange endpoint must not be called for non-provision tickets");501});502503test("offering a device key binds the derived ticket; old nodes fall back to bearer", async () => {504 const bodies: string[] = [];505 const bindingFetcher: typeof fetch = (_input, init) => {506 bodies.push(String(init?.body));507 return Promise.resolve(508 new Response(509 JSON.stringify({ v: 1, id: "bound-id", ticket: derivedTicket, pop: true }),510 { status: 200 },511 ),512 );513 };514 const deviceKey = { alg: "ES256" as const, spki: "test-spki" };515 const bound = await splitTicketForEnrollment(provisionTicket, bindingFetcher, deviceKey);516 assert(517 bound.pop !== null && bound.pop.ticketId === "bound-id",518 "a pop:true response must record the binding with its ticket id",519 );520 assert(521 bodies.length === 1 && JSON.parse(bodies[0]).devicePublicKey.spki === "test-spki",522 "the derive request must carry the offered device key",523 );524525 // A node predating the field answers without `pop`: bearer fallback.526 const legacyFetcher: typeof fetch = () =>527 Promise.resolve(528 new Response(JSON.stringify({ v: 1, id: "abc", ticket: derivedTicket }), { status: 200 }),529 );530 const legacy = await splitTicketForEnrollment(provisionTicket, legacyFetcher, deviceKey);531 assert(532 legacy.pop === null && legacy.sinkTicket === derivedTicket,533 "a keyless response must enroll the derived ticket as pure bearer",534 );535});536537test(538 "a possession binding declared with the ticket survives the sealed round-trip",539 withCleanState(async () => {540 await declareSinkFromTicket(derivedTicket, undefined, { ticketId: "bound-id" });541 assert(542 readDeclaredSink()?.pop?.ticketId === "bound-id",543 "the declaration must carry the binding",544 );545 assert(await restoreDeclaredSink() === "restored", "the sealed record must restore");546 assert(547 readDeclaredSink()?.pop?.ticketId === "bound-id",548 "the restored declaration must keep the binding",549 );550 assert(551 activeSink()?.pop?.ticketId === "bound-id",552 "the active sink must expose the binding so boot runs the exchange",553 );554 }),555);