Files
Deploymentcenter/tests/e2e/suite_kern.py
T
Deploymentcenter BotandClaude Opus 5 0e93345ab8 fix(2.8.1): Sechs Befunde, eine Bauart - die Voreinstellung, die einsprang
Drei Agenten haben von aussen gemeldet, und fast jeder Befund hat dieselbe
Form: Irgendwo sprang ein Vorgabewert ein, obwohl die richtige Antwort
bekannt war - meist das Projekt, an das der Aufrufer ohnehin gebunden ist.

#351  manage?action=next antwortete durchgaengig mit HTTP 500. In
      BugRepo::buildFilter() stand :project_scope zweimal in derselben
      Anweisung; mit ATTR_EMULATE_PREPARES=false bindet PDO jeden Namen
      genau einmal - zwei Marker, ein Wert, SQLSTATE[HY093]. Getroffen war
      nur "next", weil nur dort der Rueckkanal-Filter gesetzt wird. Damit
      gab es keine exklusive Uebernahme. Es ist der VIERTE Fall dieser
      Bauart, deshalb prueft scripts/pruefe_platzhalter.py das jetzt
      mechanisch vor jedem Ausliefern.

#382  Meldungen ohne project_slug landeten im Scheinprojekt "default" -
#387  ohne dc_projects-Zeile, also project_id NULL. Die Antwort sah wie ein
#397  Erfolg aus, und danach sperrte dieselbe Token-Bindung den Zugriff
      (403 project_forbidden). Der Rueckfall aufs Heimatprojekt war an
      !isWorkbench gebunden; ein Werkbank-Token ohne Slug bekam gar keinen
      Vorgabewert. Jetzt: genannter Slug, sonst Heimatprojekt, sonst 400.
      Ein unbekanntes Projekt wird abgewiesen statt still angelegt.

#364  Ohne project_slug lieferten list, stats und die Roadmap ALLE
#176  Projekte statt des eigenen, weil die Vorgabe auf "all" stand und ein
      Werkbank-Token beim Lesen absichtlich nicht unter die Schranke faellt.
      Gemessen: 393 Items, davon 28 eigene. Ausgerechnet der erste Aufruf
      jeder Sitzung. Die Roadmap antwortet ausserdem schmal - 55 Felder je
      Eintrag waren 628.392 Zeichen und fuellten das Kontextfenster.

#404  Watchdog-Monitore bekamen nie ein Projekt: upsertHeartbeat() wurde
      keines uebergeben, obwohl authorizeSource() die Token-Bindung liest.
      Befunde erbten dadurch "default" - dc_findings(project_slug=...) war
      fuer eine laufende, meldende Instanz leer.

#131  Wo der Leseweg richtig verengte, sagte er es nicht: HTTP 200 mit dem
      umgeschriebenen Filter sieht aus wie eine Auskunft ueber das
      angefragte Projekt. Ein Agent, der vor dem Melden dc_search fragt,
      legt so das Duplikat an. Jetzt scoped_to und scope_note im Umschlag -
      nur bei tatsaechlicher Verengung.

#354  dc-testkit 1.1.0 wurde zweimal mit verschiedenem Inhalt ausgeliefert
      (1898 und 1930 Zeilen), 1.0.0 davor ebenso. Der Absatz ueber
      DC_TESTKIT_VERSION stand die ganze Zeit im Skript und hat nicht
      getragen. Jetzt 1.1.1 plus ein Riegel in deploy.py, der abbricht,
      wenn sich der Inhalt aendert und die Nummer nicht.

Warum die Testsuite all das nicht sah: Ihr Master-Token ist an kein Projekt
gebunden und nimmt deshalb ueberall den anderen Zweig. Es gab sogar einen
Testfall fuer action=next - er lief gruen, waehrend der Aufruf in der Praxis
ausnahmslos scheiterte. Die neuen Faelle benutzen ausdruecklich
projektgebundene Token; testctl kann dafuer jetzt owner_type und scopes
waehlen.

Ausserdem: Die Agentenvorlage nennt fuer die SDK-Fassung dc_link_project
statt einer abgeschriebenen Zahl - "SDK 2.5.1" stand dort als Beispiel und
wurde als Wert uebernommen.

Nicht enthalten: die 20 Altlasten in "default". Sie sind ueber die API nicht
erreichbar; sql/wartung/2026-08-31_default-projekt-aufraeumen.sql liegt dafuer
bereit (alle 20 haben einen titelgleichen Zwilling im richtigen Projekt).

156 e2e-Faelle gruen, davon 9 neue.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-31 20:43:23 +02:00

455 lines
17 KiB
Python

#!/usr/bin/env python3
"""
Kern: Zustand, Schnittstellenbeschreibung, Installation, Token.
Was hier faellt, macht jede weitere Gruppe bedeutungslos - deshalb laeuft
diese Gruppe vor den Modulen.
"""
from pathlib import Path
from harness import (test, erwarte, erwarte_status, erwarte_erfolg,
erwarte_fehler, ueberspringen)
PROJECT_DIR = Path(__file__).resolve().parents[2]
# Ein Projekt, das nach dem Zuruecksetzen wirklich in dc_projects steht
# (sql/schema.sql). Seit 2.8.1 weist der Meldeweg einen Slug ohne
# dc_projects-Zeile ab - ein ausgedachter Name taugt hier nicht mehr.
PROJEKT_FUER_MELDUNGEN = 'myapp'
# ---------------------------------------------------------------------------
# Zustand
# ---------------------------------------------------------------------------
@test('kern', 'Health meldet gesunden Zustand')
def _(ctx):
"""Datenbank erreichbar, Logverzeichnis beschreibbar."""
r = ctx.client.get('/api/health', token=None)
erwarte_status(r, 200)
erwarte(r.feld('healthy') is True, "healthy ist %r | %s" % (r.feld('healthy'), r.kurz(300)))
erwarte(r.feld('checks', 'database', 'ok') is True, "Datenbankpruefung: %s" % r.kurz(300))
erwarte(r.feld('checks', 'log_writable', 'ok') is True, "Logverzeichnis: %s" % r.kurz(300))
@test('kern', 'Health ohne Token nennt keine Interna')
def _(ctx):
"""Ohne Berechtigung nur der Gesamtzustand - keine Schema- oder Kennzahlen."""
r = ctx.client.get('/api/health', token=None)
for feld in ('schema', 'migrations', 'metrics', 'pending_migrations'):
erwarte(feld not in (r.json or {}),
"Health liefert %r ohne Authentifizierung" % feld)
@test('kern', 'Health mit Token liefert Details')
def _(ctx):
r = ctx.client.get('/api/health')
erwarte_status(r, 200)
zusatz = [k for k in (r.json or {}) if k not in
('status', 'healthy', 'app', 'version', 'time_utc', 'checks')]
erwarte(zusatz, "Mit Token kommen keine zusaetzlichen Angaben: %s" % r.kurz(300))
@test('kern', 'Health meldet die Fassung aus dem Changelog')
def _(ctx):
"""
Die Fassung stand frueher als feste Zeichenkette in config/config.php und
blieb dort auf 2.0.0 stehen, waehrend 2.7.0 lief. Zwei gemeldete Fehler
sind daran fehldiagnostiziert worden ("der Server ist zu alt"). Sie kommt
jetzt aus public/docs/changelog.json - und dieser Fall haelt beides
zusammen.
"""
import json
pfad = PROJECT_DIR / 'public' / 'docs' / 'changelog.json'
erwartet = json.loads(pfad.read_text(encoding='utf-8'))['current']
r = ctx.client.get('/api/health', token=None)
erwarte_status(r, 200)
erwarte(r.feld('version') == erwartet,
"Server meldet %r, changelog.json fuehrt %r"
% (r.feld('version'), erwartet))
@test('kern', 'Das WebUI antwortet, statt mit einem Serverfehler abzubrechen')
def _(ctx):
"""
index.php ist mit Abstand die groesste Datei des Projekts und wurde bisher
von keinem Testfall auch nur angefasst: Die Suite prueft /api/. Ein
Syntaxfehler darin waere durch das Deploy-Tor gelaufen und haette die
Oberflaeche lahmgelegt, ohne dass ein einziger Test rot geworden waere.
Mehr als das kann dieser Fall nicht: Ohne Sitzung bricht index.php in
Zeile 45 mit einer Weiterleitung zur Anmeldung ab, der Rumpf laeuft also
nie. Geprueft wird damit, dass PHP die Datei ueberhaupt uebersetzen kann -
und genau das ist der Fehler, der im Betrieb am teuersten ist.
"""
r = ctx.client.get('/index.php', token=None)
erwarte(r.status != 500,
"index.php antwortet mit 500 - vermutlich ein Syntaxfehler: %s" % r.kurz(400))
erwarte_status(r, 200, 302, 303)
@test('kern', 'OpenAPI-Beschreibung ist gueltig')
def _(ctx):
"""Die Beschreibung ist der Vertrag mit den SDKs - sie muss ladbar bleiben."""
r = ctx.client.get('/api/openapi.json')
erwarte_status(r, 200)
erwarte(isinstance(r.json, dict), "Antwort ist kein JSON: %s" % r.kurz(200))
erwarte(str(r.feld('openapi', default='')).startswith('3.'),
"openapi-Version: %r" % r.feld('openapi'))
pfade = r.feld('paths', default={})
erwarte(len(pfade) >= 25, "Nur %d Pfade beschrieben" % len(pfade))
ctx['openapi'] = r.json
@test('kern', 'OpenAPI beschreibt alle ausgelieferten Endpunkte')
def _(ctx):
"""
Jede Datei unter public/api/ soll in der Beschreibung vorkommen.
Das ist die Pruefung, die auffaellt, wenn jemand einen Endpunkt ergaenzt
und die Beschreibung vergisst - der haeufigste Weg, auf dem ein SDK und
ein Server auseinanderlaufen.
"""
spec = ctx['openapi']
beschrieben = ' '.join(spec.get('paths', {}).keys())
bekannt = {
'health.php': '/api/health',
'openapi.php': None, # beschreibt sich nicht selbst
'report.php': '/report',
'projects.php': '/projects',
'roadmap.php': '/roadmap',
'provision.php': '/provision',
}
fehlend = []
for datei in sorted((PROJECT_DIR / 'public' / 'api').rglob('*.php')):
rel = datei.relative_to(PROJECT_DIR / 'public' / 'api').as_posix()
name = datei.name
if name in bekannt:
marke = bekannt[name]
if marke is None or marke in beschrieben:
continue
fehlend.append(rel)
continue
# index.php eines Moduls: mindestens ein Pfad des Moduls muss da sein
modul = rel.split('/')[0]
if ('/api/%s/' % modul) not in beschrieben:
fehlend.append(rel)
erwarte(not fehlend, "Nicht in der OpenAPI-Beschreibung: %s" % ', '.join(fehlend))
# ---------------------------------------------------------------------------
# Installation
# ---------------------------------------------------------------------------
@test('kern', 'Alle Migrationen sind angewendet')
def _(ctx):
"""
Der Vergleich zaehlt Dateien im Repository gegen Eintraege in
dc_migrations. Eine nicht ausgefuehrte Migration faellt sonst erst auf,
wenn ein Endpunkt eine Spalte vermisst.
"""
dateien = {p.stem for p in (PROJECT_DIR / 'sql' / 'migrations').glob('*.sql')}
dateien.add('000_schema')
r = ctx.testctl('status')
erwarte_erfolg(r)
angewendet = set(r.feld('migrations', default=[]))
fehlend = sorted(dateien - angewendet)
erwarte(not fehlend, "Nicht angewendet: %s" % ', '.join(fehlend))
ctx['tabellen'] = r.feld('tables')
@test('kern', 'Migration ohne Berechtigung abgewiesen')
def _(ctx):
"""install_db.php war einmal offen erreichbar - das darf nicht wiederkommen."""
r = ctx.client.get('/install_db.php', token=None)
erwarte_fehler(r, http=401, code='unauthorized')
@test('kern', 'Migration ist wiederholbar')
def _(ctx):
"""
Zweiter Aufruf auf demselben Stand: nichts anzuwenden, nichts kaputt.
Ein Deployment loest die Migration aus; sie laeuft also oft auf einem
Stand, der schon aktuell ist.
"""
r = ctx.client.get('/install_db.php', token=ctx.cfg['keys']['shared_key'])
erwarte_erfolg(r)
erwarte(r.feld('applied', default=[]) == [],
"Zweiter Lauf wendet erneut an: %s" % r.feld('applied'))
tabellen = r.feld('tables', default=[])
erwarte(len(tabellen) == ctx['tabellen'],
"Tabellenzahl geaendert: %d statt %d" % (len(tabellen), ctx['tabellen']))
@test('kern', 'Migration legt keinen zweiten Administrator an')
def _(ctx):
"""Der alte Stand setzte bei jedem Aufruf das Admin-Passwort zurueck."""
r = ctx.client.get('/install_db.php', token=ctx.cfg['keys']['shared_key'])
erwarte_erfolg(r)
admin = r.feld('admin', default={})
erwarte('password' not in admin,
"Die Migration hat ein Passwort ausgegeben: %s" % admin)
# ---------------------------------------------------------------------------
# Token
# ---------------------------------------------------------------------------
@test('token', 'Master-Token wird akzeptiert')
def _(ctx):
r = ctx.client.get('/api/bugtracker/v1/projects')
erwarte_erfolg(r)
@test('token', 'Ohne Token abgewiesen')
def _(ctx):
r = ctx.client.get('/api/bugtracker/v1/projects', token=None)
erwarte_fehler(r, http=401, code='unauthorized')
@test('token', 'Erfundenes Token abgewiesen')
def _(ctx):
r = ctx.client.get('/api/bugtracker/v1/projects', token='dc_master_' + 'f' * 48)
erwarte_fehler(r, http=401, code='unauthorized')
@test('token', 'Sub-Token laesst sich provisionieren')
def _(ctx):
r = ctx.client.post('/api/tokens/v1/provision', body={
'client_name': 'e2e-testlauf',
'instance_id': 'testrunner',
'scopes': ['bugtracker:report'],
'environment': 'testing',
})
erwarte_erfolg(r)
erwarte(r.feld('sub_token'), "Kein sub_token in der Antwort: %s" % r.kurz(300))
erwarte(r.feld('type') == 'sub', "type ist %r" % r.feld('type'))
ctx['sub_token'] = r.feld('sub_token')
ctx['sub_scopes'] = r.feld('scopes', default=[])
@test('token', 'Sub-Token bekommt nur die erbetenen Rechte')
def _(ctx):
"""Provisionierung darf einschraenken, niemals erweitern."""
scopes = ctx['sub_scopes']
erwarte('*' not in scopes, "Sub-Token hat den Vollzugriff geerbt: %s" % scopes)
unerwartet = [s for s in scopes if not s.startswith('bugtracker:')]
erwarte(not unerwartet, "Nicht erbetene Rechte: %s" % unerwartet)
@test('token', 'Sub-Token darf melden')
def _(ctx):
# 'myapp' und nicht 'e2e-selftest': Seit 2.8.1 weist der Endpunkt ein
# project_slug ab, zu dem es keine dc_projects-Zeile gibt (400
# unknown_project). Vorher wurde ein solcher Slug klaglos angenommen und
# das Item mit project_id NULL abgelegt - unsichtbar in jeder
# Projektansicht, unerreichbar fuer jedes projektgebundene Token. Genau
# das war Bugtracker #382/#387.
#
# Dieser Testfall prueft die RECHTE eines Sub-Tokens, nicht die
# Projektanlage; der Slug war hier nie die Aussage. Die neue Schranke
# selbst wird in der Gruppe bugtracker geprueft.
r = ctx.client.post('/api/bugtracker/v1/report', token=ctx['sub_token'], body={
'project_slug': PROJEKT_FUER_MELDUNGEN,
'title': 'Meldung ueber Sub-Token',
'description': 'Erzeugt vom automatisierten Testlauf.',
'severity': 'low',
'environment': 'testing',
})
erwarte_erfolg(r)
@test('token', 'Sub-Token darf nicht verwalten')
def _(ctx):
"""bugtracker:report schliesst bugtracker:manage nicht ein."""
r = ctx.client.post('/api/bugtracker/v1/manage?action=claim&id=1',
token=ctx['sub_token'], body={})
erwarte_fehler(r, http=(401, 403))
@test('token', 'whoami nennt Bindung und Rechte des eigenen Tokens')
def _(ctx):
"""
Ohne diese Auskunft liesse sich ein Recht nur durch Ausprobieren
feststellen - und das hat Nebenwirkungen: eine Probe auf
bugtracker:manage uebernaehme ein Item, eine auf license:dev stellte
einen Schluessel aus.
"""
r = ctx.client.get('/api/tokens/v1/whoami', token=ctx['sub_token'])
erwarte_erfolg(r)
erwarte(r.feld('type') == 'sub', "type ist %r" % r.feld('type'))
erwarte(sorted(r.feld('scopes', default=[])) == sorted(ctx['sub_scopes']),
"whoami meldet %s, provisioniert wurde %s"
% (r.feld('scopes'), ctx['sub_scopes']))
erwarte(isinstance(r.feld('is_workbench'), bool),
"is_workbench fehlt oder ist kein Wahrheitswert: %s" % r.kurz(300))
@test('token', 'whoami liefert den Klartextwert nicht aus')
def _(ctx):
"""Eine Auskunft ueber ein Token darf das Token nicht weiterreichen."""
r = ctx.client.get('/api/tokens/v1/whoami', token=ctx['sub_token'])
erwarte(ctx['sub_token'] not in (r.body or ''),
"Der Tokenwert steht in der Antwort: %s" % r.kurz(300))
@test('token', 'whoami ohne Token abgewiesen')
def _(ctx):
r = ctx.client.get('/api/tokens/v1/whoami', token=None)
erwarte_fehler(r, http=401)
@test('token', 'Provisionierung ohne Master-Token abgewiesen')
def _(ctx):
r = ctx.client.post('/api/tokens/v1/provision', token=None,
body={'scopes': ['bugtracker:report']})
erwarte_fehler(r, http=401, code='missing_master_token')
@test('token', 'Provisionierung mit erfundenem Master-Token abgewiesen')
def _(ctx):
r = ctx.client.post('/api/tokens/v1/provision', token='dc_master_' + '0' * 48,
body={'scopes': ['bugtracker:report']})
erwarte_fehler(r, http=(401, 403))
@test('token', 'Sub-Token kann sich nicht selbst vermehren')
def _(ctx):
"""
Ein Sub-Token ist kein Master-Token. Koennte es weitere Tokens ausstellen,
liesse sich aus einem ausgelieferten Client eine Token-Fabrik machen.
"""
r = ctx.client.post('/api/tokens/v1/provision', token=ctx['sub_token'],
body={'scopes': ['bugtracker:report']})
erwarte_fehler(r, http=(401, 403))
# ---------------------------------------------------------------------------
# Token nachtraeglich aendern
#
# Bis 2.7.0 ging das nicht: Ein Schreibfehler im Namen oder ein nachtraeglich
# gebrauchtes Recht erzwang ein komplett neues Token - mit der Folge, dass der
# neue Wert auf alle Maschinen verteilt werden muss und am Ende das alte
# mitlaeuft, weil eine vergessen wurde.
# ---------------------------------------------------------------------------
@test('token', 'Aenderbares Token laesst sich anlegen')
def _(ctx):
r = ctx.testctl('workbench_token', project_slug='myapp', name='vorher')
erwarte_erfolg(r)
ctx['edit_token_id'] = r.feld('token_id')
ctx['edit_token_raw'] = r.feld('raw_token')
@test('token', 'Name und Rechte lassen sich nachtraeglich aendern')
def _(ctx):
r = ctx.testctl('update_master_token',
token_id=ctx['edit_token_id'],
name='nachher',
owner_type='dev_agent',
project_slug='myapp',
environment='development',
scopes=['bugtracker:read', 'supervisor:read'])
erwarte_erfolg(r)
erwarte(r.feld('token', 'name') == 'nachher',
'Name nicht uebernommen: %r' % r.feld('token', 'name'))
erwarte(r.feld('token', 'environment') == 'development',
'Umgebung nicht uebernommen: %r' % r.feld('token', 'environment'))
erwarte('supervisor:read' in (r.feld('token', 'scopes') or ''),
'Rechte nicht uebernommen: %r' % r.feld('token', 'scopes'))
@test('token', 'Der Tokenwert bleibt bei einer Aenderung derselbe')
def _(ctx):
"""
Die eigentliche Zusage der Funktion. Waere der Wert danach ein anderer,
muessten alle Maschinen neu bestueckt werden - dann koennte man auch
gleich ein neues Token ausstellen, und die Bearbeitung waere sinnlos.
"""
r = ctx.client.get('/api/bugtracker/v1/projects', token=ctx['edit_token_raw'])
erwarte_erfolg(r)
@test('token', 'Entzogene Rechte greifen sofort')
def _(ctx):
"""
Die Gegenprobe: Der Wert bleibt, die Rechte nicht. Das Token hatte
bugtracker:manage und hat es nach der Aenderung nicht mehr.
"""
r = ctx.client.post('/api/bugtracker/v1/manage',
query={'action': 'next'},
token=ctx['edit_token_raw'],
body={'limit': 1})
erwarte_fehler(r, http=(401, 403))
@test('token', 'Aenderung ohne Rechte wird abgewiesen')
def _(ctx):
"""
Ein Token ohne jedes Recht sieht gueltig aus und kann nichts - der
Aufrufer bekaeme "unauthorized" ohne erkennbaren Grund.
"""
r = ctx.testctl('update_master_token', token_id=ctx['edit_token_id'],
name='ohne rechte', scopes=[])
erwarte_fehler(r, http=400, code='invalid_update')
@test('token', 'Aenderung ohne Namen wird abgewiesen')
def _(ctx):
r = ctx.testctl('update_master_token', token_id=ctx['edit_token_id'],
name=' ', scopes=['bugtracker:read'])
erwarte_fehler(r, http=400, code='invalid_update')
@test('token', 'Aenderung an einem fehlenden Token antwortet 404')
def _(ctx):
r = ctx.testctl('update_master_token', token_id='tok_m_gibtsnicht',
name='egal', scopes=['bugtracker:read'])
erwarte_fehler(r, http=404, code='not_found')
# ---------------------------------------------------------------------------
# Allgemeines Verhalten der Schnittstelle
# ---------------------------------------------------------------------------
@test('kern', 'Unbekannter Endpunkt antwortet 404')
def _(ctx):
r = ctx.client.get('/api/gibtesnicht/v1/nix')
erwarte_status(r, 404)
@test('kern', 'Falsche Methode antwortet 405')
def _(ctx):
r = ctx.client.get('/api/bugtracker/v1/report')
erwarte_fehler(r, http=405, code='method_not_allowed')
@test('kern', 'Kaputtes JSON antwortet 400')
def _(ctx):
"""Ein stiller Rueckfall auf einen leeren Body wuerde Fehler verschleiern."""
r = ctx.client.request('POST', '/api/bugtracker/v1/report',
roh_body='{"title": "unvollstaendig"')
erwarte_fehler(r, http=400)
@test('kern', 'Leerer Body wird abgewiesen')
def _(ctx):
r = ctx.client.request('POST', '/api/bugtracker/v1/report', roh_body='')
erwarte_fehler(r, http=400, code='empty_body')
@test('kern', 'CORS-Vorabanfrage wird beantwortet')
def _(ctx):
"""Die Maschinenschnittstellen erlauben Browser-Clients ausdruecklich."""
r = ctx.client.request('OPTIONS', '/api/bugtracker/v1/report')
erwarte_status(r, 204, 200)