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
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
141 lines
6.3 KiB
C#
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;
|
|
}
|
|
}
|