Files
docker-cli/cli/command/registry/login_test.go
Sebastiaan van Stijn 8845ccd60f cli/command/registry: login: add unit test for flag validation
Signed-off-by: Sebastiaan van Stijn <github@gone.nl>
2025-04-25 16:39:55 +02:00

591 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/test"
registrytypes "github.com/docker/docker/api/types/registry"
"github.com/docker/docker/api/types/system"
"github.com/docker/docker/client"
"github.com/docker/docker/registry"
"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: `Must provide --username with --password-stdin`,
},
{
name: "--password-stdin with empty --username",
args: []string{"--password-stdin", "--username", ""},
expectedErr: `Must provide --username with --password-stdin`,
},
{
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: `Must provide --username with --password-stdin`,
},
{
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))
}
})
}
}