Files
agent-tools/cmd/watchpr/main_test.go
T
unkin-agent 90ce747a61
ci/woodpecker/pr/build Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful
ci/woodpecker/pr/pre-commit Pipeline was successful
Name the cause when a watch stops on an auth failure
A 401/403 was handled as one thing, so watchpr re-minted on every
rejection and reported "token expired" for a permission boundary or an
anonymous run that had no token to expire, sending the reader after the
wrong problem.

Classify a 401/403 as a rejected credential, a permission denial, or a
request that carried no token, and re-mint only the first.
Reject a re-minted empty token instead of replaying anonymously.
Report the classified cause from --once as well as from the watch loop.
Document watchpr's exit behaviour per cause.
2026-09-19 16:13:38 +10:00

185 lines
6.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)
}
})
}
}