using System.Text; using System.Text.Json; using AVEVA.Historian.ReverseEngineering.Capture; namespace AVEVA.Historian.Client.Tests; /// /// Unit coverage for the CW-1 capture sanitizer and fixture writer — the reusable /// "redact identity → emit committable fixture" core that all capture-tier work depends on. /// public sealed class ProtocolCaptureSanitizerTests { private static byte[] Ascii(string s) => Encoding.ASCII.GetBytes(s); private static byte[] Utf16(string s) => Encoding.Unicode.GetBytes(s); [Fact] public void Sanitize_RedactsAsciiOccurrence_PreservingLength() { byte[] buffer = [0x01, 0x02, .. Ascii("SECRETTAG"), 0x03]; SanitizeResult result = ProtocolCaptureSanitizer.Sanitize(buffer, [new CaptureSecret("tag", "SECRETTAG")]); Assert.Equal(buffer.Length, result.Sanitized.Length); Assert.Equal(0x01, result.Sanitized[0]); Assert.Equal(0x03, result.Sanitized[^1]); Assert.DoesNotContain(Ascii("SECRETTAG"), result.Sanitized); // value gone Assert.Equal(1, result.Report[0].AsciiMatches); Assert.Equal(0, result.Report[0].Utf16Matches); } [Fact] public void Sanitize_RedactsUtf16Occurrence() { byte[] buffer = [0xAA, .. Utf16("HostName"), 0xBB]; SanitizeResult result = ProtocolCaptureSanitizer.Sanitize(buffer, [new CaptureSecret("host", "HostName")]); Assert.Equal(0, result.Report[0].AsciiMatches); Assert.Equal(1, result.Report[0].Utf16Matches); Assert.Equal(0xAA, result.Sanitized[0]); Assert.Equal(0xBB, result.Sanitized[^1]); } [Fact] public void Sanitize_IsCaseInsensitiveForAsciiLetters() { byte[] buffer = Ascii("myserver01"); SanitizeResult result = ProtocolCaptureSanitizer.Sanitize(buffer, [new CaptureSecret("host", "MyServer01")]); Assert.Equal(1, result.Report[0].AsciiMatches); Assert.All(result.Sanitized, b => Assert.Equal(ProtocolCaptureSanitizer.FillByte, b)); } [Fact] public void Sanitize_RedactsMultipleOccurrences() { byte[] buffer = [.. Ascii("TagA"), 0x00, .. Ascii("TagA"), 0x00, .. Ascii("TagA")]; SanitizeResult result = ProtocolCaptureSanitizer.Sanitize(buffer, [new CaptureSecret("tag", "TagA")]); Assert.Equal(3, result.Report[0].AsciiMatches); Assert.Equal(3, result.TotalRedactions); } [Fact] public void Sanitize_IgnoresShortSecrets_ToAvoidCollisionCorruption() { byte[] buffer = [0x41, 0x42, 0x43]; // "ABC" SanitizeResult result = ProtocolCaptureSanitizer.Sanitize(buffer, [new CaptureSecret("x", "AB")]); // length 2 < MinSecretLength Assert.Equal(buffer, result.Sanitized); // untouched Assert.Equal(0, result.TotalRedactions); } [Fact] public void Sanitize_LeavesUnrelatedBytesUntouched() { byte[] buffer = [.. Ascii("keepme"), .. Ascii("DROPME"), .. Ascii("keepme")]; SanitizeResult result = ProtocolCaptureSanitizer.Sanitize(buffer, [new CaptureSecret("s", "DROPME")]); Assert.Equal(Ascii("keepme"), result.Sanitized[..6]); Assert.Equal(Ascii("keepme"), result.Sanitized[^6..]); } [Fact] public void AssertNoSecretsRemain_Passes_WhenRedacted() { byte[] buffer = Ascii("prefix-SECRET-suffix"); SanitizeResult result = ProtocolCaptureSanitizer.Sanitize(buffer, [new CaptureSecret("s", "SECRET")]); ProtocolCaptureSanitizer.AssertNoSecretsRemain(result.Sanitized, [new CaptureSecret("s", "SECRET")]); } [Fact] public void AssertNoSecretsRemain_Throws_WhenSecretSurvives() { byte[] buffer = Ascii("prefix-SECRET-suffix"); Assert.Throws( () => ProtocolCaptureSanitizer.AssertNoSecretsRemain(buffer, [new CaptureSecret("s", "SECRET")])); } [Fact] public void FixtureWriter_BuildJson_OmitsRawIdentity_AndRecordsScrubReport() { byte[] response = [0x4E, .. Utf16("CustomerTag.PV"), 0xFE, 0x00]; var capture = new ProtocolCapture("get-tag-info", Request: null, Response: response, Notes: "live 2020 server"); var secrets = new[] { new CaptureSecret("tag", "CustomerTag.PV") }; string json = ProtocolFixtureWriter.BuildFixtureJson(capture, secrets, "2026-06-19T00:00:00Z"); Assert.DoesNotContain("CustomerTag", json); // identity scrubbed from hex using JsonDocument doc = JsonDocument.Parse(json); JsonElement root = doc.RootElement; Assert.Equal("get-tag-info", root.GetProperty("op").GetString()); Assert.Equal("2026-06-19T00:00:00Z", root.GetProperty("capturedUtc").GetString()); Assert.Equal(JsonValueKind.Null, root.GetProperty("request").ValueKind); JsonElement resp = root.GetProperty("response"); Assert.Equal(response.Length, resp.GetProperty("length").GetInt32()); Assert.Equal(64, resp.GetProperty("sha256").GetString()!.Length); Assert.Equal("tag", resp.GetProperty("redactions")[0].GetProperty("secret").GetString()); } [Fact] public void FixtureWriter_Write_CreatesOpSubdirectoryFile() { string root = Path.Combine(Path.GetTempPath(), "histsdk-fixture-test-" + Guid.NewGuid().ToString("N")); try { var capture = new ProtocolCapture("get-tag-info", Request: null, Response: [0x01, 0x02, 0x03], Notes: null); string path = ProtocolFixtureWriter.Write(root, "sample", capture, [], "2026-06-19T00:00:00Z"); Assert.True(File.Exists(path)); Assert.EndsWith(Path.Combine("get-tag-info", "sample.json"), path); } finally { if (Directory.Exists(root)) { Directory.Delete(root, recursive: true); } } } }