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.Types.InboundApi; using ZB.MOM.WW.ScadaBridge.InboundAPI.Middleware; using System.Net; using System.Text; namespace ZB.MOM.WW.ScadaBridge.InboundAPI.Tests; /// /// InboundAPI-023: 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 /// HttpContext.Items[AuditWriteMiddleware.AuditActorItemKey] (so the /// AuditWriteMiddleware sees a non-null Actor when it emits the audit row). /// public class EndpointExtensionsTests { /// /// Stub hasher that returns its input unchanged. Same pattern as /// — lets us seed an ApiKey with a /// known "hash" without depending on the configured HMAC pepper. /// private sealed class IdentityHasher : IApiKeyHasher { public string Hash(string keyValue) => keyValue; } /// /// Inline middleware that captures the value at /// HttpContext.Items[AuditWriteMiddleware.AuditActorItemKey] after the /// inbound endpoint runs, so the actor-stash invariant can be asserted from /// the test without running the real AuditWriteMiddleware. /// 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); } 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 BuildHostAsync( ApiKey key, ApiMethod method, Action? customize = null, Action? additionalServices = null) { var repo = Substitute.For(); repo.GetAllApiKeysAsync(Arg.Any()) .Returns(new List { key }); repo.GetMethodByNameAsync(method.Name, Arg.Any()) .Returns(method); repo.GetApprovedKeysForMethodAsync(method.Id, Arg.Any()) .Returns(new List { key }); var hostBuilder = new HostBuilder() .ConfigureWebHost(webBuilder => { webBuilder .UseTestServer() .ConfigureServices(services => { services.AddRouting(); services.AddSingleton(repo); services.AddSingleton(Substitute.For()); services.Configure(_ => { }); services.AddInboundAPI(); // Replace the production CommunicationService-backed // router and the configured HMAC hasher with test stubs // (same pattern as EndpointContentTypeTests). services.RemoveAll(); services.AddSingleton(Substitute.For()); services.RemoveAll(); services.AddSingleton(new IdentityHasher()); services.AddLogging(); additionalServices?.Invoke(services); }) .Configure(app => { app.UseRouting(); customize?.Invoke(app); app.UseEndpoints(endpoints => endpoints.MapInboundAPI()); }); }); return await hostBuilder.StartAsync(); } }