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
259 lines
12 KiB
C#
259 lines
12 KiB
C#
using System.Text;
|
|
using SQLInsight.Licensing;
|
|
using SQLInsight.Rules.Lifecycle;
|
|
|
|
namespace SQLInsight.Updates.Tests;
|
|
|
|
public sealed class UpdateTests : IDisposable
|
|
{
|
|
private readonly TestFeed _feed = new();
|
|
|
|
public void Dispose() => _feed.Dispose();
|
|
|
|
private UpdateApplier Applier(FakeServices services) => new(
|
|
new UpdateApplierOptions
|
|
{
|
|
Channel = "stable",
|
|
PrivateRoot = Path.Combine(_feed.Root, "private"),
|
|
Components = [new UpdateComponent { Name = "Agent", Path = _feed.AgentFolder, Service = "SQLInsight Agent" }],
|
|
HealthCheckDuration = TimeSpan.FromMilliseconds(50),
|
|
},
|
|
_feed.Directories, _feed.Keys, services, TimeProvider.System, _ => { });
|
|
|
|
private string AgentFile(string name) => File.ReadAllText(Path.Combine(_feed.AgentFolder, name));
|
|
|
|
[Fact]
|
|
public void Manifest_with_valid_signature_is_accepted()
|
|
{
|
|
var token = _feed.Sign(new UpdateManifest { KeyId = TestFeed.KeyId, Channel = "stable", PublishedAt = DateTimeOffset.UtcNow, ExpiresAt = DateTimeOffset.UtcNow.AddDays(1) });
|
|
|
|
Assert.Equal("stable", ManifestVerifier.Verify(token, _feed.Keys, "stable", DateTimeOffset.UtcNow).Channel);
|
|
}
|
|
|
|
[Fact]
|
|
public void Tampered_or_foreign_manifests_are_refused()
|
|
{
|
|
var now = DateTimeOffset.UtcNow;
|
|
var manifest = new UpdateManifest { KeyId = TestFeed.KeyId, Channel = "stable", PublishedAt = now, ExpiresAt = now.AddDays(1) };
|
|
var token = _feed.Sign(manifest);
|
|
var parts = token.Split('.');
|
|
var forged = Convert.ToBase64String(Encoding.UTF8.GetBytes("{\"keyId\":\"test\",\"channel\":\"preview\",\"publishedAt\":\"2026-01-01T00:00:00Z\",\"expiresAt\":\"2099-01-01T00:00:00Z\"}"))
|
|
.TrimEnd('=').Replace('+', '-').Replace('/', '_');
|
|
using var otherKey = System.Security.Cryptography.ECDsa.Create(System.Security.Cryptography.ECCurve.NamedCurves.nistP256);
|
|
|
|
Assert.Throws<UpdateException>(() => ManifestVerifier.Verify($"{parts[0]}.{forged}.{parts[2]}", _feed.Keys, "preview", now));
|
|
Assert.Throws<UpdateException>(() => ManifestVerifier.Verify(SignedDocument.Sign(manifest, otherKey), _feed.Keys, "stable", now));
|
|
Assert.Throws<UpdateException>(() => ManifestVerifier.Verify(token, new TrustedKeys([]), "stable", now));
|
|
}
|
|
|
|
[Fact]
|
|
public void Wrong_channel_expired_and_older_manifests_are_refused()
|
|
{
|
|
var now = DateTimeOffset.UtcNow;
|
|
var token = _feed.Sign(new UpdateManifest { KeyId = TestFeed.KeyId, Channel = "preview", PublishedAt = now.AddDays(-2), ExpiresAt = now.AddDays(-1) });
|
|
var fresh = _feed.Sign(new UpdateManifest { KeyId = TestFeed.KeyId, Channel = "stable", PublishedAt = now.AddDays(-2), ExpiresAt = now.AddDays(1) });
|
|
|
|
Assert.Contains("kanalen", Assert.Throws<UpdateException>(() => ManifestVerifier.Verify(token, _feed.Keys, "stable", now)).Message, StringComparison.Ordinal);
|
|
Assert.Contains("udløb", Assert.Throws<UpdateException>(() => ManifestVerifier.Verify(token, _feed.Keys, "preview", now)).Message, StringComparison.Ordinal);
|
|
Assert.Contains("ældre", Assert.Throws<UpdateException>(() => ManifestVerifier.Verify(fresh, _feed.Keys, "stable", now, notBefore: now.AddDays(-1))).Message, StringComparison.Ordinal);
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData("../secret.zip")]
|
|
[InlineData("/etc/passwd")]
|
|
[InlineData("releases\\x.zip")]
|
|
[InlineData("C:/x.zip")]
|
|
[InlineData("releases//x.zip")]
|
|
public void Paths_outside_the_feed_are_refused(string path)
|
|
{
|
|
var now = DateTimeOffset.UtcNow;
|
|
var token = _feed.Sign(new UpdateManifest
|
|
{
|
|
KeyId = TestFeed.KeyId, Channel = "stable", PublishedAt = now, ExpiresAt = now.AddDays(1),
|
|
RulePack = new RulePackRef("2027.1.1", new FileRef(path, "00", 1)),
|
|
});
|
|
|
|
Assert.Throws<UpdateException>(() => ManifestVerifier.Verify(token, _feed.Keys, "stable", now));
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData("1.2.0", "1.10.0")]
|
|
[InlineData("1.3.0-preview.2", "1.3.0")]
|
|
[InlineData("1.3.0-preview.2", "1.3.0-preview.10")]
|
|
[InlineData("1.3.0-beta", "1.3.0-rc")]
|
|
[InlineData("0.9.9", "1.0.0-preview.1")]
|
|
public void Versions_are_ordered_like_semver(string lower, string higher)
|
|
{
|
|
Assert.True(AppVersion.Parse(lower) < AppVersion.Parse(higher));
|
|
Assert.True(AppVersion.Parse(higher) > AppVersion.Parse(lower));
|
|
}
|
|
|
|
[Fact]
|
|
public void Build_metadata_and_short_versions_compare_equal()
|
|
{
|
|
Assert.Equal(AppVersion.Parse("1.2"), AppVersion.Parse("1.2.0+4f1c2a9"));
|
|
Assert.Equal("1.2.0", AppVersion.Parse("1.2+abc").ToString());
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Newer_rule_pack_is_downloaded_stored_and_reloaded()
|
|
{
|
|
var embedded = RulePack.Embedded();
|
|
var newer = new Version(embedded.Version.Major + 1, 1, 1);
|
|
_feed.Publish(rulePack: _feed.RulePack(newer));
|
|
var checker = _feed.Checker();
|
|
|
|
var manifest = await checker.FetchAsync(CancellationToken.None);
|
|
var pack = await checker.UpdateRulePackAsync(manifest, embedded, CancellationToken.None);
|
|
var again = await checker.UpdateRulePackAsync(manifest, pack!, CancellationToken.None);
|
|
var reloaded = new RulePackStore(_feed.Directories).Load(_feed.Keys);
|
|
|
|
Assert.Equal(newer, pack!.Version);
|
|
Assert.Null(again);
|
|
Assert.Equal(newer, reloaded!.Version);
|
|
Assert.Contains("stable", reloaded.Source, StringComparison.Ordinal);
|
|
Assert.NotNull(reloaded.Builds.Latest(Core.Instances.SqlServerRelease.SqlServer2022));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Rule_pack_changed_on_disk_is_not_loaded()
|
|
{
|
|
var newer = new Version(RulePack.Embedded().Version.Major + 1, 1, 1);
|
|
_feed.Publish(rulePack: _feed.RulePack(newer));
|
|
var checker = _feed.Checker();
|
|
await checker.UpdateRulePackAsync(await checker.FetchAsync(CancellationToken.None), RulePack.Embedded(), CancellationToken.None);
|
|
|
|
var file = Path.Combine(_feed.Directories.RulePack, "pack.json");
|
|
File.WriteAllText(file, File.ReadAllText(file).Replace("CU", "XX", StringComparison.Ordinal));
|
|
|
|
Assert.Throws<UpdateException>(() => new RulePackStore(_feed.Directories).Load(_feed.Keys));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Rule_pack_that_does_not_match_the_manifest_hash_is_refused()
|
|
{
|
|
var reference = _feed.RulePack(new Version(2099, 1, 1));
|
|
File.AppendAllText(Path.Combine(_feed.FeedDirectory, reference.File.Path), " ");
|
|
_feed.Publish(rulePack: reference);
|
|
var checker = _feed.Checker();
|
|
|
|
var manifest = await checker.FetchAsync(CancellationToken.None);
|
|
|
|
await Assert.ThrowsAsync<UpdateException>(() => checker.UpdateRulePackAsync(manifest, RulePack.Embedded(), CancellationToken.None));
|
|
Assert.Null(new RulePackStore(_feed.Directories).Load(_feed.Keys));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Older_manifest_is_refused_after_a_newer_one_was_seen()
|
|
{
|
|
var now = DateTimeOffset.UtcNow;
|
|
_feed.Publish(publishedAt: now);
|
|
await _feed.Checker().FetchAsync(CancellationToken.None);
|
|
_feed.Publish(publishedAt: now.AddHours(-1));
|
|
|
|
await Assert.ThrowsAsync<UpdateException>(() => _feed.Checker().FetchAsync(CancellationToken.None));
|
|
}
|
|
|
|
[Fact]
|
|
public void Plain_http_feed_is_refused()
|
|
{
|
|
using var http = new HttpClient();
|
|
|
|
Assert.Throws<UpdateException>(() => new UpdateFeed("http://updates.example.com/", http));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Update_is_staged_applied_and_keeps_the_configuration()
|
|
{
|
|
_feed.Install();
|
|
_feed.Publish(release: _feed.Release("1.1.0"));
|
|
var checker = _feed.Checker();
|
|
var services = new FakeServices(Path.Combine(_feed.AgentFolder, "SQLInsight.Agent.dll"));
|
|
|
|
Assert.True(await checker.StageAsync(await checker.FetchAsync(CancellationToken.None), AppVersion.Parse("1.0.0"), CancellationToken.None));
|
|
var result = await Applier(services).ApplyAsync(AppVersion.Parse("1.0.0"), CancellationToken.None);
|
|
|
|
Assert.Equal(UpdateOutcome.Applied, result.Outcome);
|
|
Assert.Equal("v2", AgentFile("SQLInsight.Agent.dll"));
|
|
Assert.Equal("new", AgentFile(Path.Combine("runtimes", "new.dll")));
|
|
Assert.Contains("customer", AgentFile("appsettings.json"), StringComparison.Ordinal);
|
|
Assert.Equal(["stop SQLInsight Agent", "start SQLInsight Agent"], services.Calls);
|
|
Assert.False(Directory.Exists(_feed.Directories.Pending));
|
|
Assert.Equal(UpdateOutcome.Applied, _feed.Directories.LastResult()!.Outcome);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Update_that_does_not_start_is_rolled_back()
|
|
{
|
|
_feed.Install();
|
|
_feed.Publish(release: _feed.Release("1.1.0", program: "broken"));
|
|
var checker = _feed.Checker();
|
|
var services = new FakeServices(Path.Combine(_feed.AgentFolder, "SQLInsight.Agent.dll"));
|
|
await checker.StageAsync(await checker.FetchAsync(CancellationToken.None), AppVersion.Parse("1.0.0"), CancellationToken.None);
|
|
|
|
var result = await Applier(services).ApplyAsync(AppVersion.Parse("1.0.0"), CancellationToken.None);
|
|
|
|
Assert.Equal(UpdateOutcome.RolledBack, result.Outcome);
|
|
Assert.Equal("v1", AgentFile("SQLInsight.Agent.dll"));
|
|
Assert.Contains("customer", AgentFile("appsettings.json"), StringComparison.Ordinal);
|
|
Assert.Equal(["stop SQLInsight Agent", "start SQLInsight Agent", "stop SQLInsight Agent", "start SQLInsight Agent"], services.Calls);
|
|
Assert.Equal("v1", File.ReadAllText(Path.Combine(_feed.Root, "private", "backup", "Agent", "SQLInsight.Agent.dll")));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Applier_refuses_a_package_changed_after_staging()
|
|
{
|
|
_feed.Install();
|
|
_feed.Publish(release: _feed.Release("1.1.0"));
|
|
var checker = _feed.Checker();
|
|
await checker.StageAsync(await checker.FetchAsync(CancellationToken.None), AppVersion.Parse("1.0.0"), CancellationToken.None);
|
|
File.AppendAllText(Path.Combine(_feed.Directories.Pending, "package.zip"), "x");
|
|
var services = new FakeServices(Path.Combine(_feed.AgentFolder, "SQLInsight.Agent.dll"));
|
|
|
|
var result = await Applier(services).ApplyAsync(AppVersion.Parse("1.0.0"), CancellationToken.None);
|
|
|
|
Assert.Equal(UpdateOutcome.Failed, result.Outcome);
|
|
Assert.Empty(services.Calls);
|
|
Assert.Equal("v1", AgentFile("SQLInsight.Agent.dll"));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Applier_refuses_a_manifest_it_cannot_verify()
|
|
{
|
|
_feed.Install();
|
|
_feed.Publish(release: _feed.Release("1.1.0"));
|
|
var checker = _feed.Checker();
|
|
await checker.StageAsync(await checker.FetchAsync(CancellationToken.None), AppVersion.Parse("1.0.0"), CancellationToken.None);
|
|
var services = new FakeServices(Path.Combine(_feed.AgentFolder, "SQLInsight.Agent.dll"));
|
|
var applier = new UpdateApplier(
|
|
new UpdateApplierOptions { Channel = "stable", PrivateRoot = Path.Combine(_feed.Root, "private"), Components = [new UpdateComponent { Name = "Agent", Path = _feed.AgentFolder, Service = "a" }] },
|
|
_feed.Directories, new TrustedKeys([]), services, TimeProvider.System, _ => { });
|
|
|
|
var result = await applier.ApplyAsync(AppVersion.Parse("1.0.0"), CancellationToken.None);
|
|
|
|
Assert.Equal(UpdateOutcome.Failed, result.Outcome);
|
|
Assert.Empty(services.Calls);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Nothing_is_staged_or_applied_when_not_newer()
|
|
{
|
|
_feed.Install();
|
|
_feed.Publish(release: _feed.Release("1.0.0"));
|
|
var checker = _feed.Checker();
|
|
|
|
Assert.False(await checker.StageAsync(await checker.FetchAsync(CancellationToken.None), AppVersion.Parse("1.0.0"), CancellationToken.None));
|
|
Assert.Equal(UpdateOutcome.NothingToDo, (await Applier(new FakeServices(Path.Combine(_feed.AgentFolder, "SQLInsight.Agent.dll"))).ApplyAsync(AppVersion.Parse("1.0.0"), CancellationToken.None)).Outcome);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Release_without_a_package_for_this_platform_is_reported()
|
|
{
|
|
_feed.Publish(release: _feed.Release("1.1.0", platform: "plan9-mips"));
|
|
var checker = _feed.Checker();
|
|
|
|
var error = await Assert.ThrowsAsync<UpdateException>(async () => await checker.StageAsync(await checker.FetchAsync(CancellationToken.None), AppVersion.Parse("1.0.0"), CancellationToken.None));
|
|
|
|
Assert.Contains(Platform.Current, error.Message, StringComparison.Ordinal);
|
|
}
|
|
}
|