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); });