Files

85 lines
7.3 KiB
JavaScript

// Integration test against a disposable, locally running panel. No model calls.
import assert from 'node:assert/strict';
import { execFileSync } from 'node:child_process';
const base = process.env.TEST_PANEL_URL || 'http://127.0.0.1:18088';
const testDirectory='integration-'+Date.now();
let cookie = '', csrf = '', instance;
const cookies = new Map();
function keepCookies(response) {
for (const value of response.headers.getSetCookie()) { const pair=value.split(';')[0]; const at=pair.indexOf('='); cookies.set(pair.slice(0,at),pair.slice(at+1)); }
cookie=[...cookies].map(([key,value])=>key+'='+value).join('; ');
}
async function request(path, method = 'GET', body) {
if(path.startsWith('/api/ws/') && instance) path += (path.includes('?')?'&':'?')+'instance='+encodeURIComponent(instance.id);
const response = await fetch(base + path, {method,headers:{Cookie:cookie,Origin:base,'X-CSRF-Token':csrf,...(body instanceof FormData ? {} : {'Content-Type':'application/json'})},body:body===undefined?undefined:body instanceof FormData?body:JSON.stringify(body)});
keepCookies(response);
const text = await response.text(); let data; try {data=JSON.parse(text)}catch{data=text}
if(!response.ok)throw new Error(method+' '+path+': '+response.status+' '+String(typeof data==='object'?data.error:data).slice(0,300));
return {response,data};
}
try {
const login=await request('/api/login','POST',{username:process.env.TEST_USERNAME || 'testadmin',password:process.env.TEST_PASSWORD || 'Local-Test-Only-1842'});
cookie=login.response.headers.getSetCookie().map(v=>v.split(';')[0]).join('; ');csrf=login.data.csrf_token;
assert.equal((await request('/api/me')).data.user.username,process.env.TEST_USERNAME || 'testadmin');
await request('/api/refresh','POST');
assert.equal((await request('/api/me')).data.user.username,process.env.TEST_USERNAME || 'testadmin');
const images=(await request('/api/images')).data.filter(i=>i.status==='available' && i.selectable===true);
assert.ok(images.length,'prepare a validated, admitted image through the admin Git release flow before running this test');
const source=images.find(i=>!process.env.TEST_FROM_VERSION || i.version===process.env.TEST_FROM_VERSION);
const target=images.find(i=>!process.env.TEST_TO_VERSION || i.version===process.env.TEST_TO_VERSION);
assert.ok(source,'source image required');assert.ok(target,'target image required');
console.log('PASS validated and admitted local image selection');
instance=(await request('/api/instances','POST',{name:'Integration check',image_id:source.image_id || source.id})).data;
console.log('Disposable instance:', instance.id);
await request('/api/ws/mkdir','POST',{path:testDirectory});
const form=new FormData();form.set('path',testDirectory);form.set('file',new Blob(['hello workspace']), 'hello.txt');await request('/api/ws/upload','POST',form);
assert.equal((await request('/api/ws/download?path='+testDirectory+'/hello.txt')).data,'hello workspace');
await request('/api/ws/rename','POST',{path:testDirectory+'/hello.txt',new_path:testDirectory+'/renamed.txt'});
const zip=await fetch(base+'/api/ws/download?path='+testDirectory+'&instance='+encodeURIComponent(instance.id),{headers:{Cookie:cookie}});
assert.equal(zip.status,200);assert.match(zip.headers.get('content-type'),/zip/);
assert.equal(new Uint8Array(await zip.arrayBuffer()).slice(0,2).toString(),'80,75');
console.log('PASS login, workspace upload/download/rename');
let ready=false;
for(let n=0;n<45;n++){try{const r=await request('/dsh/'+instance.id+'/');if(typeof r.data==='string'&&r.data.includes('<html')){ready=true;break}}catch{}await new Promise(r=>setTimeout(r,1000));}
assert.ok(ready,'authenticated DSH proxy never became ready');
console.log('PASS real Docker create/start and authenticated DSH proxy');
if(process.env.TEST_PLUGIN==='1'){
const seed = `const fs=require('fs');const p=process.env.DSH_HOME+'/profiles/web/package.json';const pkg=JSON.parse(fs.readFileSync(p,'utf8'));pkg.dependencies={...(pkg.dependencies||{}),'is-number':'7.0.0'};fs.writeFileSync(p,JSON.stringify(pkg));`;
execFileSync('docker',['exec','dsh-inst-'+instance.id,'node','-e',seed]);
console.log('Seeded exact plugin dependency fixture');
}
const root='/api/instances/'+instance.id;
await request(root+'/stop','POST');
const profile=(await request(root+'/profile')).data;assert.equal(typeof profile.content,'string');
await request(root+'/profile','PUT',{content:profile.content});
const backup=(await request(root+'/backups','POST',{password:process.env.TEST_PASSWORD || 'Local-Test-Only-1842'})).data;
assert.ok(backup.id,'backup ID');
const archive=await fetch(base+root+'/backups?download='+backup.id,{headers:{Cookie:cookie}});
assert.equal(archive.status,200);
const importForm=new FormData();importForm.set('file',new Blob([await archive.arrayBuffer()]),'roundtrip.dshbak');importForm.set('password',process.env.TEST_PASSWORD || 'Local-Test-Only-1842');
const imported=(await request(root+'/backups','POST',importForm)).data;assert.ok(imported.id);
await request(root+'/restore','POST',{backup_id:backup.id,password:process.env.TEST_PASSWORD || 'Local-Test-Only-1842'});
console.log('PASS stopped profile save, encrypted backup and restore');
await request(root+'/start','POST');await request(root+'/stop','POST');
if(process.env.TEST_UPGRADE==='1'){instance=(await request(root+'/upgrade','POST',{image_id:target.image_id||target.id,password:process.env.TEST_PASSWORD || 'Local-Test-Only-1842'})).data;console.log('PASS image replacement upgrade '+source.version+' -> '+target.version);}
if(process.env.TEST_PLUGIN==='1'){
execFileSync('docker',['exec',instance.container_id || 'dsh-inst-'+instance.id,'node','-e',`const fs=require('fs');const p=process.env.DSH_HOME+'/profiles/web/node_modules/is-number/package.json';if(JSON.parse(fs.readFileSync(p)).version!=='7.0.0')process.exit(1)`]);
console.log('PASS manifest dependency rebuilt across restore and upgrade');
}
if(process.env.TEST_OFFLINE_ROLLBACK==='1'){
const container=instance.container_id;assert.ok(container);
execFileSync('docker',['exec',container,'node','-e',`const fs=require('fs');const p=process.env.DSH_HOME+'/profiles/web/package.json';const pkg=JSON.parse(fs.readFileSync(p));pkg.dependencies['offline-design-fixture']='http://127.0.0.1:9/unavailable.tgz';fs.writeFileSync(p,JSON.stringify(pkg));fs.writeFileSync(process.env.DSH_HOME+'/rollback-marker','preserve-me');`]);
await request(root+'/stop','POST');
await assert.rejects(request(root+'/upgrade','POST',{image_id:target.image_id||target.id,password:process.env.TEST_PASSWORD}),/upgrade readiness failed/);
const current=(await request('/api/instances')).data.find(i=>i.id===instance.id);
assert.equal(current.container_id,container);assert.ok(['stopped','exited'].includes(current.status));assert.equal(current.version,target.version);
const inspect=JSON.parse(execFileSync('docker',['inspect',container]));assert.equal(inspect[0].State.Running,false);
console.log('PASS offline dependency failure rolls back identity and original stopped state');
}
await request('/api/ws/delete?path='+testDirectory+'/renamed.txt','DELETE');
await request('/api/ws/delete?path='+testDirectory,'DELETE');
console.log('PASS lifecycle and workspace cleanup');
}finally{
if(instance){await request('/api/instances/'+instance.id,'DELETE');console.log('Removed disposable instance; retained test data per API policy.');}
}