Merge branch 'claude/phone-pins'

This commit is contained in:
2026-08-09 09:46:50 +02:00
6 changed files with 329 additions and 5 deletions
@@ -2,6 +2,7 @@ using System.Globalization;
using DodoSSH.Client.Auth;
using DodoSSH.Client.Domain;
using DodoSSH.Client.Import;
using DodoSSH.Client.ObjectStore;
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.
@@ -8059,6 +8060,129 @@ public sealed class ShellFlowTests : IAsyncLifetime
shell.Transfers.RemoteEntries.Select(entry => entry.Name).ShouldBe(["notes.txt"]);
}
/// <remarks>
/// The phone's Files-screen chip row, proven at the view model rather than through Avalonia: a pin
/// saved on the host before this screen ever connects to it is read straight off the row's own
/// <c>HostSecret.PinnedPaths</c> at the moment <c>MarkHostConnected</c> runs, which is what
/// <see cref="TransfersViewModel.ConnectedPinnedPaths"/>'s own remark promises rather than a live follow
/// of the vault.
/// </remarks>
[Fact]
public async Task ConnectingATransfersHostWithPins_PopulatesConnectedPinnedPaths()
{
var vault = await ReadyToConnectAsync();
vault.EditSelectedHostCommand.Execute(null);
vault.EditorNewPin = "/var/www/app";
vault.AddEditorPinCommand.Execute(null);
await vault.SaveHostCommand.ExecuteAsync(null);
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);
shell.Transfers.ConnectedPinnedPaths.ShouldBe(["/var/www/app"]);
shell.Transfers.HasConnectedPins.ShouldBeTrue();
}
/// <remarks>
/// The other half of <see cref="ConnectingATransfersHostWithPins_PopulatesConnectedPinnedPaths"/>: the
/// chip row has to go with the connection it belongs to, or a later connect to a host with no pins would
/// show the previous host's.
/// </remarks>
[Fact]
public async Task DisconnectingTheTransfersScreen_ClearsConnectedPinnedPaths()
{
var vault = await ReadyToConnectAsync();
vault.EditSelectedHostCommand.Execute(null);
vault.EditorNewPin = "/var/www/app";
vault.AddEditorPinCommand.Execute(null);
await vault.SaveHostCommand.ExecuteAsync(null);
shell.Transfers.Attach(vault, knownHosts);
shell.Transfers.SelectedHost = shell.Transfers.Hosts[0];
await shell.Transfers.ConnectCommand.ExecuteAsync(null);
shell.Transfers.HasConnectedPins.ShouldBeTrue();
await shell.Transfers.DisconnectCommand.ExecuteAsync(null);
shell.Transfers.ConnectedPinnedPaths.ShouldBeEmpty();
shell.Transfers.HasConnectedPins.ShouldBeFalse();
}
/// <remarks>
/// A bucket is an <c>IRemoteFileStore</c> with no <c>HostSecret</c> underneath it, so there is no
/// <c>PinnedPaths</c> to read at all — see <see cref="TransfersViewModel.OpenBucketAsync"/>'s own remark.
/// The bucket here is created through the same keychain route
/// <see cref="TheS3ScreenWithNoBuckets_SaysWhereOneIsMadeAndGoesThere"/> exercises, and
/// <see cref="FakeObjectStoreFactory"/> stands in for the network the way <see cref="FakeSshConnectionFactory"/>
/// already does for SFTP.
/// </remarks>
[Fact]
public async Task ConnectingABucket_LeavesConnectedPinnedPathsEmpty()
{
var vault = await ReadyToConnectAsync();
shell.Transfers.Attach(vault, knownHosts, buckets: new FakeObjectStoreFactory());
vault.NewObjectStoreCommand.Execute(null);
vault.BucketEditorLabel = "Backups";
vault.BucketEditorBucket = "backups";
vault.BucketEditorAccessKeyId = "AKIAEXAMPLE";
vault.BucketEditorSecretAccessKey = "a-secret-access-key";
vault.BucketEditorRegion = "eu-west-1";
await vault.SaveObjectStoreCommand.ExecuteAsync(null);
shell.Transfers.SelectedBucket = shell.Transfers.Buckets[0];
await shell.Transfers.ConnectCommand.ExecuteAsync(null);
shell.Transfers.IsConnected.ShouldBeTrue(shell.Transfers.Status);
shell.Transfers.ConnectedPinnedPaths.ShouldBeEmpty();
shell.Transfers.HasConnectedPins.ShouldBeFalse();
}
/// <summary>A bucket that opens and lists as empty, so a bucket connect can be proven with no network.</summary>
private sealed class FakeObjectStoreFactory : IObjectStoreFactory
{
public IRemoteFileStore Open(ObjectStoreSecret store) => new FakeBucketStore();
}
/// <summary>The minimum <see cref="IRemoteFileStore"/> a bucket connect touches: home, then a listing.</summary>
private sealed class FakeBucketStore : IRemoteFileStore
{
public bool IsConnected => true;
public string HomeDirectory => "/";
public Task<IReadOnlyList<SftpEntry>> ListAsync(string path, CancellationToken cancellationToken) =>
Task.FromResult<IReadOnlyList<SftpEntry>>([]);
public Task<SftpEntry?> StatAsync(string path, CancellationToken cancellationToken) =>
Task.FromResult<SftpEntry?>(null);
public Task<Stream> OpenReadAsync(string path, long offset, CancellationToken cancellationToken) =>
throw new NotSupportedException("Not exercised by proving a bucket connect leaves no pins.");
public Task<Stream> OpenWriteAsync(string path, long offset, CancellationToken cancellationToken) =>
throw new NotSupportedException("Not exercised by proving a bucket connect leaves no pins.");
public Task CreateDirectoryAsync(string path, CancellationToken cancellationToken) =>
throw new NotSupportedException("Not exercised by proving a bucket connect leaves no pins.");
public Task DeleteAsync(string path, CancellationToken cancellationToken) =>
throw new NotSupportedException("Not exercised by proving a bucket connect leaves no pins.");
public Task RenameAsync(string fromPath, string toPath, CancellationToken cancellationToken) =>
throw new NotSupportedException("Not exercised by proving a bucket connect leaves no pins.");
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
}
/// <remarks>
/// <para>
/// The picker that replaced the desktop's connect bar, and the four things that put it away again. It