diff --git a/docs/plans/2026-06-15-stillpending-m2-implementation.md.tasks.json b/docs/plans/2026-06-15-stillpending-m2-implementation.md.tasks.json index 7adae545..0301494f 100644 --- a/docs/plans/2026-06-15-stillpending-m2-implementation.md.tasks.json +++ b/docs/plans/2026-06-15-stillpending-m2-implementation.md.tasks.json @@ -9,8 +9,8 @@ {"id": 37, "ref": "M2.5", "subject": "M2.5 #9: per-script execution timeout (entity+migration+flatten+actor)", "class": "standard", "status": "completed", "blockedBy": [32], "commits": ["3edef09", "3032faa"]}, {"id": 38, "ref": "M2.6", "subject": "M2.6 #13: nested Object/List extended-type validation", "class": "standard", "status": "completed", "commits": ["4b6187c", "411d0c0"]}, {"id": 39, "ref": "M2.7", "subject": "M2.7 #20+#21: return-type + argument-type compatibility checks", "class": "standard", "status": "completed", "commits": ["958229e", "a8e9e99"]}, - {"id": 40, "ref": "M2.8", "subject": "M2.8 #23: binding-completeness Error + name-exists-at-site", "class": "standard", "status": "completed", "commits": ["7c14a69"]}, - {"id": 41, "ref": "M2.9", "subject": "M2.9 #17: MachineDataDb fail-fast (reverts Host-008)", "class": "small", "status": "pending"}, + {"id": 40, "ref": "M2.8", "subject": "M2.8 #23: binding-completeness Error + name-exists-at-site", "class": "standard", "status": "completed", "commits": ["7c14a69", "21b801b"]}, + {"id": 41, "ref": "M2.9", "subject": "M2.9 #17: MachineDataDb fail-fast (reverts Host-008)", "class": "small", "status": "completed", "commits": ["76198b3"]}, {"id": 42, "ref": "M2.10", "subject": "M2.10 #18: CI grep-guard against UPDATE/DELETE on AuditLog", "class": "small", "status": "pending"}, {"id": 43, "ref": "M2.11", "subject": "M2.11 #24: debug snapshot unknown-instance returns error", "class": "small", "status": "pending"}, {"id": 44, "ref": "M2.12", "subject": "M2.12 #25: recursion-limit error to site event log", "class": "small", "status": "pending"}, diff --git a/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/AuditLogAppendOnlyGuardTests.cs b/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/AuditLogAppendOnlyGuardTests.cs new file mode 100644 index 00000000..edfdd96a --- /dev/null +++ b/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/AuditLogAppendOnlyGuardTests.cs @@ -0,0 +1,280 @@ +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;")); + } +}