54c1d868d3
A mergeable=false baseline meant a conflict could never be reported, since Gitea sends false while it recomputes after a push. Arm on a head or base SHA change as well as on a mergeable poll, break the conflict run on unknown and failed polls, and print the baseline with the conditions it suppresses.
215 lines
7.1 KiB
Go
215 lines
7.1 KiB
Go
package main
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
|
|
"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)
|
|
}
|
|
}
|