using System.Net; using System.Security.Cryptography; using System.Text; using DodoSSH.Client.App.Platform; namespace DodoSSH.Client.App.Tests; /// /// The RPM update channel: what it offers, what it refuses, and what it hands on. /// /// /// /// Driven against a stubbed HttpMessageHandler rather than a server, because every question here is /// about what this class decides from what the forge said — and a real socket would add a port to bind and /// nothing to the assertions. /// /// /// The two that matter are the version comparison and the digest. The first is what keeps a rolling /// nightly from offering a downgrade PackageKit would decline, forever, every six hours. The second is the /// only thing standing between a truncated download and a file handed to the machine's package manager — /// Velopack verifies its own downloads and Android checks a signature, and an RPM over HTTPS has neither. /// /// public sealed class LinuxUpdateChannelTests : IDisposable { private const string ReleaseUrl = "https://git.dodotech.cloud/api/v1/repos/DodoTech-Public/DodoSSH/releases/tags/nightly-desktop"; private const string ManifestUrl = "https://forge.invalid/linux-nightly.json"; private const string RpmUrl = "https://forge.invalid/dodossh-nightly.rpm"; private readonly string directory = Path.Combine(Path.GetTempPath(), $"dodossh-updates-{Guid.CreateVersion7():N}"); private static CancellationToken Token => TestContext.Current.CancellationToken; public void Dispose() { if (Directory.Exists(directory)) { Directory.Delete(directory, recursive: true); } } [Fact] public async Task ANewerPublishedVersionIsOffered() { var package = RandomNumberGenerator.GetBytes(2048); var channel = Channel(package, published: "0.0.1~alpha.0.200-1", installed: "0.0.1~alpha.0.143-1"); var update = await channel.CheckAsync(Token); update.ShouldNotBeNull(); update.Version.ShouldBe("0.0.1-alpha.0.200"); } [Fact] public async Task TheSameVersionIsNotOffered() { var channel = Channel( RandomNumberGenerator.GetBytes(16), published: "0.0.1~alpha.0.143-1", installed: "0.0.1~alpha.0.143-1"); (await channel.CheckAsync(Token)).ShouldBeNull(); } /// /// An older published build is not offered, which is the failure this channel is most exposed to. /// /// /// The nightly release is rolling and CI replaces it on every push, so a revert or a re-run of an older /// commit publishes a lower version. Comparing "is it different from mine" would offer it, PackageKit /// would decline the downgrade, and the user would see a failing update every six hours until somebody /// pushed again. /// [Fact] public async Task AnOlderPublishedVersionIsNotOffered() { var channel = Channel( RandomNumberGenerator.GetBytes(16), published: "0.0.1~alpha.0.99-1", installed: "0.0.1~alpha.0.143-1"); (await channel.CheckAsync(Token)).ShouldBeNull(); } /// /// A machine that cannot say what it has treats everything as newer. /// /// /// The wrong way to fail, chosen deliberately and for the reason the android channel gives: the cost is /// an install the package manager declines, and the cost of the other direction is a security fix that /// never arrives. /// [Fact] public async Task AnUnknownInstalledVersionTreatsEverythingAsNewer() { var channel = Channel(RandomNumberGenerator.GetBytes(16), published: "0.0.1~alpha.0.1-1", installed: null); (await channel.CheckAsync(Token)).ShouldNotBeNull(); } [Fact] public async Task AGoodDownloadIsKeptAndHandedToTheInstaller() { var package = RandomNumberGenerator.GetBytes(4096); var handed = new List(); var channel = Channel( package, published: "0.0.1~alpha.0.200-1", installed: "0.0.1~alpha.0.143-1", handToInstaller: handed.Add); var update = await channel.CheckAsync(Token); await channel.DownloadAsync(update!, new Progress(), Token); channel.ApplyAndRestart(update!); handed.Count.ShouldBe(1); (await File.ReadAllBytesAsync(handed[0], Token)).ShouldBe(package); } /// /// A package whose digest does not match is refused, deleted, and never named to the installer. /// /// /// Deleted rather than merely refused, because the alternative is a file called /// dodossh-nightly-update.rpm sitting in the profile directory that somebody could reasonably /// install by hand. /// [Fact] public async Task ADownloadThatDoesNotMatchIsRefusedAndRemoved() { var handed = new List(); var channel = Channel( RandomNumberGenerator.GetBytes(4096), published: "0.0.1~alpha.0.200-1", installed: "0.0.1~alpha.0.143-1", digest: Convert.ToHexStringLower(RandomNumberGenerator.GetBytes(32)), handToInstaller: handed.Add); var update = await channel.CheckAsync(Token); await Should.ThrowAsync( async () => await channel.DownloadAsync(update!, new Progress(), Token)); handed.ShouldBeEmpty(); Directory.Exists(Path.Combine(directory, "updates")).ShouldBeTrue(); Directory.GetFiles(Path.Combine(directory, "updates")).ShouldBeEmpty(); } [Fact] public async Task AnUpdateFromNoCheckIsRefused() { var channel = Channel(RandomNumberGenerator.GetBytes(16), "0.0.1~alpha.0.2-1", "0.0.1~alpha.0.1-1"); await Should.ThrowAsync( async () => await channel.DownloadAsync( new DodoSSH.Client.Session.AvailableUpdate("0.0.1-alpha.0.2"), new Progress(), Token)); } /// /// A release that is reachable and carries no manifest throws rather than answering "up to date". /// /// /// Half a published release is a fact worth an answer — IUpdateChannel.CheckAsync spends several /// paragraphs on why null must mean exactly one thing, after a private repository turned a total outage /// into a reassuring sentence on every client for weeks. /// [Fact] public async Task AReleaseWithNoManifestThrows() { var channel = new LinuxUpdateChannel( "nightly", directory, new HttpClient(new StubForge(new Dictionary(StringComparer.Ordinal) { [ReleaseUrl] = Encoding.UTF8.GetBytes("""{"assets":[]}"""), })), _ => "0.0.1~alpha.0.1-1", _ => { }); await Should.ThrowAsync(async () => await channel.CheckAsync(Token)); } private LinuxUpdateChannel Channel( byte[] package, string published, string? installed, string? digest = null, Action? handToInstaller = null) { // The SemVer the banner shows is derived from the rpm version the comparison uses, so the two // cannot disagree in a way this suite would miss. var semver = published[..published.LastIndexOf('-')].Replace('~', '-'); var manifest = $$""" { "rpmVersion": "{{published}}", "version": "{{semver}}", "rpm": "dodossh-nightly.rpm", "sha256": "{{digest ?? Convert.ToHexStringLower(SHA256.HashData(package))}}" } """; var release = $$""" {"assets":[ {"name":"linux-nightly.json","browser_download_url":"{{ManifestUrl}}"}, {"name":"dodossh-nightly.rpm","browser_download_url":"{{RpmUrl}}"} ]} """; return new LinuxUpdateChannel( "nightly", directory, new HttpClient(new StubForge(new Dictionary(StringComparer.Ordinal) { [ReleaseUrl] = Encoding.UTF8.GetBytes(release), [ManifestUrl] = Encoding.UTF8.GetBytes(manifest), [RpmUrl] = package, })), _ => installed, handToInstaller ?? (_ => { })); } /// Answers the three URLs this channel asks for, and 404s everything else. private sealed class StubForge(IReadOnlyDictionary content) : HttpMessageHandler { protected override Task SendAsync( HttpRequestMessage request, CancellationToken cancellationToken) => Task.FromResult( content.TryGetValue(request.RequestUri!.AbsoluteUri, out var bytes) ? new HttpResponseMessage(HttpStatusCode.OK) { Content = new ByteArrayContent(bytes) } : new HttpResponseMessage(HttpStatusCode.NotFound)); } }