Files
task/taskfile/http_auth_test.go
Valentin Maerten a41df4a127 fix(remote): report a 401 instead of a missing Taskfile
RemoteExists treated every non-200 as an absent file, so a server
refusing the credentials ended up as "No Taskfile found", sending the
user to check the URL rather than the token. A 401 now stops the search
and reports the status code; the default names need the same credentials,
so trying them would only add rejected requests. A 403 is left alone: it
is also what a server without directory listing answers for a readable
directory.

That message being correct, the expansion no longer needs to refuse an
undefined variable: os.ExpandEnv is inlined and expandEnv is gone. The
`$$` escape goes with it, so a literal value can no longer hold a `$`
followed by a name; a secret carried in an environment variable is
unaffected, as os.Expand never rescans what it substituted.

Header names are validated with httpguts.ValidHeaderFieldName, the table
net/http itself uses, rather than a denylist that let X-Foo(bar) through.
golang.org/x/net was already in the module graph, so tidy only moves it
to the direct block.

Finally, node_http_auth.go becomes http_auth.go: the node_ prefix is for
files defining a Node type, and this one holds the auth concern of
HTTPNode plus hostMatches, which reader.go uses for trusted hosts.
2026-08-23 12:11:55 +02:00

239 lines
7.7 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
hostHeaders HostHeaders
host string
env map[string]string
want map[string]string
wantErr string
}{
{
name: "no configuration",
hostHeaders: nil,
host: "gitlab.com",
},
{
name: "host does not match",
hostHeaders: HostHeaders{"gitlab.com": {"PRIVATE-TOKEN": "token"}},
host: "example.com",
},
{
name: "port is part of the host",
hostHeaders: HostHeaders{"example.com": {"PRIVATE-TOKEN": "token"}},
host: "example.com:8080",
},
{
name: "literal value",
hostHeaders: HostHeaders{"gitlab.com": {"PRIVATE-TOKEN": "token"}},
host: "gitlab.com",
want: map[string]string{"PRIVATE-TOKEN": "token"},
},
{
name: "braced environment variable",
hostHeaders: HostHeaders{"gitlab.com": {"PRIVATE-TOKEN": "${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",
hostHeaders: HostHeaders{"gitlab.com": {"Authorization": "Bearer $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",
hostHeaders: HostHeaders{"gitlab.com": {"PRIVATE-TOKEN": "${TASK_TEST_UNSET}"}}, //nolint:gosec // an env var reference, not a credential
host: "gitlab.com",
want: map[string]string{"PRIVATE-TOKEN": ""},
},
{
name: "header name with a space",
hostHeaders: HostHeaders{"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",
hostHeaders: HostHeaders{"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",
hostHeaders: HostHeaders{"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.hostHeaders, 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(HostHeaders{
mustHost(t, srv.URL): {"PRIVATE-TOKEN": "${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(HostHeaders{
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(HostHeaders{
"gitlab.com": {"PRIVATE-TOKEN": "${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.authHeaders, 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
}