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