sqlinsight-pro/tests/SQLInsight.Agent.Tests/InstanceConfigurationAgentTests.cs
Søren Eggert Lundsteen Olsen 36ad4c0656 Repository and agent: Dynamics AX history (migration 0017), seeding, hub opt-in
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>
2026-10-02 13:53:34 +02:00

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