feat(audit): ScadaBridge C4 — site SQLite two-table (audit_event canonical + audit_forward_state sidecar), forwarding on sidecar, IsCachedKind drain split (Task 2.5)
This commit is contained in:
@@ -11,12 +11,13 @@ using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
||||
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Site;
|
||||
|
||||
/// <summary>
|
||||
/// Bundle B (M2-T2) hot-path tests for <see cref="SqliteAuditWriter"/>. Exercise
|
||||
/// the Channel-based enqueue, the background writer's batch INSERTs, duplicate-
|
||||
/// EventId swallowing, ForwardState defaulting, and the
|
||||
/// <see cref="SqliteAuditWriter.ReadPendingAsync"/> /
|
||||
/// <see cref="SqliteAuditWriter.MarkForwardedAsync"/> support surface that
|
||||
/// Bundle D's telemetry actor will call.
|
||||
/// C4 (Task 2.5) hot-path + drain tests for <see cref="SqliteAuditWriter"/>'s
|
||||
/// two-table site schema. Exercise the Channel-based enqueue, the background
|
||||
/// writer's per-event canonical(<c>audit_event</c>) + sidecar
|
||||
/// (<c>audit_forward_state</c>) INSERTs, duplicate-EventId swallowing, the
|
||||
/// <c>IsCachedKind</c> drain split, the four reads, and the
|
||||
/// <see cref="SqliteAuditWriter.MarkForwardedAsync"/> /
|
||||
/// <see cref="SqliteAuditWriter.MarkReconciledAsync"/> sidecar flips.
|
||||
/// </summary>
|
||||
public class SqliteAuditWriterWriteTests
|
||||
{
|
||||
@@ -52,10 +53,40 @@ public class SqliteAuditWriterWriteTests
|
||||
return connection;
|
||||
}
|
||||
|
||||
// C3 (Task 2.5): build the canonical ZB.MOM.WW.Audit.AuditEvent via the shared
|
||||
// factory. The SQLite writer's transitional shim decomposes it into the 24 columns
|
||||
// (defaulting ForwardState=Pending) on INSERT and recomposes the canonical record
|
||||
// on read. ExecutionId/SourceNode ride through DetailsJson / the top-level field.
|
||||
/// <summary>
|
||||
/// Reads the sidecar <c>ForwardState</c> for one EventId (the column moved off
|
||||
/// the single legacy table onto <c>audit_forward_state</c> in C4).
|
||||
/// </summary>
|
||||
private static string? ReadForwardState(string dataSource, Guid eventId)
|
||||
{
|
||||
using var connection = OpenVerifierConnection(dataSource);
|
||||
using var cmd = connection.CreateCommand();
|
||||
cmd.CommandText = "SELECT ForwardState FROM audit_forward_state WHERE EventId = $id;";
|
||||
cmd.Parameters.AddWithValue("$id", eventId.ToString());
|
||||
return cmd.ExecuteScalar() as string;
|
||||
}
|
||||
|
||||
/// <summary>Sidecar ForwardState → row-count, grouped (replaces the legacy single-table GROUP BY).</summary>
|
||||
private static Dictionary<string, long> ForwardStateCounts(string dataSource)
|
||||
{
|
||||
using var connection = OpenVerifierConnection(dataSource);
|
||||
using var cmd = connection.CreateCommand();
|
||||
cmd.CommandText =
|
||||
"SELECT ForwardState, COUNT(*) FROM audit_forward_state GROUP BY ForwardState;";
|
||||
using var reader = cmd.ExecuteReader();
|
||||
var byState = new Dictionary<string, long>();
|
||||
while (reader.Read())
|
||||
{
|
||||
byState[reader.GetString(0)] = reader.GetInt64(1);
|
||||
}
|
||||
return byState;
|
||||
}
|
||||
|
||||
// C4 (Task 2.5): build the canonical ZB.MOM.WW.Audit.AuditEvent via the shared
|
||||
// factory. The SQLite writer stores the 10 canonical fields directly in
|
||||
// audit_event and writes a Pending sidecar row into audit_forward_state, with
|
||||
// IsCachedKind precomputed from the event's Kind. Reads recompose the canonical
|
||||
// record directly from audit_event's columns.
|
||||
private static AuditEvent NewEvent(
|
||||
Guid? id = null,
|
||||
DateTime? occurredAtUtc = null,
|
||||
@@ -70,22 +101,62 @@ public class SqliteAuditWriterWriteTests
|
||||
executionId: executionId,
|
||||
sourceNode: sourceNode);
|
||||
|
||||
/// <summary>A cached-lifecycle event (IsCachedKind=1) — drains via the cached read surface.</summary>
|
||||
private static AuditEvent NewCachedEvent(
|
||||
Guid? id = null,
|
||||
DateTime? occurredAtUtc = null,
|
||||
AuditKind kind = AuditKind.ApiCallCached)
|
||||
// Status is independent of IsCachedKind (which is derived from Kind);
|
||||
// Submitted is the natural first-row status for a cached lifecycle.
|
||||
=> ScadaBridgeAuditEventFactory.Create(
|
||||
channel: AuditChannel.ApiOutbound,
|
||||
kind: kind,
|
||||
status: AuditStatus.Submitted,
|
||||
eventId: id ?? Guid.NewGuid(),
|
||||
occurredAtUtc: occurredAtUtc ?? DateTime.UtcNow);
|
||||
|
||||
[Fact]
|
||||
public async Task WriteAsync_FreshEvent_PersistsWithForwardStatePending()
|
||||
public async Task WriteAsync_FreshEvent_PersistsCanonical_And_SidecarPending()
|
||||
{
|
||||
var (writer, dataSource) = CreateWriter(nameof(WriteAsync_FreshEvent_PersistsWithForwardStatePending));
|
||||
var (writer, dataSource) = CreateWriter(nameof(WriteAsync_FreshEvent_PersistsCanonical_And_SidecarPending));
|
||||
await using var _ = writer;
|
||||
|
||||
var evt = NewEvent();
|
||||
await writer.WriteAsync(evt);
|
||||
|
||||
// Canonical row landed in audit_event.
|
||||
using var connection = OpenVerifierConnection(dataSource);
|
||||
using var cmd = connection.CreateCommand();
|
||||
cmd.CommandText = "SELECT ForwardState FROM AuditLog WHERE EventId = $id;";
|
||||
cmd.Parameters.AddWithValue("$id", evt.EventId.ToString());
|
||||
var actual = cmd.ExecuteScalar() as string;
|
||||
using var eventCmd = connection.CreateCommand();
|
||||
eventCmd.CommandText = "SELECT Action FROM audit_event WHERE EventId = $id;";
|
||||
eventCmd.Parameters.AddWithValue("$id", evt.EventId.ToString());
|
||||
Assert.Equal(evt.Action, eventCmd.ExecuteScalar() as string);
|
||||
|
||||
Assert.Equal(AuditForwardState.Pending.ToString(), actual);
|
||||
// Sidecar row landed Pending.
|
||||
Assert.Equal(AuditForwardState.Pending.ToString(), ReadForwardState(dataSource, evt.EventId));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task WriteAsync_Roundtrips_Canonical_Fields_Through_Read()
|
||||
{
|
||||
var (writer, _) = CreateWriter(nameof(WriteAsync_Roundtrips_Canonical_Fields_Through_Read));
|
||||
await using var _w = writer;
|
||||
|
||||
var evt = NewEvent() with { Target = "target-1", Actor = "user-1" };
|
||||
await writer.WriteAsync(evt);
|
||||
|
||||
var rows = await writer.ReadPendingAsync(limit: 10);
|
||||
var row = Assert.Single(rows);
|
||||
|
||||
Assert.Equal(evt.EventId, row.EventId);
|
||||
Assert.Equal(evt.OccurredAtUtc, row.OccurredAtUtc);
|
||||
Assert.Equal("user-1", row.Actor);
|
||||
Assert.Equal(evt.Action, row.Action);
|
||||
Assert.Equal(evt.Outcome, row.Outcome);
|
||||
Assert.Equal(evt.Category, row.Category);
|
||||
Assert.Equal("target-1", row.Target);
|
||||
Assert.Equal(evt.CorrelationId, row.CorrelationId);
|
||||
// DetailsJson is stored verbatim and round-trips byte-for-byte.
|
||||
Assert.Equal(evt.DetailsJson, row.DetailsJson);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -100,11 +171,14 @@ public class SqliteAuditWriterWriteTests
|
||||
async (evt, ct) => await writer.WriteAsync(evt, ct));
|
||||
|
||||
using var connection = OpenVerifierConnection(dataSource);
|
||||
using var cmd = connection.CreateCommand();
|
||||
cmd.CommandText = "SELECT COUNT(*) FROM AuditLog;";
|
||||
var count = Convert.ToInt64(cmd.ExecuteScalar());
|
||||
using var eventCmd = connection.CreateCommand();
|
||||
eventCmd.CommandText = "SELECT COUNT(*) FROM audit_event;";
|
||||
Assert.Equal(1000, Convert.ToInt64(eventCmd.ExecuteScalar()));
|
||||
|
||||
Assert.Equal(1000, count);
|
||||
// Every canonical row has its matching sidecar row.
|
||||
using var sidecarCmd = connection.CreateCommand();
|
||||
sidecarCmd.CommandText = "SELECT COUNT(*) FROM audit_forward_state;";
|
||||
Assert.Equal(1000, Convert.ToInt64(sidecarCmd.ExecuteScalar()));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -122,36 +196,98 @@ public class SqliteAuditWriterWriteTests
|
||||
|
||||
using var connection = OpenVerifierConnection(dataSource);
|
||||
using var countCmd = connection.CreateCommand();
|
||||
countCmd.CommandText = "SELECT COUNT(*) FROM AuditLog WHERE EventId = $id;";
|
||||
countCmd.CommandText = "SELECT COUNT(*) FROM audit_event WHERE EventId = $id;";
|
||||
countCmd.Parameters.AddWithValue("$id", sharedId.ToString());
|
||||
var count = Convert.ToInt64(countCmd.ExecuteScalar());
|
||||
Assert.Equal(1, Convert.ToInt64(countCmd.ExecuteScalar()));
|
||||
|
||||
Assert.Equal(1, count);
|
||||
// The sidecar likewise gained exactly one row (the canonical PK throws
|
||||
// before the sidecar insert runs, so neither table double-inserts).
|
||||
using var sidecarCmd = connection.CreateCommand();
|
||||
sidecarCmd.CommandText = "SELECT COUNT(*) FROM audit_forward_state WHERE EventId = $id;";
|
||||
sidecarCmd.Parameters.AddWithValue("$id", sharedId.ToString());
|
||||
Assert.Equal(1, Convert.ToInt64(sidecarCmd.ExecuteScalar()));
|
||||
|
||||
using var targetCmd = connection.CreateCommand();
|
||||
targetCmd.CommandText = "SELECT Target FROM AuditLog WHERE EventId = $id;";
|
||||
targetCmd.CommandText = "SELECT Target FROM audit_event WHERE EventId = $id;";
|
||||
targetCmd.Parameters.AddWithValue("$id", sharedId.ToString());
|
||||
Assert.Equal("first", targetCmd.ExecuteScalar() as string);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task WriteAsync_ForcesForwardStatePending_IfNull()
|
||||
public async Task WriteAsync_ForcesSidecarForwardStatePending()
|
||||
{
|
||||
var (writer, dataSource) = CreateWriter(nameof(WriteAsync_ForcesForwardStatePending_IfNull));
|
||||
var (writer, dataSource) = CreateWriter(nameof(WriteAsync_ForcesSidecarForwardStatePending));
|
||||
await using var _ = writer;
|
||||
|
||||
// C3 (Task 2.5): ForwardState is no longer a field on the canonical record;
|
||||
// a fresh canonical event carries none, and the SQLite shim defaults it to
|
||||
// Pending on INSERT — exactly the behaviour this test pins.
|
||||
// C4 (Task 2.5): ForwardState is not a field on the canonical record; a
|
||||
// fresh event's sidecar row defaults to Pending on INSERT.
|
||||
var evt = NewEvent();
|
||||
await writer.WriteAsync(evt);
|
||||
|
||||
Assert.Equal(AuditForwardState.Pending.ToString(), ReadForwardState(dataSource, evt.EventId));
|
||||
}
|
||||
|
||||
// ----- IsCachedKind drain split (precomputed at insert) ----- //
|
||||
|
||||
[Fact]
|
||||
public async Task WriteAsync_CachedKind_SetsIsCachedKind_1_NonCached_0()
|
||||
{
|
||||
var (writer, dataSource) = CreateWriter(nameof(WriteAsync_CachedKind_SetsIsCachedKind_1_NonCached_0));
|
||||
await using var _ = writer;
|
||||
|
||||
var cached = NewCachedEvent(); // ApiCallCached → cached
|
||||
var nonCached = NewEvent(); // ApiCall → not cached
|
||||
await writer.WriteAsync(cached);
|
||||
await writer.WriteAsync(nonCached);
|
||||
|
||||
using var connection = OpenVerifierConnection(dataSource);
|
||||
using var cmd = connection.CreateCommand();
|
||||
cmd.CommandText = "SELECT ForwardState FROM AuditLog WHERE EventId = $id;";
|
||||
cmd.Parameters.AddWithValue("$id", evt.EventId.ToString());
|
||||
cmd.CommandText = "SELECT IsCachedKind FROM audit_forward_state WHERE EventId = $id;";
|
||||
var p = cmd.Parameters.Add("$id", SqliteType.Text);
|
||||
|
||||
Assert.Equal(AuditForwardState.Pending.ToString(), cmd.ExecuteScalar() as string);
|
||||
p.Value = cached.EventId.ToString();
|
||||
Assert.Equal(1L, Convert.ToInt64(cmd.ExecuteScalar()));
|
||||
|
||||
p.Value = nonCached.EventId.ToString();
|
||||
Assert.Equal(0L, Convert.ToInt64(cmd.ExecuteScalar()));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(AuditKind.CachedSubmit)]
|
||||
[InlineData(AuditKind.ApiCallCached)]
|
||||
[InlineData(AuditKind.DbWriteCached)]
|
||||
[InlineData(AuditKind.CachedResolve)]
|
||||
public async Task CachedKinds_DrainVia_ReadPendingCachedTelemetry_Not_ReadPending(AuditKind kind)
|
||||
{
|
||||
var (writer, _) = CreateWriter($"{nameof(CachedKinds_DrainVia_ReadPendingCachedTelemetry_Not_ReadPending)}-{kind}");
|
||||
await using var _w = writer;
|
||||
|
||||
var cached = NewCachedEvent(kind: kind);
|
||||
await writer.WriteAsync(cached);
|
||||
|
||||
// The cached kind appears in the cached read surface...
|
||||
var cachedRows = await writer.ReadPendingCachedTelemetryAsync(limit: 10);
|
||||
Assert.Single(cachedRows, r => r.EventId == cached.EventId);
|
||||
|
||||
// ...and NOT in the audit-only read surface.
|
||||
var pendingRows = await writer.ReadPendingAsync(limit: 10);
|
||||
Assert.DoesNotContain(pendingRows, r => r.EventId == cached.EventId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task NonCachedKind_DrainsVia_ReadPending_Not_ReadPendingCachedTelemetry()
|
||||
{
|
||||
var (writer, _) = CreateWriter(nameof(NonCachedKind_DrainsVia_ReadPending_Not_ReadPendingCachedTelemetry));
|
||||
await using var _w = writer;
|
||||
|
||||
var nonCached = NewEvent(); // ApiCall — not a cached kind
|
||||
await writer.WriteAsync(nonCached);
|
||||
|
||||
var pendingRows = await writer.ReadPendingAsync(limit: 10);
|
||||
Assert.Single(pendingRows, r => r.EventId == nonCached.EventId);
|
||||
|
||||
var cachedRows = await writer.ReadPendingCachedTelemetryAsync(limit: 10);
|
||||
Assert.DoesNotContain(cachedRows, r => r.EventId == nonCached.EventId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -184,9 +320,9 @@ public class SqliteAuditWriterWriteTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MarkForwardedAsync_FlipsRowsToForwarded()
|
||||
public async Task MarkForwardedAsync_FlipsSidecarRowsToForwarded()
|
||||
{
|
||||
var (writer, dataSource) = CreateWriter(nameof(MarkForwardedAsync_FlipsRowsToForwarded));
|
||||
var (writer, dataSource) = CreateWriter(nameof(MarkForwardedAsync_FlipsSidecarRowsToForwarded));
|
||||
await using var _ = writer;
|
||||
|
||||
var ids = new[] { Guid.NewGuid(), Guid.NewGuid(), Guid.NewGuid() };
|
||||
@@ -197,20 +333,33 @@ public class SqliteAuditWriterWriteTests
|
||||
|
||||
await writer.MarkForwardedAsync(ids);
|
||||
|
||||
using var connection = OpenVerifierConnection(dataSource);
|
||||
using var cmd = connection.CreateCommand();
|
||||
cmd.CommandText = "SELECT ForwardState, COUNT(*) FROM AuditLog GROUP BY ForwardState;";
|
||||
using var reader = cmd.ExecuteReader();
|
||||
var byState = new Dictionary<string, long>();
|
||||
while (reader.Read())
|
||||
{
|
||||
byState[reader.GetString(0)] = reader.GetInt64(1);
|
||||
}
|
||||
|
||||
var byState = ForwardStateCounts(dataSource);
|
||||
Assert.Equal(3, byState[AuditForwardState.Forwarded.ToString()]);
|
||||
Assert.False(byState.ContainsKey(AuditForwardState.Pending.ToString()));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MarkForwardedAsync_BumpsAttemptCount_And_StampsLastAttemptUtc()
|
||||
{
|
||||
var (writer, dataSource) = CreateWriter(nameof(MarkForwardedAsync_BumpsAttemptCount_And_StampsLastAttemptUtc));
|
||||
await using var _ = writer;
|
||||
|
||||
var evt = NewEvent();
|
||||
await writer.WriteAsync(evt);
|
||||
|
||||
await writer.MarkForwardedAsync(new[] { evt.EventId });
|
||||
|
||||
using var connection = OpenVerifierConnection(dataSource);
|
||||
using var cmd = connection.CreateCommand();
|
||||
cmd.CommandText =
|
||||
"SELECT AttemptCount, LastAttemptUtc FROM audit_forward_state WHERE EventId = $id;";
|
||||
cmd.Parameters.AddWithValue("$id", evt.EventId.ToString());
|
||||
using var reader = cmd.ExecuteReader();
|
||||
Assert.True(reader.Read());
|
||||
Assert.Equal(1, reader.GetInt32(0)); // AttemptCount bumped 0 → 1
|
||||
Assert.False(reader.IsDBNull(1)); // LastAttemptUtc stamped
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MarkForwardedAsync_NonExistentId_NoThrow()
|
||||
{
|
||||
@@ -223,6 +372,23 @@ public class SqliteAuditWriterWriteTests
|
||||
// No assertion needed: the call must complete without throwing.
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReadForwardedAsync_Returns_Only_Forwarded_Rows()
|
||||
{
|
||||
var (writer, _) = CreateWriter(nameof(ReadForwardedAsync_Returns_Only_Forwarded_Rows));
|
||||
await using var _w = writer;
|
||||
|
||||
var forwarded = NewEvent();
|
||||
var pending = NewEvent();
|
||||
await writer.WriteAsync(forwarded);
|
||||
await writer.WriteAsync(pending);
|
||||
await writer.MarkForwardedAsync(new[] { forwarded.EventId });
|
||||
|
||||
var rows = await writer.ReadForwardedAsync(limit: 10);
|
||||
var row = Assert.Single(rows);
|
||||
Assert.Equal(forwarded.EventId, row.EventId);
|
||||
}
|
||||
|
||||
// ----- M6 reconciliation pull surface ----- //
|
||||
|
||||
[Fact]
|
||||
@@ -327,16 +493,7 @@ public class SqliteAuditWriterWriteTests
|
||||
|
||||
await writer.MarkReconciledAsync(new[] { a.EventId, b.EventId, c.EventId });
|
||||
|
||||
using var connection = OpenVerifierConnection(dataSource);
|
||||
using var cmd = connection.CreateCommand();
|
||||
cmd.CommandText = "SELECT ForwardState, COUNT(*) FROM AuditLog GROUP BY ForwardState;";
|
||||
using var reader = cmd.ExecuteReader();
|
||||
var byState = new Dictionary<string, long>();
|
||||
while (reader.Read())
|
||||
{
|
||||
byState[reader.GetString(0)] = reader.GetInt64(1);
|
||||
}
|
||||
|
||||
var byState = ForwardStateCounts(dataSource);
|
||||
Assert.Equal(3, byState[AuditForwardState.Reconciled.ToString()]);
|
||||
Assert.False(byState.ContainsKey(AuditForwardState.Pending.ToString()));
|
||||
Assert.False(byState.ContainsKey(AuditForwardState.Forwarded.ToString()));
|
||||
@@ -354,12 +511,7 @@ public class SqliteAuditWriterWriteTests
|
||||
// Re-call must not throw and must leave the single row Reconciled.
|
||||
await writer.MarkReconciledAsync(new[] { a.EventId });
|
||||
|
||||
using var connection = OpenVerifierConnection(dataSource);
|
||||
using var cmd = connection.CreateCommand();
|
||||
cmd.CommandText = "SELECT ForwardState FROM AuditLog WHERE EventId = $id;";
|
||||
cmd.Parameters.AddWithValue("$id", a.EventId.ToString());
|
||||
|
||||
Assert.Equal(AuditForwardState.Reconciled.ToString(), cmd.ExecuteScalar() as string);
|
||||
Assert.Equal(AuditForwardState.Reconciled.ToString(), ReadForwardState(dataSource, a.EventId));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -372,12 +524,12 @@ public class SqliteAuditWriterWriteTests
|
||||
// Completes without throwing.
|
||||
}
|
||||
|
||||
// ----- ExecutionId column (universal per-run correlation value) ----- //
|
||||
// ----- ExecutionId (rides DetailsJson, recomposed via AsRow) ----- //
|
||||
|
||||
[Fact]
|
||||
public async Task WriteAsync_NonNullExecutionId_RoundTripsThroughMapRow()
|
||||
public async Task WriteAsync_NonNullExecutionId_RoundTrips()
|
||||
{
|
||||
var (writer, _) = CreateWriter(nameof(WriteAsync_NonNullExecutionId_RoundTripsThroughMapRow));
|
||||
var (writer, _) = CreateWriter(nameof(WriteAsync_NonNullExecutionId_RoundTrips));
|
||||
await using var _w = writer;
|
||||
|
||||
var executionId = Guid.NewGuid();
|
||||
@@ -458,46 +610,40 @@ public class SqliteAuditWriterWriteTests
|
||||
Assert.Null(row.SourceNode);
|
||||
}
|
||||
|
||||
// ----- C3 hardening: safe enum-parse in MapRow ----- //
|
||||
// ----- C4 hardening: safe enum-parse in MapRow ----- //
|
||||
|
||||
/// <summary>
|
||||
/// C3 hardening (Task 2.5): a row whose Channel/Kind/Status columns hold
|
||||
/// an unknown/renamed enum string must NOT fault the read path; it degrades
|
||||
/// gracefully to the same fallbacks used by <c>AuditRowProjection.Decompose</c>
|
||||
/// (ApiInbound / InboundRequest / Submitted). The read is exercised via the
|
||||
/// public <see cref="SqliteAuditWriter.ReadPendingAsync"/> surface which calls
|
||||
/// the private <c>MapRow</c>.
|
||||
/// C4 hardening (Task 2.5): a row whose stored <c>Outcome</c> column holds an
|
||||
/// unknown/renamed enum string must NOT fault the read path; it degrades
|
||||
/// gracefully to <see cref="AuditOutcome.Success"/> (the safe
|
||||
/// <see cref="AuditRowProjection.ParseEnum{TEnum}"/> fallback). The read is
|
||||
/// exercised via the public <see cref="SqliteAuditWriter.ReadPendingAsync"/>
|
||||
/// surface which calls the private <c>MapRow</c>.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task ReadPendingAsync_UnknownEnumStrings_DoNotThrow_YieldFallbackValues()
|
||||
public async Task ReadPendingAsync_UnknownOutcomeString_DoesNotThrow_YieldsFallback()
|
||||
{
|
||||
var (writer, dataSource) = CreateWriter(
|
||||
nameof(ReadPendingAsync_UnknownEnumStrings_DoNotThrow_YieldFallbackValues));
|
||||
nameof(ReadPendingAsync_UnknownOutcomeString_DoesNotThrow_YieldsFallback));
|
||||
await using var _ = writer;
|
||||
|
||||
var evt = NewEvent();
|
||||
await writer.WriteAsync(evt);
|
||||
|
||||
// Tamper: overwrite the three enum columns with unknown strings that are
|
||||
// not declared AuditChannel/AuditKind/AuditStatus member names.
|
||||
// Tamper: overwrite the canonical Outcome column with a string that is not
|
||||
// a declared AuditOutcome member name.
|
||||
using (var conn = OpenVerifierConnection(dataSource))
|
||||
using (var cmd = conn.CreateCommand())
|
||||
{
|
||||
cmd.CommandText =
|
||||
"UPDATE AuditLog SET Channel = 'ObsoleteChannelV0', " +
|
||||
"Kind = 'LegacyKindName', Status = 'RenamedStatus99' " +
|
||||
"WHERE EventId = $id;";
|
||||
cmd.CommandText = "UPDATE audit_event SET Outcome = 'RenamedOutcome99' WHERE EventId = $id;";
|
||||
cmd.Parameters.AddWithValue("$id", evt.EventId.ToString());
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
// Must not throw (previously would throw ArgumentException from Enum.Parse).
|
||||
// Must not throw (a raw Enum.Parse would throw ArgumentException).
|
||||
var rows = await writer.ReadPendingAsync(limit: 10);
|
||||
|
||||
var row = Assert.Single(rows);
|
||||
var typedRow = row.AsRow();
|
||||
Assert.Equal(AuditChannel.ApiInbound, typedRow.Channel);
|
||||
Assert.Equal(AuditKind.InboundRequest, typedRow.Kind);
|
||||
Assert.Equal(AuditStatus.Submitted, typedRow.Status);
|
||||
Assert.Equal(AuditOutcome.Success, row.Outcome);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user