402 lines
16 KiB
C#
402 lines
16 KiB
C#
using Microsoft.AspNetCore.Builder;
|
|
using Microsoft.AspNetCore.Hosting;
|
|
using Microsoft.AspNetCore.Http;
|
|
using Microsoft.AspNetCore.TestHost;
|
|
using Microsoft.Extensions.DependencyInjection;
|
|
using Microsoft.Extensions.DependencyInjection.Extensions;
|
|
using Microsoft.Extensions.Hosting;
|
|
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.Observability;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Types.InboundApi;
|
|
using ZB.MOM.WW.ScadaBridge.InboundAPI.Middleware;
|
|
using System.Diagnostics.Metrics;
|
|
using System.Net;
|
|
using System.Text;
|
|
|
|
namespace ZB.MOM.WW.ScadaBridge.InboundAPI.Tests;
|
|
|
|
/// <summary>
|
|
/// InboundAPI-023: <see cref="EndpointExtensions.HandleInboundApiRequest"/> is
|
|
/// the composition wiring that ties validator → JSON parse → ParameterValidator →
|
|
/// InboundScriptExecutor → response shaping together. Each composed component
|
|
/// has its own unit tests, but the wiring itself was uncovered. These tests
|
|
/// drive the end-to-end POST /api/{methodName} flow through a TestServer so a
|
|
/// regression in any of the seams below would be caught here:
|
|
///
|
|
/// 1. happy path — 200 + script result body
|
|
/// 2. auth failures — validator status code propagates verbatim
|
|
/// 3. invalid JSON body — 400 + sanitized error
|
|
/// 4. parameter validation failure — 400 + ParameterValidator's error message
|
|
/// 5. script failure — 500 + ErrorMessage in body
|
|
/// 6. successful auth must publish the resolved API key name into
|
|
/// <c>HttpContext.Items[AuditWriteMiddleware.AuditActorItemKey]</c> (so the
|
|
/// AuditWriteMiddleware sees a non-null Actor when it emits the audit row).
|
|
/// </summary>
|
|
public class EndpointExtensionsTests
|
|
{
|
|
/// <summary>
|
|
/// Stub hasher that returns its input unchanged. Same pattern as
|
|
/// <see cref="EndpointContentTypeTests"/> — lets us seed an ApiKey with a
|
|
/// known "hash" without depending on the configured HMAC pepper.
|
|
/// </summary>
|
|
private sealed class IdentityHasher : IApiKeyHasher
|
|
{
|
|
public string Hash(string keyValue) => keyValue;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Inline middleware that captures the value at
|
|
/// <c>HttpContext.Items[AuditWriteMiddleware.AuditActorItemKey]</c> after the
|
|
/// inbound endpoint runs, so the actor-stash invariant can be asserted from
|
|
/// the test without running the real AuditWriteMiddleware.
|
|
/// </summary>
|
|
private sealed class AuditActorCapture
|
|
{
|
|
public string? CapturedActor { get; set; }
|
|
}
|
|
|
|
private const string ApiKeyValue = "test-key";
|
|
|
|
private static ApiKey SeedKey(int id = 1, string name = "test")
|
|
{
|
|
var key = ApiKey.FromHash(name, ApiKeyValue);
|
|
key.IsEnabled = true;
|
|
key.Id = id;
|
|
return key;
|
|
}
|
|
|
|
private static ApiMethod SeedMethod(
|
|
int id, string name, string script, string? paramDefs = null)
|
|
{
|
|
return new ApiMethod(name, script)
|
|
{
|
|
Id = id,
|
|
TimeoutSeconds = 10,
|
|
ParameterDefinitions = paramDefs,
|
|
};
|
|
}
|
|
|
|
[Fact]
|
|
public async Task HappyPath_Returns200WithScriptResultJson()
|
|
{
|
|
var key = SeedKey();
|
|
var method = SeedMethod(1, "echo", "return Parameters[\"value\"];",
|
|
"""[{"name":"value","type":"Integer","required":true}]""");
|
|
|
|
using var host = await BuildHostAsync(key, method);
|
|
var client = host.GetTestClient();
|
|
|
|
var request = BuildPost("echo", """{"value":7}""");
|
|
var response = await client.SendAsync(request);
|
|
var body = await response.Content.ReadAsStringAsync();
|
|
|
|
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
|
Assert.Contains("7", body);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task MissingApiKey_Returns401()
|
|
{
|
|
var key = SeedKey();
|
|
var method = SeedMethod(1, "noKey", "return 1;");
|
|
|
|
using var host = await BuildHostAsync(key, method);
|
|
var client = host.GetTestClient();
|
|
|
|
// No X-API-Key header — auth should reject with 401.
|
|
var request = new HttpRequestMessage(HttpMethod.Post, "/api/noKey")
|
|
{
|
|
Content = new StringContent("{}", Encoding.UTF8, "application/json"),
|
|
};
|
|
var response = await client.SendAsync(request);
|
|
|
|
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task UnknownMethod_Returns403_IndistinguishableFromNotApproved()
|
|
{
|
|
// InboundAPI-011: method existence is intentionally not observable —
|
|
// both "method not found" and "key not approved" surface as 403.
|
|
var key = SeedKey();
|
|
var method = SeedMethod(1, "knownMethod", "return 1;");
|
|
|
|
using var host = await BuildHostAsync(key, method);
|
|
var client = host.GetTestClient();
|
|
|
|
var request = BuildPost("unknownMethod", "{}");
|
|
var response = await client.SendAsync(request);
|
|
|
|
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task InvalidJsonBody_Returns400()
|
|
{
|
|
var key = SeedKey();
|
|
var method = SeedMethod(1, "badJson", "return 1;");
|
|
|
|
using var host = await BuildHostAsync(key, method);
|
|
var client = host.GetTestClient();
|
|
|
|
var request = BuildPost("badJson", "{ not json");
|
|
var response = await client.SendAsync(request);
|
|
var body = await response.Content.ReadAsStringAsync();
|
|
|
|
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
|
Assert.Contains("Invalid JSON", body);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task MissingRequiredParameter_Returns400_FromParameterValidator()
|
|
{
|
|
var key = SeedKey();
|
|
var method = SeedMethod(1, "needsParam", "return Parameters[\"value\"];",
|
|
"""[{"name":"value","type":"Integer","required":true}]""");
|
|
|
|
using var host = await BuildHostAsync(key, method);
|
|
var client = host.GetTestClient();
|
|
|
|
// Body is empty object — required parameter "value" is missing.
|
|
var request = BuildPost("needsParam", "{}");
|
|
var response = await client.SendAsync(request);
|
|
var body = await response.Content.ReadAsStringAsync();
|
|
|
|
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
|
// ParameterValidator's error message is surfaced.
|
|
Assert.Contains("value", body);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ScriptThrows_Returns500_WithSanitizedErrorBody()
|
|
{
|
|
var key = SeedKey();
|
|
// Throws inside the script body — InboundScriptExecutor catches the
|
|
// exception, logs it server-side, and surfaces the generic "Internal
|
|
// script error" message to the caller (the executor deliberately does
|
|
// not leak raw exception details — see InboundScriptExecutor.ExecuteAsync's
|
|
// catch block). The endpoint maps the script failure to HTTP 500.
|
|
var method = SeedMethod(1, "boom",
|
|
"""throw new System.InvalidOperationException("boom-msg");""");
|
|
|
|
using var host = await BuildHostAsync(key, method);
|
|
var client = host.GetTestClient();
|
|
|
|
var request = BuildPost("boom", "{}");
|
|
var response = await client.SendAsync(request);
|
|
var body = await response.Content.ReadAsStringAsync();
|
|
|
|
Assert.Equal(HttpStatusCode.InternalServerError, response.StatusCode);
|
|
// Wiring contract: error body is JSON-shaped and the raw exception
|
|
// message is not leaked (the executor sanitises before this point).
|
|
Assert.Contains("error", body);
|
|
Assert.DoesNotContain("boom-msg", body);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task SuccessfulAuth_StashesResolvedApiKeyNameOnHttpContextItems()
|
|
{
|
|
// InboundAPI-023: the handler stashes the resolved API key's display name
|
|
// at HttpContext.Items[AuditWriteMiddleware.AuditActorItemKey] AFTER auth
|
|
// succeeded, so AuditWriteMiddleware sees a populated Actor when it
|
|
// emits the audit row. A capture middleware reads the slot once the
|
|
// endpoint finishes, proving the wiring still publishes it.
|
|
var key = SeedKey(id: 99, name: "audit-actor-name");
|
|
var method = SeedMethod(1, "stamp", "return 1;");
|
|
|
|
var capture = new AuditActorCapture();
|
|
|
|
using var host = await BuildHostAsync(key, method, customize: builder =>
|
|
{
|
|
builder.Use(async (ctx, next) =>
|
|
{
|
|
await next();
|
|
if (ctx.Items.TryGetValue(
|
|
AuditWriteMiddleware.AuditActorItemKey, out var stashed)
|
|
&& stashed is string actorName)
|
|
{
|
|
capture.CapturedActor = actorName;
|
|
}
|
|
});
|
|
}, additionalServices: services =>
|
|
{
|
|
services.AddSingleton(capture);
|
|
});
|
|
var client = host.GetTestClient();
|
|
|
|
var request = BuildPost("stamp", "{}");
|
|
var response = await client.SendAsync(request);
|
|
|
|
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
|
Assert.Equal("audit-actor-name", capture.CapturedActor);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ValidRequest_EmitsInboundApiRequestCounter_TaggedWithResolvedMethodName()
|
|
{
|
|
// Telemetry follow-on: a successful inbound request increments
|
|
// scadabridge.inbound_api.requests once, tagged with the resolved,
|
|
// registered method name (method.Name) — the bounded identifier, not the
|
|
// raw route value.
|
|
var key = SeedKey();
|
|
var method = SeedMethod(1, "echo", "return Parameters[\"value\"];",
|
|
"""[{"name":"value","type":"Integer","required":true}]""");
|
|
|
|
using var collector = new InboundApiRequestCounterCollector();
|
|
|
|
using var host = await BuildHostAsync(key, method);
|
|
var client = host.GetTestClient();
|
|
|
|
var response = await client.SendAsync(BuildPost("echo", """{"value":7}"""));
|
|
|
|
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
|
// Filter by the method tag this test produced: the counter is a process-wide
|
|
// static, so a parallel test class could otherwise leak measurements in.
|
|
var echoTotal = collector.Measurements
|
|
.Where(m => m.Method == "echo")
|
|
.Sum(m => m.Value);
|
|
Assert.Equal(1, echoTotal);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task UnknownMethod_EmitsInboundApiRequestCounter_WithBoundedForbiddenSentinel()
|
|
{
|
|
// Telemetry follow-on: an auth/authz failure is still counted, but the
|
|
// tag is a bounded sentinel ("<forbidden>") rather than the arbitrary
|
|
// caller-supplied route value — so an attacker posting random method
|
|
// names cannot blow up the `method` tag cardinality.
|
|
var key = SeedKey();
|
|
var method = SeedMethod(1, "knownMethod", "return 1;");
|
|
|
|
using var collector = new InboundApiRequestCounterCollector();
|
|
|
|
using var host = await BuildHostAsync(key, method);
|
|
var client = host.GetTestClient();
|
|
|
|
var response = await client.SendAsync(BuildPost("totally-made-up-name", "{}"));
|
|
|
|
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
|
|
var measurements = collector.Measurements;
|
|
// Cardinality safety: the arbitrary route value is never used as a tag.
|
|
Assert.DoesNotContain(measurements, m => m.Method == "totally-made-up-name");
|
|
// The failure path counts the request against the bounded sentinel.
|
|
Assert.Contains(measurements, m => m.Method == "<forbidden>" && m.Value == 1);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Captures <c>scadabridge.inbound_api.requests</c> measurements (value + the
|
|
/// <c>method</c> tag) via a <see cref="MeterListener"/> for the duration of a test.
|
|
/// </summary>
|
|
private sealed class InboundApiRequestCounterCollector : IDisposable
|
|
{
|
|
private readonly MeterListener _listener;
|
|
private readonly List<(long Value, string? Method)> _measurements = new();
|
|
private readonly object _gate = new();
|
|
|
|
public InboundApiRequestCounterCollector()
|
|
{
|
|
_listener = new MeterListener
|
|
{
|
|
InstrumentPublished = (instrument, listener) =>
|
|
{
|
|
if (instrument.Meter.Name == ScadaBridgeTelemetry.MeterName
|
|
&& instrument.Name == "scadabridge.inbound_api.requests")
|
|
{
|
|
listener.EnableMeasurementEvents(instrument);
|
|
}
|
|
},
|
|
};
|
|
_listener.SetMeasurementEventCallback<long>((instrument, value, tags, state) =>
|
|
{
|
|
string? method = null;
|
|
foreach (var tag in tags)
|
|
{
|
|
if (tag.Key == "method")
|
|
{
|
|
method = tag.Value as string;
|
|
}
|
|
}
|
|
|
|
lock (_gate)
|
|
{
|
|
_measurements.Add((value, method));
|
|
}
|
|
});
|
|
_listener.Start();
|
|
}
|
|
|
|
public IReadOnlyList<(long Value, string? Method)> Measurements
|
|
{
|
|
get
|
|
{
|
|
lock (_gate)
|
|
{
|
|
return _measurements.ToList();
|
|
}
|
|
}
|
|
}
|
|
|
|
public void Dispose() => _listener.Dispose();
|
|
}
|
|
|
|
private static HttpRequestMessage BuildPost(string methodName, string body)
|
|
{
|
|
var request = new HttpRequestMessage(HttpMethod.Post, "/api/" + methodName)
|
|
{
|
|
Content = new StringContent(body, Encoding.UTF8, "application/json"),
|
|
};
|
|
request.Headers.Add("X-API-Key", ApiKeyValue);
|
|
return request;
|
|
}
|
|
|
|
private static async Task<IHost> BuildHostAsync(
|
|
ApiKey key,
|
|
ApiMethod method,
|
|
Action<IApplicationBuilder>? customize = null,
|
|
Action<IServiceCollection>? additionalServices = null)
|
|
{
|
|
var repo = Substitute.For<IInboundApiRepository>();
|
|
repo.GetAllApiKeysAsync(Arg.Any<CancellationToken>())
|
|
.Returns(new List<ApiKey> { key });
|
|
repo.GetMethodByNameAsync(method.Name, Arg.Any<CancellationToken>())
|
|
.Returns(method);
|
|
repo.GetApprovedKeysForMethodAsync(method.Id, Arg.Any<CancellationToken>())
|
|
.Returns(new List<ApiKey> { key });
|
|
|
|
var hostBuilder = new HostBuilder()
|
|
.ConfigureWebHost(webBuilder =>
|
|
{
|
|
webBuilder
|
|
.UseTestServer()
|
|
.ConfigureServices(services =>
|
|
{
|
|
services.AddRouting();
|
|
services.AddSingleton(repo);
|
|
services.AddSingleton(Substitute.For<IInstanceLocator>());
|
|
services.Configure<InboundApiOptions>(_ => { });
|
|
services.AddInboundAPI();
|
|
// Replace the production CommunicationService-backed
|
|
// router and the configured HMAC hasher with test stubs
|
|
// (same pattern as EndpointContentTypeTests).
|
|
services.RemoveAll<IInstanceRouter>();
|
|
services.AddSingleton(Substitute.For<IInstanceRouter>());
|
|
services.RemoveAll<IApiKeyHasher>();
|
|
services.AddSingleton<IApiKeyHasher>(new IdentityHasher());
|
|
services.AddLogging();
|
|
additionalServices?.Invoke(services);
|
|
})
|
|
.Configure(app =>
|
|
{
|
|
app.UseRouting();
|
|
customize?.Invoke(app);
|
|
app.UseEndpoints(endpoints => endpoints.MapInboundAPI());
|
|
});
|
|
});
|
|
|
|
return await hostBuilder.StartAsync();
|
|
}
|
|
}
|