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.
///
/// <b>Known limitations:</b> This guard scans only raw SQL strings — EF Core methods
/// such as <c>ExecuteDeleteAsync</c>, <c>ExecuteUpdateAsync</c>, and <c>RemoveRange</c>
/// 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.
///
/// <b>Allow-list.</b> Two narrow maintenance-path exemptions carry the exact
/// <see cref="AuditPurgeAllowedMarker"/> trailing comment:
/// <list type="bullet">
///   <item><description>
///     M5.5 (T3) — <c>AuditLogRepository.PurgeChannelOlderThanAsync</c>: the
///     one sanctioned batched <c>DELETE TOP (@batch) FROM dbo.AuditLog</c>,
///     running on the purge/maintenance connection.
///   </description></item>
///   <item><description>
///     M5.6 (T5) — <c>AuditLogRepository.BackfillSourceNodeAsync</c>: the
///     one sanctioned batched <c>UPDATE TOP (@batch) dbo.AuditLog SET SourceNode</c>,
///     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.
///   </description></item>
/// </list>
/// The allow-list is applied in the file-scan test only
/// (<see cref="ConfigurationDatabase_ShouldNotContainAuditLogMutations"/>) — the
/// raw mutation matcher (<see cref="ContainsAuditLogMutation"/>) 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.
/// </summary>
public class AuditLogAppendOnlyGuardTests
{
    /// <summary>
    /// 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 <c>AuditLogRepository.PurgeChannelOlderThanAsync</c>
    /// (M5.5 T3 batched DELETE) and <c>AuditLogRepository.BackfillSourceNodeAsync</c>
    /// (M5.6 T5 batched UPDATE). Kept deliberately specific so it cannot be pasted
    /// onto an unrelated mutation without a reviewer noticing.
    /// </summary>
    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.
    // ---------------------------------------------------------------------------

    /// <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> or <c>[dbo].</c>) and then the table name <c>AuditLog</c>
    /// or <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 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);

    /// <summary>
    /// Returns <see langword="true"/> when <paramref name="line"/> carries the narrow
    /// <see cref="AuditPurgeAllowedMarker"/> exemption. Sanctioned uses are:
    /// <list type="bullet">
    ///   <item><description>M5.5 T3 — the per-channel maintenance-path batched DELETE.</description></item>
    ///   <item><description>M5.6 T5 — the SourceNode sentinel batched UPDATE.</description></item>
    /// </list>
    /// 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.
    /// </summary>
    /// <param name="line">A single source line already known to contain a mutation.</param>
    /// <returns><see langword="true"/> if the line is a sanctioned maintenance-path exemption.</returns>
    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<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]) && !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.");
    }
}
