conductor/scripts/test-installed-apps.mjs

152 lines
14 KiB
JavaScript

// Real Conductor, independent app packages, SQLite, Chromium and a local Node-RED API fixture.
import {chromium,expect} from '@playwright/test';
import {spawn,spawnSync} from 'node:child_process';
import fs from 'node:fs/promises';
import path from 'node:path';
import {createHash} from 'node:crypto';
import http from 'node:http';
import net from 'node:net';
import {tmpdir} from 'node:os';
const root=process.cwd(),social=path.resolve(process.argv[2]||'../social-scheduler');
const data=await fs.mkdtemp(path.join(tmpdir(),'conductor-packages-'));
const artifacts=path.join(root,'test-results/slice11');await fs.mkdir(artifacts,{recursive:true});
const internal='internal-fixture-token-keep-private',automation='automation-fixture-token';
const observed=[];
const fixture=http.createServer(async(req,res)=>{
let body='';for await(const chunk of req)body+=chunk;
observed.push({method:req.method,url:req.url,headers:req.headers,body});
let value={ok:true,method:req.method,path:req.url};
if(req.url==='/social-scheduler/session')value={displayName:'Fixture administrator',role:req.headers['x-conductor-role'],workspaces:[{id:'customer',name:'Customer workspace'}],preview:false,publishingEnabled:false};
else if(req.url==='/social-scheduler/workspaces/customer')value={accounts:[],posts:[],publishingEnabled:false};
else if(req.url==='/tool/echo')value={nested:{authorization:req.headers.authorization}};
else if(req.url==='/tool/redirect'){res.writeHead(302,{location:'https://example.invalid/never-follow'});res.end();return;}
else if(req.url==='/tool/large'){res.writeHead(200,{'content-type':'application/json'});res.end(JSON.stringify({value:'x'.repeat(2000100)}));return;}
else if(req.url.startsWith('/social-scheduler/automation/')&&req.headers.authorization!=='Bearer '+automation){res.writeHead(401,{'content-type':'application/json'});res.end('{"error":"Invalid API token."}');return;}
res.writeHead(200,{'content-type':'application/json'});res.end(JSON.stringify(value));
});
await new Promise(resolve=>fixture.listen(0,'127.0.0.1',resolve));
const port=await new Promise(resolve=>{const probe=net.createServer();probe.listen(0,'127.0.0.1',()=>{const port=probe.address().port;probe.close(()=>resolve(port));});});
const base='http://127.0.0.1:'+port;
const env={...process.env,PORT:String(port),CONDUCTOR_DATA_DIR:data,CONDUCTOR_STATIC_DIR:path.join(root,'frontend/build'),CONDUCTOR_SECRET_KEY:Buffer.alloc(32,21).toString('base64')};
let output='',server,browser,page;
async function start(){
server=spawn(process.execPath,['backend/dist/index.js'],{cwd:root,env,stdio:['ignore','pipe','pipe']});
server.stdout.on('data',c=>{output+=c;});server.stderr.on('data',c=>{output+=c;});
for(let n=0;n<100;n++){
if(server.exitCode!==null)throw new Error(output);
if(await fetch(base+'/api/health').then(r=>r.ok).catch(()=>false))return;
await new Promise(resolve=>setTimeout(resolve,50));
}
throw new Error('Conductor did not start.');
}
async function stop(){if(server&&server.exitCode===null)await new Promise(resolve=>{server.once('exit',resolve);server.kill('SIGTERM');});}
function cli(command,target,input,success=true){
const result=spawnSync(process.execPath,['backend/dist/scripts/installedApps.js',command,...(target?[target]:[])],{cwd:root,env,input:input?JSON.stringify(input):'',encoding:'utf8'});
expect(result.status===0,result.stderr).toBe(success);return success?JSON.parse(result.stdout):result.stderr;
}
const connection={origin:'http://127.0.0.1:'+fixture.address().port+'/social-scheduler',token:internal,values:{workspaceId:'customer'}};
const toolDir=path.join(data,'tool-package');
async function toolPackage(version='1.0.0'){
await fs.mkdir(toolDir,{recursive:true});
const js='export function mount(element,context){const heading=document.createElement("h1");heading.textContent="Independent tool '+version+'";element.append(heading);const identity=document.createElement("p");identity.textContent=context.user.displayName;element.append(identity);return()=>element.replaceChildren();}';
const icon='<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><rect width="32" height="32" fill="#ba6516"/></svg>';
const files={'tool.js':createHash('sha256').update(js).digest('hex'),'icon.svg':createHash('sha256').update(icon).digest('hex')};
const manifest={packageVersion:1,hostApiVersion:1,application:{schemaVersion:1,id:'independent-tool',name:'Independent Tool',description:'A second app without changes to Conductor.',version,launchPath:'/independent-tool',audience:'admins'},entry:{script:'tool.js',icon:'icon.svg'},files,api:{basePath:'/api/independent-tool',routes:[{path:'/info',methods:['GET'],access:'admin'},{path:'/redirect',methods:['GET'],access:'admin'},{path:'/large',methods:['GET'],access:'admin'},{path:'/echo',methods:['GET'],access:'admin'}]}};
await fs.writeFile(path.join(toolDir,'tool.js'),js);await fs.writeFile(path.join(toolDir,'icon.svg'),icon);await fs.writeFile(path.join(toolDir,'conductor-app.json'),JSON.stringify(manifest));
return manifest;
}
try{
await start();
browser=await chromium.launch({headless:true});
const context=await browser.newContext({baseURL:base,viewport:{width:1400,height:1000}});
const setup=await context.request.get('/api/auth/setup');
const response=await context.request.post('/api/auth/setup',{headers:{'X-Setup-Token':(await setup.json()).setupToken},data:{username:'package-admin',password:'package-test-password',displayName:'Package Administrator'}});
expect(response.status()).toBe(201);
const csrf=(await response.json()).csrfToken;
const request=(method,url,body,headers={})=>context.request.fetch(url,{method,headers:{'X-CSRF-Token':csrf,...headers},data:body});
page=await context.newPage();const errors=[];page.on('pageerror',e=>errors.push(e.message));
await page.goto('/');await expect(page).toHaveTitle('Conductor');
await page.getByRole('navigation').getByRole('button',{name:'Publishing',exact:true}).click();
await expect(page.getByText('No included applications are installed yet.',{exact:false})).toBeVisible();
expect(cli('capabilities').hostApiVersion).toBe(1);expect(cli('list')).toEqual([]);
const installed=cli('install',path.join(social,'dist/conductor-app'),connection);
expect(cli('install',path.join(social,'dist/conductor-app'),connection).digest).toBe(installed.digest);
await toolPackage();
const toolConnection={...connection,origin:connection.origin.replace('/social-scheduler','/tool')};
cli('install',toolDir,toolConnection);
expect(cli('list')).toHaveLength(2);
await page.getByRole('button',{name:'Refresh applications',exact:true}).click();
await expect(page.getByRole('heading',{name:'Social Scheduler',exact:true})).toHaveCount(1);
await expect(page.getByRole('heading',{name:'Independent Tool',exact:true})).toHaveCount(1);
await page.screenshot({path:path.join(artifacts,'two-installed-apps.png'),fullPage:true});
await page.getByRole('link',{name:'Configure',exact:true}).click();
await expect(page.getByRole('heading',{name:'Connect social media'})).toBeVisible();
await expect(page).toHaveTitle('Social Scheduler · Conductor');
await expect(page.locator('#conductor-favicon')).toHaveAttribute('href','/favicon.svg');
await page.goto('/social-scheduler');
await expect(page.getByRole('heading',{name:'Compose a post'})).toBeVisible();
await page.screenshot({path:path.join(artifacts,'social-scheduler-package.png'),fullPage:true});
await page.goto('/independent-tool/reports?view=detail');
await expect(page.getByRole('heading',{name:'Independent tool 1.0.0'})).toBeVisible();
await expect(page).toHaveTitle('Independent Tool · Conductor');
const metadata=await (await request('GET','/api/installed-apps/resolve?path=/independent-tool')).json();
await expect(page.locator('#conductor-favicon')).toHaveAttribute('href',metadata.iconPath);
expect(JSON.stringify(metadata)).not.toContain(internal);expect(JSON.stringify(metadata)).not.toContain('127.0.0.1');
const anonymous=await browser.newContext({baseURL:base});
expect((await anonymous.request.get(metadata.script)).status()).toBe(401);
expect((await anonymous.request.get('/api/installed-apps/resolve?path=/social-scheduler')).status()).toBe(401);
expect((await anonymous.request.get('/api/social-scheduler/session')).status()).toBe(401);
expect((await anonymous.request.get('/api/social-scheduler/oauth/jwks.json')).status()).toBe(200);
expect((await anonymous.request.get('/api/social-scheduler/automation/posts')).status()).toBe(401);
expect((await anonymous.request.get('/api/social-scheduler/automation/posts',{headers:{Authorization:'Bearer '+automation,'X-Conductor-Role':'admin','X-Social-Workspace':'someone-else'}})).status()).toBe(200);
let last=observed.at(-1);expect(last.headers.authorization).toBe('Bearer '+automation);expect(last.headers['x-conductor-role']).toBeUndefined();expect(last.headers['x-social-workspace']).toBeUndefined();
expect((await context.request.post('/api/social-scheduler/workspaces/customer/posts',{data:{text:'test'}})).status()).toBe(403);
const before=observed.length;
expect((await request('POST','/api/social-scheduler/workspaces/other/posts',{})).status()).toBe(404);
expect(observed).toHaveLength(before);
expect((await request('POST','/api/social-scheduler/workspaces/customer/posts',{text:'test'},{Authorization:'Bearer spoofed','X-Conductor-Role':'user','X-Social-Workspace':'other','Idempotency-Key':'fixture-key'})).status()).toBe(200);
last=observed.at(-1);expect(last.headers.authorization).toBe('Bearer '+internal);expect(last.headers['x-conductor-role']).toBe('admin');expect(last.headers['x-social-workspace']).toBe('customer');expect(last.headers['idempotency-key']).toBe('fixture-key');
expect((await request('GET','/api/social-scheduler/workspaces/customer?before=cursor&evil=ignored')).status()).toBe(200);
expect(observed.at(-1).url).toBe('/social-scheduler/workspaces/customer?before=cursor');
expect((await request('GET','/api/independent-tool/redirect')).status()).toBe(502);
expect((await request('GET','/api/independent-tool/large')).status()).toBe(502);
const reflected=await request('GET','/api/independent-tool/echo');expect(reflected.status()).toBe(502);expect(await reflected.text()).not.toContain(internal);
const callback=await context.request.get('/api/social-scheduler/oauth/callback?code=private-callback-marker&state=test',{maxRedirects:0});
expect(callback.status()).toBe(303);expect(callback.headers().location).toBe('/social-scheduler');
expect(output).not.toContain('private-callback-marker');expect(output).not.toContain(internal);
const user=await request('POST','/api/admin/users',{username:'package-user',password:'package-user-password',role:'user'});expect(user.status()).toBe(201);
const userContext=await browser.newContext({baseURL:base});
expect((await userContext.request.post('/api/auth/login',{data:{username:'package-user',password:'package-user-password'}})).status()).toBe(200);
expect((await userContext.request.get(metadata.script)).status()).toBe(403);
expect((await userContext.request.get('/api/installed-apps/resolve?path=/independent-tool')).status()).toBe(403);
expect((await userContext.request.get('/api/independent-tool/info')).status()).toBe(403);
expect((await userContext.request.get('/api/social-scheduler/workspaces/customer/admin')).status()).toBe(403);
const userPage=await userContext.newPage();await userPage.goto('/social-scheduler');
await expect(userPage.getByRole('heading',{name:'Compose a post'})).toBeVisible();
await expect(userPage.getByRole('button',{name:'Administration',exact:true})).toHaveCount(0);
await userPage.goto('/');await expect(userPage.getByRole('heading',{name:'Independent Tool',exact:true})).toHaveCount(0);
// Replacing one package does not rebuild Conductor or change another package's identity.
await toolPackage('1.1.0');cli('install',toolDir);
expect(cli('list').find(a=>a.id==='social-scheduler').digest).toBe(installed.digest);
await page.reload();await expect(page.getByRole('heading',{name:'Independent tool 1.1.0'})).toBeVisible();
await stop();await start();await page.reload();
await expect(page.getByRole('heading',{name:'Independent tool 1.1.0'})).toBeVisible();
expect((await request('GET','/api/independent-tool/info')).status()).toBe(200);
await page.goto('/social-scheduler');await expect(page.getByRole('heading',{name:'Compose a post'})).toBeVisible();
for(const file of ['conductor.db','conductor.db-wal']){const bytes=await fs.readFile(path.join(data,file)).catch(()=>Buffer.alloc(0));expect(bytes.includes(Buffer.from(internal))).toBe(false);}
// Bad content cannot replace a working package.
await fs.appendFile(path.join(toolDir,'tool.js'),'tampered');
expect(cli('install',toolDir,undefined,false)).toContain('checksum');
await page.goto('/independent-tool');await expect(page.getByRole('heading',{name:'Independent tool 1.1.0'})).toBeVisible();
await toolPackage('2.0.0');const mf=JSON.parse(await fs.readFile(path.join(toolDir,'conductor-app.json'),'utf8'));mf.application.launchPath='/social-scheduler';await fs.writeFile(path.join(toolDir,'conductor-app.json'),JSON.stringify(mf));
expect(cli('install',toolDir,undefined,false)).toContain('launch path');
cli('remove','independent-tool');expect(cli('list')).toHaveLength(1);cli('remove','independent-tool');
expect((await request('GET','/api/installed-apps/resolve?path=/independent-tool')).status()).toBe(200);
expect((await (await request('GET','/api/installed-apps/resolve?path=/independent-tool')).json())).toBeNull();
await page.goto('/social-scheduler');await expect(page.getByRole('heading',{name:'Compose a post'})).toBeVisible();
expect(errors).toEqual([]);
await fs.writeFile(path.join(artifacts,'result.json'),JSON.stringify({passed:true,checks:['independent packages','fresh empty host','unchanged core image','install/update/remove/repeat/restart','real scheduler frontend','second generic app','admin/user/anonymous access','CSRF','bearer delegation','trusted identity headers','workspace restriction','query filtering','redirect and size bounds','callback log privacy','encrypted connection','invalid package rollback']},null,2));
console.log('PASS: independent app packages, real Social Scheduler, second app, persistence, access controls, API bounds and installer rejection.');
}catch(error){if(page)await page.screenshot({path:path.join(artifacts,'failure.png'),fullPage:true}).catch(()=>{});throw error;}
finally{await browser?.close();await stop();fixture.closeAllConnections();await new Promise(resolve=>fixture.close(resolve));await fs.rm(data,{recursive:true,force:true});}