From 4019bb5b01635b34eb05a4ceb769d04fcb3f7e50 Mon Sep 17 00:00:00 2001 From: matcha Date: Sun, 4 Oct 2026 02:58:00 +0000 Subject: [PATCH] Consolidate Persimmons shared backend and tests --- tests/persimmons-host-browser.cjs | 387 ++++++++++++++++++++++++++++++ 1 file changed, 387 insertions(+) create mode 100644 tests/persimmons-host-browser.cjs diff --git a/tests/persimmons-host-browser.cjs b/tests/persimmons-host-browser.cjs new file mode 100644 index 0000000..6c8fe54 --- /dev/null +++ b/tests/persimmons-host-browser.cjs @@ -0,0 +1,387 @@ +// Fresh-profile standalone Persimmons Chromium acceptance. +const {chromium}=require(process.env.PERSIMMONS_PLAYWRIGHT_MODULE || 'playwright-core'); +const {randomBytes}=require('node:crypto'); +const path=require('node:path'); +const fs=require('node:fs'); +const os=require('node:os'); +const assert=require('node:assert/strict'); + +let phase='startup'; +(async()=>{ + const profile=fs.mkdtempSync(path.join(os.tmpdir(),'persimmons-chromium-')); + const context=await chromium.launchPersistentContext(profile,{ + executablePath:process.env.PERSIMMONS_CHROMIUM, + headless:true, + viewport:{width:1280,height:1200}, + ignoreDefaultArgs:['--disable-extensions'], + args:['--no-sandbox','--enable-unsafe-extension-debugging'] + }); + context.setDefaultTimeout(60000); + let cleanupSource; + try { + const browser=context.browser(); + const cdp=await browser.newBrowserCDPSession(); + const extensionId='kiolgjoohhgdohcafgelhikpgkbmckbg'; + const localAssets={ + '/':'index.html', + '/signin':'signin.html', + '/assets/app.js':'app.js', + '/assets/signin.js':'signin.js', + '/assets/version.mjs':'version.mjs', + '/assets/version.json':'version.json' + }; + if(process.env.PERSIMMONS_LOCAL_UI==='1'){ + await context.route('https://persimmons.app/**',route=>{ + const pathname=new URL(route.request().url()).pathname; + const file=localAssets[pathname]; + if(!file)return route.continue(); + const body=fs.readFileSync(path.join('persimmons/web',file)); + const contentType=file.endsWith('.json')?'application/json':/\.m?js$/.test(file)?'text/javascript':'text/html'; + return route.fulfill({status:200,contentType,body}); + }); + } + + const page=await context.newPage(); + let response=await page.goto('https://persimmons.app/',{waitUntil:'domcontentloaded'}); + assert.equal(response.status(),200); + await page.locator('#signInLink').waitFor({state:'visible'}); + await page.locator('#createAccount').waitFor({state:'visible'}); + assert.equal(await page.getByText('Up to date',{exact:true}).count(),0); + assert.equal(await page.locator('#updateExtension').isVisible(),false); + + phase='signup'; + const username='browser-'+randomBytes(8).toString('hex'); + const accountPassword=randomBytes(24).toString('base64url'); + await page.locator('#createAccount').click(); + await page.getByRole('heading',{name:'Create account'}).waitFor(); + await page.locator('#modalName').fill(username); + await page.locator('#modalPassword').fill(accountPassword); + await page.locator('#siteModalBody form button[type="submit"]').click(); + await page.locator('#signOutButton').waitFor({state:'visible'}); + assert.equal(await page.locator('#signInLink').isVisible(),false); + + const identity=await page.evaluate(()=>fetch('/api/session').then(r=>r.json())); + const giteaHeaders={Authorization:'Basic '+Buffer.from(username+':'+accountPassword).toString('base64')}; + cleanupSource=async()=>{ + const root='https://git.persimmons.app/api/v1'; + const response=await context.request.get(root+'/users/'+username+'/tokens',{headers:giteaHeaders}); + if(response.status()!==200)throw new Error(); + for(const token of await response.json())if(token.name.startsWith('persimmons-juice-')){ + const removed=await context.request.delete(root+'/users/'+username+'/tokens/'+token.id,{headers:giteaHeaders}); + if(![204,404].includes(removed.status()))throw new Error(); + } + const secrets=root+'/repos/'+username+'/work-test/actions/secrets'; + const listed=await context.request.get(secrets,{headers:giteaHeaders}); + if(listed.status()===200)for(const secret of await listed.json())if(secret.name.startsWith('PERSIM_JUICE_')){ + if((await context.request.delete(secrets+'/'+secret.name,{headers:giteaHeaders})).status()!==204)throw new Error(); + } + }; + + const gitUser=await context.request.get('https://git.persimmons.app/api/v1/user',{headers:giteaHeaders}); + assert.equal(gitUser.status(),200); + const user=await gitUser.json(); + assert.equal(user.login,username);assert.equal(user.id,identity.gitea_id);assert.equal(user.is_admin,false); + await page.getByRole('heading',{name:'Master vault',exact:true}).waitFor(); + phase='named-vault'; + await page.locator('#newVault').click(); + await page.locator('#vaultName').fill('work-test'); + await page.getByRole('button',{name:'Create vault',exact:true}).click(); + await page.locator('#namedVaultList').getByText(username+'/work-test',{exact:true}).waitFor(); + await page.locator('#namedVaultList').getByText('Empty vault',{exact:true}).waitFor(); + const gitRepo=await context.request.get('https://git.persimmons.app/api/v1/repos/'+username+'/work-test',{headers:giteaHeaders}); + assert.equal(gitRepo.status(),200); + const repo=await gitRepo.json(); + assert.equal(repo.owner.id,user.id);assert.equal(repo.private,true);assert.equal(repo.empty,true); + await page.locator('#newVault').click(); + await page.locator('#vaultName').fill('work-test'); + await page.getByRole('button',{name:'Create vault',exact:true}).click(); + await page.locator('#vaultStatus').getByText('A repository with this name already exists.',{exact:true}).waitFor(); + await page.getByRole('button',{name:'Close',exact:true}).click(); + console.log(JSON.stringify({step:'gitea_browser_acceptance',username,gitea_id:user.id,is_admin:false, + repository:repo.full_name,repo_id:repo.id,private:true,empty:true,duplicate_conflict:true})); + + phase='capture-fill'; + const loaded=await cdp.send('Extensions.loadUnpacked',{path:path.resolve('persimmons-extension')}); + assert.equal(loaded.id,extensionId); + await page.reload({waitUntil:'domcontentloaded'}); + await page.getByText('Open ๐ŸŠ and choose Link vault.',{exact:true}).waitFor(); + assert.equal(await page.locator('#getExtension').isVisible(),false); + assert.equal(await page.locator('#updateExtension').isVisible(),false); + assert.equal(await page.getByText('Up to date',{exact:true}).count(),0); + + // Automates the user's one-time approval of the optional Persimmons host permission. + const settings=await context.newPage(); + await settings.goto('chrome://extensions'); + await settings.evaluate(async id=>chrome.developerPrivate.addHostPermission(id,'https://persimmons.app/*'),extensionId); + await settings.close(); + + let target=await context.newPage(); + let submissions=0; + target.on('request',r=>{if(r.method()==='POST')submissions++}); + await target.goto('https://wp.phly.cloud/wp-login.php',{waitUntil:'domcontentloaded'}); + await target.locator('#user_login').waitFor(); + await target.locator('#user_login').fill('disposable-fixture'); + await target.locator('#user_pass').fill('synthetic-no-provider-authority'); + + const tabInfo=()=>cdp.send('Target.getTargets',{filter:[{type:'tab',exclude:false}]}) + .then(x=>x.targetInfos.find(t=>t.url.startsWith('https://wp.phly.cloud/wp-login.php'))); + let info=await tabInfo(); + assert.ok(info,'controlled HTTPS login tab exists'); + + let popupInfo,sessionId,sequence=0; + async function openPopup(){ + await target.bringToFront(); + await cdp.send('Extensions.triggerAction',{id:extensionId,targetId:info.targetId}); + for(let n=0;n<60;n++){ + popupInfo=(await cdp.send('Target.getTargets')).targetInfos.find(t=>t.url.startsWith('chrome-extension://'+extensionId+'/popup.html')); + if(popupInfo)break; + await new Promise(r=>setTimeout(r,100)); + } + assert.ok(popupInfo,'extension action popup opened'); + ({sessionId}=await cdp.send('Target.attachToTarget',{targetId:popupInfo.targetId,flatten:false})); + await rpc('Runtime.enable'); + for(let n=0;n<60;n++){ + if(await evaluate('document.readyState==="complete" && !!document.getElementById("human-connect")'))return; + await new Promise(r=>setTimeout(r,100)); + } + throw new Error('popup did not become ready'); + } + function rpc(method,params={}){ + const id=++sequence; + return new Promise((resolve,reject)=>{ + const timer=setTimeout(()=>{cdp.off('Target.receivedMessageFromTarget',listener);reject(new Error('popup RPC timeout: '+method))},20000); + const listener=event=>{ + if(event.sessionId!==sessionId)return; + const message=JSON.parse(event.message); + if(message.id!==id)return; + clearTimeout(timer);cdp.off('Target.receivedMessageFromTarget',listener); + message.error?reject(new Error(message.error.message||'popup RPC failed')):resolve(message.result); + }; + cdp.on('Target.receivedMessageFromTarget',listener); + cdp.send('Target.sendMessageToTarget',{sessionId,message:JSON.stringify({id,method,params})}).catch(reject); + }); + } + async function evaluate(expression){ + const r=await rpc('Runtime.evaluate',{expression,awaitPromise:true,returnByValue:true,userGesture:true}); + if(r.exceptionDetails)throw new Error(r.exceptionDetails.exception?.description||r.exceptionDetails.text); + return r.result.value; + } + async function click(id){await evaluate(`document.getElementById(${JSON.stringify(id)}).click()`)} + async function waitValue(expression,expected,label){ + for(let n=0;n<120;n++){ + const value=await evaluate(expression); + if(value===expected)return; + await new Promise(r=>setTimeout(r,100)); + } + throw new Error('Expected '+label+': '+expected+'; got '+await evaluate(expression)); + } + async function waitPageText(text){ + await page.getByText(text,{exact:true}).waitFor({state:'visible',timeout:20000}); + } + + await openPopup(); + assert.equal(await evaluate('document.getElementById("origin").textContent'),'https://wp.phly.cloud'); + assert.equal(await evaluate('document.getElementById("human-state").textContent'),'Not connected'); + assert.equal(await evaluate('document.querySelector("#human-username")'),null); + assert.equal(await evaluate('document.querySelector("#human-password")'),null); + + await click('human-connect'); + await waitValue('document.getElementById("human-state").textContent','Locked','linked state'); + await waitPageText('Open ๐ŸŠ and choose a vault passphrase.'); + + const passphrase='disposable browser vault passphrase'; + await evaluate(`document.getElementById("human-passphrase").value=${JSON.stringify(passphrase)}`); + await click('human-unlock'); + await waitValue('document.getElementById("human-state").textContent','Unlocked','unlocked state'); + await page.locator('#extensionState[aria-label="Vault unlocked"]').waitFor(); + assert.equal(await evaluate('document.getElementById("human-capture").hidden'),false); + + await click('human-capture'); + await waitValue('document.getElementById("human-accounts").hidden',false,'saved item UI'); + assert.equal(await evaluate('document.getElementById("human-items").options.length'),2); + console.log(JSON.stringify({step:'capture',ok:true,origin:'https://wp.phly.cloud'})); + + await click('human-lock'); + await waitValue('document.getElementById("human-state").textContent','Locked','locked state'); + await page.locator('#extensionState[aria-label="Vault locked"]').waitFor(); + + await evaluate(`document.getElementById("human-passphrase").value=${JSON.stringify(passphrase)}`); + await click('human-unlock'); + await waitValue('document.getElementById("human-state").textContent','Unlocked','re-unlocked state'); + await evaluate('document.getElementById("human-items").selectedIndex=1;document.getElementById("human-items").dispatchEvent(new Event("change"))'); + await click('human-fill'); + assert.equal(await target.locator('#user_login').inputValue(),'disposable-fixture'); + assert.equal(await target.locator('#user_pass').inputValue(),'synthetic-no-provider-authority'); + assert.equal(submissions,0,'fill never submits'); + await waitValue('document.getElementById("human-delete").disabled',false,'delete enabled after fill'); + + await click('human-delete'); + await waitValue('document.getElementById("human-accounts").hidden',true,'empty site state'); + console.log(JSON.stringify({step:'fill_delete',ok:true,no_submit:true})); + + phase='juice-capture'; + // Real Gitea credential capture -> drag -> derived write-only PAT -> rotate/revoke. + target = await context.newPage(); + phase='juice-source-login'; + await target.goto('https://git.persimmons.app/user/login',{waitUntil:'domcontentloaded'}); + await target.locator('input[name="user_name"]').fill(username); + await target.locator('input[name="password"]').fill(accountPassword); + info=(await cdp.send('Target.getTargets',{filter:[{type:'tab',exclude:false}]})).targetInfos.find(t=>t.url.startsWith('https://git.persimmons.app/user/login')); + popupInfo=null;sessionId=null; + phase='juice-popup'; + await openPopup(); + await waitValue('document.getElementById("human-unlock").disabled',false,'source popup connected'); + await evaluate(`document.getElementById("human-passphrase").value=${JSON.stringify(passphrase)}`); + await click('human-unlock'); + await waitValue('document.getElementById("human-state").textContent','Unlocked','Juice source unlocked'); + phase='juice-lock-policy'; + await evaluate('document.getElementById("auto-lock").value="15";document.getElementById("auto-lock").dispatchEvent(new Event("change"))'); + await waitValue('document.getElementById("human-state").textContent','Locked','policy change locks'); + await evaluate(`document.getElementById("human-passphrase").value=${JSON.stringify(passphrase)}`); + await click('human-unlock'); + await waitValue('document.getElementById("human-state").textContent','Unlocked','persistent unlock'); + phase='juice-capture-click'; + await click('human-capture'); + await waitValue('document.getElementById("human-accounts").hidden',false,'Gitea source captured'); + phase='juice-source-card'; + await page.bringToFront(); + if(popupInfo)await cdp.send('Target.closeTarget',{targetId:popupInfo.targetId}).catch(()=>{}); + await page.getByRole('heading',{name:'Master vault',exact:true}).click(); + const sourceCard=page.locator('#vaultItems .connection').filter({hasText:'https://git.persimmons.app'}); + await sourceCard.waitFor(); + await page.locator('#extensionState[aria-label="Vault unlocked"]').waitFor(); + assert.equal(await sourceCard.getAttribute('draggable'),'true'); + const sourceItemId=await sourceCard.getAttribute('data-source-id'); + let pageLeaks=false; + page.on('response',async r=>{try{const text=await r.text();if(text.includes(accountPassword)||(r.url().includes('/api/juice/')&&/[0-9a-f]{40}/.test(text)))pageLeaks=true}catch{}}); + const worker=context.serviceWorkers().find(w=>w.url().endsWith('/connection.mjs')); + assert.ok(worker,'real extension worker'); + // Verify newly issued PATs in worker memory; never return their values to the harness/page. + await worker.evaluate(({username})=>{ + const original=globalThis.fetch, tokens=new Map(); + globalThis.juiceProof={createdWorks:0,revokedDenied:0,writeOnlyPosts:0}; + globalThis.fetch=async function(url,options={}){ + const response=await original(url,options); + const provider='https://git.persimmons.app/api/v1/users/'+username+'/tokens'; + if(url===provider&&options.method==='POST'&&response.status===201){ + const data=await response.clone().json(); + tokens.set(data.name,data.sha1); + const check=await original('https://git.persimmons.app/api/v1/repos/'+username+'/work-test', + {headers:{Authorization:'token '+data.sha1},credentials:'omit',redirect:'error',cache:'no-store'}); + if(check.status===200)globalThis.juiceProof.createdWorks++; + data.sha1=''; + } + if(typeof url==='string'&&url.startsWith(provider+'/')&&options.method==='DELETE'&&response.status===204){ + const name=url.slice(provider.length+1),pat=tokens.get(name); + if(pat){ + const check=await original('https://git.persimmons.app/api/v1/repos/'+username+'/work-test', + {headers:{Authorization:'token '+pat},credentials:'omit',redirect:'error',cache:'no-store'}); + if(check.status===401)globalThis.juiceProof.revokedDenied++; + tokens.delete(name); + } + } + if(url==='https://persimmons.app/api/juice/write'&&options.method==='POST'&&response.ok)globalThis.juiceProof.writeOnlyPosts++; + return response; + }; + },{username}); + await page.evaluate(()=>{ + window.dragProof=[]; + for(const type of ['dragstart','drop','dragend'])document.addEventListener(type,event=>window.dragProof.push({type,target:event.target.className,types:[...event.dataTransfer.types]})); + }); + phase='juice-drag'; + await sourceCard.scrollIntoViewIfNeeded(); + const from=await sourceCard.boundingBox(),to=await page.locator('.namedVaultCard').boundingBox(); + await page.mouse.move(from.x+from.width-25,from.y+20); + await page.mouse.down(); + await page.mouse.move(from.x+from.width-25,from.y+30,{steps:4}); + await page.mouse.move(to.x+to.width/2,to.y+to.height/2,{steps:20}); + await page.mouse.move(to.x+to.width/2,to.y+to.height/2+2); + await page.mouse.up(); + await page.screenshot({path:'/tmp/persimmons-juice-drag.png'}); + console.log(JSON.stringify({step:'drag_events',events:await page.evaluate(()=>window.dragProof)})); + await page.getByRole('button',{name:'Create Juice',exact:true}).click(); + await page.locator('#siteModal').waitFor({state:'hidden'}); + let cards=page.locator('.juiceCard'); + await cards.first().waitFor(); + const firstId=await cards.first().getAttribute('data-juice-id'); + const providerTokens=async()=>{ + const r=await context.request.get('https://git.persimmons.app/api/v1/users/'+username+'/tokens',{headers:giteaHeaders}); + assert.equal(r.status(),200);return r.json(); + }; + let tokens=await providerTokens(); + assert.ok(tokens.some(t=>t.name==='persimmons-juice-'+firstId)); + assert.ok(tokens.every(t=>!t.sha1)); + const secretsPath='https://git.persimmons.app/api/v1/repos/'+username+'/work-test/actions/secrets'; + const listed=await context.request.get(secretsPath,{headers:giteaHeaders}); + assert.equal(listed.status(),200); + assert.ok((await listed.json()).some(s=>s.name==='PERSIM_JUICE_'+firstId.toUpperCase())); + assert.ok([404,405].includes((await context.request.get(secretsPath+'/PERSIM_JUICE_'+firstId.toUpperCase(),{headers:giteaHeaders})).status())); + assert.equal(await cards.first().isVisible(),true,'target vault remains open'); + phase='juice-rotate'; + await cards.first().getByRole('button',{name:'Rotate',exact:true}).click(); + await page.locator('.juiceCard').filter({hasText:' ยท active ยท '}).waitFor(); + for(let n=0;n<100;n++){ + tokens=await providerTokens(); + if(tokens.length===1&&tokens[0].name!=='persimmons-juice-'+firstId)break; + await new Promise(r=>setTimeout(r,200)); + } + assert.equal(tokens.length,1);assert.notEqual(tokens[0].name,'persimmons-juice-'+firstId); + phase='juice-revoke'; + const active=page.locator('.juiceCard[data-juice-id="'+tokens[0].name.slice('persimmons-juice-'.length)+'"]'); + await active.waitFor(); + await active.getByRole('button',{name:'Revoke',exact:true}).click(); + for(let n=0;n<100;n++){ + if((await providerTokens()).length===0)break; + await new Promise(r=>setTimeout(r,200)); + } + assert.equal((await providerTokens()).length,0); + assert.deepEqual(await worker.evaluate(()=>globalThis.juiceProof),{createdWorks:2,revokedDenied:2,writeOnlyPosts:2}); + assert.equal((await (await context.request.get(secretsPath,{headers:giteaHeaders})).json()).length,0); + assert.equal(pageLeaks,false,'source password never reaches Persimmons page responses'); + assert.equal(await page.locator('body').innerText().then(s=>s.includes(accountPassword)),false); + const removed=await page.evaluate(async id=>(await fetch('/api/vault-index/'+id,{method:'DELETE',headers:{'X-Requested-With':'Persimmons'}})).status,sourceItemId); + assert.equal(removed,200); + console.log(JSON.stringify({step:'juice_browser_acceptance',ok:true,repo_id:repo.id,drag:true,write_only:true,rotated:true,revoked:true,page_password_hidden:true})); + + phase='signout'; + // Browser-action popups close when focus returns to the website. Reopening after + // sign-out must live-validate the owner hint and fail back to Not connected. + await page.bringToFront(); + await page.locator('#signOutButton').click(); + await page.locator('#signInLink').waitFor({state:'visible'}); + popupInfo=null; sessionId=null; + await openPopup(); + await waitValue('document.getElementById("human-state").textContent','Not connected','reopened signed-out state'); + assert.equal(await evaluate('document.getElementById("human-connect-panel").hidden'),false); + console.log(JSON.stringify({step:'signout_sync',ok:true})); + + console.log(JSON.stringify({ + step:'browser_extension_acceptance', + ok:true, + extension_version:JSON.parse(fs.readFileSync('persimmons-extension/manifest.json')).version, + extension_id:extensionId, + fresh_profile:true, + same_document_auth:true, + link:true, + initialize_unlock:true, + homepage_popup_agree:true, + explicit_capture:true, + lock_unlock:true, + explicit_fill:true, + delete:true, + signout_clears_link:true, + current_version_quiet:true, + no_submit:submissions===0, + target:'https://wp.phly.cloud/wp-login.php' + })); + } finally { + if(cleanupSource)try{await cleanupSource()}catch{console.error('ANDON: disposable browser provider cleanup unconfirmed.');process.exitCode=1;} + for(const page of context.pages()){ + if(page.url().startsWith('https://persimmons.app')){ + await page.evaluate(()=>fetch('/api/logout',{method:'POST'})).catch(()=>{}); + } + } + await context.close(); + fs.rmSync(profile,{recursive:true,force:true}); + } +})().catch(error=>{console.error('ANDON: browser acceptance failed at '+phase+' ('+error.name+'); credential diagnostics suppressed.');console.error(String(error.stack).split('\n').filter(l=>/^\s+at .*persimmons-host-browser\.cjs:\d/.test(l)).slice(0,2).join('\n'));process.exitCode=1});