- "Ikke målbar" texts, notes and detection problems carry the GRANT text with the agent's login and database user. API v1 (batch-jobs, number-sequences, recids, databases, rule-states) returned them as they were, although logins are redacted everywhere else. AxReadAccess.WithoutPrincipals replaces them with the placeholder unless sensitive values are allowed; the grant script also hides a database user that differs from the login. - SQLI-AX-010 put the host and program name (in 2-tier AX 3.0 the user's own PC) in the finding's explanation, which reaches the hub unredacted for an opted-in database. They are only in the sensitive evidence now, with the raw CONTEXT_INFO; the CONTEXT_INFO parsing says it is not verified against customer data. - A database named e.g. "AX · test" was read from a subject as "AX", whose hub opt-in then applied. Middle dots in the database are doubled in AX subjects, and the AX community rules' subjects are built the same way. - API v1 now also has the detection problems (/detection-problems), what each raw batch STATUS means, and the thresholds behind the number sequence and RecId pages; the blocking explanation is no longer redacted (it holds nothing sensitive now). Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
511 lines
26 KiB
C#
511 lines
26 KiB
C#
using System.Text.RegularExpressions;
|
|
using SQLInsight.Core.Configuration;
|
|
using SQLInsight.Core.Data;
|
|
using SQLInsight.Core.DynamicsAx;
|
|
|
|
namespace SQLInsight.Core.Tests;
|
|
|
|
/// <summary>Builds AX databases as the detector would see them, from the schema files in docs/dynamics-ax.</summary>
|
|
internal static class AxFixtures
|
|
{
|
|
public static string Directory => Path.Combine(AppContext.BaseDirectory, "DynamicsAx");
|
|
|
|
public static AxSchemaFile Schema(string fileName) => AxSchemaFile.Find(Directory, fileName)
|
|
?? throw new InvalidOperationException($"{fileName}.tsv mangler i docs/dynamics-ax.");
|
|
|
|
/// <summary>The real AX 3.0 schema from a customer (bmssa, 32-bit RECID).</summary>
|
|
public static AxSchemaFile Ax30 => Schema("schema-ax30");
|
|
|
|
public static AxDatabase Recognise(AxSchemaFile schema, string name = "AXDB", bool readable = true, string? user = null, IEnumerable<string>? databases = null,
|
|
Func<AxDetectionRow, bool>? keep = null) =>
|
|
AxDetection.Recognise(name, schema.DetectionRows(readable, user).Where(keep ?? (_ => true)).ToList(), databases ?? [name])
|
|
?? throw new InvalidOperationException("Ikke genkendt som AX.");
|
|
}
|
|
|
|
public class AxDetectionTests
|
|
{
|
|
[Fact]
|
|
public void The_real_ax30_schema_is_axapta_30_in_bmssa_and_verified()
|
|
{
|
|
var ax = AxFixtures.Recognise(AxFixtures.Ax30);
|
|
|
|
Assert.Equal((AxVersion.Axapta30, "bmssa", true), (ax.Version, ax.Schema, ax.Verified));
|
|
Assert.Equal("int", ax.Table("BATCH")!.Column("STARTTIME")!.Type);
|
|
Assert.Equal(10, ax.Table("SQLDICTIONARY")!.Column("RECID")!.Precision);
|
|
Assert.False(AxFixtures.Ax30.Documented);
|
|
}
|
|
|
|
[Fact]
|
|
public void Ax2009_and_2012_are_told_apart_by_batchjob_scope_partition_and_the_model_database()
|
|
{
|
|
var ax2009 = AxFixtures.Schema("schema-ax2009");
|
|
var ax2012 = AxFixtures.Schema("schema-ax2012");
|
|
|
|
Assert.Equal(AxVersion.Ax2009, AxFixtures.Recognise(ax2009).Version);
|
|
Assert.Equal(AxVersion.Ax2012R2, AxFixtures.Recognise(ax2012, "AX2012", databases: ["AX2012", "AX2012_model"]).Version);
|
|
Assert.Equal("AX2012_model", AxFixtures.Recognise(ax2012, "AX2012", databases: ["AX2012", "ax2012_MODEL"]).ModelDatabase, StringComparer.OrdinalIgnoreCase);
|
|
Assert.Equal(AxVersion.FinanceOperations, AxFixtures.Recognise(ax2012, "AxDB").Version);
|
|
Assert.Equal(AxVersion.Ax2012, AxFixtures.Recognise(ax2012, keep: r => r.Column != "PARTITION").Version);
|
|
Assert.Equal(AxVersion.Ax40, AxFixtures.Recognise(ax2009, keep: r => r.Table is not ("BATCHJOB" or "BATCHJOBHISTORY")).Version);
|
|
Assert.All(new[] { AxVersion.Ax40, AxVersion.Ax2009, AxVersion.Ax2012, AxVersion.Ax2012R2, AxVersion.FinanceOperations }, v => Assert.False(AxVersions.IsVerified(v)));
|
|
Assert.Contains(AxFixtures.Recognise(ax2009).Notes, n => n.Contains("ikke verificeret mod kundedata", StringComparison.Ordinal));
|
|
}
|
|
|
|
[Fact]
|
|
public void A_database_without_sqldictionary_and_dataarea_in_one_schema_is_not_ax()
|
|
{
|
|
var rows = AxFixtures.Ax30.DetectionRows().Where(r => r.Table != "DATAAREA").ToList();
|
|
|
|
Assert.Null(AxDetection.Recognise("NotAx", rows, ["NotAx"]));
|
|
Assert.Null(AxDetection.Recognise("Empty", [], ["Empty"]));
|
|
Assert.Equal(AxVersion.Unknown, AxFixtures.Recognise(AxFixtures.Ax30, keep: r => !(r.Table == "SQLDICTIONARY" && r.Column == "RECID")).Version);
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData("3.0", AxVersion.Axapta30)]
|
|
[InlineData("2012 R2/R3", AxVersion.Ax2012R2)]
|
|
[InlineData("F&O", AxVersion.FinanceOperations)]
|
|
[InlineData("4.0+", AxVersion.Unknown)]
|
|
public void Short_version_texts_round_trip(string text, AxVersion version)
|
|
{
|
|
Assert.Equal(version, AxVersions.Parse(text));
|
|
if (version != AxVersion.Unknown)
|
|
{
|
|
Assert.Equal(text, AxVersions.ShortText(version));
|
|
Assert.True(text.Length <= 16); // insight.ConfiguredDatabases.DynamicsAxVersion varchar(16)
|
|
}
|
|
}
|
|
}
|
|
|
|
public class AxSchemaAdapterTests
|
|
{
|
|
public static TheoryData<string> Adapters() => [.. AxSchemaAdapter.All.Select(a => a.GetType().Name)];
|
|
|
|
private static AxSchemaAdapter Adapter(string name) => AxSchemaAdapter.All.Single(a => a.GetType().Name == name);
|
|
|
|
[Theory]
|
|
[MemberData(nameof(Adapters))]
|
|
public void Every_column_an_adapter_needs_is_in_its_schema_file(string name)
|
|
{
|
|
var adapter = Adapter(name);
|
|
var schema = AxSchemaFile.Find(AxFixtures.Directory, adapter.SchemaFileName);
|
|
if (schema is null)
|
|
{
|
|
// AX 4.0 and F&O: no schema file yet (neither from a customer nor written from the documentation).
|
|
Assert.Contains(adapter.Versions, v => v is AxVersion.Ax40 or AxVersion.FinanceOperations);
|
|
return;
|
|
}
|
|
|
|
Assert.Equal(adapter is not Ax30Adapter, schema.Documented);
|
|
foreach (var (table, columns) in adapter.RequiredColumns)
|
|
{
|
|
Assert.All(columns, c => Assert.True(schema.Has(table, c), $"{schema.Path}: {table}.{c} mangler"));
|
|
}
|
|
|
|
Assert.All(adapter.ReadTables, t => Assert.Contains(t, schema.Tables, StringComparer.OrdinalIgnoreCase));
|
|
}
|
|
|
|
[Fact]
|
|
public void Ax30_queries_use_bmssa_top_and_no_nolock()
|
|
{
|
|
var ax = AxFixtures.Recognise(AxFixtures.Ax30);
|
|
var adapter = AxSchemaAdapter.For(ax.Version)!;
|
|
|
|
var queries = new[] { adapter.BatchJobs(ax), adapter.NumberSequences(ax), adapter.RecIds(ax), AxSchemaAdapter.Companies(ax) };
|
|
|
|
Assert.All(queries, q => Assert.True(q.IsMeasurable, q.NotMeasurable));
|
|
Assert.All(queries, q => Assert.Contains("TOP (", q.Sql!, StringComparison.Ordinal));
|
|
Assert.All(queries, q => Assert.DoesNotContain("NOLOCK", q.Sql!, StringComparison.OrdinalIgnoreCase));
|
|
Assert.Contains("[bmssa].[BATCH]", queries[0].Sql!, StringComparison.Ordinal);
|
|
Assert.Contains("[bmssa].[NUMBERSEQUENCEREFERENCE]", queries[1].Sql!, StringComparison.Ordinal);
|
|
Assert.Contains("N'SEQNO'", queries[2].Sql!, StringComparison.Ordinal);
|
|
Assert.Null(adapter.BatchHistory(ax, null));
|
|
Assert.Empty(queries[0].Notes);
|
|
}
|
|
|
|
[Fact]
|
|
public void Ax2012_reads_the_company_from_the_scope_and_history_by_recid()
|
|
{
|
|
var ax = AxFixtures.Recognise(AxFixtures.Schema("schema-ax2012"), "AX2012", databases: ["AX2012", "AX2012_model"]);
|
|
var adapter = AxSchemaAdapter.For(ax.Version)!;
|
|
|
|
var sequences = adapter.NumberSequences(ax);
|
|
var first = adapter.BatchHistory(ax, null)!;
|
|
var next = adapter.BatchHistory(ax, 10_000)!;
|
|
|
|
Assert.Contains("LEFT JOIN [dbo].[NUMBERSEQUENCESCOPE]", sequences.Sql!, StringComparison.Ordinal);
|
|
Assert.Contains("[NUMBERSEQUENCEID]", sequences.Sql!, StringComparison.Ordinal);
|
|
Assert.Contains("ORDER BY h.[RECID] DESC", first.Sql!, StringComparison.Ordinal);
|
|
Assert.Contains($"h.[RECID] > {10_000 - AxSchemaAdapter.HistoryOverlap}", next.Sql!, StringComparison.Ordinal);
|
|
Assert.Contains("[ORIGSTARTDATETIME]", adapter.BatchJobs(ax).Sql!, StringComparison.Ordinal);
|
|
}
|
|
|
|
[Fact]
|
|
public void Without_read_access_a_check_is_not_measurable_with_the_exact_grant()
|
|
{
|
|
var ax = AxFixtures.Recognise(AxFixtures.Ax30, "AX30", readable: false) with { AgentLogin = @"KUNDE\svc-sqlinsight$" };
|
|
|
|
var batch = AxSchemaAdapter.Ax30.BatchJobs(ax);
|
|
|
|
Assert.False(batch.IsMeasurable);
|
|
Assert.StartsWith("Ikke målbar: agenten må ikke læse bmssa.BATCH i AX30", batch.NotMeasurable, StringComparison.Ordinal);
|
|
Assert.Contains(@"CREATE USER [KUNDE\svc-sqlinsight$] FOR LOGIN [KUNDE\svc-sqlinsight$];", batch.NotMeasurable, StringComparison.Ordinal);
|
|
Assert.Contains(@"GRANT SELECT ON [bmssa].[BATCH] TO [KUNDE\svc-sqlinsight$];", batch.NotMeasurable, StringComparison.Ordinal);
|
|
}
|
|
|
|
[Fact]
|
|
public void A_missing_required_column_names_it_and_a_missing_optional_one_becomes_null_with_a_note()
|
|
{
|
|
var withoutStartTime = AxFixtures.Recognise(AxFixtures.Ax30, keep: r => !(r.Table == "BATCH" && r.Column == "STARTTIME"));
|
|
var withoutFormat = AxFixtures.Recognise(AxFixtures.Ax30, keep: r => !(r.Table == "NUMBERSEQUENCETABLE" && r.Column == "FORMAT"));
|
|
|
|
var batch = AxSchemaAdapter.Ax30.BatchJobs(withoutStartTime);
|
|
var sequences = AxSchemaAdapter.Ax30.NumberSequences(withoutFormat);
|
|
|
|
Assert.Contains("bmssa.BATCH.STARTTIME", batch.NotMeasurable, StringComparison.Ordinal);
|
|
Assert.True(sequences.IsMeasurable);
|
|
Assert.Contains("CAST(NULL AS nvarchar(40))", sequences.Sql!, StringComparison.Ordinal);
|
|
Assert.Contains(sequences.Notes, n => n.Contains("FORMAT", StringComparison.Ordinal));
|
|
}
|
|
|
|
[Fact]
|
|
public void Grant_script_covers_exactly_the_adapters_tables_per_version()
|
|
{
|
|
var lists = Regex.Matches(AxReadAccess.Template(), @"N''\|([A-Z|]+)\|''").Select(m => m.Groups[1].Value.Split('|')).ToList();
|
|
|
|
Assert.Equal(3, lists.Count);
|
|
Assert.Equal(AxSchemaAdapter.Ax30.ReadTables.Order(), lists[0].Order());
|
|
Assert.Equal(AxSchemaAdapter.Ax40.ReadTables.Order(), lists[0].Order());
|
|
Assert.Equal(AxSchemaAdapter.Ax2009.ReadTables.Order(), lists[1].Order());
|
|
Assert.Equal(AxSchemaAdapter.Ax2012.ReadTables.Order(), lists[2].Order());
|
|
Assert.Equal(AxSchemaAdapter.FinanceOperations.ReadTables.Order(), lists[2].Order());
|
|
Assert.Contains("SET @DatabaseName = N'AX30';", AxReadAccess.Script("AX30", "svc"), StringComparison.Ordinal);
|
|
}
|
|
|
|
[Fact]
|
|
public void Grant_text_uses_the_existing_database_user_and_only_existing_tables()
|
|
{
|
|
var ax = AxFixtures.Recognise(AxFixtures.Ax30, "AX30", user: "sqlinsight") with { AgentLogin = "svc" };
|
|
|
|
var grant = AxReadAccess.GrantSql(ax, ax.AgentLogin);
|
|
|
|
Assert.DoesNotContain("CREATE USER", grant, StringComparison.Ordinal);
|
|
Assert.Contains("GRANT SELECT ON [bmssa].[SYSTEMSEQUENCES] TO [sqlinsight];", grant, StringComparison.Ordinal);
|
|
Assert.DoesNotContain("BATCHJOB", grant, StringComparison.Ordinal);
|
|
Assert.Empty(AxReadAccess.Missing(ax));
|
|
}
|
|
}
|
|
|
|
public class AxInterpretationTests
|
|
{
|
|
private static readonly DateTime Now = new(2026, 10, 2, 10, 0, 0, DateTimeKind.Utc);
|
|
|
|
[Fact]
|
|
public void Ax30_times_are_a_local_date_plus_seconds_since_midnight()
|
|
{
|
|
// 02:30:00 local on a server two hours ahead of UTC (offset -120 minutes) is 00:30 UTC.
|
|
Assert.Equal(new DateTime(2026, 10, 2, 0, 30, 0, DateTimeKind.Utc), AxSchemaAdapter.Ax30.ToUtc(new DateTime(2026, 10, 2), 9000, -120));
|
|
Assert.Null(AxSchemaAdapter.Ax30.ToUtc(new DateTime(1900, 1, 1), 0, 0));
|
|
Assert.Equal(new DateTime(2026, 10, 2, 8, 0, 0, DateTimeKind.Utc), AxSchemaAdapter.Ax2009.ToUtc(new DateTime(2026, 10, 2, 8, 0, 0), null, -120));
|
|
}
|
|
|
|
[Fact]
|
|
public void Batch_status_enums_per_version()
|
|
{
|
|
Assert.Equal(AxBatchStatus.Executing, AxSchemaAdapter.Ax30.BatchStatus(1));
|
|
Assert.Equal(AxBatchStatus.Error, AxSchemaAdapter.Ax30.BatchStatus(2));
|
|
Assert.Equal(AxBatchStatus.Waiting, AxSchemaAdapter.Ax2009.BatchStatus(1));
|
|
Assert.Equal(AxBatchStatus.Executing, AxSchemaAdapter.Ax2012.BatchStatus(2));
|
|
Assert.Equal(AxBatchStatus.Canceled, AxSchemaAdapter.Ax2012.BatchStatus(8));
|
|
Assert.Equal(AxBatchStatus.Unknown, AxSchemaAdapter.Ax2012.BatchStatus(42));
|
|
Assert.All(AxSchemaAdapter.All, a => Assert.False(a.BatchStatusVerified));
|
|
}
|
|
|
|
[Fact]
|
|
public void Ax30_jobs_are_keyed_by_class_company_and_group_and_late_jobs_are_measured_from_the_planned_start()
|
|
{
|
|
var row = new AxBatchJobRow(17, 1234, null, "dat", "NAT", 0, new DateTime(2026, 10, 2), 9 * 3600, null, null, null, null, -120);
|
|
|
|
var job = AxSchemaAdapter.Ax30.Interpret(row, new AxBatchHistory(), "AX30", null);
|
|
|
|
Assert.Equal("1234|dat|NAT", job.Key);
|
|
Assert.Equal("Klasse 1234 (gruppe NAT)", job.Caption);
|
|
Assert.Equal(AxBatchStatus.Waiting, job.Status);
|
|
Assert.Equal(new DateTime(2026, 10, 2, 7, 0, 0, DateTimeKind.Utc), job.PlannedStartUtc);
|
|
Assert.Equal(TimeSpan.FromHours(3), job.Late(new DateTimeOffset(Now)));
|
|
}
|
|
|
|
[Fact]
|
|
public void Job_history_gives_median_and_90th_percentile_of_successful_runs_and_is_bounded()
|
|
{
|
|
var history = new AxBatchHistory();
|
|
for (var i = 1; i <= 30; i++)
|
|
{
|
|
history.Add("AX", new AxBatchRun("7", "H" + i, "Lager", "dat", Now.AddHours(-i), Now.AddHours(-i).AddMinutes(i <= 25 ? 10 : 200), i == 1 ? AxBatchStatus.Error : AxBatchStatus.Finished));
|
|
}
|
|
|
|
history.Add("AX", new AxBatchRun("7", "H3", "Lager", "dat", Now, Now.AddMinutes(500), AxBatchStatus.Finished)); // seen before: ignored
|
|
var runs = history.Runs("AX", "7");
|
|
var (median, p90) = AxBatchHistory.Statistics(runs);
|
|
|
|
Assert.Equal(AxBatchHistory.RunsKept, runs.Count);
|
|
Assert.Equal(600, median);
|
|
Assert.Equal(600, p90);
|
|
Assert.Empty(history.Runs("AX", "8"));
|
|
}
|
|
|
|
[Fact]
|
|
public void Observed_run_needs_an_end_and_a_start_from_ax_or_from_sqlinsight()
|
|
{
|
|
var finished = new AxBatchJob { Key = "k", RecId = 5, Caption = "c", Status = AxBatchStatus.Finished, EndedUtc = Now };
|
|
|
|
Assert.Null(AxSchemaAdapter.ObservedRun(finished, null));
|
|
Assert.Equal(TimeSpan.FromMinutes(30), AxSchemaAdapter.ObservedRun(finished, Now.AddMinutes(-30))!.Duration);
|
|
Assert.Equal(TimeSpan.FromMinutes(5), AxSchemaAdapter.ObservedRun(finished with { StartedUtc = Now.AddMinutes(-5) }, Now.AddMinutes(-30))!.Duration);
|
|
Assert.Null(AxSchemaAdapter.ObservedRun(finished with { Status = AxBatchStatus.Executing }, Now.AddMinutes(-30)));
|
|
}
|
|
|
|
[Fact]
|
|
public void Number_sequence_usage_format_width_and_forecast()
|
|
{
|
|
var row = new AxNumberSequenceRow(1, "Debitor", "dat", "Debitorer", 1, 9_999_999, 95_001, "DEB-####", false, false, false, false, true, null, null, null, -120);
|
|
var samples = new[] { new AxSample(Now.AddDays(-20), 85_001), new AxSample(Now.AddDays(-10), 90_001) };
|
|
|
|
var sequence = AxSchemaAdapter.Ax30.Interpret(row, samples, Now, 30);
|
|
|
|
Assert.Equal(4, sequence.FormatDigits);
|
|
Assert.Equal(9_999, sequence.FormatCapacity);
|
|
Assert.True(sequence.FormatTooShort);
|
|
Assert.Equal(9_999, sequence.Max);
|
|
Assert.Equal(100, sequence.UsedPercent);
|
|
Assert.Equal(0, sequence.Remaining);
|
|
Assert.Equal("dat/Debitor", sequence.Key);
|
|
Assert.Equal(500, sequence.Forecast!.PerDay, 3);
|
|
Assert.Equal(0, sequence.Forecast.DaysLeft);
|
|
}
|
|
|
|
[Fact]
|
|
public void Number_sequence_forecast_needs_a_day_of_history_and_ignores_manual_and_cyclic()
|
|
{
|
|
var row = new AxNumberSequenceRow(1, "Ordre", "dat", null, 1, 1_000_000, 500_001, "######", false, false, false, false, null, null, null, null, 0);
|
|
|
|
Assert.Null(AxSchemaAdapter.Ax2009.Interpret(row, [new AxSample(Now.AddHours(-2), 1)], Now, 30).Forecast);
|
|
Assert.Null(AxSchemaAdapter.Ax2009.Interpret(row with { Manual = true }, [new AxSample(Now.AddDays(-5), 1)], Now, 30).Forecast);
|
|
var sequence = AxSchemaAdapter.Ax2009.Interpret(row, [new AxSample(Now.AddDays(-5), 450_001), new AxSample(Now.AddDays(-60), 1)], Now, 30);
|
|
Assert.Equal(10_000, sequence.Forecast!.PerDay, 3);
|
|
Assert.Equal(50, sequence.Forecast.DaysLeft!.Value, 3);
|
|
Assert.Equal(50, sequence.UsedPercent, 3);
|
|
Assert.Equal("1", AxSchemaAdapter.Ax2012.SequenceKey(row));
|
|
}
|
|
|
|
[Fact]
|
|
public void Ax30_recid_is_limited_by_the_int_range()
|
|
{
|
|
var recId = AxSchemaAdapter.Interpret(new AxRecIdRow(-1, "dat", 1_610_612_736, 0, null), nextValIsInt: true,
|
|
[new AxSample(Now.AddDays(-10), 1_600_612_736)], Now, 30);
|
|
var bigint = AxSchemaAdapter.Interpret(new AxRecIdRow(77, null, 5_000_000_000, null, "CUSTTABLE"), nextValIsInt: false, [], Now, 30);
|
|
|
|
Assert.Equal((32, int.MaxValue, 75.0), (recId.Bits, recId.Max, Math.Round(recId.UsedPercent, 1)));
|
|
Assert.Equal(1_000_000, recId.Forecast!.PerDay, 3);
|
|
Assert.Equal(536.87, recId.Forecast.DaysLeft!.Value, 2);
|
|
Assert.Equal("-1/dat", recId.Key);
|
|
Assert.Equal((64, long.MaxValue), (bigint.Bits, bigint.Max));
|
|
Assert.True(bigint.UsedPercent < 0.001);
|
|
}
|
|
}
|
|
|
|
public class AxSessionMapperTests
|
|
{
|
|
private static string Hex(string text, bool unicode = false) =>
|
|
Convert.ToHexString(unicode ? System.Text.Encoding.Unicode.GetBytes(text) : System.Text.Encoding.ASCII.GetBytes(text)) + "000000";
|
|
|
|
private static SessionInfo Session(string? context) => new() { SessionId = 61, HostName = "AOS01", ProgramName = "Microsoft Dynamics AX", ContextInfo = context };
|
|
|
|
[Fact]
|
|
public void Ax2012_context_info_gives_the_ax_user_and_session()
|
|
{
|
|
var identity = AxSessionMapper.Map(Session(Hex("anne 23")), AxVersion.Ax2012R2);
|
|
|
|
Assert.Equal(("anne", 23, "context_info"), (identity.AxUser, identity.AxSessionId, identity.Source));
|
|
Assert.Equal("anne", AxSessionMapper.Map(Session(Hex("23 anne", unicode: true)), AxVersion.Ax2009).AxUser);
|
|
}
|
|
|
|
[Fact]
|
|
public void Ax30_is_honest_that_the_user_cannot_be_seen()
|
|
{
|
|
var identity = AxSessionMapper.Map(Session(null), AxVersion.Axapta30);
|
|
|
|
Assert.False(identity.Known);
|
|
Assert.Contains("sætter ikke CONTEXT_INFO", identity.Explanation, StringComparison.Ordinal);
|
|
// Host and program are sensitive: in the finding's evidence, not in the explanation (which may reach the hub).
|
|
Assert.DoesNotContain("AOS01", identity.Explanation, StringComparison.Ordinal);
|
|
Assert.Contains("Online brugere", identity.Explanation, StringComparison.Ordinal);
|
|
}
|
|
|
|
[Fact]
|
|
public void Without_context_info_the_explanation_says_how_to_turn_it_on()
|
|
{
|
|
var identity = AxSessionMapper.Map(Session(null), AxVersion.Ax2012);
|
|
|
|
Assert.False(identity.Known);
|
|
Assert.Contains("connectioncontext", identity.Explanation, StringComparison.Ordinal);
|
|
Assert.Null(AxSessionMapper.Decode("00000000"));
|
|
Assert.Null(AxSessionMapper.Decode("zz"));
|
|
Assert.Null(AxSessionMapper.Decode("01FF02FE"));
|
|
}
|
|
}
|
|
|
|
public class DynamicsAxHubSubjectTests
|
|
{
|
|
[Theory]
|
|
[InlineData("database: AX30", "AX30")]
|
|
[InlineData("database: AX30 · batch: 17", "AX30")]
|
|
[InlineData("database: AX 30 · nummerserie: dat/Debitor", "AX 30")]
|
|
[InlineData("job: x", null)]
|
|
public void Database_of_an_ax_subject(string subject, string? database) => Assert.Equal(database, DynamicsAxFindings.DatabaseOf(subject));
|
|
|
|
[Theory]
|
|
[InlineData("SQLI-AX-005", true)]
|
|
[InlineData("COM-AX-001", true)]
|
|
[InlineData("SQLI-AG-001", false)]
|
|
public void Ax_rules(string ruleId, bool ax) => Assert.Equal(ax, DynamicsAxFindings.IsDynamicsAxRule(ruleId));
|
|
|
|
[Fact]
|
|
public void Subjects_round_trip() => Assert.Equal("AX30", DynamicsAxFindings.DatabaseOf(DynamicsAxFindings.Subject("AX30", "batch: 1")));
|
|
}
|
|
|
|
public class AxBatchHistoryWatermarkTests
|
|
{
|
|
[Fact]
|
|
public void The_history_watermark_keeps_moving_through_dense_recids()
|
|
{
|
|
// AX 4.0 and later: BATCHJOBHISTORY's RecIds are dense. A read returns the rows after watermark - overlap, in RECID
|
|
// order, at most MaxHistoryRowsAfterWatermark of them.
|
|
Assert.True(AxSchemaAdapter.MaxHistoryRowsAfterWatermark > AxSchemaAdapter.HistoryOverlap);
|
|
var history = Enumerable.Range(1, 20_000).Select(i => (long)i).ToList();
|
|
long watermark = history[AxSchemaAdapter.MaxHistoryRows - 1];
|
|
for (var read = 0; read < 25 && watermark < history[^1]; read++)
|
|
{
|
|
var after = Math.Max(0, watermark - AxSchemaAdapter.HistoryOverlap);
|
|
var rows = history.Where(r => r > after).Take(AxSchemaAdapter.MaxHistoryRowsAfterWatermark).ToList();
|
|
Assert.True(rows[^1] > watermark, $"Læsning {read} kom ikke forbi RECID {watermark}.");
|
|
watermark = rows[^1];
|
|
}
|
|
|
|
Assert.Equal(history[^1], watermark);
|
|
|
|
var database = AxFixtures.Recognise(AxFixtures.Schema("schema-ax2012"), databases: ["AXDB", "AXDB_model"]);
|
|
Assert.Contains($"SELECT TOP ({AxSchemaAdapter.MaxHistoryRowsAfterWatermark}) ", AxSchemaAdapter.Ax2012.BatchHistory(database, 50_000)!.Sql, StringComparison.Ordinal);
|
|
}
|
|
}
|
|
|
|
public class AxLocalTimeTests
|
|
{
|
|
private static readonly TimeZoneInfo Copenhagen = TimeZoneInfo.FindSystemTimeZoneById("Europe/Copenhagen");
|
|
|
|
private static DateTime Utc(int y, int mo, int d, int h, int mi) => new(y, mo, d, h, mi, 0, DateTimeKind.Utc);
|
|
|
|
[Fact]
|
|
public void A_time_from_before_a_dst_change_uses_the_offset_of_its_own_date()
|
|
{
|
|
// Read in winter (server offset -60), planned in summer: 10:00 CEST is 08:00Z, not 09:00Z.
|
|
Assert.Equal(Utc(2026, 8, 1, 8, 0), AxLocalTime.ToUtc(new DateTime(2026, 8, 1), 36_000, -60, Copenhagen));
|
|
Assert.Equal(Utc(2026, 1, 10, 9, 0), AxLocalTime.ToUtc(new DateTime(2026, 1, 10), 36_000, -120, Copenhagen));
|
|
}
|
|
|
|
[Fact]
|
|
public void The_skipped_and_the_repeated_hour_get_standard_time_and_never_throw()
|
|
{
|
|
// 29 March 2026 02:30 does not exist in Copenhagen; 25 October 2026 02:30 exists twice.
|
|
Assert.Equal(Utc(2026, 3, 29, 1, 30), AxLocalTime.ToUtc(new DateTime(2026, 3, 29), 9_000, -120, Copenhagen));
|
|
Assert.Equal(Utc(2026, 10, 25, 1, 30), AxLocalTime.ToUtc(new DateTime(2026, 10, 25), 9_000, -60, Copenhagen));
|
|
|
|
// A job planned 01:50 CET on the spring night, read at 01:00Z (03:00 CEST): 10 minutes late, not 70.
|
|
var planned = AxLocalTime.ToUtc(new DateTime(2026, 3, 29), 6_600, -120, Copenhagen)!.Value;
|
|
Assert.Equal(TimeSpan.FromMinutes(10), Utc(2026, 3, 29, 1, 0) - planned);
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData(0, 0)]
|
|
[InlineData(86_399, 86_399)]
|
|
[InlineData(86_400, 86_400)]
|
|
public void Seconds_from_midnight_up_to_the_end_of_the_day(int seconds, int expected) =>
|
|
Assert.Equal(Utc(2026, 1, 9, 23, 0).AddSeconds(expected), AxLocalTime.ToUtc(new DateTime(2026, 1, 10), seconds, -60, Copenhagen));
|
|
|
|
[Theory]
|
|
[InlineData(-1)]
|
|
[InlineData(90_000)]
|
|
public void Seconds_outside_a_day_are_unknown(int seconds) => Assert.Null(AxLocalTime.ToUtc(new DateTime(2026, 1, 10), seconds, -60, Copenhagen));
|
|
|
|
[Fact]
|
|
public void Ax_empty_date_is_unknown_and_utc_versions_are_not_converted_again()
|
|
{
|
|
Assert.Null(AxLocalTime.ToUtc(new DateTime(1900, 1, 1), 3_600, -60, Copenhagen));
|
|
var utc = Utc(2026, 7, 1, 12, 0);
|
|
Assert.Equal(utc, AxSchemaAdapter.Ax2009.ToUtc(utc, null, -120, Copenhagen));
|
|
}
|
|
|
|
[Fact]
|
|
public void Without_a_zone_the_servers_offset_is_rounded_to_a_quarter_of_an_hour()
|
|
{
|
|
Assert.Equal(Utc(2026, 10, 2, 0, 30), AxLocalTime.ToUtc(new DateTime(2026, 10, 2), 9_000, -119, null));
|
|
Assert.Equal(-120, AxLocalTime.RoundedOffset(-121));
|
|
}
|
|
|
|
[Fact]
|
|
public void The_zone_is_the_setting_else_the_agents_when_it_matches_the_server_else_the_servers_offset_with_a_note()
|
|
{
|
|
var summer = Utc(2026, 7, 1, 12, 0);
|
|
Assert.Equal("Europe/Copenhagen", AxLocalTime.Resolve("Europe/Copenhagen", 0, summer).Zone?.Id);
|
|
Assert.Same(Copenhagen, AxLocalTime.Resolve(null, -120, summer, Copenhagen).Zone);
|
|
|
|
var (zone, note) = AxLocalTime.Resolve(null, 0, summer, Copenhagen);
|
|
Assert.Null(zone);
|
|
Assert.Contains("LocalTimeZone", note, StringComparison.Ordinal);
|
|
|
|
var (unknown, unknownNote) = AxLocalTime.Resolve("Ingen/Zone", -120, summer, Copenhagen);
|
|
Assert.Null(unknown);
|
|
Assert.Contains("Ingen/Zone", unknownNote, StringComparison.Ordinal);
|
|
}
|
|
|
|
[Fact]
|
|
public void Axapta30_cleanup_without_a_time_column_is_local_midnight_not_utc()
|
|
{
|
|
var row = new AxNumberSequenceRow(1, "Debitor", "dat", null, 1, 999_999, 10, "######", false, true, false, false, null,
|
|
new DateTime(2026, 7, 1), null, null, -120);
|
|
var sequence = AxSchemaAdapter.Ax30.Interpret(row, [], Utc(2026, 7, 2, 0, 0), 30, Copenhagen);
|
|
Assert.Equal(Utc(2026, 6, 30, 22, 0), sequence.LastCleanUtc);
|
|
}
|
|
}
|
|
|
|
public class AxRedactionTests
|
|
{
|
|
[Fact]
|
|
public void A_database_name_with_the_separator_is_never_read_as_another_database()
|
|
{
|
|
var subject = DynamicsAxFindings.Subject("AX · test", "batch: 1");
|
|
|
|
Assert.Equal("AX · test", DynamicsAxFindings.DatabaseOf(subject));
|
|
Assert.Equal("AX · test", DynamicsAxFindings.DatabaseOf(DynamicsAxFindings.Subject("AX · test")));
|
|
Assert.Equal("a··b", DynamicsAxFindings.DatabaseOf(DynamicsAxFindings.Subject("a··b", "recid: 1/dat")));
|
|
Assert.Equal("database: AX30 · batch: 1", DynamicsAxFindings.Subject("AX30", "batch: 1"));
|
|
}
|
|
|
|
[Fact]
|
|
public void Grant_texts_and_error_messages_lose_the_login_and_the_database_user()
|
|
{
|
|
var database = AxFixtures.Recognise(AxFixtures.Ax30, readable: false, user: "svc") with { AgentLogin = @"KUNDE\svc-sqlinsight$" };
|
|
var text = AxReadAccess.NotMeasurable(database, ["BATCH"]);
|
|
Assert.Contains("svc", text, StringComparison.Ordinal);
|
|
|
|
var redacted = AxReadAccess.WithoutPrincipals(text)!;
|
|
Assert.DoesNotContain("KUNDE", redacted, StringComparison.Ordinal);
|
|
Assert.DoesNotContain("[svc]", redacted, StringComparison.Ordinal);
|
|
Assert.Contains("GRANT SELECT ON [bmssa].[BATCH] TO [" + AxReadAccess.LoginPlaceholder + "]", redacted, StringComparison.Ordinal);
|
|
|
|
var noUser = AxReadAccess.WithoutPrincipals(AxReadAccess.GrantSql(database with { DatabaseUser = null }, @"KUNDE\svc-sqlinsight$"))!;
|
|
Assert.DoesNotContain("KUNDE", noUser, StringComparison.Ordinal);
|
|
Assert.Equal("The server principal \"[udeladt]\" is not able to access the database",
|
|
AxReadAccess.WithoutPrincipals("The server principal \"KUNDE\\svc\" is not able to access the database"));
|
|
}
|
|
}
|