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>
This commit is contained in:
Leonid Pershin
2026-08-20 11:04:56 +03:00
co-authored by Cursor
parent d3396b5b8f
commit 1c3ae9ab7e
7 changed files with 219 additions and 6 deletions
@@ -111,6 +111,26 @@ public class SessionApiTests(AppHostFixture fixture)
}
}
[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()
{
@@ -123,6 +143,40 @@ public class SessionApiTests(AppHostFixture fixture)
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();