From bd2455c8945eb1ddcbf099190a71bdebc7e424e0 Mon Sep 17 00:00:00 2001 From: Jakob Roar Bentzon <34889891+jrbentzon@users.noreply.github.com> Date: Wed, 7 Feb 2024 13:32:53 +0100 Subject: [PATCH] Add Boxer Client (#35) --- .github/workflows/release.yaml | 2 +- .../SnD.ApiClient.Azure.csproj | 2 +- .../Boxer/Base/IBoxerClaimsClient.cs | 65 +++++++++++++ .../Boxer/Base/IJwtTokenExchangeProvider.cs | 2 +- src/SnD.ApiClient/Boxer/BoxerClaimsClient.cs | 93 +++++++++++++++++++ .../Boxer/Exceptions/UserNotFoundException.cs | 17 ++++ .../Extensions/BoxerJwtClaimExtensions.cs | 19 ++++ .../Extensions/StringHttpMethodExtensions.cs | 32 +++++++ .../Boxer/Models/BoxerClaimsApiPatchBody.cs | 33 +++++++ .../Boxer/Models/BoxerJwtClaim.cs | 48 ++++++++++ .../Boxer/Models/GetUserClaimsResponse.cs | 18 ++++ .../Config/BoxerClaimsClientOptions.cs | 9 ++ .../Extensions/ServiceCollectionExtensions.cs | 10 +- src/SnD.ApiClient/SnD.ApiClient.csproj | 2 +- .../Boxer/ApiSamples/GetUserSample.json | 13 +++ .../Boxer/BoxerClaimsClientTests.cs | 65 +++++++++++++ ...HttpHendler.cs => BoxerMockHttpHandler.cs} | 0 ...tpHendler.cs => CrystalMockHttpHandler.cs} | 0 .../SnD.ApiClient.Tests.csproj | 7 ++ 19 files changed, 432 insertions(+), 5 deletions(-) create mode 100644 src/SnD.ApiClient/Boxer/Base/IBoxerClaimsClient.cs create mode 100644 src/SnD.ApiClient/Boxer/BoxerClaimsClient.cs create mode 100644 src/SnD.ApiClient/Boxer/Exceptions/UserNotFoundException.cs create mode 100644 src/SnD.ApiClient/Boxer/Extensions/BoxerJwtClaimExtensions.cs create mode 100644 src/SnD.ApiClient/Boxer/Extensions/StringHttpMethodExtensions.cs create mode 100644 src/SnD.ApiClient/Boxer/Models/BoxerClaimsApiPatchBody.cs create mode 100644 src/SnD.ApiClient/Boxer/Models/BoxerJwtClaim.cs create mode 100644 src/SnD.ApiClient/Boxer/Models/GetUserClaimsResponse.cs create mode 100644 src/SnD.ApiClient/Config/BoxerClaimsClientOptions.cs create mode 100644 test/SnD.ApiClient.Tests/Boxer/ApiSamples/GetUserSample.json create mode 100644 test/SnD.ApiClient.Tests/Boxer/BoxerClaimsClientTests.cs rename test/SnD.ApiClient.Tests/{BoxerMockHttpHendler.cs => BoxerMockHttpHandler.cs} (100%) rename test/SnD.ApiClient.Tests/{CrystalMockHttpHendler.cs => CrystalMockHttpHandler.cs} (100%) diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 8242e75..b70e9aa 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -16,4 +16,4 @@ jobs: uses: SneaksAndData/github-actions/semver_release@v0.0.17 with: major_v: 0 - minor_v: 0 + minor_v: 1 diff --git a/src/SnD.ApiClient.Azure/SnD.ApiClient.Azure.csproj b/src/SnD.ApiClient.Azure/SnD.ApiClient.Azure.csproj index 704f4ac..40d82a5 100644 --- a/src/SnD.ApiClient.Azure/SnD.ApiClient.Azure.csproj +++ b/src/SnD.ApiClient.Azure/SnD.ApiClient.Azure.csproj @@ -1,7 +1,7 @@ - netstandard2.0 + net6.0 latest enable latest diff --git a/src/SnD.ApiClient/Boxer/Base/IBoxerClaimsClient.cs b/src/SnD.ApiClient/Boxer/Base/IBoxerClaimsClient.cs new file mode 100644 index 0000000..2db2dce --- /dev/null +++ b/src/SnD.ApiClient/Boxer/Base/IBoxerClaimsClient.cs @@ -0,0 +1,65 @@ +using SnD.ApiClient.Boxer.Exceptions; +using SnD.ApiClient.Boxer.Models; + +namespace SnD.ApiClient.Boxer.Base; + +public interface IBoxerClaimsClient +{ + /// + /// Create a jwt-user registration in Boxer for a given user id and provider + /// If user already exists, the method not do anything and return true + /// + /// + /// + /// + /// true if success or if user already exists + /// Throws if the request to Boxer fails + public Task CreateUserAsync(string userId, string provider, CancellationToken cancellationToken); + + /// + /// Deletes a user from Boxer by user id and provider (jwt-user registration) and all its claims. + /// + /// + /// + /// + /// + /// Throws if the user is not found under that identity provider + /// Throws if the request to Boxer fails + public Task DisassociateUserAsync(string userId, string provider, CancellationToken cancellationToken); + + /// + /// Get claims by user id and provider + /// + /// User principal name (UPN) in Boxer + /// Identity provider (IDP) in Boxer + /// Cancellation token + /// Enumerator of object for the user + /// Throws if the user is not found under that identity provider + /// Throws if the request to Boxer fails + public Task> GetUserClaimsAsync(string userId, string provider, CancellationToken cancellationToken); + + /// + /// Set (Update/Edit) claims for user id and provider + /// + /// User principal name (UPN) in Boxer + /// Identity provider (IDP) in Boxer + /// Claims to set in the form of an enumerator of type + /// Cancellation token + /// True if the operation was successful, false otherwise + /// Throws if the user is not found under that identity provider + /// Throws if the request to Boxer fails + public Task PatchUserClaimsAsync(string userId, string provider, IEnumerable claims, CancellationToken cancellationToken); + + /// + /// Delete claims for user id and provider + /// Note: The method will match claims by user, identity provider and path. It will not match by api methods. + /// + /// User principal name (UPN) in Boxer + /// Identity provider (IDP) in Boxer + /// Claims to delete in the form of an enumerator of type + /// Cancellation token + /// true on success + /// Throws if the user is not found under that identity provider + /// Throws if the request to Boxer fails + public Task DeleteUserClaimsAsync(string userId, string provider, IEnumerable claims, CancellationToken cancellationToken); +} diff --git a/src/SnD.ApiClient/Boxer/Base/IJwtTokenExchangeProvider.cs b/src/SnD.ApiClient/Boxer/Base/IJwtTokenExchangeProvider.cs index 5ef64d2..bdeb7cd 100644 --- a/src/SnD.ApiClient/Boxer/Base/IJwtTokenExchangeProvider.cs +++ b/src/SnD.ApiClient/Boxer/Base/IJwtTokenExchangeProvider.cs @@ -10,4 +10,4 @@ public interface IJwtTokenExchangeProvider /// Boxer JWT value /// Raised on unsuccessful http request public Task GetTokenAsync(bool refresh, CancellationToken cancellationToken); -} \ No newline at end of file +} diff --git a/src/SnD.ApiClient/Boxer/BoxerClaimsClient.cs b/src/SnD.ApiClient/Boxer/BoxerClaimsClient.cs new file mode 100644 index 0000000..26d93d1 --- /dev/null +++ b/src/SnD.ApiClient/Boxer/BoxerClaimsClient.cs @@ -0,0 +1,93 @@ +using System.Net; +using System.Text; +using System.Text.Json; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using SnD.ApiClient.Base; +using SnD.ApiClient.Boxer.Base; +using SnD.ApiClient.Boxer.Exceptions; +using SnD.ApiClient.Boxer.Extensions; +using SnD.ApiClient.Boxer.Models; +using SnD.ApiClient.Config; + +namespace SnD.ApiClient.Boxer; + +public class BoxerClaimsClient : SndApiClient, IBoxerClaimsClient +{ + private readonly Uri claimsUri; + + public BoxerClaimsClient + (IOptions boxerClientOptions, HttpClient httpClient, + IJwtTokenExchangeProvider boxerConnector, ILogger logger) : base(httpClient, boxerConnector, + logger) + { + claimsUri = new Uri(boxerClientOptions.Value.BaseUri + ?? throw new ArgumentNullException(nameof(BoxerClaimsClientOptions.BaseUri))); + } + + public async Task CreateUserAsync(string userId, string provider, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + var requestUri = new Uri(claimsUri, new Uri($"claim/{provider}/{userId}", UriKind.Relative)); + var request = new HttpRequestMessage(HttpMethod.Post, requestUri); + request.Content = new StringContent("{}", Encoding.UTF8, "application/json"); + var response = await SendAuthenticatedRequestAsync(request, cancellationToken); + return response.EnsureSuccessStatusCode().IsSuccessStatusCode; + } + + public async Task DisassociateUserAsync(string userId, string provider, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + var requestUri = new Uri(claimsUri, new Uri($"claim/{provider}/{userId}", UriKind.Relative)); + var request = new HttpRequestMessage(HttpMethod.Delete, requestUri); + request.Content = new StringContent("{}", Encoding.UTF8, "application/json"); + var response = await SendAuthenticatedRequestAsync(request, cancellationToken); + return response.StatusCode switch + { + HttpStatusCode.NotFound => throw new UserNotFoundException(), + _ => response.EnsureSuccessStatusCode().IsSuccessStatusCode + }; + } + + public async Task> GetUserClaimsAsync(string userId, string provider, + CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + var requestUri = new Uri(claimsUri, new Uri($"claim/{provider}/{userId}", UriKind.Relative)); + var request = new HttpRequestMessage(HttpMethod.Get, requestUri); + var response = await SendAuthenticatedRequestAsync(request, cancellationToken); + return await response.ExtractClaimsAsync(); + } + + public async Task PatchUserClaimsAsync(string userId, string provider, IEnumerable claims, + CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + var requestUri = new Uri(claimsUri, new Uri($"claim/{provider}/{userId}", UriKind.Relative)); + var requestBody = BoxerClaimsApiPatchBody.CreateInsertOperation(claims); + var request = new HttpRequestMessage(HttpMethod.Patch, requestUri); + request.Content = new StringContent(JsonSerializer.Serialize(requestBody), Encoding.UTF8, "application/json"); + var response = await SendAuthenticatedRequestAsync(request, cancellationToken); + return response.StatusCode switch + { + HttpStatusCode.NotFound => throw new UserNotFoundException(), + _ => response.EnsureSuccessStatusCode().IsSuccessStatusCode + }; + } + + public async Task DeleteUserClaimsAsync(string userId, string provider, IEnumerable claims, + CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + var requestUri = new Uri(claimsUri, new Uri($"claim/{provider}/{userId}", UriKind.Relative)); + var requestBody = BoxerClaimsApiPatchBody.CreateDeleteOperation(claims); + var request = new HttpRequestMessage(HttpMethod.Patch, requestUri); + request.Content = new StringContent(JsonSerializer.Serialize(requestBody), Encoding.UTF8, "application/json"); + var response = await SendAuthenticatedRequestAsync(request, cancellationToken); + return response.StatusCode switch + { + HttpStatusCode.NotFound => throw new UserNotFoundException(), + _ => response.EnsureSuccessStatusCode().IsSuccessStatusCode + }; + } +} diff --git a/src/SnD.ApiClient/Boxer/Exceptions/UserNotFoundException.cs b/src/SnD.ApiClient/Boxer/Exceptions/UserNotFoundException.cs new file mode 100644 index 0000000..3e86ee6 --- /dev/null +++ b/src/SnD.ApiClient/Boxer/Exceptions/UserNotFoundException.cs @@ -0,0 +1,17 @@ +using System.Diagnostics.CodeAnalysis; + +namespace SnD.ApiClient.Boxer.Exceptions; + +/// +/// Exception thrown when a user is not found in Boxer, e.g., when trying to add claims to a user that does not exist +/// +[ExcludeFromCodeCoverage] +public class UserNotFoundException : Exception +{ + /// + /// Create a new instance of + /// + public UserNotFoundException() : base("Requested user not found in Boxer") + { + } +} diff --git a/src/SnD.ApiClient/Boxer/Extensions/BoxerJwtClaimExtensions.cs b/src/SnD.ApiClient/Boxer/Extensions/BoxerJwtClaimExtensions.cs new file mode 100644 index 0000000..33be66d --- /dev/null +++ b/src/SnD.ApiClient/Boxer/Extensions/BoxerJwtClaimExtensions.cs @@ -0,0 +1,19 @@ +using System.Net; +using System.Text.Json; +using SnD.ApiClient.Boxer.Exceptions; +using SnD.ApiClient.Boxer.Models; + +namespace SnD.ApiClient.Boxer.Extensions; + +public static class BoxerJwtClaimExtensions +{ + public static async Task> ExtractClaimsAsync(this HttpResponseMessage response) + { + return response.StatusCode switch + { + HttpStatusCode.NotFound => throw new UserNotFoundException(), + _ => JsonSerializer.Deserialize(await response.EnsureSuccessStatusCode().Content + .ReadAsStringAsync()).Claims.Select(c => new BoxerJwtClaim(c.First().Key, c.First().Value)) + }; + } +} diff --git a/src/SnD.ApiClient/Boxer/Extensions/StringHttpMethodExtensions.cs b/src/SnD.ApiClient/Boxer/Extensions/StringHttpMethodExtensions.cs new file mode 100644 index 0000000..8481c57 --- /dev/null +++ b/src/SnD.ApiClient/Boxer/Extensions/StringHttpMethodExtensions.cs @@ -0,0 +1,32 @@ +using System.Collections.Immutable; +using System.Text.RegularExpressions; + +namespace SnD.ApiClient.Boxer.Extensions; + +public static class StringHttpMethodExtensions +{ + /// + /// Convert a string to a collection of HttpMethod that matches (by regex) the string + /// + /// + /// + public static IEnumerable ToBoxerHttpMethods(this string httpMethods) + { + if (string.IsNullOrWhiteSpace(httpMethods)) + return Enumerable.Empty(); + var regex = new Regex(httpMethods); + return new[] { HttpMethod.Get, HttpMethod.Post, HttpMethod.Put, HttpMethod.Delete, HttpMethod.Patch } + .Where(m => regex.IsMatch(m.Method)); + } + + /// + /// Convert a collection of HttpMethod to a regex string that matches the collection + /// + /// + /// + public static string ToRegexString(this IEnumerable httpMethods) + { + var methods = httpMethods.Select(m=>m.Method).ToImmutableSortedSet(); + return methods.IsEmpty ? string.Empty : $"^({string.Join("|", methods)})$"; + } +} diff --git a/src/SnD.ApiClient/Boxer/Models/BoxerClaimsApiPatchBody.cs b/src/SnD.ApiClient/Boxer/Models/BoxerClaimsApiPatchBody.cs new file mode 100644 index 0000000..41ffdaa --- /dev/null +++ b/src/SnD.ApiClient/Boxer/Models/BoxerClaimsApiPatchBody.cs @@ -0,0 +1,33 @@ +using System.Diagnostics.CodeAnalysis; +using System.Text.Json.Serialization; + + +namespace SnD.ApiClient.Boxer.Models; + +[ExcludeFromCodeCoverage] +internal class BoxerClaimsApiPatchBody +{ + private BoxerClaimsApiPatchBody() + { + } + + public static BoxerClaimsApiPatchBody CreateInsertOperation(IEnumerable claims) => + new() + { + Operation = "Insert", + Claims = claims.ToDictionary(c => c.Type, c => c.Value) + }; + + public static BoxerClaimsApiPatchBody CreateDeleteOperation(IEnumerable claims) => + new() + { + Operation = "Delete", + Claims = claims.ToDictionary(c => c.Type, c => c.Value) + }; + + [JsonPropertyName("operation")] + public string Operation { get; private set; } + + [JsonPropertyName("claims")] + public Dictionary Claims { get; private set; } = new(); +} diff --git a/src/SnD.ApiClient/Boxer/Models/BoxerJwtClaim.cs b/src/SnD.ApiClient/Boxer/Models/BoxerJwtClaim.cs new file mode 100644 index 0000000..a3170ae --- /dev/null +++ b/src/SnD.ApiClient/Boxer/Models/BoxerJwtClaim.cs @@ -0,0 +1,48 @@ +using System.Diagnostics.CodeAnalysis; +using System.Security.Claims; +using System.Text.Json; +using SnD.ApiClient.Boxer.Extensions; + +namespace SnD.ApiClient.Boxer.Models; + +[ExcludeFromCodeCoverage] +public class BoxerJwtClaim : Claim +{ + internal BoxerJwtClaim(string type, string value) : base(type, value) + { + ApiMethods = value.ToBoxerHttpMethods().ToHashSet(); + Path = type; + } + + /// + /// Static method to convert a Boxer API Claims response to a collection of BoxerJwtClaim objects + /// + /// + /// + public static IEnumerable FromBoxerClaimsApiResponse(string response) + { + return JsonSerializer.Deserialize(response) + .Claims.Select(c => new BoxerJwtClaim(c.First().Key, c.First().Value)); + } + + /// + /// Static constructor to create a BoxerJwtClaim from a path and a collection of ApiMethodElement + /// + /// + /// + /// + public static BoxerJwtClaim Create(string path, HashSet apiMethods) + { + return new BoxerJwtClaim(path, apiMethods.ToRegexString()); + } + + /// + /// API path + /// + public readonly string Path; + + /// + /// API methods + /// + public readonly HashSet ApiMethods; +} diff --git a/src/SnD.ApiClient/Boxer/Models/GetUserClaimsResponse.cs b/src/SnD.ApiClient/Boxer/Models/GetUserClaimsResponse.cs new file mode 100644 index 0000000..80ab526 --- /dev/null +++ b/src/SnD.ApiClient/Boxer/Models/GetUserClaimsResponse.cs @@ -0,0 +1,18 @@ +using System.Text.Json.Serialization; + +namespace SnD.ApiClient.Boxer.Models; + +internal class GetUserClaimsResponse +{ + [JsonPropertyName("identityProvider")] + public string IdentityProvider { get; set; } + + [JsonPropertyName("userId")] + public string UserId { get; set; } + + [JsonPropertyName("claims")] + public List> Claims { get; set; } + + [JsonPropertyName("billingId")] + public object BillingId { get; set; } +} diff --git a/src/SnD.ApiClient/Config/BoxerClaimsClientOptions.cs b/src/SnD.ApiClient/Config/BoxerClaimsClientOptions.cs new file mode 100644 index 0000000..f097300 --- /dev/null +++ b/src/SnD.ApiClient/Config/BoxerClaimsClientOptions.cs @@ -0,0 +1,9 @@ +namespace SnD.ApiClient.Config; + +public class BoxerClaimsClientOptions +{ + /// + /// Base URI of the Boxer Claims API instance + /// + public string BaseUri { get; set; } +} diff --git a/src/SnD.ApiClient/Extensions/ServiceCollectionExtensions.cs b/src/SnD.ApiClient/Extensions/ServiceCollectionExtensions.cs index 2c897d0..e20e6ad 100644 --- a/src/SnD.ApiClient/Extensions/ServiceCollectionExtensions.cs +++ b/src/SnD.ApiClient/Extensions/ServiceCollectionExtensions.cs @@ -53,4 +53,12 @@ public static IServiceCollection AddCrystalClient(this IServiceCollection servic { return services.AddSingleton(); } -} \ No newline at end of file + + /// + /// Add Boxer Claims Client to DI + /// + public static IServiceCollection AddBoxerClaimsClient(this IServiceCollection services) + { + return services.AddSingleton(); + } +} diff --git a/src/SnD.ApiClient/SnD.ApiClient.csproj b/src/SnD.ApiClient/SnD.ApiClient.csproj index 3f6e453..80decae 100644 --- a/src/SnD.ApiClient/SnD.ApiClient.csproj +++ b/src/SnD.ApiClient/SnD.ApiClient.csproj @@ -1,7 +1,7 @@ - netstandard2.0 + net6.0 enable latest SnD.ApiClient diff --git a/test/SnD.ApiClient.Tests/Boxer/ApiSamples/GetUserSample.json b/test/SnD.ApiClient.Tests/Boxer/ApiSamples/GetUserSample.json new file mode 100644 index 0000000..d8b63ab --- /dev/null +++ b/test/SnD.ApiClient.Tests/Boxer/ApiSamples/GetUserSample.json @@ -0,0 +1,13 @@ +{ + "identityProvider": "idp_123", + "userId": "user_123", + "claims": [ + { + "myapi1.com/.*": ".*" + }, + { + "myapi2.com/path/.*": ".*" + } + ], + "billingId": null +} diff --git a/test/SnD.ApiClient.Tests/Boxer/BoxerClaimsClientTests.cs b/test/SnD.ApiClient.Tests/Boxer/BoxerClaimsClientTests.cs new file mode 100644 index 0000000..232935e --- /dev/null +++ b/test/SnD.ApiClient.Tests/Boxer/BoxerClaimsClientTests.cs @@ -0,0 +1,65 @@ +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Net.Http; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Moq; +using SnD.ApiClient.Boxer; +using SnD.ApiClient.Boxer.Base; +using SnD.ApiClient.Boxer.Models; +using SnD.ApiClient.Config; +using Xunit; + +namespace SnD.ApiClient.Tests.Boxer; + +public class BoxerClaimsClientTests : IClassFixture, IClassFixture +{ + private readonly IBoxerClaimsClient boxerClaimsClient; + + public BoxerClaimsClientTests(MockServiceFixture mockServiceFixture, LoggerFixture loggerFixture) + { + var crystalOptions = new BoxerClaimsClientOptions + { BaseUri = "https://boxer.example.com" }; + + boxerClaimsClient = new BoxerClaimsClient(Options.Create(crystalOptions), + mockServiceFixture.BoxerMockHttpClient, + CreateBoxerClient(mockServiceFixture), + loggerFixture.Factory.CreateLogger()); + } + + private static BoxerTokenProvider CreateBoxerClient(MockServiceFixture mockServiceFixture) + { + var boxerOptions = new BoxerTokenProviderOptions + { IdentityProvider = "example.com", BaseUri = "https://boxer.example.com" }; + var boxerConnector = new BoxerTokenProvider( + Options.Create(boxerOptions), + mockServiceFixture.BoxerMockHttpClient, + Mock.Of>(), + _ => Task.FromResult(string.Empty)); + return boxerConnector; + } + + [Theory] + [InlineData(new[] { "GET", "POST" }, "^(GET|POST)$")] + [InlineData(new[] { "GET", "POST", "PUT" }, "^(GET|POST|PUT)$")] + [InlineData(new[] { "GET", "POST", "PUT", "PATCH" }, "^(GET|POST|PUT|PATCH)$")] + [InlineData(new[] { "GET", "POST", "PUT", "PATCH", "DELETE" }, "^(GET|POST|PUT|PATCH|DELETE)$")] + public void CreateWithSomeMethods(string[] apiMethods, string expectedValue) + { + var boxerJwtClaim = BoxerJwtClaim.Create("path", new HashSet(apiMethods.Select(s=>new HttpMethod(s)))); + Assert.Equal("path", boxerJwtClaim.Type); + Assert.True(apiMethods.All(s => boxerJwtClaim.ApiMethods.Contains(new HttpMethod(s)))); + } + + [Fact] + public void DeserializationTest() + { + var apiResponse = File.ReadAllText("Boxer/ApiSamples/GetUserSample.json"); + var boxerJwtClaims = BoxerJwtClaim.FromBoxerClaimsApiResponse(apiResponse).ToArray(); + Assert.Equal(2, boxerJwtClaims.Length); + Assert.Equal("myapi1.com/.*", boxerJwtClaims.First().Path); + Assert.Contains(HttpMethod.Get, boxerJwtClaims.First().ApiMethods); + } +} diff --git a/test/SnD.ApiClient.Tests/BoxerMockHttpHendler.cs b/test/SnD.ApiClient.Tests/BoxerMockHttpHandler.cs similarity index 100% rename from test/SnD.ApiClient.Tests/BoxerMockHttpHendler.cs rename to test/SnD.ApiClient.Tests/BoxerMockHttpHandler.cs diff --git a/test/SnD.ApiClient.Tests/CrystalMockHttpHendler.cs b/test/SnD.ApiClient.Tests/CrystalMockHttpHandler.cs similarity index 100% rename from test/SnD.ApiClient.Tests/CrystalMockHttpHendler.cs rename to test/SnD.ApiClient.Tests/CrystalMockHttpHandler.cs diff --git a/test/SnD.ApiClient.Tests/SnD.ApiClient.Tests.csproj b/test/SnD.ApiClient.Tests/SnD.ApiClient.Tests.csproj index 93f3925..f7bd7ce 100644 --- a/test/SnD.ApiClient.Tests/SnD.ApiClient.Tests.csproj +++ b/test/SnD.ApiClient.Tests/SnD.ApiClient.Tests.csproj @@ -35,4 +35,11 @@ + + + + PreserveNewest + + +