sqlinsight-pro/tests/SQLInsight.IntegrationTests/HubEndToEndTests.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

153 lines
8.6 KiB
C#

using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using SQLInsight.Agent;
using SQLInsight.Agent.Hub;
using SQLInsight.Agent.Monitoring;
using SQLInsight.Collectors;
using SQLInsight.Connectivity;
using SQLInsight.Contracts;
using SQLInsight.Hub.Data;
using SQLInsight.Repository;
using SQLInsight.Rules;
using SQLInsight.Rules.Engine;
namespace SQLInsight.IntegrationTests;
/// <summary>
/// The whole paid path: agent monitors real SQL Server, queues its state, delivers it to the hub, hub stores it in PostgreSQL.
/// </summary>
[Collection(SqlServerTestGroup.Name)]
[Trait("Category", "Integration")]
public class HubEndToEndTests(SqlServerFixture sql, HubFixture hub) : IClassFixture<HubFixture>, IAsyncLifetime
{
private readonly string _database = "SQLInsightE2E_" + Guid.NewGuid().ToString("N")[..8];
private InsightRepository _repository = null!;
public async Task InitializeAsync()
{
await sql.ExecuteAsSaAsync($"CREATE DATABASE [{_database}];");
_repository = new InsightRepository(sql.DatabaseConnectionString(_database));
await _repository.MigrateAsync();
}
public Task DisposeAsync() => sql.ExecuteAsSaAsync($"ALTER DATABASE [{_database}] SET SINGLE_USER WITH ROLLBACK IMMEDIATE; DROP DATABASE [{_database}];");
private sealed class TestServerClients(Func<HttpClient> create) : IHttpClientFactory
{
public HttpClient CreateClient(string name) => create();
}
[Fact]
public async Task Agent_state_reaches_the_hub_with_sensitive_evidence_redacted()
{
var options = Options.Create(new HubOptions { Enabled = true, Url = new Uri("http://localhost/"), License = hub.License("E2E A/S") });
options.Value.TrustedKeys[HubFixture.KeyId] = hub.PublicKey;
var outbox = new HubOutbox(options, _repository);
var monitor = new InstanceMonitor(
new MonitoredInstance
{
Name = "e2e-sql01", Server = sql.Server, Authentication = AuthenticationMode.SqlLogin, UserName = SqlServerFixture.AgentLogin,
Password = SqlServerFixture.AgentPassword, Provider = ConnectionProvider.SqlClient, TrustServerCertificate = true,
},
BuiltInCollectors.Create(), new Dictionary<string, TimeSpan>(), new SqlConnectionFactory(), new RuleEngine(BuiltInRules.Create()),
_repository, NullLogger.Instance, hubOutbox: outbox);
var sync = new HubSyncService(options, _repository, new TestServerClients(() => hub.Factory.CreateClient()), TimeProvider.System, NullLogger<HubSyncService>.Instance);
await monitor.RunCycleAsync(DateTimeOffset.UtcNow, CancellationToken.None);
var sent = await sync.RunOnceAsync(CancellationToken.None);
Assert.Equal(1, sent);
var status = await _repository.GetHubStatusAsync();
Assert.Equal("Valid", status!.LicenseState);
Assert.Equal(0, status.Pending);
Assert.NotNull(status.LastSentAt);
await using var scope = hub.Factory.Services.CreateAsyncScope();
var db = scope.ServiceProvider.GetRequiredService<HubDbContext>();
var instance = await db.Instances.Include(i => i.Customer).SingleAsync(i => i.InstanceKey == "e2e-sql01");
Assert.Equal("E2E A/S", instance.Customer!.Name);
Assert.Null(instance.ServerName); // not sent by default
var findings = await db.Findings.Where(f => f.InstanceId == instance.Id && f.ResolvedAt == null).ToListAsync();
Assert.Contains(findings, f => f.RuleId == "SQLI-CFG-001");
// Every built-in rule's outcome, except the Dynamics AX rules: no database has AX findings to the hub turned on.
Assert.True(await db.RuleStates.CountAsync(r => r.InstanceId == instance.Id)
>= BuiltInRules.Create().Count(r => !Core.Configuration.DynamicsAxFindings.IsDynamicsAxRule(r.Id)));
Assert.False(await db.RuleStates.AnyAsync(r => r.InstanceId == instance.Id && r.RuleId.StartsWith("SQLI-AX-")));
var latest = Assert.Single(await _repository.GetLatestSubmissionsAsync());
Assert.Equal("e2e-sql01", latest.InstanceName);
Assert.NotNull(latest.SentAt);
}
[Fact]
public async Task Rejected_license_is_given_up_not_retried_forever()
{
var community = hub.License("Nope A/S", SQLInsight.Licensing.LicensePlan.Professional, expires: DateTimeOffset.UtcNow.AddDays(-45));
var options = Options.Create(new HubOptions { Enabled = true, Url = new Uri("http://localhost/"), License = community });
options.Value.TrustedKeys[HubFixture.KeyId] = hub.PublicKey;
await _repository.EnqueueSubmissionAsync("x", Guid.NewGuid(), "{}", DateTimeOffset.UtcNow);
var sync = new HubSyncService(options, _repository, new TestServerClients(() => hub.Factory.CreateClient()), TimeProvider.System, NullLogger<HubSyncService>.Instance);
// Locally the agent sees the license is long expired and does not even try.
Assert.Equal(0, await sync.RunOnceAsync(CancellationToken.None));
Assert.Equal("Expired", (await _repository.GetHubStatusAsync())!.LicenseState);
}
[Fact]
public async Task License_is_never_sent_over_plain_http()
{
var options = Options.Create(new HubOptions { Enabled = true, Url = new Uri("http://hub.example.com/"), License = hub.License("Klar A/S") });
options.Value.TrustedKeys[HubFixture.KeyId] = hub.PublicKey;
await _repository.EnqueueSubmissionAsync("x", Guid.NewGuid(), "{}", DateTimeOffset.UtcNow);
var calls = 0;
var sync = new HubSyncService(options, _repository, new TestServerClients(() => { calls++; return hub.Factory.CreateClient(); }), TimeProvider.System, NullLogger<HubSyncService>.Instance);
Assert.Equal(0, await sync.RunOnceAsync(CancellationToken.None));
Assert.Equal(0, calls);
Assert.Contains("https", (await _repository.GetHubStatusAsync())!.LastError, StringComparison.Ordinal);
}
[Fact]
public async Task Experience_from_other_customers_reaches_the_customers_web_ui()
{
for (var i = 0; i < 3; i++)
{
await HubInsightTests.UpgradeAsync(hub, $"Erfaring {i}", ["SQLI-CFG-005"], []);
}
SQLInsight.Hub.Insights.InsightsEndpoint.ClearCache();
var options = new HubOptions { Enabled = true, Url = new Uri("http://localhost/"), License = hub.License("Modtager A/S") };
options.TrustedKeys[HubFixture.KeyId] = hub.PublicKey;
var sync = new HubSyncService(Options.Create(options), _repository, new TestServerClients(() => hub.Factory.CreateClient()), TimeProvider.System, NullLogger<HubSyncService>.Instance);
Assert.True(await sync.FetchInsightsAsync(hub.Factory.CreateClient(), options, CancellationToken.None));
var instanceId = await _repository.UpsertInstanceAsync("prod-sql01", new SQLInsight.Core.Instances.InstanceInfo
{
ServerName = "SQL01", Version = SQLInsight.Core.Instances.SqlServerVersion.Parse(HubInsightTests.OldBuild), ProductLevel = "RTM", ProductUpdateLevel = "CU20",
Edition = "Enterprise Edition", EngineEdition = SQLInsight.Core.Instances.EngineEdition.Enterprise,
}, "SqlClient", false, DateTimeOffset.UtcNow);
await _repository.SaveRuleResultsAsync(instanceId,
[
new RuleResult
{
RuleId = "SQLI-CFG-005", RuleTitle = "Plan cache", Outcome = RuleOutcome.Findings,
Findings = [new SQLInsight.Core.Findings.Finding { RuleId = "SQLI-CFG-005", Severity = SQLInsight.Core.Findings.Severity.Medium, Title = "Plan cache er fyldt med engangsplaner", Explanation = "e", Recommendation = "r" }],
},
], DateTimeOffset.UtcNow);
var findingId = (await _repository.GetActiveFindingsAsync(instanceId)).Single().FindingId;
await using var web = new Microsoft.AspNetCore.Mvc.Testing.WebApplicationFactory<Program>().WithWebHostBuilder(b =>
{
Microsoft.AspNetCore.Hosting.HostingAbstractionsWebHostBuilderExtensions.UseEnvironment(b, "Development");
b.UseSetting("SQLInsight:Repository:ConnectionString", sql.DatabaseConnectionString(_database));
b.UseSetting("SQLInsight:Web:Authentication", "None");
});
var html = await web.CreateClient().GetStringAsync($"/fund/{findingId}");
Assert.Contains("Erfaring fra SQLInsight-kunder", html, StringComparison.Ordinal);
Assert.Contains("Hos <strong>3 kunder</strong>", html, StringComparison.Ordinal);
Assert.Contains("CU21 (15.0.4415.2)", html, StringComparison.Ordinal);
}
}