package config import ( "os" "path/filepath" "strings" "testing" ) const testCfg = ` vertical = "teststack" work_root = "/tmp/harness-test" report_dir = "out" [redmine] url = "https://rm.test" key_ref = "env:HARNESS_TEST_RM_KEY" scope_query = "project=x&status_id=open" [litellm] base_url = "http://litellm.test:4000" key_ref = "env:HARNESS_TEST_LLM_KEY" [models] mopac-study = "glm-4.7-flash" mopac-code = "glm-5.2" mopac-review = "glm-5-turbo" mopac-primary = "glm-5.3" default_tier = "mopac-primary" [models.classes] study = "mopac-study" code = "mopac-code" primary = "mopac-primary" [tools.bash] allow = ["pwd", "ls *"] deny = ["sudo *"] ` func writeTemp(t *testing.T, content string) string { t.Helper() path := filepath.Join(t.TempDir(), "harness.toml") if err := os.WriteFile(path, []byte(content), 0o644); err != nil { t.Fatal(err) } return path } func TestLoadAppliesDocAndDefaults(t *testing.T) { cfg, err := Load(writeTemp(t, testCfg)) if err != nil { t.Fatalf("Load: %v", err) } if cfg.Vertical != "teststack" || cfg.WorkRoot != "/tmp/harness-test" || cfg.ReportDir != "out" { t.Errorf("core fields wrong: %+v", cfg) } if cfg.Loop.MaxRounds != 8 { t.Errorf("default max_rounds = %d, want 8", cfg.Loop.MaxRounds) } if cfg.LiteLLM.BaseURL != "http://litellm.test:4000" || cfg.LiteLLM.MaxRetries != 2 { t.Errorf("litellm wrong: %+v", cfg.LiteLLM) } wantTiers := map[string]string{ "mopac-study": "glm-4.7-flash", "mopac-code": "glm-5.2", "mopac-review": "glm-5-turbo", "mopac-primary": "glm-5.3", } for tier, model := range wantTiers { if got, ok := cfg.Models.Tiers[tier]; !ok || got != model { t.Errorf("tier %s = %q ok=%v, want %q", tier, got, ok, model) } } if cfg.Models.Classes["code"] != "mopac-code" { t.Errorf("class map wrong: %+v", cfg.Models.Classes) } if len(cfg.Bash.Allow) != 2 || len(cfg.Bash.Deny) != 1 { t.Errorf("bash lists wrong: %v / %v", cfg.Bash.Allow, cfg.Bash.Deny) } if cfg.Bash.TimeoutSecs != 60 || cfg.Bash.MaxOutputBytes != 100_000 { t.Errorf("bash defaults wrong: %+v", cfg.Bash) } if cfg.Demo.Prompt != "tell me about yourself" { t.Errorf("demo default prompt = %q", cfg.Demo.Prompt) } } func TestLoadTrackedExampleFile(t *testing.T) { // The example ships to users; it must always parse and validate as-is. path := filepath.Join("..", "..", "harness.toml.example") if _, err := os.Stat(path); err != nil { t.Skipf("example not found: %v", err) } cfg, err := Load(path) if err != nil { t.Fatalf("Load(harness.toml.example): %v", err) } if cfg.Vertical != "demo" || cfg.Models.Tiers["mopac-primary"] != "glm-5.3" { t.Errorf("example parsed wrong: %+v", cfg.Models.Tiers) } if len(cfg.Models.Classes) != 9 { t.Errorf("example classes = %d, want 9", len(cfg.Models.Classes)) } } func TestLoadValidationErrors(t *testing.T) { cases := []struct { name string mut func(string) string want string }{ { name: "missing vertical", mut: func(s string) string { return strings.Replace(s, `vertical = "teststack"`, "", 1) }, want: "vertical is required", }, { name: "missing litellm url", mut: func(s string) string { return strings.Replace(s, `base_url = "http://litellm.test:4000"`, "", 1) }, want: "base_url is required", }, { name: "bare key value", mut: func(s string) string { return strings.Replace(s, `key_ref = "env:HARNESS_TEST_LLM_KEY"`, `key_ref = "sk-or-whatever"`, 1) }, want: "env:, file:, literal:, mpk:, or bw:", }, { name: "bw ref not implemented", mut: func(s string) string { return strings.Replace(s, `key_ref = "env:HARNESS_TEST_LLM_KEY"`, `key_ref = "bw:item"`, 1) }, want: "not implemented", }, { name: "redmine half configured", mut: func(s string) string { return strings.Replace(s, `scope_query = "project=x&status_id=open"`, "", 1) }, want: "scope_query or scope_query_id is required", }, { name: "class to unknown tier", mut: func(s string) string { return strings.Replace(s, `code = "mopac-code"`, `code = "mopac-nope"`, 1) }, want: `unknown tier "mopac-nope"`, }, { name: "default tier missing", mut: func(s string) string { return strings.Replace(s, `mopac-primary = "glm-5.3"`, "", 1) }, want: `default_tier "mopac-primary" has no entry`, }, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { _, err := Load(writeTemp(t, tc.mut(testCfg))) if err == nil { t.Fatalf("expected error containing %q, got nil", tc.want) } if !strings.Contains(err.Error(), tc.want) { t.Fatalf("error %q does not contain %q", err, tc.want) } }) } } func TestDemoOnlyConfigIsValid(t *testing.T) { // No [redmine] at all: --demo still works. cfg, err := Load(writeTemp(t, ` vertical = "demo" [litellm] base_url = "http://x:4001" key_ref = "literal:test-key" [models] mopac-primary = "glm-5.3" `)) if err != nil { t.Fatalf("Load: %v", err) } if cfg.Redmine.URL != "" { t.Errorf("redmine should be unset") } } func TestResolveKeyRef(t *testing.T) { t.Setenv("HARNESS_TEST_KEY", "sekrit") if v, err := ResolveKeyRef("env:HARNESS_TEST_KEY"); err != nil || v != "sekrit" { t.Errorf("env ref = %q err=%v", v, err) } if _, err := ResolveKeyRef("env:HARNESS_TEST_UNSET"); err == nil { t.Errorf("unset env should error") } f := filepath.Join(t.TempDir(), "key") os.WriteFile(f, []byte(" filekey\n"), 0o600) if v, err := ResolveKeyRef("file:" + f); err != nil || v != "filekey" { t.Errorf("file ref = %q err=%v", v, err) } if v, err := ResolveKeyRef("literal:abc"); err != nil || v != "abc" { t.Errorf("literal ref = %q err=%v", v, err) } // Secrets must never leak through the redaction path used in errors. _, err := ResolveKeyRef("super-secret-value") if err == nil || strings.Contains(err.Error(), "super-secret-value") { t.Errorf("bare ref error leaks value: %v", err) } } func TestEventsDefaults(t *testing.T) { cfg, err := Load(writeTemp(t, ` vertical = "demo" [litellm] base_url = "http://x:4001" key_ref = "literal:test-key" [models] mopac-primary = "glm-5.3" `)) if err != nil { t.Fatalf("Load: %v", err) } if cfg.Events.Listen != ":4100" { t.Errorf("default listen = %q, want :4100", cfg.Events.Listen) } if cfg.Events.StateDir != "state/events" { t.Errorf("default state_dir = %q, want state/events", cfg.Events.StateDir) } if cfg.Events.Redmine.SecretHeader != "X-Redmine-Webhook-Secret" { t.Errorf("default redmine secret_header = %q", cfg.Events.Redmine.SecretHeader) } if cfg.Events.Discourse.SecretHeader != "X-Discourse-Webhook-Secret" { t.Errorf("default discourse secret_header = %q", cfg.Events.Discourse.SecretHeader) } } func TestEventsConfigParsed(t *testing.T) { cfg, err := Load(writeTemp(t, ` vertical = "demo" [litellm] base_url = "http://x:4001" key_ref = "literal:test-key" [models] mopac-primary = "glm-5.3" [events] listen = "127.0.0.1:9999" state_dir = "st/ev" [events.redmine] secret_ref = "env:HARNESS_RM_HOOK" secret_header = "X-Custom-Redmine" [events.discourse] secret_ref = "file:/run/secrets/discourse" [events.gitea] secret_ref = "env:HARNESS_GITEA_HOOK" `)) if err != nil { t.Fatalf("Load: %v", err) } if cfg.Events.Listen != "127.0.0.1:9999" || cfg.Events.StateDir != "st/ev" { t.Errorf("events core wrong: %+v", cfg.Events) } if cfg.Events.Redmine.SecretRef != "env:HARNESS_RM_HOOK" || cfg.Events.Redmine.SecretHeader != "X-Custom-Redmine" { t.Errorf("redmine source wrong: %+v", cfg.Events.Redmine) } if cfg.Events.Discourse.SecretRef != "file:/run/secrets/discourse" { t.Errorf("discourse source wrong: %+v", cfg.Events.Discourse) } if cfg.Events.Gitea.SecretRef != "env:HARNESS_GITEA_HOOK" { t.Errorf("gitea source wrong: %+v", cfg.Events.Gitea) } } func TestEventsValidateBadRef(t *testing.T) { _, err := Load(writeTemp(t, ` vertical = "demo" [litellm] base_url = "http://x:4001" key_ref = "literal:test-key" [models] mopac-primary = "glm-5.3" [events.gitea] secret_ref = "HARNESS_GITEA_HOOK" `)) if err == nil { t.Fatalf("expected validation error for unprefixed secret_ref") } if !strings.Contains(err.Error(), "[events.gitea]") { t.Errorf("error %q does not name [events.gitea]", err) } if strings.Contains(err.Error(), "HARNESS_GITEA_HOOK") { t.Errorf("error leaks ref value: %v", err) } } func TestLoopAndKeyProxyAndGiteaParsed(t *testing.T) { cfg, err := Load(writeTemp(t, ` vertical = "demo" [litellm] base_url = "http://x:4001" key_ref = "mpk:mpk-litellm" [models] mopac-primary = "glm-5.3" [loop] max_rounds = 6 poll_interval_secs = 45 state_dir = "st/loop" [redmine] url = "https://rm.test" key_ref = "mpk:redmine" scope_query = "project=x" [redmine.status_map] "In Progress" = "Done" New = "In Progress" [keyproxy] url = "http://127.0.0.1:8082" token_ref = "literal:kp-token" cache_ttl_secs = 30 [gitea] url = "https://git.test" key_ref = "mpk:gitea" owner = "ukrrs" repo = "MOPAC" branch = "reports" commit_reports = true `)) if err != nil { t.Fatalf("Load: %v", err) } if cfg.Loop.PollIntervalSecs != 45 || cfg.Loop.StateDir != "st/loop" || cfg.Loop.MaxRounds != 6 { t.Errorf("loop wrong: %+v", cfg.Loop) } if cfg.Redmine.StatusMap["In Progress"] != "Done" || cfg.Redmine.StatusMap["New"] != "In Progress" { t.Errorf("status map wrong: %+v", cfg.Redmine.StatusMap) } if cfg.KeyProxy.URL != "http://127.0.0.1:8082" || cfg.KeyProxy.TokenRef != "literal:kp-token" || cfg.KeyProxy.CacheTTLSecs != 30 { t.Errorf("keyproxy wrong: %+v", cfg.KeyProxy) } if !cfg.Gitea.CommitReports || cfg.Gitea.Owner != "ukrrs" || cfg.Gitea.Repo != "MOPAC" || cfg.Gitea.Branch != "reports" { t.Errorf("gitea wrong: %+v", cfg.Gitea) } } func TestLoopDefaults(t *testing.T) { cfg, err := Load(writeTemp(t, ` vertical = "demo" [litellm] base_url = "http://x:4001" key_ref = "literal:k" [models] mopac-primary = "glm-5.3" `)) if err != nil { t.Fatalf("Load: %v", err) } if cfg.Loop.PollIntervalSecs != 120 { t.Errorf("default poll_interval_secs = %d, want 120", cfg.Loop.PollIntervalSecs) } if cfg.Loop.StateDir != "state/loop" { t.Errorf("default loop state_dir = %q", cfg.Loop.StateDir) } if cfg.KeyProxy.URL != "" || cfg.Gitea.CommitReports { t.Errorf("keyproxy/gitea must default off: %+v %+v", cfg.KeyProxy, cfg.Gitea) } } func TestKeyProxyValidation(t *testing.T) { cases := []struct{ name, extra, want string }{ { name: "token without url", extra: "[keyproxy]\ntoken_ref = \"literal:t\"\n", want: "[keyproxy]: url is required", }, { name: "url without token", extra: "[keyproxy]\nurl = \"http://127.0.0.1:8082\"\n", want: "[keyproxy]: token_ref is required", }, { name: "mpk token ref (recursion)", extra: "[keyproxy]\nurl = \"http://127.0.0.1:8082\"\ntoken_ref = \"mpk:self\"\n", want: "must be a local ref", }, { name: "gitea on but incomplete", extra: "[gitea]\ncommit_reports = true\nurl = \"https://git.test\"\n", want: "[gitea]: url, key_ref, owner and repo are required", }, { name: "loop poll too small", extra: "[loop]\npoll_interval_secs = 0\n", want: "[loop]: poll_interval_secs must be >= 1", }, } base := ` vertical = "demo" [litellm] base_url = "http://x:4001" key_ref = "literal:k" [models] mopac-primary = "glm-5.3" ` for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { _, err := Load(writeTemp(t, base+tc.extra)) if err == nil { t.Fatalf("expected error containing %q", tc.want) } if !strings.Contains(err.Error(), tc.want) { t.Fatalf("error %q does not contain %q", err, tc.want) } }) } } func TestCheckKeyRefMPK(t *testing.T) { for _, ref := range []string{"mpk:mpk-redmine", "mpk:redmine"} { if err := CheckKeyRef(ref); err != nil { t.Errorf("CheckKeyRef(%q) = %v, want nil", ref, err) } } if err := CheckKeyRef("mpk:"); err == nil { t.Errorf("CheckKeyRef(\"mpk:\") should fail") } // The local resolver must point mpk: users at the configured resolver. _, err := ResolveKeyRef("mpk:redmine") if err == nil || !strings.Contains(err.Error(), "[keyproxy]") { t.Errorf("local ResolveKeyRef on mpk: should name [keyproxy], got %v", err) } }