using System.IO.Compression; using System.Security.Cryptography; using SQLInsight.Licensing; using SQLInsight.Rules.Lifecycle; namespace SQLInsight.Updates.Tests; /// A feed folder signed with a throwaway key, plus a data folder and an installation to update. public sealed class TestFeed : IDisposable { public const string KeyId = "test"; private readonly ECDsa _key = ECDsa.Create(ECCurve.NamedCurves.nistP256); public TestFeed() { Root = Path.Combine(Path.GetTempPath(), "sqlinsight-updates-" + Guid.NewGuid().ToString("N")); Directory.CreateDirectory(FeedDirectory); Directories = new UpdateDirectories(Path.Combine(Root, "data")); Keys = new TrustedKeys([KeyValuePair.Create(KeyId, Convert.ToBase64String(_key.ExportSubjectPublicKeyInfo()))]); Feed = new UpdateFeed(FeedDirectory, null); } public string Root { get; } public string FeedDirectory => Path.Combine(Root, "feed"); public string AgentFolder => Path.Combine(Root, "install", "Agent"); public UpdateDirectories Directories { get; } public TrustedKeys Keys { get; } public UpdateFeed Feed { get; } public FileRef AddFile(string path, byte[] bytes) { var full = Path.Combine(FeedDirectory, path); Directory.CreateDirectory(Path.GetDirectoryName(full)!); File.WriteAllBytes(full, bytes); return new FileRef(path, Convert.ToHexString(SHA256.HashData(bytes)), bytes.Length); } public string Sign(UpdateManifest manifest) => SignedDocument.Sign(manifest, _key); public UpdateManifest Publish(string channel = "stable", RulePackRef? rulePack = null, AppRelease? release = null, DateTimeOffset? publishedAt = null, DateTimeOffset? expiresAt = null) { var manifest = new UpdateManifest { KeyId = KeyId, Channel = channel, PublishedAt = publishedAt ?? DateTimeOffset.UtcNow, ExpiresAt = expiresAt ?? DateTimeOffset.UtcNow.AddDays(30), RulePack = rulePack, Release = release, }; var path = Path.Combine(FeedDirectory, channel, UpdateFeed.ManifestFile); Directory.CreateDirectory(Path.GetDirectoryName(path)!); File.WriteAllText(path, Sign(manifest)); return manifest; } public RulePackRef RulePack(Version version) { var root = RepositoryRoot(); var bytes = Rules.Lifecycle.RulePack.Create(version, File.ReadAllBytes(Path.Combine(root, "src", "SQLInsight.Rules", "Lifecycle", "build-catalog.json")), File.ReadAllBytes(Path.Combine(root, "src", "SQLInsight.Rules", "Lifecycle", "support-lifecycle.json"))); return new RulePackRef(version.ToString(), AddFile($"rulepacks/rulepack-{version}.json", bytes)); } /// A release whose Agent folder holds the given program file content. public AppRelease Release(string version, string program = "v2", string? platform = null) { using var buffer = new MemoryStream(); using (var zip = new ZipArchive(buffer, ZipArchiveMode.Create, leaveOpen: true)) { Add(zip, "Agent/SQLInsight.Agent.dll", program); Add(zip, "Agent/appsettings.json", "{ \"from\": \"package\" }"); Add(zip, "Agent/runtimes/new.dll", "new"); Add(zip, "Web/SQLInsight.Web.dll", "web"); } return new AppRelease { Version = version, Released = new DateOnly(2026, 10, 1), Assets = [new ReleaseAsset(platform ?? Platform.Current, AddFile($"releases/sqlinsight-{version}.zip", buffer.ToArray()))], }; } public void Install(string program = "v1") { Directory.CreateDirectory(AgentFolder); File.WriteAllText(Path.Combine(AgentFolder, "SQLInsight.Agent.dll"), program); File.WriteAllText(Path.Combine(AgentFolder, "appsettings.json"), "{ \"from\": \"customer\" }"); } public UpdateChecker Checker(string channel = "stable") => new(Feed, Keys, Directories, channel, TimeProvider.System); public void Dispose() { _key.Dispose(); if (Directory.Exists(Root)) { Directory.Delete(Root, recursive: true); } } private static void Add(ZipArchive zip, string name, string content) { using var writer = new StreamWriter(zip.CreateEntry(name).Open()); writer.Write(content); } 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."); } } /// Services that "crash" when the installed program file says so. public sealed class FakeServices(string programFile) : IServiceControl { public List Calls { get; } = []; public Task StopAsync(string service, CancellationToken cancellationToken) { Calls.Add("stop " + service); return Task.CompletedTask; } public Task StartAsync(string service, CancellationToken cancellationToken) { Calls.Add("start " + service); return Task.CompletedTask; } public Task IsRunningAsync(string service, CancellationToken cancellationToken) => Task.FromResult(File.ReadAllText(programFile) != "broken"); }