217 lines
7.7 KiB
C#
217 lines
7.7 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 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);
|
|
}
|