using System.Net.Http.Json; using System.Net.WebSockets; using System.Text.Json; using HSchool.Protocol; namespace HSchool.AppHost.Tests; [Collection(AppHostCollection.Name)] public class SessionApiTests(AppHostFixture fixture) { private static readonly TimeSpan DefaultTimeout = TimeSpan.FromSeconds(30); [Fact] public async Task Schools_WithoutSession_ReturnUnauthorized() { using var client = CreateAnonymousClient(); using var response = await client.GetAsync("/api/schools", TestContext.Current.CancellationToken); Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); } [Fact] public async Task Schools_AfterLogin_ReturnOk() { using var client = fixture.App.CreateHttpClient("server"); await SchoolApiTests.LoginAsync(client); using var response = await client.GetAsync("/api/schools", TestContext.Current.CancellationToken); Assert.Equal(HttpStatusCode.OK, response.StatusCode); } [Fact] public async Task Login_WithWrongPassword_ReturnsBadPassword() { using var client = fixture.App.CreateHttpClient("server"); using var response = await client.PostAsJsonAsync( "/api/session", new { password = "wrong", userName = "Player" }, TestContext.Current.CancellationToken); Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); Assert.Equal("bad-password", await SchoolApiTests.ProblemCodeAsync(response)); } [Fact] public async Task Login_WritesUsersJson_AndReusesCanonicalName() { using var client = fixture.App.CreateHttpClient("server"); await SchoolApiTests.ResetAsync(client); var unique = $"User-{Guid.NewGuid():N}"[..12]; await LoginAsAsync(client, unique); var directory = await SavesDirectoryAsync(client); var usersPath = Path.Combine(directory, "users.json"); Assert.True(File.Exists(usersPath)); var document = JsonSerializer.Deserialize( await File.ReadAllTextAsync(usersPath, TestContext.Current.CancellationToken), new JsonSerializerOptions { PropertyNameCaseInsensitive = true }); Assert.NotNull(document); Assert.Contains(document.Users, user => user.Name == unique); await LoginAsAsync(client, unique.ToUpperInvariant()); var session = await client.GetFromJsonAsync("/api/session", TestContext.Current.CancellationToken); Assert.NotNull(session); Assert.Equal(unique, session.UserName); var reread = JsonSerializer.Deserialize( await File.ReadAllTextAsync(usersPath, TestContext.Current.CancellationToken), new JsonSerializerOptions { PropertyNameCaseInsensitive = true }); Assert.NotNull(reread); Assert.Single(reread.Users, user => string.Equals(user.Name, unique, StringComparison.OrdinalIgnoreCase)); } [Fact] public async Task Login_WhileNameIsOnline_ReturnsNameOnline() { using var client = fixture.App.CreateHttpClient("server"); await SchoolApiTests.ResetAsync(client); var name = $"Online-{Guid.NewGuid():N}"[..14]; var cookie = await SchoolApiTests.LoginAndGetCookieAsync(client, name); var socket = new ClientWebSocket(); socket.Options.SetRequestHeader("Cookie", cookie); var http = fixture.App.GetEndpoint("server", "http"); var uri = new UriBuilder(http) { Scheme = "ws", Path = "/ws/game" }.Uri; await socket.ConnectAsync(uri, TestContext.Current.CancellationToken).WaitAsync(DefaultTimeout); try { await SendHelloAsync(socket); await ReceiveWelcomeAsync(socket); using var other = fixture.App.CreateHttpClient("server"); using var response = await other.PostAsJsonAsync( "/api/session", new { password = SchoolApiTests.TestPassword, userName = name }, TestContext.Current.CancellationToken); Assert.Equal(HttpStatusCode.Conflict, response.StatusCode); Assert.Equal("name-online", await SchoolApiTests.ProblemCodeAsync(response)); } finally { socket.Dispose(); } } [Fact] public async Task Login_SetsHttpOnlyLaxSessionCookie() { using var client = fixture.App.CreateHttpClient("server"); using var response = await client.PostAsJsonAsync( "/api/session", new { password = SchoolApiTests.TestPassword, userName = $"Cookie-{Guid.NewGuid():N}"[..14] }, TestContext.Current.CancellationToken); response.EnsureSuccessStatusCode(); Assert.True(response.Headers.TryGetValues("Set-Cookie", out var values)); var cookie = Assert.Single(values, value => value.StartsWith("hschool.session=", StringComparison.OrdinalIgnoreCase)); var parts = cookie.Split(';', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries); Assert.Contains(parts, part => part.Equals("httponly", StringComparison.OrdinalIgnoreCase)); Assert.Contains(parts, part => part.Equals("samesite=lax", StringComparison.OrdinalIgnoreCase)); Assert.Contains(parts, part => part.Equals("path=/", StringComparison.OrdinalIgnoreCase)); Assert.Contains(parts, part => part.Equals("max-age=1209600", StringComparison.OrdinalIgnoreCase)); } [Fact] public async Task WebSocket_WithoutSession_ClosesWithoutWelcome() { using var socket = await ConnectRawAsync(); await SendHelloAsync(socket); var frame = await ReceiveOneFrameAsync(socket, TimeSpan.FromSeconds(5)); Assert.Equal(WebSocketMessageType.Close, frame.MessageType); Assert.Equal(WebSocketCloseStatus.PolicyViolation, socket.CloseStatus); } [Fact] public async Task SecondWebSocket_ForAnOnlineName_ClosesWithoutWelcome() { using var client = fixture.App.CreateHttpClient("server"); await SchoolApiTests.ResetAsync(client); var name = $"DupWs-{Guid.NewGuid():N}"[..14]; var cookie = await SchoolApiTests.LoginAndGetCookieAsync(client, name); var first = new ClientWebSocket(); first.Options.SetRequestHeader("Cookie", cookie); var http = fixture.App.GetEndpoint("server", "http"); var uri = new UriBuilder(http) { Scheme = "ws", Path = "/ws/game" }.Uri; await first.ConnectAsync(uri, TestContext.Current.CancellationToken).WaitAsync(DefaultTimeout); try { await SendHelloAsync(first); await ReceiveWelcomeAsync(first); using var second = new ClientWebSocket(); second.Options.SetRequestHeader("Cookie", cookie); await second.ConnectAsync(uri, TestContext.Current.CancellationToken).WaitAsync(DefaultTimeout); await SendHelloAsync(second); var frame = await ReceiveOneFrameAsync(second, TimeSpan.FromSeconds(5)); Assert.Equal(WebSocketMessageType.Close, frame.MessageType); Assert.Equal(WebSocketCloseStatus.PolicyViolation, second.CloseStatus); } finally { first.Dispose(); } } private HttpClient CreateAnonymousClient() { var http = fixture.App.GetEndpoint("server", "http").ToString(); return new HttpClient { BaseAddress = new Uri(http) }; } private static async Task LoginAsAsync(HttpClient client, string userName) { _ = await SchoolApiTests.LoginAndGetCookieAsync(client, userName); } private async Task ConnectRawAsync() { var http = fixture.App.GetEndpoint("server", "http"); var uri = new UriBuilder(http) { Scheme = "ws", Path = "/ws/game" }.Uri; var socket = new ClientWebSocket(); await socket.ConnectAsync(uri, TestContext.Current.CancellationToken).WaitAsync(DefaultTimeout); return socket; } private static async Task SendHelloAsync(WebSocket socket) { var buffer = new byte[ProtocolCodec.MaxFrameSize]; var length = ProtocolCodec.WriteHello( buffer, new ClientHelloMessage(ProtocolConstants.Version, ProtocolConstants.LocaleRussian)); await socket.SendAsync( buffer.AsMemory(0, length), WebSocketMessageType.Binary, endOfMessage: true, TestContext.Current.CancellationToken); } private static async Task ReceiveWelcomeAsync(WebSocket socket) { var frame = await ReceiveBinaryFrameAsync(socket); Assert.Equal(MessageType.ServerWelcome, ProtocolCodec.PeekMessageType(frame)); } private static async Task ReceiveOneFrameAsync(WebSocket socket, TimeSpan timeout) { using var cts = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken); cts.CancelAfter(timeout); var buffer = new byte[ProtocolCodec.MaxFrameSize]; return await socket.ReceiveAsync(buffer, cts.Token); } private static async Task ReceiveBinaryFrameAsync(WebSocket socket) { var buffer = new byte[ProtocolCodec.MaxFrameSize]; var offset = 0; while (true) { var result = await socket.ReceiveAsync( buffer.AsMemory(offset, buffer.Length - offset), TestContext.Current.CancellationToken); if (result.MessageType == WebSocketMessageType.Close) { throw new InvalidOperationException($"Socket closed: {socket.CloseStatus}."); } offset += result.Count; if (result.EndOfMessage) { return buffer.AsSpan(0, offset).ToArray(); } } } private static async Task SavesDirectoryAsync(HttpClient client) { var payload = await client.GetFromJsonAsync( "/api/dev/saves-directory", TestContext.Current.CancellationToken); Assert.NotNull(payload); return payload.Path; } private sealed record SessionResponse(string UserName); private sealed record UsersDocument(IReadOnlyList Users); private sealed record UserRecord(string Name); private sealed record PathResponse(string Path); }