aim-web2.1.0rc9
This commit is contained in:
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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())
|
||||
@@ -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 '<script>' 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 '<script>' 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 '<script>'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']
|
||||
Reference in New Issue
Block a user