| 1 | // The profile form merges the fields it shows over the profile the relay |
| 2 | // holds, signs a kind 0, publishes it here and then to the listed relays. |
| 3 | import test from "node:test" ; |
| 4 | import assert from "node:assert/strict" ; |
| 5 | import fs from "node:fs" ; |
| 6 | import vm from "node:vm" ; |
| 7 | import { finalizeEvent, generateSecretKey, getPublicKey, verifyEvent } from "nostr-tools/pure" ; |
| 8 | import { sharedSigning } from "./test-signing.mjs" ; |
| 9 | |
| 10 | const source = fs.readFileSync("tinyclient/components.js" , "utf8" ); |
| 11 | const formSource = source.slice(source.indexOf(" // ProfileForm publishes" ), source.indexOf(" // AgentGrant signs" )); |
| 12 | const plain = value => JSON.parse(JSON.stringify(value)); |
| 13 | |
| 14 | function setup({attributes = {}, result, pool, noSigner = false, otherKey = false} = {}) { |
| 15 | const secret = generateSecretKey(), pubkey = getPublicKey(secret); |
| 16 | const sent = [], published = [], reports = []; |
| 17 | class FormElement { |
| 18 | getAttribute(name) { return attributes[name] ?? (name === "pubkey" ? (otherKey ? "f" .repeat(64) : pubkey) : null); } |
| 19 | report(text, error) { reports.push({text, error: Boolean(error)}); } |
| 20 | } |
| 21 | const store = {}; |
| 22 | const localStorage = {getItem: key => store[key] ?? null, setItem: (key, value) => { store[key] = value; }}; |
| 23 | const window = { |
| 24 | nostr: noSigner ? undefined : {signEvent: async event => finalizeEvent(structuredClone(event), secret)}, |
| 25 | NostrSigner: {verifyEvent, SimplePool: class { publish(relays, event) { published.push({relays, event}); return relays.map(relay => (pool?.[relay] === "fail" ? Promise.reject(Error("closed" )) : Promise.resolve("ok" ))); } close() {} }}, |
| 26 | tinyNames: {cacheKey: "tiny:names:v1" } |
| 27 | }; |
| 28 | const tiny = { |
| 29 | util: {relayURL: value => (/^wss?:\/\/[a-z0-9.-]+(?::\d+)?\/?$/i.test(String(value).trim()) ? String(value).trim().replace(/\/$/, "" ) : null)}, |
| 30 | signedFetch: async (path, method, body) => { sent.push({path, method, event: JSON.parse(body)}); return Response.json(result ?? {accepted: true}); } |
| 31 | }; |
| 32 | tiny.signing = sharedSigning(window, tiny); |
| 33 | const Constructor = vm.runInNewContext(`${formSource}\nProfileForm` , {FormElement, window, tiny, localStorage, JSON, Date, Error, Promise, setTimeout, Object, Array, String}); |
| 34 | const form = values => ({elements: Object.fromEntries(Object.entries(values).map(([key, value]) => [key, {value}]))}); |
| 35 | return {Constructor, component: new Constructor(), form, sent, published, reports, pubkey, store}; |
| 36 | } |
| 37 | |
| 38 | test("content merges shown fields over the held profile and validates links" , () => { |
| 39 | const {Constructor} = setup(); |
| 40 | const merged = Constructor.content({name: " dami " , display_name: "" , about: "hi" , picture: "https://x/p.png" }, JSON.stringify({name: "old" , display_name: "Old Name" , website: "https://kept.example" , custom: {deep: true}})); |
| 41 | assert.deepEqual(plain(merged), {name: "dami" , about: "hi" , picture: "https://x/p.png" , website: "https://kept.example" , custom: {deep: true}}); |
| 42 | assert.deepEqual(plain(Constructor.content({name: "x" }, "not json" )), {name: "x" }); |
| 43 | assert.throws(() => Constructor.content({picture: "javascript:alert(1)" }, "{}" ), /http or https/); |
| 44 | }); |
| 45 | |
| 46 | test("relays parse from any separator, dedupe and cap at twelve" , () => { |
| 47 | const {Constructor} = setup(); |
| 48 | assert.deepEqual(plain(Constructor.relays("wss://a.example/\nwss://b.example, wss://a.example ftp://no.example" )), ["wss://a.example" , "wss://b.example" ]); |
| 49 | assert.throws(() => Constructor.relays(Array.from({length: 13}, (_, i) => "wss://r" + i + ".example" ).join(" " )), /at most 12/); |
| 50 | }); |
| 51 | |
| 52 | test("submit publishes here first, then to the listed relays, and forgets the cached name" , async () => { |
| 53 | const s = setup({pool: {"wss://b.example" : "fail" }}); |
| 54 | s.store["tiny:names:v1" ] = JSON.stringify({[s.pubkey]: {event: null, at: 1}, other: {at: 2}}); |
| 55 | await s.component.submit(s.form({name: "dami" , about: "" , relays: "wss://a.example\nwss://b.example" })); |
| 56 | assert.equal(s.sent.length, 1); |
| 57 | assert.equal(s.sent[0].path, "/events" ); |
| 58 | const event = s.sent[0].event; |
| 59 | assert.equal(event.kind, 0); |
| 60 | assert.deepEqual(plain(JSON.parse(event.content)), {name: "dami" }); |
| 61 | assert.equal(verifyEvent(event), true); |
| 62 | assert.deepEqual(plain(s.published[0].relays), ["wss://a.example" , "wss://b.example" ]); |
| 63 | assert.equal(s.published[0].event.id, event.id); |
| 64 | const last = s.reports.at(-1).text.split("\n" ); |
| 65 | assert.deepEqual(last, ["Published here." , "wss://a.example: ok" , "wss://b.example: failed, closed" ]); |
| 66 | assert.deepEqual(Object.keys(JSON.parse(s.store["tiny:names:v1" ])), ["other" ]); |
| 67 | }); |
| 68 | |
| 69 | test("submit refuses without a signer, with another key, or when the relay rejects" , async () => { |
| 70 | await assert.rejects(setup({noSigner: true}).component.submit(setup().form({name: "x" })), /Connect a signer/); |
| 71 | const other = setup({otherKey: true}); |
| 72 | await assert.rejects(other.component.submit(other.form({name: "x" })), /different key/); |
| 73 | assert.equal(other.sent.length, 0); |
| 74 | const refused = setup({result: {accepted: false, message: "blocked: no" }}); |
| 75 | await assert.rejects(refused.component.submit(refused.form({name: "x" })), /blocked: no/); |
| 76 | assert.equal(refused.published.length, 0); |
| 77 | }); |