clone

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

feat/write-verdict-foundations
lofi-node / tests / gate_test.ts
gate_test.ts 14.3K
1// Gate behavior against a stub upstream (no jazz, no iroh): routing,2// secret verification, prefix stripping, WS re-origination, revocation.34import { assert, assertEquals } from "@std/assert";5import { AppTicketStore, decodeAppTicket } from "../src/appticket.ts";6import { CLOSE_TICKET_REVOKED, startGate } from "../src/gate.ts";78/** Stub upstream: echoes path+search+selected headers over HTTP; echoes9 * messages and reports path/protocol over WS. */10function startStubUpstream() {11 const server = Deno.serve({ hostname: "127.0.0.1", port: 0, onListen: () => {} }, (req) => {12 const url = new URL(req.url);13 // Jazz-shaped catalogue routes for store-status (admin header required,14 // proving the gate injected the node's secret).15 if (url.pathname === "/apps/stub-app/schemas") {16 if (req.headers.get("x-jazz-admin-secret") !== "node-admin-secret") {17 return new Response("admin required", { status: 401 });18 }19 // Deliberately unordered `hashes`; head derives from publishedAt.20 return Response.json({21 hashes: ["hash-union", "hash-a"],22 schemas: [23 { hash: "hash-union", publishedAt: 2000 },24 { hash: "hash-a", publishedAt: 1000 },25 ],26 });27 }28 if (url.pathname === "/apps/stub-app/admin/permissions/head") {29 if (req.headers.get("x-jazz-admin-secret") !== "node-admin-secret") {30 return new Response("admin required", { status: 401 });31 }32 return Response.json({ head: "perm-head-3" });33 }34 if (req.headers.get("upgrade")?.toLowerCase() === "websocket") {35 const protocol = req.headers.get("sec-websocket-protocol")?.split(",")[0].trim();36 const { socket, response } = Deno.upgradeWebSocket(req, protocol ? { protocol } : {});37 socket.addEventListener("open", () => {38 socket.send(39 JSON.stringify({ hello: url.pathname + url.search, protocol: protocol ?? null }),40 );41 });42 socket.addEventListener("message", (ev) => socket.send(ev.data));43 return response;44 }45 return new Response(46 JSON.stringify({47 path: url.pathname + url.search,48 admin: req.headers.get("x-jazz-admin-secret"),49 method: req.method,50 }),51 { headers: { "content-type": "application/json" } },52 );53 });54 return {55 url: `http://127.0.0.1:${(server.addr as Deno.NetAddr).port}`,56 close: () => server.shutdown(),57 };58}5960async function withGate(61 mode: "open" | "ticket",62 fn: (ctx: {63 gateUrl: string;64 store: AppTicketStore;65 gate: ReturnType<typeof startGate>;66 }) => Promise<void>,67) {68 const upstream = startStubUpstream();69 const store = await AppTicketStore.load();70 const gate = startGate({71 port: 0,72 hostname: "127.0.0.1",73 target: () => upstream.url,74 mode,75 store,76 appId: "stub-app",77 adminSecret: "node-admin-secret",78 });79 try {80 await fn({ gateUrl: `http://127.0.0.1:${gate.port}`, store, gate });81 } finally {82 await gate.close();83 await upstream.close();84 }85}8687Deno.test({88 name: "gate: valid secret strips prefix, preserves search, streams headers",89 sanitizeOps: false,90 sanitizeResources: false,91 fn: () =>92 withGate("ticket", async ({ gateUrl, store }) => {93 const { secret } = await store.issue("t", "provision");94 const res = await fetch(95 `${gateUrl}/t/${secret}/apps/abc/admin/schema-connectivity?appId=xyz`,96 { headers: { "X-Jazz-Admin-Secret": "adm" } },97 );98 assertEquals(res.status, 200);99 const body = await res.json();100 assertEquals(body.path, "/apps/abc/admin/schema-connectivity?appId=xyz");101 assertEquals(body.admin, "node-admin-secret", "node injects its own admin secret");102 }),103});104105Deno.test({106 name: "gate: /health is open; everything else 401/404",107 sanitizeOps: false,108 sanitizeResources: false,109 fn: () =>110 withGate("ticket", async ({ gateUrl, store }) => {111 assertEquals((await (await fetch(`${gateUrl}/health`)).json()).path, "/health");112 assertEquals((await fetch(`${gateUrl}/apps/abc/ws`)).status, 404, "no ticket prefix → 404");113 const bogus = "A".repeat(43);114 assertEquals((await fetch(`${gateUrl}/t/${bogus}/apps/abc`)).status, 401);115 const { record, secret } = await store.issue();116 await store.revoke(record.id);117 assertEquals((await fetch(`${gateUrl}/t/${secret}/apps/abc`)).status, 401, "revoked → 401");118 }),119});120121Deno.test({122 name: "gate: WS round-trip with subprotocol echo and path stripping",123 sanitizeOps: false,124 sanitizeResources: false,125 fn: () =>126 withGate("ticket", async ({ gateUrl, store }) => {127 const { secret } = await store.issue();128 const ws = new WebSocket(129 `${gateUrl.replace("http", "ws")}/t/${secret}/apps/abc/ws?key=1`,130 ["jazz-sync"],131 );132 const messages: string[] = [];133 const done = new Promise<void>((resolve, reject) => {134 const timer = setTimeout(() => reject(new Error("ws timeout")), 8000);135 ws.addEventListener("message", (ev) => {136 messages.push(ev.data as string);137 if (messages.length === 2) {138 clearTimeout(timer);139 resolve();140 }141 });142 ws.addEventListener("error", () => {143 clearTimeout(timer);144 reject(new Error("ws error"));145 });146 });147 await new Promise<void>((resolve, reject) => {148 ws.addEventListener("open", () => resolve());149 ws.addEventListener("close", () => reject(new Error("closed before open")));150 });151 assertEquals(ws.protocol, "jazz-sync", "subprotocol negotiated through the gate");152 ws.send("ping through gate");153 await done;154 const hello = JSON.parse(messages[0]);155 assertEquals(hello.hello, "/apps/abc/ws?key=1", "upstream saw stripped path + search");156 assertEquals(hello.protocol, "jazz-sync");157 assertEquals(messages[1], "ping through gate", "echo round-trips");158 ws.close(1000);159 }),160});161162Deno.test({163 name: "gate: revocation closes live sockets with 4001",164 sanitizeOps: false,165 sanitizeResources: false,166 fn: () =>167 withGate("ticket", async ({ gateUrl, store, gate }) => {168 const { record, secret } = await store.issue("victim");169 const ws = new WebSocket(`${gateUrl.replace("http", "ws")}/t/${secret}/apps/abc/ws`);170 await new Promise<void>((resolve, reject) => {171 ws.addEventListener("open", () => resolve());172 ws.addEventListener("close", () => reject(new Error("closed before open")));173 });174 assertEquals(gate.stats(), [{ ticketId: record.id, connections: 1 }]);175176 const closed = new Promise<CloseEvent>((resolve) =>177 ws.addEventListener("close", (ev) => resolve(ev))178 );179 await store.revoke(record.id);180 const ev = await Promise.race([181 closed,182 new Promise<never>((_r, reject) =>183 setTimeout(() => reject(new Error("no revocation close within 6s")), 6000)184 ),185 ]);186 assertEquals(ev.code, CLOSE_TICKET_REVOKED);187 assertEquals(gate.stats(), []);188 }),189});190191Deno.test({192 name: "gate: open mode proxies without ticket prefixes",193 sanitizeOps: false,194 sanitizeResources: false,195 fn: () =>196 withGate("open", async ({ gateUrl }) => {197 const res = await fetch(`${gateUrl}/apps/abc/schemas?x=1`);198 assertEquals((await res.json()).path, "/apps/abc/schemas?x=1");199 }),200});201202Deno.test({203 name: "gate: sync scope on admin paths → 401 with the invalid-ticket shape",204 sanitizeOps: false,205 sanitizeResources: false,206 fn: () =>207 withGate("ticket", async ({ gateUrl, store }) => {208 const { secret } = await store.issue("sync-only"); // default scope209 const res = await fetch(`${gateUrl}/t/${secret}/apps/stub-app/admin/schemas`, {210 method: "POST",211 body: "{}",212 });213 assertEquals(res.status, 401);214 assertEquals(await res.json(), { error: "invalid_ticket" }, "same shape as invalid");215 // Non-admin paths still work for the same ticket.216 const ok = await fetch(`${gateUrl}/t/${secret}/apps/abc/anything`);217 assertEquals(ok.status, 200);218 await ok.body?.cancel();219 }),220});221222Deno.test({223 name: "gate: provision scope injects the node admin secret, strips inbound",224 sanitizeOps: false,225 sanitizeResources: false,226 fn: () =>227 withGate("ticket", async ({ gateUrl, store }) => {228 const { secret } = await store.issue("admin", "provision");229 // The stub echoes the admin header it received; the client sends a FAKE230 // one which must be replaced by the node's own.231 const res = await fetch(`${gateUrl}/t/${secret}/apps/abc/echo`, {232 headers: { "X-Jazz-Admin-Secret": "client-supplied-fake" },233 });234 assertEquals((await res.json()).admin, "node-admin-secret", "injected, not forwarded");235 // Sync scope: inbound admin header is stripped, nothing injected.236 const { secret: syncSecret } = await store.issue("plain");237 const res2 = await fetch(`${gateUrl}/t/${syncSecret}/apps/abc/echo`, {238 headers: { "X-Jazz-Admin-Secret": "client-supplied-fake" },239 });240 assertEquals((await res2.json()).admin, null, "stripped for sync scope");241 }),242});243244Deno.test({245 name: "gate: derive-sync-ticket mints a linked sync ticket that syncs through the gate",246 sanitizeOps: false,247 sanitizeResources: false,248 fn: () =>249 withGate("ticket", async ({ gateUrl, store }) => {250 const { record: parent, secret } = await store.issue("laptop-admin", "provision");251 const res = await fetch(`${gateUrl}/t/${secret}/derive-sync-ticket`, {252 method: "POST",253 body: JSON.stringify({ label: "laptop" }),254 });255 assertEquals(res.status, 200);256 const body = await res.json() as { v: number; id: string; ticket: string };257 assertEquals(body.v, 1);258 const derived = decodeAppTicket(body.ticket);259 assert(derived !== null, "response carries a decodable lofisync1. ticket");260 assertEquals(derived.appId, "stub-app");261 assertEquals(derived.scope ?? "sync", "sync", "derived tickets are sync-scoped");262 assertEquals(derived.label, "laptop");263 assert(derived.url.startsWith(`${gateUrl}/t/`), "url uses the gate base");264 const record = (await store.list()).find((t) => t.id === body.id);265 assertEquals(record?.parentId, parent.id, "derived record links its parent");266267 // The derived ticket round-trips through the gate for sync traffic…268 const ok = await fetch(`${derived.url}/apps/abc/echo`, {269 headers: { "X-Jazz-Admin-Secret": "client-supplied-fake" },270 });271 assertEquals(ok.status, 200);272 assertEquals((await ok.json()).admin, null, "sync scope: no admin injection");273 // …but stays locked out of admin paths and further derivation.274 const admin = await fetch(`${derived.url}/apps/stub-app/admin/schemas`, {275 method: "POST",276 body: "{}",277 });278 assertEquals(admin.status, 401);279 assertEquals(await admin.json(), { error: "invalid_ticket" });280281 // Default label derives from the parent's label.282 const res2 = await fetch(`${gateUrl}/t/${secret}/derive-sync-ticket`, { method: "POST" });283 assertEquals(res2.status, 200);284 const derived2 = decodeAppTicket((await res2.json() as { ticket: string }).ticket);285 assertEquals(derived2?.label, "laptop-admin (sync)");286 }),287});288289Deno.test({290 name: "gate: derive-sync-ticket 401s sync scope and unknown secrets with the invalid shape",291 sanitizeOps: false,292 sanitizeResources: false,293 fn: () =>294 withGate("ticket", async ({ gateUrl, store }) => {295 const { secret } = await store.issue("sync-only"); // default scope296 const res = await fetch(`${gateUrl}/t/${secret}/derive-sync-ticket`, { method: "POST" });297 assertEquals(res.status, 401);298 assertEquals(await res.json(), { error: "invalid_ticket" }, "same shape as invalid");299 const bogus = "A".repeat(43);300 const unknown = await fetch(`${gateUrl}/t/${bogus}/derive-sync-ticket`, { method: "POST" });301 assertEquals(unknown.status, 401);302 assertEquals(await unknown.json(), { error: "invalid_ticket" });303 }),304});305306Deno.test({307 name: "gate: revoking the parent cascades — derived ticket 401s, live sockets close 4001",308 sanitizeOps: false,309 sanitizeResources: false,310 fn: () =>311 withGate("ticket", async ({ gateUrl, store }) => {312 const { record: parent, secret } = await store.issue("admin", "provision");313 const res = await fetch(`${gateUrl}/t/${secret}/derive-sync-ticket`, { method: "POST" });314 const { ticket } = await res.json() as { ticket: string };315 const derived = decodeAppTicket(ticket);316 assert(derived !== null);317 const before = await fetch(`${derived.url}/apps/abc/echo`);318 assertEquals(before.status, 200);319 await before.body?.cancel();320321 const ws = new WebSocket(`${derived.url.replace("http", "ws")}/apps/abc/ws`);322 await new Promise<void>((resolve, reject) => {323 ws.addEventListener("open", () => resolve());324 ws.addEventListener("close", () => reject(new Error("closed before open")));325 });326 const closed = new Promise<CloseEvent>((resolve) =>327 ws.addEventListener("close", (ev) => resolve(ev))328 );329330 await store.revoke(parent.id);331 const rejected = await fetch(`${derived.url}/apps/abc/echo`);332 assertEquals(rejected.status, 401, "derived ticket dies with its parent");333 assertEquals(await rejected.json(), { error: "invalid_ticket" }, "same shape as revoked");334 const ev = await Promise.race([335 closed,336 new Promise<never>((_r, reject) =>337 setTimeout(() => reject(new Error("no cascade close within 6s")), 6000)338 ),339 ]);340 assertEquals(ev.code, CLOSE_TICKET_REVOKED, "sweep closes derived sockets too");341 }),342});343344Deno.test({345 name: "gate: store-status is metadata-only and any-scope",346 sanitizeOps: false,347 sanitizeResources: false,348 fn: () =>349 withGate("ticket", async ({ gateUrl, store }) => {350 const { secret } = await store.issue("sync-only"); // sync scope suffices351 const res = await fetch(`${gateUrl}/t/${secret}/store-status`);352 assertEquals(res.status, 200);353 assertEquals(await res.json(), {354 v: 1,355 appId: "stub-app",356 schema: { deployed: true, headHash: "hash-union", permissionsHead: "perm-head-3" },357 });358 // No ticket → 404 (path shape), bogus → 401.359 const bare = await fetch(`${gateUrl}/store-status`);360 assertEquals(bare.status, 404);361 await bare.body?.cancel();362 }),363});