Merge branch 'phase/38-session'
# Conflicts: # docs/phases/README.md # tests/HSchool.AppHost.Tests/PortraitApiTests.cs
This commit is contained in:
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user