sqlinsight-pro/tests/SQLInsight.Collectors.Tests/SqlScriptTests.cs
Søren Eggert Lundsteen Olsen 08cd741b52 Dynamics AX: notice a GRANT within minutes, one AX lookup per custom-rule run
The detector looks again every 5 minutes at an AX database where the agent
lacks part of the read access, so a GRANT takes effect quickly. The AX SQL
test checks that only standard AX tables are read. The hub end-to-end test
expects no AX rule outcomes without the opt-in. Docs: what 1.5's synthetic
AX checks can reuse.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-02 13:54:36 +02:00

214 lines
12 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

using System.Text;
using System.Text.Json;
using SQLInsight.Collectors.Concurrency;
using SQLInsight.Collectors.DynamicsAx;
using SQLInsight.Core.DynamicsAx;
using SQLInsight.Core.Instances;
namespace SQLInsight.Collectors.Tests;
/// <summary>
/// "SQL Scripts/&lt;regel-id&gt;.sql": a reviewable script per rule. The header is written by hand; the SQL below the
/// marker is exactly what SQLInsight sends, generated from the code here, so it can never drift. Run the tests with
/// SQLINSIGHT_UPDATE_SQL_SCRIPTS=1 to rewrite the generated part after changing a query.
/// </summary>
public sealed class SqlScriptTests
{
public const string Begin = "-- >>> SQL, som SQLInsight kører – genereret fra koden (tests/SQLInsight.Collectors.Tests/SqlScriptTests.cs). Ret ikke herunder i hånden.";
public const string End = "-- <<< slut på genereret SQL";
private static string DynamicsAxDirectory => Path.Combine(AppContext.BaseDirectory, "DynamicsAx");
public static TheoryData<string> Files() => [.. Generated().Keys];
[Theory]
[MemberData(nameof(Files))]
public void Script_holds_exactly_the_sql_sqlinsight_runs(string file)
{
var path = Path.Combine(RepositoryRoot(), "SQL Scripts", file);
Assert.True(File.Exists(path), $"SQL Scripts/{file} mangler.");
var text = File.ReadAllText(path);
var begin = text.IndexOf(Begin, StringComparison.Ordinal);
var end = text.IndexOf(End, StringComparison.Ordinal);
Assert.True(begin > 0 && end > begin, $"SQL Scripts/{file}: markeringerne for den genererede SQL mangler.");
var expected = Generated()[file];
var actual = text[(begin + Begin.Length)..end].Replace("\r\n", "\n", StringComparison.Ordinal).Trim('\n');
if (actual != expected.Replace("\r\n", "\n", StringComparison.Ordinal).Trim('\n') && Environment.GetEnvironmentVariable("SQLINSIGHT_UPDATE_SQL_SCRIPTS") == "1")
{
var updated = text[..(begin + Begin.Length)] + "\r\n" + expected.Replace("\r\n", "\n", StringComparison.Ordinal).Trim('\n').Replace("\n", "\r\n", StringComparison.Ordinal)
+ "\r\n" + text[end..];
File.WriteAllText(path, updated, new UTF8Encoding(false));
return;
}
Assert.Equal(expected.Replace("\r\n", "\n", StringComparison.Ordinal).Trim('\n'), actual);
}
[Theory]
[MemberData(nameof(Files))]
public void Header_says_what_the_reader_needs(string file)
{
var header = File.ReadAllText(Path.Combine(RepositoryRoot(), "SQL Scripts", file)).Split(Begin)[0];
if (!file.StartsWith('_'))
{
Assert.Contains("Regel:", header, StringComparison.Ordinal);
Assert.Contains("Alvorlighed:", header, StringComparison.Ordinal);
}
Assert.Contains("Rettigheder:", header, StringComparison.Ordinal);
Assert.Contains("Versioner:", header, StringComparison.Ordinal);
Assert.Contains("Collector:", header, StringComparison.Ordinal);
Assert.Contains("ikke verificeret mod kundedata", header, StringComparison.Ordinal);
}
/// <summary>File name → the generated SQL.</summary>
internal static Dictionary<string, string> Generated()
{
var ax30 = Database("schema-ax30");
var ax40 = AxDetection.Recognise("AXDB", AxSchemaFile.Find(DynamicsAxDirectory, "schema-ax30")!.DetectionRows()
.Select(r => r with { Schema = "dbo", Precision = r.Column == "RECID" ? 19 : r.Precision, Type = r.Column == "RECID" ? "bigint" : r.Type }).ToList(), ["AXDB"])!;
var ax2009 = Database("schema-ax2009");
var ax2012 = Database("schema-ax2012");
Assert.Equal((AxVersion.Axapta30, AxVersion.Ax40, AxVersion.Ax2009, AxVersion.Ax2012R2), (ax30.Version, ax40.Version, ax2009.Version, ax2012.Version));
var versions = new (string Title, AxDatabase Database)[]
{
("Axapta 3.0 (skema bmssa)", ax30),
("AX 4.0 (skema dbo) – ikke verificeret mod kundedata", ax40),
("AX 2009 – baseret på Microsofts dokumenterede skema, ikke verificeret mod kundedata", ax2009),
("AX 2012 RTM/R2/R3 og F&O – baseret på Microsofts dokumenterede skema, ikke verificeret mod kundedata", ax2012),
};
string Batch() => Join(versions.SelectMany(v =>
{
var adapter = AxSchemaAdapter.For(v.Database.Version)!;
var list = new List<string> { Ax($"{v.Title}: batchjob (AxSchemaAdapter.BatchJobs)", v.Database, adapter.BatchJobs(v.Database)) };
if (adapter.BatchHistory(v.Database, null) is { } first)
{
list.Add(Ax($"{v.Title}: kørselshistorik, første læsning (AxSchemaAdapter.BatchHistory)", v.Database, first));
list.Add(Ax($"{v.Title}: kørselshistorik, de næste læsninger fra seneste RECID minus {AxSchemaAdapter.HistoryOverlap}", v.Database,
adapter.BatchHistory(v.Database, Watermark + AxSchemaAdapter.HistoryOverlap)!, watermark: true));
}
return list;
}));
string Numbers() => Join(versions.Select(v => Ax($"{v.Title}: nummerserier (AxSchemaAdapter.NumberSequences)", v.Database,
AxSchemaAdapter.For(v.Database.Version)!.NumberSequences(v.Database))));
string RecIds() => Join(versions.Select(v => Ax($"{v.Title}: RecId pr. tabel (AxSchemaAdapter.RecIds)", v.Database,
AxSchemaAdapter.For(v.Database.Version)!.RecIds(v.Database))));
var detection = Join(
[
Plain("Alle versioner: online brugerdatabaser og deres snapshot-indstillinger (AxDatabaseDetector.DatabasesSql)", AxDatabaseDetector.DatabasesSql),
Plain("Alle versioner: agentens login, til GRANT-teksten", "SELECT SUSER_SNAME()"),
Detection(),
Plain("SQL Server 2000 (kun opdagelse under Servere og databaser, AxDetection.MetadataSql2000)", AxDetection.MetadataSql2000("AXDB")),
.. versions.Select(v => Ax($"{v.Title}: firmaer (AxSchemaAdapter.Companies)", v.Database, AxSchemaAdapter.Companies(v.Database))),
]);
var blocking = Join(
[
Plain("SQL Server 2012 og nyere (BlockingCollector.BuildSql2005)", BlockingCollector.BuildSql2005(SqlServerVersion.Parse("11.0.7001.0"))),
Plain("SQL Server 2008 og 2008 R2", BlockingCollector.BuildSql2005(SqlServerVersion.Parse("10.50.6000.34"))),
Plain("SQL Server 2005", BlockingCollector.BuildSql2005(SqlServerVersion.Parse("9.0.5000.0"))),
Plain("SQL Server 2000 (BlockingCollector.Sql2000)", BlockingCollector.Sql2000),
]);
var files = new Dictionary<string, string>(StringComparer.Ordinal)
{
["_collectors/AxDatabaseDetector.sql"] = detection,
["_collectors/AxBatchCollector.sql"] = Batch(),
["_collectors/AxSequenceCollector.sql"] = Join([Numbers(), RecIds()]),
["SQLI-AX-001.sql"] = Batch(),
["SQLI-AX-002.sql"] = Batch(),
["SQLI-AX-003.sql"] = Batch(),
["SQLI-AX-004.sql"] = Batch(),
["SQLI-AX-005.sql"] = Numbers(),
["SQLI-AX-006.sql"] = Numbers(),
["SQLI-AX-007.sql"] = Numbers(),
["SQLI-AX-008.sql"] = Numbers(),
["SQLI-AX-009.sql"] = RecIds(),
["SQLI-AX-010.sql"] = Join([detection, blocking]),
};
foreach (var (id, minimum, query) in CommunityAxRules())
{
files[id + ".sql"] = Join(
[
Detection(),
$"-- Variant: SQL Server {Release(minimum)} og nyere – i hver overvåget database, som AX-genkendelsen herover har genkendt (CustomQueryCollector)\r\n"
+ "USE [AXDB]; -- <== ret til AX-databasen\r\n" + query + "\r\nGO",
]);
}
return files;
}
private const long Watermark = 999_999_999;
private static string Release(string minimumVersion) => minimumVersion switch { "9.0" => "2005", "10.0" => "2008", "11.0" => "2012", "12.0" => "2014", "13.0" => "2016", _ => minimumVersion };
private static AxDatabase Database(string schemaFile) =>
AxDetection.Recognise("AXDB", AxSchemaFile.Find(DynamicsAxDirectory, schemaFile)!.DetectionRows(), ["AXDB", "AXDB_model"])!;
private static string Detection() => Plain("SQL Server 2005 og nyere: AX-genkendelse i hver overvåget database (AxDetection.MetadataSql via AxSql.InDatabase)",
"DECLARE @Database sysname;\r\nSET @Database = N'AXDB'; -- <== ret til databasen\r\nDECLARE @Sql nvarchar(max);\r\nSET @Sql = "
+ AxSql.Literal(AxSql.Batch(AxDetection.MetadataSql)) + ";\r\nDECLARE @Procedure nvarchar(300);\r\nSET @Procedure = QUOTENAME(@Database) + N'.sys.sp_executesql';\r\n"
+ "EXEC @Procedure @Sql;\r\n" + AxSql.Reset);
private static string Plain(string title, string sql) => $"-- Variant: {title}\r\n{sql.Trim()}\r\nGO";
/// <summary>
/// An AX read as the agent sends it: the batch below runs inside the AX database through [db].sys.sp_executesql
/// (read committed, 2 s lock timeout; snapshot instead when the database allows it), so the session never changes database.
/// </summary>
private static string Ax(string title, AxDatabase database, AxQuery? query, bool watermark = false)
{
Assert.NotNull(query);
Assert.True(query.IsMeasurable, query.NotMeasurable);
var sql = new StringBuilder()
.Append("-- Variant: ").Append(title).Append("\r\n")
.Append("DECLARE @Database sysname;\r\nSET @Database = N'AXDB'; -- <== ret til AX-databasen\r\n")
.Append("DECLARE @Schema sysname;\r\nSET @Schema = N'").Append(database.Schema).Append("'; -- <== ejeren af AX-tabellerne: bmssa i Axapta 3.0, ellers dbo\r\n");
if (watermark)
{
sql.Append("DECLARE @FraRecId bigint;\r\nSET @FraRecId = 0; -- <== SQLInsight bruger den højeste RECID, den har læst, minus ")
.Append(AxSchemaAdapter.HistoryOverlap).Append("\r\n");
}
sql.Append("DECLARE @Sql nvarchar(max);\r\nSET @Sql = ").Append(AxSql.Literal(AxSql.Batch(query.Sql!))).Append(";\r\n")
.Append("SET @Sql = REPLACE(@Sql, N'[").Append(database.Schema).Append("].', QUOTENAME(@Schema) + N'.');\r\n");
if (watermark)
{
sql.Append("SET @Sql = REPLACE(@Sql, N'> ").Append(Watermark).Append("', N'> ' + CAST(@FraRecId AS nvarchar(20)));\r\n");
}
return sql.Append("DECLARE @Procedure nvarchar(300);\r\nSET @Procedure = QUOTENAME(@Database) + N'.sys.sp_executesql';\r\n")
.Append("EXEC @Procedure @Sql;\r\n").Append(AxSql.Reset).Append("\r\nGO").ToString();
}
private static string Join(IEnumerable<string> parts) => string.Join("\r\n\r\n", parts);
private static List<(string Id, string Minimum, string Query)> CommunityAxRules()
{
using var document = JsonDocument.Parse(File.ReadAllText(Path.Combine(RepositoryRoot(), "rules", "community", "dynamics-ax.json")),
new JsonDocumentOptions { CommentHandling = JsonCommentHandling.Skip, AllowTrailingCommas = true });
return document.RootElement.EnumerateArray()
.Select(r => (r.GetProperty("id").GetString()!, r.TryGetProperty("minimumVersion", out var v) ? v.GetString()! : "9.0", r.GetProperty("query").GetString()!))
.ToList();
}
private static string RepositoryRoot()
{
var directory = new DirectoryInfo(AppContext.BaseDirectory);
while (directory is not null && !File.Exists(Path.Combine(directory.FullName, "SQLInsight.slnx")))
{
directory = directory.Parent;
}
return directory?.FullName ?? throw new InvalidOperationException("Repository-roden blev ikke fundet.");
}
}