diff --git a/src/ZB.MOM.WW.ScadaBridge.AuditLog/Site/SqliteAuditWriter.cs b/src/ZB.MOM.WW.ScadaBridge.AuditLog/Site/SqliteAuditWriter.cs index d07c7d92..1c0754ab 100644 --- a/src/ZB.MOM.WW.ScadaBridge.AuditLog/Site/SqliteAuditWriter.cs +++ b/src/ZB.MOM.WW.ScadaBridge.AuditLog/Site/SqliteAuditWriter.cs @@ -800,9 +800,9 @@ public class SqliteAuditWriter : IAuditWriter, ISiteAuditQueue, IAsyncDisposable System.Globalization.CultureInfo.InvariantCulture, System.Globalization.DateTimeStyles.RoundtripKind), IngestedAtUtc: null, - Channel: Enum.Parse(reader.GetString(2)), - Kind: Enum.Parse(reader.GetString(3)), - Status: Enum.Parse(reader.GetString(11)), + Channel: AuditRowProjection.ParseEnum(reader.GetString(2), AuditChannel.ApiInbound), + Kind: AuditRowProjection.ParseEnum(reader.GetString(3), AuditKind.InboundRequest), + Status: AuditRowProjection.ParseEnum(reader.GetString(11), AuditStatus.Submitted), CorrelationId: reader.IsDBNull(4) ? null : Guid.Parse(reader.GetString(4)), ExecutionId: reader.IsDBNull(21) ? null : Guid.Parse(reader.GetString(21)), ParentExecutionId: reader.IsDBNull(22) ? null : Guid.Parse(reader.GetString(22)), diff --git a/src/ZB.MOM.WW.ScadaBridge.Commons/Types/Audit/AuditRowProjection.cs b/src/ZB.MOM.WW.ScadaBridge.Commons/Types/Audit/AuditRowProjection.cs index 4f0298a7..ad2263b4 100644 --- a/src/ZB.MOM.WW.ScadaBridge.Commons/Types/Audit/AuditRowProjection.cs +++ b/src/ZB.MOM.WW.ScadaBridge.Commons/Types/Audit/AuditRowProjection.cs @@ -173,7 +173,13 @@ public static class AuditRowProjection return evt with { DetailsJson = AuditDetailsCodec.Serialize(d) }; } - private static TEnum ParseEnum(string? value, TEnum fallback) where TEnum : struct, Enum + /// + /// Case-sensitive with a caller-supplied fallback. + /// Returns when is null, empty, + /// or does not match any declared member name — so callers never throw on an + /// unknown/renamed enum string (legacy or corrupt rows degrade gracefully). + /// + public static TEnum ParseEnum(string? value, TEnum fallback) where TEnum : struct, Enum => !string.IsNullOrEmpty(value) && Enum.TryParse(value, ignoreCase: false, out var parsed) ? parsed : fallback; diff --git a/src/ZB.MOM.WW.ScadaBridge.Communication/Grpc/AuditEventDtoMapper.cs b/src/ZB.MOM.WW.ScadaBridge.Communication/Grpc/AuditEventDtoMapper.cs index 071fca8f..2d586366 100644 --- a/src/ZB.MOM.WW.ScadaBridge.Communication/Grpc/AuditEventDtoMapper.cs +++ b/src/ZB.MOM.WW.ScadaBridge.Communication/Grpc/AuditEventDtoMapper.cs @@ -105,9 +105,9 @@ public static class AuditEventDtoMapper EventId: Guid.Parse(dto.EventId), OccurredAtUtc: DateTime.SpecifyKind(dto.OccurredAtUtc.ToDateTime(), DateTimeKind.Utc), IngestedAtUtc: null, - Channel: Enum.Parse(dto.Channel), - Kind: Enum.Parse(dto.Kind), - Status: Enum.Parse(dto.Status), + Channel: AuditRowProjection.ParseEnum(dto.Channel, AuditChannel.ApiInbound), + Kind: AuditRowProjection.ParseEnum(dto.Kind, AuditKind.InboundRequest), + Status: AuditRowProjection.ParseEnum(dto.Status, AuditStatus.Submitted), CorrelationId: NullIfEmpty(dto.CorrelationId) is { } cid ? Guid.Parse(cid) : null, ExecutionId: NullIfEmpty(dto.ExecutionId) is { } eid ? Guid.Parse(eid) : null, ParentExecutionId: NullIfEmpty(dto.ParentExecutionId) is { } pid ? Guid.Parse(pid) : null, diff --git a/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Site/SqliteAuditWriterWriteTests.cs b/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Site/SqliteAuditWriterWriteTests.cs index 650e859b..07978a1f 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Site/SqliteAuditWriterWriteTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Site/SqliteAuditWriterWriteTests.cs @@ -457,4 +457,47 @@ public class SqliteAuditWriterWriteTests var row = Assert.Single(rows); Assert.Null(row.SourceNode); } + + // ----- C3 hardening: safe enum-parse in MapRow ----- // + + /// + /// 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 AuditRowProjection.Decompose + /// (ApiInbound / InboundRequest / Submitted). The read is exercised via the + /// public surface which calls + /// the private MapRow. + /// + [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); + } } diff --git a/tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/AuditEventDtoMapperTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/AuditEventDtoMapperTests.cs index e73d72cc..3841a294 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/AuditEventDtoMapperTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/AuditEventDtoMapperTests.cs @@ -268,4 +268,30 @@ public class AuditEventDtoMapperTests // …and FromDto rehydrates empty → null. Assert.Null(roundTripped.SourceNode); } + + /// + /// C3 hardening (Task 2.5): a DTO that carries an unknown/renamed enum string + /// for Channel, Kind, or Status must NOT throw on ; + /// it degrades gracefully to the same fallbacks used by AuditRowProjection.Decompose + /// (ApiInbound / InboundRequest / Submitted). + /// + [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); + } }