using System.Text.RegularExpressions; namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests; /// /// Code-level guard for the AuditLog append-only invariant (task M2.10, #18). /// /// The DB-role control (DENY UPDATE / DENY DELETE on dbo.AuditLog in migration /// 20260602174346_CollapseAuditLogToCanonical) is the runtime enforcement layer. /// This test is the compile-time / test-time backstop: it fails the test run if /// any C# source file in the ConfigurationDatabase project contains an UPDATE or /// DELETE statement that targets the AuditLog table. /// /// Matching rule (see ContainsAuditLogMutation for full detail) /// A line is flagged as a violation iff it matches the DML-syntax pattern: /// • UPDATE\s+(?:dbo\.)?AuditLog\b — UPDATE targeting AuditLog /// • DELETE\s+(?:FROM\s+)?(?:dbo\.)?AuditLog\b — DELETE targeting AuditLog /// /// These tight DML-syntax patterns naturally exclude false positives: /// - DENY UPDATE ON dbo.AuditLog … → "DENY" comes before UPDATE; the regex /// requires UPDATE to be immediately followed by (optional schema.) AuditLog, /// so "UPDATE ON" does NOT match "UPDATE AuditLog". /// - ALTER TABLE dbo.AuditLog SWITCH … → ALTER TABLE precedes the table name; /// no UPDATE/DELETE keyword present. /// - Comments like "// AuditLog … UPDATE …" → UPDATE is not immediately followed /// by AuditLog (there are intervening words). /// - DELETE FROM Notifications … → AuditLog not present. /// public class AuditLogAppendOnlyGuardTests { // --------------------------------------------------------------------------- // Source root location — same walk-up pattern used by ArchitecturalConstraintTests // in the Commons.Tests project. // --------------------------------------------------------------------------- private static string GetConfigurationDatabaseSourceDirectory() { // Walk up from the test binary output directory until we find the // ConfigurationDatabase csproj (a known anchor in the repo tree). var dir = new DirectoryInfo(AppContext.BaseDirectory); while (dir != null) { var candidate = Path.Combine( dir.FullName, "src", "ZB.MOM.WW.ScadaBridge.ConfigurationDatabase", "ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.csproj"); if (File.Exists(candidate)) { return Path.GetDirectoryName(candidate)!; } dir = dir.Parent; } throw new InvalidOperationException( "Could not locate ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.csproj " + "by walking up from the test output directory. " + "Ensure the test is run from inside the repo clone."); } // --------------------------------------------------------------------------- // Detection helper — kept as a static method so it can be unit-tested in // isolation below without requiring any file I/O. // --------------------------------------------------------------------------- /// /// Returns when the supplied text (typically a single /// source line) contains a SQL UPDATE or DELETE DML statement that directly /// targets the AuditLog table. /// /// Matching rule. The regex requires the DML keyword to be /// immediately followed (possibly via FROM) by the optional schema prefix /// dbo. and then the table name AuditLog as a whole word: /// /// UPDATE\s+(?:dbo\.)?AuditLog\b /// DELETE\s+(?:FROM\s+)?(?:dbo\.)?AuditLog\b /// /// This tight DML-syntax pattern naturally excludes false positives without /// any additional keyword checks: /// /// /// DENY UPDATE ON dbo.AuditLog … — "UPDATE ON" is never immediately /// followed by AuditLog; the pattern requires UPDATE → optional schema → AuditLog. /// /// /// ALTER TABLE dbo.AuditLog SWITCH … — no UPDATE/DELETE keyword present. /// /// /// // AuditLog is append-only; never issue an UPDATE against it. — /// UPDATE is not followed by AuditLog here. /// /// /// DELETE FROM dbo.Notifications … — AuditLog not present. /// /// /// /// A single source line (or any string to probe). /// if a mutation against AuditLog is detected. internal static bool ContainsAuditLogMutation(string text) { if (string.IsNullOrEmpty(text)) { return false; } // DML-syntax pattern: the UPDATE or DELETE keyword must be directly followed // (optionally via FROM) by the optional "dbo." schema qualifier and then the // table name "AuditLog" as a whole word. // // UPDATE\s+(?:dbo\.)?AuditLog\b // matches: "UPDATE AuditLog …", "UPDATE dbo.AuditLog …" // does NOT match: "DENY UPDATE ON dbo.AuditLog" (UPDATE is followed by ON, not AuditLog) // // DELETE\s+(?:FROM\s+)?(?:dbo\.)?AuditLog\b // matches: "DELETE FROM AuditLog …", "DELETE FROM dbo.AuditLog …", "DELETE dbo.AuditLog …" // does NOT match: "DENY DELETE ON dbo.AuditLog" (DELETE is followed by ON, not FROM/AuditLog) const string dmlPattern = @"\bUPDATE\s+(?:dbo\.)?AuditLog\b" + @"|\bDELETE\s+(?:FROM\s+)?(?:dbo\.)?AuditLog\b"; return Regex.IsMatch(text, dmlPattern, RegexOptions.IgnoreCase); } // --------------------------------------------------------------------------- // Guard test: scan every *.cs file in ConfigurationDatabase (excluding // Designer/Snapshot EF artefacts and the obj/ directory). // --------------------------------------------------------------------------- [Fact] public void ConfigurationDatabase_ShouldNotContainAuditLogMutations() { var sourceDir = GetConfigurationDatabaseSourceDirectory(); // Enumerate all .cs files; exclude EF scaffolding and build output. var csFiles = Directory.GetFiles(sourceDir, "*.cs", SearchOption.AllDirectories) .Where(f => !f.Contains(Path.DirectorySeparatorChar + "obj" + Path.DirectorySeparatorChar)) .Where(f => !f.EndsWith(".Designer.cs", StringComparison.OrdinalIgnoreCase)) .Where(f => !f.EndsWith("ModelSnapshot.cs", StringComparison.OrdinalIgnoreCase)) .ToList(); Assert.True(csFiles.Count > 0, $"Expected to find .cs files under {sourceDir} but found none — source directory location may be wrong."); var violations = new List(); foreach (var file in csFiles) { var content = File.ReadAllText(file); // Scan line-by-line so violation messages cite the exact line number. var lines = content.Split('\n'); for (var i = 0; i < lines.Length; i++) { if (ContainsAuditLogMutation(lines[i])) { var relativePath = Path.GetRelativePath(sourceDir, file); violations.Add($"{relativePath}:{i + 1}: {lines[i].Trim()}"); } } } Assert.True(violations.Count == 0, "AuditLog append-only guard: found UPDATE/DELETE targeting dbo.AuditLog " + "in ConfigurationDatabase source. AuditLog is APPEND-ONLY (retention uses " + "partition-switch DDL, not row DELETE). Violation(s):\n" + string.Join("\n", violations)); } // --------------------------------------------------------------------------- // Self-verifying matcher unit tests — prove the helper does what it claims. // --------------------------------------------------------------------------- [Fact] public void ContainsAuditLogMutation_ReturnsFalse_ForCleanSource() { // The guard scan over real source PASSES (no violations) — this fact is // already asserted by ConfigurationDatabase_ShouldNotContainAuditLogMutations. // Here we verify the helper directly on a representative set of CLEAN lines // that appear in the production source tree. // INSERT is not a mutation (append-only operations are fine). Assert.False(ContainsAuditLogMutation( "INSERT INTO dbo.AuditLog (EventId, OccurredAtUtc) VALUES (@id, @ts);")); // SELECT is not a mutation. Assert.False(ContainsAuditLogMutation( "SELECT COUNT(*) FROM dbo.AuditLog WHERE OccurredAtUtc >= @threshold;")); // ALTER TABLE SWITCH is the retention purge — not a row-level mutation. Assert.False(ContainsAuditLogMutation( "ALTER TABLE dbo.AuditLog SWITCH PARTITION 3 TO dbo.AuditLog_Staging;")); // DENY DDL from the role-grant migration — must not be flagged. Assert.False(ContainsAuditLogMutation( "DENY UPDATE ON dbo.AuditLog TO scadabridge_audit_writer;")); Assert.False(ContainsAuditLogMutation( "DENY DELETE ON dbo.AuditLog TO scadabridge_audit_writer;")); // GRANT DDL — also must not be flagged. Assert.False(ContainsAuditLogMutation( "GRANT INSERT ON dbo.AuditLog TO scadabridge_audit_writer;")); Assert.False(ContainsAuditLogMutation( "GRANT SELECT ON dbo.AuditLog TO scadabridge_audit_writer;")); // DELETE on a different table — AuditLog not on the same line. Assert.False(ContainsAuditLogMutation( "DELETE FROM dbo.Notifications WHERE Status = 'Delivered';")); // DELETE on a different table even though AuditLog appears nearby in the // same line but beyond the proximity window (padded to >120 chars between). var longSeparator = new string(' ', 130); Assert.False(ContainsAuditLogMutation( $"DELETE FROM dbo.Notifications WHERE Id = @id;{longSeparator}-- see also AuditLog")); // Comment-only mention of AuditLog with UPDATE elsewhere in a comment. Assert.False(ContainsAuditLogMutation( "// AuditLog is append-only; never issue an UPDATE against it.")); // TRUNCATE on the staging table (not AuditLog directly); staging name only. Assert.False(ContainsAuditLogMutation( "TRUNCATE TABLE dbo.AuditLog_Staging_abc123;")); } [Fact] public void ContainsAuditLogMutation_ReturnsTrue_ForPlantedViolations() { // Planted positive cases — the guard MUST catch these. // Classic UPDATE targeting AuditLog. Assert.True(ContainsAuditLogMutation( "UPDATE AuditLog SET Status = 'Corrected' WHERE EventId = @id;")); // UPDATE with schema prefix. Assert.True(ContainsAuditLogMutation( "UPDATE dbo.AuditLog SET DetailsJson = @json WHERE EventId = @id;")); // DELETE FROM AuditLog. Assert.True(ContainsAuditLogMutation( "DELETE FROM AuditLog WHERE OccurredAtUtc < @threshold;")); // DELETE with schema prefix. Assert.True(ContainsAuditLogMutation( "DELETE FROM dbo.AuditLog WHERE Status = 'Parked';")); // Mixed case (SQL is case-insensitive in practice). Assert.True(ContainsAuditLogMutation( "update dbo.AuditLog set Actor = 'system' where Actor is null;")); // AuditLog first, UPDATE second (reverse order — still a violation). Assert.True(ContainsAuditLogMutation( "-- AuditLog: UPDATE dbo.AuditLog SET x = 1")); } [Fact] public void ContainsAuditLogMutation_ReturnsFalse_ForDenyGrantAndPartitionSwitchSamples() { // Extra explicit coverage for the four concrete exclusion patterns // that appear in the real migration files. // From 20260602174346_CollapseAuditLogToCanonical.cs and 20260520142214_AddAuditLogTable.cs: Assert.False(ContainsAuditLogMutation( "DENY UPDATE ON dbo.AuditLog TO scadabridge_audit_writer;")); Assert.False(ContainsAuditLogMutation( "DENY DELETE ON dbo.AuditLog TO scadabridge_audit_writer;")); // From AuditLogRepository.cs SwitchOutPartitionAsync: Assert.False(ContainsAuditLogMutation( "ALTER TABLE dbo.AuditLog SWITCH PARTITION ' + CAST(@partitionNumber AS nvarchar(10)) + ' TO dbo.[' + @stagingName + '];")); // Notifications DELETE (legitimate; AuditLog not present on the line): Assert.False(ContainsAuditLogMutation( "DELETE FROM dbo.Notifications WHERE CompletedAtUtc < @cutoff;")); // SiteCalls DELETE (legitimate; AuditLog not present on the line): Assert.False(ContainsAuditLogMutation( "DELETE FROM dbo.SiteCalls WHERE TerminalAtUtc < @cutoff;")); } }