Files
teabot/internal/config/config_test.go
T
unkinben 748048be50
ci/woodpecker/pr/build Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful
ci/woodpecker/pr/pre-commit Pipeline was successful
Add fail-closed author allowlist gating job dispatch
Sessions run claude with --dangerously-skip-permissions and a prompt built
from issue/PR/comment text, so only trusted authors may supply that text.
teabot now dispatches a job only when the triggering event's author login is
on an allowlist; an empty allowlist dispatches nothing (fail-closed).

Claude-Session: https://claude.ai/code/session_015ur3i7D2azsMAWTSVABApv
2026-07-27 00:33:09 +10:00

366 lines
11 KiB
Go

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)
}
}