1import { assert, assertEquals, assertStrictEquals } from "@std/assert";2import {3 AppTicketStore,4 decodeAppTicket,5 encodeAppTicket,6 generateSecret,7 looksLikeAppTicket,8 SECRET_LENGTH,9} from "../src/appticket.ts";1011Deno.test("secret shape: 43 chars of url-path-safe base64url", () => {12 for (let i = 0; i < 20; i++) {13 const secret = generateSecret();14 assertEquals(secret.length, SECRET_LENGTH);15 assert(/^[A-Za-z0-9_-]+$/.test(secret), `charset: ${secret}`);16 }17});1819Deno.test("app ticket round-trips including optional fields", () => {20 const secret = generateSecret();21 const ticket = {22 v: 1 as const,23 appId: crypto.randomUUID(),24 url: `http://192.168.1.10:4802/t/${secret}`,25 label: "phone",26 node: "endpointabc123",27 };28 const decoded = decodeAppTicket(encodeAppTicket(ticket));29 assertEquals(decoded, ticket);30 assertEquals(decodeAppTicket(` ${encodeAppTicket(ticket)}\n`), ticket, "tolerates whitespace");31});3233Deno.test("decode rejects malformed tickets without throwing", () => {34 assertStrictEquals(decodeAppTicket(""), null);35 assertStrictEquals(decodeAppTicket("lofisync1."), null);36 assertStrictEquals(37 decodeAppTicket("endpointabcdef"),38 null,39 "pairing tickets are not app tickets",40 );41 const bad = { v: 1, appId: "x", url: "http://h/wrong-path" };42 assertStrictEquals(43 decodeAppTicket("lofisync1." + btoa(JSON.stringify(bad))),44 null,45 "url must embed /t/<secret>",46 );47 const wsUrl = { v: 1, appId: "x", url: `ws://h/t/${generateSecret()}` };48 assertStrictEquals(49 decodeAppTicket("lofisync1." + btoa(JSON.stringify(wsUrl))),50 null,51 "url must be http(s)",52 );53});5455Deno.test("looksLikeAppTicket distinguishes ticket kinds", () => {56 assert(looksLikeAppTicket("lofisync1.abcd"));57 assert(!looksLikeAppTicket("endpointabcd"));58});5960Deno.test("store: issue → verify → revoke → verify, with persistence", async () => {61 const dir = await Deno.makeTempDir();62 try {63 const store = await AppTicketStore.load(dir);64 const { record, secret } = await store.issue("laptop");65 assertEquals(record.label, "laptop");66 assertEquals(record.id, record.secretHash.slice(0, 12));6768 const valid = await store.verify(secret);69 assert(valid.status === "valid" && valid.record.id === record.id);70 assertEquals((await store.verify(generateSecret())).status, "unknown");7172 await store.revoke(record.id);73 assertEquals((await store.verify(secret)).status, "revoked");7475 // A fresh store over the same dir sees the persisted state.76 const reloaded = await AppTicketStore.load(dir);77 assertEquals((await reloaded.verify(secret)).status, "revoked");78 assertEquals((await reloaded.list()).length, 1);79 } finally {80 await Deno.remove(dir, { recursive: true });81 }82});8384Deno.test("store: external file change is picked up (CLI → daemon hot-reload)", async () => {85 const dir = await Deno.makeTempDir();86 try {87 const daemonStore = await AppTicketStore.load(dir);88 assertEquals((await daemonStore.list()).length, 0);8990 // A second store instance models the CLI process writing the file.91 const cliStore = await AppTicketStore.load(dir);92 const { record, secret } = await cliStore.issue("issued-by-cli");9394 // Force the throttle window to expire, then verify through the daemon.95 await new Promise((r) => setTimeout(r, 1100));96 const seen = await daemonStore.verify(secret);97 assert(98 seen.status === "valid" && seen.record.id === record.id,99 "daemon sees CLI-issued ticket",100 );101102 await cliStore.revoke(record.id);103 await new Promise((r) => setTimeout(r, 1100));104 assertEquals(105 (await daemonStore.verify(secret)).status,106 "revoked",107 "daemon sees CLI revocation",108 );109 } finally {110 await Deno.remove(dir, { recursive: true });111 }112});113114Deno.test("store: in-memory fallback works without a dataDir", async () => {115 const store = await AppTicketStore.load();116 const { secret } = await store.issue();117 assertEquals((await store.verify(secret)).status, "valid");118});119120Deno.test("cross-repo fixtures: valid entries decode as expected, invalid reject", async () => {121 const fixtures = JSON.parse(122 await Deno.readTextFile(new URL("../docs/fixtures/app-ticket-fixtures.json", import.meta.url)),123 ) as {124 valid: { name: string; ticket: string; expect: Record<string, string> }[];125 invalid: { name: string; ticket: string }[];126 };127 for (const entry of fixtures.valid) {128 const decoded = decodeAppTicket(entry.ticket);129 assert(decoded !== null, `${entry.name} decodes`);130 assertEquals(decoded.appId, entry.expect.appId, entry.name);131 assertEquals(decoded.scope ?? "sync", entry.expect.scope, entry.name);132 assertEquals(decoded.url, entry.expect.url, entry.name);133 }134 for (const entry of fixtures.invalid) {135 assertStrictEquals(decodeAppTicket(entry.ticket), null, `${entry.name} rejects`);136 }137});