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