Files
app/tests/persimmons-host-browser.cjs
T

387 lines
22 KiB
JavaScript
Raw Normal View History

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