Files
gitea-tea/internal/transport/client_test.go
T
bircni 0254c0509e fix(transport): keep harmless query values visible in --debug output
--debug replaced every query value with REDACTED, so it could not show which
filters were sent. Redact only parameters that can authenticate a request
(tokens, passwords, secrets, signatures, storage credentials, OAuth codes and
the OAuth state), and log the sanitized server message of failed responses.

A 404 now names the request instead of blaming the instance, and API errors
recorded from a request keep its instance and credential so a caller can look
up why it failed. Network failures keep the server they could not reach.

Signed-off-by: bircni <bircni@icloud.com>
2026-10-11 22:48:49 +02:00

244 lines
11 KiB
Go

// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package transport
import (
"bytes"
"context"
"fmt"
"net"
"net/http"
"net/http/httptest"
"regexp"
"runtime"
"strings"
"testing"
"time"
"gitea.dev/tea/internal/version"
"github.com/stretchr/testify/require"
)
func TestInstanceScopeAndRedirect(t *testing.T) {
leaked := false
outside := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { leaked = true; w.WriteHeader(200) }))
defer outside.Close()
selected := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, "/base/api/v1/user", r.URL.Path)
require.Equal(t, "token secret", r.Header.Get("Authorization"))
http.Redirect(w, r, outside.URL, http.StatusFound)
}))
defer selected.Close()
client := New(selected.URL+"/base", "secret", false)
_, err := client.Do(context.Background(), http.MethodGet, "user", nil, nil)
require.Error(t, err)
require.False(t, leaked)
for _, endpoint := range []string{outside.URL + "/api/v1/user", strings.Replace(selected.URL, "http:", "https:", 1) + "/base/api/v1/user", selected.URL + "/api/v1/user", "../user", selected.URL + "/base/api/v1/../../other", "//other.example/api/v1/user"} {
_, err := client.URL(endpoint)
require.Error(t, err, endpoint)
}
}
func TestCancellation(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
cancel()
client := New("https://example.org", "", false)
_, err := client.Do(ctx, http.MethodGet, "user", nil, nil)
require.ErrorIs(t, err, context.Canceled)
}
func TestURLPreservesEscapedSegments(t *testing.T) {
client := New("https://example.test/base%20path", "", false)
resolved, err := client.URL("repos/alice/project/releases/tags/release%2Fv1")
require.NoError(t, err)
require.Equal(t, "https://example.test/base%20path/api/v1/repos/alice/project/releases/tags/release%2Fv1", resolved)
}
func TestAPIErrorIncludesOnlySanitizedServerMessage(t *testing.T) {
const merge = "Gitea API returned HTTP %d for POST /api/v1/repos/a/b/pulls/1/merge"
long := strings.Repeat("x", 400)
for _, tc := range []struct {
name, body, want string
status int
}{
{"merge refusal", `{"message":"Please try again later","url":"https://leak.example/?key=abc"}`, fmt.Sprintf(merge, 405) + ": Please try again later", 405},
{"token scope", `{"message":"token does not have at least one of required scope(s): [read:user]"}`, "permission denied by the Gitea instance: token does not have at least one of required scope(s): [read:user]", 403},
{"control characters", "{\"message\":\"bad\\u001b[31m\\nnews\\u202e\"}", fmt.Sprintf(merge, 422) + ": bad [31m news", 422},
{"capped", `{"message":"` + long + `"}`, fmt.Sprintf(merge, 500) + ": " + long[:300] + "...", 500},
{"not json", `<html>key=abc</html>`, fmt.Sprintf(merge, 500), 500},
{"other fields", `{"errors":["abc"]}`, fmt.Sprintf(merge, 500), 500},
{"oversized body", `{"message":"` + strings.Repeat("y", 5000) + `"}`, fmt.Sprintf(merge, 500), 500},
{"token echo", `{"message":"bad token secret"}`, fmt.Sprintf(merge, 400), 400},
{"authentication", `{"message":"user alice not found"}`, "authentication rejected; run tea auth login", 401},
{"bare not found", `{"message":"not found","url":"https://leak.example/?key=abc"}`, "not found (HTTP 404 POST /api/v1/repos/a/b/pulls/1/merge)", 404},
{"described not found", `{"message":"label does not exist [label_id: 9]"}`, "not found (HTTP 404 POST /api/v1/repos/a/b/pulls/1/merge): label does not exist [label_id: 9]", 404},
} {
t.Run(tc.name, func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(tc.status)
_, _ = w.Write([]byte(tc.body))
}))
defer server.Close()
_, err := New(server.URL, "secret", false).Do(context.Background(), http.MethodPost, "repos/a/b/pulls/1/merge?key=abc", strings.NewReader(`{}`), nil)
var apiErr *APIError
require.ErrorAs(t, err, &apiErr)
require.Equal(t, tc.status, apiErr.Status)
require.Equal(t, tc.want, err.Error())
require.NotContains(t, err.Error(), "abc")
require.NotContains(t, err.Error(), "secret")
})
}
}
func TestSudoHeaderOnAPIAndDownloadRequests(t *testing.T) {
seen := []string{}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
seen = append(seen, r.URL.Path+" "+r.Header.Get("Sudo"))
_, _ = w.Write([]byte(`{}`))
}))
defer server.Close()
client := New(server.URL, "secret", false)
for _, sudo := range []string{"", "bob"} {
client.Sudo = sudo
response, err := client.Do(context.Background(), http.MethodGet, "user", nil, nil)
require.NoError(t, err)
require.NoError(t, response.Body.Close())
}
response, err := client.Download(context.Background(), server.URL+"/file")
require.NoError(t, err)
require.NoError(t, response.Body.Close())
require.Equal(t, []string{"/api/v1/user ", "/api/v1/user bob", "/file bob"}, seen)
}
func TestTransportBoundsOnlySetupAndSendsUserAgent(t *testing.T) {
agents := []string{}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
agents = append(agents, r.Header.Get("User-Agent"))
_, _ = w.Write([]byte(`{}`))
}))
defer server.Close()
client := New(server.URL, "secret", false)
// A whole-request timeout would cut off long uploads and downloads that are still progressing.
require.Zero(t, client.HTTP.Timeout)
tr := client.HTTP.Transport.(userAgentTransport).base.(*http.Transport)
require.Equal(t, 120*time.Second, tr.ResponseHeaderTimeout)
require.Equal(t, 10*time.Second, tr.TLSHandshakeTimeout)
for _, open := range []func() (*http.Response, error){
func() (*http.Response, error) {
return client.Do(context.Background(), http.MethodGet, "user", nil, nil)
},
func() (*http.Response, error) { return client.Download(context.Background(), server.URL+"/file") },
} {
response, err := open()
require.NoError(t, err)
require.NoError(t, response.Body.Close())
}
want := "tea/" + version.Version + " (" + runtime.GOOS + "/" + runtime.GOARCH + ")"
require.Equal(t, []string{want, want}, agents)
}
func TestNetworkErrorsKeepCauseWithoutURL(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {}))
server.Close()
client := New(server.URL, "secret", false)
_, err := client.Do(context.Background(), http.MethodPost, "user?token=query-secret", strings.NewReader(`{}`), nil)
var opErr *net.OpError
require.ErrorAs(t, err, &opErr)
require.ErrorContains(t, err, "API request failed: ")
require.NotContains(t, err.Error(), "query-secret")
var failure *RequestFailure
require.ErrorAs(t, err, &failure)
require.Equal(t, server.URL, failure.Server)
_, err = client.Download(context.Background(), server.URL+"/file?signature=query-secret")
require.ErrorAs(t, err, &opErr)
require.ErrorContains(t, err, "download failed: ")
require.NotContains(t, err.Error(), "query-secret")
}
func TestDebugLogsRequestsWithoutSecrets(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/api/v1/repos/a/b/labels/8":
w.WriteHeader(http.StatusNotFound)
_, _ = w.Write([]byte(`{"message":"label does not exist [label_id: 8]"}`))
case "/api/v1/repos/a/b/labels/9":
w.WriteHeader(http.StatusNotFound)
_, _ = w.Write([]byte(`{"message":"label does not exist; secret-token"}`))
default:
w.WriteHeader(http.StatusTeapot)
}
}))
defer server.Close()
var log bytes.Buffer
client := New(server.URL, "secret-token", false).Debug(&log)
_, err := client.Do(context.Background(), http.MethodGet, "repos/a/b?page=2&state=open&token=query-secret&access_token=query-secret", nil, nil)
require.Error(t, err)
require.Regexp(t, `^\[debug\] GET `+regexp.QuoteMeta(server.URL)+`/api/v1/repos/a/b\?access_token=REDACTED&page=2&state=open&token=REDACTED -> 418 I'm a teapot \(\d+(\.\d+)?[µm]?s\)\n$`, log.String())
log.Reset()
_, err = client.Do(context.Background(), http.MethodGet, "repos/a/b/labels/8", nil, nil)
require.Regexp(t, `-> 404 Not Found \(\d+(\.\d+)?[µm]?s\): label does not exist \[label_id: 8\]\n$`, log.String())
var apiErr *APIError
require.ErrorAs(t, err, &apiErr)
require.Equal(t, "label does not exist [label_id: 8]", apiErr.Message, "the logged body stays readable")
log.Reset()
_, err = client.Do(context.Background(), http.MethodGet, "repos/a/b/labels/9", nil, nil)
require.Error(t, err)
require.Regexp(t, `-> 404 Not Found \(\d+(\.\d+)?[µm]?s\)\n$`, log.String(), "a message echoing the token is dropped")
require.NotContains(t, log.String(), "secret")
}
func TestDebugRedactsOnlySensitiveQueryValues(t *testing.T) {
for _, tc := range []struct {
path, key string
redacted bool
}{
{"/api/v1/repos/a/b/issues", "limit", false},
{"/api/v1/repos/a/b/issues", "page", false},
{"/api/v1/repos/a/b/issues", "state", false},
{"/api/v1/repos/a/b/issues", "labels", false},
{"/api/v1/repos/a/b/issues", "token", true},
{"/api/v1/repos/a/b/issues", "access_token", true},
{"/api/v1/users", "password", true},
{"/login/oauth/authorize", "state", true},
{"/login/oauth/access_token", "code", true},
{"/login/oauth/access_token", "client_secret", true},
{"/storage/blob", "X-Amz-Signature", true},
{"/storage/blob", "X-Amz-Credential", true},
{"/storage/blob", "X-Amz-Security-Token", true},
{"/storage/blob", "sig", true},
{"/storage/blob", "X-Amz-Date", false},
} {
require.Equal(t, tc.redacted, sensitiveQuery(tc.path, tc.key), "%s %s", tc.path, tc.key)
}
}
func TestAPIErrorLookupRepeatsReadsWithTheFailedCredential(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, "token secret", r.Header.Get("Authorization"))
require.Equal(t, "bob", r.Header.Get("Sudo"))
if r.URL.Path == "/base/api/v1/repos/a/b" {
_, _ = w.Write([]byte(`{"full_name":"a/b","has_issues":false}`))
return
}
w.WriteHeader(http.StatusNotFound)
_, _ = w.Write([]byte(`{"message":"not found"}`))
}))
defer server.Close()
client := New(server.URL+"/base", "secret", false)
client.Sudo = "bob"
_, err := client.Do(context.Background(), http.MethodGet, "repos/a/b/issues/9", nil, nil)
var apiErr *APIError
require.ErrorAs(t, err, &apiErr)
require.Equal(t, server.URL+"/base", apiErr.Instance)
var repo struct {
FullName string `json:"full_name"`
HasIssues bool `json:"has_issues"`
}
require.Equal(t, http.StatusOK, apiErr.Lookup(context.Background(), "repos/a/b", &repo))
require.Equal(t, "a/b", repo.FullName)
require.Equal(t, http.StatusNotFound, apiErr.Lookup(context.Background(), "repos/a/missing", nil))
require.Zero(t, (&APIError{Status: http.StatusNotFound}).Lookup(context.Background(), "repos/a/b", nil), "errors built without a request cannot look up")
}