Files
ScadaBridge/tests/ZB.MOM.WW.ScadaBridge.InboundAPI.Tests/EndpointContentTypeTests.cs
T

177 lines
7.4 KiB
C#

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;
/// <summary>
/// InboundAPI-020: the inbound API handler must accept JSON content types
/// case-insensitively. A request with <c>application/JSON</c>,
/// <c>Application/Json</c>, or <c>application/json</c> must all enter the
/// JSON-deserialization path — the previous <c>Contains("json")</c> 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.
///
/// <para>
/// 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.
/// </para>
/// </summary>
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<IInboundApiRepository>();
repo.GetMethodByNameAsync(methodName, Arg.Any<CancellationToken>())
.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);
}
/// <summary>Seeds a key scoped for <paramref name="methodName"/> and returns its Bearer token.</summary>
private static async Task<string> SeedKeyAsync(IHost host, string methodName)
{
var services = host.Services;
var commands = new ApiKeyAdminCommands(
services.GetRequiredService<IOptions<ApiKeyOptions>>().Value,
services.GetRequiredService<IApiKeyAdminStore>(),
services.GetRequiredService<IApiKeyAuditStore>(),
services.GetRequiredService<IApiKeyPepperProvider>(),
services.GetRequiredService<SqliteAuthStoreMigrator>());
var result = await commands.CreateKeyAsync(
"key1", "ct-caller", new HashSet<string> { methodName },
constraintsJson: null, remoteAddress: null, CancellationToken.None);
return result.Token!;
}
private async Task<IHost> 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<string, string?>
{
[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<IInstanceLocator>());
services.Configure<InboundApiOptions>(_ => { });
services.AddInboundAPI();
services.AddZbApiKeyAuth(context.Configuration, ApiKeyStoreSection);
services.RemoveAll<IInstanceRouter>();
services.AddSingleton(Substitute.For<IInstanceRouter>());
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.
}
}
}