clone

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

feat/connection-observability
lofi-node / tests / appticket_test.ts
appticket_test.ts 7.3K
1import { assert, assertEquals, assertRejects, assertStrictEquals } from "@std/assert";2import {3 type AppTicketRecord,4 AppTicketStore,5 decodeAppTicket,6 encodeAppTicket,7 generateSecret,8 looksLikeAppTicket,9 SECRET_LENGTH,10} from "../src/appticket.ts";1112Deno.test("secret shape: 43 chars of url-path-safe base64url", () => {13 for (let i = 0; i < 20; i++) {14 const secret = generateSecret();15 assertEquals(secret.length, SECRET_LENGTH);16 assert(/^[A-Za-z0-9_-]+$/.test(secret), `charset: ${secret}`);17 }18});1920Deno.test("app ticket round-trips including optional fields", () => {21 const secret = generateSecret();22 const ticket = {23 v: 1 as const,24 appId: crypto.randomUUID(),25 url: `http://192.168.1.10:4802/t/${secret}`,26 label: "phone",27 node: "endpointabc123",28 };29 const decoded = decodeAppTicket(encodeAppTicket(ticket));30 assertEquals(decoded, ticket);31 assertEquals(decodeAppTicket(` ${encodeAppTicket(ticket)}\n`), ticket, "tolerates whitespace");32});3334Deno.test("decode rejects malformed tickets without throwing", () => {35 assertStrictEquals(decodeAppTicket(""), null);36 assertStrictEquals(decodeAppTicket("lofisync1."), null);37 assertStrictEquals(38 decodeAppTicket("endpointabcdef"),39 null,40 "pairing tickets are not app tickets",41 );42 const bad = { v: 1, appId: "x", url: "http://h/wrong-path" };43 assertStrictEquals(44 decodeAppTicket("lofisync1." + btoa(JSON.stringify(bad))),45 null,46 "url must embed /t/<secret>",47 );48 const wsUrl = { v: 1, appId: "x", url: `ws://h/t/${generateSecret()}` };49 assertStrictEquals(50 decodeAppTicket("lofisync1." + btoa(JSON.stringify(wsUrl))),51 null,52 "url must be http(s)",53 );54});5556Deno.test("looksLikeAppTicket distinguishes ticket kinds", () => {57 assert(looksLikeAppTicket("lofisync1.abcd"));58 assert(!looksLikeAppTicket("endpointabcd"));59});6061Deno.test("store: issue → verify → revoke → verify, with persistence", async () => {62 const dir = await Deno.makeTempDir();63 try {64 const store = await AppTicketStore.load(dir);65 const { record, secret } = await store.issue("laptop");66 assertEquals(record.label, "laptop");67 assertEquals(record.id, record.secretHash.slice(0, 12));6869 const valid = await store.verify(secret);70 assert(valid.status === "valid" && valid.record.id === record.id);71 assertEquals((await store.verify(generateSecret())).status, "unknown");7273 await store.revoke(record.id);74 assertEquals((await store.verify(secret)).status, "revoked");7576 // A fresh store over the same dir sees the persisted state.77 const reloaded = await AppTicketStore.load(dir);78 assertEquals((await reloaded.verify(secret)).status, "revoked");79 assertEquals((await reloaded.list()).length, 1);80 } finally {81 await Deno.remove(dir, { recursive: true });82 }83});8485Deno.test("store: external file change is picked up (CLI → daemon hot-reload)", async () => {86 const dir = await Deno.makeTempDir();87 try {88 const daemonStore = await AppTicketStore.load(dir);89 assertEquals((await daemonStore.list()).length, 0);9091 // A second store instance models the CLI process writing the file.92 const cliStore = await AppTicketStore.load(dir);93 const { record, secret } = await cliStore.issue("issued-by-cli");9495 // Force the throttle window to expire, then verify through the daemon.96 await new Promise((r) => setTimeout(r, 1100));97 const seen = await daemonStore.verify(secret);98 assert(99 seen.status === "valid" && seen.record.id === record.id,100 "daemon sees CLI-issued ticket",101 );102103 await cliStore.revoke(record.id);104 await new Promise((r) => setTimeout(r, 1100));105 assertEquals(106 (await daemonStore.verify(secret)).status,107 "revoked",108 "daemon sees CLI revocation",109 );110 } finally {111 await Deno.remove(dir, { recursive: true });112 }113});114115Deno.test("store: parent revocation cascades to derived tickets", async () => {116 const store = await AppTicketStore.load();117 const { record: parent, secret: parentSecret } = await store.issue("laptop-admin", "provision");118 const { record: child, secret: childSecret } = await store.issue(119 "laptop-admin (sync)",120 "sync",121 parent.id,122 );123 assertEquals(child.parentId, parent.id);124 assertEquals((await store.verify(parentSecret)).status, "valid");125 assertEquals((await store.verify(childSecret)).status, "valid");126127 await store.revoke(parent.id);128 assertEquals((await store.verify(parentSecret)).status, "revoked");129 assertEquals(130 (await store.verify(childSecret)).status,131 "revoked",132 "derived ticket rejects exactly like a revoked one",133 );134});135136Deno.test("store: issuing against an unknown or revoked parent throws", async () => {137 const store = await AppTicketStore.load();138 await assertRejects(() => store.issue("x", "sync", "no-such-id"), Error, "unknown or revoked");139 const { record } = await store.issue("gone", "provision");140 await store.revoke(record.id);141 await assertRejects(() => store.issue("x", "sync", record.id), Error, "unknown or revoked");142});143144Deno.test("store: parentId persists in v1 tickets.json; a removed parent kills the child", async () => {145 const dir = await Deno.makeTempDir();146 try {147 const store = await AppTicketStore.load(dir);148 const { record: parent } = await store.issue("admin", "provision");149 const { secret: childSecret } = await store.issue("derived", "sync", parent.id);150151 const path = `${dir}/tickets.json`;152 const file = JSON.parse(await Deno.readTextFile(path)) as {153 v: number;154 tickets: AppTicketRecord[];155 };156 assertEquals(file.v, 1, "file format stays v1 — parentId is an additive optional field");157 assertEquals(file.tickets.find((t) => t.parentId)?.parentId, parent.id);158159 const reloaded = await AppTicketStore.load(dir);160 assertEquals((await reloaded.verify(childSecret)).status, "valid");161162 // Remove the parent record entirely: the orphaned child must reject like163 // a revoked ticket.164 file.tickets = file.tickets.filter((t) => t.id !== parent.id);165 await Deno.writeTextFile(path, JSON.stringify(file, null, 2) + "\n");166 const orphaned = await AppTicketStore.load(dir);167 assertEquals((await orphaned.verify(childSecret)).status, "revoked");168 } finally {169 await Deno.remove(dir, { recursive: true });170 }171});172173Deno.test("store: in-memory fallback works without a dataDir", async () => {174 const store = await AppTicketStore.load();175 const { secret } = await store.issue();176 assertEquals((await store.verify(secret)).status, "valid");177});178179Deno.test("cross-repo fixtures: valid entries decode as expected, invalid reject", async () => {180 const fixtures = JSON.parse(181 await Deno.readTextFile(new URL("../docs/fixtures/app-ticket-fixtures.json", import.meta.url)),182 ) as {183 valid: { name: string; ticket: string; expect: Record<string, string> }[];184 invalid: { name: string; ticket: string }[];185 };186 for (const entry of fixtures.valid) {187 const decoded = decodeAppTicket(entry.ticket);188 assert(decoded !== null, `${entry.name} decodes`);189 assertEquals(decoded.appId, entry.expect.appId, entry.name);190 assertEquals(decoded.scope ?? "sync", entry.expect.scope, entry.name);191 assertEquals(decoded.url, entry.expect.url, entry.name);192 }193 for (const entry of fixtures.invalid) {194 assertStrictEquals(decodeAppTicket(entry.ticket), null, `${entry.name} rejects`);195 }196});