#!/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 # --------------------------------------------------------------------------- @test('setup', 'Anmeldung liefert ein kurzlebiges Token') def _(ctx): r = ctx.client.post('/api/setup/v1/login', token=None, body={ 'username': ctx.cfg['admin']['user'], 'password': ctx.cfg['admin']['pass'], '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', 'Anmeldung mit falschem Passwort abgewiesen') def _(ctx): r = ctx.client.post('/api/setup/v1/login', token=None, body={ 'username': ctx.cfg['admin']['user'], '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'))