using System.Net; using System.Net.Http.Headers; using System.Net.Http.Json; using System.Text.Json; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Mvc.Testing; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; using SQLInsight.Agent.Hub; using SQLInsight.Core.Findings; using SQLInsight.Core.Instances; using SQLInsight.Hub.Data; using SQLInsight.Repository; using SQLInsight.Rules.Engine; using SQLInsight.Web.Api; namespace SQLInsight.IntegrationTests; /// Support cases through the API of SQLInsight Web: created and closed, through the agent to the hub and back. [Collection(SqlServerTestGroup.Name)] [Trait("Category", "Integration")] public class SupportApiTests(SqlServerFixture sql, HubFixture hub) : IClassFixture, IAsyncLifetime { private const string WriteToken = "sqli_test-itsm-write"; private const string ReadToken = "sqli_test-read-only"; private readonly string _database = "SQLInsightSupApi_" + Guid.NewGuid().ToString("N")[..8]; private InsightRepository _repository = null!; private long _findingId; public async Task InitializeAsync() { await sql.ExecuteAsSaAsync($"CREATE DATABASE [{_database}];"); _repository = new InsightRepository(sql.DatabaseConnectionString(_database)); await _repository.MigrateAsync(); var id = await _repository.UpsertInstanceAsync("prod-sql01", new InstanceInfo { ServerName = "SQL01", Version = SqlServerVersion.Parse("15.0.4355.3"), ProductLevel = "RTM", Edition = "Standard Edition", EngineEdition = EngineEdition.Standard, }, "SqlClient", false, DateTimeOffset.UtcNow); await _repository.SaveRuleResultsAsync(id, [ new RuleResult { RuleId = "SQLI-CFG-001", RuleTitle = "max server memory", Outcome = RuleOutcome.Findings, Findings = [ new Finding { RuleId = "SQLI-CFG-001", Severity = Severity.High, Title = "max server memory er ikke sat", Explanation = "SQL Server må bruge al hukommelse.", Recommendation = "Sæt den.", Evidence = [new Evidence("max server memory (MB)", "2147483647"), new Evidence("login", "KUNDE\\hemmelig", Sensitive: true)], }, ], }, ], DateTimeOffset.UtcNow); _findingId = (await _repository.GetActiveFindingsAsync(id)).Single().FindingId; } public Task DisposeAsync() => sql.ExecuteAsSaAsync($"ALTER DATABASE [{_database}] SET SINGLE_USER WITH ROLLBACK IMMEDIATE; DROP DATABASE [{_database}];"); private Task AllowSupportAsync() => _repository.SaveHubStatusAsync(new HubStatusRow { Enabled = true, HubUrl = "http://localhost/", LicenseState = "Valid", LicenseMessage = "Professional", Customer = "Kunde A/S", LicensePlan = "Professional", LicenseExpires = DateTime.UtcNow.AddYears(1), UpdatedAt = DateTime.UtcNow, }); private sealed class TestServerClients(Func create) : IHttpClientFactory { public HttpClient CreateClient(string name) => create(); } private HubSyncService Sync(string license) { var options = Options.Create(new SQLInsight.Agent.Hub.HubOptions { Enabled = true, Url = new Uri("http://localhost/"), License = license }); options.Value.TrustedKeys[HubFixture.KeyId] = hub.PublicKey; return new HubSyncService(options, _repository, new TestServerClients(() => hub.Factory.CreateClient()), TimeProvider.System, NullLogger.Instance); } private WebApplicationFactory Web() => new WebApplicationFactory().WithWebHostBuilder(b => { b.UseEnvironment("Development"); b.UseSetting("SQLInsight:Repository:ConnectionString", sql.DatabaseConnectionString(_database)); b.UseSetting("SQLInsight:Web:Authentication", "None"); }); private static StringContent Body(object value) => new(JsonSerializer.Serialize(value), System.Text.Encoding.UTF8, "application/json"); [Fact] public async Task Case_created_and_closed_through_the_api_reaches_the_hub_and_its_report() { var customer = $"Support API {Guid.NewGuid():N}"[..28]; var license = hub.License(customer); await AllowSupportAsync(); await using var web = Web(); var client = web.CreateClient(); var request = new HttpRequestMessage(HttpMethod.Post, "/api/v1/support-cases") { Content = Body(new { contactName = "Anna", contactEmail = "anna@kunde.dk", findingId = _findingId, description = "Siden mandag." }), }; request.Headers.Add("Idempotency-Key", "itsm-INC0012345"); var created = await client.SendAsync(request); var again = new HttpRequestMessage(HttpMethod.Post, "/api/v1/support-cases") { Content = Body(new { title = "Andet", contactName = "Anna", contactEmail = "anna@kunde.dk" }) }; again.Headers.Add("Idempotency-Key", "itsm-INC0012345"); var repeated = await client.SendAsync(again); Assert.Equal(HttpStatusCode.Accepted, created.StatusCode); var body = JsonDocument.Parse(await created.Content.ReadAsStringAsync()).RootElement; var id = body.GetProperty("id").GetInt64(); Assert.Equal("Pending", body.GetProperty("status").GetString()); Assert.Equal($"/api/v1/support-cases/{id}", created.Headers.Location!.OriginalString); Assert.Equal(id, JsonDocument.Parse(await repeated.Content.ReadAsStringAsync()).RootElement.GetProperty("id").GetInt64()); var stored = (await _repository.GetSupportCaseAsync(id))!; Assert.Equal("max server memory er ikke sat", stored.Title); // from the finding Assert.Contains("Siden mandag.", stored.Description, StringComparison.Ordinal); Assert.Contains("2147483647", stored.Description, StringComparison.Ordinal); Assert.DoesNotContain("hemmelig", stored.Description, StringComparison.Ordinal); // sensitive evidence left out await Sync(license).RunOnceAsync(CancellationToken.None); var numbered = JsonDocument.Parse(await client.GetStringAsync($"/api/v1/support-cases/{id}")).RootElement; Assert.StartsWith("SUP-", numbered.GetProperty("reference").GetString(), StringComparison.Ordinal); Assert.Equal("Open", numbered.GetProperty("status").GetString()); var close = await client.PostAsync($"/api/v1/support-cases/{id}/close", Body(new { comment = "Løst ved at sætte max server memory." })); Assert.Equal(HttpStatusCode.Accepted, close.StatusCode); Assert.Equal("CloseRequested", JsonDocument.Parse(await close.Content.ReadAsStringAsync()).RootElement.GetProperty("status").GetString()); await Sync(license).RunOnceAsync(CancellationToken.None); var closed = JsonDocument.Parse(await client.GetStringAsync($"/api/v1/support-cases/{id}")).RootElement; Assert.Equal("Closed", closed.GetProperty("status").GetString()); Assert.StartsWith("Kunde:", closed.GetProperty("closedBy").GetString(), StringComparison.Ordinal); Assert.Equal(HttpStatusCode.OK, (await client.PostAsync($"/api/v1/support-cases/{id}/close", Body(new { }))).StatusCode); // idempotent Assert.Equal(HttpStatusCode.NotFound, (await client.PostAsync("/api/v1/support-cases/999999/close", Body(new { }))).StatusCode); var list = JsonDocument.Parse(await client.GetStringAsync("/api/v1/support-cases?status=Closed")).RootElement; Assert.Equal(1, list.GetArrayLength()); await using var scope = hub.Factory.Services.CreateAsyncScope(); var db = scope.ServiceProvider.GetRequiredService(); var atHub = await db.SupportCases.SingleAsync(c => c.Customer!.Name == customer); Assert.False(atHub.IsOpen); Assert.Equal("Løst ved at sætte max server memory.", atHub.ClosedComment); var report = await new SQLInsight.Hub.Portal.PortalQueries(db, TimeProvider.System).SupportReportAsync(DateTime.UtcNow.AddDays(-1), DateTime.UtcNow.AddDays(1), atHub.CustomerId); Assert.Equal("Kunde", Assert.Single(report.Cases).ClosedByWhom); } [Fact] public async Task Hub_closes_only_the_license_customers_own_cases() { var owner = hub.License($"Ejer {Guid.NewGuid():N}"[..20]); var stranger = hub.License($"Fremmed {Guid.NewGuid():N}"[..20]); await _repository.AddSupportRequestAsync(new NewSupportRequest(Guid.NewGuid(), "Hjælp", null, "Anna", "anna@kunde.dk", null, null), DateTimeOffset.UtcNow); await Sync(owner).RunOnceAsync(CancellationToken.None); var reference = (await _repository.GetSupportCasesAsync()).Single().Reference!; var client = hub.Factory.CreateClient(); client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("SQLInsight-License", stranger); var response = await client.PostAsync($"/api/v1/cases/{reference}/close", JsonContent.Create(new { comment = "nej" })); Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); } [Fact] public async Task Tokens_need_the_support_scope_and_a_support_license() { int port; using (var probe = new System.Net.Sockets.TcpListener(IPAddress.Loopback, 0)) { probe.Start(); port = ((IPEndPoint)probe.LocalEndpoint).Port; } await using var web = new WebApplicationFactory().WithWebHostBuilder(b => { b.UseEnvironment("Development"); b.UseSetting("SQLInsight:Repository:ConnectionString", sql.DatabaseConnectionString(_database)); b.UseSetting("SQLInsight:Web:Authentication", "Windows"); b.UseSetting("SQLInsight:Web:ApiTokens:itsm:hash", "sha256:" + ApiTokenHandler.Hash(WriteToken)); b.UseSetting("SQLInsight:Web:ApiTokens:itsm:scopes:0", "read"); b.UseSetting("SQLInsight:Web:ApiTokens:itsm:scopes:1", "support:write"); b.UseSetting("SQLInsight:Web:ApiTokens:grafana", "sha256:" + ApiTokenHandler.Hash(ReadToken)); b.UseSetting("urls", $"http://127.0.0.1:{port}"); }); web.UseKestrel(); web.StartServer(); HttpClient Client(string token) { var c = web.CreateClient(new WebApplicationFactoryClientOptions { BaseAddress = new Uri($"http://127.0.0.1:{port}") }); c.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token); return c; } var case_ = new { title = "Fra ITSM", contactName = "Anna", contactEmail = "anna@kunde.dk" }; var withoutLicense = await Client(WriteToken).PostAsync("/api/v1/support-cases", Body(case_)); await AllowSupportAsync(); var readOnly = await Client(ReadToken).PostAsync("/api/v1/support-cases", Body(case_)); var allowed = await Client(WriteToken).PostAsync("/api/v1/support-cases", Body(case_)); var id = JsonDocument.Parse(await allowed.Content.ReadAsStringAsync()).RootElement.GetProperty("id").GetInt64(); var readOnlyClose = await Client(ReadToken).PostAsync($"/api/v1/support-cases/{id}/close", Body(new { })); var readOnlyList = await Client(ReadToken).GetAsync("/api/v1/support-cases"); var writeOnlyPages = await Client(WriteToken).GetAsync("/support"); Assert.Equal(HttpStatusCode.Forbidden, withoutLicense.StatusCode); Assert.Equal("application/problem+json", withoutLicense.Content.Headers.ContentType?.MediaType); Assert.Equal(HttpStatusCode.Forbidden, readOnly.StatusCode); Assert.Equal(HttpStatusCode.Accepted, allowed.StatusCode); Assert.Equal("token:itsm", (await _repository.GetSupportCaseAsync(id))!.RequestedBy); Assert.Equal(HttpStatusCode.Forbidden, readOnlyClose.StatusCode); Assert.Equal(HttpStatusCode.OK, readOnlyList.StatusCode); Assert.Equal(HttpStatusCode.Unauthorized, writeOnlyPages.StatusCode); // a token never opens the web pages } }