diff --git a/internal/config/loader_test.go b/internal/config/loader_test.go new file mode 100644 index 0000000..432d0fe --- /dev/null +++ b/internal/config/loader_test.go @@ -0,0 +1,554 @@ +package config + +import ( + "os" + "path/filepath" + "testing" +) + +// ── Helpers ────────────────────────────────────────────────────────────── + +// writeYAML creates a temporary YAML file and returns its path. +func writeYAML(t *testing.T, dir, name, content string) string { + t.Helper() + path := filepath.Join(dir, name) + if err := os.WriteFile(path, []byte(content), 0644); err != nil { + t.Fatal(err) + } + return path +} + +// ── 2.1: Parse minimal config ─────────────────────────────────────────── + +func TestLoad_MinimalConfig(t *testing.T) { + dir := t.TempDir() + path := writeYAML(t, dir, "config.yaml", ` +agent: + id: test-bot +matrix: + homeserver: https://matrix.example.com + user_id: "@bot:example.com" +llm: + primary: + provider: openai +`) + cfg, err := Load(path) + if err != nil { + t.Fatalf("Load() error: %v", err) + } + if cfg.Agent.ID != "test-bot" { + t.Errorf("Agent.ID = %q, want %q", cfg.Agent.ID, "test-bot") + } + if cfg.Matrix.Homeserver != "https://matrix.example.com" { + t.Errorf("Matrix.Homeserver = %q, want %q", cfg.Matrix.Homeserver, "https://matrix.example.com") + } + if cfg.Matrix.UserID != "@bot:example.com" { + t.Errorf("Matrix.UserID = %q, want %q", cfg.Matrix.UserID, "@bot:example.com") + } + if cfg.LLM.Primary.Provider != "openai" { + t.Errorf("LLM.Primary.Provider = %q, want %q", cfg.LLM.Primary.Provider, "openai") + } +} + +// ── 2.2: Parse full config with all sections ──────────────────────────── + +func TestLoad_FullConfig(t *testing.T) { + dir := t.TempDir() + path := writeYAML(t, dir, "config.yaml", ` +agent: + id: full-agent + name: "Full Agent" + version: "1.0.0" + type: agent + enabled: true + description: "A fully configured agent" + tags: ["test", "full"] + +personality: + tone: friendly + language: es + prefix: "!" + role: "test assistant" + communication: + formality: semiformal + humor: subtle + +llm: + primary: + provider: openai + model: gpt-4o + api_key_env: OPENAI_API_KEY + max_tokens: 4096 + temperature: 0.7 + tool_use: + enabled: true + max_iterations: 10 + reasoning: + system_prompt_file: prompts/system.md + context_window: 16000 + +matrix: + homeserver: https://matrix.example.com + user_id: "@bot:example.com" + device_id: TESTDEVICE + encryption: + enabled: true + store_path: ./data/crypto + trust_mode: tofu + rooms: + listen: ["!room1:example.com"] + respond: ["!room1:example.com"] + filters: + command_prefix: "!" + mention_respond: true + dm_respond: true + ignore_bots: true + threads: + enabled: true + auto_thread: false + +tools: + ssh: + enabled: true + allowed_targets: ["prod-01"] + http: + enabled: true + allowed_domains: ["api.example.com"] + +security: + sanitize: + enabled: true + mode: warn + min_severity: medium + tool_rate_limit: + enabled: true + max_calls_per_min: 20 + +memory: + enabled: true + window_size: 30 + +storage: + base_path: /tmp/test-data +`) + + cfg, err := Load(path) + if err != nil { + t.Fatalf("Load() error: %v", err) + } + + // Agent identity + if cfg.Agent.ID != "full-agent" { + t.Errorf("Agent.ID = %q, want %q", cfg.Agent.ID, "full-agent") + } + if cfg.Agent.Name != "Full Agent" { + t.Errorf("Agent.Name = %q, want %q", cfg.Agent.Name, "Full Agent") + } + if cfg.Agent.Version != "1.0.0" { + t.Errorf("Agent.Version = %q, want %q", cfg.Agent.Version, "1.0.0") + } + if !cfg.Agent.Enabled { + t.Error("Agent.Enabled = false, want true") + } + if len(cfg.Agent.Tags) != 2 { + t.Errorf("Agent.Tags len = %d, want 2", len(cfg.Agent.Tags)) + } + + // Personality + if cfg.Personality.Tone != "friendly" { + t.Errorf("Personality.Tone = %q, want %q", cfg.Personality.Tone, "friendly") + } + if cfg.Personality.Role != "test assistant" { + t.Errorf("Personality.Role = %q, want %q", cfg.Personality.Role, "test assistant") + } + if cfg.Personality.Communication.Formality != "semiformal" { + t.Errorf("Communication.Formality = %q, want %q", cfg.Personality.Communication.Formality, "semiformal") + } + + // LLM + if cfg.LLM.Primary.Model != "gpt-4o" { + t.Errorf("LLM.Primary.Model = %q, want %q", cfg.LLM.Primary.Model, "gpt-4o") + } + if cfg.LLM.Primary.MaxTokens != 4096 { + t.Errorf("LLM.Primary.MaxTokens = %d, want 4096", cfg.LLM.Primary.MaxTokens) + } + if cfg.LLM.Primary.Temperature != 0.7 { + t.Errorf("LLM.Primary.Temperature = %f, want 0.7", cfg.LLM.Primary.Temperature) + } + if !cfg.LLM.ToolUse.Enabled { + t.Error("LLM.ToolUse.Enabled = false, want true") + } + if cfg.LLM.ToolUse.MaxIterations != 10 { + t.Errorf("LLM.ToolUse.MaxIterations = %d, want 10", cfg.LLM.ToolUse.MaxIterations) + } + + // Matrix + if !cfg.Matrix.Encryption.Enabled { + t.Error("Matrix.Encryption.Enabled = false, want true") + } + if cfg.Matrix.Encryption.TrustMode != "tofu" { + t.Errorf("Encryption.TrustMode = %q, want %q", cfg.Matrix.Encryption.TrustMode, "tofu") + } + if len(cfg.Matrix.Rooms.Listen) != 1 { + t.Errorf("Rooms.Listen len = %d, want 1", len(cfg.Matrix.Rooms.Listen)) + } + if !cfg.Matrix.Threads.Enabled { + t.Error("Matrix.Threads.Enabled = false, want true") + } + + // Tools + if !cfg.Tools.SSH.Enabled { + t.Error("Tools.SSH.Enabled = false, want true") + } + if !cfg.Tools.HTTP.Enabled { + t.Error("Tools.HTTP.Enabled = false, want true") + } + + // Security + if !cfg.Security.Sanitize.Enabled { + t.Error("Security.Sanitize.Enabled = false, want true") + } + if cfg.Security.Sanitize.Mode != "warn" { + t.Errorf("Sanitize.Mode = %q, want %q", cfg.Security.Sanitize.Mode, "warn") + } + if !cfg.Security.ToolRateLimit.Enabled { + t.Error("ToolRateLimit.Enabled = false, want true") + } + + // Memory + if !cfg.Memory.Enabled { + t.Error("Memory.Enabled = false, want true") + } + if cfg.Memory.WindowSize != 30 { + t.Errorf("Memory.WindowSize = %d, want 30", cfg.Memory.WindowSize) + } + + // Storage + if cfg.Storage.BasePath != "/tmp/test-data" { + t.Errorf("Storage.BasePath = %q, want %q", cfg.Storage.BasePath, "/tmp/test-data") + } +} + +// ── 2.3: Env var expansion ────────────────────────────────────────────── + +func TestLoad_EnvVarExpansion(t *testing.T) { + // Set env vars for the test + t.Setenv("TEST_HOMESERVER", "https://expanded.example.com") + t.Setenv("TEST_USER_ID", "@expanded:example.com") + t.Setenv("TEST_PROVIDER", "anthropic") + + dir := t.TempDir() + path := writeYAML(t, dir, "config.yaml", ` +agent: + id: env-test +matrix: + homeserver: "$TEST_HOMESERVER" + user_id: "${TEST_USER_ID}" +llm: + primary: + provider: "$TEST_PROVIDER" +`) + + cfg, err := Load(path) + if err != nil { + t.Fatalf("Load() error: %v", err) + } + + if cfg.Matrix.Homeserver != "https://expanded.example.com" { + t.Errorf("Homeserver = %q, want %q", cfg.Matrix.Homeserver, "https://expanded.example.com") + } + if cfg.Matrix.UserID != "@expanded:example.com" { + t.Errorf("UserID = %q, want %q", cfg.Matrix.UserID, "@expanded:example.com") + } + if cfg.LLM.Primary.Provider != "anthropic" { + t.Errorf("Provider = %q, want %q", cfg.LLM.Primary.Provider, "anthropic") + } +} + +// ── 2.4: Unknown fields (forward compat) ──────────────────────────────── + +func TestLoad_UnknownFieldsIgnored(t *testing.T) { + dir := t.TempDir() + path := writeYAML(t, dir, "config.yaml", ` +agent: + id: unknown-fields-test + future_field: "should be ignored" +matrix: + homeserver: https://matrix.example.com + user_id: "@bot:example.com" + new_section: + enabled: true +llm: + primary: + provider: openai +`) + + cfg, err := Load(path) + if err != nil { + t.Fatalf("Load() error: %v", err) + } + if cfg.Agent.ID != "unknown-fields-test" { + t.Errorf("Agent.ID = %q, want %q", cfg.Agent.ID, "unknown-fields-test") + } +} + +// ── 2.5: Default values ───────────────────────────────────────────────── + +func TestLoad_DefaultValues(t *testing.T) { + dir := t.TempDir() + path := writeYAML(t, dir, "config.yaml", ` +agent: + id: defaults-test +matrix: + homeserver: https://matrix.example.com + user_id: "@bot:example.com" +llm: + primary: + provider: openai +`) + + cfg, err := Load(path) + if err != nil { + t.Fatalf("Load() error: %v", err) + } + + // Boolean defaults should be false (Go zero values) + if cfg.Agent.Enabled { + t.Error("Agent.Enabled should default to false") + } + if cfg.Memory.Enabled { + t.Error("Memory.Enabled should default to false") + } + if cfg.LLM.ToolUse.Enabled { + t.Error("ToolUse.Enabled should default to false") + } + if cfg.Security.Sanitize.Enabled { + t.Error("Sanitize.Enabled should default to false") + } + + // Numeric defaults should be zero + if cfg.LLM.Primary.MaxTokens != 0 { + t.Errorf("MaxTokens should default to 0, got %d", cfg.LLM.Primary.MaxTokens) + } + if cfg.Memory.WindowSize != 0 { + t.Errorf("Memory.WindowSize should default to 0, got %d", cfg.Memory.WindowSize) + } + + // String defaults should be empty + if cfg.Agent.Version != "" { + t.Errorf("Agent.Version should default to empty, got %q", cfg.Agent.Version) + } + if cfg.Agent.Type != "" { + t.Errorf("Agent.Type should default to empty, got %q", cfg.Agent.Type) + } +} + +// ── Validation tests ──────────────────────────────────────────────────── + +func TestLoad_MissingAgentID(t *testing.T) { + dir := t.TempDir() + path := writeYAML(t, dir, "config.yaml", ` +matrix: + homeserver: https://matrix.example.com + user_id: "@bot:example.com" +llm: + primary: + provider: openai +`) + + _, err := Load(path) + if err == nil { + t.Fatal("Load() should fail when agent.id is missing") + } +} + +func TestLoad_MissingHomeserver(t *testing.T) { + dir := t.TempDir() + path := writeYAML(t, dir, "config.yaml", ` +agent: + id: test-bot +matrix: + user_id: "@bot:example.com" +llm: + primary: + provider: openai +`) + + _, err := Load(path) + if err == nil { + t.Fatal("Load() should fail when matrix.homeserver is missing") + } +} + +func TestLoad_MissingUserID(t *testing.T) { + dir := t.TempDir() + path := writeYAML(t, dir, "config.yaml", ` +agent: + id: test-bot +matrix: + homeserver: https://matrix.example.com +llm: + primary: + provider: openai +`) + + _, err := Load(path) + if err == nil { + t.Fatal("Load() should fail when matrix.user_id is missing") + } +} + +func TestLoad_MissingProvider(t *testing.T) { + dir := t.TempDir() + path := writeYAML(t, dir, "config.yaml", ` +agent: + id: test-bot +matrix: + homeserver: https://matrix.example.com + user_id: "@bot:example.com" +`) + + _, err := Load(path) + if err == nil { + t.Fatal("Load() should fail when llm.primary.provider is missing") + } +} + +func TestLoad_FileNotFound(t *testing.T) { + _, err := Load("/nonexistent/path/config.yaml") + if err == nil { + t.Fatal("Load() should fail when file does not exist") + } +} + +func TestLoad_InvalidYAML(t *testing.T) { + dir := t.TempDir() + path := writeYAML(t, dir, "config.yaml", ` +agent: + id: [invalid yaml content + this is broken +`) + + _, err := Load(path) + if err == nil { + t.Fatal("Load() should fail on invalid YAML") + } +} + +// ── LoadMeta tests ────────────────────────────────────────────────────── + +func TestLoadMeta_BasicParsing(t *testing.T) { + dir := t.TempDir() + path := writeYAML(t, dir, "config.yaml", ` +agent: + id: meta-test + name: "Meta Test" + type: robot + enabled: true + description: "A test robot" +matrix: + homeserver: https://matrix.example.com + user_id: "@bot:example.com" + access_token_env: "$NONEXISTENT_VAR" +`) + + cfg, err := LoadMeta(path) + if err != nil { + t.Fatalf("LoadMeta() error: %v", err) + } + if cfg.Agent.ID != "meta-test" { + t.Errorf("Agent.ID = %q, want %q", cfg.Agent.ID, "meta-test") + } + if cfg.Agent.Type != "robot" { + t.Errorf("Agent.Type = %q, want %q", cfg.Agent.Type, "robot") + } + // LoadMeta does NOT expand env vars — raw $NONEXISTENT_VAR kept + if cfg.Matrix.AccessTokenEnv != "$NONEXISTENT_VAR" { + t.Errorf("AccessTokenEnv = %q, want %q", cfg.Matrix.AccessTokenEnv, "$NONEXISTENT_VAR") + } +} + +func TestLoadMeta_MissingAgentID(t *testing.T) { + dir := t.TempDir() + path := writeYAML(t, dir, "config.yaml", ` +agent: + name: "No ID" +`) + + _, err := LoadMeta(path) + if err == nil { + t.Fatal("LoadMeta() should fail when agent.id is missing") + } +} + +// ── LoadSpecial tests ─────────────────────────────────────────────────── + +func TestLoadSpecial_MinimalConfig(t *testing.T) { + dir := t.TempDir() + path := writeYAML(t, dir, "special.yaml", ` +special: + id: orchestrator + type: orchestrator + enabled: true +llm: + primary: + provider: openai +`) + + cfg, err := LoadSpecial(path) + if err != nil { + t.Fatalf("LoadSpecial() error: %v", err) + } + if cfg.Special.ID != "orchestrator" { + t.Errorf("Special.ID = %q, want %q", cfg.Special.ID, "orchestrator") + } + if cfg.Special.Type != "orchestrator" { + t.Errorf("Special.Type = %q, want %q", cfg.Special.Type, "orchestrator") + } +} + +func TestLoadSpecial_MissingSpecialID(t *testing.T) { + dir := t.TempDir() + path := writeYAML(t, dir, "special.yaml", ` +special: + type: orchestrator +llm: + primary: + provider: openai +`) + + _, err := LoadSpecial(path) + if err == nil { + t.Fatal("LoadSpecial() should fail when special.id is missing") + } +} + +func TestLoadSpecial_MissingType(t *testing.T) { + dir := t.TempDir() + path := writeYAML(t, dir, "special.yaml", ` +special: + id: orchestrator +llm: + primary: + provider: openai +`) + + _, err := LoadSpecial(path) + if err == nil { + t.Fatal("LoadSpecial() should fail when special.type is missing") + } +} + +func TestLoadSpecial_MissingProvider(t *testing.T) { + dir := t.TempDir() + path := writeYAML(t, dir, "special.yaml", ` +special: + id: orchestrator + type: orchestrator +`) + + _, err := LoadSpecial(path) + if err == nil { + t.Fatal("LoadSpecial() should fail when llm.primary.provider is missing") + } +}