diff --git a/CHANGELOG.md b/CHANGELOG.md index 4feddc5..e4ae874 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [1.2.2] - 2026-08-11 + +### Fixed + +#### NoaaClient Package +- Treat AWS WAF `202` challenges (`x-amzn-waf-action: challenge`) and empty response bodies as failures via `EnsureNoaaSuccessAsync` for all NOAA clients (`Rtsw`, `Ace`, `KpIndex`, `WsaEnlil`) +- Sanitize bare/quoted `NaN`/`Infinity` literals in RTSW JSON before deserialization (lazy allocation when the payload is clean) + +### Changed + +#### Dependencies +- Bump `AuroraScienceHub.Framework.Http` and `AuroraScienceHub.Framework.Utilities` from 10.0.5 to 10.0.7 +- Bump `Microsoft.Extensions.*` pins (`DependencyInjection.Abstractions`, `Hosting`, `Http`, `Logging.Abstractions`) from 10.0.7 to 10.0.10 (required by Framework 10.0.7) +- Dual-map `AuroraScienceHub.*` to nuget.org in `NuGet.Config` so stable Framework packages resolve from nuget.org + ## [1.1.1] - 2026-07-07 ### Fixed diff --git a/Directory.Build.props b/Directory.Build.props index 7f7c68f..a2419b6 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -29,7 +29,7 @@ - 1.2.0 + 1.2.2 diff --git a/Directory.Packages.props b/Directory.Packages.props index fff288d..66bb572 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -5,8 +5,8 @@ true - - + + all @@ -16,9 +16,10 @@ - - - + + + + diff --git a/NuGet.Config b/NuGet.Config index 5061ff1..e92eb30 100644 --- a/NuGet.Config +++ b/NuGet.Config @@ -2,18 +2,19 @@ - + - + + - + - diff --git a/src/NoaaClient/Ace/AceClient.cs b/src/NoaaClient/Ace/AceClient.cs index d808fa9..c30f1ef 100644 --- a/src/NoaaClient/Ace/AceClient.cs +++ b/src/NoaaClient/Ace/AceClient.cs @@ -1,5 +1,6 @@ using AuroraScienceHub.Integrations.NoaaClient.Ace.Extensions; using AuroraScienceHub.Integrations.NoaaClient.Ace.Responses; +using AuroraScienceHub.Integrations.NoaaClient.Http; using Microsoft.Extensions.Options; namespace AuroraScienceHub.Integrations.NoaaClient.Ace; @@ -23,8 +24,8 @@ public AceClient( public async Task> GetMagnetometerDataAsync(CancellationToken cancellationToken) { var url = new Uri(_baseUrl, "text/ace-magnetometer.txt"); - var response = await _httpClient.GetAsync(url, cancellationToken).ConfigureAwait(false); - response.EnsureSuccessStatusCode(); + using var response = await _httpClient.GetAsync(url, cancellationToken).ConfigureAwait(false); + await response.EnsureNoaaSuccessAsync(url, cancellationToken).ConfigureAwait(false); var text = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); return MagnetometerDataParser.Parse(text); @@ -33,8 +34,8 @@ public async Task> GetMagnetometerDataAsync(Ca public async Task> GetSwepamDataAsync(CancellationToken cancellationToken) { var url = new Uri(_baseUrl, "text/ace-swepam.txt"); - var response = await _httpClient.GetAsync(url, cancellationToken).ConfigureAwait(false); - response.EnsureSuccessStatusCode(); + using var response = await _httpClient.GetAsync(url, cancellationToken).ConfigureAwait(false); + await response.EnsureNoaaSuccessAsync(url, cancellationToken).ConfigureAwait(false); var text = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); return SolarWindPlasmaDataParser.Parse(text); diff --git a/src/NoaaClient/Http/NoaaHttpResponseExtensions.cs b/src/NoaaClient/Http/NoaaHttpResponseExtensions.cs new file mode 100644 index 0000000..b21e553 --- /dev/null +++ b/src/NoaaClient/Http/NoaaHttpResponseExtensions.cs @@ -0,0 +1,76 @@ +using System.Net; +using AuroraScienceHub.Framework.Http; + +namespace AuroraScienceHub.Integrations.NoaaClient.Http; + +/// +/// NOAA-specific HTTP response validation helpers. +/// +internal static class NoaaHttpResponseExtensions +{ + private const string WafActionHeaderName = "x-amzn-waf-action"; + private const string WafChallengeAction = "challenge"; + + /// + /// Ensures the NOAA response is not a WAF challenge and has a successful status code. + /// + public static async Task EnsureNoaaSuccessAsync( + this HttpResponseMessage response, + Uri? requestUri = null, + CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + ThrowIfWafChallenge(response, requestUri); + await response.EnsureSuccess().ConfigureAwait(false); + } + + /// + /// Throws when the NOAA response body is empty after a successful status code. + /// + public static void ThrowIfEmptyBody( + this HttpResponseMessage response, + ReadOnlySpan body, + Uri? requestUri = null) + { + if (body.Length > 0) + { + return; + } + + var contentLength = response.Content.Headers.ContentLength; + throw new HttpRequestException( + $"NOAA returned an empty response body (HTTP {(int)response.StatusCode}, ContentLength={contentLength}, Uri={requestUri}).", + inner: null, + statusCode: response.StatusCode); + } + + internal static string? GetWafAction(HttpResponseMessage response) + { + if (response.Headers.TryGetValues(WafActionHeaderName, out var values)) + { + return values.FirstOrDefault(); + } + + if (response.Content.Headers.TryGetValues(WafActionHeaderName, out values)) + { + return values.FirstOrDefault(); + } + + return null; + } + + private static void ThrowIfWafChallenge(HttpResponseMessage response, Uri? requestUri) + { + var wafAction = GetWafAction(response); + if (response.StatusCode != HttpStatusCode.Accepted + || !string.Equals(wafAction, WafChallengeAction, StringComparison.OrdinalIgnoreCase)) + { + return; + } + + throw new HttpRequestException( + $"NOAA request blocked by AWS WAF challenge (HTTP {(int)response.StatusCode}, {WafActionHeaderName}={wafAction}, Uri={requestUri}).", + inner: null, + statusCode: response.StatusCode); + } +} diff --git a/src/NoaaClient/KpIndex/KpIndexClient.cs b/src/NoaaClient/KpIndex/KpIndexClient.cs index 35a978d..5f50b78 100644 --- a/src/NoaaClient/KpIndex/KpIndexClient.cs +++ b/src/NoaaClient/KpIndex/KpIndexClient.cs @@ -1,3 +1,4 @@ +using AuroraScienceHub.Integrations.NoaaClient.Http; using AuroraScienceHub.Integrations.NoaaClient.KpIndex.Extensions; using AuroraScienceHub.Integrations.NoaaClient.KpIndex.Responses; using Microsoft.Extensions.Options; @@ -41,8 +42,8 @@ public async Task> GetKpIndexNowcastAsync( private async Task GetStringOrDefaultAsync(Uri url, CancellationToken cancellationToken) { - var response = await _client.GetAsync(url, cancellationToken).ConfigureAwait(false); - response.EnsureSuccessStatusCode(); + using var response = await _client.GetAsync(url, cancellationToken).ConfigureAwait(false); + await response.EnsureNoaaSuccessAsync(url, cancellationToken).ConfigureAwait(false); return await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); } diff --git a/src/NoaaClient/NoaaClient.csproj b/src/NoaaClient/NoaaClient.csproj index 2f3462d..8d31b61 100644 --- a/src/NoaaClient/NoaaClient.csproj +++ b/src/NoaaClient/NoaaClient.csproj @@ -10,6 +10,7 @@ + diff --git a/src/NoaaClient/Rtsw/RtswClient.cs b/src/NoaaClient/Rtsw/RtswClient.cs index a47fcee..1e4a5c6 100644 --- a/src/NoaaClient/Rtsw/RtswClient.cs +++ b/src/NoaaClient/Rtsw/RtswClient.cs @@ -1,5 +1,11 @@ +using System.Net; +using System.Text.Json; using AuroraScienceHub.Framework.Http; +using AuroraScienceHub.Framework.Json; +using AuroraScienceHub.Integrations.NoaaClient.Http; using AuroraScienceHub.Integrations.NoaaClient.Rtsw.Responses; +using AuroraScienceHub.Integrations.NoaaClient.Utilities; +using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; namespace AuroraScienceHub.Integrations.NoaaClient.Rtsw; @@ -7,37 +13,84 @@ namespace AuroraScienceHub.Integrations.NoaaClient.Rtsw; /// internal sealed class RtswClient : IRtswClient { + private static readonly JsonSerializerOptions s_jsonOptions = DefaultJsonSerializerOptions.Create(); + private readonly HttpClient _httpClient; private readonly Uri _baseUrl; + private readonly ILogger _logger; /// /// Initializes a new instance of the class. /// public RtswClient( HttpClient httpClient, - IOptions options) + IOptions options, + ILogger logger) { _httpClient = httpClient; _baseUrl = options.Value.RequiredServerUrl; + _logger = logger; } public async Task> GetMagnetometerDataAsync(CancellationToken cancellationToken) { var url = new Uri(_baseUrl, "json/rtsw/rtsw_mag_1m.json"); - var records = await _httpClient - .GetFromJsonOrDefaultAsync>(url, cancellationToken) - .ConfigureAwait(false); - - return records ?? []; + return await GetRtswJsonAsync>(url, cancellationToken).ConfigureAwait(false) + ?? []; } public async Task> GetSolarWindPlasmaDataAsync(CancellationToken cancellationToken) { var url = new Uri(_baseUrl, "json/rtsw/rtsw_wind_1m.json"); - var records = await _httpClient - .GetFromJsonOrDefaultAsync>(url, cancellationToken) + return await GetRtswJsonAsync>(url, cancellationToken).ConfigureAwait(false) + ?? []; + } + + private async Task GetRtswJsonAsync( + Uri requestUri, + CancellationToken cancellationToken) + { + using var response = await _httpClient.GetAsync(requestUri, cancellationToken).ConfigureAwait(false); + return await ReadRtswJsonOrThrowAsync(response, requestUri, cancellationToken).ConfigureAwait(false); + } + + private async Task ReadRtswJsonOrThrowAsync( + HttpResponseMessage response, + Uri requestUri, + CancellationToken cancellationToken) + { + var wafAction = NoaaHttpResponseExtensions.GetWafAction(response); + var contentLength = response.Content.Headers.ContentLength; + + _logger.LogInformation( + "RTSW HTTP response Uri={RequestUri} StatusCode={StatusCode} WafAction={WafAction} ContentLength={ContentLength}", + requestUri, + (int)response.StatusCode, + wafAction, + contentLength); + + await response.EnsureNoaaSuccessAsync(requestUri, cancellationToken).ConfigureAwait(false); + + if (response.StatusCode == HttpStatusCode.NoContent) + { + response.ThrowIfEmptyBody(ReadOnlySpan.Empty, requestUri); + } + + var rawBytes = await response.Content + .ReadAsByteArrayAsync(cancellationToken) .ConfigureAwait(false); - return records ?? []; + response.ThrowIfEmptyBody(rawBytes, requestUri); + + var sanitized = NoaaJsonSanitizer.Sanitize(rawBytes); + if (sanitized.ReplacementCount > 0) + { + _logger.LogWarning( + "NOAA RTSW JSON sanitized {ReplacementCount} non-standard numeric literals to null. Uri={RequestUri}", + sanitized.ReplacementCount, + requestUri); + } + + return JsonSerializer.Deserialize(sanitized.Bytes.Span, s_jsonOptions); } } diff --git a/src/NoaaClient/Utilities/NoaaJsonSanitizer.cs b/src/NoaaClient/Utilities/NoaaJsonSanitizer.cs new file mode 100644 index 0000000..fff0d02 --- /dev/null +++ b/src/NoaaClient/Utilities/NoaaJsonSanitizer.cs @@ -0,0 +1,205 @@ +namespace AuroraScienceHub.Integrations.NoaaClient.Utilities; + +/// +/// Replaces non-standard NOAA JSON numeric literals with RFC-compliant null tokens. +/// +internal static class NoaaJsonSanitizer +{ + private static ReadOnlySpan NullLiteral => "null"u8; + private static ReadOnlySpan NanLiteral => "NaN"u8; + private static ReadOnlySpan InfinityLiteral => "Infinity"u8; + private static ReadOnlySpan NegativeInfinityLiteral => "-Infinity"u8; + + /// + /// Sanitizes NOAA JSON payload by replacing bare and quoted NaN/Infinity literals with null. + /// + public static SanitizeResult Sanitize(ReadOnlyMemory source) + { + var span = source.Span; + if (span.Length == 0) + { + return new SanitizeResult(source, 0); + } + + if (!MayContainNonStandardNumericLiteral(span)) + { + return new SanitizeResult(source, 0); + } + + var output = new byte[span.Length + 64]; + var writeIndex = 0; + var replacements = 0; + var inString = false; + var i = 0; + + while (i < span.Length) + { + EnsureCapacity(ref output, writeIndex + 16); + + var current = span[i]; + + if (inString) + { + output[writeIndex++] = current; + + if (current == (byte)'\\' && i + 1 < span.Length) + { + output[writeIndex++] = span[i + 1]; + i += 2; + continue; + } + + if (current == (byte)'"') + { + inString = false; + } + + i++; + continue; + } + + if (current == (byte)'"') + { + if (TryReplaceQuotedLiteral(span, ref i, NegativeInfinityLiteral, output, ref writeIndex, ref replacements) + || TryReplaceQuotedLiteral(span, ref i, InfinityLiteral, output, ref writeIndex, ref replacements) + || TryReplaceQuotedLiteral(span, ref i, NanLiteral, output, ref writeIndex, ref replacements)) + { + continue; + } + + inString = true; + output[writeIndex++] = current; + i++; + continue; + } + + if (TryReplaceBareLiteral(span, ref i, NegativeInfinityLiteral, output, ref writeIndex, ref replacements) + || TryReplaceBareLiteral(span, ref i, InfinityLiteral, output, ref writeIndex, ref replacements) + || TryReplaceBareLiteral(span, ref i, NanLiteral, output, ref writeIndex, ref replacements)) + { + continue; + } + + output[writeIndex++] = current; + i++; + } + + if (replacements == 0) + { + return new SanitizeResult(source, 0); + } + + return new SanitizeResult(output.AsMemory(0, writeIndex), replacements); + } + + private static bool MayContainNonStandardNumericLiteral(ReadOnlySpan span) + { + for (var i = 0; i < span.Length; i++) + { + if (span[i] is (byte)'N' or (byte)'I') + { + return true; + } + } + + return false; + } + + private static bool TryReplaceQuotedLiteral( + ReadOnlySpan source, + ref int index, + ReadOnlySpan literal, + byte[] output, + ref int writeIndex, + ref int replacements) + { + var endIndex = index + 1 + literal.Length; + + if (endIndex >= source.Length || source[endIndex] != (byte)'"') + { + return false; + } + + if (!source.Slice(index + 1, literal.Length).SequenceEqual(literal)) + { + return false; + } + + WriteNullLiteral(output, ref writeIndex); + replacements++; + index = endIndex + 1; + return true; + } + + private static bool TryReplaceBareLiteral( + ReadOnlySpan source, + ref int index, + ReadOnlySpan literal, + byte[] output, + ref int writeIndex, + ref int replacements) + { + if (index + literal.Length > source.Length) + { + return false; + } + + if (!source.Slice(index, literal.Length).SequenceEqual(literal)) + { + return false; + } + + if (!IsBareLiteralStart(source, index) || !IsBareLiteralEnd(source, index + literal.Length)) + { + return false; + } + + WriteNullLiteral(output, ref writeIndex); + replacements++; + index += literal.Length; + return true; + } + + private static bool IsBareLiteralStart(ReadOnlySpan source, int index) + { + if (index == 0) + { + return true; + } + + return IsDelimiter(source[index - 1]); + } + + private static bool IsBareLiteralEnd(ReadOnlySpan source, int index) + { + if (index >= source.Length) + { + return true; + } + + return IsDelimiter(source[index]); + } + + private static bool IsDelimiter(byte value) + => value is (byte)' ' or (byte)'\t' or (byte)'\r' or (byte)'\n' + or (byte)':' or (byte)',' or (byte)'[' or (byte)']' or (byte)'{' or (byte)'}'; + + private static void WriteNullLiteral(byte[] output, ref int writeIndex) + { + EnsureCapacity(ref output, writeIndex + NullLiteral.Length); + NullLiteral.CopyTo(output.AsSpan(writeIndex)); + writeIndex += NullLiteral.Length; + } + + private static void EnsureCapacity(ref byte[] output, int requiredLength) + { + if (requiredLength <= output.Length) + { + return; + } + + Array.Resize(ref output, Math.Max(requiredLength, output.Length * 2)); + } + + internal readonly record struct SanitizeResult(ReadOnlyMemory Bytes, int ReplacementCount); +} diff --git a/src/NoaaClient/WsaEnlil/WsaEnlilClient.cs b/src/NoaaClient/WsaEnlil/WsaEnlilClient.cs index cf3e249..d940bb6 100644 --- a/src/NoaaClient/WsaEnlil/WsaEnlilClient.cs +++ b/src/NoaaClient/WsaEnlil/WsaEnlilClient.cs @@ -1,6 +1,8 @@ using System.Globalization; +using System.Text.Json; using System.Text.RegularExpressions; -using AuroraScienceHub.Framework.Http; +using AuroraScienceHub.Framework.Json; +using AuroraScienceHub.Integrations.NoaaClient.Http; using AuroraScienceHub.Integrations.NoaaClient.WsaEnlil.Responses; using FFMpegCore; using FFMpegCore.Pipes; @@ -10,6 +12,8 @@ namespace AuroraScienceHub.Integrations.NoaaClient.WsaEnlil; internal sealed partial class WsaEnlilClient : IWsaEnlilClient { + private static readonly JsonSerializerOptions s_jsonOptions = DefaultJsonSerializerOptions.Create(); + private const string ManifestPath = "products/animations/enlil.json"; private const int Fps = 20; private const int Crf = 23; // H.264 quality (0 = lossless, 51 = worst) @@ -90,9 +94,16 @@ public async Task GetEnlilAnimationAsync( CancellationToken cancellationToken) { var manifestUrl = new Uri(_baseUrl, ManifestPath); - return await _httpClient - .GetFromJsonOrDefaultAsync>(manifestUrl, cancellationToken) - .ConfigureAwait(false); + using var response = await _httpClient.GetAsync(manifestUrl, cancellationToken).ConfigureAwait(false); + await response.EnsureNoaaSuccessAsync(manifestUrl, cancellationToken).ConfigureAwait(false); + + var rawBytes = await response.Content.ReadAsByteArrayAsync(cancellationToken).ConfigureAwait(false); + if (rawBytes.Length == 0) + { + return null; + } + + return JsonSerializer.Deserialize>(rawBytes, s_jsonOptions); } private async Task DownloadFramesAsync( @@ -106,9 +117,9 @@ private async Task DownloadFramesAsync( cancellationToken.ThrowIfCancellationRequested(); var frameUrl = new Uri(_baseUrl, entry.Url); - await using var sourceStream = await _httpClient - .GetStreamAsync(frameUrl, cancellationToken) - .ConfigureAwait(false); + using var response = await _httpClient.GetAsync(frameUrl, cancellationToken).ConfigureAwait(false); + await response.EnsureNoaaSuccessAsync(frameUrl, cancellationToken).ConfigureAwait(false); + await using var sourceStream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); var framePath = Path.Combine(tempDir, string.Format(FrameFileFormat, index)); await using var fileStream = File.Create(framePath); diff --git a/tests/UnitTests/NoaaClient/Rtsw/RtswClientTests.cs b/tests/UnitTests/NoaaClient/Rtsw/RtswClientTests.cs new file mode 100644 index 0000000..e1dba4f --- /dev/null +++ b/tests/UnitTests/NoaaClient/Rtsw/RtswClientTests.cs @@ -0,0 +1,171 @@ +using System.Net; +using AuroraScienceHub.Integrations.NoaaClient; +using AuroraScienceHub.Integrations.NoaaClient.Rtsw; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using Shouldly; + +namespace AuroraScienceHub.Integrations.UnitTests.NoaaClient.Rtsw; + +/// +/// Unit tests for HTTP response hardening. +/// +public sealed class RtswClientTests +{ + private static readonly Uri BaseUrl = new("https://noaa.test/"); + + [Fact(DisplayName = "GetMagnetometerData throws on HTTP 202 WAF challenge with empty body")] + public async Task GetMagnetometerDataAsync_WhenWafChallenge202_ThrowsHttpRequestException() + { + // Arrange + var response = new HttpResponseMessage(HttpStatusCode.Accepted) + { + Content = new StringContent(string.Empty) + }; + response.Headers.TryAddWithoutValidation("x-amzn-waf-action", "challenge"); + response.Content.Headers.ContentLength = 0; + + var sut = CreateSut(response); + + // Act + var exception = await Should.ThrowAsync( + async () => await sut.GetMagnetometerDataAsync(TestContext.Current.CancellationToken)); + + // Assert + exception.Message.ShouldContain("WAF"); + exception.StatusCode.ShouldBe(HttpStatusCode.Accepted); + } + + [Fact(DisplayName = "GetSolarWindPlasmaData throws on successful response with empty body")] + public async Task GetSolarWindPlasmaDataAsync_WhenEmptyBody_ThrowsHttpRequestException() + { + // Arrange + var response = new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new ByteArrayContent(Array.Empty()) + }; + response.Content.Headers.ContentLength = 0; + response.Content.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("application/json"); + + var sut = CreateSut(response); + + // Act + var exception = await Should.ThrowAsync( + async () => await sut.GetSolarWindPlasmaDataAsync(TestContext.Current.CancellationToken)); + + // Assert + exception.Message.ShouldContain("empty"); + exception.StatusCode.ShouldBe(HttpStatusCode.OK); + } + + [Fact(DisplayName = "GetSolarWindPlasmaData deserializes bare NaN as null")] + public async Task GetSolarWindPlasmaDataAsync_WhenBareNaN_DeserializesNullSpeed() + { + // Arrange + var json = """ + [ + { + "time_tag": "2026-08-10T09:45:00Z", + "active": true, + "source": "SOLAR1", + "proton_speed": NaN, + "proton_density": 5.0, + "proton_temperature": 100000.0 + } + ] + """; + var response = new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(json, System.Text.Encoding.UTF8, "application/json") + }; + + var sut = CreateSut(response); + + // Act + var records = await sut.GetSolarWindPlasmaDataAsync(TestContext.Current.CancellationToken); + + // Assert + records.Count.ShouldBe(1); + records[0].ProtonSpeed.ShouldBeNull(); + records[0].ProtonDensity.ShouldBe(5f); + } + + [Fact(DisplayName = "GetSolarWindPlasmaData throws on chunked empty body without Content-Length")] + public async Task GetSolarWindPlasmaDataAsync_WhenChunkedEmptyBody_ThrowsHttpRequestException() + { + // Arrange + var response = new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new ByteArrayContent(Array.Empty()) + }; + response.Content.Headers.ContentLength = null; + + var sut = CreateSut(response); + + // Act + var exception = await Should.ThrowAsync( + async () => await sut.GetSolarWindPlasmaDataAsync(TestContext.Current.CancellationToken)); + + // Assert + exception.Message.ShouldContain("empty"); + } + + [Fact(DisplayName = "GetMagnetometerData returns records for valid JSON body")] + public async Task GetMagnetometerDataAsync_WhenValidJson_ReturnsRecords() + { + // Arrange + var json = """ + [ + { + "time_tag": "2026-08-10T09:45:00Z", + "active": true, + "source": "SOLAR1", + "bt": 5.0, + "bx_gsm": 1.0, + "by_gsm": 2.0, + "bz_gsm": 3.0 + } + ] + """; + var response = new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(json, System.Text.Encoding.UTF8, "application/json") + }; + + var sut = CreateSut(response); + + // Act + var records = await sut.GetMagnetometerDataAsync(TestContext.Current.CancellationToken); + + // Assert + records.Count.ShouldBe(1); + records[0].Source.ShouldBe("SOLAR1"); + records[0].Bt.ShouldBe(5f); + } + + private static IRtswClient CreateSut(HttpResponseMessage response) + { + var handler = new TestHttpMessageHandler(response); + var httpClient = new HttpClient(handler); + var options = Options.Create(new NoaaClientOptions { ServerUrl = BaseUrl }); + return new RtswClient(httpClient, options, NullLogger.Instance); + } + + private sealed class TestHttpMessageHandler : HttpMessageHandler + { + private readonly HttpResponseMessage _response; + + public TestHttpMessageHandler(HttpResponseMessage response) + { + _response = response; + } + + protected override Task SendAsync( + HttpRequestMessage request, + CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + return Task.FromResult(_response); + } + } +} diff --git a/tests/UnitTests/NoaaClient/Utilities/NoaaJsonSanitizerTests.cs b/tests/UnitTests/NoaaClient/Utilities/NoaaJsonSanitizerTests.cs new file mode 100644 index 0000000..109ecc9 --- /dev/null +++ b/tests/UnitTests/NoaaClient/Utilities/NoaaJsonSanitizerTests.cs @@ -0,0 +1,91 @@ +using System.Text; +using AuroraScienceHub.Integrations.NoaaClient.Utilities; +using Shouldly; + +namespace AuroraScienceHub.Integrations.UnitTests.NoaaClient.Utilities; + +/// +/// Unit tests for . +/// +public sealed class NoaaJsonSanitizerTests +{ + [Fact(DisplayName = "Sanitize replaces bare NaN with null")] + public void Sanitize_WhenBareNaN_ReplacesWithNull() + { + // Arrange + var source = Encoding.UTF8.GetBytes("""{"proton_speed": NaN}"""); + + // Act + var result = NoaaJsonSanitizer.Sanitize(source); + + // Assert + result.ReplacementCount.ShouldBe(1); + result.Bytes.Span.ToArray().ShouldBe(Encoding.UTF8.GetBytes("""{"proton_speed": null}""")); + } + + [Fact(DisplayName = "Sanitize replaces bare -Infinity with null")] + public void Sanitize_WhenBareNegativeInfinity_ReplacesWithNull() + { + // Arrange + var source = Encoding.UTF8.GetBytes("""{"proton_speed": -Infinity}"""); + + // Act + var result = NoaaJsonSanitizer.Sanitize(source); + + // Assert + result.ReplacementCount.ShouldBe(1); + result.Bytes.Span.ToArray().ShouldBe(Encoding.UTF8.GetBytes("""{"proton_speed": null}""")); + } + + [Fact(DisplayName = "Sanitize replaces quoted NaN with null")] + public void Sanitize_WhenQuotedNaN_ReplacesWithNull() + { + // Arrange + var source = Encoding.UTF8.GetBytes("""{"proton_speed": "NaN"}"""); + + // Act + var result = NoaaJsonSanitizer.Sanitize(source); + + // Assert + result.ReplacementCount.ShouldBe(1); + result.Bytes.Span.ToArray().ShouldBe(Encoding.UTF8.GetBytes("""{"proton_speed": null}""")); + } + + [Fact(DisplayName = "Sanitize does not modify NaN inside string values")] + public void Sanitize_WhenNaNInsideStringValue_DoesNotModify() + { + // Arrange + var source = Encoding.UTF8.GetBytes("""{"source": "NaN sensor"}"""); + + // Act + var result = NoaaJsonSanitizer.Sanitize(source); + + // Assert + result.ReplacementCount.ShouldBe(0); + result.Bytes.ShouldBe(source); + } + + [Fact(DisplayName = "Sanitize leaves valid JSON unchanged")] + public void Sanitize_WhenValidJson_ReturnsOriginalMemory() + { + // Arrange + var source = Encoding.UTF8.GetBytes(""" + [ + { + "time_tag": "2026-08-10T09:45:00Z", + "active": true, + "source": "SOLAR1", + "proton_speed": 420.0, + "proton_density": 5.0 + } + ] + """); + + // Act + var result = NoaaJsonSanitizer.Sanitize(source); + + // Assert + result.ReplacementCount.ShouldBe(0); + result.Bytes.ShouldBe(source); + } +}