From aeb379e7deee3d6df293d0fdb8f00157184b7ec6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20M=C3=B6hlmann?= Date: Tue, 24 Sep 2024 19:43:29 +0300 Subject: [PATCH 1/4] fix(eventstore): revert precise decimal (#8527) (#8679) --- cmd/mirror/event.go | 10 ++-- cmd/mirror/event_store.go | 3 +- go.mod | 2 - go.sum | 4 -- internal/api/oidc/key.go | 15 +++--- internal/api/saml/certificate.go | 15 +++--- internal/database/cockroach/crdb.go | 7 --- internal/database/postgres/pg.go | 6 --- internal/eventstore/event.go | 4 +- internal/eventstore/event_base.go | 6 +-- internal/eventstore/eventstore.go | 11 ++--- .../eventstore/eventstore_querier_test.go | 16 +++---- internal/eventstore/eventstore_test.go | 17 ++++--- .../eventstore/handler/v2/field_handler.go | 9 ++-- internal/eventstore/handler/v2/handler.go | 19 ++++---- internal/eventstore/handler/v2/state.go | 8 ++-- internal/eventstore/handler/v2/state_test.go | 13 +++-- internal/eventstore/handler/v2/statement.go | 3 +- internal/eventstore/local_crdb_test.go | 29 ++--------- internal/eventstore/read_model.go | 22 ++++----- internal/eventstore/repository/event.go | 6 +-- .../repository/mock/repository.mock.go | 15 +++--- .../repository/mock/repository.mock.impl.go | 5 +- .../eventstore/repository/search_query.go | 6 +-- internal/eventstore/repository/sql/crdb.go | 13 ++--- .../eventstore/repository/sql/crdb_test.go | 4 +- internal/eventstore/repository/sql/query.go | 25 +++++----- .../eventstore/repository/sql/query_test.go | 27 +++++------ internal/eventstore/search_query.go | 16 +++---- internal/eventstore/search_query_test.go | 4 +- internal/eventstore/v1/models/event.go | 6 +-- internal/eventstore/v3/event.go | 7 ++- internal/query/access_token.go | 7 ++- internal/query/current_state.go | 11 ++--- internal/query/current_state_test.go | 8 ++-- internal/query/user_grant.go | 4 +- internal/query/user_membership.go | 4 +- internal/v2/database/number_filter.go | 3 +- internal/v2/eventstore/event_store.go | 6 +-- internal/v2/eventstore/postgres/push_test.go | 24 +++++----- internal/v2/eventstore/postgres/query_test.go | 48 +++++++++---------- internal/v2/eventstore/query.go | 6 +-- internal/v2/eventstore/query_test.go | 26 +++++----- .../v2/readmodel/last_successful_mirror.go | 6 +-- internal/v2/system/mirror/succeeded.go | 6 +-- load-test/Makefile | 21 ++++---- load-test/src/use_cases/manipulate_user.ts | 1 - 47 files changed, 215 insertions(+), 319 deletions(-) diff --git a/cmd/mirror/event.go b/cmd/mirror/event.go index af0ac25e270..2bb0d52f45a 100644 --- a/cmd/mirror/event.go +++ b/cmd/mirror/event.go @@ -3,8 +3,6 @@ package mirror import ( "context" - "github.com/shopspring/decimal" - "github.com/zitadel/zitadel/internal/v2/eventstore" "github.com/zitadel/zitadel/internal/v2/projection" "github.com/zitadel/zitadel/internal/v2/readmodel" @@ -32,12 +30,12 @@ func queryLastSuccessfulMigration(ctx context.Context, destinationES *eventstore return lastSuccess, nil } -func writeMigrationStart(ctx context.Context, sourceES *eventstore.EventStore, id string, destination string) (_ decimal.Decimal, err error) { +func writeMigrationStart(ctx context.Context, sourceES *eventstore.EventStore, id string, destination string) (_ float64, err error) { var cmd *eventstore.Command if len(instanceIDs) > 0 { cmd, err = mirror_event.NewStartedInstancesCommand(destination, instanceIDs) if err != nil { - return decimal.Decimal{}, err + return 0, err } } else { cmd = mirror_event.NewStartedSystemCommand(destination) @@ -60,12 +58,12 @@ func writeMigrationStart(ctx context.Context, sourceES *eventstore.EventStore, i ), ) if err != nil { - return decimal.Decimal{}, err + return 0, err } return position.Position, nil } -func writeMigrationSucceeded(ctx context.Context, destinationES *eventstore.EventStore, id, source string, position decimal.Decimal) error { +func writeMigrationSucceeded(ctx context.Context, destinationES *eventstore.EventStore, id, source string, position float64) error { return destinationES.Push( ctx, eventstore.NewPushIntent( diff --git a/cmd/mirror/event_store.go b/cmd/mirror/event_store.go index 2eab4eb0da4..23145bdc37f 100644 --- a/cmd/mirror/event_store.go +++ b/cmd/mirror/event_store.go @@ -9,7 +9,6 @@ import ( "time" "github.com/jackc/pgx/v5/stdlib" - "github.com/shopspring/decimal" "github.com/spf13/cobra" "github.com/spf13/viper" "github.com/zitadel/logging" @@ -181,7 +180,7 @@ func copyEvents(ctx context.Context, source, dest *db.DB, bulkSize uint32) { logging.WithFields("took", time.Since(start), "count", eventCount).Info("events migrated") } -func writeCopyEventsDone(ctx context.Context, es *eventstore.EventStore, id, source string, position decimal.Decimal, errs <-chan error) { +func writeCopyEventsDone(ctx context.Context, es *eventstore.EventStore, id, source string, position float64, errs <-chan error) { joinedErrs := make([]error, 0, len(errs)) for err := range errs { joinedErrs = append(joinedErrs, err) diff --git a/go.mod b/go.mod index 0137251400b..2d6c8155200 100644 --- a/go.mod +++ b/go.mod @@ -38,7 +38,6 @@ require ( github.com/h2non/gock v1.2.0 github.com/hashicorp/golang-lru/v2 v2.0.7 github.com/improbable-eng/grpc-web v0.15.0 - github.com/jackc/pgx-shopspring-decimal v0.0.0-20220624020537-1d36b5a1853e github.com/jackc/pgx/v5 v5.6.0 github.com/jarcoal/jpath v0.0.0-20140328210829-f76b8b2dbf52 github.com/jinzhu/gorm v1.9.16 @@ -55,7 +54,6 @@ require ( github.com/rakyll/statik v0.1.7 github.com/rs/cors v1.11.0 github.com/santhosh-tekuri/jsonschema/v5 v5.3.1 - github.com/shopspring/decimal v1.4.0 github.com/sony/sonyflake v1.2.0 github.com/spf13/cobra v1.8.1 github.com/spf13/viper v1.19.0 diff --git a/go.sum b/go.sum index de6c80d5133..606f4048ccf 100644 --- a/go.sum +++ b/go.sum @@ -405,8 +405,6 @@ github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsI github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= github.com/jackc/pgservicefile v0.0.0-20231201235250-de7065d80cb9 h1:L0QtFUgDarD7Fpv9jeVMgy/+Ec0mtnmYuImjTz6dtDA= github.com/jackc/pgservicefile v0.0.0-20231201235250-de7065d80cb9/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= -github.com/jackc/pgx-shopspring-decimal v0.0.0-20220624020537-1d36b5a1853e h1:i3gQ/Zo7sk4LUVbsAjTNeC4gIjoPNIZVzs4EXstssV4= -github.com/jackc/pgx-shopspring-decimal v0.0.0-20220624020537-1d36b5a1853e/go.mod h1:zUHglCZ4mpDUPgIwqEKoba6+tcUQzRdb1+DPTuYe9pI= github.com/jackc/pgx/v5 v5.6.0 h1:SWJzexBzPL5jb0GEsrPMLIsi/3jOo7RHlzTjcAeDrPY= github.com/jackc/pgx/v5 v5.6.0/go.mod h1:DNZ/vlrUnhWCoFGxHAG8U2ljioxukquj7utPDgtQdTw= github.com/jackc/puddle/v2 v2.2.1 h1:RhxXJtFG022u4ibrCSMSiu5aOq1i77R3OHKNJj77OAk= @@ -651,8 +649,6 @@ github.com/samuel/go-zookeeper v0.0.0-20190923202752-2cc03de413da/go.mod h1:gi+0 github.com/santhosh-tekuri/jsonschema/v5 v5.3.1 h1:lZUw3E0/J3roVtGQ+SCrUrg3ON6NgVqpn3+iol9aGu4= github.com/santhosh-tekuri/jsonschema/v5 v5.3.1/go.mod h1:uToXkOrWAZ6/Oc07xWQrPOhJotwFIyu2bBVN41fcDUY= github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529/go.mod h1:DxrIzT+xaE7yg65j358z/aeFdxmN0P9QXhEzd20vsDc= -github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k= -github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME= github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc= github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo= github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE= diff --git a/internal/api/oidc/key.go b/internal/api/oidc/key.go index f7aa88409e3..a7e156fe781 100644 --- a/internal/api/oidc/key.go +++ b/internal/api/oidc/key.go @@ -11,7 +11,6 @@ import ( "github.com/go-jose/go-jose/v4" "github.com/jonboulle/clockwork" "github.com/muhlemmer/gu" - "github.com/shopspring/decimal" "github.com/zitadel/logging" "github.com/zitadel/oidc/v3/pkg/op" @@ -351,14 +350,14 @@ func (o *OPStorage) getSigningKey(ctx context.Context) (op.SigningKey, error) { if len(keys.Keys) > 0 { return o.privateKeyToSigningKey(selectSigningKey(keys.Keys)) } - var position decimal.Decimal + var position float64 if keys.State != nil { position = keys.State.Position } return nil, o.refreshSigningKey(ctx, o.signingKeyAlgorithm, position) } -func (o *OPStorage) refreshSigningKey(ctx context.Context, algorithm string, position decimal.Decimal) error { +func (o *OPStorage) refreshSigningKey(ctx context.Context, algorithm string, position float64) error { ok, err := o.ensureIsLatestKey(ctx, position) if err != nil || !ok { return zerrors.ThrowInternal(err, "OIDC-ASfh3", "cannot ensure that projection is up to date") @@ -370,12 +369,12 @@ func (o *OPStorage) refreshSigningKey(ctx context.Context, algorithm string, pos return zerrors.ThrowInternal(nil, "OIDC-Df1bh", "") } -func (o *OPStorage) ensureIsLatestKey(ctx context.Context, position decimal.Decimal) (bool, error) { +func (o *OPStorage) ensureIsLatestKey(ctx context.Context, position float64) (bool, error) { maxSequence, err := o.getMaxKeySequence(ctx) if err != nil { return false, fmt.Errorf("error retrieving new events: %w", err) } - return position.GreaterThanOrEqual(maxSequence), nil + return position >= maxSequence, nil } func (o *OPStorage) privateKeyToSigningKey(key query.PrivateKey) (_ op.SigningKey, err error) { @@ -413,9 +412,9 @@ func (o *OPStorage) lockAndGenerateSigningKeyPair(ctx context.Context, algorithm return o.command.GenerateSigningKeyPair(setOIDCCtx(ctx), algorithm) } -func (o *OPStorage) getMaxKeySequence(ctx context.Context) (decimal.Decimal, error) { - return o.eventstore.LatestPosition(ctx, - eventstore.NewSearchQueryBuilder(eventstore.ColumnsMaxPosition). +func (o *OPStorage) getMaxKeySequence(ctx context.Context) (float64, error) { + return o.eventstore.LatestSequence(ctx, + eventstore.NewSearchQueryBuilder(eventstore.ColumnsMaxSequence). ResourceOwner(authz.GetInstance(ctx).InstanceID()). AwaitOpenTransactions(). AllowTimeTravel(). diff --git a/internal/api/saml/certificate.go b/internal/api/saml/certificate.go index 079655391e3..2eac0e4d364 100644 --- a/internal/api/saml/certificate.go +++ b/internal/api/saml/certificate.go @@ -6,7 +6,6 @@ import ( "time" "github.com/go-jose/go-jose/v4" - "github.com/shopspring/decimal" "github.com/zitadel/logging" "github.com/zitadel/saml/pkg/provider/key" @@ -77,7 +76,7 @@ func (p *Storage) getCertificateAndKey(ctx context.Context, usage crypto.KeyUsag return p.certificateToCertificateAndKey(selectCertificate(certs.Certificates)) } - var position decimal.Decimal + var position float64 if certs.State != nil { position = certs.State.Position } @@ -88,7 +87,7 @@ func (p *Storage) getCertificateAndKey(ctx context.Context, usage crypto.KeyUsag func (p *Storage) refreshCertificate( ctx context.Context, usage crypto.KeyUsage, - position decimal.Decimal, + position float64, ) error { ok, err := p.ensureIsLatestCertificate(ctx, position) if err != nil { @@ -104,12 +103,12 @@ func (p *Storage) refreshCertificate( return nil } -func (p *Storage) ensureIsLatestCertificate(ctx context.Context, position decimal.Decimal) (bool, error) { +func (p *Storage) ensureIsLatestCertificate(ctx context.Context, position float64) (bool, error) { maxSequence, err := p.getMaxKeySequence(ctx) if err != nil { return false, fmt.Errorf("error retrieving new events: %w", err) } - return position.GreaterThanOrEqual(maxSequence), nil + return position >= maxSequence, nil } func (p *Storage) lockAndGenerateCertificateAndKey(ctx context.Context, usage crypto.KeyUsage) error { @@ -152,9 +151,9 @@ func (p *Storage) lockAndGenerateCertificateAndKey(ctx context.Context, usage cr } } -func (p *Storage) getMaxKeySequence(ctx context.Context) (decimal.Decimal, error) { - return p.eventstore.LatestPosition(ctx, - eventstore.NewSearchQueryBuilder(eventstore.ColumnsMaxPosition). +func (p *Storage) getMaxKeySequence(ctx context.Context) (float64, error) { + return p.eventstore.LatestSequence(ctx, + eventstore.NewSearchQueryBuilder(eventstore.ColumnsMaxSequence). ResourceOwner(authz.GetInstance(ctx).InstanceID()). AwaitOpenTransactions(). AddQuery(). diff --git a/internal/database/cockroach/crdb.go b/internal/database/cockroach/crdb.go index 988f678e161..2f685f6d92f 100644 --- a/internal/database/cockroach/crdb.go +++ b/internal/database/cockroach/crdb.go @@ -7,8 +7,6 @@ import ( "strings" "time" - pgxdecimal "github.com/jackc/pgx-shopspring-decimal" - "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgxpool" "github.com/jackc/pgx/v5/stdlib" "github.com/mitchellh/mapstructure" @@ -84,11 +82,6 @@ func (c *Config) Connect(useAdmin bool, pusherRatio, spoolerRatio float64, purpo return nil, err } - config.AfterConnect = func(ctx context.Context, conn *pgx.Conn) error { - pgxdecimal.Register(conn.TypeMap()) - return nil - } - if connConfig.MaxOpenConns != 0 { config.MaxConns = int32(connConfig.MaxOpenConns) } diff --git a/internal/database/postgres/pg.go b/internal/database/postgres/pg.go index 24b31a55fbc..ecafbe877ab 100644 --- a/internal/database/postgres/pg.go +++ b/internal/database/postgres/pg.go @@ -7,8 +7,6 @@ import ( "strings" "time" - pgxdecimal "github.com/jackc/pgx-shopspring-decimal" - "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgxpool" "github.com/jackc/pgx/v5/stdlib" "github.com/mitchellh/mapstructure" @@ -84,10 +82,6 @@ func (c *Config) Connect(useAdmin bool, pusherRatio, spoolerRatio float64, purpo if err != nil { return nil, err } - config.AfterConnect = func(ctx context.Context, conn *pgx.Conn) error { - pgxdecimal.Register(conn.TypeMap()) - return nil - } if connConfig.MaxOpenConns != 0 { config.MaxConns = int32(connConfig.MaxOpenConns) diff --git a/internal/eventstore/event.go b/internal/eventstore/event.go index 656a02f33d7..3df096f0690 100644 --- a/internal/eventstore/event.go +++ b/internal/eventstore/event.go @@ -5,8 +5,6 @@ import ( "reflect" "time" - "github.com/shopspring/decimal" - "github.com/zitadel/zitadel/internal/zerrors" ) @@ -46,7 +44,7 @@ type Event interface { // CreatedAt is the time the event was created at CreatedAt() time.Time // Position is the global position of the event - Position() decimal.Decimal + Position() float64 // Unmarshal parses the payload and stores the result // in the value pointed to by ptr. If ptr is nil or not a pointer, diff --git a/internal/eventstore/event_base.go b/internal/eventstore/event_base.go index 0cd5b6440cc..c2b56128a83 100644 --- a/internal/eventstore/event_base.go +++ b/internal/eventstore/event_base.go @@ -5,8 +5,6 @@ import ( "encoding/json" "time" - "github.com/shopspring/decimal" - "github.com/zitadel/zitadel/internal/api/authz" "github.com/zitadel/zitadel/internal/api/service" ) @@ -23,7 +21,7 @@ type BaseEvent struct { Agg *Aggregate Seq uint64 - Pos decimal.Decimal + Pos float64 Creation time.Time previousAggregateSequence uint64 previousAggregateTypeSequence uint64 @@ -36,7 +34,7 @@ type BaseEvent struct { } // Position implements Event. -func (e *BaseEvent) Position() decimal.Decimal { +func (e *BaseEvent) Position() float64 { return e.Pos } diff --git a/internal/eventstore/eventstore.go b/internal/eventstore/eventstore.go index 066a876da3e..e4561358285 100644 --- a/internal/eventstore/eventstore.go +++ b/internal/eventstore/eventstore.go @@ -8,7 +8,6 @@ import ( "time" "github.com/jackc/pgx/v5/pgconn" - "github.com/shopspring/decimal" "github.com/zitadel/logging" "github.com/zitadel/zitadel/internal/api/authz" @@ -218,11 +217,11 @@ func (es *Eventstore) FilterToReducer(ctx context.Context, searchQuery *SearchQu }) } -// LatestPosition filters the latest position for the given search query -func (es *Eventstore) LatestPosition(ctx context.Context, queryFactory *SearchQueryBuilder) (decimal.Decimal, error) { +// LatestSequence filters the latest sequence for the given search query +func (es *Eventstore) LatestSequence(ctx context.Context, queryFactory *SearchQueryBuilder) (float64, error) { queryFactory.InstanceID(authz.GetInstance(ctx).InstanceID()) - return es.querier.LatestPosition(ctx, queryFactory) + return es.querier.LatestSequence(ctx, queryFactory) } // InstanceIDs returns the instance ids found by the search query @@ -267,8 +266,8 @@ type Querier interface { Health(ctx context.Context) error // FilterToReducer calls r for every event returned from the storage FilterToReducer(ctx context.Context, searchQuery *SearchQueryBuilder, r Reducer) error - // LatestPosition returns the latest position found by the search query - LatestPosition(ctx context.Context, queryFactory *SearchQueryBuilder) (decimal.Decimal, error) + // LatestSequence returns the latest sequence found by the search query + LatestSequence(ctx context.Context, queryFactory *SearchQueryBuilder) (float64, error) // InstanceIDs returns the instance ids found by the search query InstanceIDs(ctx context.Context, queryFactory *SearchQueryBuilder) ([]string, error) } diff --git a/internal/eventstore/eventstore_querier_test.go b/internal/eventstore/eventstore_querier_test.go index 6a01c6fbf0b..856bb4a20e4 100644 --- a/internal/eventstore/eventstore_querier_test.go +++ b/internal/eventstore/eventstore_querier_test.go @@ -4,8 +4,6 @@ import ( "context" "testing" - "github.com/shopspring/decimal" - "github.com/zitadel/zitadel/internal/eventstore" ) @@ -100,7 +98,7 @@ func TestCRDB_Filter(t *testing.T) { } } -func TestCRDB_LatestPosition(t *testing.T) { +func TestCRDB_LatestSequence(t *testing.T) { type args struct { searchQuery *eventstore.SearchQueryBuilder } @@ -108,7 +106,7 @@ func TestCRDB_LatestPosition(t *testing.T) { existingEvents []eventstore.Command } type res struct { - position decimal.Decimal + sequence float64 } tests := []struct { name string @@ -120,7 +118,7 @@ func TestCRDB_LatestPosition(t *testing.T) { { name: "aggregate type filter no sequence", args: args{ - searchQuery: eventstore.NewSearchQueryBuilder(eventstore.ColumnsMaxPosition). + searchQuery: eventstore.NewSearchQueryBuilder(eventstore.ColumnsMaxSequence). AddQuery(). AggregateTypes("not found"). Builder(), @@ -137,7 +135,7 @@ func TestCRDB_LatestPosition(t *testing.T) { { name: "aggregate type filter sequence", args: args{ - searchQuery: eventstore.NewSearchQueryBuilder(eventstore.ColumnsMaxPosition). + searchQuery: eventstore.NewSearchQueryBuilder(eventstore.ColumnsMaxSequence). AddQuery(). AggregateTypes(eventstore.AggregateType(t.Name())). Builder(), @@ -171,12 +169,12 @@ func TestCRDB_LatestPosition(t *testing.T) { return } - position, err := db.LatestPosition(context.Background(), tt.args.searchQuery) + sequence, err := db.LatestSequence(context.Background(), tt.args.searchQuery) if (err != nil) != tt.wantErr { t.Errorf("CRDB.query() error = %v, wantErr %v", err, tt.wantErr) } - if tt.res.position.GreaterThan(position) { - t.Errorf("CRDB.query() expected sequence: %v got %v", tt.res.position, position) + if tt.res.sequence > sequence { + t.Errorf("CRDB.query() expected sequence: %v got %v", tt.res.sequence, sequence) } }) } diff --git a/internal/eventstore/eventstore_test.go b/internal/eventstore/eventstore_test.go index 86f7809f241..53ef4e54cff 100644 --- a/internal/eventstore/eventstore_test.go +++ b/internal/eventstore/eventstore_test.go @@ -9,7 +9,6 @@ import ( "time" "github.com/jackc/pgx/v5/pgconn" - "github.com/shopspring/decimal" "github.com/zitadel/zitadel/internal/api/authz" "github.com/zitadel/zitadel/internal/api/service" @@ -391,7 +390,7 @@ func (repo *testPusher) Push(ctx context.Context, commands ...Command) (events [ type testQuerier struct { events []Event - sequence decimal.Decimal + sequence float64 instances []string err error t *testing.T @@ -424,9 +423,9 @@ func (repo *testQuerier) FilterToReducer(ctx context.Context, searchQuery *Searc return nil } -func (repo *testQuerier) LatestPosition(ctx context.Context, queryFactory *SearchQueryBuilder) (decimal.Decimal, error) { +func (repo *testQuerier) LatestSequence(ctx context.Context, queryFactory *SearchQueryBuilder) (float64, error) { if repo.err != nil { - return decimal.Decimal{}, repo.err + return 0, repo.err } return repo.sequence, nil } @@ -1056,7 +1055,7 @@ func TestEventstore_FilterEvents(t *testing.T) { } } -func TestEventstore_LatestPosition(t *testing.T) { +func TestEventstore_LatestSequence(t *testing.T) { type args struct { query *SearchQueryBuilder } @@ -1076,7 +1075,7 @@ func TestEventstore_LatestPosition(t *testing.T) { name: "no events", args: args{ query: &SearchQueryBuilder{ - columns: ColumnsMaxPosition, + columns: ColumnsMaxSequence, queries: []*SearchQuery{ { builder: &SearchQueryBuilder{}, @@ -1099,7 +1098,7 @@ func TestEventstore_LatestPosition(t *testing.T) { name: "repo error", args: args{ query: &SearchQueryBuilder{ - columns: ColumnsMaxPosition, + columns: ColumnsMaxSequence, queries: []*SearchQuery{ { builder: &SearchQueryBuilder{}, @@ -1122,7 +1121,7 @@ func TestEventstore_LatestPosition(t *testing.T) { name: "found events", args: args{ query: &SearchQueryBuilder{ - columns: ColumnsMaxPosition, + columns: ColumnsMaxSequence, queries: []*SearchQuery{ { builder: &SearchQueryBuilder{}, @@ -1148,7 +1147,7 @@ func TestEventstore_LatestPosition(t *testing.T) { querier: tt.fields.repo, } - _, err := es.LatestPosition(context.Background(), tt.args.query) + _, err := es.LatestSequence(context.Background(), tt.args.query) if (err != nil) != tt.res.wantErr { t.Errorf("Eventstore.aggregatesToEvents() error = %v, wantErr %v", err, tt.res.wantErr) } diff --git a/internal/eventstore/handler/v2/field_handler.go b/internal/eventstore/handler/v2/field_handler.go index 2c371d67ec2..8b71f32519a 100644 --- a/internal/eventstore/handler/v2/field_handler.go +++ b/internal/eventstore/handler/v2/field_handler.go @@ -8,7 +8,6 @@ import ( "time" "github.com/jackc/pgx/v5/pgconn" - "github.com/shopspring/decimal" "github.com/zitadel/zitadel/internal/eventstore" ) @@ -124,7 +123,7 @@ func (h *FieldHandler) processEvents(ctx context.Context, config *triggerConfig) return additionalIteration, err } // stop execution if currentState.eventTimestamp >= config.maxCreatedAt - if !config.maxPosition.IsZero() && currentState.position.GreaterThanOrEqual(config.maxPosition) { + if config.maxPosition != 0 && currentState.position >= config.maxPosition { return false, nil } @@ -157,7 +156,7 @@ func (h *FieldHandler) fetchEvents(ctx context.Context, tx *sql.Tx, currentState idx, offset := skipPreviouslyReducedEvents(events, currentState) - if currentState.position.Equal(events[len(events)-1].Position()) { + if currentState.position == events[len(events)-1].Position() { offset += currentState.offset } currentState.position = events[len(events)-1].Position() @@ -187,9 +186,9 @@ func (h *FieldHandler) fetchEvents(ctx context.Context, tx *sql.Tx, currentState } func skipPreviouslyReducedEvents(events []eventstore.Event, currentState *state) (index int, offset uint32) { - var position decimal.Decimal + var position float64 for i, event := range events { - if !event.Position().Equal(position) { + if event.Position() != position { offset = 0 position = event.Position() } diff --git a/internal/eventstore/handler/v2/handler.go b/internal/eventstore/handler/v2/handler.go index aaefec2e9b3..b395035b8f6 100644 --- a/internal/eventstore/handler/v2/handler.go +++ b/internal/eventstore/handler/v2/handler.go @@ -4,13 +4,13 @@ import ( "context" "database/sql" "errors" + "math" "math/rand" "slices" "sync" "time" "github.com/jackc/pgx/v5/pgconn" - "github.com/shopspring/decimal" "github.com/zitadel/logging" "github.com/zitadel/zitadel/internal/api/authz" @@ -379,7 +379,7 @@ func (h *Handler) existingInstances(ctx context.Context) ([]string, error) { type triggerConfig struct { awaitRunning bool - maxPosition decimal.Decimal + maxPosition float64 } type TriggerOpt func(conf *triggerConfig) @@ -390,7 +390,7 @@ func WithAwaitRunning() TriggerOpt { } } -func WithMaxPosition(position decimal.Decimal) TriggerOpt { +func WithMaxPosition(position float64) TriggerOpt { return func(conf *triggerConfig) { conf.maxPosition = position } @@ -500,7 +500,7 @@ func (h *Handler) processEvents(ctx context.Context, config *triggerConfig) (add return additionalIteration, err } // stop execution if currentState.eventTimestamp >= config.maxCreatedAt - if !config.maxPosition.Equal(decimal.Decimal{}) && currentState.position.GreaterThanOrEqual(config.maxPosition) { + if config.maxPosition != 0 && currentState.position >= config.maxPosition { return false, nil } @@ -576,7 +576,7 @@ func (h *Handler) generateStatements(ctx context.Context, tx *sql.Tx, currentSta func skipPreviouslyReducedStatements(statements []*Statement, currentState *state) int { for i, statement := range statements { - if statement.Position.Equal(currentState.position) && + if statement.Position == currentState.position && statement.AggregateID == currentState.aggregateID && statement.AggregateType == currentState.aggregateType && statement.Sequence == currentState.sequence { @@ -609,14 +609,14 @@ func (h *Handler) executeStatement(ctx context.Context, tx *sql.Tx, currentState return nil } - _, err = tx.ExecContext(ctx, "SAVEPOINT exec") + _, err = tx.Exec("SAVEPOINT exec") if err != nil { h.log().WithError(err).Debug("create savepoint failed") return err } var shouldContinue bool defer func() { - _, errSave := tx.ExecContext(ctx, "RELEASE SAVEPOINT exec") + _, errSave := tx.Exec("RELEASE SAVEPOINT exec") if err == nil { err = errSave } @@ -644,8 +644,9 @@ func (h *Handler) eventQuery(currentState *state) *eventstore.SearchQueryBuilder OrderAsc(). InstanceID(currentState.instanceID) - if currentState.position.GreaterThan(decimal.Decimal{}) { - builder = builder.PositionGreaterEqual(currentState.position) + if currentState.position > 0 { + // decrease position by 10 because builder.PositionAfter filters for position > and we need position >= + builder = builder.PositionAfter(math.Float64frombits(math.Float64bits(currentState.position) - 10)) if currentState.offset > 0 { builder = builder.Offset(currentState.offset) } diff --git a/internal/eventstore/handler/v2/state.go b/internal/eventstore/handler/v2/state.go index c4afaed204b..d3b6953488c 100644 --- a/internal/eventstore/handler/v2/state.go +++ b/internal/eventstore/handler/v2/state.go @@ -7,8 +7,6 @@ import ( "errors" "time" - "github.com/shopspring/decimal" - "github.com/zitadel/zitadel/internal/api/authz" "github.com/zitadel/zitadel/internal/eventstore" "github.com/zitadel/zitadel/internal/zerrors" @@ -16,7 +14,7 @@ import ( type state struct { instanceID string - position decimal.Decimal + position float64 eventTimestamp time.Time aggregateType eventstore.AggregateType aggregateID string @@ -47,7 +45,7 @@ func (h *Handler) currentState(ctx context.Context, tx *sql.Tx, config *triggerC aggregateType = new(sql.NullString) sequence = new(sql.NullInt64) timestamp = new(sql.NullTime) - position = new(decimal.NullDecimal) + position = new(sql.NullFloat64) offset = new(sql.NullInt64) ) @@ -77,7 +75,7 @@ func (h *Handler) currentState(ctx context.Context, tx *sql.Tx, config *triggerC currentState.aggregateType = eventstore.AggregateType(aggregateType.String) currentState.sequence = uint64(sequence.Int64) currentState.eventTimestamp = timestamp.Time - currentState.position = position.Decimal + currentState.position = position.Float64 // psql does not provide unsigned numbers so we work around it currentState.offset = uint32(offset.Int64) return currentState, nil diff --git a/internal/eventstore/handler/v2/state_test.go b/internal/eventstore/handler/v2/state_test.go index ef91d78e553..cc5fb1fbab9 100644 --- a/internal/eventstore/handler/v2/state_test.go +++ b/internal/eventstore/handler/v2/state_test.go @@ -11,7 +11,6 @@ import ( "time" "github.com/jackc/pgx/v5/pgconn" - "github.com/shopspring/decimal" "github.com/zitadel/zitadel/internal/api/authz" "github.com/zitadel/zitadel/internal/database/mock" @@ -167,7 +166,7 @@ func TestHandler_updateLastUpdated(t *testing.T) { updatedState: &state{ instanceID: "instance", eventTimestamp: time.Now(), - position: decimal.NewFromInt(42), + position: 42, }, }, isErr: func(t *testing.T, err error) { @@ -193,7 +192,7 @@ func TestHandler_updateLastUpdated(t *testing.T) { updatedState: &state{ instanceID: "instance", eventTimestamp: time.Now(), - position: decimal.NewFromInt(42), + position: 42, }, }, isErr: func(t *testing.T, err error) { @@ -218,7 +217,7 @@ func TestHandler_updateLastUpdated(t *testing.T) { eventstore.AggregateType("aggregate type"), uint64(42), mock.AnyType[time.Time]{}, - decimal.NewFromInt(42), + float64(42), uint32(0), ), mock.WithExecRowsAffected(1), @@ -229,7 +228,7 @@ func TestHandler_updateLastUpdated(t *testing.T) { updatedState: &state{ instanceID: "instance", eventTimestamp: time.Now(), - position: decimal.NewFromInt(42), + position: 42, aggregateType: "aggregate type", aggregateID: "aggregate id", sequence: 42, @@ -398,7 +397,7 @@ func TestHandler_currentState(t *testing.T) { "aggregate type", int64(42), testTime, - decimal.NewFromInt(42).String(), + float64(42), uint16(10), }, }, @@ -413,7 +412,7 @@ func TestHandler_currentState(t *testing.T) { currentState: &state{ instanceID: "instance", eventTimestamp: testTime, - position: decimal.NewFromInt(42), + position: 42, aggregateType: "aggregate type", aggregateID: "aggregate id", sequence: 42, diff --git a/internal/eventstore/handler/v2/statement.go b/internal/eventstore/handler/v2/statement.go index 4bc660d9f91..207f3d0f580 100644 --- a/internal/eventstore/handler/v2/statement.go +++ b/internal/eventstore/handler/v2/statement.go @@ -9,7 +9,6 @@ import ( "strings" "time" - "github.com/shopspring/decimal" "github.com/zitadel/logging" "golang.org/x/exp/constraints" @@ -84,7 +83,7 @@ type Statement struct { AggregateType eventstore.AggregateType AggregateID string Sequence uint64 - Position decimal.Decimal + Position float64 CreationDate time.Time InstanceID string diff --git a/internal/eventstore/local_crdb_test.go b/internal/eventstore/local_crdb_test.go index e46509ba9fd..6df9e9fd298 100644 --- a/internal/eventstore/local_crdb_test.go +++ b/internal/eventstore/local_crdb_test.go @@ -2,16 +2,13 @@ package eventstore_test import ( "context" + "database/sql" "encoding/json" "os" "testing" "time" "github.com/cockroachdb/cockroach-go/v2/testserver" - pgxdecimal "github.com/jackc/pgx-shopspring-decimal" - "github.com/jackc/pgx/v5" - "github.com/jackc/pgx/v5/pgxpool" - "github.com/jackc/pgx/v5/stdlib" "github.com/zitadel/logging" "github.com/zitadel/zitadel/cmd/initialise" @@ -42,19 +39,10 @@ func TestMain(m *testing.M) { testCRDBClient = &database.DB{ Database: new(testDB), } - config, err := pgxpool.ParseConfig(ts.PGURL().String()) + testCRDBClient.DB, err = sql.Open("postgres", ts.PGURL().String()) if err != nil { - logging.WithFields("error", err).Fatal("unable to parse db config") + logging.WithFields("error", err).Fatal("unable to connect to db") } - config.AfterConnect = func(ctx context.Context, conn *pgx.Conn) error { - pgxdecimal.Register(conn.TypeMap()) - return nil - } - pool, err := pgxpool.NewWithConfig(context.Background(), config) - if err != nil { - logging.WithFields("error", err).Fatal("unable to create db pool") - } - testCRDBClient.DB = stdlib.OpenDBFromPool(pool) if err = testCRDBClient.Ping(); err != nil { logging.WithFields("error", err).Fatal("unable to ping db") } @@ -115,19 +103,10 @@ func initDB(db *database.DB) error { } func connectLocalhost() (*database.DB, error) { - config, err := pgxpool.ParseConfig("postgresql://root@localhost:26257/defaultdb?sslmode=disable") + client, err := sql.Open("pgx", "postgresql://root@localhost:26257/defaultdb?sslmode=disable") if err != nil { return nil, err } - config.AfterConnect = func(ctx context.Context, conn *pgx.Conn) error { - pgxdecimal.Register(conn.TypeMap()) - return nil - } - pool, err := pgxpool.NewWithConfig(context.Background(), config) - if err != nil { - return nil, err - } - client := stdlib.OpenDBFromPool(pool) if err = client.Ping(); err != nil { return nil, err } diff --git a/internal/eventstore/read_model.go b/internal/eventstore/read_model.go index ae772757322..d2c755cc3a1 100644 --- a/internal/eventstore/read_model.go +++ b/internal/eventstore/read_model.go @@ -1,23 +1,19 @@ package eventstore -import ( - "time" - - "github.com/shopspring/decimal" -) +import "time" // ReadModel is the minimum representation of a read model. // It implements a basic reducer // it might be saved in a database or in memory type ReadModel struct { - AggregateID string `json:"-"` - ProcessedSequence uint64 `json:"-"` - CreationDate time.Time `json:"-"` - ChangeDate time.Time `json:"-"` - Events []Event `json:"-"` - ResourceOwner string `json:"-"` - InstanceID string `json:"-"` - Position decimal.Decimal `json:"-"` + AggregateID string `json:"-"` + ProcessedSequence uint64 `json:"-"` + CreationDate time.Time `json:"-"` + ChangeDate time.Time `json:"-"` + Events []Event `json:"-"` + ResourceOwner string `json:"-"` + InstanceID string `json:"-"` + Position float64 `json:"-"` } // AppendEvents adds all the events to the read model. diff --git a/internal/eventstore/repository/event.go b/internal/eventstore/repository/event.go index 2f4c1d88438..57b85f15bad 100644 --- a/internal/eventstore/repository/event.go +++ b/internal/eventstore/repository/event.go @@ -5,8 +5,6 @@ import ( "encoding/json" "time" - "github.com/shopspring/decimal" - "github.com/zitadel/zitadel/internal/eventstore" ) @@ -20,7 +18,7 @@ type Event struct { // Seq is the sequence of the event Seq uint64 // Pos is the global sequence of the event multiple events can have the same sequence - Pos decimal.Decimal + Pos float64 //CreationDate is the time the event is created // it's used for human readability. @@ -93,7 +91,7 @@ func (e *Event) Sequence() uint64 { } // Position implements [eventstore.Event] -func (e *Event) Position() decimal.Decimal { +func (e *Event) Position() float64 { return e.Pos } diff --git a/internal/eventstore/repository/mock/repository.mock.go b/internal/eventstore/repository/mock/repository.mock.go index ebd5f501a2d..a854de29950 100644 --- a/internal/eventstore/repository/mock/repository.mock.go +++ b/internal/eventstore/repository/mock/repository.mock.go @@ -13,7 +13,6 @@ import ( context "context" reflect "reflect" - decimal "github.com/shopspring/decimal" eventstore "github.com/zitadel/zitadel/internal/eventstore" gomock "go.uber.org/mock/gomock" ) @@ -84,19 +83,19 @@ func (mr *MockQuerierMockRecorder) InstanceIDs(arg0, arg1 any) *gomock.Call { return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "InstanceIDs", reflect.TypeOf((*MockQuerier)(nil).InstanceIDs), arg0, arg1) } -// LatestPosition mocks base method. -func (m *MockQuerier) LatestPosition(arg0 context.Context, arg1 *eventstore.SearchQueryBuilder) (decimal.Decimal, error) { +// LatestSequence mocks base method. +func (m *MockQuerier) LatestSequence(arg0 context.Context, arg1 *eventstore.SearchQueryBuilder) (float64, error) { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "LatestPosition", arg0, arg1) - ret0, _ := ret[0].(decimal.Decimal) + ret := m.ctrl.Call(m, "LatestSequence", arg0, arg1) + ret0, _ := ret[0].(float64) ret1, _ := ret[1].(error) return ret0, ret1 } -// LatestPosition indicates an expected call of LatestPosition. -func (mr *MockQuerierMockRecorder) LatestPosition(arg0, arg1 any) *gomock.Call { +// LatestSequence indicates an expected call of LatestSequence. +func (mr *MockQuerierMockRecorder) LatestSequence(arg0, arg1 any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "LatestPosition", reflect.TypeOf((*MockQuerier)(nil).LatestPosition), arg0, arg1) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "LatestSequence", reflect.TypeOf((*MockQuerier)(nil).LatestSequence), arg0, arg1) } // MockPusher is a mock of Pusher interface. diff --git a/internal/eventstore/repository/mock/repository.mock.impl.go b/internal/eventstore/repository/mock/repository.mock.impl.go index 41ad4befd3e..d41521ad8f2 100644 --- a/internal/eventstore/repository/mock/repository.mock.impl.go +++ b/internal/eventstore/repository/mock/repository.mock.impl.go @@ -7,7 +7,6 @@ import ( "testing" "time" - "github.com/shopspring/decimal" "github.com/stretchr/testify/assert" "go.uber.org/mock/gomock" @@ -187,8 +186,8 @@ func (e *mockEvent) Sequence() uint64 { return e.sequence } -func (e *mockEvent) Position() decimal.Decimal { - return decimal.Decimal{} +func (e *mockEvent) Position() float64 { + return 0 } func (e *mockEvent) CreatedAt() time.Time { diff --git a/internal/eventstore/repository/search_query.go b/internal/eventstore/repository/search_query.go index b28574cc84d..39cca8b149d 100644 --- a/internal/eventstore/repository/search_query.go +++ b/internal/eventstore/repository/search_query.go @@ -55,8 +55,6 @@ const ( //OperationNotIn checks if a stored value does not match one of the passed value list OperationNotIn - OperationGreaterEqual - operationCount ) @@ -234,10 +232,10 @@ func instanceIDsFilter(builder *eventstore.SearchQueryBuilder, query *SearchQuer } func positionAfterFilter(builder *eventstore.SearchQueryBuilder, query *SearchQuery) *Filter { - if builder.GetPositionAfter().IsZero() { + if builder.GetPositionAfter() == 0 { return nil } - query.Position = NewFilter(FieldPosition, builder.GetPositionAfter(), OperationGreaterEqual) + query.Position = NewFilter(FieldPosition, builder.GetPositionAfter(), OperationGreater) return query.Position } diff --git a/internal/eventstore/repository/sql/crdb.go b/internal/eventstore/repository/sql/crdb.go index c778015497d..a60a2ef7b84 100644 --- a/internal/eventstore/repository/sql/crdb.go +++ b/internal/eventstore/repository/sql/crdb.go @@ -11,7 +11,6 @@ import ( "github.com/cockroachdb/cockroach-go/v2/crdb" "github.com/jackc/pgx/v5/pgconn" - "github.com/shopspring/decimal" "github.com/zitadel/logging" "github.com/zitadel/zitadel/internal/api/authz" @@ -266,11 +265,11 @@ func (crdb *CRDB) FilterToReducer(ctx context.Context, searchQuery *eventstore.S return err } -// LatestPosition returns the latest position found by the search query -func (db *CRDB) LatestPosition(ctx context.Context, searchQuery *eventstore.SearchQueryBuilder) (decimal.Decimal, error) { - var position decimal.Decimal +// LatestSequence returns the latest sequence found by the search query +func (db *CRDB) LatestSequence(ctx context.Context, searchQuery *eventstore.SearchQueryBuilder) (float64, error) { + var position sql.NullFloat64 err := query(ctx, db, searchQuery, &position, false) - return position, err + return position.Float64, err } // InstanceIDs returns the instance ids found by the search query @@ -337,7 +336,7 @@ func (db *CRDB) eventQuery(useV1 bool) string { " FROM eventstore.events2" } -func (db *CRDB) maxPositionQuery(useV1 bool) string { +func (db *CRDB) maxSequenceQuery(useV1 bool) string { if useV1 { return `SELECT event_sequence FROM eventstore.events` } @@ -415,8 +414,6 @@ func (db *CRDB) operation(operation repository.Operation) string { return "=" case repository.OperationGreater: return ">" - case repository.OperationGreaterEqual: - return ">=" case repository.OperationLess: return "<" case repository.OperationJSONContains: diff --git a/internal/eventstore/repository/sql/crdb_test.go b/internal/eventstore/repository/sql/crdb_test.go index aae2fde78dc..a3f3331a82f 100644 --- a/internal/eventstore/repository/sql/crdb_test.go +++ b/internal/eventstore/repository/sql/crdb_test.go @@ -4,8 +4,6 @@ import ( "database/sql" "testing" - "github.com/shopspring/decimal" - "github.com/zitadel/zitadel/internal/eventstore" "github.com/zitadel/zitadel/internal/eventstore/repository" ) @@ -314,7 +312,7 @@ func generateEvent(t *testing.T, aggregateID string, opts ...func(*repository.Ev ResourceOwner: sql.NullString{String: "ro", Valid: true}, Typ: "test.created", Version: "v1", - Pos: decimal.NewFromInt(42), + Pos: 42, } for _, opt := range opts { diff --git a/internal/eventstore/repository/sql/query.go b/internal/eventstore/repository/sql/query.go index c20bb62275d..3cddcb79246 100644 --- a/internal/eventstore/repository/sql/query.go +++ b/internal/eventstore/repository/sql/query.go @@ -9,7 +9,6 @@ import ( "strconv" "strings" - "github.com/shopspring/decimal" "github.com/zitadel/logging" "github.com/zitadel/zitadel/internal/api/call" @@ -26,7 +25,7 @@ type querier interface { conditionFormat(repository.Operation) string placeholder(query string) string eventQuery(useV1 bool) string - maxPositionQuery(useV1 bool) string + maxSequenceQuery(useV1 bool) string instanceIDsQuery(useV1 bool) string db() *database.DB orderByEventSequence(desc, shouldOrderBySequence, useV1 bool) string @@ -75,7 +74,7 @@ func query(ctx context.Context, criteria querier, searchQuery *eventstore.Search // instead of using the max function of the database (which doesn't work for postgres) // we select the most recent row - if q.Columns == eventstore.ColumnsMaxPosition { + if q.Columns == eventstore.ColumnsMaxSequence { q.Limit = 1 q.Desc = true } @@ -92,7 +91,7 @@ func query(ctx context.Context, criteria querier, searchQuery *eventstore.Search switch q.Columns { case eventstore.ColumnsEvent, - eventstore.ColumnsMaxPosition: + eventstore.ColumnsMaxSequence: query += criteria.orderByEventSequence(q.Desc, shouldOrderBySequence, useV1) } @@ -136,8 +135,8 @@ func query(ctx context.Context, criteria querier, searchQuery *eventstore.Search func prepareColumns(criteria querier, columns eventstore.Columns, useV1 bool) (string, func(s scan, dest interface{}) error) { switch columns { - case eventstore.ColumnsMaxPosition: - return criteria.maxPositionQuery(useV1), maxPositionScanner + case eventstore.ColumnsMaxSequence: + return criteria.maxSequenceQuery(useV1), maxSequenceScanner case eventstore.ColumnsInstanceIDs: return criteria.instanceIDsQuery(useV1), instanceIDsScanner case eventstore.ColumnsEvent: @@ -155,15 +154,13 @@ func prepareTimeTravel(ctx context.Context, criteria querier, allow bool) string return criteria.Timetravel(took) } -func maxPositionScanner(row scan, dest interface{}) (err error) { - position, ok := dest.(*decimal.Decimal) +func maxSequenceScanner(row scan, dest interface{}) (err error) { + position, ok := dest.(*sql.NullFloat64) if !ok { - return zerrors.ThrowInvalidArgumentf(nil, "SQL-NBjA9", "type must be decimal.Decimal got: %T", dest) + return zerrors.ThrowInvalidArgumentf(nil, "SQL-NBjA9", "type must be sql.NullInt64 got: %T", dest) } - var res decimal.NullDecimal - err = row(&res) + err = row(position) if err == nil || errors.Is(err, sql.ErrNoRows) { - *position = res.Decimal return nil } return zerrors.ThrowInternal(err, "SQL-bN5xg", "something went wrong") @@ -192,7 +189,7 @@ func eventsScanner(useV1 bool) func(scanner scan, dest interface{}) (err error) return zerrors.ThrowInvalidArgumentf(nil, "SQL-4GP6F", "events scanner: invalid type %T", dest) } event := new(repository.Event) - position := new(decimal.NullDecimal) + position := new(sql.NullFloat64) if useV1 { err = scanner( @@ -229,7 +226,7 @@ func eventsScanner(useV1 bool) func(scanner scan, dest interface{}) (err error) logging.New().WithError(err).Warn("unable to scan row") return zerrors.ThrowInternal(err, "SQL-M0dsf", "unable to scan row") } - event.Pos = position.Decimal + event.Pos = position.Float64 return reduce(event) } } diff --git a/internal/eventstore/repository/sql/query_test.go b/internal/eventstore/repository/sql/query_test.go index 654fa6d0b55..5d54b27c218 100644 --- a/internal/eventstore/repository/sql/query_test.go +++ b/internal/eventstore/repository/sql/query_test.go @@ -10,7 +10,6 @@ import ( "time" "github.com/DATA-DOG/go-sqlmock" - "github.com/shopspring/decimal" "github.com/stretchr/testify/assert" "github.com/zitadel/zitadel/internal/database" @@ -110,36 +109,36 @@ func Test_prepareColumns(t *testing.T) { { name: "max column", args: args{ - columns: eventstore.ColumnsMaxPosition, - dest: new(decimal.Decimal), + columns: eventstore.ColumnsMaxSequence, + dest: new(sql.NullFloat64), useV1: true, }, res: res{ query: `SELECT event_sequence FROM eventstore.events`, - expected: decimal.NewFromInt(42), + expected: sql.NullFloat64{Float64: 43, Valid: true}, }, fields: fields{ - dbRow: []interface{}{decimal.NewNullDecimal(decimal.NewFromInt(42))}, + dbRow: []interface{}{sql.NullFloat64{Float64: 43, Valid: true}}, }, }, { name: "max column v2", args: args{ - columns: eventstore.ColumnsMaxPosition, - dest: new(decimal.Decimal), + columns: eventstore.ColumnsMaxSequence, + dest: new(sql.NullFloat64), }, res: res{ query: `SELECT "position" FROM eventstore.events2`, - expected: decimal.NewFromInt(42), + expected: sql.NullFloat64{Float64: 43, Valid: true}, }, fields: fields{ - dbRow: []interface{}{decimal.NewNullDecimal(decimal.NewFromInt(42))}, + dbRow: []interface{}{sql.NullFloat64{Float64: 43, Valid: true}}, }, }, { name: "max sequence wrong dest type", args: args{ - columns: eventstore.ColumnsMaxPosition, + columns: eventstore.ColumnsMaxSequence, dest: new(uint64), }, res: res{ @@ -179,11 +178,11 @@ func Test_prepareColumns(t *testing.T) { res: res{ query: `SELECT created_at, event_type, "sequence", "position", payload, creator, "owner", instance_id, aggregate_type, aggregate_id, revision FROM eventstore.events2`, expected: []eventstore.Event{ - &repository.Event{AggregateID: "hodor", AggregateType: "user", Seq: 5, Pos: decimal.NewFromInt(42), Data: nil, Version: "v1"}, + &repository.Event{AggregateID: "hodor", AggregateType: "user", Seq: 5, Pos: 42, Data: nil, Version: "v1"}, }, }, fields: fields{ - dbRow: []interface{}{time.Time{}, eventstore.EventType(""), uint64(5), decimal.NewNullDecimal(decimal.NewFromInt(42)), sql.RawBytes(nil), "", sql.NullString{}, "", eventstore.AggregateType("user"), "hodor", uint8(1)}, + dbRow: []interface{}{time.Time{}, eventstore.EventType(""), uint64(5), sql.NullFloat64{Float64: 42, Valid: true}, sql.RawBytes(nil), "", sql.NullString{}, "", eventstore.AggregateType("user"), "hodor", uint8(1)}, }, }, { @@ -198,11 +197,11 @@ func Test_prepareColumns(t *testing.T) { res: res{ query: `SELECT created_at, event_type, "sequence", "position", payload, creator, "owner", instance_id, aggregate_type, aggregate_id, revision FROM eventstore.events2`, expected: []eventstore.Event{ - &repository.Event{AggregateID: "hodor", AggregateType: "user", Seq: 5, Pos: decimal.Decimal{}, Data: nil, Version: "v1"}, + &repository.Event{AggregateID: "hodor", AggregateType: "user", Seq: 5, Pos: 0, Data: nil, Version: "v1"}, }, }, fields: fields{ - dbRow: []interface{}{time.Time{}, eventstore.EventType(""), uint64(5), decimal.NullDecimal{}, sql.RawBytes(nil), "", sql.NullString{}, "", eventstore.AggregateType("user"), "hodor", uint8(1)}, + dbRow: []interface{}{time.Time{}, eventstore.EventType(""), uint64(5), sql.NullFloat64{Float64: 0, Valid: false}, sql.RawBytes(nil), "", sql.NullString{}, "", eventstore.AggregateType("user"), "hodor", uint8(1)}, }, }, { diff --git a/internal/eventstore/search_query.go b/internal/eventstore/search_query.go index c7f9a65da30..0c08a260ebb 100644 --- a/internal/eventstore/search_query.go +++ b/internal/eventstore/search_query.go @@ -5,8 +5,6 @@ import ( "database/sql" "time" - "github.com/shopspring/decimal" - "github.com/zitadel/zitadel/internal/api/authz" "github.com/zitadel/zitadel/internal/zerrors" ) @@ -25,7 +23,7 @@ type SearchQueryBuilder struct { queries []*SearchQuery tx *sql.Tx allowTimeTravel bool - positionGreaterEqual decimal.Decimal + positionAfter float64 awaitOpenTransactions bool creationDateAfter time.Time creationDateBefore time.Time @@ -76,8 +74,8 @@ func (b *SearchQueryBuilder) GetAllowTimeTravel() bool { return b.allowTimeTravel } -func (b SearchQueryBuilder) GetPositionAfter() decimal.Decimal { - return b.positionGreaterEqual +func (b SearchQueryBuilder) GetPositionAfter() float64 { + return b.positionAfter } func (b SearchQueryBuilder) GetAwaitOpenTransactions() bool { @@ -133,8 +131,8 @@ type Columns int8 const ( //ColumnsEvent represents all fields of an event ColumnsEvent = iota + 1 - // ColumnsMaxPosition represents the latest sequence of the filtered events - ColumnsMaxPosition + // ColumnsMaxSequence represents the latest sequence of the filtered events + ColumnsMaxSequence // ColumnsInstanceIDs represents the instance ids of the filtered events ColumnsInstanceIDs @@ -269,8 +267,8 @@ func (builder *SearchQueryBuilder) AllowTimeTravel() *SearchQueryBuilder { } // PositionAfter filters for events which happened after the specified time -func (builder *SearchQueryBuilder) PositionGreaterEqual(position decimal.Decimal) *SearchQueryBuilder { - builder.positionGreaterEqual = position +func (builder *SearchQueryBuilder) PositionAfter(position float64) *SearchQueryBuilder { + builder.positionAfter = position return builder } diff --git a/internal/eventstore/search_query_test.go b/internal/eventstore/search_query_test.go index da8acf878d9..8c654911ea7 100644 --- a/internal/eventstore/search_query_test.go +++ b/internal/eventstore/search_query_test.go @@ -116,10 +116,10 @@ func TestSearchQuerybuilderSetters(t *testing.T) { { name: "set columns", args: args{ - setters: []func(*SearchQueryBuilder) *SearchQueryBuilder{testSetColumns(ColumnsMaxPosition)}, + setters: []func(*SearchQueryBuilder) *SearchQueryBuilder{testSetColumns(ColumnsMaxSequence)}, }, res: &SearchQueryBuilder{ - columns: ColumnsMaxPosition, + columns: ColumnsMaxSequence, }, }, { diff --git a/internal/eventstore/v1/models/event.go b/internal/eventstore/v1/models/event.go index ab2b608872f..8c50d64da07 100644 --- a/internal/eventstore/v1/models/event.go +++ b/internal/eventstore/v1/models/event.go @@ -5,8 +5,6 @@ import ( "reflect" "time" - "github.com/shopspring/decimal" - "github.com/zitadel/zitadel/internal/eventstore" "github.com/zitadel/zitadel/internal/zerrors" ) @@ -22,7 +20,7 @@ var _ eventstore.Event = (*Event)(nil) type Event struct { ID string Seq uint64 - Pos decimal.Decimal + Pos float64 CreationDate time.Time Typ eventstore.EventType PreviousSequence uint64 @@ -82,7 +80,7 @@ func (e *Event) Sequence() uint64 { } // Position implements [eventstore.Event] -func (e *Event) Position() decimal.Decimal { +func (e *Event) Position() float64 { return e.Pos } diff --git a/internal/eventstore/v3/event.go b/internal/eventstore/v3/event.go index f489d983960..e1c95f13ff9 100644 --- a/internal/eventstore/v3/event.go +++ b/internal/eventstore/v3/event.go @@ -4,7 +4,6 @@ import ( "encoding/json" "time" - "github.com/shopspring/decimal" "github.com/zitadel/logging" "github.com/zitadel/zitadel/internal/eventstore" @@ -22,7 +21,7 @@ type event struct { typ eventstore.EventType createdAt time.Time sequence uint64 - position decimal.Decimal + position float64 payload Payload } @@ -85,8 +84,8 @@ func (e *event) Sequence() uint64 { return e.sequence } -// Position implements [eventstore.Event] -func (e *event) Position() decimal.Decimal { +// Sequence implements [eventstore.Event] +func (e *event) Position() float64 { return e.position } diff --git a/internal/query/access_token.go b/internal/query/access_token.go index a5edc068cdc..4180a6ad5e4 100644 --- a/internal/query/access_token.go +++ b/internal/query/access_token.go @@ -5,7 +5,6 @@ import ( "strings" "time" - "github.com/shopspring/decimal" "golang.org/x/text/language" "github.com/zitadel/zitadel/internal/domain" @@ -141,7 +140,7 @@ func (q *Queries) accessTokenByOIDCSessionAndTokenID(ctx context.Context, oidcSe // checkSessionNotTerminatedAfter checks if a [session.TerminateType] event (or user events leading to a session termination) // occurred after a certain time and will return an error if so. -func (q *Queries) checkSessionNotTerminatedAfter(ctx context.Context, sessionID, userID string, position decimal.Decimal, fingerprintID string) (err error) { +func (q *Queries) checkSessionNotTerminatedAfter(ctx context.Context, sessionID, userID string, position float64, fingerprintID string) (err error) { ctx, span := tracing.NewSpan(ctx) defer func() { span.EndWithError(err) }() @@ -163,7 +162,7 @@ func (q *Queries) checkSessionNotTerminatedAfter(ctx context.Context, sessionID, } type sessionTerminatedModel struct { - position decimal.Decimal + position float64 sessionID string userID string fingerPrintID string @@ -183,7 +182,7 @@ func (s *sessionTerminatedModel) AppendEvents(events ...eventstore.Event) { func (s *sessionTerminatedModel) Query() *eventstore.SearchQueryBuilder { query := eventstore.NewSearchQueryBuilder(eventstore.ColumnsEvent). - PositionGreaterEqual(s.position). + PositionAfter(s.position). AddQuery(). AggregateTypes(session.AggregateType). AggregateIDs(s.sessionID). diff --git a/internal/query/current_state.go b/internal/query/current_state.go index 790b594c2d7..29497e6eec2 100644 --- a/internal/query/current_state.go +++ b/internal/query/current_state.go @@ -10,7 +10,6 @@ import ( "time" sq "github.com/Masterminds/squirrel" - "github.com/shopspring/decimal" "github.com/zitadel/logging" "github.com/zitadel/zitadel/internal/api/call" @@ -27,7 +26,7 @@ type Stateful interface { type State struct { LastRun time.Time - Position decimal.Decimal + Position float64 EventCreatedAt time.Time AggregateID string AggregateType eventstore.AggregateType @@ -222,7 +221,7 @@ func prepareLatestState(ctx context.Context, db prepareDatabase) (sq.SelectBuild var ( creationDate sql.NullTime lastUpdated sql.NullTime - position decimal.NullDecimal + position sql.NullFloat64 ) err := row.Scan( &creationDate, @@ -235,7 +234,7 @@ func prepareLatestState(ctx context.Context, db prepareDatabase) (sq.SelectBuild return &State{ EventCreatedAt: creationDate.Time, LastRun: lastUpdated.Time, - Position: position.Decimal, + Position: position.Float64, }, nil } } @@ -260,7 +259,7 @@ func prepareCurrentStateQuery(ctx context.Context, db prepareDatabase) (sq.Selec var ( lastRun sql.NullTime eventDate sql.NullTime - currentPosition decimal.NullDecimal + currentPosition sql.NullFloat64 aggregateType sql.NullString aggregateID sql.NullString sequence sql.NullInt64 @@ -281,7 +280,7 @@ func prepareCurrentStateQuery(ctx context.Context, db prepareDatabase) (sq.Selec } currentState.State.EventCreatedAt = eventDate.Time currentState.State.LastRun = lastRun.Time - currentState.Position = currentPosition.Decimal + currentState.Position = currentPosition.Float64 currentState.AggregateType = eventstore.AggregateType(aggregateType.String) currentState.AggregateID = aggregateID.String currentState.Sequence = uint64(sequence.Int64) diff --git a/internal/query/current_state_test.go b/internal/query/current_state_test.go index f17509aa9c8..c76dae710e8 100644 --- a/internal/query/current_state_test.go +++ b/internal/query/current_state_test.go @@ -7,8 +7,6 @@ import ( "fmt" "regexp" "testing" - - "github.com/shopspring/decimal" ) var ( @@ -89,7 +87,7 @@ func Test_CurrentSequencesPrepares(t *testing.T) { State: State{ EventCreatedAt: testNow, LastRun: testNow, - Position: decimal.NewFromInt(20211108), + Position: 20211108, AggregateID: "agg-id", AggregateType: "agg-type", Sequence: 20211108, @@ -136,7 +134,7 @@ func Test_CurrentSequencesPrepares(t *testing.T) { ProjectionName: "projection-name", State: State{ EventCreatedAt: testNow, - Position: decimal.NewFromInt(20211108), + Position: 20211108, LastRun: testNow, AggregateID: "agg-id", AggregateType: "agg-type", @@ -147,7 +145,7 @@ func Test_CurrentSequencesPrepares(t *testing.T) { ProjectionName: "projection-name2", State: State{ EventCreatedAt: testNow, - Position: decimal.NewFromInt(20211108), + Position: 20211108, LastRun: testNow, AggregateID: "agg-id", AggregateType: "agg-type", diff --git a/internal/query/user_grant.go b/internal/query/user_grant.go index e2bbabdc720..265d8eaae1d 100644 --- a/internal/query/user_grant.go +++ b/internal/query/user_grant.go @@ -281,7 +281,7 @@ func (q *Queries) UserGrants(ctx context.Context, queries *UserGrantsQueries, sh return nil, zerrors.ThrowInternal(err, "QUERY-wXnQR", "Errors.Query.SQLStatement") } - latestState, err := q.latestState(ctx, userGrantTable) + latestSequence, err := q.latestState(ctx, userGrantTable) if err != nil { return nil, err } @@ -294,7 +294,7 @@ func (q *Queries) UserGrants(ctx context.Context, queries *UserGrantsQueries, sh return nil, err } - grants.State = latestState + grants.State = latestSequence return grants, nil } diff --git a/internal/query/user_membership.go b/internal/query/user_membership.go index a08617d57b5..7ba2629cfae 100644 --- a/internal/query/user_membership.go +++ b/internal/query/user_membership.go @@ -144,7 +144,7 @@ func (q *Queries) Memberships(ctx context.Context, queries *MembershipSearchQuer if err != nil { return nil, zerrors.ThrowInvalidArgument(err, "QUERY-T84X9", "Errors.Query.InvalidRequest") } - latestState, err := q.latestState(ctx, orgMemberTable, instanceMemberTable, projectMemberTable, projectGrantMemberTable) + latestSequence, err := q.latestState(ctx, orgMemberTable, instanceMemberTable, projectMemberTable, projectGrantMemberTable) if err != nil { return nil, err } @@ -157,7 +157,7 @@ func (q *Queries) Memberships(ctx context.Context, queries *MembershipSearchQuer if err != nil { return nil, err } - memberships.State = latestState + memberships.State = latestSequence return memberships, nil } diff --git a/internal/v2/database/number_filter.go b/internal/v2/database/number_filter.go index 48538064576..ce263ceeee6 100644 --- a/internal/v2/database/number_filter.go +++ b/internal/v2/database/number_filter.go @@ -3,7 +3,6 @@ package database import ( "time" - "github.com/shopspring/decimal" "github.com/zitadel/logging" "golang.org/x/exp/constraints" ) @@ -95,7 +94,7 @@ func (c numberCompare) String() string { } type number interface { - constraints.Integer | constraints.Float | time.Time | decimal.Decimal + constraints.Integer | constraints.Float | time.Time // TODO: condition must know if it's args are named parameters or not // constraints.Integer | constraints.Float | time.Time | placeholder } diff --git a/internal/v2/eventstore/event_store.go b/internal/v2/eventstore/event_store.go index e89786c657d..cc447c5e15b 100644 --- a/internal/v2/eventstore/event_store.go +++ b/internal/v2/eventstore/event_store.go @@ -2,8 +2,6 @@ package eventstore import ( "context" - - "github.com/shopspring/decimal" ) func NewEventstore(querier Querier, pusher Pusher) *EventStore { @@ -32,12 +30,12 @@ type healthier interface { } type GlobalPosition struct { - Position decimal.Decimal + Position float64 InPositionOrder uint32 } func (gp GlobalPosition) IsLess(other GlobalPosition) bool { - return gp.Position.LessThan(other.Position) || (gp.Position.Equal(other.Position) && gp.InPositionOrder < other.InPositionOrder) + return gp.Position < other.Position || (gp.Position == other.Position && gp.InPositionOrder < other.InPositionOrder) } type Reducer interface { diff --git a/internal/v2/eventstore/postgres/push_test.go b/internal/v2/eventstore/postgres/push_test.go index 6f8b224c415..91fdc1fcd7f 100644 --- a/internal/v2/eventstore/postgres/push_test.go +++ b/internal/v2/eventstore/postgres/push_test.go @@ -8,8 +8,6 @@ import ( "testing" "time" - "github.com/shopspring/decimal" - "github.com/zitadel/zitadel/internal/v2/database/mock" "github.com/zitadel/zitadel/internal/v2/eventstore" "github.com/zitadel/zitadel/internal/zerrors" @@ -820,7 +818,7 @@ func Test_push(t *testing.T) { [][]driver.Value{ { time.Now(), - decimal.NewFromFloat(123).String(), + float64(123), }, }, ), @@ -901,11 +899,11 @@ func Test_push(t *testing.T) { [][]driver.Value{ { time.Now(), - decimal.NewFromFloat(123).String(), + float64(123), }, { time.Now(), - decimal.NewFromFloat(123.1).String(), + float64(123.1), }, }, ), @@ -986,11 +984,11 @@ func Test_push(t *testing.T) { [][]driver.Value{ { time.Now(), - decimal.NewFromFloat(123).String(), + float64(123), }, { time.Now(), - decimal.NewFromFloat(123.1).String(), + float64(123.1), }, }, ), @@ -1046,7 +1044,7 @@ func Test_push(t *testing.T) { [][]driver.Value{ { time.Now(), - decimal.NewFromFloat(123).String(), + float64(123), }, }, ), @@ -1101,7 +1099,7 @@ func Test_push(t *testing.T) { [][]driver.Value{ { time.Now(), - decimal.NewFromFloat(123).String(), + float64(123), }, }, ), @@ -1183,11 +1181,11 @@ func Test_push(t *testing.T) { [][]driver.Value{ { time.Now(), - decimal.NewFromFloat(123).String(), + float64(123), }, { time.Now(), - decimal.NewFromFloat(123.1).String(), + float64(123.1), }, }, ), @@ -1274,11 +1272,11 @@ func Test_push(t *testing.T) { [][]driver.Value{ { time.Now(), - decimal.NewFromFloat(123).String(), + float64(123), }, { time.Now(), - decimal.NewFromFloat(123.1).String(), + float64(123.1), }, }, ), diff --git a/internal/v2/eventstore/postgres/query_test.go b/internal/v2/eventstore/postgres/query_test.go index 34b73bd820f..56f506ac509 100644 --- a/internal/v2/eventstore/postgres/query_test.go +++ b/internal/v2/eventstore/postgres/query_test.go @@ -8,8 +8,6 @@ import ( "testing" "time" - "github.com/shopspring/decimal" - "github.com/zitadel/zitadel/internal/v2/database" "github.com/zitadel/zitadel/internal/v2/database/mock" "github.com/zitadel/zitadel/internal/v2/eventstore" @@ -543,13 +541,13 @@ func Test_writeFilter(t *testing.T) { args: args{ filter: eventstore.NewFilter( eventstore.FilterPagination( - eventstore.PositionGreater(decimal.NewFromFloat(123.4), 0), + eventstore.PositionGreater(123.4, 0), ), ), }, want: wantQuery{ query: " WHERE instance_id = $1 AND position > $2 ORDER BY position, in_tx_order", - args: []any{"i1", decimal.NewFromFloat(123.4)}, + args: []any{"i1", 123.4}, }, }, { @@ -557,18 +555,18 @@ func Test_writeFilter(t *testing.T) { args: args{ filter: eventstore.NewFilter( eventstore.FilterPagination( - // eventstore.PositionGreater(decimal.NewFromFloat(123.4), 0), + // eventstore.PositionGreater(123.4, 0), // eventstore.PositionLess(125.4, 10), eventstore.PositionBetween( - &eventstore.GlobalPosition{Position: decimal.NewFromFloat(123.4)}, - &eventstore.GlobalPosition{Position: decimal.NewFromFloat(125.4), InPositionOrder: 10}, + &eventstore.GlobalPosition{Position: 123.4}, + &eventstore.GlobalPosition{Position: 125.4, InPositionOrder: 10}, ), ), ), }, want: wantQuery{ query: " WHERE instance_id = $1 AND ((position = $2 AND in_tx_order < $3) OR position < $4) AND position > $5 ORDER BY position, in_tx_order", - args: []any{"i1", decimal.NewFromFloat(125.4), uint32(10), decimal.NewFromFloat(125.4), decimal.NewFromFloat(123.4)}, + args: []any{"i1", 125.4, uint32(10), 125.4, 123.4}, // TODO: (adlerhurst) would require some refactoring to reuse existing args // query: " WHERE instance_id = $1 AND position > $2 AND ((position = $3 AND in_tx_order < $4) OR position < $3) ORDER BY position, in_tx_order", // args: []any{"i1", 123.4, 125.4, uint32(10)}, @@ -579,13 +577,13 @@ func Test_writeFilter(t *testing.T) { args: args{ filter: eventstore.NewFilter( eventstore.FilterPagination( - eventstore.PositionGreater(decimal.NewFromFloat(123.4), 12), + eventstore.PositionGreater(123.4, 12), ), ), }, want: wantQuery{ query: " WHERE instance_id = $1 AND ((position = $2 AND in_tx_order > $3) OR position > $4) ORDER BY position, in_tx_order", - args: []any{"i1", decimal.NewFromFloat(123.4), uint32(12), decimal.NewFromFloat(123.4)}, + args: []any{"i1", 123.4, uint32(12), 123.4}, }, }, { @@ -595,13 +593,13 @@ func Test_writeFilter(t *testing.T) { eventstore.FilterPagination( eventstore.Limit(10), eventstore.Offset(3), - eventstore.PositionGreater(decimal.NewFromFloat(123.4), 12), + eventstore.PositionGreater(123.4, 12), ), ), }, want: wantQuery{ query: " WHERE instance_id = $1 AND ((position = $2 AND in_tx_order > $3) OR position > $4) ORDER BY position, in_tx_order LIMIT $5 OFFSET $6", - args: []any{"i1", decimal.NewFromFloat(123.4), uint32(12), decimal.NewFromFloat(123.4), uint32(10), uint32(3)}, + args: []any{"i1", 123.4, uint32(12), 123.4, uint32(10), uint32(3)}, }, }, { @@ -611,14 +609,14 @@ func Test_writeFilter(t *testing.T) { eventstore.FilterPagination( eventstore.Limit(10), eventstore.Offset(3), - eventstore.PositionGreater(decimal.NewFromFloat(123.4), 12), + eventstore.PositionGreater(123.4, 12), ), eventstore.AppendAggregateFilter("user"), ), }, want: wantQuery{ query: " WHERE instance_id = $1 AND aggregate_type = $2 AND ((position = $3 AND in_tx_order > $4) OR position > $5) ORDER BY position, in_tx_order LIMIT $6 OFFSET $7", - args: []any{"i1", "user", decimal.NewFromFloat(123.4), uint32(12), decimal.NewFromFloat(123.4), uint32(10), uint32(3)}, + args: []any{"i1", "user", 123.4, uint32(12), 123.4, uint32(10), uint32(3)}, }, }, { @@ -628,7 +626,7 @@ func Test_writeFilter(t *testing.T) { eventstore.FilterPagination( eventstore.Limit(10), eventstore.Offset(3), - eventstore.PositionGreater(decimal.NewFromFloat(123.4), 12), + eventstore.PositionGreater(123.4, 12), ), eventstore.AppendAggregateFilter("user"), eventstore.AppendAggregateFilter( @@ -639,7 +637,7 @@ func Test_writeFilter(t *testing.T) { }, want: wantQuery{ query: " WHERE instance_id = $1 AND (aggregate_type = $2 OR (aggregate_type = $3 AND aggregate_id = $4)) AND ((position = $5 AND in_tx_order > $6) OR position > $7) ORDER BY position, in_tx_order LIMIT $8 OFFSET $9", - args: []any{"i1", "user", "org", "o1", decimal.NewFromFloat(123.4), uint32(12), decimal.NewFromFloat(123.4), uint32(10), uint32(3)}, + args: []any{"i1", "user", "org", "o1", 123.4, uint32(12), 123.4, uint32(10), uint32(3)}, }, }, } @@ -958,7 +956,7 @@ func Test_writeQueryUse_examples(t *testing.T) { ), eventstore.FilterPagination( // used because we need to check for first login and an app which is not console - eventstore.PositionGreater(decimal.NewFromInt(12), 4), + eventstore.PositionGreater(12, 4), ), ), eventstore.NewFilter( @@ -1067,9 +1065,9 @@ func Test_writeQueryUse_examples(t *testing.T) { "instance", "user", "user.token.added", - decimal.NewFromInt(12), + float64(12), uint32(4), - decimal.NewFromInt(12), + float64(12), "instance", "instance", []string{"instance.idp.config.added", "instance.idp.oauth.added", "instance.idp.oidc.added", "instance.idp.jwt.added", "instance.idp.azure.added", "instance.idp.github.added", "instance.idp.github.enterprise.added", "instance.idp.gitlab.added", "instance.idp.gitlab.selfhosted.added", "instance.idp.google.added", "instance.idp.ldap.added", "instance.idp.config.apple.added", "instance.idp.saml.added"}, @@ -1203,7 +1201,7 @@ func Test_executeQuery(t *testing.T) { time.Now(), "event.type", uint32(23), - decimal.NewFromInt(123).String(), + float64(123), uint32(0), nil, "gigi", @@ -1237,7 +1235,7 @@ func Test_executeQuery(t *testing.T) { time.Now(), "event.type", uint32(23), - decimal.NewFromInt(123).String(), + float64(123), uint32(0), []byte(`{"name": "gigi"}`), "gigi", @@ -1271,7 +1269,7 @@ func Test_executeQuery(t *testing.T) { time.Now(), "event.type", uint32(23), - decimal.NewFromInt(123).String(), + float64(123), uint32(0), nil, "gigi", @@ -1285,7 +1283,7 @@ func Test_executeQuery(t *testing.T) { time.Now(), "event.type", uint32(24), - decimal.NewFromInt(124).String(), + float64(124), uint32(0), []byte(`{"name": "gigi"}`), "gigi", @@ -1319,7 +1317,7 @@ func Test_executeQuery(t *testing.T) { time.Now(), "event.type", uint32(23), - decimal.NewFromInt(123).String(), + float64(123), uint32(0), nil, "gigi", @@ -1333,7 +1331,7 @@ func Test_executeQuery(t *testing.T) { time.Now(), "event.type", uint32(24), - decimal.NewFromInt(124).String(), + float64(124), uint32(0), []byte(`{"name": "gigi"}`), "gigi", diff --git a/internal/v2/eventstore/query.go b/internal/v2/eventstore/query.go index f7a30a2139d..c9b3cecd379 100644 --- a/internal/v2/eventstore/query.go +++ b/internal/v2/eventstore/query.go @@ -7,8 +7,6 @@ import ( "slices" "time" - "github.com/shopspring/decimal" - "github.com/zitadel/zitadel/internal/v2/database" ) @@ -725,7 +723,7 @@ func (pc *PositionCondition) Min() *GlobalPosition { // PositionGreater prepares the condition as follows // if inPositionOrder is set: position = AND in_tx_order > OR or position > // if inPositionOrder is NOT set: position > -func PositionGreater(position decimal.Decimal, inPositionOrder uint32) paginationOpt { +func PositionGreater(position float64, inPositionOrder uint32) paginationOpt { return func(p *Pagination) { p.ensurePosition() p.position.min = &GlobalPosition{ @@ -745,7 +743,7 @@ func GlobalPositionGreater(position *GlobalPosition) paginationOpt { // PositionLess prepares the condition as follows // if inPositionOrder is set: position = AND in_tx_order > OR or position > // if inPositionOrder is NOT set: position > -func PositionLess(position decimal.Decimal, inPositionOrder uint32) paginationOpt { +func PositionLess(position float64, inPositionOrder uint32) paginationOpt { return func(p *Pagination) { p.ensurePosition() p.position.max = &GlobalPosition{ diff --git a/internal/v2/eventstore/query_test.go b/internal/v2/eventstore/query_test.go index 0f313e95608..00c08914c19 100644 --- a/internal/v2/eventstore/query_test.go +++ b/internal/v2/eventstore/query_test.go @@ -6,8 +6,6 @@ import ( "testing" "time" - "github.com/shopspring/decimal" - "github.com/zitadel/zitadel/internal/v2/database" ) @@ -76,13 +74,13 @@ func TestPaginationOpt(t *testing.T) { name: "global position greater", args: args{ opts: []paginationOpt{ - GlobalPositionGreater(&GlobalPosition{Position: decimal.NewFromInt(10)}), + GlobalPositionGreater(&GlobalPosition{Position: 10}), }, }, want: &Pagination{ position: &PositionCondition{ min: &GlobalPosition{ - Position: decimal.NewFromInt(10), + Position: 10, InPositionOrder: 0, }, }, @@ -92,13 +90,13 @@ func TestPaginationOpt(t *testing.T) { name: "position greater", args: args{ opts: []paginationOpt{ - PositionGreater(decimal.NewFromInt(10), 0), + PositionGreater(10, 0), }, }, want: &Pagination{ position: &PositionCondition{ min: &GlobalPosition{ - Position: decimal.NewFromInt(10), + Position: 10, InPositionOrder: 0, }, }, @@ -109,13 +107,13 @@ func TestPaginationOpt(t *testing.T) { name: "position less", args: args{ opts: []paginationOpt{ - PositionLess(decimal.NewFromInt(10), 12), + PositionLess(10, 12), }, }, want: &Pagination{ position: &PositionCondition{ max: &GlobalPosition{ - Position: decimal.NewFromInt(10), + Position: 10, InPositionOrder: 12, }, }, @@ -125,13 +123,13 @@ func TestPaginationOpt(t *testing.T) { name: "global position less", args: args{ opts: []paginationOpt{ - GlobalPositionLess(&GlobalPosition{Position: decimal.NewFromInt(12), InPositionOrder: 24}), + GlobalPositionLess(&GlobalPosition{Position: 12, InPositionOrder: 24}), }, }, want: &Pagination{ position: &PositionCondition{ max: &GlobalPosition{ - Position: decimal.NewFromInt(12), + Position: 12, InPositionOrder: 24, }, }, @@ -142,19 +140,19 @@ func TestPaginationOpt(t *testing.T) { args: args{ opts: []paginationOpt{ PositionBetween( - &GlobalPosition{decimal.NewFromInt(10), 12}, - &GlobalPosition{decimal.NewFromInt(20), 0}, + &GlobalPosition{10, 12}, + &GlobalPosition{20, 0}, ), }, }, want: &Pagination{ position: &PositionCondition{ min: &GlobalPosition{ - Position: decimal.NewFromInt(10), + Position: 10, InPositionOrder: 12, }, max: &GlobalPosition{ - Position: decimal.NewFromInt(20), + Position: 20, InPositionOrder: 0, }, }, diff --git a/internal/v2/readmodel/last_successful_mirror.go b/internal/v2/readmodel/last_successful_mirror.go index 6635b73342b..80b436b896a 100644 --- a/internal/v2/readmodel/last_successful_mirror.go +++ b/internal/v2/readmodel/last_successful_mirror.go @@ -1,8 +1,6 @@ package readmodel import ( - "github.com/shopspring/decimal" - "github.com/zitadel/zitadel/internal/v2/eventstore" "github.com/zitadel/zitadel/internal/v2/system" "github.com/zitadel/zitadel/internal/v2/system/mirror" @@ -10,7 +8,7 @@ import ( type LastSuccessfulMirror struct { ID string - Position decimal.Decimal + Position float64 source string } @@ -55,7 +53,7 @@ func (h *LastSuccessfulMirror) Reduce(events ...*eventstore.StorageEvent) (err e func (h *LastSuccessfulMirror) reduceSucceeded(event *eventstore.StorageEvent) error { // if position is set we skip all older events - if h.Position.GreaterThan(decimal.NewFromInt(0)) { + if h.Position > 0 { return nil } diff --git a/internal/v2/system/mirror/succeeded.go b/internal/v2/system/mirror/succeeded.go index 34d74f184fd..6d0fba2c257 100644 --- a/internal/v2/system/mirror/succeeded.go +++ b/internal/v2/system/mirror/succeeded.go @@ -1,8 +1,6 @@ package mirror import ( - "github.com/shopspring/decimal" - "github.com/zitadel/zitadel/internal/v2/eventstore" "github.com/zitadel/zitadel/internal/zerrors" ) @@ -11,7 +9,7 @@ type succeededPayload struct { // Source is the name of the database data are mirrored from Source string `json:"source"` // Position until data will be mirrored - Position decimal.Decimal `json:"position"` + Position float64 `json:"position"` } const SucceededType = eventTypePrefix + "succeeded" @@ -40,7 +38,7 @@ func SucceededEventFromStorage(event *eventstore.StorageEvent) (e *SucceededEven }, nil } -func NewSucceededCommand(source string, position decimal.Decimal) *eventstore.Command { +func NewSucceededCommand(source string, position float64) *eventstore.Command { return &eventstore.Command{ Action: eventstore.Action[any]{ Creator: Creator, diff --git a/load-test/Makefile b/load-test/Makefile index bbd5ebf538f..61494e27cbe 100644 --- a/load-test/Makefile +++ b/load-test/Makefile @@ -4,43 +4,42 @@ ZITADEL_HOST ?= ADMIN_LOGIN_NAME ?= ADMIN_PASSWORD ?= -K6 := ./../../xk6-modules/k6 - .PHONY: human_password_login human_password_login: bundle - ${K6} run --summary-trend-stats "min,avg,max,p(50),p(95),p(99)" dist/human_password_login.js --vus ${VUS} --duration ${DURATION} + k6 run --summary-trend-stats "min,avg,max,p(50),p(95),p(99)" dist/human_password_login.js --vus ${VUS} --duration ${DURATION} .PHONY: machine_pat_login machine_pat_login: bundle - ${K6} run --summary-trend-stats "min,avg,max,p(50),p(95),p(99)" dist/machine_pat_login.js --vus ${VUS} --duration ${DURATION} + k6 run --summary-trend-stats "min,avg,max,p(50),p(95),p(99)" dist/machine_pat_login.js --vus ${VUS} --duration ${DURATION} .PHONY: machine_client_credentials_login machine_client_credentials_login: bundle - ${K6} run --summary-trend-stats "min,avg,max,p(50),p(95),p(99)" dist/machine_client_credentials_login.js --vus ${VUS} --duration ${DURATION} + k6 run --summary-trend-stats "min,avg,max,p(50),p(95),p(99)" dist/machine_client_credentials_login.js --vus ${VUS} --duration ${DURATION} .PHONY: user_info user_info: bundle - ${K6} run --summary-trend-stats "min,avg,max,p(50),p(95),p(99)" dist/user_info.js --vus ${VUS} --duration ${DURATION} + k6 run --summary-trend-stats "min,avg,max,p(50),p(95),p(99)" dist/user_info.js --vus ${VUS} --duration ${DURATION} .PHONY: manipulate_user manipulate_user: bundle - ${K6} run --summary-trend-stats "min,avg,max,p(50),p(95),p(99)" dist/manipulate_user.js --vus ${VUS} --duration ${DURATION} + k6 run --summary-trend-stats "min,avg,max,p(50),p(95),p(99)" dist/manipulate_user.js --vus ${VUS} --duration ${DURATION} .PHONY: introspect introspect: ensure_modules bundle go install go.k6.io/xk6/cmd/xk6@latest cd ../../xk6-modules && xk6 build --with xk6-zitadel=. - ${K6} run --summary-trend-stats "min,avg,max,p(50),p(95),p(99)" dist/introspection.js --vus ${VUS} --duration ${DURATION} + ./../../xk6-modules/k6 run --summary-trend-stats "min,avg,max,p(50),p(95),p(99)" dist/introspection.js --vus ${VUS} --duration ${DURATION} .PHONY: add_session add_session: bundle - ${K6} run --summary-trend-stats "min,avg,max,p(50),p(95),p(99)" dist/session.js --vus ${VUS} --duration ${DURATION} + k6 run --summary-trend-stats "min,avg,max,p(50),p(95),p(99)" dist/session.js --vus ${VUS} --duration ${DURATION} .PHONY: machine_jwt_profile_grant machine_jwt_profile_grant: ensure_modules ensure_key_pair bundle go install go.k6.io/xk6/cmd/xk6@latest cd ../../xk6-modules && xk6 build --with xk6-zitadel=. - ${K6} run --summary-trend-stats "min,avg,max,p(50),p(95),p(99)" dist/machine_jwt_profile_grant.js --vus ${VUS} --duration ${DURATION} + ./../../xk6-modules/k6 run --summary-trend-stats "min,avg,max,p(50),p(95),p(99)" dist/machine_jwt_profile_grant.js --iterations 1 + # --vus ${VUS} --duration ${DURATION} .PHONY: machine_jwt_profile_grant_single_user machine_jwt_profile_grant_single_user: ensure_modules ensure_key_pair bundle @@ -65,8 +64,6 @@ endif bundle: npm i npm run bundle - go install go.k6.io/xk6/cmd/xk6@latest - cd ../../xk6-modules && xk6 build --with xk6-zitadel=. .PHONY: ensure_key_pair ensure_key_pair: diff --git a/load-test/src/use_cases/manipulate_user.ts b/load-test/src/use_cases/manipulate_user.ts index 058bde05f12..2ea53bd3242 100644 --- a/load-test/src/use_cases/manipulate_user.ts +++ b/load-test/src/use_cases/manipulate_user.ts @@ -45,4 +45,3 @@ export function teardown(data: any) { removeOrg(data.org, data.tokens.accessToken); console.info('teardown: org removed'); } - From 624fee97c0521e54c56289e290fc5499a6df70da Mon Sep 17 00:00:00 2001 From: mffap Date: Wed, 25 Sep 2024 10:14:58 +0200 Subject: [PATCH 2/4] docs: optimized examples and sdk for search (#8657) # Which Problems Are Solved Page title was "introduction" and the headings were missing a h2 level. This makes it difficult to index for search, both internal and external. # How the Problems Are Solved * Change the page title * Pulled all headings one level up # Additional Changes - Show all elements in sdk-example folder automaticalls --- docs/docs/sdk-examples/introduction.mdx | 13 +++++++------ docs/sidebars.js | 16 ++++------------ docs/src/components/tile.jsx | 2 +- 3 files changed, 12 insertions(+), 19 deletions(-) diff --git a/docs/docs/sdk-examples/introduction.mdx b/docs/docs/sdk-examples/introduction.mdx index 45e2465fb8d..de928731c35 100644 --- a/docs/docs/sdk-examples/introduction.mdx +++ b/docs/docs/sdk-examples/introduction.mdx @@ -1,6 +1,7 @@ --- -title: Introduction +title: Examples and SDKs for ZITADEL sidebar_label: Introduction +sidebar_position: 1 --- You can integrate ZITADEL quickly into your application and be up and running within minutes. @@ -10,7 +11,7 @@ The SDKs and integration depend on the framework and language you are using. import { Frameworks } from "../../src/components/frameworks"; -### Resources +## Resources @@ -20,7 +21,7 @@ To further streamline your setup, simply visit the console in ZITADEL where you To begin configuring login for any of these samples, start [here](https://zitadel.com/signin). -### OIDC Libraries +## OIDC Libraries OIDC is a standard for authentication and most languages and frameworks do provide a OIDC library which can be easily integrated to your application. If we do not provide an specific example, SDK or guide, we strongly recommend using existing authentication libraries for your @@ -34,7 +35,7 @@ You might want to check out the following links to find a good library: - [OpenID General References](https://openid.net/developers/libraries/) - [OpenID certified developer tools](https://openid.net/certified-open-id-developer-tools/) -### Other example applications +## Other example applications - [B2B customer portal](https://github.com/zitadel/zitadel-nextjs-b2b): Showcase the use of personal access tokens in a B2B environment. Uses NextJS Framework. - [Frontend with backend API](https://github.com/zitadel/example-quote-generator-app): A simple web application using a React front-end and a Python back-end API, both secured using ZITADEL @@ -43,7 +44,7 @@ You might want to check out the following links to find a good library: Search for the "example" tag in our repository to [explore all examples](https://github.com/search?q=topic%3Aexamples+org%3Azitadel&type=repositories). -### Missing SDK +## Missing SDK Is your language/framework missing? Fear not, you can generate your gRPC API Client with ease. @@ -54,7 +55,7 @@ Is your language/framework missing? Fear not, you can generate your gRPC API Cli Let us make an example with Ruby. Any other supported language by buf will work as well. Consult the [buf plugin registry](https://buf.build/plugins) for more ideas. -#### Example with Ruby +### Example with Ruby With gRPC we usually need to generate the client stub and the messages/types. This is why we need two plugins. The plugin `grpc/ruby` generates the client stub and the plugin `protocolbuffers/ruby` takes care of the messages/types. diff --git a/docs/sidebars.js b/docs/sidebars.js index 1e26ec6074d..38596d4bfc7 100644 --- a/docs/sidebars.js +++ b/docs/sidebars.js @@ -54,18 +54,10 @@ module.exports = { label: "Examples & SDKs", link: {type: "doc", id: "sdk-examples/introduction"}, items: [ - "sdk-examples/introduction", - "sdk-examples/angular", - "sdk-examples/flutter", - "sdk-examples/go", - "sdk-examples/java", - "sdk-examples/nestjs", - "sdk-examples/nextjs", - "sdk-examples/python-flask", - "sdk-examples/python-django", - "sdk-examples/react", - "sdk-examples/symfony", - "sdk-examples/vue", + { + type: "autogenerated", + dirName: "sdk-examples" + }, { type: "link", label: "Dart", diff --git a/docs/src/components/tile.jsx b/docs/src/components/tile.jsx index e1066325ceb..1ce2a1c1f8e 100644 --- a/docs/src/components/tile.jsx +++ b/docs/src/components/tile.jsx @@ -8,7 +8,7 @@ export function Tile({ title, imageSource, imageSourceLight, link, external }) { className={styles.tile} target={external ? "_blank" : "_self"} > -

{title}

+

{title}

Date: Wed, 25 Sep 2024 15:31:31 +0200 Subject: [PATCH 3/4] feat: user v3 contact email and phone (#8644) # Which Problems Are Solved Endpoints to maintain email and phone contact on user v3 are not implemented. # How the Problems Are Solved Add 3 endpoints with SetContactEmail, VerifyContactEmail and ResendContactEmailCode. Add 3 endpoints with SetContactPhone, VerifyContactPhone and ResendContactPhoneCode. Refactor the logic how contact is managed in the user creation and update. # Additional Changes None # Additional Context - part of https://github.com/zitadel/zitadel/issues/6433 --------- Co-authored-by: Livio Spring --- cmd/start/start.go | 2 +- .../api/grpc/resources/user/v3alpha/email.go | 83 ++ .../v3alpha/integration_test/email_test.go | 772 ++++++++++++ .../v3alpha/integration_test/phone_test.go | 701 +++++++++++ .../v3alpha/integration_test/server_test.go | 12 +- .../v3alpha/integration_test/user_test.go | 33 +- .../api/grpc/resources/user/v3alpha/phone.go | 81 ++ .../api/grpc/resources/user/v3alpha/query.go | 14 + .../api/grpc/resources/user/v3alpha/server.go | 8 +- .../api/grpc/resources/user/v3alpha/user.go | 88 +- .../v3alpha/integration_test/server_test.go | 12 +- internal/command/command.go | 17 + internal/command/user_v3.go | 283 ++--- internal/command/user_v3_email.go | 115 ++ internal/command/user_v3_email_test.go | 1076 +++++++++++++++++ internal/command/user_v3_model.go | 510 +++++++- internal/command/user_v3_phone.go | 107 ++ internal/command/user_v3_phone_test.go | 1040 ++++++++++++++++ internal/command/user_v3_test.go | 374 +++--- internal/integration/client.go | 26 + .../resources/user/v3alpha/user_service.proto | 2 +- 21 files changed, 4924 insertions(+), 432 deletions(-) create mode 100644 internal/api/grpc/resources/user/v3alpha/email.go create mode 100644 internal/api/grpc/resources/user/v3alpha/integration_test/email_test.go create mode 100644 internal/api/grpc/resources/user/v3alpha/integration_test/phone_test.go create mode 100644 internal/api/grpc/resources/user/v3alpha/phone.go create mode 100644 internal/api/grpc/resources/user/v3alpha/query.go create mode 100644 internal/command/user_v3_email.go create mode 100644 internal/command/user_v3_email_test.go create mode 100644 internal/command/user_v3_phone.go create mode 100644 internal/command/user_v3_phone_test.go diff --git a/cmd/start/start.go b/cmd/start/start.go index 5fc4ba936a4..68c5bdb9154 100644 --- a/cmd/start/start.go +++ b/cmd/start/start.go @@ -454,7 +454,7 @@ func startAPIs( if err := apis.RegisterService(ctx, userschema_v3_alpha.CreateServer(config.SystemDefaults, commands, queries)); err != nil { return nil, err } - if err := apis.RegisterService(ctx, user_v3_alpha.CreateServer(commands, keys.User)); err != nil { + if err := apis.RegisterService(ctx, user_v3_alpha.CreateServer(commands)); err != nil { return nil, err } if err := apis.RegisterService(ctx, webkey.CreateServer(commands, queries)); err != nil { diff --git a/internal/api/grpc/resources/user/v3alpha/email.go b/internal/api/grpc/resources/user/v3alpha/email.go new file mode 100644 index 00000000000..7b0b561cd97 --- /dev/null +++ b/internal/api/grpc/resources/user/v3alpha/email.go @@ -0,0 +1,83 @@ +package user + +import ( + "context" + + resource_object "github.com/zitadel/zitadel/internal/api/grpc/resources/object/v3alpha" + "github.com/zitadel/zitadel/internal/command" + "github.com/zitadel/zitadel/internal/domain" + object "github.com/zitadel/zitadel/pkg/grpc/object/v3alpha" + user "github.com/zitadel/zitadel/pkg/grpc/resources/user/v3alpha" +) + +func (s *Server) SetContactEmail(ctx context.Context, req *user.SetContactEmailRequest) (_ *user.SetContactEmailResponse, err error) { + if err := checkUserSchemaEnabled(ctx); err != nil { + return nil, err + } + schemauser := setContactEmailRequestToChangeSchemaUserEmail(req) + details, err := s.command.ChangeSchemaUserEmail(ctx, schemauser) + if err != nil { + return nil, err + } + return &user.SetContactEmailResponse{ + Details: resource_object.DomainToDetailsPb(details, object.OwnerType_OWNER_TYPE_ORG, details.ResourceOwner), + VerificationCode: schemauser.ReturnCode, + }, nil +} + +func setContactEmailRequestToChangeSchemaUserEmail(req *user.SetContactEmailRequest) *command.ChangeSchemaUserEmail { + return &command.ChangeSchemaUserEmail{ + ResourceOwner: organizationToUpdateResourceOwner(req.Organization), + ID: req.GetId(), + Email: setEmailToEmail(req.Email), + } +} + +func setEmailToEmail(setEmail *user.SetEmail) *command.Email { + if setEmail == nil { + return nil + } + return &command.Email{ + Address: domain.EmailAddress(setEmail.Address), + ReturnCode: setEmail.GetReturnCode() != nil, + Verified: setEmail.GetIsVerified(), + URLTemplate: setEmail.GetSendCode().GetUrlTemplate(), + } +} + +func (s *Server) VerifyContactEmail(ctx context.Context, req *user.VerifyContactEmailRequest) (_ *user.VerifyContactEmailResponse, err error) { + if err := checkUserSchemaEnabled(ctx); err != nil { + return nil, err + } + details, err := s.command.VerifySchemaUserEmail(ctx, organizationToUpdateResourceOwner(req.Organization), req.GetId(), req.GetVerificationCode()) + if err != nil { + return nil, err + } + return &user.VerifyContactEmailResponse{ + Details: resource_object.DomainToDetailsPb(details, object.OwnerType_OWNER_TYPE_ORG, details.ResourceOwner), + }, nil +} + +func (s *Server) ResendContactEmailCode(ctx context.Context, req *user.ResendContactEmailCodeRequest) (_ *user.ResendContactEmailCodeResponse, err error) { + if err := checkUserSchemaEnabled(ctx); err != nil { + return nil, err + } + schemauser := resendContactEmailCodeRequestToResendSchemaUserEmailCode(req) + details, err := s.command.ResendSchemaUserEmailCode(ctx, schemauser) + if err != nil { + return nil, err + } + return &user.ResendContactEmailCodeResponse{ + Details: resource_object.DomainToDetailsPb(details, object.OwnerType_OWNER_TYPE_ORG, details.ResourceOwner), + VerificationCode: schemauser.PlainCode, + }, nil +} + +func resendContactEmailCodeRequestToResendSchemaUserEmailCode(req *user.ResendContactEmailCodeRequest) *command.ResendSchemaUserEmailCode { + return &command.ResendSchemaUserEmailCode{ + ResourceOwner: organizationToUpdateResourceOwner(req.Organization), + ID: req.GetId(), + URLTemplate: req.GetSendCode().GetUrlTemplate(), + ReturnCode: req.GetReturnCode() != nil, + } +} diff --git a/internal/api/grpc/resources/user/v3alpha/integration_test/email_test.go b/internal/api/grpc/resources/user/v3alpha/integration_test/email_test.go new file mode 100644 index 00000000000..c5bec9008e2 --- /dev/null +++ b/internal/api/grpc/resources/user/v3alpha/integration_test/email_test.go @@ -0,0 +1,772 @@ +//go:build integration + +package user_test + +import ( + "context" + "testing" + + "github.com/brianvoe/gofakeit/v6" + "github.com/muhlemmer/gu" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/protobuf/types/known/timestamppb" + + "github.com/zitadel/zitadel/internal/integration" + object "github.com/zitadel/zitadel/pkg/grpc/object/v3alpha" + resource_object "github.com/zitadel/zitadel/pkg/grpc/resources/object/v3alpha" + user "github.com/zitadel/zitadel/pkg/grpc/resources/user/v3alpha" +) + +func TestServer_SetContactEmail(t *testing.T) { + t.Parallel() + instance := integration.NewInstance(CTX) + ensureFeatureEnabled(t, instance) + isolatedIAMOwnerCTX := instance.WithAuthorization(CTX, integration.UserTypeIAMOwner) + + schema := []byte(`{ + "$schema": "urn:zitadel:schema:v1", + "type": "object", + "properties": { + "name": { + "type": "string" + } + } + }`) + schemaResp := instance.CreateUserSchema(isolatedIAMOwnerCTX, schema) + orgResp := instance.CreateOrganization(isolatedIAMOwnerCTX, gofakeit.Name(), gofakeit.Email()) + + type res struct { + want *resource_object.Details + returnCode bool + } + tests := []struct { + name string + ctx context.Context + dep func(req *user.SetContactEmailRequest) error + req *user.SetContactEmailRequest + res res + wantErr bool + }{ + { + name: "email patch, no context", + ctx: context.Background(), + dep: func(req *user.SetContactEmailRequest) error { + userResp := instance.CreateSchemaUser(isolatedIAMOwnerCTX, orgResp.GetOrganizationId(), schemaResp.GetDetails().GetId(), []byte("{\"name\": \"user\"}")) + req.Id = userResp.GetDetails().GetId() + return nil + }, + req: &user.SetContactEmailRequest{ + Organization: &object.Organization{ + Property: &object.Organization_OrgId{ + OrgId: orgResp.GetOrganizationId(), + }, + }, + Email: &user.SetEmail{ + Address: gofakeit.Email(), + }, + }, + wantErr: true, + }, + { + name: "email patch, no permission", + ctx: instance.WithAuthorization(CTX, integration.UserTypeLogin), + dep: func(req *user.SetContactEmailRequest) error { + userResp := instance.CreateSchemaUser(isolatedIAMOwnerCTX, orgResp.GetOrganizationId(), schemaResp.GetDetails().GetId(), []byte("{\"name\": \"user\"}")) + req.Id = userResp.GetDetails().GetId() + return nil + }, + req: &user.SetContactEmailRequest{ + Organization: &object.Organization{ + Property: &object.Organization_OrgId{ + OrgId: orgResp.GetOrganizationId(), + }, + }, + Email: &user.SetEmail{ + Address: gofakeit.Email(), + }, + }, + wantErr: true, + }, + { + name: "email patch, not found", + ctx: isolatedIAMOwnerCTX, + dep: func(req *user.SetContactEmailRequest) error { + return nil + }, + req: &user.SetContactEmailRequest{ + Organization: &object.Organization{ + Property: &object.Organization_OrgId{ + OrgId: orgResp.GetOrganizationId(), + }, + }, + Id: "notexisting", + Email: &user.SetEmail{ + Address: gofakeit.Email(), + }, + }, + wantErr: true, + }, + { + name: "email patch, not found, org", + ctx: isolatedIAMOwnerCTX, + dep: func(req *user.SetContactEmailRequest) error { + userResp := instance.CreateSchemaUser(isolatedIAMOwnerCTX, orgResp.GetOrganizationId(), schemaResp.GetDetails().GetId(), []byte("{\"name\": \"user\"}")) + req.Id = userResp.GetDetails().GetId() + return nil + }, + req: &user.SetContactEmailRequest{ + Organization: &object.Organization{ + Property: &object.Organization_OrgId{ + OrgId: "not existing", + }, + }, + Email: &user.SetEmail{ + Address: gofakeit.Email(), + }, + }, + wantErr: true, + }, + { + name: "email patch, empty", + ctx: isolatedIAMOwnerCTX, + dep: func(req *user.SetContactEmailRequest) error { + data := "{\"name\": \"user\"}" + schemaID := schemaResp.GetDetails().GetId() + userResp := instance.CreateSchemaUser(isolatedIAMOwnerCTX, orgResp.GetOrganizationId(), schemaID, []byte(data)) + req.Id = userResp.GetDetails().GetId() + email := gofakeit.Email() + instance.UpdateSchemaUserEmail(isolatedIAMOwnerCTX, orgResp.GetOrganizationId(), req.Id, email) + return nil + }, + req: &user.SetContactEmailRequest{ + Organization: &object.Organization{ + Property: &object.Organization_OrgId{ + OrgId: orgResp.GetOrganizationId(), + }, + }, + Email: &user.SetEmail{}, + }, + wantErr: true, + }, + { + name: "email patch, no change", + ctx: isolatedIAMOwnerCTX, + dep: func(req *user.SetContactEmailRequest) error { + data := "{\"name\": \"user\"}" + schemaID := schemaResp.GetDetails().GetId() + userResp := instance.CreateSchemaUser(isolatedIAMOwnerCTX, orgResp.GetOrganizationId(), schemaID, []byte(data)) + req.Id = userResp.GetDetails().GetId() + email := gofakeit.Email() + instance.UpdateSchemaUserEmail(isolatedIAMOwnerCTX, orgResp.GetOrganizationId(), req.Id, email) + req.Email.Address = email + return nil + }, + req: &user.SetContactEmailRequest{ + Organization: &object.Organization{ + Property: &object.Organization_OrgId{ + OrgId: orgResp.GetOrganizationId(), + }, + }, + Email: &user.SetEmail{}, + }, + res: res{ + want: &resource_object.Details{ + Changed: timestamppb.Now(), + Owner: &object.Owner{ + Type: object.OwnerType_OWNER_TYPE_ORG, + Id: orgResp.GetOrganizationId(), + }, + }, + }, + }, + { + name: "email patch, no org, ok", + ctx: isolatedIAMOwnerCTX, + dep: func(req *user.SetContactEmailRequest) error { + userResp := instance.CreateSchemaUser(isolatedIAMOwnerCTX, orgResp.GetOrganizationId(), schemaResp.GetDetails().GetId(), []byte("{\"name\": \"user\"}")) + req.Id = userResp.GetDetails().GetId() + return nil + }, + req: &user.SetContactEmailRequest{ + Email: &user.SetEmail{ + Address: gofakeit.Email(), + }, + }, + res: res{ + want: &resource_object.Details{ + Changed: timestamppb.Now(), + Owner: &object.Owner{ + Type: object.OwnerType_OWNER_TYPE_ORG, + Id: orgResp.GetOrganizationId(), + }, + }, + }, + }, + { + name: "email patch, return, ok", + ctx: isolatedIAMOwnerCTX, + dep: func(req *user.SetContactEmailRequest) error { + userResp := instance.CreateSchemaUser(isolatedIAMOwnerCTX, orgResp.GetOrganizationId(), schemaResp.GetDetails().GetId(), []byte("{\"name\": \"user\"}")) + req.Id = userResp.GetDetails().GetId() + return nil + }, + req: &user.SetContactEmailRequest{ + Organization: &object.Organization{ + Property: &object.Organization_OrgId{ + OrgId: orgResp.GetOrganizationId(), + }, + }, + Email: &user.SetEmail{ + Address: gofakeit.Email(), + Verification: &user.SetEmail_ReturnCode{}, + }, + }, + res: res{ + want: &resource_object.Details{ + Changed: timestamppb.Now(), + Owner: &object.Owner{ + Type: object.OwnerType_OWNER_TYPE_ORG, + Id: orgResp.GetOrganizationId(), + }, + }, + returnCode: true, + }, + }, + { + name: "email patch, return, invalid template", + ctx: isolatedIAMOwnerCTX, + dep: func(req *user.SetContactEmailRequest) error { + userResp := instance.CreateSchemaUser(isolatedIAMOwnerCTX, orgResp.GetOrganizationId(), schemaResp.GetDetails().GetId(), []byte("{\"name\": \"user\"}")) + req.Id = userResp.GetDetails().GetId() + return nil + }, + req: &user.SetContactEmailRequest{ + Organization: &object.Organization{ + Property: &object.Organization_OrgId{ + OrgId: orgResp.GetOrganizationId(), + }, + }, + Email: &user.SetEmail{ + Address: gofakeit.Email(), + Verification: &user.SetEmail_SendCode{SendCode: &user.SendEmailVerificationCode{UrlTemplate: gu.Ptr("{{")}}, + }, + }, + wantErr: true, + }, + { + name: "email patch, verified, ok", + ctx: isolatedIAMOwnerCTX, + dep: func(req *user.SetContactEmailRequest) error { + userResp := instance.CreateSchemaUser(isolatedIAMOwnerCTX, orgResp.GetOrganizationId(), schemaResp.GetDetails().GetId(), []byte("{\"name\": \"user\"}")) + req.Id = userResp.GetDetails().GetId() + return nil + }, + req: &user.SetContactEmailRequest{ + Organization: &object.Organization{ + Property: &object.Organization_OrgId{ + OrgId: orgResp.GetOrganizationId(), + }, + }, + Email: &user.SetEmail{ + Address: gofakeit.Email(), + Verification: &user.SetEmail_IsVerified{IsVerified: true}, + }, + }, + res: res{ + want: &resource_object.Details{ + Changed: timestamppb.Now(), + Owner: &object.Owner{ + Type: object.OwnerType_OWNER_TYPE_ORG, + Id: orgResp.GetOrganizationId(), + }, + }, + }, + }, + { + name: "email patch, template, ok", + ctx: isolatedIAMOwnerCTX, + dep: func(req *user.SetContactEmailRequest) error { + userResp := instance.CreateSchemaUser(isolatedIAMOwnerCTX, orgResp.GetOrganizationId(), schemaResp.GetDetails().GetId(), []byte("{\"name\": \"user\"}")) + req.Id = userResp.GetDetails().GetId() + return nil + }, + req: &user.SetContactEmailRequest{ + Organization: &object.Organization{ + Property: &object.Organization_OrgId{ + OrgId: orgResp.GetOrganizationId(), + }, + }, + Email: &user.SetEmail{ + Address: gofakeit.Email(), + Verification: &user.SetEmail_SendCode{SendCode: &user.SendEmailVerificationCode{UrlTemplate: gu.Ptr("https://example.com/email/verify?userID={{.UserID}}&code={{.Code}}&orgID={{.OrgID}}")}}, + }, + }, + res: res{ + want: &resource_object.Details{ + Changed: timestamppb.Now(), + Owner: &object.Owner{ + Type: object.OwnerType_OWNER_TYPE_ORG, + Id: orgResp.GetOrganizationId(), + }, + }, + }, + }, + { + name: "email patch, sent, ok", + ctx: isolatedIAMOwnerCTX, + dep: func(req *user.SetContactEmailRequest) error { + userResp := instance.CreateSchemaUser(isolatedIAMOwnerCTX, orgResp.GetOrganizationId(), schemaResp.GetDetails().GetId(), []byte("{\"name\": \"user\"}")) + req.Id = userResp.GetDetails().GetId() + return nil + }, + req: &user.SetContactEmailRequest{ + Organization: &object.Organization{ + Property: &object.Organization_OrgId{ + OrgId: orgResp.GetOrganizationId(), + }, + }, + Email: &user.SetEmail{ + Address: gofakeit.Email(), + Verification: &user.SetEmail_SendCode{}, + }, + }, + res: res{ + want: &resource_object.Details{ + Changed: timestamppb.Now(), + Owner: &object.Owner{ + Type: object.OwnerType_OWNER_TYPE_ORG, + Id: orgResp.GetOrganizationId(), + }, + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.dep != nil { + err := tt.dep(tt.req) + require.NoError(t, err) + } + got, err := instance.Client.UserV3Alpha.SetContactEmail(tt.ctx, tt.req) + if tt.wantErr { + assert.Error(t, err) + return + } + assert.NoError(t, err) + integration.AssertResourceDetails(t, tt.res.want, got.Details) + if tt.res.returnCode { + assert.NotNil(t, got.VerificationCode) + } else { + assert.Nil(t, got.VerificationCode) + } + }) + } +} + +func TestServer_VerifyContactEmail(t *testing.T) { + t.Parallel() + instance := integration.NewInstance(CTX) + ensureFeatureEnabled(t, instance) + isolatedIAMOwnerCTX := instance.WithAuthorization(CTX, integration.UserTypeIAMOwner) + + schema := []byte(`{ + "$schema": "urn:zitadel:schema:v1", + "type": "object", + "properties": { + "name": { + "type": "string" + } + } + }`) + schemaResp := instance.CreateUserSchema(isolatedIAMOwnerCTX, schema) + orgResp := instance.CreateOrganization(isolatedIAMOwnerCTX, gofakeit.Name(), gofakeit.Email()) + + type res struct { + want *resource_object.Details + } + tests := []struct { + name string + ctx context.Context + dep func(req *user.VerifyContactEmailRequest) error + req *user.VerifyContactEmailRequest + res res + wantErr bool + }{ + { + name: "email verify, no context", + ctx: context.Background(), + dep: func(req *user.VerifyContactEmailRequest) error { + userResp := instance.CreateSchemaUser(isolatedIAMOwnerCTX, orgResp.GetOrganizationId(), schemaResp.GetDetails().GetId(), []byte("{\"name\": \"user\"}")) + req.Id = userResp.GetDetails().GetId() + verifyResp := instance.UpdateSchemaUserEmail(isolatedIAMOwnerCTX, orgResp.GetOrganizationId(), req.Id, gofakeit.Email()) + req.VerificationCode = verifyResp.GetVerificationCode() + return nil + }, + req: &user.VerifyContactEmailRequest{ + Organization: &object.Organization{ + Property: &object.Organization_OrgId{ + OrgId: orgResp.GetOrganizationId(), + }, + }, + }, + wantErr: true, + }, + { + name: "email verify, no permission", + ctx: instance.WithAuthorization(CTX, integration.UserTypeLogin), + dep: func(req *user.VerifyContactEmailRequest) error { + userResp := instance.CreateSchemaUser(isolatedIAMOwnerCTX, orgResp.GetOrganizationId(), schemaResp.GetDetails().GetId(), []byte("{\"name\": \"user\"}")) + req.Id = userResp.GetDetails().GetId() + verifyResp := instance.UpdateSchemaUserEmail(isolatedIAMOwnerCTX, orgResp.GetOrganizationId(), req.Id, gofakeit.Email()) + req.VerificationCode = verifyResp.GetVerificationCode() + return nil + }, + req: &user.VerifyContactEmailRequest{ + Organization: &object.Organization{ + Property: &object.Organization_OrgId{ + OrgId: orgResp.GetOrganizationId(), + }, + }, + }, + wantErr: true, + }, + { + name: "email verify, not found", + ctx: isolatedIAMOwnerCTX, + dep: func(req *user.VerifyContactEmailRequest) error { + return nil + }, + req: &user.VerifyContactEmailRequest{ + Organization: &object.Organization{ + Property: &object.Organization_OrgId{ + OrgId: orgResp.GetOrganizationId(), + }, + }, + Id: "notexisting", + VerificationCode: "unimportant", + }, + wantErr: true, + }, + { + name: "email verify, not found, org", + ctx: isolatedIAMOwnerCTX, + dep: func(req *user.VerifyContactEmailRequest) error { + userResp := instance.CreateSchemaUser(isolatedIAMOwnerCTX, orgResp.GetOrganizationId(), schemaResp.GetDetails().GetId(), []byte("{\"name\": \"user\"}")) + req.Id = userResp.GetDetails().GetId() + verifyResp := instance.UpdateSchemaUserEmail(isolatedIAMOwnerCTX, orgResp.GetOrganizationId(), req.Id, gofakeit.Email()) + req.VerificationCode = verifyResp.GetVerificationCode() + return nil + }, + req: &user.VerifyContactEmailRequest{ + Organization: &object.Organization{ + Property: &object.Organization_OrgId{ + OrgId: "not existing", + }, + }, + }, + wantErr: true, + }, + { + name: "email verify, wrong code", + ctx: isolatedIAMOwnerCTX, + dep: func(req *user.VerifyContactEmailRequest) error { + data := "{\"name\": \"user\"}" + schemaID := schemaResp.GetDetails().GetId() + userResp := instance.CreateSchemaUser(isolatedIAMOwnerCTX, orgResp.GetOrganizationId(), schemaID, []byte(data)) + req.Id = userResp.GetDetails().GetId() + instance.UpdateSchemaUserEmail(isolatedIAMOwnerCTX, orgResp.GetOrganizationId(), req.Id, gofakeit.Email()) + return nil + }, + req: &user.VerifyContactEmailRequest{ + Organization: &object.Organization{ + Property: &object.Organization_OrgId{ + OrgId: orgResp.GetOrganizationId(), + }, + }, + VerificationCode: "wrong", + }, + wantErr: true, + }, + { + name: "email verify, no org, ok", + ctx: isolatedIAMOwnerCTX, + dep: func(req *user.VerifyContactEmailRequest) error { + userResp := instance.CreateSchemaUser(isolatedIAMOwnerCTX, orgResp.GetOrganizationId(), schemaResp.GetDetails().GetId(), []byte("{\"name\": \"user\"}")) + req.Id = userResp.GetDetails().GetId() + verifyResp := instance.UpdateSchemaUserEmail(isolatedIAMOwnerCTX, orgResp.GetOrganizationId(), req.Id, gofakeit.Email()) + req.VerificationCode = verifyResp.GetVerificationCode() + return nil + }, + req: &user.VerifyContactEmailRequest{}, + res: res{ + want: &resource_object.Details{ + Changed: timestamppb.Now(), + Owner: &object.Owner{ + Type: object.OwnerType_OWNER_TYPE_ORG, + Id: orgResp.GetOrganizationId(), + }, + }, + }, + }, + { + name: "email verify, return, ok", + ctx: isolatedIAMOwnerCTX, + dep: func(req *user.VerifyContactEmailRequest) error { + userResp := instance.CreateSchemaUser(isolatedIAMOwnerCTX, orgResp.GetOrganizationId(), schemaResp.GetDetails().GetId(), []byte("{\"name\": \"user\"}")) + req.Id = userResp.GetDetails().GetId() + verifyResp := instance.UpdateSchemaUserEmail(isolatedIAMOwnerCTX, orgResp.GetOrganizationId(), req.Id, gofakeit.Email()) + req.VerificationCode = verifyResp.GetVerificationCode() + return nil + }, + req: &user.VerifyContactEmailRequest{ + Organization: &object.Organization{ + Property: &object.Organization_OrgId{ + OrgId: orgResp.GetOrganizationId(), + }, + }, + }, + res: res{ + want: &resource_object.Details{ + Changed: timestamppb.Now(), + Owner: &object.Owner{ + Type: object.OwnerType_OWNER_TYPE_ORG, + Id: orgResp.GetOrganizationId(), + }, + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.dep != nil { + err := tt.dep(tt.req) + require.NoError(t, err) + } + got, err := instance.Client.UserV3Alpha.VerifyContactEmail(tt.ctx, tt.req) + if tt.wantErr { + assert.Error(t, err) + return + } + assert.NoError(t, err) + integration.AssertResourceDetails(t, tt.res.want, got.Details) + }) + } +} + +func TestServer_ResendContactEmailCode(t *testing.T) { + t.Parallel() + instance := integration.NewInstance(CTX) + ensureFeatureEnabled(t, instance) + isolatedIAMOwnerCTX := instance.WithAuthorization(CTX, integration.UserTypeIAMOwner) + + schema := []byte(`{ + "$schema": "urn:zitadel:schema:v1", + "type": "object", + "properties": { + "name": { + "type": "string" + } + } + }`) + schemaResp := instance.CreateUserSchema(isolatedIAMOwnerCTX, schema) + orgResp := instance.CreateOrganization(isolatedIAMOwnerCTX, gofakeit.Name(), gofakeit.Email()) + + type res struct { + want *resource_object.Details + returnCode bool + } + tests := []struct { + name string + ctx context.Context + dep func(req *user.ResendContactEmailCodeRequest) error + req *user.ResendContactEmailCodeRequest + res res + wantErr bool + }{ + { + name: "email resend, no context", + ctx: context.Background(), + dep: func(req *user.ResendContactEmailCodeRequest) error { + userResp := instance.CreateSchemaUser(isolatedIAMOwnerCTX, orgResp.GetOrganizationId(), schemaResp.GetDetails().GetId(), []byte("{\"name\": \"user\"}")) + req.Id = userResp.GetDetails().GetId() + instance.UpdateSchemaUserEmail(isolatedIAMOwnerCTX, orgResp.GetOrganizationId(), req.Id, gofakeit.Email()) + return nil + }, + req: &user.ResendContactEmailCodeRequest{ + Organization: &object.Organization{ + Property: &object.Organization_OrgId{ + OrgId: orgResp.GetOrganizationId(), + }, + }, + }, + wantErr: true, + }, + { + name: "email resend, no permission", + ctx: instance.WithAuthorization(CTX, integration.UserTypeLogin), + dep: func(req *user.ResendContactEmailCodeRequest) error { + userResp := instance.CreateSchemaUser(isolatedIAMOwnerCTX, orgResp.GetOrganizationId(), schemaResp.GetDetails().GetId(), []byte("{\"name\": \"user\"}")) + req.Id = userResp.GetDetails().GetId() + instance.UpdateSchemaUserEmail(isolatedIAMOwnerCTX, orgResp.GetOrganizationId(), req.Id, gofakeit.Email()) + return nil + }, + req: &user.ResendContactEmailCodeRequest{ + Organization: &object.Organization{ + Property: &object.Organization_OrgId{ + OrgId: orgResp.GetOrganizationId(), + }, + }, + }, + wantErr: true, + }, + { + name: "email resend, not found", + ctx: isolatedIAMOwnerCTX, + dep: func(req *user.ResendContactEmailCodeRequest) error { + return nil + }, + req: &user.ResendContactEmailCodeRequest{ + Organization: &object.Organization{ + Property: &object.Organization_OrgId{ + OrgId: orgResp.GetOrganizationId(), + }, + }, + Id: "notexisting", + }, + wantErr: true, + }, + { + name: "email resend, not found, org", + ctx: isolatedIAMOwnerCTX, + dep: func(req *user.ResendContactEmailCodeRequest) error { + userResp := instance.CreateSchemaUser(isolatedIAMOwnerCTX, orgResp.GetOrganizationId(), schemaResp.GetDetails().GetId(), []byte("{\"name\": \"user\"}")) + req.Id = userResp.GetDetails().GetId() + instance.UpdateSchemaUserEmail(isolatedIAMOwnerCTX, orgResp.GetOrganizationId(), req.Id, gofakeit.Email()) + return nil + }, + req: &user.ResendContactEmailCodeRequest{ + Organization: &object.Organization{ + Property: &object.Organization_OrgId{ + OrgId: "not existing", + }, + }, + }, + wantErr: true, + }, + { + name: "email resend, no code", + ctx: isolatedIAMOwnerCTX, + dep: func(req *user.ResendContactEmailCodeRequest) error { + data := "{\"name\": \"user\"}" + schemaID := schemaResp.GetDetails().GetId() + userResp := instance.CreateSchemaUser(isolatedIAMOwnerCTX, orgResp.GetOrganizationId(), schemaID, []byte(data)) + req.Id = userResp.GetDetails().GetId() + return nil + }, + req: &user.ResendContactEmailCodeRequest{ + Organization: &object.Organization{ + Property: &object.Organization_OrgId{ + OrgId: orgResp.GetOrganizationId(), + }, + }, + }, + wantErr: true, + }, + { + name: "email resend, no org, ok", + ctx: isolatedIAMOwnerCTX, + dep: func(req *user.ResendContactEmailCodeRequest) error { + userResp := instance.CreateSchemaUser(isolatedIAMOwnerCTX, orgResp.GetOrganizationId(), schemaResp.GetDetails().GetId(), []byte("{\"name\": \"user\"}")) + req.Id = userResp.GetDetails().GetId() + instance.UpdateSchemaUserEmail(isolatedIAMOwnerCTX, orgResp.GetOrganizationId(), req.Id, gofakeit.Email()) + return nil + }, + req: &user.ResendContactEmailCodeRequest{}, + res: res{ + want: &resource_object.Details{ + Changed: timestamppb.Now(), + Owner: &object.Owner{ + Type: object.OwnerType_OWNER_TYPE_ORG, + Id: orgResp.GetOrganizationId(), + }, + }, + }, + }, + { + name: "email resend, return, ok", + ctx: isolatedIAMOwnerCTX, + dep: func(req *user.ResendContactEmailCodeRequest) error { + userResp := instance.CreateSchemaUser(isolatedIAMOwnerCTX, orgResp.GetOrganizationId(), schemaResp.GetDetails().GetId(), []byte("{\"name\": \"user\"}")) + req.Id = userResp.GetDetails().GetId() + instance.UpdateSchemaUserEmail(isolatedIAMOwnerCTX, orgResp.GetOrganizationId(), req.Id, gofakeit.Email()) + return nil + }, + req: &user.ResendContactEmailCodeRequest{ + Organization: &object.Organization{ + Property: &object.Organization_OrgId{ + OrgId: orgResp.GetOrganizationId(), + }, + }, + Verification: &user.ResendContactEmailCodeRequest_ReturnCode{}, + }, + res: res{ + want: &resource_object.Details{ + Changed: timestamppb.Now(), + Owner: &object.Owner{ + Type: object.OwnerType_OWNER_TYPE_ORG, + Id: orgResp.GetOrganizationId(), + }, + }, + returnCode: true, + }, + }, + { + name: "email resend, sent, ok", + ctx: isolatedIAMOwnerCTX, + dep: func(req *user.ResendContactEmailCodeRequest) error { + userResp := instance.CreateSchemaUser(isolatedIAMOwnerCTX, orgResp.GetOrganizationId(), schemaResp.GetDetails().GetId(), []byte("{\"name\": \"user\"}")) + req.Id = userResp.GetDetails().GetId() + instance.UpdateSchemaUserEmail(isolatedIAMOwnerCTX, orgResp.GetOrganizationId(), req.Id, gofakeit.Email()) + return nil + }, + req: &user.ResendContactEmailCodeRequest{ + Organization: &object.Organization{ + Property: &object.Organization_OrgId{ + OrgId: orgResp.GetOrganizationId(), + }, + }, + Verification: &user.ResendContactEmailCodeRequest_SendCode{}, + }, + res: res{ + want: &resource_object.Details{ + Changed: timestamppb.Now(), + Owner: &object.Owner{ + Type: object.OwnerType_OWNER_TYPE_ORG, + Id: orgResp.GetOrganizationId(), + }, + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.dep != nil { + err := tt.dep(tt.req) + require.NoError(t, err) + } + got, err := instance.Client.UserV3Alpha.ResendContactEmailCode(tt.ctx, tt.req) + if tt.wantErr { + assert.Error(t, err) + return + } + assert.NoError(t, err) + integration.AssertResourceDetails(t, tt.res.want, got.Details) + if tt.res.returnCode { + assert.NotNil(t, got.VerificationCode) + } else { + assert.Nil(t, got.VerificationCode) + } + }) + } +} diff --git a/internal/api/grpc/resources/user/v3alpha/integration_test/phone_test.go b/internal/api/grpc/resources/user/v3alpha/integration_test/phone_test.go new file mode 100644 index 00000000000..d61135d30d4 --- /dev/null +++ b/internal/api/grpc/resources/user/v3alpha/integration_test/phone_test.go @@ -0,0 +1,701 @@ +//go:build integration + +package user_test + +import ( + "context" + "testing" + + "github.com/brianvoe/gofakeit/v6" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/protobuf/types/known/timestamppb" + + "github.com/zitadel/zitadel/internal/integration" + object "github.com/zitadel/zitadel/pkg/grpc/object/v3alpha" + resource_object "github.com/zitadel/zitadel/pkg/grpc/resources/object/v3alpha" + user "github.com/zitadel/zitadel/pkg/grpc/resources/user/v3alpha" +) + +func TestServer_SetContactPhone(t *testing.T) { + t.Parallel() + instance := integration.NewInstance(CTX) + ensureFeatureEnabled(t, instance) + isolatedIAMOwnerCTX := instance.WithAuthorization(CTX, integration.UserTypeIAMOwner) + + schema := []byte(`{ + "$schema": "urn:zitadel:schema:v1", + "type": "object", + "properties": { + "name": { + "type": "string" + } + } + }`) + schemaResp := instance.CreateUserSchema(isolatedIAMOwnerCTX, schema) + orgResp := instance.CreateOrganization(isolatedIAMOwnerCTX, gofakeit.Name(), gofakeit.Email()) + + type res struct { + want *resource_object.Details + returnCode bool + } + tests := []struct { + name string + ctx context.Context + dep func(req *user.SetContactPhoneRequest) error + req *user.SetContactPhoneRequest + res res + wantErr bool + }{ + { + name: "phone patch, no context", + ctx: context.Background(), + dep: func(req *user.SetContactPhoneRequest) error { + userResp := instance.CreateSchemaUser(isolatedIAMOwnerCTX, orgResp.GetOrganizationId(), schemaResp.GetDetails().GetId(), []byte("{\"name\": \"user\"}")) + req.Id = userResp.GetDetails().GetId() + return nil + }, + req: &user.SetContactPhoneRequest{ + Organization: &object.Organization{ + Property: &object.Organization_OrgId{ + OrgId: orgResp.GetOrganizationId(), + }, + }, + Phone: &user.SetPhone{ + Number: gofakeit.Phone(), + }, + }, + wantErr: true, + }, + { + name: "phone patch, no permission", + ctx: instance.WithAuthorization(CTX, integration.UserTypeLogin), + dep: func(req *user.SetContactPhoneRequest) error { + userResp := instance.CreateSchemaUser(isolatedIAMOwnerCTX, orgResp.GetOrganizationId(), schemaResp.GetDetails().GetId(), []byte("{\"name\": \"user\"}")) + req.Id = userResp.GetDetails().GetId() + return nil + }, + req: &user.SetContactPhoneRequest{ + Organization: &object.Organization{ + Property: &object.Organization_OrgId{ + OrgId: orgResp.GetOrganizationId(), + }, + }, + Phone: &user.SetPhone{ + Number: gofakeit.Phone(), + }, + }, + wantErr: true, + }, + { + name: "phone patch, not found", + ctx: isolatedIAMOwnerCTX, + dep: func(req *user.SetContactPhoneRequest) error { + return nil + }, + req: &user.SetContactPhoneRequest{ + Organization: &object.Organization{ + Property: &object.Organization_OrgId{ + OrgId: orgResp.GetOrganizationId(), + }, + }, + Id: "notexisting", + Phone: &user.SetPhone{ + Number: gofakeit.Phone(), + }, + }, + wantErr: true, + }, + { + name: "phone patch, not found, org", + ctx: isolatedIAMOwnerCTX, + dep: func(req *user.SetContactPhoneRequest) error { + userResp := instance.CreateSchemaUser(isolatedIAMOwnerCTX, orgResp.GetOrganizationId(), schemaResp.GetDetails().GetId(), []byte("{\"name\": \"user\"}")) + req.Id = userResp.GetDetails().GetId() + return nil + }, + req: &user.SetContactPhoneRequest{ + Organization: &object.Organization{ + Property: &object.Organization_OrgId{ + OrgId: "not existing", + }, + }, + Phone: &user.SetPhone{ + Number: gofakeit.Phone(), + }, + }, + wantErr: true, + }, + { + name: "phone patch, no change", + ctx: isolatedIAMOwnerCTX, + dep: func(req *user.SetContactPhoneRequest) error { + data := "{\"name\": \"user\"}" + schemaID := schemaResp.GetDetails().GetId() + userResp := instance.CreateSchemaUser(isolatedIAMOwnerCTX, orgResp.GetOrganizationId(), schemaID, []byte(data)) + req.Id = userResp.GetDetails().GetId() + number := gofakeit.Phone() + instance.UpdateSchemaUserPhone(isolatedIAMOwnerCTX, orgResp.GetOrganizationId(), req.Id, number) + req.Phone.Number = number + return nil + }, + req: &user.SetContactPhoneRequest{ + Organization: &object.Organization{ + Property: &object.Organization_OrgId{ + OrgId: orgResp.GetOrganizationId(), + }, + }, + Phone: &user.SetPhone{}, + }, + res: res{ + want: &resource_object.Details{ + Changed: timestamppb.Now(), + Owner: &object.Owner{ + Type: object.OwnerType_OWNER_TYPE_ORG, + Id: orgResp.GetOrganizationId(), + }, + }, + }, + }, + { + name: "phone patch, no org, ok", + ctx: isolatedIAMOwnerCTX, + dep: func(req *user.SetContactPhoneRequest) error { + userResp := instance.CreateSchemaUser(isolatedIAMOwnerCTX, orgResp.GetOrganizationId(), schemaResp.GetDetails().GetId(), []byte("{\"name\": \"user\"}")) + req.Id = userResp.GetDetails().GetId() + return nil + }, + req: &user.SetContactPhoneRequest{ + Phone: &user.SetPhone{ + Number: gofakeit.Phone(), + }, + }, + res: res{ + want: &resource_object.Details{ + Changed: timestamppb.Now(), + Owner: &object.Owner{ + Type: object.OwnerType_OWNER_TYPE_ORG, + Id: orgResp.GetOrganizationId(), + }, + }, + }, + }, + { + name: "phone patch, return, ok", + ctx: isolatedIAMOwnerCTX, + dep: func(req *user.SetContactPhoneRequest) error { + userResp := instance.CreateSchemaUser(isolatedIAMOwnerCTX, orgResp.GetOrganizationId(), schemaResp.GetDetails().GetId(), []byte("{\"name\": \"user\"}")) + req.Id = userResp.GetDetails().GetId() + return nil + }, + req: &user.SetContactPhoneRequest{ + Organization: &object.Organization{ + Property: &object.Organization_OrgId{ + OrgId: orgResp.GetOrganizationId(), + }, + }, + Phone: &user.SetPhone{ + Number: gofakeit.Phone(), + Verification: &user.SetPhone_ReturnCode{}, + }, + }, + res: res{ + want: &resource_object.Details{ + Changed: timestamppb.Now(), + Owner: &object.Owner{ + Type: object.OwnerType_OWNER_TYPE_ORG, + Id: orgResp.GetOrganizationId(), + }, + }, + returnCode: true, + }, + }, + { + name: "phone patch, verified, ok", + ctx: isolatedIAMOwnerCTX, + dep: func(req *user.SetContactPhoneRequest) error { + userResp := instance.CreateSchemaUser(isolatedIAMOwnerCTX, orgResp.GetOrganizationId(), schemaResp.GetDetails().GetId(), []byte("{\"name\": \"user\"}")) + req.Id = userResp.GetDetails().GetId() + return nil + }, + req: &user.SetContactPhoneRequest{ + Organization: &object.Organization{ + Property: &object.Organization_OrgId{ + OrgId: orgResp.GetOrganizationId(), + }, + }, + Phone: &user.SetPhone{ + Number: gofakeit.Phone(), + Verification: &user.SetPhone_IsVerified{IsVerified: true}, + }, + }, + res: res{ + want: &resource_object.Details{ + Changed: timestamppb.Now(), + Owner: &object.Owner{ + Type: object.OwnerType_OWNER_TYPE_ORG, + Id: orgResp.GetOrganizationId(), + }, + }, + }, + }, + { + name: "phone patch, sent, ok", + ctx: isolatedIAMOwnerCTX, + dep: func(req *user.SetContactPhoneRequest) error { + userResp := instance.CreateSchemaUser(isolatedIAMOwnerCTX, orgResp.GetOrganizationId(), schemaResp.GetDetails().GetId(), []byte("{\"name\": \"user\"}")) + req.Id = userResp.GetDetails().GetId() + return nil + }, + req: &user.SetContactPhoneRequest{ + Organization: &object.Organization{ + Property: &object.Organization_OrgId{ + OrgId: orgResp.GetOrganizationId(), + }, + }, + Phone: &user.SetPhone{ + Number: gofakeit.Phone(), + Verification: &user.SetPhone_SendCode{}, + }, + }, + res: res{ + want: &resource_object.Details{ + Changed: timestamppb.Now(), + Owner: &object.Owner{ + Type: object.OwnerType_OWNER_TYPE_ORG, + Id: orgResp.GetOrganizationId(), + }, + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.dep != nil { + err := tt.dep(tt.req) + require.NoError(t, err) + } + got, err := instance.Client.UserV3Alpha.SetContactPhone(tt.ctx, tt.req) + if tt.wantErr { + assert.Error(t, err) + return + } + assert.NoError(t, err) + integration.AssertResourceDetails(t, tt.res.want, got.Details) + if tt.res.returnCode { + assert.NotNil(t, got.VerificationCode) + } else { + assert.Nil(t, got.VerificationCode) + } + }) + } +} + +func TestServer_VerifyContactPhone(t *testing.T) { + t.Parallel() + instance := integration.NewInstance(CTX) + ensureFeatureEnabled(t, instance) + isolatedIAMOwnerCTX := instance.WithAuthorization(CTX, integration.UserTypeIAMOwner) + + schema := []byte(`{ + "$schema": "urn:zitadel:schema:v1", + "type": "object", + "properties": { + "name": { + "type": "string" + } + } + }`) + schemaResp := instance.CreateUserSchema(isolatedIAMOwnerCTX, schema) + orgResp := instance.CreateOrganization(isolatedIAMOwnerCTX, gofakeit.Name(), gofakeit.Email()) + + type res struct { + want *resource_object.Details + returnCodePhone bool + } + tests := []struct { + name string + ctx context.Context + dep func(req *user.VerifyContactPhoneRequest) error + req *user.VerifyContactPhoneRequest + res res + wantErr bool + }{ + { + name: "phone verify, no context", + ctx: context.Background(), + dep: func(req *user.VerifyContactPhoneRequest) error { + userResp := instance.CreateSchemaUser(isolatedIAMOwnerCTX, orgResp.GetOrganizationId(), schemaResp.GetDetails().GetId(), []byte("{\"name\": \"user\"}")) + req.Id = userResp.GetDetails().GetId() + verifyResp := instance.UpdateSchemaUserPhone(isolatedIAMOwnerCTX, orgResp.GetOrganizationId(), req.Id, gofakeit.Phone()) + req.VerificationCode = verifyResp.GetVerificationCode() + return nil + }, + req: &user.VerifyContactPhoneRequest{ + Organization: &object.Organization{ + Property: &object.Organization_OrgId{ + OrgId: orgResp.GetOrganizationId(), + }, + }, + }, + wantErr: true, + }, + { + name: "phone verify, no permission", + ctx: instance.WithAuthorization(CTX, integration.UserTypeLogin), + dep: func(req *user.VerifyContactPhoneRequest) error { + userResp := instance.CreateSchemaUser(isolatedIAMOwnerCTX, orgResp.GetOrganizationId(), schemaResp.GetDetails().GetId(), []byte("{\"name\": \"user\"}")) + req.Id = userResp.GetDetails().GetId() + verifyResp := instance.UpdateSchemaUserPhone(isolatedIAMOwnerCTX, orgResp.GetOrganizationId(), req.Id, gofakeit.Phone()) + req.VerificationCode = verifyResp.GetVerificationCode() + return nil + }, + req: &user.VerifyContactPhoneRequest{ + Organization: &object.Organization{ + Property: &object.Organization_OrgId{ + OrgId: orgResp.GetOrganizationId(), + }, + }, + }, + wantErr: true, + }, + { + name: "phone verify, not found", + ctx: isolatedIAMOwnerCTX, + dep: func(req *user.VerifyContactPhoneRequest) error { + return nil + }, + req: &user.VerifyContactPhoneRequest{ + Organization: &object.Organization{ + Property: &object.Organization_OrgId{ + OrgId: orgResp.GetOrganizationId(), + }, + }, + Id: "notexisting", + VerificationCode: "unimportant", + }, + wantErr: true, + }, + { + name: "phone verify, not found, org", + ctx: isolatedIAMOwnerCTX, + dep: func(req *user.VerifyContactPhoneRequest) error { + userResp := instance.CreateSchemaUser(isolatedIAMOwnerCTX, orgResp.GetOrganizationId(), schemaResp.GetDetails().GetId(), []byte("{\"name\": \"user\"}")) + req.Id = userResp.GetDetails().GetId() + verifyResp := instance.UpdateSchemaUserPhone(isolatedIAMOwnerCTX, orgResp.GetOrganizationId(), req.Id, gofakeit.Phone()) + req.VerificationCode = verifyResp.GetVerificationCode() + return nil + }, + req: &user.VerifyContactPhoneRequest{ + Organization: &object.Organization{ + Property: &object.Organization_OrgId{ + OrgId: "not existing", + }, + }, + }, + wantErr: true, + }, + { + name: "phone verify, wrong code", + ctx: isolatedIAMOwnerCTX, + dep: func(req *user.VerifyContactPhoneRequest) error { + data := "{\"name\": \"user\"}" + schemaID := schemaResp.GetDetails().GetId() + userResp := instance.CreateSchemaUser(isolatedIAMOwnerCTX, orgResp.GetOrganizationId(), schemaID, []byte(data)) + req.Id = userResp.GetDetails().GetId() + instance.UpdateSchemaUserPhone(isolatedIAMOwnerCTX, orgResp.GetOrganizationId(), req.Id, gofakeit.Phone()) + return nil + }, + req: &user.VerifyContactPhoneRequest{ + Organization: &object.Organization{ + Property: &object.Organization_OrgId{ + OrgId: orgResp.GetOrganizationId(), + }, + }, + VerificationCode: "wrong", + }, + wantErr: true, + }, + { + name: "phone verify, no org, ok", + ctx: isolatedIAMOwnerCTX, + dep: func(req *user.VerifyContactPhoneRequest) error { + userResp := instance.CreateSchemaUser(isolatedIAMOwnerCTX, orgResp.GetOrganizationId(), schemaResp.GetDetails().GetId(), []byte("{\"name\": \"user\"}")) + req.Id = userResp.GetDetails().GetId() + verifyResp := instance.UpdateSchemaUserPhone(isolatedIAMOwnerCTX, orgResp.GetOrganizationId(), req.Id, gofakeit.Phone()) + req.VerificationCode = verifyResp.GetVerificationCode() + return nil + }, + req: &user.VerifyContactPhoneRequest{}, + res: res{ + want: &resource_object.Details{ + Changed: timestamppb.Now(), + Owner: &object.Owner{ + Type: object.OwnerType_OWNER_TYPE_ORG, + Id: orgResp.GetOrganizationId(), + }, + }, + }, + }, + { + name: "phone verify, return, ok", + ctx: isolatedIAMOwnerCTX, + dep: func(req *user.VerifyContactPhoneRequest) error { + userResp := instance.CreateSchemaUser(isolatedIAMOwnerCTX, orgResp.GetOrganizationId(), schemaResp.GetDetails().GetId(), []byte("{\"name\": \"user\"}")) + req.Id = userResp.GetDetails().GetId() + verifyResp := instance.UpdateSchemaUserPhone(isolatedIAMOwnerCTX, orgResp.GetOrganizationId(), req.Id, gofakeit.Phone()) + req.VerificationCode = verifyResp.GetVerificationCode() + return nil + }, + req: &user.VerifyContactPhoneRequest{ + Organization: &object.Organization{ + Property: &object.Organization_OrgId{ + OrgId: orgResp.GetOrganizationId(), + }, + }, + }, + res: res{ + want: &resource_object.Details{ + Changed: timestamppb.Now(), + Owner: &object.Owner{ + Type: object.OwnerType_OWNER_TYPE_ORG, + Id: orgResp.GetOrganizationId(), + }, + }, + returnCodePhone: true, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.dep != nil { + err := tt.dep(tt.req) + require.NoError(t, err) + } + got, err := instance.Client.UserV3Alpha.VerifyContactPhone(tt.ctx, tt.req) + if tt.wantErr { + assert.Error(t, err) + return + } + assert.NoError(t, err) + integration.AssertResourceDetails(t, tt.res.want, got.Details) + }) + } +} + +func TestServer_ResendContactPhoneCode(t *testing.T) { + t.Parallel() + instance := integration.NewInstance(CTX) + ensureFeatureEnabled(t, instance) + isolatedIAMOwnerCTX := instance.WithAuthorization(CTX, integration.UserTypeIAMOwner) + + schema := []byte(`{ + "$schema": "urn:zitadel:schema:v1", + "type": "object", + "properties": { + "name": { + "type": "string" + } + } + }`) + schemaResp := instance.CreateUserSchema(isolatedIAMOwnerCTX, schema) + orgResp := instance.CreateOrganization(isolatedIAMOwnerCTX, gofakeit.Name(), gofakeit.Email()) + + type res struct { + want *resource_object.Details + returnCode bool + } + tests := []struct { + name string + ctx context.Context + dep func(req *user.ResendContactPhoneCodeRequest) error + req *user.ResendContactPhoneCodeRequest + res res + wantErr bool + }{ + { + name: "phone resend, no context", + ctx: context.Background(), + dep: func(req *user.ResendContactPhoneCodeRequest) error { + userResp := instance.CreateSchemaUser(isolatedIAMOwnerCTX, orgResp.GetOrganizationId(), schemaResp.GetDetails().GetId(), []byte("{\"name\": \"user\"}")) + req.Id = userResp.GetDetails().GetId() + instance.UpdateSchemaUserPhone(isolatedIAMOwnerCTX, orgResp.GetOrganizationId(), req.Id, gofakeit.Phone()) + return nil + }, + req: &user.ResendContactPhoneCodeRequest{ + Organization: &object.Organization{ + Property: &object.Organization_OrgId{ + OrgId: orgResp.GetOrganizationId(), + }, + }, + }, + wantErr: true, + }, + { + name: "phone resend, no permission", + ctx: instance.WithAuthorization(CTX, integration.UserTypeLogin), + dep: func(req *user.ResendContactPhoneCodeRequest) error { + userResp := instance.CreateSchemaUser(isolatedIAMOwnerCTX, orgResp.GetOrganizationId(), schemaResp.GetDetails().GetId(), []byte("{\"name\": \"user\"}")) + req.Id = userResp.GetDetails().GetId() + instance.UpdateSchemaUserPhone(isolatedIAMOwnerCTX, orgResp.GetOrganizationId(), req.Id, gofakeit.Phone()) + return nil + }, + req: &user.ResendContactPhoneCodeRequest{ + Organization: &object.Organization{ + Property: &object.Organization_OrgId{ + OrgId: orgResp.GetOrganizationId(), + }, + }, + }, + wantErr: true, + }, + { + name: "phone resend, not found", + ctx: isolatedIAMOwnerCTX, + dep: func(req *user.ResendContactPhoneCodeRequest) error { + return nil + }, + req: &user.ResendContactPhoneCodeRequest{ + Organization: &object.Organization{ + Property: &object.Organization_OrgId{ + OrgId: orgResp.GetOrganizationId(), + }, + }, + Id: "notexisting", + }, + wantErr: true, + }, + { + name: "phone resend, not found, org", + ctx: isolatedIAMOwnerCTX, + dep: func(req *user.ResendContactPhoneCodeRequest) error { + userResp := instance.CreateSchemaUser(isolatedIAMOwnerCTX, orgResp.GetOrganizationId(), schemaResp.GetDetails().GetId(), []byte("{\"name\": \"user\"}")) + req.Id = userResp.GetDetails().GetId() + instance.UpdateSchemaUserPhone(isolatedIAMOwnerCTX, orgResp.GetOrganizationId(), req.Id, gofakeit.Phone()) + return nil + }, + req: &user.ResendContactPhoneCodeRequest{ + Organization: &object.Organization{ + Property: &object.Organization_OrgId{ + OrgId: "not existing", + }, + }, + }, + wantErr: true, + }, + { + name: "phone resend, no code", + ctx: isolatedIAMOwnerCTX, + dep: func(req *user.ResendContactPhoneCodeRequest) error { + data := "{\"name\": \"user\"}" + schemaID := schemaResp.GetDetails().GetId() + userResp := instance.CreateSchemaUser(isolatedIAMOwnerCTX, orgResp.GetOrganizationId(), schemaID, []byte(data)) + req.Id = userResp.GetDetails().GetId() + return nil + }, + req: &user.ResendContactPhoneCodeRequest{ + Organization: &object.Organization{ + Property: &object.Organization_OrgId{ + OrgId: orgResp.GetOrganizationId(), + }, + }, + }, + wantErr: true, + }, + { + name: "phone resend, no org, ok", + ctx: isolatedIAMOwnerCTX, + dep: func(req *user.ResendContactPhoneCodeRequest) error { + userResp := instance.CreateSchemaUser(isolatedIAMOwnerCTX, orgResp.GetOrganizationId(), schemaResp.GetDetails().GetId(), []byte("{\"name\": \"user\"}")) + req.Id = userResp.GetDetails().GetId() + instance.UpdateSchemaUserPhone(isolatedIAMOwnerCTX, orgResp.GetOrganizationId(), req.Id, gofakeit.Phone()) + return nil + }, + req: &user.ResendContactPhoneCodeRequest{}, + res: res{ + want: &resource_object.Details{ + Changed: timestamppb.Now(), + Owner: &object.Owner{ + Type: object.OwnerType_OWNER_TYPE_ORG, + Id: orgResp.GetOrganizationId(), + }, + }, + }, + }, + { + name: "phone resend, return, ok", + ctx: isolatedIAMOwnerCTX, + dep: func(req *user.ResendContactPhoneCodeRequest) error { + userResp := instance.CreateSchemaUser(isolatedIAMOwnerCTX, orgResp.GetOrganizationId(), schemaResp.GetDetails().GetId(), []byte("{\"name\": \"user\"}")) + req.Id = userResp.GetDetails().GetId() + instance.UpdateSchemaUserPhone(isolatedIAMOwnerCTX, orgResp.GetOrganizationId(), req.Id, gofakeit.Phone()) + return nil + }, + req: &user.ResendContactPhoneCodeRequest{ + Organization: &object.Organization{ + Property: &object.Organization_OrgId{ + OrgId: orgResp.GetOrganizationId(), + }, + }, + Verification: &user.ResendContactPhoneCodeRequest_ReturnCode{}, + }, + res: res{ + want: &resource_object.Details{ + Changed: timestamppb.Now(), + Owner: &object.Owner{ + Type: object.OwnerType_OWNER_TYPE_ORG, + Id: orgResp.GetOrganizationId(), + }, + }, + returnCode: true, + }, + }, + { + name: "phone resend, sent, ok", + ctx: isolatedIAMOwnerCTX, + dep: func(req *user.ResendContactPhoneCodeRequest) error { + userResp := instance.CreateSchemaUser(isolatedIAMOwnerCTX, orgResp.GetOrganizationId(), schemaResp.GetDetails().GetId(), []byte("{\"name\": \"user\"}")) + req.Id = userResp.GetDetails().GetId() + instance.UpdateSchemaUserPhone(isolatedIAMOwnerCTX, orgResp.GetOrganizationId(), req.Id, gofakeit.Phone()) + return nil + }, + req: &user.ResendContactPhoneCodeRequest{ + Organization: &object.Organization{ + Property: &object.Organization_OrgId{ + OrgId: orgResp.GetOrganizationId(), + }, + }, + Verification: &user.ResendContactPhoneCodeRequest_SendCode{}, + }, + res: res{ + want: &resource_object.Details{ + Changed: timestamppb.Now(), + Owner: &object.Owner{ + Type: object.OwnerType_OWNER_TYPE_ORG, + Id: orgResp.GetOrganizationId(), + }, + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.dep != nil { + err := tt.dep(tt.req) + require.NoError(t, err) + } + got, err := instance.Client.UserV3Alpha.ResendContactPhoneCode(tt.ctx, tt.req) + if tt.wantErr { + assert.Error(t, err) + return + } + assert.NoError(t, err) + integration.AssertResourceDetails(t, tt.res.want, got.Details) + if tt.res.returnCode { + assert.NotNil(t, got.VerificationCode) + } else { + assert.Nil(t, got.VerificationCode) + } + }) + } +} diff --git a/internal/api/grpc/resources/user/v3alpha/integration_test/server_test.go b/internal/api/grpc/resources/user/v3alpha/integration_test/server_test.go index b2b11fd5105..fee1a384302 100644 --- a/internal/api/grpc/resources/user/v3alpha/integration_test/server_test.go +++ b/internal/api/grpc/resources/user/v3alpha/integration_test/server_test.go @@ -14,6 +14,7 @@ import ( "github.com/zitadel/zitadel/internal/integration" "github.com/zitadel/zitadel/pkg/grpc/feature/v2" + user "github.com/zitadel/zitadel/pkg/grpc/resources/user/v3alpha" ) var ( @@ -51,7 +52,7 @@ func ensureFeatureEnabled(t *testing.T, instance *integration.Instance) { f, err := instance.Client.FeatureV2.GetInstanceFeatures(ctx, &feature.GetInstanceFeaturesRequest{ Inheritance: true, }) - require.NoError(ttt, err) + assert.NoError(ttt, err) if f.UserSchema.GetEnabled() { return } @@ -59,4 +60,13 @@ func ensureFeatureEnabled(t *testing.T, instance *integration.Instance) { retryDuration, time.Second, "timed out waiting for ensuring instance feature") + + require.EventuallyWithT(t, + func(ttt *assert.CollectT) { + _, err := instance.Client.UserV3Alpha.SearchUsers(ctx, &user.SearchUsersRequest{}) + assert.NoError(ttt, err) + }, + retryDuration, + time.Second, + "timed out waiting for ensuring instance feature call") } diff --git a/internal/api/grpc/resources/user/v3alpha/integration_test/user_test.go b/internal/api/grpc/resources/user/v3alpha/integration_test/user_test.go index c8db0f7f6a1..95e98b1a9eb 100644 --- a/internal/api/grpc/resources/user/v3alpha/integration_test/user_test.go +++ b/internal/api/grpc/resources/user/v3alpha/integration_test/user_test.go @@ -8,6 +8,7 @@ import ( "github.com/brianvoe/gofakeit/v6" "github.com/muhlemmer/gu" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/zitadel/logging" "google.golang.org/protobuf/types/known/structpb" @@ -628,16 +629,20 @@ func TestServer_PatchUser(t *testing.T) { } got, err := instance.Client.UserV3Alpha.PatchUser(tt.ctx, tt.req) if tt.wantErr { - require.Error(t, err) + assert.Error(t, err) return } - require.NoError(t, err) + assert.NoError(t, err) integration.AssertResourceDetails(t, tt.res.want, got.Details) if tt.res.returnCodeEmail { - require.NotNil(t, got.EmailCode) + assert.NotNil(t, got.EmailCode) + } else { + assert.Nil(t, got.EmailCode) } if tt.res.returnCodePhone { - require.NotNil(t, got.PhoneCode) + assert.NotNil(t, got.PhoneCode) + } else { + assert.Nil(t, got.PhoneCode) } }) } @@ -843,10 +848,10 @@ func TestServer_DeleteUser(t *testing.T) { } got, err := instance.Client.UserV3Alpha.DeleteUser(tt.ctx, tt.req) if tt.wantErr { - require.Error(t, err) + assert.Error(t, err) return } - require.NoError(t, err) + assert.NoError(t, err) integration.AssertResourceDetails(t, tt.want, got.Details) }) } @@ -1054,10 +1059,10 @@ func TestServer_LockUser(t *testing.T) { } got, err := instance.Client.UserV3Alpha.LockUser(tt.ctx, tt.req) if tt.wantErr { - require.Error(t, err) + assert.Error(t, err) return } - require.NoError(t, err) + assert.NoError(t, err) integration.AssertResourceDetails(t, tt.want, got.Details) }) } @@ -1237,10 +1242,10 @@ func TestServer_UnlockUser(t *testing.T) { } got, err := instance.Client.UserV3Alpha.UnlockUser(tt.ctx, tt.req) if tt.wantErr { - require.Error(t, err) + assert.Error(t, err) return } - require.NoError(t, err) + assert.NoError(t, err) integration.AssertResourceDetails(t, tt.want, got.Details) }) } @@ -1439,10 +1444,10 @@ func TestServer_DeactivateUser(t *testing.T) { } got, err := instance.Client.UserV3Alpha.DeactivateUser(tt.ctx, tt.req) if tt.wantErr { - require.Error(t, err) + assert.Error(t, err) return } - require.NoError(t, err) + assert.NoError(t, err) integration.AssertResourceDetails(t, tt.want, got.Details) }) } @@ -1622,10 +1627,10 @@ func TestServer_ActivateUser(t *testing.T) { } got, err := instance.Client.UserV3Alpha.ActivateUser(tt.ctx, tt.req) if tt.wantErr { - require.Error(t, err) + assert.Error(t, err) return } - require.NoError(t, err) + assert.NoError(t, err) integration.AssertResourceDetails(t, tt.want, got.Details) }) } diff --git a/internal/api/grpc/resources/user/v3alpha/phone.go b/internal/api/grpc/resources/user/v3alpha/phone.go new file mode 100644 index 00000000000..64cab1c13ba --- /dev/null +++ b/internal/api/grpc/resources/user/v3alpha/phone.go @@ -0,0 +1,81 @@ +package user + +import ( + "context" + + resource_object "github.com/zitadel/zitadel/internal/api/grpc/resources/object/v3alpha" + "github.com/zitadel/zitadel/internal/command" + "github.com/zitadel/zitadel/internal/domain" + object "github.com/zitadel/zitadel/pkg/grpc/object/v3alpha" + user "github.com/zitadel/zitadel/pkg/grpc/resources/user/v3alpha" +) + +func (s *Server) SetContactPhone(ctx context.Context, req *user.SetContactPhoneRequest) (_ *user.SetContactPhoneResponse, err error) { + if err := checkUserSchemaEnabled(ctx); err != nil { + return nil, err + } + schemauser := setContactPhoneRequestToChangeSchemaUserPhone(req) + details, err := s.command.ChangeSchemaUserPhone(ctx, schemauser) + if err != nil { + return nil, err + } + return &user.SetContactPhoneResponse{ + Details: resource_object.DomainToDetailsPb(details, object.OwnerType_OWNER_TYPE_ORG, details.ResourceOwner), + VerificationCode: schemauser.ReturnCode, + }, nil +} + +func setContactPhoneRequestToChangeSchemaUserPhone(req *user.SetContactPhoneRequest) *command.ChangeSchemaUserPhone { + return &command.ChangeSchemaUserPhone{ + ResourceOwner: organizationToUpdateResourceOwner(req.Organization), + ID: req.GetId(), + Phone: setPhoneToPhone(req.Phone), + } +} + +func setPhoneToPhone(setPhone *user.SetPhone) *command.Phone { + if setPhone == nil { + return nil + } + return &command.Phone{ + Number: domain.PhoneNumber(setPhone.Number), + ReturnCode: setPhone.GetReturnCode() != nil, + Verified: setPhone.GetIsVerified(), + } +} + +func (s *Server) VerifyContactPhone(ctx context.Context, req *user.VerifyContactPhoneRequest) (_ *user.VerifyContactPhoneResponse, err error) { + if err := checkUserSchemaEnabled(ctx); err != nil { + return nil, err + } + details, err := s.command.VerifySchemaUserPhone(ctx, organizationToUpdateResourceOwner(req.Organization), req.GetId(), req.GetVerificationCode()) + if err != nil { + return nil, err + } + return &user.VerifyContactPhoneResponse{ + Details: resource_object.DomainToDetailsPb(details, object.OwnerType_OWNER_TYPE_ORG, details.ResourceOwner), + }, nil +} + +func (s *Server) ResendContactPhoneCode(ctx context.Context, req *user.ResendContactPhoneCodeRequest) (_ *user.ResendContactPhoneCodeResponse, err error) { + if err := checkUserSchemaEnabled(ctx); err != nil { + return nil, err + } + schemauser := resendContactPhoneCodeRequestToResendSchemaUserPhoneCode(req) + details, err := s.command.ResendSchemaUserPhoneCode(ctx, schemauser) + if err != nil { + return nil, err + } + return &user.ResendContactPhoneCodeResponse{ + Details: resource_object.DomainToDetailsPb(details, object.OwnerType_OWNER_TYPE_ORG, details.ResourceOwner), + VerificationCode: schemauser.PlainCode, + }, nil +} + +func resendContactPhoneCodeRequestToResendSchemaUserPhoneCode(req *user.ResendContactPhoneCodeRequest) *command.ResendSchemaUserPhoneCode { + return &command.ResendSchemaUserPhoneCode{ + ResourceOwner: organizationToUpdateResourceOwner(req.Organization), + ID: req.GetId(), + ReturnCode: req.GetReturnCode() != nil, + } +} diff --git a/internal/api/grpc/resources/user/v3alpha/query.go b/internal/api/grpc/resources/user/v3alpha/query.go new file mode 100644 index 00000000000..802ad3fdc36 --- /dev/null +++ b/internal/api/grpc/resources/user/v3alpha/query.go @@ -0,0 +1,14 @@ +package user + +import ( + "context" + + user "github.com/zitadel/zitadel/pkg/grpc/resources/user/v3alpha" +) + +func (s *Server) SearchUsers(ctx context.Context, _ *user.SearchUsersRequest) (_ *user.SearchUsersResponse, err error) { + if err := checkUserSchemaEnabled(ctx); err != nil { + return nil, err + } + return &user.SearchUsersResponse{}, nil +} diff --git a/internal/api/grpc/resources/user/v3alpha/server.go b/internal/api/grpc/resources/user/v3alpha/server.go index e18f0174533..57b2e440160 100644 --- a/internal/api/grpc/resources/user/v3alpha/server.go +++ b/internal/api/grpc/resources/user/v3alpha/server.go @@ -6,7 +6,6 @@ import ( "github.com/zitadel/zitadel/internal/api/authz" "github.com/zitadel/zitadel/internal/api/grpc/server" "github.com/zitadel/zitadel/internal/command" - "github.com/zitadel/zitadel/internal/crypto" user "github.com/zitadel/zitadel/pkg/grpc/resources/user/v3alpha" ) @@ -14,19 +13,16 @@ var _ user.ZITADELUsersServer = (*Server)(nil) type Server struct { user.UnimplementedZITADELUsersServer - command *command.Commands - userCodeAlg crypto.EncryptionAlgorithm + command *command.Commands } type Config struct{} func CreateServer( command *command.Commands, - userCodeAlg crypto.EncryptionAlgorithm, ) *Server { return &Server{ - command: command, - userCodeAlg: userCodeAlg, + command: command, } } diff --git a/internal/api/grpc/resources/user/v3alpha/user.go b/internal/api/grpc/resources/user/v3alpha/user.go index 7c3f2a750e4..971644de7d5 100644 --- a/internal/api/grpc/resources/user/v3alpha/user.go +++ b/internal/api/grpc/resources/user/v3alpha/user.go @@ -3,12 +3,9 @@ package user import ( "context" - "github.com/muhlemmer/gu" - "github.com/zitadel/zitadel/internal/api/authz" resource_object "github.com/zitadel/zitadel/internal/api/grpc/resources/object/v3alpha" "github.com/zitadel/zitadel/internal/command" - "github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/zerrors" object "github.com/zitadel/zitadel/pkg/grpc/object/v3alpha" "github.com/zitadel/zitadel/pkg/grpc/resources/user/v3alpha" @@ -22,14 +19,14 @@ func (s *Server) CreateUser(ctx context.Context, req *user.CreateUserRequest) (_ if err != nil { return nil, err } - - if err := s.command.CreateSchemaUser(ctx, schemauser, s.userCodeAlg); err != nil { + details, err := s.command.CreateSchemaUser(ctx, schemauser) + if err != nil { return nil, err } return &user.CreateUserResponse{ - Details: resource_object.DomainToDetailsPb(schemauser.Details, object.OwnerType_OWNER_TYPE_ORG, schemauser.Details.ResourceOwner), - EmailCode: gu.Ptr(schemauser.ReturnCodeEmail), - PhoneCode: gu.Ptr(schemauser.ReturnCodePhone), + Details: resource_object.DomainToDetailsPb(details, object.OwnerType_OWNER_TYPE_ORG, details.ResourceOwner), + EmailCode: schemauser.ReturnCodeEmail, + PhoneCode: schemauser.ReturnCodePhone, }, nil } @@ -44,6 +41,8 @@ func createUserRequestToCreateSchemaUser(ctx context.Context, req *user.CreateUs SchemaID: req.GetUser().GetSchemaId(), ID: req.GetUser().GetUserId(), Data: data, + Email: setEmailToEmail(req.GetUser().GetContact().GetEmail()), + Phone: setPhoneToPhone(req.GetUser().GetContact().GetPhone()), }, nil } @@ -91,17 +90,36 @@ func (s *Server) PatchUser(ctx context.Context, req *user.PatchUserRequest) (_ * return nil, err } - if err := s.command.ChangeSchemaUser(ctx, schemauser, s.userCodeAlg); err != nil { + details, err := s.command.ChangeSchemaUser(ctx, schemauser) + if err != nil { return nil, err } return &user.PatchUserResponse{ - Details: resource_object.DomainToDetailsPb(schemauser.Details, object.OwnerType_OWNER_TYPE_ORG, schemauser.Details.ResourceOwner), - EmailCode: gu.Ptr(schemauser.ReturnCodeEmail), - PhoneCode: gu.Ptr(schemauser.ReturnCodePhone), + Details: resource_object.DomainToDetailsPb(details, object.OwnerType_OWNER_TYPE_ORG, details.ResourceOwner), + EmailCode: schemauser.ReturnCodeEmail, + PhoneCode: schemauser.ReturnCodePhone, }, nil } func patchUserRequestToChangeSchemaUser(req *user.PatchUserRequest) (_ *command.ChangeSchemaUser, err error) { + schemaUser, err := setSchemaUserToSchemaUser(req) + if err != nil { + return nil, err + } + email, phone := setContactToContact(req.GetUser().GetContact()) + return &command.ChangeSchemaUser{ + ResourceOwner: organizationToUpdateResourceOwner(req.Organization), + ID: req.GetId(), + SchemaUser: schemaUser, + Email: email, + Phone: phone, + }, nil +} + +func setSchemaUserToSchemaUser(req *user.PatchUserRequest) (_ *command.SchemaUser, err error) { + if req.GetUser() == nil { + return nil, nil + } var data []byte if req.GetUser().Data != nil { data, err = req.GetUser().GetData().MarshalJSON() @@ -110,45 +128,19 @@ func patchUserRequestToChangeSchemaUser(req *user.PatchUserRequest) (_ *command. } } - var email *command.Email - var phone *command.Phone - if req.GetUser().GetContact() != nil { - if req.GetUser().GetContact().GetEmail() != nil { - email = &command.Email{ - Address: domain.EmailAddress(req.GetUser().GetContact().Email.Address), - } - if req.GetUser().GetContact().Email.GetIsVerified() { - email.Verified = true - } - if req.GetUser().GetContact().Email.GetReturnCode() != nil { - email.ReturnCode = true - } - if req.GetUser().GetContact().Email.GetSendCode() != nil { - email.URLTemplate = req.GetUser().GetContact().Email.GetSendCode().GetUrlTemplate() - } - } - if req.GetUser().GetContact().Phone != nil { - phone = &command.Phone{ - Number: domain.PhoneNumber(req.GetUser().GetContact().Phone.Number), - } - if req.GetUser().GetContact().Phone.GetIsVerified() { - phone.Verified = true - } - if req.GetUser().GetContact().Phone.GetReturnCode() != nil { - phone.ReturnCode = true - } - } - } - return &command.ChangeSchemaUser{ - ResourceOwner: organizationToUpdateResourceOwner(req.Organization), - ID: req.GetId(), - SchemaID: req.GetUser().SchemaId, - Data: data, - Email: email, - Phone: phone, + return &command.SchemaUser{ + SchemaID: req.GetUser().GetSchemaId(), + Data: data, }, nil } +func setContactToContact(contact *user.SetContact) (*command.Email, *command.Phone) { + if contact == nil { + return nil, nil + } + return setEmailToEmail(contact.GetEmail()), setPhoneToPhone(contact.GetPhone()) +} + func (s *Server) DeactivateUser(ctx context.Context, req *user.DeactivateUserRequest) (_ *user.DeactivateUserResponse, err error) { if err := checkUserSchemaEnabled(ctx); err != nil { return nil, err diff --git a/internal/api/grpc/resources/userschema/v3alpha/integration_test/server_test.go b/internal/api/grpc/resources/userschema/v3alpha/integration_test/server_test.go index f779a98d879..c562f9613ab 100644 --- a/internal/api/grpc/resources/userschema/v3alpha/integration_test/server_test.go +++ b/internal/api/grpc/resources/userschema/v3alpha/integration_test/server_test.go @@ -14,6 +14,7 @@ import ( "github.com/zitadel/zitadel/internal/integration" "github.com/zitadel/zitadel/pkg/grpc/feature/v2" + schema "github.com/zitadel/zitadel/pkg/grpc/resources/userschema/v3alpha" ) var ( @@ -51,7 +52,7 @@ func ensureFeatureEnabled(t *testing.T, instance *integration.Instance) { f, err := instance.Client.FeatureV2.GetInstanceFeatures(ctx, &feature.GetInstanceFeaturesRequest{ Inheritance: true, }) - require.NoError(ttt, err) + assert.NoError(ttt, err) if f.UserSchema.GetEnabled() { return } @@ -59,4 +60,13 @@ func ensureFeatureEnabled(t *testing.T, instance *integration.Instance) { retryDuration, time.Second, "timed out waiting for ensuring instance feature") + + require.EventuallyWithT(t, + func(ttt *assert.CollectT) { + _, err := instance.Client.UserSchemaV3.SearchUserSchemas(ctx, &schema.SearchUserSchemasRequest{}) + assert.NoError(ttt, err) + }, + retryDuration, + time.Second, + "timed out waiting for ensuring instance feature call") } diff --git a/internal/command/command.go b/internal/command/command.go index 89f23e6ff78..30e383c5df1 100644 --- a/internal/command/command.go +++ b/internal/command/command.go @@ -189,6 +189,9 @@ type AppendReducer interface { } func (c *Commands) pushAppendAndReduce(ctx context.Context, object AppendReducer, cmds ...eventstore.Command) error { + if len(cmds) == 0 { + return nil + } events, err := c.eventstore.Push(ctx, cmds...) if err != nil { return err @@ -196,6 +199,20 @@ func (c *Commands) pushAppendAndReduce(ctx context.Context, object AppendReducer return AppendAndReduce(object, events...) } +type AppendReducerDetails interface { + AppendEvents(...eventstore.Event) + // TODO: Why is it allowed to return an error here? + Reduce() error + GetWriteModel() *eventstore.WriteModel +} + +func (c *Commands) pushAppendAndReduceDetails(ctx context.Context, object AppendReducerDetails, cmds ...eventstore.Command) (*domain.ObjectDetails, error) { + if err := c.pushAppendAndReduce(ctx, object, cmds...); err != nil { + return nil, err + } + return writeModelToObjectDetails(object.GetWriteModel()), nil +} + func AppendAndReduce(object AppendReducer, events ...eventstore.Event) error { object.AppendEvents(events...) return object.Reduce() diff --git a/internal/command/user_v3.go b/internal/command/user_v3.go index d5a097ce27e..2251baa1365 100644 --- a/internal/command/user_v3.go +++ b/internal/command/user_v3.go @@ -6,17 +6,13 @@ import ( "encoding/json" "github.com/zitadel/zitadel/internal/api/authz" - "github.com/zitadel/zitadel/internal/crypto" "github.com/zitadel/zitadel/internal/domain" domain_schema "github.com/zitadel/zitadel/internal/domain/schema" - "github.com/zitadel/zitadel/internal/eventstore" "github.com/zitadel/zitadel/internal/repository/user/schemauser" "github.com/zitadel/zitadel/internal/zerrors" ) type CreateSchemaUser struct { - Details *domain.ObjectDetails - SchemaID string schemaRevision uint64 @@ -25,9 +21,9 @@ type CreateSchemaUser struct { Data json.RawMessage Email *Email - ReturnCodeEmail string + ReturnCodeEmail *string Phone *Phone - ReturnCodePhone string + ReturnCodePhone *string } func (s *CreateSchemaUser) Valid(ctx context.Context, c *Commands) (err error) { @@ -99,44 +95,36 @@ func (c *Commands) getSchemaRoleForWrite(ctx context.Context, resourceOwner, use return domain_schema.RoleOwner, nil } -func (c *Commands) CreateSchemaUser(ctx context.Context, user *CreateSchemaUser, alg crypto.EncryptionAlgorithm) (err error) { +func (c *Commands) CreateSchemaUser(ctx context.Context, user *CreateSchemaUser) (*domain.ObjectDetails, error) { if err := user.Valid(ctx, c); err != nil { - return err + return nil, err } writeModel, err := c.getSchemaUserExists(ctx, user.ResourceOwner, user.ID) if err != nil { - return err - } - if writeModel.Exists() { - return zerrors.ThrowPreconditionFailed(nil, "COMMAND-Nn8CRVlkeZ", "Errors.User.AlreadyExists") + return nil, err } - userAgg := UserV3AggregateFromWriteModel(&writeModel.WriteModel) - events := []eventstore.Command{ - schemauser.NewCreatedEvent(ctx, - userAgg, - user.SchemaID, user.schemaRevision, user.Data, - ), + events, codeEmail, codePhone, err := writeModel.NewCreated(ctx, + user.SchemaID, + user.schemaRevision, + user.Data, + user.Email, + user.Phone, + func(ctx context.Context) (*EncryptedCode, error) { + return c.newEmailCode(ctx, c.eventstore.Filter, c.userEncryption) //nolint:staticcheck + }, + ) + if err != nil { + return nil, err } - if user.Email != nil { - events, user.ReturnCodeEmail, err = c.updateSchemaUserEmail(ctx, writeModel, events, userAgg, user.Email, alg) - if err != nil { - return err - } + if codeEmail != "" { + user.ReturnCodeEmail = &codeEmail } - if user.Phone != nil { - events, user.ReturnCodePhone, err = c.updateSchemaUserPhone(ctx, writeModel, events, userAgg, user.Phone, alg) - if err != nil { - return err - } + if codePhone != "" { + user.ReturnCodePhone = &codePhone } - - if err := c.pushAppendAndReduce(ctx, writeModel, events...); err != nil { - return err - } - user.Details = writeModelToObjectDetails(&writeModel.WriteModel) - return nil + return c.pushAppendAndReduceDetails(ctx, writeModel, events...) } func (c *Commands) DeleteSchemaUser(ctx context.Context, resourceOwner, id string) (*domain.ObjectDetails, error) { @@ -147,50 +135,38 @@ func (c *Commands) DeleteSchemaUser(ctx context.Context, resourceOwner, id strin if err != nil { return nil, err } - if !writeModel.Exists() { - return nil, zerrors.ThrowNotFound(nil, "COMMAND-syHyCsGmvM", "Errors.User.NotFound") - } - if err := c.checkPermissionDeleteUser(ctx, writeModel.ResourceOwner, writeModel.AggregateID); err != nil { + + events, err := writeModel.NewDelete(ctx) + if err != nil { return nil, err } - if err := c.pushAppendAndReduce(ctx, writeModel, - schemauser.NewDeletedEvent(ctx, UserV3AggregateFromWriteModel(&writeModel.WriteModel)), - ); err != nil { - return nil, err - } - return writeModelToObjectDetails(&writeModel.WriteModel), nil + + return c.pushAppendAndReduceDetails(ctx, writeModel, events...) } type ChangeSchemaUser struct { - Details *domain.ObjectDetails - - SchemaID *string schemaWriteModel *UserSchemaWriteModel ResourceOwner string ID string - Data json.RawMessage + + SchemaUser *SchemaUser Email *Email - ReturnCodeEmail string + ReturnCodeEmail *string Phone *Phone - ReturnCodePhone string + ReturnCodePhone *string } -func (s *ChangeSchemaUser) Valid(ctx context.Context, c *Commands) (err error) { +type SchemaUser struct { + SchemaID string + Data json.RawMessage +} + +func (s *ChangeSchemaUser) Valid() (err error) { if s.ID == "" { return zerrors.ThrowInvalidArgument(nil, "COMMAND-gEJR1QOGHb", "Errors.IDMissing") } - if s.SchemaID != nil { - s.schemaWriteModel, err = c.getSchemaWriteModelByID(ctx, "", *s.SchemaID) - if err != nil { - return err - } - if !s.schemaWriteModel.Exists() { - return zerrors.ThrowPreconditionFailed(nil, "COMMAND-VLDTtxT3If", "Errors.UserSchema.NotExists") - } - } - if s.Email != nil && s.Email.Address != "" { if err := s.Email.Validate(); err != nil { return err @@ -206,92 +182,56 @@ func (s *ChangeSchemaUser) Valid(ctx context.Context, c *Commands) (err error) { return nil } -func (s *ChangeSchemaUser) ValidData(ctx context.Context, c *Commands, existingUser *UserV3WriteModel) (err error) { - // get role for permission check in schema through extension - role, err := c.getSchemaRoleForWrite(ctx, existingUser.ResourceOwner, existingUser.AggregateID) - if err != nil { - return err - } - - if s.schemaWriteModel == nil { - s.schemaWriteModel, err = c.getSchemaWriteModelByID(ctx, "", existingUser.SchemaID) - if err != nil { - return err - } - } - - schema, err := domain_schema.NewSchema(role, bytes.NewReader(s.schemaWriteModel.Schema)) - if err != nil { - return err - } - - // if data not changed but a new schema or revision should be used - data := s.Data - if s.Data == nil { - data = existingUser.Data - } - - var v interface{} - if err := json.Unmarshal(data, &v); err != nil { - return zerrors.ThrowInvalidArgument(nil, "COMMAND-7o3ZGxtXUz", "Errors.User.Invalid") - } - - if err := schema.Validate(v); err != nil { - return zerrors.ThrowPreconditionFailed(nil, "COMMAND-SlKXqLSeL6", "Errors.UserSchema.Data.Invalid") - } - return nil -} - -func (c *Commands) ChangeSchemaUser(ctx context.Context, user *ChangeSchemaUser, alg crypto.EncryptionAlgorithm) (err error) { - if err := user.Valid(ctx, c); err != nil { - return err +func (c *Commands) ChangeSchemaUser(ctx context.Context, user *ChangeSchemaUser) (*domain.ObjectDetails, error) { + if err := user.Valid(); err != nil { + return nil, err } writeModel, err := c.getSchemaUserWriteModelByID(ctx, user.ResourceOwner, user.ID) if err != nil { - return err + return nil, err } if !writeModel.Exists() { - return zerrors.ThrowPreconditionFailed(nil, "COMMAND-Nn8CRVlkeZ", "Errors.User.NotFound") + return nil, zerrors.ThrowPreconditionFailed(nil, "COMMAND-Nn8CRVlkeZ", "Errors.User.NotFound") } - userAgg := UserV3AggregateFromWriteModel(&writeModel.WriteModel) - events := make([]eventstore.Command, 0) - if user.Data != nil || user.SchemaID != nil { - if err := user.ValidData(ctx, c, writeModel); err != nil { - return err - } - updateEvent := writeModel.NewUpdatedEvent(ctx, - userAgg, - user.schemaWriteModel.AggregateID, - user.schemaWriteModel.SchemaRevision, - user.Data, - ) - if updateEvent != nil { - events = append(events, updateEvent) - } + schemaID := writeModel.SchemaID + if user.SchemaUser != nil && user.SchemaUser.SchemaID != "" { + schemaID = user.SchemaUser.SchemaID } - if user.Email != nil { - events, user.ReturnCodeEmail, err = c.updateSchemaUserEmail(ctx, writeModel, events, userAgg, user.Email, alg) + + var schemaWM *UserSchemaWriteModel + if user.SchemaUser != nil { + schemaWriteModel, err := c.getSchemaWriteModelByID(ctx, "", schemaID) if err != nil { - return err + return nil, err } - } - if user.Phone != nil { - events, user.ReturnCodePhone, err = c.updateSchemaUserPhone(ctx, writeModel, events, userAgg, user.Phone, alg) - if err != nil { - return err + if !schemaWriteModel.Exists() { + return nil, zerrors.ThrowPreconditionFailed(nil, "COMMAND-VLDTtxT3If", "Errors.UserSchema.NotExists") } + schemaWM = schemaWriteModel } - if len(events) == 0 { - user.Details = writeModelToObjectDetails(&writeModel.WriteModel) - return nil + + events, codeEmail, codePhone, err := writeModel.NewUpdate(ctx, + schemaWM, + user.SchemaUser, + user.Email, + user.Phone, + func(ctx context.Context) (*EncryptedCode, error) { + return c.newEmailCode(ctx, c.eventstore.Filter, c.userEncryption) //nolint:staticcheck + }, + ) + if err != nil { + return nil, err } - if err := c.pushAppendAndReduce(ctx, writeModel, events...); err != nil { - return err + + if codeEmail != "" { + user.ReturnCodeEmail = &codeEmail } - user.Details = writeModelToObjectDetails(&writeModel.WriteModel) - return nil + if codePhone != "" { + user.ReturnCodePhone = &codePhone + } + return c.pushAppendAndReduceDetails(ctx, writeModel, events...) } func (c *Commands) checkPermissionUpdateUserState(ctx context.Context, resourceOwner, userID string) error { @@ -368,7 +308,7 @@ func (c *Commands) ActivateSchemaUser(ctx context.Context, resourceOwner, id str if id == "" { return nil, zerrors.ThrowInvalidArgument(nil, "COMMAND-17XupGvxBJ", "Errors.IDMissing") } - writeModel, err := c.getSchemaUserExists(ctx, "", id) + writeModel, err := c.getSchemaUserExists(ctx, resourceOwner, id) if err != nil { return nil, err } @@ -386,65 +326,8 @@ func (c *Commands) ActivateSchemaUser(ctx context.Context, resourceOwner, id str return writeModelToObjectDetails(&writeModel.WriteModel), nil } -func (c *Commands) updateSchemaUserEmail(ctx context.Context, existing *UserV3WriteModel, events []eventstore.Command, agg *eventstore.Aggregate, email *Email, alg crypto.EncryptionAlgorithm) (_ []eventstore.Command, plainCode string, err error) { - if existing.Email == string(email.Address) { - return events, plainCode, nil - } - - events = append(events, schemauser.NewEmailUpdatedEvent(ctx, - agg, - email.Address, - )) - if email.Verified { - events = append(events, schemauser.NewEmailVerifiedEvent(ctx, agg)) - } else { - cryptoCode, err := c.newEmailCode(ctx, c.eventstore.Filter, alg) //nolint:staticcheck - if err != nil { - return nil, "", err - } - if email.ReturnCode { - plainCode = cryptoCode.Plain - } - events = append(events, schemauser.NewEmailCodeAddedEvent(ctx, agg, - cryptoCode.Crypted, - cryptoCode.Expiry, - email.URLTemplate, - email.ReturnCode, - )) - } - return events, plainCode, nil -} - -func (c *Commands) updateSchemaUserPhone(ctx context.Context, existing *UserV3WriteModel, events []eventstore.Command, agg *eventstore.Aggregate, phone *Phone, alg crypto.EncryptionAlgorithm) (_ []eventstore.Command, plainCode string, err error) { - if existing.Phone == string(phone.Number) { - return events, plainCode, nil - } - - events = append(events, schemauser.NewPhoneUpdatedEvent(ctx, - agg, - phone.Number, - )) - if phone.Verified { - events = append(events, schemauser.NewPhoneVerifiedEvent(ctx, agg)) - } else { - cryptoCode, err := c.newPhoneCode(ctx, c.eventstore.Filter, alg) //nolint:staticcheck - if err != nil { - return nil, "", err - } - if phone.ReturnCode { - plainCode = cryptoCode.Plain - } - events = append(events, schemauser.NewPhoneCodeAddedEvent(ctx, agg, - cryptoCode.Crypted, - cryptoCode.Expiry, - phone.ReturnCode, - )) - } - return events, plainCode, nil -} - func (c *Commands) getSchemaUserExists(ctx context.Context, resourceOwner, id string) (*UserV3WriteModel, error) { - writeModel := NewExistsUserV3WriteModel(resourceOwner, id) + writeModel := NewExistsUserV3WriteModel(resourceOwner, id, c.checkPermission) if err := c.eventstore.FilterToQueryReducer(ctx, writeModel); err != nil { return nil, err } @@ -452,7 +335,23 @@ func (c *Commands) getSchemaUserExists(ctx context.Context, resourceOwner, id st } func (c *Commands) getSchemaUserWriteModelByID(ctx context.Context, resourceOwner, id string) (*UserV3WriteModel, error) { - writeModel := NewUserV3WriteModel(resourceOwner, id) + writeModel := NewUserV3WriteModel(resourceOwner, id, c.checkPermission) + if err := c.eventstore.FilterToQueryReducer(ctx, writeModel); err != nil { + return nil, err + } + return writeModel, nil +} + +func (c *Commands) getSchemaUserEmailWriteModelByID(ctx context.Context, resourceOwner, id string) (*UserV3WriteModel, error) { + writeModel := NewUserV3EmailWriteModel(resourceOwner, id, c.checkPermission) + if err := c.eventstore.FilterToQueryReducer(ctx, writeModel); err != nil { + return nil, err + } + return writeModel, nil +} + +func (c *Commands) getSchemaUserPhoneWriteModelByID(ctx context.Context, resourceOwner, id string) (*UserV3WriteModel, error) { + writeModel := NewUserV3PhoneWriteModel(resourceOwner, id, c.checkPermission) if err := c.eventstore.FilterToQueryReducer(ctx, writeModel); err != nil { return nil, err } diff --git a/internal/command/user_v3_email.go b/internal/command/user_v3_email.go new file mode 100644 index 00000000000..9fa3a235f5e --- /dev/null +++ b/internal/command/user_v3_email.go @@ -0,0 +1,115 @@ +package command + +import ( + "context" + "io" + "time" + + "github.com/zitadel/zitadel/internal/crypto" + "github.com/zitadel/zitadel/internal/domain" + "github.com/zitadel/zitadel/internal/zerrors" +) + +type ChangeSchemaUserEmail struct { + ResourceOwner string + ID string + + Email *Email + ReturnCode *string +} + +func (s *ChangeSchemaUserEmail) Valid() (err error) { + if s.ID == "" { + return zerrors.ThrowInvalidArgument(nil, "COMMAND-0oj2PquNGA", "Errors.IDMissing") + } + if s.Email != nil && s.Email.Address != "" { + if err := s.Email.Validate(); err != nil { + return err + } + } + if s.Email != nil && s.Email.URLTemplate != "" { + if err := domain.RenderConfirmURLTemplate(io.Discard, s.Email.URLTemplate, s.ID, "code", "orgID"); err != nil { + return err + } + } + return nil +} + +func (c *Commands) ChangeSchemaUserEmail(ctx context.Context, user *ChangeSchemaUserEmail) (_ *domain.ObjectDetails, err error) { + if err := user.Valid(); err != nil { + return nil, err + } + + writeModel, err := c.getSchemaUserEmailWriteModelByID(ctx, user.ResourceOwner, user.ID) + if err != nil { + return nil, err + } + + events, plainCode, err := writeModel.NewEmailUpdate(ctx, + user.Email, + func(ctx context.Context) (*EncryptedCode, error) { + return c.newEmailCode(ctx, c.eventstore.Filter, c.userEncryption) //nolint:staticcheck + }, + ) + if err != nil { + return nil, err + } + if plainCode != "" { + user.ReturnCode = &plainCode + } + return c.pushAppendAndReduceDetails(ctx, writeModel, events...) +} + +func (c *Commands) VerifySchemaUserEmail(ctx context.Context, resourceOwner, id, code string) (*domain.ObjectDetails, error) { + if id == "" { + return nil, zerrors.ThrowInvalidArgument(nil, "COMMAND-y3n4Sdu8j5", "Errors.IDMissing") + } + writeModel, err := c.getSchemaUserEmailWriteModelByID(ctx, resourceOwner, id) + if err != nil { + return nil, err + } + + events, err := writeModel.NewEmailVerify(ctx, + func(creationDate time.Time, expiry time.Duration, cryptoCode *crypto.CryptoValue) error { + return crypto.VerifyCode(creationDate, expiry, cryptoCode, code, c.userEncryption) + }, + ) + if err != nil { + return nil, err + } + return c.pushAppendAndReduceDetails(ctx, writeModel, events...) +} + +type ResendSchemaUserEmailCode struct { + ResourceOwner string + ID string + + URLTemplate string + ReturnCode bool + PlainCode *string +} + +func (c *Commands) ResendSchemaUserEmailCode(ctx context.Context, user *ResendSchemaUserEmailCode) (*domain.ObjectDetails, error) { + if user.ID == "" { + return nil, zerrors.ThrowInvalidArgument(nil, "COMMAND-KvPc5o9GeJ", "Errors.IDMissing") + } + writeModel, err := c.getSchemaUserEmailWriteModelByID(ctx, user.ResourceOwner, user.ID) + if err != nil { + return nil, err + } + + events, plainCode, err := writeModel.NewResendEmailCode(ctx, + func(ctx context.Context) (*EncryptedCode, error) { + return c.newEmailCode(ctx, c.eventstore.Filter, c.userEncryption) //nolint:staticcheck + }, + user.URLTemplate, + user.ReturnCode, + ) + if err != nil { + return nil, err + } + if plainCode != "" { + user.PlainCode = &plainCode + } + return c.pushAppendAndReduceDetails(ctx, writeModel, events...) +} diff --git a/internal/command/user_v3_email_test.go b/internal/command/user_v3_email_test.go new file mode 100644 index 00000000000..5516b32f19e --- /dev/null +++ b/internal/command/user_v3_email_test.go @@ -0,0 +1,1076 @@ +package command + +import ( + "context" + "encoding/json" + "errors" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "go.uber.org/mock/gomock" + + "github.com/zitadel/zitadel/internal/api/authz" + "github.com/zitadel/zitadel/internal/crypto" + "github.com/zitadel/zitadel/internal/domain" + "github.com/zitadel/zitadel/internal/eventstore" + "github.com/zitadel/zitadel/internal/repository/user/schema" + "github.com/zitadel/zitadel/internal/repository/user/schemauser" + "github.com/zitadel/zitadel/internal/zerrors" +) + +func TestCommands_ChangeSchemaUserEmail(t *testing.T) { + type fields struct { + eventstore func(t *testing.T) *eventstore.Eventstore + checkPermission domain.PermissionCheck + newCode encrypedCodeFunc + } + type args struct { + ctx context.Context + user *ChangeSchemaUserEmail + } + type res struct { + returnCode string + details *domain.ObjectDetails + err func(error) bool + } + tests := []struct { + name string + fields fields + args args + res res + }{ + { + "no userID, error", + fields{ + eventstore: expectEventstore(), + checkPermission: newMockPermissionCheckAllowed(), + }, + args{ + ctx: authz.NewMockContext("instanceID", "", ""), + user: &ChangeSchemaUserEmail{}, + }, + res{ + err: func(err error) bool { + return errors.Is(err, zerrors.ThrowInvalidArgument(nil, "COMMAND-0oj2PquNGA", "Errors.IDMissing")) + }, + }, + }, + { + "no valid email, error", + fields{ + eventstore: expectEventstore(), + checkPermission: newMockPermissionCheckAllowed(), + }, + args{ + ctx: authz.NewMockContext("instanceID", "", ""), + user: &ChangeSchemaUserEmail{ + ID: "user1", + Email: &Email{Address: "noemail"}, + }, + }, + res{ + err: func(err error) bool { + return errors.Is(err, zerrors.ThrowInvalidArgument(nil, "EMAIL-599BI", "Errors.User.Email.Invalid")) + }, + }, + }, + { + "no valid template, error", + fields{ + eventstore: expectEventstore(), + checkPermission: newMockPermissionCheckAllowed(), + }, + args{ + ctx: authz.NewMockContext("instanceID", "", ""), + user: &ChangeSchemaUserEmail{ + ID: "user1", + Email: &Email{Address: "noemail", URLTemplate: "{{"}, + }, + }, + res{ + err: func(err error) bool { + return errors.Is(err, zerrors.ThrowInvalidArgument(nil, "EMAIL-599BI", "Errors.User.Email.Invalid")) + }, + }, + }, + { + "email update, user not found", + fields{ + eventstore: expectEventstore( + expectFilter(), + ), + checkPermission: newMockPermissionCheckNotAllowed(), + }, + args{ + ctx: authz.NewMockContext("instanceID", "", ""), + user: &ChangeSchemaUserEmail{ + ID: "user1", + }, + }, + res{ + err: func(err error) bool { + return errors.Is(err, zerrors.ThrowNotFound(nil, "COMMAND-nJ0TQFuRmP", "Errors.User.NotFound")) + }, + }, + }, + { + "email update, no permission", + fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + schemauser.NewCreatedEvent( + context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + "id1", + 1, + json.RawMessage(`{ + "name": "user1" + }`), + ), + ), + ), + ), + checkPermission: newMockPermissionCheckNotAllowed(), + }, + args{ + ctx: authz.NewMockContext("instanceID", "", ""), + user: &ChangeSchemaUserEmail{ + ID: "user1", + Email: &Email{Address: "noemail@example.com"}, + }, + }, + res{ + err: func(err error) bool { + return errors.Is(err, zerrors.ThrowPermissionDenied(nil, "AUTHZ-HKJD33", "Errors.PermissionDenied")) + }, + }, + }, + { + "email update, email not changed", + fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + schemauser.NewCreatedEvent( + context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + "id1", + 1, + json.RawMessage(`{ + "name": "user1" + }`), + ), + ), + eventFromEventPusher( + schemauser.NewEmailUpdatedEvent( + context.Background(), + &schema.NewAggregate("id1", "instanceID").Aggregate, + "test@example.com", + ), + ), + ), + ), + checkPermission: newMockPermissionCheckAllowed(), + }, + args{ + ctx: authz.NewMockContext("instanceID", "", ""), + user: &ChangeSchemaUserEmail{ + ID: "user1", + Email: &Email{ + Address: "test@example.com", + ReturnCode: true, + URLTemplate: "https://example.com/email/verify?userID={{.UserID}}&code={{.Code}}&orgID={{.OrgID}}", + }, + }, + }, + res{ + details: &domain.ObjectDetails{ + ResourceOwner: "org1", + }, + }, + }, + { + "email update, email return", + fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + schemauser.NewCreatedEvent( + context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + "id1", + 1, + json.RawMessage(`{ + "name": "user1" + }`), + ), + ), + ), + expectPush( + schemauser.NewEmailUpdatedEvent(context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + "test@example.com", + ), + schemauser.NewEmailCodeAddedEvent(context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + &crypto.CryptoValue{ + CryptoType: crypto.TypeEncryption, + Algorithm: "enc", + KeyID: "id", + Crypted: []byte("emailverify"), + }, + time.Hour*1, + "https://example.com/email/verify?userID={{.UserID}}&code={{.Code}}&orgID={{.OrgID}}", + true, + ), + ), + ), + checkPermission: newMockPermissionCheckAllowed(), + newCode: mockEncryptedCode("emailverify", time.Hour), + }, + args{ + ctx: authz.NewMockContext("instanceID", "", ""), + user: &ChangeSchemaUserEmail{ + ID: "user1", + Email: &Email{ + Address: "test@example.com", + ReturnCode: true, + URLTemplate: "https://example.com/email/verify?userID={{.UserID}}&code={{.Code}}&orgID={{.OrgID}}", + }, + }, + }, + res{ + details: &domain.ObjectDetails{ + ResourceOwner: "org1", + }, + returnCode: "emailverify", + }, + }, + { + "user updated, email to verify", + fields{ + eventstore: expectEventstore( + expectFilter( + schemauser.NewCreatedEvent( + context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + "type", + 1, + json.RawMessage(`{ + "name": "user" + }`), + )), + expectPush( + schemauser.NewEmailUpdatedEvent(context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + "test@example.com", + ), + schemauser.NewEmailCodeAddedEvent(context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + &crypto.CryptoValue{ + CryptoType: crypto.TypeEncryption, + Algorithm: "enc", + KeyID: "id", + Crypted: []byte("emailverify"), + }, + time.Hour*1, + "https://example.com/email/verify?userID={{.UserID}}&code={{.Code}}&orgID={{.OrgID}}", + false, + ), + ), + ), + checkPermission: newMockPermissionCheckAllowed(), + newCode: mockEncryptedCode("emailverify", time.Hour), + }, + args{ + ctx: authz.NewMockContext("instanceID", "", ""), + user: &ChangeSchemaUserEmail{ + ID: "user1", + Email: &Email{ + Address: "test@example.com", + URLTemplate: "https://example.com/email/verify?userID={{.UserID}}&code={{.Code}}&orgID={{.OrgID}}", + }, + }, + }, + res{ + details: &domain.ObjectDetails{ + ResourceOwner: "org1", + }, + }, + }, + { + "user updated, verified", + fields{ + eventstore: expectEventstore( + expectFilter( + schemauser.NewCreatedEvent( + context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + "type", + 1, + json.RawMessage(`{ + "name": "user" + }`), + ), + ), + expectPush( + schemauser.NewEmailUpdatedEvent(context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + "test@example.com", + ), + schemauser.NewEmailVerifiedEvent(context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + ), + ), + ), + checkPermission: newMockPermissionCheckAllowed(), + }, + args{ + ctx: authz.NewMockContext("instanceID", "", ""), + user: &ChangeSchemaUserEmail{ + ID: "user1", + Email: &Email{Address: "test@example.com", Verified: true}, + }, + }, + res{ + details: &domain.ObjectDetails{ + ResourceOwner: "org1", + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + c := &Commands{ + eventstore: tt.fields.eventstore(t), + checkPermission: tt.fields.checkPermission, + newEncryptedCode: tt.fields.newCode, + userEncryption: crypto.CreateMockEncryptionAlg(gomock.NewController(t)), + } + details, err := c.ChangeSchemaUserEmail(tt.args.ctx, tt.args.user) + 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 { + assertObjectDetails(t, tt.res.details, details) + if tt.res.returnCode != "" { + assert.NotNil(t, tt.args.user.ReturnCode) + assert.Equal(t, tt.res.returnCode, *tt.args.user.ReturnCode) + } + } + }) + } +} + +func TestCommands_VerifySchemaUserEmail(t *testing.T) { + type fields struct { + eventstore func(t *testing.T) *eventstore.Eventstore + checkPermission domain.PermissionCheck + } + type args struct { + ctx context.Context + resourceOwner string + id string + code string + } + type res struct { + details *domain.ObjectDetails + err func(error) bool + } + tests := []struct { + name string + fields fields + args args + res res + }{ + { + "no userID, error", + fields{ + eventstore: expectEventstore(), + }, + args{ + ctx: authz.NewMockContext("instanceID", "", ""), + id: "", + }, + res{ + err: func(err error) bool { + return errors.Is(err, zerrors.ThrowInvalidArgument(nil, "COMMAND-y3n4Sdu8j5", "Errors.IDMissing")) + }, + }, + }, + { + "email verify, user not found", + fields{ + eventstore: expectEventstore( + expectFilter(), + ), + }, + args{ + ctx: authz.NewMockContext("instanceID", "", ""), + id: "user1", + }, + res{ + err: func(err error) bool { + return errors.Is(err, zerrors.ThrowNotFound(nil, "COMMAND-qbGyMPvjvj", "Errors.User.NotFound")) + }, + }, + }, + { + "email verify, no code", + fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + schemauser.NewCreatedEvent( + context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + "id1", + 1, + json.RawMessage(`{ + "name": "user1" + }`), + ), + ), + ), + ), + checkPermission: newMockPermissionCheckAllowed(), + }, + args{ + ctx: authz.NewMockContext("instanceID", "", ""), + id: "user1", + }, + res{ + details: &domain.ObjectDetails{ + ResourceOwner: "org1", + }, + }, + }, + { + "email verify, already verified", + fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + schemauser.NewCreatedEvent( + context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + "id1", + 1, + json.RawMessage(`{ + "name": "user1" + }`), + ), + ), + eventFromEventPusher( + schemauser.NewEmailUpdatedEvent( + context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + "test@example.com", + ), + ), + eventFromEventPusher( + schemauser.NewEmailCodeAddedEvent( + context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + &crypto.CryptoValue{ + CryptoType: crypto.TypeEncryption, + Algorithm: "enc", + KeyID: "id", + Crypted: []byte("emailverify"), + }, + time.Hour*1, + "https://example.com/email/verify?userID={{.UserID}}&code={{.Code}}&orgID={{.OrgID}}", + false, + ), + ), + eventFromEventPusher( + schemauser.NewEmailVerifiedEvent( + context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + ), + ), + ), + ), + checkPermission: newMockPermissionCheckAllowed(), + }, + args{ + ctx: authz.NewMockContext("instanceID", "", ""), + id: "user1", + }, + res{ + details: &domain.ObjectDetails{ + ResourceOwner: "org1", + }, + }, + }, + { + "email update, no permission", + fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + schemauser.NewCreatedEvent( + context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + "id1", + 1, + json.RawMessage(`{ + "name": "user1" + }`), + ), + ), + eventFromEventPusher( + schemauser.NewEmailUpdatedEvent( + context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + "test@example.com", + ), + ), + eventFromEventPusher( + schemauser.NewEmailCodeAddedEvent( + context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + &crypto.CryptoValue{ + CryptoType: crypto.TypeEncryption, + Algorithm: "enc", + KeyID: "id", + Crypted: []byte("emailverify"), + }, + time.Hour*1, + "https://example.com/email/verify?userID={{.UserID}}&code={{.Code}}&orgID={{.OrgID}}", + false, + ), + ), + ), + ), + checkPermission: newMockPermissionCheckNotAllowed(), + }, + args{ + ctx: authz.NewMockContext("instanceID", "", ""), + id: "user1", + }, + res{ + err: func(err error) bool { + return errors.Is(err, zerrors.ThrowPermissionDenied(nil, "AUTHZ-HKJD33", "Errors.PermissionDenied")) + }, + }, + }, + { + "email verify, wrong code", + fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + schemauser.NewCreatedEvent( + context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + "id1", + 1, + json.RawMessage(`{ + "name": "user1" + }`), + ), + ), + eventFromEventPusher( + schemauser.NewEmailUpdatedEvent( + context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + "test@example.com", + ), + ), + eventFromEventPusherWithCreationDateNow( + schemauser.NewEmailCodeAddedEvent( + context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + &crypto.CryptoValue{ + CryptoType: crypto.TypeEncryption, + Algorithm: "enc", + KeyID: "id", + Crypted: []byte("emailverify"), + }, + time.Hour*1, + "https://example.com/email/verify?userID={{.UserID}}&code={{.Code}}&orgID={{.OrgID}}", + false, + ), + ), + ), + ), + checkPermission: newMockPermissionCheckAllowed(), + }, + args{ + ctx: authz.NewMockContext("instanceID", "", ""), + id: "user1", + }, + res{ + err: func(err error) bool { + return errors.Is(err, zerrors.ThrowInvalidArgument(nil, "CODE-woT0xc", "Errors.User.Code.Invalid")) + }, + }, + }, + { + "email verify, ok", + fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + schemauser.NewCreatedEvent( + context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + "id1", + 1, + json.RawMessage(`{ + "name": "user1" + }`), + ), + ), + eventFromEventPusher( + schemauser.NewEmailUpdatedEvent( + context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + "test@example.com", + ), + ), + eventFromEventPusherWithCreationDateNow( + schemauser.NewEmailCodeAddedEvent( + context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + &crypto.CryptoValue{ + CryptoType: crypto.TypeEncryption, + Algorithm: "enc", + KeyID: "id", + Crypted: []byte("emailverify"), + }, + time.Hour*1, + "https://example.com/email/verify?userID={{.UserID}}&code={{.Code}}&orgID={{.OrgID}}", + false, + ), + ), + ), + expectPush( + eventFromEventPusher( + schemauser.NewEmailVerifiedEvent( + context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + ), + ), + ), + ), + checkPermission: newMockPermissionCheckAllowed(), + }, + args{ + ctx: authz.NewMockContext("instanceID", "", ""), + id: "user1", + code: "emailverify", + }, + res{ + details: &domain.ObjectDetails{ + ResourceOwner: "org1", + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + c := &Commands{ + eventstore: tt.fields.eventstore(t), + checkPermission: tt.fields.checkPermission, + userEncryption: crypto.CreateMockEncryptionAlg(gomock.NewController(t)), + } + details, err := c.VerifySchemaUserEmail(tt.args.ctx, tt.args.resourceOwner, tt.args.id, tt.args.code) + 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) + return + } + if tt.res.err == nil { + assertObjectDetails(t, tt.res.details, details) + } + }) + } +} + +func TestCommands_ResendSchemaUserEmailCode(t *testing.T) { + type fields struct { + eventstore func(t *testing.T) *eventstore.Eventstore + checkPermission domain.PermissionCheck + newCode encrypedCodeFunc + } + type args struct { + ctx context.Context + user *ResendSchemaUserEmailCode + } + type res struct { + returnCode string + details *domain.ObjectDetails + err func(error) bool + } + tests := []struct { + name string + fields fields + args args + res res + }{ + { + "no userID, error", + fields{ + eventstore: expectEventstore(), + }, + args{ + ctx: authz.NewMockContext("instanceID", "", ""), + user: &ResendSchemaUserEmailCode{ + ID: "", + }, + }, + res{ + err: func(err error) bool { + return errors.Is(err, zerrors.ThrowInvalidArgument(nil, "COMMAND-KvPc5o9GeJ", "Errors.IDMissing")) + }, + }, + }, + { + "email code resend, user not found", + fields{ + eventstore: expectEventstore( + expectFilter(), + ), + }, + args{ + ctx: authz.NewMockContext("instanceID", "", ""), + user: &ResendSchemaUserEmailCode{ + ID: "user1", + }, + }, + res{ + err: func(err error) bool { + return errors.Is(err, zerrors.ThrowNotFound(nil, "COMMAND-EajeF6ypOV", "Errors.User.NotFound")) + }, + }, + }, + { + "email code resend, no code", + fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + schemauser.NewCreatedEvent( + context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + "id1", + 1, + json.RawMessage(`{ + "name": "user1" + }`), + ), + ), + ), + ), + checkPermission: newMockPermissionCheckAllowed(), + }, + args{ + ctx: authz.NewMockContext("instanceID", "", ""), + user: &ResendSchemaUserEmailCode{ + ID: "user1", + }, + }, + res{ + err: func(err error) bool { + return errors.Is(err, zerrors.ThrowPreconditionFailed(nil, "COMMAND-QRkNTBwF8q", "Errors.User.Code.Empty")) + }, + }, + }, + { + "email code resend, already verified", + fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + schemauser.NewCreatedEvent( + context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + "id1", + 1, + json.RawMessage(`{ + "name": "user1" + }`), + ), + ), + eventFromEventPusher( + schemauser.NewEmailUpdatedEvent( + context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + "test@example.com", + ), + ), + eventFromEventPusher( + schemauser.NewEmailCodeAddedEvent( + context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + &crypto.CryptoValue{ + CryptoType: crypto.TypeEncryption, + Algorithm: "enc", + KeyID: "id", + Crypted: []byte("emailverify"), + }, + time.Hour*1, + "https://example.com/email/verify?userID={{.UserID}}&code={{.Code}}&orgID={{.OrgID}}", + false, + ), + ), + eventFromEventPusher( + schemauser.NewEmailVerifiedEvent( + context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + ), + ), + ), + ), + checkPermission: newMockPermissionCheckAllowed(), + }, + args{ + ctx: authz.NewMockContext("instanceID", "", ""), + user: &ResendSchemaUserEmailCode{ + ID: "user1", + }, + }, + res{ + err: func(err error) bool { + return errors.Is(err, zerrors.ThrowPreconditionFailed(nil, "COMMAND-QRkNTBwF8q", "Errors.User.Code.Empty")) + }, + }, + }, + { + "email code resend, no permission", + fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + schemauser.NewCreatedEvent( + context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + "id1", + 1, + json.RawMessage(`{ + "name": "user1" + }`), + ), + ), + eventFromEventPusher( + schemauser.NewEmailUpdatedEvent( + context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + "test@example.com", + ), + ), + eventFromEventPusher( + schemauser.NewEmailCodeAddedEvent( + context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + &crypto.CryptoValue{ + CryptoType: crypto.TypeEncryption, + Algorithm: "enc", + KeyID: "id", + Crypted: []byte("emailverify"), + }, + time.Hour*1, + "https://example.com/email/verify?userID={{.UserID}}&code={{.Code}}&orgID={{.OrgID}}", + false, + ), + ), + ), + ), + checkPermission: newMockPermissionCheckNotAllowed(), + }, + args{ + ctx: authz.NewMockContext("instanceID", "", ""), + user: &ResendSchemaUserEmailCode{ + ID: "user1", + }, + }, + res{ + err: func(err error) bool { + return errors.Is(err, zerrors.ThrowPermissionDenied(nil, "AUTHZ-HKJD33", "Errors.PermissionDenied")) + }, + }, + }, + { + "email code resend, ok", + fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + schemauser.NewCreatedEvent( + context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + "id1", + 1, + json.RawMessage(`{ + "name": "user1" + }`), + ), + ), + eventFromEventPusher( + schemauser.NewEmailUpdatedEvent( + context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + "test@example.com", + ), + ), + eventFromEventPusherWithCreationDateNow( + schemauser.NewEmailCodeAddedEvent( + context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + &crypto.CryptoValue{ + CryptoType: crypto.TypeEncryption, + Algorithm: "enc", + KeyID: "id", + Crypted: []byte("emailverify"), + }, + time.Hour*1, + "https://example.com/email/verify?userID={{.UserID}}&code={{.Code}}&orgID={{.OrgID}}", + false, + ), + ), + ), + expectPush( + eventFromEventPusher( + schemauser.NewEmailCodeAddedEvent( + context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + &crypto.CryptoValue{ + CryptoType: crypto.TypeEncryption, + Algorithm: "enc", + KeyID: "id", + Crypted: []byte("emailverify2"), + }, + time.Hour*1, + "https://example.com/email/verify?userID={{.UserID}}&code={{.Code}}&orgID={{.OrgID}}", + false, + ), + ), + ), + ), + checkPermission: newMockPermissionCheckAllowed(), + newCode: mockEncryptedCode("emailverify2", time.Hour), + }, + args{ + ctx: authz.NewMockContext("instanceID", "", ""), + user: &ResendSchemaUserEmailCode{ + ID: "user1", + URLTemplate: "https://example.com/email/verify?userID={{.UserID}}&code={{.Code}}&orgID={{.OrgID}}", + }, + }, + res{ + details: &domain.ObjectDetails{ + ResourceOwner: "org1", + }, + }, + }, + { + "email code resend, return, ok", + fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + schemauser.NewCreatedEvent( + context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + "id1", + 1, + json.RawMessage(`{ + "name": "user1" + }`), + ), + ), + eventFromEventPusher( + schemauser.NewEmailUpdatedEvent( + context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + "test@example.com", + ), + ), + eventFromEventPusherWithCreationDateNow( + schemauser.NewEmailCodeAddedEvent( + context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + &crypto.CryptoValue{ + CryptoType: crypto.TypeEncryption, + Algorithm: "enc", + KeyID: "id", + Crypted: []byte("emailverify"), + }, + time.Hour*1, + "https://example.com/email/verify?userID={{.UserID}}&code={{.Code}}&orgID={{.OrgID}}", + false, + ), + ), + ), + expectPush( + eventFromEventPusher( + schemauser.NewEmailCodeAddedEvent( + context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + &crypto.CryptoValue{ + CryptoType: crypto.TypeEncryption, + Algorithm: "enc", + KeyID: "id", + Crypted: []byte("emailverify2"), + }, + time.Hour*1, + "https://example.com/email/verify?userID={{.UserID}}&code={{.Code}}&orgID={{.OrgID}}", + true, + ), + ), + ), + ), + checkPermission: newMockPermissionCheckAllowed(), + newCode: mockEncryptedCode("emailverify2", time.Hour), + }, + args{ + ctx: authz.NewMockContext("instanceID", "", ""), + user: &ResendSchemaUserEmailCode{ + ID: "user1", + URLTemplate: "https://example.com/email/verify?userID={{.UserID}}&code={{.Code}}&orgID={{.OrgID}}", + ReturnCode: true, + }, + }, + res{ + details: &domain.ObjectDetails{ + ResourceOwner: "org1", + }, + returnCode: "emailverify2", + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + c := &Commands{ + eventstore: tt.fields.eventstore(t), + checkPermission: tt.fields.checkPermission, + newEncryptedCode: tt.fields.newCode, + userEncryption: crypto.CreateMockEncryptionAlg(gomock.NewController(t)), + } + details, err := c.ResendSchemaUserEmailCode(tt.args.ctx, tt.args.user) + 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) + return + } + if tt.res.err == nil { + assertObjectDetails(t, tt.res.details, details) + if tt.res.returnCode != "" { + assert.NotNil(t, tt.args.user.PlainCode) + assert.Equal(t, tt.res.returnCode, *tt.args.user.PlainCode) + } + } + }) + } +} diff --git a/internal/command/user_v3_model.go b/internal/command/user_v3_model.go index 62315581780..574ed6cd63b 100644 --- a/internal/command/user_v3_model.go +++ b/internal/command/user_v3_model.go @@ -4,10 +4,15 @@ import ( "bytes" "context" "encoding/json" + "time" + "github.com/zitadel/zitadel/internal/api/authz" + "github.com/zitadel/zitadel/internal/crypto" "github.com/zitadel/zitadel/internal/domain" + domain_schema "github.com/zitadel/zitadel/internal/domain/schema" "github.com/zitadel/zitadel/internal/eventstore" "github.com/zitadel/zitadel/internal/repository/user/schemauser" + "github.com/zitadel/zitadel/internal/zerrors" ) type UserV3WriteModel struct { @@ -23,37 +28,77 @@ type UserV3WriteModel struct { Email string IsEmailVerified bool EmailVerifiedFailedCount int + EmailCode *VerifyCode + Phone string IsPhoneVerified bool PhoneVerifiedFailedCount int + PhoneCode *VerifyCode Data json.RawMessage Locked bool State domain.UserState + + checkPermission domain.PermissionCheck + writePermissionCheck bool } -func NewExistsUserV3WriteModel(resourceOwner, userID string) *UserV3WriteModel { +func (wm *UserV3WriteModel) GetWriteModel() *eventstore.WriteModel { + return &wm.WriteModel +} + +type VerifyCode struct { + Code *crypto.CryptoValue + CreationDate time.Time + Expiry time.Duration +} + +func NewExistsUserV3WriteModel(resourceOwner, userID string, checkPermission domain.PermissionCheck) *UserV3WriteModel { return &UserV3WriteModel{ WriteModel: eventstore.WriteModel{ AggregateID: userID, ResourceOwner: resourceOwner, }, - PhoneWM: false, - EmailWM: false, - DataWM: false, + PhoneWM: false, + EmailWM: false, + DataWM: false, + checkPermission: checkPermission, } } -func NewUserV3WriteModel(resourceOwner, userID string) *UserV3WriteModel { +func NewUserV3WriteModel(resourceOwner, userID string, checkPermission domain.PermissionCheck) *UserV3WriteModel { return &UserV3WriteModel{ WriteModel: eventstore.WriteModel{ AggregateID: userID, ResourceOwner: resourceOwner, }, - PhoneWM: true, - EmailWM: true, - DataWM: true, + PhoneWM: true, + EmailWM: true, + DataWM: true, + checkPermission: checkPermission, + } +} + +func NewUserV3EmailWriteModel(resourceOwner, userID string, checkPermission domain.PermissionCheck) *UserV3WriteModel { + return &UserV3WriteModel{ + WriteModel: eventstore.WriteModel{ + AggregateID: userID, + ResourceOwner: resourceOwner, + }, + EmailWM: true, + checkPermission: checkPermission, + } +} + +func NewUserV3PhoneWriteModel(resourceOwner, userID string, checkPermission domain.PermissionCheck) *UserV3WriteModel { + return &UserV3WriteModel{ + WriteModel: eventstore.WriteModel{ + AggregateID: userID, + ResourceOwner: resourceOwner, + }, + PhoneWM: true, + checkPermission: checkPermission, } } @@ -83,24 +128,38 @@ func (wm *UserV3WriteModel) Reduce() error { wm.Email = string(e.EmailAddress) wm.IsEmailVerified = false wm.EmailVerifiedFailedCount = 0 + wm.EmailCode = nil case *schemauser.EmailCodeAddedEvent: wm.IsEmailVerified = false wm.EmailVerifiedFailedCount = 0 + wm.EmailCode = &VerifyCode{ + Code: e.Code, + CreationDate: e.CreationDate(), + Expiry: e.Expiry, + } case *schemauser.EmailVerifiedEvent: wm.IsEmailVerified = true wm.EmailVerifiedFailedCount = 0 + wm.EmailCode = nil case *schemauser.EmailVerificationFailedEvent: wm.EmailVerifiedFailedCount += 1 case *schemauser.PhoneUpdatedEvent: wm.Phone = string(e.PhoneNumber) wm.IsPhoneVerified = false wm.PhoneVerifiedFailedCount = 0 + wm.EmailCode = nil case *schemauser.PhoneCodeAddedEvent: wm.IsPhoneVerified = false wm.PhoneVerifiedFailedCount = 0 + wm.PhoneCode = &VerifyCode{ + Code: e.Code, + CreationDate: e.CreationDate(), + Expiry: e.Expiry, + } case *schemauser.PhoneVerifiedEvent: wm.PhoneVerifiedFailedCount = 0 wm.IsPhoneVerified = true + wm.PhoneCode = nil case *schemauser.PhoneVerificationFailedEvent: wm.PhoneVerifiedFailedCount += 1 case *schemauser.LockedEvent: @@ -156,13 +215,159 @@ func (wm *UserV3WriteModel) Query() *eventstore.SearchQueryBuilder { EventTypes(eventtypes...).Builder() } -func (wm *UserV3WriteModel) NewUpdatedEvent( +func (wm *UserV3WriteModel) NewCreated( ctx context.Context, - agg *eventstore.Aggregate, schemaID string, schemaRevision uint64, data json.RawMessage, -) *schemauser.UpdatedEvent { + email *Email, + phone *Phone, + code func(context.Context) (*EncryptedCode, error), +) (_ []eventstore.Command, codeEmail string, codePhone string, err error) { + if err := wm.checkPermissionWrite(ctx, wm.ResourceOwner, wm.AggregateID); err != nil { + return nil, "", "", err + } + if wm.Exists() { + return nil, "", "", zerrors.ThrowPreconditionFailed(nil, "COMMAND-Nn8CRVlkeZ", "Errors.User.AlreadyExists") + } + events := []eventstore.Command{ + schemauser.NewCreatedEvent(ctx, + UserV3AggregateFromWriteModel(&wm.WriteModel), + schemaID, schemaRevision, data, + ), + } + if email != nil { + emailEvents, plainCodeEmail, err := wm.NewEmailCreate(ctx, + email, + code, + ) + if err != nil { + return nil, "", "", err + } + if plainCodeEmail != "" { + codeEmail = plainCodeEmail + } + events = append(events, emailEvents...) + } + + if phone != nil { + phoneEvents, plainCodePhone, err := wm.NewPhoneCreate(ctx, + phone, + code, + ) + if err != nil { + return nil, "", "", err + } + if plainCodePhone != "" { + codePhone = plainCodePhone + } + events = append(events, phoneEvents...) + } + + return events, codeEmail, codePhone, nil +} + +func (wm *UserV3WriteModel) getSchemaRoleForWrite(ctx context.Context, resourceOwner, userID string) (domain_schema.Role, error) { + if userID == authz.GetCtxData(ctx).UserID { + return domain_schema.RoleSelf, nil + } + if err := wm.checkPermission(ctx, domain.PermissionUserWrite, resourceOwner, userID); err != nil { + return domain_schema.RoleUnspecified, err + } + return domain_schema.RoleOwner, nil +} + +func (wm *UserV3WriteModel) validateData(ctx context.Context, data []byte, schemaWM *UserSchemaWriteModel) (string, uint64, error) { + // get role for permission check in schema through extension + role, err := wm.getSchemaRoleForWrite(ctx, wm.ResourceOwner, wm.AggregateID) + if err != nil { + return "", 0, err + } + + schema, err := domain_schema.NewSchema(role, bytes.NewReader(schemaWM.Schema)) + if err != nil { + return "", 0, err + } + + // if data not changed but a new schema or revision should be used + if data == nil { + data = wm.Data + } + var v interface{} + if err := json.Unmarshal(data, &v); err != nil { + return "", 0, zerrors.ThrowInvalidArgument(nil, "COMMAND-7o3ZGxtXUz", "Errors.User.Invalid") + } + + if err := schema.Validate(v); err != nil { + return "", 0, zerrors.ThrowPreconditionFailed(nil, "COMMAND-SlKXqLSeL6", "Errors.UserSchema.Data.Invalid") + } + return schemaWM.AggregateID, schemaWM.SchemaRevision, nil +} + +func (wm *UserV3WriteModel) NewUpdate( + ctx context.Context, + schemaWM *UserSchemaWriteModel, + user *SchemaUser, + email *Email, + phone *Phone, + code func(context.Context) (*EncryptedCode, error), +) (_ []eventstore.Command, codeEmail string, codePhone string, err error) { + if err := wm.checkPermissionWrite(ctx, wm.ResourceOwner, wm.AggregateID); err != nil { + return nil, "", "", err + } + if !wm.Exists() { + return nil, "", "", zerrors.ThrowPreconditionFailed(nil, "COMMAND-Nn8CRVlkeZ", "Errors.User.NotFound") + } + events := make([]eventstore.Command, 0) + if user != nil { + schemaID, schemaRevision, err := wm.validateData(ctx, user.Data, schemaWM) + if err != nil { + return nil, "", "", err + } + userEvents := wm.newUpdatedEvents(ctx, + schemaID, + schemaRevision, + user.Data, + ) + events = append(events, userEvents...) + } + if email != nil { + emailEvents, plainCodeEmail, err := wm.NewEmailUpdate(ctx, + email, + code, + ) + if err != nil { + return nil, "", "", err + } + if plainCodeEmail != "" { + codeEmail = plainCodeEmail + } + events = append(events, emailEvents...) + } + + if phone != nil { + phoneEvents, plainCodePhone, err := wm.NewPhoneCreate(ctx, + phone, + code, + ) + if err != nil { + return nil, "", "", err + } + if plainCodePhone != "" { + codePhone = plainCodePhone + } + events = append(events, phoneEvents...) + } + + return events, codeEmail, codePhone, nil +} + +func (wm *UserV3WriteModel) newUpdatedEvents( + ctx context.Context, + schemaID string, + schemaRevision uint64, + data json.RawMessage, +) []eventstore.Command { changes := make([]schemauser.Changes, 0) if wm.SchemaID != schemaID { changes = append(changes, schemauser.ChangeSchemaID(schemaID)) @@ -176,7 +381,20 @@ func (wm *UserV3WriteModel) NewUpdatedEvent( if len(changes) == 0 { return nil } - return schemauser.NewUpdatedEvent(ctx, agg, changes) + return []eventstore.Command{schemauser.NewUpdatedEvent(ctx, UserV3AggregateFromWriteModel(&wm.WriteModel), changes)} +} + +func (wm *UserV3WriteModel) NewDelete( + ctx context.Context, +) (_ []eventstore.Command, err error) { + if !wm.Exists() { + return nil, zerrors.ThrowNotFound(nil, "COMMAND-syHyCsGmvM", "Errors.User.NotFound") + } + if err := wm.checkPermissionDelete(ctx, wm.ResourceOwner, wm.AggregateID); err != nil { + return nil, err + } + return []eventstore.Command{schemauser.NewDeletedEvent(ctx, UserV3AggregateFromWriteModel(&wm.WriteModel))}, nil + } func UserV3AggregateFromWriteModel(wm *eventstore.WriteModel) *eventstore.Aggregate { @@ -192,3 +410,271 @@ func UserV3AggregateFromWriteModel(wm *eventstore.WriteModel) *eventstore.Aggreg func (wm *UserV3WriteModel) Exists() bool { return wm.State != domain.UserStateDeleted && wm.State != domain.UserStateUnspecified } + +func (wm *UserV3WriteModel) checkPermissionWrite( + ctx context.Context, + resourceOwner string, + userID string, +) error { + if wm.writePermissionCheck { + return nil + } + if userID != "" && userID == authz.GetCtxData(ctx).UserID { + return nil + } + if err := wm.checkPermission(ctx, domain.PermissionUserWrite, resourceOwner, userID); err != nil { + return err + } + wm.writePermissionCheck = true + return nil +} + +func (wm *UserV3WriteModel) checkPermissionDelete( + ctx context.Context, + resourceOwner string, + userID string, +) error { + if userID != "" && userID == authz.GetCtxData(ctx).UserID { + return nil + } + return wm.checkPermission(ctx, domain.PermissionUserDelete, resourceOwner, userID) +} + +func (wm *UserV3WriteModel) NewEmailCreate( + ctx context.Context, + email *Email, + code func(context.Context) (*EncryptedCode, error), +) (_ []eventstore.Command, plainCode string, err error) { + if err := wm.checkPermissionWrite(ctx, wm.ResourceOwner, wm.AggregateID); err != nil { + return nil, "", err + } + if email == nil || wm.Email == string(email.Address) { + return nil, "", nil + } + events := []eventstore.Command{ + schemauser.NewEmailUpdatedEvent(ctx, + UserV3AggregateFromWriteModel(&wm.WriteModel), + email.Address, + ), + } + if email.Verified { + events = append(events, wm.newEmailVerifiedEvent(ctx)) + } else { + codeEvent, code, err := wm.newEmailCodeAddedEvent(ctx, code, email.URLTemplate, email.ReturnCode) + if err != nil { + return nil, "", err + } + events = append(events, codeEvent) + if code != "" { + plainCode = code + } + } + return events, plainCode, nil +} + +func (wm *UserV3WriteModel) NewEmailUpdate( + ctx context.Context, + email *Email, + code func(context.Context) (*EncryptedCode, error), +) (_ []eventstore.Command, plainCode string, err error) { + if !wm.EmailWM { + return nil, "", nil + } + if !wm.Exists() { + return nil, "", zerrors.ThrowNotFound(nil, "COMMAND-nJ0TQFuRmP", "Errors.User.NotFound") + } + return wm.NewEmailCreate(ctx, email, code) +} + +func (wm *UserV3WriteModel) NewEmailVerify( + ctx context.Context, + verify func(creationDate time.Time, expiry time.Duration, cryptoCode *crypto.CryptoValue) error, +) ([]eventstore.Command, error) { + if !wm.EmailWM { + return nil, nil + } + if !wm.Exists() { + return nil, zerrors.ThrowNotFound(nil, "COMMAND-qbGyMPvjvj", "Errors.User.NotFound") + } + if err := wm.checkPermissionWrite(ctx, wm.ResourceOwner, wm.AggregateID); err != nil { + return nil, err + } + if wm.EmailCode == nil { + return nil, nil + } + if err := verify(wm.EmailCode.CreationDate, wm.EmailCode.Expiry, wm.EmailCode.Code); err != nil { + return nil, err + } + return []eventstore.Command{wm.newEmailVerifiedEvent(ctx)}, nil +} + +func (wm *UserV3WriteModel) newEmailVerifiedEvent( + ctx context.Context, +) *schemauser.EmailVerifiedEvent { + return schemauser.NewEmailVerifiedEvent(ctx, UserV3AggregateFromWriteModel(&wm.WriteModel)) +} + +func (wm *UserV3WriteModel) NewResendEmailCode( + ctx context.Context, + code func(context.Context) (*EncryptedCode, error), + urlTemplate string, + isReturnCode bool, +) (_ []eventstore.Command, plainCode string, err error) { + if !wm.EmailWM { + return nil, "", nil + } + if !wm.Exists() { + return nil, "", zerrors.ThrowNotFound(nil, "COMMAND-EajeF6ypOV", "Errors.User.NotFound") + } + if err := wm.checkPermissionWrite(ctx, wm.ResourceOwner, wm.AggregateID); err != nil { + return nil, "", err + } + if wm.EmailCode == nil { + return nil, "", zerrors.ThrowPreconditionFailed(err, "COMMAND-QRkNTBwF8q", "Errors.User.Code.Empty") + } + event, plainCode, err := wm.newEmailCodeAddedEvent(ctx, code, urlTemplate, isReturnCode) + if err != nil { + return nil, "", err + } + return []eventstore.Command{event}, plainCode, nil +} + +func (wm *UserV3WriteModel) newEmailCodeAddedEvent( + ctx context.Context, + code func(context.Context) (*EncryptedCode, error), + urlTemplate string, + isReturnCode bool, +) (_ *schemauser.EmailCodeAddedEvent, plainCode string, err error) { + cryptoCode, err := code(ctx) + if err != nil { + return nil, "", err + } + if isReturnCode { + plainCode = cryptoCode.Plain + } + return schemauser.NewEmailCodeAddedEvent(ctx, + UserV3AggregateFromWriteModel(&wm.WriteModel), + cryptoCode.Crypted, + cryptoCode.Expiry, + urlTemplate, + isReturnCode, + ), plainCode, nil +} + +func (wm *UserV3WriteModel) NewPhoneCreate( + ctx context.Context, + phone *Phone, + code func(context.Context) (*EncryptedCode, error), +) (_ []eventstore.Command, plainCode string, err error) { + if err := wm.checkPermissionWrite(ctx, wm.ResourceOwner, wm.AggregateID); err != nil { + return nil, "", err + } + if phone == nil || wm.Phone == string(phone.Number) { + return nil, "", nil + } + events := []eventstore.Command{ + schemauser.NewPhoneUpdatedEvent(ctx, + UserV3AggregateFromWriteModel(&wm.WriteModel), + phone.Number, + ), + } + if phone.Verified { + events = append(events, wm.newPhoneVerifiedEvent(ctx)) + } else { + codeEvent, code, err := wm.newPhoneCodeAddedEvent(ctx, code, phone.ReturnCode) + if err != nil { + return nil, "", err + } + events = append(events, codeEvent) + if code != "" { + plainCode = code + } + } + return events, plainCode, nil +} + +func (wm *UserV3WriteModel) NewPhoneUpdate( + ctx context.Context, + phone *Phone, + code func(context.Context) (*EncryptedCode, error), +) (_ []eventstore.Command, plainCode string, err error) { + if !wm.PhoneWM { + return nil, "", nil + } + if !wm.Exists() { + return nil, "", zerrors.ThrowNotFound(nil, "COMMAND-b33QAVgel6", "Errors.User.NotFound") + } + return wm.NewPhoneCreate(ctx, phone, code) +} + +func (wm *UserV3WriteModel) NewPhoneVerify( + ctx context.Context, + verify func(creationDate time.Time, expiry time.Duration, cryptoCode *crypto.CryptoValue) error, +) ([]eventstore.Command, error) { + if !wm.PhoneWM { + return nil, nil + } + if !wm.Exists() { + return nil, zerrors.ThrowNotFound(nil, "COMMAND-bx2OLtgGNS", "Errors.User.NotFound") + } + if err := wm.checkPermissionWrite(ctx, wm.ResourceOwner, wm.AggregateID); err != nil { + return nil, err + } + if wm.PhoneCode == nil { + return nil, nil + } + if err := verify(wm.PhoneCode.CreationDate, wm.PhoneCode.Expiry, wm.PhoneCode.Code); err != nil { + return nil, err + } + return []eventstore.Command{wm.newPhoneVerifiedEvent(ctx)}, nil +} + +func (wm *UserV3WriteModel) newPhoneVerifiedEvent( + ctx context.Context, +) *schemauser.PhoneVerifiedEvent { + return schemauser.NewPhoneVerifiedEvent(ctx, UserV3AggregateFromWriteModel(&wm.WriteModel)) +} + +func (wm *UserV3WriteModel) NewResendPhoneCode( + ctx context.Context, + code func(context.Context) (*EncryptedCode, error), + isReturnCode bool, +) (_ []eventstore.Command, plainCode string, err error) { + if !wm.PhoneWM { + return nil, "", nil + } + if !wm.Exists() { + return nil, "", zerrors.ThrowNotFound(nil, "COMMAND-z8Bu9vuL9s", "Errors.User.NotFound") + } + if err := wm.checkPermissionWrite(ctx, wm.ResourceOwner, wm.AggregateID); err != nil { + return nil, "", err + } + if wm.PhoneCode == nil { + return nil, "", zerrors.ThrowPreconditionFailed(err, "COMMAND-fEsHdqECzb", "Errors.User.Code.Empty") + } + event, plainCode, err := wm.newPhoneCodeAddedEvent(ctx, code, isReturnCode) + if err != nil { + return nil, "", err + } + return []eventstore.Command{event}, plainCode, nil +} + +func (wm *UserV3WriteModel) newPhoneCodeAddedEvent( + ctx context.Context, + code func(context.Context) (*EncryptedCode, error), + isReturnCode bool, +) (_ *schemauser.PhoneCodeAddedEvent, plainCode string, err error) { + cryptoCode, err := code(ctx) + if err != nil { + return nil, "", err + } + if isReturnCode { + plainCode = cryptoCode.Plain + } + return schemauser.NewPhoneCodeAddedEvent(ctx, + UserV3AggregateFromWriteModel(&wm.WriteModel), + cryptoCode.Crypted, + cryptoCode.Expiry, + isReturnCode, + ), plainCode, nil +} diff --git a/internal/command/user_v3_phone.go b/internal/command/user_v3_phone.go new file mode 100644 index 00000000000..65ca36a0eec --- /dev/null +++ b/internal/command/user_v3_phone.go @@ -0,0 +1,107 @@ +package command + +import ( + "context" + "time" + + "github.com/zitadel/zitadel/internal/crypto" + "github.com/zitadel/zitadel/internal/domain" + "github.com/zitadel/zitadel/internal/zerrors" +) + +type ChangeSchemaUserPhone struct { + ResourceOwner string + ID string + + Phone *Phone + ReturnCode *string +} + +func (s *ChangeSchemaUserPhone) Valid() (err error) { + if s.ID == "" { + return zerrors.ThrowInvalidArgument(nil, "COMMAND-DkQ9aurv5u", "Errors.IDMissing") + } + if s.Phone != nil && s.Phone.Number != "" { + if s.Phone.Number, err = s.Phone.Number.Normalize(); err != nil { + return err + } + } + return nil +} + +func (c *Commands) ChangeSchemaUserPhone(ctx context.Context, user *ChangeSchemaUserPhone) (_ *domain.ObjectDetails, err error) { + if err := user.Valid(); err != nil { + return nil, err + } + + writeModel, err := c.getSchemaUserPhoneWriteModelByID(ctx, user.ResourceOwner, user.ID) + if err != nil { + return nil, err + } + + events, plainCode, err := writeModel.NewPhoneUpdate(ctx, + user.Phone, + func(ctx context.Context) (*EncryptedCode, error) { + return c.newPhoneCode(ctx, c.eventstore.Filter, c.userEncryption) //nolint:staticcheck + }, + ) + if err != nil { + return nil, err + } + if plainCode != "" { + user.ReturnCode = &plainCode + } + return c.pushAppendAndReduceDetails(ctx, writeModel, events...) +} + +func (c *Commands) VerifySchemaUserPhone(ctx context.Context, resourceOwner, id, code string) (*domain.ObjectDetails, error) { + if id == "" { + return nil, zerrors.ThrowInvalidArgument(nil, "COMMAND-R4LKY44Ke3", "Errors.IDMissing") + } + writeModel, err := c.getSchemaUserPhoneWriteModelByID(ctx, resourceOwner, id) + if err != nil { + return nil, err + } + + events, err := writeModel.NewPhoneVerify(ctx, + func(creationDate time.Time, expiry time.Duration, cryptoCode *crypto.CryptoValue) error { + return crypto.VerifyCode(creationDate, expiry, cryptoCode, code, c.userEncryption) + }, + ) + if err != nil { + return nil, err + } + return c.pushAppendAndReduceDetails(ctx, writeModel, events...) +} + +type ResendSchemaUserPhoneCode struct { + ResourceOwner string + ID string + + ReturnCode bool + PlainCode *string +} + +func (c *Commands) ResendSchemaUserPhoneCode(ctx context.Context, user *ResendSchemaUserPhoneCode) (*domain.ObjectDetails, error) { + if user.ID == "" { + return nil, zerrors.ThrowInvalidArgument(nil, "COMMAND-zmxIFR2nMo", "Errors.IDMissing") + } + writeModel, err := c.getSchemaUserPhoneWriteModelByID(ctx, user.ResourceOwner, user.ID) + if err != nil { + return nil, err + } + + events, plainCode, err := writeModel.NewResendPhoneCode(ctx, + func(ctx context.Context) (*EncryptedCode, error) { + return c.newPhoneCode(ctx, c.eventstore.Filter, c.userEncryption) //nolint:staticcheck + }, + user.ReturnCode, + ) + if err != nil { + return nil, err + } + if plainCode != "" { + user.PlainCode = &plainCode + } + return c.pushAppendAndReduceDetails(ctx, writeModel, events...) +} diff --git a/internal/command/user_v3_phone_test.go b/internal/command/user_v3_phone_test.go new file mode 100644 index 00000000000..8a5a1ae0b0c --- /dev/null +++ b/internal/command/user_v3_phone_test.go @@ -0,0 +1,1040 @@ +package command + +import ( + "context" + "encoding/json" + "errors" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "go.uber.org/mock/gomock" + + "github.com/zitadel/zitadel/internal/api/authz" + "github.com/zitadel/zitadel/internal/crypto" + "github.com/zitadel/zitadel/internal/domain" + "github.com/zitadel/zitadel/internal/eventstore" + "github.com/zitadel/zitadel/internal/repository/user/schema" + "github.com/zitadel/zitadel/internal/repository/user/schemauser" + "github.com/zitadel/zitadel/internal/zerrors" +) + +func TestCommands_ChangeSchemaUserPhone(t *testing.T) { + type fields struct { + eventstore func(t *testing.T) *eventstore.Eventstore + checkPermission domain.PermissionCheck + newCode encrypedCodeFunc + } + type args struct { + ctx context.Context + user *ChangeSchemaUserPhone + } + type res struct { + returnCode string + details *domain.ObjectDetails + err func(error) bool + } + tests := []struct { + name string + fields fields + args args + res res + }{ + { + "no userID, error", + fields{ + eventstore: expectEventstore(), + checkPermission: newMockPermissionCheckAllowed(), + }, + args{ + ctx: authz.NewMockContext("instanceID", "", ""), + user: &ChangeSchemaUserPhone{}, + }, + res{ + err: func(err error) bool { + return errors.Is(err, zerrors.ThrowInvalidArgument(nil, "COMMAND-DkQ9aurv5u", "Errors.IDMissing")) + }, + }, + }, + { + "no valid phone, error", + fields{ + eventstore: expectEventstore(), + checkPermission: newMockPermissionCheckAllowed(), + }, + args{ + ctx: authz.NewMockContext("instanceID", "", ""), + user: &ChangeSchemaUserPhone{ + ID: "user1", + Phone: &Phone{Number: "nonumber"}, + }, + }, + res{ + err: func(err error) bool { + return errors.Is(err, zerrors.ThrowInvalidArgument(nil, "PHONE-so0wa", "Errors.User.Phone.Invalid")) + }, + }, + }, { + "phone update, user not found", + fields{ + eventstore: expectEventstore( + expectFilter(), + ), + checkPermission: newMockPermissionCheckNotAllowed(), + }, + args{ + ctx: authz.NewMockContext("instanceID", "", ""), + user: &ChangeSchemaUserPhone{ + ID: "user1", + }, + }, + res{ + err: func(err error) bool { + return errors.Is(err, zerrors.ThrowNotFound(nil, "COMMAND-b33QAVgel6", "Errors.User.NotFound")) + }, + }, + }, + { + "phone update, no permission", + fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + schemauser.NewCreatedEvent( + context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + "id1", + 1, + json.RawMessage(`{ + "name": "user1" + }`), + ), + ), + ), + ), + checkPermission: newMockPermissionCheckNotAllowed(), + }, + args{ + ctx: authz.NewMockContext("instanceID", "", ""), + user: &ChangeSchemaUserPhone{ + ID: "user1", + Phone: &Phone{Number: "+41791234567"}, + }, + }, + res{ + err: func(err error) bool { + return errors.Is(err, zerrors.ThrowPermissionDenied(nil, "AUTHZ-HKJD33", "Errors.PermissionDenied")) + }, + }, + }, + { + "phone update, phone not changed", + fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + schemauser.NewCreatedEvent( + context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + "id1", + 1, + json.RawMessage(`{ + "name": "user1" + }`), + ), + ), + eventFromEventPusher( + schemauser.NewPhoneUpdatedEvent( + context.Background(), + &schema.NewAggregate("id1", "instanceID").Aggregate, + "+41791234567", + ), + ), + ), + ), + checkPermission: newMockPermissionCheckAllowed(), + }, + args{ + ctx: authz.NewMockContext("instanceID", "", ""), + user: &ChangeSchemaUserPhone{ + ID: "user1", + Phone: &Phone{ + Number: "+41791234567", + ReturnCode: true, + }, + }, + }, + res{ + details: &domain.ObjectDetails{ + ResourceOwner: "org1", + }, + }, + }, + { + "phone update, phone return", + fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + schemauser.NewCreatedEvent( + context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + "id1", + 1, + json.RawMessage(`{ + "name": "user1" + }`), + ), + ), + ), + expectPush( + schemauser.NewPhoneUpdatedEvent(context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + "+41791234567", + ), + schemauser.NewPhoneCodeAddedEvent(context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + &crypto.CryptoValue{ + CryptoType: crypto.TypeEncryption, + Algorithm: "enc", + KeyID: "id", + Crypted: []byte("phoneverify"), + }, + time.Hour*1, + true, + ), + ), + ), + checkPermission: newMockPermissionCheckAllowed(), + newCode: mockEncryptedCode("phoneverify", time.Hour), + }, + args{ + ctx: authz.NewMockContext("instanceID", "", ""), + user: &ChangeSchemaUserPhone{ + ID: "user1", + Phone: &Phone{ + Number: "+41791234567", + ReturnCode: true, + }, + }, + }, + res{ + details: &domain.ObjectDetails{ + ResourceOwner: "org1", + }, + returnCode: "phoneverify", + }, + }, + { + "user updated, phone to verify", + fields{ + eventstore: expectEventstore( + expectFilter( + schemauser.NewCreatedEvent( + context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + "type", + 1, + json.RawMessage(`{ + "name": "user" + }`), + )), + expectPush( + schemauser.NewPhoneUpdatedEvent(context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + "+41791234567", + ), + schemauser.NewPhoneCodeAddedEvent(context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + &crypto.CryptoValue{ + CryptoType: crypto.TypeEncryption, + Algorithm: "enc", + KeyID: "id", + Crypted: []byte("phoneverify"), + }, time.Hour*1, + false, + ), + ), + ), + checkPermission: newMockPermissionCheckAllowed(), + newCode: mockEncryptedCode("phoneverify", time.Hour), + }, + args{ + ctx: authz.NewMockContext("instanceID", "", ""), + user: &ChangeSchemaUserPhone{ + ID: "user1", + Phone: &Phone{ + Number: "+41791234567", + }, + }, + }, + res{ + details: &domain.ObjectDetails{ + ResourceOwner: "org1", + }, + }, + }, + { + "user updated, verified", + fields{ + eventstore: expectEventstore( + expectFilter( + schemauser.NewCreatedEvent( + context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + "type", + 1, + json.RawMessage(`{ + "name": "user" + }`), + ), + ), + expectPush( + schemauser.NewPhoneUpdatedEvent(context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + "+41791234567", + ), + schemauser.NewPhoneVerifiedEvent(context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + ), + ), + ), + checkPermission: newMockPermissionCheckAllowed(), + }, + args{ + ctx: authz.NewMockContext("instanceID", "", ""), + user: &ChangeSchemaUserPhone{ + ID: "user1", + Phone: &Phone{ + Number: "+41791234567", + Verified: true, + }, + }, + }, + res{ + details: &domain.ObjectDetails{ + ResourceOwner: "org1", + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + c := &Commands{ + eventstore: tt.fields.eventstore(t), + checkPermission: tt.fields.checkPermission, + newEncryptedCode: tt.fields.newCode, + userEncryption: crypto.CreateMockEncryptionAlg(gomock.NewController(t)), + } + details, err := c.ChangeSchemaUserPhone(tt.args.ctx, tt.args.user) + 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 { + assertObjectDetails(t, tt.res.details, details) + } + + if tt.res.returnCode != "" { + assert.NotNil(t, tt.args.user.ReturnCode) + assert.Equal(t, tt.res.returnCode, *tt.args.user.ReturnCode) + } + }) + } +} + +func TestCommands_VerifySchemaUserPhone(t *testing.T) { + type fields struct { + eventstore func(t *testing.T) *eventstore.Eventstore + checkPermission domain.PermissionCheck + } + type args struct { + ctx context.Context + resourceOwner string + id string + code string + } + type res struct { + details *domain.ObjectDetails + err func(error) bool + } + tests := []struct { + name string + fields fields + args args + res res + }{ + { + "no userID, error", + fields{ + eventstore: expectEventstore(), + }, + args{ + ctx: authz.NewMockContext("instanceID", "", ""), + id: "", + }, + res{ + err: func(err error) bool { + return errors.Is(err, zerrors.ThrowInvalidArgument(nil, "COMMAND-R4LKY44Ke3", "Errors.IDMissing")) + }, + }, + }, + { + "phone verify, user not found", + fields{ + eventstore: expectEventstore( + expectFilter(), + ), + }, + args{ + ctx: authz.NewMockContext("instanceID", "", ""), + id: "user1", + }, + res{ + err: func(err error) bool { + return errors.Is(err, zerrors.ThrowNotFound(nil, "COMMAND-bx2OLtgGNS", "Errors.User.NotFound")) + }, + }, + }, + { + "phone verify, no code", + fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + schemauser.NewCreatedEvent( + context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + "id1", + 1, + json.RawMessage(`{ + "name": "user1" + }`), + ), + ), + ), + ), + checkPermission: newMockPermissionCheckAllowed(), + }, + args{ + ctx: authz.NewMockContext("instanceID", "", ""), + id: "user1", + }, + res{ + details: &domain.ObjectDetails{ + ResourceOwner: "org1", + }, + }, + }, + { + "phone verify, already verified", + fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + schemauser.NewCreatedEvent( + context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + "id1", + 1, + json.RawMessage(`{ + "name": "user1" + }`), + ), + ), + eventFromEventPusher( + schemauser.NewPhoneUpdatedEvent( + context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + "+41791234567", + ), + ), + eventFromEventPusher( + schemauser.NewPhoneCodeAddedEvent( + context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + &crypto.CryptoValue{ + CryptoType: crypto.TypeEncryption, + Algorithm: "enc", + KeyID: "id", + Crypted: []byte("phoneverify"), + }, + time.Hour*1, + false, + ), + ), + eventFromEventPusher( + schemauser.NewPhoneVerifiedEvent( + context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + ), + ), + ), + ), + checkPermission: newMockPermissionCheckAllowed(), + }, + args{ + ctx: authz.NewMockContext("instanceID", "", ""), + id: "user1", + }, + res{ + details: &domain.ObjectDetails{ + ResourceOwner: "org1", + }, + }, + }, + { + "phone update, no permission", + fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + schemauser.NewCreatedEvent( + context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + "id1", + 1, + json.RawMessage(`{ + "name": "user1" + }`), + ), + ), + eventFromEventPusher( + schemauser.NewPhoneUpdatedEvent( + context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + "+41791234567", + ), + ), + eventFromEventPusher( + schemauser.NewPhoneCodeAddedEvent( + context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + &crypto.CryptoValue{ + CryptoType: crypto.TypeEncryption, + Algorithm: "enc", + KeyID: "id", + Crypted: []byte("phoneverify"), + }, + time.Hour*1, + false, + ), + ), + ), + ), + checkPermission: newMockPermissionCheckNotAllowed(), + }, + args{ + ctx: authz.NewMockContext("instanceID", "", ""), + id: "user1", + }, + res{ + err: func(err error) bool { + return errors.Is(err, zerrors.ThrowPermissionDenied(nil, "AUTHZ-HKJD33", "Errors.PermissionDenied")) + }, + }, + }, + { + "phone verify, wrong code", + fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + schemauser.NewCreatedEvent( + context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + "id1", + 1, + json.RawMessage(`{ + "name": "user1" + }`), + ), + ), + eventFromEventPusher( + schemauser.NewPhoneUpdatedEvent( + context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + "+41791234567", + ), + ), + eventFromEventPusherWithCreationDateNow( + schemauser.NewPhoneCodeAddedEvent( + context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + &crypto.CryptoValue{ + CryptoType: crypto.TypeEncryption, + Algorithm: "enc", + KeyID: "id", + Crypted: []byte("phoneverify"), + }, + time.Hour*1, + false, + ), + ), + ), + ), + checkPermission: newMockPermissionCheckAllowed(), + }, + args{ + ctx: authz.NewMockContext("instanceID", "", ""), + id: "user1", + }, + res{ + err: func(err error) bool { + return errors.Is(err, zerrors.ThrowInvalidArgument(nil, "CODE-woT0xc", "Errors.User.Code.Invalid")) + }, + }, + }, + { + "phone verify, ok", + fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + schemauser.NewCreatedEvent( + context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + "id1", + 1, + json.RawMessage(`{ + "name": "user1" + }`), + ), + ), + eventFromEventPusher( + schemauser.NewPhoneUpdatedEvent( + context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + "+41791234567", + ), + ), + eventFromEventPusherWithCreationDateNow( + schemauser.NewPhoneCodeAddedEvent( + context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + &crypto.CryptoValue{ + CryptoType: crypto.TypeEncryption, + Algorithm: "enc", + KeyID: "id", + Crypted: []byte("phoneverify"), + }, + time.Hour*1, + false, + ), + ), + ), + expectPush( + eventFromEventPusher( + schemauser.NewPhoneVerifiedEvent( + context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + ), + ), + ), + ), + checkPermission: newMockPermissionCheckAllowed(), + }, + args{ + ctx: authz.NewMockContext("instanceID", "", ""), + id: "user1", + code: "phoneverify", + }, + res{ + details: &domain.ObjectDetails{ + ResourceOwner: "org1", + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + c := &Commands{ + eventstore: tt.fields.eventstore(t), + checkPermission: tt.fields.checkPermission, + userEncryption: crypto.CreateMockEncryptionAlg(gomock.NewController(t)), + } + details, err := c.VerifySchemaUserPhone(tt.args.ctx, tt.args.resourceOwner, tt.args.id, tt.args.code) + 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 { + assertObjectDetails(t, tt.res.details, details) + } + }) + } +} + +func TestCommands_ResendSchemaUserPhoneCode(t *testing.T) { + type fields struct { + eventstore func(t *testing.T) *eventstore.Eventstore + checkPermission domain.PermissionCheck + newCode encrypedCodeFunc + } + type args struct { + ctx context.Context + user *ResendSchemaUserPhoneCode + } + type res struct { + returnCode string + details *domain.ObjectDetails + err func(error) bool + } + tests := []struct { + name string + fields fields + args args + res res + }{ + { + "no userID, error", + fields{ + eventstore: expectEventstore(), + }, + args{ + ctx: authz.NewMockContext("instanceID", "", ""), + user: &ResendSchemaUserPhoneCode{ + ID: "", + }, + }, + res{ + err: func(err error) bool { + return errors.Is(err, zerrors.ThrowInvalidArgument(nil, "COMMAND-zmxIFR2nMo", "Errors.IDMissing")) + }, + }, + }, + { + "phone code resend, user not found", + fields{ + eventstore: expectEventstore( + expectFilter(), + ), + }, + args{ + ctx: authz.NewMockContext("instanceID", "", ""), + user: &ResendSchemaUserPhoneCode{ + ID: "user1", + }, + }, + res{ + err: func(err error) bool { + return errors.Is(err, zerrors.ThrowNotFound(nil, "COMMAND-z8Bu9vuL9s", "Errors.User.NotFound")) + }, + }, + }, + { + "phone code resend, no code", + fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + schemauser.NewCreatedEvent( + context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + "id1", + 1, + json.RawMessage(`{ + "name": "user1" + }`), + ), + ), + ), + ), + checkPermission: newMockPermissionCheckAllowed(), + }, + args{ + ctx: authz.NewMockContext("instanceID", "", ""), + user: &ResendSchemaUserPhoneCode{ + ID: "user1", + }, + }, + res{ + err: func(err error) bool { + return errors.Is(err, zerrors.ThrowPreconditionFailed(nil, "COMMAND-fEsHdqECzb", "Errors.User.Code.Empty")) + }, + }, + }, + { + "phone code resend, already verified", + fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + schemauser.NewCreatedEvent( + context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + "id1", + 1, + json.RawMessage(`{ + "name": "user1" + }`), + ), + ), + eventFromEventPusher( + schemauser.NewPhoneUpdatedEvent( + context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + "+41791234567", + ), + ), + eventFromEventPusher( + schemauser.NewPhoneCodeAddedEvent( + context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + &crypto.CryptoValue{ + CryptoType: crypto.TypeEncryption, + Algorithm: "enc", + KeyID: "id", + Crypted: []byte("phoneverify"), + }, + time.Hour*1, + false, + ), + ), + eventFromEventPusher( + schemauser.NewPhoneVerifiedEvent( + context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + ), + ), + ), + ), + checkPermission: newMockPermissionCheckAllowed(), + }, + args{ + ctx: authz.NewMockContext("instanceID", "", ""), + user: &ResendSchemaUserPhoneCode{ + ID: "user1", + }, + }, + res{ + err: func(err error) bool { + return errors.Is(err, zerrors.ThrowPreconditionFailed(nil, "COMMAND-fEsHdqECzb", "Errors.User.Code.Empty")) + }, + }, + }, + { + "phone code resend, no permission", + fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + schemauser.NewCreatedEvent( + context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + "id1", + 1, + json.RawMessage(`{ + "name": "user1" + }`), + ), + ), + eventFromEventPusher( + schemauser.NewPhoneUpdatedEvent( + context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + "+41791234567", + ), + ), + eventFromEventPusher( + schemauser.NewPhoneCodeAddedEvent( + context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + &crypto.CryptoValue{ + CryptoType: crypto.TypeEncryption, + Algorithm: "enc", + KeyID: "id", + Crypted: []byte("phoneverify"), + }, + time.Hour*1, + false, + ), + ), + ), + ), + checkPermission: newMockPermissionCheckNotAllowed(), + }, + args{ + ctx: authz.NewMockContext("instanceID", "", ""), + user: &ResendSchemaUserPhoneCode{ + ID: "user1", + }, + }, + res{ + err: func(err error) bool { + return errors.Is(err, zerrors.ThrowPermissionDenied(nil, "AUTHZ-HKJD33", "Errors.PermissionDenied")) + }, + }, + }, + { + "phone code resend, ok", + fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + schemauser.NewCreatedEvent( + context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + "id1", + 1, + json.RawMessage(`{ + "name": "user1" + }`), + ), + ), + eventFromEventPusher( + schemauser.NewPhoneUpdatedEvent( + context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + "+41791234567", + ), + ), + eventFromEventPusherWithCreationDateNow( + schemauser.NewPhoneCodeAddedEvent( + context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + &crypto.CryptoValue{ + CryptoType: crypto.TypeEncryption, + Algorithm: "enc", + KeyID: "id", + Crypted: []byte("phoneverify"), + }, + time.Hour*1, + false, + ), + ), + ), + expectPush( + eventFromEventPusher( + schemauser.NewPhoneCodeAddedEvent( + context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + &crypto.CryptoValue{ + CryptoType: crypto.TypeEncryption, + Algorithm: "enc", + KeyID: "id", + Crypted: []byte("phoneverify2"), + }, + time.Hour*1, + false, + ), + ), + ), + ), + checkPermission: newMockPermissionCheckAllowed(), + newCode: mockEncryptedCode("phoneverify2", time.Hour), + }, + args{ + ctx: authz.NewMockContext("instanceID", "", ""), + user: &ResendSchemaUserPhoneCode{ + ID: "user1", + }, + }, + res{ + details: &domain.ObjectDetails{ + ResourceOwner: "org1", + }, + }, + }, + { + "phone code resend, return, ok", + fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + schemauser.NewCreatedEvent( + context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + "id1", + 1, + json.RawMessage(`{ + "name": "user1" + }`), + ), + ), + eventFromEventPusher( + schemauser.NewPhoneUpdatedEvent( + context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + "+41791234567", + ), + ), + eventFromEventPusherWithCreationDateNow( + schemauser.NewPhoneCodeAddedEvent( + context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + &crypto.CryptoValue{ + CryptoType: crypto.TypeEncryption, + Algorithm: "enc", + KeyID: "id", + Crypted: []byte("phoneverify"), + }, + time.Hour*1, + false, + ), + ), + ), + expectPush( + eventFromEventPusher( + schemauser.NewPhoneCodeAddedEvent( + context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + &crypto.CryptoValue{ + CryptoType: crypto.TypeEncryption, + Algorithm: "enc", + KeyID: "id", + Crypted: []byte("phoneverify2"), + }, + time.Hour*1, + true, + ), + ), + ), + ), + checkPermission: newMockPermissionCheckAllowed(), + newCode: mockEncryptedCode("phoneverify2", time.Hour), + }, + args{ + ctx: authz.NewMockContext("instanceID", "", ""), + user: &ResendSchemaUserPhoneCode{ + ID: "user1", + ReturnCode: true, + }, + }, + res{ + details: &domain.ObjectDetails{ + ResourceOwner: "org1", + }, + returnCode: "phoneverify2", + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + c := &Commands{ + eventstore: tt.fields.eventstore(t), + checkPermission: tt.fields.checkPermission, + newEncryptedCode: tt.fields.newCode, + userEncryption: crypto.CreateMockEncryptionAlg(gomock.NewController(t)), + } + details, err := c.ResendSchemaUserPhoneCode(tt.args.ctx, tt.args.user) + 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 { + assertObjectDetails(t, tt.res.details, details) + if tt.res.returnCode != "" { + assert.NotNil(t, tt.args.user.PlainCode) + assert.Equal(t, tt.res.returnCode, *tt.args.user.PlainCode) + } + } + }) + } +} diff --git a/internal/command/user_v3_test.go b/internal/command/user_v3_test.go index 69794b3c2e0..4825626b156 100644 --- a/internal/command/user_v3_test.go +++ b/internal/command/user_v3_test.go @@ -7,7 +7,6 @@ import ( "testing" "time" - "github.com/muhlemmer/gu" "github.com/stretchr/testify/assert" "go.uber.org/mock/gomock" @@ -875,8 +874,9 @@ func TestCommands_CreateSchemaUser(t *testing.T) { idGenerator: tt.fields.idGenerator, checkPermission: tt.fields.checkPermission, newEncryptedCode: tt.fields.newCode, + userEncryption: crypto.CreateMockEncryptionAlg(gomock.NewController(t)), } - err := c.CreateSchemaUser(tt.args.ctx, tt.args.user, crypto.CreateMockEncryptionAlg(gomock.NewController(t))) + details, err := c.CreateSchemaUser(tt.args.ctx, tt.args.user) if tt.res.err == nil { assert.NoError(t, err) } @@ -884,14 +884,16 @@ func TestCommands_CreateSchemaUser(t *testing.T) { t.Errorf("got wrong err: %v ", err) } if tt.res.err == nil { - assertObjectDetails(t, tt.res.details, tt.args.user.Details) + assertObjectDetails(t, tt.res.details, details) } if tt.res.returnCodePhone != "" { - assert.Equal(t, tt.res.returnCodePhone, tt.args.user.ReturnCodePhone) + assert.NotNil(t, tt.args.user.ReturnCodePhone) + assert.Equal(t, tt.res.returnCodePhone, *tt.args.user.ReturnCodePhone) } if tt.res.returnCodeEmail != "" { - assert.Equal(t, tt.res.returnCodeEmail, tt.args.user.ReturnCodeEmail) + assert.NotNil(t, tt.args.user.ReturnCodeEmail) + assert.Equal(t, tt.res.returnCodeEmail, *tt.args.user.ReturnCodeEmail) } }) } @@ -1988,6 +1990,19 @@ func TestCommands_ChangeSchemaUser(t *testing.T) { "schema not existing, error", fields{ eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + schemauser.NewCreatedEvent( + context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + "id1", + 1, + json.RawMessage(`{ + "name": "user1" + }`), + ), + ), + ), expectFilter(), ), checkPermission: newMockPermissionCheckAllowed(), @@ -1995,8 +2010,10 @@ func TestCommands_ChangeSchemaUser(t *testing.T) { args{ ctx: authz.NewMockContext("instanceID", "", ""), user: &ChangeSchemaUser{ - ID: "user1", - SchemaID: gu.Ptr("type"), + ID: "user1", + SchemaUser: &SchemaUser{ + SchemaID: "type", + }, }, }, res{ @@ -2060,6 +2077,25 @@ func TestCommands_ChangeSchemaUser(t *testing.T) { ), ), ), + expectFilter( + eventFromEventPusher( + schema.NewCreatedEvent( + context.Background(), + &schema.NewAggregate("id1", "instanceID").Aggregate, + "type", + json.RawMessage(`{ + "$schema": "urn:zitadel:schema:v1", + "type": "object", + "properties": { + "name": { + "type": "string" + } + } + }`), + []domain.AuthenticatorType{domain.AuthenticatorTypeUsername}, + ), + ), + ), ), checkPermission: newMockPermissionCheckNotAllowed(), }, @@ -2067,9 +2103,11 @@ func TestCommands_ChangeSchemaUser(t *testing.T) { ctx: authz.NewMockContext("instanceID", "", ""), user: &ChangeSchemaUser{ ID: "user1", - Data: json.RawMessage(`{ + SchemaUser: &SchemaUser{ + Data: json.RawMessage(`{ "name": "user" }`), + }, }, }, res{ @@ -2134,9 +2172,11 @@ func TestCommands_ChangeSchemaUser(t *testing.T) { ctx: authz.NewMockContext("instanceID", "", ""), user: &ChangeSchemaUser{ ID: "user1", - Data: json.RawMessage(`{ + SchemaUser: &SchemaUser{ + Data: json.RawMessage(`{ "name": "user2" }`), + }, }, }, res{ @@ -2149,6 +2189,19 @@ func TestCommands_ChangeSchemaUser(t *testing.T) { "user updated, changed schema", fields{ eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + schemauser.NewCreatedEvent( + context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + "id1", + 1, + json.RawMessage(`{ + "name": "user1" + }`), + ), + ), + ), expectFilter( eventFromEventPusher( schema.NewCreatedEvent( @@ -2168,19 +2221,6 @@ func TestCommands_ChangeSchemaUser(t *testing.T) { ), ), ), - expectFilter( - eventFromEventPusher( - schemauser.NewCreatedEvent( - context.Background(), - &schemauser.NewAggregate("user1", "org1").Aggregate, - "id1", - 1, - json.RawMessage(`{ - "name": "user1" - }`), - ), - ), - ), expectPush( schemauser.NewUpdatedEvent( context.Background(), @@ -2196,8 +2236,10 @@ func TestCommands_ChangeSchemaUser(t *testing.T) { args{ ctx: authz.NewMockContext("instanceID", "", ""), user: &ChangeSchemaUser{ - ID: "user1", - SchemaID: gu.Ptr("id2"), + ID: "user1", + SchemaUser: &SchemaUser{ + SchemaID: "id2", + }, }, }, res{ @@ -2210,6 +2252,19 @@ func TestCommands_ChangeSchemaUser(t *testing.T) { "user updated, new schema", fields{ eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + schemauser.NewCreatedEvent( + context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + "id1", + 1, + json.RawMessage(`{ + "name": "user1" + }`), + ), + ), + ), expectFilter( eventFromEventPusher( schema.NewCreatedEvent( @@ -2229,19 +2284,6 @@ func TestCommands_ChangeSchemaUser(t *testing.T) { ), ), ), - expectFilter( - eventFromEventPusher( - schemauser.NewCreatedEvent( - context.Background(), - &schemauser.NewAggregate("user1", "org1").Aggregate, - "id1", - 1, - json.RawMessage(`{ - "name": "user1" - }`), - ), - ), - ), expectPush( schemauser.NewUpdatedEvent( context.Background(), @@ -2262,11 +2304,13 @@ func TestCommands_ChangeSchemaUser(t *testing.T) { args{ ctx: authz.NewMockContext("instanceID", "", ""), user: &ChangeSchemaUser{ - ID: "user1", - SchemaID: gu.Ptr("id2"), - Data: json.RawMessage(`{ + ID: "user1", + SchemaUser: &SchemaUser{ + SchemaID: "id2", + Data: json.RawMessage(`{ "name": "user2" }`), + }, }, }, res{ @@ -2350,9 +2394,11 @@ func TestCommands_ChangeSchemaUser(t *testing.T) { ctx: authz.NewMockContext("instanceID", "", ""), user: &ChangeSchemaUser{ ID: "user1", - Data: json.RawMessage(`{ + SchemaUser: &SchemaUser{ + Data: json.RawMessage(`{ "name2": "user2" }`), + }, }, }, res{ @@ -2365,6 +2411,19 @@ func TestCommands_ChangeSchemaUser(t *testing.T) { "user updated, new schema and revision", fields{ eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + schemauser.NewCreatedEvent( + context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + "id1", + 2, + json.RawMessage(`{ + "name1": "user1" + }`), + ), + ), + ), expectFilter( eventFromEventPusher( schema.NewCreatedEvent( @@ -2384,19 +2443,6 @@ func TestCommands_ChangeSchemaUser(t *testing.T) { ), ), ), - expectFilter( - eventFromEventPusher( - schemauser.NewCreatedEvent( - context.Background(), - &schemauser.NewAggregate("user1", "org1").Aggregate, - "id1", - 2, - json.RawMessage(`{ - "name1": "user1" - }`), - ), - ), - ), expectPush( schemauser.NewUpdatedEvent( context.Background(), @@ -2418,11 +2464,13 @@ func TestCommands_ChangeSchemaUser(t *testing.T) { args{ ctx: authz.NewMockContext("instanceID", "", ""), user: &ChangeSchemaUser{ - ID: "user1", - SchemaID: gu.Ptr("id2"), - Data: json.RawMessage(`{ + ID: "user1", + SchemaUser: &SchemaUser{ + SchemaID: "id2", + Data: json.RawMessage(`{ "name2": "user2" }`), + }, }, }, res{ @@ -2435,6 +2483,19 @@ func TestCommands_ChangeSchemaUser(t *testing.T) { "user update, no field permission as admin", fields{ eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + schemauser.NewCreatedEvent( + context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + "id1", + 1, + json.RawMessage(`{ + "name": "user1" + }`), + ), + ), + ), expectFilter( eventFromEventPusher( schema.NewCreatedEvent( @@ -2457,30 +2518,19 @@ func TestCommands_ChangeSchemaUser(t *testing.T) { ), ), ), - expectFilter( - eventFromEventPusher( - schemauser.NewCreatedEvent( - context.Background(), - &schemauser.NewAggregate("user1", "org1").Aggregate, - "id1", - 1, - json.RawMessage(`{ - "name": "user1" - }`), - ), - ), - ), ), checkPermission: newMockPermissionCheckAllowed(), }, args{ ctx: authz.NewMockContext("instanceID", "", ""), user: &ChangeSchemaUser{ - ID: "user1", - SchemaID: gu.Ptr("id1"), - Data: json.RawMessage(`{ + ID: "user1", + SchemaUser: &SchemaUser{ + SchemaID: "id1", + Data: json.RawMessage(`{ "name": "user" }`), + }, }, }, res{ @@ -2494,6 +2544,18 @@ func TestCommands_ChangeSchemaUser(t *testing.T) { fields{ eventstore: expectEventstore( expectFilter( + eventFromEventPusher( + schemauser.NewCreatedEvent( + context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + "id1", + 1, + json.RawMessage(`{ + "name": "user1" + }`), + ), + ), + ), expectFilter( eventFromEventPusher( schema.NewCreatedEvent( context.Background(), @@ -2515,29 +2577,18 @@ func TestCommands_ChangeSchemaUser(t *testing.T) { ), ), ), - expectFilter( - eventFromEventPusher( - schemauser.NewCreatedEvent( - context.Background(), - &schemauser.NewAggregate("user1", "org1").Aggregate, - "id1", - 1, - json.RawMessage(`{ - "name": "user1" - }`), - ), - ), - ), ), }, args{ ctx: authz.NewMockContext("instanceID", "org1", "user1"), user: &ChangeSchemaUser{ - ID: "user1", - SchemaID: gu.Ptr("type"), - Data: json.RawMessage(`{ + ID: "user1", + SchemaUser: &SchemaUser{ + SchemaID: "type", + Data: json.RawMessage(`{ "name": "user" }`), + }, }, }, res{ @@ -2550,6 +2601,19 @@ func TestCommands_ChangeSchemaUser(t *testing.T) { "user update, invalid data type", fields{ eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + schemauser.NewCreatedEvent( + context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + "id1", + 1, + json.RawMessage(`{ + "name": "user1" + }`), + ), + ), + ), expectFilter( eventFromEventPusher( schema.NewCreatedEvent( @@ -2569,30 +2633,19 @@ func TestCommands_ChangeSchemaUser(t *testing.T) { ), ), ), - expectFilter( - eventFromEventPusher( - schemauser.NewCreatedEvent( - context.Background(), - &schemauser.NewAggregate("user1", "org1").Aggregate, - "id1", - 1, - json.RawMessage(`{ - "name": "user1" - }`), - ), - ), - ), ), checkPermission: newMockPermissionCheckAllowed(), }, args{ ctx: authz.NewMockContext("instanceID", "org1", "user1"), user: &ChangeSchemaUser{ - ID: "user1", - SchemaID: gu.Ptr("type"), - Data: json.RawMessage(`{ + ID: "user1", + SchemaUser: &SchemaUser{ + SchemaID: "type", + Data: json.RawMessage(`{ "name": 1 }`), + }, }, }, res{ @@ -2605,6 +2658,19 @@ func TestCommands_ChangeSchemaUser(t *testing.T) { "user update, additional property", fields{ eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + schemauser.NewCreatedEvent( + context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + "id1", + 1, + json.RawMessage(`{ + "name": "user1" + }`), + ), + ), + ), expectFilter( eventFromEventPusher( schema.NewCreatedEvent( @@ -2624,19 +2690,6 @@ func TestCommands_ChangeSchemaUser(t *testing.T) { ), ), ), - expectFilter( - eventFromEventPusher( - schemauser.NewCreatedEvent( - context.Background(), - &schemauser.NewAggregate("user1", "org1").Aggregate, - "id1", - 1, - json.RawMessage(`{ - "name": "user1" - }`), - ), - ), - ), expectPush( schemauser.NewUpdatedEvent( context.Background(), @@ -2657,12 +2710,14 @@ func TestCommands_ChangeSchemaUser(t *testing.T) { args{ ctx: authz.NewMockContext("instanceID", "", ""), user: &ChangeSchemaUser{ - ID: "user1", - SchemaID: gu.Ptr("id1"), - Data: json.RawMessage(`{ + ID: "user1", + SchemaUser: &SchemaUser{ + SchemaID: "id1", + Data: json.RawMessage(`{ "name": "user1", "additional": "property" }`), + }, }, }, res{ @@ -2675,6 +2730,19 @@ func TestCommands_ChangeSchemaUser(t *testing.T) { "user update, invalid data attribute name", fields{ eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + schemauser.NewCreatedEvent( + context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + "id1", + 1, + json.RawMessage(`{ + "name": "user1" + }`), + ), + ), + ), expectFilter( eventFromEventPusher( schema.NewCreatedEvent( @@ -2695,30 +2763,19 @@ func TestCommands_ChangeSchemaUser(t *testing.T) { ), ), ), - expectFilter( - eventFromEventPusher( - schemauser.NewCreatedEvent( - context.Background(), - &schemauser.NewAggregate("user1", "org1").Aggregate, - "id1", - 1, - json.RawMessage(`{ - "name": "user1" - }`), - ), - ), - ), ), checkPermission: newMockPermissionCheckAllowed(), }, args{ ctx: authz.NewMockContext("instanceID", "org1", "user1"), user: &ChangeSchemaUser{ - ID: "user1", - SchemaID: gu.Ptr("type"), - Data: json.RawMessage(`{ + ID: "user1", + SchemaUser: &SchemaUser{ + SchemaID: "type", + Data: json.RawMessage(`{ "invalid": "user" }`), + }, }, }, res{ @@ -2775,6 +2832,19 @@ func TestCommands_ChangeSchemaUser(t *testing.T) { "user update, email return", fields{ eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + schemauser.NewCreatedEvent( + context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + "id1", + 1, + json.RawMessage(`{ + "name": "user1" + }`), + ), + ), + ), expectFilter( eventFromEventPusher( schema.NewCreatedEvent( @@ -2794,19 +2864,6 @@ func TestCommands_ChangeSchemaUser(t *testing.T) { ), ), ), - expectFilter( - eventFromEventPusher( - schemauser.NewCreatedEvent( - context.Background(), - &schemauser.NewAggregate("user1", "org1").Aggregate, - "id1", - 1, - json.RawMessage(`{ - "name": "user1" - }`), - ), - ), - ), expectPush( schemauser.NewEmailUpdatedEvent(context.Background(), &schemauser.NewAggregate("user1", "org1").Aggregate, @@ -2832,8 +2889,10 @@ func TestCommands_ChangeSchemaUser(t *testing.T) { args{ ctx: authz.NewMockContext("instanceID", "", ""), user: &ChangeSchemaUser{ - ID: "user1", - SchemaID: gu.Ptr("id1"), + ID: "user1", + SchemaUser: &SchemaUser{ + SchemaID: "id1", + }, Email: &Email{ Address: "test@example.com", ReturnCode: true, @@ -3101,8 +3160,9 @@ func TestCommands_ChangeSchemaUser(t *testing.T) { eventstore: tt.fields.eventstore(t), checkPermission: tt.fields.checkPermission, newEncryptedCode: tt.fields.newCode, + userEncryption: crypto.CreateMockEncryptionAlg(gomock.NewController(t)), } - err := c.ChangeSchemaUser(tt.args.ctx, tt.args.user, crypto.CreateMockEncryptionAlg(gomock.NewController(t))) + details, err := c.ChangeSchemaUser(tt.args.ctx, tt.args.user) if tt.res.err == nil { assert.NoError(t, err) } @@ -3110,14 +3170,16 @@ func TestCommands_ChangeSchemaUser(t *testing.T) { t.Errorf("got wrong err: %v ", err) } if tt.res.err == nil { - assertObjectDetails(t, tt.res.details, tt.args.user.Details) + assertObjectDetails(t, tt.res.details, details) } if tt.res.returnCodePhone != "" { - assert.Equal(t, tt.res.returnCodePhone, tt.args.user.ReturnCodePhone) + assert.NotNil(t, tt.args.user.ReturnCodePhone) + assert.Equal(t, tt.res.returnCodePhone, *tt.args.user.ReturnCodePhone) } if tt.res.returnCodeEmail != "" { - assert.Equal(t, tt.res.returnCodeEmail, tt.args.user.ReturnCodeEmail) + assert.NotNil(t, tt.args.user.ReturnCodeEmail) + assert.Equal(t, tt.res.returnCodeEmail, *tt.args.user.ReturnCodeEmail) } }) } diff --git a/internal/integration/client.go b/internal/integration/client.go index 9bf855f5cea..dde8822acd5 100644 --- a/internal/integration/client.go +++ b/internal/integration/client.go @@ -776,6 +776,32 @@ func (i *Instance) CreateSchemaUser(ctx context.Context, orgID string, schemaID return user } +func (i *Instance) UpdateSchemaUserEmail(ctx context.Context, orgID string, userID string, email string) *user_v3alpha.SetContactEmailResponse { + user, err := i.Client.UserV3Alpha.SetContactEmail(ctx, &user_v3alpha.SetContactEmailRequest{ + Organization: &object_v3alpha.Organization{Property: &object_v3alpha.Organization_OrgId{OrgId: orgID}}, + Id: userID, + Email: &user_v3alpha.SetEmail{ + Address: email, + Verification: &user_v3alpha.SetEmail_ReturnCode{}, + }, + }) + logging.OnError(err).Fatal("create user") + return user +} + +func (i *Instance) UpdateSchemaUserPhone(ctx context.Context, orgID string, userID string, phone string) *user_v3alpha.SetContactPhoneResponse { + user, err := i.Client.UserV3Alpha.SetContactPhone(ctx, &user_v3alpha.SetContactPhoneRequest{ + Organization: &object_v3alpha.Organization{Property: &object_v3alpha.Organization_OrgId{OrgId: orgID}}, + Id: userID, + Phone: &user_v3alpha.SetPhone{ + Number: phone, + Verification: &user_v3alpha.SetPhone_ReturnCode{}, + }, + }) + logging.OnError(err).Fatal("create user") + return user +} + func (i *Instance) CreateInviteCode(ctx context.Context, userID string) *user_v2.CreateInviteCodeResponse { user, err := i.Client.UserV2.CreateInviteCode(ctx, &user_v2.CreateInviteCodeRequest{ UserId: userID, diff --git a/proto/zitadel/resources/user/v3alpha/user_service.proto b/proto/zitadel/resources/user/v3alpha/user_service.proto index 91831bdc40e..96e595d81d0 100644 --- a/proto/zitadel/resources/user/v3alpha/user_service.proto +++ b/proto/zitadel/resources/user/v3alpha/user_service.proto @@ -1392,7 +1392,7 @@ message SetContactPhoneRequest { message SetContactPhoneResponse { zitadel.resources.object.v3alpha.Details details = 1; // The phone verification code will be set if a contact phone was set with a return_code verification option. - optional string email_code = 3 [ + optional string verification_code = 3 [ (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { example: "\"SKJd342k\""; } From a6ea83168d113a1c5c79d2ffefea77b43a413a3b Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 25 Sep 2024 13:53:38 +0000 Subject: [PATCH 4/4] chore(deps): bump micromatch from 4.0.7 to 4.0.8 in /docs (#8493) Bumps [micromatch](https://github.com/micromatch/micromatch) from 4.0.7 to 4.0.8.
Release notes

Sourced from micromatch's releases.

4.0.8

Ultimate release that fixes both CVE-2024-4067 and CVE-2024-4068. We consider the issues low-priority, so even if you see automated scanners saying otherwise, don't be scared.

Changelog

Sourced from micromatch's changelog.

[4.0.8] - 2024-08-22

  • backported CVE-2024-4067 fix (from v4.0.6) over to 4.x branch
Commits
  • 8bd704e 4.0.8
  • a0e6841 run verb to generate README documentation
  • 4ec2884 Merge branch 'v4' into hauserkristof-feature/v4.0.8
  • 03aa805 Merge pull request #266 from hauserkristof/feature/v4.0.8
  • 814f5f7 lint
  • 67fcce6 fix: CHANGELOG about braces & CVE-2024-4068, v4.0.5
  • 113f2e3 fix: CVE numbers in CHANGELOG
  • d9dbd9a feat: updated CHANGELOG
  • 2ab1315 fix: use actions/setup-node@v4
  • 1406ea3 feat: rework test to work on macos with node 10,12 and 14
  • Additional commits viewable in compare view

[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=micromatch&package-manager=npm_and_yarn&previous-version=4.0.7&new-version=4.0.8)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot merge` will merge this PR after your CI passes on it - `@dependabot squash and merge` will squash and merge this PR after your CI passes on it - `@dependabot cancel merge` will cancel a previously requested merge and block automerging - `@dependabot reopen` will reopen this PR if it is closed - `@dependabot close` will close this PR and stop Dependabot recreating it. You can achieve the same result by closing it manually - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself) You can disable automated security fix PRs for this repo from the [Security Alerts page](https://github.com/zitadel/zitadel/network/alerts).
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- docs/yarn.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/yarn.lock b/docs/yarn.lock index 00d8b00c75d..d799eddad4a 100644 --- a/docs/yarn.lock +++ b/docs/yarn.lock @@ -7976,9 +7976,9 @@ micromark@^4.0.0: micromark-util-types "^2.0.0" micromatch@^4.0.2, micromatch@^4.0.4, micromatch@^4.0.5: - version "4.0.7" - resolved "https://registry.yarnpkg.com/micromatch/-/micromatch-4.0.7.tgz#33e8190d9fe474a9895525f5618eee136d46c2e5" - integrity sha512-LPP/3KorzCwBxfeUuZmaR6bG2kdeHSbe0P2tY3FLRU4vYrjYz5hI4QZwV0njUx3jeuKe67YukQ1LSPZBKDqO/Q== + version "4.0.8" + resolved "https://registry.yarnpkg.com/micromatch/-/micromatch-4.0.8.tgz#d66fa18f3a47076789320b9b1af32bd86d9fa202" + integrity sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA== dependencies: braces "^3.0.3" picomatch "^2.3.1"