Public Access
Merge branch 'main' into the desktop updater, and give way on two numbers
Main landed a realtime push feature while this branch was building the updater, and the two collided in three places. Every one of them resolves the same way: main got there first, so this branch moves. **Two ADRs were both numbered 0012.** Main's is realtime push; this one is now [ADR 0013](docs/adr/0013-desktop-distribution-and-updates.md). Git did not call this a conflict — the filenames differ — so it would have merged quietly and left the directory with two 0012s and every cross-reference ambiguous. Renumbered here along with the nine places that point at it. **Two manual-check phases were both numbered 15**, and that one git did catch. Main's "Changes that arrive without a timer" keeps 15; installing and updating the desktop client becomes Phase 16, with its checks and every reference to them renumbered. The file's own rule is that a number is for life, which is exactly why the one that had not been pushed is the one that gives way. **The merge rewrote several files with CRLF**, and `.editorconfig` asks for LF on everything except `*.ps1`. That is not cosmetic here: IDE0055 is an error and `EnforceCodeStyleInBuild` is on, so it failed the build on three lines of App.axaml.cs whose only change in this branch was an ADR number in a comment. Forty-six files normalised back to LF; the release script keeps CRLF, which is what `.gitattributes` and `.editorconfig` both already say for a PowerShell file. Nothing else conflicted. The updater does not touch the sync loop or the event stream, and the one file both sides edited heavily — MainWindowViewModel — merged without a hunk in common. Verified after merging: the solution restores locked and builds clean, and 304 shell, 100 layout, 54 session, 28 client-api and 25 contracts tests pass. The first two counts are higher than before the merge because main's own tests came with it and pass alongside these.
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -29,10 +29,11 @@ namespace DodoSSH.Client.App.Layout.Tests;
|
||||
/// <para>
|
||||
/// Separate from <see cref="ScreenLayoutTests"/>, which measures these controls rather than driving them.
|
||||
/// What is here is the one gesture that cannot be expressed as a binding and cannot be checked by
|
||||
/// measuring: a right click has to move the selection <em>before</em> the menu opens, because all three of
|
||||
/// that menu's commands read the vault's host selection. A menu that quietly acted on whichever host
|
||||
/// measuring: a right click has to move the selection <em>before</em> the menu opens, because the commands
|
||||
/// on both of that screen's menus read the vault's selection. A menu that quietly acted on whichever host
|
||||
/// happened to be selected would delete the wrong machine, which is the version of this mistake worth a
|
||||
/// suite.
|
||||
/// suite — and the group cards have the same menu with a fallback behind it that makes getting it wrong
|
||||
/// quieter still.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// A real <see cref="VaultViewModel"/> over a real unlocked vault, for the reason the other suites here use
|
||||
@@ -159,6 +160,82 @@ public sealed class HostGridTests : IAsyncLifetime
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The same rule on the cards above, where getting it wrong is quieter and worse.
|
||||
/// </summary>
|
||||
/// <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, 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
|
||||
/// argument — it is ALL HOSTS. That makes its <c>CommandParameter</c> binding the half most likely to
|
||||
/// rot: a path that resolves to nothing compiles, draws, and quietly leaves the grid at the top level.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task ARightClickSelectsTheGroupUnderThePointer()
|
||||
{
|
||||
await AddGroupAsync("staging");
|
||||
|
||||
await OnTheGridAsync((screen, window) =>
|
||||
{
|
||||
var first = GroupRow(vault, "production");
|
||||
var other = GroupRow(vault, "staging");
|
||||
|
||||
vault.SelectedGroup = first;
|
||||
|
||||
RightClick(CardFor(screen, other), window);
|
||||
|
||||
vault.SelectedGroup.ShouldBeSameAs(other);
|
||||
|
||||
var menu = screen.GroupGrid.ContextMenu.ShouldNotBeNull();
|
||||
menu.IsOpen.ShouldBeTrue();
|
||||
|
||||
var items = menu.Items.OfType<MenuItem>().ToList();
|
||||
|
||||
var open = items.Single(item => item.Header is "Open");
|
||||
open.Command.ShouldBeSameAs(vault.OpenGroupCommand);
|
||||
open.CommandParameter.ShouldBeSameAs(other, "the card under the pointer, not ALL HOSTS");
|
||||
|
||||
var edit = items.Single(item => item.Header is "Edit…");
|
||||
edit.Command.ShouldBeSameAs(vault.EditGroupCommand);
|
||||
|
||||
edit.Command!.Execute(null);
|
||||
|
||||
vault.IsEditingGroup.ShouldBeTrue();
|
||||
vault.GroupEditorLabel.ShouldBe(
|
||||
other.Label, "the card that was right-clicked, not the one selected before");
|
||||
});
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// The space around the group cards, where a menu would be at its most misleading: nothing is under the
|
||||
/// pointer, so an unguarded one would open against the fallback and offer Delete about the group the
|
||||
/// trail ends with — which, once it is open, is not a card on screen at all.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task ARightClickOffAnyGroupCardOpensNothingAndMovesNothing()
|
||||
{
|
||||
await OnTheGridAsync((screen, _) =>
|
||||
{
|
||||
var selected = GroupRow(vault, "production");
|
||||
vault.SelectedGroup = selected;
|
||||
|
||||
screen.GroupGrid.RaiseEvent(new ContextRequestedEventArgs
|
||||
{
|
||||
RoutedEvent = Control.ContextRequestedEvent,
|
||||
Source = screen.GroupGrid,
|
||||
});
|
||||
|
||||
vault.SelectedGroup.ShouldBeSameAs(selected, "the selection the menu would have acted on");
|
||||
screen.GroupGrid.ContextMenu.ShouldNotBeNull().IsOpen.ShouldBeFalse();
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A host held over a group card would be filed there, and one held over another host card would not.
|
||||
/// </summary>
|
||||
@@ -210,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
|
||||
@@ -222,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));
|
||||
@@ -235,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);
|
||||
|
||||
@@ -247,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.
|
||||
@@ -256,7 +332,93 @@ 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>
|
||||
/// The two grids share one selection, so at most one card on the screen is ever lit.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// They are two <c>ListBox</c>es, each holding a selection of its own and each drawing it the same way.
|
||||
/// Left to themselves both stay marked — a group above and a host below — under two pairs of buttons of
|
||||
/// which only one acts on whichever card the eye has settled on. The vault is what joins them.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Driven on the screen rather than on the view model alone, because half of the rule lives in the
|
||||
/// controls: clearing the property has to reach the list that is drawing the card, and a selection
|
||||
/// nulled in the view model while the card stays highlighted is the exact failure this is about.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task TheHostAndGroupGridsShareOneSelection()
|
||||
{
|
||||
await OnTheGridAsync((screen, _) =>
|
||||
{
|
||||
var host = Row(vault, "stage-web");
|
||||
|
||||
vault.OpenHostPaneCommand.Execute(host);
|
||||
Dispatcher.UIThread.RunJobs();
|
||||
|
||||
vault.SelectedGroup = vault.VisibleGroups.Single();
|
||||
Dispatcher.UIThread.RunJobs();
|
||||
|
||||
vault.SelectedHost.ShouldBeNull("choosing a group is choosing something else");
|
||||
vault.SelectedSidebarRow.ShouldBeNull("and the list that draws the hosts is told");
|
||||
screen.HostGrid.SelectedItem.ShouldBeNull();
|
||||
CardFor(screen, host).IsSelected.ShouldBeFalse("the card the pointer left has to go dark");
|
||||
vault.IsDrawerOpen.ShouldBeFalse("a pane about one host cannot stand beside a marked group");
|
||||
|
||||
vault.SelectedHost = host;
|
||||
Dispatcher.UIThread.RunJobs();
|
||||
|
||||
vault.SelectedGroup.ShouldBeNull("and the same in the other direction");
|
||||
screen.GroupGrid.SelectedItem.ShouldBeNull();
|
||||
GroupCard(screen).IsSelected.ShouldBeFalse();
|
||||
});
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// 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 AGroupsEditAndDeleteAreOnItsCardsMenuAndNowhereElse()
|
||||
{
|
||||
await OnTheGridAsync((screen, _) =>
|
||||
{
|
||||
vault.SelectedGroup = vault.VisibleGroups.Single();
|
||||
Dispatcher.UIThread.RunJobs();
|
||||
|
||||
screen.GetVisualDescendants()
|
||||
.OfType<Button>()
|
||||
.Where(button => ReferenceEquals(button.Command, vault.EditGroupCommand)
|
||||
|| ReferenceEquals(button.Command, vault.DeleteGroupCommand))
|
||||
.ShouldBeEmpty("a selected group card puts no buttons on the screen");
|
||||
});
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// The half of the shared selection that is nobody's gesture. A reload falls back to the first host when
|
||||
/// nothing is selected, which is what puts a target under CONNECT on a fresh unlock — and with one mark
|
||||
/// between the two grids that fallback would quietly unselect a group card every time a sync landed.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task ASyncDoesNotTakeTheSelectionOffAGroupCard()
|
||||
{
|
||||
var production = vault.VisibleGroups.Single();
|
||||
|
||||
vault.SelectedGroup = production;
|
||||
vault.SelectedHost.ShouldBeNull("the seed's load left a host selected, and the group took the mark");
|
||||
|
||||
await vault.LoadAsync(Token);
|
||||
|
||||
vault.SelectedHost.ShouldBeNull("the reload invented none under the card that was chosen");
|
||||
vault.SelectedGroup
|
||||
.ShouldNotBeNull("re-found by id, since the reload replaces every row object in the list")
|
||||
.EntityId.ShouldBe(production.EntityId);
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
@@ -474,14 +636,22 @@ public sealed class HostGridTests : IAsyncLifetime
|
||||
.OfType<ListBoxItem>()
|
||||
.Single(item => item.DataContext is HostGroupRowViewModel);
|
||||
|
||||
private static ListBoxItem CardFor(Visual screen, HostRowViewModel host) =>
|
||||
/// <remarks>Any row: a host card or a group card, which are both items of a list on this screen.</remarks>
|
||||
private static ListBoxItem CardFor(Visual screen, object row) =>
|
||||
screen.GetVisualDescendants()
|
||||
.OfType<ListBoxItem>()
|
||||
.First(item => ReferenceEquals(item.DataContext, host));
|
||||
.First(item => ReferenceEquals(item.DataContext, row));
|
||||
|
||||
private static HostRowViewModel Row(VaultViewModel vault, string label) =>
|
||||
vault.Hosts.First(row => string.Equals(row.Label, label, StringComparison.Ordinal));
|
||||
|
||||
/// <remarks>
|
||||
/// Out of the cards on screen rather than out of every group, because that is what the card's own data
|
||||
/// context is — <c>Groups</c> holds the same row objects, but only one level of them is drawn.
|
||||
/// </remarks>
|
||||
private static HostGroupRowViewModel GroupRow(VaultViewModel vault, string label) =>
|
||||
vault.VisibleGroups.First(row => string.Equals(row.Label, label, StringComparison.Ordinal));
|
||||
|
||||
private static Point Centre(Visual control, Visual window) =>
|
||||
control.TranslatePoint(new Point(control.Bounds.Width / 2, control.Bounds.Height / 2), window)
|
||||
?? throw new InvalidOperationException("the control is not in this window's tree");
|
||||
|
||||
@@ -223,6 +223,36 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
|
||||
await MeasureDrawerAsync(faults => faults.ShouldBeEmpty());
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The move panel, which takes the footer as the deletion question does and is the taller of the two: a
|
||||
/// heading, a combo box, a wrapping paragraph and two buttons, in a 304-pixel column. The paragraph is
|
||||
/// the risk — it is what says the group and the tags stay behind — and the footer is one of the two
|
||||
/// parts of this drawer that is not inside a <c>ScrollViewer</c>, so nothing brings it back into view.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The state is set here rather than through <c>MoveHostCommand</c>, which would refuse: this fixture's
|
||||
/// account holds one vault, and the command declines rather than open a picker with nothing in it. What
|
||||
/// this test is about is the rectangle, and the flow that fills it is covered in
|
||||
/// <c>DodoSSH.Client.App.Tests</c>.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task TheHostDrawerFitsWithTheMovePanelOpen()
|
||||
{
|
||||
vault.OpenHostPaneCommand.Execute(vault.Hosts[0]);
|
||||
|
||||
vault.MoveVaultChoices.Add(
|
||||
new VaultChoiceViewModel(Guid.CreateVersion7(), "Platform Engineering secrets", false));
|
||||
|
||||
vault.SelectedMoveVault = vault.MoveVaultChoices[0];
|
||||
vault.IsMovingHost = true;
|
||||
|
||||
vault.ShowsHostPaneActions.ShouldBeFalse("the panel takes the footer rather than sharing it");
|
||||
|
||||
await MeasureDrawerAsync(faults => faults.ShouldBeEmpty());
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// What a double-click on a machine does everywhere else, and did not do here: it opens a shell on it.
|
||||
@@ -527,9 +557,10 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// The question replaces the group's two buttons rather than stacking under them — the same rule every
|
||||
/// other pair in this application follows — and it is the taller of the two, because it says how many
|
||||
/// hosts are about to move.
|
||||
/// 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()
|
||||
@@ -540,10 +571,43 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
|
||||
vault.DeleteGroupCommand.Execute(null);
|
||||
|
||||
vault.IsConfirmingGroupDeletion.ShouldBeTrue("the question has to be up for this to measure it");
|
||||
vault.PendingDeletion.ShouldNotBeNull().HasChoice
|
||||
.ShouldBeTrue("the hosts filed under it are what makes this the long shape");
|
||||
|
||||
await MeasureHostsAsync(faults => faults.ShouldBeEmpty("with the group question up"));
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// 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
|
||||
/// account holds one vault, and the command declines rather than open a picker with nothing in it. The
|
||||
/// flow that fills it is covered in <c>DodoSSH.Client.App.Tests</c>.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task TheHostsScreenFitsWithTheGroupMovePanelOpen()
|
||||
{
|
||||
await SeedGroupsAsync(3);
|
||||
|
||||
vault.SelectedGroup = vault.Groups[0];
|
||||
|
||||
vault.MoveGroupVaultChoices.Add(
|
||||
new VaultChoiceViewModel(Guid.CreateVersion7(), "Platform Engineering secrets", false));
|
||||
|
||||
vault.SelectedMoveGroupVault = vault.MoveGroupVaultChoices[0];
|
||||
vault.IsMovingGroup = true;
|
||||
|
||||
vault.IsConfirmingGroupDeletion.ShouldBeFalse("the two panels share the space and never the moment");
|
||||
|
||||
await MeasureHostsAsync(faults => faults.ShouldBeEmpty("with the group move panel up"));
|
||||
}
|
||||
|
||||
// ---- The vault screen ----
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -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
|
||||
@@ -3801,13 +3885,20 @@ public sealed class ShellFlowTests : IAsyncLifetime
|
||||
}
|
||||
|
||||
/// <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.
|
||||
/// <para>
|
||||
/// Deleting a group leaves the machines under it alone <em>and</em> stops them naming it. It used to do
|
||||
/// only the first: the reference was left dangling and the list resolved it to nothing, which looked
|
||||
/// identical and cost no writes. The tick is what changed that — a deletion that can take the hosts with
|
||||
/// it has to be a deletion that knows which hosts it means, and once it knows, leaving them holding the
|
||||
/// id of something that has gone is a state kept for no reason.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The tick is deliberately not touched here, which is the point of the assertions: the default answer
|
||||
/// is the one that keeps the machines.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task DeletingAGroup_LeavesItsHostsUnderTheUngroupedHeading()
|
||||
public async Task DeletingAGroup_UnfilesItsHostsRatherThanLeavingThemNamingIt()
|
||||
{
|
||||
await UnlockedAsync();
|
||||
var vault = shell.Vault!;
|
||||
@@ -3816,32 +3907,136 @@ public sealed class ShellFlowTests : IAsyncLifetime
|
||||
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
|
||||
var question = vault.PendingDeletion.ShouldNotBeNull();
|
||||
|
||||
question.Usage
|
||||
.ShouldContain("1 host", Case.Sensitive, "the count is what makes the question worth reading");
|
||||
|
||||
question.HasChoice.ShouldBeTrue("a group with a host under it has a second question");
|
||||
vault.DeletionTakesTheHostsToo.ShouldBeFalse("the safe answer is the one nobody has to choose");
|
||||
|
||||
// The pass that follows every write on this screen reports what it moved and supersedes the
|
||||
// confirmation, for a deletion as much as for a save — so it is made to fail, and what the sentence
|
||||
// says is asserted in the state where somebody actually reads it.
|
||||
server.SyncFailure = new HttpRequestException("The server is having a bad day.");
|
||||
|
||||
await vault.ConfirmDeleteCommand.ExecuteAsync(null);
|
||||
|
||||
vault.Groups.ShouldBeEmpty();
|
||||
vault.HasGroups.ShouldBeFalse();
|
||||
|
||||
// The host keeps the id, which is what makes this cheap; the list is what resolves it to nothing.
|
||||
vault.Hosts.Single().Host.GroupId.ShouldBe(groupId);
|
||||
vault.Hosts.Single().Host.GroupId.ShouldBeNull(vault.Status);
|
||||
vault.Hosts.Single().GroupLabel.ShouldBeEmpty();
|
||||
vault.SidebarRows.ShouldAllBe(row => row is HostRowViewModel);
|
||||
|
||||
// The card says the same thing the phone's list does: nothing. An id nobody can name is drawn as no
|
||||
// group rather than as a GUID on a chip.
|
||||
vault.Hosts.Single().GroupLabel.ShouldBeEmpty();
|
||||
vault.Status.ShouldContain("UNGROUPED", Case.Sensitive);
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// The picker keeps a placeholder entry for a group the vault no longer has, exactly as the
|
||||
/// The other answer, and the reason the question is asked at all: a group is sometimes a heading being
|
||||
/// tidied away and sometimes a project that has been decommissioned, and nothing in the view model can
|
||||
/// tell which of the two it is looking at.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task DeletingAGroupWithTheTickSet_TakesItsHostsWithIt()
|
||||
{
|
||||
await UnlockedAsync();
|
||||
var vault = shell.Vault!;
|
||||
|
||||
await AddHostAsync(vault, "prod-db");
|
||||
await AddHostAsync(vault, "prod-web");
|
||||
await AddGroupAsync(vault, "production");
|
||||
await FileAsync(vault, "prod-db", "production");
|
||||
|
||||
vault.SelectedGroup = vault.Groups.Single();
|
||||
vault.DeleteGroupCommand.Execute(null);
|
||||
|
||||
vault.PendingDeletion.ShouldNotBeNull().Choice.ShouldContain("host");
|
||||
vault.DeletionTakesTheHostsToo = true;
|
||||
|
||||
await vault.ConfirmDeleteCommand.ExecuteAsync(null);
|
||||
|
||||
vault.Groups.ShouldBeEmpty();
|
||||
|
||||
// Only the machine that was filed under it. A deletion aimed at a heading must not reach the hosts
|
||||
// that were never on it.
|
||||
vault.Hosts.Select(row => row.Label).ShouldBe(["prod-web"], vault.Status);
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// The answer is not carried from one question to the next. A tick left standing would delete the next
|
||||
/// group's machines on the strength of a decision about the last one's, and there is no undo on either
|
||||
/// side of that.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task AskingAboutASecondGroup_StartsFromKeepingItsHosts()
|
||||
{
|
||||
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");
|
||||
|
||||
vault.SelectedGroup = vault.Groups.Single(
|
||||
row => string.Equals(row.Label, "production", StringComparison.Ordinal));
|
||||
|
||||
vault.DeleteGroupCommand.Execute(null);
|
||||
vault.DeletionTakesTheHostsToo = true;
|
||||
vault.CancelDeleteCommand.Execute(null);
|
||||
|
||||
vault.SelectedGroup = vault.Groups.Single(
|
||||
row => string.Equals(row.Label, "staging", StringComparison.Ordinal));
|
||||
|
||||
vault.DeleteGroupCommand.Execute(null);
|
||||
|
||||
vault.DeletionTakesTheHostsToo.ShouldBeFalse("every question starts from keeping the machines");
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// Unfiling rewrites every host under the heading, so it is refused with a host editor open for the
|
||||
/// reason a drop onto a group card is: rewriting the saved host under a half-typed edit of it would be a
|
||||
/// save nobody asked for, and one they could then not cancel. Deleting a single host is not refused,
|
||||
/// because that one writes nothing to a form anybody is looking at.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task DeletingAGroupWhileTheHostEditorIsOpen_IsRefused()
|
||||
{
|
||||
await UnlockedAsync();
|
||||
var vault = shell.Vault!;
|
||||
|
||||
await AddHostAsync(vault, "prod-db");
|
||||
await AddGroupAsync(vault, "production");
|
||||
await FileAsync(vault, "prod-db", "production");
|
||||
|
||||
vault.SelectedHost = vault.Hosts.Single();
|
||||
vault.EditSelectedHostCommand.Execute(null);
|
||||
vault.EditorLabel = "half-typed";
|
||||
|
||||
vault.SelectedGroup = vault.Groups.Single();
|
||||
vault.DeleteGroupCommand.Execute(null);
|
||||
|
||||
vault.PendingDeletion.ShouldBeNull("the question was never put");
|
||||
vault.IsEditing.ShouldBeTrue("and the edit is still there to finish");
|
||||
vault.Status.ShouldContain("editing");
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The picker keeps a placeholder entry for a group the vault does not have, 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.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The dangling id is imported rather than produced by deleting the group, and that is a consequence of
|
||||
/// the change above rather than a contrivance: a group deleted <em>here</em> now unfiles its hosts on the
|
||||
/// way out, so the only way a host still names one is that the group went on another machine and this
|
||||
/// client has yet to be told — which is exactly what a host arriving with an id nothing resolves is.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task EditingAHostWhoseGroupIsGone_DoesNotUnfileItBySaving()
|
||||
@@ -3849,15 +4044,13 @@ public sealed class ShellFlowTests : IAsyncLifetime
|
||||
await UnlockedAsync();
|
||||
var vault = shell.Vault!;
|
||||
|
||||
await AddHostAsync(vault, "prod-db");
|
||||
await AddGroupAsync(vault, "production");
|
||||
await FileAsync(vault, "prod-db", "production");
|
||||
var groupId = Guid.CreateVersion7();
|
||||
|
||||
var groupId = vault.Groups.Single().EntityId;
|
||||
await vault.ImportHostsAsync(
|
||||
[new HostSecret { Label = "prod-db", Hostname = "db.internal", GroupId = groupId }],
|
||||
Token);
|
||||
|
||||
vault.SelectedGroup = vault.Groups.Single();
|
||||
vault.DeleteGroupCommand.Execute(null);
|
||||
await vault.ConfirmDeleteCommand.ExecuteAsync(null);
|
||||
vault.Groups.ShouldBeEmpty("nothing in this keychain answers to that id");
|
||||
|
||||
vault.SelectedHost = vault.Hosts.Single();
|
||||
vault.EditSelectedHostCommand.Execute(null);
|
||||
@@ -4333,6 +4526,35 @@ public sealed class ShellFlowTests : IAsyncLifetime
|
||||
vault.GroupEditorLabel.ShouldBe("production", "the heading pressed, not the group selected");
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// The heading hands its group to the editor rather than selecting it first, and this is why. A group
|
||||
/// selection clears the host selection — the desktop's two grids share one mark — and the phone draws no
|
||||
/// group cards at all, so selecting one here would take the highlight off the machine in the list with
|
||||
/// nothing on screen to say where it had gone, or how to get it back.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task AGroupsHeading_LeavesTheChosenMachineChosen()
|
||||
{
|
||||
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;
|
||||
|
||||
var heading = vault.SidebarRows.OfType<SidebarGroupHeader>().Single(
|
||||
row => string.Equals(row.Label, "production", StringComparison.Ordinal));
|
||||
|
||||
vault.EditGroupFromHeadingCommand.Execute(heading);
|
||||
|
||||
vault.IsEditingGroup.ShouldBeTrue("the editor still opens on the group the heading names");
|
||||
vault.GroupEditorLabel.ShouldBe("production");
|
||||
vault.SelectedHost.ShouldBeSameAs(host, "and the list is still on the machine it was on");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TheUngroupedHeading_OpensNothing()
|
||||
{
|
||||
|
||||
@@ -532,6 +532,121 @@ public sealed class VaultSharingTests : IAsyncLifetime
|
||||
row.VaultId.ShouldBe(sharedVaultId);
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Moving a host into a shared vault, which is the operation that used to require deleting it and
|
||||
/// typing it again: the two vaults are encrypted under different keys, so what happens underneath is a
|
||||
/// re-seal into one and a tombstone in the other. The host has to arrive intact, be gone from where it
|
||||
/// was, and carry a new id — one entity id in two vaults would make the destination's row and the
|
||||
/// source's tombstone the same row.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The group is asserted cleared, and that is the half worth a test rather than a comment. A group is
|
||||
/// an item of the vault the host is leaving, so a host that carried the reference across would resolve
|
||||
/// it on this machine — groups are resolved over every readable vault — and dangle for everybody else
|
||||
/// in the destination. The mover and their colleagues would be looking at two different hosts.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task MovingAHostToAnotherVault_ReSealsItThereAndLeavesItsGroupBehind()
|
||||
{
|
||||
await UnlockedAsync();
|
||||
|
||||
var vaults = shell.Vaults;
|
||||
|
||||
await CreateVaultAsync(vaults, "Platform secrets");
|
||||
|
||||
var vault = shell.Vault!;
|
||||
var sharedVaultId = vaults.SelectedVault!.VaultId;
|
||||
|
||||
await vault.LoadAsync(Token);
|
||||
|
||||
// In the personal vault, under a group of its own, which is what the move has to leave behind.
|
||||
vault.NewGroupCommand.Execute(null);
|
||||
vault.GroupEditorLabel = "Production";
|
||||
await vault.SaveGroupCommand.ExecuteAsync(null);
|
||||
|
||||
vault.NewHostCommand.Execute(null);
|
||||
vault.EditorLabel = "prod-db";
|
||||
vault.EditorHostname = "db.internal";
|
||||
vault.EditorUsername = "deploy";
|
||||
vault.EditorSelectedGroup = vault.EditorGroupChoices.Single(
|
||||
choice => string.Equals(choice.Label, "Production", StringComparison.Ordinal));
|
||||
|
||||
await vault.SaveHostCommand.ExecuteAsync(null);
|
||||
|
||||
var before = vault.Hosts.Single(
|
||||
host => string.Equals(host.Label, "prod-db", StringComparison.Ordinal));
|
||||
|
||||
before.VaultId.ShouldNotBe(sharedVaultId);
|
||||
before.Host.GroupId.ShouldNotBeNull("the host was filed under a group before the move");
|
||||
|
||||
vault.SelectedHost = before;
|
||||
vault.CanMoveSelectedHost.ShouldBeTrue("there is a second vault this session can write to");
|
||||
|
||||
vault.MoveHostCommand.Execute(null);
|
||||
|
||||
vault.IsMovingHost.ShouldBeTrue(vault.Status);
|
||||
vault.MoveVaultChoices.ShouldNotContain(choice => choice.VaultId == before.VaultId);
|
||||
|
||||
vault.SelectedMoveVault =
|
||||
vault.MoveVaultChoices.Single(choice => choice.VaultId == sharedVaultId);
|
||||
|
||||
// The pass that follows every write on this screen is made to fail, so that the move's own sentence
|
||||
// is still on the status line to be read. That is not a contrivance to dodge a race: a successful
|
||||
// pass reports what it moved and supersedes the confirmation of every save, delete and move alike —
|
||||
// pre-existing behaviour of the whole screen — and the state asserted here is the one where the
|
||||
// sentence matters most, because nothing has reached the server yet.
|
||||
server.SyncFailure = new IOException("The server is not answering.");
|
||||
|
||||
await vault.ConfirmMoveHostCommand.ExecuteAsync(null);
|
||||
|
||||
var after = vault.Hosts.Single(
|
||||
host => string.Equals(host.Label, "prod-db", StringComparison.Ordinal));
|
||||
|
||||
after.VaultId.ShouldBe(sharedVaultId, vault.Status);
|
||||
after.EntityId.ShouldNotBe(before.EntityId, "an id belongs to one vault");
|
||||
after.Host.Hostname.ShouldBe("db.internal");
|
||||
after.Host.Username.ShouldBe("deploy");
|
||||
after.Host.GroupId.ShouldBeNull("a group belongs to the vault the host came from");
|
||||
|
||||
vault.SelectedHost?.EntityId.ShouldBe(after.EntityId, "the pane follows the host it moved");
|
||||
vault.Status.ShouldContain("Platform secrets");
|
||||
vault.Status.ShouldContain("group", Case.Insensitive);
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// The move is refused where it would have nowhere to go, by the command rather than by an empty
|
||||
/// picker — and the phone reads the same question to decide whether to draw the button at all.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task MovingAHostWithNowhereToMoveIt_SaysSoRatherThanOpeningAnEmptyPicker()
|
||||
{
|
||||
await UnlockedAsync();
|
||||
|
||||
var vault = shell.Vault!;
|
||||
|
||||
await vault.LoadAsync(Token);
|
||||
|
||||
vault.NewHostCommand.Execute(null);
|
||||
vault.EditorLabel = "prod-db";
|
||||
vault.EditorHostname = "db.internal";
|
||||
vault.EditorUsername = "deploy";
|
||||
|
||||
await vault.SaveHostCommand.ExecuteAsync(null);
|
||||
|
||||
vault.SelectedHost = vault.Hosts.Single(
|
||||
host => string.Equals(host.Label, "prod-db", StringComparison.Ordinal));
|
||||
|
||||
vault.CanMoveSelectedHost.ShouldBeFalse("the personal vault is the only one there is");
|
||||
|
||||
vault.MoveHostCommand.Execute(null);
|
||||
|
||||
vault.IsMovingHost.ShouldBeFalse();
|
||||
vault.MoveVaultChoices.ShouldBeEmpty();
|
||||
vault.Status.ShouldContain("only vault you can write to");
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The picker the host editor grew, and the thing it is for: choosing at the moment a host is created,
|
||||
@@ -612,12 +727,364 @@ public sealed class VaultSharingTests : IAsyncLifetime
|
||||
vault.ShowsEditorVaultChoice.ShouldBeFalse("an item cannot be moved between vaults");
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// A group is a shelf, and a shared vault is what makes it everybody's shelf. The assertions are the
|
||||
/// three things that were missing while the group list was the active vault's alone: it is listed at
|
||||
/// all, the row says which vault it is in, and a rename typed into it goes back to that vault rather
|
||||
/// than forking a second group of the new name into the personal one.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Reloaded between the write and the read, so what is asserted is what came back out of the vault
|
||||
/// rather than the row the save left behind.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task AGroupFiledIntoASharedVault_IsListedThereAndRenamedThere()
|
||||
{
|
||||
await UnlockedAsync();
|
||||
|
||||
var vaults = shell.Vaults;
|
||||
|
||||
await CreateVaultAsync(vaults, "Platform secrets");
|
||||
|
||||
var vault = shell.Vault!;
|
||||
var sharedVaultId = vaults.SelectedVault!.VaultId;
|
||||
|
||||
await vault.LoadAsync(Token);
|
||||
|
||||
vault.NewGroupCommand.Execute(null);
|
||||
|
||||
vault.ShowsGroupEditorVaultChoice.ShouldBeTrue("there are two vaults to choose between");
|
||||
|
||||
vault.GroupEditorSelectedVault =
|
||||
vault.GroupEditorVaultChoices.Single(choice => choice.VaultId == sharedVaultId);
|
||||
|
||||
vault.GroupEditorLabel = "production";
|
||||
|
||||
await vault.SaveGroupCommand.ExecuteAsync(null);
|
||||
await vault.LoadAsync(Token);
|
||||
|
||||
var group = vault.Groups.ShouldHaveSingleItem();
|
||||
|
||||
group.VaultId.ShouldBe(sharedVaultId, vault.Status);
|
||||
group.VaultBadge.ShouldBe("PLATFORM SECRETS", "a card in a session holding two vaults says which");
|
||||
|
||||
vault.SelectedGroup = group;
|
||||
vault.EditGroupCommand.Execute(null);
|
||||
|
||||
vault.ShowsGroupEditorVaultChoice.ShouldBeFalse("an item cannot be moved between vaults");
|
||||
|
||||
vault.DrawerSubtitle.ShouldBe(
|
||||
"Platform secrets", "with no picker drawn, the header is what says whose shelf this is");
|
||||
|
||||
vault.GroupEditorLabel = "live";
|
||||
|
||||
await vault.SaveGroupCommand.ExecuteAsync(null);
|
||||
await vault.LoadAsync(Token);
|
||||
|
||||
var renamed = vault.Groups.ShouldHaveSingleItem();
|
||||
|
||||
renamed.Label.ShouldBe("live");
|
||||
renamed.VaultId.ShouldBe(sharedVaultId, "a rename must not fork a copy into the personal vault");
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The group editor's picker is the group's, exactly as the host editor's is the host's: moving it must
|
||||
/// not move the keychain screen's standing preference, and moving that one must not move a group
|
||||
/// half-typed here.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The second half is the one worth the test. The picker is read when the form opens and the vault is
|
||||
/// captured there, so a click on the other screen between typing the name and pressing ADD cannot
|
||||
/// redirect the group somebody was making.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task TheGroupEditorChoosesItsOwnVault_WithoutMovingTheKeychainScreensPicker()
|
||||
{
|
||||
await UnlockedAsync();
|
||||
|
||||
var vaults = shell.Vaults;
|
||||
|
||||
await CreateVaultAsync(vaults, "Platform secrets");
|
||||
|
||||
var vault = shell.Vault!;
|
||||
var sharedVaultId = vaults.SelectedVault!.VaultId;
|
||||
|
||||
await vault.LoadAsync(Token);
|
||||
|
||||
var personal = vault.SelectedTargetVault!;
|
||||
|
||||
vault.NewGroupCommand.Execute(null);
|
||||
|
||||
vault.GroupEditorSelectedVault =
|
||||
vault.GroupEditorVaultChoices.Single(choice => choice.VaultId == sharedVaultId);
|
||||
|
||||
vault.GroupEditorLabel = "production";
|
||||
|
||||
// Moved back after the editor opened, the way a click on the keychain screen would. The group must
|
||||
// still land in the shared vault.
|
||||
vault.SelectedTargetVault = personal;
|
||||
|
||||
await vault.SaveGroupCommand.ExecuteAsync(null);
|
||||
|
||||
vault.Groups.ShouldHaveSingleItem().VaultId.ShouldBe(sharedVaultId, vault.Status);
|
||||
|
||||
vault.SelectedTargetVault.ShouldBe(
|
||||
personal, "the editor's picker is the group's, not the screen's standing preference");
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// A parent belongs to one vault, and a group filed under one in another vault would be a level half
|
||||
/// the people holding the key cannot resolve — their hosts would inherit a port and a username from
|
||||
/// nothing. The same rule the host editor's group picker follows, one level up the same tree.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task AGroupsParentPicker_OffersOnlyTheVaultItIsGoingInto()
|
||||
{
|
||||
await UnlockedAsync();
|
||||
|
||||
var vaults = shell.Vaults;
|
||||
|
||||
await CreateVaultAsync(vaults, "Platform secrets");
|
||||
|
||||
var vault = shell.Vault!;
|
||||
var sharedVaultId = vaults.SelectedVault!.VaultId;
|
||||
|
||||
await vault.LoadAsync(Token);
|
||||
|
||||
// In the personal vault, which is where the standing preference points.
|
||||
vault.NewGroupCommand.Execute(null);
|
||||
vault.GroupEditorLabel = "estate";
|
||||
await vault.SaveGroupCommand.ExecuteAsync(null);
|
||||
|
||||
vault.Groups.ShouldHaveSingleItem().Label.ShouldBe("estate", vault.Status);
|
||||
|
||||
vault.NewGroupCommand.Execute(null);
|
||||
|
||||
vault.GroupEditorParentChoices
|
||||
.Any(choice => string.Equals(choice.Label, "estate", StringComparison.Ordinal))
|
||||
.ShouldBeTrue("a group in the personal vault may be filed under a personal group");
|
||||
|
||||
vault.GroupEditorSelectedVault =
|
||||
vault.GroupEditorVaultChoices.Single(choice => choice.VaultId == sharedVaultId);
|
||||
|
||||
vault.GroupEditorParentChoices.ShouldHaveSingleItem()
|
||||
.EntityId.ShouldBeNull("only 'no parent' is left once the group is going somewhere else");
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Moving the shelf rather than what is on it, which is the operation people were attempting one host at
|
||||
/// a time: a group cannot go anywhere alone, because the machines filed under it and the groups nested
|
||||
/// inside it are items of the vault it is leaving. All of them are re-sealed under the destination's key
|
||||
/// and all of them take new ids, so what this asserts is not only that they arrived but that the tree
|
||||
/// arrived — the child is still under the parent, and the host is still under the child, through two
|
||||
/// levels of ids that were rewritten on the way across.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The parent the moved group was nested under is asserted <em>gone</em>, and that is the honest half. A
|
||||
/// parent belongs to the vault it is in, so carrying the reference would leave everybody else in the
|
||||
/// destination looking at a group hanging from nothing. It arrives at the top level and the sentence
|
||||
/// says so.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task MovingAGroupToAnotherVault_TakesItsHostsAndItsNestedGroupsWithIt()
|
||||
{
|
||||
await UnlockedAsync();
|
||||
|
||||
var vaults = shell.Vaults;
|
||||
|
||||
await CreateVaultAsync(vaults, "Platform secrets");
|
||||
|
||||
var vault = shell.Vault!;
|
||||
var sharedVaultId = vaults.SelectedVault!.VaultId;
|
||||
|
||||
await vault.LoadAsync(Token);
|
||||
|
||||
await SeedNestedShelfAsync(vault);
|
||||
|
||||
var production = Named(vault, "production");
|
||||
|
||||
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.MoveGroupCommand.Execute(null);
|
||||
|
||||
vault.IsMovingGroup.ShouldBeTrue(vault.Status);
|
||||
vault.MoveGroupVaultChoices.ShouldNotContain(choice => choice.VaultId == production.VaultId);
|
||||
|
||||
vault.SelectedMoveGroupVault =
|
||||
vault.MoveGroupVaultChoices.Single(choice => choice.VaultId == sharedVaultId);
|
||||
|
||||
// The pass that follows every write on this screen is made to fail, so that the move's own sentence
|
||||
// is still on the status line to be read — the same arrangement, and for the same reason, as the
|
||||
// host's move test above.
|
||||
server.SyncFailure = new IOException("The server is not answering.");
|
||||
|
||||
await vault.ConfirmMoveGroupCommand.ExecuteAsync(null);
|
||||
|
||||
var moved = Named(vault, "production");
|
||||
var nested = Named(vault, "web");
|
||||
|
||||
moved.VaultId.ShouldBe(sharedVaultId, vault.Status);
|
||||
moved.EntityId.ShouldNotBe(production.EntityId, "an id belongs to one vault");
|
||||
moved.Group.ParentId.ShouldBeNull("a parent belongs to the vault the group came from");
|
||||
|
||||
nested.VaultId.ShouldBe(sharedVaultId, "a group inside it cannot be left in the other vault");
|
||||
nested.Group.ParentId.ShouldBe(moved.EntityId, "and it is still nested under the group it was in");
|
||||
|
||||
var host = vault.Hosts.Single(row => string.Equals(row.Label, "prod-db", StringComparison.Ordinal));
|
||||
|
||||
host.VaultId.ShouldBe(sharedVaultId, "the hosts came with the shelf");
|
||||
host.Host.GroupId.ShouldBe(nested.EntityId, "and are still filed where they were");
|
||||
|
||||
// The group it was nested under is the one thing that stayed, and it stayed where it was.
|
||||
Named(vault, "estate").VaultId.ShouldBe(production.VaultId);
|
||||
|
||||
vault.SelectedGroup?.EntityId.ShouldBe(moved.EntityId, "the buttons follow the group they moved");
|
||||
vault.Status.ShouldContain("Platform secrets");
|
||||
vault.Status.ShouldContain("top level");
|
||||
}
|
||||
|
||||
/// <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, 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()
|
||||
{
|
||||
await UnlockedAsync();
|
||||
|
||||
var vault = shell.Vault!;
|
||||
|
||||
await vault.LoadAsync(Token);
|
||||
|
||||
vault.NewGroupCommand.Execute(null);
|
||||
vault.GroupEditorLabel = "production";
|
||||
await vault.SaveGroupCommand.ExecuteAsync(null);
|
||||
|
||||
vault.SelectedGroup = vault.Groups.ShouldHaveSingleItem();
|
||||
|
||||
vault.MoveGroupCommand.Execute(null);
|
||||
|
||||
vault.IsMovingGroup.ShouldBeFalse();
|
||||
vault.MoveGroupVaultChoices.ShouldBeEmpty();
|
||||
vault.Status.ShouldContain("only vault you can write to");
|
||||
}
|
||||
|
||||
/// <summary>The group card with a given name, re-found because every row is replaced on every reload.</summary>
|
||||
private static HostGroupRowViewModel Named(VaultViewModel vault, string label) =>
|
||||
vault.Groups.Single(row => string.Equals(row.Label, label, StringComparison.Ordinal));
|
||||
|
||||
/// <summary>
|
||||
/// Builds estate › production › web in the personal vault, with prod-db on the innermost shelf.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Three levels, because two would not tell a subtree that was walked from one that was assumed a single
|
||||
/// level deep — the middle group is the one that has to arrive with a rewritten parent and a rewritten
|
||||
/// child at once.
|
||||
/// </remarks>
|
||||
private static async Task SeedNestedShelfAsync(VaultViewModel vault)
|
||||
{
|
||||
await AddGroupAsync(vault, "estate", under: null);
|
||||
await AddGroupAsync(vault, "production", under: "estate");
|
||||
await AddGroupAsync(vault, "web", under: "production");
|
||||
|
||||
vault.NewHostCommand.Execute(null);
|
||||
vault.EditorLabel = "prod-db";
|
||||
vault.EditorHostname = "db.internal";
|
||||
vault.EditorSelectedGroup = vault.EditorGroupChoices.Single(
|
||||
choice => string.Equals(choice.Label, "web", StringComparison.Ordinal));
|
||||
|
||||
await vault.SaveHostCommand.ExecuteAsync(null);
|
||||
}
|
||||
|
||||
/// <summary>Adds a group, optionally nested under one already there.</summary>
|
||||
private static async Task AddGroupAsync(VaultViewModel vault, string label, string? under)
|
||||
{
|
||||
vault.NewGroupCommand.Execute(null);
|
||||
vault.GroupEditorLabel = label;
|
||||
|
||||
if (under is not null)
|
||||
{
|
||||
vault.GroupEditorSelectedParent = vault.GroupEditorParentChoices.Single(
|
||||
choice => string.Equals(choice.Label, under, StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
await vault.SaveGroupCommand.ExecuteAsync(null);
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Dragging a host card onto a group card is the one gesture that files a host without opening its
|
||||
/// editor, and it can now be aimed across a vault boundary, because both grids draw every readable
|
||||
/// vault. The write it would make is the exact thing the host editor's group picker was fixed to
|
||||
/// prevent: an id only the other vault's holders can resolve.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Refused and said so, rather than quietly treated as "no group" — the user is plainly filing
|
||||
/// something, and unfiling it instead would be the wrong answer delivered silently.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task AHostDraggedOntoAnotherVaultsGroup_IsRefusedRatherThanFiledUnderIt()
|
||||
{
|
||||
await UnlockedAsync();
|
||||
|
||||
var vaults = shell.Vaults;
|
||||
|
||||
await CreateVaultAsync(vaults, "Platform secrets");
|
||||
|
||||
var vault = shell.Vault!;
|
||||
var sharedVaultId = vaults.SelectedVault!.VaultId;
|
||||
|
||||
await vault.LoadAsync(Token);
|
||||
|
||||
vault.NewGroupCommand.Execute(null);
|
||||
|
||||
vault.GroupEditorSelectedVault =
|
||||
vault.GroupEditorVaultChoices.Single(choice => choice.VaultId == sharedVaultId);
|
||||
|
||||
vault.GroupEditorLabel = "production";
|
||||
await vault.SaveGroupCommand.ExecuteAsync(null);
|
||||
|
||||
// The host stays in the personal vault, which is where a new one goes without being told otherwise.
|
||||
vault.NewHostCommand.Execute(null);
|
||||
vault.EditorLabel = "prod-db";
|
||||
vault.EditorHostname = "db.internal";
|
||||
await vault.SaveHostCommand.ExecuteAsync(null);
|
||||
|
||||
var host = vault.Hosts.Single(row => string.Equals(row.Label, "prod-db", StringComparison.Ordinal));
|
||||
var group = vault.Groups.Single(row => row.VaultId == sharedVaultId);
|
||||
|
||||
host.VaultId.ShouldNotBe(sharedVaultId, "this test is meaningless with both in one vault");
|
||||
|
||||
await vault.MoveHostToGroupCommand.ExecuteAsync(new HostGroupMove(host, group.EntityId));
|
||||
|
||||
vault.Status.ShouldContain("its own vault");
|
||||
|
||||
vault.Hosts
|
||||
.Single(row => string.Equals(row.Label, "prod-db", StringComparison.Ordinal))
|
||||
.Host.GroupId
|
||||
.ShouldBeNull("the host is left where it was rather than filed under an unresolvable group");
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// The mirror image of the host test above, and it goes the other way on purpose. A host filed into a
|
||||
/// shared vault has to stay there, because hosts are read across every readable vault and so come back.
|
||||
/// Tags are not — the editable list is the active vault's alone, like groups and buckets — so a tag
|
||||
/// filed anywhere else would be created, pushed, reported as added and then invisible, with nothing on
|
||||
/// the keychain screen able to rename or delete it and no active-vault switcher to go and find it with.
|
||||
/// shared vault has to stay there, because hosts are read across every readable vault and so come back;
|
||||
/// so does a group, since its list spans them too. Tags are not — the editable list is the active
|
||||
/// vault's alone, like buckets — so a tag filed anywhere else would be created, pushed, reported as
|
||||
/// added and then invisible, with nothing on the keychain screen able to rename or delete it and no
|
||||
/// active-vault switcher to go and find it with.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task ATagIgnoresTheTargetPicker_BecauseItsListOnlyEverShowsOneVault()
|
||||
|
||||
@@ -451,8 +451,10 @@ public sealed class VaultVisibilityTests : IAsyncLifetime
|
||||
/// <remarks>
|
||||
/// It is drawn in the menu and ticked, because a vault missing from a list of vaults reads as something
|
||||
/// having gone wrong — and it cannot be switched off, because snippets, logs, buckets and the editable
|
||||
/// group and tag lists are all read from it alone. Switching it off would empty half the application
|
||||
/// rather than filter it, so the refusal says why instead of doing nothing.
|
||||
/// tag list are all read from it alone. Switching it off would empty half the application rather than
|
||||
/// filter it, so the refusal says why instead of doing nothing. The group list is no longer among them:
|
||||
/// it spans every readable vault, and hiding one drops that vault's cards and headings the way it drops
|
||||
/// its hosts.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task ThePersonalVaultIsListedAndCannotBeHidden()
|
||||
@@ -471,6 +473,67 @@ public sealed class VaultVisibilityTests : IAsyncLifetime
|
||||
shell.StatusMessage.ShouldContain("always shown");
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The two halves of a group, and hiding a vault has to move exactly one of them. The cards and
|
||||
/// headings are a list a person reads, so a hidden vault's group leaves it — a folder that cannot be
|
||||
/// opened onto anything is worse than no folder. What a group also is is a port, a username and a
|
||||
/// binding lent to the hosts beneath it, and that must not move: those hosts are still in
|
||||
/// <c>Hosts</c>, which is what the connect path and the transfers screen read.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Asserted through the resolved port rather than through the map directly, because the resolved port
|
||||
/// is what a connection actually dials. A hidden vault whose hosts silently fell back to 22 would be
|
||||
/// this split having collapsed, and nothing on screen would say so.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task HidingAVault_TakesItsGroupCardsButNotWhatItsHostsDial()
|
||||
{
|
||||
await UnlockedAsync();
|
||||
|
||||
await shell.Vaults.LoadAsync(Token);
|
||||
|
||||
var teamVaultId = await CreateVaultAsync("Platform secrets");
|
||||
var vault = shell.Vault!;
|
||||
|
||||
await vault.LoadAsync(Token);
|
||||
|
||||
vault.NewGroupCommand.Execute(null);
|
||||
|
||||
vault.GroupEditorSelectedVault =
|
||||
vault.GroupEditorVaultChoices.Single(choice => choice.VaultId == teamVaultId);
|
||||
|
||||
vault.GroupEditorLabel = "production";
|
||||
vault.GroupEditorDefaultPort = 2222;
|
||||
|
||||
await vault.SaveGroupCommand.ExecuteAsync(null);
|
||||
|
||||
await AddHostAsync(vault, teamVaultId, "prod-db", "db.internal");
|
||||
|
||||
vault.SelectedHost = vault.Hosts.Single(
|
||||
row => string.Equals(row.Label, "prod-db", StringComparison.Ordinal));
|
||||
|
||||
vault.EditSelectedHostCommand.Execute(null);
|
||||
|
||||
vault.EditorSelectedGroup = vault.EditorGroupChoices.Single(
|
||||
choice => string.Equals(choice.Label, "production", StringComparison.Ordinal));
|
||||
|
||||
await vault.SaveHostCommand.ExecuteAsync(null);
|
||||
|
||||
vault.Groups.ShouldHaveSingleItem().VaultId.ShouldBe(teamVaultId, vault.Status);
|
||||
|
||||
await HideAsync(teamVaultId);
|
||||
await vault.LoadAsync(Token);
|
||||
|
||||
vault.Groups.ShouldBeEmpty("a hidden vault's groups are cards onto hosts that are not drawn");
|
||||
|
||||
vault.Hosts
|
||||
.Single(row => string.Equals(row.Label, "prod-db", StringComparison.Ordinal))
|
||||
.Resolved.Port.Value
|
||||
.ShouldBe(2222, "hiding a vault must never change what one of its hosts dials");
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// The switches are the readable vaults, personal first. A vault whose grant awaits re-wrap has nothing
|
||||
/// that would decrypt, so a switch for it would do nothing at all.
|
||||
|
||||
Reference in New Issue
Block a user