using System.Net;
using System.Text;
using DodoSSH.Client.Import;
using DodoSSH.Client.Shell.ViewModels;
namespace DodoSSH.Client.App.Tests;
///
/// The Hetzner Cloud wizard, from a token to hosts on the server.
///
///
///
/// What this establishes beyond HetznerCloudClientTests 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.
///
///
/// Hetzner is a stubbed HttpMessageHandler. 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.
///
///
public sealed partial class ShellFlowTests
{
private const string HetznerToken =
"abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789";
///
/// The whole wizard.
///
///
/// 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.
///
[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);
}
///
/// The token does not outlive the request it was typed for.
///
///
/// 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.
///
[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();
}
///
/// 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.
///
[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();
}
///
/// 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.
///
[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");
}
///
/// Choosing the private network does not quietly put a public address in the keychain.
///
///
/// 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.
///
[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");
}
///
/// 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.
///
[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");
}
///
/// A server with no public IPv4 still imports, and the row says what was assumed to reach it.
///
///
/// 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.
///
[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");
}
///
/// 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.
///
[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 ----
///
/// 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.
///
[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();
}
///
/// 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.
///
[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();
}
///
/// Reopening the wizard puts it back on step one.
///
///
/// 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.
///
[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 ----
/// A server carrying enough to be imported, used wherever the shape is not what is being tested.
private const string WebServer = """
{
"id": 2,
"name": "web-01",
"status": "running",
"public_net": { "ipv4": { "ip": "203.0.113.10" } }
}
""";
/// The wizard, over a Hetzner that answers with exactly this.
private HetznerImportViewModel Wizard(StubHetznerResponse response) =>
new(shell.Vault!, new HetznerCloudClient(new HttpClient(new StubHetzner(response))));
/// One page holding these servers and saying there are no more.
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);
/// Answers every request the same way. What varies between these tests is the body, not the sequence.
private sealed class StubHetzner(StubHetznerResponse response) : HttpMessageHandler
{
protected override Task SendAsync(
HttpRequestMessage request,
CancellationToken cancellationToken) =>
Task.FromResult(new HttpResponseMessage(response.Status)
{
Content = new StringContent(response.Body, Encoding.UTF8, "application/json"),
});
}
}