1// Gate behavior against a stub upstream (no jazz, no iroh): routing,2// secret verification, prefix stripping, WS re-origination, revocation.34import { assertEquals } from "@std/assert";5import { AppTicketStore } 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: store-status is metadata-only and any-scope",246 sanitizeOps: false,247 sanitizeResources: false,248 fn: () =>249 withGate("ticket", async ({ gateUrl, store }) => {250 const { secret } = await store.issue("sync-only"); // sync scope suffices251 const res = await fetch(`${gateUrl}/t/${secret}/store-status`);252 assertEquals(res.status, 200);253 assertEquals(await res.json(), {254 v: 1,255 appId: "stub-app",256 schema: { deployed: true, headHash: "hash-union", permissionsHead: "perm-head-3" },257 });258 // No ticket → 404 (path shape), bogus → 401.259 const bare = await fetch(`${gateUrl}/store-status`);260 assertEquals(bare.status, 404);261 await bare.body?.cancel();262 }),263});