This adds an internal fork of [github.com/docker/docker/registry], taken at commit [moby@f651a5d]. Git history was not preserved in this fork, but can be found using the URLs provided. This fork was created to remove the dependency on the "Moby" codebase, and because the CLI only needs a subset of its features. The original package was written specifically for use in the daemon code, and includes functionality that cannot be used in the CLI. [github.com/docker/docker/registry]: https://pkg.go.dev/github.com/docker/docker@v28.3.2+incompatible/registry [moby@49306c6]:49306c607b/registrySigned-off-by: Sebastiaan van Stijn <github@gone.nl> (cherry picked from commitf6b90bc253) Signed-off-by: Sebastiaan van Stijn <github@gone.nl>
601 lines
15 KiB
Go
601 lines
15 KiB
Go
package registry
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"path/filepath"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/creack/pty"
|
|
configtypes "github.com/docker/cli/cli/config/types"
|
|
"github.com/docker/cli/cli/streams"
|
|
"github.com/docker/cli/internal/prompt"
|
|
"github.com/docker/cli/internal/registry"
|
|
"github.com/docker/cli/internal/test"
|
|
registrytypes "github.com/docker/docker/api/types/registry"
|
|
"github.com/docker/docker/api/types/system"
|
|
"github.com/docker/docker/client"
|
|
"gotest.tools/v3/assert"
|
|
is "gotest.tools/v3/assert/cmp"
|
|
"gotest.tools/v3/fs"
|
|
)
|
|
|
|
const (
|
|
unknownUser = "userunknownError"
|
|
errUnknownUser = "UNKNOWN_ERR"
|
|
expiredPassword = "I_M_EXPIRED"
|
|
useToken = "I_M_TOKEN"
|
|
)
|
|
|
|
type fakeClient struct {
|
|
client.Client
|
|
}
|
|
|
|
func (*fakeClient) Info(context.Context) (system.Info, error) {
|
|
return system.Info{}, nil
|
|
}
|
|
|
|
func (*fakeClient) RegistryLogin(_ context.Context, auth registrytypes.AuthConfig) (registrytypes.AuthenticateOKBody, error) {
|
|
if auth.Password == expiredPassword {
|
|
return registrytypes.AuthenticateOKBody{}, errors.New("Invalid Username or Password")
|
|
}
|
|
if auth.Password == useToken {
|
|
return registrytypes.AuthenticateOKBody{
|
|
IdentityToken: auth.Password,
|
|
}, nil
|
|
}
|
|
if auth.Username == unknownUser {
|
|
return registrytypes.AuthenticateOKBody{}, errors.New(errUnknownUser)
|
|
}
|
|
return registrytypes.AuthenticateOKBody{}, nil
|
|
}
|
|
|
|
func TestLoginWithCredStoreCreds(t *testing.T) {
|
|
testCases := []struct {
|
|
inputAuthConfig registrytypes.AuthConfig
|
|
expectedErr string
|
|
expectedMsg string
|
|
expectedErrMsg string
|
|
}{
|
|
{
|
|
inputAuthConfig: registrytypes.AuthConfig{},
|
|
},
|
|
{
|
|
inputAuthConfig: registrytypes.AuthConfig{
|
|
Username: unknownUser,
|
|
},
|
|
expectedErr: errUnknownUser,
|
|
expectedErrMsg: fmt.Sprintf("Login did not succeed, error: %s\n", errUnknownUser),
|
|
},
|
|
}
|
|
ctx := context.Background()
|
|
cli := test.NewFakeCli(&fakeClient{})
|
|
cli.ConfigFile().Filename = filepath.Join(t.TempDir(), "config.json")
|
|
for _, tc := range testCases {
|
|
_, err := loginWithStoredCredentials(ctx, cli, tc.inputAuthConfig)
|
|
if tc.expectedErrMsg != "" {
|
|
assert.Check(t, is.Error(err, tc.expectedErr))
|
|
} else {
|
|
assert.NilError(t, err)
|
|
}
|
|
assert.Check(t, is.Equal(tc.expectedMsg, cli.OutBuffer().String()))
|
|
assert.Check(t, is.Contains(cli.ErrBuffer().String(), tc.expectedErrMsg))
|
|
cli.ErrBuffer().Reset()
|
|
cli.OutBuffer().Reset()
|
|
}
|
|
}
|
|
|
|
func TestRunLogin(t *testing.T) {
|
|
testCases := []struct {
|
|
doc string
|
|
priorCredentials map[string]configtypes.AuthConfig
|
|
input loginOptions
|
|
expectedCredentials map[string]configtypes.AuthConfig
|
|
expectedErr string
|
|
}{
|
|
{
|
|
doc: "valid auth from store",
|
|
priorCredentials: map[string]configtypes.AuthConfig{
|
|
"reg1": {
|
|
Username: "my-username",
|
|
Password: "a-password",
|
|
ServerAddress: "reg1",
|
|
},
|
|
},
|
|
input: loginOptions{
|
|
serverAddress: "reg1",
|
|
},
|
|
expectedCredentials: map[string]configtypes.AuthConfig{
|
|
"reg1": {
|
|
Username: "my-username",
|
|
Password: "a-password",
|
|
ServerAddress: "reg1",
|
|
},
|
|
},
|
|
},
|
|
{
|
|
doc: "expired auth from store",
|
|
priorCredentials: map[string]configtypes.AuthConfig{
|
|
"reg1": {
|
|
Username: "my-username",
|
|
Password: expiredPassword,
|
|
ServerAddress: "reg1",
|
|
},
|
|
},
|
|
input: loginOptions{
|
|
serverAddress: "reg1",
|
|
},
|
|
expectedErr: "Error: Cannot perform an interactive login from a non TTY device",
|
|
},
|
|
{
|
|
doc: "store valid username and password",
|
|
priorCredentials: map[string]configtypes.AuthConfig{},
|
|
input: loginOptions{
|
|
serverAddress: "reg1",
|
|
user: "my-username",
|
|
password: "p2",
|
|
},
|
|
expectedCredentials: map[string]configtypes.AuthConfig{
|
|
"reg1": {
|
|
Username: "my-username",
|
|
Password: "p2",
|
|
ServerAddress: "reg1",
|
|
},
|
|
},
|
|
},
|
|
{
|
|
doc: "unknown user w/ prior credentials",
|
|
priorCredentials: map[string]configtypes.AuthConfig{
|
|
"reg1": {
|
|
Username: "my-username",
|
|
Password: "a-password",
|
|
ServerAddress: "reg1",
|
|
},
|
|
},
|
|
input: loginOptions{
|
|
serverAddress: "reg1",
|
|
user: unknownUser,
|
|
password: "a-password",
|
|
},
|
|
expectedErr: errUnknownUser,
|
|
expectedCredentials: map[string]configtypes.AuthConfig{
|
|
"reg1": {
|
|
Username: "a-password",
|
|
Password: "a-password",
|
|
ServerAddress: "reg1",
|
|
},
|
|
},
|
|
},
|
|
{
|
|
doc: "unknown user w/o prior credentials",
|
|
priorCredentials: map[string]configtypes.AuthConfig{},
|
|
input: loginOptions{
|
|
serverAddress: "reg1",
|
|
user: unknownUser,
|
|
password: "a-password",
|
|
},
|
|
expectedErr: errUnknownUser,
|
|
expectedCredentials: map[string]configtypes.AuthConfig{},
|
|
},
|
|
{
|
|
doc: "store valid token",
|
|
priorCredentials: map[string]configtypes.AuthConfig{},
|
|
input: loginOptions{
|
|
serverAddress: "reg1",
|
|
user: "my-username",
|
|
password: useToken,
|
|
},
|
|
expectedCredentials: map[string]configtypes.AuthConfig{
|
|
"reg1": {
|
|
Username: "my-username",
|
|
IdentityToken: useToken,
|
|
ServerAddress: "reg1",
|
|
},
|
|
},
|
|
},
|
|
{
|
|
doc: "valid token from store",
|
|
priorCredentials: map[string]configtypes.AuthConfig{
|
|
"reg1": {
|
|
Username: "my-username",
|
|
Password: useToken,
|
|
ServerAddress: "reg1",
|
|
},
|
|
},
|
|
input: loginOptions{
|
|
serverAddress: "reg1",
|
|
},
|
|
expectedCredentials: map[string]configtypes.AuthConfig{
|
|
"reg1": {
|
|
Username: "my-username",
|
|
IdentityToken: useToken,
|
|
ServerAddress: "reg1",
|
|
},
|
|
},
|
|
},
|
|
{
|
|
doc: "no registry specified defaults to index server",
|
|
priorCredentials: map[string]configtypes.AuthConfig{},
|
|
input: loginOptions{
|
|
user: "my-username",
|
|
password: "my-password",
|
|
},
|
|
expectedCredentials: map[string]configtypes.AuthConfig{
|
|
registry.IndexServer: {
|
|
Username: "my-username",
|
|
Password: "my-password",
|
|
ServerAddress: registry.IndexServer,
|
|
},
|
|
},
|
|
},
|
|
{
|
|
doc: "registry-1.docker.io",
|
|
priorCredentials: map[string]configtypes.AuthConfig{},
|
|
input: loginOptions{
|
|
serverAddress: "registry-1.docker.io",
|
|
user: "my-username",
|
|
password: "my-password",
|
|
},
|
|
expectedCredentials: map[string]configtypes.AuthConfig{
|
|
"registry-1.docker.io": {
|
|
Username: "my-username",
|
|
Password: "my-password",
|
|
ServerAddress: "registry-1.docker.io",
|
|
},
|
|
},
|
|
},
|
|
// Regression test for https://github.com/docker/cli/issues/5382
|
|
{
|
|
doc: "sanitizes server address to remove repo",
|
|
priorCredentials: map[string]configtypes.AuthConfig{},
|
|
input: loginOptions{
|
|
serverAddress: "registry-1.docker.io/bork/test",
|
|
user: "my-username",
|
|
password: "a-password",
|
|
},
|
|
expectedCredentials: map[string]configtypes.AuthConfig{
|
|
"registry-1.docker.io": {
|
|
Username: "my-username",
|
|
Password: "a-password",
|
|
ServerAddress: "registry-1.docker.io",
|
|
},
|
|
},
|
|
},
|
|
// Regression test for https://github.com/docker/cli/issues/5382
|
|
{
|
|
doc: "updates credential if server address includes repo",
|
|
priorCredentials: map[string]configtypes.AuthConfig{
|
|
"registry-1.docker.io": {
|
|
Username: "my-username",
|
|
Password: "a-password",
|
|
ServerAddress: "registry-1.docker.io",
|
|
},
|
|
},
|
|
input: loginOptions{
|
|
serverAddress: "registry-1.docker.io/bork/test",
|
|
user: "my-username",
|
|
password: "new-password",
|
|
},
|
|
expectedCredentials: map[string]configtypes.AuthConfig{
|
|
"registry-1.docker.io": {
|
|
Username: "my-username",
|
|
Password: "new-password",
|
|
ServerAddress: "registry-1.docker.io",
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
for _, tc := range testCases {
|
|
t.Run(tc.doc, func(t *testing.T) {
|
|
tmpFile := fs.NewFile(t, "test-run-login")
|
|
defer tmpFile.Remove()
|
|
cli := test.NewFakeCli(&fakeClient{})
|
|
configfile := cli.ConfigFile()
|
|
configfile.Filename = tmpFile.Path()
|
|
|
|
for _, priorCred := range tc.priorCredentials {
|
|
assert.NilError(t, configfile.GetCredentialsStore(priorCred.ServerAddress).Store(priorCred))
|
|
}
|
|
storedCreds, err := configfile.GetAllCredentials()
|
|
assert.NilError(t, err)
|
|
assert.DeepEqual(t, storedCreds, tc.priorCredentials)
|
|
|
|
loginErr := runLogin(context.Background(), cli, tc.input)
|
|
if tc.expectedErr != "" {
|
|
assert.Error(t, loginErr, tc.expectedErr)
|
|
return
|
|
}
|
|
assert.NilError(t, loginErr)
|
|
|
|
outputCreds, err := configfile.GetAllCredentials()
|
|
assert.Check(t, err)
|
|
assert.DeepEqual(t, outputCreds, tc.expectedCredentials)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestLoginNonInteractive(t *testing.T) {
|
|
t.Run("no prior credentials", func(t *testing.T) {
|
|
testCases := []struct {
|
|
doc string
|
|
username bool
|
|
password bool
|
|
expectedErr string
|
|
}{
|
|
{
|
|
doc: "success - w/ user w/ password",
|
|
username: true,
|
|
password: true,
|
|
},
|
|
{
|
|
doc: "error - w/o user w/o pass ",
|
|
username: false,
|
|
password: false,
|
|
expectedErr: "Error: Cannot perform an interactive login from a non TTY device",
|
|
},
|
|
{
|
|
doc: "error - w/ user w/o pass",
|
|
username: true,
|
|
password: false,
|
|
expectedErr: "Error: Cannot perform an interactive login from a non TTY device",
|
|
},
|
|
{
|
|
doc: "error - w/o user w/ pass",
|
|
username: false,
|
|
password: true,
|
|
expectedErr: "Error: Cannot perform an interactive login from a non TTY device",
|
|
},
|
|
}
|
|
|
|
// "" meaning default registry
|
|
registries := []string{"", "my-registry.com"}
|
|
|
|
for _, registryAddr := range registries {
|
|
for _, tc := range testCases {
|
|
t.Run(tc.doc, func(t *testing.T) {
|
|
tmpFile := fs.NewFile(t, "test-run-login")
|
|
defer tmpFile.Remove()
|
|
cli := test.NewFakeCli(&fakeClient{})
|
|
cfg := cli.ConfigFile()
|
|
cfg.Filename = tmpFile.Path()
|
|
options := loginOptions{
|
|
serverAddress: registryAddr,
|
|
}
|
|
if tc.username {
|
|
options.user = "my-username"
|
|
}
|
|
if tc.password {
|
|
options.password = "my-password"
|
|
}
|
|
|
|
loginErr := runLogin(context.Background(), cli, options)
|
|
if tc.expectedErr != "" {
|
|
assert.Error(t, loginErr, tc.expectedErr)
|
|
return
|
|
}
|
|
assert.NilError(t, loginErr)
|
|
})
|
|
}
|
|
}
|
|
})
|
|
|
|
t.Run("w/ prior credentials", func(t *testing.T) {
|
|
testCases := []struct {
|
|
doc string
|
|
username bool
|
|
password bool
|
|
expectedErr string
|
|
}{
|
|
{
|
|
doc: "success - w/ user w/ password",
|
|
username: true,
|
|
password: true,
|
|
},
|
|
{
|
|
doc: "success - w/o user w/o pass ",
|
|
username: false,
|
|
password: false,
|
|
},
|
|
{
|
|
doc: "error - w/ user w/o pass",
|
|
username: true,
|
|
password: false,
|
|
expectedErr: "Error: Cannot perform an interactive login from a non TTY device",
|
|
},
|
|
{
|
|
doc: "error - w/o user w/ pass",
|
|
username: false,
|
|
password: true,
|
|
expectedErr: "Error: Cannot perform an interactive login from a non TTY device",
|
|
},
|
|
}
|
|
|
|
// "" meaning default registry
|
|
registries := []string{"", "my-registry.com"}
|
|
|
|
for _, registryAddr := range registries {
|
|
for _, tc := range testCases {
|
|
t.Run(tc.doc, func(t *testing.T) {
|
|
tmpFile := fs.NewFile(t, "test-run-login")
|
|
defer tmpFile.Remove()
|
|
cli := test.NewFakeCli(&fakeClient{})
|
|
cfg := cli.ConfigFile()
|
|
cfg.Filename = tmpFile.Path()
|
|
serverAddress := registryAddr
|
|
if serverAddress == "" {
|
|
serverAddress = "https://index.docker.io/v1/"
|
|
}
|
|
assert.NilError(t, cfg.GetCredentialsStore(serverAddress).Store(configtypes.AuthConfig{
|
|
Username: "my-username",
|
|
Password: "my-password",
|
|
ServerAddress: serverAddress,
|
|
}))
|
|
|
|
options := loginOptions{
|
|
serverAddress: registryAddr,
|
|
}
|
|
if tc.username {
|
|
options.user = "my-username"
|
|
}
|
|
if tc.password {
|
|
options.password = "my-password"
|
|
}
|
|
|
|
loginErr := runLogin(context.Background(), cli, options)
|
|
if tc.expectedErr != "" {
|
|
assert.Error(t, loginErr, tc.expectedErr)
|
|
return
|
|
}
|
|
assert.NilError(t, loginErr)
|
|
})
|
|
}
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestLoginTermination(t *testing.T) {
|
|
p, tty, err := pty.Open()
|
|
assert.NilError(t, err)
|
|
|
|
t.Cleanup(func() {
|
|
_ = tty.Close()
|
|
_ = p.Close()
|
|
})
|
|
|
|
cli := test.NewFakeCli(&fakeClient{}, func(fc *test.FakeCli) {
|
|
fc.SetOut(streams.NewOut(tty))
|
|
fc.SetIn(streams.NewIn(tty))
|
|
})
|
|
tmpFile := fs.NewFile(t, "test-login-termination")
|
|
defer tmpFile.Remove()
|
|
|
|
configFile := cli.ConfigFile()
|
|
configFile.Filename = tmpFile.Path()
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
t.Cleanup(cancel)
|
|
|
|
runErr := make(chan error)
|
|
go func() {
|
|
runErr <- runLogin(ctx, cli, loginOptions{
|
|
user: "test-user",
|
|
})
|
|
}()
|
|
|
|
// Let the prompt get canceled by the context
|
|
cancel()
|
|
|
|
select {
|
|
case <-time.After(1 * time.Second):
|
|
t.Fatal("timed out after 1 second. `runLogin` did not return")
|
|
case err := <-runErr:
|
|
assert.ErrorIs(t, err, prompt.ErrTerminated)
|
|
}
|
|
}
|
|
|
|
func TestIsOauthLoginDisabled(t *testing.T) {
|
|
testCases := []struct {
|
|
envVar string
|
|
disabled bool
|
|
}{
|
|
{
|
|
envVar: "",
|
|
disabled: false,
|
|
},
|
|
{
|
|
envVar: "bork",
|
|
disabled: false,
|
|
},
|
|
{
|
|
envVar: "0",
|
|
disabled: false,
|
|
},
|
|
{
|
|
envVar: "false",
|
|
disabled: false,
|
|
},
|
|
{
|
|
envVar: "true",
|
|
disabled: true,
|
|
},
|
|
{
|
|
envVar: "TRUE",
|
|
disabled: true,
|
|
},
|
|
{
|
|
envVar: "1",
|
|
disabled: true,
|
|
},
|
|
}
|
|
|
|
for _, tc := range testCases {
|
|
t.Setenv(OauthLoginEscapeHatchEnvVar, tc.envVar)
|
|
|
|
disabled := isOauthLoginDisabled()
|
|
|
|
assert.Equal(t, disabled, tc.disabled)
|
|
}
|
|
}
|
|
|
|
func TestLoginValidateFlags(t *testing.T) {
|
|
for _, tc := range []struct {
|
|
name string
|
|
args []string
|
|
expectedErr string
|
|
}{
|
|
{
|
|
name: "--password-stdin without --username",
|
|
args: []string{"--password-stdin"},
|
|
expectedErr: `the --password-stdin option requires --username to be set`,
|
|
},
|
|
{
|
|
name: "--password-stdin with empty --username",
|
|
args: []string{"--password-stdin", "--username", ""},
|
|
expectedErr: `username is empty`,
|
|
},
|
|
{
|
|
name: "empty --username",
|
|
args: []string{"--username", ""},
|
|
expectedErr: `username is empty`,
|
|
},
|
|
{
|
|
name: "--username without value",
|
|
args: []string{"--username"},
|
|
expectedErr: `flag needs an argument: --username`,
|
|
},
|
|
{
|
|
name: "conflicting options --password-stdin and --password",
|
|
args: []string{"--password-stdin", "--password", ""},
|
|
expectedErr: `conflicting options: cannot specify both --password and --password-stdin`,
|
|
},
|
|
{
|
|
name: "empty --password",
|
|
args: []string{"--password", ""},
|
|
expectedErr: `password is empty`,
|
|
},
|
|
{
|
|
name: "--password without value",
|
|
args: []string{"--password"},
|
|
expectedErr: `flag needs an argument: --password`,
|
|
},
|
|
} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
cmd := NewLoginCommand(test.NewFakeCli(&fakeClient{}))
|
|
cmd.SetOut(io.Discard)
|
|
cmd.SetErr(io.Discard)
|
|
cmd.SetArgs(tc.args)
|
|
|
|
err := cmd.Execute()
|
|
if tc.expectedErr != "" {
|
|
assert.Check(t, is.ErrorContains(err, tc.expectedErr))
|
|
} else {
|
|
assert.Check(t, is.Nil(err))
|
|
}
|
|
})
|
|
}
|
|
}
|