feat(api): add user creation to user service (#5745)

* chore(proto): update versions

* change protoc plugin

* some cleanups

* define api for setting emails in new api

* implement user.SetEmail

* move SetEmail buisiness logic into command

* resuse newCryptoCode

* command: add ChangeEmail unit tests

Not complete, was not able to mock the generator.

* Revert "resuse newCryptoCode"

This reverts commit c89e90ae35.

* undo change to crypto code generators

* command: use a generator so we can test properly

* command: reorganise ChangeEmail

improve test coverage

* implement VerifyEmail

including unit tests

* add URL template tests

* begin user creation

* change protos

* implement metadata and move context

* merge commands

* proto: change context to object

* remove old auth option

* remove old auth option

* fix linting errors

run gci on modified files

* add permission checks and fix some errors

* comments

* comments

* update email requests

* rename proto requests

* cleanup and docs

* simplify

* simplify

* fix setup

* remove unused proto messages / fields

---------

Co-authored-by: adlerhurst <silvan.reusser@gmail.com>
Co-authored-by: Tim Möhlmann <tim+github@zitadel.com>
This commit is contained in:
Livio Spring
2023-04-26 07:47:57 +02:00
committed by GitHub
parent 19f2f83b61
commit e4a4b7cfbe
24 changed files with 1175 additions and 226 deletions

View File

@@ -2,17 +2,19 @@ package command
import (
"context"
"errors"
"testing"
"time"
"github.com/golang/mock/gomock"
"github.com/muhlemmer/gu"
"github.com/stretchr/testify/assert"
"golang.org/x/text/language"
"github.com/zitadel/zitadel/internal/command/preparation"
"github.com/zitadel/zitadel/internal/crypto"
"github.com/zitadel/zitadel/internal/domain"
"github.com/zitadel/zitadel/internal/errors"
caos_errs "github.com/zitadel/zitadel/internal/errors"
"github.com/zitadel/zitadel/internal/eventstore"
"github.com/zitadel/zitadel/internal/eventstore/repository"
"github.com/zitadel/zitadel/internal/eventstore/v1/models"
@@ -29,16 +31,20 @@ func TestCommandSide_AddHuman(t *testing.T) {
idGenerator id.Generator
userPasswordAlg crypto.HashAlgorithm
codeAlg crypto.EncryptionAlgorithm
newEmailCode func(ctx context.Context, filter preparation.FilterToQueryReducer, alg crypto.EncryptionAlgorithm) (*CryptoCodeWithExpiry, error)
}
type args struct {
ctx context.Context
orgID string
human *AddHuman
secretGenerator crypto.Generator
allowInitMail bool
}
type res struct {
want *domain.HumanDetails
err func(error) bool
want *domain.ObjectDetails
wantID string
wantEmailCode string
err func(error) bool
}
userAgg := user.NewAggregate("user1", "org1")
@@ -68,9 +74,67 @@ func TestCommandSide_AddHuman(t *testing.T) {
Address: "email@test.ch",
},
},
allowInitMail: true,
},
res: res{
err: errors.IsErrorInvalidArgument,
err: func(err error) bool {
return errors.Is(err, caos_errs.ThrowInvalidArgument(nil, "COMMA-5Ky74", "Errors.Internal"))
},
},
},
{
name: "user invalid, invalid argument error",
fields: fields{
eventstore: eventstoreExpect(
t,
),
},
args: args{
ctx: context.Background(),
orgID: "org1",
human: &AddHuman{
Username: "username",
FirstName: "firstname",
},
allowInitMail: true,
},
res: res{
err: func(err error) bool {
return errors.Is(err, caos_errs.ThrowInvalidArgument(nil, "EMAIL-spblu", "Errors.User.Email.Empty"))
},
},
},
{
name: "with id, already exists, precondition error",
fields: fields{
eventstore: eventstoreExpect(
t,
expectFilter(
eventFromEventPusher(
newAddHumanEvent("password", true, ""),
),
),
),
},
args: args{
ctx: context.Background(),
orgID: "org1",
human: &AddHuman{
ID: "user1",
Username: "username",
FirstName: "firstname",
LastName: "lastname",
Email: Email{
Address: "email@test.ch",
},
PreferredLanguage: language.English,
},
allowInitMail: true,
},
res: res{
err: func(err error) bool {
return errors.Is(err, caos_errs.ThrowPreconditionFailed(nil, "COMMAND-k2unb", "Errors.User.AlreadyExisting"))
},
},
},
{
@@ -95,9 +159,12 @@ func TestCommandSide_AddHuman(t *testing.T) {
},
PreferredLanguage: language.English,
},
allowInitMail: true,
},
res: res{
err: errors.IsInternal,
err: func(err error) bool {
return errors.Is(err, caos_errs.ThrowInternal(nil, "USER-Ggk9n", "Errors.Internal"))
},
},
},
{
@@ -134,30 +201,13 @@ func TestCommandSide_AddHuman(t *testing.T) {
},
PreferredLanguage: language.English,
},
allowInitMail: true,
},
res: res{
err: errors.IsInternal,
},
},
{
name: "user invalid, invalid argument error",
fields: fields{
idGenerator: id_mock.NewIDGeneratorExpectIDs(t, "user1"),
eventstore: eventstoreExpect(
t,
),
},
args: args{
ctx: context.Background(),
orgID: "org1",
human: &AddHuman{
Username: "username",
FirstName: "firstname",
err: func(err error) bool {
return errors.Is(err, caos_errs.ThrowInternal(nil, "USER-uQ96e", "Errors.Internal"))
},
},
res: res{
err: errors.IsErrorInvalidArgument,
},
},
{
name: "add human (with initial code), ok",
@@ -237,16 +287,15 @@ func TestCommandSide_AddHuman(t *testing.T) {
PreferredLanguage: language.English,
},
secretGenerator: GetMockSecretGenerator(t),
allowInitMail: true,
},
res: res{
want: &domain.HumanDetails{
ID: "user1",
ObjectDetails: domain.ObjectDetails{
Sequence: 0,
EventDate: time.Time{},
ResourceOwner: "org1",
},
want: &domain.ObjectDetails{
Sequence: 0,
EventDate: time.Time{},
ResourceOwner: "org1",
},
wantID: "user1",
},
},
{
@@ -330,14 +379,172 @@ func TestCommandSide_AddHuman(t *testing.T) {
PreferredLanguage: language.English,
},
secretGenerator: GetMockSecretGenerator(t),
allowInitMail: true,
},
res: res{
want: &domain.HumanDetails{
ID: "user1",
ObjectDetails: domain.ObjectDetails{
ResourceOwner: "org1",
},
want: &domain.ObjectDetails{
ResourceOwner: "org1",
},
wantID: "user1",
},
},
{
name: "add human (with password and email code custom template), ok",
fields: fields{
eventstore: eventstoreExpect(
t,
expectFilter(
eventFromEventPusher(
org.NewDomainPolicyAddedEvent(context.Background(),
&user.NewAggregate("user1", "org1").Aggregate,
true,
true,
true,
),
),
),
expectFilter(
eventFromEventPusher(
org.NewPasswordComplexityPolicyAddedEvent(context.Background(),
&user.NewAggregate("user1", "org1").Aggregate,
1,
false,
false,
false,
false,
),
),
),
expectPush(
[]*repository.Event{
eventFromEventPusher(
newAddHumanEvent("password", false, ""),
),
eventFromEventPusher(
user.NewHumanEmailCodeAddedEventV2(context.Background(),
&user.NewAggregate("user1", "org1").Aggregate,
&crypto.CryptoValue{
CryptoType: crypto.TypeEncryption,
Algorithm: "enc",
KeyID: "id",
Crypted: []byte("emailCode"),
},
1*time.Hour,
"https://example.com/email/verify?userID={{.UserID}}&code={{.Code}}",
false,
),
),
},
uniqueConstraintsFromEventConstraint(user.NewAddUsernameUniqueConstraint("username", "org1", true)),
),
),
idGenerator: id_mock.NewIDGeneratorExpectIDs(t, "user1"),
userPasswordAlg: crypto.CreateMockHashAlg(gomock.NewController(t)),
codeAlg: crypto.CreateMockEncryptionAlg(gomock.NewController(t)),
newEmailCode: mockEmailCode("emailCode", time.Hour),
},
args: args{
ctx: context.Background(),
orgID: "org1",
human: &AddHuman{
Username: "username",
Password: "password",
FirstName: "firstname",
LastName: "lastname",
Email: Email{
Address: "email@test.ch",
URLTemplate: "https://example.com/email/verify?userID={{.UserID}}&code={{.Code}}",
},
PreferredLanguage: language.English,
},
secretGenerator: GetMockSecretGenerator(t),
allowInitMail: false,
},
res: res{
want: &domain.ObjectDetails{
ResourceOwner: "org1",
},
wantID: "user1",
},
},
{
name: "add human (with password and return email code), ok",
fields: fields{
eventstore: eventstoreExpect(
t,
expectFilter(
eventFromEventPusher(
org.NewDomainPolicyAddedEvent(context.Background(),
&user.NewAggregate("user1", "org1").Aggregate,
true,
true,
true,
),
),
),
expectFilter(
eventFromEventPusher(
org.NewPasswordComplexityPolicyAddedEvent(context.Background(),
&user.NewAggregate("user1", "org1").Aggregate,
1,
false,
false,
false,
false,
),
),
),
expectPush(
[]*repository.Event{
eventFromEventPusher(
newAddHumanEvent("password", false, ""),
),
eventFromEventPusher(
user.NewHumanEmailCodeAddedEventV2(context.Background(),
&user.NewAggregate("user1", "org1").Aggregate,
&crypto.CryptoValue{
CryptoType: crypto.TypeEncryption,
Algorithm: "enc",
KeyID: "id",
Crypted: []byte("emailCode"),
},
1*time.Hour,
"",
true,
),
),
},
uniqueConstraintsFromEventConstraint(user.NewAddUsernameUniqueConstraint("username", "org1", true)),
),
),
idGenerator: id_mock.NewIDGeneratorExpectIDs(t, "user1"),
userPasswordAlg: crypto.CreateMockHashAlg(gomock.NewController(t)),
codeAlg: crypto.CreateMockEncryptionAlg(gomock.NewController(t)),
newEmailCode: mockEmailCode("emailCode", time.Hour),
},
args: args{
ctx: context.Background(),
orgID: "org1",
human: &AddHuman{
Username: "username",
Password: "password",
FirstName: "firstname",
LastName: "lastname",
Email: Email{
Address: "email@test.ch",
ReturnCode: true,
},
PreferredLanguage: language.English,
},
secretGenerator: GetMockSecretGenerator(t),
allowInitMail: false,
},
res: res{
want: &domain.ObjectDetails{
ResourceOwner: "org1",
},
wantID: "user1",
wantEmailCode: "emailCode",
},
},
{
@@ -400,14 +607,13 @@ func TestCommandSide_AddHuman(t *testing.T) {
PasswordChangeRequired: true,
},
secretGenerator: GetMockSecretGenerator(t),
allowInitMail: true,
},
res: res{
want: &domain.HumanDetails{
ID: "user1",
ObjectDetails: domain.ObjectDetails{
ResourceOwner: "org1",
},
want: &domain.ObjectDetails{
ResourceOwner: "org1",
},
wantID: "user1",
},
},
{
@@ -470,14 +676,13 @@ func TestCommandSide_AddHuman(t *testing.T) {
PasswordChangeRequired: true,
},
secretGenerator: GetMockSecretGenerator(t),
allowInitMail: true,
},
res: res{
want: &domain.HumanDetails{
ID: "user1",
ObjectDetails: domain.ObjectDetails{
ResourceOwner: "org1",
},
want: &domain.ObjectDetails{
ResourceOwner: "org1",
},
wantID: "user1",
},
},
{
@@ -540,14 +745,13 @@ func TestCommandSide_AddHuman(t *testing.T) {
PasswordChangeRequired: true,
},
secretGenerator: GetMockSecretGenerator(t),
allowInitMail: true,
},
res: res{
want: &domain.HumanDetails{
ID: "user1",
ObjectDetails: domain.ObjectDetails{
ResourceOwner: "org1",
},
want: &domain.ObjectDetails{
ResourceOwner: "org1",
},
wantID: "user1",
},
},
{
@@ -594,9 +798,12 @@ func TestCommandSide_AddHuman(t *testing.T) {
PasswordChangeRequired: true,
},
secretGenerator: GetMockSecretGenerator(t),
allowInitMail: true,
},
res: res{
err: errors.IsErrorInvalidArgument,
err: func(err error) bool {
return errors.Is(err, caos_errs.ThrowInvalidArgument(nil, "COMMAND-SFd21", "Errors.User.DomainNotAllowedAsUsername"))
},
},
},
{
@@ -687,15 +894,14 @@ func TestCommandSide_AddHuman(t *testing.T) {
PasswordChangeRequired: true,
},
secretGenerator: GetMockSecretGenerator(t),
allowInitMail: true,
},
res: res{
want: &domain.HumanDetails{
ID: "user1",
ObjectDetails: domain.ObjectDetails{
ResourceOwner: "org1",
},
want: &domain.ObjectDetails{
ResourceOwner: "org1",
},
wantID: "user1",
},
},
{
@@ -787,14 +993,13 @@ func TestCommandSide_AddHuman(t *testing.T) {
PreferredLanguage: language.English,
},
secretGenerator: GetMockSecretGenerator(t),
allowInitMail: true,
},
res: res{
want: &domain.HumanDetails{
ID: "user1",
ObjectDetails: domain.ObjectDetails{
ResourceOwner: "org1",
},
want: &domain.ObjectDetails{
ResourceOwner: "org1",
},
wantID: "user1",
},
},
{
@@ -875,14 +1080,104 @@ func TestCommandSide_AddHuman(t *testing.T) {
PreferredLanguage: language.English,
},
secretGenerator: GetMockSecretGenerator(t),
allowInitMail: true,
},
res: res{
want: &domain.HumanDetails{
ID: "user1",
ObjectDetails: domain.ObjectDetails{
ResourceOwner: "org1",
want: &domain.ObjectDetails{
ResourceOwner: "org1",
},
wantID: "user1",
},
},
{
name: "add human with metadata, ok",
fields: fields{
eventstore: eventstoreExpect(
t,
expectFilter(
eventFromEventPusher(
org.NewDomainPolicyAddedEvent(context.Background(),
&userAgg.Aggregate,
true,
true,
true,
),
),
),
expectFilter(
eventFromEventPusher(
instance.NewSecretGeneratorAddedEvent(
context.Background(),
&instanceAgg.Aggregate,
domain.SecretGeneratorTypeInitCode,
0,
1*time.Hour,
true,
true,
true,
true,
),
),
),
expectPush(
[]*repository.Event{
eventFromEventPusher(
newAddHumanEvent("", false, ""),
),
eventFromEventPusher(
user.NewHumanInitialCodeAddedEvent(
context.Background(),
&userAgg.Aggregate,
&crypto.CryptoValue{
CryptoType: crypto.TypeEncryption,
Algorithm: "enc",
KeyID: "id",
Crypted: []byte(""),
},
1*time.Hour,
),
),
eventFromEventPusher(
user.NewMetadataSetEvent(
context.Background(),
&userAgg.Aggregate,
"testKey",
[]byte("testValue"),
),
),
},
uniqueConstraintsFromEventConstraint(user.NewAddUsernameUniqueConstraint("username", "org1", true)),
),
),
idGenerator: id_mock.NewIDGeneratorExpectIDs(t, "user1"),
codeAlg: crypto.CreateMockEncryptionAlg(gomock.NewController(t)),
},
args: args{
ctx: context.Background(),
orgID: "org1",
human: &AddHuman{
Username: "username",
FirstName: "firstname",
LastName: "lastname",
Email: Email{
Address: "email@test.ch",
},
PreferredLanguage: language.English,
Metadata: []*AddMetadataEntry{
{
Key: "testKey",
Value: []byte("testValue"),
},
},
},
secretGenerator: GetMockSecretGenerator(t),
allowInitMail: true,
},
res: res{
want: &domain.ObjectDetails{
ResourceOwner: "org1",
},
wantID: "user1",
},
},
}
@@ -893,8 +1188,9 @@ func TestCommandSide_AddHuman(t *testing.T) {
userPasswordAlg: tt.fields.userPasswordAlg,
userEncryption: tt.fields.codeAlg,
idGenerator: tt.fields.idGenerator,
newEmailCode: tt.fields.newEmailCode,
}
got, err := r.AddHuman(tt.args.ctx, tt.args.orgID, tt.args.human)
err := r.AddHuman(tt.args.ctx, tt.args.orgID, tt.args.human, tt.args.allowInitMail)
if tt.res.err == nil {
if !assert.NoError(t, err) {
t.FailNow()
@@ -904,7 +1200,9 @@ func TestCommandSide_AddHuman(t *testing.T) {
t.Errorf("got wrong err: %v ", err)
}
if tt.res.err == nil {
assert.Equal(t, tt.res.want, got)
assert.Equal(t, tt.res.want, tt.args.human.Details)
assert.Equal(t, tt.res.wantID, tt.args.human.ID)
assert.Equal(t, tt.res.wantEmailCode, gu.Value(tt.args.human.EmailCode))
}
})
}
@@ -958,7 +1256,7 @@ func TestCommandSide_ImportHuman(t *testing.T) {
},
},
res: res{
err: errors.IsErrorInvalidArgument,
err: caos_errs.IsErrorInvalidArgument,
},
},
{
@@ -985,7 +1283,7 @@ func TestCommandSide_ImportHuman(t *testing.T) {
},
},
res: res{
err: errors.IsPreconditionFailed,
err: caos_errs.IsPreconditionFailed,
},
},
{
@@ -1022,7 +1320,7 @@ func TestCommandSide_ImportHuman(t *testing.T) {
},
},
res: res{
err: errors.IsPreconditionFailed,
err: caos_errs.IsPreconditionFailed,
},
},
{
@@ -1065,7 +1363,7 @@ func TestCommandSide_ImportHuman(t *testing.T) {
},
},
res: res{
err: errors.IsErrorInvalidArgument,
err: caos_errs.IsErrorInvalidArgument,
},
},
{
@@ -1869,7 +2167,7 @@ func TestCommandSide_RegisterHuman(t *testing.T) {
},
},
res: res{
err: errors.IsErrorInvalidArgument,
err: caos_errs.IsErrorInvalidArgument,
},
},
{
@@ -1899,7 +2197,7 @@ func TestCommandSide_RegisterHuman(t *testing.T) {
},
},
res: res{
err: errors.IsPreconditionFailed,
err: caos_errs.IsPreconditionFailed,
},
},
{
@@ -1939,7 +2237,7 @@ func TestCommandSide_RegisterHuman(t *testing.T) {
},
},
res: res{
err: errors.IsPreconditionFailed,
err: caos_errs.IsPreconditionFailed,
},
},
{
@@ -1987,7 +2285,7 @@ func TestCommandSide_RegisterHuman(t *testing.T) {
},
},
res: res{
err: errors.IsPreconditionFailed,
err: caos_errs.IsPreconditionFailed,
},
},
{
@@ -2056,7 +2354,7 @@ func TestCommandSide_RegisterHuman(t *testing.T) {
},
},
res: res{
err: errors.IsPreconditionFailed,
err: caos_errs.IsPreconditionFailed,
},
},
{
@@ -2125,7 +2423,7 @@ func TestCommandSide_RegisterHuman(t *testing.T) {
},
},
res: res{
err: errors.IsErrorInvalidArgument,
err: caos_errs.IsErrorInvalidArgument,
},
},
{
@@ -2211,7 +2509,7 @@ func TestCommandSide_RegisterHuman(t *testing.T) {
},
},
res: res{
err: errors.IsErrorInvalidArgument,
err: caos_errs.IsErrorInvalidArgument,
},
},
{
@@ -3147,7 +3445,7 @@ func TestCommandSide_HumanMFASkip(t *testing.T) {
userID: "",
},
res: res{
err: errors.IsErrorInvalidArgument,
err: caos_errs.IsErrorInvalidArgument,
},
},
{
@@ -3164,7 +3462,7 @@ func TestCommandSide_HumanMFASkip(t *testing.T) {
userID: "user1",
},
res: res{
err: errors.IsNotFound,
err: caos_errs.IsNotFound,
},
},
{
@@ -3261,7 +3559,7 @@ func TestCommandSide_HumanSignOut(t *testing.T) {
userIDs: []string{"user1"},
},
res: res{
err: errors.IsErrorInvalidArgument,
err: caos_errs.IsErrorInvalidArgument,
},
},
{
@@ -3277,7 +3575,7 @@ func TestCommandSide_HumanSignOut(t *testing.T) {
userIDs: []string{},
},
res: res{
err: errors.IsErrorInvalidArgument,
err: caos_errs.IsErrorInvalidArgument,
},
},
{
@@ -3479,18 +3777,23 @@ func newRegisterHumanEvent(username, password string, changeRequired bool, phone
}
func TestAddHumanCommand(t *testing.T) {
type fields struct {
idGenerator id.Generator
}
type args struct {
a *user.Aggregate
human *AddHuman
passwordAlg crypto.HashAlgorithm
filter preparation.FilterToQueryReducer
codeAlg crypto.EncryptionAlgorithm
a *user.Aggregate
human *AddHuman
passwordAlg crypto.HashAlgorithm
filter preparation.FilterToQueryReducer
codeAlg crypto.EncryptionAlgorithm
allowInitMail bool
}
agg := user.NewAggregate("id", "ro")
tests := []struct {
name string
args args
want Want
name string
fields fields
args args
want Want
}{
{
name: "invalid email",
@@ -3503,7 +3806,7 @@ func TestAddHumanCommand(t *testing.T) {
},
},
want: Want{
ValidationErr: errors.ThrowInvalidArgument(nil, "EMAIL-599BI", "Errors.User.Email.Invalid"),
ValidationErr: caos_errs.ThrowInvalidArgument(nil, "EMAIL-599BI", "Errors.User.Email.Invalid"),
},
},
{
@@ -3519,7 +3822,7 @@ func TestAddHumanCommand(t *testing.T) {
},
},
want: Want{
ValidationErr: errors.ThrowInvalidArgument(nil, "USER-UCej2", "Errors.User.Profile.FirstNameEmpty"),
ValidationErr: caos_errs.ThrowInvalidArgument(nil, "USER-UCej2", "Errors.User.Profile.FirstNameEmpty"),
},
},
{
@@ -3534,11 +3837,14 @@ func TestAddHumanCommand(t *testing.T) {
},
},
want: Want{
ValidationErr: errors.ThrowInvalidArgument(nil, "USER-4hB7d", "Errors.User.Profile.LastNameEmpty"),
ValidationErr: caos_errs.ThrowInvalidArgument(nil, "USER-4hB7d", "Errors.User.Profile.LastNameEmpty"),
},
},
{
name: "invalid password",
fields: fields{
idGenerator: id_mock.NewIDGeneratorExpectIDs(t, "id"),
},
args: args{
a: agg,
human: &AddHuman{
@@ -3578,11 +3884,14 @@ func TestAddHumanCommand(t *testing.T) {
Filter(),
},
want: Want{
CreateErr: errors.ThrowInvalidArgument(nil, "COMMA-HuJf6", "Errors.User.PasswordComplexityPolicy.MinLength"),
CreateErr: caos_errs.ThrowInvalidArgument(nil, "COMMA-HuJf6", "Errors.User.PasswordComplexityPolicy.MinLength"),
},
},
{
name: "correct",
fields: fields{
idGenerator: id_mock.NewIDGeneratorExpectIDs(t, "id"),
},
args: args{
a: agg,
human: &AddHuman{
@@ -3654,7 +3963,25 @@ func TestAddHumanCommand(t *testing.T) {
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
AssertValidation(t, context.Background(), AddHumanCommand(tt.args.a, tt.args.human, tt.args.passwordAlg, tt.args.codeAlg), tt.args.filter, tt.want)
c := &Commands{
idGenerator: tt.fields.idGenerator,
}
AssertValidation(t, context.Background(), c.AddHumanCommand(tt.args.a, tt.args.human, tt.args.passwordAlg, tt.args.codeAlg, tt.args.allowInitMail), tt.args.filter, tt.want)
})
}
}
func mockEmailCode(code string, exp time.Duration) func(ctx context.Context, filter preparation.FilterToQueryReducer, alg crypto.EncryptionAlgorithm) (*CryptoCodeWithExpiry, error) {
return func(ctx context.Context, filter preparation.FilterToQueryReducer, alg crypto.EncryptionAlgorithm) (*CryptoCodeWithExpiry, error) {
return &CryptoCodeWithExpiry{
Crypted: &crypto.CryptoValue{
CryptoType: crypto.TypeEncryption,
Algorithm: "enc",
KeyID: "id",
Crypted: []byte(code),
},
Plain: code,
Expiry: exp,
}, nil
}
}