Public Access
542 lines
20 KiB
C#
542 lines
20 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());
|
|
}
|
|
|
|
// ---- 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);
|
|
}
|
|
}
|
|
}
|