| //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) | |
| } | |
| } |