Files
nexarch/mail/internal/protolog/protolog_test.go
T
sysops 7c892ed10a feat(mail): ING-08 strukturiertes Protokoll-Logging & Diagnose für IMAP/POP3/SMTP
Neues Paket mail/internal/protolog (log/slog): SessionLogger loggt
strukturierte Ereignisse einer Verbindung mit fester correlation_id und
protocol über die gesamte Verbindungsdauer (Akzeptanzkriterium 1) — ein
Logger mit logger==nil ist sicher benutzbar und loggt nichts
(Rückwärtskompatibilität zu ING-01..ING-07, Logging ist opt-in wie TLS
und Guard-Konfiguration). RedactCommandLine ersetzt bei sensiblen
Kommandos (PASS, LOGIN, AUTH) alle Argumente vollständig durch
[REDACTED] statt einzeln zu parsen (Akzeptanzkriterium 2).
Reconstruct liest zeilenweise JSON-Logs und liefert ausschließlich die
Einträge einer Korrelations-ID in Reihenfolge — das geforderte
Diagnosewerkzeug (Akzeptanzkriterium 3).

Alle drei Sessions loggen jetzt session_start/command (je empfangener
Zeile, redigiert)/session_end. Nachrichteninhalte werden strukturell
nie geloggt: SMTP-DATA-Body-Zeilen laufen durch eine eigene
Leseschleife, die nicht durch den Kommando-Logpfad der Hauptschleife
kommt: nur das Kommando DATA selbst erscheint im Log.

Alle drei Pflichtprüfungen mit echten Nachweisen durchgeführt, jeweils
in IMAP, POP3 und SMTP einzeln: Redaktion gegen den echten laufenden
Server bestätigt (Klartextpasswort bzw. absichtlich eingebettetes
Geheimnis im SMTP-Body erscheint nie im Log), zwei gemischte reale
Sessions über dieselbe Korrelations-ID lückenlos rekonstruiert,
Lasttest mit 100 Sessions mit/ohne Logging ohne relevante
Durchsatzeinbuße.

go build/go vet/golangci-lint clean, gesamtes Mail-Modul (~29 Pakete)
regressionsfrei getestet.
2026-09-01 09:07:35 +02:00

92 lines
3.4 KiB
Go

package protolog
import (
"bytes"
"context"
"log/slog"
"strings"
"testing"
)
// TestRedactCommandLine_HidesCredentials ist Teil der geforderten
// Pflichtprüfung 1 (ING-08): Redaktion sensibler Felder.
func TestRedactCommandLine_HidesCredentials(t *testing.T) {
cases := []struct {
verb string
args []string
wantSafe bool // true: darf das geheimnis NICHT enthalten
secret string
}{
{"PASS", []string{"geheim123"}, true, "geheim123"},
{"LOGIN", []string{"alice", "geheim123"}, true, "geheim123"},
{"AUTH", []string{"PLAIN", "AGFsaWNlAGdlaGVpbTEyMw=="}, true, "AGFsaWNlAGdlaGVpbTEyMw=="},
{"STAT", nil, false, ""},
{"USER", []string{"alice"}, false, "alice"},
}
for _, tc := range cases {
out := RedactCommandLine(tc.verb, tc.args)
if tc.wantSafe && strings.Contains(out, tc.secret) {
t.Fatalf("%s: geheimnis im klartext gefunden: %q", tc.verb, out)
}
if !strings.HasPrefix(out, strings.ToUpper(tc.verb)) {
t.Fatalf("%s: kommandoname fehlt in redigierter zeile: %q", tc.verb, out)
}
}
}
// TestSessionLogger_EventNeverContainsRawMessage stellt sicher, dass
// über die reguläre Event-API keine Nachrichteninhalte geloggt werden
// können, ohne dass der Aufrufer sie explizit (und damit sichtbar im
// Code) als Attribut übergibt — Event selbst fügt nie Rohinhalte hinzu.
func TestSessionLogger_EventNeverContainsRawMessage(t *testing.T) {
var buf bytes.Buffer
logger := slog.New(slog.NewJSONHandler(&buf, nil))
sl := NewSessionLogger(logger, "pop3")
sl.Event(context.Background(), "command", slog.String("command", RedactCommandLine("PASS", []string{"geheim123"})))
if strings.Contains(buf.String(), "geheim123") {
t.Fatalf("passwort im log gefunden: %s", buf.String())
}
if !strings.Contains(buf.String(), sl.CorrelationID()) {
t.Fatalf("correlation_id fehlt im log: %s", buf.String())
}
}
// TestReconstruct_ReturnsOnlyMatchingSessionInOrder ist die geforderte
// Pflichtprüfung 2 (ING-08): eine komplette Session ist über die
// Korrelations-ID lückenlos rekonstruierbar, aus einem Log mit
// mehreren gemischten Sessions.
func TestReconstruct_ReturnsOnlyMatchingSessionInOrder(t *testing.T) {
var buf bytes.Buffer
logger := slog.New(slog.NewJSONHandler(&buf, nil))
target := NewSessionLogger(logger, "imap")
other := NewSessionLogger(logger, "imap")
target.Event(context.Background(), "session_start", slog.String("remote_addr", "127.0.0.1:1"))
other.Event(context.Background(), "session_start", slog.String("remote_addr", "127.0.0.1:2"))
target.Event(context.Background(), "command", slog.String("command", "LOGIN [REDACTED]"))
other.Event(context.Background(), "command", slog.String("command", "SELECT INBOX"))
target.Event(context.Background(), "command", slog.String("command", "SELECT INBOX"))
target.Event(context.Background(), "session_end")
other.Event(context.Background(), "session_end")
entries, err := Reconstruct(&buf, target.CorrelationID())
if err != nil {
t.Fatalf("Reconstruct: %v", err)
}
if len(entries) != 4 {
t.Fatalf("erwartete 4 einträge für die zielsession, habe %d", len(entries))
}
wantMsgs := []string{"session_start", "command", "command", "session_end"}
for i, e := range entries {
if e.Msg != wantMsgs[i] {
t.Fatalf("eintrag %d: erwartete msg %q, habe %q", i, wantMsgs[i], e.Msg)
}
if e.CorrelationID != target.CorrelationID() {
t.Fatalf("eintrag %d gehört zur falschen session", i)
}
}
}