Merge branch 'claude/vault-realtime-push-d64c61'
ci / build and test (push) Successful in 1m33s
ci / android head (push) Failing after 5s
ci / api image (push) Canceled after 36s

This commit is contained in:
2026-08-04 16:38:42 +02:00
31 changed files with 3285 additions and 13 deletions
+30
View File
@@ -1,4 +1,6 @@
using System.Net.Http.Headers;
using System.Net.WebSockets;
using DodoSSH.Contracts;
using DodoSSH.Infrastructure;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Testing;
@@ -107,6 +109,34 @@ public sealed class ApiFixture : WebApplicationFactory<Program>, IAsyncLifetime
/// <summary>Opens a database scope for arranging state and asserting on it.</summary>
public AsyncServiceScope CreateScope() => Services.CreateAsyncScope();
/// <summary>
/// Opens the event socket as the given subject, through the real pipeline.
/// </summary>
/// <remarks>
/// <para>
/// The bearer token goes on the upgrade request, which is the whole of the socket's authorization
/// — see ADR 0012 — so a test that stubbed it would be testing nothing. The subprotocol is offered
/// because the server refuses an upgrade that does not, and that refusal is itself under test.
/// </para>
/// <para>
/// <c>TestServer</c> speaks WebSockets in-memory with no port and no network, so these run
/// wherever the rest of the suite does.
/// </para>
/// </remarks>
public Task<WebSocket> ConnectEventsAsync(string subject, CancellationToken cancellationToken)
{
var token = IdentityProvider.MintToken(subject);
var client = Server.CreateWebSocketClient();
client.SubProtocols.Add(VaultEvents.SubProtocol);
// The server-side request, so the header is a raw string rather than a typed value.
client.ConfigureRequest = request => request.Headers.Authorization = $"Bearer {token}";
return client.ConnectAsync(
new Uri(Server.BaseAddress, VaultEvents.Path.TrimStart('/')),
cancellationToken);
}
}
/// <summary>Shares one host and container across every test class in the assembly.</summary>
@@ -56,6 +56,12 @@ public sealed class EndpointInventoryTests(ApiFixture fixture)
"POST /api/v1/vaults/{vaultId:guid}/sync/pull name=SyncPull tags=Sync policies=Enrolled anon=False",
"POST /api/v1/vaults/{vaultId:guid}/sync/push name=SyncPush tags=Sync policies=Enrolled anon=False",
// The WebSocket, gated exactly as sync is and for the same reason — it announces changes to
// vaults, and a caller who could not read one has nothing to be told about. It appears here as
// an ordinary route because that is what it is until the upgrade: the bearer token authorises
// the handshake, unlike the relay's ticket. See ADR 0012.
"GET /api/v1/events name=VaultEvents tags=Events policies=Enrolled anon=False",
// Enrolled, because the answer exists to be wrapped to and a caller with no key of their own has
// nothing to wrap and no signature to attribute it with. There is no search here — see
// DirectoryService for why an exact-match-only directory is a decision rather than a shortcut.
@@ -0,0 +1,461 @@
using System.Net;
using System.Net.WebSockets;
using System.Text;
using System.Text.Json;
using DodoSSH.Contracts;
using DodoSSH.Domain;
using DodoSSH.Infrastructure;
using Microsoft.Extensions.DependencyInjection;
namespace DodoSSH.Api.Tests;
/// <summary>
/// The push channel, over a real socket through the real authentication pipeline.
/// </summary>
/// <remarks>
/// <para>
/// The tests that matter most here are the two negatives: an unauthenticated upgrade is refused, and a
/// change to somebody else's vault does not reach this socket. A push channel that leaked <em>which
/// vault ids exist and when they change</em> would be a disclosure the pull path takes deliberate
/// trouble to avoid — <c>SyncPullEndpoint</c> answers 404 rather than 403 for exactly that reason —
/// and it would be invisible in a test that only checked that notices arrive.
/// </para>
/// <para>
/// Ordering is asserted rather than absence-within-a-timeout wherever possible. "Nothing arrived in
/// two seconds" is a test that passes on a slow machine for the wrong reason; "the first notice this
/// socket saw was about its own vault, although another vault was written to first" is not.
/// </para>
/// </remarks>
[Collection(ApiCollection.Name)]
public sealed class EventsEndpointTests(ApiFixture fixture)
{
private static readonly DateTimeOffset Now = new(2026, 8, 4, 12, 0, 0, TimeSpan.Zero);
/// <summary>
/// How long a test will wait for a frame before calling it a failure.
/// </summary>
/// <remarks>
/// Generous, because it is not a measurement: every wait here is for something already committed,
/// so the only thing this bounds is how long a genuinely broken build hangs before it reports.
/// </remarks>
private static readonly TimeSpan FrameTimeout = TimeSpan.FromSeconds(30);
// ---- The handshake ----
[Fact]
public async Task WithoutAToken_TheUpgradeIsRefused()
{
var client = fixture.Server.CreateWebSocketClient();
client.SubProtocols.Add(VaultEvents.SubProtocol);
var connecting = client.ConnectAsync(
new Uri(fixture.Server.BaseAddress, VaultEvents.Path.TrimStart('/')),
TestContext.Current.CancellationToken);
await Should.ThrowAsync<InvalidOperationException>(connecting);
}
[Fact]
public async Task BeforeEnrolling_Is403WithAnActionableCode()
{
// The same bar as sync: a caller with no identity key holds no vault key either, so every
// notice this socket could carry is about ciphertext they cannot read.
var client = fixture.CreateClientFor(NewSubject());
var response = await client.GetAsync(
new Uri(VaultEvents.Path, UriKind.Relative),
TestContext.Current.CancellationToken);
response.StatusCode.ShouldBe(HttpStatusCode.Forbidden);
var problem = await response.Content.ReadProblemAsync();
problem.ShouldNotBeNull();
problem.Code.ShouldBe(ProblemCodes.EnrollmentRequired);
}
[Fact]
public async Task APlainGet_SaysItIsAWebSocket()
{
// A person, or a client with the wrong URL. Answering with a problem document rather than a
// socket that closes is the difference between a diagnosable mistake and a mysterious one.
var client = fixture.CreateClientFor(await SeedEnrolledUserAsync());
var response = await client.GetAsync(
new Uri(VaultEvents.Path, UriKind.Relative),
TestContext.Current.CancellationToken);
response.StatusCode.ShouldBe(HttpStatusCode.BadRequest);
var problem = await response.Content.ReadProblemAsync();
problem.ShouldNotBeNull();
problem.Code.ShouldBe(ProblemCodes.MalformedRequest);
problem.Detail.ShouldNotBeNull().ShouldContain(VaultEvents.SubProtocol);
}
[Fact]
public async Task AnUpgradeWithoutTheSubprotocol_IsRefused()
{
// The subprotocol is this socket's version negotiation, so accepting an upgrade that did not
// offer it would mean answering a client in a dialect it never agreed to read.
var (subject, _) = await SeedUserWithVaultAsync();
var client = fixture.Server.CreateWebSocketClient();
client.ConfigureRequest = request => request.Headers.Authorization =
$"Bearer {fixture.IdentityProvider.MintToken(subject)}";
var connecting = client.ConnectAsync(
new Uri(fixture.Server.BaseAddress, VaultEvents.Path.TrimStart('/')),
TestContext.Current.CancellationToken);
await Should.ThrowAsync<InvalidOperationException>(connecting);
}
[Fact]
public async Task TheFirstFrameIsHello()
{
var (subject, _) = await SeedUserWithVaultAsync();
using var timeout = Timeout();
using var socket = await fixture.ConnectEventsAsync(subject, timeout.Token);
var hello = await ReadAsync(socket, timeout.Token);
hello.Kind.ShouldBe(VaultEventKinds.Hello);
// Sent so a client knows when silence means the socket is dead rather than quiet, and so a
// socket following nothing — a real state, for an account with no vaults — is distinguishable
// from one that is broken.
hello.HeartbeatSeconds.ShouldNotBeNull().ShouldBeGreaterThan(0);
hello.VaultCount.ShouldBe(1);
}
[Fact]
public async Task MetaAdvertisesTheFeature()
{
// How a client decides whether to hold a socket open at all. Absence is not an error — it
// means synchronise on the timer, which is what every client did before this existed.
var client = fixture.CreateClient();
var meta = await (await client.GetAsync(
new Uri("/api/v1/meta", UriKind.Relative),
TestContext.Current.CancellationToken))
.Content.ReadContractAsync<MetaResponse>();
meta.ShouldNotBeNull();
meta.Features.ShouldContain(
feature => string.Equals(feature, VaultEvents.Feature, StringComparison.Ordinal));
}
// ---- Notices ----
[Fact]
public async Task APush_AnnouncesTheVaultToAFollowingSocket()
{
var (subject, vaultId) = await SeedUserWithVaultAsync();
using var timeout = Timeout();
using var socket = await fixture.ConnectEventsAsync(subject, timeout.Token);
await ReadAsync(socket, timeout.Token);
var client = fixture.CreateClientFor(subject);
var push = await client.PostContractAsync(PushUrl(vaultId), NewCreateBatch());
push.EnsureSuccessStatusCode();
var notice = await ReadUntilAsync(socket, VaultEventKinds.VaultChanged, timeout.Token);
notice.VaultId.ShouldBe(vaultId);
// A hint for logging and coalescing, never a cursor: cursors are opaque and integrity-tagged,
// and a client that tried to resume from this would be resuming from a number it invented.
notice.Sequence.ShouldNotBeNull().ShouldBeGreaterThan(0);
}
[Fact]
public async Task ANoticeCarriesNoCiphertext()
{
// The load-bearing property of the whole design. A notice says only that a vault moved; the
// client's answer is the delta pull it would have run on its timer anyway, which keeps exactly
// one code path applying changes. See ADR 0012.
var (subject, vaultId) = await SeedUserWithVaultAsync();
using var timeout = Timeout();
using var socket = await fixture.ConnectEventsAsync(subject, timeout.Token);
await ReadAsync(socket, timeout.Token);
var client = fixture.CreateClientFor(subject);
var batch = NewCreateBatch();
await client.PostContractAsync(PushUrl(vaultId), batch);
var raw = await ReadRawUntilAsync(socket, VaultEventKinds.VaultChanged, timeout.Token);
// The envelope this test pushed, as it would appear if a payload had been forwarded.
raw.ShouldNotContain(Convert.ToBase64String(batch.Operations[0].Payload!.Envelope));
raw.ShouldNotContain("payload", Case.Insensitive);
raw.ShouldNotContain(batch.Operations[0].EntityId.ToString());
}
[Fact]
public async Task APushToAnotherAccountsVault_IsNotAnnouncedHere()
{
// The disclosure that would matter: a socket learning that vault ids it cannot read exist,
// and when somebody works on them.
var (subject, vaultId) = await SeedUserWithVaultAsync();
var (stranger, strangersVaultId) = await SeedUserWithVaultAsync();
using var timeout = Timeout();
using var socket = await fixture.ConnectEventsAsync(subject, timeout.Token);
await ReadAsync(socket, timeout.Token);
// The stranger's write goes first, so a socket that leaked would have announced it before the
// one this test then waits for. Ordering, not a timeout: "nothing arrived in two seconds"
// passes on a slow machine for the wrong reason.
var strangersClient = fixture.CreateClientFor(stranger);
(await strangersClient.PostContractAsync(PushUrl(strangersVaultId), NewCreateBatch()))
.EnsureSuccessStatusCode();
var ownClient = fixture.CreateClientFor(subject);
(await ownClient.PostContractAsync(PushUrl(vaultId), NewCreateBatch()))
.EnsureSuccessStatusCode();
var notice = await ReadUntilAsync(socket, VaultEventKinds.VaultChanged, timeout.Token);
notice.VaultId.ShouldBe(vaultId);
notice.VaultId.ShouldNotBe(strangersVaultId);
}
[Fact]
public async Task APushThatAppliedNothing_AnnouncesNothing()
{
// A batch of pure conflicts moved no vault. Announcing one anyway would have every client on
// it pull for a change that is not there.
var (subject, vaultId) = await SeedUserWithVaultAsync();
var client = fixture.CreateClientFor(subject);
using var timeout = Timeout();
using var socket = await fixture.ConnectEventsAsync(subject, timeout.Token);
await ReadAsync(socket, timeout.Token);
// An update to an item that does not exist: rejected as a conflict, nothing written.
var stale = new SyncPushRequest(
[
NewOperation(Guid.CreateVersion7(), expectedVersion: 7, envelope: [9, 9]),
]);
var conflicted = await client.PostContractAsync(PushUrl(vaultId), stale);
conflicted.EnsureSuccessStatusCode();
var results = await conflicted.Content.ReadContractAsync<SyncPushResponse>();
results.ShouldNotBeNull();
results.Results[0].Status.ShouldBe(SyncOperationStatus.Conflict);
// Then a write that did land. The first notice must be that one.
(await client.PostContractAsync(PushUrl(vaultId), NewCreateBatch())).EnsureSuccessStatusCode();
var notice = await ReadUntilAsync(socket, VaultEventKinds.VaultChanged, timeout.Token);
notice.Sequence.ShouldNotBeNull().ShouldBeGreaterThan(0);
}
[Fact]
public async Task APing_IsAnswered()
{
var (subject, _) = await SeedUserWithVaultAsync();
using var timeout = Timeout();
using var socket = await fixture.ConnectEventsAsync(subject, timeout.Token);
await ReadAsync(socket, timeout.Token);
await SendAsync(socket, new VaultEvent(VaultEventKinds.Ping), timeout.Token);
var pong = await ReadUntilAsync(socket, VaultEventKinds.Pong, timeout.Token);
pong.Kind.ShouldBe(VaultEventKinds.Pong);
}
[Fact]
public async Task AFrameThisServerCannotRead_DoesNotEndTheSocket()
{
// A control channel whose failure mode is "the client polls instead" should tolerate a frame
// from a newer client rather than cost that client its push for the whole session.
var (subject, vaultId) = await SeedUserWithVaultAsync();
using var timeout = Timeout();
using var socket = await fixture.ConnectEventsAsync(subject, timeout.Token);
await ReadAsync(socket, timeout.Token);
await socket.SendAsync(
Encoding.UTF8.GetBytes("{ not json at all"),
WebSocketMessageType.Text,
endOfMessage: true,
timeout.Token);
var client = fixture.CreateClientFor(subject);
(await client.PostContractAsync(PushUrl(vaultId), NewCreateBatch())).EnsureSuccessStatusCode();
var notice = await ReadUntilAsync(socket, VaultEventKinds.VaultChanged, timeout.Token);
notice.VaultId.ShouldBe(vaultId);
socket.State.ShouldBe(WebSocketState.Open);
}
// ---- Helpers ----
/// <summary>A token that gives up rather than letting a broken build hang the suite.</summary>
private static CancellationTokenSource Timeout()
{
var source = CancellationTokenSource.CreateLinkedTokenSource(
TestContext.Current.CancellationToken);
source.CancelAfter(FrameTimeout);
return source;
}
private static async Task<VaultEvent> ReadAsync(WebSocket socket, CancellationToken cancellationToken)
{
var json = await ReadRawAsync(socket, cancellationToken);
return JsonSerializer.Deserialize(json, DodoSshJsonContext.Default.VaultEvent)
?? throw new InvalidOperationException($"The server sent a null frame: {json}");
}
private static async Task<string> ReadRawAsync(WebSocket socket, CancellationToken cancellationToken)
{
var buffer = new byte[8 * 1024];
var received = await socket.ReceiveAsync(buffer, cancellationToken);
if (received.MessageType == WebSocketMessageType.Close)
{
throw new InvalidOperationException(
$"The server closed the socket: {received.CloseStatus} {received.CloseStatusDescription}");
}
return Encoding.UTF8.GetString(buffer, 0, received.Count);
}
/// <summary>Reads past the frames a test does not care about — hello, and heartbeats.</summary>
private static async Task<VaultEvent> ReadUntilAsync(
WebSocket socket,
string kind,
CancellationToken cancellationToken)
{
while (true)
{
var frame = await ReadAsync(socket, cancellationToken);
if (string.Equals(frame.Kind, kind, StringComparison.Ordinal))
{
return frame;
}
}
}
/// <summary>
/// The same, but keeping the bytes.
/// </summary>
/// <remarks>
/// Deserialising and asserting on the fields would prove only that this <em>record</em> has no
/// payload member, which is a tautology. Asserting on what actually crossed the socket is what
/// would catch a field added to the frame later without anybody thinking about disclosure.
/// </remarks>
private static async Task<string> ReadRawUntilAsync(
WebSocket socket,
string kind,
CancellationToken cancellationToken)
{
while (true)
{
var json = await ReadRawAsync(socket, cancellationToken);
var frame = JsonSerializer.Deserialize(json, DodoSshJsonContext.Default.VaultEvent);
if (string.Equals(frame?.Kind, kind, StringComparison.Ordinal))
{
return json;
}
}
}
private static Task SendAsync(WebSocket socket, VaultEvent frame, CancellationToken cancellationToken) =>
socket.SendAsync(
JsonSerializer.SerializeToUtf8Bytes(frame, DodoSshJsonContext.Default.VaultEvent),
WebSocketMessageType.Text,
endOfMessage: true,
cancellationToken);
private static string PushUrl(Guid vaultId) => $"/api/v1/vaults/{vaultId}/sync/push";
private static string NewSubject() => $"events-{Guid.CreateVersion7():N}";
private static SyncPushOperation NewOperation(Guid entityId, int? expectedVersion, byte[] envelope) =>
new(
Guid.CreateVersion7(),
SyncEntityType.Host,
entityId,
SyncOperation.Upsert,
expectedVersion,
new EncryptedPayload(envelope, [0xD, 0xE], Guid.CreateVersion7(), 1, 1),
new SyncPlaintextFields());
private static SyncPushRequest NewCreateBatch() =>
new([NewOperation(Guid.CreateVersion7(), expectedVersion: null, envelope: [1, 2, 3, 4])]);
private async Task<string> SeedEnrolledUserAsync()
{
var subject = NewSubject();
await using var scope = fixture.CreateScope();
var database = scope.ServiceProvider.GetRequiredService<DodoDbContext>();
var user = NewUser(subject);
database.Users.Add(user);
database.UserKeys.Add(Seed.CurrentKey(user.Id, Now));
await database.SaveChangesAsync();
return subject;
}
private async Task<(string Subject, Guid VaultId)> SeedUserWithVaultAsync()
{
var subject = NewSubject();
await using var scope = fixture.CreateScope();
var database = scope.ServiceProvider.GetRequiredService<DodoDbContext>();
var user = NewUser(subject);
var vault = new Vault
{
Id = Guid.CreateVersion7(),
Name = "Personal",
OwnerKind = VaultOwnerKind.Personal,
OwnerUserId = user.Id,
KeyGeneration = 1,
CreatedAtUtc = Now,
UpdatedAtUtc = Now,
};
database.Users.Add(user);
database.UserKeys.Add(Seed.CurrentKey(user.Id, Now));
database.Vaults.Add(vault);
await database.SaveChangesAsync();
return (subject, vault.Id);
}
private UserAccount NewUser(string subject) => new()
{
Id = Guid.CreateVersion7(),
Issuer = fixture.IdentityProvider.Authority,
Subject = subject,
Status = UserStatus.Active,
CreatedAtUtc = Now,
UpdatedAtUtc = Now,
};
}
@@ -0,0 +1,388 @@
using System.Net.WebSockets;
using System.Text;
using System.Text.Json;
using System.Threading.Channels;
using DodoSSH.Client.Api;
using DodoSSH.Contracts;
namespace DodoSSH.Client.Api.Tests;
/// <summary>
/// The reconnection policy, which is what this class actually is.
/// </summary>
/// <remarks>
/// <para>
/// A dropped socket is the ordinary case here rather than the exception — laptops sleep, proxies time
/// out, tokens expire, servers are redeployed — so the behaviour worth covering is what happens
/// <em>after</em> a failure, not the happy path. Driven through the injected connector, because the one
/// thing a test cannot do to a real network is make it fail on cue.
/// </para>
/// <para>
/// The backoff is configured down to milliseconds throughout. What is under test is the shape of the
/// policy — does it try again, does it wait, does it stop waiting when told the token was the problem —
/// and none of that depends on the intervals a shipped client uses.
/// </para>
/// </remarks>
public sealed class VaultEventStreamTests
{
private static readonly Uri Server = new("https://dodossh.example");
private static readonly VaultEventStreamOptions Impatient = new()
{
InitialBackoff = TimeSpan.FromMilliseconds(1),
MaxBackoff = TimeSpan.FromMilliseconds(5),
InitialSilenceTimeout = TimeSpan.FromSeconds(30),
};
[Fact]
public async Task ItDialsTheWebSocketFormOfTheServersUrl()
{
// https becomes wss, and the path is the one in the contract. Getting either wrong is a client
// that reconnects against a 404 for the whole session, which from outside is indistinguishable
// from a network that eats WebSockets.
var dialled = new List<Uri>();
var socket = new FakeWebSocket();
await using var stream = Stream(
(url, _, _) =>
{
dialled.Add(url);
return Task.FromResult<WebSocket>(socket);
});
socket.Deliver(new VaultEvent(VaultEventKinds.VaultChanged, Guid.CreateVersion7(), 1));
await stream.ReadAsync(Token);
dialled[0].ShouldBe(new Uri("wss://dodossh.example/api/v1/events"));
}
[Fact]
public async Task ItSendsTheBearerTokenOnTheUpgrade()
{
// The whole of this socket's authorization, unlike the relay's ticket. See ADR 0012.
var presented = new List<string>();
var socket = new FakeWebSocket();
await using var stream = Stream(
(_, token, _) =>
{
presented.Add(token);
return Task.FromResult<WebSocket>(socket);
});
socket.Deliver(new VaultEvent(VaultEventKinds.VaultChanged, Guid.CreateVersion7(), 1));
await stream.ReadAsync(Token);
presented[0].ShouldBe(StubTokens.Token);
}
[Fact]
public async Task ANoticeReachesTheReader()
{
var vaultId = Guid.CreateVersion7();
var socket = new FakeWebSocket();
await using var stream = Stream((_, _, _) => Task.FromResult<WebSocket>(socket));
socket.Deliver(new VaultEvent(VaultEventKinds.VaultChanged, vaultId, 42));
var notice = await stream.ReadAsync(Token);
notice.Kind.ShouldBe(VaultEventKinds.VaultChanged);
notice.VaultId.ShouldBe(vaultId);
notice.Sequence.ShouldBe(42);
}
[Fact]
public async Task AHeartbeatIsAnsweredAndNotHandedToTheReader()
{
// A ping is housekeeping between the two ends. Passing it up would wake a synchronisation loop
// every thirty seconds for a frame that says nothing happened.
var socket = new FakeWebSocket();
await using var stream = Stream((_, _, _) => Task.FromResult<WebSocket>(socket));
socket.Deliver(new VaultEvent(VaultEventKinds.Ping, HeartbeatSeconds: 30));
socket.Deliver(new VaultEvent(VaultEventKinds.VaultChanged, Guid.CreateVersion7(), 1));
var first = await stream.ReadAsync(Token);
first.Kind.ShouldBe(VaultEventKinds.VaultChanged, "the ping should not have been forwarded");
var answered = await socket.SentAsync(Token);
answered.Kind.ShouldBe(VaultEventKinds.Pong);
}
[Fact]
public async Task AKindThisBuildDoesNotKnow_IsStillHandedOver()
{
// What makes the frame table extensible: this class must not decide what a newer server may
// say. Deciding to ignore it is the caller's, and costs that caller one redundant pass.
var socket = new FakeWebSocket();
await using var stream = Stream((_, _, _) => Task.FromResult<WebSocket>(socket));
socket.Deliver(new VaultEvent("session.offered"));
var notice = await stream.ReadAsync(Token);
notice.Kind.ShouldBe("session.offered");
}
[Fact]
public async Task AFailedDial_IsRetried()
{
// No server yet, or no network. Neither is an error to report: the caller's synchronisation
// timer is running regardless, which is what lets this stay silent and keep trying.
var attempts = 0;
var socket = new FakeWebSocket();
await using var stream = Stream((_, _, _) =>
{
if (++attempts < 3)
{
throw new WebSocketException("no route to host");
}
return Task.FromResult<WebSocket>(socket);
});
socket.Deliver(new VaultEvent(VaultEventKinds.VaultChanged, Guid.CreateVersion7(), 1));
var notice = await stream.ReadAsync(Token);
notice.Kind.ShouldBe(VaultEventKinds.VaultChanged);
attempts.ShouldBe(3);
}
[Fact]
public async Task ADroppedSocket_IsReplaced()
{
// The case that decides whether this feature survives a laptop lid. A stream that gave up on
// the first close would work all morning and be silently dead after lunch.
var sockets = new List<FakeWebSocket>();
await using var stream = Stream((_, _, _) =>
{
var socket = new FakeWebSocket();
sockets.Add(socket);
if (sockets.Count == 1)
{
socket.Deliver(new VaultEvent(VaultEventKinds.VaultChanged, Guid.CreateVersion7(), 1));
socket.Close(WebSocketCloseStatus.EndpointUnavailable);
}
else
{
socket.Deliver(new VaultEvent(VaultEventKinds.VaultChanged, Guid.CreateVersion7(), 2));
}
return Task.FromResult<WebSocket>(socket);
});
(await stream.ReadAsync(Token)).Sequence.ShouldBe(1);
(await stream.ReadAsync(Token)).Sequence.ShouldBe(2);
sockets.Count.ShouldBeGreaterThanOrEqualTo(2);
}
[Fact]
public async Task AnExpiredTokenClose_ReconnectsAndAsksForAFreshToken()
{
// The bound that lets a long-lived socket be authorised by a short-lived credential: the server
// closes at the token's expiry and the client comes straight back with a new one. The token
// provider being asked again is the half that matters — reconnecting with the spent token would
// be an unbroken loop of closes.
var tokens = new StubTokens();
var sockets = 0;
await using var stream = new VaultEventStream(
Server,
tokens,
TimeProvider.System,
(_, _, _) =>
{
var socket = new FakeWebSocket();
if (++sockets == 1)
{
socket.Close((WebSocketCloseStatus)VaultEvents.TokenExpiredCloseCode);
}
else
{
socket.Deliver(new VaultEvent(VaultEventKinds.VaultChanged, Guid.CreateVersion7(), 7));
}
return Task.FromResult<WebSocket>(socket);
},
Impatient);
(await stream.ReadAsync(Token)).Sequence.ShouldBe(7);
tokens.Requests.ShouldBeGreaterThanOrEqualTo(2);
}
[Fact]
public async Task TryRead_TakesWhatIsWaitingAndSaysWhenNothingIs()
{
// How a caller coalesces a burst: read one, wait a moment, swallow the rest. Without this a
// colleague tidying a folder would produce a synchronisation pass per item.
var socket = new FakeWebSocket();
await using var stream = Stream((_, _, _) => Task.FromResult<WebSocket>(socket));
socket.Deliver(new VaultEvent(VaultEventKinds.VaultChanged, Guid.CreateVersion7(), 1));
socket.Deliver(new VaultEvent(VaultEventKinds.VaultChanged, Guid.CreateVersion7(), 2));
(await stream.ReadAsync(Token)).Sequence.ShouldBe(1);
// Delivery is asynchronous, so the second may not have landed yet; this is the same
// wait-then-drain the caller performs.
await Task.Delay(TimeSpan.FromMilliseconds(200), Token);
stream.TryRead(out var queued).ShouldBeTrue();
queued.Sequence.ShouldBe(2);
stream.TryRead(out _).ShouldBeFalse();
}
[Fact]
public async Task AnIdleStream_NeverDelivers()
{
// What a server without the feature supplies. Waiting for ever rather than completing is the
// point: a caller selecting between this and a timer has to fall through to the timer, and a
// read that returned at once would spin that loop as fast as the machine allows.
using var stream = IdleVaultEventStream.Instance;
using var giveUp = CancellationTokenSource.CreateLinkedTokenSource(Token);
giveUp.CancelAfter(TimeSpan.FromMilliseconds(100));
await Should.ThrowAsync<OperationCanceledException>(
async () => await stream.ReadAsync(giveUp.Token));
stream.TryRead(out _).ShouldBeFalse();
stream.IsConnected.ShouldBeFalse();
}
private static CancellationToken Token => TestContext.Current.CancellationToken;
private static VaultEventStream Stream(
Func<Uri, string, CancellationToken, Task<WebSocket>> connect) =>
new(Server, new StubTokens(), TimeProvider.System, connect, Impatient);
/// <summary>A token provider that hands out one value and counts who asked.</summary>
private sealed class StubTokens : IAccessTokenProvider
{
internal const string Token = "access-token";
internal int Requests { get; private set; }
public ValueTask<string> GetAccessTokenAsync(CancellationToken cancellationToken)
{
Requests++;
return ValueTask.FromResult(Token);
}
}
/// <summary>
/// A socket a test writes the server's half of.
/// </summary>
/// <remarks>
/// Frames queued with <see cref="Deliver"/> are handed out by <see cref="ReceiveAsync"/> in order;
/// once the queue is empty the receive waits, which is what an idle connection does. <see
/// cref="Close"/> queues the close instead, so a test can script "two notices and then the server
/// went away" as a value rather than as a race.
/// </remarks>
private sealed class FakeWebSocket : WebSocket
{
private readonly Channel<byte[]> inbound = Channel.CreateUnbounded<byte[]>();
private readonly Channel<VaultEvent> outbound = Channel.CreateUnbounded<VaultEvent>();
private WebSocketCloseStatus? closing;
private WebSocketState state = WebSocketState.Open;
public override WebSocketCloseStatus? CloseStatus => closing;
public override string? CloseStatusDescription => null;
public override WebSocketState State => state;
public override string? SubProtocol => VaultEvents.SubProtocol;
/// <summary>Queues a frame for the client to read.</summary>
internal void Deliver(VaultEvent frame) =>
inbound.Writer.TryWrite(
JsonSerializer.SerializeToUtf8Bytes(frame, DodoSshJsonContext.Default.VaultEvent));
/// <summary>Ends the socket, after everything already queued has been read.</summary>
internal void Close(WebSocketCloseStatus status)
{
closing = status;
inbound.Writer.TryWrite([]);
}
/// <summary>The next frame the client sent.</summary>
internal ValueTask<VaultEvent> SentAsync(CancellationToken cancellationToken) =>
outbound.Reader.ReadAsync(cancellationToken);
public override async Task<WebSocketReceiveResult> ReceiveAsync(
ArraySegment<byte> buffer,
CancellationToken cancellationToken)
{
var frame = await inbound.Reader.ReadAsync(cancellationToken);
// The empty frame Close queues. Reported as a close, exactly as a real socket does once the
// peer's close frame arrives.
if (frame.Length == 0)
{
state = WebSocketState.Closed;
return new WebSocketReceiveResult(
0, WebSocketMessageType.Close, endOfMessage: true, closing, null);
}
frame.CopyTo(buffer.Array!, buffer.Offset);
return new WebSocketReceiveResult(frame.Length, WebSocketMessageType.Text, endOfMessage: true);
}
public override Task SendAsync(
ArraySegment<byte> buffer,
WebSocketMessageType messageType,
bool endOfMessage,
CancellationToken cancellationToken)
{
var json = Encoding.UTF8.GetString(buffer.Array!, buffer.Offset, buffer.Count);
if (JsonSerializer.Deserialize(json, DodoSshJsonContext.Default.VaultEvent) is { } frame)
{
outbound.Writer.TryWrite(frame);
}
return Task.CompletedTask;
}
public override void Abort() => state = WebSocketState.Aborted;
public override Task CloseAsync(
WebSocketCloseStatus closeStatus,
string? statusDescription,
CancellationToken cancellationToken) => CloseOutputAsync(
closeStatus, statusDescription, cancellationToken);
public override Task CloseOutputAsync(
WebSocketCloseStatus closeStatus,
string? statusDescription,
CancellationToken cancellationToken)
{
state = WebSocketState.Closed;
return Task.CompletedTask;
}
public override void Dispose() => state = WebSocketState.Closed;
}
}
@@ -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