Name the cause when a watch stops on an auth failure
ci/woodpecker/pr/build Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful
ci/woodpecker/pr/pre-commit Pipeline was successful

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.
This commit is contained in:
2026-09-19 16:13:38 +10:00
parent 72adebbf8b
commit 90ce747a61
6 changed files with 387 additions and 17 deletions
+192
View File
@@ -9,6 +9,7 @@ import (
"net/http/httptest"
"strings"
"testing"
"time"
)
// fakeVault serves the AppRole login and the gitea creds secret at credsPath
@@ -618,3 +619,194 @@ func TestAnonymousPollingNeverMints(t *testing.T) {
t.Errorf("sent %d Authorization headers, want none", authHeaders)
}
}
// Anonymous access to something that is not public is not a stale credential:
// nothing was sent to be rejected, so the client must not burn a Vault mint on
// every poll, and the error must say a token was missing rather than expired.
func TestAnonymousAuthFailureNeverMints(t *testing.T) {
requests := 0
mux := http.NewServeMux()
mux.HandleFunc("/api/v1/repos/unkin/repo/pulls/7", func(w http.ResponseWriter, r *http.Request) {
requests++
w.WriteHeader(http.StatusUnauthorized)
_, _ = io.WriteString(w, `{"message":"invalid username, password or token"}`)
})
srv := httptest.NewServer(mux)
defer srv.Close()
refreshes := 0
c := &GiteaClient{BaseURL: srv.URL, HTTP: srv.Client(),
Refresh: func() (string, error) { refreshes++; return "fresh", nil }}
_, err := c.GetPR("unkin/repo", 7)
if err == nil {
t.Fatal("expected an error on an anonymous 401")
}
if !IsNoCredential(err) {
t.Errorf("IsNoCredential(%v) = false, want true", err)
}
if !IsAuthError(err) {
t.Errorf("IsAuthError(%v) = false; an anonymous rejection still ends a watch", err)
}
if refreshes != 0 {
t.Errorf("refreshes = %d, want 0 (nothing was rejected)", refreshes)
}
if requests != 1 {
t.Errorf("requests = %d, want 1 (no replay)", requests)
}
}
// Gitea's bare "Forbidden" is a permission boundary, not an expired token. A
// re-mint cannot grant a permission the identity lacks, so the client must not
// spend one, and the failure must not be reported as an auth expiry.
func TestPermissionDeniedIsNotRemintedOrRetried(t *testing.T) {
requests := 0
mux := http.NewServeMux()
mux.HandleFunc("/api/v1/repos/unkin/repo/pulls/7", func(w http.ResponseWriter, r *http.Request) {
requests++
w.WriteHeader(http.StatusForbidden)
_, _ = io.WriteString(w, `{"errors":null,"message":"Forbidden","url":"https://git.unkin.net/api/swagger"}`)
})
srv := httptest.NewServer(mux)
defer srv.Close()
refreshes := 0
c := &GiteaClient{BaseURL: srv.URL, Token: "t", HTTP: srv.Client(),
Refresh: func() (string, error) { refreshes++; return "fresh", nil }}
_, err := c.GetPR("unkin/repo", 7)
if err == nil {
t.Fatal("expected an error on a 403")
}
if !IsPermissionDenied(err) {
t.Errorf("IsPermissionDenied(%v) = false, want true", err)
}
if IsCredentialRejected(err) {
t.Errorf("a bare Forbidden must not read as a rejected credential")
}
if refreshes != 0 || requests != 1 {
t.Errorf("refreshes = %d, requests = %d, want 0 and 1", refreshes, requests)
}
}
// A 403 that names the token is a credential problem after all (Gitea reports a
// missing scope this way), so it keeps the re-mint-and-replay path.
func TestScopeForbiddenIsRemintedAndRetried(t *testing.T) {
var seen []string
mux := http.NewServeMux()
mux.HandleFunc("/api/v1/repos/unkin/repo/pulls/7", func(w http.ResponseWriter, r *http.Request) {
tok := strings.TrimPrefix(r.Header.Get("Authorization"), "token ")
seen = append(seen, tok)
if tok != "fresh" {
w.WriteHeader(http.StatusForbidden)
_, _ = io.WriteString(w, `{"message":"token does not have at least one of required scope(s): [read:repository]"}`)
return
}
_, _ = io.WriteString(w, `{"number":7,"state":"open","mergeable":true,"head":{"sha":"cafebabe"}}`)
})
srv := httptest.NewServer(mux)
defer srv.Close()
refreshes := 0
c := &GiteaClient{BaseURL: srv.URL, Token: "stale", HTTP: srv.Client(),
Refresh: func() (string, error) { refreshes++; return "fresh", nil }}
pr, err := c.GetPR("unkin/repo", 7)
if err != nil {
t.Fatalf("GetPR after re-mint: %v", err)
}
if pr.Number != 7 || refreshes != 1 {
t.Errorf("PR = %+v, refreshes = %d, want PR 7 and 1 re-mint", pr, refreshes)
}
if len(seen) != 2 || seen[1] != "fresh" {
t.Errorf("tokens seen = %v, want [stale fresh]", seen)
}
}
// A re-mint that hands back an empty token must fail loudly. Replaying with it
// would drop the Authorization header, and on a public repo that anonymous
// replay succeeds — the watch would carry on having quietly lost its identity.
func TestEmptyRemintedTokenFailsInsteadOfGoingAnonymous(t *testing.T) {
var seen []string
mux := http.NewServeMux()
mux.HandleFunc("/api/v1/repos/unkin/repo/pulls/7", func(w http.ResponseWriter, r *http.Request) {
tok := strings.TrimPrefix(r.Header.Get("Authorization"), "token ")
seen = append(seen, tok)
if tok == "stale" {
w.WriteHeader(http.StatusUnauthorized)
_, _ = io.WriteString(w, `{"message":"invalid username, password or token"}`)
return
}
_, _ = io.WriteString(w, `{"number":7,"state":"open","mergeable":true,"head":{"sha":"cafebabe"}}`)
})
srv := httptest.NewServer(mux)
defer srv.Close()
c := &GiteaClient{BaseURL: srv.URL, Token: "stale", HTTP: srv.Client(),
Refresh: func() (string, error) { return "", nil }}
_, err := c.GetPR("unkin/repo", 7)
if err == nil {
t.Fatal("an empty re-minted token must be an error, not an anonymous retry")
}
if !IsAuthError(err) {
t.Errorf("IsAuthError(%v) = false, want true", err)
}
if len(seen) != 1 {
t.Errorf("requests = %d, want 1 (no anonymous replay)", len(seen))
}
}
// The real-world rotation case end to end: the watch runs happily for several
// polls, then the Vault lease expires and Gitea rejects every token, the fresh
// one included. Watch must end with a named auth failure instead of polling on.
func TestWatchAbortsWhenTokenExpiresMidWatch(t *testing.T) {
polls := 0
mux := http.NewServeMux()
mux.HandleFunc("/api/v1/repos/unkin/repo/pulls/7", func(w http.ResponseWriter, r *http.Request) {
polls++
if polls > 3 {
w.WriteHeader(http.StatusUnauthorized)
_, _ = io.WriteString(w, `{"message":"invalid username, password or token"}`)
return
}
_, _ = 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, `[]`)
})
srv := httptest.NewServer(mux)
defer srv.Close()
refreshes := 0
c := &GiteaClient{BaseURL: srv.URL, Token: "t1", HTTP: srv.Client(),
Refresh: func() (string, error) { refreshes++; return "t2", nil }}
ref := PRRef{Owner: "unkin", Repo: "repo", Number: 7}
ticks := make(chan time.Time, 5)
for i := 0; i < 5; i++ {
ticks <- time.Now()
}
close(ticks)
warned := 0
_, err := Watch(c, []PRRef{ref}, "unkin-agent", ticks, nil, func(PRRef, error) { warned++ })
if err == nil {
t.Fatal("Watch returned nil: an expired token must end the watch, not be polled past")
}
if !IsAuthError(err) {
t.Errorf("Watch error = %v, want an auth error", err)
}
if IsNoCredential(err) {
t.Errorf("a rejected token must not be reported as a missing one: %v", err)
}
if refreshes != 1 {
t.Errorf("refreshes = %d, want 1", refreshes)
}
if warned != 0 {
t.Errorf("auth failure logged as a warning %d time(s); it must abort", warned)
}
}