sqlinsight-pro/tests/SQLInsight.IntegrationTests/UpdateIntegrationTests.cs
Claude 3121b598f9
Some checks are pending
CI / Build og unit tests (push) Waiting to run
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
Fase 3: signed updates of rule packs and the application
Rule packs (support dates and newest CU per release) are published in a
signed feed and hot-loaded without a restart; the verified pack is
reloaded at startup. New versions are announced (Notify) or downloaded
and staged by the unprivileged agent (Automatic), and installed by a
privileged apply-update task that re-verifies signature and hash in an
admin-only folder, keeps appsettings/web.config, health-checks the
services and rolls back on failure. Stable and preview channels,
manifest expiry and rollback protection, https or folder/share feeds
for offline sites. Vendor tool gains rulepack and publish; the Hub can
serve the feed; the web UI gets a System page with update, rule pack,
license and exactly what is sent to the Hub.

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

141 lines
6.3 KiB
C#

using System.Security.Cryptography;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using SQLInsight.Agent.Updates;
using SQLInsight.Licensing;
using SQLInsight.Repository;
using SQLInsight.Rules.Lifecycle;
using SQLInsight.Updates;
namespace SQLInsight.IntegrationTests;
/// <summary>
/// The agent's update check against a signed feed folder (the offline case), with the status stored in a real
/// repository and shown in the web UI.
/// </summary>
[Collection(SqlServerTestGroup.Name)]
[Trait("Category", "Integration")]
public sealed class UpdateIntegrationTests(SqlServerFixture sql) : IAsyncLifetime, IDisposable
{
private const string KeyId = "itest-updates";
private readonly string _database = "SQLInsightUpd_" + Guid.NewGuid().ToString("N")[..8];
private readonly string _root = Path.Combine(Path.GetTempPath(), "sqlinsight-upd-" + Guid.NewGuid().ToString("N"));
private readonly ECDsa _key = ECDsa.Create(ECCurve.NamedCurves.nistP256);
private InsightRepository _repository = null!;
public async Task InitializeAsync()
{
await sql.ExecuteAsSaAsync($"CREATE DATABASE [{_database}];");
_repository = new InsightRepository(sql.DatabaseConnectionString(_database));
await _repository.MigrateAsync();
}
public Task DisposeAsync() => sql.ExecuteAsSaAsync($"ALTER DATABASE [{_database}] SET SINGLE_USER WITH ROLLBACK IMMEDIATE; DROP DATABASE [{_database}];");
public void Dispose()
{
_key.Dispose();
if (Directory.Exists(_root))
{
Directory.Delete(_root, recursive: true);
}
}
private FileRef Publish(string path, byte[] bytes)
{
var full = Path.Combine(_root, "feed", path);
Directory.CreateDirectory(Path.GetDirectoryName(full)!);
File.WriteAllBytes(full, bytes);
return new FileRef(path, Convert.ToHexString(SHA256.HashData(bytes)), bytes.Length);
}
private static string RepositoryRoot()
{
for (var dir = new DirectoryInfo(AppContext.BaseDirectory); dir is not null; dir = dir.Parent)
{
if (File.Exists(Path.Combine(dir.FullName, "SQLInsight.slnx")))
{
return dir.FullName;
}
}
throw new InvalidOperationException("Repository root not found.");
}
[Fact]
public async Task Rule_pack_is_hot_loaded_release_staged_and_status_shown_in_the_web_ui()
{
var lifecycle = Path.Combine(RepositoryRoot(), "src", "SQLInsight.Rules", "Lifecycle");
var pack = RulePack.Create(new Version(2099, 1, 1), File.ReadAllBytes(Path.Combine(lifecycle, "build-catalog.json")), File.ReadAllBytes(Path.Combine(lifecycle, "support-lifecycle.json")));
var manifest = new UpdateManifest
{
KeyId = KeyId,
Channel = "stable",
PublishedAt = DateTimeOffset.UtcNow,
ExpiresAt = DateTimeOffset.UtcNow.AddDays(30),
RulePack = new RulePackRef("2099.1.1", Publish("rulepacks/rulepack-2099.1.1.json", pack)),
Release = new AppRelease
{
Version = "99.0.0",
Released = new DateOnly(2099, 1, 1),
Security = true,
Assets = [new ReleaseAsset(Platform.Current, Publish("releases/sqlinsight-99.0.0.zip", [1, 2, 3]))],
},
};
Publish("stable/manifest.sqli", System.Text.Encoding.UTF8.GetBytes(SignedDocument.Sign(manifest, _key)));
var options = new UpdateOptions { Feed = Path.Combine(_root, "feed"), DataDirectory = Path.Combine(_root, "data"), Application = AppUpdateMode.Automatic };
options.TrustedKeys[KeyId] = Convert.ToBase64String(_key.ExportSubjectPublicKeyInfo());
var knowledge = RuleKnowledge.Embedded();
using var http = new HttpClient();
var service = new UpdateService(Options.Create(options), knowledge, _repository, new SingleClient(http), TimeProvider.System, NullLogger<UpdateService>.Instance);
var status = await service.CheckOnceAsync(CancellationToken.None);
Assert.Null(status.LastError);
Assert.Equal(new Version(2099, 1, 1), knowledge.Current.Version);
Assert.Equal("99.0.0", status.AvailableVersion);
Assert.Equal("99.0.0", status.StagedVersion);
Assert.True(File.Exists(Path.Combine(options.Directories.Pending, "package.zip")));
var stored = await _repository.GetUpdateStatusAsync();
Assert.Equal("2099.1.1", stored!.RulePackVersion);
Assert.True(stored.AvailableSecurity);
// After a restart the verified pack on disk is used again.
Assert.Equal(new Version(2099, 1, 1), UpdateService.LoadKnowledge(options, NullLogger.Instance).Current.Version);
await using var web = new WebApplicationFactory<Program>().WithWebHostBuilder(b =>
{
b.UseEnvironment("Development");
b.UseSetting("SQLInsight:Repository:ConnectionString", sql.DatabaseConnectionString(_database));
b.UseSetting("SQLInsight:Web:Authentication", "None");
});
var html = await web.CreateClient().GetStringAsync("/system");
Assert.Contains("SQLInsight Pro 99.0.0 er tilgængelig og indeholder sikkerhedsrettelser", html, StringComparison.Ordinal);
Assert.Contains("2099.1.1", html, StringComparison.Ordinal);
Assert.Contains("hentet og verificeret", html, StringComparison.Ordinal);
}
[Fact]
public async Task Without_a_feed_the_status_still_reports_version_and_rule_pack()
{
var options = new UpdateOptions { DataDirectory = Path.Combine(_root, "data") };
using var http = new HttpClient();
var service = new UpdateService(Options.Create(options), RuleKnowledge.Embedded(), _repository, new SingleClient(http), TimeProvider.System, NullLogger<UpdateService>.Instance);
await service.CheckOnceAsync(CancellationToken.None);
var stored = await _repository.GetUpdateStatusAsync();
Assert.Null(stored!.Feed);
Assert.Equal(RulePack.Embedded().Version.ToString(), stored.RulePackVersion);
Assert.Equal("indbygget", stored.RulePackSource);
}
private sealed class SingleClient(HttpClient client) : IHttpClientFactory
{
public HttpClient CreateClient(string name) => client;
}
}