package config import ( "os" "path/filepath" "testing" "time" ) // writeTeaConfig writes a minimal tea config.yml and returns its path. func writeTeaConfig(t *testing.T, dir, name, url, token, user string, isDefault bool) string { t.Helper() path := filepath.Join(dir, name+".yml") content := "logins:\n" + " - name: " + name + "\n" + " url: " + url + "\n" + " token: " + token + "\n" + " default: " + boolStr(isDefault) + "\n" + " user: " + user + "\n" if err := os.WriteFile(path, []byte(content), 0o600); err != nil { t.Fatalf("writing tea config: %v", err) } return path } func boolStr(b bool) string { if b { return "true" } return "false" } func writeConfig(t *testing.T, dir, body string) string { t.Helper() path := filepath.Join(dir, "config.yaml") if err := os.WriteFile(path, []byte(body), 0o600); err != nil { t.Fatalf("writing config: %v", err) } return path } func TestLoadAppliesDefaultsAndResolvesPersonalities(t *testing.T) { dir := t.TempDir() impl := writeTeaConfig(t, dir, "impl", "https://git.unkin.net", "tok-impl", "implbot", true) rev := writeTeaConfig(t, dir, "rev", "https://git.unkin.net", "tok-rev", "revbot", false) body := `repos: - unkin/teabot personalities: - name: implementer tea_config: ` + impl + ` role: implementer git_name: Impl Bot git_email: impl@unkin.net - name: reviewer tea_config: ` + rev + ` role: reviewer git_name: Rev Bot git_email: rev@unkin.net ` cfg, err := Load(writeConfig(t, dir, body)) if err != nil { t.Fatalf("Load: %v", err) } if cfg.GiteaURL != DefaultGiteaURL { t.Errorf("GiteaURL default = %q, want %q", cfg.GiteaURL, DefaultGiteaURL) } if cfg.PollInterval != DefaultPollInterval { t.Errorf("PollInterval default = %s, want %s", cfg.PollInterval, DefaultPollInterval) } if cfg.JobImage != DefaultJobImage { t.Errorf("JobImage default = %q, want %q", cfg.JobImage, DefaultJobImage) } if cfg.MaxConcurrent != DefaultMaxConcurrent { t.Errorf("MaxConcurrent default = %d, want %d", cfg.MaxConcurrent, DefaultMaxConcurrent) } // Personalities must be resolved from their tea configs. if got := cfg.Personalities[0]; got.Login != "implbot" || got.Token != "tok-impl" { t.Errorf("implementer resolved = login %q token %q", got.Login, got.Token) } if got := cfg.Personalities[1]; got.Login != "revbot" || got.Token != "tok-rev" { t.Errorf("reviewer resolved = login %q token %q", got.Login, got.Token) } } func TestLoadHonoursOverrides(t *testing.T) { dir := t.TempDir() tea := writeTeaConfig(t, dir, "both", "https://git.example.com", "tok", "bot", true) body := `gitea_url: https://git.example.com/ poll_interval: 5s job_timeout: 10m max_concurrent: 7 job_image: example/img:1 repos: - foo/bar personalities: - name: both tea_config: ` + tea + ` role: both git_name: Bot git_email: bot@example.com ` cfg, err := Load(writeConfig(t, dir, body)) if err != nil { t.Fatalf("Load: %v", err) } if cfg.GiteaURL != "https://git.example.com" { t.Errorf("GiteaURL = %q (trailing slash not trimmed?)", cfg.GiteaURL) } if cfg.PollInterval != 5*time.Second { t.Errorf("PollInterval = %s", cfg.PollInterval) } if cfg.JobTimeout != 10*time.Minute { t.Errorf("JobTimeout = %s", cfg.JobTimeout) } if cfg.MaxConcurrent != 7 { t.Errorf("MaxConcurrent = %d", cfg.MaxConcurrent) } } func TestValidateErrors(t *testing.T) { dir := t.TempDir() tea := writeTeaConfig(t, dir, "t", "https://git.unkin.net", "tok", "bot", true) cases := map[string]string{ "no repos": `personalities: - {name: a, tea_config: ` + tea + `, role: both} `, "bad repo form": `repos: [notaslash] personalities: - {name: a, tea_config: ` + tea + `, role: both} `, "no personalities": `repos: [a/b] `, "only implementer": `repos: [a/b] personalities: - {name: a, tea_config: ` + tea + `, role: implementer} `, "only reviewer": `repos: [a/b] personalities: - {name: a, tea_config: ` + tea + `, role: reviewer} `, "invalid role": `repos: [a/b] personalities: - {name: a, tea_config: ` + tea + `, role: bogus} `, } for name, body := range cases { t.Run(name, func(t *testing.T) { _, err := Load(writeConfig(t, t.TempDir(), body)) if err == nil { t.Fatalf("expected error for %q, got nil", name) } }) } } func TestDuplicatePersonalityNameRejected(t *testing.T) { dir := t.TempDir() tea := writeTeaConfig(t, dir, "t", "https://git.unkin.net", "tok", "bot", true) body := `repos: [a/b] personalities: - {name: dup, tea_config: ` + tea + `, role: implementer} - {name: dup, tea_config: ` + tea + `, role: reviewer} ` if _, err := Load(writeConfig(t, dir, body)); err == nil { t.Fatal("expected duplicate-name error") } } func TestRoleDefaultsToBoth(t *testing.T) { dir := t.TempDir() tea := writeTeaConfig(t, dir, "t", "https://git.unkin.net", "tok", "bot", true) body := `repos: [a/b] personalities: - name: solo tea_config: ` + tea + ` git_name: X git_email: x@y.z ` cfg, err := Load(writeConfig(t, dir, body)) if err != nil { t.Fatalf("Load: %v", err) } if cfg.Personalities[0].Role != RoleBoth { t.Errorf("role = %q, want both", cfg.Personalities[0].Role) } if !cfg.Personalities[0].CanImplement() || !cfg.Personalities[0].CanReview() { t.Error("both role should implement and review") } } func TestBotLoginsAndSelectors(t *testing.T) { cfg := &Config{Personalities: []Personality{ {Name: "i", Role: RoleImplementer, Login: "ibot"}, {Name: "r", Role: RoleReviewer, Login: "rbot"}, }} logins := cfg.BotLogins() if !logins["ibot"] || !logins["rbot"] || len(logins) != 2 { t.Errorf("BotLogins = %v", logins) } if p := cfg.ImplementerFor(); p == nil || p.Name != "i" { t.Errorf("ImplementerFor = %v", p) } if p := cfg.ReviewerFor(); p == nil || p.Name != "r" { t.Errorf("ReviewerFor = %v", p) } } func TestIsAuthorAllowed(t *testing.T) { c := &Config{AllowedAuthors: []string{"alice", " Bob "}} if !c.IsAuthorAllowed("a/b", "alice") { t.Error("alice should be allowed") } // Case-insensitive + surrounding whitespace tolerated. if !c.IsAuthorAllowed("a/b", "BOB") { t.Error("BOB should match ' Bob ' case-insensitively") } if c.IsAuthorAllowed("a/b", "mallory") { t.Error("mallory must not be allowed") } if c.IsAuthorAllowed("a/b", "") { t.Error("empty login must never be allowed") } } func TestAllowedAuthorsForOverridePrecedence(t *testing.T) { c := &Config{ AllowedAuthors: []string{"global1"}, RepoAllowedAuthors: map[string][]string{ "o/override": {"special"}, "o/locked": {}, // present-but-empty disables dispatch for this repo }, } // Absent entry -> global. if !c.IsAuthorAllowed("o/other", "global1") || c.IsAuthorAllowed("o/other", "special") { t.Error("absent repo entry should use the global list") } // Present entry -> replaces global (global1 no longer allowed here). if !c.IsAuthorAllowed("o/override", "special") || c.IsAuthorAllowed("o/override", "global1") { t.Error("present override should fully replace the global list") } // Present-but-empty -> nobody allowed for this repo (fail-closed). if c.IsAuthorAllowed("o/locked", "global1") || c.IsAuthorAllowed("o/locked", "special") { t.Error("empty override must deny everyone for that repo") } } func TestHasAnyAllowlist(t *testing.T) { if (&Config{}).HasAnyAllowlist() { t.Error("empty config must report no allowlist (fail-closed)") } if !(&Config{AllowedAuthors: []string{"x"}}).HasAnyAllowlist() { t.Error("global allowlist should count") } if !(&Config{RepoAllowedAuthors: map[string][]string{"a/b": {"x"}}}).HasAnyAllowlist() { t.Error("per-repo allowlist should count") } if (&Config{RepoAllowedAuthors: map[string][]string{"a/b": {}}}).HasAnyAllowlist() { t.Error("only present-but-empty overrides should not count as an allowlist") } } func TestLoadParsesAllowlist(t *testing.T) { dir := t.TempDir() impl := writeTeaConfig(t, dir, "impl", "https://git.unkin.net", "tok", "implbot", true) rev := writeTeaConfig(t, dir, "rev", "https://git.unkin.net", "tok2", "revbot", false) body := `repos: [unkin/teabot] allowed_authors: - benvin repo_allowed_authors: unkin/teabot: - benvin - colleague personalities: - {name: i, tea_config: ` + impl + `, role: implementer, git_name: I, git_email: i@x} - {name: r, tea_config: ` + rev + `, role: reviewer, git_name: R, git_email: r@x} ` cfg, err := Load(writeConfig(t, dir, body)) if err != nil { t.Fatalf("Load: %v", err) } if !cfg.IsAuthorAllowed("other/repo", "benvin") { t.Error("global allowlist not parsed") } if !cfg.IsAuthorAllowed("unkin/teabot", "colleague") { t.Error("per-repo allowlist not parsed") } if cfg.IsAuthorAllowed("unkin/teabot", "someoneelse") { t.Error("unexpected author allowed on override repo") } } func TestParseTeaConfigPrefersMatchingURL(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "multi.yml") content := `logins: - name: other url: https://other.example.com token: other-tok default: true user: otheruser - name: target url: https://git.unkin.net token: target-tok user: targetuser ` if err := os.WriteFile(path, []byte(content), 0o600); err != nil { t.Fatal(err) } login, err := ParseTeaConfig(path, "https://git.unkin.net") if err != nil { t.Fatalf("ParseTeaConfig: %v", err) } if login.User != "targetuser" || login.Token != "target-tok" { t.Errorf("matched wrong login: %+v", login) } } func TestParseTeaConfigFallsBackToDefault(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "d.yml") content := `logins: - name: a url: https://a.example.com token: a-tok user: a - name: b url: https://b.example.com token: b-tok default: true user: b ` if err := os.WriteFile(path, []byte(content), 0o600); err != nil { t.Fatal(err) } login, err := ParseTeaConfig(path, "https://nomatch.example.com") if err != nil { t.Fatalf("ParseTeaConfig: %v", err) } if login.User != "b" { t.Errorf("expected default login b, got %q", login.User) } } func TestExampleConfigIsValidWhenTeaConfigsExist(t *testing.T) { // The shipped example references tea configs by ~/ path; here we just // verify the example YAML parses into a Config with the expected shape by // substituting resolvable tea configs. dir := t.TempDir() impl := writeTeaConfig(t, dir, "impl", "https://git.unkin.net", "tok", "implbot", true) rev := writeTeaConfig(t, dir, "rev", "https://git.unkin.net", "tok2", "revbot", false) body := `gitea_url: https://git.unkin.net repos: [unkin/teabot] personalities: - {name: implementer, tea_config: ` + impl + `, role: implementer, git_name: I, git_email: i@x} - {name: reviewer, tea_config: ` + rev + `, role: reviewer, git_name: R, git_email: r@x} ` if _, err := Load(writeConfig(t, dir, body)); err != nil { t.Fatalf("example-shaped config failed to load: %v", err) } }