- 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>
181 lines
9.6 KiB
C#
181 lines
9.6 KiB
C#
using System.Net;
|
|
using System.Text.RegularExpressions;
|
|
using Microsoft.AspNetCore.Hosting;
|
|
using Microsoft.AspNetCore.Mvc.Testing;
|
|
using Microsoft.Data.SqlClient;
|
|
using SQLInsight.Core.Findings;
|
|
using SQLInsight.Core.Instances;
|
|
using SQLInsight.Repository;
|
|
using SQLInsight.Rules.Engine;
|
|
|
|
namespace SQLInsight.IntegrationTests;
|
|
|
|
/// <summary>Support cases in SQLInsight Web: form, prefill from a finding, and filing with only read rights.</summary>
|
|
[Collection(SqlServerTestGroup.Name)]
|
|
[Trait("Category", "Integration")]
|
|
public partial class WebSupportTests(SqlServerFixture sql) : IAsyncLifetime
|
|
{
|
|
private readonly string _database = "SQLInsightSup_" + Guid.NewGuid().ToString("N")[..8];
|
|
private WebApplicationFactory<Program> _factory = null!;
|
|
private InsightRepository _repository = null!;
|
|
private long _findingId;
|
|
|
|
[GeneratedRegex("name=\"__RequestVerificationToken\"[^>]*value=\"([^\"]+)\"")]
|
|
private static partial Regex AntiforgeryField();
|
|
|
|
public async Task InitializeAsync()
|
|
{
|
|
await sql.ExecuteAsSaAsync($"CREATE DATABASE [{_database}];");
|
|
var connectionString = sql.DatabaseConnectionString(_database);
|
|
_repository = new InsightRepository(connectionString);
|
|
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 er ikke sat", 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 max server memory.",
|
|
Evidence = [new Evidence("max server memory (MB)", "2147483647"), new Evidence("login", "KUNDE\\hemmelig", Sensitive: true)],
|
|
},
|
|
],
|
|
},
|
|
], DateTimeOffset.UtcNow);
|
|
_findingId = (await _repository.GetActiveFindingsAsync(id)).Single().FindingId;
|
|
_factory = new WebApplicationFactory<Program>().WithWebHostBuilder(b =>
|
|
{
|
|
b.UseEnvironment("Development");
|
|
b.UseSetting("SQLInsight:Repository:ConnectionString", connectionString);
|
|
b.UseSetting("SQLInsight:Web:Authentication", "None");
|
|
});
|
|
}
|
|
|
|
public async Task DisposeAsync()
|
|
{
|
|
await _factory.DisposeAsync();
|
|
await sql.ExecuteAsSaAsync($"ALTER DATABASE [{_database}] SET SINGLE_USER WITH ROLLBACK IMMEDIATE; DROP DATABASE [{_database}];");
|
|
}
|
|
|
|
private Task AllowSupportAsync() => _repository.SaveHubStatusAsync(new HubStatusRow
|
|
{
|
|
Enabled = true, HubUrl = "https://hub.example/", LicenseState = "Valid", LicenseMessage = "Professional til Kunde A/S", Customer = "Kunde A/S",
|
|
LicensePlan = "Professional", LicenseExpires = DateTime.UtcNow.AddYears(1), UpdatedAt = DateTime.UtcNow,
|
|
});
|
|
|
|
[Fact]
|
|
public async Task Without_a_support_license_there_is_no_form_and_no_menu()
|
|
{
|
|
var client = _factory.CreateClient();
|
|
|
|
var support = await client.GetStringAsync("/support");
|
|
var finding = await client.GetStringAsync($"/fund/{_findingId}");
|
|
|
|
Assert.Contains("Supportsager kræver et abonnement", support, StringComparison.Ordinal);
|
|
Assert.DoesNotContain("name=\"contactEmail\"", support, StringComparison.Ordinal);
|
|
Assert.DoesNotContain("data-support-available", finding, StringComparison.Ordinal);
|
|
Assert.DoesNotContain("/support?fund=", finding, StringComparison.Ordinal);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Finding_offers_a_case_that_is_prefilled_and_filed()
|
|
{
|
|
await AllowSupportAsync();
|
|
var client = _factory.CreateClient(new WebApplicationFactoryClientOptions { AllowAutoRedirect = false });
|
|
|
|
var finding = await client.GetStringAsync($"/fund/{_findingId}");
|
|
Assert.Contains("data-support-available", finding, StringComparison.Ordinal);
|
|
Assert.Contains($"data-finding-id=\"{_findingId}\"", finding, StringComparison.Ordinal);
|
|
Assert.Contains($"href=\"/support?fund={_findingId}\"", finding, StringComparison.Ordinal);
|
|
Assert.Contains("support-menu", finding, StringComparison.Ordinal);
|
|
|
|
var form = await client.GetStringAsync($"/support?fund={_findingId}");
|
|
Assert.Contains("value=\"max server memory er ikke sat\"", form, StringComparison.Ordinal);
|
|
Assert.Contains("Regel: SQLI-CFG-001", form, StringComparison.Ordinal);
|
|
Assert.Contains("max server memory (MB): 2147483647", form, StringComparison.Ordinal);
|
|
Assert.DoesNotContain("hemmelig", form, StringComparison.Ordinal);
|
|
|
|
var token = WebUtility.HtmlDecode(AntiforgeryField().Match(form).Groups[1].Value);
|
|
using var content = new FormUrlEncodedContent(new Dictionary<string, string>
|
|
{
|
|
["__RequestVerificationToken"] = token, ["findingId"] = _findingId.ToString(System.Globalization.CultureInfo.InvariantCulture),
|
|
["title"] = "max server memory er ikke sat", ["description"] = "Hjælp", ["contactName"] = "Anna", ["contactEmail"] = "anna@kunde.dk",
|
|
});
|
|
var posted = await client.PostAsync("/support", content);
|
|
|
|
Assert.Equal("/support?sendt=1", posted.Headers.Location?.OriginalString);
|
|
var stored = Assert.Single(await _repository.GetSupportCasesAsync());
|
|
Assert.Equal((SupportCaseStatus.Pending, "SQLI-CFG-001", "prod-sql01", _findingId), (stored.Status, stored.RuleId, stored.InstanceName, stored.FindingId!.Value));
|
|
Assert.Contains("Sendes", await client.GetStringAsync("/support"), StringComparison.Ordinal);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Invalid_request_is_sent_back_with_the_reason()
|
|
{
|
|
await AllowSupportAsync();
|
|
var client = _factory.CreateClient(new WebApplicationFactoryClientOptions { AllowAutoRedirect = false });
|
|
var token = WebUtility.HtmlDecode(AntiforgeryField().Match(await client.GetStringAsync("/support")).Groups[1].Value);
|
|
using var content = new FormUrlEncodedContent(new Dictionary<string, string>
|
|
{
|
|
["__RequestVerificationToken"] = token, ["title"] = "Hjælp", ["contactName"] = "Anna", ["contactEmail"] = "uden-at",
|
|
});
|
|
|
|
var posted = await client.PostAsync("/support", content);
|
|
|
|
Assert.StartsWith("/support?fejl=", posted.Headers.Location?.OriginalString, StringComparison.Ordinal);
|
|
Assert.Empty(await _repository.GetSupportCasesAsync());
|
|
}
|
|
|
|
[Fact]
|
|
public async Task A_read_only_login_can_file_a_request_but_not_change_cases()
|
|
{
|
|
var login = "web_" + Guid.NewGuid().ToString("N")[..8];
|
|
const string password = "Read-Only-Login-1!";
|
|
await sql.ExecuteAsSaAsync($"""
|
|
CREATE LOGIN [{login}] WITH PASSWORD = N'{password}', CHECK_POLICY = OFF;
|
|
USE [{_database}]; CREATE USER [{login}] FOR LOGIN [{login}]; ALTER ROLE db_datareader ADD MEMBER [{login}];
|
|
""");
|
|
var readOnly = new InsightRepository(new SqlConnectionStringBuilder(sql.DatabaseConnectionString(_database))
|
|
{
|
|
UserID = login, Password = password, IntegratedSecurity = false,
|
|
}.ConnectionString);
|
|
|
|
var id = await readOnly.AddSupportRequestAsync(new NewSupportRequest(Guid.NewGuid(), "Hjælp", null, "Anna", "anna@kunde.dk", null, "KUNDE\\anna"), DateTimeOffset.UtcNow);
|
|
|
|
Assert.True(id > 0);
|
|
await Assert.ThrowsAsync<SqlException>(() => readOnly.MarkSupportRequestSentAsync(id, "SUP-000001", true, null, DateTimeOffset.UtcNow));
|
|
|
|
// Closing is only a request on the close columns: allowed with the same read-only login.
|
|
await _repository.MarkSupportRequestSentAsync(id, "SUP-000001", true, null, DateTimeOffset.UtcNow);
|
|
Assert.Equal(SupportCloseOutcome.Requested, await readOnly.RequestSupportCloseAsync(id, "KUNDE\\anna", "Løst", DateTimeOffset.UtcNow));
|
|
Assert.Equal(SupportCaseStatus.CloseRequested, (await readOnly.GetSupportCaseAsync(id))!.Status);
|
|
await Assert.ThrowsAsync<SqlException>(() => readOnly.MarkSupportClosedAsync(id, DateTime.UtcNow, "Kunde: x", DateTimeOffset.UtcNow));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Hub_cases_are_merged_into_the_local_list()
|
|
{
|
|
var request = Guid.NewGuid();
|
|
await _repository.AddSupportRequestAsync(new NewSupportRequest(request, "Fra kunden", null, "Anna", "anna@kunde.dk", null, null), DateTimeOffset.UtcNow);
|
|
var now = DateTime.UtcNow;
|
|
|
|
await _repository.SyncSupportCasesAsync(
|
|
[
|
|
new HubSupportCase("SUP-000001", "Fra kunden", false, now.AddDays(-1), now, "Customer", request),
|
|
new HubSupportCase("SUP-000002", "Fra SEOSoft", true, now, null, "Staff", null),
|
|
], DateTimeOffset.UtcNow);
|
|
await _repository.SyncSupportCasesAsync([new HubSupportCase("SUP-000002", "Fra SEOSoft", true, now, null, "Staff", null)], DateTimeOffset.UtcNow);
|
|
|
|
var cases = await _repository.GetSupportCasesAsync();
|
|
Assert.Equal(2, cases.Count);
|
|
Assert.Equal((SupportCaseStatus.Closed, "SUP-000001"), cases.Where(c => c.ClientRequestId == request).Select(c => (c.Status, c.Reference)).Single());
|
|
Assert.Equal(SupportCaseStatus.Open, cases.Single(c => c.Reference == "SUP-000002").Status);
|
|
}
|
|
}
|