Sammelcommit fuer den ueber mehrere Sitzungen gewachsenen, bisher nicht
eingecheckten Stand. Getrennte Commits waeren ehrlicher gewesen, sind es
nachtraeglich aber nicht mehr: BugRepo, changelog.json und die Doku tragen
Aenderungen aus allen Straengen gleichzeitig, und keine Zwischenstufe waere
lauffaehig.
ROADMAP IM DEPLOYMENTCENTER (2.7.0)
Planungsstand, Reihenfolge und Zustaendigkeit lagen in handgepflegten
Markdown-Dateien neben einem Bugtracker, der genau diese drei Dinge bereits
fuehrt. Zwei Pfleger fuer dasselbe Feld - Drift war eine Frage der Zeit.
Ab jetzt: Zustand im DC, Begruendung in der Datei. Kein Feld hat zwei Pfleger.
- Migration 016: epic, roadmap_rank, target_build, defer_trigger,
doc_anchor, Status "deferred", Tabelle roadmap_epics.
KEINE Roadmap-Tabelle - ein Eintrag ist ein bugtracker_item.
- Zugehoerigkeit ist ein Feld: Ein Item steht auf der Roadmap, wenn es ein
epic hat. Ein zweites Kennzeichen daneben koennte auseinanderlaufen.
- Kennung ist die Item-ID. Handvergebene Kuerzel ("A12") erzeugen die
schlimmste Fehlerart: Beide Seiten stimmig, Verschiedenes gemeint, weil
die Kennung nach einer Loeschung neu vergeben wurde.
- Zurueckstellen verlangt defer_trigger. Ohne benannten Anlass ist ein
Vorhaben nicht zurueckgestellt, sondern vergessen. Die Regel sitzt im
Repository, damit sie auf allen drei Schreibwegen gleich greift.
- Modules/Bugtracker/Roadmap.php, /api/bugtracker/v1/roadmap.php,
WebUI-Modul mit Filtern, cli/roadmap.php --write/--check,
dc_roadmap und dc_roadmap_set im MCP-Adapter.
- cli/roadmap.php --write ERZEUGT den Zustandsteil der Markdown-Datei;
--check prueft nur die Prosa. Was erzeugt wird, kann nicht driften.
- docs/ROADMAP_MIGRATION.md: Uebernahme fuer die anderen Projekte.
TESTINSTANZ UND TESTTOR
- dc-dev.mhdf.de: zweite Installation, eigene DB, eigene Schluessel,
Basic-Auth davor, Rocket.Chat aus.
- 124 Testfaelle in tests/e2e gegen API, Rechte, Migrationen, Sperre.
- scripts/deploy.py bringt vor jedem Upload erst den anstehenden Stand auf
die Testinstanz und laesst dann den Testlauf laufen. Erst bei
Rueckgabewert 0 geht etwas auf die Produktion (--ohne-test als Notausgang).
Andersherum prueft der Lauf aelteren Code als den, der gleich hochgeht.
- tests/ ist aus dem Produktiv-Deploy ausgeschlossen; testctl.php kann
nicht versehentlich auf dc.mhdf.de landen.
- Ein Lauf mit mehr uebersprungenen als geprueften Faellen endet mit
Rueckgabewert 2 statt 0. Nichts ist kaputt, geprueft wurde es aber auch
nicht - der Unterschied darf nicht am Rueckgabewert verschwinden.
SUPERVISOR, PROJEKTLANDSCHAFT, NACHPRUEFUNG (2.6.0/2.6.1)
- Migration 013: projektuebergreifend melden (dev_agent, evidence,
triage_external, affects).
- Migration 014: supervisor_findings, Katalog, fuenf serverseitige
Detektoren im Tick.
- Migration 015: Nachpruefung als Arbeitsschlange statt Benachrichtigung.
- DEV-Lizenzschluessel zur Selbstbedienung, dc-testkit fuer die Test-VM.
- MCP-Adapter: 15 Werkzeuge, ohne Fremdabhaengigkeiten, mit Abgrenzung
von Fremddaten gegen Prompt-Injektion.
BEHOBEN
- observe und status im Supervisor legten den Zustand des Befundes als
"status" in die Antwort. Http::ok() merged ['status' => 'success']
darunter, also gewann der Befundzustand - eine angenommene Meldung kam
als status="open" an. Heisst jetzt finding_status. War Item #59.
Migrationen 013-016 sind auf dem Produktivserver noch NICHT ausgefuehrt.
Ohne sie bricht das WebUI, weil die Roadmap-Ansicht bei jedem Seitenaufruf
gegen bugtracker_items.epic abfragt.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
284 lines
10 KiB
Python
284 lines
10 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]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 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', '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):
|
|
r = ctx.client.post('/api/bugtracker/v1/report', token=ctx['sub_token'], body={
|
|
'project_slug': 'e2e-selftest',
|
|
'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', '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))
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 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)
|