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>
This commit is contained in:
Søren Eggert Lundsteen Olsen 2026-10-01 11:34:06 +02:00
parent 83c247d536
commit 50bc878776
17 changed files with 862 additions and 1 deletions

View file

@ -27,6 +27,8 @@ Kun når `SQLInsight:Hub:Enabled` er `true` og der er en gyldig licens. Agenten
Der sendes aldrig tabeldata, forespørgselsresultater, adgangskoder eller forbindelsesstrenge. Der sendes aldrig tabeldata, forespørgselsresultater, adgangskoder eller forbindelsesstrenge.
**Supportsager** (Professional og Enterprise, se [support.md](support.md)): når en bruger opretter en sag i webbrugerfladen, sendes titel, beskrivelse, kontaktpersonens navn, e-mail og eventuelt telefonnummer samt brugerens Windows-brugernavn til hubben. Det er personoplysninger, som SEOSoft bruger til at besvare sagen. Beskrivelsen indeholder kun det, brugeren selv skriver; følsomme målinger sættes ikke automatisk ind.
Kunden kan se præcis det, der er sendt, under **System** i webbrugerfladen ("Det sendes til SQLInsight Hub"). Kunden kan se præcis det, der er sendt, under **System** i webbrugerfladen ("Det sendes til SQLInsight Hub").
Slår kunden `SendSensitiveEvidence` til, kan fund indeholde personoplysninger (brugernavne, og data i forespørgselstekst som fx `WHERE cpr = '…'`). Det bør kun ske efter aftale, og det skal fremgå af databehandleraftalen. Slår kunden `SendSensitiveEvidence` til, kan fund indeholde personoplysninger (brugernavne, og data i forespørgselstekst som fx `WHERE cpr = '…'`). Det bør kun ske efter aftale, og det skal fremgå af databehandleraftalen.

25
docs/support.md Normal file
View file

@ -0,0 +1,25 @@
# Supportsager
Kunder med Professional eller Enterprise kan oprette supportsager hos SEOSoft direkte fra SQLInsight Web og følge deres status.
## Krav
- En gyldig Professional- eller Enterprise-licens og deling med SQLInsight Hub slået til (`SQLInsight:Hub:Enabled`, se [installation.md](installation.md)). Under **System** i webbrugerfladen kan I se, om licensen er gyldig.
- Uden det viser siden **Support** en forklaring i stedet for formularen, og der er ingen "Opret supportsag" på fundene.
## Opret en sag
- **Support** i menuen: titel, beskrivelse, kontaktperson, e-mail og eventuelt telefon. Kontaktpersonen er udfyldt med jeres Windows-brugernavn og kan rettes.
- **Fra et fund:** højreklik på et fund i en fundliste eller på fundets overskrift, og vælg **Opret supportsag** (tastatur: Shift+F10 eller kontekstmenu-tasten på et markeret fund; Escape lukker). På fundets side er der også en knap. Titlen og beskrivelsen udfyldes med fundets instans, regel, alvor, forklaring og målinger. Følsomme målinger (forespørgselstekst, logins, servernavne) sættes ikke ind.
Sagen sendes af agenten, ikke af webbrugerfladen: den gemmes i repository-databasen, og agenten sender den til SQLInsight Hub inden for et halvt minut. Hubben giver den et sagsnummer (`SUP-000001`, `SUP-000002` …), som vises på siden **Support**. Kan hubben ikke nås, prøver agenten igen. Afviser hubben sagen (fx fordi licensen ikke dækker support), står den som *Afvist* med årsagen.
## Status
Siden **Support** viser alle jeres sager, også dem SEOSoft har oprettet for jer, med status *Åben* eller *Lukket*. Agenten henter status fra hubben hvert 5. minut. Dialogen om sagen foregår på mail eller telefon.
## Rettigheder
Webbrugerfladens login har kun `db_datareader` på repositoriet. For at kunne oprette en sag har rollen `public` i repository-databasen fået `INSERT` på den ene tabel `insight.SupportCases` (migration `0005_support_cases.sql`). Kun repositoriets egne brugere findes i den database.
Hvad der sendes til SEOSoft, står i [databehandling.md](databehandling.md).

View file

@ -108,9 +108,89 @@ public sealed partial class HubSyncService(
await FetchInsightsAsync(http, settings, cancellationToken).ConfigureAwait(false); await FetchInsightsAsync(http, settings, cancellationToken).ConfigureAwait(false);
} }
if (status.License?.IncludesPrioritySupport == true)
{
await SyncSupportCasesAsync(http, settings, cancellationToken).ConfigureAwait(false);
}
return sent; return sent;
} }
/// <summary>How often the status of the customer's support cases is fetched when nothing was sent.</summary>
public static readonly TimeSpan CasesInterval = TimeSpan.FromMinutes(5);
private DateTimeOffset? _casesFetchedAt;
/// <summary>
/// Sends support requests filed in SQLInsight Web and stores the case number the hub gives them; then fetches the
/// status of all the customer's cases. Rejections the hub will keep making are given up, like submissions.
/// </summary>
public async Task SyncSupportCasesAsync(HttpClient http, HubOptions settings, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(http);
ArgumentNullException.ThrowIfNull(settings);
var created = 0;
foreach (var item in await repository.GetDueSupportRequestsAsync(time.GetUtcNow(), cancellationToken: cancellationToken).ConfigureAwait(false))
{
var now = time.GetUtcNow();
try
{
var body = new Contracts.SupportCaseRequest(item.ClientRequestId!.Value, item.Title, item.Description, item.ContactName ?? string.Empty,
item.ContactEmail ?? string.Empty, item.ContactPhone, item.RequestedBy, item.InstanceName, item.RuleId, item.Subject);
using var request = new HttpRequestMessage(HttpMethod.Post, new Uri(settings.Url!, "api/v1/cases"))
{
Content = new StringContent(System.Text.Json.JsonSerializer.Serialize(body, HubOutbox.Json), Encoding.UTF8, "application/json"),
};
request.Headers.Authorization = new AuthenticationHeaderValue(AuthorizationScheme, settings.License);
using var response = await http.SendAsync(request, cancellationToken).ConfigureAwait(false);
var text = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
if (response.IsSuccessStatusCode && System.Text.Json.JsonSerializer.Deserialize<Contracts.SupportCaseDto>(text, HubOutbox.Json) is { } dto)
{
await repository.MarkSupportRequestSentAsync(item.CaseId, dto.Reference, dto.IsOpen, dto.ClosedAt?.UtcDateTime, now, cancellationToken).ConfigureAwait(false);
created++;
continue;
}
var error = $"{(int)response.StatusCode} {response.ReasonPhrase}: {Truncate(text)}";
var permanent = response.StatusCode is HttpStatusCode.BadRequest or HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden or HttpStatusCode.UnprocessableEntity;
await repository.MarkSupportRequestFailedAsync(item.CaseId, error, now, permanent ? null : now + Backoff(item.Attempts), cancellationToken).ConfigureAwait(false);
LogCaseRejected(logger, item.Title, error);
}
catch (Exception ex) when (ex is HttpRequestException or System.Text.Json.JsonException or TaskCanceledException && !cancellationToken.IsCancellationRequested)
{
await repository.MarkSupportRequestFailedAsync(item.CaseId, ex.Message, now, now + Backoff(item.Attempts), cancellationToken).ConfigureAwait(false);
LogCaseUnreachable(logger, ex.Message);
}
}
if (created == 0 && _casesFetchedAt is { } fetched && time.GetUtcNow() - fetched < CasesInterval)
{
return;
}
try
{
using var request = new HttpRequestMessage(HttpMethod.Get, new Uri(settings.Url!, "api/v1/cases"));
request.Headers.Authorization = new AuthenticationHeaderValue(AuthorizationScheme, settings.License);
using var response = await http.SendAsync(request, cancellationToken).ConfigureAwait(false);
if (!response.IsSuccessStatusCode)
{
return;
}
var json = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
var cases = System.Text.Json.JsonSerializer.Deserialize<List<Contracts.SupportCaseDto>>(json, HubOutbox.Json) ?? [];
await repository.SyncSupportCasesAsync(
cases.Select(c => new HubSupportCase(c.Reference, c.Title, c.IsOpen, c.CreatedAt.UtcDateTime, c.ClosedAt?.UtcDateTime, c.Source, c.ClientRequestId)),
time.GetUtcNow(), cancellationToken).ConfigureAwait(false);
_casesFetchedAt = time.GetUtcNow();
}
catch (Exception ex) when (ex is HttpRequestException or System.Text.Json.JsonException or TaskCanceledException && !cancellationToken.IsCancellationRequested)
{
LogCaseUnreachable(logger, ex.Message);
}
}
/// <summary>How often aggregated experience from other customers is fetched.</summary> /// <summary>How often aggregated experience from other customers is fetched.</summary>
public static readonly TimeSpan InsightsInterval = TimeSpan.FromHours(6); public static readonly TimeSpan InsightsInterval = TimeSpan.FromHours(6);
@ -166,6 +246,12 @@ public sealed partial class HubSyncService(
[LoggerMessage(Level = LogLevel.Information, Message = "Could not fetch insights from the hub: {Message}")] [LoggerMessage(Level = LogLevel.Information, Message = "Could not fetch insights from the hub: {Message}")]
private static partial void LogInsightsFailed(ILogger logger, string message); private static partial void LogInsightsFailed(ILogger logger, string message);
[LoggerMessage(Level = LogLevel.Warning, Message = "Hub rejected the support case {Title}: {Error}")]
private static partial void LogCaseRejected(ILogger logger, string title, string error);
[LoggerMessage(Level = LogLevel.Information, Message = "Support cases could not be synced with the hub: {Message}")]
private static partial void LogCaseUnreachable(ILogger logger, string message);
[LoggerMessage(Level = LogLevel.Error, Message = "Hub sync round failed")] [LoggerMessage(Level = LogLevel.Error, Message = "Hub sync round failed")]
private static partial void LogRoundFailed(ILogger logger, Exception exception); private static partial void LogRoundFailed(ILogger logger, Exception exception);
} }

View file

@ -23,6 +23,7 @@ public sealed class DocsService
("integration", "Prometheus, Grafana, JSON-API og SCOM via Windows' hændelseslog."), ("integration", "Prometheus, Grafana, JSON-API og SCOM via Windows' hændelseslog."),
("rapporter", "Driftsrapport til ledelse og revision som PDF, også som månedlig mail."), ("rapporter", "Driftsrapport til ledelse og revision som PDF, også som månedlig mail."),
("regler", "Skriv egne regler i SQL, og bidrag med community-regler."), ("regler", "Skriv egne regler i SQL, og bidrag med community-regler."),
("support", "Opret supportsager hos SEOSoft fra webbrugerfladen og følg deres status."),
("databehandling", "Hvilke data der behandles, hvor de ligger, og hvad der deles."), ("databehandling", "Hvilke data der behandles, hvor de ligger, og hvad der deles."),
("legacy-versions", "SQL Server 2000, 2005 og 2008: hvad der kan måles, og hvordan der forbindes."), ("legacy-versions", "SQL Server 2000, 2005 og 2008: hvad der kan måles, og hvordan der forbindes."),
]; ];

View file

@ -19,6 +19,13 @@ public sealed record HubStatusRow
public DateTime? LastErrorAt { get; init; } public DateTime? LastErrorAt { get; init; }
public DateTime UpdatedAt { get; init; } public DateTime UpdatedAt { get; init; }
public int Pending { get; init; } public int Pending { get; init; }
/// <summary>
/// Support cases can be filed: Hub sync is on and the agent last saw a valid (or grace-period) Professional or
/// Enterprise license. The agent is what talks to the hub, so its view decides.
/// </summary>
public bool AllowsSupportCases =>
Enabled && LicenseState is "Valid" or "ExpiredInGrace" && LicensePlan is "Professional" or "Enterprise";
} }
public sealed partial class InsightRepository public sealed partial class InsightRepository

View file

@ -0,0 +1,144 @@
using Dapper;
namespace SQLInsight.Repository;
/// <summary>Values of insight.SupportCases.Status.</summary>
public static class SupportCaseStatus
{
/// <summary>Filed in SQLInsight Web; the agent has not had a case number back from the hub yet.</summary>
public const string Pending = "Pending";
public const string Open = "Open";
public const string Closed = "Closed";
/// <summary>The hub refused the request (e.g. no support in the license); see LastError.</summary>
public const string Failed = "Failed";
}
public sealed record SupportCaseRow
{
public long CaseId { get; init; }
public Guid? ClientRequestId { get; init; }
public string? Reference { get; init; }
public string Title { get; init; } = string.Empty;
public string? Description { get; init; }
public string? ContactName { get; init; }
public string? ContactEmail { get; init; }
public string? ContactPhone { get; init; }
public long? FindingId { get; init; }
public string? InstanceName { get; init; }
public string? RuleId { get; init; }
public string? Subject { get; init; }
public string? RequestedBy { get; init; }
public string Source { get; init; } = "Customer";
public string Status { get; init; } = SupportCaseStatus.Pending;
public string? LastError { get; init; }
public int Attempts { get; init; }
public DateTime CreatedAt { get; init; }
public DateTime? SentAt { get; init; }
public DateTime? ClosedAt { get; init; }
}
/// <summary>A request filed in SQLInsight Web.</summary>
public sealed record NewSupportRequest(
Guid ClientRequestId,
string Title,
string? Description,
string ContactName,
string ContactEmail,
string? ContactPhone,
string? RequestedBy,
long? FindingId = null,
string? InstanceName = null,
string? RuleId = null,
string? Subject = null);
/// <summary>A case as the hub reports it.</summary>
public sealed record HubSupportCase(string Reference, string Title, bool IsOpen, DateTime CreatedAt, DateTime? ClosedAt, string Source, Guid? ClientRequestId);
public sealed partial class InsightRepository
{
/// <summary>Files a support request (SQLInsight Web: needs only INSERT on this table, see 0005_support_cases.sql).</summary>
public async Task<long> AddSupportRequestAsync(NewSupportRequest request, DateTimeOffset now, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(request);
await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false);
return await connection.ExecuteScalarAsync<long>("""
INSERT insight.SupportCases (ClientRequestId, Title, Description, ContactName, ContactEmail, ContactPhone, RequestedBy,
FindingId, InstanceName, RuleId, Subject, Source, Status, NextAttemptAt, CreatedAt, UpdatedAt)
OUTPUT INSERTED.CaseId
VALUES (@ClientRequestId, @Title, @Description, @ContactName, @ContactEmail, @ContactPhone, @RequestedBy,
@FindingId, @InstanceName, @RuleId, @Subject, 'Customer', 'Pending', @now, @now, @now);
""", new
{
request.ClientRequestId, request.Title, request.Description, request.ContactName, request.ContactEmail, request.ContactPhone,
request.RequestedBy, request.FindingId, request.InstanceName, request.RuleId, request.Subject, now = Utc(now),
}).ConfigureAwait(false);
}
/// <summary>The customer's support cases, newest first.</summary>
public async Task<IReadOnlyList<SupportCaseRow>> GetSupportCasesAsync(int max = 200, CancellationToken cancellationToken = default)
{
await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false);
var rows = await connection.QueryAsync<SupportCaseRow>(
"SELECT TOP (@max) * FROM insight.SupportCases ORDER BY CreatedAt DESC, CaseId DESC;", new { max }).ConfigureAwait(false);
return rows.ToList();
}
public async Task<IReadOnlyList<SupportCaseRow>> GetDueSupportRequestsAsync(DateTimeOffset now, int max = 10, CancellationToken cancellationToken = default)
{
await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false);
var rows = await connection.QueryAsync<SupportCaseRow>("""
SELECT TOP (@max) * FROM insight.SupportCases
WHERE Status = 'Pending' AND ClientRequestId IS NOT NULL AND NextAttemptAt <= @now
ORDER BY NextAttemptAt;
""", new { max, now = Utc(now) }).ConfigureAwait(false);
return rows.ToList();
}
/// <summary>The hub created (or already had) the case: store its number.</summary>
public async Task MarkSupportRequestSentAsync(long caseId, string reference, bool isOpen, DateTime? closedAt, DateTimeOffset now, CancellationToken cancellationToken = default)
{
await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false);
await connection.ExecuteAsync("""
UPDATE insight.SupportCases
SET Reference = @reference, Status = CASE WHEN @isOpen = 1 THEN 'Open' ELSE 'Closed' END, ClosedAt = @closedAt,
SentAt = @now, Attempts = Attempts + 1, LastError = NULL, UpdatedAt = @now
WHERE CaseId = @caseId;
""", new { caseId, reference, isOpen, closedAt, now = Utc(now) }).ConfigureAwait(false);
}
/// <summary>A failed attempt. With <paramref name="nextAttemptAt"/> null the request is given up (Status Failed).</summary>
public async Task MarkSupportRequestFailedAsync(long caseId, string error, DateTimeOffset now, DateTimeOffset? nextAttemptAt, CancellationToken cancellationToken = default)
{
await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false);
await connection.ExecuteAsync("""
UPDATE insight.SupportCases
SET Attempts = Attempts + 1, LastError = LEFT(@error, 1000), UpdatedAt = @now,
NextAttemptAt = COALESCE(@next, NextAttemptAt), Status = CASE WHEN @next IS NULL THEN 'Failed' ELSE Status END
WHERE CaseId = @caseId;
""", new { caseId, error, now = Utc(now), next = nextAttemptAt is { } n ? Utc(n) : (DateTime?)null }).ConfigureAwait(false);
}
/// <summary>
/// Brings the local list in step with the hub: status and number of the customer's own requests, and the cases
/// SEOSoft created for the customer.
/// </summary>
public async Task SyncSupportCasesAsync(IEnumerable<HubSupportCase> cases, DateTimeOffset now, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(cases);
await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false);
foreach (var c in cases)
{
await connection.ExecuteAsync("""
UPDATE insight.SupportCases
SET Reference = @Reference, Status = CASE WHEN @IsOpen = 1 THEN 'Open' ELSE 'Closed' END, ClosedAt = @ClosedAt,
Title = CASE WHEN ClientRequestId IS NULL THEN @Title ELSE Title END, LastError = NULL, UpdatedAt = @now
WHERE (@ClientRequestId IS NOT NULL AND ClientRequestId = @ClientRequestId) OR Reference = @Reference;
IF @@ROWCOUNT = 0
INSERT insight.SupportCases (ClientRequestId, Reference, Title, Source, Status, CreatedAt, ClosedAt, SentAt, UpdatedAt)
VALUES (@ClientRequestId, @Reference, @Title, @Source, CASE WHEN @IsOpen = 1 THEN 'Open' ELSE 'Closed' END, @CreatedAt, @ClosedAt, @now, @now);
""", new { c.Reference, c.Title, c.IsOpen, c.CreatedAt, c.ClosedAt, c.Source, c.ClientRequestId, now = Utc(now) }).ConfigureAwait(false);
}
}
}

View file

@ -0,0 +1,36 @@
/* Support cases with SEOSoft (Professional/Enterprise). SQLInsight Web adds a request (Status 'Pending'); the agent
sends it to SQLInsight Hub, stores the case number it gets back, and keeps every case's status in step with the hub,
including cases SEOSoft created (ClientRequestId NULL). */
CREATE TABLE insight.SupportCases
(
CaseId bigint IDENTITY(1, 1) NOT NULL CONSTRAINT PK_SupportCases PRIMARY KEY,
ClientRequestId uniqueidentifier NULL,
Reference nvarchar(32) NULL,
Title nvarchar(200) NOT NULL,
Description nvarchar(max) NULL,
ContactName nvarchar(200) NULL,
ContactEmail nvarchar(320) NULL,
ContactPhone nvarchar(50) NULL,
FindingId bigint NULL,
InstanceName nvarchar(128) NULL,
RuleId nvarchar(50) NULL,
Subject nvarchar(400) NULL,
RequestedBy nvarchar(256) NULL,
Source varchar(16) NOT NULL CONSTRAINT DF_SupportCases_Source DEFAULT ('Customer'),
Status varchar(16) NOT NULL CONSTRAINT DF_SupportCases_Status DEFAULT ('Pending')
CONSTRAINT CK_SupportCases_Status CHECK (Status IN ('Pending', 'Open', 'Closed', 'Failed')),
LastError nvarchar(1000) NULL,
Attempts int NOT NULL CONSTRAINT DF_SupportCases_Attempts DEFAULT (0),
NextAttemptAt datetime2(0) NOT NULL CONSTRAINT DF_SupportCases_NextAttemptAt DEFAULT (SYSUTCDATETIME()),
CreatedAt datetime2(0) NOT NULL CONSTRAINT DF_SupportCases_CreatedAt DEFAULT (SYSUTCDATETIME()),
SentAt datetime2(0) NULL,
ClosedAt datetime2(0) NULL,
UpdatedAt datetime2(0) NOT NULL CONSTRAINT DF_SupportCases_UpdatedAt DEFAULT (SYSUTCDATETIME())
);
CREATE UNIQUE INDEX UQ_SupportCases_ClientRequestId ON insight.SupportCases (ClientRequestId) WHERE ClientRequestId IS NOT NULL;
CREATE UNIQUE INDEX UQ_SupportCases_Reference ON insight.SupportCases (Reference) WHERE Reference IS NOT NULL;
CREATE INDEX IX_SupportCases_Pending ON insight.SupportCases (NextAttemptAt) WHERE Status = 'Pending';
/* The web UI only has db_datareader, yet it must be able to file a request. INSERT on this one table is granted to
public: only the repository's own users (the agent, the web UI and its administrators) exist in this database. */
GRANT INSERT ON insight.SupportCases TO public;

View file

@ -14,5 +14,6 @@
<body> <body>
<Routes /> <Routes />
<script src="@Assets["app.js"]"></script> <script src="@Assets["app.js"]"></script>
<script src="@Assets["support-menu.js"]"></script>
</body> </body>
</html> </html>

View file

@ -1,5 +1,6 @@
@inherits LayoutComponentBase @inherits LayoutComponentBase
@inject Microsoft.Extensions.Options.IOptions<WebOptions> Options @inject Microsoft.Extensions.Options.IOptions<WebOptions> Options
@inject InsightRepository Repository
<div class="shell"> <div class="shell">
<aside class="sidebar"> <aside class="sidebar">
@ -14,6 +15,7 @@
<NavLink href="/fund"><Icon Name="list" /> Alle fund</NavLink> <NavLink href="/fund"><Icon Name="list" /> Alle fund</NavLink>
<NavLink href="/rapporter"><Icon Name="book" /> Rapporter</NavLink> <NavLink href="/rapporter"><Icon Name="book" /> Rapporter</NavLink>
<NavLink href="/system"><Icon Name="gauge" /> System</NavLink> <NavLink href="/system"><Icon Name="gauge" /> System</NavLink>
<NavLink href="/support"><Icon Name="support" /> Support</NavLink>
<span class="nav-label">Community</span> <span class="nav-label">Community</span>
<a href="@Options.Value.ForumUrl" rel="noopener"><Icon Name="users" /> Forum</a> <a href="@Options.Value.ForumUrl" rel="noopener"><Icon Name="users" /> Forum</a>
@ -41,3 +43,15 @@
<footer class="footer">SQLInsight Pro · MIT-licens · <a href="https://git.seosoft.dk/seo/sqlinsight-pro">kildekode</a></footer> <footer class="footer">SQLInsight Pro · MIT-licens · <a href="https://git.seosoft.dk/seo/sqlinsight-pro">kildekode</a></footer>
</div> </div>
</div> </div>
@if (_supportAvailable)
{
@* Tells support-menu.js that "Opret supportsag" may be offered on findings. *@
<div hidden data-support-available></div>
}
@code {
private bool _supportAvailable;
protected override async Task OnInitializedAsync() =>
_supportAvailable = (await Repository.GetHubStatusAsync())?.AllowsSupportCases == true;
}

View file

@ -12,7 +12,7 @@
<PageTitle>@_finding.Title · SQLInsight Pro</PageTitle> <PageTitle>@_finding.Title · SQLInsight Pro</PageTitle>
<p class="crumbs"><a href="/">Instanser</a> / <a href="/instanser/@Uri.EscapeDataString(_finding.InstanceName)">@_finding.InstanceName</a> / @_finding.RuleId</p> <p class="crumbs"><a href="/">Instanser</a> / <a href="/instanser/@Uri.EscapeDataString(_finding.InstanceName)">@_finding.InstanceName</a> / @_finding.RuleId</p>
<h1><SeverityBadge Severity="_finding.Severity" /> @_finding.Title</h1> <h1 data-finding-id="@_finding.FindingId" tabindex="0"><SeverityBadge Severity="_finding.Severity" /> @_finding.Title</h1>
<p class="lead"> <p class="lead">
@if (_finding.ResolvedAt is { } resolved) @if (_finding.ResolvedAt is { } resolved)
{ {
@ -21,6 +21,11 @@
Set første gang @Display.Time(_finding.FirstSeenAt) · senest @Display.Time(_finding.LastSeenAt) Set første gang @Display.Time(_finding.FirstSeenAt) · senest @Display.Time(_finding.LastSeenAt)
@if (!string.IsNullOrEmpty(_finding.Subject)) { <span> · @_finding.Subject</span> } @if (!string.IsNullOrEmpty(_finding.Subject)) { <span> · @_finding.Subject</span> }
</p> </p>
@if (_support)
{
@* Also without JavaScript; with it, right-click on the finding gives the same choice. *@
<p><a class="button" href="/support?fund=@_finding.FindingId">Opret supportsag</a></p>
}
<section class="panel"> <section class="panel">
<h2>Hvorfor er det et problem?</h2> <h2>Hvorfor er det et problem?</h2>
@ -88,10 +93,12 @@
private IReadOnlyList<SQLInsight.Contracts.UpgradeInsight> _elsewhere = []; private IReadOnlyList<SQLInsight.Contracts.UpgradeInsight> _elsewhere = [];
private string? _version; private string? _version;
private int _minCustomers; private int _minCustomers;
private bool _support;
protected override async Task OnInitializedAsync() protected override async Task OnInitializedAsync()
{ {
_finding = await Repository.GetFindingAsync(Id); _finding = await Repository.GetFindingAsync(Id);
_support = _finding is not null && (await Repository.GetHubStatusAsync())?.AllowsSupportCases == true;
if (_finding is null || await Repository.GetHubInsightsJsonAsync() is not { } json) if (_finding is null || await Repository.GetHubInsightsJsonAsync() is not { } json)
{ {
return; return;

View file

@ -0,0 +1,101 @@
@page "/support"
@using Microsoft.AspNetCore.Components.Forms
@inject InsightRepository Repository
@inject Display Display
<PageTitle>Support · SQLInsight Pro</PageTitle>
<h1>Support</h1>
<p class="lead">Supportsager hos SEOSoft. Sagen sendes af agenten til SQLInsight Hub, som giver den et sagsnummer. Status opdateres her, når SEOSoft lukker sagen.</p>
@if (Sendt == 1)
{
<p class="notice ok" role="status">Sagen er sendt. Den får et sagsnummer, så snart agenten har været i kontakt med SQLInsight Hub (normalt inden for et minut).</p>
}
@if (!string.IsNullOrEmpty(Fejl))
{
<p class="notice bad" role="alert">@Fejl</p>
}
@if (_hub?.AllowsSupportCases == true)
{
<section class="panel">
<h2>Opret supportsag</h2>
<form method="post" action="/support" class="support-form">
<AntiforgeryToken />
@if (_finding is not null)
{
<input type="hidden" name="findingId" value="@_finding.FindingId" />
<p class="muted">Om fundet <a href="/fund/@_finding.FindingId">@_finding.Title</a> på @_finding.InstanceName.</p>
}
<label>Titel <input name="title" required maxlength="200" value="@_title" /></label>
<label>Beskrivelse <textarea name="description" rows="10" maxlength="8000">@_description</textarea></label>
<div class="support-contact">
<label>Kontaktperson <input name="contactName" required maxlength="200" value="@_contactName" autocomplete="name" /></label>
<label>E-mail <input name="contactEmail" type="email" required maxlength="320" autocomplete="email" /></label>
<label>Telefon (valgfri) <input name="contactPhone" type="tel" maxlength="50" autocomplete="tel" /></label>
</div>
<p class="muted">Følsomme målinger (forespørgselstekst, logins, servernavne) er ikke sat ind i beskrivelsen. Tilføj kun det, I vil dele med SEOSoft.</p>
<button type="submit" class="button">Send til SEOSoft</button>
</form>
</section>
}
else
{
<section class="panel">
<h2>Supportsager kræver et abonnement</h2>
<p>Med Professional eller Enterprise kan I oprette supportsager direkte herfra og følge dem. Det kræver også, at deling med SQLInsight Hub er slået til (se <a href="/system">System</a>).</p>
<p>@(_hub?.LicenseMessage ?? "Agenten har ikke rapporteret licensstatus endnu.")</p>
</section>
}
<section class="card">
<div class="card-head"><h2>Jeres sager</h2></div>
@if (_cases.Count == 0)
{
<p class="empty">Ingen sager endnu.</p>
}
else
{
<table>
<thead><tr><th>Sag</th><th>Titel</th><th>Status</th><th>Oprettet</th><th>Lukket</th></tr></thead>
<tbody>
@foreach (var c in _cases)
{
<tr>
<td class="mono nowrap">@(c.Reference ?? "–")</td>
<td>@c.Title @if (c.FindingId is { } fid) { <a class="muted" href="/fund/@fid">(fund)</a> }</td>
<td>@Support.StatusText(c)@if (c.Status == SupportCaseStatus.Failed && c.LastError is { } error) { <div class="muted">@error</div> }</td>
<td class="nowrap">@Display.Time(c.CreatedAt)</td>
<td class="nowrap">@Display.Time(c.ClosedAt)</td>
</tr>
}
</tbody>
</table>
}
</section>
@code {
[CascadingParameter] private HttpContext? Http { get; set; }
[SupplyParameterFromQuery(Name = "fund")] public long? Fund { get; set; }
[SupplyParameterFromQuery(Name = "sendt")] public int? Sendt { get; set; }
[SupplyParameterFromQuery(Name = "fejl")] public string? Fejl { get; set; }
private HubStatusRow? _hub;
private StoredFinding? _finding;
private IReadOnlyList<SupportCaseRow> _cases = [];
private string _title = string.Empty;
private string _description = string.Empty;
private string _contactName = string.Empty;
protected override async Task OnInitializedAsync()
{
_hub = await Repository.GetHubStatusAsync();
_cases = await Repository.GetSupportCasesAsync();
_contactName = Support.ContactNameFrom(Http?.User.Identity?.Name);
if (Fund is { } id && (_finding = await Repository.GetFindingAsync(id)) is { } finding)
{
(_title, _description) = Support.Prefill(finding);
}
}
}

View file

@ -100,6 +100,7 @@ app.MapGet("/deadlocks/{id:long}.xdl", async (long id, InsightRepository reposit
: Results.NotFound()); : Results.NotFound());
IntegrationEndpoints.Map(app); IntegrationEndpoints.Map(app);
Support.Map(app);
// Management and compliance report as PDF, for the last N days. // Management and compliance report as PDF, for the last N days.
app.MapGet("/rapporter/driftsrapport.pdf", async (int? dage, InsightRepository repository, Microsoft.Extensions.Options.IOptions<WebOptions> options, CancellationToken ct) => app.MapGet("/rapporter/driftsrapport.pdf", async (int? dage, InsightRepository repository, Microsoft.Extensions.Options.IOptions<WebOptions> options, CancellationToken ct) =>

View file

@ -0,0 +1,88 @@
using System.Text;
using Microsoft.AspNetCore.Mvc;
using SQLInsight.Contracts;
using SQLInsight.Repository;
namespace SQLInsight.Web;
/// <summary>
/// Support cases with SEOSoft (Professional/Enterprise). The form posts here; the request is stored in the repository
/// and the agent sends it to SQLInsight Hub, which gives it a case number. The web UI never talks to the hub itself.
/// </summary>
public static class Support
{
public static void Map(IEndpointRouteBuilder app) => app.MapPost("/support", CreateAsync);
/// <summary>Title and description for a case about <paramref name="finding"/>. Sensitive measurements are left out.</summary>
public static (string Title, string Description) Prefill(StoredFinding finding)
{
ArgumentNullException.ThrowIfNull(finding);
var text = new StringBuilder();
text.Append("Instans: ").Append(finding.InstanceName).Append('\n');
text.Append("Regel: ").Append(finding.RuleId).Append('\n');
text.Append("Alvor: ").Append(Display.SeverityName(finding.Severity)).Append('\n');
if (!string.IsNullOrEmpty(finding.Subject))
{
text.Append("Emne: ").Append(finding.Subject).Append('\n');
}
text.Append('\n').Append(finding.Explanation).Append('\n');
var evidence = finding.Evidence.Where(e => !e.Sensitive).Take(10).ToList();
if (evidence.Count > 0)
{
text.Append("\nMålinger:\n");
foreach (var e in evidence)
{
text.Append("- ").Append(e.Name).Append(": ").Append(e.Value.Length > 200 ? e.Value[..200] + "…" : e.Value).Append('\n');
}
}
text.Append("\nHvad har I prøvet, og hvad har ændret sig?\n");
var title = finding.Title.Length > SupportCaseRequest.MaxTitle ? finding.Title[..SupportCaseRequest.MaxTitle] : finding.Title;
var description = text.ToString();
return (title, description.Length > SupportCaseRequest.MaxDescription ? description[..SupportCaseRequest.MaxDescription] : description);
}
/// <summary>The user part of DOMAIN\user, as a starting point for the contact name.</summary>
public static string ContactNameFrom(string? windowsUser) =>
windowsUser is { Length: > 0 } user ? user[(user.LastIndexOf('\\') + 1)..] : string.Empty;
public static string StatusText(SupportCaseRow row)
{
ArgumentNullException.ThrowIfNull(row);
return row.Status switch
{
SupportCaseStatus.Pending => row.Attempts > 0 ? "Venter på hubben (prøver igen)" : "Sendes",
SupportCaseStatus.Open => "Åben",
SupportCaseStatus.Closed => "Lukket",
_ => "Afvist",
};
}
private static async Task<IResult> CreateAsync(
HttpContext http, InsightRepository repository,
[FromForm] string? title, [FromForm] string? description, [FromForm] string? contactName, [FromForm] string? contactEmail,
[FromForm] string? contactPhone, [FromForm] long? findingId)
{
var back = findingId is { } id ? $"&fund={id}" : string.Empty;
if ((await repository.GetHubStatusAsync(http.RequestAborted))?.AllowsSupportCases != true)
{
return Results.Redirect("/support?fejl=" + Uri.EscapeDataString("Supportsager kræver Professional eller Enterprise og at deling med SQLInsight Hub er slået til."));
}
var finding = findingId is { } fid ? await repository.GetFindingAsync(fid, http.RequestAborted) : null;
var request = new SupportCaseRequest(Guid.NewGuid(), title?.Trim() ?? string.Empty, Blank(description), contactName?.Trim() ?? string.Empty,
contactEmail?.Trim() ?? string.Empty, Blank(contactPhone), http.User.Identity?.Name, finding?.InstanceName, finding?.RuleId, finding?.Subject);
if (request.Validate() is { } invalid)
{
return Results.Redirect("/support?fejl=" + Uri.EscapeDataString(invalid) + back);
}
await repository.AddSupportRequestAsync(new NewSupportRequest(request.ClientRequestId, request.Title, request.Description, request.ContactName,
request.ContactEmail, request.ContactPhone, request.RequestedBy, finding?.FindingId, finding?.InstanceName, finding?.RuleId, finding?.Subject),
DateTimeOffset.UtcNow, http.RequestAborted);
return Results.Redirect("/support?sendt=1");
}
private static string? Blank(string? value) => string.IsNullOrWhiteSpace(value) ? null : value.Trim();
}

View file

@ -161,3 +161,17 @@ details .script pre { max-height: 32rem; }
a.button { display: inline-block; background: var(--accent); color: #fff; padding: .5rem 1rem; border-radius: 8px; font-weight: 600; } a.button { display: inline-block; background: var(--accent); color: #fff; padding: .5rem 1rem; border-radius: 8px; font-weight: 600; }
a.button:hover { filter: brightness(1.08); } a.button:hover { filter: brightness(1.08); }
.panel.insight { border-left: 4px solid var(--accent); } .panel.insight { border-left: 4px solid var(--accent); }
/* Support cases: the form, notices and the "Opret supportsag" context menu on findings (support-menu.js). */
.support-form { display: grid; gap: .9rem; max-width: 52rem; }
.support-form label { display: grid; gap: .3rem; font-size: .85rem; color: var(--text-2); }
.support-form input, .support-form textarea { font: inherit; color: var(--text); background: var(--surface); border: 1px solid var(--border); border-radius: 8px; padding: .5rem .65rem; }
.support-form textarea { resize: vertical; }
.support-contact { display: grid; grid-template-columns: repeat(auto-fit, minmax(14rem, 1fr)); gap: .9rem; }
.support-form button.button { justify-self: start; border: 0; cursor: pointer; font: inherit; background: var(--accent); color: #fff; padding: .5rem 1rem; border-radius: 8px; font-weight: 600; }
.notice { border-radius: var(--radius); padding: .75rem 1rem; margin: 0 0 1.25rem; border: 1px solid var(--border); background: var(--surface); }
.notice.ok { border-left: 4px solid var(--accent); }
.notice.bad { border-left: 4px solid #d92d20; }
.context-menu { position: absolute; z-index: 1000; min-width: 12rem; padding: .3rem; background: var(--surface); border: 1px solid var(--border); border-radius: 8px; box-shadow: var(--shadow); }
.context-menu a { display: block; padding: .45rem .7rem; border-radius: 6px; color: var(--text); text-decoration: none; }
.context-menu a:hover, .context-menu a:focus { background: var(--surface-2); outline: none; }

View file

@ -0,0 +1,78 @@
// "Opret supportsag" in a context menu on findings: right-click a link to /fund/<id> (any finding list) or the
// heading of a finding's own page. Keyboard: Shift+F10 or the context menu key on a focused finding; Escape closes.
// Only when the layout says support is available (data-support-available). Without JavaScript the finding page has a
// visible "Opret supportsag" button, so nothing depends on this file.
(() => {
const available = () => document.querySelector('[data-support-available]') !== null;
let menu = null;
let opener = null;
const findingId = (element) => {
const marked = element.closest('[data-finding-id]');
if (marked) return marked.dataset.findingId;
const link = element.closest('a[href]');
const match = link && /^\/fund\/(\d+)$/.exec(new URL(link.href, document.baseURI).pathname);
return match ? match[1] : null;
};
const close = (restoreFocus) => {
if (!menu) return;
menu.remove();
menu = null;
if (restoreFocus && opener) opener.focus();
opener = null;
};
const open = (id, x, y, target) => {
close(false);
opener = target.closest('a[href], [tabindex]') || target;
menu = document.createElement('div');
menu.className = 'context-menu';
menu.setAttribute('role', 'menu');
menu.setAttribute('aria-label', 'Fund');
const item = document.createElement('a');
item.setAttribute('role', 'menuitem');
item.href = `/support?fund=${encodeURIComponent(id)}`;
item.textContent = 'Opret supportsag';
menu.appendChild(item);
document.body.appendChild(menu);
// Keep it inside the window. Setting style through the CSSOM is allowed by the page's Content-Security-Policy.
const width = menu.offsetWidth;
const height = menu.offsetHeight;
menu.style.left = `${Math.max(4, Math.min(x, window.innerWidth - width - 4)) + window.scrollX}px`;
menu.style.top = `${Math.max(4, Math.min(y, window.innerHeight - height - 4)) + window.scrollY}px`;
item.focus();
};
document.addEventListener('contextmenu', (event) => {
if (!available()) return;
const id = findingId(event.target);
if (!id) return;
event.preventDefault();
// From the keyboard there is no pointer position: place the menu under the focused element.
let { clientX: x, clientY: y } = event;
if (x === 0 && y === 0) {
const box = event.target.getBoundingClientRect();
x = box.left;
y = box.bottom + 4;
}
open(id, x, y, event.target);
});
document.addEventListener('keydown', (event) => {
if (!menu) return;
if (event.key === 'Escape') {
event.preventDefault();
close(true);
} else if (event.key === 'Tab') {
close(false);
}
});
document.addEventListener('mousedown', (event) => {
if (menu && !menu.contains(event.target)) close(false);
});
window.addEventListener('scroll', () => close(false), { passive: true });
window.addEventListener('resize', () => close(false));
})();

View file

@ -0,0 +1,81 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using SQLInsight.Agent.Hub;
using SQLInsight.Hub.Data;
using SQLInsight.Repository;
namespace SQLInsight.IntegrationTests;
/// <summary>A support case from SQLInsight Web, through the agent to the hub and its status back again.</summary>
[Collection(SqlServerTestGroup.Name)]
[Trait("Category", "Integration")]
public class HubSupportEndToEndTests(SqlServerFixture sql, HubFixture hub) : IClassFixture<HubFixture>, IAsyncLifetime
{
private readonly string _database = "SQLInsightSupE2E_" + Guid.NewGuid().ToString("N")[..8];
private InsightRepository _repository = null!;
public async Task InitializeAsync()
{
await sql.ExecuteAsSaAsync($"CREATE DATABASE [{_database}];");
_repository = new InsightRepository(sql.DatabaseConnectionString(_database));
await _repository.MigrateAsync();
}
public Task DisposeAsync() => sql.ExecuteAsSaAsync($"ALTER DATABASE [{_database}] SET SINGLE_USER WITH ROLLBACK IMMEDIATE; DROP DATABASE [{_database}];");
private sealed class TestServerClients(Func<HttpClient> create) : IHttpClientFactory
{
public HttpClient CreateClient(string name) => create();
}
private HubSyncService Sync(string license)
{
var options = Options.Create(new 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);
}
[Fact]
public async Task Case_filed_in_web_gets_a_number_and_its_closing_comes_back()
{
var customer = $"Support E2E {Guid.NewGuid():N}"[..28];
var license = hub.License(customer);
await _repository.AddSupportRequestAsync(new NewSupportRequest(Guid.NewGuid(), "Langsom backup", "Siden mandag", "Anna", "anna@kunde.dk", null, "KUNDE\\anna"), DateTimeOffset.UtcNow);
await Sync(license).RunOnceAsync(CancellationToken.None);
var filed = Assert.Single(await _repository.GetSupportCasesAsync());
Assert.Equal(("SUP-000001", SupportCaseStatus.Open), (filed.Reference, filed.Status));
await using (var scope = hub.Factory.Services.CreateAsyncScope())
{
var db = scope.ServiceProvider.GetRequiredService<HubDbContext>();
var stored = await db.SupportCases.SingleAsync(c => c.Customer!.Name == customer);
stored.IsOpen = false;
stored.ClosedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
}
await Sync(license).RunOnceAsync(CancellationToken.None);
var closed = Assert.Single(await _repository.GetSupportCasesAsync());
Assert.Equal(SupportCaseStatus.Closed, closed.Status);
Assert.NotNull(closed.ClosedAt);
}
[Fact]
public async Task Request_the_license_does_not_cover_is_given_up()
{
var customer = $"Community {Guid.NewGuid():N}"[..24];
await _repository.AddSupportRequestAsync(new NewSupportRequest(Guid.NewGuid(), "Hjælp", null, "Anna", "anna@kunde.dk", null, null), DateTimeOffset.UtcNow);
var sync = Sync(hub.License(customer));
await sync.SyncSupportCasesAsync(hub.Factory.CreateClient(), new HubOptions { Url = new Uri("http://localhost/"), License = hub.License(customer, SQLInsight.Licensing.LicensePlan.Community) }, CancellationToken.None);
var failed = Assert.Single(await _repository.GetSupportCasesAsync());
Assert.Equal(SupportCaseStatus.Failed, failed.Status);
Assert.Contains("403", failed.LastError, StringComparison.Ordinal);
}
}

View file

@ -0,0 +1,175 @@
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);
}
}