sqlinsight-pro/tests/SQLInsight.IntegrationTests/WebSupportTests.cs
Søren Eggert Lundsteen Olsen 50bc878776 Customers file support cases in SQLInsight Web and follow their status
- Repository migration 0005: insight.SupportCases. The web UI has only
  db_datareader, so INSERT on this one table is granted to public.
- Web: /support (menu "Support") lists the customer's cases and, with
  Hub sync on and a Professional/Enterprise license, a form (title,
  description, contact; contact prefilled from the Windows user).
  /support?fund=<id> prefills from a finding: instance, rule, severity,
  explanation and non-sensitive measurements.
- "Opret supportsag" on findings: right-click a finding link or a
  finding's heading (support-menu.js, a static file within the CSP;
  Shift+F10/context key, Escape closes), and a visible button on the
  finding page that works without JavaScript. Only when support is
  available.
- Agent: sends pending requests to POST /api/v1/cases, stores the case
  number, backs off on failures and gives up on 400/401/403/422; fetches
  the customer's cases every 5 minutes (at once after sending).
- Docs: support.md (also on the community site), databehandling.md
  (contact details are sent to the hub).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-01 11:34:06 +02:00

175 lines
9 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));
}
[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);
}
}