Built from the schema files in the SQL Server 2022 container and read as the least-privilege agent login: detection and discovery, 'ikke målbar' with the exact GRANT and measuring after it (no customer table readable, no data changed), the shipped grant/revoke scripts, AX 2012 history by RECID read once, a locked AX table skipped within seconds, the SQL Scripts run as-is, repository retention and read-back, and the web pages and API v1. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
157 lines
9.3 KiB
C#
157 lines
9.3 KiB
C#
using System.Net;
|
|
using System.Text.Json;
|
|
using Microsoft.AspNetCore.Hosting;
|
|
using Microsoft.AspNetCore.Mvc.Testing;
|
|
using SQLInsight.Core.Data;
|
|
using SQLInsight.Core.DynamicsAx;
|
|
using SQLInsight.Core.Instances;
|
|
using SQLInsight.Repository;
|
|
using SQLInsight.Web;
|
|
|
|
namespace SQLInsight.IntegrationTests;
|
|
|
|
/// <summary>The Dynamics AX pages in SQLInsight Web and the same data in API v1, with sensitive values redacted.</summary>
|
|
[Collection(SqlServerTestGroup.Name)]
|
|
[Trait("Category", "Integration")]
|
|
public class DynamicsAxWebTests(SqlServerFixture sql) : IAsyncLifetime
|
|
{
|
|
private readonly string _database = "SQLInsightAxWeb_" + Guid.NewGuid().ToString("N")[..8];
|
|
private WebApplicationFactory<Program> _factory = null!;
|
|
private const string Name = @"AX01\AX30";
|
|
|
|
public async Task InitializeAsync()
|
|
{
|
|
await sql.ExecuteAsSaAsync($"CREATE DATABASE [{_database}];");
|
|
var repository = new InsightRepository(sql.DatabaseConnectionString(_database));
|
|
await repository.MigrateAsync();
|
|
var now = DateTimeOffset.UtcNow;
|
|
var id = await repository.UpsertInstanceAsync(Name, new InstanceInfo
|
|
{
|
|
ServerName = "AX01", Version = SqlServerVersion.Parse("16.0.4135.4"), ProductLevel = "RTM", Edition = "Standard Edition", EngineEdition = EngineEdition.Standard,
|
|
}, "SqlClient", false, now);
|
|
|
|
// AX 3.0 recognised from the customer's real schema, without read access; AX 2012 with it.
|
|
var ax30 = AxDetection.Recognise("AX30", DynamicsAxTests.Schema("schema-ax30").DetectionRows(readable: false), ["AX30"])! with { AgentLogin = @"KUNDE\svc-sqlinsight$" };
|
|
var ax2012 = AxDetection.Recognise("AX2012", DynamicsAxTests.Schema("schema-ax2012").DetectionRows(readable: true, "svc"), ["AX2012", "AX2012_model"])!
|
|
with { AgentLogin = @"KUNDE\svc-sqlinsight$", Companies = [new AxCompany("dk01", "Danmark", false)] };
|
|
await repository.SaveCollectedAsync(id, DynamicsAxView.DatabasesKind, new AxDatabaseSnapshot { CapturedAt = now, Databases = [ax30, ax2012], AgentLogin = ax30.AgentLogin });
|
|
|
|
var job = AxSchemaAdapter.Ax2012.Interpret(new AxBatchJobRow(5637144576, null, "Lagerafslutning", "dk01", null, 1, now.UtcDateTime.AddHours(-2), null, null, null, null, null, 0),
|
|
new AxBatchHistory(), "AX2012", null);
|
|
await repository.SaveCollectedAsync(id, DynamicsAxView.BatchKind, new AxBatchSnapshot
|
|
{
|
|
CapturedAt = now, Thresholds = new(),
|
|
Databases =
|
|
[
|
|
new AxBatchDatabase { Database = "AX30", Version = AxVersion.Axapta30, NotMeasurable = AxSchemaAdapter.Ax30.BatchJobs(ax30).NotMeasurable },
|
|
new AxBatchDatabase
|
|
{
|
|
Database = "AX2012", Version = AxVersion.Ax2012R2, Jobs = [job], StatusCounts = new Dictionary<int, int> { [1] = 1 },
|
|
NewRuns = [new AxBatchRun(job.Key, "H1", "Lagerafslutning", "dk01", now.UtcDateTime.AddDays(-1), now.UtcDateTime.AddDays(-1).AddMinutes(12), AxBatchStatus.Finished)],
|
|
},
|
|
],
|
|
});
|
|
await repository.SaveCollectedAsync(id, DynamicsAxView.SequencesKind, new AxSequenceSnapshot
|
|
{
|
|
CapturedAt = now, Thresholds = new(),
|
|
Databases =
|
|
[
|
|
new AxSequenceDatabase
|
|
{
|
|
Database = "AX2012", Version = AxVersion.Ax2012R2,
|
|
NumberSequences = [AxSchemaAdapter.Ax2012.Interpret(new AxNumberSequenceRow(2001, "Ordre", "dk01", "Salgsordrer", 1, 1_000_000, 990_000, "######",
|
|
false, false, false, false, true, null, null, null, 0), [], now.UtcDateTime, 30)],
|
|
RecIds = [AxSchemaAdapter.Interpret(new AxRecIdRow(77, null, 5_637_150_000, null, "CustTable"), false, [], now.UtcDateTime, 30)],
|
|
},
|
|
],
|
|
});
|
|
await repository.SaveCollectedAsync(id, "blocking", new BlockingSnapshot
|
|
{
|
|
CapturedAt = now,
|
|
Sessions =
|
|
[
|
|
new SessionInfo
|
|
{
|
|
SessionId = 61, Status = "sleeping", DatabaseName = "AX2012", HostName = "AOS01", ProgramName = "Microsoft Dynamics AX", LoginName = "AXSERVICE",
|
|
ContextInfo = Convert.ToHexString("anne 17"u8), SqlText = "UPDATE INVENTSUM SET …",
|
|
},
|
|
new SessionInfo { SessionId = 62, BlockingSessionId = 61, DatabaseName = "AX2012", WaitTimeMs = 60_000, WaitType = "LCK_M_U" },
|
|
],
|
|
});
|
|
|
|
_factory = new WebApplicationFactory<Program>().WithWebHostBuilder(b =>
|
|
{
|
|
b.UseEnvironment("Development");
|
|
b.UseSetting("SQLInsight:Repository:ConnectionString", sql.DatabaseConnectionString(_database));
|
|
b.UseSetting("SQLInsight:Web:Authentication", "None");
|
|
});
|
|
}
|
|
|
|
public async Task DisposeAsync()
|
|
{
|
|
await _factory.DisposeAsync();
|
|
await sql.ExecuteAsSaAsync($"ALTER DATABASE [{_database}] SET SINGLE_USER WITH ROLLBACK IMMEDIATE; DROP DATABASE [{_database}];");
|
|
}
|
|
|
|
private async Task<string> GetAsync(string path)
|
|
{
|
|
var response = await _factory.CreateClient().GetAsync(path);
|
|
var text = await response.Content.ReadAsStringAsync();
|
|
Assert.True(response.IsSuccessStatusCode, $"{path}: {(int)response.StatusCode} {text}");
|
|
return text;
|
|
}
|
|
|
|
[Fact]
|
|
public async Task The_dynamics_ax_pages_show_versions_the_exact_grant_jobs_sequences_recid_and_ax_users()
|
|
{
|
|
var databases = await GetAsync("/dynamics-ax/databaser");
|
|
var batch = await GetAsync("/dynamics-ax/batch");
|
|
var numbers = await GetAsync("/dynamics-ax/nummerserier");
|
|
var recid = await GetAsync("/dynamics-ax/recid");
|
|
var blocking = await GetAsync("/dynamics-ax/blokering");
|
|
|
|
Assert.Contains("Axapta 3.0 (eller 2.5)", databases, StringComparison.Ordinal);
|
|
Assert.Contains(@"GRANT SELECT ON [bmssa].[BATCH] TO [KUNDE\svc-sqlinsight$];", WebUtility.HtmlDecode(databases), StringComparison.Ordinal);
|
|
Assert.Contains("ikke verificeret mod kundedata", databases, StringComparison.Ordinal);
|
|
Assert.Contains("AX2012_model", databases, StringComparison.Ordinal);
|
|
Assert.Contains("Ikke målbar", batch, StringComparison.Ordinal);
|
|
Assert.Contains("Lagerafslutning", batch, StringComparison.Ordinal);
|
|
Assert.Contains("Salgsordrer", numbers, StringComparison.Ordinal);
|
|
Assert.Contains("CustTable", recid, StringComparison.Ordinal);
|
|
Assert.Contains("anne", blocking, StringComparison.Ordinal); // the web UI is for the people logged in: names are shown
|
|
Assert.Contains("Dynamics AX</span>", await GetAsync("/"), StringComparison.Ordinal);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task The_same_is_in_api_v1_with_logins_and_ax_users_redacted()
|
|
{
|
|
var databases = JsonDocument.Parse(await GetAsync("/api/v1/dynamics-ax/databases")).RootElement;
|
|
var grant = JsonDocument.Parse(await GetAsync("/api/v1/dynamics-ax/grant-script?instance=" + Uri.EscapeDataString(Name) + "&database=AX30")).RootElement;
|
|
var jobs = await GetAsync("/api/v1/dynamics-ax/batch-jobs");
|
|
var runs = JsonDocument.Parse(await GetAsync("/api/v1/dynamics-ax/batch-runs?days=7")).RootElement;
|
|
var numbers = await GetAsync("/api/v1/dynamics-ax/number-sequences?instance=" + Uri.EscapeDataString(Name));
|
|
var recids = await GetAsync("/api/v1/dynamics-ax/recids");
|
|
var samples = JsonDocument.Parse(await GetAsync("/api/v1/dynamics-ax/sequence-samples?instance=" + Uri.EscapeDataString(Name) + "&kind=N")).RootElement;
|
|
var blocking = await GetAsync("/api/v1/dynamics-ax/blocking");
|
|
|
|
Assert.Equal(["AX30", "AX2012"], databases.EnumerateArray().Select(d => d.GetProperty("database").GetString()));
|
|
var ax30 = databases[0];
|
|
Assert.True(ax30.GetProperty("verified").GetBoolean());
|
|
Assert.Contains("GRANT SELECT ON [bmssa].[BATCH] TO [DOMÆNE\\svc-sqlinsight$];", ax30.GetProperty("grant").GetString(), StringComparison.Ordinal);
|
|
Assert.DoesNotContain("KUNDE", databases.GetRawText(), StringComparison.Ordinal);
|
|
Assert.Contains("SET @DatabaseName = N'AX30';", grant.GetProperty("script").GetString(), StringComparison.Ordinal);
|
|
Assert.Contains("Lagerafslutning", jobs, StringComparison.Ordinal);
|
|
Assert.Equal(1, runs.GetArrayLength());
|
|
Assert.Contains("\"formatTooShort\":true", numbers, StringComparison.Ordinal);
|
|
Assert.Contains("\"bits\":64", recids, StringComparison.Ordinal);
|
|
Assert.Equal("2001", Assert.Single(samples.EnumerateArray()).GetProperty("sequenceKey").GetString()); // AX 2012: keyed by RECID
|
|
Assert.Contains("[udeladt]", blocking, StringComparison.Ordinal);
|
|
Assert.Contains("context_info", blocking, StringComparison.Ordinal);
|
|
Assert.DoesNotContain("anne", blocking, StringComparison.Ordinal);
|
|
|
|
var client = _factory.CreateClient();
|
|
Assert.Equal(HttpStatusCode.NotFound, (await client.GetAsync("/api/v1/dynamics-ax/databases?instance=ukendt")).StatusCode);
|
|
Assert.Equal(HttpStatusCode.BadRequest, (await client.GetAsync("/api/v1/dynamics-ax/grant-script")).StatusCode);
|
|
Assert.Equal(HttpStatusCode.BadRequest, (await client.GetAsync("/api/v1/dynamics-ax/sequence-samples?instance=" + Uri.EscapeDataString(Name) + "&kind=X")).StatusCode);
|
|
}
|
|
}
|