Files
Leonid PershinandCursor 1c3ae9ab7e Reject a second live WebSocket for the same session name.
Two tabs with the cookie already set never POST /api/session, so name-online on login did not cover the design rule. Claim the name under the same lock as the online check.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-20 11:04:56 +03:00

271 lines
10 KiB
C#

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<UsersDocument>(
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<SessionResponse>("/api/session", TestContext.Current.CancellationToken);
Assert.NotNull(session);
Assert.Equal(unique, session.UserName);
var reread = JsonSerializer.Deserialize<UsersDocument>(
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<ClientWebSocket> 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<WebSocketReceiveResult> 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<byte[]> 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<string> SavesDirectoryAsync(HttpClient client)
{
var payload = await client.GetFromJsonAsync<PathResponse>(
"/api/dev/saves-directory",
TestContext.Current.CancellationToken);
Assert.NotNull(payload);
return payload.Path;
}
private sealed record SessionResponse(string UserName);
private sealed record UsersDocument(IReadOnlyList<UserRecord> Users);
private sealed record UserRecord(string Name);
private sealed record PathResponse(string Path);
}