sqlinsight-pro/tests/SQLInsight.Community.Tests/SiteTests.cs
Claude be35e50335
Some checks are pending
CI / Integration (mcr.microsoft.com/mssql/server:2025-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:2017-latest) (push) Blocked by required conditions
CI / Build og unit tests (push) Successful in 1m1s
deploy hub / deploy (push) Successful in 19s
CI / MSI-installer (push) Successful in 36s
Community-websted og Discourse-forum
- src/SQLInsight.Community: offentligt websted med forside, funktioner, download
  af signerede udgivelser (med SHA-256), dokumentation fra docs/, SQL Server-
  versioner fra den nyeste signerede regelpakke, regler og supportaftaler.
  Viser de seneste forumemner. Streng CSP, ingen login, ingen database.
- deploy/forum: Discourse-opsætning (app.yml til discourse_docker, dansk
  sprog og søgning), install.sh og configure.py, der idempotent opsætter
  kategorier, løste svar, velkomstemne og indstillinger via API'et.
- deploy/community: Dockerfile og docker compose med Caddy til TLS.
- Webbrugerfladen linker til forum, dokumentation og support på webstedet
  (SQLInsight:Web:ForumUrl og CommunityUrl).
- Udgivelsesfiler hedder sqlinsight-<version>-<platform>.<ext>.
- Tests: sider, 404, signerede og manipulerede feeds, forum (også nede),
  dokumentationslinks og regler.
- docs/community.md.

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

237 lines
9 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_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<Program> Site(bool trustKey = true)
{
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 (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);
}
}