Some checks are pending
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
CI / Build og unit tests (push) Successful in 58s
deploy community / deploy (push) Successful in 13s
deploy hub / deploy (push) Successful in 23s
CI / MSI-installer (push) Successful in 35s
A thin bar above the header ("Tilbage til seosoft.dk") and footer links
lead back to the vendor's site. CompanyUrl is configurable.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
286 lines
11 KiB
C#
286 lines
11 KiB
C#
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;
|
|
|
|
/// <summary>The site against a signed test feed (a folder) and a stubbed Discourse forum.</summary>
|
|
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 Every_page_links_back_to_the_vendor_site()
|
|
{
|
|
using var factory = Site();
|
|
|
|
var html = await Get(factory, "/dokumentation");
|
|
|
|
Assert.Contains("href=\"https://seosoft.dk/\"", html, StringComparison.Ordinal);
|
|
Assert.Contains("Tilbage til seosoft.dk", html, 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);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Shared_forum_shows_only_our_category_and_links_to_it()
|
|
{
|
|
using var factory = Site(forumCategory: "sqlinsight");
|
|
|
|
var html = await Get(factory, "/");
|
|
|
|
Assert.Equal("/c/sqlinsight/l/latest.json", _forum.Requests.Single().AbsolutePath);
|
|
// Topics live at the forum's root, not under the category.
|
|
Assert.Contains("https://forum.example/t/maxdop/7", html, StringComparison.Ordinal);
|
|
Assert.Contains("href=\"https://forum.example/c/sqlinsight\"", html, StringComparison.Ordinal);
|
|
|
|
using var client = factory.CreateClient(new WebApplicationFactoryClientOptions { AllowAutoRedirect = false });
|
|
using var response = await client.GetAsync(new Uri("/forum", UriKind.Relative), CancellationToken.None);
|
|
Assert.Equal(new Uri("https://forum.example/c/sqlinsight"), response.Headers.Location);
|
|
}
|
|
|
|
private WebApplicationFactory<Program> Site(bool trustKey = true, string? downloadUrl = null, string? forumCategory = null)
|
|
{
|
|
var settings = new Dictionary<string, string?>
|
|
{
|
|
["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 (forumCategory is not null)
|
|
{
|
|
settings["SQLInsight:Community:ForumCategory"] = forumCategory;
|
|
}
|
|
|
|
if (trustKey)
|
|
{
|
|
settings[$"SQLInsight:Community:TrustedKeys:{TestFeed.KeyId}"] = _feed.PublicKey;
|
|
}
|
|
|
|
return new WebApplicationFactory<Program>().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<string> Get(WebApplicationFactory<Program> 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<Uri> Requests { get; } = [];
|
|
public bool Fail { get; set; }
|
|
|
|
protected override Task<HttpResponseMessage> 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<HttpResponseMessage> Forward(HttpRequestMessage request, CancellationToken cancellationToken) => SendAsync(request, cancellationToken);
|
|
}
|
|
|
|
/// <summary>Lets the handler factory dispose its wrapper without disposing the shared stub.</summary>
|
|
private sealed class NonDisposing(StubHandler inner) : HttpMessageHandler
|
|
{
|
|
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) => inner.Forward(request, cancellationToken);
|
|
}
|
|
}
|