90ce747a61
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.
185 lines
6.1 KiB
Go
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)
|
|
}
|
|
})
|
|
}
|
|
}
|