c4d7ec9b96
Item 1 (owner decision: no signing). AuditLog:HashChain:HeadExport (default
off): both centrals periodically read every month's chain head (LastSeq,
LastHash, purge anchor, chained rows, tombstones; one snapshot under the
chain lock SHARED) and append the changed heads to a sink the DB login cannot
write - File (append-only JSONL per node per day, local or UNC) or Syslog
(RFC 5424 over UDP / TCP octet counting). `audit verify-heads --path` reads
those records back (file or syslog-collector lines) and the new
VerifyAuditChainHeads command checks each against the database: Matches /
Extended / Purged, or Mismatch / Regressed / MissingHead / MissingLink /
CountMismatch (exit 3). AuditChainHeadStoreTests forges a chain with the
production hasher: verify-chain says Intact, verify-heads says Mismatch.
Item 3. AuditLog:MaintenanceConnectionString (${secret:}-capable, unset =
today's single login) runs the chain job, chain verify / head export, the
partition switch-out + archive and the per-channel purge on
AuditMaintenanceContext (keyed audit-maintenance repository/archive, purge
actor prefers them). AuditMaintenanceLoginTests runs the hardened setup on
SQL Server: writer (datareader/datawriter/audit_writer) ingests and is
refused chaining and deleting; the chainer+purger login does the full cycle.
Two-login grants documented in Component-AuditLog.md.
Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
136 lines
6.2 KiB
C#
136 lines
6.2 KiB
C#
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;
|
|
|
|
/// <summary>
|
|
/// 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).
|
|
/// </summary>
|
|
public sealed class AuditChainHeadSinkTests : IDisposable
|
|
{
|
|
private readonly DirectoryInfo _dir = Directory.CreateTempSubdirectory("head-sink-");
|
|
|
|
/// <inheritdoc />
|
|
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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Writes append to the node's file for the day — never truncate it — one JSON record per
|
|
/// line, and a pre-existing line survives.
|
|
/// </summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[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<AuditChainHeadRecord>(l, new JsonSerializerOptions(JsonSerializerDefaults.Web))!).ToList();
|
|
Assert.Equal([3L, 7L, 9L], parsed.Select(r => r.LastSeq));
|
|
Assert.Equal(Record("2026-05", 9), parsed[2]);
|
|
}
|
|
|
|
/// <summary>A relative export directory is refused.</summary>
|
|
[Fact]
|
|
public void FileSink_RejectsARelativeDirectory()
|
|
{
|
|
Assert.Throws<ArgumentException>(() => new FileAuditChainHeadSink("relative/dir", "central-a"));
|
|
}
|
|
|
|
/// <summary>UDP: one RFC 5424 datagram per record — PRI, version, MSGID, nil SD, JSON body.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[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);
|
|
}
|
|
|
|
/// <summary>TCP: octet-counted frames (RFC 6587), one per record, on one connection.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[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<string>();
|
|
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<AuditChainHeadRecord>(json, new JsonSerializerOptions(JsonSerializerDefaults.Web))!;
|
|
Assert.Equal(seq, record.LastSeq);
|
|
Assert.Equal(new string('a', 64), record.LastHash);
|
|
}
|
|
}
|