using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.TestHost; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Options; using NSubstitute; using ZB.MOM.WW.Auth.Abstractions.ApiKeys; using ZB.MOM.WW.Auth.ApiKeys; using ZB.MOM.WW.Auth.ApiKeys.Admin; using ZB.MOM.WW.Auth.ApiKeys.DependencyInjection; using ZB.MOM.WW.Auth.ApiKeys.Sqlite; 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 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. /// /// /// Auth re-arch (A+B): the request carries a Bearer token verified by the shared /// ZB.MOM.WW.Auth.ApiKeys verifier (scope == method name), not the legacy X-API-Key /// header. The content-type behaviour under test is downstream of auth and unchanged. /// /// public sealed class EndpointContentTypeTests : IDisposable { private const string Pepper = "test-pepper-at-least-16-chars-long"; private const string PepperConfigKey = "ScadaBridge:InboundApi:ApiKeyPepper"; private const string TokenPrefix = "sbk"; private const string ApiKeyStoreSection = "ScadaBridge:InboundApi:ApiKeyStore"; private readonly string _sqlitePath = Path.Combine(Path.GetTempPath(), $"inbound-api-keys-ct-{Guid.NewGuid():N}.sqlite"); [Theory] [InlineData("application/json")] [InlineData("application/JSON")] [InlineData("Application/Json")] [InlineData("APPLICATION/JSON")] public async Task ContentTypeCheck_IsCaseInsensitive_ParsesBodyForAnyCasing(string contentType) { const string methodName = "echoParam"; 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.GetMethodByNameAsync(methodName, Arg.Any()) .Returns(method); using var host = await BuildHostAsync(repo); var token = await SeedKeyAsync(host, methodName); var client = host.GetTestClient(); var request = new HttpRequestMessage(HttpMethod.Post, "/api/" + methodName) { // Bypass HttpClient's MediaTypeHeaderValue auto-normalization by // setting the header through MediaTypeHeaderValue.Parse — we need the // exact casing to reach the server intact. Content = new ByteArrayContent(Encoding.UTF8.GetBytes("{\"value\":42}")) }; request.Content.Headers.ContentType = MediaTypeHeaderValue.Parse(contentType); request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token); 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); } /// Seeds a key scoped for and returns its Bearer token. private static async Task SeedKeyAsync(IHost host, string methodName) { var services = host.Services; var commands = new ApiKeyAdminCommands( services.GetRequiredService>().Value, services.GetRequiredService(), services.GetRequiredService(), services.GetRequiredService(), services.GetRequiredService()); var result = await commands.CreateKeyAsync( "key1", "ct-caller", new HashSet { methodName }, constraintsJson: null, remoteAddress: null, CancellationToken.None); return result.Token!; } private async Task BuildHostAsync(IInboundApiRepository repo) { // The pepper provider reads the HOST's IConfiguration (AddZbApiKeyAuth only // TryAdds its own), so the api-key settings — pepper included — must live in // the host configuration. var apiKeySettings = new Dictionary { [PepperConfigKey] = Pepper, [$"{ApiKeyStoreSection}:TokenPrefix"] = TokenPrefix, [$"{ApiKeyStoreSection}:PepperSecretName"] = PepperConfigKey, [$"{ApiKeyStoreSection}:SqlitePath"] = _sqlitePath, [$"{ApiKeyStoreSection}:RunMigrationsOnStartup"] = "true", }; var hostBuilder = new HostBuilder() .ConfigureAppConfiguration(config => config.AddInMemoryCollection(apiKeySettings)) .ConfigureWebHost(webBuilder => { webBuilder .UseTestServer() .ConfigureServices((context, 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. services.AddSingleton(Substitute.For()); services.Configure(_ => { }); services.AddInboundAPI(); services.AddZbApiKeyAuth(context.Configuration, ApiKeyStoreSection); services.RemoveAll(); services.AddSingleton(Substitute.For()); services.AddLogging(); }) .Configure(app => { app.UseRouting(); app.UseEndpoints(endpoints => endpoints.MapInboundAPI()); }); }); return await hostBuilder.StartAsync(); } public void Dispose() { try { Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools(); foreach (var suffix in new[] { "", "-wal", "-shm" }) { var path = _sqlitePath + suffix; if (File.Exists(path)) { File.Delete(path); } } } catch { // Best-effort cleanup. } } }