diff --git a/.github/README.md b/.github/README.md index d636259..94dd3fe 100644 --- a/.github/README.md +++ b/.github/README.md @@ -134,6 +134,7 @@ var client = new IPDataClient("API_KEY"); var threatInfo = await client.Threat("69.78.70.144"); Console.WriteLine($"Threat is Tor: {threatInfo.IsTor}"); +Console.WriteLine($"Threat is VPN: {threatInfo.IsVpn}, trust score: {threatInfo.Scores?.TrustScore}"); ``` ### Response fields @@ -147,6 +148,8 @@ Console.WriteLine($"Threat is Tor: {threatInfo.IsTor}"); | `threat.blocklists[].first_seen` | `BlocklistInfo.FirstSeen` | First day the address was seen on that blocklist, a `YYYY-MM-DD` (UTC) string; `null` when the feed does not report it. | | `threat.blocklists[].last_seen` | `BlocklistInfo.LastSeen` | Last day the address was seen on that blocklist, a `YYYY-MM-DD` (UTC) string; `null` when the feed does not report it. | | `threat.blocklists[].days_seen` | `BlocklistInfo.DaysSeen` | Number of days the address was seen on that blocklist (`int?`); `null` when the feed does not report it. | +| `threat.is_vpn` | `Threat.IsVpn` | `true` when the address is a VPN endpoint (`bool?`); `null` when the response does not include the key. | +| `threat.scores` | `Threat.Scores` | A `ThreatScores` object with `VpnScore`, `ProxyScore`, `ThreatScore` and `TrustScore` (`int?`, from `vpn_score`, `proxy_score`, `threat_score` and `trust_score`). `Scores` is `null` when the response does not include the object, and a score is `null` when the object does not include it. | Both flags can be requested on their own or as part of a field selection: @@ -163,6 +166,7 @@ An `IPDataClient` keeps a persistent HTTPS connection pool for as long as it liv - **Shared default pool.** Clients constructed without an `HttpClient` (`new IPDataClient("API_KEY")`, with or without a base URL) share one process-wide `HttpClient`. It lives for the lifetime of the process and is never disposed, so there is nothing to close. On .NET 8+ it keeps idle connections for 60 seconds, recycles every connection after 5 minutes and sends HTTP/2 keep-alive pings every 30 seconds. - **HTTP/2 on .NET 8+.** The `net8.0` build asks for HTTP/2 on every request (`HttpVersionPolicy.RequestVersionOrLower`) and falls back to HTTP/1.1 automatically when the server or a proxy does not negotiate it. The `netstandard2.0` build, used by .NET Framework and other .NET Standard consumers, never asks for HTTP/2 and relies on HTTP/1.1 keep-alive. - **Bring your own `HttpClient`.** Pass an `HttpClient` to send every request through it, for example one managed by `IHttpClientFactory` (see [Dependency Injection](#dependency-injection)). It can be combined with a custom base URL through the `IPDataClient(string apiKey, HttpClient httpClient, Uri baseUrl)` constructor. You own that client's lifetime; the library never disposes it. The same applies to a custom `IHttpClient` implementation. +- **Request headers.** Every request carries `User-Agent: ipdata-dotnet/` (the package version) and `Accept: application/json`. ```csharp // One client for the whole application; reuse it for every lookup. diff --git a/IPData.sln b/IPData.sln index 6c5dbed..89994e9 100644 --- a/IPData.sln +++ b/IPData.sln @@ -17,9 +17,7 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "IPData.Tests", "test\Unit\I EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = ".github", ".github", "{3B7FCB46-B3B6-41EF-9495-3FA4D027E3FC}" ProjectSection(SolutionItems) = preProject - .github\CODE_OF_CONDUCT.md = .github\CODE_OF_CONDUCT.md .github\CONTRIBUTING.md = .github\CONTRIBUTING.md - .github\PULL_REQUEST_TEMPLATE.md = .github\PULL_REQUEST_TEMPLATE.md .github\README.md = .github\README.md EndProjectSection EndProject diff --git a/samples/IPData.Lookup/Program.cs b/samples/IPData.Lookup/Program.cs index e2696e7..1272287 100644 --- a/samples/IPData.Lookup/Program.cs +++ b/samples/IPData.Lookup/Program.cs @@ -45,6 +45,7 @@ public static async Task Main() // Get threat info from IP var threatInfo = await client.Threat("69.78.70.144"); Console.WriteLine($"Threat is Tor: {threatInfo.IsTor}"); + Console.WriteLine($"Threat is VPN: {threatInfo.IsVpn}, trust score: {threatInfo.Scores?.TrustScore}"); // Get IP data for multiple IPs var ipInfoList = await client.Lookup(new[] { "1.1.1.1", "2.2.2.2", "3.3.3.3" }); diff --git a/src/IPData/Exceptions/ApiException.cs b/src/IPData/Exceptions/ApiException.cs index e4424b5..618c790 100644 --- a/src/IPData/Exceptions/ApiException.cs +++ b/src/IPData/Exceptions/ApiException.cs @@ -10,6 +10,18 @@ namespace IPData.Exceptions [Serializable] public class ApiException : Exception { +#if NET8_0_OR_GREATER + /// The message of the obsoletion that .NET 8 and later put on formatter-based serialization. + internal const string SerializationObsoleteMessage = + "This API supports obsolete formatter-based serialization. It should not be called or extended by application code."; + + /// The diagnostic id of that obsoletion. + internal const string SerializationDiagnosticId = "SYSLIB0051"; + + /// The documentation link of that obsoletion. + internal const string SerializationUrlFormat = "https://aka.ms/dotnet-warnings/{0}"; +#endif + private static readonly ISerializer _serializer = new JsonSerializer(); [NonSerialized] @@ -45,7 +57,15 @@ protected ApiException(HttpStatusCode statusCode, ApiError apiError, Exception i ApiError = apiError ?? throw new ArgumentNullException(nameof(apiError), "ApiError can't be null"); } + /// Initializes a new instance of the class with serialized data. + /// + /// Formatter-based serialization is obsolete on .NET 8 and later (SYSLIB0051). The constructor is kept on + /// every target so the type has the same shape everywhere, and carries that obsoletion on .NET 8 and later. + /// [ExcludeFromCodeCoverage] +#if NET8_0_OR_GREATER + [Obsolete(SerializationObsoleteMessage, DiagnosticId = SerializationDiagnosticId, UrlFormat = SerializationUrlFormat)] +#endif protected ApiException(SerializationInfo serializationInfo, StreamingContext streamingContext) : base(serializationInfo, streamingContext) { diff --git a/src/IPData/Exceptions/BadRequestException.cs b/src/IPData/Exceptions/BadRequestException.cs index d00c112..bf45f82 100644 --- a/src/IPData/Exceptions/BadRequestException.cs +++ b/src/IPData/Exceptions/BadRequestException.cs @@ -23,7 +23,15 @@ public BadRequestException(string responseContent, Exception innerException) { } + /// Initializes a new instance of the class with serialized data. + /// + /// Formatter-based serialization is obsolete on .NET 8 and later (SYSLIB0051). The constructor is kept on + /// every target so the type has the same shape everywhere, and carries that obsoletion on .NET 8 and later. + /// [ExcludeFromCodeCoverage] +#if NET8_0_OR_GREATER + [Obsolete(SerializationObsoleteMessage, DiagnosticId = SerializationDiagnosticId, UrlFormat = SerializationUrlFormat)] +#endif protected BadRequestException(SerializationInfo serializationInfo, StreamingContext streamingContext) : base(serializationInfo, streamingContext) { diff --git a/src/IPData/Exceptions/ForbiddenException.cs b/src/IPData/Exceptions/ForbiddenException.cs index f95db08..5f09cc7 100644 --- a/src/IPData/Exceptions/ForbiddenException.cs +++ b/src/IPData/Exceptions/ForbiddenException.cs @@ -23,7 +23,15 @@ public ForbiddenException(string responseContent, Exception innerException) { } + /// Initializes a new instance of the class with serialized data. + /// + /// Formatter-based serialization is obsolete on .NET 8 and later (SYSLIB0051). The constructor is kept on + /// every target so the type has the same shape everywhere, and carries that obsoletion on .NET 8 and later. + /// [ExcludeFromCodeCoverage] +#if NET8_0_OR_GREATER + [Obsolete(SerializationObsoleteMessage, DiagnosticId = SerializationDiagnosticId, UrlFormat = SerializationUrlFormat)] +#endif protected ForbiddenException(SerializationInfo serializationInfo, StreamingContext streamingContext) : base(serializationInfo, streamingContext) { diff --git a/src/IPData/Exceptions/UnauthorizedException.cs b/src/IPData/Exceptions/UnauthorizedException.cs index ede2412..6259614 100644 --- a/src/IPData/Exceptions/UnauthorizedException.cs +++ b/src/IPData/Exceptions/UnauthorizedException.cs @@ -23,7 +23,15 @@ public UnauthorizedException(string message, Exception innerException) { } + /// Initializes a new instance of the class with serialized data. + /// + /// Formatter-based serialization is obsolete on .NET 8 and later (SYSLIB0051). The constructor is kept on + /// every target so the type has the same shape everywhere, and carries that obsoletion on .NET 8 and later. + /// [ExcludeFromCodeCoverage] +#if NET8_0_OR_GREATER + [Obsolete(SerializationObsoleteMessage, DiagnosticId = SerializationDiagnosticId, UrlFormat = SerializationUrlFormat)] +#endif protected UnauthorizedException(SerializationInfo serializationInfo, StreamingContext streamingContext) : base(serializationInfo, streamingContext) { diff --git a/src/IPData/IPData.csproj b/src/IPData/IPData.csproj index 9a11a02..93153fd 100644 --- a/src/IPData/IPData.csproj +++ b/src/IPData/IPData.csproj @@ -12,10 +12,8 @@ git false MIT - https://image.ibb.co/iDQdUS/ipdatalogo.png - ipdata.ico + icon.png IPData Community - Release notes are at https://github.com/ipdata/dotnet/releases false false @@ -23,6 +21,10 @@ 3.1.0 + + + + diff --git a/src/IPData/IPData.nuspec b/src/IPData/IPData.nuspec deleted file mode 100644 index 4c76c2d..0000000 --- a/src/IPData/IPData.nuspec +++ /dev/null @@ -1,23 +0,0 @@ - - - - IPData - $version$ - IPData Contributors - IPData Contributors - false - https://licenses.nuget.org/MIT - https://github.com/ipdata/dotnet - https://image.ibb.co/iDQdUS/ipdatalogo.png - C# library to gather information for an IP using ipdata.co - Release notes are at https://github.com/ipdata/dotnet/releases - IPData Community - ip ipdata csharp - - - - - - - - \ No newline at end of file diff --git a/src/IPData/IPDataClient.cs b/src/IPData/IPDataClient.cs index 5d96cab..c6bd6b5 100644 --- a/src/IPData/IPDataClient.cs +++ b/src/IPData/IPDataClient.cs @@ -3,6 +3,8 @@ using System.Globalization; using System.Linq.Expressions; using System.Net.Http; +using System.Net.Http.Headers; +using System.Reflection; using System.Runtime.CompilerServices; using System.Text; using System.Threading.Tasks; @@ -23,7 +25,8 @@ namespace IPData /// client that lives as long as the process does and is never disposed; an or /// passed in stays owned by the caller. The class holds no per-request state /// and is safe to use from several threads at once. On .NET 5 and later every request asks for HTTP/2 - /// and falls back to HTTP/1.1 when the server does not negotiate it. + /// and falls back to HTTP/1.1 when the server does not negotiate it. Every request carries a + /// User-Agent header naming the library and its version and an Accept: application/json header. /// public class IPDataClient : IIPDataClient { @@ -32,6 +35,15 @@ public class IPDataClient : IIPDataClient /// The exception factory private static readonly IApiExceptionFactory _exceptionFactory = new ApiExceptionFactory(); + /// The product token sent in the User-Agent header of every request. + internal const string UserAgentProduct = "ipdata-dotnet"; + + /// + /// The User-Agent value sent with every request, ipdata-dotnet/<version>, where the + /// version is the package version of this assembly. It is read once, when the type is initialized. + /// + internal static readonly ProductInfoHeaderValue UserAgent = CreateUserAgent(); + /// The HTTP client private readonly IHttpClient _httpClient; @@ -215,6 +227,7 @@ public async Task Threat(string ip) /// Creates the request message for an API call. /// + /// Every request carries the library's User-Agent and an Accept: application/json header. /// On .NET 5 and later the request asks for HTTP/2 and lets the handler fall back to HTTP/1.1 /// (HttpVersionPolicy.RequestVersionOrLower). The .NET Standard 2.0 build leaves the default version /// alone, because .NET Framework rejects requests that ask for HTTP/2. @@ -225,6 +238,8 @@ public async Task Threat(string ip) private static HttpRequestMessage CreateRequest(HttpMethod method, Uri url) { var request = new HttpRequestMessage(method, url); + request.Headers.UserAgent.Add(UserAgent); + request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); #if NET5_0_OR_GREATER request.Version = System.Net.HttpVersion.Version20; request.VersionPolicy = HttpVersionPolicy.RequestVersionOrLower; @@ -232,6 +247,34 @@ private static HttpRequestMessage CreateRequest(HttpMethod method, Uri url) return request; } + /// + /// Builds the User-Agent value from the assembly's informational version, which is the package + /// version without the +commit build metadata the SDK appends to it. Falls back to the assembly + /// version when the informational version is missing or is not a valid header token. + /// + /// The ipdata-dotnet/<version> product token. + private static ProductInfoHeaderValue CreateUserAgent() + { + var assembly = typeof(IPDataClient).Assembly; + var version = assembly.GetCustomAttribute()?.InformationalVersion; + + if (!string.IsNullOrWhiteSpace(version)) + { + var metadataStart = version.IndexOf('+'); + if (metadataStart >= 0) + { + version = version.Substring(0, metadataStart); + } + + if (ProductInfoHeaderValue.TryParse($"{UserAgentProduct}/{version}", out var userAgent)) + { + return userAgent; + } + } + + return new ProductInfoHeaderValue(UserAgentProduct, assembly.GetName().Version?.ToString(3) ?? "0.0.0"); + } + /// Sends the request asynchronous and disposes the request and the response once the body has been read. /// The HTTP client. /// The request. diff --git a/src/IPData/Models/IPLookupResult.cs b/src/IPData/Models/IPLookupResult.cs index ed31be0..0235761 100644 --- a/src/IPData/Models/IPLookupResult.cs +++ b/src/IPData/Models/IPLookupResult.cs @@ -78,8 +78,12 @@ public class IPLookupResult [JsonPropertyName("emoji_unicode")] public string EmojiUnicode { get; set; } + /// + /// The languages spoken where the address is located. The setter is public because + /// System.Text.Json does not populate collection properties that have a non-public setter. + /// [JsonPropertyName("languages")] - public List Languages { get; private set; } = new List(); + public List Languages { get; set; } = new List(); [JsonPropertyName("currency")] public Currency Currency { get; set; } diff --git a/src/IPData/Models/Threat.cs b/src/IPData/Models/Threat.cs index 75f031e..958616b 100644 --- a/src/IPData/Models/Threat.cs +++ b/src/IPData/Models/Threat.cs @@ -8,6 +8,12 @@ public class Threat [JsonPropertyName("is_tor")] public bool? IsTor { get; set; } + /// + /// Whether the address is a VPN endpoint, or null when the response does not include the key. + /// + [JsonPropertyName("is_vpn")] + public bool? IsVpn { get; set; } + [JsonPropertyName("is_proxy")] public bool? IsProxy { get; set; } @@ -38,5 +44,12 @@ public class Threat /// [JsonPropertyName("blocklists")] public List Blocklists { get; set; } = new List(); + + /// + /// The scores the API reports for the address (vpn_score, proxy_score, threat_score + /// and trust_score), or null when the response does not include the scores object. + /// + [JsonPropertyName("scores")] + public ThreatScores Scores { get; set; } } } diff --git a/src/IPData/Models/ThreatScores.cs b/src/IPData/Models/ThreatScores.cs new file mode 100644 index 0000000..4aa6d94 --- /dev/null +++ b/src/IPData/Models/ThreatScores.cs @@ -0,0 +1,23 @@ +using System.Text.Json.Serialization; + +namespace IPData.Models +{ + /// + /// The scores object of a threat response. Each score is null when the response does not + /// include it. + /// + public class ThreatScores + { + [JsonPropertyName("vpn_score")] + public int? VpnScore { get; set; } + + [JsonPropertyName("proxy_score")] + public int? ProxyScore { get; set; } + + [JsonPropertyName("threat_score")] + public int? ThreatScore { get; set; } + + [JsonPropertyName("trust_score")] + public int? TrustScore { get; set; } + } +} diff --git a/src/IPData/icon.png b/src/IPData/icon.png new file mode 100644 index 0000000..fa19132 Binary files /dev/null and b/src/IPData/icon.png differ diff --git a/src/IPData/ipdata.ico b/src/IPData/ipdata.ico deleted file mode 100644 index 604c685..0000000 Binary files a/src/IPData/ipdata.ico and /dev/null differ diff --git a/test/Unit/IPData.Tests/Exceptions/ApiExceptionTests.cs b/test/Unit/IPData.Tests/Exceptions/ApiExceptionTests.cs index 2a742a4..9f197df 100644 --- a/test/Unit/IPData.Tests/Exceptions/ApiExceptionTests.cs +++ b/test/Unit/IPData.Tests/Exceptions/ApiExceptionTests.cs @@ -1,4 +1,8 @@ -using System.Globalization; +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Reflection; +using System.Runtime.Serialization; using FluentAssertions; using IPData.Exceptions; using Xunit; @@ -78,5 +82,32 @@ public void ApiExceptionException_WhenCreateWithValidJsonContent_ShouldBeDeseria // Assert sut.Message.Should().Be(message); } + + public static IEnumerable ExceptionTypes() + { + yield return new object[] { typeof(ApiException) }; + yield return new object[] { typeof(BadRequestException) }; + yield return new object[] { typeof(ForbiddenException) }; + yield return new object[] { typeof(UnauthorizedException) }; + } + + [Theory] + [MemberData(nameof(ExceptionTypes))] + public void Exception_ShouldKeepTheProtectedSerializationConstructorAndMarkItObsolete(Type exceptionType) + { + // Act + var constructor = exceptionType.GetConstructor( + BindingFlags.Instance | BindingFlags.NonPublic, + null, + new[] { typeof(SerializationInfo), typeof(StreamingContext) }, + null); + + // Assert + constructor.Should().NotBeNull(); + constructor.IsFamily.Should().BeTrue(); + var obsolete = constructor.GetCustomAttribute(); + obsolete.Should().NotBeNull(); + obsolete.DiagnosticId.Should().Be("SYSLIB0051"); + } } } diff --git a/test/Unit/IPData.Tests/Http/HttpClientAdapterTests.cs b/test/Unit/IPData.Tests/Http/HttpClientAdapterTests.cs index 32b5af0..488e5d6 100644 --- a/test/Unit/IPData.Tests/Http/HttpClientAdapterTests.cs +++ b/test/Unit/IPData.Tests/Http/HttpClientAdapterTests.cs @@ -71,8 +71,8 @@ public async Task SendAsync_WhenCalledRepeatedly_ShouldSendEveryRequestThroughTh var sut = new HttpClientAdapter(httpClient); // Act - using (var first = await sut.SendAsync(new HttpRequestMessage(HttpMethod.Get, "https://api.ipdata.co/8.8.8.8")).ConfigureAwait(false)) - using (var second = await sut.SendAsync(new HttpRequestMessage(HttpMethod.Get, "https://api.ipdata.co/1.1.1.1")).ConfigureAwait(false)) + using (var first = await sut.SendAsync(new HttpRequestMessage(HttpMethod.Get, "https://api.ipdata.co/8.8.8.8"))) + using (var second = await sut.SendAsync(new HttpRequestMessage(HttpMethod.Get, "https://api.ipdata.co/1.1.1.1"))) { // Assert first.StatusCode.Should().Be(HttpStatusCode.OK); diff --git a/test/Unit/IPData.Tests/Http/Serializer/JsonSerializerTests.cs b/test/Unit/IPData.Tests/Http/Serializer/JsonSerializerTests.cs index 19c90a7..43714d9 100644 --- a/test/Unit/IPData.Tests/Http/Serializer/JsonSerializerTests.cs +++ b/test/Unit/IPData.Tests/Http/Serializer/JsonSerializerTests.cs @@ -23,6 +23,47 @@ public void Deserialize_WhenCalled_ReturnedIPLookupResult(string json) actual.Should().NotBeNull(); } + [Theory] + [MemberData(nameof(TestDataSource.IPLookupResultData), MemberType = typeof(TestDataSource))] + public void Deserialize_WhenCalled_ReadsLanguages(string json) + { + // Act + var actual = _sut.Deserialize(json); + + // Assert + var language = actual.Languages.Should().ContainSingle().Which; + language.Name.Should().Be("Ukrainian"); + language.Native.Should().Be("\u0423\u043A\u0440\u0430\u0457\u043D\u0441\u044C\u043A\u0430"); + language.Code.Should().Be("uk"); + } + + [Theory] + [MemberData(nameof(TestDataSource.AnycastData), MemberType = typeof(TestDataSource))] + public void Deserialize_AnycastResponse_ReadsLanguages(string json) + { + // Act + var actual = _sut.Deserialize(json); + + // Assert + var language = actual.Languages.Should().ContainSingle().Which; + language.Name.Should().Be("English"); + language.Native.Should().Be("English"); + language.Code.Should().Be("en"); + } + + [Fact] + public void Deserialize_WhenLanguagesAbsent_ReadsEmptyList() + { + // Arrange + var json = "{\"ip\":\"1.1.1.1\"}"; + + // Act + var actual = _sut.Deserialize(json); + + // Assert + actual.Languages.Should().NotBeNull().And.BeEmpty(); + } + [Fact] public void Deserialize_CountAsString_ParsedCorrectly() { @@ -133,6 +174,103 @@ public void Deserialize_VpnResponse_LeavesFlagsFalseAndBlocklistDatesNullWhenAbs undated.DaysSeen.Should().BeNull(); } + [Theory] + [MemberData(nameof(TestDataSource.VpnData), MemberType = typeof(TestDataSource))] + public void Deserialize_VpnResponse_ReadsIsVpnAndScores(string json) + { + // Act + var actual = _sut.Deserialize(json); + + // Assert + actual.Threat.IsVpn.Should().BeTrue(); + actual.Threat.Scores.Should().NotBeNull(); + actual.Threat.Scores.VpnScore.Should().Be(100); + actual.Threat.Scores.ProxyScore.Should().Be(99); + actual.Threat.Scores.ThreatScore.Should().Be(100); + actual.Threat.Scores.TrustScore.Should().Be(0); + } + + [Theory] + [MemberData(nameof(TestDataSource.SatelliteData), MemberType = typeof(TestDataSource))] + public void Deserialize_SatelliteResponse_ReadsIsVpnFalseAndScores(string json) + { + // Act + var actual = _sut.Deserialize(json); + + // Assert + actual.Threat.IsVpn.Should().BeFalse(); + actual.Threat.Scores.VpnScore.Should().Be(0); + actual.Threat.Scores.ProxyScore.Should().Be(1); + actual.Threat.Scores.ThreatScore.Should().Be(8); + actual.Threat.Scores.TrustScore.Should().Be(97); + } + + [Theory] + [MemberData(nameof(TestDataSource.IPLookupResultData), MemberType = typeof(TestDataSource))] + public void Deserialize_WhenIsVpnAndScoresAbsent_ReadNull(string json) + { + // Act + var actual = _sut.Deserialize(json); + + // Assert + actual.Threat.IsVpn.Should().BeNull(); + actual.Threat.Scores.Should().BeNull(); + } + + [Fact] + public void Deserialize_WhenScoresPartiallyPresent_LeavesMissingScoresNull() + { + // Arrange + var json = "{\"threat\":{\"scores\":{\"trust_score\":42}}}"; + + // Act + var actual = _sut.Deserialize(json); + + // Assert + actual.Threat.Scores.TrustScore.Should().Be(42); + actual.Threat.Scores.VpnScore.Should().BeNull(); + actual.Threat.Scores.ProxyScore.Should().BeNull(); + actual.Threat.Scores.ThreatScore.Should().BeNull(); + } + + [Fact] + public void Deserialize_ThreatResponse_ReadsIsVpnAndScores() + { + // Arrange: the body of a GET /{ip}/threat request + var json = "{\"is_tor\":false,\"is_vpn\":true,\"is_proxy\":false,\"is_threat\":true,\"blocklists\":[]," + + "\"scores\":{\"vpn_score\":100,\"proxy_score\":99,\"threat_score\":100,\"trust_score\":0}}"; + + // Act + var actual = _sut.Deserialize(json); + + // Assert + actual.IsVpn.Should().BeTrue(); + actual.IsThreat.Should().BeTrue(); + actual.Blocklists.Should().BeEmpty(); + actual.Scores.VpnScore.Should().Be(100); + actual.Scores.ProxyScore.Should().Be(99); + actual.Scores.ThreatScore.Should().Be(100); + actual.Scores.TrustScore.Should().Be(0); + } + + [Fact] + public void SerializeThreat_WhenCalled_RoundTripsIsVpnAndScores() + { + // Arrange + var threat = new Threat + { + IsVpn = true, + Scores = new ThreatScores { VpnScore = 100, ProxyScore = 99, ThreatScore = 100, TrustScore = 0 } + }; + + // Act + var actual = _sut.Deserialize(_sut.Serialize(threat)); + + // Assert + actual.IsVpn.Should().BeTrue(); + actual.Scores.Should().BeEquivalentTo(threat.Scores); + } + [Theory] [MemberData(nameof(TestDataSource.BulkData), MemberType = typeof(TestDataSource))] public void Deserialize_BulkResponse_CarriesNewFieldsPerItem(string json) @@ -147,9 +285,14 @@ public void Deserialize_BulkResponse_CarriesNewFieldsPerItem(string json) actual[1].IsAnycast.Should().BeTrue(); actual[1].IsSatellite.Should().BeFalse(); actual[1].Threat.Blocklists[0].DaysSeen.Should().Be(1); + actual[1].Languages.Should().ContainSingle().Which.Code.Should().Be("en"); actual[2].IsSatellite.Should().BeFalse(); actual[2].IsAnycast.Should().BeFalse(); actual[2].Threat.Blocklists[2].DaysSeen.Should().BeNull(); + actual[0].Threat.IsVpn.Should().BeFalse(); + actual[0].Threat.Scores.TrustScore.Should().Be(97); + actual[2].Threat.IsVpn.Should().BeTrue(); + actual[2].Threat.Scores.TrustScore.Should().Be(0); } [Fact] diff --git a/test/Unit/IPData.Tests/IPData.Tests.csproj b/test/Unit/IPData.Tests/IPData.Tests.csproj index 627a296..7d38f61 100644 --- a/test/Unit/IPData.Tests/IPData.Tests.csproj +++ b/test/Unit/IPData.Tests/IPData.Tests.csproj @@ -15,18 +15,19 @@ - - - + + + all runtime; build; native; contentfiles; analyzers - - - + + + + - - + + all runtime; build; native; contentfiles; analyzers diff --git a/test/Unit/IPData.Tests/IPDataClientTests.cs b/test/Unit/IPData.Tests/IPDataClientTests.cs index 46e3fc9..602af12 100644 --- a/test/Unit/IPData.Tests/IPDataClientTests.cs +++ b/test/Unit/IPData.Tests/IPDataClientTests.cs @@ -3,6 +3,7 @@ using System.Linq; using System.Net; using System.Net.Http; +using System.Reflection; using System.Text; using System.Threading.Tasks; using AutoFixture.Xunit2; @@ -82,6 +83,79 @@ private static HttpResponseMessage OkJson(string json) => Content = new StringContent(json, Encoding.UTF8, "application/json") }; + /// The package version of the library: its informational version without the "+commit" build metadata. + private static string ExpectedLibraryVersion + { + get + { + var version = typeof(IPDataClient).Assembly + .GetCustomAttribute() + .InformationalVersion; + var metadataStart = version.IndexOf('+'); + return metadataStart < 0 ? version : version.Substring(0, metadataStart); + } + } + + private static string ExpectedUserAgent => $"ipdata-dotnet/{ExpectedLibraryVersion}"; + + [Fact] + public void UserAgent_ShouldNameTheLibraryAndItsPackageVersion() + { + // Act + var userAgent = IPDataClient.UserAgent; + + // Assert + userAgent.Product.Name.Should().Be("ipdata-dotnet"); + userAgent.Product.Version.Should().Be(ExpectedLibraryVersion); + userAgent.Product.Version.Should().StartWith(typeof(IPDataClient).Assembly.GetName().Version.ToString(3)); + userAgent.ToString().Should().Be(ExpectedUserAgent); + } + + [Fact] + public async Task Lookup_WhenCalled_ShouldSendUserAgentAndAcceptHeaders() + { + // Arrange + var requests = new List(); + var httpClient = new Mock(); + httpClient + .Setup(x => x.SendAsync(It.IsAny())) + .Callback(requests.Add) + .Returns(() => Task.FromResult(OkJson(LookupJson))); + + var sut = new IPDataClient("test-api-key", httpClient.Object); + + // Act + await sut.Lookup("8.8.8.8"); + + // Assert + var request = requests.Should().ContainSingle().Which; + request.Headers.UserAgent.ToString().Should().Be(ExpectedUserAgent); + request.Headers.Accept.Should().ContainSingle().Which.MediaType.Should().Be("application/json"); + } + + [Fact] + public async Task Lookup_WhenCalledWithIpList_ShouldSendUserAgentAndAcceptHeaders() + { + // Arrange + var requests = new List(); + var httpClient = new Mock(); + httpClient + .Setup(x => x.SendAsync(It.IsAny())) + .Callback(requests.Add) + .Returns(() => Task.FromResult(OkJson(BulkJson))); + + var sut = new IPDataClient("test-api-key", httpClient.Object); + + // Act + await sut.Lookup(new[] { "1.1.1.1", "2.2.2.2" }); + + // Assert + var request = requests.Should().ContainSingle().Which; + request.Method.Should().Be(HttpMethod.Post); + request.Headers.UserAgent.ToString().Should().Be(ExpectedUserAgent); + request.Headers.Accept.Should().ContainSingle().Which.MediaType.Should().Be("application/json"); + } + [Theory, AutoMoqData] public void IPDataClient_WhenCreatedWithHttpClientAndBaseUrl_ShouldCreateClient( string apiKey) @@ -121,7 +195,7 @@ public async Task Lookup_WhenCreatedWithHttpClientAndBaseUrl_ShouldSendThroughTh var sut = new IPDataClient("test-api-key", httpClient, new Uri("https://eu-api.ipdata.co")); // Act - var actual = await sut.Lookup("8.8.8.8").ConfigureAwait(false); + var actual = await sut.Lookup("8.8.8.8"); // Assert actual.Ip.Should().Be("8.8.8.8"); @@ -144,7 +218,7 @@ public async Task Lookup_WhenCalled_ShouldRequestHttp2WithFallbackToLowerVersion var sut = new IPDataClient("test-api-key", httpClient.Object); // Act - var actual = await sut.Lookup("8.8.8.8").ConfigureAwait(false); + var actual = await sut.Lookup("8.8.8.8"); // Assert actual.Ip.Should().Be("8.8.8.8"); @@ -174,7 +248,7 @@ public async Task Lookup_WhenCalledWithIpList_ShouldPostJsonAndRequestHttp2WithF var sut = new IPDataClient("test-api-key", httpClient.Object); // Act - var actual = await sut.Lookup(new[] { "1.1.1.1", "2.2.2.2" }).ConfigureAwait(false); + var actual = await sut.Lookup(new[] { "1.1.1.1", "2.2.2.2" }); // Assert actual.Select(x => x.Ip).Should().Equal("1.1.1.1", "2.2.2.2"); @@ -200,7 +274,7 @@ public async Task Lookup_WhenSuccessful_ShouldDisposeRequestAndResponseAfterRead var sut = new IPDataClient("test-api-key", httpClient.Object); // Act - var actual = await sut.Lookup(new[] { "1.1.1.1", "2.2.2.2" }).ConfigureAwait(false); + var actual = await sut.Lookup(new[] { "1.1.1.1", "2.2.2.2" }); // Assert: the body was deserialized before anything was disposed... actual.Select(x => x.Ip).Should().Equal("1.1.1.1", "2.2.2.2"); @@ -208,8 +282,8 @@ public async Task Lookup_WhenSuccessful_ShouldDisposeRequestAndResponseAfterRead // ...and both messages are disposed afterwards, so their content can no longer be read. Func readRequest = () => request.Content.ReadAsStringAsync(); Func readResponse = () => response.Content.ReadAsStringAsync(); - await readRequest.Should().ThrowAsync().ConfigureAwait(false); - await readResponse.Should().ThrowAsync().ConfigureAwait(false); + await readRequest.Should().ThrowAsync(); + await readResponse.Should().ThrowAsync(); } [Fact] @@ -222,14 +296,17 @@ public async Task Lookup_WhenCalledRepeatedlyOnOneInstance_ShouldSendEveryReques var sut = new IPDataClient("test-api-key", httpClient); // Act - await sut.Lookup().ConfigureAwait(false); - await sut.Lookup("8.8.8.8").ConfigureAwait(false); - await sut.Threat("8.8.8.8").ConfigureAwait(false); + await sut.Lookup(); + await sut.Lookup("8.8.8.8"); + await sut.Threat("8.8.8.8"); // Assert + var expectedUserAgent = ExpectedUserAgent; handler.Requests.Should().HaveCount(3); handler.Requests.Should().OnlyContain(r => r.Version == HttpVersion.Version20); handler.Requests.Should().OnlyContain(r => r.VersionPolicy == HttpVersionPolicy.RequestVersionOrLower); + handler.Requests.Should().OnlyContain(r => r.Headers.UserAgent.ToString() == expectedUserAgent); + handler.Requests.Should().OnlyContain(r => r.Headers.Accept.ToString() == "application/json"); } } @@ -253,10 +330,10 @@ public async Task Lookup_WhenErrorStatusCode_ShouldThrowExpectedException( .ReturnsAsync(new HttpResponseMessage(statusCode)); var sut = new IPDataClient("test-api-key", httpClient.Object); - Func act = async () => { await sut.Lookup().ConfigureAwait(false); }; + Func act = async () => { await sut.Lookup(); }; // Act/Assert - (await act.Should().ThrowAsync().ConfigureAwait(false)) + (await act.Should().ThrowAsync()) .And.Should().BeOfType(expectedExceptionType); } @@ -272,10 +349,10 @@ public async Task Lookup_WhenCalledWithIpAndErrorStatusCode_ShouldThrowExpectedE .ReturnsAsync(new HttpResponseMessage(statusCode)); var sut = new IPDataClient("test-api-key", httpClient.Object); - Func act = async () => { await sut.Lookup("69.78.70.144").ConfigureAwait(false); }; + Func act = async () => { await sut.Lookup("69.78.70.144"); }; // Act/Assert - (await act.Should().ThrowAsync().ConfigureAwait(false)) + (await act.Should().ThrowAsync()) .And.Should().BeOfType(expectedExceptionType); } @@ -292,10 +369,10 @@ public async Task Lookup_WhenCalledWithIpListAndErrorStatusCode_ShouldThrowExpec var sut = new IPDataClient("test-api-key", httpClient.Object); var ipList = new string[] { "1.1.1.1", "2.2.2.2", "3.3.3.3" }; - Func act = async () => { await sut.Lookup(ipList).ConfigureAwait(false); }; + Func act = async () => { await sut.Lookup(ipList); }; // Act/Assert - (await act.Should().ThrowAsync().ConfigureAwait(false)) + (await act.Should().ThrowAsync()) .And.Should().BeOfType(expectedExceptionType); } @@ -311,10 +388,10 @@ public async Task Lookup_WhenCalledWithSelectorAndErrorStatusCode_ShouldThrowExp .ReturnsAsync(new HttpResponseMessage(statusCode)); var sut = new IPDataClient("test-api-key", httpClient.Object); - Func act = async () => { await sut.Lookup("1.1.1.1", x => x.CountryName).ConfigureAwait(false); }; + Func act = async () => { await sut.Lookup("1.1.1.1", x => x.CountryName); }; // Act/Assert - (await act.Should().ThrowAsync().ConfigureAwait(false)) + (await act.Should().ThrowAsync()) .And.Should().BeOfType(expectedExceptionType); } @@ -330,10 +407,10 @@ public async Task Lookup_WhenCalledWithSelectorsAndErrorStatusCode_ShouldThrowEx .ReturnsAsync(new HttpResponseMessage(statusCode)); var sut = new IPDataClient("test-api-key", httpClient.Object); - Func act = async () => { await sut.Lookup("1.1.1.1", x => x.Asn, x => x.City).ConfigureAwait(false); }; + Func act = async () => { await sut.Lookup("1.1.1.1", x => x.Asn, x => x.City); }; // Act/Assert - (await act.Should().ThrowAsync().ConfigureAwait(false)) + (await act.Should().ThrowAsync()) .And.Should().BeOfType(expectedExceptionType); } @@ -349,10 +426,10 @@ public async Task Company_WhenErrorStatusCode_ShouldThrowExpectedException( .ReturnsAsync(new HttpResponseMessage(statusCode)); var sut = new IPDataClient("test-api-key", httpClient.Object); - Func act = async () => { await sut.Company("1.1.1.1").ConfigureAwait(false); }; + Func act = async () => { await sut.Company("1.1.1.1"); }; // Act/Assert - (await act.Should().ThrowAsync().ConfigureAwait(false)) + (await act.Should().ThrowAsync()) .And.Should().BeOfType(expectedExceptionType); } @@ -368,10 +445,10 @@ public async Task Carrier_WhenErrorStatusCode_ShouldThrowExpectedException( .ReturnsAsync(new HttpResponseMessage(statusCode)); var sut = new IPDataClient("test-api-key", httpClient.Object); - Func act = async () => { await sut.Carrier("1.1.1.1").ConfigureAwait(false); }; + Func act = async () => { await sut.Carrier("1.1.1.1"); }; // Act/Assert - (await act.Should().ThrowAsync().ConfigureAwait(false)) + (await act.Should().ThrowAsync()) .And.Should().BeOfType(expectedExceptionType); } @@ -387,10 +464,10 @@ public async Task Asn_WhenErrorStatusCode_ShouldThrowExpectedException( .ReturnsAsync(new HttpResponseMessage(statusCode)); var sut = new IPDataClient("test-api-key", httpClient.Object); - Func act = async () => { await sut.Asn("1.1.1.1").ConfigureAwait(false); }; + Func act = async () => { await sut.Asn("1.1.1.1"); }; // Act/Assert - (await act.Should().ThrowAsync().ConfigureAwait(false)) + (await act.Should().ThrowAsync()) .And.Should().BeOfType(expectedExceptionType); } @@ -406,10 +483,10 @@ public async Task TimeZone_WhenErrorStatusCode_ShouldThrowExpectedException( .ReturnsAsync(new HttpResponseMessage(statusCode)); var sut = new IPDataClient("test-api-key", httpClient.Object); - Func act = async () => { await sut.TimeZone("1.1.1.1").ConfigureAwait(false); }; + Func act = async () => { await sut.TimeZone("1.1.1.1"); }; // Act/Assert - (await act.Should().ThrowAsync().ConfigureAwait(false)) + (await act.Should().ThrowAsync()) .And.Should().BeOfType(expectedExceptionType); } @@ -425,10 +502,10 @@ public async Task Currency_WhenErrorStatusCode_ShouldThrowExpectedException( .ReturnsAsync(new HttpResponseMessage(statusCode)); var sut = new IPDataClient("test-api-key", httpClient.Object); - Func act = async () => { await sut.Currency("1.1.1.1").ConfigureAwait(false); }; + Func act = async () => { await sut.Currency("1.1.1.1"); }; // Act/Assert - (await act.Should().ThrowAsync().ConfigureAwait(false)) + (await act.Should().ThrowAsync()) .And.Should().BeOfType(expectedExceptionType); } @@ -444,10 +521,10 @@ public async Task Threat_WhenErrorStatusCode_ShouldThrowExpectedException( .ReturnsAsync(new HttpResponseMessage(statusCode)); var sut = new IPDataClient("test-api-key", httpClient.Object); - Func act = async () => { await sut.Threat("1.1.1.1").ConfigureAwait(false); }; + Func act = async () => { await sut.Threat("1.1.1.1"); }; // Act/Assert - (await act.Should().ThrowAsync().ConfigureAwait(false)) + (await act.Should().ThrowAsync()) .And.Should().BeOfType(expectedExceptionType); } }