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));
}
}