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:
@@ -26,6 +26,7 @@ public class ApiKeyCreationTests : TestKit, IDisposable
|
||||
private readonly FakeInboundApiKeyAdmin _admin = new();
|
||||
private readonly IAuditService _auditService = Substitute.For<IAuditService>();
|
||||
|
||||
/// <summary>Initializes the test with a fake key admin and a substituted audit service.</summary>
|
||||
public ApiKeyCreationTests()
|
||||
{
|
||||
_services.AddScoped<IInboundApiKeyAdmin>(_ => _admin);
|
||||
@@ -42,8 +43,10 @@ public class ApiKeyCreationTests : TestKit, IDisposable
|
||||
new(new AuthenticatedUser("admin", "Admin User", roles, Array.Empty<string>()),
|
||||
command, Guid.NewGuid().ToString("N"));
|
||||
|
||||
/// <inheritdoc />
|
||||
void IDisposable.Dispose() => Shutdown();
|
||||
|
||||
/// <summary>Verifies that creating an API key returns the key id and a one-time token.</summary>
|
||||
[Fact]
|
||||
public void CreateApiKey_ReturnsKeyIdAndOneTimeToken()
|
||||
{
|
||||
@@ -69,6 +72,7 @@ public class ApiKeyCreationTests : TestKit, IDisposable
|
||||
Assert.Equal(new[] { "MethodA", "MethodB" }, created.Methods);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that creating an API key writes an audit entry.</summary>
|
||||
[Fact]
|
||||
public void CreateApiKey_AuditsTheCreate()
|
||||
{
|
||||
@@ -80,6 +84,7 @@ public class ApiKeyCreationTests : TestKit, IDisposable
|
||||
"admin", "Create", "ApiKey", Arg.Any<string>(), "MES-Production", Arg.Any<object?>());
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the create response does not echo a hash of the key.</summary>
|
||||
[Fact]
|
||||
public void CreateApiKey_ResponseDoesNotEchoAHash()
|
||||
{
|
||||
@@ -93,6 +98,7 @@ public class ApiKeyCreationTests : TestKit, IDisposable
|
||||
Assert.DoesNotContain("hash", response.JsonData, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that listing API keys returns each key with its methods.</summary>
|
||||
[Fact]
|
||||
public void ListApiKeys_ReturnsKeysWithMethods()
|
||||
{
|
||||
@@ -119,6 +125,7 @@ public class ApiKeyCreationTests : TestKit, IDisposable
|
||||
Assert.DoesNotContain("hash", response.JsonData, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that updating an API key toggles its enabled flag and writes an audit entry.</summary>
|
||||
[Fact]
|
||||
public void UpdateApiKey_TogglesEnabled_AndAudits()
|
||||
{
|
||||
@@ -139,6 +146,7 @@ public class ApiKeyCreationTests : TestKit, IDisposable
|
||||
"admin", "Update", "ApiKey", "key-1", Arg.Any<string>(), Arg.Any<object?>());
|
||||
}
|
||||
|
||||
/// <summary>Verifies that deleting an API key removes it and writes an audit entry.</summary>
|
||||
[Fact]
|
||||
public void DeleteApiKey_RemovesKey_AndAudits()
|
||||
{
|
||||
@@ -156,6 +164,7 @@ public class ApiKeyCreationTests : TestKit, IDisposable
|
||||
"admin", "Delete", "ApiKey", "key-1", "key-1", null);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that setting API key methods replaces the key's scopes and writes an audit entry.</summary>
|
||||
[Fact]
|
||||
public void SetApiKeyMethods_ReplacesScopes_AndAudits()
|
||||
{
|
||||
@@ -183,6 +192,7 @@ public class ApiKeyCreationTests : TestKit, IDisposable
|
||||
// Fix 1 (review): not-found on mutating ops → ManagementError, no audit
|
||||
// =========================================================================
|
||||
|
||||
/// <summary>Verifies that updating an unknown API key returns a management error and writes no audit entry.</summary>
|
||||
[Fact]
|
||||
public void UpdateApiKey_UnknownKey_ReturnsManagementError_AndDoesNotAudit()
|
||||
{
|
||||
@@ -198,6 +208,7 @@ public class ApiKeyCreationTests : TestKit, IDisposable
|
||||
Arg.Any<string>(), "Update", "ApiKey", Arg.Any<string>(), Arg.Any<string>(), Arg.Any<object?>());
|
||||
}
|
||||
|
||||
/// <summary>Verifies that setting methods on an unknown API key returns a management error and writes no audit entry.</summary>
|
||||
[Fact]
|
||||
public void SetApiKeyMethods_UnknownKey_ReturnsManagementError_AndDoesNotAudit()
|
||||
{
|
||||
@@ -211,6 +222,7 @@ public class ApiKeyCreationTests : TestKit, IDisposable
|
||||
Arg.Any<string>(), "Update", "ApiKey", Arg.Any<string>(), Arg.Any<string>(), Arg.Any<object?>());
|
||||
}
|
||||
|
||||
/// <summary>Verifies that deleting an unknown API key returns a management error and writes no audit entry.</summary>
|
||||
[Fact]
|
||||
public void DeleteApiKey_UnknownKey_ReturnsManagementError_AndDoesNotAudit()
|
||||
{
|
||||
@@ -228,6 +240,7 @@ public class ApiKeyCreationTests : TestKit, IDisposable
|
||||
// Fix 2 (review): empty methods set is rejected before seam + audit
|
||||
// =========================================================================
|
||||
|
||||
/// <summary>Verifies that creating an API key with no methods returns a management error.</summary>
|
||||
[Fact]
|
||||
public void CreateApiKey_EmptyMethods_ReturnsManagementError()
|
||||
{
|
||||
@@ -243,6 +256,7 @@ public class ApiKeyCreationTests : TestKit, IDisposable
|
||||
Arg.Any<string>(), "Create", "ApiKey", Arg.Any<string>(), Arg.Any<string>(), Arg.Any<object?>());
|
||||
}
|
||||
|
||||
/// <summary>Verifies that setting an empty method list on an API key returns a management error.</summary>
|
||||
[Fact]
|
||||
public void SetApiKeyMethods_EmptyMethods_ReturnsManagementError()
|
||||
{
|
||||
@@ -260,6 +274,8 @@ public class ApiKeyCreationTests : TestKit, IDisposable
|
||||
Arg.Any<string>(), "Update", "ApiKey", Arg.Any<string>(), Arg.Any<string>(), Arg.Any<object?>());
|
||||
}
|
||||
|
||||
/// <summary>Verifies that every API key command is rejected for a caller without the Administrator role.</summary>
|
||||
/// <param name="command">The API key command under test.</param>
|
||||
[Theory]
|
||||
[MemberData(nameof(AllApiKeyCommands))]
|
||||
public void EveryApiKeyCommand_RequiresAdminRole(object command)
|
||||
@@ -272,6 +288,8 @@ public class ApiKeyCreationTests : TestKit, IDisposable
|
||||
Assert.Contains("Administrator", response.Message);
|
||||
}
|
||||
|
||||
/// <summary>Supplies one instance of every API key command as test data.</summary>
|
||||
/// <returns>The commands, each wrapped as a single-element argument array.</returns>
|
||||
public static IEnumerable<object[]> AllApiKeyCommands() => new[]
|
||||
{
|
||||
new object[] { new ListApiKeysCommand() },
|
||||
@@ -291,11 +309,18 @@ public class ApiKeyCreationTests : TestKit, IDisposable
|
||||
{
|
||||
public sealed record Entry(string KeyId, string Name, bool Enabled, IReadOnlyList<string> Methods);
|
||||
|
||||
/// <summary>Gets the keys currently held by the fake, indexed by key id.</summary>
|
||||
public Dictionary<string, Entry> Keys { get; } = new(StringComparer.Ordinal);
|
||||
|
||||
/// <summary>Adds or replaces a key in the fake.</summary>
|
||||
/// <param name="keyId">The key id.</param>
|
||||
/// <param name="name">The key name.</param>
|
||||
/// <param name="enabled">Whether the key is enabled.</param>
|
||||
/// <param name="methods">The methods the key may call.</param>
|
||||
public void Seed(string keyId, string name, bool enabled, params string[] methods) =>
|
||||
Keys[keyId] = new Entry(keyId, name, enabled, methods.ToList());
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<InboundApiKeyCreated> CreateAsync(
|
||||
string name, IReadOnlyCollection<string> methods, CancellationToken ct = default)
|
||||
{
|
||||
@@ -304,6 +329,7 @@ public class ApiKeyCreationTests : TestKit, IDisposable
|
||||
return Task.FromResult(new InboundApiKeyCreated(keyId, $"sbk_{keyId}_secret"));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<IReadOnlyList<InboundApiKeyInfo>> ListAsync(CancellationToken ct = default)
|
||||
{
|
||||
IReadOnlyList<InboundApiKeyInfo> result = Keys.Values
|
||||
@@ -314,6 +340,7 @@ public class ApiKeyCreationTests : TestKit, IDisposable
|
||||
return Task.FromResult(result);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<bool> SetEnabledAsync(string keyId, bool enabled, CancellationToken ct = default)
|
||||
{
|
||||
if (!Keys.TryGetValue(keyId, out var e)) return Task.FromResult(false);
|
||||
@@ -321,6 +348,7 @@ public class ApiKeyCreationTests : TestKit, IDisposable
|
||||
return Task.FromResult(true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<bool> SetMethodsAsync(string keyId, IReadOnlyCollection<string> methods, CancellationToken ct = default)
|
||||
{
|
||||
if (!Keys.TryGetValue(keyId, out var e)) return Task.FromResult(false);
|
||||
@@ -328,15 +356,18 @@ public class ApiKeyCreationTests : TestKit, IDisposable
|
||||
return Task.FromResult(true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<bool> DeleteAsync(string keyId, CancellationToken ct = default) =>
|
||||
Task.FromResult(Keys.Remove(keyId));
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<IReadOnlyList<string>> GetMethodsForKeyAsync(string keyId, CancellationToken ct = default)
|
||||
{
|
||||
IReadOnlyList<string> methods = Keys.TryGetValue(keyId, out var e) ? e.Methods : Array.Empty<string>();
|
||||
return Task.FromResult(methods);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<IReadOnlyList<string>> GetKeysForMethodAsync(string methodName, CancellationToken ct = default)
|
||||
{
|
||||
IReadOnlyList<string> keys = Keys.Values
|
||||
|
||||
@@ -20,6 +20,7 @@ public class ApiMethodTraceAllCallsTests : TestKit, IDisposable
|
||||
private readonly ServiceCollection _services = new ServiceCollection().AddTestConfigurationTransaction();
|
||||
private readonly IInboundApiRepository _apiRepo = Substitute.For<IInboundApiRepository>();
|
||||
|
||||
/// <summary>Initializes the test class with a stubbed inbound API repository and test services.</summary>
|
||||
public ApiMethodTraceAllCallsTests()
|
||||
{
|
||||
_services.AddScoped(_ => _apiRepo);
|
||||
@@ -35,8 +36,10 @@ public class ApiMethodTraceAllCallsTests : TestKit, IDisposable
|
||||
new(new AuthenticatedUser("designer", "Designer", new[] { "Designer" }, Array.Empty<string>()),
|
||||
command, Guid.NewGuid().ToString("N"));
|
||||
|
||||
/// <inheritdoc />
|
||||
void IDisposable.Dispose() => Shutdown();
|
||||
|
||||
/// <summary>Verifies that commands default to untraced when built with the five-argument constructor.</summary>
|
||||
[Fact]
|
||||
public void Commands_DefaultToUntraced_ForTheFiveArgConstructor()
|
||||
{
|
||||
@@ -45,6 +48,7 @@ public class ApiMethodTraceAllCallsTests : TestKit, IDisposable
|
||||
Assert.False(new ApiMethod("m", "return 1;").TraceAllCalls);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that creating a method with the trace-all-calls flag persists it and echoes it in the result.</summary>
|
||||
[Fact]
|
||||
public void Create_WithTraceAllCalls_PersistsTheFlag_AndEchoesItInTheResult()
|
||||
{
|
||||
@@ -60,6 +64,7 @@ public class ApiMethodTraceAllCallsTests : TestKit, IDisposable
|
||||
Assert.Contains("\"traceAllCalls\":true", response.JsonData.Replace(" ", ""));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that updating with an explicit false clears the trace-all-calls flag.</summary>
|
||||
[Fact]
|
||||
public void Update_WithExplicitFalse_ClearsTheFlag()
|
||||
{
|
||||
@@ -72,6 +77,7 @@ public class ApiMethodTraceAllCallsTests : TestKit, IDisposable
|
||||
Assert.False(existing.TraceAllCalls);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that an update that omits the flag leaves the stored value unchanged.</summary>
|
||||
[Fact]
|
||||
public void Update_OmittingTheFlag_LeavesTheStoredValueUnchanged()
|
||||
{
|
||||
|
||||
@@ -21,7 +21,7 @@ using ZB.MOM.WW.ScadaBridge.Security;
|
||||
namespace ZB.MOM.WW.ScadaBridge.ManagementService.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// HTTP-pipeline tests for the #23 M8 audit endpoints (<see cref="AuditEndpoints"/>).
|
||||
/// HTTP-pipeline tests for the audit endpoints (<see cref="AuditEndpoints"/>).
|
||||
///
|
||||
/// <para>
|
||||
/// ManagementService authenticates each request by hand (HTTP Basic → LDAP →
|
||||
@@ -35,7 +35,7 @@ public class AuditEndpointsTests
|
||||
{
|
||||
private const string BasicCredential = "auditor:password";
|
||||
|
||||
// C3 (Task 2.5): canonical ZB.MOM.WW.Audit.AuditEvent via the shared factory;
|
||||
// Canonical ZB.MOM.WW.Audit.AuditEvent via the shared factory;
|
||||
// the endpoint serializes the canonical record (eventId + occurredAtUtc top-level,
|
||||
// domain fields out of DetailsJson) so these tests assert on the JSON/CSV output.
|
||||
private static AuditEvent SampleEvent(Guid? id = null, DateTime? occurredAt = null) =>
|
||||
@@ -74,7 +74,7 @@ public class AuditEndpointsTests
|
||||
.Returns(Task.FromResult<IReadOnlyList<AuditEvent>>(Array.Empty<AuditEvent>()));
|
||||
}
|
||||
|
||||
// Substituted LDAP bind — the shared ILdapAuthService is the seam now (Task 1.2).
|
||||
// Substituted LDAP bind — the shared ILdapAuthService is the seam now.
|
||||
var ldap = Substitute.For<ILdapAuthService>();
|
||||
ldap.AuthenticateAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<CancellationToken>())
|
||||
.Returns(ldapSucceeds
|
||||
@@ -127,6 +127,8 @@ public class AuditEndpointsTests
|
||||
// /api/audit/query
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>Verifies that a query with valid parameters returns a JSON page of rows.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Query_ValidParams_ReturnsJsonPage()
|
||||
{
|
||||
@@ -152,6 +154,8 @@ public class AuditEndpointsTests
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a query with a cursor returns the next page.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Query_WithCursor_ReturnsNextPage()
|
||||
{
|
||||
@@ -196,6 +200,8 @@ public class AuditEndpointsTests
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a query without the operational-audit permission returns 403.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Query_WithoutOperationalAudit_Returns403()
|
||||
{
|
||||
@@ -209,6 +215,8 @@ public class AuditEndpointsTests
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a query without credentials returns 401.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Query_WithoutCredentials_Returns401()
|
||||
{
|
||||
@@ -220,10 +228,12 @@ public class AuditEndpointsTests
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a query by a viewer is allowed.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Query_ViewerRole_IsAllowed()
|
||||
{
|
||||
// Viewer (post Task 1.7 home of the former AuditReadOnly role) satisfies
|
||||
// Viewer (the home of the former AuditReadOnly role) satisfies
|
||||
// OperationalAudit (read) — query must succeed.
|
||||
var (client, _, host) = await BuildHostAsync(roles: new[] { "Viewer" });
|
||||
using (host)
|
||||
@@ -237,6 +247,8 @@ public class AuditEndpointsTests
|
||||
// /api/audit/export
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>Verifies that a CSV export streams its content with a CSV content type.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Export_Csv_StreamsContent_WithCsvContentType()
|
||||
{
|
||||
@@ -265,6 +277,8 @@ public class AuditEndpointsTests
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Verifies that an export defaults to CSV when the format is omitted.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Export_Csv_DefaultsWhenFormatOmitted()
|
||||
{
|
||||
@@ -280,6 +294,8 @@ public class AuditEndpointsTests
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a JSON Lines export streams one record per line.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Export_Jsonl_StreamsOnePerLine()
|
||||
{
|
||||
@@ -317,9 +333,9 @@ public class AuditEndpointsTests
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// CSV export — formula-injection neutralization + column alignment
|
||||
// (arch-review 2026-08-19 P6-7)
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>Verifies that the CSV header has 24 columns in the same order as the export row properties.</summary>
|
||||
[Fact]
|
||||
public void CsvHeader_Has24Columns_SameOrderAsAuditExportRowProperties()
|
||||
{
|
||||
@@ -341,6 +357,7 @@ public class AuditEndpointsTests
|
||||
}, columns);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a formatted CSV row includes the execution id, parent execution id and source node.</summary>
|
||||
[Fact]
|
||||
public void FormatCsvRow_IncludesExecutionParentExecutionAndSourceNode()
|
||||
{
|
||||
@@ -363,6 +380,10 @@ public class AuditEndpointsTests
|
||||
Assert.Equal("site-a-node-a", fields[9]); // SourceNode
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a free-text field starting with a formula character is prefixed with a single quote in the CSV export.</summary>
|
||||
/// <param name="malicious">The free-text value that starts with a formula character.</param>
|
||||
/// <param name="expectedPrefix">The expected start of the exported cell.</param>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Theory]
|
||||
[InlineData("=HYPERLINK(\"http://evil\",\"click\")", "'=HYPERLINK(")]
|
||||
[InlineData("+cmd|'/c calc'!A1", "'+cmd|")]
|
||||
@@ -371,7 +392,7 @@ public class AuditEndpointsTests
|
||||
[InlineData("\tsneaky", "'\tsneaky")]
|
||||
public async Task Export_Csv_FreeTextLeadingFormulaCharacter_IsPrefixedWithQuote(string malicious, string expectedPrefix)
|
||||
{
|
||||
// CWE-1236: Target is inbound-API-caller-controlled free text. A raw
|
||||
// CSV formula injection: Target is inbound-API-caller-controlled free text. A raw
|
||||
// leading =, +, -, @, or TAB must never reach the CSV cell unescaped.
|
||||
var evt = ScadaBridgeAuditEventFactory.Create(
|
||||
channel: AuditChannel.ApiInbound,
|
||||
@@ -400,6 +421,7 @@ public class AuditEndpointsTests
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the error message, response summary and extra columns are neutralized against formula injection.</summary>
|
||||
[Fact]
|
||||
public void FormatCsvRow_ErrorMessageResponseSummaryAndExtra_AreNeutralized()
|
||||
{
|
||||
@@ -422,6 +444,7 @@ public class AuditEndpointsTests
|
||||
Assert.Equal("'@evil", fields[22]); // Extra
|
||||
}
|
||||
|
||||
/// <summary>Verifies that negative durations and date columns are never neutralized.</summary>
|
||||
[Fact]
|
||||
public void FormatCsvRow_NegativeDurationAndDateColumns_NeverNeutralized()
|
||||
{
|
||||
@@ -445,6 +468,7 @@ public class AuditEndpointsTests
|
||||
Assert.Equal("200", fields[15]); // HttpStatus, unprefixed
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a benign free-text field is not prefixed.</summary>
|
||||
[Fact]
|
||||
public void FormatCsvRow_BenignFreeTextField_IsNotPrefixed()
|
||||
{
|
||||
@@ -462,6 +486,7 @@ public class AuditEndpointsTests
|
||||
Assert.Equal("plant-a-external-system", fields[13]); // Target
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the CSV header columns match the property order of the export row.</summary>
|
||||
[Fact]
|
||||
public void CsvHeader_ColumnsMatchAuditExportRowPropertyOrder()
|
||||
{
|
||||
@@ -481,6 +506,8 @@ public class AuditEndpointsTests
|
||||
Assert.Equal(propertyNames, AuditEndpoints.CsvHeader.Split(','));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a Parquet export returns 501.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Export_Parquet_Returns501()
|
||||
{
|
||||
@@ -497,12 +524,14 @@ public class AuditEndpointsTests
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Verifies that an export without the audit-export permission returns 403.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Export_WithoutAuditExport_Returns403()
|
||||
{
|
||||
// Viewer (former AuditReadOnly) grants read (OperationalAudit) but NOT
|
||||
// bulk export (AuditExport) — the export endpoint must 403. This is the
|
||||
// preserved half-SoD after the Task 1.7 AuditReadOnly→Viewer collapse.
|
||||
// preserved half-SoD after the AuditReadOnly→Viewer collapse.
|
||||
var (client, _, host) = await BuildHostAsync(roles: new[] { "Viewer" });
|
||||
using (host)
|
||||
{
|
||||
@@ -511,6 +540,8 @@ public class AuditEndpointsTests
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Verifies that an export with an unsupported format returns 400.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Export_UnsupportedFormat_Returns400()
|
||||
{
|
||||
@@ -526,6 +557,7 @@ public class AuditEndpointsTests
|
||||
// Query-string parsing units
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>Verifies that paging parsing clamps the page size to the maximum.</summary>
|
||||
[Fact]
|
||||
public void ParsePaging_ClampsPageSizeToMax()
|
||||
{
|
||||
@@ -540,6 +572,7 @@ public class AuditEndpointsTests
|
||||
Assert.Equal(AuditEndpoints.MaxPageSize, paging.PageSize);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that repeated filter parameters parse into multi-value lists.</summary>
|
||||
[Fact]
|
||||
public void ParseFilter_RepeatedParams_ParseIntoMultiValueLists()
|
||||
{
|
||||
@@ -562,6 +595,7 @@ public class AuditEndpointsTests
|
||||
Assert.Equal(new[] { "plant-a", "plant-b" }, filter.SourceSiteIds);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a single filter parameter parses into a one-element list.</summary>
|
||||
[Fact]
|
||||
public void ParseFilter_SingleParam_ParsesIntoOneElementList()
|
||||
{
|
||||
@@ -594,6 +628,7 @@ public class AuditEndpointsTests
|
||||
// removed. The Guid/date dimensions stay lax — see ParseFilter's remarks.
|
||||
// ========================================================================
|
||||
|
||||
/// <summary>Verifies that an unrecognised enum value in a filter throws.</summary>
|
||||
[Fact]
|
||||
public void ParseFilter_UnrecognisedEnumValue_Throws()
|
||||
{
|
||||
@@ -608,6 +643,7 @@ public class AuditEndpointsTests
|
||||
Assert.Contains("channel", ex.Message);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that an unrecognised status throws an error naming the valid values.</summary>
|
||||
[Fact]
|
||||
public void ParseFilter_UnrecognisedStatus_ThrowsNamingTheValidSet()
|
||||
{
|
||||
@@ -624,6 +660,7 @@ public class AuditEndpointsTests
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Verifies that lowercase enum values are accepted in a filter.</summary>
|
||||
[Fact]
|
||||
public void ParseFilter_LowercaseEnumValues_AreAccepted()
|
||||
{
|
||||
@@ -644,6 +681,7 @@ public class AuditEndpointsTests
|
||||
Assert.Equal(new[] { AuditStatus.Delivered, AuditStatus.Parked }, filter.Statuses);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a numeric enum value in a filter is rejected.</summary>
|
||||
[Fact]
|
||||
public void ParseFilter_NumericEnumValue_IsRejected()
|
||||
{
|
||||
@@ -658,6 +696,7 @@ public class AuditEndpointsTests
|
||||
Assert.Throws<AuditFilterParseException>(() => AuditEndpoints.ParseFilter(query));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that an execution id filter parses into a single GUID value.</summary>
|
||||
[Fact]
|
||||
public void ParseFilter_ExecutionId_ParsesIntoSingleValueGuid()
|
||||
{
|
||||
@@ -674,6 +713,7 @@ public class AuditEndpointsTests
|
||||
Assert.Equal(executionId, filter.ExecutionId);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that an unparseable execution id filter is silently dropped.</summary>
|
||||
[Fact]
|
||||
public void ParseFilter_UnparseableExecutionId_IsDroppedSilently()
|
||||
{
|
||||
@@ -690,6 +730,7 @@ public class AuditEndpointsTests
|
||||
Assert.Null(filter.ExecutionId);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a parent execution id filter parses into a single GUID value.</summary>
|
||||
[Fact]
|
||||
public void ParseFilter_ParentExecutionId_ParsesIntoSingleValueGuid()
|
||||
{
|
||||
@@ -706,6 +747,7 @@ public class AuditEndpointsTests
|
||||
Assert.Equal(parentExecutionId, filter.ParentExecutionId);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that an unparseable parent execution id filter is silently dropped.</summary>
|
||||
[Fact]
|
||||
public void ParseFilter_UnparseableParentExecutionId_IsDroppedSilently()
|
||||
{
|
||||
@@ -722,6 +764,8 @@ public class AuditEndpointsTests
|
||||
Assert.Null(filter.ParentExecutionId);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that repeated channel parameters reach the repository as a multi-value filter.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Query_RepeatedChannelParams_ReachRepositoryAsMultiValueFilter()
|
||||
{
|
||||
@@ -744,6 +788,7 @@ public class AuditEndpointsTests
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a half-supplied paging cursor is dropped.</summary>
|
||||
[Fact]
|
||||
public void ParsePaging_HalfSuppliedCursor_IsDropped()
|
||||
{
|
||||
@@ -762,9 +807,10 @@ public class AuditEndpointsTests
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// ApplySiteScope (Management-019)
|
||||
// ApplySiteScope
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>Verifies that a system-wide user's filter is returned unchanged by site scoping.</summary>
|
||||
[Fact]
|
||||
public void ApplySiteScope_SystemWideUser_ReturnsFilterUnchanged()
|
||||
{
|
||||
@@ -781,6 +827,7 @@ public class AuditEndpointsTests
|
||||
Assert.Same(filter, result);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that an empty caller filter is restricted to the permitted sites for a scoped user.</summary>
|
||||
[Fact]
|
||||
public void ApplySiteScope_ScopedUser_EmptyCallerFilter_RestrictedToPermittedSet()
|
||||
{
|
||||
@@ -798,6 +845,7 @@ public class AuditEndpointsTests
|
||||
Assert.Equal(new[] { "plant-a", "plant-b" }, result.SourceSiteIds!.OrderBy(s => s).ToArray());
|
||||
}
|
||||
|
||||
/// <summary>Verifies that an explicit in-scope filter is kept unchanged for a scoped user.</summary>
|
||||
[Fact]
|
||||
public void ApplySiteScope_ScopedUser_ExplicitInScopeFilter_KeptVerbatim()
|
||||
{
|
||||
@@ -811,6 +859,7 @@ public class AuditEndpointsTests
|
||||
Assert.Equal(new[] { "plant-a" }, result!.SourceSiteIds);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that an explicit out-of-scope filter yields null for a scoped user.</summary>
|
||||
[Fact]
|
||||
public void ApplySiteScope_ScopedUser_ExplicitOutOfScopeFilter_ReturnsNull()
|
||||
{
|
||||
@@ -825,6 +874,7 @@ public class AuditEndpointsTests
|
||||
Assert.Null(result);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a mixed filter is reduced to its in-scope sites for a scoped user.</summary>
|
||||
[Fact]
|
||||
public void ApplySiteScope_ScopedUser_MixedInAndOutOfScopeFilter_IntersectedToInScopeOnly()
|
||||
{
|
||||
@@ -907,6 +957,8 @@ public class AuditEndpointsTests
|
||||
FirstOccurredAtUtc: new DateTime(2026, 5, 20, 10, 0, 0, DateTimeKind.Utc),
|
||||
LastOccurredAtUtc: new DateTime(2026, 5, 20, 10, 1, 0, DateTimeKind.Utc));
|
||||
|
||||
/// <summary>Verifies that a scoped viewer's permitted sites are passed to the repository by the tree endpoint.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Tree_ScopedViewer_ThreadsPermittedSitesToRepository()
|
||||
{
|
||||
@@ -921,6 +973,8 @@ public class AuditEndpointsTests
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the tree endpoint returns a JSON array for a valid execution id.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Tree_ValidExecutionId_ReturnsJsonArray()
|
||||
{
|
||||
@@ -950,6 +1004,8 @@ public class AuditEndpointsTests
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the tree endpoint returns an empty array when the repository returns nothing.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Tree_RepoReturnsEmpty_ReturnsEmptyArray()
|
||||
{
|
||||
@@ -969,6 +1025,8 @@ public class AuditEndpointsTests
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the tree endpoint returns 400 when the execution id is missing.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Tree_MissingExecutionId_Returns400()
|
||||
{
|
||||
@@ -981,6 +1039,8 @@ public class AuditEndpointsTests
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the tree endpoint returns 400 when the execution id is invalid.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Tree_InvalidExecutionId_Returns400()
|
||||
{
|
||||
@@ -995,6 +1055,8 @@ public class AuditEndpointsTests
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the tree endpoint returns 403 without the operational-audit permission.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Tree_WithoutOperationalAudit_Returns403()
|
||||
{
|
||||
@@ -1007,6 +1069,8 @@ public class AuditEndpointsTests
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the tree endpoint returns 401 without credentials.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Tree_WithoutCredentials_Returns401()
|
||||
{
|
||||
@@ -1019,6 +1083,8 @@ public class AuditEndpointsTests
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the tree endpoint is allowed for a viewer.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Tree_ViewerRole_IsAllowed()
|
||||
{
|
||||
@@ -1032,7 +1098,7 @@ public class AuditEndpointsTests
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// POST /api/audit/backfill-source-node (M5.6 T5)
|
||||
// POST /api/audit/backfill-source-node
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
private static async Task<(HttpClient Client, IAuditLogRepository Repo, IHost Host)> BuildHostWithBackfillAsync(
|
||||
@@ -1100,6 +1166,8 @@ public class AuditEndpointsTests
|
||||
return request;
|
||||
}
|
||||
|
||||
/// <summary>Verifies that an administrator's source-node backfill returns 200 with the updated row count.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task BackfillSourceNode_AdminRole_Returns200WithRowCount()
|
||||
{
|
||||
@@ -1120,6 +1188,8 @@ public class AuditEndpointsTests
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a viewer's source-node backfill returns 403.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task BackfillSourceNode_ViewerRole_Returns403()
|
||||
{
|
||||
@@ -1135,6 +1205,8 @@ public class AuditEndpointsTests
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a source-node backfill without credentials returns 401.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task BackfillSourceNode_NoCredentials_Returns401()
|
||||
{
|
||||
@@ -1150,6 +1222,8 @@ public class AuditEndpointsTests
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a source-node backfill without a before date returns 400.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task BackfillSourceNode_MissingBefore_Returns400()
|
||||
{
|
||||
@@ -1165,6 +1239,8 @@ public class AuditEndpointsTests
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a source-node backfill with an invalid before date returns 400.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task BackfillSourceNode_InvalidBeforeDate_Returns400()
|
||||
{
|
||||
@@ -1179,6 +1255,8 @@ public class AuditEndpointsTests
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a custom sentinel and batch size are passed to the repository.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task BackfillSourceNode_CustomSentinelAndBatch_PassedToRepo()
|
||||
{
|
||||
@@ -1200,6 +1278,8 @@ public class AuditEndpointsTests
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the sentinel defaults to unknown when it is omitted.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task BackfillSourceNode_DefaultSentinel_IsUnknown_WhenOmitted()
|
||||
{
|
||||
|
||||
@@ -2,6 +2,7 @@ namespace ZB.MOM.WW.ScadaBridge.ManagementService.Tests;
|
||||
|
||||
public class ConfigSecretScrubberTests
|
||||
{
|
||||
/// <summary>Verifies that password fields are redacted at any nesting depth.</summary>
|
||||
[Fact]
|
||||
public void Scrub_RedactsPasswordFields_AtAnyDepth()
|
||||
{
|
||||
@@ -14,6 +15,7 @@ public class ConfigSecretScrubberTests
|
||||
Assert.Contains("opc.tcp://x", scrubbed); // non-secrets untouched
|
||||
}
|
||||
|
||||
/// <summary>Verifies that scrubbing a document with no secrets reports that nothing was redacted.</summary>
|
||||
[Fact]
|
||||
public void Scrub_NoSecrets_ReturnsFalseFlag()
|
||||
{
|
||||
@@ -21,6 +23,8 @@ public class ConfigSecretScrubberTests
|
||||
Assert.False(had);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that null, empty or malformed input passes through unchanged.</summary>
|
||||
/// <param name="input">The raw configuration JSON to scrub.</param>
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
@@ -32,6 +36,7 @@ public class ConfigSecretScrubberTests
|
||||
Assert.False(had);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that merging sentinels restores stored secrets while keeping the operator's other edits.</summary>
|
||||
[Fact]
|
||||
public void MergeSentinels_RestoresStoredSecrets_KeepsEdits()
|
||||
{
|
||||
@@ -60,6 +65,7 @@ public class ConfigSecretScrubberTests
|
||||
[typeof(ZB.MOM.WW.ScadaBridge.Commons.Types.DataConnections.OpcUaDeadbandConfig)] = [],
|
||||
};
|
||||
|
||||
/// <summary>Verifies that every string property on the config types is either scrubbed or deliberately classified as non-secret.</summary>
|
||||
[Fact]
|
||||
public void EveryConfigStringProperty_IsScrubbedOrDeliberatelyNonSecret()
|
||||
{
|
||||
@@ -79,6 +85,8 @@ public class ConfigSecretScrubberTests
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Verifies that each known secret property name matches the scrubber's fragment list.</summary>
|
||||
/// <param name="name">The secret property name.</param>
|
||||
[Theory]
|
||||
[InlineData("Password")]
|
||||
[InlineData("CertificatePassword")]
|
||||
|
||||
+4
@@ -4,6 +4,9 @@ namespace Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
internal static class ConfigurationTransactionTestExtensions
|
||||
{
|
||||
/// <summary>Registers a scope-per-call configuration command transaction and the area service for tests.</summary>
|
||||
/// <param name="services">The service collection to add the registrations to.</param>
|
||||
/// <returns>The same service collection, for chaining.</returns>
|
||||
public static ServiceCollection AddTestConfigurationTransaction(this ServiceCollection services)
|
||||
{
|
||||
services.AddSingleton<IConfigurationCommandTransaction, RepositoryTransaction>();
|
||||
@@ -12,6 +15,7 @@ internal static class ConfigurationTransactionTestExtensions
|
||||
}
|
||||
private sealed class RepositoryTransaction(IServiceScopeFactory scopes) : IConfigurationCommandTransaction
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task<object?> ExecuteAsync(Func<IServiceProvider, Task<object?>> operation)
|
||||
{
|
||||
using var scope = scopes.CreateScope();
|
||||
|
||||
+26
-2
@@ -16,7 +16,7 @@ using ZB.MOM.WW.ScadaBridge.Communication.Grpc;
|
||||
namespace ZB.MOM.WW.ScadaBridge.ManagementService.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Regression tests for arch-review 2026-08-19 P6-6 on the hub side. <c>SubscribeInstance</c>
|
||||
/// Regression tests for the hub-side subscribe race. <c>SubscribeInstance</c>
|
||||
/// awaits <c>StartStreamAsync</c> — up to 30s — and only then stores the session id on the
|
||||
/// connection. A client that disconnects inside that window ran <c>OnDisconnectedAsync</c>
|
||||
/// while there was still no session id to release, so the session stayed registered in the
|
||||
@@ -33,6 +33,7 @@ public class DebugStreamHubSubscribeRaceTests
|
||||
private readonly TaskCompletionSource _entered = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
private readonly List<string> _stopped = new();
|
||||
|
||||
/// <summary>Creates the gated debug stream service with an unreleased start gate.</summary>
|
||||
public GatedDebugStreamService()
|
||||
: base(
|
||||
new CommunicationService(Options.Create(new CommunicationOptions()), NullLogger<CommunicationService>.Instance),
|
||||
@@ -42,6 +43,7 @@ public class DebugStreamHubSubscribeRaceTests
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>The fixed session id returned by every started stream.</summary>
|
||||
public string SessionId => "session-under-test";
|
||||
|
||||
/// <summary>Completes once <see cref="StartStreamAsync"/> has been entered.</summary>
|
||||
@@ -53,8 +55,10 @@ public class DebugStreamHubSubscribeRaceTests
|
||||
get { lock (_stopped) { return _stopped.ToList(); } }
|
||||
}
|
||||
|
||||
/// <summary>Releases the start gate so a pending <c>StartStreamAsync</c> call completes.</summary>
|
||||
public void ReleaseStart() => _gate.TrySetResult();
|
||||
|
||||
/// <inheritdoc />
|
||||
public override async Task<DebugStreamSession> StartStreamAsync(
|
||||
int instanceId, Action<object> onEvent, Action onTerminated, CancellationToken ct = default)
|
||||
{
|
||||
@@ -69,6 +73,7 @@ public class DebugStreamHubSubscribeRaceTests
|
||||
DateTimeOffset.UtcNow));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override void StopStream(string sessionId)
|
||||
{
|
||||
lock (_stopped) { _stopped.Add(sessionId); }
|
||||
@@ -80,18 +85,31 @@ public class DebugStreamHubSubscribeRaceTests
|
||||
private readonly FeatureCollection _features = new();
|
||||
private readonly CancellationTokenSource _aborted = new();
|
||||
|
||||
/// <summary>Creates a hub caller context over the supplied HTTP context.</summary>
|
||||
/// <param name="http">The HTTP context exposed through the hub context.</param>
|
||||
public TestHubCallerContext(HttpContext http) =>
|
||||
_features.Set<IHttpContextFeature>(new TestHttpContextFeature { HttpContext = http });
|
||||
|
||||
/// <inheritdoc />
|
||||
public override string ConnectionId => "test-conn";
|
||||
/// <inheritdoc />
|
||||
public override string? UserIdentifier => null;
|
||||
/// <inheritdoc />
|
||||
public override System.Security.Claims.ClaimsPrincipal? User => null;
|
||||
/// <inheritdoc />
|
||||
public override IDictionary<object, object?> Items { get; } = new Dictionary<object, object?>();
|
||||
/// <inheritdoc />
|
||||
public override IFeatureCollection Features => _features;
|
||||
/// <inheritdoc />
|
||||
public override CancellationToken ConnectionAborted => _aborted.Token;
|
||||
/// <inheritdoc />
|
||||
public override void Abort() => _aborted.Cancel();
|
||||
|
||||
private sealed class TestHttpContextFeature : IHttpContextFeature { public HttpContext? HttpContext { get; set; } }
|
||||
private sealed class TestHttpContextFeature : IHttpContextFeature
|
||||
{
|
||||
/// <summary>Gets or sets the HTTP context exposed by the feature.</summary>
|
||||
public HttpContext? HttpContext { get; set; }
|
||||
}
|
||||
}
|
||||
|
||||
private const string SessionIdKey = "DebugStreamSessionId";
|
||||
@@ -119,6 +137,8 @@ public class DebugStreamHubSubscribeRaceTests
|
||||
return (hub, context, stream);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that subscribe instance, client disconnects mid start, stops the just created session.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task SubscribeInstance_ClientDisconnectsMidStart_StopsTheJustCreatedSession()
|
||||
{
|
||||
@@ -139,6 +159,8 @@ public class DebugStreamHubSubscribeRaceTests
|
||||
Assert.False(context.Items.ContainsKey(SessionIdKey));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that subscribe instance, connection aborted mid start, stops the just created session.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task SubscribeInstance_ConnectionAbortedMidStart_StopsTheJustCreatedSession()
|
||||
{
|
||||
@@ -157,6 +179,8 @@ public class DebugStreamHubSubscribeRaceTests
|
||||
Assert.False(context.Items.ContainsKey(SessionIdKey));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that subscribe instance, then disconnect, stops the session exactly once.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task SubscribeInstance_ThenDisconnect_StopsTheSessionExactlyOnce()
|
||||
{
|
||||
|
||||
@@ -18,11 +18,11 @@ using ZB.MOM.WW.ScadaBridge.Security.Auth;
|
||||
namespace ZB.MOM.WW.ScadaBridge.ManagementService.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for <see cref="DebugStreamHub"/> per-instance site-scope authorization
|
||||
/// (finding ManagementService-003).
|
||||
/// Tests for <see cref="DebugStreamHub"/> per-instance site-scope authorization.
|
||||
/// </summary>
|
||||
public class DebugStreamHubTests
|
||||
{
|
||||
/// <summary>Verifies that a site-scoped user is allowed to access an instance within their site scope.</summary>
|
||||
[Fact]
|
||||
public void IsInstanceAccessAllowed_SiteScopedUser_InScopeInstance_Allowed()
|
||||
{
|
||||
@@ -34,6 +34,7 @@ public class DebugStreamHubTests
|
||||
Assert.True(allowed);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a site-scoped user is denied access to an instance outside their site scope.</summary>
|
||||
[Fact]
|
||||
public void IsInstanceAccessAllowed_SiteScopedUser_OutOfScopeInstance_Denied()
|
||||
{
|
||||
@@ -45,6 +46,7 @@ public class DebugStreamHubTests
|
||||
Assert.False(allowed);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a system-wide deployment user is allowed to access instances at any site.</summary>
|
||||
[Fact]
|
||||
public void IsInstanceAccessAllowed_SystemWideDeployment_AnySiteAllowed()
|
||||
{
|
||||
@@ -57,6 +59,7 @@ public class DebugStreamHubTests
|
||||
Assert.True(allowed);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the Administrator role bypasses site scoping.</summary>
|
||||
[Fact]
|
||||
public void IsInstanceAccessAllowed_AdminRole_BypassesSiteScope()
|
||||
{
|
||||
@@ -68,6 +71,7 @@ public class DebugStreamHubTests
|
||||
Assert.True(allowed);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the Administrator role check is case-insensitive.</summary>
|
||||
[Fact]
|
||||
public void IsInstanceAccessAllowed_AdminRoleCheck_IsCaseInsensitive()
|
||||
{
|
||||
@@ -85,6 +89,7 @@ public class DebugStreamHubTests
|
||||
private readonly ServiceProvider _provider;
|
||||
private readonly LoginThrottle _throttle;
|
||||
|
||||
/// <summary>Initializes the test with a substituted LDAP service, a login throttle and a service provider.</summary>
|
||||
public DebugStreamHubTests()
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
@@ -120,6 +125,8 @@ public class DebugStreamHubTests
|
||||
return ctx;
|
||||
}
|
||||
|
||||
/// <summary>Verifies that failed binds on connect feed the shared login throttle.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task OnConnectedAsync_FailedBinds_FeedTheSharedLoginThrottle()
|
||||
{
|
||||
@@ -131,6 +138,8 @@ public class DebugStreamHubTests
|
||||
Assert.True(_throttle.IsLockedOut("alice", "10.0.0.1"));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a locked-out key aborts the connection without contacting LDAP.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task OnConnectedAsync_LockedOutKey_AbortsWithoutContactingLdap()
|
||||
{
|
||||
@@ -144,17 +153,31 @@ public class DebugStreamHubTests
|
||||
private sealed class TestHubCallerContext : HubCallerContext
|
||||
{
|
||||
private readonly FeatureCollection _features = new();
|
||||
/// <summary>Initializes the context so that its HTTP context feature returns the given request context.</summary>
|
||||
/// <param name="http">The HTTP context exposed through the context feature.</param>
|
||||
public TestHubCallerContext(HttpContext http) =>
|
||||
_features.Set<IHttpContextFeature>(new TestHttpContextFeature { HttpContext = http });
|
||||
/// <summary>Gets a value indicating whether <see cref="Abort"/> has been called.</summary>
|
||||
public bool Aborted { get; private set; }
|
||||
/// <inheritdoc />
|
||||
public override string ConnectionId => "test-conn";
|
||||
/// <inheritdoc />
|
||||
public override string? UserIdentifier => null;
|
||||
/// <inheritdoc />
|
||||
public override System.Security.Claims.ClaimsPrincipal? User => null;
|
||||
/// <inheritdoc />
|
||||
public override IDictionary<object, object?> Items { get; } = new Dictionary<object, object?>();
|
||||
/// <inheritdoc />
|
||||
public override IFeatureCollection Features => _features;
|
||||
/// <inheritdoc />
|
||||
public override CancellationToken ConnectionAborted => CancellationToken.None;
|
||||
/// <inheritdoc />
|
||||
public override void Abort() => Aborted = true;
|
||||
|
||||
private sealed class TestHttpContextFeature : IHttpContextFeature { public HttpContext? HttpContext { get; set; } }
|
||||
private sealed class TestHttpContextFeature : IHttpContextFeature
|
||||
{
|
||||
/// <summary>Gets or sets the HTTP context exposed by the feature.</summary>
|
||||
public HttpContext? HttpContext { get; set; }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,6 +42,7 @@ public class DeploymentConfigEndpointsTests
|
||||
|
||||
private static int? StatusOf(IResult result) => (result as IStatusCodeHttpResult)?.StatusCode;
|
||||
|
||||
/// <summary>Verifies that a null row resolves to not found.</summary>
|
||||
[Fact]
|
||||
public void Resolve_NullRow_ReturnsNotFound()
|
||||
{
|
||||
@@ -51,6 +52,7 @@ public class DeploymentConfigEndpointsTests
|
||||
Assert.Equal(StatusCodes.Status404NotFound, StatusOf(result));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that an expired row with the correct token resolves to not found.</summary>
|
||||
[Fact]
|
||||
public void Resolve_ExpiredRow_WithCorrectToken_ReturnsNotFound()
|
||||
{
|
||||
@@ -63,6 +65,7 @@ public class DeploymentConfigEndpointsTests
|
||||
Assert.Equal(StatusCodes.Status404NotFound, StatusOf(result));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a row that expired in the past with the correct token resolves to not found.</summary>
|
||||
[Fact]
|
||||
public void Resolve_ExpiredRowInThePast_WithCorrectToken_ReturnsNotFound()
|
||||
{
|
||||
@@ -74,6 +77,7 @@ public class DeploymentConfigEndpointsTests
|
||||
Assert.Equal(StatusCodes.Status404NotFound, StatusOf(result));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a valid row with the wrong token resolves to unauthorized.</summary>
|
||||
[Fact]
|
||||
public void Resolve_ValidRow_WrongToken_ReturnsUnauthorized()
|
||||
{
|
||||
@@ -85,6 +89,7 @@ public class DeploymentConfigEndpointsTests
|
||||
Assert.Equal(StatusCodes.Status401Unauthorized, StatusOf(result));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a valid row with an empty token resolves to unauthorized.</summary>
|
||||
[Fact]
|
||||
public void Resolve_ValidRow_EmptyToken_ReturnsUnauthorized()
|
||||
{
|
||||
@@ -96,6 +101,7 @@ public class DeploymentConfigEndpointsTests
|
||||
Assert.Equal(StatusCodes.Status401Unauthorized, StatusOf(result));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a valid row with a null token resolves to unauthorized.</summary>
|
||||
[Fact]
|
||||
public void Resolve_ValidRow_NullToken_ReturnsUnauthorized()
|
||||
{
|
||||
@@ -107,6 +113,7 @@ public class DeploymentConfigEndpointsTests
|
||||
Assert.Equal(StatusCodes.Status401Unauthorized, StatusOf(result));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a valid, unexpired row with the correct token resolves to the configuration JSON.</summary>
|
||||
[Fact]
|
||||
public void Resolve_ValidRow_CorrectToken_NotExpired_ReturnsConfigJson()
|
||||
{
|
||||
|
||||
@@ -45,8 +45,10 @@ public class DispatchCoverageTests : TestKit, IDisposable
|
||||
&& !t.IsAbstract
|
||||
&& ManagementCommandRegistry.Resolve(t.Name[..^"Command".Length]) == t);
|
||||
|
||||
/// <inheritdoc />
|
||||
void IDisposable.Dispose() => Shutdown();
|
||||
|
||||
/// <summary>Verifies that every registered command reaches a dispatch arm.</summary>
|
||||
[Fact]
|
||||
public void EveryRegisteredCommand_ReachesADispatchArm()
|
||||
{
|
||||
@@ -117,6 +119,7 @@ public class DispatchCoverageTests : TestKit, IDisposable
|
||||
return Sys.ActorOf(Props.Create(() => new ManagementActor(rootProvider, logger)));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the probe detects a command without a dispatch arm.</summary>
|
||||
[Fact]
|
||||
public void Probe_DetectsACommandWithoutADispatchArm()
|
||||
{
|
||||
@@ -135,7 +138,9 @@ public class DispatchCoverageTests : TestKit, IDisposable
|
||||
// commands before DispatchCommand had a chance to select (or miss) their arm.
|
||||
private sealed class PassthroughTransaction(IServiceProvider provider) : IConfigurationCommandTransaction
|
||||
{
|
||||
/// <summary>The number of operations dispatched through this transaction.</summary>
|
||||
public int DispatchCount { get; private set; }
|
||||
/// <inheritdoc />
|
||||
public Task<object?> ExecuteAsync(Func<IServiceProvider, Task<object?>> operation)
|
||||
{
|
||||
DispatchCount++;
|
||||
@@ -147,6 +152,7 @@ public class DispatchCoverageTests : TestKit, IDisposable
|
||||
/// "no dispatch arm" <see cref="NotSupportedException"/> signature.</summary>
|
||||
private sealed class SentinelException : Exception
|
||||
{
|
||||
/// <summary>Initializes the exception with a fixed sentinel message.</summary>
|
||||
public SentinelException() : base("sentinel: scoped provider touched") { }
|
||||
}
|
||||
|
||||
@@ -157,12 +163,24 @@ public class DispatchCoverageTests : TestKit, IDisposable
|
||||
/// </summary>
|
||||
private sealed class RecordingLogger<T> : ILogger<T>
|
||||
{
|
||||
/// <summary>The exception logged for each correlation id.</summary>
|
||||
public ConcurrentDictionary<string, Exception> Captured { get; } = new();
|
||||
|
||||
/// <inheritdoc />
|
||||
IDisposable? ILogger.BeginScope<TState>(TState state) => null;
|
||||
|
||||
/// <summary>Reports every log level as enabled.</summary>
|
||||
/// <param name="logLevel">The log level being checked.</param>
|
||||
/// <returns>Always <see langword="true"/>.</returns>
|
||||
public bool IsEnabled(LogLevel logLevel) => true;
|
||||
|
||||
/// <summary>Captures the logged exception under its correlation id.</summary>
|
||||
/// <typeparam name="TState">The type of the log state.</typeparam>
|
||||
/// <param name="logLevel">The log level.</param>
|
||||
/// <param name="eventId">The event id.</param>
|
||||
/// <param name="state">The log state, searched for a correlation id.</param>
|
||||
/// <param name="exception">The exception being logged, if any.</param>
|
||||
/// <param name="formatter">The message formatter.</param>
|
||||
public void Log<TState>(LogLevel logLevel, EventId eventId, TState state,
|
||||
Exception? exception, Func<TState, Exception?, string> formatter)
|
||||
{
|
||||
|
||||
@@ -39,6 +39,7 @@ public class FlowDebugRunnerTests
|
||||
private readonly InboundScriptExecutor _executor = new(NullLogger<InboundScriptExecutor>.Instance, Substitute.For<IServiceProvider>());
|
||||
private DateTimeOffset _clock = new(2026, 9, 5, 12, 0, 0, TimeSpan.Zero);
|
||||
|
||||
/// <summary>Initializes a new instance of the <see cref="FlowDebugRunnerTests"/> class with an active-node gate and a fake audit writer.</summary>
|
||||
public FlowDebugRunnerTests()
|
||||
{
|
||||
_gate.IsActiveNode.Returns(true);
|
||||
@@ -60,6 +61,9 @@ public class FlowDebugRunnerTests
|
||||
|
||||
private static JsonElement Json(string s) => JsonDocument.Parse(s).RootElement.Clone();
|
||||
|
||||
/// <summary>Verifies that a late completion behind the log cursor is still found despite site clock skew.</summary>
|
||||
/// <param name="skewSeconds">The offset of the site clock relative to central, in seconds.</param>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Theory]
|
||||
[InlineData(0)]
|
||||
[InlineData(-3600)]
|
||||
@@ -108,6 +112,8 @@ public class FlowDebugRunnerTests
|
||||
Assert.Equal(3, result.Events.Count);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a value-change run pages through ties and queries older descendants beyond 64 execution ids.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task RunValueChange_PagesTiesAndQueriesOlderDescendantsBeyond64Ids()
|
||||
{
|
||||
@@ -159,6 +165,8 @@ public class FlowDebugRunnerTests
|
||||
Assert.All(grandchildren.Values, id => Assert.Contains(id, queried));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that replay uses the chain anchor even when the child clock precedes the root.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Replay_UsesChainAnchorEvenWhenChildClockPrecedesRoot()
|
||||
{
|
||||
@@ -173,6 +181,8 @@ public class FlowDebugRunnerTests
|
||||
Assert.Equal(root, result.RootExecutionId);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the total deadline of a value-change run includes the initial write.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task RunValueChange_TotalDeadlineIncludesInitialWrite()
|
||||
{
|
||||
@@ -191,6 +201,8 @@ public class FlowDebugRunnerTests
|
||||
Assert.Empty(puller.ReceivedCalls());
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the total deadline of a value-change run includes the audit write.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task RunValueChange_TotalDeadlineIncludesAuditWrite()
|
||||
{
|
||||
@@ -207,6 +219,8 @@ public class FlowDebugRunnerTests
|
||||
Assert.Empty(puller.ReceivedCalls());
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a value-change run caps the returned events and reports truncation.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task RunValueChange_CapsReturnedEventsAndReportsTruncation()
|
||||
{
|
||||
@@ -220,6 +234,8 @@ public class FlowDebugRunnerTests
|
||||
Assert.InRange(result.Events.Count, 1, FlowTraceCollector.DefaultMaxEvents);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that running a method on a standby node is refused with the standby code before the method is touched.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task RunMethod_OnStandby_RefusesWithStandbyCode_BeforeTouchingTheMethod()
|
||||
{
|
||||
@@ -231,6 +247,8 @@ public class FlowDebugRunnerTests
|
||||
await _apiRepo.DidNotReceiveWithAnyArgs().GetApiMethodByIdAsync(default, default);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that invalid method parameters fail before any route call is made.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task RunMethod_InvalidParameters_FailsBeforeAnyRouteCall()
|
||||
{
|
||||
@@ -248,6 +266,8 @@ public class FlowDebugRunnerTests
|
||||
Assert.Empty(_central);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a successful method run returns the inbound row followed by the ordered sink events and writes the inbound row.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task RunMethod_HappyPath_ReturnsInboundRow_ThenSinkEvents_Ordered_AndWritesTheInboundRow()
|
||||
{
|
||||
@@ -287,6 +307,8 @@ public class FlowDebugRunnerTests
|
||||
Assert.Contains(_central, e => e.AsRow().Kind == AuditKind.RouteCall);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a site clock behind central still returns the sink events in emission order.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task RunMethod_SiteClockBehindCentral_StillReturnsTheSinkEventsInEmissionOrder()
|
||||
{
|
||||
@@ -312,6 +334,8 @@ public class FlowDebugRunnerTests
|
||||
Assert.Same(siteRun, result.Events[2]);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the inbound row carries the error detail when the method script throws.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task RunMethod_ScriptThrows_InboundRowCarriesErrorDetail()
|
||||
{
|
||||
@@ -336,6 +360,8 @@ public class FlowDebugRunnerTests
|
||||
Assert.StartsWith("System.InvalidOperationException: boom", returnedInbound.ErrorDetail);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that running an unknown method reports not found.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task RunMethod_UnknownMethod_IsNotFound()
|
||||
{
|
||||
@@ -359,6 +385,8 @@ public class FlowDebugRunnerTests
|
||||
return (templates, sites, puller);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a traced value-change write is followed by polling until the spawned run settles.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task RunValueChange_TracedWrite_ThenPollsUntilSpawnedRunSettles()
|
||||
{
|
||||
@@ -396,6 +424,8 @@ public class FlowDebugRunnerTests
|
||||
Assert.True(calls >= 3, $"expected settle polls, got {calls}");
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a value-change run reports the no-trigger code when no run is spawned within the window.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task RunValueChange_NoSpawnWithinWindow_ReportsNoTriggerCode()
|
||||
{
|
||||
@@ -410,6 +440,8 @@ public class FlowDebugRunnerTests
|
||||
Assert.Single(result.Events); // just the synthesized write row
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a value-change run asks the site for its full page of rows rather than the old 500-row cap.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task RunValueChange_AsksTheSiteForItsFullPage_NotTheOldFiveHundredRowCap()
|
||||
{
|
||||
@@ -431,6 +463,8 @@ public class FlowDebugRunnerTests
|
||||
Assert.Equal(2000, FlowDebugRunner.PullTraceEventsMax);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a value-change run against an out-of-scope site is refused.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task RunValueChange_OutOfScopeSite_IsRefused()
|
||||
{
|
||||
@@ -441,6 +475,8 @@ public class FlowDebugRunnerTests
|
||||
await _router.DidNotReceiveWithAnyArgs().RouteToSetAttributesAsync(default!, default!, default);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that replay threads the site scope through and retains a truncated root that has no rows.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Replay_ThreadsScopeAndRetainsTruncatedRootWithoutRows()
|
||||
{
|
||||
@@ -453,6 +489,8 @@ public class FlowDebugRunnerTests
|
||||
Assert.True(result.Completed); Assert.True(result.Truncated); Assert.Equal(root, result.RootExecutionId);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that replay returns the repository rows with the root taken from the first row.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Replay_ReturnsRepositoryRows_WithRootFromTheFirstRow()
|
||||
{
|
||||
@@ -504,6 +542,8 @@ public class FlowDebugRunnerTests
|
||||
return sites;
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a site-scoped deployer routing to another site is unauthorized and the inbound row is still written.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task RunMethod_ScopedDeployer_RouteToOtherSite_IsUnauthorized_AndInboundRowIsWritten()
|
||||
{
|
||||
@@ -519,6 +559,8 @@ public class FlowDebugRunnerTests
|
||||
Assert.Contains(_central, e => e.AsRow().Kind == AuditKind.InboundRequest && e.Actor == "ops");
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a site-scoped deployer routing within its scope runs the method.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task RunMethod_ScopedDeployer_RouteWithinScope_Runs()
|
||||
{
|
||||
@@ -533,6 +575,8 @@ public class FlowDebugRunnerTests
|
||||
await _router.ReceivedWithAnyArgs(1).RouteToCallAsync(default!, default!, default);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that an administrator is never restricted by site scope.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task RunMethod_Administrator_IsNeverScoped()
|
||||
{
|
||||
@@ -546,6 +590,8 @@ public class FlowDebugRunnerTests
|
||||
await _router.ReceivedWithAnyArgs(1).RouteToCallAsync(default!, default!, default);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a user without site scoping is never restricted by site scope.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task RunMethod_UnscopedUser_IsNeverScoped()
|
||||
{
|
||||
@@ -559,6 +605,7 @@ public class FlowDebugRunnerTests
|
||||
await _router.ReceivedWithAnyArgs(1).RouteToCallAsync(default!, default!, default);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the three run commands are restricted to the Deployer role.</summary>
|
||||
[Fact]
|
||||
public void RoleGate_TheThreeRunCommands_AreDeployerOnly()
|
||||
{
|
||||
@@ -572,6 +619,7 @@ public class FlowDebugRunnerTests
|
||||
Assert.Equal(new[] { "Deployer" }, ManagementActor.GetRequiredRoles(cmd));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that replay also accepts the operational audit roles.</summary>
|
||||
[Fact]
|
||||
public void RoleGate_Replay_AlsoAcceptsTheOperationalAuditRoles()
|
||||
{
|
||||
@@ -612,6 +660,8 @@ public class FlowDebugRunnerTests
|
||||
AuditChannel.ScriptTrace, AuditKind.ScriptLog, AuditStatus.Delivered,
|
||||
executionId: Guid.NewGuid(), target: "work");
|
||||
|
||||
/// <summary>Verifies that sink events are redacted when a redactor is registered.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task RunMethod_SinkEvents_AreRedacted_WhenARedactorIsRegistered()
|
||||
{
|
||||
@@ -632,6 +682,8 @@ public class FlowDebugRunnerTests
|
||||
Assert.NotEqual(written.DetailsJson, result.Events[0].DetailsJson); // the raw row still reaches the writer
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the synthesized root row of a value-change run is redacted when a redactor is registered.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task RunValueChange_SynthesizedRootRow_IsRedacted_WhenARedactorIsRegistered()
|
||||
{
|
||||
@@ -648,6 +700,8 @@ public class FlowDebugRunnerTests
|
||||
Assert.NotEqual(written.DetailsJson, root.DetailsJson);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a failed value-change write returns the redacted root row.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task RunValueChange_FailedWrite_ReturnsTheRedactedRootRow()
|
||||
{
|
||||
@@ -667,6 +721,8 @@ public class FlowDebugRunnerTests
|
||||
Assert.NotEqual(written.DetailsJson, root.DetailsJson);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a method run over the event cap reports truncation.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task RunMethod_OverCap_ReportsTruncated()
|
||||
{
|
||||
@@ -680,6 +736,8 @@ public class FlowDebugRunnerTests
|
||||
Assert.False(result.LogsTruncated); // these are full-priority step rows, not log lines
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a method run with no log rows does not report truncated logs.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task RunMethod_NoLogRows_LogsTruncatedIsFalse()
|
||||
{
|
||||
@@ -693,6 +751,8 @@ public class FlowDebugRunnerTests
|
||||
Assert.False(result.Truncated);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that exceeding the low-priority reserve reports truncated logs but not truncated events.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task RunMethod_OverLowPriorityReserve_ReportsLogsTruncated_ButNotTruncated()
|
||||
{
|
||||
@@ -736,6 +796,8 @@ public class FlowDebugRunnerTests
|
||||
private static ResolvedScript WriteScript(string name, string kind, string attribute) =>
|
||||
new() { CanonicalName = name, Code = "return 1;", TriggerType = kind, TriggerConfiguration = $$"""{"attributeName":"{{attribute}}"}""" };
|
||||
|
||||
/// <summary>Verifies that a write-kind script run writes the trigger attribute and then polls for completion.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task RunScript_WriteKind_WritesTheTriggerAttribute_ThenPolls()
|
||||
{
|
||||
@@ -773,6 +835,8 @@ public class FlowDebugRunnerTests
|
||||
await _router.DidNotReceiveWithAnyArgs().RouteToCallAsync(default!, default!, default);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that running a script on a standby node is refused with the standby code before the instance is touched.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task RunScript_OnStandby_RefusesWithStandbyCode_BeforeTouchingTheInstance()
|
||||
{
|
||||
@@ -788,6 +852,8 @@ public class FlowDebugRunnerTests
|
||||
await pipeline.DidNotReceiveWithAnyArgs().FlattenAndValidateAsync(default, default, default, default);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a write-kind script run with an unreadable trigger configuration fails validation.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task RunScript_WriteKind_UnreadableTriggerConfig_FailsValidation()
|
||||
{
|
||||
@@ -806,6 +872,8 @@ public class FlowDebugRunnerTests
|
||||
await _router.DidNotReceiveWithAnyArgs().RouteToSetAttributesAsync(default!, default!, default);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a write-kind script run without a value fails validation before any write.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task RunScript_WriteKind_WithoutValue_FailsValidation_BeforeAnyWrite()
|
||||
{
|
||||
@@ -816,6 +884,8 @@ public class FlowDebugRunnerTests
|
||||
await _router.DidNotReceiveWithAnyArgs().RouteToSetAttributesAsync(default!, default!, default);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a run of a sibling script does not count as completion of a write-kind script run.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task RunScript_WriteKind_SiblingScriptRun_DoesNotCountAsCompletion()
|
||||
{
|
||||
@@ -838,6 +908,8 @@ public class FlowDebugRunnerTests
|
||||
Assert.Equal("OtherScript", result.Events[1].Target);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a call-kind script run routes a traced call and returns the root route-call row followed by the site rows.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task RunScript_CallKind_RoutesATracedCall_AndReturnsRootRouteCallRow_ThenSiteRows()
|
||||
{
|
||||
@@ -875,6 +947,8 @@ public class FlowDebugRunnerTests
|
||||
Assert.Single(_central); // the root row was written centrally
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a call-kind script run with invalid parameters fails before routing.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task RunScript_CallKind_InvalidParameters_FailsBeforeRouting()
|
||||
{
|
||||
@@ -886,6 +960,8 @@ public class FlowDebugRunnerTests
|
||||
Assert.Empty(_central);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a site failure in a call-kind script run reports call failed with the root row.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task RunScript_CallKind_SiteFailure_ReportsCallFailed_WithTheRootRow()
|
||||
{
|
||||
@@ -903,6 +979,8 @@ public class FlowDebugRunnerTests
|
||||
Assert.Equal(AuditStatus.Failed, result.Events[0].AsRow().Status);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that running an unknown script reports not found.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task RunScript_UnknownScript_IsNotFound()
|
||||
{
|
||||
@@ -913,6 +991,8 @@ public class FlowDebugRunnerTests
|
||||
Assert.Contains("Nope", ex.Message);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that running a script on an out-of-scope site is refused before flattening.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task RunScript_OutOfScopeSite_IsRefused_BeforeFlattening()
|
||||
{
|
||||
@@ -923,6 +1003,8 @@ public class FlowDebugRunnerTests
|
||||
await pipeline.DidNotReceiveWithAnyArgs().FlattenAndValidateAsync(default, default, default, default);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that an unserializable return value in a call-kind script run still writes the root row.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task RunScript_CallKind_UnserializableReturnValue_StillWritesTheRootRow()
|
||||
{
|
||||
@@ -945,6 +1027,8 @@ public class FlowDebugRunnerTests
|
||||
Assert.Equal(AuditKind.RouteCall, written.AsRow().Kind);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a transport fault in a call-kind script run still writes the root row as call failed.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task RunScript_CallKind_TransportFault_StillWritesTheRootRow_AsCallFailed()
|
||||
{
|
||||
@@ -968,6 +1052,8 @@ public class FlowDebugRunnerTests
|
||||
Assert.Equal(AuditKind.RouteCall, written.AsRow().Kind);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that an ask timeout in a call-kind script run reports the timeout code.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task RunScript_CallKind_AskTimeout_ReportsTimeoutCode()
|
||||
{
|
||||
@@ -995,6 +1081,8 @@ public class FlowDebugRunnerTests
|
||||
throw new InvalidOperationException("unreachable");
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a timeout in a call-kind script run reports the timeout code.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task RunScript_CallKind_Timeout_ReportsTimeoutCode()
|
||||
{
|
||||
@@ -1018,6 +1106,8 @@ public class FlowDebugRunnerTests
|
||||
Assert.Equal(AuditStatus.Failed, written.AsRow().Status);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the root row of a call-kind script run is redacted when a redactor is registered.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task RunScript_CallKind_RootRow_IsRedacted_WhenARedactorIsRegistered()
|
||||
{
|
||||
@@ -1038,6 +1128,9 @@ public class FlowDebugRunnerTests
|
||||
/// <summary>Upper-cases <c>DetailsJson</c> so a test can see the filtered copy land.</summary>
|
||||
private sealed class UpperRedactor : IAuditRedactor
|
||||
{
|
||||
/// <summary>Returns a copy of the event with its details upper-cased.</summary>
|
||||
/// <param name="rawEvent">The event to filter.</param>
|
||||
/// <returns>The filtered event.</returns>
|
||||
public AuditEvent Apply(AuditEvent rawEvent)
|
||||
=> rawEvent with { DetailsJson = rawEvent.DetailsJson?.ToUpperInvariant() };
|
||||
}
|
||||
|
||||
@@ -4,6 +4,8 @@ namespace ZB.MOM.WW.ScadaBridge.ManagementService.Tests;
|
||||
|
||||
public class HttpCommandEligibilityTests
|
||||
{
|
||||
/// <summary>Verifies that parsing rejects commands that are internal contracts and not exposed over HTTP.</summary>
|
||||
/// <param name="command">The internal command name to parse.</param>
|
||||
[Theory]
|
||||
[InlineData("ReadTagValues")]
|
||||
[InlineData("ResolveRoles")]
|
||||
|
||||
+4
-2
@@ -4,12 +4,12 @@ using ZB.MOM.WW.ScadaBridge.ManagementService;
|
||||
namespace ZB.MOM.WW.ScadaBridge.ManagementService.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for the <see cref="ManagementActor"/> supervision strategy
|
||||
/// (finding ManagementService-005). The project convention is that long-lived
|
||||
/// Tests for the <see cref="ManagementActor"/> supervision strategy. The project convention is that long-lived
|
||||
/// coordinator-style actors declare an explicit Resume-based strategy.
|
||||
/// </summary>
|
||||
public class ManagementActorSupervisionTests
|
||||
{
|
||||
/// <summary>Verifies that the supervisor strategy is a one-for-one strategy.</summary>
|
||||
[Fact]
|
||||
public void CreateSupervisorStrategy_ReturnsOneForOneStrategy()
|
||||
{
|
||||
@@ -18,6 +18,7 @@ public class ManagementActorSupervisionTests
|
||||
Assert.IsType<OneForOneStrategy>(strategy);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the supervisor strategy resumes on an arbitrary exception.</summary>
|
||||
[Fact]
|
||||
public void CreateSupervisorStrategy_ResumesOnArbitraryException()
|
||||
{
|
||||
@@ -28,6 +29,7 @@ public class ManagementActorSupervisionTests
|
||||
Assert.Equal(Directive.Resume, directive);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the supervisor strategy resumes indefinitely.</summary>
|
||||
[Fact]
|
||||
public void CreateSupervisorStrategy_ResumesIndefinitely()
|
||||
{
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -25,6 +25,7 @@ public class ManagementAuthenticatorTests
|
||||
return new DefaultHttpContext { RequestServices = services.BuildServiceProvider() };
|
||||
}
|
||||
|
||||
/// <summary>Verifies that no user is returned when the disable-login option is off.</summary>
|
||||
[Fact]
|
||||
public void TryDisableLoginUser_WhenDisabled_ReturnsNull()
|
||||
{
|
||||
@@ -33,6 +34,7 @@ public class ManagementAuthenticatorTests
|
||||
Assert.Null(ManagementAuthenticator.TryDisableLoginUser(context));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that no user is returned when no disable-login options are registered.</summary>
|
||||
[Fact]
|
||||
public void TryDisableLoginUser_WhenNoOptionsRegistered_ReturnsNull()
|
||||
{
|
||||
@@ -44,6 +46,7 @@ public class ManagementAuthenticatorTests
|
||||
Assert.Null(ManagementAuthenticator.TryDisableLoginUser(context));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that an enabled disable-login option returns a dev user holding all roles system-wide.</summary>
|
||||
[Fact]
|
||||
public void TryDisableLoginUser_WhenEnabled_ReturnsDevUserWithAllRolesSystemWide()
|
||||
{
|
||||
@@ -58,6 +61,7 @@ public class ManagementAuthenticatorTests
|
||||
Assert.Empty(user.PermittedSiteIds); // empty == system-wide, mirrors AutoLoginAuthenticationHandler
|
||||
}
|
||||
|
||||
/// <summary>Verifies that an enabled option with a blank user falls back to the multi-role user.</summary>
|
||||
[Fact]
|
||||
public void TryDisableLoginUser_WhenEnabledWithBlankUser_FallsBackToMultiRole()
|
||||
{
|
||||
@@ -69,6 +73,8 @@ public class ManagementAuthenticatorTests
|
||||
Assert.Equal("multi-role", user!.Username);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that authentication bypasses Basic auth and returns the dev user when login is disabled.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task AuthenticateAsync_WhenDisabled_BypassesBasicAuthAndReturnsDevUser()
|
||||
{
|
||||
@@ -84,6 +90,8 @@ public class ManagementAuthenticatorTests
|
||||
Assert.Equal(Roles.All, outcome.User.Roles);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that authentication returns unauthorized when login is enabled and no header is sent.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task AuthenticateAsync_WhenEnabledWithNoHeader_ReturnsUnauthorized()
|
||||
{
|
||||
@@ -139,6 +147,8 @@ public class ManagementAuthenticatorTests
|
||||
return (context.Response.StatusCode, body);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a locked-out key returns HTTP 429 without attempting an LDAP bind.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task AuthenticateAsync_LockedOutKey_Returns429WithoutLdapBind()
|
||||
{
|
||||
@@ -156,6 +166,8 @@ public class ManagementAuthenticatorTests
|
||||
await ldap.DidNotReceiveWithAnyArgs().AuthenticateAsync(default!, default!, default);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a failed LDAP bind records a failure against the lockout tracker.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task AuthenticateAsync_FailedBind_RecordsFailure()
|
||||
{
|
||||
|
||||
@@ -5,10 +5,11 @@ namespace ZB.MOM.WW.ScadaBridge.ManagementService.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for <see cref="ManagementEndpoints"/> request-body parsing
|
||||
/// (findings ManagementService-006 / -013).
|
||||
/// parsing and Ask-timeout resolution.
|
||||
/// </summary>
|
||||
public class ManagementEndpointsTests
|
||||
{
|
||||
/// <summary>Verifies that parsing a request with an explicit payload deserializes into the command type.</summary>
|
||||
[Fact]
|
||||
public void ParseCommand_WithExplicitPayload_DeserializesIntoCommandType()
|
||||
{
|
||||
@@ -24,13 +25,15 @@ public class ManagementEndpointsTests
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// P7-6: <c>MgmtDeploySiteCommand.Force</c> is an ADDITIVE field, so a client
|
||||
/// <c>MgmtDeploySiteCommand.Force</c> is an ADDITIVE field, so a client
|
||||
/// that predates it — every CLI older than this change, and every scripted
|
||||
/// HTTP caller — must keep working and must land on the SAFE default. Absent
|
||||
/// means <c>false</c> means "skip instances whose config is unchanged", which
|
||||
/// is the non-destructive behaviour; only an explicit <c>true</c> restores the
|
||||
/// unconditional redeploy.
|
||||
/// </summary>
|
||||
/// <param name="json">The management request body to parse.</param>
|
||||
/// <param name="expectedForce">The expected value of the command's <c>Force</c> flag.</param>
|
||||
[Theory]
|
||||
[InlineData("""{ "command": "MgmtDeploySite", "payload": { "siteId": 7 } }""", false)]
|
||||
[InlineData("""{ "command": "MgmtDeploySite", "payload": { "siteId": 7, "force": false } }""", false)]
|
||||
@@ -45,6 +48,7 @@ public class ManagementEndpointsTests
|
||||
Assert.Equal(expectedForce, command.Force);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that parsing a request with no payload deserializes a parameterless command.</summary>
|
||||
[Fact]
|
||||
public void ParseCommand_WithMissingPayload_DeserializesParameterlessCommand()
|
||||
{
|
||||
@@ -58,6 +62,7 @@ public class ManagementEndpointsTests
|
||||
Assert.IsType<ListTemplatesCommand>(result.Command);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that parsing invalid JSON returns a failure.</summary>
|
||||
[Fact]
|
||||
public void ParseCommand_WithInvalidJson_ReturnsFailure()
|
||||
{
|
||||
@@ -67,6 +72,7 @@ public class ManagementEndpointsTests
|
||||
Assert.Equal("BAD_REQUEST", result.ErrorCode);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that parsing a request without a command field returns a failure.</summary>
|
||||
[Fact]
|
||||
public void ParseCommand_WithMissingCommandField_ReturnsFailure()
|
||||
{
|
||||
@@ -76,6 +82,7 @@ public class ManagementEndpointsTests
|
||||
Assert.Equal("BAD_REQUEST", result.ErrorCode);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that parsing an unknown command returns a failure.</summary>
|
||||
[Fact]
|
||||
public void ParseCommand_WithUnknownCommand_ReturnsFailure()
|
||||
{
|
||||
@@ -86,12 +93,13 @@ public class ManagementEndpointsTests
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// Command-timeout configuration (finding ManagementService-010)
|
||||
// Command-timeout configuration
|
||||
//
|
||||
// ManagementServiceOptions.CommandTimeout must actually drive the Ask
|
||||
// timeout instead of a hard-coded 30s constant.
|
||||
// ========================================================================
|
||||
|
||||
/// <summary>Verifies that the Ask timeout uses the configured command timeout.</summary>
|
||||
[Fact]
|
||||
public void ResolveAskTimeout_UsesConfiguredCommandTimeout()
|
||||
{
|
||||
@@ -102,6 +110,7 @@ public class ManagementEndpointsTests
|
||||
Assert.Equal(TimeSpan.FromSeconds(75), timeout);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that null options fall back to the default Ask timeout.</summary>
|
||||
[Fact]
|
||||
public void ResolveAskTimeout_WithNullOptions_FallsBackToDefault()
|
||||
{
|
||||
@@ -110,6 +119,7 @@ public class ManagementEndpointsTests
|
||||
Assert.Equal(TimeSpan.FromSeconds(30), timeout);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a non-positive configured timeout falls back to the default Ask timeout.</summary>
|
||||
[Fact]
|
||||
public void ResolveAskTimeout_WithNonPositiveTimeout_FallsBackToDefault()
|
||||
{
|
||||
@@ -129,6 +139,8 @@ public class ManagementEndpointsTests
|
||||
// LongRunningCommandTimeout (default 5 min) instead of the 30 s default.
|
||||
// ========================================================================
|
||||
|
||||
/// <summary>Verifies that long-running commands use the long Ask timeout.</summary>
|
||||
/// <param name="cmd">The long-running command type under test.</param>
|
||||
[Theory]
|
||||
[InlineData(typeof(ImportBundleCommand))]
|
||||
[InlineData(typeof(ExportBundleCommand))]
|
||||
@@ -138,15 +150,18 @@ public class ManagementEndpointsTests
|
||||
public void ResolveAskTimeout_LongRunningCommand_UsesLongTimeout(Type cmd)
|
||||
=> Assert.Equal(TimeSpan.FromMinutes(5), ManagementEndpoints.ResolveAskTimeout(null, cmd));
|
||||
|
||||
/// <summary>Verifies that an ordinary command uses the default Ask timeout.</summary>
|
||||
[Fact]
|
||||
public void ResolveAskTimeout_OrdinaryCommand_UsesDefault()
|
||||
=> Assert.Equal(TimeSpan.FromSeconds(30), ManagementEndpoints.ResolveAskTimeout(null, typeof(ListTemplatesCommand)));
|
||||
|
||||
/// <summary>Verifies that a configured long-running timeout is honored.</summary>
|
||||
[Fact]
|
||||
public void ResolveAskTimeout_ConfiguredLongTimeout_Honored()
|
||||
=> Assert.Equal(TimeSpan.FromMinutes(10), ManagementEndpoints.ResolveAskTimeout(
|
||||
new ManagementServiceOptions { LongRunningCommandTimeout = TimeSpan.FromMinutes(10) },
|
||||
typeof(ImportBundleCommand)));
|
||||
/// <summary>Verifies that a bulk Ask timeout leaves a grace period beyond the overall scheduling deadline.</summary>
|
||||
[Fact]
|
||||
public void BulkAsk_LeavesGraceBeyondOverallSchedulingDeadline()
|
||||
{
|
||||
|
||||
@@ -4,6 +4,9 @@ namespace ZB.MOM.WW.ScadaBridge.ManagementService.Tests;
|
||||
|
||||
public class ManagementPagingBoundaryTests
|
||||
{
|
||||
/// <summary>Verifies that a positive take is capped, while an omitted or non-positive take preserves unlimited paging.</summary>
|
||||
/// <param name="take">The requested page size, or null when omitted.</param>
|
||||
/// <param name="expected">The number of rows expected back.</param>
|
||||
[Theory]
|
||||
[InlineData(null, 1100)]
|
||||
[InlineData(-1, 1100)]
|
||||
|
||||
+7
@@ -15,6 +15,8 @@ public class ManagementServiceOptionsValidatorTests
|
||||
private static ValidateOptionsResult Validate(ManagementServiceOptions options) =>
|
||||
new ManagementServiceOptionsValidator().Validate(Options.DefaultName, options);
|
||||
|
||||
/// <summary>Verifies that the long-running command timeout must leave a grace margin for the response.</summary>
|
||||
/// <param name="seconds">The long-running command timeout, in seconds.</param>
|
||||
[Theory]
|
||||
[InlineData(1)]
|
||||
[InlineData(5)]
|
||||
@@ -23,6 +25,7 @@ public class ManagementServiceOptionsValidatorTests
|
||||
Assert.True(Validate(new ManagementServiceOptions { LongRunningCommandTimeout = TimeSpan.FromSeconds(seconds) }).Failed);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the default options are valid.</summary>
|
||||
[Fact]
|
||||
public void DefaultOptions_AreValid()
|
||||
{
|
||||
@@ -30,6 +33,7 @@ public class ManagementServiceOptionsValidatorTests
|
||||
Assert.True(result.Succeeded, result.FailureMessage);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a non-positive secured-write pending TTL is valid.</summary>
|
||||
[Fact]
|
||||
public void NonPositiveSecuredWritePendingTtl_IsValid()
|
||||
{
|
||||
@@ -38,6 +42,7 @@ public class ManagementServiceOptionsValidatorTests
|
||||
Assert.True(result.Succeeded, result.FailureMessage);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a zero command timeout is rejected.</summary>
|
||||
[Fact]
|
||||
public void ZeroCommandTimeout_IsRejected()
|
||||
{
|
||||
@@ -47,6 +52,7 @@ public class ManagementServiceOptionsValidatorTests
|
||||
Assert.Contains("CommandTimeout", result.FailureMessage);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a negative command timeout is rejected.</summary>
|
||||
[Fact]
|
||||
public void NegativeCommandTimeout_IsRejected()
|
||||
{
|
||||
@@ -56,6 +62,7 @@ public class ManagementServiceOptionsValidatorTests
|
||||
Assert.Contains("CommandTimeout", result.FailureMessage);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a zero long-running command timeout is rejected.</summary>
|
||||
[Fact]
|
||||
public void ZeroLongRunningCommandTimeout_IsRejected()
|
||||
{
|
||||
|
||||
+3
@@ -11,6 +11,9 @@ namespace ZB.MOM.WW.ScadaBridge.ManagementService.Tests;
|
||||
|
||||
public class NotificationRecipientPreservationTests
|
||||
{
|
||||
/// <summary>Verifies that dedicated recipient edits preserve unrelated recipient identity and name for the given channel.</summary>
|
||||
/// <param name="channel">The notification channel type under test.</param>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Theory]
|
||||
[InlineData(NotificationType.Email)]
|
||||
[InlineData(NotificationType.Sms)]
|
||||
|
||||
@@ -130,7 +130,7 @@ public class RequiredRoleMatrixTests
|
||||
// ---- Deployer-only ----------------------------------------------------------
|
||||
["CreateInstance"] = [Roles.Deployer],
|
||||
["MgmtDeployInstance"] = [Roles.Deployer],
|
||||
// Bulk site deploy (WP2.5). Same authority as the single-instance deploy
|
||||
// Bulk site deploy. Same authority as the single-instance deploy
|
||||
// it batches — it is N of those, not a new class of privilege.
|
||||
["MgmtDeploySite"] = [Roles.Deployer],
|
||||
["MgmtEnableInstance"] = [Roles.Deployer],
|
||||
@@ -208,12 +208,16 @@ public class RequiredRoleMatrixTests
|
||||
["ResolveRoles"] = null,
|
||||
};
|
||||
|
||||
/// <summary>Supplies every registered management command type as theory data.</summary>
|
||||
/// <returns>The theory data rows of command types.</returns>
|
||||
public static IEnumerable<object[]> AllRegisteredCommands()
|
||||
=> typeof(ManagementEnvelope).Assembly.GetTypes()
|
||||
.Where(t => t.Namespace == typeof(ManagementEnvelope).Namespace
|
||||
&& t.Name.EndsWith("Command", StringComparison.Ordinal) && !t.IsAbstract)
|
||||
.Select(t => new object[] { t });
|
||||
|
||||
/// <summary>Verifies that every registered command is in the frozen authorization table.</summary>
|
||||
/// <param name="commandType">The registered command type.</param>
|
||||
[Theory, MemberData(nameof(AllRegisteredCommands))]
|
||||
public void EveryRegisteredCommand_IsInTheFrozenTable(Type commandType)
|
||||
{
|
||||
|
||||
@@ -12,7 +12,7 @@ using ZB.MOM.WW.ScadaBridge.Security;
|
||||
namespace ZB.MOM.WW.ScadaBridge.ManagementService.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Security-023 (membership-validation half): the LDAP-group-mapping <c>Role</c> arrives
|
||||
/// Membership validation: the LDAP-group-mapping <c>Role</c> arrives
|
||||
/// as a free string over the CLI/Management API. The single server-side write path
|
||||
/// (<c>ManagementActor.HandleCreateRoleMapping</c> / <c>HandleUpdateRoleMapping</c>) now
|
||||
/// rejects any role that is not in the canonical <see cref="Roles.All"/> set, returning a
|
||||
@@ -21,7 +21,7 @@ namespace ZB.MOM.WW.ScadaBridge.ManagementService.Tests;
|
||||
/// a silent-misconfiguration footgun rather than changing behaviour.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Per Security-023's scope: only the membership check is added. The pre-existing
|
||||
/// Scope: only the membership check is added. The pre-existing
|
||||
/// case-sensitivity asymmetry (case-sensitive UI <c>RequireClaim</c> vs case-insensitive
|
||||
/// ManagementActor authz) is a separately-deferred change and is NOT touched — the
|
||||
/// membership check accepts a correctly-cased canonical role and stores it verbatim.
|
||||
@@ -32,6 +32,7 @@ public class RoleMappingValidationTests : TestKit, IDisposable
|
||||
private readonly IAuditService _auditService;
|
||||
private readonly ServiceCollection _services;
|
||||
|
||||
/// <summary>Initializes the test with mocked repositories and a configuration transaction.</summary>
|
||||
public RoleMappingValidationTests()
|
||||
{
|
||||
_securityRepo = Substitute.For<ISecurityRepository>();
|
||||
@@ -55,8 +56,10 @@ public class RoleMappingValidationTests : TestKit, IDisposable
|
||||
new(new AuthenticatedUser("admin", "admin", new[] { Roles.Administrator }, Array.Empty<string>()),
|
||||
command, Guid.NewGuid().ToString("N"));
|
||||
|
||||
/// <inheritdoc />
|
||||
void IDisposable.Dispose() => Shutdown();
|
||||
|
||||
/// <summary>Verifies that creating a role mapping with an unknown role returns an error and inserts no row.</summary>
|
||||
[Fact]
|
||||
public void CreateRoleMapping_UnknownRole_ReturnsError_NoRowInserted()
|
||||
{
|
||||
@@ -69,11 +72,12 @@ public class RoleMappingValidationTests : TestKit, IDisposable
|
||||
_securityRepo.DidNotReceiveWithAnyArgs().AddMappingAsync(default!, default);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that creating a role mapping with a misspelled canonical role returns an error and inserts no row.</summary>
|
||||
[Fact]
|
||||
public void CreateRoleMapping_MisspelledCanonicalRole_ReturnsError_NoRowInserted()
|
||||
{
|
||||
// A pure typo of a real role ("Deploer" for "Deployer") is exactly the silent
|
||||
// footgun Security-023 describes: it would stamp a role claim no policy matches.
|
||||
// footgun this validation guards against: it would stamp a role claim no policy matches.
|
||||
var actor = CreateActor();
|
||||
actor.Tell(Envelope(new CreateRoleMappingCommand("SCADA-Deploy", "Deploer")));
|
||||
|
||||
@@ -82,6 +86,7 @@ public class RoleMappingValidationTests : TestKit, IDisposable
|
||||
_securityRepo.DidNotReceiveWithAnyArgs().AddMappingAsync(default!, default);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that updating a role mapping to an unknown role returns an error and updates no row.</summary>
|
||||
[Fact]
|
||||
public void UpdateRoleMapping_UnknownRole_ReturnsError_NoRowUpdated()
|
||||
{
|
||||
@@ -96,6 +101,7 @@ public class RoleMappingValidationTests : TestKit, IDisposable
|
||||
_securityRepo.DidNotReceiveWithAnyArgs().UpdateMappingAsync(default!, default);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that creating a role mapping with a canonical role succeeds and inserts the row.</summary>
|
||||
[Fact]
|
||||
public void CreateRoleMapping_CanonicalRole_Succeeds_RowInserted()
|
||||
{
|
||||
@@ -114,6 +120,7 @@ public class RoleMappingValidationTests : TestKit, IDisposable
|
||||
Assert.Equal("SCADA-Deploy", inserted.LdapGroupName);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that creating a role mapping with a lowercase role stores the canonical casing.</summary>
|
||||
[Fact]
|
||||
public void CreateRoleMapping_LowercaseRole_StoresCanonicalCasing()
|
||||
{
|
||||
@@ -135,6 +142,7 @@ public class RoleMappingValidationTests : TestKit, IDisposable
|
||||
Assert.Equal("Deployer", inserted!.Role); // canonical, not the verbatim "deployer"
|
||||
}
|
||||
|
||||
/// <summary>Verifies that updating a role mapping with a lowercase role stores the canonical casing.</summary>
|
||||
[Fact]
|
||||
public void UpdateRoleMapping_LowercaseRole_StoresCanonicalCasing()
|
||||
{
|
||||
|
||||
@@ -14,9 +14,9 @@ using ZB.MOM.WW.ScadaBridge.ManagementService;
|
||||
namespace ZB.MOM.WW.ScadaBridge.ManagementService.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// ManagementActor handler tests for the M9-T32c schema-library CRUD commands. The
|
||||
/// ManagementActor handler tests for the schema-library CRUD commands. The
|
||||
/// reusable named JSON-Schema library (<see cref="SharedSchema"/> +
|
||||
/// <see cref="ISharedSchemaRepository"/>, T32a) gains its authoring surface here:
|
||||
/// <see cref="ISharedSchemaRepository"/>) gains its authoring surface here:
|
||||
/// list/get are read-only (any authenticated user), create/update/delete are
|
||||
/// Designer-gated, the schema JSON is validated as a JSON Schema, and the unique
|
||||
/// Name is enforced with a clear duplicate-name error. Mirrors the repo-backed
|
||||
@@ -28,6 +28,7 @@ public class SchemaLibraryHandlerTests : TestKit, IDisposable
|
||||
private readonly IAuditService _auditService;
|
||||
private readonly ServiceCollection _services;
|
||||
|
||||
/// <summary>Builds the management actor handler over a mocked schema repository.</summary>
|
||||
public SchemaLibraryHandlerTests()
|
||||
{
|
||||
_schemaRepo = Substitute.For<ISharedSchemaRepository>();
|
||||
@@ -49,6 +50,7 @@ public class SchemaLibraryHandlerTests : TestKit, IDisposable
|
||||
new(new AuthenticatedUser("testuser", "Test User", roles, Array.Empty<string>()),
|
||||
command, Guid.NewGuid().ToString("N"));
|
||||
|
||||
/// <inheritdoc />
|
||||
void IDisposable.Dispose() => Shutdown();
|
||||
|
||||
private const string ValidSchema =
|
||||
@@ -56,6 +58,7 @@ public class SchemaLibraryHandlerTests : TestKit, IDisposable
|
||||
|
||||
// ── Gating ──────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>Verifies that creating a shared schema with only the Deployer role returns unauthorized.</summary>
|
||||
[Fact]
|
||||
public void CreateSharedSchema_WithDeployerRole_ReturnsUnauthorized()
|
||||
{
|
||||
@@ -68,6 +71,7 @@ public class SchemaLibraryHandlerTests : TestKit, IDisposable
|
||||
Assert.Contains("Designer", response.Message);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that updating a shared schema with no roles returns unauthorized.</summary>
|
||||
[Fact]
|
||||
public void UpdateSharedSchema_WithNoRoles_ReturnsUnauthorized()
|
||||
{
|
||||
@@ -80,6 +84,7 @@ public class SchemaLibraryHandlerTests : TestKit, IDisposable
|
||||
Assert.Contains("Designer", response.Message);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that deleting a shared schema with only the Deployer role returns unauthorized.</summary>
|
||||
[Fact]
|
||||
public void DeleteSharedSchema_WithDeployerRole_ReturnsUnauthorized()
|
||||
{
|
||||
@@ -94,6 +99,7 @@ public class SchemaLibraryHandlerTests : TestKit, IDisposable
|
||||
|
||||
// ── List / Get (read-gated) ─────────────────────────────────────────────
|
||||
|
||||
/// <summary>Verifies that listing shared schemas succeeds for a user with no roles.</summary>
|
||||
[Fact]
|
||||
public void ListSharedSchemas_WithNoRoles_ReturnsSuccess()
|
||||
{
|
||||
@@ -113,6 +119,7 @@ public class SchemaLibraryHandlerTests : TestKit, IDisposable
|
||||
Assert.Contains("Addr", response.JsonData);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that getting a shared schema succeeds for a user with no roles.</summary>
|
||||
[Fact]
|
||||
public void GetSharedSchema_WithNoRoles_ReturnsSuccess()
|
||||
{
|
||||
@@ -130,6 +137,7 @@ public class SchemaLibraryHandlerTests : TestKit, IDisposable
|
||||
|
||||
// ── Create round-trip + validation ──────────────────────────────────────
|
||||
|
||||
/// <summary>Verifies that creating a shared schema with the Designer role persists it and returns the entity.</summary>
|
||||
[Fact]
|
||||
public void CreateSharedSchema_WithDesignerRole_PersistsAndReturnsEntity()
|
||||
{
|
||||
@@ -155,6 +163,7 @@ public class SchemaLibraryHandlerTests : TestKit, IDisposable
|
||||
Assert.Contains("42", response.JsonData);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that creating a shared schema with a duplicate name returns a clear error.</summary>
|
||||
[Fact]
|
||||
public void CreateSharedSchema_WithDuplicateName_ReturnsClearError()
|
||||
{
|
||||
@@ -173,6 +182,7 @@ public class SchemaLibraryHandlerTests : TestKit, IDisposable
|
||||
Assert.Contains("already exists", response.Error);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that creating a shared schema with an empty name returns a clear error.</summary>
|
||||
[Fact]
|
||||
public void CreateSharedSchema_WithEmptyName_ReturnsClearError()
|
||||
{
|
||||
@@ -187,6 +197,7 @@ public class SchemaLibraryHandlerTests : TestKit, IDisposable
|
||||
Assert.Contains("name", response.Error, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that creating a shared schema with invalid JSON returns a clear error.</summary>
|
||||
[Fact]
|
||||
public void CreateSharedSchema_WithInvalidJson_ReturnsClearError()
|
||||
{
|
||||
@@ -205,6 +216,7 @@ public class SchemaLibraryHandlerTests : TestKit, IDisposable
|
||||
|
||||
// ── Update round-trip + validation ──────────────────────────────────────
|
||||
|
||||
/// <summary>Verifies that updating a shared schema with the Designer role persists the changes.</summary>
|
||||
[Fact]
|
||||
public void UpdateSharedSchema_WithDesignerRole_PersistsChanges()
|
||||
{
|
||||
@@ -229,6 +241,7 @@ public class SchemaLibraryHandlerTests : TestKit, IDisposable
|
||||
Assert.Equal(updatedSchema, existing.SchemaJson);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that updating a shared schema that does not exist returns a clear error.</summary>
|
||||
[Fact]
|
||||
public void UpdateSharedSchema_NotFound_ReturnsClearError()
|
||||
{
|
||||
@@ -246,6 +259,7 @@ public class SchemaLibraryHandlerTests : TestKit, IDisposable
|
||||
Assert.Contains("99", response.Error);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that renaming a shared schema to an existing name returns a clear error.</summary>
|
||||
[Fact]
|
||||
public void UpdateSharedSchema_RenameToExistingName_ReturnsClearError()
|
||||
{
|
||||
@@ -266,6 +280,7 @@ public class SchemaLibraryHandlerTests : TestKit, IDisposable
|
||||
Assert.Contains("already exists", response.Error);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that updating a shared schema while keeping its own name is allowed.</summary>
|
||||
[Fact]
|
||||
public void UpdateSharedSchema_SameName_IsAllowed()
|
||||
{
|
||||
@@ -289,6 +304,7 @@ public class SchemaLibraryHandlerTests : TestKit, IDisposable
|
||||
|
||||
// ── Delete round-trip ───────────────────────────────────────────────────
|
||||
|
||||
/// <summary>Verifies that deleting a shared schema with the Designer role deletes it.</summary>
|
||||
[Fact]
|
||||
public void DeleteSharedSchema_WithDesignerRole_Deletes()
|
||||
{
|
||||
@@ -302,10 +318,11 @@ public class SchemaLibraryHandlerTests : TestKit, IDisposable
|
||||
_schemaRepo.Received(1).DeleteAsync(5, Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
/// <summary>Verifies that deleting a shared schema records its human-readable name in the audit entry.</summary>
|
||||
[Fact]
|
||||
public void DeleteSharedSchema_RecordsHumanReadableNameInAudit()
|
||||
{
|
||||
// #260: the delete handler pre-fetches the row name BEFORE deleting so the
|
||||
// The delete handler pre-fetches the row name BEFORE deleting so the
|
||||
// audit EntityName records "Addr" rather than the numeric id (mirroring the
|
||||
// Site delete handler).
|
||||
_schemaRepo.GetByIdAsync(5, Arg.Any<CancellationToken>())
|
||||
@@ -324,6 +341,7 @@ public class SchemaLibraryHandlerTests : TestKit, IDisposable
|
||||
Arg.Any<object?>(), Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
/// <summary>Verifies that listing shared schemas returns an error when the repository throws.</summary>
|
||||
[Fact]
|
||||
public void ListSharedSchemas_WhenRepoThrows_ReturnsError()
|
||||
{
|
||||
@@ -340,8 +358,9 @@ public class SchemaLibraryHandlerTests : TestKit, IDisposable
|
||||
Assert.DoesNotContain("db down secret detail", response.Error);
|
||||
}
|
||||
|
||||
// ── Concurrent-create race (#260) ───────────────────────────────────────
|
||||
// ── Concurrent-create race ───────────────────────────────────────
|
||||
|
||||
/// <summary>Verifies that a unique-constraint violation from a racing insert surfaces as a sanitized error.</summary>
|
||||
[Fact]
|
||||
public void CreateSharedSchema_WhenInsertRacesUniqueViolation_SurfacesSanitizedError()
|
||||
{
|
||||
|
||||
@@ -23,6 +23,7 @@ public class SecretProjectionTests : TestKit, IDisposable
|
||||
private readonly IAuditService _auditService;
|
||||
private readonly ServiceCollection _services;
|
||||
|
||||
/// <summary>Initializes the test class with stubbed repositories and audit service.</summary>
|
||||
public SecretProjectionTests()
|
||||
{
|
||||
_auditService = Substitute.For<IAuditService>();
|
||||
@@ -41,10 +42,11 @@ public class SecretProjectionTests : TestKit, IDisposable
|
||||
new(new AuthenticatedUser("testuser", "Test User", roles, Array.Empty<string>()),
|
||||
command, Guid.NewGuid().ToString("N"));
|
||||
|
||||
/// <inheritdoc />
|
||||
void IDisposable.Dispose() => Shutdown();
|
||||
|
||||
// ========================================================================
|
||||
// Task 7 — DataConnection config secret elision
|
||||
// DataConnection config secret elision
|
||||
// ========================================================================
|
||||
|
||||
private ISiteRepository AddSiteRepoWithConnection(DataConnection conn)
|
||||
@@ -57,6 +59,7 @@ public class SecretProjectionTests : TestKit, IDisposable
|
||||
return siteRepo;
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the get-data-connection response omits passwords.</summary>
|
||||
[Fact]
|
||||
public void GetDataConnection_ResponseOmitsPasswords()
|
||||
{
|
||||
@@ -74,6 +77,7 @@ public class SecretProjectionTests : TestKit, IDisposable
|
||||
Assert.Contains("hasSecrets", resp.JsonData); // camelCase serializer
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the list-data-connections response omits passwords.</summary>
|
||||
[Fact]
|
||||
public void ListDataConnections_ResponseOmitsPasswords()
|
||||
{
|
||||
@@ -91,6 +95,8 @@ public class SecretProjectionTests : TestKit, IDisposable
|
||||
Assert.Contains("hasSecrets", resp.JsonData);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that updating a data connection with the sentinel config preserves the stored secret.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task UpdateDataConnection_SentinelConfig_PreservesStoredSecret()
|
||||
{
|
||||
@@ -112,6 +118,7 @@ public class SecretProjectionTests : TestKit, IDisposable
|
||||
Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the audit after-state of a data connection update omits passwords.</summary>
|
||||
[Fact]
|
||||
public void UpdateDataConnection_AuditAfterState_OmitsPasswords()
|
||||
{
|
||||
@@ -133,7 +140,7 @@ public class SecretProjectionTests : TestKit, IDisposable
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// Task 8 — ExternalSystem AuthConfiguration elision
|
||||
// ExternalSystem AuthConfiguration elision
|
||||
// ========================================================================
|
||||
|
||||
private IExternalSystemRepository AddExternalSystemRepo(ExternalSystemDefinition def)
|
||||
@@ -146,6 +153,7 @@ public class SecretProjectionTests : TestKit, IDisposable
|
||||
return repo;
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the get-external-system response omits the auth configuration.</summary>
|
||||
[Fact]
|
||||
public void GetExternalSystem_ResponseOmitsAuthConfiguration()
|
||||
{
|
||||
@@ -163,6 +171,7 @@ public class SecretProjectionTests : TestKit, IDisposable
|
||||
Assert.Contains("hasAuthConfiguration", resp.JsonData);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the list-external-systems response omits the auth configuration.</summary>
|
||||
[Fact]
|
||||
public void ListExternalSystems_ResponseOmitsAuthConfiguration()
|
||||
{
|
||||
@@ -180,6 +189,8 @@ public class SecretProjectionTests : TestKit, IDisposable
|
||||
Assert.Contains("hasAuthConfiguration", resp.JsonData);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that updating an external system with a null auth configuration preserves the stored secret.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task UpdateExternalSystem_NullAuthConfiguration_PreservesStoredSecret()
|
||||
{
|
||||
@@ -199,6 +210,7 @@ public class SecretProjectionTests : TestKit, IDisposable
|
||||
Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the audit after-state of an external system update omits the auth configuration.</summary>
|
||||
[Fact]
|
||||
public void UpdateExternalSystem_AuditAfterState_OmitsAuthConfiguration()
|
||||
{
|
||||
@@ -220,7 +232,7 @@ public class SecretProjectionTests : TestKit, IDisposable
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// Task 9 — DatabaseConnection ConnectionString elision
|
||||
// DatabaseConnection ConnectionString elision
|
||||
// ========================================================================
|
||||
|
||||
private IExternalSystemRepository AddDatabaseConnectionRepo(DatabaseConnectionDefinition def)
|
||||
@@ -233,6 +245,7 @@ public class SecretProjectionTests : TestKit, IDisposable
|
||||
return repo;
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the get-database-connection response omits the connection string.</summary>
|
||||
[Fact]
|
||||
public void GetDatabaseConnection_ResponseOmitsConnectionString()
|
||||
{
|
||||
@@ -246,6 +259,7 @@ public class SecretProjectionTests : TestKit, IDisposable
|
||||
Assert.Contains("hasConnectionString", resp.JsonData);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the list-database-connections response omits the connection string.</summary>
|
||||
[Fact]
|
||||
public void ListDatabaseConnections_ResponseOmitsConnectionString()
|
||||
{
|
||||
@@ -259,6 +273,8 @@ public class SecretProjectionTests : TestKit, IDisposable
|
||||
Assert.Contains("hasConnectionString", resp.JsonData);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that updating a database connection with a null connection string preserves the stored secret.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task UpdateDatabaseConnection_NullConnectionString_PreservesStoredSecret()
|
||||
{
|
||||
|
||||
@@ -20,15 +20,15 @@ namespace ZB.MOM.WW.ScadaBridge.ManagementService.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Handler tests for the two-person ("secured") write submit/reject/list flow
|
||||
/// (M7 OPC UA / MxGateway UX, Task T14b). Submit is gated to Operator, reject to
|
||||
/// Submit is gated to Operator, reject to
|
||||
/// Verifier; the no-self-approval rule and the MxGateway-protocol precondition
|
||||
/// are enforced inside the handlers and surface as ManagementError.
|
||||
/// </summary>
|
||||
public class SecuredWriteHandlerTests : TestKit, IDisposable
|
||||
{
|
||||
/// <summary>
|
||||
/// The central node the test writer stamps onto every emitted row. #206 routes
|
||||
/// SecuredWrite audit through <see cref="ICentralAuditWriter"/>, whose
|
||||
/// The central node the test writer stamps onto every emitted row. SecuredWrite
|
||||
/// audit is routed through <see cref="ICentralAuditWriter"/>, whose
|
||||
/// <see cref="INodeIdentityProvider"/> stamps <c>SourceNode</c>; the handler tests
|
||||
/// assert each lifecycle row carries this value (proving the stamping actually runs).
|
||||
/// </summary>
|
||||
@@ -40,6 +40,7 @@ public class SecuredWriteHandlerTests : TestKit, IDisposable
|
||||
private readonly StubCommunicationService _comms;
|
||||
private readonly ServiceCollection _services;
|
||||
|
||||
/// <summary>Initializes the test kit with substitute repositories, the real central audit writer and a stub communication service.</summary>
|
||||
public SecuredWriteHandlerTests()
|
||||
{
|
||||
_siteRepo = Substitute.For<ISiteRepository>();
|
||||
@@ -59,7 +60,7 @@ public class SecuredWriteHandlerTests : TestKit, IDisposable
|
||||
_services.AddSingleton<IOptions<ManagementServiceOptions>>(
|
||||
Options.Create(new ManagementServiceOptions()));
|
||||
|
||||
// #206: route SecuredWrite audit through the REAL CentralAuditWriter (the same
|
||||
// Route SecuredWrite audit through the REAL CentralAuditWriter (the same
|
||||
// central-direct-write path Notification Outbox dispatch + Inbound API use) so
|
||||
// these tests exercise SourceNode stamping end-to-end — actor → writer → repo.
|
||||
// The writer stamps SourceNode from the INodeIdentityProvider (central-a/central-b)
|
||||
@@ -78,7 +79,7 @@ public class SecuredWriteHandlerTests : TestKit, IDisposable
|
||||
/// <summary>
|
||||
/// Captures every <see cref="AuditEvent"/> that lands at the substituted
|
||||
/// <see cref="IAuditLogRepository.InsertIfNotExistsAsync"/> — i.e. the row AFTER the
|
||||
/// real <see cref="CentralAuditWriter"/> has stamped <c>SourceNode</c> (#206), so the
|
||||
/// real <see cref="CentralAuditWriter"/> has stamped <c>SourceNode</c>, so the
|
||||
/// captured events carry the production node-of-origin value. Audit emission is
|
||||
/// best-effort and asynchronous off the actor thread, so a short await-condition
|
||||
/// poll lets the captured list settle before assertions.
|
||||
@@ -112,20 +113,26 @@ public class SecuredWriteHandlerTests : TestKit, IDisposable
|
||||
/// </summary>
|
||||
private sealed class StubCommunicationService : CommunicationService
|
||||
{
|
||||
/// <summary>Initializes a new stub with default communication options.</summary>
|
||||
public StubCommunicationService()
|
||||
: base(Options.Create(new CommunicationOptions()), NullLogger<CommunicationService>.Instance)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>Gets the last write request relayed through the stub.</summary>
|
||||
public WriteTagRequest? LastRequest { get; private set; }
|
||||
|
||||
/// <summary>Gets the number of times the stub was asked to write a tag.</summary>
|
||||
public int CallCount { get; private set; }
|
||||
|
||||
/// <summary>Gets or sets the function that produces the canned response for a write request.</summary>
|
||||
public Func<WriteTagRequest, WriteTagResponse> Responder { get; set; } =
|
||||
req => new WriteTagResponse(req.CorrelationId, Success: true, ErrorMessage: null, DateTimeOffset.UtcNow);
|
||||
|
||||
/// <summary>Gets or sets an exception to throw when a write is attempted.</summary>
|
||||
public Exception? ThrowOnWrite { get; set; }
|
||||
|
||||
/// <inheritdoc />
|
||||
public override Task<WriteTagResponse> WriteTagAsync(
|
||||
string siteId, WriteTagRequest request, CancellationToken ct = default)
|
||||
{
|
||||
@@ -154,6 +161,7 @@ public class SecuredWriteHandlerTests : TestKit, IDisposable
|
||||
private static ManagementEnvelope ScopedEnvelope(object command, string username, string[] permittedSiteIds, params string[] roles) =>
|
||||
new(new AuthenticatedUser(username, username, roles, permittedSiteIds), command, Guid.NewGuid().ToString("N"));
|
||||
|
||||
/// <inheritdoc />
|
||||
void IDisposable.Dispose() => Shutdown();
|
||||
|
||||
/// <summary>Wires a site whose single connection uses the given protocol.</summary>
|
||||
@@ -172,6 +180,7 @@ public class SecuredWriteHandlerTests : TestKit, IDisposable
|
||||
// Submit
|
||||
// ------------------------------------------------------------------------
|
||||
|
||||
/// <summary>Verifies that submitting on a non-MxGateway connection returns an error and inserts no row.</summary>
|
||||
[Fact]
|
||||
public void Submit_OnNonMxGatewayConnection_ReturnsError_NoRowInserted()
|
||||
{
|
||||
@@ -190,6 +199,7 @@ public class SecuredWriteHandlerTests : TestKit, IDisposable
|
||||
_securedWriteRepo.DidNotReceiveWithAnyArgs().AddAsync(default!, default);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that submitting on an unknown connection returns an error and inserts no row.</summary>
|
||||
[Fact]
|
||||
public void Submit_OnUnknownConnection_ReturnsError_NoRowInserted()
|
||||
{
|
||||
@@ -207,6 +217,7 @@ public class SecuredWriteHandlerTests : TestKit, IDisposable
|
||||
_securedWriteRepo.DidNotReceiveWithAnyArgs().AddAsync(default!, default);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that submitting on an MxGateway connection inserts a pending row stamped with the operator user.</summary>
|
||||
[Fact]
|
||||
public void Submit_OnMxGatewayConnection_InsertsPendingRowWithOperatorUser()
|
||||
{
|
||||
@@ -274,8 +285,7 @@ public class SecuredWriteHandlerTests : TestKit, IDisposable
|
||||
/// Arms the reject compare-and-swap to succeed (this verifier wins the
|
||||
/// Pending->Rejected transition). NSubstitute's unarmed default for the
|
||||
/// bool-returning CAS is <c>false</c>, i.e. "lost the race", so every reject test
|
||||
/// that expects the decision to land must arm it explicitly (arch-review
|
||||
/// 2026-08-19 P6-1).
|
||||
/// that expects the decision to land must arm it explicitly.
|
||||
/// </summary>
|
||||
private void ArmRejectCasSuccess(long id) =>
|
||||
_securedWriteRepo.TryMarkRejectedAsync(
|
||||
@@ -293,6 +303,7 @@ public class SecuredWriteHandlerTests : TestKit, IDisposable
|
||||
Assert.Empty(captured);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that rejecting as the submitting user returns an error and leaves the status unchanged.</summary>
|
||||
[Fact]
|
||||
public void Reject_BySubmittingUser_ReturnsError_StatusUnchanged()
|
||||
{
|
||||
@@ -311,6 +322,7 @@ public class SecuredWriteHandlerTests : TestKit, IDisposable
|
||||
default, default!, default, default, default);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that rejecting as a different user flips the status to rejected through the compare-and-swap, with no blind update.</summary>
|
||||
[Fact]
|
||||
public void Reject_ByDifferentUser_CasFlipsStatusToRejected_NoBlindUpdate()
|
||||
{
|
||||
@@ -326,7 +338,7 @@ public class SecuredWriteHandlerTests : TestKit, IDisposable
|
||||
Assert.Equal(envelope.CorrelationId, response.CorrelationId);
|
||||
|
||||
// The decision goes through the conditional Pending->Rejected CAS, exactly once,
|
||||
// carrying the verifier identity + comment (arch-review 2026-08-19 P6-1).
|
||||
// carrying the verifier identity + comment.
|
||||
_securedWriteRepo.Received(1).TryMarkRejectedAsync(
|
||||
7, "bob", "not authorized", Arg.Any<DateTime>(), Arg.Any<CancellationToken>());
|
||||
// The blind full-row overwrite is gone — it could land mid-relay on a row a
|
||||
@@ -341,6 +353,7 @@ public class SecuredWriteHandlerTests : TestKit, IDisposable
|
||||
Assert.Contains("alice", response.JsonData);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that rejecting a non-pending row returns an error.</summary>
|
||||
[Fact]
|
||||
public void Reject_NonPendingRow_ReturnsError()
|
||||
{
|
||||
@@ -362,10 +375,11 @@ public class SecuredWriteHandlerTests : TestKit, IDisposable
|
||||
AssertNoAuditRows(captured);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a reject that loses the compare-and-swap race returns a conflict, writes nothing and emits no reject audit row.</summary>
|
||||
[Fact]
|
||||
public void Reject_WhenCasLosesRace_ReturnsConflict_WritesNothing_NoRejectAudit()
|
||||
{
|
||||
// The P6-1 race, from the reject side: the status read at the top of the handler
|
||||
// The race, from the reject side: the status read at the top of the handler
|
||||
// saw Pending, but a concurrent approve won the Pending->Approved CAS and entered
|
||||
// its multi-second WriteTagAsync relay before this reject reached the store. The
|
||||
// reject must lose (CAS returns false), record NOTHING — no status write, no
|
||||
@@ -409,6 +423,7 @@ public class SecuredWriteHandlerTests : TestKit, IDisposable
|
||||
AssertNoAuditRows(captured);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that approving an already rejected row returns an error with no compare-and-swap, relay or audit.</summary>
|
||||
[Fact]
|
||||
public void Approve_OnAlreadyRejectedRow_ReturnsError_NoCas_NoRelay_NoAudit()
|
||||
{
|
||||
@@ -431,6 +446,7 @@ public class SecuredWriteHandlerTests : TestKit, IDisposable
|
||||
AssertNoAuditRows(captured);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that rejecting a missing row returns an error.</summary>
|
||||
[Fact]
|
||||
public void Reject_MissingRow_ReturnsError()
|
||||
{
|
||||
@@ -449,6 +465,7 @@ public class SecuredWriteHandlerTests : TestKit, IDisposable
|
||||
// List
|
||||
// ------------------------------------------------------------------------
|
||||
|
||||
/// <summary>Verifies that listing filters by status and site.</summary>
|
||||
[Fact]
|
||||
public void List_FiltersByStatusAndSite()
|
||||
{
|
||||
@@ -478,6 +495,7 @@ public class SecuredWriteHandlerTests : TestKit, IDisposable
|
||||
_securedWriteRepo.Received(1).QueryAsync("Pending", "SITE1", 0, 200, Arg.Any<IReadOnlyCollection<string>?>(), Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
/// <summary>Verifies that listing with no filters passes nulls to the repository.</summary>
|
||||
[Fact]
|
||||
public void List_NoFilters_PassesNullsToRepository()
|
||||
{
|
||||
@@ -494,6 +512,7 @@ public class SecuredWriteHandlerTests : TestKit, IDisposable
|
||||
_securedWriteRepo.Received(1).QueryAsync(null, null, 0, 200, Arg.Any<IReadOnlyCollection<string>?>(), Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a site-scoped reader requesting an out-of-scope site is unauthorized.</summary>
|
||||
[Fact]
|
||||
public void List_SiteScopedReader_ExplicitOutOfScopeSiteFilter_Unauthorized()
|
||||
{
|
||||
@@ -503,6 +522,7 @@ public class SecuredWriteHandlerTests : TestKit, IDisposable
|
||||
ExpectMsg<ManagementUnauthorized>(TimeSpan.FromSeconds(5));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that an unfiltered list for a site-scoped reader queries only the permitted site identifiers.</summary>
|
||||
[Fact]
|
||||
public void List_SiteScopedReader_Unfiltered_QueriesOnlyPermittedIdentifiers()
|
||||
{
|
||||
@@ -522,6 +542,7 @@ public class SecuredWriteHandlerTests : TestKit, IDisposable
|
||||
Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a system-wide reader passes a null permitted-sites filter.</summary>
|
||||
[Fact]
|
||||
public void List_SystemWideReader_PassesNullPermittedFilter()
|
||||
{
|
||||
@@ -537,7 +558,7 @@ public class SecuredWriteHandlerTests : TestKit, IDisposable
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------------
|
||||
// Approve (execute) — Task C3
|
||||
// Approve (execute)
|
||||
// ------------------------------------------------------------------------
|
||||
|
||||
/// <summary>Arms the CAS to succeed (a different verifier wins the race).</summary>
|
||||
@@ -546,6 +567,7 @@ public class SecuredWriteHandlerTests : TestKit, IDisposable
|
||||
id, Arg.Any<string>(), Arg.Any<string?>(), Arg.Any<DateTime>(), Arg.Any<CancellationToken>())
|
||||
.Returns(true);
|
||||
|
||||
/// <summary>Verifies that approval by a different verifier relays the write and flips the status to executed.</summary>
|
||||
[Fact]
|
||||
public void Approve_ByDifferentVerifier_RelaysWrite_FlipsStatusToExecuted()
|
||||
{
|
||||
@@ -581,6 +603,7 @@ public class SecuredWriteHandlerTests : TestKit, IDisposable
|
||||
Assert.Null(updated.ExecutionError);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that approving as the submitting user returns an error with no compare-and-swap and no relay.</summary>
|
||||
[Fact]
|
||||
public void Approve_BySubmittingUser_ReturnsError_CasNotCalled_NoRelay()
|
||||
{
|
||||
@@ -599,6 +622,7 @@ public class SecuredWriteHandlerTests : TestKit, IDisposable
|
||||
Assert.Equal(0, _comms.CallCount);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that an approval that loses the compare-and-swap race returns an error and does not relay.</summary>
|
||||
[Fact]
|
||||
public void Approve_WhenCasLosesRace_ReturnsError_NoRelay()
|
||||
{
|
||||
@@ -622,6 +646,7 @@ public class SecuredWriteHandlerTests : TestKit, IDisposable
|
||||
AssertNoAuditRows(captured);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a failed relay flips the status to failed and records the error.</summary>
|
||||
[Fact]
|
||||
public void Approve_WhenRelayFails_FlipsStatusToFailed_WithError()
|
||||
{
|
||||
@@ -649,6 +674,7 @@ public class SecuredWriteHandlerTests : TestKit, IDisposable
|
||||
Assert.NotNull(updated.ExecutedAtUtc);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a relay that throws flips the status to failed and records the exception message.</summary>
|
||||
[Fact]
|
||||
public void Approve_WhenRelayThrows_FlipsStatusToFailed_WithExceptionMessage()
|
||||
{
|
||||
@@ -672,6 +698,7 @@ public class SecuredWriteHandlerTests : TestKit, IDisposable
|
||||
Assert.Contains("transport down", updated.ExecutionError);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that an unknown value type flips the status to failed without relaying.</summary>
|
||||
[Fact]
|
||||
public void Approve_UnknownValueType_FlipsStatusToFailed_NoRelay()
|
||||
{
|
||||
@@ -696,6 +723,7 @@ public class SecuredWriteHandlerTests : TestKit, IDisposable
|
||||
Assert.Equal("unknown value type", updated.ExecutionError);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that approving when the connection is no longer MxGateway flips the status to failed without relaying.</summary>
|
||||
[Fact]
|
||||
public void Approve_WhenConnectionNoLongerMxGateway_FlipsStatusToFailed_NoRelay()
|
||||
{
|
||||
@@ -736,6 +764,7 @@ public class SecuredWriteHandlerTests : TestKit, IDisposable
|
||||
Assert.Equal(ZB.MOM.WW.Audit.AuditOutcome.Failure, execute.Outcome);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that approving when the connection was deleted flips the status to failed as not found, without relaying.</summary>
|
||||
[Fact]
|
||||
public void Approve_WhenConnectionDeleted_FlipsStatusToFailed_NotFound_NoRelay()
|
||||
{
|
||||
@@ -766,9 +795,10 @@ public class SecuredWriteHandlerTests : TestKit, IDisposable
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------------
|
||||
// List paging — Task 17 (arch-review P3)
|
||||
// List paging
|
||||
// ------------------------------------------------------------------------
|
||||
|
||||
/// <summary>Verifies that listing with skip and take forwards paging and returns the total count.</summary>
|
||||
[Fact]
|
||||
public void List_WithSkipTake_ForwardsPagingAndReturnsTotalCount()
|
||||
{
|
||||
@@ -786,9 +816,10 @@ public class SecuredWriteHandlerTests : TestKit, IDisposable
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------------
|
||||
// Opportunistic expiry sweep on list — Task 16 (arch-review S2)
|
||||
// Opportunistic expiry sweep on list
|
||||
// ------------------------------------------------------------------------
|
||||
|
||||
/// <summary>Verifies that listing sweeps stale pending rows to expired and calls the expiry compare-and-swap once, for the stale rows only.</summary>
|
||||
[Fact]
|
||||
public void List_SweepsStalePendingRows_MarksExpired_CallsTryMarkExpiredOnceForStaleOnly()
|
||||
{
|
||||
@@ -822,9 +853,10 @@ public class SecuredWriteHandlerTests : TestKit, IDisposable
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------------
|
||||
// Server-side TTL expiry — Task 15 (arch-review S2)
|
||||
// Server-side TTL expiry
|
||||
// ------------------------------------------------------------------------
|
||||
|
||||
/// <summary>Verifies that approving a pending row older than the time-to-live expires it instead of executing.</summary>
|
||||
[Fact]
|
||||
public void Approve_PendingRowOlderThanTtl_ExpiresInsteadOfExecuting()
|
||||
{
|
||||
@@ -845,6 +877,7 @@ public class SecuredWriteHandlerTests : TestKit, IDisposable
|
||||
_securedWriteRepo.Received(1).TryMarkExpiredAsync(1, Arg.Any<DateTime>(), Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
/// <summary>Verifies that rejecting a pending row older than the time-to-live expires it instead of rejecting.</summary>
|
||||
[Fact]
|
||||
public void Reject_PendingRowOlderThanTtl_ExpiresInsteadOfRejecting()
|
||||
{
|
||||
@@ -865,6 +898,7 @@ public class SecuredWriteHandlerTests : TestKit, IDisposable
|
||||
_securedWriteRepo.Received(1).TryMarkExpiredAsync(1, Arg.Any<DateTime>(), Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
/// <summary>Verifies that approving a fresh pending row still executes.</summary>
|
||||
[Fact]
|
||||
public void Approve_FreshPendingRow_StillExecutes()
|
||||
{
|
||||
@@ -904,6 +938,7 @@ public class SecuredWriteHandlerTests : TestKit, IDisposable
|
||||
return new Guid(bytes);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that submitting emits exactly one submit audit row carrying the operator and correlation id.</summary>
|
||||
[Fact]
|
||||
public void Submit_EmitsExactlyOneSubmitAuditRow_WithOperatorAndCorrelation()
|
||||
{
|
||||
@@ -927,13 +962,14 @@ public class SecuredWriteHandlerTests : TestKit, IDisposable
|
||||
Assert.Equal(CorrelationFor(55L), evt.CorrelationId);
|
||||
Assert.Equal("SecuredWrite", evt.Category);
|
||||
Assert.Equal("SITE1/Mx1/Tag.Setpoint", evt.Target);
|
||||
// #206: the Submit row is stamped with the writing central node's identifier
|
||||
// The Submit row is stamped with the writing central node's identifier
|
||||
// (it routed through the real CentralAuditWriter), not the legacy null SourceNode.
|
||||
Assert.Equal(ExpectedSourceNode, evt.SourceNode);
|
||||
// Exactly one row — no stray duplicates.
|
||||
Assert.Single(captured);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that rejecting emits one reject audit row carrying the verifier.</summary>
|
||||
[Fact]
|
||||
public void Reject_EmitsOneRejectAuditRow_WithVerifier()
|
||||
{
|
||||
@@ -952,11 +988,12 @@ public class SecuredWriteHandlerTests : TestKit, IDisposable
|
||||
var evt = SingleOfKind(captured, AuditKind.SecuredWriteReject);
|
||||
Assert.Equal("bob", evt.Actor);
|
||||
Assert.Equal(CorrelationFor(7L), evt.CorrelationId);
|
||||
// #206: Reject row carries the writing central node's identifier.
|
||||
// The Reject row carries the writing central node's identifier.
|
||||
Assert.Equal(ExpectedSourceNode, evt.SourceNode);
|
||||
Assert.Single(captured);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a successful approval emits an approve row followed by an execute-delivered row.</summary>
|
||||
[Fact]
|
||||
public void Approve_Success_EmitsApproveThenExecuteDeliveredAuditRows()
|
||||
{
|
||||
@@ -975,7 +1012,7 @@ public class SecuredWriteHandlerTests : TestKit, IDisposable
|
||||
var approve = SingleOfKind(captured, AuditKind.SecuredWriteApprove);
|
||||
Assert.Equal("bob", approve.Actor);
|
||||
Assert.Equal(CorrelationFor(7L), approve.CorrelationId);
|
||||
// #206: Approve + Execute rows both carry the writing central node's identifier.
|
||||
// Approve + Execute rows both carry the writing central node's identifier.
|
||||
Assert.Equal(ExpectedSourceNode, approve.SourceNode);
|
||||
|
||||
var execute = SingleOfKind(captured, AuditKind.SecuredWriteExecute);
|
||||
@@ -987,6 +1024,7 @@ public class SecuredWriteHandlerTests : TestKit, IDisposable
|
||||
Assert.Equal(2, captured.Count);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a failing audit insert does not fail the submit and the row is still persisted.</summary>
|
||||
[Fact]
|
||||
public void Submit_AuditInsertThrows_StillSucceeds_RowStillPersisted()
|
||||
{
|
||||
@@ -998,7 +1036,7 @@ public class SecuredWriteHandlerTests : TestKit, IDisposable
|
||||
_securedWriteRepo.AddAsync(Arg.Any<PendingSecuredWrite>(), Arg.Any<CancellationToken>())
|
||||
.Returns(55L);
|
||||
// Best-effort: a thrown audit insert must NOT abort the secured-write action.
|
||||
// #206: the throw now happens behind the real CentralAuditWriter (which swallows
|
||||
// The throw now happens behind the real CentralAuditWriter (which swallows
|
||||
// it), so this still exercises the standing "audit failure never aborts the
|
||||
// action" invariant — now through the central-direct-write path.
|
||||
_auditRepo.InsertIfNotExistsAsync(Arg.Any<AuditEvent>(), Arg.Any<CancellationToken>())
|
||||
@@ -1019,6 +1057,7 @@ public class SecuredWriteHandlerTests : TestKit, IDisposable
|
||||
Assert.Equal("alice", inserted.OperatorUser);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a corrupt list value JSON flips the status to failed with a decode error and no relay.</summary>
|
||||
[Fact]
|
||||
public void Approve_CorruptListValueJson_FlipsStatusToFailed_DecodeError_NoRelay()
|
||||
{
|
||||
@@ -1059,6 +1098,7 @@ public class SecuredWriteHandlerTests : TestKit, IDisposable
|
||||
// Site scope on submit / approve / reject (arch-review R2 N3)
|
||||
// ------------------------------------------------------------------------
|
||||
|
||||
/// <summary>Verifies that a site-scoped operator submitting for an out-of-scope site is unauthorized and no row is inserted.</summary>
|
||||
[Fact]
|
||||
public void Submit_SiteScopedOperator_OutOfScopeSite_Unauthorized_NoRowInserted()
|
||||
{
|
||||
@@ -1071,6 +1111,7 @@ public class SecuredWriteHandlerTests : TestKit, IDisposable
|
||||
_securedWriteRepo.DidNotReceiveWithAnyArgs().AddAsync(default!, default);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a site-scoped operator submitting for an in-scope site succeeds.</summary>
|
||||
[Fact]
|
||||
public void Submit_SiteScopedOperator_InScopeSite_Succeeds()
|
||||
{
|
||||
@@ -1082,6 +1123,7 @@ public class SecuredWriteHandlerTests : TestKit, IDisposable
|
||||
ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a site-scoped verifier approving for an out-of-scope site is unauthorized and never relays.</summary>
|
||||
[Fact]
|
||||
public void Approve_SiteScopedVerifier_OutOfScopeSite_Unauthorized_NeverRelays()
|
||||
{
|
||||
@@ -1099,6 +1141,7 @@ public class SecuredWriteHandlerTests : TestKit, IDisposable
|
||||
Assert.Equal(0, _comms.CallCount); // the device write is NEVER relayed
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a site-scoped verifier rejecting for an out-of-scope site is unauthorized.</summary>
|
||||
[Fact]
|
||||
public void Reject_SiteScopedVerifier_OutOfScopeSite_Unauthorized()
|
||||
{
|
||||
@@ -1117,6 +1160,7 @@ public class SecuredWriteHandlerTests : TestKit, IDisposable
|
||||
default, default!, default, default, default);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a scoped administrator bypasses site scoping on approval.</summary>
|
||||
[Fact]
|
||||
public void Approve_ScopedAdministrator_BypassesSiteScope()
|
||||
{
|
||||
@@ -1140,7 +1184,7 @@ public class SecuredWriteHandlerTests : TestKit, IDisposable
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------------
|
||||
// Resolve-before-scope oracles (arch-review 2026-08-19 P6-5)
|
||||
// Resolve-before-scope oracles
|
||||
//
|
||||
// Submit/approve/reject used to answer "not found" (and, on approve/reject,
|
||||
// "is 'Executed', not Pending") BEFORE the scope gate ran, so an out-of-scope
|
||||
@@ -1158,6 +1202,7 @@ public class SecuredWriteHandlerTests : TestKit, IDisposable
|
||||
OperatorUser = "alice", SubmittedAtUtc = DateTime.UtcNow,
|
||||
});
|
||||
|
||||
/// <summary>Verifies that a site-scoped verifier approving a nonexistent id is unauthorized rather than not found.</summary>
|
||||
[Fact]
|
||||
public void Approve_SiteScopedVerifier_NonexistentId_Unauthorized_NotNotFound()
|
||||
{
|
||||
@@ -1170,6 +1215,7 @@ public class SecuredWriteHandlerTests : TestKit, IDisposable
|
||||
ExpectMsg<ManagementUnauthorized>(TimeSpan.FromSeconds(5));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a site-scoped verifier approving an out-of-scope executed row is unauthorized rather than a status error.</summary>
|
||||
[Fact]
|
||||
public void Approve_SiteScopedVerifier_OutOfScopeExecutedRow_Unauthorized_NotStatusError()
|
||||
{
|
||||
@@ -1185,6 +1231,7 @@ public class SecuredWriteHandlerTests : TestKit, IDisposable
|
||||
Assert.Equal(0, _comms.CallCount);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a system-wide verifier approving a nonexistent id still gets a not-found error.</summary>
|
||||
[Fact]
|
||||
public void Approve_SystemWideVerifier_NonexistentId_StillReportsNotFound()
|
||||
{
|
||||
@@ -1199,6 +1246,7 @@ public class SecuredWriteHandlerTests : TestKit, IDisposable
|
||||
Assert.Contains("not found", response.Error);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a site-scoped verifier rejecting a nonexistent id is unauthorized rather than not found.</summary>
|
||||
[Fact]
|
||||
public void Reject_SiteScopedVerifier_NonexistentId_Unauthorized_NotNotFound()
|
||||
{
|
||||
@@ -1211,6 +1259,7 @@ public class SecuredWriteHandlerTests : TestKit, IDisposable
|
||||
ExpectMsg<ManagementUnauthorized>(TimeSpan.FromSeconds(5));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a site-scoped verifier rejecting an out-of-scope executed row is unauthorized rather than a status error.</summary>
|
||||
[Fact]
|
||||
public void Reject_SiteScopedVerifier_OutOfScopeExecutedRow_Unauthorized_NotStatusError()
|
||||
{
|
||||
@@ -1225,11 +1274,12 @@ public class SecuredWriteHandlerTests : TestKit, IDisposable
|
||||
default, default!, default, default, default);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a system-wide verifier rejecting an executed row still gets a status conflict.</summary>
|
||||
[Fact]
|
||||
public void Reject_SystemWideVerifier_ExecutedRow_StillReportsStatusConflict()
|
||||
{
|
||||
// In-scope/unscoped behavior is unchanged: the status error still names
|
||||
// the status that blocked the transition (the P6-1 CAS shape is intact).
|
||||
// the status that blocked the transition (the compare-and-swap shape is intact).
|
||||
SeedExecutedWrite(42);
|
||||
|
||||
var actor = CreateActor();
|
||||
@@ -1239,6 +1289,7 @@ public class SecuredWriteHandlerTests : TestKit, IDisposable
|
||||
Assert.Contains("Executed", response.Error);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a site-scoped operator submitting for a nonexistent site is unauthorized rather than not found.</summary>
|
||||
[Fact]
|
||||
public void Submit_SiteScopedOperator_NonexistentSite_Unauthorized_NotNotFound()
|
||||
{
|
||||
@@ -1254,6 +1305,7 @@ public class SecuredWriteHandlerTests : TestKit, IDisposable
|
||||
_securedWriteRepo.DidNotReceiveWithAnyArgs().AddAsync(default!, default);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a system-wide operator submitting for a nonexistent site still gets a not-found error.</summary>
|
||||
[Fact]
|
||||
public void Submit_SystemWideOperator_NonexistentSite_StillReportsNotFound()
|
||||
{
|
||||
|
||||
@@ -22,6 +22,7 @@ public class TransportGateTests : TestKit, IDisposable
|
||||
private readonly IBundleImporter _importer;
|
||||
private readonly ServiceCollection _services;
|
||||
|
||||
/// <summary>Initializes the test with a substituted bundle importer registered in the service collection.</summary>
|
||||
public TransportGateTests()
|
||||
{
|
||||
_importer = Substitute.For<IBundleImporter>();
|
||||
@@ -40,8 +41,11 @@ public class TransportGateTests : TestKit, IDisposable
|
||||
new(new AuthenticatedUser("admin", "Admin", new[] { "Administrator" }, Array.Empty<string>()),
|
||||
command, Guid.NewGuid().ToString("N"));
|
||||
|
||||
/// <inheritdoc />
|
||||
void IDisposable.Dispose() => Shutdown();
|
||||
|
||||
/// <summary>Verifies that concurrent import-bundle commands are single-flighted.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task ImportBundle_ConcurrentCommands_AreSingleFlighted()
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user