clone

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

feat/fix-sync-enrollment-startup
lofi / package / runtime / session_test.ts
session_test.ts 15.0K
1// Package contract tests for account-session election behavior: the no-op2// guard, phrase-guard failure handling, the stop-sync transport guard, the3// enrollment preflight policy, and the sync-owner election guard.4import {5 assertAndRecordSyncOwnerForElection,6 createBackupPasskey,7 enableSyncBackup,8 isSyncEnrollmentError,9 performTicketEnrollment,10 stopSyncBackup,11} from "./session.ts";12import { readNamespaceState } from "./namespace-state.ts";13import { appId, setSyncElected, syncAvailable, syncElected } from "./config.ts";14import {15 anchorAppId,16 clearDeclaredSink,17 declareSinkFromTicket,18 readDeclaredSink,19} from "./data-sink.ts";20import { memoryDeviceKeyStore } from "./envelope.ts";21import { lockProvisionCapability, provisionCapabilityStatus } from "./provision.ts";22import { getRuntimeDiagnostics, runtimeRecreatedEvent } from "./runtime.ts";23import type { TicketStoreStatus } from "../schema/store.ts";24import {25 clearSyncOwner,26 isSyncOwnerError,27 readSyncOwner,28 recordSyncOwner,29 secretFingerprint,30} from "./sync-owner.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 electionKeys = [48 `lofi:namespace-state:${appId}`,49 `lofi:managed-runtime:${appId}`,50 `lofi:migrate-local-rows:${appId}`,51 `lofi:sync-elected:${appId}`,52];5354test("enabling sync without a sync location is the documented no-op", async () => {55 assert(!syncAvailable(), "this contract test requires the unconfigured local build");56 for (const key of electionKeys) localStorage.removeItem(key);57 try {58 const session = await enableSyncBackup();59 assert(!session.backedUp, "no election may be recorded without a managed Jazz app");60 assert(!session.syncing, "nothing can replicate without a managed Jazz app");61 const namespace = readNamespaceState();62 assert(63 namespace.mode === "local" && !namespace.migrateLocalRows,64 "the local namespace must remain elected — a managed election would hide every local row",65 );66 for (const key of electionKeys) {67 assert(68 localStorage.getItem(key) === null,69 `the no-op must not persist election state (wrote ${key})`,70 );71 }72 } finally {73 for (const key of electionKeys) localStorage.removeItem(key);74 }75});7677const sinkKey = `lofi:data-sink:${anchorAppId}`;78const ownerKey = `lofi:sync-owner:${anchorAppId}`;7980function withCleanSyncState(body: () => void | Promise<void>): () => Promise<void> {81 return async () => {82 const keys = [...electionKeys, sinkKey, ownerKey];83 const reset = () => {84 for (const key of keys) localStorage.removeItem(key);85 clearDeclaredSink();86 clearSyncOwner();87 lockProvisionCapability();88 };89 reset();90 try {91 await body();92 } finally {93 reset();94 }95 };96}9798// A ticket encoded exactly as lofi-node's encodeAppTicket does (the format99// contract is pinned in data-sink_test.ts; this fixture only needs a valid100// instance).101const ticketSecret = "s".repeat(43);102const ticketAppId = "cfe52e44-7a59-4232-8dbb-bf53f27aeed6";103function encodeTicket(payload: unknown): string {104 const json = JSON.stringify(payload);105 return "lofisync1." +106 btoa(json).replaceAll("+", "-").replaceAll("/", "_").replaceAll("=", "");107}108const syncTicket = encodeTicket({109 v: 1,110 appId: ticketAppId,111 url: `http://192.168.1.10:4802/t/${ticketSecret}`,112 label: "phone",113});114const provisionTicket = encodeTicket({115 v: 1,116 appId: ticketAppId,117 url: `http://192.168.1.10:4802/t/${ticketSecret}`,118 scope: "provision",119 label: "phone",120});121const derivedTicket = encodeTicket({122 v: 1,123 appId: ticketAppId,124 url: `http://192.168.1.10:4802/t/${"d".repeat(43)}`,125 scope: "sync",126 label: "phone (sync)",127});128129const answering = (state: TicketStoreStatus["state"]) => (): Promise<TicketStoreStatus> =>130 Promise.resolve(131 state === "deployed"132 ? { state, appId: ticketAppId, headHash: "abc123" }133 : { state } as TicketStoreStatus,134 );135136test(137 "stopping sync without a configured transport resolves and clears the election",138 withCleanSyncState(async () => {139 // The stale-tab shape: election and owner persisted, but this document's140 // runtime never configured a server, so there is no transport to detach.141 await declareSinkFromTicket(syncTicket, memoryDeviceKeyStore());142 setSyncElected(true);143 recordSyncOwner({ fingerprint: await secretFingerprint("secret-a"), user_id: "user-1" });144 let recreations = 0;145 const onRecreated = () => void (recreations += 1);146 globalThis.addEventListener(runtimeRecreatedEvent, onRecreated);147 try {148 const session = await stopSyncBackup();149 assert(!session.backedUp, "stop-sync must clear the election");150 assert(!syncElected(), "the election flag must be cleared");151 assert(readSyncOwner() === null, "stop-sync must release the sync-owner pin");152 assert(recreations === 1, "stop-sync must announce the state change");153 } finally {154 globalThis.removeEventListener(runtimeRecreatedEvent, onRecreated);155 }156 }),157);158159test(160 "a no_schema preflight refuses enrollment and rolls the sink back",161 withCleanSyncState(async () => {162 let elected = 0;163 let thrown: unknown;164 try {165 await performTicketEnrollment(syncTicket, {166 keyStore: memoryDeviceKeyStore(),167 preflight: answering("no_schema"),168 elect: () => {169 elected += 1;170 return Promise.reject(new Error("elect must not run"));171 },172 });173 } catch (error) {174 thrown = error;175 }176 assert(177 isSyncEnrollmentError(thrown) && thrown.code === "no_schema",178 "the refusal must be the typed enrollment error",179 );180 assert(readDeclaredSink() === null, "the sink declaration must be rolled back");181 assert(elected === 0, "sync must not be elected on a refused enrollment");182 assert(!syncElected(), "no election flag may be written");183 }),184);185186test(187 "a ticket_rejected preflight refuses a provision enrollment and holds nothing",188 withCleanSyncState(async () => {189 const fetcher: typeof fetch = () =>190 Promise.resolve(191 new Response(JSON.stringify({ v: 1, id: "abc", ticket: derivedTicket }), { status: 200 }),192 );193 let thrown: unknown;194 try {195 await performTicketEnrollment(provisionTicket, {196 fetcher,197 keyStore: memoryDeviceKeyStore(),198 preflight: answering("ticket_rejected"),199 elect: () => Promise.reject(new Error("elect must not run")),200 });201 } catch (error) {202 thrown = error;203 }204 assert(205 isSyncEnrollmentError(thrown) && thrown.code === "ticket_rejected",206 "the refusal must be the typed enrollment error",207 );208 assert(readDeclaredSink() === null, "the derived sink must be rolled back");209 assert(210 !provisionCapabilityStatus().held,211 "the provision capability must not be held after a refused enrollment",212 );213 }),214);215216test(217 "a refused re-enrollment restores the previously declared sink",218 withCleanSyncState(async () => {219 const keyStore = memoryDeviceKeyStore();220 await declareSinkFromTicket(syncTicket, keyStore);221 let thrown: unknown;222 try {223 await performTicketEnrollment(syncTicket, {224 keyStore,225 preflight: answering("no_schema"),226 elect: () => Promise.reject(new Error("elect must not run")),227 });228 } catch (error) {229 thrown = error;230 }231 assert(isSyncEnrollmentError(thrown), "the refusal must still throw");232 const sink = readDeclaredSink();233 assert(234 sink !== null && sink.serverUrl === `http://192.168.1.10:4802/t/${ticketSecret}`,235 "the previous declaration must survive the refused re-enrollment",236 );237 }),238);239240test(241 "an unreachable store enrolls anyway and records the warning",242 withCleanSyncState(async () => {243 let elected = 0;244 await performTicketEnrollment(syncTicket, {245 keyStore: memoryDeviceKeyStore(),246 preflight: answering("store_unavailable"),247 elect: () => {248 elected += 1;249 return Promise.resolve(undefined as never);250 },251 });252 assert(elected === 1, "a merely unreachable store must not block enrollment");253 assert(readDeclaredSink() !== null, "the sink declaration must be kept");254 assert(255 getRuntimeDiagnostics().storeStatus.state === "store_unavailable",256 "the warning must land in runtime diagnostics for status surfaces",257 );258 }),259);260261test(262 "a deployed store enrolls a provision ticket and holds the capability",263 withCleanSyncState(async () => {264 const fetcher: typeof fetch = () =>265 Promise.resolve(266 new Response(JSON.stringify({ v: 1, id: "abc", ticket: derivedTicket }), { status: 200 }),267 );268 let elected = 0;269 await performTicketEnrollment(provisionTicket, {270 fetcher,271 keyStore: memoryDeviceKeyStore(),272 preflight: answering("deployed"),273 elect: () => {274 elected += 1;275 return Promise.resolve(undefined as never);276 },277 });278 assert(elected === 1, "a deployed store must elect sync");279 const sink = readDeclaredSink();280 assert(281 sink !== null && sink.serverUrl === `http://192.168.1.10:4802/t/${"d".repeat(43)}`,282 "the derived sync ticket must be the declared sink",283 );284 assert(285 provisionCapabilityStatus().held,286 "the provision capability must be held after a kept enrollment",287 );288 }),289);290291test(292 "a PoP-bound provision enrollment preflights through its authenticated connect URL",293 withCleanSyncState(async () => {294 const derivedSecret = "d".repeat(43);295 const connectSecret = "c".repeat(43);296 const requestedUrls: string[] = [];297 const fetcher: typeof fetch = (input, init) => {298 const url = String(input);299 requestedUrls.push(url);300 if (url.endsWith("/derive-sync-ticket")) {301 return Promise.resolve(302 new Response(303 JSON.stringify({ v: 1, id: "derived-id", ticket: derivedTicket, pop: true }),304 { status: 200 },305 ),306 );307 }308 if (url.endsWith("/pop/challenge")) {309 return Promise.resolve(310 new Response(JSON.stringify({ id: "challenge-id", nonce: "nonce" }), {311 status: 200,312 }),313 );314 }315 if (url.endsWith("/pop/answer") && init?.method === "POST") {316 return Promise.resolve(317 new Response(JSON.stringify({ v: 1, connect: connectSecret }), { status: 200 }),318 );319 }320 throw new Error(`unexpected fetch: ${url}`);321 };322 let preflightUrl = "";323 await performTicketEnrollment(provisionTicket, {324 fetcher,325 keyStore: memoryDeviceKeyStore(),326 preflight: (url) => {327 preflightUrl = url;328 return answering("deployed")();329 },330 elect: () => Promise.resolve(undefined as never),331 });332 assert(333 preflightUrl ===334 `http://192.168.1.10:4802/t/${derivedSecret}/c/${connectSecret}`,335 `store-status must use the PoP-authenticated connect URL (received ${preflightUrl})`,336 );337 assert(338 requestedUrls.some((url) => url.endsWith("/pop/challenge")) &&339 requestedUrls.some((url) => url.endsWith("/pop/answer")),340 "enrollment must complete the PoP exchange before preflight",341 );342 }),343);344345test(346 "a no_schema answer through a PoP connect URL still rolls enrollment back",347 withCleanSyncState(async () => {348 const fetcher: typeof fetch = (input) => {349 const url = String(input);350 if (url.endsWith("/derive-sync-ticket")) {351 return Promise.resolve(352 new Response(353 JSON.stringify({ v: 1, id: "derived-id", ticket: derivedTicket, pop: true }),354 { status: 200 },355 ),356 );357 }358 if (url.endsWith("/pop/challenge")) {359 return Promise.resolve(360 new Response(JSON.stringify({ id: "challenge-id", nonce: "nonce" }), {361 status: 200,362 }),363 );364 }365 if (url.endsWith("/pop/answer")) {366 return Promise.resolve(367 new Response(JSON.stringify({ v: 1, connect: "c".repeat(43) }), { status: 200 }),368 );369 }370 throw new Error(`unexpected fetch: ${url}`);371 };372 let thrown: unknown;373 try {374 await performTicketEnrollment(provisionTicket, {375 fetcher,376 keyStore: memoryDeviceKeyStore(),377 preflight: answering("no_schema"),378 elect: () => Promise.reject(new Error("elect must not run")),379 });380 } catch (error) {381 thrown = error;382 }383 assert(384 isSyncEnrollmentError(thrown) && thrown.code === "no_schema",385 `the authenticated no-schema answer must retain the enrollment safety gate (received ${386 thrown instanceof Error ? `${thrown.name}: ${thrown.message}` : String(thrown)387 })`,388 );389 assert(readDeclaredSink() === null, "the PoP-bound sink must be rolled back");390 assert(391 !provisionCapabilityStatus().held,392 "the provision capability must not be held after the refused enrollment",393 );394 }),395);396397test(398 "electing under a foreign owner is refused; an unclaimed election records the owner",399 withCleanSyncState(async () => {400 recordSyncOwner({ fingerprint: await secretFingerprint("owner-secret"), user_id: "user-1" });401 let thrown: unknown;402 try {403 await assertAndRecordSyncOwnerForElection(() => Promise.resolve("other-secret"));404 } catch (error) {405 thrown = error;406 }407 assert(408 isSyncOwnerError(thrown) && thrown.owner_user_id === "user-1",409 "a foreign account must be refused, naming the owner",410 );411 clearSyncOwner();412 await assertAndRecordSyncOwnerForElection(() => Promise.resolve("other-secret"));413 const record = readSyncOwner();414 assert(415 record !== null && record.fingerprint === await secretFingerprint("other-secret"),416 "an unclaimed election must record the electing account as owner",417 );418 }),419);420421// In Deno there is no `location`, so enrollment fails with `origin-rejected`422// before any WebAuthn surface is touched — exactly the failure class whose423// guard handling these tests pin down.424const phraseGuardKey = `lofi:phrase-passkey:${appId}`;425426test("a failed re-enrollment preserves an existing phrase guard", async () => {427 const pinned = JSON.stringify({ id: "AQIDBA" });428 localStorage.setItem(phraseGuardKey, pinned);429 try {430 const guarded = await createBackupPasskey();431 assert(guarded, "the device must still report itself guarded");432 assert(433 localStorage.getItem(phraseGuardKey) === pinned,434 "the pinned guard record must survive a failed re-enrollment",435 );436 } finally {437 localStorage.removeItem(phraseGuardKey);438 }439});440441test("a failed first enrollment stores no guard and reports unguarded", async () => {442 localStorage.removeItem(phraseGuardKey);443 const guarded = await createBackupPasskey();444 assert(!guarded, "a device that never enrolled must report unguarded");445 assert(446 localStorage.getItem(phraseGuardKey) === null,447 "no guard record may be written by a failed enrollment",448 );449});