Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions deploy/host-operator/e2e-tests/toolchainconfig.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ spec:
workatoWebHookURL: https://webhooks.testwebhook
verification:
enabled: true
phoneLookupMode: 'disabled'
excludedEmailDomains: 'redhat.com,acme.com'
secret:
ref: 'host-operator-secret'
Expand Down
4 changes: 2 additions & 2 deletions go.mod
Original file line number Diff line number Diff line change
@@ -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
Expand Down
8 changes: 4 additions & 4 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down
156 changes: 156 additions & 0 deletions test/e2e/parallel/phone_lookup_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
package parallel
Comment thread
MatousJobanek marked this conversation as resolved.

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) {
Comment thread
MatousJobanek marked this conversation as resolved.
Comment thread
MatousJobanek marked this conversation as resolved.
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)
Comment thread
MatousJobanek marked this conversation as resolved.

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)
})
Comment thread
MatousJobanek marked this conversation as resolved.
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)
Comment thread
MatousJobanek marked this conversation as resolved.

VerifyResourcesProvisionedForSignup(t, awaitilities, userSignup)
})
Comment thread
MatousJobanek marked this conversation as resolved.
}
47 changes: 47 additions & 0 deletions testsupport/wait/host.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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"
Expand Down
Loading