From 7943e290ec9ff1c4723b212184d61d8ef693cfd0 Mon Sep 17 00:00:00 2001 From: Aska Date: Sun, 9 Aug 2026 20:35:42 +0800 Subject: [PATCH] =?UTF-8?q?feat(internal):=20add=20foundational=20packages?= =?UTF-8?q?=20=E2=80=94=20config,=20logger,=20errors?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - config: env-based loading with startup validation, 13 tests - logger: structured slog wrapper with redaction, 10 tests - errors: unified AppError model with HTTP mapping, 18 tests - 41 tests total, build + vet clean --- backend/go.mod | 5 + backend/go.sum | 2 + backend/internal/config/.gitkeep | 0 backend/internal/config/config.go | 311 +++++++++++++++++++++++++ backend/internal/config/config_test.go | 308 ++++++++++++++++++++++++ backend/internal/config/loader.go | 171 ++++++++++++++ backend/internal/errors/errors.go | 159 +++++++++++++ backend/internal/errors/errors_test.go | 239 +++++++++++++++++++ backend/internal/logger/.gitkeep | 0 backend/internal/logger/logger.go | 199 ++++++++++++++++ backend/internal/logger/logger_test.go | 196 ++++++++++++++++ 11 files changed, 1590 insertions(+) create mode 100644 backend/go.mod create mode 100644 backend/go.sum delete mode 100644 backend/internal/config/.gitkeep create mode 100644 backend/internal/config/config.go create mode 100644 backend/internal/config/config_test.go create mode 100644 backend/internal/config/loader.go create mode 100644 backend/internal/errors/errors.go create mode 100644 backend/internal/errors/errors_test.go delete mode 100644 backend/internal/logger/.gitkeep create mode 100644 backend/internal/logger/logger.go create mode 100644 backend/internal/logger/logger_test.go diff --git a/backend/go.mod b/backend/go.mod new file mode 100644 index 0000000..f0c0adb --- /dev/null +++ b/backend/go.mod @@ -0,0 +1,5 @@ +module git.yeij.top/AskaEth/Cyrene_v2 + +go 1.25.0 + +require github.com/joho/godotenv v1.5.1 diff --git a/backend/go.sum b/backend/go.sum new file mode 100644 index 0000000..d61b19e --- /dev/null +++ b/backend/go.sum @@ -0,0 +1,2 @@ +github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= +github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= diff --git a/backend/internal/config/.gitkeep b/backend/internal/config/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go new file mode 100644 index 0000000..20c662a --- /dev/null +++ b/backend/internal/config/config.go @@ -0,0 +1,311 @@ +// Package config provides configuration loading with environment variable binding and startup validation. +// All configuration values are loaded from environment variables with sensible defaults. +// Required fields that are missing will cause a fatal error at startup. +package config + +import ( + "fmt" + "os" + "strconv" + "strings" + "time" +) + +// Config holds all application configuration. +// Fields are populated from environment variables during Load(). +type Config struct { + // Environment + Env string // "development" | "production" | "test" + LogLevel string // "debug" | "info" | "warn" | "error" + LogFmt string // "text" | "json" + + // Server + Port int + Host string + GinMode string // "debug" | "release" | "test" + + // Database + DatabaseURL string // Full connection string (takes priority) + PostgresHost string + PostgresPort int + PostgresUser string + PostgresPassword string + PostgresDB string + PostgresSSLMode string + + // NATS + NATSUrl string + + // Redis + RedisHost string + RedisPort int + + // LLM + LLMBaseURL string + LLMAPIKey string + LLMModel string + LLMFallbackModel string + + // Embedding + EmbeddingAPIURL string + EmbeddingModel string + EmbeddingAPIKey string + + // Voice + DashScopeAPIKey string + TTSProvider string // "edge-tts" | "espeak" + ASRProvider string // "dashscope" | "whisper-local" + + // Auth + JWTSecret string + JWTExpiryHours int + AdminUsername string + AdminPassword string + RegistrationOpen bool + + // Internal Service + InternalServiceToken string + + // CORS + AllowedOrigins []string + + // Background Thinking + EnableBackgroundThinking bool + ThinkIntervalSec int + ThinkSilenceTimeoutSec int + + // Platform Bridge + PlatformBridgeEnabled bool + PlatformChannels string + + // IoT + IoTProvider string // "mock" | "homeassistant" | "mqtt" | "none" + + // IoT Debug Service URL (legacy, kept for v1 compat) + IoTServiceURL string + + // RAG + PersonaDir string + KnowledgeDir string + DataDir string + + // Search + SearXNGURL string + + // Host Exec + HostExecBackend string // "wsl" | "docker" | "direct" | "" + + // Caddy (production) + Domain string + ACMEEmail string + + // Ethend (deprecated, kept for compat) + EthendPort int + + // WebSocket + WSMaxConnections int + SessionIdleTimeoutMin int + + // Timezone + TZ string +} + +// DefaultConfig returns a Config populated with safe default values. +func DefaultConfig() *Config { + return &Config{ + Env: "development", + LogLevel: "info", + LogFmt: "text", + + Port: 8080, + Host: "0.0.0.0", + GinMode: "debug", + + PostgresHost: "localhost", + PostgresPort: 5432, + PostgresUser: "cyrene", + PostgresPassword: "cyrene_pass", + PostgresDB: "cyrene_ai", + PostgresSSLMode: "disable", + + NATSUrl: "nats://localhost:4222", + + RedisHost: "localhost", + RedisPort: 6379, + + LLMBaseURL: "https://api.openai.com/v1", + LLMModel: "gpt-4o", + LLMFallbackModel: "gpt-4o-mini", + + EmbeddingModel: "text-embedding-3-small", + + TTSProvider: "edge-tts", + ASRProvider: "dashscope", + + JWTExpiryHours: 720, + AdminUsername: "admin", + RegistrationOpen: true, + + EnableBackgroundThinking: true, + ThinkIntervalSec: 300, + ThinkSilenceTimeoutSec: 120, + + IoTProvider: "mock", + + PersonaDir: "./internal/persona", + KnowledgeDir: "./data/knowledge", + DataDir: "./data", + + HostExecBackend: "", + + EthendPort: 9090, + + WSMaxConnections: 1000, + SessionIdleTimeoutMin: 30, + + TZ: "Asia/Shanghai", + } +} + +// DatabaseURL returns the constructed database URL if no explicit DATABASE_URL was set. +func (c *Config) GetDatabaseURL() string { + if c.DatabaseURL != "" { + return c.DatabaseURL + } + return fmt.Sprintf("postgres://%s:%s@%s:%d/%s?sslmode=%s", + c.PostgresUser, c.PostgresPassword, c.PostgresHost, c.PostgresPort, c.PostgresDB, c.PostgresSSLMode) +} + +// IsDevelopment returns true when running in development mode. +func (c *Config) IsDevelopment() bool { + return c.Env == "development" +} + +// IsProduction returns true when running in production mode. +func (c *Config) IsProduction() bool { + return c.Env == "production" +} + +// validate runs all validation checks on the loaded config. +// Returns a list of validation errors. +func (c *Config) validate() []string { + var errs []string + + // Required: LLM API Key (only required in production or when not using mock) + if c.LLMAPIKey == "" { + errs = append(errs, "LLM_API_KEY is required") + } + + // Required: JWT Secret + if c.JWTSecret == "" { + errs = append(errs, "JWT_SECRET is required") + } + if len(c.JWTSecret) < 32 { + errs = append(errs, "JWT_SECRET must be at least 32 characters") + } + + // Required: Admin Password + if c.AdminPassword == "" { + errs = append(errs, "ADMIN_PASSWORD is required") + } + if len(c.AdminPassword) < 8 { + errs = append(errs, "ADMIN_PASSWORD must be at least 8 characters") + } + + // Required: Internal Service Token + if c.InternalServiceToken == "" && c.IsProduction() { + errs = append(errs, "INTERNAL_SERVICE_TOKEN is required in production") + } + + // IoT Provider must be valid + validIoTProviders := map[string]bool{"mock": true, "homeassistant": true, "mqtt": true, "none": true} + if !validIoTProviders[c.IoTProvider] { + errs = append(errs, fmt.Sprintf("IOT_PROVIDER must be one of: mock, homeassistant, mqtt, none (got: %s)", c.IoTProvider)) + } + + return errs +} + +// ValidateOrFatal validates the config and calls log.Fatal if any required fields are missing. +func (c *Config) ValidateOrFatal() { + errs := c.validate() + if len(errs) > 0 { + fmt.Fprintf(os.Stderr, "=== Configuration validation errors ===\n") + for _, e := range errs { + fmt.Fprintf(os.Stderr, " - %s\n", e) + } + fmt.Fprintf(os.Stderr, "=======================================\n") + fmt.Fprintf(os.Stderr, "Fix the above errors and restart.\n") + os.Exit(1) + } +} + +// --- Helper functions for loading from environment --- + +func getEnv(key, defaultVal string) string { + if v := os.Getenv(key); v != "" { + return v + } + return defaultVal +} + +func getEnvInt(key string, defaultVal int) int { + v := os.Getenv(key) + if v == "" { + return defaultVal + } + i, err := strconv.Atoi(v) + if err != nil { + fmt.Fprintf(os.Stderr, "WARNING: invalid integer for %s=%q, using default %d\n", key, v, defaultVal) + return defaultVal + } + return i +} + +func getEnvBool(key string, defaultVal bool) bool { + v := os.Getenv(key) + if v == "" { + return defaultVal + } + switch strings.ToLower(v) { + case "1", "true", "yes", "on": + return true + case "0", "false", "no", "off": + return false + default: + fmt.Fprintf(os.Stderr, "WARNING: invalid boolean for %s=%q, using default %v\n", key, v, defaultVal) + return defaultVal + } +} + +func getEnvSlice(key string, defaultVal []string) []string { + v := os.Getenv(key) + if v == "" { + return defaultVal + } + parts := strings.Split(v, ",") + result := make([]string, 0, len(parts)) + for _, p := range parts { + p = strings.TrimSpace(p) + if p != "" { + result = append(result, p) + } + } + if len(result) == 0 { + return defaultVal + } + return result +} + +func getEnvDuration(key string, defaultVal time.Duration) time.Duration { + v := os.Getenv(key) + if v == "" { + return defaultVal + } + d, err := time.ParseDuration(v) + if err != nil { + fmt.Fprintf(os.Stderr, "WARNING: invalid duration for %s=%q, using default %v\n", key, v, defaultVal) + return defaultVal + } + return d +} diff --git a/backend/internal/config/config_test.go b/backend/internal/config/config_test.go new file mode 100644 index 0000000..e2f5407 --- /dev/null +++ b/backend/internal/config/config_test.go @@ -0,0 +1,308 @@ +package config + +import ( + "os" + "testing" +) + +func TestDefaultConfig(t *testing.T) { + cfg := DefaultConfig() + + if cfg.Port != 8080 { + t.Errorf("expected Port=8080, got %d", cfg.Port) + } + if cfg.Env != "development" { + t.Errorf("expected Env=development, got %s", cfg.Env) + } + if cfg.PostgresHost != "localhost" { + t.Errorf("expected PostgresHost=localhost, got %s", cfg.PostgresHost) + } + if cfg.IoTProvider != "mock" { + t.Errorf("expected IoTProvider=mock, got %s", cfg.IoTProvider) + } +} + +func TestGetDatabaseURL(t *testing.T) { + tests := []struct { + name string + dbURL string + host string + port int + user string + pass string + db string + sslMode string + expected string + }{ + { + name: "explicit DATABASE_URL takes priority", + dbURL: "postgres://custom:pass@remote:9999/mydb?sslmode=require", + expected: "postgres://custom:pass@remote:9999/mydb?sslmode=require", + }, + { + name: "default connection string from individual fields", + host: "localhost", + port: 5432, + user: "cyrene", + pass: "cyrene_pass", + db: "cyrene_ai", + sslMode: "disable", + expected: "postgres://cyrene:cyrene_pass@localhost:5432/cyrene_ai?sslmode=disable", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cfg := DefaultConfig() + if tt.dbURL != "" { + cfg.DatabaseURL = tt.dbURL + } + if tt.host != "" { + cfg.PostgresHost = tt.host + } + if tt.port != 0 { + cfg.PostgresPort = tt.port + } + if tt.user != "" { + cfg.PostgresUser = tt.user + } + if tt.pass != "" { + cfg.PostgresPassword = tt.pass + } + if tt.db != "" { + cfg.PostgresDB = tt.db + } + if tt.sslMode != "" { + cfg.PostgresSSLMode = tt.sslMode + } + + got := cfg.GetDatabaseURL() + if got != tt.expected { + t.Errorf("GetDatabaseURL() = %q, want %q", got, tt.expected) + } + }) + } +} + +func TestIsDevelopment(t *testing.T) { + cfg := DefaultConfig() + cfg.Env = "development" + if !cfg.IsDevelopment() { + t.Error("expected IsDevelopment() to be true") + } + if cfg.IsProduction() { + t.Error("expected IsProduction() to be false in dev mode") + } + + cfg.Env = "production" + if cfg.IsDevelopment() { + t.Error("expected IsDevelopment() to be false in prod mode") + } + if !cfg.IsProduction() { + t.Error("expected IsProduction() to be true") + } +} + +func TestValidateMissingLLMAPIKey(t *testing.T) { + cfg := DefaultConfig() + cfg.LLMAPIKey = "" + cfg.Env = "development" + + // In dev mode, we validate but don't call ValidateOrFatal directly + errs := cfg.validate() + found := false + for _, e := range errs { + if contains(e, "LLM_API_KEY") { + found = true + } + } + if !found { + t.Errorf("expected LLM_API_KEY validation error, got: %v", errs) + } +} + +func TestValidateMissingJWTSecret(t *testing.T) { + cfg := DefaultConfig() + cfg.JWTSecret = "" + errs := cfg.validate() + found := false + for _, e := range errs { + if contains(e, "JWT_SECRET") { + found = true + } + } + if !found { + t.Errorf("expected JWT_SECRET validation error, got: %v", errs) + } +} + +func TestValidateJWTSecretTooShort(t *testing.T) { + cfg := DefaultConfig() + cfg.JWTSecret = "short" + errs := cfg.validate() + found := false + for _, e := range errs { + if contains(e, "JWT_SECRET must be at least 32 characters") { + found = true + } + } + if !found { + t.Errorf("expected JWT_SECRET length validation error, got: %v", errs) + } +} + +func TestValidateMissingAdminPassword(t *testing.T) { + cfg := DefaultConfig() + cfg.AdminPassword = "" + errs := cfg.validate() + found := false + for _, e := range errs { + if contains(e, "ADMIN_PASSWORD") { + found = true + } + } + if !found { + t.Errorf("expected ADMIN_PASSWORD validation error, got: %v", errs) + } +} + +func TestValidateAdminPasswordTooShort(t *testing.T) { + cfg := DefaultConfig() + cfg.AdminPassword = "1234567" // 7 chars, min is 8 + errs := cfg.validate() + found := false + for _, e := range errs { + if contains(e, "ADMIN_PASSWORD must be at least 8 characters") { + found = true + } + } + if !found { + t.Errorf("expected ADMIN_PASSWORD length validation error, got: %v", errs) + } +} + +func TestValidateInvalidIoTProvider(t *testing.T) { + cfg := DefaultConfig() + cfg.IoTProvider = "invalid-provider" + errs := cfg.validate() + found := false + for _, e := range errs { + if contains(e, "IOT_PROVIDER") { + found = true + } + } + if !found { + t.Errorf("expected IOT_PROVIDER validation error, got: %v", errs) + } +} + +func TestLoadFromEnv(t *testing.T) { + // Set some test env vars + os.Setenv("PORT", "9999") + os.Setenv("ENV", "test") + os.Setenv("LLM_API_KEY", "sk-test-key") + os.Setenv("JWT_SECRET", "a-very-long-secret-that-is-at-least-32-chars!!") + os.Setenv("ADMIN_PASSWORD", "test-admin-password") + defer func() { + os.Unsetenv("PORT") + os.Unsetenv("ENV") + os.Unsetenv("LLM_API_KEY") + os.Unsetenv("JWT_SECRET") + os.Unsetenv("ADMIN_PASSWORD") + }() + + cfg, err := Load() + if err != nil { + t.Fatalf("Load() error = %v", err) + } + + if cfg.Port != 9999 { + t.Errorf("expected Port=9999 from env, got %d", cfg.Port) + } + if cfg.Env != "test" { + t.Errorf("expected Env=test from env, got %s", cfg.Env) + } + if cfg.LLMAPIKey != "sk-test-key" { + t.Errorf("expected LLMAPIKey from env, got %s", cfg.LLMAPIKey) + } + + // Validation should pass with these values + errs := cfg.validate() + if len(errs) > 0 { + t.Errorf("validation should pass but got errors: %v", errs) + } +} + +func TestGetEnvBool(t *testing.T) { + tests := []struct { + input string + expected bool + }{ + {"1", true}, {"true", true}, {"TRUE", true}, {"yes", true}, {"on", true}, + {"0", false}, {"false", false}, {"FALSE", false}, {"no", false}, {"off", false}, + {"", true}, // empty should use default=true + } + + for _, tt := range tests { + os.Setenv("TEST_BOOL_VAR", tt.input) + got := getEnvBool("TEST_BOOL_VAR", true) + if got != tt.expected { + t.Errorf("getEnvBool(%q, true) = %v, want %v", tt.input, got, tt.expected) + } + } + os.Unsetenv("TEST_BOOL_VAR") +} + +func TestGetEnvSlice(t *testing.T) { + tests := []struct { + input string + expected []string + }{ + {"", []string{"default"}}, + {"a,b,c", []string{"a", "b", "c"}}, + {" a , b , c ", []string{"a", "b", "c"}}, // trim spaces + {"single", []string{"single"}}, + } + + for _, tt := range tests { + os.Setenv("TEST_SLICE_VAR", tt.input) + got := getEnvSlice("TEST_SLICE_VAR", []string{"default"}) + if len(got) != len(tt.expected) { + t.Errorf("getEnvSlice(%q) length = %d, want %d", tt.input, len(got), len(tt.expected)) + continue + } + for i := range got { + if got[i] != tt.expected[i] { + t.Errorf("getEnvSlice(%q)[%d] = %q, want %q", tt.input, i, got[i], tt.expected[i]) + } + } + } + os.Unsetenv("TEST_SLICE_VAR") +} + +func TestAllValidIoTProviders(t *testing.T) { + validProviders := []string{"mock", "homeassistant", "mqtt", "none"} + for _, provider := range validProviders { + cfg := DefaultConfig() + cfg.IoTProvider = provider + errs := cfg.validate() + for _, e := range errs { + if contains(e, "IOT_PROVIDER") { + t.Errorf("IoTProvider=%q should be valid but got error: %s", provider, e) + } + } + } +} + +func contains(s, substr string) bool { + return len(s) >= len(substr) && searchSubstring(s, substr) +} + +func searchSubstring(s, substr string) bool { + for i := 0; i <= len(s)-len(substr); i++ { + if s[i:i+len(substr)] == substr { + return true + } + } + return false +} diff --git a/backend/internal/config/loader.go b/backend/internal/config/loader.go new file mode 100644 index 0000000..8a28b58 --- /dev/null +++ b/backend/internal/config/loader.go @@ -0,0 +1,171 @@ +package config + +import ( + "fmt" + "log" + "os" + "path/filepath" + + "github.com/joho/godotenv" +) + +// Load reads configuration from environment variables, with optional .env file support. +// It returns a fully populated Config or an error if the .env file failed to load. +// Use ValidateOrFatal() after loading to enforce required fields. +func Load() (*Config, error) { + cfg := DefaultConfig() + + // Attempt to load .env file from multiple locations + _ = loadEnvFile() + + // Merge environment variables over defaults + cfg.mergeEnv() + + return cfg, nil +} + +// loadEnvFile tries to load .env from the project root. +// It does not return an error; missing .env is acceptable. +func loadEnvFile() error { + // Try current directory first + if err := godotenv.Load(); err == nil { + return nil + } + + // Try relative to executable + if exe, err := os.Executable(); err == nil { + exeDir := filepath.Dir(exe) + candidates := []string{ + filepath.Join(exeDir, ".env"), + filepath.Join(exeDir, "..", ".env"), + filepath.Join(exeDir, "..", "..", ".env"), + } + for _, path := range candidates { + if _, err := os.Stat(path); err == nil { + _ = godotenv.Load(path) + return nil + } + } + } + + return fmt.Errorf(".env not found") +} + +// mergeEnv reads all environment variables and overrides config defaults. +func (c *Config) mergeEnv() { + // Environment + c.Env = getEnv("ENV", c.Env) + c.LogLevel = getEnv("LOG_LEVEL", c.LogLevel) + c.LogFmt = getEnv("LOG_FORMAT", c.LogFmt) + + // Server + c.Port = getEnvInt("PORT", c.Port) + c.Host = getEnv("HOST", c.Host) + c.GinMode = getEnv("GIN_MODE", c.GinMode) + + // Database + c.DatabaseURL = os.Getenv("DATABASE_URL") + c.PostgresHost = getEnv("POSTGRES_HOST", c.PostgresHost) + c.PostgresPort = getEnvInt("POSTGRES_PORT", c.PostgresPort) + c.PostgresUser = getEnv("POSTGRES_USER", c.PostgresUser) + c.PostgresPassword = getEnv("POSTGRES_PASSWORD", c.PostgresPassword) + c.PostgresDB = getEnv("POSTGRES_DB", c.PostgresDB) + c.PostgresSSLMode = getEnv("POSTGRES_SSLMODE", c.PostgresSSLMode) + + // NATS + c.NATSUrl = getEnv("NATS_URL", c.NATSUrl) + + // Redis + c.RedisHost = getEnv("REDIS_HOST", c.RedisHost) + c.RedisPort = getEnvInt("REDIS_PORT", c.RedisPort) + + // LLM + c.LLMBaseURL = getEnv("LLM_API_URL", c.LLMBaseURL) + c.LLMAPIKey = getEnv("LLM_API_KEY", c.LLMAPIKey) + c.LLMModel = getEnv("LLM_MODEL", c.LLMModel) + c.LLMFallbackModel = getEnv("LLM_FALLBACK_MODEL", c.LLMFallbackModel) + + // Embedding + c.EmbeddingAPIURL = getEnv("EMBEDDING_API_URL", c.EmbeddingAPIURL) + c.EmbeddingModel = getEnv("EMBEDDING_MODEL", c.EmbeddingModel) + c.EmbeddingAPIKey = getEnv("EMBEDDING_API_KEY", c.EmbeddingAPIKey) + + // Voice + c.DashScopeAPIKey = getEnv("DASHSCOPE_API_KEY", c.DashScopeAPIKey) + c.TTSProvider = getEnv("TTS_PROVIDER", c.TTSProvider) + c.ASRProvider = getEnv("ASR_PROVIDER", c.ASRProvider) + + // Auth + c.JWTSecret = getEnv("JWT_SECRET", c.JWTSecret) + c.JWTExpiryHours = getEnvInt("JWT_EXPIRY_HOURS", c.JWTExpiryHours) + c.AdminUsername = getEnv("ADMIN_USERNAME", c.AdminUsername) + c.AdminPassword = getEnv("ADMIN_PASSWORD", c.AdminPassword) + c.RegistrationOpen = getEnvBool("REGISTRATION_ENABLED", c.RegistrationOpen) + + // Internal Service + c.InternalServiceToken = getEnv("INTERNAL_SERVICE_TOKEN", c.InternalServiceToken) + + // CORS + c.AllowedOrigins = getEnvSlice("ALLOWED_ORIGINS", c.AllowedOrigins) + + // Background Thinking + c.EnableBackgroundThinking = getEnvBool("ENABLE_BACKGROUND_THINKING", c.EnableBackgroundThinking) + c.ThinkIntervalSec = getEnvInt("THINK_INTERVAL_SEC", c.ThinkIntervalSec) + c.ThinkSilenceTimeoutSec = getEnvInt("THINK_SILENCE_TIMEOUT_SEC", c.ThinkSilenceTimeoutSec) + + // Platform Bridge + c.PlatformBridgeEnabled = getEnvBool("PLATFORM_BRIDGE_ENABLED", c.PlatformBridgeEnabled) + c.PlatformChannels = getEnv("PLATFORM_CHANNELS", c.PlatformChannels) + + // IoT + c.IoTProvider = getEnv("IOT_PROVIDER", c.IoTProvider) + c.IoTServiceURL = getEnv("IOT_SERVICE_URL", c.IoTServiceURL) + + // RAG & Data + c.PersonaDir = getEnv("PERSONA_DIR", c.PersonaDir) + c.KnowledgeDir = getEnv("KNOWLEDGE_DIR", c.KnowledgeDir) + c.DataDir = getEnv("DATA_DIR", c.DataDir) + + // Search + c.SearXNGURL = getEnv("SEARXNG_URL", c.SearXNGURL) + + // Host Exec + c.HostExecBackend = getEnv("HOST_EXEC_BACKEND", c.HostExecBackend) + + // Caddy (production) + c.Domain = getEnv("DOMAIN", c.Domain) + c.ACMEEmail = getEnv("ACME_EMAIL", c.ACMEEmail) + + // Ethend (deprecated) + c.EthendPort = getEnvInt("ETHEND_PORT", c.EthendPort) + + // WebSocket + c.WSMaxConnections = getEnvInt("WS_MAX_CONNECTIONS", c.WSMaxConnections) + c.SessionIdleTimeoutMin = getEnvInt("SESSION_IDLE_TIMEOUT_MIN", c.SessionIdleTimeoutMin) + + // Timezone + c.TZ = getEnv("TZ", c.TZ) +} + +// logSanitized prints the loaded config, with sensitive fields redacted. +func (c *Config) LogSanitized() { + log.Printf("[config] ENV=%s LOG_LEVEL=%s LOG_FORMAT=%s", c.Env, c.LogLevel, c.LogFmt) + log.Printf("[config] PORT=%d GIN_MODE=%s", c.Port, c.GinMode) + log.Printf("[config] DB_HOST=%s DB_PORT=%d DB_USER=%s DB_NAME=%s", + c.PostgresHost, c.PostgresPort, c.PostgresUser, c.PostgresDB) + log.Printf("[config] NATS_URL=%s REDIS=%s:%d", c.NATSUrl, c.RedisHost, c.RedisPort) + log.Printf("[config] LLM_URL=%s LLM_MODEL=%s FALLBACK=%s", + c.LLMBaseURL, c.LLMModel, c.LLMFallbackModel) + log.Printf("[config] LLM_API_KEY=**** (len=%d)", len(c.LLMAPIKey)) + if c.EmbeddingAPIURL != "" { + log.Printf("[config] EMBEDDING_URL=%s MODEL=%s", c.EmbeddingAPIURL, c.EmbeddingModel) + } else { + log.Printf("[config] EMBEDDING=disabled (using n-gram fallback)") + } + log.Printf("[config] IOT_PROVIDER=%s", c.IoTProvider) + log.Printf("[config] TTS=%s ASR=%s", c.TTSProvider, c.ASRProvider) + log.Printf("[config] BACKGROUND_THINKING=%v INTERVAL=%ds SILENCE=%ds", + c.EnableBackgroundThinking, c.ThinkIntervalSec, c.ThinkSilenceTimeoutSec) + log.Printf("[config] JWT_SECRET=**** (len=%d)", len(c.JWTSecret)) + log.Printf("[config] ADMIN=%s REGISTRATION=%v", c.AdminUsername, c.RegistrationOpen) +} diff --git a/backend/internal/errors/errors.go b/backend/internal/errors/errors.go new file mode 100644 index 0000000..10c14f7 --- /dev/null +++ b/backend/internal/errors/errors.go @@ -0,0 +1,159 @@ +// Package errors provides a unified error model for the Cyrene platform. +// All errors flowing through the system should use AppError or its subtypes. +// This ensures consistent error codes, user-friendly messages, and HTTP status mapping. +package errors + +import ( + "fmt" + "net/http" +) + +// AppError is the unified application error type. +type AppError struct { + Code string // Machine-readable error code (e.g., "USER_NOT_FOUND") + Message string // Human-readable message (safe to show to users) + Cause error // Original error (internal only, not exposed to clients) + HTTPCode int // HTTP status code +} + +// Error implements the error interface. +func (e *AppError) Error() string { + if e.Cause != nil { + return fmt.Sprintf("[%s] %s: %v", e.Code, e.Message, e.Cause) + } + return fmt.Sprintf("[%s] %s", e.Code, e.Message) +} + +// Unwrap returns the underlying cause error. +func (e *AppError) Unwrap() error { + return e.Cause +} + +// --- Pre-defined common errors --- + +// NewNotFound creates a NOT_FOUND error. +func NewNotFound(message string) *AppError { + return &AppError{Code: "NOT_FOUND", Message: message, HTTPCode: http.StatusNotFound} +} + +// NewUnauthorized creates an UNAUTHORIZED error. +func NewUnauthorized(message string) *AppError { + return &AppError{Code: "UNAUTHORIZED", Message: message, HTTPCode: http.StatusUnauthorized} +} + +// NewForbidden creates a FORBIDDEN error. +func NewForbidden(message string) *AppError { + return &AppError{Code: "FORBIDDEN", Message: message, HTTPCode: http.StatusForbidden} +} + +// NewBadRequest creates a BAD_REQUEST error. +func NewBadRequest(message string) *AppError { + return &AppError{Code: "BAD_REQUEST", Message: message, HTTPCode: http.StatusBadRequest} +} + +// NewConflict creates a CONFLICT error. +func NewConflict(message string) *AppError { + return &AppError{Code: "CONFLICT", Message: message, HTTPCode: http.StatusConflict} +} + +// NewTooManyRequests creates a TOO_MANY_REQUESTS error. +func NewTooManyRequests(message string) *AppError { + return &AppError{Code: "TOO_MANY_REQUESTS", Message: message, HTTPCode: http.StatusTooManyRequests} +} + +// NewInternal creates an INTERNAL error with a generic message for clients. +// The cause is logged internally but not exposed. +func NewInternal(message string, cause error) *AppError { + return &AppError{Code: "INTERNAL", Message: message, HTTPCode: http.StatusInternalServerError, Cause: cause} +} + +// NewValidation creates a VALIDATION error. +func NewValidation(message string) *AppError { + return &AppError{Code: "VALIDATION", Message: message, HTTPCode: http.StatusBadRequest} +} + +// --- Constructors with cause --- + +// Wrap wraps an existing error with an application error. +func Wrap(err error, code, message string, httpCode int) *AppError { + return &AppError{Code: code, Message: message, HTTPCode: httpCode, Cause: err} +} + +// WrapNotFound wraps an error as NOT_FOUND. +func WrapNotFound(err error, message string) *AppError { + return &AppError{Code: "NOT_FOUND", Message: message, HTTPCode: http.StatusNotFound, Cause: err} +} + +// WrapBadRequest wraps an error as BAD_REQUEST. +func WrapBadRequest(err error, message string) *AppError { + return &AppError{Code: "BAD_REQUEST", Message: message, HTTPCode: http.StatusBadRequest, Cause: err} +} + +// WrapInternal wraps an error as INTERNAL. +func WrapInternal(err error, message string) *AppError { + return &AppError{Code: "INTERNAL", Message: message, HTTPCode: http.StatusInternalServerError, Cause: err} +} + +// --- Helpers --- + +// IsAppError checks if the error is an AppError. +func IsAppError(err error) (*AppError, bool) { + if err == nil { + return nil, false + } + ae, ok := err.(*AppError) + return ae, ok +} + +// StatusCode extracts the HTTP status code from an error. +// Returns 500 if the error is not an AppError. +func StatusCode(err error) int { + if ae, ok := IsAppError(err); ok { + return ae.HTTPCode + } + return http.StatusInternalServerError +} + +// Message extracts the user-safe message from an error. +// Returns a generic message if the error is not an AppError. +func Message(err error) string { + if ae, ok := IsAppError(err); ok { + return ae.Message + } + return "Internal server error" +} + +// Code extracts the machine-readable error code. +// Returns "INTERNAL" if the error is not an AppError. +func Code(err error) string { + if ae, ok := IsAppError(err); ok { + return ae.Code + } + return "INTERNAL" +} + +// --- Sentinel errors for quick checks --- + +var ( + // ErrUnauthorized is a pre-built unauthorized error. + ErrUnauthorized = &AppError{Code: "UNAUTHORIZED", Message: "Authentication required", HTTPCode: http.StatusUnauthorized} + + // ErrForbidden is a pre-built forbidden error. + ErrForbidden = &AppError{Code: "FORBIDDEN", Message: "Insufficient permissions", HTTPCode: http.StatusForbidden} + + // ErrInternal is a pre-built internal error. + ErrInternal = &AppError{Code: "INTERNAL", Message: "Internal server error", HTTPCode: http.StatusInternalServerError} +) + +// IsCode checks if the error has the given error code. +func IsCode(err error, code string) bool { + if ae, ok := IsAppError(err); ok { + return ae.Code == code + } + return false +} + +// IsNotFound checks if the error is a NOT_FOUND error. +func IsNotFound(err error) bool { + return IsCode(err, "NOT_FOUND") +} diff --git a/backend/internal/errors/errors_test.go b/backend/internal/errors/errors_test.go new file mode 100644 index 0000000..6483821 --- /dev/null +++ b/backend/internal/errors/errors_test.go @@ -0,0 +1,239 @@ +package errors + +import ( + "fmt" + "net/http" + "testing" +) + +func TestNewNotFound(t *testing.T) { + err := NewNotFound("user not found") + + if err.Code != "NOT_FOUND" { + t.Errorf("expected code NOT_FOUND, got %s", err.Code) + } + if err.Message != "user not found" { + t.Errorf("expected message 'user not found', got %s", err.Message) + } + if err.HTTPCode != http.StatusNotFound { + t.Errorf("expected HTTP 404, got %d", err.HTTPCode) + } +} + +func TestNewUnauthorized(t *testing.T) { + err := NewUnauthorized("invalid token") + if err.Code != "UNAUTHORIZED" { + t.Errorf("expected code UNAUTHORIZED, got %s", err.Code) + } + if err.HTTPCode != http.StatusUnauthorized { + t.Errorf("expected HTTP 401, got %d", err.HTTPCode) + } +} + +func TestNewForbidden(t *testing.T) { + err := NewForbidden("admin only") + if err.HTTPCode != http.StatusForbidden { + t.Errorf("expected HTTP 403, got %d", err.HTTPCode) + } +} + +func TestNewBadRequest(t *testing.T) { + err := NewBadRequest("invalid input") + if err.HTTPCode != http.StatusBadRequest { + t.Errorf("expected HTTP 400, got %d", err.HTTPCode) + } +} + +func TestNewConflict(t *testing.T) { + err := NewConflict("duplicate entry") + if err.HTTPCode != http.StatusConflict { + t.Errorf("expected HTTP 409, got %d", err.HTTPCode) + } +} + +func TestNewTooManyRequests(t *testing.T) { + err := NewTooManyRequests("rate limit") + if err.HTTPCode != http.StatusTooManyRequests { + t.Errorf("expected HTTP 429, got %d", err.HTTPCode) + } +} + +func TestNewInternal(t *testing.T) { + cause := fmt.Errorf("db connection refused") + err := NewInternal("database error", cause) + + if err.Code != "INTERNAL" { + t.Errorf("expected code INTERNAL, got %s", err.Code) + } + if err.HTTPCode != http.StatusInternalServerError { + t.Errorf("expected HTTP 500, got %d", err.HTTPCode) + } + if err.Cause != cause { + t.Errorf("expected cause to be preserved") + } + // Check that Error() includes the cause + errStr := err.Error() + if errStr[len(errStr)-1] == '.' { + // just check it doesn't panic + } +} + +func TestNewValidation(t *testing.T) { + err := NewValidation("field required: email") + if err.Code != "VALIDATION" { + t.Errorf("expected code VALIDATION, got %s", err.Code) + } + if err.HTTPCode != http.StatusBadRequest { + t.Errorf("expected HTTP 400, got %d", err.HTTPCode) + } +} + +func TestWrap(t *testing.T) { + cause := fmt.Errorf("raw error") + err := Wrap(cause, "CUSTOM", "custom message", http.StatusTeapot) + + if err.Code != "CUSTOM" { + t.Errorf("expected code CUSTOM, got %s", err.Code) + } + if err.Cause != cause { + t.Errorf("expected cause to be preserved") + } + if err.HTTPCode != http.StatusTeapot { + t.Errorf("expected HTTP 418, got %d", err.HTTPCode) + } +} + +func TestWrapConvenienceFuncs(t *testing.T) { + cause := fmt.Errorf("raw error") + + tests := []struct { + name string + err *AppError + code string + httpCode int + }{ + {"WrapNotFound", WrapNotFound(cause, "not found"), "NOT_FOUND", 404}, + {"WrapBadRequest", WrapBadRequest(cause, "bad input"), "BAD_REQUEST", 400}, + {"WrapInternal", WrapInternal(cause, "oops"), "INTERNAL", 500}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.err.Code != tt.code { + t.Errorf("expected code %s, got %s", tt.code, tt.err.Code) + } + if tt.err.HTTPCode != tt.httpCode { + t.Errorf("expected HTTP %d, got %d", tt.httpCode, tt.err.HTTPCode) + } + if tt.err.Cause != cause { + t.Errorf("expected cause to be preserved") + } + }) + } +} + +func TestUnwrap(t *testing.T) { + cause := fmt.Errorf("original") + err := WrapInternal(cause, "wrapped") + unwrapped := err.Unwrap() + if unwrapped != cause { + t.Errorf("Unwrap() = %v, want %v", unwrapped, cause) + } +} + +func TestIsAppError(t *testing.T) { + ae := NewNotFound("test") + _, ok := IsAppError(ae) + if !ok { + t.Error("expected AppError to be identified") + } + + plain := fmt.Errorf("plain error") + _, ok = IsAppError(plain) + if ok { + t.Error("plain error should not be AppError") + } + + _, ok = IsAppError(nil) + if ok { + t.Error("nil should not be AppError") + } +} + +func TestStatusCode(t *testing.T) { + if sc := StatusCode(NewNotFound("x")); sc != 404 { + t.Errorf("expected 404, got %d", sc) + } + if sc := StatusCode(fmt.Errorf("plain")); sc != 500 { + t.Errorf("expected 500 for plain error, got %d", sc) + } +} + +func TestMessage(t *testing.T) { + if msg := Message(NewNotFound("user x not found")); msg != "user x not found" { + t.Errorf("expected message preserved, got %s", msg) + } + if msg := Message(fmt.Errorf("raw")); msg != "Internal server error" { + t.Errorf("expected generic message for unknown error, got %s", msg) + } +} + +func TestCode(t *testing.T) { + if c := Code(NewForbidden("nope")); c != "FORBIDDEN" { + t.Errorf("expected FORBIDDEN, got %s", c) + } + if c := Code(fmt.Errorf("raw")); c != "INTERNAL" { + t.Errorf("expected INTERNAL for unknown error, got %s", c) + } +} + +func TestIsCode(t *testing.T) { + err := NewNotFound("test") + if !IsCode(err, "NOT_FOUND") { + t.Error("expected IsCode true for NOT_FOUND") + } + if IsCode(err, "FORBIDDEN") { + t.Error("expected IsCode false for wrong code") + } + if IsCode(nil, "NOT_FOUND") { + t.Error("expected IsCode false for nil") + } +} + +func TestIsNotFound(t *testing.T) { + if !IsNotFound(NewNotFound("x")) { + t.Error("expected IsNotFound true") + } + if IsNotFound(NewBadRequest("x")) { + t.Error("expected IsNotFound false for bad request") + } +} + +func TestSentinelErrors(t *testing.T) { + if ErrUnauthorized.Code != "UNAUTHORIZED" { + t.Errorf("expected UNAUTHORIZED sentinel") + } + if ErrForbidden.Code != "FORBIDDEN" { + t.Errorf("expected FORBIDDEN sentinel") + } + if ErrInternal.Code != "INTERNAL" { + t.Errorf("expected INTERNAL sentinel") + } +} + +func TestErrorFormatting(t *testing.T) { + // Without cause + err := NewNotFound("user not found") + str := err.Error() + if str != "[NOT_FOUND] user not found" { + t.Errorf("unexpected format: %s", str) + } + + // With cause + cause := fmt.Errorf("sql: no rows") + err2 := WrapInternal(cause, "database error") + str2 := err2.Error() + if str2[:len("[INTERNAL]")] != "[INTERNAL]" { + t.Errorf("unexpected format: %s", str2) + } +} diff --git a/backend/internal/logger/.gitkeep b/backend/internal/logger/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/backend/internal/logger/logger.go b/backend/internal/logger/logger.go new file mode 100644 index 0000000..13bef62 --- /dev/null +++ b/backend/internal/logger/logger.go @@ -0,0 +1,199 @@ +// Package logger provides a structured logging wrapper around Go's slog package. +// It supports text and JSON formats, configurable log levels, and automatic source location. +// Sensitive fields (API keys, passwords) are automatically redacted. +package logger + +import ( + "context" + "fmt" + "io" + "log/slog" + "os" + "strings" + "time" +) + +// Level represents the severity of a log message. +type Level = slog.Level + +const ( + LevelDebug = slog.LevelDebug + LevelInfo = slog.LevelInfo + LevelWarn = slog.LevelWarn + LevelError = slog.LevelError +) + +// Logger wraps slog.Logger with redaction and context support. +// It does NOT embed *slog.Logger to prevent bypassing redaction. +type Logger struct { + inner *slog.Logger + level Level + redact bool +} + +// New creates a new Logger with the specified writer, level, and format. +func New(w io.Writer, level Level, format string) *Logger { + if w == nil { + w = os.Stderr + } + opts := &slog.HandlerOptions{ + Level: level, + AddSource: level <= LevelDebug, + } + var h slog.Handler + switch format { + case "json": + h = slog.NewJSONHandler(w, opts) + default: + h = slog.NewTextHandler(w, opts) + } + return &Logger{inner: slog.New(h), level: level, redact: true} +} + +// NewNop creates a no-op logger that discards all output. +func NewNop() *Logger { + return &Logger{ + inner: slog.New(slog.NewTextHandler(io.Discard, &slog.HandlerOptions{})), + level: LevelDebug, + redact: false, + } +} + +// ParseLevel converts a string to a slog Level. +func ParseLevel(s string) (Level, error) { + switch strings.ToLower(s) { + case "debug": + return LevelDebug, nil + case "info": + return LevelInfo, nil + case "warn": + return LevelWarn, nil + case "error": + return LevelError, nil + default: + return LevelInfo, fmt.Errorf("unknown log level: %q (valid: debug, info, warn, error)", s) + } +} + +// With returns a child Logger with the given attributes attached. +func (l *Logger) With(args ...any) *Logger { + return &Logger{ + inner: l.inner.With(args...), + level: l.level, + redact: l.redact, + } +} + +// WithGroup returns a child Logger with the given group name. +func (l *Logger) WithGroup(name string) *Logger { + return &Logger{ + inner: l.inner.WithGroup(name), + level: l.level, + redact: l.redact, + } +} + +// Level returns the current log level. +func (l *Logger) Level() Level { return l.level } + +// ---- Core logging methods ---- + +func (l *Logger) Debug(msg string, args ...any) { + l.log(LevelDebug, msg, args...) +} +func (l *Logger) Info(msg string, args ...any) { + l.log(LevelInfo, msg, args...) +} +func (l *Logger) Warn(msg string, args ...any) { + l.log(LevelWarn, msg, args...) +} +func (l *Logger) Error(msg string, args ...any) { + l.log(LevelError, msg, args...) +} + +// ---- Context-aware variants ---- + +func (l *Logger) DebugContext(ctx context.Context, msg string, args ...any) { + l.logCtx(ctx, LevelDebug, msg, args...) +} +func (l *Logger) InfoContext(ctx context.Context, msg string, args ...any) { + l.logCtx(ctx, LevelInfo, msg, args...) +} +func (l *Logger) WarnContext(ctx context.Context, msg string, args ...any) { + l.logCtx(ctx, LevelWarn, msg, args...) +} +func (l *Logger) ErrorContext(ctx context.Context, msg string, args ...any) { + l.logCtx(ctx, LevelError, msg, args...) +} + +// ---- Internal ---- + +func (l *Logger) log(level Level, msg string, args ...any) { + if !l.inner.Enabled(context.Background(), level) { + return + } + attrs := l.buildAttrs(args...) + r := slog.NewRecord(time.Now(), level, msg, 0) + r.AddAttrs(attrs...) + _ = l.inner.Handler().Handle(context.Background(), r) +} + +func (l *Logger) logCtx(ctx context.Context, level Level, msg string, args ...any) { + if !l.inner.Enabled(ctx, level) { + return + } + attrs := l.buildAttrs(args...) + r := slog.NewRecord(time.Now(), level, msg, 0) + r.AddAttrs(attrs...) + _ = l.inner.Handler().Handle(ctx, r) +} + +func (l *Logger) buildAttrs(args ...any) []slog.Attr { + if len(args) == 0 { + return nil + } + if len(args)%2 != 0 { + args = append(args, "!MISSING") + } + attrs := make([]slog.Attr, 0, len(args)/2) + for i := 0; i < len(args); i += 2 { + key, ok := args[i].(string) + if !ok { + key = fmt.Sprintf("!BADKEY(%v)", args[i]) + } + val := args[i+1] + if l.redact { + val = redactValue(key, val) + } + attrs = append(attrs, slog.Any(key, val)) + } + return attrs +} + +// ---- Redaction ---- + +var sensitiveKeys = map[string]bool{ + "api_key": true, + "apikey": true, + "password": true, + "secret": true, + "token": true, + "jwt_secret": true, + "admin_password": true, + "llm_api_key": true, + "dashscope_api_key": true, + "access_token": true, +} + +func redactValue(key string, val any) any { + if sensitiveKeys[strings.ToLower(key)] { + if s, ok := val.(string); ok { + if len(s) > 4 { + return s[:2] + "****" + s[len(s)-2:] + } + return "****" + } + return "****" + } + return val +} diff --git a/backend/internal/logger/logger_test.go b/backend/internal/logger/logger_test.go new file mode 100644 index 0000000..109b14d --- /dev/null +++ b/backend/internal/logger/logger_test.go @@ -0,0 +1,196 @@ +package logger + +import ( + "bytes" + "strings" + "testing" +) + +func TestNewTextLogger(t *testing.T) { + var buf bytes.Buffer + l := New(&buf, LevelDebug, "text") + + l.Info("hello world", "key", "value") + + output := buf.String() + if !strings.Contains(output, "hello world") { + t.Errorf("expected output to contain 'hello world', got: %s", output) + } + if !strings.Contains(output, "key") { + t.Errorf("expected output to contain 'key', got: %s", output) + } + if !strings.Contains(output, "value") { + t.Errorf("expected output to contain 'value', got: %s", output) + } +} + +func TestNewJSONLogger(t *testing.T) { + var buf bytes.Buffer + l := New(&buf, LevelDebug, "json") + + l.Info("hello world", "key", "value") + + output := buf.String() + if !strings.Contains(output, `"msg":"hello world"`) { + t.Errorf("expected JSON output to contain msg, got: %s", output) + } +} + +func TestLogLevels(t *testing.T) { + var buf bytes.Buffer + l := New(&buf, LevelWarn, "text") + + l.Debug("debug msg") + l.Info("info msg") + l.Warn("warn msg") + l.Error("error msg") + + output := buf.String() + if strings.Contains(output, "debug") { + t.Error("debug message should be filtered out at WARN level") + } + if strings.Contains(output, "info") { + t.Error("info message should be filtered out at WARN level") + } + if !strings.Contains(output, "warn") { + t.Error("warn message should appear") + } + if !strings.Contains(output, "error") { + t.Error("error message should appear") + } +} + +func TestWithFields(t *testing.T) { + var buf bytes.Buffer + l := New(&buf, LevelDebug, "text") + + child := l.With("request_id", "abc123", "user_id", "user-1") + child.Info("request processed") + + output := buf.String() + if !strings.Contains(output, "request_id") || !strings.Contains(output, "abc123") { + t.Errorf("expected 'request_id=abc123' in output, got: %s", output) + } + if !strings.Contains(output, "user_id") || !strings.Contains(output, "user-1") { + t.Errorf("expected 'user_id=user-1' in output, got: %s", output) + } +} + +func TestNewNop(t *testing.T) { + l := NewNop() + var buf bytes.Buffer + // Nop logger should not write anything + l2 := New(&buf, LevelDebug, "text") + l2.Info("should appear") + if !strings.Contains(buf.String(), "should appear") { + t.Error("normal logger should write") + } + // Nop logger itself is fine to call + l.Info("should not appear") + l.Error("should not appear") +} + +func TestParseLevel(t *testing.T) { + tests := []struct { + input string + expected Level + hasError bool + }{ + {"debug", LevelDebug, false}, + {"DEBUG", LevelDebug, false}, + {"info", LevelInfo, false}, + {"warn", LevelWarn, false}, + {"error", LevelError, false}, + {"ERROR", LevelError, false}, + {"invalid", LevelInfo, true}, + {"", LevelInfo, true}, + } + + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + level, err := ParseLevel(tt.input) + if tt.hasError && err == nil { + t.Errorf("ParseLevel(%q) expected error but got none", tt.input) + } + if !tt.hasError && err != nil { + t.Errorf("ParseLevel(%q) unexpected error: %v", tt.input, err) + } + if level != tt.expected { + t.Errorf("ParseLevel(%q) = %v, want %v", tt.input, level, tt.expected) + } + }) + } +} + +func TestRedaction(t *testing.T) { + var buf bytes.Buffer + l := New(&buf, LevelDebug, "text") + + l.Info("auth attempt", "api_key", "sk-abcdefgh12345678", "user_id", "user-1") + + output := buf.String() + if strings.Contains(output, "sk-abcdefgh12345678") { + t.Errorf("API key should be redacted, got: %s", output) + } + if !strings.Contains(output, "sk-") && !strings.Contains(output, "****") { + t.Errorf("expected redacted value with mask, got: %s", output) + } + if !strings.Contains(output, "user-1") { + t.Errorf("non-sensitive field should NOT be redacted, got: %s", output) + } +} + +func TestRedactSensitiveKeys(t *testing.T) { + sensitiveTests := []string{"api_key", "API_KEY", "password", "secret", "token", "jwt_secret", "admin_password", "llm_api_key", "dashscope_api_key", "access_token"} + nonSensitiveTests := []string{"user_id", "request_id", "model", "port", "host", "query", "duration_ms", "count"} + + for _, key := range sensitiveTests { + val := redactValue(key, "super-secret-value-12345") + if s, ok := val.(string); ok { + if s == "super-secret-value-12345" { + t.Errorf("key %q should be redacted, got raw value instead", key) + } + if !strings.Contains(s, "****") { + t.Errorf("key %q redacted value should contain '****', got: %s", key, s) + } + } + } + + for _, key := range nonSensitiveTests { + val := redactValue(key, "normal-value") + if s, ok := val.(string); ok { + if s != "normal-value" { + t.Errorf("key %q should NOT be redacted, got: %s", key, s) + } + } + } +} + +func TestRedactShortValue(t *testing.T) { + // Short values (< 4 chars) should be fully masked + val := redactValue("api_key", "ab") + if s, ok := val.(string); ok { + if s != "****" { + t.Errorf("short value should be fully masked, got: %s", s) + } + } +} + +func TestOddNumberOfArgs(t *testing.T) { + // Go's slog vet check prevents odd numbers of arguments at compile time. + // Our wrapper detects this and appends a !MISSING placeholder internally. + // This test verifies buildAttrs handles odd args correctly. + var buf bytes.Buffer + l := New(&buf, LevelDebug, "text") + attrs := l.buildAttrs("key1", "value1", "key2") + if len(attrs) != 2 { + t.Errorf("expected 2 attrs, got %d", len(attrs)) + } + if attrs[1].Key != "key2" { + t.Errorf("expected attrs[1].Key=key2, got %s", attrs[1].Key) + } + // The value for key2 should be "!MISSING" + if attrs[1].Value.String() != "!MISSING" { + t.Errorf("expected !MISSING placeholder, got %s", attrs[1].Value.String()) + } +}