feat(api): add password reset and change to user service (#6036)

* feat(api): add password reset and change to user service

* integration tests

* invalidate password check after password change

* handle notification type

* fix proto
This commit is contained in:
Livio Spring
2023-06-20 17:34:06 +02:00
committed by GitHub
parent 1017568cf1
commit 82e7333169
20 changed files with 1373 additions and 54 deletions

View File

@@ -46,6 +46,12 @@ func eventstoreExpect(t *testing.T, expects ...expect) *eventstore.Eventstore {
return es
}
func expectEventstore(expects ...expect) func(*testing.T) *eventstore.Eventstore {
return func(t *testing.T) *eventstore.Eventstore {
return eventstoreExpect(t, expects...)
}
}
func eventPusherToEvents(eventsPushes ...eventstore.Command) []*repository.Event {
events := make([]*repository.Event, len(eventsPushes))
for i, event := range eventsPushes {

View File

@@ -26,6 +26,9 @@ func (c *Commands) SetPassword(ctx context.Context, orgID, userID, passwordStrin
if !existingPassword.UserState.Exists() {
return nil, caos_errs.ThrowPreconditionFailed(nil, "COMMAND-3M0fs", "Errors.User.NotFound")
}
if err = c.checkPermission(ctx, domain.PermissionUserWrite, existingPassword.ResourceOwner, userID); err != nil {
return nil, err
}
password := &domain.Password{
SecretString: passwordString,
ChangeRequired: oneTime,
@@ -46,28 +49,28 @@ func (c *Commands) SetPassword(ctx context.Context, orgID, userID, passwordStrin
return writeModelToObjectDetails(&existingPassword.WriteModel), nil
}
func (c *Commands) SetPasswordWithVerifyCode(ctx context.Context, orgID, userID, code, passwordString, userAgentID string, passwordVerificationCode crypto.Generator) (err error) {
func (c *Commands) SetPasswordWithVerifyCode(ctx context.Context, orgID, userID, code, passwordString, userAgentID string) (objectDetails *domain.ObjectDetails, err error) {
ctx, span := tracing.NewSpan(ctx)
defer func() { span.EndWithError(err) }()
if userID == "" {
return caos_errs.ThrowInvalidArgument(nil, "COMMAND-3M9fs", "Errors.IDMissing")
return nil, caos_errs.ThrowInvalidArgument(nil, "COMMAND-3M9fs", "Errors.IDMissing")
}
if passwordString == "" {
return caos_errs.ThrowInvalidArgument(nil, "COMMAND-Mf0sd", "Errors.User.Password.Empty")
return nil, caos_errs.ThrowInvalidArgument(nil, "COMMAND-Mf0sd", "Errors.User.Password.Empty")
}
existingCode, err := c.passwordWriteModel(ctx, userID, orgID)
if err != nil {
return err
return nil, err
}
if existingCode.Code == nil || existingCode.UserState == domain.UserStateUnspecified || existingCode.UserState == domain.UserStateDeleted {
return caos_errs.ThrowPreconditionFailed(nil, "COMMAND-2M9fs", "Errors.User.Code.NotFound")
return nil, caos_errs.ThrowPreconditionFailed(nil, "COMMAND-2M9fs", "Errors.User.Code.NotFound")
}
err = crypto.VerifyCode(existingCode.CodeCreationDate, existingCode.CodeExpiry, existingCode.Code, code, passwordVerificationCode)
err = crypto.VerifyCodeWithAlgorithm(existingCode.CodeCreationDate, existingCode.CodeExpiry, existingCode.Code, code, c.userEncryption)
if err != nil {
return err
return nil, err
}
password := &domain.Password{
@@ -77,10 +80,13 @@ func (c *Commands) SetPasswordWithVerifyCode(ctx context.Context, orgID, userID,
userAgg := UserAggregateFromWriteModel(&existingCode.WriteModel)
passwordEvent, err := c.changePassword(ctx, userAgentID, password, userAgg, existingCode)
if err != nil {
return err
return nil, err
}
_, err = c.eventstore.Push(ctx, passwordEvent)
return err
err = c.pushAppendAndReduce(ctx, existingCode, passwordEvent)
if err != nil {
return nil, err
}
return writeModelToObjectDetails(&existingCode.WriteModel), nil
}
func (c *Commands) ChangePassword(ctx context.Context, orgID, userID, oldPassword, newPassword, userAgentID string) (objectDetails *domain.ObjectDetails, err error) {

View File

@@ -2,6 +2,7 @@ package command
import (
"context"
"errors"
"testing"
"time"
@@ -22,6 +23,7 @@ func TestCommandSide_SetOneTimePassword(t *testing.T) {
type fields struct {
eventstore *eventstore.Eventstore
userPasswordAlg crypto.HashAlgorithm
checkPermission domain.PermissionCheck
}
type args struct {
ctx context.Context
@@ -72,6 +74,49 @@ func TestCommandSide_SetOneTimePassword(t *testing.T) {
err: caos_errs.IsPreconditionFailed,
},
},
{
name: "missing permission, error",
fields: fields{
eventstore: eventstoreExpect(
t,
expectFilter(
eventFromEventPusher(
user.NewHumanAddedEvent(context.Background(),
&user.NewAggregate("user1", "org1").Aggregate,
"username",
"firstname",
"lastname",
"nickname",
"displayname",
language.German,
domain.GenderUnspecified,
"email@test.ch",
true,
),
),
eventFromEventPusher(
user.NewHumanEmailVerifiedEvent(context.Background(),
&user.NewAggregate("user1", "org1").Aggregate,
),
),
),
),
userPasswordAlg: crypto.CreateMockHashAlg(gomock.NewController(t)),
checkPermission: newMockPermissionCheckNotAllowed(),
},
args: args{
ctx: context.Background(),
userID: "user1",
resourceOwner: "org1",
password: "password",
oneTime: true,
},
res: res{
err: func(err error) bool {
return errors.Is(err, caos_errs.ThrowPermissionDenied(nil, "AUTHZ-HKJD33", "Errors.PermissionDenied"))
},
},
},
{
name: "change password onetime, ok",
fields: fields{
@@ -129,6 +174,7 @@ func TestCommandSide_SetOneTimePassword(t *testing.T) {
),
),
userPasswordAlg: crypto.CreateMockHashAlg(gomock.NewController(t)),
checkPermission: newMockPermissionCheckAllowed(),
},
args: args{
ctx: context.Background(),
@@ -200,6 +246,7 @@ func TestCommandSide_SetOneTimePassword(t *testing.T) {
),
),
userPasswordAlg: crypto.CreateMockHashAlg(gomock.NewController(t)),
checkPermission: newMockPermissionCheckAllowed(),
},
args: args{
ctx: context.Background(),
@@ -220,6 +267,7 @@ func TestCommandSide_SetOneTimePassword(t *testing.T) {
r := &Commands{
eventstore: tt.fields.eventstore,
userPasswordAlg: tt.fields.userPasswordAlg,
checkPermission: tt.fields.checkPermission,
}
got, err := r.SetPassword(tt.args.ctx, tt.args.resourceOwner, tt.args.userID, tt.args.password, tt.args.oneTime)
if tt.res.err == nil {
@@ -235,19 +283,19 @@ func TestCommandSide_SetOneTimePassword(t *testing.T) {
}
}
func TestCommandSide_SetPassword(t *testing.T) {
func TestCommandSide_SetPasswordWithVerifyCode(t *testing.T) {
type fields struct {
eventstore *eventstore.Eventstore
userEncryption crypto.EncryptionAlgorithm
userPasswordAlg crypto.HashAlgorithm
}
type args struct {
ctx context.Context
userID string
code string
resourceOwner string
password string
agentID string
secretGenerator crypto.Generator
ctx context.Context
userID string
code string
resourceOwner string
password string
agentID string
}
type res struct {
want *domain.ObjectDetails
@@ -377,14 +425,14 @@ func TestCommandSide_SetPassword(t *testing.T) {
),
),
),
userEncryption: crypto.CreateMockEncryptionAlg(gomock.NewController(t)),
},
args: args{
ctx: context.Background(),
userID: "user1",
code: "test",
resourceOwner: "org1",
password: "password",
secretGenerator: GetMockSecretGenerator(t),
ctx: context.Background(),
userID: "user1",
code: "test",
resourceOwner: "org1",
password: "password",
},
res: res{
err: caos_errs.IsPreconditionFailed,
@@ -460,14 +508,14 @@ func TestCommandSide_SetPassword(t *testing.T) {
),
),
userPasswordAlg: crypto.CreateMockHashAlg(gomock.NewController(t)),
userEncryption: crypto.CreateMockEncryptionAlg(gomock.NewController(t)),
},
args: args{
ctx: context.Background(),
userID: "user1",
resourceOwner: "org1",
password: "password",
code: "a",
secretGenerator: GetMockSecretGenerator(t),
ctx: context.Background(),
userID: "user1",
resourceOwner: "org1",
password: "password",
code: "a",
},
res: res{
want: &domain.ObjectDetails{
@@ -481,14 +529,18 @@ func TestCommandSide_SetPassword(t *testing.T) {
r := &Commands{
eventstore: tt.fields.eventstore,
userPasswordAlg: tt.fields.userPasswordAlg,
userEncryption: tt.fields.userEncryption,
}
err := r.SetPasswordWithVerifyCode(tt.args.ctx, tt.args.resourceOwner, tt.args.userID, tt.args.code, tt.args.password, tt.args.agentID, tt.args.secretGenerator)
got, err := r.SetPasswordWithVerifyCode(tt.args.ctx, tt.args.resourceOwner, tt.args.userID, tt.args.code, tt.args.password, tt.args.agentID)
if tt.res.err == nil {
assert.NoError(t, err)
}
if tt.res.err != nil && !tt.res.err(err) {
t.Errorf("got wrong err: %v ", err)
}
if tt.res.err == nil {
assert.Equal(t, tt.res.want, got)
}
})
}
}

View File

@@ -15,7 +15,7 @@ import (
)
// RegisterUserPasskey creates a passkey registration for the current authenticated user.
// UserID, ussualy taken from the request is compaired against the user ID in the context.
// UserID, usually taken from the request is compared against the user ID in the context.
func (c *Commands) RegisterUserPasskey(ctx context.Context, userID, resourceOwner string, authenticator domain.AuthenticatorAttachment) (*domain.WebAuthNRegistrationDetails, error) {
if err := authz.UserIDInCTX(ctx, userID); err != nil {
return nil, err
@@ -40,7 +40,7 @@ type eventCallback func(context.Context, *eventstore.Aggregate) eventstore.Comma
// A code can only be used once.
// Upon success an event callback is returned, which must be called after
// all other events for the current request are created.
// This prevent consuming a code when another error occurred after verification.
// This prevents consuming a code when another error occurred after verification.
func (c *Commands) verifyUserPasskeyCode(ctx context.Context, userID, resourceOwner, codeID, code string, alg crypto.EncryptionAlgorithm) (eventCallback, error) {
wm := NewHumanPasswordlessInitCodeWriteModel(userID, codeID, resourceOwner)
err := c.eventstore.FilterToQueryReducer(ctx, wm)
@@ -122,7 +122,7 @@ func (c *Commands) AddUserPasskeyCodeURLTemplate(ctx context.Context, userID, re
}
// AddUserPasskeyCodeReturn generates and returns a Passkey code.
// No email will be send to the user.
// No email will be sent to the user.
func (c *Commands) AddUserPasskeyCodeReturn(ctx context.Context, userID, resourceOwner string, alg crypto.EncryptionAlgorithm) (*domain.PasskeyCodeDetails, error) {
return c.addUserPasskeyCode(ctx, userID, resourceOwner, alg, "", true)
}

View File

@@ -0,0 +1,71 @@
package command
import (
"context"
"io"
"github.com/zitadel/zitadel/internal/api/authz"
"github.com/zitadel/zitadel/internal/domain"
caos_errs "github.com/zitadel/zitadel/internal/errors"
"github.com/zitadel/zitadel/internal/repository/user"
)
// RequestPasswordReset generates a code
// and triggers a notification e-mail with the default confirmation URL format.
func (c *Commands) RequestPasswordReset(ctx context.Context, userID string) (*domain.ObjectDetails, *string, error) {
return c.requestPasswordReset(ctx, userID, false, "", domain.NotificationTypeEmail)
}
// RequestPasswordResetURLTemplate generates a code
// and triggers a notification e-mail with the confirmation URL rendered from the passed urlTmpl.
// urlTmpl must be a valid [tmpl.Template].
func (c *Commands) RequestPasswordResetURLTemplate(ctx context.Context, userID, urlTmpl string, notificationType domain.NotificationType) (*domain.ObjectDetails, *string, error) {
if err := domain.RenderConfirmURLTemplate(io.Discard, urlTmpl, userID, "code", "orgID"); err != nil {
return nil, nil, err
}
return c.requestPasswordReset(ctx, userID, false, urlTmpl, notificationType)
}
// RequestPasswordResetReturnCode generates a code and does not send a notification email.
// The generated plain text code will be returned.
func (c *Commands) RequestPasswordResetReturnCode(ctx context.Context, userID string) (*domain.ObjectDetails, *string, error) {
return c.requestPasswordReset(ctx, userID, true, "", 0)
}
// requestPasswordReset creates a code for a password change.
// returnCode controls if the plain text version of the code will be set in the return object.
// When the plain text code is returned, no notification e-mail will be sent to the user.
// urlTmpl allows changing the target URL that is used by the e-mail and should be a validated Go template, if used.
func (c *Commands) requestPasswordReset(ctx context.Context, userID string, returnCode bool, urlTmpl string, notificationType domain.NotificationType) (_ *domain.ObjectDetails, plainCode *string, err error) {
if userID == "" {
return nil, nil, caos_errs.ThrowInvalidArgument(nil, "COMMAND-SAFdda", "Errors.User.IDMissing")
}
model, err := c.getHumanWriteModelByID(ctx, userID, "")
if err != nil {
return nil, nil, err
}
if !model.UserState.Exists() {
return nil, nil, caos_errs.ThrowNotFound(nil, "COMMAND-SAF4f", "Errors.User.NotFound")
}
if model.UserState == domain.UserStateInitial {
return nil, nil, caos_errs.ThrowPreconditionFailed(nil, "COMMAND-Sfe4g", "Errors.User.NotInitialised")
}
if authz.GetCtxData(ctx).UserID != userID {
if err = c.checkPermission(ctx, domain.PermissionUserWrite, model.ResourceOwner, userID); err != nil {
return nil, nil, err
}
}
code, err := c.newCode(ctx, c.eventstore.Filter, domain.SecretGeneratorTypePasswordResetCode, c.userEncryption)
if err != nil {
return nil, nil, err
}
cmd := user.NewHumanPasswordCodeAddedEventV2(ctx, UserAggregateFromWriteModel(&model.WriteModel), code.Crypted, code.Expiry, notificationType, urlTmpl, returnCode)
if returnCode {
plainCode = &code.Plain
}
if err = c.pushAppendAndReduce(ctx, model, cmd); err != nil {
return nil, nil, err
}
return writeModelToObjectDetails(&model.WriteModel), plainCode, nil
}

View File

@@ -0,0 +1,611 @@
package command
import (
"context"
"testing"
"time"
"github.com/muhlemmer/gu"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"golang.org/x/text/language"
"github.com/zitadel/zitadel/internal/crypto"
"github.com/zitadel/zitadel/internal/domain"
caos_errs "github.com/zitadel/zitadel/internal/errors"
"github.com/zitadel/zitadel/internal/eventstore"
"github.com/zitadel/zitadel/internal/repository/user"
)
func TestCommands_RequestPasswordReset(t *testing.T) {
type fields struct {
checkPermission domain.PermissionCheck
eventstore func(t *testing.T) *eventstore.Eventstore
userEncryption crypto.EncryptionAlgorithm
}
type args struct {
ctx context.Context
userID string
}
tests := []struct {
name string
fields fields
args args
wantErr error
}{
{
name: "missing userID",
fields: fields{
eventstore: expectEventstore(),
},
args: args{
ctx: context.Background(),
userID: "",
},
wantErr: caos_errs.ThrowInvalidArgument(nil, "COMMAND-SAFdda", "Errors.User.IDMissing"),
},
{
name: "user not existing",
fields: fields{
eventstore: expectEventstore(
expectFilter(),
),
},
args: args{
ctx: context.Background(),
userID: "userID",
},
wantErr: caos_errs.ThrowNotFound(nil, "COMMAND-SAF4f", "Errors.User.NotFound"),
},
{
name: "user not initialized",
fields: fields{
eventstore: expectEventstore(
expectFilter(
eventFromEventPusher(
user.NewHumanAddedEvent(context.Background(), &user.NewAggregate("userID", "org1").Aggregate,
"username", "firstname", "lastname", "nickname", "displayname",
language.English, domain.GenderUnspecified, "email", false),
),
eventFromEventPusher(
user.NewHumanInitialCodeAddedEvent(context.Background(), &user.NewAggregate("userID", "org1").Aggregate,
&crypto.CryptoValue{CryptoType: crypto.TypeEncryption, Algorithm: "enc", KeyID: "keyID", Crypted: []byte("code")}, 10*time.Second),
),
),
),
},
args: args{
ctx: context.Background(),
userID: "userID",
},
wantErr: caos_errs.ThrowPreconditionFailed(nil, "COMMAND-Sfe4g", "Errors.User.NotInitialised"),
},
{
name: "missing permission",
fields: fields{
eventstore: expectEventstore(
expectFilter(
eventFromEventPusher(
user.NewHumanAddedEvent(context.Background(), &user.NewAggregate("userID", "org1").Aggregate,
"username", "firstname", "lastname", "nickname", "displayname",
language.English, domain.GenderUnspecified, "email", false),
),
),
),
checkPermission: newMockPermissionCheckNotAllowed(),
},
args: args{
ctx: context.Background(),
userID: "userID",
},
wantErr: caos_errs.ThrowPermissionDenied(nil, "AUTHZ-HKJD33", "Errors.PermissionDenied"),
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
c := &Commands{
checkPermission: tt.fields.checkPermission,
eventstore: tt.fields.eventstore(t),
userEncryption: tt.fields.userEncryption,
}
_, _, err := c.RequestPasswordReset(tt.args.ctx, tt.args.userID)
require.ErrorIs(t, err, tt.wantErr)
// successful cases are tested in TestCommands_requestPasswordReset
})
}
}
func TestCommands_RequestPasswordResetReturnCode(t *testing.T) {
type fields struct {
checkPermission domain.PermissionCheck
eventstore func(t *testing.T) *eventstore.Eventstore
userEncryption crypto.EncryptionAlgorithm
}
type args struct {
ctx context.Context
userID string
}
tests := []struct {
name string
fields fields
args args
wantErr error
}{
{
name: "missing userID",
fields: fields{
eventstore: expectEventstore(),
},
args: args{
ctx: context.Background(),
userID: "",
},
wantErr: caos_errs.ThrowInvalidArgument(nil, "COMMAND-SAFdda", "Errors.User.IDMissing"),
},
{
name: "user not existing",
fields: fields{
eventstore: expectEventstore(
expectFilter(),
),
},
args: args{
ctx: context.Background(),
userID: "userID",
},
wantErr: caos_errs.ThrowNotFound(nil, "COMMAND-SAF4f", "Errors.User.NotFound"),
},
{
name: "user not initialized",
fields: fields{
eventstore: expectEventstore(
expectFilter(
eventFromEventPusher(
user.NewHumanAddedEvent(context.Background(), &user.NewAggregate("userID", "org1").Aggregate,
"username", "firstname", "lastname", "nickname", "displayname",
language.English, domain.GenderUnspecified, "email", false),
),
eventFromEventPusher(
user.NewHumanInitialCodeAddedEvent(context.Background(), &user.NewAggregate("userID", "org1").Aggregate,
&crypto.CryptoValue{CryptoType: crypto.TypeEncryption, Algorithm: "enc", KeyID: "keyID", Crypted: []byte("code")}, 10*time.Second),
),
),
),
},
args: args{
ctx: context.Background(),
userID: "userID",
},
wantErr: caos_errs.ThrowPreconditionFailed(nil, "COMMAND-Sfe4g", "Errors.User.NotInitialised"),
},
{
name: "missing permission",
fields: fields{
eventstore: expectEventstore(
expectFilter(
eventFromEventPusher(
user.NewHumanAddedEvent(context.Background(), &user.NewAggregate("userID", "org1").Aggregate,
"username", "firstname", "lastname", "nickname", "displayname",
language.English, domain.GenderUnspecified, "email", false),
),
),
),
checkPermission: newMockPermissionCheckNotAllowed(),
},
args: args{
ctx: context.Background(),
userID: "userID",
},
wantErr: caos_errs.ThrowPermissionDenied(nil, "AUTHZ-HKJD33", "Errors.PermissionDenied"),
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
c := &Commands{
checkPermission: tt.fields.checkPermission,
eventstore: tt.fields.eventstore(t),
userEncryption: tt.fields.userEncryption,
}
_, _, err := c.RequestPasswordResetReturnCode(tt.args.ctx, tt.args.userID)
require.ErrorIs(t, err, tt.wantErr)
// successful cases are tested in TestCommands_requestPasswordReset
})
}
}
func TestCommands_RequestPasswordResetURLTemplate(t *testing.T) {
type fields struct {
checkPermission domain.PermissionCheck
eventstore func(t *testing.T) *eventstore.Eventstore
userEncryption crypto.EncryptionAlgorithm
}
type args struct {
ctx context.Context
userID string
urlTmpl string
notificationType domain.NotificationType
}
tests := []struct {
name string
fields fields
args args
wantErr error
}{
{
name: "invalid template",
fields: fields{
eventstore: expectEventstore(),
},
args: args{
userID: "user1",
urlTmpl: "{{",
},
wantErr: caos_errs.ThrowInvalidArgument(nil, "DOMAIN-oGh5e", "Errors.User.InvalidURLTemplate"),
},
{
name: "missing userID",
fields: fields{
eventstore: expectEventstore(),
},
args: args{
ctx: context.Background(),
userID: "",
},
wantErr: caos_errs.ThrowInvalidArgument(nil, "COMMAND-SAFdda", "Errors.User.IDMissing"),
},
{
name: "user not existing",
fields: fields{
eventstore: expectEventstore(
expectFilter(),
),
},
args: args{
ctx: context.Background(),
userID: "userID",
},
wantErr: caos_errs.ThrowNotFound(nil, "COMMAND-SAF4f", "Errors.User.NotFound"),
},
{
name: "user not initialized",
fields: fields{
eventstore: expectEventstore(
expectFilter(
eventFromEventPusher(
user.NewHumanAddedEvent(context.Background(), &user.NewAggregate("userID", "org1").Aggregate,
"username", "firstname", "lastname", "nickname", "displayname",
language.English, domain.GenderUnspecified, "email", false),
),
eventFromEventPusher(
user.NewHumanInitialCodeAddedEvent(context.Background(), &user.NewAggregate("userID", "org1").Aggregate,
&crypto.CryptoValue{CryptoType: crypto.TypeEncryption, Algorithm: "enc", KeyID: "keyID", Crypted: []byte("code")}, 10*time.Second),
),
),
),
},
args: args{
ctx: context.Background(),
userID: "userID",
},
wantErr: caos_errs.ThrowPreconditionFailed(nil, "COMMAND-Sfe4g", "Errors.User.NotInitialised"),
},
{
name: "missing permission",
fields: fields{
eventstore: expectEventstore(
expectFilter(
eventFromEventPusher(
user.NewHumanAddedEvent(context.Background(), &user.NewAggregate("userID", "org1").Aggregate,
"username", "firstname", "lastname", "nickname", "displayname",
language.English, domain.GenderUnspecified, "email", false),
),
),
),
checkPermission: newMockPermissionCheckNotAllowed(),
},
args: args{
ctx: context.Background(),
userID: "userID",
},
wantErr: caos_errs.ThrowPermissionDenied(nil, "AUTHZ-HKJD33", "Errors.PermissionDenied"),
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
c := &Commands{
checkPermission: tt.fields.checkPermission,
eventstore: tt.fields.eventstore(t),
userEncryption: tt.fields.userEncryption,
}
_, _, err := c.RequestPasswordResetURLTemplate(tt.args.ctx, tt.args.userID, tt.args.urlTmpl, tt.args.notificationType)
require.ErrorIs(t, err, tt.wantErr)
// successful cases are tested in TestCommands_requestPasswordReset
})
}
}
func TestCommands_requestPasswordReset(t *testing.T) {
type fields struct {
checkPermission domain.PermissionCheck
eventstore func(t *testing.T) *eventstore.Eventstore
userEncryption crypto.EncryptionAlgorithm
newCode cryptoCodeFunc
}
type args struct {
ctx context.Context
userID string
returnCode bool
urlTmpl string
notificationType domain.NotificationType
}
type res struct {
details *domain.ObjectDetails
code *string
err error
}
tests := []struct {
name string
fields fields
args args
res res
}{
{
name: "missing userID",
fields: fields{
eventstore: expectEventstore(),
},
args: args{
ctx: context.Background(),
userID: "",
},
res: res{
err: caos_errs.ThrowInvalidArgument(nil, "COMMAND-SAFdda", "Errors.User.IDMissing"),
},
},
{
name: "user not existing",
fields: fields{
eventstore: expectEventstore(
expectFilter(),
),
},
args: args{
ctx: context.Background(),
userID: "userID",
},
res: res{
err: caos_errs.ThrowNotFound(nil, "COMMAND-SAF4f", "Errors.User.NotFound"),
},
},
{
name: "user not initialized",
fields: fields{
eventstore: expectEventstore(
expectFilter(
eventFromEventPusher(
user.NewHumanAddedEvent(context.Background(), &user.NewAggregate("userID", "org1").Aggregate,
"username", "firstname", "lastname", "nickname", "displayname",
language.English, domain.GenderUnspecified, "email", false),
),
eventFromEventPusher(
user.NewHumanInitialCodeAddedEvent(context.Background(), &user.NewAggregate("userID", "org1").Aggregate,
&crypto.CryptoValue{CryptoType: crypto.TypeEncryption, Algorithm: "enc", KeyID: "keyID", Crypted: []byte("code")}, 10*time.Second),
),
),
),
},
args: args{
ctx: context.Background(),
userID: "userID",
},
res: res{
err: caos_errs.ThrowPreconditionFailed(nil, "COMMAND-Sfe4g", "Errors.User.NotInitialised"),
},
},
{
name: "missing permission",
fields: fields{
eventstore: expectEventstore(
expectFilter(
eventFromEventPusher(
user.NewHumanAddedEvent(context.Background(), &user.NewAggregate("userID", "org1").Aggregate,
"username", "firstname", "lastname", "nickname", "displayname",
language.English, domain.GenderUnspecified, "email", false),
),
),
),
checkPermission: newMockPermissionCheckNotAllowed(),
},
args: args{
ctx: context.Background(),
userID: "userID",
},
res: res{
err: caos_errs.ThrowPermissionDenied(nil, "AUTHZ-HKJD33", "Errors.PermissionDenied"),
},
},
{
name: "code generated",
fields: fields{
eventstore: expectEventstore(
expectFilter(
eventFromEventPusher(
user.NewHumanAddedEvent(context.Background(), &user.NewAggregate("userID", "org1").Aggregate,
"username", "firstname", "lastname", "nickname", "displayname",
language.English, domain.GenderUnspecified, "email", false),
),
),
expectPush(
eventPusherToEvents(
user.NewHumanPasswordCodeAddedEventV2(context.Background(), &user.NewAggregate("userID", "org1").Aggregate,
&crypto.CryptoValue{
CryptoType: crypto.TypeEncryption,
Algorithm: "enc",
KeyID: "id",
Crypted: []byte("code"),
},
10*time.Minute,
domain.NotificationTypeEmail,
"",
false,
)),
),
),
checkPermission: newMockPermissionCheckAllowed(),
newCode: mockCode("code", 10*time.Minute),
},
args: args{
ctx: context.Background(),
userID: "userID",
},
res: res{
details: &domain.ObjectDetails{
ResourceOwner: "org1",
},
code: nil,
},
},
{
name: "code generated template",
fields: fields{
eventstore: expectEventstore(
expectFilter(
eventFromEventPusher(
user.NewHumanAddedEvent(context.Background(), &user.NewAggregate("userID", "org1").Aggregate,
"username", "firstname", "lastname", "nickname", "displayname",
language.English, domain.GenderUnspecified, "email", false),
),
),
expectPush(
eventPusherToEvents(
user.NewHumanPasswordCodeAddedEventV2(context.Background(), &user.NewAggregate("userID", "org1").Aggregate,
&crypto.CryptoValue{
CryptoType: crypto.TypeEncryption,
Algorithm: "enc",
KeyID: "id",
Crypted: []byte("code"),
},
10*time.Minute,
domain.NotificationTypeEmail,
"https://example.com/password/changey?userID={{.UserID}}&code={{.Code}}&orgID={{.OrgID}}",
false,
)),
),
),
checkPermission: newMockPermissionCheckAllowed(),
newCode: mockCode("code", 10*time.Minute),
},
args: args{
ctx: context.Background(),
userID: "userID",
urlTmpl: "https://example.com/password/changey?userID={{.UserID}}&code={{.Code}}&orgID={{.OrgID}}",
},
res: res{
details: &domain.ObjectDetails{
ResourceOwner: "org1",
},
code: nil,
},
},
{
name: "code generated template sms",
fields: fields{
eventstore: expectEventstore(
expectFilter(
eventFromEventPusher(
user.NewHumanAddedEvent(context.Background(), &user.NewAggregate("userID", "org1").Aggregate,
"username", "firstname", "lastname", "nickname", "displayname",
language.English, domain.GenderUnspecified, "email", false),
),
),
expectPush(
eventPusherToEvents(
user.NewHumanPasswordCodeAddedEventV2(context.Background(), &user.NewAggregate("userID", "org1").Aggregate,
&crypto.CryptoValue{
CryptoType: crypto.TypeEncryption,
Algorithm: "enc",
KeyID: "id",
Crypted: []byte("code"),
},
10*time.Minute,
domain.NotificationTypeSms,
"https://example.com/password/changey?userID={{.UserID}}&code={{.Code}}&orgID={{.OrgID}}",
false,
)),
),
),
checkPermission: newMockPermissionCheckAllowed(),
newCode: mockCode("code", 10*time.Minute),
},
args: args{
ctx: context.Background(),
userID: "userID",
urlTmpl: "https://example.com/password/changey?userID={{.UserID}}&code={{.Code}}&orgID={{.OrgID}}",
notificationType: domain.NotificationTypeSms,
},
res: res{
details: &domain.ObjectDetails{
ResourceOwner: "org1",
},
code: nil,
},
},
{
name: "code generated returned",
fields: fields{
eventstore: expectEventstore(
expectFilter(
eventFromEventPusher(
user.NewHumanAddedEvent(context.Background(), &user.NewAggregate("userID", "org1").Aggregate,
"username", "firstname", "lastname", "nickname", "displayname",
language.English, domain.GenderUnspecified, "email", false),
),
),
expectPush(
eventPusherToEvents(
user.NewHumanPasswordCodeAddedEventV2(context.Background(), &user.NewAggregate("userID", "org1").Aggregate,
&crypto.CryptoValue{
CryptoType: crypto.TypeEncryption,
Algorithm: "enc",
KeyID: "id",
Crypted: []byte("code"),
},
10*time.Minute,
domain.NotificationTypeEmail,
"",
true,
)),
),
),
checkPermission: newMockPermissionCheckAllowed(),
newCode: mockCode("code", 10*time.Minute),
},
args: args{
ctx: context.Background(),
userID: "userID",
returnCode: true,
},
res: res{
details: &domain.ObjectDetails{
ResourceOwner: "org1",
},
code: gu.Ptr("code"),
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
c := &Commands{
checkPermission: tt.fields.checkPermission,
eventstore: tt.fields.eventstore(t),
userEncryption: tt.fields.userEncryption,
newCode: tt.fields.newCode,
}
got, gotPlainCode, err := c.requestPasswordReset(tt.args.ctx, tt.args.userID, tt.args.returnCode, tt.args.urlTmpl, tt.args.notificationType)
require.ErrorIs(t, err, tt.res.err)
assert.Equal(t, tt.res.details, got)
assert.Equal(t, tt.res.code, gotPlainCode)
})
}
}