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 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));
}
}