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 ScadaLink.Commons.Entities.InboundApi;
using ScadaLink.Commons.Interfaces.Repositories;
using ScadaLink.Commons.Interfaces.Services;
using ScadaLink.Commons.Types.InboundApi;
using ScadaLink.InboundAPI.Middleware;
using System.Net;
using System.Text;
namespace ScadaLink.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();
}
}