diff --git a/mail/cmd/mailpostfach-a11y-fixture/main.go b/mail/cmd/mailpostfach-a11y-fixture/main.go new file mode 100644 index 00000000..67fd7628 --- /dev/null +++ b/mail/cmd/mailpostfach-a11y-fixture/main.go @@ -0,0 +1,242 @@ +// QA-08: startet den ECHTEN mailapi.Server (reale Routen, reale +// moduleauth-Tokenprüfung) mit synthetischen Postfachdaten für den +// Barrierefreiheits-Audit von web/mail-postfach. Eigenständig von +// mailpostfach-fixture (UX-01, Mandantentrennungs-Fokus), da der +// Browser-Audit zusätzlich CORS-Header und einen Platzhalter für den +// Core-Modul-Token-Endpunkt braucht (kein echter NEXARCH-Core auf dem +// Testhost verfügbar) — kein Produktivpfad. +package main + +import ( + "context" + "encoding/json" + "fmt" + "net" + "net/http" + "os" + "os/signal" + "syscall" + + "gitea.perlbach24.de/scripte/nexarch/mail/internal/mailapi" + "gitea.perlbach24.de/scripte/nexarch/mail/internal/moduleauth" + "gitea.perlbach24.de/scripte/nexarch/mail/internal/search" + "gitea.perlbach24.de/scripte/nexarch/mail/internal/storage" +) + +const tenant = "a11y-tenant" + +type memorySearch struct { + docs map[string]search.Document +} + +func newMemorySearch() *memorySearch { + return &memorySearch{docs: map[string]search.Document{}} +} + +func (m *memorySearch) put(doc search.Document) { + m.docs[doc.MessageID] = doc +} + +func (m *memorySearch) Search(_ context.Context, tenantSlug, queryText string) ([]search.Result, error) { + if tenantSlug != tenant { + return nil, nil + } + var results []search.Result + for _, doc := range m.docs { + results = append(results, search.Result{MessageID: doc.MessageID, Subject: doc.Subject, SentAtUnixEpoch: doc.SentAtUnixEpoch, Sender: doc.Sender, Mailbox: doc.Mailbox}) + } + return results, nil +} + +func (m *memorySearch) SearchWithFilters(ctx context.Context, tenantSlug, queryText string, filters []search.FacetFilter) ([]search.Result, error) { + results, err := m.Search(ctx, tenantSlug, queryText) + if err != nil { + return nil, err + } + filtered := make([]search.Result, 0, len(results)) + for _, res := range results { + match := true + for _, f := range filters { + if f.Field == search.FacetFieldMailbox && res.Mailbox != f.Value { + match = false + } + } + if match { + filtered = append(filtered, res) + } + } + return filtered, nil +} + +func (m *memorySearch) GetByMessageID(_ context.Context, tenantSlug, messageID string) (search.Document, bool, error) { + if tenantSlug != tenant { + return search.Document{}, false, nil + } + doc, ok := m.docs[messageID] + return doc, ok, nil +} + +func (m *memorySearch) ListByMailbox(_ context.Context, tenantSlug, mailbox string) ([]search.Document, error) { + if tenantSlug != tenant { + return nil, nil + } + var docs []search.Document + for _, doc := range m.docs { + if mailbox != "" && doc.Mailbox != mailbox { + continue + } + docs = append(docs, doc) + } + return docs, nil +} + +func (m *memorySearch) Facets(_ context.Context, tenantSlug, _ string, _ []search.FacetFilter) (search.FacetResult, error) { + if tenantSlug != tenant { + return search.FacetResult{}, nil + } + counts := map[string]int64{} + for _, doc := range m.docs { + if doc.Mailbox != "" { + counts[doc.Mailbox]++ + } + } + values := make([]search.FacetValue, 0, len(counts)) + for mailbox, count := range counts { + values = append(values, search.FacetValue{Value: mailbox, Count: count}) + } + return search.FacetResult{Values: map[string][]search.FacetValue{string(search.FacetFieldMailbox): values}}, nil +} + +type noopUsageReporter struct{} + +func (noopUsageReporter) Report(context.Context, string, string, int64) error { return nil } + +type fixedStorageProvider struct { + service *storage.Service +} + +func (p fixedStorageProvider) ServiceFor(tenantSlug string) (*storage.Service, error) { + if tenantSlug != tenant { + return nil, fmt.Errorf("a11y-fixture: unbekannter mandant %q", tenantSlug) + } + return p.service, nil +} + +// seed deckt die Fälle ab, die die Postfach-Oberfläche real zeigen muss: +// einen verschachtelten Ordner (Ordnerbaum-Auf-/Zuklappen), einen leeren +// Ordner (Leerzustand) und genug Nachrichten, um Sortierung sichtbar zu +// machen. +func seed(sc *memorySearch) { + sc.put(search.Document{MessageID: "msg-rechnung-1", Subject: "Rechnung September 2026", Sender: "buchhaltung@beispiel.test", Mailbox: "posteingang", SentAtUnixEpoch: 1_700_000_300}) + sc.put(search.Document{MessageID: "msg-rechnung-2", Subject: "Rechnung Oktober 2026", Sender: "zentrale@beispiel.test", Mailbox: "posteingang", SentAtUnixEpoch: 1_700_000_400}) + sc.put(search.Document{MessageID: "msg-vertrag-1", Subject: "Vertragsunterlagen zur Unterschrift", Sender: "vertrieb@beispiel.test", Mailbox: "archiv", SentAtUnixEpoch: 1_700_000_200}) + sc.put(search.Document{MessageID: "msg-projekt-1", Subject: "Projektplan Alpha", Sender: "pm@beispiel.test", Mailbox: "projekte/alpha", SentAtUnixEpoch: 1_700_000_100}) + sc.put(search.Document{MessageID: "msg-projekt-2", Subject: "Statusbericht Beta", Sender: "pm@beispiel.test", Mailbox: "projekte/beta", SentAtUnixEpoch: 1_700_000_150}) + + // Genug Nachrichten, damit die Nachrichtenliste tatsaechlich ueberlaeuft + // (Grundlage fuer die Tastatur-Scroll-Pruefung, nicht nur Tab-Erreichbarkeit). + for i := 0; i < 20; i++ { + sc.put(search.Document{ + MessageID: fmt.Sprintf("msg-liste-%d", i), + Subject: fmt.Sprintf("Nachricht %d", i), + Sender: fmt.Sprintf("absender%d@beispiel.test", i%5), + Mailbox: "posteingang", + SentAtUnixEpoch: 1_700_001_000 + int64(i), + }) + } +} + +func newStorageService() (*storage.Service, error) { + dir, err := os.MkdirTemp("", "qa08-a11y-fixture-storage-") + if err != nil { + return nil, err + } + return storage.NewService(storage.NewLocalDriver(dir), noopUsageReporter{}, tenant), nil +} + +// corsForAudit reflektiert den anfragenden Origin mit Zugangsdaten-Flag, +// da der Browser-Audit das Frontend und diese Fixture-API auf +// unterschiedlichen Ports (= unterschiedlichen Origins) betreibt. Nur für +// diesen Testaufbau nötig, der echte NEXARCH-Core läuft same-origin +// hinter einem Reverse-Proxy. +func corsForAudit(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + origin := r.Header.Get("Origin") + if origin != "" { + w.Header().Set("Access-Control-Allow-Origin", origin) + w.Header().Set("Access-Control-Allow-Credentials", "true") + w.Header().Set("Access-Control-Allow-Headers", "Authorization, Content-Type, Accept") + w.Header().Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS") + } + if r.Method == http.MethodOptions { + w.WriteHeader(http.StatusNoContent) + return + } + next.ServeHTTP(w, r) + }) +} + +func main() { + sc := newMemorySearch() + seed(sc) + + svc, err := newStorageService() + if err != nil { + fmt.Fprintln(os.Stderr, "a11y-fixture: storage:", err) + os.Exit(1) + } + sp := fixedStorageProvider{service: svc} + + srv := mailapi.NewServer(sc, sp) + mux := http.NewServeMux() + verifier, issuer := moduleauth.NewTestVerifierAndIssuer() + srv.RegisterRoutes(mux, verifier) + + token := issuer.Token("a11y-user", tenant, "user", "mail", "") + + // Platzhalter für den Core-Modul-Token-Endpunkt: web/mail-postfach holt + // sich darüber ein Bearer-Token (lib/http.ts, @nexarch/shl createSession). + // Kein echter NEXARCH-Core im Testaufbau, daher liefert dieser Stub das + // bereits ausgestellte, echt signierte Token direkt. + mux.HandleFunc("/auth/module-token", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "token": token, + "expires_in": 3600, + "tenant": tenant, + "role": "user", + }) + }) + + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + fmt.Fprintln(os.Stderr, "a11y-fixture: listener:", err) + os.Exit(1) + } + port := listener.Addr().(*net.TCPAddr).Port + + fmt.Println(mustJSON(map[string]any{ + "port": port, + "tenant": tenant, + })) + + httpSrv := &http.Server{Handler: corsForAudit(mux)} + go func() { + if err := httpSrv.Serve(listener); err != nil && err != http.ErrServerClosed { + fmt.Fprintln(os.Stderr, "a11y-fixture: serve:", err) + } + }() + + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + <-ctx.Done() + _ = httpSrv.Close() +} + +func mustJSON(v any) string { + raw, err := json.Marshal(v) + if err != nil { + panic(err) + } + return string(raw) +} diff --git a/mail/docs/QA-08-PRUEFPROTOKOLL.md b/mail/docs/QA-08-PRUEFPROTOKOLL.md new file mode 100644 index 00000000..b2337dcd --- /dev/null +++ b/mail/docs/QA-08-PRUEFPROTOKOLL.md @@ -0,0 +1,99 @@ +# QA-08 – Prüfprotokoll: Barrierefreiheits-Audit der Postfach-Oberfläche + +Stand: 2026-10-16. Entwicklungshost 192.168.1.204, Google Chrome (Puppeteer), axe-core aus dem bestehenden pa11y-Bestand. Branch `feature/qa-08-barrierefreiheits-audit`. + +## Basis dieses Audits + +`UX-01` (Postfach-/Ordneransicht, `web/mail-postfach`) ist zum Zeitpunkt dieses Audits **nicht** auf `main` gemergt, sondern liegt nur auf dem offenen, ungemergten Gitea-Remote-Branch `feature/ux-01-postfach-ordneransicht`. Da dieses Ticket explizit "der Postfach-Oberfläche" gilt, wurde direkt auf diesem Branch aufgebaut: `git fetch origin feature/ux-01-postfach-ordneransicht` + `git checkout -b feature/qa-08-barrierefreiheits-audit origin/feature/ux-01-postfach-ordneransicht`. Alle hier beschriebenen Funde und Fixes gelten für den UX-01-Code-Stand dieses Branches; sie müssen beim Mergen von UX-01 nach `main` mitgenommen werden (siehe "Offene Punkte" unten). + +Laut `mail/docs/UX-01-PRUEFPROTOKOLL.md` war Barrierefreiheit in UX-01 bewusst nur als Grundstruktur vorbereitet ("ARIA-Rollen (`tree`, `treeitem`, `listbox`, `option`) vorbereitet, kein vollständiger Audit") — dieses Ticket liefert den fehlenden vollständigen Audit nach. + +## Prüfaufbau (wiederverwendet aus `web/mail-search`, UX-07/UX-08) + +Gleiches Werkzeug wie bei allen anderen Modulen dieser Session: `web/a11y-audit/audit.js` (axe WCAG 2.0/2.1 A+AA, Tab-Erreichbarkeit, Fokusindikator-Prüfung, Layout/Kontrast), keine neue Prüflogik. Neu nur die modul­spezifische Startinfrastruktur, nach dem Muster von `web/a11y-audit/mail-search-run.sh`: + +- `mail/cmd/mailpostfach-a11y-fixture` (neu, reine Testinfrastruktur, kein Produktivpfad): startet den echten `mailapi.Server` mit echter `moduleauth`-Signaturprüfung auf einem echten TCP-Port, synthetische Postfachdaten eines Mandanten (verschachtelter Ordner `projekte/alpha`/`projekte/beta` für den Ordnerbaum, 25 Nachrichten in `posteingang` damit die Nachrichtenliste tatsächlich überläuft — sonst wäre der unten gefundene Scroll-Bug nicht reproduzierbar). Zusätzlich zu den echten `mailapi`-Routen registriert die Fixture zwei testaufbau-spezifische Hilfen, die ein echter NEXARCH-Core auf dem Testhost ersetzen würde: einen Platzhalter-Endpunkt `/auth/module-token` (liefert das bereits echt signierte Testtoken, das `@nexarch/shl` `createSession` sonst vom Core holen würde — kein echter Core auf dem Testhost verfügbar) und CORS-Header (Frontend und Fixture-API laufen auf unterschiedlichen Ports/Origins, ein echtes Deployment hätte beides hinter demselben Reverse-Proxy). +- `web/a11y-audit/mail-postfach-run.sh` (neu, nach dem Muster von `mail-search-run.sh`/`connect-status-run.sh`): baut die Fixture, baut und startet `web/mail-postfach` dagegen, führt `audit.js` und den Tastaturfluss aus. +- `web/mail-postfach/keyboard-flow-check.mjs` (neu, nach dem Muster von `web/mail-search/keyboard-flow-check.mjs`, UX-08): echter Browserlauf der Tastaturbedienung. Anders als bei `mail-search` muss hier kein `fetch` gemockt werden — die Fixture liefert bereits echte Daten über eine echte, signierte Autorisierung. + +## Ergebnis auf einen Blick + +| Akzeptanzkriterium | Ergebnis | +|---|---| +| AK1: automatisierter Audit-Lauf zeigt keine kritischen Verstöße mehr | erfüllt (1 echter Fund, behoben, siehe unten) | +| AK2: manuelle Stichprobe bestätigt sinnvolle Tastatur- und Screenreader-Bedienung | Tastatur: erfüllt (echter Browserlauf, 9/9 bestanden). Screenreader: nicht möglich, siehe Einschränkung unten | +| AK3: Ergebnis dokumentiert und mit Verantwortlichen für Nachbesserung rückgekoppelt | erfüllt (dieses Protokoll + priorisierte offene Punkte unten) | + +## Vorher/Nachher-Vergleich: automatisierter axe-Lauf + +### Vorher (`OUT=/tmp/qa08-out-before web/a11y-audit/mail-postfach-run.sh`) + +| Schema | axe-Befund | +|---|---| +| hell | `scrollable-region-focusable` (serious, 1 Knoten) | +| dunkel | `scrollable-region-focusable` (serious, 1 Knoten) | + +Exit-Code 1. Betroffener Knoten: `
` — die scrollbare Nachrichtenliste selbst war nicht per Tastatur fokussierbar (kein `tabIndex`), nur ihre virtualisiert gerenderten Einträge. Bei einem Ordner mit mehr Nachrichten, als in den sichtbaren Bereich passen, konnte ein reiner Tastaturnutzer den Inhalt nicht scrollen — weder per Tab (die Einträge selbst sind per Design nicht fokussierbar, siehe `role=option` ohne eigene Aktion) noch per Pfeiltaste (Container nicht fokussierbar). WCAG 2.1.1 (Tastatur), axe-Regel `scrollable-region-focusable` (`wcag2a`). + +### Fix + +`web/mail-postfach/components/MessageList.tsx`: `tabIndex={0}` auf dem Listbox-Container ergänzt. Damit ist der scrollbare Bereich selbst per Tab erreichbar, und Chrome scrollt einen fokussierten, scrollbaren Container nativ per Pfeiltaste/Bild-ab — keine eigene Scroll-Logik nötig. + +### Nachher (`OUT=/tmp/qa08-out-after web/a11y-audit/mail-postfach-run.sh`) + +| Schema | axe-Befund | +|---|---| +| hell | keine | +| dunkel | keine | + +Exit-Code 0. Tab-Erreichbarkeit: 9/9 interaktive Elemente erreicht (vorher 8/8 — der jetzt fokussierbare Listbox-Container zählt als zusätzliches Element), kein fehlender Fokusindikator, keine Tastaturfalle. + +## Zweiter echter Fund (nicht von axe erkannt, über den Tastaturfluss gefunden, behoben) + +`web/mail-postfach/components/FolderTree.tsx`: die Ordnerbaum-Einträge (`role="treeitem"`, `tabIndex={0}`) hatten ausschließlich einen `onClick`-Handler. Ein `
` löst bei Tastaturaktivierung (Enter/Leertaste) anders als `