clone

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

fix/175-template-quality-pass
lofi / package / runtime / session_test.ts
session_test.ts 11.4K
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 "electing under a foreign owner is refused; an unclaimed election records the owner",293 withCleanSyncState(async () => {294 recordSyncOwner({ fingerprint: await secretFingerprint("owner-secret"), user_id: "user-1" });295 let thrown: unknown;296 try {297 await assertAndRecordSyncOwnerForElection(() => Promise.resolve("other-secret"));298 } catch (error) {299 thrown = error;300 }301 assert(302 isSyncOwnerError(thrown) && thrown.owner_user_id === "user-1",303 "a foreign account must be refused, naming the owner",304 );305 clearSyncOwner();306 await assertAndRecordSyncOwnerForElection(() => Promise.resolve("other-secret"));307 const record = readSyncOwner();308 assert(309 record !== null && record.fingerprint === await secretFingerprint("other-secret"),310 "an unclaimed election must record the electing account as owner",311 );312 }),313);314315// In Deno there is no `location`, so enrollment fails with `origin-rejected`316// before any WebAuthn surface is touched — exactly the failure class whose317// guard handling these tests pin down.318const phraseGuardKey = `lofi:phrase-passkey:${appId}`;319320test("a failed re-enrollment preserves an existing phrase guard", async () => {321 const pinned = JSON.stringify({ id: "AQIDBA" });322 localStorage.setItem(phraseGuardKey, pinned);323 try {324 const guarded = await createBackupPasskey();325 assert(guarded, "the device must still report itself guarded");326 assert(327 localStorage.getItem(phraseGuardKey) === pinned,328 "the pinned guard record must survive a failed re-enrollment",329 );330 } finally {331 localStorage.removeItem(phraseGuardKey);332 }333});334335test("a failed first enrollment stores no guard and reports unguarded", async () => {336 localStorage.removeItem(phraseGuardKey);337 const guarded = await createBackupPasskey();338 assert(!guarded, "a device that never enrolled must report unguarded");339 assert(340 localStorage.getItem(phraseGuardKey) === null,341 "no guard record may be written by a failed enrollment",342 );343});