0017_dynamics_ax adds insight.AxBatchRuns (completed batch runs, EventDays retention) and insight.AxSequenceSamples (one NEXTREC/NEXTVAL sample per sequence per day, HourlyDays retention). The agent reads them back after a restart so job comparisons and forecasts continue, takes its thresholds from SQLInsight:DynamicsAx, and only sends findings of the built-in SQLI-AX rules to the hub for databases with the AX opt-in ticked; AX rule reasons (which can name tables, logins and GRANT text) are replaced by a fixed text. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
172 lines
7.1 KiB
C#
172 lines
7.1 KiB
C#
using System.Text;
|
|
using SQLInsight.Agent.Configuration;
|
|
using SQLInsight.Agent.Hub;
|
|
using SQLInsight.Core.Configuration;
|
|
using SQLInsight.Core.Findings;
|
|
using SQLInsight.Core.Instances;
|
|
using SQLInsight.Repository;
|
|
using SQLInsight.Rules.Engine;
|
|
|
|
namespace SQLInsight.Agent.Tests;
|
|
|
|
public sealed class AgentCredentialKeyTests : IDisposable
|
|
{
|
|
private readonly string _directory = Path.Combine(Path.GetTempPath(), "sqlinsight-key-" + Guid.NewGuid().ToString("N"));
|
|
|
|
public void Dispose()
|
|
{
|
|
if (Directory.Exists(_directory))
|
|
{
|
|
Directory.Delete(_directory, recursive: true);
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public void Key_is_created_once_kept_and_opens_what_the_web_ui_sealed_with_its_public_key()
|
|
{
|
|
string keyId;
|
|
byte[] envelope;
|
|
using (var first = AgentCredentialKey.LoadOrCreate(_directory))
|
|
{
|
|
keyId = first.KeyId;
|
|
envelope = CredentialEnvelope.Seal("Hemmelig-1!", first.KeyId, first.Describe(DateTimeOffset.UtcNow).PublicKey, CredentialEnvelope.Context("sql01,1450", "mon"));
|
|
}
|
|
|
|
using var again = AgentCredentialKey.LoadOrCreate(_directory);
|
|
|
|
Assert.Equal(keyId, again.KeyId);
|
|
Assert.StartsWith("agent-", again.KeyId, StringComparison.Ordinal);
|
|
Assert.Equal("Hemmelig-1!", again.Open(envelope, "sql01,1450", "mon"));
|
|
Assert.Throws<CredentialException>(() => again.Open(envelope, "other", "mon"));
|
|
var stored = File.ReadAllBytes(Path.Combine(_directory, AgentCredentialKey.FileName));
|
|
Assert.DoesNotContain("PRIVATE KEY", Encoding.ASCII.GetString(stored), StringComparison.Ordinal);
|
|
}
|
|
|
|
[Fact]
|
|
public void An_unreadable_key_file_is_replaced_and_old_passwords_must_be_entered_again()
|
|
{
|
|
string oldId;
|
|
using (var first = AgentCredentialKey.LoadOrCreate(_directory))
|
|
{
|
|
oldId = first.KeyId;
|
|
}
|
|
|
|
File.WriteAllBytes(Path.Combine(_directory, AgentCredentialKey.FileName), [1, 2, 3, 4]);
|
|
using var replaced = AgentCredentialKey.LoadOrCreate(_directory);
|
|
|
|
Assert.NotEqual(oldId, replaced.KeyId);
|
|
// The unreadable file is kept beside the new key, not overwritten, in case it can be restored.
|
|
Assert.Single(Directory.GetFiles(_directory, AgentCredentialKey.FileName + ".unreadable-*"));
|
|
}
|
|
|
|
[Fact]
|
|
public void The_published_identity_is_the_account_and_computer_the_agent_runs_as()
|
|
{
|
|
using var key = AgentCredentialKey.LoadOrCreate(_directory);
|
|
var row = key.Describe(DateTimeOffset.UtcNow);
|
|
|
|
Assert.Equal(Environment.MachineName, row.MachineName);
|
|
Assert.False(string.IsNullOrEmpty(row.ServiceAccount));
|
|
Assert.Equal(key.KeyId, row.KeyId);
|
|
}
|
|
}
|
|
|
|
public sealed class InstanceFinderTests
|
|
{
|
|
private static byte[] Response(string text)
|
|
{
|
|
var body = Encoding.ASCII.GetBytes(text);
|
|
return [0x05, (byte)(body.Length & 0xFF), (byte)(body.Length >> 8), .. body];
|
|
}
|
|
|
|
[Fact]
|
|
public void Browser_answer_lists_default_and_named_instances()
|
|
{
|
|
var instances = InstanceFinder.Parse(Response(
|
|
"ServerName;SQL01;InstanceName;MSSQLSERVER;IsClustered;No;Version;15.0.2000.5;tcp;1433;;" +
|
|
"ServerName;SQL01;InstanceName;AX30;IsClustered;Yes;Version;9.00.5000.00;tcp;1450;np;\\\\SQL01\\pipe\\MSSQL$AX30\\sql\\query;;"), "sql01");
|
|
|
|
Assert.Equal(2, instances.Count);
|
|
Assert.Equal(("SQL01", "MSSQLSERVER", 1433, false), (instances[0].Server, instances[0].InstanceName, instances[0].TcpPort, instances[0].IsClustered));
|
|
Assert.Equal(("SQL01\\AX30", "9.00.5000.00", 1450, true), (instances[1].Server, instances[1].Version, instances[1].TcpPort, instances[1].IsClustered));
|
|
Assert.All(instances, i => Assert.Equal("SQL Server Browser", i.Source));
|
|
}
|
|
|
|
[Fact]
|
|
public void Anything_else_is_ignored()
|
|
{
|
|
Assert.Empty(InstanceFinder.Parse([0x04, 0, 0], "x"));
|
|
Assert.Empty(InstanceFinder.Parse([0x05], "x"));
|
|
Assert.Empty(InstanceFinder.Parse(Response("ServerName;SQL01;;"), "x"));
|
|
}
|
|
}
|
|
|
|
public sealed class DynamicsAxHubTests
|
|
{
|
|
private static readonly DateTimeOffset Now = new(2026, 10, 2, 12, 0, 0, TimeSpan.Zero);
|
|
|
|
private static readonly InstanceInfo Info = new()
|
|
{
|
|
ServerName = "AX01", Version = SqlServerVersion.Parse("9.0.5000.0"), Edition = "Standard Edition", EngineEdition = EngineEdition.Standard,
|
|
};
|
|
|
|
private static StoredFinding Finding(string ruleId, string subject) => new()
|
|
{
|
|
FindingId = 1, RuleId = ruleId, Subject = subject, Severity = Severity.Medium, Title = "t", Explanation = "e", Recommendation = "r",
|
|
FirstSeenAt = Now.UtcDateTime, Evidence = [],
|
|
};
|
|
|
|
private static readonly RuleResult[] Results =
|
|
[
|
|
new() { RuleId = "COM-AX-001", RuleTitle = "AX 3.0", Outcome = RuleOutcome.Findings },
|
|
new() { RuleId = "SQLI-DB-001", RuleTitle = "Backup", Outcome = RuleOutcome.Findings },
|
|
];
|
|
|
|
private static readonly StoredFinding[] Findings =
|
|
[
|
|
Finding("COM-AX-001", "database: AX30"), Finding("COM-AX-001", "database: AXTEST"), Finding("SQLI-DB-001", "database: AX30"),
|
|
];
|
|
|
|
[Fact]
|
|
public void Dynamics_ax_findings_stay_in_the_network_by_default()
|
|
{
|
|
var submission = SubmissionBuilder.Build("ax", Info, Results, Findings, new HubOptions(), Now);
|
|
|
|
Assert.Equal(["SQLI-DB-001"], submission.Findings.Select(f => f.RuleId));
|
|
Assert.Equal(["SQLI-DB-001"], submission.Rules.Select(r => r.RuleId));
|
|
}
|
|
|
|
[Fact]
|
|
public void Only_the_databases_that_allow_it_send_dynamics_ax_findings()
|
|
{
|
|
var selection = new DatabaseSelection(true, ["AX30", "AXTEST"], [], ["AX30"]);
|
|
|
|
var submission = SubmissionBuilder.Build("ax", Info, Results, Findings, new HubOptions(), Now, selection);
|
|
|
|
Assert.Equal(["database: AX30", "database: AX30"], submission.Findings.Select(f => f.Subject));
|
|
Assert.Contains(submission.Rules, r => r.RuleId == "COM-AX-001");
|
|
}
|
|
|
|
[Fact]
|
|
public void The_built_in_ax_rules_follow_the_same_opt_in_and_keep_their_reasons_local()
|
|
{
|
|
var selection = new DatabaseSelection(true, ["AX30", "AXTEST"], [], ["AX30"]);
|
|
RuleResult[] results =
|
|
[
|
|
new() { RuleId = "SQLI-AX-001", RuleTitle = "Batchjob fejlet", Outcome = RuleOutcome.Findings },
|
|
new() { RuleId = "SQLI-AX-005", RuleTitle = "Nummerserie", Outcome = RuleOutcome.NotMeasurable, Reason = "Ikke målbar: GRANT SELECT ON [bmssa].[NUMBERSEQUENCETABLE] TO [svc]" },
|
|
];
|
|
StoredFinding[] findings =
|
|
[
|
|
Finding("SQLI-AX-001", "database: AX30 · batch: 1234|dat|NAT"), Finding("SQLI-AX-001", "database: AXTEST · batch: 99"),
|
|
];
|
|
|
|
var none = SubmissionBuilder.Build("ax", Info, results, findings, new HubOptions(), Now);
|
|
var allowed = SubmissionBuilder.Build("ax", Info, results, findings, new HubOptions(), Now, selection);
|
|
|
|
Assert.Empty(none.Findings);
|
|
Assert.Empty(none.Rules);
|
|
Assert.Equal(["database: AX30 · batch: 1234|dat|NAT"], allowed.Findings.Select(f => f.Subject));
|
|
Assert.Equal(SubmissionBuilder.AxReasonKeptLocally, allowed.Rules.Single(r => r.RuleId == "SQLI-AX-005").Reason);
|
|
}
|
|
}
|