Public Access
An estate on Hetzner is already described somewhere: the project lists every server, its addresses, its plan and where it runs. Getting that into a keychain meant copying forty addresses out of a browser by hand, which is the same work the ssh_config importer exists to remove for the machines described in a file. ◆ A WIZARD RATHER THAN A SCREEN, AND THE DIFFERENCE IS WHAT EACH STEP COSTS. ImportScreen reads a file that is already on this machine, so it scans on open and shows a list. This cannot: it needs a credential that has to be made in another company's console first, and then it makes a request over the internet. Those are two moments rather than one, and a screen that mixed the token box in with the list would ask for the credential again every time somebody came back to look at the list. So three steps — token, choose, done — and the first two write nothing. Step two carries one address choice, one login user and one port for the whole import, because an estate is reached the same way throughout and a per-server dropdown would be forty answers to one question. ◆ THE TOKEN IS USED ONCE AND CLEARED, INCLUDING WHEN IT WORKED. The half worth stating is the success path. Clearing only on failure would leave a credential capable of listing somebody's whole estate sitting in a text box for as long as they spend reading forty rows, which is the longest anybody stands on this screen. Nothing writes it to the vault, to settings or to a log, and reopening the wizard restarts at step one rather than resuming — a resumed step two would be a list nothing on screen has the means to refresh, going on looking current for as long as the application stays open. Read is the only permission it needs and the note above the box says so: a Read & Write token would work identically here and would be a credential capable of deleting the estate, pasted in for no gain. The note also says a token belongs to one project — there is no API call that will say which, and none that lists the others — so an account with four projects runs this four times. Without that sentence the wizard looks like it lost most of the estate. ◆ THE ADDRESS IS A CHOICE, AND TWO OF THE THREE FALL BACK WHERE THE THIRD MUST NOT. Public IPv4 and public IPv6 fall back to each other: the same machine over the same internet, so the worst case is a route nobody picked, and the row says which. Private does not fall back at all. A server on no private network is simply not importable under that choice — its tickbox is disabled, and neither direction of the header's tick-all can reach it — because falling back would put an address on the open internet into a bookmark that was asked to stay off it. IPv6 needs saying twice over. Hetzner routes a whole /64 and the API reports the network, which nothing can dial; the address a stock image answers on is the first one in it. That is a convention of Hetzner's own images rather than a fact the API stated, so every use of it carries a warning on the row and in the stored note, and it is computed by setting the low byte rather than by string surgery on the prefix. It is not an edge case either — IPv4 is billed separately now and a project built since then can be entirely IPv6-only. ◆ NOTHING IMPORTED HERE AUTHENTICATES, AND THAT IS SAID BEFORE THE BUTTON. Hetzner has never held the private half of an SSH key. The API will name a project's keys and hand over their public halves; there is no private half to bring. So every host arrives bound to nothing and asks for a password until somebody points it at a key already in the keychain — the same shape an ssh_config import has with its key tick off, and stated above the button on the same reasoning: somebody who is not told will conclude the import was broken. What the note keeps is what does not go stale — the id, the plan, the location, the image, the labels. The power state is a column and never a note: "running" about a machine somebody switched off last March is worse than no note. ◆ WHERE IT LIVES: TWO FLAGS OVER ONE CONTENT COLUMN. Drawn over the Preferences page inside settings mode exactly as ImportScreen is, with SettingsNav unmoved and the titlebar offering "Back to preferences". IsHetznerImportOpen is a second flag rather than a "which importer" enum, because they are two independent facts about the content column and a single value would need a "neither" member IsImportOpen already expresses; what keeps them from both being true is that every path that opens one closes the other. The titlebar and the Preferences page ask IsImporterOpen, which is written against both. Preferences grows a second importer row beside the first rather than one row that asks "from where?", which would put a question in front of the far more common answer. ◆ WHAT IS NOT HERE. Hetzner's Robot — dedicated servers and Storage Boxes — is a different API behind a different credential, and nothing in this touches it. Said on the Preferences row and on the token step rather than left to be discovered by an account that has both. And there is no phone layout. This one would work on Android, unlike the ssh_config importer beside it: it is a token and a network request, the view model is shared already, and scoped storage has nothing to do with it. What is missing is a screen — the desktop draws a five-column table, and the phone version of that is a different layout rather than the same one narrower. Recorded in android-port.md and on the phone's own Preferences screen, because the note there previously read as though no import of any kind could work. ◆ TESTS, AND WHAT IS LEFT FOR A PERSON. HetznerCloudClientTests is 16 cases over the shapes the API actually returns, against a stubbed HttpMessageHandler: paging followed, the token in a header and never a query string, a refused token distinguished from a fault at Hetzner's end, a token with a line break refused before anything is sent, the /64, the private-network asymmetry, and a server the API described without a name. ShellFlowTests.Hetzner.cs is 11 more against this fixture's real vault, real cache and real server — split into a file of its own, as FakeVaultServer keeps its team endpoints. What they establish beyond the reader's own suite is the half it cannot reach: that reading stores nothing, that importing goes through the ordinary create-and-push path and lands on the server with nothing pressing Sync, that a server the keychain already has arrives unticked, and that the token does not outlive the request. Two layout cases measure both steps against the same content budget every other settings page gets, which is also what proves the markup binds to names that exist. Manual checks 2.11a–2.11d are what no test in this repository can do, because there is no account, no token and no network in the ordinary suite: a real project listed against the real console, a wrong token, the private choice, and what an imported host does when you connect to it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
657 lines
25 KiB
C#
657 lines
25 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);
|
|
}
|
|
|
|
// ---- 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"),
|
|
});
|
|
}
|
|
}
|