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 / schema / store_test.ts
store_test.ts 11.5K
1// Store-provisioning conformance over a real JazzServer: the classifier's2// four states, the create and slice-merge paths through the raw admin3// endpoints, old-hash client continuity across a merge, sibling-policy4// preservation (the honesty proof), and the namespace-confinement guards.5import { createDb } from "jazz-tools";6import { startLocalJazzServer } from "jazz-tools/testing";7import { assert } from "../runtime/test-assert.ts";8import { nestedAppDeployTarget, type NestedAppRoot, s } from "./mod.ts";9import { provisionStore, readStoreStatus, StoreProvisionError, type StoreTarget } from "./store.ts";1011// The first app on the store.12const taskRoot = s.defineNestedApp({13 taskapp: { tasks: s.table({ text: s.string(), secret: s.boolean() }) },14});15const taskPermissions = s.defineNestedPermissions(taskRoot.taskapp, ({ policy, session }) => {16 policy.tasks.allowInsert.always();17 // The gate the second app's merge must preserve verbatim.18 policy.tasks.allowRead.where({ secret: false });19 policy.tasks.allowRead.where({ $createdBy: session.user_id });20 policy.tasks.allowUpdate.always();21 policy.tasks.allowDelete.always();22});2324// The second app, enrolling later against the occupied store.25const noteRoot = s.defineNestedApp({26 notesapp: { notes: s.table({ title: s.string() }) },27});28const notePermissions = s.defineNestedPermissions(noteRoot.notesapp, ({ policy }) => {29 policy.notes.allowInsert.always();30 policy.notes.allowRead.always();31 policy.notes.allowUpdate.always();32 policy.notes.allowDelete.always();33});3435function secret(fill: number): string {36 let binary = "";37 for (const byte of new Uint8Array(32).fill(fill)) binary += String.fromCharCode(byte);38 return btoa(binary).replaceAll("+", "-").replaceAll("/", "_").replace(/=+$/, "");39}4041async function within<T>(operation: Promise<T>, label: string, milliseconds = 10_000): Promise<T> {42 let timer: ReturnType<typeof setTimeout> | undefined;43 try {44 return await Promise.race([45 operation,46 new Promise<never>((_resolve, reject) => {47 timer = setTimeout(() => reject(new Error(`${label} timed out`)), milliseconds);48 }),49 ]);50 } finally {51 if (timer !== undefined) clearTimeout(timer);52 }53}5455Deno.test("a store is classified and provisioned slice by slice, preserving its other tenants", async () => {56 const server = await startLocalJazzServer({ allowLocalFirstAuth: true });57 const target: StoreTarget = {58 serverUrl: server.url,59 appId: server.appId,60 adminSecret: server.adminSecret,61 };62 const clients: Awaited<ReturnType<typeof createDb>>[] = [];63 const client = async (fill: number) => {64 const db = await createDb({65 appId: server.appId,66 serverUrl: server.url,67 secret: secret(fill),68 userBranch: "main",69 driver: { type: "memory" },70 });71 clients.push(db);72 return db;73 };74 try {75 // A fresh store has nothing deployed — the state where writes would hang.76 const empty = await readStoreStatus(taskRoot, target);77 assert(empty.state === "no_schema", `fresh store classified as ${empty.state}`);7879 // Create: the first app's slice becomes the store schema.80 const created = await provisionStore({81 app: taskRoot,82 permissions: taskPermissions,83 target,84 });85 assert(created.status === "created", `initial provisioning reported ${created.status}`);86 const afterCreate = await readStoreStatus(taskRoot, target);87 assert(88 afterCreate.state === "ok" && afterCreate.headHash === created.headHash,89 `provisioned store classified as ${afterCreate.state}`,90 );91 const again = await provisionStore({ app: taskRoot, permissions: taskPermissions, target });92 assert(again.status === "unchanged", "re-provisioning an ok store must be a no-op");9394 const alice = await client(1);95 const task = await within(96 alice.insert(taskRoot.taskapp.tasks, { text: "visible", secret: false })97 .wait({ tier: "global" }),98 "task insert after create",99 );100 await within(101 alice.insert(taskRoot.taskapp.tasks, { text: "hidden", secret: true })102 .wait({ tier: "global" }),103 "gated task insert",104 );105106 // The second app sees the occupied store as out of date for ITS slice.107 const beforeMerge = await readStoreStatus(noteRoot, target);108 assert(109 beforeMerge.state === "schema_out_of_date" &&110 beforeMerge.missingTables.join(",") === "notesapp__notes",111 `second app classified the store as ${JSON.stringify(beforeMerge)}`,112 );113114 // Merge: only notesapp tables are added; taskapp rides through verbatim.115 const merged = await provisionStore({116 app: noteRoot,117 permissions: notePermissions,118 target,119 });120 assert(merged.status === "updated", `slice merge reported ${merged.status}`);121 const afterMerge = await readStoreStatus(noteRoot, target);122 assert(afterMerge.state === "ok", `merged store classified as ${afterMerge.state} for notes`);123 const taskView = await readStoreStatus(taskRoot, target);124 assert(taskView.state === "ok", `merged store classified as ${taskView.state} for tasks`);125126 // Old-hash continuity: the first app's client keeps working untouched.127 await within(128 alice.insert(taskRoot.taskapp.tasks, { text: "after merge", secret: false })129 .wait({ tier: "global" }),130 "old-hash insert after merge",131 );132133 // The new slice is live for the joining app's own clients — provisioning134 // registered its compiled schema and connected it to the head, so its135 // declared hash is never disconnected.136 const bob = await client(2);137 await within(138 bob.insert(noteRoot.notesapp.notes, { title: "first note" }).wait({ tier: "global" }),139 "notes insert after merge",140 );141142 // The honesty proof: notesapp's merge preserved taskapp's read gate. A143 // Db binds to one app's schema, so the non-author viewer is its own144 // client speaking the first app's handles.145 const carol = await client(3);146 const viewerTasks = await within(147 carol.all(taskRoot.taskapp.tasks.where({}), { tier: "global" }),148 "viewer task read after merge",149 );150 assert(151 viewerTasks.length === 2 && viewerTasks.every((row) => row.secret === false),152 `sibling policy not preserved: viewer sees ${viewerTasks.length} rows`,153 );154 const authorTasks = await within(155 alice.all(taskRoot.taskapp.tasks.where({ id: task.id }), { tier: "global" }),156 "author task read after merge",157 );158 assert(authorTasks.length === 1, "author lost access to pre-merge rows");159160 // Drift: an app whose declaration disagrees with the store's copy of its161 // own namespace is surfaced and refused, never repaired.162 const driftedRoot = s.defineNestedApp({163 taskapp: { tasks: s.table({ text: s.string(), secret: s.boolean(), extra: s.int() }) },164 });165 const drifted = await readStoreStatus(driftedRoot, target);166 assert(167 drifted.state === "schema_drift" &&168 drifted.driftedTables.join(",") === "taskapp__tasks",169 `drifted app classified the store as ${JSON.stringify(drifted)}`,170 );171 const driftedPermissions = s.defineNestedPermissions(172 driftedRoot.taskapp,173 ({ policy }) => policy.tasks.allowRead.always(),174 );175 let driftError = "";176 try {177 await provisionStore({ app: driftedRoot, permissions: driftedPermissions, target });178 } catch (error) {179 driftError = error instanceof StoreProvisionError ? error.code : String(error);180 }181 assert(driftError === "schema-drift", `drifted provisioning reported "${driftError}"`);182 } finally {183 await Promise.allSettled(184 clients.map((db, index) => within(db.logout(), `client ${index} cleanup`, 3_000)),185 );186 await within(server.stop(), "server cleanup", 3_000).catch(() => undefined);187 }188});189190Deno.test("provisioning is confined to the app's own namespaces", async () => {191 const target: StoreTarget = {192 serverUrl: "http://127.0.0.1:1",193 appId: "00000000-0000-0000-0000-000000000000",194 adminSecret: "unused",195 };196197 // A flat app has no namespace and therefore no tenant boundary. The cast198 // models a dynamic caller bypassing the NestedAppRoot brand; the runtime199 // defense must still refuse it.200 const flat = s.defineApp({ tasks: s.table({ text: s.string() }) });201 let flatError = "";202 try {203 await readStoreStatus(flat as unknown as NestedAppRoot, target);204 } catch (error) {205 flatError = error instanceof StoreProvisionError ? error.code : String(error);206 }207 assert(flatError === "not-nested", `flat app reported "${flatError}"`);208209 // A permissions bundle naming a table outside the app's namespaces is a210 // hard error before any request is made (the target above is unreachable).211 const outOfSlug = {212 ...notePermissions,213 taskapp__tasks: (taskPermissions as Record<string, unknown>).taskapp__tasks,214 } as typeof notePermissions;215 let slugError = "";216 try {217 await provisionStore({ app: noteRoot, permissions: outOfSlug, target });218 } catch (error) {219 slugError = error instanceof StoreProvisionError ? error.code : String(error);220 }221 assert(slugError === "outside-namespace", `out-of-slug bundle reported "${slugError}"`);222});223224Deno.test("the app slice derives from the deploy target's compiled schema", () => {225 const deployTarget = nestedAppDeployTarget(taskRoot) as { wasmSchema: Record<string, unknown> };226 assert(227 "taskapp__tasks" in deployTarget.wasmSchema,228 "expected the mangled table in the compiled wasm schema — the classifier compares this " +229 "form against the stored schema",230 );231});232233Deno.test("the ticket store-status preflight maps every documented gate response", async () => {234 // A stub of the lofi-node gate's store-status endpoint (the contract in its235 // docs/app-ticket.md): 200 with metadata, 502 store_unavailable, 401236 // invalid_ticket, and 404 for nodes without the endpoint.237 const responses: Record<string, () => Response> = {238 "/t/deployed/store-status": () =>239 Response.json({240 v: 1,241 appId: "a-app",242 schema: { deployed: true, headHash: "ff85", permissionsHead: "0195" },243 }),244 "/t/fresh/store-status": () =>245 Response.json({ v: 1, appId: "a-app", schema: { deployed: false } }),246 "/t/down/store-status": () => Response.json({ error: "store_unavailable" }, { status: 502 }),247 "/t/revoked/store-status": () => Response.json({ error: "invalid_ticket" }, { status: 401 }),248 };249 const server = Deno.serve(250 { hostname: "127.0.0.1", port: 0, onListen: () => {} },251 (request) => responses[new URL(request.url).pathname]?.() ?? new Response("", { status: 404 }),252 );253 const base = `http://127.0.0.1:${server.addr.port}`;254 try {255 const { readTicketStoreStatus } = await import("./store.ts");256 const deployed = await readTicketStoreStatus(`${base}/t/deployed`);257 assert(258 deployed.state === "deployed" && deployed.headHash === "ff85" && deployed.appId === "a-app",259 `deployed store mapped to ${JSON.stringify(deployed)}`,260 );261 const fresh = await readTicketStoreStatus(`${base}/t/fresh`);262 assert(fresh.state === "no_schema", `fresh store mapped to ${JSON.stringify(fresh)}`);263 const down = await readTicketStoreStatus(`${base}/t/down`);264 assert(down.state === "store_unavailable", `502 mapped to ${JSON.stringify(down)}`);265 const revoked = await readTicketStoreStatus(`${base}/t/revoked`);266 assert(revoked.state === "ticket_rejected", `401 mapped to ${JSON.stringify(revoked)}`);267 const open = await readTicketStoreStatus(`${base}/t/missing`);268 assert(open.state === "unsupported", `404 mapped to ${JSON.stringify(open)}`);269 } finally {270 await server.shutdown();271 }272});