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);
+ }
+}