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; /// /// The reconnection policy, which is what this class actually is. /// /// /// /// 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 /// after 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. /// /// /// 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. /// /// 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(); var socket = new FakeWebSocket(); await using var stream = Stream( (url, _, _) => { dialled.Add(url); return Task.FromResult(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(); var socket = new FakeWebSocket(); await using var stream = Stream( (_, token, _) => { presented.Add(token); return Task.FromResult(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(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(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(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(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(); 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(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(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(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( 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> connect) => new(Server, new StubTokens(), TimeProvider.System, connect, Impatient); /// A token provider that hands out one value and counts who asked. private sealed class StubTokens : IAccessTokenProvider { internal const string Token = "access-token"; internal int Requests { get; private set; } public ValueTask GetAccessTokenAsync(CancellationToken cancellationToken) { Requests++; return ValueTask.FromResult(Token); } } /// /// A socket a test writes the server's half of. /// /// /// Frames queued with are handed out by in order; /// once the queue is empty the receive waits, which is what an idle connection does. 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. /// private sealed class FakeWebSocket : WebSocket { private readonly Channel inbound = Channel.CreateUnbounded(); private readonly Channel outbound = Channel.CreateUnbounded(); 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; /// Queues a frame for the client to read. internal void Deliver(VaultEvent frame) => inbound.Writer.TryWrite( JsonSerializer.SerializeToUtf8Bytes(frame, DodoSshJsonContext.Default.VaultEvent)); /// Ends the socket, after everything already queued has been read. internal void Close(WebSocketCloseStatus status) { closing = status; inbound.Writer.TryWrite([]); } /// The next frame the client sent. internal ValueTask SentAsync(CancellationToken cancellationToken) => outbound.Reader.ReadAsync(cancellationToken); public override async Task ReceiveAsync( ArraySegment 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 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; } }