test(configdb): guard test for AuditLog append-only invariant (M2.10, #18)
Adds AuditLogAppendOnlyGuardTests.cs to
tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/ — a code-level backstop
for the DB-role DENY UPDATE / DENY DELETE control established in migration
20260602174346_CollapseAuditLogToCanonical.
The guard scans every non-Designer, non-Snapshot *.cs file in the
ConfigurationDatabase source tree and fails the test run if any line matches the
DML-syntax pattern:
UPDATE\s+(?:dbo\.)?AuditLog\b
DELETE\s+(?:FROM\s+)?(?:dbo\.)?AuditLog\b
The tight DML-syntax pattern naturally excludes false positives without extra
exclusion checks: DENY UPDATE ON dbo.AuditLog is not matched (UPDATE is followed
by ON, not the table name); ALTER TABLE … SWITCH and TRUNCATE contain no UPDATE/
DELETE keyword; comments with UPDATE/AuditLog in separate clauses are not matched.
Self-verifying unit tests (ContainsAuditLogMutation_*) prove the helper:
- returns false on clean-source lines (INSERT, SELECT, DENY DDL, ALTER SWITCH,
TRUNCATE, DELETE FROM Notifications);
- returns TRUE on planted violations (UPDATE AuditLog SET …, DELETE FROM
dbo.AuditLog WHERE …, lower-case variants);
- returns false on the exact DENY/GRANT/partition-switch strings from the
production migration files.
All 256 ConfigurationDatabase.Tests pass; solution builds 0 W / 0 E.
This commit is contained in:
@@ -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"},
|
||||
|
||||
+280
@@ -0,0 +1,280 @@
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
///
|
||||
/// <b>Matching rule (see <c>ContainsAuditLogMutation</c> for full detail)</b>
|
||||
/// A line is flagged as a violation iff it matches the DML-syntax pattern:
|
||||
/// • <c>UPDATE\s+(?:dbo\.)?AuditLog\b</c> — UPDATE targeting AuditLog
|
||||
/// • <c>DELETE\s+(?:FROM\s+)?(?:dbo\.)?AuditLog\b</c> — 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.
|
||||
/// </summary>
|
||||
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.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// Returns <see langword="true"/> when the supplied text (typically a single
|
||||
/// source line) contains a SQL UPDATE or DELETE DML statement that directly
|
||||
/// targets the <c>AuditLog</c> table.
|
||||
///
|
||||
/// <b>Matching rule.</b> The regex requires the DML keyword to be
|
||||
/// immediately followed (possibly via FROM) by the optional schema prefix
|
||||
/// <c>dbo.</c> and then the table name <c>AuditLog</c> as a whole word:
|
||||
/// <code>
|
||||
/// UPDATE\s+(?:dbo\.)?AuditLog\b
|
||||
/// DELETE\s+(?:FROM\s+)?(?:dbo\.)?AuditLog\b
|
||||
/// </code>
|
||||
/// This tight DML-syntax pattern naturally excludes false positives without
|
||||
/// any additional keyword checks:
|
||||
/// <list type="bullet">
|
||||
/// <item><description>
|
||||
/// <c>DENY UPDATE ON dbo.AuditLog …</c> — "UPDATE ON" is never immediately
|
||||
/// followed by AuditLog; the pattern requires UPDATE → optional schema → AuditLog.
|
||||
/// </description></item>
|
||||
/// <item><description>
|
||||
/// <c>ALTER TABLE dbo.AuditLog SWITCH …</c> — no UPDATE/DELETE keyword present.
|
||||
/// </description></item>
|
||||
/// <item><description>
|
||||
/// <c>// AuditLog is append-only; never issue an UPDATE against it.</c> —
|
||||
/// UPDATE is not followed by AuditLog here.
|
||||
/// </description></item>
|
||||
/// <item><description>
|
||||
/// <c>DELETE FROM dbo.Notifications …</c> — AuditLog not present.
|
||||
/// </description></item>
|
||||
/// </list>
|
||||
/// </summary>
|
||||
/// <param name="text">A single source line (or any string to probe).</param>
|
||||
/// <returns><see langword="true"/> if a mutation against AuditLog is detected.</returns>
|
||||
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<string>();
|
||||
|
||||
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;"));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user