diff --git a/CHANGELOG.md b/CHANGELOG.md index aec7db4..90237ab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,11 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/) and this project adheres to [Semantic Versioning](http://semver.org/). +## [0.6.0] - 2024-07-31 + +### Added +- SIM Swap API + ## [0.5.0] - 2024-07-25 ### Added diff --git a/CONTRIBUTORS.md b/CONTRIBUTORS.md index e2248e9..2ba8c3a 100644 --- a/CONTRIBUTORS.md +++ b/CONTRIBUTORS.md @@ -1,6 +1,6 @@ # Contributors -This is the vonage-java-sdk hall of fame! All contributors of source code, or +This is the vonage-kotlin-sdk hall of fame! All contributors of source code, or documentation, or tests are eligible to be added to this list. - Sina Madani ([@SMadani](https://github.com/SMadani)) \ No newline at end of file diff --git a/README.md b/README.md index 3f32d03..247165d 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,7 @@ You'll need to have [created a Vonage account](https://dashboard.nexmo.com/sign- - [Messages](https://developer.vonage.com/en/messages/overview) - [Verify](https://developer.vonage.com/en/verify/overview) - [Voice](https://developer.vonage.com/en/voice/voice-api/overview) +- [SIM Swap](https://developer.vonage.com/en/sim-swap/overview) - [Number Insight](https://developer.vonage.com/en/number-insight/overview) - [SMS](https://developer.vonage.com/en/messaging/sms/overview) - [Conversion](https://developer.vonage.com/en/messaging/conversion-api/overview) diff --git a/pom.xml b/pom.xml index 03f0719..14ebdf1 100644 --- a/pom.xml +++ b/pom.xml @@ -5,7 +5,7 @@ com.vonage server-sdk-kotlin - 0.5.0 + 0.6.0 Vonage Kotlin Server SDK Kotlin client for Vonage APIs diff --git a/src/main/kotlin/com/vonage/client/kt/SimSwap.kt b/src/main/kotlin/com/vonage/client/kt/SimSwap.kt new file mode 100644 index 0000000..fc5869e --- /dev/null +++ b/src/main/kotlin/com/vonage/client/kt/SimSwap.kt @@ -0,0 +1,27 @@ +/* + * Copyright 2024 Vonage + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.vonage.client.kt + +import com.vonage.client.camara.simswap.* + +class SimSwap(private val simSwapClient: SimSwapClient) { + + fun checkSimSwap(phoneNumber: String, maxAgeHours: Int = 240): Boolean = + simSwapClient.checkSimSwap(phoneNumber, maxAgeHours) + + fun retrieveSimSwapDate(phoneNumber: String) = + simSwapClient.retrieveSimSwapDate(phoneNumber) +} diff --git a/src/main/kotlin/com/vonage/client/kt/Vonage.kt b/src/main/kotlin/com/vonage/client/kt/Vonage.kt index a5f3447..872fdd7 100644 --- a/src/main/kotlin/com/vonage/client/kt/Vonage.kt +++ b/src/main/kotlin/com/vonage/client/kt/Vonage.kt @@ -28,6 +28,7 @@ class Vonage(init: VonageClient.Builder.() -> Unit) { val redact = Redact(vonageClient.redactClient) val verifyLegacy = VerifyLegacy(vonageClient.verifyClient) val numberInsight = NumberInsight(vonageClient.insightClient) + val simSwap = SimSwap(vonageClient.simSwapClient) } fun VonageClient.Builder.authFromEnv(): VonageClient.Builder { diff --git a/src/test/kotlin/com/vonage/client/kt/AbstractTest.kt b/src/test/kotlin/com/vonage/client/kt/AbstractTest.kt index 101a817..7f6c02e 100644 --- a/src/test/kotlin/com/vonage/client/kt/AbstractTest.kt +++ b/src/test/kotlin/com/vonage/client/kt/AbstractTest.kt @@ -37,12 +37,17 @@ import kotlin.test.assertEquals abstract class AbstractTest { protected val apiKey = "a1b2c3d4" protected val applicationId = "00000000-0000-4000-8000-000000000000" + protected val accessToken = "abc123456def" private val apiSecret = "1234567890abcdef" private val apiKeySecretEncoded = "YTFiMmMzZDQ6MTIzNDU2Nzg5MGFiY2RlZg==" private val privateKeyPath = "src/test/resources/com/vonage/client/kt/application_key" private val signatureSecretName = "sig" private val apiSecretName = "api_secret" private val apiKeyName = "api_key" + private val authHeaderName = "Authorization" + private val basicSecretEncodedHeader = "Basic $apiKeySecretEncoded" + private val jwtBearerPattern = "Bearer eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9(\\..+){2}" + private val accessTokenBearer = "Bearer $accessToken" protected val testUuidStr = "aaaaaaaa-bbbb-4ccc-8ddd-0123456789ab" protected val testUuid: UUID = UUID.fromString(testUuidStr) protected val toNumber = "447712345689" @@ -106,7 +111,7 @@ abstract class AbstractTest { } protected enum class AuthType { - JWT, API_KEY_SECRET_HEADER, API_KEY_SECRET_QUERY_PARAMS, API_KEY_SIGNATURE_SECRET + JWT, API_KEY_SECRET_HEADER, API_KEY_SECRET_QUERY_PARAMS, API_KEY_SIGNATURE_SECRET, ACCESS_TOKEN } private fun HttpMethod.toWireMockMethod(): Method = when (this) { @@ -128,11 +133,21 @@ abstract class AbstractTest { private fun Map.toJson(): String = ObjectMapper().writeValueAsString(this) protected fun mockPostQueryParams(expectedUrl: String, expectedRequestParams: Map, + authType: AuthType? = AuthType.API_KEY_SECRET_QUERY_PARAMS, status: Int = 200, expectedResponseParams: Map? = null) { val stub = post(urlPathEqualTo(expectedUrl)) - .withFormParam(apiKeyName, equalTo(apiKey)) - .withFormParam(apiSecretName, equalTo(apiSecret)) + when (authType) { + AuthType.API_KEY_SECRET_QUERY_PARAMS -> { + stub.withFormParam(apiKeyName, equalTo(apiKey)) + .withFormParam(apiSecretName, equalTo(apiSecret)) + } + AuthType.JWT -> stub.withHeader(authHeaderName, matching(jwtBearerPattern)) + AuthType.ACCESS_TOKEN -> stub.withHeader(authHeaderName, equalTo(accessTokenBearer)) + AuthType.API_KEY_SECRET_HEADER -> stub.withHeader(authHeaderName, equalTo(basicSecretEncodedHeader)) + AuthType.API_KEY_SIGNATURE_SECRET -> stub.withFormParam(apiKeyName, equalTo(apiKey)) + null -> Unit + } expectedRequestParams.forEach {(k, v) -> stub.withFormParam(k, equalTo(v.toString()))} @@ -162,13 +177,14 @@ abstract class AbstractTest { } if (authType != null) { - val authHeaderName = "Authorization" when (authType) { - AuthType.JWT -> headers contains authHeaderName like - "Bearer eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9(\\..+){2}" + AuthType.JWT -> headers contains authHeaderName like jwtBearerPattern + + AuthType.ACCESS_TOKEN -> + headers contains authHeaderName equalTo accessTokenBearer AuthType.API_KEY_SECRET_HEADER -> - headers contains authHeaderName equalTo "Basic $apiKeySecretEncoded" + headers contains authHeaderName equalTo basicSecretEncodedHeader AuthType.API_KEY_SECRET_QUERY_PARAMS -> { queryParams contains apiKeyName equalTo apiKey diff --git a/src/test/kotlin/com/vonage/client/kt/SimSwapTest.kt b/src/test/kotlin/com/vonage/client/kt/SimSwapTest.kt new file mode 100644 index 0000000..729d7ac --- /dev/null +++ b/src/test/kotlin/com/vonage/client/kt/SimSwapTest.kt @@ -0,0 +1,108 @@ +/* + * Copyright 2024 Vonage + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.vonage.client.kt + +import com.vonage.client.auth.camara.FraudPreventionDetectionScope +import kotlin.test.* + +class SimSwapTest : AbstractTest() { + private val simSwapClient = vonage.simSwap + private val baseSimSwapUrl = "/camara/sim-swap/v040" + private val checkSimSwapUrl = "$baseSimSwapUrl/check" + private val retrieveSimSwapDateUrl = "$baseSimSwapUrl/retrieve-date" + private val simSwapNumber = toNumber + private val phoneNumberMap = mapOf("phoneNumber" to simSwapNumber) + private val authReqId = "arid/0dadaeb4-7c79-4d39-b4b0-5a6cc08bf537" + + private fun mockBackendAuth(scope: FraudPreventionDetectionScope) { + mockPostQueryParams( + expectedUrl = "/oauth2/bc-authorize", + authType = AuthType.JWT, + expectedRequestParams = mapOf( + "login_hint" to "tel:+$simSwapNumber", + "scope" to "openid dpv:FraudPreventionAndDetection#$scope" + ), + expectedResponseParams = mapOf( + "auth_req_id" to authReqId, + "expires_in" to 120, + "interval" to 3 + ) + ) + mockPostQueryParams( + expectedUrl = "/oauth2/token", + authType = AuthType.JWT, + expectedRequestParams = mapOf( + "grant_type" to "urn:openid:params:grant-type:ciba", + "auth_req_id" to authReqId + ), + expectedResponseParams = mapOf( + "access_token" to accessToken, + "refresh_token" to "xyz789012ghi", + "token_type" to "Bearer", + "expires" to 3600 + ), + ) + } + + private fun assertCheckSimSwap(maxAge: Int = 240, invocation: SimSwap.() -> Boolean) { + mockBackendAuth(FraudPreventionDetectionScope.CHECK_SIM_SWAP) + for (result in listOf(true, false, null)) { + mockPost( + expectedUrl = checkSimSwapUrl, + authType = AuthType.ACCESS_TOKEN, + expectedRequestParams = phoneNumberMap + mapOf("maxAge" to maxAge), + expectedResponseParams = if (result != null) mapOf("swapped" to result) else mapOf() + ) + assertEquals(result ?: false, invocation.invoke(simSwapClient)) + } + } + + private fun assertRetrieveSimSwapDate(includeResponse: Boolean) { + mockBackendAuth(FraudPreventionDetectionScope.RETRIEVE_SIM_SWAP_DATE) + mockPost( + expectedUrl = retrieveSimSwapDateUrl, + authType = AuthType.ACCESS_TOKEN, + expectedRequestParams = phoneNumberMap, + expectedResponseParams = if (includeResponse) mapOf("latestSimChange" to timestampStr) else mapOf() + ) + assertEquals(if (includeResponse) timestamp else null, simSwapClient.retrieveSimSwapDate(simSwapNumber)) + } + + @Test + fun `check sim swap number only`() { + assertCheckSimSwap { + checkSimSwap(simSwapNumber) + } + } + + @Test + fun `check sim swap with maxAge`() { + val maxAge = 1200 + assertCheckSimSwap(maxAge) { + checkSimSwap(simSwapNumber, maxAge) + } + } + + @Test + fun `retrieve sim swap date success`() { + assertRetrieveSimSwapDate(true) + } + + @Test + fun `retrieve sim swap date unknown`() { + assertRetrieveSimSwapDate(false) + } +} \ No newline at end of file