Zwei angeforderte Funktionen, dazu zwei Befunde, die ihnen im Weg lagen. == Zweite Stufe der Anmeldung (#666) == Nach korrektem Passwort verlangt das WebUI einen registrierten FIDO2-Schluessel. Die Pflicht haengt je Konto daran, OB ein Schluessel registriert ist - nicht an einem Schalter. Ein Schalter, den man vor der Registrierung umlegt, sperrt den Betreiber aus seiner eigenen Verwaltung aus. Ohne Fremdbibliothek gebaut (das Projekt hat bewusst kein Composer): src/Core/Webauthn/ mit CBOR-Leser, COSE-nach-DER-Wandlung und Pruefung ueber openssl_verify (ES256, RS256). Auth::login() heisst jetzt Auth::startLogin() und liefert drei Zustaende statt eines bool - aus "wahr heisst drin" ist "wahr heisst vielleicht drin" geworden, und ein uebersehener Aufrufer soll an einem fehlenden Methodennamen scheitern statt an einer truthy Zeichenkette. Die Setup-API der Zielsysteme geht ueber verifyCredentials() daran vorbei und bleibt unberuehrt. Notfalltoken als Ersatzweg: nur als Hash in der Datenbank, Klartext einmalig angezeigt, Einmalgebrauch. Laeuft als gewoehnliches Formular ohne JavaScript - ein Ersatzweg mit denselben Voraussetzungen wie der Hauptweg waere keiner. Fehlversuche auf der zweiten Stufe laufen in dieselbe Konto- und IP-Sperre wie das Passwort; zuvor zaehlte nur die erste Stufe. Benutzerverwaltung kann Konten umbenennen. Beim Loeschen eines Kontos und beim Zuruecksetzen der Testinstanz werden seine Schluessel mitgenommen: credential_id ist geraeteweit eindeutig, eine verwaiste Zeile haette denselben Yubikey dauerhaft unbrauchbar gemacht. == Roadmap-Tafel (#664) == Neuer Unterreiter Roadmap -> Tafel. Der Server liefert die fertige Gliederung (RoadmapBoard, ?action=board), die Seite rendert nur - sonst schneidet das WebUI anders als die API und ein spaeterer Export ein drittes Mal anders. Phasen stehen nicht im Code: gepflegt in der neuen Tabelle roadmap_phases, sonst an den groessten Luecken der Rangfolge geschnitten und sichtbar als geraten gekennzeichnet. Links die gekappte Spalte des Erledigten, rechts "Wird ueberlegt". Nachpruefung ist ein eigener Zustand und wird nicht als erledigt gezeichnet. Klick oeffnet ein Seitenpanel: Volltext, Verlauf, Kommentieren mit action_taken, Aendern von Bereich, Rang, Zielfassung, Status und Ausloeser - ohne Seitenwechsel. Das war der eigentliche Gewinn: Wer etwas bemerkte, musste sich bisher die Nummer merken und im Bugtracker danach suchen. Tiefe Verweise #roadmap/<projekt>/item/<id>. Ziehen zwischen Phasen ueber ?action=rank_between. Fremdtext wird ausschliesslich ueber textContent gesetzt, nie ueber innerHTML. == Befund: roadmap_rank war projektweit und bereichslokal zugleich (#177) == Entschieden: projektweit. move() sucht den Nachbarn ohne Bereichsfilter, reorder() schreibt keinen ganzen Bereich mehr auf 10/20/30 um, sondern vertauscht nur die Raenge, die die Eintraege ohnehin halten. rank_between rechnet die Mitte zwischen zwei Nachbarn und nummeriert nur dann einmal neu - unter Erhalt der Reihenfolge -, wenn dort kein Platz mehr ist; die Antwort sagt es dann. == Befund: CSRF an sitzungsauthentifizierten API-Schreibzugriffen == ApiAuth akzeptiert die WebUI-Sitzung, die Endpunkte prueften dabei kein CSRF-Token. Der Kommentar in Http::beginJson - "ausschliesslich per Token-Header, niemals per Cookie" - traf auf die API nie zu. Schreibende Anfragen im Sitzungsweg verlangen jetzt csrf_token bzw. X-CSRF-Token. Token-Clients sind nicht betroffen und wurden gegengeprueft. == Geprueft == tests/e2e/suite_tafel.py neu (16 Faelle), Suite gesamt 172 von 172 gegen die Testinstanz. Die WebAuthn-Zeremonien zusaetzlich gegen einen Software- Authenticator mit echtem P-256 und echtem CBOR: Registrieren, Anmelden, verfaelschte Signatur, veraltete Herausforderung und Zaehler-Rueckfall als Klonverdacht werden je mit 403 abgewiesen. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
337 lines
13 KiB
Python
337 lines
13 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
Roadmap-Tafel und zweite Stufe der Anmeldung.
|
|
|
|
Die Tafel wird ueber ihre Struktur geprueft und nicht ueber ihr Aussehen:
|
|
Der Server liefert Spalten, Spuren und Zustaende fertig aus, und genau das
|
|
ist der Vertrag, auf den sich WebUI, CLI und ein spaeterer Export stuetzen.
|
|
|
|
Die WebAuthn-Zeremonie selbst laeuft hier nicht mit. Sie braucht einen
|
|
Authenticator, der wirklich unterschreibt - dafuer gibt es ein eigenes
|
|
Werkzeug ausserhalb der Suite, weil es eine Krypto-Bibliothek verlangt und
|
|
diese Suite mit der Standardbibliothek auskommen soll. Was hier geprueft
|
|
wird, ist der Rahmen: dass die Endpunkte ohne Zwischenzustand nichts
|
|
herausgeben und dass ein sitzungsloser Aufruf abgewiesen wird.
|
|
"""
|
|
|
|
import time
|
|
|
|
from harness import (test, erwarte, erwarte_status, erwarte_erfolg,
|
|
erwarte_fehler, ueberspringen)
|
|
|
|
PROJEKT = 'myapp' # existiert nach dem Zuruecksetzen, siehe sql/schema.sql
|
|
|
|
|
|
def _item_id(resp):
|
|
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 _melden(ctx, titel, ref):
|
|
return ctx.client.post('/api/bugtracker/v1/report', body={
|
|
'project_slug': PROJEKT,
|
|
'title': titel,
|
|
'description': 'Angelegt von tests/e2e (Tafel). Darf jederzeit geloescht werden.',
|
|
'severity': 'medium',
|
|
'environment': 'testing',
|
|
'client_ref': ref,
|
|
})
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Aufbau der Tafel
|
|
# ---------------------------------------------------------------------------
|
|
|
|
@test('tafel', 'Tafel liefert Spalten, Spuren und Kaertchen')
|
|
def _(ctx):
|
|
r = ctx.client.get('/api/bugtracker/v1/roadmap?action=board&project=' + PROJEKT)
|
|
erwarte_erfolg(r)
|
|
|
|
for feld in ('phases', 'lanes', 'items', 'stats', 'meta'):
|
|
erwarte(r.feld(feld) is not None, "Feld '%s' fehlt in der Antwort" % feld)
|
|
|
|
ctx['tafel'] = r.json
|
|
|
|
|
|
@test('tafel', 'Die beiden festen Randspalten sind immer da')
|
|
def _(ctx):
|
|
"""
|
|
"Fundament" links und "Wird ueberlegt" rechts haengen am Status und nicht
|
|
am Rang. Sie muessen auch dann stehen, wenn dazwischen gar nichts ist -
|
|
sonst haette ein frisches Projekt keine Tafel, sondern eine leere Flaeche.
|
|
"""
|
|
tafel = ctx.get('tafel')
|
|
if tafel is None:
|
|
ueberspringen('tafel')
|
|
|
|
schluessel = [p['key'] for p in tafel['phases']]
|
|
erwarte(schluessel[0] == 'fundament', "Erste Spalte ist '%s'" % schluessel[0])
|
|
erwarte(schluessel[-1] == 'ueberlegt', "Letzte Spalte ist '%s'" % schluessel[-1])
|
|
|
|
|
|
@test('tafel', 'Geratene Spaltengrenzen sind als solche gekennzeichnet')
|
|
def _(ctx):
|
|
"""
|
|
Ohne gepflegte roadmap_phases schneidet die Tafel selbst. Das darf sie -
|
|
aber sie muss es sagen. Eine geratene Grenze, die wie eine Entscheidung
|
|
aussieht, ist schlimmer als gar keine.
|
|
"""
|
|
tafel = ctx.get('tafel')
|
|
if tafel is None:
|
|
ueberspringen('tafel')
|
|
|
|
erwarte('phases_guessed' in tafel['meta'],
|
|
"meta.phases_guessed fehlt - die Tafel sagt nicht, woher ihre Grenzen kommen")
|
|
|
|
|
|
@test('tafel', 'Erledigte Vorhaben werden gekappt')
|
|
def _(ctx):
|
|
"""
|
|
Bei ueber hundert erledigten Eintraegen erschlaegt die Fundamentspalte
|
|
die ganze Tafel. done_limit muss greifen, und die Antwort muss die
|
|
Gesamtzahl trotzdem nennen - sonst sieht gekappt wie vollstaendig aus.
|
|
"""
|
|
r = ctx.client.get('/api/bugtracker/v1/roadmap?action=board&project=%s&done_limit=1' % PROJEKT)
|
|
erwarte_erfolg(r)
|
|
|
|
meta = r.feld('meta', default={})
|
|
erwarte('done_total' in meta and 'done_shown' in meta,
|
|
"meta nennt done_shown/done_total nicht")
|
|
erwarte(meta['done_shown'] <= 1,
|
|
"done_limit=1 zeigt %s erledigte Eintraege" % meta['done_shown'])
|
|
|
|
|
|
@test('tafel', 'Kaertchen tragen ihren Zustand und ihre Spalte')
|
|
def _(ctx):
|
|
tafel = ctx.get('tafel')
|
|
if tafel is None:
|
|
ueberspringen('tafel')
|
|
|
|
spalten = {p['key'] for p in tafel['phases']}
|
|
erlaubt = {'done', 'verification', 'working', 'deferred', 'idea', 'open'}
|
|
|
|
for karte in tafel['items']:
|
|
erwarte(karte['state'] in erlaubt,
|
|
"Unbekannter Zustand '%s' an #%s" % (karte['state'], karte['id']))
|
|
erwarte(karte['phase'] in spalten,
|
|
"Karte #%s zeigt auf die Spalte '%s', die es nicht gibt"
|
|
% (karte['id'], karte['phase']))
|
|
|
|
|
|
@test('tafel', 'Nachpruefung wird nicht als erledigt gezeichnet')
|
|
def _(ctx):
|
|
"""
|
|
Der Fall, den die Bastelloesung im Projekt clawddotnet von Hand als
|
|
"Status pruefen" danebenschreiben musste: Ein Fix, der nicht getragen
|
|
hat, ist keine Erledigung.
|
|
"""
|
|
r = _melden(ctx, 'Tafel-Testfall: wartet auf Nachpruefung', 'e2e-tafel-verify-%d' % time.time())
|
|
erwarte_status(r, 200, 201)
|
|
item = _item_id(r)
|
|
|
|
ctx.client.post('/api/bugtracker/v1/roadmap?action=set',
|
|
body={'id': item, 'epic': 'T', 'roadmap_rank': 10})
|
|
ctx.client.post('/api/bugtracker/v1/manage/?action=status',
|
|
body={'id': item, 'status': 'awaiting_verification'})
|
|
|
|
r = ctx.client.get('/api/bugtracker/v1/roadmap?action=board&project=' + PROJEKT)
|
|
erwarte_erfolg(r)
|
|
|
|
treffer = [k for k in r.feld('items', default=[]) if k['id'] == item]
|
|
erwarte(treffer, "Item #%d taucht auf der Tafel nicht auf" % item)
|
|
erwarte(treffer[0]['state'] == 'verification',
|
|
"Zustand ist '%s' statt 'verification'" % treffer[0]['state'])
|
|
|
|
ctx['tafel_item'] = item
|
|
|
|
|
|
@test('tafel', 'Vorhaben ohne Rang landen in "Wird ueberlegt"')
|
|
def _(ctx):
|
|
"""
|
|
Ein Vorhaben ohne Rang ist nicht eingeordnet. Es ans Ende einer Spalte zu
|
|
sortieren liesse es eingeordnet aussehen.
|
|
"""
|
|
r = _melden(ctx, 'Tafel-Testfall: noch nicht eingeordnet', 'e2e-tafel-ohne-%d' % time.time())
|
|
erwarte_status(r, 200, 201)
|
|
item = _item_id(r)
|
|
|
|
ctx.client.post('/api/bugtracker/v1/roadmap?action=set', body={'id': item, 'epic': 'T'})
|
|
|
|
r = ctx.client.get('/api/bugtracker/v1/roadmap?action=board&project=' + PROJEKT)
|
|
treffer = [k for k in r.feld('items', default=[]) if k['id'] == item]
|
|
erwarte(treffer, "Item #%d taucht auf der Tafel nicht auf" % item)
|
|
erwarte(treffer[0]['phase'] == 'ueberlegt',
|
|
"Ungereihtes Item steht in Spalte '%s'" % treffer[0]['phase'])
|
|
|
|
|
|
@test('tafel', 'Tafel verlangt ein einzelnes Projekt')
|
|
def _(ctx):
|
|
"""
|
|
Die Phasen werden aus roadmap_rank geschnitten, und der Rang gilt je
|
|
Projekt. Zwei Projekte uebereinanderzulegen ergaebe Spalten, die in
|
|
keinem von beiden stimmen.
|
|
"""
|
|
r = ctx.client.get('/api/bugtracker/v1/roadmap?action=board&project=all')
|
|
erwarte_fehler(r, http=400)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Einsortieren - der Befund aus #177
|
|
# ---------------------------------------------------------------------------
|
|
|
|
@test('tafel', 'rank_between setzt den Eintrag zwischen seine Nachbarn')
|
|
def _(ctx):
|
|
stempel = int(time.time())
|
|
ids = []
|
|
for i, rang in enumerate((100, 200, 300)):
|
|
r = _melden(ctx, 'Tafel-Testfall: Rangnachbar %d' % i, 'e2e-tafel-rang-%d-%d' % (stempel, i))
|
|
erwarte_status(r, 200, 201)
|
|
item = _item_id(r)
|
|
ctx.client.post('/api/bugtracker/v1/roadmap?action=set',
|
|
body={'id': item, 'epic': 'T', 'roadmap_rank': rang})
|
|
ids.append(item)
|
|
|
|
r = ctx.client.post('/api/bugtracker/v1/roadmap?action=rank_between',
|
|
body={'id': ids[2], 'prev_id': ids[0], 'next_id': ids[1]})
|
|
erwarte_erfolg(r)
|
|
|
|
rang = r.feld('rank')
|
|
erwarte(100 < rang < 200, "Rang %s liegt nicht zwischen 100 und 200" % rang)
|
|
erwarte(r.feld('renumbered') is False,
|
|
"Ohne Not wurde neu nummeriert - das ist genau das, wovor #177 warnt")
|
|
|
|
ctx['tafel_ids'] = ids
|
|
|
|
|
|
@test('tafel', 'Kein Platz: einmal neu nummerieren, und es steht in der Antwort')
|
|
def _(ctx):
|
|
"""
|
|
Zwischen zwei direkt benachbarten Raengen ist kein Platz. Dann darf neu
|
|
nummeriert werden - aber nicht stillschweigend.
|
|
"""
|
|
ids = ctx.get('tafel_ids')
|
|
if not ids:
|
|
ueberspringen('tafel_ids')
|
|
|
|
# Zwei Eintraege direkt nebeneinander setzen
|
|
ctx.client.post('/api/bugtracker/v1/roadmap?action=set',
|
|
body={'id': ids[0], 'roadmap_rank': 500})
|
|
ctx.client.post('/api/bugtracker/v1/roadmap?action=set',
|
|
body={'id': ids[1], 'roadmap_rank': 501})
|
|
|
|
r = ctx.client.post('/api/bugtracker/v1/roadmap?action=rank_between',
|
|
body={'id': ids[2], 'prev_id': ids[0], 'next_id': ids[1]})
|
|
erwarte_erfolg(r)
|
|
erwarte(r.feld('renumbered') is True,
|
|
"Zwischen 500 und 501 war Platz? renumbered=%s" % r.feld('renumbered'))
|
|
|
|
# Und danach liegt der Eintrag wirklich dazwischen.
|
|
r = ctx.client.get('/api/bugtracker/v1/roadmap?action=board&project=' + PROJEKT)
|
|
raenge = {k['id']: k['rank'] for k in r.feld('items', default=[])}
|
|
erwarte(raenge.get(ids[0]) < raenge.get(ids[2]) < raenge.get(ids[1]),
|
|
"Nach dem Neunummerieren: %s / %s / %s"
|
|
% (raenge.get(ids[0]), raenge.get(ids[2]), raenge.get(ids[1])))
|
|
|
|
|
|
@test('tafel', 'move tauscht mit dem projektweiten Nachbarn (#177)')
|
|
def _(ctx):
|
|
"""
|
|
Der Nachbar wird projektweit gesucht, nicht im eigenen Bereich. Zuvor
|
|
sprang ein Eintrag bei einem Klick ueber beliebig viele fremde Vorhaben
|
|
hinweg, obwohl die Geste "eine Position" hiess.
|
|
"""
|
|
r = ctx.client.get('/api/bugtracker/v1/roadmap?action=board&project=' + PROJEKT)
|
|
erwarte_erfolg(r)
|
|
|
|
gereiht = sorted(
|
|
[k for k in r.feld('items', default=[]) if k['rank'] is not None and k['state'] != 'done'],
|
|
key=lambda k: (k['rank'], k['id'])
|
|
)
|
|
if len(gereiht) < 3:
|
|
ueberspringen('zu wenige gereihte Eintraege')
|
|
|
|
mitte, davor = gereiht[1], gereiht[0]
|
|
|
|
r = ctx.client.post('/api/bugtracker/v1/roadmap?action=move',
|
|
body={'id': mitte['id'], 'direction': 'up'})
|
|
erwarte_erfolg(r)
|
|
|
|
r = ctx.client.get('/api/bugtracker/v1/roadmap?action=board&project=' + PROJEKT)
|
|
raenge = {k['id']: k['rank'] for k in r.feld('items', default=[])}
|
|
|
|
erwarte(raenge.get(mitte['id']) == davor['rank'] and raenge.get(davor['id']) == mitte['rank'],
|
|
"Raenge nicht getauscht: #%s %s->%s, #%s %s->%s"
|
|
% (mitte['id'], mitte['rank'], raenge.get(mitte['id']),
|
|
davor['id'], davor['rank'], raenge.get(davor['id'])))
|
|
|
|
|
|
@test('tafel', 'Themenbereich traegt Farbe und Hervorhebung')
|
|
def _(ctx):
|
|
r = ctx.client.post('/api/bugtracker/v1/roadmap?action=epic', body={
|
|
'project_slug': PROJEKT, 'epic': 'T', 'title': 'Tafel-Testbereich',
|
|
'summary': 'Von tests/e2e angelegt.', 'sort_order': 900,
|
|
'color': '#F6465D', 'highlight': 1,
|
|
})
|
|
erwarte_erfolg(r)
|
|
|
|
r = ctx.client.get('/api/bugtracker/v1/roadmap?action=board&project=' + PROJEKT)
|
|
spuren = {l['epic']: l for l in r.feld('lanes', default=[])}
|
|
erwarte('T' in spuren, "Spur 'T' fehlt auf der Tafel")
|
|
erwarte(spuren['T']['highlight'] is True, "Hervorhebung nicht uebernommen")
|
|
erwarte(spuren['T']['color'] == '#F6465D', "Farbe ist '%s'" % spuren['T']['color'])
|
|
|
|
|
|
@test('tafel', 'Ein Aufruf ohne diese Felder loescht Farbe und Hervorhebung nicht')
|
|
def _(ctx):
|
|
"""
|
|
Das bestehende Formular im WebUI kennt color und highlight nicht. Wuerde
|
|
ein Aufruf ohne sie die Werte zuruecksetzen, waere jede Titelkorrektur
|
|
ein stiller Verlust.
|
|
"""
|
|
r = ctx.client.post('/api/bugtracker/v1/roadmap?action=epic', body={
|
|
'project_slug': PROJEKT, 'epic': 'T', 'title': 'Tafel-Testbereich (umbenannt)',
|
|
'sort_order': 900,
|
|
})
|
|
erwarte_erfolg(r)
|
|
|
|
r = ctx.client.get('/api/bugtracker/v1/roadmap?action=board&project=' + PROJEKT)
|
|
spuren = {l['epic']: l for l in r.feld('lanes', default=[])}
|
|
erwarte(spuren.get('T', {}).get('highlight') is True,
|
|
"Hervorhebung ging bei einem Aufruf ohne das Feld verloren")
|
|
erwarte(spuren.get('T', {}).get('color') == '#F6465D',
|
|
"Farbe ging bei einem Aufruf ohne das Feld verloren")
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Zweite Stufe der Anmeldung
|
|
# ---------------------------------------------------------------------------
|
|
|
|
@test('tafel', 'WebAuthn-Endpunkt gibt ohne offene Anmeldung nichts heraus')
|
|
def _(ctx):
|
|
"""
|
|
login_options laeuft im Zwischenzustand zwischen Passwort und zweitem
|
|
Faktor. Ohne diesen Zustand darf der Endpunkt nicht verraten, ob es ein
|
|
Konto gibt oder welche Schluessel dazu hinterlegt sind.
|
|
"""
|
|
r = ctx.client.post('/api/auth/v1/?action=login_options', body={}, token=None)
|
|
erwarte_status(r, 401)
|
|
erwarte(r.feld('error', 'code') == 'no_pending_login',
|
|
"Fehlercode ist '%s'" % r.feld('error', 'code'))
|
|
|
|
|
|
@test('tafel', 'Registrieren verlangt eine Anmeldung')
|
|
def _(ctx):
|
|
r = ctx.client.post('/api/auth/v1/?action=register_options', body={}, token=None)
|
|
erwarte_status(r, 401)
|
|
|
|
|
|
@test('tafel', 'Unbekannte Zeremonie wird abgewiesen')
|
|
def _(ctx):
|
|
r = ctx.client.post('/api/auth/v1/?action=rundgang', body={}, token=None)
|
|
erwarte_status(r, 404)
|
|
erwarte(isinstance(r.feld('error', 'available'), list),
|
|
"Die Absage nennt die moeglichen Aktionen nicht")
|