1// The Connect fold's pure parts (connections.ts): a text's placeholders2// filled for a viewer, a library document read or refused, an owner's list3// read with warnings, and the library itself. The door and the settings4// behind it are test/object/connections.test.ts.5import { describe, it, expect } from "vitest";6import { CONNECTION_TEMPLATES, FORMAT, MAX_CONNECTIONS, fill, parseConnectionTemplate, parseConnections, type Values } from "../../src/connections.ts";78const values: Values = {9 relay: { url: "wss://club.bind.ws", host: "club.bind.ws", web: "https://club.bind.ws", name: "club", domain: "bind.ws", hex: "ab".repeat(32), npub: "npub1relay", nprofile: "nprofile1relay", naddr: "naddr1room" },10 owner: { hex: "cd".repeat(32), npub: "npub1owner", nprofile: "nprofile1owner" },11 user: null,12};1314// A library document that every relay can fill in.15const good = {16 format: FORMAT,17 title: "Feed",18 about: "The relay as a feed.",19 app: "Jumble",20 where: "web",21 icon: "notes",22 links: [23 { label: "Open", href: "https://jumble.social/?r={relay:url|enc}" },24 { label: "Copy relay URL", copy: "{relay:url}" },25 ],26};2728describe("fill", () => {29 it("resolves every source, percent-encodes with |enc, and reads inputs by name", () => {30 const r = fill("{relay:web}/p/{owner:npub}?r={relay:url|enc}&repo={input:repo}", values, { repo: "mine" });31 expect(r).toEqual({ text: "https://club.bind.ws/p/npub1owner?r=wss%3A%2F%2Fclub.bind.ws&repo=mine", missing: [] });32 expect(fill("plain text", values, {})).toEqual({ text: "plain text", missing: [] });33 expect(fill("{relay:naddr}", values, {}).text).toBe("naddr1room");34 expect(fill("{owner:hex|enc}", values, {}).text).toBe("cd".repeat(32));35 });3637 it("lists what nobody is signed in for as user:npub and leaves the text without it", () => {38 expect(fill("nostr:{user:npub}", values, {})).toEqual({ text: "nostr:", missing: ["user:npub"] });39 const signed = { ...values, user: { hex: "ef".repeat(32), npub: "npub1viewer", nprofile: "nprofile1viewer" } };40 expect(fill("nostr:{user:npub}", signed, {})).toEqual({ text: "nostr:npub1viewer", missing: [] });41 });4243 it("lists an empty owner, an unset input and an unknown field as missing rather than filling a blank", () => {44 const unclaimed = { ...values, owner: { hex: "", npub: "", nprofile: "" } };45 expect(fill("https://primal.net/p/{owner:nprofile}", unclaimed, {})).toEqual({ text: "https://primal.net/p/", missing: ["owner:nprofile"] });46 expect(fill("{relay:url}/{input:repo}.git", values, {}).missing).toEqual(["input:repo"]);47 expect(fill("{relay:url}/{input:repo}.git", values, { repo: "" }).missing).toEqual(["input:repo"]);48 expect(fill("{owner:npub} {user:hex}", unclaimed, {}).missing).toEqual(["owner:npub", "user:hex"]);49 });50});5152describe("parseConnectionTemplate", () => {53 it("accepts a good document, defaults the visibility to public and the QR to the first href", () => {54 const t = parseConnectionTemplate("feed", good);55 expect(typeof t).toBe("object");56 if (typeof t === "string") return;57 expect(t.name).toBe("feed");58 expect(t.title).toBe("Feed");59 expect(t.visibility).toBe("public");60 expect(t.icon).toBe("notes");61 expect(t.feature).toBeUndefined();62 expect(t.inputs).toEqual([]);63 expect(t.links).toEqual(good.links);64 expect(t.qr).toBe("https://jumble.social/?r={relay:url|enc}");65 });6667 it("takes the QR the document names, an icon default, a feature and declared inputs", () => {68 const t = parseConnectionTemplate("repos", { ...good, icon: undefined, feature: "grasp", visibility: "auth", qr: "nostr:{owner:nprofile}", inputs: [{ name: "repo", label: "Repository", placeholder: "name", default: "notes" }], links: [{ label: "Clone", copy: "{relay:web}/{user:npub}/{input:repo}.git" }] });69 expect(typeof t).toBe("object");70 if (typeof t === "string") return;71 expect(t.icon).toBe("app");72 expect(t.feature).toBe("grasp");73 expect(t.visibility).toBe("auth");74 expect(t.qr).toBe("nostr:{owner:nprofile}");75 expect(t.inputs).toEqual([{ name: "repo", label: "Repository", placeholder: "name", default: "notes", pattern: "" }]);76 expect(t.links).toEqual([{ label: "Clone", copy: "{relay:web}/{user:npub}/{input:repo}.git" }]);77 });7879 it("refuses what is not a template, another format, and a document short of a title, an about or an app", () => {80 expect(parseConnectionTemplate("x", null)).toBe("not a connection template");81 expect(parseConnectionTemplate("x", [])).toBe("not a connection template");82 expect(parseConnectionTemplate("x", { ...good, format: "bind.ws/connection-template/2" })).toBe("format must be " + FORMAT);83 expect(parseConnectionTemplate("x", { ...good, about: " " })).toBe("needs a title, an about and an app");84 });8586 it("refuses a link that is not https or nostr, so an owner cannot smuggle a scheme in", () => {87 const r = parseConnectionTemplate("x", { ...good, links: [{ label: "Run", href: "javascript:alert(1)" }] });88 expect(r).toMatch(/^link Run must be https:\/\/ or nostr:/);89 expect(parseConnectionTemplate("x", { ...good, links: [{ label: "Data", href: "data:text/html,hi" }] })).toMatch(/must be https/);90 // Plain http is for a resolved {relay:web} on a local host, not for a template.91 expect(parseConnectionTemplate("x", { ...good, links: [{ label: "Plain", href: "http://insecure.example/" }] })).toBe("link Plain must be https:// or nostr:");92 expect(parseConnectionTemplate("x", { ...good, links: [{ label: "Copy", copy: "javascript:alert(1)" }] })).not.toBeTypeOf("string");93 });9495 it("refuses a placeholder the relay cannot fill and an input the document does not declare", () => {96 expect(parseConnectionTemplate("x", { ...good, links: [{ label: "Open", href: "https://a.example/{relay:nonsense}" }] })).toMatch(/^link Open names \{relay:nonsense\}, which is not one of relay:url/);97 expect(parseConnectionTemplate("x", { ...good, links: [{ label: "Open", href: "https://a.example/{input:x}" }] })).toBe("link Open names {input:x} but declares no such input");98 expect(parseConnectionTemplate("x", { ...good, qr: "nostr:{user:nonsense}" })).toMatch(/^qr names \{user:nonsense\}/);99 expect(parseConnectionTemplate("x", { ...good, inputs: [{ name: "repo", label: "Repository" }], links: [{ label: "Open", href: "https://a.example/{input:branch}" }] })).toBe("link Open names {input:branch} but declares no such input");100 });101102 it("refuses a link with both an href and a copy text, with neither, without a label, and a list of none", () => {103 expect(parseConnectionTemplate("x", { ...good, links: [{ label: "Both", href: "https://a.example/", copy: "text" }] })).toBe("link Both needs an href or a copy text, not both");104 expect(parseConnectionTemplate("x", { ...good, links: [{ label: "Neither" }] })).toBe("link Neither needs an href or a copy text, not both");105 expect(parseConnectionTemplate("x", { ...good, links: [{ href: "https://a.example/" }] })).toBe("a link needs a label");106 expect(parseConnectionTemplate("x", { ...good, links: [] })).toMatch(/^links must be a list of 1 to/);107 });108109 it("takes an input's pattern and refuses one that does not compile", () => {110 const t = parseConnectionTemplate("x", { ...good, inputs: [{ name: "repo", label: "Repository", pattern: "^[a-z0-9-]{1,64}$" }], links: [{ label: "Clone", copy: "git clone '{relay:web}/{input:repo}.git'" }] });111 expect(typeof t).toBe("object");112 if (typeof t === "string") return;113 expect(t.inputs[0].pattern).toBe("^[a-z0-9-]{1,64}$");114 expect(parseConnectionTemplate("x", { ...good, inputs: [{ name: "repo", label: "Repository", pattern: "([" }] })).toBe("input repo has a pattern that does not compile");115 });116117 it("refuses an unknown icon, feature or visibility and a badly named input", () => {118 expect(parseConnectionTemplate("x", { ...good, icon: "rocket" })).toMatch(/^icon must be one of/);119 expect(parseConnectionTemplate("x", { ...good, feature: "teleport" })).toMatch(/^feature must be one of/);120 expect(parseConnectionTemplate("x", { ...good, visibility: "friends" })).toBe("visibility must be one of public, auth, members, owner");121 expect(parseConnectionTemplate("x", { ...good, inputs: [{ name: "Repo Name", label: "Repository" }] })).toBe("an input name is lowercase letters, digits, dash and underscore");122 expect(parseConnectionTemplate("x", { ...good, inputs: [{ name: "repo", label: "One" }, { name: "repo", label: "Two" }] })).toBe("input repo is declared twice");123 });124});125126describe("parseConnections", () => {127 const withInput = CONNECTION_TEMPLATES.find((t) => t.inputs.length > 0);128129 it("keeps the order given, fills the template's default visibility and trims the words", () => {130 const r = parseConnections([{ template: "group", visibility: "members", title: " The room ", about: " Where we talk. " }, { template: "notes" }]);131 expect(r).toEqual({ list: [{ template: "group", visibility: "members", title: "The room", about: "Where we talk." }, { template: "notes", visibility: "public" }], warnings: [] });132 });133134 it("drops an unknown template with a warning and keeps the rest in order", () => {135 const r = parseConnections([{ template: "notes" }, { template: "nope" }, { template: "group" }, {}, null]);136 expect(typeof r).toBe("object");137 if (typeof r === "string") return;138 expect(r.list.map((c) => c.template)).toEqual(["notes", "group"]);139 expect(r.warnings).toEqual(["connections[1]: no connection template named nope", "connections[3]: no connection template named (none)", "connections[4]: no connection template named (none)"]);140 });141142 it("keeps a bad visibility or an undeclared input as a warning, and the entry with the template's own values", () => {143 const r = parseConnections([{ template: "notes", visibility: "friends", inputs: { x: "y" } }, { template: "group", inputs: "repo" }]);144 expect(typeof r).toBe("object");145 if (typeof r === "string") return;146 expect(r.list).toEqual([{ template: "notes", visibility: "public" }, { template: "group", visibility: "public" }]);147 expect(r.warnings).toEqual(["connections[0].visibility: must be one of public, auth, members, owner", "connections[0].inputs.x: notes has no such input", "connections[1].inputs: must be an object of input name to value"]);148 });149150 it.skipIf(!withInput)("trims an input's value, drops a blank one and warns about one that is not a string", () => {151 const t = withInput!;152 const name = t.inputs[0].name;153 const r = parseConnections([{ template: t.name, inputs: { [name]: " mine " } }, { template: t.name, inputs: { [name]: " " } }, { template: t.name, inputs: { [name]: 7 } }]);154 expect(typeof r).toBe("object");155 if (typeof r === "string") return;156 expect(r.list[0].inputs).toEqual({ [name]: "mine" });157 expect(r.list[1].inputs).toBeUndefined();158 expect(r.list[2].inputs).toBeUndefined();159 expect(r.warnings).toEqual([`connections[2].inputs.${name}: must be a string`]);160 });161162 const patterned = CONNECTION_TEMPLATES.find((t) => t.inputs.some((i) => i.pattern));163 it.skipIf(!patterned)("keeps an input to its pattern, so a value cannot carry a quote or a slash into a command or a path", () => {164 const t = patterned!;165 const i = t.inputs.find((x) => x.pattern)!;166 const r = parseConnections([{ template: t.name, inputs: { [i.name]: "kid's-project" } }, { template: t.name, inputs: { [i.name]: "a/b" } }, { template: t.name, inputs: { [i.name]: "my-project" } }]);167 expect(typeof r).toBe("object");168 if (typeof r === "string") return;169 expect(r.list[0].inputs).toBeUndefined();170 expect(r.list[1].inputs).toBeUndefined();171 expect(r.list[2].inputs).toEqual({ [i.name]: "my-project" });172 expect(r.warnings).toEqual([`connections[0].inputs.${i.name}: must match ${i.pattern}`, `connections[1].inputs.${i.name}: must match ${i.pattern}`]);173 });174175 it("caps the list at 24 with a warning naming the first entry past it", () => {176 const r = parseConnections(Array.from({ length: MAX_CONNECTIONS + 2 }, () => ({ template: "notes" })));177 expect(typeof r).toBe("object");178 if (typeof r === "string") return;179 expect(r.list.length).toBe(MAX_CONNECTIONS);180 expect(r.warnings).toEqual([`connections[${MAX_CONNECTIONS}]: at most ${MAX_CONNECTIONS} connections`]);181 });182183 it("is the reason when the list is not one, under the label the caller gave", () => {184 expect(parseConnections({ template: "notes" })).toBe("invalid: connections must be a list");185 expect(parseConnections("notes", "shortcuts")).toBe("invalid: shortcuts must be a list");186 const r = parseConnections([{ template: "nope" }], "shortcuts");187 expect(typeof r === "string" ? [] : r.warnings).toEqual(["shortcuts[0]: no connection template named nope"]);188 });189});190191describe("the library", () => {192 it("has notes, find-me and group, and every template has a unique name, an about and at least one link", () => {193 const names = CONNECTION_TEMPLATES.map((t) => t.name);194 expect(names).toContain("notes");195 expect(names).toContain("find-me");196 expect(names).toContain("group");197 expect(new Set(names).size).toBe(names.length);198 for (const t of CONNECTION_TEMPLATES) {199 expect(t.links.length, t.name).toBeGreaterThan(0);200 expect(t.about.length, t.name).toBeGreaterThan(0);201 expect(t.title.length, t.name).toBeGreaterThan(0);202 expect(t.app.length, t.name).toBeGreaterThan(0);203 for (const l of t.links) expect(Boolean(l.href) !== Boolean(l.copy), `${t.name}: ${l.label}`).toBe(true);204 }205 });206});