sqlinsight-pro/tests/SQLInsight.Agent.Tests/InstanceConfigurationAgentTests.cs
Søren Eggert Lundsteen Olsen f5ff42ca83 Review 1.3: an unreadable agent key file is kept, not overwritten
LoadOrCreate replaced a key file it could not decrypt, which made the old key
unrecoverable even when only the machine (DPAPI) had changed and the file could
have been put back. The file is now moved aside as credential-key.bin.unreadable-*
and the RSA object of the failed import is disposed.

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

149 lines
5.9 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");
}
}