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.
813 lines
29 KiB
Go
813 lines
29 KiB
Go
package agent
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// fakeVault serves the AppRole login and the gitea creds secret at credsPath
|
|
// only, so a read of any other path 404s.
|
|
func fakeVault(t *testing.T, wantRoleID, credsPath, giteaToken string) *httptest.Server {
|
|
t.Helper()
|
|
mux := http.NewServeMux()
|
|
mux.HandleFunc("/v1/auth/approle/login", func(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPost {
|
|
t.Errorf("approle login method = %s, want POST", r.Method)
|
|
}
|
|
var body map[string]string
|
|
_ = json.NewDecoder(r.Body).Decode(&body)
|
|
if body["role_id"] != wantRoleID {
|
|
t.Errorf("role_id = %q, want %q", body["role_id"], wantRoleID)
|
|
}
|
|
if _, ok := body["secret_id"]; ok {
|
|
t.Errorf("secret_id must not be sent")
|
|
}
|
|
_, _ = io.WriteString(w, `{"auth":{"client_token":"s.vaulttoken"}}`)
|
|
})
|
|
mux.HandleFunc("/v1/"+credsPath, func(w http.ResponseWriter, r *http.Request) {
|
|
if got := r.Header.Get("X-Vault-Token"); got != "s.vaulttoken" {
|
|
t.Errorf("X-Vault-Token = %q, want s.vaulttoken", got)
|
|
}
|
|
_, _ = io.WriteString(w, `{"data":{"token":"`+giteaToken+`"}}`)
|
|
})
|
|
return httptest.NewServer(mux)
|
|
}
|
|
|
|
func TestFetchGiteaToken(t *testing.T) {
|
|
srv := fakeVault(t, "role-xyz", "gitea/creds/unkin-agent", "gitea-abc")
|
|
defer srv.Close()
|
|
|
|
tok, err := fetchGiteaToken(srv.URL, "role-xyz", "gitea/creds/unkin-agent")
|
|
if err != nil {
|
|
t.Fatalf("fetchGiteaToken: %v", err)
|
|
}
|
|
if tok != "gitea-abc" {
|
|
t.Errorf("token = %q, want gitea-abc", tok)
|
|
}
|
|
}
|
|
|
|
func TestFetchGiteaTokenLoginError(t *testing.T) {
|
|
mux := http.NewServeMux()
|
|
mux.HandleFunc("/v1/auth/approle/login", func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusForbidden)
|
|
_, _ = io.WriteString(w, `{"errors":["permission denied"]}`)
|
|
})
|
|
srv := httptest.NewServer(mux)
|
|
defer srv.Close()
|
|
|
|
if _, err := fetchGiteaToken(srv.URL, "role-xyz", "gitea/creds/unkin-agent"); err == nil {
|
|
t.Fatal("expected error on 403 login")
|
|
}
|
|
}
|
|
|
|
func TestCreatePRRequestBody(t *testing.T) {
|
|
var gotPath, gotAuth string
|
|
var gotBody CreatePROptions
|
|
mux := http.NewServeMux()
|
|
mux.HandleFunc("/api/v1/repos/unkin/repo/pulls", func(w http.ResponseWriter, r *http.Request) {
|
|
gotPath = r.URL.Path
|
|
gotAuth = r.Header.Get("Authorization")
|
|
_ = json.NewDecoder(r.Body).Decode(&gotBody)
|
|
_, _ = io.WriteString(w, `{"number":7,"state":"open","html_url":"https://git.unkin.net/unkin/repo/pulls/7","head":{"sha":"deadbeef"}}`)
|
|
})
|
|
srv := httptest.NewServer(mux)
|
|
defer srv.Close()
|
|
|
|
c := &GiteaClient{BaseURL: srv.URL, Token: "gitea-abc", HTTP: srv.Client()}
|
|
pr, err := c.CreatePR("unkin/repo", CreatePROptions{Base: "main", Head: "feature", Title: "T", Body: "B"})
|
|
if err != nil {
|
|
t.Fatalf("CreatePR: %v", err)
|
|
}
|
|
if gotPath != "/api/v1/repos/unkin/repo/pulls" {
|
|
t.Errorf("path = %q", gotPath)
|
|
}
|
|
if gotAuth != "token gitea-abc" {
|
|
t.Errorf("auth header = %q, want 'token gitea-abc'", gotAuth)
|
|
}
|
|
if gotBody.Base != "main" || gotBody.Head != "feature" || gotBody.Title != "T" || gotBody.Body != "B" {
|
|
t.Errorf("request body = %+v", gotBody)
|
|
}
|
|
if pr.Number != 7 || pr.HTMLURL == "" {
|
|
t.Errorf("parsed PR = %+v", pr)
|
|
}
|
|
}
|
|
|
|
// An edit must send only the fields it was given: Gitea overwrites whatever
|
|
// key it receives, so an omitted --title arriving as "" would blank the title.
|
|
func TestEditPRSendsOnlySuppliedFields(t *testing.T) {
|
|
title, body, empty := "new title", "new body", ""
|
|
tests := []struct {
|
|
name string
|
|
opts EditPROptions
|
|
want map[string]any
|
|
}{
|
|
{"body only", EditPROptions{Body: &body}, map[string]any{"body": "new body"}},
|
|
{"title only", EditPROptions{Title: &title}, map[string]any{"title": "new title"}},
|
|
{"both", EditPROptions{Title: &title, Body: &body}, map[string]any{"title": "new title", "body": "new body"}},
|
|
{"explicit empty body is sent", EditPROptions{Body: &empty}, map[string]any{"body": ""}},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
var gotBody map[string]any
|
|
var gotMethod, gotPath string
|
|
mux := http.NewServeMux()
|
|
mux.HandleFunc("/api/v1/repos/unkin/repo/pulls/7", func(w http.ResponseWriter, r *http.Request) {
|
|
gotMethod, gotPath = r.Method, r.URL.Path
|
|
_ = json.NewDecoder(r.Body).Decode(&gotBody)
|
|
_, _ = io.WriteString(w, `{"number":7,"title":"new title","html_url":"https://git.unkin.net/unkin/repo/pulls/7"}`)
|
|
})
|
|
srv := httptest.NewServer(mux)
|
|
defer srv.Close()
|
|
|
|
c := &GiteaClient{BaseURL: srv.URL, Token: "gitea-abc", HTTP: srv.Client()}
|
|
pr, err := c.EditPR("unkin/repo", 7, tt.opts)
|
|
if err != nil {
|
|
t.Fatalf("EditPR: %v", err)
|
|
}
|
|
if gotMethod != http.MethodPatch {
|
|
t.Errorf("method = %s, want PATCH", gotMethod)
|
|
}
|
|
if gotPath != "/api/v1/repos/unkin/repo/pulls/7" {
|
|
t.Errorf("path = %q", gotPath)
|
|
}
|
|
if len(gotBody) != len(tt.want) {
|
|
t.Errorf("payload = %v, want exactly the supplied fields %v", gotBody, tt.want)
|
|
}
|
|
for k, v := range tt.want {
|
|
if gotBody[k] != v {
|
|
t.Errorf("payload[%q] = %v, want %v", k, gotBody[k], v)
|
|
}
|
|
}
|
|
if pr.Number != 7 || pr.HTMLURL == "" {
|
|
t.Errorf("parsed PR = %+v", pr)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// A non-2xx must surface the API's own message rather than a bare status.
|
|
func TestEditPRAPIError(t *testing.T) {
|
|
mux := http.NewServeMux()
|
|
mux.HandleFunc("/api/v1/repos/unkin/repo/pulls/7", func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusNotFound)
|
|
_, _ = io.WriteString(w, `{"message":"not found","url":"https://git.unkin.net/api/swagger","errors":null}`)
|
|
})
|
|
srv := httptest.NewServer(mux)
|
|
defer srv.Close()
|
|
|
|
title := "new title"
|
|
c := &GiteaClient{BaseURL: srv.URL, Token: "t", HTTP: srv.Client()}
|
|
_, err := c.EditPR("unkin/repo", 7, EditPROptions{Title: &title})
|
|
if err == nil {
|
|
t.Fatal("expected error on 404")
|
|
}
|
|
if !strings.Contains(err.Error(), `"message":"not found"`) {
|
|
t.Errorf("error %q should carry the API message", err)
|
|
}
|
|
}
|
|
|
|
func TestCreateComment(t *testing.T) {
|
|
var gotBody map[string]string
|
|
mux := http.NewServeMux()
|
|
mux.HandleFunc("/api/v1/repos/unkin/repo/issues/7/comments", func(w http.ResponseWriter, r *http.Request) {
|
|
_ = json.NewDecoder(r.Body).Decode(&gotBody)
|
|
_, _ = io.WriteString(w, `{"id":99,"user":{"login":"unkin-agent"},"body":"hi"}`)
|
|
})
|
|
srv := httptest.NewServer(mux)
|
|
defer srv.Close()
|
|
|
|
c := &GiteaClient{BaseURL: srv.URL, Token: "t", HTTP: srv.Client()}
|
|
cm, err := c.CreateComment("unkin/repo", 7, "hi")
|
|
if err != nil {
|
|
t.Fatalf("CreateComment: %v", err)
|
|
}
|
|
if gotBody["body"] != "hi" {
|
|
t.Errorf("comment body = %q", gotBody["body"])
|
|
}
|
|
if cm.ID != 99 {
|
|
t.Errorf("comment id = %d, want 99", cm.ID)
|
|
}
|
|
}
|
|
|
|
func TestWhoami(t *testing.T) {
|
|
mux := http.NewServeMux()
|
|
mux.HandleFunc("/api/v1/user", func(w http.ResponseWriter, r *http.Request) {
|
|
_, _ = io.WriteString(w, `{"login":"unkin-agent","id":42}`)
|
|
})
|
|
srv := httptest.NewServer(mux)
|
|
defer srv.Close()
|
|
|
|
c := &GiteaClient{BaseURL: srv.URL, Token: "t", HTTP: srv.Client()}
|
|
u, err := c.Whoami()
|
|
if err != nil {
|
|
t.Fatalf("Whoami: %v", err)
|
|
}
|
|
if u.Login != "unkin-agent" {
|
|
t.Errorf("login = %q, want unkin-agent", u.Login)
|
|
}
|
|
}
|
|
|
|
func TestFetchState(t *testing.T) {
|
|
mux := http.NewServeMux()
|
|
mux.HandleFunc("/api/v1/repos/unkin/repo/pulls/7", func(w http.ResponseWriter, r *http.Request) {
|
|
_, _ = io.WriteString(w, `{"number":7,"state":"open","merged":false,"mergeable":true,"title":"feat","html_url":"u","head":{"sha":"cafebabecafebabe"}}`)
|
|
})
|
|
mux.HandleFunc("/api/v1/repos/unkin/repo/commits/cafebabecafebabe/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, `[{"id":1,"user":{"login":"unkin-agent"}},{"id":2,"user":{"login":"ben"}}]`)
|
|
})
|
|
srv := httptest.NewServer(mux)
|
|
defer srv.Close()
|
|
|
|
c := &GiteaClient{BaseURL: srv.URL, Token: "t", HTTP: srv.Client()}
|
|
ref := PRRef{Owner: "unkin", Repo: "repo", Number: 7}
|
|
st, err := FetchState(c, ref, "unkin-agent")
|
|
if err != nil {
|
|
t.Fatalf("FetchState: %v", err)
|
|
}
|
|
if st.State != "open" || st.CIStatus != "success" || st.HeadSHA != "cafebabecafebabe" {
|
|
t.Errorf("state = %+v", st)
|
|
}
|
|
if st.NonAgentComments != 1 {
|
|
t.Errorf("NonAgentComments = %d, want 1 (agent comment excluded)", st.NonAgentComments)
|
|
}
|
|
}
|
|
|
|
// A merged PR whose branch was deleted leaves its head commit unreachable, so
|
|
// the commit-status endpoint 404s. FetchState must still return the (merged)
|
|
// state rather than failing, otherwise the watch loop never sees the merge.
|
|
func TestFetchStateToleratesMissingCommit(t *testing.T) {
|
|
mux := http.NewServeMux()
|
|
mux.HandleFunc("/api/v1/repos/unkin/repo/pulls/7", func(w http.ResponseWriter, r *http.Request) {
|
|
_, _ = io.WriteString(w, `{"number":7,"state":"closed","merged":true,"mergeable":true,"title":"feat","html_url":"u","head":{"sha":"cafebabecafebabe"}}`)
|
|
})
|
|
mux.HandleFunc("/api/v1/repos/unkin/repo/commits/cafebabecafebabe/status", func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusNotFound)
|
|
_, _ = io.WriteString(w, `{"message":"not found"}`)
|
|
})
|
|
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()
|
|
|
|
c := &GiteaClient{BaseURL: srv.URL, Token: "t", HTTP: srv.Client()}
|
|
ref := PRRef{Owner: "unkin", Repo: "repo", Number: 7}
|
|
st, err := FetchState(c, ref, "unkin-agent")
|
|
if err != nil {
|
|
t.Fatalf("FetchState must tolerate a 404 status for a gone commit: %v", err)
|
|
}
|
|
if !st.Merged || st.State != "closed" {
|
|
t.Errorf("merged/state = %t/%q, want true/closed", st.Merged, st.State)
|
|
}
|
|
if st.CIStatus != "" {
|
|
t.Errorf("CIStatus = %q, want empty (no status for a gone commit)", st.CIStatus)
|
|
}
|
|
}
|
|
|
|
// A non-404 error from the status endpoint is still fatal: only "commit gone" is
|
|
// tolerated, not, say, an auth or server failure.
|
|
func TestFetchStateFailsOnNon404StatusError(t *testing.T) {
|
|
mux := http.NewServeMux()
|
|
mux.HandleFunc("/api/v1/repos/unkin/repo/pulls/7", func(w http.ResponseWriter, r *http.Request) {
|
|
_, _ = io.WriteString(w, `{"number":7,"state":"open","merged":false,"mergeable":true,"head":{"sha":"cafebabecafebabe"}}`)
|
|
})
|
|
mux.HandleFunc("/api/v1/repos/unkin/repo/commits/cafebabecafebabe/status", func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusInternalServerError)
|
|
_, _ = io.WriteString(w, `{"message":"boom"}`)
|
|
})
|
|
srv := httptest.NewServer(mux)
|
|
defer srv.Close()
|
|
|
|
c := &GiteaClient{BaseURL: srv.URL, Token: "t", HTTP: srv.Client()}
|
|
ref := PRRef{Owner: "unkin", Repo: "repo", Number: 7}
|
|
if _, err := FetchState(c, ref, "unkin-agent"); err == nil {
|
|
t.Fatal("FetchState should surface a 500 from the status endpoint")
|
|
}
|
|
}
|
|
|
|
// Gitea rewrites head.ref to "refs/pull/<n>/head" once the PR's branch is
|
|
// deleted, which merging does in these repos. Matching a branch against
|
|
// head.ref alone therefore finds nothing for every merged PR; head.label keeps
|
|
// the original name.
|
|
func TestPRHeadBranch(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
ref, label string
|
|
want string
|
|
}{
|
|
{"merged, branch deleted", "refs/pull/12/head", "benvin/merged", "benvin/merged"},
|
|
{"open PR", "benvin/open", "benvin/open", "benvin/open"},
|
|
{"fully qualified ref", "refs/heads/benvin/x", "", "benvin/x"},
|
|
{"no label falls back to ref", "benvin/y", "", "benvin/y"},
|
|
{"cross-repo label", "benvin/z", "someone:benvin/z", "benvin/z"},
|
|
{"nothing usable", "refs/pull/12/head", "", ""},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
var pr PullRequest
|
|
pr.Head.Ref = tt.ref
|
|
pr.Head.Label = tt.label
|
|
if got := PRHeadBranch(pr); got != tt.want {
|
|
t.Errorf("PRHeadBranch(ref=%q,label=%q) = %q, want %q", tt.ref, tt.label, got, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestListPRsPaginates(t *testing.T) {
|
|
var pages []string
|
|
mux := http.NewServeMux()
|
|
mux.HandleFunc("/api/v1/repos/unkin/repo/pulls", func(w http.ResponseWriter, r *http.Request) {
|
|
q := r.URL.Query()
|
|
pages = append(pages, q.Get("page"))
|
|
if q.Get("state") != "all" {
|
|
t.Errorf("state = %q, want all", q.Get("state"))
|
|
}
|
|
if q.Get("page") == "1" {
|
|
full := make([]string, 0, prPageSize)
|
|
for i := 0; i < prPageSize; i++ {
|
|
full = append(full, fmt.Sprintf(`{"number":%d,"state":"closed","merged":true,"head":{"ref":"refs/pull/%d/head","label":"benvin/b%d"}}`, i+1, i+1, i+1))
|
|
}
|
|
_, _ = io.WriteString(w, "["+strings.Join(full, ",")+"]")
|
|
return
|
|
}
|
|
_, _ = io.WriteString(w, `[{"number":99,"state":"open","head":{"ref":"benvin/last","label":"benvin/last"}}]`)
|
|
})
|
|
srv := httptest.NewServer(mux)
|
|
defer srv.Close()
|
|
|
|
c := &GiteaClient{BaseURL: srv.URL, HTTP: srv.Client()}
|
|
prs, err := c.ListPRs("unkin/repo", "all")
|
|
if err != nil {
|
|
t.Fatalf("ListPRs: %v", err)
|
|
}
|
|
if len(prs) != prPageSize+1 {
|
|
t.Fatalf("got %d PRs, want %d", len(prs), prPageSize+1)
|
|
}
|
|
if len(pages) != 2 || pages[0] != "1" || pages[1] != "2" {
|
|
t.Errorf("pages requested = %v, want [1 2]", pages)
|
|
}
|
|
if got := PRHeadBranch(prs[0]); got != "benvin/b1" {
|
|
t.Errorf("first PR head branch = %q, want benvin/b1", got)
|
|
}
|
|
if !prs[len(prs)-1].IsOpen() {
|
|
t.Error("last PR should be open")
|
|
}
|
|
}
|
|
|
|
// A listing that fills every page is truncated: the caller must be told rather
|
|
// than treating a partial view as the whole repo.
|
|
func TestListPRsReportsTruncation(t *testing.T) {
|
|
mux := http.NewServeMux()
|
|
mux.HandleFunc("/api/v1/repos/unkin/repo/pulls", func(w http.ResponseWriter, r *http.Request) {
|
|
full := make([]string, 0, prPageSize)
|
|
for i := 0; i < prPageSize; i++ {
|
|
full = append(full, fmt.Sprintf(`{"number":%s,"state":"open"}`, r.URL.Query().Get("page")))
|
|
}
|
|
_, _ = io.WriteString(w, "["+strings.Join(full, ",")+"]")
|
|
})
|
|
srv := httptest.NewServer(mux)
|
|
defer srv.Close()
|
|
|
|
c := &GiteaClient{BaseURL: srv.URL, HTTP: srv.Client()}
|
|
prs, err := c.ListPRs("unkin/repo", "all")
|
|
if !errors.Is(err, ErrPRListTruncated) {
|
|
t.Fatalf("ListPRs err = %v, want ErrPRListTruncated", err)
|
|
}
|
|
if len(prs) != maxPRPages*prPageSize {
|
|
t.Errorf("got %d PRs, want %d", len(prs), maxPRPages*prPageSize)
|
|
}
|
|
}
|
|
|
|
func TestGiteaAPIError(t *testing.T) {
|
|
mux := http.NewServeMux()
|
|
mux.HandleFunc("/api/v1/repos/unkin/repo/pulls", func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusUnprocessableEntity)
|
|
_, _ = io.WriteString(w, `{"message":"head and base are the same"}`)
|
|
})
|
|
srv := httptest.NewServer(mux)
|
|
defer srv.Close()
|
|
|
|
c := &GiteaClient{BaseURL: srv.URL, Token: "t", HTTP: srv.Client()}
|
|
if _, err := c.CreatePR("unkin/repo", CreatePROptions{Base: "main", Head: "main", Title: "x"}); err == nil {
|
|
t.Fatal("expected error on 422")
|
|
}
|
|
}
|
|
|
|
// expiringGitea serves the PR endpoint, rejecting every token other than
|
|
// wantToken with a 401 exactly as Gitea does once a Vault-minted token expires.
|
|
// It records the tokens it saw, newest last.
|
|
func expiringGitea(t *testing.T, wantToken string, seen *[]string) *httptest.Server {
|
|
t.Helper()
|
|
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 != wantToken {
|
|
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"}}`)
|
|
})
|
|
return httptest.NewServer(mux)
|
|
}
|
|
|
|
// The production failure: the token expired mid-run. The client must re-mint
|
|
// once and replay the request with the fresh token.
|
|
func TestExpiredTokenIsRemintedAndRetried(t *testing.T) {
|
|
var seen []string
|
|
srv := expiringGitea(t, "fresh", &seen)
|
|
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 {
|
|
t.Errorf("PR number = %d, want 7", pr.Number)
|
|
}
|
|
if refreshes != 1 {
|
|
t.Errorf("refreshes = %d, want 1", refreshes)
|
|
}
|
|
if len(seen) != 2 || seen[0] != "stale" || seen[1] != "fresh" {
|
|
t.Errorf("tokens seen = %v, want [stale fresh]", seen)
|
|
}
|
|
if c.Token != "fresh" {
|
|
t.Errorf("client token = %q, want the refreshed token", c.Token)
|
|
}
|
|
}
|
|
|
|
// A fresh token that is also rejected is a real auth failure: report it as one
|
|
// rather than re-minting forever.
|
|
func TestAuthFailureSurvivesRemint(t *testing.T) {
|
|
var seen []string
|
|
srv := expiringGitea(t, "never-issued", &seen)
|
|
defer srv.Close()
|
|
|
|
refreshes := 0
|
|
c := &GiteaClient{BaseURL: srv.URL, Token: "stale", HTTP: srv.Client(),
|
|
Refresh: func() (string, error) { refreshes++; return "still-bad", nil }}
|
|
|
|
_, err := c.GetPR("unkin/repo", 7)
|
|
if err == nil {
|
|
t.Fatal("GetPR should fail when the fresh token is rejected too")
|
|
}
|
|
if !IsAuthError(err) {
|
|
t.Errorf("IsAuthError(%v) = false, want true", err)
|
|
}
|
|
if refreshes != 1 {
|
|
t.Errorf("refreshes = %d, want 1 (re-mint exactly once)", refreshes)
|
|
}
|
|
if len(seen) != 2 {
|
|
t.Errorf("requests = %d, want 2", len(seen))
|
|
}
|
|
}
|
|
|
|
// A refresh that itself fails must surface as an auth error, not as a silent
|
|
// success or a bare Vault error.
|
|
func TestRemintErrorIsReportedAsAuthFailure(t *testing.T) {
|
|
var seen []string
|
|
srv := expiringGitea(t, "fresh", &seen)
|
|
defer srv.Close()
|
|
|
|
c := &GiteaClient{BaseURL: srv.URL, Token: "stale", HTTP: srv.Client(),
|
|
Refresh: func() (string, error) { return "", errors.New("vault approle login: HTTP 503") }}
|
|
|
|
_, err := c.GetPR("unkin/repo", 7)
|
|
if err == nil || !IsAuthError(err) {
|
|
t.Fatalf("GetPR error = %v, want an auth error", err)
|
|
}
|
|
if !strings.Contains(err.Error(), "vault approle login") {
|
|
t.Errorf("error %q should name the re-mint failure", err)
|
|
}
|
|
if len(seen) != 1 {
|
|
t.Errorf("requests = %d, want 1 (no replay without a token)", len(seen))
|
|
}
|
|
}
|
|
|
|
// A 5xx is transient, not an auth problem: no re-mint, no retry, and the caller
|
|
// keeps its existing retry behaviour.
|
|
func TestServerErrorDoesNotRemint(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.StatusBadGateway)
|
|
})
|
|
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 error on 502")
|
|
}
|
|
if IsAuthError(err) {
|
|
t.Errorf("502 must not be an auth error")
|
|
}
|
|
if refreshes != 0 || requests != 1 {
|
|
t.Errorf("refreshes = %d, requests = %d, want 0 and 1", refreshes, requests)
|
|
}
|
|
}
|
|
|
|
// The replayed request must carry the original body, not an empty one.
|
|
func TestRemintReplaysRequestBody(t *testing.T) {
|
|
var bodies []CreatePROptions
|
|
mux := http.NewServeMux()
|
|
mux.HandleFunc("/api/v1/repos/unkin/repo/pulls", func(w http.ResponseWriter, r *http.Request) {
|
|
var body CreatePROptions
|
|
_ = json.NewDecoder(r.Body).Decode(&body)
|
|
bodies = append(bodies, body)
|
|
if strings.TrimPrefix(r.Header.Get("Authorization"), "token ") != "fresh" {
|
|
w.WriteHeader(http.StatusUnauthorized)
|
|
return
|
|
}
|
|
_, _ = io.WriteString(w, `{"number":7}`)
|
|
})
|
|
srv := httptest.NewServer(mux)
|
|
defer srv.Close()
|
|
|
|
c := &GiteaClient{BaseURL: srv.URL, Token: "stale", HTTP: srv.Client(),
|
|
Refresh: func() (string, error) { return "fresh", nil }}
|
|
|
|
if _, err := c.CreatePR("unkin/repo", CreatePROptions{Base: "main", Head: "feature", Title: "T", Body: "B"}); err != nil {
|
|
t.Fatalf("CreatePR: %v", err)
|
|
}
|
|
if len(bodies) != 2 {
|
|
t.Fatalf("requests = %d, want 2", len(bodies))
|
|
}
|
|
if bodies[1] != bodies[0] {
|
|
t.Errorf("replayed body = %+v, want %+v", bodies[1], bodies[0])
|
|
}
|
|
}
|
|
|
|
func TestIsAuthError(t *testing.T) {
|
|
tests := []struct {
|
|
status int
|
|
want bool
|
|
}{
|
|
{http.StatusUnauthorized, true},
|
|
{http.StatusForbidden, true},
|
|
{http.StatusNotFound, false},
|
|
{http.StatusUnprocessableEntity, false},
|
|
{http.StatusBadGateway, false},
|
|
}
|
|
for _, tt := range tests {
|
|
err := error(&APIError{Method: "GET", Path: "/p", StatusCode: tt.status})
|
|
if got := IsAuthError(err); got != tt.want {
|
|
t.Errorf("IsAuthError(HTTP %d) = %v, want %v", tt.status, got, tt.want)
|
|
}
|
|
}
|
|
if IsAuthError(errors.New("dial tcp: timeout")) {
|
|
t.Errorf("a network error is not an auth error")
|
|
}
|
|
}
|
|
|
|
// Anonymous polling of a public repo is a supported mode: with no token the
|
|
// client must send no Authorization header, and must never reach for Vault.
|
|
func TestAnonymousPollingNeverMints(t *testing.T) {
|
|
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, `[]`)
|
|
})
|
|
srv := httptest.NewServer(mux)
|
|
defer srv.Close()
|
|
|
|
refreshes := 0
|
|
c := &GiteaClient{BaseURL: srv.URL, HTTP: srv.Client(),
|
|
Refresh: func() (string, error) { refreshes++; return "", errors.New("vault unreachable") }}
|
|
|
|
st, err := FetchState(c, PRRef{Owner: "unkin", Repo: "repo", Number: 7}, "unkin-agent")
|
|
if err != nil {
|
|
t.Fatalf("anonymous FetchState: %v", err)
|
|
}
|
|
if st.State != "open" || st.CIStatus != "success" || st.HeadSHA != "cafebabe" {
|
|
t.Errorf("state = %+v", st)
|
|
}
|
|
if refreshes != 0 {
|
|
t.Errorf("refreshes = %d, want 0 (a 200 must never trigger a mint)", refreshes)
|
|
}
|
|
if authHeaders != 0 {
|
|
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)
|
|
}
|
|
}
|