Merge branch 'main' into the group's move and its deletion question

Main took the group's EDIT and DELETE off the GROUPS heading while this branch
was adding a MOVE beside them, so the conflict was about the same six pixels
from both directions. Main's answer wins outright, and it is the better one for
the reason its own message gives: a button beside a heading has no card under a
pointer to mean, and had to work its subject out from the selection or from the
trail. Moving a group had that problem worst of all — the thing it takes with it
is everything on the shelf, and "which shelf" is not a question a button there
could answer plainly.

So the MOVE button is gone and the menu entry it was drawn beside is the whole
of it. That entry was already in this branch, above the separator DELETE sits
below, and it needed no change: the card menu selects whatever was right-clicked
before it runs anything, which is exactly the aiming a group move wants.

Three things went with the button. ShowsGroupActions, which main deleted because
hiding buttons was all it did, and which this branch had extended to hide them
for the move panel as well. CanMoveGroupTarget, which existed to answer whether
that button was worth drawing — CanMoveSelectedHost stays, because the phone
really does leave the host's MOVE out rather than offer a refusal, and a menu
whose entries came and went would be a menu whose items move. And the two test
assertions that read them, which were describing the button rather than the
behaviour; what they were guarding is that the two panels never share the
moment, and IsConfirmingGroupDeletion says that directly.

The move panel and the deletion question both keep their place under the
heading, which is where the buttons were and is now simply where that section
puts things. They still exclude each other, by disarming rather than by a
visibility flag: MoveGroup clears a pending deletion and DeleteGroup folds the
move panel away.

Manual checks 3.3 was rewritten by main for the menu and by this branch for the
tick, and now says both; 3.3a is new and walks a two-level shelf across a vault
boundary, which is the half of this feature no headless test can watch land.
This commit is contained in:
2026-08-04 17:11:06 +02:00
38 changed files with 3416 additions and 165 deletions
+30
View File
@@ -1,4 +1,6 @@
using System.Net.Http.Headers;
using System.Net.WebSockets;
using DodoSSH.Contracts;
using DodoSSH.Infrastructure;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Testing;
@@ -107,6 +109,34 @@ public sealed class ApiFixture : WebApplicationFactory<Program>, IAsyncLifetime
/// <summary>Opens a database scope for arranging state and asserting on it.</summary>
public AsyncServiceScope CreateScope() => Services.CreateAsyncScope();
/// <summary>
/// Opens the event socket as the given subject, through the real pipeline.
/// </summary>
/// <remarks>
/// <para>
/// The bearer token goes on the upgrade request, which is the whole of the socket's authorization
/// — see ADR 0012 — so a test that stubbed it would be testing nothing. The subprotocol is offered
/// because the server refuses an upgrade that does not, and that refusal is itself under test.
/// </para>
/// <para>
/// <c>TestServer</c> speaks WebSockets in-memory with no port and no network, so these run
/// wherever the rest of the suite does.
/// </para>
/// </remarks>
public Task<WebSocket> ConnectEventsAsync(string subject, CancellationToken cancellationToken)
{
var token = IdentityProvider.MintToken(subject);
var client = Server.CreateWebSocketClient();
client.SubProtocols.Add(VaultEvents.SubProtocol);
// The server-side request, so the header is a raw string rather than a typed value.
client.ConfigureRequest = request => request.Headers.Authorization = $"Bearer {token}";
return client.ConnectAsync(
new Uri(Server.BaseAddress, VaultEvents.Path.TrimStart('/')),
cancellationToken);
}
}
/// <summary>Shares one host and container across every test class in the assembly.</summary>
@@ -56,6 +56,12 @@ public sealed class EndpointInventoryTests(ApiFixture fixture)
"POST /api/v1/vaults/{vaultId:guid}/sync/pull name=SyncPull tags=Sync policies=Enrolled anon=False",
"POST /api/v1/vaults/{vaultId:guid}/sync/push name=SyncPush tags=Sync policies=Enrolled anon=False",
// The WebSocket, gated exactly as sync is and for the same reason — it announces changes to
// vaults, and a caller who could not read one has nothing to be told about. It appears here as
// an ordinary route because that is what it is until the upgrade: the bearer token authorises
// the handshake, unlike the relay's ticket. See ADR 0012.
"GET /api/v1/events name=VaultEvents tags=Events policies=Enrolled anon=False",
// Enrolled, because the answer exists to be wrapped to and a caller with no key of their own has
// nothing to wrap and no signature to attribute it with. There is no search here — see
// DirectoryService for why an exact-match-only directory is a decision rather than a shortcut.
@@ -0,0 +1,461 @@
using System.Net;
using System.Net.WebSockets;
using System.Text;
using System.Text.Json;
using DodoSSH.Contracts;
using DodoSSH.Domain;
using DodoSSH.Infrastructure;
using Microsoft.Extensions.DependencyInjection;
namespace DodoSSH.Api.Tests;
/// <summary>
/// The push channel, over a real socket through the real authentication pipeline.
/// </summary>
/// <remarks>
/// <para>
/// The tests that matter most here are the two negatives: an unauthenticated upgrade is refused, and a
/// change to somebody else's vault does not reach this socket. A push channel that leaked <em>which
/// vault ids exist and when they change</em> would be a disclosure the pull path takes deliberate
/// trouble to avoid — <c>SyncPullEndpoint</c> answers 404 rather than 403 for exactly that reason —
/// and it would be invisible in a test that only checked that notices arrive.
/// </para>
/// <para>
/// Ordering is asserted rather than absence-within-a-timeout wherever possible. "Nothing arrived in
/// two seconds" is a test that passes on a slow machine for the wrong reason; "the first notice this
/// socket saw was about its own vault, although another vault was written to first" is not.
/// </para>
/// </remarks>
[Collection(ApiCollection.Name)]
public sealed class EventsEndpointTests(ApiFixture fixture)
{
private static readonly DateTimeOffset Now = new(2026, 8, 4, 12, 0, 0, TimeSpan.Zero);
/// <summary>
/// How long a test will wait for a frame before calling it a failure.
/// </summary>
/// <remarks>
/// Generous, because it is not a measurement: every wait here is for something already committed,
/// so the only thing this bounds is how long a genuinely broken build hangs before it reports.
/// </remarks>
private static readonly TimeSpan FrameTimeout = TimeSpan.FromSeconds(30);
// ---- The handshake ----
[Fact]
public async Task WithoutAToken_TheUpgradeIsRefused()
{
var client = fixture.Server.CreateWebSocketClient();
client.SubProtocols.Add(VaultEvents.SubProtocol);
var connecting = client.ConnectAsync(
new Uri(fixture.Server.BaseAddress, VaultEvents.Path.TrimStart('/')),
TestContext.Current.CancellationToken);
await Should.ThrowAsync<InvalidOperationException>(connecting);
}
[Fact]
public async Task BeforeEnrolling_Is403WithAnActionableCode()
{
// The same bar as sync: a caller with no identity key holds no vault key either, so every
// notice this socket could carry is about ciphertext they cannot read.
var client = fixture.CreateClientFor(NewSubject());
var response = await client.GetAsync(
new Uri(VaultEvents.Path, UriKind.Relative),
TestContext.Current.CancellationToken);
response.StatusCode.ShouldBe(HttpStatusCode.Forbidden);
var problem = await response.Content.ReadProblemAsync();
problem.ShouldNotBeNull();
problem.Code.ShouldBe(ProblemCodes.EnrollmentRequired);
}
[Fact]
public async Task APlainGet_SaysItIsAWebSocket()
{
// A person, or a client with the wrong URL. Answering with a problem document rather than a
// socket that closes is the difference between a diagnosable mistake and a mysterious one.
var client = fixture.CreateClientFor(await SeedEnrolledUserAsync());
var response = await client.GetAsync(
new Uri(VaultEvents.Path, UriKind.Relative),
TestContext.Current.CancellationToken);
response.StatusCode.ShouldBe(HttpStatusCode.BadRequest);
var problem = await response.Content.ReadProblemAsync();
problem.ShouldNotBeNull();
problem.Code.ShouldBe(ProblemCodes.MalformedRequest);
problem.Detail.ShouldNotBeNull().ShouldContain(VaultEvents.SubProtocol);
}
[Fact]
public async Task AnUpgradeWithoutTheSubprotocol_IsRefused()
{
// The subprotocol is this socket's version negotiation, so accepting an upgrade that did not
// offer it would mean answering a client in a dialect it never agreed to read.
var (subject, _) = await SeedUserWithVaultAsync();
var client = fixture.Server.CreateWebSocketClient();
client.ConfigureRequest = request => request.Headers.Authorization =
$"Bearer {fixture.IdentityProvider.MintToken(subject)}";
var connecting = client.ConnectAsync(
new Uri(fixture.Server.BaseAddress, VaultEvents.Path.TrimStart('/')),
TestContext.Current.CancellationToken);
await Should.ThrowAsync<InvalidOperationException>(connecting);
}
[Fact]
public async Task TheFirstFrameIsHello()
{
var (subject, _) = await SeedUserWithVaultAsync();
using var timeout = Timeout();
using var socket = await fixture.ConnectEventsAsync(subject, timeout.Token);
var hello = await ReadAsync(socket, timeout.Token);
hello.Kind.ShouldBe(VaultEventKinds.Hello);
// Sent so a client knows when silence means the socket is dead rather than quiet, and so a
// socket following nothing — a real state, for an account with no vaults — is distinguishable
// from one that is broken.
hello.HeartbeatSeconds.ShouldNotBeNull().ShouldBeGreaterThan(0);
hello.VaultCount.ShouldBe(1);
}
[Fact]
public async Task MetaAdvertisesTheFeature()
{
// How a client decides whether to hold a socket open at all. Absence is not an error — it
// means synchronise on the timer, which is what every client did before this existed.
var client = fixture.CreateClient();
var meta = await (await client.GetAsync(
new Uri("/api/v1/meta", UriKind.Relative),
TestContext.Current.CancellationToken))
.Content.ReadContractAsync<MetaResponse>();
meta.ShouldNotBeNull();
meta.Features.ShouldContain(
feature => string.Equals(feature, VaultEvents.Feature, StringComparison.Ordinal));
}
// ---- Notices ----
[Fact]
public async Task APush_AnnouncesTheVaultToAFollowingSocket()
{
var (subject, vaultId) = await SeedUserWithVaultAsync();
using var timeout = Timeout();
using var socket = await fixture.ConnectEventsAsync(subject, timeout.Token);
await ReadAsync(socket, timeout.Token);
var client = fixture.CreateClientFor(subject);
var push = await client.PostContractAsync(PushUrl(vaultId), NewCreateBatch());
push.EnsureSuccessStatusCode();
var notice = await ReadUntilAsync(socket, VaultEventKinds.VaultChanged, timeout.Token);
notice.VaultId.ShouldBe(vaultId);
// A hint for logging and coalescing, never a cursor: cursors are opaque and integrity-tagged,
// and a client that tried to resume from this would be resuming from a number it invented.
notice.Sequence.ShouldNotBeNull().ShouldBeGreaterThan(0);
}
[Fact]
public async Task ANoticeCarriesNoCiphertext()
{
// The load-bearing property of the whole design. A notice says only that a vault moved; the
// client's answer is the delta pull it would have run on its timer anyway, which keeps exactly
// one code path applying changes. See ADR 0012.
var (subject, vaultId) = await SeedUserWithVaultAsync();
using var timeout = Timeout();
using var socket = await fixture.ConnectEventsAsync(subject, timeout.Token);
await ReadAsync(socket, timeout.Token);
var client = fixture.CreateClientFor(subject);
var batch = NewCreateBatch();
await client.PostContractAsync(PushUrl(vaultId), batch);
var raw = await ReadRawUntilAsync(socket, VaultEventKinds.VaultChanged, timeout.Token);
// The envelope this test pushed, as it would appear if a payload had been forwarded.
raw.ShouldNotContain(Convert.ToBase64String(batch.Operations[0].Payload!.Envelope));
raw.ShouldNotContain("payload", Case.Insensitive);
raw.ShouldNotContain(batch.Operations[0].EntityId.ToString());
}
[Fact]
public async Task APushToAnotherAccountsVault_IsNotAnnouncedHere()
{
// The disclosure that would matter: a socket learning that vault ids it cannot read exist,
// and when somebody works on them.
var (subject, vaultId) = await SeedUserWithVaultAsync();
var (stranger, strangersVaultId) = await SeedUserWithVaultAsync();
using var timeout = Timeout();
using var socket = await fixture.ConnectEventsAsync(subject, timeout.Token);
await ReadAsync(socket, timeout.Token);
// The stranger's write goes first, so a socket that leaked would have announced it before the
// one this test then waits for. Ordering, not a timeout: "nothing arrived in two seconds"
// passes on a slow machine for the wrong reason.
var strangersClient = fixture.CreateClientFor(stranger);
(await strangersClient.PostContractAsync(PushUrl(strangersVaultId), NewCreateBatch()))
.EnsureSuccessStatusCode();
var ownClient = fixture.CreateClientFor(subject);
(await ownClient.PostContractAsync(PushUrl(vaultId), NewCreateBatch()))
.EnsureSuccessStatusCode();
var notice = await ReadUntilAsync(socket, VaultEventKinds.VaultChanged, timeout.Token);
notice.VaultId.ShouldBe(vaultId);
notice.VaultId.ShouldNotBe(strangersVaultId);
}
[Fact]
public async Task APushThatAppliedNothing_AnnouncesNothing()
{
// A batch of pure conflicts moved no vault. Announcing one anyway would have every client on
// it pull for a change that is not there.
var (subject, vaultId) = await SeedUserWithVaultAsync();
var client = fixture.CreateClientFor(subject);
using var timeout = Timeout();
using var socket = await fixture.ConnectEventsAsync(subject, timeout.Token);
await ReadAsync(socket, timeout.Token);
// An update to an item that does not exist: rejected as a conflict, nothing written.
var stale = new SyncPushRequest(
[
NewOperation(Guid.CreateVersion7(), expectedVersion: 7, envelope: [9, 9]),
]);
var conflicted = await client.PostContractAsync(PushUrl(vaultId), stale);
conflicted.EnsureSuccessStatusCode();
var results = await conflicted.Content.ReadContractAsync<SyncPushResponse>();
results.ShouldNotBeNull();
results.Results[0].Status.ShouldBe(SyncOperationStatus.Conflict);
// Then a write that did land. The first notice must be that one.
(await client.PostContractAsync(PushUrl(vaultId), NewCreateBatch())).EnsureSuccessStatusCode();
var notice = await ReadUntilAsync(socket, VaultEventKinds.VaultChanged, timeout.Token);
notice.Sequence.ShouldNotBeNull().ShouldBeGreaterThan(0);
}
[Fact]
public async Task APing_IsAnswered()
{
var (subject, _) = await SeedUserWithVaultAsync();
using var timeout = Timeout();
using var socket = await fixture.ConnectEventsAsync(subject, timeout.Token);
await ReadAsync(socket, timeout.Token);
await SendAsync(socket, new VaultEvent(VaultEventKinds.Ping), timeout.Token);
var pong = await ReadUntilAsync(socket, VaultEventKinds.Pong, timeout.Token);
pong.Kind.ShouldBe(VaultEventKinds.Pong);
}
[Fact]
public async Task AFrameThisServerCannotRead_DoesNotEndTheSocket()
{
// A control channel whose failure mode is "the client polls instead" should tolerate a frame
// from a newer client rather than cost that client its push for the whole session.
var (subject, vaultId) = await SeedUserWithVaultAsync();
using var timeout = Timeout();
using var socket = await fixture.ConnectEventsAsync(subject, timeout.Token);
await ReadAsync(socket, timeout.Token);
await socket.SendAsync(
Encoding.UTF8.GetBytes("{ not json at all"),
WebSocketMessageType.Text,
endOfMessage: true,
timeout.Token);
var client = fixture.CreateClientFor(subject);
(await client.PostContractAsync(PushUrl(vaultId), NewCreateBatch())).EnsureSuccessStatusCode();
var notice = await ReadUntilAsync(socket, VaultEventKinds.VaultChanged, timeout.Token);
notice.VaultId.ShouldBe(vaultId);
socket.State.ShouldBe(WebSocketState.Open);
}
// ---- Helpers ----
/// <summary>A token that gives up rather than letting a broken build hang the suite.</summary>
private static CancellationTokenSource Timeout()
{
var source = CancellationTokenSource.CreateLinkedTokenSource(
TestContext.Current.CancellationToken);
source.CancelAfter(FrameTimeout);
return source;
}
private static async Task<VaultEvent> ReadAsync(WebSocket socket, CancellationToken cancellationToken)
{
var json = await ReadRawAsync(socket, cancellationToken);
return JsonSerializer.Deserialize(json, DodoSshJsonContext.Default.VaultEvent)
?? throw new InvalidOperationException($"The server sent a null frame: {json}");
}
private static async Task<string> ReadRawAsync(WebSocket socket, CancellationToken cancellationToken)
{
var buffer = new byte[8 * 1024];
var received = await socket.ReceiveAsync(buffer, cancellationToken);
if (received.MessageType == WebSocketMessageType.Close)
{
throw new InvalidOperationException(
$"The server closed the socket: {received.CloseStatus} {received.CloseStatusDescription}");
}
return Encoding.UTF8.GetString(buffer, 0, received.Count);
}
/// <summary>Reads past the frames a test does not care about — hello, and heartbeats.</summary>
private static async Task<VaultEvent> ReadUntilAsync(
WebSocket socket,
string kind,
CancellationToken cancellationToken)
{
while (true)
{
var frame = await ReadAsync(socket, cancellationToken);
if (string.Equals(frame.Kind, kind, StringComparison.Ordinal))
{
return frame;
}
}
}
/// <summary>
/// The same, but keeping the bytes.
/// </summary>
/// <remarks>
/// Deserialising and asserting on the fields would prove only that this <em>record</em> has no
/// payload member, which is a tautology. Asserting on what actually crossed the socket is what
/// would catch a field added to the frame later without anybody thinking about disclosure.
/// </remarks>
private static async Task<string> ReadRawUntilAsync(
WebSocket socket,
string kind,
CancellationToken cancellationToken)
{
while (true)
{
var json = await ReadRawAsync(socket, cancellationToken);
var frame = JsonSerializer.Deserialize(json, DodoSshJsonContext.Default.VaultEvent);
if (string.Equals(frame?.Kind, kind, StringComparison.Ordinal))
{
return json;
}
}
}
private static Task SendAsync(WebSocket socket, VaultEvent frame, CancellationToken cancellationToken) =>
socket.SendAsync(
JsonSerializer.SerializeToUtf8Bytes(frame, DodoSshJsonContext.Default.VaultEvent),
WebSocketMessageType.Text,
endOfMessage: true,
cancellationToken);
private static string PushUrl(Guid vaultId) => $"/api/v1/vaults/{vaultId}/sync/push";
private static string NewSubject() => $"events-{Guid.CreateVersion7():N}";
private static SyncPushOperation NewOperation(Guid entityId, int? expectedVersion, byte[] envelope) =>
new(
Guid.CreateVersion7(),
SyncEntityType.Host,
entityId,
SyncOperation.Upsert,
expectedVersion,
new EncryptedPayload(envelope, [0xD, 0xE], Guid.CreateVersion7(), 1, 1),
new SyncPlaintextFields());
private static SyncPushRequest NewCreateBatch() =>
new([NewOperation(Guid.CreateVersion7(), expectedVersion: null, envelope: [1, 2, 3, 4])]);
private async Task<string> SeedEnrolledUserAsync()
{
var subject = NewSubject();
await using var scope = fixture.CreateScope();
var database = scope.ServiceProvider.GetRequiredService<DodoDbContext>();
var user = NewUser(subject);
database.Users.Add(user);
database.UserKeys.Add(Seed.CurrentKey(user.Id, Now));
await database.SaveChangesAsync();
return subject;
}
private async Task<(string Subject, Guid VaultId)> SeedUserWithVaultAsync()
{
var subject = NewSubject();
await using var scope = fixture.CreateScope();
var database = scope.ServiceProvider.GetRequiredService<DodoDbContext>();
var user = NewUser(subject);
var vault = new Vault
{
Id = Guid.CreateVersion7(),
Name = "Personal",
OwnerKind = VaultOwnerKind.Personal,
OwnerUserId = user.Id,
KeyGeneration = 1,
CreatedAtUtc = Now,
UpdatedAtUtc = Now,
};
database.Users.Add(user);
database.UserKeys.Add(Seed.CurrentKey(user.Id, Now));
database.Vaults.Add(vault);
await database.SaveChangesAsync();
return (subject, vault.Id);
}
private UserAccount NewUser(string subject) => new()
{
Id = Guid.CreateVersion7(),
Issuer = fixture.IdentityProvider.Authority,
Subject = subject,
Status = UserStatus.Active,
CreatedAtUtc = Now,
UpdatedAtUtc = Now,
};
}
@@ -0,0 +1,388 @@
using System.Net.WebSockets;
using System.Text;
using System.Text.Json;
using System.Threading.Channels;
using DodoSSH.Client.Api;
using DodoSSH.Contracts;
namespace DodoSSH.Client.Api.Tests;
/// <summary>
/// The reconnection policy, which is what this class actually is.
/// </summary>
/// <remarks>
/// <para>
/// A dropped socket is the ordinary case here rather than the exception — laptops sleep, proxies time
/// out, tokens expire, servers are redeployed — so the behaviour worth covering is what happens
/// <em>after</em> a failure, not the happy path. Driven through the injected connector, because the one
/// thing a test cannot do to a real network is make it fail on cue.
/// </para>
/// <para>
/// The backoff is configured down to milliseconds throughout. What is under test is the shape of the
/// policy — does it try again, does it wait, does it stop waiting when told the token was the problem —
/// and none of that depends on the intervals a shipped client uses.
/// </para>
/// </remarks>
public sealed class VaultEventStreamTests
{
private static readonly Uri Server = new("https://dodossh.example");
private static readonly VaultEventStreamOptions Impatient = new()
{
InitialBackoff = TimeSpan.FromMilliseconds(1),
MaxBackoff = TimeSpan.FromMilliseconds(5),
InitialSilenceTimeout = TimeSpan.FromSeconds(30),
};
[Fact]
public async Task ItDialsTheWebSocketFormOfTheServersUrl()
{
// https becomes wss, and the path is the one in the contract. Getting either wrong is a client
// that reconnects against a 404 for the whole session, which from outside is indistinguishable
// from a network that eats WebSockets.
var dialled = new List<Uri>();
var socket = new FakeWebSocket();
await using var stream = Stream(
(url, _, _) =>
{
dialled.Add(url);
return Task.FromResult<WebSocket>(socket);
});
socket.Deliver(new VaultEvent(VaultEventKinds.VaultChanged, Guid.CreateVersion7(), 1));
await stream.ReadAsync(Token);
dialled[0].ShouldBe(new Uri("wss://dodossh.example/api/v1/events"));
}
[Fact]
public async Task ItSendsTheBearerTokenOnTheUpgrade()
{
// The whole of this socket's authorization, unlike the relay's ticket. See ADR 0012.
var presented = new List<string>();
var socket = new FakeWebSocket();
await using var stream = Stream(
(_, token, _) =>
{
presented.Add(token);
return Task.FromResult<WebSocket>(socket);
});
socket.Deliver(new VaultEvent(VaultEventKinds.VaultChanged, Guid.CreateVersion7(), 1));
await stream.ReadAsync(Token);
presented[0].ShouldBe(StubTokens.Token);
}
[Fact]
public async Task ANoticeReachesTheReader()
{
var vaultId = Guid.CreateVersion7();
var socket = new FakeWebSocket();
await using var stream = Stream((_, _, _) => Task.FromResult<WebSocket>(socket));
socket.Deliver(new VaultEvent(VaultEventKinds.VaultChanged, vaultId, 42));
var notice = await stream.ReadAsync(Token);
notice.Kind.ShouldBe(VaultEventKinds.VaultChanged);
notice.VaultId.ShouldBe(vaultId);
notice.Sequence.ShouldBe(42);
}
[Fact]
public async Task AHeartbeatIsAnsweredAndNotHandedToTheReader()
{
// A ping is housekeeping between the two ends. Passing it up would wake a synchronisation loop
// every thirty seconds for a frame that says nothing happened.
var socket = new FakeWebSocket();
await using var stream = Stream((_, _, _) => Task.FromResult<WebSocket>(socket));
socket.Deliver(new VaultEvent(VaultEventKinds.Ping, HeartbeatSeconds: 30));
socket.Deliver(new VaultEvent(VaultEventKinds.VaultChanged, Guid.CreateVersion7(), 1));
var first = await stream.ReadAsync(Token);
first.Kind.ShouldBe(VaultEventKinds.VaultChanged, "the ping should not have been forwarded");
var answered = await socket.SentAsync(Token);
answered.Kind.ShouldBe(VaultEventKinds.Pong);
}
[Fact]
public async Task AKindThisBuildDoesNotKnow_IsStillHandedOver()
{
// What makes the frame table extensible: this class must not decide what a newer server may
// say. Deciding to ignore it is the caller's, and costs that caller one redundant pass.
var socket = new FakeWebSocket();
await using var stream = Stream((_, _, _) => Task.FromResult<WebSocket>(socket));
socket.Deliver(new VaultEvent("session.offered"));
var notice = await stream.ReadAsync(Token);
notice.Kind.ShouldBe("session.offered");
}
[Fact]
public async Task AFailedDial_IsRetried()
{
// No server yet, or no network. Neither is an error to report: the caller's synchronisation
// timer is running regardless, which is what lets this stay silent and keep trying.
var attempts = 0;
var socket = new FakeWebSocket();
await using var stream = Stream((_, _, _) =>
{
if (++attempts < 3)
{
throw new WebSocketException("no route to host");
}
return Task.FromResult<WebSocket>(socket);
});
socket.Deliver(new VaultEvent(VaultEventKinds.VaultChanged, Guid.CreateVersion7(), 1));
var notice = await stream.ReadAsync(Token);
notice.Kind.ShouldBe(VaultEventKinds.VaultChanged);
attempts.ShouldBe(3);
}
[Fact]
public async Task ADroppedSocket_IsReplaced()
{
// The case that decides whether this feature survives a laptop lid. A stream that gave up on
// the first close would work all morning and be silently dead after lunch.
var sockets = new List<FakeWebSocket>();
await using var stream = Stream((_, _, _) =>
{
var socket = new FakeWebSocket();
sockets.Add(socket);
if (sockets.Count == 1)
{
socket.Deliver(new VaultEvent(VaultEventKinds.VaultChanged, Guid.CreateVersion7(), 1));
socket.Close(WebSocketCloseStatus.EndpointUnavailable);
}
else
{
socket.Deliver(new VaultEvent(VaultEventKinds.VaultChanged, Guid.CreateVersion7(), 2));
}
return Task.FromResult<WebSocket>(socket);
});
(await stream.ReadAsync(Token)).Sequence.ShouldBe(1);
(await stream.ReadAsync(Token)).Sequence.ShouldBe(2);
sockets.Count.ShouldBeGreaterThanOrEqualTo(2);
}
[Fact]
public async Task AnExpiredTokenClose_ReconnectsAndAsksForAFreshToken()
{
// The bound that lets a long-lived socket be authorised by a short-lived credential: the server
// closes at the token's expiry and the client comes straight back with a new one. The token
// provider being asked again is the half that matters — reconnecting with the spent token would
// be an unbroken loop of closes.
var tokens = new StubTokens();
var sockets = 0;
await using var stream = new VaultEventStream(
Server,
tokens,
TimeProvider.System,
(_, _, _) =>
{
var socket = new FakeWebSocket();
if (++sockets == 1)
{
socket.Close((WebSocketCloseStatus)VaultEvents.TokenExpiredCloseCode);
}
else
{
socket.Deliver(new VaultEvent(VaultEventKinds.VaultChanged, Guid.CreateVersion7(), 7));
}
return Task.FromResult<WebSocket>(socket);
},
Impatient);
(await stream.ReadAsync(Token)).Sequence.ShouldBe(7);
tokens.Requests.ShouldBeGreaterThanOrEqualTo(2);
}
[Fact]
public async Task TryRead_TakesWhatIsWaitingAndSaysWhenNothingIs()
{
// How a caller coalesces a burst: read one, wait a moment, swallow the rest. Without this a
// colleague tidying a folder would produce a synchronisation pass per item.
var socket = new FakeWebSocket();
await using var stream = Stream((_, _, _) => Task.FromResult<WebSocket>(socket));
socket.Deliver(new VaultEvent(VaultEventKinds.VaultChanged, Guid.CreateVersion7(), 1));
socket.Deliver(new VaultEvent(VaultEventKinds.VaultChanged, Guid.CreateVersion7(), 2));
(await stream.ReadAsync(Token)).Sequence.ShouldBe(1);
// Delivery is asynchronous, so the second may not have landed yet; this is the same
// wait-then-drain the caller performs.
await Task.Delay(TimeSpan.FromMilliseconds(200), Token);
stream.TryRead(out var queued).ShouldBeTrue();
queued.Sequence.ShouldBe(2);
stream.TryRead(out _).ShouldBeFalse();
}
[Fact]
public async Task AnIdleStream_NeverDelivers()
{
// What a server without the feature supplies. Waiting for ever rather than completing is the
// point: a caller selecting between this and a timer has to fall through to the timer, and a
// read that returned at once would spin that loop as fast as the machine allows.
using var stream = IdleVaultEventStream.Instance;
using var giveUp = CancellationTokenSource.CreateLinkedTokenSource(Token);
giveUp.CancelAfter(TimeSpan.FromMilliseconds(100));
await Should.ThrowAsync<OperationCanceledException>(
async () => await stream.ReadAsync(giveUp.Token));
stream.TryRead(out _).ShouldBeFalse();
stream.IsConnected.ShouldBeFalse();
}
private static CancellationToken Token => TestContext.Current.CancellationToken;
private static VaultEventStream Stream(
Func<Uri, string, CancellationToken, Task<WebSocket>> connect) =>
new(Server, new StubTokens(), TimeProvider.System, connect, Impatient);
/// <summary>A token provider that hands out one value and counts who asked.</summary>
private sealed class StubTokens : IAccessTokenProvider
{
internal const string Token = "access-token";
internal int Requests { get; private set; }
public ValueTask<string> GetAccessTokenAsync(CancellationToken cancellationToken)
{
Requests++;
return ValueTask.FromResult(Token);
}
}
/// <summary>
/// A socket a test writes the server's half of.
/// </summary>
/// <remarks>
/// Frames queued with <see cref="Deliver"/> are handed out by <see cref="ReceiveAsync"/> in order;
/// once the queue is empty the receive waits, which is what an idle connection does. <see
/// cref="Close"/> queues the close instead, so a test can script "two notices and then the server
/// went away" as a value rather than as a race.
/// </remarks>
private sealed class FakeWebSocket : WebSocket
{
private readonly Channel<byte[]> inbound = Channel.CreateUnbounded<byte[]>();
private readonly Channel<VaultEvent> outbound = Channel.CreateUnbounded<VaultEvent>();
private WebSocketCloseStatus? closing;
private WebSocketState state = WebSocketState.Open;
public override WebSocketCloseStatus? CloseStatus => closing;
public override string? CloseStatusDescription => null;
public override WebSocketState State => state;
public override string? SubProtocol => VaultEvents.SubProtocol;
/// <summary>Queues a frame for the client to read.</summary>
internal void Deliver(VaultEvent frame) =>
inbound.Writer.TryWrite(
JsonSerializer.SerializeToUtf8Bytes(frame, DodoSshJsonContext.Default.VaultEvent));
/// <summary>Ends the socket, after everything already queued has been read.</summary>
internal void Close(WebSocketCloseStatus status)
{
closing = status;
inbound.Writer.TryWrite([]);
}
/// <summary>The next frame the client sent.</summary>
internal ValueTask<VaultEvent> SentAsync(CancellationToken cancellationToken) =>
outbound.Reader.ReadAsync(cancellationToken);
public override async Task<WebSocketReceiveResult> ReceiveAsync(
ArraySegment<byte> buffer,
CancellationToken cancellationToken)
{
var frame = await inbound.Reader.ReadAsync(cancellationToken);
// The empty frame Close queues. Reported as a close, exactly as a real socket does once the
// peer's close frame arrives.
if (frame.Length == 0)
{
state = WebSocketState.Closed;
return new WebSocketReceiveResult(
0, WebSocketMessageType.Close, endOfMessage: true, closing, null);
}
frame.CopyTo(buffer.Array!, buffer.Offset);
return new WebSocketReceiveResult(frame.Length, WebSocketMessageType.Text, endOfMessage: true);
}
public override Task SendAsync(
ArraySegment<byte> buffer,
WebSocketMessageType messageType,
bool endOfMessage,
CancellationToken cancellationToken)
{
var json = Encoding.UTF8.GetString(buffer.Array!, buffer.Offset, buffer.Count);
if (JsonSerializer.Deserialize(json, DodoSshJsonContext.Default.VaultEvent) is { } frame)
{
outbound.Writer.TryWrite(frame);
}
return Task.CompletedTask;
}
public override void Abort() => state = WebSocketState.Aborted;
public override Task CloseAsync(
WebSocketCloseStatus closeStatus,
string? statusDescription,
CancellationToken cancellationToken) => CloseOutputAsync(
closeStatus, statusDescription, cancellationToken);
public override Task CloseOutputAsync(
WebSocketCloseStatus closeStatus,
string? statusDescription,
CancellationToken cancellationToken)
{
state = WebSocketState.Closed;
return Task.CompletedTask;
}
public override void Dispose() => state = WebSocketState.Closed;
}
}
@@ -166,9 +166,9 @@ public sealed class HostGridTests : IAsyncLifetime
/// <remarks>
/// <para>
/// The host grid's menu acts on nothing when it is not aimed; this one acts on the <em>wrong group</em>.
/// <c>GroupTarget</c> falls back to the group whose contents are on screen when no card is selected — the
/// right answer for the pair of buttons beside the heading, and the wrong one for a menu that opened on a
/// card, which would then offer to delete a group the pointer is nowhere near.
/// <c>GroupTarget</c> falls back to the group whose contents are on screen when no card is selected, and
/// a menu that opened on a card would then offer to delete a group the pointer is nowhere near. It is
/// also the only way to Edit or Delete a group on the desktop, so this is the only place it is aimed.
/// </para>
/// <para>
/// Open is the one entry that takes a parameter, because <c>OpenGroupCommand</c>'s null is a real
@@ -287,10 +287,10 @@ public sealed class HostGridTests : IAsyncLifetime
/// <remarks>
/// <para>
/// The rule one press was split into two gestures for. Selecting a group aims its EDIT and DELETE at it
/// and does nothing else; opening one is what narrows the grid, and the trail is the way back out of it.
/// While a single press meant both, a group could not be named without every host outside it leaving the
/// screen at the same moment.
/// The rule one press was split into two gestures for. Selecting a group marks it and does nothing else;
/// opening one is what narrows the grid, and the trail is the way back out of it. While a single press
/// meant both, a group could not be named without every host outside it leaving the screen at the same
/// moment.
/// </para>
/// <para>
/// Driven through the properties the cards bind rather than through a click, because what is worth
@@ -299,7 +299,7 @@ public sealed class HostGridTests : IAsyncLifetime
/// </para>
/// </remarks>
[Fact]
public async Task SelectingAGroupAimsItsButtonsAtItAndOpeningOneNarrowsTheGrid()
public async Task SelectingAGroupMarksItAndOpeningOneNarrowsTheGrid()
{
await vault.MoveHostToGroupCommand.ExecuteAsync(
new HostGroupMove(Row(vault, "prod-db"), vault.Groups.Single().EntityId));
@@ -312,8 +312,7 @@ public sealed class HostGridTests : IAsyncLifetime
vault.GroupFilter.ShouldBeNull("one press selects a group and does not open it");
vault.VisibleHosts.Select(row => row.Label)
.ShouldBe(["stage-web"], "so the grid is still the outermost level, and prod-db is inside a group");
vault.GroupTarget.ShouldBeSameAs(production, "what EDIT and DELETE act on");
vault.ShowsGroupActions.ShouldBeTrue();
vault.GroupTarget.ShouldBeSameAs(production, "what a group command with no argument acts on");
vault.OpenGroupCommand.Execute(production);
@@ -324,7 +323,7 @@ public sealed class HostGridTests : IAsyncLifetime
vault.SelectedGroup.ShouldBeNull("the card it was on is not one of the cards on screen any more");
vault.GroupTarget.ShouldBeSameAs(
production, "so the buttons fall back to the group whose contents are showing");
production, "so an unaimed command falls back to the group whose contents are showing");
// Back out, which is the trail's first crumb and nothing else: SHOW ALL was a second control for the
// same job and went with the change.
@@ -333,7 +332,6 @@ public sealed class HostGridTests : IAsyncLifetime
vault.VisibleHosts.Select(row => row.Label)
.ShouldBe(["stage-web"], "ALL HOSTS is the outermost level, not every host in the keychain");
vault.GroupTarget.ShouldBeNull("and nothing is aimed at once no group is open or selected");
vault.ShowsGroupActions.ShouldBeFalse("a pair of buttons with no subject is hidden rather than shown");
}
/// <summary>
@@ -376,35 +374,29 @@ public sealed class HostGridTests : IAsyncLifetime
vault.SelectedGroup.ShouldBeNull("and the same in the other direction");
screen.GroupGrid.SelectedItem.ShouldBeNull();
GroupCard(screen).IsSelected.ShouldBeFalse();
vault.ShowsGroupActions.ShouldBeFalse("so the group's own two buttons have nothing to act on");
});
}
/// <remarks>
/// EDIT takes the group as an argument now, so that the phone can open the editor on a heading without
/// selecting a group and losing the host selection to it — see <c>VaultViewModel.EditGroup</c>. The
/// button beside the cards passes nothing and means "the card that is selected", which is the half of
/// that change that would fail silently: a command refusing a null parameter is a button that never
/// fires, and nothing about the markup would say so.
/// A pair of EDIT and DELETE buttons used to sit beside the GROUPS heading, and the card's own menu is
/// the whole of both now — the menu came second and did the same job better, since it acts on the card
/// under the pointer rather than on <c>GroupTarget</c>. Held here because a button coming back is not a
/// compile error and barely a visible one: it would draw itself in place, aimed with no card selected at
/// the group the trail ends with, which is the mistake the two menu tests above exist to catch.
/// </remarks>
[Fact]
public async Task TheGroupsEditButtonStillActsOnTheSelectedCard()
public async Task AGroupsEditAndDeleteAreOnItsCardsMenuAndNowhereElse()
{
await OnTheGridAsync((screen, _) =>
{
vault.SelectedGroup = vault.VisibleGroups.Single();
Dispatcher.UIThread.RunJobs();
var edit = screen.GetVisualDescendants()
screen.GetVisualDescendants()
.OfType<Button>()
.Single(button => ReferenceEquals(button.Command, vault.EditGroupCommand));
edit.IsEffectivelyEnabled.ShouldBeTrue("the command has to accept the button's empty parameter");
edit.Command.ShouldNotBeNull().Execute(edit.CommandParameter);
vault.IsEditingGroup.ShouldBeTrue();
vault.GroupEditorLabel.ShouldBe("production", "the card that was selected");
.Where(button => ReferenceEquals(button.Command, vault.EditGroupCommand)
|| ReferenceEquals(button.Command, vault.DeleteGroupCommand))
.ShouldBeEmpty("a selected group card puts no buttons on the screen");
});
}
@@ -557,11 +557,10 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
}
/// <remarks>
/// The question replaces the group's row of buttons rather than stacking under it the same rule every
/// other row in this application follows — and it is the tallest thing this section draws: a heading, a
/// consequence, a boxed count, and now a tick with a sentence beside it asking whether the machines go
/// too. The tick is the part worth measuring, because it is a wrapping paragraph inside a control whose
/// own height the layout does not obviously account for.
/// The question opens under the GROUPS heading and pushes the cards down, and it is the tallest thing
/// this section draws: a heading, a consequence, a boxed count, and now a tick with a sentence beside it
/// asking whether the machines go too. The tick is the part worth measuring, because it is a wrapping
/// paragraph inside a control whose own height the layout does not obviously account for.
/// </remarks>
[Fact]
public async Task TheHostsScreenFitsWhileAGroupDeletionIsBeingConfirmed()
@@ -580,10 +579,10 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
/// <remarks>
/// <para>
/// The group's move panel, which takes that same row of buttons and is the wordier of the two: a
/// heading, a combo box, a wrapping paragraph naming everything that travels and everything that does
/// not, and two buttons — above a wrap of group cards and the host grid, all of which still have to fit
/// under it.
/// The group's move panel, under the GROUPS heading beside the deletion question and the wordier of the
/// two: a heading, a combo box, a wrapping paragraph naming everything that travels and everything that
/// does not, and two buttons — above a wrap of group cards and the host grid, all of which still have to
/// fit under it.
/// </para>
/// <para>
/// The state is set here rather than through <c>MoveGroupCommand</c>, which would refuse: this fixture's
@@ -604,7 +603,7 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
vault.SelectedMoveGroupVault = vault.MoveGroupVaultChoices[0];
vault.IsMovingGroup = true;
vault.ShowsGroupActions.ShouldBeFalse("the panel takes the row rather than sharing it");
vault.IsConfirmingGroupDeletion.ShouldBeFalse("the two panels share the space and never the moment");
await MeasureHostsAsync(faults => faults.ShouldBeEmpty("with the group move panel up"));
}
@@ -57,6 +57,17 @@ internal sealed class StubTeamServer : IVaultServer, ITeamApi, IVaultGrantApi
/// <inheritdoc />
public IKeyBindingAuthorizer KeyBinding => throw new NotSupportedException();
/// <summary>
/// A push channel that never pushes.
/// </summary>
/// <remarks>
/// Not <c>NotSupportedException</c> like its neighbours: the background synchronisation loop reads
/// this on every wait, so a layout test that opened a screen would throw from a timer thread rather
/// than draw anything. Waiting for ever is the honest stand-in — an offline layout test has no
/// server to be pushed from.
/// </remarks>
public IVaultEventStream Events => IdleVaultEventStream.Instance;
/// <inheritdoc />
public SyncOptions SyncOptions => new();
@@ -0,0 +1,49 @@
using System.Threading.Channels;
using DodoSSH.Client.Api;
using DodoSSH.Contracts;
namespace DodoSSH.Client.App.Tests;
/// <summary>
/// A server's push channel, driven by a test rather than by a socket.
/// </summary>
/// <remarks>
/// The real <c>VaultEventStream</c> is a reconnection policy wrapped round a WebSocket, and none of
/// that is what the shell's behaviour depends on: what the shell does with a notice is the same
/// whether it arrived over a healthy socket, after four reconnections, or from this. Driving it by
/// hand is what makes "the loop synchronised because it was told to, not because a minute passed" a
/// test that finishes in milliseconds and cannot flake.
/// </remarks>
internal sealed class FakeVaultEventStream : IVaultEventStream
{
private readonly Channel<VaultEvent> notices = Channel.CreateUnbounded<VaultEvent>();
/// <inheritdoc />
public bool IsConnected => true;
/// <summary>How many times the shell has waited on this. Proves the loop is watching at all.</summary>
internal int Reads { get; private set; }
/// <summary>Delivers a notice, as a server would.</summary>
internal void Push(Guid vaultId, long sequence = 1) =>
notices.Writer.TryWrite(
new VaultEvent(VaultEventKinds.VaultChanged, vaultId, sequence));
/// <summary>Delivers the notice that says the caller's vault list has changed.</summary>
internal void PushAccessChanged() =>
notices.Writer.TryWrite(new VaultEvent(VaultEventKinds.VaultsChanged));
/// <inheritdoc />
public ValueTask<VaultEvent> ReadAsync(CancellationToken cancellationToken)
{
Reads++;
return notices.Reader.ReadAsync(cancellationToken);
}
/// <inheritdoc />
public bool TryRead(out VaultEvent notice) => notices.Reader.TryRead(out notice!);
/// <inheritdoc />
public void Dispose() => notices.Writer.TryComplete();
}
@@ -39,6 +39,16 @@ internal sealed partial class FakeVaultServer : IVaultServer, IAccountApi, ISync
internal int PushCount { get; private set; }
/// <summary>
/// How many delta reads this server has served.
/// </summary>
/// <remarks>
/// The one observable a synchronisation pass always produces. <see cref="PushCount"/> only moves when
/// there is something queued, so a test asking "did a pass run" — which is what the push channel's
/// whole purpose comes down to — has to count pulls.
/// </remarks>
internal int PullCount { get; private set; }
internal bool IsEnrolled => statement is not null;
/// <summary>
@@ -99,6 +109,19 @@ internal sealed partial class FakeVaultServer : IVaultServer, IAccountApi, ISync
/// <inheritdoc />
public IKeyBindingAuthorizer KeyBinding => this;
/// <summary>
/// The push channel, which a test drives by hand.
/// </summary>
/// <remarks>
/// A real queue rather than an idle stand-in, because the behaviour worth covering here is the one
/// the socket exists for: a notice arriving makes the background loop synchronise without waiting
/// out its minute. See <see cref="FakeVaultEventStream.Push"/>.
/// </remarks>
internal FakeVaultEventStream Notices { get; } = new();
/// <inheritdoc />
public IVaultEventStream Events => Notices;
/// <inheritdoc />
public SyncOptions SyncOptions => SyncOptions.Default;
@@ -238,6 +261,8 @@ internal sealed partial class FakeVaultServer : IVaultServer, IAccountApi, ISync
SyncPullRequest request,
CancellationToken cancellationToken)
{
PullCount++;
if (SyncFailure is { } failure)
{
return Task.FromException<SyncPullResponse>(failure);
@@ -523,6 +523,90 @@ public sealed class ShellFlowTests : IAsyncLifetime
vault.Status.ShouldContain("bad day");
}
/// <summary>
/// The whole point of the push channel: a pass that did not wait for the minute.
/// </summary>
/// <remarks>
/// <para>
/// The timing is what makes this an assertion rather than a hope. The background timer is a full
/// minute and the wait below gives up in ten seconds, so a pull that arrives can only have been
/// caused by the notice — there is no interval at which the timer could have produced it.
/// </para>
/// <para>
/// The vault id in the notice is arbitrary, and deliberately so: a pass synchronises every vault
/// this session can reach, so the loop reads the notice as "there is something to fetch" and never
/// as "fetch this one". A test that seeded a real id would imply a targeting this does not do.
/// </para>
/// </remarks>
[Fact]
public async Task APushedNotice_SynchronisesWithoutWaitingForTheTimer()
{
await UnlockedAsync();
// The unlock starts the loop, whose first act is a pass; waited out so the count below is a
// baseline rather than a race with it.
await EventuallyAsync(
() => server.PullCount > 0,
"the pass on open should have run");
var before = server.PullCount;
server.Notices.Push(Guid.CreateVersion7());
await EventuallyAsync(
() => server.PullCount > before,
"a notice should have woken the loop long before the one-minute timer");
}
/// <remarks>
/// The half that is easy to get wrong. The loop selects between two waits, and both have to survive
/// losing: <c>PeriodicTimer</c> throws if a second wait is started while one is outstanding, and an
/// abandoned channel read stays registered and swallows the next notice written. Either defect
/// leaves the first notice working and every one after it silently lost, which is why one notice is
/// not enough to prove this.
/// </remarks>
[Fact]
public async Task NoticesKeepWakingTheLoop_NotJustTheFirst()
{
await UnlockedAsync();
await EventuallyAsync(() => server.PullCount > 0, "the pass on open should have run");
for (var round = 1; round <= 3; round++)
{
var before = server.PullCount;
server.Notices.Push(Guid.CreateVersion7());
await EventuallyAsync(
() => server.PullCount > before,
$"notice {round} should have woken the loop as the first one did");
}
}
/// <summary>Waits for something a background loop is expected to do, or fails saying what.</summary>
/// <remarks>
/// Polled rather than signalled because the thing under test is a loop nobody hands a completion
/// source to. The bound is generous — this is not measuring latency, only proving that the timer
/// cannot be what caused the result.
/// </remarks>
private static async Task EventuallyAsync(Func<bool> condition, string because)
{
var deadline = TimeProvider.System.GetUtcNow().AddSeconds(10);
while (TimeProvider.System.GetUtcNow() < deadline)
{
if (condition())
{
return;
}
await Task.Delay(TimeSpan.FromMilliseconds(20), Token);
}
throw new ShouldAssertException(because);
}
/// <remarks>
/// <para>
/// The page's own <c>term.focus()</c> focuses the textarea inside the document, which does nothing
@@ -912,13 +912,12 @@ public sealed class VaultSharingTests : IAsyncLifetime
production.VaultId.ShouldNotBe(sharedVaultId, "this test is meaningless with both in one vault");
production.Group.ParentId.ShouldNotBeNull("it was nested, which is what has to stay behind");
// As the card's menu does before it runs the command; see HostsScreen.OnGroupContextRequested.
vault.SelectedGroup = production;
vault.CanMoveGroupTarget.ShouldBeTrue("there is a second vault this session can write to");
vault.MoveGroupCommand.Execute(null);
vault.IsMovingGroup.ShouldBeTrue(vault.Status);
vault.ShowsGroupActions.ShouldBeFalse("the panel takes the row of buttons rather than sharing it");
vault.MoveGroupVaultChoices.ShouldNotContain(choice => choice.VaultId == production.VaultId);
vault.SelectedMoveGroupVault =
@@ -956,7 +955,9 @@ public sealed class VaultSharingTests : IAsyncLifetime
/// <remarks>
/// The move is refused where it would have nowhere to go, by the command rather than by an empty picker
/// — the same answer <c>MoveHostCommand</c> gives one level down.
/// — the same answer <c>MoveHostCommand</c> gives one level down, and the only place the question is
/// asked. The menu entry is drawn either way, because a menu whose items came and went would be a menu
/// whose items move.
/// </remarks>
[Fact]
public async Task MovingAGroupWithNowhereToMoveIt_SaysSoRatherThanOpeningAnEmptyPicker()
@@ -973,8 +974,6 @@ public sealed class VaultSharingTests : IAsyncLifetime
vault.SelectedGroup = vault.Groups.ShouldHaveSingleItem();
vault.CanMoveGroupTarget.ShouldBeFalse("the personal vault is the only one there is");
vault.MoveGroupCommand.Execute(null);
vault.IsMovingGroup.ShouldBeFalse();