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. /// /// Known limitations: This guard scans only raw SQL strings — EF Core methods /// such as ExecuteDeleteAsync, ExecuteUpdateAsync, and RemoveRange /// targeting the AuditLog entity are NOT covered and must never be introduced. /// Additionally, the scan is line-oriented: DML where the keyword and table name appear /// on separate lines is an accepted, undetected edge case. /// 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. or [dbo].) and then the table name AuditLog /// or [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 schema qualifier and then the table name. // // Schema sub-pattern : (?:\[?dbo\]?\.)? // matches: nothing, "dbo.", "[dbo]." // // Table sub-pattern : \[?AuditLog\]? // matches: "AuditLog", "[AuditLog]" // // UPDATE\s+(?:\[?dbo\]?\.)?(?:\[?AuditLog\]?)\b // matches: "UPDATE AuditLog", "UPDATE dbo.AuditLog", // "UPDATE [AuditLog]", "UPDATE [dbo].[AuditLog]" // does NOT match: "DENY UPDATE ON dbo.AuditLog" (UPDATE is followed by ON) // // DELETE\s+(?:FROM\s+)?(?:\[?dbo\]?\.)?(?:\[?AuditLog\]?)\b // matches: "DELETE FROM AuditLog", "DELETE FROM dbo.AuditLog", // "DELETE FROM [AuditLog]", "DELETE FROM [dbo].[AuditLog]" // does NOT match: "DENY DELETE ON dbo.AuditLog" (DELETE is followed by ON) return AuditLogMutationPattern.IsMatch(text); } private static readonly Regex AuditLogMutationPattern = new( @"\bUPDATE\s+(?:\[?dbo\]?\.)?(?:\[?AuditLog\]?)\b" + @"|\bDELETE\s+(?:FROM\s+)?(?:\[?dbo\]?\.)?(?:\[?AuditLog\]?)\b", RegexOptions.IgnoreCase | RegexOptions.Compiled); // --------------------------------------------------------------------------- // 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 mentioned earlier in the line (e.g. in a comment prefix), with a real // UPDATE dbo.AuditLog DML following — the DML occurrence must still be caught. Assert.True(ContainsAuditLogMutation( "-- AuditLog: UPDATE dbo.AuditLog SET x = 1")); // ---- Bracketed identifier forms (SSMS-generated SQL) ---- // UPDATE [dbo].[AuditLog] — bracketed schema and bracketed table. Assert.True(ContainsAuditLogMutation( "UPDATE [dbo].[AuditLog] SET DetailsJson = @json WHERE EventId = @id;")); // UPDATE [AuditLog] — bracketed table, no schema prefix. Assert.True(ContainsAuditLogMutation( "UPDATE [AuditLog] SET Status = 'Corrected' WHERE EventId = @id;")); // DELETE FROM [dbo].[AuditLog] — bracketed schema and bracketed table. Assert.True(ContainsAuditLogMutation( "DELETE FROM [dbo].[AuditLog] WHERE OccurredAtUtc < @threshold;")); // DELETE FROM [AuditLog] — bracketed table, no schema prefix. Assert.True(ContainsAuditLogMutation( "DELETE FROM [AuditLog] WHERE OccurredAtUtc < @threshold;")); } [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;")); // Notifications DELETE using bracketed identifiers — AuditLog not present: 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;")); } }