Files

159 lines
4.8 KiB
Go

// 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 <https://www.gnu.org/licenses/gpl-3.0.html>.
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)
}
}