- POST/GET /api/v1/support-cases[/{id}] and POST .../{id}/close in
SQLInsight Web, sharing the /support form's logic: same checks, finding
prefill without sensitive evidence, Idempotency-Key as the request id,
202 Accepted with Location, problem+json 403 without the support:write
scope or a support license. RequestedBy is the Windows user or
token:<name>.
- Closing: "Luk sag" on /support and the API record CloseRequested
(migration 0012, column-level UPDATE for the read-only web login); the
agent sends it to the hub with backoff and sets Closed when confirmed;
a pending case is closed once it has a number; an older hub list never
turns CloseRequested back to Open.
- Hub: POST /api/v1/cases/{reference}/close for the license's own cases
(others 404), idempotent, ClosedBy/ClosedComment (EF migration
CaseClosedBy). Portal closing records "SEOSoft: <user>"; the customer
page shows who closed and why; the support report and CSV get "Lukket af".
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
213 lines
12 KiB
C#
213 lines
12 KiB
C#
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;
|
|
|
|
/// <summary>Support cases through the API of SQLInsight Web: created and closed, through the agent to the hub and back.</summary>
|
|
[Collection(SqlServerTestGroup.Name)]
|
|
[Trait("Category", "Integration")]
|
|
public class SupportApiTests(SqlServerFixture sql, HubFixture hub) : IClassFixture<HubFixture>, 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<HttpClient> 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<HubSyncService>.Instance);
|
|
}
|
|
|
|
private WebApplicationFactory<Program> Web() => new WebApplicationFactory<Program>().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<HubDbContext>();
|
|
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<Program>().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
|
|
}
|
|
}
|