clone

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

feat/prf-provision-custody
lofi / package / runtime / data-sink_test.ts
data-sink_test.ts 17.6K
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 restoreDeclaredSink,15 splitTicketForEnrollment,16} from "./data-sink.ts";17import { memoryDeviceKeyStore, parseSealedEnvelope } from "./envelope.ts";18import {19 activeAppId,20 activeServerUrl,21 activeSink,22 appId,23 databaseConfig,24 setSyncElected,25 syncAvailable,26 syncElected,27 syncing,28} from "./config.ts";29import { defineLofiApp } from "./app.ts";30import { assert } from "./test-assert.ts";3132defineLofiApp({33 name: "lofi-test",34 databaseName: "lofi-test",35 schema: {},36 storage: "durable",37 credentialOrigins: [],38 sync: { adapter: "jazz" },39});4041const test = (globalThis as unknown as {42 Deno: { test(name: string, body: () => void | Promise<void>): void };43}).Deno.test;4445const sinkKey = `lofi:data-sink:${anchorAppId}`;46const electionKey = `lofi:sync-elected:${appId}`;4748function withCleanState(body: () => void | Promise<void>): () => Promise<void> {49 return async () => {50 localStorage.removeItem(sinkKey);51 localStorage.removeItem(electionKey);52 clearDeclaredSink();53 try {54 await body();55 } finally {56 localStorage.removeItem(sinkKey);57 localStorage.removeItem(electionKey);58 clearDeclaredSink();59 }60 };61}6263// A ticket encoded exactly as lofi-node's encodeAppTicket does — this fixture64// doubles as the cross-repo format contract.65const ticketSecret = "s".repeat(43);66const ticketAppId = "cfe52e44-7a59-4232-8dbb-bf53f27aeed6";67function encodeTicket(payload: unknown): string {68 const json = JSON.stringify(payload);69 return "lofisync1." +70 btoa(json).replaceAll("+", "-").replaceAll("/", "_").replaceAll("=", "");71}72const validTicket = encodeTicket({73 v: 1,74 appId: ticketAppId,75 url: `http://192.168.1.10:4802/t/${ticketSecret}`,76 label: "phone",77});7879test(80 "a declared sink round-trips through the sealed record and a restore",81 withCleanState(async () => {82 assert(readDeclaredSink() === null, "a clean device must have no declared sink");83 await declareDataSink({84 appId: ticketAppId,85 serverUrl: "https://node.example:4802",86 label: "home",87 });88 const sink = readDeclaredSink();89 assert(90 sink !== null && sink.appId === ticketAppId &&91 sink.serverUrl === "https://node.example:4802" && sink.label === "home",92 `declared sink read back as ${JSON.stringify(sink)}`,93 );94 // A fresh boot re-reads storage: the sealed record must open back to the95 // same declaration.96 assert(await restoreDeclaredSink() === "restored", "the sealed record must restore");97 assert(98 readDeclaredSink()?.serverUrl === "https://node.example:4802",99 "the restored sink must match the declaration",100 );101 clearDeclaredSink();102 assert(readDeclaredSink() === null, "clearing must remove the declaration");103 assert(await restoreDeclaredSink() === "none", "clearing must remove the stored record");104 }),105);106107test(108 "an unreadable or unversioned sink record restores as absent",109 withCleanState(async () => {110 localStorage.setItem(sinkKey, "not json");111 assert(await restoreDeclaredSink() === "none", "malformed record must restore as absent");112 assert(readDeclaredSink() === null, "malformed record must read as absent");113 localStorage.setItem(sinkKey, JSON.stringify({ v: 99, sink: { appId: "x", serverUrl: "y" } }));114 assert(await restoreDeclaredSink() === "none", "unknown record version must restore as absent");115 assert(readDeclaredSink() === null, "unknown record version must read as absent");116 }),117);118119test(120 "a pre-envelope cleartext record migrates to a sealed one on restore",121 withCleanState(async () => {122 const bearerUrl = `http://192.168.1.10:4802/t/${ticketSecret}`;123 localStorage.setItem(124 sinkKey,125 JSON.stringify({ v: 1, sink: { appId: ticketAppId, serverUrl: bearerUrl, label: "phone" } }),126 );127 assert(await restoreDeclaredSink() === "migrated", "a v1 record must migrate");128 assert(readDeclaredSink()?.serverUrl === bearerUrl, "the migrated sink must carry over");129 const raw = localStorage.getItem(sinkKey) ?? "";130 assert(!raw.includes(ticketSecret), "migration must remove the bearer secret from storage");131 const record = JSON.parse(raw) as { v: number; sealed: unknown };132 assert(133 record.v === 2 && parseSealedEnvelope(record.sealed) !== null,134 "migration must persist a sealed envelope",135 );136 assert(await restoreDeclaredSink() === "restored", "the migrated record must reopen");137 assert(readDeclaredSink()?.serverUrl === bearerUrl, "the resealed sink must round-trip");138 }),139);140141test(142 "a sealed record without its device key is unopenable, not destroyed",143 withCleanState(async () => {144 await declareDataSink({ appId: ticketAppId, serverUrl: "https://node.example:4802" });145 const stored = localStorage.getItem(sinkKey);146 assert(stored !== null, "the declaration must persist");147 // A store that never held the wrapping key models cleared IndexedDB with148 // surviving localStorage.149 assert(150 await restoreDeclaredSink(memoryDeviceKeyStore()) === "unopenable",151 "a missing device key must report unopenable",152 );153 assert(readDeclaredSink() === null, "an unopenable record must read as absent");154 assert(localStorage.getItem(sinkKey) === stored, "an unopenable record must be left intact");155 assert(await restoreDeclaredSink() === "restored", "the right key must still open it");156 }),157);158159test(160 "no bearer material reaches storage in cleartext or a trivial encoding",161 withCleanState(async () => {162 const bearerUrl = `http://192.168.1.10:4802/t/${ticketSecret}`;163 await declareSinkFromTicket(164 encodeTicket({ v: 1, appId: ticketAppId, url: bearerUrl, scope: "provision" }),165 );166 const base64Url = (text: string) =>167 btoa(text).replaceAll("+", "-").replaceAll("/", "_").replaceAll("=", "");168 const encodings = [169 ticketSecret,170 bearerUrl,171 btoa(ticketSecret),172 base64Url(ticketSecret),173 encodeURIComponent(bearerUrl),174 ];175 for (let index = 0; index < localStorage.length; index++) {176 const key = localStorage.key(index);177 const value = key === null ? null : localStorage.getItem(key);178 if (!value) continue;179 for (const encoding of encodings) {180 assert(181 !value.includes(encoding),182 `bearer material found at rest under "${key}" as ${encoding.slice(0, 16)}…`,183 );184 }185 }186 const record = JSON.parse(localStorage.getItem(sinkKey) ?? "null") as {187 v: number;188 sealed: unknown;189 };190 assert(191 record?.v === 2 && parseSealedEnvelope(record.sealed) !== null,192 "the sink at rest must be a sealed envelope",193 );194 assert(await restoreDeclaredSink() === "restored", "the sealed ticket must restore");195 assert(196 readDeclaredSink()?.serverUrl === bearerUrl && readDeclaredSink()?.scope === "provision",197 "the restored declaration must return the exact ticket URL and scope",198 );199 }),200);201202test(203 "sink declarations validate the server URL",204 withCleanState(async () => {205 for (const serverUrl of ["ws://node.example/t/x", "not a url", "ftp://node.example"]) {206 try {207 await declareDataSink({ appId: ticketAppId, serverUrl });208 throw new Error(`declaration accepted invalid server URL: ${serverUrl}`);209 } catch (error) {210 assert(211 isDataSinkError(error) && error.code === "invalid-server-url",212 `expected invalid-server-url for ${serverUrl}, received ${String(error)}`,213 );214 }215 }216 assert(readDeclaredSink() === null, "no rejected declaration may persist");217 }),218);219220test(221 "declaring over a different sink is refused; the same store updates its label",222 withCleanState(async () => {223 await declareDataSink({ appId: ticketAppId, serverUrl: "https://node.example:4802" });224 try {225 await declareDataSink({ appId: ticketAppId, serverUrl: "https://other.example:4802" });226 throw new Error("a different sink must not silently replace the declared one");227 } catch (error) {228 assert(229 isDataSinkError(error) && error.code === "sink-already-declared",230 `expected sink-already-declared, received ${String(error)}`,231 );232 }233 await declareDataSink({234 appId: ticketAppId,235 serverUrl: "https://node.example:4802",236 label: "renamed",237 });238 assert(239 readDeclaredSink()?.label === "renamed",240 "re-declaring the same store must update the label",241 );242 }),243);244245// The machine-readable format contract shared with lofi-node246// (docs/fixtures/app-ticket-fixtures.json there; vendored copy here). Either247// side changing the format updates the fixtures, and this test is what fails248// on drift.249test("the vendored lofi-node ticket fixtures parse exactly as the contract expects", async () => {250 const fixtures = JSON.parse(251 await Deno.readTextFile(252 new URL("../testdata/app-ticket-fixtures.json", import.meta.url),253 ),254 ) as {255 valid: { name: string; ticket: string; expect: Record<string, string> }[];256 invalid: { name: string; ticket: string }[];257 };258 for (const { name, ticket, expect } of fixtures.valid) {259 const parsed = parseSyncTicket(ticket);260 assert(parsed !== null, `valid fixture rejected: ${name}`);261 assert(262 parsed.appId === expect.appId && parsed.url === expect.url &&263 (parsed.scope ?? "sync") === expect.scope,264 `fixture "${name}" parsed as ${JSON.stringify(parsed)}, expected ${JSON.stringify(expect)}`,265 );266 }267 for (const { name, ticket } of fixtures.invalid) {268 assert(parseSyncTicket(ticket) === null, `invalid fixture accepted: ${name}`);269 }270});271272test("ticket parsing accepts the lofi-node format and rejects everything else", () => {273 const parsed = parseSyncTicket(` ${validTicket} `);274 assert(275 parsed !== null && parsed.appId === ticketAppId && parsed.label === "phone" &&276 parsed.url.endsWith(`/t/${ticketSecret}`),277 `valid ticket parsed as ${JSON.stringify(parsed)}`,278 );279 const provision = parseSyncTicket(280 encodeTicket({281 v: 1,282 appId: ticketAppId,283 url: `http://h/t/${ticketSecret}`,284 scope: "provision",285 }),286 );287 assert(provision?.scope === "provision", "provision scope must parse");288 const rejected = [289 "lofisync2.abc",290 "endpointAAAA",291 encodeTicket({ v: 2, appId: ticketAppId, url: `http://h/t/${ticketSecret}` }),292 encodeTicket({ v: 1, appId: ticketAppId, url: `ws://h/t/${ticketSecret}` }),293 encodeTicket({ v: 1, appId: ticketAppId, url: "http://h/t/short" }),294 encodeTicket({ v: 1, appId: ticketAppId, url: "http://h/apps/x" }),295 encodeTicket({ v: 1, url: `http://h/t/${ticketSecret}` }),296 encodeTicket({ v: 1, appId: ticketAppId, url: `http://h/t/${ticketSecret}`, scope: "root" }),297 "lofisync1.%%%not-base64%%%",298 ];299 for (const ticket of rejected) {300 assert(parseSyncTicket(ticket) === null, `malformed ticket accepted: ${ticket.slice(0, 40)}`);301 }302});303304test(305 "enrolling a ticket declares its URL verbatim as the sink",306 withCleanState(async () => {307 const declared = await declareSinkFromTicket(validTicket);308 assert(309 declared.serverUrl === `http://192.168.1.10:4802/t/${ticketSecret}`,310 "the ticket URL must be used verbatim — the secret path is the credential",311 );312 const sink = readDeclaredSink();313 assert(sink?.appId === ticketAppId && sink.label === "phone", "ticket fields must carry over");314 assert(sink.scope === undefined, "a scopeless ticket declares no explicit scope");315 try {316 await declareSinkFromTicket("lofisync1.nope");317 throw new Error("a malformed ticket must not enroll");318 } catch (error) {319 assert(320 isDataSinkError(error) && error.code === "invalid-ticket",321 `expected invalid-ticket, received ${String(error)}`,322 );323 }324 }),325);326327test(328 "the active sink resolves declared over default and gates the election",329 withCleanState(async () => {330 // This test build compiles no managed app, so the default sink is absent.331 assert(activeSink() === null && !syncAvailable(), "local build must start with no sink");332 assert(activeAppId() === appId, "without a sink the active app id is the anchor");333 setSyncElected(true);334 assert(!syncElected(), "election must be a no-op without a sync location");335336 await declareDataSink({ appId: ticketAppId, serverUrl: "https://node.example:4802" });337 const sink = activeSink();338 assert(339 sink?.source === "declared" && sink.appId === ticketAppId && syncAvailable(),340 `declared sink resolved as ${JSON.stringify(sink)}`,341 );342 assert(activeAppId() === ticketAppId, "the active app id must be the sink's");343 assert(activeServerUrl() === "https://node.example:4802", "the active URL must be the sink's");344345 setSyncElected(true);346 assert(syncElected() && syncing(), "election must stand once a sink is declared");347 setSyncElected(false);348 assert(!syncing(), "clearing the election must stop replication");349 }),350);351352test(353 "databaseConfig keeps the local tier on the anchor and gives managed the sink",354 withCleanState(async () => {355 await declareDataSink({ appId: ticketAppId, serverUrl: "https://node.example:4802" });356 setSyncElected(true);357358 const local = databaseConfig("secret", "ns", "local", false);359 assert(local.appId === appId, "the local tier must stay on the anchor app id");360 assert(!("serverUrl" in local), "the local tier must not carry a server URL");361 const localDriver = local.driver as { dbName: string };362 assert(363 localDriver.dbName === `lofi-test-${appId}-ns-local`,364 `local dbName was ${localDriver.dbName} — first-boot data must remain findable`,365 );366367 const managed = databaseConfig("secret", "ns");368 assert(managed.appId === ticketAppId, "the managed tier must use the sink's app id");369 assert(370 (managed as { serverUrl?: string }).serverUrl === "https://node.example:4802",371 "a connecting managed config must carry the sink URL",372 );373 const managedDriver = managed.driver as { dbName: string };374 assert(375 managedDriver.dbName === `lofi-test-${ticketAppId}-ns-managed`,376 `managed dbName was ${managedDriver.dbName} — each store gets its own namespace`,377 );378 }),379);380381// The scope-down exchange: a provision ticket splits into a derived sync382// ticket (declared) and the provision URL (held in memory by session.ts).383const provisionTicket = encodeTicket({384 v: 1,385 appId: ticketAppId,386 url: `http://192.168.1.10:4802/t/${ticketSecret}`,387 scope: "provision",388 label: "phone",389});390const derivedSecret = "d".repeat(43);391const derivedTicket = encodeTicket({392 v: 1,393 appId: ticketAppId,394 url: `http://192.168.1.10:4802/t/${derivedSecret}`,395 scope: "sync",396 label: "phone (sync)",397});398399function fetcherReturning(status: number, body: unknown): typeof fetch {400 return () => Promise.resolve(new Response(JSON.stringify(body), { status }));401}402403test("a provision ticket splits into the derived sync ticket and the held URL", async () => {404 const requests: string[] = [];405 const fetcher: typeof fetch = (input, init) => {406 requests.push(`${init?.method} ${String(input)}`);407 return Promise.resolve(408 new Response(JSON.stringify({ v: 1, id: "abc", ticket: derivedTicket }), { status: 200 }),409 );410 };411 const split = await splitTicketForEnrollment(provisionTicket, fetcher);412 assert(split.sinkTicket === derivedTicket, "the derived sync ticket must become the sink");413 assert(414 split.provisionUrl === `http://192.168.1.10:4802/t/${ticketSecret}`,415 "the provision URL must be returned for in-memory custody",416 );417 assert(418 requests.length === 1 &&419 requests[0] === `POST http://192.168.1.10:4802/t/${ticketSecret}/derive-sync-ticket`,420 `the exchange must POST the derive endpoint once, sent: ${requests.join("; ")}`,421 );422});423424test("exchange failures fall back to enrolling the ticket as pasted", async () => {425 const fallbacks: Array<typeof fetch> = [426 fetcherReturning(401, { error: "invalid_ticket" }),427 fetcherReturning(404, "not found"),428 fetcherReturning(200, { ticket: 7 }),429 fetcherReturning(200, { v: 1, id: "abc", ticket: "lofisync1.nope" }),430 fetcherReturning(200, {431 v: 1,432 id: "abc",433 ticket: encodeTicket({434 v: 1,435 appId: "11111111-2222-3333-4444-555555555555",436 url: `http://h/t/${derivedSecret}`,437 scope: "sync",438 }),439 }),440 fetcherReturning(200, {441 v: 1,442 id: "abc",443 ticket: encodeTicket({444 v: 1,445 appId: ticketAppId,446 url: `http://h/t/${derivedSecret}`,447 scope: "provision",448 }),449 }),450 () => Promise.reject(new Error("network down")),451 ];452 for (const fetcher of fallbacks) {453 const split = await splitTicketForEnrollment(provisionTicket, fetcher);454 assert(455 split.sinkTicket === provisionTicket && split.provisionUrl === null,456 "an unusable exchange must fall back to the pasted ticket",457 );458 }459});460461test("sync-scoped and malformed tickets never touch the exchange", async () => {462 let calls = 0;463 const fetcher: typeof fetch = () => {464 calls++;465 return Promise.resolve(new Response("{}", { status: 200 }));466 };467 for (const ticket of [validTicket, "lofisync1.nope", "not a ticket"]) {468 const split = await splitTicketForEnrollment(ticket, fetcher);469 assert(470 split.sinkTicket === ticket && split.provisionUrl === null,471 "a non-provision paste must pass through unchanged",472 );473 }474 assert(calls === 0, "the exchange endpoint must not be called for non-provision tickets");475});