refactor: rename ScadaLink → ZB.MOM.WW.ScadaBridge (code + projects + namespaces)
Solution + 23 src projects + 26 test projects renamed; folders, csproj, namespaces, and ScadaLinkDbContext/ScadaBridgeDbContext class updated. ActorSystem "scadalink" → "scadabridge", Akka seed-node URLs migrated. SQL roles/logins, LDAP domains, CLI command name, and CLI config dir (~/.scadalink → ~/.scadabridge) also renamed. Build green; 5 Host.Tests fail awaiting SQL login rename in next commit. Pre-existing StaleTagMonitor timing flakes unchanged. Rename script committed at tools/rename-to-scadabridge.sh.
This commit is contained in:
@@ -0,0 +1,96 @@
|
||||
using NSubstitute;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.InboundApi;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.InboundApi;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.InboundAPI.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// ConfigurationDatabase-012: <see cref="ApiKeyValidator"/> must authenticate by
|
||||
/// hashing the presented candidate with the same HMAC-SHA256 pepper used at
|
||||
/// creation, then comparing against the stored <see cref="ApiKey.KeyHash"/> — never
|
||||
/// against a plaintext key. The comparison stays constant-time.
|
||||
/// </summary>
|
||||
public class ApiKeyHashValidationTests
|
||||
{
|
||||
private const string Pepper = "a-sufficiently-long-server-side-pepper-value";
|
||||
|
||||
private readonly IInboundApiRepository _repository = Substitute.For<IInboundApiRepository>();
|
||||
|
||||
private static ApiKey StoredKey(ApiKeyHasher hasher, string liveKey, int id = 1, bool enabled = true)
|
||||
{
|
||||
var key = ApiKey.FromHash("MES-Production", hasher.Hash(liveKey));
|
||||
key.Id = id;
|
||||
key.IsEnabled = enabled;
|
||||
return key;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateAsync_WithPepperedHasher_AcceptsKeyHashedWithSamePepper()
|
||||
{
|
||||
var hasher = new ApiKeyHasher(Pepper);
|
||||
var stored = StoredKey(hasher, "live-secret-key");
|
||||
var method = new ApiMethod("ingest", "return 1;") { Id = 10 };
|
||||
|
||||
_repository.GetAllApiKeysAsync().Returns(new List<ApiKey> { stored });
|
||||
_repository.GetMethodByNameAsync("ingest").Returns(method);
|
||||
_repository.GetApprovedKeysForMethodAsync(10).Returns(new List<ApiKey> { stored });
|
||||
|
||||
var validator = new ApiKeyValidator(_repository, hasher);
|
||||
|
||||
var result = await validator.ValidateAsync("live-secret-key", "ingest");
|
||||
|
||||
Assert.True(result.IsValid);
|
||||
Assert.Equal(200, result.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateAsync_WrongKey_FailsEvenWhenItHashesToSomethingNonNull()
|
||||
{
|
||||
var hasher = new ApiKeyHasher(Pepper);
|
||||
var stored = StoredKey(hasher, "the-real-key");
|
||||
|
||||
_repository.GetAllApiKeysAsync().Returns(new List<ApiKey> { stored });
|
||||
|
||||
var validator = new ApiKeyValidator(_repository, hasher);
|
||||
|
||||
var result = await validator.ValidateAsync("a-wrong-key", "ingest");
|
||||
|
||||
Assert.False(result.IsValid);
|
||||
Assert.Equal(401, result.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateAsync_StoredHashIsNotThePlaintextKey()
|
||||
{
|
||||
// Sanity guard: the value the validator compares against must be a hash, not
|
||||
// the live secret — a DB dump must not yield a usable credential.
|
||||
var hasher = new ApiKeyHasher(Pepper);
|
||||
var stored = StoredKey(hasher, "live-secret-key");
|
||||
|
||||
Assert.NotEqual("live-secret-key", stored.KeyHash);
|
||||
|
||||
_repository.GetAllApiKeysAsync().Returns(new List<ApiKey> { stored });
|
||||
var validator = new ApiKeyValidator(_repository, hasher);
|
||||
|
||||
// Presenting the stored hash itself must NOT authenticate — only the live key does.
|
||||
var result = await validator.ValidateAsync(stored.KeyHash, "ingest");
|
||||
Assert.False(result.IsValid);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateAsync_KeyHashedUnderADifferentPepper_DoesNotAuthenticate()
|
||||
{
|
||||
var creationHasher = new ApiKeyHasher(Pepper);
|
||||
var stored = StoredKey(creationHasher, "live-secret-key");
|
||||
|
||||
_repository.GetAllApiKeysAsync().Returns(new List<ApiKey> { stored });
|
||||
|
||||
// A validator running with a different pepper cannot recognise the key.
|
||||
var otherHasher = new ApiKeyHasher("a-totally-different-server-side-pepper-val");
|
||||
var validator = new ApiKeyValidator(_repository, otherHasher);
|
||||
|
||||
var result = await validator.ValidateAsync("live-secret-key", "ingest");
|
||||
Assert.False(result.IsValid);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
using NSubstitute;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.InboundApi;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.InboundAPI.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// WP-1: Tests for API key validation — X-API-Key header, enabled/disabled keys,
|
||||
/// method approval.
|
||||
/// </summary>
|
||||
public class ApiKeyValidatorTests
|
||||
{
|
||||
private readonly IInboundApiRepository _repository = Substitute.For<IInboundApiRepository>();
|
||||
private readonly ApiKeyValidator _validator;
|
||||
|
||||
public ApiKeyValidatorTests()
|
||||
{
|
||||
_validator = new ApiKeyValidator(_repository);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MissingApiKey_Returns401()
|
||||
{
|
||||
var result = await _validator.ValidateAsync(null, "testMethod");
|
||||
Assert.False(result.IsValid);
|
||||
Assert.Equal(401, result.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EmptyApiKey_Returns401()
|
||||
{
|
||||
var result = await _validator.ValidateAsync("", "testMethod");
|
||||
Assert.False(result.IsValid);
|
||||
Assert.Equal(401, result.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InvalidApiKey_Returns401()
|
||||
{
|
||||
_repository.GetAllApiKeysAsync().Returns(new List<ApiKey>());
|
||||
|
||||
var result = await _validator.ValidateAsync("bad-key", "testMethod");
|
||||
Assert.False(result.IsValid);
|
||||
Assert.Equal(401, result.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DisabledApiKey_Returns401()
|
||||
{
|
||||
var key = new ApiKey("test", "valid-key") { Id = 1, IsEnabled = false };
|
||||
_repository.GetAllApiKeysAsync().Returns(new List<ApiKey> { key });
|
||||
|
||||
var result = await _validator.ValidateAsync("valid-key", "testMethod");
|
||||
Assert.False(result.IsValid);
|
||||
Assert.Equal(401, result.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidKey_MethodNotFound_IsIndistinguishableFromNotApproved()
|
||||
{
|
||||
// InboundAPI-011: a "method not found" response must not be observably
|
||||
// different from a "key not approved" response, or a caller holding any
|
||||
// valid key could enumerate which method names exist on the central API.
|
||||
var key = new ApiKey("test", "valid-key") { Id = 1, IsEnabled = true };
|
||||
var method = new ApiMethod("realMethod", "return 1;") { Id = 10 };
|
||||
|
||||
_repository.GetAllApiKeysAsync().Returns(new List<ApiKey> { key });
|
||||
_repository.GetMethodByNameAsync("nonExistent").Returns((ApiMethod?)null);
|
||||
_repository.GetMethodByNameAsync("realMethod").Returns(method);
|
||||
_repository.GetApprovedKeysForMethodAsync(10).Returns(new List<ApiKey>());
|
||||
|
||||
var notFound = await _validator.ValidateAsync("valid-key", "nonExistent");
|
||||
var notApproved = await _validator.ValidateAsync("valid-key", "realMethod");
|
||||
|
||||
Assert.False(notFound.IsValid);
|
||||
Assert.False(notApproved.IsValid);
|
||||
// Status code and error message must be identical so existence is not observable.
|
||||
Assert.Equal(notApproved.StatusCode, notFound.StatusCode);
|
||||
Assert.Equal(notApproved.ErrorMessage, notFound.ErrorMessage);
|
||||
Assert.Equal(403, notFound.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidKey_MethodNotFound_ErrorMessageDoesNotEchoMethodName()
|
||||
{
|
||||
// InboundAPI-011: the error body must not echo the caller-supplied method
|
||||
// name back verbatim (reflected-input) and must not confirm non-existence.
|
||||
var key = new ApiKey("test", "valid-key") { Id = 1, IsEnabled = true };
|
||||
_repository.GetAllApiKeysAsync().Returns(new List<ApiKey> { key });
|
||||
_repository.GetMethodByNameAsync("probe-XYZ").Returns((ApiMethod?)null);
|
||||
|
||||
var result = await _validator.ValidateAsync("valid-key", "probe-XYZ");
|
||||
|
||||
Assert.False(result.IsValid);
|
||||
Assert.DoesNotContain("probe-XYZ", result.ErrorMessage ?? string.Empty);
|
||||
Assert.DoesNotContain("not found", result.ErrorMessage ?? string.Empty,
|
||||
StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidKey_NotApprovedForMethod_Returns403()
|
||||
{
|
||||
var key = new ApiKey("test", "valid-key") { Id = 1, IsEnabled = true };
|
||||
var method = new ApiMethod("testMethod", "return 1;") { Id = 10 };
|
||||
|
||||
_repository.GetAllApiKeysAsync().Returns(new List<ApiKey> { key });
|
||||
_repository.GetMethodByNameAsync("testMethod").Returns(method);
|
||||
_repository.GetApprovedKeysForMethodAsync(10).Returns(new List<ApiKey>());
|
||||
|
||||
var result = await _validator.ValidateAsync("valid-key", "testMethod");
|
||||
Assert.False(result.IsValid);
|
||||
Assert.Equal(403, result.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidKey_ApprovedForMethod_ReturnsValid()
|
||||
{
|
||||
var key = new ApiKey("test", "valid-key") { Id = 1, IsEnabled = true };
|
||||
var method = new ApiMethod("testMethod", "return 1;") { Id = 10 };
|
||||
|
||||
_repository.GetAllApiKeysAsync().Returns(new List<ApiKey> { key });
|
||||
_repository.GetMethodByNameAsync("testMethod").Returns(method);
|
||||
_repository.GetApprovedKeysForMethodAsync(10).Returns(new List<ApiKey> { key });
|
||||
|
||||
var result = await _validator.ValidateAsync("valid-key", "testMethod");
|
||||
Assert.True(result.IsValid);
|
||||
Assert.Equal(200, result.StatusCode);
|
||||
Assert.Equal(key, result.ApiKey);
|
||||
Assert.Equal(method, result.Method);
|
||||
}
|
||||
|
||||
// --- InboundAPI-003: API key must not be matched with a non-constant-time
|
||||
// (timing-oracle) secret-equality lookup. ---
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateAsync_DoesNotUseSecretEqualityLookup()
|
||||
{
|
||||
// GetApiKeyByValueAsync translates to a SQL "WHERE KeyValue = @secret" early-exit
|
||||
// comparison — a timing side-channel. The validator must not call it.
|
||||
var key = new ApiKey("test", "valid-key") { Id = 1, IsEnabled = true };
|
||||
var method = new ApiMethod("testMethod", "return 1;") { Id = 10 };
|
||||
|
||||
_repository.GetAllApiKeysAsync().Returns(new List<ApiKey> { key });
|
||||
_repository.GetMethodByNameAsync("testMethod").Returns(method);
|
||||
_repository.GetApprovedKeysForMethodAsync(10).Returns(new List<ApiKey> { key });
|
||||
|
||||
await _validator.ValidateAsync("valid-key", "testMethod");
|
||||
|
||||
await _repository.DidNotReceive()
|
||||
.GetApiKeyByValueAsync(Arg.Any<string>(), Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateAsync_WrongKey_ConstantTimePath_Returns401()
|
||||
{
|
||||
var key = new ApiKey("test", "valid-key") { Id = 1, IsEnabled = true };
|
||||
_repository.GetAllApiKeysAsync().Returns(new List<ApiKey> { key });
|
||||
|
||||
var result = await _validator.ValidateAsync("wrong-key", "testMethod");
|
||||
|
||||
Assert.False(result.IsValid);
|
||||
Assert.Equal(401, result.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateAsync_KeyOfDifferentLength_Returns401()
|
||||
{
|
||||
// FixedTimeEquals over UTF-8 bytes must reject length mismatches without leaking.
|
||||
var key = new ApiKey("test", "valid-key") { Id = 1, IsEnabled = true };
|
||||
_repository.GetAllApiKeysAsync().Returns(new List<ApiKey> { key });
|
||||
|
||||
var result = await _validator.ValidateAsync("valid-key-with-extra", "testMethod");
|
||||
|
||||
Assert.False(result.IsValid);
|
||||
Assert.Equal(401, result.StatusCode);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.AspNetCore.TestHost;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
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 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.
|
||||
/// </summary>
|
||||
public class EndpointContentTypeTests
|
||||
{
|
||||
/// <summary>
|
||||
/// Stub hasher that returns its input unchanged. Lets the test pre-seed the
|
||||
/// repository with a known "hash" value without depending on the real
|
||||
/// HMAC-with-pepper hasher.
|
||||
/// </summary>
|
||||
private sealed class IdentityHasher : IApiKeyHasher
|
||||
{
|
||||
public string Hash(string keyValue) => keyValue;
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("application/json")]
|
||||
[InlineData("application/JSON")]
|
||||
[InlineData("Application/Json")]
|
||||
[InlineData("APPLICATION/JSON")]
|
||||
public async Task ContentTypeCheck_IsCaseInsensitive_ParsesBodyForAnyCasing(string contentType)
|
||||
{
|
||||
const string apiKeyValue = "test-key";
|
||||
const string methodName = "echoParam";
|
||||
|
||||
var key = ApiKey.FromHash("test", apiKeyValue);
|
||||
key.IsEnabled = true;
|
||||
key.Id = 1;
|
||||
|
||||
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.GetAllApiKeysAsync(Arg.Any<CancellationToken>())
|
||||
.Returns(new List<ApiKey> { key });
|
||||
repo.GetMethodByNameAsync(methodName, Arg.Any<CancellationToken>())
|
||||
.Returns(method);
|
||||
repo.GetApprovedKeysForMethodAsync(method.Id, Arg.Any<CancellationToken>())
|
||||
.Returns(new List<ApiKey> { key });
|
||||
|
||||
using var host = await BuildHostAsync(repo);
|
||||
var client = host.GetTestClient();
|
||||
|
||||
var request = new HttpRequestMessage(HttpMethod.Post, "/api/" + methodName)
|
||||
{
|
||||
// Bypass HttpClient's MediaTypeHeaderValue auto-normalization by
|
||||
// setting the header through TryAddWithoutValidation — we need the
|
||||
// exact casing reach the server intact.
|
||||
Content = new ByteArrayContent(Encoding.UTF8.GetBytes("{\"value\":42}"))
|
||||
};
|
||||
request.Content.Headers.ContentType = MediaTypeHeaderValue.Parse(contentType);
|
||||
request.Headers.Add("X-API-Key", apiKeyValue);
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
private static async Task<IHost> BuildHostAsync(IInboundApiRepository repo)
|
||||
{
|
||||
var hostBuilder = new HostBuilder()
|
||||
.ConfigureWebHost(webBuilder =>
|
||||
{
|
||||
webBuilder
|
||||
.UseTestServer()
|
||||
.ConfigureServices(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 — the production
|
||||
// CommunicationServiceInstanceRouter would need a real
|
||||
// CommunicationService which isn't wired here.
|
||||
services.AddSingleton(Substitute.For<IInstanceLocator>());
|
||||
services.Configure<InboundApiOptions>(_ => { });
|
||||
services.AddInboundAPI();
|
||||
services.RemoveAll<IInstanceRouter>();
|
||||
services.AddSingleton(Substitute.For<IInstanceRouter>());
|
||||
// The production AddInboundAPI registration of IApiKeyHasher
|
||||
// requires a configured pepper. Replace it with the identity
|
||||
// stub so the seeded ApiKey.KeyHash matches "test-key"
|
||||
// deterministically without depending on configuration.
|
||||
services.RemoveAll<IApiKeyHasher>();
|
||||
services.AddSingleton<IApiKeyHasher>(new IdentityHasher());
|
||||
services.AddLogging();
|
||||
})
|
||||
.Configure(app =>
|
||||
{
|
||||
app.UseRouting();
|
||||
app.UseEndpoints(endpoints => endpoints.MapInboundAPI());
|
||||
});
|
||||
});
|
||||
|
||||
return await hostBuilder.StartAsync();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,291 @@
|
||||
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;
|
||||
|
||||
/// <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);
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Http.Features;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.InboundAPI.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// InboundAPI-006 / InboundAPI-008: the POST /api/{methodName} endpoint must be
|
||||
/// gated to the active central node and must cap the request body size. These
|
||||
/// behaviours are enforced by <see cref="InboundApiEndpointFilter"/>.
|
||||
/// </summary>
|
||||
public class EndpointGatingTests
|
||||
{
|
||||
private static InboundApiEndpointFilter CreateFilter(InboundApiOptions? options = null) =>
|
||||
new(NullLogger<InboundApiEndpointFilter>.Instance,
|
||||
Options.Create(options ?? new InboundApiOptions()));
|
||||
|
||||
private static (DefaultHttpContext ctx, DefaultEndpointFilterInvocationContext invocation)
|
||||
BuildInvocation(bool? activeNode, long? contentLength)
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
if (activeNode.HasValue)
|
||||
services.AddSingleton<IActiveNodeGate>(new StubActiveNodeGate(activeNode.Value));
|
||||
|
||||
var ctx = new DefaultHttpContext
|
||||
{
|
||||
RequestServices = services.BuildServiceProvider()
|
||||
};
|
||||
ctx.Request.Method = "POST";
|
||||
if (contentLength.HasValue)
|
||||
ctx.Request.ContentLength = contentLength.Value;
|
||||
|
||||
return (ctx, new DefaultEndpointFilterInvocationContext(ctx));
|
||||
}
|
||||
|
||||
private static EndpointFilterDelegate NextSentinel(out Func<bool> wasCalled)
|
||||
{
|
||||
var called = false;
|
||||
wasCalled = () => called;
|
||||
return _ =>
|
||||
{
|
||||
called = true;
|
||||
return ValueTask.FromResult<object?>(Results.Ok("handler-ran"));
|
||||
};
|
||||
}
|
||||
|
||||
// --- InboundAPI-008: standby node must not serve inbound API calls ---
|
||||
|
||||
[Fact]
|
||||
public async Task StandbyNode_ShortCircuitsWith503_AndDoesNotRunHandler()
|
||||
{
|
||||
var (_, invocation) = BuildInvocation(activeNode: false, contentLength: 2);
|
||||
var next = NextSentinel(out var handlerRan);
|
||||
|
||||
var result = await CreateFilter().InvokeAsync(invocation, next);
|
||||
|
||||
Assert.False(handlerRan());
|
||||
var status = Assert.IsAssignableFrom<IStatusCodeHttpResult>(result);
|
||||
Assert.Equal(StatusCodes.Status503ServiceUnavailable, status.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ActiveNode_PassesGate_RunsHandler()
|
||||
{
|
||||
var (_, invocation) = BuildInvocation(activeNode: true, contentLength: 2);
|
||||
var next = NextSentinel(out var handlerRan);
|
||||
|
||||
await CreateFilter().InvokeAsync(invocation, next);
|
||||
|
||||
Assert.True(handlerRan());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task NoGateRegistered_PassesGate_RunsHandler()
|
||||
{
|
||||
// When no IActiveNodeGate is registered (non-clustered host), gating is
|
||||
// opt-in and defaults to "allow" so the endpoint is still served.
|
||||
var (_, invocation) = BuildInvocation(activeNode: null, contentLength: 2);
|
||||
var next = NextSentinel(out var handlerRan);
|
||||
|
||||
await CreateFilter().InvokeAsync(invocation, next);
|
||||
|
||||
Assert.True(handlerRan());
|
||||
}
|
||||
|
||||
// --- InboundAPI-006: request body size must be capped ---
|
||||
|
||||
[Fact]
|
||||
public async Task OversizedBody_ShortCircuitsWith413_AndDoesNotRunHandler()
|
||||
{
|
||||
var options = new InboundApiOptions();
|
||||
var (_, invocation) = BuildInvocation(
|
||||
activeNode: true, contentLength: options.MaxRequestBodyBytes + 1);
|
||||
var next = NextSentinel(out var handlerRan);
|
||||
|
||||
var result = await CreateFilter(options).InvokeAsync(invocation, next);
|
||||
|
||||
Assert.False(handlerRan());
|
||||
var status = Assert.IsAssignableFrom<IStatusCodeHttpResult>(result);
|
||||
Assert.Equal(StatusCodes.Status413PayloadTooLarge, status.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BodyAtLimit_RunsHandler()
|
||||
{
|
||||
var options = new InboundApiOptions();
|
||||
var (_, invocation) = BuildInvocation(
|
||||
activeNode: true, contentLength: options.MaxRequestBodyBytes);
|
||||
var next = NextSentinel(out var handlerRan);
|
||||
|
||||
await CreateFilter(options).InvokeAsync(invocation, next);
|
||||
|
||||
Assert.True(handlerRan());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FilterCapsMaxRequestBodySizeFeature()
|
||||
{
|
||||
// For chunked/unknown-length requests there is no Content-Length, so the
|
||||
// filter must also cap the per-request body size feature so Kestrel rejects
|
||||
// an oversized stream while it is being read.
|
||||
var options = new InboundApiOptions();
|
||||
var (ctx, invocation) = BuildInvocation(activeNode: true, contentLength: null);
|
||||
ctx.Features.Set<IHttpMaxRequestBodySizeFeature>(new StubMaxBodySizeFeature());
|
||||
var next = NextSentinel(out _);
|
||||
|
||||
await CreateFilter(options).InvokeAsync(invocation, next);
|
||||
|
||||
var feature = ctx.Features.Get<IHttpMaxRequestBodySizeFeature>();
|
||||
Assert.Equal(options.MaxRequestBodyBytes, feature!.MaxRequestBodySize);
|
||||
}
|
||||
|
||||
private sealed class StubActiveNodeGate : IActiveNodeGate
|
||||
{
|
||||
private readonly bool _isActive;
|
||||
public StubActiveNodeGate(bool isActive) => _isActive = isActive;
|
||||
public bool IsActiveNode => _isActive;
|
||||
}
|
||||
|
||||
private sealed class StubMaxBodySizeFeature : IHttpMaxRequestBodySizeFeature
|
||||
{
|
||||
public bool IsReadOnly => false;
|
||||
public long? MaxRequestBodySize { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
namespace ZB.MOM.WW.ScadaBridge.InboundAPI.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// InboundAPI-005 / InboundAPI-015: tests for the script-trust-model checker.
|
||||
///
|
||||
/// InboundAPI-015 hardens the textual walker against reflection reached through
|
||||
/// permitted-type members that never spell a forbidden namespace, e.g.
|
||||
/// <c>typeof(string).Assembly.GetType("System.IO.File")</c>.
|
||||
/// </summary>
|
||||
public class ForbiddenApiCheckerTests
|
||||
{
|
||||
private static bool IsRejected(string script) =>
|
||||
ForbiddenApiChecker.FindViolations(script).Count > 0;
|
||||
|
||||
// --- Baseline: legitimate scripts must still pass ---
|
||||
|
||||
[Theory]
|
||||
[InlineData("return 1 + 1;")]
|
||||
[InlineData("var list = new List<int> { 1, 2, 3 }; return list.Sum();")]
|
||||
[InlineData("return Parameters.Get<int>(\"x\") * 2;")]
|
||||
[InlineData("await Task.Delay(1); return null;")]
|
||||
[InlineData("var r = await Route.To(\"inst\").Call(\"s\"); return r;")]
|
||||
[InlineData("Action a = () => {}; a.Invoke(); return null;")]
|
||||
public void PermittedScript_NotRejected(string script)
|
||||
{
|
||||
Assert.False(IsRejected(script), script);
|
||||
}
|
||||
|
||||
// --- Baseline: forbidden namespaces (textual) must still be rejected ---
|
||||
|
||||
[Theory]
|
||||
[InlineData("System.IO.File.Delete(\"/tmp/x\"); return null;")]
|
||||
[InlineData("System.Diagnostics.Process.Start(\"/bin/sh\"); return null;")]
|
||||
[InlineData("using System.Reflection; return null;")]
|
||||
[InlineData("var s = new System.Net.Sockets.Socket(default, default, default); return null;")]
|
||||
public void ForbiddenNamespace_Rejected(string script)
|
||||
{
|
||||
Assert.True(IsRejected(script), script);
|
||||
}
|
||||
|
||||
// --- InboundAPI-015: reflection reachable without a forbidden namespace token ---
|
||||
|
||||
[Fact]
|
||||
public void Reflection_AssemblyPropertyAccess_Rejected()
|
||||
{
|
||||
// typeof(string).Assembly — .Assembly is a reflection gateway off a permitted type.
|
||||
Assert.True(IsRejected("var a = typeof(string).Assembly; return null;"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Reflection_AssemblyGetType_Rejected()
|
||||
{
|
||||
// The classic bypass: obtain System.IO.File as a Type via a string literal.
|
||||
Assert.True(IsRejected(
|
||||
"var t = typeof(string).Assembly.GetType(\"System.IO.File\"); return null;"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Reflection_ObjectGetType_Rejected()
|
||||
{
|
||||
// x.GetType() returns a System.Type — a reflection gateway.
|
||||
Assert.True(IsRejected("var t = \"\".GetType(); return null;"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Reflection_TypeGetTypeStatic_Rejected()
|
||||
{
|
||||
Assert.True(IsRejected("var t = Type.GetType(\"System.IO.File\"); return null;"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Reflection_ActivatorCreateInstance_Rejected()
|
||||
{
|
||||
Assert.True(IsRejected(
|
||||
"var o = Activator.CreateInstance(Type.GetType(\"System.IO.File\")); return null;"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Reflection_InvokeMember_Rejected()
|
||||
{
|
||||
Assert.True(IsRejected(
|
||||
"typeof(object).InvokeMember(\"x\", default, null, null, null); return null;"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Reflection_GetMethodInvoke_Rejected()
|
||||
{
|
||||
Assert.True(IsRejected(
|
||||
"var m = typeof(object).GetMethod(\"ToString\"); return null;"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Reflection_GetTypeInfo_Rejected()
|
||||
{
|
||||
Assert.True(IsRejected("var ti = \"\".GetType().GetTypeInfo(); return null;"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DynamicKeyword_Rejected()
|
||||
{
|
||||
// dynamic widens late-bound member access the static walker cannot see through.
|
||||
Assert.True(IsRejected("dynamic d = Parameters; return null;"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,561 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using NSubstitute;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.InboundApi;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.InboundApi;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.InboundAPI.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// WP-3: Tests for script execution on central — timeout, handler dispatch, error handling.
|
||||
/// WP-5: Safe error messages.
|
||||
/// </summary>
|
||||
public class InboundScriptExecutorTests
|
||||
{
|
||||
private readonly InboundScriptExecutor _executor;
|
||||
private readonly RouteHelper _route;
|
||||
|
||||
public InboundScriptExecutorTests()
|
||||
{
|
||||
_executor = new InboundScriptExecutor(NullLogger<InboundScriptExecutor>.Instance, Substitute.For<IServiceProvider>());
|
||||
var locator = Substitute.For<IInstanceLocator>();
|
||||
var router = Substitute.For<IInstanceRouter>();
|
||||
_route = new RouteHelper(locator, router);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RegisteredHandler_ExecutesSuccessfully()
|
||||
{
|
||||
var method = new ApiMethod("test", "return 42;") { Id = 1, TimeoutSeconds = 10 };
|
||||
_executor.RegisterHandler("test", async ctx =>
|
||||
{
|
||||
await Task.CompletedTask;
|
||||
return new { result = 42 };
|
||||
});
|
||||
|
||||
var result = await _executor.ExecuteAsync(
|
||||
method,
|
||||
new Dictionary<string, object?>(),
|
||||
_route,
|
||||
TimeSpan.FromSeconds(10));
|
||||
|
||||
Assert.True(result.Success);
|
||||
Assert.NotNull(result.ResultJson);
|
||||
Assert.Contains("42", result.ResultJson);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UnregisteredHandler_InvalidScript_ReturnsCompilationFailure()
|
||||
{
|
||||
// Use an invalid script that cannot be compiled by Roslyn
|
||||
var method = new ApiMethod("unknown", "%%% invalid C# %%%") { Id = 1, TimeoutSeconds = 10 };
|
||||
|
||||
var result = await _executor.ExecuteAsync(
|
||||
method,
|
||||
new Dictionary<string, object?>(),
|
||||
_route,
|
||||
TimeSpan.FromSeconds(10));
|
||||
|
||||
Assert.False(result.Success);
|
||||
Assert.Contains("Script compilation failed", result.ErrorMessage);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UnregisteredHandler_ValidScript_LazyCompiles()
|
||||
{
|
||||
// Valid script that is not pre-registered triggers lazy compilation
|
||||
var method = new ApiMethod("lazy", "return 1;") { Id = 1, TimeoutSeconds = 10 };
|
||||
|
||||
var result = await _executor.ExecuteAsync(
|
||||
method,
|
||||
new Dictionary<string, object?>(),
|
||||
_route,
|
||||
TimeSpan.FromSeconds(10));
|
||||
|
||||
Assert.True(result.Success);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HandlerThrows_ReturnsSafeErrorMessage()
|
||||
{
|
||||
var method = new ApiMethod("failing", "throw new Exception();") { Id = 1, TimeoutSeconds = 10 };
|
||||
_executor.RegisterHandler("failing", _ => throw new InvalidOperationException("internal detail leak"));
|
||||
|
||||
var result = await _executor.ExecuteAsync(
|
||||
method,
|
||||
new Dictionary<string, object?>(),
|
||||
_route,
|
||||
TimeSpan.FromSeconds(10));
|
||||
|
||||
Assert.False(result.Success);
|
||||
// WP-5: Safe error message — should NOT contain "internal detail leak"
|
||||
Assert.Equal("Internal script error", result.ErrorMessage);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HandlerTimesOut_ReturnsTimeoutError()
|
||||
{
|
||||
var method = new ApiMethod("slow", "Thread.Sleep(60000);") { Id = 1, TimeoutSeconds = 1 };
|
||||
_executor.RegisterHandler("slow", async ctx =>
|
||||
{
|
||||
await Task.Delay(TimeSpan.FromSeconds(60), ctx.CancellationToken);
|
||||
return "never";
|
||||
});
|
||||
|
||||
var result = await _executor.ExecuteAsync(
|
||||
method,
|
||||
new Dictionary<string, object?>(),
|
||||
_route,
|
||||
TimeSpan.FromMilliseconds(100));
|
||||
|
||||
Assert.False(result.Success);
|
||||
Assert.Contains("timed out", result.ErrorMessage);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HandlerAccessesParameters()
|
||||
{
|
||||
var method = new ApiMethod("echo", "return params;") { Id = 1, TimeoutSeconds = 10 };
|
||||
_executor.RegisterHandler("echo", async ctx =>
|
||||
{
|
||||
await Task.CompletedTask;
|
||||
return ctx.Parameters["name"];
|
||||
});
|
||||
|
||||
var parameters = new Dictionary<string, object?> { { "name", "ScadaBridge" } };
|
||||
|
||||
var result = await _executor.ExecuteAsync(
|
||||
method, parameters, _route, TimeSpan.FromSeconds(10));
|
||||
|
||||
Assert.True(result.Success);
|
||||
Assert.Contains("ScadaBridge", result.ResultJson!);
|
||||
}
|
||||
|
||||
// --- InboundAPI-001: concurrent lazy-compile must not corrupt the handler cache ---
|
||||
|
||||
[Fact]
|
||||
public async Task ConcurrentLazyCompile_SameMethod_DoesNotCorruptCache()
|
||||
{
|
||||
// Many concurrent first-callers of an uncompiled method race the lazy-compile
|
||||
// path. With an unsynchronized Dictionary this can throw or return a torn/null
|
||||
// handler; all calls must succeed and produce the same result.
|
||||
var method = new ApiMethod("concurrent", "return 7;") { Id = 1, TimeoutSeconds = 10 };
|
||||
|
||||
var tasks = Enumerable.Range(0, 64).Select(_ => Task.Run(() =>
|
||||
_executor.ExecuteAsync(
|
||||
method,
|
||||
new Dictionary<string, object?>(),
|
||||
_route,
|
||||
TimeSpan.FromSeconds(10)))).ToArray();
|
||||
|
||||
var results = await Task.WhenAll(tasks);
|
||||
|
||||
Assert.All(results, r =>
|
||||
{
|
||||
Assert.True(r.Success, r.ErrorMessage);
|
||||
Assert.Equal("7", r.ResultJson);
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ConcurrentRegisterAndExecute_DoesNotThrow()
|
||||
{
|
||||
// RegisterHandler/RemoveHandler racing ExecuteAsync must not crash the process
|
||||
// with an InvalidOperationException from concurrent Dictionary mutation.
|
||||
var method = new ApiMethod("racy", "return 1;") { Id = 1, TimeoutSeconds = 10 };
|
||||
|
||||
var writers = Enumerable.Range(0, 32).Select(i => Task.Run(() =>
|
||||
{
|
||||
for (var n = 0; n < 50; n++)
|
||||
{
|
||||
_executor.RegisterHandler("racy", async ctx => { await Task.CompletedTask; return i; });
|
||||
_executor.RemoveHandler("racy");
|
||||
}
|
||||
}));
|
||||
|
||||
var readers = Enumerable.Range(0, 32).Select(_ => Task.Run(async () =>
|
||||
{
|
||||
for (var n = 0; n < 50; n++)
|
||||
{
|
||||
await _executor.ExecuteAsync(
|
||||
method, new Dictionary<string, object?>(), _route, TimeSpan.FromSeconds(10));
|
||||
}
|
||||
}));
|
||||
|
||||
// Should complete without an unhandled concurrency exception.
|
||||
await Task.WhenAll(writers.Concat(readers));
|
||||
}
|
||||
|
||||
// --- InboundAPI-005: compiled scripts must not bypass the script trust model ---
|
||||
|
||||
[Theory]
|
||||
[InlineData("System.IO.File.Delete(\"/tmp/x\"); return null;")]
|
||||
[InlineData("System.Diagnostics.Process.Start(\"/bin/sh\"); return null;")]
|
||||
[InlineData("var t = System.Reflection.Assembly.GetExecutingAssembly(); return null;")]
|
||||
[InlineData("new System.Threading.Thread(() => {}).Start(); return null;")]
|
||||
[InlineData("var s = new System.Net.Sockets.Socket(System.Net.Sockets.AddressFamily.InterNetwork, System.Net.Sockets.SocketType.Stream, System.Net.Sockets.ProtocolType.Tcp); return null;")]
|
||||
public void CompileAndRegister_ForbiddenApi_RejectsScript(string script)
|
||||
{
|
||||
var method = new ApiMethod("forbidden", script) { Id = 1, TimeoutSeconds = 10 };
|
||||
|
||||
var registered = _executor.CompileAndRegister(method);
|
||||
|
||||
Assert.False(registered);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExecuteAsync_ForbiddenApiScript_DoesNotRunAndReturnsFailure()
|
||||
{
|
||||
// A fully-qualified forbidden API call must be rejected at compile/register time
|
||||
// so the script never executes.
|
||||
var marker = System.IO.Path.Combine(
|
||||
System.IO.Path.GetTempPath(), $"scadabridge-pwned-{Guid.NewGuid():N}");
|
||||
System.IO.File.Delete(marker);
|
||||
|
||||
var method = new ApiMethod("evil", $"System.IO.File.WriteAllText(@\"{marker}\", \"x\"); return 1;")
|
||||
{
|
||||
Id = 1,
|
||||
TimeoutSeconds = 10
|
||||
};
|
||||
|
||||
var result = await _executor.ExecuteAsync(
|
||||
method, new Dictionary<string, object?>(), _route, TimeSpan.FromSeconds(10));
|
||||
|
||||
Assert.False(result.Success);
|
||||
Assert.False(System.IO.File.Exists(marker));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CompileAndRegister_PermittedScript_StillRegisters()
|
||||
{
|
||||
// The trust-model check must not reject legitimate scripts.
|
||||
var method = new ApiMethod("ok", "var list = new List<int> { 1, 2, 3 }; return list.Sum();")
|
||||
{
|
||||
Id = 1,
|
||||
TimeoutSeconds = 10
|
||||
};
|
||||
|
||||
Assert.True(_executor.CompileAndRegister(method));
|
||||
}
|
||||
|
||||
// --- InboundAPI-002: lazy compile-and-fetch must be atomic, never KeyNotFoundException ---
|
||||
|
||||
[Fact]
|
||||
public async Task LazyCompile_RacingRemoveHandler_NeverThrowsKeyNotFound()
|
||||
{
|
||||
// The lazy-compile path must compile-and-fetch atomically: a concurrent
|
||||
// RemoveHandler must not be able to turn a first-call into an "Internal
|
||||
// script error" (the old check-then-act re-read could throw KeyNotFoundException).
|
||||
var method = new ApiMethod("atomic", "return 5;") { Id = 1, TimeoutSeconds = 10 };
|
||||
|
||||
var removers = Enumerable.Range(0, 16).Select(_ => Task.Run(() =>
|
||||
{
|
||||
for (var n = 0; n < 200; n++)
|
||||
_executor.RemoveHandler("atomic");
|
||||
}));
|
||||
|
||||
var callers = Enumerable.Range(0, 16).Select(_ => Task.Run(async () =>
|
||||
{
|
||||
for (var n = 0; n < 50; n++)
|
||||
{
|
||||
var r = await _executor.ExecuteAsync(
|
||||
method, new Dictionary<string, object?>(), _route, TimeSpan.FromSeconds(10));
|
||||
// Result must always be a clean success or a clean compilation
|
||||
// failure — never the catch-all "Internal script error".
|
||||
Assert.NotEqual("Internal script error", r.ErrorMessage);
|
||||
}
|
||||
}));
|
||||
|
||||
await Task.WhenAll(removers.Concat(callers));
|
||||
}
|
||||
|
||||
// --- InboundAPI-004: a client disconnect must NOT be reported as a script timeout ---
|
||||
|
||||
[Fact]
|
||||
public async Task ClientDisconnect_IsNotReportedAsTimeout()
|
||||
{
|
||||
// When the caller's request token is cancelled (client aborted the request),
|
||||
// ExecuteAsync must report a client-cancelled failure, not "Script execution
|
||||
// timed out" — that log line is reserved for genuine timeouts.
|
||||
var method = new ApiMethod("aborted", "return 1;") { Id = 1, TimeoutSeconds = 30 };
|
||||
_executor.RegisterHandler("aborted", async ctx =>
|
||||
{
|
||||
await Task.Delay(TimeSpan.FromSeconds(60), ctx.CancellationToken);
|
||||
return "never";
|
||||
});
|
||||
|
||||
using var clientAborted = new CancellationTokenSource();
|
||||
clientAborted.CancelAfter(TimeSpan.FromMilliseconds(100));
|
||||
|
||||
var result = await _executor.ExecuteAsync(
|
||||
method,
|
||||
new Dictionary<string, object?>(),
|
||||
_route,
|
||||
// Generous method timeout so the timeout CTS is NOT the cause.
|
||||
TimeSpan.FromSeconds(30),
|
||||
clientAborted.Token);
|
||||
|
||||
Assert.False(result.Success);
|
||||
Assert.DoesNotContain("timed out", result.ErrorMessage ?? string.Empty);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GenuineTimeout_StillReportedAsTimeout()
|
||||
{
|
||||
// A method that exceeds its timeout with no client abort must still be
|
||||
// reported as "timed out" (regression guard for the InboundAPI-004 fix).
|
||||
var method = new ApiMethod("genuine", "return 1;") { Id = 1, TimeoutSeconds = 1 };
|
||||
_executor.RegisterHandler("genuine", async ctx =>
|
||||
{
|
||||
await Task.Delay(TimeSpan.FromSeconds(60), ctx.CancellationToken);
|
||||
return "never";
|
||||
});
|
||||
|
||||
var result = await _executor.ExecuteAsync(
|
||||
method,
|
||||
new Dictionary<string, object?>(),
|
||||
_route,
|
||||
TimeSpan.FromMilliseconds(100),
|
||||
CancellationToken.None);
|
||||
|
||||
Assert.False(result.Success);
|
||||
Assert.Contains("timed out", result.ErrorMessage);
|
||||
}
|
||||
|
||||
// --- InboundAPI-009: a script that fails to compile must be compiled at most
|
||||
// once — repeated calls must not re-run the expensive Roslyn compilation. ---
|
||||
|
||||
[Fact]
|
||||
public async Task FailedCompilation_IsNotRetriedOnEveryRequest()
|
||||
{
|
||||
// A broken script compiled once must be remembered as bad: subsequent
|
||||
// ExecuteAsync calls must NOT recompile (CPU amplification vector — there is
|
||||
// no rate limiting on the inbound API). Compilation is observed via the
|
||||
// "compilation failed" log line, which must appear exactly once.
|
||||
var counter = new CompileLogCounter();
|
||||
var executor = new InboundScriptExecutor(
|
||||
new CountingLogger<InboundScriptExecutor>(counter),
|
||||
Substitute.For<IServiceProvider>());
|
||||
|
||||
var method = new ApiMethod("broken", "%%% invalid C# %%%") { Id = 1, TimeoutSeconds = 10 };
|
||||
|
||||
for (var i = 0; i < 5; i++)
|
||||
{
|
||||
var result = await executor.ExecuteAsync(
|
||||
method, new Dictionary<string, object?>(), _route, TimeSpan.FromSeconds(10));
|
||||
Assert.False(result.Success);
|
||||
}
|
||||
|
||||
Assert.Equal(1, counter.CompilationFailures);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FailedCompilation_RecompilesAfterCompileAndRegisterCalledAgain()
|
||||
{
|
||||
// The failure cache must not be permanent: when the method definition is
|
||||
// updated via CompileAndRegister, a now-valid script must register.
|
||||
var bad = new ApiMethod("fixable", "%%% invalid %%%") { Id = 1, TimeoutSeconds = 10 };
|
||||
Assert.False(_executor.CompileAndRegister(bad));
|
||||
|
||||
var good = new ApiMethod("fixable", "return 1;") { Id = 1, TimeoutSeconds = 10 };
|
||||
Assert.True(_executor.CompileAndRegister(good));
|
||||
}
|
||||
|
||||
// --- InboundAPI-024: _knownBadMethods must be bounded so a spam attack of
|
||||
// unique method names cannot grow the cache without bound. ---
|
||||
|
||||
[Fact]
|
||||
public void KnownBadMethodsCache_SizeNeverExceedsCap_UnderUniqueNameFlood()
|
||||
{
|
||||
// Flood the executor with bad-method names well past the cache cap. The
|
||||
// cache must stabilise at or below the cap — any further unique bad name
|
||||
// is dropped rather than added (the per-request DB lookup remains the
|
||||
// correctness path; this cache is only a fast-fail optimisation).
|
||||
const int cap = 1000;
|
||||
const int floodCount = cap + 500;
|
||||
|
||||
for (var i = 0; i < floodCount; i++)
|
||||
{
|
||||
var bad = new ApiMethod($"bad-{i}", "%%% invalid %%%") { Id = i + 1, TimeoutSeconds = 10 };
|
||||
Assert.False(_executor.CompileAndRegister(bad));
|
||||
}
|
||||
|
||||
Assert.True(
|
||||
_executor.KnownBadMethodCount <= cap,
|
||||
$"known-bad cache size {_executor.KnownBadMethodCount} must not exceed cap {cap}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task KnownBadMethodsCache_LazyCompilePath_AlsoCappedUnderUniqueNameFlood()
|
||||
{
|
||||
// The lazy-compile path (ExecuteAsync on an unregistered method) records
|
||||
// failures via the same capped helper as CompileAndRegister, so flooding
|
||||
// it with unique URLs must not grow the cache without bound.
|
||||
const int cap = 1000;
|
||||
const int floodCount = cap + 250;
|
||||
|
||||
for (var i = 0; i < floodCount; i++)
|
||||
{
|
||||
var method = new ApiMethod($"lazy-bad-{i}", "%%% invalid %%%") { Id = i + 1, TimeoutSeconds = 10 };
|
||||
var result = await _executor.ExecuteAsync(
|
||||
method, new Dictionary<string, object?>(), _route, TimeSpan.FromSeconds(10));
|
||||
Assert.False(result.Success);
|
||||
}
|
||||
|
||||
Assert.True(
|
||||
_executor.KnownBadMethodCount <= cap,
|
||||
$"known-bad cache size {_executor.KnownBadMethodCount} must not exceed cap {cap}");
|
||||
}
|
||||
|
||||
// --- InboundAPI-014: the script return value is validated against ReturnDefinition ---
|
||||
|
||||
[Fact]
|
||||
public async Task ReturnValue_MatchingReturnDefinition_Succeeds()
|
||||
{
|
||||
var method = new ApiMethod("shaped", "return x;")
|
||||
{
|
||||
Id = 1,
|
||||
TimeoutSeconds = 10,
|
||||
ReturnDefinition = """[{"name":"siteName","type":"String"},{"name":"total","type":"Integer"}]""",
|
||||
};
|
||||
_executor.RegisterHandler("shaped", async ctx =>
|
||||
{
|
||||
await Task.CompletedTask;
|
||||
return new { siteName = "Site Alpha", total = 14250 };
|
||||
});
|
||||
|
||||
var result = await _executor.ExecuteAsync(
|
||||
method, new Dictionary<string, object?>(), _route, TimeSpan.FromSeconds(10));
|
||||
|
||||
Assert.True(result.Success, result.ErrorMessage);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReturnValue_NotMatchingReturnDefinition_ReturnsFailureNotMalformed200()
|
||||
{
|
||||
// The script returns a structure inconsistent with the declared return
|
||||
// definition (missing 'total'). It must surface as a failure, not a 200.
|
||||
var method = new ApiMethod("misshaped", "return x;")
|
||||
{
|
||||
Id = 1,
|
||||
TimeoutSeconds = 10,
|
||||
ReturnDefinition = """[{"name":"siteName","type":"String"},{"name":"total","type":"Integer"}]""",
|
||||
};
|
||||
_executor.RegisterHandler("misshaped", async ctx =>
|
||||
{
|
||||
await Task.CompletedTask;
|
||||
return new { siteName = "Site Alpha" }; // 'total' missing
|
||||
});
|
||||
|
||||
var result = await _executor.ExecuteAsync(
|
||||
method, new Dictionary<string, object?>(), _route, TimeSpan.FromSeconds(10));
|
||||
|
||||
Assert.False(result.Success);
|
||||
Assert.Null(result.ResultJson);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReturnValue_NoReturnDefinition_IsUnconstrained()
|
||||
{
|
||||
// A method with no ReturnDefinition keeps the prior behaviour — the return
|
||||
// value is serialized as-is.
|
||||
var method = new ApiMethod("free", "return x;") { Id = 1, TimeoutSeconds = 10 };
|
||||
_executor.RegisterHandler("free", async ctx =>
|
||||
{
|
||||
await Task.CompletedTask;
|
||||
return new { whatever = 1 };
|
||||
});
|
||||
|
||||
var result = await _executor.ExecuteAsync(
|
||||
method, new Dictionary<string, object?>(), _route, TimeSpan.FromSeconds(10));
|
||||
|
||||
Assert.True(result.Success, result.ErrorMessage);
|
||||
Assert.Contains("whatever", result.ResultJson!);
|
||||
}
|
||||
|
||||
// --- Audit Log #23 (ParentExecutionId, T3): the inbound request's
|
||||
// ExecutionId is threaded through ExecuteAsync onto routed calls ---
|
||||
|
||||
[Fact]
|
||||
public async Task ExecuteAsync_WithParentExecutionId_RoutedCallCarriesItAsParentExecutionId()
|
||||
{
|
||||
// The endpoint hands ExecuteAsync the inbound request's ExecutionId; a
|
||||
// routed Route.To(...).Call(...) inside the script must stamp that id onto
|
||||
// the RouteToCallRequest as ParentExecutionId.
|
||||
var inboundExecutionId = Guid.NewGuid();
|
||||
|
||||
var locator = Substitute.For<IInstanceLocator>();
|
||||
locator.GetSiteIdForInstanceAsync("inst-1", Arg.Any<CancellationToken>()).Returns("SiteA");
|
||||
var router = Substitute.For<IInstanceRouter>();
|
||||
RouteToCallRequest? captured = null;
|
||||
router.RouteToCallAsync("SiteA", Arg.Do<RouteToCallRequest>(r => captured = r), Arg.Any<CancellationToken>())
|
||||
.Returns(ci => new RouteToCallResponse(
|
||||
((RouteToCallRequest)ci[1]).CorrelationId, true, null, null, DateTimeOffset.UtcNow));
|
||||
var route = new RouteHelper(locator, router);
|
||||
|
||||
var method = new ApiMethod("routes", "return 1;") { Id = 1, TimeoutSeconds = 10 };
|
||||
_executor.RegisterHandler("routes", async ctx =>
|
||||
{
|
||||
await ctx.Route.To("inst-1").Call("doWork");
|
||||
return 1;
|
||||
});
|
||||
|
||||
var result = await _executor.ExecuteAsync(
|
||||
method, new Dictionary<string, object?>(), route, TimeSpan.FromSeconds(10),
|
||||
parentExecutionId: inboundExecutionId);
|
||||
|
||||
Assert.True(result.Success, result.ErrorMessage);
|
||||
Assert.NotNull(captured);
|
||||
Assert.Equal(inboundExecutionId, captured!.ParentExecutionId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExecuteAsync_WithoutParentExecutionId_RoutedCallHasNullParentExecutionId()
|
||||
{
|
||||
// ExecuteAsync called without a parent execution id (the default) routes
|
||||
// calls with ParentExecutionId null.
|
||||
var locator = Substitute.For<IInstanceLocator>();
|
||||
locator.GetSiteIdForInstanceAsync("inst-1", Arg.Any<CancellationToken>()).Returns("SiteA");
|
||||
var router = Substitute.For<IInstanceRouter>();
|
||||
RouteToCallRequest? captured = null;
|
||||
router.RouteToCallAsync("SiteA", Arg.Do<RouteToCallRequest>(r => captured = r), Arg.Any<CancellationToken>())
|
||||
.Returns(ci => new RouteToCallResponse(
|
||||
((RouteToCallRequest)ci[1]).CorrelationId, true, null, null, DateTimeOffset.UtcNow));
|
||||
var route = new RouteHelper(locator, router);
|
||||
|
||||
var method = new ApiMethod("routes2", "return 1;") { Id = 1, TimeoutSeconds = 10 };
|
||||
_executor.RegisterHandler("routes2", async ctx =>
|
||||
{
|
||||
await ctx.Route.To("inst-1").Call("doWork");
|
||||
return 1;
|
||||
});
|
||||
|
||||
var result = await _executor.ExecuteAsync(
|
||||
method, new Dictionary<string, object?>(), route, TimeSpan.FromSeconds(10));
|
||||
|
||||
Assert.True(result.Success, result.ErrorMessage);
|
||||
Assert.NotNull(captured);
|
||||
Assert.Null(captured!.ParentExecutionId);
|
||||
}
|
||||
|
||||
private sealed class CompileLogCounter
|
||||
{
|
||||
public int CompilationFailures;
|
||||
}
|
||||
|
||||
private sealed class CountingLogger<T>(CompileLogCounter counter) : ILogger<T>
|
||||
{
|
||||
public IDisposable? BeginScope<TState>(TState state) where TState : notnull => null;
|
||||
public bool IsEnabled(LogLevel logLevel) => true;
|
||||
|
||||
public void Log<TState>(
|
||||
LogLevel logLevel, EventId eventId, TState state, Exception? exception,
|
||||
Func<TState, Exception?, string> formatter)
|
||||
{
|
||||
var message = formatter(state, exception);
|
||||
if (message.Contains("script compilation failed", StringComparison.OrdinalIgnoreCase))
|
||||
Interlocked.Increment(ref counter.CompilationFailures);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,908 @@
|
||||
using System.Net;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using ZB.MOM.WW.ScadaBridge.AuditLog.Configuration;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
||||
using ZB.MOM.WW.ScadaBridge.InboundAPI.Middleware;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.InboundAPI.Tests.Middleware;
|
||||
|
||||
/// <summary>
|
||||
/// M4 Bundle D (D1) — verifies <see cref="AuditWriteMiddleware"/> emits exactly one
|
||||
/// <see cref="AuditChannel.ApiInbound"/> row per request via
|
||||
/// <see cref="ICentralAuditWriter"/> covering all outcome shapes:
|
||||
/// success (InboundRequest/Delivered), client/server error (InboundRequest/Failed),
|
||||
/// and unauthenticated (InboundAuthFailure/Failed). Audit-write failures must NEVER
|
||||
/// alter the HTTP response (alog.md §13).
|
||||
/// </summary>
|
||||
public class AuditWriteMiddlewareTests
|
||||
{
|
||||
/// <summary>
|
||||
/// Test-only recording <see cref="ICentralAuditWriter"/>. Captures every
|
||||
/// <see cref="AuditEvent"/> the middleware emits so each test can assert on
|
||||
/// the shape of the row produced for one request.
|
||||
/// </summary>
|
||||
private sealed class RecordingAuditWriter : ICentralAuditWriter
|
||||
{
|
||||
public List<AuditEvent> Events { get; } = new();
|
||||
public Func<AuditEvent, Task>? OnWrite { get; set; }
|
||||
|
||||
public Task WriteAsync(AuditEvent evt, CancellationToken ct = default)
|
||||
{
|
||||
lock (Events)
|
||||
{
|
||||
Events.Add(evt);
|
||||
}
|
||||
|
||||
return OnWrite?.Invoke(evt) ?? Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds an <see cref="HttpContext"/> primed for the inbound API route shape:
|
||||
/// POST /api/{methodName}, optional JSON body, RemoteIpAddress + User-Agent.
|
||||
/// The route value resolver mirrors the production endpoint mapping so the
|
||||
/// middleware can pull the method name without owning routing itself.
|
||||
/// </summary>
|
||||
private static DefaultHttpContext BuildContext(
|
||||
string methodName = "echo",
|
||||
string? body = null,
|
||||
string? userAgent = "test-agent/1.0",
|
||||
IPAddress? remoteIp = null)
|
||||
{
|
||||
var ctx = new DefaultHttpContext();
|
||||
ctx.Request.Method = "POST";
|
||||
ctx.Request.Path = $"/api/{methodName}";
|
||||
ctx.Request.RouteValues["methodName"] = methodName;
|
||||
|
||||
if (body is not null)
|
||||
{
|
||||
var bytes = Encoding.UTF8.GetBytes(body);
|
||||
ctx.Request.Body = new MemoryStream(bytes);
|
||||
ctx.Request.ContentLength = bytes.Length;
|
||||
ctx.Request.ContentType = "application/json";
|
||||
}
|
||||
|
||||
if (userAgent is not null)
|
||||
{
|
||||
ctx.Request.Headers["User-Agent"] = userAgent;
|
||||
}
|
||||
|
||||
ctx.Connection.RemoteIpAddress = remoteIp ?? IPAddress.Parse("10.0.0.5");
|
||||
|
||||
return ctx;
|
||||
}
|
||||
|
||||
private static AuditWriteMiddleware CreateMiddleware(
|
||||
RequestDelegate next,
|
||||
ICentralAuditWriter writer,
|
||||
AuditLogOptions? options = null) =>
|
||||
new(
|
||||
next,
|
||||
writer,
|
||||
NullLogger<AuditWriteMiddleware>.Instance,
|
||||
new StaticAuditLogOptionsMonitor(options ?? new AuditLogOptions()));
|
||||
|
||||
/// <summary>
|
||||
/// File-local <see cref="IOptionsMonitor{TOptions}"/> test double — returns the
|
||||
/// same snapshot on every read, no change-token plumbing required. Mirrors the
|
||||
/// <c>StaticMonitor</c> pattern in
|
||||
/// <c>tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Payload/InboundChannelCapTests.cs</c>.
|
||||
/// </summary>
|
||||
private sealed class StaticAuditLogOptionsMonitor : IOptionsMonitor<AuditLogOptions>
|
||||
{
|
||||
private readonly AuditLogOptions _value;
|
||||
|
||||
public StaticAuditLogOptionsMonitor(AuditLogOptions value) => _value = value;
|
||||
|
||||
public AuditLogOptions CurrentValue => _value;
|
||||
|
||||
public AuditLogOptions Get(string? name) => _value;
|
||||
|
||||
public IDisposable? OnChange(Action<AuditLogOptions, string?> listener) => null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// 1. Happy path — InboundRequest/Delivered/HttpStatus 200
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public async Task Pipeline_Success_EmitsOneEvent_KindInboundRequest_StatusDelivered_HttpStatus200()
|
||||
{
|
||||
var writer = new RecordingAuditWriter();
|
||||
var ctx = BuildContext();
|
||||
var mw = CreateMiddleware(_ =>
|
||||
{
|
||||
ctx.Response.StatusCode = 200;
|
||||
return Task.CompletedTask;
|
||||
}, writer);
|
||||
|
||||
await mw.InvokeAsync(ctx);
|
||||
|
||||
var evt = Assert.Single(writer.Events);
|
||||
Assert.Equal(AuditChannel.ApiInbound, evt.Channel);
|
||||
Assert.Equal(AuditKind.InboundRequest, evt.Kind);
|
||||
Assert.Equal(AuditStatus.Delivered, evt.Status);
|
||||
Assert.Equal(200, evt.HttpStatus);
|
||||
// Central direct-write — no ForwardState (alog.md §6).
|
||||
Assert.Null(evt.ForwardState);
|
||||
Assert.NotEqual(Guid.Empty, evt.EventId);
|
||||
Assert.Equal("echo", evt.Target);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// 2. 400 — script/validation failure path
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public async Task Pipeline_400_EmitsEvent_Status_Failed_HttpStatus400()
|
||||
{
|
||||
var writer = new RecordingAuditWriter();
|
||||
var ctx = BuildContext();
|
||||
var mw = CreateMiddleware(_ =>
|
||||
{
|
||||
ctx.Response.StatusCode = 400;
|
||||
return Task.CompletedTask;
|
||||
}, writer);
|
||||
|
||||
await mw.InvokeAsync(ctx);
|
||||
|
||||
var evt = Assert.Single(writer.Events);
|
||||
// A 400 is a request the auth succeeded for — still InboundRequest, not
|
||||
// InboundAuthFailure. Only 401/403 maps to the auth-failure kind.
|
||||
Assert.Equal(AuditKind.InboundRequest, evt.Kind);
|
||||
Assert.Equal(AuditStatus.Failed, evt.Status);
|
||||
Assert.Equal(400, evt.HttpStatus);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// 3. 401 — auth failure path
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public async Task Pipeline_401_EmitsEvent_KindInboundAuthFailure_StatusFailed()
|
||||
{
|
||||
var writer = new RecordingAuditWriter();
|
||||
var ctx = BuildContext();
|
||||
var mw = CreateMiddleware(_ =>
|
||||
{
|
||||
ctx.Response.StatusCode = 401;
|
||||
return Task.CompletedTask;
|
||||
}, writer);
|
||||
|
||||
await mw.InvokeAsync(ctx);
|
||||
|
||||
var evt = Assert.Single(writer.Events);
|
||||
Assert.Equal(AuditKind.InboundAuthFailure, evt.Kind);
|
||||
Assert.Equal(AuditStatus.Failed, evt.Status);
|
||||
Assert.Equal(401, evt.HttpStatus);
|
||||
// The candidate API key never resolved to a name, so Actor stays null —
|
||||
// never echo back an unauthenticated principal.
|
||||
Assert.Null(evt.Actor);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Pipeline_403_EmitsEvent_KindInboundAuthFailure_StatusFailed()
|
||||
{
|
||||
var writer = new RecordingAuditWriter();
|
||||
var ctx = BuildContext();
|
||||
var mw = CreateMiddleware(_ =>
|
||||
{
|
||||
ctx.Response.StatusCode = 403;
|
||||
return Task.CompletedTask;
|
||||
}, writer);
|
||||
|
||||
await mw.InvokeAsync(ctx);
|
||||
|
||||
var evt = Assert.Single(writer.Events);
|
||||
Assert.Equal(AuditKind.InboundAuthFailure, evt.Kind);
|
||||
Assert.Equal(AuditStatus.Failed, evt.Status);
|
||||
Assert.Equal(403, evt.HttpStatus);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// 4. 500 — handler threw OR returned 500
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public async Task Pipeline_500_EmitsEvent_Status_Failed()
|
||||
{
|
||||
var writer = new RecordingAuditWriter();
|
||||
var ctx = BuildContext();
|
||||
var mw = CreateMiddleware(_ =>
|
||||
{
|
||||
ctx.Response.StatusCode = 500;
|
||||
return Task.CompletedTask;
|
||||
}, writer);
|
||||
|
||||
await mw.InvokeAsync(ctx);
|
||||
|
||||
var evt = Assert.Single(writer.Events);
|
||||
Assert.Equal(AuditKind.InboundRequest, evt.Kind);
|
||||
Assert.Equal(AuditStatus.Failed, evt.Status);
|
||||
Assert.Equal(500, evt.HttpStatus);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Pipeline_Throws_EmitsEvent_Status_Failed_And_Rethrows()
|
||||
{
|
||||
var writer = new RecordingAuditWriter();
|
||||
var ctx = BuildContext();
|
||||
var boom = new InvalidOperationException("kaboom");
|
||||
var mw = CreateMiddleware(_ => throw boom, writer);
|
||||
|
||||
// The middleware MUST re-throw so the request's own error path is
|
||||
// authoritative — audit emission is best-effort only.
|
||||
var thrown = await Assert.ThrowsAsync<InvalidOperationException>(
|
||||
() => mw.InvokeAsync(ctx));
|
||||
Assert.Same(boom, thrown);
|
||||
|
||||
var evt = Assert.Single(writer.Events);
|
||||
Assert.Equal(AuditStatus.Failed, evt.Status);
|
||||
Assert.Equal("kaboom", evt.ErrorMessage);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// 5. Actor resolution — the endpoint handler stashes the API key name
|
||||
// AFTER successful auth so the middleware can pick it up from
|
||||
// HttpContext.Items.
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public async Task ApiKeyName_Resolved_From_HttpContext_AsActor()
|
||||
{
|
||||
var writer = new RecordingAuditWriter();
|
||||
var ctx = BuildContext();
|
||||
var mw = CreateMiddleware(_ =>
|
||||
{
|
||||
// The endpoint handler is expected to stash the resolved API key
|
||||
// name here once ApiKeyValidator.ValidateAsync has succeeded.
|
||||
ctx.Items[AuditWriteMiddleware.AuditActorItemKey] = "integration-svc";
|
||||
ctx.Response.StatusCode = 200;
|
||||
return Task.CompletedTask;
|
||||
}, writer);
|
||||
|
||||
await mw.InvokeAsync(ctx);
|
||||
|
||||
var evt = Assert.Single(writer.Events);
|
||||
Assert.Equal("integration-svc", evt.Actor);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// 6. Writer failure must NEVER alter the HTTP response
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public async Task AuditWriter_Throws_HttpResponse_Unchanged_Success_Stays_Success()
|
||||
{
|
||||
var writer = new RecordingAuditWriter
|
||||
{
|
||||
OnWrite = _ => throw new InvalidOperationException("writer offline"),
|
||||
};
|
||||
var ctx = BuildContext();
|
||||
var mw = CreateMiddleware(_ =>
|
||||
{
|
||||
ctx.Response.StatusCode = 200;
|
||||
return Task.CompletedTask;
|
||||
}, writer);
|
||||
|
||||
// Audit emission is best-effort; even a thrown writer must NOT bubble
|
||||
// up and contaminate the user-facing response status.
|
||||
await mw.InvokeAsync(ctx);
|
||||
|
||||
Assert.Equal(200, ctx.Response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AuditWriter_Throws_OnFailedRequest_HttpResponse_Unchanged()
|
||||
{
|
||||
var writer = new RecordingAuditWriter
|
||||
{
|
||||
OnWrite = _ => throw new InvalidOperationException("writer offline"),
|
||||
};
|
||||
var ctx = BuildContext();
|
||||
var mw = CreateMiddleware(_ =>
|
||||
{
|
||||
ctx.Response.StatusCode = 500;
|
||||
return Task.CompletedTask;
|
||||
}, writer);
|
||||
|
||||
await mw.InvokeAsync(ctx);
|
||||
|
||||
Assert.Equal(500, ctx.Response.StatusCode);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// 7. Provenance — RemoteIp + User-Agent surface in Extra JSON
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public async Task RemoteIp_And_UserAgent_AppearInExtra()
|
||||
{
|
||||
var writer = new RecordingAuditWriter();
|
||||
var ctx = BuildContext(
|
||||
userAgent: "curl/8.4.0",
|
||||
remoteIp: IPAddress.Parse("192.168.50.50"));
|
||||
var mw = CreateMiddleware(_ =>
|
||||
{
|
||||
ctx.Response.StatusCode = 200;
|
||||
return Task.CompletedTask;
|
||||
}, writer);
|
||||
|
||||
await mw.InvokeAsync(ctx);
|
||||
|
||||
var evt = Assert.Single(writer.Events);
|
||||
Assert.NotNull(evt.Extra);
|
||||
using var doc = JsonDocument.Parse(evt.Extra!);
|
||||
var root = doc.RootElement;
|
||||
Assert.Equal("192.168.50.50", root.GetProperty("remoteIp").GetString());
|
||||
Assert.Equal("curl/8.4.0", root.GetProperty("userAgent").GetString());
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Body capture — the small JSON body is buffered and stashed on
|
||||
// RequestSummary so subsequent reads (the endpoint handler's
|
||||
// JsonDocument.Parse) still see the full payload.
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public async Task RequestBody_IsBuffered_AndStashed_OnRequestSummary()
|
||||
{
|
||||
var writer = new RecordingAuditWriter();
|
||||
var requestJson = "{\"x\":1}";
|
||||
var ctx = BuildContext(body: requestJson);
|
||||
|
||||
string? observedAfterMiddleware = null;
|
||||
var mw = CreateMiddleware(async hc =>
|
||||
{
|
||||
// Downstream code must still be able to read the body — the
|
||||
// middleware enables buffering and rewinds so the handler sees the
|
||||
// unconsumed stream.
|
||||
using var reader = new StreamReader(hc.Request.Body);
|
||||
observedAfterMiddleware = await reader.ReadToEndAsync();
|
||||
hc.Response.StatusCode = 200;
|
||||
}, writer);
|
||||
|
||||
await mw.InvokeAsync(ctx);
|
||||
|
||||
Assert.Equal(requestJson, observedAfterMiddleware);
|
||||
var evt = Assert.Single(writer.Events);
|
||||
Assert.Equal(requestJson, evt.RequestSummary);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Execution id — Audit Log #23: each inbound row carries a fresh
|
||||
// per-request execution id so inbound rows are correlatable. The inbound
|
||||
// row's CorrelationId stays null — CorrelationId is purely the
|
||||
// per-operation-lifecycle id and an inbound request is a one-shot.
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public async Task InboundRow_CarriesNonNull_ExecutionId_And_NullCorrelationId()
|
||||
{
|
||||
var writer = new RecordingAuditWriter();
|
||||
var ctx = BuildContext();
|
||||
var mw = CreateMiddleware(_ =>
|
||||
{
|
||||
ctx.Response.StatusCode = 200;
|
||||
return Task.CompletedTask;
|
||||
}, writer);
|
||||
|
||||
await mw.InvokeAsync(ctx);
|
||||
|
||||
var evt = Assert.Single(writer.Events);
|
||||
Assert.NotNull(evt.ExecutionId);
|
||||
Assert.NotEqual(Guid.Empty, evt.ExecutionId!.Value);
|
||||
// CorrelationId is the per-operation-lifecycle id; an inbound request
|
||||
// is a one-shot with no multi-row operation to correlate.
|
||||
Assert.Null(evt.CorrelationId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SeparateRequests_GetDistinct_ExecutionIds()
|
||||
{
|
||||
var writer = new RecordingAuditWriter();
|
||||
var mw = CreateMiddleware(hc =>
|
||||
{
|
||||
hc.Response.StatusCode = 200;
|
||||
return Task.CompletedTask;
|
||||
}, writer);
|
||||
|
||||
await mw.InvokeAsync(BuildContext());
|
||||
await mw.InvokeAsync(BuildContext());
|
||||
|
||||
Assert.Equal(2, writer.Events.Count);
|
||||
Assert.NotEqual(writer.Events[0].ExecutionId, writer.Events[1].ExecutionId);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// ParentExecutionId — Audit Log #23 (ParentExecutionId feature, T3): the
|
||||
// inbound request's ExecutionId is minted ONCE, early, and stashed on
|
||||
// HttpContext.Items so the endpoint handler can carry it onto the routed
|
||||
// RouteToCallRequest as ParentExecutionId. The inbound row that the
|
||||
// middleware itself emits stays top-level — its own ParentExecutionId is
|
||||
// NEVER set.
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public async Task InboundExecutionId_IsStashedOnHttpItems_BeforeEndpointRuns()
|
||||
{
|
||||
var writer = new RecordingAuditWriter();
|
||||
var ctx = BuildContext();
|
||||
object? observedDuringHandler = null;
|
||||
var mw = CreateMiddleware(hc =>
|
||||
{
|
||||
// The endpoint handler must be able to read the early-minted id —
|
||||
// it is stashed before _next so a downstream reader sees it.
|
||||
hc.Items.TryGetValue(AuditWriteMiddleware.InboundExecutionIdItemKey, out observedDuringHandler);
|
||||
hc.Response.StatusCode = 200;
|
||||
return Task.CompletedTask;
|
||||
}, writer);
|
||||
|
||||
await mw.InvokeAsync(ctx);
|
||||
|
||||
var stashed = Assert.IsType<Guid>(observedDuringHandler);
|
||||
Assert.NotEqual(Guid.Empty, stashed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InboundRow_ExecutionId_Equals_TheEarlyMintedStashedId()
|
||||
{
|
||||
var writer = new RecordingAuditWriter();
|
||||
var ctx = BuildContext();
|
||||
Guid stashedDuringHandler = Guid.Empty;
|
||||
var mw = CreateMiddleware(hc =>
|
||||
{
|
||||
stashedDuringHandler =
|
||||
(Guid)hc.Items[AuditWriteMiddleware.InboundExecutionIdItemKey]!;
|
||||
hc.Response.StatusCode = 200;
|
||||
return Task.CompletedTask;
|
||||
}, writer);
|
||||
|
||||
await mw.InvokeAsync(ctx);
|
||||
|
||||
// The inbound audit row's ExecutionId must be the SAME id minted early
|
||||
// and shared with the endpoint handler — not a second, late mint.
|
||||
var evt = Assert.Single(writer.Events);
|
||||
Assert.Equal(stashedDuringHandler, evt.ExecutionId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InboundRow_OwnParentExecutionId_StaysNull()
|
||||
{
|
||||
var writer = new RecordingAuditWriter();
|
||||
var ctx = BuildContext();
|
||||
var mw = CreateMiddleware(_ =>
|
||||
{
|
||||
ctx.Response.StatusCode = 200;
|
||||
return Task.CompletedTask;
|
||||
}, writer);
|
||||
|
||||
await mw.InvokeAsync(ctx);
|
||||
|
||||
// The inbound request is itself top-level — only the spawn id flows
|
||||
// OUT on RouteToCallRequest. The inbound row's own ParentExecutionId
|
||||
// is never set.
|
||||
var evt = Assert.Single(writer.Events);
|
||||
Assert.Null(evt.ParentExecutionId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DurationMs_IsRecorded()
|
||||
{
|
||||
var writer = new RecordingAuditWriter();
|
||||
var ctx = BuildContext();
|
||||
var mw = CreateMiddleware(async _ =>
|
||||
{
|
||||
// The middleware records elapsed milliseconds — a small delay
|
||||
// ensures DurationMs is non-negative and roughly tracks reality
|
||||
// without being flake-sensitive in CI.
|
||||
await Task.Delay(5);
|
||||
ctx.Response.StatusCode = 200;
|
||||
}, writer);
|
||||
|
||||
await mw.InvokeAsync(ctx);
|
||||
|
||||
var evt = Assert.Single(writer.Events);
|
||||
Assert.NotNull(evt.DurationMs);
|
||||
Assert.True(evt.DurationMs >= 0);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Response body capture — Audit Log #23 (inbound full-response feature).
|
||||
// Until the M5-deferred work landed, ResponseSummary was always null.
|
||||
// These tests pin the new contract: the middleware wraps Response.Body,
|
||||
// runs the pipeline, copies the buffered bytes back to the real stream,
|
||||
// and stashes a UTF-8 string copy on ResponseSummary.
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public async Task ResponseBody_IsCaptured_OnResponseSummary()
|
||||
{
|
||||
var writer = new RecordingAuditWriter();
|
||||
var ctx = BuildContext();
|
||||
var responseJson = "{\"result\":42}";
|
||||
var mw = CreateMiddleware(async hc =>
|
||||
{
|
||||
hc.Response.StatusCode = 200;
|
||||
hc.Response.ContentType = "application/json";
|
||||
await hc.Response.WriteAsync(responseJson);
|
||||
}, writer);
|
||||
|
||||
await mw.InvokeAsync(ctx);
|
||||
|
||||
var evt = Assert.Single(writer.Events);
|
||||
Assert.Equal(responseJson, evt.ResponseSummary);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ResponseBody_IsForwardedToOriginalStream_DownstreamReadersSeeIt()
|
||||
{
|
||||
// Wrapping the response body must be TRANSPARENT — the real client
|
||||
// stream still receives every byte the pipeline wrote.
|
||||
var writer = new RecordingAuditWriter();
|
||||
var ctx = BuildContext();
|
||||
var captured = new MemoryStream();
|
||||
ctx.Response.Body = captured; // simulate the client/test sink
|
||||
|
||||
var responseJson = "{\"ok\":true}";
|
||||
var mw = CreateMiddleware(async hc =>
|
||||
{
|
||||
hc.Response.StatusCode = 200;
|
||||
await hc.Response.WriteAsync(responseJson);
|
||||
}, writer);
|
||||
|
||||
await mw.InvokeAsync(ctx);
|
||||
|
||||
Assert.Equal(responseJson, Encoding.UTF8.GetString(captured.ToArray()));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ResponseBody_Empty_LeavesResponseSummaryNull()
|
||||
{
|
||||
// No bytes written => null, not empty-string. Mirrors the request-body
|
||||
// contract in ReadBufferedRequestBodyAsync.
|
||||
var writer = new RecordingAuditWriter();
|
||||
var ctx = BuildContext();
|
||||
var mw = CreateMiddleware(hc =>
|
||||
{
|
||||
hc.Response.StatusCode = 204;
|
||||
return Task.CompletedTask;
|
||||
}, writer);
|
||||
|
||||
await mw.InvokeAsync(ctx);
|
||||
|
||||
var evt = Assert.Single(writer.Events);
|
||||
Assert.Null(evt.ResponseSummary);
|
||||
Assert.Equal(204, evt.HttpStatus);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ResponseBody_OnHandlerThrow_BodyCapturedUpToTheThrow()
|
||||
{
|
||||
// If the handler writes some bytes then throws, the audit row still
|
||||
// surfaces whatever the framework had flushed. The middleware re-throws
|
||||
// (audit is best-effort, the request's error path stays authoritative).
|
||||
var writer = new RecordingAuditWriter();
|
||||
var ctx = BuildContext();
|
||||
var boom = new InvalidOperationException("kaboom");
|
||||
var mw = CreateMiddleware(async hc =>
|
||||
{
|
||||
hc.Response.StatusCode = 500;
|
||||
await hc.Response.WriteAsync("partial");
|
||||
throw boom;
|
||||
}, writer);
|
||||
|
||||
var thrown = await Assert.ThrowsAsync<InvalidOperationException>(
|
||||
() => mw.InvokeAsync(ctx));
|
||||
Assert.Same(boom, thrown);
|
||||
|
||||
var evt = Assert.Single(writer.Events);
|
||||
Assert.Equal(AuditStatus.Failed, evt.Status);
|
||||
Assert.Equal("partial", evt.ResponseSummary);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Bounded audit capture — memory safety follow-up. The capture site now
|
||||
// honours AuditLogOptions.InboundMaxBytes at READ time (not just at
|
||||
// filter-time), so a 500 MiB body cannot transiently allocate 500 MiB of
|
||||
// string. The cap is local to the AUDIT copy; downstream readers and the
|
||||
// real client still see every byte.
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public async Task RequestBody_AboveInboundMaxBytes_TruncatedToCap_PayloadTruncatedTrue()
|
||||
{
|
||||
// 4 KiB cap, 20 KB body — the audit copy must be UTF-8 byte-safe
|
||||
// capped at 4 KiB AND PayloadTruncated must flip, while the
|
||||
// downstream handler still sees the full 20 KB payload.
|
||||
const int cap = 4096;
|
||||
var bigBody = new string('a', 20_000);
|
||||
var writer = new RecordingAuditWriter();
|
||||
var ctx = BuildContext(body: bigBody);
|
||||
|
||||
string? observedAfterMiddleware = null;
|
||||
var mw = CreateMiddleware(
|
||||
async hc =>
|
||||
{
|
||||
using var reader = new StreamReader(hc.Request.Body);
|
||||
observedAfterMiddleware = await reader.ReadToEndAsync();
|
||||
hc.Response.StatusCode = 200;
|
||||
},
|
||||
writer,
|
||||
options: new AuditLogOptions { InboundMaxBytes = cap });
|
||||
|
||||
await mw.InvokeAsync(ctx);
|
||||
|
||||
// (iii) Downstream handler still sees the FULL body — the cap applied
|
||||
// only to the audit copy.
|
||||
Assert.Equal(bigBody, observedAfterMiddleware);
|
||||
|
||||
var evt = Assert.Single(writer.Events);
|
||||
// (i) Audit copy bounded at cap bytes (UTF-8 byte count).
|
||||
Assert.NotNull(evt.RequestSummary);
|
||||
Assert.True(
|
||||
Encoding.UTF8.GetByteCount(evt.RequestSummary!) <= cap,
|
||||
$"RequestSummary byte count {Encoding.UTF8.GetByteCount(evt.RequestSummary!)} exceeded cap {cap}");
|
||||
// (ii) Truncation flag set by the middleware (the filter will OR its
|
||||
// own determination on top, but the middleware MUST set it itself).
|
||||
Assert.True(evt.PayloadTruncated);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ResponseBody_AboveInboundMaxBytes_TruncatedToCap_ClientStillReceivesAllBytes_PayloadTruncatedTrue()
|
||||
{
|
||||
// 4 KiB cap, 20 KB response — the test sink (acts as the real client)
|
||||
// MUST receive all 20 KB while the audit copy is bounded at 4 KiB.
|
||||
const int cap = 4096;
|
||||
var bigResponse = new string('b', 20_000);
|
||||
var writer = new RecordingAuditWriter();
|
||||
var ctx = BuildContext();
|
||||
var captured = new MemoryStream();
|
||||
ctx.Response.Body = captured; // stand-in for the client sink
|
||||
|
||||
var mw = CreateMiddleware(
|
||||
async hc =>
|
||||
{
|
||||
hc.Response.StatusCode = 200;
|
||||
await hc.Response.WriteAsync(bigResponse);
|
||||
},
|
||||
writer,
|
||||
options: new AuditLogOptions { InboundMaxBytes = cap });
|
||||
|
||||
await mw.InvokeAsync(ctx);
|
||||
|
||||
// Client sink received every byte — the forwarding wrap is transparent.
|
||||
Assert.Equal(bigResponse, Encoding.UTF8.GetString(captured.ToArray()));
|
||||
|
||||
var evt = Assert.Single(writer.Events);
|
||||
// Audit copy bounded at cap bytes.
|
||||
Assert.NotNull(evt.ResponseSummary);
|
||||
Assert.True(
|
||||
Encoding.UTF8.GetByteCount(evt.ResponseSummary!) <= cap,
|
||||
$"ResponseSummary byte count {Encoding.UTF8.GetByteCount(evt.ResponseSummary!)} exceeded cap {cap}");
|
||||
Assert.True(evt.PayloadTruncated);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// InboundAPI-018: asynchronously faulted audit-write tasks must be
|
||||
// observed (logged) rather than silently dropped — but must still NOT
|
||||
// alter the user-facing HTTP response (alog.md §13).
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// Test-only writer whose <see cref="WriteAsync"/> returns a Task that
|
||||
/// faults AFTER an asynchronous boundary, so the throw happens after
|
||||
/// <see cref="AuditWriteMiddleware"/>'s synchronous try/catch can see it —
|
||||
/// exactly the fire-and-forget bug InboundAPI-018 closes.
|
||||
/// </summary>
|
||||
private sealed class AsyncFaultingAuditWriter : ICentralAuditWriter
|
||||
{
|
||||
public Task WriteAsync(AuditEvent evt, CancellationToken ct = default)
|
||||
{
|
||||
return FaultAsync();
|
||||
|
||||
static async Task FaultAsync()
|
||||
{
|
||||
// Yield off-thread so the fault surfaces ASYNCHRONOUSLY (not
|
||||
// captured by a synchronous try/catch around the WriteAsync
|
||||
// call site).
|
||||
await Task.Yield();
|
||||
throw new InvalidOperationException("async audit write failed");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Captures log entries written through a <see cref="ILogger{TCategoryName}"/>
|
||||
/// so the test can assert on the Warning that
|
||||
/// <see cref="AuditWriteMiddleware.ObserveAuditWriteFault"/> emits.
|
||||
/// </summary>
|
||||
private sealed class RecordingLogger : Microsoft.Extensions.Logging.ILogger<AuditWriteMiddleware>
|
||||
{
|
||||
public List<(Microsoft.Extensions.Logging.LogLevel Level, string Message, Exception? Exception)> Entries { get; } = new();
|
||||
|
||||
public IDisposable BeginScope<TState>(TState state) where TState : notnull => NullScope.Instance;
|
||||
|
||||
public bool IsEnabled(Microsoft.Extensions.Logging.LogLevel logLevel) => true;
|
||||
|
||||
public void Log<TState>(
|
||||
Microsoft.Extensions.Logging.LogLevel logLevel,
|
||||
Microsoft.Extensions.Logging.EventId eventId,
|
||||
TState state,
|
||||
Exception? exception,
|
||||
Func<TState, Exception?, string> formatter)
|
||||
{
|
||||
lock (Entries)
|
||||
{
|
||||
Entries.Add((logLevel, formatter(state, exception), exception));
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class NullScope : IDisposable
|
||||
{
|
||||
public static readonly NullScope Instance = new();
|
||||
public void Dispose() { }
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AuditWriter_AsyncFault_IsObserved_AsWarning_AndDoesNotAlterResponse()
|
||||
{
|
||||
var writer = new AsyncFaultingAuditWriter();
|
||||
var logger = new RecordingLogger();
|
||||
var ctx = BuildContext();
|
||||
|
||||
var mw = new AuditWriteMiddleware(
|
||||
next: _ =>
|
||||
{
|
||||
ctx.Response.StatusCode = 200;
|
||||
return Task.CompletedTask;
|
||||
},
|
||||
auditWriter: writer,
|
||||
logger: logger,
|
||||
options: new StaticAuditLogOptionsMonitor(new AuditLogOptions()));
|
||||
|
||||
await mw.InvokeAsync(ctx);
|
||||
|
||||
// The user-facing response is untouched — audit emission is best-effort.
|
||||
Assert.Equal(200, ctx.Response.StatusCode);
|
||||
|
||||
// Give the off-thread continuation a moment to fire and log. Spin
|
||||
// briefly rather than sleep-then-assert so the test is resilient to
|
||||
// scheduler jitter without inflating runtime on success.
|
||||
var deadline = DateTime.UtcNow.AddSeconds(2);
|
||||
while (DateTime.UtcNow < deadline)
|
||||
{
|
||||
lock (logger.Entries)
|
||||
{
|
||||
if (logger.Entries.Any(e =>
|
||||
e.Level == Microsoft.Extensions.Logging.LogLevel.Warning
|
||||
&& e.Exception is not null
|
||||
&& e.Message.Contains("async audit write faulted")))
|
||||
{
|
||||
return;
|
||||
}
|
||||
}
|
||||
await Task.Delay(20);
|
||||
}
|
||||
|
||||
// If we reach this point, the continuation did not fire — pre-fix the
|
||||
// fault would have been swallowed entirely and no log line emitted.
|
||||
var snapshot = logger.Entries.Select(e => $"{e.Level}: {e.Message}").ToList();
|
||||
Assert.Fail(
|
||||
"Expected a Warning log entry observing the async audit-write fault — none found. " +
|
||||
$"Entries: [{string.Join(", ", snapshot)}]");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// InboundAPI-019 — bodyless requests skip EnableBuffering so the
|
||||
// FileBufferingReadStream allocation is avoided on GET/HEAD/DELETE
|
||||
// and any request whose Content-Length is 0. The audit row still emits
|
||||
// with a null RequestSummary, mirroring the bodyless-POST contract.
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
[Theory]
|
||||
[InlineData("GET")]
|
||||
[InlineData("HEAD")]
|
||||
[InlineData("DELETE")]
|
||||
public async Task BodylessMethod_SkipsEnableBuffering_RequestStreamIsNotReplaced(string method)
|
||||
{
|
||||
// The middleware previously called EnableBuffering on every request,
|
||||
// installing a FileBufferingReadStream wrapper even when the request
|
||||
// had no body. The bodyless-method short-circuit must leave
|
||||
// Request.Body untouched (still the original empty stream the test
|
||||
// assigns below), proving the buffering wrapper allocation is avoided.
|
||||
var writer = new RecordingAuditWriter();
|
||||
var ctx = new DefaultHttpContext();
|
||||
ctx.Request.Method = method;
|
||||
ctx.Request.Path = "/api/echo";
|
||||
ctx.Request.RouteValues["methodName"] = "echo";
|
||||
ctx.Connection.RemoteIpAddress = IPAddress.Parse("10.0.0.5");
|
||||
|
||||
// Distinct sentinel stream — the production code path that called
|
||||
// EnableBuffering would replace this with FileBufferingReadStream.
|
||||
// After the fix the original stream survives untouched.
|
||||
var sentinel = new MemoryStream();
|
||||
ctx.Request.Body = sentinel;
|
||||
|
||||
Stream? observedDuringHandler = null;
|
||||
var mw = CreateMiddleware(hc =>
|
||||
{
|
||||
observedDuringHandler = hc.Request.Body;
|
||||
hc.Response.StatusCode = 200;
|
||||
return Task.CompletedTask;
|
||||
}, writer);
|
||||
|
||||
await mw.InvokeAsync(ctx);
|
||||
|
||||
Assert.Same(sentinel, observedDuringHandler);
|
||||
var evt = Assert.Single(writer.Events);
|
||||
// No body → RequestSummary stays null, matching the bodyless-POST contract.
|
||||
Assert.Null(evt.RequestSummary);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BodylessPost_ContentLengthZero_SkipsEnableBuffering()
|
||||
{
|
||||
// A POST with an explicit Content-Length of 0 is also bodyless — even
|
||||
// though POST is conventionally a body-carrying method, the explicit
|
||||
// zero short-circuits buffering. This pins the ContentLength branch of
|
||||
// the RequestHasBody guard.
|
||||
var writer = new RecordingAuditWriter();
|
||||
var ctx = new DefaultHttpContext();
|
||||
ctx.Request.Method = "POST";
|
||||
ctx.Request.Path = "/api/echo";
|
||||
ctx.Request.RouteValues["methodName"] = "echo";
|
||||
ctx.Request.ContentLength = 0;
|
||||
ctx.Connection.RemoteIpAddress = IPAddress.Parse("10.0.0.5");
|
||||
|
||||
var sentinel = new MemoryStream();
|
||||
ctx.Request.Body = sentinel;
|
||||
|
||||
Stream? observedDuringHandler = null;
|
||||
var mw = CreateMiddleware(hc =>
|
||||
{
|
||||
observedDuringHandler = hc.Request.Body;
|
||||
hc.Response.StatusCode = 200;
|
||||
return Task.CompletedTask;
|
||||
}, writer);
|
||||
|
||||
await mw.InvokeAsync(ctx);
|
||||
|
||||
Assert.Same(sentinel, observedDuringHandler);
|
||||
var evt = Assert.Single(writer.Events);
|
||||
Assert.Null(evt.RequestSummary);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PostWithBody_StillEnablesBuffering_AndCapturesRequestSummary()
|
||||
{
|
||||
// Regression: the bodyless short-circuit must NOT regress the existing
|
||||
// body-capture contract for normal POSTs — we still need to buffer +
|
||||
// capture the request body for the audit row.
|
||||
var writer = new RecordingAuditWriter();
|
||||
var requestJson = "{\"a\":42}";
|
||||
var ctx = BuildContext(body: requestJson);
|
||||
|
||||
string? observedAfterMiddleware = null;
|
||||
var mw = CreateMiddleware(async hc =>
|
||||
{
|
||||
using var reader = new StreamReader(hc.Request.Body);
|
||||
observedAfterMiddleware = await reader.ReadToEndAsync();
|
||||
hc.Response.StatusCode = 200;
|
||||
}, writer);
|
||||
|
||||
await mw.InvokeAsync(ctx);
|
||||
|
||||
Assert.Equal(requestJson, observedAfterMiddleware);
|
||||
var evt = Assert.Single(writer.Events);
|
||||
Assert.Equal(requestJson, evt.RequestSummary);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,257 @@
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.TestHost;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using ZB.MOM.WW.ScadaBridge.AuditLog.Configuration;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
||||
using ZB.MOM.WW.ScadaBridge.InboundAPI.Middleware;
|
||||
using System.Security.Claims;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.InboundAPI.Tests.Middleware;
|
||||
|
||||
/// <summary>
|
||||
/// M4 Bundle D (D2) — verifies the production pipeline order from
|
||||
/// <c>ZB.MOM.WW.ScadaBridge.Host.Program.cs</c> for the inbound API:
|
||||
/// <c>UseAuthentication → UseAuthorization → UseAuditWriteMiddleware → endpoint</c>.
|
||||
///
|
||||
/// <para>
|
||||
/// The order is load-bearing: the audit middleware must run AFTER auth (so any
|
||||
/// framework-resolved principal on <see cref="HttpContext.User"/> is in place)
|
||||
/// and BEFORE the inbound-API endpoint handler (so the handler's stashed actor
|
||||
/// name from <see cref="HttpContext.Items"/> is observable in the
|
||||
/// <c>finally</c> block when the handler returns). The order is also what
|
||||
/// guarantees auth-failure responses (401/403 produced by a future auth scheme)
|
||||
/// are seen by the middleware so it can emit
|
||||
/// <see cref="AuditKind.InboundAuthFailure"/>.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public class MiddlewareOrderTests
|
||||
{
|
||||
/// <summary>
|
||||
/// Captures the order of pipeline stages by appending a token to a shared
|
||||
/// list as each stage runs. The assertion compares the resulting sequence
|
||||
/// directly so a regression that re-orders the pipeline fails loudly.
|
||||
/// </summary>
|
||||
private sealed class OrderingRecorder
|
||||
{
|
||||
public List<string> Stages { get; } = new();
|
||||
public void Record(string stage)
|
||||
{
|
||||
lock (Stages) { Stages.Add(stage); }
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class RecordingAuditWriter : ICentralAuditWriter
|
||||
{
|
||||
public List<AuditEvent> Events { get; } = new();
|
||||
public Task WriteAsync(AuditEvent evt, CancellationToken ct = default)
|
||||
{
|
||||
lock (Events) { Events.Add(evt); }
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Middleware_Pipeline_PlacesAuditWriteAfterAuth_BeforeScriptExecution()
|
||||
{
|
||||
var recorder = new OrderingRecorder();
|
||||
var writer = new RecordingAuditWriter();
|
||||
|
||||
using var host = await BuildHostAsync(recorder, writer);
|
||||
var client = host.GetTestClient();
|
||||
|
||||
var response = await client.PostAsync("/api/echo", new StringContent("{}"));
|
||||
|
||||
Assert.Equal(System.Net.HttpStatusCode.OK, response.StatusCode);
|
||||
|
||||
// The recorder MUST observe these stages in exactly this order:
|
||||
// 1. Authentication (UseAuthentication marker)
|
||||
// 2. Authorization (UseAuthorization marker)
|
||||
// 3. AuditMiddleware-Before-Next (audit middleware entered)
|
||||
// 4. Endpoint handler (the route handler ran)
|
||||
// 5. AuditMiddleware-After-Next (audit middleware completed)
|
||||
Assert.Equal(
|
||||
new[]
|
||||
{
|
||||
"auth",
|
||||
"authz",
|
||||
"audit-before",
|
||||
"endpoint",
|
||||
"audit-after",
|
||||
},
|
||||
recorder.Stages);
|
||||
|
||||
// And exactly one InboundRequest/Delivered audit row was emitted —
|
||||
// proving the audit middleware actually wrapped the endpoint.
|
||||
var evt = Assert.Single(writer.Events);
|
||||
Assert.Equal(AuditKind.InboundRequest, evt.Kind);
|
||||
Assert.Equal(AuditStatus.Delivered, evt.Status);
|
||||
Assert.Equal("test-actor", evt.Actor);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Middleware_Pipeline_Records_AuthFailure_Beyond_Endpoint()
|
||||
{
|
||||
// A 401 short-circuit happens *inside the endpoint handler* in the real
|
||||
// InboundAPI (the X-API-Key validator runs there), so the audit
|
||||
// middleware still wraps it and observes the 401 response status. This
|
||||
// confirms ordering supports the InboundAuthFailure emission path.
|
||||
var recorder = new OrderingRecorder();
|
||||
var writer = new RecordingAuditWriter();
|
||||
|
||||
using var host = await BuildHostAsync(recorder, writer, endpointStatus: 401);
|
||||
var client = host.GetTestClient();
|
||||
|
||||
var response = await client.PostAsync("/api/echo", new StringContent("{}"));
|
||||
|
||||
Assert.Equal(System.Net.HttpStatusCode.Unauthorized, response.StatusCode);
|
||||
|
||||
var evt = Assert.Single(writer.Events);
|
||||
Assert.Equal(AuditKind.InboundAuthFailure, evt.Kind);
|
||||
Assert.Equal(AuditStatus.Failed, evt.Status);
|
||||
Assert.Equal(401, evt.HttpStatus);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds a minimal in-memory host whose pipeline mirrors the production
|
||||
/// arrangement in <c>ZB.MOM.WW.ScadaBridge.Host.Program.cs</c>:
|
||||
/// <c>UseRouting → UseAuthentication → UseAuthorization → UseAuditWriteMiddleware → endpoints</c>.
|
||||
/// Marker middlewares record their entry into <paramref name="recorder"/> so
|
||||
/// the test can assert on the resulting ordering.
|
||||
/// </summary>
|
||||
private static async Task<IHost> BuildHostAsync(
|
||||
OrderingRecorder recorder,
|
||||
ICentralAuditWriter writer,
|
||||
int endpointStatus = 200)
|
||||
{
|
||||
var hostBuilder = new HostBuilder()
|
||||
.ConfigureWebHost(webBuilder =>
|
||||
{
|
||||
webBuilder
|
||||
.UseTestServer()
|
||||
.ConfigureServices(services =>
|
||||
{
|
||||
services.AddSingleton(writer);
|
||||
services.AddSingleton<AuditWriteMiddleware>(sp =>
|
||||
new AuditWriteMiddleware(
|
||||
// The middleware factory pattern is bypassed
|
||||
// here so the inner delegate is closed over the
|
||||
// recorder — UseMiddleware<T> below still
|
||||
// instantiates the type correctly.
|
||||
_ => Task.CompletedTask,
|
||||
writer,
|
||||
NullLogger<AuditWriteMiddleware>.Instance,
|
||||
new StaticAuditLogOptionsMonitor(new AuditLogOptions())));
|
||||
services.AddRouting();
|
||||
services.AddAuthorization();
|
||||
services.AddAuthentication("TestScheme")
|
||||
.AddScheme<AuthenticationSchemeOptions, AlwaysAuthenticatedHandler>(
|
||||
"TestScheme", _ => { });
|
||||
})
|
||||
.Configure(app =>
|
||||
{
|
||||
app.UseRouting();
|
||||
app.Use(async (ctx, next) =>
|
||||
{
|
||||
recorder.Record("auth");
|
||||
await next();
|
||||
});
|
||||
app.UseAuthentication();
|
||||
app.Use(async (ctx, next) =>
|
||||
{
|
||||
recorder.Record("authz");
|
||||
await next();
|
||||
});
|
||||
app.UseAuthorization();
|
||||
|
||||
// The order-under-test: AuditWriteMiddleware sits
|
||||
// AFTER auth/authz markers and BEFORE the endpoint
|
||||
// marker. We wrap with a sentinel marker that fires
|
||||
// *before* the audit middleware enters so the test can
|
||||
// pin where the audit middleware lands in the chain.
|
||||
app.Use(async (ctx, next) =>
|
||||
{
|
||||
recorder.Record("audit-before");
|
||||
await next();
|
||||
recorder.Record("audit-after");
|
||||
});
|
||||
app.UseAuditWriteMiddleware();
|
||||
|
||||
app.UseEndpoints(endpoints =>
|
||||
{
|
||||
endpoints.MapPost("/api/{methodName}", async ctx =>
|
||||
{
|
||||
recorder.Record("endpoint");
|
||||
|
||||
if (endpointStatus is 401 or 403)
|
||||
{
|
||||
// Simulate an auth-failure short-circuit
|
||||
// produced by the in-handler API key
|
||||
// validator — Actor must stay null.
|
||||
ctx.Response.StatusCode = endpointStatus;
|
||||
return;
|
||||
}
|
||||
|
||||
// Simulate the production handler stashing
|
||||
// the resolved API key name AFTER auth.
|
||||
ctx.Items[AuditWriteMiddleware.AuditActorItemKey] = "test-actor";
|
||||
ctx.Response.StatusCode = endpointStatus;
|
||||
await ctx.Response.WriteAsync("ok");
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
var host = await hostBuilder.StartAsync();
|
||||
return host;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Minimal authentication handler that always succeeds — keeps
|
||||
/// <see cref="HttpContext.User"/> populated so the test's audit middleware
|
||||
/// path that prefers Items but falls back to User.Identity has a real
|
||||
/// principal to ignore. The middleware's primary path uses Items so this
|
||||
/// handler's claim never appears on the emitted Actor.
|
||||
/// </summary>
|
||||
private sealed class AlwaysAuthenticatedHandler : AuthenticationHandler<AuthenticationSchemeOptions>
|
||||
{
|
||||
public AlwaysAuthenticatedHandler(
|
||||
Microsoft.Extensions.Options.IOptionsMonitor<AuthenticationSchemeOptions> options,
|
||||
Microsoft.Extensions.Logging.ILoggerFactory logger,
|
||||
System.Text.Encodings.Web.UrlEncoder encoder)
|
||||
: base(options, logger, encoder) { }
|
||||
|
||||
protected override Task<AuthenticateResult> HandleAuthenticateAsync()
|
||||
{
|
||||
var identity = new ClaimsIdentity(new[] { new Claim(ClaimTypes.Name, "framework-user") }, "TestScheme");
|
||||
var principal = new ClaimsPrincipal(identity);
|
||||
var ticket = new AuthenticationTicket(principal, "TestScheme");
|
||||
return Task.FromResult(AuthenticateResult.Success(ticket));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// File-local <see cref="IOptionsMonitor{TOptions}"/> test double — returns the
|
||||
/// same snapshot on every read. Mirrors the helper in
|
||||
/// <c>AuditWriteMiddlewareTests</c>.
|
||||
/// </summary>
|
||||
private sealed class StaticAuditLogOptionsMonitor : IOptionsMonitor<AuditLogOptions>
|
||||
{
|
||||
private readonly AuditLogOptions _value;
|
||||
|
||||
public StaticAuditLogOptionsMonitor(AuditLogOptions value) => _value = value;
|
||||
|
||||
public AuditLogOptions CurrentValue => _value;
|
||||
|
||||
public AuditLogOptions Get(string? name) => _value;
|
||||
|
||||
public IDisposable? OnChange(Action<AuditLogOptions, string?> listener) => null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
using System.Text.Json;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.InboundAPI.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// WP-2: Tests for parameter validation — type checking, required fields, extended type system.
|
||||
/// </summary>
|
||||
public class ParameterValidatorTests
|
||||
{
|
||||
[Fact]
|
||||
public void NoDefinitions_NoBody_ReturnsValid()
|
||||
{
|
||||
var result = ParameterValidator.Validate(null, null);
|
||||
Assert.True(result.IsValid);
|
||||
Assert.Empty(result.Parameters);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EmptyDefinitions_ReturnsValid()
|
||||
{
|
||||
var result = ParameterValidator.Validate(null, "[]");
|
||||
Assert.True(result.IsValid);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RequiredParameterMissing_ReturnsInvalid()
|
||||
{
|
||||
var definitions = JsonSerializer.Serialize(new[]
|
||||
{
|
||||
new { Name = "value", Type = "Integer", Required = true }
|
||||
});
|
||||
|
||||
var result = ParameterValidator.Validate(null, definitions);
|
||||
Assert.False(result.IsValid);
|
||||
Assert.Contains("Missing required parameter", result.ErrorMessage);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BodyNotObject_ReturnsInvalid()
|
||||
{
|
||||
var definitions = JsonSerializer.Serialize(new[]
|
||||
{
|
||||
new { Name = "value", Type = "String", Required = true }
|
||||
});
|
||||
|
||||
using var doc = JsonDocument.Parse("\"just a string\"");
|
||||
var result = ParameterValidator.Validate(doc.RootElement.Clone(), definitions);
|
||||
Assert.False(result.IsValid);
|
||||
Assert.Contains("must be a JSON object", result.ErrorMessage);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("Boolean", "true", true)]
|
||||
[InlineData("Integer", "42", (long)42)]
|
||||
[InlineData("Float", "3.14", 3.14)]
|
||||
[InlineData("String", "\"hello\"", "hello")]
|
||||
public void ValidTypeCoercion_Succeeds(string type, string jsonValue, object expected)
|
||||
{
|
||||
var definitions = JsonSerializer.Serialize(new[]
|
||||
{
|
||||
new { Name = "val", Type = type, Required = true }
|
||||
});
|
||||
|
||||
using var doc = JsonDocument.Parse($"{{\"val\": {jsonValue}}}");
|
||||
var result = ParameterValidator.Validate(doc.RootElement.Clone(), definitions);
|
||||
Assert.True(result.IsValid);
|
||||
Assert.Equal(expected, result.Parameters["val"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WrongType_ReturnsInvalid()
|
||||
{
|
||||
var definitions = JsonSerializer.Serialize(new[]
|
||||
{
|
||||
new { Name = "count", Type = "Integer", Required = true }
|
||||
});
|
||||
|
||||
using var doc = JsonDocument.Parse("{\"count\": \"not a number\"}");
|
||||
var result = ParameterValidator.Validate(doc.RootElement.Clone(), definitions);
|
||||
Assert.False(result.IsValid);
|
||||
Assert.Contains("must be an Integer", result.ErrorMessage);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ObjectType_Parsed()
|
||||
{
|
||||
var definitions = JsonSerializer.Serialize(new[]
|
||||
{
|
||||
new { Name = "data", Type = "Object", Required = true }
|
||||
});
|
||||
|
||||
using var doc = JsonDocument.Parse("{\"data\": {\"key\": \"value\"}}");
|
||||
var result = ParameterValidator.Validate(doc.RootElement.Clone(), definitions);
|
||||
Assert.True(result.IsValid);
|
||||
Assert.IsType<Dictionary<string, object?>>(result.Parameters["data"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ListType_Parsed()
|
||||
{
|
||||
var definitions = JsonSerializer.Serialize(new[]
|
||||
{
|
||||
new { Name = "items", Type = "List", Required = true }
|
||||
});
|
||||
|
||||
using var doc = JsonDocument.Parse("{\"items\": [1, 2, 3]}");
|
||||
var result = ParameterValidator.Validate(doc.RootElement.Clone(), definitions);
|
||||
Assert.True(result.IsValid);
|
||||
Assert.IsType<List<object?>>(result.Parameters["items"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OptionalParameter_MissingBody_ReturnsValid()
|
||||
{
|
||||
var definitions = JsonSerializer.Serialize(new[]
|
||||
{
|
||||
new { Name = "optional", Type = "String", Required = false }
|
||||
});
|
||||
|
||||
var result = ParameterValidator.Validate(null, definitions);
|
||||
Assert.True(result.IsValid);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UnknownType_ReturnsInvalid()
|
||||
{
|
||||
var definitions = JsonSerializer.Serialize(new[]
|
||||
{
|
||||
new { Name = "val", Type = "CustomType", Required = true }
|
||||
});
|
||||
|
||||
using var doc = JsonDocument.Parse("{\"val\": \"test\"}");
|
||||
var result = ParameterValidator.Validate(doc.RootElement.Clone(), definitions);
|
||||
Assert.False(result.IsValid);
|
||||
Assert.Contains("Unknown parameter type", result.ErrorMessage);
|
||||
}
|
||||
|
||||
// --- InboundAPI-010: unexpected top-level body fields must be reported so
|
||||
// callers get feedback on typo'd parameter names instead of silent ignore. ---
|
||||
|
||||
[Fact]
|
||||
public void UnexpectedBodyField_ReturnsInvalid()
|
||||
{
|
||||
var definitions = JsonSerializer.Serialize(new[]
|
||||
{
|
||||
new { Name = "value", Type = "Integer", Required = true }
|
||||
});
|
||||
|
||||
// "valeu" is a typo for "value"; the caller must be told, not ignored.
|
||||
using var doc = JsonDocument.Parse("{\"value\": 1, \"valeu\": 2}");
|
||||
var result = ParameterValidator.Validate(doc.RootElement.Clone(), definitions);
|
||||
|
||||
Assert.False(result.IsValid);
|
||||
Assert.Contains("valeu", result.ErrorMessage);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OnlyDefinedFields_StillValid()
|
||||
{
|
||||
// Regression guard: a body containing exactly the defined parameters
|
||||
// must continue to validate.
|
||||
var definitions = JsonSerializer.Serialize(new[]
|
||||
{
|
||||
new { Name = "value", Type = "Integer", Required = true }
|
||||
});
|
||||
|
||||
using var doc = JsonDocument.Parse("{\"value\": 1}");
|
||||
var result = ParameterValidator.Validate(doc.RootElement.Clone(), definitions);
|
||||
|
||||
Assert.True(result.IsValid);
|
||||
Assert.Equal((long)1, result.Parameters["value"]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
namespace ZB.MOM.WW.ScadaBridge.InboundAPI.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// InboundAPI-014: tests for return-value validation against a method's
|
||||
/// <c>ReturnDefinition</c>. Previously the script's return value was serialized
|
||||
/// verbatim with no checking against the declared return structure.
|
||||
/// </summary>
|
||||
public class ReturnValueValidatorTests
|
||||
{
|
||||
// --- No definition → no validation (backward compatible) ---
|
||||
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
public void NoReturnDefinition_AnythingIsValid(string? returnDefinition)
|
||||
{
|
||||
var result = ReturnValueValidator.Validate("{\"anything\":1}", returnDefinition);
|
||||
Assert.True(result.IsValid);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NoReturnDefinition_NullResult_IsValid()
|
||||
{
|
||||
var result = ReturnValueValidator.Validate(null, null);
|
||||
Assert.True(result.IsValid);
|
||||
}
|
||||
|
||||
// --- Happy path: result matches the declared field shape ---
|
||||
|
||||
[Fact]
|
||||
public void ResultMatchingDefinition_IsValid()
|
||||
{
|
||||
const string def = """[{"name":"siteName","type":"String"},{"name":"totalUnits","type":"Integer"}]""";
|
||||
const string json = """{"siteName":"Site Alpha","totalUnits":14250}""";
|
||||
|
||||
var result = ReturnValueValidator.Validate(json, def);
|
||||
|
||||
Assert.True(result.IsValid);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResultWithListField_ShapeChecked_IsValid()
|
||||
{
|
||||
const string def = """[{"name":"lines","type":"List"}]""";
|
||||
const string json = """{"lines":[{"lineName":"Line-1","units":8200}]}""";
|
||||
|
||||
var result = ReturnValueValidator.Validate(json, def);
|
||||
|
||||
Assert.True(result.IsValid);
|
||||
}
|
||||
|
||||
// --- Mismatches must be reported ---
|
||||
|
||||
[Fact]
|
||||
public void ResultMissingDeclaredField_IsInvalid()
|
||||
{
|
||||
const string def = """[{"name":"siteName","type":"String"},{"name":"totalUnits","type":"Integer"}]""";
|
||||
const string json = """{"siteName":"Site Alpha"}""";
|
||||
|
||||
var result = ReturnValueValidator.Validate(json, def);
|
||||
|
||||
Assert.False(result.IsValid);
|
||||
Assert.Contains("totalUnits", result.ErrorMessage);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResultFieldWrongType_IsInvalid()
|
||||
{
|
||||
const string def = """[{"name":"totalUnits","type":"Integer"}]""";
|
||||
const string json = """{"totalUnits":"not-a-number"}""";
|
||||
|
||||
var result = ReturnValueValidator.Validate(json, def);
|
||||
|
||||
Assert.False(result.IsValid);
|
||||
Assert.Contains("totalUnits", result.ErrorMessage);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NullResultWhenStructureRequired_IsInvalid()
|
||||
{
|
||||
const string def = """[{"name":"siteName","type":"String"}]""";
|
||||
|
||||
var result = ReturnValueValidator.Validate(null, def);
|
||||
|
||||
Assert.False(result.IsValid);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NonObjectResultWhenStructureRequired_IsInvalid()
|
||||
{
|
||||
const string def = """[{"name":"siteName","type":"String"}]""";
|
||||
|
||||
var result = ReturnValueValidator.Validate("42", def);
|
||||
|
||||
Assert.False(result.IsValid);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ListFieldGivenNonArray_IsInvalid()
|
||||
{
|
||||
const string def = """[{"name":"lines","type":"List"}]""";
|
||||
const string json = """{"lines":"not-a-list"}""";
|
||||
|
||||
var result = ReturnValueValidator.Validate(json, def);
|
||||
|
||||
Assert.False(result.IsValid);
|
||||
Assert.Contains("lines", result.ErrorMessage);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MalformedReturnDefinition_IsInvalid()
|
||||
{
|
||||
var result = ReturnValueValidator.Validate("{\"x\":1}", "%%% not json %%%");
|
||||
|
||||
Assert.False(result.IsValid);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,404 @@
|
||||
using NSubstitute;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.InboundApi;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.InboundAPI.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// WP-4: Tests for <see cref="RouteHelper"/>/<see cref="RouteTarget"/> — the
|
||||
/// cross-site Route.To() routing surface inbound API scripts use.
|
||||
///
|
||||
/// InboundAPI-017: this surface previously had zero coverage.
|
||||
/// InboundAPI-016: routed calls must inherit the executing method's deadline token.
|
||||
/// </summary>
|
||||
public class RouteHelperTests
|
||||
{
|
||||
private readonly IInstanceLocator _locator = Substitute.For<IInstanceLocator>();
|
||||
private readonly IInstanceRouter _router = Substitute.For<IInstanceRouter>();
|
||||
|
||||
private RouteHelper CreateHelper() => new(_locator, _router);
|
||||
|
||||
private void SiteResolves(string instanceCode, string siteId) =>
|
||||
_locator.GetSiteIdForInstanceAsync(instanceCode, Arg.Any<CancellationToken>())
|
||||
.Returns(siteId);
|
||||
|
||||
// --- Call ---
|
||||
|
||||
[Fact]
|
||||
public async Task Call_HappyPath_ResolvesSiteAndReturnsValue()
|
||||
{
|
||||
SiteResolves("inst-1", "SiteA");
|
||||
_router.RouteToCallAsync("SiteA", Arg.Any<RouteToCallRequest>(), Arg.Any<CancellationToken>())
|
||||
.Returns(ci => new RouteToCallResponse(
|
||||
((RouteToCallRequest)ci[1]).CorrelationId, true, 99, null, DateTimeOffset.UtcNow));
|
||||
|
||||
var result = await CreateHelper().To("inst-1").Call("doWork", new { x = 1 });
|
||||
|
||||
Assert.Equal(99, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Call_GeneratesCorrelationId()
|
||||
{
|
||||
SiteResolves("inst-1", "SiteA");
|
||||
RouteToCallRequest? captured = null;
|
||||
_router.RouteToCallAsync("SiteA", Arg.Do<RouteToCallRequest>(r => captured = r), Arg.Any<CancellationToken>())
|
||||
.Returns(ci => new RouteToCallResponse(
|
||||
((RouteToCallRequest)ci[1]).CorrelationId, true, null, null, DateTimeOffset.UtcNow));
|
||||
|
||||
await CreateHelper().To("inst-1").Call("doWork");
|
||||
|
||||
Assert.NotNull(captured);
|
||||
Assert.True(Guid.TryParse(captured!.CorrelationId, out _));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Call_RemoteFailure_ThrowsInvalidOperationException()
|
||||
{
|
||||
SiteResolves("inst-1", "SiteA");
|
||||
_router.RouteToCallAsync("SiteA", Arg.Any<RouteToCallRequest>(), Arg.Any<CancellationToken>())
|
||||
.Returns(ci => new RouteToCallResponse(
|
||||
((RouteToCallRequest)ci[1]).CorrelationId, false, null, "site exploded", DateTimeOffset.UtcNow));
|
||||
|
||||
var ex = await Assert.ThrowsAsync<InvalidOperationException>(
|
||||
() => CreateHelper().To("inst-1").Call("doWork"));
|
||||
Assert.Equal("site exploded", ex.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Call_UnresolvedInstance_ThrowsInvalidOperationException()
|
||||
{
|
||||
// Locator returns null → instance not found / no assigned site.
|
||||
_locator.GetSiteIdForInstanceAsync("ghost", Arg.Any<CancellationToken>())
|
||||
.Returns((string?)null);
|
||||
|
||||
var ex = await Assert.ThrowsAsync<InvalidOperationException>(
|
||||
() => CreateHelper().To("ghost").Call("doWork"));
|
||||
Assert.Contains("ghost", ex.Message);
|
||||
}
|
||||
|
||||
// --- GetAttribute(s) ---
|
||||
|
||||
[Fact]
|
||||
public async Task GetAttributes_HappyPath_ReturnsValues()
|
||||
{
|
||||
SiteResolves("inst-1", "SiteA");
|
||||
_router.RouteToGetAttributesAsync("SiteA", Arg.Any<RouteToGetAttributesRequest>(), Arg.Any<CancellationToken>())
|
||||
.Returns(ci => new RouteToGetAttributesResponse(
|
||||
((RouteToGetAttributesRequest)ci[1]).CorrelationId,
|
||||
new Dictionary<string, object?> { ["a"] = 1, ["b"] = 2 },
|
||||
true, null, DateTimeOffset.UtcNow));
|
||||
|
||||
var result = await CreateHelper().To("inst-1").GetAttributes(new[] { "a", "b" });
|
||||
|
||||
Assert.Equal(1, result["a"]);
|
||||
Assert.Equal(2, result["b"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetAttribute_DelegatesToBatch_AndReturnsSingleValue()
|
||||
{
|
||||
SiteResolves("inst-1", "SiteA");
|
||||
_router.RouteToGetAttributesAsync("SiteA", Arg.Any<RouteToGetAttributesRequest>(), Arg.Any<CancellationToken>())
|
||||
.Returns(ci => new RouteToGetAttributesResponse(
|
||||
((RouteToGetAttributesRequest)ci[1]).CorrelationId,
|
||||
new Dictionary<string, object?> { ["temp"] = 21.5 },
|
||||
true, null, DateTimeOffset.UtcNow));
|
||||
|
||||
var value = await CreateHelper().To("inst-1").GetAttribute("temp");
|
||||
|
||||
Assert.Equal(21.5, value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetAttribute_AbsentKey_ReturnsNull()
|
||||
{
|
||||
SiteResolves("inst-1", "SiteA");
|
||||
_router.RouteToGetAttributesAsync("SiteA", Arg.Any<RouteToGetAttributesRequest>(), Arg.Any<CancellationToken>())
|
||||
.Returns(ci => new RouteToGetAttributesResponse(
|
||||
((RouteToGetAttributesRequest)ci[1]).CorrelationId,
|
||||
new Dictionary<string, object?>(), // batch returns nothing for the key
|
||||
true, null, DateTimeOffset.UtcNow));
|
||||
|
||||
var value = await CreateHelper().To("inst-1").GetAttribute("missing");
|
||||
|
||||
Assert.Null(value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetAttributes_RemoteFailure_ThrowsInvalidOperationException()
|
||||
{
|
||||
SiteResolves("inst-1", "SiteA");
|
||||
_router.RouteToGetAttributesAsync("SiteA", Arg.Any<RouteToGetAttributesRequest>(), Arg.Any<CancellationToken>())
|
||||
.Returns(ci => new RouteToGetAttributesResponse(
|
||||
((RouteToGetAttributesRequest)ci[1]).CorrelationId,
|
||||
new Dictionary<string, object?>(), false, "read failed", DateTimeOffset.UtcNow));
|
||||
|
||||
var ex = await Assert.ThrowsAsync<InvalidOperationException>(
|
||||
() => CreateHelper().To("inst-1").GetAttributes(new[] { "a" }));
|
||||
Assert.Equal("read failed", ex.Message);
|
||||
}
|
||||
|
||||
// --- SetAttribute(s) ---
|
||||
|
||||
[Fact]
|
||||
public async Task SetAttribute_DelegatesToBatch_WithSingleEntry()
|
||||
{
|
||||
SiteResolves("inst-1", "SiteA");
|
||||
RouteToSetAttributesRequest? captured = null;
|
||||
_router.RouteToSetAttributesAsync("SiteA", Arg.Do<RouteToSetAttributesRequest>(r => captured = r), Arg.Any<CancellationToken>())
|
||||
.Returns(ci => new RouteToSetAttributesResponse(
|
||||
((RouteToSetAttributesRequest)ci[1]).CorrelationId, true, null, DateTimeOffset.UtcNow));
|
||||
|
||||
await CreateHelper().To("inst-1").SetAttribute("setpoint", "42");
|
||||
|
||||
Assert.NotNull(captured);
|
||||
Assert.Equal("42", captured!.AttributeValues["setpoint"]);
|
||||
Assert.Single(captured.AttributeValues);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SetAttributes_RemoteFailure_ThrowsInvalidOperationException()
|
||||
{
|
||||
SiteResolves("inst-1", "SiteA");
|
||||
_router.RouteToSetAttributesAsync("SiteA", Arg.Any<RouteToSetAttributesRequest>(), Arg.Any<CancellationToken>())
|
||||
.Returns(ci => new RouteToSetAttributesResponse(
|
||||
((RouteToSetAttributesRequest)ci[1]).CorrelationId, false, "write rejected", DateTimeOffset.UtcNow));
|
||||
|
||||
var ex = await Assert.ThrowsAsync<InvalidOperationException>(
|
||||
() => CreateHelper().To("inst-1").SetAttributes(
|
||||
new Dictionary<string, string> { ["x"] = "1" }));
|
||||
Assert.Equal("write rejected", ex.Message);
|
||||
}
|
||||
|
||||
// --- InboundAPI-016: routed calls inherit the method deadline token ---
|
||||
|
||||
[Fact]
|
||||
public async Task Call_WithNoExplicitToken_InheritsMethodDeadlineToken()
|
||||
{
|
||||
// A natural script — Route.To("x").Call("s", p) — passes no token. The routed
|
||||
// call must still be bounded by the executing method's timeout: the helper
|
||||
// bound to a deadline token must forward THAT token to the router.
|
||||
SiteResolves("inst-1", "SiteA");
|
||||
using var deadline = new CancellationTokenSource();
|
||||
CancellationToken seen = default;
|
||||
_router.RouteToCallAsync("SiteA", Arg.Any<RouteToCallRequest>(), Arg.Do<CancellationToken>(t => seen = t))
|
||||
.Returns(ci => new RouteToCallResponse(
|
||||
((RouteToCallRequest)ci[1]).CorrelationId, true, null, null, DateTimeOffset.UtcNow));
|
||||
|
||||
var bound = CreateHelper().WithDeadline(deadline.Token);
|
||||
await bound.To("inst-1").Call("doWork");
|
||||
|
||||
Assert.Equal(deadline.Token, seen);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Call_WhenMethodDeadlineCancelled_RoutedCallObservesCancellation()
|
||||
{
|
||||
// When the method timeout fires, an in-flight routed call must see the
|
||||
// cancellation rather than running orphaned past the deadline.
|
||||
SiteResolves("inst-1", "SiteA");
|
||||
using var deadline = new CancellationTokenSource();
|
||||
_router.RouteToCallAsync("SiteA", Arg.Any<RouteToCallRequest>(), Arg.Any<CancellationToken>())
|
||||
.Returns(async ci =>
|
||||
{
|
||||
var token = (CancellationToken)ci[2];
|
||||
await Task.Delay(TimeSpan.FromSeconds(30), token);
|
||||
return new RouteToCallResponse(
|
||||
((RouteToCallRequest)ci[1]).CorrelationId, true, null, null, DateTimeOffset.UtcNow);
|
||||
});
|
||||
|
||||
var bound = CreateHelper().WithDeadline(deadline.Token);
|
||||
deadline.CancelAfter(TimeSpan.FromMilliseconds(100));
|
||||
|
||||
await Assert.ThrowsAnyAsync<OperationCanceledException>(
|
||||
() => bound.To("inst-1").Call("doWork"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Call_ExplicitToken_OverridesDeadlineToken()
|
||||
{
|
||||
// A script that DOES pass a (tighter) token must have that token honoured.
|
||||
SiteResolves("inst-1", "SiteA");
|
||||
using var deadline = new CancellationTokenSource();
|
||||
using var explicitCts = new CancellationTokenSource();
|
||||
CancellationToken seen = default;
|
||||
_router.RouteToCallAsync("SiteA", Arg.Any<RouteToCallRequest>(), Arg.Do<CancellationToken>(t => seen = t))
|
||||
.Returns(ci => new RouteToCallResponse(
|
||||
((RouteToCallRequest)ci[1]).CorrelationId, true, null, null, DateTimeOffset.UtcNow));
|
||||
|
||||
var bound = CreateHelper().WithDeadline(deadline.Token);
|
||||
await bound.To("inst-1").Call("doWork", null, explicitCts.Token);
|
||||
|
||||
Assert.Equal(explicitCts.Token, seen);
|
||||
}
|
||||
|
||||
// --- Audit Log #23 (ParentExecutionId, T3): a routed call carries the
|
||||
// inbound request's ExecutionId as RouteToCallRequest.ParentExecutionId ---
|
||||
|
||||
[Fact]
|
||||
public async Task Call_WithoutParentExecutionId_LeavesParentExecutionIdNull()
|
||||
{
|
||||
// A RouteHelper not bound to an inbound execution id (e.g. the Central UI
|
||||
// sandbox path) builds requests with ParentExecutionId null.
|
||||
SiteResolves("inst-1", "SiteA");
|
||||
RouteToCallRequest? captured = null;
|
||||
_router.RouteToCallAsync("SiteA", Arg.Do<RouteToCallRequest>(r => captured = r), Arg.Any<CancellationToken>())
|
||||
.Returns(ci => new RouteToCallResponse(
|
||||
((RouteToCallRequest)ci[1]).CorrelationId, true, null, null, DateTimeOffset.UtcNow));
|
||||
|
||||
await CreateHelper().To("inst-1").Call("doWork");
|
||||
|
||||
Assert.NotNull(captured);
|
||||
Assert.Null(captured!.ParentExecutionId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Call_WithParentExecutionId_CarriesItOnRouteToCallRequest()
|
||||
{
|
||||
// A RouteHelper bound to the inbound request's ExecutionId must stamp that
|
||||
// id onto the routed RouteToCallRequest so the site script records it as
|
||||
// its ParentExecutionId.
|
||||
SiteResolves("inst-1", "SiteA");
|
||||
var inboundExecutionId = Guid.NewGuid();
|
||||
RouteToCallRequest? captured = null;
|
||||
_router.RouteToCallAsync("SiteA", Arg.Do<RouteToCallRequest>(r => captured = r), Arg.Any<CancellationToken>())
|
||||
.Returns(ci => new RouteToCallResponse(
|
||||
((RouteToCallRequest)ci[1]).CorrelationId, true, null, null, DateTimeOffset.UtcNow));
|
||||
|
||||
var bound = CreateHelper().WithParentExecutionId(inboundExecutionId);
|
||||
await bound.To("inst-1").Call("doWork");
|
||||
|
||||
Assert.NotNull(captured);
|
||||
Assert.Equal(inboundExecutionId, captured!.ParentExecutionId);
|
||||
// ParentExecutionId is a separate concern from the per-op CorrelationId —
|
||||
// the helper still mints its own routed-call correlation id.
|
||||
Assert.True(Guid.TryParse(captured.CorrelationId, out _));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task WithParentExecutionId_PreservesDeadlineToken()
|
||||
{
|
||||
// The two builder methods compose — binding a parent execution id must
|
||||
// not drop a previously-bound deadline token.
|
||||
SiteResolves("inst-1", "SiteA");
|
||||
using var deadline = new CancellationTokenSource();
|
||||
CancellationToken seen = default;
|
||||
RouteToCallRequest? captured = null;
|
||||
_router.RouteToCallAsync("SiteA", Arg.Do<RouteToCallRequest>(r => captured = r), Arg.Do<CancellationToken>(t => seen = t))
|
||||
.Returns(ci => new RouteToCallResponse(
|
||||
((RouteToCallRequest)ci[1]).CorrelationId, true, null, null, DateTimeOffset.UtcNow));
|
||||
|
||||
var bound = CreateHelper()
|
||||
.WithDeadline(deadline.Token)
|
||||
.WithParentExecutionId(Guid.NewGuid());
|
||||
await bound.To("inst-1").Call("doWork");
|
||||
|
||||
Assert.Equal(deadline.Token, seen);
|
||||
Assert.NotNull(captured!.ParentExecutionId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetAttributes_WithNoExplicitToken_InheritsMethodDeadlineToken()
|
||||
{
|
||||
SiteResolves("inst-1", "SiteA");
|
||||
using var deadline = new CancellationTokenSource();
|
||||
CancellationToken seen = default;
|
||||
_router.RouteToGetAttributesAsync("SiteA", Arg.Any<RouteToGetAttributesRequest>(), Arg.Do<CancellationToken>(t => seen = t))
|
||||
.Returns(ci => new RouteToGetAttributesResponse(
|
||||
((RouteToGetAttributesRequest)ci[1]).CorrelationId,
|
||||
new Dictionary<string, object?>(), true, null, DateTimeOffset.UtcNow));
|
||||
|
||||
var bound = CreateHelper().WithDeadline(deadline.Token);
|
||||
await bound.To("inst-1").GetAttributes(new[] { "a" });
|
||||
|
||||
Assert.Equal(deadline.Token, seen);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SetAttributes_WithNoExplicitToken_InheritsMethodDeadlineToken()
|
||||
{
|
||||
SiteResolves("inst-1", "SiteA");
|
||||
using var deadline = new CancellationTokenSource();
|
||||
CancellationToken seen = default;
|
||||
_router.RouteToSetAttributesAsync("SiteA", Arg.Any<RouteToSetAttributesRequest>(), Arg.Do<CancellationToken>(t => seen = t))
|
||||
.Returns(ci => new RouteToSetAttributesResponse(
|
||||
((RouteToSetAttributesRequest)ci[1]).CorrelationId, true, null, DateTimeOffset.UtcNow));
|
||||
|
||||
var bound = CreateHelper().WithDeadline(deadline.Token);
|
||||
await bound.To("inst-1").SetAttributes(new Dictionary<string, string> { ["x"] = "1" });
|
||||
|
||||
Assert.Equal(deadline.Token, seen);
|
||||
}
|
||||
|
||||
// --- InboundAPI-021: ParentExecutionId flows through Get/SetAttributes too ---
|
||||
|
||||
[Fact]
|
||||
public async Task GetAttributes_WithoutParentExecutionId_LeavesParentExecutionIdNull()
|
||||
{
|
||||
SiteResolves("inst-1", "SiteA");
|
||||
RouteToGetAttributesRequest? captured = null;
|
||||
_router.RouteToGetAttributesAsync("SiteA", Arg.Do<RouteToGetAttributesRequest>(r => captured = r), Arg.Any<CancellationToken>())
|
||||
.Returns(ci => new RouteToGetAttributesResponse(
|
||||
((RouteToGetAttributesRequest)ci[1]).CorrelationId,
|
||||
new Dictionary<string, object?>(), true, null, DateTimeOffset.UtcNow));
|
||||
|
||||
await CreateHelper().To("inst-1").GetAttributes(new[] { "a" });
|
||||
|
||||
Assert.NotNull(captured);
|
||||
Assert.Null(captured!.ParentExecutionId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetAttributes_WithParentExecutionId_CarriesItOnRouteToGetAttributesRequest()
|
||||
{
|
||||
// Symmetric with Call: a RouteHelper bound to the inbound request's
|
||||
// ExecutionId stamps it onto the routed GetAttributes request so
|
||||
// future site-side audit can record the inbound→site link.
|
||||
SiteResolves("inst-1", "SiteA");
|
||||
var inboundExecutionId = Guid.NewGuid();
|
||||
RouteToGetAttributesRequest? captured = null;
|
||||
_router.RouteToGetAttributesAsync("SiteA", Arg.Do<RouteToGetAttributesRequest>(r => captured = r), Arg.Any<CancellationToken>())
|
||||
.Returns(ci => new RouteToGetAttributesResponse(
|
||||
((RouteToGetAttributesRequest)ci[1]).CorrelationId,
|
||||
new Dictionary<string, object?>(), true, null, DateTimeOffset.UtcNow));
|
||||
|
||||
var bound = CreateHelper().WithParentExecutionId(inboundExecutionId);
|
||||
await bound.To("inst-1").GetAttributes(new[] { "a" });
|
||||
|
||||
Assert.NotNull(captured);
|
||||
Assert.Equal(inboundExecutionId, captured!.ParentExecutionId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SetAttributes_WithoutParentExecutionId_LeavesParentExecutionIdNull()
|
||||
{
|
||||
SiteResolves("inst-1", "SiteA");
|
||||
RouteToSetAttributesRequest? captured = null;
|
||||
_router.RouteToSetAttributesAsync("SiteA", Arg.Do<RouteToSetAttributesRequest>(r => captured = r), Arg.Any<CancellationToken>())
|
||||
.Returns(ci => new RouteToSetAttributesResponse(
|
||||
((RouteToSetAttributesRequest)ci[1]).CorrelationId, true, null, DateTimeOffset.UtcNow));
|
||||
|
||||
await CreateHelper().To("inst-1").SetAttributes(new Dictionary<string, string> { ["x"] = "1" });
|
||||
|
||||
Assert.NotNull(captured);
|
||||
Assert.Null(captured!.ParentExecutionId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SetAttributes_WithParentExecutionId_CarriesItOnRouteToSetAttributesRequest()
|
||||
{
|
||||
SiteResolves("inst-1", "SiteA");
|
||||
var inboundExecutionId = Guid.NewGuid();
|
||||
RouteToSetAttributesRequest? captured = null;
|
||||
_router.RouteToSetAttributesAsync("SiteA", Arg.Do<RouteToSetAttributesRequest>(r => captured = r), Arg.Any<CancellationToken>())
|
||||
.Returns(ci => new RouteToSetAttributesResponse(
|
||||
((RouteToSetAttributesRequest)ci[1]).CorrelationId, true, null, DateTimeOffset.UtcNow));
|
||||
|
||||
var bound = CreateHelper().WithParentExecutionId(inboundExecutionId);
|
||||
await bound.To("inst-1").SetAttributes(new Dictionary<string, string> { ["x"] = "1" });
|
||||
|
||||
Assert.NotNull(captured);
|
||||
Assert.Equal(inboundExecutionId, captured!.ParentExecutionId);
|
||||
}
|
||||
}
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<IsPackable>false</IsPackable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<FrameworkReference Include="Microsoft.AspNetCore.App" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="coverlet.collector" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.TestHost" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" />
|
||||
<PackageReference Include="NSubstitute" />
|
||||
<PackageReference Include="xunit" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Using Include="Xunit" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../../src/ZB.MOM.WW.ScadaBridge.InboundAPI/ZB.MOM.WW.ScadaBridge.InboundAPI.csproj" />
|
||||
<ProjectReference Include="../../src/ZB.MOM.WW.ScadaBridge.Commons/ZB.MOM.WW.ScadaBridge.Commons.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
Reference in New Issue
Block a user