clone

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

release/0.5.0
lofi-node / tests / gate_test.ts
gate_test.ts 14.5K
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 const live = gate.stats().find((entry) => entry.ticketId === record.id);175 assertEquals(live?.connections, 1);176177 const closed = new Promise<CloseEvent>((resolve) =>178 ws.addEventListener("close", (ev) => resolve(ev))179 );180 await store.revoke(record.id);181 const ev = await Promise.race([182 closed,183 new Promise<never>((_r, reject) =>184 setTimeout(() => reject(new Error("no revocation close within 6s")), 6000)185 ),186 ]);187 assertEquals(ev.code, CLOSE_TICKET_REVOKED);188 // The socket is gone; the last-seen stamp for the ticket remains.189 const after = gate.stats().find((entry) => entry.ticketId === record.id);190 assertEquals(after?.connections, 0);191 }),192});193194Deno.test({195 name: "gate: open mode proxies without ticket prefixes",196 sanitizeOps: false,197 sanitizeResources: false,198 fn: () =>199 withGate("open", async ({ gateUrl }) => {200 const res = await fetch(`${gateUrl}/apps/abc/schemas?x=1`);201 assertEquals((await res.json()).path, "/apps/abc/schemas?x=1");202 }),203});204205Deno.test({206 name: "gate: sync scope on admin paths → 401 with the invalid-ticket shape",207 sanitizeOps: false,208 sanitizeResources: false,209 fn: () =>210 withGate("ticket", async ({ gateUrl, store }) => {211 const { secret } = await store.issue("sync-only"); // default scope212 const res = await fetch(`${gateUrl}/t/${secret}/apps/stub-app/admin/schemas`, {213 method: "POST",214 body: "{}",215 });216 assertEquals(res.status, 401);217 assertEquals(await res.json(), { error: "invalid_ticket" }, "same shape as invalid");218 // Non-admin paths still work for the same ticket.219 const ok = await fetch(`${gateUrl}/t/${secret}/apps/abc/anything`);220 assertEquals(ok.status, 200);221 await ok.body?.cancel();222 }),223});224225Deno.test({226 name: "gate: provision scope injects the node admin secret, strips inbound",227 sanitizeOps: false,228 sanitizeResources: false,229 fn: () =>230 withGate("ticket", async ({ gateUrl, store }) => {231 const { secret } = await store.issue("admin", "provision");232 // The stub echoes the admin header it received; the client sends a FAKE233 // one which must be replaced by the node's own.234 const res = await fetch(`${gateUrl}/t/${secret}/apps/abc/echo`, {235 headers: { "X-Jazz-Admin-Secret": "client-supplied-fake" },236 });237 assertEquals((await res.json()).admin, "node-admin-secret", "injected, not forwarded");238 // Sync scope: inbound admin header is stripped, nothing injected.239 const { secret: syncSecret } = await store.issue("plain");240 const res2 = await fetch(`${gateUrl}/t/${syncSecret}/apps/abc/echo`, {241 headers: { "X-Jazz-Admin-Secret": "client-supplied-fake" },242 });243 assertEquals((await res2.json()).admin, null, "stripped for sync scope");244 }),245});246247Deno.test({248 name: "gate: derive-sync-ticket mints a linked sync ticket that syncs through the gate",249 sanitizeOps: false,250 sanitizeResources: false,251 fn: () =>252 withGate("ticket", async ({ gateUrl, store }) => {253 const { record: parent, secret } = await store.issue("laptop-admin", "provision");254 const res = await fetch(`${gateUrl}/t/${secret}/derive-sync-ticket`, {255 method: "POST",256 body: JSON.stringify({ label: "laptop" }),257 });258 assertEquals(res.status, 200);259 const body = await res.json() as { v: number; id: string; ticket: string };260 assertEquals(body.v, 1);261 const derived = decodeAppTicket(body.ticket);262 assert(derived !== null, "response carries a decodable lofisync1. ticket");263 assertEquals(derived.appId, "stub-app");264 assertEquals(derived.scope ?? "sync", "sync", "derived tickets are sync-scoped");265 assertEquals(derived.label, "laptop");266 assert(derived.url.startsWith(`${gateUrl}/t/`), "url uses the gate base");267 const record = (await store.list()).find((t) => t.id === body.id);268 assertEquals(record?.parentId, parent.id, "derived record links its parent");269270 // The derived ticket round-trips through the gate for sync traffic…271 const ok = await fetch(`${derived.url}/apps/abc/echo`, {272 headers: { "X-Jazz-Admin-Secret": "client-supplied-fake" },273 });274 assertEquals(ok.status, 200);275 assertEquals((await ok.json()).admin, null, "sync scope: no admin injection");276 // …but stays locked out of admin paths and further derivation.277 const admin = await fetch(`${derived.url}/apps/stub-app/admin/schemas`, {278 method: "POST",279 body: "{}",280 });281 assertEquals(admin.status, 401);282 assertEquals(await admin.json(), { error: "invalid_ticket" });283284 // Default label derives from the parent's label.285 const res2 = await fetch(`${gateUrl}/t/${secret}/derive-sync-ticket`, { method: "POST" });286 assertEquals(res2.status, 200);287 const derived2 = decodeAppTicket((await res2.json() as { ticket: string }).ticket);288 assertEquals(derived2?.label, "laptop-admin (sync)");289 }),290});291292Deno.test({293 name: "gate: derive-sync-ticket 401s sync scope and unknown secrets with the invalid shape",294 sanitizeOps: false,295 sanitizeResources: false,296 fn: () =>297 withGate("ticket", async ({ gateUrl, store }) => {298 const { secret } = await store.issue("sync-only"); // default scope299 const res = await fetch(`${gateUrl}/t/${secret}/derive-sync-ticket`, { method: "POST" });300 assertEquals(res.status, 401);301 assertEquals(await res.json(), { error: "invalid_ticket" }, "same shape as invalid");302 const bogus = "A".repeat(43);303 const unknown = await fetch(`${gateUrl}/t/${bogus}/derive-sync-ticket`, { method: "POST" });304 assertEquals(unknown.status, 401);305 assertEquals(await unknown.json(), { error: "invalid_ticket" });306 }),307});308309Deno.test({310 name: "gate: revoking the parent cascades — derived ticket 401s, live sockets close 4001",311 sanitizeOps: false,312 sanitizeResources: false,313 fn: () =>314 withGate("ticket", async ({ gateUrl, store }) => {315 const { record: parent, secret } = await store.issue("admin", "provision");316 const res = await fetch(`${gateUrl}/t/${secret}/derive-sync-ticket`, { method: "POST" });317 const { ticket } = await res.json() as { ticket: string };318 const derived = decodeAppTicket(ticket);319 assert(derived !== null);320 const before = await fetch(`${derived.url}/apps/abc/echo`);321 assertEquals(before.status, 200);322 await before.body?.cancel();323324 const ws = new WebSocket(`${derived.url.replace("http", "ws")}/apps/abc/ws`);325 await new Promise<void>((resolve, reject) => {326 ws.addEventListener("open", () => resolve());327 ws.addEventListener("close", () => reject(new Error("closed before open")));328 });329 const closed = new Promise<CloseEvent>((resolve) =>330 ws.addEventListener("close", (ev) => resolve(ev))331 );332333 await store.revoke(parent.id);334 const rejected = await fetch(`${derived.url}/apps/abc/echo`);335 assertEquals(rejected.status, 401, "derived ticket dies with its parent");336 assertEquals(await rejected.json(), { error: "invalid_ticket" }, "same shape as revoked");337 const ev = await Promise.race([338 closed,339 new Promise<never>((_r, reject) =>340 setTimeout(() => reject(new Error("no cascade close within 6s")), 6000)341 ),342 ]);343 assertEquals(ev.code, CLOSE_TICKET_REVOKED, "sweep closes derived sockets too");344 }),345});346347Deno.test({348 name: "gate: store-status is metadata-only and any-scope",349 sanitizeOps: false,350 sanitizeResources: false,351 fn: () =>352 withGate("ticket", async ({ gateUrl, store }) => {353 const { secret } = await store.issue("sync-only"); // sync scope suffices354 const res = await fetch(`${gateUrl}/t/${secret}/store-status`);355 assertEquals(res.status, 200);356 assertEquals(await res.json(), {357 v: 1,358 appId: "stub-app",359 schema: { deployed: true, headHash: "hash-union", permissionsHead: "perm-head-3" },360 });361 // No ticket → 404 (path shape), bogus → 401.362 const bare = await fetch(`${gateUrl}/store-status`);363 assertEquals(bare.status, 404);364 await bare.body?.cancel();365 }),366});