using System.Net; using System.Text; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Mvc.Testing; using Microsoft.AspNetCore.TestHost; using Microsoft.Extensions.DependencyInjection; using SQLInsight.Updates; using SQLInsight.Updates.Tests; namespace SQLInsight.Community.Tests; /// The site against a signed test feed (a folder) and a stubbed Discourse forum. public sealed class SiteTests : IDisposable { private const string LatestJson = """ { "topic_list": { "topics": [ { "id": 1, "title": "Velkommen", "slug": "velkommen", "posts_count": 1, "bumped_at": "2026-09-01T10:00:00Z", "pinned": true }, { "id": 7, "title": "Hvad skal MAXDOP være?", "slug": "maxdop", "posts_count": 3, "bumped_at": "2026-09-28T10:00:00Z", "pinned": false, "has_accepted_answer": true } ] } } """; private readonly TestFeed _feed = new(); private readonly StubHandler _forum = new(LatestJson); public void Dispose() { _feed.Dispose(); _forum.Dispose(); } [Theory] [InlineData("/")] [InlineData("/funktioner")] [InlineData("/download")] [InlineData("/dokumentation")] [InlineData("/dokumentation/installation")] [InlineData("/sql-server-versioner")] [InlineData("/regler")] [InlineData("/support")] [InlineData("/health")] public async Task Pages_render(string path) { using var factory = Site(); using var client = factory.CreateClient(); using var response = await client.GetAsync(new Uri(path, UriKind.Relative), CancellationToken.None); Assert.Equal(HttpStatusCode.OK, response.StatusCode); Assert.Contains("default-src 'self'", response.Headers.GetValues("Content-Security-Policy").Single(), StringComparison.Ordinal); } [Theory] [InlineData("/findes-ikke")] [InlineData("/dokumentation/findes-ikke")] [InlineData("/dokumentation/hub")] public async Task Unknown_pages_are_404(string path) { using var factory = Site(); using var client = factory.CreateClient(); using var response = await client.GetAsync(new Uri(path, UriKind.Relative), CancellationToken.None); Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); Assert.Contains("findes ikke", await response.Content.ReadAsStringAsync(CancellationToken.None), StringComparison.OrdinalIgnoreCase); } [Fact] public async Task Download_lists_signed_releases_with_hashes() { var release = _feed.MsiRelease("1.2.0"); _feed.Publish("stable", release: release); using var factory = Site(); var html = await Get(factory, "/download"); Assert.Contains("SQLInsight Pro 1.2.0", html, StringComparison.Ordinal); Assert.Contains("SQLInsight-Pro-1.2.0-x64.msi", html, StringComparison.Ordinal); foreach (var asset in release.Assets) { Assert.Contains(asset.File.Sha256.ToLowerInvariant(), html, StringComparison.Ordinal); } } [Fact] public async Task A_manifest_with_a_bad_signature_is_not_shown() { _feed.Publish("stable", release: _feed.MsiRelease("1.2.0")); var path = Path.Combine(_feed.FeedDirectory, "stable", UpdateFeed.ManifestFile); var token = await File.ReadAllTextAsync(path, CancellationToken.None); var parts = token.Split('.'); var payload = parts[1].ToCharArray(); payload[payload.Length / 2] = payload[payload.Length / 2] == 'A' ? 'B' : 'A'; parts[1] = new string(payload); await File.WriteAllTextAsync(path, string.Join('.', parts), CancellationToken.None); using var factory = Site(); var html = await Get(factory, "/download"); Assert.DoesNotContain("1.2.0", html, StringComparison.Ordinal); Assert.Contains("ingen udgivelser", html, StringComparison.Ordinal); } [Fact] public async Task A_manifest_signed_with_an_unknown_key_is_not_shown() { _feed.Publish("stable", release: _feed.MsiRelease("1.2.0")); using var factory = Site(trustKey: false); var html = await Get(factory, "/download"); Assert.DoesNotContain("SQLInsight Pro 1.2.0", html, StringComparison.Ordinal); } [Fact] public async Task Front_page_shows_forum_topics_without_pinned_ones() { using var factory = Site(); var html = await Get(factory, "/"); Assert.Contains("Hvad skal MAXDOP være?", html, StringComparison.Ordinal); Assert.Contains("https://forum.example/t/maxdop/7", html, StringComparison.Ordinal); Assert.Contains("Løst", html, StringComparison.Ordinal); Assert.DoesNotContain(">Velkommen<", html, StringComparison.Ordinal); Assert.EndsWith("/latest.json", _forum.Requests.Single().AbsolutePath, StringComparison.Ordinal); } [Fact] public async Task Front_page_works_when_the_forum_is_down() { _forum.Fail = true; using var factory = Site(); var html = await Get(factory, "/"); Assert.Contains("SQL Server-overvågning", html, StringComparison.Ordinal); } [Fact] public async Task Versions_page_uses_the_newest_signed_rule_pack() { _feed.Publish("stable", rulePack: _feed.RulePack(new Version(2099, 1, 1))); using var factory = Site(); var html = await Get(factory, "/sql-server-versioner"); Assert.Contains("2099.1.1", html, StringComparison.Ordinal); } [Fact] public async Task Docs_are_rendered_with_rewritten_links() { using var factory = Site(); var html = await Get(factory, "/dokumentation/installation"); Assert.Contains("href=\"/dokumentation/opdatering", html, StringComparison.Ordinal); Assert.Contains("https://git.example/seo/sqlinsight-pro/src/branch/main/db/permissions/grant-least-privilege.sql", html, StringComparison.Ordinal); } [Fact] public async Task Rules_page_lists_built_in_and_community_rules() { using var factory = Site(); var html = await Get(factory, "/regler"); Assert.Contains("SQLI-CFG-004", html, StringComparison.Ordinal); Assert.Contains("COM-SEC-001", html, StringComparison.Ordinal); } [Fact] public async Task Forum_shortcut_redirects_to_the_forum() { using var factory = Site(); using var client = factory.CreateClient(new WebApplicationFactoryClientOptions { AllowAutoRedirect = false }); using var response = await client.GetAsync(new Uri("/forum", UriKind.Relative), CancellationToken.None); Assert.Equal(HttpStatusCode.Redirect, response.StatusCode); Assert.Equal(new Uri("https://forum.example/"), response.Headers.Location); } private WebApplicationFactory Site(bool trustKey = true) { var settings = new Dictionary { ["SQLInsight:Community:Feed"] = _feed.FeedDirectory, ["SQLInsight:Community:ForumUrl"] = "https://forum.example/", ["SQLInsight:Community:SourceUrl"] = "https://git.example/seo/sqlinsight-pro", }; if (trustKey) { settings[$"SQLInsight:Community:TrustedKeys:{TestFeed.KeyId}"] = _feed.PublicKey; } return new WebApplicationFactory().WithWebHostBuilder(builder => { builder.UseEnvironment("Production"); foreach (var (key, value) in settings) { builder.UseSetting(key, value); } builder.ConfigureTestServices(services => services.AddHttpClient("forum").ConfigurePrimaryHttpMessageHandler(() => new NonDisposing(_forum))); }); } private static async Task Get(WebApplicationFactory factory, string path) { using var client = factory.CreateClient(); return await client.GetStringAsync(new Uri(path, UriKind.Relative), CancellationToken.None); } private sealed class StubHandler(string body) : HttpMessageHandler { public List Requests { get; } = []; public bool Fail { get; set; } protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) { Requests.Add(request.RequestUri!); return Fail ? throw new HttpRequestException("forum nede") : Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(body, Encoding.UTF8, "application/json") }); } public Task Forward(HttpRequestMessage request, CancellationToken cancellationToken) => SendAsync(request, cancellationToken); } /// Lets the handler factory dispose its wrapper without disposing the shared stub. private sealed class NonDisposing(StubHandler inner) : HttpMessageHandler { protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) => inner.Forward(request, cancellationToken); } }