Files
DodoSSH/tests/DodoSSH.Client.App.Tests/LinuxUpdateChannelTests.cs
laurensandClaude Opus 5.5 e0509c6679 Updates: offer an RPM only to a copy rpm owns, and never read an unknown version as older
On a distribution without rpm, a locally built package that mirrors the spec puts the nightly tree
at /opt/DodoSSH.Nightly, and that path was the whole test for "the RPM installed this". The channel
then asked rpm for the installed version, got nothing because there is no rpm, read the silence as
"0", and offered every nightly as newer, fetching a 56 MB RPM into the profile directory that the
machine could never install, every six hours.

UpdateChannels.LinuxChannel still checks the path first, then asks rpm through the new
LinuxUpdateChannel.ForInstall. A copy rpm has no record of gets an UnavailableUpdateChannel that
says it was installed outside the dodossh-nightly (or dodossh) RPM and should be updated with the
package manager that installed it. UnavailableUpdateChannel gains an optional Reason for that,
UpdateViewModel exposes it as UnsupportedReason, and the desktop preferences page shows it in place
of the sentence about checkouts and unzipped copies. That costs one rpm launch at start, and only
where the path already matched.

CheckAsync no longer falls back to "0". If rpm stops answering after composition, the check throws
instead, so CHECK NOW reports it and the timer stays quiet, and nothing is offered or fetched. The
comment that called the fallback a deliberate choice is replaced with the reason it was wrong.

LinuxUpdateChannelTests: an unknown installed version throws and downloads nothing (replacing the
test that asserted the old behaviour), a copy rpm does not own is unavailable and says why, and a
copy rpm owns gets the channel. The newer, equal and older comparisons were already covered.
UpdateFlowTests covers the reason reaching the view model. Manual check 19.13.

App tests 744/752 with 8 skips, layout tests 202/203 with 1 skip; the full solution passes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 16:39:17 +02:00

311 lines
11 KiB
C#

using System.Net;
using System.Security.Cryptography;
using System.Text;
using DodoSSH.Client.App.Platform;
namespace DodoSSH.Client.App.Tests;
/// <summary>
/// The RPM update channel: what it offers, what it refuses, and what it hands on.
/// </summary>
/// <remarks>
/// <para>
/// Driven against a stubbed <c>HttpMessageHandler</c> 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.
/// </para>
/// <para>
/// <b>The two that matter are the version comparison and the digest.</b> 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.
/// </para>
/// </remarks>
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();
}
/// <summary>
/// An older published build is not offered, which is the failure this channel is most exposed to.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
[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();
}
/// <summary>
/// A machine that cannot say what it has is told so, and is offered nothing.
/// </summary>
/// <remarks>
/// This used to read the silence as <c>0</c> 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.
/// </remarks>
[Fact]
public async Task AnUnknownInstalledVersionThrowsRatherThanOfferingAnything()
{
var handed = new List<string>();
var channel = Channel(
RandomNumberGenerator.GetBytes(16),
published: "0.0.1~alpha.0.200-1",
installed: null,
handToInstaller: handed.Add);
await Should.ThrowAsync<InvalidOperationException>(async () => await channel.CheckAsync(Token));
await Should.ThrowAsync<InvalidOperationException>(
async () => await channel.DownloadAsync(
new DodoSSH.Client.Session.AvailableUpdate("0.0.1-alpha.0.200"),
new Progress<int>(),
Token));
handed.ShouldBeEmpty();
Directory.Exists(Path.Combine(directory, "updates")).ShouldBeFalse();
}
/// <summary>
/// A copy rpm does not own gets no channel at all, and a sentence saying whose it is to update.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
[Fact]
public void ACopyRpmDoesNotOwn_IsUnavailableAndSaysWhy()
{
var asked = new List<string>();
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<DodoSSH.Client.Session.UnavailableUpdateChannel>().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<string>();
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<string>();
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<int>(), Token);
channel.ApplyAndRestart(update!);
handed.Count.ShouldBe(1);
(await File.ReadAllBytesAsync(handed[0], Token)).ShouldBe(package);
}
/// <summary>
/// A package whose digest does not match is refused, deleted, and never named to the installer.
/// </summary>
/// <remarks>
/// Deleted rather than merely refused, because the alternative is a file called
/// <c>dodossh-nightly-update.rpm</c> sitting in the profile directory that somebody could reasonably
/// install by hand.
/// </remarks>
[Fact]
public async Task ADownloadThatDoesNotMatchIsRefusedAndRemoved()
{
var handed = new List<string>();
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<InvalidOperationException>(
async () => await channel.DownloadAsync(update!, new Progress<int>(), 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<InvalidOperationException>(
async () => await channel.DownloadAsync(
new DodoSSH.Client.Session.AvailableUpdate("0.0.1-alpha.0.2"),
new Progress<int>(),
Token));
}
/// <summary>
/// A release that is reachable and carries no manifest throws rather than answering "up to date".
/// </summary>
/// <remarks>
/// Half a published release is a fact worth an answer — <c>IUpdateChannel.CheckAsync</c> 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.
/// </remarks>
[Fact]
public async Task AReleaseWithNoManifestThrows()
{
var channel = new LinuxUpdateChannel(
"nightly",
directory,
new HttpClient(new StubForge(new Dictionary<string, byte[]>(StringComparer.Ordinal)
{
[ReleaseUrl] = Encoding.UTF8.GetBytes("""{"assets":[]}"""),
})),
_ => "0.0.1~alpha.0.1-1",
_ => { });
await Should.ThrowAsync<InvalidOperationException>(async () => await channel.CheckAsync(Token));
}
private LinuxUpdateChannel Channel(
byte[] package,
string published,
string? installed,
string? digest = null,
Action<string>? 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<string, byte[]>(StringComparer.Ordinal)
{
[ReleaseUrl] = Encoding.UTF8.GetBytes(release),
[ManifestUrl] = Encoding.UTF8.GetBytes(manifest),
[RpmUrl] = package,
})),
_ => installed,
handToInstaller ?? (_ => { }));
}
/// <summary>Answers the three URLs this channel asks for, and 404s everything else.</summary>
private sealed class StubForge(IReadOnlyDictionary<string, byte[]> content) : HttpMessageHandler
{
protected override Task<HttpResponseMessage> 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));
}
}