clone

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

main
tinyrelay / scripts / nostr-avatar.test.mjs
nostr-avatar.test.mjs 5.6K
1import test from "node:test";2import assert from "node:assert/strict";3import fs from "node:fs";4import vm from "node:vm";56const source = fs.readFileSync("tinyclient/components.js", "utf8");7const avatarSource = source.slice(source.indexOf(" class NostrAvatar "), source.indexOf(" // PageLink"));8const key = "a".repeat(64);9const location = {href: "https://relay.example/r/demo/chat", origin: "https://relay.example"};1011function setup(userPromise, tag = "nostr-avatar", children = []) {12 const images = [], requests = [];13 const document = {14 createElement: name => {15 const node = {nodeName: name, children: [], setAttribute(name, value) { this[name] = value; }, append(...nodes) { this.children.push(...nodes); }, addEventListener(event, callback) { this.handlers ??= {}; this.handlers[event] = callback; }};16 if (name === "img") images.push(node);17 return node;18 }19 };20 const window = {tinyNames: {load: request => { requests.push(request); return typeof userPromise === "function" ? userPromise() : userPromise; }}};21 const HTMLElement = class {22 constructor() { this.attributes = {}; this.children = children; }23 getAttribute(name) { return this.attributes[name] ?? null; }24 setAttribute(name, value) { this.attributes[name] = value; }25 replaceChildren(...nodes) { this.children = nodes; }26 append(...nodes) { this.children.push(...nodes); }27 querySelector(name) { return this.children.find(child => child.nodeName === name) || null; }28 };29 const el = name => document.createElement(name);30 const tiny = {localPath: path => "/r/demo" + path};31 const Constructor = vm.runInNewContext(`${avatarSource}\n${tag === "room-avatar" ? "RoomAvatar" : "NostrAvatar"}`, {HTMLElement, window, document, URL, Promise, String, setTimeout, el, tiny, location});32 const component = new Constructor();33 component.attributes.pubkey = key;34 return {component, images, requests};35}3637test("loads a profile image with a tenant scoped generated fallback", async () => {38 const {component, requests} = setup(Promise.resolve({image: "https://cdn.example/avatar.png"}));39 component.connectedCallback();40 await new Promise(resolve => setTimeout(resolve, 0));41 const image = component.children[0];42 assert.equal(image.src, "https://cdn.example/avatar.png");43 assert.equal(image.loading, "lazy");44 assert.equal(image.referrerPolicy, "no-referrer");45 assert.equal(image.alt, "");46 assert.equal(requests[0].pubkey, key);47 assert.equal(requests[0].refresh, true);48});4950test("uses generated avatars for missing or unsafe pictures", async () => {51 for (const image of [undefined, "javascript:alert(1)", "/avatar.png"]) {52 const {component} = setup(Promise.resolve(image === undefined ? {} : {image}));53 component.connectedCallback();54 await new Promise(resolve => setTimeout(resolve, 0));55 assert.equal(component.children[0].src, "/r/demo/avatars/v1/" + key + ".svg");56 }57 const {component} = setup(Promise.resolve({}));58 component.attributes.pubkey = "bad";59 component.connectedCallback();60 assert.equal(component.children[0].src, "/r/demo/avatars/v1/" + "0".repeat(64) + ".svg");61});6263test("restores the generated fallback after an image load failure", async () => {64 const {component, images} = setup(Promise.resolve({image: "https://cdn.example/avatar.png"}));65 component.connectedCallback();66 await new Promise(resolve => setTimeout(resolve, 0));67 images.at(-1).handlers.error();68 assert.equal(component.children[0].src, "/r/demo/avatars/v1/" + key + ".svg");69});7071test("ignores stale profile results after the pubkey changes", async () => {72 let resolve, calls = 0;73 const {component} = setup(() => calls++ === 0 ? new Promise(done => { resolve = done; }) : Promise.resolve({}));74 component.connectedCallback();75 component.attributes.pubkey = "b".repeat(64);76 component.attributeChangedCallback();77 resolve({image: "https://cdn.example/avatar.png"});78 await new Promise(resolve => setTimeout(resolve, 0));79 assert.equal(component.children[0].src, "/r/demo/avatars/v1/" + "b".repeat(64) + ".svg");80});8182test("room avatars prefer safe pictures and reject unsafe or cross origin fallbacks", () => {83 const fallback = "/r/demo/avatars/v1/" + key + ".svg";84 const {component} = setup(undefined, "room-avatar");85 component.attributes.picture = "https://cdn.example/room.png";86 component.attributes.fallback = fallback;87 component.connectedCallback();88 assert.equal(component.children[0].src, "https://cdn.example/room.png");89 component.attributes.picture = "javascript:alert(1)";90 component.attributeChangedCallback();91 assert.equal(component.children[0].src, "https://relay.example/r/demo/avatars/v1/" + key + ".svg");92 component.attributes.fallback = "https://evil.example/avatars/v1/" + key + ".svg";93 component.attributeChangedCallback();94 assert.equal(component.children.length, 0);95});9697test("room avatars accept the root fallback path", () => {98 const fallback = "/avatars/v1/" + key + ".svg";99 const {component} = setup(undefined, "room-avatar");100 component.attributes.fallback = fallback;101 component.connectedCallback();102 assert.equal(component.children[0].src, "https://relay.example" + fallback);103});104105test("room avatars restore their fallback after a picture fails", () => {106 const fallback = "/r/demo/avatars/v1/" + key + ".svg";107 const {component, images} = setup(undefined, "room-avatar");108 component.attributes.picture = "https://cdn.example/room.png";109 component.attributes.fallback = fallback;110 component.connectedCallback();111 images[0].handlers.error();112 assert.equal(component.children[0].src, "https://relay.example" + fallback);113 assert.equal(images.at(-1).handlers?.error, undefined);114});