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