Add SQLite web users, privileged account CLI and session editing claims

This commit is contained in:
Nico committed 2026-10-22 18:56:59 +02:00
1 parent 9fc5c1805c
commit 203e765992
18 files changed
+629 -60

No files matched your search

+9 -3
View File
@@ -24,15 +24,18 @@ try {
let next=0;const pending=new Map();
ws.onmessage=e=>{const m=JSON.parse(e.data);if(m.id){const p=pending.get(m.id);pending.delete(m.id);if(!p)return;m.error?p.reject(new Error(m.error.message)):p.resolve(m.result);}
if(m.method==='Runtime.exceptionThrown')errors.push(m.params.exceptionDetails.text);
if(m.method==='Log.entryAdded'&&m.params.entry.level==='error')errors.push(m.params.entry.text);};
if(m.method==='Log.entryAdded'&&m.params.entry.level==='error'&&!(m.params.entry.url?.endsWith('/v1/auth/session')&&m.params.entry.text.includes('401')))errors.push(m.params.entry.text);};
const send=(method,params={})=>new Promise((resolve,reject)=>{const id=++next;pending.set(id,{resolve,reject});ws.send(JSON.stringify({id,method,params}));});
const evaluate=async expression=>{const r=await send('Runtime.evaluate',{expression,returnByValue:true,awaitPromise:true});if(r.exceptionDetails)throw new Error(r.exceptionDetails.text);return r.result.value;};
await send('Runtime.enable');await send('Log.enable');await send('Page.enable');
await send('Emulation.setDeviceMetricsOverride',{width:1440,height:1200,deviceScaleFactor:1,mobile:false});
await send('Page.navigate',{url:`http://127.0.0.1:${port}/`});
await until(()=>evaluate("document.readyState === 'complete' && typeof render === 'function'"));
await evaluate("document.getElementById('token').value='ui_fixture_"+'x'.repeat(40)+"';document.getElementById('login-form').dispatchEvent(new Event('submit',{bubbles:true,cancelable:true}));");
await evaluate("document.getElementById('username').value='preview';document.getElementById('password').value='ui-fixture-password';document.getElementById('login-form').dispatchEvent(new Event('submit',{bubbles:true,cancelable:true}));");
await until(()=>evaluate("document.getElementById('token-count').textContent==='35.300'"));
if(await evaluate("document.cookie.includes('vba_session')"))throw new Error('Session cookie is readable by JavaScript');
await send('Page.reload');
await until(()=>evaluate("session===0&&token==='session'&&document.getElementById('token-count').textContent==='35.300'"));
await until(()=>evaluate("document.querySelector('#operation-projects button')"));
await evaluate("document.querySelector('#operation-projects button').click()");
await until(()=>evaluate("document.getElementById('operation-projects').textContent.includes('Neue Fälle pausiert')"));
@@ -55,7 +58,10 @@ try {
const sorting=await evaluate("(()=>{const original=testData.requests;const row=original[0];testData.requests=[{...row,case_id:'NEW',received_at:'2098-01-01T00:00:00Z'},{...row,project_id:'aaa',case_id:'OLD',received_at:'2000-01-01T00:00:00Z'},row];const first=()=>document.querySelector('#test-list tbody tr').textContent;document.getElementById('test-sort').value='oldest';renderTests();const oldest=first().includes('OLD');document.getElementById('test-sort').value='newest';renderTests();const newest=first().includes('NEW');document.getElementById('test-sort').value='project';renderTests();const project=first().includes('aaa');document.getElementById('test-project').value='viano-core';renderTests();const filtered=document.querySelectorAll('#test-list tbody tr').length===2;testData.requests=original;document.getElementById('test-project').value='';renderTests();return oldest&&newest&&project&&filtered;})()");
if(!sorting)throw new Error('Test request sorting/filtering failed');
await evaluate("document.getElementById('test-sort').value='project';document.getElementById('test-sort').dispatchEvent(new Event('change'));document.querySelector('#test-list button').click()");
if(!await evaluate("document.querySelector('#test-answer textarea')&&document.getElementById('test-answer').textContent.includes('Sichtbarkeit und Build')"))throw new Error('Human test answer form missing');
if(await evaluate("!!document.querySelector('#test-answer textarea')"))throw new Error('Form editable without a claim');
await evaluate("Array.from(document.querySelectorAll('#test-answer button')).find(b=>b.textContent==='Bearbeiten').click()");
await until(()=>evaluate("document.querySelector('#test-answer textarea')"));
if(!await evaluate("document.getElementById('test-answer').textContent.includes('Sichtbarkeit und Build')"))throw new Error('Human test answer form missing');
if(await evaluate('document.documentElement.scrollWidth>innerWidth'))throw new Error('Test page mobile overflow');
await writeFile(path.join(output,'test-requests-mobile.png'),Buffer.from((await send('Page.captureScreenshot',{format:'png'})).data,'base64'));
await evaluate("location.hash='#overview'");await pause(100);
+15 -2
View File
@@ -21,9 +21,13 @@ def test_web_human_feedback_auth_scope_replay_and_resume(hypotheses,monkeypatch,
monkeypatch.setenv('OTHER_TOKEN','other_'+'x'*40)
config=APIConfig(store.workspace.root,(Project('p','wow','vcore'),Project('other','wow','vcore')),
(Client('tester','TESTER_TOKEN',('p',),tester=True),Client('capture','CAPTURE_TOKEN',('p',)),Client('other','OTHER_TOKEN',('other',),tester=True)))
client=TestClient(create_app(config));auth={'Authorization':'Bearer tester_'+'x'*40}
app=create_app(config);client=TestClient(app)
app.state.sessions.users.manage('create_user','alice','test-password')
url='/v1/test-requests';handle=case_key(*('case-A','stable-package'))
assert client.get(url).status_code==401
login=client.post('/v1/auth/login',json={'username':'alice','password':'test-password'})
assert login.status_code==200
auth={'X-CSRF-Token':login.json()['csrf']}
rows=client.get(url,headers=auth).json()
assert rows['can_answer'] and rows['requests'][0]['project_id']=='p'
answer={'request_id':request['request_id'],'analysis_revision':1,'outcome':outcome,
@@ -31,7 +35,16 @@ def test_web_human_feedback_auth_scope_replay_and_resume(hypotheses,monkeypatch,
assert client.post(url+'/'+handle,headers={'Authorization':'Bearer capture_'+'x'*40},json=answer).status_code==403
other={'Authorization':'Bearer other_'+'x'*40}
assert client.get(url,headers=other).json()['requests']==[]
assert client.post(url+'/'+handle,headers=other,json=answer).status_code==404
assert client.post(url+'/'+handle,headers=other,json=answer).status_code==403
claim={'request_id':request['request_id'],'analysis_revision':1,'action':'edit'}
assert client.post(url+'/'+handle,headers=auth,json=answer).status_code==409
assert client.post(url+'/'+handle+'/claim',headers=auth,json=claim).status_code==200
app.state.sessions.users.manage('create_user','bob','second-password')
second=TestClient(app)
info=second.post('/v1/auth/login',json={'username':'bob','password':'second-password'}).json()
second_auth={'X-CSRF-Token':info['csrf']}
assert second.post(url+'/'+handle+'/claim',headers=second_auth,json=claim).status_code==409
assert second.post(url+'/'+handle,headers=second_auth,json=answer).status_code==409
assert client.post(url+'/'+handle,headers=auth,json=answer|{'request_id':'wrong'}).status_code==409
assert client.post(url+'/'+handle,headers=auth,json=answer|{'confirmed':False}).status_code==422
response=client.post(url+'/'+handle,headers=auth,json=answer)
+100
View File
@@ -0,0 +1,100 @@
import json
import sys
from types import SimpleNamespace
from concurrent.futures import ThreadPoolExecutor
import pytest
from starlette.testclient import TestClient
from viano_bug_analysis.api import APIConfig,Project,APIError
from viano_bug_analysis.api.app import create_app
from viano_bug_analysis.api.users import Users
@pytest.fixture
def auth_app(tmp_path):
config=APIConfig(tmp_path/'service',(Project('p','wow','vcore'),Project('second','wow','vcore')),())
app=create_app(config)
app.state.sessions.users.manage('create_user','alice','first password')
app.state.sessions.users.manage('create_user','bob','other password')
return app
def login(client,name='alice',password='first password'):
response=client.post('/v1/auth/login',json={'username':name,'password':password})
assert response.status_code==200,response.text
return {'X-CSRF-Token':response.json()['csrf']}
def test_login_csrf_permissions_password_change_and_delete(auth_app):
client=TestClient(auth_app)
assert client.post('/v1/auth/login',json={'username':'alice','password':'wrong'}).status_code==401
assert client.post('/v1/auth/login',headers={'Origin':'https://evil.example'},json={'username':'alice','password':'first password'}).status_code==403
auth=login(client)
assert 'httponly' in client.post('/v1/auth/login',json={'username':'alice','password':'first password'}).headers['set-cookie'].lower()
auth={'X-CSRF-Token':client.get('/v1/auth/session').json()['csrf']}
assert {p['project_id'] for p in client.get('/v1/profiles').json()['projects']}=={'p','second'}
assert client.get('/v1/operations').json()['operator']
command={'action':'pause','project_id':'p'}
assert client.post('/v1/operations',json=command).status_code==403
assert client.post('/v1/operations',headers=auth,json=command).status_code==200
with pytest.raises(APIError):auth_app.state.sessions.users.manage('change_pw_user','alice','new password','wrong')
auth_app.state.sessions.users.manage('change_pw_user','alice','new password','first password')
assert client.get('/v1/profiles').status_code==401
auth=login(client,password='new password')
auth_app.state.sessions.users.manage('delete_user','alice')
assert client.get('/v1/auth/session').status_code==401
def test_claim_is_atomic_session_bound_and_released_on_logout_or_expiry(auth_app):
sessions=auth_app.state.sessions
now=[1000.0];sessions.clock=lambda:now[0]
first,second=TestClient(auth_app),TestClient(auth_app)
first_auth,second_auth=login(first),login(second,'bob','other password')
def sid(client):return sessions.digest(client.cookies['vba_session'])
identifiers=[sid(first),sid(second)]
def acquire(identifier):
try:sessions.claim(identifier,'case','request',1);return 'won'
except APIError as error:return error.code
with ThreadPoolExecutor(max_workers=2) as pool:
results=list(pool.map(acquire,identifiers))
assert sorted(results)==['API_TEST_CLAIMED','won']
winner=results.index('won');loser=1-winner
clients=[first,second];headers=[first_auth,second_auth]
assert clients[winner].post('/v1/auth/logout',headers=headers[winner]).status_code==200
sessions.claim(identifiers[loser],'case','request',1)
sessions.claim(identifiers[loser],'case','request',1,check=True)
now[0]+=auth_app.state.sessions.config.session_seconds+1
assert clients[loser].get('/v1/auth/session').status_code==401
login(first)
sessions.claim(sid(first),'case','request',1)
def test_login_rate_limit_persists_across_requests(auth_app):
client=TestClient(auth_app)
for _ in range(10):
assert client.post('/v1/auth/login',json={'username':'missing','password':'wrong'}).status_code==401
assert client.post('/v1/auth/login',json={'username':'missing','password':'wrong'}).status_code==429
def test_console_privilege_guard_and_commands(tmp_path,monkeypatch,capsys):
from viano_bug_analysis.api.__main__ import main
import viano_bug_analysis.api.users as module
import viano_bug_analysis.api.__main__ as cli
configuration=tmp_path/'api.toml'
configuration.write_text('[api]\nworkspace="service"\n[[projects]]\nid="p"\ntype="wow"\nprofile="vcore"\n')
monkeypatch.setattr(module,'os',SimpleNamespace(name='posix',geteuid=lambda:1000))
monkeypatch.setattr(sys,'argv',['api','create_user','alice','private-password','--config',str(configuration)])
assert main()==1
output=capsys.readouterr().out
assert 'AUTH_PRIVILEGED_REQUIRED' in output and 'private-password' not in output
monkeypatch.undo()
monkeypatch.setattr(module,'require_privileged',lambda:None)
if hasattr(cli.os,'chown'):monkeypatch.setattr(cli.os,'chown',lambda *args:None)
monkeypatch.setattr(sys,'argv',['api','create_user','alice','private-password','--config',str(configuration)])
assert main()==0
users=Users(tmp_path/'service/auth/users.sqlite3')
assert users.verify('alice','private-password')
monkeypatch.setattr(sys,'argv',['api','change_pw_user','alice','private-password','new-password','--config',str(configuration)])
assert main()==0 and users.verify('alice','new-password')
monkeypatch.setattr(sys,'argv',['api','delete_user','alice','--config',str(configuration)])
assert main()==0 and users.get('alice') is None
+2
View File
@@ -103,6 +103,8 @@ if __name__ == '__main__':
details={'operation':'async-test-wait','request_ref':ref},additions={ref:json_bytes(request)})
config=APIConfig(root,(Project('viano-core','wow','vcore'),),
(Client('preview','VBA_UI_TEST_TOKEN',('viano-core',),operator=True),))
from viano_bug_analysis.api.users import Users
Users(root/'auth/users.sqlite3').manage('create_user','preview','ui-fixture-password')
os.environ['VBA_UI_TEST_TOKEN']=TOKEN
dashboard.read_status=lambda _: {'observer':'LIVE','service_id':'fixture-worker'}
operations.read_status=dashboard.read_status