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