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 { private const string Password = "korrekt-hest-batteri"; [GeneratedRegex("name=\"__RequestVerificationToken\"[^>]*value=\"([^\"]+)\"")] private static partial Regex AntiforgeryField(); private static HttpClient Client(WebApplicationFactory factory) => factory.CreateClient(new WebApplicationFactoryClientOptions { AllowAutoRedirect = false }); private async Task StaffAsync() { var userName = $"staff{Guid.NewGuid():N}"[..20]; await using var scope = hub.Factory.Services.CreateAsyncScope(); await scope.ServiceProvider.GetRequiredService().CreateAsync(userName, "Support Person", Password); return userName; } private static async Task TokenAsync(HttpClient client, string page) { var html = await client.GetStringAsync(page); return WebUtility.HtmlDecode(AntiforgeryField().Match(html).Groups[1].Value); } private static async Task PostFormAsync(HttpClient client, string page, string action, params (string Key, string Value)[] fields) { var form = fields.Select(f => new KeyValuePair(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 LoginAsync(HttpClient client, string userName, string password, string returnUrl = "") => PostFormAsync(client, "/portal/login", "/portal/login", ("userName", userName), ("password", password), ("returnUrl", returnUrl)); private async Task 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 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(); 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(); 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(); 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().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().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(() => scope.ServiceProvider.GetRequiredService().CreateAsync("kort", "Kort", "123")); } }