using System.Net; using System.Text; using DodoSSH.Client.Import; namespace DodoSSH.Client.Import.Tests; /// /// Reading a Hetzner Cloud project, against a stubbed transport. /// /// /// /// No socket, for the reason LinuxUpdateChannelTests 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. /// /// /// 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. /// /// 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(); } /// /// The token goes in an Authorization: Bearer header and nowhere else — never a query parameter, /// which is the shape that ends up in somebody's proxy log. /// [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"); } /// /// Every page is followed, which is what keeps a short list from looking like a complete one. /// /// /// 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. /// [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); } /// /// 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 IsTokenProblem is what sends the wizard back to the step that has the box. /// [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( () => 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); } /// /// 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. /// [Fact] public async Task AFaultAtHetznersEnd_IsNotTheTokensFault() { var stub = new StubHetzner([new StubResponse(HttpStatusCode.BadGateway, "nope")]); var failure = await Should.ThrowAsync( () => new HetznerCloudClient(new HttpClient(stub)).ListServersAsync(Token, Cancellation)); failure.IsTokenProblem.ShouldBeFalse(); failure.Message.ShouldContain("502"); } /// /// Refused before anything is sent, because a header value carrying a line break throws from inside /// HttpClient — a stack trace where the honest answer is a sentence. /// [Fact] public async Task ATokenWithALineBreakInTheMiddle_IsRefusedWithoutAskingHetzner() { var stub = new StubHetzner([OnePage(OneServer)]); var failure = await Should.ThrowAsync( () => new HetznerCloudClient(new HttpClient(stub)) .ListServersAsync("abcdef\n0123456789", Cancellation)); failure.Message.ShouldContain("64 letters and digits"); stub.Requests.ShouldBeEmpty(); } /// Pasting out of the console brings a trailing newline often enough to be worth handling. [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}"); } /// /// A server with no IPv4 at all, which is a whole Hetzner price tier rather than an edge case. /// /// /// 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. /// [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"); } /// /// 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. /// [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"); } /// /// The private choice does not fall back, and that asymmetry is the point. /// /// /// 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. /// [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(); } /// /// 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. /// [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"); } /// What is stored, and what is deliberately not. /// /// 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. /// [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(); } /// /// 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 User. /// [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(); } /// /// A server with no name would arrive as a bookmark called nothing, which HostSecret.TryValidate /// refuses outright — so it is named for its id and the project-level warning says one was. /// [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"); } /// /// 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. /// [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 ---- /// A server carrying everything the API can say about one, used wherever the shape is not the point. 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 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); /// /// Answers each request with the next scripted response, and remembers what was asked. /// /// /// 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. /// private sealed class StubHetzner(IReadOnlyList responses) : HttpMessageHandler { private int served; internal List Requests { get; } = []; protected override Task 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"), }); } } }