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