Open mode: Jazz serves it on the public URL. Ticket mode: unauthenticated at the top level and under a ticket base; revocation 401s the ticket path while the top level stays 200. Peer mode: carried over the iroh tunnel. A dead store degrades to 502, never a hang.
- commit
- 23e82a1305713535a22a12bd136e8722658b79f0
- parent
- 151d856137a1fc3f50ff527a5a4c72c7f521c76a
- author
- Dami <dami.etoile@gmail.com>
| A | tests/ | 147 | ++++++++++++++++++++++++++++++ |
@@ -0,0 +1,147 @@
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.7+8+import { assert, assertEquals } from "@std/assert";9+import { createSyncNode } from "../src/node.ts";10+import { startGate } from "../src/gate.ts";11+import { AppTicketStore, decodeAppTicket } from "../src/appticket.ts";12+import { loadIrohAddon } from "../src/native/addon.ts";13+import { IrohNode } from "../src/iroh/node.ts";14+import { startTunnelListener } from "../src/tunnel.ts";15+import { resolveIrohLib } from "../src/native/loader.ts";16+17+async 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+}28+29+Deno.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+});50+51+Deno.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");69+70+ // 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");76+77+ // 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+});91+92+Deno.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+});120+121+Deno.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+});