using System.Net; using System.Net.Sockets; using System.Text; using System.Text.Json; using ZB.MOM.WW.ScadaBridge.AuditLog.Central.HeadExport; using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit; namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Central.HeadExport; /// /// The two off-box chain-head sinks (ScadaBridge#107 item 1): the append-only JSON-lines file /// sink, and RFC 5424 syslog over UDP and TCP (octet-counted framing). /// public sealed class AuditChainHeadSinkTests : IDisposable { private readonly DirectoryInfo _dir = Directory.CreateTempSubdirectory("head-sink-"); /// public void Dispose() => _dir.Delete(recursive: true); private static AuditChainHeadRecord Record(string month, long seq) => new( 1, month, seq, new string('a', 64), null, null, seq, 0, new DateTime(2026, 5, 3, 0, 0, 0, DateTimeKind.Utc), new DateTime(2026, 5, 3, 1, 0, 0, DateTimeKind.Utc), "central-a"); private sealed class FixedClock(DateTimeOffset now) : TimeProvider { public override DateTimeOffset GetUtcNow() => now; } /// /// Writes append to the node's file for the day — never truncate it — one JSON record per /// line, and a pre-existing line survives. /// /// A task that represents the asynchronous operation. [Fact] public async Task FileSink_AppendsJsonLines_ToThePerNodePerDayFile_WithoutTruncating() { var clock = new FixedClock(new DateTimeOffset(2026, 5, 3, 1, 0, 0, TimeSpan.Zero)); var sink = new FileAuditChainHeadSink(_dir.FullName, "central-a", clock); var path = sink.PathFor(clock.GetUtcNow().UtcDateTime); Assert.Equal(Path.Combine(_dir.FullName, "2026", "audit-chain-heads-2026-05-03-central-a.jsonl"), path); Directory.CreateDirectory(Path.GetDirectoryName(path)!); await File.WriteAllTextAsync(path, "earlier line\n"); await sink.WriteAsync([Record("2026-04", 3), Record("2026-05", 7)], default); await sink.WriteAsync([Record("2026-05", 9)], default); var lines = await File.ReadAllLinesAsync(path); Assert.Equal(4, lines.Length); Assert.Equal("earlier line", lines[0]); var parsed = lines.Skip(1).Select(l => JsonSerializer.Deserialize(l, new JsonSerializerOptions(JsonSerializerDefaults.Web))!).ToList(); Assert.Equal([3L, 7L, 9L], parsed.Select(r => r.LastSeq)); Assert.Equal(Record("2026-05", 9), parsed[2]); } /// A relative export directory is refused. [Fact] public void FileSink_RejectsARelativeDirectory() { Assert.Throws(() => new FileAuditChainHeadSink("relative/dir", "central-a")); } /// UDP: one RFC 5424 datagram per record — PRI, version, MSGID, nil SD, JSON body. /// A task that represents the asynchronous operation. [Fact] public async Task SyslogSink_Udp_SendsOneRfc5424DatagramPerRecord() { using var server = new UdpClient(new IPEndPoint(IPAddress.Loopback, 0)); var port = ((IPEndPoint)server.Client.LocalEndPoint!).Port; var sink = new SyslogAuditChainHeadSink( new AuditChainHeadSyslogSinkOptions { Host = "127.0.0.1", Port = port, Protocol = AuditSyslogProtocol.Udp, Facility = 13 }, hostName: "central a", time: new FixedClock(new DateTimeOffset(2026, 5, 3, 1, 2, 3, 456, TimeSpan.Zero))); await sink.WriteAsync([Record("2026-04", 3), Record("2026-05", 7)], default); var first = Encoding.UTF8.GetString((await server.ReceiveAsync().WaitAsync(TimeSpan.FromSeconds(5))).Buffer); var second = Encoding.UTF8.GetString((await server.ReceiveAsync().WaitAsync(TimeSpan.FromSeconds(5))).Buffer); // facility 13 (log audit) * 8 + severity 5 (notice) = 109; the space in the host name is dropped. Assert.StartsWith($"<109>1 2026-05-03T01:02:03.456Z centrala scadabridge {Environment.ProcessId} chainhead - {{", first); AssertRecordBody(first, 3); AssertRecordBody(second, 7); } /// TCP: octet-counted frames (RFC 6587), one per record, on one connection. /// A task that represents the asynchronous operation. [Fact] public async Task SyslogSink_Tcp_SendsOctetCountedFrames() { var listener = new TcpListener(IPAddress.Loopback, 0); listener.Start(); try { var port = ((IPEndPoint)listener.LocalEndpoint).Port; var received = Task.Run(async () => { using var client = await listener.AcceptTcpClientAsync(); using var ms = new MemoryStream(); await client.GetStream().CopyToAsync(ms); return ms.ToArray(); }); var sink = new SyslogAuditChainHeadSink( new AuditChainHeadSyslogSinkOptions { Host = "127.0.0.1", Port = port, Protocol = AuditSyslogProtocol.Tcp }); await sink.WriteAsync([Record("2026-04", 3), Record("2026-05", 7)], default); var bytes = await received.WaitAsync(TimeSpan.FromSeconds(5)); var frames = new List(); var i = 0; while (i < bytes.Length) { var space = Array.IndexOf(bytes, (byte)' ', i); var length = int.Parse(Encoding.ASCII.GetString(bytes, i, space - i)); frames.Add(Encoding.UTF8.GetString(bytes, space + 1, length)); i = space + 1 + length; } Assert.Equal(2, frames.Count); Assert.All(frames, f => Assert.StartsWith("<109>1 ", f)); AssertRecordBody(frames[0], 3); AssertRecordBody(frames[1], 7); } finally { listener.Stop(); } } private static void AssertRecordBody(string message, long seq) { var json = message[message.IndexOf('{')..]; var record = JsonSerializer.Deserialize(json, new JsonSerializerOptions(JsonSerializerDefaults.Web))!; Assert.Equal(seq, record.LastSeq); Assert.Equal(new string('a', 64), record.LastHash); } }