fix(management): CAS-guard secured-write reject (WHERE Status='Pending') so reject can never falsify an executed two-person write (arch-review 2026-08-19 P6-1 High)
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).
This commit is contained in:
+102
@@ -148,6 +148,108 @@ public class SecuredWriteRepositoryTests : IClassFixture<MsSqlMigrationFixture>
|
||||
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()
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user