sqlinsight-pro/tests/SQLInsight.IntegrationTests/InstanceSetupWebTests.cs
Søren Eggert Lundsteen Olsen f3ef0cbec7 Web: Servere og databaser, and the same in API v1 (scope config:write)
A page under Overvågning lists the monitored servers (from SQLInsight Web and,
read-only, from the agent's configuration file), adds a server (name\instance,
optional port, Windows via the agent's account or a SQL login, encryption),
pauses, resumes and removes it, and finds instances via the agent (SQL Server
Browser). Each server's page shows the agent's connection test with the rights
it has and lacks, the least-privilege GRANT script for the login the agent
arrives as, and the discovered databases (state, size, recovery model,
compatibility level, Dynamics AX) to tick for monitoring, with "overvåg
automatisk nye databaser" and AX findings to the hub per database. Pages
refresh while the agent works on a request.

Only SQLInsight:Web:AdminGroups (new) may change anything; others see it.
SQL login passwords are sealed with the agent's public key on arrival and
never shown or returned; a new server or login needs the password again.

API v1: /monitored-instances (list, add, get, patch pause/monitor-new,
connection, delete), …/connection-test (start, latest with missing
permissions), …/discovery, …/databases (read, choose), …/grant-script,
/instance-discovery and /agent-requests/{id}. Changes need the new token scope
config:write, or AdminGroups for Windows users.

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

252 lines
15 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 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));
}
}