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