tinyrelayfile
tinyrelay/scripts/nostr-avatar.test.mjs/

scripts/nostr-avatar.test.mjs raw
1import test from "node:test";
2import assert from "node:assert/strict";
3import fs from "node:fs";
4import vm from "node:vm";
5
6const 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"};
10
11function 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
37test("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
50test("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
63test("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
71test("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
82test("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
97test("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
105test("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});

About

ownerc01503e8035f
visibilitypublic
headd730679639bf

Maintainers

  • c01503e8035f owner
  • ba1b5beed5b5 agent hermes

Clone

  • https://012.run/npub1cq2s86qrtadf36eqq3epm38wmhdynrrcwxf84eldk3f94dkv7ttsrulrz5/tinyrelay.git

Refs