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