mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2026-11-02 19:02:31 +00:00
fix: read completed update results without a live terminal
This commit is contained in:
@@ -249,7 +249,7 @@ class Routes(unittest.TestCase):
|
||||
_TICKET_MAX_INFLIGHT=256,time=time,secrets=__import__('secrets'),json=json,
|
||||
_host_update_completed=self.callback)
|
||||
names={'_issue_terminal_ticket','_consume_terminal_ticket','_ws_auth_check',
|
||||
'issue_terminal_ticket_route','host_update_jobs_status','host_update_viewer'}
|
||||
'issue_terminal_ticket_route','host_update_jobs_status','host_update_result','host_update_viewer'}
|
||||
nodes=[n for n in ast.parse((ROOT/'AppImage/scripts/flask_terminal_routes.py').read_text()).body if isinstance(n,ast.FunctionDef) and n.name in names]
|
||||
self.assertEqual(len(nodes),len(names))
|
||||
exec(compile(ast.Module(body=nodes,type_ignores=[]),'actual-host-routes','exec'),scope)
|
||||
@@ -277,10 +277,33 @@ class Routes(unittest.TestCase):
|
||||
with patch.object(jobs.subprocess,'Popen') as spawn:
|
||||
self.assertEqual(self.ticket(token).status_code,status)
|
||||
self.assertEqual(self.client.get('/api/host-update-jobs',headers={'Authorization':'Bearer '+token}).status_code,status)
|
||||
self.assertEqual(self.client.get('/api/host-update-jobs/fixture/result',headers={'Authorization':'Bearer '+token}).status_code,status)
|
||||
self.attach(dict(resume=False))
|
||||
spawn.assert_not_called()
|
||||
self.assertEqual(jobs.list_jobs(),[])
|
||||
|
||||
def test_finished_result_is_read_only_and_bounded_by_owner_output(self):
|
||||
view=dict(state='complete',exit_code=0,text='inert saved output',truncated=False)
|
||||
with patch.object(jobs,'get',return_value=SimpleNamespace(read=Mock(return_value=view))) as get,patch.object(jobs,'start') as start,patch.object(jobs.subprocess,'Popen') as spawn:
|
||||
response=self.client.get('/api/host-update-jobs/fixture/result',headers={'Authorization':'Bearer valid'})
|
||||
self.assertEqual(response.status_code,200)
|
||||
self.assertEqual(response.json,view)
|
||||
self.assertEqual(response.headers['Cache-Control'],'no-store')
|
||||
get.assert_called_once_with('fixture');start.assert_not_called();spawn.assert_not_called()
|
||||
|
||||
def test_result_auth_missing_invalid_running_never_starts_job(self):
|
||||
with patch.object(jobs,'get') as get,patch.object(jobs,'start') as start,patch.object(jobs.subprocess,'Popen') as spawn:
|
||||
self.assertEqual(self.client.get('/api/host-update-jobs/fixture/result').status_code,401)
|
||||
get.assert_not_called()
|
||||
get.side_effect=KeyError('missing')
|
||||
self.assertEqual(self.client.get('/api/host-update-jobs/missing/result',headers={'Authorization':'Bearer valid'}).status_code,404)
|
||||
get.side_effect=ValueError('invalid')
|
||||
self.assertEqual(self.client.get('/api/host-update-jobs/invalid/result',headers={'Authorization':'Bearer valid'}).status_code,404)
|
||||
get.side_effect=None
|
||||
for state in ('running','attention','finalizing'):
|
||||
get.return_value=SimpleNamespace(read=Mock(return_value=dict(state=state,exit_code=None)))
|
||||
self.assertEqual(self.client.get('/api/host-update-jobs/fixture/result',headers={'Authorization':'Bearer valid'}).status_code,409)
|
||||
start.assert_not_called();spawn.assert_not_called()
|
||||
def test_real_route_disconnect_401_then_relogin_replays_one_job(self):
|
||||
self.attach(dict(resume=False),self.ticket().json['ticket'])
|
||||
job=jobs.get('job-1')
|
||||
|
||||
@@ -78,6 +78,10 @@ def wire_fixture():
|
||||
assert events.read_text().splitlines() == ['start', 'poststep']
|
||||
assert len(callbacks) == 1
|
||||
assert 'completed' in ''.join(frame.get('text', '') for frame in frames)
|
||||
saved=requests.get(base+'/api/host-update-jobs/one-host-run/result',headers=headers,timeout=3)
|
||||
assert saved.status_code==200 and saved.headers['Cache-Control']=='no-store'
|
||||
assert saved.json()['exit_code']==0 and 'completed' in saved.json()['text']
|
||||
assert events.read_text().splitlines()==['start','poststep'] and len(callbacks)==1
|
||||
if viewer.connected:
|
||||
viewer.close()
|
||||
viewer = connect('missing-host-run', True)
|
||||
|
||||
@@ -60,4 +60,36 @@ const en=JSON.parse(fs.readFileSync(path.join(app,'messages/en/common.json'),'ut
|
||||
const sk=JSON.parse(fs.readFileSync(path.join(app,'messages/sk/common.json'),'utf8'))
|
||||
assert.deepEqual(Object.keys(en.healthStatus.hostUpdate).sort(),Object.keys(sk.healthStatus.hostUpdate).sort())
|
||||
assert.ok(health.includes('Number(categoryData?.count ?? 0) > 0'),'reboot-only state has no Update Now action')
|
||||
console.log('PASS host initial/resume transport, legacy/LXC routes, disconnect/unmount and EN/SK keys')
|
||||
const savedTree=ts.createSourceFile('result.tsx',fs.readFileSync(path.join(app,'components/host-update-result.tsx'),'utf8'),ts.ScriptTarget.Latest,true,ts.ScriptKind.TSX)
|
||||
let readResult
|
||||
function resultWalk(n){
|
||||
if(ts.isArrowFunction(n)&&n.modifiers?.some(m=>m.kind===ts.SyntaxKind.AsyncKeyword)&&n.getText(savedTree).includes('value.state'))readResult=n.getText(savedTree)
|
||||
ts.forEachChild(n,resultWalk)
|
||||
}
|
||||
resultWalk(savedTree);assert.ok(readResult)
|
||||
async function results(){
|
||||
const response={state:'complete',exit_code:0,text:'inert saved result',truncated:false}
|
||||
for(const code of [0,1,130,-15]){
|
||||
const calls=[],writes=[],codes=[],errors=[]
|
||||
const scope={getAuthToken:()=>null,getApiUrl:p=>p,jobId:'saved-id',controller:new AbortController(),container:{current:{}},disposed:false,
|
||||
fetch:async(p,options)=>{calls.push({p,options});return {ok:true,json:async()=>({...response,exit_code:code})}},
|
||||
require:n=>n==='xterm'?{Terminal:class{loadAddon(){}open(){}write(v){writes.push(v)}writeln(v){writes.push(v)}}}:{FitAddon:class{fit(){}}},
|
||||
ResizeObserver:class{observe(){}},setExitCode:v=>codes.push(v),setFailed:v=>errors.push(v),clearTimeout(){},timeout:1,t:s=>s}
|
||||
await compile(readResult,scope)()
|
||||
assert.deepEqual(codes,[code]);assert.deepEqual(errors,[]);assert.deepEqual(writes,['inert saved result'])
|
||||
assert.equal(calls.length,1);assert.equal(calls[0].p,'/api/host-update-jobs/saved-id/result');assert.ok(!calls[0].options.method||calls[0].options.method==='GET')
|
||||
for(const value of [null,{...response,state:'running'},{...response,exit_code:false},{...response,text:null},{...response,truncated:null}]){
|
||||
codes.length=errors.length=writes.length=0
|
||||
scope.fetch=async()=>({ok:true,json:async()=>value});await compile(readResult,scope)()
|
||||
assert.deepEqual(codes,[]);assert.deepEqual(writes,[]);assert.deepEqual(errors,[true])
|
||||
}
|
||||
codes.length=errors.length=writes.length=0
|
||||
scope.fetch=async()=>({ok:false});await compile(readResult,scope)()
|
||||
assert.deepEqual(errors,[true]);assert.deepEqual(codes,[]);assert.deepEqual(writes,[])
|
||||
codes.length=errors.length=writes.length=0;scope.disposed=true;scope.controller.abort()
|
||||
scope.fetch=async()=>({ok:true,json:async()=>response});await compile(readResult,scope)()
|
||||
assert.deepEqual(codes,[]);assert.deepEqual(errors,[]);assert.deepEqual(writes,[],'closed result cannot receive a late response')
|
||||
}
|
||||
console.log('PASS host/legacy/LXC transports and actual readonly result GET: success/error/cancel/invalid/late-response; zero WS/start callbacks')
|
||||
}
|
||||
results().catch(error=>{console.error(error);process.exitCode=1})
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
"""Actual installed-version function; package changes invalidate its old display."""
|
||||
import ast
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
import unittest
|
||||
from unittest.mock import Mock
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
class VersionCache(unittest.TestCase):
|
||||
def setUp(self):
|
||||
source = ast.parse((ROOT/'AppImage/scripts/flask_server.py').read_text())
|
||||
node = next(n for n in source.body if isinstance(n, ast.FunctionDef) and n.name == 'get_proxmox_version')
|
||||
self.cache = dict(proxmox_version='9.2.21', proxmox_version_time=100, proxmox_version_stamp=1)
|
||||
self.command = Mock(return_value=SimpleNamespace(returncode=0, stdout='pve-manager/9.2.24/hash\n'))
|
||||
self.stamp = Mock(return_value=1)
|
||||
self.scope = dict(is_pbs_host=Mock(return_value=False), get_platform=Mock(return_value={'version':'4.1.0'}),
|
||||
_system_info_cache=self.cache, _SYSTEM_INFO_CACHE_TTL=21600,
|
||||
_AVAILABLE_UPDATES_MIN_INTERVAL=30, _apt_state_stamp=self.stamp,
|
||||
time=SimpleNamespace(time=Mock(return_value=200)), subprocess=SimpleNamespace(run=self.command))
|
||||
exec(compile(ast.Module(body=[node], type_ignores=[]), 'actual-installed-version', 'exec'), self.scope)
|
||||
|
||||
def test_unchanged_package_state_has_zero_commands(self):
|
||||
self.assertEqual(self.scope['get_proxmox_version'](), '9.2.21')
|
||||
self.command.assert_not_called()
|
||||
|
||||
def test_package_change_at_floor_refreshes_version_without_apt(self):
|
||||
self.stamp.return_value=2
|
||||
self.scope['time'].time.return_value=129.999
|
||||
self.assertEqual(self.scope['get_proxmox_version'](), '9.2.21')
|
||||
self.command.assert_not_called()
|
||||
self.scope['time'].time.return_value=130
|
||||
self.assertEqual(self.scope['get_proxmox_version'](), '9.2.24')
|
||||
self.command.assert_called_once_with(['pveversion'],capture_output=True,text=True,timeout=5)
|
||||
self.assertEqual(self.cache['proxmox_version_stamp'],2)
|
||||
|
||||
def test_failed_read_returns_unknown_but_keeps_last_known_cache(self):
|
||||
self.stamp.return_value=2
|
||||
before=dict(self.cache)
|
||||
self.command.side_effect=OSError('inert unavailable command')
|
||||
self.assertIsNone(self.scope['get_proxmox_version']())
|
||||
self.assertEqual(self.cache,before)
|
||||
|
||||
def test_pbs_guard_precedes_all_pve_package_probes(self):
|
||||
self.scope['is_pbs_host'].return_value=True
|
||||
self.assertEqual(self.scope['get_proxmox_version'](),'4.1.0')
|
||||
self.stamp.assert_not_called();self.command.assert_not_called()
|
||||
|
||||
|
||||
if __name__=='__main__':
|
||||
unittest.main(verbosity=2)
|
||||
@@ -143,6 +143,29 @@ class FullHealth(unittest.TestCase):
|
||||
self.assertEqual(code, 200)
|
||||
self.owner.get_detailed_status.assert_called_once()
|
||||
|
||||
def test_explicit_refresh_publishes_then_poll_never_rechecks(self):
|
||||
result, code = self.call('?refresh=1')
|
||||
self.assertEqual(code, 200)
|
||||
self.assertIn('_bg_detailed', self.owner.cached_results)
|
||||
self.assertEqual(self.owner.cached_results['_bg_overall']['status'],result['health']['overall'])
|
||||
self.assertEqual(self.owner.last_check_times['_bg_overall'],self.owner.last_check_times['_bg_detailed'])
|
||||
before = copy.deepcopy(self.owner.cached_results)
|
||||
result, code = self.call('?cached=1')
|
||||
self.assertEqual(code, 200)
|
||||
self.assertEqual(result['health']['timestamp'], 'original')
|
||||
self.assertEqual(self.owner.cached_results, before)
|
||||
self.owner.get_detailed_status.assert_called_once()
|
||||
|
||||
def test_invalid_live_result_never_publishes_or_becomes_fresh(self):
|
||||
for result in (None, {}, {'details': {}}, {'details': {'updates': {'status': 'OK'}}}):
|
||||
with self.subTest(result=result):
|
||||
self.owner.cached_results.clear()
|
||||
self.owner.last_check_times.clear()
|
||||
self.owner.get_detailed_status.return_value = result
|
||||
self.assertEqual(self.call()[1], 500)
|
||||
self.assertNotIn('_bg_detailed', self.owner.cached_results)
|
||||
self.assertNotIn('_bg_detailed', self.owner.last_check_times)
|
||||
|
||||
|
||||
class Overlay(unittest.TestCase):
|
||||
def test_repeated_required_unknown_current_preserves_package_result_and_snapshot(self):
|
||||
|
||||
Reference in New Issue
Block a user