From 40ad51c8ae1f0c5995440a244198db685efd7a68 Mon Sep 17 00:00:00 2001 From: Disha Janardhan Date: Fri, 14 Aug 2026 03:40:34 -0400 Subject: [PATCH 1/2] Migrate Native Auth test email provider to Mail.tm Replace the 1secmail test integration with authenticated Mail.tm inboxes, checkpoint-aware OTP polling, compatibility APIs, and regression coverage. Pass the provider password through the existing secured build configuration path. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../build-consumers.yml | 2 +- changelog.txt | 1 + common4j/build.gradle | 6 + .../common/java/nativeauth/BuildValues.java | 6 + .../common/nativeauth/ApiConstants.kt | 2 +- .../nativeauth/api/TemporaryEmailService.kt | 521 ++++++++++++------ .../nativeauth/api/models/EmailContent.kt | 39 -- .../nativeauth/api/models/InboxContent.kt | 36 -- .../api/TemporaryEmailServiceTest.kt | 482 ++++++++++++++++ 9 files changed, 845 insertions(+), 250 deletions(-) delete mode 100644 testutils/src/main/java/com/microsoft/identity/internal/testutils/nativeauth/api/models/EmailContent.kt delete mode 100644 testutils/src/main/java/com/microsoft/identity/internal/testutils/nativeauth/api/models/InboxContent.kt create mode 100644 testutils/src/test/java/com/microsoft/identity/internal/testutils/nativeauth/api/TemporaryEmailServiceTest.kt diff --git a/azure-pipelines/pull-request-validation/build-consumers.yml b/azure-pipelines/pull-request-validation/build-consumers.yml index 040ab945e7..126f909ad5 100644 --- a/azure-pipelines/pull-request-validation/build-consumers.yml +++ b/azure-pipelines/pull-request-validation/build-consumers.yml @@ -129,7 +129,7 @@ stages: - task: Gradle@3 displayName: Run msal Unit tests inputs: - tasks: msal:testLocalDebugUnitTest -Plabtest -PlabSecret=$(LabVaultAppCert) -ProbolectricSdkVersion=${{variables.robolectricSdkVersion}} -PmockApiUrl=$(MOCK_API_URL) -PnativeAuthConfigString=$(NATIVE_AUTH_CONFIG_STRING) + tasks: msal:testLocalDebugUnitTest -Plabtest -PlabSecret=$(LabVaultAppCert) -ProbolectricSdkVersion=${{variables.robolectricSdkVersion}} -PmockApiUrl=$(MOCK_API_URL) -PnativeAuthConfigString=$(NATIVE_AUTH_CONFIG_STRING) -PemailProviderPassword=$(EMAIL_PROVIDER_PASSWORD) jdkArchitecture: x64 jdkVersionOption: "1.17" # msalautomationapp diff --git a/changelog.txt b/changelog.txt index 649a2edcf4..345db57f57 100644 --- a/changelog.txt +++ b/changelog.txt @@ -1,5 +1,6 @@ vNext ---------- +- [PATCH] Migrate the Native Auth E2E test email provider from 1secmail to authenticated Mail.tm (#3219) - [PATCH] Remove the abandoned CALL_REFACTORED_SAVE_AND_LOAD_AGGREGATED_ACCOUNT_METHOD flight and its unused optimized cache save-and-load implementation (#3218) - [PATCH] Improve Auth Tab / browser-selection logging and add browser-package-missing error string (#3191) - [MINOR] Add QR+PIN preference and camera-permission flow telemetry to android_spans (#3202) diff --git a/common4j/build.gradle b/common4j/build.gradle index eb586399ea..fd7d922867 100644 --- a/common4j/build.gradle +++ b/common4j/build.gradle @@ -212,6 +212,7 @@ def useMockApiForNativeAuthParameter = false // will be false unless specified b def mockApiUrlParameter = "" // will be blank unless specified by developer def nativeAuthConfigFilePathParameter = "" // will be blank unless specified by developer, used to run e2e tests locally def nativeAuthConfigStringParameter = "" // will be blank unless specified by developer, used to run e2e tests in CI +def emailProviderPasswordParameter = "" // will be blank unless specified by developer def disableAcquireTokenSilentTimeoutParameter = false // will be false unless specified by developer def allowOneboxAuthorities = false // will be false unless specified by developer @@ -243,6 +244,10 @@ if (project.hasProperty("nativeAuthConfigString")) { nativeAuthConfigStringParameter = nativeAuthConfigString } +if (project.hasProperty("emailProviderPassword")) { + emailProviderPasswordParameter = emailProviderPassword +} + // By adding -PdisableAcquireTokenSilentTimeout in your dev environment, you will no longer subject to the ATS timeout, // and your life will be much happier during debugging. if (project.hasProperty("disableAcquireTokenSilentTimeout")) { @@ -262,6 +267,7 @@ sourceSets { buildConfigField("String", "MOCK_API_URL", "\"$mockApiUrlParameter\"") buildConfigField("String", "NATIVE_AUTH_CONFIG_FILE_PATH", "\"$nativeAuthConfigFilePathParameter\"") buildConfigField("String", "NATIVE_AUTH_CONFIG_STRING", "\"$nativeAuthConfigStringParameter\"") + buildConfigField("String", "EMAIL_PROVIDER_PASSWORD", "\"$emailProviderPasswordParameter\"") buildConfigField("boolean", "DISABLE_ACQUIRE_TOKEN_SILENT_TIMEOUT", "${disableAcquireTokenSilentTimeoutParameter}") buildConfigField("boolean", "ALLOW_ONEBOX_AUTHORITIES", "${allowOneboxAuthorities}") } diff --git a/common4j/src/main/com/microsoft/identity/common/java/nativeauth/BuildValues.java b/common4j/src/main/com/microsoft/identity/common/java/nativeauth/BuildValues.java index 3e29234eb4..c238c267ed 100644 --- a/common4j/src/main/com/microsoft/identity/common/java/nativeauth/BuildValues.java +++ b/common4j/src/main/com/microsoft/identity/common/java/nativeauth/BuildValues.java @@ -53,6 +53,8 @@ public static void setDC(String dc) { private static String NATIVE_AUTH_CONFIG_FILE_PATH = BuildConfig.NATIVE_AUTH_CONFIG_FILE_PATH; + private static String EMAIL_PROVIDER_PASSWORD = BuildConfig.EMAIL_PROVIDER_PASSWORD; + public static Boolean shouldUseMockApiForNativeAuth() { return USE_MOCK_API_FOR_NATIVE_AUTH_AUTHORITY; @@ -73,4 +75,8 @@ public static String getNativeAuthConfigString() { public static String getNativeAuthConfigFilePath() { return NATIVE_AUTH_CONFIG_FILE_PATH; } + + public static String getEmailProviderPassword() { + return EMAIL_PROVIDER_PASSWORD; + } } diff --git a/common4j/src/testFixtures/java/com/microsoft/identity/common/nativeauth/ApiConstants.kt b/common4j/src/testFixtures/java/com/microsoft/identity/common/nativeauth/ApiConstants.kt index cf7892148d..5e07defec2 100644 --- a/common4j/src/testFixtures/java/com/microsoft/identity/common/nativeauth/ApiConstants.kt +++ b/common4j/src/testFixtures/java/com/microsoft/identity/common/nativeauth/ApiConstants.kt @@ -52,6 +52,6 @@ interface ApiConstants { } object TemporaryMailService { - const val BASE_URL = "https://www.1secmail.com/api/v1/" + const val BASE_URL = "https://api.mail.tm" } } diff --git a/testutils/src/main/java/com/microsoft/identity/internal/testutils/nativeauth/api/TemporaryEmailService.kt b/testutils/src/main/java/com/microsoft/identity/internal/testutils/nativeauth/api/TemporaryEmailService.kt index b987c61032..673f457073 100644 --- a/testutils/src/main/java/com/microsoft/identity/internal/testutils/nativeauth/api/TemporaryEmailService.kt +++ b/testutils/src/main/java/com/microsoft/identity/internal/testutils/nativeauth/api/TemporaryEmailService.kt @@ -1,242 +1,417 @@ -// Copyright (c) Microsoft Corporation. -// All rights reserved. +// Copyright (c) Microsoft Corporation. +// All rights reserved. // -// This code is licensed under the MIT License. +// 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 : +// 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 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. +// 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.internal.testutils.nativeauth.api +import com.google.gson.JsonParseException +import com.google.gson.annotations.SerializedName import com.google.gson.reflect.TypeToken +import com.microsoft.identity.common.java.nativeauth.BuildValues import com.microsoft.identity.common.nativeauth.ApiConstants import com.microsoft.identity.internal.test.labapi.ApiClient import com.microsoft.identity.internal.test.labapi.ApiException +import com.microsoft.identity.internal.test.labapi.JSON import com.microsoft.identity.internal.test.labapi.Pair -import com.microsoft.identity.internal.testutils.nativeauth.api.models.EmailContent -import com.microsoft.identity.internal.testutils.nativeauth.api.models.InboxContent -import com.squareup.okhttp.Call +import com.squareup.okhttp.Protocol +import com.squareup.okhttp.Request +import com.squareup.okhttp.ResponseBody +import java.io.IOException import java.text.SimpleDateFormat -import java.util.Date +import java.util.Locale import java.util.TimeZone import java.util.UUID -import java.util.concurrent.TimeUnit - /** - * + * Mail.tm inbox helper for Native Auth end-to-end tests. */ -class TemporaryEmailService { +class TemporaryEmailService internal constructor( + private val password: String, + private val api: MailTmApi, + private val clock: () -> Long, + private val sleeper: (Long) -> Unit, + private val pollingDelaysMs: List +) { + constructor(password: String) : this( + password = password, + api = MailTmApi(), + clock = { System.currentTimeMillis() }, + sleeper = { Thread.sleep(it) }, + pollingDelaysMs = DEFAULT_POLLING_DELAYS_MS + ) + + constructor() : this(BuildValues.getEmailProviderPassword()) + + private val inboxes = mutableMapOf() - private val api = TemporaryEmailApi() - private val dateFormatter = SimpleDateFormat("yyyy-MM-dd HH:mm:ss").apply { - timeZone = TimeZone.getTimeZone("UTC") + /** + * Creates and authenticates a random Mail.tm inbox. + */ + fun createRandomEmailAddress(): String { + requirePassword() + val domain = api.getDomains().firstOrNull() + ?: throw IllegalStateException("Mail.tm did not return an available domain") + val address = "native-auth-signup-${UUID.randomUUID()}@$domain" + api.createAccount(address, password) + inboxes[address] = InboxState(token = api.authenticate(address, password)) + return address } - private val numberOfRetries = 3 - private val retryDelayMs: Long = 4000 - private val newEmailCutoff = 5.0 - fun generateRandomEmailAddressLocally(): String { - val randomId = UUID.randomUUID().toString().substring(0, 8) - return "native-auth-signup-$randomId@1secmail.org" + /** + * Creates a random Mail.tm inbox for compatibility with downstream consumers. + * + * TODO: Remove after downstream consumers migrate to [createRandomEmailAddress] or + * [generateRandomUnregisteredEmailAddress], according to whether they require an inbox. + */ + fun generateRandomEmailAddressLocally(): String = createRandomEmailAddress() + + /** + * Returns a random, validly formatted address without creating an inbox. + */ + fun generateRandomUnregisteredEmailAddress(): String { + return "native-auth-signup-${UUID.randomUUID()}@mail.tm" } - fun generateRandomEmailAddressWithAPI(): String { - val generatedUsers = api.generateRandomEmailAddress() - if (generatedUsers.isEmpty()) { - throw ApiException("No generated users returned from TemporaryEmailApi") - } - val user = generatedUsers[0] - if (user.isNullOrBlank()) { - throw ApiException("No valid user returned from TemporaryEmailApi") - } - return user + /** + * Records the time immediately before an operation that sends an OTP. + */ + fun markCheckpoint(emailAddress: String) { + inboxes.getOrPut(emailAddress) { InboxState() }.checkpointMillis = clock() } /** - * Retrieve mailbox content, and OTP from latest email. - * To cater for email send and arrival delays, this is retried several times. + * Polls Mail.tm for the first OTP received after the address checkpoint. */ fun retrieveCodeFromInbox(emailAddress: String): String { - var validCodeRetrieved = false - var count = 0 - var latestEmailId: String? = null - var otpValue = "" - var apiException: Exception? = null + val state = inboxes.getOrPut(emailAddress) { InboxState() } + val token = state.token ?: authenticate(emailAddress).also { state.token = it } + val attempts = pollingDelaysMs.size + 1 + var lastException: ApiException? = null - while (count < numberOfRetries && !validCodeRetrieved) { + repeat(attempts) { attempt -> try { - //wait before calling the email endpoint - Thread.sleep(retryDelayMs) - - //API returns dates in the UTC timezone, so system time should also be converted - val currentTime = Date() - - val inboxEmails = api.retrieveMailbox(emailAddress) - - val newEmailId = inboxEmails - .filter { - val emailDate = dateFormatter.parse(it.date) - val diffInMillis = currentTime.time - emailDate.time - TimeUnit.MILLISECONDS.toSeconds(diffInMillis) < newEmailCutoff + val messages = api.getMessages(token) + .sortedByDescending(::messageTime) + .filter { isAfterCheckpoint(it, state) } + + messages.forEach { message -> + try { + val otp = extractOtp(api.getMessageSource(token, message.id)) + if (otp != null) { + state.checkpointMillis = messageTime(message) + state.consumedMessageIds.add(message.id) + return otp + } + } catch (exception: ApiException) { + lastException = exception + if (!isMessageSpecificSourceFailure(exception)) { + throw exception + } } - .map { it.id } - .firstOrNull() - - if (newEmailId != null && latestEmailId != newEmailId) { - latestEmailId = newEmailId - - val emailContent = api.retrieveEmail(emailAddress, latestEmailId) - otpValue = retrieveOtpFromEmailBody(emailContent.textBody) - validCodeRetrieved = true } + } catch (exception: ApiException) { + lastException = exception + } - count++ - } catch (e: Exception) { - //1secmail server occasionally returns an internal server error which causes the API client to throw an exception - //In this case, retry the operation - apiException = e - count++ + if (attempt < pollingDelaysMs.size) { + sleeper(pollingDelaysMs[attempt]) } } - // After the retries we still weren't able to retrieve a valid code from the inbox, so fail and restart the test. - if (!validCodeRetrieved) { - throw apiException ?: IllegalStateException("Unable to fetch valid code for user") - } + throw lastException ?: IllegalStateException("Unable to fetch a new OTP from Mail.tm") + } - return otpValue + private fun isMessageSpecificSourceFailure(exception: ApiException): Boolean { + return exception.code == 404 || + exception.code == 0 && exception.cause !is IOException } - private fun retrieveOtpFromEmailBody(emailBody: String): String { - val otpRegex = "Account verification code:\n(?[0-9]*)\n".toRegex() - val match = otpRegex.find(emailBody) - val otp = match!!.groups["otc"]!!.value - return otp + private fun authenticate(emailAddress: String): String { + requirePassword() + return api.authenticate(emailAddress, password) } - class TemporaryEmailApi { - companion object { - const val ACTION_GENERATE_RANDOM_MAILBOX = "genRandomMailbox" - const val ACTION_GET_MESSAGES = "getMessages" - const val ACTION_READ_MESSAGE = "readMessage" + private fun requirePassword() { + if (password.isBlank()) { + throw IllegalStateException("emailProviderPassword is required for Mail.tm inbox access") } + } - private val apiClient = ApiClient(ApiConstants.TemporaryMailService.BASE_URL) - - fun generateRandomEmailAddress(): List { - val queryParam = ArrayList(createActionQueryParam(ACTION_GENERATE_RANDOM_MAILBOX)) - - val apiCall = createCall(queryParam) - - val localVarReturnType = TypeToken.getParameterized( - List::class.java, - String::class.java - ).type - - val apiResponse = apiClient.execute>(apiCall, localVarReturnType) + private fun messageTime(message: MailTmMessage): Long { + return parseTimestamp(message.createdAt) ?: Long.MIN_VALUE + } - return apiResponse.data + private fun isAfterCheckpoint(message: MailTmMessage, state: InboxState): Boolean { + if (message.id in state.consumedMessageIds) { + return false } + val timestamp = message.createdAt + val time = messageTime(message) + return if (timestamp.contains('.')) { + time > state.checkpointMillis + } else { + time >= state.checkpointMillis / 1000 * 1000 + } + } - fun retrieveMailbox(userEmail: String): List { - val userLogin = userEmail.split("@")[0] - val userDomain = userEmail.split("@")[1] - - val queryParam = mutableListOf() - queryParam.addAll( - createActionQueryParam(ACTION_GET_MESSAGES) - ) - queryParam.addAll( - createLoginQueryParam(userLogin) - ) - queryParam.addAll( - createDomainQueryParam(userDomain) - ) - - val apiCall = createCall(queryParam) + private fun parseTimestamp(value: String): Long? { + TIMESTAMP_PATTERNS.forEach { pattern -> + try { + return SimpleDateFormat(pattern, Locale.US).apply { + timeZone = TimeZone.getTimeZone("UTC") + }.parse(value)?.time + } catch (_: java.text.ParseException) { + // Try the next supported Mail.tm timestamp format. + } + } + return null + } - val localVarReturnType = TypeToken.getParameterized( - List::class.java, - InboxContent::class.java - ).type + private fun extractOtp(source: String): String? { + val explicitMatch = EXPLICIT_OTP_REGEX.find(source) + if (explicitMatch != null) { + return explicitMatch.groupValues[1] + } + var body = when { + "\r\n\r\n" in source -> source.substringAfter("\r\n\r\n") + "\n\n" in source -> source.substringAfter("\n\n") + else -> source + } + val textPart = TEXT_PART_REGEX.find(body) + if (textPart != null) { + body = textPart.groupValues[1] + } + return FALLBACK_OTP_REGEX.find(body)?.groupValues?.get(1) + } + private data class InboxState( + var token: String? = null, + var checkpointMillis: Long = Long.MIN_VALUE, + val consumedMessageIds: MutableSet = mutableSetOf() + ) + + companion object { + private val DEFAULT_POLLING_DELAYS_MS = listOf(10_000L, 20_000L, 30_000L, 40_000L) + private val TIMESTAMP_PATTERNS = listOf( + "yyyy-MM-dd'T'HH:mm:ss.SSSX", + "yyyy-MM-dd'T'HH:mm:ssX" + ) + private val EXPLICIT_OTP_REGEX = + Regex("Account verification code:\\s*([0-9]+)", RegexOption.IGNORE_CASE) + private val TEXT_PART_REGEX = Regex( + "Content-Type:\\s*text/(?:plain|html)[^\\r\\n]*(?:\\r?\\n[^\\r\\n]*)*\\r?\\n\\r?\\n(.*?)(?=\\r?\\n--|$)", + setOf(RegexOption.IGNORE_CASE, RegexOption.DOT_MATCHES_ALL) + ) + private val FALLBACK_OTP_REGEX = Regex("(?>(apiCall, localVarReturnType) +internal data class MailTmRequest( + val endpoint: String, + val method: String, + val headers: Map, + val body: Any? = null +) - return apiResponse.data - } +internal interface MailTmTransport { + fun execute(request: MailTmRequest): String +} - fun retrieveEmail(userEmail: String, emailId: String): EmailContent { - val userLogin = userEmail.split("@")[0] - val userDomain = userEmail.split("@")[1] +internal class MailTmApi( + private val transport: MailTmTransport = OkHttpMailTmTransport() +) { + fun getDomains(): List { + val type = TypeToken.getParameterized( + MailTmCollection::class.java, + MailTmDomain::class.java + ).type + val response = parse>( + transport.execute(MailTmRequest("/domains", GET, headers())), + type, + "/domains" + ) + return response.members.orEmpty() + .filter { it.isActive == true && it.isPrivate == false && !it.domain.isNullOrBlank() } + .map { it.domain!! } + } - val queryParam = mutableListOf() - queryParam.addAll( - createActionQueryParam(ACTION_READ_MESSAGE) - ) - queryParam.addAll( - createLoginQueryParam(userLogin) - ) - queryParam.addAll( - createDomainQueryParam(userDomain) - ) - queryParam.addAll( - createIdQueryParam(emailId) + fun createAccount(address: String, password: String) { + transport.execute( + MailTmRequest( + endpoint = "/accounts", + method = POST, + headers = headers(includeContentType = true), + body = MailTmCredentials(address, password) ) + ) + } - val apiCall = createCall(queryParam) + fun authenticate(address: String, password: String): String { + val response = parse( + transport.execute( + MailTmRequest( + endpoint = "/token", + method = POST, + headers = headers(includeContentType = true), + body = MailTmCredentials(address, password) + ) + ), + MailTmToken::class.java, + "/token" + ) + return response.token + ?: throw ApiException("Mail.tm response for /token is missing required field 'token'") + } - val localVarReturnType = TypeToken.get(EmailContent::class.java).type + fun getMessages(token: String): List { + val type = TypeToken.getParameterized( + MailTmCollection::class.java, + MailTmMessageResponse::class.java + ).type + val response = parse>( + transport.execute(MailTmRequest("/messages", GET, headers(token = token))), + type, + "/messages" + ) + return response.members.orEmpty().mapNotNull { message -> + val id = message.id?.takeIf(String::isNotBlank) ?: return@mapNotNull null + val createdAt = message.createdAt?.takeIf(String::isNotBlank) ?: return@mapNotNull null + MailTmMessage(id, createdAt, message.updatedAt) + } + } - val apiResponse = apiClient.execute(apiCall, localVarReturnType) + fun getMessageSource(token: String, messageId: String): String { + val endpoint = "/sources/$messageId" + val response = parse( + transport.execute(MailTmRequest(endpoint, GET, headers(token = token))), + MailTmSource::class.java, + endpoint + ) + return response.data + ?: throw ApiException("Mail.tm response for $endpoint is missing required field 'data'") + } - return apiResponse.data + private fun headers( + includeContentType: Boolean = false, + token: String? = null + ): Map { + val headers = linkedMapOf("Accept" to APPLICATION_LD_JSON) + if (includeContentType) { + headers["Content-Type"] = APPLICATION_JSON } - - private fun createActionQueryParam(value: String): List { - return apiClient.parameterToPair("action", value) + if (token != null) { + headers["Authorization"] = "Bearer $token" } + return headers + } - private fun createLoginQueryParam(value: String): List { - return apiClient.parameterToPair("login", value) + private fun parse(body: String, type: java.lang.reflect.Type, endpoint: String): T { + return try { + GSON.fromJson(body, type) + ?: throw ApiException("Mail.tm response body was empty for $endpoint") + } catch (exception: JsonParseException) { + throw ApiException("Failed to parse Mail.tm response for $endpoint", exception, 0, null) } + } +} - private fun createDomainQueryParam(value: String): List { - return apiClient.parameterToPair("domain", value) - } +private class OkHttpMailTmTransport( + private val apiClient: ApiClient = ApiClient(ApiConstants.TemporaryMailService.BASE_URL) +) : MailTmTransport { + init { + apiClient.getHttpClient().protocols = listOf(Protocol.HTTP_1_1) + } - private fun createIdQueryParam(value: String): List { - return apiClient.parameterToPair("id", value) + override fun execute(request: MailTmRequest): String { + var responseBody: ResponseBody? = null + return try { + val response = apiClient.getHttpClient().newCall(buildRequest(request)).execute() + responseBody = response.body() + val body = responseBody?.string().orEmpty() + if (!response.isSuccessful) { + throw ApiException( + response.code(), + "Mail.tm request to ${request.endpoint} failed with status ${response.code()}" + ) + } + body + } catch (exception: IOException) { + throw ApiException("Mail.tm request failed for ${request.endpoint}", exception, 0, null) + } finally { + responseBody?.close() } + } - private fun createCall(queryParam: List): Call { - return apiClient.buildCall( - "", - "GET", - queryParam, - null, - null, - mapOf(), - null, - arrayOf(), - null + private fun buildRequest(request: MailTmRequest): Request { + val builder = Request.Builder().url( + apiClient.buildUrl( + request.endpoint, + emptyList(), + emptyList() ) + ) + request.headers.forEach { (name, value) -> builder.header(name, value) } + val body = if (request.method == GET) { + null + } else { + apiClient.serialize(request.body, APPLICATION_JSON) } + return builder.method(request.method, body).build() } } + +private data class MailTmCollection( + @SerializedName("hydra:member") val members: List? +) + +private data class MailTmDomain( + val domain: String?, + val isActive: Boolean?, + val isPrivate: Boolean? +) + +private data class MailTmCredentials( + val address: String, + val password: String +) + +private data class MailTmToken(val token: String?) + +private data class MailTmMessageResponse( + val id: String?, + val createdAt: String?, + val updatedAt: String? +) + +internal data class MailTmMessage( + val id: String, + val createdAt: String, + val updatedAt: String? +) + +private data class MailTmSource(val data: String?) + +private const val APPLICATION_JSON = "application/json" +private const val APPLICATION_LD_JSON = "application/ld+json" +private const val GET = "GET" +private const val POST = "POST" +private val GSON = JSON.createGson().create() diff --git a/testutils/src/main/java/com/microsoft/identity/internal/testutils/nativeauth/api/models/EmailContent.kt b/testutils/src/main/java/com/microsoft/identity/internal/testutils/nativeauth/api/models/EmailContent.kt deleted file mode 100644 index 2c6341f33f..0000000000 --- a/testutils/src/main/java/com/microsoft/identity/internal/testutils/nativeauth/api/models/EmailContent.kt +++ /dev/null @@ -1,39 +0,0 @@ -// 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.internal.testutils.nativeauth.api.models - -import com.google.gson.annotations.SerializedName - -/** - * - */ -data class EmailContent( - @SerializedName("id") val id: String, - @SerializedName("from") val from: String, - @SerializedName("subject") val subject: String, - @SerializedName("date") val date: String, // TODO make DateTimeObject - @SerializedName("body") val body: String, - @SerializedName("textBody") val textBody: String, - @SerializedName("htmlBody") val htmlBody: String, -) \ No newline at end of file diff --git a/testutils/src/main/java/com/microsoft/identity/internal/testutils/nativeauth/api/models/InboxContent.kt b/testutils/src/main/java/com/microsoft/identity/internal/testutils/nativeauth/api/models/InboxContent.kt deleted file mode 100644 index 773561e460..0000000000 --- a/testutils/src/main/java/com/microsoft/identity/internal/testutils/nativeauth/api/models/InboxContent.kt +++ /dev/null @@ -1,36 +0,0 @@ -// 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.internal.testutils.nativeauth.api.models - -import com.google.gson.annotations.SerializedName - -/** - * - */ -data class InboxContent( - @SerializedName("id") val id: String, - @SerializedName("from") val from: String, - @SerializedName("subject") val subject: String, - @SerializedName("date") val date: String // TODO make DateTimeObject -) \ No newline at end of file diff --git a/testutils/src/test/java/com/microsoft/identity/internal/testutils/nativeauth/api/TemporaryEmailServiceTest.kt b/testutils/src/test/java/com/microsoft/identity/internal/testutils/nativeauth/api/TemporaryEmailServiceTest.kt new file mode 100644 index 0000000000..6610c6822a --- /dev/null +++ b/testutils/src/test/java/com/microsoft/identity/internal/testutils/nativeauth/api/TemporaryEmailServiceTest.kt @@ -0,0 +1,482 @@ +// 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.internal.testutils.nativeauth.api + +import com.microsoft.identity.internal.test.labapi.ApiClient +import com.microsoft.identity.internal.test.labapi.ApiException +import com.squareup.okhttp.Protocol +import org.junit.Assert.assertEquals +import org.junit.Assert.assertThrows +import org.junit.Assert.assertTrue +import org.junit.Test +import java.io.IOException +import java.text.SimpleDateFormat +import java.net.ServerSocket +import java.util.ArrayDeque +import java.util.Locale +import java.util.TimeZone + +class TemporaryEmailServiceTest { + + @Test + fun constructor_withoutPassword_remainsSourceCompatible() { + val service = TemporaryEmailService() + + assertTrue(service.generateRandomUnregisteredEmailAddress().endsWith("@mail.tm")) + } + + @Test + fun generateRandomEmailAddressLocally_createsAuthenticatedInboxForCompatibility() { + val transport = FakeMailTmTransport( + """{"hydra:member":[{"domain":"example.mail.tm","isActive":true,"isPrivate":false}]}""", + "", + """{"token":"mail-tm-token"}""" + ) + + val address = createService(transport).generateRandomEmailAddressLocally() + + assertTrue(address.endsWith("@example.mail.tm")) + assertEquals(listOf("/domains", "/accounts", "/token"), transport.requests.map { it.endpoint }) + } + + @Test + fun okHttpTransport_forcesHttp11ForMailTmCompatibility() { + val apiClient = ApiClient("https://api.mail.tm") + + createOkHttpTransport(apiClient) + + assertEquals(listOf(Protocol.HTTP_1_1), apiClient.getHttpClient().protocols) + } + + @Test + fun retrieveCodeFromInbox_requestsHydraJsonLdAndPostsJsonCredentials() { + val transport = FakeMailTmTransport( + """{"token":"mail-tm-token"}""", + messages("message-1" to "2026-08-12T20:00:00.000Z"), + source("Account verification code: 123456") + ) + + createService(transport).retrieveCodeFromInbox(ADDRESS) + + assertEquals("application/ld+json", transport.requests[0].headers["Accept"]) + assertEquals("application/json", transport.requests[0].headers["Content-Type"]) + assertEquals("application/ld+json", transport.requests[1].headers["Accept"]) + assertEquals(null, transport.requests[1].headers["Content-Type"]) + } + + @Test + fun retrieveCodeFromInbox_authenticatesAndUsesBearerHeader() { + val transport = FakeMailTmTransport( + """{"token":"mail-tm-token"}""", + messages("message-1" to "2026-08-12T20:00:00.000Z"), + source("Account verification code:\r\n123456\r\n") + ) + val service = createService(transport) + + val code = service.retrieveCodeFromInbox(ADDRESS) + + assertEquals("123456", code) + assertEquals("/token", transport.requests[0].endpoint) + assertEquals(null, transport.requests[0].headers["Authorization"]) + assertEquals("Bearer mail-tm-token", transport.requests[1].headers["Authorization"]) + assertEquals("Bearer mail-tm-token", transport.requests[2].headers["Authorization"]) + } + + @Test + fun retrieveCodeFromInbox_checkpointIgnoresStaleMessages() { + val transport = FakeMailTmTransport( + """{"token":"mail-tm-token"}""", + """ + { + "hydra:member": [ + { + "id":"stale-message", + "createdAt":"2026-08-12T19:59:59.000Z", + "updatedAt":"2026-08-12T20:00:03.000Z" + }, + { + "id":"fresh-message", + "createdAt":"2026-08-12T20:00:01.000Z", + "updatedAt":"2026-08-12T20:00:01.000Z" + } + ] + } + """.trimIndent(), + source("Account verification code: 654321") + ) + val service = createService( + transport = transport, + clock = { timestamp("2026-08-12T20:00:00.000Z") } + ) + service.markCheckpoint(ADDRESS) + + assertEquals("654321", service.retrieveCodeFromInbox(ADDRESS)) + assertTrue(transport.requests.any { it.endpoint == "/sources/fresh-message" }) + assertTrue(transport.requests.none { it.endpoint == "/sources/stale-message" }) + } + + @Test + fun retrieveCodeFromInbox_afterResendReturnsOnlyNewerCode() { + var now = timestamp("2026-08-12T19:59:59.000Z") + val transport = FakeMailTmTransport( + """{"token":"mail-tm-token"}""", + messages("message-1" to "2026-08-12T20:00:00.000Z"), + source("Account verification code: 111111"), + messages( + "message-2" to "2026-08-12T20:00:02.000Z", + "message-1" to "2026-08-12T20:00:00.000Z" + ), + source("Account verification code: 222222") + ) + val service = createService(transport = transport, clock = { now }) + + service.markCheckpoint(ADDRESS) + assertEquals("111111", service.retrieveCodeFromInbox(ADDRESS)) + + now = timestamp("2026-08-12T20:00:01.000Z") + service.markCheckpoint(ADDRESS) + assertEquals("222222", service.retrieveCodeFromInbox(ADDRESS)) + assertEquals(1, transport.requests.count { it.endpoint == "/sources/message-1" }) + } + + @Test + fun retrieveCodeFromInbox_acceptsWholeSecondTimestampWithinCheckpointSecond() { + val transport = FakeMailTmTransport( + """{"token":"mail-tm-token"}""", + messages("message-1" to "2026-08-12T20:00:00Z"), + source("Account verification code: 333333") + ) + val service = createService( + transport = transport, + clock = { timestamp("2026-08-12T20:00:00.500Z") } + ) + service.markCheckpoint(ADDRESS) + + assertEquals("333333", service.retrieveCodeFromInbox(ADDRESS)) + } + + @Test + fun retrieveCodeFromInbox_ignoresMalformedMessageEntries() { + val transport = FakeMailTmTransport( + """{"token":"mail-tm-token"}""", + """ + { + "hydra:member": [ + {"createdAt":"2026-08-12T20:00:01.000Z"}, + {"id":"missing-created-at"}, + {"id":"message-1","createdAt":"2026-08-12T20:00:00.000Z"} + ] + } + """.trimIndent(), + source("Account verification code: 333333") + ) + + assertEquals("333333", createService(transport).retrieveCodeFromInbox(ADDRESS)) + assertTrue(transport.requests.any { it.endpoint == "/sources/message-1" }) + } + + @Test + fun retrieveCodeFromInbox_whenNewestSourceIsMissing_checksNextEligibleMessage() { + val transport = FakeMailTmTransport( + """{"token":"mail-tm-token"}""", + messages( + "newest-message" to "2026-08-12T20:00:01.000Z", + "older-message" to "2026-08-12T20:00:00.000Z" + ), + ApiException(404, "Mail.tm source was not found"), + source("Account verification code: 333333") + ) + + assertEquals("333333", createService(transport).retrieveCodeFromInbox(ADDRESS)) + assertEquals( + listOf("/sources/newest-message", "/sources/older-message"), + transport.requests.filter { it.endpoint.startsWith("/sources/") }.map { it.endpoint } + ) + } + + @Test + fun retrieveCodeFromInbox_whenNewestSourceIsInvalid_checksNextEligibleMessage() { + listOf("{", "{}").forEach { invalidSource -> + val transport = FakeMailTmTransport( + """{"token":"mail-tm-token"}""", + messages( + "newest-message" to "2026-08-12T20:00:01.000Z", + "older-message" to "2026-08-12T20:00:00.000Z" + ), + invalidSource, + source("Account verification code: 333333") + ) + + assertEquals("333333", createService(transport).retrieveCodeFromInbox(ADDRESS)) + assertEquals( + listOf("/sources/newest-message", "/sources/older-message"), + transport.requests.filter { it.endpoint.startsWith("/sources/") }.map { it.endpoint } + ) + } + } + + @Test + fun retrieveCodeFromInbox_whenSourceFailureIsSystemic_abortsAttempt() { + val failures = listOf( + ApiException(401, "Mail.tm token is invalid"), + ApiException(429, "Mail.tm rate limit exceeded"), + ApiException(500, "Mail.tm server failed"), + ApiException("Mail.tm transport failed", IOException(), 0, null) + ) + + failures.forEach { failure -> + val transport = FakeMailTmTransport( + """{"token":"mail-tm-token"}""", + messages( + "newest-message" to "2026-08-12T20:00:01.000Z", + "older-message" to "2026-08-12T20:00:00.000Z" + ), + failure, + source("Account verification code: 333333") + ) + + assertEquals( + failure, + assertThrows(ApiException::class.java) { + createService(transport).retrieveCodeFromInbox(ADDRESS) + } + ) + assertEquals( + listOf("/sources/newest-message"), + transport.requests.filter { it.endpoint.startsWith("/sources/") }.map { it.endpoint } + ) + } + } + + @Test + fun retrieveCodeFromInbox_whenSourceFailureIsSystemic_retriesAfterPollingDelay() { + val delays = mutableListOf() + val transport = FakeMailTmTransport( + """{"token":"mail-tm-token"}""", + messages( + "newest-message" to "2026-08-12T20:00:01.000Z", + "older-message" to "2026-08-12T20:00:00.000Z" + ), + ApiException(429, "Mail.tm rate limit exceeded"), + messages("newest-message" to "2026-08-12T20:00:01.000Z"), + source("Account verification code: 333333") + ) + val service = createService( + transport = transport, + sleeper = { delays.add(it) }, + pollingDelaysMs = listOf(10L) + ) + + assertEquals("333333", service.retrieveCodeFromInbox(ADDRESS)) + assertEquals(listOf(10L), delays) + assertEquals( + listOf("/sources/newest-message", "/sources/newest-message"), + transport.requests.filter { it.endpoint.startsWith("/sources/") }.map { it.endpoint } + ) + } + + @Test + fun retrieveCodeFromInbox_whenAllEligibleSourcesFail_throwsLastSourceException() { + val lastException = ApiException("Mail.tm source is missing required data") + val transport = FakeMailTmTransport( + """{"token":"mail-tm-token"}""", + messages( + "newest-message" to "2026-08-12T20:00:01.000Z", + "older-message" to "2026-08-12T20:00:00.000Z" + ), + ApiException(404, "Mail.tm source was not found"), + lastException + ) + + assertEquals( + lastException, + assertThrows(ApiException::class.java) { + createService(transport).retrieveCodeFromInbox(ADDRESS) + } + ) + } + + @Test + fun retrieveCodeFromInbox_extractsStandaloneOtp() { + val transport = FakeMailTmTransport( + """{"token":"mail-tm-token"}""", + messages("message-1" to "2026-08-12T20:00:00Z"), + source( + "Date: Wed, 12 Aug 2026 20:00:00 +0000\r\n" + + "Subject: Verification\r\n\r\nUse 87654321 to continue." + ) + ) + + assertEquals("87654321", createService(transport).retrieveCodeFromInbox(ADDRESS)) + } + + @Test + fun retrieveCodeFromInbox_extractsOtpFromMultipartTextPart() { + val transport = FakeMailTmTransport( + """{"token":"mail-tm-token"}""", + messages("message-1" to "2026-08-12T20:00:00Z"), + source( + "Content-Type: multipart/alternative; boundary=\"----=_NextPart_000_0000_01DA1234.5678\"\r\n\r\n" + + "------=_NextPart_000_0000_01DA1234.5678\r\n" + + "Content-Type: text/plain; charset=utf-8\r\n\r\n" + + "Use 87654321 to continue.\r\n" + ) + ) + + assertEquals("87654321", createService(transport).retrieveCodeFromInbox(ADDRESS)) + } + + @Test + fun retrieveCodeFromInbox_retriesTransientProviderFailure() { + val transport = FakeMailTmTransport( + """{"token":"mail-tm-token"}""", + ApiException("Temporary Mail.tm failure"), + messages("message-1" to "2026-08-12T20:00:00.000Z"), + source("Account verification code: 444444") + ) + val service = createService( + transport = transport, + pollingDelaysMs = listOf(10L) + ) + + assertEquals("444444", service.retrieveCodeFromInbox(ADDRESS)) + assertEquals(2, transport.requests.count { it.endpoint == "/messages" }) + } + + @Test + fun retrieveCodeFromInbox_retriesNormalizedTransportIOException() { + val unavailablePort = ServerSocket(0).use { it.localPort } + val transport = IOExceptionThenSuccessTransport( + failingTransport = createOkHttpTransport("http://127.0.0.1:$unavailablePort"), + """{"token":"mail-tm-token"}""", + messages("message-1" to "2026-08-12T20:00:00.000Z"), + source("Account verification code: 555555") + ) + val service = createService( + transport = transport, + pollingDelaysMs = listOf(0L) + ) + + assertEquals("555555", service.retrieveCodeFromInbox(ADDRESS)) + assertEquals(2, transport.requests.count { it.endpoint == "/messages" }) + } + + @Test + fun retrieveCodeFromInbox_stopsAfterBoundedProgressivePolling() { + val delays = mutableListOf() + val transport = FakeMailTmTransport( + """{"token":"mail-tm-token"}""", + messages(), + messages(), + messages() + ) + val service = createService( + transport = transport, + sleeper = { delays.add(it) }, + pollingDelaysMs = listOf(10L, 20L) + ) + + assertThrows(IllegalStateException::class.java) { + service.retrieveCodeFromInbox(ADDRESS) + } + assertEquals(listOf(10L, 20L), delays) + assertEquals(3, transport.requests.count { it.endpoint == "/messages" }) + } + + private fun createService( + transport: MailTmTransport, + clock: () -> Long = { 0L }, + sleeper: (Long) -> Unit = {}, + pollingDelaysMs: List = emptyList() + ) = TemporaryEmailService( + password = PASSWORD, + api = MailTmApi(transport), + clock = clock, + sleeper = sleeper, + pollingDelaysMs = pollingDelaysMs + ) + + private fun createOkHttpTransport(baseUrl: String): MailTmTransport { + return createOkHttpTransport(ApiClient(baseUrl)) + } + + private fun createOkHttpTransport(apiClient: ApiClient): MailTmTransport { + val constructor = Class.forName( + "com.microsoft.identity.internal.testutils.nativeauth.api.OkHttpMailTmTransport" + ).getDeclaredConstructor(ApiClient::class.java) + constructor.isAccessible = true + return constructor.newInstance(apiClient) as MailTmTransport + } + + private fun messages(vararg messages: Pair): String { + val members = messages.joinToString(",") { (id, createdAt) -> + """{"id":"$id","createdAt":"$createdAt"}""" + } + return """{"hydra:member":[$members]}""" + } + + private fun source(body: String) = """{"data":${com.google.gson.Gson().toJson(body)}}""" + + private fun timestamp(value: String): Long { + return SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.US).apply { + timeZone = TimeZone.getTimeZone("UTC") + }.parse(value)!!.time + } + + private class FakeMailTmTransport(vararg responses: Any) : MailTmTransport { + private val responses = ArrayDeque(responses.toList()) + val requests = mutableListOf() + + override fun execute(request: MailTmRequest): String { + requests.add(request) + return when (val response = responses.removeFirst()) { + is Exception -> throw response + else -> response as String + } + } + } + + private class IOExceptionThenSuccessTransport( + private val failingTransport: MailTmTransport, + vararg responses: String + ) : MailTmTransport { + private val responses = ArrayDeque(responses.toList()) + private var failureInjected = false + val requests = mutableListOf() + + override fun execute(request: MailTmRequest): String { + requests.add(request) + if (request.endpoint == "/messages" && !failureInjected) { + failureInjected = true + return failingTransport.execute(request) + } + return responses.removeFirst() + } + } + + companion object { + private const val ADDRESS = "native-auth@mail.tm" + private const val PASSWORD = "shared-password" + } +} From c109bf8e3108456184b335716fd0064a06f7401d Mon Sep 17 00:00:00 2001 From: Disha Janardhan Date: Mon, 17 Aug 2026 02:57:11 -0500 Subject: [PATCH 2/2] fix: keep email provider password at test runtime Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 83947e30-5bc9-4ec7-a8ed-c03d4415da92 --- .../pull-request-validation/build-consumers.yml | 4 +++- common4j/build.gradle | 6 ------ .../common/java/nativeauth/BuildValues.java | 5 ----- .../nativeauth/api/TemporaryEmailService.kt | 9 +++++++-- .../nativeauth/api/TemporaryEmailServiceTest.kt | 14 ++++++++++++++ 5 files changed, 24 insertions(+), 14 deletions(-) diff --git a/azure-pipelines/pull-request-validation/build-consumers.yml b/azure-pipelines/pull-request-validation/build-consumers.yml index 126f909ad5..320a348a87 100644 --- a/azure-pipelines/pull-request-validation/build-consumers.yml +++ b/azure-pipelines/pull-request-validation/build-consumers.yml @@ -129,9 +129,11 @@ stages: - task: Gradle@3 displayName: Run msal Unit tests inputs: - tasks: msal:testLocalDebugUnitTest -Plabtest -PlabSecret=$(LabVaultAppCert) -ProbolectricSdkVersion=${{variables.robolectricSdkVersion}} -PmockApiUrl=$(MOCK_API_URL) -PnativeAuthConfigString=$(NATIVE_AUTH_CONFIG_STRING) -PemailProviderPassword=$(EMAIL_PROVIDER_PASSWORD) + tasks: msal:testLocalDebugUnitTest -Plabtest -PlabSecret=$(LabVaultAppCert) -ProbolectricSdkVersion=${{variables.robolectricSdkVersion}} -PmockApiUrl=$(MOCK_API_URL) -PnativeAuthConfigString=$(NATIVE_AUTH_CONFIG_STRING) jdkArchitecture: x64 jdkVersionOption: "1.17" + env: + EMAIL_PROVIDER_PASSWORD: $(EMAIL_PROVIDER_PASSWORD) # msalautomationapp - job: msalAutomationAppValidation displayName: MSAL Automation App diff --git a/common4j/build.gradle b/common4j/build.gradle index fd7d922867..eb586399ea 100644 --- a/common4j/build.gradle +++ b/common4j/build.gradle @@ -212,7 +212,6 @@ def useMockApiForNativeAuthParameter = false // will be false unless specified b def mockApiUrlParameter = "" // will be blank unless specified by developer def nativeAuthConfigFilePathParameter = "" // will be blank unless specified by developer, used to run e2e tests locally def nativeAuthConfigStringParameter = "" // will be blank unless specified by developer, used to run e2e tests in CI -def emailProviderPasswordParameter = "" // will be blank unless specified by developer def disableAcquireTokenSilentTimeoutParameter = false // will be false unless specified by developer def allowOneboxAuthorities = false // will be false unless specified by developer @@ -244,10 +243,6 @@ if (project.hasProperty("nativeAuthConfigString")) { nativeAuthConfigStringParameter = nativeAuthConfigString } -if (project.hasProperty("emailProviderPassword")) { - emailProviderPasswordParameter = emailProviderPassword -} - // By adding -PdisableAcquireTokenSilentTimeout in your dev environment, you will no longer subject to the ATS timeout, // and your life will be much happier during debugging. if (project.hasProperty("disableAcquireTokenSilentTimeout")) { @@ -267,7 +262,6 @@ sourceSets { buildConfigField("String", "MOCK_API_URL", "\"$mockApiUrlParameter\"") buildConfigField("String", "NATIVE_AUTH_CONFIG_FILE_PATH", "\"$nativeAuthConfigFilePathParameter\"") buildConfigField("String", "NATIVE_AUTH_CONFIG_STRING", "\"$nativeAuthConfigStringParameter\"") - buildConfigField("String", "EMAIL_PROVIDER_PASSWORD", "\"$emailProviderPasswordParameter\"") buildConfigField("boolean", "DISABLE_ACQUIRE_TOKEN_SILENT_TIMEOUT", "${disableAcquireTokenSilentTimeoutParameter}") buildConfigField("boolean", "ALLOW_ONEBOX_AUTHORITIES", "${allowOneboxAuthorities}") } diff --git a/common4j/src/main/com/microsoft/identity/common/java/nativeauth/BuildValues.java b/common4j/src/main/com/microsoft/identity/common/java/nativeauth/BuildValues.java index c238c267ed..e55b11fbd5 100644 --- a/common4j/src/main/com/microsoft/identity/common/java/nativeauth/BuildValues.java +++ b/common4j/src/main/com/microsoft/identity/common/java/nativeauth/BuildValues.java @@ -53,8 +53,6 @@ public static void setDC(String dc) { private static String NATIVE_AUTH_CONFIG_FILE_PATH = BuildConfig.NATIVE_AUTH_CONFIG_FILE_PATH; - private static String EMAIL_PROVIDER_PASSWORD = BuildConfig.EMAIL_PROVIDER_PASSWORD; - public static Boolean shouldUseMockApiForNativeAuth() { return USE_MOCK_API_FOR_NATIVE_AUTH_AUTHORITY; @@ -76,7 +74,4 @@ public static String getNativeAuthConfigFilePath() { return NATIVE_AUTH_CONFIG_FILE_PATH; } - public static String getEmailProviderPassword() { - return EMAIL_PROVIDER_PASSWORD; - } } diff --git a/testutils/src/main/java/com/microsoft/identity/internal/testutils/nativeauth/api/TemporaryEmailService.kt b/testutils/src/main/java/com/microsoft/identity/internal/testutils/nativeauth/api/TemporaryEmailService.kt index 673f457073..5fd089d408 100644 --- a/testutils/src/main/java/com/microsoft/identity/internal/testutils/nativeauth/api/TemporaryEmailService.kt +++ b/testutils/src/main/java/com/microsoft/identity/internal/testutils/nativeauth/api/TemporaryEmailService.kt @@ -26,7 +26,6 @@ package com.microsoft.identity.internal.testutils.nativeauth.api import com.google.gson.JsonParseException import com.google.gson.annotations.SerializedName import com.google.gson.reflect.TypeToken -import com.microsoft.identity.common.java.nativeauth.BuildValues import com.microsoft.identity.common.nativeauth.ApiConstants import com.microsoft.identity.internal.test.labapi.ApiClient import com.microsoft.identity.internal.test.labapi.ApiException @@ -41,6 +40,12 @@ import java.util.Locale import java.util.TimeZone import java.util.UUID +private const val EMAIL_PROVIDER_PASSWORD_ENVIRONMENT_VARIABLE = "EMAIL_PROVIDER_PASSWORD" + +internal fun resolveEmailProviderPassword(environment: Map): String { + return environment[EMAIL_PROVIDER_PASSWORD_ENVIRONMENT_VARIABLE].orEmpty() +} + /** * Mail.tm inbox helper for Native Auth end-to-end tests. */ @@ -59,7 +64,7 @@ class TemporaryEmailService internal constructor( pollingDelaysMs = DEFAULT_POLLING_DELAYS_MS ) - constructor() : this(BuildValues.getEmailProviderPassword()) + constructor() : this(resolveEmailProviderPassword(System.getenv())) private val inboxes = mutableMapOf() diff --git a/testutils/src/test/java/com/microsoft/identity/internal/testutils/nativeauth/api/TemporaryEmailServiceTest.kt b/testutils/src/test/java/com/microsoft/identity/internal/testutils/nativeauth/api/TemporaryEmailServiceTest.kt index 6610c6822a..1962386c66 100644 --- a/testutils/src/test/java/com/microsoft/identity/internal/testutils/nativeauth/api/TemporaryEmailServiceTest.kt +++ b/testutils/src/test/java/com/microsoft/identity/internal/testutils/nativeauth/api/TemporaryEmailServiceTest.kt @@ -39,6 +39,20 @@ import java.util.TimeZone class TemporaryEmailServiceTest { + @Test + fun resolveEmailProviderPassword_whenEnvironmentContainsPassword_returnsPassword() { + val password = resolveEmailProviderPassword( + mapOf("EMAIL_PROVIDER_PASSWORD" to PASSWORD) + ) + + assertEquals(PASSWORD, password) + } + + @Test + fun resolveEmailProviderPassword_whenEnvironmentDoesNotContainPassword_returnsEmptyString() { + assertEquals("", resolveEmailProviderPassword(emptyMap())) + } + @Test fun constructor_withoutPassword_remainsSourceCompatible() { val service = TemporaryEmailService()