package agent import ( "encoding/json" "errors" "fmt" "io" "net/http" "net/http/httptest" "strings" "testing" ) // 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 EditOptions want map[string]any }{ {"body only", EditOptions{Body: &body}, map[string]any{"body": "new body"}}, {"title only", EditOptions{Title: &title}, map[string]any{"title": "new title"}}, {"both", EditOptions{Title: &title, Body: &body}, map[string]any{"title": "new title", "body": "new body"}}, {"explicit empty body is sent", EditOptions{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, EditOptions{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//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) } } func TestCreateIssueRequestBody(t *testing.T) { var gotPath, gotMethod, gotAuth string var gotBody CreateIssueOptions mux := http.NewServeMux() mux.HandleFunc("/api/v1/repos/unkin/repo/issues", func(w http.ResponseWriter, r *http.Request) { gotPath, gotMethod = r.URL.Path, r.Method gotAuth = r.Header.Get("Authorization") _ = json.NewDecoder(r.Body).Decode(&gotBody) _, _ = io.WriteString(w, `{"number":12,"state":"open","title":"T","html_url":"https://git.unkin.net/unkin/repo/issues/12"}`) }) srv := httptest.NewServer(mux) defer srv.Close() c := &GiteaClient{BaseURL: srv.URL, Token: "gitea-abc", HTTP: srv.Client()} issue, err := c.CreateIssue("unkin/repo", CreateIssueOptions{Title: "T", Body: "B"}) if err != nil { t.Fatalf("CreateIssue: %v", err) } if gotMethod != http.MethodPost || gotPath != "/api/v1/repos/unkin/repo/issues" { t.Errorf("request = %s %s, want POST /api/v1/repos/unkin/repo/issues", gotMethod, gotPath) } if gotAuth != "token gitea-abc" { t.Errorf("auth header = %q, want 'token gitea-abc'", gotAuth) } if gotBody.Title != "T" || gotBody.Body != "B" { t.Errorf("request body = %+v", gotBody) } if issue.Number != 12 || issue.HTMLURL != "https://git.unkin.net/unkin/repo/issues/12" { t.Errorf("parsed issue = %+v", issue) } } // Filing against a repo that does not exist (or that the token may not see) // gets Gitea's 404, which must surface as a not-found error carrying the API's // own message rather than a bare status. func TestCreateIssueRepoNotFound(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNotFound) _, _ = io.WriteString(w, `{"errors":null,"message":"user redirect does not exist [name: ghost]","url":"https://git.unkin.net/api/swagger"}`) })) defer srv.Close() c := &GiteaClient{BaseURL: srv.URL, Token: "t", HTTP: srv.Client()} _, err := c.CreateIssue("ghost/repo", CreateIssueOptions{Title: "T"}) if err == nil { t.Fatal("expected error for a repo that does not exist") } if !IsNotFound(err) { t.Errorf("IsNotFound(%v) = false, want true", err) } if !strings.Contains(err.Error(), "user redirect does not exist") { t.Errorf("error %q should carry the API message", err) } } // Any other non-2xx is a plain API failure: reported, not retried, and not // mistaken for a missing repo. func TestCreateIssueAPIError(t *testing.T) { requests := 0 mux := http.NewServeMux() mux.HandleFunc("/api/v1/repos/unkin/repo/issues", func(w http.ResponseWriter, r *http.Request) { requests++ w.WriteHeader(http.StatusUnprocessableEntity) _, _ = io.WriteString(w, `{"errors":null,"message":"Validation Error: title is empty","url":"https://git.unkin.net/api/swagger"}`) }) srv := httptest.NewServer(mux) defer srv.Close() c := &GiteaClient{BaseURL: srv.URL, Token: "t", HTTP: srv.Client()} _, err := c.CreateIssue("unkin/repo", CreateIssueOptions{Title: "T"}) if err == nil { t.Fatal("expected error on 422") } if IsNotFound(err) { t.Errorf("a 422 must not read as not-found: %v", err) } if !strings.Contains(err.Error(), "Validation Error") { t.Errorf("error %q should carry the API message", err) } if requests != 1 { t.Errorf("requests = %d, want 1 (a 422 is not retried)", requests) } } // An issue edit sends only the fields it was given, for the same reason a PR // edit does: Gitea overwrites whatever key it receives. func TestEditIssueSendsOnlySuppliedFields(t *testing.T) { title, body := "new title", "new body" tests := []struct { name string opts EditOptions want map[string]any }{ {"body only", EditOptions{Body: &body}, map[string]any{"body": "new body"}}, {"title only", EditOptions{Title: &title}, map[string]any{"title": "new title"}}, {"both", EditOptions{Title: &title, Body: &body}, map[string]any{"title": "new title", "body": "new 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/issues/12", func(w http.ResponseWriter, r *http.Request) { gotMethod, gotPath = r.Method, r.URL.Path _ = json.NewDecoder(r.Body).Decode(&gotBody) _, _ = io.WriteString(w, `{"number":12,"title":"new title","html_url":"https://git.unkin.net/unkin/repo/issues/12"}`) }) srv := httptest.NewServer(mux) defer srv.Close() c := &GiteaClient{BaseURL: srv.URL, Token: "t", HTTP: srv.Client()} issue, err := c.EditIssue("unkin/repo", 12, tt.opts) if err != nil { t.Fatalf("EditIssue: %v", err) } if gotMethod != http.MethodPatch { t.Errorf("method = %s, want PATCH", gotMethod) } if gotPath != "/api/v1/repos/unkin/repo/issues/12" { 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 issue.Number != 12 || issue.HTMLURL == "" { t.Errorf("parsed issue = %+v", issue) } }) } }