Files
ghtracker/internal/config/config_test.go
T
lukaszraczylo c79ecb728f feat(web): add automatic actions for open pull requests
A new pr_open alert carries the head commit SHA of each open, non-draft, same-repository pull request. An action with auto enabled sends its webhook once for each pull request and head commit. An authors list limits which pull requests trigger it. The alert never changes repository health.
2026-10-05 00:41:08 +01:00

247 lines
8.1 KiB
Go

package config
import (
"errors"
"os"
"path/filepath"
"strings"
"testing"
"time"
)
func noFile(string) ([]byte, error) { return nil, errors.New("no file") }
func keyFile(string) ([]byte, error) { return []byte("PEM"), nil }
func env(m map[string]string) func(string) string { return func(k string) string { return m[k] } }
func valid() *Config {
return &Config{GitHub: GitHub{AppID: 1, PrivateKeyFile: "k.pem"}, Repos: []string{"a/b"}}
}
func TestFinalizeDefaults(t *testing.T) {
c := valid()
if err := c.finalize(env(nil), keyFile); err != nil {
t.Fatal(err)
}
if c.RefreshInterval != 6*time.Hour || c.Listen != ":8080" || c.Concurrency != 2 || c.MaxItems != 100 {
t.Fatalf("defaults wrong: %+v", c)
}
if c.ManualRefresh == nil || !*c.ManualRefresh {
t.Fatal("manual refresh must default to on")
}
if c.GitHub.APIURL != DefaultAPIURL || c.GitHub.PrivateKeyPEM != "PEM" {
t.Fatalf("github defaults wrong: %+v", c.GitHub)
}
if want := 12*time.Hour + 30*time.Minute; c.Thresholds.DataStaleAfter != want {
t.Fatalf("data stale = %s, want %s", c.Thresholds.DataStaleAfter, want)
}
}
func TestFinalizeEnvKeyWins(t *testing.T) {
c := valid()
c.GitHub.PrivateKeyFile = ""
if err := c.finalize(env(map[string]string{EnvPrivateKey: "ENVPEM"}), noFile); err != nil {
t.Fatal(err)
}
if c.GitHub.PrivateKeyPEM != "ENVPEM" {
t.Fatalf("got %q", c.GitHub.PrivateKeyPEM)
}
}
func TestFinalizeErrors(t *testing.T) {
tests := map[string]func(*Config){
"no app id": func(c *Config) { c.GitHub.AppID = 0 },
"no key": func(c *Config) { c.GitHub.PrivateKeyFile = "" },
"unreadable key": func(c *Config) { c.GitHub.PrivateKeyFile = "x" },
"no repos": func(c *Config) { c.Repos = nil },
"bad repo": func(c *Config) { c.Repos = []string{"justname"} },
"bad repo slashes": func(c *Config) { c.Repos = []string{"a/b/c"} },
"refresh too small": func(c *Config) { c.RefreshInterval = time.Second },
"crit below warn": func(c *Config) {
c.Thresholds.PRWarnAfter, c.Thresholds.PRCritAfter = 10*time.Hour, time.Hour
},
}
for name, mutate := range tests {
t.Run(name, func(t *testing.T) {
c := valid()
mutate(c)
if err := c.finalize(env(nil), noFile); err == nil {
t.Fatal("expected error")
}
})
}
}
func TestNormalizeReposDedupes(t *testing.T) {
got, err := normalizeRepos([]string{"A/b", " a/B ", "c/d"})
if err != nil {
t.Fatal(err)
}
if len(got) != 2 || got[0] != "A/b" || got[1] != "c/d" {
t.Fatalf("got %v", got)
}
}
func TestNegativeThresholdDisables(t *testing.T) {
c := valid()
c.Thresholds.PRCritAfter = -1
if err := c.finalize(env(nil), keyFile); err != nil {
t.Fatal(err)
}
if c.Thresholds.PRCritAfter != -1 {
t.Fatalf("negative must be kept, got %s", c.Thresholds.PRCritAfter)
}
}
func TestLoadRejectsUnknownKeysAndReadsFile(t *testing.T) {
dir := t.TempDir()
write := func(name, body string) string {
p := filepath.Join(dir, name)
if err := os.WriteFile(p, []byte(body), 0o600); err != nil {
t.Fatal(err)
}
return p
}
key := write("k.pem", "PEM")
good := write("ok.yaml", "refresh_interval: 2h\ngithub:\n app_id: 5\n private_key_file: "+key+"\nrepos: [a/b]\n")
c, err := Load(good)
if err != nil || c.RefreshInterval != 2*time.Hour || c.GitHub.PrivateKeyPEM != "PEM" {
t.Fatalf("c=%+v err=%v", c, err)
}
typo := write("typo.yaml", "refresh_intervall: 2h\ngithub:\n app_id: 5\n private_key_file: "+key+"\nrepos: [a/b]\n")
if _, err := Load(typo); err == nil {
t.Fatal("unknown key must be rejected")
}
if _, err := Load(filepath.Join(dir, "missing.yaml")); err == nil {
t.Fatal("missing file must error")
}
}
func TestManualRefreshCanBeDisabled(t *testing.T) {
off := false
c := valid()
c.ManualRefresh = &off
if err := c.finalize(env(nil), keyFile); err != nil {
t.Fatal(err)
}
if *c.ManualRefresh {
t.Fatal("explicit false must stay off")
}
}
func TestWebBase(t *testing.T) {
tests := map[string]string{
"": "https://github.com",
"https://api.github.com": "https://github.com",
"https://api.github.com/": "https://github.com",
"https://ghe.example.com/api/v3": "https://ghe.example.com",
"https://ghe.example.com/api/v3/": "https://ghe.example.com",
"http://ghe.internal:8080/api/v3": "http://ghe.internal:8080",
"https://api.acme.ghe.com": "https://acme.ghe.com",
"https://github.example.com/sub/api/v3": "https://github.example.com/sub",
"not a url": "https://github.com",
}
for in, want := range tests {
if got := WebBase(in); got != want {
t.Errorf("WebBase(%q) = %q, want %q", in, got, want)
}
}
}
func TestActionsDefaultOff(t *testing.T) {
c := valid()
if err := c.finalize(env(nil), keyFile); err != nil {
t.Fatal(err)
}
if len(c.Actions) != 0 {
t.Fatalf("actions must default to none: %+v", c.Actions)
}
}
func TestActionsFinalize(t *testing.T) {
good := func() Action {
return Action{ID: "rerun", Webhook: Webhook{URL: "https://hooks.example.test/rerun", TokenEnv: "T"}}
}
tests := map[string]struct {
mutate func(*Action)
extra []Action
wantErr bool
}{
"ok": {mutate: func(*Action) {}},
"no token env": {mutate: func(a *Action) { a.Webhook.TokenEnv = "" }},
"bad id upper": {mutate: func(a *Action) { a.ID = "Re_run" }, wantErr: true},
"empty id": {mutate: func(a *Action) { a.ID = "" }, wantErr: true},
"duplicate id": {mutate: func(*Action) {}, extra: []Action{good()}, wantErr: true},
"bad webhook": {mutate: func(a *Action) { a.Webhook.URL = "ftp://x" }, wantErr: true},
"no webhook": {mutate: func(a *Action) { a.Webhook.URL = "" }, wantErr: true},
"bad status url": {mutate: func(a *Action) { a.StatusURL = "nope" }, wantErr: true},
"status url ok": {mutate: func(a *Action) { a.StatusURL = "http://status.example.test/s" }},
"negative timeout": {mutate: func(a *Action) { a.Webhook.Timeout = -time.Second }, wantErr: true},
"token env unset": {mutate: func(a *Action) { a.Webhook.TokenEnv = "MISSING" }, wantErr: true},
"auto with kinds": {mutate: func(a *Action) { a.Auto, a.Kinds = true, []string{"pr_open"} }},
"auto no kinds": {mutate: func(a *Action) { a.Auto = true }, wantErr: true},
}
for name, tc := range tests {
t.Run(name, func(t *testing.T) {
a := good()
tc.mutate(&a)
c := valid()
c.Actions = append([]Action{a}, tc.extra...)
err := c.finalize(env(map[string]string{"T": "s3cret"}), keyFile)
if (err != nil) != tc.wantErr {
t.Fatalf("err = %v, wantErr %v", err, tc.wantErr)
}
if err != nil && strings.Contains(err.Error(), "s3cret") {
t.Fatal("error leaks token")
}
if err == nil {
got := c.Actions[0]
if got.Label != "rerun" || got.Webhook.Timeout != DefaultActionTimeout {
t.Fatalf("defaults wrong: %+v", got)
}
if a.Webhook.TokenEnv == "T" && got.Token != "s3cret" {
t.Fatalf("token not read: %+v", got)
}
}
})
}
}
func TestActionAppliesTo(t *testing.T) {
tests := []struct {
kind string
kinds []string
want bool
}{
{kinds: nil, kind: "anything", want: true},
{kinds: []string{"workflow_failed"}, kind: "workflow_failed", want: true},
{kinds: []string{"workflow_failed"}, kind: "pr_waiting", want: false},
{kinds: []string{"a", "b"}, kind: "b", want: true},
}
for _, tc := range tests {
if got := (Action{Kinds: tc.kinds}).AppliesTo(tc.kind); got != tc.want {
t.Errorf("%v.AppliesTo(%q) = %v", tc.kinds, tc.kind, got)
}
}
}
func TestActionAuthorAllowed(t *testing.T) {
tests := map[string]struct {
author string
authors []string
want bool
}{
"no filter": {"anyone", nil, true},
"listed": {"alice", []string{"alice"}, true},
"case-insensitive": {"aLICE", []string{"Alice"}, true},
"unlisted": {"bob", []string{"alice"}, false},
"empty author": {"", []string{"alice"}, false},
}
for name, tc := range tests {
t.Run(name, func(t *testing.T) {
if got := (Action{Authors: tc.authors}).AuthorAllowed(tc.author); got != tc.want {
t.Fatalf("AuthorAllowed(%q) = %v, want %v", tc.author, got, tc.want)
}
})
}
}