clone

a self-hosted sync node for lofi apps: jazz sync over iroh, ticket-gated, with no static ip or cloud needed

release/0.4.0
lofi-node / tests / health_test.ts
health_test.ts 5.2K
1// The connection signal: GET /health answers in every connection mode —2// open (Jazz serves it directly), ticket (the gate proxies it, both at the3// top level and under a ticket base path), and peer (carried over the iroh4// tunnel to the node's Jazz). A failing store degrades to a non-200, never a5// hang, so clients can fold periodic health checks into a connection6// observable alongside WS lifecycle events.78import { assert, assertEquals } from "@std/assert";9import { createSyncNode } from "../src/node.ts";10import { startGate } from "../src/gate.ts";11import { AppTicketStore, decodeAppTicket } from "../src/appticket.ts";12import { loadIrohAddon } from "../src/native/addon.ts";13import { IrohNode } from "../src/iroh/node.ts";14import { startTunnelListener } from "../src/tunnel.ts";15import { resolveIrohLib } from "../src/native/loader.ts";1617async function fetchHealth(base: string): Promise<{ status: number; body: unknown }> {18 const res = await fetch(new URL("/health", base));19 const text = await res.text();20 let body: unknown = text;21 try {22 body = JSON.parse(text);23 } catch {24 // non-JSON error bodies stay as text25 }26 return { status: res.status, body };27}2829Deno.test({30 name: "health: open mode — Jazz answers on the public URL",31 sanitizeOps: false,32 sanitizeResources: false,33 fn: async () => {34 const node = await createSyncNode({35 appId: crypto.randomUUID(),36 backendSecret: "lofi_backend_health_open",37 adminSecret: "lofi_admin_health_open",38 inMemory: true,39 mesh: "off",40 });41 try {42 const { status, body } = await fetchHealth(node.url.replace(/^ws/, "http"));43 assertEquals(status, 200);44 assertEquals((body as { status: string }).status, "healthy");45 } finally {46 await node.stop();47 }48 },49});5051Deno.test({52 name: "health: ticket mode — unauthenticated at the top level, and under a ticket base",53 sanitizeOps: false,54 sanitizeResources: false,55 fn: async () => {56 const node = await createSyncNode({57 appId: crypto.randomUUID(),58 backendSecret: "lofi_backend_health_gate",59 adminSecret: "lofi_admin_health_gate",60 inMemory: true,61 mesh: "off",62 access: "ticket",63 });64 try {65 // Top level: no ticket needed — reachable before enrollment.66 const top = await fetchHealth(node.url.replace(/^ws/, "http"));67 assertEquals(top.status, 200);68 assertEquals((top.body as { status: string }).status, "healthy");6970 // Under a ticket base: serverUrl-relative polling works unchanged.71 const issued = await node.issueTicket({ label: "health-probe" });72 const ticket = decodeAppTicket(issued.ticket)!;73 const relative = await fetch(`${ticket.url}/health`);74 assertEquals(relative.status, 200);75 assertEquals((await relative.json() as { status: string }).status, "healthy");7677 // Revocation closes the ticket path but never the top-level signal:78 // a revoked client still distinguishes "node up, access gone" (401)79 // from "node unreachable" (network error).80 await node.revokeTicket(issued.id);81 const revoked = await fetch(`${ticket.url}/health`);82 assertEquals(revoked.status, 401);83 await revoked.body?.cancel();84 const stillUp = await fetchHealth(node.url.replace(/^ws/, "http"));85 assertEquals(stillUp.status, 200);86 } finally {87 await node.stop();88 }89 },90});9192Deno.test({93 name: "health: gate degrades to 502 when the store is unreachable",94 sanitizeOps: false,95 sanitizeResources: false,96 fn: async () => {97 // A dead loopback port stands in for a crashed Jazz process.98 const dead = Deno.listen({ hostname: "127.0.0.1", port: 0 });99 const deadPort = (dead.addr as Deno.NetAddr).port;100 dead.close();101 const store = await AppTicketStore.load();102 const gate = startGate({103 port: 0,104 hostname: "127.0.0.1",105 target: () => `http://127.0.0.1:${deadPort}`,106 mode: "ticket",107 store,108 appId: "health-app",109 adminSecret: "health-admin",110 });111 try {112 const res = await fetch(`${gate.url}/health`);113 assertEquals(res.status, 502, "gate up, store down → 502, not a hang");114 await res.body?.cancel();115 } finally {116 await gate.close();117 }118 },119});120121Deno.test({122 name: "health: peer mode — carried over the iroh tunnel to the node's Jazz",123 ignore: resolveIrohLib().status !== "ok",124 sanitizeOps: false,125 sanitizeResources: false,126 fn: async () => {127 const node = await createSyncNode({128 appId: crypto.randomUUID(),129 backendSecret: "lofi_backend_health_peer",130 adminSecret: "lofi_admin_health_peer",131 inMemory: true,132 });133 const addon = await loadIrohAddon();134 const dialer = await IrohNode.open(addon, crypto.getRandomValues(new Uint8Array(32)));135 const listener = startTunnelListener(dialer, node.ticket());136 try {137 const { status, body } = await fetchHealth(`http://127.0.0.1:${listener.port}`);138 assertEquals(status, 200);139 assertEquals((body as { status: string }).status, "healthy");140 assert(listener.stats().length >= 0, "tunnel stats observable");141 } finally {142 await listener.close();143 await dialer.close();144 await node.stop();145 }146 },147});