diff --git a/tests/common/user_test.go b/tests/common/user_test.go new file mode 100644 index 00000000000..a1f7dbb24bb --- /dev/null +++ b/tests/common/user_test.go @@ -0,0 +1,366 @@ +// Copyright 2022 The etcd Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package common + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" + "go.etcd.io/etcd/tests/v3/framework/config" + "go.etcd.io/etcd/tests/v3/framework/testutils" +) + +func TestUserAdd_Simple(t *testing.T) { + testRunner.BeforeTest(t) + tcs := []struct { + name string + config config.ClusterConfig + }{ + { + name: "NoTLS", + config: config.ClusterConfig{ClusterSize: 1}, + }, + { + name: "PeerTLS", + config: config.ClusterConfig{ClusterSize: 3, PeerTLS: config.ManualTLS}, + }, + { + name: "PeerAutoTLS", + config: config.ClusterConfig{ClusterSize: 3, PeerTLS: config.AutoTLS}, + }, + { + name: "ClientTLS", + config: config.ClusterConfig{ClusterSize: 1, ClientTLS: config.ManualTLS}, + }, + { + name: "ClientAutoTLS", + config: config.ClusterConfig{ClusterSize: 1, ClientTLS: config.AutoTLS}, + }, + } + for _, tc := range tcs { + nestedCases := []struct { + name string + username string + password string + noPassword bool + expectedError string + }{ + { + name: "empty_username_not_allowed", + username: "", + password: "foobar", + // Very Vague error expectation because the CLI and the API return very + // different error structures. + expectedError: "user name", + }, + { + // Can create a user with no password, restricted to CN auth + name: "no_password_with_noPassword_set", + username: "foo", + password: "", + noPassword: true, + }, + { + // Can create a user with no password, but not restricted to CN auth + name: "no_password_without_noPassword_set", + username: "foo", + password: "", + noPassword: false, + }, + { + name: "regular_user_with_password", + username: "foo", + password: "bar", + }, + } + for _, nc := range nestedCases { + t.Run(tc.name+"/"+nc.name, func(t *testing.T) { + clus := testRunner.NewCluster(t, tc.config) + defer clus.Close() + cc := clus.Client() + + testutils.ExecuteWithTimeout(t, 10*time.Second, func() { + resp, err := cc.UserAdd(nc.username, nc.password, config.UserAddOptions{NoPassword: nc.noPassword}) + if nc.expectedError != "" { + if err != nil { + assert.Contains(t, err.Error(), nc.expectedError) + return + } + + t.Fatalf("expected user creation to fail") + } + + if err != nil { + t.Fatalf("expected no error, err: %v", err) + } + + if resp == nil { + t.Fatalf("unexpected nil response to successful user creation") + } + }) + }) + } + } +} + +func TestUserAdd_DuplicateUserNotAllowed(t *testing.T) { + testRunner.BeforeTest(t) + tcs := []struct { + name string + config config.ClusterConfig + }{ + { + name: "NoTLS", + config: config.ClusterConfig{ClusterSize: 1}, + }, + { + name: "PeerTLS", + config: config.ClusterConfig{ClusterSize: 3, PeerTLS: config.ManualTLS}, + }, + { + name: "PeerAutoTLS", + config: config.ClusterConfig{ClusterSize: 3, PeerTLS: config.AutoTLS}, + }, + { + name: "ClientTLS", + config: config.ClusterConfig{ClusterSize: 1, ClientTLS: config.ManualTLS}, + }, + { + name: "ClientAutoTLS", + config: config.ClusterConfig{ClusterSize: 1, ClientTLS: config.AutoTLS}, + }, + } + for _, tc := range tcs { + t.Run(tc.name, func(t *testing.T) { + clus := testRunner.NewCluster(t, tc.config) + defer clus.Close() + cc := clus.Client() + + testutils.ExecuteWithTimeout(t, 10*time.Second, func() { + user := "barb" + password := "rhubarb" + + _, err := cc.UserAdd(user, password, config.UserAddOptions{}) + if err != nil { + t.Fatalf("first user creation should succeed, err: %v", err) + } + + _, err = cc.UserAdd(user, password, config.UserAddOptions{}) + if err == nil { + t.Fatalf("duplicate user creation should fail") + } + assert.Contains(t, err.Error(), "etcdserver: user name already exists") + }) + }) + } +} + +func TestUserList(t *testing.T) { + testRunner.BeforeTest(t) + tcs := []struct { + name string + config config.ClusterConfig + }{ + { + name: "NoTLS", + config: config.ClusterConfig{ClusterSize: 1}, + }, + { + name: "PeerTLS", + config: config.ClusterConfig{ClusterSize: 3, PeerTLS: config.ManualTLS}, + }, + { + name: "PeerAutoTLS", + config: config.ClusterConfig{ClusterSize: 3, PeerTLS: config.AutoTLS}, + }, + { + name: "ClientTLS", + config: config.ClusterConfig{ClusterSize: 1, ClientTLS: config.ManualTLS}, + }, + { + name: "ClientAutoTLS", + config: config.ClusterConfig{ClusterSize: 1, ClientTLS: config.AutoTLS}, + }, + } + for _, tc := range tcs { + t.Run(tc.name, func(t *testing.T) { + clus := testRunner.NewCluster(t, tc.config) + defer clus.Close() + cc := clus.Client() + + testutils.ExecuteWithTimeout(t, 10*time.Second, func() { + // No Users Yet + resp, err := cc.UserList() + if err != nil { + t.Fatalf("user listing should succeed, err: %v", err) + } + if len(resp.Users) != 0 { + t.Fatalf("expected no pre-existing users, found: %q", resp.Users) + } + + user := "barb" + password := "rhubarb" + + _, err = cc.UserAdd(user, password, config.UserAddOptions{}) + if err != nil { + t.Fatalf("user creation should succeed, err: %v", err) + } + + // Users! + resp, err = cc.UserList() + if err != nil { + t.Fatalf("user listing should succeed, err: %v", err) + } + if len(resp.Users) != 1 { + t.Fatalf("expected one user, found: %q", resp.Users) + } + }) + }) + } +} + +func TestUserDelete(t *testing.T) { + testRunner.BeforeTest(t) + tcs := []struct { + name string + config config.ClusterConfig + }{ + { + name: "NoTLS", + config: config.ClusterConfig{ClusterSize: 1}, + }, + { + name: "PeerTLS", + config: config.ClusterConfig{ClusterSize: 3, PeerTLS: config.ManualTLS}, + }, + { + name: "PeerAutoTLS", + config: config.ClusterConfig{ClusterSize: 3, PeerTLS: config.AutoTLS}, + }, + { + name: "ClientTLS", + config: config.ClusterConfig{ClusterSize: 1, ClientTLS: config.ManualTLS}, + }, + { + name: "ClientAutoTLS", + config: config.ClusterConfig{ClusterSize: 1, ClientTLS: config.AutoTLS}, + }, + } + for _, tc := range tcs { + t.Run(tc.name, func(t *testing.T) { + clus := testRunner.NewCluster(t, tc.config) + defer clus.Close() + cc := clus.Client() + + testutils.ExecuteWithTimeout(t, 10*time.Second, func() { + user := "barb" + password := "rhubarb" + + _, err := cc.UserAdd(user, password, config.UserAddOptions{}) + if err != nil { + t.Fatalf("user creation should succeed, err: %v", err) + } + + resp, err := cc.UserList() + if err != nil { + t.Fatalf("user listing should succeed, err: %v", err) + } + if len(resp.Users) != 1 { + t.Fatalf("expected one user, found: %q", resp.Users) + } + + // Delete barb, sorry barb! + _, err = cc.UserDelete(user) + if err != nil { + t.Fatalf("user deletion should succeed at first, err: %v", err) + } + + resp, err = cc.UserList() + if err != nil { + t.Fatalf("user listing should succeed, err: %v", err) + } + if len(resp.Users) != 0 { + t.Fatalf("expected no users after deletion, found: %q", resp.Users) + } + + // Try to delete barb again + _, err = cc.UserDelete(user) + if err == nil { + t.Fatalf("deleting a non-existent user should fail") + } + assert.Contains(t, err.Error(), "user name not found") + }) + }) + } +} + +func TestUserChangePassword(t *testing.T) { + testRunner.BeforeTest(t) + tcs := []struct { + name string + config config.ClusterConfig + }{ + { + name: "NoTLS", + config: config.ClusterConfig{ClusterSize: 1}, + }, + { + name: "PeerTLS", + config: config.ClusterConfig{ClusterSize: 3, PeerTLS: config.ManualTLS}, + }, + { + name: "PeerAutoTLS", + config: config.ClusterConfig{ClusterSize: 3, PeerTLS: config.AutoTLS}, + }, + { + name: "ClientTLS", + config: config.ClusterConfig{ClusterSize: 1, ClientTLS: config.ManualTLS}, + }, + { + name: "ClientAutoTLS", + config: config.ClusterConfig{ClusterSize: 1, ClientTLS: config.AutoTLS}, + }, + } + for _, tc := range tcs { + t.Run(tc.name, func(t *testing.T) { + clus := testRunner.NewCluster(t, tc.config) + defer clus.Close() + cc := clus.Client() + + testutils.ExecuteWithTimeout(t, 10*time.Second, func() { + user := "barb" + password := "rhubarb" + newPassword := "potato" + + _, err := cc.UserAdd(user, password, config.UserAddOptions{}) + if err != nil { + t.Fatalf("user creation should succeed, err: %v", err) + } + + err = cc.UserChangePass(user, newPassword) + if err != nil { + t.Fatalf("user password change should succeed, err: %v", err) + } + + err = cc.UserChangePass("non-existent-user", newPassword) + if err == nil { + t.Fatalf("user password change for non-existent user should fail") + } + assert.Contains(t, err.Error(), "user name not found") + }) + }) + } +} diff --git a/tests/e2e/ctl_v3_auth_test.go b/tests/e2e/ctl_v3_auth_test.go index 6c75f5b0634..8c9980049df 100644 --- a/tests/e2e/ctl_v3_auth_test.go +++ b/tests/e2e/ctl_v3_auth_test.go @@ -1305,3 +1305,24 @@ func ctlV3EndpointHealth(cx ctlCtx) error { } return e2e.SpawnWithExpects(cmdArgs, cx.envMap, lines...) } + +func ctlV3User(cx ctlCtx, args []string, expStr string, stdIn []string) error { + cmdArgs := append(cx.PrefixArgs(), "user") + cmdArgs = append(cmdArgs, args...) + + proc, err := e2e.SpawnCmd(cmdArgs, cx.envMap) + if err != nil { + return err + } + defer proc.Close() + + // Send 'stdIn' strings as input. + for _, s := range stdIn { + if err = proc.Send(s + "\r"); err != nil { + return err + } + } + + _, err = proc.Expect(expStr) + return err +} diff --git a/tests/e2e/ctl_v3_user_test.go b/tests/e2e/ctl_v3_user_test.go deleted file mode 100644 index 1bda2045e79..00000000000 --- a/tests/e2e/ctl_v3_user_test.go +++ /dev/null @@ -1,209 +0,0 @@ -// Copyright 2016 The etcd Authors -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package e2e - -import ( - "testing" - - "go.etcd.io/etcd/tests/v3/framework/e2e" -) - -func TestCtlV3UserAdd(t *testing.T) { testCtl(t, userAddTest) } -func TestCtlV3UserAddNoTLS(t *testing.T) { testCtl(t, userAddTest, withCfg(*e2e.NewConfigNoTLS())) } -func TestCtlV3UserAddClientTLS(t *testing.T) { - testCtl(t, userAddTest, withCfg(*e2e.NewConfigClientTLS())) -} -func TestCtlV3UserAddPeerTLS(t *testing.T) { testCtl(t, userAddTest, withCfg(*e2e.NewConfigPeerTLS())) } -func TestCtlV3UserAddTimeout(t *testing.T) { testCtl(t, userAddTest, withDialTimeout(0)) } -func TestCtlV3UserAddClientAutoTLS(t *testing.T) { - testCtl(t, userAddTest, withCfg(*e2e.NewConfigClientAutoTLS())) -} -func TestCtlV3UserList(t *testing.T) { testCtl(t, userListTest) } -func TestCtlV3UserListNoTLS(t *testing.T) { testCtl(t, userListTest, withCfg(*e2e.NewConfigNoTLS())) } -func TestCtlV3UserListClientTLS(t *testing.T) { - testCtl(t, userListTest, withCfg(*e2e.NewConfigClientTLS())) -} -func TestCtlV3UserListPeerTLS(t *testing.T) { - testCtl(t, userListTest, withCfg(*e2e.NewConfigPeerTLS())) -} -func TestCtlV3UserListClientAutoTLS(t *testing.T) { - testCtl(t, userListTest, withCfg(*e2e.NewConfigClientAutoTLS())) -} -func TestCtlV3UserDelete(t *testing.T) { testCtl(t, userDelTest) } -func TestCtlV3UserDeleteNoTLS(t *testing.T) { testCtl(t, userDelTest, withCfg(*e2e.NewConfigNoTLS())) } -func TestCtlV3UserDeleteClientTLS(t *testing.T) { - testCtl(t, userDelTest, withCfg(*e2e.NewConfigClientTLS())) -} -func TestCtlV3UserDeletePeerTLS(t *testing.T) { - testCtl(t, userDelTest, withCfg(*e2e.NewConfigPeerTLS())) -} -func TestCtlV3UserDeleteClientAutoTLS(t *testing.T) { - testCtl(t, userDelTest, withCfg(*e2e.NewConfigClientAutoTLS())) -} -func TestCtlV3UserPasswd(t *testing.T) { testCtl(t, userPasswdTest) } -func TestCtlV3UserPasswdNoTLS(t *testing.T) { - testCtl(t, userPasswdTest, withCfg(*e2e.NewConfigNoTLS())) -} -func TestCtlV3UserPasswdClientTLS(t *testing.T) { - testCtl(t, userPasswdTest, withCfg(*e2e.NewConfigClientTLS())) -} -func TestCtlV3UserPasswdPeerTLS(t *testing.T) { - testCtl(t, userPasswdTest, withCfg(*e2e.NewConfigPeerTLS())) -} -func TestCtlV3UserPasswdClientAutoTLS(t *testing.T) { - testCtl(t, userPasswdTest, withCfg(*e2e.NewConfigClientAutoTLS())) -} - -type userCmdDesc struct { - args []string - expectedStr string - stdIn []string -} - -func userAddTest(cx ctlCtx) { - cmdSet := []userCmdDesc{ - // Adds a user name. - { - args: []string{"add", "username", "--interactive=false"}, - expectedStr: "User username created", - stdIn: []string{"password"}, - }, - // Adds a user name using the usertest:password syntax. - { - args: []string{"add", "usertest:password"}, - expectedStr: "User usertest created", - stdIn: []string{}, - }, - // Tries to add a user with empty username. - { - args: []string{"add", ":password"}, - expectedStr: "empty user name is not allowed", - stdIn: []string{}, - }, - // Tries to add a user name that already exists. - { - args: []string{"add", "username", "--interactive=false"}, - expectedStr: "user name already exists", - stdIn: []string{"password"}, - }, - // Adds a user without password. - { - args: []string{"add", "userwopasswd", "--no-password"}, - expectedStr: "User userwopasswd created", - stdIn: []string{}, - }, - } - - for i, cmd := range cmdSet { - if err := ctlV3User(cx, cmd.args, cmd.expectedStr, cmd.stdIn); err != nil { - if cx.dialTimeout > 0 && !isGRPCTimedout(err) { - cx.t.Fatalf("userAddTest #%d: ctlV3User error (%v)", i, err) - } - } - } -} - -func userListTest(cx ctlCtx) { - cmdSet := []userCmdDesc{ - // Adds a user name. - { - args: []string{"add", "username", "--interactive=false"}, - expectedStr: "User username created", - stdIn: []string{"password"}, - }, - // List user name - { - args: []string{"list"}, - expectedStr: "username", - }, - } - - for i, cmd := range cmdSet { - if err := ctlV3User(cx, cmd.args, cmd.expectedStr, cmd.stdIn); err != nil { - cx.t.Fatalf("userListTest #%d: ctlV3User error (%v)", i, err) - } - } -} - -func userDelTest(cx ctlCtx) { - cmdSet := []userCmdDesc{ - // Adds a user name. - { - args: []string{"add", "username", "--interactive=false"}, - expectedStr: "User username created", - stdIn: []string{"password"}, - }, - // Deletes the user name just added. - { - args: []string{"delete", "username"}, - expectedStr: "User username deleted", - }, - // Deletes a user name that is not present. - { - args: []string{"delete", "username"}, - expectedStr: "user name not found", - }, - } - - for i, cmd := range cmdSet { - if err := ctlV3User(cx, cmd.args, cmd.expectedStr, cmd.stdIn); err != nil { - cx.t.Fatalf("userDelTest #%d: ctlV3User error (%v)", i, err) - } - } -} - -func userPasswdTest(cx ctlCtx) { - cmdSet := []userCmdDesc{ - // Adds a user name. - { - args: []string{"add", "username", "--interactive=false"}, - expectedStr: "User username created", - stdIn: []string{"password"}, - }, - // Changes the password. - { - args: []string{"passwd", "username", "--interactive=false"}, - expectedStr: "Password updated", - stdIn: []string{"password1"}, - }, - } - - for i, cmd := range cmdSet { - if err := ctlV3User(cx, cmd.args, cmd.expectedStr, cmd.stdIn); err != nil { - cx.t.Fatalf("userPasswdTest #%d: ctlV3User error (%v)", i, err) - } - } -} - -func ctlV3User(cx ctlCtx, args []string, expStr string, stdIn []string) error { - cmdArgs := append(cx.PrefixArgs(), "user") - cmdArgs = append(cmdArgs, args...) - - proc, err := e2e.SpawnCmd(cmdArgs, cx.envMap) - if err != nil { - return err - } - defer proc.Close() - - // Send 'stdIn' strings as input. - for _, s := range stdIn { - if err = proc.Send(s + "\r"); err != nil { - return err - } - } - - _, err = proc.Expect(expStr) - return err -} diff --git a/tests/framework/config/client.go b/tests/framework/config/client.go index ef2ef9c273c..ffbd8825abe 100644 --- a/tests/framework/config/client.go +++ b/tests/framework/config/client.go @@ -54,3 +54,7 @@ type DefragOption struct { type LeaseOption struct { WithAttachedKeys bool } + +type UserAddOptions struct { + NoPassword bool +} diff --git a/tests/framework/e2e/etcdctl.go b/tests/framework/e2e/etcdctl.go index dd5f57e3502..d7629c2fbb9 100644 --- a/tests/framework/e2e/etcdctl.go +++ b/tests/framework/e2e/etcdctl.go @@ -375,3 +375,90 @@ func (ctl *EtcdctlV3) AlarmDisarm(_ *clientv3.AlarmMember) (*clientv3.AlarmRespo err = json.Unmarshal([]byte(line), &resp) return &resp, err } + +func (ctl *EtcdctlV3) UserAdd(name, password string, opts config.UserAddOptions) (*clientv3.AuthUserAddResponse, error) { + args := ctl.cmdArgs() + args = append(args, "user", "add") + if password == "" { + args = append(args, name) + } else { + args = append(args, fmt.Sprintf("%s:%s", name, password)) + } + + if opts.NoPassword { + args = append(args, "--no-password") + } + + args = append(args, "--interactive=false", "-w", "json") + + cmd, err := SpawnCmd(args, nil) + if err != nil { + return nil, err + } + + // If no password is provided, and NoPassword isn't set, the CLI will always + // wait for a password, send an enter in this case for an "empty" password. + if !opts.NoPassword && password == "" { + err := cmd.Send("\n") + if err != nil { + return nil, err + } + } + + var resp clientv3.AuthUserAddResponse + line, err := cmd.Expect("header") + if err != nil { + return nil, err + } + err = json.Unmarshal([]byte(line), &resp) + return &resp, err +} + +func (ctl *EtcdctlV3) UserList() (*clientv3.AuthUserListResponse, error) { + args := ctl.cmdArgs() + args = append(args, "user", "list", "-w", "json") + cmd, err := SpawnCmd(args, nil) + if err != nil { + return nil, err + } + var resp clientv3.AuthUserListResponse + line, err := cmd.Expect("header") + if err != nil { + return nil, err + } + err = json.Unmarshal([]byte(line), &resp) + return &resp, err +} + +func (ctl *EtcdctlV3) UserDelete(name string) (*clientv3.AuthUserDeleteResponse, error) { + args := ctl.cmdArgs() + args = append(args, "user", "delete", name, "-w", "json") + cmd, err := SpawnCmd(args, nil) + if err != nil { + return nil, err + } + var resp clientv3.AuthUserDeleteResponse + line, err := cmd.Expect("header") + if err != nil { + return nil, err + } + err = json.Unmarshal([]byte(line), &resp) + return &resp, err +} + +func (ctl *EtcdctlV3) UserChangePass(user, newPass string) error { + args := ctl.cmdArgs() + args = append(args, "user", "passwd", user, "--interactive=false") + cmd, err := SpawnCmd(args, nil) + if err != nil { + return err + } + + err = cmd.Send(newPass + "\n") + if err != nil { + return err + } + + _, err = cmd.Expect("Password updated") + return err +} diff --git a/tests/framework/integration.go b/tests/framework/integration.go index f5867da4633..38bca9618c0 100644 --- a/tests/framework/integration.go +++ b/tests/framework/integration.go @@ -259,3 +259,25 @@ func (c integrationClient) LeaseRevoke(id clientv3.LeaseID) (*clientv3.LeaseRevo return c.Client.Revoke(ctx, id) } + +func (c integrationClient) UserAdd(name, password string, opts config.UserAddOptions) (*clientv3.AuthUserAddResponse, error) { + ctx := context.Background() + return c.Client.UserAddWithOptions(ctx, name, password, &clientv3.UserAddOptions{ + NoPassword: opts.NoPassword, + }) +} + +func (c integrationClient) UserList() (*clientv3.AuthUserListResponse, error) { + ctx := context.Background() + return c.Client.UserList(ctx) +} + +func (c integrationClient) UserDelete(name string) (*clientv3.AuthUserDeleteResponse, error) { + ctx := context.Background() + return c.Client.UserDelete(ctx, name) +} + +func (c integrationClient) UserChangePass(user, newPass string) error { + _, err := c.Client.UserChangePassword(context.Background(), user, newPass) + return err +} diff --git a/tests/framework/interface.go b/tests/framework/interface.go index 8460483dd57..55e51d58c79 100644 --- a/tests/framework/interface.go +++ b/tests/framework/interface.go @@ -48,4 +48,9 @@ type Client interface { LeaseList() (*clientv3.LeaseLeasesResponse, error) LeaseKeepAliveOnce(id clientv3.LeaseID) (*clientv3.LeaseKeepAliveResponse, error) LeaseRevoke(id clientv3.LeaseID) (*clientv3.LeaseRevokeResponse, error) + + UserAdd(name, password string, opts config.UserAddOptions) (*clientv3.AuthUserAddResponse, error) + UserList() (*clientv3.AuthUserListResponse, error) + UserDelete(name string) (*clientv3.AuthUserDeleteResponse, error) + UserChangePass(user, newPass string) error }