Files
gitea-tea/internal/transport/download_test.go
T
bircni 2b218a5bae refactor: add settings, transport and alias packages (#1122)
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>
2026-10-11 17:06:38 +00:00

159 lines
5.6 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 TestDownloadScopeAndRedirects(t *testing.T) {
requests := 0
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requests++
require.Equal(t, "token sensitive", r.Header.Get("Authorization"))
if r.URL.Path == "/base/redirect" {
http.Redirect(w, r, "/outside", http.StatusFound)
return
}
_, _ = w.Write([]byte("binary bytes"))
}))
defer server.Close()
client := New(server.URL+"/base", "sensitive", false)
// Even a substituted HTTP client cannot re-enable automatic redirects.
client.HTTP = server.Client()
for _, raw := range []string{server.URL + "/outside", server.URL + "/base/../outside", server.URL + "/base/%2e%2e/outside", "https://example.test/base/file"} {
_, err := client.Download(context.Background(), raw)
require.Error(t, err)
}
require.Zero(t, requests)
_, err := client.Download(context.Background(), server.URL+"/base/redirect")
require.Error(t, err)
require.Equal(t, 1, requests)
response, err := client.Download(context.Background(), server.URL+"/base/file")
require.NoError(t, err)
body, err := io.ReadAll(response.Body)
require.NoError(t, err)
require.NoError(t, response.Body.Close())
require.Equal(t, "binary bytes", string(body))
}
func TestSignedDownloadsFollowOneRedirectWithoutCredentials(t *testing.T) {
storage := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
require.Empty(t, r.Header.Get("Authorization"))
require.Empty(t, r.Header.Get("Sudo"))
_, _ = w.Write([]byte("archive"))
}))
defer storage.Close()
for _, tc := range []struct {
destination string
status int
ok bool
requests int
}{
{"/base/signed?signature=private-value", http.StatusFound, true, 1},
{"/outside?signature=private-value", http.StatusFound, true, 1},
{storage.URL + "/bucket/file?signature=private-value", http.StatusSeeOther, true, 0},
{storage.URL + "/bucket/file?signature=private-value", http.StatusTemporaryRedirect, true, 0},
{"/base/again", http.StatusFound, false, 1},
{"ftp://storage.example.test/file?signature=private-value", http.StatusFound, false, 0},
} {
t.Run(tc.destination, func(t *testing.T) {
signedRequests := 0
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/base/archive" {
require.Equal(t, "token sensitive", r.Header.Get("Authorization"))
require.Equal(t, "bob", r.Header.Get("Sudo"))
http.Redirect(w, r, tc.destination, tc.status)
return
}
signedRequests++
require.Empty(t, r.Header.Get("Authorization"))
require.Empty(t, r.Header.Get("Sudo"))
if r.URL.Path == "/base/again" {
http.Redirect(w, r, "/base/signed", http.StatusFound)
return
}
_, _ = w.Write([]byte("archive"))
}))
defer server.Close()
client := New(server.URL+"/base", "sensitive", false)
client.Sudo = "bob"
client.HTTP = server.Client() // The implementation must still suppress automatic redirects.
response, err := client.DownloadSigned(context.Background(), server.URL+"/base/archive")
require.Equal(t, tc.requests, signedRequests)
if !tc.ok {
require.Error(t, err)
require.NotContains(t, err.Error(), "private-value")
return
}
require.NoError(t, err)
defer response.Body.Close()
data, err := io.ReadAll(response.Body)
require.NoError(t, err)
require.Equal(t, "archive", string(data))
})
}
}
type fakeSigner struct{ calls int }
func (s *fakeSigner) SignRequest(r *http.Request, _ []byte) error {
s.calls++
r.Header.Set("Signature", "signed")
return nil
}
func TestDownloadsSignOnlyInstanceRequest(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/base/archive" {
require.Equal(t, "signed", r.Header.Get("Signature"))
http.Redirect(w, r, "/base/signed?signature=private-value", http.StatusFound)
return
}
require.Empty(t, r.Header.Get("Signature"))
require.Empty(t, r.Header.Get("Authorization"))
_, _ = w.Write([]byte("archive"))
}))
defer server.Close()
signer := &fakeSigner{}
client := New(server.URL+"/base", "", false)
client.HTTP = server.Client()
client.Signer = signer
response, err := client.DownloadSigned(context.Background(), server.URL+"/base/archive")
require.NoError(t, err)
require.NoError(t, response.Body.Close())
require.Equal(t, 1, signer.calls)
}
func TestURLAndDownloadShareScopeRejections(t *testing.T) {
requests := 0
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { requests++ }))
defer server.Close()
client := New(server.URL+"/base", "sensitive", false)
client.HTTP = server.Client()
host := strings.TrimPrefix(server.URL, "http://")
for _, raw := range []string{
"http://user:pass@" + host + "/base/api/v1/user", server.URL + "/base/api/v1/user#fragment", server.URL + "/base/api/v1/a\\b",
server.URL + "/base/api/v1/./user", server.URL + "/base/api/v1/%2e%2e/user", server.URL + "/basement/api/v1/user", "//" + host + "/base/api/v1/user",
} {
_, err := client.URL(raw)
require.Error(t, err, raw)
_, err = client.Download(context.Background(), raw)
require.Error(t, err, raw)
}
require.Zero(t, requests)
}
func TestSignedDownloadRejectsInvalidInstanceURL(t *testing.T) {
_, err := New("http://[invalid", "sensitive", false).DownloadSigned(context.Background(), "http://example.test/file")
require.ErrorContains(t, err, "invalid instance URL")
}