feat(2.7.0): Roadmap im DC, Testinstanz mit Testtor, Supervisor und MCP
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>
This commit is contained in:
co-authored by
Claude Opus 5
parent
9d0261306c
commit
1d5ac92051
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"_hinweis": [
|
||||
"Testfaelle, die einen gemeldeten und noch offenen Befund beschreiben.",
|
||||
"",
|
||||
"Sie laufen weiterhin mit und stehen im Bericht - aber sie lassen den Lauf",
|
||||
"nicht fehlschlagen. Ohne diese Liste haette man genau zwei Moeglichkeiten:",
|
||||
"den Testfall loeschen (dann ist der Befund vergessen) oder mit einem dauerhaft",
|
||||
"roten Lauf leben (dann sieht niemand mehr hin, wenn etwas Neues rot wird).",
|
||||
"",
|
||||
"Schluessel ist 'gruppe/name' des Testfalls. Jeder Eintrag nennt die Item-Nummer",
|
||||
"im Bugtracker - ein Eintrag ohne Item ist eine Ausrede, kein Befund.",
|
||||
"",
|
||||
"Besteht ein solcher Testfall wieder, meldet der Lauf das ausdruecklich. Dann",
|
||||
"gehoert der Eintrag hier heraus und das Item auf 'resolved'.",
|
||||
"",
|
||||
"Derzeit leer. Item 59 (Antwortumschlag von observe) ist am 29.08.2026 behoben",
|
||||
"worden - das Feld heisst jetzt finding_status."
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,602 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Gemeinsamer Unterbau der Testlaeufe gegen die Testinstanz.
|
||||
|
||||
Bewusst ohne pytest und ohne requests: Der Testlauf soll auf jedem Rechner
|
||||
und auf der Test-VM ohne vorherige Installation starten koennen. Sobald ein
|
||||
Testwerkzeug erst eingerichtet werden muss, wird es im Zweifel nicht
|
||||
ausgefuehrt - und eine Testsuite, die niemand startet, findet nichts.
|
||||
|
||||
Aufbau:
|
||||
Client - HTTP-Aufrufe gegen die Instanz, mit Basic-Auth und Token
|
||||
Ctx - was ein Testfall an den naechsten weitergibt (IDs, Tokens)
|
||||
@test - Registrierung; die Reihenfolge der Registrierung ist die
|
||||
Reihenfolge der Ausfuehrung
|
||||
run - Ausfuehrung, Ausgabe, Bericht
|
||||
"""
|
||||
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
import socket
|
||||
import ssl
|
||||
import sys
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
|
||||
PROJECT_DIR = Path(__file__).resolve().parents[2]
|
||||
DEV_CONFIG = PROJECT_DIR / 'scripts' / 'deploy_dev_config.json'
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Adressfamilie
|
||||
#
|
||||
# Die Hoster-Adressen haben einen AAAA-Eintrag. Ist von einem Arbeitsplatz aus
|
||||
# keine IPv6-Route vorhanden, laeuft jeder einzelne Aufruf zuerst 21 Sekunden
|
||||
# in einen TCP-Timeout und faellt erst dann auf IPv4 zurueck - aus einem
|
||||
# Testlauf von einer Minute werden 40. curl faellt sofort zurueck (Happy
|
||||
# Eyeballs), Python nicht; deshalb steht das hier und nicht im Netzwerk.
|
||||
#
|
||||
# Einmal am Anfang geprueft, danach steht die Entscheidung fuer den ganzen
|
||||
# Lauf. Sie betrifft nur, wie der Testlauf die Instanz erreicht - nicht, was
|
||||
# er prueft.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_ECHTES_GETADDRINFO = socket.getaddrinfo
|
||||
|
||||
|
||||
def erreichbar(host, port, familie, timeout=3.0):
|
||||
try:
|
||||
infos = _ECHTES_GETADDRINFO(host, port, familie, socket.SOCK_STREAM)
|
||||
except socket.gaierror:
|
||||
return False
|
||||
for info in infos:
|
||||
s = socket.socket(info[0], socket.SOCK_STREAM)
|
||||
s.settimeout(timeout)
|
||||
try:
|
||||
s.connect(info[4])
|
||||
return True
|
||||
except OSError:
|
||||
continue
|
||||
finally:
|
||||
s.close()
|
||||
return False
|
||||
|
||||
|
||||
def waehle_adressfamilie(host, port=443, erzwinge=None):
|
||||
"""
|
||||
Liefert eine kurze Beschreibung fuer die Ausgabe und schaltet notfalls
|
||||
auf IPv4 um.
|
||||
"""
|
||||
if erzwinge == 'ipv4' or (erzwinge is None and not erreichbar(host, port, socket.AF_INET6)):
|
||||
def nur_ipv4(*a, **kw):
|
||||
args = list(a)
|
||||
if len(args) >= 3:
|
||||
args[2] = socket.AF_INET
|
||||
else:
|
||||
kw['family'] = socket.AF_INET
|
||||
return _ECHTES_GETADDRINFO(*args, **kw)
|
||||
socket.getaddrinfo = nur_ipv4
|
||||
return 'IPv4 (IPv6 nicht erreichbar)' if erzwinge is None else 'IPv4 (erzwungen)'
|
||||
return 'IPv6/IPv4'
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Ausgabe
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class Farbe:
|
||||
"""ANSI-Farben, aber nur wenn die Ausgabe wirklich ein Terminal ist."""
|
||||
aktiv = sys.stdout.isatty() and os.name != 'nt' or os.environ.get('DC_FARBE') == '1'
|
||||
|
||||
@classmethod
|
||||
def _f(cls, code, text):
|
||||
return "\033[%sm%s\033[0m" % (code, text) if cls.aktiv else text
|
||||
|
||||
@classmethod
|
||||
def gruen(cls, t):
|
||||
return cls._f('32', t)
|
||||
|
||||
@classmethod
|
||||
def rot(cls, t):
|
||||
return cls._f('31', t)
|
||||
|
||||
@classmethod
|
||||
def gelb(cls, t):
|
||||
return cls._f('33', t)
|
||||
|
||||
@classmethod
|
||||
def grau(cls, t):
|
||||
return cls._f('90', t)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fehlerarten
|
||||
#
|
||||
# Der Unterschied zwischen "die Anwendung ist kaputt" und "dieser Testfall
|
||||
# laesst sich hier nicht pruefen" muss im Bericht sichtbar bleiben. Sonst
|
||||
# gewoehnt man sich an rote Zeilen, die keine sind.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class Fehlschlag(AssertionError):
|
||||
"""Die Anwendung hat sich anders verhalten als erwartet."""
|
||||
|
||||
|
||||
class Uebersprungen(Exception):
|
||||
"""Der Testfall ist unter diesen Bedingungen nicht pruefbar."""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# HTTP
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class Antwort:
|
||||
def __init__(self, status, headers, body, ms, url, method):
|
||||
self.status = status
|
||||
self.headers = {k.lower(): v for k, v in (headers or {}).items()}
|
||||
self.body = body
|
||||
self.ms = ms
|
||||
self.url = url
|
||||
self.method = method
|
||||
try:
|
||||
self.json = json.loads(body) if body else None
|
||||
except Exception:
|
||||
self.json = None
|
||||
|
||||
@property
|
||||
def ok(self):
|
||||
return isinstance(self.json, dict) and self.json.get('status') == 'success'
|
||||
|
||||
@property
|
||||
def fehlercode(self):
|
||||
if isinstance(self.json, dict) and isinstance(self.json.get('error'), dict):
|
||||
return self.json['error'].get('code')
|
||||
return None
|
||||
|
||||
def feld(self, *pfad, default=None):
|
||||
"""Wert aus verschachtelter Antwort holen: resp.feld('state', 'tables')."""
|
||||
wert = self.json
|
||||
for teil in pfad:
|
||||
if isinstance(wert, dict) and teil in wert:
|
||||
wert = wert[teil]
|
||||
elif isinstance(wert, list) and isinstance(teil, int) and -len(wert) <= teil < len(wert):
|
||||
wert = wert[teil]
|
||||
else:
|
||||
return default
|
||||
return wert
|
||||
|
||||
def kurz(self, n=400):
|
||||
text = self.body or ''
|
||||
return text[:n] + (' ...' if len(text) > n else '')
|
||||
|
||||
def __repr__(self):
|
||||
return "<%s %s -> %d>" % (self.method, self.url, self.status)
|
||||
|
||||
|
||||
class Client:
|
||||
"""
|
||||
HTTP-Client fuer die Testinstanz.
|
||||
|
||||
Zur Aufteilung der beiden Berechtigungen: Die Basic-Auth der Testinstanz
|
||||
geht in den Authorization-Header, das Token der Anwendung in
|
||||
X-Agent-Token. Beide in denselben Header zu packen ginge nicht - und die
|
||||
Anwendung akzeptiert X-Agent-Token ausdruecklich (Http::bearerToken).
|
||||
Wer den Bearer-Weg pruefen will, ruft mit basic=False und bearer=True auf;
|
||||
dann greift die Ausnahme der .htaccess fuer /api/.
|
||||
"""
|
||||
|
||||
def __init__(self, base_url, basic_user=None, basic_pass=None, token=None, timeout=45):
|
||||
self.base_url = base_url.rstrip('/')
|
||||
self.basic_user = basic_user
|
||||
self.basic_pass = basic_pass
|
||||
self.token = token
|
||||
self.timeout = timeout
|
||||
self.aufrufe = 0
|
||||
# Die Testinstanz laeuft hinter einem regulaeren Zertifikat; ein
|
||||
# eigener Kontext nur, damit ein Hoster-Zwischenzertifikat den
|
||||
# Testlauf nicht zum Erliegen bringt.
|
||||
self.ssl_ctx = ssl.create_default_context()
|
||||
|
||||
def basic_header(self):
|
||||
roh = ("%s:%s" % (self.basic_user, self.basic_pass)).encode('utf-8')
|
||||
return 'Basic ' + base64.b64encode(roh).decode('ascii')
|
||||
|
||||
def request(self, method, pfad, body=None, query=None, token='standard',
|
||||
bearer=False, basic=True, headers=None, roh_body=None):
|
||||
url = self.base_url + pfad
|
||||
if query:
|
||||
url += ('&' if '?' in url else '?') + urllib.parse.urlencode(query)
|
||||
|
||||
kopf = {'Accept': 'application/json', 'User-Agent': 'dc-e2e/1.0'}
|
||||
|
||||
if basic and self.basic_user:
|
||||
kopf['Authorization'] = self.basic_header()
|
||||
|
||||
# token='standard' -> das Token des Laufs; None -> keins; sonst der Wert
|
||||
wert = self.token if token == 'standard' else token
|
||||
if wert:
|
||||
if bearer:
|
||||
kopf['Authorization'] = 'Bearer ' + wert
|
||||
else:
|
||||
kopf['X-Agent-Token'] = wert
|
||||
|
||||
daten = None
|
||||
if roh_body is not None:
|
||||
daten = roh_body if isinstance(roh_body, bytes) else roh_body.encode('utf-8')
|
||||
kopf.setdefault('Content-Type', 'application/json')
|
||||
elif body is not None:
|
||||
daten = json.dumps(body).encode('utf-8')
|
||||
kopf['Content-Type'] = 'application/json'
|
||||
|
||||
kopf.update(headers or {})
|
||||
|
||||
req = urllib.request.Request(url, data=daten, headers=kopf, method=method)
|
||||
start = time.time()
|
||||
|
||||
# Ein einziger Wiederholungsversuch, und nur wenn die Verbindung
|
||||
# selbst gescheitert ist - nie bei einer HTTP-Antwort. Ein
|
||||
# zurueckgesetztes TCP sagt nichts ueber die Anwendung aus; ein
|
||||
# HTTP 500 dagegen schon, und das darf kein Wiederholungsversuch
|
||||
# verschlucken.
|
||||
for versuch in (1, 2):
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=self.timeout, context=self.ssl_ctx) as r:
|
||||
inhalt = r.read().decode('utf-8', 'replace')
|
||||
antwort = Antwort(r.status, dict(r.headers), inhalt,
|
||||
int((time.time() - start) * 1000), url, method)
|
||||
break
|
||||
except urllib.error.HTTPError as e:
|
||||
inhalt = e.read().decode('utf-8', 'replace')
|
||||
antwort = Antwort(e.code, dict(e.headers), inhalt,
|
||||
int((time.time() - start) * 1000), url, method)
|
||||
break
|
||||
except urllib.error.URLError as e:
|
||||
if versuch == 2:
|
||||
raise Fehlschlag("%s %s nicht erreichbar: %s" % (method, url, e.reason))
|
||||
time.sleep(0.5)
|
||||
|
||||
self.aufrufe += 1
|
||||
return antwort
|
||||
|
||||
def get(self, pfad, **kw):
|
||||
return self.request('GET', pfad, **kw)
|
||||
|
||||
def post(self, pfad, body=None, **kw):
|
||||
return self.request('POST', pfad, body=body, **kw)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Pruefungen
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def erwarte(bedingung, meldung):
|
||||
if not bedingung:
|
||||
raise Fehlschlag(meldung)
|
||||
|
||||
|
||||
def erwarte_status(resp, *codes):
|
||||
if resp.status not in codes:
|
||||
raise Fehlschlag("%s %s: HTTP %d erwartet %s | %s" % (
|
||||
resp.method, resp.url, resp.status,
|
||||
'/'.join(str(c) for c in codes), resp.kurz(300)))
|
||||
return resp
|
||||
|
||||
|
||||
def erwarte_erfolg(resp):
|
||||
erwarte_status(resp, 200, 201)
|
||||
if not resp.ok:
|
||||
raise Fehlschlag("%s %s: Antwort ohne status=success | %s" % (
|
||||
resp.method, resp.url, resp.kurz(300)))
|
||||
return resp
|
||||
|
||||
|
||||
def erwarte_fehler(resp, http=None, code=None):
|
||||
if http is not None:
|
||||
erwarte_status(resp, *(http if isinstance(http, (list, tuple)) else [http]))
|
||||
erwarte(isinstance(resp.json, dict) and resp.json.get('status') == 'error',
|
||||
"%s %s: Fehlerantwort erwartet, kam: %s" % (resp.method, resp.url, resp.kurz(300)))
|
||||
if code is not None:
|
||||
codes = code if isinstance(code, (list, tuple)) else [code]
|
||||
erwarte(resp.fehlercode in codes,
|
||||
"%s %s: error.code %s erwartet, war %r" % (
|
||||
resp.method, resp.url, '/'.join(codes), resp.fehlercode))
|
||||
return resp
|
||||
|
||||
|
||||
def ueberspringen(grund):
|
||||
raise Uebersprungen(grund)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Registrierung
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class Fall:
|
||||
def __init__(self, gruppe, name, fn, doc):
|
||||
self.gruppe = gruppe
|
||||
self.name = name
|
||||
self.fn = fn
|
||||
self.doc = doc
|
||||
|
||||
|
||||
FAELLE = []
|
||||
|
||||
|
||||
def test(gruppe, name):
|
||||
"""Registriert einen Testfall. Reihenfolge der Registrierung = Ablauf."""
|
||||
def deco(fn):
|
||||
FAELLE.append(Fall(gruppe, name, fn, (fn.__doc__ or '').strip()))
|
||||
return fn
|
||||
return deco
|
||||
|
||||
|
||||
class Ctx:
|
||||
"""
|
||||
Der Zustand, den Testfaelle teilen.
|
||||
|
||||
Ein Bugtracker-Item muss angelegt sein, bevor es sich uebernehmen laesst;
|
||||
ein Sub-Token muss existieren, bevor sich pruefen laesst, was es nicht
|
||||
darf. Solche Ketten hier abzubilden ist ehrlicher, als jedem Testfall ein
|
||||
eigenes Item unterzuschieben - geprueft wird schliesslich der Ablauf, den
|
||||
ein Agent tatsaechlich nimmt.
|
||||
"""
|
||||
|
||||
def __init__(self, cfg, client):
|
||||
self.cfg = cfg
|
||||
self.client = client
|
||||
self.daten = {}
|
||||
|
||||
def __getitem__(self, k):
|
||||
if k not in self.daten:
|
||||
ueberspringen("Voraussetzung fehlt: %s (vorheriger Testfall nicht durchgelaufen)" % k)
|
||||
return self.daten[k]
|
||||
|
||||
def __setitem__(self, k, v):
|
||||
self.daten[k] = v
|
||||
|
||||
def get(self, k, default=None):
|
||||
return self.daten.get(k, default)
|
||||
|
||||
def hat(self, k):
|
||||
return k in self.daten
|
||||
|
||||
# -- Steuerung der Instanz ------------------------------------------
|
||||
def testctl(self, action, **params):
|
||||
body = dict(params)
|
||||
body['action'] = action
|
||||
return self.client.post('/testctl.php', body=body,
|
||||
token=self.cfg['keys']['shared_key'])
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Konfiguration
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def lade_bekannte_befunde():
|
||||
"""
|
||||
Testfaelle, die einen gemeldeten, noch offenen Befund beschreiben.
|
||||
|
||||
Siehe die Begruendung in bekannte_befunde.json selbst: Ein dauerhaft roter
|
||||
Lauf ist genauso wertlos wie ein geloeschter Testfall - beim einen sieht
|
||||
niemand mehr hin, beim anderen ist der Befund vergessen.
|
||||
"""
|
||||
p = Path(__file__).resolve().parent / 'bekannte_befunde.json'
|
||||
if not p.exists():
|
||||
return {}
|
||||
with open(p, 'r', encoding='utf-8') as f:
|
||||
roh = json.load(f)
|
||||
return {k: v for k, v in roh.items() if not k.startswith('_')}
|
||||
|
||||
|
||||
def lade_config(pfad=None):
|
||||
p = Path(pfad) if pfad else DEV_CONFIG
|
||||
if not p.exists():
|
||||
print("Konfiguration fehlt: %s" % p)
|
||||
print("Vorlage: scripts/deploy_dev_config.example.json")
|
||||
sys.exit(2)
|
||||
with open(p, 'r', encoding='utf-8') as f:
|
||||
return json.load(f)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Ausfuehrung
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def run(cfg, gruppen=None, muster=None, reset=True, bericht_datei=None, stoppen=False,
|
||||
adressfamilie=None):
|
||||
host = urllib.parse.urlparse(cfg['base_url']).hostname
|
||||
weg = waehle_adressfamilie(host, 443, adressfamilie)
|
||||
|
||||
client = Client(cfg['base_url'],
|
||||
cfg['basic_auth']['user'], cfg['basic_auth']['pass'])
|
||||
ctx = Ctx(cfg, client)
|
||||
|
||||
faelle = FAELLE
|
||||
if gruppen:
|
||||
faelle = [f for f in faelle if f.gruppe in gruppen]
|
||||
if muster:
|
||||
m = muster.lower()
|
||||
faelle = [f for f in faelle if m in f.name.lower() or m in f.gruppe.lower()]
|
||||
|
||||
if not faelle:
|
||||
print("Kein Testfall passt zur Auswahl.")
|
||||
return 2
|
||||
|
||||
print("Testinstanz : %s (%s)" % (cfg['base_url'], weg))
|
||||
print("Testfaelle : %d in %d Gruppe(n)" % (
|
||||
len(faelle), len(set(f.gruppe for f in faelle))))
|
||||
|
||||
if reset:
|
||||
print("")
|
||||
print("Ausgangszustand herstellen ...")
|
||||
r = ctx.testctl('reset', confirm=cfg['db']['name'],
|
||||
admin_user=cfg['admin']['user'], admin_pass=cfg['admin']['pass'])
|
||||
if not r.ok:
|
||||
print(Farbe.rot(" Zuruecksetzen fehlgeschlagen: %s" % r.kurz(500)))
|
||||
return 2
|
||||
ctx['master_token'] = r.feld('master', 'raw_token')
|
||||
ctx['admin'] = r.feld('admin')
|
||||
client.token = ctx['master_token']
|
||||
print(" %d Migration(en), %d Tabellen, Master-Token bereit." % (
|
||||
len(r.feld('migration', 'applied', default=[])),
|
||||
r.feld('state', 'tables', default=0)))
|
||||
else:
|
||||
if not cfg.get('master_token'):
|
||||
print(Farbe.gelb(" Ohne --reset wird ein master_token in der Konfiguration erwartet."))
|
||||
ctx['master_token'] = cfg.get('master_token')
|
||||
client.token = ctx['master_token']
|
||||
|
||||
ergebnisse = []
|
||||
gruppe_aktiv = None
|
||||
bekannte = lade_bekannte_befunde()
|
||||
start_gesamt = time.time()
|
||||
|
||||
for fall in faelle:
|
||||
if fall.gruppe != gruppe_aktiv:
|
||||
gruppe_aktiv = fall.gruppe
|
||||
print("")
|
||||
print(Farbe.grau("--- %s %s" % (gruppe_aktiv, '-' * max(0, 58 - len(gruppe_aktiv)))))
|
||||
|
||||
start = time.time()
|
||||
zustand, meldung = 'ok', ''
|
||||
try:
|
||||
fall.fn(ctx)
|
||||
except Uebersprungen as e:
|
||||
zustand, meldung = 'skip', str(e)
|
||||
except Fehlschlag as e:
|
||||
zustand, meldung = 'fail', str(e)
|
||||
except Exception as e: # unerwarteter Fehler im Testfall selbst
|
||||
zustand = 'fail'
|
||||
meldung = "%s: %s" % (type(e).__name__, e)
|
||||
ms = int((time.time() - start) * 1000)
|
||||
|
||||
# Bekannter, gemeldeter Befund? Dann ist ein Fehlschlag erwartet und
|
||||
# blockiert nichts - und ein unerwartetes Bestehen ist die Nachricht.
|
||||
bekannt = bekannte.get("%s/%s" % (fall.gruppe, fall.name))
|
||||
item = bekannt.get('item') if bekannt else None
|
||||
if bekannt and zustand == 'fail':
|
||||
zustand = 'bekannt'
|
||||
elif bekannt and zustand == 'ok':
|
||||
zustand = 'behoben'
|
||||
|
||||
ergebnisse.append({'gruppe': fall.gruppe, 'name': fall.name,
|
||||
'zustand': zustand, 'ms': ms, 'meldung': meldung,
|
||||
'zweck': fall.doc, 'item': item})
|
||||
|
||||
if zustand == 'ok':
|
||||
print(" %s %-52s %s" % (Farbe.gruen('OK '), fall.name, Farbe.grau('%4d ms' % ms)))
|
||||
elif zustand == 'bekannt':
|
||||
print(" %s %-52s %s" % (Farbe.gelb('BEK '), fall.name,
|
||||
Farbe.grau('bekannter Befund, Item #%s' % item)))
|
||||
elif zustand == 'behoben':
|
||||
print(" %s %-52s %s" % (Farbe.gruen('NEU!'), fall.name,
|
||||
Farbe.gruen('besteht wieder - Item #%s schliessen '
|
||||
'und Eintrag aus bekannte_befunde.json '
|
||||
'entfernen' % item)))
|
||||
elif zustand == 'skip':
|
||||
print(" %s %-52s %s" % (Farbe.gelb('UEBR'), fall.name, Farbe.grau(meldung[:60])))
|
||||
else:
|
||||
print(" %s %-52s %s" % (Farbe.rot('FEHL'), fall.name, Farbe.grau('%4d ms' % ms)))
|
||||
for zeile in meldung.splitlines():
|
||||
print(" %s" % Farbe.rot(zeile))
|
||||
if stoppen:
|
||||
break
|
||||
|
||||
dauer = time.time() - start_gesamt
|
||||
n_ok = sum(1 for e in ergebnisse if e['zustand'] == 'ok')
|
||||
n_fail = sum(1 for e in ergebnisse if e['zustand'] == 'fail')
|
||||
n_skip = sum(1 for e in ergebnisse if e['zustand'] == 'skip')
|
||||
n_bekannt = sum(1 for e in ergebnisse if e['zustand'] == 'bekannt')
|
||||
n_behoben = sum(1 for e in ergebnisse if e['zustand'] == 'behoben')
|
||||
|
||||
# Ein Lauf, in dem fast nichts lief, ist kein bestandener Lauf.
|
||||
#
|
||||
# Uebersprungene Faelle zaehlten bisher nirgends hinein. Damit endete
|
||||
# `run.py --nur roadmap` mit "2 bestanden, 0 fehlgeschlagen, 5
|
||||
# uebersprungen" und Rueckgabewert 0 - gruen, obwohl die Voraussetzung
|
||||
# (ctx['freies_item'] aus der Gruppe bugtracker) vom Filter
|
||||
# ausgeschlossen war und fuenf von sieben Faellen gar nicht liefen.
|
||||
#
|
||||
# Das Tor vor der Produktion war davon nie betroffen, dort laeuft alles.
|
||||
# Betroffen war der Moment, in dem ein Mensch einem einzelnen Ergebnis
|
||||
# glauben soll - und genau dort muss das Signal ehrlich sein.
|
||||
#
|
||||
# Kein Fehlschlag: Nichts ist kaputt. Aber auch kein Erfolg, denn geprueft
|
||||
# wurde es nicht. Dafuer gibt es den dritten Rueckgabewert, den run.py
|
||||
# ohnehin schon kennt: 2 = konnte nicht ordentlich laufen.
|
||||
unbrauchbar = n_fail == 0 and n_skip > (n_ok + n_behoben + n_bekannt)
|
||||
|
||||
print("")
|
||||
print("=" * 70)
|
||||
zusammenfassung = "%d bestanden, %d fehlgeschlagen, %d uebersprungen" % (
|
||||
n_ok + n_behoben, n_fail, n_skip)
|
||||
if n_bekannt:
|
||||
zusammenfassung += ", %d bekannte Befunde" % n_bekannt
|
||||
zusammenfassung += " (%.1f s, %d Aufrufe)" % (dauer, client.aufrufe)
|
||||
|
||||
if n_fail:
|
||||
print(Farbe.rot(zusammenfassung))
|
||||
elif unbrauchbar:
|
||||
print(Farbe.gelb(zusammenfassung))
|
||||
else:
|
||||
print(Farbe.gruen(zusammenfassung))
|
||||
|
||||
if unbrauchbar:
|
||||
print("")
|
||||
print(Farbe.gelb(
|
||||
"Mehr uebersprungen als geprueft - dieser Lauf sagt nichts aus."))
|
||||
print(Farbe.grau(
|
||||
" Uebersprungen wird, wenn eine Voraussetzung aus einem frueheren"))
|
||||
print(Farbe.grau(
|
||||
" Testfall fehlt. Bei --gruppe/--nur ist der meist mit weggefiltert:"))
|
||||
print(Farbe.grau(
|
||||
" ctx['freies_item'] etwa entsteht in der Gruppe bugtracker."))
|
||||
print(Farbe.grau(
|
||||
" Abhilfe: die Gruppe mit aufnehmen, oder ohne Filter laufen lassen."))
|
||||
|
||||
if n_fail:
|
||||
print("")
|
||||
print("Fehlgeschlagen:")
|
||||
for e in ergebnisse:
|
||||
if e['zustand'] == 'fail':
|
||||
print(" - %s / %s" % (e['gruppe'], e['name']))
|
||||
|
||||
if n_bekannt:
|
||||
print("")
|
||||
print("Bekannte Befunde (gemeldet, noch offen - blockieren nichts):")
|
||||
for e in ergebnisse:
|
||||
if e['zustand'] == 'bekannt':
|
||||
print(" - Item #%s %s / %s" % (e['item'], e['gruppe'], e['name']))
|
||||
|
||||
if n_behoben:
|
||||
print("")
|
||||
print(Farbe.gruen("Wieder bestanden - Eintrag aus bekannte_befunde.json entfernen:"))
|
||||
for e in ergebnisse:
|
||||
if e['zustand'] == 'behoben':
|
||||
print(" - Item #%s %s / %s" % (e['item'], e['gruppe'], e['name']))
|
||||
|
||||
if bericht_datei:
|
||||
bericht = {
|
||||
'instanz': cfg['base_url'],
|
||||
'zeit_utc': time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime()),
|
||||
'dauer_s': round(dauer, 2),
|
||||
'bestanden': n_ok + n_behoben, 'fehlgeschlagen': n_fail,
|
||||
'uebersprungen': n_skip, 'bekannte_befunde': n_bekannt,
|
||||
'wieder_bestanden': n_behoben,
|
||||
# Damit ein ausgewerteter Bericht dieselbe Auskunft gibt wie der
|
||||
# Rueckgabewert - sonst laese ein Werkzeug "0 fehlgeschlagen" und
|
||||
# hielte einen Lauf fuer bestanden, der nichts geprueft hat.
|
||||
'aussagekraeftig': not unbrauchbar,
|
||||
'faelle': ergebnisse,
|
||||
}
|
||||
Path(bericht_datei).write_text(
|
||||
json.dumps(bericht, indent=2, ensure_ascii=False), encoding='utf-8')
|
||||
print("")
|
||||
print("Bericht: %s" % bericht_datei)
|
||||
|
||||
if n_fail:
|
||||
return 1
|
||||
return 2 if unbrauchbar else 0
|
||||
@@ -0,0 +1,107 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Testlauf gegen die Testinstanz.
|
||||
|
||||
python tests/e2e/run.py # alles, mit Zuruecksetzen
|
||||
python tests/e2e/run.py --gruppe sperre # nur eine Gruppe
|
||||
python tests/e2e/run.py --nur roadmap # Namensfilter
|
||||
python tests/e2e/run.py --kein-reset # bestehenden Datenbestand behalten
|
||||
python tests/e2e/run.py --bericht bericht.json
|
||||
python tests/e2e/run.py --log # bei Fehlern das Serverlog zeigen
|
||||
|
||||
Rueckgabewert: 0 wenn alles bestanden ist, 1 bei Fehlschlaegen, 2 wenn der
|
||||
Lauf gar nicht erst starten konnte. scripts/deploy.py wertet genau das aus und
|
||||
uebertraegt nichts auf die Produktion, solange hier etwas rot ist.
|
||||
|
||||
Testfaelle, die einen gemeldeten und noch offenen Befund festhalten, stehen in
|
||||
bekannte_befunde.json. Sie laufen mit, erscheinen als BEK - und blockieren das
|
||||
Deployment nicht. Besteht so ein Fall wieder, sagt der Lauf das ausdruecklich.
|
||||
|
||||
Gruppen:
|
||||
sperre Zugangssperre und Webroot (.htaccess)
|
||||
kern Zustand, OpenAPI, Migration, allgemeines Verhalten
|
||||
token Token, Rechte, Provisionierung
|
||||
bugtracker Melden, Arbeit holen, uebernehmen, abschliessen
|
||||
roadmap Planung
|
||||
fehlerstrom Ausnahmen aus Clients
|
||||
lizenz Lizenzen und Entwicklungsschluessel
|
||||
watchdog Lebenszeichen, Ereignisse, Auswertung
|
||||
update Releases veroeffentlichen und finden
|
||||
setup Erstinstallation
|
||||
supervisor Befunde
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
|
||||
import harness # noqa: E402
|
||||
|
||||
# Die Reihenfolge der Importe ist die Reihenfolge der Testfaelle.
|
||||
import suite_sperre # noqa: E402,F401
|
||||
import suite_kern # noqa: E402,F401
|
||||
import suite_bugtracker # noqa: E402,F401
|
||||
import suite_dienste # noqa: E402,F401
|
||||
|
||||
|
||||
def main():
|
||||
p = argparse.ArgumentParser(description="Testlauf gegen die Deploymentcenter-Testinstanz")
|
||||
p.add_argument('--config', help="Pfad zu deploy_dev_config.json")
|
||||
p.add_argument('--gruppe', action='append', help="nur diese Gruppe (mehrfach moeglich)")
|
||||
p.add_argument('--nur', help="nur Testfaelle, deren Name diesen Text enthaelt")
|
||||
p.add_argument('--kein-reset', dest='reset', action='store_false',
|
||||
help="Datenbank nicht zuruecksetzen (dann master_token in der Konfiguration)")
|
||||
p.add_argument('--bericht', help="Ergebnisse zusaetzlich als JSON ablegen")
|
||||
p.add_argument('--log', action='store_true',
|
||||
help="bei Fehlschlaegen das Serverlog der Testinstanz anzeigen")
|
||||
p.add_argument('--stopp', action='store_true', help="beim ersten Fehlschlag abbrechen")
|
||||
p.add_argument('--liste', action='store_true', help="nur die Testfaelle auflisten")
|
||||
p.add_argument('--ipv4', action='store_true',
|
||||
help="IPv4 erzwingen (sonst wird IPv6 einmal geprueft und bei "
|
||||
"fehlender Route automatisch umgeschaltet)")
|
||||
args = p.parse_args()
|
||||
|
||||
cfg = harness.lade_config(args.config)
|
||||
|
||||
if args.liste:
|
||||
gruppe = None
|
||||
for fall in harness.FAELLE:
|
||||
if fall.gruppe != gruppe:
|
||||
gruppe = fall.gruppe
|
||||
print("")
|
||||
print("%s:" % gruppe)
|
||||
print(" %s" % fall.name)
|
||||
print("")
|
||||
print("%d Testfaelle" % len(harness.FAELLE))
|
||||
return 0
|
||||
|
||||
code = harness.run(cfg,
|
||||
gruppen=args.gruppe,
|
||||
muster=args.nur,
|
||||
reset=args.reset,
|
||||
bericht_datei=args.bericht,
|
||||
stoppen=args.stopp,
|
||||
adressfamilie='ipv4' if args.ipv4 else None)
|
||||
|
||||
if code == 1 and args.log:
|
||||
# Das Log liegt hinter der Sperre; ohne diesen Weg endet ein
|
||||
# fehlgeschlagener Lauf bei "HTTP 500" und dem Raten, was dahinter
|
||||
# stand.
|
||||
client = harness.Client(cfg['base_url'],
|
||||
cfg['basic_auth']['user'], cfg['basic_auth']['pass'])
|
||||
r = client.post('/testctl.php', body={'action': 'log', 'lines': 40},
|
||||
token=cfg['keys']['shared_key'])
|
||||
print("")
|
||||
print("--- Serverlog der Testinstanz " + "-" * 40)
|
||||
for datei, zeilen in (r.feld('log', default={}) or {}).items():
|
||||
print("[%s]" % datei)
|
||||
for zeile in zeilen:
|
||||
print(" %s" % zeile)
|
||||
|
||||
return code
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,474 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Bugtracker, Roadmap und Fehlerstrom.
|
||||
|
||||
Diese Gruppe geht den Weg, den ein Agent tatsaechlich nimmt: melden, Arbeit
|
||||
holen, uebernehmen, kommentieren, abschliessen. Einzelne Endpunkte fuer sich
|
||||
zu pruefen wuerde die Uebergaenge auslassen - und dort sitzen die Fehler, die
|
||||
weh tun (doppelte Items, zwei Agenten am selben Item, verlorene Reservierung).
|
||||
"""
|
||||
|
||||
import time
|
||||
|
||||
from harness import (test, erwarte, erwarte_status, erwarte_erfolg,
|
||||
erwarte_fehler, ueberspringen)
|
||||
|
||||
PROJEKT = 'myapp' # aus sql/schema.sql - ein Projekt, das nach dem Zuruecksetzen existiert
|
||||
BUILD = '9.9.9-test'
|
||||
|
||||
|
||||
def _item_id(resp):
|
||||
"""
|
||||
Die Antwort nennt die ID als item_id. Ueber diesen einen Ort geholt,
|
||||
damit eine Umbenennung im Vertrag genau einen Testfall betrifft und
|
||||
nicht zehn.
|
||||
"""
|
||||
for name in ('item_id', 'id'):
|
||||
wert = resp.feld(name)
|
||||
if isinstance(wert, int) and wert > 0:
|
||||
return wert
|
||||
return resp.feld('item', 'id')
|
||||
|
||||
|
||||
def _liste(resp, *kandidaten):
|
||||
"""Die Liste aus einer Antwort holen, ohne sich auf einen Feldnamen zu verlassen."""
|
||||
for name in kandidaten:
|
||||
wert = resp.feld(name)
|
||||
if isinstance(wert, list):
|
||||
return wert
|
||||
for wert in (resp.json or {}).values():
|
||||
if isinstance(wert, list):
|
||||
return wert
|
||||
return []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Melden
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@test('bugtracker', 'Meldung legt ein Item an')
|
||||
def _(ctx):
|
||||
ref = 'e2e-%d' % int(time.time())
|
||||
r = ctx.client.post('/api/bugtracker/v1/report', body={
|
||||
'project_slug': PROJEKT,
|
||||
'title': 'Automatischer Testlauf: Beispielbefund',
|
||||
'description': 'Angelegt von tests/e2e. Darf jederzeit geloescht werden.',
|
||||
'severity': 'high',
|
||||
'environment': 'testing',
|
||||
'build_version': BUILD,
|
||||
'error_message': 'RuntimeException: Beispiel',
|
||||
'file_path': 'tests/e2e/suite_bugtracker.py',
|
||||
'line_no': 42,
|
||||
'client_ref': ref,
|
||||
})
|
||||
erwarte_erfolg(r)
|
||||
item_id = _item_id(r)
|
||||
erwarte(isinstance(item_id, int) and item_id > 0,
|
||||
"Keine Item-ID in der Antwort: %s" % r.kurz(300))
|
||||
ctx['bug_id'] = item_id
|
||||
ctx['bug_ref'] = ref
|
||||
|
||||
|
||||
@test('bugtracker', 'Wiederholte Meldung erzeugt kein Duplikat')
|
||||
def _(ctx):
|
||||
"""
|
||||
Gleiches client_ref, gleiches Item. Ein Agent, dem die Verbindung
|
||||
wegbricht, sendet erneut - und soll damit nicht den Tracker fluten.
|
||||
"""
|
||||
r = ctx.client.post('/api/bugtracker/v1/report', body={
|
||||
'project_slug': PROJEKT,
|
||||
'title': 'Automatischer Testlauf: Beispielbefund',
|
||||
'description': 'Zweite Sendung derselben Meldung.',
|
||||
'severity': 'high',
|
||||
'environment': 'testing',
|
||||
'build_version': BUILD,
|
||||
'client_ref': ctx['bug_ref'],
|
||||
})
|
||||
erwarte_erfolg(r)
|
||||
erneut = _item_id(r)
|
||||
erwarte(erneut == ctx['bug_id'],
|
||||
"Zweite Sendung erzeugte Item #%s statt #%s" % (erneut, ctx['bug_id']))
|
||||
erwarte(r.feld('is_new') is not True,
|
||||
"Die zweite Sendung gilt als neues Item: %s" % r.kurz(300))
|
||||
|
||||
|
||||
@test('bugtracker', 'Idempotency-Key im Header wirkt wie client_ref')
|
||||
def _(ctx):
|
||||
ref = 'e2e-header-%d' % int(time.time())
|
||||
body = {
|
||||
'project_slug': PROJEKT,
|
||||
'title': 'Automatischer Testlauf: Kopfzeilen-Idempotenz',
|
||||
'description': 'Erste Sendung.',
|
||||
'environment': 'testing',
|
||||
'build_version': BUILD,
|
||||
}
|
||||
r1 = ctx.client.post('/api/bugtracker/v1/report', body=body,
|
||||
headers={'Idempotency-Key': ref})
|
||||
erwarte_erfolg(r1)
|
||||
r2 = ctx.client.post('/api/bugtracker/v1/report', body=body,
|
||||
headers={'Idempotency-Key': ref})
|
||||
erwarte_erfolg(r2)
|
||||
erwarte(_item_id(r1) == _item_id(r2),
|
||||
"Header-Idempotenz greift nicht: #%s dann #%s" % (_item_id(r1), _item_id(r2)))
|
||||
erwarte(r2.feld('idempotent_hit') is True,
|
||||
"Die zweite Sendung wurde nicht als Wiederholung erkannt: %s" % r2.kurz(300))
|
||||
|
||||
|
||||
@test('bugtracker', 'Unbekannter Schweregrad faellt auf den Standard zurueck')
|
||||
def _(ctx):
|
||||
"""
|
||||
Ein Client mit einem Tippfehler soll seine Meldung loswerden, nicht eine
|
||||
Fehlermeldung. Die Anwendung normalisiert - das muss so bleiben, sonst
|
||||
verlieren Agenten Befunde an einer Kleinigkeit.
|
||||
"""
|
||||
r = ctx.client.post('/api/bugtracker/v1/report', body={
|
||||
'project_slug': PROJEKT,
|
||||
'title': 'Automatischer Testlauf: unbekannter Schweregrad',
|
||||
'severity': 'ganz-dringend-echt-jetzt',
|
||||
'environment': 'testing',
|
||||
'build_version': BUILD,
|
||||
'client_ref': 'e2e-severity-%d' % int(time.time()),
|
||||
})
|
||||
erwarte_erfolg(r)
|
||||
|
||||
|
||||
@test('bugtracker', 'Absender kommt aus dem Token, nicht aus dem Body')
|
||||
def _(ctx):
|
||||
"""created_by darf nicht frei waehlbar sein - sonst ist die Herkunft wertlos."""
|
||||
ref = 'e2e-absender-%d' % int(time.time())
|
||||
r = ctx.client.post('/api/bugtracker/v1/report', body={
|
||||
'project_slug': PROJEKT,
|
||||
'title': 'Automatischer Testlauf: Absender',
|
||||
'environment': 'testing',
|
||||
'build_version': BUILD,
|
||||
'created_by': 'jemand-ganz-anderes',
|
||||
'client_ref': ref,
|
||||
})
|
||||
erwarte_erfolg(r)
|
||||
|
||||
detail = ctx.client.get('/api/bugtracker/v1/manage',
|
||||
query={'action': 'get', 'id': _item_id(r)})
|
||||
erwarte_erfolg(detail)
|
||||
item = detail.feld('item') or detail.json
|
||||
erstellt_von = str(item.get('created_by', '')) if isinstance(item, dict) else ''
|
||||
erwarte(erstellt_von != 'jemand-ganz-anderes',
|
||||
"created_by wurde aus dem Body uebernommen: %r" % erstellt_von)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Lesen
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@test('bugtracker', 'Projektliste enthaelt die angelegten Projekte')
|
||||
def _(ctx):
|
||||
"""
|
||||
Die Liste kommt aus dc_projects, nicht aus den Meldungen. Genau das ist
|
||||
der Unterschied, den ein Client kennen muss: In ein unbekanntes Projekt
|
||||
zu melden ist erlaubt, aber es taucht deshalb nicht in der Liste auf.
|
||||
"""
|
||||
r = ctx.client.get('/api/bugtracker/v1/projects')
|
||||
erwarte_erfolg(r)
|
||||
slugs = [p.get('slug') for p in _liste(r, 'projects') if isinstance(p, dict)]
|
||||
erwarte(PROJEKT in slugs, "Projekt %r fehlt in der Liste: %s" % (PROJEKT, slugs))
|
||||
erwarte('deploymentcenter' in slugs,
|
||||
"Das eigene Projekt fehlt in der Liste: %s" % slugs)
|
||||
|
||||
|
||||
@test('bugtracker', 'Liste findet das angelegte Item')
|
||||
def _(ctx):
|
||||
r = ctx.client.get('/api/bugtracker/v1/manage',
|
||||
query={'action': 'list', 'project_slug': PROJEKT, 'limit': 50})
|
||||
erwarte_erfolg(r)
|
||||
items = _liste(r, 'items', 'bugs', 'data')
|
||||
ids = [i.get('id') for i in items if isinstance(i, dict)]
|
||||
erwarte(ctx['bug_id'] in ids, "Item #%s nicht in der Liste (%s)" % (ctx['bug_id'], ids[:10]))
|
||||
|
||||
|
||||
@test('bugtracker', 'Einzelabruf liefert das Item')
|
||||
def _(ctx):
|
||||
r = ctx.client.get('/api/bugtracker/v1/manage',
|
||||
query={'action': 'get', 'id': ctx['bug_id']})
|
||||
erwarte_erfolg(r)
|
||||
erwarte(str(ctx['bug_id']) in (r.body or ''), "Item #%s nicht enthalten" % ctx['bug_id'])
|
||||
|
||||
|
||||
@test('bugtracker', 'Einzelabruf eines fehlenden Items antwortet 404')
|
||||
def _(ctx):
|
||||
r = ctx.client.get('/api/bugtracker/v1/manage',
|
||||
query={'action': 'get', 'id': 999999})
|
||||
erwarte_fehler(r, http=404, code='not_found')
|
||||
|
||||
|
||||
@test('bugtracker', 'Kennzahlen abrufbar')
|
||||
def _(ctx):
|
||||
r = ctx.client.get('/api/bugtracker/v1/manage',
|
||||
query={'action': 'stats', 'project_slug': PROJEKT})
|
||||
erwarte_erfolg(r)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Arbeiten
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@test('bugtracker', 'Item laesst sich uebernehmen')
|
||||
def _(ctx):
|
||||
r = ctx.client.post('/api/bugtracker/v1/manage',
|
||||
query={'action': 'claim', 'id': ctx['bug_id']}, body={})
|
||||
erwarte_erfolg(r)
|
||||
|
||||
|
||||
@test('bugtracker', 'Ein uebernommenes Item ist fuer andere gesperrt')
|
||||
def _(ctx):
|
||||
"""
|
||||
Der Kern der Reservierung: Ein zweiter Agent darf dasselbe Item nicht
|
||||
bekommen. Ohne diese Sperre arbeiten zwei Agenten am selben Fehler und
|
||||
ueberschreiben sich gegenseitig.
|
||||
"""
|
||||
r = ctx.client.post('/api/bugtracker/v1/manage',
|
||||
query={'action': 'claim', 'id': ctx['bug_id']},
|
||||
token=ctx.get('zweites_token') or ctx['master_token'], body={})
|
||||
if r.ok:
|
||||
# Dasselbe Token darf sein eigenes Item erneut uebernehmen; das ist
|
||||
# kein Fehler. Geprueft wird der fremde Zugriff - dafuer braucht es
|
||||
# ein zweites Token, das der Testlauf sich hier holt.
|
||||
prov = ctx.client.post('/api/tokens/v1/provision', body={
|
||||
'client_name': 'e2e-zweiter-agent',
|
||||
'scopes': ['bugtracker:manage'],
|
||||
})
|
||||
erwarte_erfolg(prov)
|
||||
ctx['zweites_token'] = prov.feld('sub_token')
|
||||
r = ctx.client.post('/api/bugtracker/v1/manage',
|
||||
query={'action': 'claim', 'id': ctx['bug_id']},
|
||||
token=ctx['zweites_token'], body={})
|
||||
erwarte_fehler(r, http=409, code='already_claimed')
|
||||
|
||||
|
||||
@test('bugtracker', 'Kommentar wird angenommen')
|
||||
def _(ctx):
|
||||
r = ctx.client.post('/api/bugtracker/v1/manage',
|
||||
query={'action': 'comment', 'id': ctx['bug_id']},
|
||||
body={'comment': 'Automatischer Testlauf: Zwischenstand.',
|
||||
'action_taken': 'investigated'})
|
||||
erwarte_erfolg(r)
|
||||
|
||||
|
||||
@test('bugtracker', 'Leerer Kommentar wird abgewiesen')
|
||||
def _(ctx):
|
||||
r = ctx.client.post('/api/bugtracker/v1/manage',
|
||||
query={'action': 'comment', 'id': ctx['bug_id']},
|
||||
body={'comment': ' '})
|
||||
erwarte_fehler(r, http=400, code='missing_comment')
|
||||
|
||||
|
||||
@test('bugtracker', 'Status laesst sich setzen')
|
||||
def _(ctx):
|
||||
r = ctx.client.post('/api/bugtracker/v1/manage',
|
||||
query={'action': 'status', 'id': ctx['bug_id']},
|
||||
body={'status': 'in_progress', 'notes': 'Automatischer Testlauf.'})
|
||||
erwarte_erfolg(r)
|
||||
|
||||
|
||||
@test('bugtracker', 'Status ohne Angabe wird abgewiesen')
|
||||
def _(ctx):
|
||||
r = ctx.client.post('/api/bugtracker/v1/manage',
|
||||
query={'action': 'status', 'id': ctx['bug_id']}, body={})
|
||||
erwarte_fehler(r, http=400, code='missing_status')
|
||||
|
||||
|
||||
@test('bugtracker', 'Naechste Arbeit wird ausgegeben und reserviert')
|
||||
def _(ctx):
|
||||
r = ctx.client.post('/api/bugtracker/v1/manage',
|
||||
query={'action': 'next'},
|
||||
body={'project_slug': PROJEKT, 'limit': 1})
|
||||
erwarte_erfolg(r)
|
||||
items = _liste(r, 'items', 'item')
|
||||
ctx['next_id'] = items[0].get('id') if items and isinstance(items[0], dict) else None
|
||||
|
||||
|
||||
@test('bugtracker', 'Abschluss verlangt die Build-Angabe')
|
||||
def _(ctx):
|
||||
"""Ohne "behoben in" ist ein Abschluss nicht nachpruefbar."""
|
||||
r = ctx.client.post('/api/bugtracker/v1/manage',
|
||||
query={'action': 'resolve', 'id': ctx['bug_id']}, body={})
|
||||
erwarte_fehler(r, http=400, code='missing_build')
|
||||
|
||||
|
||||
@test('bugtracker', 'Item laesst sich abschliessen')
|
||||
def _(ctx):
|
||||
r = ctx.client.post('/api/bugtracker/v1/manage',
|
||||
query={'action': 'resolve', 'id': ctx['bug_id']},
|
||||
body={'resolved_in_build': BUILD,
|
||||
'resolution_notes': 'Automatischer Testlauf.'})
|
||||
erwarte_erfolg(r)
|
||||
|
||||
|
||||
@test('bugtracker', 'Reservierung laesst sich zuruecknehmen')
|
||||
def _(ctx):
|
||||
neu = ctx.client.post('/api/bugtracker/v1/report', body={
|
||||
'project_slug': PROJEKT,
|
||||
'title': 'Automatischer Testlauf: Reservierung zuruecknehmen',
|
||||
'environment': 'testing',
|
||||
'build_version': BUILD,
|
||||
'client_ref': 'e2e-release-%d' % int(time.time()),
|
||||
})
|
||||
erwarte_erfolg(neu)
|
||||
item = _item_id(neu)
|
||||
|
||||
erwarte_erfolg(ctx.client.post('/api/bugtracker/v1/manage',
|
||||
query={'action': 'claim', 'id': item}, body={}))
|
||||
r = ctx.client.post('/api/bugtracker/v1/manage',
|
||||
query={'action': 'release', 'id': item},
|
||||
body={'note': 'Automatischer Testlauf.'})
|
||||
erwarte_erfolg(r)
|
||||
ctx['freies_item'] = item
|
||||
|
||||
|
||||
@test('bugtracker', 'Unbekannte Aktion wird abgewiesen')
|
||||
def _(ctx):
|
||||
r = ctx.client.get('/api/bugtracker/v1/manage', query={'action': 'alles-loeschen'})
|
||||
erwarte_fehler(r, http=(400, 404))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Roadmap
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@test('roadmap', 'Epic laesst sich anlegen')
|
||||
def _(ctx):
|
||||
r = ctx.client.post('/api/bugtracker/v1/roadmap.php',
|
||||
query={'action': 'epic'},
|
||||
body={'project_slug': PROJEKT, 'epic': 'e2e-epic',
|
||||
'title': 'Testlauf-Epic',
|
||||
'summary': 'Angelegt vom automatischen Testlauf.',
|
||||
'sort_order': 10})
|
||||
erwarte_erfolg(r)
|
||||
|
||||
|
||||
@test('roadmap', 'Item laesst sich einplanen')
|
||||
def _(ctx):
|
||||
r = ctx.client.post('/api/bugtracker/v1/roadmap.php',
|
||||
query={'action': 'set', 'id': ctx['freies_item']},
|
||||
body={'project_slug': PROJEKT, 'epic': 'e2e-epic',
|
||||
'roadmap_rank': 5, 'target_build': BUILD})
|
||||
erwarte_erfolg(r)
|
||||
|
||||
|
||||
@test('roadmap', 'Einplanen ohne Feld wird abgewiesen')
|
||||
def _(ctx):
|
||||
r = ctx.client.post('/api/bugtracker/v1/roadmap.php',
|
||||
query={'action': 'set', 'id': ctx['freies_item']},
|
||||
body={'project_slug': PROJEKT})
|
||||
erwarte_fehler(r, http=400, code='nothing_to_do')
|
||||
|
||||
|
||||
@test('roadmap', 'Einplanen eines fehlenden Items antwortet 404')
|
||||
def _(ctx):
|
||||
r = ctx.client.post('/api/bugtracker/v1/roadmap.php',
|
||||
query={'action': 'set', 'id': 999999},
|
||||
body={'project_slug': PROJEKT, 'epic': 'e2e-epic'})
|
||||
erwarte_fehler(r, http=404, code='not_found')
|
||||
|
||||
|
||||
@test('roadmap', 'Zurueckstellen ohne Ausloeser wird abgewiesen')
|
||||
def _(ctx):
|
||||
"""
|
||||
Die einzige echte Regel des Moduls.
|
||||
|
||||
Ohne benannten Anlass ist ein Vorhaben nicht zurueckgestellt, sondern
|
||||
vergessen - und ein vergessener Punkt sieht in jeder Liste aus wie ein
|
||||
geplanter. Die Regel sitzt im Repository und muss deshalb auf allen
|
||||
Schreibwegen gleich greifen; geprueft wird hier der ueber roadmap.php.
|
||||
"""
|
||||
r = ctx.client.post('/api/bugtracker/v1/roadmap.php',
|
||||
query={'action': 'set', 'id': ctx['freies_item']},
|
||||
body={'project_slug': PROJEKT, 'status': 'deferred'})
|
||||
erwarte_fehler(r, http=400, code='invalid_update')
|
||||
|
||||
|
||||
@test('roadmap', 'Zurueckstellen mit Ausloeser wird angenommen')
|
||||
def _(ctx):
|
||||
"""
|
||||
Die Gegenprobe. Ohne sie liesse sich der Fall darueber auch dadurch
|
||||
bestehen, dass 'deferred' gar nicht mehr geht.
|
||||
"""
|
||||
r = ctx.client.post('/api/bugtracker/v1/roadmap.php',
|
||||
query={'action': 'set', 'id': ctx['freies_item']},
|
||||
body={'project_slug': PROJEKT, 'status': 'deferred',
|
||||
'defer_trigger': 'sobald der Testlauf danach fragt'})
|
||||
erwarte_erfolg(r)
|
||||
|
||||
|
||||
@test('roadmap', 'Zurueckgestelltes taucht nicht in der Arbeitsschlange auf')
|
||||
def _(ctx):
|
||||
"""
|
||||
Der Zweck des Status. Stuende ein zurueckgestelltes Vorhaben weiterhin in
|
||||
?action=next, waere es lediglich anders beschriftet.
|
||||
"""
|
||||
r = ctx.client.post('/api/bugtracker/v1/manage',
|
||||
query={'action': 'next'}, body={'limit': 5})
|
||||
erwarte_erfolg(r)
|
||||
ids = [i.get('id') for i in (r.feld('items', default=[]) or [])]
|
||||
|
||||
# ?action=next uebernimmt, was es ausgibt. Wieder freigeben, sonst haelt
|
||||
# dieser Testfall fuenf Items bis zum Lease-Ende - und der naechste Lauf
|
||||
# oder ein Mensch an der Instanz wundert sich, wo die Arbeit hin ist.
|
||||
for i in ids:
|
||||
ctx.client.post('/api/bugtracker/v1/manage',
|
||||
query={'action': 'release', 'id': i},
|
||||
body={'note': 'Automatischer Testlauf.'})
|
||||
|
||||
erwarte(ctx['freies_item'] not in ids,
|
||||
'Item #%s ist zurueckgestellt, wurde aber als Arbeit ausgegeben'
|
||||
% ctx['freies_item'])
|
||||
|
||||
|
||||
@test('roadmap', 'Roadmap laesst sich lesen')
|
||||
def _(ctx):
|
||||
for aktion in ('list', 'epics', 'next', 'active'):
|
||||
r = ctx.client.get('/api/bugtracker/v1/roadmap.php',
|
||||
query={'action': aktion, 'project_slug': PROJEKT})
|
||||
erwarte_erfolg(r)
|
||||
|
||||
|
||||
@test('roadmap', 'Roadmap laesst sich rendern')
|
||||
def _(ctx):
|
||||
r = ctx.client.get('/api/bugtracker/v1/roadmap.php',
|
||||
query={'action': 'render', 'project_slug': PROJEKT})
|
||||
erwarte_erfolg(r)
|
||||
|
||||
|
||||
@test('roadmap', 'Roadmap-Schreibzugriff verlangt Verwaltungsrecht')
|
||||
def _(ctx):
|
||||
r = ctx.client.post('/api/bugtracker/v1/roadmap.php',
|
||||
query={'action': 'set', 'id': ctx['freies_item']},
|
||||
token=ctx['sub_token'],
|
||||
body={'project_slug': PROJEKT, 'epic': 'e2e-epic'})
|
||||
erwarte_fehler(r, http=(401, 403))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fehlerstrom
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@test('fehlerstrom', 'Ausnahme laesst sich melden')
|
||||
def _(ctx):
|
||||
r = ctx.client.post('/api/errors/v1/report', body={
|
||||
'project_slug': PROJEKT,
|
||||
'exception': 'System.NullReferenceException',
|
||||
'message': 'Automatischer Testlauf: Beispielausnahme',
|
||||
'stack_trace': 'at Test.Method() in Test.cs:line 1',
|
||||
'level': 'error',
|
||||
'build': BUILD,
|
||||
'environment': 'testing',
|
||||
'file': 'Test.cs',
|
||||
'line': 1,
|
||||
'client_ref': 'e2e-error-%d' % int(time.time()),
|
||||
})
|
||||
erwarte_erfolg(r)
|
||||
|
||||
|
||||
@test('fehlerstrom', 'Meldung ohne Token abgewiesen')
|
||||
def _(ctx):
|
||||
r = ctx.client.post('/api/errors/v1/report', token=None,
|
||||
body={'project_slug': PROJEKT, 'message': 'ohne Token'})
|
||||
erwarte_fehler(r, http=401)
|
||||
@@ -0,0 +1,583 @@
|
||||
#!/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 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'))
|
||||
@@ -0,0 +1,283 @@
|
||||
#!/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)
|
||||
@@ -0,0 +1,174 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Zugangssperre und Webroot.
|
||||
|
||||
Diese Gruppe prueft nicht die Anwendung, sondern das, was vor ihr steht: die
|
||||
.htaccess. Sie steht am Anfang jedes Laufs, weil eine offene Testinstanz ein
|
||||
groesseres Problem waere als jeder Befund darin - und weil ein Deployment die
|
||||
.htaccess neu schreibt, also jedes Mal die Gelegenheit hat, sie kaputt zu
|
||||
machen.
|
||||
"""
|
||||
|
||||
import base64
|
||||
|
||||
from harness import test, erwarte, erwarte_status, erwarte_fehler
|
||||
|
||||
|
||||
@test('sperre', 'Startseite ohne Zugangsdaten abgewiesen')
|
||||
def _(ctx):
|
||||
"""Ohne Basic-Auth darf die Testinstanz nichts ausliefern."""
|
||||
r = ctx.client.get('/', basic=False, token=None)
|
||||
erwarte_status(r, 401)
|
||||
|
||||
|
||||
@test('sperre', 'Startseite mit falschem Passwort abgewiesen')
|
||||
def _(ctx):
|
||||
"""Ein falsches Passwort darf nicht durchrutschen - sonst waere die Datei kaputt."""
|
||||
falsch = base64.b64encode(b'deploy:definitiv-falsch').decode()
|
||||
r = ctx.client.get('/', basic=False, token=None,
|
||||
headers={'Authorization': 'Basic ' + falsch})
|
||||
erwarte_status(r, 401)
|
||||
|
||||
|
||||
@test('sperre', 'Startseite mit Zugangsdaten erreichbar')
|
||||
def _(ctx):
|
||||
"""Mit den richtigen Daten muss das WebUI antworten, sonst sperrt die Datei zu viel."""
|
||||
r = ctx.client.get('/', token=None)
|
||||
erwarte_status(r, 200, 302)
|
||||
|
||||
|
||||
@test('sperre', 'Konfiguration nicht abrufbar')
|
||||
def _(ctx):
|
||||
"""config/config.php enthaelt Zugangsdaten der Instanz."""
|
||||
for pfad in ('/config/config.php', '/config/.htpasswd', '/config/config.example.php'):
|
||||
r = ctx.client.get(pfad, token=None)
|
||||
erwarte_status(r, 403)
|
||||
|
||||
|
||||
@test('sperre', 'Anwendungscode nicht abrufbar')
|
||||
def _(ctx):
|
||||
"""src/, sql/ und cli/ sind kein Webinhalt."""
|
||||
for pfad in ('/src/Core/Config.php', '/src/bootstrap.php',
|
||||
'/sql/schema.sql', '/sql/migrations/016_roadmap.sql',
|
||||
'/cli/tick.php', '/cli/roadmap.php'):
|
||||
r = ctx.client.get(pfad, token=None)
|
||||
erwarte_status(r, 403)
|
||||
|
||||
|
||||
@test('sperre', 'Laufzeitverzeichnis nicht abrufbar')
|
||||
def _(ctx):
|
||||
"""In var/log/ stehen Fehlermeldungen samt Pfaden."""
|
||||
for pfad in ('/var/log/', '/var/.htaccess'):
|
||||
r = ctx.client.get(pfad, token=None)
|
||||
erwarte_status(r, 403)
|
||||
|
||||
|
||||
@test('sperre', 'Serverdaten und Deployment-Konfiguration nicht vorhanden')
|
||||
def _(ctx):
|
||||
"""Was gar nicht erst hochgeladen wird, kann auch nicht gelesen werden."""
|
||||
for pfad in ('/Serverdaten.txt', '/scripts/deploy_config.json',
|
||||
'/scripts/deploy_dev_config.json', '/tests/server/testctl.php'):
|
||||
r = ctx.client.get(pfad, token=None)
|
||||
erwarte_status(r, 403, 404)
|
||||
|
||||
|
||||
@test('sperre', 'Interne Dokumentation nicht abrufbar')
|
||||
def _(ctx):
|
||||
"""
|
||||
docs/ im Repository ist nicht public/docs/. Dort stehen interne Adressen
|
||||
und Wege, die Instanz zurueckzusetzen. Erreichbar ist es nicht, weil
|
||||
/docs/... auf public/docs/ umgeschrieben wird - eine Wirkung, die eine
|
||||
Aenderung an den Routen versehentlich aufheben koennte.
|
||||
"""
|
||||
for pfad in ('/docs/TESTINSTANZ.md', '/docs/TESTUMGEBUNG.md', '/docs/ROADMAP.md'):
|
||||
r = ctx.client.get(pfad, token=None)
|
||||
erwarte_status(r, 403, 404)
|
||||
|
||||
|
||||
@test('sperre', 'Oeffentliche Dokumentation weiterhin erreichbar')
|
||||
def _(ctx):
|
||||
"""Die Gegenprobe: public/docs/ soll die Sperre nicht mitgesperrt haben."""
|
||||
r = ctx.client.get('/docs/bugtracker.md', token=None)
|
||||
erwarte_status(r, 200)
|
||||
|
||||
|
||||
@test('sperre', 'Kein Verzeichnisindex')
|
||||
def _(ctx):
|
||||
"""Options -Indexes: Verzeichnisse duerfen ihren Inhalt nicht auflisten."""
|
||||
for pfad in ('/public/assets/', '/public/docs/release-template/'):
|
||||
r = ctx.client.get(pfad, token=None)
|
||||
erwarte(r.status in (403, 404) or 'Index of' not in (r.body or ''),
|
||||
"%s liefert einen Verzeichnisindex" % pfad)
|
||||
|
||||
|
||||
@test('sperre', 'API ohne Token bleibt hinter der Sperre')
|
||||
def _(ctx):
|
||||
"""Die Ausnahme in der .htaccess greift nur mit Token-Header, nie ohne."""
|
||||
r = ctx.client.get('/api/health', basic=False, token=None)
|
||||
erwarte_status(r, 401)
|
||||
erwarte('WWW-Authenticate' in ' '.join(r.headers.keys()) or 'www-authenticate' in r.headers,
|
||||
"401 ohne WWW-Authenticate - das ist die Anwendung, nicht die Sperre")
|
||||
|
||||
|
||||
@test('sperre', 'API mit Token-Header wird durchgelassen')
|
||||
def _(ctx):
|
||||
"""Mit X-Agent-Token darf ein SDK die API ohne Basic-Auth erreichen."""
|
||||
r = ctx.client.get('/api/health', basic=False)
|
||||
erwarte(r.status != 401 or r.fehlercode is not None,
|
||||
"Die .htaccess laesst /api/ mit Token-Header nicht durch (HTTP %d)" % r.status)
|
||||
erwarte_status(r, 200, 503)
|
||||
|
||||
|
||||
@test('sperre', 'API mit Bearer wird durchgelassen')
|
||||
def _(ctx):
|
||||
"""Der zweite Weg, ein Token zu uebergeben - vom .NET-SDK genutzt."""
|
||||
r = ctx.client.get('/api/health', basic=False, bearer=True)
|
||||
erwarte_status(r, 200, 503)
|
||||
|
||||
|
||||
@test('sperre', 'Token-Header oeffnet nur /api/')
|
||||
def _(ctx):
|
||||
"""Ein Token-Header darf nicht das WebUI oder die Doku freischalten."""
|
||||
for pfad in ('/', '/index.php', '/login.php', '/docs/', '/install_db.php', '/testctl.php'):
|
||||
r = ctx.client.get(pfad, basic=False)
|
||||
erwarte_status(r, 401)
|
||||
|
||||
|
||||
@test('sperre', 'Sicherheitskopfzeilen gesetzt')
|
||||
def _(ctx):
|
||||
"""nosniff, Frame-Options und Referrer-Policy kommen aus der .htaccess."""
|
||||
r = ctx.client.get('/api/health')
|
||||
erwarte(r.headers.get('x-content-type-options') == 'nosniff',
|
||||
"X-Content-Type-Options fehlt oder ist %r" % r.headers.get('x-content-type-options'))
|
||||
r2 = ctx.client.get('/', token=None)
|
||||
fehlend = [h for h in ('x-frame-options', 'referrer-policy')
|
||||
if h not in r2.headers]
|
||||
erwarte(not fehlend, "Fehlende Kopfzeilen auf der Startseite: %s" % ', '.join(fehlend))
|
||||
|
||||
|
||||
@test('sperre', 'testctl ohne Shared Key abgewiesen')
|
||||
def _(ctx):
|
||||
"""Die Basic-Auth allein darf nicht genuegen, um Tabellen zu loeschen."""
|
||||
r = ctx.client.post('/testctl.php', body={'action': 'status'}, token=None)
|
||||
erwarte_fehler(r, http=401, code='unauthorized')
|
||||
|
||||
|
||||
@test('sperre', 'testctl mit falschem Shared Key abgewiesen')
|
||||
def _(ctx):
|
||||
r = ctx.client.post('/testctl.php', body={'action': 'status'}, token='dc_master_falsch')
|
||||
erwarte_fehler(r, http=401, code='unauthorized')
|
||||
|
||||
|
||||
@test('sperre', 'testctl loescht nicht ohne Bestaetigung')
|
||||
def _(ctx):
|
||||
"""Ohne den Namen der Datenbank in confirm darf nichts verschwinden."""
|
||||
r = ctx.testctl('wipe')
|
||||
erwarte_fehler(r, http=400, code='confirmation_required')
|
||||
|
||||
r2 = ctx.testctl('wipe', confirm='irgendwas-anderes')
|
||||
erwarte_fehler(r2, http=400, code='confirmation_required')
|
||||
|
||||
# Und die Tabellen stehen noch.
|
||||
r3 = ctx.testctl('status')
|
||||
erwarte(r3.feld('tables', default=0) > 20,
|
||||
"Nach den abgewiesenen Loeschversuchen fehlen Tabellen: %s" % r3.kurz(200))
|
||||
@@ -0,0 +1,265 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Steuerung der Testinstanz.
|
||||
*
|
||||
* ACHTUNG: Diese Datei liegt NUR auf der Testinstanz. scripts/deploy_dev.py
|
||||
* laedt sie eigens nach /testctl.php hoch; scripts/deploy.py schliesst das
|
||||
* ganze Verzeichnis tests/ aus. Sie darf nie Teil eines Produktivstands
|
||||
* werden - deshalb steht sie hier und nicht unter public/.
|
||||
*
|
||||
* Warum es sie ueberhaupt gibt: Ein automatisierter Testlauf braucht einen
|
||||
* definierten Ausgangszustand. Ohne den prueft der zweite Lauf etwas anderes
|
||||
* als der erste, und ein Fehlschlag sagt nichts mehr darueber aus, ob die
|
||||
* Anwendung kaputt ist oder nur der Datenbestand gewachsen. Das Zuruecksetzen
|
||||
* muss dort passieren, wo die Datenbank steht - also auf dem Server.
|
||||
*
|
||||
* Drei Schloesser sitzen davor, und jedes einzelne wuerde reichen:
|
||||
* 1. Basic-Auth der Testinstanz (die Datei liegt nicht unter /api/)
|
||||
* 2. testing.enabled = true UND app.env = 'test' in der config.php.
|
||||
* Die Produktivkonfiguration hat beides nicht.
|
||||
* 3. Der Shared Key der Instanz, per Header. Kein Session-Zugang: ein
|
||||
* Browserfenster, das noch offen ist, soll keine Tabellen loeschen.
|
||||
* Fuer die zerstoerenden Aktionen kommt der Name der Datenbank als
|
||||
* Bestaetigung dazu - so laesst sich "reset" nicht versehentlich gegen die
|
||||
* falsche Instanz absetzen.
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/src/bootstrap.php';
|
||||
|
||||
use Deploymentcenter\Core\Config;
|
||||
use Deploymentcenter\Core\Db;
|
||||
use Deploymentcenter\Core\Http;
|
||||
use Deploymentcenter\Core\Migrator;
|
||||
use Deploymentcenter\Core\TokenManager;
|
||||
|
||||
Http::beginJson(['GET', 'POST', 'OPTIONS']);
|
||||
|
||||
// --- Schloss 2: Diese Instanz muss sich selbst als Testinstanz ausweisen ---
|
||||
if (Config::get('testing.enabled') !== true || Config::get('app.env') !== 'test') {
|
||||
Http::fail(
|
||||
403,
|
||||
'not_a_test_instance',
|
||||
'testctl steht nur auf einer Instanz mit testing.enabled = true und app.env = "test" zur Verfuegung.'
|
||||
);
|
||||
}
|
||||
|
||||
// --- Schloss 3: Shared Key, ohne Umweg ueber Sitzung oder Sub-Token ---
|
||||
$sharedKey = (string)Config::get('security.shared_key', '');
|
||||
$presented = (string)(Http::bearerToken() ?? '');
|
||||
if ($sharedKey === '' || !hash_equals($sharedKey, $presented)) {
|
||||
Http::fail(401, 'unauthorized', 'Shared Key erforderlich (Header X-Master-Token oder Authorization: Bearer).');
|
||||
}
|
||||
|
||||
$db = Db::init();
|
||||
$dbName = (string)Config::get('db.dbname', '');
|
||||
$action = strtolower(trim((string)(Http::str('action') ?? 'status')));
|
||||
|
||||
/** Bestaetigung fuer alles, was Daten vernichtet. */
|
||||
$requireConfirmation = static function () use ($dbName): void {
|
||||
if ((string)(Http::str('confirm') ?? '') !== $dbName) {
|
||||
Http::fail(
|
||||
400,
|
||||
'confirmation_required',
|
||||
sprintf('Zur Bestaetigung muss confirm den Datenbanknamen enthalten ("%s").', $dbName)
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
/** Alle Tabellen der Testdatenbank entfernen. */
|
||||
$wipe = static function () use ($db): array {
|
||||
$tables = $db->query('SHOW TABLES')->fetchAll(PDO::FETCH_COLUMN) ?: [];
|
||||
if ($tables === []) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Fremdschluessel vorruebergehend aus: sonst haengt die Reihenfolge des
|
||||
// Loeschens von der Reihenfolge der Tabellen ab, und die ist nicht stabil.
|
||||
$db->exec('SET FOREIGN_KEY_CHECKS = 0');
|
||||
try {
|
||||
foreach ($tables as $table) {
|
||||
$db->exec('DROP TABLE IF EXISTS `' . str_replace('`', '', (string)$table) . '`');
|
||||
}
|
||||
} finally {
|
||||
$db->exec('SET FOREIGN_KEY_CHECKS = 1');
|
||||
}
|
||||
|
||||
return array_map('strval', $tables);
|
||||
};
|
||||
|
||||
/** Zustand der Instanz - der Blick, den ein Testlauf vor und nach sich wirft. */
|
||||
$status = static function () use ($db, $dbName): array {
|
||||
$tables = $db->query('SHOW TABLES')->fetchAll(PDO::FETCH_COLUMN) ?: [];
|
||||
$counts = [];
|
||||
foreach ($tables as $table) {
|
||||
$name = (string)$table;
|
||||
try {
|
||||
$counts[$name] = (int)$db->query('SELECT COUNT(*) FROM `' . str_replace('`', '', $name) . '`')->fetchColumn();
|
||||
} catch (Throwable $e) {
|
||||
$counts[$name] = null;
|
||||
}
|
||||
}
|
||||
|
||||
$migrations = [];
|
||||
if (in_array('dc_migrations', array_map('strval', $tables), true)) {
|
||||
$migrations = $db->query('SELECT version FROM dc_migrations ORDER BY version')
|
||||
->fetchAll(PDO::FETCH_COLUMN) ?: [];
|
||||
}
|
||||
|
||||
return [
|
||||
'database' => $dbName,
|
||||
'php' => PHP_VERSION,
|
||||
'app_version' => Config::get('app.version'),
|
||||
'debug' => Config::isDebug(),
|
||||
'tables' => count($tables),
|
||||
'rows' => $counts,
|
||||
'migrations' => array_map('strval', $migrations),
|
||||
'log_dir' => is_writable(DC_VAR . '/log'),
|
||||
'signing_key' => is_readable((string)Config::get('security.release_private_key', '')),
|
||||
];
|
||||
};
|
||||
|
||||
switch ($action) {
|
||||
// ------------------------------------------------------------------
|
||||
case 'status':
|
||||
Http::ok($status());
|
||||
break;
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
case 'wipe':
|
||||
$requireConfirmation();
|
||||
Http::ok(['dropped' => $wipe(), 'state' => $status()]);
|
||||
break;
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
case 'migrate':
|
||||
$result = Migrator::migrate($db);
|
||||
if ($result['failed'] !== null) {
|
||||
Http::fail(500, 'migration_failed', 'Migration abgebrochen.', null, $result);
|
||||
}
|
||||
Http::ok(['migration' => $result, 'state' => $status()]);
|
||||
break;
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Ausgangszustand herstellen: leere Datenbank, frisches Schema, ein
|
||||
// Administrator und ein Master-Token mit allen Rechten. Beides gibt der
|
||||
// Aufruf zurueck - der Testlauf soll sich seine Zugaenge selbst holen
|
||||
// koennen und nicht darauf warten, dass jemand im WebUI etwas anklickt.
|
||||
// ------------------------------------------------------------------
|
||||
case 'reset':
|
||||
$requireConfirmation();
|
||||
|
||||
$dropped = $wipe();
|
||||
|
||||
$result = Migrator::migrate($db);
|
||||
if ($result['failed'] !== null) {
|
||||
Http::fail(500, 'migration_failed', 'Migration nach dem Zuruecksetzen abgebrochen.', null, $result);
|
||||
}
|
||||
|
||||
$adminUser = (string)(Http::str('admin_user') ?? 'admin');
|
||||
$adminPass = (string)(Http::str('admin_pass') ?? 'test02763!');
|
||||
|
||||
$db->prepare('DELETE FROM dc_users WHERE username = :u')->execute([':u' => $adminUser]);
|
||||
$db->prepare('
|
||||
INSERT INTO dc_users (username, password_hash, created_at)
|
||||
VALUES (:u, :h, UTC_TIMESTAMP())
|
||||
')->execute([':u' => $adminUser, ':h' => password_hash($adminPass, PASSWORD_DEFAULT)]);
|
||||
|
||||
// Rolle setzen, falls die Spalte existiert (Migration 012).
|
||||
try {
|
||||
$db->prepare('UPDATE dc_users SET role = "admin" WHERE username = :u')->execute([':u' => $adminUser]);
|
||||
} catch (Throwable $e) {
|
||||
// Ohne Rollenspalte ist jeder Benutzer Administrator - nichts zu tun.
|
||||
}
|
||||
|
||||
$manager = new TokenManager($db);
|
||||
$master = $manager->createMasterToken(
|
||||
'Testlauf (automatisch)',
|
||||
null,
|
||||
null,
|
||||
'custom',
|
||||
'testkit',
|
||||
['*'],
|
||||
'all'
|
||||
);
|
||||
|
||||
Http::ok([
|
||||
'dropped' => $dropped,
|
||||
'migration' => $result,
|
||||
'admin' => ['username' => $adminUser, 'password' => $adminPass],
|
||||
'master' => ['token_id' => $master['token_id'], 'raw_token' => $master['raw_token']],
|
||||
'state' => $status(),
|
||||
]);
|
||||
break;
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Signierschluessel fuer den Updateservice. Er wird auf der Testinstanz
|
||||
// erzeugt und nie von der Produktion kopiert - ein Testlauf darf mit dem
|
||||
// Schluessel, mit dem echte Releases signiert werden, nichts zu tun haben.
|
||||
// ------------------------------------------------------------------
|
||||
case 'genkey':
|
||||
$target = (string)Config::get('security.release_private_key', DC_ROOT . '/config/release-signing.pem');
|
||||
|
||||
if (is_readable($target) && (string)(Http::str('force') ?? '') !== '1') {
|
||||
Http::ok(['created' => false, 'path' => basename($target), 'message' => 'Schluessel vorhanden.']);
|
||||
}
|
||||
|
||||
$res = openssl_pkey_new([
|
||||
'private_key_bits' => 4096,
|
||||
'private_key_type' => OPENSSL_KEYTYPE_RSA,
|
||||
]);
|
||||
if ($res === false) {
|
||||
Http::fail(500, 'keygen_failed', 'Schluesselerzeugung fehlgeschlagen: ' . openssl_error_string());
|
||||
}
|
||||
|
||||
$pem = '';
|
||||
openssl_pkey_export($res, $pem);
|
||||
if (file_put_contents($target, $pem) === false) {
|
||||
Http::fail(500, 'keygen_failed', 'Schluessel konnte nicht geschrieben werden.');
|
||||
}
|
||||
@chmod($target, 0600);
|
||||
|
||||
$details = openssl_pkey_get_details($res);
|
||||
Http::ok([
|
||||
'created' => true,
|
||||
'path' => basename($target),
|
||||
'bits' => $details['bits'] ?? null,
|
||||
'public' => $details['key'] ?? null,
|
||||
]);
|
||||
break;
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Protokoll lesen. Ohne diese Aktion endet jeder fehlgeschlagene Testlauf
|
||||
// bei "500" und dem Ratespiel, was dahinter stand - die Logdatei liegt
|
||||
// unter var/log/ und ist per .htaccess zu Recht gesperrt.
|
||||
// ------------------------------------------------------------------
|
||||
case 'log':
|
||||
$lines = max(1, min(500, (int)(Http::str('lines') ?? 80)));
|
||||
$dir = DC_VAR . '/log';
|
||||
$files = glob($dir . '/*.log') ?: [];
|
||||
usort($files, static fn($a, $b) => filemtime($b) <=> filemtime($a));
|
||||
|
||||
$out = [];
|
||||
foreach (array_slice($files, 0, 3) as $file) {
|
||||
$content = file($file, FILE_IGNORE_NEW_LINES) ?: [];
|
||||
$out[basename($file)] = array_slice($content, -$lines);
|
||||
}
|
||||
Http::ok(['log' => $out]);
|
||||
break;
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
case 'clearlog':
|
||||
foreach (glob(DC_VAR . '/log/*.log') ?: [] as $file) {
|
||||
file_put_contents($file, '');
|
||||
}
|
||||
Http::ok(['cleared' => true]);
|
||||
break;
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
default:
|
||||
Http::fail(400, 'unknown_action', sprintf('Unbekannte Aktion "%s".', $action), null, [
|
||||
'actions' => ['status', 'wipe', 'migrate', 'reset', 'genkey', 'log', 'clearlog'],
|
||||
]);
|
||||
}
|
||||
Reference in New Issue
Block a user