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 is told so, and is offered nothing. /// /// /// This used to read the silence as 0 and offer everything, on the argument that the cost was /// an install the package manager declined. On a machine with no rpm at all — a pacman package of the /// same tree at the same path — that was every nightly, every six hours, as a 56 MB download nothing /// could install. Unknown is not older. /// [Fact] public async Task AnUnknownInstalledVersionThrowsRatherThanOfferingAnything() { var handed = new List(); var channel = Channel( RandomNumberGenerator.GetBytes(16), published: "0.0.1~alpha.0.200-1", installed: null, handToInstaller: handed.Add); await Should.ThrowAsync(async () => await channel.CheckAsync(Token)); await Should.ThrowAsync( async () => await channel.DownloadAsync( new DodoSSH.Client.Session.AvailableUpdate("0.0.1-alpha.0.200"), new Progress(), Token)); handed.ShouldBeEmpty(); Directory.Exists(Path.Combine(directory, "updates")).ShouldBeFalse(); } /// /// A copy rpm does not own gets no channel at all, and a sentence saying whose it is to update. /// /// /// The case that matters: the right path and no rpm — a locally built pacman package that mirrors the /// spec. No binary and no such package both arrive here as null. /// [Fact] public void ACopyRpmDoesNotOwn_IsUnavailableAndSaysWhy() { var asked = new List(); var updates = LinuxUpdateChannel.ForInstall( "nightly", package => { asked.Add(package); return null; }, () => throw new InvalidOperationException("Built a channel for a copy rpm does not own.")); asked.ShouldBe(["dodossh-nightly"]); updates.IsSupported.ShouldBeFalse(); var reason = updates.ShouldBeOfType().Reason; reason.ShouldNotBeNull(); reason.ShouldContain("outside the dodossh-nightly RPM"); reason.ShouldContain("package manager that installed it"); } [Fact] public void ACopyRpmOwns_GetsTheChannel() { var asked = new List(); var owned = new FakeUpdateChannel(); var updates = LinuxUpdateChannel.ForInstall( "release", package => { asked.Add(package); return "0.0.1-1"; }, () => owned); asked.ShouldBe(["dodossh"]); updates.ShouldBeSameAs(owned); } [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)); } }