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