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
@@ -3,6 +3,11 @@ workspace = "../service-data/service"
|
||||
host = "127.0.0.1"
|
||||
port = 8080
|
||||
|
||||
[auth]
|
||||
session_seconds = 3600
|
||||
# Production Linux: root-owned file, read-only for the service group.
|
||||
# users_database = "/etc/viano-bug-analysis/users.sqlite3"
|
||||
|
||||
[[projects]]
|
||||
id = "viano-core"
|
||||
type = "wow"
|
||||
@@ -21,10 +26,10 @@ id = "wow-capture-worker"
|
||||
token_env = "VBA_INTAKE_TOKEN"
|
||||
projects = ["viano-core"]
|
||||
|
||||
# Separate operator identity/token. Submission clients remain non-operators.
|
||||
# Optional privileged machine integration, not a human login.
|
||||
# [[clients]]
|
||||
# id = "human-operator"
|
||||
# id = "automation-operator"
|
||||
# token_env = "VBA_OPERATOR_TOKEN"
|
||||
# projects = ["viano-core"]
|
||||
# operator = true
|
||||
# tester = true
|
||||
# Human accounts are created only by the privileged create_user console command.
|
||||
@@ -0,0 +1,40 @@
|
||||
# Benutzeranmeldung und Bearbeitungssperren – 2026-10-22
|
||||
|
||||
Umfang: SQLite-Benutzer mit gesalzenen scrypt-Passworthashes, getrennte
|
||||
Sitzungs-/Sperrendatenbank, Cookie-Anmeldung, CSRF-Schutz, Abmeldung,
|
||||
Konsolenbefehle create_user/delete_user/change_pw_user mit Privilegienprüfung.
|
||||
Alle Webbenutzer besitzen dieselben Rechte für alle Projekte. Keine Webverwaltung
|
||||
von Benutzern und keine Benutzerklassen. Bearer-Tokens bleiben für Maschinen.
|
||||
|
||||
Konsole: Linux-root/sudo oder Windows-elevierte Administratorberechtigung.
|
||||
Die Privilegienprüfung wurde mit simuliertem unprivilegiertem Aufrufer getestet;
|
||||
Benutzerbefehle mit temporärer Datenbank unter kontrollierter Testfreigabe geprüft.
|
||||
Keine realen Benutzerkonten wurden im Dienst-Workspace erzeugt.
|
||||
|
||||
Prüfungen:
|
||||
|
||||
- 57 lokale Tests bestanden: API, Benutzer/Sitzungen, menschliches Feedback,
|
||||
Dashboard, Worker und vcore-Übergabe.
|
||||
- Gleichzeitige SQLite-Übernahmen aus zwei Threads/Sitzungen: genau ein Gewinner.
|
||||
- HTTP: ohne Bearbeiten kein Feedback, fremde Sitzung weder Übernahme noch Antwort.
|
||||
- Abmeldung/Sitzungsablauf geben Sperren frei; Passwortwechsel/Löschung sperren
|
||||
alte Sitzungen. Falsches altes Passwort wird abgewiesen.
|
||||
- Alle Webbenutzer sehen alle Projekte/Betriebsaktionen. Fehlender CSRF-Header
|
||||
wird abgewiesen; fremder Login-Origin wird abgewiesen; Anmeldeversuche begrenzt.
|
||||
- Beide Feedbackausgänge bleiben möglich; unveränderte Budgets/Revision und
|
||||
idempotente Antworten erhalten. Maschinen-Bearer können menschliche Antworten
|
||||
nicht ohne Benutzersitzung bestätigen.
|
||||
- Echter Headless-Edge mit ausschließlich temporärem Testbenutzer: normale
|
||||
Anmeldung, UI-Projektsteuerung, Testanfrage zuerst schreibgeschützt, Bearbeiten
|
||||
aktiviert Formular, Navigation/Diagramme/Mobilansicht/Logout: PASS.
|
||||
Keine JS-Ausnahmen. Die erwartete 401 beim Prüfen einer noch nicht bestehenden
|
||||
Sitzung wird separat vom Browserfehlercheck ausgenommen.
|
||||
|
||||
Testdaten vor Ausführung: Trials 16.939.129.428 Bytes, kleine Fixtures/temporäre
|
||||
Dateien mit Reserve innerhalb des 20-GB-Budgets. Keine kostenpflichtigen
|
||||
LLM-Anfragen, keine Änderung an Analysebudgets oder der Designspezifikation.
|
||||
|
||||
Deployment: Benutzerdatei in root-/administratorgeschütztem Verzeichnis mit
|
||||
passenden Dienst-Leserechten betreiben; SQLite allein ersetzt keine OS-Dateirechte.
|
||||
Native Linux-Dateirechte/Chown und reale Windows-UAC/NTFS-ACLs sind beim Server-
|
||||
Deployment zu prüfen. Die API öffnet die Benutzerdatei ausschließlich read-only.
|
||||
@@ -0,0 +1,103 @@
|
||||
# Benutzeranmeldung und exklusive Bearbeitung
|
||||
|
||||
Webbenutzer melden sich mit Benutzername und Passwort an. Alle Benutzer besitzen
|
||||
dieselben Rechte für alle Projekte. Es gibt weder Administrator-/Normalbenutzer-
|
||||
Klassen noch eine Benutzerverwaltung im Web. Bestehende Bearer-Tokens bleiben
|
||||
ausschließlich für externe API-Integrationen nutzbar und behalten deren bisherigen
|
||||
Zugriffsgrenzen. Fehlende Token-Umgebungsvariablen deaktivieren die jeweilige
|
||||
Integration; die Benutzeranmeldung benötigt keine Token-Zufallsgenerierung.
|
||||
|
||||
## Konsolenverwaltung
|
||||
|
||||
Die Befehle erfordern unter Linux effektive root-Rechte (normalerweise `sudo`),
|
||||
unter Windows eine als Administrator gestartete Konsole. Sie benötigen keinen
|
||||
Gemini-Schlüssel und senden keine Modellanfrage.
|
||||
|
||||
```bash
|
||||
sudo .venv/bin/python -m viano_bug_analysis.api create_user nico --config config/api.local.toml --service-group buganalysis
|
||||
sudo .venv/bin/python -m viano_bug_analysis.api change_pw_user nico --config config/api.local.toml
|
||||
sudo .venv/bin/python -m viano_bug_analysis.api delete_user nico --config config/api.local.toml
|
||||
```
|
||||
|
||||
Ohne Passwortargumente erfolgt eine verdeckte Abfrage. Die gewünschten expliziten
|
||||
Argumentformen funktionieren ebenfalls:
|
||||
|
||||
```text
|
||||
create_user NAME PASSWORT --config DATEI
|
||||
delete_user NAME --config DATEI
|
||||
change_pw_user NAME ALTES_PASSWORT NEUES_PASSWORT --config DATEI
|
||||
```
|
||||
|
||||
Explizite Passwortargumente können in Shellhistorie/Prozessargumenten auftauchen;
|
||||
für den normalen Betrieb die verdeckte Eingabe verwenden. Benutzernamen bestehen
|
||||
aus 1–64 ASCII-Buchstaben/Ziffern sowie Punkt, Unterstrich oder Bindestrich und
|
||||
beginnen mit einem Buchstaben oder einer Ziffer. Namen sind nicht case-sensitiv.
|
||||
Beim Passwortwechsel wird das alte Passwort geprüft. Passwortwechsel und Löschung
|
||||
entwerten bestehende Sitzungen und deren Bearbeitungssperren ohne API-Neustart.
|
||||
|
||||
Windows, in einer Administrator-PowerShell:
|
||||
|
||||
```powershell
|
||||
.\.venv\Scripts\python.exe -m viano_bug_analysis.api create_user nico --config config/api.local.toml
|
||||
```
|
||||
|
||||
## SQLite-Dateien und Betriebssystemrechte
|
||||
|
||||
Benutzer liegen standardmäßig in `<workspace>/auth/users.sqlite3`. Die API öffnet
|
||||
diese Datei ausschließlich schreibgeschützt. Gesalzene scrypt-Hashes ersetzen
|
||||
Klartextpasswörter. Sitzungen, Anmeldebegrenzung und Bearbeitungssperren liegen
|
||||
separat in `<workspace>/runtime/web-sessions/sessions.sqlite3`; Sitzungstokens
|
||||
werden dort nur als Hash gespeichert.
|
||||
|
||||
Produktionskonfiguration, Pfade relativ zur Konfigurationsdatei oder absolut:
|
||||
|
||||
```toml
|
||||
[auth]
|
||||
users_database = "/etc/viano-bug-analysis/users.sqlite3"
|
||||
session_seconds = 3600
|
||||
```
|
||||
|
||||
Die Benutzerdatei und ihr Verzeichnis gehören unter Linux root; CLI-Befehle setzen
|
||||
0640 bzw. 0750. `--service-group buganalysis` erlaubt dem dedizierten Dienstkonto
|
||||
das Lesen, nicht die Benutzerverwaltung. Verzeichnisvorfahren dürfen nicht durch
|
||||
unvertrauenswürdige Benutzer austauschbar sein. Der Dienst benötigt Schreibzugriff
|
||||
auf seinen Workspace und die Sitzungsdatei. Unter Windows entsprechend geschützte
|
||||
NTFS-Verzeichnisse/ACLs verwenden: Administratoren verwalten die Benutzerdatei,
|
||||
das Dienstkonto liest sie. Die Konsolenbefehle prüfen auch dort die erhöhte
|
||||
Administratorberechtigung. Wer Programmcode oder Authentifizierungsdateien selbst
|
||||
ändern kann, liegt außerhalb der Web-Zugriffsgrenze; deshalb sind diese OS-Rechte
|
||||
Bestandteil der Servereinrichtung.
|
||||
|
||||
SQLite benötigt keinen MariaDB-Dienst. Für einen Umzug die Benutzerdatei und die
|
||||
Projekt-/Workspace-Daten sichern; die separate Sitzungsdatei kann bei gestoppter
|
||||
API bewusst verworfen werden, um alle Sitzungen und Sperren zu beenden.
|
||||
|
||||
## Sitzungen und Bearbeiten
|
||||
|
||||
Die Anmeldung erzeugt ein HttpOnly-/SameSite-Strict-Cookie; unter HTTPS zusätzlich
|
||||
Secure. Entfernte Zugriffe über HTTPS betreiben. Mutationen benötigen den an die
|
||||
Sitzung gebundenen CSRF-Header. Die Oberfläche hält nur diesen CSRF-Wert im
|
||||
Arbeitsspeicher; Passwörter und Sitzungstokens werden nicht im Local Storage
|
||||
gespeichert. Sitzungen gelten standardmäßig absolut eine Stunde, konfigurierbar
|
||||
von 60 Sekunden bis 24 Stunden; regelmäßige Seitenaktualisierungen verlängern sie
|
||||
nicht endlos. Nach einem Neuladen kann eine gültige Sitzung weiterverwendet werden.
|
||||
Anmeldeversuche werden serverseitig zeitlich begrenzt.
|
||||
|
||||
Eine Testanfrage lässt sich zunächst nur ansehen. Erst **Bearbeiten** übernimmt
|
||||
die Anfrage atomar für die aktuelle Sitzung. Eine zweite Sitzung erhält
|
||||
`API_TEST_CLAIMED` und eine verständliche Meldung. Erst dann erscheinen Eingabefelder.
|
||||
Die Serverprüfung verlangt dieselbe Sperre auch beim Absenden; das Ausblenden
|
||||
von Feldern allein ist nicht die Zugriffskontrolle. Identische Wiederholungen
|
||||
bleiben idempotent, abweichende zweite Antworten werden zurückgewiesen.
|
||||
|
||||
**Bearbeitung freigeben**, Abmeldung oder Sitzungsablauf machen die Anfrage für
|
||||
andere Sitzungen verfügbar. Der Ablauf wird bei erneuter Übernahme anhand der
|
||||
serverseitigen Zeit geprüft; es wird kein Hintergrundtimer benötigt. Zwei Tabs
|
||||
derselben Sitzung gehören derselben Bearbeitung; zwei getrennte Sitzungen auch
|
||||
desselben Benutzers konkurrieren um die Sperre.
|
||||
|
||||
HTTP: `POST /v1/auth/login`, `GET /v1/auth/session`, `POST /v1/auth/logout`.
|
||||
Testübernahme/-freigabe: `POST /v1/test-requests/{key}/claim` mit
|
||||
`{request_id,analysis_revision,action:"edit"|"release"}`. Feedback bestätigt wie
|
||||
bisher die konkrete Anfrage und führt dieselbe Analyse-Revision fort. Bearer-
|
||||
Clients können keine menschliche Bearbeitungssperre oder Antwort übernehmen.
|
||||
+15
-20
@@ -6,10 +6,12 @@ Die Betriebsübersicht wird durch die bestehende Übergabe-API ausgeliefert:
|
||||
.\.venv\Scripts\python.exe -m viano_bug_analysis.api serve --config config/api.local.toml
|
||||
```
|
||||
|
||||
Anschließend `http://127.0.0.1:8080/` öffnen und mit einem freigegebenen **API-Token**
|
||||
anmelden. Dies ist nicht der Gemini-Schlüssel. Der Token bleibt ausschließlich im
|
||||
Arbeitsspeicher des Tabs, nicht in Cookies, Local Storage oder URLs. Für entfernte
|
||||
Zugriffe HTTPS verwenden. Der Analyseworker läuft unabhängig davon wie unter
|
||||
Anschließend `http://127.0.0.1:8080/` öffnen und mit **Benutzername und Passwort**
|
||||
anmelden. Benutzer werden ausschließlich über privilegierte Konsolenbefehle
|
||||
angelegt, geändert und gelöscht, siehe [Benutzerverwaltung](user-accounts.md).
|
||||
Alle Webbenutzer haben dieselben Rechte für alle Projekte. Die unten beschriebenen
|
||||
Client-/Projektberechtigungen gelten weiterhin für externe API-Integrationen.
|
||||
Für entfernte Zugriffe HTTPS verwenden. Der Analyseworker läuft unabhängig davon wie unter
|
||||
[Analyseworker](analysis-worker.md) beschrieben. Das Öffnen der Übersicht löst
|
||||
keine Modellanfragen aus.
|
||||
|
||||
@@ -93,7 +95,8 @@ Fortsetzung angefordert werden; Budgets werden dabei nicht zurückgesetzt.
|
||||
|
||||
## Betriebssteuerung
|
||||
|
||||
Clients erhalten standardmäßig keine Betreiberrechte. Ein eigener Client mit
|
||||
Alle angemeldeten Webbenutzer können die Betriebssteuerung verwenden. Externe
|
||||
API-Clients erhalten standardmäßig keine Betreiberrechte. Ein eigener Client mit
|
||||
eigenem Token und `operator = true` in der API-Konfiguration erhält Aktionen für
|
||||
seine freigegebenen Projekte (Beispiel in `config/api.example.toml`). API nach einer
|
||||
Rechteänderung neu starten. Der Gemini-Schlüssel ist dafür nicht geeignet.
|
||||
@@ -140,24 +143,15 @@ Beobachtungen und „Test durchgeführt“ oder „Nicht durchführbar“. Die a
|
||||
Bestätigung ist erforderlich. Textdaten bleiben untrusted observations, keine
|
||||
auszuführenden Befehle. Diese erste Oberfläche nimmt Text, keine Dateianhänge an.
|
||||
|
||||
Clients brauchen `tester = true` oder Betreiberrechte und Projektzugriff, um eine
|
||||
Antwort zu bestätigen. Ein reiner Uploadclient erhält diese Rechte nicht automatisch.
|
||||
Beispiel eines separaten menschlichen Clients in der API-Konfiguration:
|
||||
|
||||
```toml
|
||||
[[clients]]
|
||||
id = "human-dev"
|
||||
token_env = "VBA_HUMAN_TOKEN"
|
||||
projects = ["viano-core"]
|
||||
tester = true
|
||||
```
|
||||
|
||||
Eigenen API-Token über diese Umgebungsvariable bereitstellen und API neu starten.
|
||||
Menschliche Antworten erfordern eine angemeldete Benutzersitzung und die exklusive
|
||||
Übernahme über **Bearbeiten**, siehe [Benutzeranmeldung](user-accounts.md).
|
||||
Es gibt keine gesonderten Testerrollen für Webbenutzer. Menschliche Zugänge
|
||||
ausschließlich mit `create_user` anlegen.
|
||||
Die bestätigende Identität und Zeit stammen vom Server, nicht aus frei editierbaren
|
||||
Formularfeldern. `GET /v1/test-requests` liefert ausschließlich freigegebene Projekte;
|
||||
`POST /v1/test-requests/{case-key}` bestätigt die konkrete Anfrage/Revision.
|
||||
Kollisionen, veraltete Anfragen und widersprüchliche Wiederholungen werden abgewiesen.
|
||||
Eine identische erneute Übermittlung desselben Clients bleibt ohne doppelte Wirkung.
|
||||
Eine identische erneute Übermittlung derselben Sitzung bleibt ohne doppelte Wirkung.
|
||||
|
||||
Eine bestätigte Antwort setzt `AWAITING_TEST_FEEDBACK` über den vorhandenen
|
||||
Coordinator auf `INVESTIGATING_HYPOTHESES` zurück. Revision, Budget und ursprüngliche
|
||||
@@ -185,7 +179,8 @@ LLM-Aufrufe aus.
|
||||
|
||||
## Datenschutz und Betrieb
|
||||
|
||||
`GET /v1/overview` benötigt einen Bearer-Token und liefert nur freigegebene Projekte.
|
||||
`GET /v1/overview` benötigt eine Benutzersitzung oder einen Maschinen-Bearer-Token.
|
||||
Webbenutzer sehen alle Projekte; Maschinenclients nur ihre freigegebenen Projekte.
|
||||
Prompts, vollständige Modellantworten, lokale Pfade und Zugangsdaten werden nicht
|
||||
an den Browser übergeben. Fallnamen werden als Text dargestellt. Die Seite lädt
|
||||
keine externen Fonts, Chartbibliotheken oder Analysedienste; Diagramme werden lokal
|
||||
|
||||
@@ -1,15 +1,46 @@
|
||||
import argparse
|
||||
import json
|
||||
import getpass
|
||||
import os
|
||||
import sqlite3
|
||||
from .config import APIConfig, APIError
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description='Authenticated BugAnalysis intake; no analysis worker')
|
||||
parser.add_argument('command', choices=['check', 'serve'])
|
||||
parser.add_argument('command', choices=['check', 'serve','create_user','delete_user','change_pw_user'])
|
||||
parser.add_argument('name',nargs='?')
|
||||
parser.add_argument('password',nargs='?')
|
||||
parser.add_argument('new_password',nargs='?')
|
||||
parser.add_argument('--service-group',help='Linux group allowed to read the root-owned users database')
|
||||
parser.add_argument('--config', required=True)
|
||||
args = parser.parse_args()
|
||||
try:
|
||||
config = APIConfig.load(args.config)
|
||||
if args.command in {'create_user','delete_user','change_pw_user'}:
|
||||
from .users import Users,require_privileged
|
||||
require_privileged()
|
||||
gid=None
|
||||
if args.service_group and os.name!='nt':
|
||||
import grp
|
||||
gid=grp.getgrnam(args.service_group).gr_gid
|
||||
if not args.name:raise APIError('AUTH_USER_INVALID')
|
||||
if args.command=='delete_user' and (args.password or args.new_password):raise APIError('AUTH_ARGUMENTS')
|
||||
if args.command=='create_user' and args.new_password:raise APIError('AUTH_ARGUMENTS')
|
||||
users=Users(config.users_database or config.workspace/'auth/users.sqlite3')
|
||||
if args.command=='create_user':
|
||||
users.manage(args.command,args.name,args.password if args.password is not None else getpass.getpass('Password: '))
|
||||
elif args.command=='change_pw_user':
|
||||
old=args.password if args.password is not None else getpass.getpass('Old password: ')
|
||||
new=args.new_password if args.new_password is not None else getpass.getpass('New password: ')
|
||||
users.manage(args.command,args.name,new,old)
|
||||
else:users.manage(args.command,args.name)
|
||||
if os.name!='nt':
|
||||
os.chown(users.path,0,gid if gid is not None else -1)
|
||||
os.chown(users.path.parent,0,gid if gid is not None else -1)
|
||||
os.chmod(users.path,0o640);os.chmod(users.path.parent,0o750)
|
||||
print(json.dumps({'status':'OK','command':args.command,'username':args.name}))
|
||||
return 0
|
||||
config.tokens()
|
||||
if args.command == 'check':
|
||||
print(json.dumps({'status': 'OK', 'projects': [p.id for p in config.projects]}))
|
||||
@@ -21,6 +52,8 @@ def main():
|
||||
except APIError as error:
|
||||
print(json.dumps({'status': 'ERROR', 'code': error.code}))
|
||||
return 1
|
||||
except (OSError,sqlite3.Error,KeyError):
|
||||
print(json.dumps({'status':'ERROR','code':'AUTH_STORAGE'}));return 1
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
|
||||
@@ -2,6 +2,7 @@ import asyncio
|
||||
import functools
|
||||
import hmac
|
||||
import json
|
||||
import sqlite3
|
||||
import shutil
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
@@ -22,6 +23,7 @@ from .dashboard import Dashboard
|
||||
from .cases import Cases
|
||||
from .operations import Operations
|
||||
from .test_requests import TestRequests
|
||||
from .sessions import Sessions,COOKIE
|
||||
from viano_bug_analysis.lifecycle.errors import LockBusy
|
||||
from viano_bug_analysis.lifecycle.errors import LifecycleError
|
||||
from viano_bug_analysis.package import PackageReadError
|
||||
@@ -34,18 +36,51 @@ def create_app(config):
|
||||
cases = Cases(service)
|
||||
operations = Operations(service)
|
||||
test_requests = TestRequests(service)
|
||||
sessions=Sessions(config)
|
||||
tokens = config.tokens()
|
||||
service.recover()
|
||||
active = 0
|
||||
|
||||
def authenticate(request):
|
||||
header = request.headers.get('authorization', '')
|
||||
if not header.startswith('Bearer ') or not header.isascii():
|
||||
raise APIError('API_UNAUTHORIZED', 401)
|
||||
for client, token in tokens:
|
||||
if hmac.compare_digest(header[7:], token):
|
||||
return client
|
||||
raise APIError('API_UNAUTHORIZED', 401)
|
||||
if header:
|
||||
if header.startswith('Bearer ') and header.isascii():
|
||||
for client, token in tokens:
|
||||
if hmac.compare_digest(header[7:], token):return client
|
||||
raise APIError('API_UNAUTHORIZED',401)
|
||||
return sessions.authenticate(request)
|
||||
|
||||
async def small_json(request,maximum=4096):
|
||||
if request.headers.get('content-type','').split(';')[0]!='application/json':
|
||||
raise APIError('API_CONTENT_TYPE',415)
|
||||
body=bytearray()
|
||||
async for chunk in request.stream():
|
||||
body.extend(chunk)
|
||||
if len(body)>maximum:raise APIError('API_BODY_LIMIT',413)
|
||||
try:return json.loads(body)
|
||||
except ValueError:raise APIError('API_BAD_REQUEST',422) from None
|
||||
|
||||
async def auth(request):
|
||||
origin=request.headers.get('origin')
|
||||
if origin and origin!=str(request.base_url).rstrip('/'):
|
||||
raise APIError('AUTH_ORIGIN',403)
|
||||
action=request.path_params['action']
|
||||
if action=='login' and request.method=='POST':
|
||||
data=await small_json(request,16384)
|
||||
if not isinstance(data,dict) or set(data)!={'username','password'}:raise APIError('AUTH_LOGIN_INVALID',401)
|
||||
token,info=await run_in_threadpool(sessions.login,data['username'],data['password'],request.client.host if request.client else 'unknown')
|
||||
response=JSONResponse(info,headers={'Cache-Control':'no-store'})
|
||||
response.set_cookie(COOKIE,token,max_age=config.session_seconds,httponly=True,
|
||||
secure=request.url.scheme=='https',samesite='strict',path='/')
|
||||
return response
|
||||
if action not in {'session','logout'} or (action=='session' and request.method!='GET') or (action=='logout' and request.method!='POST'):
|
||||
raise APIError('API_NOT_FOUND',404)
|
||||
await run_in_threadpool(sessions.authenticate,request)
|
||||
info=request.state.session_info
|
||||
if action=='logout':
|
||||
await run_in_threadpool(sessions.logout,request.state.session_id)
|
||||
response=JSONResponse({'status':'OK'},headers={'Cache-Control':'no-store'});response.delete_cookie(COOKIE,path='/');return response
|
||||
return JSONResponse({'username':info['username'],'csrf':info['csrf'],'expires_at':info['expires']},headers={'Cache-Control':'no-store'})
|
||||
|
||||
async def health(request):
|
||||
return JSONResponse({'status': 'OK'})
|
||||
@@ -127,6 +162,8 @@ def create_app(config):
|
||||
if request.method=='GET':
|
||||
result=await run_in_threadpool(test_requests.listing,client)
|
||||
else:
|
||||
identifier=getattr(request.state,'session_id',None)
|
||||
if not identifier:raise APIError('AUTH_SESSION_REQUIRED',403)
|
||||
if not (client.operator or client.tester):
|
||||
raise APIError('API_FORBIDDEN',403)
|
||||
if request.headers.get('content-type','').split(';')[0]!='application/json':
|
||||
@@ -140,7 +177,19 @@ def create_app(config):
|
||||
data=json.loads(body)
|
||||
except ValueError:
|
||||
raise APIError('API_TEST_FEEDBACK_INVALID',422) from None
|
||||
result=await run_in_threadpool(test_requests.answer,client,request.path_params['handle'],data)
|
||||
handle=request.path_params['handle']
|
||||
if (not isinstance(data,dict) or not isinstance(data.get('request_id'),str)
|
||||
or type(data.get('analysis_revision')) is not int):
|
||||
raise APIError('API_TEST_FEEDBACK_INVALID',422)
|
||||
if request.url.path.endswith('/claim'):
|
||||
if set(data)!={'request_id','analysis_revision','action'} or not isinstance(data['action'],str) or data['action'] not in {'edit','release'}:
|
||||
raise APIError('API_TEST_FEEDBACK_INVALID',422)
|
||||
await run_in_threadpool(test_requests.pending,client,handle,data['request_id'],data['analysis_revision'])
|
||||
await run_in_threadpool(functools.partial(sessions.claim,identifier,handle,data['request_id'],data['analysis_revision'],release=data['action']=='release'))
|
||||
result={'status':'RELEASED' if data['action']=='release' else 'EDITING'}
|
||||
else:
|
||||
await run_in_threadpool(functools.partial(sessions.claim,identifier,handle,data['request_id'],data['analysis_revision'],check=True))
|
||||
result=await run_in_threadpool(test_requests.answer,client,handle,data)
|
||||
except LockBusy:
|
||||
raise APIError('API_TEST_BUSY',409) from None
|
||||
except (LifecycleError,PackageReadError) as exc:
|
||||
@@ -227,14 +276,17 @@ def create_app(config):
|
||||
return JSONResponse({'status': 'ERROR', 'code': 'API_STORAGE_ERROR'}, status_code=503)
|
||||
|
||||
app = Starlette(routes=[Route('/', web), Route('/assets/{asset}', web),
|
||||
Route('/v1/auth/{action}',auth,methods=['GET','POST']),
|
||||
Route('/v1/overview', overview), Route('/healthz', health), Route('/v1/profiles', profiles),
|
||||
Route('/v1/operations',operation,methods=['GET','POST']),
|
||||
Route('/v1/test-requests',tests),Route('/v1/test-requests/{handle}',tests,methods=['POST']),
|
||||
Route('/v1/test-requests/{handle}/claim',tests,methods=['POST']),
|
||||
Route('/v1/cases/{handle}', case_detail), Route('/v1/cases/{handle}/package', case_package),
|
||||
Route('/v1/schema', contract), Route('/v1/submissions', submit, methods=['POST']),
|
||||
Route('/v1/submissions/{submission}', status), Route('/v1/submissions/{submission}/package', package)],
|
||||
exception_handlers={APIError: error, HTTPException: http_error, OSError: storage_error})
|
||||
exception_handlers={APIError: error, HTTPException: http_error, OSError: storage_error,sqlite3.Error:storage_error})
|
||||
app.state.intake = service
|
||||
app.state.sessions=sessions
|
||||
return BodyLimit(app, config.limits.max_body_bytes)
|
||||
|
||||
|
||||
|
||||
@@ -78,13 +78,16 @@ class APIConfig:
|
||||
host: str = '127.0.0.1'
|
||||
port: int = 8080
|
||||
limits: IntakeLimits = field(default_factory=IntakeLimits)
|
||||
users_database: Path | None = None
|
||||
session_seconds: int = 3600
|
||||
|
||||
def __post_init__(self):
|
||||
ids = {p.id for p in self.projects}
|
||||
if (not ids or len(ids) != len(self.projects) or not self.clients
|
||||
if (not ids or len(ids) != len(self.projects)
|
||||
or len({c.id for c in self.clients}) != len(self.clients)
|
||||
or type(self.port) is not int or not 0 < self.port < 65536
|
||||
or not isinstance(self.host, str) or not self.host):
|
||||
or not isinstance(self.host, str) or not self.host
|
||||
or type(self.session_seconds) is not int or not 60<=self.session_seconds<=86400):
|
||||
raise APIError('API_CONFIG', 500)
|
||||
for c in self.clients:
|
||||
if (type(c.operator) is not bool or type(c.tester) is not bool or not isinstance(c.id, str) or not IDENTIFIER.fullmatch(c.id)
|
||||
@@ -97,6 +100,8 @@ class APIConfig:
|
||||
result = []
|
||||
for client in self.clients:
|
||||
token = os.environ.get(client.token_env, '')
|
||||
if not token:
|
||||
continue # Machine integrations are optional; web users need no token env.
|
||||
if not re.fullmatch(r'[A-Za-z0-9_-]{32,256}', token):
|
||||
raise APIError('API_CREDENTIALS', 500)
|
||||
if any(token == existing for _, existing in result):
|
||||
@@ -109,7 +114,7 @@ class APIConfig:
|
||||
try:
|
||||
path = Path(path).resolve()
|
||||
data = tomllib.loads(path.read_text(encoding='utf-8'))
|
||||
if set(data) - {'api', 'projects', 'clients', 'limits'}:
|
||||
if set(data) - {'api', 'projects', 'clients', 'limits','auth'}:
|
||||
raise ValueError()
|
||||
api = data['api']
|
||||
if set(api) - {'workspace', 'host', 'port'}:
|
||||
@@ -127,9 +132,13 @@ class APIConfig:
|
||||
raise ValueError()
|
||||
resolved[key] = target
|
||||
projects.append(Project(**row, settings=resolved))
|
||||
clients = [Client(**(dict(c) | {'projects': tuple(c['projects'])})) for c in data['clients']]
|
||||
clients = [Client(**(dict(c) | {'projects': tuple(c['projects'])})) for c in data.get('clients',[])]
|
||||
auth=data.get('auth',{})
|
||||
if set(auth)-{'users_database','session_seconds'}:raise ValueError()
|
||||
return cls(workspace=(path.parent / api['workspace']).resolve(),
|
||||
projects=tuple(projects), clients=tuple(clients), host=api.get('host', '127.0.0.1'),
|
||||
port=api.get('port', 8080), limits=IntakeLimits(**data.get('limits', {})))
|
||||
port=api.get('port', 8080), limits=IntakeLimits(**data.get('limits', {})),
|
||||
users_database=(path.parent/auth['users_database']).resolve() if 'users_database' in auth else None,
|
||||
session_seconds=auth.get('session_seconds',3600))
|
||||
except (OSError, ValueError, TypeError, KeyError):
|
||||
raise APIError('API_CONFIG', 500) from None
|
||||
@@ -0,0 +1,97 @@
|
||||
import hashlib
|
||||
import hmac
|
||||
import os
|
||||
import secrets
|
||||
import time
|
||||
|
||||
from .config import APIError,Client,directory
|
||||
from .users import Users,database
|
||||
|
||||
COOKIE='vba_session'
|
||||
|
||||
|
||||
class Sessions:
|
||||
def __init__(self,config,*,clock=time.time):
|
||||
self.config=config;self.clock=clock
|
||||
self.users=Users(config.users_database or config.workspace/'auth/users.sqlite3')
|
||||
root=directory(config.workspace/'runtime/web-sessions')
|
||||
self.path=root/'sessions.sqlite3'
|
||||
if not self.path.exists():
|
||||
try:
|
||||
descriptor=os.open(self.path,os.O_CREAT|os.O_EXCL|os.O_WRONLY,0o600);os.close(descriptor)
|
||||
except FileExistsError:pass
|
||||
with database(self.path) as db:
|
||||
db.executescript('''
|
||||
CREATE TABLE IF NOT EXISTS sessions (id TEXT PRIMARY KEY, username TEXT NOT NULL, version TEXT NOT NULL, csrf TEXT NOT NULL, expires REAL NOT NULL);
|
||||
CREATE TABLE IF NOT EXISTS claims (handle TEXT NOT NULL, request_id TEXT NOT NULL, revision INTEGER NOT NULL, session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE, PRIMARY KEY(handle,request_id,revision));
|
||||
CREATE TABLE IF NOT EXISTS attempts (id TEXT PRIMARY KEY, count INTEGER NOT NULL, until REAL NOT NULL);
|
||||
''')
|
||||
|
||||
@staticmethod
|
||||
def digest(value):return hashlib.sha256(value.encode()).hexdigest()
|
||||
|
||||
def _prune(self,db):
|
||||
db.execute('DELETE FROM sessions WHERE expires<=?',(self.clock(),))
|
||||
# Password changes/deletion invalidate sessions and their editing claims.
|
||||
for row in db.execute('SELECT id,username,version FROM sessions').fetchall():
|
||||
user=self.users.get(row['username'])
|
||||
if not user or user['version']!=row['version']:
|
||||
db.execute('DELETE FROM sessions WHERE id=?',(row['id'],))
|
||||
|
||||
def login(self,name,password,remote):
|
||||
if not isinstance(name,str) or not isinstance(password,str) or not 1<=len(name)<=64 or not 1<=len(password)<=1024:
|
||||
raise APIError('AUTH_LOGIN_INVALID',401)
|
||||
now=self.clock()
|
||||
with database(self.path) as db:
|
||||
db.execute('BEGIN IMMEDIATE')
|
||||
db.execute('DELETE FROM attempts WHERE until<=?',(now,))
|
||||
keys=[('user:'+name.casefold(),10),('ip:'+remote,30)]
|
||||
for value,maximum in keys:
|
||||
identifier=self.digest(value)
|
||||
row=db.execute('SELECT count FROM attempts WHERE id=?',(identifier,)).fetchone()
|
||||
if row and row['count']>=maximum:raise APIError('AUTH_LOGIN_LIMIT',429)
|
||||
for value,_ in keys:
|
||||
db.execute('INSERT INTO attempts VALUES (?,1,?) ON CONFLICT(id) DO UPDATE SET count=count+1',
|
||||
(self.digest(value),now+900))
|
||||
user=self.users.verify(name,password)
|
||||
if not user:raise APIError('AUTH_LOGIN_INVALID',401)
|
||||
token=secrets.token_urlsafe(32);csrf=secrets.token_urlsafe(32);expires=now+self.config.session_seconds
|
||||
with database(self.path) as db:
|
||||
db.execute('BEGIN IMMEDIATE');self._prune(db)
|
||||
db.execute('DELETE FROM attempts WHERE id=?',(self.digest('user:'+name.casefold()),))
|
||||
db.execute('INSERT INTO sessions VALUES (?,?,?,?,?)',(self.digest(token),user['name'],user['version'],csrf,expires))
|
||||
return token,{'username':user['name'],'csrf':csrf,'expires_at':expires}
|
||||
|
||||
def authenticate(self,request):
|
||||
token=request.cookies.get(COOKIE,'')
|
||||
if not token or len(token)>128:raise APIError('API_UNAUTHORIZED',401)
|
||||
identifier=self.digest(token)
|
||||
with database(self.path) as db:
|
||||
row=db.execute('SELECT * FROM sessions WHERE id=? AND expires>?',(identifier,self.clock())).fetchone()
|
||||
user=self.users.get(row['username']) if row else None
|
||||
if not user or user['version']!=row['version']:raise APIError('API_UNAUTHORIZED',401)
|
||||
if request.method not in {'GET','HEAD','OPTIONS'}:
|
||||
csrf=request.headers.get('x-csrf-token','')
|
||||
if not csrf.isascii() or not hmac.compare_digest(csrf,row['csrf']):
|
||||
raise APIError('AUTH_CSRF',403)
|
||||
request.state.session_id=identifier
|
||||
request.state.session_info=dict(row)
|
||||
# Human accounts deliberately have the same complete web permissions.
|
||||
return Client('user-'+user['name'],'WEB_SESSION',tuple(p.id for p in self.config.projects),operator=True,tester=True)
|
||||
|
||||
def logout(self,identifier):
|
||||
with database(self.path) as db:db.execute('DELETE FROM sessions WHERE id=?',(identifier,))
|
||||
|
||||
def claim(self,identifier,handle,request_id,revision,*,check=False,release=False):
|
||||
with database(self.path) as db:
|
||||
db.execute('BEGIN IMMEDIATE');self._prune(db)
|
||||
if not db.execute('SELECT 1 FROM sessions WHERE id=?',(identifier,)).fetchone():
|
||||
raise APIError('API_UNAUTHORIZED',401)
|
||||
identity=(handle,request_id,revision)
|
||||
row=db.execute('SELECT session_id FROM claims WHERE handle=? AND request_id=? AND revision=?',identity).fetchone()
|
||||
if row and row['session_id']!=identifier:raise APIError('API_TEST_CLAIMED',409)
|
||||
if check and not row:raise APIError('API_TEST_CLAIM_REQUIRED',409)
|
||||
if release:
|
||||
db.execute('DELETE FROM claims WHERE handle=? AND request_id=? AND revision=? AND session_id=?',identity+(identifier,))
|
||||
elif not row:
|
||||
db.execute('INSERT INTO claims VALUES (?,?,?,?)',identity+(identifier,))
|
||||
@@ -23,6 +23,13 @@ class TestRequests:
|
||||
return None
|
||||
return value
|
||||
|
||||
def pending(self,client,handle,request_id,revision):
|
||||
with PackageReader(self.cases.head(handle,client)) as reader:
|
||||
value=self.request(reader)
|
||||
if (reader.manifest['state']!='BLOCKED' or reader.manifest['routing']['reason']!='AWAITING_TEST_FEEDBACK'
|
||||
or not value or (value['request_id'],value['analysis_revision'])!=(request_id,revision)):
|
||||
raise APIError('API_TEST_REQUEST_STALE',409)
|
||||
|
||||
def listing(self,client):
|
||||
rows=[];errors=0
|
||||
for folder in self.service.workspace.cases.glob('*/*'):
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
"""Portable password database. Only privileged console commands mutate users."""
|
||||
import hashlib
|
||||
import hmac
|
||||
import os
|
||||
import re
|
||||
import secrets
|
||||
import sqlite3
|
||||
from contextlib import contextmanager
|
||||
from pathlib import Path
|
||||
|
||||
from .config import APIError,directory
|
||||
|
||||
|
||||
@contextmanager
|
||||
def database(path, *, readonly=False):
|
||||
path=Path(path).absolute()
|
||||
for part in [*path.parents,path]:
|
||||
if part.is_symlink() or (part.exists() and getattr(part.lstat(),'st_file_attributes',0)&0x400):
|
||||
raise APIError('AUTH_UNSAFE_STORAGE',500)
|
||||
connection=sqlite3.connect(path.as_uri()+'?mode=ro' if readonly else str(path),uri=readonly,timeout=5)
|
||||
connection.row_factory=sqlite3.Row
|
||||
connection.execute('PRAGMA foreign_keys=ON')
|
||||
try:
|
||||
with connection:
|
||||
yield connection
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
|
||||
def password_hash(password,salt):
|
||||
return hashlib.scrypt(password.encode('utf-8'),salt=salt,n=32768,r=8,p=1,maxmem=64*1024*1024).hex()
|
||||
|
||||
|
||||
class Users:
|
||||
def __init__(self,path):
|
||||
self.path=Path(path)
|
||||
|
||||
def get(self,name):
|
||||
if not self.path.exists():return None
|
||||
with database(self.path,readonly=True) as db:
|
||||
row=db.execute('SELECT * FROM users WHERE name=? COLLATE NOCASE',(name,)).fetchone()
|
||||
return dict(row) if row else None
|
||||
|
||||
def verify(self,name,password):
|
||||
row=self.get(name)
|
||||
actual=password_hash(password,bytes.fromhex(row['salt']) if row else b'\0'*16)
|
||||
return row if row and hmac.compare_digest(actual,row['password_hash']) else None
|
||||
|
||||
def manage(self,action,name,password=None,old_password=None):
|
||||
if not isinstance(name,str) or not re.fullmatch(r'[A-Za-z0-9][A-Za-z0-9_.-]{0,63}',name):
|
||||
raise APIError('AUTH_USER_INVALID',400)
|
||||
if action!='delete_user' and (not isinstance(password,str) or not 1<=len(password)<=1024):
|
||||
raise APIError('AUTH_PASSWORD_INVALID',400)
|
||||
if action=='change_pw_user' and (not isinstance(old_password,str) or len(old_password)>1024):
|
||||
raise APIError('AUTH_PASSWORD_INVALID',400)
|
||||
directory(self.path.parent)
|
||||
if not self.path.exists():
|
||||
descriptor=os.open(self.path,os.O_CREAT|os.O_EXCL|os.O_WRONLY,0o640);os.close(descriptor)
|
||||
with database(self.path) as db:
|
||||
db.execute('CREATE TABLE IF NOT EXISTS users (name TEXT PRIMARY KEY COLLATE NOCASE, salt TEXT NOT NULL, password_hash TEXT NOT NULL, version TEXT NOT NULL)')
|
||||
db.execute('BEGIN IMMEDIATE')
|
||||
row=db.execute('SELECT * FROM users WHERE name=? COLLATE NOCASE',(name,)).fetchone()
|
||||
if action=='create_user' and row:raise APIError('AUTH_USER_EXISTS',409)
|
||||
if action!='create_user' and not row:raise APIError('AUTH_USER_NOT_FOUND',404)
|
||||
if action=='delete_user':
|
||||
db.execute('DELETE FROM users WHERE name=? COLLATE NOCASE',(name,));return
|
||||
if action=='change_pw_user' and not hmac.compare_digest(password_hash(old_password,bytes.fromhex(row['salt'])),row['password_hash']):
|
||||
raise APIError('AUTH_OLD_PASSWORD',403)
|
||||
salt=secrets.token_bytes(16)
|
||||
digest=password_hash(password,salt)
|
||||
if action=='create_user':
|
||||
db.execute('INSERT INTO users VALUES (?,?,?,?)',(name,salt.hex(),digest,secrets.token_hex(16)))
|
||||
else:
|
||||
db.execute('UPDATE users SET salt=?,password_hash=?,version=? WHERE name=? COLLATE NOCASE',
|
||||
(salt.hex(),digest,secrets.token_hex(16),name))
|
||||
|
||||
|
||||
def require_privileged():
|
||||
if os.name=='nt':
|
||||
import ctypes
|
||||
allowed=bool(ctypes.windll.shell32.IsUserAnAdmin())
|
||||
else:
|
||||
allowed=os.geteuid()==0
|
||||
if not allowed:raise APIError('AUTH_PRIVILEGED_REQUIRED',403)
|
||||
@@ -39,8 +39,8 @@ function renderArchive(){
|
||||
function clearDetails(){selectedCase=null;detailHash=null;detailLoading=false;$('result-search').value='';}
|
||||
async function downloadCase(data,button){
|
||||
const generation=session,credential=token;button.disabled=true;error();
|
||||
try{const response=await fetch(`/v1/cases/${data.key}/package`,{headers:{Authorization:`Bearer ${credential}`},cache:'no-store'});
|
||||
if(!response.ok){const issue=await response.json();if(response.status===401&&credential===token)logout();throw new Error(issue.code||`HTTP ${response.status}`);}
|
||||
try{const response=await fetch(`/v1/cases/${data.key}/package`,{credentials:'same-origin',cache:'no-store'});
|
||||
if(!response.ok){const issue=await response.json();if(response.status===401&&credential===token&&generation===session)logout(false);throw new Error(issue.code||`HTTP ${response.status}`);}
|
||||
const blob=await response.blob();if(generation!==session)return;
|
||||
const url=URL.createObjectURL(blob),link=document.createElement('a');link.href=url;link.download=`${data.case_id.replace(/[^A-Za-z0-9_.-]/g,'_')}-Fallakte.zip`;document.body.append(link);link.click();link.remove();setTimeout(()=>URL.revokeObjectURL(url),30000);
|
||||
}catch(e){if(generation===session)error(`Download fehlgeschlagen: ${e.message}`);}finally{button.disabled=false;}
|
||||
@@ -110,7 +110,7 @@ function route(){
|
||||
if(typeof testRoute==='function'&&testRoute())return;
|
||||
if($('test-page'))$('test-page').hidden=true;
|
||||
const detail=location.hash.startsWith('#case/'),results=location.hash==='#results',submissionPage=location.hash==='#submit';
|
||||
$('overview-page').hidden=detail||results||submissionPage;$('results').hidden=!results;$('case-detail').hidden=!detail;$('submit').hidden=detail||results;
|
||||
$('overview-page').hidden=detail||results||submissionPage;$('results').hidden=!results;$('case-detail').hidden=!detail;$('submit').hidden=!submissionPage;
|
||||
document.querySelector('header h1').textContent=detail?'Die Fallakte.':results?'Abgeschlossene Fälle.':submissionPage?'Auftrag übergeben.':'Analyse im Blick.';
|
||||
document.querySelectorAll('nav a').forEach(a=>a.classList.toggle('selected',detail?a.hash==='#cases':a.hash===(results?'#results':submissionPage?'#submit':location.hash==='#cases'?'#cases':'#overview')));
|
||||
if(detail)loadDetail(location.hash);if(results)renderArchive();
|
||||
|
||||
@@ -5,13 +5,14 @@ const names = {NEW:'Eingang',VALIDATED:'Angenommen',BUILDING_CASE:'Fallaufbau',I
|
||||
const finished = new Set(['OUTPUT_READY','FAILED_TO_ANALYZE','HANDED_OFF']);
|
||||
const number = n => new Intl.NumberFormat('de-DE').format(n);
|
||||
let token = '', timer = null, overview = null, projects = [], sending = false, submission = null, session = 0;
|
||||
let csrfToken='',logoutPending=Promise.resolve(),loginBusy=false;
|
||||
const text = (tag, value, cls) => { const e=document.createElement(tag); e.textContent=value; if(cls)e.className=cls; return e; };
|
||||
function error(message='') { $('error').hidden=!message; $('error').textContent=message; }
|
||||
async function api(path, options={}) {
|
||||
const credential=token;
|
||||
const response=await fetch(path,{...options,headers:{...options.headers,Authorization:`Bearer ${credential}`},cache:'no-store'});
|
||||
const credential=token,generation=session;
|
||||
const response=await fetch(path,{...options,headers:{...options.headers,...(csrfToken?{'X-CSRF-Token':csrfToken}:{})},credentials:'same-origin',cache:'no-store'});
|
||||
const data=await response.json();
|
||||
if(!response.ok) { if(response.status===401 && credential===token) logout(); throw new Error(data.code || `HTTP ${response.status}`); }
|
||||
if(!response.ok) { if(response.status===401 && credential===token&&generation===session&&path!=='/v1/auth/login') logout(false); throw new Error(data.code || `HTTP ${response.status}`); }
|
||||
return data;
|
||||
}
|
||||
function progress(value, label) {
|
||||
@@ -19,8 +20,9 @@ function progress(value, label) {
|
||||
outer.setAttribute('aria-label',label); outer.setAttribute('aria-valuemin','0'); outer.setAttribute('aria-valuemax','100'); outer.setAttribute('aria-valuenow',String(value));
|
||||
const inner=text('div','','progress-fill'); inner.style.width=`${value}%`; outer.append(inner); return outer;
|
||||
}
|
||||
function logout() {
|
||||
session++;token=''; clearTimeout(timer); $('token').value=''; $('content').hidden=true; $('login').hidden=false;
|
||||
function logout(remote=true) {
|
||||
if(remote&&token)logoutPending=fetch('/v1/auth/logout',{method:'POST',headers:{'X-CSRF-Token':csrfToken},credentials:'same-origin'}).catch(()=>{});
|
||||
session++;token='';csrfToken=''; clearTimeout(timer); $('password').value=''; $('content').hidden=true; $('login').hidden=false;
|
||||
$('logout').hidden=true; $('connection').textContent='Nicht verbunden'; document.body.classList.remove('connected');
|
||||
$('updated').textContent='Warte auf Verbindung'; overview=null;projects=[];submission=null;
|
||||
$('submit-form').reset();$('receipt').textContent='';$('new-files').hidden=false;$('return-files').hidden=true;
|
||||
@@ -113,16 +115,24 @@ async function refresh() {
|
||||
} catch(e) {if(generation===session)error(`Aktualisierung fehlgeschlagen: ${e.message}`);}
|
||||
if(token && generation===session)timer=setTimeout(refresh,4000);
|
||||
}
|
||||
$('login-form').addEventListener('submit',async e=>{
|
||||
e.preventDefault();const generation=++session;token=$('token').value.trim();error();
|
||||
try {const response=await api('/v1/profiles');if(generation!==session)return;projects=response.projects;
|
||||
async function connected(info,generation){
|
||||
if(generation!==session)return;token='session';csrfToken=info.csrf;
|
||||
const response=await api('/v1/profiles');if(generation!==session)return;projects=response.projects;
|
||||
$('project').replaceChildren();$('project-filter').replaceChildren(new Option('Alle freigegebenen Projekte',''));$('result-project').replaceChildren(new Option('Alle freigegebenen Projekte',''));
|
||||
projects.forEach(p=>{$('project').add(new Option(p.project_id,p.project_id));$('project-filter').add(new Option(p.project_id,p.project_id));$('result-project').add(new Option(p.project_id,p.project_id));});
|
||||
$('login').hidden=true;$('content').hidden=false;$('logout').hidden=false;$('token').value='';
|
||||
$('connection').textContent='Verbunden';document.body.classList.add('connected');if(typeof route==='function')route();await refresh();
|
||||
} catch(e) {if(generation===session){token='';error(`Verbindung fehlgeschlagen: ${e.message}`);}else if(!token)error('Verbindung fehlgeschlagen: API-Zugangsschlüssel prüfen.');}
|
||||
$('login').hidden=true;$('content').hidden=false;$('logout').hidden=false;$('password').value='';
|
||||
$('connection').textContent=info.username;document.body.classList.add('connected');if(typeof route==='function')route();await refresh();
|
||||
}
|
||||
$('login-form').addEventListener('submit',async e=>{
|
||||
e.preventDefault();if(loginBusy)return;loginBusy=true;const generation=++session;error();
|
||||
const password=$('password').value;$('password').value='';
|
||||
try{await logoutPending;const info=await api('/v1/auth/login',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({username:$('username').value,password})});await connected(info,generation);
|
||||
}catch(e){if(generation===session){token='';error(`Anmeldung fehlgeschlagen: ${e.message}`);}}finally{loginBusy=false;}
|
||||
});
|
||||
$('logout').addEventListener('click',logout);$('project-filter').addEventListener('change',render);
|
||||
$('logout').addEventListener('click',()=>logout());$('project-filter').addEventListener('change',render);
|
||||
fetch('/v1/auth/session',{credentials:'same-origin',cache:'no-store'}).then(async response=>{
|
||||
if(response.ok&&session===0){const info=await response.json();await connected(info,0);}
|
||||
}).catch(()=>{});
|
||||
$('kind').addEventListener('change',()=>{$('new-files').hidden=$('kind').value!=='new_case';$('return-files').hidden=$('kind').value!=='case_return';});
|
||||
$('submit-form').addEventListener('submit',async e=>{
|
||||
e.preventDefault();if(sending)return;error();
|
||||
|
||||
@@ -10,8 +10,7 @@
|
||||
<div class="rail-bottom">ANALYSEBETRIEB<br><span id="connection">Nicht verbunden</span><button id="logout" type="button" hidden>Abmelden</button></div></aside>
|
||||
<main id="overview">
|
||||
<header><div><div class="eyebrow">BETRIEBSÜBERSICHT</div><h1>Analyse im Blick.</h1><p>Aktuelle Arbeit und messbarer Verbrauch.</p></div><div class="live"><span class="dot"></span><span id="updated">Warte auf Verbindung</span></div></header>
|
||||
<section class="login panel" id="login"><div><h2>Mit BugAnalysis verbinden</h2><p>API-Zugangsschlüssel eingeben. Er bleibt nur im Arbeitsspeicher dieses Tabs.</p></div>
|
||||
<form id="login-form"><label for="token">API-Zugangsschlüssel</label><div class="inline"><input id="token" type="password" autocomplete="off" required placeholder="Zugangsschlüssel"><button type="submit">Verbinden</button></div></form></section>
|
||||
<section class="login panel" id="login"><div><h2>Bei BugAnalysis anmelden</h2><p>Mit Benutzername und Passwort anmelden.</p></div><form id="login-form"><label>Benutzername<input id="username" autocomplete="username" required maxlength="64"></label><label>Passwort<input id="password" type="password" autocomplete="current-password" required maxlength="1024"></label><button type="submit">Anmelden</button></form></section>
|
||||
<p id="error" class="error" role="alert" hidden></p>
|
||||
<div id="content" hidden>
|
||||
<section class="panel" id="test-page" hidden><h2>Offene Testanfragen</h2><p class="hint">Beobachtungen ergänzen und die laufende Untersuchung fortsetzen.</p><div class="detail-controls"><label>Projekt<select id="test-project"></select></label><label>Sortierung<select id="test-sort"><option value="oldest">Älteste zuerst</option><option value="newest">Neueste zuerst</option><option value="project">Projekt, dann älteste zuerst</option></select></label></div><div id="test-list"></div><div id="test-answer"></div></section><div id="overview-page">
|
||||
@@ -35,7 +34,7 @@
|
||||
<div class="form-grid"><label>Projekt<select id="result-project"><option value="">Alle freigegebenen Projekte</option></select></label><label>Fall suchen<input id="result-search" type="search" placeholder="Bug-ID oder Fallname"></label></div>
|
||||
<div class="table-wrap"><table><thead><tr><th>Fall</th><th>Projekt</th><th>Ergebnis</th><th>Revision</th><th>Aktualisiert</th></tr></thead><tbody id="result-rows"></tbody></table></div></section>
|
||||
<section id="case-detail" aria-live="polite" hidden></section>
|
||||
<section class="panel" id="submit"><div class="section-head"><div><div class="eyebrow">EINGANG</div><h2>Auftrag übergeben</h2><p>BugAnalysis erstellt und ergänzt die Fallakte.</p></div></div>
|
||||
<section class="panel" id="submit" hidden><div class="section-head"><div><div class="eyebrow">EINGANG</div><h2>Auftrag übergeben</h2><p>BugAnalysis erstellt und ergänzt die Fallakte.</p></div></div>
|
||||
<form id="submit-form"><div class="form-grid"><label>Auftragstyp<select id="kind"><option value="new_case">Neue Analyse</option><option value="case_return">Abgeschlossene Akte zurückgeben</option></select></label>
|
||||
<label>Projekt<select id="project" required></select></label><label>Bug-ID<input id="case-id" required placeholder="BUG-000020" pattern="[A-Za-z0-9][A-Za-z0-9_.-]{0,127}"></label><label>Fallname / Rückmeldung<input id="title" required maxlength="240"></label></div>
|
||||
<label>Beobachtungen (eine pro Zeile)<textarea id="observations" required rows="3"></textarea></label>
|
||||
|
||||
@@ -36,6 +36,16 @@ function openTest(row){
|
||||
for(const [field,label] of [['environment','Angeforderte Umgebung'],['prerequisites','Voraussetzungen'],['steps','Testschritte'],['requested_observations','Benötigte Beobachtungen']])root.append(panel(label,semantic(request[field])));
|
||||
root.append(text('p',`Angefragter Termin: ${new Date(request.deadline).toLocaleString('de-DE')}. Ein abgelaufener Termin ersetzt keine Antwort.`,'hint'));
|
||||
if(!testData.can_answer){root.append(text('p','Zum Beantworten werden Tester- oder Betreiberrechte benötigt.','hint'));return;}
|
||||
const edit=text('button','Bearbeiten');edit.type='button';root.append(edit);
|
||||
edit.addEventListener('click',async()=>{edit.disabled=true;const generation=session;
|
||||
try{await api('/v1/test-requests/'+row.key+'/claim',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({action:'edit',request_id:request.request_id,analysis_revision:request.analysis_revision})});
|
||||
if(generation!==session||answerKey!==row.key)return;edit.remove();editTest(row,root);
|
||||
}catch(e){if(generation===session)error(e.message==='API_TEST_CLAIMED'?'Diese Anfrage wird bereits in einer anderen Sitzung bearbeitet.':`Bearbeitung nicht möglich: ${e.message}`);}finally{edit.disabled=false;}});
|
||||
root.scrollIntoView({behavior:'smooth',block:'start'});
|
||||
}
|
||||
function editTest(row,root){
|
||||
const request=row.request;
|
||||
root.append(text('p','Die Bearbeitung ist für deine aktuelle Sitzung reserviert.','hint'));
|
||||
const form=document.createElement('form');form.className='semantic-list';
|
||||
function field(label,element){const l=text('label',label);l.append(element);form.append(l);return element;}
|
||||
const outcome=document.createElement('select');outcome.add(new Option('Test durchgeführt','completed'));outcome.add(new Option('Nicht durchführbar','unable_to_test'));field('Ergebnis',outcome);
|
||||
@@ -43,6 +53,10 @@ function openTest(row){
|
||||
const observations=field('Beobachtungen bzw. Grund, warum der Test nicht möglich ist',document.createElement('textarea'));observations.required=true;observations.rows=7;observations.maxLength=12000;
|
||||
const confirm=document.createElement('input');confirm.type='checkbox';confirm.required=true;field('Ich bestätige diese Angaben als meine Test-Rückmeldung.',confirm);
|
||||
const button=text('button','Antwort bestätigen und Untersuchung fortsetzen');button.type='submit';form.append(button);root.append(form);
|
||||
const release=text('button','Bearbeitung freigeben');release.type='button';form.append(release);
|
||||
release.addEventListener('click',async()=>{const generation=session;release.disabled=true;
|
||||
try{await api('/v1/test-requests/'+row.key+'/claim',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({action:'release',request_id:request.request_id,analysis_revision:request.analysis_revision})});if(generation===session)openTest(row);
|
||||
}catch(e){if(generation===session)error(e.message);}finally{release.disabled=false;}});
|
||||
form.addEventListener('submit',async event=>{event.preventDefault();if(answerBusy)return;const generation=session;answerBusy=true;button.disabled=true;
|
||||
try{await api('/v1/test-requests/'+row.key,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({request_id:request.request_id,analysis_revision:request.analysis_revision,outcome:outcome.value,environment:environment.value,observations:[observations.value],confirmed:confirm.checked})});
|
||||
if(generation!==session)return;root.replaceChildren(text('p','Antwort gespeichert. Der Worker kann die Untersuchung fortsetzen.'));answerKey=null;await refreshTests();
|
||||
|
||||
@@ -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