clone

a self-hosted multitenant nostr relay with sites, file storage, git hosting, and inbox and outbox delivery

feat/standalone-relay
tinyrelay / scripts / profile-form.test.mjs
profile-form.test.mjs 5.0K
1// The profile form merges the fields it shows over the profile the relay2// holds, signs a kind 0, publishes it here and then to the listed relays.3import test from "node:test";4import assert from "node:assert/strict";5import fs from "node:fs";6import vm from "node:vm";7import { finalizeEvent, generateSecretKey, getPublicKey, verifyEvent } from "nostr-tools/pure";8import { sharedSigning } from "./test-signing.mjs";910const source = fs.readFileSync("tinyclient/components.js", "utf8");11const formSource = source.slice(source.indexOf(" // ProfileForm publishes"), source.indexOf(" // AgentGrant signs"));12const plain = value => JSON.parse(JSON.stringify(value));1314function 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}3738test("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});4546test("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});5152test("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});6869test("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});