blob: c6f0a71eb04fd3910f48de2ef7a372b062841985 [file] [edit]
//go:build go1.26
package secretsengine
import (
"context"
"errors"
"fmt"
"io/fs"
"path/filepath"
"sync"
"sync/atomic"
"testing"
"testing/synctest"
"time"
"github.com/docker/cli/cli/config/types"
"github.com/docker/cli/internal/registry"
seclient "github.com/docker/secrets-engine/client"
"github.com/docker/secrets-engine/client/dockerhub"
"gotest.tools/v3/assert"
)
type fakeHub struct {
session dockerhub.UserSession
sessionErr error
profile dockerhub.Profile
profileErr error
sessionCalls atomic.Int32
}
func (f *fakeHub) GetDefaultSession(context.Context) (dockerhub.UserSession, error) {
f.sessionCalls.Add(1)
return f.session, f.sessionErr
}
func (f *fakeHub) GetDefaultProfile(context.Context) (dockerhub.Profile, error) {
return f.profile, f.profileErr
}
func hubSession(username string, expiresAt time.Time) dockerhub.UserSession {
s := dockerhub.UserSession{AccessToken: "desktop-oauth-token"}
s.Claims.Username = username
if !expiresAt.IsZero() {
s.Claims.ExpiresAt = &dockerhub.NumericDate{Time: expiresAt}
}
return s
}
func inAnHour() time.Time {
return time.Now().Add(time.Hour)
}
type recordingFallback struct {
mu sync.Mutex
hosts []string
}
func (f *recordingFallback) GetAuthConfig(registryHostname string) (types.AuthConfig, error) {
f.mu.Lock()
defer f.mu.Unlock()
f.hosts = append(f.hosts, registryHostname)
return cliCredentials(registryHostname), nil
}
func cliCredentials(registryHostname string) types.AuthConfig {
return types.AuthConfig{Username: "cli-user", Password: "cli-password", ServerAddress: registryHostname}
}
func desktopCredentials() types.AuthConfig {
return types.AuthConfig{Username: "hubuser", Password: "desktop-oauth-token", ServerAddress: registry.IndexServer}
}
func stagingCredentials() types.AuthConfig {
return types.AuthConfig{Username: "stageuser", Password: "staging-oauth-token", ServerAddress: stagingRegistryHost}
}
type testProvider struct {
*provider
fallback *recordingFallback
staging *fakeHub
}
func newTestProvider(hub hubClient) *testProvider {
tp := &testProvider{
fallback: &recordingFallback{},
staging: &fakeHub{sessionErr: dockerhub.ErrNoDefaultProfile},
}
tp.provider = &provider{
fallback: tp.fallback,
hub: &session{name: "Docker Hub", hub: hub},
staging: &session{name: "Docker Hub staging", hub: tp.staging},
}
return tp
}
func (tp *testProvider) signInToStaging() {
tp.staging.sessionErr = nil
tp.staging.session = hubSession("stageuser", inAnHour())
tp.staging.session.AccessToken = "staging-oauth-token"
}
func connectError(reason seclient.ConnectReason) error {
return &seclient.ConnectError{
Reason: reason,
SocketPath: "/run/engine.sock",
Err: errors.New("dial unix /run/engine.sock"),
}
}
func TestNewDesktopUsesSeparateSessions(t *testing.T) {
t.Parallel()
p := NewDesktop(&recordingFallback{}).(*provider)
assert.Assert(t, p.hub != p.staging, "production and staging sessions must be separate")
}
func TestDockerHubUsesDesktopSession(t *testing.T) {
t.Parallel()
for _, host := range []string{"docker.io", "index.docker.io", "registry-1.docker.io", registry.IndexServer} {
t.Run(host, func(t *testing.T) {
t.Parallel()
hub := &fakeHub{session: hubSession("hubuser", inAnHour())}
p := newTestProvider(hub)
got, err := p.GetAuthConfig(host)
assert.NilError(t, err)
assert.DeepEqual(t, got, desktopCredentials())
assert.Equal(t, got.IdentityToken, "", "an IdentityToken is sent as a refresh token")
assert.Equal(t, len(p.fallback.hosts), 0)
})
}
}
func TestOtherRegistriesUseFallback(t *testing.T) {
t.Parallel()
hosts := []string{
"ghcr.io", "localhost:5000", "registry.example.com", "",
stagingRegistryHost + ".example.com", "example.com/" + stagingRegistryHost,
dhiRegistryHost + ".example.com", "example.com/" + dhiRegistryHost, "registry." + dhiRegistryHost,
"docker.io.example.com",
}
for _, host := range hosts {
t.Run(host, func(t *testing.T) {
t.Parallel()
hub := &fakeHub{session: hubSession("hubuser", inAnHour())}
p := newTestProvider(hub)
p.signInToStaging()
got, err := p.GetAuthConfig(host)
assert.NilError(t, err)
assert.DeepEqual(t, got, cliCredentials(host))
assert.DeepEqual(t, p.fallback.hosts, []string{host})
assert.Equal(t, hub.sessionCalls.Load(), int32(0), "Docker Desktop must only be contacted for Docker Hub")
assert.Equal(t, p.staging.sessionCalls.Load(), int32(0), "Docker Desktop must only be contacted for Docker Hub")
})
}
}
func TestDHIUsesDockerHubSession(t *testing.T) {
t.Parallel()
for _, host := range []string{dhiRegistryHost, "https://" + dhiRegistryHost, "https://" + dhiRegistryHost + "/"} {
t.Run(host, func(t *testing.T) {
t.Parallel()
hub := &fakeHub{session: hubSession("hubuser", inAnHour())}
p := newTestProvider(hub)
p.signInToStaging()
want := desktopCredentials()
want.ServerAddress = dhiRegistryHost
got, err := p.GetAuthConfig(host)
assert.NilError(t, err)
assert.DeepEqual(t, got, want)
assert.Equal(t, p.staging.sessionCalls.Load(), int32(0), "the staging session must not be read for DHI")
got, err = p.GetAuthConfig("docker.io")
assert.NilError(t, err)
assert.DeepEqual(t, got, desktopCredentials())
assert.Equal(t, hub.sessionCalls.Load(), int32(1))
})
}
}
func TestStagingRegistryUsesStagingSession(t *testing.T) {
t.Parallel()
for _, host := range []string{stagingRegistryHost, "https://" + stagingRegistryHost + "/"} {
t.Run(host, func(t *testing.T) {
t.Parallel()
hub := &fakeHub{session: hubSession("hubuser", inAnHour())}
p := newTestProvider(hub)
p.signInToStaging()
got, err := p.GetAuthConfig(host)
assert.NilError(t, err)
assert.DeepEqual(t, got, stagingCredentials())
assert.Equal(t, hub.sessionCalls.Load(), int32(0), "the production session must not be read for the staging registry")
})
}
}
func TestEnvironmentsDoNotMix(t *testing.T) {
t.Parallel()
t.Run("staging session is not used for docker.io", func(t *testing.T) {
t.Parallel()
p := newTestProvider(&fakeHub{sessionErr: dockerhub.ErrNoDefaultProfile})
p.signInToStaging()
got, err := p.GetAuthConfig("docker.io")
assert.NilError(t, err)
assert.DeepEqual(t, got, cliCredentials("docker.io"))
got, err = p.GetAuthConfig(stagingRegistryHost)
assert.NilError(t, err)
assert.DeepEqual(t, got, stagingCredentials())
})
t.Run("production session is not used for the staging registry", func(t *testing.T) {
t.Parallel()
p := newTestProvider(&fakeHub{session: hubSession("hubuser", inAnHour())})
got, err := p.GetAuthConfig(stagingRegistryHost)
assert.NilError(t, err)
assert.DeepEqual(t, got, cliCredentials(stagingRegistryHost))
got, err = p.GetAuthConfig("docker.io")
assert.NilError(t, err)
assert.DeepEqual(t, got, desktopCredentials())
})
}
func TestUsernameFromProfile(t *testing.T) {
t.Parallel()
hub := &fakeHub{
session: hubSession("", inAnHour()),
profile: dockerhub.Profile{Username: "hubuser"},
}
p := newTestProvider(hub)
got, err := p.GetAuthConfig("docker.io")
assert.NilError(t, err)
assert.DeepEqual(t, got, desktopCredentials())
}
func TestFallsBackWithoutUsableSession(t *testing.T) {
t.Parallel()
tests := []struct {
name string
hub func() *fakeHub
}{
{name: "secrets engine not running", hub: func() *fakeHub { return &fakeHub{sessionErr: connectError(seclient.ReasonNotRunning)} }},
{name: "secrets engine socket not accessible", hub: func() *fakeHub { return &fakeHub{sessionErr: connectError(seclient.ReasonPermissionDenied)} }},
{name: "nobody signed in", hub: func() *fakeHub { return &fakeHub{sessionErr: dockerhub.ErrNoDefaultProfile} }},
{name: "access denied", hub: func() *fakeHub { return &fakeHub{sessionErr: seclient.ErrAccessDenied} }},
{name: "no username", hub: func() *fakeHub { return &fakeHub{session: hubSession("", inAnHour())} }},
{name: "profile lookup fails", hub: func() *fakeHub {
return &fakeHub{session: hubSession("", inAnHour()), profileErr: errors.New("boom")}
}},
{name: "expired", hub: func() *fakeHub { return &fakeHub{session: hubSession("hubuser", time.Now().Add(-time.Minute))} }},
{name: "expires within the skew", hub: func() *fakeHub {
return &fakeHub{session: hubSession("hubuser", time.Now().Add(sessionExpirySkew/2))}
}},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
synctest.Test(t, func(t *testing.T) {
hub := tc.hub()
p := newTestProvider(hub)
for range 2 {
got, err := p.GetAuthConfig("docker.io")
assert.NilError(t, err)
assert.DeepEqual(t, got, cliCredentials("docker.io"))
}
assert.Equal(t, hub.sessionCalls.Load(), int32(1), "Desktop must not be asked again within retryInterval")
})
})
}
}
func TestDesktopFallsBackWhenClientCannotBeCreated(t *testing.T) {
t.Parallel()
var connects atomic.Int32
s := &session{
name: "Docker Hub",
connect: func() (hubClient, error) {
connects.Add(1)
return nil, errors.New("no socket path")
},
}
p := &provider{fallback: &recordingFallback{}, hub: s}
for range 2 {
got, err := p.GetAuthConfig("docker.io")
assert.NilError(t, err)
assert.DeepEqual(t, got, cliCredentials("docker.io"))
}
assert.Equal(t, connects.Load(), int32(1))
}
func TestRetriesAfterInterval(t *testing.T) {
t.Parallel()
synctest.Test(t, func(t *testing.T) {
hub := &fakeHub{sessionErr: dockerhub.ErrNoDefaultProfile}
p := newTestProvider(hub)
got, err := p.GetAuthConfig("docker.io")
assert.NilError(t, err)
assert.DeepEqual(t, got, cliCredentials("docker.io"))
hub.sessionErr = nil
hub.session = hubSession("hubuser", inAnHour())
time.Sleep(retryInterval - time.Second)
got, err = p.GetAuthConfig("docker.io")
assert.NilError(t, err)
assert.DeepEqual(t, got, cliCredentials("docker.io"))
assert.Equal(t, hub.sessionCalls.Load(), int32(1))
time.Sleep(time.Second)
got, err = p.GetAuthConfig("docker.io")
assert.NilError(t, err)
assert.DeepEqual(t, got, desktopCredentials())
assert.Equal(t, hub.sessionCalls.Load(), int32(2))
})
}
func TestReusesSessionUntilExpiry(t *testing.T) {
t.Parallel()
synctest.Test(t, func(t *testing.T) {
expiresAt := time.Now().Add(10 * time.Minute)
hub := &fakeHub{session: hubSession("hubuser", expiresAt)}
p := newTestProvider(hub)
got, err := p.GetAuthConfig("docker.io")
assert.NilError(t, err)
assert.DeepEqual(t, got, desktopCredentials())
time.Sleep(5 * time.Minute)
got, err = p.GetAuthConfig("docker.io")
assert.NilError(t, err)
assert.DeepEqual(t, got, desktopCredentials())
assert.Equal(t, hub.sessionCalls.Load(), int32(1))
hub.session = hubSession("hubuser", expiresAt.Add(time.Hour))
hub.session.AccessToken = "refreshed-token"
time.Sleep(5*time.Minute - sessionExpirySkew)
got, err = p.GetAuthConfig("docker.io")
assert.NilError(t, err)
assert.Equal(t, got.Password, "refreshed-token")
assert.Equal(t, hub.sessionCalls.Load(), int32(2))
})
}
func TestRefetchesSessionWithoutExpiry(t *testing.T) {
t.Parallel()
synctest.Test(t, func(t *testing.T) {
hub := &fakeHub{session: hubSession("hubuser", time.Time{})}
p := newTestProvider(hub)
got, err := p.GetAuthConfig("docker.io")
assert.NilError(t, err)
assert.DeepEqual(t, got, desktopCredentials())
time.Sleep(sessionTTL - time.Second)
got, err = p.GetAuthConfig("docker.io")
assert.NilError(t, err)
assert.DeepEqual(t, got, desktopCredentials())
assert.Equal(t, hub.sessionCalls.Load(), int32(1))
time.Sleep(time.Second)
got, err = p.GetAuthConfig("docker.io")
assert.NilError(t, err)
assert.DeepEqual(t, got, desktopCredentials())
assert.Equal(t, hub.sessionCalls.Load(), int32(2))
})
}
func TestConcurrentLookupsShareOneFetch(t *testing.T) {
t.Parallel()
hub := &fakeHub{session: hubSession("hubuser", inAnHour())}
p := newTestProvider(hub)
const lookups = 20
results := make([]types.AuthConfig, lookups)
var wg sync.WaitGroup
for i := range lookups {
wg.Go(func() {
auth, err := p.GetAuthConfig("docker.io")
assert.Check(t, err)
results[i] = auth
})
}
wg.Wait()
for _, auth := range results {
assert.DeepEqual(t, auth, desktopCredentials())
}
assert.Equal(t, hub.sessionCalls.Load(), int32(1))
}
func TestConnectSkipsMissingSocket(t *testing.T) {
t.Parallel()
_, err := connect(filepath.Join(t.TempDir(), "engine.sock"))
assert.Assert(t, errors.Is(err, fs.ErrNotExist), err)
assert.Assert(t, isSessionUnavailable(err))
}
func TestIsSessionUnavailable(t *testing.T) {
t.Parallel()
for _, err := range []error{
connectError(seclient.ReasonNotRunning),
fmt.Errorf("get session: %w", connectError(seclient.ReasonNotRunning)),
connectError(seclient.ReasonPermissionDenied),
connectError(seclient.ReasonTimeout),
connectError(seclient.ReasonUnknown),
dockerhub.ErrNoSession,
dockerhub.ErrNoDefaultProfile,
errSessionExpired,
fs.ErrNotExist,
} {
assert.Assert(t, isSessionUnavailable(err), err)
}
for _, err := range []error{
seclient.ErrAccessDenied,
errors.New("no username for the session"),
context.DeadlineExceeded,
} {
assert.Assert(t, !isSessionUnavailable(err), err)
}
}