From 50bc87877620c6c94ba28da1c2e0c4108909bc33 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=B8ren=20Eggert=20Lundsteen=20Olsen?= Date: Thu, 1 Oct 2026 11:34:06 +0200 Subject: [PATCH] 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= 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) --- docs/databehandling.md | 2 + docs/support.md | 25 +++ src/SQLInsight.Agent/Hub/HubSyncService.cs | 86 +++++++++ .../Services/DocsService.cs | 1 + .../InsightRepository.Hub.cs | 7 + .../InsightRepository.Support.cs | 144 ++++++++++++++ .../Migrations/0005_support_cases.sql | 36 ++++ src/SQLInsight.Web/Components/App.razor | 1 + .../Components/Layout/MainLayout.razor | 14 ++ .../Components/Pages/FindingDetail.razor | 9 +- .../Components/Pages/SupportPage.razor | 101 ++++++++++ src/SQLInsight.Web/Program.cs | 1 + src/SQLInsight.Web/Support.cs | 88 +++++++++ src/SQLInsight.Web/wwwroot/app.css | 14 ++ src/SQLInsight.Web/wwwroot/support-menu.js | 78 ++++++++ .../HubSupportEndToEndTests.cs | 81 ++++++++ .../WebSupportTests.cs | 175 ++++++++++++++++++ 17 files changed, 862 insertions(+), 1 deletion(-) create mode 100644 docs/support.md create mode 100644 src/SQLInsight.Repository/InsightRepository.Support.cs create mode 100644 src/SQLInsight.Repository/Migrations/0005_support_cases.sql create mode 100644 src/SQLInsight.Web/Components/Pages/SupportPage.razor create mode 100644 src/SQLInsight.Web/Support.cs create mode 100644 src/SQLInsight.Web/wwwroot/support-menu.js create mode 100644 tests/SQLInsight.IntegrationTests/HubSupportEndToEndTests.cs create mode 100644 tests/SQLInsight.IntegrationTests/WebSupportTests.cs diff --git a/docs/databehandling.md b/docs/databehandling.md index 83a052a..62752a7 100644 --- a/docs/databehandling.md +++ b/docs/databehandling.md @@ -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. +**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"). 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. diff --git a/docs/support.md b/docs/support.md new file mode 100644 index 0000000..0be2a22 --- /dev/null +++ b/docs/support.md @@ -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). diff --git a/src/SQLInsight.Agent/Hub/HubSyncService.cs b/src/SQLInsight.Agent/Hub/HubSyncService.cs index 73f333b..5e01f07 100644 --- a/src/SQLInsight.Agent/Hub/HubSyncService.cs +++ b/src/SQLInsight.Agent/Hub/HubSyncService.cs @@ -108,9 +108,89 @@ public sealed partial class HubSyncService( await FetchInsightsAsync(http, settings, cancellationToken).ConfigureAwait(false); } + if (status.License?.IncludesPrioritySupport == true) + { + await SyncSupportCasesAsync(http, settings, cancellationToken).ConfigureAwait(false); + } + return sent; } + /// How often the status of the customer's support cases is fetched when nothing was sent. + public static readonly TimeSpan CasesInterval = TimeSpan.FromMinutes(5); + + private DateTimeOffset? _casesFetchedAt; + + /// + /// 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. + /// + 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(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>(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); + } + } + /// How often aggregated experience from other customers is fetched. 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}")] 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")] private static partial void LogRoundFailed(ILogger logger, Exception exception); } diff --git a/src/SQLInsight.Community/Services/DocsService.cs b/src/SQLInsight.Community/Services/DocsService.cs index 416b8be..704cfe8 100644 --- a/src/SQLInsight.Community/Services/DocsService.cs +++ b/src/SQLInsight.Community/Services/DocsService.cs @@ -23,6 +23,7 @@ public sealed class DocsService ("integration", "Prometheus, Grafana, JSON-API og SCOM via Windows' hændelseslog."), ("rapporter", "Driftsrapport til ledelse og revision som PDF, også som månedlig mail."), ("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."), ("legacy-versions", "SQL Server 2000, 2005 og 2008: hvad der kan måles, og hvordan der forbindes."), ]; diff --git a/src/SQLInsight.Repository/InsightRepository.Hub.cs b/src/SQLInsight.Repository/InsightRepository.Hub.cs index 23dcbeb..093325f 100644 --- a/src/SQLInsight.Repository/InsightRepository.Hub.cs +++ b/src/SQLInsight.Repository/InsightRepository.Hub.cs @@ -19,6 +19,13 @@ public sealed record HubStatusRow public DateTime? LastErrorAt { get; init; } public DateTime UpdatedAt { get; init; } public int Pending { get; init; } + + /// + /// 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. + /// + public bool AllowsSupportCases => + Enabled && LicenseState is "Valid" or "ExpiredInGrace" && LicensePlan is "Professional" or "Enterprise"; } public sealed partial class InsightRepository diff --git a/src/SQLInsight.Repository/InsightRepository.Support.cs b/src/SQLInsight.Repository/InsightRepository.Support.cs new file mode 100644 index 0000000..8fffa14 --- /dev/null +++ b/src/SQLInsight.Repository/InsightRepository.Support.cs @@ -0,0 +1,144 @@ +using Dapper; + +namespace SQLInsight.Repository; + +/// Values of insight.SupportCases.Status. +public static class SupportCaseStatus +{ + /// Filed in SQLInsight Web; the agent has not had a case number back from the hub yet. + public const string Pending = "Pending"; + + public const string Open = "Open"; + public const string Closed = "Closed"; + + /// The hub refused the request (e.g. no support in the license); see LastError. + 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; } +} + +/// A request filed in SQLInsight Web. +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); + +/// A case as the hub reports it. +public sealed record HubSupportCase(string Reference, string Title, bool IsOpen, DateTime CreatedAt, DateTime? ClosedAt, string Source, Guid? ClientRequestId); + +public sealed partial class InsightRepository +{ + /// Files a support request (SQLInsight Web: needs only INSERT on this table, see 0005_support_cases.sql). + public async Task AddSupportRequestAsync(NewSupportRequest request, DateTimeOffset now, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(request); + await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false); + return await connection.ExecuteScalarAsync(""" + 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); + } + + /// The customer's support cases, newest first. + public async Task> GetSupportCasesAsync(int max = 200, CancellationToken cancellationToken = default) + { + await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false); + var rows = await connection.QueryAsync( + "SELECT TOP (@max) * FROM insight.SupportCases ORDER BY CreatedAt DESC, CaseId DESC;", new { max }).ConfigureAwait(false); + return rows.ToList(); + } + + public async Task> GetDueSupportRequestsAsync(DateTimeOffset now, int max = 10, CancellationToken cancellationToken = default) + { + await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false); + var rows = await connection.QueryAsync(""" + 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(); + } + + /// The hub created (or already had) the case: store its number. + 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); + } + + /// A failed attempt. With null the request is given up (Status Failed). + 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); + } + + /// + /// 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. + /// + public async Task SyncSupportCasesAsync(IEnumerable 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); + } + } +} diff --git a/src/SQLInsight.Repository/Migrations/0005_support_cases.sql b/src/SQLInsight.Repository/Migrations/0005_support_cases.sql new file mode 100644 index 0000000..c77d019 --- /dev/null +++ b/src/SQLInsight.Repository/Migrations/0005_support_cases.sql @@ -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; diff --git a/src/SQLInsight.Web/Components/App.razor b/src/SQLInsight.Web/Components/App.razor index d3ef523..aa931f2 100644 --- a/src/SQLInsight.Web/Components/App.razor +++ b/src/SQLInsight.Web/Components/App.razor @@ -14,5 +14,6 @@ + diff --git a/src/SQLInsight.Web/Components/Layout/MainLayout.razor b/src/SQLInsight.Web/Components/Layout/MainLayout.razor index 8055ad5..faa5f76 100644 --- a/src/SQLInsight.Web/Components/Layout/MainLayout.razor +++ b/src/SQLInsight.Web/Components/Layout/MainLayout.razor @@ -1,5 +1,6 @@ @inherits LayoutComponentBase @inject Microsoft.Extensions.Options.IOptions Options +@inject InsightRepository Repository
+@if (_supportAvailable) +{ + @* Tells support-menu.js that "Opret supportsag" may be offered on findings. *@ + +} + +@code { + private bool _supportAvailable; + + protected override async Task OnInitializedAsync() => + _supportAvailable = (await Repository.GetHubStatusAsync())?.AllowsSupportCases == true; +} diff --git a/src/SQLInsight.Web/Components/Pages/FindingDetail.razor b/src/SQLInsight.Web/Components/Pages/FindingDetail.razor index 9df83c2..6430f1b 100644 --- a/src/SQLInsight.Web/Components/Pages/FindingDetail.razor +++ b/src/SQLInsight.Web/Components/Pages/FindingDetail.razor @@ -12,7 +12,7 @@ @_finding.Title · SQLInsight Pro

Instanser / @_finding.InstanceName / @_finding.RuleId

-

@_finding.Title

+

@_finding.Title

@if (_finding.ResolvedAt is { } resolved) { @@ -21,6 +21,11 @@ Set første gang @Display.Time(_finding.FirstSeenAt) · senest @Display.Time(_finding.LastSeenAt) @if (!string.IsNullOrEmpty(_finding.Subject)) { · @_finding.Subject }

+@if (_support) +{ + @* Also without JavaScript; with it, right-click on the finding gives the same choice. *@ +

Opret supportsag

+}

Hvorfor er det et problem?

@@ -88,10 +93,12 @@ private IReadOnlyList _elsewhere = []; private string? _version; private int _minCustomers; + private bool _support; protected override async Task OnInitializedAsync() { _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) { return; diff --git a/src/SQLInsight.Web/Components/Pages/SupportPage.razor b/src/SQLInsight.Web/Components/Pages/SupportPage.razor new file mode 100644 index 0000000..bb17942 --- /dev/null +++ b/src/SQLInsight.Web/Components/Pages/SupportPage.razor @@ -0,0 +1,101 @@ +@page "/support" +@using Microsoft.AspNetCore.Components.Forms +@inject InsightRepository Repository +@inject Display Display + +Support · SQLInsight Pro + +

Support

+

Supportsager hos SEOSoft. Sagen sendes af agenten til SQLInsight Hub, som giver den et sagsnummer. Status opdateres her, når SEOSoft lukker sagen.

+ +@if (Sendt == 1) +{ +

Sagen er sendt. Den får et sagsnummer, så snart agenten har været i kontakt med SQLInsight Hub (normalt inden for et minut).

+} +@if (!string.IsNullOrEmpty(Fejl)) +{ + +} + +@if (_hub?.AllowsSupportCases == true) +{ +
+

Opret supportsag

+
+ + @if (_finding is not null) + { + +

Om fundet @_finding.Title på @_finding.InstanceName.

+ } + + +
+ + + +
+

Følsomme målinger (forespørgselstekst, logins, servernavne) er ikke sat ind i beskrivelsen. Tilføj kun det, I vil dele med SEOSoft.

+ + +
+} +else +{ +
+

Supportsager kræver et abonnement

+

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 System).

+

@(_hub?.LicenseMessage ?? "Agenten har ikke rapporteret licensstatus endnu.")

+
+} + +
+

Jeres sager

+ @if (_cases.Count == 0) + { +

Ingen sager endnu.

+ } + else + { + + + + @foreach (var c in _cases) + { + + + + + + + + } + +
SagTitelStatusOprettetLukket
@(c.Reference ?? "–")@c.Title @if (c.FindingId is { } fid) { (fund) }@Support.StatusText(c)@if (c.Status == SupportCaseStatus.Failed && c.LastError is { } error) {
@error
}
@Display.Time(c.CreatedAt)@Display.Time(c.ClosedAt)
+ } +
+ +@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 _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); + } + } +} diff --git a/src/SQLInsight.Web/Program.cs b/src/SQLInsight.Web/Program.cs index 2982a7c..bf828ab 100644 --- a/src/SQLInsight.Web/Program.cs +++ b/src/SQLInsight.Web/Program.cs @@ -100,6 +100,7 @@ app.MapGet("/deadlocks/{id:long}.xdl", async (long id, InsightRepository reposit : Results.NotFound()); IntegrationEndpoints.Map(app); +Support.Map(app); // 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 options, CancellationToken ct) => diff --git a/src/SQLInsight.Web/Support.cs b/src/SQLInsight.Web/Support.cs new file mode 100644 index 0000000..2d1b539 --- /dev/null +++ b/src/SQLInsight.Web/Support.cs @@ -0,0 +1,88 @@ +using System.Text; +using Microsoft.AspNetCore.Mvc; +using SQLInsight.Contracts; +using SQLInsight.Repository; + +namespace SQLInsight.Web; + +/// +/// 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. +/// +public static class Support +{ + public static void Map(IEndpointRouteBuilder app) => app.MapPost("/support", CreateAsync); + + /// Title and description for a case about . Sensitive measurements are left out. + 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); + } + + /// The user part of DOMAIN\user, as a starting point for the contact name. + 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 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(); +} diff --git a/src/SQLInsight.Web/wwwroot/app.css b/src/SQLInsight.Web/wwwroot/app.css index 1cb1b95..1b69c85 100644 --- a/src/SQLInsight.Web/wwwroot/app.css +++ b/src/SQLInsight.Web/wwwroot/app.css @@ -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:hover { filter: brightness(1.08); } .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; } diff --git a/src/SQLInsight.Web/wwwroot/support-menu.js b/src/SQLInsight.Web/wwwroot/support-menu.js new file mode 100644 index 0000000..48dce1e --- /dev/null +++ b/src/SQLInsight.Web/wwwroot/support-menu.js @@ -0,0 +1,78 @@ +// "Opret supportsag" in a context menu on findings: right-click a link to /fund/ (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)); +})(); diff --git a/tests/SQLInsight.IntegrationTests/HubSupportEndToEndTests.cs b/tests/SQLInsight.IntegrationTests/HubSupportEndToEndTests.cs new file mode 100644 index 0000000..758bcb7 --- /dev/null +++ b/tests/SQLInsight.IntegrationTests/HubSupportEndToEndTests.cs @@ -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; + +/// A support case from SQLInsight Web, through the agent to the hub and its status back again. +[Collection(SqlServerTestGroup.Name)] +[Trait("Category", "Integration")] +public class HubSupportEndToEndTests(SqlServerFixture sql, HubFixture hub) : IClassFixture, 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 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.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(); + 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); + } +} diff --git a/tests/SQLInsight.IntegrationTests/WebSupportTests.cs b/tests/SQLInsight.IntegrationTests/WebSupportTests.cs new file mode 100644 index 0000000..789701f --- /dev/null +++ b/tests/SQLInsight.IntegrationTests/WebSupportTests.cs @@ -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; + +/// Support cases in SQLInsight Web: form, prefill from a finding, and filing with only read rights. +[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 _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().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 + { + ["__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 + { + ["__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(() => 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); + } +}