f18a633185
The plugin required `tea`, Gitea's own CLI, for everything that is not an issue: releases, pull requests, milestones, branches, actions, webhooks. That put a second binary, a second set of logins nothing here could see, and 400 lines documenting somebody else's flags outside anything this repository can test. One command over the transport that already existed removes all three. Transport: `post` — the hand-rolled request the SDK cannot express, written for the dependency endpoint — is generalized to an exported `Do`, and `post` is three lines on top of it. Same http.Client, so the same RoundTripper files the body under .kettle/payload/, the same `token …` header authenticates it, and a non-2xx is the same *APIError. It does not paginate, does not reformat the answer, and names no domain concept, so the layering test is untouched. The endpoint rule is `tea api`'s, so an endpoint table written for that tool still works — with one restriction it did not have: a full URL must be on this instance. Every request carries the project's token in a header, and a URL on another host would hand the token to whatever was typed. Command: `kettle api <endpoint>` in a new `api` group, so the generator writes plugins/kettle/skills/api/SKILL.md — group, directory and /kettle:api are one word. No --repo and no --login, for the reason no sync command has them: a cross-repository address is an address, and another instance is KETTLE_URL. `-X DELETE` needs `--yes`; a flag typed on purpose is an operator's decision. Scopes: a token minted for issues carries write:issue and answers 403 on the first request outside issues, naming no scope. Gitea cannot be asked what a token may do — its own token listing needs a password — so `auth add --scopes` records it, `auth list` and `config` show it, and a 403 says which category it is likely to be. Documentation only; nothing is checked against it. skills/use — the tea reference, 239 lines of it — becomes skills/api: what to ask for, which endpoints paginate, and how to write a body. Every mention of `tea` as a requirement is gone from the manifests, the READMEs, the runner and the four other skills; what survives is the back-compat with the old plugin, which is a decision and not a debt. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
717 lines
26 KiB
Go
717 lines
26 KiB
Go
package gitea_test
|
|
|
|
// The transport is tested against httptest, never against a tracker: a test
|
|
// that needs a server somewhere is a test nobody runs.
|
|
//
|
|
// Every fixture builds a throwaway project in a temp directory and points the
|
|
// project walk at it with CLAUDE_PROJECT_DIR. Without that the walk falls
|
|
// through to the working directory — which during a test run is this repository
|
|
// — and a request dump would land in the developer's own project. Nothing here
|
|
// reads a login file either, but KETTLE_CONFIG_HOME is redirected all the same,
|
|
// so a run can neither read nor overwrite the developer's own tokens.
|
|
//
|
|
// EVERY FAKE ANSWERS /api/v1/version, because building a client is now a
|
|
// request: the SDK asks the instance what it is before it hands one back, and
|
|
// that answer is what the dependency gate is decided on later. A fake that did
|
|
// not answer it would be a fake no client can be built against — see
|
|
// versionRoute.
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"reflect"
|
|
"strconv"
|
|
"strings"
|
|
"testing"
|
|
|
|
sdk "code.gitea.io/sdk/gitea"
|
|
|
|
"git.noodles.cam/claude-skills/marketplace/cli/internal/config"
|
|
"git.noodles.cam/claude-skills/marketplace/cli/internal/gitea"
|
|
"git.noodles.cam/claude-skills/marketplace/cli/internal/wire"
|
|
)
|
|
|
|
// modernGitea is what a fake says it is: new enough for everything this
|
|
// transport asks for, dependency endpoints included.
|
|
const modernGitea = "1.26.1"
|
|
|
|
// newProject makes an initialized project and points the walk at it. Returns
|
|
// the project root.
|
|
func newProject(t *testing.T) string {
|
|
t.Helper()
|
|
dir := t.TempDir()
|
|
if err := os.MkdirAll(filepath.Join(dir, ".kettle"), 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Setenv("CLAUDE_PROJECT_DIR", dir)
|
|
t.Setenv(config.EnvHome, filepath.Join(dir, "config"))
|
|
return dir
|
|
}
|
|
|
|
// versionRoute answers the SDK's version handshake and reports whether it did,
|
|
// so every other handler can be written as though the request were not there.
|
|
func versionRoute(w http.ResponseWriter, r *http.Request, version string) bool {
|
|
if r.URL.Path != "/api/v1/version" {
|
|
return false
|
|
}
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = io.WriteString(w, `{"version":"`+version+`"}`)
|
|
return true
|
|
}
|
|
|
|
// serve is an httptest server that speaks the handshake and hands everything
|
|
// else to next.
|
|
func serve(t *testing.T, version string, next http.HandlerFunc) *httptest.Server {
|
|
t.Helper()
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if versionRoute(w, r, version) {
|
|
return
|
|
}
|
|
next(w, r)
|
|
}))
|
|
t.Cleanup(srv.Close)
|
|
return srv
|
|
}
|
|
|
|
func newClient(t *testing.T, url string) *gitea.Client {
|
|
t.Helper()
|
|
c, err := gitea.New(&config.Resolved{URL: url, Token: "s3cret", Owner: "acme", Repo: "widgets"})
|
|
if err != nil {
|
|
t.Fatalf("New: %v", err)
|
|
}
|
|
return c
|
|
}
|
|
|
|
func writeJSON(t *testing.T, w http.ResponseWriter, v any) {
|
|
t.Helper()
|
|
w.Header().Set("Content-Type", "application/json")
|
|
if err := json.NewEncoder(w).Encode(v); err != nil {
|
|
t.Errorf("encoding the fake response: %v", err)
|
|
}
|
|
}
|
|
|
|
// A list endpoint is followed to the last page and no further: the short page
|
|
// ends it, and the page after that is never asked for.
|
|
func TestPaginationFollowsToTheLastPage(t *testing.T) {
|
|
newProject(t)
|
|
|
|
var asked []string
|
|
var auth string
|
|
srv := serve(t, modernGitea, func(w http.ResponseWriter, r *http.Request) {
|
|
asked = append(asked, r.URL.RequestURI())
|
|
auth = r.Header.Get("Authorization")
|
|
|
|
page, _ := strconv.Atoi(r.URL.Query().Get("page"))
|
|
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
|
|
n := limit
|
|
if page == 3 {
|
|
n = 7 // the short page
|
|
} else if page > 3 {
|
|
n = 0
|
|
}
|
|
out := []map[string]any{}
|
|
for i := 0; i < n; i++ {
|
|
out = append(out, map[string]any{"id": (page-1)*limit + i + 1, "body": "hello"})
|
|
}
|
|
writeJSON(t, w, out)
|
|
})
|
|
|
|
got, err := newClient(t, srv.URL).ListComments(42)
|
|
if err != nil {
|
|
t.Fatalf("ListComments: %v", err)
|
|
}
|
|
if len(got) != 107 {
|
|
t.Errorf("got %d comments, want 107 (50 + 50 + 7)", len(got))
|
|
}
|
|
if len(asked) != 3 {
|
|
t.Errorf("made %d requests (%v), want 3 — a short page is the last one", len(asked), asked)
|
|
}
|
|
if got[0].ID != 1 || got[106].ID != 107 {
|
|
t.Errorf("pages arrived out of order: first %d, last %d", got[0].ID, got[106].ID)
|
|
}
|
|
// Gitea's own scheme, and what the CLI this replaces sent.
|
|
if auth != "token s3cret" {
|
|
t.Errorf("Authorization was %q, want %q", auth, "token s3cret")
|
|
}
|
|
want := "/api/v1/repos/acme/widgets/issues/42/comments?limit=50&page=1"
|
|
if asked[0] != want {
|
|
t.Errorf("first request was %s, want %s", asked[0], want)
|
|
}
|
|
}
|
|
|
|
// A non-2xx carries the status AND the body, because the status alone has never
|
|
// told anybody which of the four things that answer 422 actually happened.
|
|
func TestErrorNamesTheStatusAndTheBody(t *testing.T) {
|
|
newProject(t)
|
|
|
|
srv := serve(t, modernGitea, func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusUnprocessableEntity)
|
|
io.WriteString(w, `{"message":"label already exists","url":"https://example.test/docs"}`)
|
|
})
|
|
|
|
_, err := newClient(t, srv.URL).GetIssue(7)
|
|
if err == nil {
|
|
t.Fatal("a 422 returned no error")
|
|
}
|
|
var apiErr *gitea.APIError
|
|
if !errors.As(err, &apiErr) {
|
|
t.Fatalf("error is %T, want *gitea.APIError: %v", err, err)
|
|
}
|
|
if apiErr.Status != http.StatusUnprocessableEntity {
|
|
t.Errorf("Status is %d, want 422", apiErr.Status)
|
|
}
|
|
if !gitea.StatusIs(err, http.StatusUnprocessableEntity) {
|
|
t.Error("StatusIs did not recognize its own error")
|
|
}
|
|
for _, want := range []string{"422", "label already exists", "GET", "/api/v1/repos/acme/widgets/issues/7"} {
|
|
if !strings.Contains(err.Error(), want) {
|
|
t.Errorf("the error does not mention %q:\n%s", want, err)
|
|
}
|
|
}
|
|
// The token is in a header, so quoting the URL is safe — and it had better
|
|
// stay that way.
|
|
if strings.Contains(err.Error(), "s3cret") {
|
|
t.Errorf("the error quotes the token:\n%s", err)
|
|
}
|
|
}
|
|
|
|
// A request body is filed in the scratchpad, which is a SIBLING of the store
|
|
// and never inside it. A call that touches no issue must not materialize an
|
|
// issue directory.
|
|
func TestPayloadLandsBesideTheStoreAndNeverInIt(t *testing.T) {
|
|
root := newProject(t)
|
|
|
|
var sent []byte
|
|
srv := serve(t, modernGitea, func(w http.ResponseWriter, r *http.Request) {
|
|
sent, _ = io.ReadAll(r.Body)
|
|
writeJSON(t, w, map[string]any{
|
|
"number": 42, "id": 5, "html_url": "https://example.test/acme/widgets/issues/42"})
|
|
})
|
|
|
|
body := "<!-- kettle:id wire-sqlc --> a & b"
|
|
got, err := newClient(t, srv.URL).CreateIssue(
|
|
sdk.CreateIssueOption{Title: "wire sqlc", Body: body}, "issue-wire-sqlc")
|
|
if err != nil {
|
|
t.Fatalf("CreateIssue: %v", err)
|
|
}
|
|
if got.Index != 42 {
|
|
t.Errorf("got issue #%d, want #42", got.Index)
|
|
}
|
|
|
|
path := filepath.Join(root, ".kettle", "payload", "issue-wire-sqlc.json")
|
|
raw, err := os.ReadFile(path)
|
|
if err != nil {
|
|
t.Fatalf("the request body was not filed at %s: %v", path, err)
|
|
}
|
|
// The same JSON VALUE as went out, and not the same bytes: the SDK marshals
|
|
// with encoding/json's defaults, so what is on the wire has its markup
|
|
// escaped and no indentation. A dump nobody can read is a dump nobody
|
|
// reads, and a re-POST of this file sends what this call sent.
|
|
var filed, onTheWire any
|
|
if err := json.Unmarshal(raw, &filed); err != nil {
|
|
t.Fatalf("the filed body is not JSON: %v\n%s", err, raw)
|
|
}
|
|
if err := json.Unmarshal(sent, &onTheWire); err != nil {
|
|
t.Fatalf("what was sent is not JSON: %v\n%s", err, sent)
|
|
}
|
|
if !reflect.DeepEqual(filed, onTheWire) {
|
|
t.Errorf("the filed body is not the body that was sent:\nfiled: %s\nsent: %s", raw, sent)
|
|
}
|
|
// A dump escaped to \u003c is unreadable exactly when it is being read.
|
|
if !strings.Contains(string(raw), "<!-- kettle:id wire-sqlc --> a & b") {
|
|
t.Errorf("the dump escaped the markup it was meant to preserve:\n%s", raw)
|
|
}
|
|
if !strings.Contains(string(raw), "\n \"") {
|
|
t.Errorf("the dump is not indented:\n%s", raw)
|
|
}
|
|
// The whole reason the scratchpad is a sibling.
|
|
if _, err := os.Stat(filepath.Join(root, ".kettle", "issues")); !os.IsNotExist(err) {
|
|
t.Errorf("writing a request body materialized the issue store (%v)", err)
|
|
}
|
|
// A namespaced name must not climb out of the scratchpad.
|
|
if _, err := newClient(t, srv.URL).CreateLabel(sdk.CreateLabelOption{Name: "type/bug", Color: "#ee0701"}); err != nil {
|
|
t.Fatalf("CreateLabel: %v", err)
|
|
}
|
|
if _, err := os.Stat(filepath.Join(root, ".kettle", "payload", "label-type-bug.json")); err != nil {
|
|
t.Errorf("a label request body was not filed under a safe name: %v", err)
|
|
}
|
|
if _, err := os.Stat(filepath.Join(root, ".kettle", "payload", "type")); !os.IsNotExist(err) {
|
|
t.Error("a label name with a slash in it made a directory inside the scratchpad")
|
|
}
|
|
}
|
|
|
|
// A run that sends no body leaves no directory behind — the scratchpad is
|
|
// created by the first write and only then. The version handshake every client
|
|
// opens with is a read, so building one is not "a run that sent something".
|
|
func TestAReadOnlyCallCreatesNoScratchpad(t *testing.T) {
|
|
root := newProject(t)
|
|
|
|
srv := serve(t, modernGitea, func(w http.ResponseWriter, r *http.Request) {
|
|
writeJSON(t, w, map[string]any{"number": 42})
|
|
})
|
|
|
|
if _, err := newClient(t, srv.URL).GetIssue(42); err != nil {
|
|
t.Fatalf("GetIssue: %v", err)
|
|
}
|
|
if _, err := os.Stat(filepath.Join(root, ".kettle", "payload")); !os.IsNotExist(err) {
|
|
t.Errorf("a read created the payload directory (%v)", err)
|
|
}
|
|
}
|
|
|
|
// The milestone filter is re-checked on the client, because Gitea silently
|
|
// ignores one it cannot resolve and answers with the whole backlog. Pull
|
|
// requests go the same way.
|
|
func TestListIssuesRechecksWhatTheServerIgnored(t *testing.T) {
|
|
newProject(t)
|
|
|
|
srv := serve(t, modernGitea, func(w http.ResponseWriter, r *http.Request) {
|
|
if strings.Contains(r.URL.Path, "/milestones") {
|
|
writeJSON(t, w, []map[string]any{{"id": 3, "title": "v1"}, {"id": 9, "title": "later"}})
|
|
return
|
|
}
|
|
if r.URL.Query().Get("page") != "1" {
|
|
writeJSON(t, w, []map[string]any{})
|
|
return
|
|
}
|
|
writeJSON(t, w, []map[string]any{
|
|
{"number": 1, "title": "in the milestone", "milestone": map[string]any{"id": 3, "title": "v1"}},
|
|
{"number": 2, "title": "another milestone", "milestone": map[string]any{"id": 9, "title": "later"}},
|
|
{"number": 3, "title": "no milestone at all"},
|
|
{"number": 4, "title": "a pull request", "milestone": map[string]any{"id": 3, "title": "v1"},
|
|
"pull_request": map[string]any{"merged": false}},
|
|
})
|
|
})
|
|
|
|
got, err := newClient(t, srv.URL).ListIssues(gitea.IssueFilter{Milestone: "v1", Limit: 50})
|
|
if err != nil {
|
|
t.Fatalf("ListIssues: %v", err)
|
|
}
|
|
if got.Milestone != "v1" {
|
|
t.Errorf("resolved milestone is %q, want v1", got.Milestone)
|
|
}
|
|
if len(got.Issues) != 1 || got.Issues[0].Index != 1 {
|
|
t.Fatalf("got %d issue(s) %v, want only #1 — the backlog was not re-filtered", len(got.Issues), got.Issues)
|
|
}
|
|
|
|
if _, err := newClient(t, srv.URL).ListIssues(gitea.IssueFilter{Milestone: "typo", Limit: 50}); err == nil {
|
|
t.Error("an unknown milestone was accepted — that reads as a milestone with the whole backlog in it")
|
|
} else if !strings.Contains(err.Error(), "have: v1 (id 3)") {
|
|
t.Errorf("the error does not say what the repo actually has: %v", err)
|
|
}
|
|
}
|
|
|
|
// A Keep predicate that rejects everything must not turn a bounded read into a
|
|
// walk of the whole tracker, and coming up short is reported rather than
|
|
// answered in silence.
|
|
func TestListIssuesStopsAtThePageBudget(t *testing.T) {
|
|
newProject(t)
|
|
|
|
pages := 0
|
|
srv := serve(t, modernGitea, func(w http.ResponseWriter, r *http.Request) {
|
|
pages++
|
|
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
|
|
out := []map[string]any{}
|
|
for i := 0; i < limit; i++ {
|
|
out = append(out, map[string]any{"number": pages*100 + i, "state": "closed"})
|
|
}
|
|
writeJSON(t, w, out)
|
|
})
|
|
|
|
got, err := newClient(t, srv.URL).ListIssues(gitea.IssueFilter{
|
|
Limit: 2,
|
|
Keep: func(i *sdk.Issue) bool { return i.State == sdk.StateOpen },
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("ListIssues: %v", err)
|
|
}
|
|
if pages != gitea.PageSlack {
|
|
t.Errorf("read %d page(s), want %d — one ideal page times the slack", pages, gitea.PageSlack)
|
|
}
|
|
if got.Warning == "" {
|
|
t.Error("stopped short of the limit and said nothing about it")
|
|
}
|
|
// Everything enumerated comes back even though none of it counted: a caller
|
|
// with something to say about the ones that did not still can.
|
|
if len(got.Issues) != gitea.PageSlack*2 {
|
|
t.Errorf("got %d issue(s), want every payload that was enumerated", len(got.Issues))
|
|
}
|
|
}
|
|
|
|
// A Keep-bounded read stops the moment the budget is full: the page after the
|
|
// one that completed it is never requested.
|
|
func TestListIssuesStopsAtTheLimit(t *testing.T) {
|
|
newProject(t)
|
|
|
|
pages := 0
|
|
srv := serve(t, modernGitea, func(w http.ResponseWriter, r *http.Request) {
|
|
pages++
|
|
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
|
|
out := []map[string]any{}
|
|
for i := 0; i < limit; i++ {
|
|
out = append(out, map[string]any{"number": pages*100 + i, "state": "open"})
|
|
}
|
|
writeJSON(t, w, out)
|
|
})
|
|
|
|
got, err := newClient(t, srv.URL).ListIssues(gitea.IssueFilter{
|
|
Limit: 2,
|
|
Keep: func(i *sdk.Issue) bool { return i.State == sdk.StateOpen },
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("ListIssues: %v", err)
|
|
}
|
|
if pages != 1 {
|
|
t.Errorf("read %d page(s), want 1 — the budget was full after the first", pages)
|
|
}
|
|
if len(got.Issues) != 2 || got.Warning != "" {
|
|
t.Errorf("got %d issue(s), warning %q; want 2 and no warning", len(got.Issues), got.Warning)
|
|
}
|
|
}
|
|
|
|
// A dependency endpoint the instance has but this repository does not is "no
|
|
// dependencies", not a failed pull. A dead connection still is one.
|
|
func TestDependenciesToleratesAnInstanceWithoutThem(t *testing.T) {
|
|
newProject(t)
|
|
|
|
srv := serve(t, modernGitea, func(w http.ResponseWriter, r *http.Request) {
|
|
http.Error(w, "not implemented", http.StatusNotImplemented)
|
|
})
|
|
|
|
// Both clients are built while the server is up, because building one is
|
|
// itself a request now — and the question this asks is what a call does
|
|
// when the connection dies UNDER it, which is a different failure from a
|
|
// tracker that was never there.
|
|
live := newClient(t, srv.URL)
|
|
dead := newClient(t, srv.URL)
|
|
|
|
got, err := live.DependencyKeys(42)
|
|
if err != nil || len(got) != 0 {
|
|
t.Errorf("DependencyKeys = %v, %v; want no keys and no error", got, err)
|
|
}
|
|
|
|
srv.Close()
|
|
if _, err := dead.Dependencies(42); err == nil {
|
|
t.Error("a dead connection was reported as an instance without dependency support")
|
|
}
|
|
}
|
|
|
|
// The gate the SDK made possible: an instance too old to have the endpoint at
|
|
// all is answered from its version, without a request.
|
|
//
|
|
// It matters because the alternative is guessing from a status code. An old
|
|
// Gitea answers a route it does not have with the same 404 it answers for an
|
|
// issue that does not exist, and a pull that read the second as "no blockers"
|
|
// would quietly drop half the unit of work.
|
|
func TestDependenciesAreNotAskedForOnAnInstanceTooOldToHaveThem(t *testing.T) {
|
|
newProject(t)
|
|
|
|
asked := 0
|
|
srv := serve(t, "1.19.4", func(w http.ResponseWriter, r *http.Request) {
|
|
asked++
|
|
writeJSON(t, w, []map[string]any{{"number": 7}})
|
|
})
|
|
|
|
c := newClient(t, srv.URL)
|
|
got, err := c.Dependencies(42)
|
|
if err != nil || len(got) != 0 {
|
|
t.Errorf("Dependencies = %v, %v; want none and no error", got, err)
|
|
}
|
|
if asked != 0 {
|
|
t.Errorf("%d request(s) went out — the version had already answered", asked)
|
|
}
|
|
|
|
// Writing one says so out loud instead: push reports it beside the issue it
|
|
// could not link, and a warning naming the version is something an operator
|
|
// can act on.
|
|
err = c.AddDependency(42, wire.Key{Repo: wire.Repo{Owner: "acme", Name: "widgets"}, Number: 7})
|
|
if err == nil {
|
|
t.Fatal("a link was attempted against an instance that has no endpoint for it")
|
|
}
|
|
if !strings.Contains(err.Error(), "1.20.0") {
|
|
t.Errorf("the refusal does not name the version that would work: %v", err)
|
|
}
|
|
if asked != 0 {
|
|
t.Errorf("%d request(s) went out for a write the instance cannot take", asked)
|
|
}
|
|
}
|
|
|
|
// And the other side of the same gate: a modern instance is asked, and the
|
|
// answer comes back as cross-repo keys — a dependency is allowed to live
|
|
// somewhere else, and a bare number would not say where.
|
|
func TestDependenciesAreAskedForOnAnInstanceThatHasThem(t *testing.T) {
|
|
newProject(t)
|
|
|
|
var body string
|
|
srv := serve(t, modernGitea, func(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method == http.MethodPost {
|
|
raw, _ := io.ReadAll(r.Body)
|
|
body = string(raw)
|
|
w.WriteHeader(http.StatusCreated)
|
|
return
|
|
}
|
|
writeJSON(t, w, []map[string]any{
|
|
{"number": 7},
|
|
{"number": 3, "repository": map[string]any{"full_name": "other/repo"}},
|
|
})
|
|
})
|
|
|
|
c := newClient(t, srv.URL)
|
|
got, err := c.DependencyKeys(42)
|
|
if err != nil {
|
|
t.Fatalf("DependencyKeys: %v", err)
|
|
}
|
|
want := []wire.Key{
|
|
{Repo: wire.Repo{Owner: "acme", Name: "widgets"}, Number: 7},
|
|
{Repo: wire.Repo{Owner: "other", Name: "repo"}, Number: 3},
|
|
}
|
|
if !reflect.DeepEqual(got, want) {
|
|
t.Errorf("keys = %v, want %v", got, want)
|
|
}
|
|
|
|
// The one endpoint left with a hand-rolled request, because the SDK's
|
|
// IssueMeta carries an index and nothing else: a link to another repository
|
|
// needs the owner and the name with it.
|
|
if err := c.AddDependency(42, want[1]); err != nil {
|
|
t.Fatalf("AddDependency: %v", err)
|
|
}
|
|
for _, part := range []string{`"index":3`, `"owner":"other"`, `"repo":"repo"`} {
|
|
if !strings.Contains(body, part) {
|
|
t.Errorf("the link body does not carry %s: %s", part, body)
|
|
}
|
|
}
|
|
}
|
|
|
|
// The generic request: bytes out, bytes back, and the same three services every
|
|
// other call in this package gets — the header, the scratchpad, the *APIError.
|
|
func TestDoAnswersWithWhatTheServerSent(t *testing.T) {
|
|
root := newProject(t)
|
|
|
|
var got struct{ method, uri, auth, ctype string }
|
|
var sent []byte
|
|
srv := serve(t, modernGitea, func(w http.ResponseWriter, r *http.Request) {
|
|
got.method, got.uri = r.Method, r.URL.RequestURI()
|
|
got.auth, got.ctype = r.Header.Get("Authorization"), r.Header.Get("Content-Type")
|
|
sent, _ = io.ReadAll(r.Body)
|
|
w.WriteHeader(http.StatusCreated)
|
|
io.WriteString(w, `{"tag_name":"v0.2.0"}`)
|
|
})
|
|
c := newClient(t, srv.URL)
|
|
|
|
// A read: no body out, and nothing filed — the scratchpad holds what was
|
|
// SENT, and a run that sent nothing leaves no directory behind.
|
|
code, answer, err := c.Do(http.MethodGet, "repos/acme/widgets/releases?limit=50", nil, "")
|
|
if err != nil {
|
|
t.Fatalf("Do: %v", err)
|
|
}
|
|
if code != http.StatusCreated || string(answer) != `{"tag_name":"v0.2.0"}` {
|
|
t.Errorf("got %d %q, want 201 and the server's bytes", code, answer)
|
|
}
|
|
if got.uri != "/api/v1/repos/acme/widgets/releases?limit=50" {
|
|
t.Errorf("the endpoint was rewritten: %s", got.uri)
|
|
}
|
|
if got.auth != "token s3cret" {
|
|
t.Errorf("Authorization was %q, want %q", got.auth, "token s3cret")
|
|
}
|
|
if got.ctype != "" {
|
|
t.Errorf("a request with no body carried Content-Type %q", got.ctype)
|
|
}
|
|
if _, err := os.Stat(filepath.Join(root, ".kettle", "payload")); !os.IsNotExist(err) {
|
|
t.Errorf("a read created the payload directory (%v)", err)
|
|
}
|
|
|
|
// A write: the body goes out verbatim and is filed under the name it was
|
|
// given, by the same RoundTripper that files every other request.
|
|
body := []byte(`{"tag_name":"v0.2.0","body":"a & b"}`)
|
|
if _, _, err := c.Do(http.MethodPost, "/api/v1/repos/acme/widgets/releases", body, "release-v0-2-0"); err != nil {
|
|
t.Fatalf("Do: %v", err)
|
|
}
|
|
if got.method != http.MethodPost || got.ctype != "application/json" {
|
|
t.Errorf("the write went out as %s %q", got.method, got.ctype)
|
|
}
|
|
if string(sent) != string(body) {
|
|
t.Errorf("the server got %s, want %s — a passthrough reformatted the body", sent, body)
|
|
}
|
|
filed, err := os.ReadFile(filepath.Join(root, ".kettle", "payload", "release-v0-2-0.json"))
|
|
if err != nil {
|
|
t.Fatalf("the body was not filed: %v", err)
|
|
}
|
|
if !strings.Contains(string(filed), `"tag_name": "v0.2.0"`) {
|
|
t.Errorf("the dump is not the body that was sent:\n%s", filed)
|
|
}
|
|
}
|
|
|
|
// A refusal comes back as this package's error, with the status and the
|
|
// server's own words — and the status and body are returned as well, so a
|
|
// caller that would rather print them than wrap them can.
|
|
func TestDoReportsAStatusAndTheServersWords(t *testing.T) {
|
|
newProject(t)
|
|
|
|
srv := serve(t, modernGitea, func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusNotFound)
|
|
io.WriteString(w, `{"message":"release does not exist"}`)
|
|
})
|
|
|
|
code, answer, err := newClient(t, srv.URL).Do(http.MethodGet, "repos/acme/widgets/releases/9", nil, "")
|
|
if err == nil {
|
|
t.Fatal("a 404 came back as success")
|
|
}
|
|
var apiErr *gitea.APIError
|
|
if !errors.As(err, &apiErr) {
|
|
t.Fatalf("error is %T, want *gitea.APIError: %v", err, err)
|
|
}
|
|
if code != http.StatusNotFound || !strings.Contains(string(answer), "release does not exist") {
|
|
t.Errorf("got %d %q; the status and the body are the caller's too", code, answer)
|
|
}
|
|
for _, want := range []string{"404", "release does not exist", "GET", "/api/v1/repos/acme/widgets/releases/9"} {
|
|
if !strings.Contains(err.Error(), want) {
|
|
t.Errorf("the error does not mention %q:\n%s", want, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
// A 403 is answered with what to do about it, because Gitea's own 403 names no
|
|
// scope and a token minted for issues is the usual reason.
|
|
func TestAForbiddenAnswerNamesTheScopeItMightBe(t *testing.T) {
|
|
newProject(t)
|
|
|
|
srv := serve(t, modernGitea, func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusForbidden)
|
|
io.WriteString(w, `{"message":"token does not have at least one of required scope(s)"}`)
|
|
})
|
|
|
|
_, _, err := newClient(t, srv.URL).Do(http.MethodPost, "repos/acme/widgets/releases", []byte(`{}`), "")
|
|
if err == nil {
|
|
t.Fatal("a 403 came back as success")
|
|
}
|
|
for _, want := range []string{"403", "kettle auth list", "repository"} {
|
|
if !strings.Contains(err.Error(), want) {
|
|
t.Errorf("a 403 does not say %q — the server named no scope, so this has to:\n%s", want, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
// The token is this instance's. A full URL somewhere else is refused before a
|
|
// socket is opened, because sending it would hand the credential to whatever
|
|
// host was typed.
|
|
func TestDoRefusesAURLOnAnotherHost(t *testing.T) {
|
|
newProject(t)
|
|
|
|
asked := 0
|
|
srv := serve(t, modernGitea, func(w http.ResponseWriter, r *http.Request) {
|
|
asked++
|
|
writeJSON(t, w, map[string]any{})
|
|
})
|
|
c := newClient(t, srv.URL)
|
|
|
|
_, _, err := c.Do(http.MethodGet, "https://gitea.example.invalid/api/v1/user", nil, "")
|
|
if err == nil {
|
|
t.Fatal("a request to another host was allowed — that sends this project's token to it")
|
|
}
|
|
if strings.Contains(err.Error(), "s3cret") {
|
|
t.Errorf("the refusal quotes the token:\n%s", err)
|
|
}
|
|
if asked != 0 {
|
|
t.Errorf("%d request(s) went out for an endpoint that was refused", asked)
|
|
}
|
|
// A full URL on the instance itself is the same request as the bare path.
|
|
if _, _, err := c.Do(http.MethodGet, srv.URL+"/api/v1/user", nil, ""); err != nil {
|
|
t.Errorf("a full URL on this instance was refused: %v", err)
|
|
}
|
|
if asked != 1 {
|
|
t.Errorf("%d request(s) went out, want 1", asked)
|
|
}
|
|
}
|
|
|
|
// A half-filled configuration is refused here rather than at the first 401,
|
|
// because a 401 names nothing an operator can act on — and before the client is
|
|
// built at all, because building one dials.
|
|
func TestNewRefusesAHalfFilledConfiguration(t *testing.T) {
|
|
for _, tc := range []struct {
|
|
what string
|
|
cfg config.Resolved
|
|
want string
|
|
}{
|
|
{"no url", config.Resolved{Token: "t", Owner: "a", Repo: "b"}, config.EnvURL},
|
|
{"no token", config.Resolved{URL: "u", Owner: "a", Repo: "b"}, "kettle auth add"},
|
|
{"no repo", config.Resolved{URL: "u", Token: "t"}, "kettle init --repo"},
|
|
} {
|
|
_, err := gitea.New(&tc.cfg)
|
|
if err == nil {
|
|
t.Errorf("%s: accepted", tc.what)
|
|
continue
|
|
}
|
|
if !strings.Contains(err.Error(), tc.want) {
|
|
t.Errorf("%s: the error does not name the fix (%q): %v", tc.what, tc.want, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
// An instance nobody can reach is named as one. The handshake is the first
|
|
// request of every run, so this is the failure an operator meets when the URL
|
|
// is wrong or the tracker is down, and it has to say which instance.
|
|
func TestNewSaysWhichInstanceItCouldNotReach(t *testing.T) {
|
|
newProject(t)
|
|
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}))
|
|
srv.Close()
|
|
|
|
_, err := gitea.New(&config.Resolved{URL: srv.URL, Token: "t", Owner: "a", Repo: "b"})
|
|
if err == nil {
|
|
t.Fatal("a client was built against a tracker that is not there")
|
|
}
|
|
if !strings.Contains(err.Error(), srv.URL) {
|
|
t.Errorf("the error does not name the instance: %v", err)
|
|
}
|
|
}
|
|
|
|
// A version nobody can parse is refused at the handshake, not carried.
|
|
//
|
|
// The SDK hands back a client that has silently decided the server is 1.11 and
|
|
// then rewrites issue URLs from a field a modern payload need not carry, which
|
|
// is a nil dereference on the first issue read. A refusal that names the
|
|
// instance is the answer an operator can act on.
|
|
func TestNewRefusesAnInstanceWhoseVersionIsNotOne(t *testing.T) {
|
|
newProject(t)
|
|
|
|
srv := serve(t, "not-a-version", func(w http.ResponseWriter, r *http.Request) {
|
|
t.Errorf("a call went out to %s after the handshake had already failed", r.URL.Path)
|
|
})
|
|
|
|
_, err := gitea.New(&config.Resolved{URL: srv.URL, Token: "t", Owner: "a", Repo: "b"})
|
|
if err == nil {
|
|
t.Fatal("an unreadable version was accepted — the first issue read would panic inside the SDK")
|
|
}
|
|
if !strings.Contains(err.Error(), srv.URL) || !strings.Contains(err.Error(), config.EnvURL) {
|
|
t.Errorf("the refusal names neither the instance nor the setting that points at it: %v", err)
|
|
}
|
|
}
|
|
|
|
// The layering rule, from this side. The transport knows numbers, logins, HTTP
|
|
// and JSON; the domain knows none of those, and neither may reach the other.
|
|
//
|
|
// The bridge is out too, and for a reason of its own: it is the layer that
|
|
// translates between the two, so it sits ABOVE both. A transport that imported
|
|
// it would be a transport that knows what an issue is, one indirection later —
|
|
// and the vocabulary both of them share, the SDK's payloads, exists precisely
|
|
// so that neither has to reach for the other to name one.
|
|
func TestTransportDoesNotImportTheDomain(t *testing.T) {
|
|
out, err := exec.Command("go", "list", "-deps", ".").Output()
|
|
if err != nil {
|
|
t.Fatalf("go list: %v", err)
|
|
}
|
|
for _, dep := range strings.Fields(string(out)) {
|
|
switch {
|
|
case strings.HasSuffix(dep, "/internal/issue"):
|
|
t.Errorf("the transport imports %s — what an issue IS is not a transport concept", dep)
|
|
case strings.HasSuffix(dep, "/internal/mapping"):
|
|
t.Errorf("the transport imports %s — translating is a layer of its own, and it sits above this one", dep)
|
|
}
|
|
}
|
|
}
|