Public Access
Merge branch 'main' into claude/vault-unlock-logout-autosync-a84c35
ci / build and test (push) Failing after 3s
ci / build and test (push) Failing after 3s
Four files needed a hand, and all four were two branches adding something in the same place rather than either changing what the other did. The shell's constructor now takes both new parameters: main's SFTP session factory, which it must have because it builds the transfers view model, and this branch's optional resume handler, which stays last so every existing test that constructs a shell without one still gets a shell that can only be online because somebody signed in during this run. App.axaml.cs, ShellFlowTests and QuickConnectTests pass the pair; the layout suite keeps both of its new fields. Signing out now detaches the transfers screen exactly as locking does, and the confirmation says that an open transfer session survives it. That is the same policy both sides already argue for their own case: signing out destroys this machine's copy of the vault, not work that authenticated before it. QuickConnectTests did not compile on main — the SFTP commit added a constructor parameter and the quick-connect suite, merged from a parallel branch just before it, was still calling the old one. Fixed here rather than worked around, since the merged tree has to build. dotnet build, dotnet test and dotnet format --verify-no-changes are all clean: 980 tests, including the end-to-end suite against real containers.
This commit is contained in:
@@ -10,7 +10,7 @@ namespace DodoSSH.Client.App.Tests;
|
||||
/// sshd — which <c>DodoSSH.Client.Ssh.Tests</c> already covers against a container. What this makes
|
||||
/// testable is everything the connect path does <em>around</em> the connection.
|
||||
/// </remarks>
|
||||
internal sealed class FakeSshConnectionFactory : ISshConnectionFactory
|
||||
internal sealed class FakeSshConnectionFactory : ISshConnectionFactory, ISftpSessionFactory
|
||||
{
|
||||
/// <summary>Thrown instead of connecting, when set. Used for the host-key paths.</summary>
|
||||
internal Exception? Failure { get; set; }
|
||||
@@ -18,6 +18,14 @@ internal sealed class FakeSshConnectionFactory : ISshConnectionFactory
|
||||
/// <summary>Requests this factory was asked for, in order.</summary>
|
||||
internal List<SshConnectionRequest> Requests { get; } = [];
|
||||
|
||||
/// <summary>Requests for a file-transfer session, in order.</summary>
|
||||
/// <remarks>
|
||||
/// Kept apart from <see cref="Requests"/> deliberately: file transfer is a separate connection, and a
|
||||
/// test asserting that opening a terminal did not also open one would have nothing to look at if the two
|
||||
/// shared a list.
|
||||
/// </remarks>
|
||||
internal List<SshConnectionRequest> SftpRequests { get; } = [];
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<ISshConnection> ConnectAsync(
|
||||
SshConnectionRequest request,
|
||||
@@ -29,6 +37,81 @@ internal sealed class FakeSshConnectionFactory : ISshConnectionFactory
|
||||
? Task.FromException<ISshConnection>(failure)
|
||||
: Task.FromResult<ISshConnection>(new FakeSshConnection(request));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<ISftpSession> OpenSftpAsync(
|
||||
SshConnectionRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
SftpRequests.Add(request);
|
||||
|
||||
return Failure is { } failure
|
||||
? Task.FromException<ISftpSession>(failure)
|
||||
: Task.FromResult<ISftpSession>(new FakeSftpSession(request));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>A remote filesystem with one directory in it.</summary>
|
||||
/// <remarks>
|
||||
/// Enough for the shell suite, which is about what the screen does around a session rather than about
|
||||
/// moving bytes. The queue's own behaviour is covered against a fuller fake in
|
||||
/// <c>DodoSSH.Client.Transfer.Tests</c>, and the real subsystem against a container in
|
||||
/// <c>DodoSSH.Client.Ssh.Tests</c>.
|
||||
/// </remarks>
|
||||
internal sealed class FakeSftpSession(SshConnectionRequest request) : ISftpSession
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public bool IsConnected { get; private set; } = true;
|
||||
|
||||
/// <inheritdoc />
|
||||
public HostKeyPresentation HostKey { get; } =
|
||||
new(request.Host, request.Port, "ssh-ed25519", "SHA256:fake");
|
||||
|
||||
/// <inheritdoc />
|
||||
public string HomeDirectory => $"/home/{request.Username}";
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<IReadOnlyList<SftpEntry>> ListAsync(string path, CancellationToken cancellationToken) =>
|
||||
Task.FromResult<IReadOnlyList<SftpEntry>>(
|
||||
[
|
||||
new SftpEntry(
|
||||
"notes.txt",
|
||||
SftpPath.Combine(path, "notes.txt"),
|
||||
SftpEntryKind.File,
|
||||
12,
|
||||
DateTimeOffset.UnixEpoch,
|
||||
"-rw-r--r--"),
|
||||
]);
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<SftpEntry?> StatAsync(string path, CancellationToken cancellationToken) =>
|
||||
Task.FromResult<SftpEntry?>(null);
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<Stream> OpenReadAsync(string path, long offset, CancellationToken cancellationToken) =>
|
||||
Task.FromResult<Stream>(new MemoryStream("hello there\n"u8.ToArray(), writable: false));
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<Stream> OpenWriteAsync(string path, long offset, CancellationToken cancellationToken) =>
|
||||
Task.FromResult<Stream>(new MemoryStream());
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task CreateDirectoryAsync(string path, CancellationToken cancellationToken) =>
|
||||
Task.CompletedTask;
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task DeleteAsync(string path, CancellationToken cancellationToken) => Task.CompletedTask;
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task RenameAsync(string fromPath, string toPath, CancellationToken cancellationToken) =>
|
||||
Task.CompletedTask;
|
||||
|
||||
/// <inheritdoc />
|
||||
public ValueTask DisposeAsync()
|
||||
{
|
||||
IsConnected = false;
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class FakeSshConnection(SshConnectionRequest request) : ISshConnection
|
||||
|
||||
@@ -123,6 +123,7 @@ public sealed class ShellFlowTests : IAsyncLifetime
|
||||
deviceKeys,
|
||||
SignInAsync,
|
||||
TimeProvider.System,
|
||||
ssh,
|
||||
CheapProfile,
|
||||
ResumeAsync);
|
||||
|
||||
@@ -307,6 +308,7 @@ public sealed class ShellFlowTests : IAsyncLifetime
|
||||
new UnavailableDeviceKeyStore(),
|
||||
(_, _) => throw new InvalidOperationException("The shell went to the network to unlock."),
|
||||
TimeProvider.System,
|
||||
ssh,
|
||||
CheapProfile);
|
||||
|
||||
await using var _ = offline.ConfigureAwait(false);
|
||||
@@ -752,6 +754,7 @@ public sealed class ShellFlowTests : IAsyncLifetime
|
||||
new UnavailableDeviceKeyStore(),
|
||||
(_, _) => throw new InvalidOperationException("unreachable"),
|
||||
TimeProvider.System,
|
||||
ssh,
|
||||
CheapProfile);
|
||||
|
||||
await using var _ = offline.ConfigureAwait(false);
|
||||
@@ -2079,6 +2082,7 @@ public sealed class ShellFlowTests : IAsyncLifetime
|
||||
keys ?? new UnavailableDeviceKeyStore(),
|
||||
(_, _) => throw new InvalidOperationException("The shell opened a browser on launch."),
|
||||
TimeProvider.System,
|
||||
ssh,
|
||||
CheapProfile,
|
||||
resume);
|
||||
|
||||
@@ -2208,6 +2212,7 @@ public sealed class ShellFlowTests : IAsyncLifetime
|
||||
deviceKeys,
|
||||
(_, _) => throw new InvalidOperationException("The shell went to the network."),
|
||||
TimeProvider.System,
|
||||
ssh,
|
||||
CheapProfile);
|
||||
|
||||
await using var _ = offline.ConfigureAwait(false);
|
||||
@@ -2555,6 +2560,85 @@ public sealed class ShellFlowTests : IAsyncLifetime
|
||||
shell.State.ShouldBe(ShellState.NeedsServer);
|
||||
}
|
||||
|
||||
// ---- File transfer ----
|
||||
|
||||
[Fact]
|
||||
public async Task TheTransfersScreen_TakesItsHostListFromTheUnlockedVault()
|
||||
{
|
||||
await UnlockedAsync();
|
||||
await AddHostAsync(shell.Vault!, "prod-db");
|
||||
|
||||
// Attached at unlock, after the vault has loaded. Before that ordering was right the picker was
|
||||
// empty until something else happened to reload it.
|
||||
shell.Transfers.Hosts.ShouldBeEmpty("the vault had no hosts when it was attached");
|
||||
|
||||
await shell.LockCommand.ExecuteAsync(null);
|
||||
shell.Passphrase = Passphrase;
|
||||
await shell.UnlockCommand.ExecuteAsync(null);
|
||||
|
||||
shell.Transfers.Hosts.Select(host => host.Label).ShouldBe(["prod-db"]);
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// The lock policy, applied to the other thing that can be in flight. <c>LockAsync</c> argues that
|
||||
/// locking must not destroy work — it is what somebody does when they walk away from the machine, which
|
||||
/// is exactly when a long transfer is most likely to be running. A screen rebuilt per unlock would have
|
||||
/// dropped the session and with it whatever was moving.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task Locking_LeavesAFileTransferConnectionOpenAndOnlyTakesTheHostListAway()
|
||||
{
|
||||
await UnlockedAsync();
|
||||
|
||||
var vault = shell.Vault!;
|
||||
await AddHostAsync(vault, "prod-db");
|
||||
|
||||
shell.Transfers.Attach(vault, knownHosts);
|
||||
shell.Transfers.SelectedHost = shell.Transfers.Hosts[0];
|
||||
|
||||
await shell.Transfers.ConnectCommand.ExecuteAsync(null);
|
||||
|
||||
shell.Transfers.IsConnected.ShouldBeTrue(shell.Transfers.Status);
|
||||
|
||||
await shell.LockCommand.ExecuteAsync(null);
|
||||
|
||||
shell.Transfers.IsConnected.ShouldBeTrue("locking the vault is not a disconnect");
|
||||
|
||||
// What it does take is the host list, and it has to: those rows carry decrypted secrets and the
|
||||
// vault they came from has just been disposed.
|
||||
shell.Transfers.Hosts.ShouldBeEmpty();
|
||||
shell.Transfers.SelectedHost.ShouldBeNull();
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// The consequence of SSH.NET having no way to open an SFTP subsystem on an existing transport, made
|
||||
/// visible: browsing a host's files authenticates again rather than reusing the terminal's connection.
|
||||
/// It is asserted rather than merely written down because the host's audit log shows a second login,
|
||||
/// and somebody will eventually be asked to explain it.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task ConnectingTheTransfersScreen_OpensItsOwnConnectionRatherThanReusingATerminals()
|
||||
{
|
||||
var vault = await ReadyToConnectAsync();
|
||||
|
||||
await ConnectWithRendererAsync(vault);
|
||||
|
||||
ssh.Requests.Count.ShouldBe(1);
|
||||
ssh.SftpRequests.ShouldBeEmpty("opening a terminal must not open a file-transfer session");
|
||||
|
||||
shell.Transfers.Attach(vault, knownHosts);
|
||||
shell.Transfers.SelectedHost = shell.Transfers.Hosts[0];
|
||||
|
||||
await shell.Transfers.ConnectCommand.ExecuteAsync(null);
|
||||
|
||||
ssh.SftpRequests.Count.ShouldBe(1);
|
||||
ssh.Requests.Count.ShouldBe(1, "and it must not open a shell either");
|
||||
|
||||
// It opens on the account's home directory, which is the only path the layer knows without asking.
|
||||
shell.Transfers.RemotePath.ShouldBe("/home/deploy");
|
||||
shell.Transfers.RemoteEntries.Select(entry => entry.Name).ShouldBe(["notes.txt"]);
|
||||
}
|
||||
|
||||
private async Task UnlockedAsync()
|
||||
{
|
||||
await EnrolledAndConfirmedAsync();
|
||||
|
||||
@@ -476,7 +476,8 @@
|
||||
"CommunityToolkit.Mvvm": "[8.4.2, )",
|
||||
"DodoSSH.Client.Session": "[1.0.0, )",
|
||||
"DodoSSH.Client.Ssh": "[1.0.0, )",
|
||||
"DodoSSH.Client.Terminal": "[1.0.0, )"
|
||||
"DodoSSH.Client.Terminal": "[1.0.0, )",
|
||||
"DodoSSH.Client.Transfer": "[1.0.0, )"
|
||||
}
|
||||
},
|
||||
"dodossh.client.auth": {
|
||||
@@ -527,6 +528,12 @@
|
||||
"DodoSSH.Client.Ssh": "[1.0.0, )"
|
||||
}
|
||||
},
|
||||
"dodossh.client.transfer": {
|
||||
"type": "Project",
|
||||
"dependencies": {
|
||||
"DodoSSH.Client.Ssh": "[1.0.0, )"
|
||||
}
|
||||
},
|
||||
"dodossh.contracts": {
|
||||
"type": "Project"
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user