Files
DodoSSH/tests/DodoSSH.Client.App.Layout.Tests/SettingsPagesLayoutTests.cs
laurensandClaude Opus 5.5 ef365f462e Spelunk: sign each device in through the browser instead of keeping Spelunk API keys in the vault
A Spelunk API key reaches one organisation, so a server elsewhere - oc-linux in Orange Climate - was
refused before it reached the agent. Each device now signs in to a Spelunk once, under Settings ->
Security -> Spelunk or from a Spelunk host's editor: the OAuth authorization code flow with PKCE for a
native app (RFC 8252), with Spelunk as the authorization server. The browser opens Spelunk's
/api/app-sign-in/authorize with a loopback redirect (the app's own scheme on Android), the person signs
in there however they normally do and allows "DodoSSH on <device>", and the code is redeemed at
/api/app-sign-in/token for an app session token. That token opens terminals in every organisation its
person is in, with the role they have on each server at the time; tunnel tickets send no organisation.

The sign-in stays on the device: sealed in the local cache under the LocalCacheKey (AAD resource type
18, bound to its row), never synced, wiped with the cache, and only ever sent to the Spelunk it came
from. A 401 from Spelunk forgets it; SIGN OUT ends it there and here, and so does signing out of
DodoSSH. Before every Spelunk connection DodoSSH asks its own identity provider whether its sign-in
still stands (a forced refresh, at most once a minute), and an account it refuses is signed out of
DodoSSH and of every Spelunk on the device.

A route names no key any more: Spelunk hosts are written at host schema 11, which an older build shows
read-only and asks to be updated for, and a payload that still names a key credential decodes with the
id skipped. The SpelunkKey item type is gone: SyncEntityType 14, ChangeEntityType 14 and AAD type 17
are retired, the server drops spelunk_key and its change-log rows, and the client cache drops its
cached items, outbox entries and conflicts of that type. A link still never starts a sign-in: from a
Spelunk this device is not signed in to it opens the editor, which offers to.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 16:26:25 +02:00

699 lines
26 KiB
C#

using Avalonia;
using Avalonia.Controls;
using Avalonia.VisualTree;
using DodoSSH.Client.App.Views;
using DodoSSH.Client.Import;
using DodoSSH.Client.Session;
using DodoSSH.Client.Session.Tests;
using DodoSSH.Client.Shell.ViewModels;
using DodoSSH.Client.Ssh;
using DodoSSH.Client.Storage;
using DodoSSH.Client.Terminal;
using DodoSSH.Crypto;
using NSubstitute;
namespace DodoSSH.Client.App.Layout.Tests;
/// <summary>
/// v5c: whether each of settings mode's four pages fits the rectangle it is actually given, and whether the
/// rows a fidelity pass could quietly unwire — Updates on General, Windows Hello on Security — are still
/// wired to the real commands.
/// </summary>
/// <remarks>
/// <para>
/// The budget is <see cref="LayoutHarness.SettingsContentWidth"/> by <see cref="LayoutHarness.SettingsContentHeight"/>
/// — the space beside <c>SettingsNav</c>'s own 340 pixels, under settings mode's own titlebar and with no
/// status bar or update banner beneath it, at the window's minimum. Not the design's own 1100-wide column,
/// which is wider than that budget: see the <c>MaxWidth</c> remark on <see cref="LayoutHarness.SettingsDesignContentWidth"/>
/// for why the pages ask for 1100 at most rather than exactly.
/// </para>
/// <para>
/// A real unlocked vault, on the same reasoning <see cref="ScreenLayoutTests"/> gives: the Security page
/// binds <c>KnownHostsScreen.Summary</c>, which is null until a vault is open, and a stand-in vault would
/// still have to be the real <see cref="VaultViewModel"/> type for the compiled bindings to resolve at all.
/// </para>
/// </remarks>
public sealed class SettingsPagesLayoutTests : IAsyncLifetime
{
private const string Passphrase = "a sufficiently long passphrase";
private const string ServerUrl = "https://dodossh.example";
private static readonly Argon2Profile CheapProfile =
Argon2Profile.FromStoredParameters(memoryKibibytes: 8 * 1024, passes: 1, parallelism: 1);
private readonly FakeAccountServer server = new();
private readonly StubKeyBinding keyBinding = new();
private readonly VaultKnownHostStore knownHosts = new();
private ClientCacheFactory caches = null!;
private TerminalWorkspace workspace = null!;
private VaultSession session = null!;
private VaultViewModel vault = null!;
private MainWindowViewModel shell = null!;
private static CancellationToken Token => TestContext.Current.CancellationToken;
/// <inheritdoc />
public async ValueTask InitializeAsync()
{
caches = ClientCacheFactory.ForMemory($"settings-layout-{Guid.CreateVersion7():N}");
await caches.MigrateAsync(Token);
await new AccountProvisioner(server, keyBinding, caches, TimeProvider.System, CheapProfile)
.EnrollAsync(ServerUrl, Passphrase, "laptop", "Personal", Token);
var outcome = await new SessionOpener(caches, TimeProvider.System).UnlockAsync(Passphrase, Token);
outcome.IsUnlocked.ShouldBeTrue(outcome.Message);
session = outcome.Session!;
workspace = new TerminalWorkspace(
new InMemoryTerminalAssetProvider(new Dictionary<string, TerminalAsset>(StringComparer.Ordinal)),
Substitute.For<ISshConnectionFactory>(),
TimeProvider.System);
await knownHosts.OpenAsync(session, Token);
vault = new VaultViewModel(session, workspace, knownHosts, static () => null);
shell = new MainWindowViewModel(
new ClientPaths(Path.Combine(Path.GetTempPath(), $"dodossh-settings-layout-{Guid.CreateVersion7():N}")),
caches,
workspace,
knownHosts,
new UnavailableDeviceKeyStore(),
static (_, _) => throw new InvalidOperationException("A layout test has no network."),
TimeProvider.System,
Substitute.For<ISftpSessionFactory>(),
CheapProfile)
{
State = ShellState.Unlocked,
Vault = vault,
AccountName = "Ripley Vega",
Email = "ripley@example.test",
Issuer = "https://sso.example.test/realms/dodotech",
};
}
/// <inheritdoc />
public async ValueTask DisposeAsync()
{
await shell.DisposeAsync();
await vault.DisposeAsync();
knownHosts.Close();
await workspace.DisposeAsync();
await session.DisposeAsync();
caches.Dispose();
}
[Fact]
public Task TheGeneralPageFitsAtTheWindowsMinimum() =>
MeasureAsync(() => new SettingsGeneralPage(), faults => faults.ShouldBeEmpty());
[Fact]
public Task ThePreferencesPageFitsAtTheWindowsMinimum() =>
MeasureAsync(() => new SettingsPreferencesPage(), faults => faults.ShouldBeEmpty());
[Fact]
public Task TheAccountPageFitsAtTheWindowsMinimum() =>
MeasureAsync(() => new SettingsAccountPage(), faults => faults.ShouldBeEmpty("the ordinary shape"));
/// <remarks>The other shape the Account page takes — the sign-out confirmation card in place of the row.</remarks>
[Fact]
public async Task TheAccountPageFitsWithTheSignOutConfirmationUp()
{
shell.SignOutCommand.Execute(null);
shell.IsConfirmingSignOut.ShouldBeTrue();
await MeasureAsync(() => new SettingsAccountPage(), faults => faults.ShouldBeEmpty("with the confirm card up"));
}
[Fact]
public Task TheSecurityPageFitsWhileThisMachineCanRegisterADeviceKey()
{
shell.CanRegisterDevice = true;
shell.CanForgetDevice = false;
return MeasureAsync(() => new SettingsSecurityPage(), faults => faults.ShouldBeEmpty("offering to register"));
}
[Fact]
public Task TheSecurityPageFitsWhileThisMachineIsAlreadyRegistered()
{
shell.CanRegisterDevice = false;
shell.CanForgetDevice = true;
return MeasureAsync(() => new SettingsSecurityPage(), faults => faults.ShouldBeEmpty("offering to withdraw"));
}
[Fact]
public Task TheSecurityPageFitsOnAMachineWithNowhereToKeepADeviceKey()
{
shell.CanRegisterDevice = false;
shell.CanForgetDevice = false;
shell.HasNoDeviceKeyOption.ShouldBeTrue();
return MeasureAsync(() => new SettingsSecurityPage(), faults => faults.ShouldBeEmpty("the no-TPM explanation"));
}
/// <summary>
/// Checking for updates on the General page is still the real <c>UpdateViewModel</c> command, not a
/// row a fidelity pass silently detached while restyling it into the card idiom.
/// </summary>
[Fact]
public Task TheGeneralPagesCheckNowButton_IsWiredToTheRealUpdatesCommand() =>
LayoutHarness.OnTheUiThreadAsync(
() =>
{
var page = new SettingsGeneralPage { DataContext = shell };
var window = LayoutHarness.HostAtMinimumSize(
page, LayoutHarness.SettingsContentWidth, LayoutHarness.SettingsContentHeight);
try
{
ButtonNamed(window, "CHECK NOW").Command.ShouldBeSameAs(shell.Updates.CheckNowCommand);
}
finally
{
window.Close();
}
},
Token);
/// <summary>
/// Windows Hello's two commands — moved here whole from the old Preferences screen — are reachable from
/// the Security page in both of the states they can be in.
/// </summary>
[Fact]
public async Task TheSecurityPagesHelloButtons_AreWiredToTheRealDeviceCommands()
{
shell.CanRegisterDevice = true;
shell.CanForgetDevice = false;
await LayoutHarness.OnTheUiThreadAsync(
() =>
{
var page = new SettingsSecurityPage { DataContext = shell };
var window = LayoutHarness.HostAtMinimumSize(
page, LayoutHarness.SettingsContentWidth, LayoutHarness.SettingsContentHeight);
try
{
ButtonNamed(window, "REGISTER").Command.ShouldBeSameAs(shell.RegisterDeviceCommand);
}
finally
{
window.Close();
}
},
Token);
shell.CanRegisterDevice = false;
shell.CanForgetDevice = true;
await LayoutHarness.OnTheUiThreadAsync(
() =>
{
var page = new SettingsSecurityPage { DataContext = shell };
var window = LayoutHarness.HostAtMinimumSize(
page, LayoutHarness.SettingsContentWidth, LayoutHarness.SettingsContentHeight);
try
{
ButtonNamed(window, "STOP UNLOCKING HERE").Command.ShouldBeSameAs(shell.ForgetDeviceCommand);
}
finally
{
window.Close();
}
},
Token);
}
/// <summary>
/// The SPELUNK section with this device signed in: the form, and the row listing the sign-in, all fit, and
/// the form's button is the real sign-in.
/// </summary>
[Fact]
public async Task TheSecurityPageFitsWhenSignedInToSpelunk()
{
await vault.Session.SpelunkSignIns.SaveAsync(
new SpelunkSignInRecord(
"https://api.spelunk.example.com",
"spelunkapp_0123456789abcdefghijklmnopqrstuvwxyzABCDEFG",
Guid.CreateVersion7(),
"laurens@example.com",
"Laurens Example",
DateTimeOffset.UtcNow),
Token);
await vault.ReloadSpelunkSignInsAsync(Token);
vault.SpelunkSignIns.ShouldHaveSingleItem();
await MeasureAsync(() => new SettingsSecurityPage(), faults => faults.ShouldBeEmpty("signed in to Spelunk"));
await LayoutHarness.OnTheUiThreadAsync(
() =>
{
var page = new SettingsSecurityPage { DataContext = shell };
var window = LayoutHarness.HostAtMinimumSize(
page, LayoutHarness.SettingsContentWidth, LayoutHarness.SettingsContentHeight);
try
{
ButtonNamed(window, "SIGN IN").Command.ShouldBeSameAs(vault.SignInToSpelunkCommand);
}
finally
{
window.Close();
}
},
Token);
}
// ---- v5c-2: Vaults, Groups, Tags ----
//
// Vaults replaces the old full-bleed VaultsScreen, which ScreenLayoutTests used to measure at
// LayoutHarness.ScreenWidth/ScreenHeight (the space beside the ordinary nav rail); it is a settings page
// now, so its own layout coverage belongs here, against the narrower settings budget, beside every other
// settings page's. Groups and Tags are new pages with nothing to migrate.
/// <remarks>
/// A shared vault added straight into the fixture's own session — the same technique the retired
/// VaultsScreen layout tests used — so the card list draws both the personal vault and a shared one
/// without a live server: <c>VaultRowViewModel.IsShared</c> only asks whether the vault carries a team
/// id, which this sets without needing <see cref="MainWindowViewModel"/>'s own (offline) connection.
/// </remarks>
private async Task AddSharedVaultAsync()
{
using var teamServer = new StubTeamServer();
await session.CreateTeamVaultAsync(
teamServer.Teams, StubTeamServer.SharedTeamId, "Platform secrets", Token);
}
[Fact]
public async Task TheVaultsPageFitsWithTheVaultListPopulated()
{
await AddSharedVaultAsync();
await shell.Vaults.LoadAsync(Token);
await MeasureAsync(
() => new SettingsVaultsPage(),
faults => faults.ShouldBeEmpty("a personal vault and a shared one"));
}
[Fact]
public async Task TheVaultsPageFitsWithTheNewVaultFormOpen()
{
await shell.Vaults.LoadAsync(Token);
shell.Vaults.NewVaultCommand.Execute(null);
await MeasureAsync(
() => new SettingsVaultsPage(),
faults => faults.ShouldBeEmpty("the new-vault form open"));
}
[Fact]
public async Task TheVaultsPageFitsWithTheRenameFormOpen()
{
await AddSharedVaultAsync();
await shell.Vaults.LoadAsync(Token);
shell.Vaults.RenameVaultRowCommand.Execute(shell.Vaults.Vaults.First(row => row.IsShared));
await MeasureAsync(
() => new SettingsVaultsPage(),
faults => faults.ShouldBeEmpty("the rename form open"));
}
/// <remarks>
/// The panel's own geometry — the ListBoxes, the SHARE KEY/WITHDRAW KEY row, the KEY HOLDERS list —
/// with nothing in Members or Grants, since the fixture's connection is offline and both are read from
/// the server on selection. An empty ListBox is zero pixels tall and exempt from this harness's own
/// "no size" rule, so this is still a real check of everything around it.
/// </remarks>
[Fact]
public async Task TheVaultsPageFitsWithTheMembersPanelOpen()
{
await AddSharedVaultAsync();
await shell.Vaults.LoadAsync(Token);
shell.Vaults.OpenMembersPanelCommand.Execute(shell.Vaults.Vaults.First(row => row.IsShared));
await MeasureAsync(
() => new SettingsVaultsPage(),
faults => faults.ShouldBeEmpty("the members panel open"));
}
[Fact]
public async Task TheGroupsPageFitsWithGroupsPopulated()
{
await AddGroupAsync("production");
await AddGroupAsync("staging");
await MeasureAsync(
() => new SettingsGroupsPage(),
faults => faults.ShouldBeEmpty("two groups and the No group footer"));
}
[Fact]
public async Task TheGroupsPageFitsWithTheEditorOpen()
{
vault.NewGroupCommand.Execute(null);
await MeasureAsync(
() => new SettingsGroupsPage(),
faults => faults.ShouldBeEmpty("the group editor open"));
}
[Fact]
public async Task TheGroupsPageFitsWithTheDeleteConfirmationArmed()
{
await AddGroupAsync("production");
vault.DeleteGroupCommand.Execute(vault.Groups.Single());
await MeasureAsync(
() => new SettingsGroupsPage(),
faults => faults.ShouldBeEmpty("the delete confirmation armed"));
}
[Fact]
public async Task TheTagsPageFitsWithTagsPopulated()
{
await AddTagAsync("production");
await AddTagAsync("staging");
await MeasureAsync(
() => new SettingsTagsPage(),
faults => faults.ShouldBeEmpty("two tags"));
}
[Fact]
public async Task TheTagsPageFitsWithTheEditorOpen()
{
vault.NewTagCommand.Execute(null);
await MeasureAsync(
() => new SettingsTagsPage(),
faults => faults.ShouldBeEmpty("the tag editor open"));
}
[Fact]
public async Task TheTagsPageFitsWithTheDeleteConfirmationArmed()
{
await AddTagAsync("production");
vault.DeleteTagRowCommand.Execute(vault.Tags.Single());
await MeasureAsync(
() => new SettingsTagsPage(),
faults => faults.ShouldBeEmpty("the delete confirmation armed"));
}
private async Task AddGroupAsync(string label)
{
vault.NewGroupCommand.Execute(null);
vault.GroupEditorLabel = label;
await vault.SaveGroupCommand.ExecuteAsync(null);
}
private async Task AddTagAsync(string label)
{
vault.NewTagCommand.Execute(null);
vault.TagEditorLabel = label;
await vault.SaveTagCommand.ExecuteAsync(null);
}
// ---- v5c-3: Import ----
//
// The importer moved into settings mode's own chrome — see MainWindowViewModel.IsImportOpen and
// design-notes/v5c-fidelity-notes.md — so its layout coverage moved here from ScreenLayoutTests, against
// the same SettingsContentWidth/SettingsContentHeight budget every other settings page is measured
// against, rather than the plain full-bleed one it used to get beside the ordinary nav rail.
[Fact]
public async Task TheImportScreenFitsBeforeAnythingHasBeenScanned()
{
await MeasureImportAsync(faults => faults.ShouldBeEmpty("the state it opens in"));
}
/// <remarks>
/// The shape with something to decide about: a table of candidate hosts with tickboxes, a warning
/// block above it, and a footer carrying the sentence that says key files are not read. That sentence
/// is the one that must not be pushed off the bottom — it is the difference between an import somebody
/// understands and one they think is broken.
/// </remarks>
[Fact]
public async Task TheImportScreenFitsWithHostsToChooseFromAndWarnings()
{
await MeasureImportAsync(
faults => faults.ShouldBeEmpty("with a scanned list"),
await ScannedImportAsync());
}
// ---- The Hetzner wizard ----
//
// Measured against the same SettingsContentWidth/SettingsContentHeight budget, because it is drawn in
// the same content column over the same Preferences page — see MainWindowViewModel.IsHetznerImportOpen.
// Two shapes, because the two steps are different layouts rather than one with a section hidden: the
// token step is a card, and the choose step is a table with an options card above it and a footer below.
[Fact]
public async Task TheHetznerWizardFitsOnTheStepItOpensOn()
{
await MeasureHetznerAsync(faults => faults.ShouldBeEmpty("the token step"));
}
/// <remarks>
/// The shape with something to decide about, and the two rows that must not be pushed off the bottom are
/// the sentence saying no key comes with these hosts and the IMPORT button under it — the same pair
/// <see cref="TheImportScreenFitsWithHostsToChooseFromAndWarnings"/> is written about.
/// </remarks>
[Fact]
public async Task TheHetznerWizardFitsWithServersToChooseFromAndWarnings()
{
await MeasureHetznerAsync(
faults => faults.ShouldBeEmpty("with a scanned project"),
await ScannedHetznerAsync());
}
// ---- Helpers ----
/// <remarks>
/// The page is built by the factory rather than handed in already constructed: an Avalonia control is
/// owned by whichever thread creates it, and every caller of this helper must build its page on the
/// dispatcher thread <see cref="LayoutHarness.OnTheUiThreadAsync"/> switches onto, not on the test
/// runner's own thread the factory is captured from.
/// </remarks>
private Task MeasureAsync(Func<UserControl> page, Action<IReadOnlyList<string>> assert) =>
LayoutHarness.OnTheUiThreadAsync(
() =>
{
var control = page();
control.DataContext = shell;
var window = LayoutHarness.HostAtMinimumSize(
control, LayoutHarness.SettingsContentWidth, LayoutHarness.SettingsContentHeight);
try
{
assert(LayoutHarness.Unreachable(window));
}
finally
{
window.Close();
}
},
Token);
private static Button ButtonNamed(Visual root, string label) =>
root.GetVisualDescendants()
.OfType<Button>()
.First(button => string.Equals(button.Content as string, label, StringComparison.Ordinal));
/// <summary>
/// Lays the importer out at the budget settings mode's own content column actually gets.
/// </summary>
/// <remarks>
/// Not built through <see cref="MeasureAsync"/>: every other settings page is typed to
/// <see cref="MainWindowViewModel"/> and takes <see cref="shell"/> as its data context, where
/// <c>ImportScreen</c> is typed to <c>ImportViewModel</c> — the same split <c>SettingsView.axaml</c>
/// draws by handing it <c>{Binding ImportScreen}</c> rather than the shell itself.
/// </remarks>
private Task MeasureImportAsync(
Action<IReadOnlyList<string>> assert,
ImportViewModel? import = null) =>
LayoutHarness.OnTheUiThreadAsync(
() =>
{
var screen = new ImportScreen
{
DataContext = import ?? new ImportViewModel(vault, new SshConfigLocator()),
};
var window = LayoutHarness.HostAtMinimumSize(
screen, LayoutHarness.SettingsContentWidth, LayoutHarness.SettingsContentHeight);
try
{
assert(LayoutHarness.Unreachable(window));
}
finally
{
window.Close();
}
},
Token);
/// <summary>
/// An import view model that has scanned a real file, so the table has rows in it.
/// </summary>
/// <remarks>
/// Through a temporary directory rather than by populating the rows directly, because the shape being
/// measured is what the parser produces — an entry with two warnings under it is taller than one
/// without, and inventing the rows would measure a layout nothing generates.
/// </remarks>
private async Task<ImportViewModel> ScannedImportAsync()
{
var directory = Path.Combine(Path.GetTempPath(), $"dodossh-settings-import-{Guid.CreateVersion7():N}");
Directory.CreateDirectory(directory);
try
{
await File.WriteAllTextAsync(
Path.Combine(directory, "config"),
"""
Host *
ServerAliveInterval 30
Host prod-db
HostName database.production.internal
User deploy
Port 2222
IdentityFile ~/.ssh/id_ed25519
Host bastion-eu-west-1
HostName bastion.eu-west-1.example.com
User ops
ProxyCommand nc %h %p
Compression yes
compression no
Match host anything
User root
""");
var import = new ImportViewModel(vault, new SshConfigLocator(directory));
// Awaited, not fired. ScanCommand reads a file, so executing without awaiting measures an empty
// table — which is the other test.
await import.ScanCommand.ExecuteAsync(null);
import.HasRows.ShouldBeTrue("the fixture has hosts in it");
import.HasWarnings.ShouldBeTrue("the fixture has a Match block and a wildcard block");
return import;
}
finally
{
Directory.Delete(directory, recursive: true);
}
}
/// <inheritdoc cref="MeasureImportAsync" />
private Task MeasureHetznerAsync(
Action<IReadOnlyList<string>> assert,
HetznerImportViewModel? wizard = null) =>
LayoutHarness.OnTheUiThreadAsync(
() =>
{
var screen = new HetznerImportScreen
{
DataContext = wizard ?? new HetznerImportViewModel(vault, new HetznerCloudClient()),
};
var window = LayoutHarness.HostAtMinimumSize(
screen, LayoutHarness.SettingsContentWidth, LayoutHarness.SettingsContentHeight);
try
{
assert(LayoutHarness.Unreachable(window));
}
finally
{
window.Close();
}
},
Token);
/// <summary>
/// A wizard that has read a project, so the table has rows in it.
/// </summary>
/// <remarks>
/// Through the real client over a stubbed transport rather than by populating the rows directly, on the
/// reasoning <see cref="ScannedImportAsync"/> gives: a row carrying a warning is taller than one without,
/// and inventing the rows would measure a layout nothing generates. The fixture is chosen to produce all
/// three chip states plus a project-level warning — a plain server, one with no IPv4, and one the API
/// described without a name.
/// </remarks>
private async Task<HetznerImportViewModel> ScannedHetznerAsync()
{
var http = new HttpClient(new StubHetzner("""
{
"servers": [
{
"id": 1,
"name": "web-01",
"status": "running",
"public_net": { "ipv4": { "ip": "203.0.113.10" } },
"server_type": { "name": "cx22" },
"datacenter": { "location": { "name": "fsn1", "city": "Falkenstein", "country": "DE" } },
"image": { "name": "ubuntu-24.04" }
},
{
"id": 2,
"name": "edge-v6",
"status": "off",
"public_net": { "ipv6": { "ip": "2a01:4f8:1c17:d00::/64" } },
"server_type": { "name": "cax11" },
"datacenter": { "location": { "name": "hel1", "city": "Helsinki", "country": "FI" } }
},
{ "id": 3, "status": "initializing" }
],
"meta": { "pagination": { "page": 1, "per_page": 50, "next_page": null } }
}
"""));
var wizard = new HetznerImportViewModel(vault, new HetznerCloudClient(http))
{
Token = "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789",
};
await wizard.ScanCommand.ExecuteAsync(null);
wizard.HasRows.ShouldBeTrue("the fixture has servers in it");
wizard.HasWarnings.ShouldBeTrue("the fixture has a server the API left unnamed");
return wizard;
}
/// <summary>Answers anything asked of it with one page. There is no sequence to model here.</summary>
private sealed class StubHetzner(string body) : HttpMessageHandler
{
protected override Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request,
CancellationToken cancellationToken) =>
Task.FromResult(new HttpResponseMessage(System.Net.HttpStatusCode.OK)
{
Content = new StringContent(body, System.Text.Encoding.UTF8, "application/json"),
});
}
}