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;"));
}
}