|
| 1 | +/* |
| 2 | + * Copyright 2022 Amazon.com, Inc. or its affiliates. All Rights Reserved. |
| 3 | + * |
| 4 | + * Licensed under the Apache License, Version 2.0 (the "License"). |
| 5 | + * You may not use this file except in compliance with the License. |
| 6 | + * A copy of the License is located at |
| 7 | + * |
| 8 | + * http://aws.amazon.com/apache2.0 |
| 9 | + * |
| 10 | + * or in the "license" file accompanying this file. This file is distributed |
| 11 | + * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either |
| 12 | + * express or implied. See the License for the specific language governing |
| 13 | + * permissions and limitations under the License. |
| 14 | + */ |
| 15 | + |
| 16 | +package com.amplifyframework.auth.cognito |
| 17 | + |
| 18 | +import aws.sdk.kotlin.services.cognitoidentity.CognitoIdentityClient |
| 19 | +import aws.sdk.kotlin.services.cognitoidentityprovider.CognitoIdentityProviderClient |
| 20 | +import com.amplifyframework.auth.cognito.featuretest.AuthAPI |
| 21 | +import com.amplifyframework.auth.cognito.featuretest.ExpectationShapes |
| 22 | +import com.amplifyframework.auth.cognito.featuretest.ExpectationShapes.Cognito |
| 23 | +import com.amplifyframework.auth.cognito.featuretest.ExpectationShapes.Cognito.CognitoIdentity |
| 24 | +import com.amplifyframework.auth.cognito.featuretest.ExpectationShapes.Cognito.CognitoIdentityProvider |
| 25 | +import com.amplifyframework.auth.cognito.featuretest.FeatureTestCase |
| 26 | +import com.amplifyframework.auth.cognito.featuretest.generators.toJsonElement |
| 27 | +import com.amplifyframework.auth.cognito.featuretest.serializers.deserializeToAuthState |
| 28 | +import com.amplifyframework.auth.cognito.helpers.AuthHelper |
| 29 | +import com.amplifyframework.auth.cognito.usecases.AuthUseCaseFactory |
| 30 | +import com.amplifyframework.logging.Logger |
| 31 | +import com.amplifyframework.statemachine.codegen.data.AmplifyCredential |
| 32 | +import com.amplifyframework.statemachine.codegen.data.CredentialType |
| 33 | +import com.amplifyframework.statemachine.codegen.data.DeviceMetadata |
| 34 | +import com.amplifyframework.statemachine.codegen.states.AuthState |
| 35 | +import featureTest.utilities.CognitoMockFactory |
| 36 | +import featureTest.utilities.CognitoRequestFactory |
| 37 | +import featureTest.utilities.TimeZoneRule |
| 38 | +import featureTest.utilities.apiExecutor |
| 39 | +import io.kotest.assertions.json.shouldEqualJson |
| 40 | +import io.mockk.clearAllMocks |
| 41 | +import io.mockk.coEvery |
| 42 | +import io.mockk.coVerify |
| 43 | +import io.mockk.every |
| 44 | +import io.mockk.mockk |
| 45 | +import io.mockk.mockkObject |
| 46 | +import io.mockk.mockkStatic |
| 47 | +import io.mockk.slot |
| 48 | +import java.io.File |
| 49 | +import java.util.TimeZone |
| 50 | +import java.util.concurrent.CountDownLatch |
| 51 | +import java.util.concurrent.TimeUnit |
| 52 | +import kotlin.reflect.full.callSuspend |
| 53 | +import kotlin.reflect.full.declaredFunctions |
| 54 | +import kotlin.test.assertEquals |
| 55 | +import kotlinx.coroutines.Dispatchers |
| 56 | +import kotlinx.coroutines.newSingleThreadContext |
| 57 | +import kotlinx.coroutines.test.resetMain |
| 58 | +import kotlinx.coroutines.test.setMain |
| 59 | +import kotlinx.serialization.json.Json |
| 60 | +import org.json.JSONObject |
| 61 | +import org.junit.After |
| 62 | +import org.junit.Before |
| 63 | +import org.junit.Rule |
| 64 | +import org.junit.Test |
| 65 | +import org.junit.runner.RunWith |
| 66 | +import org.junit.runners.Parameterized |
| 67 | + |
| 68 | +@RunWith(Parameterized::class) |
| 69 | +class AWSCognitoAuthPluginFeatureTest(private val testCase: FeatureTestCase) { |
| 70 | + |
| 71 | + @Rule @JvmField |
| 72 | + val timeZoneRule = TimeZoneRule(TimeZone.getTimeZone("US/Pacific")) |
| 73 | + |
| 74 | + lateinit var feature: FeatureTestCase |
| 75 | + private var apiExecutionResult: Any? = null |
| 76 | + |
| 77 | + private val sut = AWSCognitoAuthPlugin() |
| 78 | + private lateinit var authStateMachine: AuthStateMachine |
| 79 | + |
| 80 | + private val mockCognitoIPClient = mockk<CognitoIdentityProviderClient>(relaxed = true) |
| 81 | + private val mockCognitoIdClient = mockk<CognitoIdentityClient>() |
| 82 | + private val cognitoMockFactory = CognitoMockFactory(mockCognitoIPClient, mockCognitoIdClient) |
| 83 | + |
| 84 | + // Used to execute a test in situations where the platform Main dispatcher is not available |
| 85 | + // see [https://kotlinlang.org/api/kotlinx.coroutines/kotlinx-coroutines-test/] |
| 86 | + private val mainThreadSurrogate = newSingleThreadContext("Main thread") |
| 87 | + |
| 88 | + @After |
| 89 | + fun tearDown() { |
| 90 | + Dispatchers.resetMain() |
| 91 | + clearAllMocks() |
| 92 | + } |
| 93 | + |
| 94 | + companion object { |
| 95 | + private const val TEST_SUITE_BASE_PATH = "/feature-test/testsuites" |
| 96 | + private const val STATES_FILE_BASE_PATH = "/feature-test/states" |
| 97 | + private const val CONFIGURATION_FILES_BASE_PATH = "/feature-test/configuration" |
| 98 | + |
| 99 | + private val apisToSkip: List<AuthAPI> = listOf() |
| 100 | + |
| 101 | + @JvmStatic |
| 102 | + @Parameterized.Parameters(name = "{0}") |
| 103 | + fun data(): Collection<FeatureTestCase> { |
| 104 | + val resourceDir = File(this::class.java.getResource(TEST_SUITE_BASE_PATH)?.file!!) |
| 105 | + assert(resourceDir.isDirectory) |
| 106 | + |
| 107 | + return resourceDir.walkTopDown() |
| 108 | + .filterNot { it.isDirectory }.map { |
| 109 | + it.toRelativeString(resourceDir) |
| 110 | + }.map { |
| 111 | + readTestFeature(it) |
| 112 | + }.toList().filterNot { |
| 113 | + it.api.name in apisToSkip |
| 114 | + } |
| 115 | + } |
| 116 | + |
| 117 | + private fun readTestFeature(fileName: String): FeatureTestCase { |
| 118 | + val testCaseFile = this::class.java.getResource("$TEST_SUITE_BASE_PATH/$fileName") |
| 119 | + return Json.decodeFromString(File(testCaseFile!!.toURI()).readText()) |
| 120 | + } |
| 121 | + } |
| 122 | + |
| 123 | + @Before |
| 124 | + fun setUp() { |
| 125 | + // set timezone to be same as generated json from JsonGenerator |
| 126 | + Dispatchers.setMain(mainThreadSurrogate) |
| 127 | + feature = testCase |
| 128 | + readConfiguration(feature.preConditions.`amplify-configuration`).let { |
| 129 | + sut.realPlugin = it.first |
| 130 | + sut.useCaseFactory = it.second |
| 131 | + } |
| 132 | + } |
| 133 | + |
| 134 | + @Test |
| 135 | + fun api_feature_test() { |
| 136 | + // GIVEN |
| 137 | + mockAndroidAPIs() |
| 138 | + feature.preConditions.mockedResponses.forEach(cognitoMockFactory::mock) |
| 139 | + |
| 140 | + // WHEN |
| 141 | + apiExecutionResult = apiExecutor(sut, feature.api) |
| 142 | + |
| 143 | + // THEN |
| 144 | + feature.validations.forEach(this::verify) |
| 145 | + } |
| 146 | + |
| 147 | + /** |
| 148 | + * Mock Android APIs as per need basis. |
| 149 | + * This is cheaper than using Robolectric. |
| 150 | + */ |
| 151 | + private fun mockAndroidAPIs() { |
| 152 | + mockkObject(AuthHelper) |
| 153 | + coEvery { AuthHelper.getSecretHash(any(), any(), any()) } returns "a hash" |
| 154 | + } |
| 155 | + |
| 156 | + private fun readConfiguration(configuration: String): Pair<RealAWSCognitoAuthPlugin, AuthUseCaseFactory> { |
| 157 | + val configFileUrl = this::class.java.getResource("$CONFIGURATION_FILES_BASE_PATH/$configuration") |
| 158 | + val configJSONObject = |
| 159 | + JSONObject(File(configFileUrl!!.file).readText()) |
| 160 | + .getJSONObject("auth") |
| 161 | + .getJSONObject("plugins") |
| 162 | + .getJSONObject("awsCognitoAuthPlugin") |
| 163 | + val authConfiguration = AuthConfiguration.fromJson(configJSONObject) |
| 164 | + |
| 165 | + val authService = mockk<AWSCognitoAuthService> { |
| 166 | + every { cognitoIdentityProviderClient } returns mockCognitoIPClient |
| 167 | + every { cognitoIdentityClient } returns mockCognitoIdClient |
| 168 | + } |
| 169 | + |
| 170 | + /** |
| 171 | + * Always consider amplify credential is valid. This will need to be mocked otherwise |
| 172 | + * when we test the expiration based test cases. |
| 173 | + */ |
| 174 | + mockkStatic("com.amplifyframework.auth.cognito.AWSCognitoAuthSessionKt") |
| 175 | + every { any<AmplifyCredential>().isValid() } returns true |
| 176 | + |
| 177 | + val credentialStoreClient = mockk<CredentialStoreClient>(relaxed = true) |
| 178 | + coEvery { credentialStoreClient.loadCredentials(capture(slot<CredentialType.Device>())) } coAnswers { |
| 179 | + AmplifyCredential.DeviceData(DeviceMetadata.Empty) |
| 180 | + } |
| 181 | + |
| 182 | + val logger = mockk<Logger>(relaxed = true) |
| 183 | + |
| 184 | + val authEnvironment = AuthEnvironment( |
| 185 | + mockk(), |
| 186 | + authConfiguration, |
| 187 | + authService, |
| 188 | + credentialStoreClient, |
| 189 | + null, |
| 190 | + null, |
| 191 | + logger |
| 192 | + ) |
| 193 | + |
| 194 | + authStateMachine = AuthStateMachine(authEnvironment, getState(feature.preConditions.state)) |
| 195 | + |
| 196 | + val realPlugin = RealAWSCognitoAuthPlugin(authConfiguration, authEnvironment, authStateMachine, logger) |
| 197 | + return Pair( |
| 198 | + RealAWSCognitoAuthPlugin(authConfiguration, authEnvironment, authStateMachine, logger), |
| 199 | + AuthUseCaseFactory(realPlugin, authEnvironment, authStateMachine) |
| 200 | + ) |
| 201 | + } |
| 202 | + |
| 203 | + private fun verify(validation: ExpectationShapes) { |
| 204 | + when (validation) { |
| 205 | + is Cognito -> verifyCognito(validation) |
| 206 | + |
| 207 | + is ExpectationShapes.Amplify -> { |
| 208 | + val expected = validation.response.toString() |
| 209 | + val actual = apiExecutionResult.toJsonElement().toString() |
| 210 | + actual shouldEqualJson expected |
| 211 | + } |
| 212 | + is ExpectationShapes.State -> { |
| 213 | + val getStateLatch = CountDownLatch(1) |
| 214 | + var authState: AuthState? = null |
| 215 | + authStateMachine.getCurrentState { |
| 216 | + authState = it |
| 217 | + getStateLatch.countDown() |
| 218 | + } |
| 219 | + getStateLatch.await(10, TimeUnit.SECONDS) |
| 220 | + assertEquals(getState(validation.expectedState), authState) |
| 221 | + } |
| 222 | + } |
| 223 | + } |
| 224 | + |
| 225 | + private fun getState(state: String): AuthState { |
| 226 | + val stateFileUrl = this::class.java.getResource("$STATES_FILE_BASE_PATH/$state") |
| 227 | + return File(stateFileUrl!!.file).readText().deserializeToAuthState() |
| 228 | + } |
| 229 | + |
| 230 | + private fun verifyCognito(validation: Cognito) { |
| 231 | + val expectedRequest = CognitoRequestFactory.getExpectedRequestFor(validation) |
| 232 | + |
| 233 | + coVerify { |
| 234 | + when (validation) { |
| 235 | + is CognitoIdentity -> mockCognitoIdClient to mockCognitoIdClient::class |
| 236 | + is CognitoIdentityProvider -> mockCognitoIPClient to mockCognitoIPClient::class |
| 237 | + }.apply { |
| 238 | + second.declaredFunctions.first { |
| 239 | + it.name == validation.apiName |
| 240 | + }.callSuspend(first, expectedRequest) |
| 241 | + } |
| 242 | + } |
| 243 | + } |
| 244 | +} |
0 commit comments