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(() => ManifestVerifier.Verify($"{parts[0]}.{forged}.{parts[2]}", _feed.Keys, "preview", now)); Assert.Throws(() => ManifestVerifier.Verify(SignedDocument.Sign(manifest, otherKey), _feed.Keys, "stable", now)); Assert.Throws(() => 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(() => ManifestVerifier.Verify(token, _feed.Keys, "stable", now)).Message, StringComparison.Ordinal); Assert.Contains("udløb", Assert.Throws(() => ManifestVerifier.Verify(token, _feed.Keys, "preview", now)).Message, StringComparison.Ordinal); Assert.Contains("ældre", Assert.Throws(() => 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(() => 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(() => 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(() => 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(() => _feed.Checker().FetchAsync(CancellationToken.None)); } [Fact] public void Plain_http_feed_is_refused() { using var http = new HttpClient(); Assert.Throws(() => 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(async () => await checker.StageAsync(await checker.FetchAsync(CancellationToken.None), AppVersion.Parse("1.0.0"), CancellationToken.None)); Assert.Contains(Platform.Current, error.Message, StringComparison.Ordinal); } }