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; /// /// 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. /// [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.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().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.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; } }