Files
Deploymentcenter/tests/e2e/suite_dienste.py
T
Deploymentcenter BotandClaude Opus 5 91d8bf353e fix(2.9.3): Der zweite Faktor war neben der Zeremonie umgehbar
Ergebnis einer beauftragten Sicherheitsbegutachtung. Die WebAuthn-
Zeremonie selbst ist nicht angefasst worden - sie war in Ordnung. Alle
Befunde lagen DANEBEN:

  Ein zweiter Faktor ist nur so stark wie der schwaechste Weg zum Konto,
  und Sitzungen, die aus ihm entstehen, erben seinen Schutz nicht.

KRITISCH
/api/setup/v1/login prueste auf totp_secret - eine Spalte, die kein
Anmeldeweg auswertet - und nicht auf dc_user_credentials. Ein
Administratorpasswort genuegte dort fuer ein setup:install-Token ohne
Hardwarekey. Neu: Konten mit zweitem Faktor abgewiesen, und die
Rollentrennung gilt symmetrisch. Der Docblock behauptete sie seit jeher;
erzwungen hat sie nichts.

HOCH
secondFactorRequired(), accountLockoutSeconds() und lockoutSeconds()
fingen jedes Throwable und fielen auf "kein Faktor" bzw. "nicht
gesperrt" zurueck. Gefangen wurden damit auch Verbindungsabbrueche und
Lock-Timeouts - jeder davon schaltete still fuer alle Konten auf
Passwort-allein. Toleriert werden jetzt nur noch 42S02 und 42S22.

HOCH
Fuenf Aktionen aenderten die Anmeldemittel ohne erneute Bestaetigung.
Eine uebernommene Sitzung konnte sich damit dauerhaft einnisten. Neu:
Authentifizierung aus den letzten zehn Minuten noetig, geprueft an
derselben Stelle, an der auch der Leerlauf sperrt.

WEITER
- Leerlauf 30 statt 120 Minuten, und GESPERRT statt beendet: AAL2 laesst
  zur Reaktivierung einen Faktor genuegen. Wer alle 30 Minuten den
  Hardwarekey vorzeigen muss, laesst ihn stecken.
- Migration 022: session_epoch. Ein Passwortwechsel beendet die uebrigen
  Sitzungen des Kontos, die eigene bleibt.
- Kontosperre wirkt auf der zweiten Stufe; Zaehler gegen unbegrenztes
  Raten im Zwischenzustand.
- Drosselung auf login.php (10/60s) und /api/auth/v1/ (30/60s).
- Anmeldefehler ununterscheidbar; die IP-Sperre darf benannt bleiben,
  sie sagt nichts ueber ein Konto.
- HSTS, TLS-Zwang VOR allen Routen, CSP. Die CSP traegt wegen 93
  Inline-Handlern nicht gegen XSS - das steht so im Kommentar.
- session.use_strict_mode, CSRF-Token bei der Anmeldung verworfen,
  Secure-Flag aus app.url statt aus $_SERVER['HTTPS'].
- rpId/expectedOrigin nicht mehr aus dem Host-Header. /api/health meldet
  unter checks.webauthn_anchor, ob der Anker steht.
- Notfalltoken laeuft nach 180 Tagen ab, geprueft nach dem Hashvergleich.
- config.example.php ohne echtes Passwort, verify_ssl auf true.
- Serverdaten.txt.bak mitgesperrt, dc_login_attempts raeumt auf.

BELEGT
188 von 188 Testfaellen gruen, darunter drei neue in der Gruppe
"haertung". Die Blockade einer veralteten Sitzung liess sich nicht in
der Suite pruefen - sie verlangt, das Zehn-Minuten-Fenster wirklich
abzuwarten. Dafuer gibt es ein Werkzeug ausserhalb; es belegt, dass der
Formularweg auf den Sperrbildschirm fuehrt, die Regel dabei unveraendert
bleibt, der JSON-Weg reauth_required meldet und das Passwort allein
wieder oeffnet.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-21 14:58:31 +02:00

681 lines
25 KiB
Python

#!/usr/bin/env python3
"""
Lizenzen, Watchdog, Updateservice, Erstinstallation, Supervisor.
Die Reihenfolge innerhalb der Gruppen folgt dem Lebenslauf einer Auslieferung:
Lizenz holen, pruefen, Rechner meldet sich, Release veroeffentlichen, Client
findet das Update, ein Beobachter meldet eine Auffaelligkeit.
"""
import time
from harness import (test, erwarte, erwarte_status, erwarte_erfolg,
erwarte_fehler, ueberspringen)
# Aus sql/schema.sql. Ein Projekt, das es nach dem Zuruecksetzen sicher gibt.
PROJEKT = 'myapp'
MASCHINE = 'e2e-testrunner'
BUILD = '9.9.9-test'
# ---------------------------------------------------------------------------
# Lizenzen
# ---------------------------------------------------------------------------
@test('lizenz', 'Statusauskunft ohne Token')
def _(ctx):
"""
Der Endpunkt sagt Clients, welche Wege es gibt - das darf offen sein.
Er antwortet mit status="ok" statt "success". Das ist eine Abweichung vom
Umschlag der uebrigen API und gehoert zum bereits ausgelieferten
Lizenzvertrag; hier wird deshalb nur geprueft, dass die Auskunft kommt.
"""
r = ctx.client.get('/api/license/v1/status', token=None)
erwarte_status(r, 200)
erwarte(r.feld('status') in ('ok', 'success'),
"Unerwarteter Zustand: %s" % r.kurz(200))
erwarte(r.feld('module') == 'license', "module fehlt: %s" % r.kurz(200))
@test('lizenz', 'Entwicklungsschluessel wird ausgestellt')
def _(ctx):
r = ctx.client.post('/api/license/v1/dev-key', body={
'project_slug': PROJEKT,
'instance_id': MASCHINE,
'ttl_days': 1,
})
erwarte_erfolg(r)
schluessel = r.feld('license_key')
erwarte(bool(schluessel), "Kein license_key in der Antwort: %s" % r.kurz(300))
ctx['dev_key'] = schluessel
@test('lizenz', 'Wiederholte Anforderung liefert denselben Schluessel')
def _(ctx):
"""
Sonst sammelt jede Maschine bei jedem Testlauf einen weiteren Schluessel
ein, bis das Kontingent des Projekts voll ist.
"""
r = ctx.client.post('/api/license/v1/dev-key', body={
'project_slug': PROJEKT, 'instance_id': MASCHINE, 'ttl_days': 1,
})
erwarte_erfolg(r)
erwarte(r.feld('license_key') == ctx['dev_key'],
"Zweiter Aufruf liefert einen anderen Schluessel")
@test('lizenz', 'Ohne Maschinenkennung zaehlt die Kennung des Tokens')
def _(ctx):
"""
Fehlt instance_id, faellt der Endpunkt auf owner_identity des Tokens
zurueck. Das ist beabsichtigt - ein Werkbank-Token ist selbst schon an
eine Maschine gebunden. Geprueft wird, dass daraus keine zweite,
anonyme Zuteilung entsteht.
"""
r = ctx.client.post('/api/license/v1/dev-key', body={'project_slug': PROJEKT})
erwarte_erfolg(r)
erwarte(bool(r.feld('instance_id')),
"Schluessel ohne jede Maschinenkennung ausgestellt: %s" % r.kurz(300))
@test('lizenz', 'Entwicklungsschluessel fuer unbekanntes Projekt abgewiesen')
def _(ctx):
r = ctx.client.post('/api/license/v1/dev-key', body={
'project_slug': 'projekt-das-es-nicht-gibt', 'instance_id': MASCHINE,
})
erwarte_fehler(r, http=(403, 404, 409))
@test('lizenz', 'Entwicklungsschluessel ohne Recht abgewiesen')
def _(ctx):
"""license:dev gehoert auf ein Werkbank-Token, nicht auf jedes Token."""
r = ctx.client.post('/api/license/v1/dev-key', token=ctx['sub_token'],
body={'project_slug': PROJEKT, 'instance_id': MASCHINE})
erwarte_fehler(r, http=(401, 403))
@test('lizenz', 'Schluessel laesst sich pruefen')
def _(ctx):
r = ctx.client.post('/api/license/v1/validate', token=None, body={
'product': PROJEKT,
'license_key': ctx['dev_key'],
'hardware_id': 'E2E-HWID-0001',
'hostname': MASCHINE,
'app_version': BUILD,
'platform': 'windows',
})
erwarte_status(r, 200)
# Dieser Endpunkt liefert bewusst nicht den JSON-Umschlag der uebrigen
# API, sondern das signierte Ergebnisobjekt, das ausgelieferte Clients
# zwischenspeichern: type=validation_result mit status=valid.
erwarte(r.feld('type') == 'validation_result',
"Unerwartetes Antwortformat: %s" % r.kurz(300))
erwarte(r.feld('status') == 'valid',
"Der eigene Schluessel gilt nicht: %s" % r.kurz(400))
erwarte(r.feld('cache_ttl_hours') is not None,
"Ohne cache_ttl_hours weiss ein Client nicht, wie lange er offline gilt")
ctx['hwid'] = 'E2E-HWID-0001'
@test('lizenz', 'Erfundener Schluessel wird abgelehnt')
def _(ctx):
r = ctx.client.post('/api/license/v1/validate', token=None, body={
'product': PROJEKT,
'license_key': 'XXXXX-XXXXX-XXXXX-XXXXX-XXXXX',
'hardware_id': 'E2E-HWID-0001',
})
erwarte(r.feld('status') != 'valid',
"Ein erfundener Schluessel wurde akzeptiert: %s" % r.kurz(300))
@test('lizenz', 'Pruefung ohne Schluessel ergibt niemals gueltig')
def _(ctx):
"""
Der Endpunkt antwortet auch bei fehlenden Feldern mit 200 und dem
Ergebnisobjekt - ein ausgelieferter Client soll an einer Fehlermeldung
nicht in einen anderen Codepfad laufen. Entscheidend ist nur, dass
daraus nie "valid" wird.
"""
r = ctx.client.post('/api/license/v1/validate', token=None,
body={'product': PROJEKT})
erwarte_status(r, 200, 400)
erwarte(r.feld('status') != 'valid',
"Pruefung ohne Schluessel ergab gueltig: %s" % r.kurz(300))
@test('lizenz', 'Aktivierung laesst sich zuruecknehmen')
def _(ctx):
r = ctx.client.post('/api/license/v1/deactivate', body={
'product': PROJEKT,
'license_key': ctx['dev_key'],
'hardware_id': ctx['hwid'],
})
erwarte_status(r, 200, 404)
erwarte(r.json is not None, "Keine JSON-Antwort: %s" % r.kurz(200))
@test('lizenz', 'Entwicklungsschluessel laesst sich zurueckgeben')
def _(ctx):
r = ctx.client.post('/api/license/v1/dev-key', body={
'project_slug': PROJEKT, 'instance_id': MASCHINE, 'action_mode': 'revoke',
})
erwarte_erfolg(r)
erwarte(r.feld('revoked') is True, "Nichts zurueckgenommen: %s" % r.kurz(200))
# ---------------------------------------------------------------------------
# Watchdog
# ---------------------------------------------------------------------------
@test('watchdog', 'Lebenszeichen wird angenommen')
def _(ctx):
r = ctx.client.post('/api/watchdog/v1/ping', body={
'source': 'e2e-runner',
'instance': MASCHINE,
'type': 'service',
'status': 'ok',
'version': BUILD,
'interval': 60,
'message': 'Automatischer Testlauf',
'metrics': {'cpu': 3.5, 'ram_mb': 128},
'os': 'windows',
})
erwarte_erfolg(r)
@test('watchdog', 'Lebenszeichen traegt das Projekt seines Tokens')
def _(ctx):
"""
BEFUND #404: watchdog_monitors.project_slug gibt es seit Migration 014,
geschrieben hat es der Ping nie. Der Monitor entstand mit project_slug
NULL, und jeder daraus abgeleitete Supervisor-Befund fiel auf das
Scheinprojekt "default" zurueck.
Wirkung: dc_findings(project_slug="...") lieferte fuer eine laufende,
meldende Instanz null Befunde. Ein Agent, der dem Leitfaden folgt, schloss
daraus "nichts auffaellig" - waehrend ein deviation.self-Befund mit elf
Vorkommnissen bereitstand, nur unter einem Projekt, das es nicht gibt.
Die Zuordnung war die ganze Zeit bekannt: Das Ping-Token ist an ein
Projekt gebunden, und authorizeSource() liest die Bindung ohnehin - sie
wurde nur nicht weitergereicht.
Der bestehende Ping-Testfall benutzt das ungebundene Master-Token des
Testlaufs und konnte den Fehler deshalb nicht sehen.
"""
r = ctx.testctl('workbench_token', project_slug='myapp',
name='e2e-ping-gebunden',
scopes=['watchdog:ping', 'watchdog:read'])
erwarte_erfolg(r)
token = r.feld('raw_token')
erwarte(token, 'Kein Token in der Antwort: %s' % r.kurz(300))
r = ctx.client.post('/api/watchdog/v1/ping', token=token, body={
'source': 'e2e-gebunden',
'instance': MASCHINE,
'type': 'service',
'status': 'ok',
'interval': 60,
'message': 'Automatischer Testlauf: Projektbindung',
})
erwarte_erfolg(r)
erwarte(r.feld('monitor', 'project_slug') == 'myapp',
'Der Monitor traegt project_slug %r statt "myapp"'
% r.feld('monitor', 'project_slug'))
@test('watchdog', 'Ping ohne Bindung loescht ein bekanntes Projekt nicht')
def _(ctx):
"""
Die Gegenprobe. Nicht jeder Ping kommt mit einem projektgebundenen Token -
der Shared Key und die aelteren Agent-Tokens aus watchdog_agent_tokens
kennen kein Projekt. So einer darf eine einmal bekannte Zuordnung nicht
wieder auf NULL setzen, sonst haengt es vom Zufall ab, welcher Ping
zuletzt kam.
"""
r = ctx.client.post('/api/watchdog/v1/ping', body={
'source': 'e2e-gebunden',
'instance': MASCHINE,
'type': 'service',
'status': 'ok',
'interval': 60,
})
erwarte_erfolg(r)
erwarte(r.feld('monitor', 'project_slug') == 'myapp',
'Ein ungebundener Ping hat die Zuordnung ueberschrieben: %r'
% r.feld('monitor', 'project_slug'))
@test('watchdog', 'Lebenszeichen ohne Quelle abgewiesen')
def _(ctx):
r = ctx.client.post('/api/watchdog/v1/ping', body={'instance': MASCHINE})
erwarte_fehler(r, http=400)
@test('watchdog', 'Lebenszeichen ohne Token abgewiesen')
def _(ctx):
r = ctx.client.post('/api/watchdog/v1/ping', token=None, body={'source': 'e2e-runner'})
erwarte_fehler(r, http=401)
@test('watchdog', 'Ereignis wird angenommen')
def _(ctx):
r = ctx.client.post('/api/watchdog/v1/event', body={
'source': 'e2e-runner',
'instance': MASCHINE,
'kind': 'test',
'severity': 'info',
'message': 'Automatischer Testlauf: Beispielereignis',
'meta': {'lauf': int(time.time())},
})
erwarte_erfolg(r)
@test('watchdog', 'Zustand enthaelt den gemeldeten Rechner')
def _(ctx):
r = ctx.client.get('/api/watchdog/v1/status')
erwarte_erfolg(r)
erwarte('e2e-runner' in (r.body or ''),
"Der eben gemeldete Monitor fehlt im Zustand")
@test('watchdog', 'Ereignisliste abrufbar')
def _(ctx):
r = ctx.client.get('/api/watchdog/v1/events', query={'limit': 10, 'source': 'e2e-runner'})
erwarte_erfolg(r)
@test('watchdog', 'Kennzahlen abrufbar')
def _(ctx):
r = ctx.client.get('/api/watchdog/v1/metrics',
query={'source': 'e2e-runner', 'metric': 'cpu', 'hours': 24})
erwarte_erfolg(r)
@test('watchdog', 'Auswertung laeuft durch')
def _(ctx):
"""
Der Lauf, den sonst der Cron ausloest. Er entscheidet ueber Alarme -
faellt er auf die Nase, merkt das ohne diesen Test niemand, weil ein
stiller Watchdog wie ein zufriedener Watchdog aussieht.
"""
r = ctx.client.get('/api/watchdog/v1/evaluate')
erwarte_erfolg(r)
@test('watchdog', 'Auswertung mit Shared Key erreichbar')
def _(ctx):
"""Der Cron-Eintrag der Anleitung nutzt genau diesen Weg."""
r = ctx.client.get('/api/watchdog/v1/evaluate', token=ctx.cfg['keys']['shared_key'])
erwarte_erfolg(r)
# ---------------------------------------------------------------------------
# Updateservice
# ---------------------------------------------------------------------------
@test('update', 'Signierschluessel vorhanden')
def _(ctx):
"""Ohne Schluessel kann die Testinstanz keine Releases signieren."""
r = ctx.testctl('genkey')
erwarte_erfolg(r)
@test('update', 'Release wird veroeffentlicht')
def _(ctx):
version = '9.9.%d' % (int(time.time()) % 1000)
r = ctx.client.post('/api/updateservice/v1/publish', body={
'product_slug': PROJEKT,
'version': version,
'channel': 'stable',
'platform': 'windows',
'download_url': 'https://example.invalid/%s-%s.zip' % (PROJEKT, version),
'sha256_hash': 'a' * 64,
'size_bytes': 1024,
'release_notes': 'Automatischer Testlauf.',
'is_critical': False,
})
erwarte_erfolg(r)
ctx['release_version'] = version
@test('update', 'Veroeffentlichung ohne Pflichtfeld abgewiesen')
def _(ctx):
r = ctx.client.post('/api/updateservice/v1/publish',
body={'product_slug': PROJEKT, 'version': '1.0.0'})
erwarte_fehler(r, http=400)
@test('update', 'Veroeffentlichung ohne Recht abgewiesen')
def _(ctx):
r = ctx.client.post('/api/updateservice/v1/publish', token=ctx['sub_token'], body={
'product_slug': PROJEKT, 'version': '1.0.1',
'download_url': 'https://example.invalid/x.zip',
})
erwarte_fehler(r, http=(401, 403))
@test('update', 'Aelterer Client bekommt das Update angeboten')
def _(ctx):
r = ctx.client.get('/api/updateservice/v1/check', token=None, query={
'product': PROJEKT, 'version': '0.0.1', 'channel': 'stable', 'platform': 'windows',
})
erwarte_erfolg(r)
erwarte(ctx['release_version'] in (r.body or ''),
"Die neue Fassung %s wird nicht angeboten: %s" % (ctx['release_version'], r.kurz(300)))
@test('update', 'Aktueller Client bekommt kein Update angeboten')
def _(ctx):
"""
Die andere Haelfte derselben Frage. Ein Dienst, der immer "ja" sagt,
schickt jeden Client in eine Endlosschleife aus Updates.
"""
r = ctx.client.get('/api/updateservice/v1/check', token=None, query={
'product': PROJEKT, 'version': ctx['release_version'],
'channel': 'stable', 'platform': 'windows',
})
erwarte_erfolg(r)
text = (r.body or '').lower()
erwarte('"update_available":false' in text.replace(' ', '')
or '"available":false' in text.replace(' ', '')
or ctx['release_version'] in text,
"Antwort laesst nicht erkennen, dass kein Update noetig ist: %s" % r.kurz(300))
@test('update', 'Neueste Fassung abrufbar')
def _(ctx):
r = ctx.client.get('/api/updateservice/v1/latest', token=None,
query={'product': PROJEKT, 'channel': 'stable', 'platform': 'windows'})
erwarte_erfolg(r)
@test('update', 'Releaseliste abrufbar')
def _(ctx):
r = ctx.client.get('/api/updateservice/v1/releases', token=None,
query={'product': PROJEKT, 'limit': 5})
erwarte_erfolg(r)
@test('update', 'Oeffentlicher Signierschluessel abrufbar')
def _(ctx):
"""Clients pruefen damit die Signatur eines Releases."""
r = ctx.client.get('/api/updateservice/v1/pubkey', token=None)
erwarte_erfolg(r)
erwarte('PUBLIC KEY' in (r.body or ''), "Kein PEM in der Antwort: %s" % r.kurz(200))
@test('update', 'Unbekanntes Produkt liefert keine Fassung')
def _(ctx):
r = ctx.client.get('/api/updateservice/v1/latest', token=None,
query={'product': 'gibt-es-nicht', 'channel': 'stable'})
erwarte(not r.ok or not r.feld('version'),
"Fuer ein unbekanntes Produkt kam eine Fassung zurueck: %s" % r.kurz(300))
# ---------------------------------------------------------------------------
# Erstinstallation
# ---------------------------------------------------------------------------
def _installer(ctx):
"""Zugangsdaten des Installationskontos, oder ueberspringen."""
konto = ctx.get('installer')
if not konto or not konto.get('username'):
ueberspringen('Kein Installationskonto - Lauf ohne --reset oder vor Migration 012.')
return konto
@test('setup', 'Anmeldung liefert ein kurzlebiges Token')
def _(ctx):
"""
Bewusst mit dem INSTALLATIONSKONTO, nicht mit dem Administrator. Bis
2.9.3 nahm dieser Endpunkt jedes Konto an, und dieser Testfall bildete
das ab - er pruefte damit einen Weg, den es nicht geben sollte.
"""
konto = _installer(ctx)
r = ctx.client.post('/api/setup/v1/login', token=None, body={
'username': konto['username'],
'password': konto['password'],
'hostname': MASCHINE,
})
erwarte_erfolg(r)
token = r.feld('setup_token') or r.feld('token')
erwarte(bool(token), "Kein Token in der Antwort: %s" % r.kurz(300))
ctx['setup_token'] = token
ctx['setup_scopes'] = r.feld('scopes', default=[])
@test('setup', 'Setup-Token ist befristet und eng gefasst')
def _(ctx):
"""
Es liegt auf einem fremden Zielsystem im Speicher. Ein unbefristetes
Vollzugriffstoken an dieser Stelle waere der teuerste Fehler der ganzen
Kette.
"""
scopes = ctx['setup_scopes']
erwarte('*' not in scopes, "Das Setup-Token hat Vollzugriff: %s" % scopes)
erwarte(all(s.startswith(('setup:', 'updateservice:')) for s in scopes),
"Unerwartete Rechte am Setup-Token: %s" % scopes)
@test('setup', 'Ein Administratorkonto kommt hier nicht herein')
def _(ctx):
"""
Die Rollentrennung gilt seit 2.9.3 in beide Richtungen: Installer nicht
ins WebUI, Administratoren nicht in die Setup-API. Wer auf jedem
Zielsystem sein Administratorpasswort eingibt, verteilt den Zugang zur
gesamten Verwaltung - und bis 2.9.3 bekam er dafuer auch noch ein Token,
ohne den hinterlegten Hardwarekey vorzeigen zu muessen.
"""
r = ctx.client.post('/api/setup/v1/login', token=None, body={
'username': ctx.cfg['admin']['user'],
'password': ctx.cfg['admin']['pass'],
'hostname': MASCHINE,
})
erwarte_status(r, 403)
erwarte(r.fehlercode in ('installer_account_required', 'second_factor_required'),
'Erwartet wurde eine Ablehnung wegen Rolle oder zweitem Faktor, kam: %s' % r.kurz(300))
@test('setup', 'Anmeldung mit falschem Passwort abgewiesen')
def _(ctx):
konto = _installer(ctx)
r = ctx.client.post('/api/setup/v1/login', token=None, body={
'username': konto['username'], 'password': 'garantiert-falsch',
})
erwarte_fehler(r, http=(401, 429), code=['invalid_credentials', 'locked_out'])
@test('setup', 'Anmeldung ohne Zugangsdaten abgewiesen')
def _(ctx):
r = ctx.client.post('/api/setup/v1/login', token=None, body={})
erwarte_fehler(r, http=(400, 429))
@test('setup', 'Katalog mit Setup-Token abrufbar')
def _(ctx):
r = ctx.client.get('/api/setup/v1/catalog', token=ctx['setup_token'],
query={'platform': 'windows'})
erwarte_erfolg(r)
@test('setup', 'Projekttoken laesst sich ausstellen')
def _(ctx):
r = ctx.client.post('/api/setup/v1/token', token=ctx['setup_token'], body={
'project': PROJEKT,
'scopes': ['watchdog:ping'],
'hostname': MASCHINE,
})
erwarte_erfolg(r)
erwarte(bool(r.feld('token') or r.feld('raw_token')),
"Kein Token in der Antwort: %s" % r.kurz(300))
@test('setup', 'Projekttoken fuer unbekanntes Projekt abgewiesen')
def _(ctx):
r = ctx.client.post('/api/setup/v1/token', token=ctx['setup_token'],
body={'project': 'gibt-es-nicht'})
erwarte_fehler(r, http=404, code='unknown_project')
@test('setup', 'Monitor laesst sich einrichten')
def _(ctx):
r = ctx.client.post('/api/setup/v1/agent', token=ctx['setup_token'], body={
'project': PROJEKT,
'source': 'e2e-setup-monitor',
'os': 'windows',
'interval': 300,
'overwrite': True,
})
erwarte_erfolg(r)
@test('setup', 'Monitor ohne Quelle abgewiesen')
def _(ctx):
r = ctx.client.post('/api/setup/v1/agent', token=ctx['setup_token'],
body={'project': PROJEKT})
erwarte_fehler(r, http=400, code='missing_source')
# ---------------------------------------------------------------------------
# Supervisor
# ---------------------------------------------------------------------------
@test('supervisor', 'Katalog der Detektoren abrufbar')
def _(ctx):
r = ctx.client.get('/api/supervisor/v1/catalog')
erwarte_erfolg(r)
@test('supervisor', 'Befund wird angenommen')
def _(ctx):
r = ctx.client.post('/api/supervisor/v1/observe', body={
'detector': 'e2e.beispiel',
'project_slug': PROJEKT,
'subject': 'Testlauf',
'instance': MASCHINE,
'severity_hint': 'low',
'observed': {'wert': 42},
'baseline': {'wert': 1},
'build': BUILD,
'environment': 'testing',
'client_ref': 'e2e-observe-%d' % int(time.time()),
})
erwarte_status(r, 200, 201)
ctx['finding_id'] = r.feld('finding_id') or r.feld('id')
erwarte(bool(ctx.get('finding_id')), "Keine Befund-ID: %s" % r.kurz(300))
erwarte(r.feld('is_new') is True, "Der Befund gilt nicht als neu: %s" % r.kurz(300))
@test('supervisor', 'Befund ohne Detektor abgewiesen')
def _(ctx):
r = ctx.client.post('/api/supervisor/v1/observe', body={'project_slug': PROJEKT})
erwarte_fehler(r, http=400)
@test('supervisor', 'Befunde abrufbar')
def _(ctx):
r = ctx.client.get('/api/supervisor/v1/findings', query={'project_slug': PROJEKT})
erwarte_erfolg(r)
@test('supervisor', 'Zusammenfassung abrufbar')
def _(ctx):
r = ctx.client.get('/api/supervisor/v1/summary')
erwarte_erfolg(r)
@test('supervisor', 'Befundstatus laesst sich setzen')
def _(ctx):
if not ctx.get('finding_id'):
ueberspringen("Der Befund hat keine ID zurueckgegeben")
r = ctx.client.post('/api/supervisor/v1/status',
body={'id': ctx['finding_id'], 'status': 'acknowledged'})
erwarte(r.status in (200, 400), "Unerwarteter HTTP-Status %d: %s" % (r.status, r.kurz(300)))
if r.status == 400:
erwarte(r.fehlercode == 'invalid_status',
"Statuswechsel abgelehnt: %s" % r.kurz(300))
@test('supervisor', 'Status ohne ID abgewiesen')
def _(ctx):
r = ctx.client.post('/api/supervisor/v1/status', body={'status': 'acknowledged'})
erwarte_fehler(r, http=400, code='missing_id')
@test('supervisor', 'Melden schliesst Lesen ein, aber kein Einstufen')
def _(ctx):
"""
supervisor:report zieht supervisor:read nach sich (IMPLIED_SCOPES) - ein
Beobachter soll sehen, was er gemeldet hat. supervisor:manage folgt
daraus ausdruecklich nicht: Wer meldet, darf nicht selbst entscheiden,
dass seine Meldung erledigt ist.
"""
prov = ctx.client.post('/api/tokens/v1/provision', body={
'client_name': 'e2e-beobachter', 'scopes': ['supervisor:report'],
})
erwarte_erfolg(prov)
nur_melden = prov.feld('sub_token')
r = ctx.client.post('/api/supervisor/v1/observe', token=nur_melden, body={
'detector': 'e2e.nur-melden', 'project_slug': PROJEKT, 'subject': 'Testlauf',
'client_ref': 'e2e-observe-nm-%d' % int(time.time()),
})
erwarte_status(r, 200, 201)
erwarte(bool(r.feld('finding_id')), "Befund nicht angenommen: %s" % r.kurz(300))
r2 = ctx.client.get('/api/supervisor/v1/findings', token=nur_melden)
erwarte_erfolg(r2)
r3 = ctx.client.post('/api/supervisor/v1/status', token=nur_melden,
body={'id': r.feld('finding_id'), 'status': 'resolved'})
erwarte_fehler(r3, http=(401, 403))
@test('supervisor', 'Antwortumschlag von observe bleibt erkennbar')
def _(ctx):
"""
Der Zustand des Befundes darf das Umschlagfeld "status" nicht ueberschreiben.
War ein Befund (Item #59, behoben am 29.08.2026): observe legte
'status' => 'open' in dieselbe Ebene wie das Umschlagfeld der API. Weil
Http::ok() mit array_merge(['status' => 'success'], $data) arbeitet, gewann
der Wert aus dem Body - eine angenommene Meldung kam beim Client als
status="open" an, und jeder Client, der wie ueberall sonst auf
status === "success" prueft, hielt sie fuer einen Fehlschlag.
Das Feld heisst jetzt 'finding_status'. Geprueft wird beides: dass der
Umschlag stimmt UND dass die Angabe nicht einfach verschwunden ist - ein
geloeschtes Feld haette denselben Test bestanden.
"""
r = ctx.client.post('/api/supervisor/v1/observe', body={
'detector': 'e2e.umschlag', 'project_slug': PROJEKT,
'subject': 'Umschlagpruefung',
'client_ref': 'e2e-umschlag-%d' % int(time.time()),
})
erwarte_status(r, 200, 201)
erwarte(r.feld('status') == 'success',
'observe ueberschreibt das Umschlagfeld: status=%r statt "success"'
% r.feld('status'))
erwarte(r.feld('finding_status') is not None,
'finding_status fehlt - der Zustand des Befundes gehoert in die '
'Antwort, nur eben nicht als "status"')
ctx['umschlag_finding'] = r.feld('finding_id')
@test('supervisor', 'Antwortumschlag von status bleibt erkennbar')
def _(ctx):
"""
Dieselbe Kollision an der Einstufung.
Sie faellt weniger auf, weil ein Mensch sie ausloest und nicht ein Client
im Minutentakt - der Fehler ist derselbe, und ohne diesen Fall waere er
beim Beheben von #59 uebersehen worden.
"""
r = ctx.client.post('/api/supervisor/v1/status',
body={'id': ctx['umschlag_finding'], 'status': 'ignored'})
erwarte_erfolg(r)
erwarte(r.feld('finding_status') == 'ignored',
'finding_status fehlt oder ist falsch: %r' % r.feld('finding_status'))