diff --git a/azure-pipelines/pull-request-validation/pr-msal.yml b/azure-pipelines/pull-request-validation/pr-msal.yml index 4218ab2f69..c37d617efa 100644 --- a/azure-pipelines/pull-request-validation/pr-msal.yml +++ b/azure-pipelines/pull-request-validation/pr-msal.yml @@ -75,6 +75,59 @@ stages: tasks: msal:jacocoTestReport -PcodeCoverageEnabled=true -ProbolectricSdkVersion=${{variables.robolectricSdkVersion}} -PmockApiUrl=$(MOCK_API_URL) -PnativeAuthConfigString=$(NATIVE_AUTH_CONFIG_STRING) javaHomeSelection: $(BuildParameters.javaHomeSelection) jdkVersion: 1.17 + - bash: | + set -euo pipefail + SKIP_LABEL="skip-native-auth-e2e-tests" + # AGENT_TEMPDIRECTORY is a Windows path on the windows-2022 pool; normalise the + # separators so curl/grep never see a backslash path under Git Bash/MSYS. + TEMP_DIR="${AGENT_TEMPDIRECTORY:-.}" + LABELS_FILE="${TEMP_DIR//\\//}/native-auth-pr-labels.json" + + if [ -z "${PR_NUMBER:-}" ]; then + echo "No PR number available (not a PR build). Skipping Native Auth E2E tests." + echo "##vso[task.setvariable variable=SKIP_NATIVE_AUTH_E2E]true" + exit 0 + fi + + LABELS_URL="https://api.github.com/repos/${REPO}/issues/${PR_NUMBER}/labels" + echo "Fetching PR labels from ${LABELS_URL}" + # Capture the body and the HTTP status separately, so that a failed API call is + # distinguishable from a successful call that simply returned no matching label. + HTTP_STATUS=$(curl -sS -o "$LABELS_FILE" -w "%{http_code}" "$LABELS_URL" || echo "000") + + if [ "$HTTP_STATUS" != "200" ]; then + echo "GitHub labels API returned HTTP ${HTTP_STATUS}. Skipping Native Auth E2E tests as a preventive measure (fail-safe to skipping, never to blocking)." + echo "##vso[task.setvariable variable=SKIP_NATIVE_AUTH_E2E]true" + exit 0 + fi + + # jq is not reliably available on the windows-2022 pool, so match on the raw JSON. + if grep -q "\"name\"[[:space:]]*:[[:space:]]*\"${SKIP_LABEL}\"" "$LABELS_FILE"; then + echo "Label '${SKIP_LABEL}' is present on PR #${PR_NUMBER}. Skipping Native Auth E2E tests." + echo "##vso[task.setvariable variable=SKIP_NATIVE_AUTH_E2E]true" + else + echo "Label '${SKIP_LABEL}' is not present on PR #${PR_NUMBER}. Running Native Auth E2E tests." + echo "##vso[task.setvariable variable=SKIP_NATIVE_AUTH_E2E]false" + fi + displayName: Check for skip-native-auth-e2e-tests label + env: + PR_NUMBER: $(System.PullRequest.PullRequestNumber) + REPO: $(Build.Repository.Name) + - task: Gradle@2 + displayName: Run Native Auth E2E Tests + # Mirrors iOS (microsoft-authentication-library-for-objc) pr-validation.yml, which guards its + # native auth E2E jobs with the same opt-out label and a 30 minute timeout. + condition: and(succeeded(), ne(variables['SKIP_NATIVE_AUTH_E2E'], 'true')) + timeoutInMinutes: 30 + inputs: + tasks: msal:testLocalDebugUnitTest -Plabtest -ProbolectricSdkVersion=${{variables.robolectricSdkVersion}} -PnativeAuthConfigString=$(NATIVE_AUTH_CONFIG_STRING) --tests com.microsoft.identity.client.e2e.tests.network.nativeauth.* + javaHomeSelection: $(BuildParameters.javaHomeSelection) + jdkVersion: 1.17 + publishJUnitResults: true + testResultsFiles: '**/build/test-results/testLocalDebugUnitTest/TEST-*.xml' + testRunTitle: Native Auth E2E Tests (PR Branch) + env: + EMAIL_PROVIDER_PASSWORD: $(EMAIL_PROVIDER_PASSWORD) - publish: $(Build.SourcesDirectory)/msal/build/reports/jacoco/jacocoTestReport/jacocoTestReport.xml artifact: jacocoReport displayName: 'Publish JaCoCo Report Artifact (PR Branch)' diff --git a/common b/common index f6fec66133..a69c0c7ae1 160000 --- a/common +++ b/common @@ -1 +1 @@ -Subproject commit f6fec66133104caff7f15f183c51ea4e8b0d65e6 +Subproject commit a69c0c7ae14474193a8ce4060bcafc140d3ec5ab diff --git a/msal/src/test/java/com/microsoft/identity/client/e2e/tests/network/nativeauth/NativeAuthPublicClientApplicationAbstractTest.kt b/msal/src/test/java/com/microsoft/identity/client/e2e/tests/network/nativeauth/NativeAuthPublicClientApplicationAbstractTest.kt index 354d235100..8d4a3b5846 100644 --- a/msal/src/test/java/com/microsoft/identity/client/e2e/tests/network/nativeauth/NativeAuthPublicClientApplicationAbstractTest.kt +++ b/msal/src/test/java/com/microsoft/identity/client/e2e/tests/network/nativeauth/NativeAuthPublicClientApplicationAbstractTest.kt @@ -30,11 +30,13 @@ import com.google.gson.reflect.TypeToken import com.microsoft.identity.client.PublicClientApplication import com.microsoft.identity.client.e2e.shadows.ShadowAndroidSdkStorageEncryptionManager import com.microsoft.identity.client.e2e.tests.IPublicClientApplicationTest +import com.microsoft.identity.client.e2e.utils.NativeAuthEmailOTPErrorClassifier import com.microsoft.identity.client.exception.MsalException import com.microsoft.identity.common.internal.controllers.CommandDispatcherHelper import com.microsoft.identity.common.java.nativeauth.BuildValues import com.microsoft.identity.internal.testutils.TestUtils import com.microsoft.identity.internal.testutils.nativeauth.ConfigType +import com.microsoft.identity.internal.testutils.nativeauth.api.TemporaryEmailService import com.microsoft.identity.internal.testutils.nativeauth.api.models.NativeAuthTestConfig import com.microsoft.identity.labapi.utilities.BuildConfig import com.microsoft.identity.labapi.utilities.authentication.LabApiAuthenticationClient @@ -69,6 +71,23 @@ abstract class NativeAuthPublicClientApplicationAbstractTest : IPublicClientAppl const val INVALID_PASSWORD = "password" const val INCORRECT_CODE = "00000000" + /** + * Retry budget for OTP-throttled (AADSTS701014) auth flows. + * + * Each retry re-runs the whole flow (new temporary inbox + signup + OTP request), so + * attempts are expensive in wall-clock time on top of the backoff itself. With 3 retries + * and the delay capped at [MAX_RETRY_DELAY_MILLIS], a persistently throttled test sleeps at + * most 5s + 10s + 20s = 35s across 4 attempts. + * + * iOS keeps 5 attempts only because XCTest's plan-level `maximumTestRepetitions` / + * `retryOnFailure` costs no extra wall-clock on success, and because its throttle path + * raises `XCTSkip` (via `skipIfEmailOTPThrottled`) instead of retrying into the throttle. + * This hand-rolled loop has neither property, hence the tighter budget. + */ + const val MAX_THROTTLE_RETRIES = 3 + private const val RETRY_BASE_DELAY_MILLIS = 5_000L + private const val MAX_RETRY_DELAY_MILLIS = 20_000L + private val labApiAuthenticationClient: LabApiAuthenticationClient = LabApiAuthenticationClient(BuildConfig.LAB_CLIENT_SECRET) val labClient: LabClient = LabClient(labApiAuthenticationClient) @@ -76,6 +95,7 @@ abstract class NativeAuthPublicClientApplicationAbstractTest : IPublicClientAppl private lateinit var context: Context private lateinit var activity: Activity + protected val tempEmailApi = TemporaryEmailService() // Remove default Coroutine test timeout of 10 seconds. private val testDispatcher = StandardTestDispatcher() @@ -150,29 +170,38 @@ abstract class NativeAuthPublicClientApplicationAbstractTest : IPublicClientAppl } fun retryOperation( - maxRetries: Int = 5, + maxRetries: Int = MAX_THROTTLE_RETRIES, authFlow: () -> T ) { var retryCount = 0 - var shouldRetry = true - while (shouldRetry) { + while (true) { try { authFlow() - shouldRetry = false // authFlow() has succeeded, so we don't need to retry. - } catch (e: Exception) { - //1secmail occasionally has a delay for emails to arrive / return from the API, or throws an internal server error, which causes tests to fail - //In this case, retry the test - if (retryCount >= maxRetries) { - Assert.fail(e.message) - shouldRetry = false - } else { - retryCount++ + return + } catch (e: AssertionError) { + // Classification (typed errorCodes first, assertion message as unconditional + // fallback) lives in the classifier so it is unit-testable without standing up + // this Robolectric-backed abstract test. + if (!NativeAuthEmailOTPErrorClassifier.isThrottleError(e)) { + throw e } + retryOrFail(e, retryCount++, maxRetries) } } } + private fun retryOrFail(error: Throwable, retryCount: Int, maxRetries: Int) { + if (retryCount >= maxRetries) { + throw AssertionError(error.message).apply { initCause(error) } + } + + // Avoid repeatedly requesting OTPs while the Native Auth test tenant is throttling them. + Thread.sleep( + minOf(RETRY_BASE_DELAY_MILLIS * (1L shl retryCount), MAX_RETRY_DELAY_MILLIS) + ) + } + private fun readConfigFile(filePath: String): String { val sb = StringBuilder() try { diff --git a/msal/src/test/java/com/microsoft/identity/client/e2e/tests/network/nativeauth/NativeAuthPublicClientApplicationAbstractTestTest.kt b/msal/src/test/java/com/microsoft/identity/client/e2e/tests/network/nativeauth/NativeAuthPublicClientApplicationAbstractTestTest.kt new file mode 100644 index 0000000000..ecb1e79389 --- /dev/null +++ b/msal/src/test/java/com/microsoft/identity/client/e2e/tests/network/nativeauth/NativeAuthPublicClientApplicationAbstractTestTest.kt @@ -0,0 +1,48 @@ +// Copyright (c) Microsoft Corporation. +// All rights reserved. +// +// This code is licensed under the MIT License. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files(the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and / or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. +package com.microsoft.identity.client.e2e.tests.network.nativeauth + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertSame +import org.junit.Assert.assertThrows +import org.junit.Test + +class NativeAuthPublicClientApplicationAbstractTestTest : + NativeAuthPublicClientApplicationAbstractTest() { + + @Test + fun retryOperationDoesNotRetryNonThrottleExceptions() { + val expected = IllegalStateException("Non-throttle failure") + var attempts = 0 + + val actual = assertThrows(IllegalStateException::class.java) { + retryOperation(maxRetries = 1) { + attempts++ + throw expected + } + } + + assertSame(expected, actual) + assertEquals(1, attempts) + } +} diff --git a/msal/src/test/java/com/microsoft/identity/client/e2e/tests/network/nativeauth/SSPRTest.kt b/msal/src/test/java/com/microsoft/identity/client/e2e/tests/network/nativeauth/SSPRTest.kt index 4d26af3e85..fb2f5d48f1 100644 --- a/msal/src/test/java/com/microsoft/identity/client/e2e/tests/network/nativeauth/SSPRTest.kt +++ b/msal/src/test/java/com/microsoft/identity/client/e2e/tests/network/nativeauth/SSPRTest.kt @@ -25,7 +25,6 @@ package com.microsoft.identity.client.e2e.tests.network.nativeauth import com.microsoft.identity.client.e2e.utils.assertResult import com.microsoft.identity.internal.testutils.nativeauth.ConfigType -import com.microsoft.identity.internal.testutils.nativeauth.api.TemporaryEmailService import com.microsoft.identity.internal.testutils.nativeauth.api.models.NativeAuthTestConfig import com.microsoft.identity.nativeauth.INativeAuthPublicClientApplication import com.microsoft.identity.nativeauth.parameters.NativeAuthResetPasswordParameters @@ -42,20 +41,18 @@ import org.junit.Test class SSPRTest : NativeAuthPublicClientApplicationAbstractTest() { - private val tempEmailApi = TemporaryEmailService() - lateinit var application: INativeAuthPublicClientApplication lateinit var config: NativeAuthTestConfig.Config private val defaultConfigType = ConfigType.SSPR private val defaultChallengeTypes = listOf("password", "oob") private val defaultCapabilities = listOf("mfa_required", "registration_required") + private val invalidPasswordTooShort = "a" /** * Verify email with email OTP first and then reset password. * (hero scenario 8 & 17, use case 3.1.1) */ - @Ignore("Retrieving OTP code failure") @Test fun testSSPRSuccess() { config = getConfig(defaultConfigType) @@ -67,6 +64,7 @@ class SSPRTest : NativeAuthPublicClientApplicationAbstractTest() { runBlocking { val user = config.email val param = NativeAuthResetPasswordParameters(username = user) + tempEmailApi.markCheckpoint(user) result = application.resetPassword(param) assertResult(result) @@ -85,7 +83,6 @@ class SSPRTest : NativeAuthPublicClientApplicationAbstractTest() { * New password being set doesn’t meet password complexity requirements set on portal * (use case 3.1.3) */ - @Ignore("Retrieving OTP code failure.") @Test fun testErrorInvalidPasswordFormat() { config = getConfig(defaultConfigType) @@ -97,6 +94,7 @@ class SSPRTest : NativeAuthPublicClientApplicationAbstractTest() { runBlocking { val user = config.email val param = NativeAuthResetPasswordParameters(username = user) + tempEmailApi.markCheckpoint(user) result = application.resetPassword(param) assertResult(result) @@ -104,10 +102,14 @@ class SSPRTest : NativeAuthPublicClientApplicationAbstractTest() { val submitCodeResult = (result as ResetPasswordStartResult.CodeRequired).nextState.submitCode(otp) assertResult(submitCodeResult) - val password = INVALID_PASSWORD + val password = invalidPasswordTooShort val submitPasswordResult = (submitCodeResult as ResetPasswordSubmitCodeResult.PasswordRequired).nextState.submitPassword(password.toCharArray()) Assert.assertTrue(submitPasswordResult is ResetPasswordSubmitPasswordError) - Assert.assertTrue((submitPasswordResult as ResetPasswordSubmitPasswordError).isInvalidPassword()) + val error = submitPasswordResult as ResetPasswordSubmitPasswordError + Assert.assertTrue( + "Expected invalid password error, but received errorType=${error.errorType}, error=${error.error}, subError=${error.subError}", + error.isInvalidPassword() + ) } } } @@ -116,7 +118,6 @@ class SSPRTest : NativeAuthPublicClientApplicationAbstractTest() { * Resend Code. * (use case 3.1.4) */ - @Ignore("Retrieving OTP code failure.") @Test fun testResendCode() { config = getConfig(defaultConfigType) @@ -128,11 +129,13 @@ class SSPRTest : NativeAuthPublicClientApplicationAbstractTest() { runBlocking { val user = config.email val param = NativeAuthResetPasswordParameters(username = user) + tempEmailApi.markCheckpoint(user) result = application.resetPassword(param) assertResult(result) val otp1 = tempEmailApi.retrieveCodeFromInbox(user) val codeRequiredState = (result as ResetPasswordStartResult.CodeRequired).nextState + tempEmailApi.markCheckpoint(user) val resendCodeResult = codeRequiredState.resendCode() assertResult(resendCodeResult) @@ -159,7 +162,7 @@ class SSPRTest : NativeAuthPublicClientApplicationAbstractTest() { application = setupPCA(config, defaultChallengeTypes, defaultCapabilities) runBlocking { - val username = tempEmailApi.generateRandomEmailAddressLocally() + val username = tempEmailApi.generateRandomUnregisteredEmailAddress() val param = NativeAuthResetPasswordParameters(username = username) val result = application.resetPassword(param) Assert.assertTrue(result is ResetPasswordError) diff --git a/msal/src/test/java/com/microsoft/identity/client/e2e/tests/network/nativeauth/SignInEmailOTPTest.kt b/msal/src/test/java/com/microsoft/identity/client/e2e/tests/network/nativeauth/SignInEmailOTPTest.kt index 5db7671f5c..c9245cdf29 100644 --- a/msal/src/test/java/com/microsoft/identity/client/e2e/tests/network/nativeauth/SignInEmailOTPTest.kt +++ b/msal/src/test/java/com/microsoft/identity/client/e2e/tests/network/nativeauth/SignInEmailOTPTest.kt @@ -25,7 +25,6 @@ package com.microsoft.identity.client.e2e.tests.network.nativeauth import com.microsoft.identity.client.e2e.utils.assertResult import com.microsoft.identity.internal.testutils.nativeauth.ConfigType -import com.microsoft.identity.internal.testutils.nativeauth.api.TemporaryEmailService import com.microsoft.identity.internal.testutils.nativeauth.api.models.NativeAuthTestConfig import com.microsoft.identity.nativeauth.INativeAuthPublicClientApplication import com.microsoft.identity.nativeauth.parameters.NativeAuthSignInParameters @@ -40,8 +39,6 @@ import org.junit.Test class SignInEmailOTPTest : NativeAuthPublicClientApplicationAbstractTest() { - private val tempEmailApi = TemporaryEmailService() - lateinit var application: INativeAuthPublicClientApplication lateinit var config: NativeAuthTestConfig.Config @@ -63,6 +60,7 @@ class SignInEmailOTPTest : NativeAuthPublicClientApplicationAbstractTest() { runBlocking { val user = config.email val param = NativeAuthSignInParameters(username = user) + tempEmailApi.markCheckpoint(user) val signInResult = application.signIn(param) assertResult(signInResult) val otp = tempEmailApi.retrieveCodeFromInbox(user) @@ -83,7 +81,7 @@ class SignInEmailOTPTest : NativeAuthPublicClientApplicationAbstractTest() { retryOperation { runBlocking { - val username = tempEmailApi.generateRandomEmailAddressLocally() + val username = tempEmailApi.generateRandomUnregisteredEmailAddress() val param = NativeAuthSignInParameters(username = username) val signInResult = application.signIn(param) Assert.assertTrue(signInResult is SignInError) @@ -146,11 +144,13 @@ class SignInEmailOTPTest : NativeAuthPublicClientApplicationAbstractTest() { runBlocking { val user = config.email val param = NativeAuthSignInParameters(username = user) + tempEmailApi.markCheckpoint(user) val signInResult = application.signIn(param) assertResult(signInResult) val otp1 = tempEmailApi.retrieveCodeFromInbox(user) val codeRequiredState = (signInResult as SignInResult.CodeRequired).nextState + tempEmailApi.markCheckpoint(user) val resendCodeResult = codeRequiredState.resendCode() assertResult(resendCodeResult) diff --git a/msal/src/test/java/com/microsoft/identity/client/e2e/tests/network/nativeauth/SignInJITTest.kt b/msal/src/test/java/com/microsoft/identity/client/e2e/tests/network/nativeauth/SignInJITTest.kt index 09d90c9fea..8f92db5680 100644 --- a/msal/src/test/java/com/microsoft/identity/client/e2e/tests/network/nativeauth/SignInJITTest.kt +++ b/msal/src/test/java/com/microsoft/identity/client/e2e/tests/network/nativeauth/SignInJITTest.kt @@ -26,7 +26,6 @@ package com.microsoft.identity.client.e2e.tests.network.nativeauth import com.microsoft.identity.client.claims.ClaimsRequest import com.microsoft.identity.client.e2e.utils.assertResult import com.microsoft.identity.internal.testutils.nativeauth.ConfigType -import com.microsoft.identity.internal.testutils.nativeauth.api.TemporaryEmailService import com.microsoft.identity.internal.testutils.nativeauth.api.models.NativeAuthTestConfig import com.microsoft.identity.nativeauth.INativeAuthPublicClientApplication import com.microsoft.identity.nativeauth.parameters.NativeAuthChallengeAuthMethodParameters @@ -47,8 +46,6 @@ import org.junit.Test class SignInJITTest : NativeAuthPublicClientApplicationAbstractTest() { - private val tempEmailApi = TemporaryEmailService() - private lateinit var resources: List lateinit var application: INativeAuthPublicClientApplication @@ -80,9 +77,10 @@ class SignInJITTest : NativeAuthPublicClientApplicationAbstractTest() { retryOperation { runBlocking { // SignUp a new user with username and password - val username = tempEmailApi.generateRandomEmailAddressLocally() + val username = tempEmailApi.createRandomEmailAddress() val signUpParams = NativeAuthSignUpParameters(username) signUpParams.password = getSafePassword().toCharArray() + tempEmailApi.markCheckpoint(username) val signUpResult = application.signUp(signUpParams) assertResult(signUpResult) val otp1 = tempEmailApi.retrieveCodeFromInbox(username) @@ -97,12 +95,13 @@ class SignInJITTest : NativeAuthPublicClientApplicationAbstractTest() { assertResult(signInResult) val authMethod = (signInResult as SignInResult.StrongAuthMethodRegistrationRequired).authMethods[0] - val contact = tempEmailApi.generateRandomEmailAddressLocally() + val contact = tempEmailApi.createRandomEmailAddress() // Specify a different email as verification contact. val authMethodParams = NativeAuthChallengeAuthMethodParameters(authMethod, contact) // Complete JIT. Verification email should be sent to the second email. + tempEmailApi.markCheckpoint(contact) val challengeResult = signInResult.nextState.challengeAuthMethod(authMethodParams) val otp2 = tempEmailApi.retrieveCodeFromInbox(contact) val submitChallengeResult = (challengeResult as RegisterStrongAuthChallengeResult.VerificationRequired).result.getNextState().submitChallenge(otp2) @@ -141,9 +140,10 @@ class SignInJITTest : NativeAuthPublicClientApplicationAbstractTest() { retryOperation { runBlocking { // SignUp a new user with username and password. - val username = tempEmailApi.generateRandomEmailAddressLocally() + val username = tempEmailApi.createRandomEmailAddress() val signUpParams = NativeAuthSignUpParameters(username) signUpParams.password = getSafePassword().toCharArray() + tempEmailApi.markCheckpoint(username) val signUpResult = application.signUp(signUpParams) assertResult(signUpResult) val otp1 = tempEmailApi.retrieveCodeFromInbox(username) @@ -159,12 +159,13 @@ class SignInJITTest : NativeAuthPublicClientApplicationAbstractTest() { assertResult(signWithContinuationResult) val authMethod = (signWithContinuationResult as SignInResult.StrongAuthMethodRegistrationRequired).authMethods[0] - val contact = tempEmailApi.generateRandomEmailAddressLocally() + val contact = tempEmailApi.createRandomEmailAddress() // Specify a different email as verification contact. val authMethodParams = NativeAuthChallengeAuthMethodParameters(authMethod, contact) // Complete JIT. Verification email should be sent to the second email. + tempEmailApi.markCheckpoint(contact) val challengeResult = signWithContinuationResult.nextState.challengeAuthMethod(authMethodParams) val otp2 = tempEmailApi.retrieveCodeFromInbox(contact) val submitChallengeResult = (challengeResult as RegisterStrongAuthChallengeResult.VerificationRequired).result.getNextState().submitChallenge(otp2) @@ -204,9 +205,10 @@ class SignInJITTest : NativeAuthPublicClientApplicationAbstractTest() { retryOperation { runBlocking { // SignUp a new user with username and password - val username = tempEmailApi.generateRandomEmailAddressLocally() + val username = tempEmailApi.createRandomEmailAddress() val signUpParams = NativeAuthSignUpParameters(username) signUpParams.password = getSafePassword().toCharArray() + tempEmailApi.markCheckpoint(username) val signUpResult = application.signUp(signUpParams) assertResult(signUpResult) val otp1 = tempEmailApi.retrieveCodeFromInbox(username) diff --git a/msal/src/test/java/com/microsoft/identity/client/e2e/tests/network/nativeauth/SignInMFATest.kt b/msal/src/test/java/com/microsoft/identity/client/e2e/tests/network/nativeauth/SignInMFATest.kt index fdb0d595b4..919dda78e1 100644 --- a/msal/src/test/java/com/microsoft/identity/client/e2e/tests/network/nativeauth/SignInMFATest.kt +++ b/msal/src/test/java/com/microsoft/identity/client/e2e/tests/network/nativeauth/SignInMFATest.kt @@ -26,7 +26,6 @@ package com.microsoft.identity.client.e2e.tests.network.nativeauth import com.microsoft.identity.client.claims.ClaimsRequest import com.microsoft.identity.client.e2e.utils.assertResult import com.microsoft.identity.internal.testutils.nativeauth.ConfigType -import com.microsoft.identity.internal.testutils.nativeauth.api.TemporaryEmailService import com.microsoft.identity.internal.testutils.nativeauth.api.models.NativeAuthTestConfig import com.microsoft.identity.nativeauth.INativeAuthPublicClientApplication import com.microsoft.identity.nativeauth.parameters.NativeAuthGetAccessTokenParameters @@ -46,8 +45,6 @@ import java.util.Base64 class SignInMFATest : NativeAuthPublicClientApplicationAbstractTest() { - private val tempEmailApi = TemporaryEmailService() - private lateinit var resources: List lateinit var application: INativeAuthPublicClientApplication @@ -90,6 +87,7 @@ class SignInMFATest : NativeAuthPublicClientApplicationAbstractTest() { assertResult(result) // Initiate challenge, send code to email + tempEmailApi.markCheckpoint(username) val sendChallengeResult = (result as SignInResult.MFARequired).nextState.requestChallenge(result.authMethods.first()) assertResult(sendChallengeResult) @@ -104,6 +102,7 @@ class SignInMFATest : NativeAuthPublicClientApplicationAbstractTest() { assertTrue((submitIncorrectChallengeResult as MFASubmitChallengeError).isInvalidChallenge()) // Request new challenge + tempEmailApi.markCheckpoint(username) val requestNewChallengeResult = sendChallengeResult.nextState.requestChallenge(result.authMethods.first()) assertResult(requestNewChallengeResult) (requestNewChallengeResult as MFARequiredResult.VerificationRequired) @@ -159,6 +158,7 @@ class SignInMFATest : NativeAuthPublicClientApplicationAbstractTest() { assertResult(result) // Initiate challenge, send code to email + tempEmailApi.markCheckpoint(username) val sendChallengeResult = (result as SignInResult.MFARequired).nextState.requestChallenge(result.authMethods.first()) assertResult(sendChallengeResult) diff --git a/msal/src/test/java/com/microsoft/identity/client/e2e/tests/network/nativeauth/SignUpEmailOTPAttributesTest.kt b/msal/src/test/java/com/microsoft/identity/client/e2e/tests/network/nativeauth/SignUpEmailOTPAttributesTest.kt index bd471cf9b2..28a61f994c 100644 --- a/msal/src/test/java/com/microsoft/identity/client/e2e/tests/network/nativeauth/SignUpEmailOTPAttributesTest.kt +++ b/msal/src/test/java/com/microsoft/identity/client/e2e/tests/network/nativeauth/SignUpEmailOTPAttributesTest.kt @@ -25,7 +25,6 @@ package com.microsoft.identity.client.e2e.tests.network.nativeauth import com.microsoft.identity.client.e2e.utils.assertResult import com.microsoft.identity.internal.testutils.nativeauth.ConfigType -import com.microsoft.identity.internal.testutils.nativeauth.api.TemporaryEmailService import com.microsoft.identity.internal.testutils.nativeauth.api.models.NativeAuthTestConfig import com.microsoft.identity.nativeauth.INativeAuthPublicClientApplication import com.microsoft.identity.nativeauth.UserAttributes @@ -39,8 +38,6 @@ import org.junit.Test class SignUpEmailOTPAttributesTest : NativeAuthPublicClientApplicationAbstractTest() { - private val tempEmailApi = TemporaryEmailService() - lateinit var application: INativeAuthPublicClientApplication lateinit var config: NativeAuthTestConfig.Config @@ -64,12 +61,13 @@ class SignUpEmailOTPAttributesTest : NativeAuthPublicClientApplicationAbstractTe fun testSuccessAttributesFirst() { retryOperation { runBlocking { // Running with runBlocking to avoid default 10 second execution timeout. - val user = tempEmailApi.generateRandomEmailAddressLocally() + val user = tempEmailApi.createRandomEmailAddress() val attributes = UserAttributes.Builder().country("Ireland").city("Dublin").build() val param = NativeAuthSignUpParameters(username = user) param.attributes = attributes + tempEmailApi.markCheckpoint(user) val signUpResult = application.signUp(param) assertResult(signUpResult) @@ -90,9 +88,10 @@ class SignUpEmailOTPAttributesTest : NativeAuthPublicClientApplicationAbstractTe fun testSuccessAttributesLastSameScreen() { retryOperation { runBlocking { // Running with runBlocking to avoid default 10 second execution timeout. - val user = tempEmailApi.generateRandomEmailAddressLocally() + val user = tempEmailApi.createRandomEmailAddress() val param = NativeAuthSignUpParameters(username = user) + tempEmailApi.markCheckpoint(user) val signUpResult = application.signUp(param) assertResult(signUpResult) @@ -121,8 +120,9 @@ class SignUpEmailOTPAttributesTest : NativeAuthPublicClientApplicationAbstractTe fun testSuccessAttributesLastMultipleScreens() { retryOperation { runBlocking { // Running with runBlocking to avoid default 10 second execution timeout. - val user = tempEmailApi.generateRandomEmailAddressLocally() + val user = tempEmailApi.createRandomEmailAddress() val param = NativeAuthSignUpParameters(username = user) + tempEmailApi.markCheckpoint(user) val signUpResult = application.signUp(param) assertResult(signUpResult) diff --git a/msal/src/test/java/com/microsoft/identity/client/e2e/tests/network/nativeauth/SignUpEmailOTPTest.kt b/msal/src/test/java/com/microsoft/identity/client/e2e/tests/network/nativeauth/SignUpEmailOTPTest.kt index 1c52142755..6e5501a541 100644 --- a/msal/src/test/java/com/microsoft/identity/client/e2e/tests/network/nativeauth/SignUpEmailOTPTest.kt +++ b/msal/src/test/java/com/microsoft/identity/client/e2e/tests/network/nativeauth/SignUpEmailOTPTest.kt @@ -25,7 +25,6 @@ package com.microsoft.identity.client.e2e.tests.network.nativeauth import com.microsoft.identity.client.e2e.utils.assertResult import com.microsoft.identity.internal.testutils.nativeauth.ConfigType -import com.microsoft.identity.internal.testutils.nativeauth.api.TemporaryEmailService import com.microsoft.identity.internal.testutils.nativeauth.api.models.NativeAuthTestConfig import com.microsoft.identity.nativeauth.INativeAuthPublicClientApplication import com.microsoft.identity.nativeauth.parameters.NativeAuthSignUpParameters @@ -40,8 +39,6 @@ import org.junit.Test class SignUpEmailOTPTest : NativeAuthPublicClientApplicationAbstractTest() { - private val tempEmailApi = TemporaryEmailService() - lateinit var application: INativeAuthPublicClientApplication lateinit var config: NativeAuthTestConfig.Config @@ -61,8 +58,9 @@ class SignUpEmailOTPTest : NativeAuthPublicClientApplicationAbstractTest() { retryOperation { runBlocking { // Running with runBlocking to avoid default 10 second execution timeout. - val user = tempEmailApi.generateRandomEmailAddressLocally() + val user = tempEmailApi.createRandomEmailAddress() val param = NativeAuthSignUpParameters(username = user) + tempEmailApi.markCheckpoint(user) val signUpResult = application.signUp(param) assertResult(signUpResult) @@ -85,12 +83,14 @@ class SignUpEmailOTPTest : NativeAuthPublicClientApplicationAbstractTest() { retryOperation { runBlocking { // Running with runBlocking to avoid default 10 second execution timeout. - val user = tempEmailApi.generateRandomEmailAddressLocally() + val user = tempEmailApi.createRandomEmailAddress() val param = NativeAuthSignUpParameters(username = user) + tempEmailApi.markCheckpoint(user) val signUpResult = application.signUp(param) assertResult(signUpResult) val otp1 = tempEmailApi.retrieveCodeFromInbox(user) val codeRequiredState = (signUpResult as SignUpResult.CodeRequired).nextState + tempEmailApi.markCheckpoint(user) val resendCodeResult = codeRequiredState.resendCode() assertResult(resendCodeResult) val otp2 = tempEmailApi.retrieveCodeFromInbox(user) @@ -183,8 +183,9 @@ class SignUpEmailOTPTest : NativeAuthPublicClientApplicationAbstractTest() { retryOperation { runBlocking { // Running with runBlocking to avoid default 10 second execution timeout. - val user = tempEmailApi.generateRandomEmailAddressLocally() + val user = tempEmailApi.createRandomEmailAddress() val param = NativeAuthSignUpParameters(username = user) + tempEmailApi.markCheckpoint(user) val signUpResult = application.signUp(param) assertResult(signUpResult) val otp = tempEmailApi.retrieveCodeFromInbox(user) @@ -207,7 +208,7 @@ class SignUpEmailOTPTest : NativeAuthPublicClientApplicationAbstractTest() { application = setupPCA(config, listOf("oob"), defaultCapabilities) runBlocking { - val user = tempEmailApi.generateRandomEmailAddressLocally() + val user = tempEmailApi.generateRandomUnregisteredEmailAddress() val param = NativeAuthSignUpParameters(username = user) val signUpResult = application.signUp(param) Assert.assertTrue(signUpResult is SignUpError) @@ -227,8 +228,9 @@ class SignUpEmailOTPTest : NativeAuthPublicClientApplicationAbstractTest() { retryOperation { runBlocking { // Running with runBlocking to avoid default 10 second execution timeout. - val user = tempEmailApi.generateRandomEmailAddressLocally() + val user = tempEmailApi.createRandomEmailAddress() val param = NativeAuthSignUpParameters(username = user) + tempEmailApi.markCheckpoint(user) val signUpResult = application.signUp(param) assertResult(signUpResult) val otp = tempEmailApi.retrieveCodeFromInbox(user) diff --git a/msal/src/test/java/com/microsoft/identity/client/e2e/tests/network/nativeauth/SignUpEmailPasswordAttributesTest.kt b/msal/src/test/java/com/microsoft/identity/client/e2e/tests/network/nativeauth/SignUpEmailPasswordAttributesTest.kt index 3cf10cf745..489b953e08 100644 --- a/msal/src/test/java/com/microsoft/identity/client/e2e/tests/network/nativeauth/SignUpEmailPasswordAttributesTest.kt +++ b/msal/src/test/java/com/microsoft/identity/client/e2e/tests/network/nativeauth/SignUpEmailPasswordAttributesTest.kt @@ -26,7 +26,6 @@ package com.microsoft.identity.client.e2e.tests.network.nativeauth import com.microsoft.identity.client.e2e.utils.assertResult import com.microsoft.identity.common.java.nativeauth.commands.parameters.SignUpStartCommandParameters import com.microsoft.identity.internal.testutils.nativeauth.ConfigType -import com.microsoft.identity.internal.testutils.nativeauth.api.TemporaryEmailService import com.microsoft.identity.internal.testutils.nativeauth.api.models.NativeAuthTestConfig import com.microsoft.identity.nativeauth.INativeAuthPublicClientApplication import com.microsoft.identity.nativeauth.UserAttributes @@ -40,8 +39,6 @@ import org.junit.Test class SignUpEmailPasswordAttributesTest : NativeAuthPublicClientApplicationAbstractTest() { - private val tempEmailApi = TemporaryEmailService() - lateinit var application: INativeAuthPublicClientApplication lateinit var config: NativeAuthTestConfig.Config @@ -67,13 +64,14 @@ class SignUpEmailPasswordAttributesTest : NativeAuthPublicClientApplicationAbstr fun testEmailPasswordAttributesOnSameScreen() { retryOperation { runBlocking { - val user = tempEmailApi.generateRandomEmailAddressLocally() + val user = tempEmailApi.createRandomEmailAddress() val attributes = UserAttributes.Builder().country("Ireland").city("Dublin").flatUsername("flatusername").build() val param = NativeAuthSignUpParameters(username = user) param.password = getSafePassword().toCharArray() param.attributes = attributes + tempEmailApi.markCheckpoint(user) val signUpResult = application.signUp(param) assertResult(signUpResult) @@ -97,8 +95,9 @@ class SignUpEmailPasswordAttributesTest : NativeAuthPublicClientApplicationAbstr fun testSeparateEmailPasswordAndAttributesOnSameScreen() { retryOperation { runBlocking { // Running with runBlocking to avoid default 10 second execution timeout. - val user = tempEmailApi.generateRandomEmailAddressLocally() + val user = tempEmailApi.createRandomEmailAddress() val param = NativeAuthSignUpParameters(username = user) + tempEmailApi.markCheckpoint(user) val signUpResult = application.signUp(param) assertResult(signUpResult) @@ -135,8 +134,9 @@ class SignUpEmailPasswordAttributesTest : NativeAuthPublicClientApplicationAbstr fun testSeparateEmailPasswordAndAttributesOnMultipleScreens() { retryOperation { runBlocking { - val user = tempEmailApi.generateRandomEmailAddressLocally() + val user = tempEmailApi.createRandomEmailAddress() val param = NativeAuthSignUpParameters(username = user) + tempEmailApi.markCheckpoint(user) val signUpResult = application.signUp(param) assertResult(signUpResult) diff --git a/msal/src/test/java/com/microsoft/identity/client/e2e/tests/network/nativeauth/SignUpEmailPasswordTest.kt b/msal/src/test/java/com/microsoft/identity/client/e2e/tests/network/nativeauth/SignUpEmailPasswordTest.kt index 5115b8c765..6e0b4ee251 100644 --- a/msal/src/test/java/com/microsoft/identity/client/e2e/tests/network/nativeauth/SignUpEmailPasswordTest.kt +++ b/msal/src/test/java/com/microsoft/identity/client/e2e/tests/network/nativeauth/SignUpEmailPasswordTest.kt @@ -25,7 +25,6 @@ package com.microsoft.identity.client.e2e.tests.network.nativeauth import com.microsoft.identity.client.e2e.utils.assertResult import com.microsoft.identity.internal.testutils.nativeauth.ConfigType -import com.microsoft.identity.internal.testutils.nativeauth.api.TemporaryEmailService import com.microsoft.identity.internal.testutils.nativeauth.api.models.NativeAuthTestConfig import com.microsoft.identity.nativeauth.INativeAuthPublicClientApplication import com.microsoft.identity.nativeauth.parameters.NativeAuthSignUpParameters @@ -40,8 +39,6 @@ import org.junit.Test class SignUpEmailPasswordTest : NativeAuthPublicClientApplicationAbstractTest() { - private val tempEmailApi = TemporaryEmailService() - lateinit var application: INativeAuthPublicClientApplication lateinit var config: NativeAuthTestConfig.Config @@ -58,7 +55,7 @@ class SignUpEmailPasswordTest : NativeAuthPublicClientApplicationAbstractTest() retryOperation { runBlocking { - val user = tempEmailApi.generateRandomEmailAddressLocally() + val user = tempEmailApi.generateRandomUnregisteredEmailAddress() val param = NativeAuthSignUpParameters(username = user) param.password = INVALID_PASSWORD.toCharArray() val result = application.signUp(param) @@ -80,9 +77,10 @@ class SignUpEmailPasswordTest : NativeAuthPublicClientApplicationAbstractTest() retryOperation { runBlocking { - val user = tempEmailApi.generateRandomEmailAddressLocally() + val user = tempEmailApi.createRandomEmailAddress() val param = NativeAuthSignUpParameters(username = user) param.password = getSafePassword().toCharArray() + tempEmailApi.markCheckpoint(user) val signUpResult = application.signUp(param) assertResult(signUpResult) @@ -105,13 +103,15 @@ class SignUpEmailPasswordTest : NativeAuthPublicClientApplicationAbstractTest() retryOperation { runBlocking { - val user = tempEmailApi.generateRandomEmailAddressLocally() + val user = tempEmailApi.createRandomEmailAddress() val param = NativeAuthSignUpParameters(username = user) param.password = getSafePassword().toCharArray() + tempEmailApi.markCheckpoint(user) val signUpResult = application.signUp(param) assertResult(signUpResult) val otp1 = tempEmailApi.retrieveCodeFromInbox(user) val codeRequiredState = (signUpResult as SignUpResult.CodeRequired).nextState + tempEmailApi.markCheckpoint(user) val resendCodeResult = codeRequiredState.resendCode() assertResult(resendCodeResult) val otp2 = tempEmailApi.retrieveCodeFromInbox(user) @@ -132,8 +132,9 @@ class SignUpEmailPasswordTest : NativeAuthPublicClientApplicationAbstractTest() retryOperation { runBlocking { // Running with runBlocking to avoid default 10 second execution timeout. - val user = tempEmailApi.generateRandomEmailAddressLocally() + val user = tempEmailApi.createRandomEmailAddress() val param = NativeAuthSignUpParameters(username = user) + tempEmailApi.markCheckpoint(user) val signUpResult = application.signUp(param) assertResult(signUpResult) @@ -159,11 +160,13 @@ class SignUpEmailPasswordTest : NativeAuthPublicClientApplicationAbstractTest() retryOperation { runBlocking { - val user = tempEmailApi.generateRandomEmailAddressLocally() + val user = tempEmailApi.createRandomEmailAddress() val param = NativeAuthSignUpParameters(username = user) + tempEmailApi.markCheckpoint(user) val signUpResult = application.signUp(param) assertResult(signUpResult) + tempEmailApi.markCheckpoint(user) val resendCodeResult = (signUpResult as SignUpResult.CodeRequired).nextState.resendCode() assertResult(resendCodeResult) @@ -246,7 +249,7 @@ class SignUpEmailPasswordTest : NativeAuthPublicClientApplicationAbstractTest() application = setupPCA(config, defaultChallengeTypes, defaultCapabilities) runBlocking { // Running with runBlocking to avoid default 10 second execution timeout. - val user = tempEmailApi.generateRandomEmailAddressLocally() + val user = tempEmailApi.generateRandomUnregisteredEmailAddress() val param = NativeAuthSignUpParameters(username = user) param.password = INVALID_PASSWORD.toCharArray() val signUpResult = application.signUp(param) @@ -267,9 +270,10 @@ class SignUpEmailPasswordTest : NativeAuthPublicClientApplicationAbstractTest() retryOperation { runBlocking { // Running with runBlocking to avoid default 10 second execution timeout. - val user = tempEmailApi.generateRandomEmailAddressLocally() + val user = tempEmailApi.createRandomEmailAddress() val param = NativeAuthSignUpParameters(username = user) param.password = getSafePassword().toCharArray() + tempEmailApi.markCheckpoint(user) val signUpResult = application.signUp(param) assertResult(signUpResult) val otp = tempEmailApi.retrieveCodeFromInbox(user) diff --git a/msal/src/test/java/com/microsoft/identity/client/e2e/utils/AssertUtils.kt b/msal/src/test/java/com/microsoft/identity/client/e2e/utils/AssertUtils.kt index 7615037a0d..6de2b6f762 100644 --- a/msal/src/test/java/com/microsoft/identity/client/e2e/utils/AssertUtils.kt +++ b/msal/src/test/java/com/microsoft/identity/client/e2e/utils/AssertUtils.kt @@ -23,15 +23,34 @@ package com.microsoft.identity.client.e2e.utils import com.microsoft.identity.nativeauth.statemachine.errors.Error -import org.junit.Assert + +/** + * An [AssertionError] that additionally carries the structured Native Auth [Error] that caused the + * assertion to fail, so callers (e.g. the throttle-aware retry loop in + * `NativeAuthPublicClientApplicationAbstractTest`) can classify the failure from typed data such as + * [Error.errorCodes] instead of re-parsing the formatted assertion message. + * + * It remains an [AssertionError] subclass so JUnit continues to report it as a test failure exactly + * as `Assert.fail` did. + * + * Note: [Error] here is [com.microsoft.identity.nativeauth.statemachine.errors.Error], not + * [kotlin.Error]. + * + * @param message the assertion message, unchanged from the previous `Assert.fail` message format. + * @param nativeAuthError the Native Auth error that failed the assertion, or null if the actual + * value was not a Native Auth [Error]. + */ +class NativeAuthAssertionError(message: String, val nativeAuthError: Error?) : AssertionError(message) + inline fun assertResult(actual: Any) { val condition = actual is ExpectedType if (!condition) { - val assertMessage: String = if (actual is Error) { - "Type comparison failed. Expected: ${ExpectedType::class.java}, actual: ${actual.javaClass}. Error: ${actual.error} - ${actual.errorMessage}" + val nativeAuthError: Error? = actual as? Error + val assertMessage: String = if (nativeAuthError != null) { + "Type comparison failed. Expected: ${ExpectedType::class.java}, actual: ${actual.javaClass}. Error: ${nativeAuthError.error} - ${nativeAuthError.errorMessage}" } else { "Type comparison failed. Expected: ${ExpectedType::class.java}, actual: ${actual.javaClass}" } - Assert.fail(assertMessage) + throw NativeAuthAssertionError(assertMessage, nativeAuthError) } } \ No newline at end of file diff --git a/msal/src/test/java/com/microsoft/identity/client/e2e/utils/NativeAuthEmailOTPErrorClassifier.kt b/msal/src/test/java/com/microsoft/identity/client/e2e/utils/NativeAuthEmailOTPErrorClassifier.kt new file mode 100644 index 0000000000..502c6542d2 --- /dev/null +++ b/msal/src/test/java/com/microsoft/identity/client/e2e/utils/NativeAuthEmailOTPErrorClassifier.kt @@ -0,0 +1,94 @@ +// Copyright (c) Microsoft Corporation. +// All rights reserved. +// +// This code is licensed under the MIT License. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files(the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and / or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions : +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. +package com.microsoft.identity.client.e2e.utils + +/** + * Test-scope classifier for the Entra/CIAM email one-time-passcode throttle error. + * + * This is the Android port of the iOS classifier + * `MSAL/test/unit/native_auth/utils/MSALNativeAuthEmailOTPErrorClassifier.swift` + * (microsoft-authentication-library-for-objc), and is kept intentionally behaviour-compatible + * with it: prefer the typed error codes, fall back to a description substring match. + */ +object NativeAuthEmailOTPErrorClassifier { + + /** + * AADSTS701014 - the service could not generate another email one-time passcode yet, because + * OTP issuance for this identity/tenant is currently being throttled. + * + * iOS phrases the equivalent skip message as + * "AADSTS701014: CIAM could not generate another email OTP." + * + * It is a transient, service-side condition: the correct reaction in an E2E test is to back + * off and retry rather than to fail the test. + */ + const val THROTTLE_ERROR_CODE = 701014 + + /** + * Substring fallback marker, matched against an error's description/assertion text when the + * typed [com.microsoft.identity.nativeauth.statemachine.errors.Error.errorCodes] list does not + * itself carry [THROTTLE_ERROR_CODE]. + */ + const val THROTTLE_ERROR_DESCRIPTION_MARKER = "AADSTS$THROTTLE_ERROR_CODE" + + /** + * Returns true when the supplied error represents the email OTP throttle. + * + * The typed [errorCodes] list is the primary, contract-based signal and is checked first, so + * that classification does not depend on how an error was formatted into a string. The + * [errorDescription] substring match is the fallback for the cases the typed list cannot cover. + * + * @param errorCodes the error codes returned by the authentication server, if available. + * @param errorDescription the error message/description text, if available. + */ + @JvmStatic + fun isThrottleError(errorCodes: List?, errorDescription: String?): Boolean { + if (errorCodes?.contains(THROTTLE_ERROR_CODE) == true) { + return true + } + return errorDescription?.contains(THROTTLE_ERROR_DESCRIPTION_MARKER) ?: false + } + + /** + * Returns true when the supplied assertion failure represents the email OTP throttle. + * + * The typed [com.microsoft.identity.nativeauth.statemachine.errors.Error.errorCodes] list + * carried by a [NativeAuthAssertionError] is the primary, contract-based signal. + * + * [Throwable.message] is the fallback and is consulted unconditionally rather than only when + * the typed error is absent: [assertResult] embeds both the server's `error` and `errorMessage` + * into the assertion message, so the message text is a strict superset of `errorMessage` for + * the typed case and is the only available text for the untyped case. Using it unconditionally + * keeps this fallback from ever being narrower than the plain string match it replaced. + * + * @param error the assertion failure thrown by the auth flow under test. + */ + @JvmStatic + fun isThrottleError(error: AssertionError): Boolean { + val nativeAuthError = (error as? NativeAuthAssertionError)?.nativeAuthError + return isThrottleError( + errorCodes = nativeAuthError?.errorCodes, + errorDescription = error.message + ) + } +} diff --git a/msal/src/test/java/com/microsoft/identity/client/e2e/utils/NativeAuthEmailOTPErrorClassifierTest.kt b/msal/src/test/java/com/microsoft/identity/client/e2e/utils/NativeAuthEmailOTPErrorClassifierTest.kt new file mode 100644 index 0000000000..9e24ddca0f --- /dev/null +++ b/msal/src/test/java/com/microsoft/identity/client/e2e/utils/NativeAuthEmailOTPErrorClassifierTest.kt @@ -0,0 +1,209 @@ +// Copyright (c) Microsoft Corporation. +// All rights reserved. +// +// This code is licensed under the MIT License. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files(the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and / or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions : +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. +package com.microsoft.identity.client.e2e.utils + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import com.microsoft.identity.nativeauth.statemachine.errors.Error as NativeAuthError + +/** + * Pure JVM tests for [NativeAuthEmailOTPErrorClassifier], mirroring the iOS + * `MSALNativeAuthEmailOTPErrorClassifierTests` coverage. + */ +class NativeAuthEmailOTPErrorClassifierTest { + + @Test + fun testThrottleCodePresentInErrorCodesReturnsTrue() { + assertTrue( + NativeAuthEmailOTPErrorClassifier.isThrottleError( + errorCodes = listOf(NativeAuthEmailOTPErrorClassifier.THROTTLE_ERROR_CODE), + errorDescription = null + ) + ) + } + + @Test + fun testThrottleCodePresentAmongOtherErrorCodesReturnsTrue() { + assertTrue( + NativeAuthEmailOTPErrorClassifier.isThrottleError( + errorCodes = listOf(50034, NativeAuthEmailOTPErrorClassifier.THROTTLE_ERROR_CODE), + errorDescription = "some unrelated description" + ) + ) + } + + @Test + fun testThrottleCodeAbsentFromErrorCodesReturnsFalse() { + assertFalse( + NativeAuthEmailOTPErrorClassifier.isThrottleError( + errorCodes = listOf(50034, 50126), + errorDescription = "AADSTS50034: The user account does not exist." + ) + ) + } + + @Test + fun testDescriptionOnlyMatchReturnsTrue() { + assertFalse( + NativeAuthEmailOTPErrorClassifier.isThrottleError( + errorCodes = null, + errorDescription = "AADSTS50034: The user account does not exist." + ) + ) + assertTrue( + NativeAuthEmailOTPErrorClassifier.isThrottleError( + errorCodes = null, + errorDescription = "AADSTS701014: CIAM could not generate another email OTP." + ) + ) + } + + @Test + fun testEmptyErrorCodesFallsBackToDescription() { + assertTrue( + NativeAuthEmailOTPErrorClassifier.isThrottleError( + errorCodes = emptyList(), + errorDescription = "AADSTS701014: CIAM could not generate another email OTP." + ) + ) + assertFalse( + NativeAuthEmailOTPErrorClassifier.isThrottleError( + errorCodes = emptyList(), + errorDescription = "some unrelated description" + ) + ) + } + + @Test + fun testNullErrorCodesAndNullDescriptionReturnsFalse() { + assertFalse( + NativeAuthEmailOTPErrorClassifier.isThrottleError( + errorCodes = null, + errorDescription = null + ) + ) + } + + @Test + fun testThrottleErrorCodeConstantMatchesServiceContract() { + assertEquals(701014, NativeAuthEmailOTPErrorClassifier.THROTTLE_ERROR_CODE) + assertEquals("AADSTS701014", NativeAuthEmailOTPErrorClassifier.THROTTLE_ERROR_DESCRIPTION_MARKER) + } + + private fun nativeAuthError( + error: String?, + errorMessage: String?, + errorCodes: List? + ) = NativeAuthError( + error = error, + errorMessage = errorMessage, + correlationId = "UNSET", + errorCodes = errorCodes + ) + + @Test + fun testAssertionErrorWithTypedThrottleCodeIsThrottled() { + val error = nativeAuthError( + error = "invalid_grant", + errorMessage = "Something the marker does not appear in", + errorCodes = listOf(NativeAuthEmailOTPErrorClassifier.THROTTLE_ERROR_CODE) + ) + assertTrue( + NativeAuthEmailOTPErrorClassifier.isThrottleError( + NativeAuthAssertionError("Type comparison failed.", error) + ) + ) + } + + /** + * Regression pin: when the carried error has no typed codes, classification must still fall + * back to the assertion message. Reading only `Error.errorMessage` here would be narrower than + * the plain `e.message.contains("AADSTS701014")` check this classifier replaced. + */ + @Test + fun testAssertionErrorWithoutTypedCodesFallsBackToAssertionMessage() { + val error = nativeAuthError(error = null, errorMessage = null, errorCodes = null) + assertTrue( + NativeAuthEmailOTPErrorClassifier.isThrottleError( + NativeAuthAssertionError( + "Type comparison failed. Error: null - AADSTS701014: CIAM could not generate another email OTP.", + error + ) + ) + ) + } + + /** + * Regression pin: the marker may arrive in the server's `error` field rather than in + * `errorMessage`. [assertResult] embeds both into the assertion message, so classifying off the + * message keeps this case retryable. + */ + @Test + fun testAssertionErrorWithMarkerOnlyInErrorFieldIsThrottled() { + val error = nativeAuthError( + error = "AADSTS701014", + errorMessage = "Request throttled.", + errorCodes = listOf(50034) + ) + assertTrue( + NativeAuthEmailOTPErrorClassifier.isThrottleError( + NativeAuthAssertionError( + "Type comparison failed. Error: AADSTS701014 - Request throttled.", + error + ) + ) + ) + } + + @Test + fun testNonThrottleAssertionErrorIsNotThrottled() { + val error = nativeAuthError( + error = "invalid_grant", + errorMessage = "AADSTS50034: The user account does not exist.", + errorCodes = listOf(50034) + ) + assertFalse( + NativeAuthEmailOTPErrorClassifier.isThrottleError( + NativeAuthAssertionError( + "Type comparison failed. Error: invalid_grant - AADSTS50034: The user account does not exist.", + error + ) + ) + ) + } + + @Test + fun testPlainAssertionErrorIsClassifiedFromItsMessage() { + assertTrue( + NativeAuthEmailOTPErrorClassifier.isThrottleError( + AssertionError("AADSTS701014: CIAM could not generate another email OTP.") + ) + ) + assertFalse( + NativeAuthEmailOTPErrorClassifier.isThrottleError(AssertionError("expected:<1> but was:<2>")) + ) + assertFalse(NativeAuthEmailOTPErrorClassifier.isThrottleError(AssertionError())) + } +}