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;
}
}