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; /// /// 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. /// 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$" }; /// A BATCH row as AX 3.0 stores it: STARTDATE (local date) + STARTTIME (seconds since midnight), server at UTC+2. 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().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? numbers = null, IReadOnlyList? recIds = null, AxVersion version = AxVersion.Axapta30) => new() { CapturedAt = Now, Thresholds = Thresholds, Databases = [new AxSequenceDatabase { Database = "AX30", Version = version, NumberSequences = numbers ?? [], RecIds = recIds ?? [] }], }; /// A NUMBERSEQUENCETABLE row as AX 3.0 stores it (int LOWEST/HIGHEST/NEXTREC per DATAAREAID, local cleanup date + seconds). 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); } [Fact] public void Ax010_ax30_keeps_host_and_program_out_of_the_explanation_which_may_reach_the_hub() { var finding = Assert.Single(Run(new AxBlockingUserRule(), With(AxDatabases(AxVersion.Axapta30), Blocking(null))).Findings); Assert.DoesNotContain("AOS01", finding.Explanation + finding.Title + finding.Subject, StringComparison.Ordinal); Assert.DoesNotContain("Microsoft Dynamics AX", finding.Explanation, StringComparison.Ordinal); Assert.True(finding.Evidence.Single(e => e.Name == "Vært / program").Sensitive); Assert.True(finding.Evidence.Single(e => e.Name == "CONTEXT_INFO (rå)").Sensitive); } [Fact] public void Ax001_ax30_rows_of_one_recurring_job_give_one_finding_with_the_highest_severity() { // AX 3.0 keeps a BATCH row per recurrence: the failed run and the next waiting one share the job key. var history = new AxBatchHistory(); history.Add("AX30", new AxBatchRun("4711|dat|NAT", "R1", "Lagerafslutning", "dat", Utc.AddHours(-3), Utc.AddHours(-2), AxBatchStatus.Error)); var failed = Job30(Batch30(50, 2, Utc.AddHours(-3)), history) with { EndedUtc = Utc.AddHours(-2) }; var waiting = Job30(Batch30(51, 0, Utc.AddHours(1)), history); var finding = Assert.Single(Run(new AxBatchFailedRule(), With(Batch(failed, waiting))).Findings); Assert.Equal(Severity.High, finding.Severity); Assert.Equal("database: AX30 · batch: 4711|dat|NAT", finding.Subject); } [Fact] public void Ax004_one_long_earlier_run_does_not_hide_a_stuck_job_and_ax002_leaves_a_stuck_job_to_ax004() { var row = Batch30(60, 1, Utc.AddHours(-9)); var oneLongRun = Job30(row, History("4711|dat|NAT", 1, TimeSpan.FromHours(10)), Utc.AddHours(-7)); Assert.Single(Run(new AxBatchStuckRule(), With(Batch(oneLongRun))).Findings); var stuckWithHistory = Job30(row, History("4711|dat|NAT", 10, TimeSpan.FromMinutes(20)), Utc.AddHours(-7)); Assert.Single(Run(new AxBatchStuckRule(), With(Batch(stuckWithHistory))).Findings); Assert.Equal(RuleOutcome.Passed, Run(new AxBatchLongRunningRule(), With(Batch(stuckWithHistory))).Outcome); } [Fact] public void An_executing_job_without_any_start_time_is_skipped_not_an_error() { var unknown = Job30(Batch30(70, 1, Utc.AddHours(-9)), History("4711|dat|NAT", 10, TimeSpan.FromMinutes(20))); Assert.Null(unknown.Running(Now)); Assert.Equal(RuleOutcome.Passed, Run(new AxBatchStuckRule(), With(Batch(unknown))).Outcome); Assert.Equal(RuleOutcome.Passed, Run(new AxBatchLongRunningRule(), With(Batch(unknown))).Outcome); } }