From 25bce5911989b4d8204c37b497407e7d397b87b7 Mon Sep 17 00:00:00 2001 From: Livio Spring Date: Wed, 4 Sep 2024 13:34:26 +0200 Subject: [PATCH 01/33] fix(api): pass trusted domain in body instead of param (#8544) # Which Problems Are Solved The trusted domain cannot be sent in the request body. # How the Problems Are Solved Added missing mapping # Additional Changes None # Additional Context None --- proto/zitadel/admin.proto | 1 + 1 file changed, 1 insertion(+) diff --git a/proto/zitadel/admin.proto b/proto/zitadel/admin.proto index e6f098c2a2..1db51d1b9b 100644 --- a/proto/zitadel/admin.proto +++ b/proto/zitadel/admin.proto @@ -342,6 +342,7 @@ service AdminService { rpc AddInstanceTrustedDomain(AddInstanceTrustedDomainRequest) returns (AddInstanceTrustedDomainResponse) { option (google.api.http) = { post: "/trusted_domains"; + body: "*" }; option (zitadel.v1.auth_option) = { From 2b73a4a180a6bdc20653b3b42280b787dc0e9590 Mon Sep 17 00:00:00 2001 From: Max Peintner Date: Wed, 4 Sep 2024 14:38:41 +0200 Subject: [PATCH 02/33] chore(docs): increase heap size (#8547) more memory, less problems --- docs/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/package.json b/docs/package.json index c7e4d36796..3d7b2fe036 100644 --- a/docs/package.json +++ b/docs/package.json @@ -6,7 +6,7 @@ "docusaurus": "docusaurus", "start": "docusaurus start", "start:api": "yarn run generate && docusaurus start", - "build": "yarn run generate && NODE_OPTIONS=--max-old-space-size=6144 docusaurus build", + "build": "yarn run generate && NODE_OPTIONS=--max-old-space-size=8192 docusaurus build", "swizzle": "docusaurus swizzle", "deploy": "docusaurus deploy", "clear": "docusaurus clear", From 32e6884141eeb7ac0855285c03615de109f77851 Mon Sep 17 00:00:00 2001 From: Elio Bischof Date: Thu, 5 Sep 2024 21:59:35 +0200 Subject: [PATCH 03/33] docs: enterprise support for charts (#8551) # Which Problems Are Solved Charts support is not described anywhere # How the Problems Are Solved It is listed in https://zitadel.com/docs/support/software-release-cycles-support#enterprise-supported along the LDAP IDP and the Terraform Provider --- docs/docs/support/software-release-cycles-support.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/docs/support/software-release-cycles-support.md b/docs/docs/support/software-release-cycles-support.md index 162b428269..6e6e094c10 100644 --- a/docs/docs/support/software-release-cycles-support.md +++ b/docs/docs/support/software-release-cycles-support.md @@ -33,6 +33,7 @@ If you encounter issues with an enterprise supported feature and you are eligibl - LDAP Identity Provider - [Terraform Provider](https://github.com/zitadel/terraform-provider-zitadel) +- [Helm Chart](https://github.com/zitadel/zitadel-charts) ### Community supported From 2981ff04da1ed03f288b7a6dce09888aca683dda Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20M=C3=B6hlmann?= Date: Fri, 6 Sep 2024 09:58:06 +0300 Subject: [PATCH 04/33] docs(oidc): web keys (#8508) # Which Problems Are Solved Explain the web key implementation and usage in zitadel. # How the Problems Are Solved Add documentation # Additional Changes - none # Additional Context - Related to https://github.com/zitadel/zitadel/issues/7809 - Example cURL commands are broken: https://github.com/zitadel/zitadel/issues/8507 --------- Co-authored-by: Livio Spring --- .../guides/integrate/login/oidc/webkeys.md | 361 ++++++++++++++++++ docs/sidebars.js | 2 + 2 files changed, 363 insertions(+) create mode 100644 docs/docs/guides/integrate/login/oidc/webkeys.md diff --git a/docs/docs/guides/integrate/login/oidc/webkeys.md b/docs/docs/guides/integrate/login/oidc/webkeys.md new file mode 100644 index 0000000000..b849012458 --- /dev/null +++ b/docs/docs/guides/integrate/login/oidc/webkeys.md @@ -0,0 +1,361 @@ +--- +title: OpenID Connect and Oauth2 web keys +sidebar_label: Web keys +--- + +Web Keys in ZITADEL are used to sign and verify JSON Web Tokens (JWT). +ID tokens are created, signed and returned by ZITADEL when a OpenID connect (OIDC) or OAuth2 +authorization flow completes and a user is authenticated. +Optionally, ZITADEL can return JWTs for access tokens if the OIDC Application is configured for it. + +## Introduction + +ZITADEL uses asymmetric cryptography to sign and validate JWTs. +Keys are generated in pairs resulting in a private and public key. +Private keys are used to sign tokens. +Public keys are used to verify tokens. +OIDC clients need the public key to verify ID tokens. +OAuth2 API apps might need the public key if they want to client-side verification of a +JWT access tokens, instead of [introspection](/docs/apis/openidoauth/endpoints#introspection_endpoint). +ZITADEL uses public key verification when API calls are made or when the userInfo or introspection +endpoints are called with a JWT access token. + +:::info +Web keys are an [experimental](/docs/support/software-release-cycles-support#beta) feature. Be sure to enable the `web_key` [feature](/docs/apis/resources/feature_service_v2/feature-service-set-instance-features) before using it. +::: + +### JSON Web Key + +ZITADEL implements the [RFC7517 - JSON Web Key (JWK)](https://www.rfc-editor.org/rfc/rfc7517) format for storage and distribution of public keys. +Web keys in ZITADEL support a number of [JSON Web Algorithms (JWA)](https://www.rfc-editor.org/rfc/rfc7518) for digital signatures: + +| Identifier | Description | +| ---------- | ------------------------------- | +| RS256 | RSASSA-PKCS1-v1_5 using SHA-256 | +| RS384 | RSASSA-PKCS1-v1_5 using SHA-384 | +| RS512 | RSASSA-PKCS1-v1_5 using SHA-512 | +| ES256 | ECDSA using P-256 and SHA-256 | +| ES384 | ECDSA using P-384 and SHA-384 | +| ES512 | ECDSA using P-521 and SHA-512 | +| EdDSA | EdDSA signature algorithms[^1] | + +[^1]: EdDSA refers to both Ed25519 and Ed448 curves. ZITADEL only supports Ed25519 with a SHA-512 hashing algorithm. EdDSA is for JSON Object Signing is defined in [RFC8037](https://www.rfc-editor.org/rfc/rfc8037). + +### Client Algorithm Support + +Before customizing the algorithm the instance admin **MUST** make sure the complete app and API ecosystem +supports the chosen algorithm. + +When all OIDC applications of an instance use opaque access tokens, and they call APIs which only use +introspection for token validation, only the OIDC applications will need to support the chosen algorithm. +If JWT access tokens are used and APIs do public key verification, those APIs need to support the chosen algorithm as well. + +RS256 is widely considered the default algorithm and must be supported by all OIDC/Oauth2 providers, relying parties and resource servers. +This is also the default ZITADEL uses when [creating web keys](#creation). +It might be reasonable to assume RS384 and RS512 are just as supported, because those are just variations on RSA based keys. +The ES256, ES384 and ES512 might have reasonable support as well, +ECDSA is part of the same [JSON Web Algorithms (JWA)](https://www.rfc-editor.org/rfc/rfc7518) as RSA. + +EdDSA usage is defined in the supplemental [RFC8037](https://www.rfc-editor.org/rfc/rfc8037), +and therefore may be less supported than the others. +Also, the `at_hash` claim in the ID token is a hashed string of the access token. +The hasher is usually defined by the keys `alg` header. For example: + +- RS256 defines an RSA key and a SHA256 hasher. +- ES512 defines an elliptic curve key with the P-512 and SHA512 hasher. + +Unfortunately, there is no published standard for the `at_hash` hasher used for EdDSA. +In fact, EdDSA may use different curves and internally uses different hashers: + +- ed25519 uses SHA512; +- ed448 uses SHAKE256; + +This resulted in a [proposal](https://bitbucket.org/openid/connect/issues/1125/_hash-algorithm-for-eddsa-id-tokens) at +the Open ID workgroup to follow suit and use the same hashing algorithms for the `at_hash` claim. +This means both signers and verifiers can't know the hasher by the `alg` value alone and need to inspect `crv` value as well. +Since the decision in the proposal isn't published yet, +there is a big change some OIDC client libraries don't have proper support for EdDSA / ed25519. + +The ZITADEL back-end is written in Go. The Go developers have denied ed448 curve implementations to be included. +Therefore ZITADEL only uses ed25519 with a SHA512. +The same counts for [zitadel/oidc](https://github.com/zitadel/oidc) Go library. + +## Web Key management + +ZITADEL provides a resource based [web keys API](/docs/apis/resources/webkey_service_v3). +The API allows the creation, activation, deletion and listing of web keys. +All public keys that are stored for an instance are served on the [JWKS endpoint](#json-web-key-set). +Applications need public keys for token verification and not all applications are capable of on-demand +key fetching when receiving a token with an unknown key ID (`kid` header claim). +Instead, those application may do a time-based refresh or only load keys at startup. + +Using the web keys API, keys can be created first and activated for signing later. +This allows the keys to be distributed to the instance's apps and caches. +Once a key is deactivated, its public key will remain available for token verification until the web key is deleted. +Delayed deletion makes sure tokens that were signed before the key got deactivated remain valid. + +When the `web_key` [feature](/docs/apis/resources/feature_service_v2/feature-service-set-instance-features) is enabled the first time, +two web key pairs are created with one activated. + +### Creation + +The web key [create](/docs/apis/resources/webkey_service_v3/zitadel-web-keys-create-web-key) endpoint generates a new web key pair, +using the passed generator configuration from the request. This config is a one-of field of: + +- RSA +- ECDSA +- ED25519 + +When the request does not contain any specific configuration, +[RSA](#rsa) is used as default with the default options as described below: + +```bash +curl -L 'https://$CUSTOM-DOMAIN/resources/v3alpha/web_keys' \ +-H 'Content-Type: application/json' \ +-H 'Accept: application/json' \ +-H 'Authorization: Bearer ' \ +-d '{}' +``` + +#### RSA + +The RSA generator config takes two enum values. + +- The `bits` fields determines the size of the RSA key: + - `RSA_BITS_2048` (**default**) + - `RSA_BITS_3072` + - `RSA_BITS_4096` +- The `hasher` field sets the hash mode and + determines the `alg` header value of the web key: + - `RSA_HASHER_SHA256` results in the RS256 algorithm header. (**default**) + - `RSA_HASHER_SHA384` results in the RS384 algorithm header. + - `RSA_HASHER_SHA512` results in the RS512 algorithm header. + +For example, to create a RSA web key with the size of 3072 bits and the SHA512 algorithm (RS512): + +```bash +curl -L 'https://$CUSTOM-DOMAIN/resources/v3alpha/web_keys' \ +-H 'Content-Type: application/json' \ +-H 'Accept: application/json' \ +-H 'Authorization: Bearer ' \ +-d '{ + "rsa": { + "bits": "RSA_BITS_3072", + "hasher": "RSA_HASHER_SHA512" + } +}' + +``` + +#### ECDSA + +The ECDSA generator config takes a single `curve` enum value which determines both the key's curve parameters and hashing algorithm: + +- `ECDSA_CURVE_P256` uses the NIST P-256 curve and sets the ES256 algorithm header. +- `ECDSA_CURVE_P384` uses the NIST P-384 curve and sets the ES384 algorithm header. +- `ECDSA_CURVE_P512` uses the NIST P-512 curve and sets the ES512 algorithm header. + +For example, to create a ECDSA web key with a P-256 curve and the SHA256 algorithm: + +```bash +curl -L 'https://$CUSTOM-DOMAIN/resources/v3alpha/web_keys' \ +-H 'Content-Type: application/json' \ +-H 'Accept: application/json' \ +-H 'Authorization: Bearer ' \ +-d '{ + "ecdsa": { + "curve": "ECDSA_CURVE_P256" + } +}' +``` + +#### ED25519 + +ED25519 is an EdDSA curve and currently the only EdDSA curve supported by ZITADEL.[^2] +No config is needed for ed25519 as its specification already includes the curve parameters. +ed25519 always uses the SHA512 hasher. + +Note that the `alg` header for ed25519 is `EdDSA` and refers to both ed25519 and ed448 curves. +Both curves specify different hashers. +Clients which support both curves must inspect `crv` header value to assert the difference. + +For example, to create a ed25519 web key: + +```bash +curl -L 'https://$CUSTOM-DOMAIN/resources/v3alpha/web_keys' \ +-H 'Content-Type: application/json' \ +-H 'Accept: application/json' \ +-H 'Authorization: Bearer ' \ +-d '{ + "ed25519": {} +}' +``` + +[^2]: The ZITADEL back-end is written in Go. The Go developers have denied ed448 curve implementations to be included. + Therefore ZITADEL won't support this either. + +### Activation + +When a generated web key is [activated](/docs/apis/resources/webkey_service_v3/zitadel-web-keys-activate-web-key), +its private key will be used to sign new tokens. +There can be only one active key on an instance. +Activating a key implies deactivation of the previously active key. + +Public keys on the [JWKS](#json-web-key-set) endpoint may be [cached](#caching). +Therefore it is advised to delay activation after generating a key, +at least for the duration of the max-age setting plus any time it might take for client applications to refresh. + +### Deletion + +Non-active keys may be [deleted](/docs/apis/resources/webkey_service_v3/zitadel-web-keys-delete-web-key). +Deletion also means tokens signed with this key become invalid. +Active keys can't be deleted. +As each public key is available on the [JWKS](#json-web-key-set) endpoint, +it is important to cleanup old web keys that are no longer needed. +Otherwise the endpoint's response size will only grow over time, which might lead to performance issues. + +Once a key was activated and deactivated (by activation of the next key) deletion should wait: + +- Until access and ID tokens are expired. See [OIDC token lifetimes](/docs/guides/manage/console/default-settings#oidc-token-lifetimes-and-expiration). +- ID tokens may be used as `id_token_hint` in authentication and end-session requests. The hint typically doesn't expire, but becomes invalid once the key is deleted. + It might be desired to keep keys around long enough to minimalize user impact. + +### Rotation example + +This section gives an example on a key rotation strategy. +This strategy aims to fulfill the following requirements: + +1. Web keys are rotated monthly. +2. Applications have enough time to see the next activated web key on the [JWKS](#json-web-key-set) endpoint. +3. Web keys are kept long enough to cover the access and ID token validity of 24 hours. +4. Web keys are kept long enough to to allow usage of the `id_token_hint` for at least 3 months. + Users that haven't logged in / refreshed tokens with the client app for that period, + will need to re-enter their username. + +When the feature flag was enabled the first time, the instance got two keys with the first one activated. When this feature becomes general available, instance creation will setup the first two keys in the same way. So the initial state always looks like this: + +| id | created | changed | state | +| --- | ---------- | ---------- | --------------- | +| 1 | 2025-01-01 | 2025-01-01 | `STATE_ACTIVE` | +| 2 | 2025-01-01 | 2025-01-01 | `STATE_INITIAL` | + +For the sake of this example we will use simplified IDs and restrict timestamps to dates. + +After one month, on 2025-02-01, we wish to activate the next available key and create a new key to be available for activation next month. This fulfills requirements 1 and 2. + +```bash +curl -L -X POST 'https://$CUSTOM-DOMAIN/resources/v3alpha/web_keys/2/_activate' \ +-H 'Accept: application/json' \ +-H 'Authorization: Bearer ' + +curl -L 'https://$CUSTOM-DOMAIN/resources/v3alpha/web_keys' \ +-H 'Content-Type: application/json' \ +-H 'Accept: application/json' \ +-H 'Authorization: Bearer ' \ +-d '{}' +``` + +Key ID 2 became active, Key ID 1 became inactive and a new key with ID 3 was created: + +| id | created | changed | state | +| --- | ---------- | ---------- | ---------------- | +| 1 | 2025-01-01 | 2025-02-01 | `STATE_INACTIVE` | +| 2 | 2025-01-01 | 2025-02-01 | `STATE_ACTIVE` | +| 3 | 2025-02-01 | 2025-02-01 | `STATE_INITIAL` | + +No keys are deleted yet. +We continue like this monthly. +At one point (on 2025-05-01) we will have a web key with `STATE_INACTIVE` with a changed date of 3 months ago: + +| id | created | changed | state | +| --- | ---------- | ---------- | ---------------- | +| 1 | 2025-01-01 | 2025-02-01 | `STATE_INACTIVE` | +| 2 | 2025-01-01 | 2025-03-01 | `STATE_INACTIVE` | +| 3 | 2025-02-01 | 2025-04-01 | `STATE_INACTIVE` | +| 4 | 2025-03-01 | 2025-05-01 | `STATE_INACTIVE` | +| 5 | 2025-04-01 | 2025-05-01 | `STATE_ACTIVE` | +| 6 | 2025-05-01 | 2025-05-01 | `STATE_INITIAL` | + +In addition to the activate and create calls we made on this iteration, +we can now safely delete the oldest key, as both requirement 3 and 4 are now fulfilled: + +```bash +curl -L -X DELETE 'https://$CUSTOM-DOMAIN/resources/v3alpha/web_keys/1' \ +-H 'Accept: application/json' \ +-H 'Authorization: Bearer ' +``` + +The final state: + +| id | created | changed | state | +| --- | ---------- | ---------- | ---------------- | +| 2 | 2025-01-01 | 2025-03-01 | `STATE_INACTIVE` | +| 3 | 2025-02-01 | 2025-04-01 | `STATE_INACTIVE` | +| 4 | 2025-03-01 | 2025-05-01 | `STATE_INACTIVE` | +| 5 | 2025-04-01 | 2025-05-01 | `STATE_ACTIVE` | +| 6 | 2025-05-01 | 2025-05-01 | `STATE_INITIAL` | + +Next month, Key ID 6 will be activated, an new key added and Key ID 2 can be deleted. + +## JSON web key set + +The JSON web key set (JWKS) endpoint serves all available public keys for the instance on +`{your_domain}/oauth/v2/keys`. This includes activated, newly non-activated and deactivated web keys. The response format is defined in [RFC7517, section 5: JWK Set Format](https://www.rfc-editor.org/rfc/rfc7517#section-5). + +And looks like: + +```json +{ + "keys": [ + { + "use": "sig", + "kty": "RSA", + "kid": "280543383892525058", + "alg": "RS384", + "n": "0pVcbjTEr-awBmvztGLbBJB_-_YwjCKKXURJRpoXrChlaqtAvbkxby7mu9wSKAibxnvaobfuxnQydlB4CoKObUr00ARVBNeP5HLzeQUEx3CZh3s1LsjiuYov_yyvK9D12WH1LikP4ZPS68j-DVoEOEcFAE6cNikXTeDyCKa-ixROALieRXUQXTlvVyA_s0FhevmH0-M6rEN4YcfQuIZACEv2nQ4AJo0sNnugwrrqNn595ONKMSh2XTVngxxAD3TGHXg9bELB-WmgnZamVbO-ObpDBp5Ov73HL60_UoBTzBDECM6ovl52fHusLFw6Vkdt9_W3QhuRFljNqTPnna6rB-bLptQltBpnSBV3TxmklBcQ1EO3qeGvgOJsmDwSRlr28Du_1pyFMFANnG174eX5XrYASqTgJ1Wq7AfMBmv7YwGU7PbMce1V_CAV9u_hNkMJf0xQ4AIqrQ98f9hC5VCdCoKSOH1-1d8icEu7UmDyJohWqvY7xGOM_0Abx8ekMRT2O9PulmQ22me_GI5zXh7iv9yaoNq8EUNP5bdtr-ZG4PG8mqpLDSLpCpobYRK5AynyJkf-7_6neSy-ihu604ADKsNzB-uO58V8MPFdSPncyuUeTPX4dAVajbFyMtoAjtI1k_HYMU8nojRUrLSCJae9b0KtcPm9s7dCIL1Zpa4B-YM", + "e": "AQAB" + }, + { + "use": "sig", + "kty": "OKP", + "kid": "280998627474669570", + "crv": "Ed25519", + "alg": "EdDSA", + "x": "B51hFhRUHMHpqO1f-OThtnk3PfnRFaPFJWCLXSM_kuI" + }, + { + "use": "sig", + "kty": "EC", + "kid": "282465789963927554", + "crv": "P-256", + "alg": "ES256", + "x": "X5s3tNoIXd5odp_-IwQq5oaAgMSoAxj0hwQ1DgHihmI", + "y": "JqmTlRjoOv5bY5E9tAZXHaUHUamAAAFshO8zLhEZ9ZM" + } + ] +} +``` + +After the `web_key` feature is enabled, the response may still contain legacy keys, in order not to invalidate older sessions. +The legacy keys will disappear once they expire. + +### Caching + +As web keys can be created and distributed ahead of time, it is safe for JWKS responses to be cached at intermediate proxies. +Once the `web_key` feature is enabled, ZITADEL will send a `Cache-Control` header which allows caching. + +By default and in ZITADEL Cloud we allow 5 minutes of caching: + +``` +Cache-Control: max-age=300, must-revalidate +``` + +Self-hosters can modify this setting through the `ZITADEL_OIDC_JWKSCACHECONTROLMAXAGE` +environment variable or in the configuration yaml: + +```yaml +OIDC: + JWKSCacheControlMaxAge: 5m +``` + +Setting the value to `0` will result in a `no-store` value in the `Cache-Control` header. diff --git a/docs/sidebars.js b/docs/sidebars.js index e3ee34149a..1ebba37416 100644 --- a/docs/sidebars.js +++ b/docs/sidebars.js @@ -230,6 +230,8 @@ module.exports = { "guides/integrate/login/oidc/oauth-recommended-flows", "guides/integrate/login/oidc/device-authorization", "guides/integrate/login/oidc/logout", + "guides/integrate/login/oidc/webkeys", + ], }, "guides/integrate/login/saml", From b522588d989f118dd22f277509c4021167507231 Mon Sep 17 00:00:00 2001 From: Silvan Date: Fri, 6 Sep 2024 11:19:19 +0200 Subject: [PATCH 05/33] fix(eventstore): precise decimal (#8527) # Which Problems Are Solved Float64 which was used for the event.Position field is [not precise in go and gets rounded](https://github.com/golang/go/issues/47300). This can lead to unprecies position tracking of events and therefore projections especially on cockcoachdb as the position used there is a big number. example of a unprecies position: exact: 1725257931223002628 float64: 1725257931223002624.000000 # How the Problems Are Solved The float64 was replaced by [github.com/jackc/pgx-shopspring-decimal](https://github.com/jackc/pgx-shopspring-decimal). # Additional Changes Correct behaviour of makefile for load tests. Rename `latestSequence`-queries to `latestPosition` --- 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, 319 insertions(+), 215 deletions(-) diff --git a/cmd/mirror/event.go b/cmd/mirror/event.go index 2bb0d52f45..af0ac25e27 100644 --- a/cmd/mirror/event.go +++ b/cmd/mirror/event.go @@ -3,6 +3,8 @@ 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" @@ -30,12 +32,12 @@ func queryLastSuccessfulMigration(ctx context.Context, destinationES *eventstore return lastSuccess, nil } -func writeMigrationStart(ctx context.Context, sourceES *eventstore.EventStore, id string, destination string) (_ float64, err error) { +func writeMigrationStart(ctx context.Context, sourceES *eventstore.EventStore, id string, destination string) (_ decimal.Decimal, err error) { var cmd *eventstore.Command if len(instanceIDs) > 0 { cmd, err = mirror_event.NewStartedInstancesCommand(destination, instanceIDs) if err != nil { - return 0, err + return decimal.Decimal{}, err } } else { cmd = mirror_event.NewStartedSystemCommand(destination) @@ -58,12 +60,12 @@ func writeMigrationStart(ctx context.Context, sourceES *eventstore.EventStore, i ), ) if err != nil { - return 0, err + return decimal.Decimal{}, err } return position.Position, nil } -func writeMigrationSucceeded(ctx context.Context, destinationES *eventstore.EventStore, id, source string, position float64) error { +func writeMigrationSucceeded(ctx context.Context, destinationES *eventstore.EventStore, id, source string, position decimal.Decimal) error { return destinationES.Push( ctx, eventstore.NewPushIntent( diff --git a/cmd/mirror/event_store.go b/cmd/mirror/event_store.go index 23145bdc37..2eab4eb0da 100644 --- a/cmd/mirror/event_store.go +++ b/cmd/mirror/event_store.go @@ -9,6 +9,7 @@ import ( "time" "github.com/jackc/pgx/v5/stdlib" + "github.com/shopspring/decimal" "github.com/spf13/cobra" "github.com/spf13/viper" "github.com/zitadel/logging" @@ -180,7 +181,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 float64, errs <-chan error) { +func writeCopyEventsDone(ctx context.Context, es *eventstore.EventStore, id, source string, position decimal.Decimal, 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 49fe4df244..61d1d711d1 100644 --- a/go.mod +++ b/go.mod @@ -36,6 +36,7 @@ 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 @@ -52,6 +53,7 @@ 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 416161b726..82ef92c3a7 100644 --- a/go.sum +++ b/go.sum @@ -404,6 +404,8 @@ 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= @@ -648,6 +650,8 @@ 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 a7e156fe78..f7aa88409e 100644 --- a/internal/api/oidc/key.go +++ b/internal/api/oidc/key.go @@ -11,6 +11,7 @@ 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" @@ -350,14 +351,14 @@ func (o *OPStorage) getSigningKey(ctx context.Context) (op.SigningKey, error) { if len(keys.Keys) > 0 { return o.privateKeyToSigningKey(selectSigningKey(keys.Keys)) } - var position float64 + var position decimal.Decimal 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 float64) error { +func (o *OPStorage) refreshSigningKey(ctx context.Context, algorithm string, position decimal.Decimal) 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") @@ -369,12 +370,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 float64) (bool, error) { +func (o *OPStorage) ensureIsLatestKey(ctx context.Context, position decimal.Decimal) (bool, error) { maxSequence, err := o.getMaxKeySequence(ctx) if err != nil { return false, fmt.Errorf("error retrieving new events: %w", err) } - return position >= maxSequence, nil + return position.GreaterThanOrEqual(maxSequence), nil } func (o *OPStorage) privateKeyToSigningKey(key query.PrivateKey) (_ op.SigningKey, err error) { @@ -412,9 +413,9 @@ func (o *OPStorage) lockAndGenerateSigningKeyPair(ctx context.Context, algorithm return o.command.GenerateSigningKeyPair(setOIDCCtx(ctx), algorithm) } -func (o *OPStorage) getMaxKeySequence(ctx context.Context) (float64, error) { - return o.eventstore.LatestSequence(ctx, - eventstore.NewSearchQueryBuilder(eventstore.ColumnsMaxSequence). +func (o *OPStorage) getMaxKeySequence(ctx context.Context) (decimal.Decimal, error) { + return o.eventstore.LatestPosition(ctx, + eventstore.NewSearchQueryBuilder(eventstore.ColumnsMaxPosition). ResourceOwner(authz.GetInstance(ctx).InstanceID()). AwaitOpenTransactions(). AllowTimeTravel(). diff --git a/internal/api/saml/certificate.go b/internal/api/saml/certificate.go index 2eac0e4d36..079655391e 100644 --- a/internal/api/saml/certificate.go +++ b/internal/api/saml/certificate.go @@ -6,6 +6,7 @@ import ( "time" "github.com/go-jose/go-jose/v4" + "github.com/shopspring/decimal" "github.com/zitadel/logging" "github.com/zitadel/saml/pkg/provider/key" @@ -76,7 +77,7 @@ func (p *Storage) getCertificateAndKey(ctx context.Context, usage crypto.KeyUsag return p.certificateToCertificateAndKey(selectCertificate(certs.Certificates)) } - var position float64 + var position decimal.Decimal if certs.State != nil { position = certs.State.Position } @@ -87,7 +88,7 @@ func (p *Storage) getCertificateAndKey(ctx context.Context, usage crypto.KeyUsag func (p *Storage) refreshCertificate( ctx context.Context, usage crypto.KeyUsage, - position float64, + position decimal.Decimal, ) error { ok, err := p.ensureIsLatestCertificate(ctx, position) if err != nil { @@ -103,12 +104,12 @@ func (p *Storage) refreshCertificate( return nil } -func (p *Storage) ensureIsLatestCertificate(ctx context.Context, position float64) (bool, error) { +func (p *Storage) ensureIsLatestCertificate(ctx context.Context, position decimal.Decimal) (bool, error) { maxSequence, err := p.getMaxKeySequence(ctx) if err != nil { return false, fmt.Errorf("error retrieving new events: %w", err) } - return position >= maxSequence, nil + return position.GreaterThanOrEqual(maxSequence), nil } func (p *Storage) lockAndGenerateCertificateAndKey(ctx context.Context, usage crypto.KeyUsage) error { @@ -151,9 +152,9 @@ func (p *Storage) lockAndGenerateCertificateAndKey(ctx context.Context, usage cr } } -func (p *Storage) getMaxKeySequence(ctx context.Context) (float64, error) { - return p.eventstore.LatestSequence(ctx, - eventstore.NewSearchQueryBuilder(eventstore.ColumnsMaxSequence). +func (p *Storage) getMaxKeySequence(ctx context.Context) (decimal.Decimal, error) { + return p.eventstore.LatestPosition(ctx, + eventstore.NewSearchQueryBuilder(eventstore.ColumnsMaxPosition). ResourceOwner(authz.GetInstance(ctx).InstanceID()). AwaitOpenTransactions(). AddQuery(). diff --git a/internal/database/cockroach/crdb.go b/internal/database/cockroach/crdb.go index 2f685f6d92..988f678e16 100644 --- a/internal/database/cockroach/crdb.go +++ b/internal/database/cockroach/crdb.go @@ -7,6 +7,8 @@ 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" @@ -82,6 +84,11 @@ 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 ecafbe877a..24b31a55fb 100644 --- a/internal/database/postgres/pg.go +++ b/internal/database/postgres/pg.go @@ -7,6 +7,8 @@ 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" @@ -82,6 +84,10 @@ 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 3df096f069..656a02f33d 100644 --- a/internal/eventstore/event.go +++ b/internal/eventstore/event.go @@ -5,6 +5,8 @@ import ( "reflect" "time" + "github.com/shopspring/decimal" + "github.com/zitadel/zitadel/internal/zerrors" ) @@ -44,7 +46,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() float64 + Position() decimal.Decimal // 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 c2b56128a8..0cd5b6440c 100644 --- a/internal/eventstore/event_base.go +++ b/internal/eventstore/event_base.go @@ -5,6 +5,8 @@ import ( "encoding/json" "time" + "github.com/shopspring/decimal" + "github.com/zitadel/zitadel/internal/api/authz" "github.com/zitadel/zitadel/internal/api/service" ) @@ -21,7 +23,7 @@ type BaseEvent struct { Agg *Aggregate Seq uint64 - Pos float64 + Pos decimal.Decimal Creation time.Time previousAggregateSequence uint64 previousAggregateTypeSequence uint64 @@ -34,7 +36,7 @@ type BaseEvent struct { } // Position implements Event. -func (e *BaseEvent) Position() float64 { +func (e *BaseEvent) Position() decimal.Decimal { return e.Pos } diff --git a/internal/eventstore/eventstore.go b/internal/eventstore/eventstore.go index e456135828..066a876da3 100644 --- a/internal/eventstore/eventstore.go +++ b/internal/eventstore/eventstore.go @@ -8,6 +8,7 @@ import ( "time" "github.com/jackc/pgx/v5/pgconn" + "github.com/shopspring/decimal" "github.com/zitadel/logging" "github.com/zitadel/zitadel/internal/api/authz" @@ -217,11 +218,11 @@ func (es *Eventstore) FilterToReducer(ctx context.Context, searchQuery *SearchQu }) } -// LatestSequence filters the latest sequence for the given search query -func (es *Eventstore) LatestSequence(ctx context.Context, queryFactory *SearchQueryBuilder) (float64, error) { +// LatestPosition filters the latest position for the given search query +func (es *Eventstore) LatestPosition(ctx context.Context, queryFactory *SearchQueryBuilder) (decimal.Decimal, error) { queryFactory.InstanceID(authz.GetInstance(ctx).InstanceID()) - return es.querier.LatestSequence(ctx, queryFactory) + return es.querier.LatestPosition(ctx, queryFactory) } // InstanceIDs returns the instance ids found by the search query @@ -266,8 +267,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 - // LatestSequence returns the latest sequence found by the search query - LatestSequence(ctx context.Context, queryFactory *SearchQueryBuilder) (float64, error) + // LatestPosition returns the latest position found by the search query + LatestPosition(ctx context.Context, queryFactory *SearchQueryBuilder) (decimal.Decimal, 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 856bb4a20e..6a01c6fbf0 100644 --- a/internal/eventstore/eventstore_querier_test.go +++ b/internal/eventstore/eventstore_querier_test.go @@ -4,6 +4,8 @@ import ( "context" "testing" + "github.com/shopspring/decimal" + "github.com/zitadel/zitadel/internal/eventstore" ) @@ -98,7 +100,7 @@ func TestCRDB_Filter(t *testing.T) { } } -func TestCRDB_LatestSequence(t *testing.T) { +func TestCRDB_LatestPosition(t *testing.T) { type args struct { searchQuery *eventstore.SearchQueryBuilder } @@ -106,7 +108,7 @@ func TestCRDB_LatestSequence(t *testing.T) { existingEvents []eventstore.Command } type res struct { - sequence float64 + position decimal.Decimal } tests := []struct { name string @@ -118,7 +120,7 @@ func TestCRDB_LatestSequence(t *testing.T) { { name: "aggregate type filter no sequence", args: args{ - searchQuery: eventstore.NewSearchQueryBuilder(eventstore.ColumnsMaxSequence). + searchQuery: eventstore.NewSearchQueryBuilder(eventstore.ColumnsMaxPosition). AddQuery(). AggregateTypes("not found"). Builder(), @@ -135,7 +137,7 @@ func TestCRDB_LatestSequence(t *testing.T) { { name: "aggregate type filter sequence", args: args{ - searchQuery: eventstore.NewSearchQueryBuilder(eventstore.ColumnsMaxSequence). + searchQuery: eventstore.NewSearchQueryBuilder(eventstore.ColumnsMaxPosition). AddQuery(). AggregateTypes(eventstore.AggregateType(t.Name())). Builder(), @@ -169,12 +171,12 @@ func TestCRDB_LatestSequence(t *testing.T) { return } - sequence, err := db.LatestSequence(context.Background(), tt.args.searchQuery) + position, err := db.LatestPosition(context.Background(), tt.args.searchQuery) if (err != nil) != tt.wantErr { t.Errorf("CRDB.query() error = %v, wantErr %v", err, tt.wantErr) } - if tt.res.sequence > sequence { - t.Errorf("CRDB.query() expected sequence: %v got %v", tt.res.sequence, sequence) + if tt.res.position.GreaterThan(position) { + t.Errorf("CRDB.query() expected sequence: %v got %v", tt.res.position, position) } }) } diff --git a/internal/eventstore/eventstore_test.go b/internal/eventstore/eventstore_test.go index 53ef4e54cf..86f7809f24 100644 --- a/internal/eventstore/eventstore_test.go +++ b/internal/eventstore/eventstore_test.go @@ -9,6 +9,7 @@ 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" @@ -390,7 +391,7 @@ func (repo *testPusher) Push(ctx context.Context, commands ...Command) (events [ type testQuerier struct { events []Event - sequence float64 + sequence decimal.Decimal instances []string err error t *testing.T @@ -423,9 +424,9 @@ func (repo *testQuerier) FilterToReducer(ctx context.Context, searchQuery *Searc return nil } -func (repo *testQuerier) LatestSequence(ctx context.Context, queryFactory *SearchQueryBuilder) (float64, error) { +func (repo *testQuerier) LatestPosition(ctx context.Context, queryFactory *SearchQueryBuilder) (decimal.Decimal, error) { if repo.err != nil { - return 0, repo.err + return decimal.Decimal{}, repo.err } return repo.sequence, nil } @@ -1055,7 +1056,7 @@ func TestEventstore_FilterEvents(t *testing.T) { } } -func TestEventstore_LatestSequence(t *testing.T) { +func TestEventstore_LatestPosition(t *testing.T) { type args struct { query *SearchQueryBuilder } @@ -1075,7 +1076,7 @@ func TestEventstore_LatestSequence(t *testing.T) { name: "no events", args: args{ query: &SearchQueryBuilder{ - columns: ColumnsMaxSequence, + columns: ColumnsMaxPosition, queries: []*SearchQuery{ { builder: &SearchQueryBuilder{}, @@ -1098,7 +1099,7 @@ func TestEventstore_LatestSequence(t *testing.T) { name: "repo error", args: args{ query: &SearchQueryBuilder{ - columns: ColumnsMaxSequence, + columns: ColumnsMaxPosition, queries: []*SearchQuery{ { builder: &SearchQueryBuilder{}, @@ -1121,7 +1122,7 @@ func TestEventstore_LatestSequence(t *testing.T) { name: "found events", args: args{ query: &SearchQueryBuilder{ - columns: ColumnsMaxSequence, + columns: ColumnsMaxPosition, queries: []*SearchQuery{ { builder: &SearchQueryBuilder{}, @@ -1147,7 +1148,7 @@ func TestEventstore_LatestSequence(t *testing.T) { querier: tt.fields.repo, } - _, err := es.LatestSequence(context.Background(), tt.args.query) + _, err := es.LatestPosition(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 8b71f32519..2c371d67ec 100644 --- a/internal/eventstore/handler/v2/field_handler.go +++ b/internal/eventstore/handler/v2/field_handler.go @@ -8,6 +8,7 @@ import ( "time" "github.com/jackc/pgx/v5/pgconn" + "github.com/shopspring/decimal" "github.com/zitadel/zitadel/internal/eventstore" ) @@ -123,7 +124,7 @@ func (h *FieldHandler) processEvents(ctx context.Context, config *triggerConfig) return additionalIteration, err } // stop execution if currentState.eventTimestamp >= config.maxCreatedAt - if config.maxPosition != 0 && currentState.position >= config.maxPosition { + if !config.maxPosition.IsZero() && currentState.position.GreaterThanOrEqual(config.maxPosition) { return false, nil } @@ -156,7 +157,7 @@ func (h *FieldHandler) fetchEvents(ctx context.Context, tx *sql.Tx, currentState idx, offset := skipPreviouslyReducedEvents(events, currentState) - if currentState.position == events[len(events)-1].Position() { + if currentState.position.Equal(events[len(events)-1].Position()) { offset += currentState.offset } currentState.position = events[len(events)-1].Position() @@ -186,9 +187,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 float64 + var position decimal.Decimal for i, event := range events { - if event.Position() != position { + if !event.Position().Equal(position) { offset = 0 position = event.Position() } diff --git a/internal/eventstore/handler/v2/handler.go b/internal/eventstore/handler/v2/handler.go index b395035b8f..aaefec2e9b 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 float64 + maxPosition decimal.Decimal } type TriggerOpt func(conf *triggerConfig) @@ -390,7 +390,7 @@ func WithAwaitRunning() TriggerOpt { } } -func WithMaxPosition(position float64) TriggerOpt { +func WithMaxPosition(position decimal.Decimal) 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 != 0 && currentState.position >= config.maxPosition { + if !config.maxPosition.Equal(decimal.Decimal{}) && currentState.position.GreaterThanOrEqual(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 == currentState.position && + if statement.Position.Equal(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.Exec("SAVEPOINT exec") + _, err = tx.ExecContext(ctx, "SAVEPOINT exec") if err != nil { h.log().WithError(err).Debug("create savepoint failed") return err } var shouldContinue bool defer func() { - _, errSave := tx.Exec("RELEASE SAVEPOINT exec") + _, errSave := tx.ExecContext(ctx, "RELEASE SAVEPOINT exec") if err == nil { err = errSave } @@ -644,9 +644,8 @@ func (h *Handler) eventQuery(currentState *state) *eventstore.SearchQueryBuilder OrderAsc(). InstanceID(currentState.instanceID) - 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.position.GreaterThan(decimal.Decimal{}) { + builder = builder.PositionGreaterEqual(currentState.position) 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 d3b6953488..c4afaed204 100644 --- a/internal/eventstore/handler/v2/state.go +++ b/internal/eventstore/handler/v2/state.go @@ -7,6 +7,8 @@ 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" @@ -14,7 +16,7 @@ import ( type state struct { instanceID string - position float64 + position decimal.Decimal eventTimestamp time.Time aggregateType eventstore.AggregateType aggregateID string @@ -45,7 +47,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(sql.NullFloat64) + position = new(decimal.NullDecimal) offset = new(sql.NullInt64) ) @@ -75,7 +77,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.Float64 + currentState.position = position.Decimal // 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 cc5fb1fbab..ef91d78e55 100644 --- a/internal/eventstore/handler/v2/state_test.go +++ b/internal/eventstore/handler/v2/state_test.go @@ -11,6 +11,7 @@ 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" @@ -166,7 +167,7 @@ func TestHandler_updateLastUpdated(t *testing.T) { updatedState: &state{ instanceID: "instance", eventTimestamp: time.Now(), - position: 42, + position: decimal.NewFromInt(42), }, }, isErr: func(t *testing.T, err error) { @@ -192,7 +193,7 @@ func TestHandler_updateLastUpdated(t *testing.T) { updatedState: &state{ instanceID: "instance", eventTimestamp: time.Now(), - position: 42, + position: decimal.NewFromInt(42), }, }, isErr: func(t *testing.T, err error) { @@ -217,7 +218,7 @@ func TestHandler_updateLastUpdated(t *testing.T) { eventstore.AggregateType("aggregate type"), uint64(42), mock.AnyType[time.Time]{}, - float64(42), + decimal.NewFromInt(42), uint32(0), ), mock.WithExecRowsAffected(1), @@ -228,7 +229,7 @@ func TestHandler_updateLastUpdated(t *testing.T) { updatedState: &state{ instanceID: "instance", eventTimestamp: time.Now(), - position: 42, + position: decimal.NewFromInt(42), aggregateType: "aggregate type", aggregateID: "aggregate id", sequence: 42, @@ -397,7 +398,7 @@ func TestHandler_currentState(t *testing.T) { "aggregate type", int64(42), testTime, - float64(42), + decimal.NewFromInt(42).String(), uint16(10), }, }, @@ -412,7 +413,7 @@ func TestHandler_currentState(t *testing.T) { currentState: &state{ instanceID: "instance", eventTimestamp: testTime, - position: 42, + position: decimal.NewFromInt(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 43d1818487..273789f015 100644 --- a/internal/eventstore/handler/v2/statement.go +++ b/internal/eventstore/handler/v2/statement.go @@ -9,6 +9,7 @@ import ( "strings" "time" + "github.com/shopspring/decimal" "github.com/zitadel/logging" "golang.org/x/exp/constraints" @@ -83,7 +84,7 @@ type Statement struct { AggregateType eventstore.AggregateType AggregateID string Sequence uint64 - Position float64 + Position decimal.Decimal CreationDate time.Time InstanceID string diff --git a/internal/eventstore/local_crdb_test.go b/internal/eventstore/local_crdb_test.go index 6df9e9fd29..e46509ba9f 100644 --- a/internal/eventstore/local_crdb_test.go +++ b/internal/eventstore/local_crdb_test.go @@ -2,13 +2,16 @@ 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" @@ -39,10 +42,19 @@ func TestMain(m *testing.M) { testCRDBClient = &database.DB{ Database: new(testDB), } - testCRDBClient.DB, err = sql.Open("postgres", ts.PGURL().String()) + config, err := pgxpool.ParseConfig(ts.PGURL().String()) if err != nil { - logging.WithFields("error", err).Fatal("unable to connect to db") + logging.WithFields("error", err).Fatal("unable to parse db config") } + 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") } @@ -103,10 +115,19 @@ func initDB(db *database.DB) error { } func connectLocalhost() (*database.DB, error) { - client, err := sql.Open("pgx", "postgresql://root@localhost:26257/defaultdb?sslmode=disable") + config, err := pgxpool.ParseConfig("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 d2c755cc3a..ae77275732 100644 --- a/internal/eventstore/read_model.go +++ b/internal/eventstore/read_model.go @@ -1,19 +1,23 @@ package eventstore -import "time" +import ( + "time" + + "github.com/shopspring/decimal" +) // 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 float64 `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 decimal.Decimal `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 57b85f15ba..2f4c1d8843 100644 --- a/internal/eventstore/repository/event.go +++ b/internal/eventstore/repository/event.go @@ -5,6 +5,8 @@ import ( "encoding/json" "time" + "github.com/shopspring/decimal" + "github.com/zitadel/zitadel/internal/eventstore" ) @@ -18,7 +20,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 float64 + Pos decimal.Decimal //CreationDate is the time the event is created // it's used for human readability. @@ -91,7 +93,7 @@ func (e *Event) Sequence() uint64 { } // Position implements [eventstore.Event] -func (e *Event) Position() float64 { +func (e *Event) Position() decimal.Decimal { return e.Pos } diff --git a/internal/eventstore/repository/mock/repository.mock.go b/internal/eventstore/repository/mock/repository.mock.go index a854de2995..ebd5f501a2 100644 --- a/internal/eventstore/repository/mock/repository.mock.go +++ b/internal/eventstore/repository/mock/repository.mock.go @@ -13,6 +13,7 @@ import ( context "context" reflect "reflect" + decimal "github.com/shopspring/decimal" eventstore "github.com/zitadel/zitadel/internal/eventstore" gomock "go.uber.org/mock/gomock" ) @@ -83,19 +84,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) } -// LatestSequence mocks base method. -func (m *MockQuerier) LatestSequence(arg0 context.Context, arg1 *eventstore.SearchQueryBuilder) (float64, error) { +// LatestPosition mocks base method. +func (m *MockQuerier) LatestPosition(arg0 context.Context, arg1 *eventstore.SearchQueryBuilder) (decimal.Decimal, error) { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "LatestSequence", arg0, arg1) - ret0, _ := ret[0].(float64) + ret := m.ctrl.Call(m, "LatestPosition", arg0, arg1) + ret0, _ := ret[0].(decimal.Decimal) ret1, _ := ret[1].(error) return ret0, ret1 } -// LatestSequence indicates an expected call of LatestSequence. -func (mr *MockQuerierMockRecorder) LatestSequence(arg0, arg1 any) *gomock.Call { +// LatestPosition indicates an expected call of LatestPosition. +func (mr *MockQuerierMockRecorder) LatestPosition(arg0, arg1 any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "LatestSequence", reflect.TypeOf((*MockQuerier)(nil).LatestSequence), arg0, arg1) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "LatestPosition", reflect.TypeOf((*MockQuerier)(nil).LatestPosition), 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 d41521ad8f..41ad4befd3 100644 --- a/internal/eventstore/repository/mock/repository.mock.impl.go +++ b/internal/eventstore/repository/mock/repository.mock.impl.go @@ -7,6 +7,7 @@ import ( "testing" "time" + "github.com/shopspring/decimal" "github.com/stretchr/testify/assert" "go.uber.org/mock/gomock" @@ -186,8 +187,8 @@ func (e *mockEvent) Sequence() uint64 { return e.sequence } -func (e *mockEvent) Position() float64 { - return 0 +func (e *mockEvent) Position() decimal.Decimal { + return decimal.Decimal{} } func (e *mockEvent) CreatedAt() time.Time { diff --git a/internal/eventstore/repository/search_query.go b/internal/eventstore/repository/search_query.go index 39cca8b149..b28574cc84 100644 --- a/internal/eventstore/repository/search_query.go +++ b/internal/eventstore/repository/search_query.go @@ -55,6 +55,8 @@ const ( //OperationNotIn checks if a stored value does not match one of the passed value list OperationNotIn + OperationGreaterEqual + operationCount ) @@ -232,10 +234,10 @@ func instanceIDsFilter(builder *eventstore.SearchQueryBuilder, query *SearchQuer } func positionAfterFilter(builder *eventstore.SearchQueryBuilder, query *SearchQuery) *Filter { - if builder.GetPositionAfter() == 0 { + if builder.GetPositionAfter().IsZero() { return nil } - query.Position = NewFilter(FieldPosition, builder.GetPositionAfter(), OperationGreater) + query.Position = NewFilter(FieldPosition, builder.GetPositionAfter(), OperationGreaterEqual) return query.Position } diff --git a/internal/eventstore/repository/sql/crdb.go b/internal/eventstore/repository/sql/crdb.go index a60a2ef7b8..c778015497 100644 --- a/internal/eventstore/repository/sql/crdb.go +++ b/internal/eventstore/repository/sql/crdb.go @@ -11,6 +11,7 @@ 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" @@ -265,11 +266,11 @@ func (crdb *CRDB) FilterToReducer(ctx context.Context, searchQuery *eventstore.S return err } -// 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 +// 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 err := query(ctx, db, searchQuery, &position, false) - return position.Float64, err + return position, err } // InstanceIDs returns the instance ids found by the search query @@ -336,7 +337,7 @@ func (db *CRDB) eventQuery(useV1 bool) string { " FROM eventstore.events2" } -func (db *CRDB) maxSequenceQuery(useV1 bool) string { +func (db *CRDB) maxPositionQuery(useV1 bool) string { if useV1 { return `SELECT event_sequence FROM eventstore.events` } @@ -414,6 +415,8 @@ 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 a3f3331a82..aae2fde78d 100644 --- a/internal/eventstore/repository/sql/crdb_test.go +++ b/internal/eventstore/repository/sql/crdb_test.go @@ -4,6 +4,8 @@ import ( "database/sql" "testing" + "github.com/shopspring/decimal" + "github.com/zitadel/zitadel/internal/eventstore" "github.com/zitadel/zitadel/internal/eventstore/repository" ) @@ -312,7 +314,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: 42, + Pos: decimal.NewFromInt(42), } for _, opt := range opts { diff --git a/internal/eventstore/repository/sql/query.go b/internal/eventstore/repository/sql/query.go index 3cddcb7924..c20bb62275 100644 --- a/internal/eventstore/repository/sql/query.go +++ b/internal/eventstore/repository/sql/query.go @@ -9,6 +9,7 @@ import ( "strconv" "strings" + "github.com/shopspring/decimal" "github.com/zitadel/logging" "github.com/zitadel/zitadel/internal/api/call" @@ -25,7 +26,7 @@ type querier interface { conditionFormat(repository.Operation) string placeholder(query string) string eventQuery(useV1 bool) string - maxSequenceQuery(useV1 bool) string + maxPositionQuery(useV1 bool) string instanceIDsQuery(useV1 bool) string db() *database.DB orderByEventSequence(desc, shouldOrderBySequence, useV1 bool) string @@ -74,7 +75,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.ColumnsMaxSequence { + if q.Columns == eventstore.ColumnsMaxPosition { q.Limit = 1 q.Desc = true } @@ -91,7 +92,7 @@ func query(ctx context.Context, criteria querier, searchQuery *eventstore.Search switch q.Columns { case eventstore.ColumnsEvent, - eventstore.ColumnsMaxSequence: + eventstore.ColumnsMaxPosition: query += criteria.orderByEventSequence(q.Desc, shouldOrderBySequence, useV1) } @@ -135,8 +136,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.ColumnsMaxSequence: - return criteria.maxSequenceQuery(useV1), maxSequenceScanner + case eventstore.ColumnsMaxPosition: + return criteria.maxPositionQuery(useV1), maxPositionScanner case eventstore.ColumnsInstanceIDs: return criteria.instanceIDsQuery(useV1), instanceIDsScanner case eventstore.ColumnsEvent: @@ -154,13 +155,15 @@ func prepareTimeTravel(ctx context.Context, criteria querier, allow bool) string return criteria.Timetravel(took) } -func maxSequenceScanner(row scan, dest interface{}) (err error) { - position, ok := dest.(*sql.NullFloat64) +func maxPositionScanner(row scan, dest interface{}) (err error) { + position, ok := dest.(*decimal.Decimal) if !ok { - return zerrors.ThrowInvalidArgumentf(nil, "SQL-NBjA9", "type must be sql.NullInt64 got: %T", dest) + return zerrors.ThrowInvalidArgumentf(nil, "SQL-NBjA9", "type must be decimal.Decimal got: %T", dest) } - err = row(position) + var res decimal.NullDecimal + err = row(&res) if err == nil || errors.Is(err, sql.ErrNoRows) { + *position = res.Decimal return nil } return zerrors.ThrowInternal(err, "SQL-bN5xg", "something went wrong") @@ -189,7 +192,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(sql.NullFloat64) + position := new(decimal.NullDecimal) if useV1 { err = scanner( @@ -226,7 +229,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.Float64 + event.Pos = position.Decimal return reduce(event) } } diff --git a/internal/eventstore/repository/sql/query_test.go b/internal/eventstore/repository/sql/query_test.go index 5d54b27c21..654fa6d0b5 100644 --- a/internal/eventstore/repository/sql/query_test.go +++ b/internal/eventstore/repository/sql/query_test.go @@ -10,6 +10,7 @@ import ( "time" "github.com/DATA-DOG/go-sqlmock" + "github.com/shopspring/decimal" "github.com/stretchr/testify/assert" "github.com/zitadel/zitadel/internal/database" @@ -109,36 +110,36 @@ func Test_prepareColumns(t *testing.T) { { name: "max column", args: args{ - columns: eventstore.ColumnsMaxSequence, - dest: new(sql.NullFloat64), + columns: eventstore.ColumnsMaxPosition, + dest: new(decimal.Decimal), useV1: true, }, res: res{ query: `SELECT event_sequence FROM eventstore.events`, - expected: sql.NullFloat64{Float64: 43, Valid: true}, + expected: decimal.NewFromInt(42), }, fields: fields{ - dbRow: []interface{}{sql.NullFloat64{Float64: 43, Valid: true}}, + dbRow: []interface{}{decimal.NewNullDecimal(decimal.NewFromInt(42))}, }, }, { name: "max column v2", args: args{ - columns: eventstore.ColumnsMaxSequence, - dest: new(sql.NullFloat64), + columns: eventstore.ColumnsMaxPosition, + dest: new(decimal.Decimal), }, res: res{ query: `SELECT "position" FROM eventstore.events2`, - expected: sql.NullFloat64{Float64: 43, Valid: true}, + expected: decimal.NewFromInt(42), }, fields: fields{ - dbRow: []interface{}{sql.NullFloat64{Float64: 43, Valid: true}}, + dbRow: []interface{}{decimal.NewNullDecimal(decimal.NewFromInt(42))}, }, }, { name: "max sequence wrong dest type", args: args{ - columns: eventstore.ColumnsMaxSequence, + columns: eventstore.ColumnsMaxPosition, dest: new(uint64), }, res: res{ @@ -178,11 +179,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: 42, Data: nil, Version: "v1"}, + &repository.Event{AggregateID: "hodor", AggregateType: "user", Seq: 5, Pos: decimal.NewFromInt(42), Data: nil, Version: "v1"}, }, }, fields: fields{ - 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)}, + dbRow: []interface{}{time.Time{}, eventstore.EventType(""), uint64(5), decimal.NewNullDecimal(decimal.NewFromInt(42)), sql.RawBytes(nil), "", sql.NullString{}, "", eventstore.AggregateType("user"), "hodor", uint8(1)}, }, }, { @@ -197,11 +198,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: 0, Data: nil, Version: "v1"}, + &repository.Event{AggregateID: "hodor", AggregateType: "user", Seq: 5, Pos: decimal.Decimal{}, Data: nil, Version: "v1"}, }, }, fields: fields{ - 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)}, + dbRow: []interface{}{time.Time{}, eventstore.EventType(""), uint64(5), decimal.NullDecimal{}, 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 0c08a260eb..c7f9a65da3 100644 --- a/internal/eventstore/search_query.go +++ b/internal/eventstore/search_query.go @@ -5,6 +5,8 @@ import ( "database/sql" "time" + "github.com/shopspring/decimal" + "github.com/zitadel/zitadel/internal/api/authz" "github.com/zitadel/zitadel/internal/zerrors" ) @@ -23,7 +25,7 @@ type SearchQueryBuilder struct { queries []*SearchQuery tx *sql.Tx allowTimeTravel bool - positionAfter float64 + positionGreaterEqual decimal.Decimal awaitOpenTransactions bool creationDateAfter time.Time creationDateBefore time.Time @@ -74,8 +76,8 @@ func (b *SearchQueryBuilder) GetAllowTimeTravel() bool { return b.allowTimeTravel } -func (b SearchQueryBuilder) GetPositionAfter() float64 { - return b.positionAfter +func (b SearchQueryBuilder) GetPositionAfter() decimal.Decimal { + return b.positionGreaterEqual } func (b SearchQueryBuilder) GetAwaitOpenTransactions() bool { @@ -131,8 +133,8 @@ type Columns int8 const ( //ColumnsEvent represents all fields of an event ColumnsEvent = iota + 1 - // ColumnsMaxSequence represents the latest sequence of the filtered events - ColumnsMaxSequence + // ColumnsMaxPosition represents the latest sequence of the filtered events + ColumnsMaxPosition // ColumnsInstanceIDs represents the instance ids of the filtered events ColumnsInstanceIDs @@ -267,8 +269,8 @@ func (builder *SearchQueryBuilder) AllowTimeTravel() *SearchQueryBuilder { } // PositionAfter filters for events which happened after the specified time -func (builder *SearchQueryBuilder) PositionAfter(position float64) *SearchQueryBuilder { - builder.positionAfter = position +func (builder *SearchQueryBuilder) PositionGreaterEqual(position decimal.Decimal) *SearchQueryBuilder { + builder.positionGreaterEqual = position return builder } diff --git a/internal/eventstore/search_query_test.go b/internal/eventstore/search_query_test.go index 8c654911ea..da8acf878d 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(ColumnsMaxSequence)}, + setters: []func(*SearchQueryBuilder) *SearchQueryBuilder{testSetColumns(ColumnsMaxPosition)}, }, res: &SearchQueryBuilder{ - columns: ColumnsMaxSequence, + columns: ColumnsMaxPosition, }, }, { diff --git a/internal/eventstore/v1/models/event.go b/internal/eventstore/v1/models/event.go index 8c50d64da0..ab2b608872 100644 --- a/internal/eventstore/v1/models/event.go +++ b/internal/eventstore/v1/models/event.go @@ -5,6 +5,8 @@ import ( "reflect" "time" + "github.com/shopspring/decimal" + "github.com/zitadel/zitadel/internal/eventstore" "github.com/zitadel/zitadel/internal/zerrors" ) @@ -20,7 +22,7 @@ var _ eventstore.Event = (*Event)(nil) type Event struct { ID string Seq uint64 - Pos float64 + Pos decimal.Decimal CreationDate time.Time Typ eventstore.EventType PreviousSequence uint64 @@ -80,7 +82,7 @@ func (e *Event) Sequence() uint64 { } // Position implements [eventstore.Event] -func (e *Event) Position() float64 { +func (e *Event) Position() decimal.Decimal { return e.Pos } diff --git a/internal/eventstore/v3/event.go b/internal/eventstore/v3/event.go index e1c95f13ff..f489d98396 100644 --- a/internal/eventstore/v3/event.go +++ b/internal/eventstore/v3/event.go @@ -4,6 +4,7 @@ import ( "encoding/json" "time" + "github.com/shopspring/decimal" "github.com/zitadel/logging" "github.com/zitadel/zitadel/internal/eventstore" @@ -21,7 +22,7 @@ type event struct { typ eventstore.EventType createdAt time.Time sequence uint64 - position float64 + position decimal.Decimal payload Payload } @@ -84,8 +85,8 @@ func (e *event) Sequence() uint64 { return e.sequence } -// Sequence implements [eventstore.Event] -func (e *event) Position() float64 { +// Position implements [eventstore.Event] +func (e *event) Position() decimal.Decimal { return e.position } diff --git a/internal/query/access_token.go b/internal/query/access_token.go index 4180a6ad5e..a5edc068cd 100644 --- a/internal/query/access_token.go +++ b/internal/query/access_token.go @@ -5,6 +5,7 @@ import ( "strings" "time" + "github.com/shopspring/decimal" "golang.org/x/text/language" "github.com/zitadel/zitadel/internal/domain" @@ -140,7 +141,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 float64, fingerprintID string) (err error) { +func (q *Queries) checkSessionNotTerminatedAfter(ctx context.Context, sessionID, userID string, position decimal.Decimal, fingerprintID string) (err error) { ctx, span := tracing.NewSpan(ctx) defer func() { span.EndWithError(err) }() @@ -162,7 +163,7 @@ func (q *Queries) checkSessionNotTerminatedAfter(ctx context.Context, sessionID, } type sessionTerminatedModel struct { - position float64 + position decimal.Decimal sessionID string userID string fingerPrintID string @@ -182,7 +183,7 @@ func (s *sessionTerminatedModel) AppendEvents(events ...eventstore.Event) { func (s *sessionTerminatedModel) Query() *eventstore.SearchQueryBuilder { query := eventstore.NewSearchQueryBuilder(eventstore.ColumnsEvent). - PositionAfter(s.position). + PositionGreaterEqual(s.position). AddQuery(). AggregateTypes(session.AggregateType). AggregateIDs(s.sessionID). diff --git a/internal/query/current_state.go b/internal/query/current_state.go index 29497e6eec..790b594c2d 100644 --- a/internal/query/current_state.go +++ b/internal/query/current_state.go @@ -10,6 +10,7 @@ import ( "time" sq "github.com/Masterminds/squirrel" + "github.com/shopspring/decimal" "github.com/zitadel/logging" "github.com/zitadel/zitadel/internal/api/call" @@ -26,7 +27,7 @@ type Stateful interface { type State struct { LastRun time.Time - Position float64 + Position decimal.Decimal EventCreatedAt time.Time AggregateID string AggregateType eventstore.AggregateType @@ -221,7 +222,7 @@ func prepareLatestState(ctx context.Context, db prepareDatabase) (sq.SelectBuild var ( creationDate sql.NullTime lastUpdated sql.NullTime - position sql.NullFloat64 + position decimal.NullDecimal ) err := row.Scan( &creationDate, @@ -234,7 +235,7 @@ func prepareLatestState(ctx context.Context, db prepareDatabase) (sq.SelectBuild return &State{ EventCreatedAt: creationDate.Time, LastRun: lastUpdated.Time, - Position: position.Float64, + Position: position.Decimal, }, nil } } @@ -259,7 +260,7 @@ func prepareCurrentStateQuery(ctx context.Context, db prepareDatabase) (sq.Selec var ( lastRun sql.NullTime eventDate sql.NullTime - currentPosition sql.NullFloat64 + currentPosition decimal.NullDecimal aggregateType sql.NullString aggregateID sql.NullString sequence sql.NullInt64 @@ -280,7 +281,7 @@ func prepareCurrentStateQuery(ctx context.Context, db prepareDatabase) (sq.Selec } currentState.State.EventCreatedAt = eventDate.Time currentState.State.LastRun = lastRun.Time - currentState.Position = currentPosition.Float64 + currentState.Position = currentPosition.Decimal 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 c76dae710e..f17509aa9c 100644 --- a/internal/query/current_state_test.go +++ b/internal/query/current_state_test.go @@ -7,6 +7,8 @@ import ( "fmt" "regexp" "testing" + + "github.com/shopspring/decimal" ) var ( @@ -87,7 +89,7 @@ func Test_CurrentSequencesPrepares(t *testing.T) { State: State{ EventCreatedAt: testNow, LastRun: testNow, - Position: 20211108, + Position: decimal.NewFromInt(20211108), AggregateID: "agg-id", AggregateType: "agg-type", Sequence: 20211108, @@ -134,7 +136,7 @@ func Test_CurrentSequencesPrepares(t *testing.T) { ProjectionName: "projection-name", State: State{ EventCreatedAt: testNow, - Position: 20211108, + Position: decimal.NewFromInt(20211108), LastRun: testNow, AggregateID: "agg-id", AggregateType: "agg-type", @@ -145,7 +147,7 @@ func Test_CurrentSequencesPrepares(t *testing.T) { ProjectionName: "projection-name2", State: State{ EventCreatedAt: testNow, - Position: 20211108, + Position: decimal.NewFromInt(20211108), LastRun: testNow, AggregateID: "agg-id", AggregateType: "agg-type", diff --git a/internal/query/user_grant.go b/internal/query/user_grant.go index 71031b5136..c8e1dc05a7 100644 --- a/internal/query/user_grant.go +++ b/internal/query/user_grant.go @@ -277,7 +277,7 @@ func (q *Queries) UserGrants(ctx context.Context, queries *UserGrantsQueries, sh return nil, zerrors.ThrowInternal(err, "QUERY-wXnQR", "Errors.Query.SQLStatement") } - latestSequence, err := q.latestState(ctx, userGrantTable) + latestState, err := q.latestState(ctx, userGrantTable) if err != nil { return nil, err } @@ -290,7 +290,7 @@ func (q *Queries) UserGrants(ctx context.Context, queries *UserGrantsQueries, sh return nil, err } - grants.State = latestSequence + grants.State = latestState return grants, nil } diff --git a/internal/query/user_membership.go b/internal/query/user_membership.go index 7ba2629cfa..a08617d57b 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") } - latestSequence, err := q.latestState(ctx, orgMemberTable, instanceMemberTable, projectMemberTable, projectGrantMemberTable) + latestState, 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 = latestSequence + memberships.State = latestState return memberships, nil } diff --git a/internal/v2/database/number_filter.go b/internal/v2/database/number_filter.go index ce263ceeee..4853806457 100644 --- a/internal/v2/database/number_filter.go +++ b/internal/v2/database/number_filter.go @@ -3,6 +3,7 @@ package database import ( "time" + "github.com/shopspring/decimal" "github.com/zitadel/logging" "golang.org/x/exp/constraints" ) @@ -94,7 +95,7 @@ func (c numberCompare) String() string { } type number interface { - constraints.Integer | constraints.Float | time.Time + constraints.Integer | constraints.Float | time.Time | decimal.Decimal // 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 cc447c5e15..e89786c657 100644 --- a/internal/v2/eventstore/event_store.go +++ b/internal/v2/eventstore/event_store.go @@ -2,6 +2,8 @@ package eventstore import ( "context" + + "github.com/shopspring/decimal" ) func NewEventstore(querier Querier, pusher Pusher) *EventStore { @@ -30,12 +32,12 @@ type healthier interface { } type GlobalPosition struct { - Position float64 + Position decimal.Decimal InPositionOrder uint32 } func (gp GlobalPosition) IsLess(other GlobalPosition) bool { - return gp.Position < other.Position || (gp.Position == other.Position && gp.InPositionOrder < other.InPositionOrder) + return gp.Position.LessThan(other.Position) || (gp.Position.Equal(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 91fdc1fcd7..6f8b224c41 100644 --- a/internal/v2/eventstore/postgres/push_test.go +++ b/internal/v2/eventstore/postgres/push_test.go @@ -8,6 +8,8 @@ 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" @@ -818,7 +820,7 @@ func Test_push(t *testing.T) { [][]driver.Value{ { time.Now(), - float64(123), + decimal.NewFromFloat(123).String(), }, }, ), @@ -899,11 +901,11 @@ func Test_push(t *testing.T) { [][]driver.Value{ { time.Now(), - float64(123), + decimal.NewFromFloat(123).String(), }, { time.Now(), - float64(123.1), + decimal.NewFromFloat(123.1).String(), }, }, ), @@ -984,11 +986,11 @@ func Test_push(t *testing.T) { [][]driver.Value{ { time.Now(), - float64(123), + decimal.NewFromFloat(123).String(), }, { time.Now(), - float64(123.1), + decimal.NewFromFloat(123.1).String(), }, }, ), @@ -1044,7 +1046,7 @@ func Test_push(t *testing.T) { [][]driver.Value{ { time.Now(), - float64(123), + decimal.NewFromFloat(123).String(), }, }, ), @@ -1099,7 +1101,7 @@ func Test_push(t *testing.T) { [][]driver.Value{ { time.Now(), - float64(123), + decimal.NewFromFloat(123).String(), }, }, ), @@ -1181,11 +1183,11 @@ func Test_push(t *testing.T) { [][]driver.Value{ { time.Now(), - float64(123), + decimal.NewFromFloat(123).String(), }, { time.Now(), - float64(123.1), + decimal.NewFromFloat(123.1).String(), }, }, ), @@ -1272,11 +1274,11 @@ func Test_push(t *testing.T) { [][]driver.Value{ { time.Now(), - float64(123), + decimal.NewFromFloat(123).String(), }, { time.Now(), - float64(123.1), + decimal.NewFromFloat(123.1).String(), }, }, ), diff --git a/internal/v2/eventstore/postgres/query_test.go b/internal/v2/eventstore/postgres/query_test.go index 56f506ac50..34b73bd820 100644 --- a/internal/v2/eventstore/postgres/query_test.go +++ b/internal/v2/eventstore/postgres/query_test.go @@ -8,6 +8,8 @@ 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" @@ -541,13 +543,13 @@ func Test_writeFilter(t *testing.T) { args: args{ filter: eventstore.NewFilter( eventstore.FilterPagination( - eventstore.PositionGreater(123.4, 0), + eventstore.PositionGreater(decimal.NewFromFloat(123.4), 0), ), ), }, want: wantQuery{ query: " WHERE instance_id = $1 AND position > $2 ORDER BY position, in_tx_order", - args: []any{"i1", 123.4}, + args: []any{"i1", decimal.NewFromFloat(123.4)}, }, }, { @@ -555,18 +557,18 @@ func Test_writeFilter(t *testing.T) { args: args{ filter: eventstore.NewFilter( eventstore.FilterPagination( - // eventstore.PositionGreater(123.4, 0), + // eventstore.PositionGreater(decimal.NewFromFloat(123.4), 0), // eventstore.PositionLess(125.4, 10), eventstore.PositionBetween( - &eventstore.GlobalPosition{Position: 123.4}, - &eventstore.GlobalPosition{Position: 125.4, InPositionOrder: 10}, + &eventstore.GlobalPosition{Position: decimal.NewFromFloat(123.4)}, + &eventstore.GlobalPosition{Position: decimal.NewFromFloat(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", 125.4, uint32(10), 125.4, 123.4}, + args: []any{"i1", decimal.NewFromFloat(125.4), uint32(10), decimal.NewFromFloat(125.4), decimal.NewFromFloat(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)}, @@ -577,13 +579,13 @@ func Test_writeFilter(t *testing.T) { args: args{ filter: eventstore.NewFilter( eventstore.FilterPagination( - eventstore.PositionGreater(123.4, 12), + eventstore.PositionGreater(decimal.NewFromFloat(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", 123.4, uint32(12), 123.4}, + args: []any{"i1", decimal.NewFromFloat(123.4), uint32(12), decimal.NewFromFloat(123.4)}, }, }, { @@ -593,13 +595,13 @@ func Test_writeFilter(t *testing.T) { eventstore.FilterPagination( eventstore.Limit(10), eventstore.Offset(3), - eventstore.PositionGreater(123.4, 12), + eventstore.PositionGreater(decimal.NewFromFloat(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", 123.4, uint32(12), 123.4, uint32(10), uint32(3)}, + args: []any{"i1", decimal.NewFromFloat(123.4), uint32(12), decimal.NewFromFloat(123.4), uint32(10), uint32(3)}, }, }, { @@ -609,14 +611,14 @@ func Test_writeFilter(t *testing.T) { eventstore.FilterPagination( eventstore.Limit(10), eventstore.Offset(3), - eventstore.PositionGreater(123.4, 12), + eventstore.PositionGreater(decimal.NewFromFloat(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", 123.4, uint32(12), 123.4, uint32(10), uint32(3)}, + args: []any{"i1", "user", decimal.NewFromFloat(123.4), uint32(12), decimal.NewFromFloat(123.4), uint32(10), uint32(3)}, }, }, { @@ -626,7 +628,7 @@ func Test_writeFilter(t *testing.T) { eventstore.FilterPagination( eventstore.Limit(10), eventstore.Offset(3), - eventstore.PositionGreater(123.4, 12), + eventstore.PositionGreater(decimal.NewFromFloat(123.4), 12), ), eventstore.AppendAggregateFilter("user"), eventstore.AppendAggregateFilter( @@ -637,7 +639,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", 123.4, uint32(12), 123.4, uint32(10), uint32(3)}, + args: []any{"i1", "user", "org", "o1", decimal.NewFromFloat(123.4), uint32(12), decimal.NewFromFloat(123.4), uint32(10), uint32(3)}, }, }, } @@ -956,7 +958,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(12, 4), + eventstore.PositionGreater(decimal.NewFromInt(12), 4), ), ), eventstore.NewFilter( @@ -1065,9 +1067,9 @@ func Test_writeQueryUse_examples(t *testing.T) { "instance", "user", "user.token.added", - float64(12), + decimal.NewFromInt(12), uint32(4), - float64(12), + decimal.NewFromInt(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"}, @@ -1201,7 +1203,7 @@ func Test_executeQuery(t *testing.T) { time.Now(), "event.type", uint32(23), - float64(123), + decimal.NewFromInt(123).String(), uint32(0), nil, "gigi", @@ -1235,7 +1237,7 @@ func Test_executeQuery(t *testing.T) { time.Now(), "event.type", uint32(23), - float64(123), + decimal.NewFromInt(123).String(), uint32(0), []byte(`{"name": "gigi"}`), "gigi", @@ -1269,7 +1271,7 @@ func Test_executeQuery(t *testing.T) { time.Now(), "event.type", uint32(23), - float64(123), + decimal.NewFromInt(123).String(), uint32(0), nil, "gigi", @@ -1283,7 +1285,7 @@ func Test_executeQuery(t *testing.T) { time.Now(), "event.type", uint32(24), - float64(124), + decimal.NewFromInt(124).String(), uint32(0), []byte(`{"name": "gigi"}`), "gigi", @@ -1317,7 +1319,7 @@ func Test_executeQuery(t *testing.T) { time.Now(), "event.type", uint32(23), - float64(123), + decimal.NewFromInt(123).String(), uint32(0), nil, "gigi", @@ -1331,7 +1333,7 @@ func Test_executeQuery(t *testing.T) { time.Now(), "event.type", uint32(24), - float64(124), + decimal.NewFromInt(124).String(), uint32(0), []byte(`{"name": "gigi"}`), "gigi", diff --git a/internal/v2/eventstore/query.go b/internal/v2/eventstore/query.go index c9b3cecd37..f7a30a2139 100644 --- a/internal/v2/eventstore/query.go +++ b/internal/v2/eventstore/query.go @@ -7,6 +7,8 @@ import ( "slices" "time" + "github.com/shopspring/decimal" + "github.com/zitadel/zitadel/internal/v2/database" ) @@ -723,7 +725,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 float64, inPositionOrder uint32) paginationOpt { +func PositionGreater(position decimal.Decimal, inPositionOrder uint32) paginationOpt { return func(p *Pagination) { p.ensurePosition() p.position.min = &GlobalPosition{ @@ -743,7 +745,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 float64, inPositionOrder uint32) paginationOpt { +func PositionLess(position decimal.Decimal, 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 00c08914c1..0f313e9560 100644 --- a/internal/v2/eventstore/query_test.go +++ b/internal/v2/eventstore/query_test.go @@ -6,6 +6,8 @@ import ( "testing" "time" + "github.com/shopspring/decimal" + "github.com/zitadel/zitadel/internal/v2/database" ) @@ -74,13 +76,13 @@ func TestPaginationOpt(t *testing.T) { name: "global position greater", args: args{ opts: []paginationOpt{ - GlobalPositionGreater(&GlobalPosition{Position: 10}), + GlobalPositionGreater(&GlobalPosition{Position: decimal.NewFromInt(10)}), }, }, want: &Pagination{ position: &PositionCondition{ min: &GlobalPosition{ - Position: 10, + Position: decimal.NewFromInt(10), InPositionOrder: 0, }, }, @@ -90,13 +92,13 @@ func TestPaginationOpt(t *testing.T) { name: "position greater", args: args{ opts: []paginationOpt{ - PositionGreater(10, 0), + PositionGreater(decimal.NewFromInt(10), 0), }, }, want: &Pagination{ position: &PositionCondition{ min: &GlobalPosition{ - Position: 10, + Position: decimal.NewFromInt(10), InPositionOrder: 0, }, }, @@ -107,13 +109,13 @@ func TestPaginationOpt(t *testing.T) { name: "position less", args: args{ opts: []paginationOpt{ - PositionLess(10, 12), + PositionLess(decimal.NewFromInt(10), 12), }, }, want: &Pagination{ position: &PositionCondition{ max: &GlobalPosition{ - Position: 10, + Position: decimal.NewFromInt(10), InPositionOrder: 12, }, }, @@ -123,13 +125,13 @@ func TestPaginationOpt(t *testing.T) { name: "global position less", args: args{ opts: []paginationOpt{ - GlobalPositionLess(&GlobalPosition{Position: 12, InPositionOrder: 24}), + GlobalPositionLess(&GlobalPosition{Position: decimal.NewFromInt(12), InPositionOrder: 24}), }, }, want: &Pagination{ position: &PositionCondition{ max: &GlobalPosition{ - Position: 12, + Position: decimal.NewFromInt(12), InPositionOrder: 24, }, }, @@ -140,19 +142,19 @@ func TestPaginationOpt(t *testing.T) { args: args{ opts: []paginationOpt{ PositionBetween( - &GlobalPosition{10, 12}, - &GlobalPosition{20, 0}, + &GlobalPosition{decimal.NewFromInt(10), 12}, + &GlobalPosition{decimal.NewFromInt(20), 0}, ), }, }, want: &Pagination{ position: &PositionCondition{ min: &GlobalPosition{ - Position: 10, + Position: decimal.NewFromInt(10), InPositionOrder: 12, }, max: &GlobalPosition{ - Position: 20, + Position: decimal.NewFromInt(20), InPositionOrder: 0, }, }, diff --git a/internal/v2/readmodel/last_successful_mirror.go b/internal/v2/readmodel/last_successful_mirror.go index 80b436b896..6635b73342 100644 --- a/internal/v2/readmodel/last_successful_mirror.go +++ b/internal/v2/readmodel/last_successful_mirror.go @@ -1,6 +1,8 @@ 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" @@ -8,7 +10,7 @@ import ( type LastSuccessfulMirror struct { ID string - Position float64 + Position decimal.Decimal source string } @@ -53,7 +55,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 > 0 { + if h.Position.GreaterThan(decimal.NewFromInt(0)) { return nil } diff --git a/internal/v2/system/mirror/succeeded.go b/internal/v2/system/mirror/succeeded.go index 6d0fba2c25..34d74f184f 100644 --- a/internal/v2/system/mirror/succeeded.go +++ b/internal/v2/system/mirror/succeeded.go @@ -1,6 +1,8 @@ package mirror import ( + "github.com/shopspring/decimal" + "github.com/zitadel/zitadel/internal/v2/eventstore" "github.com/zitadel/zitadel/internal/zerrors" ) @@ -9,7 +11,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 float64 `json:"position"` + Position decimal.Decimal `json:"position"` } const SucceededType = eventTypePrefix + "succeeded" @@ -38,7 +40,7 @@ func SucceededEventFromStorage(event *eventstore.StorageEvent) (e *SucceededEven }, nil } -func NewSucceededCommand(source string, position float64) *eventstore.Command { +func NewSucceededCommand(source string, position decimal.Decimal) *eventstore.Command { return &eventstore.Command{ Action: eventstore.Action[any]{ Creator: Creator, diff --git a/load-test/Makefile b/load-test/Makefile index 4d87760eca..c0fdbb673c 100644 --- a/load-test/Makefile +++ b/load-test/Makefile @@ -4,42 +4,43 @@ 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=. - ./../../xk6-modules/k6 run --summary-trend-stats "min,avg,max,p(50),p(95),p(99)" dist/introspection.js --vus ${VUS} --duration ${DURATION} + ${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=. - ./../../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} + ${K6} run --summary-trend-stats "min,avg,max,p(50),p(95),p(99)" dist/machine_jwt_profile_grant.js --vus ${VUS} --duration ${DURATION} .PHONY: lint lint: @@ -58,6 +59,8 @@ 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 2ea53bd324..058bde05f1 100644 --- a/load-test/src/use_cases/manipulate_user.ts +++ b/load-test/src/use_cases/manipulate_user.ts @@ -45,3 +45,4 @@ export function teardown(data: any) { removeOrg(data.org, data.tokens.accessToken); console.info('teardown: org removed'); } + From d2e0ac07f12884c7a569ad07b9e5f48305792a0a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20M=C3=B6hlmann?= Date: Fri, 6 Sep 2024 15:47:57 +0300 Subject: [PATCH 06/33] chore(tests): use a coverage server binary (#8407) # Which Problems Are Solved Use a single server instance for API integration tests. This optimizes the time taken for the integration test pipeline, because it allows running tests on multiple packages in parallel. Also, it saves time by not start and stopping a zitadel server for every package. # How the Problems Are Solved - Build a binary with `go build -race -cover ....` - Integration tests only construct clients. The server remains running in the background. - The integration package and tested packages now fully utilize the API. No more direct database access trough `query` and `command` packages. - Use Makefile recipes to setup, start and stop the server in the background. - The binary has the race detector enabled - Init and setup jobs are configured to halt immediately on race condition - Because the server runs in the background, races are only logged. When the server is stopped and race logs exist, the Makefile recipe will throw an error and print the logs. - Makefile recipes include logic to print logs and convert coverage reports after the server is stopped. - Some tests need a downstream HTTP server to make requests, like quota and milestones. A new `integration/sink` package creates an HTTP server and uses websockets to forward HTTP request back to the test packages. The package API uses Go channels for abstraction and easy usage. # Additional Changes - Integration test files already used the `//go:build integration` directive. In order to properly split integration from unit tests, integration test files need to be in a `integration_test` subdirectory of their package. - `UseIsolatedInstance` used to overwrite the `Tester.Client` for each instance. Now a `Instance` object is returned with a gRPC client that is connected to the isolated instance's hostname. - The `Tester` type is now `Instance`. The object is created for the first instance, used by default in any test. Isolated instances are also `Instance` objects and therefore benefit from the same methods and values. The first instance and any other us capable of creating an isolated instance over the system API. - All test packages run in an Isolated instance by calling `NewInstance()` - Individual tests that use an isolated instance use `t.Parallel()` # Additional Context - Closes #6684 - https://go.dev/doc/articles/race_detector - https://go.dev/doc/build-cover --------- Co-authored-by: Stefan Benz <46600784+stebenz@users.noreply.github.com> --- .github/workflows/build.yml | 16 +- ...ore-test.yml => core-integration-test.yml} | 7 +- .github/workflows/core-unit-test.yml | 76 +++ CONTRIBUTING.md | 46 +- Makefile | 59 ++- cmd/start/start.go | 5 + go.mod | 3 +- go.sum | 1 + .../iam_member_test.go} | 52 +- .../iam_settings_test.go} | 47 +- .../import_test.go} | 0 .../information_test.go} | 2 +- ...ns_allow_public_org_registrations_test.go} | 46 +- .../restrictions_allowed_languages_test.go} | 75 +-- .../server_test.go} | 19 +- .../feature_test.go} | 20 +- .../feature_test.go} | 228 +-------- internal/api/grpc/fields.go | 14 +- .../query_test.go} | 8 +- .../idp/v2/integration_test/server_test.go | 36 ++ .../grpc/idp/v2/server_integration_test.go | 40 -- .../org_test.go} | 44 +- .../server_test.go} | 12 +- .../user_test.go} | 10 +- .../oidc_test.go} | 51 +- .../oidc_test.go} | 51 +- .../org_test.go} | 31 +- .../query_test.go} | 76 +-- .../org_test.go} | 25 +- .../execution_target_test.go} | 67 +-- .../execution_test.go} | 108 +++-- .../query_test.go} | 142 +++--- .../v3alpha/integration_test/server_test.go | 70 +++ .../target_test.go} | 100 ++-- .../action/v3alpha/server_integration_test.go | 68 --- .../server_test.go} | 23 +- .../user_test.go} | 20 +- .../query_test.go} | 14 +- .../server_test.go} | 21 +- .../userschema_test.go} | 54 +-- .../webkey_integration_test.go | 80 ++-- internal/api/grpc/server/server.go | 1 + .../session_test.go} | 135 +++--- .../session_test.go} | 133 +++--- .../server_test.go} | 11 +- .../settings_test.go} | 35 +- .../server_test.go} | 11 +- .../settings_test.go} | 35 +- .../instance_test.go} | 15 +- .../limits_auditlogretention_test.go} | 96 ++-- .../limits_block_test.go} | 67 +-- .../limits_bulk_test.go} | 4 +- .../quotas_enabled/quota_test.go} | 82 ++-- .../quotas_enabled/server_test.go | 23 + .../system/integration_test/server_test.go | 24 + .../quotas_enabled/server_integration_test.go | 37 -- .../grpc/system/server_integration_test.go | 32 -- .../email_test.go} | 27 +- .../idp_link_test.go} | 73 +-- .../otp_test.go} | 115 +++-- .../passkey_test.go} | 41 +- .../password_test.go} | 23 +- .../phone_test.go} | 41 +- .../query_test.go} | 39 +- .../totp_test.go} | 63 ++- .../u2f_test.go} | 45 +- .../user_test.go} | 275 ++++++----- .../email_test.go} | 24 +- .../otp_test.go} | 96 ++-- .../passkey_test.go} | 28 +- .../password_test.go} | 20 +- .../phone_test.go} | 38 +- .../query_test.go} | 32 +- .../totp_test.go} | 71 +-- .../u2f_test.go} | 34 +- .../user_test.go} | 290 ++++++----- .../idp_test.go} | 42 +- .../auth_request_test.go} | 182 ++++--- .../client_test.go} | 62 +-- .../keys_test.go} | 30 +- .../oidc_test.go} | 165 +++---- .../server_test.go} | 6 +- .../token_client_credentials_test.go} | 32 +- .../token_exchange_test.go} | 42 +- .../token_jwt_profile_test.go} | 32 +- .../userinfo_test.go} | 73 ++- internal/api/oidc/server_test.go | 3 +- internal/command/org_idp_test.go | 2 +- internal/integration/assert.go | 12 +- internal/integration/client.go | 450 ++++++++---------- internal/integration/config.go | 53 +++ internal/integration/config/client.yaml | 10 + .../integration/config/docker-compose.yaml | 2 +- internal/integration/config/postgres.yaml | 6 +- internal/integration/config/steps.yaml | 13 + internal/integration/config/zitadel.yaml | 17 +- internal/integration/instance.go | 354 ++++++++++++++ internal/integration/integration.go | 449 ----------------- internal/integration/integration_test.go | 16 - internal/integration/oidc.go | 119 ++--- internal/integration/sink/channel.go | 9 + internal/integration/sink/channel_enumer.go | 78 +++ internal/integration/sink/server.go | 167 +++++++ internal/integration/sink/sink.go | 4 + internal/integration/sink/stub.go | 9 + internal/integration/sink/subscription.go | 90 ++++ internal/integration/system.go | 59 +++ internal/integration/user.go | 10 +- internal/integration/usertype_enumer.go | 86 ++++ internal/integration/usertype_string.go | 27 -- .../handlers/handlers_integration_test.go | 36 -- .../integration_test/handlers_test.go | 23 + .../telemetry_pusher_test.go} | 68 +-- .../handlers/quota_notifier_test.go | 155 ------ internal/webauthn/webauthn.go | 4 +- 115 files changed, 3632 insertions(+), 3348 deletions(-) rename .github/workflows/{core-test.yml => core-integration-test.yml} (95%) create mode 100644 .github/workflows/core-unit-test.yml rename internal/api/grpc/admin/{iam_member_integration_test.go => integration_test/iam_member_test.go} (82%) rename internal/api/grpc/admin/{iam_settings_integration_test.go => integration_test/iam_settings_test.go} (79%) rename internal/api/grpc/admin/{import_integration_test.go => integration_test/import_test.go} (100%) rename internal/api/grpc/admin/{information_integration_test.go => integration_test/information_test.go} (81%) rename internal/api/grpc/admin/{restrictions_integration_allow_public_org_registrations_test.go => integration_test/restrictions_allow_public_org_registrations_test.go} (64%) rename internal/api/grpc/admin/{restrictions_integration_allowed_languages_test.go => integration_test/restrictions_allowed_languages_test.go} (67%) rename internal/api/grpc/admin/{server_integration_test.go => integration_test/server_test.go} (70%) rename internal/api/grpc/feature/v2/{feature_integration_test.go => integration_test/feature_test.go} (96%) rename internal/api/grpc/feature/v2beta/{feature_integration_test.go => integration_test/feature_test.go} (56%) rename internal/api/grpc/idp/v2/{query_integration_test.go => integration_test/query_test.go} (96%) create mode 100644 internal/api/grpc/idp/v2/integration_test/server_test.go delete mode 100644 internal/api/grpc/idp/v2/server_integration_test.go rename internal/api/grpc/management/{org_integration_test.go => integration_test/org_test.go} (85%) rename internal/api/grpc/management/{server_integration_test.go => integration_test/server_test.go} (60%) rename internal/api/grpc/management/{user_integration_test.go => integration_test/user_test.go} (93%) rename internal/api/grpc/oidc/v2/{oidc_integration_test.go => integration_test/oidc_test.go} (73%) rename internal/api/grpc/oidc/v2beta/{oidc_integration_test.go => integration_test/oidc_test.go} (73%) rename internal/api/grpc/org/v2/{org_integration_test.go => integration_test/org_test.go} (85%) rename internal/api/grpc/org/v2/{query_integration_test.go => integration_test/query_test.go} (81%) rename internal/api/grpc/org/v2beta/{org_integration_test.go => integration_test/org_test.go} (89%) rename internal/api/grpc/resources/action/v3alpha/{execution_target_integration_test.go => integration_test/execution_target_test.go} (76%) rename internal/api/grpc/resources/action/v3alpha/{execution_integration_test.go => integration_test/execution_test.go} (85%) rename internal/api/grpc/resources/action/v3alpha/{query_integration_test.go => integration_test/query_test.go} (85%) create mode 100644 internal/api/grpc/resources/action/v3alpha/integration_test/server_test.go rename internal/api/grpc/resources/action/v3alpha/{target_integration_test.go => integration_test/target_test.go} (75%) delete mode 100644 internal/api/grpc/resources/action/v3alpha/server_integration_test.go rename internal/api/grpc/resources/user/v3alpha/{server_integration_test.go => integration_test/server_test.go} (63%) rename internal/api/grpc/resources/user/v3alpha/{user_integration_test.go => integration_test/user_test.go} (89%) rename internal/api/grpc/resources/userschema/v3alpha/{query_integration_test.go => integration_test/query_test.go} (93%) rename internal/api/grpc/resources/userschema/v3alpha/{server_integration_test.go => integration_test/server_test.go} (65%) rename internal/api/grpc/resources/userschema/v3alpha/{userschema_integration_test.go => integration_test/userschema_test.go} (91%) rename internal/api/grpc/resources/webkey/v3/{ => integration_test}/webkey_integration_test.go (71%) rename internal/api/grpc/session/v2/{session_integration_test.go => integration_test/session_test.go} (86%) rename internal/api/grpc/session/v2beta/{session_integration_test.go => integration_test/session_test.go} (86%) rename internal/api/grpc/settings/v2/{server_integration_test.go => integration_test/server_test.go} (59%) rename internal/api/grpc/settings/v2/{settings_integration_test.go => integration_test/settings_test.go} (78%) rename internal/api/grpc/settings/v2beta/{server_integration_test.go => integration_test/server_test.go} (59%) rename internal/api/grpc/settings/v2beta/{settings_integration_test.go => integration_test/settings_test.go} (78%) rename internal/api/grpc/system/{instance_integration_test.go => integration_test/instance_test.go} (87%) rename internal/api/grpc/system/{limits_integration_auditlogretention_test.go => integration_test/limits_auditlogretention_test.go} (57%) rename internal/api/grpc/system/{limits_integration_block_test.go => integration_test/limits_block_test.go} (79%) rename internal/api/grpc/system/{limits_integration_bulk_test.go => integration_test/limits_bulk_test.go} (95%) rename internal/api/grpc/system/{quotas_enabled/quota_integration_test.go => integration_test/quotas_enabled/quota_test.go} (61%) create mode 100644 internal/api/grpc/system/integration_test/quotas_enabled/server_test.go create mode 100644 internal/api/grpc/system/integration_test/server_test.go delete mode 100644 internal/api/grpc/system/quotas_enabled/server_integration_test.go delete mode 100644 internal/api/grpc/system/server_integration_test.go rename internal/api/grpc/user/v2/{email_integration_test.go => integration_test/email_test.go} (90%) rename internal/api/grpc/user/v2/{idp_link_integration_test.go => integration_test/idp_link_test.go} (67%) rename internal/api/grpc/user/v2/{otp_integration_test.go => integration_test/otp_test.go} (59%) rename internal/api/grpc/user/v2/{passkey_integration_test.go => integration_test/passkey_test.go} (92%) rename internal/api/grpc/user/v2/{password_integration_test.go => integration_test/password_test.go} (91%) rename internal/api/grpc/user/v2/{phone_integration_test.go => integration_test/phone_test.go} (87%) rename internal/api/grpc/user/v2/{query_integration_test.go => integration_test/query_test.go} (93%) rename internal/api/grpc/user/v2/{totp_integration_test.go => integration_test/totp_test.go} (72%) rename internal/api/grpc/user/v2/{u2f_integration_test.go => integration_test/u2f_test.go} (82%) rename internal/api/grpc/user/v2/{user_integration_test.go => integration_test/user_test.go} (88%) rename internal/api/grpc/user/v2beta/{email_integration_test.go => integration_test/email_test.go} (90%) rename internal/api/grpc/user/v2beta/{otp_integration_test.go => integration_test/otp_test.go} (66%) rename internal/api/grpc/user/v2beta/{passkey_integration_test.go => integration_test/passkey_test.go} (89%) rename internal/api/grpc/user/v2beta/{password_integration_test.go => integration_test/password_test.go} (91%) rename internal/api/grpc/user/v2beta/{phone_integration_test.go => integration_test/phone_test.go} (87%) rename internal/api/grpc/user/v2beta/{query_integration_test.go => integration_test/query_test.go} (94%) rename internal/api/grpc/user/v2beta/{totp_integration_test.go => integration_test/totp_test.go} (70%) rename internal/api/grpc/user/v2beta/{u2f_integration_test.go => integration_test/u2f_test.go} (76%) rename internal/api/grpc/user/v2beta/{user_integration_test.go => integration_test/user_test.go} (88%) rename internal/api/idp/{idp_integration_test.go => integration_test/idp_test.go} (90%) rename internal/api/oidc/{auth_request_integration_test.go => integration_test/auth_request_test.go} (68%) rename internal/api/oidc/{client_integration_test.go => integration_test/client_test.go} (81%) rename internal/api/oidc/{keys_integration_test.go => integration_test/keys_test.go} (66%) rename internal/api/oidc/{oidc_integration_test.go => integration_test/oidc_test.go} (59%) rename internal/api/oidc/{server_integration_test.go => integration_test/server_test.go} (92%) rename internal/api/oidc/{token_client_credentials_integration_test.go => integration_test/token_client_credentials_test.go} (80%) rename internal/api/oidc/{token_exchange_integration_test.go => integration_test/token_exchange_test.go} (91%) rename internal/api/oidc/{token_jwt_profile_integration_test.go => integration_test/token_jwt_profile_test.go} (76%) rename internal/api/oidc/{userinfo_integration_test.go => integration_test/userinfo_test.go} (82%) create mode 100644 internal/integration/config.go create mode 100644 internal/integration/config/client.yaml create mode 100644 internal/integration/config/steps.yaml create mode 100644 internal/integration/instance.go delete mode 100644 internal/integration/integration.go delete mode 100644 internal/integration/integration_test.go create mode 100644 internal/integration/sink/channel.go create mode 100644 internal/integration/sink/channel_enumer.go create mode 100644 internal/integration/sink/server.go create mode 100644 internal/integration/sink/sink.go create mode 100644 internal/integration/sink/stub.go create mode 100644 internal/integration/sink/subscription.go create mode 100644 internal/integration/system.go create mode 100644 internal/integration/usertype_enumer.go delete mode 100644 internal/integration/usertype_string.go delete mode 100644 internal/notification/handlers/handlers_integration_test.go create mode 100644 internal/notification/handlers/integration_test/handlers_test.go rename internal/notification/handlers/{telemetry_pusher_integration_test.go => integration_test/telemetry_pusher_test.go} (53%) delete mode 100644 internal/notification/handlers/quota_notifier_test.go diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index a8f673c2e8..ac7d909589 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -50,9 +50,19 @@ jobs: console_cache_path: ${{ needs.console.outputs.cache_path }} version: ${{ needs.version.outputs.version }} - core-test: + core-unit-test: needs: core - uses: ./.github/workflows/core-test.yml + uses: ./.github/workflows/core-unit-test.yml + with: + go_version: "1.22" + core_cache_key: ${{ needs.core.outputs.cache_key }} + core_cache_path: ${{ needs.core.outputs.cache_path }} + secrets: + CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} + + core-integration-test: + needs: core + uses: ./.github/workflows/core-integration-test.yml with: go_version: "1.22" core_cache_key: ${{ needs.core.outputs.cache_key }} @@ -93,7 +103,7 @@ jobs: issues: write pull-requests: write needs: - [version, core-test, lint, container, e2e] + [version, core-unit-test, core-integration-test, lint, container, e2e] if: ${{ github.event_name == 'workflow_dispatch' }} secrets: GCR_JSON_KEY_BASE64: ${{ secrets.GCR_JSON_KEY_BASE64 }} diff --git a/.github/workflows/core-test.yml b/.github/workflows/core-integration-test.yml similarity index 95% rename from .github/workflows/core-test.yml rename to .github/workflows/core-integration-test.yml index 4d8d978b60..b790db01d7 100644 --- a/.github/workflows/core-test.yml +++ b/.github/workflows/core-integration-test.yml @@ -18,11 +18,8 @@ on: jobs: postgres: - runs-on: ubuntu-latest - # TODO: use runner group as soon as integration tests run in parallel - # Currently it only consumes time and adds no value - # runs-on: - # group: zitadel-public + runs-on: + group: zitadel-public services: postgres: image: postgres diff --git a/.github/workflows/core-unit-test.yml b/.github/workflows/core-unit-test.yml new file mode 100644 index 0000000000..0b1467ff5d --- /dev/null +++ b/.github/workflows/core-unit-test.yml @@ -0,0 +1,76 @@ +name: Unit test core + +on: + workflow_call: + inputs: + go_version: + required: true + type: string + core_cache_key: + required: true + type: string + core_cache_path: + required: true + type: string + crdb_version: + required: false + type: string + secrets: + CODECOV_TOKEN: + required: true + +jobs: + test: + runs-on: + group: zitadel-public + steps: + - + uses: actions/checkout@v3 + - + uses: actions/setup-go@v5 + with: + go-version: ${{ inputs.go_version }} + - + uses: actions/cache/restore@v4 + timeout-minutes: 1 + name: restore core + id: restore-core + with: + path: ${{ inputs.core_cache_path }} + key: ${{ inputs.core_cache_key }} + fail-on-cache-miss: true + - + id: go-cache-path + name: set cache path + run: echo "GO_CACHE_PATH=$(go env GOCACHE)" >> $GITHUB_OUTPUT + - + uses: actions/cache/restore@v4 + id: cache + timeout-minutes: 1 + continue-on-error: true + name: restore previous results + with: + key: unit-test-${{ inputs.core_cache_key }} + restore-keys: | + unit-test-core- + path: ${{ steps.go-cache-path.outputs.GO_CACHE_PATH }} + - + name: test + if: ${{ steps.cache.outputs.cache-hit != 'true' }} + run: make core_unit_test + - + name: publish coverage + uses: codecov/codecov-action@v4.3.0 + with: + file: profile.cov + name: core-unit-tests + flags: core-unit-tests + token: ${{ secrets.CODECOV_TOKEN }} + - + uses: actions/cache/save@v4 + name: cache results + if: ${{ steps.cache.outputs.cache-hit != 'true' }} + with: + key: unit-test-${{ inputs.core_cache_key }} + path: ${{ steps.go-cache-path.outputs.GO_CACHE_PATH }} + \ No newline at end of file diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 4d58908786..e56ca307d1 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -205,16 +205,50 @@ make core_unit_test #### Run Local Integration Tests -To test the database-connected gRPC API, run PostgreSQL and CockroachDB, set up a ZITADEL instance and run the tests including integration tests: +Integration tests are run as gRPC clients against a running ZITADEL server binary. +The server binary is typically [build with coverage enabled](https://go.dev/doc/build-cover). +It is also possible to run a ZITADEL sever in a debugger and run the integrations tests like that. In order to run the server, a database is required. + +The database flavor can **optionally** be set in the environment to `cockroach` or `postgres`. The default is `postgres`. ```bash -export INTEGRATION_DB_FLAVOR="cockroach" ZITADEL_MASTERKEY="MasterkeyNeedsToHave32Characters" -docker compose -f internal/integration/config/docker-compose.yaml up --pull always --wait ${INTEGRATION_DB_FLAVOR} -make core_integration_test -docker compose -f internal/integration/config/docker-compose.yaml down +export INTEGRATION_DB_FLAVOR="cockroach" ``` -Repeat the above with `INTEGRATION_DB_FLAVOR="postgres"`. +In order to prepare the local system, the following will bring up the database, builds a coverage binary, initializes the database and starts the sever. + +```bash +make core_integration_db_up core_integration_server_start +``` + +When this job is finished, you can run individual package integration test through your IDE or command-line. The actual integration test clients reside in the `integration_test` subdirectory of the package they aim to test. Integration test files use the `integration` build tag, in order to be excluded from regular unit tests. +Because of the server-client split, Go is usually unaware of changes in server code and tends to cache test results. Pas `-count 1` to disable test caching. + +Example command to run a single package integration test: + +```bash +go test -count 1 -tags integration ./internal/api/grpc/management/integration_test +``` + +To run all available integration tests: + +```bash +make core_integration_test_packages +``` + +When you change any ZITADEL server code, be sure to rebuild and restart the server before the next test run. + +```bash +make core_integration_server_stop core_integration_server_start +``` + +To cleanup after testing (deletes the database!): + +```bash +make core_integration_server_stop core_integration_db_down +``` + +The test binary has the race detector enabled. `core_core_integration_server_stop` checks for any race logs reported by Go and will print them along a `66` exit code when found. Note that the actual race condition may have happened anywhere during the server lifetime, including start, stop or serving gRPC requests during tests. #### Run Local End-to-End Tests diff --git a/Makefile b/Makefile index 8ef48623f6..56788e9f34 100644 --- a/Makefile +++ b/Makefile @@ -7,6 +7,12 @@ VERSION ?= development-$(now) COMMIT_SHA ?= $(shell git rev-parse HEAD) ZITADEL_IMAGE ?= zitadel:local +GOCOVERDIR = tmp/coverage +INTEGRATION_DB_FLAVOR ?= postgres +ZITADEL_MASTERKEY ?= MasterkeyNeedsToHave32Characters + +export GOCOVERDIR INTEGRATION_DB_FLAVOR ZITADEL_MASTERKEY + .PHONY: compile compile: core_build console_build compile_pipeline @@ -99,25 +105,56 @@ clean: $(RM) -r .artifacts/grpc $(RM) $(gen_authopt_path) $(RM) $(gen_zitadel_path) + $(RM) -r tmp/ .PHONY: core_unit_test core_unit_test: - go test -race -coverprofile=profile.cov ./... + go test -race -coverprofile=profile.cov -coverpkg=./internal/... ./... + +.PHONY: core_integration_db_up +core_integration_db_up: + docker compose -f internal/integration/config/docker-compose.yaml up --pull always --wait $${INTEGRATION_DB_FLAVOR} + +.PHONY: core_integration_db_down +core_integration_db_down: + docker compose -f internal/integration/config/docker-compose.yaml down .PHONY: core_integration_setup core_integration_setup: - go build -o zitadel main.go - ./zitadel init --config internal/integration/config/zitadel.yaml --config internal/integration/config/${INTEGRATION_DB_FLAVOR}.yaml - ./zitadel setup --masterkeyFromEnv --init-projections --config internal/integration/config/zitadel.yaml --config internal/integration/config/${INTEGRATION_DB_FLAVOR}.yaml --steps internal/integration/config/zitadel.yaml --steps internal/integration/config/${INTEGRATION_DB_FLAVOR}.yaml - $(RM) zitadel + go build -cover -race -tags integration -o zitadel.test main.go + mkdir -p $${GOCOVERDIR} + GORACE="halt_on_error=1" ./zitadel.test init --config internal/integration/config/zitadel.yaml --config internal/integration/config/${INTEGRATION_DB_FLAVOR}.yaml + GORACE="halt_on_error=1" ./zitadel.test setup --masterkeyFromEnv --init-projections --config internal/integration/config/zitadel.yaml --config internal/integration/config/${INTEGRATION_DB_FLAVOR}.yaml --steps internal/integration/config/steps.yaml + +.PHONY: core_integration_server_start +core_integration_server_start: core_integration_setup + GORACE="log_path=tmp/race.log" \ + ./zitadel.test start --masterkeyFromEnv --config internal/integration/config/zitadel.yaml --config internal/integration/config/${INTEGRATION_DB_FLAVOR}.yaml \ + > tmp/zitadel.log 2>&1 \ + & printf $$! > tmp/zitadel.pid + +.PHONY: core_integration_test_packages +core_integration_test_packages: + go test -count 1 -tags integration -timeout 30m $$(go list -tags integration ./... | grep "integration_test") + +.PHONY: core_integration_server_stop +core_integration_server_stop: + pid=$$(cat tmp/zitadel.pid); \ + $(RM) tmp/zitadel.pid; \ + kill $$pid; \ + if [ -s tmp/race.log.$$pid ]; then \ + cat tmp/race.log.$$pid; \ + exit 66; \ + fi + +.PHONY: core_integration_reports +core_integration_reports: + go tool covdata textfmt -i=tmp/coverage -pkg=github.com/zitadel/zitadel/internal/...,github.com/zitadel/zitadel/cmd/... -o profile.cov + $(RM) -r tmp/coverage + cat tmp/zitadel.log .PHONY: core_integration_test -core_integration_test: core_integration_setup - go test -tags=integration -race -p 1 -coverprofile=profile.cov -coverpkg=./internal/...,./cmd/... ./... - -.PHONY: core_integration_test_fast -core_integration_test_fast: core_integration_setup - go test -tags=integration -p 1 ./... +core_integration_test: core_integration_server_start core_integration_test_packages core_integration_server_stop core_integration_reports .PHONY: console_lint console_lint: diff --git a/cmd/start/start.go b/cmd/start/start.go index 47bc33ca42..594e16776b 100644 --- a/cmd/start/start.go +++ b/cmd/start/start.go @@ -79,6 +79,7 @@ import ( new_es "github.com/zitadel/zitadel/internal/eventstore/v3" "github.com/zitadel/zitadel/internal/i18n" "github.com/zitadel/zitadel/internal/id" + "github.com/zitadel/zitadel/internal/integration/sink" "github.com/zitadel/zitadel/internal/logstore" "github.com/zitadel/zitadel/internal/logstore/emitters/access" "github.com/zitadel/zitadel/internal/logstore/emitters/execution" @@ -138,6 +139,10 @@ type Server struct { func startZitadel(ctx context.Context, config *Config, masterKey string, server chan<- *Server) error { showBasicInformation(config) + // sink Server is stubbed out in production builds, see function's godoc. + closeSink := sink.StartServer() + defer closeSink() + i18n.MustLoadSupportedLanguagesFromDir() queryDBClient, err := database.Connect(config.Database, false, dialect.DBPurposeQuery) diff --git a/go.mod b/go.mod index 61d1d711d1..0137251400 100644 --- a/go.mod +++ b/go.mod @@ -23,6 +23,7 @@ require ( github.com/envoyproxy/protoc-gen-validate v1.0.4 github.com/fatih/color v1.17.0 github.com/gabriel-vasile/mimetype v1.4.4 + github.com/go-chi/chi/v5 v5.1.0 github.com/go-jose/go-jose/v4 v4.0.4 github.com/go-ldap/ldap/v3 v3.4.8 github.com/go-webauthn/webauthn v0.10.2 @@ -30,6 +31,7 @@ require ( github.com/gorilla/mux v1.8.1 github.com/gorilla/schema v1.4.1 github.com/gorilla/securecookie v1.1.2 + github.com/gorilla/websocket v1.4.1 github.com/grpc-ecosystem/go-grpc-middleware v1.4.0 github.com/grpc-ecosystem/grpc-gateway v1.16.0 github.com/grpc-ecosystem/grpc-gateway/v2 v2.20.0 @@ -95,7 +97,6 @@ require ( github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.48.0 // indirect github.com/bmatcuk/doublestar/v4 v4.6.1 // indirect github.com/crewjam/httperr v0.2.0 // indirect - github.com/go-chi/chi/v5 v5.1.0 // indirect github.com/go-ini/ini v1.67.0 // indirect github.com/go-logr/logr v1.4.2 // indirect github.com/go-logr/stdr v1.2.2 // indirect diff --git a/go.sum b/go.sum index 82ef92c3a7..de6c80d513 100644 --- a/go.sum +++ b/go.sum @@ -346,6 +346,7 @@ github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kX github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo= github.com/gorilla/sessions v1.2.1/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM= github.com/gorilla/websocket v0.0.0-20170926233335-4201258b820c/go.mod h1:E7qHFY5m1UJ88s3WnNqhKjPHQ0heANvMoAMk2YaljkQ= +github.com/gorilla/websocket v1.4.1 h1:q7AeDBpnBk8AogcD4DSag/Ukw/KV+YhzLj2bP5HvKCM= github.com/gorilla/websocket v1.4.1/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/grpc-ecosystem/go-grpc-middleware v1.0.1-0.20190118093823-f849b5445de4/go.mod h1:FiyG127CGDf3tlThmgyCl78X/SZQqEOJBCDaAfeWzPs= github.com/grpc-ecosystem/go-grpc-middleware v1.2.2/go.mod h1:EaizFBKfUKtMIF5iaDEhniwNedqGo9FuLFzppDr3uwI= diff --git a/internal/api/grpc/admin/iam_member_integration_test.go b/internal/api/grpc/admin/integration_test/iam_member_test.go similarity index 82% rename from internal/api/grpc/admin/iam_member_integration_test.go rename to internal/api/grpc/admin/integration_test/iam_member_test.go index 06a8acb914..c65ede3d26 100644 --- a/internal/api/grpc/admin/iam_member_integration_test.go +++ b/internal/api/grpc/admin/integration_test/iam_member_test.go @@ -5,6 +5,7 @@ package admin_test import ( "context" "testing" + "time" "github.com/brianvoe/gofakeit/v6" "github.com/stretchr/testify/assert" @@ -33,7 +34,7 @@ func TestServer_ListIAMMemberRoles(t *testing.T) { } func TestServer_ListIAMMembers(t *testing.T) { - user := Tester.CreateHumanUserVerified(AdminCTX, Tester.Organisation.ID, gofakeit.Email()) + user := Instance.CreateHumanUserVerified(AdminCTX, Instance.DefaultOrg.Id, gofakeit.Email()) _, err := Client.AddIAMMember(AdminCTX, &admin_pb.AddIAMMemberRequest{ UserId: user.GetUserId(), Roles: iamRoles, @@ -52,7 +53,7 @@ func TestServer_ListIAMMembers(t *testing.T) { { name: "permission error", args: args{ - ctx: Tester.WithAuthorization(CTX, integration.OrgOwner), + ctx: Instance.WithAuthorization(CTX, integration.UserTypeOrgOwner), req: &admin_pb.ListIAMMembersRequest{ Query: &object.ListQuery{}, Queries: []*member.SearchQuery{{ @@ -91,26 +92,29 @@ func TestServer_ListIAMMembers(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - got, err := Client.ListIAMMembers(tt.args.ctx, tt.args.req) - if tt.wantErr { - assert.Error(t, err) - return - } - require.NoError(t, err) - wantResult := tt.want.GetResult() - gotResult := got.GetResult() + assert.EventuallyWithT(t, func(ct *assert.CollectT) { + got, err := Client.ListIAMMembers(tt.args.ctx, tt.args.req) + if tt.wantErr { + assert.Error(ct, err) + return + } + require.NoError(ct, err) + wantResult := tt.want.GetResult() + gotResult := got.GetResult() - require.Len(t, gotResult, len(wantResult)) - for i, want := range wantResult { - assert.Equal(t, want.GetUserId(), gotResult[i].GetUserId()) - assert.ElementsMatch(t, want.GetRoles(), gotResult[i].GetRoles()) - } + if assert.Len(ct, gotResult, len(wantResult)) { + for i, want := range wantResult { + assert.Equal(ct, want.GetUserId(), gotResult[i].GetUserId()) + assert.ElementsMatch(ct, want.GetRoles(), gotResult[i].GetRoles()) + } + } + }, time.Minute, time.Second) }) } } func TestServer_AddIAMMember(t *testing.T) { - user := Tester.CreateHumanUserVerified(AdminCTX, Tester.Organisation.ID, gofakeit.Email()) + user := Instance.CreateHumanUserVerified(AdminCTX, Instance.DefaultOrg.Id, gofakeit.Email()) type args struct { ctx context.Context req *admin_pb.AddIAMMemberRequest @@ -124,7 +128,7 @@ func TestServer_AddIAMMember(t *testing.T) { { name: "permission error", args: args{ - ctx: Tester.WithAuthorization(CTX, integration.OrgOwner), + ctx: Instance.WithAuthorization(CTX, integration.UserTypeOrgOwner), req: &admin_pb.AddIAMMemberRequest{ UserId: user.GetUserId(), Roles: iamRoles, @@ -143,7 +147,7 @@ func TestServer_AddIAMMember(t *testing.T) { }, want: &admin_pb.AddIAMMemberResponse{ Details: &object.ObjectDetails{ - ResourceOwner: Tester.Instance.InstanceID(), + ResourceOwner: Instance.ID(), }, }, }, @@ -184,7 +188,7 @@ func TestServer_AddIAMMember(t *testing.T) { } func TestServer_UpdateIAMMember(t *testing.T) { - user := Tester.CreateHumanUserVerified(AdminCTX, Tester.Organisation.ID, gofakeit.Email()) + user := Instance.CreateHumanUserVerified(AdminCTX, Instance.DefaultOrg.Id, gofakeit.Email()) _, err := Client.AddIAMMember(AdminCTX, &admin_pb.AddIAMMemberRequest{ UserId: user.GetUserId(), Roles: []string{"IAM_OWNER"}, @@ -204,7 +208,7 @@ func TestServer_UpdateIAMMember(t *testing.T) { { name: "permission error", args: args{ - ctx: Tester.WithAuthorization(CTX, integration.OrgOwner), + ctx: Instance.WithAuthorization(CTX, integration.UserTypeOrgOwner), req: &admin_pb.UpdateIAMMemberRequest{ UserId: user.GetUserId(), Roles: iamRoles, @@ -223,7 +227,7 @@ func TestServer_UpdateIAMMember(t *testing.T) { }, want: &admin_pb.UpdateIAMMemberResponse{ Details: &object.ObjectDetails{ - ResourceOwner: Tester.Instance.InstanceID(), + ResourceOwner: Instance.ID(), ChangeDate: timestamppb.Now(), }, }, @@ -265,7 +269,7 @@ func TestServer_UpdateIAMMember(t *testing.T) { } func TestServer_RemoveIAMMember(t *testing.T) { - user := Tester.CreateHumanUserVerified(AdminCTX, Tester.Organisation.ID, gofakeit.Email()) + user := Instance.CreateHumanUserVerified(AdminCTX, Instance.DefaultOrg.Id, gofakeit.Email()) _, err := Client.AddIAMMember(AdminCTX, &admin_pb.AddIAMMemberRequest{ UserId: user.GetUserId(), Roles: []string{"IAM_OWNER"}, @@ -285,7 +289,7 @@ func TestServer_RemoveIAMMember(t *testing.T) { { name: "permission error", args: args{ - ctx: Tester.WithAuthorization(CTX, integration.OrgOwner), + ctx: Instance.WithAuthorization(CTX, integration.UserTypeOrgOwner), req: &admin_pb.RemoveIAMMemberRequest{ UserId: user.GetUserId(), }, @@ -302,7 +306,7 @@ func TestServer_RemoveIAMMember(t *testing.T) { }, want: &admin_pb.RemoveIAMMemberResponse{ Details: &object.ObjectDetails{ - ResourceOwner: Tester.Instance.InstanceID(), + ResourceOwner: Instance.ID(), ChangeDate: timestamppb.Now(), }, }, diff --git a/internal/api/grpc/admin/iam_settings_integration_test.go b/internal/api/grpc/admin/integration_test/iam_settings_test.go similarity index 79% rename from internal/api/grpc/admin/iam_settings_integration_test.go rename to internal/api/grpc/admin/integration_test/iam_settings_test.go index 6d39abb747..2787f94755 100644 --- a/internal/api/grpc/admin/iam_settings_integration_test.go +++ b/internal/api/grpc/admin/integration_test/iam_settings_test.go @@ -17,21 +17,17 @@ import ( ) func TestServer_GetSecurityPolicy(t *testing.T) { - _, err := Client.SetSecurityPolicy(AdminCTX, &admin_pb.SetSecurityPolicyRequest{ + t.Parallel() + + instance := integration.NewInstance(CTX) + adminCtx := instance.WithAuthorization(CTX, integration.UserTypeIAMOwner) + + _, err := instance.Client.Admin.SetSecurityPolicy(adminCtx, &admin_pb.SetSecurityPolicyRequest{ EnableIframeEmbedding: true, AllowedOrigins: []string{"foo.com", "bar.com"}, EnableImpersonation: true, }) require.NoError(t, err) - t.Cleanup(func() { - _, err := Client.SetSecurityPolicy(AdminCTX, &admin_pb.SetSecurityPolicyRequest{ - EnableIframeEmbedding: false, - AllowedOrigins: []string{}, - EnableImpersonation: false, - }) - require.NoError(t, err) - }) - tests := []struct { name string ctx context.Context @@ -40,12 +36,12 @@ func TestServer_GetSecurityPolicy(t *testing.T) { }{ { name: "permission error", - ctx: Tester.WithAuthorization(CTX, integration.OrgOwner), + ctx: instance.WithAuthorization(CTX, integration.UserTypeOrgOwner), wantErr: true, }, { name: "success", - ctx: AdminCTX, + ctx: adminCtx, want: &admin_pb.GetSecurityPolicyResponse{ Policy: &settings.SecurityPolicy{ EnableIframeEmbedding: true, @@ -57,7 +53,7 @@ func TestServer_GetSecurityPolicy(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - resp, err := Client.GetSecurityPolicy(tt.ctx, &admin_pb.GetSecurityPolicyRequest{}) + resp, err := instance.Client.Admin.GetSecurityPolicy(tt.ctx, &admin_pb.GetSecurityPolicyRequest{}) if tt.wantErr { assert.Error(t, err) return @@ -72,6 +68,11 @@ func TestServer_GetSecurityPolicy(t *testing.T) { } func TestServer_SetSecurityPolicy(t *testing.T) { + t.Parallel() + + instance := integration.NewInstance(CTX) + adminCtx := instance.WithAuthorization(CTX, integration.UserTypeIAMOwner) + type args struct { ctx context.Context req *admin_pb.SetSecurityPolicyRequest @@ -85,7 +86,7 @@ func TestServer_SetSecurityPolicy(t *testing.T) { { name: "permission error", args: args{ - ctx: Tester.WithAuthorization(CTX, integration.OrgOwner), + ctx: instance.WithAuthorization(CTX, integration.UserTypeOrgOwner), req: &admin_pb.SetSecurityPolicyRequest{ EnableIframeEmbedding: true, AllowedOrigins: []string{"foo.com", "bar.com"}, @@ -97,7 +98,7 @@ func TestServer_SetSecurityPolicy(t *testing.T) { { name: "success allowed origins", args: args{ - ctx: AdminCTX, + ctx: adminCtx, req: &admin_pb.SetSecurityPolicyRequest{ AllowedOrigins: []string{"foo.com", "bar.com"}, }, @@ -105,14 +106,14 @@ func TestServer_SetSecurityPolicy(t *testing.T) { want: &admin_pb.SetSecurityPolicyResponse{ Details: &object.ObjectDetails{ ChangeDate: timestamppb.Now(), - ResourceOwner: Tester.Instance.InstanceID(), + ResourceOwner: instance.ID(), }, }, }, { name: "success iframe embedding", args: args{ - ctx: AdminCTX, + ctx: adminCtx, req: &admin_pb.SetSecurityPolicyRequest{ EnableIframeEmbedding: true, }, @@ -120,14 +121,14 @@ func TestServer_SetSecurityPolicy(t *testing.T) { want: &admin_pb.SetSecurityPolicyResponse{ Details: &object.ObjectDetails{ ChangeDate: timestamppb.Now(), - ResourceOwner: Tester.Instance.InstanceID(), + ResourceOwner: instance.ID(), }, }, }, { name: "success impersonation", args: args{ - ctx: AdminCTX, + ctx: adminCtx, req: &admin_pb.SetSecurityPolicyRequest{ EnableImpersonation: true, }, @@ -135,14 +136,14 @@ func TestServer_SetSecurityPolicy(t *testing.T) { want: &admin_pb.SetSecurityPolicyResponse{ Details: &object.ObjectDetails{ ChangeDate: timestamppb.Now(), - ResourceOwner: Tester.Instance.InstanceID(), + ResourceOwner: instance.ID(), }, }, }, { name: "success all", args: args{ - ctx: AdminCTX, + ctx: adminCtx, req: &admin_pb.SetSecurityPolicyRequest{ EnableIframeEmbedding: true, AllowedOrigins: []string{"foo.com", "bar.com"}, @@ -152,14 +153,14 @@ func TestServer_SetSecurityPolicy(t *testing.T) { want: &admin_pb.SetSecurityPolicyResponse{ Details: &object.ObjectDetails{ ChangeDate: timestamppb.Now(), - ResourceOwner: Tester.Instance.InstanceID(), + ResourceOwner: instance.ID(), }, }, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - got, err := Client.SetSecurityPolicy(tt.args.ctx, tt.args.req) + got, err := instance.Client.Admin.SetSecurityPolicy(tt.args.ctx, tt.args.req) if tt.wantErr { assert.Error(t, err) return diff --git a/internal/api/grpc/admin/import_integration_test.go b/internal/api/grpc/admin/integration_test/import_test.go similarity index 100% rename from internal/api/grpc/admin/import_integration_test.go rename to internal/api/grpc/admin/integration_test/import_test.go diff --git a/internal/api/grpc/admin/information_integration_test.go b/internal/api/grpc/admin/integration_test/information_test.go similarity index 81% rename from internal/api/grpc/admin/information_integration_test.go rename to internal/api/grpc/admin/integration_test/information_test.go index 7c324fc27a..9072bc80eb 100644 --- a/internal/api/grpc/admin/information_integration_test.go +++ b/internal/api/grpc/admin/integration_test/information_test.go @@ -15,6 +15,6 @@ import ( func TestServer_Healthz(t *testing.T) { ctx, cancel := context.WithTimeout(AdminCTX, time.Minute) defer cancel() - _, err := Tester.Client.Admin.Healthz(ctx, &admin.HealthzRequest{}) + _, err := Instance.Client.Admin.Healthz(ctx, &admin.HealthzRequest{}) require.NoError(t, err) } diff --git a/internal/api/grpc/admin/restrictions_integration_allow_public_org_registrations_test.go b/internal/api/grpc/admin/integration_test/restrictions_allow_public_org_registrations_test.go similarity index 64% rename from internal/api/grpc/admin/restrictions_integration_allow_public_org_registrations_test.go rename to internal/api/grpc/admin/integration_test/restrictions_allow_public_org_registrations_test.go index 5b29de19bb..3663fec4dd 100644 --- a/internal/api/grpc/admin/restrictions_integration_allow_public_org_registrations_test.go +++ b/internal/api/grpc/admin/integration_test/restrictions_allow_public_org_registrations_test.go @@ -10,20 +10,20 @@ import ( "net/http/cookiejar" "net/url" "testing" - "time" "github.com/muhlemmer/gu" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "github.com/zitadel/zitadel/internal/integration" "github.com/zitadel/zitadel/pkg/grpc/admin" ) func TestServer_Restrictions_DisallowPublicOrgRegistration(t *testing.T) { - ctx, cancel := context.WithTimeout(context.Background(), time.Minute) - defer cancel() - domain, _, _, iamOwnerCtx := Tester.UseIsolatedInstance(t, ctx, SystemCTX) - regOrgUrl, err := url.Parse("http://" + domain + ":8080/ui/login/register/org") + t.Parallel() + + instance := integration.NewInstance(CTX) + regOrgUrl, err := url.Parse("http://" + instance.Domain + ":8080/ui/login/register/org") require.NoError(t, err) // The CSRF cookie must be sent with every request. // We can simulate a browser session using a cookie jar. @@ -31,36 +31,38 @@ func TestServer_Restrictions_DisallowPublicOrgRegistration(t *testing.T) { require.NoError(t, err) browserSession := &http.Client{Jar: jar} var csrfToken string - t.Run("public org registration is allowed by default", func(*testing.T) { - csrfToken = awaitPubOrgRegAllowed(t, iamOwnerCtx, browserSession, regOrgUrl) + iamOwnerCtx := instance.WithAuthorization(CTX, integration.UserTypeIAMOwner) + + t.Run("public org registration is allowed by default", func(tt *testing.T) { + csrfToken = awaitPubOrgRegAllowed(tt, iamOwnerCtx, instance.Client, browserSession, regOrgUrl) }) - t.Run("disallowing public org registration disables the endpoints", func(*testing.T) { - _, err = Tester.Client.Admin.SetRestrictions(iamOwnerCtx, &admin.SetRestrictionsRequest{DisallowPublicOrgRegistration: gu.Ptr(true)}) - require.NoError(t, err) - awaitPubOrgRegDisallowed(t, iamOwnerCtx, browserSession, regOrgUrl, csrfToken) + t.Run("disallowing public org registration disables the endpoints", func(tt *testing.T) { + _, err = instance.Client.Admin.SetRestrictions(iamOwnerCtx, &admin.SetRestrictionsRequest{DisallowPublicOrgRegistration: gu.Ptr(true)}) + require.NoError(tt, err) + awaitPubOrgRegDisallowed(tt, iamOwnerCtx, instance.Client, browserSession, regOrgUrl, csrfToken) }) - t.Run("allowing public org registration again re-enables the endpoints", func(*testing.T) { - _, err = Tester.Client.Admin.SetRestrictions(iamOwnerCtx, &admin.SetRestrictionsRequest{DisallowPublicOrgRegistration: gu.Ptr(false)}) - require.NoError(t, err) - awaitPubOrgRegAllowed(t, iamOwnerCtx, browserSession, regOrgUrl) + t.Run("allowing public org registration again re-enables the endpoints", func(tt *testing.T) { + _, err = instance.Client.Admin.SetRestrictions(iamOwnerCtx, &admin.SetRestrictionsRequest{DisallowPublicOrgRegistration: gu.Ptr(false)}) + require.NoError(tt, err) + awaitPubOrgRegAllowed(tt, iamOwnerCtx, instance.Client, browserSession, regOrgUrl) }) } // awaitPubOrgRegAllowed doesn't accept a CSRF token, as we expected it to always produce a new one -func awaitPubOrgRegAllowed(t *testing.T, ctx context.Context, client *http.Client, parsedURL *url.URL) string { +func awaitPubOrgRegAllowed(t *testing.T, ctx context.Context, cc *integration.Client, client *http.Client, parsedURL *url.URL) string { csrfToken := awaitGetSSRGetResponse(t, ctx, client, parsedURL, http.StatusOK) awaitPostFormResponse(t, ctx, client, parsedURL, http.StatusOK, csrfToken) - restrictions, err := Tester.Client.Admin.GetRestrictions(ctx, &admin.GetRestrictionsRequest{}) + restrictions, err := cc.Admin.GetRestrictions(ctx, &admin.GetRestrictionsRequest{}) require.NoError(t, err) require.False(t, restrictions.DisallowPublicOrgRegistration) return csrfToken } // awaitPubOrgRegDisallowed accepts an old CSRF token, as we don't expect to get a CSRF token from the GET request anymore -func awaitPubOrgRegDisallowed(t *testing.T, ctx context.Context, client *http.Client, parsedURL *url.URL, reuseOldCSRFToken string) { +func awaitPubOrgRegDisallowed(t *testing.T, ctx context.Context, cc *integration.Client, client *http.Client, parsedURL *url.URL, reuseOldCSRFToken string) { awaitGetSSRGetResponse(t, ctx, client, parsedURL, http.StatusNotFound) awaitPostFormResponse(t, ctx, client, parsedURL, http.StatusConflict, reuseOldCSRFToken) - restrictions, err := Tester.Client.Admin.GetRestrictions(ctx, &admin.GetRestrictionsRequest{}) + restrictions, err := cc.Admin.GetRestrictions(ctx, &admin.GetRestrictionsRequest{}) require.NoError(t, err) require.True(t, restrictions.DisallowPublicOrgRegistration) } @@ -70,9 +72,9 @@ func awaitGetSSRGetResponse(t *testing.T, ctx context.Context, client *http.Clie var csrfToken []byte await(t, ctx, func(tt *assert.CollectT) { resp, err := client.Get(parsedURL.String()) - require.NoError(t, err) + require.NoError(tt, err) body, err := io.ReadAll(resp.Body) - require.NoError(t, err) + require.NoError(tt, err) searchField := ` 0", assert.Greater, zeroCounts) }, "wait for seeded event assertions to pass") - produceEvents(iamOwnerCtx, t, userID, appID, projectID, projectGrantID) - addedCount := requireEventually(t, iamOwnerCtx, userID, projectID, appID, projectGrantID, func(c assert.TestingT, counts *eventCounts) { + produceEvents(iamOwnerCtx, t, isoInstance.Client, userID, appID, projectID, projectGrantID) + addedCount := requireEventually(t, iamOwnerCtx, isoInstance.Client, userID, projectID, appID, projectGrantID, func(c assert.TestingT, counts *eventCounts) { counts.assertAll(t, c, "added events are > seeded events", assert.Greater, seededCount) }, "wait for added event assertions to pass") - _, err := Tester.Client.System.SetLimits(SystemCTX, &system.SetLimitsRequest{ - InstanceId: instanceID, + _, err := integration.SystemClient().SetLimits(CTX, &system.SetLimitsRequest{ + InstanceId: isoInstance.ID(), AuditLogRetention: durationpb.New(time.Now().Sub(beforeTime)), }) require.NoError(t, err) var limitedCounts *eventCounts - requireEventually(t, iamOwnerCtx, userID, projectID, appID, projectGrantID, func(c assert.TestingT, counts *eventCounts) { + requireEventually(t, iamOwnerCtx, isoInstance.Client, userID, projectID, appID, projectGrantID, func(c assert.TestingT, counts *eventCounts) { counts.assertAll(t, c, "limited events < added events", assert.Less, addedCount) counts.assertAll(t, c, "limited events > 0", assert.Greater, zeroCounts) limitedCounts = counts }, "wait for limited event assertions to pass") - listedEvents, err := Tester.Client.Admin.ListEvents(iamOwnerCtx, &admin.ListEventsRequest{CreationDateFilter: &admin.ListEventsRequest_From{ + listedEvents, err := isoInstance.Client.Admin.ListEvents(iamOwnerCtx, &admin.ListEventsRequest{CreationDateFilter: &admin.ListEventsRequest_From{ From: farPast, }}) require.NoError(t, err) assert.LessOrEqual(t, len(listedEvents.GetEvents()), limitedCounts.all, "ListEvents with from query older than retention doesn't return more events") - listedEvents, err = Tester.Client.Admin.ListEvents(iamOwnerCtx, &admin.ListEventsRequest{CreationDateFilter: &admin.ListEventsRequest_Range{Range: &admin.ListEventsRequestCreationDateRange{ + listedEvents, err = isoInstance.Client.Admin.ListEvents(iamOwnerCtx, &admin.ListEventsRequest{CreationDateFilter: &admin.ListEventsRequest_Range{Range: &admin.ListEventsRequestCreationDateRange{ Since: farPast, }}}) require.NoError(t, err) assert.LessOrEqual(t, len(listedEvents.GetEvents()), limitedCounts.all, "ListEvents with since query older than retention doesn't return more events") - _, err = Tester.Client.System.ResetLimits(SystemCTX, &system.ResetLimitsRequest{ - InstanceId: instanceID, + _, err = integration.SystemClient().ResetLimits(CTX, &system.ResetLimitsRequest{ + InstanceId: isoInstance.ID(), }) require.NoError(t, err) - requireEventually(t, iamOwnerCtx, userID, projectID, appID, projectGrantID, func(c assert.TestingT, counts *eventCounts) { + requireEventually(t, iamOwnerCtx, isoInstance.Client, userID, projectID, appID, projectGrantID, func(c assert.TestingT, counts *eventCounts) { counts.assertAll(t, c, "with reset limit, added events are > seeded events", assert.Greater, seededCount) }, "wait for reset event assertions to pass") } @@ -66,6 +70,7 @@ func TestServer_Limits_AuditLogRetention(t *testing.T) { func requireEventually( t *testing.T, ctx context.Context, + cc *integration.Client, userID, projectID, appID, projectGrantID string, assertCounts func(assert.TestingT, *eventCounts), msg string, @@ -75,7 +80,7 @@ func requireEventually( countCtx, cancel := context.WithTimeout(ctx, countTimeout) defer cancel() require.EventuallyWithT(t, func(c *assert.CollectT) { - counts = countEvents(countCtx, t, userID, projectID, appID, projectGrantID) + counts = countEvents(countCtx, c, cc, userID, projectID, appID, projectGrantID) assertCounts(c, counts) }, assertTimeout, time.Second, msg) return counts @@ -91,68 +96,68 @@ func randomString(resourceType string, n int) string { return "test" + resourceType + "-" + string(b) } -func seedObjects(ctx context.Context, t *testing.T) (string, string, string, string) { +func seedObjects(ctx context.Context, t *testing.T, cc *integration.Client) (string, string, string, string) { t.Helper() - project, err := Tester.Client.Mgmt.AddProject(ctx, &management.AddProjectRequest{ + project, err := cc.Mgmt.AddProject(ctx, &management.AddProjectRequest{ Name: randomString("project", 5), }) require.NoError(t, err) - app, err := Tester.Client.Mgmt.AddOIDCApp(ctx, &management.AddOIDCAppRequest{ + app, err := cc.Mgmt.AddOIDCApp(ctx, &management.AddOIDCAppRequest{ Name: randomString("app", 5), ProjectId: project.GetId(), }) - org, err := Tester.Client.Mgmt.AddOrg(ctx, &management.AddOrgRequest{ + org, err := cc.Mgmt.AddOrg(ctx, &management.AddOrgRequest{ Name: randomString("org", 5), }) require.NoError(t, err) role := randomString("role", 5) require.NoError(t, err) - _, err = Tester.Client.Mgmt.AddProjectRole(ctx, &management.AddProjectRoleRequest{ + _, err = cc.Mgmt.AddProjectRole(ctx, &management.AddProjectRoleRequest{ ProjectId: project.GetId(), RoleKey: role, DisplayName: role, }) require.NoError(t, err) - projectGrant, err := Tester.Client.Mgmt.AddProjectGrant(ctx, &management.AddProjectGrantRequest{ + projectGrant, err := cc.Mgmt.AddProjectGrant(ctx, &management.AddProjectGrantRequest{ ProjectId: project.GetId(), GrantedOrgId: org.GetId(), RoleKeys: []string{role}, }) require.NoError(t, err) - user, err := Tester.Client.Auth.GetMyUser(ctx, &auth.GetMyUserRequest{}) + user, err := cc.Auth.GetMyUser(ctx, &auth.GetMyUserRequest{}) require.NoError(t, err) userID := user.GetUser().GetId() - requireUserEvent(ctx, t, userID) + requireUserEvent(ctx, t, cc, userID) return userID, project.GetId(), app.GetAppId(), projectGrant.GetGrantId() } -func produceEvents(ctx context.Context, t *testing.T, machineID, appID, projectID, grantID string) { +func produceEvents(ctx context.Context, t *testing.T, cc *integration.Client, machineID, appID, projectID, grantID string) { t.Helper() - _, err := Tester.Client.Mgmt.UpdateOrg(ctx, &management.UpdateOrgRequest{ + _, err := cc.Mgmt.UpdateOrg(ctx, &management.UpdateOrgRequest{ Name: randomString("org", 5), }) require.NoError(t, err) - _, err = Tester.Client.Mgmt.UpdateProject(ctx, &management.UpdateProjectRequest{ + _, err = cc.Mgmt.UpdateProject(ctx, &management.UpdateProjectRequest{ Id: projectID, Name: randomString("project", 5), }) require.NoError(t, err) - _, err = Tester.Client.Mgmt.UpdateApp(ctx, &management.UpdateAppRequest{ + _, err = cc.Mgmt.UpdateApp(ctx, &management.UpdateAppRequest{ AppId: appID, ProjectId: projectID, Name: randomString("app", 5), }) require.NoError(t, err) - requireUserEvent(ctx, t, machineID) - _, err = Tester.Client.Mgmt.UpdateProjectGrant(ctx, &management.UpdateProjectGrantRequest{ + requireUserEvent(ctx, t, cc, machineID) + _, err = cc.Mgmt.UpdateProjectGrant(ctx, &management.UpdateProjectGrantRequest{ ProjectId: projectID, GrantId: grantID, }) require.NoError(t, err) } -func requireUserEvent(ctx context.Context, t *testing.T, machineID string) { - _, err := Tester.Client.Mgmt.UpdateMachine(ctx, &management.UpdateMachineRequest{ +func requireUserEvent(ctx context.Context, t *testing.T, cc *integration.Client, machineID string) { + _, err := cc.Mgmt.UpdateMachine(ctx, &management.UpdateMachineRequest{ UserId: machineID, Name: randomString("machine", 5), }) @@ -175,51 +180,50 @@ func (e *eventCounts) assertAll(t *testing.T, c assert.TestingT, name string, co }) } -func countEvents(ctx context.Context, t *testing.T, userID, projectID, appID, grantID string) *eventCounts { - t.Helper() +func countEvents(ctx context.Context, t assert.TestingT, cc *integration.Client, userID, projectID, appID, grantID string) *eventCounts { counts := new(eventCounts) var wg sync.WaitGroup wg.Add(7) go func() { defer wg.Done() - result, err := Tester.Client.Admin.ListEvents(ctx, &admin.ListEventsRequest{}) - require.NoError(t, err) + result, err := cc.Admin.ListEvents(ctx, &admin.ListEventsRequest{}) + assert.NoError(t, err) counts.all = len(result.GetEvents()) }() go func() { defer wg.Done() - result, err := Tester.Client.Auth.ListMyUserChanges(ctx, &auth.ListMyUserChangesRequest{}) - require.NoError(t, err) + result, err := cc.Auth.ListMyUserChanges(ctx, &auth.ListMyUserChangesRequest{}) + assert.NoError(t, err) counts.myUser = len(result.GetResult()) }() go func() { defer wg.Done() - result, err := Tester.Client.Mgmt.ListUserChanges(ctx, &management.ListUserChangesRequest{UserId: userID}) - require.NoError(t, err) + result, err := cc.Mgmt.ListUserChanges(ctx, &management.ListUserChangesRequest{UserId: userID}) + assert.NoError(t, err) counts.aUser = len(result.GetResult()) }() go func() { defer wg.Done() - result, err := Tester.Client.Mgmt.ListAppChanges(ctx, &management.ListAppChangesRequest{ProjectId: projectID, AppId: appID}) - require.NoError(t, err) + result, err := cc.Mgmt.ListAppChanges(ctx, &management.ListAppChangesRequest{ProjectId: projectID, AppId: appID}) + assert.NoError(t, err) counts.app = len(result.GetResult()) }() go func() { defer wg.Done() - result, err := Tester.Client.Mgmt.ListOrgChanges(ctx, &management.ListOrgChangesRequest{}) - require.NoError(t, err) + result, err := cc.Mgmt.ListOrgChanges(ctx, &management.ListOrgChangesRequest{}) + assert.NoError(t, err) counts.org = len(result.GetResult()) }() go func() { defer wg.Done() - result, err := Tester.Client.Mgmt.ListProjectChanges(ctx, &management.ListProjectChangesRequest{ProjectId: projectID}) - require.NoError(t, err) + result, err := cc.Mgmt.ListProjectChanges(ctx, &management.ListProjectChangesRequest{ProjectId: projectID}) + assert.NoError(t, err) counts.project = len(result.GetResult()) }() go func() { defer wg.Done() - result, err := Tester.Client.Mgmt.ListProjectGrantChanges(ctx, &management.ListProjectGrantChangesRequest{ProjectId: projectID, GrantId: grantID}) - require.NoError(t, err) + result, err := cc.Mgmt.ListProjectGrantChanges(ctx, &management.ListProjectGrantChangesRequest{ProjectId: projectID, GrantId: grantID}) + assert.NoError(t, err) counts.grant = len(result.GetResult()) }() wg.Wait() diff --git a/internal/api/grpc/system/limits_integration_block_test.go b/internal/api/grpc/system/integration_test/limits_block_test.go similarity index 79% rename from internal/api/grpc/system/limits_integration_block_test.go rename to internal/api/grpc/system/integration_test/limits_block_test.go index 68426dd05e..b8d17a1167 100644 --- a/internal/api/grpc/system/limits_integration_block_test.go +++ b/internal/api/grpc/system/integration_test/limits_block_test.go @@ -20,41 +20,35 @@ import ( "google.golang.org/grpc/status" "google.golang.org/protobuf/types/known/durationpb" - "github.com/zitadel/zitadel/internal/api/authz" - "github.com/zitadel/zitadel/internal/query" + "github.com/zitadel/zitadel/internal/integration" "github.com/zitadel/zitadel/pkg/grpc/admin" "github.com/zitadel/zitadel/pkg/grpc/system" ) func TestServer_Limits_Block(t *testing.T) { - domain, instanceID, _, iamOwnerCtx := Tester.UseIsolatedInstance(t, CTX, SystemCTX) + t.Parallel() + + isoInstance := integration.NewInstance(CTX) + iamOwnerCtx := isoInstance.WithAuthorization(CTX, integration.UserTypeIAMOwner) tests := []*test{ - publicAPIBlockingTest(domain), + publicAPIBlockingTest(isoInstance.Domain), { name: "mutating API", testGrpc: func(tt assert.TestingT, expectBlocked bool) { randomGrpcIdpName := randomString("idp-grpc", 5) - _, err := Tester.Client.Admin.AddGitHubProvider(iamOwnerCtx, &admin.AddGitHubProviderRequest{ + _, err := isoInstance.Client.Admin.AddGitHubProvider(iamOwnerCtx, &admin.AddGitHubProviderRequest{ Name: randomGrpcIdpName, ClientId: "client-id", ClientSecret: "client-secret", }) assertGrpcError(tt, err, expectBlocked) - //nolint:contextcheck - idpExists := idpExistsCondition(tt, instanceID, randomGrpcIdpName) - if expectBlocked { - // We ensure that the idp really is not created - assert.Neverf(tt, idpExists, 5*time.Second, 1*time.Second, "idp should never be created") - } else { - assert.Eventuallyf(tt, idpExists, 5*time.Second, 1*time.Second, "idp should be created") - } }, testHttp: func(tt assert.TestingT) (*http.Request, error, func(assert.TestingT, *http.Response, bool)) { randomHttpIdpName := randomString("idp-http", 5) req, err := http.NewRequestWithContext( CTX, "POST", - fmt.Sprintf("http://%s/admin/v1/idps/github", net.JoinHostPort(domain, "8080")), + fmt.Sprintf("http://%s/admin/v1/idps/github", net.JoinHostPort(isoInstance.Domain, "8080")), strings.NewReader(`{ "name": "`+randomHttpIdpName+`", "clientId": "client-id", @@ -64,7 +58,7 @@ func TestServer_Limits_Block(t *testing.T) { if err != nil { return nil, err, nil } - req.Header.Set("Authorization", Tester.BearerToken(iamOwnerCtx)) + req.Header.Set("Authorization", isoInstance.BearerToken(iamOwnerCtx)) return req, nil, func(ttt assert.TestingT, response *http.Response, expectBlocked bool) { assertLimitResponse(ttt, response, expectBlocked) assertSetLimitingCookie(ttt, response, expectBlocked) @@ -76,7 +70,7 @@ func TestServer_Limits_Block(t *testing.T) { req, err := http.NewRequestWithContext( CTX, "GET", - fmt.Sprintf("http://%s/.well-known/openid-configuration", net.JoinHostPort(domain, "8080")), + fmt.Sprintf("http://%s/.well-known/openid-configuration", net.JoinHostPort(isoInstance.Domain, "8080")), nil, ) return req, err, func(ttt assert.TestingT, response *http.Response, expectBlocked bool) { @@ -90,7 +84,7 @@ func TestServer_Limits_Block(t *testing.T) { req, err := http.NewRequestWithContext( CTX, "GET", - fmt.Sprintf("http://%s/ui/login/login/externalidp/callback", net.JoinHostPort(domain, "8080")), + fmt.Sprintf("http://%s/ui/login/login/externalidp/callback", net.JoinHostPort(isoInstance.Domain, "8080")), nil, ) return req, err, func(ttt assert.TestingT, response *http.Response, expectBlocked bool) { @@ -109,7 +103,7 @@ func TestServer_Limits_Block(t *testing.T) { req, err := http.NewRequestWithContext( CTX, "GET", - fmt.Sprintf("http://%s/ui/console/", net.JoinHostPort(domain, "8080")), + fmt.Sprintf("http://%s/ui/console/", net.JoinHostPort(isoInstance.Domain, "8080")), nil, ) return req, err, func(ttt assert.TestingT, response *http.Response, expectBlocked bool) { @@ -125,7 +119,7 @@ func TestServer_Limits_Block(t *testing.T) { req, err := http.NewRequestWithContext( CTX, "GET", - fmt.Sprintf("http://%s/ui/console/assets/environment.json", net.JoinHostPort(domain, "8080")), + fmt.Sprintf("http://%s/ui/console/assets/environment.json", net.JoinHostPort(isoInstance.Domain, "8080")), nil, ) return req, err, func(ttt assert.TestingT, response *http.Response, expectBlocked bool) { @@ -142,14 +136,14 @@ func TestServer_Limits_Block(t *testing.T) { } }, }} - _, err := Tester.Client.System.SetLimits(SystemCTX, &system.SetLimitsRequest{ - InstanceId: instanceID, + _, err := integration.SystemClient().SetLimits(CTX, &system.SetLimitsRequest{ + InstanceId: isoInstance.ID(), Block: gu.Ptr(true), }) require.NoError(t, err) // The following call ensures that an undefined bool is not deserialized to false - _, err = Tester.Client.System.SetLimits(SystemCTX, &system.SetLimitsRequest{ - InstanceId: instanceID, + _, err = integration.SystemClient().SetLimits(CTX, &system.SetLimitsRequest{ + InstanceId: isoInstance.ID(), AuditLogRetention: durationpb.New(time.Hour), }) require.NoError(t, err) @@ -160,8 +154,8 @@ func TestServer_Limits_Block(t *testing.T) { testBlockingAPI(t, tt, true, isFirst) }) } - _, err = Tester.Client.System.SetLimits(SystemCTX, &system.SetLimitsRequest{ - InstanceId: instanceID, + _, err = integration.SystemClient().SetLimits(CTX, &system.SetLimitsRequest{ + InstanceId: isoInstance.ID(), Block: gu.Ptr(false), }) require.NoError(t, err) @@ -183,7 +177,7 @@ type test struct { func testBlockingAPI(t *testing.T, tt *test, expectBlocked bool, isFirst bool) { req, err, assertResponse := tt.testHttp(t) require.NoError(t, err) - testHTTP := func(tt assert.TestingT) { + testHTTP := func(t require.TestingT) { resp, err := (&http.Client{ // Don't follow redirects CheckRedirect: func(req *http.Request, via []*http.Request) error { @@ -200,7 +194,7 @@ func testBlockingAPI(t *testing.T, tt *test, expectBlocked bool, isFirst bool) { // limits are eventually consistent, so we need to wait for the blocking to be set on the first test assert.EventuallyWithT(t, func(c *assert.CollectT) { testHTTP(c) - }, 15*time.Second, time.Second, "wait for blocking to be set") + }, time.Minute, time.Second, "wait for blocking to be set") } else { testHTTP(t) } @@ -249,7 +243,7 @@ func assertSetLimitingCookie(t assert.TestingT, response *http.Response, expectS return } } - assert.FailNow(t, "cookie not found") + assert.Fail(t, "cookie not found") } func assertGrpcError(t assert.TestingT, err error, expectBlocked bool) { @@ -268,20 +262,3 @@ func assertLimitResponse(t assert.TestingT, response *http.Response, expectBlock assert.GreaterOrEqual(t, response.StatusCode, 200) assert.Less(t, response.StatusCode, 300) } - -func idpExistsCondition(t assert.TestingT, instanceID, idpName string) func() bool { - return func() bool { - nameQuery, err := query.NewIDPTemplateNameSearchQuery(query.TextEquals, idpName) - assert.NoError(t, err) - instanceQuery, err := query.NewIDPTemplateResourceOwnerSearchQuery(instanceID) - assert.NoError(t, err) - idps, err := Tester.Queries.IDPTemplates(authz.WithInstanceID(CTX, instanceID), &query.IDPTemplateSearchQueries{ - Queries: []query.SearchQuery{ - instanceQuery, - nameQuery, - }, - }, false) - assert.NoError(t, err) - return len(idps.Templates) > 0 - } -} diff --git a/internal/api/grpc/system/limits_integration_bulk_test.go b/internal/api/grpc/system/integration_test/limits_bulk_test.go similarity index 95% rename from internal/api/grpc/system/limits_integration_bulk_test.go rename to internal/api/grpc/system/integration_test/limits_bulk_test.go index 0cdb37f5c3..b81c5c68ff 100644 --- a/internal/api/grpc/system/limits_integration_bulk_test.go +++ b/internal/api/grpc/system/integration_test/limits_bulk_test.go @@ -18,7 +18,7 @@ func TestServer_Limits_Bulk(t *testing.T) { instances := make([]*instance, len) for i := 0; i < len; i++ { domain := integration.RandString(5) + ".integration.localhost" - resp, err := Tester.Client.System.CreateInstance(SystemCTX, &system.CreateInstanceRequest{ + resp, err := integration.SystemClient().CreateInstance(CTX, &system.CreateInstanceRequest{ InstanceName: "testinstance", CustomDomain: domain, Owner: &system.CreateInstanceRequest_Machine_{ @@ -31,7 +31,7 @@ func TestServer_Limits_Bulk(t *testing.T) { require.NoError(t, err) instances[i] = &instance{domain, resp.GetInstanceId()} } - resp, err := Tester.Client.System.BulkSetLimits(SystemCTX, &system.BulkSetLimitsRequest{ + resp, err := integration.SystemClient().BulkSetLimits(CTX, &system.BulkSetLimitsRequest{ Limits: []*system.SetLimitsRequest{{ InstanceId: instances[0].id, Block: gu.Ptr(true), diff --git a/internal/api/grpc/system/quotas_enabled/quota_integration_test.go b/internal/api/grpc/system/integration_test/quotas_enabled/quota_test.go similarity index 61% rename from internal/api/grpc/system/quotas_enabled/quota_integration_test.go rename to internal/api/grpc/system/integration_test/quotas_enabled/quota_test.go index 225b4a2daa..09c42eeb97 100644 --- a/internal/api/grpc/system/quotas_enabled/quota_integration_test.go +++ b/internal/api/grpc/system/integration_test/quotas_enabled/quota_test.go @@ -14,21 +14,25 @@ import ( "google.golang.org/protobuf/types/known/timestamppb" "github.com/zitadel/zitadel/internal/integration" + "github.com/zitadel/zitadel/internal/integration/sink" "github.com/zitadel/zitadel/internal/repository/quota" "github.com/zitadel/zitadel/pkg/grpc/admin" quota_pb "github.com/zitadel/zitadel/pkg/grpc/quota" "github.com/zitadel/zitadel/pkg/grpc/system" ) -var callURL = "http://localhost:" + integration.PortQuotaServer +var callURL = sink.CallURL(sink.ChannelQuota) func TestServer_QuotaNotification_Limit(t *testing.T) { + instance := integration.NewInstance(CTX) + iamCTX := instance.WithAuthorization(CTX, integration.UserTypeIAMOwner) + amount := 10 percent := 50 percentAmount := amount * percent / 100 - _, err := Tester.Client.System.SetQuota(SystemCTX, &system.SetQuotaRequest{ - InstanceId: Tester.Instance.InstanceID(), + _, err := integration.SystemClient().SetQuota(CTX, &system.SetQuotaRequest{ + InstanceId: instance.Instance.Id, Unit: quota_pb.Unit_UNIT_REQUESTS_ALL_AUTHENTICATED, From: timestamppb.Now(), ResetInterval: durationpb.New(time.Minute * 5), @@ -49,30 +53,35 @@ func TestServer_QuotaNotification_Limit(t *testing.T) { }) require.NoError(t, err) + sub := sink.Subscribe(CTX, sink.ChannelQuota) + defer sub.Close() + for i := 0; i < percentAmount; i++ { - _, err := Tester.Client.Admin.GetDefaultOrg(IAMOwnerCTX, &admin.GetDefaultOrgRequest{}) + _, err := instance.Client.Admin.GetDefaultOrg(iamCTX, &admin.GetDefaultOrgRequest{}) require.NoErrorf(t, err, "error in %d call of %d", i, percentAmount) } - awaitNotification(t, Tester.QuotaNotificationChan, quota.RequestsAllAuthenticated, percent) + awaitNotification(t, sub, quota.RequestsAllAuthenticated, percent) for i := 0; i < (amount - percentAmount); i++ { - _, err := Tester.Client.Admin.GetDefaultOrg(IAMOwnerCTX, &admin.GetDefaultOrgRequest{}) + _, err := instance.Client.Admin.GetDefaultOrg(iamCTX, &admin.GetDefaultOrgRequest{}) require.NoErrorf(t, err, "error in %d call of %d", i, percentAmount) } - awaitNotification(t, Tester.QuotaNotificationChan, quota.RequestsAllAuthenticated, 100) + awaitNotification(t, sub, quota.RequestsAllAuthenticated, 100) - _, limitErr := Tester.Client.Admin.GetDefaultOrg(IAMOwnerCTX, &admin.GetDefaultOrgRequest{}) + _, limitErr := instance.Client.Admin.GetDefaultOrg(iamCTX, &admin.GetDefaultOrgRequest{}) require.Error(t, limitErr) } func TestServer_QuotaNotification_NoLimit(t *testing.T) { - _, instanceID, _, IAMOwnerCTX := Tester.UseIsolatedInstance(t, CTX, SystemCTX) + instance := integration.NewInstance(CTX) + iamCTX := instance.WithAuthorization(CTX, integration.UserTypeIAMOwner) + amount := 10 percent := 50 percentAmount := amount * percent / 100 - _, err := Tester.Client.System.SetQuota(SystemCTX, &system.SetQuotaRequest{ - InstanceId: instanceID, + _, err := integration.SystemClient().SetQuota(CTX, &system.SetQuotaRequest{ + InstanceId: instance.Instance.Id, Unit: quota_pb.Unit_UNIT_REQUESTS_ALL_AUTHENTICATED, From: timestamppb.Now(), ResetInterval: durationpb.New(time.Minute * 5), @@ -93,34 +102,39 @@ func TestServer_QuotaNotification_NoLimit(t *testing.T) { }) require.NoError(t, err) + sub := sink.Subscribe(CTX, sink.ChannelQuota) + defer sub.Close() + for i := 0; i < percentAmount; i++ { - _, err := Tester.Client.Admin.GetDefaultOrg(IAMOwnerCTX, &admin.GetDefaultOrgRequest{}) + _, err := instance.Client.Admin.GetDefaultOrg(iamCTX, &admin.GetDefaultOrgRequest{}) require.NoErrorf(t, err, "error in %d call of %d", i, percentAmount) } - awaitNotification(t, Tester.QuotaNotificationChan, quota.RequestsAllAuthenticated, percent) + awaitNotification(t, sub, quota.RequestsAllAuthenticated, percent) for i := 0; i < (amount - percentAmount); i++ { - _, err := Tester.Client.Admin.GetDefaultOrg(IAMOwnerCTX, &admin.GetDefaultOrgRequest{}) + _, err := instance.Client.Admin.GetDefaultOrg(iamCTX, &admin.GetDefaultOrgRequest{}) require.NoErrorf(t, err, "error in %d call of %d", i, percentAmount) } - awaitNotification(t, Tester.QuotaNotificationChan, quota.RequestsAllAuthenticated, 100) + awaitNotification(t, sub, quota.RequestsAllAuthenticated, 100) for i := 0; i < amount; i++ { - _, err := Tester.Client.Admin.GetDefaultOrg(IAMOwnerCTX, &admin.GetDefaultOrgRequest{}) + _, err := instance.Client.Admin.GetDefaultOrg(iamCTX, &admin.GetDefaultOrgRequest{}) require.NoErrorf(t, err, "error in %d call of %d", i, percentAmount) } - awaitNotification(t, Tester.QuotaNotificationChan, quota.RequestsAllAuthenticated, 200) + awaitNotification(t, sub, quota.RequestsAllAuthenticated, 200) - _, limitErr := Tester.Client.Admin.GetDefaultOrg(IAMOwnerCTX, &admin.GetDefaultOrgRequest{}) + _, limitErr := instance.Client.Admin.GetDefaultOrg(iamCTX, &admin.GetDefaultOrgRequest{}) require.NoError(t, limitErr) } -func awaitNotification(t *testing.T, bodies chan []byte, unit quota.Unit, percent int) { +func awaitNotification(t *testing.T, sub *sink.Subscription, unit quota.Unit, percent int) { for { select { - case body := <-bodies: + case req, ok := <-sub.Recv(): + require.True(t, ok, "channel closed") + plain := new(bytes.Buffer) - if err := json.Indent(plain, body, "", " "); err != nil { + if err := json.Indent(plain, req.Body, "", " "); err != nil { t.Fatal(err) } t.Log("received notificationDueEvent", plain.String()) @@ -132,7 +146,7 @@ func awaitNotification(t *testing.T, bodies chan []byte, unit quota.Unit, percen Threshold uint16 `json:"threshold"` Usage uint64 `json:"usage"` }{} - if err := json.Unmarshal(body, &event); err != nil { + if err := json.Unmarshal(req.Body, &event); err != nil { t.Error(err) } if event.ID == "" { @@ -148,10 +162,10 @@ func awaitNotification(t *testing.T, bodies chan []byte, unit quota.Unit, percen } func TestServer_AddAndRemoveQuota(t *testing.T) { - _, instanceID, _, _ := Tester.UseIsolatedInstance(t, CTX, SystemCTX) + instance := integration.NewInstance(CTX) - got, err := Tester.Client.System.SetQuota(SystemCTX, &system.SetQuotaRequest{ - InstanceId: instanceID, + got, err := integration.SystemClient().SetQuota(CTX, &system.SetQuotaRequest{ + InstanceId: instance.Instance.Id, Unit: quota_pb.Unit_UNIT_REQUESTS_ALL_AUTHENTICATED, From: timestamppb.Now(), ResetInterval: durationpb.New(time.Minute), @@ -166,10 +180,10 @@ func TestServer_AddAndRemoveQuota(t *testing.T) { }, }) require.NoError(t, err) - require.Equal(t, got.Details.ResourceOwner, instanceID) + require.Equal(t, got.Details.ResourceOwner, instance.Instance.Id) - gotAlreadyExisting, errAlreadyExisting := Tester.Client.System.SetQuota(SystemCTX, &system.SetQuotaRequest{ - InstanceId: instanceID, + gotAlreadyExisting, errAlreadyExisting := integration.SystemClient().SetQuota(CTX, &system.SetQuotaRequest{ + InstanceId: instance.Instance.Id, Unit: quota_pb.Unit_UNIT_REQUESTS_ALL_AUTHENTICATED, From: timestamppb.Now(), ResetInterval: durationpb.New(time.Minute), @@ -184,17 +198,17 @@ func TestServer_AddAndRemoveQuota(t *testing.T) { }, }) require.Nil(t, errAlreadyExisting) - require.Equal(t, gotAlreadyExisting.Details.ResourceOwner, instanceID) + require.Equal(t, gotAlreadyExisting.Details.ResourceOwner, instance.Instance.Id) - gotRemove, errRemove := Tester.Client.System.RemoveQuota(SystemCTX, &system.RemoveQuotaRequest{ - InstanceId: instanceID, + gotRemove, errRemove := integration.SystemClient().RemoveQuota(CTX, &system.RemoveQuotaRequest{ + InstanceId: instance.Instance.Id, Unit: quota_pb.Unit_UNIT_REQUESTS_ALL_AUTHENTICATED, }) require.NoError(t, errRemove) - require.Equal(t, gotRemove.Details.ResourceOwner, instanceID) + require.Equal(t, gotRemove.Details.ResourceOwner, instance.Instance.Id) - gotRemoveAlready, errRemoveAlready := Tester.Client.System.RemoveQuota(SystemCTX, &system.RemoveQuotaRequest{ - InstanceId: instanceID, + gotRemoveAlready, errRemoveAlready := integration.SystemClient().RemoveQuota(CTX, &system.RemoveQuotaRequest{ + InstanceId: instance.Instance.Id, Unit: quota_pb.Unit_UNIT_REQUESTS_ALL_AUTHENTICATED, }) require.Error(t, errRemoveAlready) diff --git a/internal/api/grpc/system/integration_test/quotas_enabled/server_test.go b/internal/api/grpc/system/integration_test/quotas_enabled/server_test.go new file mode 100644 index 0000000000..0780575dd6 --- /dev/null +++ b/internal/api/grpc/system/integration_test/quotas_enabled/server_test.go @@ -0,0 +1,23 @@ +//go:build integration + +package quotas_enabled_test + +import ( + "context" + "os" + "testing" + "time" +) + +var ( + CTX context.Context +) + +func TestMain(m *testing.M) { + os.Exit(func() int { + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Minute) + defer cancel() + CTX = ctx + return m.Run() + }()) +} diff --git a/internal/api/grpc/system/integration_test/server_test.go b/internal/api/grpc/system/integration_test/server_test.go new file mode 100644 index 0000000000..f21ec5a9de --- /dev/null +++ b/internal/api/grpc/system/integration_test/server_test.go @@ -0,0 +1,24 @@ +//go:build integration + +package system_test + +import ( + "context" + "os" + "testing" + "time" +) + +var ( + CTX context.Context +) + +func TestMain(m *testing.M) { + os.Exit(func() int { + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Minute) + defer cancel() + + CTX = ctx + return m.Run() + }()) +} diff --git a/internal/api/grpc/system/quotas_enabled/server_integration_test.go b/internal/api/grpc/system/quotas_enabled/server_integration_test.go deleted file mode 100644 index c552049e44..0000000000 --- a/internal/api/grpc/system/quotas_enabled/server_integration_test.go +++ /dev/null @@ -1,37 +0,0 @@ -//go:build integration - -package quotas_enabled_test - -import ( - "context" - "os" - "testing" - "time" - - "github.com/zitadel/zitadel/internal/integration" -) - -var ( - CTX context.Context - SystemCTX context.Context - IAMOwnerCTX context.Context - Tester *integration.Tester -) - -func TestMain(m *testing.M) { - os.Exit(func() int { - ctx, _, cancel := integration.Contexts(5 * time.Minute) - defer cancel() - CTX = ctx - - Tester = integration.NewTester(ctx, ` -Quotas: - Access: - Enabled: true -`) - defer Tester.Done() - SystemCTX = Tester.WithAuthorization(ctx, integration.SystemUser) - IAMOwnerCTX = Tester.WithAuthorization(ctx, integration.IAMOwner) - return m.Run() - }()) -} diff --git a/internal/api/grpc/system/server_integration_test.go b/internal/api/grpc/system/server_integration_test.go deleted file mode 100644 index f36972993f..0000000000 --- a/internal/api/grpc/system/server_integration_test.go +++ /dev/null @@ -1,32 +0,0 @@ -//go:build integration - -package system_test - -import ( - "context" - "os" - "testing" - "time" - - "github.com/zitadel/zitadel/internal/integration" -) - -var ( - CTX context.Context - SystemCTX context.Context - Tester *integration.Tester -) - -func TestMain(m *testing.M) { - os.Exit(func() int { - ctx, _, cancel := integration.Contexts(5 * time.Minute) - defer cancel() - CTX = ctx - - Tester = integration.NewTester(ctx) - defer Tester.Done() - - SystemCTX = Tester.WithAuthorization(ctx, integration.SystemUser) - return m.Run() - }()) -} diff --git a/internal/api/grpc/user/v2/email_integration_test.go b/internal/api/grpc/user/v2/integration_test/email_test.go similarity index 90% rename from internal/api/grpc/user/v2/email_integration_test.go rename to internal/api/grpc/user/v2/integration_test/email_test.go index 2264934f25..c15e2c0bf3 100644 --- a/internal/api/grpc/user/v2/email_integration_test.go +++ b/internal/api/grpc/user/v2/integration_test/email_test.go @@ -12,14 +12,13 @@ import ( "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/v2" user "github.com/zitadel/zitadel/pkg/grpc/user/v2" - - "github.com/zitadel/zitadel/internal/integration" ) func TestServer_SetEmail(t *testing.T) { - userID := Tester.CreateHumanUser(CTX).GetUserId() + userID := Instance.CreateHumanUser(CTX).GetUserId() tests := []struct { name string @@ -45,7 +44,7 @@ func TestServer_SetEmail(t *testing.T) { Details: &object.Details{ Sequence: 1, ChangeDate: timestamppb.Now(), - ResourceOwner: Tester.Organisation.ID, + ResourceOwner: Instance.DefaultOrg.Id, }, }, }, @@ -64,7 +63,7 @@ func TestServer_SetEmail(t *testing.T) { Details: &object.Details{ Sequence: 1, ChangeDate: timestamppb.Now(), - ResourceOwner: Tester.Organisation.ID, + ResourceOwner: Instance.DefaultOrg.Id, }, }, }, @@ -94,7 +93,7 @@ func TestServer_SetEmail(t *testing.T) { Details: &object.Details{ Sequence: 1, ChangeDate: timestamppb.Now(), - ResourceOwner: Tester.Organisation.ID, + ResourceOwner: Instance.DefaultOrg.Id, }, VerificationCode: gu.Ptr("xxx"), }, @@ -112,7 +111,7 @@ func TestServer_SetEmail(t *testing.T) { Details: &object.Details{ Sequence: 1, ChangeDate: timestamppb.Now(), - ResourceOwner: Tester.Organisation.ID, + ResourceOwner: Instance.DefaultOrg.Id, }, }, }, @@ -145,8 +144,8 @@ func TestServer_SetEmail(t *testing.T) { } func TestServer_ResendEmailCode(t *testing.T) { - userID := Tester.CreateHumanUser(CTX).GetUserId() - verifiedUserID := Tester.CreateHumanUserVerified(CTX, Tester.Organisation.ID, fmt.Sprintf("%d@mouse.com", time.Now().UnixNano())).GetUserId() + userID := Instance.CreateHumanUser(CTX).GetUserId() + verifiedUserID := Instance.CreateHumanUserVerified(CTX, Instance.DefaultOrg.Id, fmt.Sprintf("%d@mouse.com", time.Now().UnixNano())).GetUserId() tests := []struct { name string @@ -177,7 +176,7 @@ func TestServer_ResendEmailCode(t *testing.T) { Details: &object.Details{ Sequence: 1, ChangeDate: timestamppb.Now(), - ResourceOwner: Tester.Organisation.ID, + ResourceOwner: Instance.DefaultOrg.Id, }, }, }, @@ -195,7 +194,7 @@ func TestServer_ResendEmailCode(t *testing.T) { Details: &object.Details{ Sequence: 1, ChangeDate: timestamppb.Now(), - ResourceOwner: Tester.Organisation.ID, + ResourceOwner: Instance.DefaultOrg.Id, }, }, }, @@ -223,7 +222,7 @@ func TestServer_ResendEmailCode(t *testing.T) { Details: &object.Details{ Sequence: 1, ChangeDate: timestamppb.Now(), - ResourceOwner: Tester.Organisation.ID, + ResourceOwner: Instance.DefaultOrg.Id, }, VerificationCode: gu.Ptr("xxx"), }, @@ -246,7 +245,7 @@ func TestServer_ResendEmailCode(t *testing.T) { } func TestServer_VerifyEmail(t *testing.T) { - userResp := Tester.CreateHumanUser(CTX) + userResp := Instance.CreateHumanUser(CTX) tests := []struct { name string req *user.VerifyEmailRequest @@ -279,7 +278,7 @@ func TestServer_VerifyEmail(t *testing.T) { Details: &object.Details{ Sequence: 1, ChangeDate: timestamppb.Now(), - ResourceOwner: Tester.Organisation.ID, + ResourceOwner: Instance.DefaultOrg.Id, }, }, }, diff --git a/internal/api/grpc/user/v2/idp_link_integration_test.go b/internal/api/grpc/user/v2/integration_test/idp_link_test.go similarity index 67% rename from internal/api/grpc/user/v2/idp_link_integration_test.go rename to internal/api/grpc/user/v2/integration_test/idp_link_test.go index 99658b3024..2daa42b91a 100644 --- a/internal/api/grpc/user/v2/idp_link_integration_test.go +++ b/internal/api/grpc/user/v2/integration_test/idp_link_test.go @@ -10,6 +10,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "google.golang.org/grpc/metadata" "google.golang.org/protobuf/types/known/timestamppb" "github.com/zitadel/zitadel/internal/integration" @@ -18,7 +19,7 @@ import ( ) func TestServer_AddIDPLink(t *testing.T) { - idpID := Tester.AddGenericOAuthProvider(t, CTX) + idpResp := Instance.AddGenericOAuthProvider(IamCTX, Instance.DefaultOrg.Id) type args struct { ctx context.Context req *user.AddIDPLinkRequest @@ -36,7 +37,7 @@ func TestServer_AddIDPLink(t *testing.T) { &user.AddIDPLinkRequest{ UserId: "userID", IdpLink: &user.IDPLink{ - IdpId: idpID, + IdpId: idpResp.Id, UserId: "userID", UserName: "username", }, @@ -50,7 +51,7 @@ func TestServer_AddIDPLink(t *testing.T) { args: args{ CTX, &user.AddIDPLinkRequest{ - UserId: Tester.Users[integration.FirstInstanceUsersKey][integration.OrgOwner].ID, + UserId: Instance.Users.Get(integration.UserTypeOrgOwner).ID, IdpLink: &user.IDPLink{ IdpId: "idpID", UserId: "userID", @@ -66,9 +67,9 @@ func TestServer_AddIDPLink(t *testing.T) { args: args{ CTX, &user.AddIDPLinkRequest{ - UserId: Tester.Users[integration.FirstInstanceUsersKey][integration.OrgOwner].ID, + UserId: Instance.Users.Get(integration.UserTypeOrgOwner).ID, IdpLink: &user.IDPLink{ - IdpId: idpID, + IdpId: idpResp.Id, UserId: "userID", UserName: "username", }, @@ -77,7 +78,7 @@ func TestServer_AddIDPLink(t *testing.T) { want: &user.AddIDPLinkResponse{ Details: &object.Details{ ChangeDate: timestamppb.Now(), - ResourceOwner: Tester.Organisation.ID, + ResourceOwner: Instance.DefaultOrg.Id, }, }, wantErr: false, @@ -98,19 +99,24 @@ func TestServer_AddIDPLink(t *testing.T) { } func TestServer_ListIDPLinks(t *testing.T) { - orgResp := Tester.CreateOrganization(IamCTX, fmt.Sprintf("ListIDPLinks%d", time.Now().UnixNano()), fmt.Sprintf("%d@mouse.com", time.Now().UnixNano())) + orgResp := Instance.CreateOrganization(IamCTX, fmt.Sprintf("ListIDPLinks%d", time.Now().UnixNano()), fmt.Sprintf("%d@mouse.com", time.Now().UnixNano())) - instanceIdpID := Tester.AddGenericOAuthProvider(t, IamCTX) - userInstanceResp := Tester.CreateHumanUserVerified(IamCTX, orgResp.OrganizationId, fmt.Sprintf("%d@listidplinks.com", time.Now().UnixNano())) - Tester.CreateUserIDPlink(IamCTX, userInstanceResp.GetUserId(), "external_instance", instanceIdpID, "externalUsername_instance") + instanceIdpResp := Instance.AddGenericOAuthProvider(IamCTX, Instance.DefaultOrg.Id) + userInstanceResp := Instance.CreateHumanUserVerified(IamCTX, orgResp.OrganizationId, fmt.Sprintf("%d@listidplinks.com", time.Now().UnixNano())) + _, err := Instance.CreateUserIDPlink(IamCTX, userInstanceResp.GetUserId(), "external_instance", instanceIdpResp.Id, "externalUsername_instance") + require.NoError(t, err) - orgIdpID := Tester.AddOrgGenericOAuthProvider(t, IamCTX, orgResp.OrganizationId) - userOrgResp := Tester.CreateHumanUserVerified(IamCTX, orgResp.OrganizationId, fmt.Sprintf("%d@listidplinks.com", time.Now().UnixNano())) - Tester.CreateUserIDPlink(IamCTX, userOrgResp.GetUserId(), "external_org", orgIdpID, "externalUsername_org") + ctxOrg := metadata.AppendToOutgoingContext(IamCTX, "x-zitadel-orgid", orgResp.GetOrganizationId()) + orgIdpResp := Instance.AddOrgGenericOAuthProvider(ctxOrg, orgResp.OrganizationId) + userOrgResp := Instance.CreateHumanUserVerified(ctxOrg, orgResp.OrganizationId, fmt.Sprintf("%d@listidplinks.com", time.Now().UnixNano())) + _, err = Instance.CreateUserIDPlink(ctxOrg, userOrgResp.GetUserId(), "external_org", orgIdpResp.Id, "externalUsername_org") + require.NoError(t, err) - userMultipleResp := Tester.CreateHumanUserVerified(IamCTX, orgResp.OrganizationId, fmt.Sprintf("%d@listidplinks.com", time.Now().UnixNano())) - Tester.CreateUserIDPlink(IamCTX, userMultipleResp.GetUserId(), "external_multi", instanceIdpID, "externalUsername_multi") - Tester.CreateUserIDPlink(IamCTX, userMultipleResp.GetUserId(), "external_multi", orgIdpID, "externalUsername_multi") + userMultipleResp := Instance.CreateHumanUserVerified(IamCTX, orgResp.OrganizationId, fmt.Sprintf("%d@listidplinks.com", time.Now().UnixNano())) + _, err = Instance.CreateUserIDPlink(IamCTX, userMultipleResp.GetUserId(), "external_multi", instanceIdpResp.Id, "externalUsername_multi") + require.NoError(t, err) + _, err = Instance.CreateUserIDPlink(ctxOrg, userMultipleResp.GetUserId(), "external_multi", orgIdpResp.Id, "externalUsername_multi") + require.NoError(t, err) type args struct { ctx context.Context @@ -167,7 +173,7 @@ func TestServer_ListIDPLinks(t *testing.T) { }, Result: []*user.IDPLink{ { - IdpId: orgIdpID, + IdpId: orgIdpResp.Id, UserId: "external_org", UserName: "externalUsername_org", }, @@ -189,7 +195,7 @@ func TestServer_ListIDPLinks(t *testing.T) { }, Result: []*user.IDPLink{ { - IdpId: instanceIdpID, + IdpId: instanceIdpResp.Id, UserId: "external_instance", UserName: "externalUsername_instance", }, @@ -211,12 +217,12 @@ func TestServer_ListIDPLinks(t *testing.T) { }, Result: []*user.IDPLink{ { - IdpId: instanceIdpID, + IdpId: instanceIdpResp.Id, UserId: "external_multi", UserName: "externalUsername_multi", }, { - IdpId: orgIdpID, + IdpId: orgIdpResp.Id, UserId: "external_multi", UserName: "externalUsername_multi", }, @@ -252,17 +258,20 @@ func TestServer_ListIDPLinks(t *testing.T) { } func TestServer_RemoveIDPLink(t *testing.T) { - orgResp := Tester.CreateOrganization(IamCTX, fmt.Sprintf("ListIDPLinks%d", time.Now().UnixNano()), fmt.Sprintf("%d@mouse.com", time.Now().UnixNano())) + orgResp := Instance.CreateOrganization(IamCTX, fmt.Sprintf("ListIDPLinks%d", time.Now().UnixNano()), fmt.Sprintf("%d@mouse.com", time.Now().UnixNano())) - instanceIdpID := Tester.AddGenericOAuthProvider(t, IamCTX) - userInstanceResp := Tester.CreateHumanUserVerified(IamCTX, orgResp.OrganizationId, fmt.Sprintf("%d@listidplinks.com", time.Now().UnixNano())) - Tester.CreateUserIDPlink(IamCTX, userInstanceResp.GetUserId(), "external_instance", instanceIdpID, "externalUsername_instance") + instanceIdpResp := Instance.AddGenericOAuthProvider(IamCTX, Instance.DefaultOrg.Id) + userInstanceResp := Instance.CreateHumanUserVerified(IamCTX, orgResp.OrganizationId, fmt.Sprintf("%d@listidplinks.com", time.Now().UnixNano())) + _, err := Instance.CreateUserIDPlink(IamCTX, userInstanceResp.GetUserId(), "external_instance", instanceIdpResp.Id, "externalUsername_instance") + require.NoError(t, err) - orgIdpID := Tester.AddOrgGenericOAuthProvider(t, IamCTX, orgResp.OrganizationId) - userOrgResp := Tester.CreateHumanUserVerified(IamCTX, orgResp.OrganizationId, fmt.Sprintf("%d@listidplinks.com", time.Now().UnixNano())) - Tester.CreateUserIDPlink(IamCTX, userOrgResp.GetUserId(), "external_org", orgIdpID, "externalUsername_org") + ctxOrg := metadata.AppendToOutgoingContext(IamCTX, "x-zitadel-orgid", orgResp.GetOrganizationId()) + orgIdpResp := Instance.AddOrgGenericOAuthProvider(ctxOrg, orgResp.OrganizationId) + userOrgResp := Instance.CreateHumanUserVerified(ctxOrg, orgResp.OrganizationId, fmt.Sprintf("%d@listidplinks.com", time.Now().UnixNano())) + _, err = Instance.CreateUserIDPlink(ctxOrg, userOrgResp.GetUserId(), "external_org", orgIdpResp.Id, "externalUsername_org") + require.NoError(t, err) - userNoLinkResp := Tester.CreateHumanUserVerified(IamCTX, orgResp.OrganizationId, fmt.Sprintf("%d@listidplinks.com", time.Now().UnixNano())) + userNoLinkResp := Instance.CreateHumanUserVerified(IamCTX, orgResp.OrganizationId, fmt.Sprintf("%d@listidplinks.com", time.Now().UnixNano())) type args struct { ctx context.Context @@ -280,7 +289,7 @@ func TestServer_RemoveIDPLink(t *testing.T) { UserCTX, &user.RemoveIDPLinkRequest{ UserId: userOrgResp.GetUserId(), - IdpId: orgIdpID, + IdpId: orgIdpResp.Id, LinkedUserId: "external_org", }, }, @@ -292,7 +301,7 @@ func TestServer_RemoveIDPLink(t *testing.T) { CTX, &user.RemoveIDPLinkRequest{ UserId: userOrgResp.GetUserId(), - IdpId: orgIdpID, + IdpId: orgIdpResp.Id, LinkedUserId: "external_org", }, }, @@ -304,7 +313,7 @@ func TestServer_RemoveIDPLink(t *testing.T) { IamCTX, &user.RemoveIDPLinkRequest{ UserId: userOrgResp.GetUserId(), - IdpId: orgIdpID, + IdpId: orgIdpResp.Id, LinkedUserId: "external_org", }, }, @@ -321,7 +330,7 @@ func TestServer_RemoveIDPLink(t *testing.T) { IamCTX, &user.RemoveIDPLinkRequest{ UserId: userInstanceResp.GetUserId(), - IdpId: instanceIdpID, + IdpId: instanceIdpResp.Id, LinkedUserId: "external_instance", }, }, diff --git a/internal/api/grpc/user/v2/otp_integration_test.go b/internal/api/grpc/user/v2/integration_test/otp_test.go similarity index 59% rename from internal/api/grpc/user/v2/otp_integration_test.go rename to internal/api/grpc/user/v2/integration_test/otp_test.go index 52b30fbd38..ae7c040427 100644 --- a/internal/api/grpc/user/v2/otp_integration_test.go +++ b/internal/api/grpc/user/v2/integration_test/otp_test.go @@ -9,32 +9,31 @@ import ( "github.com/stretchr/testify/require" "google.golang.org/protobuf/types/known/timestamppb" + "github.com/zitadel/zitadel/internal/integration" "github.com/zitadel/zitadel/pkg/grpc/object/v2" "github.com/zitadel/zitadel/pkg/grpc/user/v2" - - "github.com/zitadel/zitadel/internal/integration" ) func TestServer_AddOTPSMS(t *testing.T) { - userID := Tester.CreateHumanUser(CTX).GetUserId() - Tester.RegisterUserPasskey(CTX, userID) - _, sessionToken, _, _ := Tester.CreateVerifiedWebAuthNSession(t, CTX, userID) + userID := Instance.CreateHumanUser(CTX).GetUserId() + Instance.RegisterUserPasskey(CTX, userID) + _, sessionToken, _, _ := Instance.CreateVerifiedWebAuthNSession(t, CTX, userID) - otherUser := Tester.CreateHumanUser(CTX).GetUserId() - Tester.RegisterUserPasskey(CTX, otherUser) - _, sessionTokenOtherUser, _, _ := Tester.CreateVerifiedWebAuthNSession(t, CTX, otherUser) + otherUser := Instance.CreateHumanUser(CTX).GetUserId() + Instance.RegisterUserPasskey(CTX, otherUser) + _, sessionTokenOtherUser, _, _ := Instance.CreateVerifiedWebAuthNSession(t, CTX, otherUser) - userVerified := Tester.CreateHumanUser(CTX) - _, err := Tester.Client.UserV2.VerifyPhone(CTX, &user.VerifyPhoneRequest{ + userVerified := Instance.CreateHumanUser(CTX) + _, err := Instance.Client.UserV2.VerifyPhone(CTX, &user.VerifyPhoneRequest{ UserId: userVerified.GetUserId(), VerificationCode: userVerified.GetPhoneCode(), }) require.NoError(t, err) - Tester.RegisterUserPasskey(CTX, userVerified.GetUserId()) - _, sessionTokenVerified, _, _ := Tester.CreateVerifiedWebAuthNSession(t, CTX, userVerified.GetUserId()) + Instance.RegisterUserPasskey(CTX, userVerified.GetUserId()) + _, sessionTokenVerified, _, _ := Instance.CreateVerifiedWebAuthNSession(t, CTX, userVerified.GetUserId()) - userVerified2 := Tester.CreateHumanUser(CTX) - _, err = Tester.Client.UserV2.VerifyPhone(CTX, &user.VerifyPhoneRequest{ + userVerified2 := Instance.CreateHumanUser(CTX) + _, err = Instance.Client.UserV2.VerifyPhone(CTX, &user.VerifyPhoneRequest{ UserId: userVerified2.GetUserId(), VerificationCode: userVerified2.GetPhoneCode(), }) @@ -61,7 +60,7 @@ func TestServer_AddOTPSMS(t *testing.T) { { name: "user mismatch", args: args{ - ctx: Tester.WithAuthorizationToken(context.Background(), sessionTokenOtherUser), + ctx: integration.WithAuthorizationToken(context.Background(), sessionTokenOtherUser), req: &user.AddOTPSMSRequest{ UserId: userID, }, @@ -71,7 +70,7 @@ func TestServer_AddOTPSMS(t *testing.T) { { name: "phone not verified", args: args{ - ctx: Tester.WithAuthorizationToken(context.Background(), sessionToken), + ctx: integration.WithAuthorizationToken(context.Background(), sessionToken), req: &user.AddOTPSMSRequest{ UserId: userID, }, @@ -81,14 +80,14 @@ func TestServer_AddOTPSMS(t *testing.T) { { name: "add success", args: args{ - ctx: Tester.WithAuthorizationToken(context.Background(), sessionTokenVerified), + ctx: integration.WithAuthorizationToken(context.Background(), sessionTokenVerified), req: &user.AddOTPSMSRequest{ UserId: userVerified.GetUserId(), }, }, want: &user.AddOTPSMSResponse{ Details: &object.Details{ - ResourceOwner: Tester.Organisation.ID, + ResourceOwner: Instance.DefaultOrg.Id, }, }, }, @@ -102,7 +101,7 @@ func TestServer_AddOTPSMS(t *testing.T) { }, want: &user.AddOTPSMSResponse{ Details: &object.Details{ - ResourceOwner: Tester.Organisation.ID, + ResourceOwner: Instance.DefaultOrg.Id, }, }, }, @@ -122,20 +121,20 @@ func TestServer_AddOTPSMS(t *testing.T) { } func TestServer_RemoveOTPSMS(t *testing.T) { - userID := Tester.CreateHumanUser(CTX).GetUserId() - Tester.RegisterUserPasskey(CTX, userID) - _, sessionToken, _, _ := Tester.CreateVerifiedWebAuthNSession(t, CTX, userID) + userID := Instance.CreateHumanUser(CTX).GetUserId() + Instance.RegisterUserPasskey(CTX, userID) + _, sessionToken, _, _ := Instance.CreateVerifiedWebAuthNSession(t, CTX, userID) - userVerified := Tester.CreateHumanUser(CTX) - Tester.RegisterUserPasskey(CTX, userVerified.GetUserId()) - _, sessionTokenVerified, _, _ := Tester.CreateVerifiedWebAuthNSession(t, CTX, userVerified.GetUserId()) - userVerifiedCtx := Tester.WithAuthorizationToken(context.Background(), sessionTokenVerified) - _, err := Tester.Client.UserV2.VerifyPhone(userVerifiedCtx, &user.VerifyPhoneRequest{ + userVerified := Instance.CreateHumanUser(CTX) + Instance.RegisterUserPasskey(CTX, userVerified.GetUserId()) + _, sessionTokenVerified, _, _ := Instance.CreateVerifiedWebAuthNSession(t, CTX, userVerified.GetUserId()) + userVerifiedCtx := integration.WithAuthorizationToken(context.Background(), sessionTokenVerified) + _, err := Instance.Client.UserV2.VerifyPhone(userVerifiedCtx, &user.VerifyPhoneRequest{ UserId: userVerified.GetUserId(), VerificationCode: userVerified.GetPhoneCode(), }) require.NoError(t, err) - _, err = Tester.Client.UserV2.AddOTPSMS(userVerifiedCtx, &user.AddOTPSMSRequest{UserId: userVerified.GetUserId()}) + _, err = Instance.Client.UserV2.AddOTPSMS(userVerifiedCtx, &user.AddOTPSMSRequest{UserId: userVerified.GetUserId()}) require.NoError(t, err) type args struct { @@ -151,7 +150,7 @@ func TestServer_RemoveOTPSMS(t *testing.T) { { name: "not added", args: args{ - ctx: Tester.WithAuthorizationToken(context.Background(), sessionToken), + ctx: integration.WithAuthorizationToken(context.Background(), sessionToken), req: &user.RemoveOTPSMSRequest{ UserId: userID, }, @@ -168,7 +167,7 @@ func TestServer_RemoveOTPSMS(t *testing.T) { }, want: &user.RemoveOTPSMSResponse{ Details: &object.Details{ - ResourceOwner: Tester.Organisation.ResourceOwner, + ResourceOwner: Instance.DefaultOrg.Details.ResourceOwner, }, }, }, @@ -188,25 +187,25 @@ func TestServer_RemoveOTPSMS(t *testing.T) { } func TestServer_AddOTPEmail(t *testing.T) { - userID := Tester.CreateHumanUser(CTX).GetUserId() - Tester.RegisterUserPasskey(CTX, userID) - _, sessionToken, _, _ := Tester.CreateVerifiedWebAuthNSession(t, CTX, userID) + userID := Instance.CreateHumanUser(CTX).GetUserId() + Instance.RegisterUserPasskey(CTX, userID) + _, sessionToken, _, _ := Instance.CreateVerifiedWebAuthNSession(t, CTX, userID) - otherUser := Tester.CreateHumanUser(CTX).GetUserId() - Tester.RegisterUserPasskey(CTX, otherUser) - _, sessionTokenOtherUser, _, _ := Tester.CreateVerifiedWebAuthNSession(t, CTX, otherUser) + otherUser := Instance.CreateHumanUser(CTX).GetUserId() + Instance.RegisterUserPasskey(CTX, otherUser) + _, sessionTokenOtherUser, _, _ := Instance.CreateVerifiedWebAuthNSession(t, CTX, otherUser) - userVerified := Tester.CreateHumanUser(CTX) - _, err := Tester.Client.UserV2.VerifyEmail(CTX, &user.VerifyEmailRequest{ + userVerified := Instance.CreateHumanUser(CTX) + _, err := Instance.Client.UserV2.VerifyEmail(CTX, &user.VerifyEmailRequest{ UserId: userVerified.GetUserId(), VerificationCode: userVerified.GetEmailCode(), }) require.NoError(t, err) - Tester.RegisterUserPasskey(CTX, userVerified.GetUserId()) - _, sessionTokenVerified, _, _ := Tester.CreateVerifiedWebAuthNSession(t, CTX, userVerified.GetUserId()) + Instance.RegisterUserPasskey(CTX, userVerified.GetUserId()) + _, sessionTokenVerified, _, _ := Instance.CreateVerifiedWebAuthNSession(t, CTX, userVerified.GetUserId()) - userVerified2 := Tester.CreateHumanUser(CTX) - _, err = Tester.Client.UserV2.VerifyEmail(CTX, &user.VerifyEmailRequest{ + userVerified2 := Instance.CreateHumanUser(CTX) + _, err = Instance.Client.UserV2.VerifyEmail(CTX, &user.VerifyEmailRequest{ UserId: userVerified2.GetUserId(), VerificationCode: userVerified2.GetEmailCode(), }) @@ -233,7 +232,7 @@ func TestServer_AddOTPEmail(t *testing.T) { { name: "user mismatch", args: args{ - ctx: Tester.WithAuthorizationToken(context.Background(), sessionTokenOtherUser), + ctx: integration.WithAuthorizationToken(context.Background(), sessionTokenOtherUser), req: &user.AddOTPEmailRequest{ UserId: userID, }, @@ -243,7 +242,7 @@ func TestServer_AddOTPEmail(t *testing.T) { { name: "email not verified", args: args{ - ctx: Tester.WithAuthorizationToken(context.Background(), sessionToken), + ctx: integration.WithAuthorizationToken(context.Background(), sessionToken), req: &user.AddOTPEmailRequest{ UserId: userID, }, @@ -253,7 +252,7 @@ func TestServer_AddOTPEmail(t *testing.T) { { name: "add success", args: args{ - ctx: Tester.WithAuthorizationToken(context.Background(), sessionTokenVerified), + ctx: integration.WithAuthorizationToken(context.Background(), sessionTokenVerified), req: &user.AddOTPEmailRequest{ UserId: userVerified.GetUserId(), }, @@ -261,7 +260,7 @@ func TestServer_AddOTPEmail(t *testing.T) { want: &user.AddOTPEmailResponse{ Details: &object.Details{ ChangeDate: timestamppb.Now(), - ResourceOwner: Tester.Organisation.ID, + ResourceOwner: Instance.DefaultOrg.Id, }, }, }, @@ -276,7 +275,7 @@ func TestServer_AddOTPEmail(t *testing.T) { want: &user.AddOTPEmailResponse{ Details: &object.Details{ ChangeDate: timestamppb.Now(), - ResourceOwner: Tester.Organisation.ID, + ResourceOwner: Instance.DefaultOrg.Id, }, }, }, @@ -296,20 +295,20 @@ func TestServer_AddOTPEmail(t *testing.T) { } func TestServer_RemoveOTPEmail(t *testing.T) { - userID := Tester.CreateHumanUser(CTX).GetUserId() - Tester.RegisterUserPasskey(CTX, userID) - _, sessionToken, _, _ := Tester.CreateVerifiedWebAuthNSession(t, CTX, userID) + userID := Instance.CreateHumanUser(CTX).GetUserId() + Instance.RegisterUserPasskey(CTX, userID) + _, sessionToken, _, _ := Instance.CreateVerifiedWebAuthNSession(t, CTX, userID) - userVerified := Tester.CreateHumanUser(CTX) - Tester.RegisterUserPasskey(CTX, userVerified.GetUserId()) - _, sessionTokenVerified, _, _ := Tester.CreateVerifiedWebAuthNSession(t, CTX, userVerified.GetUserId()) - userVerifiedCtx := Tester.WithAuthorizationToken(context.Background(), sessionTokenVerified) - _, err := Tester.Client.UserV2.VerifyEmail(userVerifiedCtx, &user.VerifyEmailRequest{ + userVerified := Instance.CreateHumanUser(CTX) + Instance.RegisterUserPasskey(CTX, userVerified.GetUserId()) + _, sessionTokenVerified, _, _ := Instance.CreateVerifiedWebAuthNSession(t, CTX, userVerified.GetUserId()) + userVerifiedCtx := integration.WithAuthorizationToken(context.Background(), sessionTokenVerified) + _, err := Instance.Client.UserV2.VerifyEmail(userVerifiedCtx, &user.VerifyEmailRequest{ UserId: userVerified.GetUserId(), VerificationCode: userVerified.GetEmailCode(), }) require.NoError(t, err) - _, err = Tester.Client.UserV2.AddOTPEmail(userVerifiedCtx, &user.AddOTPEmailRequest{UserId: userVerified.GetUserId()}) + _, err = Instance.Client.UserV2.AddOTPEmail(userVerifiedCtx, &user.AddOTPEmailRequest{UserId: userVerified.GetUserId()}) require.NoError(t, err) type args struct { @@ -325,7 +324,7 @@ func TestServer_RemoveOTPEmail(t *testing.T) { { name: "not added", args: args{ - ctx: Tester.WithAuthorizationToken(context.Background(), sessionToken), + ctx: integration.WithAuthorizationToken(context.Background(), sessionToken), req: &user.RemoveOTPEmailRequest{ UserId: userID, }, @@ -343,7 +342,7 @@ func TestServer_RemoveOTPEmail(t *testing.T) { want: &user.RemoveOTPEmailResponse{ Details: &object.Details{ ChangeDate: timestamppb.Now(), - ResourceOwner: Tester.Organisation.ResourceOwner, + ResourceOwner: Instance.DefaultOrg.Details.ResourceOwner, }, }, }, diff --git a/internal/api/grpc/user/v2/passkey_integration_test.go b/internal/api/grpc/user/v2/integration_test/passkey_test.go similarity index 92% rename from internal/api/grpc/user/v2/passkey_integration_test.go rename to internal/api/grpc/user/v2/integration_test/passkey_test.go index 12d3a6622b..6041e5e409 100644 --- a/internal/api/grpc/user/v2/passkey_integration_test.go +++ b/internal/api/grpc/user/v2/integration_test/passkey_test.go @@ -13,14 +13,13 @@ import ( "google.golang.org/protobuf/types/known/structpb" "google.golang.org/protobuf/types/known/timestamppb" + "github.com/zitadel/zitadel/internal/integration" "github.com/zitadel/zitadel/pkg/grpc/object/v2" "github.com/zitadel/zitadel/pkg/grpc/user/v2" - - "github.com/zitadel/zitadel/internal/integration" ) func TestServer_RegisterPasskey(t *testing.T) { - userID := Tester.CreateHumanUser(CTX).GetUserId() + userID := Instance.CreateHumanUser(CTX).GetUserId() reg, err := Client.CreatePasskeyRegistrationLink(CTX, &user.CreatePasskeyRegistrationLinkRequest{ UserId: userID, Medium: &user.CreatePasskeyRegistrationLinkRequest_ReturnCode{}, @@ -28,8 +27,8 @@ func TestServer_RegisterPasskey(t *testing.T) { require.NoError(t, err) // We also need a user session - Tester.RegisterUserPasskey(CTX, userID) - _, sessionToken, _, _ := Tester.CreateVerifiedWebAuthNSession(t, CTX, userID) + Instance.RegisterUserPasskey(CTX, userID) + _, sessionToken, _, _ := Instance.CreateVerifiedWebAuthNSession(t, CTX, userID) type args struct { ctx context.Context @@ -62,7 +61,7 @@ func TestServer_RegisterPasskey(t *testing.T) { want: &user.RegisterPasskeyResponse{ Details: &object.Details{ ChangeDate: timestamppb.Now(), - ResourceOwner: Tester.Organisation.ID, + ResourceOwner: Instance.DefaultOrg.Id, }, }, }, @@ -106,7 +105,7 @@ func TestServer_RegisterPasskey(t *testing.T) { { name: "user setting its own passkey", args: args{ - ctx: Tester.WithAuthorizationToken(CTX, sessionToken), + ctx: integration.WithAuthorizationToken(CTX, sessionToken), req: &user.RegisterPasskeyRequest{ UserId: userID, }, @@ -114,7 +113,7 @@ func TestServer_RegisterPasskey(t *testing.T) { want: &user.RegisterPasskeyResponse{ Details: &object.Details{ ChangeDate: timestamppb.Now(), - ResourceOwner: Tester.Organisation.ID, + ResourceOwner: Instance.DefaultOrg.Id, }, }, }, @@ -132,7 +131,7 @@ func TestServer_RegisterPasskey(t *testing.T) { if tt.want != nil { assert.NotEmpty(t, got.GetPasskeyId()) assert.NotEmpty(t, got.GetPublicKeyCredentialCreationOptions()) - _, err = Tester.WebAuthN.CreateAttestationResponse(got.GetPublicKeyCredentialCreationOptions()) + _, err = Instance.WebAuthN.CreateAttestationResponse(got.GetPublicKeyCredentialCreationOptions()) require.NoError(t, err) } }) @@ -142,7 +141,7 @@ func TestServer_RegisterPasskey(t *testing.T) { func TestServer_VerifyPasskeyRegistration(t *testing.T) { userID, pkr := userWithPasskeyRegistered(t) - attestationResponse, err := Tester.WebAuthN.CreateAttestationResponse(pkr.GetPublicKeyCredentialCreationOptions()) + attestationResponse, err := Instance.WebAuthN.CreateAttestationResponse(pkr.GetPublicKeyCredentialCreationOptions()) require.NoError(t, err) type args struct { @@ -181,7 +180,7 @@ func TestServer_VerifyPasskeyRegistration(t *testing.T) { want: &user.VerifyPasskeyRegistrationResponse{ Details: &object.Details{ ChangeDate: timestamppb.Now(), - ResourceOwner: Tester.Organisation.ID, + ResourceOwner: Instance.DefaultOrg.Id, }, }, }, @@ -216,7 +215,7 @@ func TestServer_VerifyPasskeyRegistration(t *testing.T) { } func TestServer_CreatePasskeyRegistrationLink(t *testing.T) { - userID := Tester.CreateHumanUser(CTX).GetUserId() + userID := Instance.CreateHumanUser(CTX).GetUserId() type args struct { ctx context.Context @@ -248,7 +247,7 @@ func TestServer_CreatePasskeyRegistrationLink(t *testing.T) { want: &user.CreatePasskeyRegistrationLinkResponse{ Details: &object.Details{ ChangeDate: timestamppb.Now(), - ResourceOwner: Tester.Organisation.ID, + ResourceOwner: Instance.DefaultOrg.Id, }, }, }, @@ -268,7 +267,7 @@ func TestServer_CreatePasskeyRegistrationLink(t *testing.T) { want: &user.CreatePasskeyRegistrationLinkResponse{ Details: &object.Details{ ChangeDate: timestamppb.Now(), - ResourceOwner: Tester.Organisation.ID, + ResourceOwner: Instance.DefaultOrg.Id, }, }, }, @@ -284,7 +283,7 @@ func TestServer_CreatePasskeyRegistrationLink(t *testing.T) { want: &user.CreatePasskeyRegistrationLinkResponse{ Details: &object.Details{ ChangeDate: timestamppb.Now(), - ResourceOwner: Tester.Organisation.ID, + ResourceOwner: Instance.DefaultOrg.Id, }, }, wantCode: true, @@ -309,7 +308,7 @@ func TestServer_CreatePasskeyRegistrationLink(t *testing.T) { } func userWithPasskeyRegistered(t *testing.T) (string, *user.RegisterPasskeyResponse) { - userID := Tester.CreateHumanUser(CTX).GetUserId() + userID := Instance.CreateHumanUser(CTX).GetUserId() return userID, passkeyRegister(t, userID) } @@ -335,7 +334,7 @@ func passkeyRegister(t *testing.T, userID string) *user.RegisterPasskeyResponse } func passkeyVerify(t *testing.T, userID string, pkr *user.RegisterPasskeyResponse) string { - attestationResponse, err := Tester.WebAuthN.CreateAttestationResponse(pkr.GetPublicKeyCredentialCreationOptions()) + attestationResponse, err := Instance.WebAuthN.CreateAttestationResponse(pkr.GetPublicKeyCredentialCreationOptions()) require.NoError(t, err) _, err = Client.VerifyPasskeyRegistration(CTX, &user.VerifyPasskeyRegistrationRequest{ @@ -349,7 +348,7 @@ func passkeyVerify(t *testing.T, userID string, pkr *user.RegisterPasskeyRespons } func TestServer_RemovePasskey(t *testing.T) { - userIDWithout := Tester.CreateHumanUser(CTX).GetUserId() + userIDWithout := Instance.CreateHumanUser(CTX).GetUserId() userIDRegistered, pkrRegistered := userWithPasskeyRegistered(t) userIDVerified, passkeyIDVerified := userWithPasskeyVerified(t) userIDVerifiedPermission, passkeyIDVerifiedPermission := userWithPasskeyVerified(t) @@ -396,7 +395,7 @@ func TestServer_RemovePasskey(t *testing.T) { want: &user.RemovePasskeyResponse{ Details: &object.Details{ ChangeDate: timestamppb.Now(), - ResourceOwner: Tester.Organisation.ID, + ResourceOwner: Instance.DefaultOrg.Id, }, }, }, @@ -423,7 +422,7 @@ func TestServer_RemovePasskey(t *testing.T) { want: &user.RemovePasskeyResponse{ Details: &object.Details{ ChangeDate: timestamppb.Now(), - ResourceOwner: Tester.Organisation.ID, + ResourceOwner: Instance.DefaultOrg.Id, }, }, }, @@ -454,7 +453,7 @@ func TestServer_RemovePasskey(t *testing.T) { } func TestServer_ListPasskeys(t *testing.T) { - userIDWithout := Tester.CreateHumanUser(CTX).GetUserId() + userIDWithout := Instance.CreateHumanUser(CTX).GetUserId() userIDRegistered, _ := userWithPasskeyRegistered(t) userIDVerified, passkeyIDVerified := userWithPasskeyVerified(t) diff --git a/internal/api/grpc/user/v2/password_integration_test.go b/internal/api/grpc/user/v2/integration_test/password_test.go similarity index 91% rename from internal/api/grpc/user/v2/password_integration_test.go rename to internal/api/grpc/user/v2/integration_test/password_test.go index f97d0467d7..a316b40df5 100644 --- a/internal/api/grpc/user/v2/password_integration_test.go +++ b/internal/api/grpc/user/v2/integration_test/password_test.go @@ -11,14 +11,13 @@ import ( "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/v2" user "github.com/zitadel/zitadel/pkg/grpc/user/v2" - - "github.com/zitadel/zitadel/internal/integration" ) func TestServer_RequestPasswordReset(t *testing.T) { - userID := Tester.CreateHumanUser(CTX).GetUserId() + userID := Instance.CreateHumanUser(CTX).GetUserId() tests := []struct { name string @@ -35,7 +34,7 @@ func TestServer_RequestPasswordReset(t *testing.T) { Details: &object.Details{ Sequence: 1, ChangeDate: timestamppb.Now(), - ResourceOwner: Tester.Organisation.ID, + ResourceOwner: Instance.DefaultOrg.Id, }, }, }, @@ -54,7 +53,7 @@ func TestServer_RequestPasswordReset(t *testing.T) { Details: &object.Details{ Sequence: 1, ChangeDate: timestamppb.Now(), - ResourceOwner: Tester.Organisation.ID, + ResourceOwner: Instance.DefaultOrg.Id, }, }, }, @@ -82,7 +81,7 @@ func TestServer_RequestPasswordReset(t *testing.T) { Details: &object.Details{ Sequence: 1, ChangeDate: timestamppb.Now(), - ResourceOwner: Tester.Organisation.ID, + ResourceOwner: Instance.DefaultOrg.Id, }, VerificationCode: gu.Ptr("xxx"), }, @@ -130,7 +129,7 @@ func TestServer_SetPassword(t *testing.T) { { name: "set successful", prepare: func(request *user.SetPasswordRequest) error { - userID := Tester.CreateHumanUser(CTX).GetUserId() + userID := Instance.CreateHumanUser(CTX).GetUserId() request.UserId = userID return nil }, @@ -145,14 +144,14 @@ func TestServer_SetPassword(t *testing.T) { want: &user.SetPasswordResponse{ Details: &object.Details{ ChangeDate: timestamppb.Now(), - ResourceOwner: Tester.Organisation.ID, + ResourceOwner: Instance.DefaultOrg.Id, }, }, }, { name: "change successful", prepare: func(request *user.SetPasswordRequest) error { - userID := Tester.CreateHumanUser(CTX).GetUserId() + userID := Instance.CreateHumanUser(CTX).GetUserId() request.UserId = userID _, err := Client.SetPassword(CTX, &user.SetPasswordRequest{ UserId: userID, @@ -176,14 +175,14 @@ func TestServer_SetPassword(t *testing.T) { want: &user.SetPasswordResponse{ Details: &object.Details{ ChangeDate: timestamppb.Now(), - ResourceOwner: Tester.Organisation.ID, + ResourceOwner: Instance.DefaultOrg.Id, }, }, }, { name: "set with code successful", prepare: func(request *user.SetPasswordRequest) error { - userID := Tester.CreateHumanUser(CTX).GetUserId() + userID := Instance.CreateHumanUser(CTX).GetUserId() request.UserId = userID resp, err := Client.PasswordReset(CTX, &user.PasswordResetRequest{ UserId: userID, @@ -210,7 +209,7 @@ func TestServer_SetPassword(t *testing.T) { want: &user.SetPasswordResponse{ Details: &object.Details{ ChangeDate: timestamppb.Now(), - ResourceOwner: Tester.Organisation.ID, + ResourceOwner: Instance.DefaultOrg.Id, }, }, }, diff --git a/internal/api/grpc/user/v2/phone_integration_test.go b/internal/api/grpc/user/v2/integration_test/phone_test.go similarity index 87% rename from internal/api/grpc/user/v2/phone_integration_test.go rename to internal/api/grpc/user/v2/integration_test/phone_test.go index ab88268ee1..456b77231d 100644 --- a/internal/api/grpc/user/v2/phone_integration_test.go +++ b/internal/api/grpc/user/v2/integration_test/phone_test.go @@ -13,14 +13,13 @@ import ( "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/v2" user "github.com/zitadel/zitadel/pkg/grpc/user/v2" - - "github.com/zitadel/zitadel/internal/integration" ) func TestServer_SetPhone(t *testing.T) { - userID := Tester.CreateHumanUser(CTX).GetUserId() + userID := Instance.CreateHumanUser(CTX).GetUserId() tests := []struct { name string @@ -38,7 +37,7 @@ func TestServer_SetPhone(t *testing.T) { Details: &object.Details{ Sequence: 1, ChangeDate: timestamppb.Now(), - ResourceOwner: Tester.Organisation.ID, + ResourceOwner: Instance.DefaultOrg.Id, }, }, }, @@ -55,7 +54,7 @@ func TestServer_SetPhone(t *testing.T) { Details: &object.Details{ Sequence: 1, ChangeDate: timestamppb.Now(), - ResourceOwner: Tester.Organisation.ID, + ResourceOwner: Instance.DefaultOrg.Id, }, }, }, @@ -72,7 +71,7 @@ func TestServer_SetPhone(t *testing.T) { Details: &object.Details{ Sequence: 1, ChangeDate: timestamppb.Now(), - ResourceOwner: Tester.Organisation.ID, + ResourceOwner: Instance.DefaultOrg.Id, }, VerificationCode: gu.Ptr("xxx"), }, @@ -90,7 +89,7 @@ func TestServer_SetPhone(t *testing.T) { Details: &object.Details{ Sequence: 1, ChangeDate: timestamppb.Now(), - ResourceOwner: Tester.Organisation.ID, + ResourceOwner: Instance.DefaultOrg.Id, }, }, }, @@ -123,8 +122,8 @@ func TestServer_SetPhone(t *testing.T) { } func TestServer_ResendPhoneCode(t *testing.T) { - userID := Tester.CreateHumanUser(CTX).GetUserId() - verifiedUserID := Tester.CreateHumanUserVerified(CTX, Tester.Organisation.ID, fmt.Sprintf("%d@mouse.com", time.Now().UnixNano())).GetUserId() + userID := Instance.CreateHumanUser(CTX).GetUserId() + verifiedUserID := Instance.CreateHumanUserVerified(CTX, Instance.DefaultOrg.Id, fmt.Sprintf("%d@mouse.com", time.Now().UnixNano())).GetUserId() tests := []struct { name string @@ -158,7 +157,7 @@ func TestServer_ResendPhoneCode(t *testing.T) { Details: &object.Details{ Sequence: 1, ChangeDate: timestamppb.Now(), - ResourceOwner: Tester.Organisation.ID, + ResourceOwner: Instance.DefaultOrg.Id, }, }, }, @@ -174,7 +173,7 @@ func TestServer_ResendPhoneCode(t *testing.T) { Details: &object.Details{ Sequence: 1, ChangeDate: timestamppb.Now(), - ResourceOwner: Tester.Organisation.ID, + ResourceOwner: Instance.DefaultOrg.Id, }, VerificationCode: gu.Ptr("xxx"), }, @@ -197,7 +196,7 @@ func TestServer_ResendPhoneCode(t *testing.T) { } func TestServer_VerifyPhone(t *testing.T) { - userResp := Tester.CreateHumanUser(CTX) + userResp := Instance.CreateHumanUser(CTX) tests := []struct { name string req *user.VerifyPhoneRequest @@ -230,7 +229,7 @@ func TestServer_VerifyPhone(t *testing.T) { Details: &object.Details{ Sequence: 1, ChangeDate: timestamppb.Now(), - ResourceOwner: Tester.Organisation.ID, + ResourceOwner: Instance.DefaultOrg.Id, }, }, }, @@ -249,13 +248,13 @@ func TestServer_VerifyPhone(t *testing.T) { } func TestServer_RemovePhone(t *testing.T) { - userResp := Tester.CreateHumanUser(CTX) - failResp := Tester.CreateHumanUserNoPhone(CTX) - otherUser := Tester.CreateHumanUser(CTX).GetUserId() - doubleRemoveUser := Tester.CreateHumanUser(CTX) + userResp := Instance.CreateHumanUser(CTX) + failResp := Instance.CreateHumanUserNoPhone(CTX) + otherUser := Instance.CreateHumanUser(CTX).GetUserId() + doubleRemoveUser := Instance.CreateHumanUser(CTX) - Tester.RegisterUserPasskey(CTX, otherUser) - _, sessionTokenOtherUser, _, _ := Tester.CreateVerifiedWebAuthNSession(t, CTX, otherUser) + Instance.RegisterUserPasskey(CTX, otherUser) + _, sessionTokenOtherUser, _, _ := Instance.CreateVerifiedWebAuthNSession(t, CTX, otherUser) tests := []struct { name string @@ -275,7 +274,7 @@ func TestServer_RemovePhone(t *testing.T) { Details: &object.Details{ Sequence: 1, ChangeDate: timestamppb.Now(), - ResourceOwner: Tester.Organisation.ID, + ResourceOwner: Instance.DefaultOrg.Id, }, }, dep: func(ctx context.Context, userID string) (*user.RemovePhoneResponse, error) { @@ -317,7 +316,7 @@ func TestServer_RemovePhone(t *testing.T) { }, { name: "other user, no permission", - ctx: Tester.WithAuthorizationToken(CTX, sessionTokenOtherUser), + ctx: integration.WithAuthorizationToken(CTX, sessionTokenOtherUser), req: &user.RemovePhoneRequest{ UserId: userResp.GetUserId(), }, diff --git a/internal/api/grpc/user/v2/query_integration_test.go b/internal/api/grpc/user/v2/integration_test/query_test.go similarity index 93% rename from internal/api/grpc/user/v2/query_integration_test.go rename to internal/api/grpc/user/v2/integration_test/query_test.go index 7509a9d430..5d0835d9ae 100644 --- a/internal/api/grpc/user/v2/query_integration_test.go +++ b/internal/api/grpc/user/v2/integration_test/query_test.go @@ -13,14 +13,13 @@ import ( "github.com/stretchr/testify/require" "google.golang.org/protobuf/types/known/timestamppb" + "github.com/zitadel/zitadel/internal/integration" "github.com/zitadel/zitadel/pkg/grpc/object/v2" "github.com/zitadel/zitadel/pkg/grpc/user/v2" - - "github.com/zitadel/zitadel/internal/integration" ) func TestServer_GetUserByID(t *testing.T) { - orgResp := Tester.CreateOrganization(IamCTX, fmt.Sprintf("GetUserByIDOrg%d", time.Now().UnixNano()), fmt.Sprintf("%d@mouse.com", time.Now().UnixNano())) + orgResp := Instance.CreateOrganization(IamCTX, fmt.Sprintf("GetUserByIDOrg%d", time.Now().UnixNano()), fmt.Sprintf("%d@mouse.com", time.Now().UnixNano())) type args struct { ctx context.Context req *user.GetUserByIDRequest @@ -64,7 +63,7 @@ func TestServer_GetUserByID(t *testing.T) { IamCTX, &user.GetUserByIDRequest{}, func(ctx context.Context, username string, request *user.GetUserByIDRequest) (*userAttr, error) { - resp := Tester.CreateHumanUserVerified(ctx, orgResp.OrganizationId, username) + resp := Instance.CreateHumanUserVerified(ctx, orgResp.OrganizationId, username) request.UserId = resp.GetUserId() return &userAttr{resp.GetUserId(), username, nil, resp.GetDetails()}, nil }, @@ -108,9 +107,9 @@ func TestServer_GetUserByID(t *testing.T) { IamCTX, &user.GetUserByIDRequest{}, func(ctx context.Context, username string, request *user.GetUserByIDRequest) (*userAttr, error) { - resp := Tester.CreateHumanUserVerified(ctx, orgResp.OrganizationId, username) + resp := Instance.CreateHumanUserVerified(ctx, orgResp.OrganizationId, username) request.UserId = resp.GetUserId() - details := Tester.SetUserPassword(ctx, resp.GetUserId(), integration.UserPassword, true) + details := Instance.SetUserPassword(ctx, resp.GetUserId(), integration.UserPassword, true) return &userAttr{resp.GetUserId(), username, details.GetChangeDate(), resp.GetDetails()}, nil }, }, @@ -181,7 +180,7 @@ func TestServer_GetUserByID(t *testing.T) { } } assert.Equal(ttt, tt.want.User, got.User) - integration.AssertDetails(t, tt.want, got) + integration.AssertDetails(ttt, tt.want, got) }, retryDuration, time.Second) }) } @@ -190,7 +189,7 @@ func TestServer_GetUserByID(t *testing.T) { func TestServer_GetUserByID_Permission(t *testing.T) { timeNow := time.Now().UTC() newOrgOwnerEmail := fmt.Sprintf("%d@permission.get.com", timeNow.UnixNano()) - newOrg := Tester.CreateOrganization(IamCTX, fmt.Sprintf("GetHuman%d", time.Now().UnixNano()), newOrgOwnerEmail) + newOrg := Instance.CreateOrganization(IamCTX, fmt.Sprintf("GetHuman%d", time.Now().UnixNano()), newOrgOwnerEmail) newUserID := newOrg.CreatedAdmins[0].GetUserId() type args struct { ctx context.Context @@ -330,8 +329,8 @@ type userAttr struct { } func TestServer_ListUsers(t *testing.T) { - orgResp := Tester.CreateOrganization(IamCTX, fmt.Sprintf("ListUsersOrg%d", time.Now().UnixNano()), fmt.Sprintf("%d@mouse.com", time.Now().UnixNano())) - userResp := Tester.CreateHumanUserVerified(IamCTX, orgResp.OrganizationId, fmt.Sprintf("%d@listusers.com", time.Now().UnixNano())) + orgResp := Instance.CreateOrganization(IamCTX, fmt.Sprintf("ListUsersOrg%d", time.Now().UnixNano()), fmt.Sprintf("%d@mouse.com", time.Now().UnixNano())) + userResp := Instance.CreateHumanUserVerified(IamCTX, orgResp.OrganizationId, fmt.Sprintf("%d@listusers.com", time.Now().UnixNano())) type args struct { ctx context.Context count int @@ -378,7 +377,7 @@ func TestServer_ListUsers(t *testing.T) { infos := make([]userAttr, len(usernames)) userIDs := make([]string, len(usernames)) for i, username := range usernames { - resp := Tester.CreateHumanUserVerified(ctx, orgResp.OrganizationId, username) + resp := Instance.CreateHumanUserVerified(ctx, orgResp.OrganizationId, username) userIDs[i] = resp.GetUserId() infos[i] = userAttr{resp.GetUserId(), username, nil, resp.GetDetails()} } @@ -432,9 +431,9 @@ func TestServer_ListUsers(t *testing.T) { infos := make([]userAttr, len(usernames)) userIDs := make([]string, len(usernames)) for i, username := range usernames { - resp := Tester.CreateHumanUserVerified(ctx, orgResp.OrganizationId, username) + resp := Instance.CreateHumanUserVerified(ctx, orgResp.OrganizationId, username) userIDs[i] = resp.GetUserId() - details := Tester.SetUserPassword(ctx, resp.GetUserId(), integration.UserPassword, true) + details := Instance.SetUserPassword(ctx, resp.GetUserId(), integration.UserPassword, true) infos[i] = userAttr{resp.GetUserId(), username, details.GetChangeDate(), resp.GetDetails()} } request.Queries = append(request.Queries, InUserIDsQuery(userIDs)) @@ -489,7 +488,7 @@ func TestServer_ListUsers(t *testing.T) { infos := make([]userAttr, len(usernames)) userIDs := make([]string, len(usernames)) for i, username := range usernames { - resp := Tester.CreateHumanUserVerified(ctx, orgResp.OrganizationId, username) + resp := Instance.CreateHumanUserVerified(ctx, orgResp.OrganizationId, username) userIDs[i] = resp.GetUserId() infos[i] = userAttr{resp.GetUserId(), username, nil, resp.GetDetails()} } @@ -585,7 +584,7 @@ func TestServer_ListUsers(t *testing.T) { infos := make([]userAttr, len(usernames)) userIDs := make([]string, len(usernames)) for i, username := range usernames { - resp := Tester.CreateHumanUserVerified(ctx, orgResp.OrganizationId, username) + resp := Instance.CreateHumanUserVerified(ctx, orgResp.OrganizationId, username) userIDs[i] = resp.GetUserId() infos[i] = userAttr{resp.GetUserId(), username, nil, resp.GetDetails()} request.Queries = append(request.Queries, UsernameQuery(username)) @@ -638,7 +637,7 @@ func TestServer_ListUsers(t *testing.T) { func(ctx context.Context, usernames []string, request *user.ListUsersRequest) ([]userAttr, error) { infos := make([]userAttr, len(usernames)) for i, username := range usernames { - resp := Tester.CreateHumanUserVerified(ctx, orgResp.OrganizationId, username) + resp := Instance.CreateHumanUserVerified(ctx, orgResp.OrganizationId, username) infos[i] = userAttr{resp.GetUserId(), username, nil, resp.GetDetails()} } request.Queries = append(request.Queries, InUserEmailsQuery(usernames)) @@ -690,7 +689,7 @@ func TestServer_ListUsers(t *testing.T) { func(ctx context.Context, usernames []string, request *user.ListUsersRequest) ([]userAttr, error) { infos := make([]userAttr, len(usernames)) for i, username := range usernames { - resp := Tester.CreateHumanUserVerified(ctx, orgResp.OrganizationId, username) + resp := Instance.CreateHumanUserVerified(ctx, orgResp.OrganizationId, username) infos[i] = userAttr{resp.GetUserId(), username, nil, resp.GetDetails()} } request.Queries = append(request.Queries, InUserEmailsQuery(usernames)) @@ -801,11 +800,11 @@ func TestServer_ListUsers(t *testing.T) { 3, &user.ListUsersRequest{}, func(ctx context.Context, usernames []string, request *user.ListUsersRequest) ([]userAttr, error) { - orgResp := Tester.CreateOrganization(ctx, fmt.Sprintf("ListUsersResourceowner%d", time.Now().UnixNano()), fmt.Sprintf("%d@mouse.com", time.Now().UnixNano())) + orgResp := Instance.CreateOrganization(ctx, fmt.Sprintf("ListUsersResourceowner%d", time.Now().UnixNano()), fmt.Sprintf("%d@mouse.com", time.Now().UnixNano())) infos := make([]userAttr, len(usernames)) for i, username := range usernames { - resp := Tester.CreateHumanUserVerified(ctx, orgResp.OrganizationId, username) + resp := Instance.CreateHumanUserVerified(ctx, orgResp.OrganizationId, username) infos[i] = userAttr{resp.GetUserId(), username, nil, resp.GetDetails()} } request.Queries = append(request.Queries, OrganizationIdQuery(orgResp.OrganizationId)) @@ -935,7 +934,7 @@ func TestServer_ListUsers(t *testing.T) { for i := range tt.want.Result { assert.Contains(ttt, got.Result, tt.want.Result[i]) } - integration.AssertListDetails(t, tt.want, got) + integration.AssertListDetails(ttt, tt.want, got) }, retryDuration, time.Millisecond*100, "timeout waiting for expected user result") }) } diff --git a/internal/api/grpc/user/v2/totp_integration_test.go b/internal/api/grpc/user/v2/integration_test/totp_test.go similarity index 72% rename from internal/api/grpc/user/v2/totp_integration_test.go rename to internal/api/grpc/user/v2/integration_test/totp_test.go index 474aed95b8..e65756c1c1 100644 --- a/internal/api/grpc/user/v2/totp_integration_test.go +++ b/internal/api/grpc/user/v2/integration_test/totp_test.go @@ -12,22 +12,21 @@ import ( "github.com/stretchr/testify/require" "google.golang.org/protobuf/types/known/timestamppb" + "github.com/zitadel/zitadel/internal/integration" "github.com/zitadel/zitadel/pkg/grpc/object/v2" "github.com/zitadel/zitadel/pkg/grpc/user/v2" - - "github.com/zitadel/zitadel/internal/integration" ) func TestServer_RegisterTOTP(t *testing.T) { - userID := Tester.CreateHumanUser(CTX).GetUserId() - Tester.RegisterUserPasskey(CTX, userID) - _, sessionToken, _, _ := Tester.CreateVerifiedWebAuthNSession(t, CTX, userID) - ctx := Tester.WithAuthorizationToken(CTX, sessionToken) + userID := Instance.CreateHumanUser(CTX).GetUserId() + Instance.RegisterUserPasskey(CTX, userID) + _, sessionToken, _, _ := Instance.CreateVerifiedWebAuthNSession(t, CTX, userID) + ctx := integration.WithAuthorizationToken(CTX, sessionToken) - otherUser := Tester.CreateHumanUser(CTX).GetUserId() - Tester.RegisterUserPasskey(CTX, otherUser) - _, sessionTokenOtherUser, _, _ := Tester.CreateVerifiedWebAuthNSession(t, CTX, otherUser) - ctxOtherUser := Tester.WithAuthorizationToken(CTX, sessionTokenOtherUser) + otherUser := Instance.CreateHumanUser(CTX).GetUserId() + Instance.RegisterUserPasskey(CTX, otherUser) + _, sessionTokenOtherUser, _, _ := Instance.CreateVerifiedWebAuthNSession(t, CTX, otherUser) + ctxOtherUser := integration.WithAuthorizationToken(CTX, sessionTokenOtherUser) type args struct { ctx context.Context @@ -68,7 +67,7 @@ func TestServer_RegisterTOTP(t *testing.T) { want: &user.RegisterTOTPResponse{ Details: &object.Details{ ChangeDate: timestamppb.Now(), - ResourceOwner: Tester.Organisation.ID, + ResourceOwner: Instance.DefaultOrg.Id, }, }, }, @@ -83,7 +82,7 @@ func TestServer_RegisterTOTP(t *testing.T) { want: &user.RegisterTOTPResponse{ Details: &object.Details{ ChangeDate: timestamppb.Now(), - ResourceOwner: Tester.Organisation.ID, + ResourceOwner: Instance.DefaultOrg.Id, }, }, }, @@ -105,10 +104,10 @@ func TestServer_RegisterTOTP(t *testing.T) { } func TestServer_VerifyTOTPRegistration(t *testing.T) { - userID := Tester.CreateHumanUser(CTX).GetUserId() - Tester.RegisterUserPasskey(CTX, userID) - _, sessionToken, _, _ := Tester.CreateVerifiedWebAuthNSession(t, CTX, userID) - ctx := Tester.WithAuthorizationToken(CTX, sessionToken) + userID := Instance.CreateHumanUser(CTX).GetUserId() + Instance.RegisterUserPasskey(CTX, userID) + _, sessionToken, _, _ := Instance.CreateVerifiedWebAuthNSession(t, CTX, userID) + ctx := integration.WithAuthorizationToken(CTX, sessionToken) reg, err := Client.RegisterTOTP(ctx, &user.RegisterTOTPRequest{ UserId: userID, @@ -117,10 +116,10 @@ func TestServer_VerifyTOTPRegistration(t *testing.T) { code, err := totp.GenerateCode(reg.Secret, time.Now()) require.NoError(t, err) - otherUser := Tester.CreateHumanUser(CTX).GetUserId() - Tester.RegisterUserPasskey(CTX, otherUser) - _, sessionTokenOtherUser, _, _ := Tester.CreateVerifiedWebAuthNSession(t, CTX, otherUser) - ctxOtherUser := Tester.WithAuthorizationToken(CTX, sessionTokenOtherUser) + otherUser := Instance.CreateHumanUser(CTX).GetUserId() + Instance.RegisterUserPasskey(CTX, otherUser) + _, sessionTokenOtherUser, _, _ := Instance.CreateVerifiedWebAuthNSession(t, CTX, otherUser) + ctxOtherUser := integration.WithAuthorizationToken(CTX, sessionTokenOtherUser) regOtherUser, err := Client.RegisterTOTP(CTX, &user.RegisterTOTPRequest{ UserId: otherUser, @@ -172,7 +171,7 @@ func TestServer_VerifyTOTPRegistration(t *testing.T) { want: &user.VerifyTOTPRegistrationResponse{ Details: &object.Details{ ChangeDate: timestamppb.Now(), - ResourceOwner: Tester.Organisation.ResourceOwner, + ResourceOwner: Instance.DefaultOrg.Details.ResourceOwner, }, }, }, @@ -188,7 +187,7 @@ func TestServer_VerifyTOTPRegistration(t *testing.T) { want: &user.VerifyTOTPRegistrationResponse{ Details: &object.Details{ ChangeDate: timestamppb.Now(), - ResourceOwner: Tester.Organisation.ResourceOwner, + ResourceOwner: Instance.DefaultOrg.Details.ResourceOwner, }, }, }, @@ -208,15 +207,15 @@ func TestServer_VerifyTOTPRegistration(t *testing.T) { } func TestServer_RemoveTOTP(t *testing.T) { - userID := Tester.CreateHumanUser(CTX).GetUserId() - Tester.RegisterUserPasskey(CTX, userID) - _, sessionToken, _, _ := Tester.CreateVerifiedWebAuthNSession(t, CTX, userID) + userID := Instance.CreateHumanUser(CTX).GetUserId() + Instance.RegisterUserPasskey(CTX, userID) + _, sessionToken, _, _ := Instance.CreateVerifiedWebAuthNSession(t, CTX, userID) - userVerified := Tester.CreateHumanUser(CTX) - Tester.RegisterUserPasskey(CTX, userVerified.GetUserId()) - _, sessionTokenVerified, _, _ := Tester.CreateVerifiedWebAuthNSession(t, CTX, userVerified.GetUserId()) - userVerifiedCtx := Tester.WithAuthorizationToken(context.Background(), sessionTokenVerified) - _, err := Tester.Client.UserV2.VerifyPhone(userVerifiedCtx, &user.VerifyPhoneRequest{ + userVerified := Instance.CreateHumanUser(CTX) + Instance.RegisterUserPasskey(CTX, userVerified.GetUserId()) + _, sessionTokenVerified, _, _ := Instance.CreateVerifiedWebAuthNSession(t, CTX, userVerified.GetUserId()) + userVerifiedCtx := integration.WithAuthorizationToken(context.Background(), sessionTokenVerified) + _, err := Instance.Client.UserV2.VerifyPhone(userVerifiedCtx, &user.VerifyPhoneRequest{ UserId: userVerified.GetUserId(), VerificationCode: userVerified.GetPhoneCode(), }) @@ -248,7 +247,7 @@ func TestServer_RemoveTOTP(t *testing.T) { { name: "not added", args: args{ - ctx: Tester.WithAuthorizationToken(context.Background(), sessionToken), + ctx: integration.WithAuthorizationToken(context.Background(), sessionToken), req: &user.RemoveTOTPRequest{ UserId: userID, }, @@ -265,7 +264,7 @@ func TestServer_RemoveTOTP(t *testing.T) { }, want: &user.RemoveTOTPResponse{ Details: &object.Details{ - ResourceOwner: Tester.Organisation.ResourceOwner, + ResourceOwner: Instance.DefaultOrg.Details.ResourceOwner, }, }, }, diff --git a/internal/api/grpc/user/v2/u2f_integration_test.go b/internal/api/grpc/user/v2/integration_test/u2f_test.go similarity index 82% rename from internal/api/grpc/user/v2/u2f_integration_test.go rename to internal/api/grpc/user/v2/integration_test/u2f_test.go index c4d4c33071..b8af753f85 100644 --- a/internal/api/grpc/user/v2/u2f_integration_test.go +++ b/internal/api/grpc/user/v2/integration_test/u2f_test.go @@ -11,21 +11,20 @@ import ( "google.golang.org/protobuf/types/known/structpb" "google.golang.org/protobuf/types/known/timestamppb" + "github.com/zitadel/zitadel/internal/integration" "github.com/zitadel/zitadel/pkg/grpc/object/v2" "github.com/zitadel/zitadel/pkg/grpc/user/v2" - - "github.com/zitadel/zitadel/internal/integration" ) func TestServer_RegisterU2F(t *testing.T) { - userID := Tester.CreateHumanUser(CTX).GetUserId() - otherUser := Tester.CreateHumanUser(CTX).GetUserId() + userID := Instance.CreateHumanUser(CTX).GetUserId() + otherUser := Instance.CreateHumanUser(CTX).GetUserId() // We also need a user session - Tester.RegisterUserPasskey(CTX, userID) - _, sessionToken, _, _ := Tester.CreateVerifiedWebAuthNSession(t, CTX, userID) - Tester.RegisterUserPasskey(CTX, otherUser) - _, sessionTokenOtherUser, _, _ := Tester.CreateVerifiedWebAuthNSession(t, CTX, otherUser) + Instance.RegisterUserPasskey(CTX, userID) + _, sessionToken, _, _ := Instance.CreateVerifiedWebAuthNSession(t, CTX, userID) + Instance.RegisterUserPasskey(CTX, otherUser) + _, sessionTokenOtherUser, _, _ := Instance.CreateVerifiedWebAuthNSession(t, CTX, otherUser) type args struct { ctx context.Context @@ -56,14 +55,14 @@ func TestServer_RegisterU2F(t *testing.T) { want: &user.RegisterU2FResponse{ Details: &object.Details{ ChangeDate: timestamppb.Now(), - ResourceOwner: Tester.Organisation.ID, + ResourceOwner: Instance.DefaultOrg.Id, }, }, }, { name: "other user, no permission", args: args{ - ctx: Tester.WithAuthorizationToken(CTX, sessionTokenOtherUser), + ctx: integration.WithAuthorizationToken(CTX, sessionTokenOtherUser), req: &user.RegisterU2FRequest{ UserId: userID, }, @@ -73,7 +72,7 @@ func TestServer_RegisterU2F(t *testing.T) { { name: "user setting its own passkey", args: args{ - ctx: Tester.WithAuthorizationToken(CTX, sessionToken), + ctx: integration.WithAuthorizationToken(CTX, sessionToken), req: &user.RegisterU2FRequest{ UserId: userID, }, @@ -81,7 +80,7 @@ func TestServer_RegisterU2F(t *testing.T) { want: &user.RegisterU2FResponse{ Details: &object.Details{ ChangeDate: timestamppb.Now(), - ResourceOwner: Tester.Organisation.ID, + ResourceOwner: Instance.DefaultOrg.Id, }, }, }, @@ -99,7 +98,7 @@ func TestServer_RegisterU2F(t *testing.T) { if tt.want != nil { assert.NotEmpty(t, got.GetU2FId()) assert.NotEmpty(t, got.GetPublicKeyCredentialCreationOptions()) - _, err = Tester.WebAuthN.CreateAttestationResponse(got.GetPublicKeyCredentialCreationOptions()) + _, err = Instance.WebAuthN.CreateAttestationResponse(got.GetPublicKeyCredentialCreationOptions()) require.NoError(t, err) } }) @@ -109,7 +108,7 @@ func TestServer_RegisterU2F(t *testing.T) { func TestServer_VerifyU2FRegistration(t *testing.T) { ctx, userID, pkr := ctxFromNewUserWithRegisteredU2F(t) - attestationResponse, err := Tester.WebAuthN.CreateAttestationResponse(pkr.GetPublicKeyCredentialCreationOptions()) + attestationResponse, err := Instance.WebAuthN.CreateAttestationResponse(pkr.GetPublicKeyCredentialCreationOptions()) require.NoError(t, err) type args struct { @@ -147,7 +146,7 @@ func TestServer_VerifyU2FRegistration(t *testing.T) { want: &user.VerifyU2FRegistrationResponse{ Details: &object.Details{ ChangeDate: timestamppb.Now(), - ResourceOwner: Tester.Organisation.ID, + ResourceOwner: Instance.DefaultOrg.Id, }, }, }, @@ -182,10 +181,10 @@ func TestServer_VerifyU2FRegistration(t *testing.T) { } func ctxFromNewUserWithRegisteredU2F(t *testing.T) (context.Context, string, *user.RegisterU2FResponse) { - userID := Tester.CreateHumanUser(CTX).GetUserId() - Tester.RegisterUserPasskey(CTX, userID) - _, sessionToken, _, _ := Tester.CreateVerifiedWebAuthNSession(t, CTX, userID) - ctx := Tester.WithAuthorizationToken(CTX, sessionToken) + userID := Instance.CreateHumanUser(CTX).GetUserId() + Instance.RegisterUserPasskey(CTX, userID) + _, sessionToken, _, _ := Instance.CreateVerifiedWebAuthNSession(t, CTX, userID) + ctx := integration.WithAuthorizationToken(CTX, sessionToken) pkr, err := Client.RegisterU2F(ctx, &user.RegisterU2FRequest{ UserId: userID, @@ -198,7 +197,7 @@ func ctxFromNewUserWithRegisteredU2F(t *testing.T) (context.Context, string, *us func ctxFromNewUserWithVerifiedU2F(t *testing.T) (context.Context, string, string) { ctx, userID, pkr := ctxFromNewUserWithRegisteredU2F(t) - attestationResponse, err := Tester.WebAuthN.CreateAttestationResponse(pkr.GetPublicKeyCredentialCreationOptions()) + attestationResponse, err := Instance.WebAuthN.CreateAttestationResponse(pkr.GetPublicKeyCredentialCreationOptions()) require.NoError(t, err) _, err = Client.VerifyU2FRegistration(ctx, &user.VerifyU2FRegistrationRequest{ @@ -212,7 +211,7 @@ func ctxFromNewUserWithVerifiedU2F(t *testing.T) (context.Context, string, strin } func TestServer_RemoveU2F(t *testing.T) { - userIDWithout := Tester.CreateHumanUser(CTX).GetUserId() + userIDWithout := Instance.CreateHumanUser(CTX).GetUserId() ctxRegistered, userIDRegistered, pkrRegistered := ctxFromNewUserWithRegisteredU2F(t) _, userIDVerified, u2fVerified := ctxFromNewUserWithVerifiedU2F(t) _, userIDVerifiedPermission, u2fVerifiedPermission := ctxFromNewUserWithVerifiedU2F(t) @@ -259,7 +258,7 @@ func TestServer_RemoveU2F(t *testing.T) { want: &user.RemoveU2FResponse{ Details: &object.Details{ ChangeDate: timestamppb.Now(), - ResourceOwner: Tester.Organisation.ID, + ResourceOwner: Instance.DefaultOrg.Id, }, }, }, @@ -286,7 +285,7 @@ func TestServer_RemoveU2F(t *testing.T) { want: &user.RemoveU2FResponse{ Details: &object.Details{ ChangeDate: timestamppb.Now(), - ResourceOwner: Tester.Organisation.ID, + ResourceOwner: Instance.DefaultOrg.Id, }, }, }, diff --git a/internal/api/grpc/user/v2/user_integration_test.go b/internal/api/grpc/user/v2/integration_test/user_test.go similarity index 88% rename from internal/api/grpc/user/v2/user_integration_test.go rename to internal/api/grpc/user/v2/integration_test/user_test.go index e762c10181..cab073c616 100644 --- a/internal/api/grpc/user/v2/user_integration_test.go +++ b/internal/api/grpc/user/v2/integration_test/user_test.go @@ -15,16 +15,13 @@ import ( "github.com/stretchr/testify/require" "google.golang.org/grpc/codes" "google.golang.org/grpc/status" - "google.golang.org/protobuf/types/known/structpb" "google.golang.org/protobuf/types/known/timestamppb" - "github.com/zitadel/zitadel/pkg/grpc/object/v2" - "github.com/zitadel/zitadel/pkg/grpc/user/v2" - - "github.com/zitadel/zitadel/internal/api/grpc" "github.com/zitadel/zitadel/internal/integration" "github.com/zitadel/zitadel/pkg/grpc/idp" mgmt "github.com/zitadel/zitadel/pkg/grpc/management" + "github.com/zitadel/zitadel/pkg/grpc/object/v2" + "github.com/zitadel/zitadel/pkg/grpc/user/v2" ) var ( @@ -32,30 +29,28 @@ var ( IamCTX context.Context UserCTX context.Context SystemCTX context.Context - ErrCTX context.Context - Tester *integration.Tester + Instance *integration.Instance Client user.UserServiceClient ) func TestMain(m *testing.M) { os.Exit(func() int { - ctx, errCtx, cancel := integration.Contexts(time.Hour) + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Minute) defer cancel() - Tester = integration.NewTester(ctx) - defer Tester.Done() + Instance = integration.NewInstance(ctx) - UserCTX = Tester.WithAuthorization(ctx, integration.Login) - IamCTX = Tester.WithAuthorization(ctx, integration.IAMOwner) - SystemCTX = Tester.WithAuthorization(ctx, integration.SystemUser) - CTX, ErrCTX = Tester.WithAuthorization(ctx, integration.OrgOwner), errCtx - Client = Tester.Client.UserV2 + UserCTX = Instance.WithAuthorization(ctx, integration.UserTypeLogin) + IamCTX = Instance.WithAuthorization(ctx, integration.UserTypeIAMOwner) + SystemCTX = integration.WithSystemAuthorization(ctx) + CTX = Instance.WithAuthorization(ctx, integration.UserTypeOrgOwner) + Client = Instance.Client.UserV2 return m.Run() }()) } func TestServer_AddHumanUser(t *testing.T) { - idpID := Tester.AddGenericOAuthProvider(t, CTX) + idpResp := Instance.AddGenericOAuthProvider(IamCTX, Instance.DefaultOrg.Id) type args struct { ctx context.Context req *user.AddHumanUserRequest @@ -73,7 +68,7 @@ func TestServer_AddHumanUser(t *testing.T) { &user.AddHumanUserRequest{ Organization: &object.Organization{ Org: &object.Organization_OrgId{ - OrgId: Tester.Organisation.ID, + OrgId: Instance.DefaultOrg.Id, }, }, Profile: &user.SetHumanProfile{ @@ -103,7 +98,7 @@ func TestServer_AddHumanUser(t *testing.T) { want: &user.AddHumanUserResponse{ Details: &object.Details{ ChangeDate: timestamppb.Now(), - ResourceOwner: Tester.Organisation.ID, + ResourceOwner: Instance.DefaultOrg.Id, }, }, }, @@ -114,7 +109,7 @@ func TestServer_AddHumanUser(t *testing.T) { &user.AddHumanUserRequest{ Organization: &object.Organization{ Org: &object.Organization_OrgId{ - OrgId: Tester.Organisation.ID, + OrgId: Instance.DefaultOrg.Id, }, }, Profile: &user.SetHumanProfile{ @@ -147,7 +142,7 @@ func TestServer_AddHumanUser(t *testing.T) { want: &user.AddHumanUserResponse{ Details: &object.Details{ ChangeDate: timestamppb.Now(), - ResourceOwner: Tester.Organisation.ID, + ResourceOwner: Instance.DefaultOrg.Id, }, EmailCode: gu.Ptr("something"), }, @@ -159,7 +154,7 @@ func TestServer_AddHumanUser(t *testing.T) { &user.AddHumanUserRequest{ Organization: &object.Organization{ Org: &object.Organization_OrgId{ - OrgId: Tester.Organisation.ID, + OrgId: Instance.DefaultOrg.Id, }, }, Profile: &user.SetHumanProfile{ @@ -194,7 +189,7 @@ func TestServer_AddHumanUser(t *testing.T) { want: &user.AddHumanUserResponse{ Details: &object.Details{ ChangeDate: timestamppb.Now(), - ResourceOwner: Tester.Organisation.ID, + ResourceOwner: Instance.DefaultOrg.Id, }, }, }, @@ -205,7 +200,7 @@ func TestServer_AddHumanUser(t *testing.T) { &user.AddHumanUserRequest{ Organization: &object.Organization{ Org: &object.Organization_OrgId{ - OrgId: Tester.Organisation.ID, + OrgId: Instance.DefaultOrg.Id, }, }, Profile: &user.SetHumanProfile{ @@ -240,7 +235,7 @@ func TestServer_AddHumanUser(t *testing.T) { want: &user.AddHumanUserResponse{ Details: &object.Details{ ChangeDate: timestamppb.Now(), - ResourceOwner: Tester.Organisation.ID, + ResourceOwner: Instance.DefaultOrg.Id, }, PhoneCode: gu.Ptr("something"), }, @@ -252,7 +247,7 @@ func TestServer_AddHumanUser(t *testing.T) { &user.AddHumanUserRequest{ Organization: &object.Organization{ Org: &object.Organization_OrgId{ - OrgId: Tester.Organisation.ID, + OrgId: Instance.DefaultOrg.Id, }, }, Profile: &user.SetHumanProfile{ @@ -293,7 +288,7 @@ func TestServer_AddHumanUser(t *testing.T) { &user.AddHumanUserRequest{ Organization: &object.Organization{ Org: &object.Organization_OrgId{ - OrgId: Tester.Organisation.ID, + OrgId: Instance.DefaultOrg.Id, }, }, Email: &user.SetHumanEmail{ @@ -324,7 +319,7 @@ func TestServer_AddHumanUser(t *testing.T) { &user.AddHumanUserRequest{ Organization: &object.Organization{ Org: &object.Organization_OrgId{ - OrgId: Tester.Organisation.ID, + OrgId: Instance.DefaultOrg.Id, }, }, Profile: &user.SetHumanProfile{ @@ -358,7 +353,7 @@ func TestServer_AddHumanUser(t *testing.T) { &user.AddHumanUserRequest{ Organization: &object.Organization{ Org: &object.Organization_OrgId{ - OrgId: Tester.Organisation.ID, + OrgId: Instance.DefaultOrg.Id, }, }, Profile: &user.SetHumanProfile{ @@ -405,7 +400,7 @@ func TestServer_AddHumanUser(t *testing.T) { &user.AddHumanUserRequest{ Organization: &object.Organization{ Org: &object.Organization_OrgId{ - OrgId: Tester.Organisation.ID, + OrgId: Instance.DefaultOrg.Id, }, }, Profile: &user.SetHumanProfile{ @@ -436,7 +431,7 @@ func TestServer_AddHumanUser(t *testing.T) { }, IdpLinks: []*user.IDPLink{ { - IdpId: idpID, + IdpId: idpResp.Id, UserId: "userID", UserName: "username", }, @@ -446,7 +441,7 @@ func TestServer_AddHumanUser(t *testing.T) { want: &user.AddHumanUserResponse{ Details: &object.Details{ ChangeDate: timestamppb.Now(), - ResourceOwner: Tester.Organisation.ID, + ResourceOwner: Instance.DefaultOrg.Id, }, }, }, @@ -457,7 +452,7 @@ func TestServer_AddHumanUser(t *testing.T) { &user.AddHumanUserRequest{ Organization: &object.Organization{ Org: &object.Organization_OrgId{ - OrgId: Tester.Organisation.ID, + OrgId: Instance.DefaultOrg.Id, }, }, Profile: &user.SetHumanProfile{ @@ -492,7 +487,7 @@ func TestServer_AddHumanUser(t *testing.T) { want: &user.AddHumanUserResponse{ Details: &object.Details{ ChangeDate: timestamppb.Now(), - ResourceOwner: Tester.Organisation.ID, + ResourceOwner: Instance.DefaultOrg.Id, }, }, }, @@ -503,7 +498,7 @@ func TestServer_AddHumanUser(t *testing.T) { &user.AddHumanUserRequest{ Organization: &object.Organization{ Org: &object.Organization_OrgId{ - OrgId: Tester.Organisation.ID, + OrgId: Instance.DefaultOrg.Id, }, }, Profile: &user.SetHumanProfile{ @@ -537,7 +532,7 @@ func TestServer_AddHumanUser(t *testing.T) { &user.AddHumanUserRequest{ Organization: &object.Organization{ Org: &object.Organization_OrgId{ - OrgId: Tester.Organisation.ID, + OrgId: Instance.DefaultOrg.Id, }, }, Profile: &user.SetHumanProfile{ @@ -565,7 +560,7 @@ func TestServer_AddHumanUser(t *testing.T) { want: &user.AddHumanUserResponse{ Details: &object.Details{ ChangeDate: timestamppb.Now(), - ResourceOwner: Tester.Organisation.ID, + ResourceOwner: Instance.DefaultOrg.Id, }, }, }, @@ -576,7 +571,7 @@ func TestServer_AddHumanUser(t *testing.T) { &user.AddHumanUserRequest{ Organization: &object.Organization{ Org: &object.Organization_OrgId{ - OrgId: Tester.Organisation.ID, + OrgId: Instance.DefaultOrg.Id, }, }, Profile: &user.SetHumanProfile{ @@ -637,7 +632,7 @@ func TestServer_AddHumanUser(t *testing.T) { func TestServer_AddHumanUser_Permission(t *testing.T) { newOrgOwnerEmail := fmt.Sprintf("%d@permission.com", time.Now().UnixNano()) - newOrg := Tester.CreateOrganization(IamCTX, fmt.Sprintf("AddHuman%d", time.Now().UnixNano()), newOrgOwnerEmail) + newOrg := Instance.CreateOrganization(IamCTX, fmt.Sprintf("AddHuman%d", time.Now().UnixNano()), newOrgOwnerEmail) type args struct { ctx context.Context req *user.AddHumanUserRequest @@ -857,7 +852,7 @@ func TestServer_UpdateHumanUser(t *testing.T) { { name: "change username, ok", prepare: func(request *user.UpdateHumanUserRequest) error { - userID := Tester.CreateHumanUser(CTX).GetUserId() + userID := Instance.CreateHumanUser(CTX).GetUserId() request.UserId = userID return nil }, @@ -870,14 +865,14 @@ func TestServer_UpdateHumanUser(t *testing.T) { want: &user.UpdateHumanUserResponse{ Details: &object.Details{ ChangeDate: timestamppb.Now(), - ResourceOwner: Tester.Organisation.ID, + ResourceOwner: Instance.DefaultOrg.Id, }, }, }, { name: "change profile, ok", prepare: func(request *user.UpdateHumanUserRequest) error { - userID := Tester.CreateHumanUser(CTX).GetUserId() + userID := Instance.CreateHumanUser(CTX).GetUserId() request.UserId = userID return nil }, @@ -897,14 +892,14 @@ func TestServer_UpdateHumanUser(t *testing.T) { want: &user.UpdateHumanUserResponse{ Details: &object.Details{ ChangeDate: timestamppb.Now(), - ResourceOwner: Tester.Organisation.ID, + ResourceOwner: Instance.DefaultOrg.Id, }, }, }, { name: "change email, ok", prepare: func(request *user.UpdateHumanUserRequest) error { - userID := Tester.CreateHumanUser(CTX).GetUserId() + userID := Instance.CreateHumanUser(CTX).GetUserId() request.UserId = userID return nil }, @@ -920,14 +915,14 @@ func TestServer_UpdateHumanUser(t *testing.T) { want: &user.UpdateHumanUserResponse{ Details: &object.Details{ ChangeDate: timestamppb.Now(), - ResourceOwner: Tester.Organisation.ID, + ResourceOwner: Instance.DefaultOrg.Id, }, }, }, { name: "change email, code, ok", prepare: func(request *user.UpdateHumanUserRequest) error { - userID := Tester.CreateHumanUser(CTX).GetUserId() + userID := Instance.CreateHumanUser(CTX).GetUserId() request.UserId = userID return nil }, @@ -943,7 +938,7 @@ func TestServer_UpdateHumanUser(t *testing.T) { want: &user.UpdateHumanUserResponse{ Details: &object.Details{ ChangeDate: timestamppb.Now(), - ResourceOwner: Tester.Organisation.ID, + ResourceOwner: Instance.DefaultOrg.Id, }, EmailCode: gu.Ptr("something"), }, @@ -951,7 +946,7 @@ func TestServer_UpdateHumanUser(t *testing.T) { { name: "change phone, ok", prepare: func(request *user.UpdateHumanUserRequest) error { - userID := Tester.CreateHumanUser(CTX).GetUserId() + userID := Instance.CreateHumanUser(CTX).GetUserId() request.UserId = userID return nil }, @@ -967,14 +962,14 @@ func TestServer_UpdateHumanUser(t *testing.T) { want: &user.UpdateHumanUserResponse{ Details: &object.Details{ ChangeDate: timestamppb.Now(), - ResourceOwner: Tester.Organisation.ID, + ResourceOwner: Instance.DefaultOrg.Id, }, }, }, { name: "change phone, code, ok", prepare: func(request *user.UpdateHumanUserRequest) error { - userID := Tester.CreateHumanUser(CTX).GetUserId() + userID := Instance.CreateHumanUser(CTX).GetUserId() request.UserId = userID return nil }, @@ -990,7 +985,7 @@ func TestServer_UpdateHumanUser(t *testing.T) { want: &user.UpdateHumanUserResponse{ Details: &object.Details{ ChangeDate: timestamppb.Now(), - ResourceOwner: Tester.Organisation.ID, + ResourceOwner: Instance.DefaultOrg.Id, }, PhoneCode: gu.Ptr("something"), }, @@ -998,7 +993,7 @@ func TestServer_UpdateHumanUser(t *testing.T) { { name: "change password, code, ok", prepare: func(request *user.UpdateHumanUserRequest) error { - userID := Tester.CreateHumanUser(CTX).GetUserId() + userID := Instance.CreateHumanUser(CTX).GetUserId() request.UserId = userID resp, err := Client.PasswordReset(CTX, &user.PasswordResetRequest{ UserId: userID, @@ -1030,14 +1025,14 @@ func TestServer_UpdateHumanUser(t *testing.T) { want: &user.UpdateHumanUserResponse{ Details: &object.Details{ ChangeDate: timestamppb.Now(), - ResourceOwner: Tester.Organisation.ID, + ResourceOwner: Instance.DefaultOrg.Id, }, }, }, { name: "change hashed password, code, ok", prepare: func(request *user.UpdateHumanUserRequest) error { - userID := Tester.CreateHumanUser(CTX).GetUserId() + userID := Instance.CreateHumanUser(CTX).GetUserId() request.UserId = userID resp, err := Client.PasswordReset(CTX, &user.PasswordResetRequest{ UserId: userID, @@ -1068,14 +1063,14 @@ func TestServer_UpdateHumanUser(t *testing.T) { want: &user.UpdateHumanUserResponse{ Details: &object.Details{ ChangeDate: timestamppb.Now(), - ResourceOwner: Tester.Organisation.ID, + ResourceOwner: Instance.DefaultOrg.Id, }, }, }, { name: "change hashed password, code, not supported", prepare: func(request *user.UpdateHumanUserRequest) error { - userID := Tester.CreateHumanUser(CTX).GetUserId() + userID := Instance.CreateHumanUser(CTX).GetUserId() request.UserId = userID resp, err := Client.PasswordReset(CTX, &user.PasswordResetRequest{ UserId: userID, @@ -1110,7 +1105,7 @@ func TestServer_UpdateHumanUser(t *testing.T) { { name: "change password, old password, ok", prepare: func(request *user.UpdateHumanUserRequest) error { - userID := Tester.CreateHumanUser(CTX).GetUserId() + userID := Instance.CreateHumanUser(CTX).GetUserId() request.UserId = userID resp, err := Client.PasswordReset(CTX, &user.PasswordResetRequest{ @@ -1157,7 +1152,7 @@ func TestServer_UpdateHumanUser(t *testing.T) { want: &user.UpdateHumanUserResponse{ Details: &object.Details{ ChangeDate: timestamppb.Now(), - ResourceOwner: Tester.Organisation.ID, + ResourceOwner: Instance.DefaultOrg.Id, }, }, }, @@ -1186,7 +1181,7 @@ func TestServer_UpdateHumanUser(t *testing.T) { func TestServer_UpdateHumanUser_Permission(t *testing.T) { newOrgOwnerEmail := fmt.Sprintf("%d@permission.update.com", time.Now().UnixNano()) - newOrg := Tester.CreateOrganization(IamCTX, fmt.Sprintf("UpdateHuman%d", time.Now().UnixNano()), newOrgOwnerEmail) + newOrg := Instance.CreateOrganization(IamCTX, fmt.Sprintf("UpdateHuman%d", time.Now().UnixNano()), newOrgOwnerEmail) newUserID := newOrg.CreatedAdmins[0].GetUserId() type args struct { ctx context.Context @@ -1296,7 +1291,7 @@ func TestServer_LockUser(t *testing.T) { CTX, &user.LockUserRequest{}, func(request *user.LockUserRequest) error { - resp := Tester.CreateHumanUser(CTX) + resp := Instance.CreateHumanUser(CTX) request.UserId = resp.GetUserId() return nil }, @@ -1304,7 +1299,7 @@ func TestServer_LockUser(t *testing.T) { want: &user.LockUserResponse{ Details: &object.Details{ ChangeDate: timestamppb.Now(), - ResourceOwner: Tester.Organisation.ID, + ResourceOwner: Instance.DefaultOrg.Id, }, }, }, @@ -1314,7 +1309,7 @@ func TestServer_LockUser(t *testing.T) { CTX, &user.LockUserRequest{}, func(request *user.LockUserRequest) error { - resp := Tester.CreateMachineUser(CTX) + resp := Instance.CreateMachineUser(CTX) request.UserId = resp.GetUserId() return nil }, @@ -1322,7 +1317,7 @@ func TestServer_LockUser(t *testing.T) { want: &user.LockUserResponse{ Details: &object.Details{ ChangeDate: timestamppb.Now(), - ResourceOwner: Tester.Organisation.ID, + ResourceOwner: Instance.DefaultOrg.Id, }, }, }, @@ -1332,7 +1327,7 @@ func TestServer_LockUser(t *testing.T) { CTX, &user.LockUserRequest{}, func(request *user.LockUserRequest) error { - resp := Tester.CreateHumanUser(CTX) + resp := Instance.CreateHumanUser(CTX) request.UserId = resp.GetUserId() _, err := Client.LockUser(CTX, &user.LockUserRequest{ UserId: resp.GetUserId(), @@ -1348,7 +1343,7 @@ func TestServer_LockUser(t *testing.T) { CTX, &user.LockUserRequest{}, func(request *user.LockUserRequest) error { - resp := Tester.CreateMachineUser(CTX) + resp := Instance.CreateMachineUser(CTX) request.UserId = resp.GetUserId() _, err := Client.LockUser(CTX, &user.LockUserRequest{ UserId: resp.GetUserId(), @@ -1404,7 +1399,7 @@ func TestServer_UnLockUser(t *testing.T) { ctx: CTX, req: &user.UnlockUserRequest{}, prepare: func(request *user.UnlockUserRequest) error { - resp := Tester.CreateHumanUser(CTX) + resp := Instance.CreateHumanUser(CTX) request.UserId = resp.GetUserId() return nil }, @@ -1417,7 +1412,7 @@ func TestServer_UnLockUser(t *testing.T) { ctx: CTX, req: &user.UnlockUserRequest{}, prepare: func(request *user.UnlockUserRequest) error { - resp := Tester.CreateMachineUser(CTX) + resp := Instance.CreateMachineUser(CTX) request.UserId = resp.GetUserId() return nil }, @@ -1430,7 +1425,7 @@ func TestServer_UnLockUser(t *testing.T) { ctx: CTX, req: &user.UnlockUserRequest{}, prepare: func(request *user.UnlockUserRequest) error { - resp := Tester.CreateHumanUser(CTX) + resp := Instance.CreateHumanUser(CTX) request.UserId = resp.GetUserId() _, err := Client.LockUser(CTX, &user.LockUserRequest{ UserId: resp.GetUserId(), @@ -1441,7 +1436,7 @@ func TestServer_UnLockUser(t *testing.T) { want: &user.UnlockUserResponse{ Details: &object.Details{ ChangeDate: timestamppb.Now(), - ResourceOwner: Tester.Organisation.ID, + ResourceOwner: Instance.DefaultOrg.Id, }, }, }, @@ -1451,7 +1446,7 @@ func TestServer_UnLockUser(t *testing.T) { ctx: CTX, req: &user.UnlockUserRequest{}, prepare: func(request *user.UnlockUserRequest) error { - resp := Tester.CreateMachineUser(CTX) + resp := Instance.CreateMachineUser(CTX) request.UserId = resp.GetUserId() _, err := Client.LockUser(CTX, &user.LockUserRequest{ UserId: resp.GetUserId(), @@ -1462,7 +1457,7 @@ func TestServer_UnLockUser(t *testing.T) { want: &user.UnlockUserResponse{ Details: &object.Details{ ChangeDate: timestamppb.Now(), - ResourceOwner: Tester.Organisation.ID, + ResourceOwner: Instance.DefaultOrg.Id, }, }, }, @@ -1512,7 +1507,7 @@ func TestServer_DeactivateUser(t *testing.T) { CTX, &user.DeactivateUserRequest{}, func(request *user.DeactivateUserRequest) error { - resp := Tester.CreateHumanUser(CTX) + resp := Instance.CreateHumanUser(CTX) request.UserId = resp.GetUserId() return nil }, @@ -1520,7 +1515,7 @@ func TestServer_DeactivateUser(t *testing.T) { want: &user.DeactivateUserResponse{ Details: &object.Details{ ChangeDate: timestamppb.Now(), - ResourceOwner: Tester.Organisation.ID, + ResourceOwner: Instance.DefaultOrg.Id, }, }, }, @@ -1530,7 +1525,7 @@ func TestServer_DeactivateUser(t *testing.T) { CTX, &user.DeactivateUserRequest{}, func(request *user.DeactivateUserRequest) error { - resp := Tester.CreateMachineUser(CTX) + resp := Instance.CreateMachineUser(CTX) request.UserId = resp.GetUserId() return nil }, @@ -1538,7 +1533,7 @@ func TestServer_DeactivateUser(t *testing.T) { want: &user.DeactivateUserResponse{ Details: &object.Details{ ChangeDate: timestamppb.Now(), - ResourceOwner: Tester.Organisation.ID, + ResourceOwner: Instance.DefaultOrg.Id, }, }, }, @@ -1548,7 +1543,7 @@ func TestServer_DeactivateUser(t *testing.T) { CTX, &user.DeactivateUserRequest{}, func(request *user.DeactivateUserRequest) error { - resp := Tester.CreateHumanUser(CTX) + resp := Instance.CreateHumanUser(CTX) request.UserId = resp.GetUserId() _, err := Client.DeactivateUser(CTX, &user.DeactivateUserRequest{ UserId: resp.GetUserId(), @@ -1564,7 +1559,7 @@ func TestServer_DeactivateUser(t *testing.T) { CTX, &user.DeactivateUserRequest{}, func(request *user.DeactivateUserRequest) error { - resp := Tester.CreateMachineUser(CTX) + resp := Instance.CreateMachineUser(CTX) request.UserId = resp.GetUserId() _, err := Client.DeactivateUser(CTX, &user.DeactivateUserRequest{ UserId: resp.GetUserId(), @@ -1620,7 +1615,7 @@ func TestServer_ReactivateUser(t *testing.T) { ctx: CTX, req: &user.ReactivateUserRequest{}, prepare: func(request *user.ReactivateUserRequest) error { - resp := Tester.CreateHumanUser(CTX) + resp := Instance.CreateHumanUser(CTX) request.UserId = resp.GetUserId() return nil }, @@ -1633,7 +1628,7 @@ func TestServer_ReactivateUser(t *testing.T) { ctx: CTX, req: &user.ReactivateUserRequest{}, prepare: func(request *user.ReactivateUserRequest) error { - resp := Tester.CreateMachineUser(CTX) + resp := Instance.CreateMachineUser(CTX) request.UserId = resp.GetUserId() return nil }, @@ -1646,7 +1641,7 @@ func TestServer_ReactivateUser(t *testing.T) { ctx: CTX, req: &user.ReactivateUserRequest{}, prepare: func(request *user.ReactivateUserRequest) error { - resp := Tester.CreateHumanUser(CTX) + resp := Instance.CreateHumanUser(CTX) request.UserId = resp.GetUserId() _, err := Client.DeactivateUser(CTX, &user.DeactivateUserRequest{ UserId: resp.GetUserId(), @@ -1657,7 +1652,7 @@ func TestServer_ReactivateUser(t *testing.T) { want: &user.ReactivateUserResponse{ Details: &object.Details{ ChangeDate: timestamppb.Now(), - ResourceOwner: Tester.Organisation.ID, + ResourceOwner: Instance.DefaultOrg.Id, }, }, }, @@ -1667,7 +1662,7 @@ func TestServer_ReactivateUser(t *testing.T) { ctx: CTX, req: &user.ReactivateUserRequest{}, prepare: func(request *user.ReactivateUserRequest) error { - resp := Tester.CreateMachineUser(CTX) + resp := Instance.CreateMachineUser(CTX) request.UserId = resp.GetUserId() _, err := Client.DeactivateUser(CTX, &user.DeactivateUserRequest{ UserId: resp.GetUserId(), @@ -1678,7 +1673,7 @@ func TestServer_ReactivateUser(t *testing.T) { want: &user.ReactivateUserResponse{ Details: &object.Details{ ChangeDate: timestamppb.Now(), - ResourceOwner: Tester.Organisation.ID, + ResourceOwner: Instance.DefaultOrg.Id, }, }, }, @@ -1700,7 +1695,7 @@ func TestServer_ReactivateUser(t *testing.T) { } func TestServer_DeleteUser(t *testing.T) { - projectResp, err := Tester.CreateProject(CTX) + projectResp, err := Instance.CreateProject(CTX) require.NoError(t, err) type args struct { ctx context.Context @@ -1730,7 +1725,7 @@ func TestServer_DeleteUser(t *testing.T) { ctx: CTX, req: &user.DeleteUserRequest{}, prepare: func(request *user.DeleteUserRequest) error { - resp := Tester.CreateHumanUser(CTX) + resp := Instance.CreateHumanUser(CTX) request.UserId = resp.GetUserId() return err }, @@ -1738,7 +1733,7 @@ func TestServer_DeleteUser(t *testing.T) { want: &user.DeleteUserResponse{ Details: &object.Details{ ChangeDate: timestamppb.Now(), - ResourceOwner: Tester.Organisation.ID, + ResourceOwner: Instance.DefaultOrg.Id, }, }, }, @@ -1748,7 +1743,7 @@ func TestServer_DeleteUser(t *testing.T) { ctx: CTX, req: &user.DeleteUserRequest{}, prepare: func(request *user.DeleteUserRequest) error { - resp := Tester.CreateMachineUser(CTX) + resp := Instance.CreateMachineUser(CTX) request.UserId = resp.GetUserId() return err }, @@ -1756,7 +1751,7 @@ func TestServer_DeleteUser(t *testing.T) { want: &user.DeleteUserResponse{ Details: &object.Details{ ChangeDate: timestamppb.Now(), - ResourceOwner: Tester.Organisation.ID, + ResourceOwner: Instance.DefaultOrg.Id, }, }, }, @@ -1766,18 +1761,18 @@ func TestServer_DeleteUser(t *testing.T) { ctx: CTX, req: &user.DeleteUserRequest{}, prepare: func(request *user.DeleteUserRequest) error { - resp := Tester.CreateHumanUser(CTX) + resp := Instance.CreateHumanUser(CTX) request.UserId = resp.GetUserId() - Tester.CreateProjectUserGrant(t, CTX, projectResp.GetId(), request.UserId) - Tester.CreateProjectMembership(t, CTX, projectResp.GetId(), request.UserId) - Tester.CreateOrgMembership(t, CTX, request.UserId) + Instance.CreateProjectUserGrant(t, CTX, projectResp.GetId(), request.UserId) + Instance.CreateProjectMembership(t, CTX, projectResp.GetId(), request.UserId) + Instance.CreateOrgMembership(t, CTX, request.UserId) return err }, }, want: &user.DeleteUserResponse{ Details: &object.Details{ ChangeDate: timestamppb.Now(), - ResourceOwner: Tester.Organisation.ID, + ResourceOwner: Instance.DefaultOrg.Id, }, }, }, @@ -1799,13 +1794,13 @@ func TestServer_DeleteUser(t *testing.T) { } func TestServer_StartIdentityProviderIntent(t *testing.T) { - idpID := Tester.AddGenericOAuthProvider(t, CTX) - orgIdpID := Tester.AddOrgGenericOAuthProvider(t, CTX, Tester.Organisation.ID) - orgResp := Tester.CreateOrganization(IamCTX, fmt.Sprintf("NotDefaultOrg%d", time.Now().UnixNano()), fmt.Sprintf("%d@mouse.com", time.Now().UnixNano())) - notDefaultOrgIdpID := Tester.AddOrgGenericOAuthProvider(t, CTX, orgResp.OrganizationId) - samlIdpID := Tester.AddSAMLProvider(t, CTX) - samlRedirectIdpID := Tester.AddSAMLRedirectProvider(t, CTX, "") - samlPostIdpID := Tester.AddSAMLPostProvider(t, CTX) + idpResp := Instance.AddGenericOAuthProvider(IamCTX, Instance.DefaultOrg.Id) + orgIdpResp := Instance.AddOrgGenericOAuthProvider(CTX, Instance.DefaultOrg.Id) + orgResp := Instance.CreateOrganization(IamCTX, fmt.Sprintf("NotDefaultOrg%d", time.Now().UnixNano()), fmt.Sprintf("%d@mouse.com", time.Now().UnixNano())) + notDefaultOrgIdpResp := Instance.AddOrgGenericOAuthProvider(IamCTX, orgResp.OrganizationId) + samlIdpID := Instance.AddSAMLProvider(IamCTX) + samlRedirectIdpID := Instance.AddSAMLRedirectProvider(IamCTX, "") + samlPostIdpID := Instance.AddSAMLPostProvider(IamCTX) type args struct { ctx context.Context req *user.StartIdentityProviderIntentRequest @@ -1828,7 +1823,7 @@ func TestServer_StartIdentityProviderIntent(t *testing.T) { args: args{ CTX, &user.StartIdentityProviderIntentRequest{ - IdpId: idpID, + IdpId: idpResp.Id, }, }, wantErr: true, @@ -1838,7 +1833,7 @@ func TestServer_StartIdentityProviderIntent(t *testing.T) { args: args{ CTX, &user.StartIdentityProviderIntentRequest{ - IdpId: idpID, + IdpId: idpResp.Id, Content: &user.StartIdentityProviderIntentRequest_Urls{ Urls: &user.RedirectURLs{ SuccessUrl: "https://example.com/success", @@ -1850,13 +1845,13 @@ func TestServer_StartIdentityProviderIntent(t *testing.T) { want: want{ details: &object.Details{ ChangeDate: timestamppb.Now(), - ResourceOwner: Tester.Instance.InstanceID(), + ResourceOwner: Instance.ID(), }, url: "https://example.com/oauth/v2/authorize", parametersEqual: map[string]string{ "client_id": "clientID", "prompt": "select_account", - "redirect_uri": "http://" + Tester.Config.ExternalDomain + ":8080/idps/callback", + "redirect_uri": "http://" + Instance.Domain + ":8080/idps/callback", "response_type": "code", "scope": "openid profile email", }, @@ -1869,7 +1864,7 @@ func TestServer_StartIdentityProviderIntent(t *testing.T) { args: args{ CTX, &user.StartIdentityProviderIntentRequest{ - IdpId: orgIdpID, + IdpId: orgIdpResp.Id, Content: &user.StartIdentityProviderIntentRequest_Urls{ Urls: &user.RedirectURLs{ SuccessUrl: "https://example.com/success", @@ -1881,13 +1876,13 @@ func TestServer_StartIdentityProviderIntent(t *testing.T) { want: want{ details: &object.Details{ ChangeDate: timestamppb.Now(), - ResourceOwner: Tester.Instance.InstanceID(), + ResourceOwner: Instance.ID(), }, url: "https://example.com/oauth/v2/authorize", parametersEqual: map[string]string{ "client_id": "clientID", "prompt": "select_account", - "redirect_uri": "http://" + Tester.Config.ExternalDomain + ":8080/idps/callback", + "redirect_uri": "http://" + Instance.Domain + ":8080/idps/callback", "response_type": "code", "scope": "openid profile email", }, @@ -1900,7 +1895,7 @@ func TestServer_StartIdentityProviderIntent(t *testing.T) { args: args{ CTX, &user.StartIdentityProviderIntentRequest{ - IdpId: notDefaultOrgIdpID, + IdpId: notDefaultOrgIdpResp.Id, Content: &user.StartIdentityProviderIntentRequest_Urls{ Urls: &user.RedirectURLs{ SuccessUrl: "https://example.com/success", @@ -1912,13 +1907,13 @@ func TestServer_StartIdentityProviderIntent(t *testing.T) { want: want{ details: &object.Details{ ChangeDate: timestamppb.Now(), - ResourceOwner: Tester.Instance.InstanceID(), + ResourceOwner: Instance.ID(), }, url: "https://example.com/oauth/v2/authorize", parametersEqual: map[string]string{ "client_id": "clientID", "prompt": "select_account", - "redirect_uri": "http://" + Tester.Config.ExternalDomain + ":8080/idps/callback", + "redirect_uri": "http://" + Instance.Domain + ":8080/idps/callback", "response_type": "code", "scope": "openid profile email", }, @@ -1931,7 +1926,7 @@ func TestServer_StartIdentityProviderIntent(t *testing.T) { args: args{ CTX, &user.StartIdentityProviderIntentRequest{ - IdpId: orgIdpID, + IdpId: orgIdpResp.Id, Content: &user.StartIdentityProviderIntentRequest_Urls{ Urls: &user.RedirectURLs{ SuccessUrl: "https://example.com/success", @@ -1943,13 +1938,13 @@ func TestServer_StartIdentityProviderIntent(t *testing.T) { want: want{ details: &object.Details{ ChangeDate: timestamppb.Now(), - ResourceOwner: Tester.Instance.InstanceID(), + ResourceOwner: Instance.ID(), }, url: "https://example.com/oauth/v2/authorize", parametersEqual: map[string]string{ "client_id": "clientID", "prompt": "select_account", - "redirect_uri": "http://" + Tester.Config.ExternalDomain + ":8080/idps/callback", + "redirect_uri": "http://" + Instance.Domain + ":8080/idps/callback", "response_type": "code", "scope": "openid profile email", }, @@ -1974,9 +1969,9 @@ func TestServer_StartIdentityProviderIntent(t *testing.T) { want: want{ details: &object.Details{ ChangeDate: timestamppb.Now(), - ResourceOwner: Tester.Instance.InstanceID(), + ResourceOwner: Instance.ID(), }, - url: "http://" + Tester.Config.ExternalDomain + ":8000/sso", + url: "http://" + Instance.Domain + ":8000/sso", parametersExisting: []string{"RelayState", "SAMLRequest"}, }, wantErr: false, @@ -1998,9 +1993,9 @@ func TestServer_StartIdentityProviderIntent(t *testing.T) { want: want{ details: &object.Details{ ChangeDate: timestamppb.Now(), - ResourceOwner: Tester.Instance.InstanceID(), + ResourceOwner: Instance.ID(), }, - url: "http://" + Tester.Config.ExternalDomain + ":8000/sso", + url: "http://" + Instance.Domain + ":8000/sso", parametersExisting: []string{"RelayState", "SAMLRequest"}, }, wantErr: false, @@ -2022,7 +2017,7 @@ func TestServer_StartIdentityProviderIntent(t *testing.T) { want: want{ details: &object.Details{ ChangeDate: timestamppb.Now(), - ResourceOwner: Tester.Instance.InstanceID(), + ResourceOwner: Instance.ID(), }, postForm: true, }, @@ -2061,14 +2056,15 @@ func TestServer_StartIdentityProviderIntent(t *testing.T) { } } +/* func TestServer_RetrieveIdentityProviderIntent(t *testing.T) { - idpID := Tester.AddGenericOAuthProvider(t, CTX) - intentID := Tester.CreateIntent(t, CTX, idpID) - successfulID, token, changeDate, sequence := Tester.CreateSuccessfulOAuthIntent(t, CTX, idpID, "", "id") - successfulWithUserID, withUsertoken, withUserchangeDate, withUsersequence := Tester.CreateSuccessfulOAuthIntent(t, CTX, idpID, "user", "id") - ldapSuccessfulID, ldapToken, ldapChangeDate, ldapSequence := Tester.CreateSuccessfulLDAPIntent(t, CTX, idpID, "", "id") - ldapSuccessfulWithUserID, ldapWithUserToken, ldapWithUserChangeDate, ldapWithUserSequence := Tester.CreateSuccessfulLDAPIntent(t, CTX, idpID, "user", "id") - samlSuccessfulID, samlToken, samlChangeDate, samlSequence := Tester.CreateSuccessfulSAMLIntent(t, CTX, idpID, "", "id") + idpID := Instance.AddGenericOAuthProvider(t, CTX) + intentID := Instance.CreateIntent(t, CTX, idpID) + successfulID, token, changeDate, sequence := Instance.CreateSuccessfulOAuthIntent(t, CTX, idpID, "", "id") + successfulWithUserID, withUsertoken, withUserchangeDate, withUsersequence := Instance.CreateSuccessfulOAuthIntent(t, CTX, idpID, "user", "id") + ldapSuccessfulID, ldapToken, ldapChangeDate, ldapSequence := Instance.CreateSuccessfulLDAPIntent(t, CTX, idpID, "", "id") + ldapSuccessfulWithUserID, ldapWithUserToken, ldapWithUserChangeDate, ldapWithUserSequence := Instance.CreateSuccessfulLDAPIntent(t, CTX, idpID, "user", "id") + samlSuccessfulID, samlToken, samlChangeDate, samlSequence := Instance.CreateSuccessfulSAMLIntent(t, CTX, idpID, "", "id") type args struct { ctx context.Context req *user.RetrieveIdentityProviderIntentRequest @@ -2113,7 +2109,7 @@ func TestServer_RetrieveIdentityProviderIntent(t *testing.T) { want: &user.RetrieveIdentityProviderIntentResponse{ Details: &object.Details{ ChangeDate: timestamppb.New(changeDate), - ResourceOwner: Tester.Instance.InstanceID(), + ResourceOwner: Instance.ID(), Sequence: sequence, }, IdpInformation: &user.IDPInformation{ @@ -2150,7 +2146,7 @@ func TestServer_RetrieveIdentityProviderIntent(t *testing.T) { want: &user.RetrieveIdentityProviderIntentResponse{ Details: &object.Details{ ChangeDate: timestamppb.New(withUserchangeDate), - ResourceOwner: Tester.Instance.InstanceID(), + ResourceOwner: Instance.ID(), Sequence: withUsersequence, }, UserId: "user", @@ -2188,7 +2184,7 @@ func TestServer_RetrieveIdentityProviderIntent(t *testing.T) { want: &user.RetrieveIdentityProviderIntentResponse{ Details: &object.Details{ ChangeDate: timestamppb.New(ldapChangeDate), - ResourceOwner: Tester.Instance.InstanceID(), + ResourceOwner: Instance.ID(), Sequence: ldapSequence, }, IdpInformation: &user.IDPInformation{ @@ -2233,7 +2229,7 @@ func TestServer_RetrieveIdentityProviderIntent(t *testing.T) { want: &user.RetrieveIdentityProviderIntentResponse{ Details: &object.Details{ ChangeDate: timestamppb.New(ldapWithUserChangeDate), - ResourceOwner: Tester.Instance.InstanceID(), + ResourceOwner: Instance.ID(), Sequence: ldapWithUserSequence, }, UserId: "user", @@ -2279,7 +2275,7 @@ func TestServer_RetrieveIdentityProviderIntent(t *testing.T) { want: &user.RetrieveIdentityProviderIntentResponse{ Details: &object.Details{ ChangeDate: timestamppb.New(samlChangeDate), - ResourceOwner: Tester.Instance.InstanceID(), + ResourceOwner: Instance.ID(), Sequence: samlSequence, }, IdpInformation: &user.IDPInformation{ @@ -2319,40 +2315,41 @@ func TestServer_RetrieveIdentityProviderIntent(t *testing.T) { }) } } +*/ func TestServer_ListAuthenticationMethodTypes(t *testing.T) { - userIDWithoutAuth := Tester.CreateHumanUser(CTX).GetUserId() + userIDWithoutAuth := Instance.CreateHumanUser(CTX).GetUserId() - userIDWithPasskey := Tester.CreateHumanUser(CTX).GetUserId() - Tester.RegisterUserPasskey(CTX, userIDWithPasskey) + userIDWithPasskey := Instance.CreateHumanUser(CTX).GetUserId() + Instance.RegisterUserPasskey(CTX, userIDWithPasskey) - userMultipleAuth := Tester.CreateHumanUser(CTX).GetUserId() - Tester.RegisterUserPasskey(CTX, userMultipleAuth) - provider, err := Tester.Client.Mgmt.AddGenericOIDCProvider(CTX, &mgmt.AddGenericOIDCProviderRequest{ + userMultipleAuth := Instance.CreateHumanUser(CTX).GetUserId() + Instance.RegisterUserPasskey(CTX, userMultipleAuth) + provider, err := Instance.Client.Mgmt.AddGenericOIDCProvider(CTX, &mgmt.AddGenericOIDCProviderRequest{ Name: "ListAuthenticationMethodTypes", Issuer: "https://example.com", ClientId: "client_id", ClientSecret: "client_secret", }) require.NoError(t, err) - _, err = Tester.Client.Mgmt.AddCustomLoginPolicy(CTX, &mgmt.AddCustomLoginPolicyRequest{}) + _, err = Instance.Client.Mgmt.AddCustomLoginPolicy(CTX, &mgmt.AddCustomLoginPolicyRequest{}) require.Condition(t, func() bool { code := status.Convert(err).Code() return code == codes.AlreadyExists || code == codes.OK }) - _, err = Tester.Client.Mgmt.AddIDPToLoginPolicy(CTX, &mgmt.AddIDPToLoginPolicyRequest{ + _, err = Instance.Client.Mgmt.AddIDPToLoginPolicy(CTX, &mgmt.AddIDPToLoginPolicyRequest{ IdpId: provider.GetId(), OwnerType: idp.IDPOwnerType_IDP_OWNER_TYPE_ORG, }) require.NoError(t, err) - idpLink, err := Tester.Client.UserV2.AddIDPLink(CTX, &user.AddIDPLinkRequest{UserId: userMultipleAuth, IdpLink: &user.IDPLink{ + idpLink, err := Instance.Client.UserV2.AddIDPLink(CTX, &user.AddIDPLinkRequest{UserId: userMultipleAuth, IdpLink: &user.IDPLink{ IdpId: provider.GetId(), UserId: "external-id", UserName: "displayName", }}) require.NoError(t, err) // This should not remove the user IDP links - _, err = Tester.Client.Mgmt.RemoveIDPFromLoginPolicy(CTX, &mgmt.RemoveIDPFromLoginPolicyRequest{ + _, err = Instance.Client.Mgmt.RemoveIDPFromLoginPolicy(CTX, &mgmt.RemoveIDPFromLoginPolicyRequest{ IdpId: provider.GetId(), }) require.NoError(t, err) diff --git a/internal/api/grpc/user/v2beta/email_integration_test.go b/internal/api/grpc/user/v2beta/integration_test/email_test.go similarity index 90% rename from internal/api/grpc/user/v2beta/email_integration_test.go rename to internal/api/grpc/user/v2beta/integration_test/email_test.go index 4034a5e7da..71d411fdbb 100644 --- a/internal/api/grpc/user/v2beta/email_integration_test.go +++ b/internal/api/grpc/user/v2beta/integration_test/email_test.go @@ -18,7 +18,7 @@ import ( ) func TestServer_SetEmail(t *testing.T) { - userID := Tester.CreateHumanUser(CTX).GetUserId() + userID := Instance.CreateHumanUser(CTX).GetUserId() tests := []struct { name string @@ -44,7 +44,7 @@ func TestServer_SetEmail(t *testing.T) { Details: &object.Details{ Sequence: 1, ChangeDate: timestamppb.Now(), - ResourceOwner: Tester.Organisation.ID, + ResourceOwner: Instance.DefaultOrg.Id, }, }, }, @@ -63,7 +63,7 @@ func TestServer_SetEmail(t *testing.T) { Details: &object.Details{ Sequence: 1, ChangeDate: timestamppb.Now(), - ResourceOwner: Tester.Organisation.ID, + ResourceOwner: Instance.DefaultOrg.Id, }, }, }, @@ -93,7 +93,7 @@ func TestServer_SetEmail(t *testing.T) { Details: &object.Details{ Sequence: 1, ChangeDate: timestamppb.Now(), - ResourceOwner: Tester.Organisation.ID, + ResourceOwner: Instance.DefaultOrg.Id, }, VerificationCode: gu.Ptr("xxx"), }, @@ -111,7 +111,7 @@ func TestServer_SetEmail(t *testing.T) { Details: &object.Details{ Sequence: 1, ChangeDate: timestamppb.Now(), - ResourceOwner: Tester.Organisation.ID, + ResourceOwner: Instance.DefaultOrg.Id, }, }, }, @@ -144,8 +144,8 @@ func TestServer_SetEmail(t *testing.T) { } func TestServer_ResendEmailCode(t *testing.T) { - userID := Tester.CreateHumanUser(CTX).GetUserId() - verifiedUserID := Tester.CreateHumanUserVerified(CTX, Tester.Organisation.ID, fmt.Sprintf("%d@mouse.com", time.Now().UnixNano())).GetUserId() + userID := Instance.CreateHumanUser(CTX).GetUserId() + verifiedUserID := Instance.CreateHumanUserVerified(CTX, Instance.DefaultOrg.Id, fmt.Sprintf("%d@mouse.com", time.Now().UnixNano())).GetUserId() tests := []struct { name string @@ -176,7 +176,7 @@ func TestServer_ResendEmailCode(t *testing.T) { Details: &object.Details{ Sequence: 1, ChangeDate: timestamppb.Now(), - ResourceOwner: Tester.Organisation.ID, + ResourceOwner: Instance.DefaultOrg.Id, }, }, }, @@ -194,7 +194,7 @@ func TestServer_ResendEmailCode(t *testing.T) { Details: &object.Details{ Sequence: 1, ChangeDate: timestamppb.Now(), - ResourceOwner: Tester.Organisation.ID, + ResourceOwner: Instance.DefaultOrg.Id, }, }, }, @@ -222,7 +222,7 @@ func TestServer_ResendEmailCode(t *testing.T) { Details: &object.Details{ Sequence: 1, ChangeDate: timestamppb.Now(), - ResourceOwner: Tester.Organisation.ID, + ResourceOwner: Instance.DefaultOrg.Id, }, VerificationCode: gu.Ptr("xxx"), }, @@ -245,7 +245,7 @@ func TestServer_ResendEmailCode(t *testing.T) { } func TestServer_VerifyEmail(t *testing.T) { - userResp := Tester.CreateHumanUser(CTX) + userResp := Instance.CreateHumanUser(CTX) tests := []struct { name string req *user.VerifyEmailRequest @@ -278,7 +278,7 @@ func TestServer_VerifyEmail(t *testing.T) { Details: &object.Details{ Sequence: 1, ChangeDate: timestamppb.Now(), - ResourceOwner: Tester.Organisation.ID, + ResourceOwner: Instance.DefaultOrg.Id, }, }, }, diff --git a/internal/api/grpc/user/v2beta/otp_integration_test.go b/internal/api/grpc/user/v2beta/integration_test/otp_test.go similarity index 66% rename from internal/api/grpc/user/v2beta/otp_integration_test.go rename to internal/api/grpc/user/v2beta/integration_test/otp_test.go index a6d671c645..6d6e2eff3e 100644 --- a/internal/api/grpc/user/v2beta/otp_integration_test.go +++ b/internal/api/grpc/user/v2beta/integration_test/otp_test.go @@ -15,24 +15,24 @@ import ( ) func TestServer_AddOTPSMS(t *testing.T) { - userID := Tester.CreateHumanUser(CTX).GetUserId() - Tester.RegisterUserPasskey(CTX, userID) - _, sessionToken, _, _ := Tester.CreateVerifiedWebAuthNSession(t, CTX, userID) + userID := Instance.CreateHumanUser(CTX).GetUserId() + Instance.RegisterUserPasskey(CTX, userID) + _, sessionToken, _, _ := Instance.CreateVerifiedWebAuthNSession(t, CTX, userID) - otherUser := Tester.CreateHumanUser(CTX).GetUserId() - Tester.RegisterUserPasskey(CTX, otherUser) - _, sessionTokenOtherUser, _, _ := Tester.CreateVerifiedWebAuthNSession(t, CTX, otherUser) + otherUser := Instance.CreateHumanUser(CTX).GetUserId() + Instance.RegisterUserPasskey(CTX, otherUser) + _, sessionTokenOtherUser, _, _ := Instance.CreateVerifiedWebAuthNSession(t, CTX, otherUser) - userVerified := Tester.CreateHumanUser(CTX) + userVerified := Instance.CreateHumanUser(CTX) _, err := Client.VerifyPhone(CTX, &user.VerifyPhoneRequest{ UserId: userVerified.GetUserId(), VerificationCode: userVerified.GetPhoneCode(), }) require.NoError(t, err) - Tester.RegisterUserPasskey(CTX, userVerified.GetUserId()) - _, sessionTokenVerified, _, _ := Tester.CreateVerifiedWebAuthNSession(t, CTX, userVerified.GetUserId()) + Instance.RegisterUserPasskey(CTX, userVerified.GetUserId()) + _, sessionTokenVerified, _, _ := Instance.CreateVerifiedWebAuthNSession(t, CTX, userVerified.GetUserId()) - userVerified2 := Tester.CreateHumanUser(CTX) + userVerified2 := Instance.CreateHumanUser(CTX) _, err = Client.VerifyPhone(CTX, &user.VerifyPhoneRequest{ UserId: userVerified2.GetUserId(), VerificationCode: userVerified2.GetPhoneCode(), @@ -60,7 +60,7 @@ func TestServer_AddOTPSMS(t *testing.T) { { name: "user mismatch", args: args{ - ctx: Tester.WithAuthorizationToken(context.Background(), sessionTokenOtherUser), + ctx: integration.WithAuthorizationToken(context.Background(), sessionTokenOtherUser), req: &user.AddOTPSMSRequest{ UserId: userID, }, @@ -70,7 +70,7 @@ func TestServer_AddOTPSMS(t *testing.T) { { name: "phone not verified", args: args{ - ctx: Tester.WithAuthorizationToken(context.Background(), sessionToken), + ctx: integration.WithAuthorizationToken(context.Background(), sessionToken), req: &user.AddOTPSMSRequest{ UserId: userID, }, @@ -80,14 +80,14 @@ func TestServer_AddOTPSMS(t *testing.T) { { name: "add success", args: args{ - ctx: Tester.WithAuthorizationToken(context.Background(), sessionTokenVerified), + ctx: integration.WithAuthorizationToken(context.Background(), sessionTokenVerified), req: &user.AddOTPSMSRequest{ UserId: userVerified.GetUserId(), }, }, want: &user.AddOTPSMSResponse{ Details: &object.Details{ - ResourceOwner: Tester.Organisation.ID, + ResourceOwner: Instance.DefaultOrg.Id, }, }, }, @@ -101,7 +101,7 @@ func TestServer_AddOTPSMS(t *testing.T) { }, want: &user.AddOTPSMSResponse{ Details: &object.Details{ - ResourceOwner: Tester.Organisation.ID, + ResourceOwner: Instance.DefaultOrg.Id, }, }, }, @@ -121,14 +121,14 @@ func TestServer_AddOTPSMS(t *testing.T) { } func TestServer_RemoveOTPSMS(t *testing.T) { - userID := Tester.CreateHumanUser(CTX).GetUserId() - Tester.RegisterUserPasskey(CTX, userID) - _, sessionToken, _, _ := Tester.CreateVerifiedWebAuthNSession(t, CTX, userID) + userID := Instance.CreateHumanUser(CTX).GetUserId() + Instance.RegisterUserPasskey(CTX, userID) + _, sessionToken, _, _ := Instance.CreateVerifiedWebAuthNSession(t, CTX, userID) - userVerified := Tester.CreateHumanUser(CTX) - Tester.RegisterUserPasskey(CTX, userVerified.GetUserId()) - _, sessionTokenVerified, _, _ := Tester.CreateVerifiedWebAuthNSession(t, CTX, userVerified.GetUserId()) - userVerifiedCtx := Tester.WithAuthorizationToken(context.Background(), sessionTokenVerified) + userVerified := Instance.CreateHumanUser(CTX) + Instance.RegisterUserPasskey(CTX, userVerified.GetUserId()) + _, sessionTokenVerified, _, _ := Instance.CreateVerifiedWebAuthNSession(t, CTX, userVerified.GetUserId()) + userVerifiedCtx := integration.WithAuthorizationToken(context.Background(), sessionTokenVerified) _, err := Client.VerifyPhone(userVerifiedCtx, &user.VerifyPhoneRequest{ UserId: userVerified.GetUserId(), VerificationCode: userVerified.GetPhoneCode(), @@ -150,7 +150,7 @@ func TestServer_RemoveOTPSMS(t *testing.T) { { name: "not added", args: args{ - ctx: Tester.WithAuthorizationToken(context.Background(), sessionToken), + ctx: integration.WithAuthorizationToken(context.Background(), sessionToken), req: &user.RemoveOTPSMSRequest{ UserId: userID, }, @@ -167,7 +167,7 @@ func TestServer_RemoveOTPSMS(t *testing.T) { }, want: &user.RemoveOTPSMSResponse{ Details: &object.Details{ - ResourceOwner: Tester.Organisation.ResourceOwner, + ResourceOwner: Instance.DefaultOrg.Details.ResourceOwner, }, }, }, @@ -187,24 +187,24 @@ func TestServer_RemoveOTPSMS(t *testing.T) { } func TestServer_AddOTPEmail(t *testing.T) { - userID := Tester.CreateHumanUser(CTX).GetUserId() - Tester.RegisterUserPasskey(CTX, userID) - _, sessionToken, _, _ := Tester.CreateVerifiedWebAuthNSession(t, CTX, userID) + userID := Instance.CreateHumanUser(CTX).GetUserId() + Instance.RegisterUserPasskey(CTX, userID) + _, sessionToken, _, _ := Instance.CreateVerifiedWebAuthNSession(t, CTX, userID) - otherUser := Tester.CreateHumanUser(CTX).GetUserId() - Tester.RegisterUserPasskey(CTX, otherUser) - _, sessionTokenOtherUser, _, _ := Tester.CreateVerifiedWebAuthNSession(t, CTX, otherUser) + otherUser := Instance.CreateHumanUser(CTX).GetUserId() + Instance.RegisterUserPasskey(CTX, otherUser) + _, sessionTokenOtherUser, _, _ := Instance.CreateVerifiedWebAuthNSession(t, CTX, otherUser) - userVerified := Tester.CreateHumanUser(CTX) + userVerified := Instance.CreateHumanUser(CTX) _, err := Client.VerifyEmail(CTX, &user.VerifyEmailRequest{ UserId: userVerified.GetUserId(), VerificationCode: userVerified.GetEmailCode(), }) require.NoError(t, err) - Tester.RegisterUserPasskey(CTX, userVerified.GetUserId()) - _, sessionTokenVerified, _, _ := Tester.CreateVerifiedWebAuthNSession(t, CTX, userVerified.GetUserId()) + Instance.RegisterUserPasskey(CTX, userVerified.GetUserId()) + _, sessionTokenVerified, _, _ := Instance.CreateVerifiedWebAuthNSession(t, CTX, userVerified.GetUserId()) - userVerified2 := Tester.CreateHumanUser(CTX) + userVerified2 := Instance.CreateHumanUser(CTX) _, err = Client.VerifyEmail(CTX, &user.VerifyEmailRequest{ UserId: userVerified2.GetUserId(), VerificationCode: userVerified2.GetEmailCode(), @@ -232,7 +232,7 @@ func TestServer_AddOTPEmail(t *testing.T) { { name: "user mismatch", args: args{ - ctx: Tester.WithAuthorizationToken(context.Background(), sessionTokenOtherUser), + ctx: integration.WithAuthorizationToken(context.Background(), sessionTokenOtherUser), req: &user.AddOTPEmailRequest{ UserId: userID, }, @@ -242,7 +242,7 @@ func TestServer_AddOTPEmail(t *testing.T) { { name: "email not verified", args: args{ - ctx: Tester.WithAuthorizationToken(context.Background(), sessionToken), + ctx: integration.WithAuthorizationToken(context.Background(), sessionToken), req: &user.AddOTPEmailRequest{ UserId: userID, }, @@ -252,7 +252,7 @@ func TestServer_AddOTPEmail(t *testing.T) { { name: "add success", args: args{ - ctx: Tester.WithAuthorizationToken(context.Background(), sessionTokenVerified), + ctx: integration.WithAuthorizationToken(context.Background(), sessionTokenVerified), req: &user.AddOTPEmailRequest{ UserId: userVerified.GetUserId(), }, @@ -260,7 +260,7 @@ func TestServer_AddOTPEmail(t *testing.T) { want: &user.AddOTPEmailResponse{ Details: &object.Details{ ChangeDate: timestamppb.Now(), - ResourceOwner: Tester.Organisation.ID, + ResourceOwner: Instance.DefaultOrg.Id, }, }, }, @@ -275,7 +275,7 @@ func TestServer_AddOTPEmail(t *testing.T) { want: &user.AddOTPEmailResponse{ Details: &object.Details{ ChangeDate: timestamppb.Now(), - ResourceOwner: Tester.Organisation.ID, + ResourceOwner: Instance.DefaultOrg.Id, }, }, }, @@ -295,14 +295,14 @@ func TestServer_AddOTPEmail(t *testing.T) { } func TestServer_RemoveOTPEmail(t *testing.T) { - userID := Tester.CreateHumanUser(CTX).GetUserId() - Tester.RegisterUserPasskey(CTX, userID) - _, sessionToken, _, _ := Tester.CreateVerifiedWebAuthNSession(t, CTX, userID) + userID := Instance.CreateHumanUser(CTX).GetUserId() + Instance.RegisterUserPasskey(CTX, userID) + _, sessionToken, _, _ := Instance.CreateVerifiedWebAuthNSession(t, CTX, userID) - userVerified := Tester.CreateHumanUser(CTX) - Tester.RegisterUserPasskey(CTX, userVerified.GetUserId()) - _, sessionTokenVerified, _, _ := Tester.CreateVerifiedWebAuthNSession(t, CTX, userVerified.GetUserId()) - userVerifiedCtx := Tester.WithAuthorizationToken(context.Background(), sessionTokenVerified) + userVerified := Instance.CreateHumanUser(CTX) + Instance.RegisterUserPasskey(CTX, userVerified.GetUserId()) + _, sessionTokenVerified, _, _ := Instance.CreateVerifiedWebAuthNSession(t, CTX, userVerified.GetUserId()) + userVerifiedCtx := integration.WithAuthorizationToken(context.Background(), sessionTokenVerified) _, err := Client.VerifyEmail(userVerifiedCtx, &user.VerifyEmailRequest{ UserId: userVerified.GetUserId(), VerificationCode: userVerified.GetEmailCode(), @@ -324,7 +324,7 @@ func TestServer_RemoveOTPEmail(t *testing.T) { { name: "not added", args: args{ - ctx: Tester.WithAuthorizationToken(context.Background(), sessionToken), + ctx: integration.WithAuthorizationToken(context.Background(), sessionToken), req: &user.RemoveOTPEmailRequest{ UserId: userID, }, @@ -342,7 +342,7 @@ func TestServer_RemoveOTPEmail(t *testing.T) { want: &user.RemoveOTPEmailResponse{ Details: &object.Details{ ChangeDate: timestamppb.Now(), - ResourceOwner: Tester.Organisation.ResourceOwner, + ResourceOwner: Instance.DefaultOrg.Details.ResourceOwner, }, }, }, diff --git a/internal/api/grpc/user/v2beta/passkey_integration_test.go b/internal/api/grpc/user/v2beta/integration_test/passkey_test.go similarity index 89% rename from internal/api/grpc/user/v2beta/passkey_integration_test.go rename to internal/api/grpc/user/v2beta/integration_test/passkey_test.go index 230a744a64..acca01885c 100644 --- a/internal/api/grpc/user/v2beta/passkey_integration_test.go +++ b/internal/api/grpc/user/v2beta/integration_test/passkey_test.go @@ -18,7 +18,7 @@ import ( ) func TestServer_RegisterPasskey(t *testing.T) { - userID := Tester.CreateHumanUser(CTX).GetUserId() + userID := Instance.CreateHumanUser(CTX).GetUserId() reg, err := Client.CreatePasskeyRegistrationLink(CTX, &user.CreatePasskeyRegistrationLinkRequest{ UserId: userID, Medium: &user.CreatePasskeyRegistrationLinkRequest_ReturnCode{}, @@ -26,8 +26,8 @@ func TestServer_RegisterPasskey(t *testing.T) { require.NoError(t, err) // We also need a user session - Tester.RegisterUserPasskey(CTX, userID) - _, sessionToken, _, _ := Tester.CreateVerifiedWebAuthNSession(t, CTX, userID) + Instance.RegisterUserPasskey(CTX, userID) + _, sessionToken, _, _ := Instance.CreateVerifiedWebAuthNSession(t, CTX, userID) type args struct { ctx context.Context @@ -60,7 +60,7 @@ func TestServer_RegisterPasskey(t *testing.T) { want: &user.RegisterPasskeyResponse{ Details: &object.Details{ ChangeDate: timestamppb.Now(), - ResourceOwner: Tester.Organisation.ID, + ResourceOwner: Instance.DefaultOrg.Id, }, }, }, @@ -104,7 +104,7 @@ func TestServer_RegisterPasskey(t *testing.T) { { name: "user setting its own passkey", args: args{ - ctx: Tester.WithAuthorizationToken(CTX, sessionToken), + ctx: integration.WithAuthorizationToken(CTX, sessionToken), req: &user.RegisterPasskeyRequest{ UserId: userID, }, @@ -112,7 +112,7 @@ func TestServer_RegisterPasskey(t *testing.T) { want: &user.RegisterPasskeyResponse{ Details: &object.Details{ ChangeDate: timestamppb.Now(), - ResourceOwner: Tester.Organisation.ID, + ResourceOwner: Instance.DefaultOrg.Id, }, }, }, @@ -130,7 +130,7 @@ func TestServer_RegisterPasskey(t *testing.T) { if tt.want != nil { assert.NotEmpty(t, got.GetPasskeyId()) assert.NotEmpty(t, got.GetPublicKeyCredentialCreationOptions()) - _, err = Tester.WebAuthN.CreateAttestationResponse(got.GetPublicKeyCredentialCreationOptions()) + _, err = Instance.WebAuthN.CreateAttestationResponse(got.GetPublicKeyCredentialCreationOptions()) require.NoError(t, err) } }) @@ -138,7 +138,7 @@ func TestServer_RegisterPasskey(t *testing.T) { } func TestServer_VerifyPasskeyRegistration(t *testing.T) { - userID := Tester.CreateHumanUser(CTX).GetUserId() + userID := Instance.CreateHumanUser(CTX).GetUserId() reg, err := Client.CreatePasskeyRegistrationLink(CTX, &user.CreatePasskeyRegistrationLinkRequest{ UserId: userID, Medium: &user.CreatePasskeyRegistrationLinkRequest_ReturnCode{}, @@ -152,7 +152,7 @@ func TestServer_VerifyPasskeyRegistration(t *testing.T) { require.NotEmpty(t, pkr.GetPasskeyId()) require.NotEmpty(t, pkr.GetPublicKeyCredentialCreationOptions()) - attestationResponse, err := Tester.WebAuthN.CreateAttestationResponse(pkr.GetPublicKeyCredentialCreationOptions()) + attestationResponse, err := Instance.WebAuthN.CreateAttestationResponse(pkr.GetPublicKeyCredentialCreationOptions()) require.NoError(t, err) type args struct { @@ -191,7 +191,7 @@ func TestServer_VerifyPasskeyRegistration(t *testing.T) { want: &user.VerifyPasskeyRegistrationResponse{ Details: &object.Details{ ChangeDate: timestamppb.Now(), - ResourceOwner: Tester.Organisation.ID, + ResourceOwner: Instance.DefaultOrg.Id, }, }, }, @@ -226,7 +226,7 @@ func TestServer_VerifyPasskeyRegistration(t *testing.T) { } func TestServer_CreatePasskeyRegistrationLink(t *testing.T) { - userID := Tester.CreateHumanUser(CTX).GetUserId() + userID := Instance.CreateHumanUser(CTX).GetUserId() type args struct { ctx context.Context @@ -258,7 +258,7 @@ func TestServer_CreatePasskeyRegistrationLink(t *testing.T) { want: &user.CreatePasskeyRegistrationLinkResponse{ Details: &object.Details{ ChangeDate: timestamppb.Now(), - ResourceOwner: Tester.Organisation.ID, + ResourceOwner: Instance.DefaultOrg.Id, }, }, }, @@ -278,7 +278,7 @@ func TestServer_CreatePasskeyRegistrationLink(t *testing.T) { want: &user.CreatePasskeyRegistrationLinkResponse{ Details: &object.Details{ ChangeDate: timestamppb.Now(), - ResourceOwner: Tester.Organisation.ID, + ResourceOwner: Instance.DefaultOrg.Id, }, }, }, @@ -294,7 +294,7 @@ func TestServer_CreatePasskeyRegistrationLink(t *testing.T) { want: &user.CreatePasskeyRegistrationLinkResponse{ Details: &object.Details{ ChangeDate: timestamppb.Now(), - ResourceOwner: Tester.Organisation.ID, + ResourceOwner: Instance.DefaultOrg.Id, }, }, wantCode: true, diff --git a/internal/api/grpc/user/v2beta/password_integration_test.go b/internal/api/grpc/user/v2beta/integration_test/password_test.go similarity index 91% rename from internal/api/grpc/user/v2beta/password_integration_test.go rename to internal/api/grpc/user/v2beta/integration_test/password_test.go index 03b18a5fa7..7dd71edbba 100644 --- a/internal/api/grpc/user/v2beta/password_integration_test.go +++ b/internal/api/grpc/user/v2beta/integration_test/password_test.go @@ -17,7 +17,7 @@ import ( ) func TestServer_RequestPasswordReset(t *testing.T) { - userID := Tester.CreateHumanUser(CTX).GetUserId() + userID := Instance.CreateHumanUser(CTX).GetUserId() tests := []struct { name string @@ -34,7 +34,7 @@ func TestServer_RequestPasswordReset(t *testing.T) { Details: &object.Details{ Sequence: 1, ChangeDate: timestamppb.Now(), - ResourceOwner: Tester.Organisation.ID, + ResourceOwner: Instance.DefaultOrg.Id, }, }, }, @@ -53,7 +53,7 @@ func TestServer_RequestPasswordReset(t *testing.T) { Details: &object.Details{ Sequence: 1, ChangeDate: timestamppb.Now(), - ResourceOwner: Tester.Organisation.ID, + ResourceOwner: Instance.DefaultOrg.Id, }, }, }, @@ -81,7 +81,7 @@ func TestServer_RequestPasswordReset(t *testing.T) { Details: &object.Details{ Sequence: 1, ChangeDate: timestamppb.Now(), - ResourceOwner: Tester.Organisation.ID, + ResourceOwner: Instance.DefaultOrg.Id, }, VerificationCode: gu.Ptr("xxx"), }, @@ -129,7 +129,7 @@ func TestServer_SetPassword(t *testing.T) { { name: "set successful", prepare: func(request *user.SetPasswordRequest) error { - userID := Tester.CreateHumanUser(CTX).GetUserId() + userID := Instance.CreateHumanUser(CTX).GetUserId() request.UserId = userID return nil }, @@ -144,14 +144,14 @@ func TestServer_SetPassword(t *testing.T) { want: &user.SetPasswordResponse{ Details: &object.Details{ ChangeDate: timestamppb.Now(), - ResourceOwner: Tester.Organisation.ID, + ResourceOwner: Instance.DefaultOrg.Id, }, }, }, { name: "change successful", prepare: func(request *user.SetPasswordRequest) error { - userID := Tester.CreateHumanUser(CTX).GetUserId() + userID := Instance.CreateHumanUser(CTX).GetUserId() request.UserId = userID _, err := Client.SetPassword(CTX, &user.SetPasswordRequest{ UserId: userID, @@ -175,14 +175,14 @@ func TestServer_SetPassword(t *testing.T) { want: &user.SetPasswordResponse{ Details: &object.Details{ ChangeDate: timestamppb.Now(), - ResourceOwner: Tester.Organisation.ID, + ResourceOwner: Instance.DefaultOrg.Id, }, }, }, { name: "set with code successful", prepare: func(request *user.SetPasswordRequest) error { - userID := Tester.CreateHumanUser(CTX).GetUserId() + userID := Instance.CreateHumanUser(CTX).GetUserId() request.UserId = userID resp, err := Client.PasswordReset(CTX, &user.PasswordResetRequest{ UserId: userID, @@ -209,7 +209,7 @@ func TestServer_SetPassword(t *testing.T) { want: &user.SetPasswordResponse{ Details: &object.Details{ ChangeDate: timestamppb.Now(), - ResourceOwner: Tester.Organisation.ID, + ResourceOwner: Instance.DefaultOrg.Id, }, }, }, diff --git a/internal/api/grpc/user/v2beta/phone_integration_test.go b/internal/api/grpc/user/v2beta/integration_test/phone_test.go similarity index 87% rename from internal/api/grpc/user/v2beta/phone_integration_test.go rename to internal/api/grpc/user/v2beta/integration_test/phone_test.go index 692f7af5f7..baa7b0f895 100644 --- a/internal/api/grpc/user/v2beta/phone_integration_test.go +++ b/internal/api/grpc/user/v2beta/integration_test/phone_test.go @@ -19,7 +19,7 @@ import ( ) func TestServer_SetPhone(t *testing.T) { - userID := Tester.CreateHumanUser(CTX).GetUserId() + userID := Instance.CreateHumanUser(CTX).GetUserId() tests := []struct { name string @@ -37,7 +37,7 @@ func TestServer_SetPhone(t *testing.T) { Details: &object.Details{ Sequence: 1, ChangeDate: timestamppb.Now(), - ResourceOwner: Tester.Organisation.ID, + ResourceOwner: Instance.DefaultOrg.Id, }, }, }, @@ -54,7 +54,7 @@ func TestServer_SetPhone(t *testing.T) { Details: &object.Details{ Sequence: 1, ChangeDate: timestamppb.Now(), - ResourceOwner: Tester.Organisation.ID, + ResourceOwner: Instance.DefaultOrg.Id, }, }, }, @@ -71,7 +71,7 @@ func TestServer_SetPhone(t *testing.T) { Details: &object.Details{ Sequence: 1, ChangeDate: timestamppb.Now(), - ResourceOwner: Tester.Organisation.ID, + ResourceOwner: Instance.DefaultOrg.Id, }, VerificationCode: gu.Ptr("xxx"), }, @@ -89,7 +89,7 @@ func TestServer_SetPhone(t *testing.T) { Details: &object.Details{ Sequence: 1, ChangeDate: timestamppb.Now(), - ResourceOwner: Tester.Organisation.ID, + ResourceOwner: Instance.DefaultOrg.Id, }, }, }, @@ -122,8 +122,8 @@ func TestServer_SetPhone(t *testing.T) { } func TestServer_ResendPhoneCode(t *testing.T) { - userID := Tester.CreateHumanUser(CTX).GetUserId() - verifiedUserID := Tester.CreateHumanUserVerified(CTX, Tester.Organisation.ID, fmt.Sprintf("%d@mouse.com", time.Now().UnixNano())).GetUserId() + userID := Instance.CreateHumanUser(CTX).GetUserId() + verifiedUserID := Instance.CreateHumanUserVerified(CTX, Instance.DefaultOrg.Id, fmt.Sprintf("%d@mouse.com", time.Now().UnixNano())).GetUserId() tests := []struct { name string @@ -157,7 +157,7 @@ func TestServer_ResendPhoneCode(t *testing.T) { Details: &object.Details{ Sequence: 1, ChangeDate: timestamppb.Now(), - ResourceOwner: Tester.Organisation.ID, + ResourceOwner: Instance.DefaultOrg.Id, }, }, }, @@ -173,7 +173,7 @@ func TestServer_ResendPhoneCode(t *testing.T) { Details: &object.Details{ Sequence: 1, ChangeDate: timestamppb.Now(), - ResourceOwner: Tester.Organisation.ID, + ResourceOwner: Instance.DefaultOrg.Id, }, VerificationCode: gu.Ptr("xxx"), }, @@ -196,7 +196,7 @@ func TestServer_ResendPhoneCode(t *testing.T) { } func TestServer_VerifyPhone(t *testing.T) { - userResp := Tester.CreateHumanUser(CTX) + userResp := Instance.CreateHumanUser(CTX) tests := []struct { name string req *user.VerifyPhoneRequest @@ -229,7 +229,7 @@ func TestServer_VerifyPhone(t *testing.T) { Details: &object.Details{ Sequence: 1, ChangeDate: timestamppb.Now(), - ResourceOwner: Tester.Organisation.ID, + ResourceOwner: Instance.DefaultOrg.Id, }, }, }, @@ -248,13 +248,13 @@ func TestServer_VerifyPhone(t *testing.T) { } func TestServer_RemovePhone(t *testing.T) { - userResp := Tester.CreateHumanUser(CTX) - failResp := Tester.CreateHumanUserNoPhone(CTX) - otherUser := Tester.CreateHumanUser(CTX).GetUserId() - doubleRemoveUser := Tester.CreateHumanUser(CTX) + userResp := Instance.CreateHumanUser(CTX) + failResp := Instance.CreateHumanUserNoPhone(CTX) + otherUser := Instance.CreateHumanUser(CTX).GetUserId() + doubleRemoveUser := Instance.CreateHumanUser(CTX) - Tester.RegisterUserPasskey(CTX, otherUser) - _, sessionTokenOtherUser, _, _ := Tester.CreateVerifiedWebAuthNSession(t, CTX, otherUser) + Instance.RegisterUserPasskey(CTX, otherUser) + _, sessionTokenOtherUser, _, _ := Instance.CreateVerifiedWebAuthNSession(t, CTX, otherUser) tests := []struct { name string @@ -274,7 +274,7 @@ func TestServer_RemovePhone(t *testing.T) { Details: &object.Details{ Sequence: 1, ChangeDate: timestamppb.Now(), - ResourceOwner: Tester.Organisation.ID, + ResourceOwner: Instance.DefaultOrg.Id, }, }, dep: func(ctx context.Context, userID string) (*user.RemovePhoneResponse, error) { @@ -316,7 +316,7 @@ func TestServer_RemovePhone(t *testing.T) { }, { name: "other user, no permission", - ctx: Tester.WithAuthorizationToken(CTX, sessionTokenOtherUser), + ctx: integration.WithAuthorizationToken(CTX, sessionTokenOtherUser), req: &user.RemovePhoneRequest{ UserId: userResp.GetUserId(), }, diff --git a/internal/api/grpc/user/v2beta/query_integration_test.go b/internal/api/grpc/user/v2beta/integration_test/query_test.go similarity index 94% rename from internal/api/grpc/user/v2beta/query_integration_test.go rename to internal/api/grpc/user/v2beta/integration_test/query_test.go index 1b375e4091..f78a54d049 100644 --- a/internal/api/grpc/user/v2beta/query_integration_test.go +++ b/internal/api/grpc/user/v2beta/integration_test/query_test.go @@ -28,7 +28,7 @@ func detailsV2ToV2beta(obj *object.Details) *object_v2beta.Details { } func TestServer_GetUserByID(t *testing.T) { - orgResp := Tester.CreateOrganization(IamCTX, fmt.Sprintf("GetUserByIDOrg%d", time.Now().UnixNano()), fmt.Sprintf("%d@mouse.com", time.Now().UnixNano())) + orgResp := Instance.CreateOrganization(IamCTX, fmt.Sprintf("GetUserByIDOrg%d", time.Now().UnixNano()), fmt.Sprintf("%d@mouse.com", time.Now().UnixNano())) type args struct { ctx context.Context req *user.GetUserByIDRequest @@ -72,7 +72,7 @@ func TestServer_GetUserByID(t *testing.T) { IamCTX, &user.GetUserByIDRequest{}, func(ctx context.Context, username string, request *user.GetUserByIDRequest) (*userAttr, error) { - resp := Tester.CreateHumanUserVerified(ctx, orgResp.OrganizationId, username) + resp := Instance.CreateHumanUserVerified(ctx, orgResp.OrganizationId, username) request.UserId = resp.GetUserId() return &userAttr{resp.GetUserId(), username, nil, resp.GetDetails()}, nil }, @@ -116,9 +116,9 @@ func TestServer_GetUserByID(t *testing.T) { IamCTX, &user.GetUserByIDRequest{}, func(ctx context.Context, username string, request *user.GetUserByIDRequest) (*userAttr, error) { - resp := Tester.CreateHumanUserVerified(ctx, orgResp.OrganizationId, username) + resp := Instance.CreateHumanUserVerified(ctx, orgResp.OrganizationId, username) request.UserId = resp.GetUserId() - details := Tester.SetUserPassword(ctx, resp.GetUserId(), integration.UserPassword, true) + details := Instance.SetUserPassword(ctx, resp.GetUserId(), integration.UserPassword, true) return &userAttr{resp.GetUserId(), username, details.GetChangeDate(), resp.GetDetails()}, nil }, }, @@ -198,7 +198,7 @@ func TestServer_GetUserByID(t *testing.T) { func TestServer_GetUserByID_Permission(t *testing.T) { timeNow := time.Now().UTC() newOrgOwnerEmail := fmt.Sprintf("%d@permission.get.com", timeNow.UnixNano()) - newOrg := Tester.CreateOrganization(IamCTX, fmt.Sprintf("GetHuman%d", time.Now().UnixNano()), newOrgOwnerEmail) + newOrg := Instance.CreateOrganization(IamCTX, fmt.Sprintf("GetHuman%d", time.Now().UnixNano()), newOrgOwnerEmail) newUserID := newOrg.CreatedAdmins[0].GetUserId() type args struct { ctx context.Context @@ -338,8 +338,8 @@ type userAttr struct { } func TestServer_ListUsers(t *testing.T) { - orgResp := Tester.CreateOrganization(IamCTX, fmt.Sprintf("ListUsersOrg%d", time.Now().UnixNano()), fmt.Sprintf("%d@mouse.com", time.Now().UnixNano())) - userResp := Tester.CreateHumanUserVerified(IamCTX, orgResp.OrganizationId, fmt.Sprintf("%d@listusers.com", time.Now().UnixNano())) + orgResp := Instance.CreateOrganization(IamCTX, fmt.Sprintf("ListUsersOrg%d", time.Now().UnixNano()), fmt.Sprintf("%d@mouse.com", time.Now().UnixNano())) + userResp := Instance.CreateHumanUserVerified(IamCTX, orgResp.OrganizationId, fmt.Sprintf("%d@listusers.com", time.Now().UnixNano())) type args struct { ctx context.Context count int @@ -386,7 +386,7 @@ func TestServer_ListUsers(t *testing.T) { infos := make([]userAttr, len(usernames)) userIDs := make([]string, len(usernames)) for i, username := range usernames { - resp := Tester.CreateHumanUserVerified(ctx, orgResp.OrganizationId, username) + resp := Instance.CreateHumanUserVerified(ctx, orgResp.OrganizationId, username) userIDs[i] = resp.GetUserId() infos[i] = userAttr{resp.GetUserId(), username, nil, resp.GetDetails()} } @@ -440,9 +440,9 @@ func TestServer_ListUsers(t *testing.T) { infos := make([]userAttr, len(usernames)) userIDs := make([]string, len(usernames)) for i, username := range usernames { - resp := Tester.CreateHumanUserVerified(ctx, orgResp.OrganizationId, username) + resp := Instance.CreateHumanUserVerified(ctx, orgResp.OrganizationId, username) userIDs[i] = resp.GetUserId() - details := Tester.SetUserPassword(ctx, resp.GetUserId(), integration.UserPassword, true) + details := Instance.SetUserPassword(ctx, resp.GetUserId(), integration.UserPassword, true) infos[i] = userAttr{resp.GetUserId(), username, details.GetChangeDate(), resp.GetDetails()} } request.Queries = append(request.Queries, InUserIDsQuery(userIDs)) @@ -497,7 +497,7 @@ func TestServer_ListUsers(t *testing.T) { infos := make([]userAttr, len(usernames)) userIDs := make([]string, len(usernames)) for i, username := range usernames { - resp := Tester.CreateHumanUserVerified(ctx, orgResp.OrganizationId, username) + resp := Instance.CreateHumanUserVerified(ctx, orgResp.OrganizationId, username) userIDs[i] = resp.GetUserId() infos[i] = userAttr{resp.GetUserId(), username, nil, resp.GetDetails()} } @@ -593,7 +593,7 @@ func TestServer_ListUsers(t *testing.T) { infos := make([]userAttr, len(usernames)) userIDs := make([]string, len(usernames)) for i, username := range usernames { - resp := Tester.CreateHumanUserVerified(ctx, orgResp.OrganizationId, username) + resp := Instance.CreateHumanUserVerified(ctx, orgResp.OrganizationId, username) userIDs[i] = resp.GetUserId() infos[i] = userAttr{resp.GetUserId(), username, nil, resp.GetDetails()} request.Queries = append(request.Queries, UsernameQuery(username)) @@ -646,7 +646,7 @@ func TestServer_ListUsers(t *testing.T) { func(ctx context.Context, usernames []string, request *user.ListUsersRequest) ([]userAttr, error) { infos := make([]userAttr, len(usernames)) for i, username := range usernames { - resp := Tester.CreateHumanUserVerified(ctx, orgResp.OrganizationId, username) + resp := Instance.CreateHumanUserVerified(ctx, orgResp.OrganizationId, username) infos[i] = userAttr{resp.GetUserId(), username, nil, resp.GetDetails()} } request.Queries = append(request.Queries, InUserEmailsQuery(usernames)) @@ -698,7 +698,7 @@ func TestServer_ListUsers(t *testing.T) { func(ctx context.Context, usernames []string, request *user.ListUsersRequest) ([]userAttr, error) { infos := make([]userAttr, len(usernames)) for i, username := range usernames { - resp := Tester.CreateHumanUserVerified(ctx, orgResp.OrganizationId, username) + resp := Instance.CreateHumanUserVerified(ctx, orgResp.OrganizationId, username) infos[i] = userAttr{resp.GetUserId(), username, nil, resp.GetDetails()} } request.Queries = append(request.Queries, InUserEmailsQuery(usernames)) @@ -809,11 +809,11 @@ func TestServer_ListUsers(t *testing.T) { 3, &user.ListUsersRequest{}, func(ctx context.Context, usernames []string, request *user.ListUsersRequest) ([]userAttr, error) { - orgResp := Tester.CreateOrganization(ctx, fmt.Sprintf("ListUsersResourceowner%d", time.Now().UnixNano()), fmt.Sprintf("%d@mouse.com", time.Now().UnixNano())) + orgResp := Instance.CreateOrganization(ctx, fmt.Sprintf("ListUsersResourceowner%d", time.Now().UnixNano()), fmt.Sprintf("%d@mouse.com", time.Now().UnixNano())) infos := make([]userAttr, len(usernames)) for i, username := range usernames { - resp := Tester.CreateHumanUserVerified(ctx, orgResp.OrganizationId, username) + resp := Instance.CreateHumanUserVerified(ctx, orgResp.OrganizationId, username) infos[i] = userAttr{resp.GetUserId(), username, nil, resp.GetDetails()} } request.Queries = append(request.Queries, OrganizationIdQuery(orgResp.OrganizationId)) diff --git a/internal/api/grpc/user/v2beta/totp_integration_test.go b/internal/api/grpc/user/v2beta/integration_test/totp_test.go similarity index 70% rename from internal/api/grpc/user/v2beta/totp_integration_test.go rename to internal/api/grpc/user/v2beta/integration_test/totp_test.go index 47b2952afd..4afe5e1f31 100644 --- a/internal/api/grpc/user/v2beta/totp_integration_test.go +++ b/internal/api/grpc/user/v2beta/integration_test/totp_test.go @@ -18,15 +18,15 @@ import ( ) func TestServer_RegisterTOTP(t *testing.T) { - userID := Tester.CreateHumanUser(CTX).GetUserId() - Tester.RegisterUserPasskey(CTX, userID) - _, sessionToken, _, _ := Tester.CreateVerifiedWebAuthNSession(t, CTX, userID) - ctx := Tester.WithAuthorizationToken(CTX, sessionToken) + userID := Instance.CreateHumanUser(CTX).GetUserId() + Instance.RegisterUserPasskey(CTX, userID) + _, sessionToken, _, _ := Instance.CreateVerifiedWebAuthNSession(t, CTX, userID) + ctx := integration.WithAuthorizationToken(CTX, sessionToken) - otherUser := Tester.CreateHumanUser(CTX).GetUserId() - Tester.RegisterUserPasskey(CTX, otherUser) - _, sessionTokenOtherUser, _, _ := Tester.CreateVerifiedWebAuthNSession(t, CTX, otherUser) - ctxOtherUser := Tester.WithAuthorizationToken(CTX, sessionTokenOtherUser) + otherUser := Instance.CreateHumanUser(CTX).GetUserId() + Instance.RegisterUserPasskey(CTX, otherUser) + _, sessionTokenOtherUser, _, _ := Instance.CreateVerifiedWebAuthNSession(t, CTX, otherUser) + ctxOtherUser := integration.WithAuthorizationToken(CTX, sessionTokenOtherUser) type args struct { ctx context.Context @@ -67,7 +67,7 @@ func TestServer_RegisterTOTP(t *testing.T) { want: &user.RegisterTOTPResponse{ Details: &object.Details{ ChangeDate: timestamppb.Now(), - ResourceOwner: Tester.Organisation.ID, + ResourceOwner: Instance.DefaultOrg.Id, }, }, }, @@ -82,7 +82,7 @@ func TestServer_RegisterTOTP(t *testing.T) { want: &user.RegisterTOTPResponse{ Details: &object.Details{ ChangeDate: timestamppb.Now(), - ResourceOwner: Tester.Organisation.ID, + ResourceOwner: Instance.DefaultOrg.Id, }, }, }, @@ -104,22 +104,27 @@ func TestServer_RegisterTOTP(t *testing.T) { } func TestServer_VerifyTOTPRegistration(t *testing.T) { - userID := Tester.CreateHumanUser(CTX).GetUserId() - Tester.RegisterUserPasskey(CTX, userID) - _, sessionToken, _, _ := Tester.CreateVerifiedWebAuthNSession(t, CTX, userID) - ctx := Tester.WithAuthorizationToken(CTX, sessionToken) + userID := Instance.CreateHumanUser(CTX).GetUserId() + Instance.RegisterUserPasskey(CTX, userID) + _, sessionToken, _, _ := Instance.CreateVerifiedWebAuthNSession(t, CTX, userID) + ctx := integration.WithAuthorizationToken(CTX, sessionToken) + + var reg *user.RegisterTOTPResponse + assert.EventuallyWithT(t, func(ct *assert.CollectT) { + var err error + reg, err = Client.RegisterTOTP(ctx, &user.RegisterTOTPRequest{ + UserId: userID, + }) + assert.NoError(ct, err) + }, time.Minute, time.Second/10) - reg, err := Client.RegisterTOTP(ctx, &user.RegisterTOTPRequest{ - UserId: userID, - }) - require.NoError(t, err) code, err := totp.GenerateCode(reg.Secret, time.Now()) require.NoError(t, err) - otherUser := Tester.CreateHumanUser(CTX).GetUserId() - Tester.RegisterUserPasskey(CTX, otherUser) - _, sessionTokenOtherUser, _, _ := Tester.CreateVerifiedWebAuthNSession(t, CTX, otherUser) - ctxOtherUser := Tester.WithAuthorizationToken(CTX, sessionTokenOtherUser) + otherUser := Instance.CreateHumanUser(CTX).GetUserId() + Instance.RegisterUserPasskey(CTX, otherUser) + _, sessionTokenOtherUser, _, _ := Instance.CreateVerifiedWebAuthNSession(t, CTX, otherUser) + ctxOtherUser := integration.WithAuthorizationToken(CTX, sessionTokenOtherUser) regOtherUser, err := Client.RegisterTOTP(CTX, &user.RegisterTOTPRequest{ UserId: otherUser, @@ -171,7 +176,7 @@ func TestServer_VerifyTOTPRegistration(t *testing.T) { want: &user.VerifyTOTPRegistrationResponse{ Details: &object.Details{ ChangeDate: timestamppb.Now(), - ResourceOwner: Tester.Organisation.ResourceOwner, + ResourceOwner: Instance.DefaultOrg.Details.ResourceOwner, }, }, }, @@ -187,7 +192,7 @@ func TestServer_VerifyTOTPRegistration(t *testing.T) { want: &user.VerifyTOTPRegistrationResponse{ Details: &object.Details{ ChangeDate: timestamppb.Now(), - ResourceOwner: Tester.Organisation.ResourceOwner, + ResourceOwner: Instance.DefaultOrg.Details.ResourceOwner, }, }, }, @@ -207,14 +212,14 @@ func TestServer_VerifyTOTPRegistration(t *testing.T) { } func TestServer_RemoveTOTP(t *testing.T) { - userID := Tester.CreateHumanUser(CTX).GetUserId() - Tester.RegisterUserPasskey(CTX, userID) - _, sessionToken, _, _ := Tester.CreateVerifiedWebAuthNSession(t, CTX, userID) + userID := Instance.CreateHumanUser(CTX).GetUserId() + Instance.RegisterUserPasskey(CTX, userID) + _, sessionToken, _, _ := Instance.CreateVerifiedWebAuthNSession(t, CTX, userID) - userVerified := Tester.CreateHumanUser(CTX) - Tester.RegisterUserPasskey(CTX, userVerified.GetUserId()) - _, sessionTokenVerified, _, _ := Tester.CreateVerifiedWebAuthNSession(t, CTX, userVerified.GetUserId()) - userVerifiedCtx := Tester.WithAuthorizationToken(context.Background(), sessionTokenVerified) + userVerified := Instance.CreateHumanUser(CTX) + Instance.RegisterUserPasskey(CTX, userVerified.GetUserId()) + _, sessionTokenVerified, _, _ := Instance.CreateVerifiedWebAuthNSession(t, CTX, userVerified.GetUserId()) + userVerifiedCtx := integration.WithAuthorizationToken(context.Background(), sessionTokenVerified) _, err := Client.VerifyPhone(userVerifiedCtx, &user.VerifyPhoneRequest{ UserId: userVerified.GetUserId(), VerificationCode: userVerified.GetPhoneCode(), @@ -247,7 +252,7 @@ func TestServer_RemoveTOTP(t *testing.T) { { name: "not added", args: args{ - ctx: Tester.WithAuthorizationToken(context.Background(), sessionToken), + ctx: integration.WithAuthorizationToken(context.Background(), sessionToken), req: &user.RemoveTOTPRequest{ UserId: userID, }, @@ -264,7 +269,7 @@ func TestServer_RemoveTOTP(t *testing.T) { }, want: &user.RemoveTOTPResponse{ Details: &object.Details{ - ResourceOwner: Tester.Organisation.ResourceOwner, + ResourceOwner: Instance.DefaultOrg.Details.ResourceOwner, }, }, }, diff --git a/internal/api/grpc/user/v2beta/u2f_integration_test.go b/internal/api/grpc/user/v2beta/integration_test/u2f_test.go similarity index 76% rename from internal/api/grpc/user/v2beta/u2f_integration_test.go rename to internal/api/grpc/user/v2beta/integration_test/u2f_test.go index 3b7fbd293c..6e47cbbb99 100644 --- a/internal/api/grpc/user/v2beta/u2f_integration_test.go +++ b/internal/api/grpc/user/v2beta/integration_test/u2f_test.go @@ -17,14 +17,14 @@ import ( ) func TestServer_RegisterU2F(t *testing.T) { - userID := Tester.CreateHumanUser(CTX).GetUserId() - otherUser := Tester.CreateHumanUser(CTX).GetUserId() + userID := Instance.CreateHumanUser(CTX).GetUserId() + otherUser := Instance.CreateHumanUser(CTX).GetUserId() // We also need a user session - Tester.RegisterUserPasskey(CTX, userID) - _, sessionToken, _, _ := Tester.CreateVerifiedWebAuthNSession(t, CTX, userID) - Tester.RegisterUserPasskey(CTX, otherUser) - _, sessionTokenOtherUser, _, _ := Tester.CreateVerifiedWebAuthNSession(t, CTX, otherUser) + Instance.RegisterUserPasskey(CTX, userID) + _, sessionToken, _, _ := Instance.CreateVerifiedWebAuthNSession(t, CTX, userID) + Instance.RegisterUserPasskey(CTX, otherUser) + _, sessionTokenOtherUser, _, _ := Instance.CreateVerifiedWebAuthNSession(t, CTX, otherUser) type args struct { ctx context.Context @@ -55,14 +55,14 @@ func TestServer_RegisterU2F(t *testing.T) { want: &user.RegisterU2FResponse{ Details: &object.Details{ ChangeDate: timestamppb.Now(), - ResourceOwner: Tester.Organisation.ID, + ResourceOwner: Instance.DefaultOrg.Id, }, }, }, { name: "other user, no permission", args: args{ - ctx: Tester.WithAuthorizationToken(CTX, sessionTokenOtherUser), + ctx: integration.WithAuthorizationToken(CTX, sessionTokenOtherUser), req: &user.RegisterU2FRequest{ UserId: userID, }, @@ -72,7 +72,7 @@ func TestServer_RegisterU2F(t *testing.T) { { name: "user setting its own passkey", args: args{ - ctx: Tester.WithAuthorizationToken(CTX, sessionToken), + ctx: integration.WithAuthorizationToken(CTX, sessionToken), req: &user.RegisterU2FRequest{ UserId: userID, }, @@ -80,7 +80,7 @@ func TestServer_RegisterU2F(t *testing.T) { want: &user.RegisterU2FResponse{ Details: &object.Details{ ChangeDate: timestamppb.Now(), - ResourceOwner: Tester.Organisation.ID, + ResourceOwner: Instance.DefaultOrg.Id, }, }, }, @@ -98,7 +98,7 @@ func TestServer_RegisterU2F(t *testing.T) { if tt.want != nil { assert.NotEmpty(t, got.GetU2FId()) assert.NotEmpty(t, got.GetPublicKeyCredentialCreationOptions()) - _, err = Tester.WebAuthN.CreateAttestationResponse(got.GetPublicKeyCredentialCreationOptions()) + _, err = Instance.WebAuthN.CreateAttestationResponse(got.GetPublicKeyCredentialCreationOptions()) require.NoError(t, err) } }) @@ -106,10 +106,10 @@ func TestServer_RegisterU2F(t *testing.T) { } func TestServer_VerifyU2FRegistration(t *testing.T) { - userID := Tester.CreateHumanUser(CTX).GetUserId() - Tester.RegisterUserPasskey(CTX, userID) - _, sessionToken, _, _ := Tester.CreateVerifiedWebAuthNSession(t, CTX, userID) - ctx := Tester.WithAuthorizationToken(CTX, sessionToken) + userID := Instance.CreateHumanUser(CTX).GetUserId() + Instance.RegisterUserPasskey(CTX, userID) + _, sessionToken, _, _ := Instance.CreateVerifiedWebAuthNSession(t, CTX, userID) + ctx := integration.WithAuthorizationToken(CTX, sessionToken) pkr, err := Client.RegisterU2F(ctx, &user.RegisterU2FRequest{ UserId: userID, @@ -117,7 +117,7 @@ func TestServer_VerifyU2FRegistration(t *testing.T) { require.NoError(t, err) require.NotEmpty(t, pkr.GetPublicKeyCredentialCreationOptions()) - attestationResponse, err := Tester.WebAuthN.CreateAttestationResponse(pkr.GetPublicKeyCredentialCreationOptions()) + attestationResponse, err := Instance.WebAuthN.CreateAttestationResponse(pkr.GetPublicKeyCredentialCreationOptions()) require.NoError(t, err) type args struct { @@ -155,7 +155,7 @@ func TestServer_VerifyU2FRegistration(t *testing.T) { want: &user.VerifyU2FRegistrationResponse{ Details: &object.Details{ ChangeDate: timestamppb.Now(), - ResourceOwner: Tester.Organisation.ID, + ResourceOwner: Instance.DefaultOrg.Id, }, }, }, diff --git a/internal/api/grpc/user/v2beta/user_integration_test.go b/internal/api/grpc/user/v2beta/integration_test/user_test.go similarity index 88% rename from internal/api/grpc/user/v2beta/user_integration_test.go rename to internal/api/grpc/user/v2beta/integration_test/user_test.go index d808e46c5f..bf93b67c93 100644 --- a/internal/api/grpc/user/v2beta/user_integration_test.go +++ b/internal/api/grpc/user/v2beta/integration_test/user_test.go @@ -15,10 +15,8 @@ import ( "github.com/stretchr/testify/require" "google.golang.org/grpc/codes" "google.golang.org/grpc/status" - "google.golang.org/protobuf/types/known/structpb" "google.golang.org/protobuf/types/known/timestamppb" - "github.com/zitadel/zitadel/internal/api/grpc" "github.com/zitadel/zitadel/internal/integration" "github.com/zitadel/zitadel/pkg/grpc/idp" mgmt "github.com/zitadel/zitadel/pkg/grpc/management" @@ -31,30 +29,28 @@ var ( IamCTX context.Context UserCTX context.Context SystemCTX context.Context - ErrCTX context.Context - Tester *integration.Tester + Instance *integration.Instance Client user.UserServiceClient ) func TestMain(m *testing.M) { os.Exit(func() int { - ctx, errCtx, cancel := integration.Contexts(time.Hour) + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Minute) defer cancel() - Tester = integration.NewTester(ctx) - defer Tester.Done() + Instance = integration.NewInstance(ctx) - UserCTX = Tester.WithAuthorization(ctx, integration.Login) - IamCTX = Tester.WithAuthorization(ctx, integration.IAMOwner) - SystemCTX = Tester.WithAuthorization(ctx, integration.SystemUser) - CTX, ErrCTX = Tester.WithAuthorization(ctx, integration.OrgOwner), errCtx - Client = Tester.Client.UserV2beta + UserCTX = Instance.WithAuthorization(ctx, integration.UserTypeLogin) + IamCTX = Instance.WithAuthorization(ctx, integration.UserTypeIAMOwner) + SystemCTX = integration.WithSystemAuthorization(ctx) + CTX = Instance.WithAuthorization(ctx, integration.UserTypeOrgOwner) + Client = Instance.Client.UserV2beta return m.Run() }()) } func TestServer_AddHumanUser(t *testing.T) { - idpID := Tester.AddGenericOAuthProvider(t, CTX) + idpResp := Instance.AddGenericOAuthProvider(IamCTX, Instance.DefaultOrg.Id) type args struct { ctx context.Context req *user.AddHumanUserRequest @@ -72,7 +68,7 @@ func TestServer_AddHumanUser(t *testing.T) { &user.AddHumanUserRequest{ Organization: &object.Organization{ Org: &object.Organization_OrgId{ - OrgId: Tester.Organisation.ID, + OrgId: Instance.DefaultOrg.Id, }, }, Profile: &user.SetHumanProfile{ @@ -102,7 +98,7 @@ func TestServer_AddHumanUser(t *testing.T) { want: &user.AddHumanUserResponse{ Details: &object.Details{ ChangeDate: timestamppb.Now(), - ResourceOwner: Tester.Organisation.ID, + ResourceOwner: Instance.DefaultOrg.Id, }, }, }, @@ -113,7 +109,7 @@ func TestServer_AddHumanUser(t *testing.T) { &user.AddHumanUserRequest{ Organization: &object.Organization{ Org: &object.Organization_OrgId{ - OrgId: Tester.Organisation.ID, + OrgId: Instance.DefaultOrg.Id, }, }, Profile: &user.SetHumanProfile{ @@ -146,7 +142,7 @@ func TestServer_AddHumanUser(t *testing.T) { want: &user.AddHumanUserResponse{ Details: &object.Details{ ChangeDate: timestamppb.Now(), - ResourceOwner: Tester.Organisation.ID, + ResourceOwner: Instance.DefaultOrg.Id, }, EmailCode: gu.Ptr("something"), }, @@ -158,7 +154,7 @@ func TestServer_AddHumanUser(t *testing.T) { &user.AddHumanUserRequest{ Organization: &object.Organization{ Org: &object.Organization_OrgId{ - OrgId: Tester.Organisation.ID, + OrgId: Instance.DefaultOrg.Id, }, }, Profile: &user.SetHumanProfile{ @@ -193,7 +189,7 @@ func TestServer_AddHumanUser(t *testing.T) { want: &user.AddHumanUserResponse{ Details: &object.Details{ ChangeDate: timestamppb.Now(), - ResourceOwner: Tester.Organisation.ID, + ResourceOwner: Instance.DefaultOrg.Id, }, }, }, @@ -204,7 +200,7 @@ func TestServer_AddHumanUser(t *testing.T) { &user.AddHumanUserRequest{ Organization: &object.Organization{ Org: &object.Organization_OrgId{ - OrgId: Tester.Organisation.ID, + OrgId: Instance.DefaultOrg.Id, }, }, Profile: &user.SetHumanProfile{ @@ -239,7 +235,7 @@ func TestServer_AddHumanUser(t *testing.T) { want: &user.AddHumanUserResponse{ Details: &object.Details{ ChangeDate: timestamppb.Now(), - ResourceOwner: Tester.Organisation.ID, + ResourceOwner: Instance.DefaultOrg.Id, }, PhoneCode: gu.Ptr("something"), }, @@ -251,7 +247,7 @@ func TestServer_AddHumanUser(t *testing.T) { &user.AddHumanUserRequest{ Organization: &object.Organization{ Org: &object.Organization_OrgId{ - OrgId: Tester.Organisation.ID, + OrgId: Instance.DefaultOrg.Id, }, }, Profile: &user.SetHumanProfile{ @@ -292,7 +288,7 @@ func TestServer_AddHumanUser(t *testing.T) { &user.AddHumanUserRequest{ Organization: &object.Organization{ Org: &object.Organization_OrgId{ - OrgId: Tester.Organisation.ID, + OrgId: Instance.DefaultOrg.Id, }, }, Email: &user.SetHumanEmail{ @@ -323,7 +319,7 @@ func TestServer_AddHumanUser(t *testing.T) { &user.AddHumanUserRequest{ Organization: &object.Organization{ Org: &object.Organization_OrgId{ - OrgId: Tester.Organisation.ID, + OrgId: Instance.DefaultOrg.Id, }, }, Profile: &user.SetHumanProfile{ @@ -357,7 +353,7 @@ func TestServer_AddHumanUser(t *testing.T) { &user.AddHumanUserRequest{ Organization: &object.Organization{ Org: &object.Organization_OrgId{ - OrgId: Tester.Organisation.ID, + OrgId: Instance.DefaultOrg.Id, }, }, Profile: &user.SetHumanProfile{ @@ -404,7 +400,7 @@ func TestServer_AddHumanUser(t *testing.T) { &user.AddHumanUserRequest{ Organization: &object.Organization{ Org: &object.Organization_OrgId{ - OrgId: Tester.Organisation.ID, + OrgId: Instance.DefaultOrg.Id, }, }, Profile: &user.SetHumanProfile{ @@ -435,7 +431,7 @@ func TestServer_AddHumanUser(t *testing.T) { }, IdpLinks: []*user.IDPLink{ { - IdpId: idpID, + IdpId: idpResp.Id, UserId: "userID", UserName: "username", }, @@ -445,7 +441,7 @@ func TestServer_AddHumanUser(t *testing.T) { want: &user.AddHumanUserResponse{ Details: &object.Details{ ChangeDate: timestamppb.Now(), - ResourceOwner: Tester.Organisation.ID, + ResourceOwner: Instance.DefaultOrg.Id, }, }, }, @@ -456,7 +452,7 @@ func TestServer_AddHumanUser(t *testing.T) { &user.AddHumanUserRequest{ Organization: &object.Organization{ Org: &object.Organization_OrgId{ - OrgId: Tester.Organisation.ID, + OrgId: Instance.DefaultOrg.Id, }, }, Profile: &user.SetHumanProfile{ @@ -491,7 +487,7 @@ func TestServer_AddHumanUser(t *testing.T) { want: &user.AddHumanUserResponse{ Details: &object.Details{ ChangeDate: timestamppb.Now(), - ResourceOwner: Tester.Organisation.ID, + ResourceOwner: Instance.DefaultOrg.Id, }, }, }, @@ -502,7 +498,7 @@ func TestServer_AddHumanUser(t *testing.T) { &user.AddHumanUserRequest{ Organization: &object.Organization{ Org: &object.Organization_OrgId{ - OrgId: Tester.Organisation.ID, + OrgId: Instance.DefaultOrg.Id, }, }, Profile: &user.SetHumanProfile{ @@ -536,7 +532,7 @@ func TestServer_AddHumanUser(t *testing.T) { &user.AddHumanUserRequest{ Organization: &object.Organization{ Org: &object.Organization_OrgId{ - OrgId: Tester.Organisation.ID, + OrgId: Instance.DefaultOrg.Id, }, }, Profile: &user.SetHumanProfile{ @@ -564,7 +560,7 @@ func TestServer_AddHumanUser(t *testing.T) { want: &user.AddHumanUserResponse{ Details: &object.Details{ ChangeDate: timestamppb.Now(), - ResourceOwner: Tester.Organisation.ID, + ResourceOwner: Instance.DefaultOrg.Id, }, }, }, @@ -575,7 +571,7 @@ func TestServer_AddHumanUser(t *testing.T) { &user.AddHumanUserRequest{ Organization: &object.Organization{ Org: &object.Organization_OrgId{ - OrgId: Tester.Organisation.ID, + OrgId: Instance.DefaultOrg.Id, }, }, Profile: &user.SetHumanProfile{ @@ -636,7 +632,7 @@ func TestServer_AddHumanUser(t *testing.T) { func TestServer_AddHumanUser_Permission(t *testing.T) { newOrgOwnerEmail := fmt.Sprintf("%d@permission.com", time.Now().UnixNano()) - newOrg := Tester.CreateOrganization(IamCTX, fmt.Sprintf("AddHuman%d", time.Now().UnixNano()), newOrgOwnerEmail) + newOrg := Instance.CreateOrganization(IamCTX, fmt.Sprintf("AddHuman%d", time.Now().UnixNano()), newOrgOwnerEmail) type args struct { ctx context.Context req *user.AddHumanUserRequest @@ -856,7 +852,7 @@ func TestServer_UpdateHumanUser(t *testing.T) { { name: "change username, ok", prepare: func(request *user.UpdateHumanUserRequest) error { - userID := Tester.CreateHumanUser(CTX).GetUserId() + userID := Instance.CreateHumanUser(CTX).GetUserId() request.UserId = userID return nil }, @@ -869,14 +865,14 @@ func TestServer_UpdateHumanUser(t *testing.T) { want: &user.UpdateHumanUserResponse{ Details: &object.Details{ ChangeDate: timestamppb.Now(), - ResourceOwner: Tester.Organisation.ID, + ResourceOwner: Instance.DefaultOrg.Id, }, }, }, { name: "change profile, ok", prepare: func(request *user.UpdateHumanUserRequest) error { - userID := Tester.CreateHumanUser(CTX).GetUserId() + userID := Instance.CreateHumanUser(CTX).GetUserId() request.UserId = userID return nil }, @@ -896,14 +892,14 @@ func TestServer_UpdateHumanUser(t *testing.T) { want: &user.UpdateHumanUserResponse{ Details: &object.Details{ ChangeDate: timestamppb.Now(), - ResourceOwner: Tester.Organisation.ID, + ResourceOwner: Instance.DefaultOrg.Id, }, }, }, { name: "change email, ok", prepare: func(request *user.UpdateHumanUserRequest) error { - userID := Tester.CreateHumanUser(CTX).GetUserId() + userID := Instance.CreateHumanUser(CTX).GetUserId() request.UserId = userID return nil }, @@ -919,14 +915,14 @@ func TestServer_UpdateHumanUser(t *testing.T) { want: &user.UpdateHumanUserResponse{ Details: &object.Details{ ChangeDate: timestamppb.Now(), - ResourceOwner: Tester.Organisation.ID, + ResourceOwner: Instance.DefaultOrg.Id, }, }, }, { name: "change email, code, ok", prepare: func(request *user.UpdateHumanUserRequest) error { - userID := Tester.CreateHumanUser(CTX).GetUserId() + userID := Instance.CreateHumanUser(CTX).GetUserId() request.UserId = userID return nil }, @@ -942,7 +938,7 @@ func TestServer_UpdateHumanUser(t *testing.T) { want: &user.UpdateHumanUserResponse{ Details: &object.Details{ ChangeDate: timestamppb.Now(), - ResourceOwner: Tester.Organisation.ID, + ResourceOwner: Instance.DefaultOrg.Id, }, EmailCode: gu.Ptr("something"), }, @@ -950,7 +946,7 @@ func TestServer_UpdateHumanUser(t *testing.T) { { name: "change phone, ok", prepare: func(request *user.UpdateHumanUserRequest) error { - userID := Tester.CreateHumanUser(CTX).GetUserId() + userID := Instance.CreateHumanUser(CTX).GetUserId() request.UserId = userID return nil }, @@ -966,14 +962,14 @@ func TestServer_UpdateHumanUser(t *testing.T) { want: &user.UpdateHumanUserResponse{ Details: &object.Details{ ChangeDate: timestamppb.Now(), - ResourceOwner: Tester.Organisation.ID, + ResourceOwner: Instance.DefaultOrg.Id, }, }, }, { name: "change phone, code, ok", prepare: func(request *user.UpdateHumanUserRequest) error { - userID := Tester.CreateHumanUser(CTX).GetUserId() + userID := Instance.CreateHumanUser(CTX).GetUserId() request.UserId = userID return nil }, @@ -989,7 +985,7 @@ func TestServer_UpdateHumanUser(t *testing.T) { want: &user.UpdateHumanUserResponse{ Details: &object.Details{ ChangeDate: timestamppb.Now(), - ResourceOwner: Tester.Organisation.ID, + ResourceOwner: Instance.DefaultOrg.Id, }, PhoneCode: gu.Ptr("something"), }, @@ -997,7 +993,7 @@ func TestServer_UpdateHumanUser(t *testing.T) { { name: "change password, code, ok", prepare: func(request *user.UpdateHumanUserRequest) error { - userID := Tester.CreateHumanUser(CTX).GetUserId() + userID := Instance.CreateHumanUser(CTX).GetUserId() request.UserId = userID resp, err := Client.PasswordReset(CTX, &user.PasswordResetRequest{ UserId: userID, @@ -1029,14 +1025,14 @@ func TestServer_UpdateHumanUser(t *testing.T) { want: &user.UpdateHumanUserResponse{ Details: &object.Details{ ChangeDate: timestamppb.Now(), - ResourceOwner: Tester.Organisation.ID, + ResourceOwner: Instance.DefaultOrg.Id, }, }, }, { name: "change hashed password, code, ok", prepare: func(request *user.UpdateHumanUserRequest) error { - userID := Tester.CreateHumanUser(CTX).GetUserId() + userID := Instance.CreateHumanUser(CTX).GetUserId() request.UserId = userID resp, err := Client.PasswordReset(CTX, &user.PasswordResetRequest{ UserId: userID, @@ -1067,14 +1063,14 @@ func TestServer_UpdateHumanUser(t *testing.T) { want: &user.UpdateHumanUserResponse{ Details: &object.Details{ ChangeDate: timestamppb.Now(), - ResourceOwner: Tester.Organisation.ID, + ResourceOwner: Instance.DefaultOrg.Id, }, }, }, { name: "change hashed password, code, not supported", prepare: func(request *user.UpdateHumanUserRequest) error { - userID := Tester.CreateHumanUser(CTX).GetUserId() + userID := Instance.CreateHumanUser(CTX).GetUserId() request.UserId = userID resp, err := Client.PasswordReset(CTX, &user.PasswordResetRequest{ UserId: userID, @@ -1109,7 +1105,7 @@ func TestServer_UpdateHumanUser(t *testing.T) { { name: "change password, old password, ok", prepare: func(request *user.UpdateHumanUserRequest) error { - userID := Tester.CreateHumanUser(CTX).GetUserId() + userID := Instance.CreateHumanUser(CTX).GetUserId() request.UserId = userID resp, err := Client.PasswordReset(CTX, &user.PasswordResetRequest{ @@ -1156,7 +1152,7 @@ func TestServer_UpdateHumanUser(t *testing.T) { want: &user.UpdateHumanUserResponse{ Details: &object.Details{ ChangeDate: timestamppb.Now(), - ResourceOwner: Tester.Organisation.ID, + ResourceOwner: Instance.DefaultOrg.Id, }, }, }, @@ -1185,7 +1181,7 @@ func TestServer_UpdateHumanUser(t *testing.T) { func TestServer_UpdateHumanUser_Permission(t *testing.T) { newOrgOwnerEmail := fmt.Sprintf("%d@permission.update.com", time.Now().UnixNano()) - newOrg := Tester.CreateOrganization(IamCTX, fmt.Sprintf("UpdateHuman%d", time.Now().UnixNano()), newOrgOwnerEmail) + newOrg := Instance.CreateOrganization(IamCTX, fmt.Sprintf("UpdateHuman%d", time.Now().UnixNano()), newOrgOwnerEmail) newUserID := newOrg.CreatedAdmins[0].GetUserId() type args struct { ctx context.Context @@ -1295,7 +1291,7 @@ func TestServer_LockUser(t *testing.T) { CTX, &user.LockUserRequest{}, func(request *user.LockUserRequest) error { - resp := Tester.CreateHumanUser(CTX) + resp := Instance.CreateHumanUser(CTX) request.UserId = resp.GetUserId() return nil }, @@ -1303,7 +1299,7 @@ func TestServer_LockUser(t *testing.T) { want: &user.LockUserResponse{ Details: &object.Details{ ChangeDate: timestamppb.Now(), - ResourceOwner: Tester.Organisation.ID, + ResourceOwner: Instance.DefaultOrg.Id, }, }, }, @@ -1313,7 +1309,7 @@ func TestServer_LockUser(t *testing.T) { CTX, &user.LockUserRequest{}, func(request *user.LockUserRequest) error { - resp := Tester.CreateMachineUser(CTX) + resp := Instance.CreateMachineUser(CTX) request.UserId = resp.GetUserId() return nil }, @@ -1321,7 +1317,7 @@ func TestServer_LockUser(t *testing.T) { want: &user.LockUserResponse{ Details: &object.Details{ ChangeDate: timestamppb.Now(), - ResourceOwner: Tester.Organisation.ID, + ResourceOwner: Instance.DefaultOrg.Id, }, }, }, @@ -1331,7 +1327,7 @@ func TestServer_LockUser(t *testing.T) { CTX, &user.LockUserRequest{}, func(request *user.LockUserRequest) error { - resp := Tester.CreateHumanUser(CTX) + resp := Instance.CreateHumanUser(CTX) request.UserId = resp.GetUserId() _, err := Client.LockUser(CTX, &user.LockUserRequest{ UserId: resp.GetUserId(), @@ -1347,7 +1343,7 @@ func TestServer_LockUser(t *testing.T) { CTX, &user.LockUserRequest{}, func(request *user.LockUserRequest) error { - resp := Tester.CreateMachineUser(CTX) + resp := Instance.CreateMachineUser(CTX) request.UserId = resp.GetUserId() _, err := Client.LockUser(CTX, &user.LockUserRequest{ UserId: resp.GetUserId(), @@ -1403,7 +1399,7 @@ func TestServer_UnLockUser(t *testing.T) { ctx: CTX, req: &user.UnlockUserRequest{}, prepare: func(request *user.UnlockUserRequest) error { - resp := Tester.CreateHumanUser(CTX) + resp := Instance.CreateHumanUser(CTX) request.UserId = resp.GetUserId() return nil }, @@ -1416,7 +1412,7 @@ func TestServer_UnLockUser(t *testing.T) { ctx: CTX, req: &user.UnlockUserRequest{}, prepare: func(request *user.UnlockUserRequest) error { - resp := Tester.CreateMachineUser(CTX) + resp := Instance.CreateMachineUser(CTX) request.UserId = resp.GetUserId() return nil }, @@ -1429,7 +1425,7 @@ func TestServer_UnLockUser(t *testing.T) { ctx: CTX, req: &user.UnlockUserRequest{}, prepare: func(request *user.UnlockUserRequest) error { - resp := Tester.CreateHumanUser(CTX) + resp := Instance.CreateHumanUser(CTX) request.UserId = resp.GetUserId() _, err := Client.LockUser(CTX, &user.LockUserRequest{ UserId: resp.GetUserId(), @@ -1440,7 +1436,7 @@ func TestServer_UnLockUser(t *testing.T) { want: &user.UnlockUserResponse{ Details: &object.Details{ ChangeDate: timestamppb.Now(), - ResourceOwner: Tester.Organisation.ID, + ResourceOwner: Instance.DefaultOrg.Id, }, }, }, @@ -1450,7 +1446,7 @@ func TestServer_UnLockUser(t *testing.T) { ctx: CTX, req: &user.UnlockUserRequest{}, prepare: func(request *user.UnlockUserRequest) error { - resp := Tester.CreateMachineUser(CTX) + resp := Instance.CreateMachineUser(CTX) request.UserId = resp.GetUserId() _, err := Client.LockUser(CTX, &user.LockUserRequest{ UserId: resp.GetUserId(), @@ -1461,7 +1457,7 @@ func TestServer_UnLockUser(t *testing.T) { want: &user.UnlockUserResponse{ Details: &object.Details{ ChangeDate: timestamppb.Now(), - ResourceOwner: Tester.Organisation.ID, + ResourceOwner: Instance.DefaultOrg.Id, }, }, }, @@ -1511,7 +1507,7 @@ func TestServer_DeactivateUser(t *testing.T) { CTX, &user.DeactivateUserRequest{}, func(request *user.DeactivateUserRequest) error { - resp := Tester.CreateHumanUser(CTX) + resp := Instance.CreateHumanUser(CTX) request.UserId = resp.GetUserId() return nil }, @@ -1519,7 +1515,7 @@ func TestServer_DeactivateUser(t *testing.T) { want: &user.DeactivateUserResponse{ Details: &object.Details{ ChangeDate: timestamppb.Now(), - ResourceOwner: Tester.Organisation.ID, + ResourceOwner: Instance.DefaultOrg.Id, }, }, }, @@ -1529,7 +1525,7 @@ func TestServer_DeactivateUser(t *testing.T) { CTX, &user.DeactivateUserRequest{}, func(request *user.DeactivateUserRequest) error { - resp := Tester.CreateMachineUser(CTX) + resp := Instance.CreateMachineUser(CTX) request.UserId = resp.GetUserId() return nil }, @@ -1537,7 +1533,7 @@ func TestServer_DeactivateUser(t *testing.T) { want: &user.DeactivateUserResponse{ Details: &object.Details{ ChangeDate: timestamppb.Now(), - ResourceOwner: Tester.Organisation.ID, + ResourceOwner: Instance.DefaultOrg.Id, }, }, }, @@ -1547,7 +1543,7 @@ func TestServer_DeactivateUser(t *testing.T) { CTX, &user.DeactivateUserRequest{}, func(request *user.DeactivateUserRequest) error { - resp := Tester.CreateHumanUser(CTX) + resp := Instance.CreateHumanUser(CTX) request.UserId = resp.GetUserId() _, err := Client.DeactivateUser(CTX, &user.DeactivateUserRequest{ UserId: resp.GetUserId(), @@ -1563,7 +1559,7 @@ func TestServer_DeactivateUser(t *testing.T) { CTX, &user.DeactivateUserRequest{}, func(request *user.DeactivateUserRequest) error { - resp := Tester.CreateMachineUser(CTX) + resp := Instance.CreateMachineUser(CTX) request.UserId = resp.GetUserId() _, err := Client.DeactivateUser(CTX, &user.DeactivateUserRequest{ UserId: resp.GetUserId(), @@ -1619,7 +1615,7 @@ func TestServer_ReactivateUser(t *testing.T) { ctx: CTX, req: &user.ReactivateUserRequest{}, prepare: func(request *user.ReactivateUserRequest) error { - resp := Tester.CreateHumanUser(CTX) + resp := Instance.CreateHumanUser(CTX) request.UserId = resp.GetUserId() return nil }, @@ -1632,7 +1628,7 @@ func TestServer_ReactivateUser(t *testing.T) { ctx: CTX, req: &user.ReactivateUserRequest{}, prepare: func(request *user.ReactivateUserRequest) error { - resp := Tester.CreateMachineUser(CTX) + resp := Instance.CreateMachineUser(CTX) request.UserId = resp.GetUserId() return nil }, @@ -1645,7 +1641,7 @@ func TestServer_ReactivateUser(t *testing.T) { ctx: CTX, req: &user.ReactivateUserRequest{}, prepare: func(request *user.ReactivateUserRequest) error { - resp := Tester.CreateHumanUser(CTX) + resp := Instance.CreateHumanUser(CTX) request.UserId = resp.GetUserId() _, err := Client.DeactivateUser(CTX, &user.DeactivateUserRequest{ UserId: resp.GetUserId(), @@ -1656,7 +1652,7 @@ func TestServer_ReactivateUser(t *testing.T) { want: &user.ReactivateUserResponse{ Details: &object.Details{ ChangeDate: timestamppb.Now(), - ResourceOwner: Tester.Organisation.ID, + ResourceOwner: Instance.DefaultOrg.Id, }, }, }, @@ -1666,7 +1662,7 @@ func TestServer_ReactivateUser(t *testing.T) { ctx: CTX, req: &user.ReactivateUserRequest{}, prepare: func(request *user.ReactivateUserRequest) error { - resp := Tester.CreateMachineUser(CTX) + resp := Instance.CreateMachineUser(CTX) request.UserId = resp.GetUserId() _, err := Client.DeactivateUser(CTX, &user.DeactivateUserRequest{ UserId: resp.GetUserId(), @@ -1677,7 +1673,7 @@ func TestServer_ReactivateUser(t *testing.T) { want: &user.ReactivateUserResponse{ Details: &object.Details{ ChangeDate: timestamppb.Now(), - ResourceOwner: Tester.Organisation.ID, + ResourceOwner: Instance.DefaultOrg.Id, }, }, }, @@ -1699,7 +1695,7 @@ func TestServer_ReactivateUser(t *testing.T) { } func TestServer_DeleteUser(t *testing.T) { - projectResp, err := Tester.CreateProject(CTX) + projectResp, err := Instance.CreateProject(CTX) require.NoError(t, err) type args struct { ctx context.Context @@ -1729,7 +1725,7 @@ func TestServer_DeleteUser(t *testing.T) { ctx: CTX, req: &user.DeleteUserRequest{}, prepare: func(request *user.DeleteUserRequest) error { - resp := Tester.CreateHumanUser(CTX) + resp := Instance.CreateHumanUser(CTX) request.UserId = resp.GetUserId() return err }, @@ -1737,7 +1733,7 @@ func TestServer_DeleteUser(t *testing.T) { want: &user.DeleteUserResponse{ Details: &object.Details{ ChangeDate: timestamppb.Now(), - ResourceOwner: Tester.Organisation.ID, + ResourceOwner: Instance.DefaultOrg.Id, }, }, }, @@ -1747,7 +1743,7 @@ func TestServer_DeleteUser(t *testing.T) { ctx: CTX, req: &user.DeleteUserRequest{}, prepare: func(request *user.DeleteUserRequest) error { - resp := Tester.CreateMachineUser(CTX) + resp := Instance.CreateMachineUser(CTX) request.UserId = resp.GetUserId() return err }, @@ -1755,7 +1751,7 @@ func TestServer_DeleteUser(t *testing.T) { want: &user.DeleteUserResponse{ Details: &object.Details{ ChangeDate: timestamppb.Now(), - ResourceOwner: Tester.Organisation.ID, + ResourceOwner: Instance.DefaultOrg.Id, }, }, }, @@ -1765,18 +1761,18 @@ func TestServer_DeleteUser(t *testing.T) { ctx: CTX, req: &user.DeleteUserRequest{}, prepare: func(request *user.DeleteUserRequest) error { - resp := Tester.CreateHumanUser(CTX) + resp := Instance.CreateHumanUser(CTX) request.UserId = resp.GetUserId() - Tester.CreateProjectUserGrant(t, CTX, projectResp.GetId(), request.UserId) - Tester.CreateProjectMembership(t, CTX, projectResp.GetId(), request.UserId) - Tester.CreateOrgMembership(t, CTX, request.UserId) + Instance.CreateProjectUserGrant(t, CTX, projectResp.GetId(), request.UserId) + Instance.CreateProjectMembership(t, CTX, projectResp.GetId(), request.UserId) + Instance.CreateOrgMembership(t, CTX, request.UserId) return err }, }, want: &user.DeleteUserResponse{ Details: &object.Details{ ChangeDate: timestamppb.Now(), - ResourceOwner: Tester.Organisation.ID, + ResourceOwner: Instance.DefaultOrg.Id, }, }, }, @@ -1798,7 +1794,7 @@ func TestServer_DeleteUser(t *testing.T) { } func TestServer_AddIDPLink(t *testing.T) { - idpID := Tester.AddGenericOAuthProvider(t, CTX) + idpResp := Instance.AddGenericOAuthProvider(IamCTX, Instance.DefaultOrg.Id) type args struct { ctx context.Context req *user.AddIDPLinkRequest @@ -1816,7 +1812,7 @@ func TestServer_AddIDPLink(t *testing.T) { &user.AddIDPLinkRequest{ UserId: "userID", IdpLink: &user.IDPLink{ - IdpId: idpID, + IdpId: idpResp.Id, UserId: "userID", UserName: "username", }, @@ -1830,7 +1826,7 @@ func TestServer_AddIDPLink(t *testing.T) { args: args{ CTX, &user.AddIDPLinkRequest{ - UserId: Tester.Users[integration.FirstInstanceUsersKey][integration.OrgOwner].ID, + UserId: Instance.Users.Get(integration.UserTypeOrgOwner).ID, IdpLink: &user.IDPLink{ IdpId: "idpID", UserId: "userID", @@ -1846,9 +1842,9 @@ func TestServer_AddIDPLink(t *testing.T) { args: args{ CTX, &user.AddIDPLinkRequest{ - UserId: Tester.Users[integration.FirstInstanceUsersKey][integration.OrgOwner].ID, + UserId: Instance.Users.Get(integration.UserTypeOrgOwner).ID, IdpLink: &user.IDPLink{ - IdpId: idpID, + IdpId: idpResp.Id, UserId: "userID", UserName: "username", }, @@ -1857,7 +1853,7 @@ func TestServer_AddIDPLink(t *testing.T) { want: &user.AddIDPLinkResponse{ Details: &object.Details{ ChangeDate: timestamppb.Now(), - ResourceOwner: Tester.Organisation.ID, + ResourceOwner: Instance.DefaultOrg.Id, }, }, wantErr: false, @@ -1878,13 +1874,13 @@ func TestServer_AddIDPLink(t *testing.T) { } func TestServer_StartIdentityProviderIntent(t *testing.T) { - idpID := Tester.AddGenericOAuthProvider(t, CTX) - orgIdpID := Tester.AddOrgGenericOAuthProvider(t, CTX, Tester.Organisation.ID) - orgResp := Tester.CreateOrganization(IamCTX, fmt.Sprintf("NotDefaultOrg%d", time.Now().UnixNano()), fmt.Sprintf("%d@mouse.com", time.Now().UnixNano())) - notDefaultOrgIdpID := Tester.AddOrgGenericOAuthProvider(t, CTX, orgResp.OrganizationId) - samlIdpID := Tester.AddSAMLProvider(t, CTX) - samlRedirectIdpID := Tester.AddSAMLRedirectProvider(t, CTX, "") - samlPostIdpID := Tester.AddSAMLPostProvider(t, CTX) + idpResp := Instance.AddGenericOAuthProvider(IamCTX, Instance.DefaultOrg.Id) + orgIdpID := Instance.AddOrgGenericOAuthProvider(CTX, Instance.DefaultOrg.Id) + orgResp := Instance.CreateOrganization(IamCTX, fmt.Sprintf("NotDefaultOrg%d", time.Now().UnixNano()), fmt.Sprintf("%d@mouse.com", time.Now().UnixNano())) + notDefaultOrgIdpID := Instance.AddOrgGenericOAuthProvider(IamCTX, orgResp.OrganizationId) + samlIdpID := Instance.AddSAMLProvider(IamCTX) + samlRedirectIdpID := Instance.AddSAMLRedirectProvider(IamCTX, "") + samlPostIdpID := Instance.AddSAMLPostProvider(IamCTX) type args struct { ctx context.Context req *user.StartIdentityProviderIntentRequest @@ -1907,7 +1903,7 @@ func TestServer_StartIdentityProviderIntent(t *testing.T) { args: args{ CTX, &user.StartIdentityProviderIntentRequest{ - IdpId: idpID, + IdpId: idpResp.Id, }, }, wantErr: true, @@ -1917,7 +1913,7 @@ func TestServer_StartIdentityProviderIntent(t *testing.T) { args: args{ CTX, &user.StartIdentityProviderIntentRequest{ - IdpId: idpID, + IdpId: idpResp.Id, Content: &user.StartIdentityProviderIntentRequest_Urls{ Urls: &user.RedirectURLs{ SuccessUrl: "https://example.com/success", @@ -1929,13 +1925,13 @@ func TestServer_StartIdentityProviderIntent(t *testing.T) { want: want{ details: &object.Details{ ChangeDate: timestamppb.Now(), - ResourceOwner: Tester.Instance.InstanceID(), + ResourceOwner: Instance.ID(), }, url: "https://example.com/oauth/v2/authorize", parametersEqual: map[string]string{ "client_id": "clientID", "prompt": "select_account", - "redirect_uri": "http://" + Tester.Config.ExternalDomain + ":8080/idps/callback", + "redirect_uri": "http://" + Instance.Domain + ":8080/idps/callback", "response_type": "code", "scope": "openid profile email", }, @@ -1948,7 +1944,7 @@ func TestServer_StartIdentityProviderIntent(t *testing.T) { args: args{ CTX, &user.StartIdentityProviderIntentRequest{ - IdpId: orgIdpID, + IdpId: orgIdpID.Id, Content: &user.StartIdentityProviderIntentRequest_Urls{ Urls: &user.RedirectURLs{ SuccessUrl: "https://example.com/success", @@ -1960,13 +1956,13 @@ func TestServer_StartIdentityProviderIntent(t *testing.T) { want: want{ details: &object.Details{ ChangeDate: timestamppb.Now(), - ResourceOwner: Tester.Instance.InstanceID(), + ResourceOwner: Instance.ID(), }, url: "https://example.com/oauth/v2/authorize", parametersEqual: map[string]string{ "client_id": "clientID", "prompt": "select_account", - "redirect_uri": "http://" + Tester.Config.ExternalDomain + ":8080/idps/callback", + "redirect_uri": "http://" + Instance.Domain + ":8080/idps/callback", "response_type": "code", "scope": "openid profile email", }, @@ -1979,7 +1975,7 @@ func TestServer_StartIdentityProviderIntent(t *testing.T) { args: args{ CTX, &user.StartIdentityProviderIntentRequest{ - IdpId: notDefaultOrgIdpID, + IdpId: notDefaultOrgIdpID.Id, Content: &user.StartIdentityProviderIntentRequest_Urls{ Urls: &user.RedirectURLs{ SuccessUrl: "https://example.com/success", @@ -1991,13 +1987,13 @@ func TestServer_StartIdentityProviderIntent(t *testing.T) { want: want{ details: &object.Details{ ChangeDate: timestamppb.Now(), - ResourceOwner: Tester.Instance.InstanceID(), + ResourceOwner: Instance.ID(), }, url: "https://example.com/oauth/v2/authorize", parametersEqual: map[string]string{ "client_id": "clientID", "prompt": "select_account", - "redirect_uri": "http://" + Tester.Config.ExternalDomain + ":8080/idps/callback", + "redirect_uri": "http://" + Instance.Domain + ":8080/idps/callback", "response_type": "code", "scope": "openid profile email", }, @@ -2010,7 +2006,7 @@ func TestServer_StartIdentityProviderIntent(t *testing.T) { args: args{ CTX, &user.StartIdentityProviderIntentRequest{ - IdpId: orgIdpID, + IdpId: orgIdpID.Id, Content: &user.StartIdentityProviderIntentRequest_Urls{ Urls: &user.RedirectURLs{ SuccessUrl: "https://example.com/success", @@ -2022,13 +2018,13 @@ func TestServer_StartIdentityProviderIntent(t *testing.T) { want: want{ details: &object.Details{ ChangeDate: timestamppb.Now(), - ResourceOwner: Tester.Instance.InstanceID(), + ResourceOwner: Instance.ID(), }, url: "https://example.com/oauth/v2/authorize", parametersEqual: map[string]string{ "client_id": "clientID", "prompt": "select_account", - "redirect_uri": "http://" + Tester.Config.ExternalDomain + ":8080/idps/callback", + "redirect_uri": "http://" + Instance.Domain + ":8080/idps/callback", "response_type": "code", "scope": "openid profile email", }, @@ -2053,9 +2049,9 @@ func TestServer_StartIdentityProviderIntent(t *testing.T) { want: want{ details: &object.Details{ ChangeDate: timestamppb.Now(), - ResourceOwner: Tester.Instance.InstanceID(), + ResourceOwner: Instance.ID(), }, - url: "http://" + Tester.Config.ExternalDomain + ":8000/sso", + url: "http://" + Instance.Domain + ":8000/sso", parametersExisting: []string{"RelayState", "SAMLRequest"}, }, wantErr: false, @@ -2077,9 +2073,9 @@ func TestServer_StartIdentityProviderIntent(t *testing.T) { want: want{ details: &object.Details{ ChangeDate: timestamppb.Now(), - ResourceOwner: Tester.Instance.InstanceID(), + ResourceOwner: Instance.ID(), }, - url: "http://" + Tester.Config.ExternalDomain + ":8000/sso", + url: "http://" + Instance.Domain + ":8000/sso", parametersExisting: []string{"RelayState", "SAMLRequest"}, }, wantErr: false, @@ -2101,7 +2097,7 @@ func TestServer_StartIdentityProviderIntent(t *testing.T) { want: want{ details: &object.Details{ ChangeDate: timestamppb.Now(), - ResourceOwner: Tester.Instance.InstanceID(), + ResourceOwner: Instance.ID(), }, postForm: true, }, @@ -2140,14 +2136,15 @@ func TestServer_StartIdentityProviderIntent(t *testing.T) { } } +/* func TestServer_RetrieveIdentityProviderIntent(t *testing.T) { - idpID := Tester.AddGenericOAuthProvider(t, CTX) - intentID := Tester.CreateIntent(t, CTX, idpID) - successfulID, token, changeDate, sequence := Tester.CreateSuccessfulOAuthIntent(t, CTX, idpID, "", "id") - successfulWithUserID, withUsertoken, withUserchangeDate, withUsersequence := Tester.CreateSuccessfulOAuthIntent(t, CTX, idpID, "user", "id") - ldapSuccessfulID, ldapToken, ldapChangeDate, ldapSequence := Tester.CreateSuccessfulLDAPIntent(t, CTX, idpID, "", "id") - ldapSuccessfulWithUserID, ldapWithUserToken, ldapWithUserChangeDate, ldapWithUserSequence := Tester.CreateSuccessfulLDAPIntent(t, CTX, idpID, "user", "id") - samlSuccessfulID, samlToken, samlChangeDate, samlSequence := Tester.CreateSuccessfulSAMLIntent(t, CTX, idpID, "", "id") + idpID := Instance.AddGenericOAuthProvider(t, CTX) + intentID := Instance.CreateIntent(t, CTX, idpID) + successfulID, token, changeDate, sequence := Instance.CreateSuccessfulOAuthIntent(t, CTX, idpID.Id, "", "id") + successfulWithUserID, withUsertoken, withUserchangeDate, withUsersequence := Instance.CreateSuccessfulOAuthIntent(t, CTX, idpID.Id, "user", "id") + ldapSuccessfulID, ldapToken, ldapChangeDate, ldapSequence := Instance.CreateSuccessfulLDAPIntent(t, CTX, idpID.Id, "", "id") + ldapSuccessfulWithUserID, ldapWithUserToken, ldapWithUserChangeDate, ldapWithUserSequence := Instance.CreateSuccessfulLDAPIntent(t, CTX, idpID.Id, "user", "id") + samlSuccessfulID, samlToken, samlChangeDate, samlSequence := Instance.CreateSuccessfulSAMLIntent(t, CTX, idpID.Id, "", "id") type args struct { ctx context.Context req *user.RetrieveIdentityProviderIntentRequest @@ -2192,7 +2189,7 @@ func TestServer_RetrieveIdentityProviderIntent(t *testing.T) { want: &user.RetrieveIdentityProviderIntentResponse{ Details: &object.Details{ ChangeDate: timestamppb.New(changeDate), - ResourceOwner: Tester.Instance.InstanceID(), + ResourceOwner: Instance.ID(), Sequence: sequence, }, IdpInformation: &user.IDPInformation{ @@ -2202,7 +2199,7 @@ func TestServer_RetrieveIdentityProviderIntent(t *testing.T) { IdToken: gu.Ptr("idToken"), }, }, - IdpId: idpID, + IdpId: idpID.Id, UserId: "id", UserName: "username", RawInformation: func() *structpb.Struct { @@ -2229,7 +2226,7 @@ func TestServer_RetrieveIdentityProviderIntent(t *testing.T) { want: &user.RetrieveIdentityProviderIntentResponse{ Details: &object.Details{ ChangeDate: timestamppb.New(withUserchangeDate), - ResourceOwner: Tester.Instance.InstanceID(), + ResourceOwner: Instance.ID(), Sequence: withUsersequence, }, UserId: "user", @@ -2240,7 +2237,7 @@ func TestServer_RetrieveIdentityProviderIntent(t *testing.T) { IdToken: gu.Ptr("idToken"), }, }, - IdpId: idpID, + IdpId: idpID.Id, UserId: "id", UserName: "username", RawInformation: func() *structpb.Struct { @@ -2267,7 +2264,7 @@ func TestServer_RetrieveIdentityProviderIntent(t *testing.T) { want: &user.RetrieveIdentityProviderIntentResponse{ Details: &object.Details{ ChangeDate: timestamppb.New(ldapChangeDate), - ResourceOwner: Tester.Instance.InstanceID(), + ResourceOwner: Instance.ID(), Sequence: ldapSequence, }, IdpInformation: &user.IDPInformation{ @@ -2284,7 +2281,7 @@ func TestServer_RetrieveIdentityProviderIntent(t *testing.T) { }(), }, }, - IdpId: idpID, + IdpId: idpID.Id, UserId: "id", UserName: "username", RawInformation: func() *structpb.Struct { @@ -2312,7 +2309,7 @@ func TestServer_RetrieveIdentityProviderIntent(t *testing.T) { want: &user.RetrieveIdentityProviderIntentResponse{ Details: &object.Details{ ChangeDate: timestamppb.New(ldapWithUserChangeDate), - ResourceOwner: Tester.Instance.InstanceID(), + ResourceOwner: Instance.ID(), Sequence: ldapWithUserSequence, }, UserId: "user", @@ -2330,7 +2327,7 @@ func TestServer_RetrieveIdentityProviderIntent(t *testing.T) { }(), }, }, - IdpId: idpID, + IdpId: idpID.Id, UserId: "id", UserName: "username", RawInformation: func() *structpb.Struct { @@ -2358,7 +2355,7 @@ func TestServer_RetrieveIdentityProviderIntent(t *testing.T) { want: &user.RetrieveIdentityProviderIntentResponse{ Details: &object.Details{ ChangeDate: timestamppb.New(samlChangeDate), - ResourceOwner: Tester.Instance.InstanceID(), + ResourceOwner: Instance.ID(), Sequence: samlSequence, }, IdpInformation: &user.IDPInformation{ @@ -2367,7 +2364,7 @@ func TestServer_RetrieveIdentityProviderIntent(t *testing.T) { Assertion: []byte(""), }, }, - IdpId: idpID, + IdpId: idpID.Id, UserId: "id", UserName: "", RawInformation: func() *structpb.Struct { @@ -2398,28 +2395,29 @@ func TestServer_RetrieveIdentityProviderIntent(t *testing.T) { }) } } +*/ func TestServer_ListAuthenticationMethodTypes(t *testing.T) { - userIDWithoutAuth := Tester.CreateHumanUser(CTX).GetUserId() + userIDWithoutAuth := Instance.CreateHumanUser(CTX).GetUserId() - userIDWithPasskey := Tester.CreateHumanUser(CTX).GetUserId() - Tester.RegisterUserPasskey(CTX, userIDWithPasskey) + userIDWithPasskey := Instance.CreateHumanUser(CTX).GetUserId() + Instance.RegisterUserPasskey(CTX, userIDWithPasskey) - userMultipleAuth := Tester.CreateHumanUser(CTX).GetUserId() - Tester.RegisterUserPasskey(CTX, userMultipleAuth) - provider, err := Tester.Client.Mgmt.AddGenericOIDCProvider(CTX, &mgmt.AddGenericOIDCProviderRequest{ + userMultipleAuth := Instance.CreateHumanUser(CTX).GetUserId() + Instance.RegisterUserPasskey(CTX, userMultipleAuth) + provider, err := Instance.Client.Mgmt.AddGenericOIDCProvider(CTX, &mgmt.AddGenericOIDCProviderRequest{ Name: "ListAuthenticationMethodTypes", Issuer: "https://example.com", ClientId: "client_id", ClientSecret: "client_secret", }) require.NoError(t, err) - _, err = Tester.Client.Mgmt.AddCustomLoginPolicy(CTX, &mgmt.AddCustomLoginPolicyRequest{}) + _, err = Instance.Client.Mgmt.AddCustomLoginPolicy(CTX, &mgmt.AddCustomLoginPolicyRequest{}) require.Condition(t, func() bool { code := status.Convert(err).Code() return code == codes.AlreadyExists || code == codes.OK }) - _, err = Tester.Client.Mgmt.AddIDPToLoginPolicy(CTX, &mgmt.AddIDPToLoginPolicyRequest{ + _, err = Instance.Client.Mgmt.AddIDPToLoginPolicy(CTX, &mgmt.AddIDPToLoginPolicyRequest{ IdpId: provider.GetId(), OwnerType: idp.IDPOwnerType_IDP_OWNER_TYPE_ORG, }) @@ -2431,7 +2429,7 @@ func TestServer_ListAuthenticationMethodTypes(t *testing.T) { }}) require.NoError(t, err) // This should not remove the user IDP links - _, err = Tester.Client.Mgmt.RemoveIDPFromLoginPolicy(CTX, &mgmt.RemoveIDPFromLoginPolicyRequest{ + _, err = Instance.Client.Mgmt.RemoveIDPFromLoginPolicy(CTX, &mgmt.RemoveIDPFromLoginPolicyRequest{ IdpId: provider.GetId(), }) require.NoError(t, err) diff --git a/internal/api/idp/idp_integration_test.go b/internal/api/idp/integration_test/idp_test.go similarity index 90% rename from internal/api/idp/idp_integration_test.go rename to internal/api/idp/integration_test/idp_test.go index 51d9bbfeea..609b98262b 100644 --- a/internal/api/idp/idp_integration_test.go +++ b/internal/api/idp/integration_test/idp_test.go @@ -31,29 +31,27 @@ import ( ) var ( - CTX context.Context - ErrCTX context.Context - Tester *integration.Tester - Client user.UserServiceClient + CTX context.Context + Instance *integration.Instance + Client user.UserServiceClient ) func TestMain(m *testing.M) { os.Exit(func() int { - ctx, errCtx, cancel := integration.Contexts(time.Hour) + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Minute) defer cancel() - Tester = integration.NewTester(ctx) - defer Tester.Done() + Instance = integration.NewInstance(ctx) - CTX, ErrCTX = Tester.WithAuthorization(ctx, integration.OrgOwner), errCtx - Client = Tester.Client.UserV2 + CTX = Instance.WithAuthorization(ctx, integration.UserTypeIAMOwner) + Client = Instance.Client.UserV2 return m.Run() }()) } func TestServer_SAMLCertificate(t *testing.T) { - samlRedirectIdpID := Tester.AddSAMLRedirectProvider(t, CTX, "") - oauthIdpID := Tester.AddGenericOAuthProvider(t, CTX) + samlRedirectIdpID := Instance.AddSAMLRedirectProvider(CTX, "") + oauthIdpResp := Instance.AddGenericOAuthProvider(CTX, Instance.DefaultOrg.Id) type args struct { ctx context.Context @@ -76,7 +74,7 @@ func TestServer_SAMLCertificate(t *testing.T) { name: "saml certificate, invalid idp type", args: args{ ctx: CTX, - idpID: oauthIdpID, + idpID: oauthIdpResp.Id, }, want: http.StatusBadRequest, }, @@ -91,7 +89,7 @@ func TestServer_SAMLCertificate(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - certificateURL := http_util.BuildOrigin(Tester.Host(), Tester.Server.Config.ExternalSecure) + "/idps/" + tt.args.idpID + "/saml/certificate" + certificateURL := http_util.BuildOrigin(Instance.Host(), Instance.Config.Secure) + "/idps/" + tt.args.idpID + "/saml/certificate" resp, err := http.Get(certificateURL) assert.NoError(t, err) assert.Equal(t, tt.want, resp.StatusCode) @@ -109,8 +107,8 @@ func TestServer_SAMLCertificate(t *testing.T) { } func TestServer_SAMLMetadata(t *testing.T) { - samlRedirectIdpID := Tester.AddSAMLRedirectProvider(t, CTX, "") - oauthIdpID := Tester.AddGenericOAuthProvider(t, CTX) + samlRedirectIdpID := Instance.AddSAMLRedirectProvider(CTX, "") + oauthIdpResp := Instance.AddGenericOAuthProvider(CTX, Instance.DefaultOrg.Id) type args struct { ctx context.Context @@ -133,7 +131,7 @@ func TestServer_SAMLMetadata(t *testing.T) { name: "saml metadata, invalid idp type", args: args{ ctx: CTX, - idpID: oauthIdpID, + idpID: oauthIdpResp.Id, }, want: http.StatusBadRequest, }, @@ -148,7 +146,7 @@ func TestServer_SAMLMetadata(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - metadataURL := http_util.BuildOrigin(Tester.Host(), Tester.Server.Config.ExternalSecure) + "/idps/" + tt.args.idpID + "/saml/metadata" + metadataURL := http_util.BuildOrigin(Instance.Host(), Instance.Config.Secure) + "/idps/" + tt.args.idpID + "/saml/metadata" resp, err := http.Get(metadataURL) assert.NoError(t, err) assert.Equal(t, tt.want, resp.StatusCode) @@ -166,13 +164,13 @@ func TestServer_SAMLMetadata(t *testing.T) { } func TestServer_SAMLACS(t *testing.T) { - userHuman := Tester.CreateHumanUser(CTX) - samlRedirectIdpID := Tester.AddSAMLRedirectProvider(t, CTX, "urn:oid:0.9.2342.19200300.100.1.1") // the username is set in urn:oid:0.9.2342.19200300.100.1.1 + userHuman := Instance.CreateHumanUser(CTX) + samlRedirectIdpID := Instance.AddSAMLRedirectProvider(CTX, "urn:oid:0.9.2342.19200300.100.1.1") // the username is set in urn:oid:0.9.2342.19200300.100.1.1 externalUserID := "test1" linkedExternalUserID := "test2" - Tester.CreateUserIDPlink(CTX, userHuman.UserId, linkedExternalUserID, samlRedirectIdpID, linkedExternalUserID) + Instance.CreateUserIDPlink(CTX, userHuman.UserId, linkedExternalUserID, samlRedirectIdpID, linkedExternalUserID) idp, err := getIDP( - http_util.BuildOrigin(Tester.Host(), Tester.Server.Config.ExternalSecure), + http_util.BuildOrigin(Instance.Host(), Instance.Config.Secure), []string{samlRedirectIdpID}, externalUserID, linkedExternalUserID, @@ -328,7 +326,7 @@ func TestServer_SAMLACS(t *testing.T) { if tt.args.intentID != "" { relayState = tt.args.intentID } - callbackURL := http_util.BuildOrigin(Tester.Host(), Tester.Server.Config.ExternalSecure) + "/idps/" + tt.args.idpID + "/saml/acs" + callbackURL := http_util.BuildOrigin(Instance.Host(), Instance.Config.Secure) + "/idps/" + tt.args.idpID + "/saml/acs" response := createResponse(t, idp, samlRequest, tt.args.nameID, tt.args.nameIDFormat, tt.args.username) //test purposes, use defined response if tt.args.response != "" { diff --git a/internal/api/oidc/auth_request_integration_test.go b/internal/api/oidc/integration_test/auth_request_test.go similarity index 68% rename from internal/api/oidc/auth_request_integration_test.go rename to internal/api/oidc/integration_test/auth_request_test.go index faa5126676..bd9142a3f6 100644 --- a/internal/api/oidc/auth_request_integration_test.go +++ b/internal/api/oidc/integration_test/auth_request_test.go @@ -28,17 +28,21 @@ var ( ) func TestOPStorage_CreateAuthRequest(t *testing.T) { - clientID, _ := createClient(t) + t.Parallel() - id := createAuthRequest(t, clientID, redirectURI) + clientID, _ := createClient(t, Instance) + + id := createAuthRequest(t, Instance, clientID, redirectURI) require.Contains(t, id, command.IDPrefixV2) } func TestOPStorage_CreateAccessToken_code(t *testing.T) { - clientID, _ := createClient(t) - authRequestID := createAuthRequest(t, clientID, redirectURI) - sessionID, sessionToken, startTime, changeTime := Tester.CreateVerifiedWebAuthNSession(t, CTXLOGIN, User.GetUserId()) - linkResp, err := Tester.Client.OIDCv2.CreateCallback(CTXLOGIN, &oidc_pb.CreateCallbackRequest{ + t.Parallel() + + clientID, _ := createClient(t, Instance) + authRequestID := createAuthRequest(t, Instance, clientID, redirectURI) + sessionID, sessionToken, startTime, changeTime := Instance.CreateVerifiedWebAuthNSession(t, CTXLOGIN, User.GetUserId()) + linkResp, err := Instance.Client.OIDCv2.CreateCallback(CTXLOGIN, &oidc_pb.CreateCallbackRequest{ AuthRequestId: authRequestID, CallbackKind: &oidc_pb.CreateCallbackRequest_Session{ Session: &oidc_pb.Session{ @@ -51,13 +55,13 @@ func TestOPStorage_CreateAccessToken_code(t *testing.T) { // test code exchange code := assertCodeResponse(t, linkResp.GetCallbackUrl()) - tokens, err := exchangeTokens(t, clientID, code, redirectURI) + tokens, err := exchangeTokens(t, Instance, clientID, code, redirectURI) require.NoError(t, err) assertTokens(t, tokens, false) assertIDTokenClaims(t, tokens.IDTokenClaims, User.GetUserId(), armPasskey, startTime, changeTime, sessionID) // callback on a succeeded request must fail - linkResp, err = Tester.Client.OIDCv2.CreateCallback(CTXLOGIN, &oidc_pb.CreateCallbackRequest{ + linkResp, err = Instance.Client.OIDCv2.CreateCallback(CTXLOGIN, &oidc_pb.CreateCallbackRequest{ AuthRequestId: authRequestID, CallbackKind: &oidc_pb.CreateCallbackRequest_Session{ Session: &oidc_pb.Session{ @@ -69,15 +73,17 @@ func TestOPStorage_CreateAccessToken_code(t *testing.T) { require.Error(t, err) // exchange with a used code must fail - _, err = exchangeTokens(t, clientID, code, redirectURI) + _, err = exchangeTokens(t, Instance, clientID, code, redirectURI) require.Error(t, err) } func TestOPStorage_CreateAccessToken_implicit(t *testing.T) { + t.Parallel() + clientID := createImplicitClient(t) authRequestID := createAuthRequestImplicit(t, clientID, redirectURIImplicit) - sessionID, sessionToken, startTime, changeTime := Tester.CreateVerifiedWebAuthNSession(t, CTXLOGIN, User.GetUserId()) - linkResp, err := Tester.Client.OIDCv2.CreateCallback(CTXLOGIN, &oidc_pb.CreateCallbackRequest{ + sessionID, sessionToken, startTime, changeTime := Instance.CreateVerifiedWebAuthNSession(t, CTXLOGIN, User.GetUserId()) + linkResp, err := Instance.Client.OIDCv2.CreateCallback(CTXLOGIN, &oidc_pb.CreateCallbackRequest{ AuthRequestId: authRequestID, CallbackKind: &oidc_pb.CreateCallbackRequest_Session{ Session: &oidc_pb.Session{ @@ -104,14 +110,14 @@ func TestOPStorage_CreateAccessToken_implicit(t *testing.T) { assert.Equal(t, "state", values.Get("state")) // check id_token / claims - provider, err := Tester.CreateRelyingParty(CTX, clientID, redirectURIImplicit) + provider, err := Instance.CreateRelyingParty(CTX, clientID, redirectURIImplicit) require.NoError(t, err) claims, err := rp.VerifyTokens[*oidc.IDTokenClaims](context.Background(), accessToken, idToken, provider.IDTokenVerifier()) require.NoError(t, err) assertIDTokenClaims(t, claims, User.GetUserId(), armPasskey, startTime, changeTime, sessionID) // callback on a succeeded request must fail - linkResp, err = Tester.Client.OIDCv2.CreateCallback(CTXLOGIN, &oidc_pb.CreateCallbackRequest{ + linkResp, err = Instance.Client.OIDCv2.CreateCallback(CTXLOGIN, &oidc_pb.CreateCallbackRequest{ AuthRequestId: authRequestID, CallbackKind: &oidc_pb.CreateCallbackRequest_Session{ Session: &oidc_pb.Session{ @@ -124,10 +130,12 @@ func TestOPStorage_CreateAccessToken_implicit(t *testing.T) { } func TestOPStorage_CreateAccessAndRefreshTokens_code(t *testing.T) { - clientID, _ := createClient(t) - authRequestID := createAuthRequest(t, clientID, redirectURI, oidc.ScopeOpenID, oidc.ScopeOfflineAccess) - sessionID, sessionToken, startTime, changeTime := Tester.CreateVerifiedWebAuthNSession(t, CTXLOGIN, User.GetUserId()) - linkResp, err := Tester.Client.OIDCv2.CreateCallback(CTXLOGIN, &oidc_pb.CreateCallbackRequest{ + t.Parallel() + + clientID, _ := createClient(t, Instance) + authRequestID := createAuthRequest(t, Instance, clientID, redirectURI, oidc.ScopeOpenID, oidc.ScopeOfflineAccess) + sessionID, sessionToken, startTime, changeTime := Instance.CreateVerifiedWebAuthNSession(t, CTXLOGIN, User.GetUserId()) + linkResp, err := Instance.Client.OIDCv2.CreateCallback(CTXLOGIN, &oidc_pb.CreateCallbackRequest{ AuthRequestId: authRequestID, CallbackKind: &oidc_pb.CreateCallbackRequest_Session{ Session: &oidc_pb.Session{ @@ -140,19 +148,21 @@ func TestOPStorage_CreateAccessAndRefreshTokens_code(t *testing.T) { // test code exchange (expect refresh token to be returned) code := assertCodeResponse(t, linkResp.GetCallbackUrl()) - tokens, err := exchangeTokens(t, clientID, code, redirectURI) + tokens, err := exchangeTokens(t, Instance, clientID, code, redirectURI) require.NoError(t, err) assertTokens(t, tokens, true) assertIDTokenClaims(t, tokens.IDTokenClaims, User.GetUserId(), armPasskey, startTime, changeTime, sessionID) } func TestOPStorage_CreateAccessAndRefreshTokens_refresh(t *testing.T) { - clientID, _ := createClient(t) - provider, err := Tester.CreateRelyingParty(CTX, clientID, redirectURI) + t.Parallel() + + clientID, _ := createClient(t, Instance) + provider, err := Instance.CreateRelyingParty(CTX, clientID, redirectURI) require.NoError(t, err) - authRequestID := createAuthRequest(t, clientID, redirectURI, oidc.ScopeOpenID, oidc.ScopeOfflineAccess) - sessionID, sessionToken, startTime, changeTime := Tester.CreateVerifiedWebAuthNSession(t, CTXLOGIN, User.GetUserId()) - linkResp, err := Tester.Client.OIDCv2.CreateCallback(CTXLOGIN, &oidc_pb.CreateCallbackRequest{ + authRequestID := createAuthRequest(t, Instance, clientID, redirectURI, oidc.ScopeOpenID, oidc.ScopeOfflineAccess) + sessionID, sessionToken, startTime, changeTime := Instance.CreateVerifiedWebAuthNSession(t, CTXLOGIN, User.GetUserId()) + linkResp, err := Instance.Client.OIDCv2.CreateCallback(CTXLOGIN, &oidc_pb.CreateCallbackRequest{ AuthRequestId: authRequestID, CallbackKind: &oidc_pb.CreateCallbackRequest_Session{ Session: &oidc_pb.Session{ @@ -165,7 +175,7 @@ func TestOPStorage_CreateAccessAndRefreshTokens_refresh(t *testing.T) { // code exchange code := assertCodeResponse(t, linkResp.GetCallbackUrl()) - tokens, err := exchangeTokens(t, clientID, code, redirectURI) + tokens, err := exchangeTokens(t, Instance, clientID, code, redirectURI) require.NoError(t, err) assertTokens(t, tokens, true) assertIDTokenClaims(t, tokens.IDTokenClaims, User.GetUserId(), armPasskey, startTime, changeTime, sessionID) @@ -183,12 +193,14 @@ func TestOPStorage_CreateAccessAndRefreshTokens_refresh(t *testing.T) { } func TestOPStorage_RevokeToken_access_token(t *testing.T) { - clientID, _ := createClient(t) - provider, err := Tester.CreateRelyingParty(CTX, clientID, redirectURI) + t.Parallel() + + clientID, _ := createClient(t, Instance) + provider, err := Instance.CreateRelyingParty(CTX, clientID, redirectURI) require.NoError(t, err) - authRequestID := createAuthRequest(t, clientID, redirectURI, oidc.ScopeOpenID, oidc.ScopeOfflineAccess) - sessionID, sessionToken, startTime, changeTime := Tester.CreateVerifiedWebAuthNSession(t, CTXLOGIN, User.GetUserId()) - linkResp, err := Tester.Client.OIDCv2.CreateCallback(CTXLOGIN, &oidc_pb.CreateCallbackRequest{ + authRequestID := createAuthRequest(t, Instance, clientID, redirectURI, oidc.ScopeOpenID, oidc.ScopeOfflineAccess) + sessionID, sessionToken, startTime, changeTime := Instance.CreateVerifiedWebAuthNSession(t, CTXLOGIN, User.GetUserId()) + linkResp, err := Instance.Client.OIDCv2.CreateCallback(CTXLOGIN, &oidc_pb.CreateCallbackRequest{ AuthRequestId: authRequestID, CallbackKind: &oidc_pb.CreateCallbackRequest_Session{ Session: &oidc_pb.Session{ @@ -201,7 +213,7 @@ func TestOPStorage_RevokeToken_access_token(t *testing.T) { // code exchange code := assertCodeResponse(t, linkResp.GetCallbackUrl()) - tokens, err := exchangeTokens(t, clientID, code, redirectURI) + tokens, err := exchangeTokens(t, Instance, clientID, code, redirectURI) require.NoError(t, err) assertTokens(t, tokens, true) assertIDTokenClaims(t, tokens.IDTokenClaims, User.GetUserId(), armPasskey, startTime, changeTime, sessionID) @@ -226,12 +238,14 @@ func TestOPStorage_RevokeToken_access_token(t *testing.T) { } func TestOPStorage_RevokeToken_access_token_invalid_token_hint_type(t *testing.T) { - clientID, _ := createClient(t) - provider, err := Tester.CreateRelyingParty(CTX, clientID, redirectURI) + t.Parallel() + + clientID, _ := createClient(t, Instance) + provider, err := Instance.CreateRelyingParty(CTX, clientID, redirectURI) require.NoError(t, err) - authRequestID := createAuthRequest(t, clientID, redirectURI, oidc.ScopeOpenID, oidc.ScopeOfflineAccess) - sessionID, sessionToken, startTime, changeTime := Tester.CreateVerifiedWebAuthNSession(t, CTXLOGIN, User.GetUserId()) - linkResp, err := Tester.Client.OIDCv2.CreateCallback(CTXLOGIN, &oidc_pb.CreateCallbackRequest{ + authRequestID := createAuthRequest(t, Instance, clientID, redirectURI, oidc.ScopeOpenID, oidc.ScopeOfflineAccess) + sessionID, sessionToken, startTime, changeTime := Instance.CreateVerifiedWebAuthNSession(t, CTXLOGIN, User.GetUserId()) + linkResp, err := Instance.Client.OIDCv2.CreateCallback(CTXLOGIN, &oidc_pb.CreateCallbackRequest{ AuthRequestId: authRequestID, CallbackKind: &oidc_pb.CreateCallbackRequest_Session{ Session: &oidc_pb.Session{ @@ -244,7 +258,7 @@ func TestOPStorage_RevokeToken_access_token_invalid_token_hint_type(t *testing.T // code exchange code := assertCodeResponse(t, linkResp.GetCallbackUrl()) - tokens, err := exchangeTokens(t, clientID, code, redirectURI) + tokens, err := exchangeTokens(t, Instance, clientID, code, redirectURI) require.NoError(t, err) assertTokens(t, tokens, true) assertIDTokenClaims(t, tokens.IDTokenClaims, User.GetUserId(), armPasskey, startTime, changeTime, sessionID) @@ -263,12 +277,14 @@ func TestOPStorage_RevokeToken_access_token_invalid_token_hint_type(t *testing.T } func TestOPStorage_RevokeToken_refresh_token(t *testing.T) { - clientID, _ := createClient(t) - provider, err := Tester.CreateRelyingParty(CTX, clientID, redirectURI) + t.Parallel() + + clientID, _ := createClient(t, Instance) + provider, err := Instance.CreateRelyingParty(CTX, clientID, redirectURI) require.NoError(t, err) - authRequestID := createAuthRequest(t, clientID, redirectURI, oidc.ScopeOpenID, oidc.ScopeOfflineAccess) - sessionID, sessionToken, startTime, changeTime := Tester.CreateVerifiedWebAuthNSession(t, CTXLOGIN, User.GetUserId()) - linkResp, err := Tester.Client.OIDCv2.CreateCallback(CTXLOGIN, &oidc_pb.CreateCallbackRequest{ + authRequestID := createAuthRequest(t, Instance, clientID, redirectURI, oidc.ScopeOpenID, oidc.ScopeOfflineAccess) + sessionID, sessionToken, startTime, changeTime := Instance.CreateVerifiedWebAuthNSession(t, CTXLOGIN, User.GetUserId()) + linkResp, err := Instance.Client.OIDCv2.CreateCallback(CTXLOGIN, &oidc_pb.CreateCallbackRequest{ AuthRequestId: authRequestID, CallbackKind: &oidc_pb.CreateCallbackRequest_Session{ Session: &oidc_pb.Session{ @@ -281,7 +297,7 @@ func TestOPStorage_RevokeToken_refresh_token(t *testing.T) { // code exchange code := assertCodeResponse(t, linkResp.GetCallbackUrl()) - tokens, err := exchangeTokens(t, clientID, code, redirectURI) + tokens, err := exchangeTokens(t, Instance, clientID, code, redirectURI) require.NoError(t, err) assertTokens(t, tokens, true) assertIDTokenClaims(t, tokens.IDTokenClaims, User.GetUserId(), armPasskey, startTime, changeTime, sessionID) @@ -306,12 +322,14 @@ func TestOPStorage_RevokeToken_refresh_token(t *testing.T) { } func TestOPStorage_RevokeToken_refresh_token_invalid_token_type_hint(t *testing.T) { - clientID, _ := createClient(t) - provider, err := Tester.CreateRelyingParty(CTX, clientID, redirectURI) + t.Parallel() + + clientID, _ := createClient(t, Instance) + provider, err := Instance.CreateRelyingParty(CTX, clientID, redirectURI) require.NoError(t, err) - authRequestID := createAuthRequest(t, clientID, redirectURI, oidc.ScopeOpenID, oidc.ScopeOfflineAccess) - sessionID, sessionToken, startTime, changeTime := Tester.CreateVerifiedWebAuthNSession(t, CTXLOGIN, User.GetUserId()) - linkResp, err := Tester.Client.OIDCv2.CreateCallback(CTXLOGIN, &oidc_pb.CreateCallbackRequest{ + authRequestID := createAuthRequest(t, Instance, clientID, redirectURI, oidc.ScopeOpenID, oidc.ScopeOfflineAccess) + sessionID, sessionToken, startTime, changeTime := Instance.CreateVerifiedWebAuthNSession(t, CTXLOGIN, User.GetUserId()) + linkResp, err := Instance.Client.OIDCv2.CreateCallback(CTXLOGIN, &oidc_pb.CreateCallbackRequest{ AuthRequestId: authRequestID, CallbackKind: &oidc_pb.CreateCallbackRequest_Session{ Session: &oidc_pb.Session{ @@ -324,7 +342,7 @@ func TestOPStorage_RevokeToken_refresh_token_invalid_token_type_hint(t *testing. // code exchange code := assertCodeResponse(t, linkResp.GetCallbackUrl()) - tokens, err := exchangeTokens(t, clientID, code, redirectURI) + tokens, err := exchangeTokens(t, Instance, clientID, code, redirectURI) require.NoError(t, err) assertTokens(t, tokens, true) assertIDTokenClaims(t, tokens.IDTokenClaims, User.GetUserId(), armPasskey, startTime, changeTime, sessionID) @@ -343,10 +361,12 @@ func TestOPStorage_RevokeToken_refresh_token_invalid_token_type_hint(t *testing. } func TestOPStorage_RevokeToken_invalid_client(t *testing.T) { - clientID, _ := createClient(t) - authRequestID := createAuthRequest(t, clientID, redirectURI, oidc.ScopeOpenID, oidc.ScopeOfflineAccess) - sessionID, sessionToken, startTime, changeTime := Tester.CreateVerifiedWebAuthNSession(t, CTXLOGIN, User.GetUserId()) - linkResp, err := Tester.Client.OIDCv2.CreateCallback(CTXLOGIN, &oidc_pb.CreateCallbackRequest{ + t.Parallel() + + clientID, _ := createClient(t, Instance) + authRequestID := createAuthRequest(t, Instance, clientID, redirectURI, oidc.ScopeOpenID, oidc.ScopeOfflineAccess) + sessionID, sessionToken, startTime, changeTime := Instance.CreateVerifiedWebAuthNSession(t, CTXLOGIN, User.GetUserId()) + linkResp, err := Instance.Client.OIDCv2.CreateCallback(CTXLOGIN, &oidc_pb.CreateCallbackRequest{ AuthRequestId: authRequestID, CallbackKind: &oidc_pb.CreateCallbackRequest_Session{ Session: &oidc_pb.Session{ @@ -359,26 +379,28 @@ func TestOPStorage_RevokeToken_invalid_client(t *testing.T) { // code exchange code := assertCodeResponse(t, linkResp.GetCallbackUrl()) - tokens, err := exchangeTokens(t, clientID, code, redirectURI) + tokens, err := exchangeTokens(t, Instance, clientID, code, redirectURI) require.NoError(t, err) assertTokens(t, tokens, true) assertIDTokenClaims(t, tokens.IDTokenClaims, User.GetUserId(), armPasskey, startTime, changeTime, sessionID) // simulate second client (not part of the audience) trying to revoke the token - otherClientID, _ := createClient(t) - provider, err := Tester.CreateRelyingParty(CTX, otherClientID, redirectURI) + otherClientID, _ := createClient(t, Instance) + provider, err := Instance.CreateRelyingParty(CTX, otherClientID, redirectURI) require.NoError(t, err) err = rp.RevokeToken(CTX, provider, tokens.AccessToken, "") require.Error(t, err) } func TestOPStorage_TerminateSession(t *testing.T) { - clientID, _ := createClient(t) - provider, err := Tester.CreateRelyingParty(CTX, clientID, redirectURI) + t.Parallel() + + clientID, _ := createClient(t, Instance) + provider, err := Instance.CreateRelyingParty(CTX, clientID, redirectURI) require.NoError(t, err) - authRequestID := createAuthRequest(t, clientID, redirectURI) - sessionID, sessionToken, startTime, changeTime := Tester.CreateVerifiedWebAuthNSession(t, CTXLOGIN, User.GetUserId()) - linkResp, err := Tester.Client.OIDCv2.CreateCallback(CTXLOGIN, &oidc_pb.CreateCallbackRequest{ + authRequestID := createAuthRequest(t, Instance, clientID, redirectURI) + sessionID, sessionToken, startTime, changeTime := Instance.CreateVerifiedWebAuthNSession(t, CTXLOGIN, User.GetUserId()) + linkResp, err := Instance.Client.OIDCv2.CreateCallback(CTXLOGIN, &oidc_pb.CreateCallbackRequest{ AuthRequestId: authRequestID, CallbackKind: &oidc_pb.CreateCallbackRequest_Session{ Session: &oidc_pb.Session{ @@ -391,7 +413,7 @@ func TestOPStorage_TerminateSession(t *testing.T) { // test code exchange code := assertCodeResponse(t, linkResp.GetCallbackUrl()) - tokens, err := exchangeTokens(t, clientID, code, redirectURI) + tokens, err := exchangeTokens(t, Instance, clientID, code, redirectURI) require.NoError(t, err) assertTokens(t, tokens, false) assertIDTokenClaims(t, tokens.IDTokenClaims, User.GetUserId(), armPasskey, startTime, changeTime, sessionID) @@ -410,12 +432,14 @@ func TestOPStorage_TerminateSession(t *testing.T) { } func TestOPStorage_TerminateSession_refresh_grant(t *testing.T) { - clientID, _ := createClient(t) - provider, err := Tester.CreateRelyingParty(CTX, clientID, redirectURI) + t.Parallel() + + clientID, _ := createClient(t, Instance) + provider, err := Instance.CreateRelyingParty(CTX, clientID, redirectURI) require.NoError(t, err) - authRequestID := createAuthRequest(t, clientID, redirectURI, oidc.ScopeOpenID, oidc.ScopeOfflineAccess) - sessionID, sessionToken, startTime, changeTime := Tester.CreateVerifiedWebAuthNSession(t, CTXLOGIN, User.GetUserId()) - linkResp, err := Tester.Client.OIDCv2.CreateCallback(CTXLOGIN, &oidc_pb.CreateCallbackRequest{ + authRequestID := createAuthRequest(t, Instance, clientID, redirectURI, oidc.ScopeOpenID, oidc.ScopeOfflineAccess) + sessionID, sessionToken, startTime, changeTime := Instance.CreateVerifiedWebAuthNSession(t, CTXLOGIN, User.GetUserId()) + linkResp, err := Instance.Client.OIDCv2.CreateCallback(CTXLOGIN, &oidc_pb.CreateCallbackRequest{ AuthRequestId: authRequestID, CallbackKind: &oidc_pb.CreateCallbackRequest_Session{ Session: &oidc_pb.Session{ @@ -428,7 +452,7 @@ func TestOPStorage_TerminateSession_refresh_grant(t *testing.T) { // test code exchange code := assertCodeResponse(t, linkResp.GetCallbackUrl()) - tokens, err := exchangeTokens(t, clientID, code, redirectURI) + tokens, err := exchangeTokens(t, Instance, clientID, code, redirectURI) require.NoError(t, err) assertTokens(t, tokens, true) assertIDTokenClaims(t, tokens.IDTokenClaims, User.GetUserId(), armPasskey, startTime, changeTime, sessionID) @@ -454,12 +478,14 @@ func TestOPStorage_TerminateSession_refresh_grant(t *testing.T) { } func TestOPStorage_TerminateSession_empty_id_token_hint(t *testing.T) { - clientID, _ := createClient(t) - provider, err := Tester.CreateRelyingParty(CTX, clientID, redirectURI) + t.Parallel() + + clientID, _ := createClient(t, Instance) + provider, err := Instance.CreateRelyingParty(CTX, clientID, redirectURI) require.NoError(t, err) - authRequestID := createAuthRequest(t, clientID, redirectURI) - sessionID, sessionToken, startTime, changeTime := Tester.CreateVerifiedWebAuthNSession(t, CTXLOGIN, User.GetUserId()) - linkResp, err := Tester.Client.OIDCv2.CreateCallback(CTXLOGIN, &oidc_pb.CreateCallbackRequest{ + authRequestID := createAuthRequest(t, Instance, clientID, redirectURI) + sessionID, sessionToken, startTime, changeTime := Instance.CreateVerifiedWebAuthNSession(t, CTXLOGIN, User.GetUserId()) + linkResp, err := Instance.Client.OIDCv2.CreateCallback(CTXLOGIN, &oidc_pb.CreateCallbackRequest{ AuthRequestId: authRequestID, CallbackKind: &oidc_pb.CreateCallbackRequest_Session{ Session: &oidc_pb.Session{ @@ -472,21 +498,21 @@ func TestOPStorage_TerminateSession_empty_id_token_hint(t *testing.T) { // test code exchange code := assertCodeResponse(t, linkResp.GetCallbackUrl()) - tokens, err := exchangeTokens(t, clientID, code, redirectURI) + tokens, err := exchangeTokens(t, Instance, clientID, code, redirectURI) require.NoError(t, err) assertTokens(t, tokens, false) assertIDTokenClaims(t, tokens.IDTokenClaims, User.GetUserId(), armPasskey, startTime, changeTime, sessionID) postLogoutRedirect, err := rp.EndSession(CTX, provider, "", logoutRedirectURI, "state") require.NoError(t, err) - assert.Equal(t, http_utils.BuildOrigin(Tester.Host(), Tester.Config.ExternalSecure)+Tester.Config.OIDC.DefaultLogoutURLV2+logoutRedirectURI+"?state=state", postLogoutRedirect.String()) + assert.Equal(t, http_utils.BuildOrigin(Instance.Host(), Instance.Config.Secure)+Instance.Config.LogoutURLV2+logoutRedirectURI+"?state=state", postLogoutRedirect.String()) // userinfo must not fail until login UI terminated session _, err = rp.Userinfo[*oidc.UserInfo](CTX, tokens.AccessToken, tokens.TokenType, tokens.IDTokenClaims.Subject, provider) require.NoError(t, err) // simulate termination by login UI - _, err = Tester.Client.SessionV2.DeleteSession(CTXLOGIN, &session.DeleteSessionRequest{ + _, err = Instance.Client.SessionV2.DeleteSession(CTXLOGIN, &session.DeleteSessionRequest{ SessionId: sessionID, SessionToken: gu.Ptr(sessionToken), }) @@ -497,15 +523,15 @@ func TestOPStorage_TerminateSession_empty_id_token_hint(t *testing.T) { require.Error(t, err) } -func exchangeTokens(t testing.TB, clientID, code, redirectURI string) (*oidc.Tokens[*oidc.IDTokenClaims], error) { - provider, err := Tester.CreateRelyingParty(CTX, clientID, redirectURI) +func exchangeTokens(t testing.TB, instance *integration.Instance, clientID, code, redirectURI string) (*oidc.Tokens[*oidc.IDTokenClaims], error) { + provider, err := instance.CreateRelyingParty(CTX, clientID, redirectURI) require.NoError(t, err) return rp.CodeExchange[*oidc.IDTokenClaims](context.Background(), code, provider, rp.WithCodeVerifier(integration.CodeVerifier)) } func refreshTokens(t testing.TB, clientID, refreshToken string) (*oidc.Tokens[*oidc.IDTokenClaims], error) { - provider, err := Tester.CreateRelyingParty(CTX, clientID, redirectURI) + provider, err := Instance.CreateRelyingParty(CTX, clientID, redirectURI) require.NoError(t, err) return rp.RefreshTokens[*oidc.IDTokenClaims](CTX, provider, refreshToken, "", "") diff --git a/internal/api/oidc/client_integration_test.go b/internal/api/oidc/integration_test/client_test.go similarity index 81% rename from internal/api/oidc/client_integration_test.go rename to internal/api/oidc/integration_test/client_test.go index 9ff0b104d9..7e9f15ffac 100644 --- a/internal/api/oidc/client_integration_test.go +++ b/internal/api/oidc/integration_test/client_test.go @@ -24,9 +24,11 @@ import ( ) func TestServer_Introspect(t *testing.T) { - project, err := Tester.CreateProject(CTX) + t.Parallel() + + project, err := Instance.CreateProject(CTX) require.NoError(t, err) - app, err := Tester.CreateOIDCNativeClient(CTX, redirectURI, logoutRedirectURI, project.GetId(), false) + app, err := Instance.CreateOIDCNativeClient(CTX, redirectURI, logoutRedirectURI, project.GetId(), false) require.NoError(t, err) wantAudience := []string{app.GetClientId(), project.GetId()} @@ -39,16 +41,16 @@ func TestServer_Introspect(t *testing.T) { { name: "client assertion", api: func(t *testing.T) (string, rs.ResourceServer) { - api, err := Tester.CreateAPIClientJWT(CTX, project.GetId()) + api, err := Instance.CreateAPIClientJWT(CTX, project.GetId()) require.NoError(t, err) - keyResp, err := Tester.Client.Mgmt.AddAppKey(CTX, &management.AddAppKeyRequest{ + keyResp, err := Instance.Client.Mgmt.AddAppKey(CTX, &management.AddAppKeyRequest{ ProjectId: project.GetId(), AppId: api.GetAppId(), Type: authn.KeyType_KEY_TYPE_JSON, ExpirationDate: nil, }) require.NoError(t, err) - resourceServer, err := Tester.CreateResourceServerJWTProfile(CTX, keyResp.GetKeyDetails()) + resourceServer, err := Instance.CreateResourceServerJWTProfile(CTX, keyResp.GetKeyDetails()) require.NoError(t, err) return api.GetClientId(), resourceServer }, @@ -56,9 +58,9 @@ func TestServer_Introspect(t *testing.T) { { name: "client credentials", api: func(t *testing.T) (string, rs.ResourceServer) { - api, err := Tester.CreateAPIClientBasic(CTX, project.GetId()) + api, err := Instance.CreateAPIClientBasic(CTX, project.GetId()) require.NoError(t, err) - resourceServer, err := Tester.CreateResourceServerClientCredentials(CTX, api.GetClientId(), api.GetClientSecret()) + resourceServer, err := Instance.CreateResourceServerClientCredentials(CTX, api.GetClientId(), api.GetClientSecret()) require.NoError(t, err) return api.GetClientId(), resourceServer }, @@ -66,9 +68,9 @@ func TestServer_Introspect(t *testing.T) { { name: "client invalid id, error", api: func(t *testing.T) (string, rs.ResourceServer) { - api, err := Tester.CreateAPIClientBasic(CTX, project.GetId()) + api, err := Instance.CreateAPIClientBasic(CTX, project.GetId()) require.NoError(t, err) - resourceServer, err := Tester.CreateResourceServerClientCredentials(CTX, "xxxxx", api.GetClientSecret()) + resourceServer, err := Instance.CreateResourceServerClientCredentials(CTX, "xxxxx", api.GetClientSecret()) require.NoError(t, err) return api.GetClientId(), resourceServer }, @@ -77,9 +79,9 @@ func TestServer_Introspect(t *testing.T) { { name: "client invalid secret, error", api: func(t *testing.T) (string, rs.ResourceServer) { - api, err := Tester.CreateAPIClientBasic(CTX, project.GetId()) + api, err := Instance.CreateAPIClientBasic(CTX, project.GetId()) require.NoError(t, err) - resourceServer, err := Tester.CreateResourceServerClientCredentials(CTX, api.GetClientId(), "xxxxx") + resourceServer, err := Instance.CreateResourceServerClientCredentials(CTX, api.GetClientId(), "xxxxx") require.NoError(t, err) return api.GetClientId(), resourceServer }, @@ -88,9 +90,9 @@ func TestServer_Introspect(t *testing.T) { { name: "client credentials on jwt client, error", api: func(t *testing.T) (string, rs.ResourceServer) { - api, err := Tester.CreateAPIClientJWT(CTX, project.GetId()) + api, err := Instance.CreateAPIClientJWT(CTX, project.GetId()) require.NoError(t, err) - resourceServer, err := Tester.CreateResourceServerClientCredentials(CTX, api.GetClientId(), "xxxxx") + resourceServer, err := Instance.CreateResourceServerClientCredentials(CTX, api.GetClientId(), "xxxxx") require.NoError(t, err) return api.GetClientId(), resourceServer }, @@ -104,9 +106,9 @@ func TestServer_Introspect(t *testing.T) { wantAudience = append(wantAudience, apiID) scope := []string{oidc.ScopeOpenID, oidc.ScopeProfile, oidc.ScopeEmail, oidc.ScopeOfflineAccess, oidc_api.ScopeResourceOwner} - authRequestID := createAuthRequest(t, app.GetClientId(), redirectURI, scope...) - sessionID, sessionToken, startTime, changeTime := Tester.CreateVerifiedWebAuthNSession(t, CTXLOGIN, User.GetUserId()) - linkResp, err := Tester.Client.OIDCv2.CreateCallback(CTXLOGIN, &oidc_pb.CreateCallbackRequest{ + authRequestID := createAuthRequest(t, Instance, app.GetClientId(), redirectURI, scope...) + sessionID, sessionToken, startTime, changeTime := Instance.CreateVerifiedWebAuthNSession(t, CTXLOGIN, User.GetUserId()) + linkResp, err := Instance.Client.OIDCv2.CreateCallback(CTXLOGIN, &oidc_pb.CreateCallbackRequest{ AuthRequestId: authRequestID, CallbackKind: &oidc_pb.CreateCallbackRequest_Session{ Session: &oidc_pb.Session{ @@ -119,7 +121,7 @@ func TestServer_Introspect(t *testing.T) { // code exchange code := assertCodeResponse(t, linkResp.GetCallbackUrl()) - tokens, err := exchangeTokens(t, app.GetClientId(), code, redirectURI) + tokens, err := exchangeTokens(t, Instance, app.GetClientId(), code, redirectURI) require.NoError(t, err) assertTokens(t, tokens, true) assertIDTokenClaims(t, tokens.IDTokenClaims, User.GetUserId(), armPasskey, startTime, changeTime, sessionID) @@ -133,7 +135,7 @@ func TestServer_Introspect(t *testing.T) { require.NoError(t, err) assertIntrospection(t, introspection, - Tester.OIDCIssuer(), app.GetClientId(), + Instance.OIDCIssuer(), app.GetClientId(), scope, wantAudience, tokens.Expiry, tokens.Expiry.Add(-12*time.Hour)) }) @@ -141,9 +143,11 @@ func TestServer_Introspect(t *testing.T) { } func TestServer_Introspect_invalid_auth_invalid_token(t *testing.T) { + t.Parallel() + // ensure that when an invalid authentication and token is sent, the authentication error is returned // https://github.com/zitadel/zitadel/pull/8133 - resourceServer, err := Tester.CreateResourceServerClientCredentials(CTX, "xxxxx", "xxxxx") + resourceServer, err := Instance.CreateResourceServerClientCredentials(CTX, "xxxxx", "xxxxx") require.NoError(t, err) _, err = rs.Introspect[*oidc.IntrospectionResponse](context.Background(), resourceServer, "xxxxx") require.Error(t, err) @@ -187,17 +191,19 @@ func assertIntrospection( // TestServer_VerifyClient tests verification by running code flow tests // with clients that have different authentication methods. func TestServer_VerifyClient(t *testing.T) { - sessionID, sessionToken, startTime, changeTime := Tester.CreateVerifiedWebAuthNSession(t, CTXLOGIN, User.GetUserId()) - project, err := Tester.CreateProject(CTX) + t.Parallel() + + sessionID, sessionToken, startTime, changeTime := Instance.CreateVerifiedWebAuthNSession(t, CTXLOGIN, User.GetUserId()) + project, err := Instance.CreateProject(CTX) require.NoError(t, err) - inactiveClient, err := Tester.CreateOIDCInactivateClient(CTX, redirectURI, logoutRedirectURI, project.GetId()) + inactiveClient, err := Instance.CreateOIDCInactivateClient(CTX, redirectURI, logoutRedirectURI, project.GetId()) require.NoError(t, err) - nativeClient, err := Tester.CreateOIDCNativeClient(CTX, redirectURI, logoutRedirectURI, project.GetId(), false) + nativeClient, err := Instance.CreateOIDCNativeClient(CTX, redirectURI, logoutRedirectURI, project.GetId(), false) require.NoError(t, err) - basicWebClient, err := Tester.CreateOIDCWebClientBasic(CTX, redirectURI, logoutRedirectURI, project.GetId()) + basicWebClient, err := Instance.CreateOIDCWebClientBasic(CTX, redirectURI, logoutRedirectURI, project.GetId()) require.NoError(t, err) - jwtWebClient, keyData, err := Tester.CreateOIDCWebClientJWT(CTX, redirectURI, logoutRedirectURI, project.GetId()) + jwtWebClient, keyData, err := Instance.CreateOIDCWebClientJWT(CTX, redirectURI, logoutRedirectURI, project.GetId()) require.NoError(t, err) type clientDetails struct { @@ -295,9 +301,9 @@ func TestServer_VerifyClient(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - authRequestID, err := Tester.CreateOIDCAuthRequest(CTX, tt.client.authReqClientID, Tester.Users[integration.FirstInstanceUsersKey][integration.Login].ID, redirectURI, oidc.ScopeOpenID) + authRequestID, err := Instance.CreateOIDCAuthRequest(CTX, tt.client.authReqClientID, Instance.Users.Get(integration.UserTypeLogin).ID, redirectURI, oidc.ScopeOpenID) require.NoError(t, err) - linkResp, err := Tester.Client.OIDCv2.CreateCallback(CTXLOGIN, &oidc_pb.CreateCallbackRequest{ + linkResp, err := Instance.Client.OIDCv2.CreateCallback(CTXLOGIN, &oidc_pb.CreateCallbackRequest{ AuthRequestId: authRequestID, CallbackKind: &oidc_pb.CreateCallbackRequest_Session{ Session: &oidc_pb.Session{ @@ -313,7 +319,7 @@ func TestServer_VerifyClient(t *testing.T) { if tt.client.keyData != nil { options = append(options, rp.WithJWTProfile(rp.SignerFromKeyFile(tt.client.keyData))) } - provider, err := rp.NewRelyingPartyOIDC(CTX, Tester.OIDCIssuer(), tt.client.clientID, tt.client.clientSecret, redirectURI, []string{oidc.ScopeOpenID}, options...) + provider, err := rp.NewRelyingPartyOIDC(CTX, Instance.OIDCIssuer(), tt.client.clientID, tt.client.clientSecret, redirectURI, []string{oidc.ScopeOpenID}, options...) require.NoError(t, err) // test code exchange diff --git a/internal/api/oidc/keys_integration_test.go b/internal/api/oidc/integration_test/keys_test.go similarity index 66% rename from internal/api/oidc/keys_integration_test.go rename to internal/api/oidc/integration_test/keys_test.go index a78e4fc1b8..e8160017a5 100644 --- a/internal/api/oidc/keys_integration_test.go +++ b/internal/api/oidc/integration_test/keys_test.go @@ -18,17 +18,23 @@ import ( http_util "github.com/zitadel/zitadel/internal/api/http" "github.com/zitadel/zitadel/internal/crypto" + "github.com/zitadel/zitadel/internal/integration" "github.com/zitadel/zitadel/pkg/grpc/feature/v2" oidc_pb "github.com/zitadel/zitadel/pkg/grpc/oidc/v2" ) func TestServer_Keys(t *testing.T) { - // TODO: isolated instance + t.Parallel() - clientID, _ := createClient(t) - authRequestID := createAuthRequest(t, clientID, redirectURI, oidc.ScopeOpenID, oidc.ScopeOfflineAccess, zitadelAudienceScope) - sessionID, sessionToken, _, _ := Tester.CreateVerifiedWebAuthNSession(t, CTXLOGIN, User.GetUserId()) - linkResp, err := Tester.Client.OIDCv2.CreateCallback(CTXLOGIN, &oidc_pb.CreateCallbackRequest{ + instance := integration.NewInstance(CTX) + ctxLogin := instance.WithAuthorization(CTX, integration.UserTypeLogin) + + clientID, _ := createClient(t, instance) + authRequestID := createAuthRequest(t, instance, clientID, redirectURI, oidc.ScopeOpenID, oidc.ScopeOfflineAccess, zitadelAudienceScope) + + instance.RegisterUserPasskey(instance.WithAuthorization(CTX, integration.UserTypeOrgOwner), instance.AdminUserID) + sessionID, sessionToken, _, _ := instance.CreateVerifiedWebAuthNSession(t, ctxLogin, instance.AdminUserID) + linkResp, err := instance.Client.OIDCv2.CreateCallback(ctxLogin, &oidc_pb.CreateCallbackRequest{ AuthRequestId: authRequestID, CallbackKind: &oidc_pb.CreateCallbackRequest_Session{ Session: &oidc_pb.Session{ @@ -41,10 +47,10 @@ func TestServer_Keys(t *testing.T) { // code exchange so we are sure there is 1 legacy key pair. code := assertCodeResponse(t, linkResp.GetCallbackUrl()) - _, err = exchangeTokens(t, clientID, code, redirectURI) + _, err = exchangeTokens(t, instance, clientID, code, redirectURI) require.NoError(t, err) - issuer := http_util.BuildHTTP(Tester.Config.ExternalDomain, Tester.Config.Port, Tester.Config.ExternalSecure) + issuer := http_util.BuildHTTP(instance.Domain, instance.Config.Port, instance.Config.Secure) discovery, err := client.Discover(CTX, issuer, http.DefaultClient) require.NoError(t, err) @@ -66,7 +72,7 @@ func TestServer_Keys(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - ensureWebKeyFeature(t, tt.webKeyFeature) + ensureWebKeyFeature(t, instance, tt.webKeyFeature) assert.EventuallyWithT(t, func(ttt *assert.CollectT) { resp, err := http.Get(discovery.JwksURI) @@ -100,14 +106,16 @@ func TestServer_Keys(t *testing.T) { } } -func ensureWebKeyFeature(t *testing.T, set bool) { - _, err := Tester.Client.FeatureV2.SetInstanceFeatures(CTXIAM, &feature.SetInstanceFeaturesRequest{ +func ensureWebKeyFeature(t *testing.T, instance *integration.Instance, set bool) { + ctxIam := instance.WithAuthorization(CTX, integration.UserTypeIAMOwner) + + _, err := instance.Client.FeatureV2.SetInstanceFeatures(ctxIam, &feature.SetInstanceFeaturesRequest{ WebKey: proto.Bool(set), }) require.NoError(t, err) t.Cleanup(func() { - _, err := Tester.Client.FeatureV2.SetInstanceFeatures(CTXIAM, &feature.SetInstanceFeaturesRequest{ + _, err := instance.Client.FeatureV2.SetInstanceFeatures(ctxIam, &feature.SetInstanceFeaturesRequest{ WebKey: proto.Bool(false), }) require.NoError(t, err) diff --git a/internal/api/oidc/oidc_integration_test.go b/internal/api/oidc/integration_test/oidc_test.go similarity index 59% rename from internal/api/oidc/oidc_integration_test.go rename to internal/api/oidc/integration_test/oidc_test.go index 1f7f615809..016745cf56 100644 --- a/internal/api/oidc/oidc_integration_test.go +++ b/internal/api/oidc/integration_test/oidc_test.go @@ -28,7 +28,7 @@ var ( CTX context.Context CTXLOGIN context.Context CTXIAM context.Context - Tester *integration.Tester + Instance *integration.Instance User *user.AddHumanUserResponse ) @@ -41,27 +41,26 @@ const ( func TestMain(m *testing.M) { os.Exit(func() int { - ctx, _, cancel := integration.Contexts(10 * time.Minute) + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Minute) defer cancel() - Tester = integration.NewTester(ctx) - defer Tester.Done() + Instance = integration.NewInstance(ctx) - CTX = Tester.WithAuthorization(ctx, integration.OrgOwner) - User = Tester.CreateHumanUser(CTX) - Tester.SetUserPassword(CTX, User.GetUserId(), integration.UserPassword, false) - Tester.RegisterUserPasskey(CTX, User.GetUserId()) - CTXLOGIN = Tester.WithAuthorization(ctx, integration.Login) - CTXIAM = Tester.WithAuthorization(ctx, integration.IAMOwner) + CTX = Instance.WithAuthorization(ctx, integration.UserTypeOrgOwner) + User = Instance.CreateHumanUser(CTX) + Instance.SetUserPassword(CTX, User.GetUserId(), integration.UserPassword, false) + Instance.RegisterUserPasskey(CTX, User.GetUserId()) + CTXLOGIN = Instance.WithAuthorization(ctx, integration.UserTypeLogin) + CTXIAM = Instance.WithAuthorization(ctx, integration.UserTypeIAMOwner) return m.Run() }()) } func Test_ZITADEL_API_missing_audience_scope(t *testing.T) { - clientID, _ := createClient(t) - authRequestID := createAuthRequest(t, clientID, redirectURI, oidc.ScopeOpenID) - sessionID, sessionToken, startTime, changeTime := Tester.CreateVerifiedWebAuthNSession(t, CTXLOGIN, User.GetUserId()) - linkResp, err := Tester.Client.OIDCv2.CreateCallback(CTXLOGIN, &oidc_pb.CreateCallbackRequest{ + clientID, _ := createClient(t, Instance) + authRequestID := createAuthRequest(t, Instance, clientID, redirectURI, oidc.ScopeOpenID) + sessionID, sessionToken, startTime, changeTime := Instance.CreateVerifiedWebAuthNSession(t, CTXLOGIN, User.GetUserId()) + linkResp, err := Instance.Client.OIDCv2.CreateCallback(CTXLOGIN, &oidc_pb.CreateCallbackRequest{ AuthRequestId: authRequestID, CallbackKind: &oidc_pb.CreateCallbackRequest_Session{ Session: &oidc_pb.Session{ @@ -74,22 +73,22 @@ func Test_ZITADEL_API_missing_audience_scope(t *testing.T) { // code exchange code := assertCodeResponse(t, linkResp.GetCallbackUrl()) - tokens, err := exchangeTokens(t, clientID, code, redirectURI) + tokens, err := exchangeTokens(t, Instance, clientID, code, redirectURI) require.NoError(t, err) assertTokens(t, tokens, false) assertIDTokenClaims(t, tokens.IDTokenClaims, User.GetUserId(), armPasskey, startTime, changeTime, sessionID) ctx := metadata.AppendToOutgoingContext(context.Background(), "Authorization", fmt.Sprintf("%s %s", tokens.TokenType, tokens.AccessToken)) - myUserResp, err := Tester.Client.Auth.GetMyUser(ctx, &auth.GetMyUserRequest{}) + myUserResp, err := Instance.Client.Auth.GetMyUser(ctx, &auth.GetMyUserRequest{}) require.Error(t, err) require.Nil(t, myUserResp) } func Test_ZITADEL_API_missing_authentication(t *testing.T) { - clientID, _ := createClient(t) - authRequestID := createAuthRequest(t, clientID, redirectURI, oidc.ScopeOpenID, zitadelAudienceScope) - createResp, err := Tester.Client.SessionV2.CreateSession(CTX, &session.CreateSessionRequest{ + clientID, _ := createClient(t, Instance) + authRequestID := createAuthRequest(t, Instance, clientID, redirectURI, oidc.ScopeOpenID, zitadelAudienceScope) + createResp, err := Instance.Client.SessionV2.CreateSession(CTX, &session.CreateSessionRequest{ Checks: &session.Checks{ User: &session.CheckUser{ Search: &session.CheckUser_UserId{UserId: User.GetUserId()}, @@ -97,7 +96,7 @@ func Test_ZITADEL_API_missing_authentication(t *testing.T) { }, }) require.NoError(t, err) - linkResp, err := Tester.Client.OIDCv2.CreateCallback(CTXLOGIN, &oidc_pb.CreateCallbackRequest{ + linkResp, err := Instance.Client.OIDCv2.CreateCallback(CTXLOGIN, &oidc_pb.CreateCallbackRequest{ AuthRequestId: authRequestID, CallbackKind: &oidc_pb.CreateCallbackRequest_Session{ Session: &oidc_pb.Session{ @@ -110,24 +109,24 @@ func Test_ZITADEL_API_missing_authentication(t *testing.T) { // code exchange code := assertCodeResponse(t, linkResp.GetCallbackUrl()) - tokens, err := exchangeTokens(t, clientID, code, redirectURI) + tokens, err := exchangeTokens(t, Instance, clientID, code, redirectURI) require.NoError(t, err) ctx := metadata.AppendToOutgoingContext(context.Background(), "Authorization", fmt.Sprintf("%s %s", tokens.TokenType, tokens.AccessToken)) - myUserResp, err := Tester.Client.Auth.GetMyUser(ctx, &auth.GetMyUserRequest{}) + myUserResp, err := Instance.Client.Auth.GetMyUser(ctx, &auth.GetMyUserRequest{}) require.Error(t, err) require.Nil(t, myUserResp) } func Test_ZITADEL_API_missing_mfa_policy(t *testing.T) { - clientID, _ := createClient(t) - org := Tester.CreateOrganization(CTXIAM, fmt.Sprintf("ZITADEL_API_MISSING_MFA_%d", time.Now().UnixNano()), fmt.Sprintf("%d@mouse.com", time.Now().UnixNano())) + clientID, _ := createClient(t, Instance) + org := Instance.CreateOrganization(CTXIAM, fmt.Sprintf("ZITADEL_API_MISSING_MFA_%d", time.Now().UnixNano()), fmt.Sprintf("%d@mouse.com", time.Now().UnixNano())) userID := org.CreatedAdmins[0].GetUserId() - Tester.SetUserPassword(CTXIAM, userID, integration.UserPassword, false) - authRequestID := createAuthRequest(t, clientID, redirectURI, oidc.ScopeOpenID, zitadelAudienceScope) - sessionID, sessionToken, startTime, changeTime := Tester.CreatePasswordSession(t, CTXLOGIN, userID, integration.UserPassword) - linkResp, err := Tester.Client.OIDCv2.CreateCallback(CTXLOGIN, &oidc_pb.CreateCallbackRequest{ + Instance.SetUserPassword(CTXIAM, userID, integration.UserPassword, false) + authRequestID := createAuthRequest(t, Instance, clientID, redirectURI, oidc.ScopeOpenID, zitadelAudienceScope) + sessionID, sessionToken, startTime, changeTime := Instance.CreatePasswordSession(t, CTXLOGIN, userID, integration.UserPassword) + linkResp, err := Instance.Client.OIDCv2.CreateCallback(CTXLOGIN, &oidc_pb.CreateCallbackRequest{ AuthRequestId: authRequestID, CallbackKind: &oidc_pb.CreateCallbackRequest_Session{ Session: &oidc_pb.Session{ @@ -140,20 +139,20 @@ func Test_ZITADEL_API_missing_mfa_policy(t *testing.T) { // code exchange code := assertCodeResponse(t, linkResp.GetCallbackUrl()) - tokens, err := exchangeTokens(t, clientID, code, redirectURI) + tokens, err := exchangeTokens(t, Instance, clientID, code, redirectURI) require.NoError(t, err) assertIDTokenClaims(t, tokens.IDTokenClaims, userID, armPassword, startTime, changeTime, sessionID) ctx := metadata.AppendToOutgoingContext(context.Background(), "Authorization", fmt.Sprintf("%s %s", tokens.TokenType, tokens.AccessToken)) // pre check if request would succeed - myUserResp, err := Tester.Client.Auth.GetMyUser(ctx, &auth.GetMyUserRequest{}) + myUserResp, err := Instance.Client.Auth.GetMyUser(ctx, &auth.GetMyUserRequest{}) require.NoError(t, err) require.Equal(t, userID, myUserResp.GetUser().GetId()) // require MFA ctxOrg := metadata.AppendToOutgoingContext(CTXIAM, "x-zitadel-orgid", org.GetOrganizationId()) - _, err = Tester.Client.Mgmt.AddCustomLoginPolicy(ctxOrg, &mgmt.AddCustomLoginPolicyRequest{ + _, err = Instance.Client.Mgmt.AddCustomLoginPolicy(ctxOrg, &mgmt.AddCustomLoginPolicyRequest{ ForceMfa: true, }) require.NoError(t, err) @@ -164,23 +163,23 @@ func Test_ZITADEL_API_missing_mfa_policy(t *testing.T) { retryDuration = time.Until(ctxDeadline) } require.EventuallyWithT(t, func(ttt *assert.CollectT) { - got, getErr := Tester.Client.Mgmt.GetLoginPolicy(ctxOrg, &mgmt.GetLoginPolicyRequest{}) + got, getErr := Instance.Client.Mgmt.GetLoginPolicy(ctxOrg, &mgmt.GetLoginPolicyRequest{}) assert.NoError(ttt, getErr) assert.False(ttt, got.GetPolicy().IsDefault) }, retryDuration, time.Millisecond*100, "timeout waiting for login policy") // now it must fail - myUserResp, err = Tester.Client.Auth.GetMyUser(ctx, &auth.GetMyUserRequest{}) + myUserResp, err = Instance.Client.Auth.GetMyUser(ctx, &auth.GetMyUserRequest{}) require.Error(t, err) require.Nil(t, myUserResp) } func Test_ZITADEL_API_success(t *testing.T) { - clientID, _ := createClient(t) - authRequestID := createAuthRequest(t, clientID, redirectURI, oidc.ScopeOpenID, zitadelAudienceScope) - sessionID, sessionToken, startTime, changeTime := Tester.CreateVerifiedWebAuthNSession(t, CTXLOGIN, User.GetUserId()) - linkResp, err := Tester.Client.OIDCv2.CreateCallback(CTXLOGIN, &oidc_pb.CreateCallbackRequest{ + clientID, _ := createClient(t, Instance) + authRequestID := createAuthRequest(t, Instance, clientID, redirectURI, oidc.ScopeOpenID, zitadelAudienceScope) + sessionID, sessionToken, startTime, changeTime := Instance.CreateVerifiedWebAuthNSession(t, CTXLOGIN, User.GetUserId()) + linkResp, err := Instance.Client.OIDCv2.CreateCallback(CTXLOGIN, &oidc_pb.CreateCallbackRequest{ AuthRequestId: authRequestID, CallbackKind: &oidc_pb.CreateCallbackRequest_Session{ Session: &oidc_pb.Session{ @@ -193,28 +192,28 @@ func Test_ZITADEL_API_success(t *testing.T) { // code exchange code := assertCodeResponse(t, linkResp.GetCallbackUrl()) - tokens, err := exchangeTokens(t, clientID, code, redirectURI) + tokens, err := exchangeTokens(t, Instance, clientID, code, redirectURI) require.NoError(t, err) assertTokens(t, tokens, false) assertIDTokenClaims(t, tokens.IDTokenClaims, User.GetUserId(), armPasskey, startTime, changeTime, sessionID) ctx := metadata.AppendToOutgoingContext(context.Background(), "Authorization", fmt.Sprintf("%s %s", tokens.TokenType, tokens.AccessToken)) - myUserResp, err := Tester.Client.Auth.GetMyUser(ctx, &auth.GetMyUserRequest{}) + myUserResp, err := Instance.Client.Auth.GetMyUser(ctx, &auth.GetMyUserRequest{}) require.NoError(t, err) require.Equal(t, User.GetUserId(), myUserResp.GetUser().GetId()) } func Test_ZITADEL_API_glob_redirects(t *testing.T) { const redirectURI = "https://my-org-1yfnjl2xj-my-app.vercel.app/api/auth/callback/zitadel" - clientID, _ := createClientWithOpts(t, clientOpts{ + clientID, _ := createClientWithOpts(t, Instance, clientOpts{ redirectURI: "https://my-org-*-my-app.vercel.app/api/auth/callback/zitadel", logoutURI: "https://my-org-*-my-app.vercel.app/", devMode: true, }) - authRequestID := createAuthRequest(t, clientID, redirectURI, oidc.ScopeOpenID, zitadelAudienceScope) - sessionID, sessionToken, startTime, changeTime := Tester.CreateVerifiedWebAuthNSession(t, CTXLOGIN, User.GetUserId()) - linkResp, err := Tester.Client.OIDCv2.CreateCallback(CTXLOGIN, &oidc_pb.CreateCallbackRequest{ + authRequestID := createAuthRequest(t, Instance, clientID, redirectURI, oidc.ScopeOpenID, zitadelAudienceScope) + sessionID, sessionToken, startTime, changeTime := Instance.CreateVerifiedWebAuthNSession(t, CTXLOGIN, User.GetUserId()) + linkResp, err := Instance.Client.OIDCv2.CreateCallback(CTXLOGIN, &oidc_pb.CreateCallbackRequest{ AuthRequestId: authRequestID, CallbackKind: &oidc_pb.CreateCallbackRequest_Session{ Session: &oidc_pb.Session{ @@ -227,23 +226,23 @@ func Test_ZITADEL_API_glob_redirects(t *testing.T) { // code exchange code := assertCodeResponse(t, linkResp.GetCallbackUrl()) - tokens, err := exchangeTokens(t, clientID, code, redirectURI) + tokens, err := exchangeTokens(t, Instance, clientID, code, redirectURI) require.NoError(t, err) assertTokens(t, tokens, false) assertIDTokenClaims(t, tokens.IDTokenClaims, User.GetUserId(), armPasskey, startTime, changeTime, sessionID) ctx := metadata.AppendToOutgoingContext(context.Background(), "Authorization", fmt.Sprintf("%s %s", tokens.TokenType, tokens.AccessToken)) - myUserResp, err := Tester.Client.Auth.GetMyUser(ctx, &auth.GetMyUserRequest{}) + myUserResp, err := Instance.Client.Auth.GetMyUser(ctx, &auth.GetMyUserRequest{}) require.NoError(t, err) require.Equal(t, User.GetUserId(), myUserResp.GetUser().GetId()) } func Test_ZITADEL_API_inactive_access_token(t *testing.T) { - clientID, _ := createClient(t) - authRequestID := createAuthRequest(t, clientID, redirectURI, oidc.ScopeOpenID, oidc.ScopeOfflineAccess, zitadelAudienceScope) - sessionID, sessionToken, startTime, changeTime := Tester.CreateVerifiedWebAuthNSession(t, CTXLOGIN, User.GetUserId()) - linkResp, err := Tester.Client.OIDCv2.CreateCallback(CTXLOGIN, &oidc_pb.CreateCallbackRequest{ + clientID, _ := createClient(t, Instance) + authRequestID := createAuthRequest(t, Instance, clientID, redirectURI, oidc.ScopeOpenID, oidc.ScopeOfflineAccess, zitadelAudienceScope) + sessionID, sessionToken, startTime, changeTime := Instance.CreateVerifiedWebAuthNSession(t, CTXLOGIN, User.GetUserId()) + linkResp, err := Instance.Client.OIDCv2.CreateCallback(CTXLOGIN, &oidc_pb.CreateCallbackRequest{ AuthRequestId: authRequestID, CallbackKind: &oidc_pb.CreateCallbackRequest_Session{ Session: &oidc_pb.Session{ @@ -256,14 +255,14 @@ func Test_ZITADEL_API_inactive_access_token(t *testing.T) { // code exchange code := assertCodeResponse(t, linkResp.GetCallbackUrl()) - tokens, err := exchangeTokens(t, clientID, code, redirectURI) + tokens, err := exchangeTokens(t, Instance, clientID, code, redirectURI) require.NoError(t, err) assertTokens(t, tokens, true) assertIDTokenClaims(t, tokens.IDTokenClaims, User.GetUserId(), armPasskey, startTime, changeTime, sessionID) // make sure token works ctx := metadata.AppendToOutgoingContext(context.Background(), "Authorization", fmt.Sprintf("%s %s", tokens.TokenType, tokens.AccessToken)) - myUserResp, err := Tester.Client.Auth.GetMyUser(ctx, &auth.GetMyUserRequest{}) + myUserResp, err := Instance.Client.Auth.GetMyUser(ctx, &auth.GetMyUserRequest{}) require.NoError(t, err) require.Equal(t, User.GetUserId(), myUserResp.GetUser().GetId()) @@ -274,18 +273,18 @@ func Test_ZITADEL_API_inactive_access_token(t *testing.T) { // use invalidated token ctx = metadata.AppendToOutgoingContext(context.Background(), "Authorization", fmt.Sprintf("%s %s", tokens.TokenType, tokens.AccessToken)) - myUserResp, err = Tester.Client.Auth.GetMyUser(ctx, &auth.GetMyUserRequest{}) + myUserResp, err = Instance.Client.Auth.GetMyUser(ctx, &auth.GetMyUserRequest{}) require.Error(t, err) require.Nil(t, myUserResp) } func Test_ZITADEL_API_terminated_session(t *testing.T) { - clientID, _ := createClient(t) - provider, err := Tester.CreateRelyingParty(CTX, clientID, redirectURI) + clientID, _ := createClient(t, Instance) + provider, err := Instance.CreateRelyingParty(CTX, clientID, redirectURI) require.NoError(t, err) - authRequestID := createAuthRequest(t, clientID, redirectURI, oidc.ScopeOpenID, oidc.ScopeOfflineAccess, zitadelAudienceScope) - sessionID, sessionToken, startTime, changeTime := Tester.CreateVerifiedWebAuthNSession(t, CTXLOGIN, User.GetUserId()) - linkResp, err := Tester.Client.OIDCv2.CreateCallback(CTXLOGIN, &oidc_pb.CreateCallbackRequest{ + authRequestID := createAuthRequest(t, Instance, clientID, redirectURI, oidc.ScopeOpenID, oidc.ScopeOfflineAccess, zitadelAudienceScope) + sessionID, sessionToken, startTime, changeTime := Instance.CreateVerifiedWebAuthNSession(t, CTXLOGIN, User.GetUserId()) + linkResp, err := Instance.Client.OIDCv2.CreateCallback(CTXLOGIN, &oidc_pb.CreateCallbackRequest{ AuthRequestId: authRequestID, CallbackKind: &oidc_pb.CreateCallbackRequest_Session{ Session: &oidc_pb.Session{ @@ -298,14 +297,14 @@ func Test_ZITADEL_API_terminated_session(t *testing.T) { // code exchange code := assertCodeResponse(t, linkResp.GetCallbackUrl()) - tokens, err := exchangeTokens(t, clientID, code, redirectURI) + tokens, err := exchangeTokens(t, Instance, clientID, code, redirectURI) require.NoError(t, err) assertTokens(t, tokens, true) assertIDTokenClaims(t, tokens.IDTokenClaims, User.GetUserId(), armPasskey, startTime, changeTime, sessionID) // make sure token works ctx := metadata.AppendToOutgoingContext(context.Background(), "Authorization", fmt.Sprintf("%s %s", tokens.TokenType, tokens.AccessToken)) - myUserResp, err := Tester.Client.Auth.GetMyUser(ctx, &auth.GetMyUserRequest{}) + myUserResp, err := Instance.Client.Auth.GetMyUser(ctx, &auth.GetMyUserRequest{}) require.NoError(t, err) require.Equal(t, User.GetUserId(), myUserResp.GetUser().GetId()) @@ -316,13 +315,13 @@ func Test_ZITADEL_API_terminated_session(t *testing.T) { // use token from terminated session ctx = metadata.AppendToOutgoingContext(context.Background(), "Authorization", fmt.Sprintf("%s %s", tokens.TokenType, tokens.AccessToken)) - myUserResp, err = Tester.Client.Auth.GetMyUser(ctx, &auth.GetMyUserRequest{}) + myUserResp, err = Instance.Client.Auth.GetMyUser(ctx, &auth.GetMyUserRequest{}) require.Error(t, err) require.Nil(t, myUserResp) } func Test_ZITADEL_API_terminated_session_user_disabled(t *testing.T) { - clientID, _ := createClient(t) + clientID, _ := createClient(t, Instance) tests := []struct { name string disable func(userID string) error @@ -330,32 +329,32 @@ func Test_ZITADEL_API_terminated_session_user_disabled(t *testing.T) { { name: "deactivated", disable: func(userID string) error { - _, err := Tester.Client.UserV2.DeactivateUser(CTX, &user.DeactivateUserRequest{UserId: userID}) + _, err := Instance.Client.UserV2.DeactivateUser(CTX, &user.DeactivateUserRequest{UserId: userID}) return err }, }, { name: "locked", disable: func(userID string) error { - _, err := Tester.Client.UserV2.LockUser(CTX, &user.LockUserRequest{UserId: userID}) + _, err := Instance.Client.UserV2.LockUser(CTX, &user.LockUserRequest{UserId: userID}) return err }, }, { name: "deleted", disable: func(userID string) error { - _, err := Tester.Client.UserV2.DeleteUser(CTX, &user.DeleteUserRequest{UserId: userID}) + _, err := Instance.Client.UserV2.DeleteUser(CTX, &user.DeleteUserRequest{UserId: userID}) return err }, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - disabledUser := Tester.CreateHumanUser(CTX) - Tester.SetUserPassword(CTX, disabledUser.GetUserId(), integration.UserPassword, false) - authRequestID := createAuthRequest(t, clientID, redirectURI, oidc.ScopeOpenID, oidc.ScopeOfflineAccess, zitadelAudienceScope) - sessionID, sessionToken, startTime, changeTime := Tester.CreatePasswordSession(t, CTXLOGIN, disabledUser.GetUserId(), integration.UserPassword) - linkResp, err := Tester.Client.OIDCv2.CreateCallback(CTXLOGIN, &oidc_pb.CreateCallbackRequest{ + disabledUser := Instance.CreateHumanUser(CTX) + Instance.SetUserPassword(CTX, disabledUser.GetUserId(), integration.UserPassword, false) + authRequestID := createAuthRequest(t, Instance, clientID, redirectURI, oidc.ScopeOpenID, oidc.ScopeOfflineAccess, zitadelAudienceScope) + sessionID, sessionToken, startTime, changeTime := Instance.CreatePasswordSession(t, CTXLOGIN, disabledUser.GetUserId(), integration.UserPassword) + linkResp, err := Instance.Client.OIDCv2.CreateCallback(CTXLOGIN, &oidc_pb.CreateCallbackRequest{ AuthRequestId: authRequestID, CallbackKind: &oidc_pb.CreateCallbackRequest_Session{ Session: &oidc_pb.Session{ @@ -368,14 +367,14 @@ func Test_ZITADEL_API_terminated_session_user_disabled(t *testing.T) { // code exchange code := assertCodeResponse(t, linkResp.GetCallbackUrl()) - tokens, err := exchangeTokens(t, clientID, code, redirectURI) + tokens, err := exchangeTokens(t, Instance, clientID, code, redirectURI) require.NoError(t, err) assertTokens(t, tokens, true) assertIDTokenClaims(t, tokens.IDTokenClaims, disabledUser.GetUserId(), armPassword, startTime, changeTime, sessionID) // make sure token works ctx := metadata.AppendToOutgoingContext(context.Background(), "Authorization", fmt.Sprintf("%s %s", tokens.TokenType, tokens.AccessToken)) - myUserResp, err := Tester.Client.Auth.GetMyUser(ctx, &auth.GetMyUserRequest{}) + myUserResp, err := Instance.Client.Auth.GetMyUser(ctx, &auth.GetMyUserRequest{}) require.NoError(t, err) require.Equal(t, disabledUser.GetUserId(), myUserResp.GetUser().GetId()) @@ -385,15 +384,15 @@ func Test_ZITADEL_API_terminated_session_user_disabled(t *testing.T) { // use token from deactivated user ctx = metadata.AppendToOutgoingContext(context.Background(), "Authorization", fmt.Sprintf("%s %s", tokens.TokenType, tokens.AccessToken)) - myUserResp, err = Tester.Client.Auth.GetMyUser(ctx, &auth.GetMyUserRequest{}) + myUserResp, err = Instance.Client.Auth.GetMyUser(ctx, &auth.GetMyUserRequest{}) require.Error(t, err) require.Nil(t, myUserResp) }) } } -func createClient(t testing.TB) (clientID, projectID string) { - return createClientWithOpts(t, clientOpts{ +func createClient(t testing.TB, instance *integration.Instance) (clientID, projectID string) { + return createClientWithOpts(t, instance, clientOpts{ redirectURI: redirectURI, logoutURI: logoutRedirectURI, devMode: false, @@ -406,28 +405,30 @@ type clientOpts struct { devMode bool } -func createClientWithOpts(t testing.TB, opts clientOpts) (clientID, projectID string) { - project, err := Tester.CreateProject(CTX) +func createClientWithOpts(t testing.TB, instance *integration.Instance, opts clientOpts) (clientID, projectID string) { + ctx := instance.WithAuthorization(CTX, integration.UserTypeOrgOwner) + + project, err := instance.CreateProject(ctx) require.NoError(t, err) - app, err := Tester.CreateOIDCNativeClient(CTX, opts.redirectURI, opts.logoutURI, project.GetId(), opts.devMode) + app, err := instance.CreateOIDCNativeClient(ctx, opts.redirectURI, opts.logoutURI, project.GetId(), opts.devMode) require.NoError(t, err) return app.GetClientId(), project.GetId() } func createImplicitClient(t testing.TB) string { - app, err := Tester.CreateOIDCImplicitFlowClient(CTX, redirectURIImplicit) + app, err := Instance.CreateOIDCImplicitFlowClient(CTX, redirectURIImplicit) require.NoError(t, err) return app.GetClientId() } -func createAuthRequest(t testing.TB, clientID, redirectURI string, scope ...string) string { - redURL, err := Tester.CreateOIDCAuthRequest(CTX, clientID, Tester.Users[integration.FirstInstanceUsersKey][integration.Login].ID, redirectURI, scope...) +func createAuthRequest(t testing.TB, instance *integration.Instance, clientID, redirectURI string, scope ...string) string { + redURL, err := instance.CreateOIDCAuthRequest(CTX, clientID, instance.Users.Get(integration.UserTypeLogin).ID, redirectURI, scope...) require.NoError(t, err) return redURL } func createAuthRequestImplicit(t testing.TB, clientID, redirectURI string, scope ...string) string { - redURL, err := Tester.CreateOIDCAuthRequestImplicit(CTX, clientID, Tester.Users[integration.FirstInstanceUsersKey][integration.Login].ID, redirectURI, scope...) + redURL, err := Instance.CreateOIDCAuthRequestImplicit(CTX, clientID, Instance.Users.Get(integration.UserTypeLogin).ID, redirectURI, scope...) require.NoError(t, err) return redURL } @@ -437,5 +438,5 @@ func assertOIDCTime(t *testing.T, actual oidc.Time, expected time.Time) { } func assertOIDCTimeRange(t *testing.T, actual oidc.Time, expectedStart, expectedEnd time.Time) { - assert.WithinRange(t, actual.AsTime(), expectedStart.Add(-1*time.Second), expectedEnd.Add(1*time.Second)) + assert.WithinRange(t, actual.AsTime(), expectedStart.Add(-10*time.Second), expectedEnd.Add(10*time.Second)) } diff --git a/internal/api/oidc/server_integration_test.go b/internal/api/oidc/integration_test/server_test.go similarity index 92% rename from internal/api/oidc/server_integration_test.go rename to internal/api/oidc/integration_test/server_test.go index 8a0c8796fa..fcf9bfb65e 100644 --- a/internal/api/oidc/server_integration_test.go +++ b/internal/api/oidc/integration_test/server_test.go @@ -18,8 +18,10 @@ import ( ) func TestServer_RefreshToken_Status(t *testing.T) { - clientID, _ := createClient(t) - provider, err := Tester.CreateRelyingParty(CTX, clientID, redirectURI) + t.Parallel() + + clientID, _ := createClient(t, Instance) + provider, err := Instance.CreateRelyingParty(CTX, clientID, redirectURI) require.NoError(t, err) tests := []struct { diff --git a/internal/api/oidc/token_client_credentials_integration_test.go b/internal/api/oidc/integration_test/token_client_credentials_test.go similarity index 80% rename from internal/api/oidc/token_client_credentials_integration_test.go rename to internal/api/oidc/integration_test/token_client_credentials_test.go index 21a1c4de75..5316202e31 100644 --- a/internal/api/oidc/token_client_credentials_integration_test.go +++ b/internal/api/oidc/integration_test/token_client_credentials_test.go @@ -19,7 +19,9 @@ import ( ) func TestServer_ClientCredentialsExchange(t *testing.T) { - machine, name, clientID, clientSecret, err := Tester.CreateOIDCCredentialsClient(CTX) + t.Parallel() + + machine, name, clientID, clientSecret, err := Instance.CreateOIDCCredentialsClient(CTX) require.NoError(t, err) type claims struct { @@ -57,7 +59,7 @@ func TestServer_ClientCredentialsExchange(t *testing.T) { name: "machine user without secret error", clientID: func() string { name := gofakeit.Username() - _, err := Tester.Client.Mgmt.AddMachineUser(CTX, &management.AddMachineUserRequest{ + _, err := Instance.Client.Mgmt.AddMachineUser(CTX, &management.AddMachineUserRequest{ Name: name, UserName: name, AccessTokenType: user.AccessTokenType_ACCESS_TOKEN_TYPE_JWT, @@ -99,14 +101,14 @@ func TestServer_ClientCredentialsExchange(t *testing.T) { clientSecret: clientSecret, scope: []string{ oidc.ScopeOpenID, - domain.OrgIDScope + Tester.Organisation.ID, - domain.OrgDomainPrimaryScope + Tester.Organisation.Domain, + domain.OrgIDScope + Instance.DefaultOrg.Id, + domain.OrgDomainPrimaryScope + Instance.DefaultOrg.PrimaryDomain, }, wantClaims: claims{ - resourceOwnerID: Tester.Organisation.ID, - resourceOwnerName: Tester.Organisation.Name, - resourceOwnerPrimaryDomain: Tester.Organisation.Domain, - orgDomain: Tester.Organisation.Domain, + resourceOwnerID: Instance.DefaultOrg.Id, + resourceOwnerName: Instance.DefaultOrg.Name, + resourceOwnerPrimaryDomain: Instance.DefaultOrg.PrimaryDomain, + orgDomain: Instance.DefaultOrg.PrimaryDomain, }, }, { @@ -115,10 +117,10 @@ func TestServer_ClientCredentialsExchange(t *testing.T) { clientSecret: clientSecret, scope: []string{ oidc.ScopeOpenID, - domain.OrgDomainPrimaryScope + Tester.Organisation.Domain, + domain.OrgDomainPrimaryScope + Instance.DefaultOrg.PrimaryDomain, domain.OrgDomainPrimaryScope + "foo"}, wantClaims: claims{ - orgDomain: Tester.Organisation.Domain, + orgDomain: Instance.DefaultOrg.PrimaryDomain, }, }, { @@ -126,19 +128,19 @@ func TestServer_ClientCredentialsExchange(t *testing.T) { clientID: clientID, clientSecret: clientSecret, scope: []string{oidc.ScopeOpenID, - domain.OrgIDScope + Tester.Organisation.ID, + domain.OrgIDScope + Instance.DefaultOrg.Id, domain.OrgIDScope + "foo", }, wantClaims: claims{ - resourceOwnerID: Tester.Organisation.ID, - resourceOwnerName: Tester.Organisation.Name, - resourceOwnerPrimaryDomain: Tester.Organisation.Domain, + resourceOwnerID: Instance.DefaultOrg.Id, + resourceOwnerName: Instance.DefaultOrg.Name, + resourceOwnerPrimaryDomain: Instance.DefaultOrg.PrimaryDomain, }, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - provider, err := rp.NewRelyingPartyOIDC(CTX, Tester.OIDCIssuer(), tt.clientID, tt.clientSecret, redirectURI, tt.scope) + provider, err := rp.NewRelyingPartyOIDC(CTX, Instance.OIDCIssuer(), tt.clientID, tt.clientSecret, redirectURI, tt.scope) require.NoError(t, err) tokens, err := rp.ClientCredentials(CTX, provider, nil) if tt.wantErr { diff --git a/internal/api/oidc/token_exchange_integration_test.go b/internal/api/oidc/integration_test/token_exchange_test.go similarity index 91% rename from internal/api/oidc/token_exchange_integration_test.go rename to internal/api/oidc/integration_test/token_exchange_test.go index b8b4b0bbfe..26c4475024 100644 --- a/internal/api/oidc/token_exchange_integration_test.go +++ b/internal/api/oidc/integration_test/token_exchange_test.go @@ -26,9 +26,9 @@ import ( ) func setTokenExchangeFeature(t *testing.T, value bool) { - iamCTX := Tester.WithAuthorization(CTX, integration.IAMOwner) + iamCTX := Instance.WithAuthorization(CTX, integration.UserTypeIAMOwner) - _, err := Tester.Client.FeatureV2.SetInstanceFeatures(iamCTX, &feature.SetInstanceFeaturesRequest{ + _, err := Instance.Client.FeatureV2.SetInstanceFeatures(iamCTX, &feature.SetInstanceFeaturesRequest{ OidcTokenExchange: proto.Bool(value), }) require.NoError(t, err) @@ -36,19 +36,19 @@ func setTokenExchangeFeature(t *testing.T, value bool) { } func resetFeatures(t *testing.T) { - iamCTX := Tester.WithAuthorization(CTX, integration.IAMOwner) - _, err := Tester.Client.FeatureV2.ResetInstanceFeatures(iamCTX, &feature.ResetInstanceFeaturesRequest{}) + iamCTX := Instance.WithAuthorization(CTX, integration.UserTypeIAMOwner) + _, err := Instance.Client.FeatureV2.ResetInstanceFeatures(iamCTX, &feature.ResetInstanceFeaturesRequest{}) require.NoError(t, err) time.Sleep(time.Second) } func setImpersonationPolicy(t *testing.T, value bool) { - iamCTX := Tester.WithAuthorization(CTX, integration.IAMOwner) + iamCTX := Instance.WithAuthorization(CTX, integration.UserTypeIAMOwner) - policy, err := Tester.Client.Admin.GetSecurityPolicy(iamCTX, &admin.GetSecurityPolicyRequest{}) + policy, err := Instance.Client.Admin.GetSecurityPolicy(iamCTX, &admin.GetSecurityPolicyRequest{}) require.NoError(t, err) if policy.GetPolicy().GetEnableImpersonation() != value { - _, err = Tester.Client.Admin.SetSecurityPolicy(iamCTX, &admin.SetSecurityPolicyRequest{ + _, err = Instance.Client.Admin.SetSecurityPolicy(iamCTX, &admin.SetSecurityPolicyRequest{ EnableImpersonation: value, }) require.NoError(t, err) @@ -57,8 +57,8 @@ func setImpersonationPolicy(t *testing.T, value bool) { } func createMachineUserPATWithMembership(t *testing.T, roles ...string) (userID, pat string) { - iamCTX := Tester.WithAuthorization(CTX, integration.IAMOwner) - userID, pat, err := Tester.CreateMachineUserPATWithMembership(iamCTX, roles...) + iamCTX := Instance.WithAuthorization(CTX, integration.UserTypeIAMOwner) + userID, pat, err := Instance.CreateMachineUserPATWithMembership(iamCTX, roles...) require.NoError(t, err) return userID, pat } @@ -112,16 +112,18 @@ func refreshTokenVerifier(ctx context.Context, provider rp.RelyingParty, subject } func TestServer_TokenExchange(t *testing.T) { + t.Parallel() + t.Cleanup(func() { resetFeatures(t) setImpersonationPolicy(t, false) }) - client, keyData, err := Tester.CreateOIDCTokenExchangeClient(CTX) + client, keyData, err := Instance.CreateOIDCTokenExchangeClient(CTX) require.NoError(t, err) signer, err := rp.SignerFromKeyFile(keyData)() require.NoError(t, err) - exchanger, err := tokenexchange.NewTokenExchangerJWTProfile(CTX, Tester.OIDCIssuer(), client.GetClientId(), signer) + exchanger, err := tokenexchange.NewTokenExchangerJWTProfile(CTX, Instance.OIDCIssuer(), client.GetClientId(), signer) require.NoError(t, err) time.Sleep(time.Second) @@ -137,9 +139,9 @@ func TestServer_TokenExchange(t *testing.T) { patScopes := oidc.SpaceDelimitedArray{"openid", "profile", "urn:zitadel:iam:user:metadata", "urn:zitadel:iam:user:resourceowner"} - relyingParty, err := rp.NewRelyingPartyOIDC(CTX, Tester.OIDCIssuer(), client.GetClientId(), "", "", []string{"openid"}, rp.WithJWTProfile(rp.SignerFromKeyFile(keyData))) + relyingParty, err := rp.NewRelyingPartyOIDC(CTX, Instance.OIDCIssuer(), client.GetClientId(), "", "", []string{"openid"}, rp.WithJWTProfile(rp.SignerFromKeyFile(keyData))) require.NoError(t, err) - resourceServer, err := Tester.CreateResourceServerJWTProfile(CTX, keyData) + resourceServer, err := Instance.CreateResourceServerJWTProfile(CTX, keyData) require.NoError(t, err) type settings struct { @@ -481,7 +483,7 @@ func TestServer_TokenExchange(t *testing.T) { token, err := crypto.Sign(&oidc.JWTTokenRequest{ Issuer: client.GetClientId(), Subject: User.GetUserId(), - Audience: oidc.Audience{Tester.OIDCIssuer()}, + Audience: oidc.Audience{Instance.OIDCIssuer()}, ExpiresAt: oidc.FromTime(time.Now().Add(time.Hour)), IssuedAt: oidc.FromTime(time.Now().Add(-time.Second)), }, signer) @@ -559,13 +561,13 @@ func TestServer_TokenExchange(t *testing.T) { // This test tries to call the zitadel API with an impersonated token, // which should fail. func TestImpersonation_API_Call(t *testing.T) { - client, keyData, err := Tester.CreateOIDCTokenExchangeClient(CTX) + client, keyData, err := Instance.CreateOIDCTokenExchangeClient(CTX) require.NoError(t, err) signer, err := rp.SignerFromKeyFile(keyData)() require.NoError(t, err) - exchanger, err := tokenexchange.NewTokenExchangerJWTProfile(CTX, Tester.OIDCIssuer(), client.GetClientId(), signer) + exchanger, err := tokenexchange.NewTokenExchangerJWTProfile(CTX, Instance.OIDCIssuer(), client.GetClientId(), signer) require.NoError(t, err) - resourceServer, err := Tester.CreateResourceServerJWTProfile(CTX, keyData) + resourceServer, err := Instance.CreateResourceServerJWTProfile(CTX, keyData) require.NoError(t, err) setTokenExchangeFeature(t, true) @@ -576,15 +578,15 @@ func TestImpersonation_API_Call(t *testing.T) { }) iamUserID, iamImpersonatorPAT := createMachineUserPATWithMembership(t, "IAM_ADMIN_IMPERSONATOR") - iamOwner := Tester.Users.Get(integration.FirstInstanceUsersKey, integration.IAMOwner) + iamOwner := Instance.Users.Get(integration.UserTypeIAMOwner) // impersonating the IAM owner! resp, err := tokenexchange.ExchangeToken(CTX, exchanger, iamOwner.Token, oidc.AccessTokenType, iamImpersonatorPAT, oidc.AccessTokenType, nil, nil, nil, oidc.AccessTokenType) require.NoError(t, err) accessTokenVerifier(CTX, resourceServer, iamOwner.ID, iamUserID) - impersonatedCTX := Tester.WithAuthorizationToken(CTX, resp.AccessToken) - _, err = Tester.Client.Admin.GetAllowedLanguages(impersonatedCTX, &admin.GetAllowedLanguagesRequest{}) + impersonatedCTX := integration.WithAuthorizationToken(CTX, resp.AccessToken) + _, err = Instance.Client.Admin.GetAllowedLanguages(impersonatedCTX, &admin.GetAllowedLanguagesRequest{}) status := status.Convert(err) assert.Equal(t, codes.PermissionDenied, status.Code()) assert.Equal(t, "Errors.TokenExchange.Token.NotForAPI (APP-Shi0J)", status.Message()) diff --git a/internal/api/oidc/token_jwt_profile_integration_test.go b/internal/api/oidc/integration_test/token_jwt_profile_test.go similarity index 76% rename from internal/api/oidc/token_jwt_profile_integration_test.go rename to internal/api/oidc/integration_test/token_jwt_profile_test.go index 0ad8d76da2..24fad41a2b 100644 --- a/internal/api/oidc/token_jwt_profile_integration_test.go +++ b/internal/api/oidc/integration_test/token_jwt_profile_test.go @@ -17,7 +17,9 @@ import ( ) func TestServer_JWTProfile(t *testing.T) { - user, name, keyData, err := Tester.CreateOIDCJWTProfileClient(CTX) + t.Parallel() + + user, name, keyData, err := Instance.CreateOIDCJWTProfileClient(CTX) require.NoError(t, err) type claims struct { @@ -56,14 +58,14 @@ func TestServer_JWTProfile(t *testing.T) { keyData: keyData, scope: []string{ oidc.ScopeOpenID, - domain.OrgIDScope + Tester.Organisation.ID, - domain.OrgDomainPrimaryScope + Tester.Organisation.Domain, + domain.OrgIDScope + Instance.DefaultOrg.Id, + domain.OrgDomainPrimaryScope + Instance.DefaultOrg.PrimaryDomain, }, wantClaims: claims{ - resourceOwnerID: Tester.Organisation.ID, - resourceOwnerName: Tester.Organisation.Name, - resourceOwnerPrimaryDomain: Tester.Organisation.Domain, - orgDomain: Tester.Organisation.Domain, + resourceOwnerID: Instance.DefaultOrg.Id, + resourceOwnerName: Instance.DefaultOrg.Name, + resourceOwnerPrimaryDomain: Instance.DefaultOrg.PrimaryDomain, + orgDomain: Instance.DefaultOrg.PrimaryDomain, }, }, { @@ -71,29 +73,29 @@ func TestServer_JWTProfile(t *testing.T) { keyData: keyData, scope: []string{ oidc.ScopeOpenID, - domain.OrgDomainPrimaryScope + Tester.Organisation.Domain, + domain.OrgDomainPrimaryScope + Instance.DefaultOrg.PrimaryDomain, domain.OrgDomainPrimaryScope + "foo"}, wantClaims: claims{ - orgDomain: Tester.Organisation.Domain, + orgDomain: Instance.DefaultOrg.PrimaryDomain, }, }, { name: "invalid org id filtered", keyData: keyData, scope: []string{oidc.ScopeOpenID, - domain.OrgIDScope + Tester.Organisation.ID, + domain.OrgIDScope + Instance.DefaultOrg.Id, domain.OrgIDScope + "foo", }, wantClaims: claims{ - resourceOwnerID: Tester.Organisation.ID, - resourceOwnerName: Tester.Organisation.Name, - resourceOwnerPrimaryDomain: Tester.Organisation.Domain, + resourceOwnerID: Instance.DefaultOrg.Id, + resourceOwnerName: Instance.DefaultOrg.Name, + resourceOwnerPrimaryDomain: Instance.DefaultOrg.PrimaryDomain, }, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - tokenSource, err := profile.NewJWTProfileTokenSourceFromKeyFileData(CTX, Tester.OIDCIssuer(), tt.keyData, tt.scope) + tokenSource, err := profile.NewJWTProfileTokenSourceFromKeyFileData(CTX, Instance.OIDCIssuer(), tt.keyData, tt.scope) require.NoError(t, err) tokens, err := tokenSource.TokenCtx(CTX) @@ -104,7 +106,7 @@ func TestServer_JWTProfile(t *testing.T) { require.NoError(t, err) require.NotNil(t, tokens) - provider, err := rp.NewRelyingPartyOIDC(CTX, Tester.OIDCIssuer(), "", "", redirectURI, tt.scope) + provider, err := rp.NewRelyingPartyOIDC(CTX, Instance.OIDCIssuer(), "", "", redirectURI, tt.scope) require.NoError(t, err) userinfo, err := rp.Userinfo[*oidc.UserInfo](CTX, tokens.AccessToken, oidc.BearerToken, user.GetUserId(), provider) require.NoError(t, err) diff --git a/internal/api/oidc/userinfo_integration_test.go b/internal/api/oidc/integration_test/userinfo_test.go similarity index 82% rename from internal/api/oidc/userinfo_integration_test.go rename to internal/api/oidc/integration_test/userinfo_test.go index e938078078..c2ad3be3ec 100644 --- a/internal/api/oidc/userinfo_integration_test.go +++ b/internal/api/oidc/integration_test/userinfo_test.go @@ -27,9 +27,9 @@ import ( // userinfo integration test against a matrix of different feature flags. // This ensure that the response of the different implementations remains the same. func TestServer_UserInfo(t *testing.T) { - iamOwnerCTX := Tester.WithAuthorization(CTX, integration.IAMOwner) + iamOwnerCTX := Instance.WithAuthorization(CTX, integration.UserTypeIAMOwner) t.Cleanup(func() { - _, err := Tester.Client.FeatureV2.ResetInstanceFeatures(iamOwnerCTX, &feature.ResetInstanceFeaturesRequest{}) + _, err := Instance.Client.FeatureV2.ResetInstanceFeatures(iamOwnerCTX, &feature.ResetInstanceFeaturesRequest{}) require.NoError(t, err) }) tests := []struct { @@ -67,7 +67,7 @@ func TestServer_UserInfo(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - _, err := Tester.Client.FeatureV2.SetInstanceFeatures(iamOwnerCTX, &feature.SetInstanceFeaturesRequest{ + _, err := Instance.Client.FeatureV2.SetInstanceFeatures(iamOwnerCTX, &feature.SetInstanceFeaturesRequest{ OidcLegacyIntrospection: &tt.legacy, OidcTriggerIntrospectionProjections: &tt.trigger, WebKey: &tt.webKey, @@ -86,7 +86,7 @@ func testServer_UserInfo(t *testing.T) { roleBar = "bar" ) - clientID, projectID := createClient(t) + clientID, projectID := createClient(t, Instance) addProjectRolesGrants(t, User.GetUserId(), projectID, roleFoo, roleBar) tests := []struct { @@ -133,21 +133,21 @@ func testServer_UserInfo(t *testing.T) { { name: "project role assertion", prepare: func(t *testing.T, clientID string, scope []string) *oidc.Tokens[*oidc.IDTokenClaims] { - _, err := Tester.Client.Mgmt.UpdateProject(CTX, &management.UpdateProjectRequest{ + _, err := Instance.Client.Mgmt.UpdateProject(CTX, &management.UpdateProjectRequest{ Id: projectID, Name: fmt.Sprintf("project-%d", time.Now().UnixNano()), ProjectRoleAssertion: true, }) require.NoError(t, err) t.Cleanup(func() { - _, err := Tester.Client.Mgmt.UpdateProject(CTX, &management.UpdateProjectRequest{ + _, err := Instance.Client.Mgmt.UpdateProject(CTX, &management.UpdateProjectRequest{ Id: projectID, Name: fmt.Sprintf("project-%d", time.Now().UnixNano()), ProjectRoleAssertion: false, }) require.NoError(t, err) }) - resp, err := Tester.Client.Mgmt.GetProjectByID(CTX, &management.GetProjectByIDRequest{Id: projectID}) + resp, err := Instance.Client.Mgmt.GetProjectByID(CTX, &management.GetProjectByIDRequest{Id: projectID}) require.NoError(t, err) require.True(t, resp.GetProject().GetProjectRoleAssertion(), "project role assertion") @@ -157,7 +157,7 @@ func testServer_UserInfo(t *testing.T) { assertions: []func(*testing.T, *oidc.UserInfo){ assertUserinfo, func(t *testing.T, ui *oidc.UserInfo) { - assertProjectRoleClaims(t, projectID, ui.Claims, true, []string{roleFoo, roleBar}, []string{Tester.Organisation.ID}) + assertProjectRoleClaims(t, projectID, ui.Claims, true, []string{roleFoo, roleBar}, []string{Instance.DefaultOrg.Id}) }, }, }, @@ -170,7 +170,7 @@ func testServer_UserInfo(t *testing.T) { assertions: []func(*testing.T, *oidc.UserInfo){ assertUserinfo, func(t *testing.T, ui *oidc.UserInfo) { - assertProjectRoleClaims(t, projectID, ui.Claims, true, []string{roleFoo}, []string{Tester.Organisation.ID}) + assertProjectRoleClaims(t, projectID, ui.Claims, true, []string{roleFoo}, []string{Instance.DefaultOrg.Id}) }, }, }, @@ -184,14 +184,14 @@ func testServer_UserInfo(t *testing.T) { assertions: []func(*testing.T, *oidc.UserInfo){ assertUserinfo, func(t *testing.T, ui *oidc.UserInfo) { - assertProjectRoleClaims(t, projectID, ui.Claims, true, []string{roleFoo}, []string{Tester.Organisation.ID}) + assertProjectRoleClaims(t, projectID, ui.Claims, true, []string{roleFoo}, []string{Instance.DefaultOrg.Id}) }, }, }, { name: "PAT", prepare: func(t *testing.T, clientID string, scope []string) *oidc.Tokens[*oidc.IDTokenClaims] { - user := Tester.Users.Get(integration.FirstInstanceUsersKey, integration.OrgOwner) + user := Instance.Users.Get(integration.UserTypeOrgOwner) return &oidc.Tokens[*oidc.IDTokenClaims]{ Token: &oauth2.Token{ AccessToken: user.Token, @@ -206,11 +206,8 @@ func testServer_UserInfo(t *testing.T) { }, assertions: []func(*testing.T, *oidc.UserInfo){ func(t *testing.T, ui *oidc.UserInfo) { - user := Tester.Users.Get(integration.FirstInstanceUsersKey, integration.OrgOwner) + user := Instance.Users.Get(integration.UserTypeOrgOwner) assert.Equal(t, user.ID, ui.Subject) - assert.Equal(t, user.PreferredLoginName, ui.PreferredUsername) - assert.Equal(t, user.Machine.Name, ui.Name) - assert.Equal(t, user.ResourceOwner, ui.Claims[oidc_api.ClaimResourceOwnerID]) assert.NotEmpty(t, ui.Claims[oidc_api.ClaimResourceOwnerName]) assert.NotEmpty(t, ui.Claims[oidc_api.ClaimResourceOwnerPrimaryDomain]) }, @@ -220,7 +217,7 @@ func testServer_UserInfo(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { tokens := tt.prepare(t, clientID, tt.scope) - provider, err := Tester.CreateRelyingParty(CTX, clientID, redirectURI) + provider, err := Instance.CreateRelyingParty(CTX, clientID, redirectURI) require.NoError(t, err) userinfo, err := rp.Userinfo[*oidc.UserInfo](CTX, tokens.AccessToken, tokens.TokenType, tokens.IDTokenClaims.Subject, provider) if tt.wantErr { @@ -242,17 +239,17 @@ func TestServer_UserInfo_OrgIDRoles(t *testing.T) { roleFoo = "foo" roleBar = "bar" ) - clientID, projectID := createClient(t) + clientID, projectID := createClient(t, Instance) addProjectRolesGrants(t, User.GetUserId(), projectID, roleFoo, roleBar) grantedOrgID := addProjectOrgGrant(t, User.GetUserId(), projectID, roleFoo, roleBar) - _, err := Tester.Client.Mgmt.UpdateProject(CTX, &management.UpdateProjectRequest{ + _, err := Instance.Client.Mgmt.UpdateProject(CTX, &management.UpdateProjectRequest{ Id: projectID, Name: fmt.Sprintf("project-%d", time.Now().UnixNano()), ProjectRoleAssertion: true, }) require.NoError(t, err) - resp, err := Tester.Client.Mgmt.GetProjectByID(CTX, &management.GetProjectByIDRequest{Id: projectID}) + resp, err := Instance.Client.Mgmt.GetProjectByID(CTX, &management.GetProjectByIDRequest{Id: projectID}) require.NoError(t, err) require.True(t, resp.GetProject().GetProjectRoleAssertion(), "project role assertion") @@ -266,7 +263,7 @@ func TestServer_UserInfo_OrgIDRoles(t *testing.T) { scope: []string{ oidc.ScopeOpenID, oidc.ScopeOfflineAccess, }, - wantRoleOrgIDs: []string{Tester.Organisation.ID, grantedOrgID}, + wantRoleOrgIDs: []string{Instance.DefaultOrg.Id, grantedOrgID}, }, { name: "only granted org", @@ -279,24 +276,24 @@ func TestServer_UserInfo_OrgIDRoles(t *testing.T) { name: "only own org", scope: []string{ oidc.ScopeOpenID, oidc.ScopeOfflineAccess, - domain.OrgRoleIDScope + Tester.Organisation.ID, + domain.OrgRoleIDScope + Instance.DefaultOrg.Id, }, - wantRoleOrgIDs: []string{Tester.Organisation.ID}, + wantRoleOrgIDs: []string{Instance.DefaultOrg.Id}, }, { name: "request both orgs", scope: []string{ oidc.ScopeOpenID, oidc.ScopeOfflineAccess, - domain.OrgRoleIDScope + Tester.Organisation.ID, + domain.OrgRoleIDScope + Instance.DefaultOrg.Id, domain.OrgRoleIDScope + grantedOrgID, }, - wantRoleOrgIDs: []string{Tester.Organisation.ID, grantedOrgID}, + wantRoleOrgIDs: []string{Instance.DefaultOrg.Id, grantedOrgID}, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { tokens := getTokens(t, clientID, tt.scope) - provider, err := Tester.CreateRelyingParty(CTX, clientID, redirectURI) + provider, err := Instance.CreateRelyingParty(CTX, clientID, redirectURI) require.NoError(t, err) userinfo, err := rp.Userinfo[*oidc.UserInfo](CTX, tokens.AccessToken, tokens.TokenType, tokens.IDTokenClaims.Subject, provider) require.NoError(t, err) @@ -312,10 +309,10 @@ func TestServer_UserInfo_Issue6662(t *testing.T) { roleBar = "bar" ) - project, err := Tester.CreateProject(CTX) + project, err := Instance.CreateProject(CTX) projectID := project.GetId() require.NoError(t, err) - user, _, clientID, clientSecret, err := Tester.CreateOIDCCredentialsClient(CTX) + user, _, clientID, clientSecret, err := Instance.CreateOIDCCredentialsClient(CTX) require.NoError(t, err) addProjectRolesGrants(t, user.GetUserId(), projectID, roleFoo, roleBar) @@ -324,14 +321,14 @@ func TestServer_UserInfo_Issue6662(t *testing.T) { domain.ProjectIDScope + projectID + domain.AudSuffix, } - provider, err := rp.NewRelyingPartyOIDC(CTX, Tester.OIDCIssuer(), clientID, clientSecret, redirectURI, scope) + provider, err := rp.NewRelyingPartyOIDC(CTX, Instance.OIDCIssuer(), clientID, clientSecret, redirectURI, scope) require.NoError(t, err) tokens, err := rp.ClientCredentials(CTX, provider, nil) require.NoError(t, err) userinfo, err := rp.Userinfo[*oidc.UserInfo](CTX, tokens.AccessToken, tokens.TokenType, user.GetUserId(), provider) require.NoError(t, err) - assertProjectRoleClaims(t, projectID, userinfo.Claims, false, []string{roleFoo}, []string{Tester.Organisation.ID}) + assertProjectRoleClaims(t, projectID, userinfo.Claims, false, []string{roleFoo}, []string{Instance.DefaultOrg.Id}) } func addProjectRolesGrants(t *testing.T, userID, projectID string, roles ...string) { @@ -343,12 +340,12 @@ func addProjectRolesGrants(t *testing.T, userID, projectID string, roles ...stri DisplayName: role, } } - _, err := Tester.Client.Mgmt.BulkAddProjectRoles(CTX, &management.BulkAddProjectRolesRequest{ + _, err := Instance.Client.Mgmt.BulkAddProjectRoles(CTX, &management.BulkAddProjectRolesRequest{ ProjectId: projectID, Roles: bulkRoles, }) require.NoError(t, err) - _, err = Tester.Client.Mgmt.AddUserGrant(CTX, &management.AddUserGrantRequest{ + _, err = Instance.Client.Mgmt.AddUserGrant(CTX, &management.AddUserGrantRequest{ UserId: userID, ProjectId: projectID, RoleKeys: roles, @@ -359,8 +356,8 @@ func addProjectRolesGrants(t *testing.T, userID, projectID string, roles ...stri // addProjectOrgGrant adds a new organization which will be granted on the projectID with the specified roles. // The userID will be granted in the new organization to the project with the same roles. func addProjectOrgGrant(t *testing.T, userID, projectID string, roles ...string) (grantedOrgID string) { - grantedOrg := Tester.CreateOrganization(CTXIAM, fmt.Sprintf("ZITADEL_GRANTED_%d", time.Now().UnixNano()), fmt.Sprintf("%d@mouse.com", time.Now().UnixNano())) - projectGrant, err := Tester.Client.Mgmt.AddProjectGrant(CTX, &management.AddProjectGrantRequest{ + grantedOrg := Instance.CreateOrganization(CTXIAM, fmt.Sprintf("ZITADEL_GRANTED_%d", time.Now().UnixNano()), fmt.Sprintf("%d@mouse.com", time.Now().UnixNano())) + projectGrant, err := Instance.Client.Mgmt.AddProjectGrant(CTX, &management.AddProjectGrantRequest{ ProjectId: projectID, GrantedOrgId: grantedOrg.GetOrganizationId(), RoleKeys: roles, @@ -368,7 +365,7 @@ func addProjectOrgGrant(t *testing.T, userID, projectID string, roles ...string) require.NoError(t, err) ctxOrg := metadata.AppendToOutgoingContext(CTXIAM, "x-zitadel-orgid", grantedOrg.GetOrganizationId()) - _, err = Tester.Client.Mgmt.AddUserGrant(ctxOrg, &management.AddUserGrantRequest{ + _, err = Instance.Client.Mgmt.AddUserGrant(ctxOrg, &management.AddUserGrantRequest{ UserId: userID, ProjectId: projectID, ProjectGrantId: projectGrant.GetGrantId(), @@ -379,9 +376,9 @@ func addProjectOrgGrant(t *testing.T, userID, projectID string, roles ...string) } func getTokens(t *testing.T, clientID string, scope []string) *oidc.Tokens[*oidc.IDTokenClaims] { - authRequestID := createAuthRequest(t, clientID, redirectURI, scope...) - sessionID, sessionToken, startTime, changeTime := Tester.CreateVerifiedWebAuthNSession(t, CTXLOGIN, User.GetUserId()) - linkResp, err := Tester.Client.OIDCv2.CreateCallback(CTXLOGIN, &oidc_pb.CreateCallbackRequest{ + authRequestID := createAuthRequest(t, Instance, clientID, redirectURI, scope...) + sessionID, sessionToken, startTime, changeTime := Instance.CreateVerifiedWebAuthNSession(t, CTXLOGIN, User.GetUserId()) + linkResp, err := Instance.Client.OIDCv2.CreateCallback(CTXLOGIN, &oidc_pb.CreateCallbackRequest{ AuthRequestId: authRequestID, CallbackKind: &oidc_pb.CreateCallbackRequest_Session{ Session: &oidc_pb.Session{ @@ -394,7 +391,7 @@ func getTokens(t *testing.T, clientID string, scope []string) *oidc.Tokens[*oidc // code exchange code := assertCodeResponse(t, linkResp.GetCallbackUrl()) - tokens, err := exchangeTokens(t, clientID, code, redirectURI) + tokens, err := exchangeTokens(t, Instance, clientID, code, redirectURI) require.NoError(t, err) assertTokens(t, tokens, true) assertIDTokenClaims(t, tokens.IDTokenClaims, User.GetUserId(), armPasskey, startTime, changeTime, sessionID) diff --git a/internal/api/oidc/server_test.go b/internal/api/oidc/server_test.go index 47f24fcdee..76d073151a 100644 --- a/internal/api/oidc/server_test.go +++ b/internal/api/oidc/server_test.go @@ -7,9 +7,10 @@ import ( "github.com/stretchr/testify/assert" "github.com/zitadel/oidc/v3/pkg/oidc" "github.com/zitadel/oidc/v3/pkg/op" + "golang.org/x/text/language" + "github.com/zitadel/zitadel/internal/api/authz" "github.com/zitadel/zitadel/internal/feature" - "golang.org/x/text/language" ) func TestServer_createDiscoveryConfig(t *testing.T) { diff --git a/internal/command/org_idp_test.go b/internal/command/org_idp_test.go index e1fa43e879..b2b4632cf6 100644 --- a/internal/command/org_idp_test.go +++ b/internal/command/org_idp_test.go @@ -21,7 +21,7 @@ import ( "github.com/zitadel/zitadel/internal/zerrors" ) -func TestCommandSide_AddOrgGenericOAuthIDP(t *testing.T) { +func TestCommandSide_AddOrgGenericOAuthProvider(t *testing.T) { type fields struct { eventstore func(*testing.T) *eventstore.Eventstore idGenerator id.Generator diff --git a/internal/integration/assert.go b/internal/integration/assert.go index b3e63fd29e..3c5fadb373 100644 --- a/internal/integration/assert.go +++ b/internal/integration/assert.go @@ -50,7 +50,7 @@ type ResourceListDetailsMsg interface { // If the change date is populated, it is checked with a tolerance of 1 minute around Now. // // The resource owner is compared with expected. -func AssertDetails[D Details, M DetailsMsg[D]](t testing.TB, expected, actual M) { +func AssertDetails[D Details, M DetailsMsg[D]](t assert.TestingT, expected, actual M) { wantDetails, gotDetails := expected.GetDetails(), actual.GetDetails() var nilDetails D if wantDetails == nilDetails { @@ -69,7 +69,7 @@ func AssertDetails[D Details, M DetailsMsg[D]](t testing.TB, expected, actual M) assert.Equal(t, wantDetails.GetResourceOwner(), gotDetails.GetResourceOwner()) } -func AssertResourceDetails(t testing.TB, expected *resources_object.Details, actual *resources_object.Details) { +func AssertResourceDetails(t assert.TestingT, expected *resources_object.Details, actual *resources_object.Details) { if expected.GetChanged() != nil { wantChangeDate := time.Now() gotChangeDate := actual.GetChanged().AsTime() @@ -87,7 +87,7 @@ func AssertResourceDetails(t testing.TB, expected *resources_object.Details, act } } -func AssertListDetails[L ListDetails, D ListDetailsMsg[L]](t testing.TB, expected, actual D) { +func AssertListDetails[L ListDetails, D ListDetailsMsg[L]](t assert.TestingT, expected, actual D) { wantDetails, gotDetails := expected.GetDetails(), actual.GetDetails() var nilDetails L if wantDetails == nilDetails { @@ -99,11 +99,11 @@ func AssertListDetails[L ListDetails, D ListDetailsMsg[L]](t testing.TB, expecte if wantDetails.GetTimestamp() != nil { gotCD := gotDetails.GetTimestamp().AsTime() wantCD := time.Now() - assert.WithinRange(t, gotCD, wantCD.Add(-time.Minute), wantCD.Add(time.Minute)) + assert.WithinRange(t, gotCD, wantCD.Add(-10*time.Minute), wantCD.Add(time.Minute)) } } -func AssertResourceListDetails[D ResourceListDetailsMsg](t testing.TB, expected, actual D) { +func AssertResourceListDetails[D ResourceListDetailsMsg](t assert.TestingT, expected, actual D) { wantDetails, gotDetails := expected.GetDetails(), actual.GetDetails() if wantDetails == nil { assert.Nil(t, gotDetails) @@ -116,7 +116,7 @@ func AssertResourceListDetails[D ResourceListDetailsMsg](t testing.TB, expected, if wantDetails.GetTimestamp() != nil { gotCD := gotDetails.GetTimestamp().AsTime() wantCD := time.Now() - assert.WithinRange(t, gotCD, wantCD.Add(-time.Minute), wantCD.Add(time.Minute)) + assert.WithinRange(t, gotCD, wantCD.Add(-10*time.Minute), wantCD.Add(time.Minute)) } } diff --git a/internal/integration/client.go b/internal/integration/client.go index ed5f0620c4..404ec0162b 100644 --- a/internal/integration/client.go +++ b/internal/integration/client.go @@ -6,26 +6,17 @@ import ( "testing" "time" - crewjam_saml "github.com/crewjam/saml" + "github.com/brianvoe/gofakeit/v6" "github.com/muhlemmer/gu" - "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/zitadel/logging" - "github.com/zitadel/oidc/v3/pkg/oidc" - "golang.org/x/oauth2" - "golang.org/x/text/language" "google.golang.org/grpc" + "google.golang.org/grpc/credentials/insecure" "google.golang.org/grpc/metadata" "google.golang.org/protobuf/types/known/durationpb" "google.golang.org/protobuf/types/known/structpb" - "github.com/zitadel/zitadel/internal/api/authz" - "github.com/zitadel/zitadel/internal/command" "github.com/zitadel/zitadel/internal/domain" - "github.com/zitadel/zitadel/internal/idp/providers/ldap" - openid "github.com/zitadel/zitadel/internal/idp/providers/oidc" - "github.com/zitadel/zitadel/internal/idp/providers/saml" - idp_rp "github.com/zitadel/zitadel/internal/repository/idp" "github.com/zitadel/zitadel/pkg/grpc/admin" "github.com/zitadel/zitadel/pkg/grpc/auth" "github.com/zitadel/zitadel/pkg/grpc/feature/v2" @@ -37,7 +28,7 @@ import ( object_v3alpha "github.com/zitadel/zitadel/pkg/grpc/object/v3alpha" oidc_pb "github.com/zitadel/zitadel/pkg/grpc/oidc/v2" oidc_pb_v2beta "github.com/zitadel/zitadel/pkg/grpc/oidc/v2beta" - "github.com/zitadel/zitadel/pkg/grpc/org/v2" + org "github.com/zitadel/zitadel/pkg/grpc/org/v2" org_v2beta "github.com/zitadel/zitadel/pkg/grpc/org/v2beta" action "github.com/zitadel/zitadel/pkg/grpc/resources/action/v3alpha" user_v3alpha "github.com/zitadel/zitadel/pkg/grpc/resources/user/v3alpha" @@ -47,7 +38,6 @@ import ( session_v2beta "github.com/zitadel/zitadel/pkg/grpc/session/v2beta" "github.com/zitadel/zitadel/pkg/grpc/settings/v2" settings_v2beta "github.com/zitadel/zitadel/pkg/grpc/settings/v2beta" - "github.com/zitadel/zitadel/pkg/grpc/system" user_pb "github.com/zitadel/zitadel/pkg/grpc/user" user_v2 "github.com/zitadel/zitadel/pkg/grpc/user/v2" user_v2beta "github.com/zitadel/zitadel/pkg/grpc/user/v2beta" @@ -68,7 +58,6 @@ type Client struct { OIDCv2 oidc_pb.OIDCServiceClient OrgV2beta org_v2beta.OrganizationServiceClient OrgV2 org.OrganizationServiceClient - System system.SystemServiceClient ActionV3Alpha action.ZITADELActionsClient FeatureV2beta feature_v2beta.FeatureServiceClient FeatureV2 feature.FeatureServiceClient @@ -78,8 +67,14 @@ type Client struct { UserV3Alpha user_v3alpha.ZITADELUsersClient } -func newClient(cc *grpc.ClientConn) Client { - return Client{ +func newClient(ctx context.Context, target string) (*Client, error) { + cc, err := grpc.NewClient(target, + grpc.WithTransportCredentials(insecure.NewCredentials()), + ) + if err != nil { + return nil, err + } + client := &Client{ CC: cc, Admin: admin.NewAdminServiceClient(cc), Mgmt: mgmt.NewManagementServiceClient(cc), @@ -94,7 +89,6 @@ func newClient(cc *grpc.ClientConn) Client { OIDCv2: oidc_pb.NewOIDCServiceClient(cc), OrgV2beta: org_v2beta.NewOrganizationServiceClient(cc), OrgV2: org.NewOrganizationServiceClient(cc), - System: system.NewSystemServiceClient(cc), ActionV3Alpha: action.NewZITADELActionsClient(cc), FeatureV2beta: feature_v2beta.NewFeatureServiceClient(cc), FeatureV2: feature.NewFeatureServiceClient(cc), @@ -103,60 +97,38 @@ func newClient(cc *grpc.ClientConn) Client { IDPv2: idp_pb.NewIdentityProviderServiceClient(cc), UserV3Alpha: user_v3alpha.NewZITADELUsersClient(cc), } + return client, client.pollHealth(ctx) } -func (t *Tester) UseIsolatedInstance(tt *testing.T, iamOwnerCtx, systemCtx context.Context) (primaryDomain, instanceId, adminID string, authenticatedIamOwnerCtx context.Context) { - primaryDomain = RandString(5) + ".integration.localhost" - instance, err := t.Client.System.CreateInstance(systemCtx, &system.CreateInstanceRequest{ - InstanceName: "testinstance", - CustomDomain: primaryDomain, - Owner: &system.CreateInstanceRequest_Machine_{ - Machine: &system.CreateInstanceRequest_Machine{ - UserName: "owner", - Name: "owner", - PersonalAccessToken: &system.CreateInstanceRequest_PersonalAccessToken{}, - }, - }, - }) - require.NoError(tt, err) - t.createClientConn(iamOwnerCtx, fmt.Sprintf("%s:%d", primaryDomain, t.Config.Port)) - instanceId = instance.GetInstanceId() - owner, err := t.Queries.GetUserByLoginName(authz.WithInstanceID(iamOwnerCtx, instanceId), true, "owner@"+primaryDomain) - require.NoError(tt, err) - t.Users.Set(instanceId, IAMOwner, &User{ - User: owner, - Token: instance.GetPat(), - }) - newCtx := t.WithInstanceAuthorization(iamOwnerCtx, IAMOwner, instanceId) - var adminUser *mgmt.ImportHumanUserResponse - // the following serves two purposes: - // 1. it ensures that the instance is ready to be used - // 2. it enables a normal login with the default admin user credentials - require.EventuallyWithT(tt, func(collectT *assert.CollectT) { - var importErr error - adminUser, importErr = t.Client.Mgmt.ImportHumanUser(newCtx, &mgmt.ImportHumanUserRequest{ - UserName: "zitadel-admin@zitadel.localhost", - Email: &mgmt.ImportHumanUserRequest_Email{ - Email: "zitadel-admin@zitadel.localhost", - IsEmailVerified: true, - }, - Password: "Password1!", - Profile: &mgmt.ImportHumanUserRequest_Profile{ - FirstName: "hodor", - LastName: "hodor", - NickName: "hodor", - }, - }) - assert.NoError(collectT, importErr) - }, 2*time.Minute, 100*time.Millisecond, "instance not ready") - return primaryDomain, instanceId, adminUser.GetUserId(), t.updateInstanceAndOrg(newCtx, fmt.Sprintf("%s:%d", primaryDomain, t.Config.ExternalPort)) +// pollHealth waits until a healthy status is reported. +func (c *Client) pollHealth(ctx context.Context) (err error) { + for { + err = func(ctx context.Context) error { + ctx, cancel := context.WithTimeout(ctx, 5*time.Second) + defer cancel() + + _, err := c.Admin.Healthz(ctx, &admin.HealthzRequest{}) + return err + }(ctx) + if err == nil { + return nil + } + logging.WithError(err).Debug("poll healthz") + + select { + case <-ctx.Done(): + return ctx.Err() + case <-time.After(time.Second): + continue + } + } } -func (s *Tester) CreateHumanUser(ctx context.Context) *user_v2.AddHumanUserResponse { - resp, err := s.Client.UserV2.AddHumanUser(ctx, &user_v2.AddHumanUserRequest{ +func (i *Instance) CreateHumanUser(ctx context.Context) *user_v2.AddHumanUserResponse { + resp, err := i.Client.UserV2.AddHumanUser(ctx, &user_v2.AddHumanUserRequest{ Organization: &object.Organization{ Org: &object.Organization_OrgId{ - OrgId: s.Organisation.ID, + OrgId: i.DefaultOrg.GetId(), }, }, Profile: &user_v2.SetHumanProfile{ @@ -178,15 +150,15 @@ func (s *Tester) CreateHumanUser(ctx context.Context) *user_v2.AddHumanUserRespo }, }, }) - logging.OnError(err).Fatal("create human user") + logging.OnError(err).Panic("create human user") return resp } -func (s *Tester) CreateHumanUserNoPhone(ctx context.Context) *user_v2.AddHumanUserResponse { - resp, err := s.Client.UserV2.AddHumanUser(ctx, &user_v2.AddHumanUserRequest{ +func (i *Instance) CreateHumanUserNoPhone(ctx context.Context) *user_v2.AddHumanUserResponse { + resp, err := i.Client.UserV2.AddHumanUser(ctx, &user_v2.AddHumanUserRequest{ Organization: &object.Organization{ Org: &object.Organization_OrgId{ - OrgId: s.Organisation.ID, + OrgId: i.DefaultOrg.GetId(), }, }, Profile: &user_v2.SetHumanProfile{ @@ -202,15 +174,15 @@ func (s *Tester) CreateHumanUserNoPhone(ctx context.Context) *user_v2.AddHumanUs }, }, }) - logging.OnError(err).Fatal("create human user") + logging.OnError(err).Panic("create human user") return resp } -func (s *Tester) CreateHumanUserWithTOTP(ctx context.Context, secret string) *user_v2.AddHumanUserResponse { - resp, err := s.Client.UserV2.AddHumanUser(ctx, &user_v2.AddHumanUserRequest{ +func (i *Instance) CreateHumanUserWithTOTP(ctx context.Context, secret string) *user_v2.AddHumanUserResponse { + resp, err := i.Client.UserV2.AddHumanUser(ctx, &user_v2.AddHumanUserRequest{ Organization: &object.Organization{ Org: &object.Organization_OrgId{ - OrgId: s.Organisation.ID, + OrgId: i.DefaultOrg.GetId(), }, }, Profile: &user_v2.SetHumanProfile{ @@ -233,12 +205,12 @@ func (s *Tester) CreateHumanUserWithTOTP(ctx context.Context, secret string) *us }, TotpSecret: gu.Ptr(secret), }) - logging.OnError(err).Fatal("create human user") + logging.OnError(err).Panic("create human user") return resp } -func (s *Tester) CreateOrganization(ctx context.Context, name, adminEmail string) *org.AddOrganizationResponse { - resp, err := s.Client.OrgV2.AddOrganization(ctx, &org.AddOrganizationRequest{ +func (i *Instance) CreateOrganization(ctx context.Context, name, adminEmail string) *org.AddOrganizationResponse { + resp, err := i.Client.OrgV2.AddOrganization(ctx, &org.AddOrganizationRequest{ Name: name, Admins: []*org.AddOrganizationRequest_Admin{ { @@ -259,12 +231,12 @@ func (s *Tester) CreateOrganization(ctx context.Context, name, adminEmail string }, }, }) - logging.OnError(err).Fatal("create org") + logging.OnError(err).Panic("create org") return resp } -func (s *Tester) DeactivateOrganization(ctx context.Context, orgID string) *mgmt.DeactivateOrgResponse { - resp, err := s.Client.Mgmt.DeactivateOrg( +func (i *Instance) DeactivateOrganization(ctx context.Context, orgID string) *mgmt.DeactivateOrgResponse { + resp, err := i.Client.Mgmt.DeactivateOrg( SetOrgID(ctx, orgID), &mgmt.DeactivateOrgRequest{}, ) @@ -281,8 +253,8 @@ func SetOrgID(ctx context.Context, orgID string) context.Context { return metadata.NewOutgoingContext(ctx, md) } -func (s *Tester) CreateOrganizationWithUserID(ctx context.Context, name, userID string) *org.AddOrganizationResponse { - resp, err := s.Client.OrgV2.AddOrganization(ctx, &org.AddOrganizationRequest{ +func (i *Instance) CreateOrganizationWithUserID(ctx context.Context, name, userID string) *org.AddOrganizationResponse { + resp, err := i.Client.OrgV2.AddOrganization(ctx, &org.AddOrganizationRequest{ Name: name, Admins: []*org.AddOrganizationRequest_Admin{ { @@ -296,8 +268,8 @@ func (s *Tester) CreateOrganizationWithUserID(ctx context.Context, name, userID return resp } -func (s *Tester) CreateHumanUserVerified(ctx context.Context, org, email string) *user_v2.AddHumanUserResponse { - resp, err := s.Client.UserV2.AddHumanUser(ctx, &user_v2.AddHumanUserRequest{ +func (i *Instance) CreateHumanUserVerified(ctx context.Context, org, email string) *user_v2.AddHumanUserResponse { + resp, err := i.Client.UserV2.AddHumanUser(ctx, &user_v2.AddHumanUserRequest{ Organization: &object.Organization{ Org: &object.Organization_OrgId{ OrgId: org, @@ -323,23 +295,23 @@ func (s *Tester) CreateHumanUserVerified(ctx context.Context, org, email string) }, }, }) - logging.OnError(err).Fatal("create human user") + logging.OnError(err).Panic("create human user") return resp } -func (s *Tester) CreateMachineUser(ctx context.Context) *mgmt.AddMachineUserResponse { - resp, err := s.Client.Mgmt.AddMachineUser(ctx, &mgmt.AddMachineUserRequest{ +func (i *Instance) CreateMachineUser(ctx context.Context) *mgmt.AddMachineUserResponse { + resp, err := i.Client.Mgmt.AddMachineUser(ctx, &mgmt.AddMachineUserRequest{ UserName: fmt.Sprintf("%d@mouse.com", time.Now().UnixNano()), Name: "Mickey", Description: "Mickey Mouse", AccessTokenType: user_pb.AccessTokenType_ACCESS_TOKEN_TYPE_BEARER, }) - logging.OnError(err).Fatal("create human user") + logging.OnError(err).Panic("create human user") return resp } -func (s *Tester) CreateUserIDPlink(ctx context.Context, userID, externalID, idpID, username string) *user_v2.AddIDPLinkResponse { - resp, err := s.Client.UserV2.AddIDPLink( +func (i *Instance) CreateUserIDPlink(ctx context.Context, userID, externalID, idpID, username string) (*user_v2.AddIDPLinkResponse, error) { + return i.Client.UserV2.AddIDPLink( ctx, &user_v2.AddIDPLinkRequest{ UserId: userID, @@ -350,67 +322,65 @@ func (s *Tester) CreateUserIDPlink(ctx context.Context, userID, externalID, idpI }, }, ) - logging.OnError(err).Fatal("create human user link") - return resp } -func (s *Tester) RegisterUserPasskey(ctx context.Context, userID string) { - reg, err := s.Client.UserV2.CreatePasskeyRegistrationLink(ctx, &user_v2.CreatePasskeyRegistrationLinkRequest{ +func (i *Instance) RegisterUserPasskey(ctx context.Context, userID string) { + reg, err := i.Client.UserV2.CreatePasskeyRegistrationLink(ctx, &user_v2.CreatePasskeyRegistrationLinkRequest{ UserId: userID, Medium: &user_v2.CreatePasskeyRegistrationLinkRequest_ReturnCode{}, }) - logging.OnError(err).Fatal("create user passkey") + logging.OnError(err).Panic("create user passkey") - pkr, err := s.Client.UserV2.RegisterPasskey(ctx, &user_v2.RegisterPasskeyRequest{ + pkr, err := i.Client.UserV2.RegisterPasskey(ctx, &user_v2.RegisterPasskeyRequest{ UserId: userID, Code: reg.GetCode(), - Domain: s.Config.ExternalDomain, + Domain: i.Domain, }) - logging.OnError(err).Fatal("create user passkey") - attestationResponse, err := s.WebAuthN.CreateAttestationResponse(pkr.GetPublicKeyCredentialCreationOptions()) - logging.OnError(err).Fatal("create user passkey") + logging.OnError(err).Panic("create user passkey") + attestationResponse, err := i.WebAuthN.CreateAttestationResponse(pkr.GetPublicKeyCredentialCreationOptions()) + logging.OnError(err).Panic("create user passkey") - _, err = s.Client.UserV2.VerifyPasskeyRegistration(ctx, &user_v2.VerifyPasskeyRegistrationRequest{ + _, err = i.Client.UserV2.VerifyPasskeyRegistration(ctx, &user_v2.VerifyPasskeyRegistrationRequest{ UserId: userID, PasskeyId: pkr.GetPasskeyId(), PublicKeyCredential: attestationResponse, PasskeyName: "nice name", }) - logging.OnError(err).Fatal("create user passkey") + logging.OnError(err).Panic("create user passkey") } -func (s *Tester) RegisterUserU2F(ctx context.Context, userID string) { - pkr, err := s.Client.UserV2.RegisterU2F(ctx, &user_v2.RegisterU2FRequest{ +func (i *Instance) RegisterUserU2F(ctx context.Context, userID string) { + pkr, err := i.Client.UserV2.RegisterU2F(ctx, &user_v2.RegisterU2FRequest{ UserId: userID, - Domain: s.Config.ExternalDomain, + Domain: i.Domain, }) - logging.OnError(err).Fatal("create user u2f") - attestationResponse, err := s.WebAuthN.CreateAttestationResponse(pkr.GetPublicKeyCredentialCreationOptions()) - logging.OnError(err).Fatal("create user u2f") + logging.OnError(err).Panic("create user u2f") + attestationResponse, err := i.WebAuthN.CreateAttestationResponse(pkr.GetPublicKeyCredentialCreationOptions()) + logging.OnError(err).Panic("create user u2f") - _, err = s.Client.UserV2.VerifyU2FRegistration(ctx, &user_v2.VerifyU2FRegistrationRequest{ + _, err = i.Client.UserV2.VerifyU2FRegistration(ctx, &user_v2.VerifyU2FRegistrationRequest{ UserId: userID, U2FId: pkr.GetU2FId(), PublicKeyCredential: attestationResponse, TokenName: "nice name", }) - logging.OnError(err).Fatal("create user u2f") + logging.OnError(err).Panic("create user u2f") } -func (s *Tester) SetUserPassword(ctx context.Context, userID, password string, changeRequired bool) *object.Details { - resp, err := s.Client.UserV2.SetPassword(ctx, &user_v2.SetPasswordRequest{ +func (i *Instance) SetUserPassword(ctx context.Context, userID, password string, changeRequired bool) *object.Details { + resp, err := i.Client.UserV2.SetPassword(ctx, &user_v2.SetPasswordRequest{ UserId: userID, NewPassword: &user_v2.Password{ Password: password, ChangeRequired: changeRequired, }, }) - logging.OnError(err).Fatal("set user password") + logging.OnError(err).Panic("set user password") return resp.GetDetails() } -func (s *Tester) AddGenericOAuthIDP(ctx context.Context, name string) *admin.AddGenericOAuthProviderResponse { - resp, err := s.Client.Admin.AddGenericOAuthProvider(ctx, &admin.AddGenericOAuthProviderRequest{ +func (i *Instance) AddGenericOAuthProvider(ctx context.Context, name string) *admin.AddGenericOAuthProviderResponse { + resp, err := i.Client.Admin.AddGenericOAuthProvider(ctx, &admin.AddGenericOAuthProviderRequest{ Name: name, ClientId: "clientID", ClientSecret: "clientSecret", @@ -427,136 +397,126 @@ func (s *Tester) AddGenericOAuthIDP(ctx context.Context, name string) *admin.Add AutoLinking: idp.AutoLinkingOption_AUTO_LINKING_OPTION_USERNAME, }, }) - logging.OnError(err).Fatal("create generic OAuth idp") - return resp -} + logging.OnError(err).Panic("create generic OAuth idp") -func (s *Tester) AddGenericOAuthProvider(t *testing.T, ctx context.Context) string { - ctx = authz.WithInstance(ctx, s.Instance) - id, _, err := s.Commands.AddInstanceGenericOAuthProvider(ctx, command.GenericOAuthProvider{ - Name: "idp", - ClientID: "clientID", - ClientSecret: "clientSecret", - AuthorizationEndpoint: "https://example.com/oauth/v2/authorize", - TokenEndpoint: "https://example.com/oauth/v2/token", - UserEndpoint: "https://api.example.com/user", - Scopes: []string{"openid", "profile", "email"}, - IDAttribute: "id", - IDPOptions: idp_rp.Options{ - IsLinkingAllowed: true, - IsCreationAllowed: true, - IsAutoCreation: true, - IsAutoUpdate: true, - }, - }) - require.NoError(t, err) - return id -} - -func (s *Tester) AddOrgGenericOAuthIDP(ctx context.Context, name string) *mgmt.AddGenericOAuthProviderResponse { - resp, err := s.Client.Mgmt.AddGenericOAuthProvider(ctx, &mgmt.AddGenericOAuthProviderRequest{ - Name: name, - ClientId: "clientID", - ClientSecret: "clientSecret", - AuthorizationEndpoint: "https://example.com/oauth/v2/authorize", - TokenEndpoint: "https://example.com/oauth/v2/token", - UserEndpoint: "https://api.example.com/user", - Scopes: []string{"openid", "profile", "email"}, - IdAttribute: "id", - ProviderOptions: &idp.Options{ - IsLinkingAllowed: true, - IsCreationAllowed: true, - IsAutoCreation: true, - IsAutoUpdate: true, - AutoLinking: idp.AutoLinkingOption_AUTO_LINKING_OPTION_USERNAME, - }, - }) - logging.OnError(err).Fatal("create generic OAuth idp") - return resp -} - -func (s *Tester) AddOrgGenericOAuthProvider(t *testing.T, ctx context.Context, orgID string) string { - ctx = authz.WithInstance(ctx, s.Instance) - id, _, err := s.Commands.AddOrgGenericOAuthProvider(ctx, orgID, - command.GenericOAuthProvider{ - Name: "idp", - ClientID: "clientID", - ClientSecret: "clientSecret", - AuthorizationEndpoint: "https://example.com/oauth/v2/authorize", - TokenEndpoint: "https://example.com/oauth/v2/token", - UserEndpoint: "https://api.example.com/user", - Scopes: []string{"openid", "profile", "email"}, - IDAttribute: "id", - IDPOptions: idp_rp.Options{ - IsLinkingAllowed: true, - IsCreationAllowed: true, - IsAutoCreation: true, - IsAutoUpdate: true, - }, + mustAwait(func() error { + _, err := i.Client.Admin.GetProviderByID(ctx, &admin.GetProviderByIDRequest{ + Id: resp.GetId(), }) - require.NoError(t, err) - return id + return err + }) + + return resp } -func (s *Tester) AddSAMLProvider(t *testing.T, ctx context.Context) string { - ctx = authz.WithInstance(ctx, s.Instance) - id, _, err := s.Server.Commands.AddInstanceSAMLProvider(ctx, command.SAMLProvider{ - Name: "saml-idp", - Metadata: []byte("\n \n \n \n \n MIIDBzCCAe+gAwIBAgIJAPr/Mrlc8EGhMA0GCSqGSIb3DQEBBQUAMBoxGDAWBgNVBAMMD3d3dy5leGFtcGxlLmNvbTAeFw0xNTEyMjgxOTE5NDVaFw0yNTEyMjUxOTE5NDVaMBoxGDAWBgNVBAMMD3d3dy5leGFtcGxlLmNvbTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBANDoWzLos4LWxTn8Gyu2lEbl4WcelUbgLN5zYm4ron8Ahs+rvcsu2zkdD/s6jdGJI8WqJKhYK2u61ygnXgAZqC6ggtFPnBpizcDzjgND2g+aucSoUODHt67f0fQuAmupN/zp5MZysJ6IHLJnYLNpfJYk96lRz9ODnO1Mpqtr9PWxm+pz7nzq5F0vRepkgpcRxv6ufQBjlrFytccyEVdXrvFtkjXcnhVVNSR4kHuOOMS6D7pebSJ1mrCmshbD5SX1jXPBKFPAjozYX6PxqLxUx1Y4faFEf4MBBVcInyB4oURNB2s59hEEi2jq9izNE7EbEK6BY5sEhoCPl9m32zE6ljkCAwEAAaNQME4wHQYDVR0OBBYEFB9ZklC1Ork2zl56zg08ei7ss/+iMB8GA1UdIwQYMBaAFB9ZklC1Ork2zl56zg08ei7ss/+iMAwGA1UdEwQFMAMBAf8wDQYJKoZIhvcNAQEFBQADggEBAAVoTSQ5pAirw8OR9FZ1bRSuTDhY9uxzl/OL7lUmsv2cMNeCB3BRZqm3mFt+cwN8GsH6f3uvNONIhgFpTGN5LEcXQz89zJEzB+qaHqmbFpHQl/sx2B8ezNgT/882H2IH00dXESEfy/+1gHg2pxjGnhRBN6el/gSaDiySIMKbilDrffuvxiCfbpPN0NRRiPJhd2ay9KuL/RxQRl1gl9cHaWiouWWba1bSBb2ZPhv2rPMUsFo98ntkGCObDX6Y1SpkqmoTbrsbGFsTG2DLxnvr4GdN1BSr0Uu/KV3adj47WkXVPeMYQti/bQmxQB8tRFhrw80qakTLUzreO96WzlBBMtY=\n \n \n \n \n \n \n MIIDBzCCAe+gAwIBAgIJAPr/Mrlc8EGhMA0GCSqGSIb3DQEBBQUAMBoxGDAWBgNVBAMMD3d3dy5leGFtcGxlLmNvbTAeFw0xNTEyMjgxOTE5NDVaFw0yNTEyMjUxOTE5NDVaMBoxGDAWBgNVBAMMD3d3dy5leGFtcGxlLmNvbTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBANDoWzLos4LWxTn8Gyu2lEbl4WcelUbgLN5zYm4ron8Ahs+rvcsu2zkdD/s6jdGJI8WqJKhYK2u61ygnXgAZqC6ggtFPnBpizcDzjgND2g+aucSoUODHt67f0fQuAmupN/zp5MZysJ6IHLJnYLNpfJYk96lRz9ODnO1Mpqtr9PWxm+pz7nzq5F0vRepkgpcRxv6ufQBjlrFytccyEVdXrvFtkjXcnhVVNSR4kHuOOMS6D7pebSJ1mrCmshbD5SX1jXPBKFPAjozYX6PxqLxUx1Y4faFEf4MBBVcInyB4oURNB2s59hEEi2jq9izNE7EbEK6BY5sEhoCPl9m32zE6ljkCAwEAAaNQME4wHQYDVR0OBBYEFB9ZklC1Ork2zl56zg08ei7ss/+iMB8GA1UdIwQYMBaAFB9ZklC1Ork2zl56zg08ei7ss/+iMAwGA1UdEwQFMAMBAf8wDQYJKoZIhvcNAQEFBQADggEBAAVoTSQ5pAirw8OR9FZ1bRSuTDhY9uxzl/OL7lUmsv2cMNeCB3BRZqm3mFt+cwN8GsH6f3uvNONIhgFpTGN5LEcXQz89zJEzB+qaHqmbFpHQl/sx2B8ezNgT/882H2IH00dXESEfy/+1gHg2pxjGnhRBN6el/gSaDiySIMKbilDrffuvxiCfbpPN0NRRiPJhd2ay9KuL/RxQRl1gl9cHaWiouWWba1bSBb2ZPhv2rPMUsFo98ntkGCObDX6Y1SpkqmoTbrsbGFsTG2DLxnvr4GdN1BSr0Uu/KV3adj47WkXVPeMYQti/bQmxQB8tRFhrw80qakTLUzreO96WzlBBMtY=\n \n \n \n \n \n \n \n urn:oasis:names:tc:SAML:2.0:nameid-format:transient\n \n \n \n"), - IDPOptions: idp_rp.Options{ +func (i *Instance) AddOrgGenericOAuthProvider(ctx context.Context, name string) *mgmt.AddGenericOAuthProviderResponse { + resp, err := i.Client.Mgmt.AddGenericOAuthProvider(ctx, &mgmt.AddGenericOAuthProviderRequest{ + Name: name, + ClientId: "clientID", + ClientSecret: "clientSecret", + AuthorizationEndpoint: "https://example.com/oauth/v2/authorize", + TokenEndpoint: "https://example.com/oauth/v2/token", + UserEndpoint: "https://api.example.com/user", + Scopes: []string{"openid", "profile", "email"}, + IdAttribute: "id", + ProviderOptions: &idp.Options{ + IsLinkingAllowed: true, + IsCreationAllowed: true, + IsAutoCreation: true, + IsAutoUpdate: true, + AutoLinking: idp.AutoLinkingOption_AUTO_LINKING_OPTION_USERNAME, + }, + }) + logging.OnError(err).Panic("create generic OAuth idp") + /* + mustAwait(func() error { + _, err := i.Client.Mgmt.GetProviderByID(ctx, &mgmt.GetProviderByIDRequest{ + Id: resp.GetId(), + }) + return err + }) + */ + return resp +} + +func (i *Instance) AddSAMLProvider(ctx context.Context) string { + resp, err := i.Client.Admin.AddSAMLProvider(ctx, &admin.AddSAMLProviderRequest{ + Name: "saml-idp", + Metadata: &admin.AddSAMLProviderRequest_MetadataXml{ + MetadataXml: []byte("\n \n \n \n \n MIIDBzCCAe+gAwIBAgIJAPr/Mrlc8EGhMA0GCSqGSIb3DQEBBQUAMBoxGDAWBgNVBAMMD3d3dy5leGFtcGxlLmNvbTAeFw0xNTEyMjgxOTE5NDVaFw0yNTEyMjUxOTE5NDVaMBoxGDAWBgNVBAMMD3d3dy5leGFtcGxlLmNvbTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBANDoWzLos4LWxTn8Gyu2lEbl4WcelUbgLN5zYm4ron8Ahs+rvcsu2zkdD/s6jdGJI8WqJKhYK2u61ygnXgAZqC6ggtFPnBpizcDzjgND2g+aucSoUODHt67f0fQuAmupN/zp5MZysJ6IHLJnYLNpfJYk96lRz9ODnO1Mpqtr9PWxm+pz7nzq5F0vRepkgpcRxv6ufQBjlrFytccyEVdXrvFtkjXcnhVVNSR4kHuOOMS6D7pebSJ1mrCmshbD5SX1jXPBKFPAjozYX6PxqLxUx1Y4faFEf4MBBVcInyB4oURNB2s59hEEi2jq9izNE7EbEK6BY5sEhoCPl9m32zE6ljkCAwEAAaNQME4wHQYDVR0OBBYEFB9ZklC1Ork2zl56zg08ei7ss/+iMB8GA1UdIwQYMBaAFB9ZklC1Ork2zl56zg08ei7ss/+iMAwGA1UdEwQFMAMBAf8wDQYJKoZIhvcNAQEFBQADggEBAAVoTSQ5pAirw8OR9FZ1bRSuTDhY9uxzl/OL7lUmsv2cMNeCB3BRZqm3mFt+cwN8GsH6f3uvNONIhgFpTGN5LEcXQz89zJEzB+qaHqmbFpHQl/sx2B8ezNgT/882H2IH00dXESEfy/+1gHg2pxjGnhRBN6el/gSaDiySIMKbilDrffuvxiCfbpPN0NRRiPJhd2ay9KuL/RxQRl1gl9cHaWiouWWba1bSBb2ZPhv2rPMUsFo98ntkGCObDX6Y1SpkqmoTbrsbGFsTG2DLxnvr4GdN1BSr0Uu/KV3adj47WkXVPeMYQti/bQmxQB8tRFhrw80qakTLUzreO96WzlBBMtY=\n \n \n \n \n \n \n MIIDBzCCAe+gAwIBAgIJAPr/Mrlc8EGhMA0GCSqGSIb3DQEBBQUAMBoxGDAWBgNVBAMMD3d3dy5leGFtcGxlLmNvbTAeFw0xNTEyMjgxOTE5NDVaFw0yNTEyMjUxOTE5NDVaMBoxGDAWBgNVBAMMD3d3dy5leGFtcGxlLmNvbTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBANDoWzLos4LWxTn8Gyu2lEbl4WcelUbgLN5zYm4ron8Ahs+rvcsu2zkdD/s6jdGJI8WqJKhYK2u61ygnXgAZqC6ggtFPnBpizcDzjgND2g+aucSoUODHt67f0fQuAmupN/zp5MZysJ6IHLJnYLNpfJYk96lRz9ODnO1Mpqtr9PWxm+pz7nzq5F0vRepkgpcRxv6ufQBjlrFytccyEVdXrvFtkjXcnhVVNSR4kHuOOMS6D7pebSJ1mrCmshbD5SX1jXPBKFPAjozYX6PxqLxUx1Y4faFEf4MBBVcInyB4oURNB2s59hEEi2jq9izNE7EbEK6BY5sEhoCPl9m32zE6ljkCAwEAAaNQME4wHQYDVR0OBBYEFB9ZklC1Ork2zl56zg08ei7ss/+iMB8GA1UdIwQYMBaAFB9ZklC1Ork2zl56zg08ei7ss/+iMAwGA1UdEwQFMAMBAf8wDQYJKoZIhvcNAQEFBQADggEBAAVoTSQ5pAirw8OR9FZ1bRSuTDhY9uxzl/OL7lUmsv2cMNeCB3BRZqm3mFt+cwN8GsH6f3uvNONIhgFpTGN5LEcXQz89zJEzB+qaHqmbFpHQl/sx2B8ezNgT/882H2IH00dXESEfy/+1gHg2pxjGnhRBN6el/gSaDiySIMKbilDrffuvxiCfbpPN0NRRiPJhd2ay9KuL/RxQRl1gl9cHaWiouWWba1bSBb2ZPhv2rPMUsFo98ntkGCObDX6Y1SpkqmoTbrsbGFsTG2DLxnvr4GdN1BSr0Uu/KV3adj47WkXVPeMYQti/bQmxQB8tRFhrw80qakTLUzreO96WzlBBMtY=\n \n \n \n \n \n \n \n urn:oasis:names:tc:SAML:2.0:nameid-format:transient\n \n \n \n"), + }, + ProviderOptions: &idp.Options{ IsLinkingAllowed: true, IsCreationAllowed: true, IsAutoCreation: true, IsAutoUpdate: true, }, }) - require.NoError(t, err) - return id + logging.OnError(err).Panic("create saml idp") + return resp.GetId() } -func (s *Tester) AddSAMLRedirectProvider(t *testing.T, ctx context.Context, transientMappingAttributeName string) string { - ctx = authz.WithInstance(ctx, s.Instance) - id, _, err := s.Server.Commands.AddInstanceSAMLProvider(ctx, command.SAMLProvider{ - Name: "saml-idp-redirect", - Binding: "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect", - Metadata: []byte("\n \n \n \n \n MIIDBzCCAe+gAwIBAgIJAPr/Mrlc8EGhMA0GCSqGSIb3DQEBBQUAMBoxGDAWBgNVBAMMD3d3dy5leGFtcGxlLmNvbTAeFw0xNTEyMjgxOTE5NDVaFw0yNTEyMjUxOTE5NDVaMBoxGDAWBgNVBAMMD3d3dy5leGFtcGxlLmNvbTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBANDoWzLos4LWxTn8Gyu2lEbl4WcelUbgLN5zYm4ron8Ahs+rvcsu2zkdD/s6jdGJI8WqJKhYK2u61ygnXgAZqC6ggtFPnBpizcDzjgND2g+aucSoUODHt67f0fQuAmupN/zp5MZysJ6IHLJnYLNpfJYk96lRz9ODnO1Mpqtr9PWxm+pz7nzq5F0vRepkgpcRxv6ufQBjlrFytccyEVdXrvFtkjXcnhVVNSR4kHuOOMS6D7pebSJ1mrCmshbD5SX1jXPBKFPAjozYX6PxqLxUx1Y4faFEf4MBBVcInyB4oURNB2s59hEEi2jq9izNE7EbEK6BY5sEhoCPl9m32zE6ljkCAwEAAaNQME4wHQYDVR0OBBYEFB9ZklC1Ork2zl56zg08ei7ss/+iMB8GA1UdIwQYMBaAFB9ZklC1Ork2zl56zg08ei7ss/+iMAwGA1UdEwQFMAMBAf8wDQYJKoZIhvcNAQEFBQADggEBAAVoTSQ5pAirw8OR9FZ1bRSuTDhY9uxzl/OL7lUmsv2cMNeCB3BRZqm3mFt+cwN8GsH6f3uvNONIhgFpTGN5LEcXQz89zJEzB+qaHqmbFpHQl/sx2B8ezNgT/882H2IH00dXESEfy/+1gHg2pxjGnhRBN6el/gSaDiySIMKbilDrffuvxiCfbpPN0NRRiPJhd2ay9KuL/RxQRl1gl9cHaWiouWWba1bSBb2ZPhv2rPMUsFo98ntkGCObDX6Y1SpkqmoTbrsbGFsTG2DLxnvr4GdN1BSr0Uu/KV3adj47WkXVPeMYQti/bQmxQB8tRFhrw80qakTLUzreO96WzlBBMtY=\n \n \n \n \n \n \n MIIDBzCCAe+gAwIBAgIJAPr/Mrlc8EGhMA0GCSqGSIb3DQEBBQUAMBoxGDAWBgNVBAMMD3d3dy5leGFtcGxlLmNvbTAeFw0xNTEyMjgxOTE5NDVaFw0yNTEyMjUxOTE5NDVaMBoxGDAWBgNVBAMMD3d3dy5leGFtcGxlLmNvbTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBANDoWzLos4LWxTn8Gyu2lEbl4WcelUbgLN5zYm4ron8Ahs+rvcsu2zkdD/s6jdGJI8WqJKhYK2u61ygnXgAZqC6ggtFPnBpizcDzjgND2g+aucSoUODHt67f0fQuAmupN/zp5MZysJ6IHLJnYLNpfJYk96lRz9ODnO1Mpqtr9PWxm+pz7nzq5F0vRepkgpcRxv6ufQBjlrFytccyEVdXrvFtkjXcnhVVNSR4kHuOOMS6D7pebSJ1mrCmshbD5SX1jXPBKFPAjozYX6PxqLxUx1Y4faFEf4MBBVcInyB4oURNB2s59hEEi2jq9izNE7EbEK6BY5sEhoCPl9m32zE6ljkCAwEAAaNQME4wHQYDVR0OBBYEFB9ZklC1Ork2zl56zg08ei7ss/+iMB8GA1UdIwQYMBaAFB9ZklC1Ork2zl56zg08ei7ss/+iMAwGA1UdEwQFMAMBAf8wDQYJKoZIhvcNAQEFBQADggEBAAVoTSQ5pAirw8OR9FZ1bRSuTDhY9uxzl/OL7lUmsv2cMNeCB3BRZqm3mFt+cwN8GsH6f3uvNONIhgFpTGN5LEcXQz89zJEzB+qaHqmbFpHQl/sx2B8ezNgT/882H2IH00dXESEfy/+1gHg2pxjGnhRBN6el/gSaDiySIMKbilDrffuvxiCfbpPN0NRRiPJhd2ay9KuL/RxQRl1gl9cHaWiouWWba1bSBb2ZPhv2rPMUsFo98ntkGCObDX6Y1SpkqmoTbrsbGFsTG2DLxnvr4GdN1BSr0Uu/KV3adj47WkXVPeMYQti/bQmxQB8tRFhrw80qakTLUzreO96WzlBBMtY=\n \n \n \n \n \n \n \n urn:oasis:names:tc:SAML:2.0:nameid-format:transient\n \n \n"), - TransientMappingAttributeName: transientMappingAttributeName, - IDPOptions: idp_rp.Options{ +func (i *Instance) AddSAMLRedirectProvider(ctx context.Context, transientMappingAttributeName string) string { + resp, err := i.Client.Admin.AddSAMLProvider(ctx, &admin.AddSAMLProviderRequest{ + Name: "saml-idp-redirect", + Binding: idp.SAMLBinding_SAML_BINDING_REDIRECT, + Metadata: &admin.AddSAMLProviderRequest_MetadataXml{ + MetadataXml: []byte("\n \n \n \n \n MIIDBzCCAe+gAwIBAgIJAPr/Mrlc8EGhMA0GCSqGSIb3DQEBBQUAMBoxGDAWBgNVBAMMD3d3dy5leGFtcGxlLmNvbTAeFw0xNTEyMjgxOTE5NDVaFw0yNTEyMjUxOTE5NDVaMBoxGDAWBgNVBAMMD3d3dy5leGFtcGxlLmNvbTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBANDoWzLos4LWxTn8Gyu2lEbl4WcelUbgLN5zYm4ron8Ahs+rvcsu2zkdD/s6jdGJI8WqJKhYK2u61ygnXgAZqC6ggtFPnBpizcDzjgND2g+aucSoUODHt67f0fQuAmupN/zp5MZysJ6IHLJnYLNpfJYk96lRz9ODnO1Mpqtr9PWxm+pz7nzq5F0vRepkgpcRxv6ufQBjlrFytccyEVdXrvFtkjXcnhVVNSR4kHuOOMS6D7pebSJ1mrCmshbD5SX1jXPBKFPAjozYX6PxqLxUx1Y4faFEf4MBBVcInyB4oURNB2s59hEEi2jq9izNE7EbEK6BY5sEhoCPl9m32zE6ljkCAwEAAaNQME4wHQYDVR0OBBYEFB9ZklC1Ork2zl56zg08ei7ss/+iMB8GA1UdIwQYMBaAFB9ZklC1Ork2zl56zg08ei7ss/+iMAwGA1UdEwQFMAMBAf8wDQYJKoZIhvcNAQEFBQADggEBAAVoTSQ5pAirw8OR9FZ1bRSuTDhY9uxzl/OL7lUmsv2cMNeCB3BRZqm3mFt+cwN8GsH6f3uvNONIhgFpTGN5LEcXQz89zJEzB+qaHqmbFpHQl/sx2B8ezNgT/882H2IH00dXESEfy/+1gHg2pxjGnhRBN6el/gSaDiySIMKbilDrffuvxiCfbpPN0NRRiPJhd2ay9KuL/RxQRl1gl9cHaWiouWWba1bSBb2ZPhv2rPMUsFo98ntkGCObDX6Y1SpkqmoTbrsbGFsTG2DLxnvr4GdN1BSr0Uu/KV3adj47WkXVPeMYQti/bQmxQB8tRFhrw80qakTLUzreO96WzlBBMtY=\n \n \n \n \n \n \n MIIDBzCCAe+gAwIBAgIJAPr/Mrlc8EGhMA0GCSqGSIb3DQEBBQUAMBoxGDAWBgNVBAMMD3d3dy5leGFtcGxlLmNvbTAeFw0xNTEyMjgxOTE5NDVaFw0yNTEyMjUxOTE5NDVaMBoxGDAWBgNVBAMMD3d3dy5leGFtcGxlLmNvbTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBANDoWzLos4LWxTn8Gyu2lEbl4WcelUbgLN5zYm4ron8Ahs+rvcsu2zkdD/s6jdGJI8WqJKhYK2u61ygnXgAZqC6ggtFPnBpizcDzjgND2g+aucSoUODHt67f0fQuAmupN/zp5MZysJ6IHLJnYLNpfJYk96lRz9ODnO1Mpqtr9PWxm+pz7nzq5F0vRepkgpcRxv6ufQBjlrFytccyEVdXrvFtkjXcnhVVNSR4kHuOOMS6D7pebSJ1mrCmshbD5SX1jXPBKFPAjozYX6PxqLxUx1Y4faFEf4MBBVcInyB4oURNB2s59hEEi2jq9izNE7EbEK6BY5sEhoCPl9m32zE6ljkCAwEAAaNQME4wHQYDVR0OBBYEFB9ZklC1Ork2zl56zg08ei7ss/+iMB8GA1UdIwQYMBaAFB9ZklC1Ork2zl56zg08ei7ss/+iMAwGA1UdEwQFMAMBAf8wDQYJKoZIhvcNAQEFBQADggEBAAVoTSQ5pAirw8OR9FZ1bRSuTDhY9uxzl/OL7lUmsv2cMNeCB3BRZqm3mFt+cwN8GsH6f3uvNONIhgFpTGN5LEcXQz89zJEzB+qaHqmbFpHQl/sx2B8ezNgT/882H2IH00dXESEfy/+1gHg2pxjGnhRBN6el/gSaDiySIMKbilDrffuvxiCfbpPN0NRRiPJhd2ay9KuL/RxQRl1gl9cHaWiouWWba1bSBb2ZPhv2rPMUsFo98ntkGCObDX6Y1SpkqmoTbrsbGFsTG2DLxnvr4GdN1BSr0Uu/KV3adj47WkXVPeMYQti/bQmxQB8tRFhrw80qakTLUzreO96WzlBBMtY=\n \n \n \n \n \n \n \n urn:oasis:names:tc:SAML:2.0:nameid-format:transient\n \n \n"), + }, + TransientMappingAttributeName: &transientMappingAttributeName, + ProviderOptions: &idp.Options{ IsLinkingAllowed: true, IsCreationAllowed: true, IsAutoCreation: true, IsAutoUpdate: true, }, }) - require.NoError(t, err) - return id + logging.OnError(err).Panic("create saml idp") + return resp.GetId() } -func (s *Tester) AddSAMLPostProvider(t *testing.T, ctx context.Context) string { - ctx = authz.WithInstance(ctx, s.Instance) - id, _, err := s.Server.Commands.AddInstanceSAMLProvider(ctx, command.SAMLProvider{ - Name: "saml-idp-post", - Binding: "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST", - Metadata: []byte("\n \n \n \n \n MIIDBzCCAe+gAwIBAgIJAPr/Mrlc8EGhMA0GCSqGSIb3DQEBBQUAMBoxGDAWBgNVBAMMD3d3dy5leGFtcGxlLmNvbTAeFw0xNTEyMjgxOTE5NDVaFw0yNTEyMjUxOTE5NDVaMBoxGDAWBgNVBAMMD3d3dy5leGFtcGxlLmNvbTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBANDoWzLos4LWxTn8Gyu2lEbl4WcelUbgLN5zYm4ron8Ahs+rvcsu2zkdD/s6jdGJI8WqJKhYK2u61ygnXgAZqC6ggtFPnBpizcDzjgND2g+aucSoUODHt67f0fQuAmupN/zp5MZysJ6IHLJnYLNpfJYk96lRz9ODnO1Mpqtr9PWxm+pz7nzq5F0vRepkgpcRxv6ufQBjlrFytccyEVdXrvFtkjXcnhVVNSR4kHuOOMS6D7pebSJ1mrCmshbD5SX1jXPBKFPAjozYX6PxqLxUx1Y4faFEf4MBBVcInyB4oURNB2s59hEEi2jq9izNE7EbEK6BY5sEhoCPl9m32zE6ljkCAwEAAaNQME4wHQYDVR0OBBYEFB9ZklC1Ork2zl56zg08ei7ss/+iMB8GA1UdIwQYMBaAFB9ZklC1Ork2zl56zg08ei7ss/+iMAwGA1UdEwQFMAMBAf8wDQYJKoZIhvcNAQEFBQADggEBAAVoTSQ5pAirw8OR9FZ1bRSuTDhY9uxzl/OL7lUmsv2cMNeCB3BRZqm3mFt+cwN8GsH6f3uvNONIhgFpTGN5LEcXQz89zJEzB+qaHqmbFpHQl/sx2B8ezNgT/882H2IH00dXESEfy/+1gHg2pxjGnhRBN6el/gSaDiySIMKbilDrffuvxiCfbpPN0NRRiPJhd2ay9KuL/RxQRl1gl9cHaWiouWWba1bSBb2ZPhv2rPMUsFo98ntkGCObDX6Y1SpkqmoTbrsbGFsTG2DLxnvr4GdN1BSr0Uu/KV3adj47WkXVPeMYQti/bQmxQB8tRFhrw80qakTLUzreO96WzlBBMtY=\n \n \n \n \n \n \n MIIDBzCCAe+gAwIBAgIJAPr/Mrlc8EGhMA0GCSqGSIb3DQEBBQUAMBoxGDAWBgNVBAMMD3d3dy5leGFtcGxlLmNvbTAeFw0xNTEyMjgxOTE5NDVaFw0yNTEyMjUxOTE5NDVaMBoxGDAWBgNVBAMMD3d3dy5leGFtcGxlLmNvbTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBANDoWzLos4LWxTn8Gyu2lEbl4WcelUbgLN5zYm4ron8Ahs+rvcsu2zkdD/s6jdGJI8WqJKhYK2u61ygnXgAZqC6ggtFPnBpizcDzjgND2g+aucSoUODHt67f0fQuAmupN/zp5MZysJ6IHLJnYLNpfJYk96lRz9ODnO1Mpqtr9PWxm+pz7nzq5F0vRepkgpcRxv6ufQBjlrFytccyEVdXrvFtkjXcnhVVNSR4kHuOOMS6D7pebSJ1mrCmshbD5SX1jXPBKFPAjozYX6PxqLxUx1Y4faFEf4MBBVcInyB4oURNB2s59hEEi2jq9izNE7EbEK6BY5sEhoCPl9m32zE6ljkCAwEAAaNQME4wHQYDVR0OBBYEFB9ZklC1Ork2zl56zg08ei7ss/+iMB8GA1UdIwQYMBaAFB9ZklC1Ork2zl56zg08ei7ss/+iMAwGA1UdEwQFMAMBAf8wDQYJKoZIhvcNAQEFBQADggEBAAVoTSQ5pAirw8OR9FZ1bRSuTDhY9uxzl/OL7lUmsv2cMNeCB3BRZqm3mFt+cwN8GsH6f3uvNONIhgFpTGN5LEcXQz89zJEzB+qaHqmbFpHQl/sx2B8ezNgT/882H2IH00dXESEfy/+1gHg2pxjGnhRBN6el/gSaDiySIMKbilDrffuvxiCfbpPN0NRRiPJhd2ay9KuL/RxQRl1gl9cHaWiouWWba1bSBb2ZPhv2rPMUsFo98ntkGCObDX6Y1SpkqmoTbrsbGFsTG2DLxnvr4GdN1BSr0Uu/KV3adj47WkXVPeMYQti/bQmxQB8tRFhrw80qakTLUzreO96WzlBBMtY=\n \n \n \n \n \n \n \n urn:oasis:names:tc:SAML:2.0:nameid-format:transient\n \n \n"), - IDPOptions: idp_rp.Options{ +func (i *Instance) AddSAMLPostProvider(ctx context.Context) string { + resp, err := i.Client.Admin.AddSAMLProvider(ctx, &admin.AddSAMLProviderRequest{ + Name: "saml-idp-post", + Binding: idp.SAMLBinding_SAML_BINDING_POST, + Metadata: &admin.AddSAMLProviderRequest_MetadataXml{ + MetadataXml: []byte("\n \n \n \n \n MIIDBzCCAe+gAwIBAgIJAPr/Mrlc8EGhMA0GCSqGSIb3DQEBBQUAMBoxGDAWBgNVBAMMD3d3dy5leGFtcGxlLmNvbTAeFw0xNTEyMjgxOTE5NDVaFw0yNTEyMjUxOTE5NDVaMBoxGDAWBgNVBAMMD3d3dy5leGFtcGxlLmNvbTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBANDoWzLos4LWxTn8Gyu2lEbl4WcelUbgLN5zYm4ron8Ahs+rvcsu2zkdD/s6jdGJI8WqJKhYK2u61ygnXgAZqC6ggtFPnBpizcDzjgND2g+aucSoUODHt67f0fQuAmupN/zp5MZysJ6IHLJnYLNpfJYk96lRz9ODnO1Mpqtr9PWxm+pz7nzq5F0vRepkgpcRxv6ufQBjlrFytccyEVdXrvFtkjXcnhVVNSR4kHuOOMS6D7pebSJ1mrCmshbD5SX1jXPBKFPAjozYX6PxqLxUx1Y4faFEf4MBBVcInyB4oURNB2s59hEEi2jq9izNE7EbEK6BY5sEhoCPl9m32zE6ljkCAwEAAaNQME4wHQYDVR0OBBYEFB9ZklC1Ork2zl56zg08ei7ss/+iMB8GA1UdIwQYMBaAFB9ZklC1Ork2zl56zg08ei7ss/+iMAwGA1UdEwQFMAMBAf8wDQYJKoZIhvcNAQEFBQADggEBAAVoTSQ5pAirw8OR9FZ1bRSuTDhY9uxzl/OL7lUmsv2cMNeCB3BRZqm3mFt+cwN8GsH6f3uvNONIhgFpTGN5LEcXQz89zJEzB+qaHqmbFpHQl/sx2B8ezNgT/882H2IH00dXESEfy/+1gHg2pxjGnhRBN6el/gSaDiySIMKbilDrffuvxiCfbpPN0NRRiPJhd2ay9KuL/RxQRl1gl9cHaWiouWWba1bSBb2ZPhv2rPMUsFo98ntkGCObDX6Y1SpkqmoTbrsbGFsTG2DLxnvr4GdN1BSr0Uu/KV3adj47WkXVPeMYQti/bQmxQB8tRFhrw80qakTLUzreO96WzlBBMtY=\n \n \n \n \n \n \n MIIDBzCCAe+gAwIBAgIJAPr/Mrlc8EGhMA0GCSqGSIb3DQEBBQUAMBoxGDAWBgNVBAMMD3d3dy5leGFtcGxlLmNvbTAeFw0xNTEyMjgxOTE5NDVaFw0yNTEyMjUxOTE5NDVaMBoxGDAWBgNVBAMMD3d3dy5leGFtcGxlLmNvbTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBANDoWzLos4LWxTn8Gyu2lEbl4WcelUbgLN5zYm4ron8Ahs+rvcsu2zkdD/s6jdGJI8WqJKhYK2u61ygnXgAZqC6ggtFPnBpizcDzjgND2g+aucSoUODHt67f0fQuAmupN/zp5MZysJ6IHLJnYLNpfJYk96lRz9ODnO1Mpqtr9PWxm+pz7nzq5F0vRepkgpcRxv6ufQBjlrFytccyEVdXrvFtkjXcnhVVNSR4kHuOOMS6D7pebSJ1mrCmshbD5SX1jXPBKFPAjozYX6PxqLxUx1Y4faFEf4MBBVcInyB4oURNB2s59hEEi2jq9izNE7EbEK6BY5sEhoCPl9m32zE6ljkCAwEAAaNQME4wHQYDVR0OBBYEFB9ZklC1Ork2zl56zg08ei7ss/+iMB8GA1UdIwQYMBaAFB9ZklC1Ork2zl56zg08ei7ss/+iMAwGA1UdEwQFMAMBAf8wDQYJKoZIhvcNAQEFBQADggEBAAVoTSQ5pAirw8OR9FZ1bRSuTDhY9uxzl/OL7lUmsv2cMNeCB3BRZqm3mFt+cwN8GsH6f3uvNONIhgFpTGN5LEcXQz89zJEzB+qaHqmbFpHQl/sx2B8ezNgT/882H2IH00dXESEfy/+1gHg2pxjGnhRBN6el/gSaDiySIMKbilDrffuvxiCfbpPN0NRRiPJhd2ay9KuL/RxQRl1gl9cHaWiouWWba1bSBb2ZPhv2rPMUsFo98ntkGCObDX6Y1SpkqmoTbrsbGFsTG2DLxnvr4GdN1BSr0Uu/KV3adj47WkXVPeMYQti/bQmxQB8tRFhrw80qakTLUzreO96WzlBBMtY=\n \n \n \n \n \n \n \n urn:oasis:names:tc:SAML:2.0:nameid-format:transient\n \n \n"), + }, + ProviderOptions: &idp.Options{ IsLinkingAllowed: true, IsCreationAllowed: true, IsAutoCreation: true, IsAutoUpdate: true, }, }) - require.NoError(t, err) - return id + logging.OnError(err).Panic("create saml idp") + return resp.GetId() } -func (s *Tester) CreateIntent(t *testing.T, ctx context.Context, idpID string) string { +/* +func (s *Instance) CreateIntent(t *testing.T, ctx context.Context, idpID string) string { + resp, err := i.Client.UserV2.StartIdentityProviderIntent(ctx, &user.StartIdentityProviderIntentRequest{ + IdpId: idpID, + Content: &user.StartIdentityProviderIntentRequest_Urls{ + Urls: &user.RedirectURLs{ + SuccessUrl: "https://example.com/success", + FailureUrl: "https://example.com/failure", + }, + AutoLinking: idp.AutoLinkingOption_AUTO_LINKING_OPTION_USERNAME, + }, + }) + logging.OnError(err).Fatal("create generic OAuth idp") + return resp +} + +func (i *Instance) CreateIntent(t *testing.T, ctx context.Context, idpID string) string { ctx = authz.WithInstance(context.WithoutCancel(ctx), s.Instance) writeModel, _, err := s.Commands.CreateIntent(ctx, idpID, "https://example.com/success", "https://example.com/failure", s.Instance.InstanceID()) require.NoError(t, err) return writeModel.AggregateID } -func (s *Tester) CreateSuccessfulOAuthIntent(t *testing.T, ctx context.Context, idpID, userID, idpUserID string) (string, string, time.Time, uint64) { +func (i *Instance) CreateSuccessfulOAuthIntent(t *testing.T, ctx context.Context, idpID, userID, idpUserID string) (string, string, time.Time, uint64) { ctx = authz.WithInstance(context.WithoutCancel(ctx), s.Instance) intentID := s.CreateIntent(t, ctx, idpID) writeModel, err := s.Commands.GetIntentWriteModel(ctx, intentID, s.Instance.InstanceID()) @@ -582,7 +542,7 @@ func (s *Tester) CreateSuccessfulOAuthIntent(t *testing.T, ctx context.Context, return intentID, token, writeModel.ChangeDate, writeModel.ProcessedSequence } -func (s *Tester) CreateSuccessfulLDAPIntent(t *testing.T, ctx context.Context, idpID, userID, idpUserID string) (string, string, time.Time, uint64) { +func (s *Instance) CreateSuccessfulLDAPIntent(t *testing.T, ctx context.Context, idpID, userID, idpUserID string) (string, string, time.Time, uint64) { ctx = authz.WithInstance(context.WithoutCancel(ctx), s.Instance) intentID := s.CreateIntent(t, ctx, idpID) writeModel, err := s.Commands.GetIntentWriteModel(ctx, intentID, s.Instance.InstanceID()) @@ -610,7 +570,7 @@ func (s *Tester) CreateSuccessfulLDAPIntent(t *testing.T, ctx context.Context, i return intentID, token, writeModel.ChangeDate, writeModel.ProcessedSequence } -func (s *Tester) CreateSuccessfulSAMLIntent(t *testing.T, ctx context.Context, idpID, userID, idpUserID string) (string, string, time.Time, uint64) { +func (s *Instance) CreateSuccessfulSAMLIntent(t *testing.T, ctx context.Context, idpID, userID, idpUserID string) (string, string, time.Time, uint64) { ctx = authz.WithInstance(context.WithoutCancel(ctx), s.Instance) intentID := s.CreateIntent(t, ctx, idpID) writeModel, err := s.Server.Commands.GetIntentWriteModel(ctx, intentID, s.Instance.InstanceID()) @@ -626,17 +586,18 @@ func (s *Tester) CreateSuccessfulSAMLIntent(t *testing.T, ctx context.Context, i require.NoError(t, err) return intentID, token, writeModel.ChangeDate, writeModel.ProcessedSequence } +*/ -func (s *Tester) CreateVerifiedWebAuthNSession(t *testing.T, ctx context.Context, userID string) (id, token string, start, change time.Time) { - return s.CreateVerifiedWebAuthNSessionWithLifetime(t, ctx, userID, 0) +func (i *Instance) CreateVerifiedWebAuthNSession(t *testing.T, ctx context.Context, userID string) (id, token string, start, change time.Time) { + return i.CreateVerifiedWebAuthNSessionWithLifetime(t, ctx, userID, 0) } -func (s *Tester) CreateVerifiedWebAuthNSessionWithLifetime(t *testing.T, ctx context.Context, userID string, lifetime time.Duration) (id, token string, start, change time.Time) { +func (i *Instance) CreateVerifiedWebAuthNSessionWithLifetime(t *testing.T, ctx context.Context, userID string, lifetime time.Duration) (id, token string, start, change time.Time) { var sessionLifetime *durationpb.Duration if lifetime > 0 { sessionLifetime = durationpb.New(lifetime) } - createResp, err := s.Client.SessionV2.CreateSession(ctx, &session.CreateSessionRequest{ + createResp, err := i.Client.SessionV2.CreateSession(ctx, &session.CreateSessionRequest{ Checks: &session.Checks{ User: &session.CheckUser{ Search: &session.CheckUser_UserId{UserId: userID}, @@ -644,7 +605,7 @@ func (s *Tester) CreateVerifiedWebAuthNSessionWithLifetime(t *testing.T, ctx con }, Challenges: &session.RequestChallenges{ WebAuthN: &session.RequestChallenges_WebAuthN{ - Domain: s.Config.ExternalDomain, + Domain: i.Domain, UserVerificationRequirement: session.UserVerificationRequirement_USER_VERIFICATION_REQUIREMENT_REQUIRED, }, }, @@ -652,10 +613,10 @@ func (s *Tester) CreateVerifiedWebAuthNSessionWithLifetime(t *testing.T, ctx con }) require.NoError(t, err) - assertion, err := s.WebAuthN.CreateAssertionResponse(createResp.GetChallenges().GetWebAuthN().GetPublicKeyCredentialRequestOptions(), true) + assertion, err := i.WebAuthN.CreateAssertionResponse(createResp.GetChallenges().GetWebAuthN().GetPublicKeyCredentialRequestOptions(), true) require.NoError(t, err) - updateResp, err := s.Client.SessionV2.SetSession(ctx, &session.SetSessionRequest{ + updateResp, err := i.Client.SessionV2.SetSession(ctx, &session.SetSessionRequest{ SessionId: createResp.GetSessionId(), Checks: &session.Checks{ WebAuthN: &session.CheckWebAuthN{ @@ -668,8 +629,8 @@ func (s *Tester) CreateVerifiedWebAuthNSessionWithLifetime(t *testing.T, ctx con createResp.GetDetails().GetChangeDate().AsTime(), updateResp.GetDetails().GetChangeDate().AsTime() } -func (s *Tester) CreatePasswordSession(t *testing.T, ctx context.Context, userID, password string) (id, token string, start, change time.Time) { - createResp, err := s.Client.SessionV2.CreateSession(ctx, &session.CreateSessionRequest{ +func (i *Instance) CreatePasswordSession(t *testing.T, ctx context.Context, userID, password string) (id, token string, start, change time.Time) { + createResp, err := i.Client.SessionV2.CreateSession(ctx, &session.CreateSessionRequest{ Checks: &session.Checks{ User: &session.CheckUser{ Search: &session.CheckUser_UserId{UserId: userID}, @@ -684,8 +645,8 @@ func (s *Tester) CreatePasswordSession(t *testing.T, ctx context.Context, userID createResp.GetDetails().GetChangeDate().AsTime(), createResp.GetDetails().GetChangeDate().AsTime() } -func (s *Tester) CreateProjectUserGrant(t *testing.T, ctx context.Context, projectID, userID string) string { - resp, err := s.Client.Mgmt.AddUserGrant(ctx, &mgmt.AddUserGrantRequest{ +func (i *Instance) CreateProjectUserGrant(t *testing.T, ctx context.Context, projectID, userID string) string { + resp, err := i.Client.Mgmt.AddUserGrant(ctx, &mgmt.AddUserGrantRequest{ UserId: userID, ProjectId: projectID, }) @@ -693,16 +654,16 @@ func (s *Tester) CreateProjectUserGrant(t *testing.T, ctx context.Context, proje return resp.GetUserGrantId() } -func (s *Tester) CreateOrgMembership(t *testing.T, ctx context.Context, userID string) { - _, err := s.Client.Mgmt.AddOrgMember(ctx, &mgmt.AddOrgMemberRequest{ +func (i *Instance) CreateOrgMembership(t *testing.T, ctx context.Context, userID string) { + _, err := i.Client.Mgmt.AddOrgMember(ctx, &mgmt.AddOrgMemberRequest{ UserId: userID, Roles: []string{domain.RoleOrgOwner}, }) require.NoError(t, err) } -func (s *Tester) CreateProjectMembership(t *testing.T, ctx context.Context, projectID, userID string) { - _, err := s.Client.Mgmt.AddProjectMember(ctx, &mgmt.AddProjectMemberRequest{ +func (i *Instance) CreateProjectMembership(t *testing.T, ctx context.Context, projectID, userID string) { + _, err := i.Client.Mgmt.AddProjectMember(ctx, &mgmt.AddProjectMemberRequest{ ProjectId: projectID, UserId: userID, Roles: []string{domain.RoleProjectOwner}, @@ -710,13 +671,12 @@ func (s *Tester) CreateProjectMembership(t *testing.T, ctx context.Context, proj require.NoError(t, err) } -func (s *Tester) CreateTarget(ctx context.Context, t *testing.T, name, endpoint string, ty domain.TargetType, interrupt bool) *action.CreateTargetResponse { - nameSet := fmt.Sprint(time.Now().UnixNano() + 1) - if name != "" { - nameSet = name +func (i *Instance) CreateTarget(ctx context.Context, t *testing.T, name, endpoint string, ty domain.TargetType, interrupt bool) *action.CreateTargetResponse { + if name == "" { + name = gofakeit.Name() } reqTarget := &action.Target{ - Name: nameSet, + Name: name, Endpoint: endpoint, Timeout: durationpb.New(10 * time.Second), } @@ -738,20 +698,20 @@ func (s *Tester) CreateTarget(ctx context.Context, t *testing.T, name, endpoint RestAsync: &action.SetRESTAsync{}, } } - target, err := s.Client.ActionV3Alpha.CreateTarget(ctx, &action.CreateTargetRequest{Target: reqTarget}) + target, err := i.Client.ActionV3Alpha.CreateTarget(ctx, &action.CreateTargetRequest{Target: reqTarget}) require.NoError(t, err) return target } -func (s *Tester) DeleteExecution(ctx context.Context, t *testing.T, cond *action.Condition) { - _, err := s.Client.ActionV3Alpha.SetExecution(ctx, &action.SetExecutionRequest{ +func (i *Instance) DeleteExecution(ctx context.Context, t *testing.T, cond *action.Condition) { + _, err := i.Client.ActionV3Alpha.SetExecution(ctx, &action.SetExecutionRequest{ Condition: cond, }) require.NoError(t, err) } -func (s *Tester) SetExecution(ctx context.Context, t *testing.T, cond *action.Condition, targets []*action.ExecutionTargetType) *action.SetExecutionResponse { - target, err := s.Client.ActionV3Alpha.SetExecution(ctx, &action.SetExecutionRequest{ +func (i *Instance) SetExecution(ctx context.Context, t *testing.T, cond *action.Condition, targets []*action.ExecutionTargetType) *action.SetExecutionResponse { + target, err := i.Client.ActionV3Alpha.SetExecution(ctx, &action.SetExecutionRequest{ Condition: cond, Execution: &action.Execution{ Targets: targets, @@ -761,15 +721,15 @@ func (s *Tester) SetExecution(ctx context.Context, t *testing.T, cond *action.Co return target } -func (s *Tester) CreateUserSchemaEmpty(ctx context.Context) *userschema_v3alpha.CreateUserSchemaResponse { - return s.CreateUserSchemaEmptyWithType(ctx, fmt.Sprint(time.Now().UnixNano()+1)) +func (i *Instance) CreateUserSchemaEmpty(ctx context.Context) *userschema_v3alpha.CreateUserSchemaResponse { + return i.CreateUserSchemaEmptyWithType(ctx, fmt.Sprint(time.Now().UnixNano()+1)) } -func (s *Tester) CreateUserSchema(ctx context.Context, schemaData []byte) *userschema_v3alpha.CreateUserSchemaResponse { +func (i *Instance) CreateUserSchema(ctx context.Context, schemaData []byte) *userschema_v3alpha.CreateUserSchemaResponse { userSchema := new(structpb.Struct) err := userSchema.UnmarshalJSON(schemaData) logging.OnError(err).Fatal("create userschema unmarshal") - schema, err := s.Client.UserSchemaV3.CreateUserSchema(ctx, &userschema_v3alpha.CreateUserSchemaRequest{ + schema, err := i.Client.UserSchemaV3.CreateUserSchema(ctx, &userschema_v3alpha.CreateUserSchemaRequest{ UserSchema: &userschema_v3alpha.UserSchema{ Type: fmt.Sprint(time.Now().UnixNano() + 1), DataType: &userschema_v3alpha.UserSchema_Schema{ @@ -781,7 +741,7 @@ func (s *Tester) CreateUserSchema(ctx context.Context, schemaData []byte) *users return schema } -func (s *Tester) CreateUserSchemaEmptyWithType(ctx context.Context, schemaType string) *userschema_v3alpha.CreateUserSchemaResponse { +func (i *Instance) CreateUserSchemaEmptyWithType(ctx context.Context, schemaType string) *userschema_v3alpha.CreateUserSchemaResponse { userSchema := new(structpb.Struct) err := userSchema.UnmarshalJSON([]byte(`{ "$schema": "urn:zitadel:schema:v1", @@ -789,7 +749,7 @@ func (s *Tester) CreateUserSchemaEmptyWithType(ctx context.Context, schemaType s "properties": {} }`)) logging.OnError(err).Fatal("create userschema unmarshal") - schema, err := s.Client.UserSchemaV3.CreateUserSchema(ctx, &userschema_v3alpha.CreateUserSchemaRequest{ + schema, err := i.Client.UserSchemaV3.CreateUserSchema(ctx, &userschema_v3alpha.CreateUserSchemaRequest{ UserSchema: &userschema_v3alpha.UserSchema{ Type: schemaType, DataType: &userschema_v3alpha.UserSchema_Schema{ @@ -801,11 +761,11 @@ func (s *Tester) CreateUserSchemaEmptyWithType(ctx context.Context, schemaType s return schema } -func (s *Tester) CreateSchemaUser(ctx context.Context, orgID string, schemaID string, data []byte) *user_v3alpha.CreateUserResponse { +func (i *Instance) CreateSchemaUser(ctx context.Context, orgID string, schemaID string, data []byte) *user_v3alpha.CreateUserResponse { userData := new(structpb.Struct) err := userData.UnmarshalJSON(data) logging.OnError(err).Fatal("create user unmarshal") - user, err := s.Client.UserV3Alpha.CreateUser(ctx, &user_v3alpha.CreateUserRequest{ + user, err := i.Client.UserV3Alpha.CreateUser(ctx, &user_v3alpha.CreateUserRequest{ Organization: &object_v3alpha.Organization{Property: &object_v3alpha.Organization_OrgId{OrgId: orgID}}, User: &user_v3alpha.CreateUser{ SchemaId: schemaID, diff --git a/internal/integration/config.go b/internal/integration/config.go new file mode 100644 index 0000000000..5aea740752 --- /dev/null +++ b/internal/integration/config.go @@ -0,0 +1,53 @@ +package integration + +import ( + "bytes" + _ "embed" + "os/exec" + "path/filepath" + + "github.com/zitadel/logging" + "sigs.k8s.io/yaml" +) + +type Config struct { + Log *logging.Config + Hostname string + Port uint16 + Secure bool + LoginURLV2 string + LogoutURLV2 string + WebAuthNName string +} + +var ( + //go:embed config/client.yaml + clientYAML []byte +) + +var ( + tmpDir string + loadedConfig Config +) + +// TmpDir returns the absolute path to the projects's temp directory. +func TmpDir() string { + return tmpDir +} + +func init() { + cmd := exec.Command("git", "rev-parse", "--show-toplevel") + out, err := cmd.Output() + if err != nil { + panic(err) + } + tmpDir = filepath.Join(string(bytes.TrimSpace(out)), "tmp") + + if err := yaml.Unmarshal(clientYAML, &loadedConfig); err != nil { + panic(err) + } + if err := loadedConfig.Log.SetLogger(); err != nil { + panic(err) + } + SystemToken = systemUserToken() +} diff --git a/internal/integration/config/client.yaml b/internal/integration/config/client.yaml new file mode 100644 index 0000000000..43e417d4d6 --- /dev/null +++ b/internal/integration/config/client.yaml @@ -0,0 +1,10 @@ +Log: + Level: info + Formatter: + Format: text +Hostname: localhost +Port: 8080 +Secure: false +LoginURLV2: "/login?authRequest=" +LogoutURLV2: "/logout?post_logout_redirect=" +WebAuthNName: ZITADEL diff --git a/internal/integration/config/docker-compose.yaml b/internal/integration/config/docker-compose.yaml index b7f065fad7..1749b9f0ab 100644 --- a/internal/integration/config/docker-compose.yaml +++ b/internal/integration/config/docker-compose.yaml @@ -14,7 +14,7 @@ services: - PGUSER=zitadel - POSTGRES_DB=zitadel - POSTGRES_HOST_AUTH_METHOD=trust - command: postgres -c shared_preload_libraries=pg_stat_statements -c pg_stat_statements.track=all + command: postgres -c shared_preload_libraries=pg_stat_statements -c pg_stat_statements.track=all -c shared_buffers=1GB -c work_mem=16MB -c effective_io_concurrency=100 -c wal_level=minimal -c archive_mode=off -c max_wal_senders=0 healthcheck: test: ["CMD-SHELL", "pg_isready"] interval: '10s' diff --git a/internal/integration/config/postgres.yaml b/internal/integration/config/postgres.yaml index c75187d7fb..9cabb440c0 100644 --- a/internal/integration/config/postgres.yaml +++ b/internal/integration/config/postgres.yaml @@ -1,10 +1,12 @@ Database: + EventPushConnRatio: 0.2 # 4 + ProjectionSpoolerConnRatio: 0.3 # 6 postgres: Host: localhost Port: 5432 Database: zitadel - MaxOpenConns: 15 - MaxIdleConns: 10 + MaxOpenConns: 20 + MaxIdleConns: 20 User: Username: zitadel SSL: diff --git a/internal/integration/config/steps.yaml b/internal/integration/config/steps.yaml new file mode 100644 index 0000000000..fea8441e2b --- /dev/null +++ b/internal/integration/config/steps.yaml @@ -0,0 +1,13 @@ +FirstInstance: + Skip: false + PatPath: tmp/admin-pat.txt + InstanceName: ZITADEL + DefaultLanguage: en + Org: + Name: ZITADEL + Machine: + Machine: + Username: boss + Name: boss + Pat: + ExpirationDate: 2099-01-01T00:00:00Z diff --git a/internal/integration/config/zitadel.yaml b/internal/integration/config/zitadel.yaml index d58529b7a8..68e0b43f9c 100644 --- a/internal/integration/config/zitadel.yaml +++ b/internal/integration/config/zitadel.yaml @@ -1,15 +1,19 @@ Log: - Level: debug + Level: info ExternalSecure: false TLS: Enabled: false +Quotas: + Access: + Enabled: true + Telemetry: Enabled: true Endpoints: - - http://localhost:8081 + - http://localhost:8081/milestone Headers: single-value: "single-value" multi-value: @@ -27,14 +31,15 @@ LogStore: Enabled: true Projections: - HandleActiveInstances: 60s + HandleActiveInstances: 30m + RequeueEvery: 5s + TransactionDuration: 1m Customizations: NotificationsQuotas: RequeueEvery: 1s + telemetry: HandleActiveInstances: 60s - Telemetry: - RequeueEvery: 5s - HandleActiveInstances: 60s + RequeueEvery: 1s DefaultInstance: LoginPolicy: diff --git a/internal/integration/instance.go b/internal/integration/instance.go new file mode 100644 index 0000000000..c6e0b9737c --- /dev/null +++ b/internal/integration/instance.go @@ -0,0 +1,354 @@ +// Package integration provides helpers for integration testing. +package integration + +import ( + "bytes" + "context" + _ "embed" + "errors" + "fmt" + "os" + "path/filepath" + "time" + + "github.com/brianvoe/gofakeit/v6" + "github.com/zitadel/logging" + "google.golang.org/grpc/metadata" + "google.golang.org/protobuf/proto" + + http_util "github.com/zitadel/zitadel/internal/api/http" + "github.com/zitadel/zitadel/internal/webauthn" + "github.com/zitadel/zitadel/pkg/grpc/admin" + "github.com/zitadel/zitadel/pkg/grpc/auth" + "github.com/zitadel/zitadel/pkg/grpc/instance" + "github.com/zitadel/zitadel/pkg/grpc/management" + "github.com/zitadel/zitadel/pkg/grpc/org" + "github.com/zitadel/zitadel/pkg/grpc/system" + "github.com/zitadel/zitadel/pkg/grpc/user" + user_v2 "github.com/zitadel/zitadel/pkg/grpc/user/v2" +) + +// NotEmpty can be used as placeholder, when the returned values is unknown. +// It can be used in tests to assert whether a value should be empty or not. +const NotEmpty = "not empty" + +const ( + adminPATFile = "admin-pat.txt" +) + +// UserType provides constants that give +// a short explanation with the purpose +// a service user. +// This allows to pre-create users with +// different permissions and reuse them. +type UserType int + +//go:generate enumer -type UserType -transform snake -trimprefix UserType +const ( + UserTypeUnspecified UserType = iota + UserTypeIAMOwner + UserTypeOrgOwner + UserTypeLogin +) + +const ( + UserPassword = "VeryS3cret!" +) + +const ( + PortMilestoneServer = "8081" + PortQuotaServer = "8082" +) + +// User information with a Personal Access Token. +type User struct { + ID string + Username string + Token string +} + +type UserMap map[UserType]*User + +func (m UserMap) Set(typ UserType, user *User) { + m[typ] = user +} + +func (m UserMap) Get(typ UserType) *User { + return m[typ] +} + +// Host returns the primary host of zitadel, on which the first instance is served. +// http://localhost:8080 by default +func (c *Config) Host() string { + return fmt.Sprintf("%s:%d", c.Hostname, c.Port) +} + +// Instance is a Zitadel server and client with all resources available for testing. +type Instance struct { + Config Config + Domain string + Instance *instance.InstanceDetail + DefaultOrg *org.Org + Users UserMap + AdminUserID string // First human user for password login + + Client *Client + WebAuthN *webauthn.Client +} + +// GetFirstInstance returns the default instance and org information, +// with authorized machine users. +// Using the first instance is not recommended as parallel test might +// interfere with each other. +// It is recommended to use [NewInstance] instead. +func GetFirstInstance(ctx context.Context) *Instance { + i := &Instance{ + Config: loadedConfig, + Domain: loadedConfig.Hostname, + } + token := loadInstanceOwnerPAT() + i.setClient(ctx) + i.setupInstance(ctx, token) + return i +} + +// NewInstance returns a new instance that can be used for integration tests. +// The instance contains a gRPC client connected to the domain of this instance. +// The included users are the IAM_OWNER, ORG_OWNER of the default org and +// a Login client user. +// +// The instance is isolated and is safe for parallel testing. +func NewInstance(ctx context.Context) *Instance { + primaryDomain := RandString(5) + ".integration.localhost" + + ctx = WithSystemAuthorization(ctx) + resp, err := SystemClient().CreateInstance(ctx, &system.CreateInstanceRequest{ + InstanceName: "testinstance", + CustomDomain: primaryDomain, + Owner: &system.CreateInstanceRequest_Machine_{ + Machine: &system.CreateInstanceRequest_Machine{ + UserName: "owner", + Name: "owner", + PersonalAccessToken: &system.CreateInstanceRequest_PersonalAccessToken{}, + }, + }, + }) + if err != nil { + panic(err) + } + i := &Instance{ + Config: loadedConfig, + Domain: primaryDomain, + } + i.setClient(ctx) + i.awaitFirstUser(WithAuthorizationToken(ctx, resp.GetPat())) + i.setupInstance(ctx, resp.GetPat()) + return i +} + +func (i *Instance) ID() string { + return i.Instance.GetId() +} + +func (i *Instance) awaitFirstUser(ctx context.Context) { + var allErrs []error + for { + resp, err := i.Client.UserV2.AddHumanUser(ctx, &user_v2.AddHumanUserRequest{ + Username: proto.String("zitadel-admin@zitadel.localhost"), + Profile: &user_v2.SetHumanProfile{ + GivenName: "hodor", + FamilyName: "hodor", + NickName: proto.String("hodor"), + }, + Email: &user_v2.SetHumanEmail{ + Email: "zitadel-admin@zitadel.localhost", + Verification: &user_v2.SetHumanEmail_IsVerified{ + IsVerified: true, + }, + }, + PasswordType: &user_v2.AddHumanUserRequest_Password{ + Password: &user_v2.Password{ + Password: "Password1!", + ChangeRequired: false, + }, + }, + }) + if err == nil { + i.AdminUserID = resp.GetUserId() + return + } + logging.WithError(err).Debug("await first instance user") + allErrs = append(allErrs, err) + select { + case <-ctx.Done(): + panic(errors.Join(append(allErrs, ctx.Err())...)) + case <-time.After(time.Second): + continue + } + } +} + +func (i *Instance) setupInstance(ctx context.Context, token string) { + i.Users = make(UserMap) + ctx = WithAuthorizationToken(ctx, token) + i.setInstance(ctx) + i.setOrganization(ctx) + i.createMachineUserInstanceOwner(ctx, token) + i.createMachineUserOrgOwner(ctx) + i.createLoginClient(ctx) + i.createWebAuthNClient() +} + +// Host returns the primary Domain of the instance with the port. +func (i *Instance) Host() string { + return fmt.Sprintf("%s:%d", i.Domain, i.Config.Port) +} + +func loadInstanceOwnerPAT() string { + data, err := os.ReadFile(filepath.Join(tmpDir, adminPATFile)) + if err != nil { + panic(err) + } + return string(bytes.TrimSpace(data)) +} + +func (i *Instance) createMachineUserInstanceOwner(ctx context.Context, token string) { + mustAwait(func() error { + user, err := i.Client.Auth.GetMyUser(WithAuthorizationToken(ctx, token), &auth.GetMyUserRequest{}) + if err != nil { + return err + } + i.Users.Set(UserTypeIAMOwner, &User{ + ID: user.GetUser().GetId(), + Username: user.GetUser().GetUserName(), + Token: token, + }) + return nil + }) +} + +func (i *Instance) createMachineUserOrgOwner(ctx context.Context) { + _, err := i.Client.Mgmt.AddOrgMember(ctx, &management.AddOrgMemberRequest{ + UserId: i.createMachineUser(ctx, UserTypeOrgOwner), + Roles: []string{"ORG_OWNER"}, + }) + if err != nil { + panic(err) + } +} + +func (i *Instance) createLoginClient(ctx context.Context) { + i.createMachineUser(ctx, UserTypeLogin) +} + +func (i *Instance) setClient(ctx context.Context) { + client, err := newClient(ctx, i.Host()) + if err != nil { + panic(err) + } + i.Client = client +} + +func (i *Instance) setInstance(ctx context.Context) { + mustAwait(func() error { + instance, err := i.Client.Admin.GetMyInstance(ctx, &admin.GetMyInstanceRequest{}) + i.Instance = instance.GetInstance() + return err + }) +} + +func (i *Instance) setOrganization(ctx context.Context) { + mustAwait(func() error { + resp, err := i.Client.Mgmt.GetMyOrg(ctx, &management.GetMyOrgRequest{}) + i.DefaultOrg = resp.GetOrg() + return err + }) +} + +func (i *Instance) createMachineUser(ctx context.Context, userType UserType) (userID string) { + mustAwait(func() error { + username := gofakeit.Username() + userResp, err := i.Client.Mgmt.AddMachineUser(ctx, &management.AddMachineUserRequest{ + UserName: username, + Name: username, + Description: userType.String(), + AccessTokenType: user.AccessTokenType_ACCESS_TOKEN_TYPE_JWT, + }) + if err != nil { + return err + } + userID = userResp.GetUserId() + patResp, err := i.Client.Mgmt.AddPersonalAccessToken(ctx, &management.AddPersonalAccessTokenRequest{ + UserId: userID, + }) + if err != nil { + return err + } + i.Users.Set(userType, &User{ + ID: userID, + Username: username, + Token: patResp.GetToken(), + }) + return nil + }) + return userID +} + +func (i *Instance) createWebAuthNClient() { + i.WebAuthN = webauthn.NewClient(i.Config.WebAuthNName, i.Domain, http_util.BuildOrigin(i.Host(), i.Config.Secure)) +} + +func (i *Instance) WithAuthorization(ctx context.Context, u UserType) context.Context { + return i.WithInstanceAuthorization(ctx, u) +} + +func (i *Instance) WithInstanceAuthorization(ctx context.Context, u UserType) context.Context { + return WithAuthorizationToken(ctx, i.Users.Get(u).Token) +} + +func (i *Instance) GetUserID(u UserType) string { + return i.Users.Get(u).ID +} + +func WithAuthorizationToken(ctx context.Context, token string) context.Context { + md, ok := metadata.FromOutgoingContext(ctx) + if !ok { + md = make(metadata.MD) + } + md.Set("Authorization", fmt.Sprintf("Bearer %s", token)) + return metadata.NewOutgoingContext(ctx, md) +} + +func (i *Instance) BearerToken(ctx context.Context) string { + md, ok := metadata.FromOutgoingContext(ctx) + if !ok { + return "" + } + return md.Get("Authorization")[0] +} + +func (i *Instance) WithSystemAuthorizationHTTP(u UserType) map[string]string { + return map[string]string{"Authorization": fmt.Sprintf("Bearer %s", i.Users.Get(u).Token)} +} + +func await(af func() error) error { + maxTimer := time.NewTimer(15 * time.Minute) + for { + err := af() + if err == nil { + return nil + } + select { + case <-maxTimer.C: + return err + case <-time.After(time.Second): + continue + } + } +} + +func mustAwait(af func() error) { + if err := await(af); err != nil { + panic(err) + } +} diff --git a/internal/integration/integration.go b/internal/integration/integration.go deleted file mode 100644 index 20af65993b..0000000000 --- a/internal/integration/integration.go +++ /dev/null @@ -1,449 +0,0 @@ -// Package integration provides helpers for integration testing. -package integration - -import ( - "bytes" - "context" - "database/sql" - _ "embed" - "errors" - "fmt" - "io" - "net/http" - "net/http/httptest" - "os" - "reflect" - "strings" - "sync" - "time" - - "github.com/spf13/viper" - "github.com/zitadel/logging" - "github.com/zitadel/oidc/v3/pkg/client" - "github.com/zitadel/oidc/v3/pkg/oidc" - "google.golang.org/grpc" - "google.golang.org/grpc/credentials/insecure" - "google.golang.org/grpc/metadata" - - "github.com/zitadel/zitadel/cmd" - "github.com/zitadel/zitadel/cmd/start" - "github.com/zitadel/zitadel/internal/api/authz" - http_util "github.com/zitadel/zitadel/internal/api/http" - z_oidc "github.com/zitadel/zitadel/internal/api/oidc" - "github.com/zitadel/zitadel/internal/command" - "github.com/zitadel/zitadel/internal/domain" - "github.com/zitadel/zitadel/internal/eventstore/v1/models" - "github.com/zitadel/zitadel/internal/net" - "github.com/zitadel/zitadel/internal/query" - "github.com/zitadel/zitadel/internal/webauthn" - "github.com/zitadel/zitadel/internal/zerrors" - "github.com/zitadel/zitadel/pkg/grpc/admin" -) - -var ( - //go:embed config/zitadel.yaml - zitadelYAML []byte - //go:embed config/cockroach.yaml - cockroachYAML []byte - //go:embed config/postgres.yaml - postgresYAML []byte - //go:embed config/system-user-key.pem - systemUserKey []byte -) - -// NotEmpty can be used as placeholder, when the returned values is unknown. -// It can be used in tests to assert whether a value should be empty or not. -const NotEmpty = "not empty" - -// UserType provides constants that give -// a short explinanation with the purpose -// a serverice user. -// This allows to pre-create users with -// different permissions and reuse them. -type UserType int - -//go:generate stringer -type=UserType -const ( - Unspecified UserType = iota - OrgOwner - Login - IAMOwner - SystemUser // SystemUser is a user with access to the system service. -) - -const ( - FirstInstanceUsersKey = "first" - UserPassword = "VeryS3cret!" -) - -const ( - PortMilestoneServer = "8081" - PortQuotaServer = "8082" -) - -// User information with a Personal Access Token. -type User struct { - *query.User - Token string -} - -type InstanceUserMap map[string]map[UserType]*User - -func (m InstanceUserMap) Set(instanceID string, typ UserType, user *User) { - if m[instanceID] == nil { - m[instanceID] = make(map[UserType]*User) - } - m[instanceID][typ] = user -} - -func (m InstanceUserMap) Get(instanceID string, typ UserType) *User { - if users, ok := m[instanceID]; ok { - return users[typ] - } - return nil -} - -// Tester is a Zitadel server and client with all resources available for testing. -type Tester struct { - *start.Server - - Instance authz.Instance - Organisation *query.Org - Users InstanceUserMap - - MilestoneChan chan []byte - milestoneServer *httptest.Server - QuotaNotificationChan chan []byte - quotaNotificationServer *httptest.Server - - Client Client - WebAuthN *webauthn.Client - wg sync.WaitGroup // used for shutdown -} - -const commandLine = `start --masterkeyFromEnv` - -func (s *Tester) Host() string { - return fmt.Sprintf("%s:%d", s.Config.ExternalDomain, s.Config.Port) -} - -func (s *Tester) createClientConn(ctx context.Context, target string) { - cc, err := grpc.DialContext(ctx, target, - grpc.WithBlock(), - grpc.WithTransportCredentials(insecure.NewCredentials()), - ) - if err != nil { - s.Shutdown <- os.Interrupt - s.wg.Wait() - } - logging.OnError(err).Fatal("integration tester client dial") - logging.New().WithField("target", target).Info("finished dialing grpc client conn") - - s.Client = newClient(cc) - err = s.pollHealth(ctx) - logging.OnError(err).Fatal("integration tester health") -} - -// pollHealth waits until a healthy status is reported. -// TODO: remove when we make the setup blocking on all -// projections completed. -func (s *Tester) pollHealth(ctx context.Context) (err error) { - for { - err = func(ctx context.Context) error { - ctx, cancel := context.WithTimeout(ctx, 5*time.Second) - defer cancel() - - _, err := s.Client.Admin.Healthz(ctx, &admin.HealthzRequest{}) - return err - }(ctx) - if err == nil { - return nil - } - logging.WithError(err).Info("poll healthz") - - select { - case <-ctx.Done(): - return ctx.Err() - case <-time.After(time.Second): - continue - } - } -} - -const ( - LoginUser = "loginClient" - MachineUserOrgOwner = "integrationOrgOwner" - MachineUserInstanceOwner = "integrationInstanceOwner" -) - -func (s *Tester) createMachineUserOrgOwner(ctx context.Context) { - var err error - - ctx, user := s.createMachineUser(ctx, MachineUserOrgOwner, OrgOwner) - _, err = s.Commands.AddOrgMember(ctx, user.ResourceOwner, user.ID, "ORG_OWNER") - target := new(zerrors.AlreadyExistsError) - if !errors.As(err, &target) { - logging.OnError(err).Fatal("add org member") - } -} - -func (s *Tester) createMachineUserInstanceOwner(ctx context.Context) { - var err error - - ctx, user := s.createMachineUser(ctx, MachineUserInstanceOwner, IAMOwner) - _, err = s.Commands.AddInstanceMember(ctx, user.ID, "IAM_OWNER") - target := new(zerrors.AlreadyExistsError) - if !errors.As(err, &target) { - logging.OnError(err).Fatal("add instance member") - } -} - -func (s *Tester) createLoginClient(ctx context.Context) { - s.createMachineUser(ctx, LoginUser, Login) -} - -func (s *Tester) createMachineUser(ctx context.Context, username string, userType UserType) (context.Context, *query.User) { - var err error - ctx = s.updateInstanceAndOrg(ctx, s.Host()) - usernameQuery, err := query.NewUserUsernameSearchQuery(username, query.TextEquals) - logging.OnError(err).Fatal("user query") - user, err := s.Queries.GetUser(ctx, true, usernameQuery) - if errors.Is(err, sql.ErrNoRows) { - _, err = s.Commands.AddMachine(ctx, &command.Machine{ - ObjectRoot: models.ObjectRoot{ - ResourceOwner: s.Organisation.ID, - }, - Username: username, - Name: username, - Description: "who cares?", - AccessTokenType: domain.OIDCTokenTypeJWT, - }) - logging.WithFields("username", username).OnError(err).Fatal("add machine user") - user, err = s.Queries.GetUser(ctx, true, usernameQuery) - } - logging.WithFields("username", username).OnError(err).Fatal("get user") - - scopes := []string{oidc.ScopeOpenID, oidc.ScopeProfile, z_oidc.ScopeUserMetaData, z_oidc.ScopeResourceOwner} - pat := command.NewPersonalAccessToken(user.ResourceOwner, user.ID, time.Now().Add(time.Hour), scopes, domain.UserTypeMachine) - _, err = s.Commands.AddPersonalAccessToken(ctx, pat) - logging.WithFields("username", SystemUser).OnError(err).Fatal("add pat") - s.Users.Set(FirstInstanceUsersKey, userType, &User{ - User: user, - Token: pat.Token, - }) - return ctx, user -} - -func (s *Tester) WithAuthorization(ctx context.Context, u UserType) context.Context { - return s.WithInstanceAuthorization(ctx, u, FirstInstanceUsersKey) -} - -func (s *Tester) WithInstanceAuthorization(ctx context.Context, u UserType, instanceID string) context.Context { - if u == SystemUser { - s.ensureSystemUser() - } - return s.WithAuthorizationToken(ctx, s.Users.Get(instanceID, u).Token) -} - -func (s *Tester) GetUserID(u UserType) string { - if u == SystemUser { - s.ensureSystemUser() - } - return s.Users.Get(FirstInstanceUsersKey, u).ID -} - -func (s *Tester) WithAuthorizationToken(ctx context.Context, token string) context.Context { - md, ok := metadata.FromOutgoingContext(ctx) - if !ok { - md = make(metadata.MD) - } - md.Set("Authorization", fmt.Sprintf("Bearer %s", token)) - return metadata.NewOutgoingContext(ctx, md) -} - -func (s *Tester) BearerToken(ctx context.Context) string { - md, ok := metadata.FromOutgoingContext(ctx) - if !ok { - return "" - } - return md.Get("Authorization")[0] -} - -func (s *Tester) ensureSystemUser() { - const ISSUER = "tester" - if s.Users.Get(FirstInstanceUsersKey, SystemUser) != nil { - return - } - audience := http_util.BuildOrigin(s.Host(), s.Server.Config.ExternalSecure) - signer, err := client.NewSignerFromPrivateKeyByte(systemUserKey, "") - logging.OnError(err).Fatal("system key signer") - jwt, err := client.SignedJWTProfileAssertion(ISSUER, []string{audience}, time.Hour, signer) - logging.OnError(err).Fatal("system key jwt") - s.Users.Set(FirstInstanceUsersKey, SystemUser, &User{Token: jwt}) -} - -func (s *Tester) WithSystemAuthorizationHTTP(u UserType) map[string]string { - return map[string]string{"Authorization": fmt.Sprintf("Bearer %s", s.Users.Get(FirstInstanceUsersKey, u).Token)} -} - -// Done send an interrupt signal to cleanly shutdown the server. -func (s *Tester) Done() { - err := s.Client.CC.Close() - logging.OnError(err).Error("integration tester client close") - - s.Shutdown <- os.Interrupt - s.wg.Wait() - s.milestoneServer.Close() - s.quotaNotificationServer.Close() -} - -// NewTester start a new Zitadel server by passing the default commandline. -// The server will listen on the configured port. -// The database configuration that will be used can be set by the -// INTEGRATION_DB_FLAVOR environment variable and can have the values "cockroach" -// or "postgres". Defaults to "cockroach". -// -// The default Instance and Organisation are read from the DB and system -// users are created as needed. -// -// After the server is started, a [grpc.ClientConn] will be created and -// the server is polled for it's health status. -// -// Note: the database must already be setup and initialized before -// using NewTester. See the CONTRIBUTING.md document for details. - -func NewTester(ctx context.Context, zitadelConfigYAML ...string) *Tester { - args := strings.Split(commandLine, " ") - - sc := make(chan *start.Server) - //nolint:contextcheck - cmd := cmd.New(os.Stdout, os.Stdin, args, sc) - cmd.SetArgs(args) - for _, yaml := range append([]string{string(zitadelYAML)}, zitadelConfigYAML...) { - err := viper.MergeConfig(bytes.NewBuffer([]byte(yaml))) - logging.OnError(err).Fatal() - } - var err error - flavor := os.Getenv("INTEGRATION_DB_FLAVOR") - switch flavor { - case "cockroach", "": - err = viper.MergeConfig(bytes.NewBuffer(cockroachYAML)) - case "postgres": - err = viper.MergeConfig(bytes.NewBuffer(postgresYAML)) - default: - logging.New().WithField("flavor", flavor).Fatal("unknown db flavor set in INTEGRATION_DB_FLAVOR") - } - logging.OnError(err).Fatal() - - tester := Tester{ - Users: make(InstanceUserMap), - } - tester.MilestoneChan = make(chan []byte, 100) - tester.milestoneServer, err = runMilestoneServer(ctx, tester.MilestoneChan) - logging.OnError(err).Fatal() - tester.QuotaNotificationChan = make(chan []byte, 100) - tester.quotaNotificationServer, err = runQuotaServer(ctx, tester.QuotaNotificationChan) - logging.OnError(err).Fatal() - - tester.wg.Add(1) - go func(wg *sync.WaitGroup) { - logging.OnError(cmd.Execute()).Fatal() - wg.Done() - }(&tester.wg) - - select { - case tester.Server = <-sc: - case <-ctx.Done(): - logging.OnError(ctx.Err()).Fatal("waiting for integration tester server") - } - host := tester.Host() - tester.createClientConn(ctx, host) - tester.createLoginClient(ctx) - tester.WebAuthN = webauthn.NewClient(tester.Config.WebAuthNName, tester.Config.ExternalDomain, http_util.BuildOrigin(host, tester.Config.ExternalSecure)) - tester.createMachineUserOrgOwner(ctx) - tester.createMachineUserInstanceOwner(ctx) - tester.WebAuthN = webauthn.NewClient(tester.Config.WebAuthNName, tester.Config.ExternalDomain, "https://"+tester.Host()) - return &tester -} - -func Contexts(timeout time.Duration) (ctx, errCtx context.Context, cancel context.CancelFunc) { - errCtx, cancel = context.WithCancel(context.Background()) - cancel() - ctx, cancel = context.WithTimeout(context.Background(), timeout) - return ctx, errCtx, cancel -} - -func runMilestoneServer(ctx context.Context, bodies chan []byte) (*httptest.Server, error) { - mockServer := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - body, err := io.ReadAll(r.Body) - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - if r.Header.Get("single-value") != "single-value" { - http.Error(w, "single-value header not set", http.StatusInternalServerError) - return - } - if reflect.DeepEqual(r.Header.Get("multi-value"), "multi-value-1,multi-value-2") { - http.Error(w, "single-value header not set", http.StatusInternalServerError) - return - } - bodies <- body - w.WriteHeader(http.StatusOK) - })) - config := net.ListenConfig() - listener, err := config.Listen(ctx, "tcp", ":"+PortMilestoneServer) - if err != nil { - return nil, err - } - mockServer.Listener = listener - mockServer.Start() - return mockServer, nil -} - -func runQuotaServer(ctx context.Context, bodies chan []byte) (*httptest.Server, error) { - mockServer := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - body, err := io.ReadAll(r.Body) - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - bodies <- body - w.WriteHeader(http.StatusOK) - })) - config := net.ListenConfig() - listener, err := config.Listen(ctx, "tcp", ":"+PortQuotaServer) - if err != nil { - return nil, err - } - mockServer.Listener = listener - mockServer.Start() - return mockServer, nil -} - -func (s *Tester) updateInstanceAndOrg(ctx context.Context, domain string) context.Context { - var err error - s.Instance, err = s.Queries.InstanceByHost(ctx, domain, "") - logging.OnError(err).Fatal("query instance") - ctx = authz.WithInstance(ctx, s.Instance) - - s.Organisation, err = s.Queries.OrgByID(ctx, true, s.Instance.DefaultOrganisationID()) - logging.OnError(err).Fatal("query organisation") - return ctx -} - -func await(af func() error) error { - maxTimer := time.NewTimer(15 * time.Minute) - for { - err := af() - if err == nil { - return nil - } - select { - case <-maxTimer.C: - return err - case <-time.After(time.Second / 10): - continue - } - } -} diff --git a/internal/integration/integration_test.go b/internal/integration/integration_test.go deleted file mode 100644 index 416602ea25..0000000000 --- a/internal/integration/integration_test.go +++ /dev/null @@ -1,16 +0,0 @@ -//go:build integration - -package integration - -import ( - "testing" - "time" -) - -func TestNewTester(t *testing.T) { - ctx, _, cancel := Contexts(time.Hour) - defer cancel() - - s := NewTester(ctx) - defer s.Done() -} diff --git a/internal/integration/oidc.go b/internal/integration/oidc.go index 1d15d25f29..9f394e2c2c 100644 --- a/internal/integration/oidc.go +++ b/internal/integration/oidc.go @@ -24,11 +24,11 @@ import ( "github.com/zitadel/zitadel/pkg/grpc/user" ) -func (s *Tester) CreateOIDCClient(ctx context.Context, redirectURI, logoutRedirectURI, projectID string, appType app.OIDCAppType, authMethod app.OIDCAuthMethodType, devMode bool, grantTypes ...app.OIDCGrantType) (*management.AddOIDCAppResponse, error) { +func (i *Instance) CreateOIDCClient(ctx context.Context, redirectURI, logoutRedirectURI, projectID string, appType app.OIDCAppType, authMethod app.OIDCAuthMethodType, devMode bool, grantTypes ...app.OIDCGrantType) (*management.AddOIDCAppResponse, error) { if len(grantTypes) == 0 { grantTypes = []app.OIDCGrantType{app.OIDCGrantType_OIDC_GRANT_TYPE_AUTHORIZATION_CODE, app.OIDCGrantType_OIDC_GRANT_TYPE_REFRESH_TOKEN} } - resp, err := s.Client.Mgmt.AddOIDCApp(ctx, &management.AddOIDCAppRequest{ + resp, err := i.Client.Mgmt.AddOIDCApp(ctx, &management.AddOIDCAppRequest{ ProjectId: projectID, Name: fmt.Sprintf("app-%d", time.Now().UnixNano()), RedirectUris: []string{redirectURI}, @@ -51,7 +51,7 @@ func (s *Tester) CreateOIDCClient(ctx context.Context, redirectURI, logoutRedire return nil, err } return resp, await(func() error { - _, err := s.Client.Mgmt.GetAppByID(ctx, &management.GetAppByIDRequest{ + _, err := i.Client.Mgmt.GetAppByID(ctx, &management.GetAppByIDRequest{ ProjectId: projectID, AppId: resp.GetAppId(), }) @@ -59,20 +59,20 @@ func (s *Tester) CreateOIDCClient(ctx context.Context, redirectURI, logoutRedire }) } -func (s *Tester) CreateOIDCNativeClient(ctx context.Context, redirectURI, logoutRedirectURI, projectID string, devMode bool) (*management.AddOIDCAppResponse, error) { - return s.CreateOIDCClient(ctx, redirectURI, logoutRedirectURI, projectID, app.OIDCAppType_OIDC_APP_TYPE_NATIVE, app.OIDCAuthMethodType_OIDC_AUTH_METHOD_TYPE_NONE, devMode) +func (i *Instance) CreateOIDCNativeClient(ctx context.Context, redirectURI, logoutRedirectURI, projectID string, devMode bool) (*management.AddOIDCAppResponse, error) { + return i.CreateOIDCClient(ctx, redirectURI, logoutRedirectURI, projectID, app.OIDCAppType_OIDC_APP_TYPE_NATIVE, app.OIDCAuthMethodType_OIDC_AUTH_METHOD_TYPE_NONE, devMode) } -func (s *Tester) CreateOIDCWebClientBasic(ctx context.Context, redirectURI, logoutRedirectURI, projectID string) (*management.AddOIDCAppResponse, error) { - return s.CreateOIDCClient(ctx, redirectURI, logoutRedirectURI, projectID, app.OIDCAppType_OIDC_APP_TYPE_WEB, app.OIDCAuthMethodType_OIDC_AUTH_METHOD_TYPE_BASIC, false) +func (i *Instance) CreateOIDCWebClientBasic(ctx context.Context, redirectURI, logoutRedirectURI, projectID string) (*management.AddOIDCAppResponse, error) { + return i.CreateOIDCClient(ctx, redirectURI, logoutRedirectURI, projectID, app.OIDCAppType_OIDC_APP_TYPE_WEB, app.OIDCAuthMethodType_OIDC_AUTH_METHOD_TYPE_BASIC, false) } -func (s *Tester) CreateOIDCWebClientJWT(ctx context.Context, redirectURI, logoutRedirectURI, projectID string, grantTypes ...app.OIDCGrantType) (client *management.AddOIDCAppResponse, keyData []byte, err error) { - client, err = s.CreateOIDCClient(ctx, redirectURI, logoutRedirectURI, projectID, app.OIDCAppType_OIDC_APP_TYPE_WEB, app.OIDCAuthMethodType_OIDC_AUTH_METHOD_TYPE_PRIVATE_KEY_JWT, false, grantTypes...) +func (i *Instance) CreateOIDCWebClientJWT(ctx context.Context, redirectURI, logoutRedirectURI, projectID string, grantTypes ...app.OIDCGrantType) (client *management.AddOIDCAppResponse, keyData []byte, err error) { + client, err = i.CreateOIDCClient(ctx, redirectURI, logoutRedirectURI, projectID, app.OIDCAppType_OIDC_APP_TYPE_WEB, app.OIDCAuthMethodType_OIDC_AUTH_METHOD_TYPE_PRIVATE_KEY_JWT, false, grantTypes...) if err != nil { return nil, nil, err } - key, err := s.Client.Mgmt.AddAppKey(ctx, &management.AddAppKeyRequest{ + key, err := i.Client.Mgmt.AddAppKey(ctx, &management.AddAppKeyRequest{ ProjectId: projectID, AppId: client.GetAppId(), Type: authn.KeyType_KEY_TYPE_JSON, @@ -81,15 +81,23 @@ func (s *Tester) CreateOIDCWebClientJWT(ctx context.Context, redirectURI, logout if err != nil { return nil, nil, err } + mustAwait(func() error { + _, err := i.Client.Mgmt.GetAppByID(ctx, &management.GetAppByIDRequest{ + ProjectId: projectID, + AppId: client.GetAppId(), + }) + return err + }) + return client, key.GetKeyDetails(), nil } -func (s *Tester) CreateOIDCInactivateClient(ctx context.Context, redirectURI, logoutRedirectURI, projectID string) (*management.AddOIDCAppResponse, error) { - client, err := s.CreateOIDCNativeClient(ctx, redirectURI, logoutRedirectURI, projectID, false) +func (i *Instance) CreateOIDCInactivateClient(ctx context.Context, redirectURI, logoutRedirectURI, projectID string) (*management.AddOIDCAppResponse, error) { + client, err := i.CreateOIDCNativeClient(ctx, redirectURI, logoutRedirectURI, projectID, false) if err != nil { return nil, err } - _, err = s.Client.Mgmt.DeactivateApp(ctx, &management.DeactivateAppRequest{ + _, err = i.Client.Mgmt.DeactivateApp(ctx, &management.DeactivateAppRequest{ ProjectId: projectID, AppId: client.GetAppId(), }) @@ -99,14 +107,14 @@ func (s *Tester) CreateOIDCInactivateClient(ctx context.Context, redirectURI, lo return client, err } -func (s *Tester) CreateOIDCImplicitFlowClient(ctx context.Context, redirectURI string) (*management.AddOIDCAppResponse, error) { - project, err := s.Client.Mgmt.AddProject(ctx, &management.AddProjectRequest{ +func (i *Instance) CreateOIDCImplicitFlowClient(ctx context.Context, redirectURI string) (*management.AddOIDCAppResponse, error) { + project, err := i.Client.Mgmt.AddProject(ctx, &management.AddProjectRequest{ Name: fmt.Sprintf("project-%d", time.Now().UnixNano()), }) if err != nil { return nil, err } - resp, err := s.Client.Mgmt.AddOIDCApp(ctx, &management.AddOIDCAppRequest{ + resp, err := i.Client.Mgmt.AddOIDCApp(ctx, &management.AddOIDCAppRequest{ ProjectId: project.GetId(), Name: fmt.Sprintf("app-%d", time.Now().UnixNano()), RedirectUris: []string{redirectURI}, @@ -129,7 +137,7 @@ func (s *Tester) CreateOIDCImplicitFlowClient(ctx context.Context, redirectURI s return nil, err } return resp, await(func() error { - _, err := s.Client.Mgmt.GetAppByID(ctx, &management.GetAppByIDRequest{ + _, err := i.Client.Mgmt.GetAppByID(ctx, &management.GetAppByIDRequest{ ProjectId: project.GetId(), AppId: resp.GetAppId(), }) @@ -137,32 +145,32 @@ func (s *Tester) CreateOIDCImplicitFlowClient(ctx context.Context, redirectURI s }) } -func (s *Tester) CreateOIDCTokenExchangeClient(ctx context.Context) (client *management.AddOIDCAppResponse, keyData []byte, err error) { - project, err := s.Client.Mgmt.AddProject(ctx, &management.AddProjectRequest{ +func (i *Instance) CreateOIDCTokenExchangeClient(ctx context.Context) (client *management.AddOIDCAppResponse, keyData []byte, err error) { + project, err := i.Client.Mgmt.AddProject(ctx, &management.AddProjectRequest{ Name: fmt.Sprintf("project-%d", time.Now().UnixNano()), }) if err != nil { return nil, nil, err } - return s.CreateOIDCWebClientJWT(ctx, "", "", project.GetId(), app.OIDCGrantType_OIDC_GRANT_TYPE_TOKEN_EXCHANGE, app.OIDCGrantType_OIDC_GRANT_TYPE_AUTHORIZATION_CODE, app.OIDCGrantType_OIDC_GRANT_TYPE_REFRESH_TOKEN) + return i.CreateOIDCWebClientJWT(ctx, "", "", project.GetId(), app.OIDCGrantType_OIDC_GRANT_TYPE_TOKEN_EXCHANGE, app.OIDCGrantType_OIDC_GRANT_TYPE_AUTHORIZATION_CODE, app.OIDCGrantType_OIDC_GRANT_TYPE_REFRESH_TOKEN) } -func (s *Tester) CreateProject(ctx context.Context) (*management.AddProjectResponse, error) { - return s.Client.Mgmt.AddProject(ctx, &management.AddProjectRequest{ +func (i *Instance) CreateProject(ctx context.Context) (*management.AddProjectResponse, error) { + return i.Client.Mgmt.AddProject(ctx, &management.AddProjectRequest{ Name: fmt.Sprintf("project-%d", time.Now().UnixNano()), }) } -func (s *Tester) CreateAPIClientJWT(ctx context.Context, projectID string) (*management.AddAPIAppResponse, error) { - return s.Client.Mgmt.AddAPIApp(ctx, &management.AddAPIAppRequest{ +func (i *Instance) CreateAPIClientJWT(ctx context.Context, projectID string) (*management.AddAPIAppResponse, error) { + return i.Client.Mgmt.AddAPIApp(ctx, &management.AddAPIAppRequest{ ProjectId: projectID, Name: fmt.Sprintf("api-%d", time.Now().UnixNano()), AuthMethodType: app.APIAuthMethodType_API_AUTH_METHOD_TYPE_PRIVATE_KEY_JWT, }) } -func (s *Tester) CreateAPIClientBasic(ctx context.Context, projectID string) (*management.AddAPIAppResponse, error) { - return s.Client.Mgmt.AddAPIApp(ctx, &management.AddAPIAppRequest{ +func (i *Instance) CreateAPIClientBasic(ctx context.Context, projectID string) (*management.AddAPIAppResponse, error) { + return i.Client.Mgmt.AddAPIApp(ctx, &management.AddAPIAppRequest{ ProjectId: projectID, Name: fmt.Sprintf("api-%d", time.Now().UnixNano()), AuthMethodType: app.APIAuthMethodType_API_AUTH_METHOD_TYPE_BASIC, @@ -171,36 +179,36 @@ func (s *Tester) CreateAPIClientBasic(ctx context.Context, projectID string) (*m const CodeVerifier = "codeVerifier" -func (s *Tester) CreateOIDCAuthRequest(ctx context.Context, clientID, loginClient, redirectURI string, scope ...string) (authRequestID string, err error) { - return s.CreateOIDCAuthRequestWithDomain(ctx, s.Config.ExternalDomain, clientID, loginClient, redirectURI, scope...) +func (i *Instance) CreateOIDCAuthRequest(ctx context.Context, clientID, loginClient, redirectURI string, scope ...string) (authRequestID string, err error) { + return i.CreateOIDCAuthRequestWithDomain(ctx, i.Domain, clientID, loginClient, redirectURI, scope...) } -func (s *Tester) CreateOIDCAuthRequestWithDomain(ctx context.Context, domain, clientID, loginClient, redirectURI string, scope ...string) (authRequestID string, err error) { - provider, err := s.CreateRelyingPartyForDomain(ctx, domain, clientID, redirectURI, scope...) +func (i *Instance) CreateOIDCAuthRequestWithDomain(ctx context.Context, domain, clientID, loginClient, redirectURI string, scope ...string) (authRequestID string, err error) { + provider, err := i.CreateRelyingPartyForDomain(ctx, domain, clientID, redirectURI, scope...) if err != nil { - return "", err + return "", fmt.Errorf("create relying party: %w", err) } codeChallenge := oidc.NewSHACodeChallenge(CodeVerifier) authURL := rp.AuthURL("state", provider, rp.WithCodeChallenge(codeChallenge)) req, err := GetRequest(authURL, map[string]string{oidc_internal.LoginClientHeader: loginClient}) if err != nil { - return "", err + return "", fmt.Errorf("get request: %w", err) } loc, err := CheckRedirect(req) if err != nil { - return "", err + return "", fmt.Errorf("check redirect: %w", err) } - prefixWithHost := provider.Issuer() + s.Config.OIDC.DefaultLoginURLV2 + prefixWithHost := provider.Issuer() + i.Config.LoginURLV2 if !strings.HasPrefix(loc.String(), prefixWithHost) { return "", fmt.Errorf("login location has not prefix %s, but is %s", prefixWithHost, loc.String()) } return strings.TrimPrefix(loc.String(), prefixWithHost), nil } -func (s *Tester) CreateOIDCAuthRequestImplicit(ctx context.Context, clientID, loginClient, redirectURI string, scope ...string) (authRequestID string, err error) { - provider, err := s.CreateRelyingParty(ctx, clientID, redirectURI, scope...) +func (i *Instance) CreateOIDCAuthRequestImplicit(ctx context.Context, clientID, loginClient, redirectURI string, scope ...string) (authRequestID string, err error) { + provider, err := i.CreateRelyingParty(ctx, clientID, redirectURI, scope...) if err != nil { return "", err } @@ -224,48 +232,49 @@ func (s *Tester) CreateOIDCAuthRequestImplicit(ctx context.Context, clientID, lo return "", err } - prefixWithHost := provider.Issuer() + s.Config.OIDC.DefaultLoginURLV2 + prefixWithHost := provider.Issuer() + i.Config.LoginURLV2 if !strings.HasPrefix(loc.String(), prefixWithHost) { return "", fmt.Errorf("login location has not prefix %s, but is %s", prefixWithHost, loc.String()) } return strings.TrimPrefix(loc.String(), prefixWithHost), nil } -func (s *Tester) OIDCIssuer() string { - return http_util.BuildHTTP(s.Config.ExternalDomain, s.Config.Port, s.Config.ExternalSecure) +func (i *Instance) OIDCIssuer() string { + return http_util.BuildHTTP(i.Domain, i.Config.Port, i.Config.Secure) } -func (s *Tester) CreateRelyingParty(ctx context.Context, clientID, redirectURI string, scope ...string) (rp.RelyingParty, error) { - return s.CreateRelyingPartyForDomain(ctx, s.Config.ExternalDomain, clientID, redirectURI, scope...) +func (i *Instance) CreateRelyingParty(ctx context.Context, clientID, redirectURI string, scope ...string) (rp.RelyingParty, error) { + return i.CreateRelyingPartyForDomain(ctx, i.Domain, clientID, redirectURI, scope...) } -func (s *Tester) CreateRelyingPartyForDomain(ctx context.Context, domain, clientID, redirectURI string, scope ...string) (rp.RelyingParty, error) { +func (i *Instance) CreateRelyingPartyForDomain(ctx context.Context, domain, clientID, redirectURI string, scope ...string) (rp.RelyingParty, error) { if len(scope) == 0 { scope = []string{oidc.ScopeOpenID} } - loginClient := &http.Client{Transport: &loginRoundTripper{http.DefaultTransport}} - return rp.NewRelyingPartyOIDC(ctx, http_util.BuildHTTP(domain, s.Config.Port, s.Config.ExternalSecure), clientID, "", redirectURI, scope, rp.WithHTTPClient(loginClient)) + loginClient := &http.Client{Transport: &loginRoundTripper{http.DefaultTransport, i.Users.Get(UserTypeLogin).Username}} + return rp.NewRelyingPartyOIDC(ctx, http_util.BuildHTTP(domain, i.Config.Port, i.Config.Secure), clientID, "", redirectURI, scope, rp.WithHTTPClient(loginClient)) } type loginRoundTripper struct { http.RoundTripper + loginUsername string } func (c *loginRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) { - req.Header.Set(oidc_internal.LoginClientHeader, LoginUser) + req.Header.Set(oidc_internal.LoginClientHeader, c.loginUsername) return c.RoundTripper.RoundTrip(req) } -func (s *Tester) CreateResourceServerJWTProfile(ctx context.Context, keyFileData []byte) (rs.ResourceServer, error) { +func (i *Instance) CreateResourceServerJWTProfile(ctx context.Context, keyFileData []byte) (rs.ResourceServer, error) { keyFile, err := client.ConfigFromKeyFileData(keyFileData) if err != nil { return nil, err } - return rs.NewResourceServerJWTProfile(ctx, s.OIDCIssuer(), keyFile.ClientID, keyFile.KeyID, []byte(keyFile.Key)) + return rs.NewResourceServerJWTProfile(ctx, i.OIDCIssuer(), keyFile.ClientID, keyFile.KeyID, []byte(keyFile.Key)) } -func (s *Tester) CreateResourceServerClientCredentials(ctx context.Context, clientID, clientSecret string) (rs.ResourceServer, error) { - return rs.NewResourceServerClientCredentials(ctx, s.OIDCIssuer(), clientID, clientSecret) +func (i *Instance) CreateResourceServerClientCredentials(ctx context.Context, clientID, clientSecret string) (rs.ResourceServer, error) { + return rs.NewResourceServerClientCredentials(ctx, i.OIDCIssuer(), clientID, clientSecret) } func GetRequest(url string, headers map[string]string) (*http.Request, error) { @@ -313,9 +322,9 @@ func CheckRedirect(req *http.Request) (*url.URL, error) { return resp.Location() } -func (s *Tester) CreateOIDCCredentialsClient(ctx context.Context) (machine *management.AddMachineUserResponse, name, clientID, clientSecret string, err error) { +func (i *Instance) CreateOIDCCredentialsClient(ctx context.Context) (machine *management.AddMachineUserResponse, name, clientID, clientSecret string, err error) { name = gofakeit.Username() - machine, err = s.Client.Mgmt.AddMachineUser(ctx, &management.AddMachineUserRequest{ + machine, err = i.Client.Mgmt.AddMachineUser(ctx, &management.AddMachineUserRequest{ Name: name, UserName: name, AccessTokenType: user.AccessTokenType_ACCESS_TOKEN_TYPE_JWT, @@ -323,7 +332,7 @@ func (s *Tester) CreateOIDCCredentialsClient(ctx context.Context) (machine *mana if err != nil { return nil, "", "", "", err } - secret, err := s.Client.Mgmt.GenerateMachineSecret(ctx, &management.GenerateMachineSecretRequest{ + secret, err := i.Client.Mgmt.GenerateMachineSecret(ctx, &management.GenerateMachineSecretRequest{ UserId: machine.GetUserId(), }) if err != nil { @@ -332,9 +341,9 @@ func (s *Tester) CreateOIDCCredentialsClient(ctx context.Context) (machine *mana return machine, name, secret.GetClientId(), secret.GetClientSecret(), nil } -func (s *Tester) CreateOIDCJWTProfileClient(ctx context.Context) (machine *management.AddMachineUserResponse, name string, keyData []byte, err error) { +func (i *Instance) CreateOIDCJWTProfileClient(ctx context.Context) (machine *management.AddMachineUserResponse, name string, keyData []byte, err error) { name = gofakeit.Username() - machine, err = s.Client.Mgmt.AddMachineUser(ctx, &management.AddMachineUserRequest{ + machine, err = i.Client.Mgmt.AddMachineUser(ctx, &management.AddMachineUserRequest{ Name: name, UserName: name, AccessTokenType: user.AccessTokenType_ACCESS_TOKEN_TYPE_JWT, @@ -342,7 +351,7 @@ func (s *Tester) CreateOIDCJWTProfileClient(ctx context.Context) (machine *manag if err != nil { return nil, "", nil, err } - keyResp, err := s.Client.Mgmt.AddMachineKey(ctx, &management.AddMachineKeyRequest{ + keyResp, err := i.Client.Mgmt.AddMachineKey(ctx, &management.AddMachineKeyRequest{ UserId: machine.GetUserId(), Type: authn.KeyType_KEY_TYPE_JSON, ExpirationDate: timestamppb.New(time.Now().Add(time.Hour)), diff --git a/internal/integration/sink/channel.go b/internal/integration/sink/channel.go new file mode 100644 index 0000000000..e25ae4d70c --- /dev/null +++ b/internal/integration/sink/channel.go @@ -0,0 +1,9 @@ +package sink + +//go:generate enumer -type Channel -trimprefix Channel -transform snake +type Channel int + +const ( + ChannelMilestone Channel = iota + ChannelQuota +) diff --git a/internal/integration/sink/channel_enumer.go b/internal/integration/sink/channel_enumer.go new file mode 100644 index 0000000000..85792ffec7 --- /dev/null +++ b/internal/integration/sink/channel_enumer.go @@ -0,0 +1,78 @@ +// Code generated by "enumer -type Channel -trimprefix Channel -transform snake"; DO NOT EDIT. + +package sink + +import ( + "fmt" + "strings" +) + +const _ChannelName = "milestonequota" + +var _ChannelIndex = [...]uint8{0, 9, 14} + +const _ChannelLowerName = "milestonequota" + +func (i Channel) String() string { + if i < 0 || i >= Channel(len(_ChannelIndex)-1) { + return fmt.Sprintf("Channel(%d)", i) + } + return _ChannelName[_ChannelIndex[i]:_ChannelIndex[i+1]] +} + +// An "invalid array index" compiler error signifies that the constant values have changed. +// Re-run the stringer command to generate them again. +func _ChannelNoOp() { + var x [1]struct{} + _ = x[ChannelMilestone-(0)] + _ = x[ChannelQuota-(1)] +} + +var _ChannelValues = []Channel{ChannelMilestone, ChannelQuota} + +var _ChannelNameToValueMap = map[string]Channel{ + _ChannelName[0:9]: ChannelMilestone, + _ChannelLowerName[0:9]: ChannelMilestone, + _ChannelName[9:14]: ChannelQuota, + _ChannelLowerName[9:14]: ChannelQuota, +} + +var _ChannelNames = []string{ + _ChannelName[0:9], + _ChannelName[9:14], +} + +// ChannelString retrieves an enum value from the enum constants string name. +// Throws an error if the param is not part of the enum. +func ChannelString(s string) (Channel, error) { + if val, ok := _ChannelNameToValueMap[s]; ok { + return val, nil + } + + if val, ok := _ChannelNameToValueMap[strings.ToLower(s)]; ok { + return val, nil + } + return 0, fmt.Errorf("%s does not belong to Channel values", s) +} + +// ChannelValues returns all values of the enum +func ChannelValues() []Channel { + return _ChannelValues +} + +// ChannelStrings returns a slice of all String values of the enum +func ChannelStrings() []string { + strs := make([]string, len(_ChannelNames)) + copy(strs, _ChannelNames) + return strs +} + +// IsAChannel returns "true" if the value is listed in the enum definition. "false" otherwise +func (i Channel) IsAChannel() bool { + for _, v := range _ChannelValues { + if i == v { + return true + } + } + return false +} diff --git a/internal/integration/sink/server.go b/internal/integration/sink/server.go new file mode 100644 index 0000000000..959353ae5f --- /dev/null +++ b/internal/integration/sink/server.go @@ -0,0 +1,167 @@ +//go:build integration + +package sink + +import ( + "errors" + "io" + "net/http" + "net/url" + "path" + "sync" + "sync/atomic" + + "github.com/go-chi/chi/v5" + "github.com/gorilla/websocket" + "github.com/sirupsen/logrus" + "github.com/zitadel/logging" +) + +const ( + port = "8081" + listenAddr = "127.0.0.1:" + port + host = "localhost:" + port +) + +// CallURL returns the full URL to the handler of a [Channel]. +func CallURL(ch Channel) string { + u := url.URL{ + Scheme: "http", + Host: host, + Path: rootPath(ch), + } + return u.String() +} + +// StartServer starts a simple HTTP server on localhost:8081 +// ZITADEL can use the server to send HTTP requests which can be +// used to validate tests through [Subscribe]rs. +// For each [Channel] a route is registered on http://localhost:8081/. +// The route must be used to send the HTTP request to be validated. +// [CallURL] can be used to obtain the full URL for a given Channel. +// +// This function is only active when the `integration` build tag is enabled +func StartServer() (close func()) { + router := chi.NewRouter() + for _, ch := range ChannelValues() { + fwd := &forwarder{ + channelID: ch, + subscribers: make(map[int64]chan<- *Request), + } + router.HandleFunc(rootPath(ch), fwd.receiveHandler) + router.HandleFunc(subscribePath(ch), fwd.subscriptionHandler) + } + s := &http.Server{ + Addr: listenAddr, + Handler: router, + } + + logging.WithFields("listen_addr", listenAddr).Warn("!!!! A sink server is started which may expose sensitive data on a public endpoint. Make sure the `integration` build tag is disabled for production builds. !!!!") + go func() { + err := s.ListenAndServe() + if !errors.Is(err, http.ErrServerClosed) { + logging.WithError(err).Fatal("sink server") + } + }() + return func() { + logging.OnError(s.Close()).Error("sink server") + } +} + +func rootPath(c Channel) string { + return path.Join("/", c.String()) +} + +func subscribePath(c Channel) string { + return path.Join("/", c.String(), "subscribe") +} + +// forwarder handles incoming HTTP requests from ZITADEL and +// forwards them to all subscribed web sockets. +type forwarder struct { + channelID Channel + id atomic.Int64 + mtx sync.RWMutex + subscribers map[int64]chan<- *Request + upgrader websocket.Upgrader +} + +// receiveHandler receives a simple HTTP for a single [Channel] +// and forwards them on all active subscribers of that Channel. +func (c *forwarder) receiveHandler(w http.ResponseWriter, r *http.Request) { + req := &Request{ + Header: r.Header.Clone(), + } + var err error + req.Body, err = io.ReadAll(r.Body) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + } + + c.mtx.RLock() + for _, reqChan := range c.subscribers { + reqChan <- req + } + c.mtx.RUnlock() + w.WriteHeader(http.StatusOK) +} + +// subscriptionHandler upgrades HTTP request to a websocket connection for subscribers. +// All received HTTP requests on a subscriber's channel are send on the websocket to the client. +func (c *forwarder) subscriptionHandler(w http.ResponseWriter, r *http.Request) { + ws, err := c.upgrader.Upgrade(w, r, nil) + logging.OnError(err).Error("websocket upgrade") + if err != nil { + return + } + done := readLoop(ws) + + id := c.id.Add(1) + reqChannel := make(chan *Request, 100) + + c.mtx.Lock() + c.subscribers[id] = reqChannel + c.mtx.Unlock() + + logging.WithFields("id", id, "channel", c.channelID).Info("websocket opened") + + defer func() { + c.mtx.Lock() + delete(c.subscribers, id) + c.mtx.Unlock() + + ws.Close() + close(reqChannel) + }() + + for { + select { + case err := <-done: + logging.WithError(err).WithFields(logrus.Fields{"id": id, "channel": c.channelID}).Info("websocket closed") + return + case req := <-reqChannel: + if err := ws.WriteJSON(req); err != nil { + logging.WithError(err).WithFields(logrus.Fields{"id": id, "channel": c.channelID}).Error("websocket write json") + return + } + } + } +} + +// readLoop makes sure we can receive close messages +func readLoop(ws *websocket.Conn) (done chan error) { + done = make(chan error, 1) + + go func(done chan<- error) { + for { + _, _, err := ws.NextReader() + if err != nil { + done <- err + break + } + } + close(done) + }(done) + + return done +} diff --git a/internal/integration/sink/sink.go b/internal/integration/sink/sink.go new file mode 100644 index 0000000000..fa339e4763 --- /dev/null +++ b/internal/integration/sink/sink.go @@ -0,0 +1,4 @@ +// Package sink provides a simple HTTP server where Zitadel can send HTTP based messages, +// which are then possible to be observed using observers on websockets. +// The contents of this package become available when the `integration` build tag is enabled. +package sink diff --git a/internal/integration/sink/stub.go b/internal/integration/sink/stub.go new file mode 100644 index 0000000000..01d1047f34 --- /dev/null +++ b/internal/integration/sink/stub.go @@ -0,0 +1,9 @@ +//go:build !integration + +package sink + +// StartServer and its returned close function are a no-op +// when the `integration` build tag is disabled. +func StartServer() (close func()) { + return func() {} +} diff --git a/internal/integration/sink/subscription.go b/internal/integration/sink/subscription.go new file mode 100644 index 0000000000..7d5ca36e91 --- /dev/null +++ b/internal/integration/sink/subscription.go @@ -0,0 +1,90 @@ +//go:build integration + +package sink + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net" + "net/http" + "net/url" + "sync/atomic" + + "github.com/gorilla/websocket" + "github.com/zitadel/logging" +) + +// Request is a message forwarded from the handler to [Subscription]s. +type Request struct { + Header http.Header + Body json.RawMessage +} + +// Subscription is a websocket client to which [Request]s are forwarded by the server. +type Subscription struct { + conn *websocket.Conn + closed atomic.Bool + reqChannel chan *Request +} + +// Subscribe to a channel. +// The subscription forwards all requests it received on the channel's +// handler, after Subscribe has returned. +// Multiple subscription may be active on a single channel. +// Each request is always forwarded to each Subscription. +// Close must be called to cleanup up the Subscription's channel and go routine. +func Subscribe(ctx context.Context, ch Channel) *Subscription { + u := url.URL{ + Scheme: "ws", + Host: listenAddr, + Path: subscribePath(ch), + } + conn, resp, err := websocket.DefaultDialer.DialContext(ctx, u.String(), nil) + if err != nil { + if resp != nil { + defer resp.Body.Close() + body, _ := io.ReadAll(resp.Body) + err = fmt.Errorf("subscribe: %w, status: %s, body: %s", err, resp.Status, body) + } + panic(err) + } + + sub := &Subscription{ + conn: conn, + reqChannel: make(chan *Request, 10), + } + go sub.readToChan() + return sub +} + +func (s *Subscription) readToChan() { + for { + if s.closed.Load() { + break + } + req := new(Request) + if err := s.conn.ReadJSON(req); err != nil { + opErr := new(net.OpError) + if errors.As(err, &opErr) { + break + } + logging.WithError(err).Error("subscription read") + break + } + s.reqChannel <- req + } + close(s.reqChannel) +} + +// Recv returns the channel over which [Request]s are send. +func (s *Subscription) Recv() <-chan *Request { + return s.reqChannel +} + +func (s *Subscription) Close() error { + s.closed.Store(true) + return s.conn.Close() +} diff --git a/internal/integration/system.go b/internal/integration/system.go new file mode 100644 index 0000000000..a9673a40ae --- /dev/null +++ b/internal/integration/system.go @@ -0,0 +1,59 @@ +package integration + +import ( + "context" + _ "embed" + "sync" + "time" + + "github.com/zitadel/oidc/v3/pkg/client" + "google.golang.org/grpc" + "google.golang.org/grpc/credentials/insecure" + + http_util "github.com/zitadel/zitadel/internal/api/http" + "github.com/zitadel/zitadel/pkg/grpc/system" +) + +var ( + //go:embed config/system-user-key.pem + systemUserKey []byte +) + +var ( + // SystemClient creates a system connection once and reuses it on every use. + // Each client call automatically gets the authorization context for the system user. + SystemClient = sync.OnceValue[system.SystemServiceClient](systemClient) + SystemToken string +) + +func systemClient() system.SystemServiceClient { + cc, err := grpc.NewClient(loadedConfig.Host(), + grpc.WithTransportCredentials(insecure.NewCredentials()), + grpc.WithChainUnaryInterceptor(func(ctx context.Context, method string, req, reply any, cc *grpc.ClientConn, invoker grpc.UnaryInvoker, opts ...grpc.CallOption) error { + ctx = WithSystemAuthorization(ctx) + return invoker(ctx, method, req, reply, cc, opts...) + }), + ) + if err != nil { + panic(err) + } + return system.NewSystemServiceClient(cc) +} + +func systemUserToken() string { + const ISSUER = "tester" + audience := http_util.BuildOrigin(loadedConfig.Host(), loadedConfig.Secure) + signer, err := client.NewSignerFromPrivateKeyByte(systemUserKey, "") + if err != nil { + panic(err) + } + token, err := client.SignedJWTProfileAssertion(ISSUER, []string{audience}, time.Hour, signer) + if err != nil { + panic(err) + } + return token +} + +func WithSystemAuthorization(ctx context.Context) context.Context { + return WithAuthorizationToken(ctx, SystemToken) +} diff --git a/internal/integration/user.go b/internal/integration/user.go index 60a6e41318..6eb26fa5a7 100644 --- a/internal/integration/user.go +++ b/internal/integration/user.go @@ -11,10 +11,10 @@ import ( "github.com/zitadel/zitadel/pkg/grpc/management" ) -func (s *Tester) CreateMachineUserPATWithMembership(ctx context.Context, roles ...string) (id, pat string, err error) { - user := s.CreateMachineUser(ctx) +func (i *Instance) CreateMachineUserPATWithMembership(ctx context.Context, roles ...string) (id, pat string, err error) { + user := i.CreateMachineUser(ctx) - patResp, err := s.Client.Mgmt.AddPersonalAccessToken(ctx, &management.AddPersonalAccessTokenRequest{ + patResp, err := i.Client.Mgmt.AddPersonalAccessToken(ctx, &management.AddPersonalAccessTokenRequest{ UserId: user.GetUserId(), ExpirationDate: timestamppb.New(time.Now().Add(24 * time.Hour)), }) @@ -35,7 +35,7 @@ func (s *Tester) CreateMachineUserPATWithMembership(ctx context.Context, roles . } if len(orgRoles) > 0 { - _, err := s.Client.Mgmt.AddOrgMember(ctx, &management.AddOrgMemberRequest{ + _, err := i.Client.Mgmt.AddOrgMember(ctx, &management.AddOrgMemberRequest{ UserId: user.GetUserId(), Roles: orgRoles, }) @@ -44,7 +44,7 @@ func (s *Tester) CreateMachineUserPATWithMembership(ctx context.Context, roles . } } if len(iamRoles) > 0 { - _, err := s.Client.Admin.AddIAMMember(ctx, &admin.AddIAMMemberRequest{ + _, err := i.Client.Admin.AddIAMMember(ctx, &admin.AddIAMMemberRequest{ UserId: user.GetUserId(), Roles: iamRoles, }) diff --git a/internal/integration/usertype_enumer.go b/internal/integration/usertype_enumer.go new file mode 100644 index 0000000000..66d49ced4d --- /dev/null +++ b/internal/integration/usertype_enumer.go @@ -0,0 +1,86 @@ +// Code generated by "enumer -type UserType -transform snake -trimprefix UserType"; DO NOT EDIT. + +package integration + +import ( + "fmt" + "strings" +) + +const _UserTypeName = "unspecifiediam_ownerorg_ownerlogin" + +var _UserTypeIndex = [...]uint8{0, 11, 20, 29, 34} + +const _UserTypeLowerName = "unspecifiediam_ownerorg_ownerlogin" + +func (i UserType) String() string { + if i < 0 || i >= UserType(len(_UserTypeIndex)-1) { + return fmt.Sprintf("UserType(%d)", i) + } + return _UserTypeName[_UserTypeIndex[i]:_UserTypeIndex[i+1]] +} + +// An "invalid array index" compiler error signifies that the constant values have changed. +// Re-run the stringer command to generate them again. +func _UserTypeNoOp() { + var x [1]struct{} + _ = x[UserTypeUnspecified-(0)] + _ = x[UserTypeIAMOwner-(1)] + _ = x[UserTypeOrgOwner-(2)] + _ = x[UserTypeLogin-(3)] +} + +var _UserTypeValues = []UserType{UserTypeUnspecified, UserTypeIAMOwner, UserTypeOrgOwner, UserTypeLogin} + +var _UserTypeNameToValueMap = map[string]UserType{ + _UserTypeName[0:11]: UserTypeUnspecified, + _UserTypeLowerName[0:11]: UserTypeUnspecified, + _UserTypeName[11:20]: UserTypeIAMOwner, + _UserTypeLowerName[11:20]: UserTypeIAMOwner, + _UserTypeName[20:29]: UserTypeOrgOwner, + _UserTypeLowerName[20:29]: UserTypeOrgOwner, + _UserTypeName[29:34]: UserTypeLogin, + _UserTypeLowerName[29:34]: UserTypeLogin, +} + +var _UserTypeNames = []string{ + _UserTypeName[0:11], + _UserTypeName[11:20], + _UserTypeName[20:29], + _UserTypeName[29:34], +} + +// UserTypeString retrieves an enum value from the enum constants string name. +// Throws an error if the param is not part of the enum. +func UserTypeString(s string) (UserType, error) { + if val, ok := _UserTypeNameToValueMap[s]; ok { + return val, nil + } + + if val, ok := _UserTypeNameToValueMap[strings.ToLower(s)]; ok { + return val, nil + } + return 0, fmt.Errorf("%s does not belong to UserType values", s) +} + +// UserTypeValues returns all values of the enum +func UserTypeValues() []UserType { + return _UserTypeValues +} + +// UserTypeStrings returns a slice of all String values of the enum +func UserTypeStrings() []string { + strs := make([]string, len(_UserTypeNames)) + copy(strs, _UserTypeNames) + return strs +} + +// IsAUserType returns "true" if the value is listed in the enum definition. "false" otherwise +func (i UserType) IsAUserType() bool { + for _, v := range _UserTypeValues { + if i == v { + return true + } + } + return false +} diff --git a/internal/integration/usertype_string.go b/internal/integration/usertype_string.go deleted file mode 100644 index 6477630986..0000000000 --- a/internal/integration/usertype_string.go +++ /dev/null @@ -1,27 +0,0 @@ -// Code generated by "stringer -type=UserType"; DO NOT EDIT. - -package integration - -import "strconv" - -func _() { - // An "invalid array index" compiler error signifies that the constant values have changed. - // Re-run the stringer command to generate them again. - var x [1]struct{} - _ = x[Unspecified-0] - _ = x[OrgOwner-1] - _ = x[Login-2] - _ = x[IAMOwner-3] - _ = x[SystemUser-4] -} - -const _UserType_name = "UnspecifiedOrgOwnerLoginIAMOwnerSystemUser" - -var _UserType_index = [...]uint8{0, 11, 19, 24, 32, 42} - -func (i UserType) String() string { - if i < 0 || i >= UserType(len(_UserType_index)-1) { - return "UserType(" + strconv.FormatInt(int64(i), 10) + ")" - } - return _UserType_name[_UserType_index[i]:_UserType_index[i+1]] -} diff --git a/internal/notification/handlers/handlers_integration_test.go b/internal/notification/handlers/handlers_integration_test.go deleted file mode 100644 index 329bba1310..0000000000 --- a/internal/notification/handlers/handlers_integration_test.go +++ /dev/null @@ -1,36 +0,0 @@ -//go:build integration - -package handlers_test - -import ( - "context" - "os" - "testing" - "time" - - "github.com/zitadel/zitadel/internal/integration" -) - -var ( - CTX context.Context - SystemCTX context.Context - Tester *integration.Tester -) - -func TestMain(m *testing.M) { - os.Exit(func() int { - ctx, _, cancel := integration.Contexts(5 * time.Minute) - defer cancel() - CTX = ctx - - Tester = integration.NewTester(ctx, ` -Quotas: - Access: - Enabled: true -`) - defer Tester.Done() - - SystemCTX = Tester.WithAuthorization(ctx, integration.SystemUser) - return m.Run() - }()) -} diff --git a/internal/notification/handlers/integration_test/handlers_test.go b/internal/notification/handlers/integration_test/handlers_test.go new file mode 100644 index 0000000000..3b2dfe2406 --- /dev/null +++ b/internal/notification/handlers/integration_test/handlers_test.go @@ -0,0 +1,23 @@ +//go:build integration + +package handlers_test + +import ( + "context" + "os" + "testing" + "time" +) + +var ( + CTX context.Context +) + +func TestMain(m *testing.M) { + os.Exit(func() int { + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Minute) + defer cancel() + CTX = ctx + return m.Run() + }()) +} diff --git a/internal/notification/handlers/telemetry_pusher_integration_test.go b/internal/notification/handlers/integration_test/telemetry_pusher_test.go similarity index 53% rename from internal/notification/handlers/telemetry_pusher_integration_test.go rename to internal/notification/handlers/integration_test/telemetry_pusher_test.go index 8f207b9de3..c12ab64f35 100644 --- a/internal/notification/handlers/telemetry_pusher_integration_test.go +++ b/internal/notification/handlers/integration_test/telemetry_pusher_test.go @@ -4,7 +4,6 @@ package handlers_test import ( "bytes" - "context" "encoding/json" "net/url" "testing" @@ -16,6 +15,7 @@ import ( "github.com/zitadel/oidc/v3/pkg/oidc" "github.com/zitadel/zitadel/internal/integration" + "github.com/zitadel/zitadel/internal/integration/sink" "github.com/zitadel/zitadel/pkg/grpc/app" "github.com/zitadel/zitadel/pkg/grpc/management" "github.com/zitadel/zitadel/pkg/grpc/object" @@ -25,16 +25,20 @@ import ( ) func TestServer_TelemetryPushMilestones(t *testing.T) { - primaryDomain, instanceID, adminID, iamOwnerCtx := Tester.UseIsolatedInstance(t, CTX, SystemCTX) - t.Log("testing against instance with primary domain", primaryDomain) - awaitMilestone(t, Tester.MilestoneChan, primaryDomain, "InstanceCreated") + sub := sink.Subscribe(CTX, sink.ChannelMilestone) + defer sub.Close() - projectAdded, err := Tester.Client.Mgmt.AddProject(iamOwnerCtx, &management.AddProjectRequest{Name: "integration"}) + instance := integration.NewInstance(CTX) + iamOwnerCtx := instance.WithAuthorization(CTX, integration.UserTypeIAMOwner) + t.Log("testing against instance with primary domain", instance.Domain) + awaitMilestone(t, sub, instance.Domain, "InstanceCreated") + + projectAdded, err := instance.Client.Mgmt.AddProject(iamOwnerCtx, &management.AddProjectRequest{Name: "integration"}) require.NoError(t, err) - awaitMilestone(t, Tester.MilestoneChan, primaryDomain, "ProjectCreated") + awaitMilestone(t, sub, instance.Domain, "ProjectCreated") redirectURI := "http://localhost:8888" - application, err := Tester.Client.Mgmt.AddOIDCApp(iamOwnerCtx, &management.AddOIDCAppRequest{ + application, err := instance.Client.Mgmt.AddOIDCApp(iamOwnerCtx, &management.AddOIDCAppRequest{ ProjectId: projectAdded.GetId(), Name: "integration", RedirectUris: []string{redirectURI}, @@ -46,35 +50,37 @@ func TestServer_TelemetryPushMilestones(t *testing.T) { AccessTokenType: app.OIDCTokenType_OIDC_TOKEN_TYPE_JWT, }) require.NoError(t, err) - awaitMilestone(t, Tester.MilestoneChan, primaryDomain, "ApplicationCreated") + awaitMilestone(t, sub, instance.Domain, "ApplicationCreated") // create the session to be used for the authN of the clients - sessionID, sessionToken, _, _ := Tester.CreatePasswordSession(t, iamOwnerCtx, adminID, "Password1!") + sessionID, sessionToken, _, _ := instance.CreatePasswordSession(t, iamOwnerCtx, instance.AdminUserID, "Password1!") - console := consoleOIDCConfig(iamOwnerCtx, t) - loginToClient(iamOwnerCtx, t, primaryDomain, console.GetClientId(), instanceID, console.GetRedirectUris()[0], sessionID, sessionToken) - awaitMilestone(t, Tester.MilestoneChan, primaryDomain, "AuthenticationSucceededOnInstance") + console := consoleOIDCConfig(t, instance) + loginToClient(t, instance, console.GetClientId(), console.GetRedirectUris()[0], sessionID, sessionToken) + awaitMilestone(t, sub, instance.Domain, "AuthenticationSucceededOnInstance") // make sure the client has been projected require.EventuallyWithT(t, func(collectT *assert.CollectT) { - _, err := Tester.Client.Mgmt.GetAppByID(iamOwnerCtx, &management.GetAppByIDRequest{ + _, err := instance.Client.Mgmt.GetAppByID(iamOwnerCtx, &management.GetAppByIDRequest{ ProjectId: projectAdded.GetId(), AppId: application.GetAppId(), }) assert.NoError(collectT, err) - }, 1*time.Minute, 100*time.Millisecond, "app not found") - loginToClient(iamOwnerCtx, t, primaryDomain, application.GetClientId(), instanceID, redirectURI, sessionID, sessionToken) - awaitMilestone(t, Tester.MilestoneChan, primaryDomain, "AuthenticationSucceededOnApplication") + }, time.Minute, time.Second, "app not found") + loginToClient(t, instance, application.GetClientId(), redirectURI, sessionID, sessionToken) + awaitMilestone(t, sub, instance.Domain, "AuthenticationSucceededOnApplication") - _, err = Tester.Client.System.RemoveInstance(SystemCTX, &system.RemoveInstanceRequest{InstanceId: instanceID}) + _, err = integration.SystemClient().RemoveInstance(CTX, &system.RemoveInstanceRequest{InstanceId: instance.ID()}) require.NoError(t, err) - awaitMilestone(t, Tester.MilestoneChan, primaryDomain, "InstanceDeleted") + awaitMilestone(t, sub, instance.Domain, "InstanceDeleted") } -func loginToClient(iamOwnerCtx context.Context, t *testing.T, primaryDomain, clientID, instanceID, redirectURI, sessionID, sessionToken string) { - authRequestID, err := Tester.CreateOIDCAuthRequestWithDomain(iamOwnerCtx, primaryDomain, clientID, Tester.Users.Get(instanceID, integration.IAMOwner).ID, redirectURI, "openid") +func loginToClient(t *testing.T, instance *integration.Instance, clientID, redirectURI, sessionID, sessionToken string) { + iamOwnerCtx := instance.WithAuthorization(CTX, integration.UserTypeIAMOwner) + + authRequestID, err := instance.CreateOIDCAuthRequestWithDomain(iamOwnerCtx, instance.Domain, clientID, instance.Users.Get(integration.UserTypeIAMOwner).ID, redirectURI, "openid") require.NoError(t, err) - callback, err := Tester.Client.OIDCv2.CreateCallback(iamOwnerCtx, &oidc_v2.CreateCallbackRequest{ + callback, err := instance.Client.OIDCv2.CreateCallback(iamOwnerCtx, &oidc_v2.CreateCallbackRequest{ AuthRequestId: authRequestID, CallbackKind: &oidc_v2.CreateCallbackRequest_Session{Session: &oidc_v2.Session{ SessionId: sessionID, @@ -82,7 +88,7 @@ func loginToClient(iamOwnerCtx context.Context, t *testing.T, primaryDomain, cli }}, }) require.NoError(t, err) - provider, err := Tester.CreateRelyingPartyForDomain(iamOwnerCtx, primaryDomain, clientID, redirectURI) + provider, err := instance.CreateRelyingPartyForDomain(iamOwnerCtx, instance.Domain, clientID, redirectURI) require.NoError(t, err) callbackURL, err := url.Parse(callback.GetCallbackUrl()) require.NoError(t, err) @@ -91,8 +97,10 @@ func loginToClient(iamOwnerCtx context.Context, t *testing.T, primaryDomain, cli require.NoError(t, err) } -func consoleOIDCConfig(iamOwnerCtx context.Context, t *testing.T) *app.OIDCConfig { - projects, err := Tester.Client.Mgmt.ListProjects(iamOwnerCtx, &management.ListProjectsRequest{ +func consoleOIDCConfig(t *testing.T, instance *integration.Instance) *app.OIDCConfig { + iamOwnerCtx := instance.WithAuthorization(CTX, integration.UserTypeIAMOwner) + + projects, err := instance.Client.Mgmt.ListProjects(iamOwnerCtx, &management.ListProjectsRequest{ Queries: []*project.ProjectQuery{ { Query: &project.ProjectQuery_NameQuery{ @@ -106,7 +114,7 @@ func consoleOIDCConfig(iamOwnerCtx context.Context, t *testing.T) *app.OIDCConfi }) require.NoError(t, err) require.Len(t, projects.GetResult(), 1) - apps, err := Tester.Client.Mgmt.ListApps(iamOwnerCtx, &management.ListAppsRequest{ + apps, err := instance.Client.Mgmt.ListApps(iamOwnerCtx, &management.ListAppsRequest{ ProjectId: projects.GetResult()[0].GetId(), Queries: []*app.AppQuery{ { @@ -124,12 +132,12 @@ func consoleOIDCConfig(iamOwnerCtx context.Context, t *testing.T) *app.OIDCConfi return apps.GetResult()[0].GetOidcConfig() } -func awaitMilestone(t *testing.T, bodies chan []byte, primaryDomain, expectMilestoneType string) { +func awaitMilestone(t *testing.T, sub *sink.Subscription, primaryDomain, expectMilestoneType string) { for { select { - case body := <-bodies: + case req := <-sub.Recv(): plain := new(bytes.Buffer) - if err := json.Indent(plain, body, "", " "); err != nil { + if err := json.Indent(plain, req.Body, "", " "); err != nil { t.Fatal(err) } t.Log("received milestone", plain.String()) @@ -137,13 +145,13 @@ func awaitMilestone(t *testing.T, bodies chan []byte, primaryDomain, expectMiles Type string `json:"type"` PrimaryDomain string `json:"primaryDomain"` }{} - if err := json.Unmarshal(body, &milestone); err != nil { + if err := json.Unmarshal(req.Body, &milestone); err != nil { t.Error(err) } if milestone.Type == expectMilestoneType && milestone.PrimaryDomain == primaryDomain { return } - case <-time.After(60 * time.Second): + case <-time.After(2 * time.Minute): // why does it take so long to get a milestone !? t.Fatalf("timed out waiting for milestone %s in domain %s", expectMilestoneType, primaryDomain) } } diff --git a/internal/notification/handlers/quota_notifier_test.go b/internal/notification/handlers/quota_notifier_test.go deleted file mode 100644 index 14de4e369c..0000000000 --- a/internal/notification/handlers/quota_notifier_test.go +++ /dev/null @@ -1,155 +0,0 @@ -//go:build integration - -package handlers_test - -import ( - "bytes" - "encoding/json" - "fmt" - "strconv" - "testing" - "time" - - "github.com/stretchr/testify/require" - "google.golang.org/protobuf/types/known/durationpb" - "google.golang.org/protobuf/types/known/timestamppb" - - "github.com/zitadel/zitadel/internal/repository/quota" - "github.com/zitadel/zitadel/pkg/grpc/admin" - quota_pb "github.com/zitadel/zitadel/pkg/grpc/quota" - "github.com/zitadel/zitadel/pkg/grpc/system" -) - -func TestServer_QuotaNotification_Limit(t *testing.T) { - _, instanceID, _, iamOwnerCtx := Tester.UseIsolatedInstance(t, CTX, SystemCTX) - amount := 10 - percent := 50 - percentAmount := amount * percent / 100 - - _, err := Tester.Client.System.AddQuota(SystemCTX, &system.AddQuotaRequest{ - InstanceId: instanceID, - Unit: quota_pb.Unit_UNIT_REQUESTS_ALL_AUTHENTICATED, - From: timestamppb.Now(), - ResetInterval: durationpb.New(time.Minute * 5), - Amount: uint64(amount), - Limit: true, - Notifications: []*quota_pb.Notification{ - { - Percent: uint32(percent), - Repeat: true, - CallUrl: "http://localhost:8082", - }, - { - Percent: 100, - Repeat: true, - CallUrl: "http://localhost:8082", - }, - }, - }) - require.NoError(t, err) - - for i := 0; i < percentAmount; i++ { - _, err := Tester.Client.Admin.GetDefaultOrg(iamOwnerCtx, &admin.GetDefaultOrgRequest{}) - if err != nil { - require.NoError(t, fmt.Errorf("error in %d call of %d: %f", i, percentAmount, err)) - } - } - awaitNotification(t, time.Now(), Tester.QuotaNotificationChan, quota.RequestsAllAuthenticated, percent) - - for i := 0; i < (amount - percentAmount); i++ { - _, err := Tester.Client.Admin.GetDefaultOrg(iamOwnerCtx, &admin.GetDefaultOrgRequest{}) - require.NoError(t, err) - } - awaitNotification(t, time.Now(), Tester.QuotaNotificationChan, quota.RequestsAllAuthenticated, 100) - - _, limitErr := Tester.Client.Admin.GetDefaultOrg(iamOwnerCtx, &admin.GetDefaultOrgRequest{}) - require.Error(t, limitErr) -} - -func TestServer_QuotaNotification_NoLimit(t *testing.T) { - _, instanceID, _, iamOwnerCtx := Tester.UseIsolatedInstance(t, CTX, SystemCTX) - amount := 10 - percent := 50 - percentAmount := amount * percent / 100 - - _, err := Tester.Client.System.AddQuota(SystemCTX, &system.AddQuotaRequest{ - InstanceId: instanceID, - Unit: quota_pb.Unit_UNIT_REQUESTS_ALL_AUTHENTICATED, - From: timestamppb.Now(), - ResetInterval: durationpb.New(time.Minute * 5), - Amount: uint64(amount), - Limit: false, - Notifications: []*quota_pb.Notification{ - { - Percent: uint32(percent), - Repeat: false, - CallUrl: "http://localhost:8082", - }, - { - Percent: 100, - Repeat: true, - CallUrl: "http://localhost:8082", - }, - }, - }) - require.NoError(t, err) - - for i := 0; i < percentAmount; i++ { - _, err := Tester.Client.Admin.GetDefaultOrg(iamOwnerCtx, &admin.GetDefaultOrgRequest{}) - if err != nil { - require.NoError(t, fmt.Errorf("error in %d call of %d: %f", i, percentAmount, err)) - } - } - awaitNotification(t, time.Now(), Tester.QuotaNotificationChan, quota.RequestsAllAuthenticated, percent) - - for i := 0; i < (amount - percentAmount); i++ { - _, err := Tester.Client.Admin.GetDefaultOrg(iamOwnerCtx, &admin.GetDefaultOrgRequest{}) - if err != nil { - require.NoError(t, fmt.Errorf("error in %d call of %d: %f", percentAmount+i, amount, err)) - } - } - awaitNotification(t, time.Now(), Tester.QuotaNotificationChan, quota.RequestsAllAuthenticated, 100) - - for i := 0; i < amount; i++ { - _, err := Tester.Client.Admin.GetDefaultOrg(iamOwnerCtx, &admin.GetDefaultOrgRequest{}) - if err != nil { - require.NoError(t, fmt.Errorf("error in %d call of %d over limit: %f", i, amount, err)) - } - } - awaitNotification(t, time.Now(), Tester.QuotaNotificationChan, quota.RequestsAllAuthenticated, 200) - - _, limitErr := Tester.Client.Admin.GetDefaultOrg(iamOwnerCtx, &admin.GetDefaultOrgRequest{}) - require.NoError(t, limitErr) -} - -func awaitNotification(t *testing.T, start time.Time, bodies chan []byte, unit quota.Unit, percent int) { - for { - select { - case body := <-bodies: - plain := new(bytes.Buffer) - if err := json.Indent(plain, body, "", " "); err != nil { - t.Fatal(err) - } - t.Log("received notificationDueEvent", plain.String()) - event := struct { - Unit quota.Unit `json:"unit"` - ID string `json:"id"` - CallURL string `json:"callURL"` - PeriodStart time.Time `json:"periodStart"` - Threshold uint16 `json:"threshold"` - Usage uint64 `json:"usage"` - }{} - if err := json.Unmarshal(body, &event); err != nil { - t.Error(err) - } - if event.ID == "" { - continue - } - if event.Unit == unit && event.Threshold == uint16(percent) { - return - } - case <-time.After(20 * time.Second): - t.Fatalf("start %s stop %s timed out waiting for unit %s and percent %d", start.Format(time.RFC3339), time.Now().Format(time.RFC3339), strconv.Itoa(int(unit)), percent) - } - } -} diff --git a/internal/webauthn/webauthn.go b/internal/webauthn/webauthn.go index 5938f885bc..998c013a3c 100644 --- a/internal/webauthn/webauthn.go +++ b/internal/webauthn/webauthn.go @@ -100,7 +100,7 @@ func (w *Config) FinishRegistration(ctx context.Context, user *domain.Human, web } credentialData, err := protocol.ParseCredentialCreationResponseBody(bytes.NewReader(credData)) if err != nil { - logging.WithFields("error", tryExtractProtocolErrMsg(err)).Debug("webauthn credential could not be parsed") + logging.WithFields("error", tryExtractProtocolErrMsg(err), "err_id", "WEBAU-sEr8c").Debug("webauthn credential could not be parsed") return nil, zerrors.ThrowInternal(err, "WEBAU-sEr8c", "Errors.User.WebAuthN.ErrorOnParseCredential") } sessionData := WebAuthNToSessionData(webAuthN) @@ -115,7 +115,7 @@ func (w *Config) FinishRegistration(ctx context.Context, user *domain.Human, web sessionData, credentialData) if err != nil { - logging.WithFields("error", tryExtractProtocolErrMsg(err)).Debug("webauthn credential could not be created") + logging.WithFields("error", tryExtractProtocolErrMsg(err), "err_id", "WEBAU-3Vb9s").Debug("webauthn credential could not be created") return nil, zerrors.ThrowInternal(err, "WEBAU-3Vb9s", "Errors.User.WebAuthN.CreateCredentialFailed") } From 5bdf1a4547cdecdd87c4299a6b089c530fc666c1 Mon Sep 17 00:00:00 2001 From: Stefan Benz <46600784+stebenz@users.noreply.github.com> Date: Fri, 6 Sep 2024 15:11:36 +0200 Subject: [PATCH 07/33] feat: add http as sms provider (#8540) # Which Problems Are Solved Send SMS messages as a HTTP call to a relay, for own logic on handling different SMS providers. # How the Problems Are Solved Add HTTP as SMS provider type and handling of webhook messages in the notification handlers. # Additional Changes Clean up old Twilio events, which were supposed to handle the general SMS providers with deactivate, activate and remove. # Additional Context Partially closes #8270 --------- Co-authored-by: Livio Spring --- internal/api/grpc/admin/sms.go | 35 +- internal/api/grpc/admin/sms_converter.go | 67 +- internal/command/sms_config.go | 329 +++++--- internal/command/sms_config_model.go | 100 ++- internal/command/sms_config_test.go | 741 +++++++++++++++--- internal/command/smtp_test.go | 43 + internal/notification/channels.go | 10 +- internal/notification/channels/sms/config.go | 17 + internal/notification/handlers/config_sms.go | 52 ++ .../notification/handlers/config_twilio.go | 35 - .../handlers/mock/queries.mock.go | 17 +- internal/notification/handlers/queries.go | 2 +- .../notification/handlers/user_notifier.go | 6 +- .../handlers/user_notifier_test.go | 4 +- internal/notification/senders/sms.go | 30 +- .../notification/templates/templateData.go | 32 +- internal/notification/types/notification.go | 9 +- internal/notification/types/user_phone.go | 66 +- internal/query/projection/sms.go | 336 ++++++-- internal/query/projection/sms_test.go | 388 ++++++++- internal/query/sms.go | 169 ++-- internal/query/sms_test.go | 234 +++++- internal/repository/instance/eventstore.go | 17 +- internal/repository/instance/sms.go | 278 +++++-- proto/zitadel/admin.proto | 100 +++ proto/zitadel/settings.proto | 12 +- 26 files changed, 2536 insertions(+), 593 deletions(-) create mode 100644 internal/notification/channels/sms/config.go create mode 100644 internal/notification/handlers/config_sms.go delete mode 100644 internal/notification/handlers/config_twilio.go diff --git a/internal/api/grpc/admin/sms.go b/internal/api/grpc/admin/sms.go index 18e11b23df..8517a8ff2d 100644 --- a/internal/api/grpc/admin/sms.go +++ b/internal/api/grpc/admin/sms.go @@ -34,23 +34,23 @@ func (s *Server) GetSMSProvider(ctx context.Context, req *admin_pb.GetSMSProvide } func (s *Server) AddSMSProviderTwilio(ctx context.Context, req *admin_pb.AddSMSProviderTwilioRequest) (*admin_pb.AddSMSProviderTwilioResponse, error) { - id, result, err := s.command.AddSMSConfigTwilio(ctx, authz.GetInstance(ctx).InstanceID(), AddSMSConfigTwilioToConfig(req)) - if err != nil { + smsConfig := addSMSConfigTwilioToConfig(ctx, req) + if err := s.command.AddSMSConfigTwilio(ctx, smsConfig); err != nil { return nil, err } return &admin_pb.AddSMSProviderTwilioResponse{ - Details: object.DomainToAddDetailsPb(result), - Id: id, + Details: object.DomainToAddDetailsPb(smsConfig.Details), + Id: smsConfig.ID, }, nil } func (s *Server) UpdateSMSProviderTwilio(ctx context.Context, req *admin_pb.UpdateSMSProviderTwilioRequest) (*admin_pb.UpdateSMSProviderTwilioResponse, error) { - result, err := s.command.ChangeSMSConfigTwilio(ctx, authz.GetInstance(ctx).InstanceID(), req.Id, UpdateSMSConfigTwilioToConfig(req)) - if err != nil { + smsConfig := updateSMSConfigTwilioToConfig(ctx, req) + if err := s.command.ChangeSMSConfigTwilio(ctx, smsConfig); err != nil { return nil, err } return &admin_pb.UpdateSMSProviderTwilioResponse{ - Details: object.DomainToChangeDetailsPb(result), + Details: object.DomainToChangeDetailsPb(smsConfig.Details), }, nil } @@ -65,6 +65,27 @@ func (s *Server) UpdateSMSProviderTwilioToken(ctx context.Context, req *admin_pb }, nil } +func (s *Server) AddSMSProviderHTTP(ctx context.Context, req *admin_pb.AddSMSProviderHTTPRequest) (*admin_pb.AddSMSProviderHTTPResponse, error) { + smsConfig := addSMSConfigHTTPToConfig(ctx, req) + if err := s.command.AddSMSConfigHTTP(ctx, smsConfig); err != nil { + return nil, err + } + return &admin_pb.AddSMSProviderHTTPResponse{ + Details: object.DomainToAddDetailsPb(smsConfig.Details), + Id: smsConfig.ID, + }, nil +} + +func (s *Server) UpdateSMSProviderHTTP(ctx context.Context, req *admin_pb.UpdateSMSProviderHTTPRequest) (*admin_pb.UpdateSMSProviderHTTPResponse, error) { + smsConfig := updateSMSConfigHTTPToConfig(ctx, req) + if err := s.command.ChangeSMSConfigHTTP(ctx, smsConfig); err != nil { + return nil, err + } + return &admin_pb.UpdateSMSProviderHTTPResponse{ + Details: object.DomainToChangeDetailsPb(smsConfig.Details), + }, nil +} + func (s *Server) ActivateSMSProvider(ctx context.Context, req *admin_pb.ActivateSMSProviderRequest) (*admin_pb.ActivateSMSProviderResponse, error) { result, err := s.command.ActivateSMSConfig(ctx, authz.GetInstance(ctx).InstanceID(), req.Id) if err != nil { diff --git a/internal/api/grpc/admin/sms_converter.go b/internal/api/grpc/admin/sms_converter.go index 08ca931a7a..d13b68f558 100644 --- a/internal/api/grpc/admin/sms_converter.go +++ b/internal/api/grpc/admin/sms_converter.go @@ -1,9 +1,14 @@ package admin import ( + "context" + + "github.com/muhlemmer/gu" + + "github.com/zitadel/zitadel/internal/api/authz" "github.com/zitadel/zitadel/internal/api/grpc/object" + "github.com/zitadel/zitadel/internal/command" "github.com/zitadel/zitadel/internal/domain" - "github.com/zitadel/zitadel/internal/notification/channels/twilio" "github.com/zitadel/zitadel/internal/query" admin_pb "github.com/zitadel/zitadel/pkg/grpc/admin" settings_pb "github.com/zitadel/zitadel/pkg/grpc/settings" @@ -30,10 +35,11 @@ func SMSConfigsToPb(configs []*query.SMSConfig) []*settings_pb.SMSProvider { func SMSConfigToProviderPb(config *query.SMSConfig) *settings_pb.SMSProvider { return &settings_pb.SMSProvider{ - Details: object.ToViewDetailsPb(config.Sequence, config.CreationDate, config.ChangeDate, config.ResourceOwner), - Id: config.ID, - State: smsStateToPb(config.State), - Config: SMSConfigToPb(config), + Details: object.ToViewDetailsPb(config.Sequence, config.CreationDate, config.ChangeDate, config.ResourceOwner), + Id: config.ID, + Description: config.Description, + State: smsStateToPb(config.State), + Config: SMSConfigToPb(config), } } @@ -41,9 +47,20 @@ func SMSConfigToPb(config *query.SMSConfig) settings_pb.SMSConfig { if config.TwilioConfig != nil { return TwilioConfigToPb(config.TwilioConfig) } + if config.HTTPConfig != nil { + return HTTPConfigToPb(config.HTTPConfig) + } return nil } +func HTTPConfigToPb(http *query.HTTP) *settings_pb.SMSProvider_Http { + return &settings_pb.SMSProvider_Http{ + Http: &settings_pb.HTTPConfig{ + Endpoint: http.Endpoint, + }, + } +} + func TwilioConfigToPb(twilio *query.Twilio) *settings_pb.SMSProvider_Twilio { return &settings_pb.SMSProvider_Twilio{ Twilio: &settings_pb.TwilioConfig{ @@ -64,17 +81,39 @@ func smsStateToPb(state domain.SMSConfigState) settings_pb.SMSProviderConfigStat } } -func AddSMSConfigTwilioToConfig(req *admin_pb.AddSMSProviderTwilioRequest) *twilio.Config { - return &twilio.Config{ - SID: req.Sid, - SenderNumber: req.SenderNumber, - Token: req.Token, +func addSMSConfigTwilioToConfig(ctx context.Context, req *admin_pb.AddSMSProviderTwilioRequest) *command.AddTwilioConfig { + return &command.AddTwilioConfig{ + ResourceOwner: authz.GetInstance(ctx).InstanceID(), + Description: req.Description, + SID: req.Sid, + SenderNumber: req.SenderNumber, + Token: req.Token, } } -func UpdateSMSConfigTwilioToConfig(req *admin_pb.UpdateSMSProviderTwilioRequest) *twilio.Config { - return &twilio.Config{ - SID: req.Sid, - SenderNumber: req.SenderNumber, +func updateSMSConfigTwilioToConfig(ctx context.Context, req *admin_pb.UpdateSMSProviderTwilioRequest) *command.ChangeTwilioConfig { + return &command.ChangeTwilioConfig{ + ResourceOwner: authz.GetInstance(ctx).InstanceID(), + ID: req.Id, + Description: gu.Ptr(req.Description), + SID: gu.Ptr(req.Sid), + SenderNumber: gu.Ptr(req.SenderNumber), + } +} + +func addSMSConfigHTTPToConfig(ctx context.Context, req *admin_pb.AddSMSProviderHTTPRequest) *command.AddSMSHTTP { + return &command.AddSMSHTTP{ + ResourceOwner: authz.GetInstance(ctx).InstanceID(), + Description: req.GetDescription(), + Endpoint: req.GetEndpoint(), + } +} + +func updateSMSConfigHTTPToConfig(ctx context.Context, req *admin_pb.UpdateSMSProviderHTTPRequest) *command.ChangeSMSHTTP { + return &command.ChangeSMSHTTP{ + ResourceOwner: authz.GetInstance(ctx).InstanceID(), + ID: req.Id, + Description: gu.Ptr(req.Description), + Endpoint: gu.Ptr(req.Endpoint), } } diff --git a/internal/command/sms_config.go b/internal/command/sms_config.go index cd5e38e518..82eae763df 100644 --- a/internal/command/sms_config.go +++ b/internal/command/sms_config.go @@ -5,203 +5,328 @@ import ( "github.com/zitadel/zitadel/internal/crypto" "github.com/zitadel/zitadel/internal/domain" - "github.com/zitadel/zitadel/internal/notification/channels/twilio" "github.com/zitadel/zitadel/internal/repository/instance" "github.com/zitadel/zitadel/internal/zerrors" ) -func (c *Commands) AddSMSConfigTwilio(ctx context.Context, instanceID string, config *twilio.Config) (string, *domain.ObjectDetails, error) { - id, err := c.idGenerator.Next() - if err != nil { - return "", nil, err +type AddTwilioConfig struct { + Details *domain.ObjectDetails + ResourceOwner string + ID string + + Description string + SID string + Token string + SenderNumber string +} + +func (c *Commands) AddSMSConfigTwilio(ctx context.Context, config *AddTwilioConfig) (err error) { + if config.ResourceOwner == "" { + return zerrors.ThrowInvalidArgument(nil, "COMMAND-ZLrZhKSKq0", "Errors.ResourceOwnerMissing") } - smsConfigWriteModel, err := c.getSMSConfig(ctx, instanceID, id) + if config.ID == "" { + config.ID, err = c.idGenerator.Next() + if err != nil { + return err + } + } + smsConfigWriteModel, err := c.getSMSConfig(ctx, config.ResourceOwner, config.ID) if err != nil { - return "", nil, err + return err } var token *crypto.CryptoValue if config.Token != "" { token, err = crypto.Encrypt([]byte(config.Token), c.smsEncryption) if err != nil { - return "", nil, err + return err } } - - iamAgg := InstanceAggregateFromWriteModel(&smsConfigWriteModel.WriteModel) - pushedEvents, err := c.eventstore.Push(ctx, instance.NewSMSConfigTwilioAddedEvent( - ctx, - iamAgg, - id, - config.SID, - config.SenderNumber, - token)) + err = c.pushAppendAndReduce(ctx, + smsConfigWriteModel, + instance.NewSMSConfigTwilioAddedEvent( + ctx, + InstanceAggregateFromWriteModel(&smsConfigWriteModel.WriteModel), + config.ID, + config.Description, + config.SID, + config.SenderNumber, + token, + ), + ) if err != nil { - return "", nil, err + return err } - err = AppendAndReduce(smsConfigWriteModel, pushedEvents...) - if err != nil { - return "", nil, err - } - return id, writeModelToObjectDetails(&smsConfigWriteModel.WriteModel), nil + config.Details = writeModelToObjectDetails(&smsConfigWriteModel.WriteModel) + return nil } -func (c *Commands) ChangeSMSConfigTwilio(ctx context.Context, instanceID, id string, config *twilio.Config) (*domain.ObjectDetails, error) { - if id == "" { - return nil, zerrors.ThrowInvalidArgument(nil, "SMS-e9jwf", "Errors.IDMissing") +type ChangeTwilioConfig struct { + Details *domain.ObjectDetails + ResourceOwner string + ID string + + Description *string + SID *string + Token *string + SenderNumber *string +} + +func (c *Commands) ChangeSMSConfigTwilio(ctx context.Context, config *ChangeTwilioConfig) (err error) { + if config.ResourceOwner == "" { + return zerrors.ThrowInvalidArgument(nil, "COMMAND-RHXryJwmFG", "Errors.ResourceOwnerMissing") } - smsConfigWriteModel, err := c.getSMSConfig(ctx, instanceID, id) + if config.ID == "" { + return zerrors.ThrowInvalidArgument(nil, "COMMAND-gMr93iNhTR", "Errors.IDMissing") + } + smsConfigWriteModel, err := c.getSMSConfig(ctx, config.ResourceOwner, config.ID) if err != nil { - return nil, err + return err } if !smsConfigWriteModel.State.Exists() || smsConfigWriteModel.Twilio == nil { - return nil, zerrors.ThrowNotFound(nil, "COMMAND-2m9fw", "Errors.SMSConfig.NotFound") + return zerrors.ThrowNotFound(nil, "COMMAND-MUY0IFAf8O", "Errors.SMSConfig.NotFound") } - iamAgg := InstanceAggregateFromWriteModel(&smsConfigWriteModel.WriteModel) - - changedEvent, hasChanged, err := smsConfigWriteModel.NewChangedEvent( + changedEvent, hasChanged, err := smsConfigWriteModel.NewTwilioChangedEvent( ctx, - iamAgg, - id, + InstanceAggregateFromWriteModel(&smsConfigWriteModel.WriteModel), + config.ID, + config.Description, config.SID, config.SenderNumber) if err != nil { - return nil, err + return err } if !hasChanged { - return nil, zerrors.ThrowPreconditionFailed(nil, "COMMAND-jf9wk", "Errors.NoChangesFound") + config.Details = writeModelToObjectDetails(&smsConfigWriteModel.WriteModel) + return nil } - pushedEvents, err := c.eventstore.Push(ctx, changedEvent) + err = c.pushAppendAndReduce(ctx, + smsConfigWriteModel, + changedEvent, + ) if err != nil { - return nil, err + return err } - err = AppendAndReduce(smsConfigWriteModel, pushedEvents...) - if err != nil { - return nil, err - } - return writeModelToObjectDetails(&smsConfigWriteModel.WriteModel), nil + config.Details = writeModelToObjectDetails(&smsConfigWriteModel.WriteModel) + return nil } -func (c *Commands) ChangeSMSConfigTwilioToken(ctx context.Context, instanceID, id, token string) (*domain.ObjectDetails, error) { - smsConfigWriteModel, err := c.getSMSConfig(ctx, instanceID, id) +func (c *Commands) ChangeSMSConfigTwilioToken(ctx context.Context, resourceOwner, id, token string) (*domain.ObjectDetails, error) { + if resourceOwner == "" { + return nil, zerrors.ThrowInvalidArgument(nil, "COMMAND-sLLA1HnMzj", "Errors.ResourceOwnerMissing") + } + if id == "" { + return nil, zerrors.ThrowInvalidArgument(nil, "SMS-PeNaqbC0r0", "Errors.IDMissing") + } + + smsConfigWriteModel, err := c.getSMSConfig(ctx, resourceOwner, id) if err != nil { return nil, err } if !smsConfigWriteModel.State.Exists() || smsConfigWriteModel.Twilio == nil { - return nil, zerrors.ThrowNotFound(nil, "COMMAND-fj9wf", "Errors.SMSConfig.NotFound") + return nil, zerrors.ThrowNotFound(nil, "COMMAND-ij3NhEHATp", "Errors.SMSConfig.NotFound") } - iamAgg := InstanceAggregateFromWriteModel(&smsConfigWriteModel.WriteModel) newtoken, err := crypto.Encrypt([]byte(token), c.smsEncryption) if err != nil { return nil, err } - pushedEvents, err := c.eventstore.Push(ctx, instance.NewSMSConfigTokenChangedEvent( - ctx, - iamAgg, - id, - newtoken)) - if err != nil { - return nil, err - } - err = AppendAndReduce(smsConfigWriteModel, pushedEvents...) + err = c.pushAppendAndReduce(ctx, + smsConfigWriteModel, + instance.NewSMSConfigTokenChangedEvent( + ctx, + InstanceAggregateFromWriteModel(&smsConfigWriteModel.WriteModel), + id, + newtoken, + ), + ) if err != nil { return nil, err } return writeModelToObjectDetails(&smsConfigWriteModel.WriteModel), nil } -func (c *Commands) ActivateSMSConfig(ctx context.Context, instanceID, id string) (*domain.ObjectDetails, error) { - if id == "" { - return nil, zerrors.ThrowInvalidArgument(nil, "SMS-dn93n", "Errors.IDMissing") +type AddSMSHTTP struct { + Details *domain.ObjectDetails + ResourceOwner string + ID string + + Description string + Endpoint string +} + +func (c *Commands) AddSMSConfigHTTP(ctx context.Context, config *AddSMSHTTP) (err error) { + if config.ResourceOwner == "" { + return zerrors.ThrowInvalidArgument(nil, "COMMAND-huy99qWjX4", "Errors.ResourceOwnerMissing") } - smsConfigWriteModel, err := c.getSMSConfig(ctx, instanceID, id) + if config.ID == "" { + config.ID, err = c.idGenerator.Next() + if err != nil { + return err + } + } + smsConfigWriteModel, err := c.getSMSConfig(ctx, config.ResourceOwner, config.ID) + if err != nil { + return err + } + + err = c.pushAppendAndReduce(ctx, + smsConfigWriteModel, + instance.NewSMSConfigHTTPAddedEvent( + ctx, + InstanceAggregateFromWriteModel(&smsConfigWriteModel.WriteModel), + config.ID, + config.Description, + config.Endpoint, + ), + ) + if err != nil { + return err + } + config.Details = writeModelToObjectDetails(&smsConfigWriteModel.WriteModel) + return nil +} + +type ChangeSMSHTTP struct { + Details *domain.ObjectDetails + ResourceOwner string + ID string + + Description *string + Endpoint *string +} + +func (c *Commands) ChangeSMSConfigHTTP(ctx context.Context, config *ChangeSMSHTTP) (err error) { + if config.ResourceOwner == "" { + return zerrors.ThrowInvalidArgument(nil, "COMMAND-M622CFQnwK", "Errors.ResourceOwnerMissing") + } + if config.ID == "" { + return zerrors.ThrowInvalidArgument(nil, "COMMAND-phyb2e4Kll", "Errors.IDMissing") + } + smsConfigWriteModel, err := c.getSMSConfig(ctx, config.ResourceOwner, config.ID) + if err != nil { + return err + } + if !smsConfigWriteModel.State.Exists() || smsConfigWriteModel.HTTP == nil { + return zerrors.ThrowNotFound(nil, "COMMAND-6NW4I5Kqzj", "Errors.SMSConfig.NotFound") + } + changedEvent, hasChanged, err := smsConfigWriteModel.NewHTTPChangedEvent( + ctx, + InstanceAggregateFromWriteModel(&smsConfigWriteModel.WriteModel), + config.ID, + config.Description, + config.Endpoint) + if err != nil { + return err + } + if !hasChanged { + config.Details = writeModelToObjectDetails(&smsConfigWriteModel.WriteModel) + return nil + } + err = c.pushAppendAndReduce(ctx, smsConfigWriteModel, changedEvent) + if err != nil { + return err + } + config.Details = writeModelToObjectDetails(&smsConfigWriteModel.WriteModel) + return nil +} + +func (c *Commands) ActivateSMSConfig(ctx context.Context, resourceOwner, id string) (*domain.ObjectDetails, error) { + if resourceOwner == "" { + return nil, zerrors.ThrowInvalidArgument(nil, "COMMAND-EFgoOg997V", "Errors.ResourceOwnerMissing") + } + if id == "" { + return nil, zerrors.ThrowInvalidArgument(nil, "COMMAND-jJ6TVqzvjp", "Errors.IDMissing") + } + + smsConfigWriteModel, err := c.getSMSConfig(ctx, resourceOwner, id) if err != nil { return nil, err } if !smsConfigWriteModel.State.Exists() { - return nil, zerrors.ThrowNotFound(nil, "COMMAND-sn9we", "Errors.SMSConfig.NotFound") + return nil, zerrors.ThrowNotFound(nil, "COMMAND-9ULtp9PH5E", "Errors.SMSConfig.NotFound") } if smsConfigWriteModel.State == domain.SMSConfigStateActive { - return nil, zerrors.ThrowNotFound(nil, "COMMAND-sn9we", "Errors.SMSConfig.AlreadyActive") + return nil, zerrors.ThrowPreconditionFailed(nil, "COMMAND-B25GFeIvRi", "Errors.SMSConfig.AlreadyActive") } - iamAgg := InstanceAggregateFromWriteModel(&smsConfigWriteModel.WriteModel) - pushedEvents, err := c.eventstore.Push(ctx, instance.NewSMSConfigTwilioActivatedEvent( - ctx, - iamAgg, - id)) - if err != nil { - return nil, err - } - err = AppendAndReduce(smsConfigWriteModel, pushedEvents...) + err = c.pushAppendAndReduce(ctx, smsConfigWriteModel, + instance.NewSMSConfigActivatedEvent( + ctx, + InstanceAggregateFromWriteModel(&smsConfigWriteModel.WriteModel), + id, + ), + ) if err != nil { return nil, err } return writeModelToObjectDetails(&smsConfigWriteModel.WriteModel), nil } -func (c *Commands) DeactivateSMSConfig(ctx context.Context, instanceID, id string) (*domain.ObjectDetails, error) { - if id == "" { - return nil, zerrors.ThrowInvalidArgument(nil, "SMS-frkwf", "Errors.IDMissing") +func (c *Commands) DeactivateSMSConfig(ctx context.Context, resourceOwner, id string) (*domain.ObjectDetails, error) { + if resourceOwner == "" { + return nil, zerrors.ThrowInvalidArgument(nil, "COMMAND-V9NWOZj8Gi", "Errors.ResourceOwnerMissing") } - smsConfigWriteModel, err := c.getSMSConfig(ctx, instanceID, id) + if id == "" { + return nil, zerrors.ThrowInvalidArgument(nil, "COMMAND-xs1ah1v1CL", "Errors.IDMissing") + } + smsConfigWriteModel, err := c.getSMSConfig(ctx, resourceOwner, id) if err != nil { return nil, err } if !smsConfigWriteModel.State.Exists() { - return nil, zerrors.ThrowNotFound(nil, "COMMAND-s39Kg", "Errors.SMSConfig.NotFound") + return nil, zerrors.ThrowNotFound(nil, "COMMAND-La91dGNhbM", "Errors.SMSConfig.NotFound") } if smsConfigWriteModel.State == domain.SMSConfigStateInactive { - return nil, zerrors.ThrowNotFound(nil, "COMMAND-dm9e3", "Errors.SMSConfig.AlreadyDeactivated") + return nil, zerrors.ThrowPreconditionFailed(nil, "COMMAND-OSZAEkYvk7", "Errors.SMSConfig.AlreadyDeactivated") } - - iamAgg := InstanceAggregateFromWriteModel(&smsConfigWriteModel.WriteModel) - pushedEvents, err := c.eventstore.Push(ctx, instance.NewSMSConfigDeactivatedEvent( - ctx, - iamAgg, - id)) - if err != nil { - return nil, err - } - err = AppendAndReduce(smsConfigWriteModel, pushedEvents...) + err = c.pushAppendAndReduce(ctx, + smsConfigWriteModel, + instance.NewSMSConfigDeactivatedEvent( + ctx, + InstanceAggregateFromWriteModel(&smsConfigWriteModel.WriteModel), + id, + ), + ) if err != nil { return nil, err } return writeModelToObjectDetails(&smsConfigWriteModel.WriteModel), nil } -func (c *Commands) RemoveSMSConfig(ctx context.Context, instanceID, id string) (*domain.ObjectDetails, error) { - if id == "" { - return nil, zerrors.ThrowInvalidArgument(nil, "SMS-3j9fs", "Errors.IDMissing") +func (c *Commands) RemoveSMSConfig(ctx context.Context, resourceOwner, id string) (*domain.ObjectDetails, error) { + if resourceOwner == "" { + return nil, zerrors.ThrowInvalidArgument(nil, "COMMAND-cw0NSJsn1v", "Errors.ResourceOwnerMissing") } - smsConfigWriteModel, err := c.getSMSConfig(ctx, instanceID, id) + if id == "" { + return nil, zerrors.ThrowInvalidArgument(nil, "COMMAND-Qrz7lvdC4c", "Errors.IDMissing") + } + smsConfigWriteModel, err := c.getSMSConfig(ctx, resourceOwner, id) if err != nil { return nil, err } if !smsConfigWriteModel.State.Exists() { - return nil, zerrors.ThrowNotFound(nil, "COMMAND-sn9we", "Errors.SMSConfig.NotFound") + return nil, zerrors.ThrowNotFound(nil, "COMMAND-povEVHPCkV", "Errors.SMSConfig.NotFound") } - iamAgg := InstanceAggregateFromWriteModel(&smsConfigWriteModel.WriteModel) - pushedEvents, err := c.eventstore.Push(ctx, instance.NewSMSConfigRemovedEvent( - ctx, - iamAgg, - id)) - if err != nil { - return nil, err - } - err = AppendAndReduce(smsConfigWriteModel, pushedEvents...) + err = c.pushAppendAndReduce(ctx, + smsConfigWriteModel, + instance.NewSMSConfigRemovedEvent( + ctx, + InstanceAggregateFromWriteModel(&smsConfigWriteModel.WriteModel), + id, + ), + ) if err != nil { return nil, err } return writeModelToObjectDetails(&smsConfigWriteModel.WriteModel), nil } + func (c *Commands) getSMSConfig(ctx context.Context, instanceID, id string) (_ *IAMSMSConfigWriteModel, err error) { writeModel := NewIAMSMSConfigWriteModel(instanceID, id) err = c.eventstore.FilterToQueryReducer(ctx, writeModel) if err != nil { return nil, err } - return writeModel, nil } diff --git a/internal/command/sms_config_model.go b/internal/command/sms_config_model.go index 2c215ebf6b..8c0e7585dd 100644 --- a/internal/command/sms_config_model.go +++ b/internal/command/sms_config_model.go @@ -12,9 +12,11 @@ import ( type IAMSMSConfigWriteModel struct { eventstore.WriteModel - ID string - Twilio *TwilioConfig - State domain.SMSConfigState + ID string + Description string + Twilio *TwilioConfig + HTTP *HTTPConfig + State domain.SMSConfigState } type TwilioConfig struct { @@ -23,6 +25,10 @@ type TwilioConfig struct { SenderNumber string } +type HTTPConfig struct { + Endpoint string +} + func NewIAMSMSConfigWriteModel(instanceID, id string) *IAMSMSConfigWriteModel { return &IAMSMSConfigWriteModel{ WriteModel: eventstore.WriteModel{ @@ -46,11 +52,15 @@ func (wm *IAMSMSConfigWriteModel) Reduce() error { Token: e.Token, SenderNumber: e.SenderNumber, } + wm.Description = e.Description wm.State = domain.SMSConfigStateInactive case *instance.SMSConfigTwilioChangedEvent: if wm.ID != e.ID { continue } + if e.Description != nil { + wm.Description = *e.Description + } if e.SID != nil { wm.Twilio.SID = *e.SID } @@ -62,6 +72,42 @@ func (wm *IAMSMSConfigWriteModel) Reduce() error { continue } wm.Twilio.Token = e.Token + case *instance.SMSConfigHTTPAddedEvent: + if wm.ID != e.ID { + continue + } + wm.HTTP = &HTTPConfig{ + Endpoint: e.Endpoint, + } + wm.Description = e.Description + wm.State = domain.SMSConfigStateInactive + case *instance.SMSConfigHTTPChangedEvent: + if wm.ID != e.ID { + continue + } + if e.Description != nil { + wm.Description = *e.Description + } + if e.Endpoint != nil { + wm.HTTP.Endpoint = *e.Endpoint + } + case *instance.SMSConfigTwilioActivatedEvent: + if wm.ID != e.ID { + continue + } + wm.State = domain.SMSConfigStateActive + case *instance.SMSConfigTwilioDeactivatedEvent: + if wm.ID != e.ID { + continue + } + wm.State = domain.SMSConfigStateInactive + case *instance.SMSConfigTwilioRemovedEvent: + if wm.ID != e.ID { + continue + } + wm.Twilio = nil + wm.HTTP = nil + wm.State = domain.SMSConfigStateRemoved case *instance.SMSConfigActivatedEvent: if wm.ID != e.ID { continue @@ -77,6 +123,7 @@ func (wm *IAMSMSConfigWriteModel) Reduce() error { continue } wm.Twilio = nil + wm.HTTP = nil wm.State = domain.SMSConfigStateRemoved } } @@ -92,21 +139,33 @@ func (wm *IAMSMSConfigWriteModel) Query() *eventstore.SearchQueryBuilder { instance.SMSConfigTwilioAddedEventType, instance.SMSConfigTwilioChangedEventType, instance.SMSConfigTwilioTokenChangedEventType, + instance.SMSConfigHTTPAddedEventType, + instance.SMSConfigHTTPChangedEventType, + instance.SMSConfigTwilioActivatedEventType, + instance.SMSConfigTwilioDeactivatedEventType, + instance.SMSConfigTwilioRemovedEventType, instance.SMSConfigActivatedEventType, instance.SMSConfigDeactivatedEventType, instance.SMSConfigRemovedEventType). Builder() } -func (wm *IAMSMSConfigWriteModel) NewChangedEvent(ctx context.Context, aggregate *eventstore.Aggregate, id, sid, senderNumber string) (*instance.SMSConfigTwilioChangedEvent, bool, error) { +func (wm *IAMSMSConfigWriteModel) NewTwilioChangedEvent(ctx context.Context, aggregate *eventstore.Aggregate, id string, description, sid, senderNumber *string) (*instance.SMSConfigTwilioChangedEvent, bool, error) { changes := make([]instance.SMSConfigTwilioChanges, 0) var err error - if wm.Twilio.SID != sid { - changes = append(changes, instance.ChangeSMSConfigTwilioSID(sid)) + if wm.Twilio == nil { + return nil, false, nil } - if wm.Twilio.SenderNumber != senderNumber { - changes = append(changes, instance.ChangeSMSConfigTwilioSenderNumber(senderNumber)) + + if description != nil && wm.Description != *description { + changes = append(changes, instance.ChangeSMSConfigTwilioDescription(*description)) + } + if sid != nil && wm.Twilio.SID != *sid { + changes = append(changes, instance.ChangeSMSConfigTwilioSID(*sid)) + } + if senderNumber != nil && wm.Twilio.SenderNumber != *senderNumber { + changes = append(changes, instance.ChangeSMSConfigTwilioSenderNumber(*senderNumber)) } if len(changes) == 0 { @@ -118,3 +177,28 @@ func (wm *IAMSMSConfigWriteModel) NewChangedEvent(ctx context.Context, aggregate } return changeEvent, true, nil } + +func (wm *IAMSMSConfigWriteModel) NewHTTPChangedEvent(ctx context.Context, aggregate *eventstore.Aggregate, id string, description, endpoint *string) (*instance.SMSConfigHTTPChangedEvent, bool, error) { + changes := make([]instance.SMSConfigHTTPChanges, 0) + var err error + + if wm.HTTP == nil { + return nil, false, nil + } + + if description != nil && wm.Description != *description { + changes = append(changes, instance.ChangeSMSConfigHTTPDescription(*description)) + } + if endpoint != nil && wm.HTTP.Endpoint != *endpoint { + changes = append(changes, instance.ChangeSMSConfigHTTPEndpoint(*endpoint)) + } + + if len(changes) == 0 { + return nil, false, nil + } + changeEvent, err := instance.NewSMSConfigHTTPChangedEvent(ctx, aggregate, id, changes) + if err != nil { + return nil, false, err + } + return changeEvent, true, nil +} diff --git a/internal/command/sms_config_test.go b/internal/command/sms_config_test.go index 8d96751944..b0936ab8f1 100644 --- a/internal/command/sms_config_test.go +++ b/internal/command/sms_config_test.go @@ -2,8 +2,10 @@ package command import ( "context" + "errors" "testing" + "github.com/muhlemmer/gu" "github.com/stretchr/testify/assert" "go.uber.org/mock/gomock" @@ -12,21 +14,19 @@ import ( "github.com/zitadel/zitadel/internal/eventstore" "github.com/zitadel/zitadel/internal/id" id_mock "github.com/zitadel/zitadel/internal/id/mock" - "github.com/zitadel/zitadel/internal/notification/channels/twilio" "github.com/zitadel/zitadel/internal/repository/instance" "github.com/zitadel/zitadel/internal/zerrors" ) func TestCommandSide_AddSMSConfigTwilio(t *testing.T) { type fields struct { - eventstore *eventstore.Eventstore + eventstore func(*testing.T) *eventstore.Eventstore idGenerator id.Generator alg crypto.EncryptionAlgorithm } type args struct { - ctx context.Context - instanceID string - sms *twilio.Config + ctx context.Context + sms *AddTwilioConfig } type res struct { want *domain.ObjectDetails @@ -38,17 +38,32 @@ func TestCommandSide_AddSMSConfigTwilio(t *testing.T) { args args res res }{ + { + name: "add sms config twilio, missing resourceowner", + fields: fields{ + eventstore: expectEventstore(), + }, + args: args{ + ctx: context.Background(), + sms: &AddTwilioConfig{}, + }, + res: res{ + err: func(err error) bool { + return errors.Is(err, zerrors.ThrowInvalidArgument(nil, "COMMAND-ZLrZhKSKq0", "Errors.ResourceOwnerMissing")) + }, + }, + }, { name: "add sms config twilio, ok", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter(), expectPush( instance.NewSMSConfigTwilioAddedEvent( context.Background(), &instance.NewAggregate("INSTANCE").Aggregate, "providerid", + "description", "sid", "senderName", &crypto.CryptoValue{ @@ -64,12 +79,13 @@ func TestCommandSide_AddSMSConfigTwilio(t *testing.T) { alg: crypto.CreateMockEncryptionAlg(gomock.NewController(t)), }, args: args{ - ctx: context.Background(), - instanceID: "INSTANCE", - sms: &twilio.Config{ - SID: "sid", - Token: "token", - SenderNumber: "senderName", + ctx: context.Background(), + sms: &AddTwilioConfig{ + ResourceOwner: "INSTANCE", + Description: "description", + SID: "sid", + Token: "token", + SenderNumber: "senderName", }, }, res: res{ @@ -82,11 +98,11 @@ func TestCommandSide_AddSMSConfigTwilio(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { r := &Commands{ - eventstore: tt.fields.eventstore, + eventstore: tt.fields.eventstore(t), idGenerator: tt.fields.idGenerator, smsEncryption: tt.fields.alg, } - _, got, err := r.AddSMSConfigTwilio(tt.args.ctx, tt.args.instanceID, tt.args.sms) + err := r.AddSMSConfigTwilio(tt.args.ctx, tt.args.sms) if tt.res.err == nil { assert.NoError(t, err) } @@ -94,7 +110,7 @@ func TestCommandSide_AddSMSConfigTwilio(t *testing.T) { t.Errorf("got wrong err: %v ", err) } if tt.res.err == nil { - assertObjectDetails(t, tt.res.want, got) + assertObjectDetails(t, tt.res.want, tt.args.sms.Details) } }) } @@ -102,13 +118,11 @@ func TestCommandSide_AddSMSConfigTwilio(t *testing.T) { func TestCommandSide_ChangeSMSConfigTwilio(t *testing.T) { type fields struct { - eventstore *eventstore.Eventstore + eventstore func(*testing.T) *eventstore.Eventstore } type args struct { - ctx context.Context - instanceID string - id string - sms *twilio.Config + ctx context.Context + sms *ChangeTwilioConfig } type res struct { want *domain.ObjectDetails @@ -120,50 +134,70 @@ func TestCommandSide_ChangeSMSConfigTwilio(t *testing.T) { args args res res }{ + { - name: "id empty, precondition error", + name: "resourceowner empty, invalid argument error", fields: fields{ - eventstore: eventstoreExpect( - t, - ), + eventstore: expectEventstore(), }, args: args{ ctx: context.Background(), - sms: &twilio.Config{}, + sms: &ChangeTwilioConfig{}, }, res: res{ - err: zerrors.IsErrorInvalidArgument, + err: func(err error) bool { + return errors.Is(err, zerrors.ThrowInvalidArgument(nil, "COMMAND-RHXryJwmFG", "Errors.ResourceOwnerMissing")) + }, + }, + }, + { + name: "id empty, invalid argument error", + fields: fields{ + eventstore: expectEventstore(), + }, + args: args{ + ctx: context.Background(), + sms: &ChangeTwilioConfig{ + ResourceOwner: "INSTANCE", + }, + }, + res: res{ + err: func(err error) bool { + return errors.Is(err, zerrors.ThrowInvalidArgument(nil, "COMMAND-gMr93iNhTR", "Errors.IDMissing")) + }, }, }, { name: "sms not existing, not found error", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter(), ), }, args: args{ - ctx: context.Background(), - sms: &twilio.Config{}, - instanceID: "INSTANCE", - id: "id", + ctx: context.Background(), + sms: &ChangeTwilioConfig{ + ResourceOwner: "INSTANCE", + ID: "id", + }, }, res: res{ - err: zerrors.IsNotFound, + err: func(err error) bool { + return errors.Is(err, zerrors.ThrowNotFound(nil, "COMMAND-MUY0IFAf8O", "Errors.SMSConfig.NotFound")) + }, }, }, { - name: "no changes, precondition error", + name: "no changes", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher( instance.NewSMSConfigTwilioAddedEvent( context.Background(), &instance.NewAggregate("INSTANCE").Aggregate, "providerid", + "description", "sid", "senderName", &crypto.CryptoValue{ @@ -179,29 +213,31 @@ func TestCommandSide_ChangeSMSConfigTwilio(t *testing.T) { }, args: args{ ctx: context.Background(), - sms: &twilio.Config{ - SID: "sid", - Token: "token", - SenderNumber: "senderName", + sms: &ChangeTwilioConfig{ + ResourceOwner: "INSTANCE", + ID: "providerid", + SID: gu.Ptr("sid"), + Token: gu.Ptr("token"), + SenderNumber: gu.Ptr("senderName"), }, - instanceID: "INSTANCE", - id: "providerid", }, res: res{ - err: zerrors.IsPreconditionFailed, + want: &domain.ObjectDetails{ + ResourceOwner: "INSTANCE", + }, }, }, { name: "sms config twilio change, ok", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher( instance.NewSMSConfigTwilioAddedEvent( context.Background(), &instance.NewAggregate("INSTANCE").Aggregate, "providerid", + "description", "sid", "token", &crypto.CryptoValue{ @@ -219,19 +255,21 @@ func TestCommandSide_ChangeSMSConfigTwilio(t *testing.T) { "providerid", "sid2", "senderName2", + "description2", ), ), ), }, args: args{ ctx: context.Background(), - sms: &twilio.Config{ - SID: "sid2", - Token: "token2", - SenderNumber: "senderName2", + sms: &ChangeTwilioConfig{ + ResourceOwner: "INSTANCE", + ID: "providerid", + Description: gu.Ptr("description2"), + SID: gu.Ptr("sid2"), + Token: gu.Ptr("token2"), + SenderNumber: gu.Ptr("senderName2"), }, - instanceID: "INSTANCE", - id: "providerid", }, res: res{ want: &domain.ObjectDetails{ @@ -243,9 +281,9 @@ func TestCommandSide_ChangeSMSConfigTwilio(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { r := &Commands{ - eventstore: tt.fields.eventstore, + eventstore: tt.fields.eventstore(t), } - got, err := r.ChangeSMSConfigTwilio(tt.args.ctx, tt.args.instanceID, tt.args.id, tt.args.sms) + err := r.ChangeSMSConfigTwilio(tt.args.ctx, tt.args.sms) if tt.res.err == nil { assert.NoError(t, err) } @@ -253,15 +291,264 @@ func TestCommandSide_ChangeSMSConfigTwilio(t *testing.T) { t.Errorf("got wrong err: %v ", err) } if tt.res.err == nil { - assertObjectDetails(t, tt.res.want, got) + assertObjectDetails(t, tt.res.want, tt.args.sms.Details) } }) } } -func TestCommandSide_ActivateSMSConfigTwilio(t *testing.T) { +func TestCommandSide_AddSMSConfigHTTP(t *testing.T) { type fields struct { - eventstore *eventstore.Eventstore + eventstore func(t *testing.T) *eventstore.Eventstore + idGenerator id.Generator + alg crypto.EncryptionAlgorithm + } + type args struct { + ctx context.Context + http *AddSMSHTTP + } + type res struct { + want *domain.ObjectDetails + err func(error) bool + } + tests := []struct { + name string + fields fields + args args + res res + }{ + { + name: "add sms config http, resource owner missing", + fields: fields{ + eventstore: expectEventstore(), + }, + args: args{ + ctx: context.Background(), + http: &AddSMSHTTP{}, + }, + res: res{ + err: func(err error) bool { + return errors.Is(err, zerrors.ThrowInvalidArgument(nil, "COMMAND-huy99qWjX4", "Errors.ResourceOwnerMissing")) + }, + }, + }, + { + name: "add sms config http, ok", + fields: fields{ + eventstore: expectEventstore( + expectFilter(), + expectPush( + instance.NewSMSConfigHTTPAddedEvent( + context.Background(), + &instance.NewAggregate("INSTANCE").Aggregate, + "providerid", + "description", + "endpoint", + ), + ), + ), + idGenerator: id_mock.NewIDGeneratorExpectIDs(t, "providerid"), + }, + args: args{ + ctx: context.Background(), + http: &AddSMSHTTP{ + ResourceOwner: "INSTANCE", + Description: "description", + Endpoint: "endpoint", + }, + }, + res: res{ + want: &domain.ObjectDetails{ + ResourceOwner: "INSTANCE", + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + r := &Commands{ + eventstore: tt.fields.eventstore(t), + idGenerator: tt.fields.idGenerator, + smsEncryption: tt.fields.alg, + } + err := r.AddSMSConfigHTTP(tt.args.ctx, tt.args.http) + 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.want, tt.args.http.Details) + } + }) + } +} + +func TestCommandSide_ChangeSMSConfigHTTP(t *testing.T) { + type fields struct { + eventstore func(*testing.T) *eventstore.Eventstore + } + type args struct { + ctx context.Context + http *ChangeSMSHTTP + } + type res struct { + want *domain.ObjectDetails + err func(error) bool + } + tests := []struct { + name string + fields fields + args args + res res + }{ + { + name: "resourceowner empty, precondition error", + fields: fields{ + eventstore: expectEventstore(), + }, + args: args{ + ctx: context.Background(), + http: &ChangeSMSHTTP{}, + }, + res: res{ + err: func(err error) bool { + return errors.Is(err, zerrors.ThrowInvalidArgument(nil, "COMMAND-M622CFQnwK", "Errors.ResourceOwnerMissing")) + }, + }, + }, + { + name: "id empty, precondition error", + fields: fields{ + eventstore: expectEventstore(), + }, + args: args{ + ctx: context.Background(), + http: &ChangeSMSHTTP{ + ResourceOwner: "INSTANCE", + }, + }, + res: res{ + err: func(err error) bool { + return errors.Is(err, zerrors.ThrowInvalidArgument(nil, "COMMAND-phyb2e4Kll", "Errors.IDMissing")) + }, + }, + }, + { + name: "sms not existing, not found error", + fields: fields{ + eventstore: expectEventstore( + expectFilter(), + ), + }, + args: args{ + ctx: context.Background(), + http: &ChangeSMSHTTP{ + ResourceOwner: "INSTANCE", + ID: "id", + }, + }, + res: res{ + err: func(err error) bool { + return errors.Is(err, zerrors.ThrowNotFound(nil, "COMMAND-6NW4I5Kqzj", "Errors.SMSConfig.NotFound")) + }, + }, + }, + { + name: "no changes", + fields: fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + instance.NewSMSConfigHTTPAddedEvent( + context.Background(), + &instance.NewAggregate("INSTANCE").Aggregate, + "providerid", + "description", + "endpoint", + ), + ), + ), + ), + }, + args: args{ + ctx: context.Background(), + http: &ChangeSMSHTTP{ + ResourceOwner: "INSTANCE", + ID: "providerid", + Endpoint: gu.Ptr("endpoint"), + }, + }, + res: res{ + want: &domain.ObjectDetails{ + ResourceOwner: "INSTANCE", + }, + }, + }, + { + name: "sms config http change, ok", + fields: fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + instance.NewSMSConfigHTTPAddedEvent( + context.Background(), + &instance.NewAggregate("INSTANCE").Aggregate, + "providerid", + "description", + "endpoint", + ), + ), + ), + expectPush( + newSMSConfigHTTPChangedEvent( + context.Background(), + "providerid", + "endpoint2", + "description2", + ), + ), + ), + }, + args: args{ + ctx: context.Background(), + http: &ChangeSMSHTTP{ + ResourceOwner: "INSTANCE", + ID: "providerid", + Description: gu.Ptr("description2"), + Endpoint: gu.Ptr("endpoint2"), + }, + }, + res: res{ + want: &domain.ObjectDetails{ + ResourceOwner: "INSTANCE", + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + r := &Commands{ + eventstore: tt.fields.eventstore(t), + } + err := r.ChangeSMSConfigHTTP(tt.args.ctx, tt.args.http) + 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.want, tt.args.http.Details) + } + }) + } +} + +func TestCommandSide_ActivateSMSConfig(t *testing.T) { + type fields struct { + eventstore func(*testing.T) *eventstore.Eventstore } type args struct { ctx context.Context @@ -279,24 +566,38 @@ func TestCommandSide_ActivateSMSConfigTwilio(t *testing.T) { res res }{ { - name: "id empty, invalid error", + name: "resourceowner empty, invalid error", fields: fields{ - eventstore: eventstoreExpect( - t, - ), + eventstore: expectEventstore(), }, args: args{ ctx: context.Background(), }, res: res{ - err: zerrors.IsErrorInvalidArgument, + err: func(err error) bool { + return errors.Is(err, zerrors.ThrowInvalidArgument(nil, "COMMAND-EFgoOg997V", "Errors.ResourceOwnerMissing")) + }, + }, + }, + { + name: "id empty, invalid error", + fields: fields{ + eventstore: expectEventstore(), + }, + args: args{ + ctx: context.Background(), + instanceID: "INSTANCE", + }, + res: res{ + err: func(err error) bool { + return errors.Is(err, zerrors.ThrowInvalidArgument(nil, "COMMAND-jJ6TVqzvjp", "Errors.IDMissing")) + }, }, }, { name: "sms not existing, not found error", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter(), ), }, @@ -306,20 +607,59 @@ func TestCommandSide_ActivateSMSConfigTwilio(t *testing.T) { id: "id", }, res: res{ - err: zerrors.IsNotFound, + err: func(err error) bool { + return errors.Is(err, zerrors.ThrowNotFound(nil, "COMMAND-9ULtp9PH5E", "Errors.SMSConfig.NotFound")) + }, }, }, { - name: "sms config twilio activate, ok", + name: "sms existing, already active", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher( instance.NewSMSConfigTwilioAddedEvent( context.Background(), &instance.NewAggregate("INSTANCE").Aggregate, "providerid", + "description", + "sid", + "sender-name", + &crypto.CryptoValue{}, + ), + ), + eventFromEventPusher( + instance.NewSMSConfigActivatedEvent( + context.Background(), + &instance.NewAggregate("INSTANCE").Aggregate, + "providerid", + ), + ), + ), + ), + }, + args: args{ + ctx: context.Background(), + instanceID: "INSTANCE", + id: "providerid", + }, + res: res{ + err: func(err error) bool { + return errors.Is(err, zerrors.ThrowPreconditionFailed(nil, "COMMAND-B25GFeIvRi", "Errors.SMSConfig.AlreadyActive")) + }, + }, + }, + { + name: "sms config twilio activate, ok", + fields: fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + instance.NewSMSConfigTwilioAddedEvent( + context.Background(), + &instance.NewAggregate("INSTANCE").Aggregate, + "providerid", + "description", "sid", "sender-name", &crypto.CryptoValue{}, @@ -327,7 +667,42 @@ func TestCommandSide_ActivateSMSConfigTwilio(t *testing.T) { ), ), expectPush( - instance.NewSMSConfigTwilioActivatedEvent( + instance.NewSMSConfigActivatedEvent( + context.Background(), + &instance.NewAggregate("INSTANCE").Aggregate, + "providerid", + ), + ), + ), + }, + args: args{ + ctx: context.Background(), + instanceID: "INSTANCE", + id: "providerid", + }, + res: res{ + want: &domain.ObjectDetails{ + ResourceOwner: "INSTANCE", + }, + }, + }, + { + name: "sms config http activate, ok", + fields: fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + instance.NewSMSConfigHTTPAddedEvent( + context.Background(), + &instance.NewAggregate("INSTANCE").Aggregate, + "providerid", + "description", + "endpoint", + ), + ), + ), + expectPush( + instance.NewSMSConfigActivatedEvent( context.Background(), &instance.NewAggregate("INSTANCE").Aggregate, "providerid", @@ -350,7 +725,7 @@ func TestCommandSide_ActivateSMSConfigTwilio(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { r := &Commands{ - eventstore: tt.fields.eventstore, + eventstore: tt.fields.eventstore(t), } got, err := r.ActivateSMSConfig(tt.args.ctx, tt.args.instanceID, tt.args.id) if tt.res.err == nil { @@ -366,9 +741,9 @@ func TestCommandSide_ActivateSMSConfigTwilio(t *testing.T) { } } -func TestCommandSide_DeactivateSMSConfigTwilio(t *testing.T) { +func TestCommandSide_DeactivateSMSConfig(t *testing.T) { type fields struct { - eventstore *eventstore.Eventstore + eventstore func(*testing.T) *eventstore.Eventstore } type args struct { ctx context.Context @@ -384,26 +759,39 @@ func TestCommandSide_DeactivateSMSConfigTwilio(t *testing.T) { fields fields args args res res - }{ + }{{ + name: "resourceowner empty, invalid error", + fields: fields{ + eventstore: expectEventstore(), + }, + args: args{ + ctx: context.Background(), + }, + res: res{ + err: func(err error) bool { + return errors.Is(err, zerrors.ThrowInvalidArgument(nil, "COMMAND-V9NWOZj8Gi", "Errors.ResourceOwnerMissing")) + }, + }, + }, { name: "id empty, invalid error", fields: fields{ - eventstore: eventstoreExpect( - t, - ), + eventstore: expectEventstore(), }, args: args{ - ctx: context.Background(), + ctx: context.Background(), + instanceID: "INSTANCE", }, res: res{ - err: zerrors.IsErrorInvalidArgument, + err: func(err error) bool { + return errors.Is(err, zerrors.ThrowInvalidArgument(nil, "COMMAND-xs1ah1v1CL", "Errors.IDMissing")) + }, }, }, { name: "sms not existing, not found error", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter(), ), }, @@ -413,27 +801,115 @@ func TestCommandSide_DeactivateSMSConfigTwilio(t *testing.T) { id: "id", }, res: res{ - err: zerrors.IsNotFound, + err: func(err error) bool { + return errors.Is(err, zerrors.ThrowNotFound(nil, "COMMAND-La91dGNhbM", "Errors.SMSConfig.NotFound")) + }, }, }, { - name: "sms config twilio deactivate, ok", + name: "sms config twilio deactivate, already deactivated", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher( instance.NewSMSConfigTwilioAddedEvent( context.Background(), &instance.NewAggregate("INSTANCE").Aggregate, "providerid", + "description", "sid", "sender-name", &crypto.CryptoValue{}, ), ), eventFromEventPusher( - instance.NewSMSConfigTwilioActivatedEvent( + instance.NewSMSConfigActivatedEvent( + context.Background(), + &instance.NewAggregate("INSTANCE").Aggregate, + "providerid", + ), + ), + eventFromEventPusher( + instance.NewSMSConfigDeactivatedEvent( + context.Background(), + &instance.NewAggregate("INSTANCE").Aggregate, + "providerid", + ), + ), + ), + ), + }, + args: args{ + ctx: context.Background(), + instanceID: "INSTANCE", + id: "providerid", + }, + res: res{ + err: func(err error) bool { + return errors.Is(err, zerrors.ThrowPreconditionFailed(nil, "COMMAND-OSZAEkYvk7", "Errors.SMSConfig.AlreadyDeactivated")) + }, + }, + }, + { + name: "sms config twilio deactivate, ok", + fields: fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + instance.NewSMSConfigTwilioAddedEvent( + context.Background(), + &instance.NewAggregate("INSTANCE").Aggregate, + "providerid", + "description", + "sid", + "sender-name", + &crypto.CryptoValue{}, + ), + ), + eventFromEventPusher( + instance.NewSMSConfigActivatedEvent( + context.Background(), + &instance.NewAggregate("INSTANCE").Aggregate, + "providerid", + ), + ), + ), + expectPush( + instance.NewSMSConfigDeactivatedEvent( + context.Background(), + &instance.NewAggregate("INSTANCE").Aggregate, + "providerid", + ), + ), + ), + }, + args: args{ + ctx: context.Background(), + instanceID: "INSTANCE", + id: "providerid", + }, + res: res{ + want: &domain.ObjectDetails{ + ResourceOwner: "INSTANCE", + }, + }, + }, + { + name: "sms config http deactivate, ok", + fields: fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + instance.NewSMSConfigHTTPAddedEvent( + context.Background(), + &instance.NewAggregate("INSTANCE").Aggregate, + "providerid", + "description", + "endpoint", + ), + ), + eventFromEventPusher( + instance.NewSMSConfigActivatedEvent( context.Background(), &instance.NewAggregate("INSTANCE").Aggregate, "providerid", @@ -464,7 +940,7 @@ func TestCommandSide_DeactivateSMSConfigTwilio(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { r := &Commands{ - eventstore: tt.fields.eventstore, + eventstore: tt.fields.eventstore(t), } got, err := r.DeactivateSMSConfig(tt.args.ctx, tt.args.instanceID, tt.args.id) if tt.res.err == nil { @@ -482,7 +958,7 @@ func TestCommandSide_DeactivateSMSConfigTwilio(t *testing.T) { func TestCommandSide_RemoveSMSConfig(t *testing.T) { type fields struct { - eventstore *eventstore.Eventstore + eventstore func(*testing.T) *eventstore.Eventstore } type args struct { ctx context.Context @@ -500,24 +976,38 @@ func TestCommandSide_RemoveSMSConfig(t *testing.T) { res res }{ { - name: "id empty, invalid error", + name: "resourceowner empty, invalid error", fields: fields{ - eventstore: eventstoreExpect( - t, - ), + eventstore: expectEventstore(), }, args: args{ ctx: context.Background(), }, res: res{ - err: zerrors.IsErrorInvalidArgument, + err: func(err error) bool { + return errors.Is(err, zerrors.ThrowInvalidArgument(nil, "COMMAND-cw0NSJsn1v", "Errors.ResourceOwnerMissing")) + }, + }, + }, + { + name: "id empty, invalid error", + fields: fields{ + eventstore: expectEventstore(), + }, + args: args{ + ctx: context.Background(), + instanceID: "INSTANCE", + }, + res: res{ + err: func(err error) bool { + return errors.Is(err, zerrors.ThrowInvalidArgument(nil, "COMMAND-Qrz7lvdC4c", "Errors.IDMissing")) + }, }, }, { name: "sms not existing, not found error", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter(), ), }, @@ -527,20 +1017,22 @@ func TestCommandSide_RemoveSMSConfig(t *testing.T) { id: "id", }, res: res{ - err: zerrors.IsNotFound, + err: func(err error) bool { + return errors.Is(err, zerrors.ThrowNotFound(nil, "COMMAND-povEVHPCkV", "Errors.SMSConfig.NotFound")) + }, }, }, { - name: "sms config remove, ok", + name: "sms config remove, twilio, ok", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher( instance.NewSMSConfigTwilioAddedEvent( context.Background(), &instance.NewAggregate("INSTANCE").Aggregate, "providerid", + "description", "sid", "sender-name", &crypto.CryptoValue{}, @@ -567,11 +1059,46 @@ func TestCommandSide_RemoveSMSConfig(t *testing.T) { }, }, }, + { + name: "sms config remove, http, ok", + fields: fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + instance.NewSMSConfigHTTPAddedEvent( + context.Background(), + &instance.NewAggregate("INSTANCE").Aggregate, + "providerid", + "description", + "endpoint", + ), + ), + ), + expectPush( + instance.NewSMSConfigRemovedEvent( + context.Background(), + &instance.NewAggregate("INSTANCE").Aggregate, + "providerid", + ), + ), + ), + }, + args: args{ + ctx: context.Background(), + instanceID: "INSTANCE", + id: "providerid", + }, + res: res{ + want: &domain.ObjectDetails{ + ResourceOwner: "INSTANCE", + }, + }, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { r := &Commands{ - eventstore: tt.fields.eventstore, + eventstore: tt.fields.eventstore(t), } got, err := r.RemoveSMSConfig(tt.args.ctx, tt.args.instanceID, tt.args.id) if tt.res.err == nil { @@ -587,10 +1114,11 @@ func TestCommandSide_RemoveSMSConfig(t *testing.T) { } } -func newSMSConfigTwilioChangedEvent(ctx context.Context, id, sid, senderName string) *instance.SMSConfigTwilioChangedEvent { +func newSMSConfigTwilioChangedEvent(ctx context.Context, id, sid, senderName, description string) *instance.SMSConfigTwilioChangedEvent { changes := []instance.SMSConfigTwilioChanges{ instance.ChangeSMSConfigTwilioSID(sid), instance.ChangeSMSConfigTwilioSenderNumber(senderName), + instance.ChangeSMSConfigTwilioDescription(description), } event, _ := instance.NewSMSConfigTwilioChangedEvent(ctx, &instance.NewAggregate("INSTANCE").Aggregate, @@ -599,3 +1127,16 @@ func newSMSConfigTwilioChangedEvent(ctx context.Context, id, sid, senderName str ) return event } + +func newSMSConfigHTTPChangedEvent(ctx context.Context, id, endpoint, description string) *instance.SMSConfigHTTPChangedEvent { + changes := []instance.SMSConfigHTTPChanges{ + instance.ChangeSMSConfigHTTPEndpoint(endpoint), + instance.ChangeSMSConfigHTTPDescription(description), + } + event, _ := instance.NewSMSConfigHTTPChangedEvent(ctx, + &instance.NewAggregate("INSTANCE").Aggregate, + id, + changes, + ) + return event +} diff --git a/internal/command/smtp_test.go b/internal/command/smtp_test.go index b6bb7d98a7..17f7f088d0 100644 --- a/internal/command/smtp_test.go +++ b/internal/command/smtp_test.go @@ -953,6 +953,49 @@ func TestCommandSide_ActivateSMTPConfig(t *testing.T) { }, }, }, + { + name: "activate smtp config, already active, ok", + fields: fields{ + eventstore: eventstoreExpect( + t, + expectFilter( + eventFromEventPusher( + instance.NewSMTPConfigAddedEvent( + context.Background(), + &instance.NewAggregate("INSTANCE").Aggregate, + "ID", + "test", + true, + "from", + "name", + "", + "host:587", + "user", + &crypto.CryptoValue{}, + ), + ), + ), + expectPush( + instance.NewSMTPConfigActivatedEvent( + context.Background(), + &instance.NewAggregate("INSTANCE").Aggregate, + "ID", + ), + ), + ), + }, + args: args{ + ctx: authz.WithInstanceID(context.Background(), "INSTANCE"), + id: "ID", + instanceID: "INSTANCE", + activatedId: "", + }, + res: res{ + want: &domain.ObjectDetails{ + ResourceOwner: "INSTANCE", + }, + }, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { diff --git a/internal/notification/channels.go b/internal/notification/channels.go index 68ad673472..0511aaf78b 100644 --- a/internal/notification/channels.go +++ b/internal/notification/channels.go @@ -5,8 +5,8 @@ import ( "github.com/zitadel/logging" + "github.com/zitadel/zitadel/internal/notification/channels/sms" "github.com/zitadel/zitadel/internal/notification/channels/smtp" - "github.com/zitadel/zitadel/internal/notification/channels/twilio" "github.com/zitadel/zitadel/internal/notification/channels/webhook" "github.com/zitadel/zitadel/internal/notification/handlers" "github.com/zitadel/zitadel/internal/notification/senders" @@ -78,20 +78,20 @@ func (c *channels) Email(ctx context.Context) (*senders.Chain, *smtp.Config, err return chain, smtpCfg, err } -func (c *channels) SMS(ctx context.Context) (*senders.Chain, *twilio.Config, error) { - twilioCfg, err := c.q.GetTwilioConfig(ctx) +func (c *channels) SMS(ctx context.Context) (*senders.Chain, *sms.Config, error) { + smsCfg, err := c.q.GetActiveSMSConfig(ctx) if err != nil { return nil, nil, err } chain, err := senders.SMSChannels( ctx, - twilioCfg, + smsCfg, c.q.GetFileSystemProvider, c.q.GetLogProvider, c.counters.success.sms, c.counters.failed.sms, ) - return chain, twilioCfg, err + return chain, smsCfg, err } func (c *channels) Webhook(ctx context.Context, cfg webhook.Config) (*senders.Chain, error) { diff --git a/internal/notification/channels/sms/config.go b/internal/notification/channels/sms/config.go new file mode 100644 index 0000000000..d759272084 --- /dev/null +++ b/internal/notification/channels/sms/config.go @@ -0,0 +1,17 @@ +package sms + +import ( + "github.com/zitadel/zitadel/internal/notification/channels/twilio" + "github.com/zitadel/zitadel/internal/notification/channels/webhook" +) + +type Config struct { + ProviderConfig *Provider + TwilioConfig *twilio.Config + WebhookConfig *webhook.Config +} + +type Provider struct { + ID string `json:"id,omitempty"` + Description string `json:"description,omitempty"` +} diff --git a/internal/notification/handlers/config_sms.go b/internal/notification/handlers/config_sms.go new file mode 100644 index 0000000000..4698772eae --- /dev/null +++ b/internal/notification/handlers/config_sms.go @@ -0,0 +1,52 @@ +package handlers + +import ( + "context" + "net/http" + + "github.com/zitadel/zitadel/internal/api/authz" + "github.com/zitadel/zitadel/internal/crypto" + "github.com/zitadel/zitadel/internal/notification/channels/sms" + "github.com/zitadel/zitadel/internal/notification/channels/twilio" + "github.com/zitadel/zitadel/internal/notification/channels/webhook" + "github.com/zitadel/zitadel/internal/zerrors" +) + +// GetActiveSMSConfig reads the active iam sms provider config +func (n *NotificationQueries) GetActiveSMSConfig(ctx context.Context) (*sms.Config, error) { + config, err := n.SMSProviderConfigActive(ctx, authz.GetInstance(ctx).InstanceID()) + if err != nil { + return nil, err + } + + provider := &sms.Provider{ + ID: config.ID, + Description: config.Description, + } + if config.TwilioConfig != nil { + token, err := crypto.DecryptString(config.TwilioConfig.Token, n.SMSTokenCrypto) + if err != nil { + return nil, err + } + return &sms.Config{ + ProviderConfig: provider, + TwilioConfig: &twilio.Config{ + SID: config.TwilioConfig.SID, + Token: token, + SenderNumber: config.TwilioConfig.SenderNumber, + }, + }, nil + } + if config.HTTPConfig != nil { + return &sms.Config{ + ProviderConfig: provider, + WebhookConfig: &webhook.Config{ + CallURL: config.HTTPConfig.Endpoint, + Method: http.MethodPost, + Headers: nil, + }, + }, nil + } + + return nil, zerrors.ThrowNotFound(nil, "HANDLER-8nfow", "Errors.SMS.Twilio.NotFound") +} diff --git a/internal/notification/handlers/config_twilio.go b/internal/notification/handlers/config_twilio.go deleted file mode 100644 index 5391553690..0000000000 --- a/internal/notification/handlers/config_twilio.go +++ /dev/null @@ -1,35 +0,0 @@ -package handlers - -import ( - "context" - - "github.com/zitadel/zitadel/internal/crypto" - "github.com/zitadel/zitadel/internal/domain" - "github.com/zitadel/zitadel/internal/notification/channels/twilio" - "github.com/zitadel/zitadel/internal/query" - "github.com/zitadel/zitadel/internal/zerrors" -) - -// GetTwilioConfig reads the iam Twilio provider config -func (n *NotificationQueries) GetTwilioConfig(ctx context.Context) (*twilio.Config, error) { - active, err := query.NewSMSProviderStateQuery(domain.SMSConfigStateActive) - if err != nil { - return nil, err - } - config, err := n.SMSProviderConfig(ctx, active) - if err != nil { - return nil, err - } - if config.TwilioConfig == nil { - return nil, zerrors.ThrowNotFound(nil, "HANDLER-8nfow", "Errors.SMS.Twilio.NotFound") - } - token, err := crypto.DecryptString(config.TwilioConfig.Token, n.SMSTokenCrypto) - if err != nil { - return nil, err - } - return &twilio.Config{ - SID: config.TwilioConfig.SID, - Token: token, - SenderNumber: config.TwilioConfig.SenderNumber, - }, nil -} diff --git a/internal/notification/handlers/mock/queries.mock.go b/internal/notification/handlers/mock/queries.mock.go index 210493d875..48d7ec21ec 100644 --- a/internal/notification/handlers/mock/queries.mock.go +++ b/internal/notification/handlers/mock/queries.mock.go @@ -161,24 +161,19 @@ func (mr *MockQueriesMockRecorder) NotificationProviderByIDAndType(arg0, arg1, a return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "NotificationProviderByIDAndType", reflect.TypeOf((*MockQueries)(nil).NotificationProviderByIDAndType), arg0, arg1, arg2) } -// SMSProviderConfig mocks base method. -func (m *MockQueries) SMSProviderConfig(arg0 context.Context, arg1 ...query.SearchQuery) (*query.SMSConfig, error) { +// SMSProviderConfigActive mocks base method. +func (m *MockQueries) SMSProviderConfigActive(arg0 context.Context, arg1 string) (*query.SMSConfig, error) { m.ctrl.T.Helper() - varargs := []any{arg0} - for _, a := range arg1 { - varargs = append(varargs, a) - } - ret := m.ctrl.Call(m, "SMSProviderConfig", varargs...) + ret := m.ctrl.Call(m, "SMSProviderConfigActive", arg0, arg1) ret0, _ := ret[0].(*query.SMSConfig) ret1, _ := ret[1].(error) return ret0, ret1 } -// SMSProviderConfig indicates an expected call of SMSProviderConfig. -func (mr *MockQueriesMockRecorder) SMSProviderConfig(arg0 any, arg1 ...any) *gomock.Call { +// SMSProviderConfigActive indicates an expected call of SMSProviderConfigActive. +func (mr *MockQueriesMockRecorder) SMSProviderConfigActive(arg0, arg1 any) *gomock.Call { mr.mock.ctrl.T.Helper() - varargs := append([]any{arg0}, arg1...) - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SMSProviderConfig", reflect.TypeOf((*MockQueries)(nil).SMSProviderConfig), varargs...) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SMSProviderConfigActive", reflect.TypeOf((*MockQueries)(nil).SMSProviderConfigActive), arg0, arg1) } // SMTPConfigActive mocks base method. diff --git a/internal/notification/handlers/queries.go b/internal/notification/handlers/queries.go index ce7597ead8..49cffc5e49 100644 --- a/internal/notification/handlers/queries.go +++ b/internal/notification/handlers/queries.go @@ -21,7 +21,7 @@ type Queries interface { NotificationPolicyByOrg(ctx context.Context, shouldTriggerBulk bool, orgID string, withOwnerRemoved bool) (*query.NotificationPolicy, error) SearchMilestones(ctx context.Context, instanceIDs []string, queries *query.MilestonesSearchQueries) (*query.Milestones, error) NotificationProviderByIDAndType(ctx context.Context, aggID string, providerType domain.NotificationProviderType) (*query.DebugNotificationProvider, error) - SMSProviderConfig(ctx context.Context, queries ...query.SearchQuery) (*query.SMSConfig, error) + SMSProviderConfigActive(ctx context.Context, resourceOwner string) (config *query.SMSConfig, err error) SMTPConfigActive(ctx context.Context, resourceOwner string) (*query.SMTPConfig, error) GetDefaultLanguage(ctx context.Context) language.Tag GetInstanceRestrictions(ctx context.Context) (restrictions query.Restrictions, err error) diff --git a/internal/notification/handlers/user_notifier.go b/internal/notification/handlers/user_notifier.go index 066796ae3b..1826e9d79c 100644 --- a/internal/notification/handlers/user_notifier.go +++ b/internal/notification/handlers/user_notifier.go @@ -283,7 +283,7 @@ func (u *userNotifier) reducePasswordCodeAdded(event eventstore.Event) (*handler } notify := types.SendEmail(ctx, u.channels, string(template.Template), translator, notifyUser, colors, e) if e.NotificationType == domain.NotificationTypeSms { - notify = types.SendSMSTwilio(ctx, u.channels, translator, notifyUser, colors, e) + notify = types.SendSMS(ctx, u.channels, translator, notifyUser, colors, e) } err = notify.SendPasswordCode(ctx, notifyUser, code, e.URLTemplate, e.AuthRequestID) if err != nil { @@ -373,7 +373,7 @@ func (u *userNotifier) reduceOTPSMS( if err != nil { return nil, err } - notify := types.SendSMSTwilio(ctx, u.channels, translator, notifyUser, colors, event) + notify := types.SendSMS(ctx, u.channels, translator, notifyUser, colors, event) err = notify.SendOTPSMSCode(ctx, plainCode, expiry) if err != nil { return nil, err @@ -709,7 +709,7 @@ func (u *userNotifier) reducePhoneCodeAdded(event eventstore.Event) (*handler.St if err != nil { return err } - err = types.SendSMSTwilio(ctx, u.channels, translator, notifyUser, colors, e). + err = types.SendSMS(ctx, u.channels, translator, notifyUser, colors, e). SendPhoneVerificationCode(ctx, code) if err != nil { return err diff --git a/internal/notification/handlers/user_notifier_test.go b/internal/notification/handlers/user_notifier_test.go index 8b2155cc27..60fa1791dd 100644 --- a/internal/notification/handlers/user_notifier_test.go +++ b/internal/notification/handlers/user_notifier_test.go @@ -16,8 +16,8 @@ import ( "github.com/zitadel/zitadel/internal/eventstore/repository" es_repo_mock "github.com/zitadel/zitadel/internal/eventstore/repository/mock" channel_mock "github.com/zitadel/zitadel/internal/notification/channels/mock" + "github.com/zitadel/zitadel/internal/notification/channels/sms" "github.com/zitadel/zitadel/internal/notification/channels/smtp" - "github.com/zitadel/zitadel/internal/notification/channels/twilio" "github.com/zitadel/zitadel/internal/notification/channels/webhook" "github.com/zitadel/zitadel/internal/notification/handlers/mock" "github.com/zitadel/zitadel/internal/notification/messages" @@ -1463,7 +1463,7 @@ func (c *channels) Email(context.Context) (*senders.Chain, *smtp.Config, error) return &c.Chain, nil, nil } -func (c *channels) SMS(context.Context) (*senders.Chain, *twilio.Config, error) { +func (c *channels) SMS(context.Context) (*senders.Chain, *sms.Config, error) { return &c.Chain, nil, nil } diff --git a/internal/notification/senders/sms.go b/internal/notification/senders/sms.go index 361fc56509..7ce2adc71c 100644 --- a/internal/notification/senders/sms.go +++ b/internal/notification/senders/sms.go @@ -3,36 +3,60 @@ package senders import ( "context" + "github.com/zitadel/logging" + + "github.com/zitadel/zitadel/internal/api/authz" "github.com/zitadel/zitadel/internal/notification/channels" "github.com/zitadel/zitadel/internal/notification/channels/fs" "github.com/zitadel/zitadel/internal/notification/channels/instrumenting" "github.com/zitadel/zitadel/internal/notification/channels/log" + "github.com/zitadel/zitadel/internal/notification/channels/sms" "github.com/zitadel/zitadel/internal/notification/channels/twilio" + "github.com/zitadel/zitadel/internal/notification/channels/webhook" ) const twilioSpanName = "twilio.NotificationChannel" func SMSChannels( ctx context.Context, - twilioConfig *twilio.Config, + smsConfig *sms.Config, getFileSystemProvider func(ctx context.Context) (*fs.Config, error), getLogProvider func(ctx context.Context) (*log.Config, error), successMetricName, failureMetricName string, ) (chain *Chain, err error) { channels := make([]channels.NotificationChannel, 0, 3) - if twilioConfig != nil { + if smsConfig.TwilioConfig != nil { channels = append( channels, instrumenting.Wrap( ctx, - twilio.InitChannel(*twilioConfig), + twilio.InitChannel(*smsConfig.TwilioConfig), twilioSpanName, successMetricName, failureMetricName, ), ) } + if smsConfig.WebhookConfig != nil { + webhookChannel, err := webhook.InitChannel(ctx, *smsConfig.WebhookConfig) + logging.WithFields( + "instance", authz.GetInstance(ctx).InstanceID(), + "callurl", smsConfig.WebhookConfig.CallURL, + ).OnError(err).Debug("initializing JSON channel failed") + if err == nil { + channels = append( + channels, + instrumenting.Wrap( + ctx, + webhookChannel, + webhookSpanName, + successMetricName, + failureMetricName, + ), + ) + } + } channels = append(channels, debugChannels(ctx, getFileSystemProvider, getLogProvider)...) return ChainChannels(channels...), nil } diff --git a/internal/notification/templates/templateData.go b/internal/notification/templates/templateData.go index 8ff750da53..2c3885ef75 100644 --- a/internal/notification/templates/templateData.go +++ b/internal/notification/templates/templateData.go @@ -15,23 +15,23 @@ const ( ) type TemplateData struct { - Title string - PreHeader string - Subject string - Greeting string - Text string - URL string - ButtonText string - PrimaryColor string - BackgroundColor string - FontColor string - LogoURL string - FontURL string - FontFaceFamily string - FontFamily string + Title string `json:"title,omitempty"` + PreHeader string `json:"preHeader,omitempty"` + Subject string `json:"subject,omitempty"` + Greeting string `json:"greeting,omitempty"` + Text string `json:"text,omitempty"` + URL string `json:"url,omitempty"` + ButtonText string `json:"buttonText,omitempty"` + PrimaryColor string `json:"primaryColor,omitempty"` + BackgroundColor string `json:"backgroundColor,omitempty"` + FontColor string `json:"fontColor,omitempty"` + LogoURL string `json:"logoUrl,omitempty"` + FontURL string `json:"fontUrl,omitempty"` + FontFaceFamily string `json:"fontFaceFamily,omitempty"` + FontFamily string `json:"fontFamily,omitempty"` - IncludeFooter bool - FooterText string + IncludeFooter bool `json:"includeFooter,omitempty"` + FooterText string `json:"footerText,omitempty"` } func (data *TemplateData) Translate(translator *i18n.Translator, msgType string, args map[string]interface{}, langs ...string) { diff --git a/internal/notification/types/notification.go b/internal/notification/types/notification.go index 9bbfb66c8f..4d72d911f9 100644 --- a/internal/notification/types/notification.go +++ b/internal/notification/types/notification.go @@ -7,8 +7,8 @@ import ( "github.com/zitadel/zitadel/internal/database" "github.com/zitadel/zitadel/internal/eventstore" "github.com/zitadel/zitadel/internal/i18n" + "github.com/zitadel/zitadel/internal/notification/channels/sms" "github.com/zitadel/zitadel/internal/notification/channels/smtp" - "github.com/zitadel/zitadel/internal/notification/channels/twilio" "github.com/zitadel/zitadel/internal/notification/channels/webhook" "github.com/zitadel/zitadel/internal/notification/senders" "github.com/zitadel/zitadel/internal/notification/templates" @@ -24,7 +24,7 @@ type Notify func( type ChannelChains interface { Email(context.Context) (*senders.Chain, *smtp.Config, error) - SMS(context.Context) (*senders.Chain, *twilio.Config, error) + SMS(context.Context) (*senders.Chain, *sms.Config, error) Webhook(context.Context, webhook.Config) (*senders.Chain, error) } @@ -79,7 +79,7 @@ func sanitizeArgsForHTML(args map[string]any) { } } -func SendSMSTwilio( +func SendSMS( ctx context.Context, channels ChannelChains, translator *i18n.Translator, @@ -99,7 +99,8 @@ func SendSMSTwilio( ctx, channels, user, - data.Text, + data, + args, allowUnverifiedNotificationChannel, triggeringEvent, ) diff --git a/internal/notification/types/user_phone.go b/internal/notification/types/user_phone.go index c9adfeac83..8e79f73718 100644 --- a/internal/notification/types/user_phone.go +++ b/internal/notification/types/user_phone.go @@ -2,40 +2,78 @@ package types import ( "context" + "strings" "github.com/zitadel/logging" "github.com/zitadel/zitadel/internal/eventstore" "github.com/zitadel/zitadel/internal/notification/messages" + "github.com/zitadel/zitadel/internal/notification/templates" "github.com/zitadel/zitadel/internal/query" "github.com/zitadel/zitadel/internal/zerrors" ) +type serializableData struct { + ContextInfo map[string]interface{} `json:"contextInfo,omitempty"` + TemplateData templates.TemplateData `json:"templateData,omitempty"` + Args map[string]interface{} `json:"args,omitempty"` +} + func generateSms( ctx context.Context, channels ChannelChains, user *query.NotifyUser, - content string, + data templates.TemplateData, + args map[string]interface{}, lastPhone bool, triggeringEvent eventstore.Event, ) error { - number := "" - smsChannels, twilioConfig, err := channels.SMS(ctx) + smsChannels, config, err := channels.SMS(ctx) logging.OnError(err).Error("could not create sms channel") if smsChannels == nil || smsChannels.Len() == 0 { return zerrors.ThrowPreconditionFailed(nil, "PHONE-w8nfow", "Errors.Notification.Channels.NotPresent") } - if err == nil { - number = twilioConfig.SenderNumber - } - message := &messages.SMS{ - SenderPhoneNumber: number, - RecipientPhoneNumber: user.VerifiedPhone, - Content: content, - TriggeringEvent: triggeringEvent, - } + recipient := user.VerifiedPhone if lastPhone { - message.RecipientPhoneNumber = user.LastPhone + recipient = user.LastPhone } - return smsChannels.HandleMessage(message) + if config.TwilioConfig != nil { + number := "" + if err == nil { + number = config.TwilioConfig.SenderNumber + } + message := &messages.SMS{ + SenderPhoneNumber: number, + RecipientPhoneNumber: recipient, + Content: data.Text, + TriggeringEvent: triggeringEvent, + } + return smsChannels.HandleMessage(message) + } + if config.WebhookConfig != nil { + caseArgs := make(map[string]interface{}, len(args)) + for k, v := range args { + caseArgs[strings.ToLower(string(k[0]))+k[1:]] = v + } + contextInfo := map[string]interface{}{ + "recipientPhoneNumber": recipient, + "eventType": triggeringEvent.Type(), + "provider": config.ProviderConfig, + } + + message := &messages.JSON{ + Serializable: &serializableData{ + TemplateData: data, + Args: caseArgs, + ContextInfo: contextInfo, + }, + TriggeringEvent: triggeringEvent, + } + webhookChannels, err := channels.Webhook(ctx, *config.WebhookConfig) + if err != nil { + return err + } + return webhookChannels.HandleMessage(message) + } + return zerrors.ThrowPreconditionFailed(nil, "PHONE-w8nfow", "Errors.Notification.Channels.NotPresent") } diff --git a/internal/query/projection/sms.go b/internal/query/projection/sms.go index 13059203f9..9b157ff992 100644 --- a/internal/query/projection/sms.go +++ b/internal/query/projection/sms.go @@ -8,12 +8,12 @@ import ( old_handler "github.com/zitadel/zitadel/internal/eventstore/handler" "github.com/zitadel/zitadel/internal/eventstore/handler/v2" "github.com/zitadel/zitadel/internal/repository/instance" - "github.com/zitadel/zitadel/internal/zerrors" ) const ( - SMSConfigProjectionTable = "projections.sms_configs2" + SMSConfigProjectionTable = "projections.sms_configs3" SMSTwilioTable = SMSConfigProjectionTable + "_" + smsTwilioTableSuffix + SMSHTTPTable = SMSConfigProjectionTable + "_" + smsHTTPTableSuffix SMSColumnID = "id" SMSColumnAggregateID = "aggregate_id" @@ -23,13 +23,19 @@ const ( SMSColumnState = "state" SMSColumnResourceOwner = "resource_owner" SMSColumnInstanceID = "instance_id" + SMSColumnDescription = "description" - smsTwilioTableSuffix = "twilio" - SMSTwilioConfigColumnSMSID = "sms_id" - SMSTwilioColumnInstanceID = "instance_id" - SMSTwilioConfigColumnSID = "sid" - SMSTwilioConfigColumnSenderNumber = "sender_number" - SMSTwilioConfigColumnToken = "token" + smsTwilioTableSuffix = "twilio" + SMSTwilioColumnSMSID = "sms_id" + SMSTwilioColumnInstanceID = "instance_id" + SMSTwilioColumnSID = "sid" + SMSTwilioColumnSenderNumber = "sender_number" + SMSTwilioColumnToken = "token" + + smsHTTPTableSuffix = "http" + SMSHTTPColumnSMSID = "sms_id" + SMSHTTPColumnInstanceID = "instance_id" + SMSHTTPColumnEndpoint = "endpoint" ) type smsConfigProjection struct{} @@ -53,20 +59,30 @@ func (*smsConfigProjection) Init() *old_handler.Check { handler.NewColumn(SMSColumnState, handler.ColumnTypeEnum), handler.NewColumn(SMSColumnResourceOwner, handler.ColumnTypeText), handler.NewColumn(SMSColumnInstanceID, handler.ColumnTypeText), + handler.NewColumn(SMSColumnDescription, handler.ColumnTypeText), }, handler.NewPrimaryKey(SMSColumnInstanceID, SMSColumnID), ), handler.NewSuffixedTable([]*handler.InitColumn{ - handler.NewColumn(SMSTwilioConfigColumnSMSID, handler.ColumnTypeText), + handler.NewColumn(SMSTwilioColumnSMSID, handler.ColumnTypeText), handler.NewColumn(SMSTwilioColumnInstanceID, handler.ColumnTypeText), - handler.NewColumn(SMSTwilioConfigColumnSID, handler.ColumnTypeText), - handler.NewColumn(SMSTwilioConfigColumnSenderNumber, handler.ColumnTypeText), - handler.NewColumn(SMSTwilioConfigColumnToken, handler.ColumnTypeJSONB), + handler.NewColumn(SMSTwilioColumnSID, handler.ColumnTypeText), + handler.NewColumn(SMSTwilioColumnSenderNumber, handler.ColumnTypeText), + handler.NewColumn(SMSTwilioColumnToken, handler.ColumnTypeJSONB), }, - handler.NewPrimaryKey(SMSTwilioColumnInstanceID, SMSTwilioConfigColumnSMSID), + handler.NewPrimaryKey(SMSTwilioColumnInstanceID, SMSTwilioColumnSMSID), smsTwilioTableSuffix, handler.WithForeignKey(handler.NewForeignKeyOfPublicKeys()), ), + handler.NewSuffixedTable([]*handler.InitColumn{ + handler.NewColumn(SMSHTTPColumnSMSID, handler.ColumnTypeText), + handler.NewColumn(SMSHTTPColumnInstanceID, handler.ColumnTypeText), + handler.NewColumn(SMSHTTPColumnEndpoint, handler.ColumnTypeText), + }, + handler.NewPrimaryKey(SMSHTTPColumnInstanceID, SMSHTTPColumnSMSID), + smsHTTPTableSuffix, + handler.WithForeignKey(handler.NewForeignKeyOfPublicKeys()), + ), ) } @@ -87,6 +103,26 @@ func (p *smsConfigProjection) Reducers() []handler.AggregateReducer { Event: instance.SMSConfigTwilioTokenChangedEventType, Reduce: p.reduceSMSConfigTwilioTokenChanged, }, + { + Event: instance.SMSConfigHTTPAddedEventType, + Reduce: p.reduceSMSConfigHTTPAdded, + }, + { + Event: instance.SMSConfigHTTPChangedEventType, + Reduce: p.reduceSMSConfigHTTPChanged, + }, + { + Event: instance.SMSConfigTwilioActivatedEventType, + Reduce: p.reduceSMSConfigTwilioActivated, + }, + { + Event: instance.SMSConfigTwilioDeactivatedEventType, + Reduce: p.reduceSMSConfigTwilioDeactivated, + }, + { + Event: instance.SMSConfigTwilioRemovedEventType, + Reduce: p.reduceSMSConfigTwilioRemoved, + }, { Event: instance.SMSConfigActivatedEventType, Reduce: p.reduceSMSConfigActivated, @@ -109,9 +145,9 @@ func (p *smsConfigProjection) Reducers() []handler.AggregateReducer { } func (p *smsConfigProjection) reduceSMSConfigTwilioAdded(event eventstore.Event) (*handler.Statement, error) { - e, ok := event.(*instance.SMSConfigTwilioAddedEvent) - if !ok { - return nil, zerrors.ThrowInvalidArgumentf(nil, "HANDL-s8efs", "reduce.wrong.event.type %s", instance.SMSConfigTwilioAddedEventType) + e, err := assertEvent[*instance.SMSConfigTwilioAddedEvent](event) + if err != nil { + return nil, err } return handler.NewMultiStatement( @@ -126,15 +162,16 @@ func (p *smsConfigProjection) reduceSMSConfigTwilioAdded(event eventstore.Event) handler.NewCol(SMSColumnInstanceID, e.Aggregate().InstanceID), handler.NewCol(SMSColumnState, domain.SMSConfigStateInactive), handler.NewCol(SMSColumnSequence, e.Sequence()), + handler.NewCol(SMSColumnDescription, e.Description), }, ), handler.AddCreateStatement( []handler.Column{ - handler.NewCol(SMSTwilioConfigColumnSMSID, e.ID), + handler.NewCol(SMSTwilioColumnSMSID, e.ID), handler.NewCol(SMSTwilioColumnInstanceID, e.Aggregate().InstanceID), - handler.NewCol(SMSTwilioConfigColumnSID, e.SID), - handler.NewCol(SMSTwilioConfigColumnToken, e.Token), - handler.NewCol(SMSTwilioConfigColumnSenderNumber, e.SenderNumber), + handler.NewCol(SMSTwilioColumnSID, e.SID), + handler.NewCol(SMSTwilioColumnToken, e.Token), + handler.NewCol(SMSTwilioColumnSenderNumber, e.SenderNumber), }, handler.WithTableSuffix(smsTwilioTableSuffix), ), @@ -142,57 +179,64 @@ func (p *smsConfigProjection) reduceSMSConfigTwilioAdded(event eventstore.Event) } func (p *smsConfigProjection) reduceSMSConfigTwilioChanged(event eventstore.Event) (*handler.Statement, error) { - e, ok := event.(*instance.SMSConfigTwilioChangedEvent) - if !ok { - return nil, zerrors.ThrowInvalidArgumentf(nil, "HANDL-fi99F", "reduce.wrong.event.type %s", instance.SMSConfigTwilioChangedEventType) - } - columns := make([]handler.Column, 0) - if e.SID != nil { - columns = append(columns, handler.NewCol(SMSTwilioConfigColumnSID, *e.SID)) - } - if e.SenderNumber != nil { - columns = append(columns, handler.NewCol(SMSTwilioConfigColumnSenderNumber, *e.SenderNumber)) + e, err := assertEvent[*instance.SMSConfigTwilioChangedEvent](event) + if err != nil { + return nil, err } - return handler.NewMultiStatement( - e, - handler.AddUpdateStatement( + stmts := make([]func(eventstore.Event) handler.Exec, 0, 3) + columns := []handler.Column{ + handler.NewCol(SMSColumnChangeDate, e.CreationDate()), + handler.NewCol(SMSColumnSequence, e.Sequence()), + } + if e.Description != nil { + columns = append(columns, handler.NewCol(SMSColumnDescription, *e.Description)) + } + if len(columns) > 0 { + stmts = append(stmts, handler.AddUpdateStatement( columns, - []handler.Condition{ - handler.NewCond(SMSTwilioConfigColumnSMSID, e.ID), - handler.NewCond(SMSTwilioColumnInstanceID, e.Aggregate().InstanceID), - }, - handler.WithTableSuffix(smsTwilioTableSuffix), - ), - handler.AddUpdateStatement( - []handler.Column{ - handler.NewCol(SMSColumnChangeDate, e.CreationDate()), - handler.NewCol(SMSColumnSequence, e.Sequence()), - }, []handler.Condition{ handler.NewCond(SMSColumnID, e.ID), handler.NewCond(SMSColumnInstanceID, e.Aggregate().InstanceID), }, - ), - ), nil + )) + } + + twilioColumns := make([]handler.Column, 0) + if e.SID != nil { + twilioColumns = append(twilioColumns, handler.NewCol(SMSTwilioColumnSID, *e.SID)) + } + if e.SenderNumber != nil { + twilioColumns = append(twilioColumns, handler.NewCol(SMSTwilioColumnSenderNumber, *e.SenderNumber)) + } + if len(twilioColumns) > 0 { + stmts = append(stmts, handler.AddUpdateStatement( + twilioColumns, + []handler.Condition{ + handler.NewCond(SMSTwilioColumnSMSID, e.ID), + handler.NewCond(SMSTwilioColumnInstanceID, e.Aggregate().InstanceID), + }, + handler.WithTableSuffix(smsTwilioTableSuffix), + )) + } + + return handler.NewMultiStatement(e, stmts...), nil } func (p *smsConfigProjection) reduceSMSConfigTwilioTokenChanged(event eventstore.Event) (*handler.Statement, error) { - e, ok := event.(*instance.SMSConfigTwilioTokenChangedEvent) - if !ok { - return nil, zerrors.ThrowInvalidArgumentf(nil, "HANDL-fi99F", "reduce.wrong.event.type %s", instance.SMSConfigTwilioTokenChangedEventType) - } - columns := make([]handler.Column, 0) - if e.Token != nil { - columns = append(columns, handler.NewCol(SMSTwilioConfigColumnToken, e.Token)) + e, err := assertEvent[*instance.SMSConfigTwilioTokenChangedEvent](event) + if err != nil { + return nil, err } return handler.NewMultiStatement( e, handler.AddUpdateStatement( - columns, + []handler.Column{ + handler.NewCol(SMSTwilioColumnToken, e.Token), + }, []handler.Condition{ - handler.NewCond(SMSTwilioConfigColumnSMSID, e.ID), + handler.NewCond(SMSTwilioColumnSMSID, e.ID), handler.NewCond(SMSTwilioColumnInstanceID, e.Aggregate().InstanceID), }, handler.WithTableSuffix(smsTwilioTableSuffix), @@ -210,15 +254,122 @@ func (p *smsConfigProjection) reduceSMSConfigTwilioTokenChanged(event eventstore ), nil } -func (p *smsConfigProjection) reduceSMSConfigActivated(event eventstore.Event) (*handler.Statement, error) { - e, ok := event.(*instance.SMSConfigActivatedEvent) - if !ok { - return nil, zerrors.ThrowInvalidArgumentf(nil, "HANDL-fj9Ef", "reduce.wrong.event.type %s", instance.SMSConfigActivatedEventType) +func (p *smsConfigProjection) reduceSMSConfigHTTPAdded(event eventstore.Event) (*handler.Statement, error) { + e, err := assertEvent[*instance.SMSConfigHTTPAddedEvent](event) + if err != nil { + return nil, err } + + return handler.NewMultiStatement( + e, + handler.AddCreateStatement( + []handler.Column{ + handler.NewCol(SMSColumnID, e.ID), + handler.NewCol(SMSColumnAggregateID, e.Aggregate().ID), + handler.NewCol(SMSColumnCreationDate, e.CreationDate()), + handler.NewCol(SMSColumnChangeDate, e.CreationDate()), + handler.NewCol(SMSColumnResourceOwner, e.Aggregate().ResourceOwner), + handler.NewCol(SMSColumnInstanceID, e.Aggregate().InstanceID), + handler.NewCol(SMSColumnState, domain.SMSConfigStateInactive), + handler.NewCol(SMSColumnSequence, e.Sequence()), + handler.NewCol(SMSColumnDescription, e.Description), + }, + ), + handler.AddCreateStatement( + []handler.Column{ + handler.NewCol(SMSHTTPColumnSMSID, e.ID), + handler.NewCol(SMSHTTPColumnInstanceID, e.Aggregate().InstanceID), + handler.NewCol(SMSHTTPColumnEndpoint, e.Endpoint), + }, + handler.WithTableSuffix(smsHTTPTableSuffix), + ), + ), nil +} + +func (p *smsConfigProjection) reduceSMSConfigHTTPChanged(event eventstore.Event) (*handler.Statement, error) { + e, err := assertEvent[*instance.SMSConfigHTTPChangedEvent](event) + if err != nil { + return nil, err + } + + stmts := make([]func(eventstore.Event) handler.Exec, 0, 3) + columns := []handler.Column{ + handler.NewCol(SMSColumnChangeDate, e.CreationDate()), + handler.NewCol(SMSColumnSequence, e.Sequence()), + } + if e.Description != nil { + columns = append(columns, handler.NewCol(SMSColumnDescription, *e.Description)) + } + if len(columns) > 0 { + stmts = append(stmts, handler.AddUpdateStatement( + columns, + []handler.Condition{ + handler.NewCond(SMSColumnID, e.ID), + handler.NewCond(SMSColumnInstanceID, e.Aggregate().InstanceID), + }, + )) + } + + if e.Endpoint != nil { + stmts = append(stmts, handler.AddUpdateStatement( + []handler.Column{ + handler.NewCol(SMSHTTPColumnEndpoint, *e.Endpoint), + }, + []handler.Condition{ + handler.NewCond(SMSHTTPColumnSMSID, e.ID), + handler.NewCond(SMSHTTPColumnInstanceID, e.Aggregate().InstanceID), + }, + handler.WithTableSuffix(smsHTTPTableSuffix), + )) + } + + return handler.NewMultiStatement(e, stmts...), nil +} + +func (p *smsConfigProjection) reduceSMSConfigTwilioActivated(event eventstore.Event) (*handler.Statement, error) { + e, err := assertEvent[*instance.SMSConfigTwilioActivatedEvent](event) + if err != nil { + return nil, err + } + + return handler.NewMultiStatement( + e, + handler.AddUpdateStatement( + []handler.Column{ + handler.NewCol(SMSColumnState, domain.SMSConfigStateInactive), + handler.NewCol(SMSColumnChangeDate, e.CreationDate()), + handler.NewCol(SMSColumnSequence, e.Sequence()), + }, + []handler.Condition{ + handler.Not(handler.NewCond(SMSColumnID, e.ID)), + handler.NewCond(SMSColumnState, domain.SMSConfigStateActive), + handler.NewCond(SMSColumnInstanceID, e.Aggregate().InstanceID), + }, + ), + handler.AddUpdateStatement( + []handler.Column{ + handler.NewCol(SMSColumnState, domain.SMSConfigStateActive), + handler.NewCol(SMSColumnChangeDate, e.CreationDate()), + handler.NewCol(SMSColumnSequence, e.Sequence()), + }, + []handler.Condition{ + handler.NewCond(SMSColumnID, e.ID), + handler.NewCond(SMSColumnInstanceID, e.Aggregate().InstanceID), + }, + ), + ), nil +} + +func (p *smsConfigProjection) reduceSMSConfigTwilioDeactivated(event eventstore.Event) (*handler.Statement, error) { + e, err := assertEvent[*instance.SMSConfigTwilioDeactivatedEvent](event) + if err != nil { + return nil, err + } + return handler.NewUpdateStatement( e, []handler.Column{ - handler.NewCol(SMSColumnState, domain.SMSConfigStateActive), + handler.NewCol(SMSColumnState, domain.SMSConfigStateInactive), handler.NewCol(SMSColumnChangeDate, e.CreationDate()), handler.NewCol(SMSColumnSequence, e.Sequence()), }, @@ -229,11 +380,61 @@ func (p *smsConfigProjection) reduceSMSConfigActivated(event eventstore.Event) ( ), nil } -func (p *smsConfigProjection) reduceSMSConfigDeactivated(event eventstore.Event) (*handler.Statement, error) { - e, ok := event.(*instance.SMSConfigDeactivatedEvent) - if !ok { - return nil, zerrors.ThrowInvalidArgumentf(nil, "HANDL-dj9Js", "reduce.wrong.event.type %s", instance.SMSConfigDeactivatedEventType) +func (p *smsConfigProjection) reduceSMSConfigTwilioRemoved(event eventstore.Event) (*handler.Statement, error) { + e, err := assertEvent[*instance.SMSConfigTwilioRemovedEvent](event) + if err != nil { + return nil, err } + + return handler.NewDeleteStatement( + e, + []handler.Condition{ + handler.NewCond(SMSColumnID, e.ID), + handler.NewCond(SMSColumnInstanceID, e.Aggregate().InstanceID), + }, + ), nil +} + +func (p *smsConfigProjection) reduceSMSConfigActivated(event eventstore.Event) (*handler.Statement, error) { + e, err := assertEvent[*instance.SMSConfigActivatedEvent](event) + if err != nil { + return nil, err + } + + return handler.NewMultiStatement( + e, + handler.AddUpdateStatement( + []handler.Column{ + handler.NewCol(SMSColumnState, domain.SMSConfigStateInactive), + handler.NewCol(SMSColumnChangeDate, e.CreationDate()), + handler.NewCol(SMSColumnSequence, e.Sequence()), + }, + []handler.Condition{ + handler.Not(handler.NewCond(SMSColumnID, e.ID)), + handler.NewCond(SMSColumnState, domain.SMSConfigStateActive), + handler.NewCond(SMSColumnInstanceID, e.Aggregate().InstanceID), + }, + ), + handler.AddUpdateStatement( + []handler.Column{ + handler.NewCol(SMSColumnState, domain.SMSConfigStateActive), + handler.NewCol(SMSColumnChangeDate, e.CreationDate()), + handler.NewCol(SMSColumnSequence, e.Sequence()), + }, + []handler.Condition{ + handler.NewCond(SMSColumnID, e.ID), + handler.NewCond(SMSColumnInstanceID, e.Aggregate().InstanceID), + }, + ), + ), nil +} + +func (p *smsConfigProjection) reduceSMSConfigDeactivated(event eventstore.Event) (*handler.Statement, error) { + e, err := assertEvent[*instance.SMSConfigDeactivatedEvent](event) + if err != nil { + return nil, err + } + return handler.NewUpdateStatement( e, []handler.Column{ @@ -249,10 +450,11 @@ func (p *smsConfigProjection) reduceSMSConfigDeactivated(event eventstore.Event) } func (p *smsConfigProjection) reduceSMSConfigRemoved(event eventstore.Event) (*handler.Statement, error) { - e, ok := event.(*instance.SMSConfigRemovedEvent) - if !ok { - return nil, zerrors.ThrowInvalidArgumentf(nil, "HANDL-s9JJf", "reduce.wrong.event.type %s", instance.SMSConfigRemovedEventType) + e, err := assertEvent[*instance.SMSConfigRemovedEvent](event) + if err != nil { + return nil, err } + return handler.NewDeleteStatement( e, []handler.Condition{ diff --git a/internal/query/projection/sms_test.go b/internal/query/projection/sms_test.go index 1e1a384ed2..88ce6e4417 100644 --- a/internal/query/projection/sms_test.go +++ b/internal/query/projection/sms_test.go @@ -37,9 +37,10 @@ func TestSMSProjection_reduces(t *testing.T) { "keyId": "key-id", "crypted": "Y3J5cHRlZA==" }, - "senderNumber": "sender-number" + "senderNumber": "sender-number", + "description": "description" }`), - ), instance.SMSConfigTwilioAddedEventMapper), + ), eventstore.GenericEventMapper[instance.SMSConfigTwilioAddedEvent]), }, reduce: (&smsConfigProjection{}).reduceSMSConfigTwilioAdded, want: wantReduce{ @@ -48,7 +49,7 @@ func TestSMSProjection_reduces(t *testing.T) { executer: &testExecuter{ executions: []execution{ { - expectedStmt: "INSERT INTO projections.sms_configs2 (id, aggregate_id, creation_date, change_date, resource_owner, instance_id, state, sequence) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)", + expectedStmt: "INSERT INTO projections.sms_configs3 (id, aggregate_id, creation_date, change_date, resource_owner, instance_id, state, sequence, description) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)", expectedArgs: []interface{}{ "id", "agg-id", @@ -58,10 +59,11 @@ func TestSMSProjection_reduces(t *testing.T) { "instance-id", domain.SMSConfigStateInactive, uint64(15), + "description", }, }, { - expectedStmt: "INSERT INTO projections.sms_configs2_twilio (sms_id, instance_id, sid, token, sender_number) VALUES ($1, $2, $3, $4, $5)", + expectedStmt: "INSERT INTO projections.sms_configs3_twilio (sms_id, instance_id, sid, token, sender_number) VALUES ($1, $2, $3, $4, $5)", expectedArgs: []interface{}{ "id", "instance-id", @@ -89,9 +91,10 @@ func TestSMSProjection_reduces(t *testing.T) { []byte(`{ "id": "id", "sid": "sid", - "senderNumber": "sender-number" + "senderNumber": "sender-number", + "description": "description" }`), - ), instance.SMSConfigTwilioChangedEventMapper), + ), eventstore.GenericEventMapper[instance.SMSConfigTwilioChangedEvent]), }, reduce: (&smsConfigProjection{}).reduceSMSConfigTwilioChanged, want: wantReduce{ @@ -100,7 +103,17 @@ func TestSMSProjection_reduces(t *testing.T) { executer: &testExecuter{ executions: []execution{ { - expectedStmt: "UPDATE projections.sms_configs2_twilio SET (sid, sender_number) = ($1, $2) WHERE (sms_id = $3) AND (instance_id = $4)", + expectedStmt: "UPDATE projections.sms_configs3 SET (change_date, sequence, description) = ($1, $2, $3) WHERE (id = $4) AND (instance_id = $5)", + expectedArgs: []interface{}{ + anyArg{}, + uint64(15), + "description", + "id", + "instance-id", + }, + }, + { + expectedStmt: "UPDATE projections.sms_configs3_twilio SET (sid, sender_number) = ($1, $2) WHERE (sms_id = $3) AND (instance_id = $4)", expectedArgs: []interface{}{ "sid", "sender-number", @@ -108,11 +121,75 @@ func TestSMSProjection_reduces(t *testing.T) { "instance-id", }, }, + }, + }, + }, + }, + { + name: "instance reduceSMSConfigTwilioChanged, only description", + args: args{ + event: getEvent( + testEvent( + instance.SMSConfigTwilioChangedEventType, + instance.AggregateType, + []byte(`{ + "id": "id", + "description": "description" + }`), + ), eventstore.GenericEventMapper[instance.SMSConfigTwilioChangedEvent]), + }, + reduce: (&smsConfigProjection{}).reduceSMSConfigTwilioChanged, + want: wantReduce{ + aggregateType: eventstore.AggregateType("instance"), + sequence: 15, + executer: &testExecuter{ + executions: []execution{ { - expectedStmt: "UPDATE projections.sms_configs2 SET (change_date, sequence) = ($1, $2) WHERE (id = $3) AND (instance_id = $4)", + expectedStmt: "UPDATE projections.sms_configs3 SET (change_date, sequence, description) = ($1, $2, $3) WHERE (id = $4) AND (instance_id = $5)", expectedArgs: []interface{}{ anyArg{}, uint64(15), + "description", + "id", + "instance-id", + }, + }, + }, + }, + }, + }, + { + name: "instance reduceSMSConfigTwilioChanged, only sid", + args: args{ + event: getEvent( + testEvent( + instance.SMSConfigTwilioChangedEventType, + instance.AggregateType, + []byte(`{ + "id": "id", + "sid": "sid" + }`), + ), eventstore.GenericEventMapper[instance.SMSConfigTwilioChangedEvent]), + }, + reduce: (&smsConfigProjection{}).reduceSMSConfigTwilioChanged, + want: wantReduce{ + aggregateType: eventstore.AggregateType("instance"), + sequence: 15, + executer: &testExecuter{ + executions: []execution{ + { + expectedStmt: "UPDATE projections.sms_configs3 SET (change_date, sequence) = ($1, $2) WHERE (id = $3) AND (instance_id = $4)", + expectedArgs: []interface{}{ + anyArg{}, + uint64(15), + "id", + "instance-id", + }, + }, + { + expectedStmt: "UPDATE projections.sms_configs3_twilio SET sid = $1 WHERE (sms_id = $2) AND (instance_id = $3)", + expectedArgs: []interface{}{ + "sid", "id", "instance-id", }, @@ -137,7 +214,7 @@ func TestSMSProjection_reduces(t *testing.T) { "crypted": "Y3J5cHRlZA==" } }`), - ), instance.SMSConfigTwilioTokenChangedEventMapper), + ), eventstore.GenericEventMapper[instance.SMSConfigTwilioTokenChangedEvent]), }, reduce: (&smsConfigProjection{}).reduceSMSConfigTwilioTokenChanged, want: wantReduce{ @@ -146,7 +223,7 @@ func TestSMSProjection_reduces(t *testing.T) { executer: &testExecuter{ executions: []execution{ { - expectedStmt: "UPDATE projections.sms_configs2_twilio SET token = $1 WHERE (sms_id = $2) AND (instance_id = $3)", + expectedStmt: "UPDATE projections.sms_configs3_twilio SET token = $1 WHERE (sms_id = $2) AND (instance_id = $3)", expectedArgs: []interface{}{ &crypto.CryptoValue{ CryptoType: crypto.TypeEncryption, @@ -159,7 +236,7 @@ func TestSMSProjection_reduces(t *testing.T) { }, }, { - expectedStmt: "UPDATE projections.sms_configs2 SET (change_date, sequence) = ($1, $2) WHERE (id = $3) AND (instance_id = $4)", + expectedStmt: "UPDATE projections.sms_configs3 SET (change_date, sequence) = ($1, $2) WHERE (id = $3) AND (instance_id = $4)", expectedArgs: []interface{}{ anyArg{}, uint64(15), @@ -171,6 +248,270 @@ func TestSMSProjection_reduces(t *testing.T) { }, }, }, + { + name: "instance reduceSMSHTTPAdded", + args: args{ + event: getEvent( + testEvent( + instance.SMSConfigHTTPAddedEventType, + instance.AggregateType, + []byte(`{ + "id": "id", + "description": "description", + "endpoint": "endpoint" + }`), + ), eventstore.GenericEventMapper[instance.SMSConfigHTTPAddedEvent]), + }, + reduce: (&smsConfigProjection{}).reduceSMSConfigHTTPAdded, + want: wantReduce{ + aggregateType: eventstore.AggregateType("instance"), + sequence: 15, + executer: &testExecuter{ + executions: []execution{ + { + expectedStmt: "INSERT INTO projections.sms_configs3 (id, aggregate_id, creation_date, change_date, resource_owner, instance_id, state, sequence, description) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)", + expectedArgs: []interface{}{ + "id", + "agg-id", + anyArg{}, + anyArg{}, + "ro-id", + "instance-id", + domain.SMSConfigStateInactive, + uint64(15), + "description", + }, + }, + { + expectedStmt: "INSERT INTO projections.sms_configs3_http (sms_id, instance_id, endpoint) VALUES ($1, $2, $3)", + expectedArgs: []interface{}{ + "id", + "instance-id", + "endpoint", + }, + }, + }, + }, + }, + }, + { + name: "instance reduceSMSConfigHTTPChanged", + args: args{ + event: getEvent( + testEvent( + instance.SMSConfigHTTPChangedEventType, + instance.AggregateType, + []byte(`{ + "id": "id", + "endpoint": "endpoint", + "description": "description" + }`), + ), eventstore.GenericEventMapper[instance.SMSConfigHTTPChangedEvent]), + }, + reduce: (&smsConfigProjection{}).reduceSMSConfigHTTPChanged, + want: wantReduce{ + aggregateType: eventstore.AggregateType("instance"), + sequence: 15, + executer: &testExecuter{ + executions: []execution{ + { + expectedStmt: "UPDATE projections.sms_configs3 SET (change_date, sequence, description) = ($1, $2, $3) WHERE (id = $4) AND (instance_id = $5)", + expectedArgs: []interface{}{ + anyArg{}, + uint64(15), + "description", + "id", + "instance-id", + }, + }, + { + expectedStmt: "UPDATE projections.sms_configs3_http SET endpoint = $1 WHERE (sms_id = $2) AND (instance_id = $3)", + expectedArgs: []interface{}{ + "endpoint", + "id", + "instance-id", + }, + }, + }, + }, + }, + }, + { + name: "instance reduceSMSConfigHTTPChanged, only description", + args: args{ + event: getEvent( + testEvent( + instance.SMSConfigHTTPChangedEventType, + instance.AggregateType, + []byte(`{ + "id": "id", + "description": "description" + }`), + ), eventstore.GenericEventMapper[instance.SMSConfigHTTPChangedEvent]), + }, + reduce: (&smsConfigProjection{}).reduceSMSConfigHTTPChanged, + want: wantReduce{ + aggregateType: eventstore.AggregateType("instance"), + sequence: 15, + executer: &testExecuter{ + executions: []execution{ + { + expectedStmt: "UPDATE projections.sms_configs3 SET (change_date, sequence, description) = ($1, $2, $3) WHERE (id = $4) AND (instance_id = $5)", + expectedArgs: []interface{}{ + anyArg{}, + uint64(15), + "description", + "id", + "instance-id", + }, + }, + }, + }, + }, + }, { + name: "instance reduceSMSConfigHTTPChanged, only endpoint", + args: args{ + event: getEvent( + testEvent( + instance.SMSConfigHTTPChangedEventType, + instance.AggregateType, + []byte(`{ + "id": "id", + "endpoint": "endpoint" + }`), + ), eventstore.GenericEventMapper[instance.SMSConfigHTTPChangedEvent]), + }, + reduce: (&smsConfigProjection{}).reduceSMSConfigHTTPChanged, + want: wantReduce{ + aggregateType: eventstore.AggregateType("instance"), + sequence: 15, + executer: &testExecuter{ + executions: []execution{ + { + expectedStmt: "UPDATE projections.sms_configs3 SET (change_date, sequence) = ($1, $2) WHERE (id = $3) AND (instance_id = $4)", + expectedArgs: []interface{}{ + anyArg{}, + uint64(15), + "id", + "instance-id", + }, + }, + { + expectedStmt: "UPDATE projections.sms_configs3_http SET endpoint = $1 WHERE (sms_id = $2) AND (instance_id = $3)", + expectedArgs: []interface{}{ + "endpoint", + "id", + "instance-id", + }, + }, + }, + }, + }, + }, + { + name: "instance reduceSMSConfigTwilioActivated", + args: args{ + event: getEvent( + testEvent( + instance.SMSConfigTwilioActivatedEventType, + instance.AggregateType, + []byte(`{ + "id": "id" + }`), + ), eventstore.GenericEventMapper[instance.SMSConfigTwilioActivatedEvent]), + }, + reduce: (&smsConfigProjection{}).reduceSMSConfigTwilioActivated, + want: wantReduce{ + aggregateType: eventstore.AggregateType("instance"), + sequence: 15, + executer: &testExecuter{ + executions: []execution{ + { + expectedStmt: "UPDATE projections.sms_configs3 SET (state, change_date, sequence) = ($1, $2, $3) WHERE (NOT (id = $4)) AND (state = $5) AND (instance_id = $6)", + expectedArgs: []interface{}{ + domain.SMSConfigStateInactive, + anyArg{}, + uint64(15), + "id", + domain.SMSConfigStateActive, + "instance-id", + }, + }, + { + expectedStmt: "UPDATE projections.sms_configs3 SET (state, change_date, sequence) = ($1, $2, $3) WHERE (id = $4) AND (instance_id = $5)", + expectedArgs: []interface{}{ + domain.SMSConfigStateActive, + anyArg{}, + uint64(15), + "id", + "instance-id", + }, + }, + }, + }, + }, + }, + { + name: "instance reduceSMSConfigTwilioDeactivated", + args: args{ + event: getEvent( + testEvent( + instance.SMSConfigTwilioDeactivatedEventType, + instance.AggregateType, + []byte(`{ + "id": "id" + }`), + ), eventstore.GenericEventMapper[instance.SMSConfigTwilioDeactivatedEvent]), + }, + reduce: (&smsConfigProjection{}).reduceSMSConfigTwilioDeactivated, + want: wantReduce{ + aggregateType: eventstore.AggregateType("instance"), + sequence: 15, + executer: &testExecuter{ + executions: []execution{ + { + expectedStmt: "UPDATE projections.sms_configs3 SET (state, change_date, sequence) = ($1, $2, $3) WHERE (id = $4) AND (instance_id = $5)", + expectedArgs: []interface{}{ + domain.SMSConfigStateInactive, + anyArg{}, + uint64(15), + "id", + "instance-id", + }, + }, + }, + }, + }, + }, + { + name: "instance reduceSMSConfigTwilioRemoved", + args: args{ + event: getEvent( + testEvent( + instance.SMSConfigTwilioRemovedEventType, + instance.AggregateType, + []byte(`{ + "id": "id" + }`), + ), eventstore.GenericEventMapper[instance.SMSConfigTwilioRemovedEvent]), + }, + reduce: (&smsConfigProjection{}).reduceSMSConfigTwilioRemoved, + want: wantReduce{ + aggregateType: eventstore.AggregateType("instance"), + sequence: 15, + executer: &testExecuter{ + executions: []execution{ + { + expectedStmt: "DELETE FROM projections.sms_configs3 WHERE (id = $1) AND (instance_id = $2)", + expectedArgs: []interface{}{ + "id", + "instance-id", + }, + }, + }, + }, + }, + }, { name: "instance reduceSMSConfigActivated", args: args{ @@ -181,7 +522,7 @@ func TestSMSProjection_reduces(t *testing.T) { []byte(`{ "id": "id" }`), - ), instance.SMSConfigActivatedEventMapper), + ), eventstore.GenericEventMapper[instance.SMSConfigActivatedEvent]), }, reduce: (&smsConfigProjection{}).reduceSMSConfigActivated, want: wantReduce{ @@ -190,7 +531,18 @@ func TestSMSProjection_reduces(t *testing.T) { executer: &testExecuter{ executions: []execution{ { - expectedStmt: "UPDATE projections.sms_configs2 SET (state, change_date, sequence) = ($1, $2, $3) WHERE (id = $4) AND (instance_id = $5)", + expectedStmt: "UPDATE projections.sms_configs3 SET (state, change_date, sequence) = ($1, $2, $3) WHERE (NOT (id = $4)) AND (state = $5) AND (instance_id = $6)", + expectedArgs: []interface{}{ + domain.SMSConfigStateInactive, + anyArg{}, + uint64(15), + "id", + domain.SMSConfigStateActive, + "instance-id", + }, + }, + { + expectedStmt: "UPDATE projections.sms_configs3 SET (state, change_date, sequence) = ($1, $2, $3) WHERE (id = $4) AND (instance_id = $5)", expectedArgs: []interface{}{ domain.SMSConfigStateActive, anyArg{}, @@ -213,7 +565,7 @@ func TestSMSProjection_reduces(t *testing.T) { []byte(`{ "id": "id" }`), - ), instance.SMSConfigDeactivatedEventMapper), + ), eventstore.GenericEventMapper[instance.SMSConfigDeactivatedEvent]), }, reduce: (&smsConfigProjection{}).reduceSMSConfigDeactivated, want: wantReduce{ @@ -222,7 +574,7 @@ func TestSMSProjection_reduces(t *testing.T) { executer: &testExecuter{ executions: []execution{ { - expectedStmt: "UPDATE projections.sms_configs2 SET (state, change_date, sequence) = ($1, $2, $3) WHERE (id = $4) AND (instance_id = $5)", + expectedStmt: "UPDATE projections.sms_configs3 SET (state, change_date, sequence) = ($1, $2, $3) WHERE (id = $4) AND (instance_id = $5)", expectedArgs: []interface{}{ domain.SMSConfigStateInactive, anyArg{}, @@ -245,7 +597,7 @@ func TestSMSProjection_reduces(t *testing.T) { []byte(`{ "id": "id" }`), - ), instance.SMSConfigRemovedEventMapper), + ), eventstore.GenericEventMapper[instance.SMSConfigRemovedEvent]), }, reduce: (&smsConfigProjection{}).reduceSMSConfigRemoved, want: wantReduce{ @@ -254,7 +606,7 @@ func TestSMSProjection_reduces(t *testing.T) { executer: &testExecuter{ executions: []execution{ { - expectedStmt: "DELETE FROM projections.sms_configs2 WHERE (id = $1) AND (instance_id = $2)", + expectedStmt: "DELETE FROM projections.sms_configs3 WHERE (id = $1) AND (instance_id = $2)", expectedArgs: []interface{}{ "id", "instance-id", @@ -281,7 +633,7 @@ func TestSMSProjection_reduces(t *testing.T) { executer: &testExecuter{ executions: []execution{ { - expectedStmt: "DELETE FROM projections.sms_configs2 WHERE (instance_id = $1)", + expectedStmt: "DELETE FROM projections.sms_configs3 WHERE (instance_id = $1)", expectedArgs: []interface{}{ "agg-id", }, diff --git a/internal/query/sms.go b/internal/query/sms.go index 38c798ba17..7aa4be2310 100644 --- a/internal/query/sms.go +++ b/internal/query/sms.go @@ -30,8 +30,10 @@ type SMSConfig struct { ResourceOwner string State domain.SMSConfigState Sequence uint64 + Description string TwilioConfig *Twilio + HTTPConfig *HTTP } type Twilio struct { @@ -40,6 +42,10 @@ type Twilio struct { SenderNumber string } +type HTTP struct { + Endpoint string +} + type SMSConfigsSearchQueries struct { SearchRequest Queries []SearchQuery @@ -58,60 +64,79 @@ var ( name: projection.SMSConfigProjectionTable, instanceIDCol: projection.SMSColumnInstanceID, } - SMSConfigColumnID = Column{ + SMSColumnID = Column{ name: projection.SMSColumnID, table: smsConfigsTable, } - SMSConfigColumnAggregateID = Column{ + SMSColumnAggregateID = Column{ name: projection.SMSColumnAggregateID, table: smsConfigsTable, } - SMSConfigColumnCreationDate = Column{ + SMSColumnCreationDate = Column{ name: projection.SMSColumnCreationDate, table: smsConfigsTable, } - SMSConfigColumnChangeDate = Column{ + SMSColumnChangeDate = Column{ name: projection.SMSColumnChangeDate, table: smsConfigsTable, } - SMSConfigColumnResourceOwner = Column{ + SMSColumnResourceOwner = Column{ name: projection.SMSColumnResourceOwner, table: smsConfigsTable, } - SMSConfigColumnInstanceID = Column{ + SMSColumnInstanceID = Column{ name: projection.SMSColumnInstanceID, table: smsConfigsTable, } - SMSConfigColumnState = Column{ + SMSColumnState = Column{ name: projection.SMSColumnState, table: smsConfigsTable, } - SMSConfigColumnSequence = Column{ + SMSColumnSequence = Column{ name: projection.SMSColumnSequence, table: smsConfigsTable, } + SMSColumnDescription = Column{ + name: projection.SMSColumnDescription, + table: smsConfigsTable, + } ) var ( - smsTwilioConfigsTable = table{ + smsTwilioTable = table{ name: projection.SMSTwilioTable, instanceIDCol: projection.SMSTwilioColumnInstanceID, } - SMSTwilioConfigColumnSMSID = Column{ - name: projection.SMSTwilioConfigColumnSMSID, - table: smsTwilioConfigsTable, + SMSTwilioColumnSMSID = Column{ + name: projection.SMSTwilioColumnSMSID, + table: smsTwilioTable, } - SMSTwilioConfigColumnSID = Column{ - name: projection.SMSTwilioConfigColumnSID, - table: smsTwilioConfigsTable, + SMSTwilioColumnSID = Column{ + name: projection.SMSTwilioColumnSID, + table: smsTwilioTable, } - SMSTwilioConfigColumnToken = Column{ - name: projection.SMSTwilioConfigColumnToken, - table: smsTwilioConfigsTable, + SMSTwilioColumnToken = Column{ + name: projection.SMSTwilioColumnToken, + table: smsTwilioTable, } - SMSTwilioConfigColumnSenderNumber = Column{ - name: projection.SMSTwilioConfigColumnSenderNumber, - table: smsTwilioConfigsTable, + SMSTwilioColumnSenderNumber = Column{ + name: projection.SMSTwilioColumnSenderNumber, + table: smsTwilioTable, + } +) + +var ( + smsHTTPTable = table{ + name: projection.SMSHTTPTable, + instanceIDCol: projection.SMSHTTPColumnInstanceID, + } + SMSHTTPColumnSMSID = Column{ + name: projection.SMSHTTPColumnSMSID, + table: smsHTTPTable, + } + SMSHTTPColumnEndpoint = Column{ + name: projection.SMSHTTPColumnEndpoint, + table: smsHTTPTable, } ) @@ -122,8 +147,8 @@ func (q *Queries) SMSProviderConfigByID(ctx context.Context, id string) (config query, scan := prepareSMSConfigQuery(ctx, q.client) stmt, args, err := query.Where( sq.Eq{ - SMSConfigColumnID.identifier(): id, - SMSConfigColumnInstanceID.identifier(): authz.GetInstance(ctx).InstanceID(), + SMSColumnID.identifier(): id, + SMSColumnInstanceID.identifier(): authz.GetInstance(ctx).InstanceID(), }, ).ToSql() if err != nil { @@ -137,17 +162,15 @@ func (q *Queries) SMSProviderConfigByID(ctx context.Context, id string) (config return config, err } -func (q *Queries) SMSProviderConfig(ctx context.Context, queries ...SearchQuery) (config *SMSConfig, err error) { +func (q *Queries) SMSProviderConfigActive(ctx context.Context, instanceID string) (config *SMSConfig, err error) { ctx, span := tracing.NewSpan(ctx) defer func() { span.EndWithError(err) }() query, scan := prepareSMSConfigQuery(ctx, q.client) - for _, searchQuery := range queries { - query = searchQuery.toQuery(query) - } stmt, args, err := query.Where( sq.Eq{ - SMSConfigColumnInstanceID.identifier(): authz.GetInstance(ctx).InstanceID(), + SMSColumnInstanceID.identifier(): instanceID, + SMSColumnState.identifier(): domain.SMSConfigStateActive, }, ).ToSql() if err != nil { @@ -168,7 +191,7 @@ func (q *Queries) SearchSMSConfigs(ctx context.Context, queries *SMSConfigsSearc query, scan := prepareSMSConfigsQuery(ctx, q.client) stmt, args, err := queries.toQuery(query). Where(sq.Eq{ - SMSConfigColumnInstanceID.identifier(): authz.GetInstance(ctx).InstanceID(), + SMSColumnInstanceID.identifier(): authz.GetInstance(ctx).InstanceID(), }).ToSql() if err != nil { return nil, zerrors.ThrowInvalidArgument(err, "QUERY-sn9Jf", "Errors.Query.InvalidRequest") @@ -186,30 +209,36 @@ func (q *Queries) SearchSMSConfigs(ctx context.Context, queries *SMSConfigsSearc } func NewSMSProviderStateQuery(state domain.SMSConfigState) (SearchQuery, error) { - return NewNumberQuery(SMSConfigColumnState, state, NumberEquals) + return NewNumberQuery(SMSColumnState, state, NumberEquals) } func prepareSMSConfigQuery(ctx context.Context, db prepareDatabase) (sq.SelectBuilder, func(*sql.Row) (*SMSConfig, error)) { return sq.Select( - SMSConfigColumnID.identifier(), - SMSConfigColumnAggregateID.identifier(), - SMSConfigColumnCreationDate.identifier(), - SMSConfigColumnChangeDate.identifier(), - SMSConfigColumnResourceOwner.identifier(), - SMSConfigColumnState.identifier(), - SMSConfigColumnSequence.identifier(), + SMSColumnID.identifier(), + SMSColumnAggregateID.identifier(), + SMSColumnCreationDate.identifier(), + SMSColumnChangeDate.identifier(), + SMSColumnResourceOwner.identifier(), + SMSColumnState.identifier(), + SMSColumnSequence.identifier(), + SMSColumnDescription.identifier(), - SMSTwilioConfigColumnSMSID.identifier(), - SMSTwilioConfigColumnSID.identifier(), - SMSTwilioConfigColumnToken.identifier(), - SMSTwilioConfigColumnSenderNumber.identifier(), + SMSTwilioColumnSMSID.identifier(), + SMSTwilioColumnSID.identifier(), + SMSTwilioColumnToken.identifier(), + SMSTwilioColumnSenderNumber.identifier(), + + SMSHTTPColumnSMSID.identifier(), + SMSHTTPColumnEndpoint.identifier(), ).From(smsConfigsTable.identifier()). - LeftJoin(join(SMSTwilioConfigColumnSMSID, SMSConfigColumnID) + db.Timetravel(call.Took(ctx))). + LeftJoin(join(SMSTwilioColumnSMSID, SMSColumnID)). + LeftJoin(join(SMSHTTPColumnSMSID, SMSColumnID) + db.Timetravel(call.Took(ctx))). PlaceholderFormat(sq.Dollar), func(row *sql.Row) (*SMSConfig, error) { config := new(SMSConfig) var ( twilioConfig = sqlTwilioConfig{} + httpConfig = sqlHTTPConfig{} ) err := row.Scan( @@ -220,11 +249,15 @@ func prepareSMSConfigQuery(ctx context.Context, db prepareDatabase) (sq.SelectBu &config.ResourceOwner, &config.State, &config.Sequence, + &config.Description, &twilioConfig.smsID, &twilioConfig.sid, &twilioConfig.token, &twilioConfig.senderNumber, + + &httpConfig.smsID, + &httpConfig.endpoint, ) if err != nil { @@ -235,6 +268,7 @@ func prepareSMSConfigQuery(ctx context.Context, db prepareDatabase) (sq.SelectBu } twilioConfig.set(config) + httpConfig.set(config) return config, nil } @@ -242,21 +276,27 @@ func prepareSMSConfigQuery(ctx context.Context, db prepareDatabase) (sq.SelectBu func prepareSMSConfigsQuery(ctx context.Context, db prepareDatabase) (sq.SelectBuilder, func(*sql.Rows) (*SMSConfigs, error)) { return sq.Select( - SMSConfigColumnID.identifier(), - SMSConfigColumnAggregateID.identifier(), - SMSConfigColumnCreationDate.identifier(), - SMSConfigColumnChangeDate.identifier(), - SMSConfigColumnResourceOwner.identifier(), - SMSConfigColumnState.identifier(), - SMSConfigColumnSequence.identifier(), + SMSColumnID.identifier(), + SMSColumnAggregateID.identifier(), + SMSColumnCreationDate.identifier(), + SMSColumnChangeDate.identifier(), + SMSColumnResourceOwner.identifier(), + SMSColumnState.identifier(), + SMSColumnSequence.identifier(), + SMSColumnDescription.identifier(), + + SMSTwilioColumnSMSID.identifier(), + SMSTwilioColumnSID.identifier(), + SMSTwilioColumnToken.identifier(), + SMSTwilioColumnSenderNumber.identifier(), + + SMSHTTPColumnSMSID.identifier(), + SMSHTTPColumnEndpoint.identifier(), - SMSTwilioConfigColumnSMSID.identifier(), - SMSTwilioConfigColumnSID.identifier(), - SMSTwilioConfigColumnToken.identifier(), - SMSTwilioConfigColumnSenderNumber.identifier(), countColumn.identifier(), ).From(smsConfigsTable.identifier()). - LeftJoin(join(SMSTwilioConfigColumnSMSID, SMSConfigColumnID) + db.Timetravel(call.Took(ctx))). + LeftJoin(join(SMSTwilioColumnSMSID, SMSColumnID)). + LeftJoin(join(SMSHTTPColumnSMSID, SMSColumnID) + db.Timetravel(call.Took(ctx))). PlaceholderFormat(sq.Dollar), func(row *sql.Rows) (*SMSConfigs, error) { configs := &SMSConfigs{Configs: []*SMSConfig{}} @@ -264,6 +304,7 @@ func prepareSMSConfigsQuery(ctx context.Context, db prepareDatabase) (sq.SelectB config := new(SMSConfig) var ( twilioConfig = sqlTwilioConfig{} + httpConfig = sqlHTTPConfig{} ) err := row.Scan( @@ -274,11 +315,16 @@ func prepareSMSConfigsQuery(ctx context.Context, db prepareDatabase) (sq.SelectB &config.ResourceOwner, &config.State, &config.Sequence, + &config.Description, &twilioConfig.smsID, &twilioConfig.sid, &twilioConfig.token, &twilioConfig.senderNumber, + + &httpConfig.smsID, + &httpConfig.endpoint, + &configs.Count, ) @@ -287,6 +333,7 @@ func prepareSMSConfigsQuery(ctx context.Context, db prepareDatabase) (sq.SelectB } twilioConfig.set(config) + httpConfig.set(config) configs.Configs = append(configs.Configs, config) } @@ -312,3 +359,17 @@ func (c sqlTwilioConfig) set(smsConfig *SMSConfig) { SenderNumber: c.senderNumber.String, } } + +type sqlHTTPConfig struct { + smsID sql.NullString + endpoint sql.NullString +} + +func (c sqlHTTPConfig) set(smsConfig *SMSConfig) { + if !c.smsID.Valid { + return + } + smsConfig.HTTPConfig = &HTTP{ + Endpoint: c.endpoint.String, + } +} diff --git a/internal/query/sms_test.go b/internal/query/sms_test.go index 56bb97e2eb..20cf62f8cb 100644 --- a/internal/query/sms_test.go +++ b/internal/query/sms_test.go @@ -14,38 +14,50 @@ import ( ) var ( - expectedSMSConfigQuery = regexp.QuoteMeta(`SELECT projections.sms_configs2.id,` + - ` projections.sms_configs2.aggregate_id,` + - ` projections.sms_configs2.creation_date,` + - ` projections.sms_configs2.change_date,` + - ` projections.sms_configs2.resource_owner,` + - ` projections.sms_configs2.state,` + - ` projections.sms_configs2.sequence,` + + expectedSMSConfigQuery = regexp.QuoteMeta(`SELECT projections.sms_configs3.id,` + + ` projections.sms_configs3.aggregate_id,` + + ` projections.sms_configs3.creation_date,` + + ` projections.sms_configs3.change_date,` + + ` projections.sms_configs3.resource_owner,` + + ` projections.sms_configs3.state,` + + ` projections.sms_configs3.sequence,` + + ` projections.sms_configs3.description,` + // twilio config - ` projections.sms_configs2_twilio.sms_id,` + - ` projections.sms_configs2_twilio.sid,` + - ` projections.sms_configs2_twilio.token,` + - ` projections.sms_configs2_twilio.sender_number` + - ` FROM projections.sms_configs2` + - ` LEFT JOIN projections.sms_configs2_twilio ON projections.sms_configs2.id = projections.sms_configs2_twilio.sms_id AND projections.sms_configs2.instance_id = projections.sms_configs2_twilio.instance_id` + + ` projections.sms_configs3_twilio.sms_id,` + + ` projections.sms_configs3_twilio.sid,` + + ` projections.sms_configs3_twilio.token,` + + ` projections.sms_configs3_twilio.sender_number,` + + + // http config + ` projections.sms_configs3_http.sms_id,` + + ` projections.sms_configs3_http.endpoint` + + ` FROM projections.sms_configs3` + + ` LEFT JOIN projections.sms_configs3_twilio ON projections.sms_configs3.id = projections.sms_configs3_twilio.sms_id AND projections.sms_configs3.instance_id = projections.sms_configs3_twilio.instance_id` + + ` LEFT JOIN projections.sms_configs3_http ON projections.sms_configs3.id = projections.sms_configs3_http.sms_id AND projections.sms_configs3.instance_id = projections.sms_configs3_http.instance_id` + ` AS OF SYSTEM TIME '-1 ms'`) - expectedSMSConfigsQuery = regexp.QuoteMeta(`SELECT projections.sms_configs2.id,` + - ` projections.sms_configs2.aggregate_id,` + - ` projections.sms_configs2.creation_date,` + - ` projections.sms_configs2.change_date,` + - ` projections.sms_configs2.resource_owner,` + - ` projections.sms_configs2.state,` + - ` projections.sms_configs2.sequence,` + + expectedSMSConfigsQuery = regexp.QuoteMeta(`SELECT projections.sms_configs3.id,` + + ` projections.sms_configs3.aggregate_id,` + + ` projections.sms_configs3.creation_date,` + + ` projections.sms_configs3.change_date,` + + ` projections.sms_configs3.resource_owner,` + + ` projections.sms_configs3.state,` + + ` projections.sms_configs3.sequence,` + + ` projections.sms_configs3.description,` + // twilio config - ` projections.sms_configs2_twilio.sms_id,` + - ` projections.sms_configs2_twilio.sid,` + - ` projections.sms_configs2_twilio.token,` + - ` projections.sms_configs2_twilio.sender_number,` + + ` projections.sms_configs3_twilio.sms_id,` + + ` projections.sms_configs3_twilio.sid,` + + ` projections.sms_configs3_twilio.token,` + + ` projections.sms_configs3_twilio.sender_number,` + + + // http config + ` projections.sms_configs3_http.sms_id,` + + ` projections.sms_configs3_http.endpoint,` + ` COUNT(*) OVER ()` + - ` FROM projections.sms_configs2` + - ` LEFT JOIN projections.sms_configs2_twilio ON projections.sms_configs2.id = projections.sms_configs2_twilio.sms_id AND projections.sms_configs2.instance_id = projections.sms_configs2_twilio.instance_id` + + ` FROM projections.sms_configs3` + + ` LEFT JOIN projections.sms_configs3_twilio ON projections.sms_configs3.id = projections.sms_configs3_twilio.sms_id AND projections.sms_configs3.instance_id = projections.sms_configs3_twilio.instance_id` + + ` LEFT JOIN projections.sms_configs3_http ON projections.sms_configs3.id = projections.sms_configs3_http.sms_id AND projections.sms_configs3.instance_id = projections.sms_configs3_http.instance_id` + ` AS OF SYSTEM TIME '-1 ms'`) smsConfigCols = []string{ @@ -56,16 +68,20 @@ var ( "resource_owner", "state", "sequence", + "description", // twilio config "sms_id", "sid", "token", "sender-number", + // http config + "sms_id", + "endpoint", } smsConfigsCols = append(smsConfigCols, "count") ) -func Test_SMSConfigssPrepare(t *testing.T) { +func Test_SMSConfigsPrepare(t *testing.T) { type want struct { sqlExpectations sqlExpectation err checkErr @@ -104,11 +120,15 @@ func Test_SMSConfigssPrepare(t *testing.T) { "ro", domain.SMSConfigStateInactive, uint64(20211109), + "description", // twilio config "sms-id", "sid", &crypto.CryptoValue{}, "sender-number", + // http config + nil, + nil, }, }, ), @@ -126,6 +146,7 @@ func Test_SMSConfigssPrepare(t *testing.T) { ResourceOwner: "ro", State: domain.SMSConfigStateInactive, Sequence: 20211109, + Description: "description", TwilioConfig: &Twilio{ SID: "sid", Token: &crypto.CryptoValue{}, @@ -135,6 +156,56 @@ func Test_SMSConfigssPrepare(t *testing.T) { }, }, }, + { + name: "prepareSMSQuery http config", + prepare: prepareSMSConfigsQuery, + want: want{ + sqlExpectations: mockQueries( + expectedSMSConfigsQuery, + smsConfigsCols, + [][]driver.Value{ + { + "sms-id", + "agg-id", + testNow, + testNow, + "ro", + domain.SMSConfigStateInactive, + uint64(20211109), + "description", + // twilio config + nil, + nil, + nil, + nil, + // http config + "sms-id", + "endpoint", + }, + }, + ), + }, + object: &SMSConfigs{ + SearchResponse: SearchResponse{ + Count: 1, + }, + Configs: []*SMSConfig{ + { + ID: "sms-id", + AggregateID: "agg-id", + CreationDate: testNow, + ChangeDate: testNow, + ResourceOwner: "ro", + State: domain.SMSConfigStateInactive, + Sequence: 20211109, + Description: "description", + HTTPConfig: &HTTP{ + Endpoint: "endpoint", + }, + }, + }, + }, + }, { name: "prepareSMSConfigsQuery multiple result", prepare: prepareSMSConfigsQuery, @@ -149,13 +220,17 @@ func Test_SMSConfigssPrepare(t *testing.T) { testNow, testNow, "ro", - domain.SMSConfigStateInactive, + domain.SMSConfigStateActive, uint64(20211109), + "description", // twilio config "sms-id", "sid", &crypto.CryptoValue{}, "sender-number", + // http config + nil, + nil, }, { "sms-id2", @@ -165,18 +240,40 @@ func Test_SMSConfigssPrepare(t *testing.T) { "ro", domain.SMSConfigStateInactive, uint64(20211109), + "description", // twilio config "sms-id2", "sid2", &crypto.CryptoValue{}, "sender-number2", + // http config + nil, + nil, + }, + { + "sms-id3", + "agg-id", + testNow, + testNow, + "ro", + domain.SMSConfigStateInactive, + uint64(20211109), + "description", + // twilio config + nil, + nil, + nil, + nil, + // http config + "sms-id3", + "endpoint3", }, }, ), }, object: &SMSConfigs{ SearchResponse: SearchResponse{ - Count: 2, + Count: 3, }, Configs: []*SMSConfig{ { @@ -185,8 +282,9 @@ func Test_SMSConfigssPrepare(t *testing.T) { CreationDate: testNow, ChangeDate: testNow, ResourceOwner: "ro", - State: domain.SMSConfigStateInactive, + State: domain.SMSConfigStateActive, Sequence: 20211109, + Description: "description", TwilioConfig: &Twilio{ SID: "sid", Token: &crypto.CryptoValue{}, @@ -201,12 +299,26 @@ func Test_SMSConfigssPrepare(t *testing.T) { ResourceOwner: "ro", State: domain.SMSConfigStateInactive, Sequence: 20211109, + Description: "description", TwilioConfig: &Twilio{ SID: "sid2", Token: &crypto.CryptoValue{}, SenderNumber: "sender-number2", }, }, + { + ID: "sms-id3", + AggregateID: "agg-id", + CreationDate: testNow, + ChangeDate: testNow, + ResourceOwner: "ro", + State: domain.SMSConfigStateInactive, + Sequence: 20211109, + Description: "description", + HTTPConfig: &HTTP{ + Endpoint: "endpoint3", + }, + }, }, }, }, @@ -265,7 +377,50 @@ func Test_SMSConfigPrepare(t *testing.T) { object: (*SMSConfig)(nil), }, { - name: "prepareSMSConfigQuery found", + name: "prepareSMSConfigQuery, twilio, found", + prepare: prepareSMSConfigQuery, + want: want{ + sqlExpectations: mockQuery( + expectedSMSConfigQuery, + smsConfigCols, + []driver.Value{ + "sms-id", + "agg-id", + testNow, + testNow, + "ro", + domain.SMSConfigStateActive, + uint64(20211109), + "description", + // twilio config + "sms-id", + "sid", + &crypto.CryptoValue{}, + "sender-number", + // http config + nil, + nil, + }, + ), + }, + object: &SMSConfig{ + ID: "sms-id", + AggregateID: "agg-id", + CreationDate: testNow, + ChangeDate: testNow, + ResourceOwner: "ro", + State: domain.SMSConfigStateActive, + Sequence: 20211109, + Description: "description", + TwilioConfig: &Twilio{ + SID: "sid", + SenderNumber: "sender-number", + Token: &crypto.CryptoValue{}, + }, + }, + }, + { + name: "prepareSMSConfigQuery, http, found", prepare: prepareSMSConfigQuery, want: want{ sqlExpectations: mockQuery( @@ -279,11 +434,15 @@ func Test_SMSConfigPrepare(t *testing.T) { "ro", domain.SMSConfigStateInactive, uint64(20211109), + "description", // twilio config + nil, + nil, + nil, + nil, + // http config "sms-id", - "sid", - &crypto.CryptoValue{}, - "sender-number", + "endpoint", }, ), }, @@ -295,10 +454,9 @@ func Test_SMSConfigPrepare(t *testing.T) { ResourceOwner: "ro", State: domain.SMSConfigStateInactive, Sequence: 20211109, - TwilioConfig: &Twilio{ - SID: "sid", - SenderNumber: "sender-number", - Token: &crypto.CryptoValue{}, + Description: "description", + HTTPConfig: &HTTP{ + Endpoint: "endpoint", }, }, }, diff --git a/internal/repository/instance/eventstore.go b/internal/repository/instance/eventstore.go index 16b7e3967e..d88793a399 100644 --- a/internal/repository/instance/eventstore.go +++ b/internal/repository/instance/eventstore.go @@ -18,12 +18,17 @@ func init() { eventstore.RegisterFilterEventMapper(AggregateType, SMTPConfigDeactivatedEventType, SMTPConfigDeactivatedEventMapper) eventstore.RegisterFilterEventMapper(AggregateType, SMTPConfigPasswordChangedEventType, SMTPConfigPasswordChangedEventMapper) eventstore.RegisterFilterEventMapper(AggregateType, SMTPConfigRemovedEventType, SMTPConfigRemovedEventMapper) - eventstore.RegisterFilterEventMapper(AggregateType, SMSConfigTwilioAddedEventType, SMSConfigTwilioAddedEventMapper) - eventstore.RegisterFilterEventMapper(AggregateType, SMSConfigTwilioChangedEventType, SMSConfigTwilioChangedEventMapper) - eventstore.RegisterFilterEventMapper(AggregateType, SMSConfigTwilioTokenChangedEventType, SMSConfigTwilioTokenChangedEventMapper) - eventstore.RegisterFilterEventMapper(AggregateType, SMSConfigActivatedEventType, SMSConfigActivatedEventMapper) - eventstore.RegisterFilterEventMapper(AggregateType, SMSConfigDeactivatedEventType, SMSConfigDeactivatedEventMapper) - eventstore.RegisterFilterEventMapper(AggregateType, SMSConfigRemovedEventType, SMSConfigRemovedEventMapper) + eventstore.RegisterFilterEventMapper(AggregateType, SMSConfigTwilioAddedEventType, eventstore.GenericEventMapper[SMSConfigTwilioAddedEvent]) + eventstore.RegisterFilterEventMapper(AggregateType, SMSConfigTwilioChangedEventType, eventstore.GenericEventMapper[SMSConfigTwilioChangedEvent]) + eventstore.RegisterFilterEventMapper(AggregateType, SMSConfigTwilioTokenChangedEventType, eventstore.GenericEventMapper[SMSConfigTwilioTokenChangedEvent]) + eventstore.RegisterFilterEventMapper(AggregateType, SMSConfigHTTPAddedEventType, eventstore.GenericEventMapper[SMSConfigHTTPAddedEvent]) + eventstore.RegisterFilterEventMapper(AggregateType, SMSConfigHTTPChangedEventType, eventstore.GenericEventMapper[SMSConfigHTTPChangedEvent]) + eventstore.RegisterFilterEventMapper(AggregateType, SMSConfigTwilioActivatedEventType, eventstore.GenericEventMapper[SMSConfigTwilioActivatedEvent]) + eventstore.RegisterFilterEventMapper(AggregateType, SMSConfigTwilioDeactivatedEventType, eventstore.GenericEventMapper[SMSConfigTwilioDeactivatedEvent]) + eventstore.RegisterFilterEventMapper(AggregateType, SMSConfigTwilioRemovedEventType, eventstore.GenericEventMapper[SMSConfigTwilioRemovedEvent]) + eventstore.RegisterFilterEventMapper(AggregateType, SMSConfigActivatedEventType, eventstore.GenericEventMapper[SMSConfigActivatedEvent]) + eventstore.RegisterFilterEventMapper(AggregateType, SMSConfigDeactivatedEventType, eventstore.GenericEventMapper[SMSConfigDeactivatedEvent]) + eventstore.RegisterFilterEventMapper(AggregateType, SMSConfigRemovedEventType, eventstore.GenericEventMapper[SMSConfigRemovedEvent]) eventstore.RegisterFilterEventMapper(AggregateType, DebugNotificationProviderFileAddedEventType, DebugNotificationProviderFileAddedEventMapper) eventstore.RegisterFilterEventMapper(AggregateType, DebugNotificationProviderFileChangedEventType, DebugNotificationProviderFileChangedEventMapper) eventstore.RegisterFilterEventMapper(AggregateType, DebugNotificationProviderFileRemovedEventType, DebugNotificationProviderFileRemovedEventMapper) diff --git a/internal/repository/instance/sms.go b/internal/repository/instance/sms.go index 1b79f1d77e..309ce9aa46 100644 --- a/internal/repository/instance/sms.go +++ b/internal/repository/instance/sms.go @@ -11,18 +11,25 @@ import ( const ( smsConfigPrefix = "sms.config" smsConfigTwilioPrefix = "twilio." + smsConfigHTTPPrefix = "http." SMSConfigTwilioAddedEventType = instanceEventTypePrefix + smsConfigPrefix + smsConfigTwilioPrefix + "added" SMSConfigTwilioChangedEventType = instanceEventTypePrefix + smsConfigPrefix + smsConfigTwilioPrefix + "changed" + SMSConfigHTTPAddedEventType = instanceEventTypePrefix + smsConfigPrefix + smsConfigHTTPPrefix + "added" + SMSConfigHTTPChangedEventType = instanceEventTypePrefix + smsConfigPrefix + smsConfigHTTPPrefix + "changed" SMSConfigTwilioTokenChangedEventType = instanceEventTypePrefix + smsConfigPrefix + smsConfigTwilioPrefix + "token.changed" - SMSConfigActivatedEventType = instanceEventTypePrefix + smsConfigPrefix + smsConfigTwilioPrefix + "activated" - SMSConfigDeactivatedEventType = instanceEventTypePrefix + smsConfigPrefix + smsConfigTwilioPrefix + "deactivated" - SMSConfigRemovedEventType = instanceEventTypePrefix + smsConfigPrefix + smsConfigTwilioPrefix + "removed" + SMSConfigTwilioActivatedEventType = instanceEventTypePrefix + smsConfigPrefix + smsConfigTwilioPrefix + "activated" + SMSConfigTwilioDeactivatedEventType = instanceEventTypePrefix + smsConfigPrefix + smsConfigTwilioPrefix + "deactivated" + SMSConfigTwilioRemovedEventType = instanceEventTypePrefix + smsConfigPrefix + smsConfigTwilioPrefix + "removed" + SMSConfigActivatedEventType = instanceEventTypePrefix + smsConfigPrefix + "activated" + SMSConfigDeactivatedEventType = instanceEventTypePrefix + smsConfigPrefix + "deactivated" + SMSConfigRemovedEventType = instanceEventTypePrefix + smsConfigPrefix + "removed" ) type SMSConfigTwilioAddedEvent struct { - eventstore.BaseEvent `json:"-"` + *eventstore.BaseEvent `json:"-"` ID string `json:"id,omitempty"` + Description string `json:"description,omitempty"` SID string `json:"sid,omitempty"` Token *crypto.CryptoValue `json:"token,omitempty"` SenderNumber string `json:"senderNumber,omitempty"` @@ -32,23 +39,29 @@ func NewSMSConfigTwilioAddedEvent( ctx context.Context, aggregate *eventstore.Aggregate, id, + description string, sid, senderNumber string, token *crypto.CryptoValue, ) *SMSConfigTwilioAddedEvent { return &SMSConfigTwilioAddedEvent{ - BaseEvent: *eventstore.NewBaseEventForPush( + BaseEvent: eventstore.NewBaseEventForPush( ctx, aggregate, SMSConfigTwilioAddedEventType, ), ID: id, + Description: description, SID: sid, Token: token, SenderNumber: senderNumber, } } +func (e *SMSConfigTwilioAddedEvent) SetBaseEvent(event *eventstore.BaseEvent) { + e.BaseEvent = event +} + func (e *SMSConfigTwilioAddedEvent) Payload() interface{} { return e } @@ -57,22 +70,11 @@ func (e *SMSConfigTwilioAddedEvent) UniqueConstraints() []*eventstore.UniqueCons return nil } -func SMSConfigTwilioAddedEventMapper(event eventstore.Event) (eventstore.Event, error) { - smsConfigAdded := &SMSConfigTwilioAddedEvent{ - BaseEvent: *eventstore.BaseEventFromRepo(event), - } - err := event.Unmarshal(smsConfigAdded) - if err != nil { - return nil, zerrors.ThrowInternal(err, "IAM-smwiR", "unable to unmarshal sms config twilio added") - } - - return smsConfigAdded, nil -} - type SMSConfigTwilioChangedEvent struct { - eventstore.BaseEvent `json:"-"` + *eventstore.BaseEvent `json:"-"` ID string `json:"id,omitempty"` + Description *string `json:"description,omitempty"` SID *string `json:"sid,omitempty"` SenderNumber *string `json:"senderNumber,omitempty"` } @@ -87,7 +89,7 @@ func NewSMSConfigTwilioChangedEvent( return nil, zerrors.ThrowPreconditionFailed(nil, "IAM-smn8e", "Errors.NoChangesFound") } changeEvent := &SMSConfigTwilioChangedEvent{ - BaseEvent: *eventstore.NewBaseEventForPush( + BaseEvent: eventstore.NewBaseEventForPush( ctx, aggregate, SMSConfigTwilioChangedEventType, @@ -108,12 +110,22 @@ func ChangeSMSConfigTwilioSID(sid string) func(event *SMSConfigTwilioChangedEven } } +func ChangeSMSConfigTwilioDescription(description string) func(event *SMSConfigTwilioChangedEvent) { + return func(e *SMSConfigTwilioChangedEvent) { + e.Description = &description + } +} + func ChangeSMSConfigTwilioSenderNumber(senderNumber string) func(event *SMSConfigTwilioChangedEvent) { return func(e *SMSConfigTwilioChangedEvent) { e.SenderNumber = &senderNumber } } +func (e *SMSConfigTwilioChangedEvent) SetBaseEvent(event *eventstore.BaseEvent) { + e.BaseEvent = event +} + func (e *SMSConfigTwilioChangedEvent) Payload() interface{} { return e } @@ -122,20 +134,8 @@ func (e *SMSConfigTwilioChangedEvent) UniqueConstraints() []*eventstore.UniqueCo return nil } -func SMSConfigTwilioChangedEventMapper(event eventstore.Event) (eventstore.Event, error) { - smsConfigChanged := &SMSConfigTwilioChangedEvent{ - BaseEvent: *eventstore.BaseEventFromRepo(event), - } - err := event.Unmarshal(smsConfigChanged) - if err != nil { - return nil, zerrors.ThrowInternal(err, "IAM-smwiR", "unable to unmarshal sms config twilio added") - } - - return smsConfigChanged, nil -} - type SMSConfigTwilioTokenChangedEvent struct { - eventstore.BaseEvent `json:"-"` + *eventstore.BaseEvent `json:"-"` ID string `json:"id,omitempty"` Token *crypto.CryptoValue `json:"token,omitempty"` @@ -148,7 +148,7 @@ func NewSMSConfigTokenChangedEvent( token *crypto.CryptoValue, ) *SMSConfigTwilioTokenChangedEvent { return &SMSConfigTwilioTokenChangedEvent{ - BaseEvent: *eventstore.NewBaseEventForPush( + BaseEvent: eventstore.NewBaseEventForPush( ctx, aggregate, SMSConfigTwilioTokenChangedEventType, @@ -158,6 +158,10 @@ func NewSMSConfigTokenChangedEvent( } } +func (e *SMSConfigTwilioTokenChangedEvent) SetBaseEvent(event *eventstore.BaseEvent) { + e.BaseEvent = event +} + func (e *SMSConfigTwilioTokenChangedEvent) Payload() interface{} { return e } @@ -166,30 +170,130 @@ func (e *SMSConfigTwilioTokenChangedEvent) UniqueConstraints() []*eventstore.Uni return nil } -func SMSConfigTwilioTokenChangedEventMapper(event eventstore.Event) (eventstore.Event, error) { - smtpConfigTokenChagned := &SMSConfigTwilioTokenChangedEvent{ - BaseEvent: *eventstore.BaseEventFromRepo(event), - } - err := event.Unmarshal(smtpConfigTokenChagned) - if err != nil { - return nil, zerrors.ThrowInternal(err, "IAM-fi9Wf", "unable to unmarshal sms config token changed") - } +type SMSConfigHTTPAddedEvent struct { + *eventstore.BaseEvent `json:"-"` - return smtpConfigTokenChagned, nil + ID string `json:"id,omitempty"` + Description string `json:"description,omitempty"` + Endpoint string `json:"endpoint,omitempty"` +} + +func NewSMSConfigHTTPAddedEvent( + ctx context.Context, + aggregate *eventstore.Aggregate, + id, + description, + endpoint string, +) *SMSConfigHTTPAddedEvent { + return &SMSConfigHTTPAddedEvent{ + BaseEvent: eventstore.NewBaseEventForPush( + ctx, + aggregate, + SMSConfigHTTPAddedEventType, + ), + ID: id, + Description: description, + Endpoint: endpoint, + } +} + +func (e *SMSConfigHTTPAddedEvent) SetBaseEvent(event *eventstore.BaseEvent) { + e.BaseEvent = event +} + +func (e *SMSConfigHTTPAddedEvent) Payload() interface{} { + return e +} + +func (e *SMSConfigHTTPAddedEvent) UniqueConstraints() []*eventstore.UniqueConstraint { + return nil +} + +type SMSConfigHTTPChangedEvent struct { + *eventstore.BaseEvent `json:"-"` + + ID string `json:"id,omitempty"` + Description *string `json:"description,omitempty"` + Endpoint *string `json:"endpoint,omitempty"` +} + +func NewSMSConfigHTTPChangedEvent( + ctx context.Context, + aggregate *eventstore.Aggregate, + id string, + changes []SMSConfigHTTPChanges, +) (*SMSConfigHTTPChangedEvent, error) { + if len(changes) == 0 { + return nil, zerrors.ThrowPreconditionFailed(nil, "IAM-smn8e", "Errors.NoChangesFound") + } + changeEvent := &SMSConfigHTTPChangedEvent{ + BaseEvent: eventstore.NewBaseEventForPush( + ctx, + aggregate, + SMSConfigHTTPChangedEventType, + ), + ID: id, + } + for _, change := range changes { + change(changeEvent) + } + return changeEvent, nil +} + +type SMSConfigHTTPChanges func(event *SMSConfigHTTPChangedEvent) + +func ChangeSMSConfigHTTPDescription(description string) func(event *SMSConfigHTTPChangedEvent) { + return func(e *SMSConfigHTTPChangedEvent) { + e.Description = &description + } +} +func ChangeSMSConfigHTTPEndpoint(endpoint string) func(event *SMSConfigHTTPChangedEvent) { + return func(e *SMSConfigHTTPChangedEvent) { + e.Endpoint = &endpoint + } +} + +func (e *SMSConfigHTTPChangedEvent) SetBaseEvent(event *eventstore.BaseEvent) { + e.BaseEvent = event +} + +func (e *SMSConfigHTTPChangedEvent) Payload() interface{} { + return e +} + +func (e *SMSConfigHTTPChangedEvent) UniqueConstraints() []*eventstore.UniqueConstraint { + return nil +} + +type SMSConfigTwilioActivatedEvent struct { + *eventstore.BaseEvent `json:"-"` + ID string `json:"id,omitempty"` +} + +func (e *SMSConfigTwilioActivatedEvent) SetBaseEvent(event *eventstore.BaseEvent) { + e.BaseEvent = event +} + +func (e *SMSConfigTwilioActivatedEvent) Payload() interface{} { + return e +} + +func (e *SMSConfigTwilioActivatedEvent) UniqueConstraints() []*eventstore.UniqueConstraint { + return nil } type SMSConfigActivatedEvent struct { - eventstore.BaseEvent `json:"-"` - ID string `json:"id,omitempty"` + *eventstore.BaseEvent `json:"-"` + ID string `json:"id,omitempty"` } -func NewSMSConfigTwilioActivatedEvent( +func NewSMSConfigActivatedEvent( ctx context.Context, aggregate *eventstore.Aggregate, id string, ) *SMSConfigActivatedEvent { return &SMSConfigActivatedEvent{ - BaseEvent: *eventstore.NewBaseEventForPush( + BaseEvent: eventstore.NewBaseEventForPush( ctx, aggregate, SMSConfigActivatedEventType, @@ -198,6 +302,10 @@ func NewSMSConfigTwilioActivatedEvent( } } +func (e *SMSConfigActivatedEvent) SetBaseEvent(event *eventstore.BaseEvent) { + e.BaseEvent = event +} + func (e *SMSConfigActivatedEvent) Payload() interface{} { return e } @@ -206,21 +314,26 @@ func (e *SMSConfigActivatedEvent) UniqueConstraints() []*eventstore.UniqueConstr return nil } -func SMSConfigActivatedEventMapper(event eventstore.Event) (eventstore.Event, error) { - smsConfigActivated := &SMSConfigActivatedEvent{ - BaseEvent: *eventstore.BaseEventFromRepo(event), - } - err := event.Unmarshal(smsConfigActivated) - if err != nil { - return nil, zerrors.ThrowInternal(err, "IAM-dn92f", "unable to unmarshal sms config twilio activated changed") - } +type SMSConfigTwilioDeactivatedEvent struct { + *eventstore.BaseEvent `json:"-"` + ID string `json:"id,omitempty"` +} - return smsConfigActivated, nil +func (e *SMSConfigTwilioDeactivatedEvent) SetBaseEvent(event *eventstore.BaseEvent) { + e.BaseEvent = event +} + +func (e *SMSConfigTwilioDeactivatedEvent) Payload() interface{} { + return e +} + +func (e *SMSConfigTwilioDeactivatedEvent) UniqueConstraints() []*eventstore.UniqueConstraint { + return nil } type SMSConfigDeactivatedEvent struct { - eventstore.BaseEvent `json:"-"` - ID string `json:"id,omitempty"` + *eventstore.BaseEvent `json:"-"` + ID string `json:"id,omitempty"` } func NewSMSConfigDeactivatedEvent( @@ -229,7 +342,7 @@ func NewSMSConfigDeactivatedEvent( id string, ) *SMSConfigDeactivatedEvent { return &SMSConfigDeactivatedEvent{ - BaseEvent: *eventstore.NewBaseEventForPush( + BaseEvent: eventstore.NewBaseEventForPush( ctx, aggregate, SMSConfigDeactivatedEventType, @@ -238,6 +351,10 @@ func NewSMSConfigDeactivatedEvent( } } +func (e *SMSConfigDeactivatedEvent) SetBaseEvent(event *eventstore.BaseEvent) { + e.BaseEvent = event +} + func (e *SMSConfigDeactivatedEvent) Payload() interface{} { return e } @@ -246,21 +363,26 @@ func (e *SMSConfigDeactivatedEvent) UniqueConstraints() []*eventstore.UniqueCons return nil } -func SMSConfigDeactivatedEventMapper(event eventstore.Event) (eventstore.Event, error) { - smsConfigDeactivated := &SMSConfigDeactivatedEvent{ - BaseEvent: *eventstore.BaseEventFromRepo(event), - } - err := event.Unmarshal(smsConfigDeactivated) - if err != nil { - return nil, zerrors.ThrowInternal(err, "IAM-dn92f", "unable to unmarshal sms config twilio deactivated changed") - } +type SMSConfigTwilioRemovedEvent struct { + *eventstore.BaseEvent `json:"-"` + ID string `json:"id,omitempty"` +} - return smsConfigDeactivated, nil +func (e *SMSConfigTwilioRemovedEvent) SetBaseEvent(event *eventstore.BaseEvent) { + e.BaseEvent = event +} + +func (e *SMSConfigTwilioRemovedEvent) Payload() interface{} { + return e +} + +func (e *SMSConfigTwilioRemovedEvent) UniqueConstraints() []*eventstore.UniqueConstraint { + return nil } type SMSConfigRemovedEvent struct { - eventstore.BaseEvent `json:"-"` - ID string `json:"id,omitempty"` + *eventstore.BaseEvent `json:"-"` + ID string `json:"id,omitempty"` } func NewSMSConfigRemovedEvent( @@ -269,7 +391,7 @@ func NewSMSConfigRemovedEvent( id string, ) *SMSConfigRemovedEvent { return &SMSConfigRemovedEvent{ - BaseEvent: *eventstore.NewBaseEventForPush( + BaseEvent: eventstore.NewBaseEventForPush( ctx, aggregate, SMSConfigRemovedEventType, @@ -278,6 +400,10 @@ func NewSMSConfigRemovedEvent( } } +func (e *SMSConfigRemovedEvent) SetBaseEvent(event *eventstore.BaseEvent) { + e.BaseEvent = event +} + func (e *SMSConfigRemovedEvent) Payload() interface{} { return e } @@ -285,15 +411,3 @@ func (e *SMSConfigRemovedEvent) Payload() interface{} { func (e *SMSConfigRemovedEvent) UniqueConstraints() []*eventstore.UniqueConstraint { return nil } - -func SMSConfigRemovedEventMapper(event eventstore.Event) (eventstore.Event, error) { - smsConfigRemoved := &SMSConfigRemovedEvent{ - BaseEvent: *eventstore.BaseEventFromRepo(event), - } - err := event.Unmarshal(smsConfigRemoved) - if err != nil { - return nil, zerrors.ThrowInternal(err, "IAM-99iNF", "unable to unmarshal sms config removed") - } - - return smsConfigRemoved, nil -} diff --git a/proto/zitadel/admin.proto b/proto/zitadel/admin.proto index 1db51d1b9b..41de3f845c 100644 --- a/proto/zitadel/admin.proto +++ b/proto/zitadel/admin.proto @@ -690,6 +690,40 @@ service AdminService { }; } + rpc AddSMSProviderHTTP(AddSMSProviderHTTPRequest) returns (AddSMSProviderHTTPResponse) { + option (google.api.http) = { + post: "/sms/http"; + body: "*" + }; + + option (zitadel.v1.auth_option) = { + permission: "iam.write"; + }; + + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + tags: "SMS Provider"; + summary: "Add HTTP SMS Provider"; + description: "Configure a new SMS provider of the type HTTP. A provider has to be activated to be able to send notifications." + }; + } + + rpc UpdateSMSProviderHTTP(UpdateSMSProviderHTTPRequest) returns (UpdateSMSProviderHTTPResponse) { + option (google.api.http) = { + put: "/sms/http/{id}"; + body: "*" + }; + + option (zitadel.v1.auth_option) = { + permission: "iam.write"; + }; + + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + tags: "SMS Provider"; + summary: "Update HTTP SMS Provider"; + description: "Change the configuration of an SMS provider of the type HTTP. A provider has to be activated to be able to send notifications." + }; + } + rpc ActivateSMSProvider(ActivateSMSProviderRequest) returns (ActivateSMSProviderResponse) { option (google.api.http) = { post: "/sms/{id}/_activate"; @@ -4507,6 +4541,14 @@ message AddSMSProviderTwilioRequest { max_length: 200; } ]; + string description = 4 [ + (validate.rules).string = {min_len: 0, max_len: 200}, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"provider description\""; + min_length: 0; + max_length: 200; + } + ]; } message AddSMSProviderTwilioResponse { @@ -4534,6 +4576,14 @@ message UpdateSMSProviderTwilioRequest { max_length: 200; } ]; + string description = 4 [ + (validate.rules).string = {min_len: 0, max_len: 200}, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"provider description\""; + min_length: 0; + max_length: 200; + } + ]; } message UpdateSMSProviderTwilioResponse { @@ -4549,6 +4599,56 @@ message UpdateSMSProviderTwilioTokenResponse { zitadel.v1.ObjectDetails details = 1; } +message AddSMSProviderHTTPRequest { + string endpoint = 1 [ + (validate.rules).string = {min_len: 1, max_len: 2048}, + (google.api.field_behavior) = REQUIRED, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"http://relay.example.com/provider\""; + min_length: 1; + max_length: 2048; + } + ]; + string description = 2 [ + (validate.rules).string = {min_len: 0, max_len: 200}, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"provider description\""; + min_length: 0; + max_length: 200; + } + ]; +} + +message AddSMSProviderHTTPResponse { + zitadel.v1.ObjectDetails details = 1; + string id = 2; +} + +message UpdateSMSProviderHTTPRequest { + string id = 1 [(validate.rules).string = {min_len: 1, max_len: 200}]; + string endpoint = 2 [ + (validate.rules).string = {min_len: 1, max_len: 2048}, + (google.api.field_behavior) = REQUIRED, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"http://relay.example.com/provider\""; + min_length: 1; + max_length: 2048; + } + ]; + string description = 3 [ + (validate.rules).string = {min_len: 0, max_len: 200}, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"provider description\""; + min_length: 0; + max_length: 200; + } + ]; +} + +message UpdateSMSProviderHTTPResponse { + zitadel.v1.ObjectDetails details = 1; +} + message ActivateSMSProviderRequest { string id = 1 [(validate.rules).string = {min_len: 1, max_len: 200}]; } diff --git a/proto/zitadel/settings.proto b/proto/zitadel/settings.proto index 18b343df08..7b4f656bf2 100644 --- a/proto/zitadel/settings.proto +++ b/proto/zitadel/settings.proto @@ -7,7 +7,7 @@ import "protoc-gen-openapiv2/options/annotations.proto"; package zitadel.settings.v1; -option go_package ="github.com/zitadel/zitadel/pkg/grpc/settings"; +option go_package = "github.com/zitadel/zitadel/pkg/grpc/settings"; message SecretGenerator { SecretGeneratorType generator_type = 1; @@ -99,9 +99,11 @@ message SMSProvider { zitadel.v1.ObjectDetails details = 1; string id = 2; SMSProviderConfigState state = 3; + string description = 6; oneof config { TwilioConfig twilio = 4; + HTTPConfig http = 5; } } @@ -110,6 +112,10 @@ message TwilioConfig { string sender_number = 2; } +message HTTPConfig { + string endpoint = 1; +} + enum SMSProviderConfigState { SMS_PROVIDER_CONFIG_STATE_UNSPECIFIED = 0; SMS_PROVIDER_CONFIG_ACTIVE = 1; @@ -117,8 +123,8 @@ enum SMSProviderConfigState { } message DebugNotificationProvider { - zitadel.v1.ObjectDetails details = 1; - bool compact = 2; + zitadel.v1.ObjectDetails details = 1; + bool compact = 2; } message OIDCSettings { From abe2502b27e2818ba96f03b287b15ee882e475a2 Mon Sep 17 00:00:00 2001 From: Livio Spring Date: Mon, 9 Sep 2024 11:58:25 +0200 Subject: [PATCH 08/33] chore: use default (lts) node version for devcontainer (#8532) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Which Problems Are Solved Node devcontainer still used node version 18. # How the Problems Are Solved Use default (lts) version to mitigate problems. # Additional Changes None # Additional Context - reference: https://github.com/devcontainers/features/tree/main/src/node Co-authored-by: Tim Möhlmann --- .devcontainer/devcontainer.json | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 90dbb52ba7..5d49f92cf4 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -7,9 +7,7 @@ "ghcr.io/devcontainers/features/go:1": { "version": "1.22" }, - "ghcr.io/devcontainers/features/node:1": { - "version": "18" - }, + "ghcr.io/devcontainers/features/node:1": {}, "ghcr.io/guiyomh/features/golangci-lint:0": {}, "ghcr.io/devcontainers/features/docker-outside-of-docker:1": {}, "ghcr.io/devcontainers/features/github-cli:1": {}, @@ -21,4 +19,4 @@ 8080 ], "onCreateCommand": "npm install -g sass@1.64.1" -} \ No newline at end of file +} From ccf222f0f6e92da8f20a957f54401adf853ecc1b Mon Sep 17 00:00:00 2001 From: Fabi Date: Mon, 9 Sep 2024 14:09:34 +0200 Subject: [PATCH 09/33] docs(api): fix title of project grant member list (#8548) # Which Problems Are Solved The title of the project grant member list was wrong # How the Problems Are Solved Added right title --- proto/zitadel/management.proto | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/proto/zitadel/management.proto b/proto/zitadel/management.proto index 7b57065a48..448e8b597d 100644 --- a/proto/zitadel/management.proto +++ b/proto/zitadel/management.proto @@ -3976,7 +3976,7 @@ service ManagementService { tags: "Project Grants" tags: "Members"; tags: "ZITADEL Administrators"; - summary: "List Project Members"; + summary: "List Project Grant Members"; description: "Members are users with permission to administrate ZITADEL on different levels. This request returns all users with memberships on the project grant level, matching the search queries. The search queries will be AND linked." parameters: { headers: { From 650c21f18af91b0056f1e337e5d3aa21946e84b6 Mon Sep 17 00:00:00 2001 From: Livio Spring Date: Tue, 10 Sep 2024 12:55:32 +0200 Subject: [PATCH 10/33] fix: check if pw login allowed (#8584) # Which Problems Are Solved When checking for the next step for the login UI and a user did not yet have an IdP linked, they would always be presented the password check screen, even if the local authentication was disabled. # How the Problems Are Solved - Correctly check the login policy for the `Allow Username Password` option - In case the user has no IdP linked yet, fallback to the organizations configuration (and redirect if possible) - the user can be auto-linked based on the username / email after successfully authenticating at the IdP # Additional Changes None # Additional Context - closes https://github.com/zitadel/zitadel/issues/5106 - closes https://github.com/zitadel/zitadel/issues/7502 --- .../eventsourcing/eventstore/auth_request.go | 33 +++-- .../eventstore/auth_request_test.go | 134 +++++++++++++++--- 2 files changed, 141 insertions(+), 26 deletions(-) diff --git a/internal/auth/repository/eventsourcing/eventstore/auth_request.go b/internal/auth/repository/eventsourcing/eventstore/auth_request.go index ca1cc5a03c..9a9c52012c 100644 --- a/internal/auth/repository/eventsourcing/eventstore/auth_request.go +++ b/internal/auth/repository/eventsourcing/eventstore/auth_request.go @@ -1060,8 +1060,12 @@ func (repo *AuthRequestRepo) nextSteps(ctx context.Context, request *domain.Auth if err != nil { return nil, err } - if (!isInternalLogin || len(idps.Links) > 0) && len(request.LinkingUsers) == 0 { - step := repo.idpChecked(request, idps.Links, userSession) + noLocalAuth := request.LoginPolicy != nil && !request.LoginPolicy.AllowUsernamePassword + if (!isInternalLogin || len(idps.Links) > 0 || noLocalAuth) && len(request.LinkingUsers) == 0 { + step, err := repo.idpChecked(request, idps.Links, userSession) + if err != nil { + return nil, err + } if step != nil { return append(steps, step), nil } @@ -1272,20 +1276,29 @@ func (repo *AuthRequestRepo) firstFactorChecked(ctx context.Context, request *do return &domain.PasswordStep{} } -func (repo *AuthRequestRepo) idpChecked(request *domain.AuthRequest, idps []*query.IDPUserLink, userSession *user_model.UserSessionView) domain.NextStep { +func (repo *AuthRequestRepo) idpChecked(request *domain.AuthRequest, idps []*query.IDPUserLink, userSession *user_model.UserSessionView) (domain.NextStep, error) { if checkVerificationTimeMaxAge(userSession.ExternalLoginVerification, request.LoginPolicy.ExternalLoginCheckLifetime, request) { request.IDPLoginChecked = true request.AuthTime = userSession.ExternalLoginVerification - return nil + return nil, nil } - selectedIDPConfigID := request.SelectedIDPConfigID - if selectedIDPConfigID == "" { - selectedIDPConfigID = userSession.SelectedIDPConfigID + // use the explicitly set IdP first + if request.SelectedIDPConfigID != "" { + return &domain.ExternalLoginStep{SelectedIDPConfigID: request.SelectedIDPConfigID}, nil } - if selectedIDPConfigID == "" && len(idps) > 0 { - selectedIDPConfigID = idps[0].IDPID + // reuse the previously used IdP from the session + if userSession.SelectedIDPConfigID != "" { + return &domain.ExternalLoginStep{SelectedIDPConfigID: userSession.SelectedIDPConfigID}, nil } - return &domain.ExternalLoginStep{SelectedIDPConfigID: selectedIDPConfigID} + // then use an existing linked IdP of the user + if len(idps) > 0 { + return &domain.ExternalLoginStep{SelectedIDPConfigID: idps[0].IDPID}, nil + } + // if the user did not link one, then just use one of the configured IdPs of the org + if len(request.AllowedExternalIDPs) > 0 { + return &domain.ExternalLoginStep{SelectedIDPConfigID: request.AllowedExternalIDPs[0].IDPConfigID}, nil + } + return nil, zerrors.ThrowPreconditionFailed(nil, "LOGIN-5Hm8s", "Errors.Org.IdpNotExisting") } func (repo *AuthRequestRepo) mfaChecked(userSession *user_model.UserSessionView, request *domain.AuthRequest, user *user_model.UserView, isInternalAuthentication bool) (domain.NextStep, bool, error) { diff --git a/internal/auth/repository/eventsourcing/eventstore/auth_request_test.go b/internal/auth/repository/eventsourcing/eventstore/auth_request_test.go index 9ed47762b7..96a7fc8e48 100644 --- a/internal/auth/repository/eventsourcing/eventstore/auth_request_test.go +++ b/internal/auth/repository/eventsourcing/eventstore/auth_request_test.go @@ -563,6 +563,7 @@ func TestAuthRequestRepo_nextSteps(t *testing.T) { idpUserLinksProvider: &mockIDPUserLinks{}, loginPolicyProvider: &mockLoginPolicy{ policy: &query.LoginPolicy{ + AllowUsernamePassword: true, SecondFactors: []domain.SecondFactorType{domain.SecondFactorTypeTOTP}, PasswordCheckLifetime: database.Duration(10 * 24 * time.Hour), SecondFactorCheckLifetime: database.Duration(18 * time.Hour), @@ -584,6 +585,7 @@ func TestAuthRequestRepo_nextSteps(t *testing.T) { args{&domain.AuthRequest{ Request: &domain.AuthRequestOIDC{}, LoginPolicy: &domain.LoginPolicy{ + AllowUsernamePassword: true, SecondFactors: []domain.SecondFactorType{domain.SecondFactorTypeTOTP}, PasswordCheckLifetime: 10 * 24 * time.Hour, SecondFactorCheckLifetime: 18 * time.Hour, @@ -812,7 +814,15 @@ func TestAuthRequestRepo_nextSteps(t *testing.T) { }, idpUserLinksProvider: &mockIDPUserLinks{}, }, - args{&domain.AuthRequest{UserID: "UserID", LoginPolicy: &domain.LoginPolicy{}}, false}, + args{ + &domain.AuthRequest{ + UserID: "UserID", + LoginPolicy: &domain.LoginPolicy{ + AllowUsernamePassword: true, + }, + }, + false, + }, []domain.NextStep{&domain.PasswordStep{}}, nil, }, @@ -849,9 +859,22 @@ func TestAuthRequestRepo_nextSteps(t *testing.T) { ShowFailures: true, }, }, + loginPolicyProvider: &mockLoginPolicy{ + policy: &query.LoginPolicy{ + AllowUsernamePassword: true, + }, + }, idpUserLinksProvider: &mockIDPUserLinks{}, }, - args{&domain.AuthRequest{UserID: "UserID", LoginPolicy: &domain.LoginPolicy{}}, false}, + args{ + &domain.AuthRequest{ + UserID: "UserID", + LoginPolicy: &domain.LoginPolicy{ + AllowUsernamePassword: true, + }, + }, + false, + }, []domain.NextStep{&domain.InitUserStep{ PasswordSet: true, }}, @@ -878,7 +901,16 @@ func TestAuthRequestRepo_nextSteps(t *testing.T) { }, idpUserLinksProvider: &mockIDPUserLinks{}, }, - args{&domain.AuthRequest{UserID: "UserID", LoginPolicy: &domain.LoginPolicy{PasswordlessType: domain.PasswordlessTypeAllowed}}, false}, + args{ + &domain.AuthRequest{ + UserID: "UserID", + LoginPolicy: &domain.LoginPolicy{ + AllowUsernamePassword: true, + PasswordlessType: domain.PasswordlessTypeAllowed, + }, + }, + false, + }, []domain.NextStep{&domain.PasswordlessRegistrationPromptStep{}}, nil, }, @@ -903,7 +935,15 @@ func TestAuthRequestRepo_nextSteps(t *testing.T) { }, idpUserLinksProvider: &mockIDPUserLinks{}, }, - args{&domain.AuthRequest{UserID: "UserID", LoginPolicy: &domain.LoginPolicy{PasswordlessType: domain.PasswordlessTypeAllowed}}, false}, + args{ + &domain.AuthRequest{ + UserID: "UserID", + LoginPolicy: &domain.LoginPolicy{ + AllowUsernamePassword: true, + PasswordlessType: domain.PasswordlessTypeAllowed, + }, + }, false, + }, []domain.NextStep{&domain.PasswordlessStep{}}, nil, }, @@ -929,7 +969,15 @@ func TestAuthRequestRepo_nextSteps(t *testing.T) { }, idpUserLinksProvider: &mockIDPUserLinks{}, }, - args{&domain.AuthRequest{UserID: "UserID", LoginPolicy: &domain.LoginPolicy{PasswordlessType: domain.PasswordlessTypeAllowed}}, false}, + args{ + &domain.AuthRequest{ + UserID: "UserID", + LoginPolicy: &domain.LoginPolicy{ + AllowUsernamePassword: true, + PasswordlessType: domain.PasswordlessTypeAllowed, + }, + }, false, + }, []domain.NextStep{&domain.PasswordlessStep{PasswordSet: true}}, nil, }, @@ -956,14 +1004,18 @@ func TestAuthRequestRepo_nextSteps(t *testing.T) { orgViewProvider: &mockViewOrg{State: domain.OrgStateActive}, idpUserLinksProvider: &mockIDPUserLinks{}, }, - args{&domain.AuthRequest{ - UserID: "UserID", - LoginPolicy: &domain.LoginPolicy{ - PasswordlessType: domain.PasswordlessTypeAllowed, - MultiFactors: []domain.MultiFactorType{domain.MultiFactorTypeU2FWithPIN}, - MultiFactorCheckLifetime: 10 * time.Hour, + args{ + &domain.AuthRequest{ + UserID: "UserID", + LoginPolicy: &domain.LoginPolicy{ + AllowUsernamePassword: true, + PasswordlessType: domain.PasswordlessTypeAllowed, + MultiFactors: []domain.MultiFactorType{domain.MultiFactorTypeU2FWithPIN}, + MultiFactorCheckLifetime: 10 * time.Hour, + }, }, - }, false}, + false, + }, []domain.NextStep{&domain.VerifyEMailStep{}}, nil, }, @@ -983,7 +1035,15 @@ func TestAuthRequestRepo_nextSteps(t *testing.T) { orgViewProvider: &mockViewOrg{State: domain.OrgStateActive}, idpUserLinksProvider: &mockIDPUserLinks{}, }, - args{&domain.AuthRequest{UserID: "UserID", LoginPolicy: &domain.LoginPolicy{}}, false}, + args{ + &domain.AuthRequest{ + UserID: "UserID", + LoginPolicy: &domain.LoginPolicy{ + AllowUsernamePassword: true, + }, + }, + false, + }, []domain.NextStep{&domain.VerifyEMailStep{InitPassword: true}}, nil, }, @@ -1007,7 +1067,15 @@ func TestAuthRequestRepo_nextSteps(t *testing.T) { idpUserLinksProvider: &mockIDPUserLinks{}, passwordReset: newMockPasswordReset(false), }, - args{&domain.AuthRequest{UserID: "UserID", LoginPolicy: &domain.LoginPolicy{}}, false}, + args{ + &domain.AuthRequest{ + UserID: "UserID", + LoginPolicy: &domain.LoginPolicy{ + AllowUsernamePassword: true, + }, + }, + false, + }, []domain.NextStep{&domain.InitPasswordStep{}}, nil, }, @@ -1031,7 +1099,15 @@ func TestAuthRequestRepo_nextSteps(t *testing.T) { idpUserLinksProvider: &mockIDPUserLinks{}, passwordReset: newMockPasswordReset(true), }, - args{&domain.AuthRequest{UserID: "UserID", LoginPolicy: &domain.LoginPolicy{}}, false}, + args{ + &domain.AuthRequest{ + UserID: "UserID", + LoginPolicy: &domain.LoginPolicy{ + AllowUsernamePassword: true, + }, + }, + false, + }, []domain.NextStep{&domain.InitPasswordStep{}}, nil, }, @@ -1063,6 +1139,7 @@ func TestAuthRequestRepo_nextSteps(t *testing.T) { UserID: "UserID", SelectedIDPConfigID: "IDPConfigID", LoginPolicy: &domain.LoginPolicy{ + AllowUsernamePassword: false, SecondFactorCheckLifetime: 18 * time.Hour, }}, false}, []domain.NextStep{&domain.ExternalLoginStep{SelectedIDPConfigID: "IDPConfigID"}}, @@ -1097,6 +1174,7 @@ func TestAuthRequestRepo_nextSteps(t *testing.T) { args{&domain.AuthRequest{ UserID: "UserID", LoginPolicy: &domain.LoginPolicy{ + AllowUsernamePassword: false, SecondFactorCheckLifetime: 18 * time.Hour, }}, false}, []domain.NextStep{&domain.ExternalLoginStep{SelectedIDPConfigID: "IDPConfigID"}}, @@ -1131,6 +1209,7 @@ func TestAuthRequestRepo_nextSteps(t *testing.T) { SelectedIDPConfigID: "IDPConfigID", Request: &domain.AuthRequestOIDC{}, LoginPolicy: &domain.LoginPolicy{ + AllowUsernamePassword: false, ExternalLoginCheckLifetime: 10 * 24 * time.Hour, SecondFactorCheckLifetime: 18 * time.Hour, }, @@ -1160,7 +1239,15 @@ func TestAuthRequestRepo_nextSteps(t *testing.T) { }, idpUserLinksProvider: &mockIDPUserLinks{}, }, - args{&domain.AuthRequest{UserID: "UserID", LoginPolicy: &domain.LoginPolicy{}}, false}, + args{ + &domain.AuthRequest{ + UserID: "UserID", + LoginPolicy: &domain.LoginPolicy{ + AllowUsernamePassword: true, + }, + }, + false, + }, []domain.NextStep{&domain.PasswordStep{}}, nil, }, @@ -1194,6 +1281,7 @@ func TestAuthRequestRepo_nextSteps(t *testing.T) { SelectedIDPConfigID: "IDPConfigID", Request: &domain.AuthRequestOIDC{}, LoginPolicy: &domain.LoginPolicy{ + AllowUsernamePassword: true, SecondFactorCheckLifetime: 18 * time.Hour, ExternalLoginCheckLifetime: 10 * 24 * time.Hour, }, @@ -1226,6 +1314,7 @@ func TestAuthRequestRepo_nextSteps(t *testing.T) { &domain.AuthRequest{ UserID: "UserID", LoginPolicy: &domain.LoginPolicy{ + AllowUsernamePassword: true, SecondFactors: []domain.SecondFactorType{domain.SecondFactorTypeTOTP}, PasswordCheckLifetime: 10 * 24 * time.Hour, SecondFactorCheckLifetime: 18 * time.Hour, @@ -1260,6 +1349,7 @@ func TestAuthRequestRepo_nextSteps(t *testing.T) { &domain.AuthRequest{ UserID: "UserID", LoginPolicy: &domain.LoginPolicy{ + AllowUsernamePassword: true, SecondFactors: []domain.SecondFactorType{domain.SecondFactorTypeTOTP}, PasswordCheckLifetime: 10 * 24 * time.Hour, SecondFactorCheckLifetime: 18 * time.Hour, @@ -1296,6 +1386,7 @@ func TestAuthRequestRepo_nextSteps(t *testing.T) { UserID: "UserID", SelectedIDPConfigID: "IDPConfigID", LoginPolicy: &domain.LoginPolicy{ + AllowUsernamePassword: true, SecondFactors: []domain.SecondFactorType{domain.SecondFactorTypeTOTP}, PasswordCheckLifetime: 10 * 24 * time.Hour, ExternalLoginCheckLifetime: 10 * 24 * time.Hour, @@ -1333,6 +1424,7 @@ func TestAuthRequestRepo_nextSteps(t *testing.T) { &domain.AuthRequest{ UserID: "UserID", LoginPolicy: &domain.LoginPolicy{ + AllowUsernamePassword: true, SecondFactors: []domain.SecondFactorType{domain.SecondFactorTypeTOTP}, PasswordCheckLifetime: 10 * 24 * time.Hour, SecondFactorCheckLifetime: 18 * time.Hour, @@ -1364,6 +1456,7 @@ func TestAuthRequestRepo_nextSteps(t *testing.T) { args{&domain.AuthRequest{ UserID: "UserID", LoginPolicy: &domain.LoginPolicy{ + AllowUsernamePassword: true, SecondFactors: []domain.SecondFactorType{domain.SecondFactorTypeTOTP}, PasswordCheckLifetime: 10 * 24 * time.Hour, SecondFactorCheckLifetime: 18 * time.Hour, @@ -1396,6 +1489,7 @@ func TestAuthRequestRepo_nextSteps(t *testing.T) { args{&domain.AuthRequest{ UserID: "UserID", LoginPolicy: &domain.LoginPolicy{ + AllowUsernamePassword: true, SecondFactors: []domain.SecondFactorType{domain.SecondFactorTypeTOTP}, PasswordCheckLifetime: 10 * 24 * time.Hour, SecondFactorCheckLifetime: 18 * time.Hour, @@ -1435,6 +1529,7 @@ func TestAuthRequestRepo_nextSteps(t *testing.T) { args{&domain.AuthRequest{ UserID: "UserID", LoginPolicy: &domain.LoginPolicy{ + AllowUsernamePassword: true, SecondFactors: []domain.SecondFactorType{domain.SecondFactorTypeTOTP}, PasswordCheckLifetime: 10 * 24 * time.Hour, SecondFactorCheckLifetime: 18 * time.Hour, @@ -1474,6 +1569,7 @@ func TestAuthRequestRepo_nextSteps(t *testing.T) { UserID: "UserID", Request: &domain.AuthRequestOIDC{}, LoginPolicy: &domain.LoginPolicy{ + AllowUsernamePassword: true, SecondFactors: []domain.SecondFactorType{domain.SecondFactorTypeTOTP}, PasswordCheckLifetime: 10 * 24 * time.Hour, SecondFactorCheckLifetime: 18 * time.Hour, @@ -1511,6 +1607,7 @@ func TestAuthRequestRepo_nextSteps(t *testing.T) { Prompt: []domain.Prompt{domain.PromptNone}, Request: &domain.AuthRequestOIDC{}, LoginPolicy: &domain.LoginPolicy{ + AllowUsernamePassword: true, SecondFactors: []domain.SecondFactorType{domain.SecondFactorTypeTOTP}, PasswordCheckLifetime: 10 * 24 * time.Hour, SecondFactorCheckLifetime: 18 * time.Hour, @@ -1548,6 +1645,7 @@ func TestAuthRequestRepo_nextSteps(t *testing.T) { Prompt: []domain.Prompt{domain.PromptNone}, Request: &domain.AuthRequestOIDC{}, LoginPolicy: &domain.LoginPolicy{ + AllowUsernamePassword: true, SecondFactors: []domain.SecondFactorType{domain.SecondFactorTypeTOTP}, PasswordCheckLifetime: 10 * 24 * time.Hour, SecondFactorCheckLifetime: 18 * time.Hour, @@ -1587,6 +1685,7 @@ func TestAuthRequestRepo_nextSteps(t *testing.T) { Prompt: []domain.Prompt{domain.PromptNone}, Request: &domain.AuthRequestOIDC{}, LoginPolicy: &domain.LoginPolicy{ + AllowUsernamePassword: true, SecondFactors: []domain.SecondFactorType{domain.SecondFactorTypeTOTP}, PasswordCheckLifetime: 10 * 24 * time.Hour, SecondFactorCheckLifetime: 18 * time.Hour, @@ -1627,6 +1726,7 @@ func TestAuthRequestRepo_nextSteps(t *testing.T) { Prompt: []domain.Prompt{domain.PromptNone}, Request: &domain.AuthRequestOIDC{}, LoginPolicy: &domain.LoginPolicy{ + AllowUsernamePassword: true, SecondFactors: []domain.SecondFactorType{domain.SecondFactorTypeTOTP}, PasswordCheckLifetime: 10 * 24 * time.Hour, SecondFactorCheckLifetime: 18 * time.Hour, @@ -1667,6 +1767,7 @@ func TestAuthRequestRepo_nextSteps(t *testing.T) { Prompt: []domain.Prompt{domain.PromptNone}, Request: &domain.AuthRequestOIDC{}, LoginPolicy: &domain.LoginPolicy{ + AllowUsernamePassword: true, SecondFactors: []domain.SecondFactorType{domain.SecondFactorTypeTOTP}, PasswordCheckLifetime: 10 * 24 * time.Hour, SecondFactorCheckLifetime: 18 * time.Hour, @@ -1708,6 +1809,7 @@ func TestAuthRequestRepo_nextSteps(t *testing.T) { Prompt: []domain.Prompt{domain.PromptNone}, Request: &domain.AuthRequestOIDC{}, LoginPolicy: &domain.LoginPolicy{ + AllowUsernamePassword: true, SecondFactors: []domain.SecondFactorType{domain.SecondFactorTypeTOTP}, PasswordCheckLifetime: 10 * 24 * time.Hour, SecondFactorCheckLifetime: 18 * time.Hour, From 622a176be40162251501c0e98cc0c4532117cf6f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20M=C3=B6hlmann?= Date: Tue, 10 Sep 2024 14:15:25 +0300 Subject: [PATCH 11/33] fix(tests): check eventual web key state (#8587) # Which Problems Are Solved Deal with eventual consistency in the webkey integration tests. # How the Problems Are Solved Use an eventual with T for the list state check. # Additional Changes - none # Additional Context - none --------- Co-authored-by: Livio Spring --- .../webkey_integration_test.go | 62 ++++++++++--------- 1 file changed, 32 insertions(+), 30 deletions(-) diff --git a/internal/api/grpc/resources/webkey/v3/integration_test/webkey_integration_test.go b/internal/api/grpc/resources/webkey/v3/integration_test/webkey_integration_test.go index 4df94d1286..a545f1fb06 100644 --- a/internal/api/grpc/resources/webkey/v3/integration_test/webkey_integration_test.go +++ b/internal/api/grpc/resources/webkey/v3/integration_test/webkey_integration_test.go @@ -191,13 +191,13 @@ func createInstance(t *testing.T, enableFeature bool) (*integration.Instance, co }) require.NoError(t, err) } - assert.EventuallyWithT(t, func(ttt *assert.CollectT) { + assert.EventuallyWithT(t, func(collect *assert.CollectT) { resp, err := instance.Client.WebKeyV3Alpha.ListWebKeys(iamCTX, &webkey.ListWebKeysRequest{}) if enableFeature { - assert.NoError(ttt, err) - assert.Len(t, resp.GetWebKeys(), 2) + assert.NoError(collect, err) + assert.Len(collect, resp.GetWebKeys(), 2) } else { - assert.Error(t, err) + assert.Error(collect, err) } }, time.Minute, time.Second) @@ -213,33 +213,35 @@ func assertFeatureDisabledError(t *testing.T, err error) { } func checkWebKeyListState(ctx context.Context, t *testing.T, instance *integration.Instance, nKeys int, expectActiveKeyID string, config any) { - resp, err := instance.Client.WebKeyV3Alpha.ListWebKeys(ctx, &webkey.ListWebKeysRequest{}) - require.NoError(t, err) - list := resp.GetWebKeys() - require.Len(t, list, nKeys) + assert.EventuallyWithT(t, func(collect *assert.CollectT) { + resp, err := instance.Client.WebKeyV3Alpha.ListWebKeys(ctx, &webkey.ListWebKeysRequest{}) + require.NoError(collect, err) + list := resp.GetWebKeys() + assert.Len(collect, list, nKeys) - now := time.Now() - var gotActiveKeyID string - for _, key := range list { - integration.AssertResourceDetails(t, &resource_object.Details{ - Created: timestamppb.Now(), - Changed: timestamppb.Now(), - Owner: &object.Owner{ - Type: object.OwnerType_OWNER_TYPE_INSTANCE, - Id: instance.ID(), - }, - }, key.GetDetails()) - assert.WithinRange(t, key.GetDetails().GetChanged().AsTime(), now.Add(-time.Minute), now.Add(time.Minute)) - assert.NotEqual(t, webkey.WebKeyState_STATE_UNSPECIFIED, key.GetState()) - assert.NotEqual(t, webkey.WebKeyState_STATE_REMOVED, key.GetState()) - assert.Equal(t, config, key.GetConfig().GetConfig()) + now := time.Now() + var gotActiveKeyID string + for _, key := range list { + integration.AssertResourceDetails(t, &resource_object.Details{ + Created: timestamppb.Now(), + Changed: timestamppb.Now(), + Owner: &object.Owner{ + Type: object.OwnerType_OWNER_TYPE_INSTANCE, + Id: instance.ID(), + }, + }, key.GetDetails()) + assert.WithinRange(collect, key.GetDetails().GetChanged().AsTime(), now.Add(-time.Minute), now.Add(time.Minute)) + assert.NotEqual(collect, webkey.WebKeyState_STATE_UNSPECIFIED, key.GetState()) + assert.NotEqual(collect, webkey.WebKeyState_STATE_REMOVED, key.GetState()) + assert.Equal(collect, config, key.GetConfig().GetConfig()) - if key.GetState() == webkey.WebKeyState_STATE_ACTIVE { - gotActiveKeyID = key.GetDetails().GetId() + if key.GetState() == webkey.WebKeyState_STATE_ACTIVE { + gotActiveKeyID = key.GetDetails().GetId() + } } - } - assert.NotEmpty(t, gotActiveKeyID) - if expectActiveKeyID != "" { - assert.Equal(t, expectActiveKeyID, gotActiveKeyID) - } + assert.NotEmpty(collect, gotActiveKeyID) + if expectActiveKeyID != "" { + assert.Equal(collect, expectActiveKeyID, gotActiveKeyID) + } + }, time.Minute, time.Second) } From 328c409271d3893d2cf752ba756f288dde056766 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20M=C3=B6hlmann?= Date: Wed, 11 Sep 2024 07:45:59 +0300 Subject: [PATCH 12/33] fix(oidc): roles in service user ID token (#8561) # Which Problems Are Solved Return the user's project roles when the `urn:zitadel:iam:org:projects:roles` scope is requested. We alreayd returned it for access tokens, now also ID tokens. # How the Problems Are Solved Set `idTokenRoleAssertion` to `true` when calling `accessTokenResponseFromSession` for service users. This parameter is normally set to the client config. However, service user authentication does not have a client. # Additional Changes - none # Additional Context - Introduced in https://github.com/zitadel/zitadel/pull/8046 - Closes https://github.com/zitadel/zitadel/issues/8107 Co-authored-by: Livio Spring --- internal/api/oidc/token_client_credentials.go | 2 +- internal/api/oidc/token_jwt_profile.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/api/oidc/token_client_credentials.go b/internal/api/oidc/token_client_credentials.go index 2ad429d2b9..459a98f8ad 100644 --- a/internal/api/oidc/token_client_credentials.go +++ b/internal/api/oidc/token_client_credentials.go @@ -51,5 +51,5 @@ func (s *Server) ClientCredentialsExchange(ctx context.Context, r *op.ClientRequ return nil, err } - return response(s.accessTokenResponseFromSession(ctx, client, session, "", "", false, true, false, false)) + return response(s.accessTokenResponseFromSession(ctx, client, session, "", "", false, true, true, false)) } diff --git a/internal/api/oidc/token_jwt_profile.go b/internal/api/oidc/token_jwt_profile.go index 92d70d33d6..d3c32604c3 100644 --- a/internal/api/oidc/token_jwt_profile.go +++ b/internal/api/oidc/token_jwt_profile.go @@ -58,7 +58,7 @@ func (s *Server) JWTProfile(ctx context.Context, r *op.Request[oidc.JWTProfileGr if err != nil { return nil, err } - return response(s.accessTokenResponseFromSession(ctx, client, session, "", "", false, true, false, false)) + return response(s.accessTokenResponseFromSession(ctx, client, session, "", "", false, true, true, false)) } func (s *Server) verifyJWTProfile(ctx context.Context, req *oidc.JWTProfileGrantRequest) (user *query.User, tokenRequest *oidc.JWTTokenRequest, err error) { From 762efd0a2056c16c8ed393696bf6df90c47c836f Mon Sep 17 00:00:00 2001 From: Mark Stosberg Date: Wed, 11 Sep 2024 02:51:01 -0400 Subject: [PATCH 13/33] docs: some English spelling and grammar fixes (#8592) - English typo and grammar fixes. Co-authored-by: Fabi --- console/src/assets/i18n/en.json | 6 +++--- internal/api/ui/login/static/i18n/en.yaml | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/console/src/assets/i18n/en.json b/console/src/assets/i18n/en.json index 5270266742..8d59aab444 100644 --- a/console/src/assets/i18n/en.json +++ b/console/src/assets/i18n/en.json @@ -37,7 +37,7 @@ "DESCRIPTION": "A project hosts one or more applications, that you can use to authenticate your users. Also you can authorize your users with projects. To allow users from other organizations to log in to your applications, grant them access to your project.

If you can't find a project, contact the projects owner or someone with the corresponding rights to gain access.", "OWNED": { "TITLE": "Owned Projects", - "DESCRIPTION": "These are the projects you own. You can manage theses projects settings, authorizations and applications." + "DESCRIPTION": "These are the projects you own. You can manage these project settings, authorizations and applications." }, "GRANTED": { "TITLE": "Granted Projects", @@ -235,11 +235,11 @@ }, "EXT_LOGIN_CHECK": { "TITLE": "External Login Check", - "DESCRIPTION": "Your users are redirected to their external identity proviers in theses periods." + "DESCRIPTION": "Your users are redirected to their external identity providers after this periods." }, "MULTI_FACTOR_INIT": { "TITLE": "Multifactor Init Check", - "DESCRIPTION": "Your users will be prompted to set up a second factor or a Multifactor in these periods, if they haven't done so already. A lifetime of 0 deactivates this prompt." + "DESCRIPTION": "Your users will be prompted to set up a second factor or a Multifactor after this periods if they haven't done so already. A lifetime of 0 deactivates this prompt." }, "SECOND_FACTOR_CHECK": { "TITLE": "Second Factor Check", diff --git a/internal/api/ui/login/static/i18n/en.yaml b/internal/api/ui/login/static/i18n/en.yaml index b2c0a98489..7c4cfdb993 100644 --- a/internal/api/ui/login/static/i18n/en.yaml +++ b/internal/api/ui/login/static/i18n/en.yaml @@ -346,7 +346,7 @@ LinkingUsersDone: ExternalNotFound: Title: External User Not Found - Description: External user not found. Do you want to link your user or auto register a new one. + Description: External user not found. Do you want to link your user or auto-register a new one? LinkButtonText: Link AutoRegisterButtonText: Register TosAndPrivacyLabel: Terms and conditions From a56950110812820519be749756772cfd2cbc4ddd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20M=C3=B6hlmann?= Date: Wed, 11 Sep 2024 10:45:02 +0300 Subject: [PATCH 14/33] chore(test): set connection lifetimes for postgresql (#8586) # Which Problems Are Solved defaults.yaml only specifies defaults for cockroach. Therefore, options omitted for postgresql are actually set to `0`. This means that the connections timeouts are set to `0` and connections were not reused, resulting in a performance penalty while running the integration tests. # How the Problems Are Solved Set MaxConnLifeTime and MaxConnIdleTime options in postgres # Additional Changes - none # Additional Context - none Co-authored-by: Silvan Co-authored-by: Livio Spring --- internal/integration/config/postgres.yaml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/internal/integration/config/postgres.yaml b/internal/integration/config/postgres.yaml index 9cabb440c0..df1d08d3bc 100644 --- a/internal/integration/config/postgres.yaml +++ b/internal/integration/config/postgres.yaml @@ -7,6 +7,8 @@ Database: Database: zitadel MaxOpenConns: 20 MaxIdleConns: 20 + MaxConnLifetime: 1h + MaxConnIdleTime: 5m User: Username: zitadel SSL: From 3aba942162676c9f9c65d74ffa6f61820e73195e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20M=C3=B6hlmann?= Date: Wed, 11 Sep 2024 11:24:00 +0300 Subject: [PATCH 15/33] feat: add debug events API (#8533) # Which Problems Are Solved Add a debug API which allows pushing a set of events to be reduced in a dedicated projection. The events can carry a sleep duration which simulates a slow query during projection handling. # How the Problems Are Solved - `CreateDebugEvents` allows pushing multiple events which simulate the lifecycle of a resource. Each event has a `projectionSleep` field, which issues a `pg_sleep()` statement query in the projection handler : - Add - Change - Remove - `ListDebugEventsStates` list the current state of the projection, optionally with a Trigger - `GetDebugEventsStateByID` get the current state of the aggregate ID in the projection, optionally with a Trigger # Additional Changes - none # Additional Context - Allows reproduction of https://github.com/zitadel/zitadel/issues/8517 --- cmd/defaults.yaml | 3 + cmd/start/start.go | 4 + .../debug_events/debug_events/debug.go | 51 +++ .../debug_events/debug_converter.go | 63 ++++ .../debug_events/debug_events/server.go | 47 +++ internal/command/debug_events.go | 82 +++++ internal/command/debug_events_model.go | 68 ++++ internal/command/debug_events_test.go | 340 ++++++++++++++++++ internal/domain/debug_events.go | 14 + internal/eventstore/handler/v2/statement.go | 21 ++ internal/query/debug_events.go | 106 ++++++ internal/query/debug_events_state_by_id.sql | 5 + internal/query/debug_events_states.sql | 4 + internal/query/projection/debug_events.go | 146 ++++++++ internal/query/projection/projection.go | 3 + internal/repository/debug_events/aggregate.go | 27 ++ internal/repository/debug_events/debug.go | 125 +++++++ .../repository/debug_events/eventstore.go | 11 + .../v3alpha/debug_events_service.proto | 223 ++++++++++++ .../debug_events/v3alpha/event.proto | 47 +++ .../debug_events/v3alpha/state.proto | 14 + 21 files changed, 1404 insertions(+) create mode 100644 internal/api/grpc/resources/debug_events/debug_events/debug.go create mode 100644 internal/api/grpc/resources/debug_events/debug_events/debug_converter.go create mode 100644 internal/api/grpc/resources/debug_events/debug_events/server.go create mode 100644 internal/command/debug_events.go create mode 100644 internal/command/debug_events_model.go create mode 100644 internal/command/debug_events_test.go create mode 100644 internal/domain/debug_events.go create mode 100644 internal/query/debug_events.go create mode 100644 internal/query/debug_events_state_by_id.sql create mode 100644 internal/query/debug_events_states.sql create mode 100644 internal/query/projection/debug_events.go create mode 100644 internal/repository/debug_events/aggregate.go create mode 100644 internal/repository/debug_events/debug.go create mode 100644 internal/repository/debug_events/eventstore.go create mode 100644 proto/zitadel/resources/debug_events/v3alpha/debug_events_service.proto create mode 100644 proto/zitadel/resources/debug_events/v3alpha/event.proto create mode 100644 proto/zitadel/resources/debug_events/v3alpha/state.proto diff --git a/cmd/defaults.yaml b/cmd/defaults.yaml index 76f2fb7fbb..f36932b02b 100644 --- a/cmd/defaults.yaml +++ b/cmd/defaults.yaml @@ -1033,6 +1033,8 @@ InternalAuthZ: - "iam.web_key.write" - "iam.web_key.delete" - "iam.web_key.read" + - "iam.debug.write" + - "iam.debug.read" - "org.read" - "org.global.read" - "org.create" @@ -1110,6 +1112,7 @@ InternalAuthZ: - "iam.restrictions.read" - "iam.feature.read" - "iam.web_key.read" + - "iam.debug.read" - "org.read" - "org.member.read" - "org.idp.read" diff --git a/cmd/start/start.go b/cmd/start/start.go index 594e16776b..5fc4ba936a 100644 --- a/cmd/start/start.go +++ b/cmd/start/start.go @@ -45,6 +45,7 @@ import ( org_v2 "github.com/zitadel/zitadel/internal/api/grpc/org/v2" org_v2beta "github.com/zitadel/zitadel/internal/api/grpc/org/v2beta" action_v3_alpha "github.com/zitadel/zitadel/internal/api/grpc/resources/action/v3alpha" + "github.com/zitadel/zitadel/internal/api/grpc/resources/debug_events/debug_events" user_v3_alpha "github.com/zitadel/zitadel/internal/api/grpc/resources/user/v3alpha" userschema_v3_alpha "github.com/zitadel/zitadel/internal/api/grpc/resources/userschema/v3alpha" "github.com/zitadel/zitadel/internal/api/grpc/resources/webkey/v3" @@ -459,6 +460,9 @@ func startAPIs( if err := apis.RegisterService(ctx, webkey.CreateServer(commands, queries)); err != nil { return nil, err } + if err := apis.RegisterService(ctx, debug_events.CreateServer(commands, queries)); err != nil { + return nil, err + } instanceInterceptor := middleware.InstanceInterceptor(queries, config.ExternalDomain, login.IgnoreInstanceEndpoints...) assetsCache := middleware.AssetsCacheInterceptor(config.AssetStorage.Cache.MaxAge, config.AssetStorage.Cache.SharedMaxAge) apis.RegisterHandlerOnPrefix(assets.HandlerPrefix, assets.NewHandler(commands, verifier, config.InternalAuthZ, id.SonyFlakeGenerator(), store, queries, middleware.CallDurationHandler, instanceInterceptor.Handler, assetsCache.Handler, limitingAccessInterceptor.Handle)) diff --git a/internal/api/grpc/resources/debug_events/debug_events/debug.go b/internal/api/grpc/resources/debug_events/debug_events/debug.go new file mode 100644 index 0000000000..109165ffbc --- /dev/null +++ b/internal/api/grpc/resources/debug_events/debug_events/debug.go @@ -0,0 +1,51 @@ +package debug_events + +import ( + "context" + + "github.com/zitadel/zitadel/internal/api/authz" + resource_object "github.com/zitadel/zitadel/internal/api/grpc/resources/object/v3alpha" + "github.com/zitadel/zitadel/internal/telemetry/tracing" + object "github.com/zitadel/zitadel/pkg/grpc/object/v3alpha" + debug_events "github.com/zitadel/zitadel/pkg/grpc/resources/debug_events/v3alpha" +) + +func (s *Server) CreateDebugEvents(ctx context.Context, req *debug_events.CreateDebugEventsRequest) (_ *debug_events.CreateDebugEventsResponse, err error) { + ctx, span := tracing.NewSpan(ctx) + defer func() { span.EndWithError(err) }() + + details, err := s.command.CreateDebugEvents(ctx, debugEventsFromRequest(req)) + if err != nil { + return nil, err + } + return &debug_events.CreateDebugEventsResponse{ + Details: resource_object.DomainToDetailsPb(details, object.OwnerType_OWNER_TYPE_INSTANCE, authz.GetInstance(ctx).InstanceID()), + }, nil +} + +func (s *Server) GetDebugEventsStateById(ctx context.Context, req *debug_events.GetDebugEventsStateByIdRequest) (_ *debug_events.GetDebugEventsStateByIdResponse, err error) { + ctx, span := tracing.NewSpan(ctx) + defer func() { span.EndWithError(err) }() + + state, err := s.query.GetDebugEventsStateByID(ctx, req.GetId(), req.GetTriggerBulk()) + if err != nil { + return nil, err + } + + return &debug_events.GetDebugEventsStateByIdResponse{ + State: eventsStateToPB(state), + }, nil +} +func (s *Server) ListDebugEventsStates(ctx context.Context, req *debug_events.ListDebugEventsStatesRequest) (_ *debug_events.ListDebugEventsStatesResponse, err error) { + ctx, span := tracing.NewSpan(ctx) + defer func() { span.EndWithError(err) }() + + states, err := s.query.ListDebugEventsStates(ctx, req.GetTriggerBulk()) + if err != nil { + return nil, err + } + + return &debug_events.ListDebugEventsStatesResponse{ + States: eventStatesToPB(states), + }, nil +} diff --git a/internal/api/grpc/resources/debug_events/debug_events/debug_converter.go b/internal/api/grpc/resources/debug_events/debug_events/debug_converter.go new file mode 100644 index 0000000000..9924d7813b --- /dev/null +++ b/internal/api/grpc/resources/debug_events/debug_events/debug_converter.go @@ -0,0 +1,63 @@ +package debug_events + +import ( + "fmt" + + resource_object "github.com/zitadel/zitadel/internal/api/grpc/resources/object/v3alpha" + "github.com/zitadel/zitadel/internal/command" + "github.com/zitadel/zitadel/internal/query" + object "github.com/zitadel/zitadel/pkg/grpc/object/v3alpha" + debug_events "github.com/zitadel/zitadel/pkg/grpc/resources/debug_events/v3alpha" +) + +func debugEventsFromRequest(req *debug_events.CreateDebugEventsRequest) *command.DebugEvents { + reqEvents := req.GetEvents() + events := make([]command.DebugEvent, len(reqEvents)) + for i, event := range reqEvents { + events[i] = debugEventFromRequest(event) + } + + return &command.DebugEvents{ + AggregateID: req.GetAggregateId(), + Events: events, + } +} + +func debugEventFromRequest(event *debug_events.Event) command.DebugEvent { + switch e := event.Event.(type) { + case *debug_events.Event_Add: + return command.DebugEventAdded{ + ProjectionSleep: e.Add.GetProjectionSleep().AsDuration(), + Blob: e.Add.Blob, + } + + case *debug_events.Event_Change: + return command.DebugEventChanged{ + ProjectionSleep: e.Change.GetProjectionSleep().AsDuration(), + Blob: e.Change.Blob, + } + + case *debug_events.Event_Remove: + return command.DebugEventRemoved{ + ProjectionSleep: e.Remove.GetProjectionSleep().AsDuration(), + } + + default: + panic(fmt.Errorf("invalid debug event type %T", event.Event)) + } +} + +func eventsStateToPB(state *query.DebugEventState) *debug_events.State { + return &debug_events.State{ + Details: resource_object.DomainToDetailsPb(&state.ObjectDetails, object.OwnerType_OWNER_TYPE_INSTANCE, state.ResourceOwner), + Blob: state.Blob, + } +} + +func eventStatesToPB(states []query.DebugEventState) []*debug_events.State { + out := make([]*debug_events.State, len(states)) + for i, state := range states { + out[i] = eventsStateToPB(&state) + } + return out +} diff --git a/internal/api/grpc/resources/debug_events/debug_events/server.go b/internal/api/grpc/resources/debug_events/debug_events/server.go new file mode 100644 index 0000000000..82401f2148 --- /dev/null +++ b/internal/api/grpc/resources/debug_events/debug_events/server.go @@ -0,0 +1,47 @@ +package debug_events + +import ( + "google.golang.org/grpc" + + "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/query" + debug_events "github.com/zitadel/zitadel/pkg/grpc/resources/debug_events/v3alpha" +) + +type Server struct { + debug_events.UnimplementedZITADELDebugEventsServer + command *command.Commands + query *query.Queries +} + +func CreateServer( + command *command.Commands, + query *query.Queries, +) *Server { + return &Server{ + command: command, + query: query, + } +} + +func (s *Server) RegisterServer(grpcServer *grpc.Server) { + debug_events.RegisterZITADELDebugEventsServer(grpcServer, s) +} + +func (s *Server) AppName() string { + return debug_events.ZITADELDebugEvents_ServiceDesc.ServiceName +} + +func (s *Server) MethodPrefix() string { + return debug_events.ZITADELDebugEvents_ServiceDesc.ServiceName +} + +func (s *Server) AuthMethods() authz.MethodMapping { + return debug_events.ZITADELDebugEvents_AuthMethods +} + +func (s *Server) RegisterGateway() server.RegisterGatewayFunc { + return debug_events.RegisterZITADELDebugEventsHandler +} diff --git a/internal/command/debug_events.go b/internal/command/debug_events.go new file mode 100644 index 0000000000..50f87bd2fd --- /dev/null +++ b/internal/command/debug_events.go @@ -0,0 +1,82 @@ +package command + +import ( + "context" + "time" + + "github.com/zitadel/zitadel/internal/api/authz" + "github.com/zitadel/zitadel/internal/domain" + "github.com/zitadel/zitadel/internal/eventstore" + "github.com/zitadel/zitadel/internal/repository/debug_events" + "github.com/zitadel/zitadel/internal/telemetry/tracing" + "github.com/zitadel/zitadel/internal/zerrors" +) + +type DebugEvents struct { + AggregateID string + Events []DebugEvent +} + +type DebugEvent interface { + isADebugEvent() +} + +type DebugEventAdded struct { + ProjectionSleep time.Duration + Blob *string +} + +type DebugEventChanged struct { + ProjectionSleep time.Duration + Blob *string +} + +type DebugEventRemoved struct { + ProjectionSleep time.Duration +} + +func (DebugEventAdded) isADebugEvent() {} +func (DebugEventChanged) isADebugEvent() {} +func (DebugEventRemoved) isADebugEvent() {} + +func (c *Commands) CreateDebugEvents(ctx context.Context, dbe *DebugEvents) (_ *domain.ObjectDetails, err error) { + ctx, span := tracing.NewSpan(ctx) + defer func() { span.EndWithError(err) }() + + model := NewDebugEventsWriteModel(dbe.AggregateID, authz.GetInstance(ctx).InstanceID()) + if err = c.eventstore.FilterToQueryReducer(ctx, model); err != nil { + return nil, err + } + aggr := debug_events.AggregateFromWriteModel(ctx, &model.WriteModel) + + cmds := make([]eventstore.Command, len(dbe.Events)) + for i, event := range dbe.Events { + var cmd eventstore.Command + switch e := event.(type) { + case DebugEventAdded: + if model.State.Exists() { + return nil, zerrors.ThrowAlreadyExists(nil, "COMMAND-Aex6j", "debug aggregate already exists") + } + cmd = debug_events.NewAddedEvent(ctx, aggr, e.ProjectionSleep, e.Blob) + case DebugEventChanged: + if !model.State.Exists() { + return nil, zerrors.ThrowNotFound(nil, "COMMAND-Thie6", "debug aggregate not found") + } + cmd = debug_events.NewChangedEvent(ctx, aggr, e.ProjectionSleep, e.Blob) + case DebugEventRemoved: + if !model.State.Exists() { + return nil, zerrors.ThrowNotFound(nil, "COMMAND-Ohna9", "debug aggregate not found") + } + cmd = debug_events.NewRemovedEvent(ctx, aggr, e.ProjectionSleep) + } + + cmds[i] = cmd + // be sure the state of the last event is reduced before handling the next one. + model.reduceEvent(cmd.(eventstore.Event)) + } + events, err := c.eventstore.Push(ctx, cmds...) + if err != nil { + return nil, err + } + return pushedEventsToObjectDetails(events), nil +} diff --git a/internal/command/debug_events_model.go b/internal/command/debug_events_model.go new file mode 100644 index 0000000000..81d0aca6f9 --- /dev/null +++ b/internal/command/debug_events_model.go @@ -0,0 +1,68 @@ +package command + +import ( + "github.com/zitadel/zitadel/internal/domain" + "github.com/zitadel/zitadel/internal/eventstore" + debug "github.com/zitadel/zitadel/internal/repository/debug_events" +) + +type DebugEventsWriteModel struct { + eventstore.WriteModel + State domain.DebugEventsState + Blob string +} + +func NewDebugEventsWriteModel(aggregateID, resourceOwner string) *DebugEventsWriteModel { + return &DebugEventsWriteModel{ + WriteModel: eventstore.WriteModel{ + AggregateID: aggregateID, + ResourceOwner: resourceOwner, + }, + } +} + +func (wm *DebugEventsWriteModel) AppendEvents(events ...eventstore.Event) { + wm.WriteModel.AppendEvents(events...) +} + +func (wm *DebugEventsWriteModel) Reduce() error { + for _, event := range wm.Events { + wm.reduceEvent(event) + } + return wm.WriteModel.Reduce() +} + +func (wm *DebugEventsWriteModel) reduceEvent(event eventstore.Event) { + if event.Aggregate().ID != wm.AggregateID { + return + } + switch e := event.(type) { + case *debug.AddedEvent: + wm.State = domain.DebugEventsStateInitial + if e.Blob != nil { + wm.Blob = *e.Blob + } + case *debug.ChangedEvent: + wm.State = domain.DebugEventsStateChanged + if e.Blob != nil { + wm.Blob = *e.Blob + } + case *debug.RemovedEvent: + wm.State = domain.DebugEventsStateRemoved + wm.Blob = "" + } +} + +func (wm *DebugEventsWriteModel) Query() *eventstore.SearchQueryBuilder { + return eventstore.NewSearchQueryBuilder(eventstore.ColumnsEvent). + ResourceOwner(wm.ResourceOwner). + AddQuery(). + AggregateTypes(debug.AggregateType). + AggregateIDs(wm.AggregateID). + EventTypes( + debug.AddedEventType, + debug.ChangedEventType, + debug.RemovedEventType, + ). + Builder() +} diff --git a/internal/command/debug_events_test.go b/internal/command/debug_events_test.go new file mode 100644 index 0000000000..e740de8574 --- /dev/null +++ b/internal/command/debug_events_test.go @@ -0,0 +1,340 @@ +package command + +import ( + "io" + "testing" + "time" + + "github.com/muhlemmer/gu" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/zitadel/zitadel/internal/api/authz" + "github.com/zitadel/zitadel/internal/domain" + "github.com/zitadel/zitadel/internal/eventstore" + "github.com/zitadel/zitadel/internal/repository/debug_events" + "github.com/zitadel/zitadel/internal/zerrors" +) + +func TestCommands_CreateDebugEvents(t *testing.T) { + ctx := authz.NewMockContextWithPermissions("instance1", "org1", "user1", nil) + type fields struct { + eventstore func(*testing.T) *eventstore.Eventstore + } + type args struct { + dbe *DebugEvents + } + tests := []struct { + name string + fields fields + args args + want *domain.ObjectDetails + wantErr error + }{ + { + name: "filter error", + fields: fields{ + eventstore: expectEventstore( + expectFilterError(io.ErrClosedPipe), + ), + }, + args: args{&DebugEvents{ + AggregateID: "dbg1", + Events: []DebugEvent{ + DebugEventAdded{ + ProjectionSleep: time.Millisecond, + Blob: gu.Ptr("a"), + }, + }, + }}, + wantErr: io.ErrClosedPipe, + }, + { + name: "already exists", + fields: fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + debug_events.NewAddedEvent( + ctx, debug_events.NewAggregate("dbg1", "instance1"), + time.Millisecond, gu.Ptr("a"), + ), + ), + ), + ), + }, + args: args{&DebugEvents{ + AggregateID: "dbg1", + Events: []DebugEvent{ + DebugEventAdded{ + ProjectionSleep: time.Millisecond, + Blob: gu.Ptr("a"), + }, + }, + }}, + wantErr: zerrors.ThrowAlreadyExists(nil, "COMMAND-Aex6j", "debug aggregate already exists"), + }, + { + name: "double added event, already exists", + fields: fields{ + eventstore: expectEventstore( + expectFilter(), + ), + }, + args: args{&DebugEvents{ + AggregateID: "dbg1", + Events: []DebugEvent{ + DebugEventAdded{ + ProjectionSleep: time.Millisecond, + Blob: gu.Ptr("a"), + }, + DebugEventAdded{ + ProjectionSleep: time.Millisecond, + Blob: gu.Ptr("a"), + }, + }, + }}, + wantErr: zerrors.ThrowAlreadyExists(nil, "COMMAND-Aex6j", "debug aggregate already exists"), + }, + { + name: "changed event, not found", + fields: fields{ + eventstore: expectEventstore( + expectFilter(), + ), + }, + args: args{&DebugEvents{ + AggregateID: "dbg1", + Events: []DebugEvent{ + DebugEventChanged{ + ProjectionSleep: time.Millisecond, + Blob: gu.Ptr("a"), + }, + }, + }}, + wantErr: zerrors.ThrowNotFound(nil, "COMMAND-Thie6", "debug aggregate not found"), + }, + { + name: "removed event, not found", + fields: fields{ + eventstore: expectEventstore( + expectFilter(), + ), + }, + args: args{&DebugEvents{ + AggregateID: "dbg1", + Events: []DebugEvent{ + DebugEventRemoved{ + ProjectionSleep: time.Millisecond, + }, + }, + }}, + wantErr: zerrors.ThrowNotFound(nil, "COMMAND-Ohna9", "debug aggregate not found"), + }, + { + name: "changed after removed event, not found", + fields: fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + debug_events.NewAddedEvent( + ctx, debug_events.NewAggregate("dbg1", "instance1"), + time.Millisecond, gu.Ptr("a"), + ), + ), + ), + ), + }, + args: args{&DebugEvents{ + AggregateID: "dbg1", + Events: []DebugEvent{ + DebugEventRemoved{ + ProjectionSleep: time.Millisecond, + }, + DebugEventChanged{ + ProjectionSleep: time.Millisecond, + Blob: gu.Ptr("a"), + }, + }, + }}, + wantErr: zerrors.ThrowNotFound(nil, "COMMAND-Thie6", "debug aggregate not found"), + }, + { + name: "double removed event, not found", + fields: fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + debug_events.NewAddedEvent( + ctx, debug_events.NewAggregate("dbg1", "instance1"), + time.Millisecond, gu.Ptr("a"), + ), + ), + ), + ), + }, + args: args{&DebugEvents{ + AggregateID: "dbg1", + Events: []DebugEvent{ + DebugEventRemoved{ + ProjectionSleep: time.Millisecond, + }, + DebugEventRemoved{ + ProjectionSleep: time.Millisecond, + }, + }, + }}, + wantErr: zerrors.ThrowNotFound(nil, "COMMAND-Ohna9", "debug aggregate not found"), + }, + { + name: "added, ok", + fields: fields{ + eventstore: expectEventstore( + expectFilter(), + expectPush( + debug_events.NewAddedEvent( + ctx, debug_events.NewAggregate("dbg1", "instance1"), + time.Millisecond, gu.Ptr("a"), + ), + ), + ), + }, + args: args{&DebugEvents{ + AggregateID: "dbg1", + Events: []DebugEvent{ + DebugEventAdded{ + ProjectionSleep: time.Millisecond, + Blob: gu.Ptr("a"), + }, + }, + }}, + want: &domain.ObjectDetails{ + ResourceOwner: "instance1", + }, + }, + { + name: "changed, ok", + fields: fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + debug_events.NewAddedEvent( + ctx, debug_events.NewAggregate("dbg1", "instance1"), + time.Millisecond, gu.Ptr("a"), + ), + ), + ), + expectPush( + debug_events.NewChangedEvent( + ctx, debug_events.NewAggregate("dbg1", "instance1"), + time.Millisecond, gu.Ptr("b"), + ), + ), + ), + }, + args: args{&DebugEvents{ + AggregateID: "dbg1", + Events: []DebugEvent{ + DebugEventChanged{ + ProjectionSleep: time.Millisecond, + Blob: gu.Ptr("b"), + }, + }, + }}, + want: &domain.ObjectDetails{ + ResourceOwner: "instance1", + }, + }, + { + name: "removed, ok", + fields: fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + debug_events.NewAddedEvent( + ctx, debug_events.NewAggregate("dbg1", "instance1"), + time.Millisecond, gu.Ptr("a"), + ), + ), + ), + expectPush( + debug_events.NewRemovedEvent( + ctx, debug_events.NewAggregate("dbg1", "instance1"), + time.Millisecond, + ), + ), + ), + }, + args: args{&DebugEvents{ + AggregateID: "dbg1", + Events: []DebugEvent{ + DebugEventRemoved{ + ProjectionSleep: time.Millisecond, + }, + }, + }}, + want: &domain.ObjectDetails{ + ResourceOwner: "instance1", + }, + }, + { + name: "added, changed, changed, removed ok", + fields: fields{ + eventstore: expectEventstore( + expectFilter(), + expectPush( + debug_events.NewAddedEvent( + ctx, debug_events.NewAggregate("dbg1", "instance1"), + time.Millisecond, gu.Ptr("a"), + ), + debug_events.NewChangedEvent( + ctx, debug_events.NewAggregate("dbg1", "instance1"), + time.Millisecond, gu.Ptr("b"), + ), + debug_events.NewChangedEvent( + ctx, debug_events.NewAggregate("dbg1", "instance1"), + time.Millisecond, gu.Ptr("c"), + ), + debug_events.NewRemovedEvent( + ctx, debug_events.NewAggregate("dbg1", "instance1"), + time.Millisecond, + ), + ), + ), + }, + args: args{&DebugEvents{ + AggregateID: "dbg1", + Events: []DebugEvent{ + DebugEventAdded{ + ProjectionSleep: time.Millisecond, + Blob: gu.Ptr("a"), + }, + DebugEventChanged{ + ProjectionSleep: time.Millisecond, + Blob: gu.Ptr("b"), + }, + DebugEventChanged{ + ProjectionSleep: time.Millisecond, + Blob: gu.Ptr("c"), + }, + DebugEventRemoved{ + ProjectionSleep: time.Millisecond, + }, + }, + }}, + want: &domain.ObjectDetails{ + ResourceOwner: "instance1", + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + c := &Commands{ + eventstore: tt.fields.eventstore(t), + } + got, err := c.CreateDebugEvents(ctx, tt.args.dbe) + require.ErrorIs(t, err, tt.wantErr) + assert.Equal(t, tt.want, got) + }) + } +} diff --git a/internal/domain/debug_events.go b/internal/domain/debug_events.go new file mode 100644 index 0000000000..627c4e54b2 --- /dev/null +++ b/internal/domain/debug_events.go @@ -0,0 +1,14 @@ +package domain + +type DebugEventsState int + +const ( + DebugEventsStateUnspecified DebugEventsState = iota + DebugEventsStateInitial + DebugEventsStateChanged + DebugEventsStateRemoved +) + +func (state DebugEventsState) Exists() bool { + return state == DebugEventsStateInitial || state == DebugEventsStateChanged +} diff --git a/internal/eventstore/handler/v2/statement.go b/internal/eventstore/handler/v2/statement.go index 273789f015..4bc660d9f9 100644 --- a/internal/eventstore/handler/v2/statement.go +++ b/internal/eventstore/handler/v2/statement.go @@ -338,6 +338,21 @@ func NewNoOpStatement(event eventstore.Event) *Statement { return NewStatement(event, nil) } +func NewSleepStatement(event eventstore.Event, d time.Duration, opts ...execOption) *Statement { + return NewStatement( + event, + exec( + execConfig{ + args: []any{float64(d) / float64(time.Second)}, + }, + func(_ execConfig) string { + return "SELECT pg_sleep($1);" + }, + opts, + ), + ) +} + func NewMultiStatement(event eventstore.Event, opts ...func(eventstore.Event) Exec) *Statement { if len(opts) == 0 { return NewNoOpStatement(event) @@ -385,6 +400,12 @@ func AddCopyStatement(conflict, from, to []Column, conditions []NamespacedCondit } } +func AddSleepStatement(d time.Duration, opts ...execOption) func(eventstore.Event) Exec { + return func(event eventstore.Event) Exec { + return NewSleepStatement(event, d, opts...).Execute + } +} + func NewArrayAppendCol(column string, value interface{}) Column { return Column{ Name: column, diff --git a/internal/query/debug_events.go b/internal/query/debug_events.go new file mode 100644 index 0000000000..cc3e6e6cd6 --- /dev/null +++ b/internal/query/debug_events.go @@ -0,0 +1,106 @@ +package query + +import ( + "context" + "database/sql" + _ "embed" + "errors" + + "github.com/zitadel/zitadel/internal/api/authz" + "github.com/zitadel/zitadel/internal/domain" + "github.com/zitadel/zitadel/internal/eventstore/handler/v2" + "github.com/zitadel/zitadel/internal/query/projection" + "github.com/zitadel/zitadel/internal/telemetry/tracing" + "github.com/zitadel/zitadel/internal/zerrors" +) + +type DebugEventState struct { + domain.ObjectDetails + Blob string +} + +var ( + //go:embed debug_events_state_by_id.sql + debugEventsStateByIdQuery string + //go:embed debug_events_states.sql + debugEventsStatesQuery string +) + +func (q *Queries) GetDebugEventsStateByID(ctx context.Context, id string, triggerBulk bool) (_ *DebugEventState, err error) { + ctx, span := tracing.NewSpan(ctx) + defer func() { span.EndWithError(err) }() + ctx, err = triggerDebugEventsProjection(ctx, triggerBulk) + if err != nil { + return nil, err + } + + dst := new(DebugEventState) + err = q.client.QueryRowContext(ctx, + func(row *sql.Row) error { + return row.Scan( + &dst.ID, + &dst.CreationDate, + &dst.EventDate, + &dst.ResourceOwner, + &dst.Sequence, + &dst.Blob, + ) + }, + debugEventsStateByIdQuery, + authz.GetInstance(ctx).InstanceID(), + id, + ) + if errors.Is(err, sql.ErrNoRows) { + return nil, zerrors.ThrowNotFound(err, "QUERY-Eeth5", "debug event state not found") + } + if err != nil { + return nil, zerrors.ThrowInternal(err, "QUERY-oe0Ae", "Errors.Internal") + } + return dst, err +} + +func (q *Queries) ListDebugEventsStates(ctx context.Context, triggerBulk bool) (out []DebugEventState, err error) { + ctx, span := tracing.NewSpan(ctx) + defer func() { span.EndWithError(err) }() + ctx, err = triggerDebugEventsProjection(ctx, triggerBulk) + if err != nil { + return nil, err + } + + err = q.client.QueryContext(ctx, + func(rows *sql.Rows) error { + for rows.Next() { + var dst DebugEventState + err := rows.Scan( + &dst.ID, + &dst.CreationDate, + &dst.EventDate, + &dst.ResourceOwner, + &dst.Sequence, + &dst.Blob, + ) + if err != nil { + return err + } + out = append(out, dst) + } + return rows.Err() + }, + debugEventsStatesQuery, + authz.GetInstance(ctx).InstanceID(), + ) + + if err != nil { + return nil, zerrors.ThrowInternal(err, "QUERY-nooZ2", "Errors.Internal") + } + return out, nil +} + +func triggerDebugEventsProjection(ctx context.Context, trigger bool) (_ context.Context, err error) { + if trigger { + ctx, span := tracing.NewSpan(ctx) + defer func() { span.EndWithError(err) }() + return projection.DebugEventsProjection.Trigger(ctx, handler.WithAwaitRunning()) + } + return ctx, nil +} diff --git a/internal/query/debug_events_state_by_id.sql b/internal/query/debug_events_state_by_id.sql new file mode 100644 index 0000000000..a91f38f9d1 --- /dev/null +++ b/internal/query/debug_events_state_by_id.sql @@ -0,0 +1,5 @@ +select id, creation_date, change_date, resource_owner, sequence, blob +from projections.debug_events +where instance_id = $1 +and id = $2 +limit 1; diff --git a/internal/query/debug_events_states.sql b/internal/query/debug_events_states.sql new file mode 100644 index 0000000000..ec3e66bb89 --- /dev/null +++ b/internal/query/debug_events_states.sql @@ -0,0 +1,4 @@ +select id, creation_date, change_date, resource_owner, sequence, blob +from projections.debug_events +where instance_id = $1 +order by creation_date asc; diff --git a/internal/query/projection/debug_events.go b/internal/query/projection/debug_events.go new file mode 100644 index 0000000000..687bcc2c95 --- /dev/null +++ b/internal/query/projection/debug_events.go @@ -0,0 +1,146 @@ +package projection + +import ( + "context" + + "github.com/muhlemmer/gu" + + "github.com/zitadel/zitadel/internal/eventstore" + old_handler "github.com/zitadel/zitadel/internal/eventstore/handler" + "github.com/zitadel/zitadel/internal/eventstore/handler/v2" + "github.com/zitadel/zitadel/internal/repository/debug_events" + "github.com/zitadel/zitadel/internal/repository/instance" + "github.com/zitadel/zitadel/internal/zerrors" +) + +const ( + DebugEventsProjectionTable = "projections.debug_events" + + DebugEventsColumnID = "id" + DebugEventsColumnCreationDate = "creation_date" + DebugEventsColumnChangeDate = "change_date" + DebugEventsColumnResourceOwner = "resource_owner" + DebugEventsColumnInstanceID = "instance_id" + DebugEventsColumnSequence = "sequence" + DebugEventsColumnBlob = "blob" +) + +type debugEventsProjection struct{} + +func (*debugEventsProjection) Name() string { + return DebugEventsProjectionTable +} + +func newDebugEventsProjection(ctx context.Context, config handler.Config) *handler.Handler { + return handler.NewHandler(ctx, &config, new(debugEventsProjection)) +} + +// Init implements [handler.initializer] +func (p *debugEventsProjection) Init() *old_handler.Check { + return handler.NewTableCheck( + handler.NewTable([]*handler.InitColumn{ + handler.NewColumn(DebugEventsColumnID, handler.ColumnTypeText), + handler.NewColumn(DebugEventsColumnCreationDate, handler.ColumnTypeTimestamp), + handler.NewColumn(DebugEventsColumnChangeDate, handler.ColumnTypeTimestamp), + handler.NewColumn(DebugEventsColumnResourceOwner, handler.ColumnTypeText), + handler.NewColumn(DebugEventsColumnInstanceID, handler.ColumnTypeText), + handler.NewColumn(DebugEventsColumnSequence, handler.ColumnTypeInt64), + handler.NewColumn(DebugEventsColumnBlob, handler.ColumnTypeText), + }, + handler.NewPrimaryKey(DebugEventsColumnInstanceID, DebugEventsColumnID), + ), + ) +} + +func (p *debugEventsProjection) Reducers() []handler.AggregateReducer { + return []handler.AggregateReducer{ + { + Aggregate: debug_events.AggregateType, + EventReducers: []handler.EventReducer{ + { + Event: debug_events.AddedEventType, + Reduce: p.reduceDebugEventAdded, + }, + { + Event: debug_events.ChangedEventType, + Reduce: p.reduceDebugEventChanged, + }, + { + Event: debug_events.RemovedEventType, + Reduce: p.reduceDebugEventRemoved, + }, + }, + }, + { + Aggregate: instance.AggregateType, + EventReducers: []handler.EventReducer{ + { + Event: instance.InstanceRemovedEventType, + Reduce: reduceInstanceRemovedHelper(DebugEventsColumnInstanceID), + }, + }, + }, + } +} + +func (p *debugEventsProjection) reduceDebugEventAdded(event eventstore.Event) (*handler.Statement, error) { + e, ok := event.(*debug_events.AddedEvent) + if !ok { + return nil, zerrors.ThrowInvalidArgumentf(nil, "HANDL-uYq4r", "reduce.wrong.event.type %s", debug_events.AddedEventType) + } + return handler.NewMultiStatement( + e, + handler.AddSleepStatement(e.ProjectionSleep), + handler.AddCreateStatement([]handler.Column{ + handler.NewCol(DebugEventsColumnID, e.Aggregate().ID), + handler.NewCol(DebugEventsColumnCreationDate, e.CreationDate()), + handler.NewCol(DebugEventsColumnChangeDate, e.CreationDate()), + handler.NewCol(DebugEventsColumnResourceOwner, e.Aggregate().ResourceOwner), + handler.NewCol(DebugEventsColumnInstanceID, e.Aggregate().InstanceID), + handler.NewCol(DebugEventsColumnSequence, e.Sequence()), + handler.NewCol(DebugEventsColumnBlob, gu.Value(e.Blob)), + }), + ), nil +} + +func (p *debugEventsProjection) reduceDebugEventChanged(event eventstore.Event) (*handler.Statement, error) { + e, ok := event.(*debug_events.ChangedEvent) + if !ok { + return nil, zerrors.ThrowInvalidArgumentf(nil, "HANDL-Bg8oM", "reduce.wrong.event.type %s", debug_events.ChangedEventType) + } + updateCols := []handler.Column{ + handler.NewCol(DebugEventsColumnChangeDate, e.CreationDate()), + handler.NewCol(DebugEventsColumnSequence, e.Sequence()), + } + if e.Blob != nil { + updateCols = append(updateCols, + handler.NewCol(DebugEventsColumnBlob, *e.Blob), + ) + } + + return handler.NewMultiStatement( + e, + handler.AddSleepStatement(e.ProjectionSleep), + handler.AddUpdateStatement(updateCols, + []handler.Condition{ + handler.NewCond(DebugEventsColumnID, e.Aggregate().ID), + handler.NewCond(DebugEventsColumnInstanceID, e.Aggregate().InstanceID), + }), + ), nil +} + +func (p *debugEventsProjection) reduceDebugEventRemoved(event eventstore.Event) (*handler.Statement, error) { + e, ok := event.(*debug_events.RemovedEvent) + if !ok { + return nil, zerrors.ThrowInvalidArgumentf(nil, "PROJE-DgMSg", "reduce.wrong.event.type %s", debug_events.RemovedEventType) + } + return handler.NewMultiStatement( + e, + handler.AddSleepStatement(e.ProjectionSleep), + handler.AddDeleteStatement( + []handler.Condition{ + handler.NewCond(DebugEventsColumnID, e.Aggregate().ID), + handler.NewCond(DebugEventsColumnInstanceID, e.Aggregate().InstanceID), + }), + ), nil +} diff --git a/internal/query/projection/projection.go b/internal/query/projection/projection.go index 0151a9953b..c4660c6c38 100644 --- a/internal/query/projection/projection.go +++ b/internal/query/projection/projection.go @@ -79,6 +79,7 @@ var ( ExecutionProjection *handler.Handler UserSchemaProjection *handler.Handler WebKeyProjection *handler.Handler + DebugEventsProjection *handler.Handler ProjectGrantFields *handler.FieldHandler OrgDomainVerifiedFields *handler.FieldHandler @@ -165,6 +166,7 @@ func Create(ctx context.Context, sqlClient *database.DB, es handler.EventStore, ExecutionProjection = newExecutionProjection(ctx, applyCustomConfig(projectionConfig, config.Customizations["executions"])) UserSchemaProjection = newUserSchemaProjection(ctx, applyCustomConfig(projectionConfig, config.Customizations["user_schemas"])) WebKeyProjection = newWebKeyProjection(ctx, applyCustomConfig(projectionConfig, config.Customizations["web_keys"])) + DebugEventsProjection = newDebugEventsProjection(ctx, applyCustomConfig(projectionConfig, config.Customizations["debug_events"])) ProjectGrantFields = newFillProjectGrantFields(applyCustomConfig(projectionConfig, config.Customizations[fieldsProjectGrant])) OrgDomainVerifiedFields = newFillOrgDomainVerifiedFields(applyCustomConfig(projectionConfig, config.Customizations[fieldsOrgDomainVerified])) @@ -295,5 +297,6 @@ func newProjectionsList() { ExecutionProjection, UserSchemaProjection, WebKeyProjection, + DebugEventsProjection, } } diff --git a/internal/repository/debug_events/aggregate.go b/internal/repository/debug_events/aggregate.go new file mode 100644 index 0000000000..f2abd17b56 --- /dev/null +++ b/internal/repository/debug_events/aggregate.go @@ -0,0 +1,27 @@ +package debug_events + +import ( + "github.com/zitadel/zitadel/internal/eventstore" +) + +const ( + eventTypePrefix = eventstore.EventType("debug.") +) + +const ( + AggregateType = "debug" + AggregateVersion = "v1" +) + +type Aggregate struct { + eventstore.Aggregate +} + +func NewAggregate(id, resourceOwner string) *eventstore.Aggregate { + return &eventstore.Aggregate{ + Type: AggregateType, + Version: AggregateVersion, + ID: id, + ResourceOwner: resourceOwner, + } +} diff --git a/internal/repository/debug_events/debug.go b/internal/repository/debug_events/debug.go new file mode 100644 index 0000000000..f56d995d6a --- /dev/null +++ b/internal/repository/debug_events/debug.go @@ -0,0 +1,125 @@ +package debug_events + +import ( + "context" + "time" + + "github.com/zitadel/zitadel/internal/eventstore" + "github.com/zitadel/zitadel/internal/zerrors" +) + +const ( + AddedEventType = eventTypePrefix + "added" + ChangedEventType = eventTypePrefix + "changed" + RemovedEventType = eventTypePrefix + "removed" +) + +type AddedEvent struct { + eventstore.BaseEvent `json:"-"` + ProjectionSleep time.Duration `json:"projectionSleep,omitempty"` + Blob *string `json:"blob,omitempty"` +} + +func (e *AddedEvent) Payload() interface{} { + return e +} + +func (e *AddedEvent) UniqueConstraints() []*eventstore.UniqueConstraint { + return nil +} + +func NewAddedEvent(ctx context.Context, aggregate *eventstore.Aggregate, projectionSleep time.Duration, blob *string) *AddedEvent { + return &AddedEvent{ + BaseEvent: *eventstore.NewBaseEventForPush( + ctx, + aggregate, + AddedEventType, + ), + Blob: blob, + ProjectionSleep: projectionSleep, + } +} + +func DebugAddedEventMapper(event eventstore.Event) (eventstore.Event, error) { + debugAdded := &AddedEvent{ + BaseEvent: *eventstore.BaseEventFromRepo(event), + } + err := event.Unmarshal(debugAdded) + if err != nil { + return nil, zerrors.ThrowInternal(err, "ORG-Bren2", "unable to unmarshal debug added") + } + + return debugAdded, nil +} + +type ChangedEvent struct { + eventstore.BaseEvent `json:"-"` + ProjectionSleep time.Duration `json:"projectionSleep,omitempty"` + Blob *string `json:"blob,omitempty"` +} + +func (e *ChangedEvent) Payload() interface{} { + return e +} + +func (e *ChangedEvent) UniqueConstraints() []*eventstore.UniqueConstraint { + return nil +} + +func NewChangedEvent(ctx context.Context, aggregate *eventstore.Aggregate, projectionSleep time.Duration, blob *string) *ChangedEvent { + return &ChangedEvent{ + BaseEvent: *eventstore.NewBaseEventForPush( + ctx, + aggregate, + ChangedEventType, + ), + ProjectionSleep: projectionSleep, + Blob: blob, + } +} + +func DebugChangedEventMapper(event eventstore.Event) (eventstore.Event, error) { + debugChanged := &ChangedEvent{ + BaseEvent: *eventstore.BaseEventFromRepo(event), + } + err := event.Unmarshal(debugChanged) + if err != nil { + return nil, zerrors.ThrowInternal(err, "ORG-Bren2", "unable to unmarshal debug added") + } + + return debugChanged, nil +} + +type RemovedEvent struct { + eventstore.BaseEvent `json:"-"` + ProjectionSleep time.Duration `json:"projectionSleep,omitempty"` +} + +func (e *RemovedEvent) Payload() interface{} { + return nil +} + +func (e *RemovedEvent) UniqueConstraints() []*eventstore.UniqueConstraint { + return nil +} + +func NewRemovedEvent(ctx context.Context, aggregate *eventstore.Aggregate, projectionSleep time.Duration) *RemovedEvent { + return &RemovedEvent{ + BaseEvent: *eventstore.NewBaseEventForPush( + ctx, + aggregate, + RemovedEventType, + ), + ProjectionSleep: projectionSleep, + } +} + +func DebugRemovedEventMapper(event eventstore.Event) (eventstore.Event, error) { + return &RemovedEvent{ + BaseEvent: *eventstore.BaseEventFromRepo(event), + }, nil +} + +func AggregateFromWriteModel(ctx context.Context, wm *eventstore.WriteModel) *eventstore.Aggregate { + return eventstore.AggregateFromWriteModelCtx(ctx, wm, AggregateType, AggregateVersion) +} diff --git a/internal/repository/debug_events/eventstore.go b/internal/repository/debug_events/eventstore.go new file mode 100644 index 0000000000..f774d2140d --- /dev/null +++ b/internal/repository/debug_events/eventstore.go @@ -0,0 +1,11 @@ +package debug_events + +import ( + "github.com/zitadel/zitadel/internal/eventstore" +) + +func init() { + eventstore.RegisterFilterEventMapper(AggregateType, AddedEventType, DebugAddedEventMapper) + eventstore.RegisterFilterEventMapper(AggregateType, ChangedEventType, DebugChangedEventMapper) + eventstore.RegisterFilterEventMapper(AggregateType, RemovedEventType, DebugRemovedEventMapper) +} diff --git a/proto/zitadel/resources/debug_events/v3alpha/debug_events_service.proto b/proto/zitadel/resources/debug_events/v3alpha/debug_events_service.proto new file mode 100644 index 0000000000..6a5990f783 --- /dev/null +++ b/proto/zitadel/resources/debug_events/v3alpha/debug_events_service.proto @@ -0,0 +1,223 @@ +syntax = "proto3"; + +package zitadel.resources.debug_events.v3alpha; + +import "google/api/annotations.proto"; +import "google/api/field_behavior.proto"; +import "protoc-gen-openapiv2/options/annotations.proto"; +import "validate/validate.proto"; + +import "zitadel/protoc_gen_zitadel/v2/options.proto"; +import "zitadel/object/v3alpha/object.proto"; +import "zitadel/resources/object/v3alpha/object.proto"; +import "zitadel/resources/debug_events/v3alpha/event.proto"; +import "zitadel/resources/debug_events/v3alpha/state.proto"; + +option go_package = "github.com/zitadel/zitadel/pkg/grpc/resources/debug_events/v3alpha;debug_events"; + +option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_swagger) = { + info: { + title: "Debug Service"; + version: "3.0-preview"; + description: "This API is intended to push specific debug payload through ZITADEL's storage system."; + contact:{ + name: "ZITADEL" + url: "https://zitadel.com" + email: "hi@zitadel.com" + } + license: { + name: "Apache 2.0", + url: "https://github.com/zitadel/zitadel/blob/main/LICENSE"; + }; + }; + schemes: HTTPS; + schemes: HTTP; + + consumes: "application/json"; + produces: "application/json"; + + consumes: "application/grpc"; + produces: "application/grpc"; + + consumes: "application/grpc-web+proto"; + produces: "application/grpc-web+proto"; + + host: "$CUSTOM-DOMAIN"; + base_path: "/resources/v3alpha/debug"; + + external_docs: { + description: "Detailed information about ZITADEL", + url: "https://zitadel.com/docs" + } + security_definitions: { + security: { + key: "OAuth2"; + value: { + type: TYPE_OAUTH2; + flow: FLOW_ACCESS_CODE; + authorization_url: "$CUSTOM-DOMAIN/oauth/v2/authorize"; + token_url: "$CUSTOM-DOMAIN/oauth/v2/token"; + scopes: { + scope: { + key: "openid"; + value: "openid"; + } + scope: { + key: "urn:zitadel:iam:org:project:id:zitadel:aud"; + value: "urn:zitadel:iam:org:project:id:zitadel:aud"; + } + } + } + } + } + security: { + security_requirement: { + key: "OAuth2"; + value: { + scope: "openid"; + scope: "urn:zitadel:iam:org:project:id:zitadel:aud"; + } + } + } + responses: { + key: "403"; + value: { + description: "Returned when the user does not have permission to access the resource."; + schema: { + json_schema: { + ref: "#/definitions/rpcStatus"; + } + } + } + } + responses: { + key: "404"; + value: { + description: "Returned when the resource does not exist."; + schema: { + json_schema: { + ref: "#/definitions/rpcStatus"; + } + } + } + } +}; + +service ZITADELDebugEvents { + rpc CreateDebugEvents(CreateDebugEventsRequest) returns (CreateDebugEventsResponse) { + option (google.api.http) = { + post: "/" + body: "events" + }; + + option (zitadel.protoc_gen_zitadel.v2.options) = { + auth_option: { + permission: "iam.debug.write" + } + http_response: { + success_code: 201 + } + }; + + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + summary: "Create a set of debug events."; + description: "Create a set of debug events which will be pushed to the eventstore and reduced to the projection." + responses: { + key: "200" + value: { + description: "OK"; + } + }; + }; + } + + rpc GetDebugEventsStateById(GetDebugEventsStateByIdRequest) returns (GetDebugEventsStateByIdResponse) { + option (google.api.http) = { + get: "/v3alpha/debug_events/{id}" + }; + + option (zitadel.protoc_gen_zitadel.v2.options) = { + auth_option: { + permission: "iam.debug.read" + } + }; + + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + responses: { + key: "200" + value: { + description: "Debug events state successfully retrieved"; + } + }; + }; + } + + rpc ListDebugEventsStates(ListDebugEventsStatesRequest) returns (ListDebugEventsStatesResponse) { + option (google.api.http) = { + get: "/v3alpha/debug_events" + }; + + option (zitadel.protoc_gen_zitadel.v2.options) = { + auth_option: { + permission: "iam.debug.read" + } + }; + + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + responses: { + key: "200" + value: { + description: "Debug events states successfully retrieved"; + } + }; + }; + } +} + +message CreateDebugEventsRequest { + optional zitadel.object.v3alpha.Instance instance = 1 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + default: "\"domain from HOST or :authority header\"" + } + ]; + // unique identifier for the aggregate we want to push events to. + string aggregate_id = 2 [ + (google.api.field_behavior) = REQUIRED, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + min_length: 1, + max_length: 200, + example: "\"69629026806489455\""; + } + ]; + repeated Event events = 3; +} + +message CreateDebugEventsResponse { + zitadel.resources.object.v3alpha.Details details = 1; +} + +message GetDebugEventsStateByIdRequest { + // unique identifier of the aggregate. + string id = 1 [ + (validate.rules).string = {min_len: 1, max_len: 200}, + (google.api.field_behavior) = REQUIRED, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + min_length: 1, + max_length: 200, + example: "\"69629026806489455\""; + } + ]; + bool trigger_bulk = 2; +} + +message GetDebugEventsStateByIdResponse { + State state = 1; +} + +message ListDebugEventsStatesRequest { + bool trigger_bulk = 1; +} + +message ListDebugEventsStatesResponse { + repeated State states = 1; +} \ No newline at end of file diff --git a/proto/zitadel/resources/debug_events/v3alpha/event.proto b/proto/zitadel/resources/debug_events/v3alpha/event.proto new file mode 100644 index 0000000000..096c9918bb --- /dev/null +++ b/proto/zitadel/resources/debug_events/v3alpha/event.proto @@ -0,0 +1,47 @@ +syntax = "proto3"; + +package zitadel.resources.debug_events.v3alpha; + +import "google/protobuf/duration.proto"; +import "protoc-gen-openapiv2/options/annotations.proto"; + +option go_package = "github.com/zitadel/zitadel/pkg/grpc/resources/debug_events/v3alpha;debug_events"; + +message Event { + oneof event { + AddedEvent add = 1; + ChangedEvent change = 2; + RemovedEvent remove = 3; + } +} + +message AddedEvent { + // issues a pg_sleep command in the projection reducer, simulating a slow query. + google.protobuf.Duration projection_sleep = 1 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"5s\""; + } + ]; + // optional text that can be set as a state. + optional string blob = 2; +} + +message ChangedEvent { + // issues a pg_sleep command in the projection reducer, simulating a slow query. + google.protobuf.Duration projection_sleep = 1 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"5s\""; + } + ]; + // optional text that can be set as a state. + optional string blob = 2; +} + +message RemovedEvent { + // issues a pg_sleep command in the projection reducer, simulating a slow query. + google.protobuf.Duration projection_sleep = 1 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"5s\""; + } + ]; +} \ No newline at end of file diff --git a/proto/zitadel/resources/debug_events/v3alpha/state.proto b/proto/zitadel/resources/debug_events/v3alpha/state.proto new file mode 100644 index 0000000000..5cdf60d740 --- /dev/null +++ b/proto/zitadel/resources/debug_events/v3alpha/state.proto @@ -0,0 +1,14 @@ +syntax = "proto3"; + +package zitadel.resources.debug_events.v3alpha; + +import "zitadel/resources/object/v3alpha/object.proto"; + +option go_package = "github.com/zitadel/zitadel/pkg/grpc/resources/debug_events/v3alpha;debug_events"; + +message State { + // Details provide some base information (such as the last change date) of the schema. + zitadel.resources.object.v3alpha.Details details = 1; + + string blob = 2; +} \ No newline at end of file From 58a7eb1f268aca6b171af7f78c8ba29b7fcf0eac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20M=C3=B6hlmann?= Date: Wed, 11 Sep 2024 12:04:09 +0300 Subject: [PATCH 16/33] perf(oidc): remove get user by ID from jwt profile grant (#8580) # Which Problems Are Solved Improve performance by removing a GetUserByID call. The call also executed a Trigger on projections, which significantly impacted concurrent requests. # How the Problems Are Solved Token creation needs information from the user, such as the resource owner and access token type. For client credentials this is solved in a single search. By getting the user by username (`client_id`), the user details and secret were obtained in a single query. After that verification and token creation can proceed. For JWT profile it is a bit more complex. We didn't know anything about the user until after JWT verification. The verification did a query for the AuthN key and after that we did a GetUserByID to get remaining details. This change uses a joined query when the OIDC library calls the `GetKeyByIDAndClientID` method on the token storage. The found user details are set to the verifieer object and returned after verification is completed. It is safe because the `jwtProfileKeyStorage` is a single-use object as a wrapper around `query.Queries`. This way getting the public key and user details are obtained in a single query. # Additional Changes - Correctly set the `client_id` field with machine's username. # Additional Context - Related to: https://github.com/zitadel/zitadel/issues/8352 --- internal/api/oidc/client.go | 6 +- internal/api/oidc/client_credentials.go | 18 +++-- internal/api/oidc/token_client_credentials.go | 8 +-- internal/api/oidc/token_jwt_profile.go | 40 ++++++----- internal/query/authn_key.go | 39 +++++++++++ internal/query/authn_key_test.go | 67 +++++++++++++++++++ internal/query/authn_key_user.sql | 11 +++ 7 files changed, 154 insertions(+), 35 deletions(-) create mode 100644 internal/query/authn_key_user.sql diff --git a/internal/api/oidc/client.go b/internal/api/oidc/client.go index 01e8203b51..41fe19cb4a 100644 --- a/internal/api/oidc/client.go +++ b/internal/api/oidc/client.go @@ -1043,11 +1043,11 @@ func (s *Server) verifyClientSecret(ctx context.Context, client *query.OIDCClien return nil } -func (s *Server) checkOrgScopes(ctx context.Context, user *query.User, scopes []string) ([]string, error) { +func (s *Server) checkOrgScopes(ctx context.Context, resourceOwner string, scopes []string) ([]string, error) { if slices.ContainsFunc(scopes, func(scope string) bool { return strings.HasPrefix(scope, domain.OrgDomainPrimaryScope) }) { - org, err := s.query.OrgByID(ctx, false, user.ResourceOwner) + org, err := s.query.OrgByID(ctx, false, resourceOwner) if err != nil { return nil, err } @@ -1060,7 +1060,7 @@ func (s *Server) checkOrgScopes(ctx context.Context, user *query.User, scopes [] } return slices.DeleteFunc(scopes, func(scope string) bool { if orgID, ok := strings.CutPrefix(scope, domain.OrgIDScope); ok { - return orgID != user.ResourceOwner + return orgID != resourceOwner } return false }), nil diff --git a/internal/api/oidc/client_credentials.go b/internal/api/oidc/client_credentials.go index b8c2a10a59..9087360452 100644 --- a/internal/api/oidc/client_credentials.go +++ b/internal/api/oidc/client_credentials.go @@ -9,7 +9,7 @@ import ( "github.com/zitadel/oidc/v3/pkg/op" "github.com/zitadel/zitadel/internal/api/authz" - "github.com/zitadel/zitadel/internal/query" + "github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/telemetry/tracing" "github.com/zitadel/zitadel/internal/zerrors" ) @@ -56,25 +56,29 @@ func (s *Server) clientCredentialsAuth(ctx context.Context, clientID, clientSecr s.command.MachineSecretCheckSucceeded(ctx, user.ID, user.ResourceOwner, updated) return &clientCredentialsClient{ - id: clientID, - user: user, + clientID: user.Username, + userID: user.ID, + resourceOwner: user.ResourceOwner, + tokenType: user.Machine.AccessTokenType, }, nil } type clientCredentialsClient struct { - id string - user *query.User + clientID string + userID string + resourceOwner string + tokenType domain.OIDCTokenType } // AccessTokenType returns the AccessTokenType for the token to be created because of the client credentials request // machine users currently only have opaque tokens ([op.AccessTokenTypeBearer]) func (c *clientCredentialsClient) AccessTokenType() op.AccessTokenType { - return accessTokenTypeToOIDC(c.user.Machine.AccessTokenType) + return accessTokenTypeToOIDC(c.tokenType) } // GetID returns the client_id (username of the machine user) for the token to be created because of the client credentials request func (c *clientCredentialsClient) GetID() string { - return c.id + return c.clientID } // RedirectURIs returns nil as there are no redirect uris diff --git a/internal/api/oidc/token_client_credentials.go b/internal/api/oidc/token_client_credentials.go index 459a98f8ad..2fedd71c44 100644 --- a/internal/api/oidc/token_client_credentials.go +++ b/internal/api/oidc/token_client_credentials.go @@ -26,15 +26,15 @@ func (s *Server) ClientCredentialsExchange(ctx context.Context, r *op.ClientRequ if err != nil { return nil, err } - scope, err = s.checkOrgScopes(ctx, client.user, scope) + scope, err = s.checkOrgScopes(ctx, client.resourceOwner, scope) if err != nil { return nil, err } session, err := s.command.CreateOIDCSession(ctx, - client.user.ID, - client.user.ResourceOwner, - "", + client.userID, + client.resourceOwner, + client.clientID, scope, domain.AddAudScopeToAudience(ctx, nil, r.Data.Scope), []domain.UserAuthMethodType{domain.UserAuthMethodTypePassword}, diff --git a/internal/api/oidc/token_jwt_profile.go b/internal/api/oidc/token_jwt_profile.go index d3c32604c3..253432cc83 100644 --- a/internal/api/oidc/token_jwt_profile.go +++ b/internal/api/oidc/token_jwt_profile.go @@ -21,28 +21,30 @@ func (s *Server) JWTProfile(ctx context.Context, r *op.Request[oidc.JWTProfileGr err = oidcError(err) }() - user, jwtReq, err := s.verifyJWTProfile(ctx, r.Data) + user, err := s.verifyJWTProfile(ctx, r.Data) if err != nil { return nil, err } client := &clientCredentialsClient{ - id: jwtReq.Subject, - user: user, + clientID: user.Username, + userID: user.UserID, + resourceOwner: user.ResourceOwner, + tokenType: user.TokenType, } scope, err := op.ValidateAuthReqScopes(client, r.Data.Scope) if err != nil { return nil, err } - scope, err = s.checkOrgScopes(ctx, client.user, scope) + scope, err = s.checkOrgScopes(ctx, client.resourceOwner, scope) if err != nil { return nil, err } session, err := s.command.CreateOIDCSession(ctx, - user.ID, - user.ResourceOwner, - "", + client.userID, + client.resourceOwner, + client.clientID, scope, domain.AddAudScopeToAudience(ctx, nil, r.Data.Scope), []domain.UserAuthMethodType{domain.UserAuthMethodTypePrivateKey}, @@ -61,37 +63,33 @@ func (s *Server) JWTProfile(ctx context.Context, r *op.Request[oidc.JWTProfileGr return response(s.accessTokenResponseFromSession(ctx, client, session, "", "", false, true, true, false)) } -func (s *Server) verifyJWTProfile(ctx context.Context, req *oidc.JWTProfileGrantRequest) (user *query.User, tokenRequest *oidc.JWTTokenRequest, err error) { +func (s *Server) verifyJWTProfile(ctx context.Context, req *oidc.JWTProfileGrantRequest) (_ *query.AuthNKeyUser, err error) { ctx, span := tracing.NewSpan(ctx) defer func() { span.EndWithError(err) }() - checkSubject := func(jwt *oidc.JWTTokenRequest) (err error) { - user, err = s.query.GetUserByID(ctx, true, jwt.Subject) - return err - } + storage := &jwtProfileKeyStorage{query: s.query} verifier := op.NewJWTProfileVerifier( - &jwtProfileKeyStorage{query: s.query}, - op.IssuerFromContext(ctx), + storage, op.IssuerFromContext(ctx), time.Hour, time.Second, - op.SubjectCheck(checkSubject), ) - tokenRequest, err = op.VerifyJWTAssertion(ctx, req.Assertion, verifier) + _, err = op.VerifyJWTAssertion(ctx, req.Assertion, verifier) if err != nil { - return nil, nil, err + return nil, err } - return user, tokenRequest, nil + return storage.user, nil } type jwtProfileKeyStorage struct { query *query.Queries + user *query.AuthNKeyUser // only populated after GetKeyByIDAndClientID is called } -func (s *jwtProfileKeyStorage) GetKeyByIDAndClientID(ctx context.Context, keyID, userID string) (*jose.JSONWebKey, error) { - publicKeyData, err := s.query.GetAuthNKeyPublicKeyByIDAndIdentifier(ctx, keyID, userID) +func (s *jwtProfileKeyStorage) GetKeyByIDAndClientID(ctx context.Context, keyID, userID string) (_ *jose.JSONWebKey, err error) { + s.user, err = s.query.GetAuthNKeyUser(ctx, keyID, userID) if err != nil { return nil, err } - publicKey, err := crypto.BytesToPublicKey(publicKeyData) + publicKey, err := crypto.BytesToPublicKey(s.user.PublicKey) if err != nil { return nil, err } diff --git a/internal/query/authn_key.go b/internal/query/authn_key.go index 679f444327..6c05a03f6f 100644 --- a/internal/query/authn_key.go +++ b/internal/query/authn_key.go @@ -3,6 +3,7 @@ package query import ( "context" "database/sql" + _ "embed" "errors" "time" @@ -249,6 +250,44 @@ func NewAuthNKeyObjectIDQuery(id string) (SearchQuery, error) { return NewTextQuery(AuthNKeyColumnObjectID, id, TextEquals) } +//go:embed authn_key_user.sql +var authNKeyUserQuery string + +type AuthNKeyUser struct { + UserID string + ResourceOwner string + Username string + TokenType domain.OIDCTokenType + PublicKey []byte +} + +func (q *Queries) GetAuthNKeyUser(ctx context.Context, keyID, userID string) (_ *AuthNKeyUser, err error) { + ctx, span := tracing.NewSpan(ctx) + defer func() { span.EndWithError(err) }() + + dst := new(AuthNKeyUser) + err = q.client.QueryRowContext(ctx, func(row *sql.Row) error { + return row.Scan( + &dst.UserID, + &dst.ResourceOwner, + &dst.Username, + &dst.TokenType, + &dst.PublicKey, + ) + }, + authNKeyUserQuery, + authz.GetInstance(ctx).InstanceID(), + keyID, userID, + ) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, zerrors.ThrowNotFound(err, "QUERY-Tha6f", "Errors.AuthNKey.NotFound") + } + return nil, zerrors.ThrowInternal(err, "QUERY-aen2A", "Errors.Internal") + } + return dst, nil +} + func prepareAuthNKeysQuery(ctx context.Context, db prepareDatabase) (sq.SelectBuilder, func(rows *sql.Rows) (*AuthNKeys, error)) { return sq.Select( AuthNKeyColumnID.identifier(), diff --git a/internal/query/authn_key_test.go b/internal/query/authn_key_test.go index 5f5084de35..19005893f8 100644 --- a/internal/query/authn_key_test.go +++ b/internal/query/authn_key_test.go @@ -8,6 +8,11 @@ import ( "regexp" "testing" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/zitadel/zitadel/internal/api/authz" + "github.com/zitadel/zitadel/internal/database" "github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/zerrors" ) @@ -470,3 +475,65 @@ func Test_AuthNKeyPrepares(t *testing.T) { }) } } + +func TestQueries_GetAuthNKeyUser(t *testing.T) { + expQuery := regexp.QuoteMeta(authNKeyUserQuery) + cols := []string{"user_id", "resource_owner", "username", "access_token_type", "public_key"} + pubkey := []byte(`-----BEGIN RSA PUBLIC KEY----- +MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA2ufAL1b72bIy1ar+Ws6b +GohJJQFB7dfRapDqeqM8Ukp6CVdPzq/pOz1viAq50yzWZJryF+2wshFAKGF9A2/B +2Yf9bJXPZ/KbkFrYT3NTvYDkvlaSTl9mMnzrU29s48F1PTWKfB+C3aMsOEG1BufV +s63qF4nrEPjSbhljIco9FZq4XppIzhMQ0fDdA/+XygCJqvuaL0LibM1KrlUdnu71 +YekhSJjEPnvOisXIk4IXywoGIOwtjxkDvNItQvaMVldr4/kb6uvbgdWwq5EwBZXq +low2kyJov38V4Uk2I8kuXpLcnrpw5Tio2ooiUE27b0vHZqBKOei9Uo88qCrn3EKx +6QIDAQAB +-----END RSA PUBLIC KEY-----`) + + tests := []struct { + name string + mock sqlExpectation + want *AuthNKeyUser + wantErr error + }{ + { + name: "no rows", + mock: mockQueryErr(expQuery, sql.ErrNoRows, "instanceID", "keyID", "userID"), + wantErr: zerrors.ThrowNotFound(sql.ErrNoRows, "QUERY-Tha6f", "Errors.AuthNKey.NotFound"), + }, + { + name: "internal error", + mock: mockQueryErr(expQuery, sql.ErrConnDone, "instanceID", "keyID", "userID"), + wantErr: zerrors.ThrowInternal(sql.ErrConnDone, "QUERY-aen2A", "Errors.Internal"), + }, + { + name: "success", + mock: mockQuery(expQuery, cols, + []driver.Value{"userID", "orgID", "username", domain.OIDCTokenTypeJWT, pubkey}, + "instanceID", "keyID", "userID", + ), + want: &AuthNKeyUser{ + UserID: "userID", + ResourceOwner: "orgID", + Username: "username", + TokenType: domain.OIDCTokenTypeJWT, + PublicKey: pubkey, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + execMock(t, tt.mock, func(db *sql.DB) { + q := &Queries{ + client: &database.DB{ + DB: db, + Database: &prepareDB{}, + }, + } + ctx := authz.NewMockContext("instanceID", "orgID", "userID") + got, err := q.GetAuthNKeyUser(ctx, "keyID", "userID") + require.ErrorIs(t, err, tt.wantErr) + assert.Equal(t, tt.want, got) + }) + }) + } +} diff --git a/internal/query/authn_key_user.sql b/internal/query/authn_key_user.sql new file mode 100644 index 0000000000..e8eb4c3a1b --- /dev/null +++ b/internal/query/authn_key_user.sql @@ -0,0 +1,11 @@ +select u.id as user_id, u.resource_owner, u.username, m.access_token_type, k.public_key +from projections.authn_keys2 k +join projections.users13 u + on k.instance_id = u.instance_id + and k.identifier = u.id +join projections.users13_machines m + on u.instance_id = m.instance_id + and u.id = m.user_id +where k.instance_id = $1 + and k.id = $2 + and u.id = $3; From 15c9f71bee1fa8d297d3fac7c6d04bc728265938 Mon Sep 17 00:00:00 2001 From: Silvan Date: Wed, 11 Sep 2024 11:23:24 +0200 Subject: [PATCH 17/33] test(load): add machine jwt profile test for a single user (#8593) # How the Problems Are Solved Adds a new load test to use the token endpoint with a single user by multiple threads. --- load-test/Makefile | 6 ++++ load-test/README.md | 5 ++- .../machine_jwt_profile_grant_single_user.ts | 35 +++++++++++++++++++ 3 files changed, 45 insertions(+), 1 deletion(-) create mode 100644 load-test/src/use_cases/machine_jwt_profile_grant_single_user.ts diff --git a/load-test/Makefile b/load-test/Makefile index c0fdbb673c..bbd5ebf538 100644 --- a/load-test/Makefile +++ b/load-test/Makefile @@ -42,6 +42,12 @@ machine_jwt_profile_grant: ensure_modules ensure_key_pair bundle 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} +.PHONY: machine_jwt_profile_grant_single_user +machine_jwt_profile_grant_single_user: 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_single_user.js --vus ${VUS} --duration ${DURATION} + .PHONY: lint lint: npm i diff --git a/load-test/README.md b/load-test/README.md index e046372ee0..b4b2a6cae9 100644 --- a/load-test/README.md +++ b/load-test/README.md @@ -52,4 +52,7 @@ Before you run the tests you need an initialized user. The tests don't implement test: creates new sessions with user id check * `make machine_jwt_profile_grant` setup: generates private/public key, creates machine users, adds a key - test: creates a token and calls user info \ No newline at end of file + test: creates a token and calls user info +* `make machine_jwt_profile_grant_single_user` + setup: generates private/public key, creates machine user, adds a key + test: creates a token and calls user info in parallel for the same user \ No newline at end of file diff --git a/load-test/src/use_cases/machine_jwt_profile_grant_single_user.ts b/load-test/src/use_cases/machine_jwt_profile_grant_single_user.ts new file mode 100644 index 0000000000..c654fb9492 --- /dev/null +++ b/load-test/src/use_cases/machine_jwt_profile_grant_single_user.ts @@ -0,0 +1,35 @@ +import { loginByUsernamePassword } from '../login_ui'; +import { createOrg, removeOrg } from '../org'; +import {createMachine, User, addMachineKey} from '../user'; +import {JWTProfileRequest, token, userinfo} from '../oidc'; +import { Config } from '../config'; +import encoding from 'k6/encoding'; + +const publicKey = encoding.b64encode(open('../.keys/key.pem.pub')); + +export async function setup() { + const tokens = loginByUsernamePassword(Config.admin as User); + console.info('setup: admin signed in'); + + const org = await createOrg(tokens.accessToken!); + console.info(`setup: org (${org.organizationId}) created`); + + const machine = await createMachine(`zitachine`, org, tokens.accessToken!); + console.info(`setup: machine ${machine.userId} created`); + const key = await addMachineKey(machine.userId, org, tokens.accessToken!, publicKey); + console.info(`setup: key ${key.keyId} added`); + + return { tokens, machine: {userId: machine.userId, keyId: key.keyId}, org }; +} + +export default function (data: any) { + token(new JWTProfileRequest(data.machine.userId, data.machine.keyId)) + .then((token) => { + userinfo(token.accessToken!) + }) +} + +export function teardown(data: any) { + removeOrg(data.org, data.tokens.accessToken); + console.info('teardown: org removed'); +} From 02c78a19c6e096f2e7997c346aa67f9131525f7d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20M=C3=B6hlmann?= Date: Wed, 11 Sep 2024 12:43:44 +0300 Subject: [PATCH 18/33] fix(actions-v3): check feature flag on list methods (#8595) # Which Problems Are Solved In actions/v3 there was no check for the feature flag on any of the: - ListExecutionFunctions - ListExecutionMethods - ListExecutionServices In the integration tests `ensureFeatureEnabled` relies on `ListExecutionMethods` to return an error if the feature is not enabled. This fix makes the test wait untill the feature is fully projected. # How the Problems Are Solved Add the feature check to all of the above methods. # Additional Changes - none # Additional Context Flaky introduced in https://github.com/zitadel/zitadel/pull/8407 --- .../grpc/resources/action/v3alpha/execution.go | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/internal/api/grpc/resources/action/v3alpha/execution.go b/internal/api/grpc/resources/action/v3alpha/execution.go index 794827970b..94ad17c2f0 100644 --- a/internal/api/grpc/resources/action/v3alpha/execution.go +++ b/internal/api/grpc/resources/action/v3alpha/execution.go @@ -91,19 +91,28 @@ func conditionToInclude(cond *action.Condition) (string, error) { } } -func (s *Server) ListExecutionFunctions(_ context.Context, _ *action.ListExecutionFunctionsRequest) (*action.ListExecutionFunctionsResponse, error) { +func (s *Server) ListExecutionFunctions(ctx context.Context, _ *action.ListExecutionFunctionsRequest) (*action.ListExecutionFunctionsResponse, error) { + if err := checkActionsEnabled(ctx); err != nil { + return nil, err + } return &action.ListExecutionFunctionsResponse{ Functions: s.ListActionFunctions(), }, nil } -func (s *Server) ListExecutionMethods(_ context.Context, _ *action.ListExecutionMethodsRequest) (*action.ListExecutionMethodsResponse, error) { +func (s *Server) ListExecutionMethods(ctx context.Context, _ *action.ListExecutionMethodsRequest) (*action.ListExecutionMethodsResponse, error) { + if err := checkActionsEnabled(ctx); err != nil { + return nil, err + } return &action.ListExecutionMethodsResponse{ Methods: s.ListGRPCMethods(), }, nil } -func (s *Server) ListExecutionServices(_ context.Context, _ *action.ListExecutionServicesRequest) (*action.ListExecutionServicesResponse, error) { +func (s *Server) ListExecutionServices(ctx context.Context, _ *action.ListExecutionServicesRequest) (*action.ListExecutionServicesResponse, error) { + if err := checkActionsEnabled(ctx); err != nil { + return nil, err + } return &action.ListExecutionServicesResponse{ Services: s.ListGRPCServices(), }, nil From a07b2f4677326c84cdb67004d19d0e589143b1e9 Mon Sep 17 00:00:00 2001 From: Livio Spring Date: Wed, 11 Sep 2024 12:53:55 +0200 Subject: [PATCH 19/33] feat: invite user link (#8578) # Which Problems Are Solved As an administrator I want to be able to invite users to my application with the API V2, some user data I will already prefil, the user should add the authentication method themself (password, passkey, sso). # How the Problems Are Solved - A user can now be created with a email explicitly set to false. - If a user has no verified email and no authentication method, an `InviteCode` can be created through the User V2 API. - the code can be returned or sent through email - additionally `URLTemplate` and an `ApplicatioName` can provided for the email - The code can be resent and verified through the User V2 API - The V1 login allows users to verify and resend the code and set a password (analog user initialization) - The message text for the user invitation can be customized # Additional Changes - `verifyUserPasskeyCode` directly uses `crypto.VerifyCode` (instead of `verifyEncryptedCode`) - `verifyEncryptedCode` is removed (unnecessarily queried for the code generator) # Additional Context - closes #8310 - TODO: login V2 will have to implement invite flow: https://github.com/zitadel/typescript/issues/166 --- cmd/defaults.yaml | 7 + .../message-texts/message-texts.component.ts | 64 + console/src/app/services/admin.service.ts | 34 + console/src/app/services/mgmt.service.ts | 34 + console/src/assets/i18n/bg.json | 9 +- console/src/assets/i18n/cs.json | 9 +- console/src/assets/i18n/de.json | 9 +- console/src/assets/i18n/en.json | 9 +- console/src/assets/i18n/es.json | 9 +- console/src/assets/i18n/fr.json | 9 +- console/src/assets/i18n/id.json | 9 +- console/src/assets/i18n/it.json | 9 +- console/src/assets/i18n/ja.json | 6 +- console/src/assets/i18n/mk.json | 9 +- console/src/assets/i18n/nl.json | 9 +- console/src/assets/i18n/pl.json | 9 +- console/src/assets/i18n/pt.json | 9 +- console/src/assets/i18n/ru.json | 9 +- console/src/assets/i18n/sv.json | 9 +- console/src/assets/i18n/zh.json | 9 +- internal/api/grpc/admin/custom_text.go | 48 + .../api/grpc/admin/custom_text_converter.go | 15 + internal/api/grpc/admin/import.go | 17 + internal/api/grpc/management/custom_text.go | 48 + .../grpc/management/custom_text_converter.go | 15 + .../user/v2/integration_test/user_test.go | 307 +++++ internal/api/grpc/user/v2/user.go | 89 +- internal/api/ui/login/invite_user_handler.go | 154 +++ internal/api/ui/login/renderer.go | 6 + internal/api/ui/login/router.go | 3 + internal/api/ui/login/static/i18n/bg.yaml | 8 + internal/api/ui/login/static/i18n/cs.yaml | 9 + internal/api/ui/login/static/i18n/de.yaml | 9 + internal/api/ui/login/static/i18n/en.yaml | 9 + internal/api/ui/login/static/i18n/es.yaml | 9 + internal/api/ui/login/static/i18n/fr.yaml | 9 + internal/api/ui/login/static/i18n/id.yaml | 8 + internal/api/ui/login/static/i18n/it.yaml | 9 + internal/api/ui/login/static/i18n/ja.yaml | 9 + internal/api/ui/login/static/i18n/mk.yaml | 9 + internal/api/ui/login/static/i18n/nl.yaml | 9 + internal/api/ui/login/static/i18n/pl.yaml | 9 + internal/api/ui/login/static/i18n/pt.yaml | 9 + internal/api/ui/login/static/i18n/ru.yaml | 9 + internal/api/ui/login/static/i18n/sv.yaml | 9 + internal/api/ui/login/static/i18n/zh.yaml | 9 + .../login/static/templates/invite_user.html | 63 + .../eventsourcing/eventstore/auth_request.go | 11 + .../eventstore/auth_request_test.go | 49 +- .../eventsourcing/eventstore/user.go | 38 + internal/command/crypto.go | 8 - internal/command/crypto_test.go | 72 - internal/command/email.go | 3 + internal/command/instance.go | 1 + internal/command/user.go | 4 + internal/command/user_human.go | 3 + internal/command/user_human_test.go | 61 + internal/command/user_model.go | 6 + internal/command/user_v2_invite.go | 193 +++ internal/command/user_v2_invite_model.go | 141 ++ internal/command/user_v2_invite_test.go | 1207 +++++++++++++++++ internal/command/user_v2_passkey.go | 2 +- internal/command/user_v2_passkey_test.go | 22 +- internal/domain/custom_message_text.go | 14 +- internal/domain/next_step.go | 7 + internal/domain/secret_generator.go | 1 + internal/domain/secretgeneratortype_enumer.go | 85 +- internal/integration/client.go | 9 + internal/notification/handlers/commands.go | 1 + .../handlers/mock/commands.mock.go | 100 +- .../notification/handlers/user_notifier.go | 59 + internal/notification/static/i18n/bg.yaml | 7 + internal/notification/static/i18n/cs.yaml | 7 + internal/notification/static/i18n/de.yaml | 7 + internal/notification/static/i18n/en.yaml | 7 + internal/notification/static/i18n/es.yaml | 7 + internal/notification/static/i18n/fr.yaml | 7 + internal/notification/static/i18n/id.yaml | 7 + internal/notification/static/i18n/it.yaml | 7 + internal/notification/static/i18n/ja.yaml | 7 + internal/notification/static/i18n/mk.yaml | 7 + internal/notification/static/i18n/nl.yaml | 7 + internal/notification/static/i18n/pl.yaml | 7 + internal/notification/static/i18n/pt.yaml | 7 + internal/notification/static/i18n/ru.yaml | 21 +- internal/notification/static/i18n/sv.yaml | 7 + internal/notification/static/i18n/zh.yaml | 7 + internal/notification/types/invite_code.go | 31 + internal/query/message_text.go | 3 + internal/query/projection/message_texts.go | 3 +- internal/repository/user/eventstore.go | 4 + internal/repository/user/human.go | 135 ++ internal/static/i18n/bg.yaml | 7 + internal/static/i18n/cs.yaml | 7 + internal/static/i18n/de.yaml | 7 + internal/static/i18n/en.yaml | 7 + internal/static/i18n/es.yaml | 7 + internal/static/i18n/fr.yaml | 7 + internal/static/i18n/id.yaml | 7 + internal/static/i18n/it.yaml | 7 + internal/static/i18n/ja.yaml | 7 + internal/static/i18n/mk.yaml | 7 + internal/static/i18n/nl.yaml | 7 + internal/static/i18n/pl.yaml | 7 + internal/static/i18n/pt.yaml | 7 + internal/static/i18n/ru.yaml | 7 + internal/static/i18n/sv.yaml | 7 + internal/static/i18n/zh.yaml | 7 + proto/zitadel/admin.proto | 149 ++ proto/zitadel/management.proto | 177 +++ proto/zitadel/user/v2/email.proto | 2 +- proto/zitadel/user/v2/user.proto | 26 + proto/zitadel/user/v2/user_service.proto | 137 ++ proto/zitadel/v1.proto | 2 + 114 files changed, 3898 insertions(+), 293 deletions(-) create mode 100644 internal/api/ui/login/invite_user_handler.go create mode 100644 internal/api/ui/login/static/templates/invite_user.html create mode 100644 internal/command/user_v2_invite.go create mode 100644 internal/command/user_v2_invite_model.go create mode 100644 internal/command/user_v2_invite_test.go create mode 100644 internal/notification/types/invite_code.go diff --git a/cmd/defaults.yaml b/cmd/defaults.yaml index f36932b02b..a81a1ff126 100644 --- a/cmd/defaults.yaml +++ b/cmd/defaults.yaml @@ -712,6 +712,13 @@ DefaultInstance: IncludeUpperLetters: false # ZITADEL_DEFAULTINSTANCE_SECRETGENERATORS_OTPEMAIL_INCLUDEUPPERLETTERS IncludeDigits: true # ZITADEL_DEFAULTINSTANCE_SECRETGENERATORS_OTPEMAIL_INCLUDEDIGITS IncludeSymbols: false # ZITADEL_DEFAULTINSTANCE_SECRETGENERATORS_OTPEMAIL_INCLUDESYMBOLS + InviteCode: + Length: 6 # ZITADEL_DEFAULTINSTANCE_SECRETGENERATORS_INITIALIZEUSERCODE_LENGTH + Expiry: "72h" # ZITADEL_DEFAULTINSTANCE_SECRETGENERATORS_INITIALIZEUSERCODE_EXPIRY + IncludeLowerLetters: false # ZITADEL_DEFAULTINSTANCE_SECRETGENERATORS_INITIALIZEUSERCODE_INCLUDELOWERLETTERS + IncludeUpperLetters: true # ZITADEL_DEFAULTINSTANCE_SECRETGENERATORS_INITIALIZEUSERCODE_INCLUDEUPPERLETTERS + IncludeDigits: true # ZITADEL_DEFAULTINSTANCE_SECRETGENERATORS_INITIALIZEUSERCODE_INCLUDEDIGITS + IncludeSymbols: false # ZITADEL_DEFAULTINSTANCE_SECRETGENERATORS_INITIALIZEUSERCODE_INCLUDESYMBOLS PasswordComplexityPolicy: MinLength: 8 # ZITADEL_DEFAULTINSTANCE_PASSWORDCOMPLEXITYPOLICY_MINLENGTH HasLowercase: true # ZITADEL_DEFAULTINSTANCE_PASSWORDCOMPLEXITYPOLICY_HASLOWERCASE diff --git a/console/src/app/modules/policies/message-texts/message-texts.component.ts b/console/src/app/modules/policies/message-texts/message-texts.component.ts index 2042130399..b3be74c17f 100644 --- a/console/src/app/modules/policies/message-texts/message-texts.component.ts +++ b/console/src/app/modules/policies/message-texts/message-texts.component.ts @@ -7,6 +7,7 @@ import { GetDefaultInitMessageTextRequest as AdminGetDefaultInitMessageTextRequest, GetDefaultPasswordChangeMessageTextRequest as AdminGetDefaultPasswordChangeMessageTextRequest, GetDefaultPasswordlessRegistrationMessageTextRequest as AdminGetDefaultPasswordlessRegistrationMessageTextRequest, + GetDefaultInviteUserMessageTextRequest as AdminGetDefaultInviteUserMessageTextRequest, GetDefaultPasswordResetMessageTextRequest as AdminGetDefaultPasswordResetMessageTextRequest, GetDefaultVerifyEmailMessageTextRequest as AdminGetDefaultVerifyEmailMessageTextRequest, GetDefaultVerifyEmailOTPMessageTextRequest as AdminGetDefaultVerifyEmailOTPMessageTextRequest, @@ -16,6 +17,7 @@ import { SetDefaultInitMessageTextRequest, SetDefaultPasswordChangeMessageTextRequest, SetDefaultPasswordlessRegistrationMessageTextRequest, + SetDefaultInviteUserMessageTextRequest, SetDefaultPasswordResetMessageTextRequest, SetDefaultVerifyEmailMessageTextRequest, SetDefaultVerifyEmailOTPMessageTextRequest, @@ -27,6 +29,7 @@ import { GetCustomInitMessageTextRequest, GetCustomPasswordChangeMessageTextRequest, GetCustomPasswordlessRegistrationMessageTextRequest, + GetCustomInviteUserMessageTextRequest, GetCustomPasswordResetMessageTextRequest, GetCustomVerifyEmailMessageTextRequest, GetCustomVerifyEmailOTPMessageTextRequest, @@ -36,6 +39,7 @@ import { GetDefaultInitMessageTextRequest, GetDefaultPasswordChangeMessageTextRequest, GetDefaultPasswordlessRegistrationMessageTextRequest, + GetDefaultInviteUserMessageTextRequest, GetDefaultPasswordResetMessageTextRequest, GetDefaultVerifyEmailMessageTextRequest, GetDefaultVerifyEmailOTPMessageTextRequest, @@ -45,6 +49,7 @@ import { SetCustomInitMessageTextRequest, SetCustomPasswordChangeMessageTextRequest, SetCustomPasswordlessRegistrationMessageTextRequest, + SetCustomInviteUserMessageTextRequest, SetCustomPasswordResetMessageTextRequest, SetCustomVerifyEmailMessageTextRequest, SetCustomVerifyEmailOTPMessageTextRequest, @@ -73,6 +78,7 @@ enum MESSAGETYPES { PASSWORDCHANGE = 'PC', VERIFYSMSOTP = 'VSO', VERIFYEMAILOTP = 'VEO', + INVITEUSER = 'IU', } const REQUESTMAP = { @@ -226,6 +232,23 @@ const REQUESTMAP = { req.setText(map.text ?? ''); req.setTitle(map.title ?? ''); + return req; + }, + }, + [MESSAGETYPES.INVITEUSER]: { + get: new GetCustomInviteUserMessageTextRequest(), + set: new SetCustomInviteUserMessageTextRequest(), + getDefault: new GetDefaultInviteUserMessageTextRequest(), + setFcn: (map: Partial): SetCustomInviteUserMessageTextRequest => { + const req = new SetCustomInviteUserMessageTextRequest(); + req.setButtonText(map.buttonText ?? ''); + req.setFooterText(map.footerText ?? ''); + req.setGreeting(map.greeting ?? ''); + req.setPreHeader(map.preHeader ?? ''); + req.setSubject(map.subject ?? ''); + req.setText(map.text ?? ''); + req.setTitle(map.title ?? ''); + return req; }, }, @@ -371,6 +394,22 @@ const REQUESTMAP = { req.setText(map.text ?? ''); req.setTitle(map.title ?? ''); + return req; + }, + }, + [MESSAGETYPES.INVITEUSER]: { + get: new AdminGetDefaultInviteUserMessageTextRequest(), + set: new SetDefaultInviteUserMessageTextRequest(), + setFcn: (map: Partial): SetDefaultInviteUserMessageTextRequest => { + const req = new SetDefaultInviteUserMessageTextRequest(); + req.setButtonText(map.buttonText ?? ''); + req.setFooterText(map.footerText ?? ''); + req.setGreeting(map.greeting ?? ''); + req.setPreHeader(map.preHeader ?? ''); + req.setSubject(map.subject ?? ''); + req.setText(map.text ?? ''); + req.setTitle(map.title ?? ''); + return req; }, }, @@ -540,6 +579,21 @@ export class MessageTextsComponent implements OnInit, OnDestroy { { key: 'POLICY.MESSAGE_TEXTS.CHIPS.loginnames', value: '{{.LoginNames}}' }, { key: 'POLICY.MESSAGE_TEXTS.CHIPS.changedate', value: '{{.ChangeDate}}' }, ], + [MESSAGETYPES.INVITEUSER]: [ + { key: 'POLICY.MESSAGE_TEXTS.CHIPS.preferredLoginName', value: '{{.PreferredLoginName}}' }, + { key: 'POLICY.MESSAGE_TEXTS.CHIPS.username', value: '{{.UserName}}' }, + { key: 'POLICY.MESSAGE_TEXTS.CHIPS.firstname', value: '{{.FirstName}}' }, + { key: 'POLICY.MESSAGE_TEXTS.CHIPS.lastname', value: '{{.LastName}}' }, + { key: 'POLICY.MESSAGE_TEXTS.CHIPS.nickName', value: '{{.NickName}}' }, + { key: 'POLICY.MESSAGE_TEXTS.CHIPS.displayName', value: '{{.DisplayName}}' }, + { key: 'POLICY.MESSAGE_TEXTS.CHIPS.lastEmail', value: '{{.LastEmail}}' }, + { key: 'POLICY.MESSAGE_TEXTS.CHIPS.verifiedEmail', value: '{{.VerifiedEmail}}' }, + { key: 'POLICY.MESSAGE_TEXTS.CHIPS.lastPhone', value: '{{.LastPhone}}' }, + { key: 'POLICY.MESSAGE_TEXTS.CHIPS.verifiedPhone', value: '{{.VerifiedPhone}}' }, + { key: 'POLICY.MESSAGE_TEXTS.CHIPS.loginnames', value: '{{.LoginNames}}' }, + { key: 'POLICY.MESSAGE_TEXTS.CHIPS.changedate', value: '{{.ChangeDate}}' }, + { key: 'POLICY.MESSAGE_TEXTS.CHIPS.applicationName', value: '{{.ApplicationName}}' }, + ], }; public language: string = 'en'; @@ -599,6 +653,8 @@ export class MessageTextsComponent implements OnInit, OnDestroy { return this.stripEmail(this.service.getDefaultPasswordlessRegistrationMessageText(req)); case MESSAGETYPES.PASSWORDCHANGE: return this.stripEmail(this.service.getDefaultPasswordChangeMessageText(req)); + case MESSAGETYPES.INVITEUSER: + return this.stripEmail(this.service.getDefaultInviteUserMessageText(req)); } } @@ -622,6 +678,8 @@ export class MessageTextsComponent implements OnInit, OnDestroy { return this.stripEmail(this.service.getCustomPasswordlessRegistrationMessageText(req)); case MESSAGETYPES.PASSWORDCHANGE: return this.stripEmail(this.service.getCustomPasswordChangeMessageText(req)); + case MESSAGETYPES.INVITEUSER: + return this.stripEmail(this.service.getCustomInviteUserMessageText(req)); default: return undefined; } @@ -690,6 +748,8 @@ export class MessageTextsComponent implements OnInit, OnDestroy { ); case MESSAGETYPES.PASSWORDCHANGE: return handler((this.service as ManagementService).setCustomPasswordChangeMessageText(this.updateRequest)); + case MESSAGETYPES.INVITEUSER: + return handler((this.service as ManagementService).setCustomInviteUserMessageText(this.updateRequest)); } } else if (this.serviceType === PolicyComponentServiceType.ADMIN) { switch (this.currentType) { @@ -711,6 +771,8 @@ export class MessageTextsComponent implements OnInit, OnDestroy { return handler((this.service as AdminService).setDefaultPasswordlessRegistrationMessageText(this.updateRequest)); case MESSAGETYPES.PASSWORDCHANGE: return handler((this.service as AdminService).setDefaultPasswordChangeMessageText(this.updateRequest)); + case MESSAGETYPES.INVITEUSER: + return handler((this.service as AdminService).setDefaultInviteUserMessageText(this.updateRequest)); } } } @@ -763,6 +825,8 @@ export class MessageTextsComponent implements OnInit, OnDestroy { return handler(this.service.resetCustomPasswordlessRegistrationMessageTextToDefault(this.language)); case MESSAGETYPES.PASSWORDCHANGE: return handler(this.service.resetCustomPasswordChangeMessageTextToDefault(this.language)); + case MESSAGETYPES.INVITEUSER: + return handler(this.service.resetCustomInviteUserMessageTextToDefault(this.language)); default: return Promise.reject(); } diff --git a/console/src/app/services/admin.service.ts b/console/src/app/services/admin.service.ts index 791fc2bb71..59c125380f 100644 --- a/console/src/app/services/admin.service.ts +++ b/console/src/app/services/admin.service.ts @@ -72,6 +72,8 @@ import { GetCustomPasswordChangeMessageTextResponse, GetCustomPasswordlessRegistrationMessageTextRequest, GetCustomPasswordlessRegistrationMessageTextResponse, + GetCustomInviteUserMessageTextRequest, + GetCustomInviteUserMessageTextResponse, GetCustomPasswordResetMessageTextRequest, GetCustomPasswordResetMessageTextResponse, GetCustomVerifyEmailMessageTextRequest, @@ -96,6 +98,8 @@ import { GetDefaultPasswordChangeMessageTextResponse, GetDefaultPasswordlessRegistrationMessageTextRequest, GetDefaultPasswordlessRegistrationMessageTextResponse, + GetDefaultInviteUserMessageTextRequest, + GetDefaultInviteUserMessageTextResponse, GetDefaultPasswordResetMessageTextRequest, GetDefaultPasswordResetMessageTextResponse, GetDefaultVerifyEmailMessageTextRequest, @@ -224,6 +228,8 @@ import { SetDefaultPasswordChangeMessageTextResponse, SetDefaultPasswordlessRegistrationMessageTextRequest, SetDefaultPasswordlessRegistrationMessageTextResponse, + SetDefaultInviteUserMessageTextRequest, + SetDefaultInviteUserMessageTextResponse, SetDefaultPasswordResetMessageTextRequest, SetDefaultPasswordResetMessageTextResponse, SetDefaultVerifyEmailMessageTextRequest, @@ -311,6 +317,8 @@ import { ResetCustomPasswordChangeMessageTextToDefaultResponse, ResetCustomPasswordlessRegistrationMessageTextToDefaultRequest, ResetCustomPasswordlessRegistrationMessageTextToDefaultResponse, + ResetCustomInviteUserMessageTextToDefaultRequest, + ResetCustomInviteUserMessageTextToDefaultResponse, ResetCustomPasswordResetMessageTextToDefaultRequest, ResetCustomPasswordResetMessageTextToDefaultResponse, ResetCustomVerifyEmailMessageTextToDefaultRequest, @@ -722,6 +730,32 @@ export class AdminService { return this.grpcService.admin.resetCustomPasswordChangeMessageTextToDefault(req, null).then((resp) => resp.toObject()); } + public getDefaultInviteUserMessageText( + req: GetDefaultInviteUserMessageTextRequest, + ): Promise { + return this.grpcService.admin.getDefaultInviteUserMessageText(req, null).then((resp) => resp.toObject()); + } + + public getCustomInviteUserMessageText( + req: GetCustomInviteUserMessageTextRequest, + ): Promise { + return this.grpcService.admin.getCustomInviteUserMessageText(req, null).then((resp) => resp.toObject()); + } + + public setDefaultInviteUserMessageText( + req: SetDefaultInviteUserMessageTextRequest, + ): Promise { + return this.grpcService.admin.setDefaultInviteUserMessageText(req, null).then((resp) => resp.toObject()); + } + + public resetCustomInviteUserMessageTextToDefault( + lang: string, + ): Promise { + const req = new ResetCustomInviteUserMessageTextToDefaultRequest(); + req.setLanguage(lang); + return this.grpcService.admin.resetCustomInviteUserMessageTextToDefault(req, null).then((resp) => resp.toObject()); + } + public SetUpOrg(org: SetUpOrgRequest.Org, human: SetUpOrgRequest.Human): Promise { const req = new SetUpOrgRequest(); diff --git a/console/src/app/services/mgmt.service.ts b/console/src/app/services/mgmt.service.ts index b167799b23..cb92e98431 100644 --- a/console/src/app/services/mgmt.service.ts +++ b/console/src/app/services/mgmt.service.ts @@ -133,6 +133,8 @@ import { GetCustomLoginTextsResponse, GetCustomPasswordChangeMessageTextRequest, GetCustomPasswordChangeMessageTextResponse, + GetCustomInviteUserMessageTextRequest, + GetCustomInviteUserMessageTextResponse, GetCustomPasswordlessRegistrationMessageTextRequest, GetCustomPasswordlessRegistrationMessageTextResponse, GetCustomPasswordResetMessageTextRequest, @@ -155,6 +157,8 @@ import { GetDefaultLoginTextsResponse, GetDefaultPasswordChangeMessageTextRequest, GetDefaultPasswordChangeMessageTextResponse, + GetDefaultInviteUserMessageTextRequest, + GetDefaultInviteUserMessageTextResponse, GetDefaultPasswordComplexityPolicyRequest, GetDefaultPasswordComplexityPolicyResponse, GetDefaultPasswordlessRegistrationMessageTextRequest, @@ -386,6 +390,8 @@ import { ResetCustomLoginTextsToDefaultResponse, ResetCustomPasswordChangeMessageTextToDefaultRequest, ResetCustomPasswordChangeMessageTextToDefaultResponse, + ResetCustomInviteUserMessageTextToDefaultRequest, + ResetCustomInviteUserMessageTextToDefaultResponse, ResetCustomPasswordlessRegistrationMessageTextToDefaultRequest, ResetCustomPasswordlessRegistrationMessageTextToDefaultResponse, ResetCustomPasswordResetMessageTextToDefaultRequest, @@ -423,6 +429,8 @@ import { SetCustomLoginTextsResponse, SetCustomPasswordChangeMessageTextRequest, SetCustomPasswordChangeMessageTextResponse, + SetCustomInviteUserMessageTextRequest, + SetCustomInviteUserMessageTextResponse, SetCustomPasswordlessRegistrationMessageTextRequest, SetCustomPasswordlessRegistrationMessageTextResponse, SetCustomPasswordResetMessageTextRequest, @@ -804,6 +812,32 @@ export class ManagementService { return this.grpcService.mgmt.resetCustomPasswordChangeMessageTextToDefault(req, null).then((resp) => resp.toObject()); } + public getDefaultInviteUserMessageText( + req: GetDefaultInviteUserMessageTextRequest, + ): Promise { + return this.grpcService.mgmt.getDefaultInviteUserMessageText(req, null).then((resp) => resp.toObject()); + } + + public getCustomInviteUserMessageText( + req: GetCustomInviteUserMessageTextRequest, + ): Promise { + return this.grpcService.mgmt.getCustomInviteUserMessageText(req, null).then((resp) => resp.toObject()); + } + + public setCustomInviteUserMessageText( + req: SetCustomInviteUserMessageTextRequest, + ): Promise { + return this.grpcService.mgmt.setCustomInviteUserMessageCustomText(req, null).then((resp) => resp.toObject()); + } + + public resetCustomInviteUserMessageTextToDefault( + lang: string, + ): Promise { + const req = new ResetCustomInviteUserMessageTextToDefaultRequest(); + req.setLanguage(lang); + return this.grpcService.mgmt.resetCustomInviteUserMessageTextToDefault(req, null).then((resp) => resp.toObject()); + } + public updateUserName(userId: string, username: string): Promise { const req = new UpdateUserNameRequest(); req.setUserId(userId); diff --git a/console/src/assets/i18n/bg.json b/console/src/assets/i18n/bg.json index dd048242cd..88cc186625 100644 --- a/console/src/assets/i18n/bg.json +++ b/console/src/assets/i18n/bg.json @@ -197,7 +197,8 @@ "VE": "Когато потребител промени своя имейл адрес, той ще получи имейл с връзка за верифициране на новия адрес.", "VP": "Когато потребител промени своя телефонен номер, той ще получи SMS с код за верификация на новия номер.", "VEO": "Когато потребител добави метод за One-Time Password чрез имейл, трябва да го активира, като въведе код, изпратен на неговия имейл адрес.", - "VSO": "Когато потребител добави метод за One-Time Password чрез SMS, трябва да го активира, като въведе код, изпратен на неговия телефонен номер." + "VSO": "Когато потребител добави метод за One-Time Password чрез SMS, трябва да го активира, като въведе код, изпратен на неговия телефонен номер.", + "IU": "Когато се създаде покана за потребител, те ще получат имейл с връзка за задаване на своя метод за удостоверяване." } }, "LOGIN_TEXTS": { @@ -1666,7 +1667,8 @@ "PR": "Нулиране на парола", "DC": "Заявка за домейн", "PL": "Без парола", - "PC": "Промяна на паролата" + "PC": "Промяна на паролата", + "IU": "Покана за потребител" }, "CHIPS": { "firstname": "Първо име", @@ -1686,7 +1688,8 @@ "tempUsername": "Временно потребителско име", "otp": "Еднократна парола", "verifyUrl": "URL за потвърждаване на еднократна парола", - "expiry": "Изтичане" + "expiry": "Изтичане", + "applicationName": "Името на приложението" }, "TOAST": { "UPDATED": "Персонализираните текстове са запазени." diff --git a/console/src/assets/i18n/cs.json b/console/src/assets/i18n/cs.json index fd50d9702f..c49d2ce87a 100644 --- a/console/src/assets/i18n/cs.json +++ b/console/src/assets/i18n/cs.json @@ -197,7 +197,8 @@ "VE": "Když uživatel změní svou e-mailovou adresu, obdrží e-mail s odkazem na ověření nové adresy.", "VP": "Když uživatel změní své telefonní číslo, obdrží SMS s kódem pro ověření nového čísla.", "VEO": "Když uživatel přidá metodu jednorázového hesla přes e-mail, musí ji aktivovat zadáním kódu poslaného na jeho e-mailovou adresu.", - "VSO": "Když uživatel přidá metodu jednorázového hesla přes SMS, musí ji aktivovat zadáním kódu poslaného na jeho telefonní číslo." + "VSO": "Když uživatel přidá metodu jednorázového hesla přes SMS, musí ji aktivovat zadáním kódu poslaného na jeho telefonní číslo.", + "IU": "Když se vytvoří pozvánka pro uživatele, obdrží e-mail s odkazem na nastavení své metody ověřování." } }, "LOGIN_TEXTS": { @@ -1667,7 +1668,8 @@ "PR": "Reset hesla", "DC": "Nárok na doménu", "PL": "Bezheslový", - "PC": "Změna hesla" + "PC": "Změna hesla", + "IU": "Pozvat uživatele" }, "CHIPS": { "firstname": "Křestní jméno", @@ -1687,7 +1689,8 @@ "tempUsername": "Dočasné uživatelské jméno", "otp": "Jednorázové heslo", "verifyUrl": "Ověřovací URL jednorázového hesla", - "expiry": "Expirace" + "expiry": "Expirace", + "applicationName": "Název aplikace" }, "TOAST": { "UPDATED": "Vlastní texty uloženy." diff --git a/console/src/assets/i18n/de.json b/console/src/assets/i18n/de.json index e284375a44..f0f01e4682 100644 --- a/console/src/assets/i18n/de.json +++ b/console/src/assets/i18n/de.json @@ -197,7 +197,8 @@ "VE": "Wenn ein Benutzer seine E-Mail-Adresse ändert, erhält er eine E-Mail mit einem Link zur Verifizierung der neuen Adresse.", "VP": "Wenn ein Benutzer seine Telefonnummer ändert, erhält er eine SMS mit einem Code zur Verifizierung der neuen Nummer.", "VEO": "Wenn ein Benutzer eine Einmalpasswort-Methode per E-Mail hinzufügt, muss er sie aktivieren, indem er einen Code eingibt, der an seine E-Mail-Adresse gesendet wurde.", - "VSO": "Wenn ein Benutzer eine Einmalpasswort-Methode per SMS hinzufügt, muss er sie aktivieren, indem er einen Code eingibt, der an seine Telefonnummer gesendet wurde." + "VSO": "Wenn ein Benutzer eine Einmalpasswort-Methode per SMS hinzufügt, muss er sie aktivieren, indem er einen Code eingibt, der an seine Telefonnummer gesendet wurde.", + "IU": "Wenn ein Benutzer-Einladungscode erstellt wird, erhält er eine E-Mail mit einem Link zur Einstellung seiner Authentifizierungsmethode." } }, "LOGIN_TEXTS": { @@ -1667,7 +1668,8 @@ "PR": "Passwort Wiederherstellung", "DC": "Domainbeanspruchung", "PL": "Passwortlos", - "PC": "Passwordwechsel" + "PC": "Passwordwechsel", + "IU": "Benutzer einladen" }, "CHIPS": { "firstname": "Vorname", @@ -1687,7 +1689,8 @@ "tempUsername": "Temp. Username", "otp": "Einmalpasswort", "verifyUrl": "URL zur Überprüfung des Einmalpassworts", - "expiry": "Ablauf" + "expiry": "Ablauf", + "applicationName": "Anwendungsname" }, "TOAST": { "UPDATED": "Benutzerdefinierte Texte gespeichert." diff --git a/console/src/assets/i18n/en.json b/console/src/assets/i18n/en.json index 8d59aab444..63b07aba72 100644 --- a/console/src/assets/i18n/en.json +++ b/console/src/assets/i18n/en.json @@ -197,7 +197,8 @@ "VE": "When a user changes their email address, they will receive an email with a link to verify the new address.", "VP": "When a user changes their phone number, they will receive an SMS with a code to verify the new number.", "VEO": "When a user adds a One-Time Password via email method, they need to activate it by entering a code sent to their email address.", - "VSO": "When a user adds a One-Time Password via SMS method, they need to activate it by entering a code sent to their phone number." + "VSO": "When a user adds a One-Time Password via SMS method, they need to activate it by entering a code sent to their phone number.", + "IU": "When a user invite code is created, they will receive an email with a link to set their authentication method." } }, "LOGIN_TEXTS": { @@ -1667,7 +1668,8 @@ "PR": "Password Reset", "DC": "Domain Claim", "PL": "Passwordless", - "PC": "Password Change" + "PC": "Password Change", + "IU": "Invite User" }, "CHIPS": { "firstname": "Given name", @@ -1687,7 +1689,8 @@ "tempUsername": "Temp username", "otp": "One-time password", "verifyUrl": "Verify One-time-password URL", - "expiry": "Expiry" + "expiry": "Expiry", + "applicationName": "Application name" }, "TOAST": { "UPDATED": "Custom Texts saved." diff --git a/console/src/assets/i18n/es.json b/console/src/assets/i18n/es.json index bc2cc37d9f..979541eb43 100644 --- a/console/src/assets/i18n/es.json +++ b/console/src/assets/i18n/es.json @@ -197,7 +197,8 @@ "VE": "Cuando un usuario cambia su dirección de correo electrónico, recibirá un correo electrónico con un enlace para verificar la nueva dirección.", "VP": "Cuando un usuario cambia su número de teléfono, recibirá un SMS con un código para verificar el nuevo número.", "VEO": "Cuando un usuario agrega una Contraseña de Un Solo Uso mediante correo electrónico, necesita activarla ingresando un código enviado a su dirección de correo electrónico.", - "VSO": "Cuando un usuario agrega una Contraseña de Un Solo Uso mediante SMS, necesita activarla ingresando un código enviado a su número de teléfono." + "VSO": "Cuando un usuario agrega una Contraseña de Un Solo Uso mediante SMS, necesita activarla ingresando un código enviado a su número de teléfono.", + "IU": "Cuando se crea un código de invitación de usuario, recibirán un correo electrónico con un enlace para configurar su método de autenticación." } }, "LOGIN_TEXTS": { @@ -1668,7 +1669,8 @@ "PR": "Restablecimiento de contraseña", "DC": "Reclamar un dominio", "PL": "Acceso sin contraseña", - "PC": "Cambio de contraseña" + "PC": "Cambio de contraseña", + "IU": "Invitar usuario" }, "CHIPS": { "firstname": "Nombre", @@ -1688,7 +1690,8 @@ "tempUsername": "Nombre de usuario temporal", "otp": "Contraseña de un solo uso", "verifyUrl": "URL para verificar la contraseña de un solo uso", - "expiry": "Expiración" + "expiry": "Expiración", + "applicationName": "Nombre de la aplicación" }, "TOAST": { "UPDATED": "Textos personalizados guardados." diff --git a/console/src/assets/i18n/fr.json b/console/src/assets/i18n/fr.json index 5fb378f0b7..e7f99670a7 100644 --- a/console/src/assets/i18n/fr.json +++ b/console/src/assets/i18n/fr.json @@ -197,7 +197,8 @@ "VE": "Lorsqu'un utilisateur change son adresse e-mail, il recevra un e-mail avec un lien pour vérifier la nouvelle adresse.", "VP": "Lorsqu'un utilisateur change son numéro de téléphone, il recevra un SMS avec un code pour vérifier le nouveau numéro.", "VEO": "Lorsqu'un utilisateur ajoute un Mot de Passe à Usage Unique via e-mail, il doit l'activer en entrant un code envoyé à son adresse e-mail.", - "VSO": "Lorsqu'un utilisateur ajoute un Mot de Passe à Usage Unique via SMS, il doit l'activer en entrant un code envoyé à son numéro de téléphone." + "VSO": "Lorsqu'un utilisateur ajoute un Mot de Passe à Usage Unique via SMS, il doit l'activer en entrant un code envoyé à son numéro de téléphone.", + "IU": "Lorsqu'un code d'invitation d'utilisateur est créé, il recevra un e-mail avec un lien pour configurer sa méthode d'authentification." } }, "LOGIN_TEXTS": { @@ -1667,7 +1668,8 @@ "PR": "Réinitialisation du mot de passe", "DC": "Réclamation de domaine", "PL": "Sans mot de passe", - "PC": "Modification du mot de passe" + "PC": "Modification du mot de passe", + "IU": "Inviter un utilisateur" }, "CHIPS": { "firstname": "Prénom", @@ -1687,7 +1689,8 @@ "tempUsername": "Nom d'utilisateur temporaire", "otp": "Mot de passe à usage unique", "verifyUrl": "URL pour vérifier le mot de passe à usage unique", - "expiry": "Expiration" + "expiry": "Expiration", + "applicationName": "Nom de l'application" }, "TOAST": { "UPDATED": "Textes personnalisés enregistrés." diff --git a/console/src/assets/i18n/id.json b/console/src/assets/i18n/id.json index a170040477..2569835012 100644 --- a/console/src/assets/i18n/id.json +++ b/console/src/assets/i18n/id.json @@ -185,7 +185,8 @@ "VE": "Saat pengguna mengubah alamat emailnya, mereka akan menerima email berisi tautan untuk memverifikasi alamat baru.", "VP": "Saat pengguna mengganti nomor teleponnya, mereka akan menerima SMS berisi kode untuk memverifikasi nomor baru.", "VEO": "Ketika pengguna menambahkan Kata Sandi Sekali Pakai melalui metode email, mereka perlu mengaktifkannya dengan memasukkan kode yang dikirimkan ke alamat email mereka.", - "VSO": "Ketika pengguna menambahkan One-Time Password melalui metode SMS, mereka perlu mengaktifkannya dengan memasukkan kode yang dikirimkan ke nomor telepon mereka." + "VSO": "Ketika pengguna menambahkan One-Time Password melalui metode SMS, mereka perlu mengaktifkannya dengan memasukkan kode yang dikirimkan ke nomor telepon mereka.", + "IU": "Ketika kode undangan pengguna dibuat, mereka akan menerima email dengan tautan untuk mengatur metode otentikasi mereka." } }, "LOGIN_TEXTS": { @@ -1533,7 +1534,8 @@ "PR": "Reset Kata Sandi", "DC": "Klaim Domain", "PL": "Tanpa kata sandi", - "PC": "Perubahan Kata Sandi" + "PC": "Perubahan Kata Sandi", + "IU": "Mengundang Pengguna" }, "CHIPS": { "firstname": "Nama yang diberikan", @@ -1553,7 +1555,8 @@ "tempUsername": "Nama pengguna sementara", "otp": "Kata sandi satu kali", "verifyUrl": "Verifikasi URL kata sandi satu kali", - "expiry": "Kedaluwarsa" + "expiry": "Kedaluwarsa", + "applicationName": "Nama aplikasi" }, "TOAST": { "UPDATED": "Teks Khusus disimpan." } }, diff --git a/console/src/assets/i18n/it.json b/console/src/assets/i18n/it.json index c368862afa..cf793e78b6 100644 --- a/console/src/assets/i18n/it.json +++ b/console/src/assets/i18n/it.json @@ -197,7 +197,8 @@ "VE": "Quando un utente cambia il suo indirizzo email, riceverà un'email con un link per verificare il nuovo indirizzo.", "VP": "Quando un utente cambia il suo numero di telefono, riceverà un SMS con un codice per verificare il nuovo numero.", "VEO": "Quando un utente aggiunge una Password Monouso tramite metodo email, deve attivarla inserendo un codice inviato al suo indirizzo email.", - "VSO": "Quando un utente aggiunge una Password Monouso tramite metodo SMS, deve attivarla inserendo un codice inviato al suo numero di telefono." + "VSO": "Quando un utente aggiunge una Password Monouso tramite metodo SMS, deve attivarla inserendo un codice inviato al suo numero di telefono.", + "IU": "Quando viene creato un codice di invito per un utente, riceverà un'e-mail con un collegamento per impostare il suo metodo di autenticazione." } }, "LOGIN_TEXTS": { @@ -1667,7 +1668,8 @@ "PR": "Ripristino della password", "DC": "Rivendicazione del dominio", "PL": "Autenticazione Passwordless", - "PC": "Cambiamento della password" + "PC": "Cambiamento della password", + "IU": "Invita utente" }, "CHIPS": { "firstname": "Nome", @@ -1687,7 +1689,8 @@ "tempUsername": "Nome utente temporaneo", "otp": "Password monouso", "verifyUrl": "URL per verificare la password monouso", - "expiry": "Scadenza" + "expiry": "Scadenza", + "applicationName": "Nome dell'applicazione" }, "TOAST": { "UPDATED": "Testi personalizzati salvati." diff --git a/console/src/assets/i18n/ja.json b/console/src/assets/i18n/ja.json index 387df3bb16..4ba6a69d72 100644 --- a/console/src/assets/i18n/ja.json +++ b/console/src/assets/i18n/ja.json @@ -197,7 +197,8 @@ "VE": "ユーザーがメールアドレスを変更すると、新しいアドレスを検証するリンクが記載されたメールを受け取ります。", "VP": "ユーザーが電話番号を変更すると、新しい番号を検証するコードが記載されたSMSを受け取ります。", "VEO": "ユーザーがメール経由でワンタイムパスワードの方法を追加すると、それをアクティブにするためにメールに送信されたコードを入力する必要があります。", - "VSO": "ユーザーがSMS経由でワンタイムパスワードの方法を追加すると、それをアクティブにするために電話番号に送信されたコードを入力する必要があります。" + "VSO": "ユーザーがSMS経由でワンタイムパスワードの方法を追加すると、それをアクティブにするために電話番号に送信されたコードを入力する必要があります。", + "IU": "ユーザー招待コードが作成されると、認証方法を設定するためのリンクを含むメールが送信されます。" } }, "LOGIN_TEXTS": { @@ -1683,7 +1684,8 @@ "tempUsername": "一時ユーザー名", "otp": "ワンタイムパスワード", "verifyUrl": "ワンタイムパスワードを確認するURL", - "expiry": "有効期限" + "expiry": "有効期限", + "applicationName": "アプリケーション名" }, "TOAST": { "UPDATED": "カスタムテキストが保存されました。" diff --git a/console/src/assets/i18n/mk.json b/console/src/assets/i18n/mk.json index c0723fc2a7..ecf038eacc 100644 --- a/console/src/assets/i18n/mk.json +++ b/console/src/assets/i18n/mk.json @@ -197,7 +197,8 @@ "VE": "Кога корисник ја менува својата е-маил адреса, тој ќе добие е-маил со врска за верификација на новата адреса.", "VP": "Кога корисник ја менува својата телефонска бројка, тој ќе добие SMS со код за верификација на новиот број.", "VEO": "Кога корисник додава метод за еднократна лозинка преку е-маил, потребно е да го активира со внесување на кодот испратен на нивната е-маил адреса.", - "VSO": "Кога корисник додава метод за еднократна лозинка преку SMS, потребно е да го активира со внесување на кодот испратен на нивниот телефонски број." + "VSO": "Кога корисник додава метод за еднократна лозинка преку SMS, потребно е да го активира со внесување на кодот испратен на нивниот телефонски број.", + "IU": "Кога се создаде покана за корисникот, тие ќе добијат имејл со врска за поставување на нивниот метод за автентикација." } }, "LOGIN_TEXTS": { @@ -1668,7 +1669,8 @@ "PR": "Ресетирање на лозинка", "DC": "Зафатница на домен", "PL": "Лозинка без лозинка", - "PC": "Промена на лозинка" + "PC": "Промена на лозинка", + "IU": "Покана за корисникот" }, "CHIPS": { "firstname": "Име", @@ -1688,7 +1690,8 @@ "tempUsername": "Привремено корисничко име", "otp": "Еднократна лозинка", "verifyUrl": "URL за потврдување на еднократна лозинка", - "expiry": "Истекување" + "expiry": "Истекување", + "applicationName": "Името на апликацијата" }, "TOAST": { "UPDATED": "Прилагодените текстови се зачувани." diff --git a/console/src/assets/i18n/nl.json b/console/src/assets/i18n/nl.json index 9c592cda96..525113e0b5 100644 --- a/console/src/assets/i18n/nl.json +++ b/console/src/assets/i18n/nl.json @@ -197,7 +197,8 @@ "VE": "Wanneer een gebruiker zijn e-mailadres wijzigt, ontvangt hij een e-mail met een link om het nieuwe adres te verifiëren.", "VP": "Wanneer een gebruiker zijn telefoonnummer wijzigt, ontvangt hij een SMS met een code om het nieuwe nummer te verifiëren.", "VEO": "Wanneer een gebruiker een eenmalig wachtwoord via e-mailmethode toevoegt, moeten ze dit activeren door een code in te voeren die naar hun e-mailadres is verzonden.", - "VSO": "Wanneer een gebruiker een eenmalig wachtwoord via SMS-methode toevoegt, moeten ze dit activeren door een code in te voeren die naar hun telefoonnummer is verzonden." + "VSO": "Wanneer een gebruiker een eenmalig wachtwoord via SMS-methode toevoegt, moeten ze dit activeren door een code in te voeren die naar hun telefoonnummer is verzonden.", + "IU": "Wanneer een uitnodigingscode voor gebruikers wordt gemaakt, ontvangt de gebruiker een e-mail met een link om zijn verificatiemethode in te stellen." } }, "LOGIN_TEXTS": { @@ -1666,7 +1667,8 @@ "PR": "Wachtwoord Reset", "DC": "Domein Claim", "PL": "Wachtwoordloos", - "PC": "Wachtwoord Verandering" + "PC": "Wachtwoord Verandering", + "IU": "Gebruiker uitnodigen" }, "CHIPS": { "firstname": "Voornaam", @@ -1686,7 +1688,8 @@ "tempUsername": "Tijdelijke gebruikersnaam", "otp": "Eenmalig wachtwoord", "verifyUrl": "Verifieer Eenmalig-wachtwoord URL", - "expiry": "Vervaldatum" + "expiry": "Vervaldatum", + "applicationName": "Toepassingsnaam" }, "TOAST": { "UPDATED": "Aangepaste Teksten opgeslagen." diff --git a/console/src/assets/i18n/pl.json b/console/src/assets/i18n/pl.json index 2592828fee..a739bc08d2 100644 --- a/console/src/assets/i18n/pl.json +++ b/console/src/assets/i18n/pl.json @@ -197,7 +197,8 @@ "VE": "Gdy użytkownik zmieni swój adres e-mail, otrzyma e-mail z linkiem do weryfikacji nowego adresu.", "VP": "Gdy użytkownik zmieni swój numer telefonu, otrzyma SMS z kodem do weryfikacji nowego numeru.", "VEO": "Gdy użytkownik doda metodę jednorazowego hasła przez e-mail, musi ją aktywować, wprowadzając kod wysłany na jego adres e-mail.", - "VSO": "Gdy użytkownik doda metodę jednorazowego hasła przez SMS, musi ją aktywować, wprowadzając kod wysłany na jego numer telefonu." + "VSO": "Gdy użytkownik doda metodę jednorazowego hasła przez SMS, musi ją aktywować, wprowadzając kod wysłany na jego numer telefonu.", + "IU": "Kiedy zostanie utworzony kod zaproszenia użytkownika, otrzyma on e-mail z linkiem do ustawienia swojej metody uwierzytelniania." } }, "LOGIN_TEXTS": { @@ -1666,7 +1667,8 @@ "PR": "Resetowanie hasła", "DC": "Rejestracja domeny", "PL": "Bez hasła", - "PC": "Zmiana hasła" + "PC": "Zmiana hasła", + "IU": "Zaproś użytkownika" }, "CHIPS": { "firstname": "Imię", @@ -1686,7 +1688,8 @@ "tempUsername": "Tymczasowa nazwa użytkownika", "otp": "Hasło jednorazowe", "verifyUrl": "URL do weryfikacji hasła jednorazowego", - "expiry": "Wygaśnięcie" + "expiry": "Wygaśnięcie", + "applicationName": "Nazwa aplikacji" }, "TOAST": { "UPDATED": "Teksty niestandardowe zapisane." diff --git a/console/src/assets/i18n/pt.json b/console/src/assets/i18n/pt.json index 93739f187d..1bd825e7a1 100644 --- a/console/src/assets/i18n/pt.json +++ b/console/src/assets/i18n/pt.json @@ -197,7 +197,8 @@ "VE": "Quando um usuário muda seu endereço de e-mail, ele receberá um e-mail com um link para verificar o novo endereço.", "VP": "Quando um usuário muda seu número de telefone, ele receberá um SMS com um código para verificar o novo número.", "VEO": "Quando um usuário adiciona um método de Senha Única via e-mail, ele precisa ativá-lo inserindo um código enviado para seu endereço de e-mail.", - "VSO": "Quando um usuário adiciona um método de Senha Única via SMS, ele precisa ativá-lo inserindo um código enviado para seu número de telefone." + "VSO": "Quando um usuário adiciona um método de Senha Única via SMS, ele precisa ativá-lo inserindo um código enviado para seu número de telefone.", + "IU": "Quando um código de convite de usuário é criado, eles receberão um e-mail com um link para configurar seu método de autenticação." } }, "LOGIN_TEXTS": { @@ -1668,7 +1669,8 @@ "PR": "Redefinição de Senha", "DC": "Reivindicação de Domínio", "PL": "Sem senha", - "PC": "Alteração de Senha" + "PC": "Alteração de Senha", + "IU": "Convidar usuário" }, "CHIPS": { "firstname": "Nome próprio", @@ -1688,7 +1690,8 @@ "tempUsername": "Nome de usuário temporário", "otp": "Senha de uso único", "verifyUrl": "URL para verificar a senha de uso único", - "expiry": "Data de expiração" + "expiry": "Data de expiração", + "applicationName": "Nome do aplicativo" }, "TOAST": { "UPDATED": "Textos personalizados salvos." diff --git a/console/src/assets/i18n/ru.json b/console/src/assets/i18n/ru.json index a2ed9ff361..292280af37 100644 --- a/console/src/assets/i18n/ru.json +++ b/console/src/assets/i18n/ru.json @@ -197,7 +197,8 @@ "VE": "Когда пользователь меняет свой адрес электронной почты, он получает электронное письмо со ссылкой для подтверждения нового адреса.", "VP": "Когда пользователь меняет свой телефонный номер, он получает SMS с кодом для подтверждения нового номера.", "VEO": "Когда пользователь добавляет метод одноразового пароля по электронной почте, ему необходимо активировать его, введя код, отправленный на его адрес электронной почты.", - "VSO": "Когда пользователь добавляет метод одноразового пароля по SMS, ему необходимо активировать его, введя код, отправленный на его телефонный номер." + "VSO": "Когда пользователь добавляет метод одноразового пароля по SMS, ему необходимо активировать его, введя код, отправленный на его телефонный номер.", + "IU": "Когда создается код приглашения пользователя, он получит электронное письмо со ссылкой для настройки своего метода аутентификации." } }, "LOGIN_TEXTS": { @@ -1735,7 +1736,8 @@ "PR": "Восстановление пароля", "DC": "Утверждение домена", "PL": "Без пароля", - "PC": "Изменение пароля" + "PC": "Изменение пароля", + "IU": "Пригласить пользователя" }, "CHIPS": { "firstname": "Имя", @@ -1755,7 +1757,8 @@ "tempUsername": "Временное имя пользователя", "otp": "Одноразовый пароль", "verifyUrl": "Проверка URL-адреса с одноразовым паролем", - "expiry": "Срок действия" + "expiry": "Срок действия", + "applicationName": "Имя приложения" }, "TOAST": { "UPDATED": "Тексты сохранены." diff --git a/console/src/assets/i18n/sv.json b/console/src/assets/i18n/sv.json index cc7bc22596..061cbb6bba 100644 --- a/console/src/assets/i18n/sv.json +++ b/console/src/assets/i18n/sv.json @@ -197,7 +197,8 @@ "VE": "När en användare ändrar sin e-postadress, kommer de att få ett mail med en länk för att verifiera den nya adressen.", "VP": "När en användare ändrar sitt telefonnummer, kommer de att få ett SMS med en kod för att verifiera det nya numret.", "VEO": "När en användare lägger till en engångslösenord via e-postmetod, måste de aktivera den genom att ange en kod som skickas till deras e-postadress.", - "VSO": "När en användare lägger till en engångslösenord via SMS-metod, måste de aktivera den genom att ange en kod som skickas till deras telefonnummer." + "VSO": "När en användare lägger till en engångslösenord via SMS-metod, måste de aktivera den genom att ange en kod som skickas till deras telefonnummer.", + "IU": "När en inbjudningskod för användare skapas, får de ett e-mail med en länk för att ställa in sin autentiseringsmetod." } }, "LOGIN_TEXTS": { @@ -1671,7 +1672,8 @@ "PR": "Återställ Lösenord", "DC": "Domänkrav", "PL": "lösenordsfri", - "PC": "Lösenordsändring" + "PC": "Lösenordsändring", + "IU": "Bjud in användare" }, "CHIPS": { "firstname": "Förnamn", @@ -1691,7 +1693,8 @@ "tempUsername": "Tillfälligt användarnamn", "otp": "Engångslösenord", "verifyUrl": "Verifiera Engångslösenord URL", - "expiry": "Utgångsdatum" + "expiry": "Utgångsdatum", + "applicationName": "Applikationsnamn" }, "TOAST": { "UPDATED": "Anpassade Texter sparade." diff --git a/console/src/assets/i18n/zh.json b/console/src/assets/i18n/zh.json index 061f7faac6..48f7ae849e 100644 --- a/console/src/assets/i18n/zh.json +++ b/console/src/assets/i18n/zh.json @@ -197,7 +197,8 @@ "VE": "当用户更改其电子邮件地址时,他们将收到一封带有验证新地址链接的电子邮件。", "VP": "当用户更改其电话号码时,他们将收到一条带有验证新号码的代码的短信。", "VEO": "当用户通过电子邮件方式添加一次性密码时,他们需要通过输入发送到其电子邮件地址的代码来激活它。", - "VSO": "当用户通过短信方式添加一次性密码时,他们需要通过输入发送到其电话号码的代码来激活它。" + "VSO": "当用户通过短信方式添加一次性密码时,他们需要通过输入发送到其电话号码的代码来激活它。", + "IU": "当创建用户邀请代码时,他们将收到一封包含设置其身份验证方法的链接的电子邮件。" } }, "LOGIN_TEXTS": { @@ -1666,7 +1667,8 @@ "PR": "重置密码", "DC": "域名声明", "PL": "无密码身份验证", - "PC": "修改密码" + "PC": "修改密码", + "IU": "邀请用户" }, "CHIPS": { "firstname": "名", @@ -1686,7 +1688,8 @@ "tempUsername": "临时用户名", "otp": "一次性密码", "verifyUrl": "验证一次性密码的URL", - "expiry": "过期时间" + "expiry": "过期时间", + "applicationName": "应用程序名称" }, "TOAST": { "UPDATED": "自定义文本已保存。" diff --git a/internal/api/grpc/admin/custom_text.go b/internal/api/grpc/admin/custom_text.go index 7a24c709bd..0fe664542d 100644 --- a/internal/api/grpc/admin/custom_text.go +++ b/internal/api/grpc/admin/custom_text.go @@ -396,6 +396,54 @@ func (s *Server) ResetCustomPasswordChangeMessageTextToDefault(ctx context.Conte }, nil } +func (s *Server) GetDefaultInviteUserMessageText(ctx context.Context, req *admin_pb.GetDefaultInviteUserMessageTextRequest) (*admin_pb.GetDefaultInviteUserMessageTextResponse, error) { + msg, err := s.query.DefaultMessageTextByTypeAndLanguageFromFileSystem(ctx, domain.InviteUserMessageType, req.Language) + if err != nil { + return nil, err + } + return &admin_pb.GetDefaultInviteUserMessageTextResponse{ + CustomText: text_grpc.ModelCustomMessageTextToPb(msg), + }, nil +} + +func (s *Server) GetCustomInviteUserMessageText(ctx context.Context, req *admin_pb.GetCustomInviteUserMessageTextRequest) (*admin_pb.GetCustomInviteUserMessageTextResponse, error) { + msg, err := s.query.CustomMessageTextByTypeAndLanguage(ctx, authz.GetInstance(ctx).InstanceID(), domain.InviteUserMessageType, req.Language, false) + if err != nil { + return nil, err + } + return &admin_pb.GetCustomInviteUserMessageTextResponse{ + CustomText: text_grpc.ModelCustomMessageTextToPb(msg), + }, nil +} + +func (s *Server) SetDefaultInviteUserMessageText(ctx context.Context, req *admin_pb.SetDefaultInviteUserMessageTextRequest) (*admin_pb.SetDefaultInviteUserMessageTextResponse, error) { + result, err := s.command.SetDefaultMessageText(ctx, authz.GetInstance(ctx).InstanceID(), SetInviteUserCustomTextToDomain(req)) + if err != nil { + return nil, err + } + return &admin_pb.SetDefaultInviteUserMessageTextResponse{ + Details: object.ChangeToDetailsPb( + result.Sequence, + result.EventDate, + result.ResourceOwner, + ), + }, nil +} + +func (s *Server) ResetCustomInviteUserMessageTextToDefault(ctx context.Context, req *admin_pb.ResetCustomInviteUserMessageTextToDefaultRequest) (*admin_pb.ResetCustomInviteUserMessageTextToDefaultResponse, error) { + result, err := s.command.RemoveInstanceMessageTexts(ctx, domain.InviteUserMessageType, language.Make(req.Language)) + if err != nil { + return nil, err + } + return &admin_pb.ResetCustomInviteUserMessageTextToDefaultResponse{ + Details: object.ChangeToDetailsPb( + result.Sequence, + result.EventDate, + result.ResourceOwner, + ), + }, nil +} + func (s *Server) GetDefaultPasswordlessRegistrationMessageText(ctx context.Context, req *admin_pb.GetDefaultPasswordlessRegistrationMessageTextRequest) (*admin_pb.GetDefaultPasswordlessRegistrationMessageTextResponse, error) { msg, err := s.query.DefaultMessageTextByTypeAndLanguageFromFileSystem(ctx, domain.PasswordlessRegistrationMessageType, req.Language) if err != nil { diff --git a/internal/api/grpc/admin/custom_text_converter.go b/internal/api/grpc/admin/custom_text_converter.go index a7471525d9..07cb9792ca 100644 --- a/internal/api/grpc/admin/custom_text_converter.go +++ b/internal/api/grpc/admin/custom_text_converter.go @@ -122,6 +122,21 @@ func SetPasswordChangeCustomTextToDomain(msg *admin_pb.SetDefaultPasswordChangeM } } +func SetInviteUserCustomTextToDomain(msg *admin_pb.SetDefaultInviteUserMessageTextRequest) *domain.CustomMessageText { + langTag := language.Make(msg.Language) + return &domain.CustomMessageText{ + MessageTextType: domain.InviteUserMessageType, + Language: langTag, + Title: msg.Title, + PreHeader: msg.PreHeader, + Subject: msg.Subject, + Greeting: msg.Greeting, + Text: msg.Text, + ButtonText: msg.ButtonText, + FooterText: msg.FooterText, + } +} + func SetPasswordlessRegistrationCustomTextToDomain(msg *admin_pb.SetDefaultPasswordlessRegistrationMessageTextRequest) *domain.CustomMessageText { langTag := language.Make(msg.Language) return &domain.CustomMessageText{ diff --git a/internal/api/grpc/admin/import.go b/internal/api/grpc/admin/import.go index 7c20e7617e..6a8c1a9297 100644 --- a/internal/api/grpc/admin/import.go +++ b/internal/api/grpc/admin/import.go @@ -793,6 +793,7 @@ func importResources(ctx context.Context, s *Server, errors *[]*admin_pb.ImportD importVerifyPhoneMessageTexts(ctx, s, errors, org) importDomainClaimedMessageTexts(ctx, s, errors, org) importPasswordlessRegistrationMessageTexts(ctx, s, errors, org) + importInviteUserMessageTexts(ctx, s, errors, org) if err := importHumanUsers(ctx, s, errors, successOrg, org, count, initCodeGenerator, emailCodeGenerator, phoneCodeGenerator, passwordlessInitCode); err != nil { return err } @@ -975,6 +976,21 @@ func importPasswordlessRegistrationMessageTexts(ctx context.Context, s *Server, } } +func importInviteUserMessageTexts(ctx context.Context, s *Server, errors *[]*admin_pb.ImportDataError, org *admin_pb.DataOrg) { + ctx, span := tracing.NewSpan(ctx) + defer func() { span.End() }() + + if org.PasswordlessRegistrationMessages == nil { + return + } + for _, message := range org.GetInviteUserMessages() { + _, err := s.command.SetOrgMessageText(ctx, authz.GetCtxData(ctx).OrgID, management.SetInviteUserCustomTextToDomain(message)) + if err != nil { + *errors = append(*errors, &admin_pb.ImportDataError{Type: "invite_user_messages", Id: org.GetOrgId() + "_" + message.Language, Message: err.Error()}) + } + } +} + func importOrg2(ctx context.Context, s *Server, errors *[]*admin_pb.ImportDataError, success *admin_pb.ImportDataSuccess, count *counts, org *admin_pb.DataOrg) (err error) { ctx, span := tracing.NewSpan(ctx) defer func() { span.EndWithError(err) }() @@ -1236,6 +1252,7 @@ func (s *Server) dataOrgsV1ToDataOrgs(ctx context.Context, dataOrgs *v1_pb.Impor VerifyPhoneMessages: orgV1.GetVerifyPhoneMessages(), DomainClaimedMessages: orgV1.GetDomainClaimedMessages(), PasswordlessRegistrationMessages: orgV1.GetPasswordlessRegistrationMessages(), + InviteUserMessages: orgV1.GetInviteUserMessages(), OidcIdps: orgV1.GetOidcIdps(), JwtIdps: orgV1.GetJwtIdps(), UserLinks: orgV1.GetUserLinks(), diff --git a/internal/api/grpc/management/custom_text.go b/internal/api/grpc/management/custom_text.go index d18b12676a..8bd91f13c3 100644 --- a/internal/api/grpc/management/custom_text.go +++ b/internal/api/grpc/management/custom_text.go @@ -396,6 +396,54 @@ func (s *Server) ResetCustomPasswordChangeMessageTextToDefault(ctx context.Conte }, nil } +func (s *Server) GetCustomInviteUserMessageText(ctx context.Context, req *mgmt_pb.GetCustomInviteUserMessageTextRequest) (*mgmt_pb.GetCustomInviteUserMessageTextResponse, error) { + msg, err := s.query.CustomMessageTextByTypeAndLanguage(ctx, authz.GetCtxData(ctx).OrgID, domain.InviteUserMessageType, req.Language, false) + if err != nil { + return nil, err + } + return &mgmt_pb.GetCustomInviteUserMessageTextResponse{ + CustomText: text_grpc.ModelCustomMessageTextToPb(msg), + }, nil +} + +func (s *Server) GetDefaultInviteUserMessageText(ctx context.Context, req *mgmt_pb.GetDefaultInviteUserMessageTextRequest) (*mgmt_pb.GetDefaultInviteUserMessageTextResponse, error) { + msg, err := s.query.IAMMessageTextByTypeAndLanguage(ctx, domain.InviteUserMessageType, req.Language) + if err != nil { + return nil, err + } + return &mgmt_pb.GetDefaultInviteUserMessageTextResponse{ + CustomText: text_grpc.ModelCustomMessageTextToPb(msg), + }, nil +} + +func (s *Server) SetCustomInviteUserMessageCustomText(ctx context.Context, req *mgmt_pb.SetCustomInviteUserMessageTextRequest) (*mgmt_pb.SetCustomInviteUserMessageTextResponse, error) { + result, err := s.command.SetOrgMessageText(ctx, authz.GetCtxData(ctx).OrgID, SetInviteUserCustomTextToDomain(req)) + if err != nil { + return nil, err + } + return &mgmt_pb.SetCustomInviteUserMessageTextResponse{ + Details: object.ChangeToDetailsPb( + result.Sequence, + result.EventDate, + result.ResourceOwner, + ), + }, nil +} + +func (s *Server) ResetCustomInviteUserMessageTextToDefault(ctx context.Context, req *mgmt_pb.ResetCustomInviteUserMessageTextToDefaultRequest) (*mgmt_pb.ResetCustomInviteUserMessageTextToDefaultResponse, error) { + result, err := s.command.RemoveOrgMessageTexts(ctx, authz.GetCtxData(ctx).OrgID, domain.InviteUserMessageType, language.Make(req.Language)) + if err != nil { + return nil, err + } + return &mgmt_pb.ResetCustomInviteUserMessageTextToDefaultResponse{ + Details: object.ChangeToDetailsPb( + result.Sequence, + result.EventDate, + result.ResourceOwner, + ), + }, nil +} + func (s *Server) GetCustomPasswordlessRegistrationMessageText(ctx context.Context, req *mgmt_pb.GetCustomPasswordlessRegistrationMessageTextRequest) (*mgmt_pb.GetCustomPasswordlessRegistrationMessageTextResponse, error) { msg, err := s.query.CustomMessageTextByTypeAndLanguage(ctx, authz.GetCtxData(ctx).OrgID, domain.PasswordlessRegistrationMessageType, req.Language, false) if err != nil { diff --git a/internal/api/grpc/management/custom_text_converter.go b/internal/api/grpc/management/custom_text_converter.go index 06dfed6a8d..20770f3665 100644 --- a/internal/api/grpc/management/custom_text_converter.go +++ b/internal/api/grpc/management/custom_text_converter.go @@ -122,6 +122,21 @@ func SetPasswordChangeCustomTextToDomain(msg *mgmt_pb.SetCustomPasswordChangeMes } } +func SetInviteUserCustomTextToDomain(msg *mgmt_pb.SetCustomInviteUserMessageTextRequest) *domain.CustomMessageText { + langTag := language.Make(msg.Language) + return &domain.CustomMessageText{ + MessageTextType: domain.InviteUserMessageType, + Language: langTag, + Title: msg.Title, + PreHeader: msg.PreHeader, + Subject: msg.Subject, + Greeting: msg.Greeting, + Text: msg.Text, + ButtonText: msg.ButtonText, + FooterText: msg.FooterText, + } +} + func SetPasswordlessRegistrationCustomTextToDomain(msg *mgmt_pb.SetCustomPasswordlessRegistrationMessageTextRequest) *domain.CustomMessageText { langTag := language.Make(msg.Language) return &domain.CustomMessageText{ diff --git a/internal/api/grpc/user/v2/integration_test/user_test.go b/internal/api/grpc/user/v2/integration_test/user_test.go index cab073c616..2fc2d05b00 100644 --- a/internal/api/grpc/user/v2/integration_test/user_test.go +++ b/internal/api/grpc/user/v2/integration_test/user_test.go @@ -2437,3 +2437,310 @@ func TestServer_ListAuthenticationMethodTypes(t *testing.T) { }) } } + +func TestServer_CreateInviteCode(t *testing.T) { + type args struct { + ctx context.Context + req *user.CreateInviteCodeRequest + prepare func(request *user.CreateInviteCodeRequest) error + } + tests := []struct { + name string + args args + want *user.CreateInviteCodeResponse + wantErr bool + }{ + { + name: "create, not existing", + args: args{ + CTX, + &user.CreateInviteCodeRequest{ + UserId: "notexisting", + }, + func(request *user.CreateInviteCodeRequest) error { return nil }, + }, + wantErr: true, + }, + { + name: "create, ok", + args: args{ + ctx: CTX, + req: &user.CreateInviteCodeRequest{}, + prepare: func(request *user.CreateInviteCodeRequest) error { + resp := Instance.CreateHumanUser(CTX) + request.UserId = resp.GetUserId() + return nil + }, + }, + want: &user.CreateInviteCodeResponse{ + Details: &object.Details{ + ChangeDate: timestamppb.Now(), + ResourceOwner: Instance.DefaultOrg.Id, + }, + }, + }, + { + name: "create, invalid template", + args: args{ + ctx: CTX, + req: &user.CreateInviteCodeRequest{ + Verification: &user.CreateInviteCodeRequest_SendCode{ + SendCode: &user.SendInviteCode{ + UrlTemplate: gu.Ptr("{{"), + }, + }, + }, + prepare: func(request *user.CreateInviteCodeRequest) error { + resp := Instance.CreateHumanUser(CTX) + request.UserId = resp.GetUserId() + return nil + }, + }, + wantErr: true, + }, + { + name: "create, valid template", + args: args{ + ctx: CTX, + req: &user.CreateInviteCodeRequest{ + Verification: &user.CreateInviteCodeRequest_SendCode{ + SendCode: &user.SendInviteCode{ + UrlTemplate: gu.Ptr("https://example.com/email/verify?userID={{.UserID}}&code={{.Code}}&orgID={{.OrgID}}"), + ApplicationName: gu.Ptr("TestApp"), + }, + }, + }, + prepare: func(request *user.CreateInviteCodeRequest) error { + resp := Instance.CreateHumanUser(CTX) + request.UserId = resp.GetUserId() + return nil + }, + }, + want: &user.CreateInviteCodeResponse{ + Details: &object.Details{ + ChangeDate: timestamppb.Now(), + ResourceOwner: Instance.DefaultOrg.Id, + }, + }, + }, + { + name: "create, return code, ok", + args: args{ + ctx: CTX, + req: &user.CreateInviteCodeRequest{ + Verification: &user.CreateInviteCodeRequest_ReturnCode{ + ReturnCode: &user.ReturnInviteCode{}, + }, + }, + prepare: func(request *user.CreateInviteCodeRequest) error { + resp := Instance.CreateHumanUser(CTX) + request.UserId = resp.GetUserId() + return nil + }, + }, + want: &user.CreateInviteCodeResponse{ + Details: &object.Details{ + ChangeDate: timestamppb.Now(), + ResourceOwner: Instance.DefaultOrg.Id, + }, + InviteCode: gu.Ptr("something"), + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := tt.args.prepare(tt.args.req) + require.NoError(t, err) + + got, err := Client.CreateInviteCode(tt.args.ctx, tt.args.req) + if tt.wantErr { + require.Error(t, err) + } else { + require.NoError(t, err) + } + integration.AssertDetails(t, tt.want, got) + if tt.want.GetInviteCode() != "" { + assert.NotEmpty(t, got.GetInviteCode()) + } else { + assert.Empty(t, got.GetInviteCode()) + } + }) + } +} + +func TestServer_ResendInviteCode(t *testing.T) { + type args struct { + ctx context.Context + req *user.ResendInviteCodeRequest + prepare func(request *user.ResendInviteCodeRequest) error + } + tests := []struct { + name string + args args + want *user.ResendInviteCodeResponse + wantErr bool + }{ + { + name: "user not existing", + args: args{ + CTX, + &user.ResendInviteCodeRequest{ + UserId: "notexisting", + }, + func(request *user.ResendInviteCodeRequest) error { return nil }, + }, + wantErr: true, + }, + { + name: "code not existing", + args: args{ + ctx: CTX, + req: &user.ResendInviteCodeRequest{}, + prepare: func(request *user.ResendInviteCodeRequest) error { + resp := Instance.CreateHumanUser(CTX) + request.UserId = resp.GetUserId() + return nil + }, + }, + wantErr: true, + }, + { + name: "code not sent before", + args: args{ + ctx: CTX, + req: &user.ResendInviteCodeRequest{}, + prepare: func(request *user.ResendInviteCodeRequest) error { + userResp := Instance.CreateHumanUser(CTX) + request.UserId = userResp.GetUserId() + Instance.CreateInviteCode(CTX, userResp.GetUserId()) + return nil + }, + }, + wantErr: true, + }, + { + name: "resend, ok", + args: args{ + ctx: CTX, + req: &user.ResendInviteCodeRequest{}, + prepare: func(request *user.ResendInviteCodeRequest) error { + resp := Instance.CreateHumanUser(CTX) + request.UserId = resp.GetUserId() + _, err := Instance.Client.UserV2.CreateInviteCode(CTX, &user.CreateInviteCodeRequest{ + UserId: resp.GetUserId(), + }) + return err + }, + }, + want: &user.ResendInviteCodeResponse{ + Details: &object.Details{ + ChangeDate: timestamppb.Now(), + ResourceOwner: Instance.DefaultOrg.Id, + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := tt.args.prepare(tt.args.req) + require.NoError(t, err) + + got, err := Client.ResendInviteCode(tt.args.ctx, tt.args.req) + if tt.wantErr { + require.Error(t, err) + } else { + require.NoError(t, err) + } + integration.AssertDetails(t, tt.want, got) + }) + } +} + +func TestServer_VerifyInviteCode(t *testing.T) { + type args struct { + ctx context.Context + req *user.VerifyInviteCodeRequest + prepare func(request *user.VerifyInviteCodeRequest) error + } + tests := []struct { + name string + args args + want *user.VerifyInviteCodeResponse + wantErr bool + }{ + { + name: "user not existing", + args: args{ + CTX, + &user.VerifyInviteCodeRequest{ + UserId: "notexisting", + }, + func(request *user.VerifyInviteCodeRequest) error { return nil }, + }, + wantErr: true, + }, + { + name: "code not existing", + args: args{ + ctx: CTX, + req: &user.VerifyInviteCodeRequest{}, + prepare: func(request *user.VerifyInviteCodeRequest) error { + resp := Instance.CreateHumanUser(CTX) + request.UserId = resp.GetUserId() + return nil + }, + }, + wantErr: true, + }, + { + name: "invalid code", + args: args{ + ctx: CTX, + req: &user.VerifyInviteCodeRequest{ + VerificationCode: "invalid", + }, + prepare: func(request *user.VerifyInviteCodeRequest) error { + userResp := Instance.CreateHumanUser(CTX) + request.UserId = userResp.GetUserId() + Instance.CreateInviteCode(CTX, userResp.GetUserId()) + return nil + }, + }, + wantErr: true, + }, + { + name: "verify, ok", + args: args{ + ctx: CTX, + req: &user.VerifyInviteCodeRequest{}, + prepare: func(request *user.VerifyInviteCodeRequest) error { + userResp := Instance.CreateHumanUser(CTX) + request.UserId = userResp.GetUserId() + codeResp := Instance.CreateInviteCode(CTX, userResp.GetUserId()) + request.VerificationCode = codeResp.GetInviteCode() + return nil + }, + }, + want: &user.VerifyInviteCodeResponse{ + Details: &object.Details{ + ChangeDate: timestamppb.Now(), + ResourceOwner: Instance.DefaultOrg.Id, + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := tt.args.prepare(tt.args.req) + require.NoError(t, err) + + got, err := Client.VerifyInviteCode(tt.args.ctx, tt.args.req) + if tt.wantErr { + require.Error(t, err) + } else { + require.NoError(t, err) + } + integration.AssertDetails(t, tt.want, got) + }) + } +} diff --git a/internal/api/grpc/user/v2/user.go b/internal/api/grpc/user/v2/user.go index e46f6d3cb8..13072e7c7c 100644 --- a/internal/api/grpc/user/v2/user.go +++ b/internal/api/grpc/user/v2/user.go @@ -45,14 +45,6 @@ func AddUserRequestToAddHuman(req *user.AddHumanUserRequest) (*command.AddHuman, if username == "" { username = req.GetEmail().GetEmail() } - var urlTemplate string - if req.GetEmail().GetSendCode() != nil { - urlTemplate = req.GetEmail().GetSendCode().GetUrlTemplate() - // test the template execution so the async notification will not fail because of it and the user won't realize - if err := domain.RenderConfirmURLTemplate(io.Discard, urlTemplate, req.GetUserId(), "code", "orgID"); err != nil { - return nil, err - } - } passwordChangeRequired := req.GetPassword().GetChangeRequired() || req.GetHashedPassword().GetChangeRequired() metadata := make([]*command.AddMetadataEntry, len(req.Metadata)) for i, metadataEntry := range req.Metadata { @@ -69,6 +61,10 @@ func AddUserRequestToAddHuman(req *user.AddHumanUserRequest) (*command.AddHuman, DisplayName: link.GetUserName(), } } + email, err := addUserRequestEmailToCommand(req.GetEmail()) + if err != nil { + return nil, err + } return &command.AddHuman{ ID: req.GetUserId(), Username: username, @@ -76,12 +72,7 @@ func AddUserRequestToAddHuman(req *user.AddHumanUserRequest) (*command.AddHuman, LastName: req.GetProfile().GetFamilyName(), NickName: req.GetProfile().GetNickName(), DisplayName: req.GetProfile().GetDisplayName(), - Email: command.Email{ - Address: domain.EmailAddress(req.GetEmail().GetEmail()), - Verified: req.GetEmail().GetIsVerified(), - ReturnCode: req.GetEmail().GetReturnCode() != nil, - URLTemplate: urlTemplate, - }, + Email: email, Phone: command.Phone{ Number: domain.PhoneNumber(req.GetPhone().GetPhone()), Verified: req.GetPhone().GetIsVerified(), @@ -100,6 +91,25 @@ func AddUserRequestToAddHuman(req *user.AddHumanUserRequest) (*command.AddHuman, }, nil } +func addUserRequestEmailToCommand(email *user.SetHumanEmail) (command.Email, error) { + address := domain.EmailAddress(email.GetEmail()) + switch v := email.GetVerification().(type) { + case *user.SetHumanEmail_ReturnCode: + return command.Email{Address: address, ReturnCode: true}, nil + case *user.SetHumanEmail_SendCode: + urlTemplate := v.SendCode.GetUrlTemplate() + // test the template execution so the async notification will not fail because of it and the user won't realize + if err := domain.RenderConfirmURLTemplate(io.Discard, urlTemplate, "userID", "code", "orgID"); err != nil { + return command.Email{}, err + } + return command.Email{Address: address, URLTemplate: urlTemplate}, nil + case *user.SetHumanEmail_IsVerified: + return command.Email{Address: address, Verified: v.IsVerified, NoEmailVerification: true}, nil + default: + return command.Email{Address: address}, nil + } +} + func genderToDomain(gender user.Gender) domain.Gender { switch gender { case user.Gender_GENDER_UNSPECIFIED: @@ -617,3 +627,54 @@ func authMethodTypeToPb(methodType domain.UserAuthMethodType) user.Authenticatio return user.AuthenticationMethodType_AUTHENTICATION_METHOD_TYPE_UNSPECIFIED } } + +func (s *Server) CreateInviteCode(ctx context.Context, req *user.CreateInviteCodeRequest) (*user.CreateInviteCodeResponse, error) { + invite, err := createInviteCodeRequestToCommand(req) + if err != nil { + return nil, err + } + details, code, err := s.command.CreateInviteCode(ctx, invite) + if err != nil { + return nil, err + } + return &user.CreateInviteCodeResponse{ + Details: object.DomainToDetailsPb(details), + InviteCode: code, + }, nil +} + +func (s *Server) ResendInviteCode(ctx context.Context, req *user.ResendInviteCodeRequest) (*user.ResendInviteCodeResponse, error) { + details, err := s.command.ResendInviteCode(ctx, req.GetUserId(), "", "") + if err != nil { + return nil, err + } + return &user.ResendInviteCodeResponse{ + Details: object.DomainToDetailsPb(details), + }, nil +} + +func (s *Server) VerifyInviteCode(ctx context.Context, req *user.VerifyInviteCodeRequest) (*user.VerifyInviteCodeResponse, error) { + details, err := s.command.VerifyInviteCode(ctx, req.GetUserId(), req.GetVerificationCode()) + if err != nil { + return nil, err + } + return &user.VerifyInviteCodeResponse{ + Details: object.DomainToDetailsPb(details), + }, nil +} + +func createInviteCodeRequestToCommand(req *user.CreateInviteCodeRequest) (*command.CreateUserInvite, error) { + switch v := req.GetVerification().(type) { + case *user.CreateInviteCodeRequest_SendCode: + urlTemplate := v.SendCode.GetUrlTemplate() + // test the template execution so the async notification will not fail because of it and the user won't realize + if err := domain.RenderConfirmURLTemplate(io.Discard, urlTemplate, req.GetUserId(), "code", "orgID"); err != nil { + return nil, err + } + return &command.CreateUserInvite{UserID: req.GetUserId(), URLTemplate: urlTemplate, ApplicationName: v.SendCode.GetApplicationName()}, nil + case *user.CreateInviteCodeRequest_ReturnCode: + return &command.CreateUserInvite{UserID: req.GetUserId(), ReturnCode: true}, nil + default: + return &command.CreateUserInvite{UserID: req.GetUserId()}, nil + } +} diff --git a/internal/api/ui/login/invite_user_handler.go b/internal/api/ui/login/invite_user_handler.go new file mode 100644 index 0000000000..3141af2d78 --- /dev/null +++ b/internal/api/ui/login/invite_user_handler.go @@ -0,0 +1,154 @@ +package login + +import ( + "net/http" + "net/url" + + http_mw "github.com/zitadel/zitadel/internal/api/http/middleware" + "github.com/zitadel/zitadel/internal/domain" + "github.com/zitadel/zitadel/internal/zerrors" +) + +const ( + queryInviteUserCode = "code" + queryInviteUserUserID = "userID" + queryInviteUserLoginName = "loginname" + + tmplInviteUser = "inviteuser" +) + +type inviteUserFormData struct { + Code string `schema:"code"` + LoginName string `schema:"loginname"` + Password string `schema:"password"` + PasswordConfirm string `schema:"passwordconfirm"` + UserID string `schema:"userID"` + OrgID string `schema:"orgID"` + Resend bool `schema:"resend"` +} + +type inviteUserData struct { + baseData + profileData + Code string + LoginName string + UserID string + MinLength uint64 + HasUppercase string + HasLowercase string + HasNumber string + HasSymbol string +} + +func InviteUserLink(origin, userID, loginName, code, orgID string, authRequestID string) string { + v := url.Values{} + v.Set(queryInviteUserUserID, userID) + v.Set(queryInviteUserLoginName, loginName) + v.Set(queryInviteUserCode, code) + v.Set(queryOrgID, orgID) + v.Set(QueryAuthRequestID, authRequestID) + return externalLink(origin) + EndpointInviteUser + "?" + v.Encode() +} + +func (l *Login) handleInviteUser(w http.ResponseWriter, r *http.Request) { + authReq := l.checkOptionalAuthRequestOfEmailLinks(r) + userID := r.FormValue(queryInviteUserUserID) + orgID := r.FormValue(queryOrgID) + code := r.FormValue(queryInviteUserCode) + loginName := r.FormValue(queryInviteUserLoginName) + l.renderInviteUser(w, r, authReq, userID, orgID, loginName, code, nil) +} + +func (l *Login) handleInviteUserCheck(w http.ResponseWriter, r *http.Request) { + data := new(inviteUserFormData) + authReq, err := l.getAuthRequestAndParseData(r, data) + if err != nil { + l.renderError(w, r, nil, err) + return + } + + if data.Resend { + l.resendUserInvite(w, r, authReq, data.UserID, data.OrgID, data.LoginName) + return + } + l.checkUserInviteCode(w, r, authReq, data) +} + +func (l *Login) checkUserInviteCode(w http.ResponseWriter, r *http.Request, authReq *domain.AuthRequest, data *inviteUserFormData) { + if data.Password != data.PasswordConfirm { + err := zerrors.ThrowInvalidArgument(nil, "VIEW-KJS3h", "Errors.User.Password.ConfirmationWrong") + l.renderInviteUser(w, r, authReq, data.UserID, data.OrgID, data.LoginName, data.Code, err) + return + } + userOrgID := "" + if authReq != nil { + userOrgID = authReq.UserOrgID + } + userAgentID, _ := http_mw.UserAgentIDFromCtx(r.Context()) + _, err := l.command.VerifyInviteCodeSetPassword(setUserContext(r.Context(), data.UserID, userOrgID), data.UserID, data.Code, data.Password, userAgentID) + if err != nil { + l.renderInviteUser(w, r, authReq, data.UserID, data.OrgID, data.LoginName, "", err) + return + } + if authReq == nil { + l.defaultRedirect(w, r) + return + } + l.renderNextStep(w, r, authReq) +} + +func (l *Login) resendUserInvite(w http.ResponseWriter, r *http.Request, authReq *domain.AuthRequest, userID, orgID, loginName string) { + var userOrgID, authRequestID string + if authReq != nil { + userOrgID = authReq.UserOrgID + authRequestID = authReq.ID + } + _, err := l.command.ResendInviteCode(setUserContext(r.Context(), userID, userOrgID), userID, userOrgID, authRequestID) + l.renderInviteUser(w, r, authReq, userID, orgID, loginName, "", err) +} + +func (l *Login) renderInviteUser(w http.ResponseWriter, r *http.Request, authReq *domain.AuthRequest, userID, orgID, loginName string, code string, err error) { + var errID, errMessage string + if err != nil { + errID, errMessage = l.getErrorMessage(r, err) + } + if authReq != nil { + userID = authReq.UserID + orgID = authReq.UserOrgID + } + + translator := l.getTranslator(r.Context(), authReq) + data := inviteUserData{ + baseData: l.getBaseData(r, authReq, translator, "InviteUser.Title", "InviteUser.Description", errID, errMessage), + profileData: l.getProfileData(authReq), + UserID: userID, + Code: code, + } + // if the user clicked on the link in the mail, we need to make sure the loginName is rendered + if authReq == nil { + data.LoginName = loginName + data.UserName = loginName + } + policy := l.getPasswordComplexityPolicyByUserID(r, userID) + if policy != nil { + data.MinLength = policy.MinLength + if policy.HasUppercase { + data.HasUppercase = UpperCaseRegex + } + if policy.HasLowercase { + data.HasLowercase = LowerCaseRegex + } + if policy.HasSymbol { + data.HasSymbol = SymbolRegex + } + if policy.HasNumber { + data.HasNumber = NumberRegex + } + } + if authReq == nil { + if err == nil { + l.customTexts(r.Context(), translator, orgID) + } + } + l.renderer.RenderTemplate(w, r, translator, l.renderer.Templates[tmplInviteUser], data, nil) +} diff --git a/internal/api/ui/login/renderer.go b/internal/api/ui/login/renderer.go index 3cfcb60cf0..cb05f78323 100644 --- a/internal/api/ui/login/renderer.go +++ b/internal/api/ui/login/renderer.go @@ -68,6 +68,7 @@ func CreateRenderer(pathPrefix string, staticStorage static.Storage, cookieName tmplInitPasswordDone: "init_password_done.html", tmplInitUser: "init_user.html", tmplInitUserDone: "init_user_done.html", + tmplInviteUser: "invite_user.html", tmplPasswordResetDone: "password_reset_done.html", tmplChangePassword: "change_password.html", tmplChangePasswordDone: "change_password_done.html", @@ -193,6 +194,9 @@ func CreateRenderer(pathPrefix string, staticStorage static.Storage, cookieName "initUserUrl": func() string { return path.Join(r.pathPrefix, EndpointInitUser) }, + "inviteUserUrl": func() string { + return path.Join(r.pathPrefix, EndpointInviteUser) + }, "changePasswordUrl": func() string { return path.Join(r.pathPrefix, EndpointChangePassword) }, @@ -329,6 +333,8 @@ func (l *Login) chooseNextStep(w http.ResponseWriter, r *http.Request, authReq * l.renderInternalError(w, r, authReq, zerrors.ThrowPreconditionFailed(nil, "APP-asb43", "Errors.User.GrantRequired")) case *domain.ProjectRequiredStep: l.renderInternalError(w, r, authReq, zerrors.ThrowPreconditionFailed(nil, "APP-m92d", "Errors.User.ProjectRequired")) + case *domain.VerifyInviteStep: + l.renderInviteUser(w, r, authReq, "", "", "", "", nil) default: l.renderInternalError(w, r, authReq, zerrors.ThrowInternal(nil, "APP-ds3QF", "step no possible")) } diff --git a/internal/api/ui/login/router.go b/internal/api/ui/login/router.go index a815784af5..6e346c9da0 100644 --- a/internal/api/ui/login/router.go +++ b/internal/api/ui/login/router.go @@ -30,6 +30,7 @@ const ( EndpointChangePassword = "/password/change" EndpointPasswordReset = "/password/reset" EndpointInitUser = "/user/init" + EndpointInviteUser = "/user/invite" EndpointMFAVerify = "/mfa/verify" EndpointMFAPrompt = "/mfa/prompt" EndpointMFAInitVerify = "/mfa/init/verify" @@ -94,6 +95,8 @@ func CreateRouter(login *Login, interceptors ...mux.MiddlewareFunc) *mux.Router router.HandleFunc(EndpointPasswordReset, login.handlePasswordReset).Methods(http.MethodGet) router.HandleFunc(EndpointInitUser, login.handleInitUser).Methods(http.MethodGet) router.HandleFunc(EndpointInitUser, login.handleInitUserCheck).Methods(http.MethodPost) + router.HandleFunc(EndpointInviteUser, login.handleInviteUser).Methods(http.MethodGet) + router.HandleFunc(EndpointInviteUser, login.handleInviteUserCheck).Methods(http.MethodPost) router.HandleFunc(EndpointMFAVerify, login.handleMFAVerify).Methods(http.MethodPost) router.HandleFunc(EndpointMFAPrompt, login.handleMFAPromptSelection).Methods(http.MethodGet) router.HandleFunc(EndpointMFAPrompt, login.handleMFAPrompt).Methods(http.MethodPost) diff --git a/internal/api/ui/login/static/i18n/bg.yaml b/internal/api/ui/login/static/i18n/bg.yaml index 8b23a236cf..4702792bf6 100644 --- a/internal/api/ui/login/static/i18n/bg.yaml +++ b/internal/api/ui/login/static/i18n/bg.yaml @@ -81,6 +81,14 @@ InitUserDone: Description: Имейлът е потвърден и паролата е успешно зададена NextButtonText: следващия CancelButtonText: анулиране +InviteUser: + Title: Активиране на потребителя + Description: Проверете своя имейл с кода по-долу и задайте паролата си. + CodeLabel: Код + NewPasswordLabel: Нова парола + NewPasswordConfirm: Потвърди парола + NextButtonText: Напред + ResendButtonText: Изпрати отново код InitMFAPrompt: Title: 2-факторна настройка Description: >- diff --git a/internal/api/ui/login/static/i18n/cs.yaml b/internal/api/ui/login/static/i18n/cs.yaml index f93753de16..14e5dcc1fb 100644 --- a/internal/api/ui/login/static/i18n/cs.yaml +++ b/internal/api/ui/login/static/i18n/cs.yaml @@ -86,6 +86,15 @@ InitUserDone: NextButtonText: Další CancelButtonText: Zrušit +InviteUser: + Title: Aktivace uživatele + Description: Ověřte svůj e-mail pomocí níže uvedeného kódu a nastavte si heslo. + CodeLabel: Kód + NewPasswordLabel: Nové heslo + NewPasswordConfirm: Potvrďte heslo + NextButtonText: Další + ResendButtonText: Odeslat kód znovu + InitMFAPrompt: Title: Nastavení 2-faktorové autentizace Description: 2-faktorová autentizace vám poskytuje další zabezpečení pro váš uživatelský účet. Tím je zajištěno, že k vašemu účtu máte přístup pouze vy. diff --git a/internal/api/ui/login/static/i18n/de.yaml b/internal/api/ui/login/static/i18n/de.yaml index 7e4a2bee44..29e7fc54de 100644 --- a/internal/api/ui/login/static/i18n/de.yaml +++ b/internal/api/ui/login/static/i18n/de.yaml @@ -86,6 +86,15 @@ InitUserDone: NextButtonText: Weiter CancelButtonText: Abbrechen +InviteUser: + Title: Benutzer aktivieren + Description: Bestätige deine E-Mail-Adresse mit dem unten stehenden Code und lege dein Passwort fest. + CodeLabel: Code + NewPasswordLabel: Neues Passwort + NewPasswordConfirm: Passwort bestätigen + NextButtonText: Weiter + ResendButtonText: Code erneut senden + InitMFAPrompt: Title: Zweitfaktor hinzufügen Description: Die Zwei-Faktor-Authentifizierung gibt dir eine zusätzliche Sicherheit für dein Benutzerkonto. Damit stellst du sicher, dass nur du Zugriff auf dein Konto hast. diff --git a/internal/api/ui/login/static/i18n/en.yaml b/internal/api/ui/login/static/i18n/en.yaml index 7c4cfdb993..bf0f3d0a5e 100644 --- a/internal/api/ui/login/static/i18n/en.yaml +++ b/internal/api/ui/login/static/i18n/en.yaml @@ -86,6 +86,15 @@ InitUserDone: NextButtonText: Next CancelButtonText: Cancel +InviteUser: + Title: Activate User + Description: Verify your e-mail with the code below and set your password. + CodeLabel: Code + NewPasswordLabel: New Password + NewPasswordConfirm: Confirm Password + NextButtonText: Next + ResendButtonText: Resend Code + InitMFAPrompt: Title: 2-Factor Setup Description: 2-factor authentication gives you an additional security for your user account. This ensures that only you have access to your account. diff --git a/internal/api/ui/login/static/i18n/es.yaml b/internal/api/ui/login/static/i18n/es.yaml index 34121a936e..b797b37a71 100644 --- a/internal/api/ui/login/static/i18n/es.yaml +++ b/internal/api/ui/login/static/i18n/es.yaml @@ -86,6 +86,15 @@ InitUserDone: NextButtonText: siguiente CancelButtonText: cancelar +InviteUser: + Title: Activar usuario + Description: Verifica tu email con el siguiente código y establece tu contraseña. + CodeLabel: Código + NewPasswordLabel: Nueva contraseña + NewPasswordConfirm: Confirmar contraseña + NextButtonText: siguiente + ResendButtonText: reenviar código + InitMFAPrompt: Title: Configuración de doble factor Description: La autenticación de doble factor te proporciona seguridad adicional para tu cuenta de usuario. Ésta asegura que solo tú tienes acceso a tu cuenta. diff --git a/internal/api/ui/login/static/i18n/fr.yaml b/internal/api/ui/login/static/i18n/fr.yaml index 7a7e71cff5..080b3d7aa2 100644 --- a/internal/api/ui/login/static/i18n/fr.yaml +++ b/internal/api/ui/login/static/i18n/fr.yaml @@ -86,6 +86,15 @@ InitUserDone: NextButtonText: Suivant CancelButtonText: Annuler +InviteUser: + Title: Activer l'utilisateur + Description: Vérifiez votre e-mail avec le code ci-dessous et définissez votre mot de passe. + CodeLabel: Code + NewPasswordLabel: Nouveau mot de passe + NewPasswordConfirm: Confirmer le mot de passe + NextButtonText: Suivant + ResendButtonText: Renvoyer le code + InitMFAPrompt: Title: Configuration authentification à 2 facteurs Description: L'authentification authentification à 2 facteurs vous offre une sécurité supplémentaire pour votre compte d'utilisateur. Vous êtes ainsi assuré d'être le seul à avoir accès à votre compte. diff --git a/internal/api/ui/login/static/i18n/id.yaml b/internal/api/ui/login/static/i18n/id.yaml index 7516ce898a..e407ad59c9 100644 --- a/internal/api/ui/login/static/i18n/id.yaml +++ b/internal/api/ui/login/static/i18n/id.yaml @@ -76,6 +76,14 @@ InitUserDone: Description: Email terverifikasi dan Kata Sandi berhasil ditetapkan NextButtonText: Berikutnya CancelButtonText: Membatalkan +InviteUser: + Title: Aktifkan Pengguna + Description: Verifikasi email Anda dengan kode di bawah ini dan atur kata sandi Anda. + CodeLabel: Kode + NewPasswordLabel: Kata Sandi Baru + NewPasswordConfirm: Konfirmasi Kata Sandi + NextButtonText: Selanjutnya + ResendButtonText: Kirim Ulang Kode InitMFAPrompt: Title: Pengaturan 2 Faktor Description: Otentikasi 2 faktor memberi Anda keamanan tambahan untuk akun pengguna Anda. diff --git a/internal/api/ui/login/static/i18n/it.yaml b/internal/api/ui/login/static/i18n/it.yaml index 3d447f8c7a..02b354874c 100644 --- a/internal/api/ui/login/static/i18n/it.yaml +++ b/internal/api/ui/login/static/i18n/it.yaml @@ -86,6 +86,15 @@ InitUserDone: NextButtonText: Avanti CancelButtonText: annulla +InviteUser: + Title: Attiva utente + Description: Verifica la tua email con il codice seguente e imposta la tua password. + CodeLabel: Codice + NewPasswordLabel: Nuova password + NewPasswordConfirm: Conferma password + NextButtonText: Avanti + ResendButtonText: Reinvia codice + InitMFAPrompt: Title: Impostazione a 2 fattori Description: L'autenticazione a due fattori offre un'ulteriore sicurezza al vostro account utente. Questo garantisce che solo voi possiate accedere al vostro account. diff --git a/internal/api/ui/login/static/i18n/ja.yaml b/internal/api/ui/login/static/i18n/ja.yaml index 6e6da76992..4b93968e9e 100644 --- a/internal/api/ui/login/static/i18n/ja.yaml +++ b/internal/api/ui/login/static/i18n/ja.yaml @@ -79,6 +79,15 @@ InitUserDone: NextButtonText: 次へ CancelButtonText: キャンセル +InviteUser: + Title: ユーザーの有効化 + Description: 下のコードでメールアドレスを確認し、パスワードを設定してください。 + CodeLabel: コード + NewPasswordLabel: 新しいパスワード + NewPasswordConfirm: パスワードの確認 + NextButtonText: 次へ + ResendButtonText: コードを再送信 + InitMFAPrompt: Title: 二要素認証のセットアップ Description: 二要素認証でアカウントのセキュリティを強化します。 diff --git a/internal/api/ui/login/static/i18n/mk.yaml b/internal/api/ui/login/static/i18n/mk.yaml index cb5b94b65b..120ff06603 100644 --- a/internal/api/ui/login/static/i18n/mk.yaml +++ b/internal/api/ui/login/static/i18n/mk.yaml @@ -86,6 +86,15 @@ InitUserDone: NextButtonText: следно CancelButtonText: откажи +InviteUser: + Title: Активирање на корисникот + Description: Проверете го вашиот имејл со кодот подолу и поставете ја вашата лозинка. + CodeLabel: Код + NewPasswordLabel: Нова лозинка + NewPasswordConfirm: Потврди лозинка + NextButtonText: Следно + ResendButtonText: Повторно испрати код + InitMFAPrompt: Title: Подесување на 2-факторска автентикација Description: 2-факторската автентикација ви дава дополнителна безбедност за вашата корисничка сметка. Ова обезбедува само вие да имате пристап до вашата сметка. diff --git a/internal/api/ui/login/static/i18n/nl.yaml b/internal/api/ui/login/static/i18n/nl.yaml index c6739bdbf0..b7efd00440 100644 --- a/internal/api/ui/login/static/i18n/nl.yaml +++ b/internal/api/ui/login/static/i18n/nl.yaml @@ -86,6 +86,15 @@ InitUserDone: NextButtonText: Volgende CancelButtonText: Annuleren +InviteUser: + Title: Gebruiker activeren + Description: Verifieer uw e-mail met de onderstaande code en stel uw wachtwoord in. + CodeLabel: Code + NewPasswordLabel: Nieuw wachtwoord + NewPasswordConfirm: Wachtwoord bevestigen + NextButtonText: Volgende + ResendButtonText: Code opnieuw verzenden + InitMFAPrompt: Title: 2-Factor Setup Description: 2-factor authenticatie geeft u extra beveiliging voor uw gebruikersaccount. Hierdoor bent u de enige die toegang heeft tot uw account. diff --git a/internal/api/ui/login/static/i18n/pl.yaml b/internal/api/ui/login/static/i18n/pl.yaml index 4361dfd28a..4feca3ede5 100644 --- a/internal/api/ui/login/static/i18n/pl.yaml +++ b/internal/api/ui/login/static/i18n/pl.yaml @@ -86,6 +86,15 @@ InitUserDone: NextButtonText: dalej CancelButtonText: anuluj +InviteUser: + Title: Aktywuj użytkownika + Description: Zweryfikuj swój adres e-mail za pomocą poniższego kodu i ustaw swoje hasło. + CodeLabel: Kod + NewPasswordLabel: Nowe hasło + NewPasswordConfirm: Potwierdź hasło + NextButtonText: Dalej + ResendButtonText: Wyślij ponownie kod + InitMFAPrompt: Title: Konfiguracja 2-etapowego uwierzytelniania Description: 2-etapowe uwierzytelnianie daje Ci dodatkową ochronę dla Twojego konta użytkownika. Dzięki temu masz pewność, że tylko Ty masz dostęp do swojego konta. diff --git a/internal/api/ui/login/static/i18n/pt.yaml b/internal/api/ui/login/static/i18n/pt.yaml index 3ab1923c0d..048c842c6e 100644 --- a/internal/api/ui/login/static/i18n/pt.yaml +++ b/internal/api/ui/login/static/i18n/pt.yaml @@ -86,6 +86,15 @@ InitUserDone: NextButtonText: próximo CancelButtonText: cancelar +InviteUser: + Title: Ativar usuário + Description: Verifique seu e-mail com o código abaixo e defina sua senha. + CodeLabel: Código + NewPasswordLabel: Nova senha + NewPasswordConfirm: Confirmar senha + NextButtonText: Próximo + ResendButtonText: Reenviar código + InitMFAPrompt: Title: Configuração de 2 fatores Description: A autenticação de 2 fatores fornece uma segurança adicional para sua conta de usuário. Isso garante que apenas você tenha acesso à sua conta. diff --git a/internal/api/ui/login/static/i18n/ru.yaml b/internal/api/ui/login/static/i18n/ru.yaml index b738d735bc..9f562ce520 100644 --- a/internal/api/ui/login/static/i18n/ru.yaml +++ b/internal/api/ui/login/static/i18n/ru.yaml @@ -85,6 +85,15 @@ InitUserDone: NextButtonText: далее CancelButtonText: отмена +InviteUser: + Title: Активировать пользователя + Description: Проверьте свой адрес электронной почты с помощью кода ниже и установите свой пароль. + CodeLabel: Код + NewPasswordLabel: Новый пароль + NewPasswordConfirm: Подтвердить пароль + NextButtonText: Далее + ResendButtonText: Отправить код повторно + InitMFAPrompt: Title: Установка двухфакторной аутентификации Description: Двухфакторная аутентификация обеспечивает дополнительную защиту вашей учётной записи. diff --git a/internal/api/ui/login/static/i18n/sv.yaml b/internal/api/ui/login/static/i18n/sv.yaml index 22dacb7f51..ae91b8dedf 100644 --- a/internal/api/ui/login/static/i18n/sv.yaml +++ b/internal/api/ui/login/static/i18n/sv.yaml @@ -86,6 +86,15 @@ InitUserDone: NextButtonText: Fortsätt CancelButtonText: Avbryt +InviteUser: + Title: Aktivera användare + Description: Verifiera din e-post med koden nedan och sätt ditt lösenord. + CodeLabel: Kod + NewPasswordLabel: Nytt lösenord + NewPasswordConfirm: Bekräfta lösenord + NextButtonText: Nästa + ResendButtonText: Skicka koden igen + InitMFAPrompt: Title: tvåfaktorinställningar Description: 2-factor-identifiering ökar säkerheten för ditt konto. Enbart du som har tillgång till enheten kan logga in. diff --git a/internal/api/ui/login/static/i18n/zh.yaml b/internal/api/ui/login/static/i18n/zh.yaml index a3a5d5ab04..6131e62ce0 100644 --- a/internal/api/ui/login/static/i18n/zh.yaml +++ b/internal/api/ui/login/static/i18n/zh.yaml @@ -86,6 +86,15 @@ InitUserDone: NextButtonText: 继续 CancelButtonText: 取消 +InviteUser: + Title: 激活用户 + Description: 使用以下代码验证您的电子邮件并设置您的密码。 + CodeLabel: 代码 + NewPasswordLabel: 新密码 + NewPasswordConfirm: 确认密码 + NextButtonText: 下一步 + ResendButtonText: 重新发送代码 + InitMFAPrompt: Title: 两步验证设置 Description: 两步验证为您的账户提供了额外的安全保障。这确保只有你能访问你的账户。 diff --git a/internal/api/ui/login/static/templates/invite_user.html b/internal/api/ui/login/static/templates/invite_user.html new file mode 100644 index 0000000000..70baaa81ec --- /dev/null +++ b/internal/api/ui/login/static/templates/invite_user.html @@ -0,0 +1,63 @@ +{{template "main-top" .}} + +
+

{{t "InviteUser.Title"}}

+ + {{ template "user-profile" . }} + +

{{t "InviteUser.Description"}}

+
+ +
+ + {{ .CSRF }} + + + + + + +
+
+ + +
+ +
+ + +
+
+ + + {{ template "password-complexity-policy-description" . }} +
+
+ + {{ template "error-message" .}} + +
+ + + + + + + + + + +
+
+ + + + + +{{template "main-bottom" .}} diff --git a/internal/auth/repository/eventsourcing/eventstore/auth_request.go b/internal/auth/repository/eventsourcing/eventstore/auth_request.go index 9a9c52012c..bec5833853 100644 --- a/internal/auth/repository/eventsourcing/eventstore/auth_request.go +++ b/internal/auth/repository/eventsourcing/eventstore/auth_request.go @@ -106,6 +106,7 @@ type idpUserLinksProvider interface { type userEventProvider interface { UserEventsByID(ctx context.Context, id string, changeDate time.Time, eventTypes []eventstore.EventType) ([]eventstore.Event, error) PasswordCodeExists(ctx context.Context, userID string) (exists bool, err error) + InviteCodeExists(ctx context.Context, userID string) (exists bool, err error) } type userCommandProvider interface { @@ -1254,8 +1255,18 @@ func (repo *AuthRequestRepo) firstFactorChecked(ctx context.Context, request *do if user.PasswordInitRequired { if !user.IsEmailVerified { + // If the user was created through the user resource API, + // they can either have an invite code... + exists, err := repo.UserEventProvider.InviteCodeExists(ctx, user.ID) + logging.WithFields("userID", user.ID).OnError(err).Error("unable to check if invite code exists") + if err == nil && exists { + return &domain.VerifyInviteStep{} + } + // or were created with an explicit email verification mail return &domain.VerifyEMailStep{InitPassword: true} } + // If they were created with a verified mail, they might have never received mail to set their password, + // e.g. when created through a user resource API. In this case we'll just create and send one now. exists, err := repo.UserEventProvider.PasswordCodeExists(ctx, user.ID) logging.WithFields("userID", user.ID).OnError(err).Error("unable to check if password code exists") if err == nil && !exists { diff --git a/internal/auth/repository/eventsourcing/eventstore/auth_request_test.go b/internal/auth/repository/eventsourcing/eventstore/auth_request_test.go index 96a7fc8e48..276cb1fd07 100644 --- a/internal/auth/repository/eventsourcing/eventstore/auth_request_test.go +++ b/internal/auth/repository/eventsourcing/eventstore/auth_request_test.go @@ -110,8 +110,9 @@ func (m *mockViewNoUser) UserByID(context.Context, string, string) (*user_view_m } type mockEventUser struct { - Events []eventstore.Event - CodeExists bool + Events []eventstore.Event + PwCodeExists bool + InvitationCodeExists bool } func (m *mockEventUser) UserEventsByID(ctx context.Context, id string, changeDate time.Time, types []eventstore.EventType) ([]eventstore.Event, error) { @@ -119,7 +120,11 @@ func (m *mockEventUser) UserEventsByID(ctx context.Context, id string, changeDat } func (m *mockEventUser) PasswordCodeExists(ctx context.Context, userID string) (bool, error) { - return m.CodeExists, nil + return m.PwCodeExists, nil +} + +func (m *mockEventUser) InviteCodeExists(ctx context.Context, userID string) (bool, error) { + return m.InvitationCodeExists, nil } func (m *mockEventUser) GetLatestUserSessionSequence(ctx context.Context, instanceID string) (*query.CurrentState, error) { @@ -140,6 +145,10 @@ func (m *mockEventErrUser) PasswordCodeExists(ctx context.Context, userID string return false, zerrors.ThrowInternal(nil, "id", "internal error") } +func (m *mockEventErrUser) InviteCodeExists(ctx context.Context, userID string) (bool, error) { + return false, zerrors.ThrowInternal(nil, "id", "internal error") +} + type mockViewUser struct { InitRequired bool PasswordInitRequired bool @@ -1019,6 +1028,36 @@ func TestAuthRequestRepo_nextSteps(t *testing.T) { []domain.NextStep{&domain.VerifyEMailStep{}}, nil, }, + { + "password not set (email not verified), invite code exists, invite step", + fields{ + userSessionViewProvider: &mockViewUserSession{}, + userViewProvider: &mockViewUser{ + PasswordInitRequired: true, + }, + userEventProvider: &mockEventUser{ + InvitationCodeExists: true, + }, + lockoutPolicyProvider: &mockLockoutPolicy{ + policy: &query.LockoutPolicy{ + ShowFailures: true, + }, + }, + orgViewProvider: &mockViewOrg{State: domain.OrgStateActive}, + idpUserLinksProvider: &mockIDPUserLinks{}, + }, + args{ + &domain.AuthRequest{ + UserID: "UserID", + LoginPolicy: &domain.LoginPolicy{ + AllowUsernamePassword: true, + }, + }, + false, + }, + []domain.NextStep{&domain.VerifyInviteStep{}}, + nil, + }, { "password not set (email not verified), init password step", fields{ @@ -1056,7 +1095,7 @@ func TestAuthRequestRepo_nextSteps(t *testing.T) { IsEmailVerified: true, }, userEventProvider: &mockEventUser{ - CodeExists: true, + PwCodeExists: true, }, lockoutPolicyProvider: &mockLockoutPolicy{ policy: &query.LockoutPolicy{ @@ -1088,7 +1127,7 @@ func TestAuthRequestRepo_nextSteps(t *testing.T) { IsEmailVerified: true, }, userEventProvider: &mockEventUser{ - CodeExists: false, + PwCodeExists: false, }, lockoutPolicyProvider: &mockLockoutPolicy{ policy: &query.LockoutPolicy{ diff --git a/internal/auth/repository/eventsourcing/eventstore/user.go b/internal/auth/repository/eventsourcing/eventstore/user.go index 0a33199b0a..b11f770d77 100644 --- a/internal/auth/repository/eventsourcing/eventstore/user.go +++ b/internal/auth/repository/eventsourcing/eventstore/user.go @@ -93,3 +93,41 @@ func (repo *UserRepo) PasswordCodeExists(ctx context.Context, userID string) (ex } return model.exists, nil } + +type inviteCodeCheck struct { + userID string + + exists bool + events int +} + +func (p *inviteCodeCheck) Reduce() error { + p.exists = p.events > 0 + return nil +} + +func (p *inviteCodeCheck) AppendEvents(events ...eventstore.Event) { + p.events += len(events) +} + +func (p *inviteCodeCheck) Query() *eventstore.SearchQueryBuilder { + return eventstore.NewSearchQueryBuilder(eventstore.ColumnsEvent). + AddQuery(). + AggregateTypes(user.AggregateType). + AggregateIDs(p.userID). + EventTypes( + user.HumanInviteCodeAddedType, + user.HumanInviteCodeSentType). + Builder() +} + +func (repo *UserRepo) InviteCodeExists(ctx context.Context, userID string) (exists bool, err error) { + model := &inviteCodeCheck{ + userID: userID, + } + err = repo.Eventstore.FilterToQueryReducer(ctx, model) + if err != nil { + return false, zerrors.ThrowPermissionDenied(err, "EVENT-GJ2os", "Errors.Internal") + } + return model.exists, nil +} diff --git a/internal/command/crypto.go b/internal/command/crypto.go index 5db4764f28..45c597fd95 100644 --- a/internal/command/crypto.go +++ b/internal/command/crypto.go @@ -41,14 +41,6 @@ func newEncryptedCodeWithDefaultConfig(ctx context.Context, filter preparation.F }, nil } -func verifyEncryptedCode(ctx context.Context, filter preparation.FilterToQueryReducer, typ domain.SecretGeneratorType, alg crypto.EncryptionAlgorithm, creation time.Time, expiry time.Duration, crypted *crypto.CryptoValue, plain string) error { - gen, _, err := encryptedCodeGenerator(ctx, filter, typ, alg, emptyConfig) - if err != nil { - return err - } - return crypto.VerifyCode(creation, expiry, crypted, plain, gen.Alg()) -} - func encryptedCodeGenerator(ctx context.Context, filter preparation.FilterToQueryReducer, typ domain.SecretGeneratorType, alg crypto.EncryptionAlgorithm, defaultConfig *crypto.GeneratorConfig) (crypto.Generator, *crypto.GeneratorConfig, error) { config, err := cryptoGeneratorConfigWithDefault(ctx, filter, typ, defaultConfig) if err != nil { diff --git a/internal/command/crypto_test.go b/internal/command/crypto_test.go index 07db20e2ec..815539120a 100644 --- a/internal/command/crypto_test.go +++ b/internal/command/crypto_test.go @@ -123,78 +123,6 @@ func Test_newCryptoCode(t *testing.T) { } } -func Test_verifyCryptoCode(t *testing.T) { - es := eventstoreExpect(t, expectFilter( - eventFromEventPusher(testSecretGeneratorAddedEvent(domain.SecretGeneratorTypeVerifyEmailCode)), - )) - code, err := newEncryptedCode(context.Background(), es.Filter, domain.SecretGeneratorTypeVerifyEmailCode, crypto.CreateMockEncryptionAlg(gomock.NewController(t))) //nolint:staticcheck - require.NoError(t, err) - - type args struct { - typ domain.SecretGeneratorType - alg crypto.EncryptionAlgorithm - expiry time.Duration - crypted *crypto.CryptoValue - plain string - } - tests := []struct { - name string - eventsore *eventstore.Eventstore - args args - wantErr bool - }{ - { - name: "filter config error", - eventsore: eventstoreExpect(t, expectFilterError(io.ErrClosedPipe)), - args: args{ - typ: domain.SecretGeneratorTypeVerifyEmailCode, - alg: crypto.CreateMockEncryptionAlg(gomock.NewController(t)), - expiry: code.Expiry, - crypted: code.Crypted, - plain: code.Plain, - }, - wantErr: true, - }, - { - name: "success", - eventsore: eventstoreExpect(t, expectFilter( - eventFromEventPusher(testSecretGeneratorAddedEvent(domain.SecretGeneratorTypeVerifyEmailCode)), - )), - args: args{ - typ: domain.SecretGeneratorTypeVerifyEmailCode, - alg: crypto.CreateMockEncryptionAlg(gomock.NewController(t)), - expiry: code.Expiry, - crypted: code.Crypted, - plain: code.Plain, - }, - }, - { - name: "wrong plain", - eventsore: eventstoreExpect(t, expectFilter( - eventFromEventPusher(testSecretGeneratorAddedEvent(domain.SecretGeneratorTypeVerifyEmailCode)), - )), - args: args{ - typ: domain.SecretGeneratorTypeVerifyEmailCode, - alg: crypto.CreateMockEncryptionAlg(gomock.NewController(t)), - expiry: code.Expiry, - crypted: code.Crypted, - plain: "wrong", - }, - wantErr: true, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - err := verifyEncryptedCode(context.Background(), tt.eventsore.Filter, tt.args.typ, tt.args.alg, time.Now(), tt.args.expiry, tt.args.crypted, tt.args.plain) //nolint:staticcheck - if tt.wantErr { - assert.Error(t, err) - return - } - require.NoError(t, err) - }) - } -} - func Test_cryptoCodeGenerator(t *testing.T) { type args struct { typ domain.SecretGeneratorType diff --git a/internal/command/email.go b/internal/command/email.go index bb7dfc3f7d..85314f27b3 100644 --- a/internal/command/email.go +++ b/internal/command/email.go @@ -12,6 +12,9 @@ type Email struct { Address domain.EmailAddress Verified bool + // NoEmailVerification is used Verified field is false + NoEmailVerification bool + // ReturnCode is used if the Verified field is false ReturnCode bool diff --git a/internal/command/instance.go b/internal/command/instance.go index a0cc773019..c70b7541f5 100644 --- a/internal/command/instance.go +++ b/internal/command/instance.go @@ -145,6 +145,7 @@ type SecretGenerators struct { DomainVerification *crypto.GeneratorConfig OTPSMS *crypto.GeneratorConfig OTPEmail *crypto.GeneratorConfig + InviteCode *crypto.GeneratorConfig } type ZitadelConfig struct { diff --git a/internal/command/user.go b/internal/command/user.go index bb3c75775a..6b65aa83ec 100644 --- a/internal/command/user.go +++ b/internal/command/user.go @@ -388,6 +388,10 @@ func (c *Commands) newUserInitCode(ctx context.Context, filter preparation.Filte return c.newEncryptedCode(ctx, filter, domain.SecretGeneratorTypeInitCode, alg) } +func (c *Commands) newUserInviteCode(ctx context.Context, filter preparation.FilterToQueryReducer, alg crypto.EncryptionAlgorithm) (*EncryptedCode, error) { + return c.newEncryptedCodeWithDefault(ctx, filter, domain.SecretGeneratorTypeInviteCode, alg, c.defaultSecretGenerators.InviteCode) +} + func userWriteModelByID(ctx context.Context, filter preparation.FilterToQueryReducer, userID, resourceOwner string) (*UserWriteModel, error) { user := NewUserWriteModel(userID, resourceOwner) events, err := filter(ctx, user.Query()) diff --git a/internal/command/user_human.go b/internal/command/user_human.go index b1abf50b33..1208296159 100644 --- a/internal/command/user_human.go +++ b/internal/command/user_human.go @@ -284,6 +284,9 @@ func (c *Commands) addHumanCommandEmail(ctx context.Context, filter preparation. } return append(cmds, user.NewHumanInitialCodeAddedEvent(ctx, &a.Aggregate, initCode.Crypted, initCode.Expiry, human.AuthRequestID)), nil } + if human.Email.NoEmailVerification { + return cmds, nil + } if !human.Email.Verified { emailCode, err := c.newEmailCode(ctx, filter, codeAlg) if err != nil { diff --git a/internal/command/user_human_test.go b/internal/command/user_human_test.go index ffde2480c1..468a27e8b8 100644 --- a/internal/command/user_human_test.go +++ b/internal/command/user_human_test.go @@ -626,6 +626,67 @@ func TestCommandSide_AddHuman(t *testing.T) { wantEmailCode: "emailCode", }, }, + { + name: "add human (with password and unverified email), ok (no email code)", + fields: fields{ + eventstore: expectEventstore( + expectFilter(), + expectFilter( + eventFromEventPusher( + org.NewDomainPolicyAddedEvent(context.Background(), + &user.NewAggregate("user1", "org1").Aggregate, + true, + true, + true, + ), + ), + ), + expectFilter( + eventFromEventPusher( + org.NewPasswordComplexityPolicyAddedEvent(context.Background(), + &user.NewAggregate("user1", "org1").Aggregate, + 1, + false, + false, + false, + false, + ), + ), + ), + expectPush( + newAddHumanEvent("$plain$x$password", false, true, "", AllowedLanguage), + ), + ), + idGenerator: id_mock.NewIDGeneratorExpectIDs(t, "user1"), + userPasswordHasher: mockPasswordHasher("x"), + codeAlg: crypto.CreateMockEncryptionAlg(gomock.NewController(t)), + newCode: mockEncryptedCode("emailCode", time.Hour), + }, + args: args{ + ctx: context.Background(), + orgID: "org1", + human: &AddHuman{ + Username: "username", + Password: "password", + FirstName: "firstname", + LastName: "lastname", + Email: Email{ + Address: "email@test.ch", + Verified: false, + NoEmailVerification: true, + }, + PreferredLanguage: AllowedLanguage, + }, + secretGenerator: GetMockSecretGenerator(t), + allowInitMail: false, + }, + res: res{ + want: &domain.ObjectDetails{ + ResourceOwner: "org1", + }, + wantID: "user1", + }, + }, { name: "add human email verified, ok", fields: fields{ diff --git a/internal/command/user_model.go b/internal/command/user_model.go index 10c6dd2cf8..ad625756f6 100644 --- a/internal/command/user_model.go +++ b/internal/command/user_model.go @@ -1,6 +1,8 @@ package command import ( + "context" + "github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/eventstore" "github.com/zitadel/zitadel/internal/repository/user" @@ -122,6 +124,10 @@ func UserAggregateFromWriteModel(wm *eventstore.WriteModel) *eventstore.Aggregat return eventstore.AggregateFromWriteModel(wm, user.AggregateType, user.AggregateVersion) } +func UserAggregateFromWriteModelCtx(ctx context.Context, wm *eventstore.WriteModel) *eventstore.Aggregate { + return eventstore.AggregateFromWriteModelCtx(ctx, wm, user.AggregateType, user.AggregateVersion) +} + func isUserStateExists(state domain.UserState) bool { return !hasUserState(state, domain.UserStateDeleted, domain.UserStateUnspecified) } diff --git a/internal/command/user_v2_invite.go b/internal/command/user_v2_invite.go new file mode 100644 index 0000000000..78b46a530e --- /dev/null +++ b/internal/command/user_v2_invite.go @@ -0,0 +1,193 @@ +package command + +import ( + "context" + "strings" + + "github.com/zitadel/logging" + + "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" + "github.com/zitadel/zitadel/internal/telemetry/tracing" + "github.com/zitadel/zitadel/internal/zerrors" +) + +type CreateUserInvite struct { + UserID string + URLTemplate string + ReturnCode bool + ApplicationName string +} + +func (c *Commands) CreateInviteCode(ctx context.Context, invite *CreateUserInvite) (details *domain.ObjectDetails, returnCode *string, err error) { + invite.UserID = strings.TrimSpace(invite.UserID) + if invite.UserID == "" { + return nil, nil, zerrors.ThrowInvalidArgument(nil, "COMMAND-4jio3", "Errors.User.UserIDMissing") + } + wm, err := c.userInviteCodeWriteModel(ctx, invite.UserID, "") + if err != nil { + return nil, nil, err + } + if err := c.checkPermission(ctx, domain.PermissionUserWrite, wm.ResourceOwner, wm.AggregateID); err != nil { + return nil, nil, err + } + if !wm.UserState.Exists() { + return nil, nil, zerrors.ThrowPreconditionFailed(nil, "COMMAND-Wgvn4", "Errors.User.NotFound") + } + if !wm.CreationAllowed() { + return nil, nil, zerrors.ThrowPreconditionFailed(nil, "COMMAND-EF34g", "Errors.User.AlreadyInitialised") + } + code, err := c.newUserInviteCode(ctx, c.eventstore.Filter, c.userEncryption) //nolint + if err != nil { + return nil, nil, err + } + err = c.pushAppendAndReduce(ctx, wm, user.NewHumanInviteCodeAddedEvent( + ctx, + UserAggregateFromWriteModelCtx(ctx, &wm.WriteModel), + code.Crypted, + code.Expiry, + invite.URLTemplate, + invite.ReturnCode, + invite.ApplicationName, + "", + )) + if err != nil { + return nil, nil, err + } + if invite.ReturnCode { + returnCode = &code.Plain + } + return writeModelToObjectDetails(&wm.WriteModel), returnCode, nil +} + +// ResendInviteCode resends the invite mail with a new code and an optional authRequestID. +// It will reuse the applicationName from the previous code. +func (c *Commands) ResendInviteCode(ctx context.Context, userID, resourceOwner, authRequestID string) (objectDetails *domain.ObjectDetails, err error) { + if userID == "" { + return nil, zerrors.ThrowInvalidArgument(nil, "COMMAND-2n8vs", "Errors.User.UserIDMissing") + } + + existingCode, err := c.userInviteCodeWriteModel(ctx, userID, resourceOwner) + if err != nil { + return nil, err + } + if authz.GetCtxData(ctx).UserID != userID { + if err := c.checkPermission(ctx, domain.PermissionUserWrite, existingCode.ResourceOwner, userID); err != nil { + return nil, err + } + } + if !existingCode.UserState.Exists() { + return nil, zerrors.ThrowPreconditionFailed(nil, "COMMAND-H3b2a", "Errors.User.NotFound") + } + if !existingCode.CreationAllowed() { + return nil, zerrors.ThrowPreconditionFailed(nil, "COMMAND-Gg42s", "Errors.User.AlreadyInitialised") + } + if existingCode.InviteCode == nil || existingCode.CodeReturned { + return nil, zerrors.ThrowPreconditionFailed(nil, "COMMAND-Wr3gq", "Errors.User.Code.NotFound") + } + code, err := c.newUserInviteCode(ctx, c.eventstore.Filter, c.userEncryption) //nolint + if err != nil { + return nil, err + } + if authRequestID == "" { + authRequestID = existingCode.AuthRequestID + } + err = c.pushAppendAndReduce(ctx, existingCode, + user.NewHumanInviteCodeAddedEvent( + ctx, + UserAggregateFromWriteModelCtx(ctx, &existingCode.WriteModel), + code.Crypted, + code.Expiry, + existingCode.URLTemplate, + false, + existingCode.ApplicationName, + authRequestID, + )) + if err != nil { + return nil, err + } + return writeModelToObjectDetails(&existingCode.WriteModel), nil +} + +func (c *Commands) InviteCodeSent(ctx context.Context, userID, orgID string) (err error) { + if userID == "" { + return zerrors.ThrowInvalidArgument(nil, "COMMAND-Sgf31", "Errors.User.UserIDMissing") + } + existingCode, err := c.userInviteCodeWriteModel(ctx, userID, orgID) + if err != nil { + return err + } + if !existingCode.UserState.Exists() { + return zerrors.ThrowPreconditionFailed(nil, "COMMAND-HN34a", "Errors.User.NotFound") + } + if existingCode.InviteCode == nil || existingCode.CodeReturned { + return zerrors.ThrowPreconditionFailed(nil, "COMMAND-Wr3gq", "Errors.User.Code.NotFound") + } + userAgg := UserAggregateFromWriteModelCtx(ctx, &existingCode.WriteModel) + _, err = c.eventstore.Push(ctx, user.NewHumanInviteCodeSentEvent(ctx, userAgg)) + return err +} + +func (c *Commands) VerifyInviteCode(ctx context.Context, userID, code string) (details *domain.ObjectDetails, err error) { + return c.VerifyInviteCodeSetPassword(ctx, userID, code, "", "") +} + +func (c *Commands) VerifyInviteCodeSetPassword(ctx context.Context, userID, code, password, userAgentID string) (details *domain.ObjectDetails, err error) { + if userID == "" { + return nil, zerrors.ThrowInvalidArgument(nil, "COMMAND-Gk3f2", "Errors.User.UserIDMissing") + } + wm, err := c.userInviteCodeWriteModel(ctx, userID, "") + if err != nil { + return nil, err + } + if !wm.UserState.Exists() { + return nil, zerrors.ThrowPreconditionFailed(nil, "COMMAND-F5g2h", "Errors.User.NotFound") + } + userAgg := UserAggregateFromWriteModelCtx(ctx, &wm.WriteModel) + err = crypto.VerifyCode(wm.InviteCodeCreationDate, wm.InviteCodeExpiry, wm.InviteCode, code, c.userEncryption) + if err != nil { + _, err = c.eventstore.Push(ctx, user.NewHumanInviteCheckFailedEvent(ctx, userAgg)) + logging.WithFields("userID", userAgg.ID).OnError(err).Error("NewHumanInviteCheckFailedEvent push failed") + return nil, zerrors.ThrowInvalidArgument(err, "COMMAND-Wgn4q", "Errors.User.Code.Invalid") + } + commands := []eventstore.Command{ + user.NewHumanInviteCheckSucceededEvent(ctx, userAgg), + user.NewHumanEmailVerifiedEvent(ctx, userAgg), + } + if password != "" { + passwordCommand, err := c.setPasswordCommand( + ctx, + userAgg, + wm.UserState, + password, + "", + userAgentID, + false, + nil, + ) + if err != nil { + return nil, err + } + commands = append(commands, passwordCommand) + } + err = c.pushAppendAndReduce(ctx, wm, commands...) + if err != nil { + return nil, err + } + return writeModelToObjectDetails(&wm.WriteModel), nil +} + +func (c *Commands) userInviteCodeWriteModel(ctx context.Context, userID, orgID string) (writeModel *UserV2InviteWriteModel, err error) { + ctx, span := tracing.NewSpan(ctx) + defer func() { span.EndWithError(err) }() + + writeModel = newUserV2InviteWriteModel(userID, orgID) + err = c.eventstore.FilterToQueryReducer(ctx, writeModel) + if err != nil { + return nil, err + } + return writeModel, nil +} diff --git a/internal/command/user_v2_invite_model.go b/internal/command/user_v2_invite_model.go new file mode 100644 index 0000000000..23f6322a19 --- /dev/null +++ b/internal/command/user_v2_invite_model.go @@ -0,0 +1,141 @@ +package command + +import ( + "time" + + "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" +) + +type UserV2InviteWriteModel struct { + eventstore.WriteModel + + InviteCode *crypto.CryptoValue + InviteCodeCreationDate time.Time + InviteCodeExpiry time.Duration + InviteCheckFailureCount uint8 + + ApplicationName string + AuthRequestID string + URLTemplate string + CodeReturned bool + EmailVerified bool + AuthMethodSet bool + + UserState domain.UserState +} + +func (wm *UserV2InviteWriteModel) CreationAllowed() bool { + return !wm.EmailVerified && !wm.AuthMethodSet +} + +func newUserV2InviteWriteModel(userID, orgID string) *UserV2InviteWriteModel { + return &UserV2InviteWriteModel{ + WriteModel: eventstore.WriteModel{ + AggregateID: userID, + ResourceOwner: orgID, + }, + } +} + +func (wm *UserV2InviteWriteModel) Reduce() error { + for _, event := range wm.Events { + switch e := event.(type) { + case *user.HumanAddedEvent: + wm.UserState = domain.UserStateActive + wm.AuthMethodSet = crypto.SecretOrEncodedHash(e.Secret, e.EncodedHash) != "" + wm.EmptyInviteCode() + wm.ApplicationName = "" + wm.AuthRequestID = "" + case *user.HumanRegisteredEvent: + wm.UserState = domain.UserStateActive + wm.AuthMethodSet = crypto.SecretOrEncodedHash(e.Secret, e.EncodedHash) != "" + wm.EmptyInviteCode() + wm.ApplicationName = "" + wm.AuthRequestID = "" + case *user.HumanInviteCodeAddedEvent: + wm.SetInviteCode(e.Code, e.Expiry, e.CreationDate()) + wm.URLTemplate = e.URLTemplate + wm.CodeReturned = e.CodeReturned + wm.ApplicationName = e.ApplicationName + wm.AuthRequestID = e.AuthRequestID + case *user.HumanInviteCheckSucceededEvent: + wm.EmptyInviteCode() + case *user.HumanInviteCheckFailedEvent: + wm.InviteCheckFailureCount++ + if wm.InviteCheckFailureCount >= 3 { //TODO: config? + wm.UserState = domain.UserStateDeleted + } + case *user.HumanEmailVerifiedEvent: + wm.EmailVerified = true + wm.EmptyInviteCode() + case *user.UserLockedEvent: + wm.UserState = domain.UserStateLocked + case *user.UserUnlockedEvent: + wm.UserState = domain.UserStateActive + case *user.UserDeactivatedEvent: + wm.UserState = domain.UserStateInactive + case *user.UserReactivatedEvent: + wm.UserState = domain.UserStateActive + case *user.UserRemovedEvent: + wm.UserState = domain.UserStateDeleted + case *user.HumanPasswordChangedEvent: + wm.AuthMethodSet = true + case *user.UserIDPLinkAddedEvent: + wm.AuthMethodSet = true + case *user.HumanPasswordlessVerifiedEvent: + wm.AuthMethodSet = true + } + } + return wm.WriteModel.Reduce() +} + +func (wm *UserV2InviteWriteModel) SetInviteCode(code *crypto.CryptoValue, expiry time.Duration, creationDate time.Time) { + wm.InviteCode = code + wm.InviteCodeExpiry = expiry + wm.InviteCodeCreationDate = creationDate + wm.InviteCheckFailureCount = 0 +} + +func (wm *UserV2InviteWriteModel) EmptyInviteCode() { + wm.InviteCode = nil + wm.InviteCodeExpiry = 0 + wm.InviteCodeCreationDate = time.Time{} + wm.InviteCheckFailureCount = 0 +} +func (wm *UserV2InviteWriteModel) Query() *eventstore.SearchQueryBuilder { + query := eventstore.NewSearchQueryBuilder(eventstore.ColumnsEvent). + AddQuery(). + AggregateTypes(user.AggregateType). + AggregateIDs(wm.AggregateID). + EventTypes( + user.UserV1AddedType, + user.HumanAddedType, + user.UserV1RegisteredType, + user.HumanRegisteredType, + user.HumanInviteCodeAddedType, + user.HumanInviteCheckSucceededType, + user.HumanInviteCheckFailedType, + user.UserV1EmailVerifiedType, + user.HumanEmailVerifiedType, + user.UserLockedType, + user.UserUnlockedType, + user.UserDeactivatedType, + user.UserReactivatedType, + user.UserRemovedType, + user.HumanPasswordChangedType, + user.UserV1PasswordChangedType, + user.UserIDPLinkAddedType, + user.HumanPasswordlessTokenVerifiedType, + ).Builder() + if wm.ResourceOwner != "" { + query.ResourceOwner(wm.ResourceOwner) + } + return query +} + +func (wm *UserV2InviteWriteModel) Aggregate() *user.Aggregate { + return user.NewAggregate(wm.AggregateID, wm.ResourceOwner) +} diff --git a/internal/command/user_v2_invite_test.go b/internal/command/user_v2_invite_test.go new file mode 100644 index 0000000000..efb57d86ad --- /dev/null +++ b/internal/command/user_v2_invite_test.go @@ -0,0 +1,1207 @@ +package command + +import ( + "context" + "testing" + "time" + + "github.com/muhlemmer/gu" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" + "golang.org/x/text/language" + + "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/org" + "github.com/zitadel/zitadel/internal/repository/user" + "github.com/zitadel/zitadel/internal/zerrors" +) + +func TestCommands_CreateInviteCode(t *testing.T) { + type fields struct { + checkPermission domain.PermissionCheck + newEncryptedCodeWithDefault encryptedCodeWithDefaultFunc + eventstore func(*testing.T) *eventstore.Eventstore + defaultSecretGenerators *SecretGenerators + } + type args struct { + ctx context.Context + invite *CreateUserInvite + } + type want struct { + details *domain.ObjectDetails + returnCode *string + err error + } + tests := []struct { + name string + fields fields + args args + want want + }{ + { + "user id missing", + fields{ + eventstore: expectEventstore(), + }, + args{ + ctx: context.Background(), + invite: &CreateUserInvite{ + UserID: "", + }, + }, + want{ + err: zerrors.ThrowInvalidArgument(nil, "COMMAND-4jio3", "Errors.User.UserIDMissing"), + }, + }, + { + "missing permission", + fields{ + eventstore: expectEventstore( + expectFilter(), + ), + checkPermission: newMockPermissionCheckNotAllowed(), + }, + args{ + ctx: context.Background(), + invite: &CreateUserInvite{ + UserID: "userID", + }, + }, + want{ + err: zerrors.ThrowPermissionDenied(nil, "AUTHZ-HKJD33", "Errors.PermissionDenied"), + }, + }, + { + "user does not exist", + fields{ + eventstore: expectEventstore( + expectFilter(), + ), + checkPermission: newMockPermissionCheckAllowed(), + }, + args{ + ctx: context.Background(), + invite: &CreateUserInvite{ + UserID: "unknown", + }, + }, + want{ + err: zerrors.ThrowPreconditionFailed(nil, "COMMAND-Wgvn4", "Errors.User.NotFound"), + }, + }, + { + "create ok", + fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + user.NewHumanAddedEvent(context.Background(), + &user.NewAggregate("userID", "org1").Aggregate, + "username", "firstName", + "lastName", + "nickName", + "displayName", + language.Afrikaans, + domain.GenderUnspecified, + "email", + false, + ), + ), + ), + expectPush( + eventFromEventPusher( + user.NewHumanInviteCodeAddedEvent(context.Background(), + &user.NewAggregate("userID", "org1").Aggregate, + &crypto.CryptoValue{ + CryptoType: crypto.TypeEncryption, + Algorithm: "enc", + KeyID: "id", + Crypted: []byte("code"), + }, + time.Hour, + "", + false, + "", + "", + ), + ), + ), + ), + checkPermission: newMockPermissionCheckAllowed(), + newEncryptedCodeWithDefault: mockEncryptedCodeWithDefault("code", time.Hour), + defaultSecretGenerators: &SecretGenerators{}, + }, + args{ + ctx: context.Background(), + invite: &CreateUserInvite{ + UserID: "userID", + }, + }, + want{ + details: &domain.ObjectDetails{ + ResourceOwner: "org1", + ID: "userID", + }, + returnCode: nil, + }, + }, + { + "return ok", + fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + user.NewHumanAddedEvent(context.Background(), + &user.NewAggregate("userID", "org1").Aggregate, + "username", "firstName", + "lastName", + "nickName", + "displayName", + language.Afrikaans, + domain.GenderUnspecified, + "email", + false, + ), + ), + ), + expectPush( + eventFromEventPusher( + user.NewHumanInviteCodeAddedEvent(context.Background(), + &user.NewAggregate("userID", "org1").Aggregate, + &crypto.CryptoValue{ + CryptoType: crypto.TypeEncryption, + Algorithm: "enc", + KeyID: "id", + Crypted: []byte("code"), + }, + time.Hour, + "", + true, + "", + "", + ), + ), + ), + ), + checkPermission: newMockPermissionCheckAllowed(), + newEncryptedCodeWithDefault: mockEncryptedCodeWithDefault("code", time.Hour), + defaultSecretGenerators: &SecretGenerators{}, + }, + args{ + ctx: context.Background(), + invite: &CreateUserInvite{ + UserID: "userID", + ReturnCode: true, + }, + }, + want{ + details: &domain.ObjectDetails{ + ResourceOwner: "org1", + ID: "userID", + }, + returnCode: gu.Ptr("code"), + }, + }, + { + "with template and application name ok", + fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + user.NewHumanAddedEvent(context.Background(), + &user.NewAggregate("userID", "org1").Aggregate, + "username", "firstName", + "lastName", + "nickName", + "displayName", + language.Afrikaans, + domain.GenderUnspecified, + "email", + false, + ), + ), + ), + expectPush( + eventFromEventPusher( + user.NewHumanInviteCodeAddedEvent(context.Background(), + &user.NewAggregate("userID", "org1").Aggregate, + &crypto.CryptoValue{ + CryptoType: crypto.TypeEncryption, + Algorithm: "enc", + KeyID: "id", + Crypted: []byte("code"), + }, + time.Hour, + "https://example.com/invite?userID={{.UserID}}", + false, + "applicationName", + "", + ), + ), + ), + ), + checkPermission: newMockPermissionCheckAllowed(), + newEncryptedCodeWithDefault: mockEncryptedCodeWithDefault("code", time.Hour), + defaultSecretGenerators: &SecretGenerators{}, + }, + args{ + ctx: context.Background(), + invite: &CreateUserInvite{ + UserID: "userID", + URLTemplate: "https://example.com/invite?userID={{.UserID}}", + ReturnCode: false, + ApplicationName: "applicationName", + }, + }, + want{ + details: &domain.ObjectDetails{ + ResourceOwner: "org1", + ID: "userID", + }, + returnCode: nil, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + c := &Commands{ + checkPermission: tt.fields.checkPermission, + newEncryptedCodeWithDefault: tt.fields.newEncryptedCodeWithDefault, + eventstore: tt.fields.eventstore(t), + defaultSecretGenerators: tt.fields.defaultSecretGenerators, + } + gotDetails, gotReturnCode, err := c.CreateInviteCode(tt.args.ctx, tt.args.invite) + + require.ErrorIs(t, err, tt.want.err) + assert.Equal(t, tt.want.details, gotDetails) + assert.Equal(t, tt.want.returnCode, gotReturnCode) + }) + } +} + +func TestCommands_ResendInviteCode(t *testing.T) { + type fields struct { + checkPermission domain.PermissionCheck + newEncryptedCodeWithDefault encryptedCodeWithDefaultFunc + eventstore func(*testing.T) *eventstore.Eventstore + defaultSecretGenerators *SecretGenerators + } + type args struct { + ctx context.Context + userID string + orgID string + authRequestID string + } + type want struct { + details *domain.ObjectDetails + err error + } + tests := []struct { + name string + fields fields + args args + want want + }{ + { + "missing user id", + fields{ + eventstore: expectEventstore(), + }, + args{ + ctx: context.Background(), + userID: "", + }, + want{ + err: zerrors.ThrowInvalidArgument(nil, "COMMAND-2n8vs", "Errors.User.UserIDMissing"), + }, + }, + { + "missing permission", + fields{ + eventstore: expectEventstore( + expectFilter(), + ), + checkPermission: newMockPermissionCheckNotAllowed(), + }, + args{ + ctx: context.Background(), + userID: "userID", + }, + want{ + err: zerrors.ThrowPermissionDenied(nil, "AUTHZ-HKJD33", "Errors.PermissionDenied"), + }, + }, + { + "user does not exist", + fields{ + eventstore: expectEventstore( + expectFilter(), + ), + checkPermission: newMockPermissionCheckAllowed(), + }, + args{ + ctx: context.Background(), + userID: "unknown", + }, + want{ + err: zerrors.ThrowPreconditionFailed(nil, "COMMAND-H3b2a", "Errors.User.NotFound"), + }, + }, + { + "no previous code", + fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + user.NewHumanAddedEvent(context.Background(), + &user.NewAggregate("userID", "org1").Aggregate, + "username", "firstName", + "lastName", + "nickName", + "displayName", + language.Afrikaans, + domain.GenderUnspecified, + "email", + false, + ), + ), + ), + ), + checkPermission: newMockPermissionCheckAllowed(), + }, + args{ + ctx: context.Background(), + userID: "userID", + }, + want{ + err: zerrors.ThrowPreconditionFailed(nil, "COMMAND-Wr3gq", "Errors.User.Code.NotFound"), + }, + }, + { + "previous code returned", + fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + user.NewHumanAddedEvent(context.Background(), + &user.NewAggregate("userID", "org1").Aggregate, + "username", "firstName", + "lastName", + "nickName", + "displayName", + language.Afrikaans, + domain.GenderUnspecified, + "email", + false, + ), + ), + eventFromEventPusher( + user.NewHumanInviteCodeAddedEvent(context.Background(), + &user.NewAggregate("userID", "org1").Aggregate, + &crypto.CryptoValue{ + CryptoType: crypto.TypeEncryption, + Algorithm: "enc", + KeyID: "id", + Crypted: []byte("code"), + }, + time.Hour, + "", + true, + "", + "", + ), + ), + ), + ), + checkPermission: newMockPermissionCheckAllowed(), + }, + args{ + ctx: context.Background(), + userID: "userID", + }, + want{ + err: zerrors.ThrowPreconditionFailed(nil, "COMMAND-Wr3gq", "Errors.User.Code.NotFound"), + }, + }, + { + "resend ok", + fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + user.NewHumanAddedEvent(context.Background(), + &user.NewAggregate("userID", "org1").Aggregate, + "username", "firstName", + "lastName", + "nickName", + "displayName", + language.Afrikaans, + domain.GenderUnspecified, + "email", + false, + ), + ), + eventFromEventPusher( + user.NewHumanInviteCodeAddedEvent(context.Background(), + &user.NewAggregate("userID", "org1").Aggregate, + &crypto.CryptoValue{ + CryptoType: crypto.TypeEncryption, + Algorithm: "enc", + KeyID: "id", + Crypted: []byte("code"), + }, + time.Hour, + "", + false, + "", + "authRequestID", + ), + ), + ), + expectPush( + eventFromEventPusher( + user.NewHumanInviteCodeAddedEvent(context.Background(), + &user.NewAggregate("userID", "org1").Aggregate, + &crypto.CryptoValue{ + CryptoType: crypto.TypeEncryption, + Algorithm: "enc", + KeyID: "id", + Crypted: []byte("code"), + }, + time.Hour, + "", + false, + "", + "authRequestID", + ), + ), + ), + ), + checkPermission: newMockPermissionCheckAllowed(), + newEncryptedCodeWithDefault: mockEncryptedCodeWithDefault("code", time.Hour), + defaultSecretGenerators: &SecretGenerators{}, + }, + args{ + ctx: context.Background(), + userID: "userID", + }, + want{ + details: &domain.ObjectDetails{ + ResourceOwner: "org1", + ID: "userID", + }, + }, + }, + { + "resend with new auth requestID ok", + fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + user.NewHumanAddedEvent(context.Background(), + &user.NewAggregate("userID", "org1").Aggregate, + "username", "firstName", + "lastName", + "nickName", + "displayName", + language.Afrikaans, + domain.GenderUnspecified, + "email", + false, + ), + ), + eventFromEventPusher( + user.NewHumanInviteCodeAddedEvent(context.Background(), + &user.NewAggregate("userID", "org1").Aggregate, + &crypto.CryptoValue{ + CryptoType: crypto.TypeEncryption, + Algorithm: "enc", + KeyID: "id", + Crypted: []byte("code"), + }, + time.Hour, + "", + false, + "", + "authRequestID", + ), + ), + ), + expectPush( + eventFromEventPusher( + user.NewHumanInviteCodeAddedEvent(context.Background(), + &user.NewAggregate("userID", "org1").Aggregate, + &crypto.CryptoValue{ + CryptoType: crypto.TypeEncryption, + Algorithm: "enc", + KeyID: "id", + Crypted: []byte("code"), + }, + time.Hour, + "", + false, + "", + "authRequestID2", + ), + ), + ), + ), + checkPermission: newMockPermissionCheckAllowed(), + newEncryptedCodeWithDefault: mockEncryptedCodeWithDefault("code", time.Hour), + defaultSecretGenerators: &SecretGenerators{}, + }, + args{ + ctx: context.Background(), + userID: "userID", + authRequestID: "authRequestID2", + }, + want{ + details: &domain.ObjectDetails{ + ResourceOwner: "org1", + ID: "userID", + }, + }, + }, + { + "resend with own user ok", + fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + user.NewHumanAddedEvent(context.Background(), + &user.NewAggregate("userID", "org1").Aggregate, + "username", "firstName", + "lastName", + "nickName", + "displayName", + language.Afrikaans, + domain.GenderUnspecified, + "email", + false, + ), + ), + eventFromEventPusher( + user.NewHumanInviteCodeAddedEvent(context.Background(), + &user.NewAggregate("userID", "org1").Aggregate, + &crypto.CryptoValue{ + CryptoType: crypto.TypeEncryption, + Algorithm: "enc", + KeyID: "id", + Crypted: []byte("code"), + }, + time.Hour, + "", + false, + "", + "authRequestID", + ), + ), + ), + expectPush( + eventFromEventPusher( + user.NewHumanInviteCodeAddedEvent(authz.NewMockContext("instanceID", "org1", "userID"), + &user.NewAggregate("userID", "org1").Aggregate, + &crypto.CryptoValue{ + CryptoType: crypto.TypeEncryption, + Algorithm: "enc", + KeyID: "id", + Crypted: []byte("code"), + }, + time.Hour, + "", + false, + "", + "authRequestID2", + ), + ), + ), + ), + checkPermission: newMockPermissionCheckNotAllowed(), // user does not have permission, is allowed in the own context + newEncryptedCodeWithDefault: mockEncryptedCodeWithDefault("code", time.Hour), + defaultSecretGenerators: &SecretGenerators{}, + }, + args{ + ctx: authz.NewMockContext("instanceID", "org1", "userID"), + userID: "userID", + authRequestID: "authRequestID2", + }, + want{ + details: &domain.ObjectDetails{ + ResourceOwner: "org1", + ID: "userID", + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + c := &Commands{ + checkPermission: tt.fields.checkPermission, + newEncryptedCodeWithDefault: tt.fields.newEncryptedCodeWithDefault, + eventstore: tt.fields.eventstore(t), + defaultSecretGenerators: tt.fields.defaultSecretGenerators, + } + details, err := c.ResendInviteCode(tt.args.ctx, tt.args.userID, tt.args.orgID, tt.args.authRequestID) + assert.ErrorIs(t, err, tt.want.err) + assert.Equal(t, tt.want.details, details) + }) + } +} + +func TestCommands_InviteCodeSent(t *testing.T) { + type fields struct { + eventstore func(*testing.T) *eventstore.Eventstore + } + type args struct { + ctx context.Context + userID string + orgID string + } + tests := []struct { + name string + fields fields + args args + wantErr error + }{ + { + "missing user id", + fields{ + eventstore: expectEventstore(), + }, + args{ + ctx: context.Background(), + userID: "", + }, + zerrors.ThrowInvalidArgument(nil, "COMMAND-Sgf31", "Errors.User.UserIDMissing"), + }, + { + "user does not exist", + fields{ + eventstore: expectEventstore( + expectFilter(), + ), + }, + args{ + ctx: context.Background(), + userID: "unknown", + }, + zerrors.ThrowPreconditionFailed(nil, "COMMAND-HN34a", "Errors.User.NotFound"), + }, + { + "code does not exist", + fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + user.NewHumanAddedEvent(context.Background(), + &user.NewAggregate("userID", "org1").Aggregate, + "username", "firstName", + "lastName", + "nickName", + "displayName", + language.Afrikaans, + domain.GenderUnspecified, + "email", + false, + ), + ), + ), + ), + }, + args{ + ctx: context.Background(), + userID: "unknown", + }, + zerrors.ThrowPreconditionFailed(nil, "COMMAND-Wr3gq", "Errors.User.Code.NotFound"), + }, + { + "sent ok", + fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + user.NewHumanAddedEvent(context.Background(), + &user.NewAggregate("userID", "org1").Aggregate, + "username", "firstName", + "lastName", + "nickName", + "displayName", + language.Afrikaans, + domain.GenderUnspecified, + "email", + false, + ), + ), + eventFromEventPusher( + user.NewHumanInviteCodeAddedEvent(context.Background(), + &user.NewAggregate("userID", "org1").Aggregate, + &crypto.CryptoValue{ + CryptoType: crypto.TypeEncryption, + Algorithm: "enc", + KeyID: "id", + Crypted: []byte("code"), + }, + time.Hour, + "", + false, + "", + "authRequestID", + ), + ), + ), + expectPush( + eventFromEventPusher( + user.NewHumanInviteCodeSentEvent(context.Background(), + &user.NewAggregate("userID", "org1").Aggregate, + ), + ), + ), + ), + }, + args{ + ctx: context.Background(), + userID: "userID", + }, + nil, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + c := &Commands{ + eventstore: tt.fields.eventstore(t), + } + err := c.InviteCodeSent(tt.args.ctx, tt.args.userID, tt.args.orgID) + assert.ErrorIs(t, err, tt.wantErr) + }) + } +} + +func TestCommands_VerifyInviteCode(t *testing.T) { + type fields struct { + eventstore func(*testing.T) *eventstore.Eventstore + userEncryption crypto.EncryptionAlgorithm + } + type args struct { + ctx context.Context + userID string + code string + } + type want struct { + details *domain.ObjectDetails + err error + } + tests := []struct { + name string + fields fields + args args + want want + }{ + { + "code ok", + fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + user.NewHumanAddedEvent(context.Background(), + &user.NewAggregate("userID", "org1").Aggregate, + "username", "firstName", + "lastName", + "nickName", + "displayName", + language.Afrikaans, + domain.GenderUnspecified, + "email", + false, + ), + ), + eventFromEventPusherWithCreationDateNow( + user.NewHumanInviteCodeAddedEvent(context.Background(), + &user.NewAggregate("userID", "org1").Aggregate, + &crypto.CryptoValue{ + CryptoType: crypto.TypeEncryption, + Algorithm: "enc", + KeyID: "id", + Crypted: []byte("code"), + }, + time.Hour, + "", + false, + "", + "authRequestID", + ), + ), + ), + expectPush( + eventFromEventPusher( + user.NewHumanInviteCheckSucceededEvent(context.Background(), + &user.NewAggregate("userID", "org1").Aggregate, + ), + ), + eventFromEventPusher( + user.NewHumanEmailVerifiedEvent(context.Background(), + &user.NewAggregate("userID", "org1").Aggregate, + ), + ), + ), + ), + userEncryption: crypto.CreateMockEncryptionAlg(gomock.NewController(t)), + }, + args{ + ctx: context.Background(), + userID: "userID", + code: "code", + }, + want{ + details: &domain.ObjectDetails{ + ResourceOwner: "org1", + ID: "userID", + }, + }, + }, + // all other cases are tested in TestCommands_VerifyInviteCodeSetPassword + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + c := &Commands{ + eventstore: tt.fields.eventstore(t), + userEncryption: tt.fields.userEncryption, + } + gotDetails, err := c.VerifyInviteCode(tt.args.ctx, tt.args.userID, tt.args.code) + assert.ErrorIs(t, err, tt.want.err) + assert.Equal(t, tt.want.details, gotDetails) + }) + } +} + +func TestCommands_VerifyInviteCodeSetPassword(t *testing.T) { + type fields struct { + eventstore func(*testing.T) *eventstore.Eventstore + userEncryption crypto.EncryptionAlgorithm + userPasswordHasher *crypto.Hasher + } + type args struct { + ctx context.Context + userID string + code string + password string + userAgentID string + } + type want struct { + details *domain.ObjectDetails + err error + } + tests := []struct { + name string + fields fields + args args + want want + }{ + { + "missing user id", + fields{ + eventstore: expectEventstore(), + }, + args{ + ctx: context.Background(), + userID: "", + }, + want{ + err: zerrors.ThrowInvalidArgument(nil, "COMMAND-Gk3f2", "Errors.User.UserIDMissing"), + }, + }, + { + "user does not exist", + fields{ + eventstore: expectEventstore( + expectFilter(), + ), + }, + args{ + ctx: context.Background(), + userID: "unknown", + }, + want{ + err: zerrors.ThrowPreconditionFailed(nil, "COMMAND-F5g2h", "Errors.User.NotFound"), + }, + }, + { + "invalid code", + fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + user.NewHumanAddedEvent(context.Background(), + &user.NewAggregate("userID", "org1").Aggregate, + "username", "firstName", + "lastName", + "nickName", + "displayName", + language.Afrikaans, + domain.GenderUnspecified, + "email", + false, + ), + ), + eventFromEventPusherWithCreationDateNow( + user.NewHumanInviteCodeAddedEvent(context.Background(), + &user.NewAggregate("userID", "org1").Aggregate, + &crypto.CryptoValue{ + CryptoType: crypto.TypeEncryption, + Algorithm: "enc", + KeyID: "id", + Crypted: []byte("code"), + }, + time.Hour, + "", + false, + "", + "authRequestID", + ), + ), + ), + expectPush( + eventFromEventPusher( + user.NewHumanInviteCheckFailedEvent(context.Background(), + &user.NewAggregate("userID", "org1").Aggregate, + ), + ), + ), + ), + userEncryption: crypto.CreateMockEncryptionAlg(gomock.NewController(t)), + }, + args{ + ctx: context.Background(), + userID: "userID", + code: "invalid", + }, + want{ + err: zerrors.ThrowInvalidArgument(nil, "COMMAND-Wgn4q", "Errors.User.Code.Invalid"), + }, + }, + { + "code ok", + fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + user.NewHumanAddedEvent(context.Background(), + &user.NewAggregate("userID", "org1").Aggregate, + "username", "firstName", + "lastName", + "nickName", + "displayName", + language.Afrikaans, + domain.GenderUnspecified, + "email", + false, + ), + ), + eventFromEventPusherWithCreationDateNow( + user.NewHumanInviteCodeAddedEvent(context.Background(), + &user.NewAggregate("userID", "org1").Aggregate, + &crypto.CryptoValue{ + CryptoType: crypto.TypeEncryption, + Algorithm: "enc", + KeyID: "id", + Crypted: []byte("code"), + }, + time.Hour, + "", + false, + "", + "authRequestID", + ), + ), + ), + expectPush( + eventFromEventPusher( + user.NewHumanInviteCheckSucceededEvent(context.Background(), + &user.NewAggregate("userID", "org1").Aggregate, + ), + ), + eventFromEventPusher( + user.NewHumanEmailVerifiedEvent(context.Background(), + &user.NewAggregate("userID", "org1").Aggregate, + ), + ), + ), + ), + userEncryption: crypto.CreateMockEncryptionAlg(gomock.NewController(t)), + }, + args{ + ctx: context.Background(), + userID: "userID", + code: "code", + }, + want{ + details: &domain.ObjectDetails{ + ResourceOwner: "org1", + ID: "userID", + }, + }, + }, + { + "code ok, with password and user agent", + fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + user.NewHumanAddedEvent(context.Background(), + &user.NewAggregate("userID", "org1").Aggregate, + "username", "firstName", + "lastName", + "nickName", + "displayName", + language.Afrikaans, + domain.GenderUnspecified, + "email", + false, + ), + ), + eventFromEventPusherWithCreationDateNow( + user.NewHumanInviteCodeAddedEvent(context.Background(), + &user.NewAggregate("userID", "org1").Aggregate, + &crypto.CryptoValue{ + CryptoType: crypto.TypeEncryption, + Algorithm: "enc", + KeyID: "id", + Crypted: []byte("code"), + }, + time.Hour, + "", + false, + "", + "authRequestID", + ), + ), + ), + expectFilter( + eventFromEventPusher( + org.NewPasswordComplexityPolicyAddedEvent(context.Background(), + &org.NewAggregate("org1").Aggregate, + 6, + true, + true, + true, + true, + ), + ), + ), + expectPush( + eventFromEventPusher( + user.NewHumanInviteCheckSucceededEvent(context.Background(), + &user.NewAggregate("userID", "org1").Aggregate, + ), + ), + eventFromEventPusher( + user.NewHumanEmailVerifiedEvent(context.Background(), + &user.NewAggregate("userID", "org1").Aggregate, + ), + ), + eventFromEventPusher( + user.NewHumanPasswordChangedEvent(context.Background(), + &user.NewAggregate("userID", "org1").Aggregate, + "$plain$x$Password1!", + false, + "userAgentID", + ), + ), + ), + ), + userEncryption: crypto.CreateMockEncryptionAlg(gomock.NewController(t)), + userPasswordHasher: mockPasswordHasher("x"), + }, + args{ + ctx: context.Background(), + userID: "userID", + code: "code", + password: "Password1!", + userAgentID: "userAgentID", + }, + want{ + details: &domain.ObjectDetails{ + ResourceOwner: "org1", + ID: "userID", + }, + }, + }, + { + "code ok, with non compliant password", + fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + user.NewHumanAddedEvent(context.Background(), + &user.NewAggregate("userID", "org1").Aggregate, + "username", "firstName", + "lastName", + "nickName", + "displayName", + language.Afrikaans, + domain.GenderUnspecified, + "email", + false, + ), + ), + eventFromEventPusherWithCreationDateNow( + user.NewHumanInviteCodeAddedEvent(context.Background(), + &user.NewAggregate("userID", "org1").Aggregate, + &crypto.CryptoValue{ + CryptoType: crypto.TypeEncryption, + Algorithm: "enc", + KeyID: "id", + Crypted: []byte("code"), + }, + time.Hour, + "", + false, + "", + "authRequestID", + ), + ), + ), + expectFilter( + eventFromEventPusher( + org.NewPasswordComplexityPolicyAddedEvent(context.Background(), + &org.NewAggregate("org1").Aggregate, + 6, + true, + true, + true, + true, + ), + ), + ), + ), + userEncryption: crypto.CreateMockEncryptionAlg(gomock.NewController(t)), + userPasswordHasher: mockPasswordHasher("x"), + }, + args{ + ctx: context.Background(), + userID: "userID", + code: "code", + password: "pw", + userAgentID: "userAgentID", + }, + want{ + err: zerrors.ThrowInvalidArgument(nil, "DOMAIN-HuJf6", "Errors.User.PasswordComplexityPolicy.MinLength"), + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + c := &Commands{ + eventstore: tt.fields.eventstore(t), + userEncryption: tt.fields.userEncryption, + userPasswordHasher: tt.fields.userPasswordHasher, + } + gotDetails, err := c.VerifyInviteCodeSetPassword(tt.args.ctx, tt.args.userID, tt.args.code, tt.args.password, tt.args.userAgentID) + assert.ErrorIs(t, err, tt.want.err) + assert.Equal(t, tt.want.details, gotDetails) + }) + } +} diff --git a/internal/command/user_v2_passkey.go b/internal/command/user_v2_passkey.go index 4d42d105dd..897a1ab41d 100644 --- a/internal/command/user_v2_passkey.go +++ b/internal/command/user_v2_passkey.go @@ -48,7 +48,7 @@ func (c *Commands) verifyUserPasskeyCode(ctx context.Context, userID, resourceOw if err != nil { return nil, err } - err = verifyEncryptedCode(ctx, c.eventstore.Filter, domain.SecretGeneratorTypePasswordlessInitCode, alg, wm.ChangeDate, wm.Expiration, wm.CryptoCode, code) //nolint:staticcheck + err = crypto.VerifyCode(wm.ChangeDate, wm.Expiration, wm.CryptoCode, code, alg) if err != nil || wm.State != domain.PasswordlessInitCodeStateActive { c.verifyUserPasskeyCodeFailed(ctx, wm) return nil, zerrors.ThrowInvalidArgument(err, "COMMAND-Eeb2a", "Errors.User.Code.Invalid") diff --git a/internal/command/user_v2_passkey_test.go b/internal/command/user_v2_passkey_test.go index aa2ded6d7a..a6ba470d2b 100644 --- a/internal/command/user_v2_passkey_test.go +++ b/internal/command/user_v2_passkey_test.go @@ -143,7 +143,7 @@ func TestCommands_RegisterUserPasskeyWithCode(t *testing.T) { require.NoError(t, err) userAgg := &user.NewAggregate("user1", "org1").Aggregate type fields struct { - eventstore *eventstore.Eventstore + eventstore func(*testing.T) *eventstore.Eventstore idGenerator id.Generator } type args struct { @@ -163,7 +163,7 @@ func TestCommands_RegisterUserPasskeyWithCode(t *testing.T) { { name: "code verification error", fields: fields{ - eventstore: eventstoreExpect(t, + eventstore: expectEventstore( expectFilter( eventFromEventPusherWithCreationDateNow( user.NewHumanPasswordlessInitCodeRequestedEvent(context.Background(), @@ -174,7 +174,6 @@ func TestCommands_RegisterUserPasskeyWithCode(t *testing.T) { user.NewHumanPasswordlessInitCodeSentEvent(ctx, userAgg, "123"), ), ), - expectFilter(eventFromEventPusher(testSecretGeneratorAddedEvent(domain.SecretGeneratorTypePasswordlessInitCode))), expectPush( user.NewHumanPasswordlessInitCodeCheckFailedEvent(ctx, userAgg, "123"), ), @@ -192,7 +191,7 @@ func TestCommands_RegisterUserPasskeyWithCode(t *testing.T) { { name: "code verification ok, get human passwordless error", fields: fields{ - eventstore: eventstoreExpect(t, + eventstore: expectEventstore( expectFilter( eventFromEventPusherWithCreationDateNow( user.NewHumanPasswordlessInitCodeRequestedEvent(context.Background(), @@ -203,7 +202,6 @@ func TestCommands_RegisterUserPasskeyWithCode(t *testing.T) { user.NewHumanPasswordlessInitCodeSentEvent(ctx, userAgg, "123"), ), ), - expectFilter(eventFromEventPusher(testSecretGeneratorAddedEvent(domain.SecretGeneratorTypePasswordlessInitCode))), expectFilterError(io.ErrClosedPipe), ), }, @@ -220,7 +218,7 @@ func TestCommands_RegisterUserPasskeyWithCode(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { c := &Commands{ - eventstore: tt.fields.eventstore, + eventstore: tt.fields.eventstore(t), idGenerator: tt.fields.idGenerator, webauthnConfig: webauthnConfig, } @@ -242,7 +240,7 @@ func TestCommands_verifyUserPasskeyCode(t *testing.T) { userAgg := &user.NewAggregate("user1", "org1").Aggregate type fields struct { - eventstore *eventstore.Eventstore + eventstore func(*testing.T) *eventstore.Eventstore } type args struct { userID string @@ -260,7 +258,7 @@ func TestCommands_verifyUserPasskeyCode(t *testing.T) { { name: "filter error", fields: fields{ - eventstore: eventstoreExpect(t, + eventstore: expectEventstore( expectFilterError(io.ErrClosedPipe), ), }, @@ -274,7 +272,7 @@ func TestCommands_verifyUserPasskeyCode(t *testing.T) { { name: "code verification error", fields: fields{ - eventstore: eventstoreExpect(t, + eventstore: expectEventstore( expectFilter( eventFromEventPusherWithCreationDateNow( user.NewHumanPasswordlessInitCodeRequestedEvent(context.Background(), @@ -285,7 +283,6 @@ func TestCommands_verifyUserPasskeyCode(t *testing.T) { user.NewHumanPasswordlessInitCodeSentEvent(ctx, userAgg, "123"), ), ), - expectFilter(eventFromEventPusher(testSecretGeneratorAddedEvent(domain.SecretGeneratorTypePasswordlessInitCode))), expectPush( user.NewHumanPasswordlessInitCodeCheckFailedEvent(ctx, userAgg, "123"), ), @@ -302,7 +299,7 @@ func TestCommands_verifyUserPasskeyCode(t *testing.T) { { name: "success", fields: fields{ - eventstore: eventstoreExpect(t, + eventstore: expectEventstore( expectFilter( eventFromEventPusherWithCreationDateNow( user.NewHumanPasswordlessInitCodeRequestedEvent(context.Background(), @@ -313,7 +310,6 @@ func TestCommands_verifyUserPasskeyCode(t *testing.T) { user.NewHumanPasswordlessInitCodeSentEvent(ctx, userAgg, "123"), ), ), - expectFilter(eventFromEventPusher(testSecretGeneratorAddedEvent(domain.SecretGeneratorTypePasswordlessInitCode))), ), }, args: args{ @@ -328,7 +324,7 @@ func TestCommands_verifyUserPasskeyCode(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { c := &Commands{ - eventstore: tt.fields.eventstore, + eventstore: tt.fields.eventstore(t), } got, err := c.verifyUserPasskeyCode(ctx, tt.args.userID, tt.args.resourceOwner, tt.args.codeID, tt.args.code, alg) require.ErrorIs(t, err, tt.wantErr) diff --git a/internal/domain/custom_message_text.go b/internal/domain/custom_message_text.go index 4ae0a3925a..aa73615451 100644 --- a/internal/domain/custom_message_text.go +++ b/internal/domain/custom_message_text.go @@ -17,6 +17,7 @@ const ( DomainClaimedMessageType = "DomainClaimed" PasswordlessRegistrationMessageType = "PasswordlessRegistration" PasswordChangeMessageType = "PasswordChange" + InviteUserMessageType = "InviteUser" MessageTitle = "Title" MessagePreHeader = "PreHeader" MessageSubject = "Subject" @@ -26,16 +27,6 @@ const ( MessageFooterText = "Footer" ) -type MessageTexts struct { - InitCode CustomMessageText - PasswordReset CustomMessageText - VerifyEmail CustomMessageText - VerifyPhone CustomMessageText - DomainClaimed CustomMessageText - PasswordlessRegistration CustomMessageText - PasswordChange CustomMessageText -} - type CustomMessageText struct { models.ObjectRoot @@ -71,5 +62,6 @@ func IsMessageTextType(textType string) bool { textType == VerifyEmailOTPMessageType || textType == DomainClaimedMessageType || textType == PasswordlessRegistrationMessageType || - textType == PasswordChangeMessageType + textType == PasswordChangeMessageType || + textType == InviteUserMessageType } diff --git a/internal/domain/next_step.go b/internal/domain/next_step.go index 009989956f..7242d6eb1d 100644 --- a/internal/domain/next_step.go +++ b/internal/domain/next_step.go @@ -29,6 +29,7 @@ const ( NextStepProjectRequired NextStepRedirectToExternalIDP NextStepLoginSucceeded + NextStepVerifyInvite ) type LoginStep struct{} @@ -191,3 +192,9 @@ type LoginSucceededStep struct{} func (s *LoginSucceededStep) Type() NextStepType { return NextStepLoginSucceeded } + +type VerifyInviteStep struct{} + +func (s *VerifyInviteStep) Type() NextStepType { + return NextStepVerifyInvite +} diff --git a/internal/domain/secret_generator.go b/internal/domain/secret_generator.go index 10afb774a7..855e3447c1 100644 --- a/internal/domain/secret_generator.go +++ b/internal/domain/secret_generator.go @@ -14,6 +14,7 @@ const ( SecretGeneratorTypeAppSecret SecretGeneratorTypeOTPSMS SecretGeneratorTypeOTPEmail + SecretGeneratorTypeInviteCode secretGeneratorTypeCount ) diff --git a/internal/domain/secretgeneratortype_enumer.go b/internal/domain/secretgeneratortype_enumer.go index 92e0ead334..f819bafc1f 100644 --- a/internal/domain/secretgeneratortype_enumer.go +++ b/internal/domain/secretgeneratortype_enumer.go @@ -4,14 +4,11 @@ package domain import ( "fmt" - "strings" ) -const _SecretGeneratorTypeName = "unspecifiedinit_codeverify_email_codeverify_phone_codeverify_domainpassword_reset_codepasswordless_init_codeapp_secretotpsmsotp_emailsecret_generator_type_count" +const _SecretGeneratorTypeName = "unspecifiedinit_codeverify_email_codeverify_phone_codeverify_domainpassword_reset_codepasswordless_init_codeapp_secretotpsmsotp_emailinvite_codesecret_generator_type_count" -var _SecretGeneratorTypeIndex = [...]uint8{0, 11, 20, 37, 54, 67, 86, 108, 118, 124, 133, 160} - -const _SecretGeneratorTypeLowerName = "unspecifiedinit_codeverify_email_codeverify_phone_codeverify_domainpassword_reset_codepasswordless_init_codeapp_secretotpsmsotp_emailsecret_generator_type_count" +var _SecretGeneratorTypeIndex = [...]uint8{0, 11, 20, 37, 54, 67, 86, 108, 118, 124, 133, 144, 171} func (i SecretGeneratorType) String() string { if i < 0 || i >= SecretGeneratorType(len(_SecretGeneratorTypeIndex)-1) { @@ -20,62 +17,21 @@ func (i SecretGeneratorType) String() string { return _SecretGeneratorTypeName[_SecretGeneratorTypeIndex[i]:_SecretGeneratorTypeIndex[i+1]] } -// An "invalid array index" compiler error signifies that the constant values have changed. -// Re-run the stringer command to generate them again. -func _SecretGeneratorTypeNoOp() { - var x [1]struct{} - _ = x[SecretGeneratorTypeUnspecified-(0)] - _ = x[SecretGeneratorTypeInitCode-(1)] - _ = x[SecretGeneratorTypeVerifyEmailCode-(2)] - _ = x[SecretGeneratorTypeVerifyPhoneCode-(3)] - _ = x[SecretGeneratorTypeVerifyDomain-(4)] - _ = x[SecretGeneratorTypePasswordResetCode-(5)] - _ = x[SecretGeneratorTypePasswordlessInitCode-(6)] - _ = x[SecretGeneratorTypeAppSecret-(7)] - _ = x[SecretGeneratorTypeOTPSMS-(8)] - _ = x[SecretGeneratorTypeOTPEmail-(9)] - _ = x[secretGeneratorTypeCount-(10)] -} - -var _SecretGeneratorTypeValues = []SecretGeneratorType{SecretGeneratorTypeUnspecified, SecretGeneratorTypeInitCode, SecretGeneratorTypeVerifyEmailCode, SecretGeneratorTypeVerifyPhoneCode, SecretGeneratorTypeVerifyDomain, SecretGeneratorTypePasswordResetCode, SecretGeneratorTypePasswordlessInitCode, SecretGeneratorTypeAppSecret, SecretGeneratorTypeOTPSMS, SecretGeneratorTypeOTPEmail, secretGeneratorTypeCount} +var _SecretGeneratorTypeValues = []SecretGeneratorType{0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11} var _SecretGeneratorTypeNameToValueMap = map[string]SecretGeneratorType{ - _SecretGeneratorTypeName[0:11]: SecretGeneratorTypeUnspecified, - _SecretGeneratorTypeLowerName[0:11]: SecretGeneratorTypeUnspecified, - _SecretGeneratorTypeName[11:20]: SecretGeneratorTypeInitCode, - _SecretGeneratorTypeLowerName[11:20]: SecretGeneratorTypeInitCode, - _SecretGeneratorTypeName[20:37]: SecretGeneratorTypeVerifyEmailCode, - _SecretGeneratorTypeLowerName[20:37]: SecretGeneratorTypeVerifyEmailCode, - _SecretGeneratorTypeName[37:54]: SecretGeneratorTypeVerifyPhoneCode, - _SecretGeneratorTypeLowerName[37:54]: SecretGeneratorTypeVerifyPhoneCode, - _SecretGeneratorTypeName[54:67]: SecretGeneratorTypeVerifyDomain, - _SecretGeneratorTypeLowerName[54:67]: SecretGeneratorTypeVerifyDomain, - _SecretGeneratorTypeName[67:86]: SecretGeneratorTypePasswordResetCode, - _SecretGeneratorTypeLowerName[67:86]: SecretGeneratorTypePasswordResetCode, - _SecretGeneratorTypeName[86:108]: SecretGeneratorTypePasswordlessInitCode, - _SecretGeneratorTypeLowerName[86:108]: SecretGeneratorTypePasswordlessInitCode, - _SecretGeneratorTypeName[108:118]: SecretGeneratorTypeAppSecret, - _SecretGeneratorTypeLowerName[108:118]: SecretGeneratorTypeAppSecret, - _SecretGeneratorTypeName[118:124]: SecretGeneratorTypeOTPSMS, - _SecretGeneratorTypeLowerName[118:124]: SecretGeneratorTypeOTPSMS, - _SecretGeneratorTypeName[124:133]: SecretGeneratorTypeOTPEmail, - _SecretGeneratorTypeLowerName[124:133]: SecretGeneratorTypeOTPEmail, - _SecretGeneratorTypeName[133:160]: secretGeneratorTypeCount, - _SecretGeneratorTypeLowerName[133:160]: secretGeneratorTypeCount, -} - -var _SecretGeneratorTypeNames = []string{ - _SecretGeneratorTypeName[0:11], - _SecretGeneratorTypeName[11:20], - _SecretGeneratorTypeName[20:37], - _SecretGeneratorTypeName[37:54], - _SecretGeneratorTypeName[54:67], - _SecretGeneratorTypeName[67:86], - _SecretGeneratorTypeName[86:108], - _SecretGeneratorTypeName[108:118], - _SecretGeneratorTypeName[118:124], - _SecretGeneratorTypeName[124:133], - _SecretGeneratorTypeName[133:160], + _SecretGeneratorTypeName[0:11]: 0, + _SecretGeneratorTypeName[11:20]: 1, + _SecretGeneratorTypeName[20:37]: 2, + _SecretGeneratorTypeName[37:54]: 3, + _SecretGeneratorTypeName[54:67]: 4, + _SecretGeneratorTypeName[67:86]: 5, + _SecretGeneratorTypeName[86:108]: 6, + _SecretGeneratorTypeName[108:118]: 7, + _SecretGeneratorTypeName[118:124]: 8, + _SecretGeneratorTypeName[124:133]: 9, + _SecretGeneratorTypeName[133:144]: 10, + _SecretGeneratorTypeName[144:171]: 11, } // SecretGeneratorTypeString retrieves an enum value from the enum constants string name. @@ -84,10 +40,6 @@ func SecretGeneratorTypeString(s string) (SecretGeneratorType, error) { if val, ok := _SecretGeneratorTypeNameToValueMap[s]; ok { return val, nil } - - if val, ok := _SecretGeneratorTypeNameToValueMap[strings.ToLower(s)]; ok { - return val, nil - } return 0, fmt.Errorf("%s does not belong to SecretGeneratorType values", s) } @@ -96,13 +48,6 @@ func SecretGeneratorTypeValues() []SecretGeneratorType { return _SecretGeneratorTypeValues } -// SecretGeneratorTypeStrings returns a slice of all String values of the enum -func SecretGeneratorTypeStrings() []string { - strs := make([]string, len(_SecretGeneratorTypeNames)) - copy(strs, _SecretGeneratorTypeNames) - return strs -} - // IsASecretGeneratorType returns "true" if the value is listed in the enum definition. "false" otherwise func (i SecretGeneratorType) IsASecretGeneratorType() bool { for _, v := range _SecretGeneratorTypeValues { diff --git a/internal/integration/client.go b/internal/integration/client.go index 404ec0162b..82b8ab3b6e 100644 --- a/internal/integration/client.go +++ b/internal/integration/client.go @@ -775,3 +775,12 @@ func (i *Instance) CreateSchemaUser(ctx context.Context, orgID string, schemaID 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, + Verification: &user_v2.CreateInviteCodeRequest_ReturnCode{ReturnCode: &user_v2.ReturnInviteCode{}}, + }) + logging.OnError(err).Fatal("create invite code") + return user +} diff --git a/internal/notification/handlers/commands.go b/internal/notification/handlers/commands.go index 5308a27c44..3d8546f800 100644 --- a/internal/notification/handlers/commands.go +++ b/internal/notification/handlers/commands.go @@ -19,6 +19,7 @@ type Commands interface { HumanPasswordlessInitCodeSent(ctx context.Context, userID, resourceOwner, codeID string) error PasswordChangeSent(ctx context.Context, orgID, userID string) error HumanPhoneVerificationCodeSent(ctx context.Context, orgID, userID string) error + InviteCodeSent(ctx context.Context, orgID, userID string) error UsageNotificationSent(ctx context.Context, dueEvent *quota.NotificationDueEvent) error MilestonePushed(ctx context.Context, msType milestone.Type, endpoints []string, primaryDomain string) error } diff --git a/internal/notification/handlers/mock/commands.mock.go b/internal/notification/handlers/mock/commands.mock.go index 3105dcdf60..ab94eda2cc 100644 --- a/internal/notification/handlers/mock/commands.mock.go +++ b/internal/notification/handlers/mock/commands.mock.go @@ -18,30 +18,30 @@ import ( gomock "go.uber.org/mock/gomock" ) -// MockCommands is a mock of Commands interface. +// MockCommands is a mock of Commands interface type MockCommands struct { ctrl *gomock.Controller recorder *MockCommandsMockRecorder } -// MockCommandsMockRecorder is the mock recorder for MockCommands. +// MockCommandsMockRecorder is the mock recorder for MockCommands type MockCommandsMockRecorder struct { mock *MockCommands } -// NewMockCommands creates a new mock instance. +// NewMockCommands creates a new mock instance func NewMockCommands(ctrl *gomock.Controller) *MockCommands { mock := &MockCommands{ctrl: ctrl} mock.recorder = &MockCommandsMockRecorder{mock} return mock } -// EXPECT returns an object that allows the caller to indicate expected use. +// EXPECT returns an object that allows the caller to indicate expected use func (m *MockCommands) EXPECT() *MockCommandsMockRecorder { return m.recorder } -// HumanEmailVerificationCodeSent mocks base method. +// HumanEmailVerificationCodeSent mocks base method func (m *MockCommands) HumanEmailVerificationCodeSent(arg0 context.Context, arg1, arg2 string) error { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "HumanEmailVerificationCodeSent", arg0, arg1, arg2) @@ -49,13 +49,13 @@ func (m *MockCommands) HumanEmailVerificationCodeSent(arg0 context.Context, arg1 return ret0 } -// HumanEmailVerificationCodeSent indicates an expected call of HumanEmailVerificationCodeSent. -func (mr *MockCommandsMockRecorder) HumanEmailVerificationCodeSent(arg0, arg1, arg2 any) *gomock.Call { +// HumanEmailVerificationCodeSent indicates an expected call of HumanEmailVerificationCodeSent +func (mr *MockCommandsMockRecorder) HumanEmailVerificationCodeSent(arg0, arg1, arg2 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "HumanEmailVerificationCodeSent", reflect.TypeOf((*MockCommands)(nil).HumanEmailVerificationCodeSent), arg0, arg1, arg2) } -// HumanInitCodeSent mocks base method. +// HumanInitCodeSent mocks base method func (m *MockCommands) HumanInitCodeSent(arg0 context.Context, arg1, arg2 string) error { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "HumanInitCodeSent", arg0, arg1, arg2) @@ -63,13 +63,13 @@ func (m *MockCommands) HumanInitCodeSent(arg0 context.Context, arg1, arg2 string return ret0 } -// HumanInitCodeSent indicates an expected call of HumanInitCodeSent. -func (mr *MockCommandsMockRecorder) HumanInitCodeSent(arg0, arg1, arg2 any) *gomock.Call { +// HumanInitCodeSent indicates an expected call of HumanInitCodeSent +func (mr *MockCommandsMockRecorder) HumanInitCodeSent(arg0, arg1, arg2 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "HumanInitCodeSent", reflect.TypeOf((*MockCommands)(nil).HumanInitCodeSent), arg0, arg1, arg2) } -// HumanOTPEmailCodeSent mocks base method. +// HumanOTPEmailCodeSent mocks base method func (m *MockCommands) HumanOTPEmailCodeSent(arg0 context.Context, arg1, arg2 string) error { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "HumanOTPEmailCodeSent", arg0, arg1, arg2) @@ -77,13 +77,13 @@ func (m *MockCommands) HumanOTPEmailCodeSent(arg0 context.Context, arg1, arg2 st return ret0 } -// HumanOTPEmailCodeSent indicates an expected call of HumanOTPEmailCodeSent. -func (mr *MockCommandsMockRecorder) HumanOTPEmailCodeSent(arg0, arg1, arg2 any) *gomock.Call { +// HumanOTPEmailCodeSent indicates an expected call of HumanOTPEmailCodeSent +func (mr *MockCommandsMockRecorder) HumanOTPEmailCodeSent(arg0, arg1, arg2 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "HumanOTPEmailCodeSent", reflect.TypeOf((*MockCommands)(nil).HumanOTPEmailCodeSent), arg0, arg1, arg2) } -// HumanOTPSMSCodeSent mocks base method. +// HumanOTPSMSCodeSent mocks base method func (m *MockCommands) HumanOTPSMSCodeSent(arg0 context.Context, arg1, arg2 string) error { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "HumanOTPSMSCodeSent", arg0, arg1, arg2) @@ -91,13 +91,13 @@ func (m *MockCommands) HumanOTPSMSCodeSent(arg0 context.Context, arg1, arg2 stri return ret0 } -// HumanOTPSMSCodeSent indicates an expected call of HumanOTPSMSCodeSent. -func (mr *MockCommandsMockRecorder) HumanOTPSMSCodeSent(arg0, arg1, arg2 any) *gomock.Call { +// HumanOTPSMSCodeSent indicates an expected call of HumanOTPSMSCodeSent +func (mr *MockCommandsMockRecorder) HumanOTPSMSCodeSent(arg0, arg1, arg2 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "HumanOTPSMSCodeSent", reflect.TypeOf((*MockCommands)(nil).HumanOTPSMSCodeSent), arg0, arg1, arg2) } -// HumanPasswordlessInitCodeSent mocks base method. +// HumanPasswordlessInitCodeSent mocks base method func (m *MockCommands) HumanPasswordlessInitCodeSent(arg0 context.Context, arg1, arg2, arg3 string) error { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "HumanPasswordlessInitCodeSent", arg0, arg1, arg2, arg3) @@ -105,13 +105,13 @@ func (m *MockCommands) HumanPasswordlessInitCodeSent(arg0 context.Context, arg1, return ret0 } -// HumanPasswordlessInitCodeSent indicates an expected call of HumanPasswordlessInitCodeSent. -func (mr *MockCommandsMockRecorder) HumanPasswordlessInitCodeSent(arg0, arg1, arg2, arg3 any) *gomock.Call { +// HumanPasswordlessInitCodeSent indicates an expected call of HumanPasswordlessInitCodeSent +func (mr *MockCommandsMockRecorder) HumanPasswordlessInitCodeSent(arg0, arg1, arg2, arg3 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "HumanPasswordlessInitCodeSent", reflect.TypeOf((*MockCommands)(nil).HumanPasswordlessInitCodeSent), arg0, arg1, arg2, arg3) } -// HumanPhoneVerificationCodeSent mocks base method. +// HumanPhoneVerificationCodeSent mocks base method func (m *MockCommands) HumanPhoneVerificationCodeSent(arg0 context.Context, arg1, arg2 string) error { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "HumanPhoneVerificationCodeSent", arg0, arg1, arg2) @@ -119,13 +119,27 @@ func (m *MockCommands) HumanPhoneVerificationCodeSent(arg0 context.Context, arg1 return ret0 } -// HumanPhoneVerificationCodeSent indicates an expected call of HumanPhoneVerificationCodeSent. -func (mr *MockCommandsMockRecorder) HumanPhoneVerificationCodeSent(arg0, arg1, arg2 any) *gomock.Call { +// HumanPhoneVerificationCodeSent indicates an expected call of HumanPhoneVerificationCodeSent +func (mr *MockCommandsMockRecorder) HumanPhoneVerificationCodeSent(arg0, arg1, arg2 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "HumanPhoneVerificationCodeSent", reflect.TypeOf((*MockCommands)(nil).HumanPhoneVerificationCodeSent), arg0, arg1, arg2) } -// MilestonePushed mocks base method. +// InviteCodeSent mocks base method +func (m *MockCommands) InviteCodeSent(arg0 context.Context, arg1, arg2 string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "InviteCodeSent", arg0, arg1, arg2) + ret0, _ := ret[0].(error) + return ret0 +} + +// InviteCodeSent indicates an expected call of InviteCodeSent +func (mr *MockCommandsMockRecorder) InviteCodeSent(arg0, arg1, arg2 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "InviteCodeSent", reflect.TypeOf((*MockCommands)(nil).InviteCodeSent), arg0, arg1, arg2) +} + +// MilestonePushed mocks base method func (m *MockCommands) MilestonePushed(arg0 context.Context, arg1 milestone.Type, arg2 []string, arg3 string) error { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "MilestonePushed", arg0, arg1, arg2, arg3) @@ -133,13 +147,13 @@ func (m *MockCommands) MilestonePushed(arg0 context.Context, arg1 milestone.Type return ret0 } -// MilestonePushed indicates an expected call of MilestonePushed. -func (mr *MockCommandsMockRecorder) MilestonePushed(arg0, arg1, arg2, arg3 any) *gomock.Call { +// MilestonePushed indicates an expected call of MilestonePushed +func (mr *MockCommandsMockRecorder) MilestonePushed(arg0, arg1, arg2, arg3 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "MilestonePushed", reflect.TypeOf((*MockCommands)(nil).MilestonePushed), arg0, arg1, arg2, arg3) } -// OTPEmailSent mocks base method. +// OTPEmailSent mocks base method func (m *MockCommands) OTPEmailSent(arg0 context.Context, arg1, arg2 string) error { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "OTPEmailSent", arg0, arg1, arg2) @@ -147,13 +161,13 @@ func (m *MockCommands) OTPEmailSent(arg0 context.Context, arg1, arg2 string) err return ret0 } -// OTPEmailSent indicates an expected call of OTPEmailSent. -func (mr *MockCommandsMockRecorder) OTPEmailSent(arg0, arg1, arg2 any) *gomock.Call { +// OTPEmailSent indicates an expected call of OTPEmailSent +func (mr *MockCommandsMockRecorder) OTPEmailSent(arg0, arg1, arg2 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "OTPEmailSent", reflect.TypeOf((*MockCommands)(nil).OTPEmailSent), arg0, arg1, arg2) } -// OTPSMSSent mocks base method. +// OTPSMSSent mocks base method func (m *MockCommands) OTPSMSSent(arg0 context.Context, arg1, arg2 string) error { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "OTPSMSSent", arg0, arg1, arg2) @@ -161,13 +175,13 @@ func (m *MockCommands) OTPSMSSent(arg0 context.Context, arg1, arg2 string) error return ret0 } -// OTPSMSSent indicates an expected call of OTPSMSSent. -func (mr *MockCommandsMockRecorder) OTPSMSSent(arg0, arg1, arg2 any) *gomock.Call { +// OTPSMSSent indicates an expected call of OTPSMSSent +func (mr *MockCommandsMockRecorder) OTPSMSSent(arg0, arg1, arg2 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "OTPSMSSent", reflect.TypeOf((*MockCommands)(nil).OTPSMSSent), arg0, arg1, arg2) } -// PasswordChangeSent mocks base method. +// PasswordChangeSent mocks base method func (m *MockCommands) PasswordChangeSent(arg0 context.Context, arg1, arg2 string) error { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "PasswordChangeSent", arg0, arg1, arg2) @@ -175,13 +189,13 @@ func (m *MockCommands) PasswordChangeSent(arg0 context.Context, arg1, arg2 strin return ret0 } -// PasswordChangeSent indicates an expected call of PasswordChangeSent. -func (mr *MockCommandsMockRecorder) PasswordChangeSent(arg0, arg1, arg2 any) *gomock.Call { +// PasswordChangeSent indicates an expected call of PasswordChangeSent +func (mr *MockCommandsMockRecorder) PasswordChangeSent(arg0, arg1, arg2 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PasswordChangeSent", reflect.TypeOf((*MockCommands)(nil).PasswordChangeSent), arg0, arg1, arg2) } -// PasswordCodeSent mocks base method. +// PasswordCodeSent mocks base method func (m *MockCommands) PasswordCodeSent(arg0 context.Context, arg1, arg2 string) error { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "PasswordCodeSent", arg0, arg1, arg2) @@ -189,13 +203,13 @@ func (m *MockCommands) PasswordCodeSent(arg0 context.Context, arg1, arg2 string) return ret0 } -// PasswordCodeSent indicates an expected call of PasswordCodeSent. -func (mr *MockCommandsMockRecorder) PasswordCodeSent(arg0, arg1, arg2 any) *gomock.Call { +// PasswordCodeSent indicates an expected call of PasswordCodeSent +func (mr *MockCommandsMockRecorder) PasswordCodeSent(arg0, arg1, arg2 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PasswordCodeSent", reflect.TypeOf((*MockCommands)(nil).PasswordCodeSent), arg0, arg1, arg2) } -// UsageNotificationSent mocks base method. +// UsageNotificationSent mocks base method func (m *MockCommands) UsageNotificationSent(arg0 context.Context, arg1 *quota.NotificationDueEvent) error { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "UsageNotificationSent", arg0, arg1) @@ -203,13 +217,13 @@ func (m *MockCommands) UsageNotificationSent(arg0 context.Context, arg1 *quota.N return ret0 } -// UsageNotificationSent indicates an expected call of UsageNotificationSent. -func (mr *MockCommandsMockRecorder) UsageNotificationSent(arg0, arg1 any) *gomock.Call { +// UsageNotificationSent indicates an expected call of UsageNotificationSent +func (mr *MockCommandsMockRecorder) UsageNotificationSent(arg0, arg1 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UsageNotificationSent", reflect.TypeOf((*MockCommands)(nil).UsageNotificationSent), arg0, arg1) } -// UserDomainClaimedSent mocks base method. +// UserDomainClaimedSent mocks base method func (m *MockCommands) UserDomainClaimedSent(arg0 context.Context, arg1, arg2 string) error { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "UserDomainClaimedSent", arg0, arg1, arg2) @@ -217,8 +231,8 @@ func (m *MockCommands) UserDomainClaimedSent(arg0 context.Context, arg1, arg2 st return ret0 } -// UserDomainClaimedSent indicates an expected call of UserDomainClaimedSent. -func (mr *MockCommandsMockRecorder) UserDomainClaimedSent(arg0, arg1, arg2 any) *gomock.Call { +// UserDomainClaimedSent indicates an expected call of UserDomainClaimedSent +func (mr *MockCommandsMockRecorder) UserDomainClaimedSent(arg0, arg1, arg2 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UserDomainClaimedSent", reflect.TypeOf((*MockCommands)(nil).UserDomainClaimedSent), arg0, arg1, arg2) } diff --git a/internal/notification/handlers/user_notifier.go b/internal/notification/handlers/user_notifier.go index 1826e9d79c..57aa3a9251 100644 --- a/internal/notification/handlers/user_notifier.go +++ b/internal/notification/handlers/user_notifier.go @@ -106,6 +106,10 @@ func (u *userNotifier) Reducers() []handler.AggregateReducer { Event: user.HumanOTPEmailCodeAddedType, Reduce: u.reduceOTPEmailCodeAdded, }, + { + Event: user.HumanInviteCodeAddedType, + Reduce: u.reduceInviteCodeAdded, + }, }, }, { @@ -718,6 +722,61 @@ func (u *userNotifier) reducePhoneCodeAdded(event eventstore.Event) (*handler.St }), nil } +func (u *userNotifier) reduceInviteCodeAdded(event eventstore.Event) (*handler.Statement, error) { + e, ok := event.(*user.HumanInviteCodeAddedEvent) + if !ok { + return nil, zerrors.ThrowInvalidArgumentf(nil, "HANDL-Eeg3s", "reduce.wrong.event.type %s", user.HumanInviteCodeAddedType) + } + if e.CodeReturned { + return handler.NewNoOpStatement(e), nil + } + + return handler.NewStatement(event, func(ex handler.Executer, projectionName string) error { + ctx := HandlerContext(event.Aggregate()) + alreadyHandled, err := u.checkIfCodeAlreadyHandledOrExpired(ctx, event, e.Expiry, nil, + user.HumanInviteCodeAddedType, user.HumanInviteCodeSentType) + if err != nil { + return err + } + if alreadyHandled { + return nil + } + code, err := crypto.DecryptString(e.Code, u.queries.UserDataCrypto) + if err != nil { + return err + } + colors, err := u.queries.ActiveLabelPolicyByOrg(ctx, e.Aggregate().ResourceOwner, false) + if err != nil { + return err + } + + template, err := u.queries.MailTemplateByOrg(ctx, e.Aggregate().ResourceOwner, false) + if err != nil { + return err + } + + notifyUser, err := u.queries.GetNotifyUserByID(ctx, true, e.Aggregate().ID) + if err != nil { + return err + } + translator, err := u.queries.GetTranslatorWithOrgTexts(ctx, notifyUser.ResourceOwner, domain.InviteUserMessageType) + if err != nil { + return err + } + + ctx, err = u.queries.Origin(ctx, e) + if err != nil { + return err + } + notify := types.SendEmail(ctx, u.channels, string(template.Template), translator, notifyUser, colors, e) + err = notify.SendInviteCode(ctx, notifyUser, code, e.ApplicationName, e.URLTemplate, e.AuthRequestID) + if err != nil { + return err + } + return u.commands.InviteCodeSent(ctx, e.Aggregate().ID, e.Aggregate().ResourceOwner) + }), nil +} + func (u *userNotifier) checkIfCodeAlreadyHandledOrExpired(ctx context.Context, event eventstore.Event, expiry time.Duration, data map[string]interface{}, eventTypes ...eventstore.EventType) (bool, error) { if event.CreatedAt().Add(expiry).Before(time.Now().UTC()) { return true, nil diff --git a/internal/notification/static/i18n/bg.yaml b/internal/notification/static/i18n/bg.yaml index 7f67c24dfb..47bed1cb5d 100644 --- a/internal/notification/static/i18n/bg.yaml +++ b/internal/notification/static/i18n/bg.yaml @@ -69,3 +69,10 @@ PasswordChange: Паролата на вашия потребител е променена, ако тази промяна не е направена от вас, моля, незабавно нулирайте паролата си. ButtonText: Влизам +InviteUser: + Title: Покана за {{.ApplicationName}} + PreHeader: Покана за {{.ApplicationName}} + Subject: Покана за {{.ApplicationName}} + Greeting: 'Здравейте {{.DisplayName}},' + Text: Вашият потребител е бил поканен за {{.ApplicationName}}. Моля, кликнете върху бутона по-долу, за да завършите процеса на покана. Ако не сте поискали този имейл, моля, игнорирайте го. + ButtonText: Приеми поканата \ No newline at end of file diff --git a/internal/notification/static/i18n/cs.yaml b/internal/notification/static/i18n/cs.yaml index b3d4cf18f5..95e897998e 100644 --- a/internal/notification/static/i18n/cs.yaml +++ b/internal/notification/static/i18n/cs.yaml @@ -59,3 +59,10 @@ PasswordChange: Greeting: Dobrý den, {{.DisplayName}}, Text: Heslo vašeho uživatele bylo změněno. Pokud tato změna nebyla provedena Vámi pak doporučujeme okamžitě resetovat/změnit vaše heslo. ButtonText: Přihlásit se +InviteUser: + Title: Pozvánka do {{.ApplicationName}} + PreHeader: Pozvánka do {{.ApplicationName}} + Subject: Pozvánka do {{.ApplicationName}} + Greeting: Dobrý den, {{.DisplayName}}, + Text: Váš uživatel byl pozván do {{.ApplicationName}}. Klikněte prosím na tlačítko níže, abyste dokončili proces pozvání. Pokud jste o tento e-mail nepožádali, prosím, ignorujte ho. + ButtonText: Přijmout pozvání \ No newline at end of file diff --git a/internal/notification/static/i18n/de.yaml b/internal/notification/static/i18n/de.yaml index 673b207ef2..88ca694754 100644 --- a/internal/notification/static/i18n/de.yaml +++ b/internal/notification/static/i18n/de.yaml @@ -59,3 +59,10 @@ PasswordChange: Greeting: Hallo {{.DisplayName}}, Text: Dein Passwort wurde geändert. Wenn diese Änderung nicht von dir gemacht wurde, empfehlen wir das sofortige Zurücksetzen deines Passworts. ButtonText: Login +InviteUser: + Title: Einladung zu {{.ApplicationName}} + PreHeader: Einladung zu {{.ApplicationName}} + Subject: Einladung zu {{.ApplicationName}} + Greeting: Hallo {{.DisplayName}}, + Text: Ihr Benutzer wurde zu {{.ApplicationName}} eingeladen. Bitte klicken Sie auf die Schaltfläche unten, um den Einladungsprozess abzuschließen. Wenn Sie diese E-Mail nicht angefordert haben, ignorieren Sie sie bitte. + ButtonText: Einladung annehmen \ No newline at end of file diff --git a/internal/notification/static/i18n/en.yaml b/internal/notification/static/i18n/en.yaml index 20e187aa0b..a431fc999d 100644 --- a/internal/notification/static/i18n/en.yaml +++ b/internal/notification/static/i18n/en.yaml @@ -59,3 +59,10 @@ PasswordChange: Greeting: Hello {{.DisplayName}}, Text: The password of your user has changed. If this change was not done by you, please be advised to immediately reset your password. ButtonText: Login +InviteUser: + Title: Invitation to {{.ApplicationName}} + PreHeader: Invitation to {{.ApplicationName}} + Subject: Invitation to {{.ApplicationName}} + Greeting: Hello {{.DisplayName}}, + Text: Your user has been invited to {{.ApplicationName}}. Please click the button below to finish the invite process. If you didn't ask for this mail, please ignore it. + ButtonText: Accept invite \ No newline at end of file diff --git a/internal/notification/static/i18n/es.yaml b/internal/notification/static/i18n/es.yaml index 1e92e30be2..caf138f1f9 100644 --- a/internal/notification/static/i18n/es.yaml +++ b/internal/notification/static/i18n/es.yaml @@ -59,3 +59,10 @@ PasswordChange: Greeting: Hola {{.DisplayName}}, Text: La contraseña de tu usuario ha sido cambiada, si este cambio no fue hecho por ti, por favor proceder a restablecer inmediatamente tu contraseña. ButtonText: Iniciar sesión +InviteUser: + Title: Invitación a {{.ApplicationName}} + PreHeader: Invitación a {{.ApplicationName}} + Subject: Invitación a {{.ApplicationName}} + Greeting: Hola {{.DisplayName}}, + Text: Tu usuario ha sido invitado a {{.ApplicationName}}. Haz clic en el botón de abajo para finalizar el proceso de invitación. Si no solicitaste este correo electrónico, por favor ignóralo. + ButtonText: Aceptar invitación \ No newline at end of file diff --git a/internal/notification/static/i18n/fr.yaml b/internal/notification/static/i18n/fr.yaml index a313343ba5..8fab61808b 100644 --- a/internal/notification/static/i18n/fr.yaml +++ b/internal/notification/static/i18n/fr.yaml @@ -59,3 +59,10 @@ PasswordChange: Greeting: Bonjour {{.DisplayName}}, Text: Le mot de passe de votre utilisateur a changé, si ce changement n'a pas été fait par vous, nous vous conseillons de réinitialiser immédiatement votre mot de passe. ButtonText: Login +InviteUser: + Title: Invitation à {{.ApplicationName}} + PreHeader: Invitation à {{.ApplicationName}} + Subject: Invitation à {{.ApplicationName}} + Greeting: Bonjour {{.DisplayName}}, + Text: Votre utilisateur a été invité à {{.ApplicationName}}. Veuillez cliquer sur le bouton ci-dessous pour terminer le processus d'invitation. Si vous n'avez pas demandé cet e-mail, veuillez l'ignorer. + ButtonText: Accepter l'invitation \ No newline at end of file diff --git a/internal/notification/static/i18n/id.yaml b/internal/notification/static/i18n/id.yaml index e5bd60d218..928c94215f 100644 --- a/internal/notification/static/i18n/id.yaml +++ b/internal/notification/static/i18n/id.yaml @@ -59,3 +59,10 @@ PasswordChange: Greeting: 'Halo {{.DisplayName}},' Text: 'Kata sandi pengguna Anda telah berubah. ' ButtonText: Login +InviteUser: + Title: Undangan ke {{.ApplicationName}} + PreHeader: Undangan ke {{.ApplicationName}} + Subject: Undangan ke {{.ApplicationName}} + Greeting: 'Halo {{.DisplayName}},' + Text: Pengguna Anda telah diundang ke {{.ApplicationName}}. Silakan klik tombol di bawah ini untuk menyelesaikan proses undangan. Jika Anda tidak meminta email ini, harap abaikan. + ButtonText: Terima undangan \ No newline at end of file diff --git a/internal/notification/static/i18n/it.yaml b/internal/notification/static/i18n/it.yaml index 58538b36b7..6be2f16a5a 100644 --- a/internal/notification/static/i18n/it.yaml +++ b/internal/notification/static/i18n/it.yaml @@ -59,3 +59,10 @@ PasswordChange: Greeting: Ciao {{.DisplayName}}, Text: La password del vostro utente è cambiata; se questa modifica non è stata fatta da voi, vi consigliamo di reimpostare immediatamente la vostra password. ButtonText: Login +InviteUser: + Title: Invito a {{.ApplicationName}} + PreHeader: Invito a {{.ApplicationName}} + Subject: Invito a {{.ApplicationName}} + Greeting: 'Ciao {{.DisplayName}},' + Text: Il tuo utente è stato invitato a {{.ApplicationName}}. Clicca sul pulsante qui sotto per completare il processo di invito. Se non hai richiesto questa email, ignorala. + ButtonText: Accetta invito \ No newline at end of file diff --git a/internal/notification/static/i18n/ja.yaml b/internal/notification/static/i18n/ja.yaml index 0696be71d8..4419f51b83 100644 --- a/internal/notification/static/i18n/ja.yaml +++ b/internal/notification/static/i18n/ja.yaml @@ -59,3 +59,10 @@ PasswordChange: Greeting: こんにちは {{.DisplayName}} さん、 Text: ユーザーのパスワードが変更されました。この変更があなたによって行われなかった場合は、すぐにパスワードをリセットすることをお勧めします。 ButtonText: ログイン +InviteUser: + Title: '{{.ApplicationName}}への招待' + PreHeader: '{{.ApplicationName}}への招待' + Subject: '{{.ApplicationName}}への招待' + Greeting: こんにちは {{.DisplayName}} さん、 + Text: あなたのユーザーは{{.ApplicationName}}に招待されました。下のボタンをクリックして、招待プロセスを完了してください。このメールをリクエストしていない場合は、無視してください。 + ButtonText: 招待を受け入れる \ No newline at end of file diff --git a/internal/notification/static/i18n/mk.yaml b/internal/notification/static/i18n/mk.yaml index 1d9d19b71d..a89b91227f 100644 --- a/internal/notification/static/i18n/mk.yaml +++ b/internal/notification/static/i18n/mk.yaml @@ -59,3 +59,10 @@ PasswordChange: Greeting: Здраво {{.DisplayName}}, Text: Лозинката на вашиот корисник е променета. Ако оваа промена не е извршена од вас, ве молиме веднаш ресетирајте ја вашата лозинка. ButtonText: Најава +InviteUser: + Title: Покана за {{.ApplicationName}} + PreHeader: Покана за {{.ApplicationName}} + Subject: Покана за {{.ApplicationName}} + Greeting: Здраво {{.DisplayName}}, + Text: Вашиот корисник е бил поканет за {{.ApplicationName}}. Ве молиме кликнете на копчето подолу за да го завршите процесот на покана. Ако не сте побарале овој мејл, ве молиме игнорирајте го. + ButtonText: Прифати покана \ No newline at end of file diff --git a/internal/notification/static/i18n/nl.yaml b/internal/notification/static/i18n/nl.yaml index 2c2c3632aa..46e247b64c 100644 --- a/internal/notification/static/i18n/nl.yaml +++ b/internal/notification/static/i18n/nl.yaml @@ -59,3 +59,10 @@ PasswordChange: Greeting: Hallo {{.DisplayName}}, Text: Het wachtwoord van uw gebruiker is veranderd. Als deze wijziging niet door u is gedaan, wordt u geadviseerd om direct uw wachtwoord te resetten. ButtonText: Inloggen +InviteUser: + Title: Uitnodiging voor {{.ApplicationName}} + PreHeader: Uitnodiging voor {{.ApplicationName}} + Subject: Uitnodiging voor {{.ApplicationName}} + Greeting: Hallo {{.DisplayName}}, + Text: Uw gebruiker is uitgenodigd voor {{.ApplicationName}}. Klik op de onderstaande knop om het uitnodigingsproces te voltooien. Als u deze e-mail niet hebt aangevraagd, negeer deze dan. + ButtonText: Uitnodiging accepteren \ No newline at end of file diff --git a/internal/notification/static/i18n/pl.yaml b/internal/notification/static/i18n/pl.yaml index e67b178c29..b2e9f268bb 100644 --- a/internal/notification/static/i18n/pl.yaml +++ b/internal/notification/static/i18n/pl.yaml @@ -59,3 +59,10 @@ PasswordChange: Greeting: Witaj {{.DisplayName}}, Text: Hasło Twojego użytkownika zostało zmienione, jeśli ta zmiana nie została dokonana przez Ciebie, zalecamy natychmiastowe zresetowanie hasła. ButtonText: Zaloguj się +InviteUser: + Title: Zaproszenie do {{.ApplicationName}} + PreHeader: Zaproszenie do {{.ApplicationName}} + Subject: Zaproszenie do {{.ApplicationName}} + Greeting: Witaj {{.DisplayName}}, + Text: Twój użytkownik został zaproszony do {{.ApplicationName}}. Kliknij poniższy przycisk, aby zakończyć proces zaproszenia. Jeśli nie zażądałeś tego e-maila, zignoruj go. + ButtonText: Akceptuj zaproszenie \ No newline at end of file diff --git a/internal/notification/static/i18n/pt.yaml b/internal/notification/static/i18n/pt.yaml index a7a2480b81..c45c20b8f7 100644 --- a/internal/notification/static/i18n/pt.yaml +++ b/internal/notification/static/i18n/pt.yaml @@ -59,3 +59,10 @@ PasswordChange: Greeting: Olá {{.DisplayName}}, Text: A senha do seu usuário foi alterada. Se esta alteração não foi feita por você, recomendamos que você redefina sua senha imediatamente. ButtonText: Fazer login +InviteUser: + Title: Convite para {{.ApplicationName}} + PreHeader: Convite para {{.ApplicationName}} + Subject: Convite para {{.ApplicationName}} + Greeting: Olá {{.DisplayName}}, + Text: Seu usuário foi convidado para {{.ApplicationName}}. Clique no botão abaixo para concluir o processo de convite. Se você não solicitou este e-mail, por favor, ignore-o. + ButtonText: Aceitar convite \ No newline at end of file diff --git a/internal/notification/static/i18n/ru.yaml b/internal/notification/static/i18n/ru.yaml index ec187554d0..af700dc975 100644 --- a/internal/notification/static/i18n/ru.yaml +++ b/internal/notification/static/i18n/ru.yaml @@ -2,28 +2,28 @@ InitCode: Title: Регистрация пользователя PreHeader: Регистрация пользователя Subject: Регистрация пользователя - Greeting: Здравствуйте {{.FirstName}} {{.LastName}}, + Greeting: Здравствуйте {{.DisplayName}}, Text: Используйте логин {{.PreferredLoginName}} для входа. Пожалуйста, нажмите кнопку ниже для завершения процесса регистрации. (Код {{.Code}}) Если вы не запрашивали это письмо, пожалуйста, проигнорируйте его. ButtonText: Завершить регистрацию PasswordReset: Title: Сброс пароля PreHeader: Сброс пароля Subject: Сброс пароля - Greeting: Здравствуйте {{.FirstName}} {{.LastName}}, + Greeting: Здравствуйте {{.DisplayName}}, Text: Мы получили запрос на сброс пароля. Пожалуйста, нажмите кнопку ниже для сброса вашего пароля. (Код {{.Code}}) Если вы не запрашивали это письмо, пожалуйста, проигнорируйте его. ButtonText: Сбросить пароль VerifyEmail: Title: Подтверждение email PreHeader: Подтверждение email Subject: Подтверждение email - Greeting: Здравствуйте {{.FirstName}} {{.LastName}}, + Greeting: Здравствуйте {{.DisplayName}}, Text: Добавлен новый email. Пожалуйста, нажмите кнопку ниже для подтверждения вашего email. (Код {{.Code}}) Если вы не запрашивали это письмо, пожалуйста, проигнорируйте его. ButtonText: Подтвердить email VerifyPhone: Title: Подтверждение телефона PreHeader: Подтверждение телефона Subject: Подтверждение телефона - Greeting: Здравствуйте {{.FirstName}} {{.LastName}}, + Greeting: Здравствуйте {{.DisplayName}}, Text: Добавлен новый номер телефона. Пожалуйста, используйте следующий код, чтобы подтвердить его. Код {{.Code}} ButtonText: Подтвердить телефон VerifyEmailOTP: @@ -42,20 +42,27 @@ DomainClaimed: Title: Утверждение домена PreHeader: Изменение email / логина Subject: Домен был утвержден - Greeting: Здравствуйте {{.FirstName}} {{.LastName}}, + Greeting: Здравствуйте {{.DisplayName}}, Text: Домен {{.Domain}} был утвержден организацией. Ваш текущий пользователь {{.Username}} не является частью этой организации. Вам необходимо изменить свой email при входе в систему. Мы создали временный логин ({{.TempUsername}}) для входа. ButtonText: Вход PasswordlessRegistration: Title: Добавление входа без пароля PreHeader: Добавление входа без пароля Subject: Добавление входа без пароля - Greeting: Здравствуйте {{.FirstName}} {{.LastName}}, + Greeting: Здравствуйте {{.DisplayName}}, Text: Мы получили запрос на добавление токена для входа без пароля. Пожалуйста, используйте кнопку ниже, чтобы добавить свой токен или устройство для входа без пароля. ButtonText: Добавить вход без пароля PasswordChange: Title: Смена пароля пользователя PreHeader: Смена пароля Subject: Пароль пользователя изменен - Greeting: Здравствуйте {{.FirstName}} {{.LastName}}, + Greeting: Здравствуйте {{.DisplayName}}, Text: Пароль пользователя был изменен. Если это изменение сделано не вами, советуем немедленно сбросить пароль. ButtonText: Вход +InviteUser: + Title: Приглашение в {{.ApplicationName}} + PreHeader: Приглашение в {{.ApplicationName}} + Subject: Приглашение в {{.ApplicationName}} + Greeting: Здравствуйте, {{.DisplayName}}, + Text: Ваш пользователь был приглашен в {{.ApplicationName}}. Пожалуйста, нажмите кнопку ниже, чтобы завершить процесс приглашения. Если вы не запрашивали это письмо, пожалуйста, игнорируйте его. + ButtonText: Принять приглашение \ No newline at end of file diff --git a/internal/notification/static/i18n/sv.yaml b/internal/notification/static/i18n/sv.yaml index 4788644c79..49c79a6790 100644 --- a/internal/notification/static/i18n/sv.yaml +++ b/internal/notification/static/i18n/sv.yaml @@ -59,3 +59,10 @@ PasswordChange: Greeting: Hej {{.DisplayName}}, Text: Lösenordet för din användare har ändrats. Om denna ändring inte gjordes av dig, vänligen återställ ditt lösenord omedelbart. ButtonText: Logga in +InviteUser: + Title: Inbjudan till {{.ApplicationName}} + PreHeader: Inbjudan till {{.ApplicationName}} + Subject: Inbjudan till {{.ApplicationName}} + Greeting: Hej {{.DisplayName}}, + Text: Din användare har blivit inbjuden till {{.ApplicationName}}. Klicka på knappen nedan för att slutföra inbjudansprocessen. Om du inte har begärt detta e-postmeddelande, ignorera det. + ButtonText: Acceptera inbjudan \ No newline at end of file diff --git a/internal/notification/static/i18n/zh.yaml b/internal/notification/static/i18n/zh.yaml index b0dd21cad3..4b83ebac79 100644 --- a/internal/notification/static/i18n/zh.yaml +++ b/internal/notification/static/i18n/zh.yaml @@ -59,3 +59,10 @@ PasswordChange: Greeting: 你好 {{.DisplayName}}, Text: 您的用户的密码已经改变,如果这个改变不是由您做的,请注意立即重新设置您的密码。 ButtonText: 登录 +InviteUser: + Title: '{{.ApplicationName}}邀请' + PreHeader: '{{.ApplicationName}}邀请' + Subject: '{{.ApplicationName}}邀请' + Greeting: 您好,{{.DisplayName}}, + Text: 您的用户已被邀请加入{{.ApplicationName}}。请点击下面的按钮完成邀请过程。如果您没有请求此邮件,请忽略它。 + ButtonText: 接受邀请 \ No newline at end of file diff --git a/internal/notification/types/invite_code.go b/internal/notification/types/invite_code.go new file mode 100644 index 0000000000..953124a553 --- /dev/null +++ b/internal/notification/types/invite_code.go @@ -0,0 +1,31 @@ +package types + +import ( + "context" + "strings" + + http_utils "github.com/zitadel/zitadel/internal/api/http" + "github.com/zitadel/zitadel/internal/api/ui/login" + "github.com/zitadel/zitadel/internal/domain" + "github.com/zitadel/zitadel/internal/query" +) + +func (notify Notify) SendInviteCode(ctx context.Context, user *query.NotifyUser, code, applicationName, urlTmpl, authRequestID string) error { + var url string + if applicationName == "" { + applicationName = "ZITADEL" + } + if urlTmpl == "" { + url = login.InviteUserLink(http_utils.DomainContext(ctx).Origin(), user.ID, user.PreferredLoginName, code, user.ResourceOwner, authRequestID) + } else { + var buf strings.Builder + if err := domain.RenderConfirmURLTemplate(&buf, urlTmpl, user.ID, code, user.ResourceOwner); err != nil { + return err + } + url = buf.String() + } + args := make(map[string]interface{}) + args["Code"] = code + args["ApplicationName"] = applicationName + return notify(url, args, domain.InviteUserMessageType, true) +} diff --git a/internal/query/message_text.go b/internal/query/message_text.go index dd2d9cd50b..cb524d289a 100644 --- a/internal/query/message_text.go +++ b/internal/query/message_text.go @@ -33,6 +33,7 @@ type MessageTexts struct { DomainClaimed MessageText PasswordlessRegistration MessageText PasswordChange MessageText + InviteUser MessageText } type MessageText struct { @@ -346,6 +347,8 @@ func (m *MessageTexts) GetMessageTextByType(msgType string) *MessageText { return &m.PasswordlessRegistration case domain.PasswordChangeMessageType: return &m.PasswordChange + case domain.InviteUserMessageType: + return &m.InviteUser } return nil } diff --git a/internal/query/projection/message_texts.go b/internal/query/projection/message_texts.go index a588e7e98a..8a001db905 100644 --- a/internal/query/projection/message_texts.go +++ b/internal/query/projection/message_texts.go @@ -272,7 +272,8 @@ func isMessageTemplate(template string) bool { template == domain.VerifyEmailOTPMessageType || template == domain.DomainClaimedMessageType || template == domain.PasswordlessRegistrationMessageType || - template == domain.PasswordChangeMessageType + template == domain.PasswordChangeMessageType || + template == domain.InviteUserMessageType } func isTitle(key string) bool { return key == domain.MessageTitle diff --git a/internal/repository/user/eventstore.go b/internal/repository/user/eventstore.go index e04754e54a..2b726d378a 100644 --- a/internal/repository/user/eventstore.go +++ b/internal/repository/user/eventstore.go @@ -137,4 +137,8 @@ func init() { eventstore.RegisterFilterEventMapper(AggregateType, MachineSecretCheckSucceededType, MachineSecretCheckSucceededEventMapper) eventstore.RegisterFilterEventMapper(AggregateType, MachineSecretCheckFailedType, MachineSecretCheckFailedEventMapper) eventstore.RegisterFilterEventMapper(AggregateType, MachineSecretHashUpdatedType, eventstore.GenericEventMapper[MachineSecretHashUpdatedEvent]) + eventstore.RegisterFilterEventMapper(AggregateType, HumanInviteCodeAddedType, eventstore.GenericEventMapper[HumanInviteCodeAddedEvent]) + eventstore.RegisterFilterEventMapper(AggregateType, HumanInviteCodeSentType, eventstore.GenericEventMapper[HumanInviteCodeSentEvent]) + eventstore.RegisterFilterEventMapper(AggregateType, HumanInviteCheckSucceededType, eventstore.GenericEventMapper[HumanInviteCheckSucceededEvent]) + eventstore.RegisterFilterEventMapper(AggregateType, HumanInviteCheckFailedType, eventstore.GenericEventMapper[HumanInviteCheckFailedEvent]) } diff --git a/internal/repository/user/human.go b/internal/repository/user/human.go index e9fd49a359..ae1e9672ef 100644 --- a/internal/repository/user/human.go +++ b/internal/repository/user/human.go @@ -21,6 +21,10 @@ const ( HumanInitialCodeSentType = humanEventPrefix + "initialization.code.sent" HumanInitializedCheckSucceededType = humanEventPrefix + "initialization.check.succeeded" HumanInitializedCheckFailedType = humanEventPrefix + "initialization.check.failed" + HumanInviteCodeAddedType = humanEventPrefix + "invite.code.added" + HumanInviteCodeSentType = humanEventPrefix + "invite.code.sent" + HumanInviteCheckSucceededType = humanEventPrefix + "invite.check.succeeded" + HumanInviteCheckFailedType = humanEventPrefix + "invite.check.failed" HumanSignedOutType = humanEventPrefix + "signed.out" ) @@ -379,6 +383,137 @@ func HumanInitializedCheckFailedEventMapper(event eventstore.Event) (eventstore. }, nil } +type HumanInviteCodeAddedEvent struct { + *eventstore.BaseEvent `json:"-"` + Code *crypto.CryptoValue `json:"code,omitempty"` + Expiry time.Duration `json:"expiry,omitempty"` + TriggeredAtOrigin string `json:"triggerOrigin,omitempty"` + URLTemplate string `json:"urlTemplate,omitempty"` + CodeReturned bool `json:"codeReturned,omitempty"` + ApplicationName string `json:"applicationName,omitempty"` + AuthRequestID string `json:"authRequestID,omitempty"` +} + +func (e *HumanInviteCodeAddedEvent) SetBaseEvent(b *eventstore.BaseEvent) { + e.BaseEvent = b +} + +func (e *HumanInviteCodeAddedEvent) Payload() interface{} { + return e +} + +func (e *HumanInviteCodeAddedEvent) UniqueConstraints() []*eventstore.UniqueConstraint { + return nil +} + +func (e *HumanInviteCodeAddedEvent) TriggerOrigin() string { + return e.TriggeredAtOrigin +} + +func NewHumanInviteCodeAddedEvent( + ctx context.Context, + aggregate *eventstore.Aggregate, + code *crypto.CryptoValue, + expiry time.Duration, + urlTemplate string, + codeReturned bool, + applicationName string, + authRequestID string, +) *HumanInviteCodeAddedEvent { + return &HumanInviteCodeAddedEvent{ + BaseEvent: eventstore.NewBaseEventForPush( + ctx, + aggregate, + HumanInviteCodeAddedType, + ), + Code: code, + Expiry: expiry, + TriggeredAtOrigin: http.DomainContext(ctx).Origin(), + URLTemplate: urlTemplate, + CodeReturned: codeReturned, + ApplicationName: applicationName, + AuthRequestID: authRequestID, + } +} + +type HumanInviteCodeSentEvent struct { + *eventstore.BaseEvent `json:"-"` +} + +func (e *HumanInviteCodeSentEvent) SetBaseEvent(b *eventstore.BaseEvent) { + e.BaseEvent = b +} + +func (e *HumanInviteCodeSentEvent) Payload() interface{} { + return nil +} + +func (e *HumanInviteCodeSentEvent) UniqueConstraints() []*eventstore.UniqueConstraint { + return nil +} + +func NewHumanInviteCodeSentEvent(ctx context.Context, aggregate *eventstore.Aggregate) *HumanInviteCodeSentEvent { + return &HumanInviteCodeSentEvent{ + BaseEvent: eventstore.NewBaseEventForPush( + ctx, + aggregate, + HumanInviteCodeSentType, + ), + } +} + +type HumanInviteCheckSucceededEvent struct { + *eventstore.BaseEvent `json:"-"` +} + +func (e *HumanInviteCheckSucceededEvent) SetBaseEvent(b *eventstore.BaseEvent) { + e.BaseEvent = b +} + +func (e *HumanInviteCheckSucceededEvent) Payload() interface{} { + return nil +} + +func (e *HumanInviteCheckSucceededEvent) UniqueConstraints() []*eventstore.UniqueConstraint { + return nil +} + +func NewHumanInviteCheckSucceededEvent(ctx context.Context, aggregate *eventstore.Aggregate) *HumanInviteCheckSucceededEvent { + return &HumanInviteCheckSucceededEvent{ + BaseEvent: eventstore.NewBaseEventForPush( + ctx, + aggregate, + HumanInviteCheckSucceededType, + ), + } +} + +type HumanInviteCheckFailedEvent struct { + *eventstore.BaseEvent `json:"-"` +} + +func (e *HumanInviteCheckFailedEvent) SetBaseEvent(b *eventstore.BaseEvent) { + e.BaseEvent = b +} + +func (e *HumanInviteCheckFailedEvent) Payload() interface{} { + return nil +} + +func (e *HumanInviteCheckFailedEvent) UniqueConstraints() []*eventstore.UniqueConstraint { + return nil +} + +func NewHumanInviteCheckFailedEvent(ctx context.Context, aggregate *eventstore.Aggregate) *HumanInviteCheckFailedEvent { + return &HumanInviteCheckFailedEvent{ + BaseEvent: eventstore.NewBaseEventForPush( + ctx, + aggregate, + HumanInviteCheckFailedType, + ), + } +} + type HumanSignedOutEvent struct { eventstore.BaseEvent `json:"-"` diff --git a/internal/static/i18n/bg.yaml b/internal/static/i18n/bg.yaml index f89afe8440..01582d0a15 100644 --- a/internal/static/i18n/bg.yaml +++ b/internal/static/i18n/bg.yaml @@ -704,6 +704,13 @@ EventTypes: check: succeeded: Проверката за инициализация е успешна failed: Проверката на инициализацията е неуспешна + invite: + code: + added: Генериран е код за покана + sent: Изпратен е код за покана + check: + succeeded: Проверката на поканата е успешна + failed: Проверката на поканата е неуспешна username: reserved: Потребителското име е запазено released: Потребителското име е освободено diff --git a/internal/static/i18n/cs.yaml b/internal/static/i18n/cs.yaml index 1d7afec913..effff55828 100644 --- a/internal/static/i18n/cs.yaml +++ b/internal/static/i18n/cs.yaml @@ -685,6 +685,13 @@ EventTypes: check: succeeded: Kontrola inicializace byla úspěšná failed: Kontrola inicializace selhala + invite: + code: + added: Vygenerován pozvánkový kód + sent: Pozvánkový kód byl odeslán + check: + succeeded: Kontrola pozvánky byla úspěšná + failed: Kontrola pozvánky selhala username: reserved: Uživatelské jméno rezervováno released: Uživatelské jméno uvolněno diff --git a/internal/static/i18n/de.yaml b/internal/static/i18n/de.yaml index 982693a490..21a863d18c 100644 --- a/internal/static/i18n/de.yaml +++ b/internal/static/i18n/de.yaml @@ -687,6 +687,13 @@ EventTypes: check: succeeded: Benutzerinitialisierung erfolgreich failed: Benutzerinitialisierung fehlgeschlagen + invite: + code: + added: Einladungscode generiert + sent: Einladungscode gesendet + check: + succeeded: Einladungsprüfung erfolgreich + failed: Einladungsprüfung fehlgeschlagen username: reserved: Benutzername reserviert released: Benutzername freigegeben diff --git a/internal/static/i18n/en.yaml b/internal/static/i18n/en.yaml index ea15bd8a2c..4f4e06fd7f 100644 --- a/internal/static/i18n/en.yaml +++ b/internal/static/i18n/en.yaml @@ -687,6 +687,13 @@ EventTypes: check: succeeded: Initialization check succeeded failed: Initialization check failed + invite: + code: + added: Invitation code generated + sent: Invitation code sent + check: + succeeded: Invitation check succeeded + failed: Invitation check failed username: reserved: Username reserved released: Username released diff --git a/internal/static/i18n/es.yaml b/internal/static/i18n/es.yaml index dca302f731..48f7f3d33e 100644 --- a/internal/static/i18n/es.yaml +++ b/internal/static/i18n/es.yaml @@ -687,6 +687,13 @@ EventTypes: check: succeeded: Comprobación exitosa de la inicialización failed: Fallo en la comprobación de la inicialización + invite: + code: + added: Código de invitación generado + sent: Código de invitación enviado + check: + succeeded: Comprobación de invitación correcta + failed: Comprobación de invitación fallida username: reserved: Nombre de usuario reservado released: Nombre de usuario liberado diff --git a/internal/static/i18n/fr.yaml b/internal/static/i18n/fr.yaml index 8d6e653e72..7f92dbea4a 100644 --- a/internal/static/i18n/fr.yaml +++ b/internal/static/i18n/fr.yaml @@ -685,6 +685,13 @@ EventTypes: check: succeeded: Vérification de l'initialisation réussie failed: La vérification de l'initialisation a échoué + invite: + code: + added: Code d'invitation généré + sent: Code d'invitation envoyé + check: + succeeded: Vérification de l'invitation réussie + failed: Vérification de l'invitation échouée username: reserved: Nom d'utilisateur réservé released: Nom d'utilisateur libéré diff --git a/internal/static/i18n/id.yaml b/internal/static/i18n/id.yaml index 652759e6b9..60e4c395ad 100644 --- a/internal/static/i18n/id.yaml +++ b/internal/static/i18n/id.yaml @@ -680,6 +680,13 @@ EventTypes: check: succeeded: Pemeriksaan inisialisasi berhasil failed: Pemeriksaan inisialisasi gagal + invite: + code: + added: Kode undangan dihasilkan + sent: Kode undangan dikirim + check: + succeeded: Pemeriksaan undangan berhasil + failed: Pemeriksaan undangan gagal username: reserved: Nama pengguna dicadangkan released: Nama pengguna dirilis diff --git a/internal/static/i18n/it.yaml b/internal/static/i18n/it.yaml index b6c33023c1..0d7d39d2db 100644 --- a/internal/static/i18n/it.yaml +++ b/internal/static/i18n/it.yaml @@ -686,6 +686,13 @@ EventTypes: check: succeeded: Controllo dell'inizializzazione riuscito failed: Controllo dell'inizializzazione fallito + invite: + code: + added: Codice invito generato + sent: Codice invito inviato + check: + succeeded: Controllo invito riuscito + failed: Controllo invito fallito username: reserved: Nome utente riservato released: Nome utente rilasciato diff --git a/internal/static/i18n/ja.yaml b/internal/static/i18n/ja.yaml index df23a56f5b..f340b490ba 100644 --- a/internal/static/i18n/ja.yaml +++ b/internal/static/i18n/ja.yaml @@ -676,6 +676,13 @@ EventTypes: check: succeeded: 初期化チェックの成功 failed: 初期化チェックの失敗 + invite: + code: + added: 招待コードが生成されました + sent: 招待コードが送信されました + check: + succeeded: 招待のチェックが成功しました + failed: 招待のチェックが失敗しました username: reserved: ユーザー名の予約 released: ユーザー名の解放 diff --git a/internal/static/i18n/mk.yaml b/internal/static/i18n/mk.yaml index cc27d3fb38..c23efe3152 100644 --- a/internal/static/i18n/mk.yaml +++ b/internal/static/i18n/mk.yaml @@ -686,6 +686,13 @@ EventTypes: check: succeeded: Проверката на иницијализацијата е успешна failed: Проверката на иницијализацијата е неуспешна + invite: + code: + added: Генериран е код за покана + sent: Изпратен е код за покана + check: + succeeded: Проверката на поканата е успешна + failed: Проверката на поканата е неуспешна username: reserved: Корисничкото име е резервирано released: Корисничкото име е ослободено diff --git a/internal/static/i18n/nl.yaml b/internal/static/i18n/nl.yaml index 6d8b8014f4..89477091d6 100644 --- a/internal/static/i18n/nl.yaml +++ b/internal/static/i18n/nl.yaml @@ -687,6 +687,13 @@ EventTypes: check: succeeded: Initialisatiecontrole geslaagd failed: Initialisatiecontrole mislukt + invite: + code: + added: Uitnodigingscode gegenereerd + sent: Uitnodigingscode verzonden + check: + succeeded: Uitnodigingscontrole geslaagd + failed: Uitnodigingscontrole mislukt username: reserved: Gebruikersnaam gereserveerd released: Gebruikersnaam vrijgegeven diff --git a/internal/static/i18n/pl.yaml b/internal/static/i18n/pl.yaml index 9508fb8019..ddd6df8be7 100644 --- a/internal/static/i18n/pl.yaml +++ b/internal/static/i18n/pl.yaml @@ -687,6 +687,13 @@ EventTypes: check: succeeded: Sprawdzenie inicjalizacji zakończone powodzeniem failed: Sprawdzenie inicjalizacji nie powiodło się + invite: + code: + added: Wygenerowano kod zaproszenia + sent: Kod zaproszenia został wysłany + check: + succeeded: Kontrola zaproszenia zakończona sukcesem + failed: Kontrola zaproszenia zakończona niepowodzeniem username: reserved: Zarezerwowano nazwę użytkownika released: Zwolniono nazwę użytkownika diff --git a/internal/static/i18n/pt.yaml b/internal/static/i18n/pt.yaml index aa1a809f5d..88e798ca0b 100644 --- a/internal/static/i18n/pt.yaml +++ b/internal/static/i18n/pt.yaml @@ -682,6 +682,13 @@ EventTypes: check: succeeded: Verificação de inicialização bem-sucedida failed: Verificação de inicialização falhou + invite: + code: + added: Código de convite gerado + sent: Código de convite enviado + check: + succeeded: Verificação do convite bem-sucedida + failed: Verificação do convite falhou username: reserved: Nome de usuário reservado released: Nome de usuário liberado diff --git a/internal/static/i18n/ru.yaml b/internal/static/i18n/ru.yaml index eb846a6877..e253b5a678 100644 --- a/internal/static/i18n/ru.yaml +++ b/internal/static/i18n/ru.yaml @@ -676,6 +676,13 @@ EventTypes: check: succeeded: Проверка инициализации прошла успешно failed: Проверка инициализации не удалась + invite: + code: + added: Сгенерирован код приглашения + sent: Код приглашения отправлен + check: + succeeded: Проверка приглашения успешно завершена + failed: Проверка приглашения завершилась неудачно username: reserved: Имя пользователя зарезервировано released: Имя пользователя опубликовано diff --git a/internal/static/i18n/sv.yaml b/internal/static/i18n/sv.yaml index da33e42269..91198335b8 100644 --- a/internal/static/i18n/sv.yaml +++ b/internal/static/i18n/sv.yaml @@ -687,6 +687,13 @@ EventTypes: check: succeeded: Initialiseringskontroll lyckades failed: Initialiseringskontroll misslyckades + invite: + code: + added: Inbjudningskod genererad + sent: Inbjudningskod skickad + check: + succeeded: Inbjudningskontroll lyckad + failed: Inbjudningskontroll misslyckad username: reserved: Användarnamn reserverat released: Användarnamn släppt diff --git a/internal/static/i18n/zh.yaml b/internal/static/i18n/zh.yaml index 774232ea9e..8e94bc2623 100644 --- a/internal/static/i18n/zh.yaml +++ b/internal/static/i18n/zh.yaml @@ -686,6 +686,13 @@ EventTypes: check: succeeded: 初始化检查成功 failed: 初始化检查失败 + invite: + code: + added: 生成了邀请码 + sent: 发送了邀请码 + check: + succeeded: 邀请检查成功 + failed: 邀请检查失败 username: reserved: 保留用户名 released: 用户名已发布 diff --git a/proto/zitadel/admin.proto b/proto/zitadel/admin.proto index 41de3f845c..c9b44a61ef 100644 --- a/proto/zitadel/admin.proto +++ b/proto/zitadel/admin.proto @@ -3588,6 +3588,71 @@ service AdminService { }; } + rpc GetDefaultInviteUserMessageText(GetDefaultInviteUserMessageTextRequest) returns (GetDefaultInviteUserMessageTextResponse) { + option (google.api.http) = { + get: "/text/default/message/invite_user/{language}"; + }; + + option (zitadel.v1.auth_option) = { + permission: "iam.policy.read"; + }; + + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + tags: "Message Texts"; + summary: "Get Default Invite User Message Text"; + description: "Get the default text of the invite user message/email that is stored as translation files in ZITADEL itself. The text will be sent to the users of all organizations, that do not have a custom text configured. The message is sent when an invite code email is requested." + }; + } + + rpc GetCustomInviteUserMessageText(GetCustomInviteUserMessageTextRequest) returns (GetCustomInviteUserMessageTextResponse) { + option (google.api.http) = { + get: "/text/message/invite_user/{language}"; + }; + + option (zitadel.v1.auth_option) = { + permission: "iam.policy.read"; + }; + + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + tags: "Message Texts"; + summary: "Get Custom Invite User Message Text"; + description: "Get the custom text of the invite user message/email that is overwritten on the instance as settings/database. The text will be sent to the users of all organizations, that do not have a custom text configured. The message is sent when an invite code email is requested." + }; + } + + rpc SetDefaultInviteUserMessageText(SetDefaultInviteUserMessageTextRequest) returns (SetDefaultInviteUserMessageTextResponse) { + option (google.api.http) = { + put: "/text/message/invite_user/{language}"; + body: "*"; + }; + + option (zitadel.v1.auth_option) = { + permission: "iam.policy.write"; + }; + + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + tags: "Message Texts"; + summary: "Set Default Invite User Message Text"; + description: "Set the custom text of the invite user message/email that is overwritten on the instance as settings/database. The text will be sent to the users of all organizations, that do not have a custom text configured. The message is sent when an invite code email is requested. The Following Variables can be used: {{.UserName}} {{.FirstName}} {{.LastName}} {{.NickName}} {{.DisplayName}} {{.LastEmail}} {{.VerifiedEmail}} {{.LastPhone}} {{.VerifiedPhone}} {{.PreferredLoginName}} {{.LoginNames}} {{.ChangeDate}} {{.CreationDate}} {{.ApplicationName}}" + }; + } + + rpc ResetCustomInviteUserMessageTextToDefault(ResetCustomInviteUserMessageTextToDefaultRequest) returns (ResetCustomInviteUserMessageTextToDefaultResponse) { + option (google.api.http) = { + delete: "/text/message/invite_user/{language}" + }; + + option (zitadel.v1.auth_option) = { + permission: "iam.policy.delete" + }; + + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + tags: "Message Texts"; + summary: "Reset Custom Invite User Message Text to Default"; + description: "Removes the custom text of the invite user message that is overwritten on the instance and triggers the text from the translation files stored in ZITADEL itself. The text will be sent to the users of all organizations, that do not have a custom text configured." + }; + } + rpc GetDefaultLoginTexts(GetDefaultLoginTextsRequest) returns (GetDefaultLoginTextsResponse) { option (google.api.http) = { get: "/text/default/login/{language}"; @@ -7789,6 +7854,89 @@ message ResetCustomPasswordChangeMessageTextToDefaultResponse { zitadel.v1.ObjectDetails details = 1; } +message GetDefaultInviteUserMessageTextRequest { + string language = 1 [(validate.rules).string = {min_len: 1, max_len: 200}]; +} + +message GetDefaultInviteUserMessageTextResponse { + zitadel.text.v1.MessageCustomText custom_text = 1; +} + +message GetCustomInviteUserMessageTextRequest { + string language = 1 [(validate.rules).string = {min_len: 1, max_len: 200}]; +} + +message GetCustomInviteUserMessageTextResponse { + zitadel.text.v1.MessageCustomText custom_text = 1; +} + +message SetDefaultInviteUserMessageTextRequest { + string language = 1 [ + (validate.rules).string = {min_len: 1, max_len: 200}, + (google.api.field_behavior) = REQUIRED, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"de\""; + min_length: 1; + max_length: 200; + } + ]; + string title = 2 [ + (validate.rules).string = {max_bytes: 2000}, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"Invitation to {{.ApplicationName}}\"" + max_length: 500; + } + ]; + string pre_header = 3 [ + (validate.rules).string = {max_bytes: 2000}, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"Invitation to {{.ApplicationName}}\"" + max_length: 500; + } + ]; + string subject = 4 [ + (validate.rules).string = {max_bytes: 2000}, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"Invitation to {{.ApplicationName}}\"" + max_length: 500; + } + ]; + string greeting = 5 [ + (validate.rules).string = {max_bytes: 4000}, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"Hello {{.DisplayName}},\"" + max_length: 1000; + } + ]; + string text = 6 [ + (validate.rules).string = {max_bytes: 40000}, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"Your user has been invited to {{.ApplicationName}}. Please click the button below to finish the invite process. If you didn't ask for this mail, please ignore it.\"" + max_length: 10000; + } + ]; + string button_text = 7 [ + (validate.rules).string = {max_bytes: 4000}, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"Accept invite\"" + max_length: 1000; + } + ]; + string footer_text = 8 [(validate.rules).string = {max_len: 8000}]; +} + +message SetDefaultInviteUserMessageTextResponse { + zitadel.v1.ObjectDetails details = 1; +} + +message ResetCustomInviteUserMessageTextToDefaultRequest { + string language = 1 [(validate.rules).string = {min_len: 1, max_len: 200}]; +} + +message ResetCustomInviteUserMessageTextToDefaultResponse { + zitadel.v1.ObjectDetails details = 1; +} + message GetDefaultPasswordlessRegistrationMessageTextRequest { string language = 1 [(validate.rules).string = {min_len: 1, max_len: 200}]; @@ -8239,6 +8387,7 @@ message DataOrg { repeated zitadel.management.v1.SetCustomVerifySMSOTPMessageTextRequest verify_sms_otp_messages = 37; repeated zitadel.management.v1.SetCustomVerifyEmailOTPMessageTextRequest verify_email_otp_messages = 38; + repeated zitadel.management.v1.SetCustomInviteUserMessageTextRequest invite_user_messages = 39; } message ImportDataResponse{ diff --git a/proto/zitadel/management.proto b/proto/zitadel/management.proto index 448e8b597d..0eeaa29b0b 100644 --- a/proto/zitadel/management.proto +++ b/proto/zitadel/management.proto @@ -6496,6 +6496,103 @@ service ManagementService { }; } + rpc GetCustomInviteUserMessageText(GetCustomInviteUserMessageTextRequest) returns (GetCustomInviteUserMessageTextResponse) { + option (google.api.http) = { + get: "/text/message/invite_user/{language}"; + }; + + option (zitadel.v1.auth_option) = { + permission: "policy.read"; + }; + + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + tags: "Message Texts"; + summary: "Get Custom Invite User Message Text"; + description: "Get the custom text of the password-changed message/email that is configured on the organization. The message is sent when an invite code email is requested." + parameters: { + headers: { + name: "x-zitadel-orgid"; + description: "The default is always the organization of the requesting user. If you like to get/set a result of another organization include the header. Make sure the user has permission to access the requested data."; + type: STRING, + required: false; + }; + }; + }; + } + + rpc GetDefaultInviteUserMessageText(GetDefaultInviteUserMessageTextRequest) returns (GetDefaultInviteUserMessageTextResponse) { + option (google.api.http) = { + get: "/text/default/message/invite_user/{language}"; + }; + + option (zitadel.v1.auth_option) = { + permission: "policy.read"; + }; + + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + tags: "Message Texts"; + summary: "Get Default Invite User Message Text"; + description: "Get the default text of the invite user message/email that is configured on the instance or as translation files in ZITADEL itself. The message is sent when an invite code email is requested." + parameters: { + headers: { + name: "x-zitadel-orgid"; + description: "The default is always the organization of the requesting user. If you like to get/set a result of another organization include the header. Make sure the user has permission to access the requested data."; + type: STRING, + required: false; + }; + }; + }; + } + + rpc SetCustomInviteUserMessageCustomText(SetCustomInviteUserMessageTextRequest) returns (SetCustomInviteUserMessageTextResponse) { + option (google.api.http) = { + put: "/text/message/invite_user/{language}"; + body: "*"; + }; + + option (zitadel.v1.auth_option) = { + permission: "policy.write"; + }; + + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + tags: "Message Texts"; + summary: "Set Custom Invite User Message Text"; + description: "Set the custom text of the invite user message/email for the organization. The message is sent when an invite code email is requested. The Following Variables can be used: {{.UserName}} {{.FirstName}} {{.LastName}} {{.NickName}} {{.DisplayName}} {{.LastEmail}} {{.VerifiedEmail}} {{.LastPhone}} {{.VerifiedPhone}} {{.PreferredLoginName}} {{.LoginNames}} {{.ChangeDate}} {{.CreationDate}} {{.ApplicationName}}" + parameters: { + headers: { + name: "x-zitadel-orgid"; + description: "The default is always the organization of the requesting user. If you like to get/set a result of another organization include the header. Make sure the user has permission to access the requested data."; + type: STRING, + required: false; + }; + }; + }; + } + + rpc ResetCustomInviteUserMessageTextToDefault(ResetCustomInviteUserMessageTextToDefaultRequest) returns (ResetCustomInviteUserMessageTextToDefaultResponse) { + option (google.api.http) = { + delete: "/text/message/invite_user/{language}" + }; + + option (zitadel.v1.auth_option) = { + permission: "policy.delete" + }; + + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + tags: "Message Texts"; + summary: "Reset Custom Invite User Message Text to Default"; + description: "Removes the custom text of the invite user message from the organization and therefore the default texts from the instance or translation files will be triggered for the users." + parameters: { + headers: { + name: "x-zitadel-orgid"; + description: "The default is always the organization of the requesting user. If you like to get/set a result of another organization include the header. Make sure the user has permission to access the requested data."; + type: STRING, + required: false; + }; + }; + }; + } + rpc GetCustomLoginTexts(GetCustomLoginTextsRequest) returns (GetCustomLoginTextsResponse) { option (google.api.http) = { get: "/text/login/{language}"; @@ -11919,6 +12016,86 @@ message ResetCustomPasswordChangeMessageTextToDefaultResponse { zitadel.v1.ObjectDetails details = 1; } +message GetCustomInviteUserMessageTextRequest { + string language = 1 [(validate.rules).string = {min_len: 1, max_len: 200}]; +} + +message GetCustomInviteUserMessageTextResponse { + zitadel.text.v1.MessageCustomText custom_text = 1; +} + +message GetDefaultInviteUserMessageTextRequest { + string language = 1 [(validate.rules).string = {min_len: 1, max_len: 200}]; +} + +message GetDefaultInviteUserMessageTextResponse { + zitadel.text.v1.MessageCustomText custom_text = 1; +} + +message SetCustomInviteUserMessageTextRequest { + string language = 1 [ + (validate.rules).string = {min_len: 1, max_len: 200}, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"de\"" + } + ]; + string title = 2 [ + (validate.rules).string = {max_bytes: 2000}, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"Invitation to {{.ApplicationName}}\"" + max_length: 500; + } + ]; + string pre_header = 3 [ + (validate.rules).string = {max_bytes: 2000}, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"Invitation to {{.ApplicationName}}\"" + max_length: 500; + } + ]; + string subject = 4 [ + (validate.rules).string = {max_bytes: 2000}, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"Invitation to {{.ApplicationName}}\"" + max_length: 500; + } + ]; + string greeting = 5 [ + (validate.rules).string = {max_bytes: 4000}, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"Hello {{.DisplayName}},\"" + max_length: 1000; + } + ]; + string text = 6 [ + (validate.rules).string = {max_bytes: 40000}, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"Your user has been invited to {{.ApplicationName}}. Please click the button below to finish the invite process. If you didn't ask for this mail, please ignore it.\"" + max_length: 10000; + } + ]; + string button_text = 7 [ + (validate.rules).string = {max_bytes: 4000}, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"Accept invite\"" + max_length: 500; + } + ]; + string footer_text = 8 [(validate.rules).string = {max_bytes: 8000}]; +} + +message SetCustomInviteUserMessageTextResponse { + zitadel.v1.ObjectDetails details = 1; +} + +message ResetCustomInviteUserMessageTextToDefaultRequest { + string language = 1 [(validate.rules).string = {min_len: 1, max_len: 200}]; +} + +message ResetCustomInviteUserMessageTextToDefaultResponse { + zitadel.v1.ObjectDetails details = 1; +} + message GetOrgIDPByIDRequest { string id = 1 [(validate.rules).string = {min_len: 1, max_len: 200}]; } diff --git a/proto/zitadel/user/v2/email.proto b/proto/zitadel/user/v2/email.proto index 2af5369fe6..cbc07b8762 100644 --- a/proto/zitadel/user/v2/email.proto +++ b/proto/zitadel/user/v2/email.proto @@ -23,7 +23,7 @@ message SetHumanEmail { oneof verification { SendEmailVerificationCode send_code = 2; ReturnEmailVerificationCode return_code = 3; - bool is_verified = 4 [(validate.rules).bool.const = true]; + bool is_verified = 4; } } diff --git a/proto/zitadel/user/v2/user.proto b/proto/zitadel/user/v2/user.proto index 9a0794197e..e1e98eb036 100644 --- a/proto/zitadel/user/v2/user.proto +++ b/proto/zitadel/user/v2/user.proto @@ -282,3 +282,29 @@ enum AuthFactorState { AUTH_FACTOR_STATE_READY = 2; AUTH_FACTOR_STATE_REMOVED = 3; } + +message SendInviteCode { + // Optionally set a url_template, which will be used in the invite mail sent by ZITADEL to guide the user to your invitation page. + // If no template is set, the default ZITADEL url will be used. + optional string url_template = 1 [ + (validate.rules).string = {min_len: 1, max_len: 200}, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + min_length: 1; + max_length: 200; + example: "\"https://example.com/user/invite?userID={{.UserID}}&code={{.Code}}&orgID={{.OrgID}}\""; + } + ]; + // Optionally set an application name, which will be used in the invite mail sent by ZITADEL. + // If no application name is set, ZITADEL will be used as default. + optional string application_name = 2 [ + (validate.rules).string = {min_len: 1, max_len: 200}, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + min_length: 1; + max_length: 200; + example: "\"CustomerPortal\""; + } + ]; +} + +message ReturnInviteCode {} + diff --git a/proto/zitadel/user/v2/user_service.proto b/proto/zitadel/user/v2/user_service.proto index 214c600bda..9b82bfe297 100644 --- a/proto/zitadel/user/v2/user_service.proto +++ b/proto/zitadel/user/v2/user_service.proto @@ -1083,6 +1083,79 @@ service UserService { }; }; } + + // Create an invite code for a user + // + // Create an invite code for a user to initialize their first authentication method (password, passkeys, IdP) depending on the organization's available methods. + rpc CreateInviteCode (CreateInviteCodeRequest) returns (CreateInviteCodeResponse) { + option (google.api.http) = { + post: "/v2/users/{user_id}/invite_code" + }; + + option (zitadel.protoc_gen_zitadel.v2.options) = { + auth_option: { + permission: "authenticated" + } + }; + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + responses: { + key: "200" + value: { + description: "OK"; + } + }; + }; + } + + // Resend an invite code for a user + // + // Resend an invite code for a user to initialize their first authentication method (password, passkeys, IdP) depending on the organization's available methods. + // A resend is only possible if a code has been created previously and sent to the user. If there is no code or it was directly returned, an error will be returned. + rpc ResendInviteCode (ResendInviteCodeRequest) returns (ResendInviteCodeResponse) { + option (google.api.http) = { + post: "/v2/users/{user_id}/invite_code/resend" + }; + + option (zitadel.protoc_gen_zitadel.v2.options) = { + auth_option: { + permission: "authenticated" + } + }; + + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + responses: { + key: "200" + value: { + description: "OK"; + } + }; + }; + } + + // Verify an invite code for a user + // + // Verify the invite code of a user previously issued. This will set their email to a verified state and + // allow the user to set up their first authentication method (password, passkeys, IdP) depending on the organization's available methods. + rpc VerifyInviteCode (VerifyInviteCodeRequest) returns (VerifyInviteCodeResponse) { + option (google.api.http) = { + post: "/v2/users/{user_id}/invite_code/verify" + }; + + option (zitadel.protoc_gen_zitadel.v2.options) = { + auth_option: { + permission: "authenticated" + } + }; + + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + responses: { + key: "200" + value: { + description: "OK"; + } + }; + }; + } } message AddHumanUserRequest{ @@ -2076,3 +2149,67 @@ enum AuthenticationMethodType { AUTHENTICATION_METHOD_TYPE_OTP_SMS = 6; AUTHENTICATION_METHOD_TYPE_OTP_EMAIL = 7; } + +message CreateInviteCodeRequest { + string user_id = 1 [ + (validate.rules).string = {min_len: 1, max_len: 200}, + (google.api.field_behavior) = REQUIRED, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + min_length: 1; + max_length: 200; + example: "\"69629026806489455\""; + } + ]; + // if no verification is specified, an email is sent with the default url and application name (ZITADEL) + oneof verification { + SendInviteCode send_code = 2; + ReturnInviteCode return_code = 3; + } +} + +message CreateInviteCodeResponse { + zitadel.object.v2.Details details = 1; + // The invite code is returned if the verification was set to return_code. + optional string invite_code = 2; +} +message ResendInviteCodeRequest { + string user_id = 1 [ + (validate.rules).string = {min_len: 1, max_len: 200}, + (google.api.field_behavior) = REQUIRED, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + min_length: 1; + max_length: 200; + example: "\"69629026806489455\""; + } + ]; +} + +message ResendInviteCodeResponse { + zitadel.object.v2.Details details = 1; +} + +message VerifyInviteCodeRequest { + string user_id = 1 [ + (validate.rules).string = {min_len: 1, max_len: 200}, + (google.api.field_behavior) = REQUIRED, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + min_length: 1; + max_length: 200; + example: "\"69629026806489455\""; + } + ]; + string verification_code = 2 [ + (validate.rules).string = {min_len: 1, max_len: 20}, + (google.api.field_behavior) = REQUIRED, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + min_length: 1; + max_length: 20; + example: "\"SKJd342k\""; + description: "\"the verification code generated during the invite code request\""; + } + ]; +} + +message VerifyInviteCodeResponse { + zitadel.object.v2.Details details = 1; +} \ No newline at end of file diff --git a/proto/zitadel/v1.proto b/proto/zitadel/v1.proto index 6b64f7db68..c186ea7d61 100644 --- a/proto/zitadel/v1.proto +++ b/proto/zitadel/v1.proto @@ -90,6 +90,8 @@ message DataOrg { repeated DataAppKey app_keys = 38; repeated DataMachineKey machine_keys = 39; + + repeated zitadel.management.v1.SetCustomInviteUserMessageTextRequest invite_user_messages = 40; } message DataOIDCIDP{ From 05af47b9abf5f20db322f4b98852331d9565e09f Mon Sep 17 00:00:00 2001 From: Stygmates Date: Wed, 11 Sep 2024 13:52:44 +0200 Subject: [PATCH 20/33] feat(console): Display seconds and milliseconds in the tooltip of the user audit log (#8495) # Which Problems Are Solved The exact timestamp including the seconds and milliseconds in the user audit log is not shown, either in the direct log or in the tooltip that appears when hovering over the item. ![353743484-e9f18fa8-4691-4e8f-a0c9-e223e2c48ffe](https://github.com/user-attachments/assets/aaeff804-62a6-435d-a15d-e0d05106e6b5) # How the Problems Are Solved The exact timestamp is added to the tooltip that appears when hovering over the item. ![image](https://github.com/user-attachments/assets/d1052d85-1558-45f4-9d7f-49340acc684e) # Additional Changes None # Additional Context - Closes #8371 --------- Co-authored-by: Max Peintner Co-authored-by: Fabi --- console/src/app/modules/changes/changes.component.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/console/src/app/modules/changes/changes.component.html b/console/src/app/modules/changes/changes.component.html index 1cf65b3db8..e78fb0fbb3 100644 --- a/console/src/app/modules/changes/changes.component.html +++ b/console/src/app/modules/changes/changes.component.html @@ -28,7 +28,7 @@ {{ action.localizedMessage }} {{ dayelement.dates[j] | timestampToDate | localizedDate: 'HH:mm' }} From f6da52829695f3377d05506cf5421c9255a1675f Mon Sep 17 00:00:00 2001 From: Max Peintner Date: Wed, 11 Sep 2024 14:55:19 +0200 Subject: [PATCH 21/33] fix(console): dynamic documentation button (#8596) This PR makes the documenation dynamic according to the external links --- console/src/app/modules/header/header.component.html | 8 ++++---- console/src/app/modules/header/header.component.ts | 1 - 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/console/src/app/modules/header/header.component.html b/console/src/app/modules/header/header.component.html index 2a167731ec..e4ef76dd2a 100644 --- a/console/src/app/modules/header/header.component.html +++ b/console/src/app/modules/header/header.component.html @@ -172,11 +172,11 @@ {{ pP.customLinkText }} - - - {{ 'MENU.DOCUMENTATION' | translate }} - + + {{ 'MENU.DOCUMENTATION' | translate }} + +
= new Subject(); public BreadcrumbType: any = BreadcrumbType; public ActionKeysType: any = ActionKeysType; - public docsLink = 'https://zitadel.com/docs'; public positions: ConnectedPosition[] = [ new ConnectionPositionPair({ originX: 'start', originY: 'bottom' }, { overlayX: 'start', overlayY: 'top' }, 0, 10), From 1a7541431536b329f83fbc885ca30d4d83c98749 Mon Sep 17 00:00:00 2001 From: Stefan Benz <46600784+stebenz@users.noreply.github.com> Date: Wed, 11 Sep 2024 16:26:28 +0200 Subject: [PATCH 22/33] fix: add apple as identity provider type to settings v2 api (#8472) # Which Problems Are Solved Apple is not availabe as IDP type in the settings v2 API. # How the Problems Are Solved Add Apple as type. # Additional Changes None # Additional Context None Co-authored-by: Max Peintner --- internal/api/grpc/settings/v2/settings_converter.go | 2 ++ internal/api/grpc/settings/v2/settings_converter_test.go | 4 ++++ proto/zitadel/settings/v2/login_settings.proto | 3 ++- 3 files changed, 8 insertions(+), 1 deletion(-) diff --git a/internal/api/grpc/settings/v2/settings_converter.go b/internal/api/grpc/settings/v2/settings_converter.go index 848ea3e14a..222e548d1b 100644 --- a/internal/api/grpc/settings/v2/settings_converter.go +++ b/internal/api/grpc/settings/v2/settings_converter.go @@ -216,6 +216,8 @@ func idpTypeToPb(idpType domain.IDPType) settings.IdentityProviderType { return settings.IdentityProviderType_IDENTITY_PROVIDER_TYPE_GITLAB_SELF_HOSTED case domain.IDPTypeGoogle: return settings.IdentityProviderType_IDENTITY_PROVIDER_TYPE_GOOGLE + case domain.IDPTypeApple: + return settings.IdentityProviderType_IDENTITY_PROVIDER_TYPE_APPLE case domain.IDPTypeSAML: return settings.IdentityProviderType_IDENTITY_PROVIDER_TYPE_SAML default: diff --git a/internal/api/grpc/settings/v2/settings_converter_test.go b/internal/api/grpc/settings/v2/settings_converter_test.go index 75785c47b8..40c381986a 100644 --- a/internal/api/grpc/settings/v2/settings_converter_test.go +++ b/internal/api/grpc/settings/v2/settings_converter_test.go @@ -466,6 +466,10 @@ func Test_idpTypeToPb(t *testing.T) { args: args{domain.IDPTypeGoogle}, want: settings.IdentityProviderType_IDENTITY_PROVIDER_TYPE_GOOGLE, }, + { + args: args{domain.IDPTypeApple}, + want: settings.IdentityProviderType_IDENTITY_PROVIDER_TYPE_APPLE, + }, { args: args{domain.IDPTypeSAML}, want: settings.IdentityProviderType_IDENTITY_PROVIDER_TYPE_SAML, diff --git a/proto/zitadel/settings/v2/login_settings.proto b/proto/zitadel/settings/v2/login_settings.proto index d7d41a8a90..ca004288fe 100644 --- a/proto/zitadel/settings/v2/login_settings.proto +++ b/proto/zitadel/settings/v2/login_settings.proto @@ -148,5 +148,6 @@ enum IdentityProviderType { IDENTITY_PROVIDER_TYPE_GITLAB = 8; IDENTITY_PROVIDER_TYPE_GITLAB_SELF_HOSTED = 9; IDENTITY_PROVIDER_TYPE_GOOGLE = 10; - IDENTITY_PROVIDER_TYPE_SAML=11; + IDENTITY_PROVIDER_TYPE_SAML = 11; + IDENTITY_PROVIDER_TYPE_APPLE = 12; } From d8a71d217c7d889f9eb0410ea5afed10108aa798 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20M=C3=B6hlmann?= Date: Wed, 11 Sep 2024 20:51:18 +0300 Subject: [PATCH 23/33] test: upload integration server logs as artifacts (#8600) # Which Problems Are Solved Upload the integration test server logs as artifacts, even if the tests fail. Before this change logs where printed through the Makefile. However if a test would fail, the logs wouldn't get printed. # How the Problems Are Solved - Add an extra build step that pushes `tmp/zitadel.log` and `tmp/race.log.$pid` to artificats storage. - Logs are no longer printed in the `core_integration_reports` Makefile recipe. # Additional Changes Do not remove coverage data when generating the coverage report in `core_integration_reports`. This is to prevent future "File not found" erros when running the command repeatedly. # Additional Context Reported as internal feedback --- .github/workflows/core-integration-test.yml | 9 +++++++++ Makefile | 2 -- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/.github/workflows/core-integration-test.yml b/.github/workflows/core-integration-test.yml index b790db01d7..2673d4addf 100644 --- a/.github/workflows/core-integration-test.yml +++ b/.github/workflows/core-integration-test.yml @@ -74,6 +74,15 @@ jobs: ZITADEL_MASTERKEY: MasterkeyNeedsToHave32Characters INTEGRATION_DB_FLAVOR: postgres run: make core_integration_test + - + name: upload server logs + if: always() + uses: actions/upload-artifact@v4 + with: + name: integration-test-server-logs + path: | + tmp/zitadel.log + tmp/race.log.* - name: publish coverage uses: codecov/codecov-action@v4.3.0 diff --git a/Makefile b/Makefile index 56788e9f34..17e1bbd9b7 100644 --- a/Makefile +++ b/Makefile @@ -150,8 +150,6 @@ core_integration_server_stop: .PHONY: core_integration_reports core_integration_reports: go tool covdata textfmt -i=tmp/coverage -pkg=github.com/zitadel/zitadel/internal/...,github.com/zitadel/zitadel/cmd/... -o profile.cov - $(RM) -r tmp/coverage - cat tmp/zitadel.log .PHONY: core_integration_test core_integration_test: core_integration_server_start core_integration_test_packages core_integration_server_stop core_integration_reports From 21c38b061d3ab6f6a2e855ec5f02b6bee9462a8f Mon Sep 17 00:00:00 2001 From: Stefan Benz <46600784+stebenz@users.noreply.github.com> Date: Thu, 12 Sep 2024 06:27:29 +0200 Subject: [PATCH 24/33] feat: add http as smtp provider (#8545) # Which Problems Are Solved Send Email messages as a HTTP call to a relay, for own logic on handling different Email providers # How the Problems Are Solved Create endpoints under Email provider to manage SMTP and HTTP in the notification handlers. # Additional Changes Clean up old logic in command and query side to handle the general Email providers with deactivate, activate and remove. # Additional Context Partially closes #8270 --------- Co-authored-by: Livio Spring --- internal/api/grpc/admin/email.go | 140 ++ internal/api/grpc/admin/email_converters.go | 145 ++ .../api/grpc/admin/iam_settings_converter.go | 57 +- internal/api/grpc/admin/smtp.go | 45 +- internal/api/grpc/admin/smtp_converters.go | 26 +- internal/command/instance.go | 13 +- .../command/instance_smtp_config_model.go | 160 ++- internal/command/smtp.go | 386 ++++-- internal/command/smtp_test.go | 1206 ++++++++++++----- internal/notification/channels.go | 10 +- .../notification/channels/email/config.go | 17 + internal/notification/channels/smtp/config.go | 5 +- .../notification/handlers/config_email.go | 56 + internal/notification/handlers/config_smtp.go | 33 - .../handlers/user_notifier_test.go | 28 +- internal/notification/senders/email.go | 55 +- internal/notification/types/notification.go | 7 +- internal/notification/types/user_email.go | 67 +- internal/query/projection/smtp.go | 402 ++++-- internal/query/projection/smtp_test.go | 370 ++++- internal/query/sms.go | 14 +- internal/query/smtp.go | 250 +++- internal/query/smtp_test.go | 200 ++- internal/repository/instance/eventstore.go | 14 +- internal/repository/instance/smtp_config.go | 242 ++-- pkg/grpc/settings/settings.go | 2 + proto/zitadel/admin.proto | 726 +++++++++- proto/zitadel/settings.proto | 51 + 28 files changed, 3575 insertions(+), 1152 deletions(-) create mode 100644 internal/api/grpc/admin/email.go create mode 100644 internal/api/grpc/admin/email_converters.go create mode 100644 internal/notification/channels/email/config.go create mode 100644 internal/notification/handlers/config_email.go delete mode 100644 internal/notification/handlers/config_smtp.go diff --git a/internal/api/grpc/admin/email.go b/internal/api/grpc/admin/email.go new file mode 100644 index 0000000000..c207970000 --- /dev/null +++ b/internal/api/grpc/admin/email.go @@ -0,0 +1,140 @@ +package admin + +import ( + "context" + + "github.com/zitadel/zitadel/internal/api/authz" + "github.com/zitadel/zitadel/internal/api/grpc/object" + admin_pb "github.com/zitadel/zitadel/pkg/grpc/admin" +) + +func (s *Server) GetEmailProvider(ctx context.Context, req *admin_pb.GetEmailProviderRequest) (*admin_pb.GetEmailProviderResponse, error) { + smtp, err := s.query.SMTPConfigActive(ctx, authz.GetInstance(ctx).InstanceID()) + if err != nil { + return nil, err + } + return &admin_pb.GetEmailProviderResponse{ + Config: emailProviderToProviderPb(smtp), + }, nil +} + +func (s *Server) GetEmailProviderById(ctx context.Context, req *admin_pb.GetEmailProviderByIdRequest) (*admin_pb.GetEmailProviderByIdResponse, error) { + smtp, err := s.query.SMTPConfigByID(ctx, authz.GetInstance(ctx).InstanceID(), req.Id) + if err != nil { + return nil, err + } + return &admin_pb.GetEmailProviderByIdResponse{ + Config: emailProviderToProviderPb(smtp), + }, nil +} + +func (s *Server) AddEmailProviderSMTP(ctx context.Context, req *admin_pb.AddEmailProviderSMTPRequest) (*admin_pb.AddEmailProviderSMTPResponse, error) { + config := addEmailProviderSMTPToConfig(ctx, req) + if err := s.command.AddSMTPConfig(ctx, config); err != nil { + return nil, err + } + return &admin_pb.AddEmailProviderSMTPResponse{ + Details: object.DomainToChangeDetailsPb(config.Details), + Id: config.ID, + }, nil +} + +func (s *Server) UpdateEmailProviderSMTP(ctx context.Context, req *admin_pb.UpdateEmailProviderSMTPRequest) (*admin_pb.UpdateEmailProviderSMTPResponse, error) { + config := updateEmailProviderSMTPToConfig(ctx, req) + if err := s.command.ChangeSMTPConfig(ctx, config); err != nil { + return nil, err + } + return &admin_pb.UpdateEmailProviderSMTPResponse{ + Details: object.DomainToChangeDetailsPb(config.Details), + }, nil +} + +func (s *Server) AddEmailProviderHTTP(ctx context.Context, req *admin_pb.AddEmailProviderHTTPRequest) (*admin_pb.AddEmailProviderHTTPResponse, error) { + config := addEmailProviderHTTPToConfig(ctx, req) + if err := s.command.AddSMTPConfigHTTP(ctx, config); err != nil { + return nil, err + } + return &admin_pb.AddEmailProviderHTTPResponse{ + Details: object.DomainToChangeDetailsPb(config.Details), + Id: config.ID, + }, nil +} + +func (s *Server) UpdateEmailProviderHTTP(ctx context.Context, req *admin_pb.UpdateEmailProviderHTTPRequest) (*admin_pb.UpdateEmailProviderHTTPResponse, error) { + config := updateEmailProviderHTTPToConfig(ctx, req) + if err := s.command.ChangeSMTPConfigHTTP(ctx, config); err != nil { + return nil, err + } + return &admin_pb.UpdateEmailProviderHTTPResponse{ + Details: object.DomainToChangeDetailsPb(config.Details), + }, nil +} + +func (s *Server) RemoveEmailProvider(ctx context.Context, req *admin_pb.RemoveEmailProviderRequest) (*admin_pb.RemoveEmailProviderResponse, error) { + details, err := s.command.RemoveSMTPConfig(ctx, authz.GetInstance(ctx).InstanceID(), req.Id) + if err != nil { + return nil, err + } + return &admin_pb.RemoveEmailProviderResponse{ + Details: object.DomainToChangeDetailsPb(details), + }, nil +} + +func (s *Server) UpdateEmailProviderSMTPPassword(ctx context.Context, req *admin_pb.UpdateEmailProviderSMTPPasswordRequest) (*admin_pb.UpdateEmailProviderSMTPPasswordResponse, error) { + details, err := s.command.ChangeSMTPConfigPassword(ctx, authz.GetInstance(ctx).InstanceID(), req.Id, req.Password) + if err != nil { + return nil, err + } + return &admin_pb.UpdateEmailProviderSMTPPasswordResponse{ + Details: object.DomainToChangeDetailsPb(details), + }, nil +} + +func (s *Server) ListEmailProviders(ctx context.Context, req *admin_pb.ListEmailProvidersRequest) (*admin_pb.ListEmailProvidersResponse, error) { + queries, err := listEmailProvidersToModel(req) + if err != nil { + return nil, err + } + result, err := s.query.SearchSMTPConfigs(ctx, queries) + if err != nil { + return nil, err + } + return &admin_pb.ListEmailProvidersResponse{ + Details: object.ToListDetails(result.Count, result.Sequence, result.LastRun), + Result: emailProvidersToPb(result.Configs), + }, nil +} + +func (s *Server) ActivateEmailProvider(ctx context.Context, req *admin_pb.ActivateEmailProviderRequest) (*admin_pb.ActivateEmailProviderResponse, error) { + result, err := s.command.ActivateSMTPConfig(ctx, authz.GetInstance(ctx).InstanceID(), req.Id) + if err != nil { + return nil, err + } + return &admin_pb.ActivateEmailProviderResponse{ + Details: object.DomainToAddDetailsPb(result), + }, nil +} + +func (s *Server) DeactivateEmailProvider(ctx context.Context, req *admin_pb.DeactivateEmailProviderRequest) (*admin_pb.DeactivateEmailProviderResponse, error) { + result, err := s.command.DeactivateSMTPConfig(ctx, authz.GetInstance(ctx).InstanceID(), req.Id) + if err != nil { + return nil, err + } + return &admin_pb.DeactivateEmailProviderResponse{ + Details: object.DomainToAddDetailsPb(result), + }, nil +} + +func (s *Server) TestEmailProviderById(ctx context.Context, req *admin_pb.TestEmailProviderSMTPByIdRequest) (*admin_pb.TestEmailProviderSMTPByIdResponse, error) { + if err := s.command.TestSMTPConfigById(ctx, authz.GetInstance(ctx).InstanceID(), req.Id, req.ReceiverAddress); err != nil { + return nil, err + } + return &admin_pb.TestEmailProviderSMTPByIdResponse{}, nil +} + +func (s *Server) TestEmailProviderSMTP(ctx context.Context, req *admin_pb.TestEmailProviderSMTPRequest) (*admin_pb.TestEmailProviderSMTPResponse, error) { + if err := s.command.TestSMTPConfig(ctx, authz.GetInstance(ctx).InstanceID(), req.Id, req.ReceiverAddress, testEmailProviderSMTPToConfig(req)); err != nil { + return nil, err + } + return &admin_pb.TestEmailProviderSMTPResponse{}, nil +} diff --git a/internal/api/grpc/admin/email_converters.go b/internal/api/grpc/admin/email_converters.go new file mode 100644 index 0000000000..d1f566ec70 --- /dev/null +++ b/internal/api/grpc/admin/email_converters.go @@ -0,0 +1,145 @@ +package admin + +import ( + "context" + + "github.com/zitadel/zitadel/internal/api/authz" + "github.com/zitadel/zitadel/internal/api/grpc/object" + "github.com/zitadel/zitadel/internal/command" + "github.com/zitadel/zitadel/internal/domain" + "github.com/zitadel/zitadel/internal/notification/channels/smtp" + "github.com/zitadel/zitadel/internal/query" + admin_pb "github.com/zitadel/zitadel/pkg/grpc/admin" + settings_pb "github.com/zitadel/zitadel/pkg/grpc/settings" +) + +func listEmailProvidersToModel(req *admin_pb.ListEmailProvidersRequest) (*query.SMTPConfigsSearchQueries, error) { + offset, limit, asc := object.ListQueryToModel(req.Query) + return &query.SMTPConfigsSearchQueries{ + SearchRequest: query.SearchRequest{ + Offset: offset, + Limit: limit, + Asc: asc, + }, + }, nil +} + +func emailProvidersToPb(configs []*query.SMTPConfig) []*settings_pb.EmailProvider { + c := make([]*settings_pb.EmailProvider, len(configs)) + for i, config := range configs { + c[i] = emailProviderToProviderPb(config) + } + return c +} + +func emailProviderToProviderPb(config *query.SMTPConfig) *settings_pb.EmailProvider { + return &settings_pb.EmailProvider{ + Details: object.ToViewDetailsPb(config.Sequence, config.CreationDate, config.ChangeDate, config.ResourceOwner), + Id: config.ID, + Description: config.Description, + State: emailProviderStateToPb(config.State), + Config: emailProviderToPb(config), + } +} + +func emailProviderStateToPb(state domain.SMTPConfigState) settings_pb.EmailProviderState { + switch state { + case domain.SMTPConfigStateUnspecified, domain.SMTPConfigStateRemoved: + return settings_pb.EmailProviderState_EMAIL_PROVIDER_STATE_UNSPECIFIED + case domain.SMTPConfigStateActive: + return settings_pb.EmailProviderState_EMAIL_PROVIDER_ACTIVE + case domain.SMTPConfigStateInactive: + return settings_pb.EmailProviderState_EMAIL_PROVIDER_INACTIVE + default: + return settings_pb.EmailProviderState_EMAIL_PROVIDER_STATE_UNSPECIFIED + } +} + +func emailProviderToPb(config *query.SMTPConfig) settings_pb.EmailConfig { + if config.SMTPConfig != nil { + return smtpToPb(config.SMTPConfig) + } + if config.HTTPConfig != nil { + return httpToPb(config.HTTPConfig) + } + return nil +} + +func httpToPb(http *query.HTTP) *settings_pb.EmailProvider_Http { + return &settings_pb.EmailProvider_Http{ + Http: &settings_pb.EmailProviderHTTP{ + Endpoint: http.Endpoint, + }, + } +} + +func smtpToPb(config *query.SMTP) *settings_pb.EmailProvider_Smtp { + return &settings_pb.EmailProvider_Smtp{ + Smtp: &settings_pb.EmailProviderSMTP{ + Tls: config.TLS, + Host: config.Host, + User: config.User, + SenderAddress: config.SenderAddress, + SenderName: config.SenderName, + }, + } +} + +func addEmailProviderSMTPToConfig(ctx context.Context, req *admin_pb.AddEmailProviderSMTPRequest) *command.AddSMTPConfig { + return &command.AddSMTPConfig{ + ResourceOwner: authz.GetInstance(ctx).InstanceID(), + Description: req.Description, + Tls: req.Tls, + From: req.SenderAddress, + FromName: req.SenderName, + ReplyToAddress: req.ReplyToAddress, + Host: req.Host, + User: req.User, + Password: req.Password, + } +} + +func updateEmailProviderSMTPToConfig(ctx context.Context, req *admin_pb.UpdateEmailProviderSMTPRequest) *command.ChangeSMTPConfig { + return &command.ChangeSMTPConfig{ + ResourceOwner: authz.GetInstance(ctx).InstanceID(), + ID: req.Id, + Description: req.Description, + Tls: req.Tls, + From: req.SenderAddress, + FromName: req.SenderName, + ReplyToAddress: req.ReplyToAddress, + Host: req.Host, + User: req.User, + Password: req.Password, + } +} + +func addEmailProviderHTTPToConfig(ctx context.Context, req *admin_pb.AddEmailProviderHTTPRequest) *command.AddSMTPConfigHTTP { + return &command.AddSMTPConfigHTTP{ + ResourceOwner: authz.GetInstance(ctx).InstanceID(), + Description: req.Description, + Endpoint: req.Endpoint, + } +} + +func updateEmailProviderHTTPToConfig(ctx context.Context, req *admin_pb.UpdateEmailProviderHTTPRequest) *command.ChangeSMTPConfigHTTP { + return &command.ChangeSMTPConfigHTTP{ + ResourceOwner: authz.GetInstance(ctx).InstanceID(), + ID: req.Id, + Description: req.Description, + Endpoint: req.Endpoint, + } +} + +func testEmailProviderSMTPToConfig(req *admin_pb.TestEmailProviderSMTPRequest) *smtp.Config { + return &smtp.Config{ + Tls: req.Tls, + From: req.SenderAddress, + FromName: req.SenderName, + SMTP: smtp.SMTP{ + Host: req.Host, + User: req.User, + Password: req.Password, + }, + } +} diff --git a/internal/api/grpc/admin/iam_settings_converter.go b/internal/api/grpc/admin/iam_settings_converter.go index b967faf4c5..bfdebe35f0 100644 --- a/internal/api/grpc/admin/iam_settings_converter.go +++ b/internal/api/grpc/admin/iam_settings_converter.go @@ -1,14 +1,16 @@ package admin import ( + "context" + "google.golang.org/protobuf/types/known/durationpb" + "github.com/zitadel/zitadel/internal/api/authz" "github.com/zitadel/zitadel/internal/api/grpc/object" obj_grpc "github.com/zitadel/zitadel/internal/api/grpc/object" "github.com/zitadel/zitadel/internal/command" "github.com/zitadel/zitadel/internal/crypto" "github.com/zitadel/zitadel/internal/domain" - "github.com/zitadel/zitadel/internal/notification/channels/smtp" "github.com/zitadel/zitadel/internal/query" "github.com/zitadel/zitadel/internal/zerrors" admin_pb "github.com/zitadel/zitadel/pkg/grpc/admin" @@ -131,50 +133,51 @@ func SecretGeneratorTypeToDomain(generatorType settings_pb.SecretGeneratorType) } } -func AddSMTPToConfig(req *admin_pb.AddSMTPConfigRequest) *smtp.Config { - return &smtp.Config{ +func addSMTPToConfig(ctx context.Context, req *admin_pb.AddSMTPConfigRequest) *command.AddSMTPConfig { + return &command.AddSMTPConfig{ + ResourceOwner: authz.GetInstance(ctx).InstanceID(), Description: req.Description, Tls: req.Tls, From: req.SenderAddress, FromName: req.SenderName, ReplyToAddress: req.ReplyToAddress, - SMTP: smtp.SMTP{ - Host: req.Host, - User: req.User, - Password: req.Password, - }, + Host: req.Host, + User: req.User, + Password: req.Password, } } -func UpdateSMTPToConfig(req *admin_pb.UpdateSMTPConfigRequest) *smtp.Config { - return &smtp.Config{ +func updateSMTPToConfig(ctx context.Context, req *admin_pb.UpdateSMTPConfigRequest) *command.ChangeSMTPConfig { + return &command.ChangeSMTPConfig{ + ResourceOwner: authz.GetInstance(ctx).InstanceID(), + ID: req.Id, Description: req.Description, Tls: req.Tls, From: req.SenderAddress, FromName: req.SenderName, ReplyToAddress: req.ReplyToAddress, - SMTP: smtp.SMTP{ - Host: req.Host, - User: req.User, - Password: req.Password, - }, + Host: req.Host, + User: req.User, + Password: req.Password, } } func SMTPConfigToPb(smtp *query.SMTPConfig) *settings_pb.SMTPConfig { - mapped := &settings_pb.SMTPConfig{ - Description: smtp.Description, - Tls: smtp.TLS, - SenderAddress: smtp.SenderAddress, - SenderName: smtp.SenderName, - ReplyToAddress: smtp.ReplyToAddress, - Host: smtp.Host, - User: smtp.User, - Details: obj_grpc.ToViewDetailsPb(smtp.Sequence, smtp.CreationDate, smtp.ChangeDate, smtp.ResourceOwner), - Id: smtp.ID, - State: settings_pb.SMTPConfigState(smtp.State), + if smtp.SMTPConfig != nil { + return &settings_pb.SMTPConfig{ + Description: smtp.Description, + Tls: smtp.SMTPConfig.TLS, + SenderAddress: smtp.SMTPConfig.SenderAddress, + SenderName: smtp.SMTPConfig.SenderName, + ReplyToAddress: smtp.SMTPConfig.ReplyToAddress, + Host: smtp.SMTPConfig.Host, + User: smtp.SMTPConfig.User, + Details: obj_grpc.ToViewDetailsPb(smtp.Sequence, smtp.CreationDate, smtp.ChangeDate, smtp.ResourceOwner), + Id: smtp.ID, + State: settings_pb.SMTPConfigState(smtp.State), + } } - return mapped + return nil } func SecurityPolicyToPb(policy *query.SecurityPolicy) *settings_pb.SecurityPolicy { diff --git a/internal/api/grpc/admin/smtp.go b/internal/api/grpc/admin/smtp.go index f5abaff5bd..b695e7022e 100644 --- a/internal/api/grpc/admin/smtp.go +++ b/internal/api/grpc/admin/smtp.go @@ -20,10 +20,7 @@ func (s *Server) GetSMTPConfig(ctx context.Context, req *admin_pb.GetSMTPConfigR } func (s *Server) GetSMTPConfigById(ctx context.Context, req *admin_pb.GetSMTPConfigByIdRequest) (*admin_pb.GetSMTPConfigByIdResponse, error) { - instanceID := authz.GetInstance(ctx).InstanceID() - resourceOwner := instanceID // Will be replaced when orgs have smtp configs - - smtp, err := s.query.SMTPConfigByID(ctx, instanceID, resourceOwner, req.Id) + smtp, err := s.query.SMTPConfigByID(ctx, authz.GetInstance(ctx).InstanceID(), req.Id) if err != nil { return nil, err } @@ -33,29 +30,23 @@ func (s *Server) GetSMTPConfigById(ctx context.Context, req *admin_pb.GetSMTPCon } func (s *Server) AddSMTPConfig(ctx context.Context, req *admin_pb.AddSMTPConfigRequest) (*admin_pb.AddSMTPConfigResponse, error) { - id, details, err := s.command.AddSMTPConfig(ctx, authz.GetInstance(ctx).InstanceID(), AddSMTPToConfig(req)) - if err != nil { + config := addSMTPToConfig(ctx, req) + if err := s.command.AddSMTPConfig(ctx, config); err != nil { return nil, err } return &admin_pb.AddSMTPConfigResponse{ - Details: object.ChangeToDetailsPb( - details.Sequence, - details.EventDate, - details.ResourceOwner), - Id: id, + Details: object.DomainToChangeDetailsPb(config.Details), + Id: config.ID, }, nil } func (s *Server) UpdateSMTPConfig(ctx context.Context, req *admin_pb.UpdateSMTPConfigRequest) (*admin_pb.UpdateSMTPConfigResponse, error) { - details, err := s.command.ChangeSMTPConfig(ctx, authz.GetInstance(ctx).InstanceID(), req.Id, UpdateSMTPToConfig(req)) - if err != nil { + config := updateSMTPToConfig(ctx, req) + if err := s.command.ChangeSMTPConfig(ctx, config); err != nil { return nil, err } return &admin_pb.UpdateSMTPConfigResponse{ - Details: object.ChangeToDetailsPb( - details.Sequence, - details.EventDate, - details.ResourceOwner), + Details: object.DomainToChangeDetailsPb(config.Details), }, nil } @@ -65,10 +56,7 @@ func (s *Server) RemoveSMTPConfig(ctx context.Context, req *admin_pb.RemoveSMTPC return nil, err } return &admin_pb.RemoveSMTPConfigResponse{ - Details: object.ChangeToDetailsPb( - details.Sequence, - details.EventDate, - details.ResourceOwner), + Details: object.DomainToChangeDetailsPb(details), }, nil } @@ -78,10 +66,7 @@ func (s *Server) UpdateSMTPConfigPassword(ctx context.Context, req *admin_pb.Upd return nil, err } return &admin_pb.UpdateSMTPConfigPasswordResponse{ - Details: object.ChangeToDetailsPb( - details.Sequence, - details.EventDate, - details.ResourceOwner), + Details: object.DomainToChangeDetailsPb(details), }, nil } @@ -101,19 +86,11 @@ func (s *Server) ListSMTPConfigs(ctx context.Context, req *admin_pb.ListSMTPConf } func (s *Server) ActivateSMTPConfig(ctx context.Context, req *admin_pb.ActivateSMTPConfigRequest) (*admin_pb.ActivateSMTPConfigResponse, error) { - // Get the ID of current SMTP active provider if any - currentActiveProviderID := "" - smtp, err := s.query.SMTPConfigActive(ctx, authz.GetInstance(ctx).InstanceID()) - if err == nil { - currentActiveProviderID = smtp.ID - } - - result, err := s.command.ActivateSMTPConfig(ctx, authz.GetInstance(ctx).InstanceID(), req.Id, currentActiveProviderID) + result, err := s.command.ActivateSMTPConfig(ctx, authz.GetInstance(ctx).InstanceID(), req.Id) if err != nil { return nil, err } - return &admin_pb.ActivateSMTPConfigResponse{ Details: object.DomainToAddDetailsPb(result), }, nil diff --git a/internal/api/grpc/admin/smtp_converters.go b/internal/api/grpc/admin/smtp_converters.go index 2ebeed58ef..961000c33f 100644 --- a/internal/api/grpc/admin/smtp_converters.go +++ b/internal/api/grpc/admin/smtp_converters.go @@ -2,6 +2,7 @@ package admin import ( "github.com/zitadel/zitadel/internal/api/grpc/object" + "github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/query" admin_pb "github.com/zitadel/zitadel/pkg/grpc/admin" settings_pb "github.com/zitadel/zitadel/pkg/grpc/settings" @@ -23,12 +24,12 @@ func SMTPConfigToProviderPb(config *query.SMTPConfig) *settings_pb.SMTPConfig { Details: object.ToViewDetailsPb(config.Sequence, config.CreationDate, config.ChangeDate, config.ResourceOwner), Id: config.ID, Description: config.Description, - Tls: config.TLS, - Host: config.Host, - User: config.User, - State: settings_pb.SMTPConfigState(config.State), - SenderAddress: config.SenderAddress, - SenderName: config.SenderName, + Tls: config.SMTPConfig.TLS, + Host: config.SMTPConfig.Host, + User: config.SMTPConfig.User, + State: SMTPConfigStateToPb(config.State), + SenderAddress: config.SMTPConfig.SenderAddress, + SenderName: config.SMTPConfig.SenderName, } } @@ -39,3 +40,16 @@ func SMTPConfigsToPb(configs []*query.SMTPConfig) []*settings_pb.SMTPConfig { } return c } + +func SMTPConfigStateToPb(state domain.SMTPConfigState) settings_pb.SMTPConfigState { + switch state { + case domain.SMTPConfigStateUnspecified, domain.SMTPConfigStateRemoved: + return settings_pb.SMTPConfigState_SMTP_CONFIG_STATE_UNSPECIFIED + case domain.SMTPConfigStateActive: + return settings_pb.SMTPConfigState_SMTP_CONFIG_ACTIVE + case domain.SMTPConfigStateInactive: + return settings_pb.SMTPConfigState_SMTP_CONFIG_INACTIVE + default: + return settings_pb.SMTPConfigState_SMTP_CONFIG_STATE_UNSPECIFIED + } +} diff --git a/internal/command/instance.go b/internal/command/instance.go index c70b7541f5..f220c0c961 100644 --- a/internal/command/instance.go +++ b/internal/command/instance.go @@ -116,7 +116,7 @@ type InstanceSetup struct { } EmailTemplate []byte MessageTexts []*domain.CustomMessageText - SMTPConfiguration *smtp.Config + SMTPConfiguration *SMTPConfiguration OIDCSettings *OIDCSettings Quotas *SetQuotas Features *InstanceFeatures @@ -124,6 +124,15 @@ type InstanceSetup struct { Restrictions *SetRestrictions } +type SMTPConfiguration struct { + Description string + SMTP smtp.SMTP + Tls bool + From string + FromName string + ReplyToAddress string +} + type OIDCSettings struct { AccessTokenLifetime time.Duration IdTokenLifetime time.Duration @@ -440,7 +449,7 @@ func setupOIDCSettings(commands *Commands, validations *[]preparation.Validation ) } -func setupSMTPSettings(commands *Commands, validations *[]preparation.Validation, smtpConfig *smtp.Config, instanceAgg *instance.Aggregate) { +func setupSMTPSettings(commands *Commands, validations *[]preparation.Validation, smtpConfig *SMTPConfiguration, instanceAgg *instance.Aggregate) { if smtpConfig == nil { return } diff --git a/internal/command/instance_smtp_config_model.go b/internal/command/instance_smtp_config_model.go index e165419093..8af867f9f0 100644 --- a/internal/command/instance_smtp_config_model.go +++ b/internal/command/instance_smtp_config_model.go @@ -12,8 +12,20 @@ import ( type IAMSMTPConfigWriteModel struct { eventstore.WriteModel - ID string - Description string + ID string + Description string + + SMTPConfig *SMTPConfig + HTTPConfig *HTTPConfig + + State domain.SMTPConfigState + + domain string + domainState domain.InstanceDomainState + smtpSenderAddressMatchesInstanceDomain bool +} + +type SMTPConfig struct { TLS bool Host string User string @@ -21,11 +33,6 @@ type IAMSMTPConfigWriteModel struct { SenderAddress string SenderName string ReplyToAddress string - State domain.SMTPConfigState - - domain string - domainState domain.InstanceDomainState - smtpSenderAddressMatchesInstanceDomain bool } func NewIAMSMTPConfigWriteModel(instanceID, id, domain string) *IAMSMTPConfigWriteModel { @@ -73,6 +80,23 @@ func (wm *IAMSMTPConfigWriteModel) Reduce() error { continue } wm.reduceSMTPConfigChangedEvent(e) + case *instance.SMTPConfigPasswordChangedEvent: + if wm.ID != e.ID { + continue + } + if e.Password != nil { + wm.SMTPConfig.Password = e.Password + } + case *instance.SMTPConfigHTTPAddedEvent: + if wm.ID != e.ID { + continue + } + wm.reduceSMTPConfigHTTPAddedEvent(e) + case *instance.SMTPConfigHTTPChangedEvent: + if wm.ID != e.ID { + continue + } + wm.reduceSMTPConfigHTTPChangedEvent(e) case *instance.SMTPConfigRemovedEvent: if wm.ID != e.ID { continue @@ -120,6 +144,8 @@ func (wm *IAMSMTPConfigWriteModel) Query() *eventstore.SearchQueryBuilder { instance.SMTPConfigRemovedEventType, instance.SMTPConfigChangedEventType, instance.SMTPConfigPasswordChangedEventType, + instance.SMTPConfigHTTPAddedEventType, + instance.SMTPConfigHTTPChangedEventType, instance.SMTPConfigActivatedEventType, instance.SMTPConfigDeactivatedEventType, instance.SMTPConfigRemovedEventType, @@ -133,6 +159,9 @@ func (wm *IAMSMTPConfigWriteModel) Query() *eventstore.SearchQueryBuilder { func (wm *IAMSMTPConfigWriteModel) NewChangedEvent(ctx context.Context, aggregate *eventstore.Aggregate, id, description string, tls bool, fromAddress, fromName, replyToAddress, smtpHost, smtpUser string, smtpPassword *crypto.CryptoValue) (*instance.SMTPConfigChangedEvent, bool, error) { changes := make([]instance.SMTPConfigChanges, 0) var err error + if wm.SMTPConfig == nil { + return nil, false, nil + } if wm.ID != id { changes = append(changes, instance.ChangeSMTPConfigID(id)) @@ -140,22 +169,22 @@ func (wm *IAMSMTPConfigWriteModel) NewChangedEvent(ctx context.Context, aggregat if wm.Description != description { changes = append(changes, instance.ChangeSMTPConfigDescription(description)) } - if wm.TLS != tls { + if wm.SMTPConfig.TLS != tls { changes = append(changes, instance.ChangeSMTPConfigTLS(tls)) } - if wm.SenderAddress != fromAddress { + if wm.SMTPConfig.SenderAddress != fromAddress { changes = append(changes, instance.ChangeSMTPConfigFromAddress(fromAddress)) } - if wm.SenderName != fromName { + if wm.SMTPConfig.SenderName != fromName { changes = append(changes, instance.ChangeSMTPConfigFromName(fromName)) } - if wm.ReplyToAddress != replyToAddress { + if wm.SMTPConfig.ReplyToAddress != replyToAddress { changes = append(changes, instance.ChangeSMTPConfigReplyToAddress(replyToAddress)) } - if wm.Host != smtpHost { + if wm.SMTPConfig.Host != smtpHost { changes = append(changes, instance.ChangeSMTPConfigSMTPHost(smtpHost)) } - if wm.User != smtpUser { + if wm.SMTPConfig.User != smtpUser { changes = append(changes, instance.ChangeSMTPConfigSMTPUser(smtpUser)) } if smtpPassword != nil { @@ -171,15 +200,58 @@ func (wm *IAMSMTPConfigWriteModel) NewChangedEvent(ctx context.Context, aggregat return changeEvent, true, nil } +func (wm *IAMSMTPConfigWriteModel) NewHTTPChangedEvent(ctx context.Context, aggregate *eventstore.Aggregate, id, description, endpoint string) (*instance.SMTPConfigHTTPChangedEvent, bool, error) { + changes := make([]instance.SMTPConfigHTTPChanges, 0) + var err error + if wm.HTTPConfig == nil { + return nil, false, nil + } + + if wm.ID != id { + changes = append(changes, instance.ChangeSMTPConfigHTTPID(id)) + } + if wm.Description != description { + changes = append(changes, instance.ChangeSMTPConfigHTTPDescription(description)) + } + if wm.HTTPConfig.Endpoint != endpoint { + changes = append(changes, instance.ChangeSMTPConfigHTTPEndpoint(endpoint)) + } + if len(changes) == 0 { + return nil, false, nil + } + changeEvent, err := instance.NewSMTPConfigHTTPChangeEvent(ctx, aggregate, id, changes) + if err != nil { + return nil, false, err + } + return changeEvent, true, nil +} + func (wm *IAMSMTPConfigWriteModel) reduceSMTPConfigAddedEvent(e *instance.SMTPConfigAddedEvent) { wm.Description = e.Description - wm.TLS = e.TLS - wm.Host = e.Host - wm.User = e.User - wm.Password = e.Password - wm.SenderAddress = e.SenderAddress - wm.SenderName = e.SenderName - wm.ReplyToAddress = e.ReplyToAddress + wm.SMTPConfig = &SMTPConfig{ + TLS: e.TLS, + Host: e.Host, + User: e.User, + Password: e.Password, + SenderName: e.SenderName, + SenderAddress: e.SenderAddress, + ReplyToAddress: e.ReplyToAddress, + } + wm.State = domain.SMTPConfigStateInactive + // If ID has empty value we're dealing with the old and unique smtp settings + // These would be the default values for ID and State + if e.ID == "" { + wm.Description = "generic" + wm.ID = e.Aggregate().ResourceOwner + wm.State = domain.SMTPConfigStateActive + } +} + +func (wm *IAMSMTPConfigWriteModel) reduceSMTPConfigHTTPAddedEvent(e *instance.SMTPConfigHTTPAddedEvent) { + wm.Description = e.Description + wm.HTTPConfig = &HTTPConfig{ + Endpoint: e.Endpoint, + } wm.State = domain.SMTPConfigStateInactive // If ID has empty value we're dealing with the old and unique smtp settings // These would be the default values for ID and State @@ -191,29 +263,54 @@ func (wm *IAMSMTPConfigWriteModel) reduceSMTPConfigAddedEvent(e *instance.SMTPCo } func (wm *IAMSMTPConfigWriteModel) reduceSMTPConfigChangedEvent(e *instance.SMTPConfigChangedEvent) { + if wm.SMTPConfig == nil { + return + } + if e.Description != nil { wm.Description = *e.Description } if e.TLS != nil { - wm.TLS = *e.TLS + wm.SMTPConfig.TLS = *e.TLS } if e.Host != nil { - wm.Host = *e.Host + wm.SMTPConfig.Host = *e.Host } if e.User != nil { - wm.User = *e.User + wm.SMTPConfig.User = *e.User } if e.Password != nil { - wm.Password = e.Password + wm.SMTPConfig.Password = e.Password } if e.FromAddress != nil { - wm.SenderAddress = *e.FromAddress + wm.SMTPConfig.SenderAddress = *e.FromAddress } if e.FromName != nil { - wm.SenderName = *e.FromName + wm.SMTPConfig.SenderName = *e.FromName } if e.ReplyToAddress != nil { - wm.ReplyToAddress = *e.ReplyToAddress + wm.SMTPConfig.ReplyToAddress = *e.ReplyToAddress + } + + // If ID has empty value we're dealing with the old and unique smtp settings + // These would be the default values for ID and State + if e.ID == "" { + wm.Description = "generic" + wm.ID = e.Aggregate().ResourceOwner + wm.State = domain.SMTPConfigStateActive + } +} + +func (wm *IAMSMTPConfigWriteModel) reduceSMTPConfigHTTPChangedEvent(e *instance.SMTPConfigHTTPChangedEvent) { + if wm.HTTPConfig == nil { + return + } + + if e.Description != nil { + wm.Description = *e.Description + } + if e.Endpoint != nil { + wm.HTTPConfig.Endpoint = *e.Endpoint } // If ID has empty value we're dealing with the old and unique smtp settings @@ -227,13 +324,8 @@ func (wm *IAMSMTPConfigWriteModel) reduceSMTPConfigChangedEvent(e *instance.SMTP func (wm *IAMSMTPConfigWriteModel) reduceSMTPConfigRemovedEvent(e *instance.SMTPConfigRemovedEvent) { wm.Description = "" - wm.TLS = false - wm.SenderName = "" - wm.SenderAddress = "" - wm.ReplyToAddress = "" - wm.Host = "" - wm.User = "" - wm.Password = nil + wm.HTTPConfig = nil + wm.SMTPConfig = nil wm.State = domain.SMTPConfigStateRemoved // If ID has empty value we're dealing with the old and unique smtp settings diff --git a/internal/command/smtp.go b/internal/command/smtp.go index f51bef9b53..d856b9a3da 100644 --- a/internal/command/smtp.go +++ b/internal/command/smtp.go @@ -15,151 +15,189 @@ import ( "github.com/zitadel/zitadel/internal/zerrors" ) -func (c *Commands) AddSMTPConfig(ctx context.Context, instanceID string, config *smtp.Config) (string, *domain.ObjectDetails, error) { - id, err := c.idGenerator.Next() - if err != nil { - return "", nil, err +type AddSMTPConfig struct { + Details *domain.ObjectDetails + ResourceOwner string + ID string + + Description string + Host string + User string + Password string + Tls bool + From string + FromName string + ReplyToAddress string +} + +func (c *Commands) AddSMTPConfig(ctx context.Context, config *AddSMTPConfig) (err error) { + if config.ResourceOwner == "" { + return zerrors.ThrowInvalidArgument(nil, "COMMAND-PQN0wsqSyi", "Errors.ResourceOwnerMissing") + } + if config.ID == "" { + config.ID, err = c.idGenerator.Next() + if err != nil { + return err + } } from := strings.TrimSpace(config.From) if from == "" { - return "", nil, zerrors.ThrowInvalidArgument(nil, "INST-ASv2d", "Errors.Invalid.Argument") + return zerrors.ThrowInvalidArgument(nil, "COMMAND-SAAFpV8VKV", "Errors.Invalid.Argument") } fromSplitted := strings.Split(from, "@") senderDomain := fromSplitted[len(fromSplitted)-1] description := strings.TrimSpace(config.Description) replyTo := strings.TrimSpace(config.ReplyToAddress) - hostAndPort := strings.TrimSpace(config.SMTP.Host) + hostAndPort := strings.TrimSpace(config.Host) if _, _, err := net.SplitHostPort(hostAndPort); err != nil { - return "", nil, zerrors.ThrowInvalidArgument(nil, "INST-9JdRe", "Errors.Invalid.Argument") + return zerrors.ThrowInvalidArgument(nil, "COMMAND-EvAtufIinh", "Errors.Invalid.Argument") } var smtpPassword *crypto.CryptoValue - if config.SMTP.Password != "" { - smtpPassword, err = crypto.Encrypt([]byte(config.SMTP.Password), c.smtpEncryption) + if config.Password != "" { + smtpPassword, err = crypto.Encrypt([]byte(config.Password), c.smtpEncryption) if err != nil { - return "", nil, err + return err } } - smtpConfigWriteModel, err := c.getSMTPConfig(ctx, instanceID, id, senderDomain) + smtpConfigWriteModel, err := c.getSMTPConfig(ctx, config.ResourceOwner, config.ID, senderDomain) if err != nil { - return "", nil, err + return err } err = checkSenderAddress(smtpConfigWriteModel) if err != nil { - return "", nil, err + return err } - iamAgg := InstanceAggregateFromWriteModel(&smtpConfigWriteModel.WriteModel) - pushedEvents, err := c.eventstore.Push(ctx, instance.NewSMTPConfigAddedEvent( - ctx, - iamAgg, - id, - description, - config.Tls, - config.From, - config.FromName, - replyTo, - hostAndPort, - config.SMTP.User, - smtpPassword, - )) + err = c.pushAppendAndReduce(ctx, + smtpConfigWriteModel, + instance.NewSMTPConfigAddedEvent( + ctx, + InstanceAggregateFromWriteModel(&smtpConfigWriteModel.WriteModel), + config.ID, + description, + config.Tls, + config.From, + config.FromName, + replyTo, + hostAndPort, + config.User, + smtpPassword, + ), + ) if err != nil { - return "", nil, err + return err } - - err = AppendAndReduce(smtpConfigWriteModel, pushedEvents...) - if err != nil { - return "", nil, err - } - return id, writeModelToObjectDetails(&smtpConfigWriteModel.WriteModel), nil + config.Details = writeModelToObjectDetails(&smtpConfigWriteModel.WriteModel) + return nil } -func (c *Commands) ChangeSMTPConfig(ctx context.Context, instanceID string, id string, config *smtp.Config) (*domain.ObjectDetails, error) { - if id == "" { - return nil, zerrors.ThrowInvalidArgument(nil, "SMTP-x8vo9", "Errors.IDMissing") +type ChangeSMTPConfig struct { + Details *domain.ObjectDetails + ResourceOwner string + ID string + + Description string + Host string + User string + Password string + Tls bool + From string + FromName string + ReplyToAddress string +} + +func (c *Commands) ChangeSMTPConfig(ctx context.Context, config *ChangeSMTPConfig) error { + if config.ResourceOwner == "" { + return zerrors.ThrowInvalidArgument(nil, "COMMAND-jwA8gxldy3", "Errors.ResourceOwnerMissing") + } + if config.ID == "" { + return zerrors.ThrowInvalidArgument(nil, "COMMAND-2JPlSRzuHy", "Errors.IDMissing") } from := strings.TrimSpace(config.From) if from == "" { - return nil, zerrors.ThrowInvalidArgument(nil, "INST-HSv2d", "Errors.Invalid.Argument") + return zerrors.ThrowInvalidArgument(nil, "COMMAND-gyPUXOTA4N", "Errors.Invalid.Argument") } fromSplitted := strings.Split(from, "@") senderDomain := fromSplitted[len(fromSplitted)-1] description := strings.TrimSpace(config.Description) replyTo := strings.TrimSpace(config.ReplyToAddress) - hostAndPort := strings.TrimSpace(config.SMTP.Host) + hostAndPort := strings.TrimSpace(config.Host) if _, _, err := net.SplitHostPort(hostAndPort); err != nil { - return nil, zerrors.ThrowInvalidArgument(nil, "INST-Kv875", "Errors.Invalid.Argument") + return zerrors.ThrowInvalidArgument(nil, "COMMAND-kZNVkuL32L", "Errors.Invalid.Argument") } var smtpPassword *crypto.CryptoValue var err error - if config.SMTP.Password != "" { - smtpPassword, err = crypto.Encrypt([]byte(config.SMTP.Password), c.smtpEncryption) + if config.Password != "" { + smtpPassword, err = crypto.Encrypt([]byte(config.Password), c.smtpEncryption) if err != nil { - return nil, err + return err } } - smtpConfigWriteModel, err := c.getSMTPConfig(ctx, instanceID, id, senderDomain) + smtpConfigWriteModel, err := c.getSMTPConfig(ctx, config.ResourceOwner, config.ID, senderDomain) if err != nil { - return nil, err + return err } if !smtpConfigWriteModel.State.Exists() { - return nil, zerrors.ThrowNotFound(nil, "COMMAND-7j8gv", "Errors.SMTPConfig.NotFound") + return zerrors.ThrowNotFound(nil, "COMMAND-j5IDFtt3T1", "Errors.SMTPConfig.NotFound") } err = checkSenderAddress(smtpConfigWriteModel) if err != nil { - return nil, err + return err } - iamAgg := InstanceAggregateFromWriteModel(&smtpConfigWriteModel.WriteModel) - changedEvent, hasChanged, err := smtpConfigWriteModel.NewChangedEvent( ctx, - iamAgg, - id, + InstanceAggregateFromWriteModel(&smtpConfigWriteModel.WriteModel), + config.ID, description, config.Tls, from, config.FromName, replyTo, hostAndPort, - config.SMTP.User, + config.User, smtpPassword, ) if err != nil { - return nil, err + return err } if !hasChanged { - return nil, zerrors.ThrowPreconditionFailed(nil, "COMMAND-lh3op", "Errors.NoChangesFound") + config.Details = writeModelToObjectDetails(&smtpConfigWriteModel.WriteModel) + return nil } - pushedEvents, err := c.eventstore.Push(ctx, changedEvent) + err = c.pushAppendAndReduce(ctx, smtpConfigWriteModel, changedEvent) if err != nil { - return nil, err + return err } - err = AppendAndReduce(smtpConfigWriteModel, pushedEvents...) - if err != nil { - return nil, err - } - return writeModelToObjectDetails(&smtpConfigWriteModel.WriteModel), nil + config.Details = writeModelToObjectDetails(&smtpConfigWriteModel.WriteModel) + return nil } -func (c *Commands) ChangeSMTPConfigPassword(ctx context.Context, instanceID, id string, password string) (*domain.ObjectDetails, error) { - instanceAgg := instance.NewAggregate(authz.GetInstance(ctx).InstanceID()) - smtpConfigWriteModel, err := c.getSMTPConfig(ctx, instanceID, id, "") +func (c *Commands) ChangeSMTPConfigPassword(ctx context.Context, resourceOwner, id string, password string) (*domain.ObjectDetails, error) { + if resourceOwner == "" { + return nil, zerrors.ThrowInvalidArgument(nil, "COMMAND-gHAyvUXCAF", "Errors.ResourceOwnerMissing") + } + if id == "" { + return nil, zerrors.ThrowInvalidArgument(nil, "COMMAND-BCkAf7LcJA", "Errors.IDMissing") + } + + smtpConfigWriteModel, err := c.getSMTPConfig(ctx, resourceOwner, id, "") if err != nil { return nil, err } if smtpConfigWriteModel.State != domain.SMTPConfigStateActive { - return nil, zerrors.ThrowNotFound(nil, "COMMAND-3n9ls", "Errors.SMTPConfig.NotFound") + return nil, zerrors.ThrowNotFound(nil, "COMMAND-rDHzqjGuKQ", "Errors.SMTPConfig.NotFound") } var smtpPassword *crypto.CryptoValue @@ -170,68 +208,152 @@ func (c *Commands) ChangeSMTPConfigPassword(ctx context.Context, instanceID, id } } - pushedEvents, err := c.eventstore.Push(ctx, instance.NewSMTPConfigPasswordChangedEvent( - ctx, - &instanceAgg.Aggregate, - id, - smtpPassword)) + err = c.pushAppendAndReduce(ctx, + smtpConfigWriteModel, + instance.NewSMTPConfigPasswordChangedEvent( + ctx, + InstanceAggregateFromWriteModel(&smtpConfigWriteModel.WriteModel), + id, + smtpPassword, + ), + ) if err != nil { return nil, err } - err = AppendAndReduce(smtpConfigWriteModel, pushedEvents...) - if err != nil { - return nil, err - } - return writeModelToObjectDetails(&smtpConfigWriteModel.WriteModel), nil } -func (c *Commands) ActivateSMTPConfig(ctx context.Context, instanceID, id, activatedId string) (*domain.ObjectDetails, error) { - if id == "" { - return nil, zerrors.ThrowInvalidArgument(nil, "SMTP-nm56k", "Errors.IDMissing") - } +type AddSMTPConfigHTTP struct { + Details *domain.ObjectDetails + ResourceOwner string + ID string - if len(activatedId) > 0 { - _, err := c.DeactivateSMTPConfig(ctx, instanceID, activatedId) + Description string + Endpoint string +} + +func (c *Commands) AddSMTPConfigHTTP(ctx context.Context, config *AddSMTPConfigHTTP) (err error) { + if config.ResourceOwner == "" { + return zerrors.ThrowInvalidArgument(nil, "COMMAND-FTNDXc8ACS", "Errors.ResourceOwnerMissing") + } + if config.ID == "" { + config.ID, err = c.idGenerator.Next() if err != nil { - return nil, err + return err } } - smtpConfigWriteModel, err := c.getSMTPConfig(ctx, instanceID, id, "") + smtpConfigWriteModel, err := c.getSMTPConfig(ctx, config.ResourceOwner, config.ID, "") + if err != nil { + return err + } + + err = c.pushAppendAndReduce(ctx, smtpConfigWriteModel, instance.NewSMTPConfigHTTPAddedEvent( + ctx, + InstanceAggregateFromWriteModel(&smtpConfigWriteModel.WriteModel), + config.ID, + config.Description, + config.Endpoint, + )) + if err != nil { + return err + } + config.Details = writeModelToObjectDetails(&smtpConfigWriteModel.WriteModel) + return nil +} + +type ChangeSMTPConfigHTTP struct { + Details *domain.ObjectDetails + ResourceOwner string + ID string + + Description string + Endpoint string +} + +func (c *Commands) ChangeSMTPConfigHTTP(ctx context.Context, config *ChangeSMTPConfigHTTP) (err error) { + if config.ResourceOwner == "" { + return zerrors.ThrowInvalidArgument(nil, "COMMAND-k7QCGOWyJA", "Errors.ResourceOwnerMissing") + } + if config.ID == "" { + return zerrors.ThrowInvalidArgument(nil, "COMMAND-2MHkV8ObWo", "Errors.IDMissing") + } + + smtpConfigWriteModel, err := c.getSMTPConfig(ctx, config.ResourceOwner, config.ID, "") + if err != nil { + return err + } + + if !smtpConfigWriteModel.State.Exists() || smtpConfigWriteModel.HTTPConfig == nil { + return zerrors.ThrowNotFound(nil, "COMMAND-xIrdledqv4", "Errors.SMTPConfig.NotFound") + } + + changedEvent, hasChanged, err := smtpConfigWriteModel.NewHTTPChangedEvent( + ctx, + InstanceAggregateFromWriteModel(&smtpConfigWriteModel.WriteModel), + config.ID, + config.Description, + config.Endpoint, + ) + if err != nil { + return err + } + if !hasChanged { + config.Details = writeModelToObjectDetails(&smtpConfigWriteModel.WriteModel) + return nil + } + + err = c.pushAppendAndReduce(ctx, smtpConfigWriteModel, changedEvent) + if err != nil { + return err + } + config.Details = writeModelToObjectDetails(&smtpConfigWriteModel.WriteModel) + return nil +} + +func (c *Commands) ActivateSMTPConfig(ctx context.Context, resourceOwner, id string) (*domain.ObjectDetails, error) { + if resourceOwner == "" { + return nil, zerrors.ThrowInvalidArgument(nil, "COMMAND-h5htMCebv3", "Errors.ResourceOwnerMissing") + } + if id == "" { + return nil, zerrors.ThrowInvalidArgument(nil, "COMMAND-1hPl6oVMJa", "Errors.IDMissing") + } + + smtpConfigWriteModel, err := c.getSMTPConfig(ctx, resourceOwner, id, "") if err != nil { return nil, err } if !smtpConfigWriteModel.State.Exists() { - return nil, zerrors.ThrowNotFound(nil, "COMMAND-kg8yr", "Errors.SMTPConfig.NotFound") + return nil, zerrors.ThrowNotFound(nil, "COMMAND-E9K20hxOS9", "Errors.SMTPConfig.NotFound") } - if smtpConfigWriteModel.State == domain.SMTPConfigStateActive { - return nil, zerrors.ThrowNotFound(nil, "COMMAND-ed3lr", "Errors.SMTPConfig.AlreadyActive") + return nil, zerrors.ThrowPreconditionFailed(nil, "COMMAND-vUHBSmBzaw", "Errors.SMTPConfig.AlreadyActive") } - iamAgg := InstanceAggregateFromWriteModel(&smtpConfigWriteModel.WriteModel) - pushedEvents, err := c.eventstore.Push(ctx, instance.NewSMTPConfigActivatedEvent( - ctx, - iamAgg, - id)) - if err != nil { - return nil, err - } - err = AppendAndReduce(smtpConfigWriteModel, pushedEvents...) + err = c.pushAppendAndReduce(ctx, + smtpConfigWriteModel, + instance.NewSMTPConfigActivatedEvent( + ctx, + InstanceAggregateFromWriteModel(&smtpConfigWriteModel.WriteModel), + id, + ), + ) if err != nil { return nil, err } return writeModelToObjectDetails(&smtpConfigWriteModel.WriteModel), nil } -func (c *Commands) DeactivateSMTPConfig(ctx context.Context, instanceID, id string) (*domain.ObjectDetails, error) { +func (c *Commands) DeactivateSMTPConfig(ctx context.Context, resourceOwner, id string) (*domain.ObjectDetails, error) { + if resourceOwner == "" { + return nil, zerrors.ThrowInvalidArgument(nil, "COMMAND-pvNHou89Tw", "Errors.ResourceOwnerMissing") + } if id == "" { - return nil, zerrors.ThrowInvalidArgument(nil, "SMTP-98ikl", "Errors.IDMissing") + return nil, zerrors.ThrowInvalidArgument(nil, "COMMAND-jLTIMrtApO", "Errors.IDMissing") } - smtpConfigWriteModel, err := c.getSMTPConfig(ctx, instanceID, id, "") + smtpConfigWriteModel, err := c.getSMTPConfig(ctx, resourceOwner, id, "") if err != nil { return nil, err } @@ -239,46 +361,47 @@ func (c *Commands) DeactivateSMTPConfig(ctx context.Context, instanceID, id stri return nil, zerrors.ThrowNotFound(nil, "COMMAND-k39PJ", "Errors.SMTPConfig.NotFound") } if smtpConfigWriteModel.State == domain.SMTPConfigStateInactive { - return nil, zerrors.ThrowNotFound(nil, "COMMAND-km8g3", "Errors.SMTPConfig.AlreadyDeactivated") + return nil, zerrors.ThrowPreconditionFailed(nil, "COMMAND-km8g3", "Errors.SMTPConfig.AlreadyDeactivated") } - iamAgg := InstanceAggregateFromWriteModel(&smtpConfigWriteModel.WriteModel) - pushedEvents, err := c.eventstore.Push(ctx, instance.NewSMTPConfigDeactivatedEvent( - ctx, - iamAgg, - id)) - if err != nil { - return nil, err - } - err = AppendAndReduce(smtpConfigWriteModel, pushedEvents...) + err = c.pushAppendAndReduce(ctx, + smtpConfigWriteModel, + instance.NewSMTPConfigDeactivatedEvent( + ctx, + InstanceAggregateFromWriteModel(&smtpConfigWriteModel.WriteModel), + id, + ), + ) if err != nil { return nil, err } return writeModelToObjectDetails(&smtpConfigWriteModel.WriteModel), nil } -func (c *Commands) RemoveSMTPConfig(ctx context.Context, instanceID, id string) (*domain.ObjectDetails, error) { +func (c *Commands) RemoveSMTPConfig(ctx context.Context, resourceOwner, id string) (*domain.ObjectDetails, error) { + if resourceOwner == "" { + return nil, zerrors.ThrowInvalidArgument(nil, "COMMAND-t2WsPRgGaK", "Errors.ResourceOwnerMissing") + } if id == "" { - return nil, zerrors.ThrowInvalidArgument(nil, "SMTP-7f5cv", "Errors.IDMissing") + return nil, zerrors.ThrowInvalidArgument(nil, "COMMAND-0ZV5whuUfu", "Errors.IDMissing") } - smtpConfigWriteModel, err := c.getSMTPConfig(ctx, instanceID, id, "") + smtpConfigWriteModel, err := c.getSMTPConfig(ctx, resourceOwner, id, "") if err != nil { return nil, err } if !smtpConfigWriteModel.State.Exists() { - return nil, zerrors.ThrowNotFound(nil, "COMMAND-kg8rt", "Errors.SMTPConfig.NotFound") + return nil, zerrors.ThrowNotFound(nil, "COMMAND-09CXlTDL6w", "Errors.SMTPConfig.NotFound") } - iamAgg := InstanceAggregateFromWriteModel(&smtpConfigWriteModel.WriteModel) - pushedEvents, err := c.eventstore.Push(ctx, instance.NewSMTPConfigRemovedEvent( - ctx, - iamAgg, - id)) - if err != nil { - return nil, err - } - err = AppendAndReduce(smtpConfigWriteModel, pushedEvents...) + err = c.pushAppendAndReduce(ctx, + smtpConfigWriteModel, + instance.NewSMTPConfigRemovedEvent( + ctx, + InstanceAggregateFromWriteModel(&smtpConfigWriteModel.WriteModel), + id, + ), + ) if err != nil { return nil, err } @@ -303,11 +426,11 @@ func (c *Commands) TestSMTPConfig(ctx context.Context, instanceID, id, email str if err != nil { return err } - if !smtpConfigWriteModel.State.Exists() { + if !smtpConfigWriteModel.State.Exists() || smtpConfigWriteModel.SMTPConfig == nil { return zerrors.ThrowNotFound(nil, "SMTP-p9cc", "Errors.SMTPConfig.NotFound") } - password, err = crypto.DecryptString(smtpConfigWriteModel.Password, c.smtpEncryption) + password, err = crypto.DecryptString(smtpConfigWriteModel.SMTPConfig.Password, c.smtpEncryption) if err != nil { return err } @@ -338,23 +461,22 @@ func (c *Commands) TestSMTPConfigById(ctx context.Context, instanceID, id, email return err } - if !smtpConfigWriteModel.State.Exists() { + if !smtpConfigWriteModel.State.Exists() || smtpConfigWriteModel.SMTPConfig == nil { return zerrors.ThrowNotFound(nil, "SMTP-99klw", "Errors.SMTPConfig.NotFound") } - password, err := crypto.DecryptString(smtpConfigWriteModel.Password, c.smtpEncryption) + password, err := crypto.DecryptString(smtpConfigWriteModel.SMTPConfig.Password, c.smtpEncryption) if err != nil { return err } smtpConfig := &smtp.Config{ - Description: smtpConfigWriteModel.Description, - Tls: smtpConfigWriteModel.TLS, - From: smtpConfigWriteModel.SenderAddress, - FromName: smtpConfigWriteModel.SenderName, + Tls: smtpConfigWriteModel.SMTPConfig.TLS, + From: smtpConfigWriteModel.SMTPConfig.SenderAddress, + FromName: smtpConfigWriteModel.SMTPConfig.SenderName, SMTP: smtp.SMTP{ - Host: smtpConfigWriteModel.Host, - User: smtpConfigWriteModel.User, + Host: smtpConfigWriteModel.SMTPConfig.Host, + User: smtpConfigWriteModel.SMTPConfig.User, Password: password, }, } @@ -373,7 +495,7 @@ func checkSenderAddress(writeModel *IAMSMTPConfigWriteModel) error { return nil } if !writeModel.domainState.Exists() { - return zerrors.ThrowInvalidArgument(nil, "INST-83nl8", "Errors.SMTPConfig.SenderAdressNotCustomDomain") + return zerrors.ThrowInvalidArgument(nil, "INST-xtWIiR2ZbR", "Errors.SMTPConfig.SenderAdressNotCustomDomain") } return nil } diff --git a/internal/command/smtp_test.go b/internal/command/smtp_test.go index 17f7f088d0..7aa224f251 100644 --- a/internal/command/smtp_test.go +++ b/internal/command/smtp_test.go @@ -2,6 +2,7 @@ package command import ( "context" + "errors" "testing" "github.com/stretchr/testify/assert" @@ -20,14 +21,13 @@ import ( func TestCommandSide_AddSMTPConfig(t *testing.T) { type fields struct { - eventstore *eventstore.Eventstore + eventstore func(t *testing.T) *eventstore.Eventstore idGenerator id.Generator alg crypto.EncryptionAlgorithm } type args struct { - ctx context.Context instanceID string - smtp *smtp.Config + smtp *AddSMTPConfig } type res struct { want *domain.ObjectDetails @@ -39,11 +39,24 @@ func TestCommandSide_AddSMTPConfig(t *testing.T) { args args res res }{ + { + name: "resourceowner empty, invalid argument", + fields: fields{ + eventstore: expectEventstore(), + }, + args: args{ + smtp: &AddSMTPConfig{}, + }, + res: res{ + err: func(err error) bool { + return errors.Is(err, zerrors.ThrowInvalidArgument(nil, "COMMAND-PQN0wsqSyi", "Errors.ResourceOwnerMissing")) + }, + }, + }, { name: "smtp config, custom domain not existing", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher( instance.NewDomainPolicyAddedEvent(context.Background(), @@ -59,25 +72,42 @@ func TestCommandSide_AddSMTPConfig(t *testing.T) { alg: crypto.CreateMockEncryptionAlg(gomock.NewController(t)), }, args: args{ - ctx: authz.WithInstanceID(context.Background(), "INSTANCE"), - smtp: &smtp.Config{ - From: "from@domain.ch", - SMTP: smtp.SMTP{ - Host: "host:587", - User: "user", - Password: "password", - }, + smtp: &AddSMTPConfig{ + ResourceOwner: "INSTANCE", + From: "from@domain.ch", + Host: "host:587", + User: "user", + Password: "password", }, }, res: res{ - err: zerrors.IsErrorInvalidArgument, + err: func(err error) bool { + return errors.Is(err, zerrors.ThrowInvalidArgument(nil, "INST-xtWIiR2ZbR", "Errors.SMTPConfig.SenderAdressNotCustomDomain")) + }, + }, + }, + { + name: "add smtp config, from empty", + fields: fields{ + eventstore: expectEventstore(), + idGenerator: id_mock.NewIDGeneratorExpectIDs(t, "configid"), + }, + args: args{ + smtp: &AddSMTPConfig{ + ResourceOwner: "INSTANCE", + From: " ", + }, + }, + res: res{ + err: func(err error) bool { + return errors.Is(err, zerrors.ThrowInvalidArgument(nil, "COMMAND-SAAFpV8VKV", "Errors.Invalid.Argument")) + }, }, }, { name: "add smtp config, ok", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher( instance.NewDomainAddedEvent(context.Background(), @@ -118,17 +148,15 @@ func TestCommandSide_AddSMTPConfig(t *testing.T) { alg: crypto.CreateMockEncryptionAlg(gomock.NewController(t)), }, args: args{ - ctx: authz.WithInstanceID(context.Background(), "INSTANCE"), - smtp: &smtp.Config{ - Description: "test", - Tls: true, - From: "from@domain.ch", - FromName: "name", - SMTP: smtp.SMTP{ - Host: "host:587", - User: "user", - Password: "password", - }, + smtp: &AddSMTPConfig{ + ResourceOwner: "INSTANCE", + Description: "test", + Tls: true, + From: "from@domain.ch", + FromName: "name", + Host: "host:587", + User: "user", + Password: "password", }, }, res: res{ @@ -140,8 +168,7 @@ func TestCommandSide_AddSMTPConfig(t *testing.T) { { name: "add smtp config with reply to address, ok", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher( instance.NewDomainAddedEvent(context.Background(), @@ -182,18 +209,16 @@ func TestCommandSide_AddSMTPConfig(t *testing.T) { alg: crypto.CreateMockEncryptionAlg(gomock.NewController(t)), }, args: args{ - ctx: authz.WithInstanceID(context.Background(), "INSTANCE"), - smtp: &smtp.Config{ + smtp: &AddSMTPConfig{ + ResourceOwner: "INSTANCE", Description: "test", Tls: true, From: "from@domain.ch", FromName: "name", ReplyToAddress: "replyto@domain.ch", - SMTP: smtp.SMTP{ - Host: "host:587", - User: "user", - Password: "password", - }, + Host: "host:587", + User: "user", + Password: "password", }, }, res: res{ @@ -205,58 +230,57 @@ func TestCommandSide_AddSMTPConfig(t *testing.T) { { name: "smtp config, port is missing", fields: fields{ - eventstore: eventstoreExpect(t), + eventstore: expectEventstore(), idGenerator: id_mock.NewIDGeneratorExpectIDs(t, "configid"), alg: crypto.CreateMockEncryptionAlg(gomock.NewController(t)), }, args: args{ - ctx: authz.WithInstanceID(context.Background(), "INSTANCE"), - smtp: &smtp.Config{ - Description: "test", - Tls: true, - From: "from@domain.ch", - FromName: "name", - SMTP: smtp.SMTP{ - Host: "host", - User: "user", - Password: "password", - }, + smtp: &AddSMTPConfig{ + ResourceOwner: "INSTANCE", + Description: "test", + Tls: true, + From: "from@domain.ch", + FromName: "name", + Host: "host", + User: "user", + Password: "password", }, }, res: res{ - err: zerrors.IsErrorInvalidArgument, + err: func(err error) bool { + return errors.Is(err, zerrors.ThrowInvalidArgument(nil, "COMMAND-EvAtufIinh", "Errors.Invalid.Argument")) + }, }, }, { name: "smtp config, host is empty", fields: fields{ - eventstore: eventstoreExpect(t), + eventstore: expectEventstore(), idGenerator: id_mock.NewIDGeneratorExpectIDs(t, "configid"), alg: crypto.CreateMockEncryptionAlg(gomock.NewController(t)), }, args: args{ - ctx: authz.WithInstanceID(context.Background(), "INSTANCE"), - smtp: &smtp.Config{ - Description: "test", - Tls: true, - From: "from@domain.ch", - FromName: "name", - SMTP: smtp.SMTP{ - Host: " ", - User: "user", - Password: "password", - }, + smtp: &AddSMTPConfig{ + ResourceOwner: "INSTANCE", + Description: "test", + Tls: true, + From: "from@domain.ch", + FromName: "name", + Host: " ", + User: "user", + Password: "password", }, }, res: res{ - err: zerrors.IsErrorInvalidArgument, + err: func(err error) bool { + return errors.Is(err, zerrors.ThrowInvalidArgument(nil, "COMMAND-EvAtufIinh", "Errors.Invalid.Argument")) + }, }, }, { name: "add smtp config, ipv6 works", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher( instance.NewDomainAddedEvent(context.Background(), @@ -297,17 +321,15 @@ func TestCommandSide_AddSMTPConfig(t *testing.T) { idGenerator: id_mock.NewIDGeneratorExpectIDs(t, "configid"), }, args: args{ - ctx: authz.WithInstanceID(context.Background(), "INSTANCE"), - smtp: &smtp.Config{ - Description: "test", - Tls: true, - From: "from@domain.ch", - FromName: "name", - SMTP: smtp.SMTP{ - Host: "[2001:db8::1]:2525", - User: "user", - Password: "password", - }, + smtp: &AddSMTPConfig{ + ResourceOwner: "INSTANCE", + Description: "test", + Tls: true, + From: "from@domain.ch", + FromName: "name", + Host: "[2001:db8::1]:2525", + User: "user", + Password: "password", }, }, res: res{ @@ -320,11 +342,11 @@ func TestCommandSide_AddSMTPConfig(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { r := &Commands{ - eventstore: tt.fields.eventstore, + eventstore: tt.fields.eventstore(t), idGenerator: tt.fields.idGenerator, smtpEncryption: tt.fields.alg, } - _, got, err := r.AddSMTPConfig(tt.args.ctx, tt.args.instanceID, tt.args.smtp) + err := r.AddSMTPConfig(context.Background(), tt.args.smtp) if tt.res.err == nil { assert.NoError(t, err) } @@ -332,7 +354,8 @@ func TestCommandSide_AddSMTPConfig(t *testing.T) { t.Errorf("got wrong err: %v ", err) } if tt.res.err == nil { - assertObjectDetails(t, tt.res.want, got) + assertObjectDetails(t, tt.res.want, tt.args.smtp.Details) + assert.NotEmpty(t, tt.args.smtp.ID) } }) } @@ -340,13 +363,10 @@ func TestCommandSide_AddSMTPConfig(t *testing.T) { func TestCommandSide_ChangeSMTPConfig(t *testing.T) { type fields struct { - eventstore *eventstore.Eventstore + eventstore func(t *testing.T) *eventstore.Eventstore } type args struct { - ctx context.Context - instanceID string - id string - smtp *smtp.Config + smtp *ChangeSMTPConfig } type res struct { want *domain.ObjectDetails @@ -359,68 +379,81 @@ func TestCommandSide_ChangeSMTPConfig(t *testing.T) { res res }{ { - name: "id empty, precondition error", + name: "resourceowner empty, precondition error", fields: fields{ - eventstore: eventstoreExpect( - t, - ), + eventstore: expectEventstore(), }, args: args{ - ctx: authz.WithInstanceID(context.Background(), "INSTANCE"), - smtp: &smtp.Config{}, + smtp: &ChangeSMTPConfig{}, }, res: res{ - err: zerrors.IsErrorInvalidArgument, + err: func(err error) bool { + return errors.Is(err, zerrors.ThrowInvalidArgument(nil, "COMMAND-jwA8gxldy3", "Errors.ResourceOwnerMissing")) + }, + }, + }, + { + name: "id empty, precondition error", + fields: fields{ + eventstore: expectEventstore(), + }, + args: args{ + smtp: &ChangeSMTPConfig{ + ResourceOwner: "INSTANCE", + }, + }, + res: res{ + err: func(err error) bool { + return errors.Is(err, zerrors.ThrowInvalidArgument(nil, "COMMAND-2JPlSRzuHy", "Errors.IDMissing")) + }, }, }, { name: "empty config, invalid argument error", fields: fields{ - eventstore: eventstoreExpect( - t, - ), + eventstore: expectEventstore(), }, args: args{ - ctx: authz.WithInstanceID(context.Background(), "INSTANCE"), - smtp: &smtp.Config{}, - id: "configID", + smtp: &ChangeSMTPConfig{ + ResourceOwner: "INSTANCE", + ID: "configID", + }, }, res: res{ - err: zerrors.IsErrorInvalidArgument, + err: func(err error) bool { + return errors.Is(err, zerrors.ThrowInvalidArgument(nil, "COMMAND-gyPUXOTA4N", "Errors.Invalid.Argument")) + }, }, }, { name: "smtp not existing, not found error", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter(), ), }, args: args{ - ctx: authz.WithInstanceID(context.Background(), "INSTANCE"), - smtp: &smtp.Config{ - Description: "test", - Tls: true, - From: "from@domain.ch", - FromName: "name", - SMTP: smtp.SMTP{ - Host: "host:587", - User: "user", - }, + smtp: &ChangeSMTPConfig{ + ResourceOwner: "INSTANCE", + ID: "ID", + Description: "test", + Tls: true, + From: "from@domain.ch", + FromName: "name", + Host: "host:587", + User: "user", }, - instanceID: "INSTANCE", - id: "ID", }, res: res{ - err: zerrors.IsNotFound, + err: func(err error) bool { + return errors.Is(err, zerrors.ThrowNotFound(nil, "COMMAND-j5IDFtt3T1", "Errors.SMTPConfig.NotFound")) + }, }, }, { name: "smtp domain not matched", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher( instance.NewDomainAddedEvent(context.Background(), @@ -454,18 +487,15 @@ func TestCommandSide_ChangeSMTPConfig(t *testing.T) { ), }, args: args{ - ctx: authz.WithInstanceID(context.Background(), "INSTANCE"), - instanceID: "INSTANCE", - id: "ID", - smtp: &smtp.Config{ - Description: "test", - Tls: true, - From: "from@wrongdomain.ch", - FromName: "name", - SMTP: smtp.SMTP{ - Host: "host:587", - User: "user", - }, + smtp: &ChangeSMTPConfig{ + ResourceOwner: "INSTANCE", + ID: "ID", + Description: "test", + Tls: true, + From: "from@wrongdomain.ch", + FromName: "name", + Host: "host:587", + User: "user", }, }, res: res{ @@ -475,8 +505,7 @@ func TestCommandSide_ChangeSMTPConfig(t *testing.T) { { name: "no changes, precondition error", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher( instance.NewDomainAddedEvent(context.Background(), @@ -510,29 +539,27 @@ func TestCommandSide_ChangeSMTPConfig(t *testing.T) { ), }, args: args{ - ctx: authz.WithInstanceID(context.Background(), "INSTANCE"), - smtp: &smtp.Config{ - Description: "test", - Tls: true, - From: "from@domain.ch", - FromName: "name", - SMTP: smtp.SMTP{ - Host: "host:587", - User: "user", - }, + smtp: &ChangeSMTPConfig{ + ResourceOwner: "INSTANCE", + ID: "ID", + Description: "test", + Tls: true, + From: "from@domain.ch", + FromName: "name", + Host: "host:587", + User: "user", }, - instanceID: "INSTANCE", - id: "ID", }, res: res{ - err: zerrors.IsPreconditionFailed, + want: &domain.ObjectDetails{ + ResourceOwner: "INSTANCE", + }, }, }, { name: "smtp config change, ok", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher( instance.NewDomainAddedEvent(context.Background(), @@ -579,20 +606,17 @@ func TestCommandSide_ChangeSMTPConfig(t *testing.T) { ), }, args: args{ - ctx: authz.WithInstanceID(context.Background(), "INSTANCE"), - smtp: &smtp.Config{ + smtp: &ChangeSMTPConfig{ + ResourceOwner: "INSTANCE", + ID: "ID", Description: "test", Tls: false, From: "from2@domain.ch", FromName: "name2", ReplyToAddress: "replyto@domain.ch", - SMTP: smtp.SMTP{ - Host: "host2:587", - User: "user2", - }, + Host: "host2:587", + User: "user2", }, - id: "ID", - instanceID: "INSTANCE", }, res: res{ want: &domain.ObjectDetails{ @@ -603,58 +627,55 @@ func TestCommandSide_ChangeSMTPConfig(t *testing.T) { { name: "smtp config, port is missing", fields: fields{ - eventstore: eventstoreExpect(t), + eventstore: expectEventstore(), }, args: args{ - ctx: authz.WithInstanceID(context.Background(), "INSTANCE"), - smtp: &smtp.Config{ - Description: "test", - Tls: true, - From: "from@domain.ch", - FromName: "name", - SMTP: smtp.SMTP{ - Host: "host", - User: "user", - Password: "password", - }, + smtp: &ChangeSMTPConfig{ + ResourceOwner: "INSTANCE", + ID: "ID", + Description: "test", + Tls: true, + From: "from@domain.ch", + FromName: "name", + Host: "host", + User: "user", + Password: "password", }, - instanceID: "INSTANCE", - id: "ID", }, res: res{ - err: zerrors.IsErrorInvalidArgument, + err: func(err error) bool { + return errors.Is(err, zerrors.ThrowInvalidArgument(nil, "COMMAND-kZNVkuL32L", "Errors.Invalid.Argument")) + }, }, }, { name: "smtp config, host is empty", fields: fields{ - eventstore: eventstoreExpect(t), + eventstore: expectEventstore(), }, args: args{ - ctx: authz.WithInstanceID(context.Background(), "INSTANCE"), - smtp: &smtp.Config{ - Description: "test", - Tls: true, - From: "from@domain.ch", - FromName: "name", - SMTP: smtp.SMTP{ - Host: " ", - User: "user", - Password: "password", - }, + smtp: &ChangeSMTPConfig{ + ResourceOwner: "INSTANCE", + ID: "ID", + Description: "test", + Tls: true, + From: "from@domain.ch", + FromName: "name", + Host: " ", + User: "user", + Password: "password", }, - instanceID: "INSTANCE", - id: "ID", }, res: res{ - err: zerrors.IsErrorInvalidArgument, + err: func(err error) bool { + return errors.Is(err, zerrors.ThrowInvalidArgument(nil, "COMMAND-kZNVkuL32L", "Errors.Invalid.Argument")) + }, }, }, { name: "smtp config change, ipv6 works", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher( instance.NewDomainAddedEvent(context.Background(), @@ -701,20 +722,17 @@ func TestCommandSide_ChangeSMTPConfig(t *testing.T) { ), }, args: args{ - ctx: authz.WithInstanceID(context.Background(), "INSTANCE"), - smtp: &smtp.Config{ + smtp: &ChangeSMTPConfig{ + ResourceOwner: "INSTANCE", + ID: "ID", Description: "test", Tls: false, From: "from2@domain.ch", FromName: "name2", ReplyToAddress: "replyto@domain.ch", - SMTP: smtp.SMTP{ - Host: "[2001:db8::1]:2525", - User: "user2", - }, + Host: "[2001:db8::1]:2525", + User: "user2", }, - instanceID: "INSTANCE", - id: "ID", }, res: res{ want: &domain.ObjectDetails{ @@ -726,9 +744,9 @@ func TestCommandSide_ChangeSMTPConfig(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { r := &Commands{ - eventstore: tt.fields.eventstore, + eventstore: tt.fields.eventstore(t), } - got, err := r.ChangeSMTPConfig(tt.args.ctx, tt.args.instanceID, tt.args.id, tt.args.smtp) + err := r.ChangeSMTPConfig(context.Background(), tt.args.smtp) if tt.res.err == nil { assert.NoError(t, err) } @@ -736,7 +754,7 @@ func TestCommandSide_ChangeSMTPConfig(t *testing.T) { t.Errorf("got wrong err: %v ", err) } if tt.res.err == nil { - assertObjectDetails(t, tt.res.want, got) + assertObjectDetails(t, tt.res.want, tt.args.smtp.Details) } }) } @@ -744,11 +762,10 @@ func TestCommandSide_ChangeSMTPConfig(t *testing.T) { func TestCommandSide_ChangeSMTPConfigPassword(t *testing.T) { type fields struct { - eventstore *eventstore.Eventstore + eventstore func(t *testing.T) *eventstore.Eventstore alg crypto.EncryptionAlgorithm } type args struct { - ctx context.Context instanceID string id string password string @@ -763,28 +780,54 @@ func TestCommandSide_ChangeSMTPConfigPassword(t *testing.T) { args args res res }{ + { + name: "smtp config, error resourceOwner empty", + fields: fields{ + eventstore: expectEventstore(), + }, + args: args{}, + res: res{ + err: func(err error) bool { + return errors.Is(err, zerrors.ThrowInvalidArgument(nil, "COMMAND-gHAyvUXCAF", "Errors.ResourceOwnerMissing")) + }, + }, + }, + { + name: "smtp config, error id empty", + fields: fields{ + eventstore: expectEventstore(), + }, + args: args{ + instanceID: "INSTANCE", + }, + res: res{ + err: func(err error) bool { + return errors.Is(err, zerrors.ThrowInvalidArgument(nil, "COMMAND-BCkAf7LcJA", "Errors.IDMissing")) + }, + }, + }, { name: "smtp config, error not found", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter(), ), }, args: args{ - ctx: context.Background(), - password: "", - id: "ID", + instanceID: "INSTANCE", + password: "", + id: "ID", }, res: res{ - err: zerrors.IsNotFound, + err: func(err error) bool { + return errors.Is(err, zerrors.ThrowNotFound(nil, "COMMAND-rDHzqjGuKQ", "Errors.SMTPConfig.NotFound")) + }, }, }, { name: "change smtp config password, ok", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher( instance.NewSMTPConfigAddedEvent( @@ -826,7 +869,6 @@ func TestCommandSide_ChangeSMTPConfigPassword(t *testing.T) { alg: crypto.CreateMockEncryptionAlg(gomock.NewController(t)), }, args: args{ - ctx: authz.WithInstanceID(context.Background(), "INSTANCE"), password: "password", id: "ID", instanceID: "INSTANCE", @@ -841,10 +883,10 @@ func TestCommandSide_ChangeSMTPConfigPassword(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { r := &Commands{ - eventstore: tt.fields.eventstore, + eventstore: tt.fields.eventstore(t), smtpEncryption: tt.fields.alg, } - got, err := r.ChangeSMTPConfigPassword(tt.args.ctx, tt.args.instanceID, tt.args.id, tt.args.password) + got, err := r.ChangeSMTPConfigPassword(context.Background(), tt.args.instanceID, tt.args.id, tt.args.password) if tt.res.err == nil { assert.NoError(t, err) } @@ -858,16 +900,13 @@ func TestCommandSide_ChangeSMTPConfigPassword(t *testing.T) { } } -func TestCommandSide_ActivateSMTPConfig(t *testing.T) { +func TestCommandSide_AddSMTPConfigHTTP(t *testing.T) { type fields struct { - eventstore *eventstore.Eventstore - alg crypto.EncryptionAlgorithm + eventstore func(t *testing.T) *eventstore.Eventstore + idGenerator id.Generator } type args struct { - ctx context.Context - instanceID string - id string - activatedId string + http *AddSMTPConfigHTTP } type res struct { want *domain.ObjectDetails @@ -880,115 +919,42 @@ func TestCommandSide_ActivateSMTPConfig(t *testing.T) { res res }{ { - name: "id empty, precondition error", + name: "add smtp config, resourceowner empty", fields: fields{ - eventstore: eventstoreExpect( - t, - ), + eventstore: expectEventstore(), }, args: args{ - ctx: authz.WithInstanceID(context.Background(), "INSTANCE"), + http: &AddSMTPConfigHTTP{}, }, res: res{ - err: zerrors.IsErrorInvalidArgument, - }, - }, - { - name: "smtp not existing, not found error", - fields: fields{ - eventstore: eventstoreExpect( - t, - expectFilter(), - ), - }, - args: args{ - ctx: context.Background(), - instanceID: "INSTANCE", - id: "id", - }, - res: res{ - err: zerrors.IsNotFound, - }, - }, - { - name: "activate smtp config, ok", - fields: fields{ - eventstore: eventstoreExpect( - t, - expectFilter( - eventFromEventPusher( - instance.NewSMTPConfigAddedEvent( - context.Background(), - &instance.NewAggregate("INSTANCE").Aggregate, - "ID", - "test", - true, - "from", - "name", - "", - "host:587", - "user", - &crypto.CryptoValue{}, - ), - ), - ), - expectPush( - instance.NewSMTPConfigActivatedEvent( - context.Background(), - &instance.NewAggregate("INSTANCE").Aggregate, - "ID", - ), - ), - ), - }, - args: args{ - ctx: authz.WithInstanceID(context.Background(), "INSTANCE"), - id: "ID", - instanceID: "INSTANCE", - activatedId: "", - }, - res: res{ - want: &domain.ObjectDetails{ - ResourceOwner: "INSTANCE", + err: func(err error) bool { + return errors.Is(err, zerrors.ThrowInvalidArgument(nil, "COMMAND-FTNDXc8ACS", "Errors.ResourceOwnerMissing")) }, }, }, { - name: "activate smtp config, already active, ok", + name: "add smtp config, ok", fields: fields{ - eventstore: eventstoreExpect( - t, - expectFilter( - eventFromEventPusher( - instance.NewSMTPConfigAddedEvent( - context.Background(), - &instance.NewAggregate("INSTANCE").Aggregate, - "ID", - "test", - true, - "from", - "name", - "", - "host:587", - "user", - &crypto.CryptoValue{}, - ), - ), - ), + eventstore: expectEventstore( + expectFilter(), expectPush( - instance.NewSMTPConfigActivatedEvent( + instance.NewSMTPConfigHTTPAddedEvent( context.Background(), &instance.NewAggregate("INSTANCE").Aggregate, - "ID", + "configid", + "test", + "endpoint", ), ), ), + idGenerator: id_mock.NewIDGeneratorExpectIDs(t, "configid"), }, args: args{ - ctx: authz.WithInstanceID(context.Background(), "INSTANCE"), - id: "ID", - instanceID: "INSTANCE", - activatedId: "", + http: &AddSMTPConfigHTTP{ + ResourceOwner: "INSTANCE", + Description: "test", + Endpoint: "endpoint", + }, }, res: res{ want: &domain.ObjectDetails{ @@ -1000,10 +966,371 @@ func TestCommandSide_ActivateSMTPConfig(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { r := &Commands{ - eventstore: tt.fields.eventstore, + eventstore: tt.fields.eventstore(t), + idGenerator: tt.fields.idGenerator, + } + err := r.AddSMTPConfigHTTP(context.Background(), tt.args.http) + 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.want, tt.args.http.Details) + assert.NotEmpty(t, tt.args.http.ID) + } + }) + } +} + +func TestCommandSide_ChangeSMTPConfigHTTP(t *testing.T) { + type fields struct { + eventstore func(t *testing.T) *eventstore.Eventstore + } + type args struct { + http *ChangeSMTPConfigHTTP + } + type res struct { + want *domain.ObjectDetails + err func(error) bool + } + tests := []struct { + name string + fields fields + args args + res res + }{{ + name: "resourceowner empty, precondition error", + fields: fields{ + eventstore: expectEventstore(), + }, + args: args{ + http: &ChangeSMTPConfigHTTP{}, + }, + res: res{ + err: func(err error) bool { + return errors.Is(err, zerrors.ThrowInvalidArgument(nil, "COMMAND-k7QCGOWyJA", "Errors.ResourceOwnerMissing")) + }, + }, + }, + { + name: "id empty, precondition error", + fields: fields{ + eventstore: expectEventstore(), + }, + args: args{ + http: &ChangeSMTPConfigHTTP{ + ResourceOwner: "INSTANCE", + }, + }, + res: res{ + err: func(err error) bool { + return errors.Is(err, zerrors.ThrowInvalidArgument(nil, "COMMAND-2MHkV8ObWo", "Errors.IDMissing")) + }, + }, + }, + { + name: "smtp not existing, not found error", + fields: fields{ + eventstore: expectEventstore( + expectFilter(), + ), + }, + args: args{ + http: &ChangeSMTPConfigHTTP{ + ResourceOwner: "INSTANCE", + ID: "ID", + Description: "test", + Endpoint: "endpoint", + }, + }, + res: res{ + err: func(err error) bool { + return errors.Is(err, zerrors.ThrowNotFound(nil, "COMMAND-xIrdledqv4", "Errors.SMTPConfig.NotFound")) + }, + }, + }, + { + name: "no changes, precondition error", + fields: fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + instance.NewSMTPConfigHTTPAddedEvent( + context.Background(), + &instance.NewAggregate("INSTANCE").Aggregate, + "ID", + "test", + "endpoint", + ), + ), + ), + ), + }, + args: args{ + http: &ChangeSMTPConfigHTTP{ + ResourceOwner: "INSTANCE", + ID: "ID", + Description: "test", + Endpoint: "endpoint", + }, + }, + res: res{ + want: &domain.ObjectDetails{ + ResourceOwner: "INSTANCE", + }, + }, + }, + { + name: "smtp config change, ok", + fields: fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + instance.NewSMTPConfigHTTPAddedEvent( + context.Background(), + &instance.NewAggregate("INSTANCE").Aggregate, + "ID", + "", + "endpoint", + ), + ), + ), + expectPush( + newSMTPConfigHTTPChangedEvent( + context.Background(), + "ID", + "test", + "endpoint2", + ), + ), + ), + }, + args: args{ + http: &ChangeSMTPConfigHTTP{ + ResourceOwner: "INSTANCE", + ID: "ID", + Description: "test", + Endpoint: "endpoint2", + }, + }, + res: res{ + want: &domain.ObjectDetails{ + ResourceOwner: "INSTANCE", + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + r := &Commands{ + eventstore: tt.fields.eventstore(t), + } + err := r.ChangeSMTPConfigHTTP(context.Background(), tt.args.http) + 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.want, tt.args.http.Details) + } + }) + } +} + +func TestCommandSide_ActivateSMTPConfig(t *testing.T) { + type fields struct { + eventstore func(t *testing.T) *eventstore.Eventstore + alg crypto.EncryptionAlgorithm + } + type args struct { + ctx context.Context + instanceID string + id string + } + type res struct { + want *domain.ObjectDetails + err func(error) bool + } + tests := []struct { + name string + fields fields + args args + res res + }{{ + name: "resourceowner empty, precondition error", + fields: fields{ + eventstore: expectEventstore(), + }, + args: args{ + instanceID: "", + }, + res: res{ + err: func(err error) bool { + return errors.Is(err, zerrors.ThrowInvalidArgument(nil, "COMMAND-h5htMCebv3", "Errors.ResourceOwnerMissing")) + }, + }, + }, + { + name: "id empty, precondition error", + fields: fields{ + eventstore: expectEventstore(), + }, + args: args{ + instanceID: "INSTANCE", + }, + res: res{ + err: func(err error) bool { + return errors.Is(err, zerrors.ThrowInvalidArgument(nil, "COMMAND-1hPl6oVMJa", "Errors.IDMissing")) + }, + }, + }, + { + name: "smtp not existing, not found error", + fields: fields{ + eventstore: expectEventstore( + expectFilter(), + ), + }, + args: args{ + ctx: context.Background(), + instanceID: "INSTANCE", + id: "id", + }, + res: res{ + err: func(err error) bool { + return errors.Is(err, zerrors.ThrowNotFound(nil, "COMMAND-E9K20hxOS9", "Errors.SMTPConfig.NotFound")) + }, + }, + }, + { + name: "activate smtp config, ok", + fields: fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + instance.NewSMTPConfigAddedEvent( + context.Background(), + &instance.NewAggregate("INSTANCE").Aggregate, + "ID", + "test", + true, + "from", + "name", + "", + "host:587", + "user", + &crypto.CryptoValue{}, + ), + ), + ), + expectPush( + instance.NewSMTPConfigActivatedEvent( + context.Background(), + &instance.NewAggregate("INSTANCE").Aggregate, + "ID", + ), + ), + ), + }, + args: args{ + ctx: authz.WithInstanceID(context.Background(), "INSTANCE"), + id: "ID", + instanceID: "INSTANCE", + }, + res: res{ + want: &domain.ObjectDetails{ + ResourceOwner: "INSTANCE", + }, + }, + }, + { + name: "activate smtp config, already active", + fields: fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + instance.NewSMTPConfigAddedEvent( + context.Background(), + &instance.NewAggregate("INSTANCE").Aggregate, + "ID", + "test", + true, + "from", + "name", + "", + "host:587", + "user", + &crypto.CryptoValue{}, + ), + ), + eventFromEventPusher( + instance.NewSMTPConfigActivatedEvent( + context.Background(), + &instance.NewAggregate("INSTANCE").Aggregate, + "ID", + ), + ), + ), + ), + }, + args: args{ + ctx: authz.WithInstanceID(context.Background(), "INSTANCE"), + id: "ID", + instanceID: "INSTANCE", + }, + res: res{ + err: func(err error) bool { + return errors.Is(err, zerrors.ThrowPreconditionFailed(nil, "COMMAND-vUHBSmBzaw", "Errors.SMTPConfig.AlreadyActive")) + }, + }, + }, + { + name: "activate smtp config http, ok", + fields: fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + instance.NewSMTPConfigHTTPAddedEvent( + context.Background(), + &instance.NewAggregate("INSTANCE").Aggregate, + "ID", + "test", + "endpoint", + ), + ), + ), + expectPush( + instance.NewSMTPConfigActivatedEvent( + context.Background(), + &instance.NewAggregate("INSTANCE").Aggregate, + "ID", + ), + ), + ), + }, + args: args{ + ctx: authz.WithInstanceID(context.Background(), "INSTANCE"), + id: "ID", + instanceID: "INSTANCE", + }, + res: res{ + want: &domain.ObjectDetails{ + ResourceOwner: "INSTANCE", + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + r := &Commands{ + eventstore: tt.fields.eventstore(t), smtpEncryption: tt.fields.alg, } - got, err := r.ActivateSMTPConfig(tt.args.ctx, tt.args.instanceID, tt.args.id, tt.args.activatedId) + got, err := r.ActivateSMTPConfig(tt.args.ctx, tt.args.instanceID, tt.args.id) if tt.res.err == nil { assert.NoError(t, err) } @@ -1019,14 +1346,12 @@ func TestCommandSide_ActivateSMTPConfig(t *testing.T) { func TestCommandSide_DeactivateSMTPConfig(t *testing.T) { type fields struct { - eventstore *eventstore.Eventstore + eventstore func(t *testing.T) *eventstore.Eventstore alg crypto.EncryptionAlgorithm } type args struct { - ctx context.Context - instanceID string - id string - activatedId string + instanceID string + id string } type res struct { want *domain.ObjectDetails @@ -1037,43 +1362,53 @@ func TestCommandSide_DeactivateSMTPConfig(t *testing.T) { fields fields args args res res - }{ + }{{ + name: "resourceOwner empty, precondition error", + fields: fields{ + eventstore: expectEventstore(), + }, + args: args{}, + res: res{ + err: func(err error) bool { + return errors.Is(err, zerrors.ThrowInvalidArgument(nil, "COMMAND-pvNHou89Tw", "Errors.ResourceOwnerMissing")) + }, + }, + }, { name: "id empty, precondition error", fields: fields{ - eventstore: eventstoreExpect( - t, - ), + eventstore: expectEventstore(), }, args: args{ - ctx: authz.WithInstanceID(context.Background(), "INSTANCE"), + instanceID: "INSTANCE", }, res: res{ - err: zerrors.IsErrorInvalidArgument, + err: func(err error) bool { + return errors.Is(err, zerrors.ThrowInvalidArgument(nil, "COMMAND-jLTIMrtApO", "Errors.IDMissing")) + }, }, }, { name: "smtp not existing, not found error", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter(), ), }, args: args{ - ctx: context.Background(), instanceID: "INSTANCE", id: "id", }, res: res{ - err: zerrors.IsNotFound, + err: func(err error) bool { + return errors.Is(err, zerrors.ThrowNotFound(nil, "COMMAND-k39PJ", "Errors.SMTPConfig.NotFound")) + }, }, }, { name: "deactivate smtp config, ok", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher( instance.NewSMTPConfigAddedEvent( @@ -1108,10 +1443,96 @@ func TestCommandSide_DeactivateSMTPConfig(t *testing.T) { ), }, args: args{ - ctx: authz.WithInstanceID(context.Background(), "INSTANCE"), - id: "ID", - instanceID: "INSTANCE", - activatedId: "", + id: "ID", + instanceID: "INSTANCE", + }, + res: res{ + want: &domain.ObjectDetails{ + ResourceOwner: "INSTANCE", + }, + }, + }, + { + name: "deactivate smtp config, already deactivated", + fields: fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + instance.NewSMTPConfigAddedEvent( + context.Background(), + &instance.NewAggregate("INSTANCE").Aggregate, + "ID", + "test", + true, + "from", + "name", + "", + "host:587", + "user", + &crypto.CryptoValue{}, + ), + ), + eventFromEventPusher( + instance.NewSMTPConfigActivatedEvent( + context.Background(), + &instance.NewAggregate("INSTANCE").Aggregate, + "ID", + ), + ), + eventFromEventPusher( + instance.NewSMTPConfigDeactivatedEvent( + context.Background(), + &instance.NewAggregate("INSTANCE").Aggregate, + "ID", + ), + ), + ), + ), + }, + args: args{ + id: "ID", + instanceID: "INSTANCE", + }, + res: res{ + err: func(err error) bool { + return errors.Is(err, zerrors.ThrowPreconditionFailed(nil, "COMMAND-km8g3", "Errors.SMTPConfig.AlreadyDeactivated")) + }, + }, + }, + { + name: "deactivate smtp config http, ok", + fields: fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + instance.NewSMTPConfigHTTPAddedEvent( + context.Background(), + &instance.NewAggregate("INSTANCE").Aggregate, + "ID", + "test", + "endpoint", + ), + ), + eventFromEventPusher( + instance.NewSMTPConfigActivatedEvent( + context.Background(), + &instance.NewAggregate("INSTANCE").Aggregate, + "ID", + ), + ), + ), + expectPush( + instance.NewSMTPConfigDeactivatedEvent( + context.Background(), + &instance.NewAggregate("INSTANCE").Aggregate, + "ID", + ), + ), + ), + }, + args: args{ + id: "ID", + instanceID: "INSTANCE", }, res: res{ want: &domain.ObjectDetails{ @@ -1123,10 +1544,10 @@ func TestCommandSide_DeactivateSMTPConfig(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { r := &Commands{ - eventstore: tt.fields.eventstore, + eventstore: tt.fields.eventstore(t), smtpEncryption: tt.fields.alg, } - got, err := r.DeactivateSMTPConfig(tt.args.ctx, tt.args.instanceID, tt.args.id) + got, err := r.DeactivateSMTPConfig(context.Background(), tt.args.instanceID, tt.args.id) if tt.res.err == nil { assert.NoError(t, err) } @@ -1142,11 +1563,10 @@ func TestCommandSide_DeactivateSMTPConfig(t *testing.T) { func TestCommandSide_RemoveSMTPConfig(t *testing.T) { type fields struct { - eventstore *eventstore.Eventstore + eventstore func(t *testing.T) *eventstore.Eventstore alg crypto.EncryptionAlgorithm } type args struct { - ctx context.Context instanceID string id string } @@ -1160,27 +1580,55 @@ func TestCommandSide_RemoveSMTPConfig(t *testing.T) { args args res res }{ + { + name: "resourceowner empty, invalid argument error", + fields: fields{ + eventstore: expectEventstore(), + }, + args: args{ + id: "ID", + }, + res: res{ + err: func(err error) bool { + return errors.Is(err, zerrors.ThrowInvalidArgument(nil, "COMMAND-t2WsPRgGaK", "Errors.ResourceOwnerMissing")) + }, + }, + }, + { + name: "id empty, invalid argument error", + fields: fields{ + eventstore: expectEventstore(), + }, + args: args{ + instanceID: "INSTANCE", + }, + res: res{ + err: func(err error) bool { + return errors.Is(err, zerrors.ThrowInvalidArgument(nil, "COMMAND-0ZV5whuUfu", "Errors.IDMissing")) + }, + }, + }, { name: "smtp config, error not found", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter(), ), }, args: args{ - ctx: context.Background(), - id: "ID", + instanceID: "INSTANCE", + id: "ID", }, res: res{ - err: zerrors.IsNotFound, + err: func(err error) bool { + return errors.Is(err, zerrors.ThrowNotFound(nil, "COMMAND-09CXlTDL6w", "Errors.SMTPConfig.NotFound")) + }, }, }, { name: "remove smtp config, ok", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher( instance.NewSMTPConfigAddedEvent( @@ -1208,7 +1656,40 @@ func TestCommandSide_RemoveSMTPConfig(t *testing.T) { ), }, args: args{ - ctx: authz.WithInstanceID(context.Background(), "INSTANCE"), + id: "ID", + instanceID: "INSTANCE", + }, + res: res{ + want: &domain.ObjectDetails{ + ResourceOwner: "INSTANCE", + }, + }, + }, + { + name: "remove smtp config http, ok", + fields: fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + instance.NewSMTPConfigHTTPAddedEvent( + context.Background(), + &instance.NewAggregate("INSTANCE").Aggregate, + "ID", + "test", + "endpoint", + ), + ), + ), + expectPush( + instance.NewSMTPConfigRemovedEvent( + context.Background(), + &instance.NewAggregate("INSTANCE").Aggregate, + "ID", + ), + ), + ), + }, + args: args{ id: "ID", instanceID: "INSTANCE", }, @@ -1222,10 +1703,10 @@ func TestCommandSide_RemoveSMTPConfig(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { r := &Commands{ - eventstore: tt.fields.eventstore, + eventstore: tt.fields.eventstore(t), smtpEncryption: tt.fields.alg, } - got, err := r.RemoveSMTPConfig(tt.args.ctx, tt.args.instanceID, tt.args.id) + got, err := r.RemoveSMTPConfig(context.Background(), tt.args.instanceID, tt.args.id) if tt.res.err == nil { assert.NoError(t, err) } @@ -1241,7 +1722,7 @@ func TestCommandSide_RemoveSMTPConfig(t *testing.T) { func TestCommandSide_TestSMTPConfig(t *testing.T) { type fields struct { - eventstore *eventstore.Eventstore + eventstore func(t *testing.T) *eventstore.Eventstore alg crypto.EncryptionAlgorithm } type args struct { @@ -1263,9 +1744,7 @@ func TestCommandSide_TestSMTPConfig(t *testing.T) { { name: "id empty, precondition error", fields: fields{ - eventstore: eventstoreExpect( - t, - ), + eventstore: expectEventstore(), }, args: args{ ctx: authz.WithInstanceID(context.Background(), "INSTANCE"), @@ -1277,9 +1756,7 @@ func TestCommandSide_TestSMTPConfig(t *testing.T) { { name: "email empty, precondition error", fields: fields{ - eventstore: eventstoreExpect( - t, - ), + eventstore: expectEventstore(), }, args: args{ ctx: authz.WithInstanceID(context.Background(), "INSTANCE"), @@ -1293,9 +1770,7 @@ func TestCommandSide_TestSMTPConfig(t *testing.T) { { name: "if password is empty, smtp id must not", fields: fields{ - eventstore: eventstoreExpect( - t, - ), + eventstore: expectEventstore(), }, args: args{ ctx: context.Background(), @@ -1319,8 +1794,7 @@ func TestCommandSide_TestSMTPConfig(t *testing.T) { { name: "password empty and smtp config not found, error", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter(), ), }, @@ -1346,10 +1820,8 @@ func TestCommandSide_TestSMTPConfig(t *testing.T) { { name: "valid new smtp config, wrong auth, ok", fields: fields{ - eventstore: eventstoreExpect( - t, - ), - alg: crypto.CreateMockEncryptionAlg(gomock.NewController(t)), + eventstore: expectEventstore(), + alg: crypto.CreateMockEncryptionAlg(gomock.NewController(t)), }, args: args{ ctx: context.Background(), @@ -1372,8 +1844,7 @@ func TestCommandSide_TestSMTPConfig(t *testing.T) { { name: "valid smtp config using stored password, wrong auth, ok", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher( instance.NewSMTPConfigAddedEvent( @@ -1422,7 +1893,7 @@ func TestCommandSide_TestSMTPConfig(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { r := &Commands{ - eventstore: tt.fields.eventstore, + eventstore: tt.fields.eventstore(t), smtpEncryption: tt.fields.alg, } err := r.TestSMTPConfig(tt.args.ctx, tt.args.instanceID, tt.args.id, tt.args.email, &tt.args.config) @@ -1579,3 +2050,16 @@ func newSMTPConfigChangedEvent(ctx context.Context, id, description string, tls ) return event } + +func newSMTPConfigHTTPChangedEvent(ctx context.Context, id, description, endpoint string) *instance.SMTPConfigHTTPChangedEvent { + changes := []instance.SMTPConfigHTTPChanges{ + instance.ChangeSMTPConfigHTTPDescription(description), + instance.ChangeSMTPConfigHTTPEndpoint(endpoint), + } + event, _ := instance.NewSMTPConfigHTTPChangeEvent(ctx, + &instance.NewAggregate("INSTANCE").Aggregate, + id, + changes, + ) + return event +} diff --git a/internal/notification/channels.go b/internal/notification/channels.go index 0511aaf78b..c70eaecbcc 100644 --- a/internal/notification/channels.go +++ b/internal/notification/channels.go @@ -5,8 +5,8 @@ import ( "github.com/zitadel/logging" + "github.com/zitadel/zitadel/internal/notification/channels/email" "github.com/zitadel/zitadel/internal/notification/channels/sms" - "github.com/zitadel/zitadel/internal/notification/channels/smtp" "github.com/zitadel/zitadel/internal/notification/channels/webhook" "github.com/zitadel/zitadel/internal/notification/handlers" "github.com/zitadel/zitadel/internal/notification/senders" @@ -62,20 +62,20 @@ func registerCounter(counter, desc string) { logging.WithFields("metric", counter).OnError(err).Panic("unable to register counter") } -func (c *channels) Email(ctx context.Context) (*senders.Chain, *smtp.Config, error) { - smtpCfg, err := c.q.GetSMTPConfig(ctx) +func (c *channels) Email(ctx context.Context) (*senders.Chain, *email.Config, error) { + emailCfg, err := c.q.GetActiveEmailConfig(ctx) if err != nil { return nil, nil, err } chain, err := senders.EmailChannels( ctx, - smtpCfg, + emailCfg, c.q.GetFileSystemProvider, c.q.GetLogProvider, c.counters.success.email, c.counters.failed.email, ) - return chain, smtpCfg, err + return chain, emailCfg, err } func (c *channels) SMS(ctx context.Context) (*senders.Chain, *sms.Config, error) { diff --git a/internal/notification/channels/email/config.go b/internal/notification/channels/email/config.go new file mode 100644 index 0000000000..d06029f8c2 --- /dev/null +++ b/internal/notification/channels/email/config.go @@ -0,0 +1,17 @@ +package email + +import ( + "github.com/zitadel/zitadel/internal/notification/channels/smtp" + "github.com/zitadel/zitadel/internal/notification/channels/webhook" +) + +type Config struct { + ProviderConfig *Provider + SMTPConfig *smtp.Config + WebhookConfig *webhook.Config +} + +type Provider struct { + ID string `json:"id,omitempty"` + Description string `json:"description,omitempty"` +} diff --git a/internal/notification/channels/smtp/config.go b/internal/notification/channels/smtp/config.go index 865a2f4cd1..d8323ae574 100644 --- a/internal/notification/channels/smtp/config.go +++ b/internal/notification/channels/smtp/config.go @@ -1,7 +1,6 @@ package smtp type Config struct { - Description string SMTP SMTP Tls bool From string @@ -18,3 +17,7 @@ type SMTP struct { func (smtp *SMTP) HasAuth() bool { return smtp.User != "" && smtp.Password != "" } + +type ConfigHTTP struct { + Endpoint string +} diff --git a/internal/notification/handlers/config_email.go b/internal/notification/handlers/config_email.go new file mode 100644 index 0000000000..b78540a423 --- /dev/null +++ b/internal/notification/handlers/config_email.go @@ -0,0 +1,56 @@ +package handlers + +import ( + "context" + "net/http" + + "github.com/zitadel/zitadel/internal/api/authz" + "github.com/zitadel/zitadel/internal/crypto" + "github.com/zitadel/zitadel/internal/notification/channels/email" + "github.com/zitadel/zitadel/internal/notification/channels/smtp" + "github.com/zitadel/zitadel/internal/notification/channels/webhook" + "github.com/zitadel/zitadel/internal/zerrors" +) + +// GetSMTPConfig reads the iam SMTP provider config +func (n *NotificationQueries) GetActiveEmailConfig(ctx context.Context) (*email.Config, error) { + config, err := n.SMTPConfigActive(ctx, authz.GetInstance(ctx).InstanceID()) + if err != nil { + return nil, err + } + provider := &email.Provider{ + ID: config.ID, + Description: config.Description, + } + if config.SMTPConfig != nil { + password, err := crypto.DecryptString(config.SMTPConfig.Password, n.SMTPPasswordCrypto) + if err != nil { + return nil, err + } + return &email.Config{ + ProviderConfig: provider, + SMTPConfig: &smtp.Config{ + From: config.SMTPConfig.SenderAddress, + FromName: config.SMTPConfig.SenderName, + ReplyToAddress: config.SMTPConfig.ReplyToAddress, + Tls: config.SMTPConfig.TLS, + SMTP: smtp.SMTP{ + Host: config.SMTPConfig.Host, + User: config.SMTPConfig.User, + Password: password, + }, + }, + }, nil + } + if config.HTTPConfig != nil { + return &email.Config{ + ProviderConfig: provider, + WebhookConfig: &webhook.Config{ + CallURL: config.HTTPConfig.Endpoint, + Method: http.MethodPost, + Headers: nil, + }, + }, nil + } + return nil, zerrors.ThrowNotFound(err, "QUERY-KPQleOckOV", "Errors.SMTPConfig.NotFound") +} diff --git a/internal/notification/handlers/config_smtp.go b/internal/notification/handlers/config_smtp.go deleted file mode 100644 index 8cce47dec6..0000000000 --- a/internal/notification/handlers/config_smtp.go +++ /dev/null @@ -1,33 +0,0 @@ -package handlers - -import ( - "context" - - "github.com/zitadel/zitadel/internal/api/authz" - "github.com/zitadel/zitadel/internal/crypto" - "github.com/zitadel/zitadel/internal/notification/channels/smtp" -) - -// GetSMTPConfig reads the iam SMTP provider config -func (n *NotificationQueries) GetSMTPConfig(ctx context.Context) (*smtp.Config, error) { - config, err := n.SMTPConfigActive(ctx, authz.GetInstance(ctx).InstanceID()) - if err != nil { - return nil, err - } - password, err := crypto.DecryptString(config.Password, n.SMTPPasswordCrypto) - if err != nil { - return nil, err - } - return &smtp.Config{ - Description: config.Description, - From: config.SenderAddress, - FromName: config.SenderName, - ReplyToAddress: config.ReplyToAddress, - Tls: config.TLS, - SMTP: smtp.SMTP{ - Host: config.Host, - User: config.User, - Password: password, - }, - }, nil -} diff --git a/internal/notification/handlers/user_notifier_test.go b/internal/notification/handlers/user_notifier_test.go index 60fa1791dd..c18ddc1df3 100644 --- a/internal/notification/handlers/user_notifier_test.go +++ b/internal/notification/handlers/user_notifier_test.go @@ -15,6 +15,7 @@ import ( "github.com/zitadel/zitadel/internal/eventstore" "github.com/zitadel/zitadel/internal/eventstore/repository" es_repo_mock "github.com/zitadel/zitadel/internal/eventstore/repository/mock" + "github.com/zitadel/zitadel/internal/notification/channels/email" channel_mock "github.com/zitadel/zitadel/internal/notification/channels/mock" "github.com/zitadel/zitadel/internal/notification/channels/sms" "github.com/zitadel/zitadel/internal/notification/channels/smtp" @@ -1449,7 +1450,27 @@ func newUserNotifier(t *testing.T, ctrl *gomock.Controller, queries *mock.MockQu f.SMSTokenCrypto, ), otpEmailTmpl: defaultOTPEmailTemplate, - channels: &channels{Chain: *senders.ChainChannels(channel)}, + channels: &channels{ + Chain: *senders.ChainChannels(channel), + EmailConfig: &email.Config{ + ProviderConfig: &email.Provider{ + ID: "ID", + Description: "Description", + }, + SMTPConfig: &smtp.Config{ + SMTP: smtp.SMTP{ + Host: "host", + User: "user", + Password: "password", + }, + Tls: true, + From: "from", + FromName: "fromName", + ReplyToAddress: "replyToAddress", + }, + WebhookConfig: nil, + }, + }, } } @@ -1457,10 +1478,11 @@ var _ types.ChannelChains = (*channels)(nil) type channels struct { senders.Chain + EmailConfig *email.Config } -func (c *channels) Email(context.Context) (*senders.Chain, *smtp.Config, error) { - return &c.Chain, nil, nil +func (c *channels) Email(context.Context) (*senders.Chain, *email.Config, error) { + return &c.Chain, c.EmailConfig, nil } func (c *channels) SMS(context.Context) (*senders.Chain, *sms.Config, error) { diff --git a/internal/notification/senders/email.go b/internal/notification/senders/email.go index ea93c0911e..4dfc815919 100644 --- a/internal/notification/senders/email.go +++ b/internal/notification/senders/email.go @@ -7,38 +7,61 @@ import ( "github.com/zitadel/zitadel/internal/api/authz" "github.com/zitadel/zitadel/internal/notification/channels" + "github.com/zitadel/zitadel/internal/notification/channels/email" "github.com/zitadel/zitadel/internal/notification/channels/fs" "github.com/zitadel/zitadel/internal/notification/channels/instrumenting" "github.com/zitadel/zitadel/internal/notification/channels/log" "github.com/zitadel/zitadel/internal/notification/channels/smtp" + "github.com/zitadel/zitadel/internal/notification/channels/webhook" ) const smtpSpanName = "smtp.NotificationChannel" func EmailChannels( ctx context.Context, - emailConfig *smtp.Config, + emailConfig *email.Config, getFileSystemProvider func(ctx context.Context) (*fs.Config, error), getLogProvider func(ctx context.Context) (*log.Config, error), successMetricName, failureMetricName string, ) (chain *Chain, err error) { channels := make([]channels.NotificationChannel, 0, 3) - p, err := smtp.InitChannel(emailConfig) - logging.WithFields( - "instance", authz.GetInstance(ctx).InstanceID(), - ).OnError(err).Debug("initializing SMTP channel failed") - if err == nil { - channels = append( - channels, - instrumenting.Wrap( - ctx, - p, - smtpSpanName, - successMetricName, - failureMetricName, - ), - ) + if emailConfig.SMTPConfig != nil { + p, err := smtp.InitChannel(emailConfig.SMTPConfig) + logging.WithFields( + "instance", authz.GetInstance(ctx).InstanceID(), + ).OnError(err).Debug("initializing SMTP channel failed") + if err == nil { + channels = append( + channels, + instrumenting.Wrap( + ctx, + p, + smtpSpanName, + successMetricName, + failureMetricName, + ), + ) + } + } + if emailConfig.WebhookConfig != nil { + webhookChannel, err := webhook.InitChannel(ctx, *emailConfig.WebhookConfig) + logging.WithFields( + "instance", authz.GetInstance(ctx).InstanceID(), + "callurl", emailConfig.WebhookConfig.CallURL, + ).OnError(err).Debug("initializing JSON channel failed") + if err == nil { + channels = append( + channels, + instrumenting.Wrap( + ctx, + webhookChannel, + webhookSpanName, + successMetricName, + failureMetricName, + ), + ) + } } channels = append(channels, debugChannels(ctx, getFileSystemProvider, getLogProvider)...) return ChainChannels(channels...), nil diff --git a/internal/notification/types/notification.go b/internal/notification/types/notification.go index 4d72d911f9..8d1b013164 100644 --- a/internal/notification/types/notification.go +++ b/internal/notification/types/notification.go @@ -7,8 +7,8 @@ import ( "github.com/zitadel/zitadel/internal/database" "github.com/zitadel/zitadel/internal/eventstore" "github.com/zitadel/zitadel/internal/i18n" + "github.com/zitadel/zitadel/internal/notification/channels/email" "github.com/zitadel/zitadel/internal/notification/channels/sms" - "github.com/zitadel/zitadel/internal/notification/channels/smtp" "github.com/zitadel/zitadel/internal/notification/channels/webhook" "github.com/zitadel/zitadel/internal/notification/senders" "github.com/zitadel/zitadel/internal/notification/templates" @@ -23,7 +23,7 @@ type Notify func( ) error type ChannelChains interface { - Email(context.Context) (*senders.Chain, *smtp.Config, error) + Email(context.Context) (*senders.Chain, *email.Config, error) SMS(context.Context) (*senders.Chain, *sms.Config, error) Webhook(context.Context, webhook.Config) (*senders.Chain, error) } @@ -54,8 +54,9 @@ func SendEmail( ctx, channels, user, - data.Subject, template, + data, + args, allowUnverifiedNotificationChannel, triggeringEvent, ) diff --git a/internal/notification/types/user_email.go b/internal/notification/types/user_email.go index d3c3bfdd4a..210ca14cf8 100644 --- a/internal/notification/types/user_email.go +++ b/internal/notification/types/user_email.go @@ -3,9 +3,13 @@ package types import ( "context" "html" + "strings" + + "github.com/zitadel/logging" "github.com/zitadel/zitadel/internal/eventstore" "github.com/zitadel/zitadel/internal/notification/messages" + "github.com/zitadel/zitadel/internal/notification/templates" "github.com/zitadel/zitadel/internal/query" "github.com/zitadel/zitadel/internal/zerrors" ) @@ -14,29 +18,56 @@ func generateEmail( ctx context.Context, channels ChannelChains, user *query.NotifyUser, - subject, - content string, + template string, + data templates.TemplateData, + args map[string]interface{}, lastEmail bool, triggeringEvent eventstore.Event, ) error { - content = html.UnescapeString(content) - message := &messages.Email{ - Recipients: []string{user.VerifiedEmail}, - Subject: subject, - Content: content, - TriggeringEvent: triggeringEvent, - } - if lastEmail { - message.Recipients = []string{user.LastEmail} - } - emailChannels, _, err := channels.Email(ctx) - if err != nil { - return err - } + emailChannels, config, err := channels.Email(ctx) + logging.OnError(err).Error("could not create email channel") if emailChannels == nil || emailChannels.Len() == 0 { - return zerrors.ThrowPreconditionFailed(nil, "MAIL-83nof", "Errors.Notification.Channels.NotPresent") + return zerrors.ThrowPreconditionFailed(nil, "PHONE-w8nfow", "Errors.Notification.Channels.NotPresent") } - return emailChannels.HandleMessage(message) + recipient := user.VerifiedEmail + if lastEmail { + recipient = user.LastEmail + } + if config.SMTPConfig != nil { + message := &messages.Email{ + Recipients: []string{recipient}, + Subject: data.Subject, + Content: html.UnescapeString(template), + TriggeringEvent: triggeringEvent, + } + return emailChannels.HandleMessage(message) + } + if config.WebhookConfig != nil { + caseArgs := make(map[string]interface{}, len(args)) + for k, v := range args { + caseArgs[strings.ToLower(string(k[0]))+k[1:]] = v + } + contextInfo := map[string]interface{}{ + "recipientEmailAddress": recipient, + "eventType": triggeringEvent.Type(), + "provider": config.ProviderConfig, + } + + message := &messages.JSON{ + Serializable: &serializableData{ + ContextInfo: contextInfo, + TemplateData: data, + Args: caseArgs, + }, + TriggeringEvent: triggeringEvent, + } + webhookChannels, err := channels.Webhook(ctx, *config.WebhookConfig) + if err != nil { + return err + } + return webhookChannels.HandleMessage(message) + } + return zerrors.ThrowPreconditionFailed(nil, "MAIL-83nof", "Errors.Notification.Channels.NotPresent") } func mapNotifyUserToArgs(user *query.NotifyUser, args map[string]interface{}) map[string]interface{} { diff --git a/internal/query/projection/smtp.go b/internal/query/projection/smtp.go index 9b53da4150..dd1d9c9c09 100644 --- a/internal/query/projection/smtp.go +++ b/internal/query/projection/smtp.go @@ -8,26 +8,38 @@ import ( old_handler "github.com/zitadel/zitadel/internal/eventstore/handler" "github.com/zitadel/zitadel/internal/eventstore/handler/v2" "github.com/zitadel/zitadel/internal/repository/instance" - "github.com/zitadel/zitadel/internal/zerrors" ) const ( - SMTPConfigProjectionTable = "projections.smtp_configs2" - SMTPConfigColumnInstanceID = "instance_id" - SMTPConfigColumnResourceOwner = "resource_owner" - SMTPConfigColumnID = "id" - SMTPConfigColumnCreationDate = "creation_date" - SMTPConfigColumnChangeDate = "change_date" - SMTPConfigColumnSequence = "sequence" - SMTPConfigColumnTLS = "tls" - SMTPConfigColumnSenderAddress = "sender_address" - SMTPConfigColumnSenderName = "sender_name" - SMTPConfigColumnReplyToAddress = "reply_to_address" - SMTPConfigColumnSMTPHost = "host" - SMTPConfigColumnSMTPUser = "username" - SMTPConfigColumnSMTPPassword = "password" - SMTPConfigColumnState = "state" - SMTPConfigColumnDescription = "description" + SMTPConfigProjectionTable = "projections.smtp_configs3" + SMTPConfigTable = SMTPConfigProjectionTable + "_" + smtpConfigSMTPTableSuffix + SMTPConfigHTTPTable = SMTPConfigProjectionTable + "_" + smtpConfigHTTPTableSuffix + + SMTPConfigColumnInstanceID = "instance_id" + SMTPConfigColumnResourceOwner = "resource_owner" + SMTPConfigColumnAggregateID = "aggregate_id" + SMTPConfigColumnID = "id" + SMTPConfigColumnCreationDate = "creation_date" + SMTPConfigColumnChangeDate = "change_date" + SMTPConfigColumnSequence = "sequence" + SMTPConfigColumnState = "state" + SMTPConfigColumnDescription = "description" + + smtpConfigSMTPTableSuffix = "smtp" + SMTPConfigSMTPColumnInstanceID = "instance_id" + SMTPConfigSMTPColumnID = "id" + SMTPConfigSMTPColumnTLS = "tls" + SMTPConfigSMTPColumnSenderAddress = "sender_address" + SMTPConfigSMTPColumnSenderName = "sender_name" + SMTPConfigSMTPColumnReplyToAddress = "reply_to_address" + SMTPConfigSMTPColumnHost = "host" + SMTPConfigSMTPColumnUser = "username" + SMTPConfigSMTPColumnPassword = "password" + + smtpConfigHTTPTableSuffix = "http" + SMTPConfigHTTPColumnInstanceID = "instance_id" + SMTPConfigHTTPColumnID = "id" + SMTPConfigHTTPColumnEndpoint = "endpoint" ) type smtpConfigProjection struct{} @@ -41,25 +53,43 @@ func (*smtpConfigProjection) Name() string { } func (*smtpConfigProjection) Init() *old_handler.Check { - return handler.NewTableCheck( + return handler.NewMultiTableCheck( handler.NewTable([]*handler.InitColumn{ handler.NewColumn(SMTPConfigColumnID, handler.ColumnTypeText), + handler.NewColumn(SMTPConfigColumnAggregateID, handler.ColumnTypeText), handler.NewColumn(SMTPConfigColumnCreationDate, handler.ColumnTypeTimestamp), handler.NewColumn(SMTPConfigColumnChangeDate, handler.ColumnTypeTimestamp), handler.NewColumn(SMTPConfigColumnSequence, handler.ColumnTypeInt64), handler.NewColumn(SMTPConfigColumnResourceOwner, handler.ColumnTypeText), handler.NewColumn(SMTPConfigColumnInstanceID, handler.ColumnTypeText), - handler.NewColumn(SMTPConfigColumnTLS, handler.ColumnTypeBool), - handler.NewColumn(SMTPConfigColumnSenderAddress, handler.ColumnTypeText), - handler.NewColumn(SMTPConfigColumnSenderName, handler.ColumnTypeText), - handler.NewColumn(SMTPConfigColumnReplyToAddress, handler.ColumnTypeText), - handler.NewColumn(SMTPConfigColumnSMTPHost, handler.ColumnTypeText), - handler.NewColumn(SMTPConfigColumnSMTPUser, handler.ColumnTypeText), - handler.NewColumn(SMTPConfigColumnSMTPPassword, handler.ColumnTypeJSONB, handler.Nullable()), - handler.NewColumn(SMTPConfigColumnState, handler.ColumnTypeEnum), handler.NewColumn(SMTPConfigColumnDescription, handler.ColumnTypeText), + handler.NewColumn(SMTPConfigColumnState, handler.ColumnTypeEnum), }, - handler.NewPrimaryKey(SMTPConfigColumnInstanceID, SMTPConfigColumnResourceOwner, SMTPConfigColumnID), + handler.NewPrimaryKey(SMTPConfigColumnInstanceID, SMTPConfigColumnID), + ), + handler.NewSuffixedTable([]*handler.InitColumn{ + handler.NewColumn(SMTPConfigSMTPColumnID, handler.ColumnTypeText), + handler.NewColumn(SMTPConfigSMTPColumnInstanceID, handler.ColumnTypeText), + handler.NewColumn(SMTPConfigSMTPColumnTLS, handler.ColumnTypeBool), + handler.NewColumn(SMTPConfigSMTPColumnSenderAddress, handler.ColumnTypeText), + handler.NewColumn(SMTPConfigSMTPColumnSenderName, handler.ColumnTypeText), + handler.NewColumn(SMTPConfigSMTPColumnReplyToAddress, handler.ColumnTypeText), + handler.NewColumn(SMTPConfigSMTPColumnHost, handler.ColumnTypeText), + handler.NewColumn(SMTPConfigSMTPColumnUser, handler.ColumnTypeText), + handler.NewColumn(SMTPConfigSMTPColumnPassword, handler.ColumnTypeJSONB, handler.Nullable()), + }, + handler.NewPrimaryKey(SMTPConfigSMTPColumnInstanceID, SMTPConfigSMTPColumnID), + smtpConfigSMTPTableSuffix, + handler.WithForeignKey(handler.NewForeignKeyOfPublicKeys()), + ), + handler.NewSuffixedTable([]*handler.InitColumn{ + handler.NewColumn(SMTPConfigHTTPColumnID, handler.ColumnTypeText), + handler.NewColumn(SMTPConfigHTTPColumnInstanceID, handler.ColumnTypeText), + handler.NewColumn(SMTPConfigHTTPColumnEndpoint, handler.ColumnTypeText), + }, + handler.NewPrimaryKey(SMTPConfigHTTPColumnInstanceID, SMTPConfigHTTPColumnID), + smtpConfigHTTPTableSuffix, + handler.WithForeignKey(handler.NewForeignKeyOfPublicKeys()), ), ) } @@ -81,6 +111,14 @@ func (p *smtpConfigProjection) Reducers() []handler.AggregateReducer { Event: instance.SMTPConfigPasswordChangedEventType, Reduce: p.reduceSMTPConfigPasswordChanged, }, + { + Event: instance.SMTPConfigHTTPAddedEventType, + Reduce: p.reduceSMTPConfigHTTPAdded, + }, + { + Event: instance.SMTPConfigHTTPChangedEventType, + Reduce: p.reduceSMTPConfigHTTPChanged, + }, { Event: instance.SMTPConfigActivatedEventType, Reduce: p.reduceSMTPConfigActivated, @@ -103,9 +141,9 @@ func (p *smtpConfigProjection) Reducers() []handler.AggregateReducer { } func (p *smtpConfigProjection) reduceSMTPConfigAdded(event eventstore.Event) (*handler.Statement, error) { - e, ok := event.(*instance.SMTPConfigAddedEvent) - if !ok { - return nil, zerrors.ThrowInvalidArgumentf(nil, "HANDL-sk99F", "reduce.wrong.event.type %s", instance.SMTPConfigAddedEventType) + e, err := assertEvent[*instance.SMTPConfigAddedEvent](event) + if err != nil { + return nil, err } // Deal with old and unique SMTP settings (empty ID) @@ -118,83 +156,116 @@ func (p *smtpConfigProjection) reduceSMTPConfigAdded(event eventstore.Event) (*h state = domain.SMTPConfigStateActive } - return handler.NewCreateStatement( + return handler.NewMultiStatement( e, - []handler.Column{ - handler.NewCol(SMTPConfigColumnCreationDate, e.CreationDate()), - handler.NewCol(SMTPConfigColumnChangeDate, e.CreationDate()), - handler.NewCol(SMTPConfigColumnResourceOwner, e.Aggregate().ResourceOwner), - handler.NewCol(SMTPConfigColumnInstanceID, e.Aggregate().InstanceID), - handler.NewCol(SMTPConfigColumnSequence, e.Sequence()), - handler.NewCol(SMTPConfigColumnID, id), - handler.NewCol(SMTPConfigColumnTLS, e.TLS), - handler.NewCol(SMTPConfigColumnSenderAddress, e.SenderAddress), - handler.NewCol(SMTPConfigColumnSenderName, e.SenderName), - handler.NewCol(SMTPConfigColumnReplyToAddress, e.ReplyToAddress), - handler.NewCol(SMTPConfigColumnSMTPHost, e.Host), - handler.NewCol(SMTPConfigColumnSMTPUser, e.User), - handler.NewCol(SMTPConfigColumnSMTPPassword, e.Password), - handler.NewCol(SMTPConfigColumnState, state), - handler.NewCol(SMTPConfigColumnDescription, description), - }, + handler.AddCreateStatement( + []handler.Column{ + handler.NewCol(SMTPConfigColumnCreationDate, e.CreationDate()), + handler.NewCol(SMTPConfigColumnChangeDate, e.CreationDate()), + handler.NewCol(SMTPConfigColumnInstanceID, e.Aggregate().InstanceID), + handler.NewCol(SMTPConfigColumnResourceOwner, e.Aggregate().ResourceOwner), + handler.NewCol(SMTPConfigColumnAggregateID, e.Aggregate().ID), + handler.NewCol(SMTPConfigColumnID, id), + handler.NewCol(SMTPConfigColumnSequence, e.Sequence()), + handler.NewCol(SMTPConfigColumnState, state), + handler.NewCol(SMTPConfigColumnDescription, description), + }, + ), + handler.AddCreateStatement( + []handler.Column{ + handler.NewCol(SMTPConfigSMTPColumnInstanceID, e.Aggregate().InstanceID), + handler.NewCol(SMTPConfigSMTPColumnID, e.ID), + handler.NewCol(SMTPConfigSMTPColumnTLS, e.TLS), + handler.NewCol(SMTPConfigSMTPColumnSenderAddress, e.SenderAddress), + handler.NewCol(SMTPConfigSMTPColumnSenderName, e.SenderName), + handler.NewCol(SMTPConfigSMTPColumnReplyToAddress, e.ReplyToAddress), + handler.NewCol(SMTPConfigSMTPColumnHost, e.Host), + handler.NewCol(SMTPConfigSMTPColumnUser, e.User), + handler.NewCol(SMTPConfigSMTPColumnPassword, e.Password), + }, + handler.WithTableSuffix(smtpConfigSMTPTableSuffix), + ), ), nil } -func (p *smtpConfigProjection) reduceSMTPConfigChanged(event eventstore.Event) (*handler.Statement, error) { - e, ok := event.(*instance.SMTPConfigChangedEvent) - if !ok { - return nil, zerrors.ThrowInvalidArgumentf(nil, "HANDL-wl0wd", "reduce.wrong.event.type %s", instance.SMTPConfigChangedEventType) +func (p *smtpConfigProjection) reduceSMTPConfigHTTPAdded(event eventstore.Event) (*handler.Statement, error) { + e, err := assertEvent[*instance.SMTPConfigHTTPAddedEvent](event) + if err != nil { + return nil, err } - columns := make([]handler.Column, 0, 8) - columns = append(columns, handler.NewCol(SMTPConfigColumnChangeDate, e.CreationDate()), - handler.NewCol(SMTPConfigColumnSequence, e.Sequence())) + return handler.NewMultiStatement( + e, + handler.AddCreateStatement( + []handler.Column{ + handler.NewCol(SMTPConfigColumnCreationDate, e.CreationDate()), + handler.NewCol(SMTPConfigColumnChangeDate, e.CreationDate()), + handler.NewCol(SMTPConfigColumnInstanceID, e.Aggregate().InstanceID), + handler.NewCol(SMTPConfigColumnResourceOwner, e.Aggregate().ResourceOwner), + handler.NewCol(SMTPConfigColumnAggregateID, e.Aggregate().ID), + handler.NewCol(SMTPConfigColumnID, e.ID), + handler.NewCol(SMTPConfigColumnSequence, e.Sequence()), + handler.NewCol(SMTPConfigColumnState, domain.SMTPConfigStateInactive), + handler.NewCol(SMTPConfigColumnDescription, e.Description), + }, + ), + handler.AddCreateStatement( + []handler.Column{ + handler.NewCol(SMTPConfigSMTPColumnInstanceID, e.Aggregate().InstanceID), + handler.NewCol(SMTPConfigSMTPColumnID, e.ID), + handler.NewCol(SMTPConfigHTTPColumnEndpoint, e.Endpoint), + }, + handler.WithTableSuffix(smtpConfigHTTPTableSuffix), + ), + ), nil +} - // Deal with old and unique SMTP settings (empty ID) - id := e.ID - if e.ID == "" { - id = e.Aggregate().ResourceOwner +func (p *smtpConfigProjection) reduceSMTPConfigHTTPChanged(event eventstore.Event) (*handler.Statement, error) { + e, err := assertEvent[*instance.SMTPConfigHTTPChangedEvent](event) + if err != nil { + return nil, err } - if e.TLS != nil { - columns = append(columns, handler.NewCol(SMTPConfigColumnTLS, *e.TLS)) - } - if e.FromAddress != nil { - columns = append(columns, handler.NewCol(SMTPConfigColumnSenderAddress, *e.FromAddress)) - } - if e.FromName != nil { - columns = append(columns, handler.NewCol(SMTPConfigColumnSenderName, *e.FromName)) - } - if e.ReplyToAddress != nil { - columns = append(columns, handler.NewCol(SMTPConfigColumnReplyToAddress, *e.ReplyToAddress)) - } - if e.Host != nil { - columns = append(columns, handler.NewCol(SMTPConfigColumnSMTPHost, *e.Host)) - } - if e.User != nil { - columns = append(columns, handler.NewCol(SMTPConfigColumnSMTPUser, *e.User)) - } - if e.Password != nil { - columns = append(columns, handler.NewCol(SMTPConfigColumnSMTPPassword, *e.Password)) + stmts := make([]func(eventstore.Event) handler.Exec, 0, 3) + columns := []handler.Column{ + handler.NewCol(SMTPConfigColumnChangeDate, e.CreationDate()), + handler.NewCol(SMTPConfigColumnSequence, e.Sequence()), } if e.Description != nil { columns = append(columns, handler.NewCol(SMTPConfigColumnDescription, *e.Description)) } - return handler.NewUpdateStatement( - e, - columns, - []handler.Condition{ - handler.NewCond(SMTPConfigColumnID, id), - handler.NewCond(SMTPConfigColumnResourceOwner, e.Aggregate().ResourceOwner), - handler.NewCond(SMTPConfigColumnInstanceID, e.Aggregate().InstanceID), - }, - ), nil + if len(columns) > 0 { + stmts = append(stmts, handler.AddUpdateStatement( + columns, + []handler.Condition{ + handler.NewCond(SMTPConfigColumnID, e.ID), + handler.NewCond(SMTPConfigColumnInstanceID, e.Aggregate().InstanceID), + }, + )) + } + + smtpColumns := make([]handler.Column, 0, 1) + if e.Endpoint != nil { + smtpColumns = append(smtpColumns, handler.NewCol(SMTPConfigHTTPColumnEndpoint, *e.Endpoint)) + } + if len(smtpColumns) > 0 { + stmts = append(stmts, handler.AddUpdateStatement( + smtpColumns, + []handler.Condition{ + handler.NewCond(SMTPConfigHTTPColumnID, e.ID), + handler.NewCond(SMTPConfigHTTPColumnInstanceID, e.Aggregate().InstanceID), + }, + handler.WithTableSuffix(smtpConfigHTTPTableSuffix), + )) + } + + return handler.NewMultiStatement(e, stmts...), nil } -func (p *smtpConfigProjection) reduceSMTPConfigPasswordChanged(event eventstore.Event) (*handler.Statement, error) { - e, ok := event.(*instance.SMTPConfigPasswordChangedEvent) - if !ok { - return nil, zerrors.ThrowInvalidArgumentf(nil, "HANDL-fk02f", "reduce.wrong.event.type %s", instance.SMTPConfigChangedEventType) +func (p *smtpConfigProjection) reduceSMTPConfigChanged(event eventstore.Event) (*handler.Statement, error) { + e, err := assertEvent[*instance.SMTPConfigChangedEvent](event) + if err != nil { + return nil, err } // Deal with old and unique SMTP settings (empty ID) @@ -203,25 +274,101 @@ func (p *smtpConfigProjection) reduceSMTPConfigPasswordChanged(event eventstore. id = e.Aggregate().ResourceOwner } - return handler.NewUpdateStatement( + stmts := make([]func(eventstore.Event) handler.Exec, 0, 3) + columns := []handler.Column{ + handler.NewCol(SMTPConfigColumnChangeDate, e.CreationDate()), + handler.NewCol(SMTPConfigColumnSequence, e.Sequence()), + } + if e.Description != nil { + columns = append(columns, handler.NewCol(SMTPConfigColumnDescription, *e.Description)) + } + if len(columns) > 0 { + stmts = append(stmts, handler.AddUpdateStatement( + columns, + []handler.Condition{ + handler.NewCond(SMTPConfigColumnID, id), + handler.NewCond(SMTPConfigColumnInstanceID, e.Aggregate().InstanceID), + }, + )) + } + + httpColumns := make([]handler.Column, 0, 7) + if e.TLS != nil { + httpColumns = append(httpColumns, handler.NewCol(SMTPConfigSMTPColumnTLS, *e.TLS)) + } + if e.FromAddress != nil { + httpColumns = append(httpColumns, handler.NewCol(SMTPConfigSMTPColumnSenderAddress, *e.FromAddress)) + } + if e.FromName != nil { + httpColumns = append(httpColumns, handler.NewCol(SMTPConfigSMTPColumnSenderName, *e.FromName)) + } + if e.ReplyToAddress != nil { + httpColumns = append(httpColumns, handler.NewCol(SMTPConfigSMTPColumnReplyToAddress, *e.ReplyToAddress)) + } + if e.Host != nil { + httpColumns = append(httpColumns, handler.NewCol(SMTPConfigSMTPColumnHost, *e.Host)) + } + if e.User != nil { + httpColumns = append(httpColumns, handler.NewCol(SMTPConfigSMTPColumnUser, *e.User)) + } + if e.Password != nil { + httpColumns = append(httpColumns, handler.NewCol(SMTPConfigSMTPColumnPassword, *e.Password)) + } + if len(httpColumns) > 0 { + stmts = append(stmts, handler.AddUpdateStatement( + httpColumns, + []handler.Condition{ + handler.NewCond(SMTPConfigSMTPColumnID, e.ID), + handler.NewCond(SMTPConfigSMTPColumnInstanceID, e.Aggregate().InstanceID), + }, + handler.WithTableSuffix(smtpConfigSMTPTableSuffix), + )) + } + + return handler.NewMultiStatement(e, stmts...), nil +} + +func (p *smtpConfigProjection) reduceSMTPConfigPasswordChanged(event eventstore.Event) (*handler.Statement, error) { + e, err := assertEvent[*instance.SMTPConfigPasswordChangedEvent](event) + if err != nil { + return nil, err + } + + // Deal with old and unique SMTP settings (empty ID) + id := e.ID + if e.ID == "" { + id = e.Aggregate().ResourceOwner + } + + return handler.NewMultiStatement( e, - []handler.Column{ - handler.NewCol(SMTPConfigColumnChangeDate, e.CreationDate()), - handler.NewCol(SMTPConfigColumnSequence, e.Sequence()), - handler.NewCol(SMTPConfigColumnSMTPPassword, e.Password), - }, - []handler.Condition{ - handler.NewCond(SMTPConfigColumnID, id), - handler.NewCond(SMTPConfigColumnResourceOwner, e.Aggregate().ResourceOwner), - handler.NewCond(SMTPConfigColumnInstanceID, e.Aggregate().InstanceID), - }, + handler.AddUpdateStatement( + []handler.Column{ + handler.NewCol(SMTPConfigSMTPColumnPassword, e.Password), + }, + []handler.Condition{ + handler.NewCond(SMTPConfigColumnID, id), + handler.NewCond(SMTPConfigColumnInstanceID, e.Aggregate().InstanceID), + }, + handler.WithTableSuffix(smtpConfigSMTPTableSuffix), + ), + handler.AddUpdateStatement( + []handler.Column{ + handler.NewCol(SMTPConfigColumnChangeDate, e.CreationDate()), + handler.NewCol(SMTPConfigColumnSequence, e.Sequence()), + }, + []handler.Condition{ + handler.NewCond(SMTPConfigColumnID, id), + handler.NewCond(SMTPConfigColumnInstanceID, e.Aggregate().InstanceID), + }, + ), ), nil } func (p *smtpConfigProjection) reduceSMTPConfigActivated(event eventstore.Event) (*handler.Statement, error) { - e, ok := event.(*instance.SMTPConfigActivatedEvent) - if !ok { - return nil, zerrors.ThrowInvalidArgumentf(nil, "HANDL-fq92r", "reduce.wrong.event.type %s", instance.SMTPConfigActivatedEventType) + e, err := assertEvent[*instance.SMTPConfigActivatedEvent](event) + if err != nil { + return nil, err } // Deal with old and unique SMTP settings (empty ID) @@ -230,25 +377,38 @@ func (p *smtpConfigProjection) reduceSMTPConfigActivated(event eventstore.Event) id = e.Aggregate().ResourceOwner } - return handler.NewUpdateStatement( + return handler.NewMultiStatement( e, - []handler.Column{ - handler.NewCol(SMTPConfigColumnChangeDate, e.CreationDate()), - handler.NewCol(SMTPConfigColumnSequence, e.Sequence()), - handler.NewCol(SMTPConfigColumnState, domain.SMTPConfigStateActive), - }, - []handler.Condition{ - handler.NewCond(SMTPConfigColumnID, id), - handler.NewCond(SMTPConfigColumnResourceOwner, e.Aggregate().ResourceOwner), - handler.NewCond(SMTPConfigColumnInstanceID, e.Aggregate().InstanceID), - }, + handler.AddUpdateStatement( + []handler.Column{ + handler.NewCol(SMTPConfigColumnChangeDate, e.CreationDate()), + handler.NewCol(SMTPConfigColumnSequence, e.Sequence()), + handler.NewCol(SMTPConfigColumnState, domain.SMTPConfigStateInactive), + }, + []handler.Condition{ + handler.Not(handler.NewCond(SMTPConfigColumnID, e.ID)), + handler.NewCond(SMTPConfigColumnState, domain.SMTPConfigStateActive), + handler.NewCond(SMTPConfigColumnInstanceID, e.Aggregate().InstanceID), + }, + ), + handler.AddUpdateStatement( + []handler.Column{ + handler.NewCol(SMTPConfigColumnChangeDate, e.CreationDate()), + handler.NewCol(SMTPConfigColumnSequence, e.Sequence()), + handler.NewCol(SMTPConfigColumnState, domain.SMTPConfigStateActive), + }, + []handler.Condition{ + handler.NewCond(SMTPConfigColumnID, id), + handler.NewCond(SMTPConfigColumnInstanceID, e.Aggregate().InstanceID), + }, + ), ), nil } func (p *smtpConfigProjection) reduceSMTPConfigDeactivated(event eventstore.Event) (*handler.Statement, error) { - e, ok := event.(*instance.SMTPConfigDeactivatedEvent) - if !ok { - return nil, zerrors.ThrowInvalidArgumentf(nil, "HANDL-hv89j", "reduce.wrong.event.type %s", instance.SMTPConfigDeactivatedEventType) + e, err := assertEvent[*instance.SMTPConfigDeactivatedEvent](event) + if err != nil { + return nil, err } // Deal with old and unique SMTP settings (empty ID) @@ -266,7 +426,6 @@ func (p *smtpConfigProjection) reduceSMTPConfigDeactivated(event eventstore.Even }, []handler.Condition{ handler.NewCond(SMTPConfigColumnID, id), - handler.NewCond(SMTPConfigColumnResourceOwner, e.Aggregate().ResourceOwner), handler.NewCond(SMTPConfigColumnInstanceID, e.Aggregate().InstanceID), }, ), nil @@ -288,7 +447,6 @@ func (p *smtpConfigProjection) reduceSMTPConfigRemoved(event eventstore.Event) ( e, []handler.Condition{ handler.NewCond(SMTPConfigColumnID, id), - handler.NewCond(SMTPConfigColumnResourceOwner, e.Aggregate().ResourceOwner), handler.NewCond(SMTPConfigColumnInstanceID, e.Aggregate().InstanceID), }, ), nil diff --git a/internal/query/projection/smtp_test.go b/internal/query/projection/smtp_test.go index 4d7b5e4a99..135caade07 100644 --- a/internal/query/projection/smtp_test.go +++ b/internal/query/projection/smtp_test.go @@ -28,19 +28,20 @@ func TestSMTPConfigProjection_reduces(t *testing.T) { instance.SMTPConfigChangedEventType, instance.AggregateType, []byte(`{ + "instance_id": "instance-id", + "resource_owner": "ro-id", + "aggregate_id": "agg-id", + "id": "config-id", "description": "test", "tls": true, "senderAddress": "sender", "senderName": "name", "replyToAddress": "reply-to", "host": "host", - "user": "user", - "id": "44444", - "resource_owner": "ro-id", - "instance_id": "instance-id" + "user": "user" }`, ), - ), instance.SMTPConfigChangedEventMapper), + ), eventstore.GenericEventMapper[instance.SMTPConfigChangedEvent]), }, reduce: (&smtpConfigProjection{}).reduceSMTPConfigChanged, want: wantReduce{ @@ -49,19 +50,233 @@ func TestSMTPConfigProjection_reduces(t *testing.T) { executer: &testExecuter{ executions: []execution{ { - expectedStmt: "UPDATE projections.smtp_configs2 SET (change_date, sequence, tls, sender_address, sender_name, reply_to_address, host, username, description) = ($1, $2, $3, $4, $5, $6, $7, $8, $9) WHERE (id = $10) AND (resource_owner = $11) AND (instance_id = $12)", + expectedStmt: "UPDATE projections.smtp_configs3 SET (change_date, sequence, description) = ($1, $2, $3) WHERE (id = $4) AND (instance_id = $5)", expectedArgs: []interface{}{ anyArg{}, uint64(15), + "test", + "config-id", + "instance-id", + }, + }, + { + expectedStmt: "UPDATE projections.smtp_configs3_smtp SET (tls, sender_address, sender_name, reply_to_address, host, username) = ($1, $2, $3, $4, $5, $6) WHERE (id = $7) AND (instance_id = $8)", + expectedArgs: []interface{}{ true, "sender", "name", "reply-to", "host", "user", + "config-id", + "instance-id", + }, + }, + }, + }, + }, + }, + { + name: "reduceSMTPConfigChanged, description", + args: args{ + event: getEvent( + testEvent( + instance.SMTPConfigChangedEventType, + instance.AggregateType, + []byte(`{ + "instance_id": "instance-id", + "resource_owner": "ro-id", + "aggregate_id": "agg-id", + "id": "config-id", + "description": "test" + }`, + ), + ), eventstore.GenericEventMapper[instance.SMTPConfigChangedEvent]), + }, + reduce: (&smtpConfigProjection{}).reduceSMTPConfigChanged, + want: wantReduce{ + aggregateType: eventstore.AggregateType("instance"), + sequence: 15, + executer: &testExecuter{ + executions: []execution{ + { + expectedStmt: "UPDATE projections.smtp_configs3 SET (change_date, sequence, description) = ($1, $2, $3) WHERE (id = $4) AND (instance_id = $5)", + expectedArgs: []interface{}{ + anyArg{}, + uint64(15), "test", - "44444", - "ro-id", + "config-id", + "instance-id", + }, + }, + }, + }, + }, + }, + { + name: "reduceSMTPConfigChanged, senderAddress", + args: args{ + event: getEvent( + testEvent( + instance.SMTPConfigChangedEventType, + instance.AggregateType, + []byte(`{ + "instance_id": "instance-id", + "resource_owner": "ro-id", + "aggregate_id": "agg-id", + "id": "config-id", + "senderAddress": "sender" + }`, + ), + ), eventstore.GenericEventMapper[instance.SMTPConfigChangedEvent]), + }, + reduce: (&smtpConfigProjection{}).reduceSMTPConfigChanged, + want: wantReduce{ + aggregateType: eventstore.AggregateType("instance"), + sequence: 15, + executer: &testExecuter{ + executions: []execution{ + { + expectedStmt: "UPDATE projections.smtp_configs3 SET (change_date, sequence) = ($1, $2) WHERE (id = $3) AND (instance_id = $4)", + expectedArgs: []interface{}{ + anyArg{}, + uint64(15), + "config-id", + "instance-id", + }, + }, + { + expectedStmt: "UPDATE projections.smtp_configs3_smtp SET sender_address = $1 WHERE (id = $2) AND (instance_id = $3)", + expectedArgs: []interface{}{ + "sender", + "config-id", + "instance-id", + }, + }, + }, + }, + }, + }, + { + name: "reduceSMTPConfigHTTPChanged", + args: args{ + event: getEvent( + testEvent( + instance.SMTPConfigHTTPChangedEventType, + instance.AggregateType, + []byte(`{ + "instance_id": "instance-id", + "resource_owner": "ro-id", + "aggregate_id": "agg-id", + "id": "config-id", + "description": "test", + "endpoint": "endpoint" + }`, + ), + ), eventstore.GenericEventMapper[instance.SMTPConfigHTTPChangedEvent]), + }, + reduce: (&smtpConfigProjection{}).reduceSMTPConfigHTTPChanged, + want: wantReduce{ + aggregateType: eventstore.AggregateType("instance"), + sequence: 15, + executer: &testExecuter{ + executions: []execution{ + { + expectedStmt: "UPDATE projections.smtp_configs3 SET (change_date, sequence, description) = ($1, $2, $3) WHERE (id = $4) AND (instance_id = $5)", + expectedArgs: []interface{}{ + anyArg{}, + uint64(15), + "test", + "config-id", + "instance-id", + }, + }, + { + expectedStmt: "UPDATE projections.smtp_configs3_http SET endpoint = $1 WHERE (id = $2) AND (instance_id = $3)", + expectedArgs: []interface{}{ + "endpoint", + "config-id", + "instance-id", + }, + }, + }, + }, + }, + }, + { + name: "reduceSMTPConfigHTTPChanged, description", + args: args{ + event: getEvent( + testEvent( + instance.SMTPConfigHTTPChangedEventType, + instance.AggregateType, + []byte(`{ + "instance_id": "instance-id", + "resource_owner": "ro-id", + "aggregate_id": "agg-id", + "id": "config-id", + "description": "test" + }`, + ), + ), eventstore.GenericEventMapper[instance.SMTPConfigHTTPChangedEvent]), + }, + reduce: (&smtpConfigProjection{}).reduceSMTPConfigHTTPChanged, + want: wantReduce{ + aggregateType: eventstore.AggregateType("instance"), + sequence: 15, + executer: &testExecuter{ + executions: []execution{ + { + expectedStmt: "UPDATE projections.smtp_configs3 SET (change_date, sequence, description) = ($1, $2, $3) WHERE (id = $4) AND (instance_id = $5)", + expectedArgs: []interface{}{ + anyArg{}, + uint64(15), + "test", + "config-id", + "instance-id", + }, + }, + }, + }, + }, + }, + { + name: "reduceSMTPConfigHTTPChanged, endpoint", + args: args{ + event: getEvent( + testEvent( + instance.SMTPConfigHTTPChangedEventType, + instance.AggregateType, + []byte(`{ + "instance_id": "instance-id", + "resource_owner": "ro-id", + "aggregate_id": "agg-id", + "id": "config-id", + "endpoint": "endpoint" + }`, + ), + ), eventstore.GenericEventMapper[instance.SMTPConfigHTTPChangedEvent]), + }, + reduce: (&smtpConfigProjection{}).reduceSMTPConfigHTTPChanged, + want: wantReduce{ + aggregateType: eventstore.AggregateType("instance"), + sequence: 15, + executer: &testExecuter{ + executions: []execution{ + { + expectedStmt: "UPDATE projections.smtp_configs3 SET (change_date, sequence) = ($1, $2) WHERE (id = $3) AND (instance_id = $4)", + expectedArgs: []interface{}{ + anyArg{}, + uint64(15), + "config-id", + "instance-id", + }, + }, + { + expectedStmt: "UPDATE projections.smtp_configs3_http SET endpoint = $1 WHERE (id = $2) AND (instance_id = $3)", + expectedArgs: []interface{}{ + "endpoint", + "config-id", "instance-id", }, }, @@ -77,9 +292,12 @@ func TestSMTPConfigProjection_reduces(t *testing.T) { instance.SMTPConfigAddedEventType, instance.AggregateType, []byte(`{ - "tls": true, - "id": "id", + "instance_id": "instance-id", + "resource_owner": "ro-id", + "aggregate_id": "agg-id", + "id": "config-id", "description": "test", + "tls": true, "senderAddress": "sender", "senderName": "name", "replyToAddress": "reply-to", @@ -91,7 +309,7 @@ func TestSMTPConfigProjection_reduces(t *testing.T) { "keyId": "key-id" } }`), - ), instance.SMTPConfigAddedEventMapper), + ), eventstore.GenericEventMapper[instance.SMTPConfigAddedEvent]), }, reduce: (&smtpConfigProjection{}).reduceSMTPConfigAdded, want: wantReduce{ @@ -100,14 +318,24 @@ func TestSMTPConfigProjection_reduces(t *testing.T) { executer: &testExecuter{ executions: []execution{ { - expectedStmt: "INSERT INTO projections.smtp_configs2 (creation_date, change_date, resource_owner, instance_id, sequence, id, tls, sender_address, sender_name, reply_to_address, host, username, password, state, description) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15)", + expectedStmt: "INSERT INTO projections.smtp_configs3 (creation_date, change_date, instance_id, resource_owner, aggregate_id, id, sequence, state, description) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)", expectedArgs: []interface{}{ anyArg{}, anyArg{}, - "ro-id", "instance-id", + "ro-id", + "agg-id", + "config-id", uint64(15), - "id", + domain.SMTPConfigStateInactive, + "test", + }, + }, + { + expectedStmt: "INSERT INTO projections.smtp_configs3_smtp (instance_id, id, tls, sender_address, sender_name, reply_to_address, host, username, password) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)", + expectedArgs: []interface{}{ + "instance-id", + "config-id", true, "sender", "name", @@ -115,10 +343,58 @@ func TestSMTPConfigProjection_reduces(t *testing.T) { "host", "user", anyArg{}, - domain.SMTPConfigState(3), + }, + }, + }, + }, + }, + }, + { + name: "reduceSMTPConfigHTTPAdded", + args: args{ + event: getEvent( + testEvent( + instance.SMTPConfigHTTPAddedEventType, + instance.AggregateType, + []byte(`{ + "instance_id": "instance-id", + "resource_owner": "ro-id", + "aggregate_id": "agg-id", + "id": "config-id", + "description": "test", + "senderAddress": "sender", + "endpoint": "endpoint" + }`), + ), eventstore.GenericEventMapper[instance.SMTPConfigHTTPAddedEvent]), + }, + reduce: (&smtpConfigProjection{}).reduceSMTPConfigHTTPAdded, + want: wantReduce{ + aggregateType: eventstore.AggregateType("instance"), + sequence: 15, + executer: &testExecuter{ + executions: []execution{ + { + expectedStmt: "INSERT INTO projections.smtp_configs3 (creation_date, change_date, instance_id, resource_owner, aggregate_id, id, sequence, state, description) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)", + expectedArgs: []interface{}{ + anyArg{}, + anyArg{}, + "instance-id", + "ro-id", + "agg-id", + "config-id", + uint64(15), + domain.SMTPConfigStateInactive, "test", }, }, + { + expectedStmt: "INSERT INTO projections.smtp_configs3_http (instance_id, id, endpoint) VALUES ($1, $2, $3)", + expectedArgs: []interface{}{ + "instance-id", + "config-id", + "endpoint", + }, + }, }, }, }, @@ -130,9 +406,12 @@ func TestSMTPConfigProjection_reduces(t *testing.T) { instance.SMTPConfigActivatedEventType, instance.AggregateType, []byte(`{ - "id": "config-id" + "instance_id": "instance-id", + "resource_owner": "ro-id", + "aggregate_id": "agg-id", + "id": "config-id" }`), - ), instance.SMTPConfigActivatedEventMapper), + ), eventstore.GenericEventMapper[instance.SMTPConfigActivatedEvent]), }, reduce: (&smtpConfigProjection{}).reduceSMTPConfigActivated, want: wantReduce{ @@ -141,13 +420,23 @@ func TestSMTPConfigProjection_reduces(t *testing.T) { executer: &testExecuter{ executions: []execution{ { - expectedStmt: "UPDATE projections.smtp_configs2 SET (change_date, sequence, state) = ($1, $2, $3) WHERE (id = $4) AND (resource_owner = $5) AND (instance_id = $6)", + expectedStmt: "UPDATE projections.smtp_configs3 SET (change_date, sequence, state) = ($1, $2, $3) WHERE (NOT (id = $4)) AND (state = $5) AND (instance_id = $6)", + expectedArgs: []interface{}{ + anyArg{}, + uint64(15), + domain.SMTPConfigStateInactive, + "config-id", + domain.SMTPConfigStateActive, + "instance-id", + }, + }, + { + expectedStmt: "UPDATE projections.smtp_configs3 SET (change_date, sequence, state) = ($1, $2, $3) WHERE (id = $4) AND (instance_id = $5)", expectedArgs: []interface{}{ anyArg{}, uint64(15), domain.SMTPConfigStateActive, "config-id", - "ro-id", "instance-id", }, }, @@ -162,9 +451,12 @@ func TestSMTPConfigProjection_reduces(t *testing.T) { instance.SMTPConfigDeactivatedEventType, instance.AggregateType, []byte(`{ - "id": "config-id" + "instance_id": "instance-id", + "resource_owner": "ro-id", + "aggregate_id": "agg-id", + "id": "config-id" }`), - ), instance.SMTPConfigDeactivatedEventMapper), + ), eventstore.GenericEventMapper[instance.SMTPConfigDeactivatedEvent]), }, reduce: (&smtpConfigProjection{}).reduceSMTPConfigDeactivated, want: wantReduce{ @@ -173,13 +465,12 @@ func TestSMTPConfigProjection_reduces(t *testing.T) { executer: &testExecuter{ executions: []execution{ { - expectedStmt: "UPDATE projections.smtp_configs2 SET (change_date, sequence, state) = ($1, $2, $3) WHERE (id = $4) AND (resource_owner = $5) AND (instance_id = $6)", + expectedStmt: "UPDATE projections.smtp_configs3 SET (change_date, sequence, state) = ($1, $2, $3) WHERE (id = $4) AND (instance_id = $5)", expectedArgs: []interface{}{ anyArg{}, uint64(15), domain.SMTPConfigStateInactive, "config-id", - "ro-id", "instance-id", }, }, @@ -195,14 +486,17 @@ func TestSMTPConfigProjection_reduces(t *testing.T) { instance.SMTPConfigPasswordChangedEventType, instance.AggregateType, []byte(`{ - "id": "config-id", + "instance_id": "instance-id", + "resource_owner": "ro-id", + "aggregate_id": "agg-id", + "id": "config-id", "password": { "cryptoType": 0, "algorithm": "RSA-265", "keyId": "key-id" } }`), - ), instance.SMTPConfigPasswordChangedEventMapper), + ), eventstore.GenericEventMapper[instance.SMTPConfigPasswordChangedEvent]), }, reduce: (&smtpConfigProjection{}).reduceSMTPConfigPasswordChanged, want: wantReduce{ @@ -211,13 +505,19 @@ func TestSMTPConfigProjection_reduces(t *testing.T) { executer: &testExecuter{ executions: []execution{ { - expectedStmt: "UPDATE projections.smtp_configs2 SET (change_date, sequence, password) = ($1, $2, $3) WHERE (id = $4) AND (resource_owner = $5) AND (instance_id = $6)", + expectedStmt: "UPDATE projections.smtp_configs3_smtp SET password = $1 WHERE (id = $2) AND (instance_id = $3)", + expectedArgs: []interface{}{ + anyArg{}, + "config-id", + "instance-id", + }, + }, + { + expectedStmt: "UPDATE projections.smtp_configs3 SET (change_date, sequence) = ($1, $2) WHERE (id = $3) AND (instance_id = $4)", expectedArgs: []interface{}{ anyArg{}, uint64(15), - anyArg{}, "config-id", - "ro-id", "instance-id", }, }, @@ -231,8 +531,13 @@ func TestSMTPConfigProjection_reduces(t *testing.T) { event: getEvent(testEvent( instance.SMTPConfigRemovedEventType, instance.AggregateType, - []byte(`{ "id": "config-id"}`), - ), instance.SMTPConfigRemovedEventMapper), + []byte(`{ + "instance_id": "instance-id", + "resource_owner": "ro-id", + "aggregate_id": "agg-id", + "id": "config-id" +}`), + ), eventstore.GenericEventMapper[instance.SMTPConfigRemovedEvent]), }, reduce: (&smtpConfigProjection{}).reduceSMTPConfigRemoved, want: wantReduce{ @@ -241,10 +546,9 @@ func TestSMTPConfigProjection_reduces(t *testing.T) { executer: &testExecuter{ executions: []execution{ { - expectedStmt: "DELETE FROM projections.smtp_configs2 WHERE (id = $1) AND (resource_owner = $2) AND (instance_id = $3)", + expectedStmt: "DELETE FROM projections.smtp_configs3 WHERE (id = $1) AND (instance_id = $2)", expectedArgs: []interface{}{ "config-id", - "ro-id", "instance-id", }, }, @@ -269,7 +573,7 @@ func TestSMTPConfigProjection_reduces(t *testing.T) { executer: &testExecuter{ executions: []execution{ { - expectedStmt: "DELETE FROM projections.smtp_configs2 WHERE (instance_id = $1)", + expectedStmt: "DELETE FROM projections.smtp_configs3 WHERE (instance_id = $1)", expectedArgs: []interface{}{ "agg-id", }, diff --git a/internal/query/sms.go b/internal/query/sms.go index 7aa4be2310..6f0555634f 100644 --- a/internal/query/sms.go +++ b/internal/query/sms.go @@ -256,7 +256,7 @@ func prepareSMSConfigQuery(ctx context.Context, db prepareDatabase) (sq.SelectBu &twilioConfig.token, &twilioConfig.senderNumber, - &httpConfig.smsID, + &httpConfig.id, &httpConfig.endpoint, ) @@ -268,7 +268,7 @@ func prepareSMSConfigQuery(ctx context.Context, db prepareDatabase) (sq.SelectBu } twilioConfig.set(config) - httpConfig.set(config) + httpConfig.setSMS(config) return config, nil } @@ -322,7 +322,7 @@ func prepareSMSConfigsQuery(ctx context.Context, db prepareDatabase) (sq.SelectB &twilioConfig.token, &twilioConfig.senderNumber, - &httpConfig.smsID, + &httpConfig.id, &httpConfig.endpoint, &configs.Count, @@ -333,7 +333,7 @@ func prepareSMSConfigsQuery(ctx context.Context, db prepareDatabase) (sq.SelectB } twilioConfig.set(config) - httpConfig.set(config) + httpConfig.setSMS(config) configs.Configs = append(configs.Configs, config) } @@ -361,12 +361,12 @@ func (c sqlTwilioConfig) set(smsConfig *SMSConfig) { } type sqlHTTPConfig struct { - smsID sql.NullString + id sql.NullString endpoint sql.NullString } -func (c sqlHTTPConfig) set(smsConfig *SMSConfig) { - if !c.smsID.Valid { +func (c sqlHTTPConfig) setSMS(smsConfig *SMSConfig) { + if !c.id.Valid { return } smsConfig.HTTPConfig = &HTTP{ diff --git a/internal/query/smtp.go b/internal/query/smtp.go index b9dd3e0bff..7c45fe33fe 100644 --- a/internal/query/smtp.go +++ b/internal/query/smtp.go @@ -52,34 +52,6 @@ var ( name: projection.SMTPConfigColumnSequence, table: smtpConfigsTable, } - SMTPConfigColumnTLS = Column{ - name: projection.SMTPConfigColumnTLS, - table: smtpConfigsTable, - } - SMTPConfigColumnSenderAddress = Column{ - name: projection.SMTPConfigColumnSenderAddress, - table: smtpConfigsTable, - } - SMTPConfigColumnSenderName = Column{ - name: projection.SMTPConfigColumnSenderName, - table: smtpConfigsTable, - } - SMTPConfigColumnReplyToAddress = Column{ - name: projection.SMTPConfigColumnReplyToAddress, - table: smtpConfigsTable, - } - SMTPConfigColumnSMTPHost = Column{ - name: projection.SMTPConfigColumnSMTPHost, - table: smtpConfigsTable, - } - SMTPConfigColumnSMTPUser = Column{ - name: projection.SMTPConfigColumnSMTPUser, - table: smtpConfigsTable, - } - SMTPConfigColumnSMTPPassword = Column{ - name: projection.SMTPConfigColumnSMTPPassword, - table: smtpConfigsTable, - } SMTPConfigColumnID = Column{ name: projection.SMTPConfigColumnID, table: smtpConfigsTable, @@ -92,13 +64,82 @@ var ( name: projection.SMTPConfigColumnDescription, table: smtpConfigsTable, } + + smtpConfigsSMTPTable = table{ + name: projection.SMTPConfigTable, + instanceIDCol: projection.SMTPConfigColumnInstanceID, + } + SMTPConfigSMTPColumnInstanceID = Column{ + name: projection.SMTPConfigColumnInstanceID, + table: smtpConfigsSMTPTable, + } + SMTPConfigSMTPColumnID = Column{ + name: projection.SMTPConfigColumnID, + table: smtpConfigsSMTPTable, + } + SMTPConfigSMTPColumnTLS = Column{ + name: projection.SMTPConfigSMTPColumnTLS, + table: smtpConfigsSMTPTable, + } + SMTPConfigSMTPColumnSenderAddress = Column{ + name: projection.SMTPConfigSMTPColumnSenderAddress, + table: smtpConfigsSMTPTable, + } + SMTPConfigSMTPColumnSenderName = Column{ + name: projection.SMTPConfigSMTPColumnSenderName, + table: smtpConfigsSMTPTable, + } + SMTPConfigSMTPColumnReplyToAddress = Column{ + name: projection.SMTPConfigSMTPColumnReplyToAddress, + table: smtpConfigsSMTPTable, + } + SMTPConfigSMTPColumnHost = Column{ + name: projection.SMTPConfigSMTPColumnHost, + table: smtpConfigsSMTPTable, + } + SMTPConfigSMTPColumnUser = Column{ + name: projection.SMTPConfigSMTPColumnUser, + table: smtpConfigsSMTPTable, + } + SMTPConfigSMTPColumnPassword = Column{ + name: projection.SMTPConfigSMTPColumnPassword, + table: smtpConfigsSMTPTable, + } + + smtpConfigsHTTPTable = table{ + name: projection.SMTPConfigHTTPTable, + instanceIDCol: projection.SMTPConfigHTTPColumnInstanceID, + } + SMTPConfigHTTPColumnInstanceID = Column{ + name: projection.SMTPConfigHTTPColumnInstanceID, + table: smtpConfigsHTTPTable, + } + SMTPConfigHTTPColumnID = Column{ + name: projection.SMTPConfigHTTPColumnID, + table: smtpConfigsHTTPTable, + } + SMTPConfigHTTPColumnEndpoint = Column{ + name: projection.SMTPConfigHTTPColumnEndpoint, + table: smtpConfigsHTTPTable, + } ) type SMTPConfig struct { - CreationDate time.Time - ChangeDate time.Time - ResourceOwner string - Sequence uint64 + CreationDate time.Time + ChangeDate time.Time + ResourceOwner string + AggregateID string + ID string + Sequence uint64 + Description string + + SMTPConfig *SMTP + HTTPConfig *HTTP + + State domain.SMTPConfigState +} + +type SMTP struct { TLS bool SenderAddress string SenderName string @@ -106,9 +147,6 @@ type SMTPConfig struct { Host string User string Password *crypto.CryptoValue - ID string - State domain.SMTPConfigState - Description string } func (q *Queries) SMTPConfigActive(ctx context.Context, resourceOwner string) (config *SMTPConfig, err error) { @@ -132,15 +170,14 @@ func (q *Queries) SMTPConfigActive(ctx context.Context, resourceOwner string) (c return config, err } -func (q *Queries) SMTPConfigByID(ctx context.Context, instanceID, resourceOwner, id string) (config *SMTPConfig, err error) { +func (q *Queries) SMTPConfigByID(ctx context.Context, instanceID, id string) (config *SMTPConfig, err error) { ctx, span := tracing.NewSpan(ctx) defer func() { span.EndWithError(err) }() stmt, scan := prepareSMTPConfigQuery(ctx, q.client) query, args, err := stmt.Where(sq.Eq{ - SMTPConfigColumnResourceOwner.identifier(): resourceOwner, - SMTPConfigColumnInstanceID.identifier(): instanceID, - SMTPConfigColumnID.identifier(): id, + SMTPConfigColumnInstanceID.identifier(): instanceID, + SMTPConfigColumnID.identifier(): id, }).ToSql() if err != nil { return nil, zerrors.ThrowInternal(err, "QUERY-8f8gw", "Errors.Query.SQLStatement") @@ -161,35 +198,49 @@ func prepareSMTPConfigQuery(ctx context.Context, db prepareDatabase) (sq.SelectB SMTPConfigColumnChangeDate.identifier(), SMTPConfigColumnResourceOwner.identifier(), SMTPConfigColumnSequence.identifier(), - SMTPConfigColumnTLS.identifier(), - SMTPConfigColumnSenderAddress.identifier(), - SMTPConfigColumnSenderName.identifier(), - SMTPConfigColumnReplyToAddress.identifier(), - SMTPConfigColumnSMTPHost.identifier(), - SMTPConfigColumnSMTPUser.identifier(), - SMTPConfigColumnSMTPPassword.identifier(), SMTPConfigColumnID.identifier(), SMTPConfigColumnState.identifier(), - SMTPConfigColumnDescription.identifier()). - From(smtpConfigsTable.identifier() + db.Timetravel(call.Took(ctx))). + SMTPConfigColumnDescription.identifier(), + + SMTPConfigSMTPColumnID.identifier(), + SMTPConfigSMTPColumnTLS.identifier(), + SMTPConfigSMTPColumnSenderAddress.identifier(), + SMTPConfigSMTPColumnSenderName.identifier(), + SMTPConfigSMTPColumnReplyToAddress.identifier(), + SMTPConfigSMTPColumnHost.identifier(), + SMTPConfigSMTPColumnUser.identifier(), + SMTPConfigSMTPColumnPassword.identifier(), + + SMTPConfigHTTPColumnID.identifier(), + SMTPConfigHTTPColumnEndpoint.identifier()). + From(smtpConfigsTable.identifier()). + LeftJoin(join(SMTPConfigSMTPColumnID, SMTPConfigColumnID)). + LeftJoin(join(SMTPConfigHTTPColumnID, SMTPConfigColumnID) + db.Timetravel(call.Took(ctx))). PlaceholderFormat(sq.Dollar), func(row *sql.Row) (*SMTPConfig, error) { config := new(SMTPConfig) + var ( + smtpConfig = sqlSmtpConfig{} + httpConfig = sqlHTTPConfig{} + ) err := row.Scan( &config.CreationDate, &config.ChangeDate, &config.ResourceOwner, &config.Sequence, - &config.TLS, - &config.SenderAddress, - &config.SenderName, - &config.ReplyToAddress, - &config.Host, - &config.User, - &password, &config.ID, &config.State, &config.Description, + &smtpConfig.id, + &smtpConfig.tls, + &smtpConfig.senderAddress, + &smtpConfig.senderName, + &smtpConfig.replyToAddress, + &smtpConfig.host, + &smtpConfig.user, + &password, + &httpConfig.id, + &httpConfig.endpoint, ) if err != nil { if errors.Is(err, sql.ErrNoRows) { @@ -197,7 +248,9 @@ func prepareSMTPConfigQuery(ctx context.Context, db prepareDatabase) (sq.SelectB } return nil, zerrors.ThrowInternal(err, "QUERY-9k87F", "Errors.Internal") } - config.Password = password + smtpConfig.password = password + smtpConfig.set(config) + httpConfig.setSMTP(config) return config, nil } } @@ -208,38 +261,53 @@ func prepareSMTPConfigsQuery(ctx context.Context, db prepareDatabase) (sq.Select SMTPConfigColumnChangeDate.identifier(), SMTPConfigColumnResourceOwner.identifier(), SMTPConfigColumnSequence.identifier(), - SMTPConfigColumnTLS.identifier(), - SMTPConfigColumnSenderAddress.identifier(), - SMTPConfigColumnSenderName.identifier(), - SMTPConfigColumnReplyToAddress.identifier(), - SMTPConfigColumnSMTPHost.identifier(), - SMTPConfigColumnSMTPUser.identifier(), - SMTPConfigColumnSMTPPassword.identifier(), SMTPConfigColumnID.identifier(), SMTPConfigColumnState.identifier(), SMTPConfigColumnDescription.identifier(), - countColumn.identifier()). - From(smtpConfigsTable.identifier() + db.Timetravel(call.Took(ctx))). + + SMTPConfigSMTPColumnID.identifier(), + SMTPConfigSMTPColumnTLS.identifier(), + SMTPConfigSMTPColumnSenderAddress.identifier(), + SMTPConfigSMTPColumnSenderName.identifier(), + SMTPConfigSMTPColumnReplyToAddress.identifier(), + SMTPConfigSMTPColumnHost.identifier(), + SMTPConfigSMTPColumnUser.identifier(), + SMTPConfigSMTPColumnPassword.identifier(), + + SMTPConfigHTTPColumnID.identifier(), + SMTPConfigHTTPColumnEndpoint.identifier(), + countColumn.identifier(), + ).From(smtpConfigsTable.identifier()). + LeftJoin(join(SMTPConfigSMTPColumnID, SMTPConfigColumnID)). + LeftJoin(join(SMTPConfigHTTPColumnID, SMTPConfigColumnID) + db.Timetravel(call.Took(ctx))). PlaceholderFormat(sq.Dollar), func(rows *sql.Rows) (*SMTPConfigs, error) { configs := &SMTPConfigs{Configs: []*SMTPConfig{}} for rows.Next() { config := new(SMTPConfig) + password := new(crypto.CryptoValue) + var ( + smtpConfig = sqlSmtpConfig{} + httpConfig = sqlHTTPConfig{} + ) err := rows.Scan( &config.CreationDate, &config.ChangeDate, &config.ResourceOwner, &config.Sequence, - &config.TLS, - &config.SenderAddress, - &config.SenderName, - &config.ReplyToAddress, - &config.Host, - &config.User, - &config.Password, &config.ID, &config.State, &config.Description, + &smtpConfig.id, + &smtpConfig.tls, + &smtpConfig.senderAddress, + &smtpConfig.senderName, + &smtpConfig.replyToAddress, + &smtpConfig.host, + &smtpConfig.user, + &password, + &httpConfig.id, + &httpConfig.endpoint, &configs.Count, ) if err != nil { @@ -248,6 +316,9 @@ func prepareSMTPConfigsQuery(ctx context.Context, db prepareDatabase) (sq.Select } return nil, zerrors.ThrowInternal(err, "QUERY-9k87F", "Errors.Internal") } + smtpConfig.password = password + smtpConfig.set(config) + httpConfig.setSMTP(config) configs.Configs = append(configs.Configs, config) } return configs, nil @@ -277,3 +348,38 @@ func (q *Queries) SearchSMTPConfigs(ctx context.Context, queries *SMTPConfigsSea configs.State, err = q.latestState(ctx, smsConfigsTable) return configs, err } + +type sqlSmtpConfig struct { + id sql.NullString + tls sql.NullBool + senderAddress sql.NullString + senderName sql.NullString + replyToAddress sql.NullString + host sql.NullString + user sql.NullString + password *crypto.CryptoValue +} + +func (c sqlSmtpConfig) set(smtpConfig *SMTPConfig) { + if !c.id.Valid { + return + } + smtpConfig.SMTPConfig = &SMTP{ + TLS: c.tls.Bool, + SenderAddress: c.senderAddress.String, + SenderName: c.senderName.String, + ReplyToAddress: c.replyToAddress.String, + Host: c.host.String, + User: c.user.String, + Password: c.password, + } +} + +func (c sqlHTTPConfig) setSMTP(smtpConfig *SMTPConfig) { + if !c.id.Valid { + return + } + smtpConfig.HTTPConfig = &HTTP{ + Endpoint: c.endpoint.String, + } +} diff --git a/internal/query/smtp_test.go b/internal/query/smtp_test.go index e1824fd277..080fc951c1 100644 --- a/internal/query/smtp_test.go +++ b/internal/query/smtp_test.go @@ -14,27 +14,36 @@ import ( ) var ( - prepareSMTPConfigStmt = `SELECT projections.smtp_configs2.creation_date,` + - ` projections.smtp_configs2.change_date,` + - ` projections.smtp_configs2.resource_owner,` + - ` projections.smtp_configs2.sequence,` + - ` projections.smtp_configs2.tls,` + - ` projections.smtp_configs2.sender_address,` + - ` projections.smtp_configs2.sender_name,` + - ` projections.smtp_configs2.reply_to_address,` + - ` projections.smtp_configs2.host,` + - ` projections.smtp_configs2.username,` + - ` projections.smtp_configs2.password,` + - ` projections.smtp_configs2.id,` + - ` projections.smtp_configs2.state,` + - ` projections.smtp_configs2.description` + - ` FROM projections.smtp_configs2` + + prepareSMTPConfigStmt = `SELECT projections.smtp_configs3.creation_date,` + + ` projections.smtp_configs3.change_date,` + + ` projections.smtp_configs3.resource_owner,` + + ` projections.smtp_configs3.sequence,` + + ` projections.smtp_configs3.id,` + + ` projections.smtp_configs3.state,` + + ` projections.smtp_configs3.description,` + + ` projections.smtp_configs3_smtp.id,` + + ` projections.smtp_configs3_smtp.tls,` + + ` projections.smtp_configs3_smtp.sender_address,` + + ` projections.smtp_configs3_smtp.sender_name,` + + ` projections.smtp_configs3_smtp.reply_to_address,` + + ` projections.smtp_configs3_smtp.host,` + + ` projections.smtp_configs3_smtp.username,` + + ` projections.smtp_configs3_smtp.password,` + + ` projections.smtp_configs3_http.id,` + + ` projections.smtp_configs3_http.endpoint` + + ` FROM projections.smtp_configs3` + + ` LEFT JOIN projections.smtp_configs3_smtp ON projections.smtp_configs3.id = projections.smtp_configs3_smtp.id AND projections.smtp_configs3.instance_id = projections.smtp_configs3_smtp.instance_id` + + ` LEFT JOIN projections.smtp_configs3_http ON projections.smtp_configs3.id = projections.smtp_configs3_http.id AND projections.smtp_configs3.instance_id = projections.smtp_configs3_http.instance_id` + ` AS OF SYSTEM TIME '-1 ms'` prepareSMTPConfigCols = []string{ "creation_date", "change_date", "resource_owner", "sequence", + "id", + "state", + "description", + "id", "tls", "sender_address", "sender_name", @@ -43,8 +52,7 @@ var ( "smtp_user", "smtp_password", "id", - "state", - "description", + "endpoint", } ) @@ -89,6 +97,10 @@ func Test_SMTPConfigsPrepares(t *testing.T) { testNow, "ro", uint64(20211108), + "2232323", + domain.SMTPConfigStateActive, + "test", + "2232323", true, "sender", "name", @@ -96,27 +108,69 @@ func Test_SMTPConfigsPrepares(t *testing.T) { "host", "user", &crypto.CryptoValue{}, - "2232323", - domain.SMTPConfigStateActive, - "test", + nil, + nil, }, ), }, object: &SMTPConfig{ - CreationDate: testNow, - ChangeDate: testNow, - ResourceOwner: "ro", - Sequence: 20211108, - TLS: true, - SenderAddress: "sender", - SenderName: "name", - ReplyToAddress: "reply-to", - Host: "host", - User: "user", - Password: &crypto.CryptoValue{}, - ID: "2232323", - State: domain.SMTPConfigStateActive, - Description: "test", + CreationDate: testNow, + ChangeDate: testNow, + ResourceOwner: "ro", + Sequence: 20211108, + SMTPConfig: &SMTP{ + TLS: true, + SenderAddress: "sender", + SenderName: "name", + ReplyToAddress: "reply-to", + Host: "host", + User: "user", + Password: &crypto.CryptoValue{}, + }, + ID: "2232323", + State: domain.SMTPConfigStateActive, + Description: "test", + }, + }, + { + name: "prepareSMTPConfigQuery found, http", + prepare: prepareSMTPConfigQuery, + want: want{ + sqlExpectations: mockQuery( + regexp.QuoteMeta(prepareSMTPConfigStmt), + prepareSMTPConfigCols, + []driver.Value{ + testNow, + testNow, + "ro", + uint64(20211108), + "2232323", + domain.SMTPConfigStateActive, + "test", + nil, + nil, + nil, + nil, + nil, + nil, + nil, + nil, + "2232323", + "endpoint", + }, + ), + }, + object: &SMTPConfig{ + CreationDate: testNow, + ChangeDate: testNow, + ResourceOwner: "ro", + Sequence: 20211108, + HTTPConfig: &HTTP{ + Endpoint: "endpoint", + }, + ID: "2232323", + State: domain.SMTPConfigStateActive, + Description: "test", }, }, { @@ -131,6 +185,10 @@ func Test_SMTPConfigsPrepares(t *testing.T) { testNow, "ro", uint64(20211109), + "44442323", + domain.SMTPConfigStateInactive, + "test2", + "44442323", true, "sender2", "name2", @@ -138,27 +196,28 @@ func Test_SMTPConfigsPrepares(t *testing.T) { "host2", "user2", &crypto.CryptoValue{}, - "44442323", - domain.SMTPConfigStateInactive, - "test2", + nil, + nil, }, ), }, object: &SMTPConfig{ - CreationDate: testNow, - ChangeDate: testNow, - ResourceOwner: "ro", - Sequence: 20211109, - TLS: true, - SenderAddress: "sender2", - SenderName: "name2", - ReplyToAddress: "reply-to2", - Host: "host2", - User: "user2", - Password: &crypto.CryptoValue{}, - ID: "44442323", - State: domain.SMTPConfigStateInactive, - Description: "test2", + CreationDate: testNow, + ChangeDate: testNow, + ResourceOwner: "ro", + Sequence: 20211109, + SMTPConfig: &SMTP{ + TLS: true, + SenderAddress: "sender2", + SenderName: "name2", + ReplyToAddress: "reply-to2", + Host: "host2", + User: "user2", + Password: &crypto.CryptoValue{}, + }, + ID: "44442323", + State: domain.SMTPConfigStateInactive, + Description: "test2", }, }, { @@ -173,6 +232,10 @@ func Test_SMTPConfigsPrepares(t *testing.T) { testNow, "ro", uint64(20211109), + "23234444", + domain.SMTPConfigStateInactive, + "test3", + "23234444", true, "sender3", "name3", @@ -180,27 +243,28 @@ func Test_SMTPConfigsPrepares(t *testing.T) { "host3", "user3", &crypto.CryptoValue{}, - "23234444", - domain.SMTPConfigStateInactive, - "test3", + nil, + nil, }, ), }, object: &SMTPConfig{ - CreationDate: testNow, - ChangeDate: testNow, - ResourceOwner: "ro", - Sequence: 20211109, - TLS: true, - SenderAddress: "sender3", - SenderName: "name3", - ReplyToAddress: "reply-to3", - Host: "host3", - User: "user3", - Password: &crypto.CryptoValue{}, - ID: "23234444", - State: domain.SMTPConfigStateInactive, - Description: "test3", + CreationDate: testNow, + ChangeDate: testNow, + ResourceOwner: "ro", + Sequence: 20211109, + SMTPConfig: &SMTP{ + TLS: true, + SenderAddress: "sender3", + SenderName: "name3", + ReplyToAddress: "reply-to3", + Host: "host3", + User: "user3", + Password: &crypto.CryptoValue{}, + }, + ID: "23234444", + State: domain.SMTPConfigStateInactive, + Description: "test3", }, }, { diff --git a/internal/repository/instance/eventstore.go b/internal/repository/instance/eventstore.go index d88793a399..68621597a8 100644 --- a/internal/repository/instance/eventstore.go +++ b/internal/repository/instance/eventstore.go @@ -12,12 +12,14 @@ func init() { eventstore.RegisterFilterEventMapper(AggregateType, SecretGeneratorAddedEventType, SecretGeneratorAddedEventMapper) eventstore.RegisterFilterEventMapper(AggregateType, SecretGeneratorChangedEventType, SecretGeneratorChangedEventMapper) eventstore.RegisterFilterEventMapper(AggregateType, SecretGeneratorRemovedEventType, SecretGeneratorRemovedEventMapper) - eventstore.RegisterFilterEventMapper(AggregateType, SMTPConfigAddedEventType, SMTPConfigAddedEventMapper) - eventstore.RegisterFilterEventMapper(AggregateType, SMTPConfigChangedEventType, SMTPConfigChangedEventMapper) - eventstore.RegisterFilterEventMapper(AggregateType, SMTPConfigActivatedEventType, SMTPConfigActivatedEventMapper) - eventstore.RegisterFilterEventMapper(AggregateType, SMTPConfigDeactivatedEventType, SMTPConfigDeactivatedEventMapper) - eventstore.RegisterFilterEventMapper(AggregateType, SMTPConfigPasswordChangedEventType, SMTPConfigPasswordChangedEventMapper) - eventstore.RegisterFilterEventMapper(AggregateType, SMTPConfigRemovedEventType, SMTPConfigRemovedEventMapper) + eventstore.RegisterFilterEventMapper(AggregateType, SMTPConfigAddedEventType, eventstore.GenericEventMapper[SMTPConfigAddedEvent]) + eventstore.RegisterFilterEventMapper(AggregateType, SMTPConfigChangedEventType, eventstore.GenericEventMapper[SMTPConfigChangedEvent]) + eventstore.RegisterFilterEventMapper(AggregateType, SMTPConfigActivatedEventType, eventstore.GenericEventMapper[SMTPConfigActivatedEvent]) + eventstore.RegisterFilterEventMapper(AggregateType, SMTPConfigDeactivatedEventType, eventstore.GenericEventMapper[SMTPConfigDeactivatedEvent]) + eventstore.RegisterFilterEventMapper(AggregateType, SMTPConfigPasswordChangedEventType, eventstore.GenericEventMapper[SMTPConfigPasswordChangedEvent]) + eventstore.RegisterFilterEventMapper(AggregateType, SMTPConfigHTTPAddedEventType, eventstore.GenericEventMapper[SMTPConfigHTTPAddedEvent]) + eventstore.RegisterFilterEventMapper(AggregateType, SMTPConfigHTTPChangedEventType, eventstore.GenericEventMapper[SMTPConfigHTTPChangedEvent]) + eventstore.RegisterFilterEventMapper(AggregateType, SMTPConfigRemovedEventType, eventstore.GenericEventMapper[SMTPConfigRemovedEvent]) eventstore.RegisterFilterEventMapper(AggregateType, SMSConfigTwilioAddedEventType, eventstore.GenericEventMapper[SMSConfigTwilioAddedEvent]) eventstore.RegisterFilterEventMapper(AggregateType, SMSConfigTwilioChangedEventType, eventstore.GenericEventMapper[SMSConfigTwilioChangedEvent]) eventstore.RegisterFilterEventMapper(AggregateType, SMSConfigTwilioTokenChangedEventType, eventstore.GenericEventMapper[SMSConfigTwilioTokenChangedEvent]) diff --git a/internal/repository/instance/smtp_config.go b/internal/repository/instance/smtp_config.go index 3f08fc8e8a..403cc569a3 100644 --- a/internal/repository/instance/smtp_config.go +++ b/internal/repository/instance/smtp_config.go @@ -10,16 +10,19 @@ import ( const ( smtpConfigPrefix = "smtp.config." + httpConfigPrefix = "http." SMTPConfigAddedEventType = instanceEventTypePrefix + smtpConfigPrefix + "added" SMTPConfigChangedEventType = instanceEventTypePrefix + smtpConfigPrefix + "changed" SMTPConfigPasswordChangedEventType = instanceEventTypePrefix + smtpConfigPrefix + "password.changed" + SMTPConfigHTTPAddedEventType = instanceEventTypePrefix + smtpConfigPrefix + httpConfigPrefix + "added" + SMTPConfigHTTPChangedEventType = instanceEventTypePrefix + smtpConfigPrefix + httpConfigPrefix + "changed" SMTPConfigRemovedEventType = instanceEventTypePrefix + smtpConfigPrefix + "removed" SMTPConfigActivatedEventType = instanceEventTypePrefix + smtpConfigPrefix + "activated" SMTPConfigDeactivatedEventType = instanceEventTypePrefix + smtpConfigPrefix + "deactivated" ) type SMTPConfigAddedEvent struct { - eventstore.BaseEvent `json:"-"` + *eventstore.BaseEvent `json:"-"` ID string `json:"id,omitempty"` Description string `json:"description,omitempty"` @@ -45,7 +48,7 @@ func NewSMTPConfigAddedEvent( password *crypto.CryptoValue, ) *SMTPConfigAddedEvent { return &SMTPConfigAddedEvent{ - BaseEvent: *eventstore.NewBaseEventForPush( + BaseEvent: eventstore.NewBaseEventForPush( ctx, aggregate, SMTPConfigAddedEventType, @@ -61,6 +64,9 @@ func NewSMTPConfigAddedEvent( Password: password, } } +func (e *SMTPConfigAddedEvent) SetBaseEvent(event *eventstore.BaseEvent) { + e.BaseEvent = event +} func (e *SMTPConfigAddedEvent) Payload() interface{} { return e @@ -70,29 +76,21 @@ func (e *SMTPConfigAddedEvent) UniqueConstraints() []*eventstore.UniqueConstrain return nil } -func SMTPConfigAddedEventMapper(event eventstore.Event) (eventstore.Event, error) { - smtpConfigAdded := &SMTPConfigAddedEvent{ - BaseEvent: *eventstore.BaseEventFromRepo(event), - } - err := event.Unmarshal(smtpConfigAdded) - if err != nil { - return nil, zerrors.ThrowInternal(err, "IAM-39fks", "unable to unmarshal smtp config added") - } - - return smtpConfigAdded, nil +type SMTPConfigChangedEvent struct { + *eventstore.BaseEvent `json:"-"` + ID string `json:"id,omitempty"` + Description *string `json:"description,omitempty"` + FromAddress *string `json:"senderAddress,omitempty"` + FromName *string `json:"senderName,omitempty"` + ReplyToAddress *string `json:"replyToAddress,omitempty"` + TLS *bool `json:"tls,omitempty"` + Host *string `json:"host,omitempty"` + User *string `json:"user,omitempty"` + Password *crypto.CryptoValue `json:"password,omitempty"` } -type SMTPConfigChangedEvent struct { - eventstore.BaseEvent `json:"-"` - ID string `json:"id,omitempty"` - Description *string `json:"description,omitempty"` - FromAddress *string `json:"senderAddress,omitempty"` - FromName *string `json:"senderName,omitempty"` - ReplyToAddress *string `json:"replyToAddress,omitempty"` - TLS *bool `json:"tls,omitempty"` - Host *string `json:"host,omitempty"` - User *string `json:"user,omitempty"` - Password *crypto.CryptoValue `json:"password,omitempty"` +func (e *SMTPConfigChangedEvent) SetBaseEvent(event *eventstore.BaseEvent) { + e.BaseEvent = event } func (e *SMTPConfigChangedEvent) Payload() interface{} { @@ -113,7 +111,7 @@ func NewSMTPConfigChangeEvent( return nil, zerrors.ThrowPreconditionFailed(nil, "IAM-o0pWf", "Errors.NoChangesFound") } changeEvent := &SMTPConfigChangedEvent{ - BaseEvent: *eventstore.NewBaseEventForPush( + BaseEvent: eventstore.NewBaseEventForPush( ctx, aggregate, SMTPConfigChangedEventType, @@ -182,23 +180,10 @@ func ChangeSMTPConfigSMTPPassword(password *crypto.CryptoValue) func(event *SMTP } } -func SMTPConfigChangedEventMapper(event eventstore.Event) (eventstore.Event, error) { - e := &SMTPConfigChangedEvent{ - BaseEvent: *eventstore.BaseEventFromRepo(event), - } - - err := event.Unmarshal(e) - if err != nil { - return nil, zerrors.ThrowInternal(err, "IAM-m09oo", "unable to unmarshal smtp changed") - } - - return e, nil -} - type SMTPConfigPasswordChangedEvent struct { - eventstore.BaseEvent `json:"-"` - ID string `json:"id,omitempty"` - Password *crypto.CryptoValue `json:"password,omitempty"` + *eventstore.BaseEvent `json:"-"` + ID string `json:"id,omitempty"` + Password *crypto.CryptoValue `json:"password,omitempty"` } func NewSMTPConfigPasswordChangedEvent( @@ -208,7 +193,7 @@ func NewSMTPConfigPasswordChangedEvent( password *crypto.CryptoValue, ) *SMTPConfigPasswordChangedEvent { return &SMTPConfigPasswordChangedEvent{ - BaseEvent: *eventstore.NewBaseEventForPush( + BaseEvent: eventstore.NewBaseEventForPush( ctx, aggregate, SMTPConfigPasswordChangedEventType, @@ -217,6 +202,10 @@ func NewSMTPConfigPasswordChangedEvent( } } +func (e *SMTPConfigPasswordChangedEvent) SetBaseEvent(event *eventstore.BaseEvent) { + e.BaseEvent = event +} + func (e *SMTPConfigPasswordChangedEvent) Payload() interface{} { return e } @@ -225,21 +214,109 @@ func (e *SMTPConfigPasswordChangedEvent) UniqueConstraints() []*eventstore.Uniqu return nil } -func SMTPConfigPasswordChangedEventMapper(event eventstore.Event) (eventstore.Event, error) { - smtpConfigPasswordChanged := &SMTPConfigPasswordChangedEvent{ - BaseEvent: *eventstore.BaseEventFromRepo(event), - } - err := event.Unmarshal(smtpConfigPasswordChanged) - if err != nil { - return nil, zerrors.ThrowInternal(err, "IAM-99iNF", "unable to unmarshal smtp config password changed") - } +type SMTPConfigHTTPAddedEvent struct { + *eventstore.BaseEvent `json:"-"` - return smtpConfigPasswordChanged, nil + ID string `json:"id,omitempty"` + Description string `json:"description,omitempty"` + Endpoint string `json:"endpoint,omitempty"` +} + +func NewSMTPConfigHTTPAddedEvent( + ctx context.Context, + aggregate *eventstore.Aggregate, + id, description string, + endpoint string, +) *SMTPConfigHTTPAddedEvent { + return &SMTPConfigHTTPAddedEvent{ + BaseEvent: eventstore.NewBaseEventForPush( + ctx, + aggregate, + SMTPConfigHTTPAddedEventType, + ), + ID: id, + Description: description, + Endpoint: endpoint, + } +} + +func (e *SMTPConfigHTTPAddedEvent) SetBaseEvent(event *eventstore.BaseEvent) { + e.BaseEvent = event +} + +func (e *SMTPConfigHTTPAddedEvent) Payload() interface{} { + return e +} + +func (e *SMTPConfigHTTPAddedEvent) UniqueConstraints() []*eventstore.UniqueConstraint { + return nil +} + +type SMTPConfigHTTPChangedEvent struct { + *eventstore.BaseEvent `json:"-"` + ID string `json:"id,omitempty"` + Description *string `json:"description,omitempty"` + Endpoint *string `json:"endpoint,omitempty"` +} + +func (e *SMTPConfigHTTPChangedEvent) SetBaseEvent(event *eventstore.BaseEvent) { + e.BaseEvent = event +} + +func (e *SMTPConfigHTTPChangedEvent) Payload() interface{} { + return e +} + +func (e *SMTPConfigHTTPChangedEvent) UniqueConstraints() []*eventstore.UniqueConstraint { + return nil +} + +func NewSMTPConfigHTTPChangeEvent( + ctx context.Context, + aggregate *eventstore.Aggregate, + id string, + changes []SMTPConfigHTTPChanges, +) (*SMTPConfigHTTPChangedEvent, error) { + if len(changes) == 0 { + return nil, zerrors.ThrowPreconditionFailed(nil, "IAM-o0pWf", "Errors.NoChangesFound") + } + changeEvent := &SMTPConfigHTTPChangedEvent{ + BaseEvent: eventstore.NewBaseEventForPush( + ctx, + aggregate, + SMTPConfigHTTPChangedEventType, + ), + ID: id, + } + for _, change := range changes { + change(changeEvent) + } + return changeEvent, nil +} + +type SMTPConfigHTTPChanges func(event *SMTPConfigHTTPChangedEvent) + +func ChangeSMTPConfigHTTPID(id string) func(event *SMTPConfigHTTPChangedEvent) { + return func(e *SMTPConfigHTTPChangedEvent) { + e.ID = id + } +} + +func ChangeSMTPConfigHTTPDescription(description string) func(event *SMTPConfigHTTPChangedEvent) { + return func(e *SMTPConfigHTTPChangedEvent) { + e.Description = &description + } +} + +func ChangeSMTPConfigHTTPEndpoint(endpoint string) func(event *SMTPConfigHTTPChangedEvent) { + return func(e *SMTPConfigHTTPChangedEvent) { + e.Endpoint = &endpoint + } } type SMTPConfigActivatedEvent struct { - eventstore.BaseEvent `json:"-"` - ID string `json:"id,omitempty"` + *eventstore.BaseEvent `json:"-"` + ID string `json:"id,omitempty"` } func NewSMTPConfigActivatedEvent( @@ -248,7 +325,7 @@ func NewSMTPConfigActivatedEvent( id string, ) *SMTPConfigActivatedEvent { return &SMTPConfigActivatedEvent{ - BaseEvent: *eventstore.NewBaseEventForPush( + BaseEvent: eventstore.NewBaseEventForPush( ctx, aggregate, SMTPConfigActivatedEventType, @@ -257,6 +334,10 @@ func NewSMTPConfigActivatedEvent( } } +func (e *SMTPConfigActivatedEvent) SetBaseEvent(event *eventstore.BaseEvent) { + e.BaseEvent = event +} + func (e *SMTPConfigActivatedEvent) Payload() interface{} { return e } @@ -265,21 +346,9 @@ func (e *SMTPConfigActivatedEvent) UniqueConstraints() []*eventstore.UniqueConst return nil } -func SMTPConfigActivatedEventMapper(event eventstore.Event) (eventstore.Event, error) { - smtpConfigActivated := &SMTPConfigActivatedEvent{ - BaseEvent: *eventstore.BaseEventFromRepo(event), - } - err := event.Unmarshal(smtpConfigActivated) - if err != nil { - return nil, zerrors.ThrowInternal(err, "IAM-KPr5t", "unable to unmarshal smtp config removed") - } - - return smtpConfigActivated, nil -} - type SMTPConfigDeactivatedEvent struct { - eventstore.BaseEvent `json:"-"` - ID string `json:"id,omitempty"` + *eventstore.BaseEvent `json:"-"` + ID string `json:"id,omitempty"` } func NewSMTPConfigDeactivatedEvent( @@ -288,7 +357,7 @@ func NewSMTPConfigDeactivatedEvent( id string, ) *SMTPConfigDeactivatedEvent { return &SMTPConfigDeactivatedEvent{ - BaseEvent: *eventstore.NewBaseEventForPush( + BaseEvent: eventstore.NewBaseEventForPush( ctx, aggregate, SMTPConfigDeactivatedEventType, @@ -297,6 +366,10 @@ func NewSMTPConfigDeactivatedEvent( } } +func (e *SMTPConfigDeactivatedEvent) SetBaseEvent(event *eventstore.BaseEvent) { + e.BaseEvent = event +} + func (e *SMTPConfigDeactivatedEvent) Payload() interface{} { return e } @@ -305,21 +378,9 @@ func (e *SMTPConfigDeactivatedEvent) UniqueConstraints() []*eventstore.UniqueCon return nil } -func SMTPConfigDeactivatedEventMapper(event eventstore.Event) (eventstore.Event, error) { - smtpConfigDeactivated := &SMTPConfigDeactivatedEvent{ - BaseEvent: *eventstore.BaseEventFromRepo(event), - } - err := event.Unmarshal(smtpConfigDeactivated) - if err != nil { - return nil, zerrors.ThrowInternal(err, "IAM-KPr5t", "unable to unmarshal smtp config removed") - } - - return smtpConfigDeactivated, nil -} - type SMTPConfigRemovedEvent struct { - eventstore.BaseEvent `json:"-"` - ID string `json:"id,omitempty"` + *eventstore.BaseEvent `json:"-"` + ID string `json:"id,omitempty"` } func NewSMTPConfigRemovedEvent( @@ -328,7 +389,7 @@ func NewSMTPConfigRemovedEvent( id string, ) *SMTPConfigRemovedEvent { return &SMTPConfigRemovedEvent{ - BaseEvent: *eventstore.NewBaseEventForPush( + BaseEvent: eventstore.NewBaseEventForPush( ctx, aggregate, SMTPConfigRemovedEventType, @@ -337,6 +398,9 @@ func NewSMTPConfigRemovedEvent( } } +func (e *SMTPConfigRemovedEvent) SetBaseEvent(event *eventstore.BaseEvent) { + e.BaseEvent = event +} func (e *SMTPConfigRemovedEvent) Payload() interface{} { return e } @@ -344,15 +408,3 @@ func (e *SMTPConfigRemovedEvent) Payload() interface{} { func (e *SMTPConfigRemovedEvent) UniqueConstraints() []*eventstore.UniqueConstraint { return nil } - -func SMTPConfigRemovedEventMapper(event eventstore.Event) (eventstore.Event, error) { - smtpConfigRemoved := &SMTPConfigRemovedEvent{ - BaseEvent: *eventstore.BaseEventFromRepo(event), - } - err := event.Unmarshal(smtpConfigRemoved) - if err != nil { - return nil, zerrors.ThrowInternal(err, "IAM-DVw1s", "unable to unmarshal smtp config removed") - } - - return smtpConfigRemoved, nil -} diff --git a/pkg/grpc/settings/settings.go b/pkg/grpc/settings/settings.go index 32b6d83125..3daa4ed141 100644 --- a/pkg/grpc/settings/settings.go +++ b/pkg/grpc/settings/settings.go @@ -1,3 +1,5 @@ package settings type SMSConfig = isSMSProvider_Config + +type EmailConfig = isEmailProvider_Config diff --git a/proto/zitadel/admin.proto b/proto/zitadel/admin.proto index c9b44a61ef..278421deac 100644 --- a/proto/zitadel/admin.proto +++ b/proto/zitadel/admin.proto @@ -143,32 +143,32 @@ option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_swagger) = { } security_definitions: { - security: { - key: "BasicAuth"; - value: { - type: TYPE_BASIC; - } - } - security: { - key: "OAuth2"; - value: { - type: TYPE_OAUTH2; - flow: FLOW_ACCESS_CODE; - authorization_url: "$CUSTOM-DOMAIN/oauth/v2/authorize"; - token_url: "$CUSTOM-DOMAIN/oauth/v2/token"; - scopes: { - scope: { - key: "openid"; - value: "openid"; - } - scope: { - key: "urn:zitadel:iam:org:project:id:zitadel:aud"; - value: "urn:zitadel:iam:org:project:id:zitadel:aud"; - } - } - } - } - } + security: { + key: "BasicAuth"; + value: { + type: TYPE_BASIC; + } + } + security: { + key: "OAuth2"; + value: { + type: TYPE_OAUTH2; + flow: FLOW_ACCESS_CODE; + authorization_url: "$CUSTOM-DOMAIN/oauth/v2/authorize"; + token_url: "$CUSTOM-DOMAIN/oauth/v2/token"; + scopes: { + scope: { + key: "openid"; + value: "openid"; + } + scope: { + key: "urn:zitadel:iam:org:project:id:zitadel:aud"; + value: "urn:zitadel:iam:org:project:id:zitadel:aud"; + } + } + } + } + } security: { security_requirement: { key: "OAuth2"; @@ -422,6 +422,11 @@ service AdminService { }; } + // Deprecated: Get active SMTP Configuration + // + // Returns the active SMTP configuration from the system. This is used to send E-Mails to the users. + // + // Deprecated: please move to the new endpoint GetEmailProvider. rpc GetSMTPConfig(GetSMTPConfigRequest) returns (GetSMTPConfigResponse) { option (google.api.http) = { get: "/smtp"; @@ -433,11 +438,15 @@ service AdminService { option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { tags: "SMTP"; - summary: "Get active SMTP Configuration"; - description: "Returns the active SMTP configuration from the system. This is used to send E-Mails to the users." + deprecated: true; }; } + // Deprecated: Get SMTP provider configuration by its id + // + // Get a specific SMTP provider configuration by its ID. + // + // Deprecated: please move to the new endpoint GetEmailProviderById. rpc GetSMTPConfigById(GetSMTPConfigByIdRequest) returns (GetSMTPConfigByIdResponse) { option (google.api.http) = { get: "/smtp/{id}"; @@ -449,11 +458,15 @@ service AdminService { option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { tags: "SMTP"; - summary: "Get SMTP provider configuration by its id"; - description: "Get a specific SMTP provider configuration by its ID."; + deprecated: true; }; } + // Deprecated: Add SMTP Configuration + // + // Add a new SMTP configuration if nothing is set yet. + // + // Deprecated: please move to the new endpoint AddEmailProviderSMTP. rpc AddSMTPConfig(AddSMTPConfigRequest) returns (AddSMTPConfigResponse) { option (google.api.http) = { post: "/smtp"; @@ -466,11 +479,15 @@ service AdminService { option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { tags: "SMTP"; - summary: "Add SMTP Configuration"; - description: "Add a new SMTP configuration if nothing is set yet." + deprecated: true; }; } + // Deprecated: Update SMTP Configuration + // + // Update the SMTP configuration, be aware that this will be activated as soon as it is saved. So the users will get notifications from the newly configured SMTP. + // + // Deprecated: please move to the new endpoint UpdateEmailProviderSMTP. rpc UpdateSMTPConfig(UpdateSMTPConfigRequest) returns (UpdateSMTPConfigResponse) { option (google.api.http) = { put: "/smtp/{id}"; @@ -483,11 +500,15 @@ service AdminService { option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { tags: "SMTP"; - summary: "Update SMTP Configuration"; - description: "Update the SMTP configuration, be aware that this will be activated as soon as it is saved. So the users will get notifications from the newly configured SMTP." + deprecated: true; }; } + // Deprecated: Update SMTP Password + // + // Update the SMTP password that is used for the host, be aware that this will be activated as soon as it is saved. So the users will get notifications from the newly configured SMTP. + // + // Deprecated: please move to the new endpoint UpdateEmailProviderSMTPPassword. rpc UpdateSMTPConfigPassword(UpdateSMTPConfigPasswordRequest) returns (UpdateSMTPConfigPasswordResponse) { option (google.api.http) = { put: "/smtp/{id}/password"; @@ -500,11 +521,15 @@ service AdminService { option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { tags: "SMTP"; - summary: "Update SMTP Password"; - description: "Update the SMTP password that is used for the host, be aware that this will be activated as soon as it is saved. So the users will get notifications from the newly configured SMTP." + deprecated: true; }; } + // Deprecated: Activate SMTP Provider + // + // Activate an SMTP provider. + // + // Deprecated: please move to the new endpoint ActivateEmailProvider. rpc ActivateSMTPConfig(ActivateSMTPConfigRequest) returns (ActivateSMTPConfigResponse) { option (google.api.http) = { post: "/smtp/{id}/_activate"; @@ -519,9 +544,15 @@ service AdminService { tags: "SMTP Provider"; summary: "Activate SMTP Provider"; description: "Activate an SMTP provider." + deprecated: true; }; } + // Deprecated: Deactivate SMTP Provider + // + // Deactivate an SMTP provider. After deactivating the provider, the users will not be able to receive SMTP notifications from that provider anymore. + // + // Deprecated: please move to the new endpoint DeactivateEmailProvider. rpc DeactivateSMTPConfig(DeactivateSMTPConfigRequest) returns (DeactivateSMTPConfigResponse) { option (google.api.http) = { post: "/smtp/{id}/_deactivate"; @@ -534,11 +565,15 @@ service AdminService { option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { tags: "SMTP Provider"; - summary: "Deactivate SMTP Provider"; - description: "Deactivate an SMTP provider. After deactivating the provider, the users will not be able to receive SMTP notifications from that provider anymore." + deprecated: true; }; } + // Deprecated: Remove SMTP Configuration + // + // Remove the SMTP configuration, be aware that the users will not get an E-Mail if no SMTP is set. + // + // Deprecated: please move to the new endpoint RemoveEmailProvider. rpc RemoveSMTPConfig(RemoveSMTPConfigRequest) returns (RemoveSMTPConfigResponse) { option (google.api.http) = { delete: "/smtp/{id}"; @@ -550,11 +585,15 @@ service AdminService { option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { tags: "SMTP"; - summary: "Remove SMTP Configuration"; - description: "Remove the SMTP configuration, be aware that the users will not get an E-Mail if no SMTP is set." + deprecated: true; }; } + // Deprecated: Test SMTP Provider + // + // Test an SMTP provider identified by its ID. After testing the provider, the users will receive information about the test results. + // + // Deprecated: please move to the new endpoint TestEmailProviderSMTPById. rpc TestSMTPConfigById(TestSMTPConfigByIdRequest) returns (TestSMTPConfigByIdResponse) { option (google.api.http) = { post: "/smtp/{id}/_test"; @@ -567,11 +606,15 @@ service AdminService { option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { tags: "SMTP Provider"; - summary: "Test SMTP Provider "; - description: "Test an SMTP provider identified by its ID. After testing the provider, the users will receive information about the test results." + deprecated: true; }; } + // Deprecated: Test SMTP Provider + // + // Test an SMTP provider. After testing the provider, the users will receive information about the test results. + // + // Deprecated: please move to the new endpoint TestEmailProviderSMTP. rpc TestSMTPConfig(TestSMTPConfigRequest) returns (TestSMTPConfigResponse) { option (google.api.http) = { post: "/smtp/_test"; @@ -584,11 +627,15 @@ service AdminService { option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { tags: "SMTP Provider"; - summary: "Test SMTP Provider"; - description: "Test an SMTP provider. After testing the provider, the users will receive information about the test results." + deprecated: true; }; } + // Deprecated: List SMTP Configs + // + // Returns a list of SMTP configurations. + // + // Deprecated: please move to the new endpoint ListEmailProviders. rpc ListSMTPConfigs(ListSMTPConfigsRequest) returns (ListSMTPConfigsResponse) { option (google.api.http) = { post: "/smtp/_search" @@ -601,8 +648,225 @@ service AdminService { option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { tags: "SMTP Configs"; - summary: "List SMTP Configs"; - description: "Returns a list of SMTP configurations." + deprecated: true; + }; + } + + rpc ListEmailProviders(ListEmailProvidersRequest) returns (ListEmailProvidersResponse) { + option (google.api.http) = { + post: "/email/_search" + body: "*" + }; + + option (zitadel.v1.auth_option) = { + permission: "iam.read"; + }; + + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + tags: "Email providers"; + summary: "List Email providers"; + description: "Returns a list of Email providers." + }; + } + + rpc GetEmailProvider(GetEmailProviderRequest) returns (GetEmailProviderResponse) { + option (google.api.http) = { + get: "/email"; + }; + + option (zitadel.v1.auth_option) = { + permission: "iam.read"; + }; + + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + tags: "Email"; + summary: "Get active Email provider"; + description: "Returns the active Email provider from the system. This is used to send E-Mails to the users." + }; + } + + rpc GetEmailProviderById(GetEmailProviderByIdRequest) returns (GetEmailProviderByIdResponse) { + option (google.api.http) = { + get: "/email/{id}"; + }; + + option (zitadel.v1.auth_option) = { + permission: "iam.read"; + }; + + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + tags: "Email"; + summary: "Get Email provider by its id"; + description: "Get a specific Email provider by its ID."; + }; + } + + rpc AddEmailProviderSMTP(AddEmailProviderSMTPRequest) returns (AddEmailProviderSMTPResponse) { + option (google.api.http) = { + post: "/email/smtp"; + body: "*" + }; + + option (zitadel.v1.auth_option) = { + permission: "iam.write"; + }; + + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + tags: "Email"; + summary: "Add SMTP Email provider"; + description: "Add a new SMTP Email provider if nothing is set yet." + }; + } + + rpc UpdateEmailProviderSMTP(UpdateEmailProviderSMTPRequest) returns (UpdateEmailProviderSMTPResponse) { + option (google.api.http) = { + put: "/email/smtp/{id}"; + body: "*" + }; + + option (zitadel.v1.auth_option) = { + permission: "iam.write"; + }; + + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + tags: "Email"; + summary: "Update SMTP Email provider"; + description: "Update the SMTP Email provider, be aware that this will be activated as soon as it is saved. So the users will get notifications from the newly configured SMTP." + }; + } + + rpc AddEmailProviderHTTP(AddEmailProviderHTTPRequest) returns (AddEmailProviderHTTPResponse) { + option (google.api.http) = { + post: "/email/http"; + body: "*" + }; + + option (zitadel.v1.auth_option) = { + permission: "iam.write"; + }; + + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + tags: "Email"; + summary: "Add HTTP Email provider"; + description: "Add a new HTTP Email provider if nothing is set yet." + }; + } + + rpc UpdateEmailProviderHTTP(UpdateEmailProviderHTTPRequest) returns (UpdateEmailProviderHTTPResponse) { + option (google.api.http) = { + put: "/email/http/{id}"; + body: "*" + }; + + option (zitadel.v1.auth_option) = { + permission: "iam.write"; + }; + + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + tags: "Email"; + summary: "Update HTTP Email provider"; + description: "Update the HTTP Email provider, be aware that this will be activated as soon as it is saved. So the users will get notifications from the newly configured HTTP." + }; + } + + rpc UpdateEmailProviderSMTPPassword(UpdateEmailProviderSMTPPasswordRequest) returns (UpdateEmailProviderSMTPPasswordResponse) { + option (google.api.http) = { + put: "/email/smtp/{id}/password"; + body: "*" + }; + + option (zitadel.v1.auth_option) = { + permission: "iam.write"; + }; + + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + tags: "SMTP"; + summary: "Update SMTP Password"; + description: "Update the SMTP password that is used for the host, be aware that this will be activated as soon as it is saved. So the users will get notifications from the newly configured SMTP." + }; + } + + rpc ActivateEmailProvider(ActivateEmailProviderRequest) returns (ActivateEmailProviderResponse) { + option (google.api.http) = { + post: "/email/{id}/_activate"; + body: "*" + }; + + option (zitadel.v1.auth_option) = { + permission: "iam.write"; + }; + + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + tags: "Email Provider"; + summary: "Activate Email Provider"; + description: "Activate an Email provider." + }; + } + + rpc DeactivateEmailProvider(DeactivateEmailProviderRequest) returns (DeactivateEmailProviderResponse) { + option (google.api.http) = { + post: "/email/{id}/_deactivate"; + body: "*" + }; + + option (zitadel.v1.auth_option) = { + permission: "iam.write"; + }; + + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + tags: "Email Provider"; + summary: "Deactivate Email Provider"; + description: "Deactivate an Email provider. After deactivating the provider, the users will not be able to receive Email notifications from that provider anymore." + }; + } + + rpc RemoveEmailProvider(RemoveEmailProviderRequest) returns (RemoveEmailProviderResponse) { + option (google.api.http) = { + delete: "/email/{id}"; + }; + + option (zitadel.v1.auth_option) = { + permission: "iam.write"; + }; + + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + tags: "Email"; + summary: "Remove Email provider"; + description: "Remove the Email provider, be aware that the users will not get an E-Mail if no provider is set." + }; + } + + rpc TestEmailProviderSMTPById(TestEmailProviderSMTPByIdRequest) returns (TestEmailProviderSMTPByIdResponse) { + option (google.api.http) = { + post: "/email/smtp/{id}/_test"; + body: "*" + }; + + option (zitadel.v1.auth_option) = { + permission: "iam.write"; + }; + + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + tags: "SMTP Email Provider"; + summary: "Test SMTP Email Provider"; + description: "Test an SMTP Email provider identified by its ID. After testing the provider, the users will receive information about the test results." + }; + } + + rpc TestEmailProviderSMTP(TestEmailProviderSMTPRequest) returns (TestEmailProviderSMTPResponse) { + option (google.api.http) = { + post: "/email/smtp/_test"; + body: "*" + }; + + option (zitadel.v1.auth_option) = { + permission: "iam.write"; + }; + + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + tags: "SMTP EMailProvider"; + summary: "Test SMTP Email Provider"; + description: "Test an SMTP Email provider. After testing the provider, the users will receive information about the test results." }; } @@ -690,39 +954,39 @@ service AdminService { }; } - rpc AddSMSProviderHTTP(AddSMSProviderHTTPRequest) returns (AddSMSProviderHTTPResponse) { - option (google.api.http) = { - post: "/sms/http"; - body: "*" - }; + rpc AddSMSProviderHTTP(AddSMSProviderHTTPRequest) returns (AddSMSProviderHTTPResponse) { + option (google.api.http) = { + post: "/sms/http"; + body: "*" + }; - option (zitadel.v1.auth_option) = { - permission: "iam.write"; - }; + option (zitadel.v1.auth_option) = { + permission: "iam.write"; + }; - option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { - tags: "SMS Provider"; - summary: "Add HTTP SMS Provider"; - description: "Configure a new SMS provider of the type HTTP. A provider has to be activated to be able to send notifications." - }; - } + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + tags: "SMS Provider"; + summary: "Add HTTP SMS Provider"; + description: "Configure a new SMS provider of the type HTTP. A provider has to be activated to be able to send notifications." + }; + } - rpc UpdateSMSProviderHTTP(UpdateSMSProviderHTTPRequest) returns (UpdateSMSProviderHTTPResponse) { - option (google.api.http) = { - put: "/sms/http/{id}"; - body: "*" - }; + rpc UpdateSMSProviderHTTP(UpdateSMSProviderHTTPRequest) returns (UpdateSMSProviderHTTPResponse) { + option (google.api.http) = { + put: "/sms/http/{id}"; + body: "*" + }; - option (zitadel.v1.auth_option) = { - permission: "iam.write"; - }; + option (zitadel.v1.auth_option) = { + permission: "iam.write"; + }; - option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { - tags: "SMS Provider"; - summary: "Update HTTP SMS Provider"; - description: "Change the configuration of an SMS provider of the type HTTP. A provider has to be activated to be able to send notifications." - }; - } + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + tags: "SMS Provider"; + summary: "Update HTTP SMS Provider"; + description: "Change the configuration of an SMS provider of the type HTTP. A provider has to be activated to be able to send notifications." + }; + } rpc ActivateSMSProvider(ActivateSMSProviderRequest) returns (ActivateSMSProviderResponse) { option (google.api.http) = { @@ -4561,6 +4825,320 @@ message TestSMTPConfigRequest { // This is an empty response message TestSMTPConfigResponse {} +//This is an empty request +message GetEmailProviderRequest {} + +message GetEmailProviderResponse { + zitadel.settings.v1.EmailProvider config = 1; +} + +message GetEmailProviderByIdRequest { + string id = 1 [(validate.rules).string = {min_len: 1, max_len: 100}]; +} + +message GetEmailProviderByIdResponse { + zitadel.settings.v1.EmailProvider config = 1; +} + +message ListEmailProvidersRequest { + zitadel.v1.ListQuery query = 1; +} + +message ListEmailProvidersResponse { + zitadel.v1.ListDetails details = 1; + repeated zitadel.settings.v1.EmailProvider result = 2; +} + +message AddEmailProviderSMTPRequest { + string sender_address = 1 [ + (validate.rules).string = {min_len: 1, max_len: 200}, + (google.api.field_behavior) = REQUIRED, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"noreply@m.zitadel.cloud\""; + min_length: 1; + max_length: 200; + } + ]; + string sender_name = 2 [ + (validate.rules).string = {min_len: 1, max_len: 200}, + (google.api.field_behavior) = REQUIRED, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"ZITADEL\""; + min_length: 1; + max_length: 200; + } + ]; + bool tls = 3; + string host = 4 [ + (validate.rules).string = {min_len: 1, max_len: 500}, + (google.api.field_behavior) = REQUIRED, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"smtp.postmarkapp.com:587\""; + description: "Make sure to include the port."; + min_length: 1; + max_length: 500; + } + ]; + string user = 5 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"197f0117-529e-443d-bf6c-0292dd9a02b7\""; + } + ]; + string password = 6 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"this-is-my-password\""; + } + ]; + string reply_to_address = 7 [ + (validate.rules).string = {min_len: 0, max_len: 200}, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"replyto@m.zitadel.cloud\""; + min_length: 0; + max_length: 200; + } + ]; + string description = 8 [ + (validate.rules).string = {min_len: 0, max_len: 200}, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"provider description\""; + min_length: 0; + max_length: 200; + } + ]; +} + +message AddEmailProviderSMTPResponse { + zitadel.v1.ObjectDetails details = 1; + string id = 2; +} + +message UpdateEmailProviderSMTPRequest { + string sender_address = 1 [ + (validate.rules).string = {min_len: 1, max_len: 200}, + (google.api.field_behavior) = REQUIRED, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"noreply@m.zitadel.cloud\""; + min_length: 1; + max_length: 200; + } + ]; + string sender_name = 2 [ + (validate.rules).string = {min_len: 1, max_len: 200}, + (google.api.field_behavior) = REQUIRED, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"ZITADEL\""; + min_length: 1; + max_length: 200; + } + ]; + bool tls = 3; + string host = 4 [ + (validate.rules).string = {min_len: 1, max_len: 500}, + (google.api.field_behavior) = REQUIRED, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"smtp.postmarkapp.com:587\""; + description: "Make sure to include the port."; + min_length: 1; + max_length: 500; + } + ]; + string user = 5 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"197f0117-529e-443d-bf6c-0292dd9a02b7\""; + } + ]; + string reply_to_address = 6 [ + (validate.rules).string = {min_len: 0, max_len: 200}, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"replyto@m.zitadel.cloud\""; + min_length: 0; + max_length: 200; + } + ]; + string password = 7 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"this-is-my-password\""; + } + ]; + string description = 8 [ + (validate.rules).string = {min_len: 0, max_len: 200}, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"provider description\""; + min_length: 0; + max_length: 200; + } + ]; + string id = 9 [(validate.rules).string = {min_len: 1, max_len: 100}]; +} + +message UpdateEmailProviderSMTPResponse { + zitadel.v1.ObjectDetails details = 1; +} + +message UpdateEmailProviderSMTPPasswordRequest { + string password = 1 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"this-is-my-updated-password\""; + } + ]; + string id = 2 [(validate.rules).string = {min_len: 1, max_len: 100}]; +} + +message UpdateEmailProviderSMTPPasswordResponse { + zitadel.v1.ObjectDetails details = 1; +} + + +message AddEmailProviderHTTPRequest { + string endpoint = 1 [ + (validate.rules).string = {min_len: 1, max_len: 2048}, + (google.api.field_behavior) = REQUIRED, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"http://relay.example.com/provider\""; + min_length: 1; + max_length: 2048; + } + ]; + string description = 2 [ + (validate.rules).string = {min_len: 0, max_len: 200}, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"provider description\""; + min_length: 0; + max_length: 200; + } + ]; +} + +message AddEmailProviderHTTPResponse { + zitadel.v1.ObjectDetails details = 1; + string id = 2; +} + +message UpdateEmailProviderHTTPRequest { + string id = 1 [(validate.rules).string = {min_len: 1, max_len: 100}]; + string endpoint = 2 [ + (validate.rules).string = {min_len: 1, max_len: 2048}, + (google.api.field_behavior) = REQUIRED, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"http://relay.example.com/provider\""; + min_length: 1; + max_length: 2048; + } + ]; + string description = 3 [ + (validate.rules).string = {min_len: 0, max_len: 200}, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"provider description\""; + min_length: 0; + max_length: 200; + } + ]; +} + +message UpdateEmailProviderHTTPResponse { + zitadel.v1.ObjectDetails details = 1; +} + +message ActivateEmailProviderRequest { + string id = 1 [(validate.rules).string = {min_len: 1, max_len: 200}]; +} + +message ActivateEmailProviderResponse { + zitadel.v1.ObjectDetails details = 1; +} + +message DeactivateEmailProviderRequest { + string id = 1 [(validate.rules).string = {min_len: 1, max_len: 200}]; +} + +message DeactivateEmailProviderResponse { + zitadel.v1.ObjectDetails details = 1; +} + +message RemoveEmailProviderRequest { + string id = 1 [(validate.rules).string = {min_len: 1, max_len: 100}]; +} + +message RemoveEmailProviderResponse { + zitadel.v1.ObjectDetails details = 1; +} + +message TestEmailProviderSMTPByIdRequest { + string id = 1 [(validate.rules).string = {min_len: 1, max_len: 100}]; + string receiver_address = 2 [ + (validate.rules).string = {min_len: 1, max_len: 200, email: true}, + (google.api.field_behavior) = REQUIRED, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"noreply@m.zitadel.cloud\""; + min_length: 1; + max_length: 200; + } + ]; +} + +// This is an empty response +message TestEmailProviderSMTPByIdResponse {} + +message TestEmailProviderSMTPRequest { + string sender_address = 1 [ + (validate.rules).string = {min_len: 1, max_len: 200}, + (google.api.field_behavior) = REQUIRED, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"noreply@m.zitadel.cloud\""; + min_length: 1; + max_length: 200; + } + ]; + string sender_name = 2 [ + (validate.rules).string = {min_len: 1, max_len: 200}, + (google.api.field_behavior) = REQUIRED, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"ZITADEL\""; + min_length: 1; + max_length: 200; + } + ]; + bool tls = 3; + string host = 4 [ + (validate.rules).string = {min_len: 1, max_len: 500}, + (google.api.field_behavior) = REQUIRED, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"smtp.postmarkapp.com:587\""; + description: "Make sure to include the port."; + min_length: 1; + max_length: 500; + } + ]; + string user = 5 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"197f0117-529e-443d-bf6c-0292dd9a02b7\""; + } + ]; + string password = 6 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"this-is-my-password\""; + } + ]; + string receiver_address = 7 [ + (validate.rules).string = {min_len: 1, max_len: 200, email: true}, + (google.api.field_behavior) = REQUIRED, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"noreply@m.zitadel.cloud\""; + min_length: 1; + max_length: 200; + } + ]; + string id = 8 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "Zitadel SMTP provider id in case you are not sending the password and want to reuse the stored password"; + example: "\"267191369515139464\""; + } + ]; +} + +// This is an empty response +message TestEmailProviderSMTPResponse {} + message ListSMSProvidersRequest { //list limitations and ordering zitadel.v1.ListQuery query = 1; diff --git a/proto/zitadel/settings.proto b/proto/zitadel/settings.proto index 7b4f656bf2..a2a6806c65 100644 --- a/proto/zitadel/settings.proto +++ b/proto/zitadel/settings.proto @@ -95,6 +95,57 @@ message SMTPConfig { string id = 10; } +message EmailProvider { + zitadel.v1.ObjectDetails details = 1; + string id = 2; + EmailProviderState state = 3; + string description = 6; + + oneof config { + EmailProviderSMTP smtp = 4; + EmailProviderHTTP http = 5; + } +} + +enum EmailProviderState { + EMAIL_PROVIDER_STATE_UNSPECIFIED = 0; + EMAIL_PROVIDER_ACTIVE = 1; + EMAIL_PROVIDER_INACTIVE = 2; +} + +message EmailProviderSMTP { + string sender_address = 1 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"noreply@m.zitadel.cloud\""; + } + ]; + string sender_name = 2 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"ZITADEL\""; + } + ]; + bool tls = 3; + string host = 4 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"smtp.postmarkapp.com:587\""; + } + ]; + string user = 5 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"197f0117-529e-443d-bf6c-0292dd9a02b7\""; + } + ]; + string reply_to_address = 6 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"replyto@m.zitadel.cloud\""; + } + ]; +} + +message EmailProviderHTTP { + string endpoint = 1; +} + message SMSProvider { zitadel.v1.ObjectDetails details = 1; string id = 2; From 87e8ac48aecc2240b87551c4b51b44e7f9ff7a93 Mon Sep 17 00:00:00 2001 From: Stefan Benz <46600784+stebenz@users.noreply.github.com> Date: Thu, 12 Sep 2024 09:30:56 +0200 Subject: [PATCH 25/33] chore: fix crdb version on v24.2.1 (#8607) Fix crdb to version v24.2.1 for e2e tests --- e2e/config/localhost/docker-compose.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/e2e/config/localhost/docker-compose.yaml b/e2e/config/localhost/docker-compose.yaml index 7ec0f28c5c..a14c0dd603 100644 --- a/e2e/config/localhost/docker-compose.yaml +++ b/e2e/config/localhost/docker-compose.yaml @@ -32,7 +32,7 @@ services: db: restart: 'always' - image: 'cockroachdb/cockroach:latest' + image: 'cockroachdb/cockroach:v24.2.1' command: 'start-single-node --insecure --http-addr :9090' healthcheck: test: ['CMD', 'curl', '-f', 'http://localhost:9090/health?ready=1'] From 0db92c69d4cd5974fdd5ff347684c540394abbf6 Mon Sep 17 00:00:00 2001 From: hofq <54744977+hofq@users.noreply.github.com> Date: Thu, 12 Sep 2024 12:22:06 +0200 Subject: [PATCH 26/33] docs: upstream sent too big header while reading response header from upstream (#8466) # Which Problems Are Solved - If you operate Zitadel behind a Reverse Proxy or Ingress inside a Kubernetes cluster, you may encounter an Error like `upstream sent too big header while reading response header from upstream` - The Docs explain how to solve it # How the Problems Are Solved - Adding Troubleshooting Docs for too big upstream header, so people that search for it find a solution. --------- Co-authored-by: Elio Bischof --- .../troubleshooting/_upstream_header.mdx | 37 +++++++++++++++++++ .../troubleshooting/troubleshooting.mdx | 8 +++- 2 files changed, 44 insertions(+), 1 deletion(-) create mode 100644 docs/docs/self-hosting/deploy/troubleshooting/_upstream_header.mdx diff --git a/docs/docs/self-hosting/deploy/troubleshooting/_upstream_header.mdx b/docs/docs/self-hosting/deploy/troubleshooting/_upstream_header.mdx new file mode 100644 index 0000000000..773e32aff7 --- /dev/null +++ b/docs/docs/self-hosting/deploy/troubleshooting/_upstream_header.mdx @@ -0,0 +1,37 @@ +If you operate Zitadel behind a Reverse Proxy or Ingress inside a Kubernetes cluster, +you may encounter an Error like `upstream sent too big header while reading response header from upstream` +in your NGINX Logs and receive a 403 Error when accessing NGINX. + +you can solve it by increasing the grpc buffer size in your nginx config: + +### Ingress NGINX +```yaml +ingress: +enabled: true +annotations: + nginx.ingress.kubernetes.io/modsecurity-snippet: | + SecRuleRemoveById 949110 + nginx.ingress.kubernetes.io/backend-protocol: "GRPC" + nginx.ingress.kubernetes.io/configuration-snippet: | + grpc_set_header Host $host; + more_clear_input_headers "Host" "X-Forwarded-Host"; + proxy_set_header Host $http_host; + proxy_set_header X-Forwarded-Host $http_x_forwarded_host; + # highlight-next-line + nginx.ingress.kubernetes.io/server-snippet: "grpc_buffer_size 8k;" +``` +### NGINX Config +```nginx + http { + server { + listen 80; + http2 on; + location / { + grpc_pass grpc://zitadel-disabled-tls:8080; + grpc_set_header Host $host:$server_port; + # highlight-next-line + grpc_buffer_size 8k; + } + } + } +``` \ No newline at end of file diff --git a/docs/docs/self-hosting/deploy/troubleshooting/troubleshooting.mdx b/docs/docs/self-hosting/deploy/troubleshooting/troubleshooting.mdx index 911aceace3..a778c23946 100644 --- a/docs/docs/self-hosting/deploy/troubleshooting/troubleshooting.mdx +++ b/docs/docs/self-hosting/deploy/troubleshooting/troubleshooting.mdx @@ -3,8 +3,14 @@ title: Troubleshoot ZITADEL --- import InstanceNotFound from '/docs/self-hosting/deploy/troubleshooting/_instance_not_found.mdx'; +import UpstreamHeader from '/docs/self-hosting/deploy/troubleshooting/_upstream_header.mdx' ## Instance not found - \ No newline at end of file + + + +## upstream sent too big header while reading response header from upstream + + \ No newline at end of file From 3b140a67c8ff07e112be6be93e08ff5664bdb23d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20M=C3=B6hlmann?= Date: Thu, 12 Sep 2024 15:36:33 +0300 Subject: [PATCH 27/33] fix(oidc): always set sub claim (#8598) # Which Problems Are Solved When the `openid` scope was not requested, as is possible in machine authentication, we didn't set the `sub` (subject) claim to tokens and possibly also userInfo and introspection. This fix always sets the `sub` claim for all cases. # How the Problems Are Solved Set the `Subject` field to regardless of passed scopes. # Additional Changes - none # Additional Context According to standards: - [RFC9068 - JSON Web Token (JWT) Profile for OAuth 2.0 Access Tokens](https://datatracker.ietf.org/doc/html/rfc9068#name-data-structure) this claim is **required**. - [RFC7667 - OAuth 2.0 Token Introspection](https://datatracker.ietf.org/doc/html/rfc7662#section-2.2) the claim is optional, however there is no correlation to the `openid` or OpenID Connect. Therefore it doesn't harm to always return this claim. - [OpenID connect, User Info Response](https://openid.net/specs/openid-connect-core-1_0.html#UserInfoResponse): "The sub (subject) Claim **MUST** always be returned in the UserInfo Response." Closes https://github.com/zitadel/zitadel/issues/8591 --- internal/api/oidc/userinfo.go | 6 ++--- internal/api/oidc/userinfo_test.go | 36 ++++++++++++++++++++++++------ 2 files changed, 32 insertions(+), 10 deletions(-) diff --git a/internal/api/oidc/userinfo.go b/internal/api/oidc/userinfo.go index e023d7a63d..542ea6a083 100644 --- a/internal/api/oidc/userinfo.go +++ b/internal/api/oidc/userinfo.go @@ -163,11 +163,11 @@ func prepareRoles(ctx context.Context, scope []string, projectID string, project } func userInfoToOIDC(user *query.OIDCUserInfo, userInfoAssertion bool, scope []string, assetPrefix string) *oidc.UserInfo { - out := new(oidc.UserInfo) + out := &oidc.UserInfo{ + Subject: user.User.ID, + } for _, s := range scope { switch s { - case oidc.ScopeOpenID: - out.Subject = user.User.ID case oidc.ScopeEmail: if !userInfoAssertion { continue diff --git a/internal/api/oidc/userinfo_test.go b/internal/api/oidc/userinfo_test.go index 741f7eed36..2646cc9030 100644 --- a/internal/api/oidc/userinfo_test.go +++ b/internal/api/oidc/userinfo_test.go @@ -280,14 +280,18 @@ func Test_userInfoToOIDC(t *testing.T) { args: args{ user: humanUserInfo, }, - want: &oidc.UserInfo{}, + want: &oidc.UserInfo{ + Subject: "human1", + }, }, { name: "machine, empty", args: args{ user: machineUserInfo, }, - want: &oidc.UserInfo{}, + want: &oidc.UserInfo{ + Subject: "machine1", + }, }, { name: "human, scope openid", @@ -317,6 +321,7 @@ func Test_userInfoToOIDC(t *testing.T) { scope: []string{oidc.ScopeEmail}, }, want: &oidc.UserInfo{ + Subject: "human1", UserInfoEmail: oidc.UserInfoEmail{ Email: "foo@bar.com", EmailVerified: true, @@ -329,7 +334,9 @@ func Test_userInfoToOIDC(t *testing.T) { user: humanUserInfo, scope: []string{oidc.ScopeEmail}, }, - want: &oidc.UserInfo{}, + want: &oidc.UserInfo{ + Subject: "human1", + }, }, { name: "machine, scope email, profileInfoAssertion", @@ -338,7 +345,7 @@ func Test_userInfoToOIDC(t *testing.T) { scope: []string{oidc.ScopeEmail}, }, want: &oidc.UserInfo{ - UserInfoEmail: oidc.UserInfoEmail{}, + Subject: "machine1", }, }, { @@ -349,6 +356,7 @@ func Test_userInfoToOIDC(t *testing.T) { scope: []string{oidc.ScopeProfile}, }, want: &oidc.UserInfo{ + Subject: "human1", UserInfoProfile: oidc.UserInfoProfile{ Name: "xxx", GivenName: "user", @@ -370,6 +378,7 @@ func Test_userInfoToOIDC(t *testing.T) { scope: []string{oidc.ScopeProfile}, }, want: &oidc.UserInfo{ + Subject: "machine1", UserInfoProfile: oidc.UserInfoProfile{ Name: "machine", UpdatedAt: oidc.FromTime(time.Unix(567, 890)), @@ -383,7 +392,9 @@ func Test_userInfoToOIDC(t *testing.T) { user: machineUserInfo, scope: []string{oidc.ScopeProfile}, }, - want: &oidc.UserInfo{}, + want: &oidc.UserInfo{ + Subject: "machine1", + }, }, { name: "human, scope phone, profileInfoAssertion", @@ -393,6 +404,7 @@ func Test_userInfoToOIDC(t *testing.T) { scope: []string{oidc.ScopePhone}, }, want: &oidc.UserInfo{ + Subject: "human1", UserInfoPhone: oidc.UserInfoPhone{ PhoneNumber: "+31123456789", PhoneNumberVerified: true, @@ -405,7 +417,9 @@ func Test_userInfoToOIDC(t *testing.T) { user: humanUserInfo, scope: []string{oidc.ScopePhone}, }, - want: &oidc.UserInfo{}, + want: &oidc.UserInfo{ + Subject: "human1", + }, }, { name: "machine, scope phone", @@ -414,6 +428,7 @@ func Test_userInfoToOIDC(t *testing.T) { scope: []string{oidc.ScopePhone}, }, want: &oidc.UserInfo{ + Subject: "machine1", UserInfoPhone: oidc.UserInfoPhone{}, }, }, @@ -424,6 +439,8 @@ func Test_userInfoToOIDC(t *testing.T) { scope: []string{ScopeUserMetaData}, }, want: &oidc.UserInfo{ + Subject: "human1", + UserInfoEmail: oidc.UserInfoEmail{}, Claims: map[string]any{ ClaimUserMetaData: map[string]string{ "key1": base64.RawURLEncoding.EncodeToString([]byte{1, 2, 3}), @@ -438,7 +455,9 @@ func Test_userInfoToOIDC(t *testing.T) { user: machineUserInfo, scope: []string{ScopeUserMetaData}, }, - want: &oidc.UserInfo{}, + want: &oidc.UserInfo{ + Subject: "machine1", + }, }, { name: "machine, scope resource owner", @@ -447,6 +466,7 @@ func Test_userInfoToOIDC(t *testing.T) { scope: []string{ScopeResourceOwner}, }, want: &oidc.UserInfo{ + Subject: "machine1", Claims: map[string]any{ ClaimResourceOwnerID: "orgID", ClaimResourceOwnerName: "orgName", @@ -461,6 +481,7 @@ func Test_userInfoToOIDC(t *testing.T) { scope: []string{domain.OrgDomainPrimaryScope + "foo.com"}, }, want: &oidc.UserInfo{ + Subject: "human1", Claims: map[string]any{ domain.OrgDomainPrimaryClaim: "foo.com", }, @@ -473,6 +494,7 @@ func Test_userInfoToOIDC(t *testing.T) { scope: []string{domain.OrgIDScope + "orgID"}, }, want: &oidc.UserInfo{ + Subject: "machine1", Claims: map[string]any{ domain.OrgIDClaim: "orgID", ClaimResourceOwnerID: "orgID", From 3d87220180aa9dfc724496d6c9d7a856fd3098bd Mon Sep 17 00:00:00 2001 From: Max Peintner Date: Fri, 13 Sep 2024 09:04:18 +0200 Subject: [PATCH 28/33] docs: typescript repo update (#8563) The typescript repository documentation is updated --------- Co-authored-by: Fabi Co-authored-by: Livio Spring --- .../integrate/login-ui/typescript-repo.mdx | 32 ++++++++++++------ .../img/typescript-login-architecture.png | Bin 88806 -> 70661 bytes 2 files changed, 21 insertions(+), 11 deletions(-) diff --git a/docs/docs/guides/integrate/login-ui/typescript-repo.mdx b/docs/docs/guides/integrate/login-ui/typescript-repo.mdx index 4c864cbc90..d1a3f1d877 100644 --- a/docs/docs/guides/integrate/login-ui/typescript-repo.mdx +++ b/docs/docs/guides/integrate/login-ui/typescript-repo.mdx @@ -12,8 +12,6 @@ The typescript repository contains all TypeScript and JavaScript packages and ap - `@zitadel/proto`: Typescript implementation of Protocol Buffers, suitable for web browsers and Node.js. - `@zitadel/client`: Core components for establishing a client connection - `@zitadel/node`: Core components for establishing a server connection -- `@zitadel/react`: Shared React Utilities and components built with Tailwind CSS -- `@zitadel/next`: Shared Next.js Utilities - `@zitadel/tsconfig`: shared `tsconfig.json`s used throughout the monorepo - `eslint-config-zitadel`: ESLint preset @@ -47,10 +45,13 @@ It does so by redirecting to `/login`. The login is then able to load an [AuthRequest](/docs/apis/resources/oidc_service_v2/oidc-service-get-auth-request#get-oidc-auth-request-details). The Auth Request defines how users proceed to authenticate. If no special prompts or scopes are set, the login brings up the `/loginname` page. -The /loginname page allows to enter loginname or email of a user. User discovery is implemented at /api/loginname and if the user is found, they will be redirected to the available authentication method page. -Right after the user is found, a session is created and set as cookie. -This cookie is then hydrated with more information once the users continues. +The /loginname page allows to enter loginname or email of a user which is then used to search for a user. +If the user is found, a session is created and set as cookie, then the user is redirected to the available authentication method page. +While the users continues and provides more information, the cookie is hydrated with this information until a final state is reached. + +The communication from the browser to the server is done by [NextJS Server Actions](https://nextjs.org/docs/app/building-your-application/data-fetching/server-actions-and-mutations). The OIDC Auth request is always passed in the url to have a context to the ongoing authentication flow. + If enough user information is retrieved and the user is authenticated according to the policies, the flow is finalized by [requesting a the callback url](/docs/apis/resources/oidc_service_v2/oidc-service-create-callback) for the auth request and the user is redirected back to the application. The application can then request a token calling the /token endpoint of the login which is proxied to the ZITADEL API. @@ -91,9 +92,9 @@ The application can then request a token calling the /token endpoint of the logi - [x] Passkey - [x] IDPs - [x] Google - - [ ] GitHub + - [x] GitHub - [x] GitLab - - [ ] Azure + - [x] Azure - [ ] Apple - Register - [x] Email Password @@ -113,7 +114,7 @@ Authenticated users are directly able to self service their account. - [x] OTP via email - [x] OTP via SMS - [x] Setup Multifactor Passkey (U2F) -- [ ] Validate Account (email verification) +- [x] Validate Account (email verification) ### Setup @@ -122,10 +123,18 @@ In order to run the new login app, make sure to follow the instructions in the [ #### How to setup domains for OIDC When setting up the new login app for OIDC, the domain must be registered on your instance and use https. -If you are using a self hosted instance, [install](/docs/apis/resources/system/system-service-add-domain) your domain on your instance using the system service. +To register your login domain on your instance, [add](/docs/apis/resources/admin/admin-service-add-instance-trusted-domain) your domain on your instances trusted domains. -If you want to use the login with your cloud instance, you can purchase your domain on zitadel.com, install it on your domain following [our guide](/docs/guides/manage/cloud/instances#add-custom-domain). -After your domain has been verified, you can reconfigure your DNS settings in order to deploy the login on your own. +#### OIDC Proxy + +When setting up the new login app for OIDC, ensure it meets the following requirements: + +- The OIDC Proxy is deployed and running on HTTPS +- The OIDC Proxy sets `x-zitadel-login-client` which is the user ID of the service account +- The OIDC Proxy sets `x-zitadel-public-host` which is the host, your login is deployed to `ex. login.example.com`. +- The OIDC Proxy sets `x-zitadel-instance-host` which is the host of your instance `ex. test-hdujwl.zitadel.cloud`. + +You can review an example implementation of a middlware [here](https://github.com/zitadel/typescript/blob/main/apps/login/src/middleware.ts). #### Deploy to Vercel @@ -135,5 +144,6 @@ To deploy your own version on Vercel, navigate to your instance and create a ser Copy its id from the overview and set it as `ZITADEL_SERVICE_USER_ID`. Then create a personal access token (PAT), copy and set it as `ZITADEL_SERVICE_USER_TOKEN`, then navigate to Default settings and make sure it gets `IAM_OWNER` permissions. Finally set your instance url as `ZITADEL_API_URL`. Make sure to set it without trailing slash. +Also ensure your login domain is registered on your instance by adding it as a [trusted domain](/docs/apis/resources/admin/admin-service-add-instance-trusted-domain). ![Deploy to Vercel](/img/deploy-to-vercel.png) diff --git a/docs/static/img/typescript-login-architecture.png b/docs/static/img/typescript-login-architecture.png index eac2ed4e70505ddfca875de44f9557c12b3898d5..9b3c05535f8ca0e20bd661cbaa21578babc79849 100644 GIT binary patch literal 70661 zcmeEv2OyR0`~N{IRI(xsBYW>X53=_>GLmCuX75p`2*(zQWD}({?3Pe=hzi+c%U=Kc z;7ICyzwaKu|J!t)aXMrYHDF+0-@%V*!+o_gU#I5!I7F%lA43V$j*)h zZee6*Z)EMjVq@Y64uSL5wr~qm3lsR}H5_alT+D3z%xpXw?CjK>;@}caadtKqULIcE z&FhWKOl-CaRJ3)su(C3u<~YsG!Ul?>Q#3NRuyM3?FsJ5(g3q!xjwV*%e{dN5tE>+G z(E)$i*$mma4SDIokK%TARwmje=j1IwZBBD@^0IL9fWvh1G8&2s)EpAvXDbU!6Y!t3 ziLs?E@)ikmds}O8>Vs@ZMGWE#lns(4tcYqo1Mw#Q8QaxGb`{wBkUS8_*;z-zO4~u& z$x>C(!d=_M4*7tcrJ>5Sc9pg_vNKn-g>UpO+;y{9ejeVDxO)GgdmE!k?anUe+F#9{N^tqUD(ZLJ(F?7lr{Y-?jtD_v!10}31kk8#TXGb@bIfE|S zFnA|xD+LQv6DtcFa3j>t#NNW%#BsyGt+o!Ud_4wrF}H9uQMWTPMn1&_SS7f}+|k+! zd;;hJ1(xDy1WH2s2k-_c+}_qs!^qwYV9(|OV_Se9b`BQjHmZTV#NNc%3Am)QiJFPS zriCJp+B!KRYb3F4dB}ryw)T$Th=Zd&cm}dW6C(#l7ZW7hL2XQ2q~O51Pz~e-c*JwT z2|3nKlkF4RhXBa!IB#{%cE{acFGAwcb|J`%zJC0s001hE7Qn7|oZfjWXrME|+wG#j zy}$r=lx=V8WCQ>13SLQ2cL48J61G;hn~1{+H!|TfH3r8)C)iqo{@FgmW6WoA&J>*4 z9BDhFuhzC@fXG^!Y+=X7p`Z5xl*0h@F#!PVXzvD$Y3p_llqYT>5AT*iyL@%yZD%## z;lkX=LXik%v;`ez+eIUf{u1{B*KB#`&sYBkPW(g90nbG~dWYwH=PEl4+|CwQ-bUTI zq14>S0ov&?z&_1w&1`Lqtakd$4{h0T!zlK@y!CT&Z<@%@ z8UN-LJ8hrqpJ)49?bu=a+ff5*WNc|-13(Mu91bW{<>2@)Y;420$$w#E1G52afA{WrkBWLR<_2L$V1x{9j20C8}jI8y?;QW9c-QKHKodFtW{?1P>JXQx|SshiuvARlk#yGXrIWjeQH3>`knU96?gG zqvRix?w=>oJAoYu=_*M23mS(?1Gv9sLhQRub+fm&?n3s;|7Q5#J`&i05i;*U+5Q&) z20i@CE52Fc530|}^=&xXuKusBCAfaGqQAUSKd25j-?zQV$+=ZMRO2^I>UZ?!*8xNo zX=`fgfD#6OX{$Lw<_dDK?ePBdjo!L?Lr?i%SAHkE{1BP>Hy!L-^s%xvv#{Abv4gGt z7PQeY2L=5?#6dyW&*Ke{U+mpn9{X+Wxd7m)trC zKMhm?aLMmOsviK=5BqxyQhq;B{WYM($@#4tvGeb8qdyUpem%f_b;&JE<=PEOyQ}}3 zQQ$B1+aE?oR3iRWqtunv5SNq!x<4{B>~P!vlFWZ&l4;6kY-0RFZSou+Hy2R#e}-25 zf!ZYZE(bz!l5c4C79{+0lFT1^%oaj_6X|xj%8m%P#f<>t{7;+Q(EY#B*#AHg{x=!= z4+j_piGNy>`9tI1#M_;=|MzN>KkORLpJaI(6RW#J#tuI~k+T1pSN<7HZzna|=6y(> z_KP{+X1jlodj8Lh5(Rv}pQ&wekl z7dtyI4;L@n7MVunU%#2meV^2#iu{MCbU$=<)FAJObPgsC$b}j#V7Bd#^mQB5ItV_b zPyP~B2?ZzM`k&F1fM&HLDKl}kaMamK(2zePbG;1^06%|C^^hOF$@Gx7Y^QzO_aKwS zuQzRcLf!R~Sn)q)s-M7$Z*?n6c6&($6BnqqrKS{Io=wTch23>?slm@@@?VW}TO`=l zjF2i78{cNd|0+n~VEGnQINA7Fc)%G$`&WgSZvmNupN)lM%O+4(`ny5y zr-RBLAUl3XT>tOLj$1>sgIfaM+@hEN3fKHeKl&pRJuW_uzm%16^KGeHTjSJAQ4&(k*A>M5-!$>|g)v%HRIs(804MX`!G4CDQ&n==f=9 z+O^6Kkde&@^6Sdqm@fPSmNjr8claQ)nBC1q;mB`>roVkcb1Pu}$1IR5dw#s+6G+H^ zH?!d6K&m~PHi)vhe>g(yA{RSb@b@_JZho<|MyNjh&3^nnlA((HS5l1c*C+j-pcucO zI&4vAqymguCHYseiEq<$&VO01u`8f&SO4$9g53dqyE-VZ{SOE>Rsy30pl|5`*d}Oo z4s5R3j7Psx6m$Sx>j*g6#@gsDMfV4*r8h3y+V9KvXU=nNJZNj)>u1;Z?95MoZjs@p z>1^u3KU7*#!14Rh=&ymE?Ufa%)a4I{p0A1d7QV9Yh6_}F@rU{6Pb-msfU?5zQ`P@< zX!kv-_^IwejmWPn|5v6*e|N6Jv6Ui!3r2tco{e8NyZ+6r)t|LviAe7(luKsUM8~%1j+Y&gxjjdmaK97;L9bn-$=Nyp# z(SggrYh}QOhs_mkzip4?zcv&23mN_v;oDfnisC|D*G|#>y9)efXG8h-pPB>gPGPpI|7%eQT>pEfFi^wwZ`2_rVP9l z2>F_%9jlT6LTzlxHU_a0`7$Rcil(F1)LUD?;mB7w{qTiQ->y;Hw8I~)0z#Jg2fdF7 zObC8s?mt*chMIKSxsq)62HU@XAsOeE+wKS@;FWosZQG$0{kK8xU%5ff)P(298|2`; z{O7q7awzB6;$f)lamTXDf8e{jepU?J;Vs{}$_`ZfZ-d-l^p-l)-(u)L9$^$7 z{&BGaCHMWD`EOd$&QP(fl+98hbjzKMLN z|MUI7_f1THjkvIDevEr}7V}Ssh+WbMs``HuM4$k6iw=C(oBv*j_&)#N(EgFz=l|uq zYFXI1cmSzD?W_fRZP+-sK7ZYx^v&*C@L~7vq>U|0I>;TjEZ^?WXJO~(MV9t6`))VR z?JjVWH2y&7*tz)Ohb$MBk?op^`2S-wE(hnvmf)}2t0q-zcA=eDWKHwM#htR_Oc{n{lZ*I8E5$+$Knz`EC;~_+AH2 zBP724B%u6(%IWq-p{@A0{l1NF3f#c?vjMyMw2@i(S-qsPkk8|UDM>v4s_ScLpC(o{(7hGU5{^n5b7+rpL z7QU^G3_Ar6PJWiHJwt!X2>H2Y~O|bUy@D#c+14L)bvku$&JMK zC%!`kl@x#dvYMTpuyGdk^*Ea+wr&8gDF9PR$ggBESMo52K*%AolHwY!2BV4lq6npk z18gH5q7LBulZj*EQDM;>mW`m6sT050N2SVvPkq&Qf6!fDbaZt5D00=P8xE)UpiyIe zsb_R<)&KA@>*LM#@#{~#4(*F`)7SJeRGinIC>`gi2*HMAezY`Lzl__c{Nf4`4#vQt ztQNzy+WIVvK@1;aAzaA`p^9J}A~rfSBD<$iIJueoiIUMZe6dx~7|!aWL1UZ_uBHUU z3lCfue}4Xg$U*?G+hK6_U6!xp+GF#=W{kSw1Y*s>g=ekdhYPHtlM>a@7`mmgCp|}` z>mIn$`7;HhFTEbub6X6ibvur{@eYLIwGA#`h7UBx!SiW2c#IfkrY{*?g;Wk1U!%lj zv8rhV!YUB?d~gB+Dszj79DYHB4)gJJk0h#U-7w7ku$wK-W@n5K`qdYOVq~CeR2X5~ zHIA@de#dtl8WYkZm}Lo#i7a_~&s7(#nTr5j#)PDjU-?n(TxLZis9z2LCpBNh%O@3% z6obCnL~M%pMSNip(qn<_%`Mk0;thqDMx%^KN>cL zpU0WUn;wBYhm;UoIgnHwc+M6mU8vllj;0UlRV24qeuColy4RaisptT!Knx*| z7l$TB1C3TFFt7u2n*)u$TpNKXHY;F?byUMiDi>wQEg5|KARQQwF}hDc?xRI<9x#^D zSWXirYEbnZhMtHW+> z@anVBEC|v5{h;z_pG@cXRY%8qgqY(AV_u}l5%d*!3ekMSc8xht<_JWV*amZw7UGw! z7M7wUd{SIn4l;c5p|K~{s}DDTE8;wiCO{#^{lFzC(Ceg4`fe)Gx~mPvp09ZP)^gC#O<%> zg4BsH==yZ99pL1GV@g+aGx)ls;h-s?Awb(Lo1E$l~a60L~09%#IOemNCO+09(!dyj~I%5t0$X1b-jTe+RT-KCSyr*ntET& zONvE5bblsMyOhg^{1E*V6zvCqV|-#fd2 zO;_sob>X>kE#Muf7m+*l5FN3vLIQ$+3;);?W0jG-AffDf_oEfl(hXXpjf3TTq|3E1 z5Si}|HgjQimPJa-kp!~)irhx;RPhbgvdtVxu*NU&Y_>hlO*DBF^QM1pw|Qhm<4Hdz zH~&i(@r8ZYwQn8Djx{2YP;A!2BxiwSWkf7xm*Ou*WJX)WA44md-XEV@_SpflcyT_7 z)#Vgv;8d7JycAm1%wv21pa^C{9gnvY`bl`<7-_(dX@SEproI;vHP0&%%BMebEgn7H zf+l9rTsTNB(V<24iRDwS+NfLS^9qFBzdRq44!eSIG^#RctDU zktpVhgs?X1xsXFT65YqVd+30f+m)f~JrL-UX6J@NwG`;bCcXE=4!In@EDdm|o~~jh ztNwA#XAEk%oBp}3Azwz;u1NxES#WL!REvN6wp#BEGu9v{C+tq)CjXG@gz#r#zHTnL z30mOUDt?*tVpT!$2Huvn(Xm}cG5bz$p5pgor8V2z_Z2SCK)fq z>zVihsZGq>e-`tx`N6Y;q8ElLzf`i(U%pRhzQm8d*W2XfAk!>=e6mp?y6Ku*$NL-g zH?`ind-!oB)gK&sK9e;|-`jYE5FTPM+Q`!$hlwemxp&l;saVUvUR77b@-F7f$2qh_ zbY023=));}vSX>tb!N(p=kJt?CDfzCYE9ofpdp=~i$iaY=R&7=ZEWwJPEkz$I*%0H zAWDeSR;WcTSwBiBEg|Q`=54~o1NZfVwV*MDi#@}oSTIl*j4`|dA#%!uD-Y3~^`laR z&t4vrsj`oe7>%!YX4b`m)y4bxGnp+)gjBLr6eMKfZh!I&EpzUC*POR#)s_++ z&Sd*0W$2M?RZy9;!TU%=Ket8u-9TgwxB>?VXuuPn&^HWlQoM%EgdzHweAx7d#C-O2 zAkfEB`YhJ*3xw`u7S5qtix3p#))FgEC{|5BNI7X)oqj#dK~e z0xg7t_cMqJTW{*mTpxy7de6jyh9CMu1!}5EPm1vrs>zpz zq5GCjH3Y)jq^b=aY2f^?Bb{r_pfUFc{Pb^Az}wS2sa89Pste??4WdTXL{_}Vis<;| z1BSu^XZV*o+>Dir2RNGpIw#X73T3P8lk0)aJIX;0R_6srl*=f9KBmR67U9$%%3YKO zA4KC=9Fs-St!4b2Ri_{LY>bVrhaXQ87OZt3IAb1ku&yfZ12b(Zm(Ly99+F_Lt!&hVL`Pghe1ZF#kA=vBQRaB%^B>SVI@90^2V(yiv7gi zxg`j9PK-8>bZFAE(I;K!-n^)PJ~q?7E_}(Nci*XX95-lLl91OSkri_;h2|G6>gC;O z+Kdzql?M=0gH`LNqh7HHO7eJr+zUE)IJAD)8BaVW|4mz^Q;zYRV6@WWG5W!+aY1mW6<5N} z?5S}du5#BUuJ{sNbVIAoi}{t;o+iG3)X*Dw_0y8+tFX@vk5naQKc*7pAb1>TO0*}b zKNNdtd&Nv=WlXk&CK}BNmwe`Bif6|VWidz<$zK+io*k?n`*5|_e68~H!r2!xPN65- zN)w8+`l2Vi_7?P1V)C3U&6A9qUzEHy-u0UCBHt}lq_;+EJ8G{vb=~o&Hx_+XTfSB~ zBlY^zNa3p)3G)8YC&5F+2SG${9`-thDDFPP=QCv=H+-11ppP<&5~k)j%z40NqEq6@ zBZKE=z58yTFXhoGwNZ~-Rm+g`6HbUV?tEr4Bx)sO^RXx-*1+>*mp3<7WV%5GHAt-Q+#_nQCSsZ1YDU>ZGHGf}#)|sn=OQVn~ zn}}_wOcp#t%zDc{QA_p6D@>TOs5XAvS_xkN>c=s$D(oB;1#zt=Li)q3-YbZ@`~BWW z$ZF$5+z4p9gTqe|!fTHT9{GHP724ES9j)IkFmX$xaAau7#%XrlXZ+*y5X&}%u<}WT z74NjAT%YF3cMnZNmRz#AET;PBP7Ey?Sl`m>NDAQ!_DB2G~DV&Un>#ksZ^^9ZhFkuG#Bq68oh-nSZ)!Oxk<-Bw0-Y+nBe1)t^E_ROo+VOWMsDp@}-CVug z;4;ZK+Tj7YBr=+sqN@0Q&fkb6j^BdMySR4*$glgp+5R zfalyxp#vv`sr!~q4_cn9FMIy3sP_?@^vB!A@?3dmFjxs0!#vhk2&VM|9(9S`T<@=V zTy5wtrqv^PwIR*xK0?#&V|;YOLn*JS!I?5xAf9msrPip9&g__iLXt4qm@b#t%EA^Z8Y0~Q$&EkzdkB`c>}5E#eV?+RiQxt_EwQ#bT# zP19F@o2`X+ulGe|P0Q!0Ps!}Alig@HJ$Q&K4}1<8oLRwTY_qCJ4ku`_m7#>8!`f_D z0>r%pDD>msnt!~{JPxV`>o;RMgJ2Pb!;znwKABw;m<860#?#%`OXK{>)3RU&*C&e~@Et zt%&IMk`r`_x2C6b;DidLnSGRZhQMj>hY%c7#itQ&Nw+PQJ6#th(K=dk(%W-{P5qgK zKT>l{iYHW>e@GYHO;Qb%BDDF!j?ttz&}O`I8SHU+4jbk$zxUqA9?(bPBs{(H_5O?r5Ndw9?s{xG*+{ zI_R}TA%fU28%_F}JhDR)g)TM8;)&U=46k0(sXkEg?`>0$Mx^DaOFD;($FRQ9_RGaB zE(kmqFHh>Exxm_G5dGzk7(!sFnXJ zRy=r0{PXFB)f&w&q?T+cBI>%ionBZ09Iq-9-{U$JJ$$*z zpG>GDsEL_j7jVbgF!N)XE^5DSRi&4dCH5{J-DkD1W*IFfY5JV~y>+FRdp=`!iSz2yHQo+%%1%*RY;qloewH0l_?efn+aZlvKhkWfO`VX+ z>9e4a9D<0;p`=Krv@5N=Bj0*^e$b;c}SD z1hZy@s;M%ch+d&!Z*Xfm`-q6`L|;1 zO_85H|8D(tD1t121x`e#ieH+cpjxP7k7npN$tE(sfY$20tmqG1c#)XRdzj-b^A~)E zx3<3X0-nCc9`k{V;@*O*oVlxWXP_~4!?`scPV7UGtQQ9QR84MDpHW&_Z97;JFNmp@ z>Mkc$AlD}sB$~gLRz|#k*9!u#h(ozdv*g8M&c~ z%3?8Zy;h2humVG_U!)8$&_K+7zPWlndbN+nLW=(rtwNmG)1chUB(Epe7(x#mgtg^~ z=8;_>swk#}HJh=DMNnOZ2Dzps2HBS1Vo``Fa7bLyUkyFlaZJk48}FsuWoG>7a|`${ zv3r8^%dmJO3LT!~_tk`RznM&18@*24rP1qs?}Z?rbI*gNwSg!4bZc6QH-vrs5GJp} zcr=m(5dD3Nqy|NI-@V4Xd%jVDd!EYuZN7(~3`O}H>2?~0#1Q$|{O;8wvr}r-az;Yk z+12)53+JB*EI7@3Yh*2G`IjqSZ@Xm+3Gezc^3sspB7Nn&cw>>eA(O#FU2b}dm(K#G z1rHUB>OQEXf(xHXhMe{(CW&0~zLIv|G+?Tj1@3f|^N6k1oxSvNvCON@%Fi_qPh?lS zb3|z7zIkuX>qyecr~`Bs-wgi`@|fG`Lt53n0fVH>IW6Nrw7aVe)kYfq0oKQ0!0x{&R> zUH&UOK%pqQWS1t!OFf2~Jj@>>3g?R3-#uO=K5$~@@i?PqP@haB4fmlF{CVW^=S!4s zibz*AC9trYreVN1NHXf$CdnDyrc?QfeNJ`ewUR{FL_;*#q>IO<;>sbTdbb|p z6IBtqd6*vaM)u`pd|rKbrw5y#tYuId&l<3#uCWG8C_}aCuSOo`H4r*r)p-qJGW#Zm z-;yTxo!b%MAxAFHpTM>lk7O->TI%7#-t&A8yC7A|@>zojo%Wq0fl4=n8S+5dT?a-d6(M>7|^R7zD@h4VnMmQ zqFPuq>lKwk{2EcGXCcXvH}SsDY_(o}EOxnP*{sIpR=#g7mQygk)kiz+y#fK>eVbe)^)ud}f!S7whQxpfi z@t`q~RLhlphlE2{PCFSFv1JDZ6t)F0Wz=c=G08sH&hHwSl45nBd0?%o>=*i0vpO*l zeV!whI)sk*OlC3sm3NT3N89Z##D#1cOPQ`ZnJBhfa*@T@UDP8U=C5!^TxQ0aH1XC% znlro@pj8pa#LsKzB$F!#rDBfq*sez-h=dLrqZjlIT=L^I`_vwboov{9`tfXAFH?k- z{j2VF<5v&JKi6h!HV)ht&(!ah`hZ(TFVj8zIkCKF#_0K7w|+TH7+IlJ=zZxh3hxwu zx--S0Ma6gZUrsYf(FIH_8oFUdE_sp+T&A1Vr^P*?G5icpuXe6AGu)_5^g_39Az9Ht zXJ~oxUD;)&OoMz(210M1Hx#An_jv-9)<~m+`(O0bN?$lT&sr6kpDcGuY4NeB$U6K$ zjb={hnz3nj-Pn7XM4A&UiK3S}C)zYIXOuceFESN#O%cMY7t-v!KF4EX7xdvBYGEu5 zxgII)f4sxjsCR0Lwwc07ZK|hIYSASjt9a(&oz=a^ygTz!vzQ|M;6tcU$5p&9j zvMO+oDaPwR^fcD6CZp+S`+d_xS0k8VWqZepabJ{rtapkJjp$&zWhfs%LbKYI_Zbpx z;7C*^x_Bd(usZV7>-y6HZ#~C86yiE1xI81%UOzxwKy`v%Rj6&@xyONk&@e2R0E3j( zZ5)_VV*V##T6ywx>GE&{zs^WuPn_`V1yTdbXF07llCLWZHH&n|#TpL&*hw%OTgZZa9sXWns1kwR-+ZxWLxbk<*QECELGY1 zMVZP^-fzfPCbKDh>UFbzPxp36RX4MI@|=5+(%OOZU{Rr}fX7g%^ijQpRu9#bN{;NA zH#a#**FWGe)^k2ddWyqJ^fD!%j96x=#*`S|u9;a4bTCs>q`G!TWLsZC%10EhJ3Obp zbc*1@ZXS7o1;n)*-2oLRE&;{NNA(7fvXncxlg4x(;1}HBUf&N?r^}=v`UsP@nqVNQ zDMG(j%M_}n19`pj6`?U0UdTsNW3tLQ)026ZgI>Fl2K^%aeDS!~SjoK%lzP<}C)H>h zi68<8AJS61wrnaeI*2)1L}ziJmR12g(Ox}yQ^g_!>e|}bq6WXU{SPQGk^Qj-Bv@Tl z%Q4Ijpy$C7SZ{VFAo?w&gd1+^hcgwQ-|wnAU6DFV#px{Qo?565?8r9@|%>jqLNGES9VM7~A*jRi$(EMsKicd>a z7055jEEO_Fn8Xs6&;gZgVYS0a)jkHhk?i20!n2s$W-tpM3#7!eH|{l306P5sG?2-w=d>|!Izr`?xZbRxbZZ{dg~6U`_8n-OMvOkbTXW-JHvy~rEV^_wBaVV7^uSpevGUmW{POz zW6EI0@@#E_Ka=GWyS}-iX#QyJGnf3Tn$z4w-LjhMbn%P+=r`(HKS;`atuq$PAUrzc zJVQlrM7j#s3>GF|lGiguZ$TO068f(lZ*gbX^-Hbh(pQwOf(PTSR40_VFewl(-K^(3 z$BS*(UqcT?&R0xcL-*IH9~#2Fv!M8?Sd{O1oiRDQJxgRc9ZXv!A*aBSN-V~7R`wRe zu{_|xdpw=u-AdTqxt0p4-`83qTUC%w2K@2MOid?~=X_AEm4-UnO%3jVlYOK~QtfN`Mx(D&#P6`a4JFbP;u<KWJ(mv)dZLzynpB{BSi9zvNX||mgR3F*H8Ebut zmalqv5!BqUA~mknq}mJ4uk4R$Lu8Q4p)(uzAMcE6F&Q4<4!43FR-hy92aw++E6oq>7M;C87e&nT^YL9i;|P~U7M(7~B;}BJME|LAN;u4cP&;pF z39sNwVG1?Ten~Ko)iN(YA#ccdh8avf^wEO4gLI`WC`N<)Fj4oSM%QP5Ca*hIgu<2a(^*zNeKyMm*c$|yk^^x}5o%9rpBk=*9mv)uTyE+hjn+CpkrENb9h&9nU zn}wOnVxp^}fuh^6Kuy}u0|%fH1r6A2c`CYJCK$CP1JDpllN@RQMk{)5s5X^lRov$9 zoM>dcwRd4)XpD@TLcVL?k86mZ`y??~YqPl<57Aun9l3!ZQW-x~b%`2|nfl(E-y9ml zTp(Sl4%K?gjZ?Z$+hA#F5f6TSNm@}Jm+`FYW8pBKS2KRn{cz)OIlZmbb{vi22~?E7S_Iv&Ux?tz%pL_K{OOnlE{-sZS~Io3OvQ-vP}9jt(=E=klj>&_Kt z?@s~5c0Dh!R%Y7_b;pXyEkUbw?wcMA#3c*W%+abY3k`)&c_#OF?hpCS(w>Nl$4Gk1 z#j=DT8>m}&eyF36(Y~+Buh6nB1$N(5CV~nphSf0iA|?(4SzDI+K7NOR7ofY@y_Ot5 zlzAO<`rP{R#Z>~$2LmnIhMLY~o|9;0KxGzM9=#tY;KX3yIVH6R6VvEzrYg3-fb(c3 zr4F@Rl*)3=dN3jVMT_S2TPnAp6f+iWdHM`cny!!&f?6Spm8a%>))vj;+~(^9Co+5K z49kQHOkSsVEBnfXT8@8sLc(K3vbMHtn`=<28!~q1QRg$u{L(<6SKrnzc4mmDS*{|x zrx?R@{k(Fh*K}WXrbZS%7@EtV);U9ok`LU!459hGILc{ycBtXjc_A|I1qxCgt1HbJ z$~o(-dXEpI!$8|lm%7iY+&d@rO5zB`(+>I&M0P$I%D*4hkf|gt6ToY43Li7{nvD)B z_)zSkjMnzh`u$roM(w;8JuibV47@o?QrTM}|Nf!%)CY(9TN0NKA-rBXgZYDZ#V+|l zC%J7qk8@kLO5V{eM2Px$%P^=>nxNAbD0h~)5$%zc%sBJNCeyIIEGX~J)_ z*VLDmSLeIf&%M~MSL&{b)`g&uAm>)Mzs6y3)gOxwo2!N$=KwZ*cBDoAX;&P(vBnFf zZn-jGwq!NYjKt`$)#Z8Zv((nXd#~bCtBKJ;Xc5%zD|7E{vqe7FB?y3Jb5`|@Ju6?6)3=>QPE}bHUE`Q&&R?U~-Bpl0A z=sL@BKD*?L_dh&w$dSM_t}IDR6Wc z`UPSZJzwAOYw}`pJZM};!>%b)DjWK&76y&TzWw~^`DF?Ob>p4WA*5M!iB{2!+FA&= z{u&}oeE=w`Nv!%sgp69bDhO(b>&$>++Qd}?+T#adg&wsNqApd%tFt~t4^q(^kt>CS zKc4esg~o7KKe2CVt2ZRiv1%_!m$asekK!F-L(v*`C5mXfJpRJ?(-jAG&STX-SO{56k~u?h5jt7LCIs%Sj$NC`T0%{WvRzh_B07% z@k8_oB2jNo%==G$2hFRR*0~!vgplzriFh}PinaDtM=EA1N5tO({>qSy7>cYwNJdaG zP!-CeOVo?5m&mnavB?Cfkjq1)nhYbb4|uuU!YQD7NmoJP(jrTy7|*4OS8(oS5dD5E z+=5gQxuy&F7mA$6;_ZX3NjF_$Ennd{L?)8k6smTcde-O`?^OmYu>d7vY`w8}_axFt z(nJ)JIe>7-B>PrYEv_jnb>4k$G^;5IKg6=VAWe>4?!2mRGbRkbCE{($@nGNNFU%Io zS3_m>6CTj2Q1?dDT^EZH8w3yOK?fkcrd&73dMdK$BL%yC)lV-u1@Ko_JOqvha9^BWCW{S39KL-eJMQ+ z_)3kIJT&Ic^gfNjH3;nKq2>BWi6rkO3(YfBP6`oF!o&D>GKm6(K|-=`X}-{ev%1~z z6S~5cqIf>M4-b!48SrIT<43BOw6hBMsyGuD2!Nkx)u%=X9>vV1BBh-*yv}KM=t(nc zNf~tyjfLdV1j&??0$I_N$PX`vI#PW_J?2KU@2KFCX=W*8;Y30`G642cFx>Ahf2j9h zWP?xbsNVrt*|-e-Y2w3?x^#X_S0nh12!fdqhrRY0_MFh*CAgTw`T>f&b^d3=rV+XY1LPJ|%IY9b9-mIO`r zCgT@CKi(<6@WJ=gw4?@kPW1r?-!ox*tI&P#Yy$5C8lPn`d&nI!sr;VHr_xH8 zoR$~;yn^uxQurIs*+0q@(`v3yT(xhFdmG{&tvJp=>wea}iv(av(RkVeFr;=T6aiwADDk~=XaG{=7=D=n(e6+Q)@;9jj27fKY1JrhSoO<5!Qfe=~Urop8r@> z=w3a)kIeIMe;aqT{EL3!_}MvG^LaY1!;9 zyc++3RXe_Ro}PG))*Y2}g`=2;uR>2qfN0)O;^rX3!0xlQ;?KlO5HJi)@R0?%)FEUJ zx0MGW9~ldXq#wqt$Ni-0Dc$-j~FNFUH9*oitJ)fB>v6->3{B!`id-f_OWbD80WK>OPKnv+v+fneYIt&NS+SB#nm4;6hef~WZ~^6F z+7#X)5qnZfkdxhBIU9#uFH4C_p$2l%u>R}3wm12mMl^^*?8Re*o_z`vU8?|pGezVR zc$h)#zTI@WI)Yk#76usG^V!dB7bZG|XP>ORpWVMNt2*1TTm*2?z|r>my$e&l*Wk5h zsa{WIAiIf}T|e_{HwlsXFEBs591d8qcG@zFK}jaL+(XaBX$rULzIP>VoFD|Jt=Y8Y zo$v-UJn?EpG0rK~tK2XF^{QYVFswcH{ab8UR}GC!n_N!WlXz%p>`@n+YZI^el#MLW zl>1m=n+AJH<>@L)xP<~i0?<%f{dZ)=z#6XL!>jMrI26k991qSB6j8^^1(bJKP7K^J zTo)bp=oAn&ZtV1y27a=*^u_qMff%m16$a1Q45n&t&#q)XF>h&$3YgeRVt3}3A^Y(a znmvU{a?eAqV351r6%!>WSW9FOK#(E`7;jtxNpY0|Si-w=6eQ8E*9+KtE~+}c2q8EV z6pSBe>_bo?lIUzMfeB@_^FG^^QVZm=8Cpz`5{EtBhPK3f-#SmFC^m{pg@pqX(OyPG zzD*Dthz#Dy&>Y14)5`7s{fX%n0Bd=HwmT4QR|yo`0jk!+L!UDoR3lnzc!!vky$)5K z)AHiBuy};7nph8(5~HgSi9J8t9Z@rm6KK>|u&i^VNtF1`(#^9)Dm#S+;+EhD@7I8V zvgi9oFIvT$_H;j+KNx6q{Murp;C<jg)LTv96k5oGOs6W&{Wd zzHn2cGJUN@jBE#kC`zYCT6l$FiX`Pqi}oTO-aPh=+4=M+GPIEb&7??HO=n=$FOF_# zL=>Pa!Fo-k$N&=C5<00vFxJv95$>|%`Q>Zbox?ovs>5Cv&{E}Nl}d4yI#*5OL-$`} zk9c{EKZWbEMQ%O>Rd{Qk*+D-bQ{+}r$C+#tc8 zl}^z>68^4rkSD!+eBspE%8ar?92*+IC9!ZysfIgecvF=UNvY*y{7XEw=LoJu&K=`2 zMY1fSx%^x@DgHATy>jrUY^Ii}GQJt4K?1FbJ5@cVd9EO*?>Oij0Es z+#UPDI_o-+at7cKS5AI3-8%W6C-Z`T!UJF{Ia^F;Oc3#`i zD@|m%`w}j+DF9ulC0kPhq(tHT4yKqQ==xwx1dg!iU6G4s_yiL8>@Xhdj_&y`pI0l3 zGcEJe=M2z9(75~r7de3{^5zCV0Wz-zPbSm@y@`fJK%+6~H2RMHzzG3D5J=P>#$&$Rpi_rXmp7@kLod@M`45ec@69)gJ1Mh{aPrTMI{0lE-ohbUs8SBTX(+mtG+ z8GqM(cI2_gJPSzGg678F=QyUMPVK4c<7_*19Nqa)gJ@Y_OK@J^#xR^DN&>*bWr^p6lo4X()N@? zuuo$YY*gVnUEa$Ffp8@S3f9b4I$wDFA(Gyy#z%m3_$&G#bDe(C83a~S7ZGNbFFD`^??I9kA6Tj4u|M2IW#Tw6T^1H)Xn)79KNaFB!mvbV4TUZ_}Qj@;vvd z7;305A7EkN0e^)l|^d8^O zJudR&kbQRSkMA{bD|N0s;(Z?@G3B^?(}tD2lCRLC0pp{aT$TN@Lui997^ATrNRR9R zSU_&fskM;3ap|xeUWF9v$pI{;m*A=xgv@mN3H8YP04zgh zC4L|%j!Y0Ipu7?B2@0M;wR&D$ zovNlyN(PYw880hCPXq&%8Oa?-W_UYYF+ik{3?igQ!Y#c1<_U043W(QJwY*(t>u(BY z>K8u@3UV6%0Fw%*ToSTqN+l8X7OIJ54Fl+I)K?WAC+Z`Dq|zBl-lU5&gVOO_7HsF= z(rILC$d=l<19=pvwipV{lD(B7KsCp0X}flmlMUn&NSY5{0|L?Uk3~+;r+Qzhrpn-P zS-n${`nabgwF?vH$Q9;OFtpj=u%)L+F0(D{Y`3g+d7?bbzQ5)cAPPu*J@DYMKoH_@ zffl&-M{73}T57+|(ZpS=f3Q@dph1eg!Ua+)at^%xiQka|}!c@6;BIAIUxsujg3 zIy|Kte7DFa2Wk`Em3r{rbRVIIzlpd9 zx2bJlBV>usp#;igSg$M;Y88+bW4kaN#Sfp&<{mUjCxfdW#w+o)6WqbstP~F zc0fX0%QXq-cvtL=upa}Jv|owh zvZpd2!26K|Sh@<-738ZDFN28gc`Z9r_Y^sO);K>F%vz>WsF+s|Qu13VD$1t|NCE2t z2^*z!=yB{TfI@T*0{SNf2AdtwxacN=g$v@XKw;95l|3 z=IXGo0II@}QfTt}h(ZF-LF60=UXGy4c&2%@m?p^d8`2ezz0HNG6V1yz-T{uF^U&%j z2t1;(z7P7q?BN&+dA4T{jmSPtmanhkMsP$i0~L9wIU`JtY@j-FpPJ0t(&zk~4w|fb z{4-f;_QKD^E2PxC(UsK>vxZY^Q!$p*QwLFJ$z0W~E>r8B1>8t{+&=2dO~Mjl1`Qda z;>)3MtSVVABrVYxi%DfQQ9xG*@%VZBeKRC=l+hT^$uD~*I8kz&(nefdyegJ8X+#-& zjGEMWea#E#ruEe1U*E=PRogUX&o&o@yYE{o(ohJ<~4+K<|%y)_Y=~U;~QeWEmkhkN2*S^~= z#-XxUOoup8YXK6+8Ynq^KRz9P)=(~e6)x*2PYJu9?u?5~33pp}dUjmZJ$NgG}BrBAM^I3ybW(TsJ=@fmBK` zfVQ`pSR#ws?5vm%u*ZyqIv?E%T%cNS7XBZ5?;VeI`~Cr67uj28WY276m7TpQL{_;J zk{u;mWTnd<6%jI1R}`61xkMR}6rqelwrrl`Q{9cQd|?!&pZQvi7?#Qveyt>CQ#rk9lnF##?dF%F7Q`n^&(DM4CA zVno?>6n;!(8TJ0dT&8&%EQfuIEYD~lbDCWg$2Dq}Hgf*=l>*nW#lzalcVjxK(oH3NkpsmiaPND>wEK1~-S7DZKGXivGIky1VW+&l|k8=G<4p z2zPO(=_?TJEOPEQ-wS$ttaIH3j&&3`QRla1tXXm%_g*9dv8w)dugd;Hiu20p$UY5) zmgWbKYke3V_1i{lRp7ceCA&QB$wzKadkXZ;8Gx&_I#J_Ypf?Az3H7yoGG^U^0|zYc z#RF|qy6Z9Y#9Xru5ZOrJZb#tNj6ULS;=`KX(W0_x4BjtnmTwZ$8plin?n3jRo#i*i z2b^mBzFCiTWbTEs6?UqKi;34N9-}b1M;+=PMIi+;T>-k?o?+f}$netDTo)Ft>gz&W z7^UypaEFF5$DLkfG171WBCw!874Z3*oJrk9ua3-vg3n@FuXDD>GK!eo zRz*AU(yoj=C1en{ksp+$7ceN|Duw2dJe#lYBAm|nNrE#_mRmPgrn_&e5fZs8c#oNw zimAU136YD(JfEomxJx%sK0E*Rs7){5^ zz-L?SsaCK*WNq~7Zsh~$v#`Fo`5gQ*HJ+pDhg^Cz3Wy8N>oU{h&OF&Jpu-L7joA{a zr}?iqP46O#9XHP;*vZO-BSN7KPeWHu%~Rlq39)Y^<7@9N(Bn8nq&z+<9=ok;7KEev z)ds>uZN6;|lQ6fbh_GvVxNnT2?wJ16L+LXeMdTleo;~w}X&q}_KK~vHnLVDDozF== zWGB$3A&?bjJoEFq#|^kjgSR(mx!SXpYNyf@MM041n_%k54TBfi&(RPL{eo2_nL{e{ zZPY0SS;HkZlq1RwJx>xU9d!!d*r$ajUuX^KDTW#>aseoANu%V$2V(4Q$$RmP`Gl1! zURo?t_p71kuF~v{n%?BoP2xIMXi!2^+~)o8fq+PgYCNXqhPd5MHcYw>Obye&5cQ9i zO^06XqmM^pDVo9^@JS>GZHckNlJP@V%68YnD*6_7dV8nBI@@}Z+Jboj0_LRsO{L6< zK-dR8su5L9$i*1?4V6tee6IHWao#rIv-g$p=~u3r7}fJzNzq19!$*wrlx z-XfQ*X;j^8LCfifVRxE>P#<2?%$x)jF`xW`H%{EejLxGB}> zG9Qh=P+OMh+g8`VH!@?4TqSGeRFc04FVhek1Hr}Q9l%LXzR9N z`W8t%OZ6kt=3F_HL|xEl{&%=5!KJ12f59Ks;r;MvX|`Etz3Ch`c4h-Z_dMy3yddL{j4PQU<4h)h^e`W8So zk@u@|N`w67SM3CYYA&uEM)1F?WQLpv2Qg56h{z%cn$)Xt%n~I*W5&g11pxXH^66kv zPVH&WfvoGgf9mKKAk6?hrmY(D%?3aRdQ50>?$5reuLyO?dKJ#uXVTqw!zHT*&Aw;8 z>=esYQmcUA4B7Kpmq1xWqzjQE*h$4)sG#zGGb~Zm`(xptN5*H+ztLMia*F|G??A@; z#t@Z4!y8e6g7N^0a6s(DuWGvgu&*jedPek^Ki2q=z}0Gj4vAL6PSGGoc|T~nG+)HC zK7cd1Do3NPVRz?prOO-Q;h@$108JLih1I)g;ui~_xE-m44g8u!P#8MiP1FIlC~g{l zbGYUO^cDwu8@VvSX+9C}&I?n$Z8IP1j|doM<>#3|p;ZI%#Qk3ao^LnhJnV=ptZ2f?qD(Jldjd9Mst?*f6~* z16o@2rSGPK4+CY5Y>ZZ$KO$mC6nx;(Ta*}Uqr0a>HH=_tn81;S zL(yd97e-k?3BZ1N)-+H6fT0v{m4a~2Rb_y$d1bnyk-Y2&#*_T+Bm(EZl>SY`y@-cm zWCg zJ9#0}6u8!bFeV1-46u=2G!(;*6E^gxB;ewx|MY6C50Py|)Wm80(frFkI2yb|0L5)d zA?-`#)yDE@lADh-03i%Ddk+H9yC@HUhOj)(wB;&|Hs~#nAC`i?lDy+|YeKo%H3FN( z0YK!t8$))%KkU)CUDy+y6lIWgNEpHWzn7ZihELr+{k*l{g4t*OMA1SD&dqo%ySZaq()A6H&4E4`B0B>^GIO1$dm}cWD}ABL+a6qj!g5Q)Is4d?-2>u ztau8~8eq#;1I3A^3G6|jLb+}*a=NPQA1%3#8(IjWZlLmM?zIK5s*1Nr&PpZ~d?Tp<1JmU_yE1$LOp6+D z(tOkV5e)XC9CpTI`fe^_q%jZ+C5Sq8=)#3_S!eW6cTsf^W>q1DwkmXaK^>g(teHhI z(AXb#RArv~`_Fra9Z$@E@zch~-t!)mD1kX6uY~1WeK8yghA3Z>`J1O$4qOLtcpqwU zr#U?8)XXvyhA1MHVW>a7RdY`xfmYsGf{TMip(mlHy7PNS!2pxRnen@7|2SQeI25;v zI4<*sIz0s_pIKXwBEfTLi4Q|WICw6%NzZO5nWviO(%FtBP)lzP*~1yN9xeD|0BiRD zb<9eiVIU3mKV^*4MB4;1^kvaFP;&jxA7`gWvX(CH&17`)xs`w4zas5d3H>BxBakp+uyd3b*w5y&{#>D9Vzt zMKW-GR{i?g4FHK)|3Ciz0MA-C*>(E>=I^uWH4uwnpqlnM@)K%*F^q|c?(1{6f77F| zI;<5Itd6>q><2J!farycsqUw-dYULA>^H|4*q@`wsXr@D2@RQ^vj-IX*S6PLJiALI zc>e=)|5K=Ym!^i7rP~FNwM0=J&6IW;oE_ndfIU63INpw3ZC@wHe4PUQhWX2^{eU<8 zXq*0llWY1Qk)Sbno1x4gTXEnUwUIOz20hpRCHA#FHTCDTMXCT^=M!Q%(K7=|InbeS zfF9)MYx@NXo%9#MANKu1i|CI*x=C5?6YanP|0OYJxryoG4*qo3Z zR)G7C`5_u)m?q|5id2jMOxRTI_1SZFP{DV0kX6jOvI*(}GKsjoyN?E_TpjA1Crm$X zgVl5Z7(NHoT+*Ql#jbdf77BfQJTEA~5<<>HX z&b>7VYB>Fzyv)AQPVG2?g)g>Cl+N9S!VF4pA+D+JJ6G$j(tw=vDbS4by~m8XLFP-I zP0l1*q&f}FLMyPvy^VrUKX)WHEsIRh9(4=E0JmNRz%dWgmPB5J@Z&q%n<7O(m@9+O zHXPLJBJ(#6Unn7{%#t}r4`7A6;9;sVSVXvcr{XK&UjhezcEW?MPJUl|G}T@L1k8v@ z6+3C6SvvL*IOfXE(Rk2*|qjFRPc_4|-w4uh%J7@iK4 z^JH;MV$E<_<{IaKpCrFO`>{TbUBO$ZC~2SMSCOENW%4=%_zpFajWN^Rz&Us4>7)oH z5;TGy6%o3apaetOSsu9$>A@6IbL-2Y`}bhmJc*L(ft0SS4uq__jvcoU@c~Ko0X;t9 zqhVzrR5>-=5Cl0T*#tbovCOJT=XbW&$$d*KEAF8Lp{xs;m!yRAyTIdQ{W9=SP}=Eb zeorucW|B~9{wAVtN)1kth1V22*SHI>W~#TCQI#y3tVuV^>=AT9#mDccrp2EF(E+yT zMm`g0hOS>;o#jTbhBDE>vxaFOGC9J~K-#?g3yJ$ttjh3JFO&G=gz1FBmi>1Jc+f?7~@c{*vHnf4qpXrkK5M=?3EJ37i z#xh)F%KI#qQD3uyPf19XPjl+DglQ3og^B}80jSP}hKIa5JUslkN{05N>R1m$4jwxQ}nSL9Vd%4cRR^VzN28``9-?!MWA69mKUbm-SR!s zl$bW3`>^P8^URg2k)}^s{(R6`JV1jBjIe_5oB%{J=PtK?6S=B6>KZy9jqQ`9%;BRC zVsvr2N?^eJB7W-s1)01=0KX)KdBcK?xU}r)9A2MP^`qX+aBRUGjrG(|z%!N~PUoq= z)*yms|3rfa(OfF1;x7ri8l5z#E&8onJD_{sKCoR7@-9tmTm3O0%;X5dT+R~cpL;Tf zN9I4t-XEzgM`)QWY7u>rS(P2tc9iTQXVXd4?8Otkk{8T4+>g*^Em3R(*!vzDpB>-v zJac=;f1u>Y07`53lE=g%+AYOGVV3Go1UdvHk*JPn!>jXw1sDa{byQVh3B6=(+WG{oS7@0ZqE{KbYiyIa#QgkYD!~ zQ7niVAhZJZF`~{n0Hl&>6DOf^_FhI3#rB&XiO2Lo`3qF@t(;_qlcDGLTlwkhYUjYe z%#tY$s96SXQ|#4aAgmBMB#^P>+#&=-uqO47#UIgD100y)8SYIA++om9c0#kv9LyuH z9yqqZEti|Ihe(}w`flBb&Y^g%AcW7f9QeEPdrRQ2_tbro%6_BjGAi_5Eh1W4mZ zch0=RPU#^e5h11=Abl$wN;G2VGZZi3-n+o?N}j#PlTTU(=tLO6hzZWXlnZ@t2FS(% zri-|n9iaDg09q5%`wnVEN0Y>DKzzj{VHXe2gWIm|B6(JRJydL=FB@O(T%Z4XH>X4y zkkfdOU0$I)F>|&v`x*d_r$Dko{%H^C>fG>kfcQ>9`66&drtuvzZgzbr?c={odlsl! ze_b`ybRT3CJ-{bfdFIu=)KAHzy?{M4@;#5)s~wcXt4)nSf5w-9=~Y+lNbO-1Nfn8FdP`Mw}6V*tf1 zaT^dX#h!kZC8NLMGe4px#s5S1LOOb4F|b79w-M==B|+UH-#QbhSQAsxup6K_M&SNx z@A261JgDMVXB0t@wz3Q7v|4O8f0dl$3(94XSizcAPd#AX+kh~jv@pnyzrJ-+^U0p~ zm|tToBY{=aLPlb~Qdl*fOFz^|)nJ2JDA_9nbaFo*7bNC@2q%qy{j~d8;9p$!e^H{t z@noaHP?dWL=1}vqsmehCj5T^-8PG$&1|=~xa0;=^68FsfE-g)%yEJkijdIxDSShlr ze?lM?<5mSA1I+o)z;tA8XpT(vq`H^s&nGe5MkQ*L^W7415U8?)&<5~^bV4mIopYiD z)z^(!JKg#dx$2Rg-`>lZZ$jA?i-||JaN1907otD@bC@B@F6gG*kM#r-h=9`~19*l; zf>LAU^*yi^wZKHR@bU88D9r6(29i0xtk)9(I@Uyw?8v;v!kbKAg{t&1V zE}YG+(pP0rt!GYDx9kxK~m&|O~S4%OV!5$jvX(64{3AO2wb3Zbk_ zZ<$qIhr_nanVk7eEkJm}uhk8aMV@#xtsyC)YFPmAsXgV#TrY{H# zFr;piaMV``c@-@Tk`k9*Ln)_Qi?vAZ1kt+*9esUP@S>DwD>Bdx{}|{ZvoiKmzFKoc{kv2%x?+Luj9fl1z{|f7)JuX*IkFwmjxb+pDLjrB}zgM zJ?`G9@(y*YGuOk}v*bEqUN+F0ynLE%n5ZP6QT42blISN2e(lsD{2iOJs#Wj(Hj}h*YOy&A`s+W1c2OidgCP zSbW!K2R@OMeZPD!>o*0I-`JOL<7T7AU&svr4L;FAu zxDeY3v4}CgUwewvbF>|o5K^@DwT{K1Gg13%f$u9ef+r-QDxtSQ(A{$vxmY)(5Z$^LX?cnmjG2dtEY7s4^PIhazL8fh>gQ>lMz{!a@~vD0{A+JbQbN-`q=YZ0L7kNgmY zN>;UiT4v={w2%%tD+_UR3XVBz+pmw*BT3Um<{skub zB@5%HpaQuh;VYax2U+zrAwy5vypWQr@QU3Vl`AGucpG&X2Gxs;7+5@FDt%2 z{9U>8UoFCgSJW#PzR&RYp@hq@l##75n+PtGBC|s%+H}70xHlB*nd9s8^nW)lbs$o>nmj; zJBOu{$LD!tVKJZ;#e1hGTQzknsKq`TqE%k_03Z{A66)2$w!B@Bb?b(cYDWQ%vL?~M{(pY|5{T-;Lj~AZM)yf$qJ6); zH<$JJ=r&a`^|o9DZ=GATpXe)Ik$Wj}qYN6gtZ@HO7B|qvo>wckIDMW0Uhb#}yC+8e z+*?tgn4>YTvid3`%UHNd!Rk)H1*l8T*Yr%$GdP=7_8&Ydw|+$qCo_Nj;mUY9qQHHf zyKB;BO?zDnU_<)HP?%~*&h&w1fA7wIPxB%Z?qm1_WA3PX`R_@$6^}+yCV_4C4T&p` z$EK`}d~bASna(Kv=Nvdz$vQr(*9R-xrYsY5OW$LdcTrqo8@8n{*vb}7m*?q`gqp|j zsq>pz)7`iv+r?mI+q?jVjz55aISX7^7_UQWNM6g(dq7&%~sLiD~Rr3PGr2gvC7&Xz@uXOg+7E|i4t+` zJeaqwu&<0uYH$Lx#+W77`-{}X5h6Q>cZ8F+c(3Jf7~fL9d_JqU*yvejw4;arq+znQYjlQl6fuglRiHi(1ixBW+&=k+qBZh}248*j z)&U;Y#Cm$V{Cg9`1YyG$ZJa~4p66@ARajlJDeAu-mt-em{lp&q;^DS355|_jicNFR z?NpS-XzX6x%=N~MN*#P`OqtsuI-+VJDbc!opX;~;raN!miU81&>SJEwbi zxJ3kTI8${d$EMFPA}G!}q#FaS<4qD7r43-e&}`lEhL>p%IQ~{enl3gx({|t55j753 zI>Oz)-AQ^-9j-|GL6j=wO7YiQRQfg&#KU-&Yzo|@*|yMz07jGBf?nIt(A&gGCP?BH_!8hNz57#C(fMJUV|;Az-;EtXQI9O&$0p2>{U&L2+`!@hKpRdLU;SE54hZ zw7WH_LVs8s(Kk9lvFQQ2Qx_V3aL}lM!blWO)D-w9V-vd&0GC|=5}ie8u#x`!wMsm| z;ek!aF11NnhB%aMctsK$MW7;j3enqbAmuS+a)klG474r-b(bDip6h?j0;ZLBq2?g) zbb|R+AP({H0DBk{2oKc^l$ZnePeru3akEu{C0fDjlLi6C{GQD~6XOUKvx7L`mAHTv z*RO*$57E*#;2=S?)969O`Y_3fq`xSS+@)Cgd-;920|k}X%Z-HAwI@Hu;HG>xrlQa| z$K655mzymz2WpZy=4$8M<0_9aIwli7NL$!|w zJzJ=Van_IB&(F3*Quc#@nD_)Q%m6n-u+XfmgE;B0eYT{@DP~tE4+<+)t18!2@b>g$ zZeXpN7aV|99s@jFx;3PzyV;b(wS2Z=(_O)DCL?~I%u7s}$-CfZgKL)HM&g4L^h%k?)|315L!)QOpcW8b{AatvWkAx2 zL^Nd|h_m=Xm{Qmj1WUOG=cIoLSp8R0ocEv;$#m<5WQqK9{r4v1r9jY1+yQ-=OTs?1 z&DvwgU^oeqqjYTBdz@38g4Q#su?zjSf)vc+hgJa&7+O9C>M1zE>#%K~yhew2VAXDk z!vD;mI4^W+wrRiq{`5F&XS%$?MG%0s8_Eq~rb_2^pLSh%CDWf_%$!HM1ND_`HzE8& zQ-4FyCjAc8k20|iZP7TWOLwF5RJpi7ot_9lMy7$~l{hGXWja#yn@}W9S4dr6JBb$8 zi{B$545tkP&gRQWaAmZw-v^X$34Q&q)!<;MND}chg~#j&Eoq0>Zdo!r_poh7$Lr5> z4T29xEI;h$o_0;G&A2ss?t<^W_fF#hosdIN4aFS{X-Qb%(&+0xHcJ0TG;Bjmxz%Q+#-)XuU-O z7$w+Khb?bu*9xucu^Y+GGTf){ENhEB;$Q3DbVankhHn7`u~77mNYn>gF!2XTAf@I` zsO`c`qA|0;^)EC02&{E;4`f3D7+O@^-+50{cbFGoyBP)t#Sax?@I1gl7EsRqGbahl zXsRL?9Aab^@qIa+S!GH>J5oP;iT=CE}l369PKg|&= zL2<{0jpoBu3ie&k16w`AY5vnMZ&_+=hXdr03j0eJSfc{JZ|@g@^3nmg%|Lc4^K7D9f(20lLyaq(1zNaKEH-{pgXA;nsWH1)TSfX6QwdYJFiBc-*OZMfl~!0ai+He2Q03$a~>!LxPu z%IpDB3x~`W*^yqPBq){G)Zl7_VXBzuOxS;f+zqMnprpSpoYY8<;bxP80_7utI|V0G z>Pe4!nQmFF>m27}KO{aN4;zAs4Fj)Tpe6E?Kf~MWEK=bRmtgtSAIw8D{tD1DgB!OZ z^$w^?w8vKiCjpVB2uAt_FcY?cSdm>h@C3{n5&#IMz$(&(!@lFe$PJGMmz@?<9=D1; zh%qn)N_mza^!B1)xl9K8r%Jah$`>ZuGR3u#i11A;QG}A~eUoIM1!b)soQ`A=Jwf|? zy#sSendjRLF79?^v7=&M|L%dyPnH}cGd=;EI7!YR#0Bb9IUh}}Vi*J=R*H^L3-8Vy z7wJY%{>HS_92`2w&Vd)GMx(KL6k>(aZ@53v#hrO|0Brb4Fg*zj5XXLm1L6@A-yxL_cKT^E_p$BQO+ThDJ`xCz#C19N@vfacEgYcpI>v)C?oHEOUmo$6l zIPJBvAY6;kgnM3{0&kP+HwE4zY5TrVYvI74qh^+0is-aMa;Npq0|MRB8+;2?TzZ7i zNcsv=sY8d#LSY2r(;waCBSKvG(~%22W-7F#2GM!vjDPG5w)5LBR-@~!4swBIz0>l? z`;q%|mQ@m1F^`lyj9#WqWZ?O6`uqE;w+%wZTiyt)Y!HcX0I@MJC!=h$r!nYlNO>w#W~P9B8`uNJX{UrSF^`zUSO{ky8q`%1A>mMyEuBI7m;UNB%IYpApeA zc^O0E=JEs_+vx`+FAq?=bXS^cSe3|pqSoithLPL4mX{s>gu9{!d5FGe(yzbV2 z-|;?46L6*f3t*6wOZN*MdjT4$6Oc1dwTGx*pV5)5*l&ugYX+nIe1M zdWl0v&IXVm4cogTeL5`1-jyZ*Z|+IkO3MuE;8U|9544C0@XKLb<(@{mUkDz1TSt2t zR`4P;z=Faj*7QqPO0*ua!?_IS-?KWQ53?e0h%5q&uMeg;N_m%ir&^+5e_q~m|H97J zrAsS)mPa4c;SLe>M3G06a*G%>hyJo=PETs%H`YF1F976c5z;^OfP;5}1Htmjf8}E( zPwT$u@gi(Tw$e$&Y!-Ff`LJ>7fmHa_W{CBwL2Qo|bZm*hOWb%cIzGe`5)zdXa$f~5~Rwp&8rfNo_&22=h#YJ3_pPY1FAh^q^yjP zaV^2$Vmq-uRaybT2jc1S71KD0_9r8pR!Djsw3$7Z)Bb8#N_-mCm>lxJbchSy4p@gp zMybP<9C6wMlQ%DjQvnJ_dY}L(8KmXl1MdkkO29N1AC?GjgH65}oRMM*zF)A*Q+@4C zRBpQvo#1#3vIhu7b46%LGnBDT>V=e8^T+PCFtfx;p+^84vYF`2@sQSiJmhy{-5*nd ziUMk0c9-Th;WIS#!dVbw9~c|sf* zGTfS|xwP<$eV@6y+q~bDTnAh8&u%c`HCB6g*&!FA?#3PQYB|jAWsygxLQWr zG!(q~ZGwdk8_BDTx#_6{F^6W4+^h%{0OF-~O71w}<;Gg_P+gX?H8xa;otLMj@8MZv zpE4F|D>wuaq1UlD%c+71^bHNp3nnkA?S`e-M~H>cR`?P$mjsIA94>H0V+2!4e~l#t$UQ}_7F`vIa8$xXKx21ihqcNQz+vgg&qZyqzN2=5mjubb+^N9^ zkq0F`fbOdhyFZ6g<0O2O#|S6PeipXCaHvhC{pr}#LF|zPJV%+iM1fc=G-kj4N&Oa6 z==9F!Tsk2Y~pW!mT075k3--nJ0oveYv@c^2POifKd!T*C=cEFr#`E}KmpGwE3u zUG^?AP6oVqsT%V|Cig7S&Rpj>1juftVT>pWL;!bM3j>B`&vHB^v z(cwPvZID@ceG;Ofd~+5%j=t_iZfO>}(6JVsO2EuTQE?SN-%k>@+w^r~{665KsT;D7 zkwqBWaUxUVL=%1&?}BbH*A3-|bY zT$B7qdc^zhn;fg)^WO;o=SMIROXv5^=*~BdGEM^RS2IPy2~Weo!92xT`EMs8Vv2t< zmLYi@)tM!_`B_3hOgfoyuhoDv4+0mSK;XiM+aJdYSLpGd?oxcCc`)XYh)vzf+N{zR z*9%^$lbd{X@q3~DkhDm!aoS16Q(o-QbP#w?{*4Vi+zc8Ur(3L+G^&G#IUxiZn3d0Y z`H(j}YZA0Aip zyN~?UL@6-y+t++pN_JAb{PFp{Y?urBW^3f-5u_H{!@n@CpVv)~`}C|9>7<|`v{Mn+ z`|IgKQ}TOG|JjrvqzI$f&=16#6OfNT8GrxqA4rCe>0)ZFl8V)fhm*E5Q)Kiwn{U~15kE3D{@3!f>8U z9{2yP?*BVXe!-&ucbFh&w~vt0oBy-`zwp-o-_LQyN+F_^hlK^N$oIIp&z0O*GEuaz zBChLJ0Ho-(3#WPKm9nC-jjK}UNS8M*Ih*3Ba3GfB2UB3OL)RmL73!J}X=H?Np;56} z+4lR01n1n*1qP)fN6_ddsSG5U9CJ>udTvcz=*cxDhhkz1rEs$k56Uk)7TUNesVV8e(9qASt>I zB3{zCD6HSf_Ms^q%=%wZZdV$QKm%b02FBNpP9&5#Kgyjm#$#kYgF)!@+t5QZ{-#wy#D zKfvS7Z;#snl;DOC6)ennfQZ+fRxJVuRUl#|XLqZ0Fm6}kSYbS9V(ow!o4s=J{gVr& zZP?MXfFwvdwp?`BZI@`Yq4%dzTE=E-1CPlEf_~y1ltxDX?#`+TtK!A`TMMn62;`FW zKxw{-&gF+b|Suk%H89A%U~<81u!0`fG6%H7*U)7dT_ol_t6#w z7>H4dS_Eh)7$jOo`uzJt$xaGE8`{>?~Er+CnV@SjgsI??Rmmsr=CHiTiK!U8P599}y>VKm4Box}S#sE<DOcnucMfGkihq8JRN& zdEF%z6709y6VG# z^ksDfRxm|U%|gOG%=QtQ_DC|K^btH0It^ldPNM{+@C1Q2!8Y$2LqZ78B0xi%uTw{c zY4G-nWm}4H^7>v{pQ&D9LbFg#RZ`U0VwULECfHUCgP4O8?mUAbT^~@0_+*qT-sn`_ zDD%h!$Z4=(Q#gpm-K&|{=R;`?eDo#APd7}X#1cgx0YfG#UQ#@iK^(R3MZ^O4(lUIv zAft=}X9pr8<4{q|V@RWL$N5$R6b9;M8Hm6QDR$f(O5V|H%%N z$d(Cd9FQf=DEOKmx#u6}5)lqKetM0!!~^+2!c&A&V6V8*#lA;drXPI^(4z$Cvt(Ga z^IdVCH={d_12z6Chr3ISm?Y;Ol3W1RC@VlN`2S(L^iHk}*xO%o`lH(W!(QUBMy#R9<-#U=}hGB>A0guv7 zB{%56HNCqtw`*WQ&Z>kdoB!HmB0xbj16r|zpUG{7JQ3q;ns<&o zVZH01D9<4Zj-WsQT_@hr$ygWBP&8uA2~;CFnF;P8)RWJ%=nY9#CwUAy4SS)PPGIBd zf*nGfEC}MfKs+v^9WQUa$1@5}PBC_|)8Z(#X zTj+-9L&X$TI%~1fP$M$4%1Y}!pIr?Q!1U5@K8ZnqnYsLCK~}T{{zdN2bm0wmy|sM% z(?PtSqZ~EjsWOY4#g_?h%?rQiiX_;9l9lC;GV(ic6vq=(P6EH6{O_f; zV;ScXG^7rzQdXJ3&TJd>L_e7)158#VDyrqPx}1EJV{{F{QwLg3!faO3JqdzuS-C|f z#P8oFglTp^g1ms9{9Ni_8lGqL%~ibU(51;+6X&=FJx7Ze99z7`BOx4~xYd(V%AAXZ za)K^#h{xMbV_ws{Mf|E5q`YJBg0Tu`#aBm#o6+;Qtw|;bO zTs$GsIwkFpGw<|Ug9X*$FSrD#6J_JQk!9Gf(DD=>X_n0LquZNXZv#G!MH{gU<7&pH zHp9fUz??0YCgza-p zx@`B_wU*XRa)R7*lG7(yc9dwotj+u75!3JO1{Nvdwr5`7O8*-jm|ZokOTgxcGt9By0GYw| zJ{({(e~G|=DKebaG+bA*`rew(D~5bw2h_jQfA9f?yz4s)XRbv1+ti1h)w@_pKVNN9 zn$qG2?}K^-IGsN~Ycd)nZkj1yrXcFVvceYkQ5UagQ4=)83OGmYuut+?MQz{WFwwd} zHVKAS{mz9|@b<}nx;9D-R77Gc$7Rvw@cDdbjYPK7phifC0=nI~ z2j5JA2QvSFvEsrNNNTc!APiCPcck)Y#^M2n%?H)08DtY>s)RIDp8sGGyzx~KzV~zM zI3mmHhqB5Gp?+yVSCJ;r&sBe>R)5QA^Yn9bwAa3q$es)Ocjap@n|dNDIw*!(@iz|$pPBUfW;XdZ=^ng&tOSt0G9`dXeH`oPX< z7qY!FQ8eGJ&6$d5U5s*+&+qIHp$Up4bj5+wiw6Y5DR7b^>|{n;?EH~7gR~YJF?b0i zuvPxuA9{=JIu%GWG`I@XCR=o~IrKzr8B%u;w#+PF=hP0s?010TlRd3n)~06--`)@i zJnamj90KAL!+%Tc629CiYFOCcg=8aULcYm5AGfp}n35!)&&S5uk7BRrToKh=e%1q` zc|%0?^cygoiJkEBtP?82E>8%ywF>G z4~NFrSBTiqPgHN?qq8p2>C&dY7e10K9U?q5aXJ)qZM;011Yu475B8rAtb7gQoG~o> zKUDF0pSWHFeN6H-Hlnkf4)lxvE^AT!+aB_YN77?9h&?TVP$7E$Ks>&Yu%>qYG5WKb4ImCy>i|S ze_oh;z~UqNp0a;f?7g5u=V#7xEb*%>%`L;zh87wzr_TXDR49Qo!5tb3#{Dj1qlmn_ep`vfG^U?XxF$+I&n=yCx1x7`gy5v zr6sVk1#mK&f$Vl2lHG_Tyv2ZfPdQ-zvhHTzOBo0cLNux{cJ9Q!0wty%bUQrISjCsr zc3oV+4Rpm}TG-b58+Z7@7)VyHBU|#Q;)Og6I}^9uP`wV4hi4;a5z_&rw#)R&fR^wk zmU5LAm!yXAO9W|HnLHWwkIO2HYAX8z4U>!s;_z4*6TeypCBWLfaog`C7sY23rha|m zhHe6hs@&!8Zo-fg-0MJqZ@vhIO>xvsn6DR;f|J;OAROQrO@TwJc!h=)IT>&;2;OOd zQWhNo;+OK%i-WMp?9Zm3xBixM`x+EfH->|0hOmo@r?n9MbUG5ri(m|ylBdEMgv~U- zis|;zc^7aJ9e@nR;h2%xyF0wQbN05VFy8fTS!TXLrkVSbuL7(}0hNe68lv_)H9n|b z1|TEN((aNbpb8Fl%Nvkbs3Vk2gr5Sv8iSCDDp2Yd+7^ajYInn_togw45AkgoSK{Bo z_dzhf2G8W&S`e1Ky8H@+3kRSW9?Xxkvs3VRP}9bkzQ z=NdL&i|!!mzRlU%&bmNG)u>1gTE<`-mY10Ng%@l;)Cj65>33W3Kw};)z-Lr4azLWj zT1g=v9t8I|_OMeq`J{Wz^QK`!8byat;Vo+rbD>4q8DWZ#BUa?NG)5;3U;hf*izz~+ z4v63w%^J#(Al+?Aio-r2Fc{5X_?BxNzYChebbuA?39L{;@#s~@LHU<^Qqb?g_0EmD zPm2g2IxAP;|I~g>?xPTt(L-Dp`)&Q(Ky)|`{}5-R&d75-TT^e1WD$HH@F_+x{&nax z&^KhMbjwvnes`wnij+cB3lnGMpTO+9xg9?~1g%paC^ddd?Eqi$^XHi9DToUr!)PH> zZL+RH@K*KuUKxGxgh^?e;&EIQ5pG>DwhIYS8dKun0}U+JGgKO5?ditvAA96Dvk8fk zbfD)w(ORhecsumIt`J1&|G;i7av|>>o8$W$pSx}9Xy2Z@#vU6TfEa`wz)YZ=S^Pop zVUVb}@q`^Gd8UaYlr*II4DU&d6<&*AmBPp-nk+*hR8$c+bESrw>FnwrGKF%PiC1Y-n6sL<*zrV&r@8&4Qx zxZubEGvub+vlrhyUP6`VnaNX zq^TGj1?i;8hcCFoXK7L%s1Z{#PlUPA;MC<^33bpWp4@x=Td3r8zzV)MQZ$r4RQS*; zZ6$i5xuWX?N(OvWssH$<3Q@;Y%^=;k;{-OddEo%=T&O(eW*4z<>jDqXnj7DogK9Pj zVv=@~cin^GCL~t5!c_{gO#w#>K;Q^3%`+afYTG%IKMTR+X|YjKNJc85sbe=Ew8$my z?=OLa`AD?e%gE;gC<@mE$bxd5x*WW_?SITZ6XjM&BDF=}IZ`CC+|~?{D_Np! zQ;jCah`E~Aq?k|)BgjYoV$_*JNqn@gy@8I`KmbsaQ7jQ^3+x+Xv$b<+2>LMpbU_$* z9M6+`9@f$kyismF2uw2^wFB!3b~wApZWC&JV%gi(4;}TD&*p(oXjfeD6Wkw&-W@F@ zzyAS}v-r3vWKPLjM1p1WZLo^tB{^=9v?<&-9Z;t62#+Npjwgj?Lzz7QQ&4?cqfiZ_ zuwo9SQg$J&e%j4El?EU26j#|^P7aHNIe^W9fZb+?ks2?GC|D+RsI)Is&*fg_kk*ch zsq$*w@6SR?XmYy~^q|9-=;p>|w3QS?<{c_bn0h}LklmZc)gL@AHg0C|GGN&_Ot?jc zN4uc~Odm#mM<=aKMa)-tI(OX;Lo37+wj!F+U6lk1uSzm3Au4Z&9J}Whe3w*Q-rFEu z{8n+d`ULs?tD+39&rnyos8|)8<7XIgQK5nuBCkWNFEMH@NC)e3I&iHE-NcApgKrG& zBW;P4OKkqh#Vt!jan1*OBE&IU;vw8NcN8A+(6xAz@&=yY;I1V(Dq1Y&bwL~E{jsa8%5p|sY1VN^5S=WTFE^104sCA zH18Q>V>34#DTQw-tpqWTQL*x_1~bj_P<@<3g^DS(Z_3{rgu2QvP|Z9bj@f9IU{B{+ zB0^Ex7Wp4tI4tdkU$YI){YW*S6w2({dCynPviA;}T;j{C3ePTLQ|S+MxEba_r_N)W zg3gX`&1}!(osRjbT1c|g8EH(B5QLFEU@v9+;5YDE)PoTxZ={VGRf*Gah}N^_AqfC zO){$o%NBu;UugtPrzS3e|BxkSAd^rvYOje_l_ zlh3U{7xAg%6#FbAG^__zZz4hb`5UIo=SeV{-M1zU057WTJsW3c6LL25W9cL77v^5o zou39Qv>!@M!@ey$mmw+!|7z>lM*vR6lPNZY2tG-(7MeWKtlzZ2$GZHv%6Cv3^?nrAnO$iU;Xt3Z#Ar3WK{724ft=I@TA_iBd2g;DeJa z4P4K53(r4Te&DuqwgpeZK&4CalNefzVWzYuBJY3U_y4qa=3h;nTNpkG1R+6$f>9KZ z01cuP8gPJGVn8V6GB`yfN*8)P5&>Ig0ZSoRvq}LY!(}k2ma^HQAB~3 zIayMo#4=hy7L@KoZLfCSf8gFLzq3|2)$@q>T%1Aln< z)L6@74ubS~Z)I`oAci)7A({Ln2JnG=hz+rU#K#y|%%JJRH4XwS+6HFRJ#Y!WVnGm9 zJ|s2auUxPT6=Gx?1V5oR`+>>$7bFf6$KWA<`=Yl}nVxeRurbdTBwYr;A&;htJh_R{mqMt2^k6Gq$O)Skx3#u5hc+Mht$y z=mrpMs0=Kg4TYi?N-rDut(t_)^7{FIfO;9`=27ZX(S(pWCC~(@jcsBU9ht`);06&# zOqKwW>fTum-F(0y3L7w$3DR1ETQVT6vl`0LHU}V#uh_?_UW9+XwOo7yv@U--rwN2p z4Vwcw@nDEvc@efp3~>y4pTQs?p#*kc_(HzcF;VPUOFsuKJ619r083(yZxviz77wsl z-+#Lo_3@HbM>=K{H-qi;kXDZ!>bNr{AlIy@`n-<{2pX+#A7>Pq!tIuE<(b4b9nZ@%E(Tks zV`E3gZhC+!UMy4B(#FdeRP{)=qVMnv$SJ3-EMyJG69gzvk2w<(f)I<`oE@2qWMlQ6 zuKpy(nYOi~5)()AyhDhL)A})&M@kdmBLt=JePTdN+6m(i4MeG3P3DB!NTol|1~l+M zn_y|8zz;D5&`~n&m2u#FCKt}SXx-S(EvoO1N+28Hn8X{;TbyT$hjBiul0#fV#2;RE)(O@Wfk0;%Rb}LXu zy33aP$vI84pk%O&qGdRJdaM|h$~;2rQVju($!(%2u|AhoGT}jS!#iiqFFMmGZ2{$qmGzP|c*PuAOY$Fv7B%0=~Zl=hXj= zhcB2=%^)mqvDxQW4ev4`?2JEMf{kCSTi^?5F4k)bunSG0ov2Gqh;_Y0m+-Q~S4!v7TYsvF48lNqHDM`4wwm|o@oa(F z3T9aKTGL!A=sYJ}nt-GYMI-%*InOmz;6-YT1b+8E*xfZ)9d_MUzwf^_YUuvfJ1Y;E zF;_@e?Lm2nJ$yIi)pDm(T6dGBNuP;*GL~hLvOOS+{AEW~f!d|XGi`;VG^wN`(^C9e zT{-^UabTVaOW>(~h~6lB$t^lHv;JBso@soWoTp>xWw8{=gWi(x;3Ah98y z7LW$>F7G!-i?5xY&Xmn&TaIilNYwnqUkL`$hRVx!_Cnp3s*_gL^Yz*XT_MU7uRnl4PbGCxK z*J2$|b^yu$DzJ#3Jy-UIAyqiV(&>qSp_~ZHvSW{C$d##G)mfhwyA39|_i@p$bymwB z?i4z2Ni@ZZlO6Cp+EA|WUKChTvldtBHB?GnT7!*dXJYl+2WQGNZo21FH_KleQq3Lo zT!z{(NZzEdKj!%xu2Cq@nw9Dx>TT$Tx+-(7d^7BRJ5PJtp4pwupZXGbe{0u6C>4Z> z|7a}5=IP5on zcs+aKScjCpU->20Dgv-q1sf5m@9y1GaA|-zAqcDzo@{r6L)_h|DD2|LA?4Ak*Y9B2 zS7eeTmk5bg8)@3hQ5x=pDGk-y0H@#7_@KbA_}j-yNd0aU6)+`+n)@AeBZJ zP7IJ9hJ tNy}6DVLE|mtM=nkIh(BRHGF;b+>+$pUaFz>u>=Hu++4hz%bbp!`5n>`bl(5~ literal 88806 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 z

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 N(;B+CqLY_{{sr119QFVJ From 289378713e0f47f4316536f7ffeb40eb83f628b4 Mon Sep 17 00:00:00 2001 From: Stefan Benz <46600784+stebenz@users.noreply.github.com> Date: Fri, 13 Sep 2024 15:22:25 +0200 Subject: [PATCH 29/33] fix: smtp provider (#8610) There were some problems after changing from SMTP providers to email providers (https://github.com/zitadel/zitadel/pull/8545): - panic (nil pointer) on SMTP provider retrieval for HTTP configs - old SMTP configuration created before the multi SMTP configurations (without id) - were not projected / listed - could not be always be activated - Console treated HTTP configs as SMTP --------- Co-authored-by: Livio Spring --- .../smtp-table/smtp-table.component.html | 14 +-- .../smtp-table/smtp-table.component.ts | 3 + internal/api/grpc/admin/smtp_converters.go | 23 ++-- .../command/instance_smtp_config_model.go | 1 + internal/command/sms_config_model.go | 2 + internal/query/projection/smtp.go | 4 +- internal/query/projection/smtp_test.go | 105 ++++++++++++++---- internal/query/smtp_test.go | 40 +++---- 8 files changed, 132 insertions(+), 60 deletions(-) diff --git a/console/src/app/modules/smtp-table/smtp-table.component.html b/console/src/app/modules/smtp-table/smtp-table.component.html index 3359eda596..b71a7fcefd 100644 --- a/console/src/app/modules/smtp-table/smtp-table.component.html +++ b/console/src/app/modules/smtp-table/smtp-table.component.html @@ -24,7 +24,7 @@ {{ 'SMTP.LIST.ACTIVATED' | translate }} - + {{ 'SETTING.SMTP.DESCRIPTION' | translate }} - + {{ config?.description }} @@ -45,22 +45,22 @@ TLS - + - + {{ 'SETTING.SMTP.HOSTANDPORT' | translate }} - + {{ config?.host }} {{ 'SETTING.SMTP.SENDERADDRESS' | translate }} - + {{ config?.senderAddress }} @@ -95,7 +95,7 @@