mirror of
https://gitea.com/gitea/tea.git
synced 2026-10-26 19:11:25 +01:00
Part 1/5 of the tea 1.0 rewrite. **Stack** (split from #1121; each PR targets `main` and builds, tests and lints on its own — the shipped CLI only changes in part 5): 1. #1122 Toolchain and foundation packages ← this PR 2. #1123 Credentials and Git 3. #1124 Terminal 4. #1125 Services 5. #1126 CLI wiring, docs, removal of the old tree ## Changes - **build:** require Go 1.27; bump gofumpt and golangci-lint to releases that can read Go 1.27 export data (own commit). - `internal/clierr`: typed errors and executable exit codes. - `internal/version`: build version and tags. - `internal/filelock`: moved unchanged from `modules/filelock`. - `internal/aliases`: user-defined command aliases. - `internal/transport`: the HTTP client the Gitea SDK runs on (part 4): token, Sudo and SSH-signature auth, retries for safe reads, connect/TLS/response-header timeouts, typed API errors that keep Gitea's message, and a redacted request log for `--debug`; plus raw requests for `tea api`, signed downloads that follow object-storage redirects, and log streaming. - `internal/settings`: split `config.yml` / `hosts.yml` with locked transactions, plus remote URL parsing. Nothing imports these packages yet. Note: `flake.nix` now asks for `go_1_27`; the pinned nixpkgs in `flake.lock` predates Go 1.27 and still needs a `nix flake update nixpkgs`. Reviewed-on: https://gitea.com/gitea/tea/pulls/1122 Reviewed-by: TheFox0x7 <95654+thefox0x7@noreply.gitea.com>
204 lines
7.1 KiB
Go
204 lines
7.1 KiB
Go
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||
// SPDX-License-Identifier: MIT
|
||
|
||
package transport
|
||
|
||
import (
|
||
"context"
|
||
"io"
|
||
"net/http"
|
||
"net/http/httptest"
|
||
"strings"
|
||
"testing"
|
||
|
||
"github.com/stretchr/testify/require"
|
||
)
|
||
|
||
func TestAPIRetriesOnlyBodyFreeReads(t *testing.T) {
|
||
for _, tc := range []struct {
|
||
name, method string
|
||
body io.Reader
|
||
status int
|
||
calls int
|
||
}{
|
||
{"get 503", http.MethodGet, nil, http.StatusServiceUnavailable, 3},
|
||
{"head 502", http.MethodHead, nil, http.StatusBadGateway, 3},
|
||
{"get 504", http.MethodGet, http.NoBody, http.StatusGatewayTimeout, 3},
|
||
{"get 500", http.MethodGet, nil, http.StatusInternalServerError, 1},
|
||
{"get with body", http.MethodGet, strings.NewReader(`{}`), http.StatusServiceUnavailable, 1},
|
||
{"post", http.MethodPost, nil, http.StatusServiceUnavailable, 1},
|
||
{"delete", http.MethodDelete, nil, http.StatusServiceUnavailable, 1},
|
||
} {
|
||
t.Run(tc.name, func(t *testing.T) {
|
||
calls := 0
|
||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||
calls++
|
||
w.WriteHeader(tc.status)
|
||
}))
|
||
defer server.Close()
|
||
req, err := http.NewRequest(tc.method, server.URL+"/api/v1/user", tc.body)
|
||
require.NoError(t, err)
|
||
resp, err := New(server.URL, "", false).API().Do(req)
|
||
require.NoError(t, err)
|
||
require.NoError(t, resp.Body.Close())
|
||
require.Equal(t, tc.status, resp.StatusCode)
|
||
require.Equal(t, tc.calls, calls)
|
||
})
|
||
}
|
||
}
|
||
|
||
func TestAPIRetryRecoversAndStopsOnCancellation(t *testing.T) {
|
||
calls := 0
|
||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||
calls++
|
||
if calls == 1 {
|
||
w.WriteHeader(http.StatusServiceUnavailable)
|
||
return
|
||
}
|
||
_, _ = w.Write([]byte(`{}`))
|
||
}))
|
||
defer server.Close()
|
||
client := New(server.URL, "", false)
|
||
resp, err := client.API().Get(server.URL + "/api/v1/user")
|
||
require.NoError(t, err)
|
||
require.NoError(t, resp.Body.Close())
|
||
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||
require.Equal(t, 2, calls)
|
||
|
||
calls = 0
|
||
ctx, cancel := context.WithCancel(context.Background())
|
||
unavailable := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||
calls++
|
||
cancel()
|
||
w.WriteHeader(http.StatusServiceUnavailable)
|
||
}))
|
||
defer unavailable.Close()
|
||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, unavailable.URL+"/api/v1/user", nil)
|
||
require.NoError(t, err)
|
||
_, err = New(unavailable.URL, "", false).API().Do(req)
|
||
require.ErrorIs(t, err, context.Canceled)
|
||
require.Equal(t, 1, calls)
|
||
}
|
||
|
||
func TestAPIAuthenticatesEveryAttempt(t *testing.T) {
|
||
type seen struct{ authorization, sudo string }
|
||
requests := []seen{}
|
||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||
requests = append(requests, seen{r.Header.Get("Authorization"), r.Header.Get("Sudo")})
|
||
if len(requests) == 1 {
|
||
w.WriteHeader(http.StatusBadGateway)
|
||
}
|
||
}))
|
||
defer server.Close()
|
||
client := New(server.URL, "secret", false)
|
||
client.Sudo = "bob"
|
||
resp, err := client.API().Get(server.URL + "/api/v1/user")
|
||
require.NoError(t, err)
|
||
require.NoError(t, resp.Body.Close())
|
||
require.Equal(t, []seen{{"token secret", "bob"}, {"token secret", "bob"}}, requests)
|
||
}
|
||
|
||
type bodySigner struct{ bodies []string }
|
||
|
||
func (s *bodySigner) SignRequest(r *http.Request, contents []byte) error {
|
||
s.bodies = append(s.bodies, string(contents))
|
||
r.Header.Set("Signature", "signed")
|
||
return nil
|
||
}
|
||
|
||
func TestAPISignsRequestBodies(t *testing.T) {
|
||
received := []string{}
|
||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||
require.Equal(t, "signed", r.Header.Get("Signature"))
|
||
require.Empty(t, r.Header.Get("Authorization"))
|
||
data, err := io.ReadAll(r.Body)
|
||
require.NoError(t, err)
|
||
received = append(received, string(data))
|
||
}))
|
||
defer server.Close()
|
||
signer := &bodySigner{}
|
||
client := New(server.URL, "", false)
|
||
client.Signer = signer
|
||
for _, body := range []io.Reader{strings.NewReader(`{"replayable":true}`), io.MultiReader(strings.NewReader(`{"streamed":true}`)), nil} {
|
||
req, err := http.NewRequest(http.MethodPost, server.URL+"/api/v1/user/keys", body)
|
||
require.NoError(t, err)
|
||
resp, err := client.API().Do(req)
|
||
require.NoError(t, err)
|
||
require.NoError(t, resp.Body.Close())
|
||
}
|
||
require.Equal(t, []string{`{"replayable":true}`, `{"streamed":true}`, ""}, signer.bodies)
|
||
require.Equal(t, signer.bodies, received)
|
||
}
|
||
|
||
func TestAPINeverFollowsRedirects(t *testing.T) {
|
||
leaked := false
|
||
outside := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { leaked = true }))
|
||
defer outside.Close()
|
||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||
http.Redirect(w, r, outside.URL, http.StatusFound)
|
||
}))
|
||
defer server.Close()
|
||
client := New(server.URL, "secret", false)
|
||
client.HTTP = server.Client()
|
||
resp, err := client.API().Get(server.URL + "/api/v1/user")
|
||
require.NoError(t, err)
|
||
require.NoError(t, resp.Body.Close())
|
||
require.Equal(t, http.StatusFound, resp.StatusCode)
|
||
require.False(t, leaked)
|
||
}
|
||
|
||
func TestCaptureErrorsSanitizesAndKeepsBodyReadable(t *testing.T) {
|
||
long := `{"message":"` + strings.Repeat("y", 5000) + `"}`
|
||
for _, tc := range []struct {
|
||
name, body, message string
|
||
status int
|
||
}{
|
||
{"message", `{"message":"branch exists"}`, "branch exists", http.StatusUnprocessableEntity},
|
||
{"oversized", long, "", http.StatusInternalServerError},
|
||
{"redirect", ``, "", http.StatusMovedPermanently},
|
||
} {
|
||
t.Run(tc.name, func(t *testing.T) {
|
||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||
w.WriteHeader(tc.status)
|
||
_, _ = w.Write([]byte(tc.body))
|
||
}))
|
||
defer server.Close()
|
||
ctx, failure := CaptureErrors(context.Background())
|
||
req, err := http.NewRequestWithContext(ctx, http.MethodPatch, server.URL+"/api/v1/repos/a/b?token=secret", nil)
|
||
require.NoError(t, err)
|
||
resp, err := New(server.URL, "secret", false).API().Do(req)
|
||
require.NoError(t, err)
|
||
data, err := io.ReadAll(resp.Body)
|
||
require.NoError(t, err)
|
||
require.NoError(t, resp.Body.Close())
|
||
require.Equal(t, tc.body, string(data))
|
||
require.Equal(t, &APIError{Status: tc.status, Method: http.MethodPatch, Endpoint: "/api/v1/repos/a/b", Message: tc.message}, failure())
|
||
})
|
||
}
|
||
}
|
||
|
||
func TestCaptureErrorsIgnoresSuccessAndUncapturedRequests(t *testing.T) {
|
||
status := http.StatusOK
|
||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||
w.WriteHeader(status)
|
||
_, _ = w.Write([]byte(`{"message":"ignored"}`))
|
||
}))
|
||
defer server.Close()
|
||
client := New(server.URL, "", false)
|
||
ctx, failure := CaptureErrors(context.Background())
|
||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, server.URL+"/api/v1/user", nil)
|
||
require.NoError(t, err)
|
||
resp, err := client.API().Do(req)
|
||
require.NoError(t, err)
|
||
require.NoError(t, resp.Body.Close())
|
||
require.Nil(t, failure())
|
||
|
||
status = http.StatusNotFound
|
||
resp, err = client.API().Get(server.URL + "/api/v1/user")
|
||
require.NoError(t, err)
|
||
data, err := io.ReadAll(resp.Body)
|
||
require.NoError(t, err)
|
||
require.NoError(t, resp.Body.Close())
|
||
require.JSONEq(t, `{"message":"ignored"}`, string(data))
|
||
}
|