mirror of
https://github.com/go-task/task.git
synced 2026-09-02 04:02:08 +02:00
The type is a map of host to headers, not a flat header set. Name it after that shape, and rename the fields carrying it to authHeadersByHost so the lookup step is visible at every call site. Claude-Session: https://claude.ai/code/session_01KNPMznEzkRpxFZMLisjdqL
266 lines
9.3 KiB
Go
266 lines
9.3 KiB
Go
package taskfile
|
|
|
|
import (
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"net/url"
|
|
"testing"
|
|
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
func TestResolveAuthHeaders(t *testing.T) { //nolint:paralleltest // t.Setenv cannot be used in parallel tests
|
|
tests := []struct {
|
|
name string
|
|
headersByHost HeadersByHost
|
|
host string
|
|
env map[string]string
|
|
want map[string]string
|
|
wantErr string
|
|
}{
|
|
{
|
|
name: "no configuration",
|
|
headersByHost: nil,
|
|
host: "gitlab.com",
|
|
},
|
|
{
|
|
name: "host does not match",
|
|
headersByHost: HeadersByHost{"gitlab.com": {"PRIVATE-TOKEN": "token"}},
|
|
host: "example.com",
|
|
},
|
|
{
|
|
name: "port is part of the host",
|
|
headersByHost: HeadersByHost{"example.com": {"PRIVATE-TOKEN": "token"}},
|
|
host: "example.com:8080",
|
|
},
|
|
{
|
|
name: "literal value",
|
|
headersByHost: HeadersByHost{"gitlab.com": {"PRIVATE-TOKEN": "token"}},
|
|
host: "gitlab.com",
|
|
want: map[string]string{"PRIVATE-TOKEN": "token"},
|
|
},
|
|
{
|
|
name: "environment variable",
|
|
headersByHost: HeadersByHost{"gitlab.com": {"PRIVATE-TOKEN": `{{env "TASK_TEST_TOKEN"}}`}}, //nolint:gosec // an env var reference, not a credential
|
|
host: "gitlab.com",
|
|
env: map[string]string{"TASK_TEST_TOKEN": "s3cret"},
|
|
want: map[string]string{"PRIVATE-TOKEN": "s3cret"},
|
|
},
|
|
{
|
|
name: "environment variable inside a longer value",
|
|
headersByHost: HeadersByHost{"gitlab.com": {"Authorization": `Bearer {{env "TASK_TEST_TOKEN"}}`}},
|
|
host: "gitlab.com",
|
|
env: map[string]string{"TASK_TEST_TOKEN": "s3cret"},
|
|
want: map[string]string{"Authorization": "Bearer s3cret"},
|
|
},
|
|
{
|
|
name: "undefined environment variable expands to nothing",
|
|
headersByHost: HeadersByHost{"gitlab.com": {"PRIVATE-TOKEN": `{{env "TASK_TEST_UNSET"}}`}}, //nolint:gosec // an env var reference, not a credential
|
|
host: "gitlab.com",
|
|
want: map[string]string{"PRIVATE-TOKEN": ""},
|
|
},
|
|
{
|
|
name: "functions compose, so Basic auth needs no manual base64",
|
|
headersByHost: HeadersByHost{"gitlab.com": {"Authorization": `Basic {{ printf "%s:%s" (env "TASK_TEST_USER") (env "TASK_TEST_TOKEN") | b64enc }}`}},
|
|
host: "gitlab.com",
|
|
env: map[string]string{"TASK_TEST_USER": "alice", "TASK_TEST_TOKEN": "s3cret"},
|
|
want: map[string]string{"Authorization": "Basic YWxpY2U6czNjcmV0"},
|
|
},
|
|
{
|
|
// The .taskrc is read before any Taskfile, so no variable exists.
|
|
name: "a variable reference resolves to nothing",
|
|
headersByHost: HeadersByHost{"gitlab.com": {"PRIVATE-TOKEN": "{{.TASK_TEST_TOKEN}}"}}, //nolint:gosec // a template, not a credential
|
|
host: "gitlab.com",
|
|
env: map[string]string{"TASK_TEST_TOKEN": "s3cret"},
|
|
want: map[string]string{"PRIVATE-TOKEN": ""},
|
|
},
|
|
{
|
|
name: "a literal value is left untouched",
|
|
headersByHost: HeadersByHost{"gitlab.com": {"PRIVATE-TOKEN": "p$ssw0rd"}}, //nolint:gosec // a test fixture
|
|
host: "gitlab.com",
|
|
want: map[string]string{"PRIVATE-TOKEN": "p$ssw0rd"},
|
|
},
|
|
{
|
|
name: "malformed template",
|
|
headersByHost: HeadersByHost{"gitlab.com": {"PRIVATE-TOKEN": `{{env "TASK_TEST_TOKEN"`}}, //nolint:gosec // a template, not a credential
|
|
host: "gitlab.com",
|
|
wantErr: `remote auth for host "gitlab.com": template: :1: unclosed action`,
|
|
},
|
|
{
|
|
name: "header name with a space",
|
|
headersByHost: HeadersByHost{"gitlab.com": {"PRIVATE TOKEN": "token"}},
|
|
host: "gitlab.com",
|
|
wantErr: `remote auth for host "gitlab.com": invalid header name "PRIVATE TOKEN"`,
|
|
},
|
|
{
|
|
name: "header name outside the HTTP token grammar",
|
|
headersByHost: HeadersByHost{"gitlab.com": {"X-Foo(bar)": "token"}},
|
|
host: "gitlab.com",
|
|
wantErr: `remote auth for host "gitlab.com": invalid header name "X-Foo(bar)"`,
|
|
},
|
|
{
|
|
name: "empty header name",
|
|
headersByHost: HeadersByHost{"gitlab.com": {"": "token"}},
|
|
host: "gitlab.com",
|
|
wantErr: `remote auth for host "gitlab.com": invalid header name ""`,
|
|
},
|
|
}
|
|
|
|
for _, test := range tests {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
for name, value := range test.env {
|
|
t.Setenv(name, value)
|
|
}
|
|
headers, err := resolveAuthHeaders(test.headersByHost, test.host)
|
|
if test.wantErr != "" {
|
|
require.EqualError(t, err, test.wantErr)
|
|
return
|
|
}
|
|
require.NoError(t, err)
|
|
assert.Equal(t, test.want, headers)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestAuthTransport(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
transport := &authTransport{
|
|
base: roundTripperFunc(func(req *http.Request) (*http.Response, error) { return newResponse(req), nil }),
|
|
host: "gitlab.com",
|
|
headers: map[string]string{"PRIVATE-TOKEN": "token"},
|
|
}
|
|
|
|
t.Run("sets the headers on the configured host", func(t *testing.T) {
|
|
t.Parallel()
|
|
req := newRequest(t, "https://gitlab.com/api/v4/Taskfile.yml")
|
|
resp, err := transport.RoundTrip(req)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, "token", resp.Request.Header.Get("PRIVATE-TOKEN"))
|
|
// The transport must leave the request it was given untouched.
|
|
assert.Empty(t, req.Header.Get("PRIVATE-TOKEN"))
|
|
})
|
|
|
|
t.Run("leaves any other host alone", func(t *testing.T) {
|
|
t.Parallel()
|
|
req := newRequest(t, "https://example.com/Taskfile.yml")
|
|
resp, err := transport.RoundTrip(req)
|
|
require.NoError(t, err)
|
|
assert.Empty(t, resp.Request.Header.Get("PRIVATE-TOKEN"))
|
|
})
|
|
}
|
|
|
|
func TestWithAuthHeadersDoesNotMutateTheDefaultClient(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
client := withAuthHeaders(http.DefaultClient, "gitlab.com", map[string]string{"PRIVATE-TOKEN": "token"})
|
|
|
|
assert.NotSame(t, http.DefaultClient, client)
|
|
assert.Nil(t, http.DefaultClient.Transport)
|
|
assert.IsType(t, &authTransport{}, client.Transport)
|
|
}
|
|
|
|
// Both requests must carry the headers: RemoteExists probes with HEAD before
|
|
// ReadContext issues the GET.
|
|
func TestHTTPNodeAuthHeaders(t *testing.T) { //nolint:paralleltest // t.Setenv cannot be used in parallel tests
|
|
var methods []string
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.Header.Get("PRIVATE-TOKEN") != "s3cret" {
|
|
w.WriteHeader(http.StatusUnauthorized)
|
|
return
|
|
}
|
|
methods = append(methods, r.Method)
|
|
w.Header().Set("Content-Type", "text/yaml")
|
|
_, _ = w.Write([]byte("version: '3'\n"))
|
|
}))
|
|
defer srv.Close()
|
|
|
|
t.Setenv("TASK_TEST_TOKEN", "s3cret")
|
|
node, err := NewHTTPNode(srv.URL+"/Taskfile.yml", "", true,
|
|
WithAuthHeaders(HeadersByHost{
|
|
mustHost(t, srv.URL): {"PRIVATE-TOKEN": `{{env "TASK_TEST_TOKEN"}}`}, //nolint:gosec // an env var reference, not a credential
|
|
}),
|
|
)
|
|
require.NoError(t, err)
|
|
|
|
b, err := node.Read()
|
|
require.NoError(t, err)
|
|
assert.Equal(t, "version: '3'\n", string(b))
|
|
assert.Equal(t, []string{"HEAD", "GET"}, methods)
|
|
}
|
|
|
|
// A server bouncing the request must not get the credentials forwarded to it.
|
|
func TestHTTPNodeAuthHeadersNotSentOnRedirect(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
var received []string
|
|
elsewhere := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
received = append(received, r.Header.Get("PRIVATE-TOKEN"))
|
|
w.Header().Set("Content-Type", "text/yaml")
|
|
_, _ = w.Write([]byte("version: '3'\n"))
|
|
}))
|
|
defer elsewhere.Close()
|
|
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
http.Redirect(w, r, elsewhere.URL+"/Taskfile.yml", http.StatusFound)
|
|
}))
|
|
defer srv.Close()
|
|
|
|
node, err := NewHTTPNode(srv.URL+"/Taskfile.yml", "", true,
|
|
WithAuthHeaders(HeadersByHost{
|
|
mustHost(t, srv.URL): {"PRIVATE-TOKEN": "s3cret"},
|
|
}),
|
|
)
|
|
require.NoError(t, err)
|
|
|
|
_, err = node.Read()
|
|
require.NoError(t, err)
|
|
require.NotEmpty(t, received)
|
|
for _, header := range received {
|
|
assert.Empty(t, header, "the token must not follow a redirect to another host")
|
|
}
|
|
}
|
|
|
|
// A node must build without the credentials it would need to download, so that
|
|
// cached and offline runs do not require them.
|
|
func TestHTTPNodeAuthHeadersResolvedLazily(t *testing.T) { //nolint:paralleltest // t.Setenv cannot be used in parallel tests
|
|
node, err := NewHTTPNode("https://gitlab.com/Taskfile.yml", "", false,
|
|
WithAuthHeaders(HeadersByHost{
|
|
"gitlab.com": {"PRIVATE-TOKEN": `{{env "TASK_TEST_LAZY"}}`}, //nolint:gosec // an env var reference, not a credential
|
|
}),
|
|
)
|
|
require.NoError(t, err)
|
|
|
|
// Defined only after the node was built: the value must still be picked up.
|
|
t.Setenv("TASK_TEST_LAZY", "s3cret")
|
|
|
|
headers, err := resolveAuthHeaders(node.authHeadersByHost, node.url.Host)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, map[string]string{"PRIVATE-TOKEN": "s3cret"}, headers)
|
|
}
|
|
|
|
type roundTripperFunc func(*http.Request) (*http.Response, error)
|
|
|
|
func (f roundTripperFunc) RoundTrip(req *http.Request) (*http.Response, error) {
|
|
return f(req)
|
|
}
|
|
|
|
func newRequest(t *testing.T, rawURL string) *http.Request {
|
|
t.Helper()
|
|
req, err := http.NewRequestWithContext(t.Context(), http.MethodGet, rawURL, nil)
|
|
require.NoError(t, err)
|
|
return req
|
|
}
|
|
|
|
func newResponse(req *http.Request) *http.Response {
|
|
return &http.Response{StatusCode: http.StatusOK, Request: req, Header: http.Header{}}
|
|
}
|
|
|
|
func mustHost(t *testing.T, rawURL string) string {
|
|
t.Helper()
|
|
parsed, err := url.Parse(rawURL)
|
|
require.NoError(t, err)
|
|
return parsed.Host
|
|
}
|