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;
///
/// The push channel, over a real socket through the real authentication pipeline.
///
///
///
/// 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 which
/// vault ids exist and when they change would be a disclosure the pull path takes deliberate
/// trouble to avoid — SyncPullEndpoint answers 404 rather than 403 for exactly that reason —
/// and it would be invisible in a test that only checked that notices arrive.
///
///
/// 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.
///
///
[Collection(ApiCollection.Name)]
public sealed class EventsEndpointTests(ApiFixture fixture)
{
private static readonly DateTimeOffset Now = new(2026, 8, 4, 12, 0, 0, TimeSpan.Zero);
///
/// How long a test will wait for a frame before calling it a failure.
///
///
/// 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.
///
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(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(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();
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();
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 ----
/// A token that gives up rather than letting a broken build hang the suite.
private static CancellationTokenSource Timeout()
{
var source = CancellationTokenSource.CreateLinkedTokenSource(
TestContext.Current.CancellationToken);
source.CancelAfter(FrameTimeout);
return source;
}
private static async Task 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 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);
}
/// Reads past the frames a test does not care about — hello, and heartbeats.
private static async Task 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;
}
}
}
///
/// The same, but keeping the bytes.
///
///
/// Deserialising and asserting on the fields would prove only that this record 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.
///
private static async Task 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 SeedEnrolledUserAsync()
{
var subject = NewSubject();
await using var scope = fixture.CreateScope();
var database = scope.ServiceProvider.GetRequiredService();
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();
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,
};
}