diff --git a/deploy/host-operator/e2e-tests/toolchainconfig.yaml b/deploy/host-operator/e2e-tests/toolchainconfig.yaml index 127b39888..0cb3f8cee 100644 --- a/deploy/host-operator/e2e-tests/toolchainconfig.yaml +++ b/deploy/host-operator/e2e-tests/toolchainconfig.yaml @@ -20,6 +20,7 @@ spec: workatoWebHookURL: https://webhooks.testwebhook verification: enabled: true + phoneLookupMode: 'disabled' excludedEmailDomains: 'redhat.com,acme.com' secret: ref: 'host-operator-secret' diff --git a/go.mod b/go.mod index 43b05dadb..365a2a07d 100644 --- a/go.mod +++ b/go.mod @@ -1,8 +1,8 @@ module github.com/codeready-toolchain/toolchain-e2e require ( - github.com/codeready-toolchain/api v0.0.0-20260603082246-cfa3dd9db9cc - github.com/codeready-toolchain/toolchain-common v0.0.0-20260603091009-6db0c02f4506 + github.com/codeready-toolchain/api v0.0.0-20260609071155-c8f486b1a581 + github.com/codeready-toolchain/toolchain-common v0.0.0-20260609073430-82d1748db579 github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc github.com/fatih/color v1.18.0 github.com/ghodss/yaml v1.0.0 diff --git a/go.sum b/go.sum index aa255f4f0..43354f72d 100644 --- a/go.sum +++ b/go.sum @@ -26,10 +26,10 @@ github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMn github.com/cloudflare/circl v1.1.0/go.mod h1:prBCrKB9DV4poKZY1l9zBXg2QJY7mvgRvtMxxK7fi4I= github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg8= github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4= -github.com/codeready-toolchain/api v0.0.0-20260603082246-cfa3dd9db9cc h1:Xwt43SDrCQ3cxwetRijdFrDwRp1yoam6j+36HazNivg= -github.com/codeready-toolchain/api v0.0.0-20260603082246-cfa3dd9db9cc/go.mod h1:PMg6kNHuCGNlu3MOdrCisqGkBpvzB0qS1+E6nrXxPAc= -github.com/codeready-toolchain/toolchain-common v0.0.0-20260603091009-6db0c02f4506 h1:FYOvK015wTBSh9J+T/yr4zjcVMpT1eKL1D/oS8yF1Lk= -github.com/codeready-toolchain/toolchain-common v0.0.0-20260603091009-6db0c02f4506/go.mod h1:V3Ah7YRoTIHkU0G4Ynvj/6AVgOH3Ss0fw/B/Gurr3AA= +github.com/codeready-toolchain/api v0.0.0-20260609071155-c8f486b1a581 h1:KE14RYWzMatSrwGa2wOB4SoVkbvpTm8hfnUH/nrpnfw= +github.com/codeready-toolchain/api v0.0.0-20260609071155-c8f486b1a581/go.mod h1:PMg6kNHuCGNlu3MOdrCisqGkBpvzB0qS1+E6nrXxPAc= +github.com/codeready-toolchain/toolchain-common v0.0.0-20260609073430-82d1748db579 h1:1qfOdNV6gRQSE0xOmJggqQxAiEjOpV7nZ6Xph75Mb1I= +github.com/codeready-toolchain/toolchain-common v0.0.0-20260609073430-82d1748db579/go.mod h1:aYvTzEtTuw3O+kjWMMkH/1YgV4pgUPC3v3X2Li3ixlM= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= diff --git a/test/e2e/parallel/phone_lookup_test.go b/test/e2e/parallel/phone_lookup_test.go new file mode 100644 index 000000000..9e79a8a50 --- /dev/null +++ b/test/e2e/parallel/phone_lookup_test.go @@ -0,0 +1,156 @@ +package parallel + +import ( + "fmt" + "net/http" + "testing" + + toolchainv1alpha1 "github.com/codeready-toolchain/api/api/v1alpha1" + "github.com/codeready-toolchain/toolchain-common/pkg/states" + testconfig "github.com/codeready-toolchain/toolchain-common/pkg/test/config" + . "github.com/codeready-toolchain/toolchain-e2e/testsupport" + "github.com/codeready-toolchain/toolchain-e2e/testsupport/wait" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// Twilio test credentials with magic lookup numbers return deterministic SMS Pumping Risk +// responses at no cost. The API takes country_code and phone_number separately. +// See: https://www.twilio.com/docs/lookup/magic-numbers-for-lookup/testing-sms-pumping-risk-with-magic-numbers +// +// Magic numbers used: +// +// +441234567890 → high risk, not blocked, score 2 +// +441234567891 → high risk, blocked, score 34 +// +911234567890 → low risk, not blocked, score 2 +const ( + twilioMagicPhoneHighRisk = "1234567890" // +44 prefix → high risk, not blocked + twilioMagicPhoneHighRiskBlocked = "1234567891" // +44 prefix → high risk, blocked + usTestPhone = "2025550123" // +1 prefix → fictional NANPA 555-01XX range, safe with test credentials +) + +func TestPhoneLookupMode(t *testing.T) { + t.Parallel() + awaitilities := WaitForDeployments(t) + hostAwait := awaitilities.Host() + route := hostAwait.RegistrationServiceURL + + t.Run("log mode stores annotations and user is provisioned", func(t *testing.T) { + // given + hostAwait.UpdateToolchainConfig(t, testconfig.RegistrationService().Verification().PhoneLookupMode(toolchainv1alpha1.PhoneLookupModeLog)) + userSignup, token := signup(t, hostAwait) + + // when + NewHTTPRequest(t). + InvokeEndpoint("PUT", route+"/api/v1/signup/verification", token, + fmt.Sprintf(`{ "country_code":"+44", "phone_number":"%s" }`, twilioMagicPhoneHighRiskBlocked), http.StatusNoContent) + + // then — verification code is set and lookup details are recorded + userSignup, err := hostAwait.WaitForUserSignup(t, userSignup.Name, + wait.UntilUserSignupHasAnnotationNotEmpty(toolchainv1alpha1.UserSignupVerificationCodeAnnotationKey)) + require.NoError(t, err) + + assert.NotEmpty(t, userSignup.Annotations[toolchainv1alpha1.UserSignupPhoneLookupDetailsAnnotationKey]) + assert.NotEmpty(t, userSignup.Annotations[toolchainv1alpha1.UserSignupVerificationCodeAnnotationKey]) + assert.False(t, states.Rejected(userSignup), "UserSignup should NOT be rejected in log mode") + + // complete verification and confirm user is provisioned + NewHTTPRequest(t).InvokeEndpoint("GET", route+fmt.Sprintf("/api/v1/signup/verification/%s", + userSignup.Annotations[toolchainv1alpha1.UserSignupVerificationCodeAnnotationKey]), token, "", http.StatusOK) + + userSignup, err = wait.For(t, hostAwait.Awaitility, &toolchainv1alpha1.UserSignup{}). + Update(userSignup.Name, hostAwait.Namespace, + func(instance *toolchainv1alpha1.UserSignup) { + states.SetApprovedManually(instance, true) + }) + require.NoError(t, err) + + VerifyResourcesProvisionedForSignup(t, awaitilities, userSignup) + }) + + t.Run("enabled mode rejects high-risk phone number", func(t *testing.T) { + // given + hostAwait.UpdateToolchainConfig(t, testconfig.RegistrationService(). + Verification().PhoneLookupMode(toolchainv1alpha1.PhoneLookupModeEnabled). + Verification().PhoneLookupExcludedCountries([]string{"US", "CA"})) + userSignup, token := signup(t, hostAwait) + + // when + responseMap := NewHTTPRequest(t). + InvokeEndpoint("PUT", route+"/api/v1/signup/verification", token, + fmt.Sprintf(`{ "country_code":"+44", "phone_number":"%s" }`, twilioMagicPhoneHighRiskBlocked), http.StatusForbidden). + UnmarshalMap() + + // then + require.NotEmpty(t, responseMap) + assert.Equal(t, "Forbidden", responseMap["status"]) + + userSignup, err := hostAwait.WaitForUserSignup(t, userSignup.Name) + require.NoError(t, err) + assert.True(t, states.Rejected(userSignup), "UserSignup should be rejected in enabled mode with high-risk phone") + assert.Empty(t, userSignup.Annotations[toolchainv1alpha1.UserSignupVerificationCodeAnnotationKey]) + }) + + t.Run("enabled mode blocks verification for previously rejected signup", func(t *testing.T) { + // given + hostAwait.UpdateToolchainConfig(t, testconfig.RegistrationService().Verification().PhoneLookupMode(toolchainv1alpha1.PhoneLookupModeEnabled)) + userSignup, token := signup(t, hostAwait) + + _, err := wait.For(t, hostAwait.Awaitility, &toolchainv1alpha1.UserSignup{}). + Update(userSignup.Name, hostAwait.Namespace, + func(us *toolchainv1alpha1.UserSignup) { + states.SetRejected(us, true) + }) + require.NoError(t, err) + + // when + responseMap := NewHTTPRequest(t). + InvokeEndpoint("PUT", route+"/api/v1/signup/verification", token, + fmt.Sprintf(`{ "country_code":"+91", "phone_number":"%s" }`, twilioMagicPhoneHighRisk), http.StatusForbidden). + UnmarshalMap() + + // then + require.NotEmpty(t, responseMap) + assert.Equal(t, "Forbidden", responseMap["status"]) + + userSignup, err = hostAwait.WaitForUserSignup(t, userSignup.Name) + require.NoError(t, err) + assert.True(t, states.Rejected(userSignup), "UserSignup should remain rejected") + assert.Empty(t, userSignup.Annotations[toolchainv1alpha1.UserSignupVerificationCodeAnnotationKey]) + }) + + t.Run("enabled mode skips lookup for US numbers and user is provisioned", func(t *testing.T) { + // given + hostAwait.UpdateToolchainConfig(t, testconfig.RegistrationService(). + Verification().PhoneLookupMode(toolchainv1alpha1.PhoneLookupModeEnabled). + Verification().PhoneLookupExcludedCountries([]string{"US", "CA"})) + userSignup, token := signup(t, hostAwait) + + // when — US is excluded so lookup is never called + NewHTTPRequest(t). + InvokeEndpoint("PUT", route+"/api/v1/signup/verification", token, + fmt.Sprintf(`{ "country_code":"+1", "phone_number":"%s" }`, usTestPhone), http.StatusNoContent) + + // then + userSignup, err := hostAwait.WaitForUserSignup(t, userSignup.Name, + wait.UntilUserSignupHasAnnotationNotEmpty(toolchainv1alpha1.UserSignupVerificationCodeAnnotationKey)) + require.NoError(t, err) + + assert.Empty(t, userSignup.Annotations[toolchainv1alpha1.UserSignupPhoneLookupDetailsAnnotationKey]) + assert.NotEmpty(t, userSignup.Annotations[toolchainv1alpha1.UserSignupVerificationCodeAnnotationKey]) + assert.False(t, states.Rejected(userSignup), "UserSignup should NOT be rejected for excluded country") + + // complete verification and confirm user is provisioned + NewHTTPRequest(t).InvokeEndpoint("GET", route+fmt.Sprintf("/api/v1/signup/verification/%s", + userSignup.Annotations[toolchainv1alpha1.UserSignupVerificationCodeAnnotationKey]), token, "", http.StatusOK) + + userSignup, err = wait.For(t, hostAwait.Awaitility, &toolchainv1alpha1.UserSignup{}). + Update(userSignup.Name, hostAwait.Namespace, + func(instance *toolchainv1alpha1.UserSignup) { + states.SetApprovedManually(instance, true) + }) + require.NoError(t, err) + + VerifyResourcesProvisionedForSignup(t, awaitilities, userSignup) + }) +} diff --git a/testsupport/wait/host.go b/testsupport/wait/host.go index b3bece872..3e64838c6 100644 --- a/testsupport/wait/host.go +++ b/testsupport/wait/host.go @@ -484,6 +484,34 @@ func ContainsCondition(expected toolchainv1alpha1.Condition) UserSignupWaitCrite } } +// UntilUserSignupHasAnnotation returns a `UserSignupWaitCriterion` which checks that the given +// UserSignup has an annotation with the expected key and value +func UntilUserSignupHasAnnotation(key, value string) UserSignupWaitCriterion { + return UserSignupWaitCriterion{ + Match: func(actual *toolchainv1alpha1.UserSignup) bool { + actualValue, exist := actual.Annotations[key] + return exist && actualValue == value + }, + Diff: func(actual *toolchainv1alpha1.UserSignup) string { + return fmt.Sprintf("expected UserSignup annotation '%s' to be '%s'\nbut it was '%s'", key, value, actual.Annotations[key]) + }, + } +} + +// UntilUserSignupHasAnnotationNotEmpty returns a `UserSignupWaitCriterion` which checks that the given +// UserSignup has a non-empty annotation for the expected key +func UntilUserSignupHasAnnotationNotEmpty(key string) UserSignupWaitCriterion { + return UserSignupWaitCriterion{ + Match: func(actual *toolchainv1alpha1.UserSignup) bool { + actualValue, exist := actual.Annotations[key] + return exist && actualValue != "" + }, + Diff: func(actual *toolchainv1alpha1.UserSignup) string { + return fmt.Sprintf("expected UserSignup annotation '%s' to be non-empty\nbut it was '%s'", key, actual.Annotations[key]) + }, + } +} + // UntilUserSignupHasLabel returns a `UserSignupWaitCriterion` which checks that the given // UserSignup has a `key` equal to the given `value` func UntilUserSignupHasLabel(key, value string) UserSignupWaitCriterion { @@ -1697,6 +1725,25 @@ func UntilToolchainConfigHasVerificationEnabled(expected bool) ToolchainConfigWa } } +func UntilToolchainConfigHasPhoneLookupMode(expected toolchainv1alpha1.PhoneLookupMode) ToolchainConfigWaitCriterion { + return ToolchainConfigWaitCriterion{ + Match: func(actual *toolchainv1alpha1.ToolchainConfig) bool { + mode := toolchainv1alpha1.PhoneLookupModeLog + if actual.Spec.Host.RegistrationService.Verification.PhoneLookupMode != nil { + mode = *actual.Spec.Host.RegistrationService.Verification.PhoneLookupMode + } + return mode == expected + }, + Diff: func(actual *toolchainv1alpha1.ToolchainConfig) string { + mode := toolchainv1alpha1.PhoneLookupModeLog + if actual.Spec.Host.RegistrationService.Verification.PhoneLookupMode != nil { + mode = *actual.Spec.Host.RegistrationService.Verification.PhoneLookupMode + } + return fmt.Sprintf("expected phoneLookupMode to be '%s'.\n\tactual: '%s'", expected, mode) + }, + } +} + // WaitForToolchainConfig waits until the ToolchainConfig is available with the provided criteria, if any func (a *HostAwaitility) WaitForToolchainConfig(t *testing.T, criteria ...ToolchainConfigWaitCriterion) (*toolchainv1alpha1.ToolchainConfig, error) { // there should only be one ToolchainConfig with the name "config"