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. /// /// Allow-list. Two narrow maintenance-path exemptions carry the exact /// trailing comment: /// /// /// M5.5 (T3) — AuditLogRepository.PurgeChannelOlderThanAsync: the /// one sanctioned batched DELETE TOP (@batch) FROM dbo.AuditLog, /// running on the purge/maintenance connection. /// /// /// M5.6 (T5) — AuditLogRepository.BackfillSourceNodeAsync: the /// one sanctioned batched UPDATE TOP (@batch) dbo.AuditLog SET SourceNode, /// running on the maintenance connection. The sentinel backfill is a /// one-time ops procedure; the append-only invariant still applies to all /// other columns and all other UPDATE forms. /// /// /// The allow-list is applied in the file-scan test only /// () — the /// raw mutation matcher () is marker-blind, /// so the matcher's self-tests remain honest and any OTHER UPDATE/DELETE against /// AuditLog (or any DML lacking the marker) still fails the build. /// public class AuditLogAppendOnlyGuardTests { /// /// The exact trailing-comment marker that exempts a single sanctioned /// maintenance-path DML line from the append-only guard. Carried at the END of /// the SQL constant string in both AuditLogRepository.PurgeChannelOlderThanAsync /// (M5.5 T3 batched DELETE) and AuditLogRepository.BackfillSourceNodeAsync /// (M5.6 T5 batched UPDATE). Kept deliberately specific so it cannot be pasted /// onto an unrelated mutation without a reviewer noticing. /// internal const string AuditPurgeAllowedMarker = "AUDIT-PURGE-ALLOWED"; // --------------------------------------------------------------------------- // 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); } // The DELETE branch tolerates an optional TOP (...) batch-size clause between // DELETE and the (optional) FROM — e.g. "DELETE TOP (@batch) FROM dbo.AuditLog" // (the M5.5 T3 batched purge shape). Without this the guard would silently miss a // batched row DELETE against AuditLog, which is exactly the kind of mutation it // must catch. The TOP sub-pattern is (?:TOP\s*\(.*?\)\s+)? — optional, lazy inside // the parens so it never swallows past the matching ')'. // // The UPDATE branch similarly tolerates an optional TOP (...) clause between // UPDATE and (optional schema.) AuditLog — e.g. // "UPDATE TOP (@batch) dbo.AuditLog SET SourceNode = @sentinel …" // (the M5.6 T5 batched backfill shape). private static readonly Regex AuditLogMutationPattern = new( @"\bUPDATE\s+(?:TOP\s*\(.*?\)\s+)?(?:\[?dbo\]?\.)?(?:\[?AuditLog\]?)\b" + @"|\bDELETE\s+(?:TOP\s*\(.*?\)\s+)?(?:FROM\s+)?(?:\[?dbo\]?\.)?(?:\[?AuditLog\]?)\b", RegexOptions.IgnoreCase | RegexOptions.Compiled); /// /// Returns when carries the narrow /// exemption. Sanctioned uses are: /// /// M5.5 T3 — the per-channel maintenance-path batched DELETE. /// M5.6 T5 — the SourceNode sentinel batched UPDATE. /// /// A flagged line that lacks the marker is NOT allow-listed. The mutation matcher /// itself stays marker-blind; the allow-list is applied only by the file-scan test, /// so the matcher's self-tests still observe the raw mutation. /// /// A single source line already known to contain a mutation. /// if the line is a sanctioned maintenance-path exemption. internal static bool IsAllowListed(string line) => line.Contains(AuditPurgeAllowedMarker, StringComparison.Ordinal); // --------------------------------------------------------------------------- // 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]) && !IsAllowListed(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/UPDATE). 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;")); // ---- Batched DELETE TOP (...) forms (M5.5 T3 purge shape) ---- // The matcher must catch a batched DELETE against AuditLog regardless of the // marker — the allow-list (IsAllowListed) is what forgives the ONE sanctioned // line, not the matcher. Assert.True(ContainsAuditLogMutation( "DELETE TOP (@batch) FROM dbo.AuditLog WHERE Category = @channel AND OccurredAtUtc < @threshold;")); Assert.True(ContainsAuditLogMutation( "DELETE TOP (5000) FROM dbo.AuditLog WHERE OccurredAtUtc < @threshold;")); Assert.True(ContainsAuditLogMutation( "DELETE TOP(100) FROM [dbo].[AuditLog] WHERE Status = 'Parked';")); // ---- Batched UPDATE TOP (...) forms (M5.6 T5 backfill shape) ---- // The matcher must also catch a batched UPDATE against AuditLog, regardless of // the marker — the allow-list is what forgives the ONE sanctioned backfill line. Assert.True(ContainsAuditLogMutation( "UPDATE TOP (@batch) dbo.AuditLog SET SourceNode = @sentinel WHERE SourceNode IS NULL AND OccurredAtUtc < @before;")); Assert.True(ContainsAuditLogMutation( "UPDATE TOP (500) dbo.AuditLog SET SourceNode = 'unknown' WHERE SourceNode IS NULL;")); Assert.True(ContainsAuditLogMutation( "UPDATE TOP(100) [dbo].[AuditLog] SET SourceNode = @s WHERE SourceNode IS NULL;")); } [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;")); } // --------------------------------------------------------------------------- // Allow-list self-tests (M5.5 T3 / M5.6 T5) — prove the narrow exemption only // forgives the marked maintenance-path DML and still blocks everything else. // --------------------------------------------------------------------------- [Fact] public void AllowList_ForgivesMarkedPurgeDelete_ButMatcherStillTrips() { // The sanctioned per-channel purge DELETE — verbatim shape from // AuditLogRepository.PurgeChannelOlderThanAsync, carrying the trailing marker. const string sanctioned = "\"DELETE TOP (@batch) FROM dbo.AuditLog WHERE Category = @channel AND OccurredAtUtc < @threshold;\"; " + "// AUDIT-PURGE-ALLOWED: per-channel retention override (M5.5 T3), maintenance path"; // The raw matcher STILL sees the mutation (the matcher is marker-blind) ... Assert.True(ContainsAuditLogMutation(sanctioned)); // ... but the allow-list forgives it because of the trailing marker. Assert.True(IsAllowListed(sanctioned)); } [Fact] public void AllowList_ForgivesMarkedBackfillUpdate_ButMatcherStillTrips() { // The sanctioned SourceNode sentinel backfill UPDATE — verbatim shape from // AuditLogRepository.BackfillSourceNodeAsync, carrying the trailing marker. const string sanctioned = "\"UPDATE TOP (@batch) dbo.AuditLog SET SourceNode = @sentinel WHERE SourceNode IS NULL AND OccurredAtUtc < @before;\"; " + "// AUDIT-PURGE-ALLOWED: SourceNode sentinel backfill (M5.6 T5), maintenance path"; // The raw matcher STILL sees the mutation (the matcher is marker-blind) ... Assert.True(ContainsAuditLogMutation(sanctioned)); // ... but the allow-list forgives it because of the trailing marker. Assert.True(IsAllowListed(sanctioned)); } [Fact] public void AllowList_DoesNotForgive_UnmarkedStrayDelete() { // A stray DELETE against AuditLog WITHOUT the marker — exactly the kind of // regression the guard exists to catch. It must be flagged (matcher) AND not // forgiven (allow-list), so the file-scan test would record it as a violation. const string stray = "DELETE FROM dbo.AuditLog WHERE Status = 'Parked';"; Assert.True(ContainsAuditLogMutation(stray)); Assert.False(IsAllowListed(stray), "A DELETE against AuditLog without the AUDIT-PURGE-ALLOWED marker must NOT be allow-listed."); } [Fact] public void AllowList_DoesNotForgive_UnmarkedStrayUpdate() { // A stray UPDATE against AuditLog WITHOUT the marker — must still trip the guard. const string stray = "UPDATE dbo.AuditLog SET Status = 'Corrected' WHERE EventId = @id;"; Assert.True(ContainsAuditLogMutation(stray)); Assert.False(IsAllowListed(stray), "An UPDATE against AuditLog without the AUDIT-PURGE-ALLOWED marker must NOT be allow-listed."); } [Fact] public void AllowList_DoesNotForgive_BatchedUpdateWithoutMarker() { // A batched UPDATE TOP ... AuditLog without the marker — the TOP clause variant // must also be caught and not forgiven without the explicit marker. const string stray = "UPDATE TOP (500) dbo.AuditLog SET SourceNode = 'unknown' WHERE SourceNode IS NULL;"; Assert.True(ContainsAuditLogMutation(stray)); Assert.False(IsAllowListed(stray), "A batched UPDATE against AuditLog without the AUDIT-PURGE-ALLOWED marker must NOT be allow-listed."); } }