Batch jobs: failed (001), longer than their own history (002), late or never started (003), stuck in Executing (004). Number sequences: running out with a forecast from SQLInsight's own samples (005), format shorter than HIGHEST (006), blocked but still referenced (007), continuous and not cleaned up (008). RecId running out, flagged far earlier for AX 3.0's 32-bit RecId (009). AX user behind blocking from CONTEXT_INFO, honest where AX 3.0 cannot tell (010). Subjects carry the database, so findings follow the hub opt-in. SQL Scripts/<rule>.sql (and _collectors/) for every AX rule added or changed in 1.4, incl. COM-AX-001..007: a Danish header plus the exact SQL SQLInsight sends, generated from the code and checked by SqlScriptTests (rewrite with SQLINSIGHT_UPDATE_SQL_SCRIPTS=1). Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
299 lines
16 KiB
C#
299 lines
16 KiB
C#
using SQLInsight.Core.Data;
|
|
using SQLInsight.Core.DynamicsAx;
|
|
using SQLInsight.Core.Findings;
|
|
using SQLInsight.Rules.DynamicsAx;
|
|
using SQLInsight.Rules.Engine;
|
|
using static SQLInsight.Rules.Tests.TestInstances;
|
|
|
|
namespace SQLInsight.Rules.Tests;
|
|
|
|
/// <summary>
|
|
/// The Dynamics AX rules on realistic data: AX 3.0 rows as the real customer schema stores them (bmssa, local date +
|
|
/// seconds since midnight, 32-bit RecId), interpreted by the AX 3.0 adapter, and AX 2012 rows by the 2012 adapter.
|
|
/// </summary>
|
|
public class DynamicsAxRuleTests
|
|
{
|
|
private static readonly DateTime Utc = Now.UtcDateTime;
|
|
private static readonly AxThresholds Thresholds = new();
|
|
|
|
private static RuleResult Run(IRule rule, RuleContext context) => Assert.Single(new RuleEngine([rule]).Evaluate(context));
|
|
|
|
private static AxDatabase Ax30(bool readable = true) =>
|
|
AxDetection.Recognise("AX30", AxSchemaFile.Find(Path.Combine(AppContext.BaseDirectory, "DynamicsAx"), "schema-ax30")!.DetectionRows(readable), ["AX30"])!
|
|
with { AgentLogin = @"KUNDE\svc-sqlinsight$" };
|
|
|
|
/// <summary>A BATCH row as AX 3.0 stores it: STARTDATE (local date) + STARTTIME (seconds since midnight), server at UTC+2.</summary>
|
|
private static AxBatchJobRow Batch30(long recId, int status, DateTime plannedUtc, int classNumber = 4711) =>
|
|
new(recId, classNumber, null, "dat", "NAT", status, plannedUtc.AddHours(2).Date, (int)plannedUtc.AddHours(2).TimeOfDay.TotalSeconds,
|
|
null, null, null, null, -120);
|
|
|
|
private static AxBatchSnapshot Batch(params AxBatchJob[] jobs) => new()
|
|
{
|
|
CapturedAt = Now, Thresholds = Thresholds, Databases = [new AxBatchDatabase { Database = "AX30", Version = AxVersion.Axapta30, Jobs = jobs }],
|
|
};
|
|
|
|
private static AxBatchJob Job30(AxBatchJobRow row, AxBatchHistory? history = null, DateTime? firstSeen = null) =>
|
|
AxSchemaAdapter.Ax30.Interpret(row, history ?? new AxBatchHistory(), "AX30", firstSeen);
|
|
|
|
private static AxBatchHistory History(string jobKey, int runs, TimeSpan duration, string database = "AX30")
|
|
{
|
|
var history = new AxBatchHistory();
|
|
for (var i = 1; i <= runs; i++)
|
|
{
|
|
history.Add(database, new AxBatchRun(jobKey, "R" + i, "Lagerafslutning", "dat", Utc.AddDays(-i), Utc.AddDays(-i) + duration, AxBatchStatus.Finished));
|
|
}
|
|
|
|
return history;
|
|
}
|
|
|
|
[Fact]
|
|
public void Without_ax_databases_the_ax_rules_are_not_relevant()
|
|
{
|
|
var empty = new AxBatchSnapshot { CapturedAt = Now, Thresholds = Thresholds, Databases = [] };
|
|
var sequences = new AxSequenceSnapshot { CapturedAt = Now, Thresholds = Thresholds, Databases = [] };
|
|
var ax = new AxDatabaseSnapshot { CapturedAt = Now, Databases = [] };
|
|
var rules = BuiltInRules.Create().OfType<DynamicsAxRule>().ToList();
|
|
|
|
Assert.All(rules, rule =>
|
|
Assert.Equal(RuleOutcome.NotApplicable, Run(rule, With(empty, sequences, ax, new BlockingSnapshot { CapturedAt = Now, Sessions = [] })).Outcome));
|
|
Assert.Equal(10, rules.Count);
|
|
Assert.All(rules, r => Assert.StartsWith("SQLI-AX-", r.Id, StringComparison.Ordinal));
|
|
Assert.All(rules, r => Assert.True(Core.Configuration.DynamicsAxFindings.IsDynamicsAxRule(r.Id)));
|
|
}
|
|
|
|
[Fact]
|
|
public void Without_the_read_grant_the_batch_rules_are_not_measurable_with_the_exact_grant()
|
|
{
|
|
var reason = AxSchemaAdapter.Ax30.BatchJobs(Ax30(readable: false)).NotMeasurable!;
|
|
var snapshot = new AxBatchSnapshot
|
|
{
|
|
CapturedAt = Now, Thresholds = Thresholds, Databases = [new AxBatchDatabase { Database = "AX30", Version = AxVersion.Axapta30, NotMeasurable = reason }],
|
|
};
|
|
|
|
var result = Run(new AxBatchFailedRule(), With(snapshot));
|
|
|
|
Assert.Equal(RuleOutcome.NotMeasurable, result.Outcome);
|
|
Assert.Contains(@"GRANT SELECT ON [bmssa].[BATCH] TO [KUNDE\svc-sqlinsight$];", result.Reason, StringComparison.Ordinal);
|
|
}
|
|
|
|
[Fact]
|
|
public void Ax001_a_batch_job_in_error_is_high_and_an_old_error_is_not_reported()
|
|
{
|
|
var failed = Job30(Batch30(10, 2, Utc.AddHours(-3)));
|
|
var old = Job30(Batch30(11, 2, Utc.AddDays(-3), classNumber: 99));
|
|
|
|
var finding = Assert.Single(Run(new AxBatchFailedRule(), With(Batch(failed, old))).Findings);
|
|
|
|
Assert.Equal(Severity.High, finding.Severity);
|
|
Assert.Equal("database: AX30 · batch: 4711|dat|NAT", finding.Subject);
|
|
Assert.Contains("Fejl (STATUS = 2)", finding.Evidence.Single(e => e.Name == "Status i AX").Value, StringComparison.Ordinal);
|
|
Assert.Equal(RuleOutcome.Passed, Run(new AxBatchFailedRule(), With(Batch(Job30(Batch30(12, 0, Utc.AddMinutes(10)))))).Outcome);
|
|
}
|
|
|
|
[Fact]
|
|
public void Ax001_a_recurring_job_whose_last_run_failed_is_reported_while_it_waits_for_the_next_run()
|
|
{
|
|
var row = new AxBatchJobRow(5_000_000_001, null, "Integration til webshop", "dk01", null, 1, Utc.AddHours(1), null, null, null, null, null, 0);
|
|
var history = new AxBatchHistory();
|
|
history.Add("AX2012", new AxBatchRun("5000000001", "H1", "Integration til webshop", "dk01", Utc.AddHours(-2), Utc.AddHours(-1), AxBatchStatus.Error));
|
|
var job = AxSchemaAdapter.Ax2012.Interpret(row, history, "AX2012", null);
|
|
var snapshot = new AxBatchSnapshot
|
|
{
|
|
CapturedAt = Now, Thresholds = Thresholds, Databases = [new AxBatchDatabase { Database = "AX2012", Version = AxVersion.Ax2012R2, Jobs = [job] }],
|
|
};
|
|
|
|
var finding = Assert.Single(Run(new AxBatchFailedRule(), With(snapshot)).Findings);
|
|
|
|
Assert.Contains("Integration til webshop", finding.Title, StringComparison.Ordinal);
|
|
Assert.Contains("ikke verificeret mod kundedata", finding.Evidence.Single(e => e.Name == "AX-version").Value, StringComparison.Ordinal);
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData(25, null)]
|
|
[InlineData(45, Severity.Medium)]
|
|
[InlineData(100, Severity.High)]
|
|
public void Ax002_a_job_running_much_longer_than_its_own_history(int runningMinutes, Severity? expected)
|
|
{
|
|
var row = Batch30(20, 1, Utc.AddHours(-5));
|
|
var job = Job30(row, History("4711|dat|NAT", 10, TimeSpan.FromMinutes(20)), firstSeen: Utc.AddMinutes(-runningMinutes));
|
|
|
|
var result = Run(new AxBatchLongRunningRule(), With(Batch(job)));
|
|
|
|
Assert.Equal(expected, result.Findings.SingleOrDefault()?.Severity);
|
|
|
|
// Too little history to compare with.
|
|
var young = Job30(row, History("4711|dat|NAT", 3, TimeSpan.FromMinutes(20)), Utc.AddHours(-3));
|
|
Assert.Equal(RuleOutcome.Passed, Run(new AxBatchLongRunningRule(), With(Batch(young))).Outcome);
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData(20, null)]
|
|
[InlineData(45, Severity.Medium)]
|
|
[InlineData(60 * 30, Severity.High)]
|
|
public void Ax003_a_waiting_job_past_its_planned_start_is_late_and_after_a_day_never_started(int minutesLate, Severity? expected)
|
|
{
|
|
var job = Job30(Batch30(30, 0, Utc.AddMinutes(-minutesLate)));
|
|
|
|
var finding = Run(new AxBatchLateRule(), With(Batch(job))).Findings.SingleOrDefault();
|
|
|
|
Assert.Equal(expected, finding?.Severity);
|
|
if (expected == Severity.High)
|
|
{
|
|
Assert.Contains("aldrig startet", finding!.Title, StringComparison.Ordinal);
|
|
Assert.Contains("lokal dato + sekunder efter midnat", finding.Evidence.Single(e => e.Name == "Tid").Value, StringComparison.Ordinal);
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public void Ax003_held_jobs_are_not_late()
|
|
{
|
|
var held = Job30(Batch30(31, 4, Utc.AddDays(-2)));
|
|
|
|
Assert.Equal(AxBatchStatus.Hold, held.Status);
|
|
Assert.Equal(RuleOutcome.Passed, Run(new AxBatchLateRule(), With(Batch(held))).Outcome);
|
|
}
|
|
|
|
[Fact]
|
|
public void Ax004_a_job_executing_for_hours_is_stuck_unless_it_normally_runs_that_long()
|
|
{
|
|
var row = Batch30(40, 1, Utc.AddHours(-9));
|
|
var stuck = Job30(row, firstSeen: Utc.AddHours(-7));
|
|
var normallyLong = Job30(row, History("4711|dat|NAT", 10, TimeSpan.FromHours(6)), Utc.AddHours(-7));
|
|
|
|
var finding = Assert.Single(Run(new AxBatchStuckRule(), With(Batch(stuck))).Findings);
|
|
|
|
Assert.Equal(Severity.High, finding.Severity);
|
|
Assert.Contains("Målt fra første gang SQLInsight så jobbet køre", finding.Evidence.Single(e => e.Name == "Bemærk").Value, StringComparison.Ordinal);
|
|
Assert.Equal(RuleOutcome.Passed, Run(new AxBatchStuckRule(), With(Batch(normallyLong))).Outcome);
|
|
Assert.Equal(RuleOutcome.Passed, Run(new AxBatchStuckRule(), With(Batch(Job30(row, firstSeen: Utc.AddHours(-1))))).Outcome);
|
|
}
|
|
|
|
private static AxSequenceSnapshot Sequences(IReadOnlyList<AxNumberSequence>? numbers = null, IReadOnlyList<AxRecIdSequence>? recIds = null,
|
|
AxVersion version = AxVersion.Axapta30) => new()
|
|
{
|
|
CapturedAt = Now, Thresholds = Thresholds,
|
|
Databases = [new AxSequenceDatabase { Database = "AX30", Version = version, NumberSequences = numbers ?? [], RecIds = recIds ?? [] }],
|
|
};
|
|
|
|
/// <summary>A NUMBERSEQUENCETABLE row as AX 3.0 stores it (int LOWEST/HIGHEST/NEXTREC per DATAAREAID, local cleanup date + seconds).</summary>
|
|
private static AxNumberSequence Number30(string code, long next, long highest = 999_999, string format = "######", bool blocked = false, bool continuous = false,
|
|
bool? referenced = true, DateTime? lastCleanLocal = null, params AxSample[] samples) =>
|
|
AxSchemaAdapter.Ax30.Interpret(new AxNumberSequenceRow(1, code, "dat", "Tekst", 1, highest, next, format, blocked, continuous, false, false, referenced,
|
|
lastCleanLocal?.Date ?? new DateTime(1900, 1, 1), (int?)lastCleanLocal?.TimeOfDay.TotalSeconds ?? 0, 24, -120), samples, Utc, 30);
|
|
|
|
[Theory]
|
|
[InlineData(500_000, null)]
|
|
[InlineData(850_000, Severity.Medium)]
|
|
[InlineData(960_000, Severity.High)]
|
|
[InlineData(1_000_000, Severity.Critical)]
|
|
public void Ax005_number_sequence_usage(long next, Severity? expected) =>
|
|
Assert.Equal(expected, Run(new AxNumberSequenceExhaustionRule(), With(Sequences([Number30("Debitor", next)]))).Findings.SingleOrDefault()?.Severity);
|
|
|
|
[Fact]
|
|
public void Ax005_forecast_from_sqlinsights_own_samples()
|
|
{
|
|
// 50 % used, but 10.000 numbers a day: 50 days left.
|
|
var sequence = Number30("Ordre", 500_001, samples: [new AxSample(Utc.AddDays(-10), 400_001)]);
|
|
|
|
var finding = Assert.Single(Run(new AxNumberSequenceExhaustionRule(), With(Sequences([sequence]))).Findings);
|
|
|
|
Assert.Equal(Severity.Medium, finding.Severity);
|
|
Assert.Contains("løber tør om ca. 50 dage", finding.Title, StringComparison.Ordinal);
|
|
Assert.Equal("database: AX30 · nummerserie: dat/Ordre", finding.Subject);
|
|
}
|
|
|
|
[Fact]
|
|
public void Ax006_format_shorter_than_highest()
|
|
{
|
|
var tooShort = Number30("Faktura", 12_000, highest: 99_999_999, format: "FA-####");
|
|
var fits = Number30("Faktura", 12_000, highest: 99_999, format: "FA-#####");
|
|
|
|
Assert.Equal(Severity.High, Assert.Single(Run(new AxNumberSequenceFormatRule(), With(Sequences([tooShort]))).Findings).Severity);
|
|
Assert.Equal(RuleOutcome.Passed, Run(new AxNumberSequenceFormatRule(), With(Sequences([fits]))).Outcome);
|
|
}
|
|
|
|
[Fact]
|
|
public void Ax007_blocked_but_still_referenced()
|
|
{
|
|
Assert.Single(Run(new AxNumberSequenceBlockedRule(), With(Sequences([Number30("Gammel", 10, blocked: true)]))).Findings);
|
|
Assert.Equal(RuleOutcome.Passed, Run(new AxNumberSequenceBlockedRule(), With(Sequences([Number30("Gammel", 10, blocked: true, referenced: false)]))).Outcome);
|
|
Assert.Equal(RuleOutcome.Passed, Run(new AxNumberSequenceBlockedRule(), With(Sequences([Number30("Ukendt", 10, blocked: true, referenced: null)]))).Outcome);
|
|
}
|
|
|
|
[Fact]
|
|
public void Ax008_continuous_sequence_never_or_long_ago_cleaned_up()
|
|
{
|
|
var never = Number30("Kont", 100, continuous: true);
|
|
var recent = Number30("Kont", 100, continuous: true, lastCleanLocal: Utc.AddDays(-1).AddHours(2));
|
|
var old = Number30("Kont", 100, continuous: true, lastCleanLocal: Utc.AddDays(-30).AddHours(2));
|
|
|
|
Assert.Contains("aldrig ryddet op", Assert.Single(Run(new AxNumberSequenceContinuousRule(), With(Sequences([never]))).Findings).Title, StringComparison.Ordinal);
|
|
Assert.Equal(RuleOutcome.Passed, Run(new AxNumberSequenceContinuousRule(), With(Sequences([recent]))).Outcome);
|
|
Assert.Equal(Severity.Low, Assert.Single(Run(new AxNumberSequenceContinuousRule(), With(Sequences([old]))).Findings).Severity);
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData(1_000_000_000, null)]
|
|
[InlineData(1_200_000_000, Severity.High)]
|
|
[InlineData(1_700_000_000, Severity.Critical)]
|
|
public void Ax009_ax30_recid_is_flagged_early_and_critical_from_75_percent(long next, Severity? expected)
|
|
{
|
|
var recId = AxSchemaAdapter.Interpret(new AxRecIdRow(-1, "dat", next, 0, null), nextValIsInt: true, [], Utc, 30);
|
|
|
|
Assert.Equal(expected, Run(new AxRecIdExhaustionRule(), With(Sequences(recIds: [recId]))).Findings.SingleOrDefault()?.Severity);
|
|
}
|
|
|
|
[Fact]
|
|
public void Ax009_64_bit_recid_only_matters_with_a_lowered_maxval_or_a_fast_forecast()
|
|
{
|
|
var huge = AxSchemaAdapter.Interpret(new AxRecIdRow(77, null, 5_000_000_000, null, "CUSTTABLE"), nextValIsInt: false, [], Utc, 30);
|
|
var lowered = AxSchemaAdapter.Interpret(new AxRecIdRow(77, null, 900, 1_000, "CUSTTABLE"), nextValIsInt: false, [], Utc, 30);
|
|
|
|
Assert.Null(AxRecIdExhaustionRule.Assess(huge, Thresholds));
|
|
Assert.Equal(Severity.High, AxRecIdExhaustionRule.Assess(lowered, Thresholds));
|
|
var finding = Assert.Single(Run(new AxRecIdExhaustionRule(), With(Sequences(recIds: [lowered], version: AxVersion.Ax2012R2))).Findings);
|
|
Assert.Contains("CUSTTABLE", finding.Title, StringComparison.Ordinal);
|
|
}
|
|
|
|
private static AxDatabaseSnapshot AxDatabases(AxVersion version) => new()
|
|
{
|
|
CapturedAt = Now, Databases = [new AxDatabase { Database = "AX", Schema = version == AxVersion.Axapta30 ? "bmssa" : "dbo", Version = version }],
|
|
};
|
|
|
|
private static BlockingSnapshot Blocking(string? contextInfo, long waitMs = 120_000) => new()
|
|
{
|
|
CapturedAt = Now,
|
|
Sessions =
|
|
[
|
|
new SessionInfo
|
|
{
|
|
SessionId = 61, Status = "sleeping", DatabaseName = "AX", LoginName = "AXSERVICE", HostName = "AOS01", ProgramName = "Microsoft Dynamics AX",
|
|
OpenTransactionCount = 1, ContextInfo = contextInfo, SqlText = "UPDATE INVENTSUM SET …",
|
|
},
|
|
new SessionInfo { SessionId = 62, BlockingSessionId = 61, DatabaseName = "AX", WaitTimeMs = waitMs, WaitType = "LCK_M_U" },
|
|
],
|
|
};
|
|
|
|
[Fact]
|
|
public void Ax010_ax2012_names_the_ax_user_from_context_info_as_sensitive_evidence()
|
|
{
|
|
var context = Convert.ToHexString(System.Text.Encoding.ASCII.GetBytes("anne 17"));
|
|
|
|
var finding = Assert.Single(Run(new AxBlockingUserRule(), With(AxDatabases(AxVersion.Ax2012R2), Blocking(context))).Findings);
|
|
|
|
var user = finding.Evidence.Single(e => e.Name == "AX-bruger");
|
|
Assert.Equal(("anne", true), (user.Value, user.Sensitive));
|
|
Assert.DoesNotContain("anne", finding.Title + finding.Subject, StringComparison.Ordinal);
|
|
Assert.Equal("database: AX · blokering: session 61", finding.Subject);
|
|
}
|
|
|
|
[Fact]
|
|
public void Ax010_ax30_says_honestly_that_the_ax_user_cannot_be_seen()
|
|
{
|
|
var finding = Assert.Single(Run(new AxBlockingUserRule(), With(AxDatabases(AxVersion.Axapta30), Blocking(null))).Findings);
|
|
|
|
Assert.Equal("ukendt", finding.Evidence.Single(e => e.Name == "AX-bruger").Value);
|
|
Assert.Contains("sætter ikke CONTEXT_INFO", finding.Explanation, StringComparison.Ordinal);
|
|
Assert.Equal(RuleOutcome.Passed, Run(new AxBlockingUserRule(), With(AxDatabases(AxVersion.Axapta30), Blocking(null, waitMs: 5_000))).Outcome);
|
|
}
|
|
}
|