fix(audit): ScadaBridge C4 review — enable PRAGMA foreign_keys + MarkForwarded state guard (no Reconciled demotion) + test (Task 2.5)

This commit is contained in:
Joseph Doherty
2026-06-02 13:23:36 -04:00
parent 946d3e2aef
commit 1737d15f04
2 changed files with 106 additions and 1 deletions
@@ -524,6 +524,72 @@ public class SqliteAuditWriterWriteTests
// Completes without throwing.
}
/// <summary>
/// Fix 2 / M1 state guard: <see cref="SqliteAuditWriter.MarkForwardedAsync"/>
/// must NOT demote a <see cref="AuditForwardState.Reconciled"/> row back to
/// <see cref="AuditForwardState.Forwarded"/>. When a batch contains both a
/// Pending ID and an already-Reconciled ID:
/// <list type="bullet">
/// <item>the Pending row transitions to Forwarded (normal path)</item>
/// <item>the Reconciled row stays Reconciled (AttemptCount unchanged)</item>
/// </list>
/// This mirrors the idempotency guard already present on
/// <see cref="SqliteAuditWriter.MarkReconciledAsync"/>.
/// </summary>
[Fact]
public async Task MarkForwardedAsync_DoesNotDemoteReconciledRow_WhilePendingStillTransitions()
{
var (writer, dataSource) = CreateWriter(
nameof(MarkForwardedAsync_DoesNotDemoteReconciledRow_WhilePendingStillTransitions));
await using var _ = writer;
var pending = NewEvent();
var reconciled = NewEvent();
await writer.WriteAsync(pending);
await writer.WriteAsync(reconciled);
// Advance reconciled through Forwarded → Reconciled so its AttemptCount = 1.
await writer.MarkForwardedAsync(new[] { reconciled.EventId });
await writer.MarkReconciledAsync(new[] { reconciled.EventId });
// Verify the reconciled row's AttemptCount is 1 before the test call.
using var conn = OpenVerifierConnection(dataSource);
long reconciledAttemptBefore;
using (var cmd = conn.CreateCommand())
{
cmd.CommandText =
"SELECT AttemptCount FROM audit_forward_state WHERE EventId = $id;";
cmd.Parameters.AddWithValue("$id", reconciled.EventId.ToString());
reconciledAttemptBefore = Convert.ToInt64(cmd.ExecuteScalar());
}
Assert.Equal(1L, reconciledAttemptBefore);
// Now call MarkForwardedAsync with BOTH IDs in the same batch.
await writer.MarkForwardedAsync(new[] { pending.EventId, reconciled.EventId });
// The Pending row must have transitioned to Forwarded.
Assert.Equal(
AuditForwardState.Forwarded.ToString(),
ReadForwardState(dataSource, pending.EventId));
// The Reconciled row must remain Reconciled — the state guard must have
// excluded it from the UPDATE.
Assert.Equal(
AuditForwardState.Reconciled.ToString(),
ReadForwardState(dataSource, reconciled.EventId));
// AttemptCount on the Reconciled row must be unchanged (still 1, not 2).
using (var cmd = conn.CreateCommand())
{
cmd.CommandText =
"SELECT AttemptCount FROM audit_forward_state WHERE EventId = $id;";
cmd.Parameters.AddWithValue("$id", reconciled.EventId.ToString());
var attemptAfter = Convert.ToInt64(cmd.ExecuteScalar());
Assert.Equal(reconciledAttemptBefore, attemptAfter);
}
}
// ----- ExecutionId (rides DetailsJson, recomposed via AsRow) ----- //
[Fact]