Some checks are pending
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 / Build og unit tests (push) Waiting to run
CI / MSI-installer (push) Blocked by required conditions
CI / Integration (mcr.microsoft.com/mssql/server:2017-latest) (push) Blocked by required conditions
CI / Integration (mcr.microsoft.com/mssql/server:2025-latest) (push) Blocked by required conditions
installer/SQLInsight.wxs (WiX 3 syntax) builds on Linux with wixl (installer/build-msi.sh) and in Forgejo CI on every v* tag. Agent and Web are installed self-contained as Windows services under virtual service accounts (or a gMSA), with a settings page in the wizard or MSI properties for silent installs; the values go to sqlinsight.ini, read after appsettings.json. The data folder is locked to SYSTEM, Administrators and the agent account, the event log source is registered, the web port is opened in the firewall, and the automatic update task is an optional feature. Upgrades happen in place, keep edited settings and the chosen features; uninstall keeps the settings. msi-tables.py writes what wixl lacks (component and feature conditions, Permanent/NeverOverwrite, vital services, REG_EXPAND_SZ) and verify-msi.py checks the result (sequencing, deferred actions, ASCII only). test-msi-wine.sh runs install, upgrade and uninstall under Wine. Installations made with the MSI get new versions as MSI packages run by the update task, so Windows Installer stays in charge of the files. The example instance is removed from the shipped appsettings.json, and the CLI writes UTF-8 to the console. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E3EExwndy8Z22EK2Qpt6BK
300 lines
14 KiB
C#
300 lines
14 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, string? platform = null, IMsiInstaller? msi = null) => new(
|
|
new UpdateApplierOptions
|
|
{
|
|
Channel = "stable",
|
|
Platform = platform ?? Platform.Current,
|
|
Msi = msi ?? new FakeMsi(0),
|
|
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", Platform = Platform.Current, 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);
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData(0, UpdateOutcome.Applied)]
|
|
[InlineData(3010, UpdateOutcome.Applied)]
|
|
[InlineData(1603, UpdateOutcome.RolledBack)]
|
|
public async Task Msi_installations_are_updated_with_the_new_msi(int exitCode, UpdateOutcome expected)
|
|
{
|
|
_feed.Install();
|
|
_feed.Publish(release: _feed.MsiRelease("1.1.0"));
|
|
var checker = _feed.Checker(platform: "win-x64-msi");
|
|
await checker.StageAsync(await checker.FetchAsync(CancellationToken.None), AppVersion.Parse("1.0.0"), CancellationToken.None);
|
|
var msi = new FakeMsi(exitCode);
|
|
var services = new FakeServices(Path.Combine(_feed.AgentFolder, "SQLInsight.Agent.dll"));
|
|
|
|
var result = await Applier(services, "win-x64-msi", msi).ApplyAsync(AppVersion.Parse("1.0.0"), CancellationToken.None);
|
|
|
|
Assert.Equal(expected, result.Outcome);
|
|
var (path, content) = Assert.Single(msi.Installed);
|
|
Assert.EndsWith("SQLInsight-Pro-1.1.0.msi", path, StringComparison.Ordinal);
|
|
Assert.Equal([0xD0, 0xCF, 0x11, 0xE0, 4, 5, 6], content);
|
|
Assert.Empty(services.Calls); // Windows Installer stops and starts the services itself
|
|
Assert.Equal("v1", AgentFile("SQLInsight.Agent.dll"));
|
|
Assert.False(Directory.Exists(_feed.Directories.Pending));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Msi_package_changed_after_staging_is_not_installed()
|
|
{
|
|
_feed.Publish(release: _feed.MsiRelease("1.1.0"));
|
|
var checker = _feed.Checker(platform: "win-x64-msi");
|
|
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 msi = new FakeMsi(0);
|
|
|
|
var result = await Applier(new FakeServices(Path.Combine(_feed.Root, "none")), "win-x64-msi", msi).ApplyAsync(AppVersion.Parse("1.0.0"), CancellationToken.None);
|
|
|
|
Assert.Equal(UpdateOutcome.Failed, result.Outcome);
|
|
Assert.Empty(msi.Installed);
|
|
}
|
|
}
|