diff --git a/extension/tests/juice.test.mjs b/extension/tests/juice.test.mjs new file mode 100644 index 0000000..5558528 --- /dev/null +++ b/extension/tests/juice.test.mjs @@ -0,0 +1,79 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {runJuice, giteaToken, checkedScopes} from '../juice.mjs'; +const sourceId = 'a'.repeat(32), juiceId = 'b'.repeat(32), oldId = 'c'.repeat(32); +const sender = {origin:'https://persimmons.app',url:'https://persimmons.app/',frameId:0}; +const message = {type:'persimmons.juice.v1',action:'create',owner:'pg-2',source_id:sourceId,target:{id:1,name:'PackPack'},policy:{scopes:['read:repository']}}; +const raw = 'RAW-SOURCE-NEVER-PAGE', pat = 'd'.repeat(40); +function fixture() { + const calls = [], provider = [], account = {username:'source-fixture',password:raw}; + const deps = { + id:()=>juiceId, live:async()=>{}, unlocked:async()=>{}, + source:async()=>({id:sourceId,origin:'https://teahub.xyz',ciphertext:'opaque'}), + use:async(owner,item,fn)=>{try{return await fn(account,()=>{});}finally{account.password='';}}, + request:async(op,owner,data)=>{calls.push({op,data});return {items:[{id:oldId,source_id:sourceId,source_origin:'https://teahub.xyz',source_username:'source-fixture',scopes:['read:repository'],status:'active'}]};}, + provider:async(origin,account,name,scopes)=>{provider.push({origin,name,scopes});return scopes?pat:true;} + }; + return {deps,calls,provider,account}; +} +test('create sends only the derived PAT to write-only backend, never to page or metadata',async()=>{ + const f=fixture();const result=await runJuice(message,sender,f.deps); + assert.deepEqual(result,{ok:true,id:juiceId,status:'active'}); + assert.doesNotMatch(JSON.stringify(result),new RegExp(raw+'|'+pat)); + assert.deepEqual(f.calls.map(x=>x.op),['prepare','write']); + assert.equal(f.calls[1].data.pat,pat); + assert.doesNotMatch(JSON.stringify(f.calls),new RegExp(raw)); + assert.doesNotMatch(JSON.stringify(f.calls[0]),new RegExp(pat)); + assert.equal(f.account.password,''); +}); +test('top frame, origin, owner, unlock and scope gates reject before provider mutation',async()=>{ + for(const change of [{origin:'https://evil.invalid'},{url:'https://evil.invalid'},{frameId:1},{url:'invalid'}]){ + const f=fixture();assert.equal(await runJuice(message,{...sender,...change},f.deps),null);assert.equal(f.calls.length,0); + } + for(const gate of ['live','unlocked']){ + const f=fixture();f.deps[gate]=async()=>{throw new Error(raw)}; + assert.equal((await runJuice(message,sender,f.deps)).ok,false);assert.equal(f.provider.length,0); + } + for(const scopes of [[],['all'],['write:repository'],['read:issue','write:issue'],['read:user'],['read:repository','read:repository']]) assert.throws(()=>checkedScopes(scopes)); + const f=fixture();f.deps.source=async()=>({origin:'https://evil.invalid'}); + assert.equal((await runJuice(message,sender,f.deps)).message,'Juice adapter not available yet.');assert.equal(f.calls.length,0); +}); +test('failed or uncertain write/create compensates by deterministic source token name',async()=>{ + for(const failure of ['write','provider']) { + const f=fixture();const request=f.deps.request,provider=f.deps.provider; + f.deps.request=async(op,...args)=>{if(op==='write'&&failure==='write')throw new Error(pat);return request(op,...args)}; + f.deps.provider=async(...args)=>{if(args[3]&&failure==='provider'){await provider(...args);throw new Error(raw)}return provider(...args)}; + const result=await runJuice(message,sender,f.deps); + assert.equal(result.ok,false);assert.equal(result.status,'revoked'); + assert.equal(f.provider.at(-1).name,'persimmons-juice-'+juiceId);assert.equal(f.provider.at(-1).scopes,undefined); + assert.equal(f.calls.at(-1).data.provider_status,'revoked');assert.doesNotMatch(JSON.stringify(result),new RegExp(raw+'|'+pat)); + } +}); +test('unconfirmed rollback is explicit ANDON; rotation revokes old only after replacement stored',async()=>{ + const f=fixture(),request=f.deps.request; + f.deps.request=async(op,...args)=>{if(op==='write')throw new Error();return request(op,...args)}; + f.deps.provider=async(o,a,n,s)=>{if(!s)throw new Error();return pat}; + const failed=await runJuice(message,sender,f.deps); + assert.equal(failed.status,'partial-failure');assert.match(failed.message,/ANDON/); + const r=fixture();const result=await runJuice({type:message.type,action:'rotate',owner:message.owner,target:message.target,id:oldId},sender,r.deps); + assert.equal(result.ok,true);assert.deepEqual(r.calls.map(c=>c.op),['list','prepare','write','remove','cleanup']); + assert.equal(r.provider.at(-1).name,'persimmons-juice-'+oldId); +}); +test('locked revoke still removes target, records provider partial revoke truthfully',async()=>{ + const f=fixture();f.deps.unlocked=async()=>{throw new Error()}; + const result=await runJuice({type:message.type,action:'revoke',owner:message.owner,target:message.target,id:oldId},sender,f.deps); + assert.equal(result.status,'partial-failure');assert.match(result.message,/ANDON/); + assert.deepEqual(f.calls.map(c=>c.op),['list','remove','cleanup']);assert.equal(f.provider.length,0); + assert.equal(f.calls.at(-1).data.provider_status,'unknown'); +}); +test('Gitea requests are pinned Basic auth, no cookies/redirects; responses do not escape',async()=>{ + const account={username:'fixture',password:raw}; + for(const origin of ['https://teahub.xyz']){ + const value=await giteaToken(origin,account,'persimmons-juice-'+juiceId,['read:repository'],async(url,opts)=>{ + assert.equal(url,origin+'/api/v1/users/fixture/tokens');assert.equal(opts.credentials,'omit');assert.equal(opts.redirect,'error'); + assert.equal(Buffer.from(opts.headers.Authorization.slice(6),'base64').toString(),'fixture:'+raw); + return Response.json({name:'persimmons-juice-'+juiceId,sha1:pat,scopes:['read:repository']},{status:201}); + });assert.equal(value,pat); + } + let called=false;await assert.rejects(giteaToken('https://evil.invalid',account,'persimmons-juice-'+juiceId,['read:repository'],async()=>{called=true}));assert.equal(called,false); +});