fix(audit): ScadaBridge C3 review — safe enum-parse (fallback) in SqliteAuditWriter.MapRow + AuditEventDtoMapper.FromDto (Task 2.5)

This commit is contained in:
Joseph Doherty
2026-06-02 12:55:07 -04:00
parent db707bb0de
commit c27b2c3d5f
5 changed files with 82 additions and 7 deletions
@@ -457,4 +457,47 @@ public class SqliteAuditWriterWriteTests
var row = Assert.Single(rows);
Assert.Null(row.SourceNode);
}
// ----- C3 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>.
/// </summary>
[Fact]
public async Task ReadPendingAsync_UnknownEnumStrings_DoNotThrow_YieldFallbackValues()
{
var (writer, dataSource) = CreateWriter(
nameof(ReadPendingAsync_UnknownEnumStrings_DoNotThrow_YieldFallbackValues));
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.
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.Parameters.AddWithValue("$id", evt.EventId.ToString());
cmd.ExecuteNonQuery();
}
// Must not throw (previously would throw ArgumentException from Enum.Parse).
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);
}
}
@@ -268,4 +268,30 @@ public class AuditEventDtoMapperTests
// …and FromDto rehydrates empty → null.
Assert.Null(roundTripped.SourceNode);
}
/// <summary>
/// C3 hardening (Task 2.5): a DTO that carries an unknown/renamed enum string
/// for Channel, Kind, or Status must NOT throw on <see cref="AuditEventDtoMapper.FromDto"/>;
/// it degrades gracefully to the same fallbacks used by <c>AuditRowProjection.Decompose</c>
/// (ApiInbound / InboundRequest / Submitted).
/// </summary>
[Fact]
public void FromDto_UnknownEnumStrings_DoNotThrow_YieldFallbackValues()
{
var dto = new AuditEventDto
{
EventId = Guid.NewGuid().ToString(),
OccurredAtUtc = Timestamp.FromDateTime(DateTime.UtcNow),
Channel = "ObsoleteChannelV0", // unknown — not a declared AuditChannel member
Kind = "LegacyKindName", // unknown — not a declared AuditKind member
Status = "RenamedStatus99", // unknown — not a declared AuditStatus member
};
// Must not throw (previously would throw ArgumentException from Enum.Parse).
var row = AuditEventDtoMapper.FromDto(dto).AsRow();
Assert.Equal(AuditChannel.ApiInbound, row.Channel);
Assert.Equal(AuditKind.InboundRequest, row.Kind);
Assert.Equal(AuditStatus.Submitted, row.Status);
}
}