Files
DodoSSH/tests/DodoSSH.Client.App.Tests/UpdateFlowTests.cs
T
jaap-jan 6728a0a597 Let the desktop client replace itself, and give the repository one version
Packaging for Windows, and the updater that only exists once something is
packaged. Velopack, win-x64, fed from the project's own forge — never from the
deployment a client signs in to, which is ADR 0011 rule 2 carried over
unchanged and is why the feed address is a constant in the code rather than a
setting. See docs/adr/0012-desktop-distribution-and-updates.md.

**Nothing is ever installed while somebody is using it.** A newer build is found
on a six-hourly pass, downloaded in the background, and then waits — for a
restart the user presses, or for the next launch they were going to do anyway.
That is a policy rather than caution: this application argues at length that
locking keeps shells running, because a lock that destroyed work would stop
being used, and a restart does not keep them. Having taught that, it owes the
user the choice at the one moment it stops being true, and the sentence saying
so counts the shells it would close.

**The version is now derived from the v* tag**, by MinVer, for everything. There
was no version before this — no property anywhere, so every assembly reported
the SDK's 1.0.0 and the API served that string as its serverVersion to every
client that asked. The tag was already the version of record for the container
image; this makes it the version of record full stop. MinVer's failure mode is
answering plausibly rather than failing, and here a wrong version is a client
that never updates, so it is guarded twice: fetch-depth 0 on every checkout, and
a step that fails a tag build when the tag and the computed version disagree.

**The pack id is DodoSSH.Desktop and not DodoSSH**, which is the one decision
here that would have destroyed data. Velopack installs to %LOCALAPPDATA%\<packId>
and removes that whole directory on uninstall, and %LOCALAPPDATA%\DodoSSH is
where ClientPaths keeps the encrypted cache, the outbox of changes not yet
pushed, and the device key. The obvious id would have had the uninstaller
silently delete work the server has never seen — the thing the application
refuses to do without a counted confirmation. Velopack's own advice to move user
data to roaming %APPDATA% is declined for the reason ClientPaths already gives.

**Releases are cut by a person, and CI gains no job that could.** The tempting
argument is that a forge write token is not a signing key. It does not survive
contact with what the token does: Velopack clients trust their feed and do not
verify a package signature when they apply one, so whoever can write a release
can ship an update every install runs. That is the capability ADR 0011 rule 1
puts on a machine which is not a runner, reached through a different door. The
mechanical objection — vpk needs Windows and the runners are Linux — is the
smaller of the two and is recorded beside it, because somebody will fix one and
believe they are done.

Unsigned for now, deliberately and with the cost stated where a user reads it:
SmartScreen warns once per person, on Setup.exe, because Mark-of-the-Web is
applied by the browser that downloaded it. In-app updates are fetched by the
application and applied from a local file, and never trip it.

The banner is a fourth row of the window rather than an overlay. Anything drawn
in the terminal's rectangle is sliced by the native child window that composites
above it — the defect this window has shipped once — and a sibling row is the
arrangement TitleBar and StatusBar already prove works.

----

Three defects surfaced on the way, none of them in the feature being built.

**A settings key absent from the file came back as the CLR default, not the
declared one.** The JSON source generator builds a record through a synthesised
parameterised constructor and assigns every property from its argument array, so
a property initializer runs and is then overwritten by a default for anything the
file did not contain. A settings.json of {} read back a font size of 0, clamped
up to the 8px floor rather than the 13px the renderer draws at. It could not bite
while there was one setting, because that setting was written on every save and
so was never absent; adding a second would have turned automatic update checks
off for every existing profile, silently, the opposite of the documented default.
Reflection-based deserialisation of the same JSON answers correctly, which is why
every way of checking it by hand agrees except the one that ships. The defaults
now live on the constructor parameters, which is the only place the generator
reads them from.

**Declaring a RuntimeIdentifier on the desktop head broke the server's image
build.** It is the obvious way to let a self-contained publish restore under
locked mode, and it writes a net10.0/win-x64 target into the lock file of every
project the head references transitively — including DodoSSH.Contracts and
DodoSSH.Crypto, which the API builds too. The Dockerfile restores those with no
RID and fails NU1004. Found by running docker build rather than by reading. The
RID stays out of the committed state; the two commands that need one ask for it
unlocked, and the release script puts the lock files back.

**A Docker ARG named VERSION silently sets MSBuild's Version.** An ARG is an
environment variable for the rest of the stage, MSBuild reads environment
variables as properties, and property names are case-insensitive. With the
workflow passing main-<short sha> on a main build the publish died with
NETSDK1018 pointing at DodoSSH.Contracts, a project nobody had touched. The build
stage's argument is ASSEMBLY_VERSION now, empty except on a tag build.

All three are in docs/platform-flags.md, which is where the next person will look.

----

Verified: the whole solution builds and restores locked; 289 shell, 93 layout and
54 session tests pass, including the regression test for the settings defect and
a measurement of the banner at the window's minimum width. vpk pack runs end to
end and reports "Verified VelopackApp.Run()" against Program.Main. The API image
builds correctly both as a main build and as a tag build, carrying 1.0.0 and
0.1.0 respectively.

Not verified, and it needs a published release to be: installing, updating and
uninstalling on a real machine. That is Phase 15 of docs/manual-checks.md, and
the pack id and the WebView2 profile fix are reasoned and commented but only
proved by walking it. Two things to watch at the first upload — the reverse
proxy's body-size limit for a 64 MB asset, and whether vpk upload gitea is happy
with Gitea 1.27.1.
2026-08-04 17:04:41 +02:00

392 lines
13 KiB
C#

using DodoSSH.Client.Session;
using DodoSSH.Client.Shell.ViewModels;
namespace DodoSSH.Client.App.Tests;
/// <summary>
/// Finding a newer build, fetching it, and not installing it until somebody says so.
/// </summary>
/// <remarks>
/// <para>
/// The view model is driven a pass at a time through <c>CheckOnceAsync</c> rather than through its timer,
/// which is the same split <c>VaultViewModel</c> makes between its sync pass and its sync loop and for the
/// same reason: a suite that waited on a <c>PeriodicTimer</c> would be testing the clock.
/// </para>
/// <para>
/// The load-bearing test here is <see cref="AReadyUpdate_IsNeverAppliedOnItsOwn"/>. Everything else is
/// about how the feature behaves; that one is about the promise it makes.
/// </para>
/// </remarks>
public sealed class UpdateFlowTests : IDisposable
{
private static CancellationToken Token => TestContext.Current.CancellationToken;
private readonly string directory =
Path.Combine(Path.GetTempPath(), $"dodossh-updates-{Guid.CreateVersion7():N}");
private readonly FakeUpdateChannel channel = new();
private int liveSessions;
private int restarts;
private ClientPaths Paths => new(directory);
/// <remarks>
/// The real settings store over a real temporary directory, not a stand-in. Persistence is one of the
/// things being asserted, and the file is the thing that persists.
/// </remarks>
private UpdateViewModel Build() =>
new(
channel,
new ClientSettingsStore(Paths),
TimeProvider.System,
() => liveSessions,
restart: update =>
{
restarts++;
channel.ApplyAndRestart(update);
return Task.CompletedTask;
});
/// <inheritdoc />
public void Dispose()
{
if (Directory.Exists(directory))
{
Directory.Delete(directory, recursive: true);
}
}
[Fact]
public void ABuildThatCannotReplaceItself_OffersNothing()
{
channel.IsSupported = false;
var updates = Build();
updates.State.ShouldBe(UpdateState.Unsupported);
updates.IsUnsupported.ShouldBeTrue();
updates.CanCheckNow.ShouldBeFalse();
updates.IsBannerShowing.ShouldBeFalse();
}
[Fact]
public void ABuildThatCannotReplaceItself_StillKnowsItsOwnVersion()
{
channel.IsSupported = false;
channel.CurrentVersion = "0.4.2";
Build().CurrentVersion.ShouldBe("0.4.2");
}
[Fact]
public async Task ABackgroundPassThatFindsNothing_SaysNothing()
{
var updates = Build();
await updates.CheckOnceAsync(Token);
channel.Checks.ShouldBe(1);
updates.State.ShouldBe(UpdateState.Idle);
updates.Status.ShouldBeEmpty();
}
[Fact]
public async Task APressedCheckThatFindsNothing_SaysSo()
{
channel.CurrentVersion = "1.2.0";
var updates = Build();
await updates.CheckNowCommand.ExecuteAsync(null);
updates.State.ShouldBe(UpdateState.Idle);
updates.Status.ShouldContain("1.2.0");
}
[Fact]
public async Task AnUpdateThatIsFound_IsFetchedWithoutBeingAskedAbout()
{
channel.Available = new AvailableUpdate("1.3.0");
var updates = Build();
await updates.CheckOnceAsync(Token);
channel.Downloads.ShouldBe(1);
updates.State.ShouldBe(UpdateState.Ready);
updates.ReadyVersion.ShouldBe("1.3.0");
updates.DownloadPercent.ShouldBe(100);
updates.IsBannerShowing.ShouldBeTrue();
}
/// <remarks>
/// The whole policy in one assertion. A fetched update sits until a person presses the button, or until
/// the application is next started for their own reasons — because a restart ends every shell, and this
/// application has spent a lot of design effort on shells surviving a lock.
/// </remarks>
[Fact]
public async Task AReadyUpdate_IsNeverAppliedOnItsOwn()
{
channel.Available = new AvailableUpdate("1.3.0");
var updates = Build();
await updates.CheckOnceAsync(Token);
updates.State.ShouldBe(UpdateState.Ready);
channel.Restarts.ShouldBe(0);
restarts.ShouldBe(0);
}
[Fact]
public async Task RestartingApplies_TheUpdateThatWasFound()
{
channel.Available = new AvailableUpdate("1.3.0");
var updates = Build();
await updates.CheckOnceAsync(Token);
await updates.RestartNowCommand.ExecuteAsync(null);
channel.Restarts.ShouldBe(1);
channel.RestartedWith!.Version.ShouldBe("1.3.0");
}
[Fact]
public async Task RestartingWithNothingReady_DoesNothing()
{
var updates = Build();
await updates.RestartNowCommand.ExecuteAsync(null);
channel.Restarts.ShouldBe(0);
}
[Fact]
public async Task DismissingTheBanner_KeepsTheOfferOnPreferences()
{
channel.Available = new AvailableUpdate("1.3.0");
var updates = Build();
await updates.CheckOnceAsync(Token);
updates.DismissBannerCommand.Execute(null);
updates.IsBannerShowing.ShouldBeFalse();
updates.IsReady.ShouldBeTrue();
}
/// <remarks>
/// A forge that cannot be reached is a laptop on a train. Nothing asked, so nothing is said, and the
/// state goes back to Idle rather than to Failed — Failed is reserved for something a person is waiting
/// on an answer to.
/// </remarks>
[Fact]
public async Task ABackgroundCheckThatFails_IsNotAnnounced()
{
channel.CheckFailure = new HttpRequestException("no such host");
var updates = Build();
await updates.CheckOnceAsync(Token);
updates.State.ShouldBe(UpdateState.Idle);
updates.Status.ShouldBeEmpty();
}
[Fact]
public async Task APressedCheckThatFails_SaysWhy()
{
channel.CheckFailure = new HttpRequestException("no such host");
var updates = Build();
await updates.CheckNowCommand.ExecuteAsync(null);
updates.State.ShouldBe(UpdateState.Failed);
updates.Status.ShouldBe("no such host");
}
[Fact]
public async Task TurningOffAutomaticChecks_StopsThePassDoingAnything()
{
channel.Available = new AvailableUpdate("1.3.0");
var updates = Build();
updates.IsAutomatic = false;
await updates.CheckOnceAsync(Token);
channel.Checks.ShouldBe(0);
}
/// <remarks>
/// A second view model over the same <see cref="ClientPaths"/>, which is how this repository tests that
/// a preference reached the disk — the same shape as the terminal font size's own persistence test.
/// </remarks>
[Fact]
public void TurningOffAutomaticChecks_IsStillOffOnTheNextLaunch()
{
var first = Build();
first.IsAutomatic.ShouldBeTrue();
first.IsAutomatic = false;
Build().IsAutomatic.ShouldBeFalse();
}
/// <summary>Storing one preference does not discard the others.</summary>
/// <remarks>
/// <para>
/// Pins the read-modify-write in <c>OnIsAutomaticChanged</c>, which nothing else covered: writing this
/// setting reads the file first, so a font size chosen earlier is still there afterwards. Writing the
/// view model's own state instead would silently reset every preference it does not hold.
/// </para>
/// <para>
/// Note the limit of what read-modify-write buys here, because the comment it guards is easy to read
/// as promising more. It preserves settings <em>this build knows about</em>. A key written by a newer
/// build is dropped, because the store deserialises with
/// <c>JsonUnmappedMemberHandling.Skip</c> — unmapped members are skipped rather than carried, so they
/// do not survive a round trip. That is asserted below rather than left as an assumption, so that
/// anybody who needs forward-compatibility discovers the cost here instead of in the field.
/// </para>
/// </remarks>
[Fact]
public void StoringOnePreference_KeepsTheOthersThisBuildKnows()
{
Directory.CreateDirectory(directory);
File.WriteAllText(
Paths.SettingsFile,
"""{"terminalFontSize":19,"somethingOnlyANewerBuildKnows":"keep me"}""");
Build().IsAutomatic = false;
var reread = new ClientSettingsStore(Paths).Read();
reread.TerminalFontSize.ShouldBe(19);
reread.AutomaticUpdateChecks.ShouldBeFalse();
// The honest limit, stated as an assertion: an unmapped key does not survive.
File.ReadAllText(Paths.SettingsFile).ShouldNotContain("somethingOnlyANewerBuildKnows");
}
/// <summary>
/// A setting missing from the file reads back as the default the record declares.
/// </summary>
/// <remarks>
/// <para>
/// This is the regression test for a defect that was already in the tree and could not bite until a
/// second preference existed. The JSON source generator builds a record through a synthesised
/// parameterised constructor and assigns <em>every</em> property from its argument array, so a member
/// absent from the file arrived as the CLR default and overwrote whatever a property initializer had
/// set. A settings file of <c>{}</c> read back a font size of 0 — clamped to the 8px floor rather than
/// the 13px the renderer draws at — and, once it existed, update checks off.
/// </para>
/// <para>
/// It is asserted on both settings, not just the new one, because the mechanism has nothing to do with
/// either: it is a property of how this record is deserialised, and the next preference somebody adds
/// inherits it. The fix is that the defaults live on the constructor parameters, which is the only
/// place the generator reads them from.
/// </para>
/// <para>
/// A file that exists and lacks the key is the case that matters, and it is not the same as no file at
/// all — <c>Read</c> short-circuits to a fresh record when the file is missing, which is why the bug
/// hid. Every machine that has ever run this application has a settings.json without the newer key.
/// </para>
/// </remarks>
[Fact]
public void ASettingAbsentFromTheFile_ComesBackAsItsDeclaredDefault()
{
Directory.CreateDirectory(directory);
var store = new ClientSettingsStore(Paths);
File.WriteAllText(Paths.SettingsFile, "{}");
var fromEmptyObject = store.Read();
fromEmptyObject.AutomaticUpdateChecks.ShouldBeTrue();
fromEmptyObject.TerminalFontSize.ShouldBe(ClientSettings.DefaultTerminalFontSize);
// The realistic shape: a file written by the build before this feature existed.
File.WriteAllText(Paths.SettingsFile, """{"terminalFontSize":19}""");
var fromOlderBuild = store.Read();
fromOlderBuild.TerminalFontSize.ShouldBe(19);
fromOlderBuild.AutomaticUpdateChecks.ShouldBeTrue();
}
[Fact]
public async Task TheRestartWarning_CountsTheShellsItWouldClose()
{
channel.Available = new AvailableUpdate("1.3.0");
var updates = Build();
await updates.CheckOnceAsync(Token);
liveSessions = 0;
updates.RestartWarning.ShouldContain("Nothing is connected");
liveSessions = 1;
updates.RestartWarning.ShouldContain("the shell you have open");
liveSessions = 3;
updates.RestartWarning.ShouldContain("the 3 shells you have open");
}
[Fact]
public async Task ADownloadInFlight_IsAStateTheScreenCanShow()
{
channel.Available = new AvailableUpdate("1.3.0");
channel.HoldDownload = new TaskCompletionSource();
var updates = Build();
var pass = updates.CheckOnceAsync(Token);
// The gate is what makes this observable at all; without it the download would be over before
// anything could look.
while (updates.State is not UpdateState.Downloading)
{
await Task.Yield();
}
updates.IsDownloading.ShouldBeTrue();
updates.CanCheckNow.ShouldBeFalse();
channel.HoldDownload.SetResult();
await pass;
updates.State.ShouldBe(UpdateState.Ready);
}
[Fact]
public async Task APassWhileOneIsAlreadyReady_DoesNotAskAgain()
{
channel.Available = new AvailableUpdate("1.3.0");
var updates = Build();
await updates.CheckOnceAsync(Token);
await updates.CheckOnceAsync(Token);
channel.Checks.ShouldBe(1);
}
[Fact]
public async Task DisposingStopsTheLoop()
{
var updates = Build();
updates.Start();
await updates.DisposeAsync();
await updates.DisposeAsync();
}
}