| 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 | import {webcrypto,createHash} from "node:crypto" ; |
| 6 | |
| 7 | const source=fs.readFileSync("tinyclient/components.js" ,"utf8" ); |
| 8 | const code=source.slice(source.indexOf(" // Social follows" ),source.indexOf(" // Social end." )); |
| 9 | const author="a" .repeat(64),parentAuthor="b" .repeat(64),root="c" .repeat(64),parent="d" .repeat(64),hint="wss://relay.test" ; |
| 10 | function setup(attrs={},values={},options={}) { |
| 11 | const attributes={actor:author,relay:hint,...attrs},store=options.store||new Map(),calls=[],signed=[]; |
| 12 | let now=1789000000,fail=options.fail||0; |
| 13 | class FormElement {getAttribute(n){return attributes[n]??null;}report(message){this.message=message;}} |
| 14 | class HTMLElement {} |
| 15 | const fields=Object.fromEntries(Object.entries(values).map(([name,value])=>[name, typeof value === "object" ? value : {value}])); |
| 16 | const form={elements:fields,reset(){for(const field of Object.values(fields))if("value" in field)field.value="" ;}}; |
| 17 | const tiny={root:"" ,localPath:path=>path,sha256hex:async data=>createHash("sha256" ).update(data).digest("hex" ), |
| 18 | signing:{signEvent:async unsigned=>{signed.push(unsigned);return {...unsigned,id:"e" .repeat(64),pubkey:author};}}, |
| 19 | signedFetch:async(path,method,body)=>{calls.push({path,method,body});if(path === "/social/preview" ) return {ok:true,json:async()=>({html:"<p>Preview</p>" })};if(path.startsWith("/upload" )){return {ok:true,json:async()=>({sha256:await tiny.sha256hex(body),size:body.length,url:"https://relay.test/file.jpg" })};}if(fail-->0)return {ok:false,json:async()=>({error:"interrupted" })};return {ok:true,json:async()=>({accepted:true})};}, |
| 20 | navigate:async href=>{tiny.href=href;} |
| 21 | }; |
| 22 | const ctx={FormElement,HTMLElement,tiny,URL,Uint8Array,WeakMap,crypto:webcrypto,location:{origin:"https://relay.test" ,pathname:"/social" ,href:"https://relay.test/social" },localStorage:{getItem:k=>store.get(k)||null,setItem:(k,v)=>store.set(k,v),removeItem:k=>store.delete(k)},isHex64:v=>typeof v==="string" &&/^[0-9a-f]{64}$/.test(v),unixNow:()=>now,el:()=>({setAttribute(){}}),fetch:options.fetch}; |
| 23 | const classes=vm.runInNewContext(code+"\n({SocialCompose,SocialReaction,socialDraftKey})" ,ctx); |
| 24 | const component=new classes.SocialCompose();component.form=form; |
| 25 | return {component,form,calls,signed,tiny,store,attrs:attributes,classes,advance(){now+=90;},tags(){return JSON.parse(JSON.stringify(signed.at(-1).tags));}}; |
| 26 | } |
| 27 | |
| 28 | test("notes keep plaintext and topics normalized" ,async()=>{ |
| 29 | const s=setup({mode:"note" },{content:"**literal**\nTwo lines" ,tags:"#Nostr, nostr, NEWS" }); |
| 30 | await s.component.submit(s.form); |
| 31 | assert.equal(s.signed[0].kind,1);assert.equal(s.signed[0].content,"**literal**\nTwo lines" ); |
| 32 | assert.deepEqual(s.tags(),[["t" ,"nostr" ],["t" ,"news" ]]); |
| 33 | }); |
| 34 | |
| 35 | test("new articles with identical titles have distinct persistent addresses" ,async()=>{ |
| 36 | const s=setup({mode:"article" },{title:"Same title" ,content:"# Markdown" ,summary:"Subtitle" ,cover:"https://cdn.test/a.png" }); |
| 37 | const other=setup({mode:"article" },{title:"Same title" ,content:"Another story" }); |
| 38 | await s.component.submit(s.form);await other.component.submit(other.form); |
| 39 | const tags=s.tags();assert.notEqual(tags.find(t=>t[0]==="d" )[1],other.tags().find(t=>t[0]==="d" )[1]); |
| 40 | assert.equal(s.signed[0].kind,30023);assert.ok(tags.some(t=>t[0]==="title" &&t[1]==="Same title" ));assert.ok(tags.some(t=>t[0]==="published_at" )); |
| 41 | }); |
| 42 | |
| 43 | test("NIP-10 top-level reply uses one root marker and inherited p tags" ,async()=>{ |
| 44 | const s=setup({mode:"comment" ,target:root,"target-kind" :"1" ,"target-pubkey" :author,"target-tags" :JSON.stringify([["p" ,parentAuthor]])},{content:"Reply" }); |
| 45 | await s.component.submit(s.form); |
| 46 | assert.deepEqual(s.tags(),[["e" ,root,hint,"root" ,author],["p" ,author,hint],["p" ,parentAuthor,hint]]); |
| 47 | }); |
| 48 | |
| 49 | test("NIP-10 nested replies distinguish root and direct parent" ,async()=>{ |
| 50 | const s=setup({mode:"comment" ,target:parent,"target-kind" :"1" ,"target-pubkey" :parentAuthor,root,"root-pubkey" :author},{content:"Nested" }); |
| 51 | await s.component.submit(s.form);assert.deepEqual(s.tags(),[["e" ,root,hint,"root" ,author],["e" ,parent,hint,"reply" ,parentAuthor],["p" ,author,hint],["p" ,parentAuthor,hint]]); |
| 52 | }); |
| 53 | |
| 54 | test("NIP-22 article comment has address scope and address plus id parent" ,async()=>{ |
| 55 | const address=`30023:${author}:essay:part-one` ; |
| 56 | const s=setup({mode:"comment" ,target:root,"target-kind" :"30023" ,"target-pubkey" :author,address},{content:"Article comment" }); |
| 57 | await s.component.submit(s.form);assert.equal(s.signed[0].kind,1111); |
| 58 | assert.deepEqual(s.tags(),[["A" ,address,hint],["K" ,"30023" ],["P" ,author,hint],["a" ,address,hint],["e" ,root,hint,author],["k" ,"30023" ],["p" ,author,hint]]); |
| 59 | }); |
| 60 | |
| 61 | test("NIP-22 nested comment keeps root scope but direct parent's author and kind" ,async()=>{ |
| 62 | const address=`30023:${author}:essay` ; |
| 63 | const s=setup({mode:"comment" ,target:parent,"target-kind" :"1111" ,"target-pubkey" :parentAuthor,root,"root-pubkey" :author,address},{content:"Nested article comment" }); |
| 64 | await s.component.submit(s.form);assert.equal(s.signed[0].kind,1111); |
| 65 | assert.deepEqual(s.tags(),[["A" ,address,hint],["K" ,"30023" ],["P" ,author,hint],["e" ,parent,hint,parentAuthor],["k" ,"1111" ],["p" ,parentAuthor,hint]]); |
| 66 | }); |
| 67 | |
| 68 | test("relay failure retains signed event across a later retry" ,async()=>{ |
| 69 | const s=setup({mode:"note" },{content:"Retry" },{fail:1}); |
| 70 | await assert.rejects(s.component.submit(s.form),/interrupted/);s.advance();await s.component.submit(s.form); |
| 71 | assert.equal(s.signed.length,1);assert.equal(s.calls.length,2);assert.equal(s.calls[0].body,s.calls[1].body); |
| 72 | }); |
| 73 | |
| 74 | test("editing a failed post requires a fresh signature" ,async()=>{ |
| 75 | const s=setup({mode:"note" },{content:"Before" },{fail:1}); |
| 76 | await assert.rejects(s.component.submit(s.form));s.form.elements.content.value="After" ;await s.component.submit(s.form);assert.equal(s.signed.length,2); |
| 77 | }); |
| 78 | |
| 79 | test("duplicate submits publish only once" ,async()=>{ |
| 80 | const s=setup({mode:"note" },{content:"One" });await Promise.all([s.component.submit(s.form),s.component.submit(s.form)]);assert.equal(s.signed.length,1);assert.equal(s.calls.length,1); |
| 81 | }); |
| 82 | |
| 83 | test("drafts remain scoped to account and article and clear when erased" ,()=>{ |
| 84 | const s=setup({mode:"article" },{title:"Story" ,content:"private draft" });s.component.saveDraft(); |
| 85 | const other=setup({mode:"article" ,actor:parentAuthor},{title:"" ,content:"" },{store:s.store});other.component.restoreDraft();assert.equal(other.form.elements.content.value,"" ); |
| 86 | const same=setup({mode:"article" },{title:"" ,content:"" },{store:s.store});same.component.restoreDraft();assert.equal(same.form.elements.content.value,"private draft" ); |
| 87 | s.form.elements.title.value="" ;s.form.elements.content.value="" ;s.component.saveDraft();assert.equal(s.store.size,0); |
| 88 | }); |
| 89 | |
| 90 | test("media is uploaded once on retry and notes embed raw URLs with imeta" ,async()=>{ |
| 91 | const file={name:"photo.jpg" ,type:"image/jpeg" ,size:3,arrayBuffer:async()=>new Uint8Array([1,2,3]).buffer}; |
| 92 | const s=setup({mode:"note" },{content:"Photo" ,alt:"A mountain" , "media-file" :{files:[file]}},{fail:1}); |
| 93 | await assert.rejects(s.component.submit(s.form),/interrupted/);await s.component.submit(s.form); |
| 94 | assert.equal(s.calls.filter(c=>c.path.startsWith("/upload" )).length,1); |
| 95 | assert.equal(s.signed[0].content,"Photo\n\nhttps://relay.test/file.jpg" ); |
| 96 | const imeta=s.tags().find(t=>t[0]==="imeta" );assert.ok(imeta.includes("m image/jpeg" ));assert.ok(imeta.includes("alt A mountain" ));assert.ok(imeta.includes("size 3" )); |
| 97 | }); |
| 98 | |
| 99 | test("unsafe media and cover URLs are rejected before publishing" ,async()=>{ |
| 100 | for(const values of [{content:"note" ,media:"javascript:alert(1)" },{content:"note" ,media:"https://secret@example.com/a" }]){const s=setup({mode:"note" },values);await assert.rejects(s.component.submit(s.form));assert.equal(s.signed.length,0);} |
| 101 | const s=setup({mode:"article" },{title:"Title" ,content:"Body" ,cover:"data:image/svg+xml,bad" });await assert.rejects(s.component.submit(s.form));assert.equal(s.signed.length,0); |
| 102 | }); |
| 103 | |
| 104 | |
| 105 | test("draft previews use authenticated requests without publishing" , async()=>{ |
| 106 | const s=setup({mode:"article" },{content:"**Preview**" }); |
| 107 | const output={hidden:true,innerHTML:"" };s.component.querySelector=()=>output; |
| 108 | await s.component.preview(); |
| 109 | assert.equal(s.calls[0].path,"/social/preview" );assert.equal(s.calls[0].method,"POST" ); |
| 110 | assert.deepEqual(JSON.parse(s.calls[0].body),{content:"**Preview**" ,kind:30023}); |
| 111 | assert.equal(s.signed.length,0);assert.equal(output.hidden,false);assert.equal(output.innerHTML,"<p>Preview</p>" ); |
| 112 | }); |
| 113 | |
| 114 | test("legacy replies omit an unknown optional root author rather than misattribute it" , async()=>{ |
| 115 | const s=setup({mode:"comment" ,target:parent,"target-kind" :"1" ,"target-pubkey" :parentAuthor,root,"root-pubkey" :"" },{content:"Legacy reply" }); |
| 116 | await s.component.submit(s.form); |
| 117 | assert.deepEqual(s.tags(),[["e" ,root,hint,"root" ],["e" ,parent,hint,"reply" ,parentAuthor],["p" ,parentAuthor,hint]]); |
| 118 | }); |