Merge branch 'phase/38-session'

# Conflicts:
#	docs/phases/README.md
#	tests/HSchool.AppHost.Tests/PortraitApiTests.cs
This commit is contained in:
Leonid Pershin
2026-08-20 07:25:12 +03:00
25 changed files with 1003 additions and 27 deletions
+40 -4
View File
@@ -431,7 +431,13 @@ public class GameSocketTests(AppHostFixture fixture)
[Fact]
public async Task OldProtocolVersion_IsRejected()
{
using var socket = await ConnectRawAsync();
using var httpClient = fixture.App.CreateHttpClient("server");
var cookie = await SchoolApiTests.LoginAndGetCookieAsync(httpClient);
using 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);
await SendAsync(socket, buffer => ProtocolCodec.WriteHello(
buffer,
@@ -447,7 +453,13 @@ public class GameSocketTests(AppHostFixture fixture)
[Fact]
public async Task VersionMismatch_IsRejected()
{
using var socket = await ConnectRawAsync();
using var httpClient = fixture.App.CreateHttpClient("server");
var cookie = await SchoolApiTests.LoginAndGetCookieAsync(httpClient);
using 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);
await SendAsync(socket, buffer => ProtocolCodec.WriteHello(
buffer,
@@ -474,7 +486,8 @@ public class GameSocketTests(AppHostFixture fixture)
private async Task<ClientWebSocket> OpenSchoolAsync(int schoolId, byte locale = ProtocolConstants.LocaleRussian)
{
var socket = await ConnectAsync(locale);
using var httpClient = fixture.App.CreateHttpClient("server");
var socket = await ConnectAsync(httpClient, locale);
await ReceiveUntilAsync(socket, MessageType.ServerWelcome);
await SendAsync(socket, buffer =>
ProtocolCodec.WriteOpenSchool(buffer, new ClientOpenSchoolMessage(schoolId)));
@@ -483,7 +496,30 @@ public class GameSocketTests(AppHostFixture fixture)
private async Task<ClientWebSocket> ConnectAsync(byte locale = ProtocolConstants.LocaleRussian)
{
var socket = await ConnectRawAsync();
using var httpClient = fixture.App.CreateHttpClient("server");
var userName = $"Ws-{Guid.NewGuid():N}"[..12];
var cookie = await SchoolApiTests.LoginAndGetCookieAsync(httpClient, userName);
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);
await SendAsync(socket, buffer =>
ProtocolCodec.WriteHello(buffer, new ClientHelloMessage(ProtocolConstants.Version, locale)));
return socket;
}
private async Task<ClientWebSocket> ConnectAsync(HttpClient client, byte locale = ProtocolConstants.LocaleRussian)
{
var userName = $"Ws-{Guid.NewGuid():N}"[..12];
var cookie = await SchoolApiTests.LoginAndGetCookieAsync(client, userName);
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);
await SendAsync(socket, buffer =>
ProtocolCodec.WriteHello(buffer, new ClientHelloMessage(ProtocolConstants.Version, locale)));
return socket;
@@ -94,6 +94,8 @@ public class PortraitApiTests(AppHostFixture fixture)
{
using var client = fixture.App.CreateHttpClient("server");
await SchoolApiTests.LoginAsync(client);
var settings = await client.GetFromJsonAsync<SwarmUiSettingsPayload>(
"/api/settings/swarmui",
TestContext.Current.CancellationToken);
@@ -110,6 +110,7 @@ public class SchoolApiTests(AppHostFixture fixture)
public async Task DeleteSchool_ThatDoesNotExist_IsNotFound()
{
using var client = fixture.App.CreateHttpClient("server");
await LoginAsync(client);
using var response = await client.DeleteAsync("/api/schools/999999", TestContext.Current.CancellationToken);
@@ -486,6 +487,7 @@ public class SchoolApiTests(AppHostFixture fixture)
public async Task Status_ReportsTheLoopAndTheLimit()
{
using var client = fixture.App.CreateHttpClient("server");
await LoginAsync(client);
var status = await client.GetFromJsonAsync<StatusResponse>("/api/status", TestContext.Current.CancellationToken);
@@ -510,8 +512,72 @@ public class SchoolApiTests(AppHostFixture fixture)
Assert.True(restored.Running);
}
internal const string TestPassword = "test-alpha";
internal const string TestUserName = "TestPlayer";
internal static async Task LoginAsync(HttpClient client, string userName = TestUserName)
{
using var current = await client.GetAsync("/api/session", TestContext.Current.CancellationToken);
if (current.IsSuccessStatusCode)
{
return;
}
_ = await LoginAndGetCookieAsync(client, userName);
}
internal static async Task<string> LoginAndGetCookieAsync(HttpClient client, string userName = TestUserName)
{
using var first = await client.PostAsJsonAsync(
"/api/session",
new { password = TestPassword, userName },
TestContext.Current.CancellationToken);
if (first.IsSuccessStatusCode)
{
return ExtractSessionCookie(first);
}
if (first.StatusCode == HttpStatusCode.Conflict)
{
userName = $"T-{Guid.NewGuid():N}"[..10];
using var retry = await client.PostAsJsonAsync(
"/api/session",
new { password = TestPassword, userName },
TestContext.Current.CancellationToken);
retry.EnsureSuccessStatusCode();
return ExtractSessionCookie(retry);
}
first.EnsureSuccessStatusCode();
return ExtractSessionCookie(first);
}
internal static string ExtractSessionCookie(HttpResponseMessage response)
{
if (!response.Headers.TryGetValues("Set-Cookie", out var values))
{
throw new InvalidOperationException("Login did not return a session cookie.");
}
foreach (var value in values)
{
if (!value.StartsWith("hschool.session=", StringComparison.OrdinalIgnoreCase))
{
continue;
}
var end = value.IndexOf(';');
return end >= 0 ? value[..end] : value;
}
throw new InvalidOperationException("Login did not return a session cookie.");
}
internal static async Task ResetAsync(HttpClient client)
{
await LoginAsync(client);
var state = await GetSchoolsAsync(client);
foreach (var school in state.Schools)
@@ -117,6 +117,7 @@ public class ServiceabilityTests(AppHostFixture fixture)
public async Task Dump_UnknownSchool_IsNotFound()
{
using var client = fixture.App.CreateHttpClient("server");
await SchoolApiTests.LoginAsync(client);
using var response = await client.GetAsync("/api/dev/schools/999999/dump", TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
@@ -0,0 +1,216 @@
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 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);
}
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);
}