aim-web2.1.0rc9

This commit is contained in:
admin_rb
2026-09-22 19:23:17 +02:00
parent d095887d2e
commit 3dfc80b782
438 changed files with 31613 additions and 1510 deletions
@@ -0,0 +1,46 @@
"""Synthetic local metadata retention benchmark; no Core calls or managed hosts."""
from pathlib import Path
import argparse,json,resource,sys,tempfile,time
sys.path.insert(0,str(Path(__file__).resolve().parents[1]/'src'))
from aim_webgui.auth.service import Auth
from aim_webgui.config import Settings
from aim_webgui.journal import Journal,project
from evidence_fixtures import job,ev
def main():
parser=argparse.ArgumentParser()
parser.add_argument('--events',type=int,default=25000)
parser.add_argument('--out',type=Path,required=True)
args=parser.parse_args()
if not 1<=args.events<=200000:parser.error('events must be 1..200000')
with tempfile.TemporaryDirectory(prefix='aim-journal-benchmark-') as directory:
settings=Settings(state_dir=Path(directory)/'state',public_url='https://example.test')
auth=Auth(settings);auth.bootstrap()
with auth.store.transaction() as db:
db.execute('UPDATE users SET must_change_password=0')
owner=db.execute('SELECT id FROM users').fetchone()[0]
ident=job(auth,owner);journal=Journal(settings);journal.begin(ident)
begin=time.perf_counter()
for start in range(1,args.events+1,128):
batch=[(*project(ev(i),{'test01.example'}),time.time()) for i in range(start,min(start+128,args.events+1))]
journal.append(ident,batch)
journal.append(ident,[],closed=True)
elapsed=time.perf_counter()-begin
t=time.perf_counter();snapshot=journal.snapshot(owner,ident);read=time.perf_counter()-t
assert snapshot['cursor']==args.events
assert snapshot['retained_events']<=settings.journal_max_events
assert snapshot['retained_bytes']<=settings.journal_max_bytes
assert snapshot['checkpoint']['observed_task_starts']==args.events
assert len(snapshot['events'])<=200
result={'synthetic_only':True,'events':args.events,'batch_events':128,
'write_seconds':round(elapsed,4),'latest_snapshot_seconds':round(read,4),
'retained_events':snapshot['retained_events'],'omitted_events':snapshot['omitted_events'],
'retained_metadata_bytes':snapshot['retained_bytes'],'response_events':len(snapshot['events']),
'checkpoint_tasks':len(snapshot['checkpoint']['tasks']),
'process_peak_rss_kib':resource.getrusage(resource.RUSAGE_SELF).ru_maxrss,
'database_bytes':settings.database.stat().st_size,'core_calls':0,
'limits':'20,000 event / 8 MiB tail, using synchronous 128-event batches; not executor/load qualification'}
args.out.write_text(json.dumps(result,indent=2)+'\n');print(json.dumps(result,indent=2))
if __name__=='__main__':main()
@@ -0,0 +1,65 @@
"""Optional synthetic read-projection benchmark; never pass a production DB.
python tests/benchmark_read_history.py --jobs 20000 --out /tmp/aim-history-benchmark.json
Uses an automatically cleaned temporary database and only retained WebGUI fixtures.
"""
from pathlib import Path
import argparse
import json
import resource
import statistics
import sys
import tempfile
import time
sys.path.insert(0,str(Path(__file__).resolve().parents[1]/'src'))
from aim_webgui.activity import Activity, HistoryFilter
from aim_webgui.auth.service import Auth
from aim_webgui.config import Settings
def main():
parser=argparse.ArgumentParser()
parser.add_argument('--jobs',type=int,default=20000)
parser.add_argument('--out',type=Path,required=True)
args=parser.parse_args()
if not 1 <= args.jobs <= 100000:parser.error('--jobs must be 1..100000')
counts=('ok','changed','failures','unreachable','skipped','rescued','ignored')
now=int(time.time())
with tempfile.TemporaryDirectory(prefix='aim-read-benchmark-') as tmp:
auth=Auth(Settings(state_dir=Path(tmp)/'state',public_url='https://fixture.invalid').validate())
auth.bootstrap()
with auth.store.transaction() as db:
db.execute('UPDATE users SET must_change_password=0')
actor=db.execute('SELECT id FROM users WHERE username=?',('admin',)).fetchone()[0]
for i in range(args.jobs):
hosts=[f'host-{(i+j)%200:04d}.example' for j in range(4)]
outcome='unreachable' if i%5==0 else 'successful'
targets=[]
for j,h in enumerate(hosts):
c=dict.fromkeys(counts,0);c['ok']=30;c['changed']=1
state=outcome if j==3 else 'successful'
if state=='unreachable':c['unreachable']=1
targets.append({'host':h,'outcome':state,'counts':c})
unreachable=int(outcome=='unreachable')
result={'status':'failed' if unreachable else 'succeeded','targets':targets,
'target_summary':{'schema':'target_outcome_summary_v1','requested':4,'accounted':4,'complete':True,
'successful':4-unreachable,'failed':0,'unreachable':unreachable,'indeterminate':0,'not_started':0}}
plan={'customer':'synthetic','playbook':f'debug_fixture_{i%8}','targets':hosts}
db.execute('INSERT INTO jobs(id,owner_id,plan,mode,status,created_at,scheduled_at,finished_at,core_result) VALUES(?,?,?,?,?,?,?,?,?)',
(f'{i:032x}',actor,json.dumps(plan),'apply','failed' if unreachable else 'successful',now-i-1,now-i-1,now-i-1,json.dumps(result)))
reader=Activity(auth.settings);durations=[]
for _ in range(3):
start=time.perf_counter();report=reader.report(actor,HistoryFilter(days='all'),now=now)
durations.append(time.perf_counter()-start)
assert report['jobs']==args.jobs and report['samples']==args.jobs*4
assert len(report['records'])==min(25,args.jobs*4) and len(report['matrix'])<=20
result={'synthetic_only':True,'jobs':args.jobs,'host_run_samples':report['samples'],
'distinct_customer_hosts':report['host_count'],'playbooks':len(report['playbooks']),
'runs_seconds':durations,'median_seconds':statistics.median(durations),
'process_peak_rss_kib':resource.getrusage(resource.RUSAGE_SELF).ru_maxrss,
'sqlite_bytes':auth.settings.database.stat().st_size,'output_records':len(report['records']),
'matrix_rows':len(report['matrix']),'core_calls':0,'projection_tables_added':0}
args.out.write_text(json.dumps(result,indent=2)+'\n')
print(json.dumps(result,indent=2))
if __name__=='__main__':main()
@@ -0,0 +1,200 @@
"""Chromium about:blank fixtures bridged to real ASGI endpoints; synthetic worker handoffs.
This is not live systemd/Core/SSH qualification. Bootstrap may be a declared
substitute. HTMX is deliberately not loaded when unavailable: status replacements
are driven explicitly and labeled as synthetic lifecycle coverage, not HTMX proof.
Never install fixture assets into production static/vendor.
"""
from pathlib import Path
import argparse
import json
import re
import sys
import tempfile
import time
from urllib.parse import urlsplit
sys.path.insert(0, str(Path(__file__).resolve().parents[1]/'src'))
import pytest
from fastapi.testclient import TestClient
from playwright.sync_api import sync_playwright
from aim_webgui.app import create_app
from aim_webgui.credentials import wire
from test_credential_ux import authz as auth_fixture, make_job, SYNTHETIC
ROOT = Path(__file__).resolve().parents[1]
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--out', type=Path, required=True)
parser.add_argument('--bootstrap', type=Path, default=Path('/opt/imagemagick/share/doc/ImageMagick-7/www/assets/bootstrap.min.css'))
args = parser.parse_args(); args.out.mkdir(parents=True, exist_ok=True)
cases = []
with tempfile.TemporaryDirectory(prefix='aim-credential-qa-') as tmp, pytest.MonkeyPatch.context() as monkey:
auth, users = auth_fixture.__wrapped__(Path(tmp), monkey)
fixture = (auth, users)
packets = []
class Sock:
def __enter__(self): return self
def __exit__(self, *args): pass
monkey.setattr(wire, 'connect', lambda *a, **k: Sock())
monkey.setattr(wire, 'send', lambda sock, packet, limit: packets.append(json.loads(json.dumps(packet))))
def claimed(*args):
ident = packets[-1]['job']
with auth.store.transaction() as db:
db.execute("UPDATE jobs SET credential_phase='claimed' WHERE id=?", (ident,))
return {'accepted': True}
monkey.setattr(wire, 'receive', claimed)
origin = auth.settings.public_url
with TestClient(create_app(auth.settings), base_url=origin, follow_redirects=False) as server, sync_playwright() as pw:
browser = pw.chromium.launch(executable_path='/usr/bin/chromium', args=['--no-sandbox', '--disable-dev-shm-usage'])
try:
for width, height in ((320,750),(390,844),(760,900),(1440,1000),(740,390)):
for theme in ('light','dark'):
# Independent viewport fixtures must not share the live five/minute bucket.
# Production throttle remains enabled and is covered in unit tests.
with auth.store.transaction() as db: db.execute('DELETE FROM rate_limits')
token, session = auth.new_session(users['operator'])
context = browser.new_context(viewport={'width':width,'height':height}, locale='en-GB', timezone_id='Europe/Berlin', reduced_motion='reduce')
context.add_cookies([{'name':auth.settings.cookie_name, 'value':token, 'url':origin,'secure':True,'httpOnly':True,'sameSite':'Lax'}])
errors = []
mode = {'drop_post':False}
def bridge(url, options):
path = urlsplit(url).path
method = options.get('method', 'GET')
if mode['drop_post'] and method == 'POST' and path.endswith('/credentials'):
mode['post_count'] = mode.get('post_count',0)+1
return {'network_error': True}
headers = {**options.get('headers', {}), 'Origin': origin}
response = server.request(method, path, headers=headers, content=options.get('body'))
return {'status': response.status_code, 'body': response.text}
server.cookies.set(auth.settings.cookie_name, token)
page = context.new_page(); page.on('pageerror', lambda e: errors.append(str(e)))
page.expose_function('_fixtureHTTP', bridge)
def mount(path):
page.goto('about:blank')
content = server.get(path).text
content = re.sub(r'<script[^>]+src="[^"]+"[^>]*></script>', '', content)
content = re.sub(r'<link[^>]+rel="stylesheet"[^>]*>', '', content)
css = args.bootstrap.read_text() + '\n' + '\n'.join((ROOT/'src/aim_webgui/static/css'/n).read_text() for n in ('tokens.css','bootstrap-overrides.css','aim.css','experience.css','credentials.css','evidence.css'))
fixture_js = """
window.fetch = async function(url, options) {
const r=await window._fixtureHTTP(url, options||{});
if(r.network_error) throw new TypeError('Synthetic lost response');
return new Response(r.body,{status:r.status,headers:{'Content-Type':'application/json'}});
};
window.EventSource=class { constructor(){} addEventListener(){} close(){} };
"""
js='\n'.join((ROOT/'src/aim_webgui/static/js'/n).read_text() for n in ('theme.js','aim.js','experience.js','credentials.js','evidence.js'))
content=content.replace('</head>','<style>'+css+'</style></head>').replace('</body>','<script>'+fixture_js+js+'</script></body>')
page.set_content(content, wait_until='load')
try:
ident = make_job(fixture)
mount('/jobs/'+ident)
page.locator('[data-theme-option='+theme+']:visible').click()
opener = page.locator('[data-credential-open]')
opener.click()
panel = page.locator('dialog [data-credential-panel]'); panel.wait_for()
assert page.locator('dialog').evaluate('(e)=>e.open')
assert page.locator('#credential-dialog-title').evaluate('(e)=>e===document.activeElement')
assert page.locator('[data-key-source-choice]').is_visible()
assert not page.locator('[name="ssh_key_passphrase"]').is_visible()
assert not page.locator('dialog').evaluate('(e)=>e.scrollWidth>e.clientWidth+1')
assert page.evaluate('document.documentElement.scrollWidth<=innerWidth')
box=page.locator('dialog').bounding_box(); assert box['x']>=0 and box['y']>=0 and box['x']+box['width']<=width+1 and box['y']+box['height']<=height+1
# Native modal keeps background inert and cycles focus within dialog.
for _ in range(12):
page.keyboard.press('Tab')
assert page.evaluate("document.querySelector('dialog').contains(document.activeElement)")
page.locator('[name="vault_password"]').fill(SYNTHETIC)
page.locator('button[aria-controls="credential-vault"]').click()
assert page.locator('[name="vault_password"]').get_attribute('type')=='text'
# Poll replacement cannot replace the modal or its typed/revealed input.
page.evaluate("async (job)=>{const r=await fetch('/_partials/jobs/'+job);const html=await r.text();document.getElementById('job-status').outerHTML=html;document.dispatchEvent(new CustomEvent('htmx:afterSwap',{detail:{target:document.getElementById('job-status')}}));}", ident)
assert page.locator('[name="vault_password"]').input_value()==SYNTHETIC
page.locator('label[for="credential-key-separate"]').click()
page.locator('[name="ssh_key_passphrase"]').fill('Synthetic separate key')
page.locator('label[for="credential-key-vault"]').click()
assert page.locator('[name="ssh_key_passphrase"]').input_value()==''
# Revealed input must be cleared on Escape, not just hidden visually.
page.keyboard.press('Escape')
assert not page.locator('dialog').evaluate('(e)=>e.open')
assert page.locator('[data-credential-secret]').count()==0
assert page.locator('[data-credential-open]').evaluate('(e)=>e===document.activeElement')
page.locator('[data-credential-open]').click(); panel.wait_for()
assert page.locator('[name="vault_password"]').input_value()==''
if theme=='dark' and width in (390,1440):
page.screenshot(path=str(args.out/f'credential-dialog-{width}.png'), full_page=False)
start=len(packets)
page.locator('[name="vault_password"]').fill(SYNTHETIC)
page.locator('[data-credential-submit]').click()
page.locator('[data-credential-feedback][data-tone="accepted"]').wait_for()
assert len(packets)==start+1 and packets[-1]['credentials']['vault_password']==SYNTHETIC
assert 'ssh_key_passphrase' not in packets[-1]['credentials']
assert page.locator('[name="vault_password"]').input_value()==''
assert 'accepted' in page.locator('[data-credential-feedback]').inner_text().lower()
assert page.url=='about:blank'
page.locator('[data-credential-dismiss]').click()
assert not page.locator('dialog').evaluate('(e)=>e.open')
# Attention action belongs to this viewer; choose only their credentials.
ident2=make_job(fixture, requirements=['vault_password'])
mount('/jobs')
page.locator('#attention [data-job-id="'+ident2+'"]').click(); panel.wait_for()
assert page.locator('[data-key-source-choice]').count()==0
# Lost acknowledgement: only one POST, then GET reconciliation; no replay.
mode['drop_post']=True;mode['post_count']=0
page.locator('[name="vault_password"]').fill(SYNTHETIC)
page.locator('[data-credential-submit]').click()
page.wait_for_function("document.querySelector('[data-credential-feedback]').textContent.includes('not confirmed')")
page.wait_for_timeout(3500)
assert mode['post_count']==1 and page.locator('[name="vault_password"]').input_value()==''
page.locator('[data-credential-dismiss]').click()
page.locator('#attention [data-job-id="'+ident2+'"]').click();panel.wait_for()
page.wait_for_function("document.querySelector('[data-credential-feedback]').textContent.includes('not been confirmed')")
assert page.locator('[data-credential-submit]').is_hidden()
page.keyboard.press('Escape');mode['drop_post']=False
# Server cancels while the form is open: input is cleared without POST.
ident3=make_job(fixture)
mount('/jobs/'+ident3);page.locator('[data-credential-open]').click();panel.wait_for()
page.locator('[name="vault_password"]').fill(SYNTHETIC)
with auth.store.transaction() as db: db.execute('UPDATE jobs SET cancel_requested=1 WHERE id=?',(ident3,))
page.wait_for_function("document.querySelector('[data-credential-feedback]').textContent.includes('cancellation')")
assert page.locator('[name="vault_password"]').input_value()==''
page.keyboard.press('Escape')
# Short visual viewport is internally scrollable; footer stays reachable.
ident4=make_job(fixture)
mount('/jobs/'+ident4);page.locator('[data-credential-open]').click();panel.wait_for()
page.set_viewport_size({'width':width,'height':min(360,height)})
page.wait_for_timeout(100)
assert page.locator('.credential-dialog-body').evaluate('(e)=>e.scrollHeight>e.clientHeight')
page.locator('[data-credential-submit]').scroll_into_view_if_needed()
box=page.locator('dialog').bounding_box();assert box['y']>=-1 and box['y']+box['height']<=min(360,height)+1
# Page hide clears revealed secrets too. BFCache style re-init never restores them.
page.locator('[name="vault_password"]').fill(SYNTHETIC)
page.locator('button[aria-controls="credential-vault"]').click()
page.evaluate("window.dispatchEvent(new PageTransitionEvent('pagehide'))")
assert page.locator('[data-credential-secret]').count()==0
assert not errors, errors
cases.append({'width':width,'height':height,'theme':theme,'passed':True})
print('Credential QA passed',width,height,theme,flush=True)
finally: context.close()
# No-JS native markup and a plain HTTP form POST are verified separately.
with auth.store.transaction() as db: db.execute('DELETE FROM rate_limits')
token, session = auth.new_session(users['operator'])
server.cookies.set(auth.settings.cookie_name, token)
ident=make_job(fixture,requirements=['vault_password'])
r=server.get('/jobs/'+ident+'/credentials')
assert r.status_code==200 and 'method="post"' in r.text
r=server.post('/jobs/'+ident+'/credentials', data={'_csrf':session['csrf'],'vault_password':SYNTHETIC},headers={'Origin':origin})
assert r.status_code==303 and r.headers['location']=='/jobs/'+ident
cases.append({'plain_http_form_fallback':True,'passed':True})
finally: browser.close()
report={'fixture_only':True,'cases':cases,'asgi_auth_csrf_endpoints':True,'browser_network':'about:blank fetch bridge to real TestClient; no real browser CSP/TLS/cookie transport qualification',
'bootstrap_source':str(args.bootstrap),'bootstrap_substitution':'5.3.6, production pin 5.3.8',
'htmx_loaded':False,'htmx_swap':'synthetic replacement + lifecycle event',
'real_worker_core_execution':False,'browser':'Chromium via Playwright'}
(args.out/'results.json').write_text(json.dumps(report,indent=2))
print(len(cases),'browser scenario groups passed')
if __name__=='__main__': main()
@@ -0,0 +1,136 @@
"""Chromium fixture evidence QA. ASGI GET with simulated EventSource, no native tasks.
Default CSS is the explicitly identified Bootstrap 5.3.6 fixture substitute.
No fixture stylesheet is copied into the release. HTMX is deliberately unavailable
unless the operator supplies the pinned asset; this is not proxy/HTTPS acceptance.
"""
from pathlib import Path
import argparse,json,os,socket,sys,tempfile,time,re
sys.path.insert(0,str(Path(__file__).resolve().parents[1]/'src'))
from dataclasses import replace
from fastapi.testclient import TestClient
from playwright.sync_api import sync_playwright
from aim_webgui.app import create_app
from aim_webgui.auth.service import Auth
from aim_webgui.config import Settings
from aim_webgui.journal import Journal,project
from aim_webgui.reports import persist, TITLES
from evidence_fixtures import declared,sample,plan,result,job,ev
def main():
p=argparse.ArgumentParser();p.add_argument('--out',type=Path,required=True);p.add_argument('--bootstrap',type=Path,default=Path('/opt/imagemagick/share/doc/ImageMagick-7/www/assets/bootstrap.min.css'));args=p.parse_args();args.out.mkdir(parents=True,exist_ok=True)
with tempfile.TemporaryDirectory(prefix='aim-evidence-browser-')as td:
state=Path(td)/'state'
sock=socket.socket();sock.bind(('127.0.0.1',0));port=sock.getsockname()[1];sock.close()
url=f'http://127.0.0.1:{port}'
s=Settings(state_dir=state,public_url=url,core_transport='stdio');a=Auth(s);a.bootstrap()
with a.store.transaction()as db:
db.execute('UPDATE users SET must_change_password=0');uid=db.execute('SELECT id FROM users').fetchone()[0]
paths={};jobs={}
for name in TITLES:
d=declared(name);data=sample(d['data_schema'])
if name=='host_capabilities_v1':data['is_unifi_controller']=True
if name=='filesystem_usage_v1':
data={'platform':'linux','filesystems':[{'name':'/dev/synthetic1','mount':'/','filesystem_type':'ext4','used_bytes':42949672960,'total_bytes':128849018880,'available_bytes':85899345920,'used_percent':33.3,'status':'available'},{'name':'/dev/synthetic2','mount':'/srv/archive','filesystem_type':None,'used_bytes':None,'total_bytes':None,'available_bytes':None,'used_percent':None,'status':'unavailable'}]}
ident=job(a,uid,plan(d),status='successful');r=result(d,report_data=data)
with a.store.transaction()as db:
small=persist(db,s,ident,plan(d),r)
db.execute('UPDATE jobs SET core_result=?,finished_at=? WHERE id=?',(json.dumps(small),time.time(),ident))
paths[name]='/jobs/'+ident+'/reports';jobs[name]=ident
running=job(a,uid,plan(declared()),status='running');j=Journal(s);j.begin(running)
def add(start,end):j.append(running,[(*project(ev(i),{'test01.example'}),time.time())for i in range(start,end+1)])
add(1,300)
paths['progress']='/jobs/'+running
client=TestClient(create_app(s),base_url=url)
token,_=a.new_session(uid);client.cookies.set(s.cookie_name,token);records=[]
static=Path(__file__).resolve().parents[1]/'src/aim_webgui/static'
def html(path):
response=client.get(path);assert response.status_code==200,(path,response.text[:400])
text=response.text
def css(m):
name=m.group(1).split('?')[0]
source=args.bootstrap if 'bootstrap.min.css' in name else static/name.removeprefix('/static/')
return '<style>'+source.read_text()+'</style>'
def js(m):
name=m.group(1).split('?')[0]
if 'htmx.min.js'in name:return ''
source=static/name.removeprefix('/static/')
return '<script>'+source.read_text()+'</script>'
text=re.sub(r'<link[^>]+href="(/static/[^"]+\.css(?:\?[^"]*)?)"[^>]*>',css,text)
text=re.sub(r'<script[^>]+src="(/static/[^"]+\.js(?:\?[^"]*)?)"[^>]*></script>',js,text)
return text
def bridge_read(source,path):
response=client.get(path)
return {'status':response.status_code,'text':response.text}
fixture_js="""
window.__sources=[];
window.fetch=async function(url,opts){const r=await window.fixtureRead(String(url));return {ok:r.status>=200&&r.status<300,status:r.status,json:async()=>JSON.parse(r.text),text:async()=>r.text};};
window.EventSource=class{constructor(url){this.url=url;this.listeners={};window.__sources.push(this);setTimeout(()=>this.emit('open',{}),0);}addEventListener(k,f){this.listeners[k]=f;}close(){this.closed=true;}emit(k,d){if(this.listeners[k]&&!this.closed)this.listeners[k]({data:JSON.stringify(d)});}};
window.__emit=function(kind,data){window.__sources.forEach(s=>s.emit(kind,data));};
"""
def mount(page,path):
content=html(path)
page.set_content(content.replace('<head>','<head><script>'+fixture_js+'</script>'),wait_until='domcontentloaded')
def emit(page,start):
data=j.snapshot(uid,running,after=start)
page.evaluate('(d)=>window.__emit("snapshot",d)',data)
for row in data['events']:page.evaluate('(r)=>window.__emit("line",{record:r,text:r.text})',row)
try:
with sync_playwright()as p:
browser=p.chromium.launch(executable_path=os.environ.get('AIM_BROWSER_EXECUTABLE','/usr/bin/chromium'),args=['--no-sandbox'])
for w,h in ((320,750),(390,844),(760,900),(1440,1000),(740,390)):
for theme in ('light','dark'):
ctx=browser.new_context(viewport={'width':w,'height':h},reduced_motion='reduce')
ctx.add_cookies([{'name':s.cookie_name,'value':token,'url':url}])
ctx.add_init_script("localStorage.setItem('aim-web-display-theme',"+json.dumps(theme)+");")
page=ctx.new_page();page.expose_binding("fixtureRead",bridge_read);errors=[];page.on('pageerror',lambda e:errors.append(str(e)))
for name,path in paths.items():
print('Browser',name,w,h,theme,flush=True)
mount(page,path);page.evaluate("(t)=>document.querySelectorAll('[data-theme-option]').forEach(b=>{if(b.dataset.themeOption===t)b.click()})",theme);page.wait_for_timeout(120)
assert page.locator('h1').count()==1,(name,page.content()[:300])
assert page.evaluate('document.documentElement.scrollWidth<=innerWidth+1'),(name,w,h,'horizontal overflow')
if name=='progress':
page.wait_for_function("document.querySelector('[data-journal-output]').textContent.includes('Synthetic task 300')")
box=page.locator('[data-journal-output]');height=box.evaluate('(e)=>e.clientHeight')
assert height<h and box.evaluate('(e)=>e.scrollHeight>e.clientHeight')
else:
box=page.locator('[data-report-json]');box.locator('summary').click();page.wait_for_function("document.querySelector('[data-json-status]').textContent.includes('Retained')")
assert page.locator('[data-json-output]').inner_text() not in ('','null'),name
if name=='checkmk_user_config_v1':assert 'full configuration sections were not retained' in page.locator('body').inner_text()
records.append({'page':name,'width':w,'height':h,'theme':theme,'passed':True})
if (w==1440 and theme=='dark' and name in ('host_capabilities_v1','filesystem_usage_v1')) or(w==390 and theme=='dark' and name in ('host_capabilities_v1','progress')):
page.evaluate('window.scrollTo(0,0)');page.wait_for_timeout(80);page.screenshot(path=str(args.out/f'{name}-{w}-{theme}.png'),full_page=True)
assert not errors,errors
ctx.close()
# Two independent browser sessions, HTTP snapshots and real EventSource.
ctx1=browser.new_context();ctx2=browser.new_context()
for ctx in (ctx1,ctx2):ctx.add_cookies([{'name':s.cookie_name,'value':token,'url':url}])
one=ctx1.new_page();two=ctx2.new_page()
for page in(one,two):page.expose_binding('fixtureRead',bridge_read)
for page in (one,two):mount(page,paths['progress'])
for page in (one,two):page.wait_for_function("document.querySelector('[data-journal-output]').textContent.includes('Synthetic task 300')")
add(301,310)
for page in(one,two):emit(page,300)
for page in(one,two):page.wait_for_function("document.querySelector('[data-journal-output]').textContent.includes('Synthetic task 310')")
mount(one,paths['progress']);one.wait_for_function("document.querySelector('[data-journal-output]').textContent.includes('Synthetic task 310')")
add(311,320)
for page in(one,two):emit(page,310)
for page in(one,two):page.wait_for_function("document.querySelector('[data-journal-output]').textContent.includes('Synthetic task 320')")
assert one.locator('[data-journal-output]').inner_text().count('Synthetic task 320')==1
# Manual upward scrolling pauses following without moving the page.
one.locator('[data-journal-output]').evaluate('(e)=>{e.scrollTop=0;e.dispatchEvent(new Event("scroll"));}')
assert not one.locator('[data-journal-follow]').is_checked()
position=one.evaluate('scrollY');add(321,321);emit(one,320);emit(two,320);one.wait_for_timeout(700)
assert one.evaluate('scrollY')==position
with a.store.transaction()as db:db.execute("UPDATE jobs SET status='successful',finished_at=? WHERE id=?",(time.time(),running))
for page in(one,two):page.evaluate('()=>window.__emit("end",{cursor:321,job_status:"successful"})')
for page in(one,two):page.wait_for_function("document.querySelector('[data-journal-state]').textContent.includes('Ended')")
mount(one,paths['progress']);one.wait_for_function("document.querySelector('[data-journal-output]').textContent.includes('Synthetic task 321')")
records.append({'scenario':'two fixture views remount replay final reread internal follow','passed':True})
ctx1.close();ctx2.close();browser.close()
finally:client.close()
(args.out/'results.json').write_text(json.dumps({'fixture':'ASGI-backed reads, synthetic EventSource/remount; Bootstrap5.3.6 substitute; no HTMX/HTTPS/native operations','results':records},indent=2))
print(len(records),'browser cases passed')
if __name__=='__main__':main()
@@ -0,0 +1,137 @@
"""New rc4 ASGI-backed browser fixtures with synthetic retained reports.
Bootstrap5.3.6 is a declared fixture substitute; HTMX is not loaded. This does not
qualify production HTTPS/proxy traffic or native Ansible/Windows Update behavior.
No substituted browser asset is copied to the release static/vendor directory.
"""
from __future__ import annotations
from dataclasses import replace
from pathlib import Path
import argparse
import json
import re
import sys
import tempfile
import time
ROOT=Path(__file__).resolve().parents[1]
sys.path.insert(0,str(ROOT/'src'))
from playwright.sync_api import sync_playwright
from fastapi.testclient import TestClient
from aim_webgui.app import create_app
from aim_webgui.auth.service import Auth
from aim_webgui.reports import persist
from conftest import core_root as core_fixture, settings as settings_fixture
from evidence_fixtures import declared, plan, result, job
from patch_fixtures import patch_data, update_failure
def main():
parser=argparse.ArgumentParser(description=__doc__)
parser.add_argument('--out',type=Path,required=True)
parser.add_argument('--bootstrap',type=Path,default=Path('/opt/imagemagick/share/doc/ImageMagick-7/www/assets/bootstrap.min.css'))
args=parser.parse_args();args.out.mkdir(parents=True,exist_ok=True)
records=[]
with tempfile.TemporaryDirectory(prefix='aim-rc4-patch-browser-') as tmp:
root=Path(tmp);core=core_fixture.__wrapped__(root);base=settings_fixture.__wrapped__(root,core)
url=base.public_url
settings=replace(base,public_url=url,execution_enabled=False,execution_transport_verified=False,credentials_enabled=False)
a=Auth(settings);a.bootstrap()
with a.store.transaction() as db:
db.execute('UPDATE users SET must_change_password=0');uid=db.execute('SELECT id FROM users').fetchone()[0]
d=declared('patch_summary_v1');paths={}
scenarios={
'next-wave':patch_data(),
'pending':patch_data(remaining_updates_known=True,reboot_performed=False,pending=[
{'name':'Synthetic security update','identifier':'fixture-pending','kb':['KB0000002']}]),
'failed-update':patch_data(complete=False,blocked_reason='install_not_allowed',failed_updates=[update_failure()]),
'reboot-deferred':patch_data(reboot_deferred=True,reboot_performed=False,reboot_required=True,reboot_required_after=True),
}
for name,content in scenarios.items():
p=plan(d,['win01.example']);p['playbook']='maintenance_patch_os';p['core_request']['playbook']=p['playbook']
failed=name=='failed-update'
ident=job(a,uid,p,status='failed' if failed else 'successful')
r=result(d,hosts=p['targets'],report_data=content,status='failed' if failed else 'succeeded')
if failed:
r.update(stage='execution',exit_code=2,error={'code':'playbook_failed','message':'Synthetic task failed','stage':'execution'})
r['targets'][0]['outcome']='failed';r['targets'][0]['counts']['failures']=1;r['counts']['failures']=1
r['target_summary'].update(successful=0,failed=1)
with a.store.transaction() as db:
small=persist(db,settings,ident,p,r)
db.execute('UPDATE jobs SET core_result=?,finished_at=? WHERE id=?',(json.dumps(small),time.time(),ident))
paths[name]=f'/jobs/{ident}/reports'
client=TestClient(create_app(settings),base_url=url)
token,_=a.new_session(uid);client.cookies.set(settings.cookie_name,token)
pages={}
def bridge_read(source,path):
response=client.get(path)
return {'status':response.status_code,'text':response.text}
def mount(page,path):
if path not in pages:
response=client.get(path);assert response.status_code==200,(path,response.text[:500])
content=response.text
def css(match):
name=match.group(1).split('?')[0]
source=args.bootstrap if name.endswith('bootstrap.min.css') else ROOT/'src/aim_webgui'/name.lstrip('/')
return '<style>'+source.read_text()+'</style>'
def js(match):
name=match.group(1).split('?')[0]
if 'htmx.min.js' in name:return ''
return '<script>'+(ROOT/'src/aim_webgui'/name.lstrip('/')).read_text()+'</script>'
content=re.sub(r'<link[^>]+href="(/static/[^"]+\.css(?:\?[^"]*)?)"[^>]*>',css,content)
content=re.sub(r'<script[^>]+src="(/static/[^"]+\.js(?:\?[^"]*)?)"[^>]*></script>',js,content)
fixture="""<script>window.fetch=async function(url,opts){const r=await window.fixtureRead(String(url));return {ok:r.status>=200&&r.status<300,status:r.status,json:async()=>JSON.parse(r.text),text:async()=>r.text};};</script>"""
pages[path]=content.replace('<head>','<head>'+fixture)
page.set_content(pages[path],wait_until='domcontentloaded')
try:
with sync_playwright() as pw:
browser=pw.chromium.launch(executable_path='/usr/bin/chromium',args=['--no-sandbox','--disable-dev-shm-usage'])
try:
for width,height in ((320,750),(390,844),(760,900),(1440,1000),(740,390)):
for theme in ('light','dark'):
context=browser.new_context(viewport={'width':width,'height':height},locale='en-GB',timezone_id='Europe/Berlin',reduced_motion='reduce')
page=context.new_page();page.expose_binding('fixtureRead',bridge_read);errors=[];page.on('pageerror',lambda exc:errors.append(str(exc)))
for name,path in paths.items():
mount(page,path)
page.locator('[data-theme-option='+theme+']:visible').click()
assert page.evaluate('document.documentElement.scrollWidth<=innerWidth+1'),(name,width,height)
assert page.locator('.patch-wave-panel').is_visible()
if name=='next-wave':
assert 'Another patch run needs review' in page.locator('.patch-wave-panel').inner_text()
assert 'Final pending list not established' in page.locator('#report-pending').inner_text()
assert '0 entries' not in page.locator('#report-pending').inner_text()
elif name=='pending':
assert 'final read-only discovery' in page.locator('#report-pending').inner_text()
assert 'Synthetic security update' in page.locator('#report-pending').inner_text()
elif name=='failed-update':
assert '0x80240016' in page.locator('#report-failed_updates').inner_text()
assert 'does not by itself prove a pending reboot' in page.locator('.patch-wave-panel').inner_text()
page.locator('[data-report-json] summary').click()
page.wait_for_function("document.querySelector('[data-json-status]').textContent.includes('Retained')")
stored=json.loads(page.locator('[data-json-output]').inner_text())
assert stored==scenarios[name] or stored.get('data')==scenarios[name]
if name=='next-wave' and width in(390,1440) and theme=='dark':
page.locator('[data-report-json] summary').click();page.evaluate('window.scrollTo(0,0)')
page.screenshot(path=str(args.out/f'patch-wave-{width}-dark.png'),full_page=True)
records.append({'page':name,'width':width,'height':height,'theme':theme,'passed':True})
mount(page,'/plan?customer=example&playbook=maintenance_patch_os')
page.locator('[data-theme-option='+theme+']:visible').click()
option=page.locator('[name="option.os_patching_rescan_after_reboot"]')
assert option.input_value()=='' and 'catalog hint: false' in option.inner_text()
option.select_option('false')
page.locator('[name="option.os_patching_reboot_delay_minutes"]').fill('10')
page.locator('[name="option.os_patching_reboot_message"]').fill('Synthetic approved maintenance')
assert page.evaluate('document.documentElement.scrollWidth<=innerWidth+1')
records.append({'page':'catalog patch options','width':width,'height':height,'theme':theme,'passed':True})
assert not errors,errors
context.close()
finally:browser.close()
finally:
client.close()
evidence={'cases':records,'count':len(records),'real_local_http_and_fetch':False,'asgi_bridge':True,'production_https_tested':False,
'bootstrap_substitute':'5.3.6 for pinned5.3.8','htmx_loaded':False,'native_updates_executed':False,
'source':'synthetic retained data; real public Core catalog/discovery; about:blank fetch bridge to ASGI TestClient'}
(args.out/'results.json').write_text(json.dumps(evidence,indent=2)+'\n')
print(len(records),'patch UI cases passed')
if __name__=='__main__':main()
+191
View File
@@ -0,0 +1,191 @@
"""Optional Chromium fixture QA, not deployment/remote/production-HTMX acceptance.
Uses the real supplied Core for discovery against a temporary synthetic inventory.
The external assets are embedded only in about:blank test documents. With no pinned
assets available, --bootstrap (or the local default) is a declared substitute.
No substitutions are written into production static/vendor.
"""
from pathlib import Path
import argparse
import json
import re
import sys
import tempfile
import time
sys.path.insert(0,str(Path(__file__).resolve().parents[1]/'src'))
from fastapi.testclient import TestClient
from playwright.sync_api import sync_playwright
from aim_webgui.app import create_app
from aim_webgui.auth.service import Auth
from conftest import core_root as core_fixture, settings as settings_fixture
from test_read_experience import add_job
ROOT=Path(__file__).resolve().parents[1]
def main():
parser=argparse.ArgumentParser()
parser.add_argument('--out',type=Path,required=True)
parser.add_argument('--bootstrap',type=Path,default=Path('/opt/imagemagick/share/doc/ImageMagick-7/www/assets/bootstrap.min.css'))
args=parser.parse_args();args.out.mkdir(parents=True,exist_ok=True)
results=[];screenshots=[]
with tempfile.TemporaryDirectory(prefix='aim-browser-readonly-') as tmp:
tmp=Path(tmp);core=core_fixture.__wrapped__(tmp);settings=settings_fixture.__wrapped__(tmp,core)
(core/'inventories/example/hosts.yml').write_text('''all:
children:
example:
hosts:
edge-01.example:
ansible_host: 192.0.2.1
children:
linux:
hosts:
api-01.example:
ansible_host: 192.0.2.10
api-02.example:
ansible_host: 192.0.2.11
children:
database:
hosts:
db-01.example:
ansible_host: 192.0.2.12
db-02.example:
ansible_host: 192.0.2.13
windows:
hosts:
desktop-01.example:
ansible_host: 192.0.2.20
children:
servers:
hosts:
mail-01.example:
ansible_host: 192.0.2.21
app-01.example:
ansible_host: 192.0.2.22
staging:
hosts:
api-02.example: {}
''')
print('Fixture core ready',flush=True)
auth=Auth(settings);auth.bootstrap()
print('Fixture auth ready',flush=True)
with auth.store.transaction() as db:
db.execute("UPDATE users SET must_change_password=0 WHERE username='admin'")
uid=db.execute("SELECT id FROM users WHERE username='admin'").fetchone()[0]
now=int(time.time())
hosts=['api-01.example','api-02.example','db-01.example','db-02.example','desktop-01.example','mail-01.example','app-01.example']
for i in range(18):
facts={h:('unreachable' if i%4==0 and h=='db-02.example' else 'successful') for h in hosts}
add_job(auth,owner='admin',book=['checkmk_install_agent','debug_test_connection','maintenance_patch_os'][i%3],
hosts=facts,status='failed' if i%4==0 else 'successful',when=now-3600*(i+1))
active=add_job(auth,owner='admin',status='running',hosts={'api-01.example':'successful'})
token,session=auth.new_session(uid)
paths={'overview':'/','new-run':'/plan?customer=example&playbook=debug_test_connection',
'explorer':'/inventory/example/explore','map':'/inventory/example/explore?view=map',
'branch':'/inventory/example/explore?view=map&branch=%5B%22linux%22%5D',
'activity':'/inventory/example/activity?host=api-01.example',
'insights':'/insights?customer=example','audit':'/audit'}
pages={}
with TestClient(create_app(settings),base_url=settings.public_url) as client:
client.cookies.set(settings.cookie_name,token)
for name,path in paths.items():
print('Render',name,path,flush=True)
response=client.get(path);assert response.status_code==200,(name,response.text)
pages[name]=response.text
# Every SVG host link really points to an authenticated host page, not an action.
import html
links=re.findall(r'<a href="([^"]+)" class="graph-node graph-host"',pages['map'])
assert links
for link in links:
assert client.get(html.unescape(link)).status_code==200
(args.out/'html').mkdir(exist_ok=True)
for name,content in pages.items(): (args.out/'html'/(name+'.html')).write_text(content)
def inline(content):
def css(m):
path=m.group(1).split('?')[0]
file=args.bootstrap if path.endswith('bootstrap.min.css') else ROOT/'src/aim_webgui'/path.lstrip('/')
return '<style>'+file.read_text()+'</style>'
content=re.sub(r'<link rel="stylesheet" href="([^"]+)"[^>]*>',css,content)
content=re.sub(r'<script src="[^"]+"[^>]*></script>','',content)
fixture='''window.fetch=async()=>({ok:true,json:async()=>({saved:true})});
window.__sources=[]; window.EventSource=class {
static CLOSED=2; constructor(url){this.url=url;this.readyState=1;this.handlers={};window.__sources.push(this);}
addEventListener(k,fn){this.handlers[k]=fn;} close(){this.readyState=2;}
emit(k,text){if(this.handlers[k])this.handlers[k]({data:JSON.stringify({text})});}
};'''
script='\n'.join((ROOT/'src/aim_webgui/static/js'/x).read_text() for x in ('theme.js','aim.js','experience.js','credentials.js'))
return content.replace('</body>','<script>'+fixture+script+'</script></body>')
with sync_playwright() as pw:
browser=pw.chromium.launch(executable_path='/usr/bin/chromium',headless=True,args=['--no-sandbox','--disable-dev-shm-usage'])
try:
for width,height in ((320,750),(390,844),(760,900),(1440,1000),(740,390)):
for theme in ('light','dark'):
context=browser.new_context(viewport={'width':width,'height':height},locale='de-DE',timezone_id='Europe/Berlin',reduced_motion='reduce')
errors=[]
for name,content in pages.items():
print('QA',width,height,theme,name,flush=True)
page=context.new_page();page.on('pageerror',lambda e:errors.append(str(e)))
page.set_content(inline(content),wait_until='load')
page.locator('[data-theme-option='+theme+']:visible').click()
page.wait_for_timeout(100)
assert page.evaluate('document.documentElement.scrollWidth<=innerWidth'),(name,width,height,'overflow')
if width<=760:
assert not page.locator('.sidebar').is_visible()
toggle=page.locator('[data-mobile-menu] summary');toggle.focus();page.keyboard.press('Enter')
assert page.locator('[data-mobile-menu]').get_attribute('open') is not None
assert page.locator('#mobile-navigation').get_by_role('link',name='Playbook insights',exact=True).is_visible()
page.keyboard.press('Escape')
assert page.locator('[data-mobile-menu]').get_attribute('open') is None
assert toggle.evaluate('(e)=>e===document.activeElement')
a=page.locator('.mobile-brand').bounding_box();b=page.locator('.mobile-actions .theme-control').bounding_box();c=toggle.bounding_box()
assert abs((a['y']+a['height']/2)-(c['y']+c['height']/2))<2
assert abs((b['y']+b['height']/2)-(c['y']+c['height']/2))<2
assert b['x']+b['width']<=c['x'] and c['x']+c['width']<=width
assert page.locator('.mobile-topbar').bounding_box()['height']<80
assert page.locator('[data-nav-forward]').count()==0
else:
assert page.locator('.sidebar').is_visible() and not page.locator('.mobile-topbar').is_visible()
if name=='explorer':
assert page.locator('.explorer-map').is_visible()==(width>760)
if name=='map':
graph=page.locator('[data-inventory-graph]');old=float(graph.get_attribute('width'))
page.locator('[data-graph-zoom="in"]').click()
assert float(graph.get_attribute('width'))>old
page.locator('[data-graph-zoom="reset"]').click()
assert page.locator('svg .graph-host').count()>0
assert page.locator('.inventory-canvas').evaluate('(e)=>e.scrollHeight>=e.clientHeight')
if name=='insights':
assert page.locator('.matrix-mobile').is_visible()==(width<=760)
if name=='new-run':
page.locator('[data-select-all-hosts]').check()
assert page.locator('[data-host-target]:checked').count()==7
if name=='job':
output=page.locator('[data-console-output]');output.scroll_into_view_if_needed();before=page.evaluate('scrollY')
page.evaluate("()=>{for(let i=0;i<240;i++)window.__sources[0].emit('line','TASK [Synthetic fixture '+i+']');}")
page.wait_for_timeout(100)
assert output.evaluate('(e)=>e.scrollTop>0 && e.scrollHeight>e.clientHeight')
assert abs(page.evaluate('scrollY')-before)<3
output.evaluate('(e)=>e.scrollTop=0');page.wait_for_timeout(100)
assert not page.locator('[data-console-autoscroll]').is_checked()
page.locator('[data-console-autoscroll]').check();page.wait_for_timeout(100)
assert output.evaluate('(e)=>e.scrollTop>0')
if name in ('map','activity','insights','explorer') and width in (390,1440) and theme=='dark':
page.evaluate('scrollTo(0,0)');file=f'{name}-{width}-{theme}.png';page.screenshot(path=str(args.out/file),full_page=True);screenshots.append(file)
if name=='activity':
shot=f'activity-{width}-viewport.png';page.screenshot(path=str(args.out/shot),full_page=False);screenshots.append(shot)
if name=='overview' and width==390 and theme=='dark':
page.locator('[data-mobile-menu] summary').click();page.screenshot(path=str(args.out/'mobile-menu.png'),full_page=False);screenshots.append('mobile-menu.png')
# Formatter includes an HTMX-replaced <time> root itself.
page.evaluate("()=>{let t=document.createElement('time');t.className='js-local-time';t.dateTime='2026-09-19T07:00:00Z';t.textContent='UTC-FALLBACK';document.querySelector('main').append(t);document.dispatchEvent(new CustomEvent('htmx:afterSwap',{detail:{target:t}}));}")
assert 'UTC-FALLBACK' not in page.locator('main > time').last.inner_text()
results.append({'page':name,'width':width,'height':height,'theme':theme,'passed':True});page.close()
assert not errors,errors
context.close()
finally:browser.close()
report={'fixture_only':True,'bootstrap_source':str(args.bootstrap),'htmx_loaded':False,'real_remote_execution':False,
'cases':results,'screenshots':screenshots}
(args.out/'results.json').write_text(json.dumps(report,indent=2))
print(f'{len(results)} browser fixture cases passed. No live HTMX/TLS/remote qualification.')
if __name__=='__main__':
main()
+57
View File
@@ -0,0 +1,57 @@
from pathlib import Path
from dataclasses import replace
import grp
import os
import shutil
import sys
import pytest
from aim_webgui.config import Settings
from aim_webgui.auth.service import Auth
@pytest.fixture
def core_root(tmp_path):
source=os.environ.get('AIM_TEST_CORE_ROOT')
if not source: pytest.skip('Set AIM_TEST_CORE_ROOT to the extracted unmodified AIM 3.3.0rc8 source bundle.')
root=tmp_path/'core'
shutil.copytree(source,root,ignore=shutil.ignore_patterns('__pycache__','*.pyc'))
config=root/'scripts/aim.yml'
config.write_text(f'root_dir: {root}\nservice_user: svc_bf-ansible\nrequired_group: {grp.getgrgid(os.getegid()).gr_name}\naddons:\n execution_enabled: true\n')
customer=root/'inventories/example';customer.mkdir(parents=True)
(customer/'hosts.yml').write_text('''all:
children:
example:
children:
linux:
children:
lab:
hosts:
test01.example:
ansible_host: 192.0.2.1
windows:
hosts:
win01.example:
ansible_host: 192.0.2.2
''')
(customer/'group_vars/all').mkdir(parents=True)
(customer/'group_vars/linux/.ssh').mkdir(parents=True)
return root
@pytest.fixture
def settings(tmp_path,core_root):
return Settings(aim_scripts=core_root/'scripts', state_dir=tmp_path/'state',
core_transport='stdio',core_command=(sys.executable,str(core_root/'scripts/aimctl.py')),
core_config=core_root/'scripts/aim.yml',host='127.0.0.1',public_url='https://aim.example.test',
execution_enabled=True,execution_playbooks=('debug_test_connection','debug_detect_host_roles'),
execution_require_approval=False,execution_transport_verified=True,credentials_enabled=True).validate()
@pytest.fixture
def auth(settings):
a=Auth(settings);a.bootstrap()
with a.store.transaction() as db:
db.execute("UPDATE users SET must_change_password=0 WHERE username='admin'")
return a
@pytest.fixture
def admin(auth):
with auth.store.read() as db:return db.execute("SELECT id FROM users WHERE username='admin'").fetchone()[0]
@@ -0,0 +1,68 @@
"""Synthetic fixture data only; no controller inventory or real report observations."""
from pathlib import Path
import json,os,time,uuid
from ruamel.yaml import YAML
from aim_webgui.core.protocol import COUNTS
def declared(name='host_capabilities_v1',scope='per_host'):
source=Path(os.environ['AIM_TEST_CORE_ROOT'])/'playbooks/schemas'/f'{name}.yml'
schema=YAML(typ='safe').load(source.read_text())
return {'protocol':'aim_output_v1','schema':name,'scope':scope,'required':True,'sensitivity':'safe',
'max_bytes_per_host':1048576,'data_schema':schema}
def sample(schema):
t=schema['type']
if isinstance(t,list):t='null' if 'null' in t else t[0]
if 'enum' in schema:return schema['enum'][0]
if t=='object':return {k:sample(v) for k,v in schema['properties'].items()}
if t=='array':return []
if t=='boolean':return False
if t=='integer':return int(schema.get('minimum',0))
if t=='number':return float(schema.get('minimum',0))
if t=='null':return None
return 'synthetic'
def plan(declaration=None,hosts=None):
hosts=hosts or ['test01.example']
req={'customer':'example','playbook':'debug_detect_host_roles','hosts':hosts,'overrides':{},'check':False,
'key_mode':'none','become_password':False,'timeout_seconds':30,'progress_mode':'detail'}
return {'customer':'example','playbook':req['playbook'],'targets':hosts,'overrides':{},'result_contract':declaration,
'core_request':req,'core_version':'3.3.0rc8','core_api':'1.0','core_revision':'a'*64,'inventory_revision':'a'*64,
'credential_requirements':[],'authentication':{'mode':'inventory','key_mode':'none'}}
def result(declaration=None,hosts=None,*,availability='available',status='succeeded',report_data=None):
hosts=hosts or ['test01.example'];counts=dict.fromkeys(COUNTS,0);counts['ok']=3
report=None
if declaration is not None:
data=sample(declaration['data_schema']) if report_data is None else report_data
entry={'schema':declaration['schema'],'status':availability,'data':data if availability=='available' else None,'error':None}
report={'protocol':'aim_operation_result_v1','schema':declaration['schema'],'scope':declaration['scope'],
'required':True,'complete':availability=='available','check_mode':False,
'hosts':{h:dict(entry) for h in hosts} if declaration['scope']=='per_host' else {},
'global':dict(entry) if declaration['scope']=='global' else None}
return {'api_version':'1.0','run_id':'fixture-run','status':status,'stage':'completed' if status=='succeeded' else 'result_validation',
'exit_code':0,'remote_work_may_have_started':True,'counts':counts,
'error':None if status=='succeeded' else {'code':'operation_result_missing','message':'Required report missing','stage':'result_validation'},
'target_summary':{'schema':'target_outcome_summary_v1','requested':len(hosts),'accounted':len(hosts),
'successful':len(hosts),'failed':0,'unreachable':0,'not_started':0,'indeterminate':0,'complete':True},
'targets':[{'host':h,'outcome':'successful','counts':dict(counts)} for h in hosts],'operation_result':report}
def job(auth,owner,p=None,*,status='running'):
ident=uuid.uuid4().hex;p=p or plan()
with auth.store.transaction() as db:
db.execute('''INSERT INTO jobs(id,owner_id,plan,mode,status,scheduled_at,created_at) VALUES(?,?,?,?,?,?,?)''',
(ident,owner,json.dumps(p),'apply',status,int(time.time()),int(time.time())))
return ident
def ev(seq,kind='task_started',**extra):
data={'event_version':'1.0','run_id':'fixture-run','sequence':seq,'timestamp':'2026-09-20T10:00:00Z','kind':kind}
if kind=='task_started':data.update(play_id='p1',task_id='t'+str(seq),label='Synthetic task '+str(seq),label_redacted=False,handler=False)
if kind=='host_result':data.update(play_id='p1',task_id='t1',host='test01.example',host_redacted=False,status='ok',changed=False,ignored=False,details_redacted=False,error=None)
if kind=='stage':data['stage']='execution'
data.update(extra);return data
@@ -0,0 +1,214 @@
{
"type": "object",
"properties": {
"platform": {
"type": "string",
"maxLength": 128,
"enum": [
"windows",
"debian",
"redhat"
]
},
"mode": {
"type": "string",
"maxLength": 128,
"enum": [
"apply",
"check"
]
},
"evidence": {
"type": "string",
"maxLength": 128,
"enum": [
"package_snapshots",
"windows_update_result"
]
},
"complete": {
"type": "boolean"
},
"updates": {
"type": "array",
"items": {
"type": "object",
"properties": {
"name": {
"type": "string",
"maxLength": 1024
},
"identifier": {
"type": [
"string",
"null"
],
"maxLength": 1024
},
"architecture": {
"type": [
"string",
"null"
],
"maxLength": 1024
},
"old_versions": {
"type": "array",
"items": {
"type": "string",
"maxLength": 1024
},
"maxItems": 100
},
"new_versions": {
"type": "array",
"items": {
"type": "string",
"maxLength": 1024
},
"maxItems": 100
},
"action": {
"type": "string",
"maxLength": 128,
"enum": [
"updated",
"installed",
"removed"
]
},
"kb": {
"type": "array",
"items": {
"type": "string",
"maxLength": 1024
},
"maxItems": 100
}
},
"required": [
"name",
"identifier",
"architecture",
"old_versions",
"new_versions",
"action",
"kb"
],
"additionalProperties": false
},
"maxItems": 20000
},
"updated_count": {
"type": "integer",
"minimum": 0
},
"installed_count": {
"type": "integer",
"minimum": 0
},
"removed_count": {
"type": "integer",
"minimum": 0
},
"pending": {
"type": "array",
"items": {
"type": "object",
"properties": {
"name": {
"type": "string",
"maxLength": 1024
},
"identifier": {
"type": [
"string",
"null"
],
"maxLength": 1024
},
"kb": {
"type": "array",
"items": {
"type": "string",
"maxLength": 1024
},
"maxItems": 100
}
},
"required": [
"name",
"identifier",
"kb"
],
"additionalProperties": false
},
"maxItems": 20000
},
"failed_updates": {
"type": "array",
"items": {
"type": "object",
"properties": {
"name": {
"type": "string",
"maxLength": 1024
},
"identifier": {
"type": [
"string",
"null"
],
"maxLength": 1024
},
"native_code": {
"type": [
"integer",
"null"
],
"minimum": 0
},
"reason": {
"type": "string",
"maxLength": 128,
"enum": [
"update_failed"
]
}
},
"required": [
"name",
"identifier",
"native_code",
"reason"
],
"additionalProperties": false
},
"maxItems": 20000
},
"reboot_required": {
"type": [
"boolean",
"null"
]
},
"reboot_performed": {
"type": "boolean"
}
},
"required": [
"platform",
"mode",
"evidence",
"complete",
"updates",
"updated_count",
"installed_count",
"removed_count",
"pending",
"failed_updates",
"reboot_required",
"reboot_performed"
],
"additionalProperties": false
}
@@ -0,0 +1,24 @@
"""Synthetic report scenarios; never native Windows Update acceptance."""
def patch_data(**extra):
payload = {
'platform': 'windows', 'mode': 'apply', 'evidence': 'windows_update_result',
'complete': True,
'updates': [{'name': 'Synthetic cumulative update', 'identifier': 'fixture-update-1',
'architecture': None, 'old_versions': [], 'new_versions': [],
'action': 'installed', 'kb': ['KB0000001']}],
'updated_count': 0, 'installed_count': 1, 'removed_count': 0,
'pending': [], 'failed_updates': [],
'reboot_required': False, 'reboot_required_before': False,
'reboot_required_after': False, 'reboot_performed': True,
'reboot_deferred': False, 'reboot_delay_minutes': 5, 'blocked_reason': None,
'rescan_after_reboot': False, 'patch_cycles': 1,
'continuation_required': True, 'remaining_updates_known': False,
}
payload.update(extra)
return payload
def update_failure(reason='install_not_allowed'):
return {'name': 'Synthetic failed update', 'identifier': 'fixture-update-2',
'native_code': 2149842966, 'native_code_hex': '0x80240016',
'reason': reason, 'message': 'Installation is not allowed in the observed Windows Update state.'}
@@ -0,0 +1,53 @@
"""Run release tests in bounded file groups using a disposable test environment.
Set AIM_TEST_CORE_ROOT to a pristine Core source extraction, not a live controller.
Artifacts go to --out; this does not install dependencies or contact real hosts.
"""
from __future__ import annotations
import argparse
import concurrent.futures
import json
import os
from pathlib import Path
import re
import subprocess
import sys
import time
def main() -> int:
parser=argparse.ArgumentParser(description=__doc__)
parser.add_argument('--out',type=Path,required=True)
parser.add_argument('--workers',type=int,default=3,choices=range(1,5))
parser.add_argument('--timeout',type=int,default=180)
args=parser.parse_args()
if args.timeout<30:parser.error('timeout must be at least 30 seconds')
core=os.environ.get('AIM_TEST_CORE_ROOT','')
if not core or not (Path(core)/'scripts/aimctl.py').is_file():
parser.error('Set AIM_TEST_CORE_ROOT to the pristine supplied Core source extraction.')
root=Path(__file__).resolve().parents[1];args.out.mkdir(parents=True,exist_ok=True)
env={**os.environ,'PYTEST_DISABLE_PLUGIN_AUTOLOAD':'1'}
def run(path: Path) -> dict:
started=time.monotonic()
try:
p=subprocess.run([sys.executable,'-m','pytest',str(path),'-q','-p','no:cacheprovider'],
cwd=root,env=env,capture_output=True,text=True,timeout=args.timeout)
output=p.stdout+p.stderr;code=p.returncode
except subprocess.TimeoutExpired as exc:
output=(exc.stdout or b'').decode(errors='replace') if isinstance(exc.stdout,bytes) else (exc.stdout or '')
output+='\nRelease test-file timeout; not a pass.\n';code=124
(args.out/(path.stem+'.log')).write_text(output)
summary=next((line for line in reversed(output.splitlines()) if re.search(r'\d+ (passed|failed|skipped|error)',line)),'No test summary')
counts={k:int(n) for n,k in re.findall(r'(\d+) (passed|failed|skipped|errors?)',summary)}
return {'file':path.name,'returncode':code,'seconds':round(time.monotonic()-started,3),'summary':summary,'counts':counts}
with concurrent.futures.ThreadPoolExecutor(max_workers=args.workers) as pool:
results=list(pool.map(run,sorted((root/'tests').glob('test_*.py'))))
totals={k:sum(x['counts'].get(k,0) for x in results) for k in ('passed','failed','skipped','error','errors')}
value={'mode':'independent bounded test-file groups','files':results,'totals':totals,
'all_passed':all(x['returncode']==0 for x in results),'native_managed_hosts_tested':False}
(args.out/'results.json').write_text(json.dumps(value,indent=2)+'\n')
for result in results:print(result['file']+': '+result['summary'],flush=True)
print(json.dumps(totals),flush=True)
return 0 if value['all_passed'] else 1
if __name__=='__main__':raise SystemExit(main())
+178
View File
@@ -0,0 +1,178 @@
import concurrent.futures
import json
import os
import sqlite3
import stat
import time
import pytest
from aim_webgui.auth.service import Auth, HASHER, digest
from aim_webgui.errors import WebError
from aim_webgui.config import Settings
@pytest.fixture
def settings(tmp_path):
state=tmp_path/'state';state.mkdir(mode=0o700)
return Settings(state_dir=state)
@pytest.fixture
def auth(settings):
auth=Auth(settings);auth.bootstrap();return auth
@pytest.fixture
def password(auth):return json.loads(auth.settings.credentials.read_text())['password']
NEW = 'Independent-test-passphrase-2026'
def test_bootstrap_secure_and_idempotent(auth, password):
assert stat.S_IMODE(auth.settings.credentials.stat().st_mode) == 0o600
assert stat.S_IMODE(auth.settings.database.stat().st_mode) == 0o600
assert len(password) >= 32
with auth.store.read() as db:
row = db.execute('SELECT * FROM users').fetchone()
assert row['password_hash'].startswith('$argon2id$')
assert HASHER.verify(row['password_hash'], password)
assert row['must_change_password'] == 1
auth.settings.credentials.unlink()
assert auth.bootstrap() is False
assert not auth.settings.credentials.exists()
assert len(auth.users()) == 1
def test_bootstrap_concurrent(settings):
with concurrent.futures.ThreadPoolExecutor(2) as pool:
results = list(pool.map(lambda _: Auth(settings).bootstrap(), range(2)))
assert sorted(results) == [False, True]
assert len(Auth(settings).users()) == 1
def test_missing_database_not_rebootstrapped(auth):
auth.settings.database.unlink()
with pytest.raises(ValueError, match='missing'):
auth.bootstrap()
def test_password_change_revokes_and_consumes(auth, password):
anon, _ = auth.new_session()
token, s = auth.login('admin', password, anon, 'test-peer')
assert s['must_change_password']
assert token != anon
assert auth.session(anon) is None
with auth.store.read() as db:
assert db.execute('SELECT token_hash FROM sessions').fetchone()[0] == digest(token)
assert token.encode() not in auth.settings.database.read_bytes()
auth.change_password(s['user_id'], password, NEW)
assert auth.session(token) is None
assert not auth.settings.credentials.exists()
assert password not in (auth.settings.state_dir / '.credentials.used').read_text()
anon, _ = auth.new_session()
_, active = auth.login('admin', NEW, anon, 'test-peer')
assert not active['must_change_password']
def test_last_admin_guard(auth):
for action in ('disable', 'demote'):
with pytest.raises(WebError, match='last enabled'):
auth.manage_user('admin', action)
auth.create_user('second-admin', NEW, 'admin')
auth.manage_user('admin', 'disable')
with pytest.raises(WebError):
auth.manage_user('second-admin', 'disable')
def test_role_authorization_rechecked(auth):
auth.create_user('viewer', NEW)
viewer = next(u for u in auth.users() if u['username'] == 'viewer')
with pytest.raises(WebError) as result:
auth.manage_user('admin', 'revoke', actor_id=viewer['id'])
assert result.value.status == 403
def test_disabled_and_reset_sessions(auth, password):
auth.create_user('reader', NEW)
reader = next(u for u in auth.users() if u['username'] == 'reader')
token, _ = auth.new_session(reader['id'])
auth.manage_user('reader', 'disable')
assert auth.session(token) is None
with pytest.raises(WebError, match='Invalid username'):
auth.login('reader', NEW, '', 'peer')
auth.manage_user('reader', 'enable')
token, _ = auth.new_session(reader['id'])
auth.manage_user('reader', 'reset-password', password=NEW + '-reset')
assert auth.session(token) is None
def test_expired_sessions(auth):
token, _ = auth.new_session()
with auth.store.transaction() as db:
db.execute('UPDATE sessions SET expires_at=?', (int(time.time()) - 1,))
assert auth.session(token) is None
def test_throttle(auth):
for _ in range(10):
with pytest.raises(WebError) as e:
auth.login('admin', 'bad', '', 'test-peer')
assert e.value.status == 401
with pytest.raises(WebError) as e:
auth.login('admin', 'bad', '', 'test-peer')
assert e.value.status == 429
def test_migration_backup_and_future_schema(auth, tmp_path):
auth.store.migrate()
out = tmp_path / 'auth-backup.sqlite3'
auth.store.backup(out)
assert stat.S_IMODE(out.stat().st_mode) == 0o600
db = sqlite3.connect(out)
assert db.execute('PRAGMA integrity_check').fetchone()[0] == 'ok'
assert db.execute('SELECT COUNT(*) FROM users').fetchone()[0] == 1
db.close()
with auth.store.transaction() as db:
db.execute('PRAGMA user_version=999')
with pytest.raises(ValueError, match='newer'):
auth.store.migrate()
def test_credentials_symlink_rejected(settings, tmp_path):
other = tmp_path / 'other'
other.write_text('do not overwrite')
settings.credentials.symlink_to(other)
with pytest.raises(ValueError):
Auth(settings).bootstrap()
assert other.read_text() == 'do not overwrite'
def test_short_window_does_not_clear_long_window(auth, monkeypatch):
import aim_webgui.auth.service as module
monkeypatch.setattr(module.time, 'time', lambda:10000)
auth.throttle([('long',1)],window=300)
monkeypatch.setattr(module.time, 'time', lambda:10070)
auth.throttle([('short',1)],window=60)
with pytest.raises(WebError) as e:
auth.throttle([('long',1)],window=300)
assert e.value.status == 429
def test_bootstrap_repairs_missing_post_commit_marker_without_reset(auth, password):
marker = auth.settings.state_dir / '.initialized'
marker.unlink()
before = auth.settings.credentials.read_bytes()
assert auth.bootstrap() is False
assert marker.is_file()
assert auth.settings.credentials.read_bytes() == before
auth.settings.database.unlink()
with pytest.raises(ValueError, match='missing'):
auth.bootstrap()
def test_local_recovery_can_reenable_out_of_band_disabled_admin(auth):
with auth.store.transaction() as db:
db.execute("UPDATE users SET enabled=0 WHERE username='admin'")
with pytest.raises(ValueError, match='administrator'):
auth.store.check()
auth.store.check(require_admin=False)
auth.manage_user('admin', 'enable')
auth.store.check()
@@ -0,0 +1,134 @@
from dataclasses import replace
from pathlib import Path
import json
import os
import stat
import pytest
from aim_webgui.adapters.core_v1 import CoreAdapter
from aim_webgui.core.client import CoreClient
from aim_webgui.core.protocol import request_message,validate_request,validate_secrets,safe_event,response_result,COUNTS
from aim_webgui.errors import WebError
def test_real_core_capabilities_and_customers(settings):
core=CoreAdapter(settings)
assert core.version=='3.3.0rc8'
assert core.capabilities['execution']['profile']=='same_uid_native_inventory'
assert 'custom_password_override' in core.capabilities['unsupported']
assert core.capabilities['target_outcomes']['schema']=='target_outcome_summary_v1'
assert core.capabilities['inventory_hierarchy']['schema']=='inventory_hierarchy_v1'
assert core.capabilities['controller_staging']['profile']=='native_defaults_preflight_v2'
assert core.capabilities['collection_baselines']['ansible.windows']=='>=3.8.0,<4.0.0'
assert [x['name'] for x in core.customers()]==['example']
def test_real_core_hosts_and_catalog(settings):
a=CoreAdapter(settings)
hosts=a.hosts('example')
assert {x['name'] for x in hosts}=={'test01.example','win01.example'}
assert next(x for x in hosts if x['name']=='test01.example')['groups']==['linux','linux/lab']
assert len(a.playbooks('example'))==13 # customer-specific Sophos file is absent
def test_real_core_inventory_hierarchy(settings):
a=CoreAdapter(settings)
hierarchy=a.inventory_hierarchy('example')
assert hierarchy['schema']=='inventory_hierarchy_v1'
linux=next(x for x in hierarchy['groups'] if x['name']=='linux')
lab=next(x for x in linux['children'] if x['name']=='lab')
assert linux['hosts']==[] and lab['hosts']==['test01.example']
groups={x['name']:x for x in a.hierarchy_groups('example')['groups']}
assert groups['linux']['hosts']==['test01.example']
assert groups['linux/lab']['direct_hosts']==['test01.example']
def test_real_prepare_typed_options_and_invalid_target(settings):
a=CoreAdapter(settings)
p=a.preflight('example','debug_test_connection',['test01.example'],{'aim_debug':'false'},text_inputs=True)
assert p['core_request']['overrides']=={'aim_debug':False}
assert p['core_request']['check'] is True
assert len(p['core_revision'])==64
with pytest.raises(WebError) as e:a.preflight('example','debug_test_connection',['missing.example'],{})
assert e.value.code=='core_invalid_target'
def test_real_core_invalid_option_error(settings):
with pytest.raises(WebError) as e:
CoreAdapter(settings).preflight('example','debug_test_connection',['test01.example'],{'not_declared':True})
assert e.value.code=='core_invalid_options'
def test_real_core_conservative_vault_requirements(settings,core_root):
(core_root/'inventories/example/group_vars/all/vault.yml').write_text('$ANSIBLE_VAULT;1.1;AES256\n'+('a'*80)+'\n')
result=CoreAdapter(settings).preflight('example','debug_test_connection',['test01.example'],{})
assert result['credential_requirements']==['vault_password']
assert result['credential_requirement_reasons']['vault_password']==['customer_vault_present']
def test_real_core_key_ownership_policy_stays_core_owned(settings,core_root):
key=core_root/'inventories/example/group_vars/linux/.ssh/svc_bf-ansible'
key.write_text('synthetic-only');key.chmod(0o640)
with pytest.raises(WebError) as e:
CoreAdapter(settings).preflight('example','debug_test_connection',['test01.example'],{},key_mode='customer')
assert e.value.code=='core_key_access_policy'
assert stat.S_IMODE(key.stat().st_mode)==0o640
def test_request_has_no_paths_actors_or_credentials():
for field in ('actor','credentials','vault_password','command','inventory_path'):
with pytest.raises(WebError):validate_request({'api_version':'1.0','operation':'prepare','request':{'customer':'x','playbook':'x','hosts':['x'],field:'bad'}})
def test_literal_secret_channel_validation():
val='Synthetic +% ! {{ literal }} end '
assert validate_secrets({'vault_password':val})['vault_password']==val
for val in ('line\nfeed','NUL\x00', 'x'*2049):
with pytest.raises(WebError):validate_secrets({'vault_password':val})
with pytest.raises(WebError):validate_secrets({'become_password':'out-of-scope'})
def test_structured_events_drop_raw_fields():
e=safe_event({'event_version':'1.0','run_id':'x','timestamp':'now','sequence':1,'kind':'progress','status':'ok','task_name':'DO NOT FORWARD','raw':'secret'})
assert 'task_name' not in e and 'raw' not in e
assert safe_event({'event_version':'1.0','sequence':2,'kind':'future'}) is None
def test_missing_stats_never_means_success():
with pytest.raises(WebError):response_result({'type':'response','api_version':'1.0','ok':True,'result':{'status':'succeeded','remote_work_may_have_started':True,'counts':{}}},'execute')
def test_target_outcome_projection_is_authoritative():
counts=dict.fromkeys(COUNTS,0)
counts['ok']=38
result={'type':'response','api_version':'1.0','ok':False,'result':{
'status':'failed','stage':'execution','exit_code':4,'remote_work_may_have_started':True,
'error':{'code':'host_unreachable','message':'withheld','stage':'execution','retryable':False},
'counts':dict(counts,unreachable=1),
'target_summary':{'schema':'target_outcome_summary_v1','requested':2,'successful':1,'failed':0,'unreachable':1,'not_started':0,'indeterminate':0,'complete':True,'accounted':2},
'targets':[{'host':'host01.example','outcome':'successful','counts':counts},
{'host':'host25.example','outcome':'unreachable','counts':dict(counts,unreachable=1)}]}}
projected=response_result(result,'execute')
assert projected['target_summary']['successful']==1
assert projected['targets'][1]['outcome']=='unreachable'
def test_unknown_error_fails_safely():
with pytest.raises(WebError) as e:
response_result({'type':'response','api_version':'1.0','ok':False,'error':{'code':'new_error','message':'secret-looking upstream message'}},'prepare')
assert 'secret-looking' not in e.value.message
def test_detail_progress_contract_and_projection(settings):
a=CoreAdapter(settings)
assert a.capabilities['execution_progress']['detail_schema']=='play_task_host_v1'
plan=a.preflight('example','debug_test_connection',['test01.example'],{})
assert plan['core_request']['progress_mode']=='detail'
play=safe_event({'event_version':'1.0','run_id':'r','timestamp':'now','sequence':1,'kind':'play_started','play_id':'p1','label':'Debug | Linux manageability','label_redacted':False})
task=safe_event({'event_version':'1.0','run_id':'r','timestamp':'now','sequence':2,'kind':'task_started','play_id':'p1','task_id':'t1','label':'Linux | Test SSH and Python','label_redacted':False,'handler':False})
host=safe_event({'event_version':'1.0','run_id':'r','timestamp':'now','sequence':3,'kind':'host_result','play_id':'p1','task_id':'t1','host':'test01.example','host_redacted':False,'status':'ok','changed':False,'ignored':False,'details_redacted':False,'error':None})
assert play['label'].startswith('Debug') and task['label'].startswith('Linux') and host['host']=='test01.example'
assert 'raw' not in host and 'module' not in host
def test_staging_operation_is_public_contract(settings):
assert 'staging_check' in CoreAdapter(settings).capabilities['operations']
assert request_message('staging_check')=={'api_version':'1.0','operation':'staging_check'}
@@ -0,0 +1,76 @@
"""Real core wire/process, FAKE native Ansible commands: no target contacted."""
from dataclasses import replace
from pathlib import Path
import os
import sys
from aim_webgui.adapters.core_v1 import CoreAdapter
from aim_webgui.core.client import CoreClient
def native_fixture(core_root):
directory=core_root/'fakebin';directory.mkdir()
source='''#!EXE
import json,os,sys
from pathlib import Path
name=Path(sys.argv[0]).name
if '--version' in sys.argv:
print(name+' [core 2.19.11]')
print('python version = synthetic-qualified-runtime')
print('ansible python module location = /synthetic/runtime')
elif name=='ansible-galaxy':
print(json.dumps({'/synthetic':{'ansible.windows':{'version':'3.8.0'}}}))
elif '--syntax-check' in sys.argv:
print('SYNTHETIC-RAW-OUTPUT-MUST-NOT-LEAVE-CORE')
else:
fd=int(os.environ['AIM_EVENT_FD'])
counts={'ok':1,'changed':0,'failures':0,'unreachable':0,'skipped':0,'rescued':0,'ignored':0}
events=[
{'kind':'started'},
{'kind':'detail_begin','schema':'play_task_host_v1'},
{'kind':'detail','event':'play_started','play_id':'p1','label':'Synthetic play','label_redacted':False},
{'kind':'detail','event':'task_started','play_id':'p1','task_id':'t1','label':'Synthetic task','label_redacted':False,'handler':False},
{'kind':'task'},
{'kind':'detail','event':'host_result','play_id':'p1','task_id':'t1','host':'test01.example','host_redacted':False,'status':'ok','changed':False,'ignored':False,'details_redacted':False,'error_code':None},
{'kind':'ok'},
{'kind':'target_recap','target_index':0,'counts':counts},
{'kind':'detail','event':'host_recap','host':'test01.example','host_redacted':False,'counts':counts},
{'kind':'detail_end','complete':True},
{'kind':'stats','counts':counts},
]
for sequence,event in enumerate(events,1):
os.write(fd,(json.dumps({'bridge_sequence':sequence,**event})+'\\n').encode())
print('SYNTHETIC-RAW-OUTPUT-MUST-NOT-LEAVE-CORE')
'''.replace('EXE',sys.executable)
for name in ('ansible-playbook','ansible-vault','ansible-galaxy'):
p=directory/name;p.write_text(source);p.chmod(0o755)
config=core_root/'scripts/aim.yml'
with config.open('a')as f:f.write(f'runtime:\n ansible_playbook: {directory}/ansible-playbook\n')
return directory
def test_real_core_wire_execute_with_fake_native(settings,core_root):
native_fixture(core_root)
adapter=CoreAdapter(settings)
plan=adapter.preflight('example','debug_test_connection',['test01.example'],{},check=True)
ready=adapter.readiness(plan)
assert ready['ready'] is True
events=[]
result=CoreClient(settings).request('execute',request=plan['core_request'],expected_revision=plan['core_revision'],event_sink=events.append)
assert result['status']=='succeeded'
assert result['exit_code']==0 and result['counts']['ok']==1
assert result['remote_work_may_have_started'] is True
assert {e['kind']for e in events}>={'stage','progress','stats','result'}
assert [e['status'] for e in events if e['kind']=='result']==['succeeded']
assert 'SYNTHETIC-RAW-OUTPUT' not in repr(events)+repr(result)
def test_missing_core_runtime_is_not_success(settings,core_root):
config=core_root/'scripts/aim.yml'
with config.open('a')as f:f.write('runtime:\n ansible_playbook: /nonexistent/ansible-playbook\n')
adapter=CoreAdapter(settings)
plan=adapter.preflight('example','debug_test_connection',['test01.example'],{},check=True)
result=CoreClient(settings).request('execute',request=plan['core_request'],expected_revision=plan['core_revision'])
assert result['status']=='failed'
assert result['error']['code']=='runtime_missing'
assert result['remote_work_may_have_started'] is False
@@ -0,0 +1,236 @@
"""Public Core rc8 discovery/prepare and WebGUI patch/report presentation/persistence.
All data and inventory are synthetic. No updates, reboots or managed hosts run.
"""
from copy import deepcopy
from dataclasses import replace
import base64
import json
from pathlib import Path
import re
import pytest
from fastapi.testclient import TestClient
from aim_webgui.adapters.core_v1 import CoreAdapter
from aim_webgui.app import create_app
from aim_webgui.core.client import CoreClient
from aim_webgui.core.reports import contract, data, operation_result
from aim_webgui.errors import WebError
from aim_webgui.patch_view import patch_view
from aim_webgui.reports import Reports, persist, presentation
from aim_webgui.workflows import Workflows, presentation_status
from evidence_fixtures import declared, plan, result, job
from patch_fixtures import patch_data, update_failure
from test_core_execution import native_fixture
def patch_plan(d, host='win01.example'):
p=plan(d, [host]);p['playbook']='maintenance_patch_os'
p['core_request']['playbook']=p['playbook']
return p
def retained(settings, auth, admin, payload, *, status='succeeded', declaration=None, host='win01.example'):
d=declaration or declared('patch_summary_v1');p=patch_plan(d, host)
r=result(d,hosts=p['targets'],report_data=payload,status=status)
ident=job(auth,admin,p,status='successful' if status=='succeeded' else 'failed')
with auth.store.transaction() as db:
small=persist(db,settings,ident,p,r)
db.execute('UPDATE jobs SET core_result=? WHERE id=?',(json.dumps(small),ident))
return ident, Reports(settings).slot(admin,ident), small
def client_for(auth, admin):
token,session=auth.new_session(admin)
c=TestClient(create_app(auth.settings),base_url=auth.settings.public_url)
c.cookies.set(auth.settings.cookie_name,token)
c.headers.update({'Origin':auth.settings.public_url,'X-CSRF-Token':session['csrf']})
return c
def test_rc8_public_catalog_options_not_hardcoded_defaults(settings):
a=CoreAdapter(settings);spec=a.spec('maintenance_patch_os','example')
fields={i.name:i for i in spec.inputs}
assert fields['os_patching_rescan_after_reboot'].type=='bool'
assert fields['os_patching_rescan_after_reboot'].default_hint=='false'
assert list(fields['os_patching_rescan_after_reboot'].platforms)==['windows']
delay=fields['os_patching_reboot_delay_minutes']
assert delay.minimum==0 and delay.maximum==1440 and delay.default_hint=='0'
assert fields['os_patching_reboot_message'].type=='text'
p=a.preflight('example',spec.key,['win01.example'],{},check=False)
assert p['overrides']=={} and p['core_request']['overrides']=={}
assert p['result_contract']['schema']=='patch_summary_v1'
assert 'remaining_updates_known' in p['result_contract']['data_schema']['properties']
@pytest.mark.parametrize('choice',['true','false'])
def test_explicit_continuation_bool_survives_public_prepare(settings, choice):
p=CoreAdapter(settings).preflight('example','maintenance_patch_os',['win01.example'],
{'os_patching_rescan_after_reboot':choice,'os_patching_reboot_delay_minutes':'5',
'os_patching_reboot_message':'Approved fixture maintenance'},text_inputs=True,check=False)
assert p['core_request']['overrides']=={
'os_patching_rescan_after_reboot':choice=='true',
'os_patching_reboot_delay_minutes':5,
'os_patching_reboot_message':'Approved fixture maintenance'}
@pytest.mark.parametrize('delay',[-1,1441])
def test_rc8_core_rejects_delay_outside_catalog(settings,delay):
with pytest.raises(WebError) as e:
CoreAdapter(settings).preflight('example','maintenance_patch_os',['win01.example'],
{'os_patching_reboot_delay_minutes':delay})
assert e.value.code=='core_invalid_options'
def test_new_report_schema_and_old_core_gate(settings):
a=CoreAdapter(settings);caps=a.capabilities
assert caps['collection_baselines']['ansible.windows']=='>=3.8.0,<4.0.0'
class Old:
def request(self,*args,**kwargs):return {**caps,'core_version':'3.3.0rc1'}
with pytest.raises(WebError) as e:CoreAdapter(settings,Old()).capabilities
assert e.value.code=='core_incompatible'
class WrongBaseline:
def request(self,*args,**kwargs):return {**caps,'collection_baselines':{'ansible.windows':'>=3.2.0'}}
with pytest.raises(WebError) as e:CoreAdapter(settings,WrongBaseline()).capabilities
assert e.value.code=='core_incompatible'
d=declared('patch_summary_v1')
assert data(patch_data(),contract(d)['data_schema'])==patch_data()
bad=patch_data();del bad['reboot_required_before']
with pytest.raises(WebError):data(bad,contract(d)['data_schema'])
enriched=patch_data(reboot_reasons_before=[{'source':'fixture_source','description':'Synthetic reboot source'}])
assert data(enriched,contract(d)['data_schema'])['reboot_reasons_before'][0]['source']=='fixture_source'
def test_historical_rc1_patch_schema_remains_usable(settings,auth,admin):
d=declared('patch_summary_v1')
d['data_schema']=json.loads((Path(__file__).parent/'fixtures/patch-summary-rc1.json').read_text())
old={k:v for k,v in patch_data().items() if k in d['data_schema']['properties']}
old['failed_updates']=[{'name':'Old synthetic update','identifier':None,'native_code':None,'reason':'update_failed'}]
ident,item,small=retained(settings,auth,admin,old,declaration=d,status='failed')
assert item['data']==old and 'remaining_updates_known' not in item['data']
assert any(n['title']=='Historical patch-report format' for n in patch_view(item)['notices'])
assert presentation_status('failed',small)=='failed'
with client_for(auth,admin) as c:
text=c.get(f'/jobs/{ident}/reports').text
assert 'Historical patch-report format' in text and 'newer remaining-update knowledge' in text
assert c.get(f'/api/v2/runs/{ident}/report').json()['data']==old
# Never silently validate newly added fields with an old recorded contract.
with pytest.raises(WebError):data(patch_data(),contract(d)['data_schema'])
def test_successful_wave_does_not_become_failure_or_new_job(settings,auth,admin):
ident,item,small=retained(settings,auth,admin,patch_data())
assert presentation_status('successful',small)=='successful'
p=patch_view(item)
assert any(n['title']=='Another patch run needs review' for n in p['notices'])
assert p['pending']['suppressed'] is True
with client_for(auth,admin) as c:
for _ in range(2):
response=c.get(f'/jobs/{ident}/reports');assert response.status_code==200
assert 'Another patch run needs review' in response.text
assert 'Remaining updates not established' in response.text
assert 'Final pending list not established' in response.text
assert c.get(f'/api/v2/runs/{ident}/report').json()['data']==patch_data()
with auth.store.read() as db:assert db.execute('SELECT count(*) FROM jobs').fetchone()[0]==1
def test_unknown_pending_does_not_show_old_queue_as_next_wave(settings,auth,admin):
payload=patch_data(pending=[{'name':'DO-NOT-DISPLAY-AS-FINAL-QUEUE','identifier':None,'kb':[]}])
ident,item,_=retained(settings,auth,admin,payload)
section=next(s for s in presentation(item)['sections'] if s['key']=='pending')
assert section['rows']==[] and section['total'] is None
with client_for(auth,admin) as c:
assert 'DO-NOT-DISPLAY-AS-FINAL-QUEUE' not in c.get(f'/jobs/{ident}/reports').text
assert c.get(f'/api/v2/runs/{ident}/report').json()['data']['pending']==payload['pending']
@pytest.mark.parametrize('pending',[[],[{'name':'Synthetic pending update','identifier':'fixture-pending','kb':['KB0000002']} ]])
def test_known_remaining_is_dated_discovery_not_install(settings,auth,admin,pending):
payload=patch_data(remaining_updates_known=True,pending=pending,continuation_required=bool(pending),reboot_performed=False)
ident,item,_=retained(settings,auth,admin,payload)
section=next(s for s in presentation(item)['sections'] if s['key']=='pending')
assert not section['suppressed'] and section['total']==len(pending)
with client_for(auth,admin) as c:
text=c.get(f'/jobs/{ident}/reports').text
assert 'Pending updates - final read-only discovery' in text
assert 'not a live compliance check' in text
assert ('Synthetic pending update' if pending else 'No pending updates reported by that final discovery') in text
@pytest.mark.parametrize('reason',[None,'preexisting_reboot_required','install_not_allowed','cycle_limit_reached'])
def test_reboot_stop_reason_is_explicit_and_never_inferred(settings,auth,admin,reason):
payload=patch_data(blocked_reason=reason,reboot_required=True,reboot_required_after=True,reboot_deferred=True,
failed_updates=[update_failure()] if reason=='install_not_allowed' else [])
ident,item,_=retained(settings,auth,admin,payload,status='failed' if reason else 'succeeded')
text=' '.join(n['title']+' '+n['text'] for n in patch_view(item)['notices'])
assert 'Reboot deferred' in text
assert ('Pending reboot observed before patching' in text)==(reason=='preexisting_reboot_required')
with client_for(auth,admin) as c:
html=c.get(f'/jobs/{ident}/reports').text
if reason=='install_not_allowed':
assert '0x80240016' in html and '2149842966' in html
assert 'does not by itself prove a pending reboot' in html
def test_check_report_and_linux_optional_flags_not_invented(settings,auth,admin):
item={'schema_id':'patch_summary_v1','status':'available','check_mode':True,'data':patch_data(mode='check')}
assert patch_view(item)['notices'][0]['title']=='Check mode: no installation claim'
item['data']['platform']='debian'
for key in ('rescan_after_reboot','remaining_updates_known','continuation_required','patch_cycles'):
item['data'].pop(key)
view=patch_view(item)
assert not view['windows'] and not any(n['title']=='Historical patch-report format' for n in view['notices'])
item['status']='missing';item['data']=None
assert patch_view(item) is None
def test_failure_details_are_escaped_and_retained_no_raw_fields(settings,auth,admin):
f=update_failure();f['message']='<script>fixture()</script>'
payload=patch_data(failed_updates=[f],blocked_reason='install_not_allowed')
ident,_,_=retained(settings,auth,admin,payload,status='failed')
with client_for(auth,admin) as c:
text=c.get(f'/jobs/{ident}/reports').text
assert '<script>fixture()</script>' not in text and '&lt;script&gt;' in text
f['failure_msg']='must not be accepted'
with pytest.raises(WebError):data(patch_data(failed_updates=[f]),contract(declared('patch_summary_v1'))['data_schema'])
def test_option_form_and_review_platform_hint_and_no_auto_opt_in(settings,auth,admin):
cfg=replace(settings,execution_playbooks=(*settings.execution_playbooks,'maintenance_patch_os'))
token,sess=auth.new_session(admin)
with TestClient(create_app(cfg),base_url=cfg.public_url) as c:
c.cookies.set(cfg.cookie_name,token);c.headers.update({'Origin':cfg.public_url,'X-CSRF-Token':sess['csrf']})
page=c.get('/plan?customer=example&playbook=maintenance_patch_os')
assert page.status_code==200 and 'Continue patching after reboot' in page.text
select=re.search(r'<select[^>]+name="option.os_patching_rescan_after_reboot"[^>]*>(.*?)</select>',page.text,re.S).group(1)
assert 'catalog hint: false' in select and 'selected' not in select
assert 'Reboot delay (minutes)' in page.text and 'Reboot message' in page.text
form={'customer':'example','playbook':'maintenance_patch_os','targets':'win01.example','mode':'apply','key_mode':'none',
'option.os_patching_reboot':'true','option.os_patching_rescan_after_reboot':''}
reviewed=c.post('/_partials/preflight',data=form,headers={'HX-Request':'true'})
assert reviewed.status_code==200,reviewed.text
assert 'not enabled by WebGUI' in reviewed.text and 'Enabled (explicit override)' in reviewed.text
rid=re.search('name="review_id" value="([a-f0-9]+)"',reviewed.text).group(1)
record=Workflows(cfg).review_record(admin,rid)
assert record['core_request']['overrides']=={'os_patching_reboot':True}
assert record['core_request']['check'] is False
assert record['result_contract']['schema']=='patch_summary_v1'
def test_real_core_rc8_patch_finalization_through_fake_native(settings,core_root):
directory=native_fixture(core_root)
raw=json.dumps({'protocol':'aim_output_v1','schema':'patch_summary_v1','data':patch_data()}).encode()
frames=[]
for i,pos in enumerate(range(0,len(raw),2048)):
frames.append({'kind':'output_chunk','target_index':0,'part':i,'content':base64.b64encode(raw[pos:pos+2048]).decode()})
for path in directory.iterdir():
old=path.read_text();marker=" {'kind':'stats','counts':counts},"
assert marker in old
path.write_text(old.replace(marker,','.join(repr(f) for f in frames)+",\n {'kind':'output_end','target_index':0},\n {'kind':'outputs_end'},\n"+marker))
adapter=CoreAdapter(settings);p=adapter.preflight('example','maintenance_patch_os',['win01.example'],{},check=False)
events=[]
out=CoreClient(settings).request('execute',request=p['core_request'],expected_revision=p['core_revision'],
result_contract=p['result_contract'],event_sink=events.append)
assert out['status']=='succeeded' and out['operation_result']['hosts']['win01.example']['data']==patch_data()
assert len([e for e in events if e['kind']=='result'])==1
assert all('operation_result' not in e for e in events)
@@ -0,0 +1,39 @@
"""Actual uploaded public Core plus explicitly simulated native publishers."""
import base64,json
from aim_webgui.adapters.core_v1 import CoreAdapter
from aim_webgui.core.client import CoreClient
from aim_webgui.core.reports import contract
from test_core_execution import native_fixture
def test_real_core_nine_catalog_contracts_and_schema_revision(settings,core_root):
adapter=CoreAdapter(settings)
catalogs=adapter.playbooks('example');declared=[p for p in catalogs if p.get('result')]
assert len(declared)==9
for p in declared:assert contract(p['result'])==p['result']
p=adapter.preflight('example','debug_detect_host_roles',['test01.example'],{})
assert p['result_contract']['schema']=='host_capabilities_v1'
assert p['result_contract']['required']is True
path=core_root/'playbooks/schemas/host_capabilities_v1.yml'
path.write_text(path.read_text()+'\ndescription: Synthetic schema revision change\n')
new=adapter.preflight('example','debug_detect_host_roles',['test01.example'],{})
assert new['core_revision']!=p['core_revision']
def test_actual_core_report_finalization_with_fake_native(settings,core_root):
directory=native_fixture(core_root)
keys=['is_dc','is_dhcp_server','is_hyperv_host','has_veeam_vbr','has_veeam_vbo','has_veeam_em','is_unifi_controller','is_unifi_os_server']
data={k:False for k in keys};data['is_unifi_controller']=True
raw=json.dumps({'protocol':'aim_output_v1','schema':'host_capabilities_v1','data':data}).encode()
frame={'kind':'output_chunk','target_index':0,'part':0,'content':base64.b64encode(raw).decode()}
for path in directory.iterdir():
s=path.read_text();s=s.replace(" {'kind':'stats','counts':counts},",repr(frame)+",\n {'kind':'output_end','target_index':0},\n {'kind':'outputs_end'},\n {'kind':'stats','counts':counts},")
path.write_text(s)
adapter=CoreAdapter(settings);p=adapter.preflight('example','debug_detect_host_roles',['test01.example'],{},check=False)
events=[]
r=CoreClient(settings).request('execute',request=p['core_request'],expected_revision=p['core_revision'],result_contract=p['result_contract'],event_sink=events.append)
assert r['status']=='succeeded'
assert r['operation_result']['hosts']['test01.example']['data']==data
assert r['operation_result']['complete']is True
assert len([e for e in events if e['kind']=='result'])==1
assert all('operation_result'not in e for e in events)
@@ -0,0 +1,289 @@
"""Credential modal/read status/attention boundaries; synthetic secrets, no Core run."""
from dataclasses import replace
import json
import time
import uuid
from pathlib import Path
import pytest
from fastapi.testclient import TestClient
from aim_webgui.app import create_app
from aim_webgui.auth.service import Auth
from aim_webgui.config import Settings
from aim_webgui.workflows import Workflows
from aim_webgui.credentials import presentation, service, wire
from aim_webgui.errors import WebError
SYNTHETIC = 'fixture-only +%! {{ 7 * 7 }} " snowman \u2603'
@pytest.fixture
def authz(tmp_path, monkeypatch):
settings = Settings(state_dir=tmp_path/'state', public_url='https://aim.example.test',
execution_enabled=True, execution_require_approval=False,
execution_playbooks=('checkmk_install_agent',),
execution_transport_verified=True, credentials_enabled=True).validate()
auth = Auth(settings); auth.bootstrap()
for name, role in [('operator', 'viewer'), ('another', 'viewer'), ('admin2', 'admin')]:
auth.create_user(name, 'Synthetic-fixture-password-2026', role)
with auth.store.transaction() as db:
db.execute('UPDATE users SET must_change_password=0')
users = {row['username']: row['id'] for row in db.execute('SELECT id,username FROM users')}
for user in ('operator', 'another'):
db.execute('INSERT INTO grants(user_id,customer,playbook) VALUES(?,?,?)',
(users[user], 'example', 'checkmk_install_agent'))
# New UX must not prepare/execute/decrypt/probe remotely on page load.
from aim_webgui.adapters.core_v1 import CoreAdapter
def forbidden(*args, **kwargs):
raise AssertionError('Credential UX must not call Core')
for name in ('preflight', 'readiness', 'reprepare'):
monkeypatch.setattr(CoreAdapter, name, forbidden)
return auth, users
def make_job(authz, owner='operator', *, requirements=None, status='running', phase='waiting', deadline=None, canceled=False, when=None):
auth, users = authz
now = int(time.time())
ident = uuid.uuid4().hex
plan = {'customer': 'example', 'playbook': 'checkmk_install_agent', 'targets': ['lab.example'],
'overrides': {}, 'core_request': {'key_mode': 'customer'},
'credential_requirements': requirements if requirements is not None else ['vault_password', 'ssh_key_passphrase_or_customer_vault_value']}
with auth.store.transaction() as db:
db.execute('''INSERT INTO jobs(id,owner_id,plan,mode,status,created_at,scheduled_at,
credential_phase,credential_deadline,cancel_requested)
VALUES(?,?,?,?,?,?,?,?,?,?)''',
(ident, users[owner], json.dumps(plan), 'apply', status, when or now, now, phase,
now+240 if deadline is None else deadline, canceled))
return ident
def client(authz, user='operator'):
auth, users = authz
token, session = auth.new_session(users[user])
browser = TestClient(create_app(auth.settings), base_url=auth.settings.public_url, follow_redirects=False)
browser.cookies.set(auth.settings.cookie_name, token)
browser.headers.update({'Origin': auth.settings.public_url, 'X-CSRF-Token': session['csrf']})
return browser
def test_modal_fragment_is_eligible_owner_only_and_full_page_fallback(authz):
ident = make_job(authz)
with client(authz) as b:
r = b.get('/jobs/'+ident)
assert r.status_code == 200
assert 'data-credential-open' in r.text and '<dialog' in r.text
assert 'name="vault_password"' not in r.text # Lazy-loaded, outside pollable fragment.
r = b.get('/_partials/jobs/'+ident+'/credentials')
assert r.status_code == 200
assert 'Use customer Vault' in r.text and 'Enter separately' in r.text
assert 'type="radio"' in r.text and 'btn-check' in r.text
assert 'data-credential-secret' in r.text and 'hx-history="false"' in r.text
assert 'value="'+SYNTHETIC+'"' not in r.text
assert 'no-store' in r.headers['cache-control']
r = b.get('/jobs/'+ident+'/credentials')
assert r.status_code == 200 and 'data-credential-form' in r.text
assert 'action="/jobs/'+ident+'/credentials"' in r.text
for user in ('another', 'admin2'):
with client(authz, user) as b:
assert b.get('/_partials/jobs/'+ident+'/credentials').status_code in (403, 404)
assert b.get('/api/v2/runs/'+ident+'/credential-status').status_code in (403, 404)
def test_only_core_reported_fields_shown(authz):
vault = make_job(authz, requirements=['vault_password'])
key = make_job(authz, requirements=['ssh_key_passphrase_or_customer_vault_value'])
conn = make_job(authz, requirements=['connection_password'])
unsupported = make_job(authz, requirements=['become_password'])
with client(authz) as b:
v = b.get('/_partials/jobs/'+vault+'/credentials').text
assert 'name="vault_password"' in v and 'name="ssh_key_passphrase"' not in v
k = b.get('/_partials/jobs/'+key+'/credentials').text
assert 'Use customer Vault' not in k
assert 'name="ssh_key_passphrase" maxlength="2048" required' in k
c = b.get('/_partials/jobs/'+conn+'/credentials').text
assert 'name="connection_password"' in c and 'name="vault_password"' not in c
assert b.get('/_partials/jobs/'+unsupported+'/credentials').status_code == 409
@pytest.mark.parametrize('status,phase,canceled,expired', [
('queued', '', False, False), ('running', 'claimed', False, False),
('running', 'waiting', True, False), ('running', 'waiting', False, True),
('failed', 'released', False, False)])
def test_reservation_state_changes_never_keep_form_eligible(authz, status, phase, canceled, expired):
ident = make_job(authz, status=status, phase=phase, canceled=canceled,
deadline=int(time.time())-1 if expired else None)
with client(authz) as b:
r = b.get('/api/v2/runs/'+ident+'/credential-status')
assert r.status_code == 200 and not r.json()['can_submit']
assert b.get('/_partials/jobs/'+ident+'/credentials').status_code == 409
r = b.post('/api/v2/runs/'+ident+'/credentials', json={'vault_password': SYNTHETIC})
assert r.status_code == 409 and SYNTHETIC not in r.text
def test_status_get_is_read_only_and_does_not_renew(authz):
ident = make_job(authz)
auth, users = authz
flow = Workflows(auth.settings)
before = flow.job(users['operator'], ident)
with client(authz) as b:
for _ in range(3):
r = b.get('/api/v2/runs/'+ident+'/credential-status')
assert r.json()['can_submit']
assert set(r.json()) == {'job_id','status','phase','can_submit','cancel_requested','server_now','deadline'}
b.get('/_partials/jobs/'+ident+'/credentials')
after = flow.job(users['operator'], ident)
assert before['credential_deadline'] == after['credential_deadline']
assert before['events'] == after['events'] and before['status'] == after['status']
def test_revoked_grant_and_session_denied(authz):
ident = make_job(authz)
auth, users = authz
with client(authz) as b:
with auth.store.transaction() as db:
db.execute('DELETE FROM grants WHERE user_id=?', (users['operator'],))
assert b.get('/api/v2/runs/'+ident+'/credential-status').status_code == 403
assert b.get('/_partials/jobs/'+ident+'/credentials').status_code == 403
assert b.post('/api/v2/runs/'+ident+'/credentials', json={'vault_password': SYNTHETIC}).status_code == 403
with auth.store.transaction() as db: db.execute('DELETE FROM sessions')
assert b.get('/api/v2/runs/'+ident+'/credential-status').status_code == 401
@pytest.mark.parametrize('route', ['/api/v2/runs/', '/api/v2/jobs/'])
def test_submit_keeps_one_run_wire_and_literal_secret_semantics(authz, monkeypatch, route):
ident = make_job(authz)
auth, users = authz
packets = []
class Sock:
def __enter__(self): return self
def __exit__(self, *args): pass
monkeypatch.setattr(wire, 'connect', lambda *a, **k: Sock())
monkeypatch.setattr(wire, 'send', lambda sock, value, limit: packets.append(json.loads(json.dumps(value))))
monkeypatch.setattr(wire, 'receive', lambda *a: {'accepted': True})
with client(authz) as b:
r = b.post(route+ident+'/credentials', json={'vault_password': SYNTHETIC})
assert r.status_code == 202 and r.json()['accepted']
assert 'not remote authentication verification' in r.json()['notice']
assert SYNTHETIC not in r.text
assert packets[0]['credentials']['vault_password'] == SYNTHETIC
assert packets[0]['job'] == ident and packets[0]['user'] == users['operator']
assert SYNTHETIC.encode() not in auth.settings.database.read_bytes()
def test_submission_unknown_does_not_claim_it_was_not_received(authz, monkeypatch):
ident = make_job(authz)
monkeypatch.setattr(wire, 'connect', lambda *a, **k: (_ for _ in ()).throw(OSError('synthetic internal detail')))
with client(authz) as b:
r = b.post('/api/v2/runs/'+ident+'/credentials', json={'vault_password': SYNTHETIC})
assert r.status_code == 409 and 'could not be confirmed' in r.json()['error']['message']
assert 'synthetic internal' not in r.text and SYNTHETIC not in r.text
def test_csrf_origin_body_limit_duplicate_and_forbidden_fields(authz):
ident = make_job(authz)
with client(authz) as b:
path = '/api/v2/runs/'+ident+'/credentials'
assert b.post(path, json={'vault_password': SYNTHETIC}, headers={'Origin':'https://other.invalid'}).status_code == 403
csrf = b.headers.pop('X-CSRF-Token')
assert b.post(path, json={'vault_password': SYNTHETIC}).status_code == 403
b.headers['X-CSRF-Token'] = csrf
assert b.post(path, json={'vault_password': 'x'*9000}).status_code == 413
for value in ({'vault_password': '\n'}, {'vault_password': 'x'*2049}, {'username': 'root'},
{'become_password': 'x'}, {'vault_password': SYNTHETIC, 'scope': 'all'}):
r = b.post(path, json=value)
assert r.status_code == 400 and SYNTHETIC not in r.text
r = b.post('/jobs/'+ident+'/credentials', content='_csrf='+csrf+'&vault_password=x&vault_password=y',
headers={'Content-Type': 'application/x-www-form-urlencoded'})
assert r.status_code == 400
def test_required_key_without_vault_and_no_new_auth_override():
with pytest.raises(WebError): service.fields({}, ['ssh_key_passphrase_or_customer_vault_value'])
assert service.fields({'ssh_key_passphrase': SYNTHETIC}, ['ssh_key_passphrase_or_customer_vault_value']) == {'ssh_key_passphrase': SYNTHETIC}
with pytest.raises(WebError): service.fields({}, ['ssh_key_passphrase'])
with pytest.raises(WebError): service.fields({'ssh_key_passphrase': SYNTHETIC}, ['vault_password'])
def test_attention_scope_not_latest_100_and_no_auto_approval(authz):
auth, users = authz
own = make_job(authz, when=1)
others = make_job(authz, owner='another')
pending = make_job(authz, owner='another', status='pending', phase='')
admin_own = make_job(authz, owner='admin2', status='pending', phase='')
for _ in range(101): make_job(authz, status='failed', phase='released')
data = presentation.attention(Workflows(auth.settings), users['operator'])
assert data['total'] == 1 and data['items'][0]['id'] == own
admin_data = presentation.attention(Workflows(auth.settings), users['admin2'])
assert admin_data['total'] == 1 and admin_data['items'][0]['id'] == pending
assert others not in repr(admin_data) and admin_own not in repr(admin_data)
with client(authz) as b:
text = b.get('/_partials/attention').text
assert 'data-credential-open' in text and own in text and others not in text
r = b.get('/jobs')
assert 'Credentials needed' in r.text and own in r.text
assert Workflows(auth.settings).job(users['admin2'],pending)['status'] == 'pending'
def test_modal_is_outside_status_poll_and_no_custom_toggle():
root = Path(__file__).resolve().parents[1]/'src/aim_webgui'
partial = (root/'templates/partials/job.html').read_text()
assert '<dialog' not in partial and '<input' not in partial.replace('<input type="hidden" name="_csrf" value="{{ csrf }}">','')
js = (root/'static/js/credentials.js').read_text()
assert 'localStorage' not in js and 'sessionStorage' not in js
assert 'dialog.showModal()' in js and 'performance.now()' in js
assert 'data-credential-secret' in js and 'credentials: \'same-origin\'' in js
assert "'Use custom credentials'" not in js
def test_real_worker_socket_claim_is_single_use_and_never_persisted(authz, monkeypatch):
"""Exercise the actual existing worker wait/claim; never call Core execution."""
import threading
import aim_webgui.worker as worker_module
ident = make_job(authz)
auth, users = authz
worker = worker_module.Worker(auth.settings, Path('/synthetic-unused-config.toml'))
monkeypatch.setattr(worker_module, 'revalidate', lambda *args: None)
with auth.store.read() as db:
row = dict(db.execute('SELECT * FROM jobs WHERE id=?',(ident,)).fetchone())
result, errors = [], []
def wait():
try: result.append(worker.wait_credentials(row))
except Exception as error: errors.append(error)
thread = threading.Thread(target=wait, daemon=True);thread.start()
until = time.monotonic()+3
while not (auth.settings.state_dir/'.credential.sock').exists() and time.monotonic()<until:
time.sleep(.01)
assert (auth.settings.state_dir/'.credential.sock').exists()
with client(authz) as b:
r = b.post('/api/v2/runs/'+ident+'/credentials', json={'vault_password': SYNTHETIC})
assert r.status_code == 202 and r.json()['accepted']
thread.join(timeout=3)
assert not thread.is_alive() and not errors
assert result[0]['credentials']['vault_password'] == SYNTHETIC
assert b.post('/api/v2/runs/'+ident+'/credentials', json={'vault_password': SYNTHETIC}).status_code == 409
state = b.get('/api/v2/runs/'+ident+'/credential-status').json()
assert state['phase'] == 'claimed' and not state['can_submit']
assert SYNTHETIC.encode() not in auth.settings.database.read_bytes()
result[0]['credentials'].clear();result.clear()
def test_explicit_key_requirement_is_not_downgraded_when_vault_also_required(authz):
ident = make_job(authz, requirements=['vault_password', 'ssh_key_passphrase'])
with client(authz) as b:
r = b.get('/_partials/jobs/'+ident+'/credentials')
assert r.status_code == 200
assert 'data-key-source-choice' not in r.text
assert 'name="ssh_key_passphrase" maxlength="2048" required' in r.text
r = b.post('/api/v2/runs/'+ident+'/credentials', json={'vault_password': SYNTHETIC})
assert r.status_code == 400
def test_credential_rate_limit_is_preserved_across_api_aliases(authz, monkeypatch):
ident = make_job(authz)
# This fixture isolates throttling; actual wire/claim is covered separately.
monkeypatch.setattr(service, 'submit', lambda *args: {'accepted': True})
with client(authz) as b:
for i in range(5):
route = '/api/v2/'+('runs' if i % 2 else 'jobs')+'/'+ident+'/credentials'
assert b.post(route, json={'vault_password': SYNTHETIC}).status_code == 202
assert b.post('/api/v2/runs/'+ident+'/credentials', json={'vault_password': SYNTHETIC}).status_code == 429
@@ -0,0 +1,131 @@
import importlib.util
from pathlib import Path
import sys
import tomllib
ROOT=Path(__file__).resolve().parents[1]
sys.path.insert(0,str(ROOT/'deploy'))
spec=importlib.util.spec_from_file_location('deployment_v2',ROOT/'deploy/deploy.py')
deployment=importlib.util.module_from_spec(spec);sys.modules[spec.name]=deployment;spec.loader.exec_module(deployment)
def test_units_have_no_sudo_or_root_runtime():
for mode,executor,user in [('serve',False,'aim-web'),('worker',False,'aim-web'),('executor',True,'svc_bf-ansible')]:
text=deployment.unit_text(user,991,Path('/etc/ansible/scripts/config/webgui.toml'),mode,executor=executor,executor_group=1001 if executor else None,supplementary_group=991 if executor else None,executor_local_home='/home/svc_bf-ansible' if executor else None)
assert f'User={user}\n'in text
assert 'NoNewPrivileges=true' in text
assert 'CapabilityBoundingSet=\n'in text
assert 'CAP_SETUID'not in text and 'sudo'not in text
assert 'RuntimeDirectory=aim-web-executor' in text if executor else 'RuntimeDirectory='not in text
if executor: assert 'RuntimeDirectoryMode=0711' in text
if executor:
assert 'Group=1001\n' in text
assert 'SupplementaryGroups=991\n' in text
assert 'Environment=HOME=/var/lib/aim-web-executor' in text
assert 'ReadWritePaths=/var/lib/aim-web-executor /var/lib/aim-web-executor/.ansible/tmp /home/svc_bf-ansible/.ansible/tmp -/etc/ansible/inventories' in text
assert 'core-staging-check' in text
def test_no_private_core_import_and_no_old_stage_bridge():
text=(ROOT/'deploy/deploy.py').read_text()
assert 'from aim.config'not in text and 'sys.path.insert'not in text
assert 'key_export.py'not in text
assert 'core_transport="stdio"'in text
assert '--migrate-core'in text
assert "'db','migrate'"in text
assert text.index("os.replace(stage, target)")<text.index('atomic_bytes(EXECUTOR_UNIT')
def test_site_profile_and_versions():
from aim_webgui.config import Settings
from aim_webgui import __version__,SCHEMA_VERSION,HTTP_API_VERSION
settings=Settings.load(ROOT/'deploy/webgui.example.toml')
assert settings.core_transport=='unix' and settings.core_executor_user=='svc_bf-ansible'
assert settings.host=='127.0.0.1' and settings.execution_require_approval is False
assert len(settings.execution_playbooks)==14
assert __version__=='2.1.0rc9' and SCHEMA_VERSION==5 and HTTP_API_VERSION==2
metadata=tomllib.loads((ROOT/'pyproject.toml').read_text())
assert metadata['tool']['aim-web']['http-api']==2
def test_stage_and_rollback_preserve_modes(tmp_path):
target=tmp_path/'file';deployment.atomic_bytes(target,b'first',0o640)
deployment.atomic_bytes(target,b'second',0o600)
assert target.read_bytes()==b'second' and target.stat().st_mode&0o777==0o600
link=tmp_path/'link';link.symlink_to(target)
import pytest
with pytest.raises(ValueError):deployment.atomic_bytes(link,b'unsafe')
assert target.read_bytes()==b'second'
def test_release_manifest_detects_changed_or_traversing_payload(tmp_path):
import hashlib
import pytest
files={'pyproject.toml':b'project','deploy/deploy.py':b'installer','src/aim_webgui/__init__.py':b'version'}
for name,data in files.items():
p=tmp_path/name;p.parent.mkdir(parents=True,exist_ok=True);p.write_bytes(data)
manifest=tmp_path/'MANIFEST.sha256'
original=''.join(hashlib.sha256(data).hexdigest()+' '+name+'\n'for name,data in files.items())
manifest.write_text(original);deployment.verify_release(tmp_path)
dotted=''.join(hashlib.sha256(data).hexdigest()+' ./'+name+'\n' for name,data in files.items())
manifest.write_text(dotted);deployment.verify_release(tmp_path)
manifest.write_text(original)
(tmp_path/'pyproject.toml').write_bytes(b'changed')
with pytest.raises(ValueError,match='integrity'):deployment.verify_release(tmp_path)
manifest.write_text('0'*64+' ../outside\n')
with pytest.raises(ValueError,match='manifest'):deployment.verify_release(tmp_path)
def test_home_and_direct_profile_defaults():
from aim_webgui.config import Settings
from dataclasses import replace
import pytest
settings=Settings.load(ROOT/'deploy/webgui.example.toml')
assert settings.core_home==Path('/var/lib/aim-web-executor')
with pytest.raises(ValueError):replace(settings,core_home=settings.state_dir).validate()
browsing=Settings.load(ROOT/'deploy/profiles/direct-npm.toml')
assert not browsing.execution_enabled and not browsing.credentials_enabled
def test_executor_identity_preserves_primary_gid_and_scopes_web_group():
text=(ROOT/'deploy/deploy.py').read_text()
assert 'os.setgid(account.pw_gid)' in text
assert "os.getgrouplist(user,account.pw_gid) + [web.pw_gid]" in text
executor=(ROOT/'src/aim_webgui/core/executor.py').read_text()
assert 'os.chown(parent,-1,client_gid)' not in executor
assert "stat.S_IMODE(st.st_mode)!=0o711" in executor
assert 'os.chown(path,-1,client_gid)' in executor
def test_executor_socket_readiness_waits_for_type_simple_bind(monkeypatch,tmp_path):
attempts=[]
def fake_validate(*args,**kwargs):
attempts.append(1)
if len(attempts)<3:
raise ValueError('Managed executor socket is missing or a symlink')
class Result:
returncode=0
monkeypatch.setattr(deployment,'validate_executor_runtime_permissions',fake_validate)
monkeypatch.setattr(deployment.subprocess,'run',lambda *a,**k: Result())
monkeypatch.setattr(deployment.time,'sleep',lambda *_: None)
deployment.wait_executor_runtime_permissions('aim-web','svc-bf',tmp_path/'core.sock',timeout=1)
assert len(attempts)==3
def test_executor_socket_readiness_fails_if_service_exits(monkeypatch,tmp_path):
import pytest
monkeypatch.setattr(deployment,'validate_executor_runtime_permissions',lambda *a,**k: (_ for _ in ()).throw(ValueError('missing socket')))
class Result:
returncode=3
monkeypatch.setattr(deployment.subprocess,'run',lambda *a,**k: Result())
with pytest.raises(ValueError,match='exited before'):
deployment.wait_executor_runtime_permissions('aim-web','svc-bf',tmp_path/'core.sock',timeout=1)
def test_restore_guard_probes_restored_adapter_as_executor_before_start(monkeypatch,tmp_path):
d=deployment.Deployment(tmp_path,'root',0);calls=[]
monkeypatch.setattr(d,'as_executor',lambda user,cmd,**kw:calls.append((user,cmd)))
assert d.restored_core_compatible({'venv':str(tmp_path/'old'),'executor_user':'nobody'})
assert calls[0][0]=='nobody' and str(tmp_path/'old/bin/python')==str(calls[0][1][0])
assert 'core_transport="stdio"'in calls[0][1][3]
def fail(*args,**kwargs):raise deployment.subprocess.CalledProcessError(1,['fixture'])
monkeypatch.setattr(d,'as_executor',fail)
assert not d.restored_core_compatible({'venv':str(tmp_path/'old')})
@@ -0,0 +1,75 @@
"""Linux local socket/UID integration. Synthetic core inventory, no remote hosts."""
from dataclasses import replace
from pathlib import Path
import grp
import json
import os
import pwd
import shutil
import signal
import socket
import struct
import subprocess
import sys
import tempfile
import time
import pytest
from aim_webgui.config import Settings
from aim_webgui.core.client import CoreClient
from aim_webgui.credentials import wire
def test_nonroot_executor_public_core_and_peer_rejection(core_root):
if os.geteuid()!=0 or not hasattr(socket,'SO_PEERCRED'):
pytest.skip('Needs Linux root in the isolated test container to exercise different local UIDs.')
account=pwd.getpwnam('nobody')
base=Path(tempfile.mkdtemp(prefix='aim-exec-'));base.chmod(0o755)
process=None
try:
core=base/'core';shutil.copytree(core_root,core)
for p in [core,*core.rglob('*')]:
if p.is_dir():p.chmod(0o755)
else:p.chmod(0o644)
config=core/'scripts/aim.yml'
config.write_text(f'root_dir: {core}\nservice_user: synthetic\nrequired_group: {grp.getgrgid(account.pw_gid).gr_name}\n')
runtime=base/'socket';runtime.mkdir(mode=0o711);os.chown(runtime,account.pw_uid,account.pw_gid)
home=base/'home';home.mkdir(mode=0o700);os.chown(home,account.pw_uid,account.pw_gid)
cfg=base/'web.toml'
cfg.write_text('[core]\ntransport="unix"\n'+f'command=[{json.dumps(sys.executable)},{json.dumps(str(core/"scripts/aimctl.py"))}]\n'+
f'config={json.dumps(str(config))}\nhome={json.dumps(str(home))}\nsocket={json.dumps(str(runtime/"core.sock"))}\nexecutor_user="nobody"\nclient_user="root"\n')
cfg.chmod(0o644)
settings=Settings.load(cfg)
source=Path(__file__).resolve().parents[1]/'src'
def drop():os.setgroups([0,account.pw_gid]);os.setgid(account.pw_gid);os.setuid(account.pw_uid)
process=subprocess.Popen([sys.executable,'-B','-m','aim_webgui','--config',str(cfg),'executor'],
env={**os.environ,'PYTHONPATH':str(source)},stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL,preexec_fn=drop)
for _ in range(200):
if settings.core_socket.exists():break
if process.poll()is not None:pytest.fail('Executor fixture exited before opening socket')
time.sleep(.05)
assert settings.core_socket.exists()
assert settings.core_socket.stat().st_uid==account.pw_uid
assert runtime.stat().st_gid==account.pw_gid
assert settings.core_socket.stat().st_gid==0
assert settings.core_socket.stat().st_mode&0o777==0o660
client=CoreClient(settings)
assert client.request('capabilities')['core_version']=='3.3.0rc8'
assert client.request('list_customers')==['example']
# A direct local client cannot smuggle an execution executable/path field.
with socket.socket(socket.AF_UNIX)as conn:
conn.connect(str(settings.core_socket));wire.send(conn,{'request':{'api_version':'1.0','operation':'list_customers','command':'id'},'credential_frame':False,'start_seconds':None})
assert wire.receive(conn)['type']=='transport_error'
# Make only this synthetic endpoint reachable to a different UID, so the
# SO_PEERCRED check, rather than DAC alone, is exercised.
runtime.chmod(0o755);settings.core_socket.chmod(0o666)
script='''import socket,sys
s=socket.socket(socket.AF_UNIX);s.connect(sys.argv[1]);s.settimeout(3)
assert s.recv(4)==b''
'''
other=pwd.getpwnam('daemon')
def other_drop():os.setgroups([]);os.setgid(other.pw_gid);os.setuid(other.pw_uid)
result=subprocess.run([sys.executable,'-c',script,str(settings.core_socket)],preexec_fn=other_drop,capture_output=True,timeout=5)
assert result.returncode==0
finally:
if process and process.poll()is None:process.send_signal(signal.SIGTERM);process.wait(timeout=20)
shutil.rmtree(base)
@@ -0,0 +1,138 @@
from dataclasses import replace
import json,sqlite3,time,threading
import pytest
from fastapi.testclient import TestClient
from aim_webgui.config import Settings
from aim_webgui.auth.service import Auth
from aim_webgui.journal import Journal,Capture,project
from aim_webgui.app import create_app
from aim_webgui.workflows import Workflows
from aim_webgui.errors import WebError
from evidence_fixtures import job,ev
@pytest.fixture
def local(tmp_path):
s=Settings(state_dir=tmp_path/'state',public_url='https://aim.example.test');a=Auth(s);a.bootstrap()
with a.store.transaction()as db:db.execute('UPDATE users SET must_change_password=0');uid=db.execute('SELECT id FROM users').fetchone()[0]
return s,a,uid
def batch(*items):return [(*project(e,{'test01.example'}),time.time())for e in items]
def test_committed_snapshot_duplicate_sequences_and_metadata_only(local):
s,a,u=local;ident=job(a,u);j=Journal(s);j.begin(ident)
e=ev(1,raw='SYNTHETIC-SECRET-CANARY',msg={'password':'HIDDEN'})
j.append(ident,batch(e,e,ev(3,'host_result')))
snap=j.snapshot(u,ident)
assert snap['cursor']==2 and len(snap['events'])==2 and snap['dropped_events']==0
assert snap['checkpoint']['observed_task_starts']==1
assert snap['checkpoint']['hosts'][0]['task_id']=='t1'
with a.store.read()as db:assert 'SYNTHETIC-SECRET-CANARY'not in ''.join(r[0] for r in db.execute('SELECT payload FROM job_progress_events'))
j.append(ident,batch(e));assert j.snapshot(u,ident)['cursor']==2
def test_retention_cursor_gap_and_checkpoint(local):
s,a,u=local;s=replace(s,journal_max_events=100);ident=job(a,u);j=Journal(s);j.begin(ident)
j.append(ident,batch(*(ev(i)for i in range(1,251))))
snap=j.snapshot(u,ident,after=0)
assert snap['first_cursor']==151 and snap['cursor']==250 and snap['omitted_events']==150 and snap['gap_before']
assert snap['checkpoint']['observed_task_starts']==250 and len(snap['checkpoint']['tasks'])==64
assert len(j.snapshot(u,ident,before=201,limit=20)['events'])==20
with pytest.raises(WebError):j.snapshot(u,ident,after=9999)
def test_byte_limit_and_deletion(local):
s,a,u=local;s=replace(s,journal_max_bytes=65536);ident=job(a,u,status='failed');j=Journal(s);j.begin(ident)
j.append(ident,batch(*(ev(i,label='X'*200)for i in range(1,400))),closed=True)
snap=j.snapshot(u,ident);assert snap['retained_bytes']<=65536 and snap['omitted_events']>0
Workflows(s).delete_jobs(u,[ident])
with a.store.read()as db:
assert db.execute('SELECT COUNT(*) FROM job_progress_events').fetchone()[0]==0
assert db.execute('SELECT COUNT(*) FROM job_progress_state').fetchone()[0]==0
def test_capture_independent_of_viewers_and_end(local):
s,a,u=local;ident=job(a,u);c=Capture(s,ident,['test01.example'])
for i in range(1,31):c.submit(ev(i))
assert c.close()
snap=Journal(s).snapshot(u,ident);assert len(snap['events'])==30 and snap['capture_state']=='closed'
with a.store.transaction()as db:db.execute("UPDATE jobs SET status='successful' WHERE id=?",(ident,))
token,_=a.new_session(u)
with TestClient(create_app(s),base_url=s.public_url)as client:
client.cookies.set(s.cookie_name,token)
first=client.get('/api/v2/runs/'+ident+'/progress').json()
replay=client.get('/api/v2/runs/'+ident+'/progress/stream?after=0',headers={'Last-Event-ID':'20'})
assert replay.status_code==200 and replay.text.count('event: line\n')==10
assert 'id: 21\n'in replay.text and 'id: 20\n'not in replay.text
assert 'event: end'in replay.text
second=client.get('/api/v2/runs/'+ident+'/progress').json();assert first['cursor']==second['cursor']==30
def test_owner_scope_active_delete_and_crash_marker(local):
s,a,u=local;a.create_user('viewer','Synthetic-password-value-2026','viewer')
with a.store.transaction()as db:db.execute('UPDATE users SET must_change_password=0');v=db.execute("SELECT id FROM users WHERE username='viewer'").fetchone()[0]
ident=job(a,u);j=Journal(s);j.begin(ident);j.append(ident,batch(ev(1)))
with pytest.raises(WebError):j.snapshot(v,ident)
with pytest.raises(WebError):Workflows(s).delete_jobs(u,[ident])
with a.store.transaction()as db:db.execute("UPDATE jobs SET status='interrupted' WHERE id=?",(ident,))
assert j.snapshot(u,ident)['capture_interrupted']
def test_queue_pressure_is_bounded_and_disclosed(local,monkeypatch):
s,a,u=local;ident=job(a,u)
monkeypatch.setattr('aim_webgui.journal.QUEUE_EVENTS',2)
original=Journal.append
def slow(self,*args,**kwargs):time.sleep(.05);return original(self,*args,**kwargs)
monkeypatch.setattr(Journal,'append',slow)
c=Capture(s,ident,['test01.example']);start=time.monotonic()
for i in range(1,301):c.submit(ev(i))
assert time.monotonic()-start<1
c.close();snap=Journal(s).snapshot(u,ident)
assert snap['dropped_events']>0 and snap['capture_state']=='closed_with_gaps'
def test_storage_failure_does_not_throw_from_sink(local,monkeypatch):
s,a,u=local;ident=job(a,u)
def fail(*args,**kwargs):raise sqlite3.OperationalError('SYNTHETIC disk full; never logged')
monkeypatch.setattr(Journal,'append',fail)
c=Capture(s,ident,['test01.example']);c.submit(ev(1));c.close()
with a.store.transaction()as db:db.execute("UPDATE jobs SET status='failed' WHERE id=?",(ident,))
assert Journal(s).snapshot(u,ident)['capture_interrupted']
def test_core_result_event_is_not_final_success(local):
s,a,u=local;ident=job(a,u);j=Journal(s);j.begin(ident)
j.append(ident,batch(ev(1,'result',result={'status':'succeeded','operation_result':{'raw':'NOT-STORED'}})))
snap=j.snapshot(u,ident);assert snap['job_status']=='running' and not snap['terminal']
assert snap['checkpoint']['result_event_observed'] and 'NOT-STORED'not in json.dumps(snap)
def test_legacy_and_new_no_events_are_explicit(local):
s,a,u=local;ident=job(a,u,status='successful')
snap=Journal(s).snapshot(u,ident);assert not snap['available'] and 'not captured'in snap['message']
def test_wrong_identity_and_unknown_additive_payloads(local):
s,a,u=local;ident=job(a,u);c=Capture(s,ident,['test01.example'])
c.submit(ev(1,'host_result',host='someone-else.example'))
c.submit(ev(2,'progress',status='ok',msg='NO-SAVE'))
c.submit(ev(3,'task_started',environment={'secret':'NO-SAVE'}))
c.close();snap=Journal(s).snapshot(u,ident)
assert snap['dropped_events']==1 and len(snap['events'])==1 and 'NO-SAVE'not in json.dumps(snap)
def test_two_live_authorized_streams_share_committed_cursor_and_revocation(local):
s,a,u=local;ident=job(a,u);j=Journal(s);j.begin(ident);j.append(ident,batch(ev(1)))
tokens=[a.new_session(u)[0] for _ in range(2)];responses=[];errors=[]
def view(token):
try:
with TestClient(create_app(s),base_url=s.public_url)as client:
client.cookies.set(s.cookie_name,token)
responses.append(client.get('/api/v2/runs/'+ident+'/progress/stream').text)
except Exception as e:errors.append(type(e).__name__)
threads=[threading.Thread(target=view,args=(t,),daemon=True)for t in tokens]
for t in threads:t.start()
time.sleep(.3);j.append(ident,batch(ev(2),ev(3)))
time.sleep(.3)
with a.store.transaction()as db:db.execute("UPDATE jobs SET status='successful' WHERE id=?",(ident,))
for t in threads:t.join(timeout=5)
assert not errors and len(responses)==2
for text in responses:
assert text.count('event: line\n')==3 and text.count('id: 3\n')==1 and 'event: end'in text
# A separate active stream must stop when its server-side session is revoked.
ident=job(a,u);j.begin(ident);responses.clear()
token=a.new_session(u)[0];thread=threading.Thread(target=view,args=(token,),daemon=True);thread.start()
time.sleep(.3)
with a.store.transaction()as db:db.execute('DELETE FROM sessions')
thread.join(timeout=5)
assert responses and '"clear":true'in responses[0]
assert not thread.is_alive()
@@ -0,0 +1,41 @@
import json
from pathlib import Path
import sqlite3
import pytest
from aim_webgui.db.store import Store
from aim_webgui.config import Settings
from aim_webgui.workflows import Workflows
from aim_webgui.errors import WebError
def test_v3_migration_keeps_auth_history_and_duplicate_titles(tmp_path):
state=tmp_path/'state';state.mkdir(mode=0o700)
path=state/'webgui.sqlite3';path.touch(mode=0o600)
db=sqlite3.connect(path)
migrations=Path(__import__('aim_webgui.db.store',fromlist=['x']).__file__).parent/'migrations'
for version in (1,2,3):db.executescript(next(migrations.glob(f'{version:04d}_*.sql')).read_text())
db.execute('PRAGMA user_version=3')
db.execute("INSERT INTO users(username,password_hash,role,enabled,must_change_password,created_at,updated_at) VALUES('old-admin','SYNTHETIC-HASH','admin',1,0,1,1)")
uid=db.execute("SELECT id FROM users").fetchone()[0]
db.execute("INSERT INTO metadata VALUES('initialized','1')")
for name,ident in [('Duplicate','plan-a'),('duplicate','plan-b')]:
db.execute('INSERT INTO plans VALUES(?,?,?,?,?,?,?)',(ident,uid,name,'example','debug_test_connection','{"legacy":true}',1))
for ident,status in [('oldq','queued'),('oldp','pending'),('oldr','running'),('oldf','failed')]:
db.execute('INSERT INTO jobs(id,owner_id,status,plan,created_at,scheduled_at,mode) VALUES(?,?,?,?,?,?,?)',(ident,uid,status,'{"legacy":true}',1,1,'check'))
db.execute('INSERT INTO job_events(job_id,occurred_at,event) VALUES(?,?,?)',(ident,1,'Historical event'))
db.execute("INSERT INTO audit(occurred_at,actor,action,subject) VALUES(1,'old-admin','old-action','old')")
db.commit();db.close()
store=Store(path);store.migrate();store.check()
with store.read()as db:
assert db.execute('PRAGMA user_version').fetchone()[0]==5
assert db.execute('SELECT password_hash FROM users').fetchone()[0]=='SYNTHETIC-HASH'
assert [r[0]for r in db.execute('SELECT name FROM plans ORDER BY id')]==['Duplicate','duplicate']
assert dict(db.execute('SELECT id,status FROM jobs'))=={'oldq':'blocked','oldp':'blocked','oldr':'interrupted','oldf':'failed'}
assert db.execute('SELECT COUNT(*) FROM job_events').fetchone()[0]==4
assert db.execute("SELECT COUNT(*) FROM audit WHERE action='legacy-job-stopped'").fetchone()[0]==3
flow=Workflows(Settings(state_dir=state))
with pytest.raises(WebError)as error:
flow._insert_plan(uid,' DUPLICATE ',{'customer':'example','playbook':'debug_test_connection'})
assert error.value.code=='plan_name_exists'
store.migrate()
with store.read()as db:assert db.execute("SELECT COUNT(*) FROM audit WHERE action='legacy-job-stopped'").fetchone()[0]==3
@@ -0,0 +1,30 @@
"""Additive v4 -> v5 migration, not a running managed DB."""
import json,sqlite3
from pathlib import Path
from aim_webgui.db.store import Store
def test_schema4_history_and_accounts_preserved_old_work_stopped_once(tmp_path):
state=tmp_path/'state';state.mkdir(mode=0o700);path=state/'webgui.sqlite3';path.touch(mode=0o600)
directory=Path(__import__('aim_webgui.db.store',fromlist=['x']).__file__).parent/'migrations'
db=sqlite3.connect(path)
for version in range(1,5):db.executescript(next(directory.glob(f'{version:04d}_*.sql')).read_text())
db.execute('PRAGMA user_version=4')
db.execute("INSERT INTO users(username,password_hash,role,enabled,must_change_password,created_at,updated_at) VALUES('admin','SYNTHETIC-only','admin',1,0,1,1)")
user=db.execute('SELECT id FROM users').fetchone()[0]
db.execute("INSERT INTO metadata VALUES('initialized','1')")
for status in ('successful','failed','blocked','interrupted','queued','pending','running'):
db.execute('INSERT INTO jobs(id,owner_id,status,plan,created_at,scheduled_at,mode,core_result) VALUES(?,?,?,?,?,?,?,?)',
(status,user,status,'{"targets":["fixture.example"]}',1,1,'apply','{"fixture":"keep"}' if status=='successful' else None))
db.commit();db.close()
store=Store(path);store.migrate();store.migrate();store.check()
with store.read()as db:
assert db.execute('PRAGMA user_version').fetchone()[0]==5
assert db.execute('SELECT password_hash FROM users').fetchone()[0]=='SYNTHETIC-only'
assert dict(db.execute('SELECT id,status FROM jobs'))=={k:('blocked' if k in ('queued','pending') else 'interrupted' if k=='running' else k)for k in ('successful','failed','blocked','interrupted','queued','pending','running')}
assert db.execute("SELECT core_result FROM jobs WHERE id='successful'").fetchone()[0]=='{"fixture":"keep"}'
assert db.execute("SELECT COUNT(*) FROM audit WHERE action='core33-job-stopped'").fetchone()[0]==3
assert db.execute('SELECT COUNT(*) FROM job_progress_events').fetchone()[0]==0
assert db.execute('SELECT COUNT(*) FROM job_operation_reports').fetchone()[0]==0
backup=tmp_path/'schema5-backup';store.backup(backup)
with sqlite3.connect(backup)as db:assert db.execute('PRAGMA user_version').fetchone()[0]==5
@@ -0,0 +1,228 @@
"""Read-only projections and mobile shell; no Ansible, keys or remote hosts."""
from dataclasses import replace
from pathlib import Path
import json
import time
import uuid
import pytest
from fastapi.testclient import TestClient
from aim_webgui.activity import Activity, HistoryFilter, host_url
from aim_webgui.explorer import Explorer
from aim_webgui.auth.service import Auth
from aim_webgui.config import Settings
from aim_webgui.app import create_app
from aim_webgui.errors import WebError
from aim_webgui.workflows import Workflows
COUNTS=('ok','changed','failures','unreachable','skipped','rescued','ignored')
@pytest.fixture
def local_auth(tmp_path):
s=Settings(state_dir=tmp_path/'state',public_url='https://aim.example.test').validate()
a=Auth(s);a.bootstrap()
a.create_user('operator','Fixture-only-long-password-2026','viewer')
a.create_user('other','Fixture-only-other-password-2026','viewer')
with a.store.transaction() as db:
db.execute('UPDATE users SET must_change_password=0')
return a
def uid(auth,name):
with auth.store.read() as db:return db.execute('SELECT id FROM users WHERE username=?',(name,)).fetchone()[0]
def add_job(auth,owner='operator',customer='example',book='debug_test_connection',hosts=None,status='successful',mode='apply',when=None,legacy=False):
hosts=hosts or {'test01.example':'successful'}
when=int(time.time())-60 if when is None else when
target_list=[];summ=dict.fromkeys(('successful','failed','unreachable','not_started','indeterminate'),0)
for host,outcome in hosts.items():
counts=dict.fromkeys(COUNTS,0);counts['ok']=4
if outcome=='unreachable':counts['unreachable']=1
if outcome=='failed':counts['failures']=1
if outcome=='successful':counts['changed']=1
summ[outcome]+=1
target_list.append({'host':host,'outcome':outcome,'counts':counts})
ident=uuid.uuid4().hex
result={'status':'failed' if status=='failed' else 'succeeded','targets':target_list,
'target_summary':{'schema':'target_outcome_summary_v1','requested':len(hosts),'accounted':len(hosts),'complete':True,**summ}}
plan={'customer':customer,'playbook':book,'targets':list(hosts),'overrides':{'ignored_fixture_field':'MUST-NOT-LEAVE-PROJECTION'}}
with auth.store.transaction() as db:
db.execute('''INSERT INTO jobs(id,owner_id,plan,mode,status,created_at,scheduled_at,finished_at,core_result)
VALUES(?,?,?,?,?,?,?,?,?)''',(ident,uid(auth,owner),json.dumps(plan),mode,status,when,when,
when if status not in ('queued','running','pending') else None,None if legacy else json.dumps(result)))
return ident
def test_mixed_target_semantics_and_no_task_count_inference(local_auth):
a=local_auth;ident=add_job(a,hosts={'test01.example':'successful','test02.example':'unreachable'},status='failed')
data=Activity(a.settings).report(uid(a,'operator'),HistoryFilter(customer='example',days='all'))
assert data['jobs']==1 and data['samples']==2
assert data['counts']['successful']==1 and data['counts']['unreachable']==1
assert data['success_percent']==50 and data['eligible']==2
assert data['records'][0]['job_display_status']=='partially_succeeded'
assert 'MUST-NOT-LEAVE' not in repr(data)
def test_all_history_not_last_100_and_pagination(local_auth):
a=local_auth
for i in range(135):add_job(a,when=int(time.time())-1000-i)
data=Activity(a.settings).report(uid(a,'operator'),HistoryFilter(host='test01.example',days='all',page=5))
assert data['jobs']==135 and data['samples']==135 and data['pages']==6
assert len(data['records'])==25
def test_visibility_for_totals_filters_and_matrix(local_auth):
a=local_auth
add_job(a);add_job(a,owner='other',customer='secret-customer',book='secret-book',hosts={'secret-host.example':'failed'},status='failed')
data=Activity(a.settings).report(uid(a,'operator'),HistoryFilter(days='all'))
assert data['samples']==1 and 'secret-' not in repr(data)
admin=Activity(a.settings).report(uid(a,'admin'),HistoryFilter(days='all'))
assert admin['samples']==2
def test_mode_range_and_unknown_outcomes(local_auth):
a=local_auth
add_job(a,mode='check');add_job(a,legacy=True);add_job(a,status='queued')
add_job(a,hosts={'test01.example':'not_started'});add_job(a,hosts={'test01.example':'indeterminate'})
add_job(a,when=int(time.time())-86400*100)
data=Activity(a.settings).report(uid(a,'operator'),HistoryFilter(days='30'))
assert data['samples']==4 and data['eligible']==0 and data['success_percent'] is None
assert data['counts']['unavailable']==1 and data['counts']['outstanding']==1
check=Activity(a.settings).report(uid(a,'operator'),HistoryFilter(mode='check',days='all'))
assert check['samples']==1 and check['counts']['successful']==1
def test_customer_identity_deletion_and_bad_facts(local_auth):
a=local_auth
ident=add_job(a);add_job(a,customer='second')
s=Activity(a.settings)
assert s.report(uid(a,'operator'),HistoryFilter(customer='example',days='all'))['samples']==1
Workflows(a.settings).delete_jobs(uid(a,'operator'), [ident])
assert s.report(uid(a,'operator'),HistoryFilter(customer='example',days='all'))['samples']==0
ident=add_job(a)
with a.store.transaction() as db:db.execute('UPDATE jobs SET core_result=? WHERE id=?',(json.dumps({'targets':[],'target_summary':{}}),ident))
assert s.report(uid(a,'operator'),HistoryFilter(customer='example',days='all'))['counts']['unavailable']==1
def test_plan_visibility_is_owner_only(local_auth):
a=local_auth
with a.store.transaction() as db:
for name in ('admin','operator','other'):
db.execute('INSERT INTO plans(id,owner_id,name,customer,playbook,payload,created_at) VALUES(?,?,?,?,?,?,?)',
(uuid.uuid4().hex,uid(a,name),name+' plan','example','debug_test_connection',json.dumps({'targets':['test01.example']}),int(time.time())))
data=Activity(a.settings).report(uid(a,'admin'),HistoryFilter(customer='example',host='test01.example',days='all'))
assert [p['name'] for p in data['plans']]==['admin plan']
class FakeCore:
operations=[]
def __init__(self,*args,**kwargs):self.client=self
def request(self,operation,**kwargs):
self.operations.append(operation)
assert operation=='list_hosts'
return [{'name':h,'address':'192.0.2.'+str(i+1),'platforms':['linux']} for i,h in enumerate(('direct.example','shared.example','leaf.example'))]
def inventory_hierarchy(self,customer):
self.operations.append('inventory_hierarchy')
def node(name,path,hosts,children=None):return dict(name=name,path=path,hosts=hosts,children=children or [])
return {'schema':'inventory_hierarchy_v1','customer':customer,'hosts':['direct.example'],'groups':[
node('linux',['linux'],['shared.example'],[node('servers',['linux','servers'],['shared.example','leaf.example'])]),
node('lab',['lab'],[],[node('servers',['lab','servers'],['shared.example'])]),node('empty',['empty'],[])]}
def test_explorer_distinct_counts_paths_root_members_and_search(local_auth,monkeypatch):
monkeypatch.setattr('aim_webgui.explorer.CoreAdapter',FakeCore)
e=Explorer(local_auth.settings)
p=e.page('example')
assert p['snap']['host_count']==3 and len(p['direct_hosts'])==1
assert len(p['snap']['groups'][('linux',)]['members'])==2
assert len(p['snap']['hosts']['shared.example']['memberships'])==3
p=e.page('example',branch='["lab","servers"]')
assert p['selected']['path']==['lab','servers'] and len(p['direct_hosts'])==1
assert 'activity?host=' in p['direct_hosts'][0]['url']
assert e.page('example',q='shared')['search_count']==1
with pytest.raises(WebError):e.page('example',branch='["missing"]')
def test_explorer_map_is_bounded(local_auth,monkeypatch):
class Large(FakeCore):
def request(self,op,**kwargs):return []
def inventory_hierarchy(self,customer):return {'schema':'inventory_hierarchy_v1','customer':customer,'hosts':[],
'groups':[{'name':str(n),'path':[str(n)],'hosts':[],'children':[]}for n in range(50)]}
monkeypatch.setattr('aim_webgui.explorer.CoreAdapter',Large)
page=Explorer(local_auth.settings).page('example')
assert len(page['groups'])==12 and page['group_pages']==5 and len(page['graph_nodes'])<=49
assert page['groups_next']
def test_new_get_views_are_read_only_and_survive_core_outage(local_auth,monkeypatch):
a=local_auth;add_job(a);FakeCore.operations=[]
monkeypatch.setattr('aim_webgui.explorer.CoreAdapter',FakeCore)
token,_=a.new_session(uid(a,'operator'))
with TestClient(create_app(a.settings),base_url=a.settings.public_url) as c:
c.cookies.set(a.settings.cookie_name,token)
for path in ('/inventory/example/explore','/inventory/example/explore?view=map',
'/inventory/example/explore?q=shared',host_url('example','test01.example'),
'/insights','/api/v2/insights','/api/v2/activity?customer=example&host=test01.example',
'/api/v2/inventory/example/explore'):
r=c.get(path); assert r.status_code==200,(path,r.text[:2000])
assert r.headers['cache-control']=='no-store'
assert set(FakeCore.operations)=={'inventory_hierarchy','list_hosts'}
before=Activity(a.settings).report(uid(a,'operator'),HistoryFilter(days='all'))['jobs']
assert before==1
def down(*a,**k):raise WebError('core_offline','Do not echo sensitive path',503)
monkeypatch.setattr(FakeCore,'inventory_hierarchy',down)
r=c.get(host_url('example','test01.example'))
assert r.status_code==200 and 'Current inventory is unavailable' in r.text
assert 'Do not echo sensitive path' not in r.text
assert c.get('/inventory/example/explore').status_code==503
def test_filters_bad_input_and_anonymous_access(local_auth):
a=local_auth
for f in (HistoryFilter(mode='invalid'),HistoryFilter(page=0),HistoryFilter(days='-1'),HistoryFilter(outcome='fake'),HistoryFilter(q='x'*256)):
with pytest.raises(WebError):Activity(a.settings).report(uid(a,'operator'),f)
with TestClient(create_app(a.settings),base_url=a.settings.public_url,follow_redirects=False) as c:
assert c.get('/api/v2/insights').status_code==401
assert c.get('/insights').status_code==303
def test_menu_markup_and_escaped_history(local_auth,monkeypatch):
a=local_auth;add_job(a,book='<script>alert(1)</script>')
monkeypatch.setattr('aim_webgui.explorer.CoreAdapter',FakeCore)
token,_=a.new_session(uid(a,'operator'))
with TestClient(create_app(a.settings),base_url=a.settings.public_url) as c:
c.cookies.set(a.settings.cookie_name,token)
r=c.get('/insights?days=all')
assert '<script>alert(1)</script>' not in r.text
assert '&lt;script&gt;' in r.text
assert 'data-mobile-menu' in r.text and 'aria-controls="mobile-navigation"' in r.text
assert 'data-nav-forward' not in r.text and 'Swipe or use arrows' not in r.text
assert 'No terminal history collection' in r.text
def test_public_core_explorer_with_execution_disabled(settings):
# Real public Core discovery only; no Ansible or remote execution.
s=replace(settings,execution_enabled=False,credentials_enabled=False)
page=Explorer(s).page('example')
assert page['snap']['host_count']==2
assert ('linux','lab') in page['snap']['groups']
assert page['snap']['hosts']['test01.example']['memberships'][0]['label']=='linux / lab'
assert 'activity?host=test01.example' in page['snap']['hosts']['test01.example']['url']
def test_real_core_read_pages_with_empty_history(settings):
s=replace(settings,execution_enabled=False,credentials_enabled=False)
a=Auth(s);a.bootstrap()
with a.store.transaction() as db:db.execute('UPDATE users SET must_change_password=0')
token,_=a.new_session(uid(a,'admin'))
with TestClient(create_app(s),base_url=s.public_url) as c:
c.cookies.set(s.cookie_name,token)
for path in ('/inventory/example/explore',host_url('example','test01.example'),'/insights'):
r=c.get(path)
assert r.status_code==200,(path,r.text[:200])
r=c.get(host_url('example','old-host.example'))
assert 'Not in current inventory.' in r.text
assert 'No retained runs match' in r.text
with a.store.read() as db:
assert db.execute('SELECT COUNT(*) FROM jobs').fetchone()[0]==0
assert db.execute('SELECT COUNT(*) FROM run_reviews').fetchone()[0]==0
@@ -0,0 +1,88 @@
"""Public-result framing with synthetic JSONL commands; no Ansible or remote calls."""
from dataclasses import replace
import json,os,pwd,socket,struct,sys,threading,time
from pathlib import Path
import pytest
from aim_webgui.config import Settings
from aim_webgui.core.client import CoreClient
from aim_webgui.core.executor import Executor
from aim_webgui.core.jsonio import loads
from aim_webgui.core.reports import encoded
from aim_webgui.credentials import wire
from aim_webgui.errors import WebError
from evidence_fixtures import result,plan
def large_fixture(tmp_path, *, full_budget=False):
decl={'protocol':'aim_output_v1','schema':'synthetic_rows_v1','scope':'per_host','required':True,'sensitivity':'safe',
'max_bytes_per_host':1048576,'data_schema':{'type':'object','properties':{'rows':{'type':'array','maxItems':20000,
'items':{'type':'string','maxLength':8192}}},'required':['rows'],'additionalProperties':False}}
hosts=['test'+str(i)+'.example' for i in range(16 if full_budget else 4)]
rows=['\u2603'*2600]*128 if full_budget else ['synthetic-\u2603'*90]*500
final=result(decl,hosts,report_data={'rows':rows})
assert 1024*1024<len(encoded(final))<16*1024*1024
script=tmp_path/'wire_fixture.py';payload=tmp_path/'fixture-result.json';payload.write_bytes(encoded(final))
script.write_text('''import os,sys,json
request=json.load(sys.stdin)
fd=int(sys.argv[sys.argv.index('--credentials-fd')+1])
with os.fdopen(fd) as stream: credentials=json.load(stream)
assert credentials['vault_password']=='SYNTHETIC-PRIVATE-ONLY'
assert 'credentials' not in request
r=json.load(open('''+repr(str(payload))+'''))
e={'event_version':'1.0','run_id':'fixture-run','sequence':1,'timestamp':'2026-09-20T10:00:00Z','kind':'result','status':'succeeded','result':r}
for value in [{'type':'event','event':e},{'type':'response','api_version':'1.0','ok':True,'result':r}]:
raw=(json.dumps(value,ensure_ascii=False)+'\\n').encode()
for i in range(0,len(raw),16381):os.write(1,raw[i:i+16381])
''')
if full_budget:script.write_text(script.read_text().replace('ensure_ascii=False','ensure_ascii=True'))
settings=Settings(core_transport='stdio',core_command=(sys.executable,str(script)),core_config=tmp_path/'synthetic.yml')
return settings,decl,hosts,final
def test_large_result_stdio_and_duplicate_event_is_not_report_sample(tmp_path):
settings,decl,hosts,expected=large_fixture(tmp_path);events=[]
r=CoreClient(settings).request('execute',request=plan(decl,hosts)['core_request'],expected_revision='a'*64,
credentials={'vault_password':'SYNTHETIC-PRIVATE-ONLY'},result_contract=decl,event_sink=events.append)
assert r['operation_result']==expected['operation_result']
assert len(events)==1 and 'operation_result'not in events[0] and 'result'not in events[0]
def test_large_result_through_executor_socket_and_unchanged_secret_limits(tmp_path):
settings,decl,hosts,expected=large_fixture(tmp_path)
path=tmp_path/'core.sock'
who=pwd.getpwuid(os.geteuid()).pw_name
settings=replace(settings,core_transport='unix',core_socket=path,core_executor_user=who,core_client_user=who)
server=socket.socket(socket.AF_UNIX);server.bind(str(path));path.chmod(0o660);server.listen(1)
executor=Executor(settings)
def run():
conn,_=server.accept();executor.handle(conn)
thread=threading.Thread(target=run);thread.start()
try:
r=CoreClient(settings).request('execute',request=plan(decl,hosts)['core_request'],expected_revision='a'*64,
result_contract=decl,credentials={'vault_password':'SYNTHETIC-PRIVATE-ONLY'})
assert r['operation_result']==expected['operation_result']
finally:thread.join(timeout=10);server.close()
assert not thread.is_alive()
assert wire.SECRET_LIMIT==8192 and wire.MAX_MESSAGE==1048576
@pytest.mark.parametrize('raw',[b'{"a":1,"a":2}',b'{"a":NaN}',b'['*49+b'0'+b']'*49,b'{"a":"\xff"}',b'{"a":'])
def test_strict_decoder_rejects_ambiguous_or_torn_json(raw):
with pytest.raises((ValueError,UnicodeError)):loads(raw)
def test_frame_bounds_before_allocation_and_torn_frames():
for body in (struct.pack('!I',33*1024*1024),struct.pack('!I',30)+b'{}'):
a,b=socket.socketpair()
try:
a.sendall(body);a.shutdown(socket.SHUT_WR)
with pytest.raises(ValueError):wire.receive(b,32*1024*1024,decoder=loads)
finally:a.close();b.close()
def test_near_run_limit_with_escaped_unicode_and_repeated_final(tmp_path):
settings,decl,hosts,expected=large_fixture(tmp_path,full_budget=True)
assert 15*1024*1024<len(encoded(expected))<16*1024*1024
r=CoreClient(settings).request('execute',request=plan(decl,hosts)['core_request'],expected_revision='a'*64,
result_contract=decl,credentials={'vault_password':'SYNTHETIC-PRIVATE-ONLY'})
assert r['operation_result']==expected['operation_result']
@@ -0,0 +1,130 @@
from copy import deepcopy
from dataclasses import replace
import json,os,time
from pathlib import Path
import pytest
from fastapi.testclient import TestClient
from aim_webgui.app import create_app
from aim_webgui.auth.service import Auth
from aim_webgui.config import Settings
from aim_webgui.core.reports import contract,data,operation_result,encoded,RUN_BYTES
from aim_webgui.core.protocol import response_result
from aim_webgui.reports import persist,Reports,presentation,TITLES
from aim_webgui.workflows import Workflows,presentation_status
from aim_webgui.errors import WebError
from evidence_fixtures import declared,sample,plan,result,job
@pytest.fixture
def local(tmp_path):
s=Settings(state_dir=tmp_path/'state',public_url='https://aim.example.test')
a=Auth(s);a.bootstrap()
with a.store.transaction()as db:db.execute('UPDATE users SET must_change_password=0');uid=db.execute('SELECT id FROM users').fetchone()[0]
return s,a,uid
@pytest.mark.parametrize('schema',sorted(TITLES))
def test_all_nine_real_schema_contracts_and_retained_views(local,schema):
s,a,u=local;decl=declared(schema);p=plan(decl);r=result(decl);ident=job(a,u,p,status='successful')
assert contract(decl)['schema']==schema
projected=response_result({'type':'response','api_version':'1.0','ok':True,'result':r},'execute',declaration=decl,request=p['core_request'])
with a.store.transaction()as db:
small=persist(db,s,ident,p,projected)
db.execute('UPDATE jobs SET core_result=? WHERE id=?',(json.dumps(small),ident))
index=Reports(s).index(u,ident);item=Reports(s).slot(u,ident)
assert index['recorded'] and item['status']=='available'
if schema=='checkmk_user_config_v1':assert item['retention']=='metadata_only' and 'sections'not in item['data']
else:assert item['data']==r['operation_result']['hosts']['test01.example']['data']
assert 'data'not in small['operation_result']['hosts']['test01.example']
view=presentation(item);assert isinstance(view['facts'],list)
token,session=a.new_session(u)
with TestClient(create_app(s),base_url=s.public_url)as c:
c.cookies.set(s.cookie_name,token)
for path in (f'/jobs/{ident}',f'/jobs/{ident}/reports',f'/api/v2/runs/{ident}/reports',f'/api/v2/runs/{ident}/report'):
response=c.get(path);assert response.status_code==200,response.text
@pytest.mark.parametrize('state',['missing','withheld','invalid','not_started','indeterminate'])
def test_unavailable_is_not_empty_data(local,state):
s,a,u=local;decl=declared();p=plan(decl);r=result(decl,availability=state,status='failed')
clean=operation_result(r['operation_result'],decl,targets=p['targets'],check=False)
assert clean['hosts']['test01.example']['data']is None
ident=job(a,u,p,status='failed')
with a.store.transaction()as db:persist(db,s,ident,p,r)
item=Reports(s).slot(u,ident);assert item['status']==state and item['data']is None
@pytest.mark.parametrize('mutation',[
lambda r:r.update(schema='wrong_v1'),lambda r:r.update(scope='global'),lambda r:r.update(check_mode=True),
lambda r:r['hosts'].update({'other.example':r['hosts']['test01.example']}),
lambda r:r['hosts']['test01.example']['data'].update(is_dc='false'),
lambda r:r['hosts']['test01.example']['data'].update(unrecognized='secret'),
lambda r:r['hosts']['test01.example'].update(status='withheld'),
lambda r:r['hosts']['test01.example'].update(error={'message':'RAW'})])
def test_mismatched_or_invalid_report_rejected(mutation):
decl=declared();value=result(decl)['operation_result'];mutation(value)
with pytest.raises(WebError):operation_result(value,decl,targets=['test01.example'],check=False)
def test_required_report_failure_preserves_native_success(local):
s,a,u=local;decl=declared();r=result(decl,availability='missing',status='failed');p=plan(decl)
out=response_result({'type':'response','api_version':'1.0','ok':False,'result':r},'execute',declaration=decl,request=p['core_request'])
assert out['stage']=='result_validation' and out['exit_code']==0
assert out['targets'][0]['outcome']=='successful' and presentation_status('failed',out)=='failed'
assert out['operation_result']['hosts']['test01.example']['status']=='missing'
def test_global_and_unknown_supported_schema():
decl=declared(scope='global');decl['schema']='future_capabilities_v1'
r=result(decl)['operation_result'];out=operation_result(r,decl,targets=['test01.example'],check=False)
assert out['hosts']=={} and out['global']['status']=='available'
@pytest.mark.parametrize('badshape',[{'$ref':'https://evil.invalid/schema'}, {'type':'string'}, {'type':'array','items':{'type':'boolean'},'maxItems':50000}])
def test_unsupported_schema_language_rejected(badshape):
decl=declared();decl['data_schema']=badshape
with pytest.raises(WebError):contract(decl)
def test_null_false_zero_and_redacted_metadata(local):
s,a,u=local;decl=declared('checkmk_user_config_v1');content=sample(decl['data_schema'])
content['sections']={'plugins':{'enabled':False,'count':0,'missing':None,'password':'[REDACTED]','normal':'fixture-config'}}
content['redacted_paths']=['sections.plugins.password'];p=plan(decl)
for full in (False,True):
cfg=replace(s,reports_retain_configuration=full);ident=job(a,u,p,status='successful')
with a.store.transaction()as db:persist(db,cfg,ident,p,result(decl,report_data=content))
item=Reports(cfg).slot(u,ident)
if full:assert item['data']==content
else:assert 'sections'not in item['data'] and item['data']['redacted_paths']==content['redacted_paths']
def test_secret_canary_and_nonfinite_rejected():
decl=declared('checkmk_user_config_v1');content=sample(decl['data_schema']);content['sections']={'value':'FIXTURE-SECRET'}
with pytest.raises(WebError):data(content,decl['data_schema'],secrets=['FIXTURE-SECRET'])
content['sections']={'password':'PLAIN'}
with pytest.raises(WebError):data(content,decl['data_schema'])
content['sections']={'value':float('nan')}
with pytest.raises(WebError):data(content,decl['data_schema'])
def test_deletion_and_owner_admin_scope(local):
s,a,admin=local;a.create_user('viewer','Synthetic-fixture-password-2026','viewer')
with a.store.transaction()as db:db.execute('UPDATE users SET must_change_password=0');viewer=db.execute("SELECT id FROM users WHERE username='viewer'").fetchone()[0]
decl=declared();p=plan(decl);ident=job(a,admin,p,status='failed')
with a.store.transaction()as db:persist(db,s,ident,p,result(decl))
with pytest.raises(WebError):Reports(s).index(viewer,ident)
assert Reports(s).host_links(viewer,'example','test01.example')==[]
assert len(Reports(s).host_links(admin,'example','test01.example'))==1
Workflows(s).delete_jobs(admin,[ident])
with a.store.read()as db:
assert db.execute('SELECT count(*) FROM job_operation_results').fetchone()[0]==0
assert db.execute('SELECT count(*) FROM job_operation_reports').fetchone()[0]==0
assert db.execute("SELECT count(*) FROM audit WHERE action='job-deleted'").fetchone()[0]==1
def test_json_escaped_html_and_lazy_report_content(local):
s,a,u=local;decl=declared('event_log_export_v1');content=sample(decl['data_schema']);content['files']=['<script>alert(1)</script>','javascript:alert(1)'];ident=job(a,u,plan(decl),status='successful')
with a.store.transaction()as db:
small=persist(db,s,ident,plan(decl),result(decl,report_data=content));db.execute('UPDATE jobs SET core_result=? WHERE id=?',(json.dumps(small),ident))
token,_=a.new_session(u)
with TestClient(create_app(s),base_url=s.public_url)as c:
c.cookies.set(s.cookie_name,token)
r=c.get('/jobs/'+ident+'/reports')
assert '<script>alert(1)</script>'not in r.text and '&lt;script&gt;'in r.text
assert 'href="javascript:'not in r.text
assert 'alert(1)'not in c.get('/api/v2/runs/'+ident).text
def test_smaller_local_budget_does_not_truncate(local):
s,a,u=local;decl=declared('event_log_export_v1');data=sample(decl['data_schema']);data['files']=['x'*1000]*90
ident=job(a,u,plan(decl),status='successful');s=replace(s,reports_max_bytes=65536)
with a.store.transaction()as db:persist(db,s,ident,plan(decl),result(decl,report_data=data))
item=Reports(s).slot(u,ident);assert item['status']=='available' and item['retention']=='not_retained_limit' and item['data']is None
@@ -0,0 +1,91 @@
"""HTTP/HTMX flows against the real core metadata/prepare protocol, no execution."""
import json
import re
import pytest
from fastapi.testclient import TestClient
from aim_webgui.app import create_app
from aim_webgui.workflows import Workflows
@pytest.fixture
def browser(auth,admin):
token,session=auth.new_session(admin)
with TestClient(create_app(auth.settings),base_url=auth.settings.public_url,follow_redirects=False) as client:
client.cookies.set(auth.settings.cookie_name,token)
client.headers.update({'Origin':auth.settings.public_url,'X-CSRF-Token':session['csrf']})
yield client
def test_html_new_run_review_queue_and_save_optional(browser,auth,admin):
response=browser.get('/plan?customer=example&playbook=debug_test_connection')
assert response.status_code==200, response.text
assert 'Load customer SSH key' in response.text
response=browser.post('/_partials/preflight',data={'customer':'example','playbook':'debug_test_connection',
'targets':'test01.example','mode':'check','key_mode':'none'},headers={'HX-Request':'true'})
assert response.status_code==200,response.text
assert 'Run once' in response.text and 'Save as a reusable plan (optional)' in response.text
review=re.search(r'name="review_id" value="([a-f0-9]+)"',response.text).group(1)
assert 'name="name"' in response.text and not re.search('name="name"[^>]+required',response.text)
response=browser.post('/jobs',data={'review_id':review,'submission_key':review,'confirm':'reviewed'})
assert response.status_code==303,response.text
path=response.headers['location']
response=browser.get(path)
assert response.status_code==200,response.text
assert 'queued' in response.text
assert Workflows(auth.settings).plans(admin)==[]
saved=browser.post('/plans',data={'review_id':review,'name':''})
assert saved.status_code==303,saved.text
assert len(Workflows(auth.settings).plans(admin))==1
title=Workflows(auth.settings).plans(admin)[0]['name']
conflict=browser.post('/plans',data={'review_id':review,'name':title.upper()})
assert conflict.status_code==409
assert 'nothing was overwritten' in conflict.text
assert len(Workflows(auth.settings).plans(admin))==1
def test_json_one_run_idempotency_and_api_version(browser,auth,admin):
r=browser.post('/api/v2/preflight',json={'customer':'example','playbook':'debug_test_connection','targets':['test01.example'],'overrides':{}})
assert r.status_code==200,r.text
data={'review_id':r.json()['review_id'],'confirm':True}
r=browser.post('/api/v2/runs',json=data,headers={'Idempotency-Key':'test-run-once'})
assert r.status_code==202,r.text
ident=r.json()['id']
repeat=browser.post('/api/v2/runs',json=data,headers={'Idempotency-Key':'test-run-once'})
assert repeat.json()['id']==ident
assert browser.get('/api/v2/runs/'+ident).json()['plan']['core_request']['check'] is True
assert browser.get('/api/v1/runs/'+ident).status_code==404
def test_security_and_private_review(browser,auth,admin):
response=browser.post('/api/v2/preflight',headers={'Origin':'https://evil.invalid'},json={})
assert response.status_code==403
browser.headers.pop('X-CSRF-Token')
assert browser.post('/jobs',data={}).status_code==403
assert browser.get('/users').status_code==200
auth.create_user('viewer','Long-fixture-password-2026','viewer')
with auth.store.transaction()as db:
db.execute("UPDATE users SET must_change_password=0 WHERE username='viewer'")
user=db.execute("SELECT id FROM users WHERE username='viewer'").fetchone()[0]
token,session=auth.new_session(user)
browser.cookies.set(auth.settings.cookie_name,token)
browser.headers['X-CSRF-Token']=session['csrf']
assert browser.get('/users').status_code==403
assert browser.post('/api/v2/runs',json={'review_id':'not-mine','confirm':True},headers={'Idempotency-Key':'x'}).status_code in(403,409)
def test_core_outage_keeps_local_login_recovery(auth,admin,monkeypatch):
from aim_webgui.adapters.core_v1 import CoreAdapter
from aim_webgui.errors import WebError
def down(*args,**kwargs):raise WebError('executor_unavailable','Offline',503)
monkeypatch.setattr(CoreAdapter,'customers',down)
with TestClient(create_app(auth.settings),base_url=auth.settings.public_url)as client:
assert client.get('/login').status_code==200
assert client.get('/healthz').status_code==200
assert client.get('/readyz').status_code==503
def test_retained_overviews_and_admin_pages(browser):
for path in ('/','/customers','/customers/example/hosts','/playbooks','/jobs','/plans','/users','/setup','/audit','/system','/password'):
response=browser.get(path)
assert response.status_code==200,(path,response.status_code,response.text[:500])
response=browser.get('/users')
assert response.text.count('Scoped execution grants')==1
@@ -0,0 +1,84 @@
"""Protocol robustness tests against a synthetic command, never a real remote host."""
from dataclasses import replace
import json
import os
from pathlib import Path
import sys
import threading
import time
import pytest
from aim_webgui.core.client import CoreClient
from aim_webgui.config import Settings
from aim_webgui.core.protocol import validate_secrets, response_result, COUNTS
from aim_webgui.errors import WebError
@pytest.fixture
def transport(tmp_path):
script=tmp_path/'fake.py'
settings=Settings(core_transport='stdio',core_command=(sys.executable,str(script)),core_config=tmp_path/'core.yml')
return script,settings
def script_write(script,body):
script.write_text('import sys,json,os,time,stat\n'+body)
def test_credentials_go_only_to_nonstandard_pipe_fd(transport):
script,settings=transport
script_write(script,"""
line=sys.stdin.buffer.readline(); request=json.loads(line)
assert 'credentials' not in request
fd=int(sys.argv[sys.argv.index('--credentials-fd')+1]);assert fd>=3 and stat.S_ISFIFO(os.fstat(fd).st_mode)
with os.fdopen(fd,'rb')as stream:secret=json.load(stream)
assert secret['vault_password']=='fixture+%!{{data}}'
assert secret['vault_password'].encode() not in line
assert all(secret['vault_password']not in x for x in os.environ.values())
assert all(secret['vault_password']not in x for x in sys.argv)
print(json.dumps({'type':'response','api_version':'1.0','ok':False,'result':{
'status':'failed','stage':'credentials','exit_code':None,'remote_work_may_have_started':False,
'error':{'code':'vault_unlock_failed'},'counts':{},
'target_summary':{'schema':'target_outcome_summary_v1','requested':1,'successful':0,'failed':0,'unreachable':0,'not_started':1,'indeterminate':0,'complete':True,'accounted':1},
'targets':[{'host':'h','outcome':'not_started','counts':{'ok':0,'changed':0,'failures':0,'unreachable':0,'skipped':0,'rescued':0,'ignored':0}}]}}),flush=True)
""")
request={'customer':'a','playbook':'p','hosts':['h']}
result=CoreClient(settings).request('execute',request=request,expected_revision='a'*64,credentials={'vault_password':'fixture+%!{{data}}'})
assert result['status']=='failed' and result['error']['code']=='vault_unlock_failed'
@pytest.mark.parametrize('body,code',[
("print('not-json',flush=True)",'core_protocol'),
("print(json.dumps({'type':'event','event':{'event_version':'1.0','kind':'stage','sequence':1,'stage':'execution'}}),flush=True)",'core_outcome_unknown'),
("print(json.dumps({'type':'response','api_version':'9','ok':True,'result':{}}),flush=True)",'core_protocol'),
("print('X'*1048577,flush=True)",'core_protocol'),
])
def test_invalid_wire_is_not_success(transport,body,code):
script,settings=transport;script_write(script,body)
with pytest.raises(WebError)as e:CoreClient(settings).request('capabilities')
assert e.value.code==code
def test_cancellation_stops_child(transport):
script,settings=transport;script_write(script,'time.sleep(60)')
cancel=threading.Event();timer=threading.Timer(.25,cancel.set);timer.start()
try:
with pytest.raises(WebError)as e:CoreClient(settings).request('capabilities',cancel=cancel)
assert e.value.code=='core_cancelled'
finally:timer.cancel()
def test_start_deadline_cancels_preparation(transport):
script,settings=transport;script_write(script,'time.sleep(60)')
with pytest.raises(WebError)as e:
CoreClient(settings).request('execute',request={'customer':'a','playbook':'p','hosts':['h']},
expected_revision='a'*64,deadline=time.monotonic()+.3)
assert e.value.code=='credential_expired'
@pytest.mark.parametrize('secret',[{'vault_password':'x\n'},{'username':'admin'},{'vault_password':'\ud800'},{'become_password':'x'}])
def test_invalid_secrets_rejected(secret):
with pytest.raises(WebError):validate_secrets(secret)
def test_success_needs_valid_complete_counters():
result={'type':'response','api_version':'1.0','ok':True,'result':{'status':'succeeded','exit_code':0,'remote_work_may_have_started':True,'counts':dict.fromkeys(COUNTS,0),'target_summary':{'schema':'target_outcome_summary_v1','requested':0,'successful':0,'failed':0,'unreachable':0,'not_started':0,'indeterminate':0,'complete':True,'accounted':0},'targets':[]}}
assert response_result(result,'execute')['status']=='succeeded'
result['result']['counts']['ok']=-1
with pytest.raises(WebError):response_result(result,'execute')
@@ -0,0 +1,27 @@
from dataclasses import replace
from pathlib import Path
import json
import time
from aim_webgui.auth.service import Auth
from aim_webgui.workflows import Workflows
from aim_webgui.worker import execute_claimed
from test_core_execution import native_fixture
def test_worker(settings,core_root):
native_fixture(core_root)
auth=Auth(settings);auth.bootstrap()
with auth.store.transaction()as db:
db.execute("UPDATE users SET must_change_password=0 WHERE username='admin'")
uid=db.execute("SELECT id FROM users WHERE username='admin'").fetchone()[0]
flow=Workflows(settings)
review=flow.review(uid,'example','debug_test_connection',['test01.example'],{})
ident=flow.queue_review(uid,review['review_id'],idempotency_key='worker-proof')
with flow.store.transaction()as db:
db.execute("UPDATE jobs SET status='running' WHERE id=?",(ident,))
execute_claimed(settings,ident)
result=flow.job(uid,ident)['core_result']
assert result['status']=='succeeded' and result['counts']['ok']==1
assert result['remote_work_may_have_started'] is True
assert not list(settings.state_dir.glob('.console-*'))
assert not flow.plans(uid)
@@ -0,0 +1,104 @@
import json
import concurrent.futures
import sqlite3
import time
from dataclasses import replace
import pytest
from aim_webgui.workflows import Workflows, presentation_status
from aim_webgui.errors import WebError
def reviewed(flow,admin,**kw):
return flow.review(admin,'example','debug_test_connection',['test01.example'],{},**kw)
def test_partial_success_presentation_uses_core_target_summary():
core={'target_summary':{'schema':'target_outcome_summary_v1','requested':25,'successful':24,'failed':0,'unreachable':1,'not_started':0,'indeterminate':0,'complete':True,'accounted':25}}
assert presentation_status('failed',core)=='partially_succeeded'
assert presentation_status('successful',core)=='successful'
core['target_summary']['successful']=0;core['target_summary']['failed']=24
assert presentation_status('failed',core)=='failed'
def test_one_run_without_saving(settings,admin):
f=Workflows(settings);r=reviewed(f,admin)
ident=f.queue_review(admin,r['review_id'],idempotency_key='OneRun1')
assert f.job(admin,ident)['status']=='queued'
assert f.plans(admin)==[]
assert f.queue_review(admin,r['review_id'],idempotency_key='OneRun1')==ident
with pytest.raises(WebError):f.queue_review(admin,r['review_id'],scheduled_at=int(time.time())+60,idempotency_key='OneRun1')
def test_optional_plan_names_and_collision(settings,admin):
f=Workflows(settings);r=reviewed(f,admin)
a=f.save_review(admin,r['review_id'],'');b=f.save_review(admin,r['review_id'],'')
assert a!=b and f.plan(admin,a)['name']!=f.plan(admin,b)['name']
p=f.save_review(admin,r['review_id'],'My Plan')
with pytest.raises(WebError) as e:f.save_review(admin,r['review_id'],' MY PLAN ')
assert e.value.code=='plan_name_exists'
assert f.plan(admin,p)['name']=='My Plan'
assert len(f.plans(admin))==3
def test_unicode_name_equivalence(settings,admin):
f=Workflows(settings);r=reviewed(f,admin)
f.save_review(admin,r['review_id'],'Plan A')
with pytest.raises(WebError):f.save_review(admin,r['review_id'],'Plan \uff21')
def test_concurrent_saves_do_not_overwrite(settings,admin):
f=Workflows(settings);r=reviewed(f,admin)
def save():
try:return f.save_review(admin,r['review_id'],'concurrent')
except WebError as e:return e.code
with concurrent.futures.ThreadPoolExecutor(max_workers=2) as ex:results=list(ex.map(lambda _:save(),range(2)))
assert results.count('plan_name_exists')==1
assert len(f.plans(admin))==1
def test_stale_review_rejected(settings,admin,core_root):
f=Workflows(settings);r=reviewed(f,admin)
with (core_root/'inventories/example/hosts.yml').open('a') as file:file.write('# external edit\n')
with pytest.raises(WebError) as e:f.queue_review(admin,r['review_id'],idempotency_key='fresh1')
assert e.value.code=='plan_stale'
def test_mode_and_authentication_bound_to_revision(settings,admin):
f=Workflows(settings);r=reviewed(f,admin,check=False)
ident=f.queue_review(admin,r['review_id'],idempotency_key='mode1')
job=f.job(admin,ident)
assert job['mode']=='apply' and job['plan']['core_request']['check'] is False
assert job['plan']['authentication']['mode']=='inventory'
def test_pending_approval_when_enabled(settings,admin):
f=Workflows(replace(settings,execution_require_approval=True));r=reviewed(f,admin)
ident=f.queue_review(admin,r['review_id'],idempotency_key='approval1')
assert f.job(admin,ident)['status']=='pending'
with pytest.raises(WebError):f.job_action(admin,ident,'approve')
def test_core_opt_in_separate(settings,admin,core_root):
config=core_root/'scripts/aim.yml';config.write_text(config.read_text().replace('execution_enabled: true','execution_enabled: false'))
f=Workflows(settings);r=reviewed(f,admin)
with pytest.raises(WebError) as e:f.queue_review(admin,r['review_id'],idempotency_key='disabled1')
assert e.value.code=='core_execution_disabled'
def test_retry_creates_fresh_job_and_bulk_delete_preserves_audit(settings,admin):
f=Workflows(settings);r=reviewed(f,admin)
one=f.queue_review(admin,r['review_id'],idempotency_key='retry1')
with f.store.transaction() as db:db.execute("UPDATE jobs SET status='failed' WHERE id=?",(one,))
two=f.retry_job(admin,one)
assert two!=one and f.job(admin,two)['status']=='queued'
with pytest.raises(WebError):f.delete_jobs(admin,[one,two])
f.delete_jobs(admin,[one])
with f.store.read() as db:assert db.execute("SELECT 1 FROM audit WHERE action='job-deleted'").fetchone()
def test_legacy_plan_requires_fresh_review(settings,admin):
f=Workflows(settings)
with f.store.transaction() as db:
db.execute('INSERT INTO plans(id,owner_id,name,customer,playbook,payload,created_at) VALUES(?,?,?,?,?,?,?)',('a'*32,admin,'legacy','example','debug_test_connection',json.dumps({'customer':'example','playbook':'debug_test_connection','targets':['test01.example'],'overrides':{}}),0))
assert f.plan(admin,'a'*32)['name']=='legacy'
assert 'core_request' not in f.plan(admin,'a'*32)['payload']