sqlinsight-pro/tests/SQLInsight.IntegrationTests/InstanceSetupWebTests.cs
Søren Eggert Lundsteen Olsen 8e1b877fda Review 1.3: overview leaves out removed instances and marks paused ones
The overview kept listing removed and paused instances with their last data and
counted them in the totals and alerts. Removed instances now disappear from the
overview, Alle fund, /api/v1/instances and /metrics (their history stays for
reports and the instance page); paused ones are shown as "Sat på pause" and
are left out of the totals, the urgent findings and the alerts (Prometheus gets
sqlinsight_instance_paused instead of instance_up = 0).

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

277 lines
17 KiB
C#

using System.Net;
using System.Security.Claims;
using System.Security.Cryptography;
using System.Text.Json;
using System.Text.RegularExpressions;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Testing;
using SQLInsight.Core.Configuration;
using SQLInsight.Repository;
using SQLInsight.Web;
using SQLInsight.Web.Api;
namespace SQLInsight.IntegrationTests;
/// <summary>Servere og databaser in SQLInsight Web: adding, testing, choosing databases, the GRANT script and passwords.</summary>
[Collection(SqlServerTestGroup.Name)]
[Trait("Category", "Integration")]
public sealed partial class InstanceSetupWebTests(SqlServerFixture sql) : IAsyncLifetime
{
private const string Password = "Hemmelig-Kode-1!";
private readonly string _database = "SQLInsightSetup_" + Guid.NewGuid().ToString("N")[..8];
private readonly RSA _agentKey = RSA.Create(3072);
private WebApplicationFactory<Program> _factory = null!;
private InsightRepository _repository = null!;
[GeneratedRegex("name=\"__RequestVerificationToken\"[^>]*value=\"([^\"]+)\"")]
private static partial Regex AntiforgeryField();
public async Task InitializeAsync()
{
await sql.ExecuteAsSaAsync($"CREATE DATABASE [{_database}];");
_repository = new InsightRepository(sql.DatabaseConnectionString(_database));
await _repository.MigrateAsync();
_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()
{
_agentKey.Dispose();
await _factory.DisposeAsync();
await sql.ExecuteAsSaAsync($"ALTER DATABASE [{_database}] SET SINGLE_USER WITH ROLLBACK IMMEDIATE; DROP DATABASE [{_database}];");
}
private Task PublishAgentKeyAsync() => _repository.PublishAgentKeyAsync(new AgentKeyRow
{
KeyId = "agent-test", PublicKey = _agentKey.ExportSubjectPublicKeyInfo(), MachineName = "AGENT01", ServiceAccount = @"NT SERVICE\SQLInsight Agent",
Domain = "FIRMA", CreatedAt = DateTime.UtcNow, LastSeenAt = DateTime.UtcNow,
});
private HttpClient Client() => _factory.CreateClient(new WebApplicationFactoryClientOptions { AllowAutoRedirect = false });
/// <summary>Posts a form the way the browser does: with the antiforgery token from <paramref name="page"/>.</summary>
private static async Task<HttpResponseMessage> PostAsync(HttpClient client, string page, string action, Dictionary<string, string> fields, params (string Name, string Value)[] repeated)
{
var html = await client.GetStringAsync(page);
var values = new List<KeyValuePair<string, string>> { new("__RequestVerificationToken", WebUtility.HtmlDecode(AntiforgeryField().Match(html).Groups[1].Value)) };
values.AddRange(fields);
values.AddRange(repeated.Select(r => new KeyValuePair<string, string>(r.Name, r.Value)));
using var content = new FormUrlEncodedContent(values);
return await client.PostAsync(action, content);
}
private static int IdFrom(HttpResponseMessage response) =>
int.Parse(Regex.Match(response.Headers.Location!.OriginalString, @"/opsaetning/instanser/(\d+)").Groups[1].Value, System.Globalization.CultureInfo.InvariantCulture);
[Fact]
public async Task Overview_marks_paused_servers_and_leaves_out_removed_ones_and_so_do_the_alerts()
{
var now = DateTimeOffset.UtcNow;
var paused = await _repository.AddConfiguredInstanceAsync(new NewConfiguredInstance("paused-sql", "sql05", null, "Windows", null, null, null, "Mandatory", false, false), "anna", now);
var removed = await _repository.AddConfiguredInstanceAsync(new NewConfiguredInstance("removed-sql", "sql06", null, "Windows", null, null, null, "Mandatory", false, false), "anna", now);
await _repository.RecordInstanceErrorAsync("paused-sql", "kan ikke nås", now);
await _repository.RecordInstanceErrorAsync("removed-sql", "kan ikke nås", now);
await _repository.SetInstancePausedAsync(paused, true, "anna", now);
await _repository.RemoveConfiguredInstanceAsync(removed, "anna", now);
var home = await Client().GetStringAsync("/");
var metrics = await Client().GetStringAsync("/metrics");
Assert.Contains("paused-sql", home, StringComparison.Ordinal);
Assert.Contains("Sat på pause", home, StringComparison.Ordinal);
Assert.DoesNotContain("removed-sql", home, StringComparison.Ordinal);
Assert.Contains("sqlinsight_instance_paused{instance=\"paused-sql\"} 1", metrics, StringComparison.Ordinal);
Assert.DoesNotContain("sqlinsight_instance_up{instance=\"paused-sql\"}", metrics, StringComparison.Ordinal);
Assert.DoesNotContain("removed-sql", metrics, StringComparison.Ordinal);
// The history is still there.
Assert.Contains("fjernet", await Client().GetStringAsync("/instanser/removed-sql"), StringComparison.Ordinal);
}
[Fact]
public async Task Page_lists_servers_from_the_configuration_file_and_the_web_and_the_menu_links_to_it()
{
await _repository.SyncConfigFileInstancesAsync([new ConfigFileInstance("prod-sql01", "sql01", "Windows", null, "Mandatory", false)], DateTimeOffset.UtcNow);
await _repository.AddConfiguredInstanceAsync(new NewConfiguredInstance("ax-sql", "ax01\\AX30", null, "Windows", null, null, null, "Mandatory", false, false), "anna", DateTimeOffset.UtcNow);
var page = await Client().GetStringAsync("/opsaetning/instanser");
var home = await Client().GetStringAsync("/");
Assert.Contains("prod-sql01", page, StringComparison.Ordinal);
Assert.Contains("fra konfigurationsfil", page, StringComparison.Ordinal);
Assert.Contains(@"ax01\AX30", page, StringComparison.Ordinal);
Assert.Contains("Alle (også nye)", page, StringComparison.Ordinal);
Assert.Contains("Agenten har ikke meldt sig endnu", page, StringComparison.Ordinal);
Assert.Contains("href=\"/opsaetning/instanser\"", home, StringComparison.Ordinal);
Assert.Contains("Servere og databaser", home, StringComparison.Ordinal);
}
[Fact]
public async Task Admin_adds_a_server_and_the_agent_is_asked_to_test_it_and_find_its_databases()
{
var client = Client();
var posted = await PostAsync(client, "/opsaetning/instanser", "/opsaetning/instanser/tilfoej", new()
{
["server"] = @"sql01.firma.local\PROD", ["port"] = "1450", ["authentication"] = "Windows", ["encryption"] = "Mandatory",
});
Assert.Equal(HttpStatusCode.Redirect, posted.StatusCode);
var id = IdFrom(posted);
var row = (await _repository.GetConfiguredInstanceAsync(id))!;
Assert.Equal((@"sql01.firma.local\PROD", 1450, "Windows", false), (row.Name, row.Port, row.Authentication, row.MonitorNewDatabases));
Assert.Equal(AgentRequestStatus.Pending, (await _repository.GetLatestAgentRequestAsync(id, AgentRequestKind.ConnectionTest))!.Status);
Assert.NotNull(await _repository.GetLatestAgentRequestAsync(id, AgentRequestKind.DatabaseDiscovery));
var waiting = await client.GetStringAsync($"/opsaetning/instanser/{id}?tilfoejet=1");
Assert.Contains("Venter på agenten", waiting, StringComparison.Ordinal);
Assert.Contains("http-equiv=\"refresh\"", waiting, StringComparison.Ordinal);
// The agent has published who it is: the script is for the computer account it arrives as on another server.
await PublishAgentKeyAsync();
var page = await client.GetStringAsync($"/opsaetning/instanser/{id}");
Assert.Contains(@"SET @LoginName = N&#x27;FIRMA\AGENT01$&#x27;;", page, StringComparison.Ordinal);
Assert.Contains(@"CREATE LOGIN [FIRMA\AGENT01$] FROM WINDOWS", page, StringComparison.Ordinal);
var duplicate = await PostAsync(client, "/opsaetning/instanser", "/opsaetning/instanser/tilfoej", new() { ["server"] = @"sql01.firma.local\PROD", ["authentication"] = "Windows" });
Assert.Contains("bruges allerede", Uri.UnescapeDataString(duplicate.Headers.Location!.OriginalString), StringComparison.Ordinal);
}
[Fact]
public async Task A_sql_login_password_is_sealed_for_the_agent_and_never_shown_again()
{
var client = Client();
var fields = new Dictionary<string, string>
{
["server"] = "sql02", ["authentication"] = "SqlLogin", ["userName"] = "sqlinsight", ["password"] = Password, ["trustServerCertificate"] = "true",
};
var withoutAgent = await PostAsync(client, "/opsaetning/instanser", "/opsaetning/instanser/tilfoej", fields);
Assert.Contains("Agenten har ikke meldt sig endnu", Uri.UnescapeDataString(withoutAgent.Headers.Location!.OriginalString), StringComparison.Ordinal);
Assert.Empty(await _repository.GetConfiguredInstancesAsync());
await PublishAgentKeyAsync();
var id = IdFrom(await PostAsync(client, "/opsaetning/instanser", "/opsaetning/instanser/tilfoej", fields));
var row = (await _repository.GetConfiguredInstanceAsync(id))!;
Assert.True(row.HasPassword);
Assert.Equal(Password, CredentialEnvelope.Open(row.PasswordCipher!, "agent-test", _agentKey, CredentialEnvelope.Context("sql02", "sqlinsight")));
var page = await client.GetStringAsync($"/opsaetning/instanser/{id}");
var api = await client.GetStringAsync($"/api/v1/monitored-instances/{id}");
Assert.DoesNotContain(Password, page, StringComparison.Ordinal);
Assert.Contains("adgangskode gemt krypteret", page, StringComparison.Ordinal);
Assert.DoesNotContain(Password, api, StringComparison.Ordinal);
Assert.DoesNotContain("cipher", api, StringComparison.OrdinalIgnoreCase);
Assert.True(JsonDocument.Parse(api).RootElement.GetProperty("hasPassword").GetBoolean());
// Another server without the password again is refused: the password is sealed to sql02.
var moved = await PostAsync(client, $"/opsaetning/instanser/{id}", $"/opsaetning/instanser/{id}/forbindelse", new()
{
["server"] = "evil01", ["authentication"] = "SqlLogin", ["userName"] = "sqlinsight",
});
Assert.Contains("Indtast adgangskoden igen", Uri.UnescapeDataString(moved.Headers.Location!.OriginalString), StringComparison.Ordinal);
Assert.Equal("sql02", (await _repository.GetConfiguredInstanceAsync(id))!.Server);
}
[Fact]
public async Task Connection_test_shows_the_missing_rights_and_the_script_for_the_login_the_agent_used()
{
var id = await _repository.AddConfiguredInstanceAsync(new NewConfiguredInstance("prod", "sql01", null, "Windows", null, null, null, "Mandatory", false, false), null, DateTimeOffset.UtcNow);
var request = await _repository.AddAgentRequestAsync(AgentRequestKind.ConnectionTest, id, null, "anna", DateTimeOffset.UtcNow);
await _repository.ClaimAgentRequestAsync(DateTimeOffset.UtcNow);
await _repository.CompleteAgentRequestAsync(request, true, AgentRequestJson.Serialize(new ConnectionTestResult
{
Connected = true, Provider = "SqlClient", ProductVersion = "16.0.4135.4", Release = "SQL Server 2022", Edition = "Standard Edition", ServerName = "SQL01",
Login = @"FIRMA\AGENT01$", PermissionsChecked = true,
Permissions =
[
new PermissionStatus("VIEW SERVER PERFORMANCE STATE", false, true, "DMV'er"),
new PermissionStatus("VIEW ANY DEFINITION", true, true, "Metadata"),
new PermissionStatus("ALTER TRACE", false, false, "Valgfri"),
],
}), null, DateTimeOffset.UtcNow);
var page = await Client().GetStringAsync($"/opsaetning/instanser/{id}");
var api = JsonDocument.Parse(await Client().GetStringAsync($"/api/v1/monitored-instances/{id}/connection-test")).RootElement;
Assert.Contains("Agenten mangler 1 rettighed", page, StringComparison.Ordinal);
Assert.Contains("VIEW SERVER PERFORMANCE STATE", page, StringComparison.Ordinal);
Assert.Contains("Valgfri, mangler", page, StringComparison.Ordinal);
Assert.Contains(@"SET @LoginName = N&#x27;FIRMA\AGENT01$&#x27;;", page, StringComparison.Ordinal);
Assert.DoesNotContain("http-equiv=\"refresh\"", page, StringComparison.Ordinal);
Assert.Equal(["VIEW SERVER PERFORMANCE STATE"], api.GetProperty("missingPermissions").EnumerateArray().Select(e => e.GetString()));
Assert.True(api.GetProperty("request").GetProperty("result").GetProperty("connected").GetBoolean());
}
[Fact]
public async Task Databases_and_ax_findings_to_the_hub_are_chosen_with_the_form()
{
var id = await _repository.AddConfiguredInstanceAsync(new NewConfiguredInstance("ax", "ax01", null, "Windows", null, null, null, "Mandatory", false, false), null, DateTimeOffset.UtcNow);
await _repository.SaveDiscoveredDatabasesAsync(id,
[
new DiscoveredDatabase { Name = "master", IsSystem = true, HasAccess = true, State = "ONLINE" },
new DiscoveredDatabase { Name = "AX30", HasAccess = true, State = "ONLINE", IsDynamicsAx = true, DynamicsAxVersion = "3.0", SizeMb = 20480, RecoveryModel = "FULL", CompatibilityLevel = 90 },
new DiscoveredDatabase { Name = "Sales", HasAccess = true, State = "ONLINE" },
], DateTimeOffset.UtcNow);
var page = $"/opsaetning/instanser/{id}";
var html = await Client().GetStringAsync(page);
Assert.Contains("Ja, AX 3.0", html, StringComparison.Ordinal);
Assert.Contains("20,0 GB", html, StringComparison.Ordinal);
var posted = await PostAsync(Client(), page, page + "/databaser", new() { ["monitorNew"] = "true" }, ("monitor", "AX30"), ("ax", "AX30"), ("ax", "Sales"));
Assert.Equal(HttpStatusCode.Redirect, posted.StatusCode);
var databases = (await _repository.GetConfiguredDatabasesAsync(id)).ToDictionary(d => d.DatabaseName);
Assert.Equal((true, true), (databases["AX30"].Monitored, databases["AX30"].SendDynamicsAxToHub));
Assert.Equal((false, false), (databases["Sales"].Monitored, databases["Sales"].SendDynamicsAxToHub));
Assert.True((await _repository.GetConfiguredInstanceAsync(id))!.MonitorNewDatabases);
}
[Fact]
public async Task A_configuration_file_server_is_paused_here_but_removed_in_the_file()
{
var id = (await _repository.SyncConfigFileInstancesAsync([new ConfigFileInstance("prod-sql01", "sql01", "Windows", null, "Mandatory", false)], DateTimeOffset.UtcNow))["prod-sql01"];
var page = $"/opsaetning/instanser/{id}";
var removed = await PostAsync(Client(), page, page + "/fjern", []);
var paused = await PostAsync(Client(), page, page + "/pause", new() { ["paused"] = "true" });
Assert.Contains("konfigurationsfil", Uri.UnescapeDataString(removed.Headers.Location!.OriginalString), StringComparison.Ordinal);
Assert.Equal($"{page}?pause=1", paused.Headers.Location!.OriginalString);
Assert.True((await _repository.GetConfiguredInstanceAsync(id))!.Paused);
Assert.DoesNotContain("Fjern serveren", await Client().GetStringAsync(page), StringComparison.Ordinal);
}
[Fact]
public async Task A_form_without_the_antiforgery_token_is_refused()
{
using var content = new FormUrlEncodedContent(new Dictionary<string, string> { ["server"] = "sql09" });
var response = await Client().PostAsync("/opsaetning/instanser/tilfoej", content);
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
Assert.Empty(await _repository.GetConfiguredInstancesAsync());
}
[Fact]
public void Only_admin_groups_and_config_write_tokens_may_change_the_configuration()
{
var windows = new WebOptions { Authentication = "Windows" };
windows.AdminGroups.Add(@"FIRMA\SQL-Admins");
static ClaimsPrincipal User(params Claim[] claims) => new(new ClaimsIdentity(claims, "Negotiate", ClaimTypes.Name, ClaimTypes.Role));
Assert.True(InstanceSetup.MayChange(User(new Claim(ClaimTypes.Role, @"FIRMA\SQL-Admins")), windows));
Assert.False(InstanceSetup.MayChange(User(new Claim(ClaimTypes.Role, @"FIRMA\SQL-Readers")), windows));
Assert.False(InstanceSetup.MayChange(User(new Claim(ClaimTypes.Role, @"FIRMA\SQL-Admins")), new WebOptions { Authentication = "Windows" }));
Assert.True(InstanceSetup.MayChange(User(), new WebOptions { Authentication = "None" }));
Assert.True(InstanceSetup.MayChange(User(new Claim(ApiTokenHandler.ClaimType, "itsm"), new Claim(ApiTokenHandler.ScopeClaimType, ApiToken.ConfigWrite)), windows));
Assert.False(InstanceSetup.MayChange(User(new Claim(ApiTokenHandler.ClaimType, "grafana"), new Claim(ApiTokenHandler.ScopeClaimType, ApiToken.Read),
new Claim(ClaimTypes.Role, @"FIRMA\SQL-Admins")), windows));
}
}