using Microsoft.Extensions.Configuration; using Serilog; using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Host.Logging; namespace ZB.MOM.WW.OtOpcUa.Host.Tests.Logging; /// /// The startup log-pipeline self-check. The failure mode under test is "healthy process, empty /// log": Serilog isolates per-sink write failures, so nothing else in the system can observe a /// file sink whose writes never reach disk. The load-bearing test here is the failure /// case (): a check that can only pass would /// be indistinguishable from no check at all. /// /// ⚠️ These tests are platform-blind in one direction, and it mattered. The check /// originally measured growth with FileInfo.Length, which on Windows reports the /// directory entry — frozen at size-at-open for as long as the sink holds the file. That made /// the check report FAILED on every healthy Windows boot. /// would have caught it: it measures while Serilog holds /// the file open, which is precisely the broken case. It never did, because it only ever ran on /// macOS, where the length is honest. So a green suite here does NOT establish that the check /// behaves on the platform it ships to — the same blindness produced the false 0-byte reading /// that made Gitea #561 look like a six-week production outage when the log held 4.66 MB. /// public sealed class LogPipelineDiagnosticsTests : IDisposable { private readonly string _dir = Directory.CreateTempSubdirectory("otopcua-logcheck-").FullName; public void Dispose() { try { Directory.Delete(_dir, recursive: true); } catch { /* best effort */ } } private static IConfiguration FileSinkConfig(string pathTemplate, bool? buffered = null) { var values = new Dictionary { ["Serilog:WriteTo:0:Name"] = "Console", ["Serilog:WriteTo:1:Name"] = "File", ["Serilog:WriteTo:1:Args:path"] = pathTemplate, ["Serilog:WriteTo:1:Args:rollingInterval"] = "Day", }; if (buffered is not null) values["Serilog:WriteTo:1:Args:buffered"] = buffered.Value ? "true" : "false"; return new ConfigurationBuilder().AddInMemoryCollection(values).Build(); } [Fact] public void A_working_file_sink_passes() { var template = Path.Combine(_dir, "t-.log"); using var logger = new LoggerConfiguration() .WriteTo.File(template, rollingInterval: RollingInterval.Day) .CreateLogger(); var warning = LogPipelineDiagnostics.CheckFileSinkGrowth( FileSinkConfig(template), () => logger.Information("marker")); warning.ShouldBeNull(); } [Fact] public void A_sink_that_writes_nothing_is_reported() { // The marker goes through a logger with NO file sink, so the configured template never // grows — the on-disk shape of the wonder-app-vd03 incident (open handle, failed writes). var template = Path.Combine(_dir, "t-.log"); using var fileless = new LoggerConfiguration().CreateLogger(); var warning = LogPipelineDiagnostics.CheckFileSinkGrowth( FileSinkConfig(template), () => fileless.Information("marker")); warning.ShouldNotBeNull(); warning.ShouldContain(template); warning.ShouldContain("did not grow"); } [Fact] public void A_preexisting_file_does_not_mask_a_silent_sink() { // A previous healthy run's bytes on disk must not satisfy the check: growth is measured // across the marker, not existence. This is the exact false-pass an existence check // would have given on wonder (the 0-byte file was created by the broken run itself, but a // >0-byte survivor from an earlier build would fool existence just the same). var template = Path.Combine(_dir, "t-.log"); File.WriteAllText(Path.Combine(_dir, "t-20260811.log"), "bytes from a previous run"); using var fileless = new LoggerConfiguration().CreateLogger(); var warning = LogPipelineDiagnostics.CheckFileSinkGrowth( FileSinkConfig(template), () => fileless.Information("marker")); warning.ShouldNotBeNull(); } [Fact] public void The_rolled_and_sequenced_family_counts_as_the_template() { // The sink writes t-20260811.log (rolled) — the template t-.log itself never exists. // Growth must be attributed to the template through the family glob, or every healthy // rolling deployment would false-alarm. var template = Path.Combine(_dir, "t-.log"); using var logger = new LoggerConfiguration() .WriteTo.File(template, rollingInterval: RollingInterval.Day) .CreateLogger(); logger.Information("creates the rolled file before the check runs"); var warning = LogPipelineDiagnostics.CheckFileSinkGrowth( FileSinkConfig(template), () => logger.Information("marker")); warning.ShouldBeNull(); File.Exists(template).ShouldBeFalse("the un-rolled template name must not exist — only the rolled form"); } [Fact] public void No_file_sink_configured_is_not_a_failure() { var config = new ConfigurationBuilder().AddInMemoryCollection( new Dictionary { ["Serilog:WriteTo:0:Name"] = "Console" }).Build(); var markerRan = false; var warning = LogPipelineDiagnostics.CheckFileSinkGrowth(config, () => markerRan = true); warning.ShouldBeNull(); markerRan.ShouldBeFalse("with nothing checkable the marker event should not be emitted"); } [Fact] public void A_buffered_sink_is_skipped_not_reported() { // buffered:true legitimately defers bytes past the marker; growth proves nothing either // way, so the check must skip rather than false-alarm. var template = Path.Combine(_dir, "t-.log"); using var fileless = new LoggerConfiguration().CreateLogger(); var warning = LogPipelineDiagnostics.CheckFileSinkGrowth( FileSinkConfig(template, buffered: true), () => fileless.Information("marker")); warning.ShouldBeNull(); } [Fact] public void Growth_is_measured_while_a_writer_holds_the_file_open() { // The measurement must come from an opened stream, not the directory entry. A second writer // holding the file the way Serilog does (FileAccess.Write / FileShare.Read) must neither // block the read nor hide the bytes it has already flushed. var template = Path.Combine(_dir, "t-.log"); var rolled = Path.Combine(_dir, "t-20260811.log"); using var held = new FileStream(rolled, FileMode.Create, FileAccess.Write, FileShare.Read); held.Write("first"u8); held.Flush(); var warning = LogPipelineDiagnostics.CheckFileSinkGrowth( FileSinkConfig(template), () => { held.Write("grew"u8); held.Flush(); }); warning.ShouldBeNull("bytes written through a still-open handle must count as growth"); } [Fact] public void A_file_locked_against_readers_does_not_break_the_check() { // Opening to measure can fail where a metadata read would not (FileShare.None). That must // degrade to "unmeasurable", never to an exception escaping into startup. var template = Path.Combine(_dir, "t-.log"); var rolled = Path.Combine(_dir, "t-20260811.log"); using var exclusive = new FileStream(rolled, FileMode.Create, FileAccess.Write, FileShare.None); exclusive.Write("locked"u8); exclusive.Flush(); using var fileless = new LoggerConfiguration().CreateLogger(); Should.NotThrow(() => LogPipelineDiagnostics.CheckFileSinkGrowth( FileSinkConfig(template), () => fileless.Information("marker"))); } [Fact] public void A_file_entry_with_no_path_is_tolerated() { var config = new ConfigurationBuilder().AddInMemoryCollection( new Dictionary { ["Serilog:WriteTo:0:Name"] = "File" }).Build(); Should.NotThrow(() => LogPipelineDiagnostics.CheckFileSinkGrowth( config, () => { })).ShouldBeNull(); } }