// Cthulhu screen reader. // Copyright (c) 2026 Storm Dragon // Authors: Storm Dragon, Angus Kola // // This program is free software: you can redistribute it and/or modify it // under the terms of the GNU General Public License as published by the Free // Software Foundation, either version 3 of the License, or (at your option) // any later version. // // This program is distributed in the hope that it will be useful, but WITHOUT // ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or // FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for // more details. // // You should have received a copy of the GNU General Public License along with // this program. If not, see . package logging import ( "bytes" "encoding/json" "errors" "fmt" "log/slog" "strings" "testing" ) func TestRedactsSensitiveFieldsAndGroups(t *testing.T) { var output bytes.Buffer logger := New(&output, Options{}) logger.Info( "request accepted", "password", "generic-secret", "username", "Username", "request", slog.GroupValue( slog.String("token", "generic-token"), slog.String("role", "input"), ), ) line := output.String() if !json.Valid([]byte(line)) { t.Fatalf("output is not valid JSON: %q", line) } if strings.Contains(line, "generic-secret") || strings.Contains(line, "generic-token") { t.Fatalf("sensitive value was logged: %q", line) } if !strings.Contains(line, redactedValue) { t.Fatalf("redaction marker missing: %q", line) } if !strings.Contains(line, `"request.role":"input"`) { t.Fatalf("non-sensitive group value missing: %q", line) } } func TestJSONTimestampIsLastField(t *testing.T) { var output bytes.Buffer logger := New(&output, Options{}) logger.Info("starting", "role", "screen-reader") line := output.String() timeIndex := strings.LastIndex(line, `"time":`) roleIndex := strings.LastIndex(line, `"role":`) if timeIndex < 0 || roleIndex < 0 { t.Fatalf("structured field missing: %q", line) } if timeIndex < roleIndex || !strings.HasPrefix(line[timeIndex:], `"time":"`) || !strings.HasSuffix(line, "\"}\n") { t.Fatalf("time is not the last field: %q", line) } if !strings.Contains(line, `"message":"starting"`) || !strings.Contains(line, `"role":"screen-reader"`) { t.Fatalf("expected fields missing: %q", line) } } func TestRespectsLevelAndCustomSensitiveKey(t *testing.T) { var output bytes.Buffer logger := New(&output, Options{ Level: slog.LevelWarn, SensitiveKeys: []string{"api_key"}, }) logger.Info("debug event", "api_key", "generic-key") if output.Len() != 0 { t.Fatalf("disabled info event was written: %q", output.String()) } logger.Warn("warning event", "api_key", "generic-key") if !strings.Contains(output.String(), redactedValue) { t.Fatalf("custom sensitive key was not redacted: %q", output.String()) } } func TestErrorsIncludeWrappedAndJoinedMessages(t *testing.T) { var output bytes.Buffer logger := New(&output, Options{}) logger.Error("delivery failed", "error", errors.Join( fmt.Errorf("send speech: %w", errors.New("connection closed")), errors.New("cancellation failed"), )) var record map[string]any if err := json.Unmarshal(output.Bytes(), &record); err != nil { t.Fatal(err) } if record["error"] != "send speech: connection closed\ncancellation failed" { t.Fatalf("error = %v", record["error"]) } } func TestDebugLevelAndSource(t *testing.T) { for _, enabled := range []bool{false, true} { var output bytes.Buffer level := slog.LevelInfo if enabled { level = slog.LevelDebug } logger := New(&output, Options{Level: level, AddSource: enabled}) logger.Debug("event received") if !enabled { if output.Len() != 0 { t.Fatal("debug logged without debug level") } continue } var record map[string]any if err := json.Unmarshal(output.Bytes(), &record); err != nil { t.Fatal(err) } if !strings.Contains(fmt.Sprint(record["source"]), "logging_test.go:") || !strings.Contains(fmt.Sprint(record["function"]), "TestDebugLevelAndSource") { t.Fatalf("missing call site: %v", record) } } } type diagnosticValue struct{} func (diagnosticValue) LogValue() slog.Value { return slog.GroupValue(slog.String("token", "generic-token"), slog.String("role", "terminal")) } func TestResolvedAndBoundGroups(t *testing.T) { var output bytes.Buffer logger := New(&output, Options{}).WithGroup("speech").With( slog.Group("request", slog.String("role", "terminal")), ) logger.Info("event", "details", diagnosticValue{}) var record map[string]any if err := json.Unmarshal(output.Bytes(), &record); err != nil { t.Fatal(err) } if record["speech.request.role"] != "terminal" || record["speech.details.role"] != "terminal" || record["speech.details.token"] != redactedValue { t.Fatalf("incorrect grouped values: %v", record) } }