513fa1aa16
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.
387 lines
17 KiB
C#
387 lines
17 KiB
C#
using Microsoft.EntityFrameworkCore;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Entities.SecuredWrites;
|
|
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase;
|
|
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Repositories;
|
|
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests.Migrations;
|
|
using Xunit;
|
|
|
|
namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests;
|
|
|
|
/// <summary>
|
|
/// Integration tests for <see cref="SecuredWriteRepository"/> (OPC UA / MxGateway
|
|
/// secured writes). Uses the same <see cref="MsSqlMigrationFixture"/> as the migration
|
|
/// tests so the EF reads/writes execute against the real <c>PendingSecuredWrites</c>
|
|
/// schema produced by the migration. Each test mints a fresh per-test
|
|
/// <c>SiteId</c>/<c>Status</c> suffix so tests neither collide nor require teardown.
|
|
/// Tests pair <see cref="SkippableFactAttribute"/> with <c>Skip.IfNot(...)</c> so the
|
|
/// runner reports them as Skipped (not Passed) when MSSQL is unreachable.
|
|
/// </summary>
|
|
public class SecuredWriteRepositoryTests : IClassFixture<MsSqlMigrationFixture>
|
|
{
|
|
private readonly MsSqlMigrationFixture _fixture;
|
|
|
|
/// <summary>Initializes the suite with the shared MS SQL migration fixture.</summary>
|
|
/// <param name="fixture">The shared MS SQL migration fixture.</param>
|
|
public SecuredWriteRepositoryTests(MsSqlMigrationFixture fixture)
|
|
{
|
|
_fixture = fixture;
|
|
}
|
|
|
|
/// <summary>Verifies that a pending secured write round-trips through add, get, update and query.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[SkippableFact]
|
|
public async Task Lifecycle_AddGetUpdateQuery_RoundTrips()
|
|
{
|
|
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
|
|
|
|
var siteId = NewSiteId();
|
|
|
|
// Add a Pending row.
|
|
await using var context = CreateContext();
|
|
var repo = new SecuredWriteRepository(context);
|
|
|
|
var pending = NewRow(siteId, status: "Pending");
|
|
var id = await repo.AddAsync(pending);
|
|
Assert.True(id > 0, "AddAsync should return the store-generated identity.");
|
|
|
|
// Get returns it.
|
|
await using (var readContext = CreateContext())
|
|
{
|
|
var loaded = await new SecuredWriteRepository(readContext).GetAsync(id);
|
|
Assert.NotNull(loaded);
|
|
Assert.Equal(siteId, loaded!.SiteId);
|
|
Assert.Equal("Pending", loaded.Status);
|
|
Assert.Equal("conn-1", loaded.ConnectionName);
|
|
Assert.Equal("Plant.Tank.Setpoint", loaded.TagPath);
|
|
Assert.Equal("op.alice", loaded.OperatorUser);
|
|
Assert.Null(loaded.VerifierUser);
|
|
Assert.Null(loaded.DecidedAtUtc);
|
|
}
|
|
|
|
// Update to Approved.
|
|
await using (var updateContext = CreateContext())
|
|
{
|
|
var updateRepo = new SecuredWriteRepository(updateContext);
|
|
var toApprove = await updateRepo.GetAsync(id);
|
|
Assert.NotNull(toApprove);
|
|
toApprove!.Status = "Approved";
|
|
toApprove.VerifierUser = "ver.bob";
|
|
toApprove.VerifierComment = "looks good";
|
|
toApprove.DecidedAtUtc = DateTime.UtcNow;
|
|
await updateRepo.UpdateAsync(toApprove);
|
|
}
|
|
|
|
// Get reflects the update.
|
|
await using (var verifyContext = CreateContext())
|
|
{
|
|
var reloaded = await new SecuredWriteRepository(verifyContext).GetAsync(id);
|
|
Assert.NotNull(reloaded);
|
|
Assert.Equal("Approved", reloaded!.Status);
|
|
Assert.Equal("ver.bob", reloaded.VerifierUser);
|
|
Assert.Equal("looks good", reloaded.VerifierComment);
|
|
Assert.NotNull(reloaded.DecidedAtUtc);
|
|
}
|
|
|
|
// Query by status returns it.
|
|
await using (var queryContext = CreateContext())
|
|
{
|
|
var queryRepo = new SecuredWriteRepository(queryContext);
|
|
var byStatus = await queryRepo.QueryAsync(status: "Approved", siteId: siteId, skip: 0, take: 50);
|
|
Assert.Contains(byStatus, p => p.Id == id);
|
|
Assert.All(byStatus, p => Assert.Equal("Approved", p.Status));
|
|
|
|
// A non-matching status filter excludes the row.
|
|
var pendingPage = await queryRepo.QueryAsync(status: "Pending", siteId: siteId, skip: 0, take: 50);
|
|
Assert.DoesNotContain(pendingPage, p => p.Id == id);
|
|
}
|
|
}
|
|
|
|
/// <summary>Verifies that null filters match every status for the site.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[SkippableFact]
|
|
public async Task QueryAsync_NullFilters_MatchEveryStatusForSite()
|
|
{
|
|
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
|
|
|
|
var siteId = NewSiteId();
|
|
await using var context = CreateContext();
|
|
var repo = new SecuredWriteRepository(context);
|
|
|
|
var pendingId = await repo.AddAsync(NewRow(siteId, status: "Pending"));
|
|
var executedId = await repo.AddAsync(NewRow(siteId, status: "Executed"));
|
|
|
|
var all = await repo.QueryAsync(status: null, siteId: siteId, skip: 0, take: 50);
|
|
Assert.Contains(all, p => p.Id == pendingId);
|
|
Assert.Contains(all, p => p.Id == executedId);
|
|
}
|
|
|
|
/// <summary>Verifies that a pending row flips to expired exactly once.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[SkippableFact]
|
|
public async Task TryMarkExpiredAsync_PendingRow_FlipsToExpired_Once()
|
|
{
|
|
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
|
|
|
|
var siteId = NewSiteId();
|
|
await using var context = CreateContext();
|
|
var repo = new SecuredWriteRepository(context);
|
|
|
|
var id = await repo.AddAsync(NewRow(siteId, status: "Pending"));
|
|
|
|
Assert.True(await repo.TryMarkExpiredAsync(id, DateTime.UtcNow));
|
|
// CAS: a second caller loses because the row is no longer Pending.
|
|
Assert.False(await repo.TryMarkExpiredAsync(id, DateTime.UtcNow));
|
|
|
|
await using var readContext = CreateContext();
|
|
var row = await new SecuredWriteRepository(readContext).GetAsync(id);
|
|
Assert.NotNull(row);
|
|
Assert.Equal("Expired", row!.Status);
|
|
Assert.NotNull(row.DecidedAtUtc);
|
|
Assert.Null(row.VerifierUser); // system decision, no verifier
|
|
}
|
|
|
|
/// <summary>Verifies that marking an already decided row expired returns false.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[SkippableFact]
|
|
public async Task TryMarkExpiredAsync_DecidedRow_ReturnsFalse()
|
|
{
|
|
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
|
|
|
|
var siteId = NewSiteId();
|
|
await using var context = CreateContext();
|
|
var repo = new SecuredWriteRepository(context);
|
|
|
|
var id = await repo.AddAsync(NewRow(siteId, status: "Pending"));
|
|
Assert.True(await repo.TryMarkApprovedAsync(id, "ver.bob", null, DateTime.UtcNow));
|
|
|
|
// Already Approved -> the conditional WHERE Status='Pending' matches no row.
|
|
Assert.False(await repo.TryMarkExpiredAsync(id, DateTime.UtcNow));
|
|
}
|
|
|
|
/// <summary>Verifies that a pending row flips to rejected exactly once.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[SkippableFact]
|
|
public async Task TryMarkRejectedAsync_PendingRow_FlipsToRejected_Once()
|
|
{
|
|
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
|
|
|
|
var siteId = NewSiteId();
|
|
await using var context = CreateContext();
|
|
var repo = new SecuredWriteRepository(context);
|
|
|
|
var id = await repo.AddAsync(NewRow(siteId, status: "Pending"));
|
|
|
|
Assert.True(await repo.TryMarkRejectedAsync(id, "ver.bob", "not authorized", DateTime.UtcNow));
|
|
// CAS: a second caller loses because the row is no longer Pending.
|
|
Assert.False(await repo.TryMarkRejectedAsync(id, "ver.carol", "also no", DateTime.UtcNow));
|
|
|
|
await using var readContext = CreateContext();
|
|
var row = await new SecuredWriteRepository(readContext).GetAsync(id);
|
|
Assert.NotNull(row);
|
|
Assert.Equal("Rejected", row!.Status);
|
|
Assert.Equal("ver.bob", row.VerifierUser);
|
|
Assert.Equal("not authorized", row.VerifierComment);
|
|
Assert.NotNull(row.DecidedAtUtc);
|
|
// Reject writes only the columns its transition owns — the operator's submission
|
|
// and the (never-run) execution columns are untouched.
|
|
Assert.Equal("op.alice", row.OperatorUser);
|
|
Assert.Null(row.ExecutedAtUtc);
|
|
Assert.Null(row.ExecutionError);
|
|
}
|
|
|
|
/// <summary>Verifies that when an approve is followed by a reject on one pending row, the approve wins and the reject returns false.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[SkippableFact]
|
|
public async Task ApproveThenReject_OnOnePendingRow_ApproveWins_RejectReturnsFalse()
|
|
{
|
|
// Approve and reject both transition out of Pending,
|
|
// so exactly one may win. This is the ordering that used to falsify the
|
|
// two-person-control record — a blind reject update landing after approve had
|
|
// already committed (and, in production, while its multi-second site relay was
|
|
// still in flight) rewrote the row to Rejected for a write that executed.
|
|
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
|
|
|
|
var siteId = NewSiteId();
|
|
await using var context = CreateContext();
|
|
var repo = new SecuredWriteRepository(context);
|
|
|
|
var id = await repo.AddAsync(NewRow(siteId, status: "Pending"));
|
|
|
|
Assert.True(await repo.TryMarkApprovedAsync(id, "ver.bob", "ok", DateTime.UtcNow));
|
|
Assert.False(await repo.TryMarkRejectedAsync(id, "ver.carol", "no", DateTime.UtcNow));
|
|
|
|
await using var readContext = CreateContext();
|
|
var row = await new SecuredWriteRepository(readContext).GetAsync(id);
|
|
Assert.NotNull(row);
|
|
// The row reflects the winner ONLY — no trace of the losing reject.
|
|
Assert.Equal("Approved", row!.Status);
|
|
Assert.Equal("ver.bob", row.VerifierUser);
|
|
Assert.Equal("ok", row.VerifierComment);
|
|
}
|
|
|
|
/// <summary>Verifies that when a reject is followed by an approve on one pending row, the reject wins and the approve returns false.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[SkippableFact]
|
|
public async Task RejectThenApprove_OnOnePendingRow_RejectWins_ApproveReturnsFalse()
|
|
{
|
|
// The opposite order: once rejected, the row can never be approved (and therefore
|
|
// never relayed to the plant).
|
|
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
|
|
|
|
var siteId = NewSiteId();
|
|
await using var context = CreateContext();
|
|
var repo = new SecuredWriteRepository(context);
|
|
|
|
var id = await repo.AddAsync(NewRow(siteId, status: "Pending"));
|
|
|
|
Assert.True(await repo.TryMarkRejectedAsync(id, "ver.carol", "no", DateTime.UtcNow));
|
|
Assert.False(await repo.TryMarkApprovedAsync(id, "ver.bob", "ok", DateTime.UtcNow));
|
|
|
|
await using var readContext = CreateContext();
|
|
var row = await new SecuredWriteRepository(readContext).GetAsync(id);
|
|
Assert.NotNull(row);
|
|
Assert.Equal("Rejected", row!.Status);
|
|
Assert.Equal("ver.carol", row.VerifierUser);
|
|
Assert.Equal("no", row.VerifierComment);
|
|
}
|
|
|
|
/// <summary>Verifies that rejecting an expired row returns false.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[SkippableFact]
|
|
public async Task TryMarkRejectedAsync_ExpiredRow_ReturnsFalse()
|
|
{
|
|
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
|
|
|
|
var siteId = NewSiteId();
|
|
await using var context = CreateContext();
|
|
var repo = new SecuredWriteRepository(context);
|
|
|
|
var id = await repo.AddAsync(NewRow(siteId, status: "Pending"));
|
|
Assert.True(await repo.TryMarkExpiredAsync(id, DateTime.UtcNow));
|
|
|
|
// Already Expired -> the conditional WHERE Status='Pending' matches no row.
|
|
Assert.False(await repo.TryMarkRejectedAsync(id, "ver.bob", null, DateTime.UtcNow));
|
|
|
|
await using var readContext = CreateContext();
|
|
var row = await new SecuredWriteRepository(readContext).GetAsync(id);
|
|
Assert.Equal("Expired", row!.Status);
|
|
Assert.Null(row.VerifierUser);
|
|
}
|
|
|
|
/// <summary>Verifies that the count applies the same filters as the query.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[SkippableFact]
|
|
public async Task CountAsync_FiltersLikeQueryAsync()
|
|
{
|
|
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
|
|
|
|
var siteId = NewSiteId();
|
|
await using var context = CreateContext();
|
|
var repo = new SecuredWriteRepository(context);
|
|
|
|
await repo.AddAsync(NewRow(siteId, status: "Pending"));
|
|
await repo.AddAsync(NewRow(siteId, status: "Pending"));
|
|
|
|
Assert.Equal(2, await repo.CountAsync(status: "Pending", siteId: siteId));
|
|
Assert.Equal(0, await repo.CountAsync(status: "Executed", siteId: siteId));
|
|
}
|
|
|
|
/// <summary>Verifies that a query with permitted site ids returns only rows for those sites.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[SkippableFact]
|
|
public async Task QueryAsync_PermittedSiteIds_ReturnsOnlyPermittedRows()
|
|
{
|
|
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
|
|
|
|
// Distinct per-run site identifiers stand in for "SITE1"/"SITE2"/"SITE3" so the
|
|
// permitted-set filter is asserted deterministically against a shared MSSQL.
|
|
var site1 = NewSiteId();
|
|
var site2 = NewSiteId();
|
|
var site3 = NewSiteId();
|
|
await using var context = CreateContext();
|
|
var repo = new SecuredWriteRepository(context);
|
|
|
|
await repo.AddAsync(NewRow(site1, status: "Pending"));
|
|
await repo.AddAsync(NewRow(site1, status: "Pending"));
|
|
await repo.AddAsync(NewRow(site2, status: "Pending"));
|
|
await repo.AddAsync(NewRow(site3, status: "Pending"));
|
|
|
|
var rows = await repo.QueryAsync(status: null, siteId: null, skip: 0, take: 100,
|
|
permittedSiteIds: new[] { site1, site3 });
|
|
Assert.Equal(3, rows.Count);
|
|
Assert.DoesNotContain(rows, r => r.SiteId == site2);
|
|
}
|
|
|
|
/// <summary>Verifies that a count with permitted site ids counts only rows for those sites.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[SkippableFact]
|
|
public async Task CountAsync_PermittedSiteIds_CountsOnlyPermittedRows()
|
|
{
|
|
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
|
|
|
|
var site1 = NewSiteId();
|
|
var site2 = NewSiteId();
|
|
var site3 = NewSiteId();
|
|
await using var context = CreateContext();
|
|
var repo = new SecuredWriteRepository(context);
|
|
|
|
await repo.AddAsync(NewRow(site1, status: "Pending"));
|
|
await repo.AddAsync(NewRow(site1, status: "Pending"));
|
|
await repo.AddAsync(NewRow(site2, status: "Pending"));
|
|
await repo.AddAsync(NewRow(site3, status: "Pending"));
|
|
|
|
var count = await repo.CountAsync(status: null, siteId: null,
|
|
permittedSiteIds: new[] { site1, site3 });
|
|
Assert.Equal(3, count);
|
|
}
|
|
|
|
/// <summary>Verifies that a null permitted-site list leaves the query unfiltered, for backward compatibility.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[SkippableFact]
|
|
public async Task QueryAsync_NullPermittedSiteIds_IsUnfiltered_BackCompat()
|
|
{
|
|
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
|
|
|
|
// 4 rows on a single unique site; null permittedSiteIds must not filter them
|
|
// out — exactly today's behavior.
|
|
var siteId = NewSiteId();
|
|
await using var context = CreateContext();
|
|
var repo = new SecuredWriteRepository(context);
|
|
|
|
await repo.AddAsync(NewRow(siteId, status: "Pending"));
|
|
await repo.AddAsync(NewRow(siteId, status: "Pending"));
|
|
await repo.AddAsync(NewRow(siteId, status: "Executed"));
|
|
await repo.AddAsync(NewRow(siteId, status: "Rejected"));
|
|
|
|
var rows = await repo.QueryAsync(status: null, siteId: siteId, skip: 0, take: 100,
|
|
permittedSiteIds: null);
|
|
Assert.Equal(4, rows.Count);
|
|
}
|
|
|
|
// --- helpers ------------------------------------------------------------
|
|
|
|
private ScadaBridgeDbContext CreateContext()
|
|
{
|
|
var options = new DbContextOptionsBuilder<ScadaBridgeDbContext>()
|
|
.UseSqlServer(_fixture.ConnectionString)
|
|
.Options;
|
|
return new ScadaBridgeDbContext(options);
|
|
}
|
|
|
|
private static string NewSiteId() =>
|
|
"site-t14b-" + Guid.NewGuid().ToString("N").Substring(0, 8);
|
|
|
|
private static PendingSecuredWrite NewRow(string siteId, string status) => new()
|
|
{
|
|
SiteId = siteId,
|
|
ConnectionName = "conn-1",
|
|
TagPath = "Plant.Tank.Setpoint",
|
|
ValueJson = "42.5",
|
|
ValueType = "Double",
|
|
Status = status,
|
|
OperatorUser = "op.alice",
|
|
OperatorComment = "raise setpoint",
|
|
SubmittedAtUtc = DateTime.UtcNow,
|
|
};
|
|
}
|