sqlinsight-pro/tests/SQLInsight.IntegrationTests/HubPortalTests.cs
Claude 7088efff52
Some checks are pending
CI / Build og unit tests (push) Waiting to run
CI / Integration (mcr.microsoft.com/mssql/server:2017-latest) (push) Blocked by required conditions
CI / Integration (mcr.microsoft.com/mssql/server:2019-latest) (push) Blocked by required conditions
CI / Integration (mcr.microsoft.com/mssql/server:2022-latest) (push) Blocked by required conditions
CI / Integration (mcr.microsoft.com/mssql/server:2025-latest) (push) Blocked by required conditions
Fase 3: Hub portal for vendor staff
Server-rendered portal on the Hub: staff login (PBKDF2 hashes, rate
limited, antiforgery, local-only return URLs), customer overview with
the most widespread problems across customers, customer and finding
pages, support cases linked to findings, and license revocation that
takes effect on the next agent submission. add-staff CLI command.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E3EExwndy8Z22EK2Qpt6BK
2026-09-29 08:58:34 +00:00

238 lines
11 KiB
C#

using System.Net;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Text.RegularExpressions;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using SQLInsight.Hub;
using SQLInsight.Hub.Data;
using SQLInsight.Hub.Portal;
namespace SQLInsight.IntegrationTests;
[Trait("Category", "Integration")]
public partial class HubPortalTests(HubFixture hub) : IClassFixture<HubFixture>
{
private const string Password = "korrekt-hest-batteri";
[GeneratedRegex("name=\"__RequestVerificationToken\"[^>]*value=\"([^\"]+)\"")]
private static partial Regex AntiforgeryField();
private static HttpClient Client(WebApplicationFactory<HubServerOptions> factory) =>
factory.CreateClient(new WebApplicationFactoryClientOptions { AllowAutoRedirect = false });
private async Task<string> StaffAsync()
{
var userName = $"staff{Guid.NewGuid():N}"[..20];
await using var scope = hub.Factory.Services.CreateAsyncScope();
await scope.ServiceProvider.GetRequiredService<StaffAccounts>().CreateAsync(userName, "Support Person", Password);
return userName;
}
private static async Task<string> TokenAsync(HttpClient client, string page)
{
var html = await client.GetStringAsync(page);
return WebUtility.HtmlDecode(AntiforgeryField().Match(html).Groups[1].Value);
}
private static async Task<HttpResponseMessage> PostFormAsync(HttpClient client, string page, string action, params (string Key, string Value)[] fields)
{
var form = fields.Select(f => new KeyValuePair<string, string>(f.Key, f.Value)).Append(new("__RequestVerificationToken", await TokenAsync(client, page)));
using var content = new FormUrlEncodedContent(form);
return await client.PostAsync(action, content);
}
private static Task<HttpResponseMessage> LoginAsync(HttpClient client, string userName, string password, string returnUrl = "") =>
PostFormAsync(client, "/portal/login", "/portal/login", ("userName", userName), ("password", password), ("returnUrl", returnUrl));
private async Task<HttpClient> SignedInAsync()
{
var client = Client(hub.Factory);
var response = await LoginAsync(client, await StaffAsync(), Password);
Assert.Equal("/portal", response.Headers.Location?.OriginalString);
return client;
}
private async Task<long> SeedAsync(string customer, string license)
{
var client = hub.Factory.CreateClient();
using var request = new HttpRequestMessage(HttpMethod.Post, "/api/v1/findings")
{
Content = JsonContent.Create(HubApiTests.Submission("sql01", DateTimeOffset.UtcNow, ("SQLI-DB-007", "database: Sales", "Critical"))),
};
request.Headers.Authorization = new AuthenticationHeaderValue("SQLInsight-License", license);
(await client.SendAsync(request)).EnsureSuccessStatusCode();
await using var scope = hub.Factory.Services.CreateAsyncScope();
var db = scope.ServiceProvider.GetRequiredService<HubDbContext>();
return await db.Findings.Where(f => f.Instance!.Customer!.Name == customer).Select(f => f.Id).SingleAsync();
}
[Fact]
public async Task Pages_require_login()
{
var client = Client(hub.Factory);
var response = await client.GetAsync("/portal/fund");
Assert.Equal(HttpStatusCode.Redirect, response.StatusCode);
Assert.StartsWith("/portal/login", response.Headers.Location!.OriginalString.Replace("http://localhost", "", StringComparison.Ordinal));
var action = await client.PostAsync("/portal/actions/cases/1/close", null);
Assert.Equal(HttpStatusCode.Redirect, action.StatusCode);
Assert.Contains("/portal/login", action.Headers.Location!.OriginalString, StringComparison.Ordinal);
}
[Fact]
public async Task Wrong_password_is_rejected()
{
var client = Client(hub.Factory);
var userName = await StaffAsync();
var wrong = await LoginAsync(client, userName, "forkert-adgangskode");
var unknown = await LoginAsync(client, "findes-ikke", Password);
Assert.Equal("/portal/login?fejl=1", wrong.Headers.Location?.OriginalString);
Assert.Equal("/portal/login?fejl=1", unknown.Headers.Location?.OriginalString);
Assert.Equal(HttpStatusCode.Redirect, (await client.GetAsync("/portal")).StatusCode);
}
[Fact]
public async Task Login_without_antiforgery_token_is_refused()
{
var client = Client(hub.Factory);
using var content = new FormUrlEncodedContent([new("userName", await StaffAsync()), new("password", Password)]);
var response = await client.PostAsync("/portal/login", content);
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
}
[Theory]
[InlineData("/portal/sager", "/portal/sager")]
[InlineData("https://evil.example/portal", "/portal")]
[InlineData("//evil.example/portal", "/portal")]
[InlineData("/\\evil.example", "/portal")]
public async Task Return_url_is_only_followed_inside_the_portal(string returnUrl, string expected)
{
var client = Client(hub.Factory);
var response = await LoginAsync(client, await StaffAsync(), Password, returnUrl);
Assert.Equal(expected, response.Headers.Location?.OriginalString);
}
[Fact]
public async Task Login_is_rate_limited()
{
await using var isolated = hub.Factory.WithWebHostBuilder(b => b.UseSetting("SQLInsight:Hub:LoginAttemptsPerMinute", "10"));
var client = Client(isolated);
var statuses = new List<HttpStatusCode>();
for (var i = 0; i < 12; i++)
{
statuses.Add((await LoginAsync(client, "findes-ikke", "forkert-adgangskode")).StatusCode);
}
Assert.Equal(10, statuses.Count(s => s == HttpStatusCode.Redirect));
Assert.Equal(HttpStatusCode.TooManyRequests, statuses[^1]);
}
[Fact]
public async Task Signed_in_staff_sees_customers_findings_cases_and_licenses()
{
var customer = $"Portal {Guid.NewGuid():N}"[..20];
var id = await SeedAsync(customer, hub.License(customer));
var client = await SignedInAsync();
var overview = await client.GetStringAsync("/portal");
var findings = await client.GetStringAsync("/portal/fund");
var detail = await client.GetStringAsync($"/portal/fund/{id}");
var licenses = await client.GetStringAsync("/portal/licenser");
var cases = await client.GetAsync("/portal/sager");
Assert.Contains(customer, overview, StringComparison.Ordinal);
Assert.Contains("SQLI-DB-007", findings, StringComparison.Ordinal);
Assert.Contains("database: Sales", detail, StringComparison.Ordinal);
Assert.Contains(customer, licenses, StringComparison.Ordinal);
Assert.Equal(HttpStatusCode.OK, cases.StatusCode);
Assert.Contains("default-src 'self'", cases.Headers.GetValues("Content-Security-Policy").Single(), StringComparison.Ordinal);
}
[Fact]
public async Task Finding_can_be_linked_to_a_new_support_case_and_the_case_closed()
{
var customer = $"Sag {Guid.NewGuid():N}"[..20];
var id = await SeedAsync(customer, hub.License(customer));
var client = await SignedInAsync();
var linked = await PostFormAsync(client, $"/portal/fund/{id}", $"/portal/actions/findings/{id}/case", ("reference", "SUP-1234"), ("title", ""));
Assert.Equal($"/portal/fund/{id}", linked.Headers.Location?.OriginalString);
Assert.Contains("SUP-1234", await client.GetStringAsync("/portal/sager"), StringComparison.Ordinal);
int caseId;
await using (var scope = hub.Factory.Services.CreateAsyncScope())
{
var db = scope.ServiceProvider.GetRequiredService<HubDbContext>();
var finding = await db.Findings.Include(f => f.SupportCase).SingleAsync(f => f.Id == id);
caseId = finding.SupportCase!.Id;
Assert.Equal("SUP-1234", finding.SupportCase.Reference);
Assert.Equal(finding.Title, finding.SupportCase.Title);
}
var closed = await PostFormAsync(client, "/portal/sager", $"/portal/actions/cases/{caseId}/close");
Assert.Equal("/portal/sager", closed.Headers.Location?.OriginalString);
await using (var scope = hub.Factory.Services.CreateAsyncScope())
{
Assert.False((await scope.ServiceProvider.GetRequiredService<HubDbContext>().SupportCases.SingleAsync(c => c.Id == caseId)).IsOpen);
}
}
[Fact]
public async Task Finding_cannot_be_linked_to_another_customers_case()
{
var a = $"A {Guid.NewGuid():N}"[..20];
var b = $"B {Guid.NewGuid():N}"[..20];
var findingA = await SeedAsync(a, hub.License(a));
var findingB = await SeedAsync(b, hub.License(b));
var client = await SignedInAsync();
await PostFormAsync(client, $"/portal/fund/{findingB}", $"/portal/actions/findings/{findingB}/case", ("reference", "SUP-B"));
int caseB;
await using (var scope = hub.Factory.Services.CreateAsyncScope())
{
caseB = (await scope.ServiceProvider.GetRequiredService<HubDbContext>().Findings.SingleAsync(f => f.Id == findingB)).SupportCaseId!.Value;
}
var response = await PostFormAsync(client, $"/portal/fund/{findingA}", $"/portal/actions/findings/{findingA}/case", ("caseId", caseB.ToString(System.Globalization.CultureInfo.InvariantCulture)));
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
}
[Fact]
public async Task Revoking_a_license_in_the_portal_blocks_the_agent()
{
var customer = $"Spær {Guid.NewGuid():N}"[..20];
var licenseId = Guid.NewGuid().ToString("N");
var license = hub.License(customer, id: licenseId);
await SeedAsync(customer, license);
var client = await SignedInAsync();
var revoked = await PostFormAsync(client, "/portal/licenser", $"/portal/actions/licenses/{licenseId}/revoke", ("reason", "Ikke betalt"));
Assert.Equal("/portal/licenser", revoked.Headers.Location?.OriginalString);
var api = hub.Factory.CreateClient();
using var request = new HttpRequestMessage(HttpMethod.Post, "/api/v1/findings")
{
Content = JsonContent.Create(HubApiTests.Submission("sql01", DateTimeOffset.UtcNow)),
};
request.Headers.Authorization = new AuthenticationHeaderValue("SQLInsight-License", license);
Assert.Equal(HttpStatusCode.Forbidden, (await api.SendAsync(request)).StatusCode);
}
[Fact]
public async Task Short_passwords_are_refused()
{
await using var scope = hub.Factory.Services.CreateAsyncScope();
await Assert.ThrowsAsync<ArgumentException>(() => scope.ServiceProvider.GetRequiredService<StaffAccounts>().CreateAsync("kort", "Kort", "123"));
}
}