61a14bccab
Reject read the row as Pending, then landed a blind full-row UpdateAsync. A concurrent approve could win its own Pending->Approved CAS in that gap and enter the multi-second WriteTagAsync relay; the reject's update then stamped Status/ExecutedAtUtc/ExecutionError to Rejected mid-relay, so the two-person-control record said Rejected for a write that physically executed at the plant. Works cross-node (both centrals share one MS SQL). - ISecuredWriteRepository/SecuredWriteRepository: new TryMarkRejectedAsync, mirroring TryMarkApprovedAsync exactly (conditional UPDATE ... WHERE Status='Pending', writes only the columns the reject transition owns). No schema change, no migration, no rowversion column. - HandleRejectSecuredWrite: uses the CAS; on loss re-reads the row and returns the status that actually won (same UX shape as approve's lost-race error), and emits the SecuredWriteReject audit row ONLY on a won CAS. - Audited the other lifecycle writes: approve/reject/expiry (inline + the opportunistic list sweep) now ALL transition only out of Pending, so winning the approve CAS gives that leg exclusive ownership of the row and its post-relay Executed/Failed update stays safe as an unconditional write. Documented in place. - Tests: repository-level approve-vs-reject races in both orders (exactly one wins, loser false, row reflects the winner only) plus reject-vs-expiry; handler-level lost-race reject (conflict error, no write, no audit row) and approve on an already-rejected row (no CAS, no relay, no audit).
361 lines
15 KiB
C#
361 lines
15 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"/> (M7 OPC UA / MxGateway
|
|
/// UX, Task T14b). 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;
|
|
|
|
public SecuredWriteRepositoryTests(MsSqlMigrationFixture fixture)
|
|
{
|
|
_fixture = fixture;
|
|
}
|
|
|
|
[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);
|
|
}
|
|
}
|
|
|
|
[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);
|
|
}
|
|
|
|
[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
|
|
}
|
|
|
|
[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));
|
|
}
|
|
|
|
[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);
|
|
}
|
|
|
|
[SkippableFact]
|
|
public async Task ApproveThenReject_OnOnePendingRow_ApproveWins_RejectReturnsFalse()
|
|
{
|
|
// arch-review 2026-08-19 P6-1: 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);
|
|
}
|
|
|
|
[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);
|
|
}
|
|
|
|
[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);
|
|
}
|
|
|
|
[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));
|
|
}
|
|
|
|
[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);
|
|
}
|
|
|
|
[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);
|
|
}
|
|
|
|
[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,
|
|
};
|
|
}
|