Public Access
Six things, all of them answers to a question a screen was deciding on somebody's behalf. The board draws a heading per vault and sorts each vault's hosts by that vault's own groups. A group is an item of one vault, so drawing every group of every vault in one run flattened away containment that already exists: two vaults each holding a "production" were two headings with the same word on them, told apart by a small grey name beside each. Single- vault sessions are unchanged. There is now one "No group" heading per vault, so the folded set is keyed on SidebarGroupHeader.CollapseKey rather than on GroupId ?? Guid.Empty, which had one vault's unfiled hosts folding every other vault's away with them. A Ctrl-click starts its set with the card the board has lit. The board always has a selection — a fresh unlock lights the first host so CONNECT has something under it — so "one card lit, nothing ticked" is the state this screen opens in, and ticking a second card there produced a set of one: the machine the user was looking at was left out of the count and out of everything Delete, Move and Change group then ran over. The card tooltip says the host's name as well as its address. 214 pixels of card trims the name, and the tooltip was the one place it was not said in full. The card's floor drops from 120 to 104. A card with no tags measures about 87, so the mock's number was thirty pixels of air under every card on a board that is scrolled. "Move to another vault…" joins the board's context menu for one host. Moving one machine meant opening its pane and finding the menu inside it, while moving six was a right click away — the one-host case was the harder of the two. And the Hetzner import asks which vault and which key. A project belongs to a team more often than to a person, so forty servers landing wherever the session happens to be standing is the wrong default; ImportHostsAsync takes the target vault, and the ssh_config importer passes null and is unchanged. The key is the half Hetzner cannot supply and the keychain usually already holds — the public half went to Hetzner when the servers were made — so the import points every host at it rather than bringing one. It defaults to no key rather than guessing, and says so when the key it was given lives in a different vault from the hosts.
524 lines
20 KiB
C#
524 lines
20 KiB
C#
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>
|
|
/// <summary>
|
|
/// The two answers this import used to decide on somebody's behalf: which vault, and which key.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// ◆ <b>A Hetzner project is the case where "wherever I happen to be standing" is the wrong vault.</b>
|
|
/// The <c>ssh_config</c> importer reads this machine's own account and files into the active vault
|
|
/// fairly; a project belongs to a team more often than to a person, and forty servers landing in
|
|
/// somebody's personal vault are either forty items nobody else can see or forty moved one at a time.
|
|
/// </para>
|
|
/// <para>
|
|
/// <b>The key is the half Hetzner cannot supply and the keychain usually already holds.</b> The public
|
|
/// half went to Hetzner when the servers were made; the private half never did. So the import points at
|
|
/// a key rather than bringing one — the host stores its id, exactly as one bound by hand does, and the
|
|
/// material is neither read nor copied. Without it this screen's honest answer was forty hosts asking
|
|
/// for a password.
|
|
/// </para>
|
|
/// <para>
|
|
/// The key here is deliberately left in the personal vault while the hosts go to the shared one, which
|
|
/// is allowed and is the one thing about it worth saying out loud: a binding resolves across every vault
|
|
/// <em>this</em> account can read, so these hosts connect here and not for a colleague who holds only
|
|
/// the destination's key.
|
|
/// </para>
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task ImportingIntoAChosenVault_BindsEveryServerToTheChosenKey()
|
|
{
|
|
await UnlockedAsync();
|
|
|
|
var vaults = shell.Vaults;
|
|
|
|
await vaults.LoadAsync(Token);
|
|
|
|
vaults.NewVaultCommand.Execute(null);
|
|
vaults.NewVaultName = "Platform secrets";
|
|
|
|
await vaults.CreateVaultCommand.ExecuteAsync(null);
|
|
|
|
var vault = shell.Vault!;
|
|
var sharedVaultId = vaults.SelectedVault!.VaultId;
|
|
|
|
await vault.LoadAsync(Token);
|
|
await AddKeyAsync(vault, "deploy");
|
|
|
|
var keyId = vault.Keys.ShouldHaveSingleItem().EntityId;
|
|
|
|
var wizard = Wizard(Project(WebServer));
|
|
|
|
wizard.Token = HetznerToken;
|
|
|
|
await wizard.ScanCommand.ExecuteAsync(null);
|
|
|
|
wizard.HasVaultChoice.ShouldBeTrue("there are two vaults to choose between");
|
|
wizard.HasKeyChoice.ShouldBeTrue("the keychain has a key to point at");
|
|
wizard.SelectedKey.ShouldNotBeNull().KeyId.ShouldBeNull("binding a key is chosen, never defaulted");
|
|
|
|
wizard.SelectedVault = wizard.VaultChoices.Single(choice => choice.VaultId == sharedVaultId);
|
|
wizard.SelectedKey = wizard.KeyChoices.Single(choice => choice.KeyId == keyId);
|
|
|
|
wizard.AuthenticationNote.ShouldContain("deploy");
|
|
wizard.AuthenticationNote.ShouldContain(
|
|
"another vault",
|
|
Case.Sensitive,
|
|
"the key stayed behind, and the colleagues these hosts just joined cannot resolve it");
|
|
|
|
await wizard.ImportCommand.ExecuteAsync(null);
|
|
|
|
var imported = vault.Hosts.Single(row => string.Equals(row.Label, "web-01", StringComparison.Ordinal));
|
|
|
|
imported.VaultId.ShouldBe(sharedVaultId, wizard.Status);
|
|
imported.Host.SshKeyId.ShouldBe(keyId, "pointed at the key rather than carrying one");
|
|
wizard.Status.ShouldContain("Platform secrets");
|
|
|
|
// Through the ordinary path, as every other write here is: it reached the server without anything
|
|
// pressing Sync.
|
|
server.LiveRowCount.ShouldBe(2, "the key and the host");
|
|
}
|
|
|
|
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"),
|
|
});
|
|
}
|
|
}
|