using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.TestHost; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; using NSubstitute; using ZB.MOM.WW.ScadaBridge.Commons.Entities.InboundApi; using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories; using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services; using ZB.MOM.WW.ScadaBridge.Commons.Types.InboundApi; using System.Net; using System.Net.Http.Headers; using System.Text; namespace ZB.MOM.WW.ScadaBridge.InboundAPI.Tests; /// /// InboundAPI-020: the inbound API handler must accept JSON content types /// case-insensitively. A request with application/JSON, /// Application/Json, or application/json must all enter the /// JSON-deserialization path — the previous Contains("json") check /// was case-sensitive so a capitalised value silently skipped body parsing /// and any required parameters surfaced as a 400 even though the caller /// sent a valid JSON body. /// public class EndpointContentTypeTests { /// /// Stub hasher that returns its input unchanged. Lets the test pre-seed the /// repository with a known "hash" value without depending on the real /// HMAC-with-pepper hasher. /// private sealed class IdentityHasher : IApiKeyHasher { public string Hash(string keyValue) => keyValue; } [Theory] [InlineData("application/json")] [InlineData("application/JSON")] [InlineData("Application/Json")] [InlineData("APPLICATION/JSON")] public async Task ContentTypeCheck_IsCaseInsensitive_ParsesBodyForAnyCasing(string contentType) { const string apiKeyValue = "test-key"; const string methodName = "echoParam"; var key = ApiKey.FromHash("test", apiKeyValue); key.IsEnabled = true; key.Id = 1; var method = new ApiMethod(methodName, "return Parameters[\"value\"];") { Id = 1, TimeoutSeconds = 10, // One Integer parameter, required — proves the body was actually // parsed: if the case-sensitive bug returns, body parsing is // skipped and the validator reports the missing field as a 400. ParameterDefinitions = """[{"name":"value","type":"Integer","required":true}]""", }; var repo = Substitute.For(); repo.GetAllApiKeysAsync(Arg.Any()) .Returns(new List { key }); repo.GetMethodByNameAsync(methodName, Arg.Any()) .Returns(method); repo.GetApprovedKeysForMethodAsync(method.Id, Arg.Any()) .Returns(new List { key }); using var host = await BuildHostAsync(repo); var client = host.GetTestClient(); var request = new HttpRequestMessage(HttpMethod.Post, "/api/" + methodName) { // Bypass HttpClient's MediaTypeHeaderValue auto-normalization by // setting the header through TryAddWithoutValidation — we need the // exact casing reach the server intact. Content = new ByteArrayContent(Encoding.UTF8.GetBytes("{\"value\":42}")) }; request.Content.Headers.ContentType = MediaTypeHeaderValue.Parse(contentType); request.Headers.Add("X-API-Key", apiKeyValue); var response = await client.SendAsync(request); var body = await response.Content.ReadAsStringAsync(); Assert.True( response.StatusCode == HttpStatusCode.OK, $"Expected 200 for content-type '{contentType}' but got {(int)response.StatusCode}: {body}"); Assert.Contains("42", body); } private static async Task BuildHostAsync(IInboundApiRepository repo) { var hostBuilder = new HostBuilder() .ConfigureWebHost(webBuilder => { webBuilder .UseTestServer() .ConfigureServices(services => { services.AddRouting(); services.AddSingleton(repo); // RouteHelper depends on IInstanceLocator + IInstanceRouter // (InboundAPI-017). Tests for content-type handling never // route, so both can be no-op stubs — the production // CommunicationServiceInstanceRouter would need a real // CommunicationService which isn't wired here. services.AddSingleton(Substitute.For()); services.Configure(_ => { }); services.AddInboundAPI(); services.RemoveAll(); services.AddSingleton(Substitute.For()); // The production AddInboundAPI registration of IApiKeyHasher // requires a configured pepper. Replace it with the identity // stub so the seeded ApiKey.KeyHash matches "test-key" // deterministically without depending on configuration. services.RemoveAll(); services.AddSingleton(new IdentityHasher()); services.AddLogging(); }) .Configure(app => { app.UseRouting(); app.UseEndpoints(endpoints => endpoints.MapInboundAPI()); }); }); return await hostBuilder.StartAsync(); } }