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_folder_feed_links_downloads_to_the_public_address()
{
_feed.Publish("stable", release: _feed.MsiRelease("1.2.0"));
using var factory = Site(downloadUrl: "https://hub.example/updates/");
var html = await Get(factory, "/download");
Assert.Contains("href=\"https://hub.example/updates/releases/SQLInsight-Pro-1.2.0-x64.msi\"", 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, string? downloadUrl = null)
{
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 (downloadUrl is not null)
{
settings["SQLInsight:Community:DownloadUrl"] = downloadUrl;
}
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);
}
}