package main import ( "bytes" "encoding/json" "errors" "fmt" "io" "net/http" "net/http/httptest" "strings" "testing" "time" "git.unkin.net/unkin/agent-tools/internal/agent" ) // A bad PR reference must fail the command (so main exits non-zero) rather than // return nil. Parsing rejects the ref before any Vault/Gitea call, so this stays // hermetic. func TestExecuteBadRefErrors(t *testing.T) { cmd := newRootCmd() cmd.SetArgs([]string{"--once", "not-a-ref"}) cmd.SetOut(io.Discard) cmd.SetErr(io.Discard) if err := cmd.Execute(); err == nil { t.Fatal("Execute() = nil, want error for a bad PR reference") } } // No arguments is also an error (nothing to watch). func TestExecuteNoArgsErrors(t *testing.T) { cmd := newRootCmd() cmd.SetArgs(nil) cmd.SetOut(io.Discard) cmd.SetErr(io.Discard) if err := cmd.Execute(); err == nil { t.Fatal("Execute() = nil, want error when no PR references are given") } } // Watching a public repo with no credentials available must work: the failed // mint is a warning, the poll goes out unauthenticated, and the command exits 0. func TestOnceRunsAnonymouslyWhenNoTokenIsAvailable(t *testing.T) { vault := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusForbidden) })) defer vault.Close() authHeaders := 0 mux := http.NewServeMux() mux.HandleFunc("/api/v1/repos/unkin/repo/pulls/7", func(w http.ResponseWriter, r *http.Request) { if r.Header.Get("Authorization") != "" { authHeaders++ } _, _ = io.WriteString(w, `{"number":7,"state":"open","mergeable":true,"head":{"sha":"cafebabe"}}`) }) mux.HandleFunc("/api/v1/repos/unkin/repo/commits/cafebabe/status", func(w http.ResponseWriter, r *http.Request) { _, _ = io.WriteString(w, `{"state":"success"}`) }) mux.HandleFunc("/api/v1/repos/unkin/repo/issues/7/comments", func(w http.ResponseWriter, r *http.Request) { _, _ = io.WriteString(w, `[]`) }) gitea := httptest.NewServer(mux) defer gitea.Close() t.Setenv("VAULT_ADDR", vault.URL) t.Setenv("GITEA_URL", gitea.URL) cmd := newRootCmd() cmd.SetArgs([]string{"--once", "unkin/repo#7"}) cmd.SetOut(io.Discard) cmd.SetErr(io.Discard) if err := cmd.Execute(); err != nil { t.Fatalf("anonymous --once should succeed without a token: %v", err) } if authHeaders != 0 { t.Errorf("sent %d Authorization headers, want none", authHeaders) } } // A bare integer interval means seconds and must survive flag parsing: the // command should fail on the missing PR reference, not on the flag value. func TestExecuteBareIntervalIsSeconds(t *testing.T) { cmd := newRootCmd() cmd.SetArgs([]string{"--interval", "15"}) cmd.SetOut(io.Discard) cmd.SetErr(io.Discard) err := cmd.Execute() if err == nil { t.Fatal("Execute() = nil, want the no-references error") } if !strings.Contains(err.Error(), "no PR references given") { t.Fatalf("Execute() error = %v, want the no-references error", err) } } // An unparseable interval is rejected before any Vault/Gitea call, with an // error naming the flag and showing valid forms. func TestExecuteBadIntervalErrors(t *testing.T) { cmd := newRootCmd() cmd.SetArgs([]string{"--interval", "soon", "unkin/repo#1"}) cmd.SetOut(io.Discard) cmd.SetErr(io.Discard) err := cmd.Execute() if err == nil { t.Fatal("Execute() = nil, want error for an unparseable --interval") } for _, want := range []string{"--interval", "30s"} { if !strings.Contains(err.Error(), want) { t.Errorf("Execute() error %q does not mention %q", err, want) } } } // failingVault serves an AppRole login that never issues a token, so the // command falls back to anonymous polling exactly as it does when Vault is // unreachable. func failingVault(t *testing.T) *httptest.Server { t.Helper() srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusForbidden) })) t.Cleanup(srv.Close) return srv } // An anonymous run against a repo that is not public must exit non-zero saying // no token was available — not claim a token expired, and not keep going. func TestOnceAnonymousRejectionNamesTheMissingToken(t *testing.T) { vault := failingVault(t) requests := 0 gitea := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { requests++ w.WriteHeader(http.StatusUnauthorized) _, _ = io.WriteString(w, `{"message":"invalid username, password or token"}`) })) defer gitea.Close() t.Setenv("VAULT_ADDR", vault.URL) t.Setenv("GITEA_URL", gitea.URL) cmd := newRootCmd() cmd.SetArgs([]string{"--once", "unkin/repo#7"}) cmd.SetOut(io.Discard) cmd.SetErr(io.Discard) err := cmd.Execute() if err == nil { t.Fatal("Execute() = nil, want a non-zero exit when the poll is rejected") } if !strings.Contains(err.Error(), "no token could be minted") { t.Errorf("Execute() error = %q, want it to name the missing token", err) } if requests != 1 { t.Errorf("gitea requests = %d, want 1 (no replay without a credential)", requests) } } // describeFailure must tell the four terminal causes apart: each one sends the // reader somewhere different, and a watcher that stops without saying why is // the failure this names. func TestDescribeFailureNamesTheCause(t *testing.T) { rejected := &agent.APIError{Method: "GET", Path: "/p", StatusCode: 401, Body: `{"message":"invalid username, password or token"}`} forbidden := &agent.APIError{Method: "GET", Path: "/p", StatusCode: 403, Body: `{"message":"Forbidden"}`} tests := []struct { name string err error want string }{ {"anonymous", fmt.Errorf("%w: %w", agent.ErrNoCredential, rejected), "no token could be minted"}, {"permission boundary", error(forbidden), "denied access"}, {"rejected token", error(rejected), "re-minting did not recover it"}, {"other", errors.New("dial tcp: timeout"), "dial tcp: timeout"}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got := describeFailure(tt.err) if got == nil || !strings.Contains(got.Error(), tt.want) { t.Errorf("describeFailure = %v, want it to mention %q", got, tt.want) } if !errors.Is(got, tt.err) { t.Errorf("describeFailure dropped the underlying error %v", tt.err) } }) } } // A watch started against an already conflicted or CI-red PR sends no alert for // either, so the baseline line has to say that is what it is doing: before this, // such a run printed nothing in either direction. func TestBaselineLineNamesSuppressedConditions(t *testing.T) { st := agent.PRState{ Ref: agent.PRRef{Owner: "unkin", Repo: "repo", Number: 7}, State: "open", Mergeable: agent.MergeNo, CIStatus: "failure", HeadSHA: "cafebabecafebabe", BaseSHA: "deadbeefdeadbeef", } got := baselineLine(st) for _, want := range []string{ "unkin/repo#7", "mergeable=false", "ci=failure", "head=cafebabe", "base=deadbeef", "already non-mergeable", "CI already failure", "not alerting", } { if !strings.Contains(got, want) { t.Errorf("baselineLine = %q, want it to mention %q", got, want) } } clean := st clean.Mergeable = agent.MergeYes clean.CIStatus = "success" if got := baselineLine(clean); strings.Contains(got, "not alerting") { t.Errorf("baselineLine = %q, want no suppression note for a clean baseline", got) } } // --json is the mode automation uses, and automation is exactly who needs to be // told the watch started against an already-conflicted PR -- the one case that // will never produce an alert. It must therefore be emitted in JSON mode too, // on stderr, where it cannot corrupt the result record on stdout. func TestBaselineIsEmittedInJSONMode(t *testing.T) { st := agent.PRState{ Ref: agent.PRRef{Owner: "unkin", Repo: "repo", Number: 7}, State: "open", Mergeable: agent.MergeNo, CIStatus: "failure", HeadSHA: "cafebabecafebabe", } var buf bytes.Buffer emitBaselines(&buf, []agent.PRState{st}, 30*time.Second, true) var got baselineRecord if err := json.Unmarshal(buf.Bytes(), &got); err != nil { t.Fatalf("baseline is not a JSON record (%v); got %q", err, buf.String()) } if !got.Baseline { t.Error("record does not mark itself as the baseline") } if got.State.Ref != st.Ref || got.State.Mergeable != agent.MergeNo { t.Errorf("record state = %+v, want the conflicted snapshot the watch started from", got.State) } for _, want := range []string{"already non-mergeable", "CI already failure"} { if !strings.Contains(got.Suppressed, want) { t.Errorf("suppressed = %q, want it to mention %q", got.Suppressed, want) } } buf.Reset() clean := st clean.Mergeable = agent.MergeYes clean.CIStatus = "success" emitBaselines(&buf, []agent.PRState{clean}, 30*time.Second, true) if strings.Contains(buf.String(), "suppressed") { t.Errorf("clean baseline = %q, want no suppression field", buf.String()) } } // Under --json stderr is the baseline and warning channel, so every notice // watchpr writes there has to be one shape: a caller parsing it line by line // must never meet a bare `warning:` line between two NDJSON records. Cobra's // terminal `Error: ...` line is not covered here -- SilenceErrors stays off, so // it is plain text on stderr alongside a non-zero exit. func TestJSONModeWarningsAndBaselineAreRecords(t *testing.T) { st := agent.PRState{ Ref: agent.PRRef{Owner: "unkin", Repo: "repo", Number: 7}, State: "open", Mergeable: agent.MergeNo, } var buf bytes.Buffer warn(&buf, true, "no Gitea token (%v); polling anonymously", errors.New("vault refused")) emitBaselines(&buf, []agent.PRState{st}, 30*time.Second, true) warn(&buf, true, "polling %s: %v", st.Ref.String(), errors.New("HTTP 502")) lines := strings.Split(strings.TrimSpace(buf.String()), "\n") if len(lines) != 3 { t.Fatalf("stderr = %q, want 3 records", buf.String()) } for _, line := range lines { var rec map[string]any if err := json.Unmarshal([]byte(line), &rec); err != nil { t.Errorf("stderr line %q is not a JSON record: %v", line, err) } } var first warnRecord if err := json.Unmarshal([]byte(lines[0]), &first); err != nil || !strings.Contains(first.Warning, "vault refused") { t.Errorf("first record = %q, want the token warning", lines[0]) } buf.Reset() warn(&buf, false, "polling %s: %v", st.Ref.String(), errors.New("HTTP 502")) if got := buf.String(); !strings.HasPrefix(got, "warning: ") { t.Errorf("plain-mode warning = %q, want the warning: prefix", got) } } // An unknown mergeability disarms the conflict rule exactly as a non-mergeable // baseline does, so the baseline has to name it too. func TestBaselineNamesAnUnknownMergeability(t *testing.T) { st := agent.PRState{ Ref: agent.PRRef{Owner: "unkin", Repo: "repo", Number: 7}, State: "open", Mergeable: agent.MergeUnknown, CIStatus: "success", } if got := suppressedAtBaseline(st); !strings.Contains(got, "mergeability unknown") { t.Errorf("suppressed = %q, want it to name the unknown mergeability", got) } if got := baselineLine(st); !strings.Contains(got, "not alerting") { t.Errorf("baselineLine = %q, want the suppression note", got) } }