diff --git a/Vonage.Test/SimSwap/Authenticate/E2ETest.cs b/Vonage.Test/SimSwap/Authenticate/E2ETest.cs index d580bac1..dce3e5ba 100644 --- a/Vonage.Test/SimSwap/Authenticate/E2ETest.cs +++ b/Vonage.Test/SimSwap/Authenticate/E2ETest.cs @@ -34,7 +34,7 @@ public async Task Authenticate() .WithBody(this.Serialization.GetResponseJson(nameof(SerializationTest.ShouldDeserializeAccessToken)))); await this.Helper.VonageClient.SimSwapClient .AuthenticateAsync(AuthenticateRequest.Parse("447700900000", - "scope=openid dpv:FraudPreventionAndDetection#check-sim-swap")) + "dpv:FraudPreventionAndDetection#check-sim-swap")) .Should() .BeSuccessAsync(new AuthenticateResponse("ABCDEFG")); } diff --git a/Vonage.Test/SimSwap/GetSwapDate/Data/ShouldDeserializeAccessToken-response.json b/Vonage.Test/SimSwap/GetSwapDate/Data/ShouldDeserializeAccessToken-response.json new file mode 100644 index 00000000..73ab58da --- /dev/null +++ b/Vonage.Test/SimSwap/GetSwapDate/Data/ShouldDeserializeAccessToken-response.json @@ -0,0 +1,5 @@ +{ + "access_token": "ABCDEFG", + "token_type": "Bearer", + "expires_in": 3600 +} \ No newline at end of file diff --git a/Vonage.Test/SimSwap/GetSwapDate/Data/ShouldDeserializeAuthorize-response.json b/Vonage.Test/SimSwap/GetSwapDate/Data/ShouldDeserializeAuthorize-response.json new file mode 100644 index 00000000..7b424919 --- /dev/null +++ b/Vonage.Test/SimSwap/GetSwapDate/Data/ShouldDeserializeAuthorize-response.json @@ -0,0 +1,5 @@ +{ + "auth_req_id": "123456789", + "expires_in": "120", + "interval": "2" +} \ No newline at end of file diff --git a/Vonage.Test/SimSwap/GetSwapDate/Data/ShouldDeserializeGetSwapDate-response.json b/Vonage.Test/SimSwap/GetSwapDate/Data/ShouldDeserializeGetSwapDate-response.json new file mode 100644 index 00000000..750080e4 --- /dev/null +++ b/Vonage.Test/SimSwap/GetSwapDate/Data/ShouldDeserializeGetSwapDate-response.json @@ -0,0 +1,3 @@ +{ + "latestSimChange": "2019-08-24T14:15:22Z" +} \ No newline at end of file diff --git a/Vonage.Test/SimSwap/GetSwapDate/Data/ShouldSerialize-request.json b/Vonage.Test/SimSwap/GetSwapDate/Data/ShouldSerialize-request.json new file mode 100644 index 00000000..c67cde88 --- /dev/null +++ b/Vonage.Test/SimSwap/GetSwapDate/Data/ShouldSerialize-request.json @@ -0,0 +1,3 @@ +{ + "phoneNumber": "346661113334" +} \ No newline at end of file diff --git a/Vonage.Test/SimSwap/GetSwapDate/E2ETest.cs b/Vonage.Test/SimSwap/GetSwapDate/E2ETest.cs new file mode 100644 index 00000000..214c39c8 --- /dev/null +++ b/Vonage.Test/SimSwap/GetSwapDate/E2ETest.cs @@ -0,0 +1,57 @@ +using System; +using System.Net; +using System.Threading.Tasks; +using Vonage.SimSwap.GetSwapDate; +using Vonage.Test.Common.Extensions; +using WireMock.ResponseBuilders; +using Xunit; + +namespace Vonage.Test.SimSwap.GetSwapDate; + +[Trait("Category", "E2E")] +public class E2ETest : E2EBase +{ + public E2ETest() : base(typeof(E2ETest).Namespace) + { + } + + [Fact] + public async Task GetSwapDateAsync() + { + this.SetupAuthorization(); + this.SetupToken(); + this.SetupSimSwap(nameof(SerializationTest.ShouldSerialize)); + await this.Helper.VonageClient.SimSwapClient + .GetSwapDateAsync(GetSwapDateRequest.Parse("346661113334")) + .Should() + .BeSuccessAsync(DateTimeOffset.Parse("2019-08-24T14:15:22Z")); + } + + private void SetupSimSwap(string expectedOutput) => + this.Helper.Server.Given(WireMock.RequestBuilders.Request.Create() + .WithPath("/camara/sim-swap/v040/retrieve-date") + .WithHeader("Authorization", "Bearer ABCDEFG") + .WithBody(this.Serialization.GetRequestJson(expectedOutput)) + .UsingPost()) + .RespondWith(Response.Create().WithStatusCode(HttpStatusCode.OK) + .WithBody(this.Serialization.GetResponseJson(nameof(SerializationTest.ShouldDeserializeGetSwapDate)))); + + private void SetupToken() => + this.Helper.Server.Given(WireMock.RequestBuilders.Request.Create() + .WithPath("/oauth2/token") + .WithHeader("Authorization", this.Helper.ExpectedAuthorizationHeaderValue) + .WithBody("auth_req_id=123456789&grant_type=urn:openid:params:grant-type:ciba") + .UsingPost()) + .RespondWith(Response.Create().WithStatusCode(HttpStatusCode.OK) + .WithBody(this.Serialization.GetResponseJson(nameof(SerializationTest.ShouldDeserializeAccessToken)))); + + private void SetupAuthorization() => + this.Helper.Server.Given(WireMock.RequestBuilders.Request.Create() + .WithPath("/oauth2/bc-authorize") + .WithHeader("Authorization", this.Helper.ExpectedAuthorizationHeaderValue) + .WithBody( + "login_hint=tel:%2B346661113334&scope=openid+dpv%3AFraudPreventionAndDetection%23retrieve-sim-swap-date") + .UsingPost()) + .RespondWith(Response.Create().WithStatusCode(HttpStatusCode.OK) + .WithBody(this.Serialization.GetResponseJson(nameof(SerializationTest.ShouldDeserializeAuthorize)))); +} \ No newline at end of file diff --git a/Vonage.Test/SimSwap/GetSwapDate/RequestTest.cs b/Vonage.Test/SimSwap/GetSwapDate/RequestTest.cs new file mode 100644 index 00000000..371b0526 --- /dev/null +++ b/Vonage.Test/SimSwap/GetSwapDate/RequestTest.cs @@ -0,0 +1,52 @@ +using Vonage.Common.Failures; +using Vonage.SimSwap.GetSwapDate; +using Vonage.Test.Common.Extensions; +using Xunit; + +namespace Vonage.Test.SimSwap.GetSwapDate; + +[Trait("Category", "Request")] +public class RequestTest +{ + [Theory] + [InlineData("")] + [InlineData(" ")] + [InlineData(null)] + public void Parse_ShouldReturnFailure_GivenNumberIsNullOrWhitespace(string value) => + GetSwapDateRequest.Parse(value).Should() + .BeFailure(ResultFailure.FromErrorMessage("Number cannot be null or whitespace.")); + + [Fact] + public void Parse_ShouldReturnFailure_GivenNumberContainsNonDigits() => + GetSwapDateRequest.Parse("1234567abc123").Should() + .BeFailure(ResultFailure.FromErrorMessage("Number can only contain digits.")); + + [Fact] + public void Parse_ShouldReturnFailure_GivenNumberLengthIsHigherThan7() => + GetSwapDateRequest.Parse("123456").Should() + .BeFailure(ResultFailure.FromErrorMessage("Number length cannot be lower than 7.")); + + [Fact] + public void Parse_ShouldReturnFailure_GivenNumberLengthIsLowerThan15() => + GetSwapDateRequest.Parse("1234567890123456").Should() + .BeFailure(ResultFailure.FromErrorMessage("Number length cannot be higher than 15.")); + + [Theory] + [InlineData("1234567", "1234567")] + [InlineData("123456789012345", "123456789012345")] + [InlineData("+1234567890", "1234567890")] + [InlineData("+123456789012345", "123456789012345")] + [InlineData("+++1234567890", "1234567890")] + public void Parse_ShouldReturnSuccess(string value, string expected) => + GetSwapDateRequest.Parse(value) + .Map(request => request.PhoneNumber.Number) + .Should() + .BeSuccess(expected); + + [Fact] + public void GetEndpointPath_ShouldReturnApiEndpoint() => + GetSwapDateRequest.Parse("123456789") + .Map(request => request.GetEndpointPath()) + .Should() + .BeSuccess("camara/sim-swap/v040/retrieve-date"); +} \ No newline at end of file diff --git a/Vonage.Test/SimSwap/GetSwapDate/SerializationTest.cs b/Vonage.Test/SimSwap/GetSwapDate/SerializationTest.cs new file mode 100644 index 00000000..9c90d87a --- /dev/null +++ b/Vonage.Test/SimSwap/GetSwapDate/SerializationTest.cs @@ -0,0 +1,49 @@ +using System; +using Vonage.Serialization; +using Vonage.SimSwap.Authenticate; +using Vonage.SimSwap.GetSwapDate; +using Vonage.Test.Common; +using Vonage.Test.Common.Extensions; +using Xunit; + +namespace Vonage.Test.SimSwap.GetSwapDate; + +[Trait("Category", "Serialization")] +public class SerializationTest +{ + private readonly SerializationTestHelper helper = new SerializationTestHelper( + typeof(SerializationTest).Namespace, + JsonSerializerBuilder.BuildWithSnakeCase()); + + [Fact] + public void ShouldDeserializeAuthorize() => this.helper.Serializer + .DeserializeObject(this.helper.GetResponseJson()) + .Should() + .BeSuccess(GetExpectedAuthorizeResponse()); + + [Fact] + public void ShouldDeserializeAccessToken() => this.helper.Serializer + .DeserializeObject(this.helper.GetResponseJson()) + .Should() + .BeSuccess(GetExpectedTokenResponse()); + + [Fact] + public void ShouldDeserializeGetSwapDate() => this.helper.Serializer + .DeserializeObject(this.helper.GetResponseJson()) + .Should() + .BeSuccess(GetExpectedResponse()); + + [Fact] + public void ShouldSerialize() => + GetSwapDateRequest.Parse("346661113334") + .GetStringContent() + .Should() + .BeSuccess(this.helper.GetRequestJson()); + + private static AuthorizeResponse GetExpectedAuthorizeResponse() => new AuthorizeResponse("123456789", "120", "2"); + + private static GetTokenResponse GetExpectedTokenResponse() => new GetTokenResponse("ABCDEFG", "Bearer", 3600); + + private static GetSwapDateResponse GetExpectedResponse() => + new GetSwapDateResponse(DateTimeOffset.Parse("2019-08-24T14:15:22Z")); +} \ No newline at end of file diff --git a/Vonage.Test/Vonage.Test.csproj b/Vonage.Test/Vonage.Test.csproj index 758bb54a..f7db4d6f 100644 --- a/Vonage.Test/Vonage.Test.csproj +++ b/Vonage.Test/Vonage.Test.csproj @@ -1122,6 +1122,18 @@ Always + + Always + + + Always + + + Always + + + Always + diff --git a/Vonage/SimSwap/Authenticate/AuthenticateRequest.cs b/Vonage/SimSwap/Authenticate/AuthenticateRequest.cs index ae682eed..9ff838b7 100644 --- a/Vonage/SimSwap/Authenticate/AuthenticateRequest.cs +++ b/Vonage/SimSwap/Authenticate/AuthenticateRequest.cs @@ -37,5 +37,5 @@ public static Result Parse(string number, string tokenScope private static Result VerifyScope(AuthenticateRequest request) => InputValidation.VerifyNotEmpty(request, request.Scope, nameof(request.Scope)); - internal AuthorizeRequest BuildAuthorizeRequest() => new AuthorizeRequest(this.PhoneNumber); + internal AuthorizeRequest BuildAuthorizeRequest() => new AuthorizeRequest(this.PhoneNumber, this.Scope); } \ No newline at end of file diff --git a/Vonage/SimSwap/Authenticate/AuthorizeRequest.cs b/Vonage/SimSwap/Authenticate/AuthorizeRequest.cs index 2037bc12..766acb1c 100644 --- a/Vonage/SimSwap/Authenticate/AuthorizeRequest.cs +++ b/Vonage/SimSwap/Authenticate/AuthorizeRequest.cs @@ -6,7 +6,7 @@ namespace Vonage.SimSwap.Authenticate; -internal record AuthorizeRequest(PhoneNumber Number) : IVonageRequest +internal record AuthorizeRequest(PhoneNumber Number, string Scope) : IVonageRequest { public string GetEndpointPath() => "oauth2/bc-authorize"; @@ -21,8 +21,8 @@ private string GetUrlEncoded() var builder = new StringBuilder(); builder.Append("login_hint=tel:"); builder.Append(WebUtility.UrlEncode(this.Number.NumberWithInternationalIndicator)); - builder.Append("&scope="); - builder.Append(WebUtility.UrlEncode("openid dpv:FraudPreventionAndDetection#check-sim-swap")); + builder.Append("&scope=openid"); + builder.Append(WebUtility.UrlEncode($" {this.Scope}")); return builder.ToString(); } diff --git a/Vonage/SimSwap/Check/CheckRequest.cs b/Vonage/SimSwap/Check/CheckRequest.cs index aee019c1..69ba72c6 100644 --- a/Vonage/SimSwap/Check/CheckRequest.cs +++ b/Vonage/SimSwap/Check/CheckRequest.cs @@ -44,14 +44,10 @@ private StringContent GetRequestContent() => [JsonPropertyName("maxAge")] public int Period { get; internal init; } - /// - /// The authorization scope for the token. - /// - [JsonIgnore] - public string Scope => "scope=openid dpv:FraudPreventionAndDetection#check-sim-swap"; + private static string Scope => "dpv:FraudPreventionAndDetection#check-sim-swap"; internal Result BuildAuthenticationRequest() => - AuthenticateRequest.Parse(this.PhoneNumber.NumberWithInternationalIndicator, this.Scope); + AuthenticateRequest.Parse(this.PhoneNumber.NumberWithInternationalIndicator, Scope); /// /// Initializes a builder. diff --git a/Vonage/SimSwap/GetSwapDate/GetSwapDateRequest.cs b/Vonage/SimSwap/GetSwapDate/GetSwapDateRequest.cs new file mode 100644 index 00000000..62f137e7 --- /dev/null +++ b/Vonage/SimSwap/GetSwapDate/GetSwapDateRequest.cs @@ -0,0 +1,53 @@ +using System.Net.Http; +using System.Text; +using System.Text.Json.Serialization; +using Vonage.Common; +using Vonage.Common.Client; +using Vonage.Common.Monads; +using Vonage.Common.Serialization; +using Vonage.Serialization; +using Vonage.SimSwap.Authenticate; + +namespace Vonage.SimSwap.GetSwapDate; + +/// +/// Represents a request to retrieve a SIM swap date. +/// +public readonly struct GetSwapDateRequest : IVonageRequest +{ + /// + public HttpRequestMessage BuildRequestMessage() => VonageRequestBuilder + .Initialize(HttpMethod.Post, this.GetEndpointPath()) + .WithContent(this.GetRequestContent()) + .Build(); + + private StringContent GetRequestContent() => + new StringContent(JsonSerializerBuilder.BuildWithSnakeCase().SerializeObject(this), Encoding.UTF8, + "application/json"); + + /// + public string GetEndpointPath() => "camara/sim-swap/v040/retrieve-date"; + + /// + /// Subscriber number in E.164 format (starting with country code). Optionally prefixed with '+'. + /// + [JsonConverter(typeof(PhoneNumberJsonConverter))] + [JsonPropertyName("phoneNumber")] + public PhoneNumber PhoneNumber { get; internal init; } + + private static string Scope => "dpv:FraudPreventionAndDetection#retrieve-sim-swap-date"; + + internal Result BuildAuthenticationRequest() => + AuthenticateRequest.Parse(this.PhoneNumber.NumberWithInternationalIndicator, Scope); + + /// + /// Parses the input into an GetSwapDateRequest. + /// + /// The phone number. + /// Success if the input matches all requirements. Failure otherwise. + public static Result Parse(string number) => + PhoneNumber.Parse(number).Map(phoneNumber => new GetSwapDateRequest + { + PhoneNumber = phoneNumber, + }); +} \ No newline at end of file diff --git a/Vonage/SimSwap/GetSwapDate/GetSwapDateResponse.cs b/Vonage/SimSwap/GetSwapDate/GetSwapDateResponse.cs new file mode 100644 index 00000000..c10a35a1 --- /dev/null +++ b/Vonage/SimSwap/GetSwapDate/GetSwapDateResponse.cs @@ -0,0 +1,8 @@ +using System; +using System.Text.Json.Serialization; + +namespace Vonage.SimSwap.GetSwapDate; + +internal record GetSwapDateResponse( + [property: JsonPropertyName("latestSimChange")] + DateTimeOffset LatestSimChange); \ No newline at end of file diff --git a/Vonage/SimSwap/ISimSwapClient.cs b/Vonage/SimSwap/ISimSwapClient.cs index a0323189..dac957e2 100644 --- a/Vonage/SimSwap/ISimSwapClient.cs +++ b/Vonage/SimSwap/ISimSwapClient.cs @@ -1,7 +1,9 @@ -using System.Threading.Tasks; +using System; +using System.Threading.Tasks; using Vonage.Common.Monads; using Vonage.SimSwap.Authenticate; using Vonage.SimSwap.Check; +using Vonage.SimSwap.GetSwapDate; namespace Vonage.SimSwap; @@ -23,4 +25,11 @@ public interface ISimSwapClient /// The request. /// Success or Failure. Task> CheckAsync(Result request); + + /// + /// Get timestamp of last MSISDN/IMSI pairing change for a mobile user account provided with MSIDN. + /// + /// The request. + /// Success or Failure. + Task> GetSwapDateAsync(Result request); } \ No newline at end of file diff --git a/Vonage/SimSwap/SimSwapClient.cs b/Vonage/SimSwap/SimSwapClient.cs index d0adb260..51ea8e81 100644 --- a/Vonage/SimSwap/SimSwapClient.cs +++ b/Vonage/SimSwap/SimSwapClient.cs @@ -1,10 +1,12 @@ -using System.Net.Http.Headers; +using System; +using System.Net.Http.Headers; using System.Threading.Tasks; using Vonage.Common.Client; using Vonage.Common.Monads; using Vonage.Serialization; using Vonage.SimSwap.Authenticate; using Vonage.SimSwap.Check; +using Vonage.SimSwap.GetSwapDate; namespace Vonage.SimSwap; @@ -31,6 +33,16 @@ await request.BindAsync(this.AuthenticateCheckRequest) .BindAsync(client => client.SendWithResponseAsync(request)) .Map(response => response.Swapped); + /// + public async Task> GetSwapDateAsync(Result request) + { + return await request.BindAsync(this.AuthenticateGetSwapDateRequest) + .Map(BuildAuthenticationHeader) + .Map(this.BuildClientWithAuthenticationHeader) + .BindAsync(client => client.SendWithResponseAsync(request)) + .Map(response => response.LatestSimChange); + } + private VonageHttpClient BuildClientWithAuthenticationHeader(AuthenticationHeaderValue header) => this.vonageClient.WithDifferentHeader(header); @@ -40,6 +52,9 @@ private static AuthenticationHeaderValue BuildAuthenticationHeader(AuthenticateR private Task> AuthenticateCheckRequest(CheckRequest request) => this.AuthenticateAsync(request.BuildAuthenticationRequest()); + private Task> AuthenticateGetSwapDateRequest(GetSwapDateRequest request) => + this.AuthenticateAsync(request.BuildAuthenticationRequest()); + private static AuthenticateResponse BuildAuthenticateResponse(GetTokenResponse response) => new AuthenticateResponse(response.AccessToken);