316 lines
13 KiB
C#
316 lines
13 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);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task NonJsonContentType_WithBody_Returns415()
|
|
{
|
|
// Task 5 (S9): a body with a non-JSON Content-Type must be rejected with 415 +
|
|
// UNSUPPORTED_MEDIA_TYPE rather than a misleading 400 "invalid JSON". A body
|
|
// with NO Content-Type header is still parsed leniently as JSON (preserving
|
|
// existing callers).
|
|
const string methodName = "echo415";
|
|
|
|
var method = new ApiMethod(methodName, "return Parameters[\"value\"];")
|
|
{
|
|
Id = 1,
|
|
TimeoutSeconds = 10,
|
|
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();
|
|
|
|
// (a) text/plain body → 415 UNSUPPORTED_MEDIA_TYPE
|
|
var nonJson = new HttpRequestMessage(HttpMethod.Post, "/api/" + methodName)
|
|
{
|
|
Content = new ByteArrayContent(Encoding.UTF8.GetBytes("hello")),
|
|
};
|
|
nonJson.Content.Headers.ContentType = MediaTypeHeaderValue.Parse("text/plain");
|
|
nonJson.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
|
|
|
var nonJsonResponse = await client.SendAsync(nonJson);
|
|
var nonJsonBody = await nonJsonResponse.Content.ReadAsStringAsync();
|
|
|
|
Assert.Equal(HttpStatusCode.UnsupportedMediaType, nonJsonResponse.StatusCode);
|
|
Assert.Contains("UNSUPPORTED_MEDIA_TYPE", nonJsonBody);
|
|
|
|
// (b) body with NO Content-Type header → still parses as JSON (lenient), 200.
|
|
var noContentType = new HttpRequestMessage(HttpMethod.Post, "/api/" + methodName)
|
|
{
|
|
Content = new ByteArrayContent(Encoding.UTF8.GetBytes("{\"value\":42}")),
|
|
};
|
|
noContentType.Content.Headers.ContentType = null;
|
|
noContentType.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
|
|
|
var lenientResponse = await client.SendAsync(noContentType);
|
|
var lenientBody = await lenientResponse.Content.ReadAsStringAsync();
|
|
|
|
Assert.Equal(HttpStatusCode.OK, lenientResponse.StatusCode);
|
|
Assert.Contains("42", lenientBody);
|
|
}
|
|
|
|
/// <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.
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ChunkedNonJsonBody_Returns415()
|
|
{
|
|
// Arch-review R2 N5: a chunked body has NULL ContentLength — it must not
|
|
// slip past the 415 guard into a misleading VALIDATION_FAILED.
|
|
const string methodName = "echoChunked";
|
|
var method = new ApiMethod(methodName, "return Parameters[\"value\"];")
|
|
{
|
|
Id = 1,
|
|
TimeoutSeconds = 10,
|
|
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)
|
|
{
|
|
Content = new ChunkedByteContent(Encoding.UTF8.GetBytes("hello")),
|
|
};
|
|
request.Content.Headers.ContentType = MediaTypeHeaderValue.Parse("text/plain");
|
|
request.Headers.TransferEncodingChunked = true;
|
|
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
|
|
|
var response = await client.SendAsync(request);
|
|
var body = await response.Content.ReadAsStringAsync();
|
|
|
|
Assert.Equal(HttpStatusCode.UnsupportedMediaType, response.StatusCode);
|
|
Assert.Contains("UNSUPPORTED_MEDIA_TYPE", body);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ChunkedJsonBody_NoContentType_StillParsesLeniently()
|
|
{
|
|
// The parse condition must also learn about chunked bodies: a chunked JSON
|
|
// body with no Content-Type previously skipped parsing entirely (body=null →
|
|
// misleading "missing required parameter").
|
|
const string methodName = "echoChunkedJson";
|
|
var method = new ApiMethod(methodName, "return Parameters[\"value\"];")
|
|
{
|
|
Id = 1,
|
|
TimeoutSeconds = 10,
|
|
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)
|
|
{
|
|
Content = new ChunkedByteContent(Encoding.UTF8.GetBytes("{\"value\":42}")),
|
|
};
|
|
request.Content.Headers.ContentType = null;
|
|
request.Headers.TransferEncodingChunked = true;
|
|
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
|
|
|
var response = await client.SendAsync(request);
|
|
var body = await response.Content.ReadAsStringAsync();
|
|
|
|
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
|
Assert.Contains("42", body);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Content whose length is never computable, forcing HttpClient to transmit it
|
|
/// chunked (no Content-Length header ever reaches the server).
|
|
/// </summary>
|
|
private sealed class ChunkedByteContent(byte[] bytes) : HttpContent
|
|
{
|
|
protected override Task SerializeToStreamAsync(
|
|
Stream stream, System.Net.TransportContext? context) =>
|
|
stream.WriteAsync(bytes, 0, bytes.Length);
|
|
|
|
protected override bool TryComputeLength(out long length)
|
|
{
|
|
length = -1;
|
|
return false;
|
|
}
|
|
}
|
|
}
|