| 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 | const source=fs.readFileSync('tinyclient/grant-request.js' ,'utf8' ); |
| 6 | const id='a' .repeat(64),base='b' .repeat(64),agent='c' .repeat(64),operator='d' .repeat(64); |
| 7 | const review=()=>({request_id:id,base,agent,operator,before:{name:"Agent" ,scope:{kinds:[9]}},after:{scope:{kinds:[9,30617]}},unsigned:{kind:30392,pubkey:operator,created_at:100,tags:[['grant-request' ,id],['grant-base' ,base],['e' ,id]],content:'' }}); |
| 8 | function setup() { |
| 9 | const published=[],buttons=[{hasAttribute:n=>n==='data-grant-approve' },{hasAttribute:()=>false}]; let Ctor; |
| 10 | class Element {constructor(){this.isConnected=true;this.attrs={'data-review' :JSON.stringify(review())};}getAttribute(n){return this.attrs[n]??null;}hasAttribute(n){return n in this.attrs;}querySelectorAll(){return buttons;}addEventListener(){} } |
| 11 | const signer={getPublicKey:async()=>operator},tiny={signer:()=>signer,signing:{publish:async e=>published.push(e)},navigate:async()=>{}}; |
| 12 | vm.runInNewContext(source,{tiny,HTMLElement:Element,customElements:{define:(_,c)=>Ctor=c},AbortController,JSON,Date,Error,location:{href:'https://relay.test/approvals' }}); |
| 13 | const host=new Ctor();host.report=m=>host.message=m;host.connectedCallback(); |
| 14 | const fresh=review();fresh.unsigned.created_at=200;fresh.before={scope:{kinds:[9]},name:"Agent" }; |
| 15 | const item={id,type:'grant' ,state:'open' ,asker:agent,asked:[operator],grant:fresh};host.latest=async()=>({item}); |
| 16 | return {host,tiny,signer,buttons,published,item}; |
| 17 | } |
| 18 | test('grant approval signs exactly one fresh replacement, not a reaction' ,async()=>{ |
| 19 | const {host,published}=setup();await host.decide(null,true); |
| 20 | assert.equal(published.length,1);assert.equal(published[0].kind,30392);assert.equal(published[0].created_at,200);assert.deepEqual(Object.keys(published[0]),['kind' ,'created_at' ,'tags' ,'content' ]); |
| 21 | assert.equal(published[0].tags.find(t=>t[0]==='grant-base' )[1],base); |
| 22 | }); |
| 23 | test('denial signs one reaction to the agent even when grant is paused' ,async()=>{ |
| 24 | const {host,published,item}=setup();delete item.grant;item.grant_error='paused' ;await host.decide(null,false); |
| 25 | assert.equal(published.length,1);assert.equal(published[0].kind,7);assert.equal(published[0].content,'-' );assert.equal(published[0].tags.find(t=>t[0]==='p' )[1],agent); |
| 26 | }); |
| 27 | for(const scenario of ['base' ,'scope' ,'closed' ,'actor' ,'identity' ,'disconnect' ]) test('rejects changed ' +scenario+' before signing' ,async()=>{ |
| 28 | const {host,published,item,tiny}=setup(); |
| 29 | host.latest=async()=>{if(scenario==='base' )item.grant.base='e' .repeat(64);if(scenario==='scope' )item.grant.after.scope.kinds.push(7);if(scenario==='closed' )item.state='answered' ;if(scenario==='actor' )item.asked=['f' .repeat(64)];if(scenario==='identity' )tiny.signer=()=>({getPublicKey:async()=>operator});if(scenario==='disconnect' ){host.isConnected=false;host.disconnectedCallback();}return {item};}; |
| 30 | await host.decide(null,true);assert.equal(published.length,0); |
| 31 | }); |
| 32 | test('double tap cannot publish an approval and denial together' ,async()=>{ |
| 33 | const {host,published,buttons,item}=setup();let resolve;host.latest=()=>new Promise(done=>resolve=done); |
| 34 | const pending=host.decide(null,true);await new Promise(done=>setImmediate(done));assert.ok(buttons.every(b=>b.disabled)); |
| 35 | await host.decide(null,false);resolve({item});await pending;assert.equal(published.length,1);assert.equal(published[0].kind,30392); |
| 36 | }); |