Public Access
Merge branch 'main' into the Android head
Main grew the screens the host-management plan called for — hosts, pins, snippets, logs, import, teams — plus the ObjectStore and Import projects behind two of them, and moved WindowsDeviceKeyStore into the desktop head's Platform folder. Five of those view models landed in a directory this branch had already moved, so they join the rest in DodoSSH.Client.Shell: git spotted the rename and put them there, and the namespaces followed. Shell picks up ObjectStore and Import as a result, which the Android head then gets transitively and will use neither of at first — scoped storage means there is no ~/.ssh/config to import, and file transfer is out of its first scope. Desktop suites green at 155 and 64.
This commit is contained in:
@@ -0,0 +1,366 @@
|
||||
using DodoSSH.Client.Api;
|
||||
using DodoSSH.Contracts;
|
||||
using DodoSSH.Crypto;
|
||||
|
||||
namespace DodoSSH.Client.App.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// The team, directory and grant half of the fake server.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>The key log is real.</b> Entries are chained with <see cref="KeyLogChain.ComputeEntryHash"/> exactly
|
||||
/// as the server chains them, because the client refuses to wrap a vault key to a directory answer that
|
||||
/// does not appear in a log whose chain verifies — so a fake that returned a plausible-looking log would
|
||||
/// make every sharing test pass against a check that was never exercised. It also means a test can break
|
||||
/// the chain deliberately and watch the client refuse.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Everything else is deliberately thin. Roles, slugs and idempotency are the server's rules and are
|
||||
/// tested against the real one in <c>DodoSSH.Api.Tests</c>; what the shell needs from here is that a team
|
||||
/// can be created, a member added, and a vault key wrapped and recorded.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal sealed partial class FakeVaultServer : ITeamApi, IDirectoryApi, IVaultGrantApi
|
||||
{
|
||||
private readonly List<TeamSummary> teams = [];
|
||||
private readonly Dictionary<Guid, List<TeamMemberSummary>> members = [];
|
||||
private readonly Dictionary<Guid, VaultSummary> teamVaults = [];
|
||||
private readonly Dictionary<(Guid VaultId, Guid UserId), IssueVaultGrantRequest> grants = [];
|
||||
private readonly List<KeyLogRecord> keyLog = [];
|
||||
private readonly List<DirectoryEntry> directory = [];
|
||||
|
||||
/// <inheritdoc />
|
||||
public ITeamApi Teams => this;
|
||||
|
||||
/// <inheritdoc />
|
||||
public IDirectoryApi Directory => this;
|
||||
|
||||
/// <inheritdoc />
|
||||
public IVaultGrantApi Grants => this;
|
||||
|
||||
/// <summary>Grants this fake has been asked to record, for a test to assert on.</summary>
|
||||
internal IReadOnlyDictionary<(Guid VaultId, Guid UserId), IssueVaultGrantRequest> IssuedGrants => grants;
|
||||
|
||||
/// <summary>
|
||||
/// When true, the log served omits its last entry's link, so its chain no longer verifies.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The switch a test flips to prove the client refuses rather than shares. A fake with no way to be
|
||||
/// wrong can only ever confirm the happy path.
|
||||
/// </remarks>
|
||||
internal bool CorruptKeyLog { get; set; }
|
||||
|
||||
/// <summary>Registers another account, as though they had signed in and enrolled here.</summary>
|
||||
/// <returns>Their user id.</returns>
|
||||
internal Guid AddAccount(string email, string displayName)
|
||||
{
|
||||
var userId = Guid.CreateVersion7();
|
||||
|
||||
// Real keys rather than filler: the client recomputes the fingerprint over both halves and refuses
|
||||
// an entry whose fingerprint does not match, so random bytes would fail for the wrong reason.
|
||||
using var bundle = UserSecretBundle.Create(DateTimeOffset.UnixEpoch);
|
||||
|
||||
var sequence = AppendKeyLog(
|
||||
userId, bundle.EncryptionPublicKey, bundle.SigningPublicKey, new byte[64]);
|
||||
|
||||
directory.Add(new DirectoryEntry(
|
||||
userId,
|
||||
email,
|
||||
displayName,
|
||||
bundle.EncryptionPublicKey,
|
||||
bundle.SigningPublicKey,
|
||||
DshCrypto.ComputeFingerprint(bundle.EncryptionPublicKey, bundle.SigningPublicKey),
|
||||
KeyGeneration: 1,
|
||||
KeyLogSequence: sequence));
|
||||
|
||||
return userId;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<IReadOnlyList<TeamSummary>> ListTeamsAsync(CancellationToken cancellationToken) =>
|
||||
Task.FromResult<IReadOnlyList<TeamSummary>>([.. teams]);
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<TeamSummary> CreateTeamAsync(
|
||||
CreateTeamRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var team = new TeamSummary(
|
||||
request.TeamId,
|
||||
request.Name,
|
||||
request.Slug,
|
||||
request.Description,
|
||||
TeamMemberRole.Owner,
|
||||
MemberCount: 1,
|
||||
VaultCount: 0,
|
||||
DateTimeOffset.UnixEpoch);
|
||||
|
||||
teams.Add(team);
|
||||
|
||||
members[team.TeamId] =
|
||||
[
|
||||
new TeamMemberSummary(
|
||||
UserId,
|
||||
"alice@example.com",
|
||||
"Alice Example",
|
||||
TeamMemberRole.Owner,
|
||||
TeamMemberStatus.Active,
|
||||
IsEnrolled: true,
|
||||
DateTimeOffset.UnixEpoch),
|
||||
];
|
||||
|
||||
return Task.FromResult(team);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<IReadOnlyList<TeamMemberSummary>> ListTeamMembersAsync(
|
||||
Guid teamId,
|
||||
CancellationToken cancellationToken) =>
|
||||
Task.FromResult<IReadOnlyList<TeamMemberSummary>>(
|
||||
members.TryGetValue(teamId, out var list) ? [.. list] : []);
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<TeamMemberSummary> AddTeamMemberAsync(
|
||||
Guid teamId,
|
||||
AddTeamMemberRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var entry = directory.Find(candidate => candidate.UserId == request.UserId)
|
||||
?? throw new DodoSshApiException(
|
||||
System.Net.HttpStatusCode.BadRequest,
|
||||
ProblemCodes.InvalidTeam,
|
||||
"No such account on this server.");
|
||||
|
||||
var member = new TeamMemberSummary(
|
||||
entry.UserId,
|
||||
entry.Email,
|
||||
entry.DisplayName,
|
||||
request.Role,
|
||||
TeamMemberStatus.Active,
|
||||
IsEnrolled: true,
|
||||
DateTimeOffset.UnixEpoch);
|
||||
|
||||
members[teamId] = [.. members.GetValueOrDefault(teamId, []), member];
|
||||
|
||||
Recount(teamId);
|
||||
|
||||
return Task.FromResult(member);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<TeamMemberSummary> ChangeTeamMemberRoleAsync(
|
||||
Guid teamId,
|
||||
Guid userId,
|
||||
ChangeTeamMemberRoleRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var list = members.GetValueOrDefault(teamId, []);
|
||||
var index = list.FindIndex(member => member.UserId == userId);
|
||||
|
||||
if (index < 0)
|
||||
{
|
||||
throw new DodoSshApiException(
|
||||
System.Net.HttpStatusCode.BadRequest,
|
||||
ProblemCodes.InvalidTeam,
|
||||
"That account is not an active member of this team.");
|
||||
}
|
||||
|
||||
list[index] = list[index] with { Role = request.Role };
|
||||
|
||||
return Task.FromResult(list[index]);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<bool> RemoveTeamMemberAsync(
|
||||
Guid teamId,
|
||||
Guid userId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var list = members.GetValueOrDefault(teamId, []);
|
||||
var removed = list.RemoveAll(member => member.UserId == userId) > 0;
|
||||
|
||||
// Every grant they held from this team goes with them, as the real service revokes them in the
|
||||
// same transaction. A fake that removed the membership and left the grants would let a test
|
||||
// "prove" a revocation that had not happened.
|
||||
foreach (var vaultId in teamVaults.Values
|
||||
.Where(vault => vault.TeamId == teamId)
|
||||
.Select(vault => vault.VaultId))
|
||||
{
|
||||
grants.Remove((vaultId, userId));
|
||||
}
|
||||
|
||||
Recount(teamId);
|
||||
|
||||
return Task.FromResult(removed);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<VaultSummary> CreateTeamVaultAsync(
|
||||
Guid teamId,
|
||||
CreateTeamVaultRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var vault = new VaultSummary(
|
||||
request.VaultId,
|
||||
request.Name,
|
||||
IsPersonal: false,
|
||||
TeamId: teamId,
|
||||
KeyGeneration: 1,
|
||||
Permissions: 31,
|
||||
request.WrappedVaultKey,
|
||||
RekeyRequired: false);
|
||||
|
||||
teamVaults[vault.VaultId] = vault;
|
||||
|
||||
Recount(teamId);
|
||||
|
||||
return Task.FromResult(vault);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<IReadOnlyList<DirectoryEntry>> LookupByEmailAsync(
|
||||
string email,
|
||||
CancellationToken cancellationToken) =>
|
||||
Task.FromResult<IReadOnlyList<DirectoryEntry>>(
|
||||
[
|
||||
.. directory.Where(entry =>
|
||||
string.Equals(entry.Email, email, StringComparison.OrdinalIgnoreCase)),
|
||||
]);
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<DirectoryEntry?> LookupByIdAsync(Guid userId, CancellationToken cancellationToken) =>
|
||||
Task.FromResult(directory.Find(entry => entry.UserId == userId));
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<KeyLogPage> ReadKeyLogAsync(
|
||||
long afterSequence,
|
||||
int? limit,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var page = keyLog.Where(entry => entry.Sequence > afterSequence).ToList();
|
||||
|
||||
if (CorruptKeyLog && page.Count > 0)
|
||||
{
|
||||
// One byte, in the field the chain is built from. Enough to break the link and nothing else,
|
||||
// which is what a tampered log would look like.
|
||||
var last = page[^1];
|
||||
page[^1] = last with { EncryptionPublicKey = [.. last.EncryptionPublicKey.Reverse()] };
|
||||
}
|
||||
|
||||
var head = keyLog.Count == 0
|
||||
? KeyLogChain.CreateGenesisPreviousHash()
|
||||
: keyLog[^1].Hash;
|
||||
|
||||
return Task.FromResult(new KeyLogPage(page, keyLog.Count, head, HasMore: false));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<VaultGrantsResponse> ListVaultGrantsAsync(
|
||||
Guid vaultId,
|
||||
CancellationToken cancellationToken) =>
|
||||
Task.FromResult(new VaultGrantsResponse(
|
||||
vaultId,
|
||||
KeyGeneration: 1,
|
||||
RekeyRequired: false,
|
||||
Grants:
|
||||
[
|
||||
.. grants.Where(entry => entry.Key.VaultId == vaultId).Select(entry =>
|
||||
new VaultGrantSummary(
|
||||
entry.Key.UserId,
|
||||
directory.Find(candidate => candidate.UserId == entry.Key.UserId)?.Email,
|
||||
null,
|
||||
KeyGeneration: 1,
|
||||
VaultGrantState.Active,
|
||||
UserId,
|
||||
DateTimeOffset.UnixEpoch,
|
||||
null)),
|
||||
]));
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task IssueVaultGrantAsync(
|
||||
Guid vaultId,
|
||||
IssueVaultGrantRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
grants[(vaultId, request.RecipientUserId)] = request;
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<bool> RevokeVaultGrantAsync(
|
||||
Guid vaultId,
|
||||
Guid userId,
|
||||
CancellationToken cancellationToken) =>
|
||||
Task.FromResult(grants.Remove((vaultId, userId)));
|
||||
|
||||
/// <summary>Publishes the enrolling account's own key, in the directory and the key log.</summary>
|
||||
private void RegisterSelf(KeyStatement statement, byte[] statementSignature)
|
||||
{
|
||||
if (directory.Exists(entry => entry.UserId == UserId))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var sequence = AppendKeyLog(
|
||||
UserId, statement.EncryptionPublicKey, statement.SigningPublicKey, statementSignature);
|
||||
|
||||
directory.Add(new DirectoryEntry(
|
||||
UserId,
|
||||
"alice@example.com",
|
||||
"Alice Example",
|
||||
statement.EncryptionPublicKey,
|
||||
statement.SigningPublicKey,
|
||||
DshCrypto.ComputeFingerprint(statement.EncryptionPublicKey, statement.SigningPublicKey),
|
||||
statement.KeyGeneration,
|
||||
sequence));
|
||||
}
|
||||
|
||||
/// <summary>Appends a key log entry, chained as the real log chains it.</summary>
|
||||
private long AppendKeyLog(
|
||||
Guid userId,
|
||||
byte[] encryptionPublicKey,
|
||||
byte[] signingPublicKey,
|
||||
byte[] statementSignature)
|
||||
{
|
||||
var previous = keyLog.Count == 0
|
||||
? KeyLogChain.CreateGenesisPreviousHash()
|
||||
: keyLog[^1].Hash;
|
||||
|
||||
var createdAt = KeyLogChain.TruncateTimestamp(DateTimeOffset.UnixEpoch);
|
||||
var sequence = keyLog.Count + 1;
|
||||
|
||||
var hash = KeyLogChain.ComputeEntryHash(
|
||||
previous, userId, 1, encryptionPublicKey, signingPublicKey, statementSignature, createdAt);
|
||||
|
||||
keyLog.Add(new KeyLogRecord(
|
||||
sequence,
|
||||
userId,
|
||||
Generation: 1,
|
||||
encryptionPublicKey,
|
||||
signingPublicKey,
|
||||
statementSignature,
|
||||
previous,
|
||||
hash,
|
||||
createdAt));
|
||||
|
||||
return sequence;
|
||||
}
|
||||
|
||||
private void Recount(Guid teamId)
|
||||
{
|
||||
var index = teams.FindIndex(team => team.TeamId == teamId);
|
||||
|
||||
if (index < 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
teams[index] = teams[index] with
|
||||
{
|
||||
MemberCount = members.GetValueOrDefault(teamId, []).Count,
|
||||
VaultCount = teamVaults.Values.Count(vault => vault.TeamId == teamId),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -17,7 +17,7 @@ namespace DodoSSH.Client.App.Tests;
|
||||
/// conflict behaviour is covered in <c>DodoSSH.Client.Sync.Tests</c> against a server that enforces
|
||||
/// version checks.
|
||||
/// </remarks>
|
||||
internal sealed class FakeVaultServer : IVaultServer, IAccountApi, ISyncApi, IKeyBindingAuthorizer
|
||||
internal sealed partial class FakeVaultServer : IVaultServer, IAccountApi, ISyncApi, IKeyBindingAuthorizer
|
||||
{
|
||||
private readonly List<SyncChange> log = [];
|
||||
|
||||
@@ -41,7 +41,24 @@ internal sealed class FakeVaultServer : IVaultServer, IAccountApi, ISyncApi, IKe
|
||||
|
||||
internal bool IsEnrolled => statement is not null;
|
||||
|
||||
internal int LiveRowCount => rows.Values.Count(row => row.Operation != SyncOperation.Delete);
|
||||
/// <summary>
|
||||
/// How many of the <em>user's</em> items are live on this server.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Log entries are excluded, and every assertion that uses this was written before they existed and
|
||||
/// means exactly what it says: "the host reached the server". Counting the connection and activity
|
||||
/// entries alongside them would make a number about somebody's keychain depend on how many times they
|
||||
/// had connected — which is what <see cref="LogRowCount"/> is for.
|
||||
/// </remarks>
|
||||
internal int LiveRowCount => rows.Values.Count(row =>
|
||||
row.Operation != SyncOperation.Delete && !IsLog(row.EntityType));
|
||||
|
||||
/// <summary>How many log entries are live on this server, of either kind.</summary>
|
||||
internal int LogRowCount => rows.Values.Count(row =>
|
||||
row.Operation != SyncOperation.Delete && IsLog(row.EntityType));
|
||||
|
||||
private static bool IsLog(SyncEntityType type) =>
|
||||
type is SyncEntityType.ConnectionLogEntry or SyncEntityType.ActivityLogEntry;
|
||||
|
||||
/// <summary>Device wraps registered after enrollment, keyed on the device public key.</summary>
|
||||
internal Dictionary<string, byte[]> RegisteredDevices { get; } = new(StringComparer.Ordinal);
|
||||
@@ -114,7 +131,11 @@ internal sealed class FakeVaultServer : IVaultServer, IAccountApi, ISyncApi, IKe
|
||||
KeyGeneration: statement?.KeyGeneration,
|
||||
WrappedPrivateKey: wrappedPrivateKey,
|
||||
KdfParameters: kdfParameters,
|
||||
Vaults: personalVault is null ? [] : [personalVault]));
|
||||
|
||||
// Team vaults alongside the personal one, in the order the real /me returns them: this is
|
||||
// where a vault somebody shared arrives, and a fake that only ever reported the personal one
|
||||
// would make a refresh that admits a new vault untestable.
|
||||
Vaults: personalVault is null ? [] : [personalVault, .. teamVaults.Values]));
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<EnrollmentResponse> EnrollAsync(
|
||||
@@ -127,6 +148,10 @@ internal sealed class FakeVaultServer : IVaultServer, IAccountApi, ISyncApi, IKe
|
||||
wrappedPrivateKey = request.WrappedPrivateKey;
|
||||
kdfParameters = request.KdfParameters;
|
||||
|
||||
// The enrolling account joins the directory and the key log, as it does on the real server. Both
|
||||
// are what a later share reads: this client verifies its own entry as part of verifying anyone's.
|
||||
RegisterSelf(request.Statement, request.StatementSignature);
|
||||
|
||||
personalVault = new VaultSummary(
|
||||
request.PersonalVault.VaultId,
|
||||
request.PersonalVault.Name,
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
using DodoSSH.Client.Shell.ViewModels;
|
||||
using DodoSSH.Client.Auth;
|
||||
using DodoSSH.Client.Import;
|
||||
using DodoSSH.Client.Session;
|
||||
|
||||
// FakeDeviceKeyStore is compiled into this assembly from a source link and keeps its original namespace;
|
||||
// see the csproj for why it is shared rather than reimplemented.
|
||||
using DodoSSH.Client.Session.Tests;
|
||||
using DodoSSH.Client.Shell.ViewModels;
|
||||
using DodoSSH.Client.Ssh;
|
||||
using DodoSSH.Client.Storage;
|
||||
using DodoSSH.Client.Terminal;
|
||||
@@ -494,6 +494,250 @@ public sealed class ShellFlowTests : IAsyncLifetime
|
||||
requests.ShouldBe(0);
|
||||
}
|
||||
|
||||
// ---- Which surface is showing ----
|
||||
//
|
||||
// The tab strip is visible from every screen, so a terminal and a page are two things the window can be
|
||||
// showing rather than one screen among five. These fix that state machine. None of them can see the
|
||||
// WebView itself — headless Avalonia has no native window — but every transition below is decided here,
|
||||
// in ordinary objects, which is why they are worth having.
|
||||
|
||||
/// <remarks>
|
||||
/// The point of the whole rework, stated as one assertion: a terminal opened from somewhere other than
|
||||
/// the hosts screen shows, and the screen underneath it does not move. Moving it would make opening a
|
||||
/// terminal a way to lose your place in a transfer that is still running.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task OpeningATerminalFromAnotherScreen_ShowsItAndLeavesTheScreenWhereItWas()
|
||||
{
|
||||
var vault = await ReadyToConnectAsync();
|
||||
|
||||
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
|
||||
|
||||
shell.ShowScreenCommand.Execute(ShellScreen.Transfers);
|
||||
|
||||
await vault.ConnectCommand.ExecuteAsync(null);
|
||||
|
||||
shell.Surface.ShouldBe(ShellSurface.Terminal);
|
||||
shell.IsTerminalShowing.ShouldBeTrue();
|
||||
shell.IsShowingPages.ShouldBeFalse();
|
||||
|
||||
shell.Screen.ShouldBe(ShellScreen.Transfers, "the page underneath is what closing the tab returns to");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ANavRailClick_HidesTheTerminalAndKeepsTheTab()
|
||||
{
|
||||
var vault = await ReadyToConnectAsync();
|
||||
|
||||
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
|
||||
await vault.ConnectCommand.ExecuteAsync(null);
|
||||
|
||||
shell.ShowScreenCommand.Execute(ShellScreen.Vault);
|
||||
|
||||
shell.IsTerminalShowing.ShouldBeFalse();
|
||||
shell.IsShowingPages.ShouldBeTrue();
|
||||
shell.IsVaultShowing.ShouldBeTrue();
|
||||
|
||||
// The session is untouched. Navigating away from a terminal is not a way to end one; only closing
|
||||
// its tab is.
|
||||
var tab = shell.Tabs.ShouldHaveSingleItem();
|
||||
tab.IsLive.ShouldBeTrue();
|
||||
shell.SelectedTab.ShouldBe(tab);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ClickingATab_BringsTheTerminalBackFromAPage()
|
||||
{
|
||||
var vault = await ReadyToConnectAsync();
|
||||
|
||||
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
|
||||
await vault.ConnectCommand.ExecuteAsync(null);
|
||||
|
||||
shell.ShowScreenCommand.Execute(ShellScreen.Preferences);
|
||||
shell.IsTerminalShowing.ShouldBeFalse();
|
||||
|
||||
shell.SelectTabCommand.Execute(shell.Tabs[0]);
|
||||
|
||||
shell.IsTerminalShowing.ShouldBeTrue();
|
||||
shell.Screen.ShouldBe(ShellScreen.Preferences);
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// A visible WebView with no pane in it reads as the application having broken, so this is the one
|
||||
/// transition that moves the surface back on its own.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task ClosingTheLastTab_ReturnsToThePageThatWasShowing()
|
||||
{
|
||||
var vault = await ReadyToConnectAsync();
|
||||
|
||||
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
|
||||
|
||||
shell.ShowScreenCommand.Execute(ShellScreen.Transfers);
|
||||
await vault.ConnectCommand.ExecuteAsync(null);
|
||||
|
||||
await shell.CloseTabCommand.ExecuteAsync(shell.Tabs[0]);
|
||||
|
||||
shell.Tabs.ShouldBeEmpty();
|
||||
shell.SelectedTab.ShouldBeNull();
|
||||
shell.IsTerminalShowing.ShouldBeFalse();
|
||||
shell.IsTransfersShowing.ShouldBeTrue("the page that was showing when the terminal opened");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ClosingOneOfTwoTabs_KeepsTheTerminalShowing()
|
||||
{
|
||||
var vault = await ReadyToConnectAsync();
|
||||
|
||||
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
|
||||
|
||||
await vault.ConnectCommand.ExecuteAsync(null);
|
||||
await vault.ConnectCommand.ExecuteAsync(null);
|
||||
|
||||
shell.Tabs.Count.ShouldBe(2);
|
||||
|
||||
// The selected one, which is the second. The neighbour takes its place and the terminal stays.
|
||||
await shell.CloseTabCommand.ExecuteAsync(shell.SelectedTab!);
|
||||
|
||||
shell.SelectedTab.ShouldBe(shell.Tabs.ShouldHaveSingleItem());
|
||||
shell.IsTerminalShowing.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ClosingATabThatIsNotSelected_ChangesNothingAboutTheSurface()
|
||||
{
|
||||
var vault = await ReadyToConnectAsync();
|
||||
|
||||
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
|
||||
|
||||
await vault.ConnectCommand.ExecuteAsync(null);
|
||||
var first = shell.Tabs[0];
|
||||
|
||||
await vault.ConnectCommand.ExecuteAsync(null);
|
||||
var second = shell.Tabs[1];
|
||||
|
||||
await shell.CloseTabCommand.ExecuteAsync(first);
|
||||
|
||||
shell.SelectedTab.ShouldBe(second);
|
||||
shell.IsTerminalShowing.ShouldBeTrue();
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// A shell outlives a lock, so there can be a selected tab while the unlock card is up. The card and the
|
||||
/// terminal share a rectangle, and the card is the one that has to win.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task Locking_HidesTheTerminalWhateverTheSurfaceWas()
|
||||
{
|
||||
var vault = await ReadyToConnectAsync();
|
||||
|
||||
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
|
||||
await vault.ConnectCommand.ExecuteAsync(null);
|
||||
|
||||
shell.IsTerminalShowing.ShouldBeTrue();
|
||||
|
||||
await shell.LockCommand.ExecuteAsync(null);
|
||||
|
||||
shell.IsTerminalShowing.ShouldBeFalse();
|
||||
shell.Tabs.ShouldHaveSingleItem().IsLive.ShouldBeTrue("locking does not end a session");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AnUnlock_LandsOnAPageEvenWithATabStillOpen()
|
||||
{
|
||||
var vault = await ReadyToConnectAsync();
|
||||
|
||||
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
|
||||
await vault.ConnectCommand.ExecuteAsync(null);
|
||||
|
||||
await shell.LockCommand.ExecuteAsync(null);
|
||||
|
||||
shell.Passphrase = Passphrase;
|
||||
await shell.UnlockCommand.ExecuteAsync(null);
|
||||
|
||||
shell.State.ShouldBe(ShellState.Unlocked, shell.StatusMessage);
|
||||
|
||||
shell.IsHostsShowing.ShouldBeTrue();
|
||||
shell.IsTerminalShowing.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ThePalette_HidesTheTerminalAndClosingItBringsItBack()
|
||||
{
|
||||
var vault = await ReadyToConnectAsync();
|
||||
|
||||
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
|
||||
await vault.ConnectCommand.ExecuteAsync(null);
|
||||
|
||||
shell.ToggleSearchCommand.Execute(null);
|
||||
|
||||
shell.IsSearching.ShouldBeTrue();
|
||||
shell.IsTerminalShowing.ShouldBeFalse("the palette draws over the terminal's rectangle");
|
||||
|
||||
shell.CloseSearchCommand.Execute(null);
|
||||
|
||||
shell.IsTerminalShowing.ShouldBeTrue();
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// The rail marks where you are, and a terminal is not one of its destinations. Lighting HOSTS while a
|
||||
/// terminal fills the window would point at a screen that is not showing — and the selected tab already
|
||||
/// carries that mark, in the strip.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task TheNavRailLightsExactlyOneEntryOnAPage_AndNoneOnATerminal()
|
||||
{
|
||||
var vault = await ReadyToConnectAsync();
|
||||
|
||||
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
|
||||
|
||||
LitEntries().ShouldBe(1);
|
||||
|
||||
await vault.ConnectCommand.ExecuteAsync(null);
|
||||
LitEntries().ShouldBe(0);
|
||||
|
||||
shell.ShowScreenCommand.Execute(ShellScreen.Vault);
|
||||
LitEntries().ShouldBe(1);
|
||||
shell.IsVaultShowing.ShouldBeTrue();
|
||||
|
||||
int LitEntries() => new[]
|
||||
{
|
||||
shell.IsHostsShowing,
|
||||
shell.IsTransfersShowing,
|
||||
shell.IsVaultShowing,
|
||||
shell.IsTeamShowing,
|
||||
shell.IsPreferencesShowing,
|
||||
}.Count(lit => lit);
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// The palette can be opened from any screen, and an unknown host key is answered by a prompt drawn on
|
||||
/// the hosts screen. Without this the connection would block on a question sitting behind whatever screen
|
||||
/// the user happened to be on.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task ConnectingFromThePalette_LandsOnTheHostsPageBeforeItCanBeRefused()
|
||||
{
|
||||
var vault = await ReadyToConnectAsync();
|
||||
|
||||
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
|
||||
|
||||
shell.ShowScreenCommand.Execute(ShellScreen.Transfers);
|
||||
|
||||
ssh.Failure = new SshHostKeyUnknownException(
|
||||
new HostKeyPresentation("db.internal", 22, "ssh-ed25519", "SHA256:unknown"));
|
||||
|
||||
shell.ToggleSearchCommand.Execute(null);
|
||||
shell.SelectedSearchResult = shell.SearchResults[0];
|
||||
|
||||
await shell.ConnectToSearchResultCommand.ExecuteAsync(null);
|
||||
|
||||
vault.HasPendingHostKey.ShouldBeTrue();
|
||||
|
||||
shell.IsHostsShowing.ShouldBeTrue("the prompt is drawn on the hosts screen");
|
||||
shell.IsTerminalShowing.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TrustingAHostKey_PinsItInTheVaultAndConnects()
|
||||
{
|
||||
@@ -1609,7 +1853,7 @@ public sealed class ShellFlowTests : IAsyncLifetime
|
||||
await vault.ConnectCommand.ExecuteAsync(null);
|
||||
|
||||
ssh.Requests.ShouldBeEmpty("nothing should have been dialled at all");
|
||||
vault.Status.ShouldContain("not in this vault");
|
||||
vault.Status.ShouldContain("not in this keychain");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -1844,7 +2088,7 @@ public sealed class ShellFlowTests : IAsyncLifetime
|
||||
vault.SelectedHost = vault.Hosts[0];
|
||||
|
||||
vault.SelectedHostAsksForAPassword.ShouldBeFalse();
|
||||
vault.SelectedHostAuthenticationNote.ShouldContain("stored in your vault");
|
||||
vault.SelectedHostAuthenticationNote.ShouldContain("stored in your keychain");
|
||||
}
|
||||
|
||||
// ---- Authenticating with a stored credential ----
|
||||
@@ -1952,7 +2196,7 @@ public sealed class ShellFlowTests : IAsyncLifetime
|
||||
await vault.ConnectCommand.ExecuteAsync(null);
|
||||
|
||||
ssh.Requests.ShouldBeEmpty("nothing should have been dialled at all");
|
||||
vault.Status.ShouldContain("credential that is not in this vault");
|
||||
vault.Status.ShouldContain("credential that is not in this keychain");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -2169,18 +2413,235 @@ public sealed class ShellFlowTests : IAsyncLifetime
|
||||
vault.KnownHostPins.ShouldHaveSingleItem();
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// Pins used to be a category on the keychain screen. They are a destination of their own now, and this
|
||||
/// is the seam that could silently come apart: the screen's view model is built from the vault in
|
||||
/// <c>OnVaultChanged</c>, so a vault opened by any path other than the one this test takes would leave
|
||||
/// the nav rail pointing at a null.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task ThePinSectionIsReachableAndTakesItsTurn()
|
||||
public async Task ThePinsScreenExistsForAsLongAsTheKeychainDoes()
|
||||
{
|
||||
await UnlockedAsync();
|
||||
|
||||
var pins = shell.KnownHostsScreen.ShouldNotBeNull("unlocking builds it");
|
||||
|
||||
shell.ShowScreenCommand.Execute(ShellScreen.KnownHosts);
|
||||
shell.IsKnownHostsShowing.ShouldBeTrue();
|
||||
shell.IsVaultShowing.ShouldBeFalse();
|
||||
|
||||
await knownHosts.TrustAsync(
|
||||
new HostKeyPresentation("db.internal", 22, "ssh-ed25519", "SHA256:the-key"), Token);
|
||||
await shell.Vault!.LoadAsync(Token);
|
||||
|
||||
pins.VisiblePins.ShouldHaveSingleItem().Host.ShouldBe("db.internal");
|
||||
|
||||
await shell.LockCommand.ExecuteAsync(null);
|
||||
|
||||
shell.KnownHostsScreen.ShouldBeNull("it goes with the keychain it was built from");
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// The workflow the screen exists for: an operator publishes a fingerprint and somebody wants to know
|
||||
/// whether it is the one they approved. A filter that searched only host names would answer a different
|
||||
/// question, so this is the assertion that keeps the fingerprint in the search.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task ThePinsScreenFiltersByFingerprintAsWellAsByHost()
|
||||
{
|
||||
await UnlockedAsync();
|
||||
|
||||
await knownHosts.TrustAsync(
|
||||
new HostKeyPresentation("db.internal", 22, "ssh-ed25519", "SHA256:aaaaaaaa"), Token);
|
||||
await knownHosts.TrustAsync(
|
||||
new HostKeyPresentation("web.internal", 22, "ssh-ed25519", "SHA256:bbbbbbbb"), Token);
|
||||
|
||||
await shell.Vault!.LoadAsync(Token);
|
||||
|
||||
var pins = shell.KnownHostsScreen!;
|
||||
pins.VisiblePins.Count.ShouldBe(2);
|
||||
|
||||
pins.Filter = "bbbb";
|
||||
pins.VisiblePins.ShouldHaveSingleItem().Host.ShouldBe("web.internal");
|
||||
|
||||
pins.Filter = "db.";
|
||||
pins.VisiblePins.ShouldHaveSingleItem().Host.ShouldBe("db.internal");
|
||||
|
||||
pins.Filter = "nothing matches this";
|
||||
pins.VisiblePins.ShouldBeEmpty();
|
||||
pins.EmptyMessage.ShouldContain("matches that", Case.Insensitive);
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// Forgetting is forwarded to the vault's command, which is the one wired into the reload and the push.
|
||||
/// What this covers is the forwarding: that the screen's own selection reaches it.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task ForgettingFromThePinsScreen_WithdrawsTheTrust()
|
||||
{
|
||||
await UnlockedAsync();
|
||||
|
||||
await knownHosts.TrustAsync(
|
||||
new HostKeyPresentation("db.internal", 22, "ssh-ed25519", "SHA256:the-key"), Token);
|
||||
await shell.Vault!.LoadAsync(Token);
|
||||
|
||||
var pins = shell.KnownHostsScreen!;
|
||||
pins.Selected = pins.VisiblePins[0];
|
||||
|
||||
await pins.ForgetSelectedCommand.ExecuteAsync(null);
|
||||
|
||||
pins.VisiblePins.ShouldBeEmpty();
|
||||
(await knownHosts.FindAsync("db.internal", 22, "ssh-ed25519", Token)).ShouldBeNull();
|
||||
}
|
||||
|
||||
// ---- Generating a key ----
|
||||
|
||||
/// <remarks>
|
||||
/// The property that keeps this feature from being a second way to write a key: generating fills the
|
||||
/// editor and stops. Everything after that — validation, encoding, the outbox, the push — is the path a
|
||||
/// pasted key already takes, and SAVE is still the only thing that writes.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task GeneratingAKey_FillsTheEditorAndStoresNothing()
|
||||
{
|
||||
await UnlockedAsync();
|
||||
var vault = shell.Vault!;
|
||||
|
||||
vault.ShowSectionCommand.Execute(VaultSection.KnownHosts);
|
||||
vault.NewGeneratedKeyCommand.Execute(null);
|
||||
vault.IsGeneratingKey.ShouldBeTrue();
|
||||
vault.GenerateComment = "deploy@laptop";
|
||||
|
||||
vault.ShowsKnownHosts.ShouldBeTrue();
|
||||
vault.ShowsAll.ShouldBeFalse();
|
||||
vault.ShowsKeys.ShouldBeFalse();
|
||||
vault.ShowsCredentials.ShouldBeFalse();
|
||||
await vault.GenerateKeyCommand.ExecuteAsync(null);
|
||||
|
||||
vault.IsGeneratingKey.ShouldBeFalse();
|
||||
vault.IsEditingKey.ShouldBeTrue("what it made lands in the editor, unsaved");
|
||||
|
||||
vault.KeyEditorLabel.ShouldBe("deploy@laptop");
|
||||
vault.KeyEditorPrivateKey.ShouldStartWith("-----BEGIN OPENSSH PRIVATE KEY-----");
|
||||
vault.KeyEditorPublicKey.ShouldStartWith("ssh-ed25519 ");
|
||||
vault.KeyEditorPublicKey.ShouldEndWith("deploy@laptop");
|
||||
|
||||
vault.Keys.ShouldBeEmpty("nothing is stored until SAVE");
|
||||
vault.PendingChanges.ShouldBe(0);
|
||||
vault.Status.ShouldContain("SAVE");
|
||||
|
||||
// And then it saves through the ordinary path, which is the other half of the claim.
|
||||
await vault.SaveKeyCommand.ExecuteAsync(null);
|
||||
|
||||
vault.Keys.ShouldHaveSingleItem().Label.ShouldBe("deploy@laptop");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CancellingTheGenerateForm_MakesNothing()
|
||||
{
|
||||
await UnlockedAsync();
|
||||
var vault = shell.Vault!;
|
||||
|
||||
vault.NewGeneratedKeyCommand.Execute(null);
|
||||
vault.CancelGenerateKeyCommand.Execute(null);
|
||||
|
||||
vault.IsGeneratingKey.ShouldBeFalse();
|
||||
vault.IsEditingKey.ShouldBeFalse();
|
||||
vault.Keys.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// A machine with no clipboard reports itself rather than appearing to have copied. This shell is built
|
||||
/// without one, which is what makes the case reachable at all.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task CopyingAPublicKey_WithNoClipboard_SaysSo()
|
||||
{
|
||||
await UnlockedAsync();
|
||||
var vault = shell.Vault!;
|
||||
|
||||
vault.NewGeneratedKeyCommand.Execute(null);
|
||||
await vault.GenerateKeyCommand.ExecuteAsync(null);
|
||||
await vault.SaveKeyCommand.ExecuteAsync(null);
|
||||
|
||||
vault.Section = VaultSection.Keys;
|
||||
vault.SelectedVaultItem = vault.VaultItems[0];
|
||||
vault.SelectedItemIsKey.ShouldBeTrue();
|
||||
|
||||
await vault.CopyPublicKeyCommand.ExecuteAsync(null);
|
||||
|
||||
vault.Status.ShouldContain("no clipboard", Case.Insensitive);
|
||||
}
|
||||
|
||||
// ---- Importing ssh_config ----
|
||||
|
||||
/// <remarks>
|
||||
/// The whole of the import, from a file on disk to hosts on the server. What it establishes beyond the
|
||||
/// parser's own suite is the half that suite cannot reach: that scanning writes nothing, that importing
|
||||
/// goes through the ordinary create-and-push path, and that a host already in the keychain arrives
|
||||
/// unticked rather than being silently duplicated.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task ImportingAnSshConfig_ShowsItFirstAndThenStoresWhatWasTicked()
|
||||
{
|
||||
await UnlockedAsync();
|
||||
var vault = shell.Vault!;
|
||||
|
||||
await AddHostAsync(vault, "prod-db");
|
||||
|
||||
var sshDirectory = Path.Combine(directory, "ssh");
|
||||
Directory.CreateDirectory(sshDirectory);
|
||||
|
||||
// db.internal:deploy is what AddHostAsync creates, so the first entry is a host already held.
|
||||
await File.WriteAllTextAsync(
|
||||
Path.Combine(sshDirectory, "config"),
|
||||
"""
|
||||
Host already-here
|
||||
HostName db.internal
|
||||
User deploy
|
||||
|
||||
Host web-01
|
||||
HostName web-01.internal
|
||||
User deploy
|
||||
Port 2222
|
||||
""",
|
||||
Token);
|
||||
|
||||
var import = new ImportViewModel(vault, new SshConfigLocator(sshDirectory));
|
||||
|
||||
await import.ScanCommand.ExecuteAsync(null);
|
||||
|
||||
import.Rows.Count.ShouldBe(2);
|
||||
vault.Hosts.Count.ShouldBe(1, "scanning stores nothing");
|
||||
|
||||
var known = import.Rows.Single(row => string.Equals(row.Alias, "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");
|
||||
|
||||
import.Rows
|
||||
.Single(row => string.Equals(row.Alias, "web-01", StringComparison.Ordinal))
|
||||
.IsSelected.ShouldBeTrue();
|
||||
|
||||
await import.ImportCommand.ExecuteAsync(null);
|
||||
|
||||
var imported = vault.Hosts.Single(row => string.Equals(row.Label, "web-01", StringComparison.Ordinal));
|
||||
imported.Address.ShouldBe("deploy@web-01.internal:2222");
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ImportingWithNoConfigFile_SaysSoRatherThanFailing()
|
||||
{
|
||||
await UnlockedAsync();
|
||||
|
||||
var import = new ImportViewModel(
|
||||
shell.Vault!,
|
||||
new SshConfigLocator(Path.Combine(directory, "nothing-here")));
|
||||
|
||||
await import.ScanCommand.ExecuteAsync(null);
|
||||
|
||||
import.Rows.ShouldBeEmpty();
|
||||
import.Status.ShouldContain("no", Case.Insensitive);
|
||||
}
|
||||
|
||||
// ---- Filtering the host sidebar ----
|
||||
@@ -2245,6 +2706,403 @@ public sealed class ShellFlowTests : IAsyncLifetime
|
||||
vault.VisibleHosts.ShouldContain(row => ReferenceEquals(row, vault.SelectedHost));
|
||||
}
|
||||
|
||||
// ---- Groups ----
|
||||
|
||||
/// <remarks>
|
||||
/// The property that makes this feature free to ignore. Somebody with eleven machines and no wish to file
|
||||
/// them should see the list they have always seen — not a heading telling them their hosts are ungrouped.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task AVaultWithNoGroups_DrawsNoHeadings()
|
||||
{
|
||||
await UnlockedAsync();
|
||||
var vault = shell.Vault!;
|
||||
|
||||
await AddHostAsync(vault, "prod-db");
|
||||
await AddHostAsync(vault, "stage-web");
|
||||
|
||||
vault.HasGroups.ShouldBeFalse();
|
||||
vault.SidebarRows.ShouldAllBe(row => row is HostRowViewModel);
|
||||
vault.SidebarRows.Count.ShouldBe(vault.VisibleHosts.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FilingAHostIntoAGroup_PutsItUnderThatHeading()
|
||||
{
|
||||
await UnlockedAsync();
|
||||
var vault = shell.Vault!;
|
||||
|
||||
await AddHostAsync(vault, "prod-db");
|
||||
await AddHostAsync(vault, "stage-web");
|
||||
await AddGroupAsync(vault, "production");
|
||||
|
||||
await FileAsync(vault, "prod-db", "production");
|
||||
|
||||
var rows = vault.SidebarRows.ToArray();
|
||||
|
||||
// One group, so: its heading, its one host, then the ungrouped heading and the other host.
|
||||
rows[0].ShouldBeOfType<SidebarGroupHeader>().Label.ShouldBe("production");
|
||||
rows[1].ShouldBeOfType<HostRowViewModel>().Label.ShouldBe("prod-db");
|
||||
rows[2].ShouldBeOfType<SidebarGroupHeader>().Label.ShouldBe("UNGROUPED");
|
||||
rows[3].ShouldBeOfType<HostRowViewModel>().Label.ShouldBe("stage-web");
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// An empty group keeps its heading; a group emptied by the filter does not. The first is a folder
|
||||
/// somebody made and can put things in, the second is an absence of search results — and a heading with
|
||||
/// nothing under it reads as a group that has lost its contents.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task AGroupEmptiedByTheFilter_LosesItsHeading()
|
||||
{
|
||||
await UnlockedAsync();
|
||||
var vault = shell.Vault!;
|
||||
|
||||
await AddHostAsync(vault, "prod-db");
|
||||
await AddGroupAsync(vault, "production");
|
||||
await AddGroupAsync(vault, "staging");
|
||||
await FileAsync(vault, "prod-db", "production");
|
||||
|
||||
Headings(vault).ShouldBe(["production", "staging"], "an empty group keeps its heading");
|
||||
|
||||
vault.HostFilter = "nothing matches this";
|
||||
|
||||
Headings(vault).ShouldBe(["production", "staging"]);
|
||||
vault.SidebarRows.OfType<HostRowViewModel>().ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FoldingAGroupAwayHidesItsHostsAndSurvivesAReload()
|
||||
{
|
||||
await UnlockedAsync();
|
||||
var vault = shell.Vault!;
|
||||
|
||||
await AddHostAsync(vault, "prod-db");
|
||||
await AddGroupAsync(vault, "production");
|
||||
await FileAsync(vault, "prod-db", "production");
|
||||
|
||||
vault.ToggleGroupCommand.Execute(vault.SidebarRows.OfType<SidebarGroupHeader>().First());
|
||||
|
||||
vault.SidebarRows.OfType<HostRowViewModel>().ShouldBeEmpty("the group is folded away");
|
||||
|
||||
// Folded state is held by group id rather than on the row, because a background sync rebuilds every
|
||||
// row once a minute and a flag on one would be forgotten the first time it did.
|
||||
await vault.LoadAsync(Token);
|
||||
|
||||
vault.SidebarRows.OfType<HostRowViewModel>().ShouldBeEmpty("and a reload does not unfold it");
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// The heading is a row in the same <c>ListBox</c> as the hosts, so the control will select it. Nothing
|
||||
/// else in the application acts on a heading — CONNECT, EDIT and DELETE all read the host selection — so
|
||||
/// clicking one has to leave that selection exactly where it was.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task SelectingAHeading_LeavesTheHostSelectionAlone()
|
||||
{
|
||||
await UnlockedAsync();
|
||||
var vault = shell.Vault!;
|
||||
|
||||
await AddHostAsync(vault, "prod-db");
|
||||
await AddGroupAsync(vault, "production");
|
||||
await FileAsync(vault, "prod-db", "production");
|
||||
|
||||
var host = vault.Hosts.Single();
|
||||
vault.SelectedHost = host;
|
||||
|
||||
vault.SelectedSidebarRow = vault.SidebarRows.OfType<SidebarGroupHeader>().First();
|
||||
|
||||
vault.SelectedHost.ShouldBeSameAs(host);
|
||||
vault.SelectedSidebarRow.ShouldBeSameAs(host, "the heading hands the highlight straight back");
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// Deleting a group deliberately does not rewrite the hosts in it — one delete would otherwise become N
|
||||
/// writes, N outbox rows and N chances to merge against a change nobody made — so those hosts keep an id
|
||||
/// that resolves to nothing. "The group is gone" and "this host is in no group" have to look the same,
|
||||
/// because to the person reading the list they are the same thing.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task DeletingAGroup_LeavesItsHostsUnderTheUngroupedHeading()
|
||||
{
|
||||
await UnlockedAsync();
|
||||
var vault = shell.Vault!;
|
||||
|
||||
await AddHostAsync(vault, "prod-db");
|
||||
await AddGroupAsync(vault, "production");
|
||||
await FileAsync(vault, "prod-db", "production");
|
||||
|
||||
var groupId = vault.Groups.Single().EntityId;
|
||||
|
||||
vault.SelectedGroup = vault.Groups.Single();
|
||||
vault.DeleteGroupCommand.Execute(null);
|
||||
|
||||
vault.PendingDeletion.ShouldNotBeNull().Usage
|
||||
.ShouldContain("1 host", Case.Sensitive, "the count is what makes the question worth reading");
|
||||
|
||||
await vault.ConfirmDeleteCommand.ExecuteAsync(null);
|
||||
|
||||
vault.Groups.ShouldBeEmpty();
|
||||
vault.HasGroups.ShouldBeFalse();
|
||||
|
||||
// The host keeps the id, which is what makes this cheap; the sidebar is what resolves it to nothing.
|
||||
vault.Hosts.Single().Host.GroupId.ShouldBe(groupId);
|
||||
vault.SidebarRows.ShouldAllBe(row => row is HostRowViewModel);
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// The picker keeps a placeholder entry for a group the vault no longer has, exactly as the
|
||||
/// authentication picker does for a deleted key. Without it the picker would open on "No group" and
|
||||
/// somebody editing the host's port would unfile it by saving.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task EditingAHostWhoseGroupIsGone_DoesNotUnfileItBySaving()
|
||||
{
|
||||
await UnlockedAsync();
|
||||
var vault = shell.Vault!;
|
||||
|
||||
await AddHostAsync(vault, "prod-db");
|
||||
await AddGroupAsync(vault, "production");
|
||||
await FileAsync(vault, "prod-db", "production");
|
||||
|
||||
var groupId = vault.Groups.Single().EntityId;
|
||||
|
||||
vault.SelectedGroup = vault.Groups.Single();
|
||||
vault.DeleteGroupCommand.Execute(null);
|
||||
await vault.ConfirmDeleteCommand.ExecuteAsync(null);
|
||||
|
||||
vault.SelectedHost = vault.Hosts.Single();
|
||||
vault.EditSelectedHostCommand.Execute(null);
|
||||
|
||||
vault.EditorSelectedGroup.ShouldNotBeNull().EntityId.ShouldBe(groupId);
|
||||
|
||||
vault.EditorPort = 2222;
|
||||
await vault.SaveHostCommand.ExecuteAsync(null);
|
||||
|
||||
vault.Hosts.Single().Host.GroupId.ShouldBe(groupId, "an unrelated edit must not unfile the host");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RenamingAGroup_RenamesItsHeading()
|
||||
{
|
||||
await UnlockedAsync();
|
||||
var vault = shell.Vault!;
|
||||
|
||||
await AddHostAsync(vault, "prod-db");
|
||||
await AddGroupAsync(vault, "production");
|
||||
await FileAsync(vault, "prod-db", "production");
|
||||
|
||||
vault.SelectedGroup = vault.Groups.Single();
|
||||
vault.EditGroupCommand.Execute(null);
|
||||
|
||||
vault.GroupEditorLabel.ShouldBe("production", "renaming loads the current name into the box");
|
||||
|
||||
vault.GroupEditorLabel = "live";
|
||||
await vault.SaveGroupCommand.ExecuteAsync(null);
|
||||
|
||||
Headings(vault).ShouldBe(["live"]);
|
||||
vault.EditingGroupId.ShouldBeNull("the box goes back to creating once the rename is saved");
|
||||
}
|
||||
|
||||
// ---- Snippets ----
|
||||
|
||||
/// <remarks>
|
||||
/// The default that the whole feature's safety rests on. A snippet somebody writes without thinking
|
||||
/// about the flag has to be one that gets typed and waits, because the alternative is a command that
|
||||
/// runs the first time it is clicked.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task ANewSnippet_DoesNotRunOnItsOwn()
|
||||
{
|
||||
await UnlockedAsync();
|
||||
var vault = shell.Vault!;
|
||||
var snippets = shell.SnippetsScreen.ShouldNotBeNull();
|
||||
|
||||
snippets.NewCommand.Execute(null);
|
||||
|
||||
snippets.EditorRunsOnInsert.ShouldBeFalse("the box starts off");
|
||||
|
||||
snippets.EditorLabel = "restart the api";
|
||||
snippets.EditorCommand = "sudo systemctl restart dodossh-api";
|
||||
await snippets.SaveCommand.ExecuteAsync(null);
|
||||
|
||||
vault.Snippets.ShouldHaveSingleItem().RunsOnInsert.ShouldBeFalse();
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// A here-document's terminator has to arrive on a line of its own with nothing after it. Trim the
|
||||
/// trailing newline and the shell waits for one that never comes, which reads to the user as the snippet
|
||||
/// having hung the terminal — so the command is stored exactly as typed, in the same way key armour is.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task ASnippetsText_IsStoredExactlyAsTyped()
|
||||
{
|
||||
await UnlockedAsync();
|
||||
var vault = shell.Vault!;
|
||||
var snippets = shell.SnippetsScreen.ShouldNotBeNull();
|
||||
|
||||
const string Command = "cat <<'EOF' > /etc/motd\n welcome \nEOF\n";
|
||||
|
||||
snippets.NewCommand.Execute(null);
|
||||
snippets.EditorLabel = " set the motd ";
|
||||
snippets.EditorCommand = Command;
|
||||
await snippets.SaveCommand.ExecuteAsync(null);
|
||||
|
||||
var stored = vault.Snippets.ShouldHaveSingleItem();
|
||||
|
||||
stored.Snippet.Command.ShouldBe(Command);
|
||||
stored.Label.ShouldBe("set the motd", "the name is trimmed, and only the name");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InsertingASnippet_SendsItsTextToTheSelectedTabWithoutRunningIt()
|
||||
{
|
||||
await UnlockedAsync();
|
||||
|
||||
var sent = new List<(uint SessionId, string Text, bool Execute)>();
|
||||
var snippets = SnippetsOver(shell.Vault!, new InsertTarget(7, "prod-db"), sent);
|
||||
|
||||
await AddSnippetAsync(snippets, "uptime", "uptime", runs: false);
|
||||
|
||||
snippets.Selected = snippets.Visible.Single();
|
||||
snippets.CanInsert.ShouldBeTrue();
|
||||
|
||||
await snippets.InsertCommand.ExecuteAsync(null);
|
||||
|
||||
var delivered = sent.ShouldHaveSingleItem();
|
||||
|
||||
delivered.SessionId.ShouldBe(7u);
|
||||
delivered.Text.ShouldBe("uptime");
|
||||
delivered.Execute.ShouldBeFalse("INSERT types the command and stops");
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// RUN is offered only for a snippet whose own flag says it runs, so that "this one runs" is a decision
|
||||
/// taken once while writing it. Pressing the command for a snippet without the flag has to do nothing —
|
||||
/// not throw, and above all not send.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task RunningASnippet_IsRefusedUnlessTheSnippetSaysItRuns()
|
||||
{
|
||||
await UnlockedAsync();
|
||||
|
||||
var sent = new List<(uint SessionId, string Text, bool Execute)>();
|
||||
var snippets = SnippetsOver(shell.Vault!, new InsertTarget(7, "prod-db"), sent);
|
||||
|
||||
await AddSnippetAsync(snippets, "safe", "ls -la", runs: false);
|
||||
await AddSnippetAsync(snippets, "armed", "sudo reboot", runs: true);
|
||||
|
||||
snippets.Selected = snippets.Visible.Single(row => !row.RunsOnInsert);
|
||||
snippets.SelectionRuns.ShouldBeFalse();
|
||||
|
||||
await snippets.RunCommand.ExecuteAsync(null);
|
||||
sent.ShouldBeEmpty("this snippet is not one that runs");
|
||||
|
||||
snippets.Selected = snippets.Visible.Single(row => row.RunsOnInsert);
|
||||
snippets.SelectionRuns.ShouldBeTrue();
|
||||
|
||||
await snippets.RunCommand.ExecuteAsync(null);
|
||||
|
||||
sent.ShouldHaveSingleItem().Execute.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InsertingWithNoTerminalOpen_SaysSoAndSendsNothing()
|
||||
{
|
||||
await UnlockedAsync();
|
||||
|
||||
var sent = new List<(uint SessionId, string Text, bool Execute)>();
|
||||
var snippets = SnippetsOver(shell.Vault!, InsertTarget.None, sent);
|
||||
|
||||
await AddSnippetAsync(snippets, "uptime", "uptime", runs: false);
|
||||
|
||||
snippets.Selected = snippets.Visible.Single();
|
||||
|
||||
snippets.CanInsert.ShouldBeFalse();
|
||||
snippets.InsertLabel.ShouldBe("NO TERMINAL OPEN");
|
||||
|
||||
await snippets.InsertCommand.ExecuteAsync(null);
|
||||
|
||||
sent.ShouldBeEmpty();
|
||||
snippets.Status.ShouldContain("Open a terminal first", Case.Sensitive);
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// The transport drops frames for a pane nothing is listening to, so a send at a tab whose remote hung
|
||||
/// up succeeds exactly as loudly as one at a live tab. That is why the insert reports back — and why the
|
||||
/// screen has to say so rather than leaving somebody to wonder whether the command landed.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task InsertingIntoATabThatIsNoLongerConnected_SaysSo()
|
||||
{
|
||||
await UnlockedAsync();
|
||||
|
||||
var snippets = new SnippetsViewModel(
|
||||
shell.Vault!,
|
||||
() => new InsertTarget(7, "prod-db"),
|
||||
static (_, _, _, _) => Task.FromResult(false));
|
||||
|
||||
await AddSnippetAsync(snippets, "uptime", "uptime", runs: false);
|
||||
|
||||
snippets.Selected = snippets.Visible.Single();
|
||||
await snippets.InsertCommand.ExecuteAsync(null);
|
||||
|
||||
snippets.Status.ShouldContain("no longer connected", Case.Sensitive);
|
||||
}
|
||||
|
||||
private static SnippetsViewModel SnippetsOver(
|
||||
VaultViewModel vault,
|
||||
InsertTarget target,
|
||||
List<(uint SessionId, string Text, bool Execute)> sent) =>
|
||||
new(
|
||||
vault,
|
||||
() => target,
|
||||
(sessionId, text, execute, _) =>
|
||||
{
|
||||
sent.Add((sessionId, text, execute));
|
||||
return Task.FromResult(true);
|
||||
});
|
||||
|
||||
private static async Task AddSnippetAsync(
|
||||
SnippetsViewModel snippets,
|
||||
string label,
|
||||
string command,
|
||||
bool runs)
|
||||
{
|
||||
snippets.NewCommand.Execute(null);
|
||||
snippets.EditorLabel = label;
|
||||
snippets.EditorCommand = command;
|
||||
snippets.EditorRunsOnInsert = runs;
|
||||
|
||||
await snippets.SaveCommand.ExecuteAsync(null);
|
||||
}
|
||||
|
||||
private static string[] Headings(VaultViewModel vault) =>
|
||||
[.. vault.SidebarRows.OfType<SidebarGroupHeader>()
|
||||
.Where(header => header.GroupId is not null)
|
||||
.Select(header => header.Label)];
|
||||
|
||||
private static async Task AddGroupAsync(VaultViewModel vault, string label)
|
||||
{
|
||||
vault.GroupEditorLabel = label;
|
||||
|
||||
await vault.SaveGroupCommand.ExecuteAsync(null);
|
||||
}
|
||||
|
||||
/// <summary>Files a host into a group the way a user can: through the host's own editor.</summary>
|
||||
private static async Task FileAsync(VaultViewModel vault, string host, string group)
|
||||
{
|
||||
vault.SelectedHost = vault.Hosts.Single(
|
||||
row => string.Equals(row.Label, host, StringComparison.Ordinal));
|
||||
|
||||
vault.EditSelectedHostCommand.Execute(null);
|
||||
|
||||
vault.EditorSelectedGroup = vault.EditorGroupChoices.Single(
|
||||
choice => string.Equals(choice.Label, group, StringComparison.Ordinal));
|
||||
|
||||
await vault.SaveHostCommand.ExecuteAsync(null);
|
||||
}
|
||||
|
||||
// ---- Helpers ----
|
||||
|
||||
private static CancellationToken Token => TestContext.Current.CancellationToken;
|
||||
|
||||
@@ -0,0 +1,276 @@
|
||||
using DodoSSH.Client.Session;
|
||||
// FakeDeviceKeyStore is compiled into this assembly from a source link and keeps its original namespace;
|
||||
// see the csproj for why it is shared rather than reimplemented.
|
||||
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;
|
||||
|
||||
namespace DodoSSH.Client.App.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Teams, from the side that holds the keys: create one, add somebody, and wrap a vault key to them.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The reason this suite exists rather than leaving teams to the server's own tests is that the
|
||||
/// interesting half is not on the server. Adding a member is a row; <b>sharing is a decision the client
|
||||
/// makes about whether to trust a public key the server just handed it</b>, and that decision is what
|
||||
/// stands between an end-to-end encrypted vault and one the operator can read by answering a directory
|
||||
/// lookup with a key of their own.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// So the fake server keeps a real key log — chained with the same <c>KeyLogChain</c> the server uses —
|
||||
/// and can be told to corrupt it. A test that only ever saw a well-formed log would be checking that
|
||||
/// sharing works, not that verification does.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class TeamSharingTests : IAsyncLifetime
|
||||
{
|
||||
private const string Passphrase = "a sufficiently long passphrase";
|
||||
|
||||
private static readonly Argon2Profile CheapProfile =
|
||||
Argon2Profile.FromStoredParameters(memoryKibibytes: 8 * 1024, passes: 1, parallelism: 1);
|
||||
|
||||
private readonly FakeVaultServer server = new();
|
||||
private readonly FakeSshConnectionFactory ssh = new();
|
||||
|
||||
private string directory = null!;
|
||||
private ClientCacheFactory caches = null!;
|
||||
private TerminalWorkspace workspace = null!;
|
||||
private VaultKnownHostStore knownHosts = null!;
|
||||
private FakeDeviceKeyStore deviceKeys = null!;
|
||||
private MainWindowViewModel shell = null!;
|
||||
|
||||
private static CancellationToken Token => TestContext.Current.CancellationToken;
|
||||
|
||||
/// <inheritdoc />
|
||||
public ValueTask InitializeAsync()
|
||||
{
|
||||
directory = Path.Combine(Path.GetTempPath(), $"dodossh-teams-{Guid.CreateVersion7():N}");
|
||||
|
||||
var paths = new ClientPaths(directory);
|
||||
|
||||
caches = ClientCacheFactory.ForFile(paths.CacheFile);
|
||||
knownHosts = new VaultKnownHostStore();
|
||||
deviceKeys = new FakeDeviceKeyStore();
|
||||
|
||||
workspace = new TerminalWorkspace(
|
||||
new InMemoryTerminalAssetProvider(
|
||||
new Dictionary<string, TerminalAsset>(StringComparer.Ordinal)),
|
||||
ssh,
|
||||
TimeProvider.System);
|
||||
|
||||
shell = new MainWindowViewModel(
|
||||
paths,
|
||||
caches,
|
||||
workspace,
|
||||
knownHosts,
|
||||
deviceKeys,
|
||||
(_, _) => Task.FromResult<IVaultServer>(server),
|
||||
TimeProvider.System,
|
||||
NSubstitute.Substitute.For<ISftpSessionFactory>(),
|
||||
CheapProfile);
|
||||
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
await shell.DisposeAsync();
|
||||
knownHosts.Close();
|
||||
await workspace.DisposeAsync();
|
||||
caches.Dispose();
|
||||
|
||||
try
|
||||
{
|
||||
Directory.Delete(directory, recursive: true);
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
// A cache file the process has not finished releasing. The directory is under the temp path
|
||||
// and named per run, so leaving it costs a few kilobytes and never collides.
|
||||
}
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// The whole point of a team, in one test. Note what the status line says after the add and before
|
||||
/// the share: adding somebody grants them nothing readable, and the interface has to say so rather
|
||||
/// than let a user believe the credential is already with their colleague.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task CreatingATeamAndSharingItsVault_WrapsTheKeyToTheOtherMember()
|
||||
{
|
||||
await UnlockedAsync();
|
||||
|
||||
var teams = shell.Teams;
|
||||
var colleague = server.AddAccount("bob@example.com", "Bob Example");
|
||||
|
||||
await CreateTeamAsync(teams, "Platform", "platform");
|
||||
|
||||
await teams.CreateVaultCommand.ExecuteAsync(null);
|
||||
teams.Vaults.Count.ShouldBe(1, teams.Status);
|
||||
|
||||
teams.InviteEmail = "bob@example.com";
|
||||
await teams.AddMemberCommand.ExecuteAsync(null);
|
||||
|
||||
teams.Members.Count.ShouldBe(2, teams.Status);
|
||||
teams.Status.ShouldContain("cannot read anything yet");
|
||||
|
||||
teams.SelectedMember = teams.Members.Single(member => member.UserId == colleague);
|
||||
teams.SelectedVault = teams.Vaults[0];
|
||||
|
||||
await teams.ShareVaultCommand.ExecuteAsync(null);
|
||||
|
||||
var vaultId = teams.Vaults[0].VaultId;
|
||||
|
||||
server.IssuedGrants.ShouldContainKey((vaultId, colleague));
|
||||
teams.Status.ShouldContain("Shared");
|
||||
|
||||
// The one thing verification cannot promise, said in the same breath as the success.
|
||||
teams.Status.ShouldContain("fingerprint", Case.Insensitive);
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The test this whole design exists for. A server that wants to read a team's vault only has to
|
||||
/// answer one directory lookup with a key it holds the private half of — so the client reads the
|
||||
/// append-only key log, verifies its chain, and refuses to wrap anything unless the key it was
|
||||
/// offered is in there unchanged.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Nothing may be sent. A refusal that still issued the grant, or that issued it on a retry, would be
|
||||
/// worse than no check at all, because the interface would have said it was verified.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task ATamperedKeyLog_StopsTheShareRatherThanWarningAboutIt()
|
||||
{
|
||||
await UnlockedAsync();
|
||||
|
||||
var teams = shell.Teams;
|
||||
var colleague = server.AddAccount("mallory@example.com", "Mallory Example");
|
||||
|
||||
await CreateTeamAsync(teams, "Platform", "platform");
|
||||
await teams.CreateVaultCommand.ExecuteAsync(null);
|
||||
|
||||
teams.InviteEmail = "mallory@example.com";
|
||||
await teams.AddMemberCommand.ExecuteAsync(null);
|
||||
|
||||
teams.SelectedMember = teams.Members.Single(member => member.UserId == colleague);
|
||||
teams.SelectedVault = teams.Vaults[0];
|
||||
|
||||
server.CorruptKeyLog = true;
|
||||
|
||||
await teams.ShareVaultCommand.ExecuteAsync(null);
|
||||
|
||||
server.IssuedGrants.ShouldBeEmpty();
|
||||
teams.Status.ShouldContain("Did not share");
|
||||
teams.Status.ShouldContain("key log");
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// A vault created here is usable here, without a relock. The key was generated in this process, so
|
||||
/// making the user lock and unlock to reach the vault they just made would be asking them to work
|
||||
/// around bookkeeping.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task ATeamVaultCreatedHere_IsImmediatelyReadableAndWritable()
|
||||
{
|
||||
await UnlockedAsync();
|
||||
|
||||
var teams = shell.Teams;
|
||||
|
||||
await CreateTeamAsync(teams, "Platform", "platform");
|
||||
await teams.CreateVaultCommand.ExecuteAsync(null);
|
||||
|
||||
var vaultId = teams.Vaults[0].VaultId;
|
||||
var session = shell.Vault!.Session;
|
||||
|
||||
session.ReadableVaults.Select(vault => vault.VaultId).ShouldContain(vaultId);
|
||||
|
||||
// And it is offered as somewhere to file a new item, which is what makes it worth having.
|
||||
await shell.Vault.LoadAsync(Token);
|
||||
|
||||
shell.Vault.TargetVaults.Select(choice => choice.VaultId).ShouldContain(vaultId);
|
||||
shell.Vault.HasVaultChoice.ShouldBeTrue();
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// Filing into a team vault has to be chosen and has to stick. The bug this guards is the obvious
|
||||
/// one: an editor that read the picker at save time rather than at open time, so changing the picker
|
||||
/// with a half-typed host on screen would move it.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task AHostFiledIntoATeamVault_StaysThere()
|
||||
{
|
||||
await UnlockedAsync();
|
||||
|
||||
var teams = shell.Teams;
|
||||
|
||||
await CreateTeamAsync(teams, "Platform", "platform");
|
||||
await teams.CreateVaultCommand.ExecuteAsync(null);
|
||||
|
||||
var vault = shell.Vault!;
|
||||
var teamVaultId = teams.Vaults[0].VaultId;
|
||||
|
||||
await vault.LoadAsync(Token);
|
||||
|
||||
vault.SelectedTargetVault =
|
||||
vault.TargetVaults.Single(choice => choice.VaultId == teamVaultId);
|
||||
|
||||
vault.NewHostCommand.Execute(null);
|
||||
vault.EditorLabel = "prod-db";
|
||||
vault.EditorHostname = "db.internal";
|
||||
vault.EditorUsername = "deploy";
|
||||
|
||||
// Moved back after the editor opened. The host must still land in the team's vault.
|
||||
vault.SelectedTargetVault =
|
||||
vault.TargetVaults.First(choice => choice.VaultId != teamVaultId);
|
||||
|
||||
await vault.SaveHostCommand.ExecuteAsync(null);
|
||||
|
||||
var row = vault.Hosts.Single(
|
||||
host => string.Equals(host.Label, "prod-db", StringComparison.Ordinal));
|
||||
row.VaultId.ShouldBe(teamVaultId);
|
||||
}
|
||||
|
||||
private async Task CreateTeamAsync(TeamsViewModel teams, string name, string slug)
|
||||
{
|
||||
await teams.LoadAsync(Token);
|
||||
|
||||
teams.NewTeamCommand.Execute(null);
|
||||
teams.NewTeamName = name;
|
||||
teams.NewTeamSlug = slug;
|
||||
|
||||
await teams.CreateTeamCommand.ExecuteAsync(null);
|
||||
|
||||
teams.SelectedTeam.ShouldNotBeNull(teams.Status);
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// The whole path rather than a shortcut into the unlocked state, because sharing needs an identity
|
||||
/// key that was really enrolled: the fake server publishes it into its key log during enrollment, and
|
||||
/// that entry is what the client verifies its own directory answer against.
|
||||
/// </remarks>
|
||||
private async Task UnlockedAsync()
|
||||
{
|
||||
await shell.StartAsync(Token);
|
||||
await shell.SignInCommand.ExecuteAsync(null);
|
||||
|
||||
shell.Passphrase = Passphrase;
|
||||
shell.ConfirmPassphrase = Passphrase;
|
||||
await shell.EnrollCommand.ExecuteAsync(null);
|
||||
|
||||
shell.RecoveryCodeWrittenDown = true;
|
||||
shell.ConfirmRecoveryCodeCommand.Execute(null);
|
||||
|
||||
shell.Passphrase = Passphrase;
|
||||
await shell.UnlockCommand.ExecuteAsync(null);
|
||||
|
||||
shell.State.ShouldBe(ShellState.Unlocked, shell.StatusMessage);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
using Avalonia.Threading;
|
||||
using DodoSSH.Client.Shell.ViewModels;
|
||||
using DodoSSH.Client.Ssh;
|
||||
using NSubstitute;
|
||||
|
||||
namespace DodoSSH.Client.App.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// What may be queued for transfer, and what is said about the rest.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// This is the whole of what drag and drop decides. The handlers on the screen extract paths or rows from a
|
||||
/// drop and hand them here; every rule about which of them can be moved, which are skipped and what the
|
||||
/// status line says lives in the view model, where it needs no window.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>What these cannot cover is the drag itself.</b> Headless Avalonia has no native window and cannot
|
||||
/// synthesise a platform drag, so a test that pretended to drop a file from the file manager would pass
|
||||
/// while confirming nothing. The wiring is verified by hand — see <c>docs/manual-checks.md</c> — and what
|
||||
/// is automated is the half that a person checking by eye would most easily get wrong: the counting.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class TransferQueueingTests : IDisposable
|
||||
{
|
||||
private readonly string directory =
|
||||
Path.Combine(Path.GetTempPath(), $"dodossh-drop-{Guid.CreateVersion7():N}");
|
||||
|
||||
private readonly TransfersViewModel transfers =
|
||||
new(Substitute.For<ISftpSessionFactory>(), TimeProvider.System);
|
||||
|
||||
public TransferQueueingTests() => Directory.CreateDirectory(directory);
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Dispose()
|
||||
{
|
||||
if (Directory.Exists(directory))
|
||||
{
|
||||
Directory.Delete(directory, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// The refusal that has to happen before anything else: there is nowhere to put a file until a host is
|
||||
/// connected, and a queue that filled up first would start failing the moment one was.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public void DroppingFilesWhileDisconnected_QueuesNothingAndSaysWhy()
|
||||
{
|
||||
transfers.IsConnected = false;
|
||||
|
||||
transfers.QueueUploads([File("one.txt")]);
|
||||
|
||||
Queued().ShouldBeEmpty();
|
||||
transfers.Status.ShouldContain("Connect to a host first");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DroppingSeveralFiles_QueuesEachOfThem()
|
||||
{
|
||||
Connected();
|
||||
|
||||
transfers.QueueUploads([File("one.txt"), File("two.txt"), File("three.txt")]);
|
||||
|
||||
Queued().Count.ShouldBe(3);
|
||||
transfers.Status.ShouldContain("3 files");
|
||||
transfers.Status.ShouldContain("/srv/app");
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// The queue moves files. There is no recursive upload, and a folder dragged in and silently ignored
|
||||
/// looks exactly like a transfer that failed to start — so it is counted and reported.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public void DroppingAFolderAmongFiles_SkipsItAndSaysSo()
|
||||
{
|
||||
Connected();
|
||||
|
||||
var folder = Path.Combine(directory, "a-folder");
|
||||
Directory.CreateDirectory(folder);
|
||||
|
||||
transfers.QueueUploads([File("one.txt"), folder]);
|
||||
|
||||
Queued().ShouldHaveSingleItem();
|
||||
transfers.Status.ShouldContain("1 file");
|
||||
transfers.Status.ShouldContain("1 folder was skipped");
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// The paths in an operating-system drop come from another process and are not obliged to still be
|
||||
/// right by the time the drop lands.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public void DroppingAFileThatHasGone_SkipsItAndSaysSo()
|
||||
{
|
||||
Connected();
|
||||
|
||||
transfers.QueueUploads([File("one.txt"), Path.Combine(directory, "never-existed.txt")]);
|
||||
|
||||
Queued().ShouldHaveSingleItem();
|
||||
transfers.Status.ShouldContain("1 item was no longer there");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DroppingOnlyFolders_QueuesNothingAndDoesNotClaimOtherwise()
|
||||
{
|
||||
Connected();
|
||||
|
||||
var folder = Path.Combine(directory, "a-folder");
|
||||
Directory.CreateDirectory(folder);
|
||||
|
||||
transfers.QueueUploads([folder]);
|
||||
|
||||
Queued().ShouldBeEmpty();
|
||||
transfers.Status.ShouldContain("Nothing was queued");
|
||||
transfers.Status.ShouldContain("1 folder was skipped");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DroppingRemoteRowsOnTheLocalPane_QueuesDownloads()
|
||||
{
|
||||
Connected();
|
||||
|
||||
transfers.QueueDownloads([RemoteFile("one.log"), RemoteFile("two.log")]);
|
||||
|
||||
Queued().Count.ShouldBe(2);
|
||||
transfers.Status.ShouldContain("2 files");
|
||||
transfers.Status.ShouldContain("download into");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DroppingARemoteDirectory_SkipsItAndSaysSo()
|
||||
{
|
||||
Connected();
|
||||
|
||||
transfers.QueueDownloads([RemoteFile("one.log"), RemoteDirectory("logs")]);
|
||||
|
||||
Queued().ShouldHaveSingleItem();
|
||||
transfers.Status.ShouldContain("1 folder was skipped");
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// The buttons were the only way to queue anything before drag and drop, and they now go through the
|
||||
/// same two methods — so there is one set of rules rather than two that have to agree. This is what
|
||||
/// says they still do.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public void TheDownloadButton_GoesThroughTheSamePathAsADrop()
|
||||
{
|
||||
Connected();
|
||||
|
||||
transfers.SelectedRemoteEntry = RemoteFile("one.log");
|
||||
transfers.DownloadCommand.Execute(null);
|
||||
|
||||
Queued().ShouldHaveSingleItem();
|
||||
|
||||
// And refuses a directory in the same words, rather than with the button's own message.
|
||||
transfers.SelectedRemoteEntry = RemoteDirectory("logs");
|
||||
transfers.DownloadCommand.Execute(null);
|
||||
|
||||
Queued().Count.ShouldBe(1);
|
||||
transfers.Status.ShouldContain("1 folder was skipped");
|
||||
}
|
||||
|
||||
/// <summary>The queue's rows, once the posts that create them have been let run.</summary>
|
||||
/// <remarks>
|
||||
/// <c>TransfersViewModel</c> adds a row from the queue's own <c>Changed</c> event, which it marshals
|
||||
/// through <c>Dispatcher.UIThread</c> because the queue raises it from a pump thread. There is no
|
||||
/// Avalonia application here to drain that, so the posts are run by hand — the alternative is asserting
|
||||
/// on the status line alone, which is a string this code wrote about itself and proves nothing about
|
||||
/// anything having been enqueued.
|
||||
/// </remarks>
|
||||
private IReadOnlyList<TransferRowViewModel> Queued()
|
||||
{
|
||||
Dispatcher.UIThread.RunJobs();
|
||||
|
||||
return transfers.Transfers;
|
||||
}
|
||||
|
||||
private void Connected()
|
||||
{
|
||||
transfers.IsConnected = true;
|
||||
transfers.RemotePath = "/srv/app";
|
||||
transfers.LocalPath = directory;
|
||||
}
|
||||
|
||||
private string File(string name)
|
||||
{
|
||||
var path = Path.Combine(directory, name);
|
||||
System.IO.File.WriteAllText(path, "contents");
|
||||
|
||||
return path;
|
||||
}
|
||||
|
||||
private static RemoteEntryRowViewModel RemoteFile(string name) => new(
|
||||
new SftpEntry(name, $"/srv/app/{name}", SftpEntryKind.File, 128, DateTimeOffset.UnixEpoch, "-rw-r--r--"));
|
||||
|
||||
private static RemoteEntryRowViewModel RemoteDirectory(string name) => new(
|
||||
new SftpEntry(name, $"/srv/app/{name}", SftpEntryKind.Directory, 0, DateTimeOffset.UnixEpoch, "drwxr-xr-x"));
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
using System.Runtime.Versioning;
|
||||
using DodoSSH.Client.App.Platform;
|
||||
using DodoSSH.Client.Session;
|
||||
|
||||
namespace DodoSSH.Client.App.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// The real TPM-backed store, as far as it can be exercised without a person. Which is not far.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Two tests, and the reason there are only two is a measured finding.</b> A key created under
|
||||
/// <c>CngUIProtectionLevels.ProtectKey</c> prompts at <em>creation</em>, not only at use: the policy means
|
||||
/// "protect this key with a PIN", so Windows asks the user to set that up when the key is made. Sealing
|
||||
/// therefore prompts as well as opening, even though sealing needs only the public half.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// So anything that calls <c>SaveAsync</c>, <c>TryLoadAsync</c> with a blob present, or <c>ForgetAsync</c>
|
||||
/// after a save will block a suite forever waiting for somebody to enter a PIN. That was found by writing
|
||||
/// those tests and watching the run hang for ten minutes. They are gone; what is left is the two paths that
|
||||
/// provably reach no dialog.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The rest of this store is verified by using the application, and that is not a gap this file can close —
|
||||
/// a consent dialog needs hardware and a person by design. Disabling the UI policy to make it testable
|
||||
/// would be testing a different class, and the one property worth having would be the property removed.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[SupportedOSPlatform("windows")]
|
||||
public sealed class WindowsDeviceKeyStoreTests
|
||||
{
|
||||
private static CancellationToken Token => TestContext.Current.CancellationToken;
|
||||
|
||||
[Fact]
|
||||
public async Task OnAMachineWithATpm_TheStoreOffersItself()
|
||||
{
|
||||
// IsSupported probes with a throwaway key carrying no UI policy, which is why this one is safe to
|
||||
// run: no policy, no dialog. It is also the only honest availability test, because the platform
|
||||
// provider reports itself present on machines where creating a key then fails.
|
||||
SkipUnlessSupported();
|
||||
|
||||
var store = DesktopDeviceKeyStores.ForThisMachine(new ClientPaths(Path.GetTempPath()));
|
||||
|
||||
store.ShouldBeOfType<WindowsDeviceKeyStore>();
|
||||
(await store.IsAvailableAsync(Token)).ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task WithNothingSaved_LoadingReturnsNullWithoutPrompting()
|
||||
{
|
||||
// Reaches no dialog because it returns on the missing file, before touching the TPM at all. That is
|
||||
// also what keeps a fresh machine's unlock screen quiet: it must not prompt for a key it has never
|
||||
// been given. If this test ever hangs, that ordering has been lost.
|
||||
SkipUnlessSupported();
|
||||
|
||||
var directory = Path.Combine(Path.GetTempPath(), $"dodossh-devicekey-{Guid.CreateVersion7():N}");
|
||||
var store = new WindowsDeviceKeyStore(new ClientPaths(directory));
|
||||
|
||||
(await store.TryLoadAsync(Token)).ShouldBeNull();
|
||||
|
||||
// Nothing was created, so there is nothing to clean up — asserted, because a store that wrote a
|
||||
// directory just to answer "no" would be leaving litter on every launch of an unregistered machine.
|
||||
Directory.Exists(directory).ShouldBeFalse();
|
||||
}
|
||||
|
||||
private static void SkipUnlessSupported()
|
||||
{
|
||||
var supported = OperatingSystem.IsWindows()
|
||||
&& DesktopDeviceKeyStores.ForThisMachine(new ClientPaths(Path.GetTempPath()))
|
||||
is WindowsDeviceKeyStore;
|
||||
|
||||
if (!supported)
|
||||
{
|
||||
Assert.Skip("This machine has no TPM the platform crypto provider will hold a key in.");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -474,6 +474,8 @@
|
||||
"Avalonia.Fonts.Inter": "[12.1.1, )",
|
||||
"Avalonia.Themes.Fluent": "[12.1.1, )",
|
||||
"CommunityToolkit.Mvvm": "[8.4.2, )",
|
||||
"DodoSSH.Client.Import": "[1.0.0, )",
|
||||
"DodoSSH.Client.ObjectStore": "[1.0.0, )",
|
||||
"DodoSSH.Client.Session": "[1.0.0, )",
|
||||
"DodoSSH.Client.Shell": "[1.0.0, )",
|
||||
"DodoSSH.Client.Ssh": "[1.0.0, )",
|
||||
@@ -487,6 +489,21 @@
|
||||
"dodossh.client.domain": {
|
||||
"type": "Project"
|
||||
},
|
||||
"dodossh.client.import": {
|
||||
"type": "Project",
|
||||
"dependencies": {
|
||||
"DodoSSH.Client.Domain": "[1.0.0, )"
|
||||
}
|
||||
},
|
||||
"dodossh.client.objectstore": {
|
||||
"type": "Project",
|
||||
"dependencies": {
|
||||
"AWSSDK.Core": "[4.0.100.9, )",
|
||||
"AWSSDK.S3": "[4.0.101.6, )",
|
||||
"DodoSSH.Client.Domain": "[1.0.0, )",
|
||||
"DodoSSH.Client.Ssh": "[1.0.0, )"
|
||||
}
|
||||
},
|
||||
"dodossh.client.session": {
|
||||
"type": "Project",
|
||||
"dependencies": {
|
||||
@@ -495,7 +512,8 @@
|
||||
"DodoSSH.Client.Domain": "[1.0.0, )",
|
||||
"DodoSSH.Client.Ssh": "[1.0.0, )",
|
||||
"DodoSSH.Client.Storage": "[1.0.0, )",
|
||||
"DodoSSH.Client.Sync": "[1.0.0, )"
|
||||
"DodoSSH.Client.Sync": "[1.0.0, )",
|
||||
"DodoSSH.Client.Terminal": "[1.0.0, )"
|
||||
}
|
||||
},
|
||||
"dodossh.client.shell": {
|
||||
@@ -503,6 +521,8 @@
|
||||
"dependencies": {
|
||||
"Avalonia": "[12.1.1, )",
|
||||
"CommunityToolkit.Mvvm": "[8.4.2, )",
|
||||
"DodoSSH.Client.Import": "[1.0.0, )",
|
||||
"DodoSSH.Client.ObjectStore": "[1.0.0, )",
|
||||
"DodoSSH.Client.Session": "[1.0.0, )",
|
||||
"DodoSSH.Client.Ssh": "[1.0.0, )",
|
||||
"DodoSSH.Client.Terminal": "[1.0.0, )",
|
||||
@@ -512,6 +532,7 @@
|
||||
"dodossh.client.ssh": {
|
||||
"type": "Project",
|
||||
"dependencies": {
|
||||
"NSec.Cryptography": "[26.4.0, )",
|
||||
"SSH.NET": "[2025.1.0, )"
|
||||
}
|
||||
},
|
||||
@@ -607,6 +628,21 @@
|
||||
"Avalonia": "12.1.1"
|
||||
}
|
||||
},
|
||||
"AWSSDK.Core": {
|
||||
"type": "CentralTransitive",
|
||||
"requested": "[4.0.100.9, )",
|
||||
"resolved": "4.0.100.9",
|
||||
"contentHash": "OPYy41jZjXwxxcYRotaq24HDrwUnVtBB/mvg1IwB9D1ICXAtHqMa1sp2hpmlVJCZwjlcrPcTCJIejInvV1vp5g=="
|
||||
},
|
||||
"AWSSDK.S3": {
|
||||
"type": "CentralTransitive",
|
||||
"requested": "[4.0.101.6, )",
|
||||
"resolved": "4.0.101.6",
|
||||
"contentHash": "LsVXGc3lyJuUJe+EbGubkFeR0cVmtmj4YdMChqsqSIsjZtCMzPg2BXR7cJqcrIBGoHab3q3RS6K8T9QD2tbhhQ==",
|
||||
"dependencies": {
|
||||
"AWSSDK.Core": "[4.0.100.9, 5.0.0)"
|
||||
}
|
||||
},
|
||||
"BouncyCastle.Cryptography": {
|
||||
"type": "CentralTransitive",
|
||||
"requested": "[2.6.2, )",
|
||||
|
||||
Reference in New Issue
Block a user