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