Import a Hetzner Cloud project's servers, and forget the token that read it
ci / build and test (push) Successful in 3m18s
ci / android head (push) Successful in 3m54s
ci / api image (push) Successful in 30s
ci / desktop nightly (push) Successful in 2m10s

An estate on Hetzner is already described somewhere: the project lists every
server, its addresses, its plan and where it runs. Getting that into a keychain
meant copying forty addresses out of a browser by hand, which is the same work
the ssh_config importer exists to remove for the machines described in a file.

◆ A WIZARD RATHER THAN A SCREEN, AND THE DIFFERENCE IS WHAT EACH STEP COSTS.

ImportScreen reads a file that is already on this machine, so it scans on open
and shows a list. This cannot: it needs a credential that has to be made in
another company's console first, and then it makes a request over the internet.
Those are two moments rather than one, and a screen that mixed the token box in
with the list would ask for the credential again every time somebody came back
to look at the list.

So three steps — token, choose, done — and the first two write nothing. Step two
carries one address choice, one login user and one port for the whole import,
because an estate is reached the same way throughout and a per-server dropdown
would be forty answers to one question.

◆ THE TOKEN IS USED ONCE AND CLEARED, INCLUDING WHEN IT WORKED.

The half worth stating is the success path. Clearing only on failure would leave
a credential capable of listing somebody's whole estate sitting in a text box for
as long as they spend reading forty rows, which is the longest anybody stands on
this screen. Nothing writes it to the vault, to settings or to a log, and
reopening the wizard restarts at step one rather than resuming — a resumed step
two would be a list nothing on screen has the means to refresh, going on looking
current for as long as the application stays open.

Read is the only permission it needs and the note above the box says so: a
Read & Write token would work identically here and would be a credential capable
of deleting the estate, pasted in for no gain. The note also says a token belongs
to one project — there is no API call that will say which, and none that lists
the others — so an account with four projects runs this four times. Without that
sentence the wizard looks like it lost most of the estate.

◆ THE ADDRESS IS A CHOICE, AND TWO OF THE THREE FALL BACK WHERE THE THIRD MUST NOT.

Public IPv4 and public IPv6 fall back to each other: the same machine over the
same internet, so the worst case is a route nobody picked, and the row says which.
Private does not fall back at all. A server on no private network is simply not
importable under that choice — its tickbox is disabled, and neither direction of
the header's tick-all can reach it — because falling back would put an address on
the open internet into a bookmark that was asked to stay off it.

IPv6 needs saying twice over. Hetzner routes a whole /64 and the API reports the
network, which nothing can dial; the address a stock image answers on is the
first one in it. That is a convention of Hetzner's own images rather than a fact
the API stated, so every use of it carries a warning on the row and in the stored
note, and it is computed by setting the low byte rather than by string surgery on
the prefix. It is not an edge case either — IPv4 is billed separately now and a
project built since then can be entirely IPv6-only.

◆ NOTHING IMPORTED HERE AUTHENTICATES, AND THAT IS SAID BEFORE THE BUTTON.

Hetzner has never held the private half of an SSH key. The API will name a
project's keys and hand over their public halves; there is no private half to
bring. So every host arrives bound to nothing and asks for a password until
somebody points it at a key already in the keychain — the same shape an
ssh_config import has with its key tick off, and stated above the button on the
same reasoning: somebody who is not told will conclude the import was broken.

What the note keeps is what does not go stale — the id, the plan, the location,
the image, the labels. The power state is a column and never a note: "running"
about a machine somebody switched off last March is worse than no note.

◆ WHERE IT LIVES: TWO FLAGS OVER ONE CONTENT COLUMN.

Drawn over the Preferences page inside settings mode exactly as ImportScreen is,
with SettingsNav unmoved and the titlebar offering "Back to preferences".
IsHetznerImportOpen is a second flag rather than a "which importer" enum, because
they are two independent facts about the content column and a single value would
need a "neither" member IsImportOpen already expresses; what keeps them from both
being true is that every path that opens one closes the other. The titlebar and
the Preferences page ask IsImporterOpen, which is written against both.

Preferences grows a second importer row beside the first rather than one row that
asks "from where?", which would put a question in front of the far more common
answer.

◆ WHAT IS NOT HERE.

Hetzner's Robot — dedicated servers and Storage Boxes — is a different API behind
a different credential, and nothing in this touches it. Said on the Preferences
row and on the token step rather than left to be discovered by an account that
has both.

And there is no phone layout. This one would work on Android, unlike the
ssh_config importer beside it: it is a token and a network request, the view
model is shared already, and scoped storage has nothing to do with it. What is
missing is a screen — the desktop draws a five-column table, and the phone
version of that is a different layout rather than the same one narrower. Recorded
in android-port.md and on the phone's own Preferences screen, because the note
there previously read as though no import of any kind could work.

◆ TESTS, AND WHAT IS LEFT FOR A PERSON.

HetznerCloudClientTests is 16 cases over the shapes the API actually returns,
against a stubbed HttpMessageHandler: paging followed, the token in a header and
never a query string, a refused token distinguished from a fault at Hetzner's
end, a token with a line break refused before anything is sent, the /64, the
private-network asymmetry, and a server the API described without a name.

ShellFlowTests.Hetzner.cs is 11 more against this fixture's real vault, real
cache and real server — split into a file of its own, as FakeVaultServer keeps
its team endpoints. What they establish beyond the reader's own suite is the half
it cannot reach: that reading stores nothing, that importing goes through the
ordinary create-and-push path and lands on the server with nothing pressing Sync,
that a server the keychain already has arrives unticked, and that the token does
not outlive the request.

Two layout cases measure both steps against the same content budget every other
settings page gets, which is also what proves the markup binds to names that
exist.

Manual checks 2.11a–2.11d are what no test in this repository can do, because
there is no account, no token and no network in the ordinary suite: a real
project listed against the real console, a wrong token, the private choice, and
what an imported host does when you connect to it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
jaap-janandClaude Opus 5 committed 2026-08-26 10:51:11 +02:00
1 parent 56c19b006d
commit c270f3fe39
19 files changed
+2903 -17

No files matched your search

@@ -416,6 +416,32 @@ public sealed class SettingsPagesLayoutTests : IAsyncLifetime
await ScannedImportAsync());
}
// ---- The Hetzner wizard ----
//
// Measured against the same SettingsContentWidth/SettingsContentHeight budget, because it is drawn in
// the same content column over the same Preferences page — see MainWindowViewModel.IsHetznerImportOpen.
// Two shapes, because the two steps are different layouts rather than one with a section hidden: the
// token step is a card, and the choose step is a table with an options card above it and a footer below.
[Fact]
public async Task TheHetznerWizardFitsOnTheStepItOpensOn()
{
await MeasureHetznerAsync(faults => faults.ShouldBeEmpty("the token step"));
}
/// <remarks>
/// The shape with something to decide about, and the two rows that must not be pushed off the bottom are
/// the sentence saying no key comes with these hosts and the IMPORT button under it — the same pair
/// <see cref="TheImportScreenFitsWithHostsToChooseFromAndWarnings"/> is written about.
/// </remarks>
[Fact]
public async Task TheHetznerWizardFitsWithServersToChooseFromAndWarnings()
{
await MeasureHetznerAsync(
faults => faults.ShouldBeEmpty("with a scanned project"),
await ScannedHetznerAsync());
}
// ---- Helpers ----
/// <remarks>
@@ -538,4 +564,93 @@ public sealed class SettingsPagesLayoutTests : IAsyncLifetime
Directory.Delete(directory, recursive: true);
}
}
/// <inheritdoc cref="MeasureImportAsync" />
private Task MeasureHetznerAsync(
Action<IReadOnlyList<string>> assert,
HetznerImportViewModel? wizard = null) =>
LayoutHarness.OnTheUiThreadAsync(
() =>
{
var screen = new HetznerImportScreen
{
DataContext = wizard ?? new HetznerImportViewModel(vault, new HetznerCloudClient()),
};
var window = LayoutHarness.HostAtMinimumSize(
screen, LayoutHarness.SettingsContentWidth, LayoutHarness.SettingsContentHeight);
try
{
assert(LayoutHarness.Unreachable(window));
}
finally
{
window.Close();
}
},
Token);
/// <summary>
/// A wizard that has read a project, so the table has rows in it.
/// </summary>
/// <remarks>
/// Through the real client over a stubbed transport rather than by populating the rows directly, on the
/// reasoning <see cref="ScannedImportAsync"/> gives: a row carrying a warning is taller than one without,
/// and inventing the rows would measure a layout nothing generates. The fixture is chosen to produce all
/// three chip states plus a project-level warning — a plain server, one with no IPv4, and one the API
/// described without a name.
/// </remarks>
private async Task<HetznerImportViewModel> ScannedHetznerAsync()
{
var http = new HttpClient(new StubHetzner("""
{
"servers": [
{
"id": 1,
"name": "web-01",
"status": "running",
"public_net": { "ipv4": { "ip": "203.0.113.10" } },
"server_type": { "name": "cx22" },
"datacenter": { "location": { "name": "fsn1", "city": "Falkenstein", "country": "DE" } },
"image": { "name": "ubuntu-24.04" }
},
{
"id": 2,
"name": "edge-v6",
"status": "off",
"public_net": { "ipv6": { "ip": "2a01:4f8:1c17:d00::/64" } },
"server_type": { "name": "cax11" },
"datacenter": { "location": { "name": "hel1", "city": "Helsinki", "country": "FI" } }
},
{ "id": 3, "status": "initializing" }
],
"meta": { "pagination": { "page": 1, "per_page": 50, "next_page": null } }
}
"""));
var wizard = new HetznerImportViewModel(vault, new HetznerCloudClient(http))
{
Token = "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789",
};
await wizard.ScanCommand.ExecuteAsync(null);
wizard.HasRows.ShouldBeTrue("the fixture has servers in it");
wizard.HasWarnings.ShouldBeTrue("the fixture has a server the API left unnamed");
return wizard;
}
/// <summary>Answers anything asked of it with one page. There is no sequence to model here.</summary>
private sealed class StubHetzner(string body) : HttpMessageHandler
{
protected override Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request,
CancellationToken cancellationToken) =>
Task.FromResult(new HttpResponseMessage(System.Net.HttpStatusCode.OK)
{
Content = new StringContent(body, System.Text.Encoding.UTF8, "application/json"),
});
}
}
@@ -0,0 +1,445 @@
using System.Net;
using System.Text;
using DodoSSH.Client.Import;
using DodoSSH.Client.Shell.ViewModels;
namespace DodoSSH.Client.App.Tests;
/// <summary>
/// The Hetzner Cloud wizard, from a token to hosts on the server.
/// </summary>
/// <remarks>
/// <para>
/// What this establishes beyond <c>HetznerCloudClientTests</c> is the half that suite cannot reach: that
/// reading a project stores nothing, that importing goes through the ordinary create-and-push path every
/// other write uses, that a server the keychain already points at arrives unticked rather than silently
/// duplicated, and that the token does not outlive the request it was typed for.
/// </para>
/// <para>
/// Hetzner is a stubbed <c>HttpMessageHandler</c>. Everything else — the vault, the cache, the server — is
/// this fixture's real one, which is the point: the assertion that matters most is that the imported hosts
/// reached the server without anything pressing Sync.
/// </para>
/// </remarks>
public sealed partial class ShellFlowTests
{
private const string HetznerToken =
"abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789";
/// <summary>
/// The whole wizard.
/// </summary>
/// <remarks>
/// One server already in the keychain and one not, so the two halves of the default are both exercised:
/// the new one arrives ticked, the known one does not, and only the ticked one is stored.
/// </remarks>
[Fact]
public async Task ImportingFromHetzner_ShowsTheProjectFirstAndThenStoresWhatWasTicked()
{
await UnlockedAsync();
var vault = shell.Vault!;
// db.internal:deploy is what AddHostAsync creates, so the first server is one the keychain has.
await AddHostAsync(vault, "prod-db");
var wizard = Wizard(Project("""
{
"id": 1,
"name": "already-here",
"status": "running",
"public_net": { "ipv4": { "ip": "db.internal" } }
}
""",
"""
{
"id": 2,
"name": "web-01",
"status": "running",
"public_net": { "ipv4": { "ip": "203.0.113.10" } },
"server_type": { "name": "cx22" },
"datacenter": { "name": "fsn1-dc14", "location": { "name": "fsn1", "city": "Falkenstein", "country": "DE" } },
"image": { "name": "ubuntu-24.04" }
}
"""));
wizard.Step.ShouldBe(HetznerImportStep.Token, "it opens on the box, having sent nothing");
wizard.Token = HetznerToken;
wizard.Username = "deploy";
await wizard.ScanCommand.ExecuteAsync(null);
wizard.Step.ShouldBe(HetznerImportStep.Choose);
wizard.Rows.Count.ShouldBe(2);
vault.Hosts.Count.ShouldBe(1, "reading the project stores nothing");
var known = wizard.Rows.Single(row => string.Equals(row.Name, "already-here", StringComparison.Ordinal));
known.AlreadyPresent.ShouldBeTrue("it points at a machine the keychain already has");
known.IsSelected.ShouldBeFalse("a duplicate takes a click rather than being the default");
var fresh = wizard.Rows.Single(row => string.Equals(row.Name, "web-01", StringComparison.Ordinal));
fresh.IsSelected.ShouldBeTrue();
fresh.Address.ShouldBe("203.0.113.10");
await wizard.ImportCommand.ExecuteAsync(null);
wizard.Step.ShouldBe(HetznerImportStep.Done);
var imported = vault.Hosts.Single(row => string.Equals(row.Label, "web-01", StringComparison.Ordinal));
imported.Address.ShouldBe("deploy@203.0.113.10:22");
imported.Host.SshKeyId.ShouldBeNull("Hetzner has no private key to hand over");
imported.Host.Notes.ShouldNotBeNull().ShouldContain("cx22");
vault.Hosts.Count.ShouldBe(2, "only the ticked one was stored");
// Through the ordinary path, which is the point of routing it through the vault: it reached the
// server without anything pressing Sync.
server.LiveRowCount.ShouldBe(2);
}
/// <summary>
/// The token does not outlive the request it was typed for.
/// </summary>
/// <remarks>
/// This is the whole of what makes pasting a credential capable of listing somebody's estate into a text
/// box acceptable: it is used once and dropped. Asserted on the success path deliberately — a wizard
/// that cleared the box only when something went wrong would leave the token sitting there for the
/// entire time somebody spends reading a list of forty servers.
/// </remarks>
[Fact]
public async Task TheTokenIsForgottenAsSoonAsTheProjectHasBeenRead()
{
await UnlockedAsync();
var wizard = Wizard(Project(WebServer));
wizard.Token = HetznerToken;
await wizard.ScanCommand.ExecuteAsync(null);
wizard.Token.ShouldBeEmpty();
wizard.Rows.ShouldHaveSingleItem();
}
/// <remarks>
/// A refused token is the failure this meets most often, and the recovery is to type a different one —
/// so it stays on the step that has the box, with a message that says where tokens come from.
/// </remarks>
[Fact]
public async Task ARefusedToken_StaysOnTheTokenStepAndStoresNothing()
{
await UnlockedAsync();
var wizard = Wizard(new StubHetznerResponse(
HttpStatusCode.Unauthorized,
"""{ "error": { "code": "unauthorized", "message": "unable to authenticate" } }"""));
wizard.Token = HetznerToken;
await wizard.ScanCommand.ExecuteAsync(null);
wizard.Step.ShouldBe(HetznerImportStep.Token);
wizard.Status.ShouldContain("API tokens");
wizard.Rows.ShouldBeEmpty();
wizard.Token.ShouldBeEmpty("a refused token is retyped rather than corrected in place");
shell.Vault!.Hosts.ShouldBeEmpty();
}
/// <remarks>
/// An empty project is a correct answer to a correct token, so it is not an error — and it is not a step
/// forward either, because a list screen with nothing on it says less than the sentence does.
/// </remarks>
[Fact]
public async Task AnEmptyProject_SaysSoWithoutMovingOn()
{
await UnlockedAsync();
var wizard = Wizard(Project());
wizard.Token = HetznerToken;
await wizard.ScanCommand.ExecuteAsync(null);
wizard.Step.ShouldBe(HetznerImportStep.Token);
wizard.Status.ShouldContain("no servers");
}
/// <summary>
/// Choosing the private network does not quietly put a public address in the keychain.
/// </summary>
/// <remarks>
/// The one asymmetry in how addresses resolve, and the reason for it: falling back between v4 and v6
/// reaches the same machine over the same internet, where falling back from a private address to a
/// public one would put an address on the open internet into a bookmark that was asked to stay off it.
/// So the row loses its tick and cannot get it back while that choice stands.
/// </remarks>
[Fact]
public async Task ChoosingThePrivateNetwork_LeavesAServerWithoutOneUnimportable()
{
await UnlockedAsync();
var wizard = Wizard(Project(WebServer, """
{
"id": 3,
"name": "db-01",
"status": "running",
"public_net": { "ipv4": { "ip": "203.0.113.20" } },
"private_net": [ { "ip": "10.0.0.4" } ]
}
"""));
wizard.Token = HetznerToken;
await wizard.ScanCommand.ExecuteAsync(null);
wizard.SelectedCount.ShouldBe(2);
wizard.UseAddressCommand.Execute(HetznerAddressKind.Private);
var public_ = wizard.Rows.Single(row => string.Equals(row.Name, "web-01", StringComparison.Ordinal));
var inside = wizard.Rows.Single(row => string.Equals(row.Name, "db-01", StringComparison.Ordinal));
public_.CanImport.ShouldBeFalse("it is on no private network");
public_.IsSelected.ShouldBeFalse();
public_.Address.ShouldBe("—");
inside.CanImport.ShouldBeTrue();
inside.Address.ShouldBe("10.0.0.4");
wizard.SelectedCount.ShouldBe(1);
// Neither direction of "tick everything" can reach it, which is what "not importable" has to mean.
wizard.ToggleAllCommand.Execute(null);
public_.IsSelected.ShouldBeFalse();
inside.IsSelected.ShouldBeFalse("that press was the untick, since everything reachable was ticked");
wizard.ToggleAllCommand.Execute(null);
public_.IsSelected.ShouldBeFalse();
inside.IsSelected.ShouldBeTrue();
await wizard.ImportCommand.ExecuteAsync(null);
shell.Vault!.Hosts.ShouldHaveSingleItem().Host.Hostname.ShouldBe("10.0.0.4");
}
/// <remarks>
/// Changing the address choice must not throw away ticks somebody set — but changing the login user has
/// to re-ask "is this already here", because the user is half of what that is decided on.
/// </remarks>
[Fact]
public async Task ChangingTheLoginUser_ReasksWhetherTheKeychainAlreadyHasIt()
{
await UnlockedAsync();
var vault = shell.Vault!;
await AddHostAsync(vault, "prod-db");
var wizard = Wizard(Project("""
{
"id": 1,
"name": "db",
"status": "running",
"public_net": { "ipv4": { "ip": "db.internal" } }
}
"""));
wizard.Token = HetznerToken;
wizard.Username = "deploy";
await wizard.ScanCommand.ExecuteAsync(null);
wizard.Rows.ShouldHaveSingleItem().AlreadyPresent.ShouldBeTrue();
wizard.Username = "ubuntu";
wizard.Rows[0].AlreadyPresent.ShouldBeFalse("a different account on that machine is a different host");
}
/// <summary>
/// A server with no public IPv4 still imports, and the row says what was assumed to reach it.
/// </summary>
/// <remarks>
/// IPv6-only is a whole Hetzner price tier rather than an edge case. The address is the first in the
/// routed /64 — the API reports a network, which nothing can dial — and that is a convention of
/// Hetzner's own images rather than a fact, so it is on the row and in the stored note.
/// </remarks>
[Fact]
public async Task AnIPv6OnlyServer_ImportsWithWhatWasAssumedWrittenDown()
{
await UnlockedAsync();
var wizard = Wizard(Project("""
{
"id": 5,
"name": "v6-only",
"status": "running",
"public_net": { "ipv4": null, "ipv6": { "ip": "2a01:4f8:1c17:d00::/64" } }
}
"""));
wizard.Token = HetznerToken;
await wizard.ScanCommand.ExecuteAsync(null);
var row = wizard.Rows.ShouldHaveSingleItem();
row.Address.ShouldBe("2a01:4f8:1c17:d00::1");
row.HasWarning.ShouldBeTrue();
row.Meaning.ShouldContain("No public IPv4");
await wizard.ImportCommand.ExecuteAsync(null);
var host = shell.Vault!.Hosts.ShouldHaveSingleItem();
host.Host.Hostname.ShouldBe("2a01:4f8:1c17:d00::1");
host.Host.Notes.ShouldNotBeNull().ShouldContain("No public IPv4");
}
/// <remarks>
/// After an import the list is asked again rather than cleared, so a row that was just stored says
/// "already here" — which is what makes coming back and importing the rest of the project harmless and
/// visible rather than harmless and confusing.
/// </remarks>
[Fact]
public async Task AfterImporting_TheListSaysWhichOnesAreNowHere()
{
await UnlockedAsync();
var wizard = Wizard(Project(WebServer));
wizard.Token = HetznerToken;
await wizard.ScanCommand.ExecuteAsync(null);
await wizard.ImportCommand.ExecuteAsync(null);
wizard.ReviewCommand.Execute(null);
wizard.Step.ShouldBe(HetznerImportStep.Choose);
var row = wizard.Rows.ShouldHaveSingleItem();
row.AlreadyPresent.ShouldBeTrue();
row.IsSelected.ShouldBeFalse("importing it twice takes a click");
}
// ---- Where it lives in the shell ----
/// <remarks>
/// The wizard is drawn over the Preferences page inside settings mode, exactly as the ssh_config
/// importer is: the nav stays lit on Preferences and the titlebar offers "Back to preferences". What
/// would silently come apart is the content column — two importers and one page beneath them.
/// </remarks>
[Fact]
public async Task TheHetznerWizardOpensOverPreferencesAndBacksOutToIt()
{
await UnlockedAsync();
shell.ShowScreenCommand.Execute(ShellScreen.HetznerImport);
shell.IsHetznerImportOpen.ShouldBeTrue();
shell.IsImporterOpen.ShouldBeTrue("the titlebar's back button asks this one");
shell.IsSettingsPreferencesPage.ShouldBeTrue("the nav stays lit on Preferences");
shell.IsSettingsPreferencesContentShowing.ShouldBeFalse("the wizard is what the column draws");
shell.CloseImportCommand.Execute(null);
shell.IsHetznerImportOpen.ShouldBeFalse();
shell.IsSettingsMode.ShouldBeTrue("back to preferences, not out of settings");
shell.IsSettingsPreferencesContentShowing.ShouldBeTrue();
}
/// <remarks>
/// Opening one closes the other. They share a content column, and two importers drawn over each other
/// would leave whichever was on top looking like the one the back button belongs to.
/// </remarks>
[Fact]
public async Task OpeningOneImporterClosesTheOther()
{
await UnlockedAsync();
shell.ShowScreenCommand.Execute(ShellScreen.Import);
shell.IsImportOpen.ShouldBeTrue();
shell.ShowScreenCommand.Execute(ShellScreen.HetznerImport);
shell.IsImportOpen.ShouldBeFalse();
shell.IsHetznerImportOpen.ShouldBeTrue();
shell.ShowScreenCommand.Execute(ShellScreen.Import);
shell.IsHetznerImportOpen.ShouldBeFalse();
shell.IsImportOpen.ShouldBeTrue();
}
/// <summary>
/// Reopening the wizard puts it back on step one.
/// </summary>
/// <remarks>
/// A decision rather than an omission. The first step holds a credential this deliberately does not
/// keep, so a "resumed" second step would be a list nothing on screen has the means to refresh — and it
/// would go on looking current for as long as somebody left the application open.
/// </remarks>
[Fact]
public async Task ReopeningTheWizard_StartsAtTheTokenAgain()
{
await UnlockedAsync();
var wizard = shell.HetznerImportScreen.ShouldNotBeNull();
// Driven through the shell's own view model rather than the one this file builds, because what is
// being checked is what OpenHetznerImport does to the screen the shell is holding.
wizard.Token = HetznerToken;
wizard.Step = HetznerImportStep.Choose;
shell.ShowScreenCommand.Execute(ShellScreen.HetznerImport);
wizard.Step.ShouldBe(HetznerImportStep.Token);
wizard.Rows.ShouldBeEmpty();
}
// ---- Helpers ----
/// <summary>A server carrying enough to be imported, used wherever the shape is not what is being tested.</summary>
private const string WebServer = """
{
"id": 2,
"name": "web-01",
"status": "running",
"public_net": { "ipv4": { "ip": "203.0.113.10" } }
}
""";
/// <summary>The wizard, over a Hetzner that answers with exactly this.</summary>
private HetznerImportViewModel Wizard(StubHetznerResponse response) =>
new(shell.Vault!, new HetznerCloudClient(new HttpClient(new StubHetzner(response))));
/// <summary>One page holding these servers and saying there are no more.</summary>
private static StubHetznerResponse Project(params string[] servers) =>
new(
HttpStatusCode.OK,
$$"""
{
"servers": [ {{string.Join(",", servers)}} ],
"meta": { "pagination": { "page": 1, "per_page": 50, "next_page": null } }
}
""");
private sealed record StubHetznerResponse(HttpStatusCode Status, string Body);
/// <summary>Answers every request the same way. What varies between these tests is the body, not the sequence.</summary>
private sealed class StubHetzner(StubHetznerResponse response) : HttpMessageHandler
{
protected override Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request,
CancellationToken cancellationToken) =>
Task.FromResult(new HttpResponseMessage(response.Status)
{
Content = new StringContent(response.Body, Encoding.UTF8, "application/json"),
});
}
}
@@ -26,7 +26,12 @@ namespace DodoSSH.Client.App.Tests;
/// wrong — the one that must not be skipped, and the one that has to work offline — are checked by a test
/// rather than by remembering to click through them.
/// </remarks>
public sealed class ShellFlowTests : IAsyncLifetime
/// <remarks>
/// Split across two files rather than one: the Hetzner wizard's own path is in
/// <c>ShellFlowTests.Hetzner.cs</c>, which needs this fixture's unlocked vault and nothing else of it. The
/// same reason <c>FakeVaultServer</c> keeps its team endpoints in a file of their own.
/// </remarks>
public sealed partial class ShellFlowTests : IAsyncLifetime
{
private const string Passphrase = "a sufficiently long passphrase";
@@ -1,9 +1,15 @@
<Project Sdk="Microsoft.NET.Sdk">
<!--
The ssh_config reader, against strings. No disk, because the parser takes its include reader as a
Both readers, each against a stand-in for the world it reads.
The ssh_config one runs against strings. No disk, because the parser takes its include reader as a
parameter — which is what lets Include, the one directive whose behaviour is a file-system behaviour,
be covered by ordinary unit tests.
The Hetzner one runs against a stubbed HttpMessageHandler, which is the same trade: no account, no token
and no network, and every question about what the client decides from what Hetzner said still answerable.
What that leaves for a person is in docs/manual-checks.md, 2.11a onwards.
-->
<ItemGroup>
@@ -0,0 +1,416 @@
using System.Net;
using System.Text;
using DodoSSH.Client.Import;
namespace DodoSSH.Client.Import.Tests;
/// <summary>
/// Reading a Hetzner Cloud project, against a stubbed transport.
/// </summary>
/// <remarks>
/// <para>
/// No socket, for the reason <c>LinuxUpdateChannelTests</c> gives: every question here is what this client
/// decides from what Hetzner said, and a real request would add an account, a token and a network to the
/// assertions without adding anything to what is being checked.
/// </para>
/// <para>
/// The bodies are real shapes rather than minimal ones. An importer's whole value is agreeing with the
/// source about what it means — the IPv6 field holding a network rather than an address, and a server that
/// has no IPv4 at all, are both ordinary in a Hetzner project and both are exactly where an importer that
/// was nearly right would produce bookmarks that nearly connect.
/// </para>
/// </remarks>
public sealed class HetznerCloudClientTests
{
private const string Token = "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789";
private static CancellationToken Cancellation => TestContext.Current.CancellationToken;
[Fact]
public async Task APageOfServers_ArrivesWithWhatTheProjectSaidAboutThem()
{
var import = await ReadAsync(OnePage(OneServer));
var server = import.Servers.ShouldHaveSingleItem();
server.Id.ShouldBe(42);
server.Name.ShouldBe("web-01");
server.Status.ShouldBe("running");
server.PublicIPv4.ShouldBe("203.0.113.10");
server.ServerType.ShouldBe("cx22");
server.Location.ShouldBe("fsn1 · Falkenstein, DE");
server.Image.ShouldBe("ubuntu-24.04");
server.Labels.ShouldBe(["env=prod", "role=web"]);
import.Warnings.ShouldBeEmpty();
}
/// <remarks>
/// The token goes in an <c>Authorization: Bearer</c> header and nowhere else — never a query parameter,
/// which is the shape that ends up in somebody's proxy log.
/// </remarks>
[Fact]
public async Task TheTokenTravelsAsABearerHeader()
{
var stub = new StubHetzner([OnePage(OneServer)]);
await new HetznerCloudClient(new HttpClient(stub)).ListServersAsync(Token, Cancellation);
var request = stub.Requests.ShouldHaveSingleItem();
request.Authorization.ShouldBe($"Bearer {Token}");
request.Uri.ShouldNotContain(Token);
request.Uri.ShouldContain("per_page=50");
}
/// <summary>
/// Every page is followed, which is what keeps a short list from looking like a complete one.
/// </summary>
/// <remarks>
/// Fifty-one servers in a project is enough to meet this, and an importer that showed the first fifty
/// would present a list nobody has any reason to doubt.
/// </remarks>
[Fact]
public async Task EveryPageIsFollowed()
{
var stub = new StubHetzner([
Page(nextPage: 2, """{ "id": 1, "name": "web-01" }"""),
Page(nextPage: 3, """{ "id": 2, "name": "web-02" }"""),
Page(nextPage: null, """{ "id": 3, "name": "web-03" }"""),
]);
var import = await new HetznerCloudClient(new HttpClient(stub))
.ListServersAsync(Token, Cancellation);
import.Servers.Select(server => server.Name).ShouldBe(["web-01", "web-02", "web-03"]);
stub.Requests.Select(request => request.Uri).ShouldAllBe(uri => uri.Contains("page="));
stub.Requests.Count.ShouldBe(3);
}
/// <remarks>
/// A refused token is the failure this wizard meets most, because the value is shown once in the Hetzner
/// console and a half-copied one fails exactly like a wrong one. So the message says where tokens come
/// from, and <c>IsTokenProblem</c> is what sends the wizard back to the step that has the box.
/// </remarks>
[Fact]
public async Task ARefusedToken_SaysWhereTokensComeFromAndIsMarkedAsTheTokensFault()
{
var stub = new StubHetzner([
new StubResponse(
HttpStatusCode.Unauthorized,
"""{ "error": { "code": "unauthorized", "message": "unable to authenticate" } }"""),
]);
var failure = await Should.ThrowAsync<HetznerCloudException>(
() => new HetznerCloudClient(new HttpClient(stub)).ListServersAsync(Token, Cancellation));
failure.IsTokenProblem.ShouldBeTrue();
failure.Code.ShouldBe("unauthorized");
failure.Message.ShouldContain("API tokens");
failure.Message.ShouldContain("unable to authenticate", Case.Insensitive);
}
/// <remarks>
/// The other half of that split: Hetzner having a bad minute is not a reason to send somebody back to
/// re-type a token that was fine.
/// </remarks>
[Fact]
public async Task AFaultAtHetznersEnd_IsNotTheTokensFault()
{
var stub = new StubHetzner([new StubResponse(HttpStatusCode.BadGateway, "<html>nope</html>")]);
var failure = await Should.ThrowAsync<HetznerCloudException>(
() => new HetznerCloudClient(new HttpClient(stub)).ListServersAsync(Token, Cancellation));
failure.IsTokenProblem.ShouldBeFalse();
failure.Message.ShouldContain("502");
}
/// <remarks>
/// Refused before anything is sent, because a header value carrying a line break throws from inside
/// <c>HttpClient</c> — a stack trace where the honest answer is a sentence.
/// </remarks>
[Fact]
public async Task ATokenWithALineBreakInTheMiddle_IsRefusedWithoutAskingHetzner()
{
var stub = new StubHetzner([OnePage(OneServer)]);
var failure = await Should.ThrowAsync<HetznerCloudException>(
() => new HetznerCloudClient(new HttpClient(stub))
.ListServersAsync("abcdef\n0123456789", Cancellation));
failure.Message.ShouldContain("64 letters and digits");
stub.Requests.ShouldBeEmpty();
}
/// <remarks>Pasting out of the console brings a trailing newline often enough to be worth handling.</remarks>
[Fact]
public async Task ATokenPastedWithSurroundingWhitespace_IsTrimmedRatherThanRefused()
{
var stub = new StubHetzner([OnePage(OneServer)]);
await new HetznerCloudClient(new HttpClient(stub)).ListServersAsync($" {Token}\n", Cancellation);
stub.Requests.ShouldHaveSingleItem().Authorization.ShouldBe($"Bearer {Token}");
}
/// <summary>
/// A server with no IPv4 at all, which is a whole Hetzner price tier rather than an edge case.
/// </summary>
/// <remarks>
/// Two things are being checked and the second is the one that matters. The address is the first in the
/// routed /64, because what the API reports is a network and nothing can dial a network. And it carries
/// a warning, because that address is a convention of Hetzner's own images rather than a fact the API
/// stated — an import that silently guessed would produce a bookmark that fails to connect with no
/// indication of why.
/// </remarks>
[Fact]
public async Task AServerWithNoIPv4_DialsTheFirstAddressOfItsSixtyFourAndSaysSo()
{
var import = await ReadAsync(OnePage("""
{
"id": 7,
"name": "v6-only",
"status": "running",
"public_net": {
"ipv4": null,
"ipv6": { "ip": "2a01:4f8:1c17:d00::/64" }
}
}
"""));
var server = import.Servers.ShouldHaveSingleItem();
server.HasPublicIPv4.ShouldBeFalse();
server.FirstIPv6Address.ShouldBe("2a01:4f8:1c17:d00::1");
var address = server.Resolve(HetznerAddressKind.PublicIPv4).ShouldNotBeNull();
address.Value.ShouldBe("2a01:4f8:1c17:d00::1");
address.Warning.ShouldNotBeNull().ShouldContain("No public IPv4");
}
/// <remarks>
/// The v6 choice warns even where it is the one that was asked for, on the same reasoning: the address
/// is inferred from a network either way.
/// </remarks>
[Fact]
public async Task ChoosingIPv6_StillSaysTheAddressWasInferredFromANetwork()
{
var import = await ReadAsync(OnePage(OneServer));
var address = import.Servers[0].Resolve(HetznerAddressKind.PublicIPv6).ShouldNotBeNull();
address.Value.ShouldBe("2a01:4f8:1c17:d00::1");
address.Warning.ShouldNotBeNull().ShouldContain("2a01:4f8:1c17:d00::/64");
}
/// <summary>
/// The private choice does not fall back, and that asymmetry is the point.
/// </summary>
/// <remarks>
/// Falling back between v4 and v6 reaches the same machine over the same internet. Falling back from a
/// private address to a public one would put an address on the open internet into a bookmark that was
/// asked to stay off it, so a server with no private network is simply not importable under that choice.
/// </remarks>
[Fact]
public async Task AServerOnNoPrivateNetwork_HasNoPrivateAddressToOffer()
{
var import = await ReadAsync(OnePage(OneServer));
import.Servers[0].Resolve(HetznerAddressKind.Private).ShouldBeNull();
}
[Fact]
public async Task AServerOnAPrivateNetwork_DialsItWithoutAWarning()
{
var import = await ReadAsync(OnePage("""
{
"id": 9,
"name": "db-01",
"private_net": [ { "ip": "10.0.0.4" } ]
}
"""));
var address = import.Servers[0].Resolve(HetznerAddressKind.Private).ShouldNotBeNull();
address.Value.ShouldBe("10.0.0.4");
address.Warning.ShouldBeNull();
}
/// <remarks>
/// Two private networks is a real shape and the first one is a choice this makes rather than a fact, so
/// it is said out loud — the same rule the IPv6 address follows.
/// </remarks>
[Fact]
public async Task AServerOnTwoPrivateNetworks_TakesTheFirstAndSaysThereWereTwo()
{
var import = await ReadAsync(OnePage("""
{
"id": 9,
"name": "db-01",
"private_net": [ { "ip": "10.0.0.4" }, { "ip": "10.1.0.4" } ]
}
"""));
var address = import.Servers[0].Resolve(HetznerAddressKind.Private).ShouldNotBeNull();
address.Value.ShouldBe("10.0.0.4");
address.Warning.ShouldNotBeNull().ShouldContain("2 private networks");
}
/// <summary>What is stored, and what is deliberately not.</summary>
/// <remarks>
/// The note keeps what does not go stale — the id, the plan, where it runs, what it was built from. The
/// power state is not in it: a note saying "running" about a machine somebody switched off last March is
/// worse than no note, so status is a column on the import screen and never a stored fact.
/// </remarks>
[Fact]
public async Task WhatIsStored_CarriesTheProjectsContextAndNotItsPowerState()
{
var import = await ReadAsync(OnePage(OneServer));
var server = import.Servers[0];
var host = server.ToSecret(
server.Resolve(HetznerAddressKind.PublicIPv4).ShouldNotBeNull(),
"root",
22);
host.Label.ShouldBe("web-01");
host.Hostname.ShouldBe("203.0.113.10");
host.Username.ShouldBe("root");
host.Port.ShouldBe(22);
host.SshKeyId.ShouldBeNull("the private half of a key was never Hetzner's to hand over");
var notes = host.Notes.ShouldNotBeNull();
notes.ShouldContain("server 42");
notes.ShouldContain("cx22");
notes.ShouldContain("fsn1 · Falkenstein, DE");
notes.ShouldContain("ubuntu-24.04");
notes.ShouldContain("env=prod");
notes.ShouldNotContain("running");
host.TryValidate(out _).ShouldBeTrue();
}
/// <remarks>
/// A blank login user stores none rather than storing an empty one, which is what the ssh_config import
/// does for an entry that named no <c>User</c>.
/// </remarks>
[Fact]
public async Task ABlankLoginUser_StoresNoUsername()
{
var import = await ReadAsync(OnePage(OneServer));
var server = import.Servers[0];
server.ToSecret(server.Resolve(HetznerAddressKind.PublicIPv4)!, " ", 22).Username.ShouldBeNull();
}
/// <remarks>
/// A server with no name would arrive as a bookmark called nothing, which <c>HostSecret.TryValidate</c>
/// refuses outright — so it is named for its id and the project-level warning says one was.
/// </remarks>
[Fact]
public async Task AServerWithNoName_IsOfferedUnderItsIdAndWarnedAbout()
{
var import = await ReadAsync(OnePage("""{ "id": 512, "status": "off" }"""));
import.Servers.ShouldHaveSingleItem().Name.ShouldBe("server-512");
import.Warnings.ShouldHaveSingleItem().ShouldContain("server-512");
}
/// <remarks>
/// Everything but the id is optional in practice — a server built from a snapshot has no image, and one
/// mid-creation has no datacentre yet. None of that is a reason to refuse the whole page.
/// </remarks>
[Fact]
public async Task AServerTheApiDescribedSparsely_StillImports()
{
var import = await ReadAsync(OnePage("""{ "id": 3, "name": "fresh", "status": "initializing" }"""));
var server = import.Servers.ShouldHaveSingleItem();
server.ServerType.ShouldBeNull();
server.Location.ShouldBeNull();
server.Image.ShouldBeNull();
server.Labels.ShouldBeEmpty();
server.Resolve(HetznerAddressKind.PublicIPv4).ShouldBeNull("it has no address of any kind yet");
}
// ---- Helpers ----
/// <summary>A server carrying everything the API can say about one, used wherever the shape is not the point.</summary>
private const string OneServer = """
{
"id": 42,
"name": "web-01",
"status": "running",
"public_net": {
"ipv4": { "id": 1, "ip": "203.0.113.10", "blocked": false },
"ipv6": { "id": 2, "ip": "2a01:4f8:1c17:d00::/64", "blocked": false }
},
"private_net": [],
"server_type": { "id": 22, "name": "cx22", "cores": 2 },
"datacenter": {
"id": 4,
"name": "fsn1-dc14",
"location": { "name": "fsn1", "city": "Falkenstein", "country": "DE" }
},
"image": { "id": 100, "name": "ubuntu-24.04", "os_flavor": "ubuntu" },
"labels": { "role": "web", "env": "prod" }
}
""";
private static Task<HetznerImport> ReadAsync(StubResponse page) =>
new HetznerCloudClient(new HttpClient(new StubHetzner([page]))).ListServersAsync(Token, Cancellation);
private static StubResponse OnePage(string server) => Page(nextPage: null, server);
private static StubResponse Page(int? nextPage, string server) =>
new(
HttpStatusCode.OK,
$$"""
{
"servers": [ {{server}} ],
"meta": { "pagination": { "page": 1, "per_page": 50, "next_page": {{Next(nextPage)}} } }
}
""");
private static string Next(int? page) =>
page is { } value ? value.ToString(System.Globalization.CultureInfo.InvariantCulture) : "null";
private sealed record StubResponse(HttpStatusCode Status, string Body);
private sealed record SeenRequest(string Uri, string? Authorization);
/// <summary>
/// Answers each request with the next scripted response, and remembers what was asked.
/// </summary>
/// <remarks>
/// Scripted in order rather than keyed by URL, because the thing being checked in the paging test is the
/// sequence: a client that asked for page one three times would pass a lookup by address and produce the
/// same list three times over.
/// </remarks>
private sealed class StubHetzner(IReadOnlyList<StubResponse> responses) : HttpMessageHandler
{
private int served;
internal List<SeenRequest> Requests { get; } = [];
protected override Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request,
CancellationToken cancellationToken)
{
Requests.Add(new SeenRequest(
request.RequestUri!.AbsoluteUri,
request.Headers.Authorization?.ToString()));
var scripted = responses[Math.Min(served++, responses.Count - 1)];
return Task.FromResult(new HttpResponseMessage(scripted.Status)
{
Content = new StringContent(scripted.Body, Encoding.UTF8, "application/json"),
});
}
}
}