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; /// Servere og databaser in SQLInsight Web: adding, testing, choosing databases, the GRANT script and passwords. [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 _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().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 }); /// Posts a form the way the browser does: with the antiforgery token from . private static async Task PostAsync(HttpClient client, string page, string action, Dictionary fields, params (string Name, string Value)[] repeated) { var html = await client.GetStringAsync(page); var values = new List> { new("__RequestVerificationToken", WebUtility.HtmlDecode(AntiforgeryField().Match(html).Groups[1].Value)) }; values.AddRange(fields); values.AddRange(repeated.Select(r => new KeyValuePair(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'FIRMA\AGENT01$';", 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 { ["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'FIRMA\AGENT01$';", 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 { ["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)); } }