| 1 | import test from "node:test" ; |
| 2 | import assert from "node:assert/strict" ; |
| 3 | import fs from "node:fs" ; |
| 4 | import vm from "node:vm" ; |
| 5 | |
| 6 | const source = fs.readFileSync("tinyclient/components.js" , "utf8" ); |
| 7 | const avatarSource = source.slice(source.indexOf(" class NostrAvatar " ), source.indexOf(" // PageLink" )); |
| 8 | const key = "a" .repeat(64); |
| 9 | const location = {href: "https://relay.example/r/demo/chat" , origin: "https://relay.example" }; |
| 10 | |
| 11 | function 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 | } |
| 36 | |
| 37 | test("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 | }); |
| 49 | |
| 50 | test("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 | }); |
| 62 | |
| 63 | test("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 | }); |
| 70 | |
| 71 | test("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 | }); |
| 81 | |
| 82 | test("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 | }); |
| 96 | |
| 97 | test("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 | }); |
| 104 | |
| 105 | test("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 | }); |