Add SQLite web users, privileged account CLI and session editing claims
This commit is contained in:
1 parent
9fc5c1805c
commit
203e765992
18 files changed
+629
-60
No files matched your search
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user