docs(tests): fix CommentChecker findings across tests/ — 13,392 → 0 (repo-wide scan clean)
Comment/XML-doc changes only: - add summaries to ~10k test methods, fixtures, fakes and helpers (+ <param> on theories, <returns> on async Task methods, <inheritdoc/> on interface implementations the checker can resolve) - strip ~2.6k project-tracking references (WP*, P*-*, row-NN, Task N, #NN, Component-0NN) from comments, keeping the explanatory text; reword false positives in comments only - real docs on fakes implementing BCL/library interfaces (IDisposable, ILogger, IOptionsMonitor, IAsyncLifetime) - reword the last 5 src/ false positives (CWE-1236, failures-1, year-0001, M365) Only non-comment edits: four one-line nested classes split across lines so members can carry docs. Build 0 errors / 0 warnings.
This commit is contained in:
@@ -3,7 +3,7 @@ using ZB.MOM.WW.ScadaBridge.Commons.Entities.InboundApi;
|
||||
namespace ZB.MOM.WW.ScadaBridge.InboundAPI.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// WP2.6b (arch-review misc — Inbound API per-request SQL): <see cref="ApiMethodCache"/>
|
||||
/// <see cref="ApiMethodCache"/>
|
||||
/// hit/miss/expiry/invalidation behavior, independent of the endpoint and subscriber wiring
|
||||
/// (covered separately by <see cref="ScriptArtifactChangeSubscriberTests"/>).
|
||||
/// </summary>
|
||||
@@ -11,6 +11,8 @@ public class ApiMethodCacheTests
|
||||
{
|
||||
private static ApiMethod Method(string name) => new(name, "return 1;") { Id = 1 };
|
||||
|
||||
/// <summary>Verifies that a second call within the TTL is served from the cache without refetching.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task GetOrFetchAsync_SecondCallWithinTtl_ServesFromCache_NoRefetch()
|
||||
{
|
||||
@@ -29,6 +31,8 @@ public class ApiMethodCacheTests
|
||||
Assert.Same(first, second);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a call after the TTL expires refetches the method.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task GetOrFetchAsync_AfterTtlExpiry_Refetches()
|
||||
{
|
||||
@@ -48,6 +52,8 @@ public class ApiMethodCacheTests
|
||||
Assert.Equal(2, fetchCount);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a negative result is cached until the TTL expires.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task GetOrFetchAsync_NegativeResult_IsCachedUntilTtlExpires()
|
||||
{
|
||||
@@ -67,6 +73,8 @@ public class ApiMethodCacheTests
|
||||
Assert.Equal(1, fetchCount);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that invalidating an entry makes the next call refetch it.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Invalidate_DropsEntry_NextCallRefetches()
|
||||
{
|
||||
@@ -85,6 +93,7 @@ public class ApiMethodCacheTests
|
||||
Assert.Equal(2, fetchCount);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that invalidating an unknown method name does not throw.</summary>
|
||||
[Fact]
|
||||
public void Invalidate_UnknownName_DoesNotThrow()
|
||||
{
|
||||
@@ -92,6 +101,7 @@ public class ApiMethodCacheTests
|
||||
cache.Invalidate("never-cached");
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a non-positive TTL makes the constructor throw.</summary>
|
||||
[Fact]
|
||||
public void Constructor_NonPositiveTtl_Throws()
|
||||
{
|
||||
@@ -103,8 +113,13 @@ public class ApiMethodCacheTests
|
||||
private sealed class FakeTimeProvider : TimeProvider
|
||||
{
|
||||
private DateTimeOffset _now;
|
||||
/// <summary>Initializes the provider at the given start time.</summary>
|
||||
/// <param name="start">The initial UTC time.</param>
|
||||
public FakeTimeProvider(DateTimeOffset start) => _now = start;
|
||||
/// <summary>Advances the current time.</summary>
|
||||
/// <param name="by">The amount of time to advance.</param>
|
||||
public void Advance(TimeSpan by) => _now += by;
|
||||
/// <inheritdoc />
|
||||
public override DateTimeOffset GetUtcNow() => _now;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@ using System.Text;
|
||||
namespace ZB.MOM.WW.ScadaBridge.InboundAPI.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// InboundAPI-020: the inbound API handler must accept JSON content types
|
||||
/// 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
|
||||
@@ -46,6 +46,9 @@ public sealed class EndpointContentTypeTests : IDisposable
|
||||
private readonly string _sqlitePath =
|
||||
Path.Combine(Path.GetTempPath(), $"inbound-api-keys-ct-{Guid.NewGuid():N}.sqlite");
|
||||
|
||||
/// <summary>Verifies that the content-type check is case-insensitive and the body is parsed for any casing.</summary>
|
||||
/// <param name="contentType">The JSON content type in the casing under test.</param>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Theory]
|
||||
[InlineData("application/json")]
|
||||
[InlineData("application/JSON")]
|
||||
@@ -92,10 +95,12 @@ public sealed class EndpointContentTypeTests : IDisposable
|
||||
Assert.Contains("42", body);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a body with a non-JSON content type is rejected with 415.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task NonJsonContentType_WithBody_Returns415()
|
||||
{
|
||||
// Task 5 (S9): a body with a non-JSON Content-Type must be rejected with 415 +
|
||||
// A body with a non-JSON Content-Type must be rejected with 415 +
|
||||
// UNSUPPORTED_MEDIA_TYPE rather than a misleading 400 "invalid JSON". A body
|
||||
// with NO Content-Type header is still parsed leniently as JSON (preserving
|
||||
// existing callers).
|
||||
@@ -187,7 +192,7 @@ public sealed class EndpointContentTypeTests : IDisposable
|
||||
services.AddRouting();
|
||||
services.AddSingleton(repo);
|
||||
// RouteHelper depends on IInstanceLocator + IInstanceRouter
|
||||
// (InboundAPI-017). Tests for content-type handling never
|
||||
// . Tests for content-type handling never
|
||||
// route, so both can be no-op stubs.
|
||||
services.AddSingleton(Substitute.For<IInstanceLocator>());
|
||||
services.Configure<InboundApiOptions>(_ => { });
|
||||
@@ -207,6 +212,7 @@ public sealed class EndpointContentTypeTests : IDisposable
|
||||
return await hostBuilder.StartAsync();
|
||||
}
|
||||
|
||||
/// <summary>Releases the resources used by this instance.</summary>
|
||||
public void Dispose()
|
||||
{
|
||||
try
|
||||
@@ -227,6 +233,8 @@ public sealed class EndpointContentTypeTests : IDisposable
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a chunked non-JSON body is rejected with 415.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task ChunkedNonJsonBody_Returns415()
|
||||
{
|
||||
@@ -261,6 +269,8 @@ public sealed class EndpointContentTypeTests : IDisposable
|
||||
Assert.Contains("UNSUPPORTED_MEDIA_TYPE", body);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a chunked JSON body with no content type is still parsed leniently.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task ChunkedJsonBody_NoContentType_StillParsesLeniently()
|
||||
{
|
||||
@@ -302,10 +312,12 @@ public sealed class EndpointContentTypeTests : IDisposable
|
||||
/// </summary>
|
||||
private sealed class ChunkedByteContent(byte[] bytes) : HttpContent
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override Task SerializeToStreamAsync(
|
||||
Stream stream, System.Net.TransportContext? context) =>
|
||||
stream.WriteAsync(bytes, 0, bytes.Length);
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override bool TryComputeLength(out long length)
|
||||
{
|
||||
length = -1;
|
||||
|
||||
@@ -63,12 +63,12 @@ public sealed class EndpointExtensionsTests : IDisposable
|
||||
private const string TokenPrefix = "sbk";
|
||||
private const string ApiKeyStoreSection = "ScadaBridge:InboundApi:ApiKeyStore";
|
||||
|
||||
// Review #1: the EXACT canonical 403 body shared by the "method not found" and
|
||||
// The EXACT canonical 403 body shared by the "method not found" and
|
||||
// "key not in scope" branches. Asserting against this literal (not just that the
|
||||
// two bodies are equal-to-each-other) catches a single-branch divergence where
|
||||
// BOTH branches change in lockstep but away from the agreed enumeration-safe
|
||||
// body — the equal-to-each-other check alone would still pass in that case.
|
||||
// Pinned to EndpointExtensions.NotApprovedMessage's JSON shape. Task 5 (C3): the
|
||||
// Pinned to EndpointExtensions.NotApprovedMessage's JSON shape. The
|
||||
// error body now additionally carries the machine-readable "code" field; both 403
|
||||
// branches still emit this identical body, preserving enumeration-safety.
|
||||
private const string NotApprovedBodyJson =
|
||||
@@ -87,6 +87,7 @@ public sealed class EndpointExtensionsTests : IDisposable
|
||||
/// </summary>
|
||||
private sealed class AuditActorCapture
|
||||
{
|
||||
/// <summary>Gets or sets the actor captured from the request.</summary>
|
||||
public string? CapturedActor { get; set; }
|
||||
}
|
||||
|
||||
@@ -101,6 +102,8 @@ public sealed class EndpointExtensionsTests : IDisposable
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a valid bearer token in scope returns 200 with the script result as JSON.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task HappyPath_ValidBearerInScope_Returns200WithScriptResultJson()
|
||||
{
|
||||
@@ -121,6 +124,8 @@ public sealed class EndpointExtensionsTests : IDisposable
|
||||
Assert.Contains("7", body);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a valid X-API-Key header in scope returns 200 with the script result as JSON.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task HappyPath_ValidXApiKeyInScope_Returns200WithScriptResultJson()
|
||||
{
|
||||
@@ -144,6 +149,8 @@ public sealed class EndpointExtensionsTests : IDisposable
|
||||
Assert.Contains("7", body);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that when both headers are present the Authorization header wins, so a valid bearer beats a garbage X-API-Key.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task BothHeadersPresent_AuthorizationWins_ValidBearerBeatsGarbageXApiKey()
|
||||
{
|
||||
@@ -172,6 +179,8 @@ public sealed class EndpointExtensionsTests : IDisposable
|
||||
Assert.Contains("7", body);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that when both headers are present the Authorization header wins, so a garbage bearer beats a valid X-API-Key and returns 401.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task BothHeadersPresent_AuthorizationWins_GarbageBearerBeatsValidXApiKey_Returns401()
|
||||
{
|
||||
@@ -199,6 +208,8 @@ public sealed class EndpointExtensionsTests : IDisposable
|
||||
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a request with neither header returns 401.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task NeitherHeader_Returns401()
|
||||
{
|
||||
@@ -220,6 +231,8 @@ public sealed class EndpointExtensionsTests : IDisposable
|
||||
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a whitespace Authorization header with a valid X-API-Key returns 200.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task WhitespaceAuthorization_ValidXApiKey_Returns200()
|
||||
{
|
||||
@@ -245,6 +258,8 @@ public sealed class EndpointExtensionsTests : IDisposable
|
||||
Assert.Contains("7", body);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a garbage bearer token returns 401.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task GarbageBearer_Returns401()
|
||||
{
|
||||
@@ -266,6 +281,8 @@ public sealed class EndpointExtensionsTests : IDisposable
|
||||
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a revoked key returns 401.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task RevokedKey_Returns401()
|
||||
{
|
||||
@@ -285,6 +302,8 @@ public sealed class EndpointExtensionsTests : IDisposable
|
||||
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a valid key without the method in scope returns 403.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task ValidKey_MethodNotInScope_Returns403()
|
||||
{
|
||||
@@ -302,12 +321,14 @@ public sealed class EndpointExtensionsTests : IDisposable
|
||||
var body = await response.Content.ReadAsStringAsync();
|
||||
|
||||
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
|
||||
// Review #1: pin the not-in-scope body to the exact canonical literal so a
|
||||
// Pin the not-in-scope body to the exact canonical literal so a
|
||||
// single-branch divergence is caught here too (not only in the
|
||||
// identical-bodies comparison test).
|
||||
Assert.Equal(NotApprovedBodyJson, body);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that an unknown method returns 403 with a body identical to the not-in-scope body.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task UnknownMethod_Returns403_WithIdenticalBodyToNotInScope()
|
||||
{
|
||||
@@ -337,7 +358,7 @@ public sealed class EndpointExtensionsTests : IDisposable
|
||||
Assert.Equal(HttpStatusCode.Forbidden, notInScopeResponse.StatusCode);
|
||||
// The crux of the enumeration-safety invariant: identical bodies.
|
||||
Assert.Equal(notInScopeBody, unknownBody);
|
||||
// Review #1: pin BOTH bodies to the exact canonical literal, so a future
|
||||
// Pin BOTH bodies to the exact canonical literal, so a future
|
||||
// change that diverges either branch from the agreed body is caught even if
|
||||
// the two branches change together (the equal-to-each-other check above
|
||||
// would not catch that on its own).
|
||||
@@ -345,10 +366,12 @@ public sealed class EndpointExtensionsTests : IDisposable
|
||||
Assert.Equal(NotApprovedBodyJson, notInScopeBody);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that error bodies carry a machine-readable code.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task ErrorBodies_CarryMachineReadableCode()
|
||||
{
|
||||
// Task 5 (C3): every error body carries a machine-readable "code" field for
|
||||
// Every error body carries a machine-readable "code" field for
|
||||
// external integrators — UNAUTHORIZED (401), INVALID_JSON (400),
|
||||
// VALIDATION_FAILED (400).
|
||||
var method = SeedMethod(1, "needsParam", "return Parameters[\"value\"];",
|
||||
@@ -384,6 +407,8 @@ public sealed class EndpointExtensionsTests : IDisposable
|
||||
return doc.RootElement.TryGetProperty("code", out var code) ? code.GetString() : null;
|
||||
}
|
||||
|
||||
/// <summary>Verifies that an invalid JSON body returns 400.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task InvalidJsonBody_Returns400()
|
||||
{
|
||||
@@ -401,6 +426,8 @@ public sealed class EndpointExtensionsTests : IDisposable
|
||||
Assert.Contains("Invalid JSON", body);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a missing required parameter returns 400 from the parameter validator.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task MissingRequiredParameter_Returns400_FromParameterValidator()
|
||||
{
|
||||
@@ -421,6 +448,8 @@ public sealed class EndpointExtensionsTests : IDisposable
|
||||
Assert.Contains("value", body);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a throwing script returns 500 with a sanitized error body.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task ScriptThrows_Returns500_WithSanitizedErrorBody()
|
||||
{
|
||||
@@ -467,6 +496,8 @@ public sealed class EndpointExtensionsTests : IDisposable
|
||||
Assert.Contains("boom-msg", capturedDetail);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that successful authentication stashes the verified key display name on the HTTP context items.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task SuccessfulAuth_StashesVerifiedKeyDisplayNameOnHttpContextItems()
|
||||
{
|
||||
@@ -505,6 +536,8 @@ public sealed class EndpointExtensionsTests : IDisposable
|
||||
Assert.Equal("audit-actor-name", capture.CapturedActor);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a valid request emits the inbound API request counter tagged with the resolved method name.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task ValidRequest_EmitsInboundApiRequestCounter_TaggedWithResolvedMethodName()
|
||||
{
|
||||
@@ -532,6 +565,8 @@ public sealed class EndpointExtensionsTests : IDisposable
|
||||
Assert.Equal(1, echoTotal);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that an unknown method emits the inbound API request counter with the bounded forbidden sentinel.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task UnknownMethod_EmitsInboundApiRequestCounter_WithBoundedForbiddenSentinel()
|
||||
{
|
||||
@@ -569,6 +604,7 @@ public sealed class EndpointExtensionsTests : IDisposable
|
||||
private readonly List<(long Value, string? Method)> _measurements = new();
|
||||
private readonly object _gate = new();
|
||||
|
||||
/// <summary>Initializes the collector and starts listening for the inbound API request counter.</summary>
|
||||
public InboundApiRequestCounterCollector()
|
||||
{
|
||||
_listener = new MeterListener
|
||||
@@ -601,6 +637,7 @@ public sealed class EndpointExtensionsTests : IDisposable
|
||||
_listener.Start();
|
||||
}
|
||||
|
||||
/// <summary>Gets a snapshot of the recorded counter measurements.</summary>
|
||||
public IReadOnlyList<(long Value, string? Method)> Measurements
|
||||
{
|
||||
get
|
||||
@@ -612,19 +649,22 @@ public sealed class EndpointExtensionsTests : IDisposable
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Releases the resources held by the test fixture.</summary>
|
||||
public void Dispose() => _listener.Dispose();
|
||||
}
|
||||
|
||||
// --- InboundAPI-028: drive the new script-facing capabilities (Database +
|
||||
// --- Drive the new script-facing capabilities (Database +
|
||||
// WaitForAttribute) end-to-end through the real POST /api/{methodName} flow,
|
||||
// so a wiring regression in executor→context→helper / RouteHelper is caught. ---
|
||||
|
||||
/// <summary>Verifies that a script using the database helper runs end to end through the endpoint.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Script_UsingDatabase_RunsEndToEndThroughEndpoint()
|
||||
{
|
||||
// A method whose script reads through Database.QueryAsync — proving the executor
|
||||
// builds the Database helper, resolves IDatabaseGateway from the per-execution
|
||||
// DI scope, and the parameterized read reaches the body. (InboundAPI-028)
|
||||
// DI scope, and the parameterized read reaches the body.
|
||||
var method = SeedMethod(1, "movein",
|
||||
"return await Database.QuerySingleAsync<string>(\"BTDB\", \"SELECT Code FROM Machine WHERE SAPID=@s\", new { s = (string)Parameters[\"sap\"] });",
|
||||
"""[{"name":"sap","type":"String","required":true}]""");
|
||||
@@ -644,12 +684,14 @@ public sealed class EndpointExtensionsTests : IDisposable
|
||||
Assert.Contains("Z28061A", body);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a script using WaitForAttribute runs end to end through the endpoint.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Script_UsingWaitForAttribute_RunsEndToEndThroughEndpoint()
|
||||
{
|
||||
// A method whose script awaits Route.To(...).WaitForAttribute(...) — proving the
|
||||
// executor binds the RouteHelper and the wait routes through IInstanceRouter and
|
||||
// returns its result through the endpoint. (InboundAPI-028)
|
||||
// returns its result through the endpoint.
|
||||
var method = SeedMethod(2, "waitmethod",
|
||||
"return await Route.To(\"inst-1\").WaitForAttribute(\"Flag\", true, System.TimeSpan.FromSeconds(1));");
|
||||
|
||||
@@ -690,6 +732,8 @@ public sealed class EndpointExtensionsTests : IDisposable
|
||||
{
|
||||
private readonly string _cs;
|
||||
private readonly SqliteConnection _keepAlive;
|
||||
/// <summary>Initializes the gateway with an in-memory SQLite connection string and seeds the machine table.</summary>
|
||||
/// <param name="cs">The SQLite connection string.</param>
|
||||
public SqliteGateway(string cs)
|
||||
{
|
||||
_cs = cs;
|
||||
@@ -699,12 +743,15 @@ public sealed class EndpointExtensionsTests : IDisposable
|
||||
cmd.CommandText = "CREATE TABLE IF NOT EXISTS Machine(Code TEXT, SAPID TEXT); DELETE FROM Machine; INSERT INTO Machine VALUES('Z28061A','131453');";
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
/// <inheritdoc />
|
||||
public async Task<DbConnection> GetConnectionAsync(string name, CancellationToken ct = default)
|
||||
{ var c = new SqliteConnection(_cs); await c.OpenAsync(ct); return c; }
|
||||
/// <inheritdoc />
|
||||
public Task<ExternalCallResult> CachedWriteAsync(string c, string s,
|
||||
IReadOnlyDictionary<string, object?>? p = null, string? o = null, CancellationToken ct = default,
|
||||
TrackedOperationId? t = null, Guid? e = null, string? src = null, Guid? pe = null)
|
||||
=> throw new NotImplementedException();
|
||||
/// <summary>Releases the resources held by the test fixture.</summary>
|
||||
public void Dispose() => _keepAlive.Dispose();
|
||||
}
|
||||
|
||||
@@ -836,6 +883,7 @@ public sealed class EndpointExtensionsTests : IDisposable
|
||||
return await hostBuilder.StartAsync();
|
||||
}
|
||||
|
||||
/// <summary>Releases the resources held by the test fixture.</summary>
|
||||
public void Dispose()
|
||||
{
|
||||
try
|
||||
|
||||
@@ -7,7 +7,7 @@ using Microsoft.Extensions.Options;
|
||||
namespace ZB.MOM.WW.ScadaBridge.InboundAPI.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// InboundAPI-006 / InboundAPI-008: the POST /api/{methodName} endpoint must be
|
||||
/// 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>
|
||||
@@ -46,8 +46,10 @@ public class EndpointGatingTests
|
||||
};
|
||||
}
|
||||
|
||||
// --- InboundAPI-008: standby node must not serve inbound API calls ---
|
||||
// --- Standby node must not serve inbound API calls ---
|
||||
|
||||
/// <summary>Verifies that a standby node short-circuits with a 503 and does not run the handler.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task StandbyNode_ShortCircuitsWith503_AndDoesNotRunHandler()
|
||||
{
|
||||
@@ -61,6 +63,8 @@ public class EndpointGatingTests
|
||||
Assert.Equal(StatusCodes.Status503ServiceUnavailable, status.StatusCode);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that an active node passes the gate and runs the handler.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task ActiveNode_PassesGate_RunsHandler()
|
||||
{
|
||||
@@ -72,6 +76,8 @@ public class EndpointGatingTests
|
||||
Assert.True(handlerRan());
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the handler runs when no gate is registered.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task NoGateRegistered_PassesGate_RunsHandler()
|
||||
{
|
||||
@@ -85,8 +91,10 @@ public class EndpointGatingTests
|
||||
Assert.True(handlerRan());
|
||||
}
|
||||
|
||||
// --- InboundAPI-006: request body size must be capped ---
|
||||
// --- Request body size must be capped ---
|
||||
|
||||
/// <summary>Verifies that an oversized body short-circuits with a 413 and does not run the handler.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task OversizedBody_ShortCircuitsWith413_AndDoesNotRunHandler()
|
||||
{
|
||||
@@ -102,6 +110,8 @@ public class EndpointGatingTests
|
||||
Assert.Equal(StatusCodes.Status413PayloadTooLarge, status.StatusCode);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a body exactly at the limit runs the handler.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task BodyAtLimit_RunsHandler()
|
||||
{
|
||||
@@ -115,6 +125,8 @@ public class EndpointGatingTests
|
||||
Assert.True(handlerRan());
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the filter caps the max request body size feature.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task FilterCapsMaxRequestBodySizeFeature()
|
||||
{
|
||||
@@ -135,18 +147,25 @@ public class EndpointGatingTests
|
||||
private sealed class StubActiveNodeGate : IActiveNodeGate
|
||||
{
|
||||
private readonly bool _isActive;
|
||||
/// <summary>Initializes the gate with a fixed active-node answer.</summary>
|
||||
/// <param name="isActive">Whether this node reports itself as active.</param>
|
||||
public StubActiveNodeGate(bool isActive) => _isActive = isActive;
|
||||
/// <inheritdoc />
|
||||
public bool IsActiveNode => _isActive;
|
||||
}
|
||||
|
||||
private sealed class StubMaxBodySizeFeature : IHttpMaxRequestBodySizeFeature
|
||||
{
|
||||
/// <summary>Gets a value indicating the feature is writable (always false).</summary>
|
||||
public bool IsReadOnly => false;
|
||||
/// <summary>Gets or sets the maximum request body size in bytes.</summary>
|
||||
public long? MaxRequestBodySize { get; set; }
|
||||
}
|
||||
|
||||
// --- InboundAPI hot-reload: options must be re-read per request, not cached at ctor time ---
|
||||
|
||||
/// <summary>Verifies that the body size cap hot-reloads without a restart.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task BodySizeCap_HotReloads_WithoutRestart()
|
||||
{
|
||||
@@ -172,9 +191,18 @@ public class EndpointGatingTests
|
||||
|
||||
private sealed class TestOptionsMonitor<T> : IOptionsMonitor<T>
|
||||
{
|
||||
/// <summary>Initializes the monitor with a fixed current value.</summary>
|
||||
/// <param name="currentValue">The value returned by the monitor.</param>
|
||||
public TestOptionsMonitor(T currentValue) => CurrentValue = currentValue;
|
||||
/// <summary>Gets the fixed options instance.</summary>
|
||||
public T CurrentValue { get; set; }
|
||||
/// <summary>Returns the fixed options instance regardless of name.</summary>
|
||||
/// <param name="name">The named-options key, ignored.</param>
|
||||
/// <returns>The fixed options instance.</returns>
|
||||
public T Get(string? name) => CurrentValue;
|
||||
/// <summary>Does nothing; the fake never raises change notifications.</summary>
|
||||
/// <param name="listener">The change listener, ignored.</param>
|
||||
/// <returns>Always null.</returns>
|
||||
public IDisposable? OnChange(Action<T, string?> listener) => null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
namespace ZB.MOM.WW.ScadaBridge.InboundAPI.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// InboundAPI-005 / InboundAPI-015: tests for the script-trust-model checker.
|
||||
/// Tests for the script-trust-model checker.
|
||||
///
|
||||
/// Since M3.4 <see cref="ForbiddenApiChecker"/> delegates to the shared
|
||||
/// The <see cref="ForbiddenApiChecker"/> delegates to the shared
|
||||
/// <c>ScriptTrustValidator</c> in <c>ZB.MOM.WW.ScadaBridge.ScriptAnalysis</c>.
|
||||
/// The unified policy differs from the old local deny-list in one notable way:
|
||||
/// <c>System.Diagnostics</c> is loosened to <c>System.Diagnostics.Process</c>
|
||||
/// only — <c>Stopwatch</c>, <c>Debug</c>, and other non-Process Diagnostics
|
||||
/// types are now ALLOWED.
|
||||
///
|
||||
/// InboundAPI-015 hardening (reflection-gateway / dynamic / Activator) is
|
||||
/// Hardening (reflection-gateway / dynamic / Activator) is
|
||||
/// preserved unchanged via the shared validator.
|
||||
///
|
||||
/// Violation message wording is intentionally not asserted — callers should
|
||||
@@ -23,6 +23,8 @@ public class ForbiddenApiCheckerTests
|
||||
|
||||
// --- Baseline: legitimate scripts must still pass ---
|
||||
|
||||
/// <summary>Verifies that a legitimate script is not rejected.</summary>
|
||||
/// <param name="script">The permitted script source.</param>
|
||||
[Theory]
|
||||
[InlineData("return 1 + 1;")]
|
||||
[InlineData("var list = new List<int> { 1, 2, 3 }; return list.Sum();")]
|
||||
@@ -36,9 +38,10 @@ public class ForbiddenApiCheckerTests
|
||||
}
|
||||
|
||||
// --- System.Diagnostics loosening: Process is still forbidden; non-Process types
|
||||
// (Stopwatch, Debug) are now ALLOWED by the unified policy (M3.4). The old
|
||||
// (Stopwatch, Debug) are now ALLOWED by the unified policy. The old
|
||||
// InboundAPI checker blocked the entire System.Diagnostics namespace. ---
|
||||
|
||||
/// <summary>Verifies that System.Diagnostics.Process is still forbidden.</summary>
|
||||
[Fact]
|
||||
public void Diagnostics_Process_StillForbidden()
|
||||
{
|
||||
@@ -46,6 +49,7 @@ public class ForbiddenApiCheckerTests
|
||||
Assert.True(IsRejected("System.Diagnostics.Process.Start(\"/bin/sh\"); return null;"));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that Stopwatch from System.Diagnostics is now allowed.</summary>
|
||||
[Fact]
|
||||
public void Diagnostics_Stopwatch_NowAllowed()
|
||||
{
|
||||
@@ -55,6 +59,7 @@ public class ForbiddenApiCheckerTests
|
||||
Assert.False(IsRejected("var sw = new System.Diagnostics.Stopwatch(); sw.Start(); return null;"));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that Debug from System.Diagnostics is now allowed.</summary>
|
||||
[Fact]
|
||||
public void Diagnostics_Debug_NowAllowed()
|
||||
{
|
||||
@@ -64,6 +69,8 @@ public class ForbiddenApiCheckerTests
|
||||
|
||||
// --- Baseline: forbidden namespaces (textual) must still be rejected ---
|
||||
|
||||
/// <summary>Verifies that a script using a forbidden namespace is rejected.</summary>
|
||||
/// <param name="script">The script source that uses a forbidden namespace.</param>
|
||||
[Theory]
|
||||
[InlineData("System.IO.File.Delete(\"/tmp/x\"); return null;")]
|
||||
[InlineData("using System.Reflection; return null;")]
|
||||
@@ -73,8 +80,9 @@ public class ForbiddenApiCheckerTests
|
||||
Assert.True(IsRejected(script), script);
|
||||
}
|
||||
|
||||
// --- InboundAPI-015: reflection reachable without a forbidden namespace token ---
|
||||
// --- Reflection reachable without a forbidden namespace token ---
|
||||
|
||||
/// <summary>Verifies that reflection through assembly property access is rejected.</summary>
|
||||
[Fact]
|
||||
public void Reflection_AssemblyPropertyAccess_Rejected()
|
||||
{
|
||||
@@ -82,6 +90,7 @@ public class ForbiddenApiCheckerTests
|
||||
Assert.True(IsRejected("var a = typeof(string).Assembly; return null;"));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that reflection through Assembly.GetType is rejected.</summary>
|
||||
[Fact]
|
||||
public void Reflection_AssemblyGetType_Rejected()
|
||||
{
|
||||
@@ -90,6 +99,7 @@ public class ForbiddenApiCheckerTests
|
||||
"var t = typeof(string).Assembly.GetType(\"System.IO.File\"); return null;"));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that reflection through Object.GetType is rejected.</summary>
|
||||
[Fact]
|
||||
public void Reflection_ObjectGetType_Rejected()
|
||||
{
|
||||
@@ -97,12 +107,14 @@ public class ForbiddenApiCheckerTests
|
||||
Assert.True(IsRejected("var t = \"\".GetType(); return null;"));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the static Type.GetType reflection entry point is rejected.</summary>
|
||||
[Fact]
|
||||
public void Reflection_TypeGetTypeStatic_Rejected()
|
||||
{
|
||||
Assert.True(IsRejected("var t = Type.GetType(\"System.IO.File\"); return null;"));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that Activator.CreateInstance is rejected.</summary>
|
||||
[Fact]
|
||||
public void Reflection_ActivatorCreateInstance_Rejected()
|
||||
{
|
||||
@@ -110,6 +122,7 @@ public class ForbiddenApiCheckerTests
|
||||
"var o = Activator.CreateInstance(Type.GetType(\"System.IO.File\")); return null;"));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that Type.InvokeMember is rejected.</summary>
|
||||
[Fact]
|
||||
public void Reflection_InvokeMember_Rejected()
|
||||
{
|
||||
@@ -117,6 +130,7 @@ public class ForbiddenApiCheckerTests
|
||||
"typeof(object).InvokeMember(\"x\", default, null, null, null); return null;"));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that GetMethod followed by Invoke is rejected.</summary>
|
||||
[Fact]
|
||||
public void Reflection_GetMethodInvoke_Rejected()
|
||||
{
|
||||
@@ -124,12 +138,14 @@ public class ForbiddenApiCheckerTests
|
||||
"var m = typeof(object).GetMethod(\"ToString\"); return null;"));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that GetTypeInfo is rejected.</summary>
|
||||
[Fact]
|
||||
public void Reflection_GetTypeInfo_Rejected()
|
||||
{
|
||||
Assert.True(IsRejected("var ti = \"\".GetType().GetTypeInfo(); return null;"));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the dynamic keyword is rejected.</summary>
|
||||
[Fact]
|
||||
public void DynamicKeyword_Rejected()
|
||||
{
|
||||
@@ -139,6 +155,8 @@ public class ForbiddenApiCheckerTests
|
||||
|
||||
// --- FindViolations returns a non-null, non-throwing result for edge inputs ---
|
||||
|
||||
/// <summary>Verifies that an empty, whitespace or null script yields an empty violation list.</summary>
|
||||
/// <param name="script">The empty, whitespace or null script source.</param>
|
||||
[Theory]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
|
||||
@@ -16,6 +16,8 @@ public class InboundApiOptionsValidatorTests
|
||||
private static ValidateOptionsResult Validate(InboundApiOptions options) =>
|
||||
new InboundApiOptionsValidator().Validate(Options.DefaultName, options);
|
||||
|
||||
/// <summary>Verifies that the default options are valid.</summary>
|
||||
/// <returns>The result.</returns>
|
||||
[Fact]
|
||||
public void DefaultOptions_AreValid()
|
||||
{
|
||||
@@ -23,6 +25,8 @@ public class InboundApiOptionsValidatorTests
|
||||
Assert.True(result.Succeeded, result.FailureMessage);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that an empty API key pepper is valid.</summary>
|
||||
/// <returns>The result.</returns>
|
||||
[Fact]
|
||||
public void EmptyApiKeyPepper_IsValid()
|
||||
{
|
||||
@@ -30,6 +34,8 @@ public class InboundApiOptionsValidatorTests
|
||||
Assert.True(result.Succeeded, result.FailureMessage);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a zero default method timeout is rejected.</summary>
|
||||
/// <returns>The result.</returns>
|
||||
[Fact]
|
||||
public void ZeroDefaultMethodTimeout_IsRejected()
|
||||
{
|
||||
@@ -39,6 +45,8 @@ public class InboundApiOptionsValidatorTests
|
||||
Assert.Contains("DefaultMethodTimeout", result.FailureMessage);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a negative default method timeout is rejected.</summary>
|
||||
/// <returns>The result.</returns>
|
||||
[Fact]
|
||||
public void NegativeDefaultMethodTimeout_IsRejected()
|
||||
{
|
||||
@@ -48,6 +56,8 @@ public class InboundApiOptionsValidatorTests
|
||||
Assert.Contains("DefaultMethodTimeout", result.FailureMessage);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a zero maximum request body size is rejected.</summary>
|
||||
/// <returns>The result.</returns>
|
||||
[Fact]
|
||||
public void ZeroMaxRequestBodyBytes_IsRejected()
|
||||
{
|
||||
@@ -57,6 +67,8 @@ public class InboundApiOptionsValidatorTests
|
||||
Assert.Contains("MaxRequestBodyBytes", result.FailureMessage);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a negative maximum request body size is rejected.</summary>
|
||||
/// <returns>The result.</returns>
|
||||
[Fact]
|
||||
public void NegativeMaxRequestBodyBytes_IsRejected()
|
||||
{
|
||||
@@ -66,6 +78,8 @@ public class InboundApiOptionsValidatorTests
|
||||
Assert.Contains("MaxRequestBodyBytes", result.FailureMessage);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a zero API method cache TTL is rejected.</summary>
|
||||
/// <returns>The result.</returns>
|
||||
[Fact]
|
||||
public void ZeroApiMethodCacheTtl_IsRejected()
|
||||
{
|
||||
@@ -75,6 +89,8 @@ public class InboundApiOptionsValidatorTests
|
||||
Assert.Contains("ApiMethodCacheTtl", result.FailureMessage);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a negative API method cache TTL is rejected.</summary>
|
||||
/// <returns>The result.</returns>
|
||||
[Fact]
|
||||
public void NegativeApiMethodCacheTtl_IsRejected()
|
||||
{
|
||||
@@ -86,6 +102,8 @@ public class InboundApiOptionsValidatorTests
|
||||
|
||||
// --- Standby forwarding (2026-08-28) ---
|
||||
|
||||
/// <summary>Verifies that absolute HTTP URLs are valid standby forward peer endpoints.</summary>
|
||||
/// <returns>The result.</returns>
|
||||
[Fact]
|
||||
public void StandbyForwardPeerEndpoints_AbsoluteHttpUrls_AreValid()
|
||||
{
|
||||
@@ -96,6 +114,9 @@ public class InboundApiOptionsValidatorTests
|
||||
Assert.True(result.Succeeded, result.FailureMessage);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that non-HTTP or relative standby forward peer endpoints are rejected.</summary>
|
||||
/// <param name="peer">The peer.</param>
|
||||
/// <returns>The result.</returns>
|
||||
[Theory]
|
||||
[InlineData("central-b:5000")]
|
||||
[InlineData("/api")]
|
||||
@@ -109,6 +130,8 @@ public class InboundApiOptionsValidatorTests
|
||||
Assert.Contains("StandbyForwardPeerEndpoints", result.FailureMessage);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a zero standby forward timeout is rejected.</summary>
|
||||
/// <returns>The result.</returns>
|
||||
[Fact]
|
||||
public void ZeroStandbyForwardTimeout_IsRejected()
|
||||
{
|
||||
@@ -120,6 +143,9 @@ public class InboundApiOptionsValidatorTests
|
||||
|
||||
// --- Flow Debugger step logs (design 2026-09-09 D7) ---
|
||||
|
||||
/// <summary>Verifies that a non-positive maximum log line count per run is rejected.</summary>
|
||||
/// <param name="max">The max.</param>
|
||||
/// <returns>The result.</returns>
|
||||
[Theory]
|
||||
[InlineData(0)]
|
||||
[InlineData(-1)]
|
||||
|
||||
@@ -14,7 +14,7 @@ using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
||||
namespace ZB.MOM.WW.ScadaBridge.InboundAPI.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Flow Debugger outbound calls (plan 2026-09-06, Task 4 / D7): every
|
||||
/// Flow Debugger outbound calls (plan 2026-09-06, D7): every
|
||||
/// <see cref="InboundDatabaseHelper"/> call made by a central method script writes ONE
|
||||
/// <c>DbOutbound.DbWrite</c> row through <see cref="ICentralAuditWriter"/> — best-effort,
|
||||
/// never into the script — using the site's <c>AuditingDbCommand</c> vocabulary
|
||||
@@ -32,6 +32,7 @@ public class InboundDatabaseHelperAuditTests
|
||||
private readonly string _cs = $"DataSource=file:dbaudit-{Guid.NewGuid():N}?mode=memory&cache=shared";
|
||||
private readonly SqliteConnection _keepAlive;
|
||||
|
||||
/// <summary>Opens the keep-alive connection and seeds the Machine table.</summary>
|
||||
public SqliteGateway()
|
||||
{
|
||||
_keepAlive = new SqliteConnection(_cs);
|
||||
@@ -41,8 +42,10 @@ public class InboundDatabaseHelperAuditTests
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<DbConnection> GetConnectionAsync(string name, CancellationToken ct = default)
|
||||
{ var c = new SqliteConnection(_cs); await c.OpenAsync(ct); return c; }
|
||||
/// <inheritdoc />
|
||||
public Task<ExternalCallResult> CachedWriteAsync(string c, string s,
|
||||
IReadOnlyDictionary<string, object?>? p = null, string? o = null, CancellationToken ct = default,
|
||||
TrackedOperationId? t = null, Guid? e = null, string? src = null, Guid? pe = null)
|
||||
@@ -51,8 +54,10 @@ public class InboundDatabaseHelperAuditTests
|
||||
|
||||
private sealed class ThrowingGateway : IDatabaseGateway
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public Task<DbConnection> GetConnectionAsync(string name, CancellationToken ct = default)
|
||||
=> throw new InvalidOperationException("no such connection: " + name);
|
||||
/// <inheritdoc />
|
||||
public Task<ExternalCallResult> CachedWriteAsync(string c, string s,
|
||||
IReadOnlyDictionary<string, object?>? p = null, string? o = null, CancellationToken ct = default,
|
||||
TrackedOperationId? t = null, Guid? e = null, string? src = null, Guid? pe = null)
|
||||
@@ -66,11 +71,26 @@ public class InboundDatabaseHelperAuditTests
|
||||
/// </summary>
|
||||
private sealed class RecordingLogger : ILogger
|
||||
{
|
||||
/// <summary>Gets the log entries recorded so far.</summary>
|
||||
public List<(LogLevel Level, string Message, Exception? Exception)> Entries { get; } = new();
|
||||
|
||||
/// <summary>Returns no scope; scopes are not captured.</summary>
|
||||
/// <typeparam name="TState">The scope state type.</typeparam>
|
||||
/// <param name="state">The scope state (ignored).</param>
|
||||
/// <returns>Always null.</returns>
|
||||
public IDisposable? BeginScope<TState>(TState state) where TState : notnull => null;
|
||||
/// <summary>Reports every log level as enabled.</summary>
|
||||
/// <param name="logLevel">The log level being queried.</param>
|
||||
/// <returns>Always true.</returns>
|
||||
public bool IsEnabled(LogLevel logLevel) => true;
|
||||
|
||||
/// <summary>Records the formatted log entry for later assertions.</summary>
|
||||
/// <typeparam name="TState">The log state type.</typeparam>
|
||||
/// <param name="logLevel">The entry's log level.</param>
|
||||
/// <param name="eventId">The entry's event id.</param>
|
||||
/// <param name="state">The entry's state.</param>
|
||||
/// <param name="exception">The exception attached to the entry, if any.</param>
|
||||
/// <param name="formatter">Formats the state and exception into the message.</param>
|
||||
public void Log<TState>(
|
||||
LogLevel logLevel, EventId eventId, TState state, Exception? exception,
|
||||
Func<TState, Exception?, string> formatter)
|
||||
@@ -87,6 +107,7 @@ public class InboundDatabaseHelperAuditTests
|
||||
private readonly Guid _exec = Guid.NewGuid();
|
||||
private readonly Guid _parent = Guid.NewGuid();
|
||||
|
||||
/// <summary>Wires the audit writer double to record written events and names the node.</summary>
|
||||
public InboundDatabaseHelperAuditTests()
|
||||
{
|
||||
_writer.WriteAsync(Arg.Do<AuditEvent>(e => _written.Add(e)), Arg.Any<CancellationToken>()).Returns(Task.CompletedTask);
|
||||
@@ -99,6 +120,8 @@ public class InboundDatabaseHelperAuditTests
|
||||
auditWriter: writer ?? _writer, nodeIdentity: _node,
|
||||
executionId: _exec, parentExecutionId: _parent, methodName: "movein", traceSink: sink);
|
||||
|
||||
/// <summary>Verifies that a successful execute writes a delivered database-write row carrying the rows-affected count.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task ExecuteAsync_WritesDbWriteDeliveredRow_WithRowsAffected()
|
||||
{
|
||||
@@ -126,6 +149,8 @@ public class InboundDatabaseHelperAuditTests
|
||||
Assert.Contains("\"@c\":\"Z28061A\"", row.RequestSummary);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a query writes a read row carrying the rows-returned count.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task QueryAsync_WritesReadRow_WithRowsReturned()
|
||||
{
|
||||
@@ -140,6 +165,8 @@ public class InboundDatabaseHelperAuditTests
|
||||
Assert.Equal("{\"op\":\"read\",\"rowsReturned\":1}", row.Extra);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a single-row query with no match writes a read row reporting zero rows returned.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task QuerySingleAsync_WritesReadRow_ZeroRowsWhenNoMatch()
|
||||
{
|
||||
@@ -152,6 +179,8 @@ public class InboundDatabaseHelperAuditTests
|
||||
Assert.Equal("{\"op\":\"read\",\"rowsReturned\":0}", row.Extra);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a gateway failure writes a failed row and the exception still reaches the script.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task ThrowingGateway_WritesFailedRow_AndRethrowsToScript()
|
||||
{
|
||||
@@ -170,6 +199,8 @@ public class InboundDatabaseHelperAuditTests
|
||||
Assert.Contains("\"sql\":\"DELETE FROM Machine\"", row.RequestSummary);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a faulting audit writer does not fail the database call.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task AuditWriterFaulting_DoesNotFailTheCall()
|
||||
{
|
||||
@@ -187,6 +218,8 @@ public class InboundDatabaseHelperAuditTests
|
||||
Assert.Equal(1, n2);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a faulting audit writer logs a warning while the call still succeeds.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task AuditWriterFaulting_LogsWarning_AndCallStillSucceeds()
|
||||
{
|
||||
@@ -213,6 +246,8 @@ public class InboundDatabaseHelperAuditTests
|
||||
Assert.Contains("MesDb", entry.Message);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the trace sink receives the same event instance before the audit writer does.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task TraceSink_ReceivesTheSameInstance_BeforeTheWriter()
|
||||
{
|
||||
@@ -226,6 +261,8 @@ public class InboundDatabaseHelperAuditTests
|
||||
Assert.Same(order[0].Evt, order[1].Evt);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a throwing trace sink does not fail the call and the writer still receives the row.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task ThrowingSink_DoesNotFailTheCall_AndWriterStillGetsTheRow()
|
||||
{
|
||||
@@ -237,6 +274,8 @@ public class InboundDatabaseHelperAuditTests
|
||||
Assert.Single(_written);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that with no writer and no sink nothing is emitted and the call still works.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task NoWriterNoSink_EmitsNothing_AndStillWorks()
|
||||
{
|
||||
@@ -248,6 +287,8 @@ public class InboundDatabaseHelperAuditTests
|
||||
Assert.Empty(_written);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the script executor passes the writer, node identity, execution id and route sink to the helper.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Executor_WiresWriterNodeIdentityExecutionIdAndRouteSink_IntoTheHelper()
|
||||
{
|
||||
|
||||
@@ -12,14 +12,21 @@ public class InboundDatabaseHelperTests
|
||||
private sealed class SqliteGateway : IDatabaseGateway
|
||||
{
|
||||
private readonly string _cs;
|
||||
// InboundAPI-027 test seam: when false the open ignores the passed token, so a
|
||||
// Test seam: When false the open ignores the passed token, so a
|
||||
// pre-cancelled token surfaces at Execute/Read (not at open) — letting a test
|
||||
// prove the EXECUTE path honours the deadline token, not just the open.
|
||||
private readonly bool _honorTokenOnOpen;
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <c>SqliteGateway</c> class.
|
||||
/// </summary>
|
||||
/// <param name="cs">The cs.</param>
|
||||
/// <param name="honorTokenOnOpen">The honor token on open.</param>
|
||||
public SqliteGateway(string cs, bool honorTokenOnOpen = true)
|
||||
{ _cs = cs; _honorTokenOnOpen = honorTokenOnOpen; }
|
||||
/// <inheritdoc />
|
||||
public async Task<DbConnection> GetConnectionAsync(string name, CancellationToken ct = default)
|
||||
{ var c = new SqliteConnection(_cs); await c.OpenAsync(_honorTokenOnOpen ? ct : CancellationToken.None); return c; }
|
||||
/// <inheritdoc />
|
||||
public Task<ExternalCallResult> CachedWriteAsync(string c, string s,
|
||||
IReadOnlyDictionary<string, object?>? p = null, string? o = null, CancellationToken ct = default,
|
||||
TrackedOperationId? t = null, Guid? e = null, string? src = null, Guid? pe = null)
|
||||
@@ -36,6 +43,8 @@ public class InboundDatabaseHelperTests
|
||||
return new SqliteGateway("DataSource=file:movein?mode=memory&cache=shared", honorTokenOnOpen);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that query single async returns first column with bound parameter.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task QuerySingleAsync_returns_first_column_with_bound_parameter()
|
||||
{
|
||||
@@ -44,6 +53,8 @@ public class InboundDatabaseHelperTests
|
||||
Assert.Equal("Z28061A", code);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that query single async returns default when no rows.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task QuerySingleAsync_returns_default_when_no_rows()
|
||||
{
|
||||
@@ -52,6 +63,8 @@ public class InboundDatabaseHelperTests
|
||||
Assert.Null(code);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that query async returns row with case insensitive keys.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task QueryAsync_returns_row_with_case_insensitive_keys()
|
||||
{
|
||||
@@ -63,6 +76,8 @@ public class InboundDatabaseHelperTests
|
||||
Assert.Equal("Z28061A", row["CODE"]);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that query async returns empty list when no rows match.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task QueryAsync_returns_empty_list_when_no_rows_match()
|
||||
{
|
||||
@@ -71,8 +86,10 @@ public class InboundDatabaseHelperTests
|
||||
Assert.Empty(rows);
|
||||
}
|
||||
|
||||
// --- InboundAPI-026: writes are permitted (design decision) ---
|
||||
// --- Writes are permitted (design decision) ---
|
||||
|
||||
/// <summary>Verifies that execute async performs write and returns rows affected.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task ExecuteAsync_performs_write_and_returns_rows_affected()
|
||||
{
|
||||
@@ -86,8 +103,10 @@ public class InboundDatabaseHelperTests
|
||||
Assert.Equal("UPDATED", code);
|
||||
}
|
||||
|
||||
// --- InboundAPI-026: SQL-injection protection — values are bound, not concatenated ---
|
||||
// --- SQL-injection protection — values are bound, not concatenated ---
|
||||
|
||||
/// <summary>Verifies that parameter values are bound not concatenated.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task ParameterValues_are_bound_not_concatenated()
|
||||
{
|
||||
@@ -101,8 +120,10 @@ public class InboundDatabaseHelperTests
|
||||
Assert.Null(code);
|
||||
}
|
||||
|
||||
// --- InboundAPI-028: negative paths ---
|
||||
// --- Negative paths ---
|
||||
|
||||
/// <summary>Verifies that query single async null gateway throws.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task QuerySingleAsync_null_gateway_throws()
|
||||
{
|
||||
@@ -111,6 +132,8 @@ public class InboundDatabaseHelperTests
|
||||
() => helper.QuerySingleAsync<string>("BTDB", "SELECT 1"));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that query async null gateway throws.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task QueryAsync_null_gateway_throws()
|
||||
{
|
||||
@@ -119,6 +142,8 @@ public class InboundDatabaseHelperTests
|
||||
() => helper.QueryAsync("BTDB", "SELECT 1"));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that execute async null gateway throws.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task ExecuteAsync_null_gateway_throws()
|
||||
{
|
||||
@@ -127,8 +152,10 @@ public class InboundDatabaseHelperTests
|
||||
() => helper.ExecuteAsync("BTDB", "DELETE FROM Machine"));
|
||||
}
|
||||
|
||||
// --- InboundAPI-027: the method-deadline token bounds the query on the EXECUTE path ---
|
||||
// --- The method-deadline token bounds the query on the EXECUTE path ---
|
||||
|
||||
/// <summary>Verifies that query single async honours cancellation token on execute.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task QuerySingleAsync_honours_cancellation_token_on_execute()
|
||||
{
|
||||
|
||||
@@ -16,6 +16,7 @@ public class InboundScriptExecutorStalenessTests
|
||||
private readonly InboundScriptExecutor _executor;
|
||||
private readonly RouteHelper _route;
|
||||
|
||||
/// <summary>Initializes the test class with an executor and route helper backed by substitutes.</summary>
|
||||
public InboundScriptExecutorStalenessTests()
|
||||
{
|
||||
_executor = new InboundScriptExecutor(
|
||||
@@ -27,6 +28,8 @@ public class InboundScriptExecutorStalenessTests
|
||||
private Task<InboundScriptResult> Run(ApiMethod m) => _executor.ExecuteAsync(
|
||||
m, new Dictionary<string, object?>(), _route, TimeSpan.FromSeconds(10));
|
||||
|
||||
/// <summary>Verifies that an updated script is recompiled rather than served from a stale handler.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task UpdatedScript_RecompilesInsteadOfServingStaleHandler()
|
||||
{
|
||||
@@ -45,6 +48,8 @@ public class InboundScriptExecutorStalenessTests
|
||||
Assert.Contains("2", r2.ResultJson); // old cache would have returned 1
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a known-bad method whose script was fixed is compiled instead of fast-failing.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task KnownBadMethod_FixedScript_CompilesInsteadOfFastFailing()
|
||||
{
|
||||
@@ -63,6 +68,8 @@ public class InboundScriptExecutorStalenessTests
|
||||
Assert.Contains("42", r2.ResultJson);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a known-bad method with an unchanged script still fast-fails.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task KnownBadMethod_SameScript_StillFastFails()
|
||||
{
|
||||
@@ -76,6 +83,8 @@ public class InboundScriptExecutorStalenessTests
|
||||
Assert.Contains("Script compilation failed", r.ErrorMessage);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a handler registered through the test seam without a script is not revision checked.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task TestSeamRegisterHandler_WithoutScript_IsNotRevisionChecked()
|
||||
{
|
||||
@@ -87,6 +96,8 @@ public class InboundScriptExecutorStalenessTests
|
||||
Assert.Contains("99", r.ResultJson);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that invalidating a method drops both its handler and its known-bad record.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task InvalidateMethod_DropsHandlerAndKnownBadRecord()
|
||||
{
|
||||
|
||||
@@ -17,14 +17,15 @@ using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
||||
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.
|
||||
/// Tests for script execution on central — timeout, handler dispatch, error handling.
|
||||
/// Also covers safe error messages.
|
||||
/// </summary>
|
||||
public class InboundScriptExecutorTests
|
||||
{
|
||||
private readonly InboundScriptExecutor _executor;
|
||||
private readonly RouteHelper _route;
|
||||
|
||||
/// <summary>Initializes a new instance of the <see cref="InboundScriptExecutorTests"/> class.</summary>
|
||||
public InboundScriptExecutorTests()
|
||||
{
|
||||
_executor = new InboundScriptExecutor(NullLogger<InboundScriptExecutor>.Instance, Substitute.For<IServiceProvider>());
|
||||
@@ -33,6 +34,8 @@ public class InboundScriptExecutorTests
|
||||
_route = new RouteHelper(locator, router);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the trace flag reaches the routed call request.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task ExecuteAsync_TraceFlag_ReachesTheRoutedCallRequest()
|
||||
{
|
||||
@@ -62,6 +65,8 @@ public class InboundScriptExecutorTests
|
||||
Assert.False(captured[1].Trace);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a method with SkipBodyCapture set routes with body capture turned off.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Executor_MethodWithSkipBodyCapture_RoutesWithBodyCaptureOff()
|
||||
{
|
||||
@@ -99,6 +104,8 @@ public class InboundScriptExecutorTests
|
||||
Assert.Null(row.ResponseSummary);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a method without SkipBodyCapture routes with body capture turned on.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Executor_MethodWithoutSkipBodyCapture_RoutesWithBodyCaptureOn()
|
||||
{
|
||||
@@ -133,6 +140,8 @@ public class InboundScriptExecutorTests
|
||||
Assert.Equal("1", row.ResponseSummary);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a registered handler executes successfully.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task RegisteredHandler_ExecutesSuccessfully()
|
||||
{
|
||||
@@ -154,6 +163,8 @@ public class InboundScriptExecutorTests
|
||||
Assert.Contains("42", result.ResultJson);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that an unregistered handler with an invalid script returns a compilation failure.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task UnregisteredHandler_InvalidScript_ReturnsCompilationFailure()
|
||||
{
|
||||
@@ -170,6 +181,8 @@ public class InboundScriptExecutorTests
|
||||
Assert.Contains("Script compilation failed", result.ErrorMessage);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that an unregistered handler with a valid script is compiled lazily.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task UnregisteredHandler_ValidScript_LazyCompiles()
|
||||
{
|
||||
@@ -185,6 +198,8 @@ public class InboundScriptExecutorTests
|
||||
Assert.True(result.Success);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a throwing handler returns a safe error message.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task HandlerThrows_ReturnsSafeErrorMessage()
|
||||
{
|
||||
@@ -198,10 +213,12 @@ public class InboundScriptExecutorTests
|
||||
TimeSpan.FromSeconds(10));
|
||||
|
||||
Assert.False(result.Success);
|
||||
// WP-5: Safe error message — should NOT contain "internal detail leak"
|
||||
// Safe error message — should NOT contain "internal detail leak"
|
||||
Assert.Equal("Internal script error", result.ErrorMessage);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the error detail carries the exception while the message stays safe.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task HandlerThrows_ErrorDetailCarriesTheException_WhileTheMessageStaysSafe()
|
||||
{
|
||||
@@ -229,6 +246,8 @@ public class InboundScriptExecutorTests
|
||||
Assert.DoesNotContain("real reason", result.ErrorMessage!);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a handler that times out carries no error detail.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task HandlerTimesOut_CarriesNoErrorDetail()
|
||||
{
|
||||
@@ -252,6 +271,8 @@ public class InboundScriptExecutorTests
|
||||
Assert.Null(result.ErrorDetail);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a timed-out routed ask returns a 500 with the site-unreachable code.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task RoutedAskTimeout_Returns500WithSiteUnreachableCode()
|
||||
{
|
||||
@@ -276,6 +297,8 @@ public class InboundScriptExecutorTests
|
||||
Assert.Equal("SITE_UNREACHABLE", result.ErrorCode);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a site failure whose message mentions unreachable is reported as a script error rather than site unreachable.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task SiteRespondedFailure_MessageMentioningUnreachable_IsScriptErrorNotSiteUnreachable()
|
||||
{
|
||||
@@ -303,6 +326,8 @@ public class InboundScriptExecutorTests
|
||||
Assert.Equal("SCRIPT_ERROR", result.ErrorCode);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a handler that times out returns a timeout error.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task HandlerTimesOut_ReturnsTimeoutError()
|
||||
{
|
||||
@@ -323,6 +348,8 @@ public class InboundScriptExecutorTests
|
||||
Assert.Contains("timed out", result.ErrorMessage);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a handler can access the request parameters.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task HandlerAccessesParameters()
|
||||
{
|
||||
@@ -342,8 +369,10 @@ public class InboundScriptExecutorTests
|
||||
Assert.Contains("ScadaBridge", result.ResultJson!);
|
||||
}
|
||||
|
||||
// --- InboundAPI-001: concurrent lazy-compile must not corrupt the handler cache ---
|
||||
// --- Concurrent lazy-compile must not corrupt the handler cache ---
|
||||
|
||||
/// <summary>Verifies that concurrent lazy compilation of the same method does not corrupt the handler cache.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task ConcurrentLazyCompile_SameMethod_DoesNotCorruptCache()
|
||||
{
|
||||
@@ -368,6 +397,8 @@ public class InboundScriptExecutorTests
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>Verifies that concurrent registration and execution do not throw.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task ConcurrentRegisterAndExecute_DoesNotThrow()
|
||||
{
|
||||
@@ -397,8 +428,10 @@ public class InboundScriptExecutorTests
|
||||
await Task.WhenAll(writers.Concat(readers));
|
||||
}
|
||||
|
||||
// --- InboundAPI-005: compiled scripts must not bypass the script trust model ---
|
||||
// --- Compiled scripts must not bypass the script trust model ---
|
||||
|
||||
/// <summary>Verifies that compiling and registering a script that uses a forbidden API rejects the script.</summary>
|
||||
/// <param name="script">The script source that uses a forbidden API.</param>
|
||||
[Theory]
|
||||
[InlineData("System.IO.File.Delete(\"/tmp/x\"); return null;")]
|
||||
[InlineData("System.Diagnostics.Process.Start(\"/bin/sh\"); return null;")]
|
||||
@@ -414,6 +447,8 @@ public class InboundScriptExecutorTests
|
||||
Assert.False(registered);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that executing a script that uses a forbidden API does not run it and returns a failure.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task ExecuteAsync_ForbiddenApiScript_DoesNotRunAndReturnsFailure()
|
||||
{
|
||||
@@ -436,6 +471,7 @@ public class InboundScriptExecutorTests
|
||||
Assert.False(System.IO.File.Exists(marker));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a permitted script still registers.</summary>
|
||||
[Fact]
|
||||
public void CompileAndRegister_PermittedScript_StillRegisters()
|
||||
{
|
||||
@@ -451,6 +487,7 @@ public class InboundScriptExecutorTests
|
||||
|
||||
// --- Compile-diagnostics overload: surface errors so a non-compiling save is not a silent no-op ---
|
||||
|
||||
/// <summary>Verifies that a script that does not compile reports errors.</summary>
|
||||
[Fact]
|
||||
public void CompileAndRegister_NonCompilingScript_ReportsErrors()
|
||||
{
|
||||
@@ -465,6 +502,7 @@ public class InboundScriptExecutorTests
|
||||
Assert.NotEmpty(errors);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a valid script reports no errors.</summary>
|
||||
[Fact]
|
||||
public void CompileAndRegister_ValidScript_ReportsNoErrors()
|
||||
{
|
||||
@@ -476,6 +514,8 @@ public class InboundScriptExecutorTests
|
||||
Assert.Empty(errors);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that executing after a non-compiling update surfaces the compile failure.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task CompileAndRegister_NonCompilingUpdate_ExecuteSurfacesCompileFailure()
|
||||
{
|
||||
@@ -509,8 +549,10 @@ public class InboundScriptExecutorTests
|
||||
Assert.Contains("111", stillGood.ResultJson);
|
||||
}
|
||||
|
||||
// --- InboundAPI-002: lazy compile-and-fetch must be atomic, never KeyNotFoundException ---
|
||||
// --- Lazy compile-and-fetch must be atomic, never KeyNotFoundException ---
|
||||
|
||||
/// <summary>Verifies that a lazy compile racing a handler removal never throws a <see cref="KeyNotFoundException"/>.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task LazyCompile_RacingRemoveHandler_NeverThrowsKeyNotFound()
|
||||
{
|
||||
@@ -540,8 +582,10 @@ public class InboundScriptExecutorTests
|
||||
await Task.WhenAll(removers.Concat(callers));
|
||||
}
|
||||
|
||||
// --- InboundAPI-004: a client disconnect must NOT be reported as a script timeout ---
|
||||
// --- A client disconnect must NOT be reported as a script timeout ---
|
||||
|
||||
/// <summary>Verifies that a client disconnect is not reported as a timeout.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task ClientDisconnect_IsNotReportedAsTimeout()
|
||||
{
|
||||
@@ -570,11 +614,13 @@ public class InboundScriptExecutorTests
|
||||
Assert.DoesNotContain("timed out", result.ErrorMessage ?? string.Empty);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a genuine timeout is still reported as a timeout.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[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).
|
||||
// reported as "timed out" (regression guard for the client-disconnect fix).
|
||||
var method = new ApiMethod("genuine", "return 1;") { Id = 1, TimeoutSeconds = 1 };
|
||||
_executor.RegisterHandler("genuine", async ctx =>
|
||||
{
|
||||
@@ -593,9 +639,11 @@ public class InboundScriptExecutorTests
|
||||
Assert.Contains("timed out", result.ErrorMessage);
|
||||
}
|
||||
|
||||
// --- InboundAPI-009: a script that fails to compile must be compiled at most
|
||||
// --- A script that fails to compile must be compiled at most
|
||||
// once — repeated calls must not re-run the expensive Roslyn compilation. ---
|
||||
|
||||
/// <summary>Verifies that a failed compilation is not retried on every request.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task FailedCompilation_IsNotRetriedOnEveryRequest()
|
||||
{
|
||||
@@ -620,6 +668,7 @@ public class InboundScriptExecutorTests
|
||||
Assert.Equal(1, counter.CompilationFailures);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a failed compilation is recompiled after compile-and-register is called again.</summary>
|
||||
[Fact]
|
||||
public void FailedCompilation_RecompilesAfterCompileAndRegisterCalledAgain()
|
||||
{
|
||||
@@ -632,9 +681,10 @@ public class InboundScriptExecutorTests
|
||||
Assert.True(_executor.CompileAndRegister(good));
|
||||
}
|
||||
|
||||
// --- InboundAPI-024: _knownBadMethods must be bounded so a spam attack of
|
||||
// --- _knownBadMethods must be bounded so a spam attack of
|
||||
// unique method names cannot grow the cache without bound. ---
|
||||
|
||||
/// <summary>Verifies that the known-bad-methods cache never exceeds its cap under a flood of unique method names.</summary>
|
||||
[Fact]
|
||||
public void KnownBadMethodsCache_SizeNeverExceedsCap_UnderUniqueNameFlood()
|
||||
{
|
||||
@@ -656,6 +706,8 @@ public class InboundScriptExecutorTests
|
||||
$"known-bad cache size {_executor.KnownBadMethodCount} must not exceed cap {cap}");
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the lazy-compile path also keeps the known-bad-methods cache capped under a flood of unique method names.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task KnownBadMethodsCache_LazyCompilePath_AlsoCappedUnderUniqueNameFlood()
|
||||
{
|
||||
@@ -678,8 +730,10 @@ public class InboundScriptExecutorTests
|
||||
$"known-bad cache size {_executor.KnownBadMethodCount} must not exceed cap {cap}");
|
||||
}
|
||||
|
||||
// --- InboundAPI-014: the script return value is validated against ReturnDefinition ---
|
||||
// --- The script return value is validated against ReturnDefinition ---
|
||||
|
||||
/// <summary>Verifies that a return value matching the return definition succeeds.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task ReturnValue_MatchingReturnDefinition_Succeeds()
|
||||
{
|
||||
@@ -701,6 +755,8 @@ public class InboundScriptExecutorTests
|
||||
Assert.True(result.Success, result.ErrorMessage);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a return value not matching the return definition returns a failure instead of a malformed 200.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task ReturnValue_NotMatchingReturnDefinition_ReturnsFailureNotMalformed200()
|
||||
{
|
||||
@@ -725,6 +781,8 @@ public class InboundScriptExecutorTests
|
||||
Assert.Null(result.ResultJson);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a method with no return definition leaves the return value unconstrained.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task ReturnValue_NoReturnDefinition_IsUnconstrained()
|
||||
{
|
||||
@@ -744,9 +802,11 @@ public class InboundScriptExecutorTests
|
||||
Assert.Contains("whatever", result.ResultJson!);
|
||||
}
|
||||
|
||||
// --- Audit Log #23 (ParentExecutionId, T3): the inbound request's
|
||||
// --- ParentExecutionId: the inbound request's
|
||||
// ExecutionId is threaded through ExecuteAsync onto routed calls ---
|
||||
|
||||
/// <summary>Verifies that executing with a parent execution id makes the routed call carry it as its parent execution id.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task ExecuteAsync_WithParentExecutionId_RoutedCallCarriesItAsParentExecutionId()
|
||||
{
|
||||
@@ -780,6 +840,8 @@ public class InboundScriptExecutorTests
|
||||
Assert.Equal(inboundExecutionId, captured!.ParentExecutionId);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that executing without a parent execution id gives the routed call a null parent execution id.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task ExecuteAsync_WithoutParentExecutionId_RoutedCallHasNullParentExecutionId()
|
||||
{
|
||||
@@ -812,6 +874,8 @@ public class InboundScriptExecutorTests
|
||||
// --- IpsenMES MoveIn: the read-only Database helper is exposed on the script
|
||||
// context and resolves IDatabaseGateway from a DI scope per execution. ---
|
||||
|
||||
/// <summary>Verifies that a script that uses Database queries through the gateway.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Script_UsingDatabase_QueriesViaGateway()
|
||||
{
|
||||
@@ -844,6 +908,8 @@ public class InboundScriptExecutorTests
|
||||
Assert.Contains("Z28061A", result.ResultJson!);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a script that does not use Database runs with a service provider that lacks the gateway.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Script_NotUsingDatabase_RunsWithProviderLackingGateway()
|
||||
{
|
||||
@@ -865,6 +931,8 @@ public class InboundScriptExecutorTests
|
||||
Assert.Contains("99", result.ResultJson!);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a throwing script still disposes its DI scope.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task ScriptThrows_DisposesDiScope()
|
||||
{
|
||||
@@ -915,9 +983,13 @@ public class InboundScriptExecutorTests
|
||||
private sealed class SqliteGateway : IDatabaseGateway
|
||||
{
|
||||
private readonly string _cs;
|
||||
/// <summary>Initializes a new instance of the <see cref="SqliteGateway"/> class.</summary>
|
||||
/// <param name="cs">The SQLite connection string.</param>
|
||||
public SqliteGateway(string cs) => _cs = cs;
|
||||
/// <inheritdoc />
|
||||
public async Task<DbConnection> GetConnectionAsync(string name, CancellationToken ct = default)
|
||||
{ var c = new SqliteConnection(_cs); await c.OpenAsync(ct); return c; }
|
||||
/// <inheritdoc />
|
||||
public Task<ExternalCallResult> CachedWriteAsync(string c, string s,
|
||||
IReadOnlyDictionary<string, object?>? p = null, string? o = null, CancellationToken ct = default,
|
||||
TrackedOperationId? t = null, Guid? e = null, string? src = null, Guid? pe = null)
|
||||
@@ -934,6 +1006,8 @@ public class InboundScriptExecutorTests
|
||||
return new SqliteGateway("DataSource=file:movein-exec?mode=memory&cache=shared");
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a timed-out script has its scope disposal deferred so no scope is orphaned.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task TimedOutScript_ScopeDisposalDeferred_NoOrphanLeak()
|
||||
{
|
||||
@@ -956,6 +1030,8 @@ public class InboundScriptExecutorTests
|
||||
|
||||
// --- Flow Debugger step logs (design 2026-09-09 D4/D5/D7): the Log global ---
|
||||
|
||||
/// <summary>Verifies that a traced run emits one ScriptLog row on the trace sink.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Log_TracedRun_EmitsOneScriptLogRowOnTheTraceSink()
|
||||
{
|
||||
@@ -987,6 +1063,8 @@ public class InboundScriptExecutorTests
|
||||
Assert.Null(extra.StepEventId);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a traced run writes the ScriptLog row to the central audit store.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Log_TracedRun_WritesTheScriptLogRowToTheCentralAuditStore()
|
||||
{
|
||||
@@ -1018,6 +1096,8 @@ public class InboundScriptExecutorTests
|
||||
Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
/// <summary>Verifies that an untraced run writes only to the node logger.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Log_UntracedRun_WritesOnlyToTheNodeLogger()
|
||||
{
|
||||
@@ -1045,6 +1125,8 @@ public class InboundScriptExecutorTests
|
||||
Assert.Contains(recorded, m => m.Contains("unwatched", StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a traced log call with an exception carries both the message and the detail.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Log_TracedWithException_CarriesMessageAndDetail()
|
||||
{
|
||||
@@ -1068,6 +1150,8 @@ public class InboundScriptExecutorTests
|
||||
Assert.Equal("warn", ScriptTraceExtra.TryParse(details.Extra)!.LogLevel);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that log lines past the per-run maximum are dropped and a single closing runtime warning is emitted.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Log_PastMaxLogLinesPerRun_DropsAndEmitsOneClosingRuntimeWarning()
|
||||
{
|
||||
@@ -1110,9 +1194,23 @@ public class InboundScriptExecutorTests
|
||||
// logger saw a line even when nothing was audited.
|
||||
private sealed class RecordingLogger<T>(List<string> messages) : ILogger<T>
|
||||
{
|
||||
/// <summary>Returns no scope; the fake logger does not track scopes.</summary>
|
||||
/// <typeparam name="TState">The scope state type.</typeparam>
|
||||
/// <param name="state">The scope state.</param>
|
||||
/// <returns>Always null.</returns>
|
||||
public IDisposable? BeginScope<TState>(TState state) where TState : notnull => null;
|
||||
/// <summary>Reports every log level as enabled.</summary>
|
||||
/// <param name="logLevel">The log level being queried.</param>
|
||||
/// <returns>Always true.</returns>
|
||||
public bool IsEnabled(LogLevel logLevel) => true;
|
||||
|
||||
/// <summary>Records the log entry for later assertions.</summary>
|
||||
/// <typeparam name="TState">The log state type.</typeparam>
|
||||
/// <param name="logLevel">The entry's log level.</param>
|
||||
/// <param name="eventId">The entry's event id.</param>
|
||||
/// <param name="state">The entry's state.</param>
|
||||
/// <param name="exception">The entry's exception, if any.</param>
|
||||
/// <param name="formatter">Formats the state and exception into the message.</param>
|
||||
public void Log<TState>(
|
||||
LogLevel logLevel, EventId eventId, TState state, Exception? exception,
|
||||
Func<TState, Exception?, string> formatter)
|
||||
@@ -1128,9 +1226,24 @@ public class InboundScriptExecutorTests
|
||||
|
||||
private sealed class CountingLogger<T>(CompileLogCounter counter) : ILogger<T>
|
||||
{
|
||||
/// <summary>Returns no scope; the fake does not track scopes.</summary>
|
||||
/// <typeparam name="TState">The type of the scope state.</typeparam>
|
||||
/// <param name="state">The scope state (ignored).</param>
|
||||
/// <returns>Always null.</returns>
|
||||
public IDisposable? BeginScope<TState>(TState state) where TState : notnull => null;
|
||||
|
||||
/// <summary>Reports every log level as enabled.</summary>
|
||||
/// <param name="logLevel">The log level to check.</param>
|
||||
/// <returns>Always true.</returns>
|
||||
public bool IsEnabled(LogLevel logLevel) => true;
|
||||
|
||||
/// <summary>Counts entries whose message reports a script compilation failure.</summary>
|
||||
/// <typeparam name="TState">The type of the log state.</typeparam>
|
||||
/// <param name="logLevel">The severity of the entry.</param>
|
||||
/// <param name="eventId">The event identifier.</param>
|
||||
/// <param name="state">The state object to format.</param>
|
||||
/// <param name="exception">The exception associated with the entry, if any.</param>
|
||||
/// <param name="formatter">Formats the state and exception into the message.</param>
|
||||
public void Log<TState>(
|
||||
LogLevel logLevel, EventId eventId, TState state, Exception? exception,
|
||||
Func<TState, Exception?, string> formatter)
|
||||
|
||||
@@ -7,7 +7,7 @@ namespace ZB.MOM.WW.ScadaBridge.InboundAPI.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Flow Debugger step logs (design 2026-09-09 D7): the central <c>Log</c> global's per-message cap.
|
||||
/// Final review M2 — the site runtime's <c>ScriptLogger.Clip</c> already backed off a char when the
|
||||
/// The site runtime's <c>ScriptLogger.Clip</c> already backed off a char when the
|
||||
/// cap landed between the halves of a surrogate pair; this side did not, so an emoji straddling the
|
||||
/// boundary left a LONE surrogate on the row (which breaks JSON serialization further down the pipe).
|
||||
/// </summary>
|
||||
@@ -21,6 +21,7 @@ public class InboundScriptLoggerTests
|
||||
return (logger, rows);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a surrogate pair straddling the cap: is dropped whole.</summary>
|
||||
[Fact]
|
||||
public void ASurrogatePairStraddlingTheCap_IsDroppedWhole()
|
||||
{
|
||||
@@ -35,6 +36,7 @@ public class InboundScriptLoggerTests
|
||||
Assert.DoesNotContain(message, static c => char.IsSurrogate(c));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that an overlong message with no surrogate at the boundary: keeps the full cap.</summary>
|
||||
[Fact]
|
||||
public void AnOverlongMessageWithNoSurrogateAtTheBoundary_KeepsTheFullCap()
|
||||
{
|
||||
@@ -47,6 +49,7 @@ public class InboundScriptLoggerTests
|
||||
Assert.Equal(new string('x', InboundScriptLogger.MaxMessageChars) + "…", message);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a message exactly at the cap: passes through unclipped.</summary>
|
||||
[Fact]
|
||||
public void AMessageExactlyAtTheCap_PassesThroughUnclipped()
|
||||
{
|
||||
|
||||
+160
-13
@@ -16,7 +16,7 @@ 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
|
||||
/// 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),
|
||||
@@ -25,6 +25,10 @@ namespace ZB.MOM.WW.ScadaBridge.InboundAPI.Tests.Middleware;
|
||||
/// </summary>
|
||||
public class AuditWriteMiddlewareTests
|
||||
{
|
||||
/// <summary>Verifies that only the peer that serves a relayed request writes the local failure audit row.</summary>
|
||||
/// <param name="scenario">The scenario selector.</param>
|
||||
/// <param name="localRows">The expected number of locally written audit rows.</param>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Theory]
|
||||
[InlineData(0, 1)]
|
||||
[InlineData(1, 1)]
|
||||
@@ -58,19 +62,35 @@ public class AuditWriteMiddlewareTests
|
||||
if (localRows > 0) Assert.NotNull(writer.Events[0].ExecutionId);
|
||||
}
|
||||
|
||||
private sealed class StandbyGate : IActiveNodeGate { public bool IsActiveNode => false; }
|
||||
private sealed class StandbyGate : IActiveNodeGate
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public bool IsActiveNode => false;
|
||||
}
|
||||
private sealed class ResultForwarder(IResult result) : IStandbyRequestForwarder
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public bool IsConfigured => true;
|
||||
/// <inheritdoc />
|
||||
public Task<IResult> ForwardAsync(HttpContext context, CancellationToken cancellationToken) => Task.FromResult(result);
|
||||
}
|
||||
private sealed class InboundOptions(InboundApiOptions value) : IOptionsMonitor<InboundApiOptions>
|
||||
{
|
||||
/// <summary>Gets the fixed options instance.</summary>
|
||||
public InboundApiOptions CurrentValue => value;
|
||||
/// <summary>Returns the fixed options instance regardless of name.</summary>
|
||||
/// <param name="name">The named-options key, ignored.</param>
|
||||
/// <returns>The fixed options instance.</returns>
|
||||
public InboundApiOptions Get(string? name) => value;
|
||||
/// <summary>Does nothing; the fake never raises change notifications.</summary>
|
||||
/// <param name="listener">The change listener, ignored.</param>
|
||||
/// <returns>Always null.</returns>
|
||||
public IDisposable? OnChange(Action<InboundApiOptions, string?> listener) => null;
|
||||
}
|
||||
|
||||
/// <summary>Verifies that skipping body capture suppresses the exception text in the audit row.</summary>
|
||||
/// <param name="escaped">Whether the exception text contains characters that need escaping.</param>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Theory]
|
||||
[InlineData(false)]
|
||||
[InlineData(true)]
|
||||
@@ -100,11 +120,14 @@ public class AuditWriteMiddlewareTests
|
||||
/// </summary>
|
||||
private sealed class RecordingAuditWriter : ICentralAuditWriter
|
||||
{
|
||||
// C3 (Task 2.5): store the decomposed row view so assertions keep
|
||||
// Store the decomposed row view so assertions keep
|
||||
// reading the ScadaBridge domain fields as typed properties.
|
||||
/// <summary>Gets the recorded audit rows.</summary>
|
||||
public List<AuditRowProjection.AuditRowValues> Events { get; } = new();
|
||||
/// <summary>Gets or sets an optional callback invoked on each write.</summary>
|
||||
public Func<AuditEvent, Task>? OnWrite { get; set; }
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task WriteAsync(AuditEvent evt, CancellationToken ct = default)
|
||||
{
|
||||
lock (Events)
|
||||
@@ -170,8 +193,11 @@ public class AuditWriteMiddlewareTests
|
||||
/// </summary>
|
||||
private sealed class StubAuditActorAccessor : IAuditActorAccessor
|
||||
{
|
||||
/// <summary>Initializes the accessor with a fixed actor.</summary>
|
||||
/// <param name="currentActor">The actor string to return, or null for none.</param>
|
||||
public StubAuditActorAccessor(string? currentActor) => CurrentActor = currentActor;
|
||||
|
||||
/// <inheritdoc />
|
||||
public string? CurrentActor { get; }
|
||||
}
|
||||
|
||||
@@ -185,12 +211,21 @@ public class AuditWriteMiddlewareTests
|
||||
{
|
||||
private readonly AuditLogOptions _value;
|
||||
|
||||
/// <summary>Initializes the monitor with a fixed options value.</summary>
|
||||
/// <param name="value">The options value returned on every read.</param>
|
||||
public StaticAuditLogOptionsMonitor(AuditLogOptions value) => _value = value;
|
||||
|
||||
/// <summary>Gets the fixed options instance.</summary>
|
||||
public AuditLogOptions CurrentValue => _value;
|
||||
|
||||
/// <summary>Returns the fixed options instance regardless of name.</summary>
|
||||
/// <param name="name">The named-options key, ignored.</param>
|
||||
/// <returns>The fixed options instance.</returns>
|
||||
public AuditLogOptions Get(string? name) => _value;
|
||||
|
||||
/// <summary>Does nothing; the fake never raises change notifications.</summary>
|
||||
/// <param name="listener">The change listener, ignored.</param>
|
||||
/// <returns>Always null.</returns>
|
||||
public IDisposable? OnChange(Action<AuditLogOptions, string?> listener) => null;
|
||||
}
|
||||
|
||||
@@ -198,6 +233,8 @@ public class AuditWriteMiddlewareTests
|
||||
// 1. Happy path — InboundRequest/Delivered/HttpStatus 200
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
/// <summary>Verifies that a successful request emits one inbound-request event with Delivered status and HTTP 200.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Pipeline_Success_EmitsOneEvent_KindInboundRequest_StatusDelivered_HttpStatus200()
|
||||
{
|
||||
@@ -226,6 +263,8 @@ public class AuditWriteMiddlewareTests
|
||||
// 2. 400 — script/validation failure path
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
/// <summary>Verifies that a 400 response emits an event with Failed status and HTTP 400.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Pipeline_400_EmitsEvent_Status_Failed_HttpStatus400()
|
||||
{
|
||||
@@ -251,6 +290,8 @@ public class AuditWriteMiddlewareTests
|
||||
// 3. 401 — auth failure path
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
/// <summary>Verifies that a 401 response emits an inbound-auth-failure event with Failed status.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Pipeline_401_EmitsEvent_KindInboundAuthFailure_StatusFailed()
|
||||
{
|
||||
@@ -273,6 +314,8 @@ public class AuditWriteMiddlewareTests
|
||||
Assert.Null(evt.Actor);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a 403 response emits an inbound-auth-failure event with Failed status.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Pipeline_403_EmitsEvent_KindInboundAuthFailure_StatusFailed()
|
||||
{
|
||||
@@ -296,6 +339,8 @@ public class AuditWriteMiddlewareTests
|
||||
// 4. 500 — handler threw OR returned 500
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
/// <summary>Verifies that a 500 response emits an event with Failed status.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Pipeline_500_EmitsEvent_Status_Failed()
|
||||
{
|
||||
@@ -315,6 +360,8 @@ public class AuditWriteMiddlewareTests
|
||||
Assert.Equal(500, evt.HttpStatus);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a throwing pipeline emits a Failed event and rethrows.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Pipeline_Throws_EmitsEvent_Status_Failed_And_Rethrows()
|
||||
{
|
||||
@@ -340,6 +387,8 @@ public class AuditWriteMiddlewareTests
|
||||
// message), and the middleware lifts it onto the row.
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
/// <summary>Verifies that a script error detail stashed on the items lands on the audit row.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task ScriptErrorDetail_StashedOnItems_LandsOnTheRow()
|
||||
{
|
||||
@@ -360,6 +409,8 @@ public class AuditWriteMiddlewareTests
|
||||
Assert.Equal(detail, evt.ErrorDetail);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that no stashed error detail leaves the row error detail null.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task NoErrorDetailStashed_LeavesErrorDetailNull()
|
||||
{
|
||||
@@ -377,6 +428,8 @@ public class AuditWriteMiddlewareTests
|
||||
Assert.Null(evt.ErrorDetail);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the error detail of a thrown exception wins over the stashed item.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task ThrownException_ErrorDetail_WinsOverTheStashedItem()
|
||||
{
|
||||
@@ -408,6 +461,8 @@ public class AuditWriteMiddlewareTests
|
||||
// HttpContext.Items.
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
/// <summary>Verifies that the API key name resolved from the HTTP context is recorded as the actor.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task ApiKeyName_Resolved_From_HttpContext_AsActor()
|
||||
{
|
||||
@@ -437,6 +492,8 @@ public class AuditWriteMiddlewareTests
|
||||
// null — never echo an unauthenticated principal back.
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
/// <summary>Verifies that the authenticated user from the accessor is recorded as the actor when there is no API key stash.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task AuthenticatedUser_FromAccessor_RecordedAsActor_WhenNoApiKeyStash()
|
||||
{
|
||||
@@ -459,6 +516,8 @@ public class AuditWriteMiddlewareTests
|
||||
Assert.Equal("alice", evt.Actor);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the API key stash takes precedence over the authenticated principal.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task ApiKeyStash_TakesPrecedence_OverAuthenticatedPrincipal()
|
||||
{
|
||||
@@ -483,6 +542,8 @@ public class AuditWriteMiddlewareTests
|
||||
Assert.Equal("integration-svc", evt.Actor);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that an auth failure keeps the actor null even when the accessor resolves a principal.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task AuthFailure_KeepsActorNull_EvenWhenAccessorResolvesPrincipal()
|
||||
{
|
||||
@@ -506,6 +567,8 @@ public class AuditWriteMiddlewareTests
|
||||
Assert.Null(evt.Actor);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a request with no API key and no authenticated principal leaves the actor null.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task NoApiKey_NoAuthenticatedPrincipal_LeavesActorNull()
|
||||
{
|
||||
@@ -532,6 +595,8 @@ public class AuditWriteMiddlewareTests
|
||||
// 6. Writer failure must NEVER alter the HTTP response
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
/// <summary>Verifies that a throwing audit writer leaves a successful HTTP response unchanged.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task AuditWriter_Throws_HttpResponse_Unchanged_Success_Stays_Success()
|
||||
{
|
||||
@@ -553,6 +618,8 @@ public class AuditWriteMiddlewareTests
|
||||
Assert.Equal(200, ctx.Response.StatusCode);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a throwing audit writer leaves a failed HTTP response unchanged.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task AuditWriter_Throws_OnFailedRequest_HttpResponse_Unchanged()
|
||||
{
|
||||
@@ -576,6 +643,8 @@ public class AuditWriteMiddlewareTests
|
||||
// 7. Provenance — RemoteIp + User-Agent surface in Extra JSON
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
/// <summary>Verifies that the remote IP and user agent appear in the Extra JSON.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task RemoteIp_And_UserAgent_AppearInExtra()
|
||||
{
|
||||
@@ -605,6 +674,8 @@ public class AuditWriteMiddlewareTests
|
||||
// JsonDocument.Parse) still see the full payload.
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
/// <summary>Verifies that the request body is buffered and stashed on the request summary.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task RequestBody_IsBuffered_AndStashed_OnRequestSummary()
|
||||
{
|
||||
@@ -631,12 +702,14 @@ public class AuditWriteMiddlewareTests
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Execution id — Audit Log #23: each inbound row carries a fresh
|
||||
// Execution id — 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.
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
/// <summary>Verifies that an inbound row carries a non-null execution id and a null correlation id.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task InboundRow_CarriesNonNull_ExecutionId_And_NullCorrelationId()
|
||||
{
|
||||
@@ -658,6 +731,8 @@ public class AuditWriteMiddlewareTests
|
||||
Assert.Null(evt.CorrelationId);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that separate requests get distinct execution ids.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task SeparateRequests_GetDistinct_ExecutionIds()
|
||||
{
|
||||
@@ -676,7 +751,7 @@ public class AuditWriteMiddlewareTests
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// ParentExecutionId — Audit Log #23 (ParentExecutionId feature, T3): the
|
||||
// ParentExecutionId — 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
|
||||
@@ -684,6 +759,8 @@ public class AuditWriteMiddlewareTests
|
||||
// NEVER set.
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
/// <summary>Verifies that the inbound execution id is stashed on the HTTP items before the endpoint runs.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task InboundExecutionId_IsStashedOnHttpItems_BeforeEndpointRuns()
|
||||
{
|
||||
@@ -705,6 +782,8 @@ public class AuditWriteMiddlewareTests
|
||||
Assert.NotEqual(Guid.Empty, stashed);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the inbound row execution id equals the early-minted stashed id.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task InboundRow_ExecutionId_Equals_TheEarlyMintedStashedId()
|
||||
{
|
||||
@@ -727,6 +806,8 @@ public class AuditWriteMiddlewareTests
|
||||
Assert.Equal(stashedDuringHandler, evt.ExecutionId);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the inbound row's own parent execution id stays null.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task InboundRow_OwnParentExecutionId_StaysNull()
|
||||
{
|
||||
@@ -747,6 +828,8 @@ public class AuditWriteMiddlewareTests
|
||||
Assert.Null(evt.ParentExecutionId);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the duration in milliseconds is recorded.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task DurationMs_IsRecorded()
|
||||
{
|
||||
@@ -769,13 +852,15 @@ public class AuditWriteMiddlewareTests
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Response body capture — Audit Log #23 (inbound full-response feature).
|
||||
// Until the M5-deferred work landed, ResponseSummary was always null.
|
||||
// Response body capture (inbound full-response feature).
|
||||
// Until response capture was implemented, 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.
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
/// <summary>Verifies that the response body is captured on the response summary.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task ResponseBody_IsCaptured_OnResponseSummary()
|
||||
{
|
||||
@@ -795,6 +880,8 @@ public class AuditWriteMiddlewareTests
|
||||
Assert.Equal(responseJson, evt.ResponseSummary);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the response body is forwarded to the original stream so downstream readers see it.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task ResponseBody_IsForwardedToOriginalStream_DownstreamReadersSeeIt()
|
||||
{
|
||||
@@ -817,6 +904,8 @@ public class AuditWriteMiddlewareTests
|
||||
Assert.Equal(responseJson, Encoding.UTF8.GetString(captured.ToArray()));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that an empty response body leaves the response summary null.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task ResponseBody_Empty_LeavesResponseSummaryNull()
|
||||
{
|
||||
@@ -837,6 +926,8 @@ public class AuditWriteMiddlewareTests
|
||||
Assert.Equal(204, evt.HttpStatus);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that when the handler throws, the body written up to the throw is captured.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task ResponseBody_OnHandlerThrow_BodyCapturedUpToTheThrow()
|
||||
{
|
||||
@@ -870,6 +961,8 @@ public class AuditWriteMiddlewareTests
|
||||
// real client still see every byte.
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
/// <summary>Verifies that a request body above the inbound maximum is truncated to the cap and flagged as payload truncated.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task RequestBody_AboveInboundMaxBytes_TruncatedToCap_PayloadTruncatedTrue()
|
||||
{
|
||||
@@ -909,6 +1002,8 @@ public class AuditWriteMiddlewareTests
|
||||
Assert.True(evt.PayloadTruncated);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a response body above the inbound maximum is truncated to the cap, the client still receives all bytes, and the payload is flagged as truncated.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task ResponseBody_AboveInboundMaxBytes_TruncatedToCap_ClientStillReceivesAllBytes_PayloadTruncatedTrue()
|
||||
{
|
||||
@@ -945,7 +1040,7 @@ public class AuditWriteMiddlewareTests
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// InboundAPI-018: asynchronously faulted audit-write tasks must be
|
||||
// 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).
|
||||
// ---------------------------------------------------------------------
|
||||
@@ -954,10 +1049,11 @@ public class AuditWriteMiddlewareTests
|
||||
/// 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.
|
||||
/// exactly the fire-and-forget bug this test guards against.
|
||||
/// </summary>
|
||||
private sealed class AsyncFaultingAuditWriter : ICentralAuditWriter
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public Task WriteAsync(AuditEvent evt, CancellationToken ct = default)
|
||||
{
|
||||
return FaultAsync();
|
||||
@@ -980,12 +1076,27 @@ public class AuditWriteMiddlewareTests
|
||||
/// </summary>
|
||||
private sealed class RecordingLogger : Microsoft.Extensions.Logging.ILogger<AuditWriteMiddleware>
|
||||
{
|
||||
/// <summary>Gets the captured log entries.</summary>
|
||||
public List<(Microsoft.Extensions.Logging.LogLevel Level, string Message, Exception? Exception)> Entries { get; } = new();
|
||||
|
||||
/// <summary>Returns no scope; the fake logger does not track scopes.</summary>
|
||||
/// <typeparam name="TState">The type of the scope state.</typeparam>
|
||||
/// <param name="state">The scope state, ignored.</param>
|
||||
/// <returns>Always null or a no-op scope.</returns>
|
||||
public IDisposable BeginScope<TState>(TState state) where TState : notnull => NullScope.Instance;
|
||||
|
||||
/// <summary>Reports every log level as enabled.</summary>
|
||||
/// <param name="logLevel">The log level being queried.</param>
|
||||
/// <returns>Always true.</returns>
|
||||
public bool IsEnabled(Microsoft.Extensions.Logging.LogLevel logLevel) => true;
|
||||
|
||||
/// <summary>Records the log entry for later assertions.</summary>
|
||||
/// <typeparam name="TState">The type of the log state.</typeparam>
|
||||
/// <param name="logLevel">The severity of the entry.</param>
|
||||
/// <param name="eventId">The event identifier.</param>
|
||||
/// <param name="state">The log state.</param>
|
||||
/// <param name="exception">The associated exception, if any.</param>
|
||||
/// <param name="formatter">Formats the state and exception into the message.</param>
|
||||
public void Log<TState>(
|
||||
Microsoft.Extensions.Logging.LogLevel logLevel,
|
||||
Microsoft.Extensions.Logging.EventId eventId,
|
||||
@@ -1002,10 +1113,13 @@ public class AuditWriteMiddlewareTests
|
||||
private sealed class NullScope : IDisposable
|
||||
{
|
||||
public static readonly NullScope Instance = new();
|
||||
/// <summary>Releases the resources held by this test class.</summary>
|
||||
public void Dispose() { }
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Verifies that an asynchronous audit writer fault is observed as a warning and does not alter the response.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task AuditWriter_AsyncFault_IsObserved_AsWarning_AndDoesNotAlterResponse()
|
||||
{
|
||||
@@ -1056,12 +1170,15 @@ public class AuditWriteMiddlewareTests
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// InboundAPI-019 — bodyless requests skip EnableBuffering so the
|
||||
// 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.
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
/// <summary>Verifies that a bodyless HTTP method skips enabling buffering so the request stream is not replaced.</summary>
|
||||
/// <param name="method">The bodyless HTTP method under test.</param>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Theory]
|
||||
[InlineData("GET")]
|
||||
[InlineData("HEAD")]
|
||||
@@ -1102,6 +1219,8 @@ public class AuditWriteMiddlewareTests
|
||||
Assert.Null(evt.RequestSummary);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a bodyless POST with content length zero skips enabling buffering.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task BodylessPost_ContentLengthZero_SkipsEnableBuffering()
|
||||
{
|
||||
@@ -1135,6 +1254,8 @@ public class AuditWriteMiddlewareTests
|
||||
Assert.Null(evt.RequestSummary);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a POST with a body still enables buffering and captures the request summary.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task PostWithBody_StillEnablesBuffering_AndCapturesRequestSummary()
|
||||
{
|
||||
@@ -1161,13 +1282,15 @@ public class AuditWriteMiddlewareTests
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// M5.3 (T7) Increment 1: Request headers in Extra JSON
|
||||
// Increment 1: Request headers in Extra JSON
|
||||
// Request headers are captured into the Extra JSON object alongside the
|
||||
// existing remoteIp / userAgent fields. Sensitive headers (e.g.
|
||||
// Authorization, X-Api-Key) are redacted to "<redacted>" using the same
|
||||
// HeaderRedactList as ScadaBridgeAuditRedactor.
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
/// <summary>Verifies that request headers appear in the Extra JSON under the request headers key.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task RequestHeaders_AppearInExtra_UnderRequestHeadersKey()
|
||||
{
|
||||
@@ -1197,6 +1320,8 @@ public class AuditWriteMiddlewareTests
|
||||
Assert.Equal("custom-value", customVal.GetString());
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the Authorization header is redacted.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task RequestHeaders_AuthorizationHeader_IsRedacted()
|
||||
{
|
||||
@@ -1224,6 +1349,8 @@ public class AuditWriteMiddlewareTests
|
||||
Assert.Equal("<redacted>", authVal.GetString());
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the X-API-Key header is redacted.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task RequestHeaders_XApiKeyHeader_IsRedacted()
|
||||
{
|
||||
@@ -1249,6 +1376,8 @@ public class AuditWriteMiddlewareTests
|
||||
Assert.Equal("<redacted>", keyVal.GetString());
|
||||
}
|
||||
|
||||
/// <summary>Verifies that an entry on the custom redact list is redacted.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task RequestHeaders_CustomRedactListEntry_IsRedacted()
|
||||
{
|
||||
@@ -1284,6 +1413,8 @@ public class AuditWriteMiddlewareTests
|
||||
Assert.Equal("safe-value", headers.GetProperty("X-Safe-Header").GetString());
|
||||
}
|
||||
|
||||
/// <summary>Verifies that header redaction is case-insensitive.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task RequestHeaders_Redaction_IsCaseInsensitive()
|
||||
{
|
||||
@@ -1312,7 +1443,7 @@ public class AuditWriteMiddlewareTests
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// M5.3 (T7) Increment 2: AuditInboundCeilingHits counter
|
||||
// Increment 2: AuditInboundCeilingHits counter
|
||||
// When request OR response exceeds InboundMaxBytes, the middleware
|
||||
// increments IAuditInboundCeilingHitsCounter once per request.
|
||||
// ---------------------------------------------------------------------
|
||||
@@ -1324,7 +1455,9 @@ public class AuditWriteMiddlewareTests
|
||||
private sealed class RecordingCeilingHitsCounter : ZB.MOM.WW.ScadaBridge.AuditLog.Central.IAuditInboundCeilingHitsCounter
|
||||
{
|
||||
private int _count;
|
||||
/// <summary>Gets the number of recorded increments.</summary>
|
||||
public int Count => Volatile.Read(ref _count);
|
||||
/// <inheritdoc />
|
||||
public void Increment() => Interlocked.Increment(ref _count);
|
||||
}
|
||||
|
||||
@@ -1341,6 +1474,8 @@ public class AuditWriteMiddlewareTests
|
||||
actorAccessor: null,
|
||||
ceilingHitsCounter: counter);
|
||||
|
||||
/// <summary>Verifies that a request body above the inbound maximum increments the ceiling-hits counter.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task RequestBody_AboveInboundMaxBytes_IncrementsCeilingHitsCounter()
|
||||
{
|
||||
@@ -1367,6 +1502,8 @@ public class AuditWriteMiddlewareTests
|
||||
Assert.True(evt.PayloadTruncated);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a response body above the inbound maximum increments the ceiling-hits counter.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task ResponseBody_AboveInboundMaxBytes_IncrementsCeilingHitsCounter()
|
||||
{
|
||||
@@ -1394,6 +1531,8 @@ public class AuditWriteMiddlewareTests
|
||||
Assert.True(evt.PayloadTruncated);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a normal request within the cap does not increment the ceiling-hits counter.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task NormalRequest_WithinCap_DoesNotIncrementCeilingHitsCounter()
|
||||
{
|
||||
@@ -1418,7 +1557,7 @@ public class AuditWriteMiddlewareTests
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// M5.3 (T7) Increment 3: SkipBodyCapture per-method opt-out
|
||||
// Increment 3: SkipBodyCapture per-method opt-out
|
||||
// A target with SkipBodyCapture=true produces an audit row with
|
||||
// headers/metadata but empty/omitted body. A normal target still captures.
|
||||
// ---------------------------------------------------------------------
|
||||
@@ -1444,6 +1583,8 @@ public class AuditWriteMiddlewareTests
|
||||
return ctx;
|
||||
}
|
||||
|
||||
/// <summary>Verifies that with body capture skipped, an audit row is still emitted but its body is null.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task SkipBodyCapture_True_AuditRowEmitted_ButBodyIsNull()
|
||||
{
|
||||
@@ -1489,6 +1630,8 @@ public class AuditWriteMiddlewareTests
|
||||
Assert.Equal(200, evt.HttpStatus);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that with body capture skipped, the ceiling-hits counter is not incremented.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task SkipBodyCapture_True_CeilingHitsCounter_NotIncremented()
|
||||
{
|
||||
@@ -1526,6 +1669,8 @@ public class AuditWriteMiddlewareTests
|
||||
Assert.Equal(0, counter.Count);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that with body capture not skipped, a normal target still captures the body.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task SkipBodyCapture_False_NormalTarget_StillCapturesBody()
|
||||
{
|
||||
@@ -1561,6 +1706,8 @@ public class AuditWriteMiddlewareTests
|
||||
Assert.Equal("{\"result\":1}", evt.ResponseSummary);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that with no override, the default target still captures the body.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task SkipBodyCapture_NoOverride_DefaultTarget_StillCapturesBody()
|
||||
{
|
||||
|
||||
@@ -18,7 +18,7 @@ using System.Security.Claims;
|
||||
namespace ZB.MOM.WW.ScadaBridge.InboundAPI.Tests.Middleware;
|
||||
|
||||
/// <summary>
|
||||
/// M4 Bundle D (D2) — verifies the production pipeline order from
|
||||
/// 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>.
|
||||
///
|
||||
@@ -42,7 +42,10 @@ public class MiddlewareOrderTests
|
||||
/// </summary>
|
||||
private sealed class OrderingRecorder
|
||||
{
|
||||
/// <summary>Gets the stages recorded so far, in order.</summary>
|
||||
public List<string> Stages { get; } = new();
|
||||
/// <summary>Appends a stage name to the recorded list.</summary>
|
||||
/// <param name="stage">The name of the stage that ran.</param>
|
||||
public void Record(string stage)
|
||||
{
|
||||
lock (Stages) { Stages.Add(stage); }
|
||||
@@ -51,9 +54,11 @@ public class MiddlewareOrderTests
|
||||
|
||||
private sealed class RecordingAuditWriter : ICentralAuditWriter
|
||||
{
|
||||
// C3 (Task 2.5): store the decomposed row view so assertions keep
|
||||
// Store the decomposed row view so assertions keep
|
||||
// reading the ScadaBridge domain fields as typed properties.
|
||||
/// <summary>Gets the audit events written, as decomposed row views.</summary>
|
||||
public List<AuditRowProjection.AuditRowValues> Events { get; } = new();
|
||||
/// <inheritdoc />
|
||||
public Task WriteAsync(AuditEvent evt, CancellationToken ct = default)
|
||||
{
|
||||
lock (Events) { Events.Add(evt.AsRow()); }
|
||||
@@ -61,6 +66,8 @@ public class MiddlewareOrderTests
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the audit-write middleware runs after authentication and before script execution.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Middleware_Pipeline_PlacesAuditWriteAfterAuth_BeforeScriptExecution()
|
||||
{
|
||||
@@ -99,6 +106,8 @@ public class MiddlewareOrderTests
|
||||
Assert.Equal("test-actor", evt.Actor);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the pipeline records an authentication failure without reaching the endpoint.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Middleware_Pipeline_Records_AuthFailure_Beyond_Endpoint()
|
||||
{
|
||||
@@ -225,12 +234,17 @@ public class MiddlewareOrderTests
|
||||
/// </summary>
|
||||
private sealed class AlwaysAuthenticatedHandler : AuthenticationHandler<AuthenticationSchemeOptions>
|
||||
{
|
||||
/// <summary>Initializes the handler with the standard authentication-handler dependencies.</summary>
|
||||
/// <param name="options">The authentication scheme options monitor.</param>
|
||||
/// <param name="logger">The logger factory.</param>
|
||||
/// <param name="encoder">The URL encoder.</param>
|
||||
public AlwaysAuthenticatedHandler(
|
||||
Microsoft.Extensions.Options.IOptionsMonitor<AuthenticationSchemeOptions> options,
|
||||
Microsoft.Extensions.Logging.ILoggerFactory logger,
|
||||
System.Text.Encodings.Web.UrlEncoder encoder)
|
||||
: base(options, logger, encoder) { }
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override Task<AuthenticateResult> HandleAuthenticateAsync()
|
||||
{
|
||||
var identity = new ClaimsIdentity(new[] { new Claim(ClaimTypes.Name, "framework-user") }, "TestScheme");
|
||||
@@ -249,12 +263,21 @@ public class MiddlewareOrderTests
|
||||
{
|
||||
private readonly AuditLogOptions _value;
|
||||
|
||||
/// <summary>Initializes the monitor with a fixed options value.</summary>
|
||||
/// <param name="value">The options value to return.</param>
|
||||
public StaticAuditLogOptionsMonitor(AuditLogOptions value) => _value = value;
|
||||
|
||||
/// <summary>Gets the fixed options value.</summary>
|
||||
public AuditLogOptions CurrentValue => _value;
|
||||
|
||||
/// <summary>Returns the fixed options value regardless of name.</summary>
|
||||
/// <param name="name">The named-options name, ignored.</param>
|
||||
/// <returns>The fixed options value.</returns>
|
||||
public AuditLogOptions Get(string? name) => _value;
|
||||
|
||||
/// <summary>Ignores the listener because the value never changes.</summary>
|
||||
/// <param name="listener">The change listener, ignored.</param>
|
||||
/// <returns>Always null.</returns>
|
||||
public IDisposable? OnChange(Action<AuditLogOptions, string?> listener) => null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ using ZB.MOM.WW.ScadaBridge.Commons.Types.InboundApi;
|
||||
namespace ZB.MOM.WW.ScadaBridge.InboundAPI.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// WP-2 / InboundAPI-M2.6: tests for parameter validation — type checking,
|
||||
/// Tests for parameter validation — type checking,
|
||||
/// required fields, the extended type system, and RECURSIVE (nested Object /
|
||||
/// List element) type validation with path-qualified errors.
|
||||
///
|
||||
@@ -25,6 +25,7 @@ public class ParameterValidatorTests
|
||||
|
||||
// ── No / empty definitions ────────────────────────────────────────────────
|
||||
|
||||
/// <summary>Verifies that a missing body with no parameter definitions is valid.</summary>
|
||||
[Fact]
|
||||
public void NoDefinitions_NoBody_ReturnsValid()
|
||||
{
|
||||
@@ -33,6 +34,7 @@ public class ParameterValidatorTests
|
||||
Assert.Empty(result.Parameters);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that an empty object schema is valid.</summary>
|
||||
[Fact]
|
||||
public void EmptyObjectSchema_ReturnsValid()
|
||||
{
|
||||
@@ -40,6 +42,7 @@ public class ParameterValidatorTests
|
||||
Assert.True(result.IsValid);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that an empty legacy array definition is valid.</summary>
|
||||
[Fact]
|
||||
public void EmptyLegacyArray_ReturnsValid()
|
||||
{
|
||||
@@ -49,6 +52,7 @@ public class ParameterValidatorTests
|
||||
|
||||
// ── Required / body shape ──────────────────────────────────────────────────
|
||||
|
||||
/// <summary>Verifies that a missing required parameter is invalid.</summary>
|
||||
[Fact]
|
||||
public void RequiredParameterMissing_ReturnsInvalid()
|
||||
{
|
||||
@@ -59,6 +63,7 @@ public class ParameterValidatorTests
|
||||
Assert.Contains("Missing required parameter", result.ErrorMessage);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a body that is not an object is invalid.</summary>
|
||||
[Fact]
|
||||
public void BodyNotObject_ReturnsInvalid()
|
||||
{
|
||||
@@ -69,6 +74,7 @@ public class ParameterValidatorTests
|
||||
Assert.Contains("must be a JSON object", result.ErrorMessage);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a missing body is valid when the parameter is optional.</summary>
|
||||
[Fact]
|
||||
public void OptionalParameter_MissingBody_ReturnsValid()
|
||||
{
|
||||
@@ -80,6 +86,10 @@ public class ParameterValidatorTests
|
||||
|
||||
// ── Scalar coercion ────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>Verifies that a valid value of each scalar type is coerced successfully.</summary>
|
||||
/// <param name="type">The JSON Schema type name.</param>
|
||||
/// <param name="jsonValue">The JSON text of the value.</param>
|
||||
/// <param name="expected">The expected coerced CLR value.</param>
|
||||
[Theory]
|
||||
[InlineData("boolean", "true", true)]
|
||||
[InlineData("integer", "42", (long)42)]
|
||||
@@ -94,6 +104,7 @@ public class ParameterValidatorTests
|
||||
Assert.Equal(expected, result.Parameters["val"]);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a wrong scalar type is invalid.</summary>
|
||||
[Fact]
|
||||
public void WrongScalarType_ReturnsInvalid()
|
||||
{
|
||||
@@ -105,6 +116,7 @@ public class ParameterValidatorTests
|
||||
Assert.Contains("Integer", result.ErrorMessage);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that an unknown type is invalid.</summary>
|
||||
[Fact]
|
||||
public void UnknownType_ReturnsInvalid()
|
||||
{
|
||||
@@ -117,6 +129,7 @@ public class ParameterValidatorTests
|
||||
|
||||
// ── Object / List shape + materialization ──────────────────────────────────
|
||||
|
||||
/// <summary>Verifies that an object type with no declared fields is materialized shape-only.</summary>
|
||||
[Fact]
|
||||
public void ObjectType_NoDeclaredFields_ShapeOnly_Materialized()
|
||||
{
|
||||
@@ -127,6 +140,7 @@ public class ParameterValidatorTests
|
||||
Assert.IsType<Dictionary<string, object?>>(result.Parameters["data"]);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a list type with no declared element type is materialized shape-only.</summary>
|
||||
[Fact]
|
||||
public void ListType_NoDeclaredElement_ShapeOnly_Materialized()
|
||||
{
|
||||
@@ -139,6 +153,7 @@ public class ParameterValidatorTests
|
||||
|
||||
// ── Undeclared / unexpected fields (rejected, recursively) ─────────────────
|
||||
|
||||
/// <summary>Verifies that an unexpected top-level field is invalid.</summary>
|
||||
[Fact]
|
||||
public void UnexpectedTopLevelField_ReturnsInvalid()
|
||||
{
|
||||
@@ -152,6 +167,7 @@ public class ParameterValidatorTests
|
||||
Assert.Contains("not a declared field", result.ErrorMessage);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a body containing only declared fields is still valid.</summary>
|
||||
[Fact]
|
||||
public void OnlyDeclaredFields_StillValid()
|
||||
{
|
||||
@@ -163,6 +179,7 @@ public class ParameterValidatorTests
|
||||
Assert.Equal((long)1, result.Parameters["value"]);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that an undeclared nested field is invalid with a path-qualified error.</summary>
|
||||
[Fact]
|
||||
public void UndeclaredNestedField_ReturnsInvalid_PathQualified()
|
||||
{
|
||||
@@ -180,7 +197,7 @@ public class ParameterValidatorTests
|
||||
Assert.Contains("not a declared field", result.ErrorMessage);
|
||||
}
|
||||
|
||||
// ── Nested validation: the M2.6 core ───────────────────────────────────────
|
||||
// ── Nested validation: the core cases ───────────────────────────────────────
|
||||
|
||||
private const string NestedDef = """
|
||||
{
|
||||
@@ -211,6 +228,7 @@ public class ParameterValidatorTests
|
||||
}
|
||||
""";
|
||||
|
||||
/// <summary>Verifies that a valid nested payload passes.</summary>
|
||||
[Fact]
|
||||
public void ValidNestedPayload_Passes()
|
||||
{
|
||||
@@ -229,6 +247,7 @@ public class ParameterValidatorTests
|
||||
Assert.True(result.IsValid);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a wrong scalar two levels deep is invalid with the exact path.</summary>
|
||||
[Fact]
|
||||
public void WrongScalarTwoLevelsDeep_ReturnsInvalid_WithExactPath()
|
||||
{
|
||||
@@ -247,6 +266,7 @@ public class ParameterValidatorTests
|
||||
Assert.Contains("Boolean", result.ErrorMessage);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a wrong scalar inside a list element is invalid with the element index in the path.</summary>
|
||||
[Fact]
|
||||
public void WrongScalarInsideListElement_ReturnsInvalid_WithElementIndexInPath()
|
||||
{
|
||||
@@ -268,6 +288,7 @@ public class ParameterValidatorTests
|
||||
Assert.Contains("Integer", result.ErrorMessage);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a list element of the wrong shape is invalid with the element index in the path.</summary>
|
||||
[Fact]
|
||||
public void ListElementWrongShape_ReturnsInvalid_WithElementIndexInPath()
|
||||
{
|
||||
@@ -286,6 +307,7 @@ public class ParameterValidatorTests
|
||||
Assert.Contains("Object", result.ErrorMessage);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a missing required nested field is invalid with a path-qualified error.</summary>
|
||||
[Fact]
|
||||
public void MissingRequiredNestedField_ReturnsInvalid_PathQualified()
|
||||
{
|
||||
@@ -306,6 +328,7 @@ public class ParameterValidatorTests
|
||||
|
||||
// ── Empty / null edge cases ────────────────────────────────────────────────
|
||||
|
||||
/// <summary>Verifies that an empty list against a typed element passes.</summary>
|
||||
[Fact]
|
||||
public void EmptyList_AgainstTypedElement_Passes()
|
||||
{
|
||||
@@ -317,6 +340,7 @@ public class ParameterValidatorTests
|
||||
Assert.True(result.IsValid);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a null for an optional nested scalar passes.</summary>
|
||||
[Fact]
|
||||
public void NullForOptionalNestedScalar_Passes()
|
||||
{
|
||||
@@ -333,6 +357,7 @@ public class ParameterValidatorTests
|
||||
Assert.True(result.IsValid);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a null for a required nested scalar passes.</summary>
|
||||
[Fact]
|
||||
public void NullForRequiredNestedScalar_Passes()
|
||||
{
|
||||
@@ -350,7 +375,7 @@ public class ParameterValidatorTests
|
||||
Assert.True(result.IsValid);
|
||||
}
|
||||
|
||||
// ── Recursive materialization: nested values coerced to typed CLR (#55) ─────
|
||||
// ── Recursive materialization: nested values coerced to typed CLR ─────
|
||||
|
||||
/// <summary>
|
||||
/// Recursively asserts that no value anywhere in a materialized parameter
|
||||
@@ -382,6 +407,7 @@ public class ParameterValidatorTests
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a nested object field scalar is materialized as a typed CLR value rather than a JSON element.</summary>
|
||||
[Fact]
|
||||
public void NestedObjectFieldScalar_IsTypedClr_NotJsonElement()
|
||||
{
|
||||
@@ -410,6 +436,7 @@ public class ParameterValidatorTests
|
||||
Assert.IsType<long>(order["id"]);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a list of scalars is fully coerced with no JSON element left.</summary>
|
||||
[Fact]
|
||||
public void ListOfScalars_FullyCoerced_NoJsonElement()
|
||||
{
|
||||
@@ -424,6 +451,7 @@ public class ParameterValidatorTests
|
||||
AssertNoJsonElement(nums);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the element fields of a list of objects are coerced with no JSON element left.</summary>
|
||||
[Fact]
|
||||
public void ListOfObjects_ElementFieldsCoerced_NoJsonElement()
|
||||
{
|
||||
@@ -453,6 +481,7 @@ public class ParameterValidatorTests
|
||||
Assert.IsType<double>(first["price"]);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that an object in a list in an object is fully coerced with no JSON element left.</summary>
|
||||
[Fact]
|
||||
public void DeeplyNested_ObjectInListInObject_FullyCoerced_NoJsonElement()
|
||||
{
|
||||
@@ -512,6 +541,7 @@ public class ParameterValidatorTests
|
||||
Assert.Equal(false, tag1["active"]);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a shape-only nested object is still fully coerced.</summary>
|
||||
[Fact]
|
||||
public void ShapeOnlyNestedObject_StillFullyCoerced_NoJsonElement()
|
||||
{
|
||||
@@ -540,6 +570,7 @@ public class ParameterValidatorTests
|
||||
|
||||
// ── Legacy flat-array backward-compat ──────────────────────────────────────
|
||||
|
||||
/// <summary>Verifies that a legacy flat array definition is still accepted.</summary>
|
||||
[Fact]
|
||||
public void LegacyFlatArrayDefinition_StillAccepted()
|
||||
{
|
||||
@@ -556,6 +587,8 @@ public class ParameterValidatorTests
|
||||
|
||||
// FIX 1: legacy "required":"false" string → field is optional ─────────────
|
||||
|
||||
/// <summary>Verifies that a legacy flat array field with required set to a string false is treated as optional.</summary>
|
||||
/// <param name="def">The legacy flat array definition JSON.</param>
|
||||
[Theory]
|
||||
[InlineData("""[{"name":"opt","type":"String","required":"false"}]""")]
|
||||
[InlineData("""[{"name":"opt","type":"String","required":"False"}]""")]
|
||||
@@ -569,6 +602,7 @@ public class ParameterValidatorTests
|
||||
Assert.True(result.IsValid, $"Expected optional field to be valid when absent; error: {result.ErrorMessage}");
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a legacy flat array field with required false that is present and correctly typed passes.</summary>
|
||||
[Fact]
|
||||
public void LegacyFlatArray_RequiredStringFalse_FieldPresentAndTypedCorrectly_Passes()
|
||||
{
|
||||
@@ -597,6 +631,7 @@ public class ParameterValidatorTests
|
||||
return schema;
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a schema at the depth ceiling parses successfully.</summary>
|
||||
[Fact]
|
||||
public void SchemaAtDepthCeiling_ParsesSuccessfully()
|
||||
{
|
||||
@@ -606,6 +641,7 @@ public class ParameterValidatorTests
|
||||
Assert.NotNull(schema);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a schema exceeding the depth ceiling throws a JSON exception rather than overflowing the stack.</summary>
|
||||
[Fact]
|
||||
public void SchemaExceedingDepthCeiling_ThrowsJsonException_NotStackOverflow()
|
||||
{
|
||||
@@ -615,6 +651,7 @@ public class ParameterValidatorTests
|
||||
Assert.Throws<System.Text.Json.JsonException>(() => InboundApiSchema.Parse(def));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the parameter validator returns invalid for a schema exceeding the depth ceiling.</summary>
|
||||
[Fact]
|
||||
public void SchemaExceedingDepthCeiling_ParameterValidator_ReturnsInvalid()
|
||||
{
|
||||
|
||||
@@ -15,6 +15,9 @@ namespace ZB.MOM.WW.ScadaBridge.InboundAPI.Tests;
|
||||
|
||||
public class RelayProvenanceTests
|
||||
{
|
||||
/// <summary>Verifies that a genuine relay preserves the caller IP only when the signed request is intact.</summary>
|
||||
/// <param name="tamper">The kind of tampering applied to the relayed request.</param>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Theory]
|
||||
[InlineData("none")]
|
||||
[InlineData("body")]
|
||||
@@ -59,6 +62,8 @@ public class RelayProvenanceTests
|
||||
if (tamper == "none") Assert.Equal(original.Items[AuditWriteMiddleware.InboundExecutionIdItemKey], row.ExecutionId);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that directly forged forwarding headers cannot change the audited caller.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task EI5_DirectForgedForwardingHeadersCannotChangeAuditedCaller()
|
||||
{
|
||||
@@ -72,6 +77,8 @@ public class RelayProvenanceTests
|
||||
Assert.Equal("10.4.5.6", extra.RootElement.GetProperty("remoteIp").GetString());
|
||||
}
|
||||
|
||||
/// <summary>Verifies that when every authenticated peer is standby, only the origin node emits the failure audit.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task EI1_AllAuthenticatedPeersStandby_OnlyOriginEmitsFailureAudit()
|
||||
{
|
||||
@@ -98,7 +105,11 @@ public class RelayProvenanceTests
|
||||
Assert.Equal(original.Items[AuditWriteMiddleware.InboundExecutionIdItemKey], row.ExecutionId);
|
||||
}
|
||||
|
||||
private sealed class Standby : IActiveNodeGate { public bool IsActiveNode => false; }
|
||||
private sealed class Standby : IActiveNodeGate
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public bool IsActiveNode => false;
|
||||
}
|
||||
|
||||
private static async Task AuditAsync(HttpContext context, Writer writer, IOptionsMonitor<InboundApiOptions> options, IActiveNodeGate? gate = null, IStandbyRequestForwarder? forwarder = null)
|
||||
{
|
||||
@@ -132,21 +143,34 @@ public class RelayProvenanceTests
|
||||
}
|
||||
private sealed class Writer : ICentralAuditWriter
|
||||
{
|
||||
/// <summary>Gets the audit events captured by this writer.</summary>
|
||||
public List<AuditEvent> Events { get; } = [];
|
||||
/// <inheritdoc />
|
||||
public Task WriteAsync(AuditEvent evt, CancellationToken ct = default) { Events.Add(evt); return Task.CompletedTask; }
|
||||
}
|
||||
private sealed class Monitor<T>(T value) : IOptionsMonitor<T>
|
||||
{
|
||||
/// <summary>Gets the fixed options value.</summary>
|
||||
public T CurrentValue => value;
|
||||
/// <summary>Returns the fixed options value regardless of name.</summary>
|
||||
/// <param name="name">The options name, ignored.</param>
|
||||
/// <returns>The fixed options value.</returns>
|
||||
public T Get(string? name) => value;
|
||||
/// <summary>Ignores the change listener; the fake never raises change notifications.</summary>
|
||||
/// <param name="listener">The change listener, ignored.</param>
|
||||
/// <returns>Always <see langword="null"/>.</returns>
|
||||
public IDisposable? OnChange(Action<T, string?> listener) => null;
|
||||
}
|
||||
private sealed class Handler(Func<HttpRequestMessage, Task<HttpResponseMessage>> send) : HttpMessageHandler
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) => send(request);
|
||||
}
|
||||
private sealed class Factory(HttpMessageHandler handler) : IHttpClientFactory
|
||||
{
|
||||
/// <summary>Creates an HTTP client over the fake message handler.</summary>
|
||||
/// <param name="name">The logical client name.</param>
|
||||
/// <returns>An HTTP client backed by the fake handler.</returns>
|
||||
public HttpClient CreateClient(string name) => new(handler, false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ using ZB.MOM.WW.ScadaBridge.Commons.Types.InboundApi;
|
||||
namespace ZB.MOM.WW.ScadaBridge.InboundAPI.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// InboundAPI-014 / InboundAPI-M2.6: tests for return-value validation against a
|
||||
/// Tests for return-value validation against a
|
||||
/// method's <c>ReturnDefinition</c>. Mirrors <see cref="ParameterValidatorTests"/>
|
||||
/// (shared recursive engine) — RECURSIVE nested Object / List-element type
|
||||
/// validation with path-qualified errors.
|
||||
@@ -17,6 +17,8 @@ public class ReturnValueValidatorTests
|
||||
{
|
||||
// ── No definition → no validation (backward compatible) ───────────────────
|
||||
|
||||
/// <summary>Verifies that any result is valid when no return definition is declared.</summary>
|
||||
/// <param name="returnDefinition">The return definition under test, which is null or blank.</param>
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
@@ -27,6 +29,7 @@ public class ReturnValueValidatorTests
|
||||
Assert.True(result.IsValid);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a null result is valid when no return definition is declared.</summary>
|
||||
[Fact]
|
||||
public void NoReturnDefinition_NullResult_IsValid()
|
||||
{
|
||||
@@ -36,6 +39,7 @@ public class ReturnValueValidatorTests
|
||||
|
||||
// ── Happy path: result matches the declared object shape ──────────────────
|
||||
|
||||
/// <summary>Verifies that a result matching the declared definition is valid.</summary>
|
||||
[Fact]
|
||||
public void ResultMatchingDefinition_IsValid()
|
||||
{
|
||||
@@ -52,6 +56,7 @@ public class ReturnValueValidatorTests
|
||||
Assert.True(result.IsValid);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a list of scalars whose elements have the declared type is valid.</summary>
|
||||
[Fact]
|
||||
public void ResultWithListOfScalars_TypeChecked_IsValid()
|
||||
{
|
||||
@@ -69,6 +74,7 @@ public class ReturnValueValidatorTests
|
||||
|
||||
// ── Scalar / shape mismatches must be reported ────────────────────────────
|
||||
|
||||
/// <summary>Verifies that a result missing a declared field is invalid.</summary>
|
||||
[Fact]
|
||||
public void ResultMissingDeclaredField_IsInvalid()
|
||||
{
|
||||
@@ -86,6 +92,7 @@ public class ReturnValueValidatorTests
|
||||
Assert.Contains("totalUnits", result.ErrorMessage);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a result field of the wrong type is invalid.</summary>
|
||||
[Fact]
|
||||
public void ResultFieldWrongType_IsInvalid()
|
||||
{
|
||||
@@ -98,6 +105,7 @@ public class ReturnValueValidatorTests
|
||||
Assert.Contains("totalUnits", result.ErrorMessage);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a null result is invalid when a structure is required.</summary>
|
||||
[Fact]
|
||||
public void NullResultWhenStructureRequired_IsInvalid()
|
||||
{
|
||||
@@ -108,6 +116,7 @@ public class ReturnValueValidatorTests
|
||||
Assert.False(result.IsValid);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a non-object result is invalid when a structure is required.</summary>
|
||||
[Fact]
|
||||
public void NonObjectResultWhenStructureRequired_IsInvalid()
|
||||
{
|
||||
@@ -118,6 +127,7 @@ public class ReturnValueValidatorTests
|
||||
Assert.False(result.IsValid);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a list field given a non-array value is invalid.</summary>
|
||||
[Fact]
|
||||
public void ListFieldGivenNonArray_IsInvalid()
|
||||
{
|
||||
@@ -130,6 +140,7 @@ public class ReturnValueValidatorTests
|
||||
Assert.Contains("lines", result.ErrorMessage);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a malformed return definition is invalid.</summary>
|
||||
[Fact]
|
||||
public void MalformedReturnDefinition_IsInvalid()
|
||||
{
|
||||
@@ -138,7 +149,7 @@ public class ReturnValueValidatorTests
|
||||
Assert.False(result.IsValid);
|
||||
}
|
||||
|
||||
// ── Nested validation: the M2.6 core (production-report shape) ─────────────
|
||||
// ── Nested validation: the core (production-report shape) ─────────────
|
||||
|
||||
private const string ReportDef = """
|
||||
{
|
||||
@@ -163,6 +174,7 @@ public class ReturnValueValidatorTests
|
||||
}
|
||||
""";
|
||||
|
||||
/// <summary>Verifies that a valid nested return value passes validation.</summary>
|
||||
[Fact]
|
||||
public void ValidNestedReturn_Passes()
|
||||
{
|
||||
@@ -181,6 +193,7 @@ public class ReturnValueValidatorTests
|
||||
Assert.True(result.IsValid);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a wrong scalar inside a list element is invalid and the error path includes the element index.</summary>
|
||||
[Fact]
|
||||
public void WrongScalarInsideListElement_IsInvalid_WithElementIndexInPath()
|
||||
{
|
||||
@@ -202,6 +215,7 @@ public class ReturnValueValidatorTests
|
||||
Assert.Contains("Integer", result.ErrorMessage);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a wrong list element type is invalid and the error path includes the element index.</summary>
|
||||
[Fact]
|
||||
public void WrongListElementType_IsInvalid_WithElementIndexInPath()
|
||||
{
|
||||
@@ -220,6 +234,7 @@ public class ReturnValueValidatorTests
|
||||
Assert.Contains("Object", result.ErrorMessage);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a missing required nested field is invalid and the error path is qualified.</summary>
|
||||
[Fact]
|
||||
public void MissingRequiredNestedField_IsInvalid_PathQualified()
|
||||
{
|
||||
@@ -238,6 +253,7 @@ public class ReturnValueValidatorTests
|
||||
Assert.Contains("'lines[0].units'", result.ErrorMessage);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that an undeclared nested field is invalid and the error path is qualified.</summary>
|
||||
[Fact]
|
||||
public void UndeclaredNestedField_IsInvalid_PathQualified()
|
||||
{
|
||||
@@ -258,6 +274,7 @@ public class ReturnValueValidatorTests
|
||||
|
||||
// ── Empty / null edge cases ────────────────────────────────────────────────
|
||||
|
||||
/// <summary>Verifies that an empty list against a typed element definition passes.</summary>
|
||||
[Fact]
|
||||
public void EmptyListAgainstTypedElement_Passes()
|
||||
{
|
||||
@@ -267,6 +284,7 @@ public class ReturnValueValidatorTests
|
||||
Assert.True(result.IsValid);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that any result is valid against an empty object schema.</summary>
|
||||
[Fact]
|
||||
public void EmptyObjectSchema_AnythingIsValid()
|
||||
{
|
||||
@@ -276,6 +294,7 @@ public class ReturnValueValidatorTests
|
||||
Assert.True(result.IsValid);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a null optional nested scalar passes validation.</summary>
|
||||
[Fact]
|
||||
public void NullOptionalNestedScalar_Passes()
|
||||
{
|
||||
@@ -294,6 +313,7 @@ public class ReturnValueValidatorTests
|
||||
|
||||
// ── Legacy flat-array backward-compat ──────────────────────────────────────
|
||||
|
||||
/// <summary>Verifies that the legacy flat-array return definition is still accepted.</summary>
|
||||
[Fact]
|
||||
public void LegacyFlatArrayDefinition_StillAccepted()
|
||||
{
|
||||
@@ -311,6 +331,7 @@ public class ReturnValueValidatorTests
|
||||
// (Guards the intentional ParameterValidator/ReturnValueValidator asymmetry:
|
||||
// ReturnValueValidator must NOT short-circuit on non-object schema types.)
|
||||
|
||||
/// <summary>Verifies that a scalar string return schema validates a scalar string return.</summary>
|
||||
[Fact]
|
||||
public void ScalarStringReturnSchema_ValidatesScalarStringReturn()
|
||||
{
|
||||
@@ -319,6 +340,7 @@ public class ReturnValueValidatorTests
|
||||
Assert.True(result.IsValid);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a scalar integer return schema validates a scalar integer return.</summary>
|
||||
[Fact]
|
||||
public void ScalarIntegerReturnSchema_ValidatesScalarIntegerReturn()
|
||||
{
|
||||
@@ -326,6 +348,7 @@ public class ReturnValueValidatorTests
|
||||
Assert.True(result.IsValid);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a scalar string return schema rejects an integer return.</summary>
|
||||
[Fact]
|
||||
public void ScalarStringReturnSchema_RejectsIntegerReturn()
|
||||
{
|
||||
@@ -334,6 +357,7 @@ public class ReturnValueValidatorTests
|
||||
Assert.Contains("String", result.ErrorMessage);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a scalar boolean return schema validates a boolean return.</summary>
|
||||
[Fact]
|
||||
public void ScalarBooleanReturnSchema_ValidatesBooleanReturn()
|
||||
{
|
||||
@@ -343,6 +367,7 @@ public class ReturnValueValidatorTests
|
||||
|
||||
// FIX 2: recursion depth guard on Validate ─────────────────────────────────
|
||||
|
||||
/// <summary>Verifies that validating a schema nested beyond the depth ceiling adds a depth error instead of throwing.</summary>
|
||||
[Fact]
|
||||
public void ValidateExceedingDepthCeiling_AddsDepthError_DoesNotThrow()
|
||||
{
|
||||
|
||||
@@ -8,11 +8,11 @@ using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
|
||||
namespace ZB.MOM.WW.ScadaBridge.InboundAPI.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// WP-4: Tests for <see cref="RouteHelper"/>/<see cref="RouteTarget"/> — the
|
||||
/// 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.
|
||||
/// This surface previously had zero coverage.
|
||||
/// Routed calls must inherit the executing method's deadline token.
|
||||
/// </summary>
|
||||
public class RouteHelperTests
|
||||
{
|
||||
@@ -27,6 +27,8 @@ public class RouteHelperTests
|
||||
|
||||
// --- Call ---
|
||||
|
||||
/// <summary>Verifies that a routed call resolves the site and returns the value.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Call_HappyPath_ResolvesSiteAndReturnsValue()
|
||||
{
|
||||
@@ -40,6 +42,8 @@ public class RouteHelperTests
|
||||
Assert.Equal(99, result);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a routed call generates a correlation id.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Call_GeneratesCorrelationId()
|
||||
{
|
||||
@@ -55,6 +59,8 @@ public class RouteHelperTests
|
||||
Assert.True(Guid.TryParse(captured!.CorrelationId, out _));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a remote failure on a routed call throws an invalid-operation exception.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Call_RemoteFailure_ThrowsInvalidOperationException()
|
||||
{
|
||||
@@ -68,6 +74,8 @@ public class RouteHelperTests
|
||||
Assert.Equal("site exploded", ex.Message);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that an unresolved instance throws an invalid-operation exception.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Call_UnresolvedInstance_ThrowsInvalidOperationException()
|
||||
{
|
||||
@@ -82,6 +90,8 @@ public class RouteHelperTests
|
||||
|
||||
// --- GetAttribute(s) ---
|
||||
|
||||
/// <summary>Verifies that getting attributes returns the values.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task GetAttributes_HappyPath_ReturnsValues()
|
||||
{
|
||||
@@ -98,6 +108,8 @@ public class RouteHelperTests
|
||||
Assert.Equal(2, result["b"]);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that getting a single attribute delegates to the batch call and returns one value.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task GetAttribute_DelegatesToBatch_AndReturnsSingleValue()
|
||||
{
|
||||
@@ -113,6 +125,8 @@ public class RouteHelperTests
|
||||
Assert.Equal(21.5, value);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that getting an absent attribute key returns null.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task GetAttribute_AbsentKey_ReturnsNull()
|
||||
{
|
||||
@@ -128,6 +142,8 @@ public class RouteHelperTests
|
||||
Assert.Null(value);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a remote failure when getting attributes throws an invalid-operation exception.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task GetAttributes_RemoteFailure_ThrowsInvalidOperationException()
|
||||
{
|
||||
@@ -144,6 +160,8 @@ public class RouteHelperTests
|
||||
|
||||
// --- WaitForAttribute (spec §6) ---
|
||||
|
||||
/// <summary>Verifies that waiting for an attribute returns true when the value matches.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task WaitForAttribute_Matched_ReturnsTrue()
|
||||
{
|
||||
@@ -160,6 +178,8 @@ public class RouteHelperTests
|
||||
Assert.True(matched);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that waiting for an attribute returns false when the wait times out.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task WaitForAttribute_TimedOut_ReturnsFalse()
|
||||
{
|
||||
@@ -176,6 +196,8 @@ public class RouteHelperTests
|
||||
Assert.False(matched);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a routing failure while waiting for an attribute throws an invalid-operation exception.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task WaitForAttribute_RoutingFailure_ThrowsInvalidOperationException()
|
||||
{
|
||||
@@ -193,6 +215,8 @@ public class RouteHelperTests
|
||||
Assert.Equal("instance not found", ex.Message);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that waiting for an attribute encodes the target value on the request.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task WaitForAttribute_EncodesTargetValue_OnRequest()
|
||||
{
|
||||
@@ -215,10 +239,12 @@ public class RouteHelperTests
|
||||
Assert.True(Guid.TryParse(captured.CorrelationId, out _));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the wait timeout, not the method deadline, governs a wait for an attribute.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task WaitForAttribute_IsNotBoundByMethodDeadline_WaitTimeoutGoverns()
|
||||
{
|
||||
// InboundAPI-029 (spec §6): unlike Call/GetAttributes/SetAttributes, the wait is
|
||||
// Spec §6: unlike Call/GetAttributes/SetAttributes, the wait is
|
||||
// bounded by its OWN timeout, not the method deadline. Even with the method
|
||||
// deadline ALREADY cancelled, the wait still proceeds and returns the site's
|
||||
// wait-timeout result — the deadline does not cut it short.
|
||||
@@ -240,6 +266,8 @@ public class RouteHelperTests
|
||||
Assert.NotEqual(deadline.Token, seen); // it is a per-wait token, not the method deadline
|
||||
}
|
||||
|
||||
/// <summary>Verifies that an explicit cancellation token is honored when waiting for an attribute.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task WaitForAttribute_ExplicitToken_IsHonoured()
|
||||
{
|
||||
@@ -260,6 +288,8 @@ public class RouteHelperTests
|
||||
Assert.True(seen.IsCancellationRequested); // the explicit caller token cancels the wait
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a client disconnect cancels a wait for an attribute.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task WaitForAttribute_ClientDisconnect_CancelsTheWait()
|
||||
{
|
||||
@@ -281,13 +311,15 @@ public class RouteHelperTests
|
||||
Assert.True(seen.IsCancellationRequested); // the client-abort token cancels the wait
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a slow timed-out response is not preempted by a local backstop timeout.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task WaitForAttribute_SlowTimedOutResponse_NotPreemptedByLocalBackstop()
|
||||
{
|
||||
// InboundAPI-031: WaitForAttribute must NOT impose a local wait-timeout backstop.
|
||||
// WaitForAttribute must NOT impose a local wait-timeout backstop.
|
||||
// The site enforces the wait timeout and returns Matched=false; the round trip is
|
||||
// bounded by CommunicationService's Ask (timeout + IntegrationTimeout). A local CTS
|
||||
// of `timeout + small grace` (the prior InboundAPI-029 approach) was TIGHTER than
|
||||
// of `timeout + small grace` (the prior approach) was TIGHTER than
|
||||
// that round-trip budget, so a slow-but-valid timed-out response would be cancelled
|
||||
// into an exception (a 500) instead of the spec §6 `false`. With the backstop
|
||||
// removed, a response arriving well after the (tiny) wait timeout still returns a
|
||||
@@ -316,6 +348,8 @@ public class RouteHelperTests
|
||||
Assert.False(seen.IsCancellationRequested); // no local wait-timeout backstop fired
|
||||
}
|
||||
|
||||
/// <summary>Verifies that waiting for an attribute carries the parent execution id on the request.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task WaitForAttribute_WithParentExecutionId_CarriesItOnRequest()
|
||||
{
|
||||
@@ -337,6 +371,8 @@ public class RouteHelperTests
|
||||
|
||||
// --- SetAttribute(s) ---
|
||||
|
||||
/// <summary>Verifies that setting a single attribute delegates to the batch call with one entry.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task SetAttribute_DelegatesToBatch_WithSingleEntry()
|
||||
{
|
||||
@@ -353,6 +389,8 @@ public class RouteHelperTests
|
||||
Assert.Single(captured.AttributeValues);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a remote failure when setting attributes throws an invalid-operation exception.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task SetAttributes_RemoteFailure_ThrowsInvalidOperationException()
|
||||
{
|
||||
@@ -367,8 +405,10 @@ public class RouteHelperTests
|
||||
Assert.Equal("write rejected", ex.Message);
|
||||
}
|
||||
|
||||
// --- InboundAPI-016: routed calls inherit the method deadline token ---
|
||||
// --- Routed calls inherit the method deadline token ---
|
||||
|
||||
/// <summary>Verifies that a routed call without an explicit token inherits the method deadline token.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Call_WithNoExplicitToken_InheritsMethodDeadlineToken()
|
||||
{
|
||||
@@ -388,6 +428,8 @@ public class RouteHelperTests
|
||||
Assert.Equal(deadline.Token, seen);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a routed call observes cancellation when the method deadline is cancelled.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Call_WhenMethodDeadlineCancelled_RoutedCallObservesCancellation()
|
||||
{
|
||||
@@ -411,6 +453,8 @@ public class RouteHelperTests
|
||||
() => bound.To("inst-1").Call("doWork"));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that an explicit token overrides the deadline token on a routed call.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Call_ExplicitToken_OverridesDeadlineToken()
|
||||
{
|
||||
@@ -429,9 +473,11 @@ public class RouteHelperTests
|
||||
Assert.Equal(explicitCts.Token, seen);
|
||||
}
|
||||
|
||||
// --- Audit Log #23 (ParentExecutionId, T3): a routed call carries the
|
||||
// --- ParentExecutionId: a routed call carries the
|
||||
// inbound request's ExecutionId as RouteToCallRequest.ParentExecutionId ---
|
||||
|
||||
/// <summary>Verifies that a routed call without a parent execution id leaves it null on the request.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Call_WithoutParentExecutionId_LeavesParentExecutionIdNull()
|
||||
{
|
||||
@@ -449,6 +495,8 @@ public class RouteHelperTests
|
||||
Assert.Null(captured!.ParentExecutionId);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a routed call carries the parent execution id on the route-to-call request.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Call_WithParentExecutionId_CarriesItOnRouteToCallRequest()
|
||||
{
|
||||
@@ -472,6 +520,8 @@ public class RouteHelperTests
|
||||
Assert.True(Guid.TryParse(captured.CorrelationId, out _));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that setting the parent execution id preserves the deadline token.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task WithParentExecutionId_PreservesDeadlineToken()
|
||||
{
|
||||
@@ -494,6 +544,8 @@ public class RouteHelperTests
|
||||
Assert.NotNull(captured!.ParentExecutionId);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that getting attributes without an explicit token inherits the method deadline token.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task GetAttributes_WithNoExplicitToken_InheritsMethodDeadlineToken()
|
||||
{
|
||||
@@ -511,6 +563,8 @@ public class RouteHelperTests
|
||||
Assert.Equal(deadline.Token, seen);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that setting attributes without an explicit token inherits the method deadline token.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task SetAttributes_WithNoExplicitToken_InheritsMethodDeadlineToken()
|
||||
{
|
||||
@@ -527,8 +581,10 @@ public class RouteHelperTests
|
||||
Assert.Equal(deadline.Token, seen);
|
||||
}
|
||||
|
||||
// --- InboundAPI-021: ParentExecutionId flows through Get/SetAttributes too ---
|
||||
// --- ParentExecutionId flows through Get/SetAttributes too ---
|
||||
|
||||
/// <summary>Verifies that getting attributes without a parent execution id leaves it null on the request.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task GetAttributes_WithoutParentExecutionId_LeavesParentExecutionIdNull()
|
||||
{
|
||||
@@ -545,6 +601,8 @@ public class RouteHelperTests
|
||||
Assert.Null(captured!.ParentExecutionId);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that getting attributes carries the parent execution id on the route-to-get-attributes request.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task GetAttributes_WithParentExecutionId_CarriesItOnRouteToGetAttributesRequest()
|
||||
{
|
||||
@@ -566,6 +624,8 @@ public class RouteHelperTests
|
||||
Assert.Equal(inboundExecutionId, captured!.ParentExecutionId);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that setting attributes without a parent execution id leaves it null on the request.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task SetAttributes_WithoutParentExecutionId_LeavesParentExecutionIdNull()
|
||||
{
|
||||
@@ -581,6 +641,8 @@ public class RouteHelperTests
|
||||
Assert.Null(captured!.ParentExecutionId);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that setting attributes carries the parent execution id on the route-to-set-attributes request.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task SetAttributes_WithParentExecutionId_CarriesItOnRouteToSetAttributesRequest()
|
||||
{
|
||||
@@ -600,6 +662,8 @@ public class RouteHelperTests
|
||||
|
||||
// --- N3: typed unreachability at the Ask boundary ---
|
||||
|
||||
/// <summary>Verifies that an ask timeout on a routed call throws a site-unreachable exception.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Call_AskTimeout_ThrowsSiteUnreachableException()
|
||||
{
|
||||
@@ -612,6 +676,8 @@ public class RouteHelperTests
|
||||
() => CreateHelper().To("inst").Call("s"));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that an ask timeout when getting attributes throws a site-unreachable exception.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task GetAttributes_AskTimeout_ThrowsSiteUnreachableException()
|
||||
{
|
||||
@@ -632,6 +698,8 @@ public class RouteHelperTests
|
||||
private static IReadOnlySet<string> Scope(params string[] siteIdentifiers) =>
|
||||
new HashSet<string>(siteIdentifiers, StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>Verifies that a routed call outside the permitted site scope throws before any routing happens.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Call_OutsideSiteScope_ThrowsRouteSiteScopeException_BeforeRouting()
|
||||
{
|
||||
@@ -647,6 +715,8 @@ public class RouteHelperTests
|
||||
await _router.DidNotReceiveWithAnyArgs().RouteToCallAsync(default!, default!, default);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a routed call inside the permitted site scope is routed.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Call_InsideSiteScope_Routes()
|
||||
{
|
||||
@@ -660,6 +730,8 @@ public class RouteHelperTests
|
||||
Assert.Equal(42, await helper.To("inst-1").Call("doWork"));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a null site scope leaves routed calls unscoped.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Call_NullScope_IsUnscoped()
|
||||
{
|
||||
@@ -673,6 +745,8 @@ public class RouteHelperTests
|
||||
Assert.Equal(42, await helper.To("inst-1").Call("doWork"));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that setting attributes outside the permitted site scope throws before any routing happens.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task SetAttributes_OutsideSiteScope_ThrowsRouteSiteScopeException_BeforeRouting()
|
||||
{
|
||||
@@ -687,6 +761,8 @@ public class RouteHelperTests
|
||||
await _router.DidNotReceiveWithAnyArgs().RouteToSetAttributesAsync(default!, default!, default);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the site scope survives the other With-style copies.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task WithSiteScope_SurvivesTheOtherWithCopies()
|
||||
{
|
||||
@@ -708,6 +784,8 @@ public class RouteHelperTests
|
||||
|
||||
// --- Arch-review F11: WithBodyCapture ---
|
||||
|
||||
/// <summary>Verifies that the route-call audit row has no request or response summary when body capture is off.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task RouteCallRow_WithBodyCaptureOff_HasNoRequestOrResponseSummary()
|
||||
{
|
||||
@@ -730,6 +808,8 @@ public class RouteHelperTests
|
||||
Assert.Null(row.ResponseSummary);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the route-call audit row has request and response summaries when body capture is on by default.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task RouteCallRow_WithBodyCaptureOn_ByDefault_HasRequestAndResponseSummary()
|
||||
{
|
||||
@@ -751,10 +831,12 @@ public class RouteHelperTests
|
||||
Assert.Equal("99", row.ResponseSummary);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the body-capture flag survives the other With-style copies.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task WithBodyCapture_SurvivesTheOtherWithCopies()
|
||||
{
|
||||
// Mirrors WithSiteScope_SurvivesTheOtherWithCopies (Task 3): the flag must not be
|
||||
// Mirrors WithSiteScope_SurvivesTheOtherWithCopies: the flag must not be
|
||||
// dropped by the copies InboundScriptExecutor makes (WithDeadline /
|
||||
// WithRequestAborted / WithParentExecutionId / WithTrace / WithSiteScope).
|
||||
SiteResolves("inst-1", "SiteA");
|
||||
|
||||
@@ -21,6 +21,7 @@ public class RouteHelperTraceTests
|
||||
private readonly ICentralAuditWriter _writer = Substitute.For<ICentralAuditWriter>();
|
||||
private readonly INodeIdentityProvider _node = Substitute.For<INodeIdentityProvider>();
|
||||
|
||||
/// <summary>Wires the instance locator and audit writer doubles.</summary>
|
||||
public RouteHelperTraceTests()
|
||||
{
|
||||
_locator.GetSiteIdForInstanceAsync("inst-1", Arg.Any<CancellationToken>()).Returns("SiteA");
|
||||
@@ -33,6 +34,8 @@ public class RouteHelperTraceTests
|
||||
private static AuditEvent SiteEvent(Guid exec) => ScadaBridgeAuditEventFactory.Create(
|
||||
AuditChannel.ScriptTrace, AuditKind.ScriptRun, AuditStatus.Delivered, executionId: exec, target: "doWork");
|
||||
|
||||
/// <summary>Verifies that a route failure does not persist an echoed secret when body capture is disabled.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task EI2_RouteFailureWithBodyCaptureDisabled_DoesNotPersistEchoedSecret()
|
||||
{
|
||||
@@ -44,6 +47,8 @@ public class RouteHelperTraceTests
|
||||
Assert.DoesNotContain("SECRET123", System.Text.Json.JsonSerializer.Serialize(row));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a call writes a route-call row using the parent as execution id and the child id from the reply.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Call_WritesRouteCallRow_WithParentAsExecutionId_AndChildFromReply()
|
||||
{
|
||||
@@ -70,6 +75,8 @@ public class RouteHelperTraceTests
|
||||
Assert.Equal("99", row.ResponseSummary);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the route-call row is always written, even when the run is not traced, and the request carries trace false.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Call_IsAlwaysOn_EvenWhenNotTraced_AndTraceFalseOnRequest()
|
||||
{
|
||||
@@ -83,6 +90,8 @@ public class RouteHelperTraceTests
|
||||
Assert.Single(_written);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that enabling tracing propagates the trace flag to both the call and the set-attributes request.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task WithTrace_PropagatesOnCallAndSetAttributes()
|
||||
{
|
||||
@@ -101,6 +110,8 @@ public class RouteHelperTraceTests
|
||||
Assert.True(set!.Trace);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the sink receives the route-call row followed by the reply's trace events, in order.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Sink_ReceivesRouteCallRow_ThenTheReplysTraceEvents_InOrder()
|
||||
{
|
||||
@@ -119,6 +130,8 @@ public class RouteHelperTraceTests
|
||||
Assert.Same(siteEvents[1], sunk[2]);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a routing failure writes a failed row and still throws.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Failure_WritesFailedRow_AndStillThrows()
|
||||
{
|
||||
@@ -132,6 +145,8 @@ public class RouteHelperTraceTests
|
||||
Assert.Equal("script faulted", row.ErrorMessage);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that with no writer and no sink nothing is written and no exception is thrown.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task NoWriterAndNoSink_WritesNothing_AndNeverThrows()
|
||||
{
|
||||
@@ -144,6 +159,8 @@ public class RouteHelperTraceTests
|
||||
Assert.Empty(_written);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a faulting audit writer does not fail the call.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task FaultingWriter_DoesNotFailTheCall()
|
||||
{
|
||||
|
||||
@@ -4,7 +4,7 @@ using ZB.MOM.WW.ScadaBridge.Commons.Types.InboundApi;
|
||||
namespace ZB.MOM.WW.ScadaBridge.InboundAPI.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// M9-T32b runtime regression: the InboundAPI RUNTIME validators
|
||||
/// Runtime regression: the InboundAPI RUNTIME validators
|
||||
/// (<see cref="ParameterValidator"/> / <see cref="ReturnValueValidator"/>) must
|
||||
/// resolve <c>{"$ref":"lib:Name"}</c> library references through the same seam the
|
||||
/// deploy-time path uses. Before this fix the runtime validators called the
|
||||
@@ -31,6 +31,7 @@ public class SchemaRefRuntimeValidationTests
|
||||
|
||||
// ── ParameterValidator: $ref in parameter definitions ─────────────────────
|
||||
|
||||
/// <summary>Verifies that the parameter validator resolves a valid library reference and validates against it.</summary>
|
||||
[Fact]
|
||||
public void ParameterValidator_ValidLibRef_ResolvesAndValidates()
|
||||
{
|
||||
@@ -60,6 +61,7 @@ public class SchemaRefRuntimeValidationTests
|
||||
Assert.DoesNotContain("definitions in method configuration", bad.ErrorMessage);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the parameter validator returns a clear invalid result naming a dangling library reference.</summary>
|
||||
[Fact]
|
||||
public void ParameterValidator_DanglingLibRef_ReturnsClearInvalidNamingTheRef()
|
||||
{
|
||||
@@ -78,6 +80,7 @@ public class SchemaRefRuntimeValidationTests
|
||||
Assert.Contains("GoneLib", result.ErrorMessage!, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the parameter validator behaves unchanged without a resolver when there is no reference.</summary>
|
||||
[Fact]
|
||||
public void ParameterValidator_NoRef_UnchangedWithoutResolver()
|
||||
{
|
||||
@@ -96,6 +99,7 @@ public class SchemaRefRuntimeValidationTests
|
||||
|
||||
// ── ReturnValueValidator: $ref in return definition ───────────────────────
|
||||
|
||||
/// <summary>Verifies that the return value validator resolves a valid library reference and validates against it.</summary>
|
||||
[Fact]
|
||||
public void ReturnValueValidator_ValidLibRef_ResolvesAndValidates()
|
||||
{
|
||||
@@ -121,6 +125,7 @@ public class SchemaRefRuntimeValidationTests
|
||||
Assert.DoesNotContain("definition in method configuration", bad.ErrorMessage);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the return value validator returns a clear invalid result naming a dangling library reference.</summary>
|
||||
[Fact]
|
||||
public void ReturnValueValidator_DanglingLibRef_ReturnsClearInvalidNamingTheRef()
|
||||
{
|
||||
@@ -135,6 +140,7 @@ public class SchemaRefRuntimeValidationTests
|
||||
Assert.Contains("GoneReturn", result.ErrorMessage, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the return value validator behaves unchanged without a resolver when there is no reference.</summary>
|
||||
[Fact]
|
||||
public void ReturnValueValidator_NoRef_UnchangedWithoutResolver()
|
||||
{
|
||||
|
||||
+16
-1
@@ -20,13 +20,16 @@ public class ScriptArtifactChangeSubscriberTests
|
||||
private sealed class RecordingBus : IScriptArtifactChangeBus
|
||||
{
|
||||
private readonly List<Action<ScriptArtifactsChanged>> _handlers = new();
|
||||
/// <summary>Gets the number of currently registered handlers.</summary>
|
||||
public int SubscriberCount => _handlers.Count;
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Publish(ScriptArtifactsChanged notification)
|
||||
{
|
||||
foreach (var handler in _handlers.ToArray()) handler(notification);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public IDisposable Subscribe(Action<ScriptArtifactsChanged> handler)
|
||||
{
|
||||
_handlers.Add(handler);
|
||||
@@ -35,6 +38,7 @@ public class ScriptArtifactChangeSubscriberTests
|
||||
|
||||
private sealed class Token(Action dispose) : IDisposable
|
||||
{
|
||||
/// <summary>Releases the resources used by this instance.</summary>
|
||||
public void Dispose() => dispose();
|
||||
}
|
||||
}
|
||||
@@ -55,6 +59,8 @@ public class ScriptArtifactChangeSubscriberTests
|
||||
private static ScriptArtifactsChanged ApiMethodChanged(params string[] names) =>
|
||||
new(ScriptArtifactKinds.ApiMethod, names, "BundleImport", DateTimeOffset.UtcNow);
|
||||
|
||||
/// <summary>Verifies that an ApiMethod publish invalidates the cached handler without waiting for the revision-check self-heal.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task ApiMethodPublish_InvalidatesCachedHandler_WithoutRevisionCheckSelfHeal()
|
||||
{
|
||||
@@ -77,6 +83,8 @@ public class ScriptArtifactChangeSubscriberTests
|
||||
Assert.Contains("1", after.ResultJson); // recompiled from the fresh row — invalidation, not self-heal
|
||||
}
|
||||
|
||||
/// <summary>Verifies that an ApiMethod publish purges the known-bad record.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task ApiMethodPublish_PurgesKnownBadRecord()
|
||||
{
|
||||
@@ -92,6 +100,8 @@ public class ScriptArtifactChangeSubscriberTests
|
||||
Assert.Equal(0, _executor.KnownBadMethodCount);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that non-ApiMethod artifact kinds are ignored.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task NonApiMethodKinds_AreIgnored()
|
||||
{
|
||||
@@ -110,6 +120,8 @@ public class ScriptArtifactChangeSubscriberTests
|
||||
Assert.Contains("99", result.ResultJson); // pinned handler untouched
|
||||
}
|
||||
|
||||
/// <summary>Verifies that stopping the subscriber unsubscribes it from the bus.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task StopAsync_Unsubscribes()
|
||||
{
|
||||
@@ -123,10 +135,11 @@ public class ScriptArtifactChangeSubscriberTests
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// WP2.6b: an ApiMethod change notification must also drop the resolved-row cache
|
||||
/// An ApiMethod change notification must also drop the resolved-row cache
|
||||
/// entry (not just the compiled handler) — proving EndpointExtensions' next lookup
|
||||
/// re-fetches instead of serving a stale cached row for the rest of the TTL window.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task ApiMethodPublish_InvalidatesResolvedMethodCache()
|
||||
{
|
||||
@@ -150,6 +163,8 @@ public class ScriptArtifactChangeSubscriberTests
|
||||
Assert.Equal(2, fetchCount); // cache entry was dropped — re-fetched
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a null bus is tolerated and the subscriber does nothing.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task NullBus_NoOps()
|
||||
{
|
||||
|
||||
@@ -20,6 +20,8 @@ public class StandbyRequestForwardingTests
|
||||
// Filter: when does a standby relay, and when does it refuse?
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
/// <summary>Verifies that the relay does not negotiate an encoding it cannot forward.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Relay_DoesNotNegotiateAnEncodingItCannotForward()
|
||||
{
|
||||
@@ -34,6 +36,9 @@ public class StandbyRequestForwardingTests
|
||||
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a request the peer applied whose response was lost is not replayed.</summary>
|
||||
/// <param name="timeout">True to simulate the lost response as a timeout, false to simulate a connection failure.</param>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Theory]
|
||||
[InlineData(false)]
|
||||
[InlineData(true)]
|
||||
@@ -56,6 +61,8 @@ public class StandbyRequestForwardingTests
|
||||
Assert.Contains("OUTCOME_UNKNOWN", await new StreamReader(ctx.Response.Body).ReadToEndAsync());
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a standby with a forwarder configured relays the request and does not run the handler.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Standby_WithForwarderConfigured_RelaysAndDoesNotRunHandler()
|
||||
{
|
||||
@@ -71,6 +78,8 @@ public class StandbyRequestForwardingTests
|
||||
Assert.True(ctx.Items.ContainsKey(InboundApiEndpointFilter.RelayedToActiveItemKey));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a standby without a configured forwarder returns 503 STANDBY_NODE marked for peers.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Standby_WithForwarderUnconfigured_Returns503StandbyNode_MarkedForPeers()
|
||||
{
|
||||
@@ -87,6 +96,8 @@ public class StandbyRequestForwardingTests
|
||||
Assert.False(ctx.Items.ContainsKey(InboundApiEndpointFilter.RelayedToActiveItemKey));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a standby does not relay an already forwarded request again (loop guard).</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Standby_AlreadyForwardedRequest_IsNotRelayedAgain_LoopGuard()
|
||||
{
|
||||
@@ -103,6 +114,8 @@ public class StandbyRequestForwardingTests
|
||||
Assert.Equal("1", ctx.Response.Headers[HttpStandbyRequestForwarder.StandbyResponseHeader]);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a standby returns 413 for an oversized body before relaying.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Standby_OversizedBody_Returns413_BeforeRelaying()
|
||||
{
|
||||
@@ -119,6 +132,8 @@ public class StandbyRequestForwardingTests
|
||||
AssertStatus(result, StatusCodes.Status413PayloadTooLarge);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that an active node serves a forwarded request locally.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task ActiveNode_ForwardedRequest_IsServedLocally()
|
||||
{
|
||||
@@ -139,6 +154,8 @@ public class StandbyRequestForwardingTests
|
||||
// Forwarder: relay mechanics against a fake HTTP peer
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
/// <summary>Verifies that the forwarder relays the method, path, query, headers and body and replays the peer response.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Forwarder_RelaysMethodPathQueryHeadersAndBody_AndReplaysPeerResponse()
|
||||
{
|
||||
@@ -175,6 +192,8 @@ public class StandbyRequestForwardingTests
|
||||
Assert.Equal("application/json", seen.Content!.Headers.ContentType!.MediaType);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the forwarder replays a peer error status unchanged.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Forwarder_ReplaysPeerErrorStatusUnchanged()
|
||||
{
|
||||
@@ -191,6 +210,8 @@ public class StandbyRequestForwardingTests
|
||||
Assert.Contains("NOT_APPROVED", Encoding.UTF8.GetString(relayed.Body));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the forwarder tries the next peer with the same body when a peer is unreachable.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Forwarder_PeerUnreachable_TriesNextPeer_WithSameBody()
|
||||
{
|
||||
@@ -211,6 +232,8 @@ public class StandbyRequestForwardingTests
|
||||
Assert.Equal(new[] { "{\"n\":7}", "{\"n\":7}" }, bodies);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the forwarder tries the next peer when a peer is also a standby.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Forwarder_PeerIsAlsoStandby_TriesNextPeer()
|
||||
{
|
||||
@@ -233,6 +256,8 @@ public class StandbyRequestForwardingTests
|
||||
Assert.Equal("served", Encoding.UTF8.GetString(relayed.Body));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a genuine 503 from the active peer is replayed rather than retried.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Forwarder_Genuine503FromActivePeer_IsReplayed_NotRetried()
|
||||
{
|
||||
@@ -257,6 +282,8 @@ public class StandbyRequestForwardingTests
|
||||
Assert.Equal(1, calls);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the forwarder returns 503 ACTIVE_NODE_UNREACHABLE when all peers fail.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Forwarder_AllPeersFail_Returns503ActiveNodeUnreachable()
|
||||
{
|
||||
@@ -269,6 +296,8 @@ public class StandbyRequestForwardingTests
|
||||
AssertStatus(result, StatusCodes.Status503ServiceUnavailable);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a peer timeout returns an uncertain result without trying the next peer.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Forwarder_PeerTimesOut_ReturnsUncertainWithoutTryingNextPeer()
|
||||
{
|
||||
@@ -288,6 +317,8 @@ public class StandbyRequestForwardingTests
|
||||
AssertStatus(result, StatusCodes.Status502BadGateway);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a caller abort propagates and does not try the next peer.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Forwarder_CallerAborts_Propagates_DoesNotTryNextPeer()
|
||||
{
|
||||
@@ -307,6 +338,7 @@ public class StandbyRequestForwardingTests
|
||||
Assert.Equal(1, calls);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that IsConfigured tracks the peer list.</summary>
|
||||
[Fact]
|
||||
public void Forwarder_IsConfigured_TracksPeerList()
|
||||
{
|
||||
@@ -314,6 +346,8 @@ public class StandbyRequestForwardingTests
|
||||
Assert.True(CreateForwarder(new FakeHandler(_ => throw new InvalidOperationException()), "http://b:5000").IsConfigured);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a relayed result writes the status, content type and body.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task RelayedResult_WritesStatusContentTypeAndBody()
|
||||
{
|
||||
@@ -392,10 +426,17 @@ public class StandbyRequestForwardingTests
|
||||
|
||||
private sealed class StubForwarder : IStandbyRequestForwarder
|
||||
{
|
||||
/// <summary>Initializes the stub with its configured flag and canned result.</summary>
|
||||
/// <param name="configured">Whether the forwarder reports itself as configured.</param>
|
||||
/// <param name="result">The result returned from forwarding.</param>
|
||||
public StubForwarder(bool configured, IResult result) { IsConfigured = configured; Result = result; }
|
||||
/// <inheritdoc />
|
||||
public bool IsConfigured { get; }
|
||||
/// <summary>Gets the canned result returned from forwarding.</summary>
|
||||
public IResult Result { get; }
|
||||
/// <summary>Gets the number of times forwarding was invoked.</summary>
|
||||
public int Calls { get; private set; }
|
||||
/// <inheritdoc />
|
||||
public Task<IResult> ForwardAsync(HttpContext context, CancellationToken cancellationToken)
|
||||
{
|
||||
Calls++;
|
||||
@@ -405,15 +446,23 @@ public class StandbyRequestForwardingTests
|
||||
|
||||
private sealed class StubActiveNodeGate : IActiveNodeGate
|
||||
{
|
||||
/// <summary>Initializes the gate with a fixed active-node answer.</summary>
|
||||
/// <param name="isActive">Whether this node reports itself as active.</param>
|
||||
public StubActiveNodeGate(bool isActive) => IsActiveNode = isActive;
|
||||
/// <inheritdoc />
|
||||
public bool IsActiveNode { get; }
|
||||
}
|
||||
|
||||
private sealed class FakeHandler : HttpMessageHandler
|
||||
{
|
||||
private readonly Func<HttpRequestMessage, CancellationToken, Task<HttpResponseMessage>> _send;
|
||||
/// <summary>Initializes the handler with a send delegate that ignores the cancellation token.</summary>
|
||||
/// <param name="send">The delegate that produces the response for a request.</param>
|
||||
public FakeHandler(Func<HttpRequestMessage, Task<HttpResponseMessage>> send) => _send = (r, _) => send(r);
|
||||
/// <summary>Initializes the handler with a send delegate that observes the cancellation token.</summary>
|
||||
/// <param name="send">The delegate that produces the response for a request.</param>
|
||||
public FakeHandler(Func<HttpRequestMessage, CancellationToken, Task<HttpResponseMessage>> send) => _send = send;
|
||||
/// <inheritdoc />
|
||||
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken ct) =>
|
||||
_send(request, ct);
|
||||
}
|
||||
@@ -421,16 +470,30 @@ public class StandbyRequestForwardingTests
|
||||
private sealed class FakeHttpClientFactory : IHttpClientFactory
|
||||
{
|
||||
private readonly HttpMessageHandler _handler;
|
||||
/// <summary>Initializes the factory with the handler every client will use.</summary>
|
||||
/// <param name="handler">The message handler shared by created clients.</param>
|
||||
public FakeHttpClientFactory(HttpMessageHandler handler) => _handler = handler;
|
||||
/// <summary>Returns a client over the canned handler.</summary>
|
||||
/// <param name="name">The requested client name (ignored).</param>
|
||||
/// <returns>An <see cref="HttpClient"/> that uses the fake handler.</returns>
|
||||
public HttpClient CreateClient(string name) =>
|
||||
new(_handler, disposeHandler: false) { Timeout = Timeout.InfiniteTimeSpan };
|
||||
}
|
||||
|
||||
private sealed class StaticOptionsMonitor<T> : IOptionsMonitor<T>
|
||||
{
|
||||
/// <summary>Initializes the monitor with a fixed value.</summary>
|
||||
/// <param name="value">The value returned by the monitor.</param>
|
||||
public StaticOptionsMonitor(T value) => CurrentValue = value;
|
||||
/// <summary>Gets the canned options value.</summary>
|
||||
public T CurrentValue { get; }
|
||||
/// <summary>Returns the canned options value regardless of name.</summary>
|
||||
/// <param name="name">The requested options name (ignored).</param>
|
||||
/// <returns>The canned options value.</returns>
|
||||
public T Get(string? name) => CurrentValue;
|
||||
/// <summary>Ignores the change listener; the fake never raises changes.</summary>
|
||||
/// <param name="listener">The change listener (ignored).</param>
|
||||
/// <returns>Always null.</returns>
|
||||
public IDisposable? OnChange(Action<T, string?> listener) => null;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user