Add alpha session gate with cookie auth and login UI.

This commit is contained in:
Leonid Pershin
2026-08-20 07:17:18 +03:00
parent 2a55a025f4
commit a444fc011e
24 changed files with 1005 additions and 27 deletions
+13 -13
View File
@@ -13,28 +13,28 @@
## Задачи ## Задачи
- [ ] `HSchool:AlphaPassword` в конфиге; пустая строка — процесс не стартует. В `appsettings.json` - [x] `HSchool:AlphaPassword` в конфиге; пустая строка — процесс не стартует. В `appsettings.json`
заглушка для локальной игры; headless AppHost задаёт тестовый пароль окружением заглушка для локальной игры; headless AppHost задаёт тестовый пароль окружением
- [ ] `POST /api/session` `{ password, userName }`: неверный пароль — `401` `bad-password`; - [x] `POST /api/session` `{ password, userName }`: неверный пароль — `401` `bad-password`;
кривое имя — `400` `invalid-name` (те же правила, что у школы); имя с живым сокетом — кривое имя — `400` `invalid-name` (те же правила, что у школы); имя с живым сокетом —
`409` `name-online`. Успех ставит HttpOnly-куку и при первом разе пишет имя в `409` `name-online`. Успех ставит HttpOnly-куку и при первом разе пишет имя в
`saves/users.json` (занятость без учёта регистра) `saves/users.json` (занятость без учёта регистра)
- [ ] `GET /api/session` — текущее имя или `401`. `DELETE /api/session` снимает куку - [x] `GET /api/session` — текущее имя или `401`. `DELETE /api/session` снимает куку
- [ ] Все игровые HTTP без куки — `401`. Живут без неё: `/health` и три ручки сессии - [x] Все игровые HTTP без куки — `401`. Живут без неё: `/health` и три ручки сессии
- [ ] WebSocket без куки не шлёт Welcome, закрывается. Hello по-прежнему версия + локаль - [x] WebSocket без куки не шлёт Welcome, закрывается. Hello по-прежнему версия + локаль
- [ ] Клиент: шаг пароля, шаг имени; при живой куке оба пропускаются. Сокет — после сессии. - [x] Клиент: шаг пароля, шаг имени; при живой куке оба пропускаются. Сокет — после сессии.
Кнопка выхода на меню Кнопка выхода на меню
- [ ] Хостовые тесты логинятся в общем месте (`ResetAsync` или рядом), не в каждом факте - [x] Хостовые тесты логинятся в общем месте (`ResetAsync` или рядом), не в каждом факте
- [ ] `docs/protocol.md` — HTTP сессии в том же коммите. Раскладку сокета и версию не трогать - [x] `docs/protocol.md` — HTTP сессии в том же коммите. Раскладку сокета и версию не трогать
## Тесты, без которых фаза не закрыта ## Тесты, без которых фаза не закрыта
- [ ] Без сессии `GET /api/schools``401`; после `POST /api/session``200` - [x] Без сессии `GET /api/schools``401`; после `POST /api/session``200`
- [ ] Верный пароль и новое имя пишут `users.json`; повтор с тем же именем (другой регистр) — - [x] Верный пароль и новое имя пишут `users.json`; повтор с тем же именем (другой регистр) —
тот же человек, не вторая запись тот же человек, не вторая запись
- [ ] Пока сокет имени жив, второй `POST /api/session` с ним — `409` `name-online` - [x] Пока сокет имени жив, второй `POST /api/session` с ним — `409` `name-online`
- [ ] Сокет без куки закрывается, Welcome не приходит - [x] Сокет без куки закрывается, Welcome не приходит
- [ ] Клиентский тест: без куки виден ввод пароля, не сетка школ - [x] Клиентский тест: без куки виден ввод пароля, не сетка школ
## Критерий готовности ## Критерий готовности
+39
View File
@@ -25,9 +25,48 @@ closes connections whose hello carries a different version with `1002 ProtocolEr
## HTTP API ## HTTP API
Most routes require a signed session cookie set by `POST /api/session`. Without it the server
returns `401`. Public exceptions: `GET /health` and the three `/api/session` routes.
Game dates are ISO-8601 UTC instants. The in-game calendar has no time zone — UTC is only used so Game dates are ISO-8601 UTC instants. The in-game calendar has no time zone — UTC is only used so
the wire format is unambiguous, and the client formats it back in UTC. the wire format is unambiguous, and the client formats it back in UTC.
### `POST /api/session`
Alpha login. Body:
```json
{ "password": "alpha", "userName": "Leo" }
```
Success (`200`) sets an HttpOnly cookie (`SameSite=Lax`, `Path=/`) and returns:
```json
{ "userName": "Leo" }
```
The name is normalized like a school name (trim, no control characters, 140 chars). Occupancy is
case-insensitive: `Leo` and `leo` are the same person; the first spelling is kept in
`saves/users.json`.
| Status | `code` | When |
| --- | --- | --- |
| `401` | `bad-password` | Wrong alpha password |
| `400` | `invalid-name` | Name fails normalization |
| `409` | `name-online` | A live WebSocket already uses that name |
### `GET /api/session`
Returns `{ "userName": "Leo" }` when the cookie is valid, otherwise `401`.
### `DELETE /api/session`
Clears the session cookie. `204`.
The WebSocket at `/ws/game` uses the same cookie on upgrade. Without a valid cookie the server
closes the connection with a policy violation and never sends Welcome. Hello is unchanged
(version + locale only).
### `GET /api/schools` ### `GET /api/schools`
Everything the main menu needs in one request. `schoolWeekDays` is 57 working days counted Everything the main menu needs in one request. `schoolWeekDays` is 57 working days counted
+1
View File
@@ -16,6 +16,7 @@ if (headless)
server server
.WithEnvironment("Simulation__SavesDirectory", saves) .WithEnvironment("Simulation__SavesDirectory", saves)
.WithEnvironment("HSchool__AllowSaveReload", "true") .WithEnvironment("HSchool__AllowSaveReload", "true")
.WithEnvironment("HSchool__AlphaPassword", "test-alpha")
.WithEnvironment("SwarmUi__BaseUrl", ""); .WithEnvironment("SwarmUi__BaseUrl", "");
} }
+26
View File
@@ -9,6 +9,19 @@ const ru = {
pingPlaceholder: '-- мс', pingPlaceholder: '-- мс',
language: 'Язык', language: 'Язык',
sessionTitle: 'Вход',
sessionPassword: 'Пароль альфы',
sessionPasswordPlaceholder: 'Пароль',
sessionUserName: 'Ваше имя',
sessionUserNamePlaceholder: 'Имя в сети',
sessionContinue: 'Продолжить',
sessionEnter: 'Войти',
sessionBadPassword: 'Неверный пароль.',
sessionNameOnline: 'Это имя уже в сети.',
sessionInvalidName: 'Имя должно быть от 1 до 40 символов.',
sessionFailed: 'Не удалось войти. Попробуйте ещё раз.',
sessionLogout: 'Выйти',
schoolsTitle: 'Школы', schoolsTitle: 'Школы',
createSchool: 'Создать школу', createSchool: 'Создать школу',
settings: 'Настройки', settings: 'Настройки',
@@ -338,6 +351,19 @@ const en: Messages = {
pingPlaceholder: '-- ms', pingPlaceholder: '-- ms',
language: 'Language', language: 'Language',
sessionTitle: 'Sign in',
sessionPassword: 'Alpha password',
sessionPasswordPlaceholder: 'Password',
sessionUserName: 'Your name',
sessionUserNamePlaceholder: 'Display name',
sessionContinue: 'Continue',
sessionEnter: 'Enter',
sessionBadPassword: 'Wrong password.',
sessionNameOnline: 'That name is already online.',
sessionInvalidName: 'The name must be 140 characters.',
sessionFailed: 'Could not sign in. Try again.',
sessionLogout: 'Sign out',
schoolsTitle: 'Schools', schoolsTitle: 'Schools',
createSchool: 'Create school', createSchool: 'Create school',
settings: 'Settings', settings: 'Settings',
+19 -3
View File
@@ -1,9 +1,11 @@
import { GameConnection, gameSocketUrl, type ConnectionStatus } from './net/connection.ts'; import { GameConnection, gameSocketUrl, type ConnectionStatus } from './net/connection.ts';
import { logoutSession } from './net/api.ts';
import { getLocale, onLocaleChange } from './i18n/locale.ts'; import { getLocale, onLocaleChange } from './i18n/locale.ts';
import { t, type MessageKey } from './i18n/strings.ts'; import { t, type MessageKey } from './i18n/strings.ts';
import { GameScreen } from './ui/gameScreen.ts'; import { GameScreen } from './ui/gameScreen.ts';
import { localeSwitch } from './ui/localeSwitch.ts'; import { localeSwitch } from './ui/localeSwitch.ts';
import { MainMenu } from './ui/mainMenu.ts'; import { MainMenu } from './ui/mainMenu.ts';
import { ensureSession } from './ui/sessionGate.ts';
import type { School } from './net/api.ts'; import type { School } from './net/api.ts';
import './style.css'; import './style.css';
@@ -15,7 +17,7 @@ const STATUS_KEYS: Record<ConnectionStatus, MessageKey> = {
}; };
/** Wires the two screens to one WebSocket connection. */ /** Wires the two screens to one WebSocket connection. */
function bootstrap(): void { async function bootstrap(): Promise<void> {
const app = requireElement('#app'); const app = requireElement('#app');
const footer = requireElement('#status'); const footer = requireElement('#status');
const statusLabel = document.querySelector<HTMLElement>('[data-status="connection"]'); const statusLabel = document.querySelector<HTMLElement>('[data-status="connection"]');
@@ -23,11 +25,16 @@ function bootstrap(): void {
footer.prepend(localeSwitch()); footer.prepend(localeSwitch());
await ensureSession();
let openSchool: School | null = null; let openSchool: School | null = null;
let connectionStatus: ConnectionStatus = 'connecting'; let connectionStatus: ConnectionStatus = 'connecting';
let lastPingMs: number | null = null; let lastPingMs: number | null = null;
const menu = new MainMenu({ onOpenSchool: (school) => enterSchool(school) }); const menu = new MainMenu({
onOpenSchool: (school) => enterSchool(school),
onLogout: () => void handleLogout(),
});
const game = new GameScreen({ const game = new GameScreen({
onLeave: () => leaveSchool(), onLeave: () => leaveSchool(),
onSetRunning: (running) => connection.setRunning(running), onSetRunning: (running) => connection.setRunning(running),
@@ -97,6 +104,15 @@ function bootstrap(): void {
menu.start(); menu.start();
} }
async function handleLogout(): Promise<void> {
connection.close();
menu.stop();
await logoutSession();
await ensureSession();
connection.connect();
showMenu();
}
onLocaleChange(() => { onLocaleChange(() => {
paintChrome(); paintChrome();
menu.localize(); menu.localize();
@@ -120,4 +136,4 @@ function requireElement(selector: string): HTMLElement {
return element; return element;
} }
bootstrap(); void bootstrap();
+21 -1
View File
@@ -402,6 +402,26 @@ export async function fetchGameStatus(): Promise<GameStatus> {
return request<GameStatus>('/api/status'); return request<GameStatus>('/api/status');
} }
export interface SessionInfo {
readonly userName: string;
}
export async function fetchSession(): Promise<SessionInfo> {
return request<SessionInfo>('/api/session');
}
export async function loginSession(password: string, userName: string): Promise<SessionInfo> {
return request<SessionInfo>('/api/session', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ password, userName }),
});
}
export async function logoutSession(): Promise<void> {
await request<void>('/api/session', { method: 'DELETE' }, { expectBody: false });
}
export interface SwarmUiKindPreset { export interface SwarmUiKindPreset {
width: number; width: number;
height: number; height: number;
@@ -764,7 +784,7 @@ async function request<T>(
init?: RequestInit, init?: RequestInit,
options: { expectBody?: boolean } = {}, options: { expectBody?: boolean } = {},
): Promise<T> { ): Promise<T> {
const response = await fetch(url, init); const response = await fetch(url, { credentials: 'include', ...init });
if (!response.ok) { if (!response.ok) {
throw await toApiError(response); throw await toApiError(response);
+8 -1
View File
@@ -16,6 +16,7 @@ import { SchoolCard } from './schoolCard.ts';
interface MainMenuOptions { interface MainMenuOptions {
readonly onOpenSchool: (school: School) => void; readonly onOpenSchool: (school: School) => void;
readonly onLogout: () => void;
} }
/** /**
@@ -40,6 +41,10 @@ export class MainMenu {
class: 'button', class: 'button',
type: 'button', type: 'button',
}); });
private readonly logoutButton = el('button', {
class: 'button',
type: 'button',
});
private readonly limitHint = el('p', { class: 'hint' }); private readonly limitHint = el('p', { class: 'hint' });
private readonly status = el('p', { class: 'hint hint--error' }); private readonly status = el('p', { class: 'hint hint--error' });
@@ -53,6 +58,7 @@ export class MainMenu {
constructor(private readonly options: MainMenuOptions) { constructor(private readonly options: MainMenuOptions) {
this.createButton.addEventListener('click', () => void this.openCreateDialog()); this.createButton.addEventListener('click', () => void this.openCreateDialog());
this.settingsButton.addEventListener('click', () => void swarmUiSettingsDialog()); this.settingsButton.addEventListener('click', () => void swarmUiSettingsDialog());
this.logoutButton.addEventListener('click', () => this.options.onLogout());
this.status.hidden = true; this.status.hidden = true;
this.root.append( this.root.append(
@@ -60,7 +66,7 @@ export class MainMenu {
'header', 'header',
{ class: 'screen__header' }, { class: 'screen__header' },
this.title, this.title,
el('div', { class: 'screen__actions' }, this.settingsButton, this.createButton), el('div', { class: 'screen__actions' }, this.logoutButton, this.settingsButton, this.createButton),
), ),
this.limitHint, this.limitHint,
this.status, this.status,
@@ -80,6 +86,7 @@ export class MainMenu {
this.title.textContent = t('schoolsTitle'); this.title.textContent = t('schoolsTitle');
this.createButton.textContent = t('createSchool'); this.createButton.textContent = t('createSchool');
this.settingsButton.textContent = t('settings'); this.settingsButton.textContent = t('settings');
this.logoutButton.textContent = t('sessionLogout');
this.emptyHint.textContent = t('emptySchools'); this.emptyHint.textContent = t('emptySchools');
for (const card of this.cards.values()) { for (const card of this.cards.values()) {
@@ -0,0 +1,40 @@
/**
* @vitest-environment happy-dom
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { ApiError, fetchSchools, fetchSession } from '../net/api.ts';
import { t } from '../i18n/strings.ts';
import { ensureSession } from './sessionGate.ts';
vi.mock('../net/api.ts', async (importOriginal) => {
const actual = await importOriginal<typeof import('../net/api.ts')>();
return {
...actual,
fetchSession: vi.fn(),
fetchSchools: vi.fn(),
loginSession: vi.fn(),
};
});
describe('session gate', () => {
beforeEach(() => {
document.body.innerHTML = '<main id="app"></main>';
});
afterEach(() => {
vi.restoreAllMocks();
});
it('shows the password step when there is no session cookie', async () => {
vi.mocked(fetchSession).mockRejectedValue(new ApiError(401, 'unknown', 'Unauthorized'));
const pending = ensureSession();
await Promise.resolve();
const app = document.querySelector('#app');
expect(app?.textContent).toContain(t('sessionPassword'));
expect(app?.textContent).not.toContain(t('schoolsTitle'));
pending.catch(() => undefined);
});
});
+120
View File
@@ -0,0 +1,120 @@
import { ApiError, fetchSession, loginSession } from '../net/api.ts';
import { t } from '../i18n/strings.ts';
import { el } from './dom.ts';
/**
* Blocks until the player has a live session cookie. Returns the signed-in name.
*/
export async function ensureSession(): Promise<string> {
try {
const session = await fetchSession();
return session.userName;
} catch (error) {
if (!(error instanceof ApiError) || error.status !== 401) {
throw error;
}
}
return showSessionGate();
}
function showSessionGate(): Promise<string> {
const app = document.querySelector<HTMLElement>('#app');
if (app === null) {
throw new Error('#app is missing from index.html.');
}
return new Promise((resolve) => {
let password = '';
const root = el('section', { class: 'screen session-gate' });
const title = el('h1', { class: 'screen__title' });
const passwordLabel = el('label', { class: 'field' });
const passwordInput = el('input', {
class: 'field__input',
type: 'password',
autocomplete: 'current-password',
}) as HTMLInputElement;
const nameLabel = el('label', { class: 'field' });
const nameInput = el('input', {
class: 'field__input',
type: 'text',
autocomplete: 'username',
maxlength: '40',
}) as HTMLInputElement;
const submit = el('button', { class: 'button button--primary', type: 'submit' });
const error = el('p', { class: 'hint hint--error' });
error.hidden = true;
passwordLabel.append(el('span', { class: 'field__label' }), passwordInput);
nameLabel.append(el('span', { class: 'field__label' }), nameInput);
nameLabel.hidden = true;
const form = el(
'form',
{ class: 'session-gate__form' },
passwordLabel,
nameLabel,
error,
submit,
);
form.addEventListener('submit', (event) => {
event.preventDefault();
void submitStep();
});
root.append(title, form);
app.replaceChildren(root);
paint();
async function submitStep(): Promise<void> {
error.hidden = true;
submit.toggleAttribute('disabled', true);
try {
if (nameLabel.hidden) {
password = passwordInput.value;
nameLabel.hidden = false;
submit.textContent = t('sessionEnter');
nameInput.focus();
return;
}
const session = await loginSession(password, nameInput.value.trim());
resolve(session.userName);
} catch (caught) {
if (caught instanceof ApiError) {
if (caught.code === 'bad-password') {
error.textContent = t('sessionBadPassword');
passwordInput.focus();
} else if (caught.code === 'invalid-name') {
error.textContent = t('sessionInvalidName');
nameInput.focus();
} else if (caught.code === 'name-online') {
error.textContent = t('sessionNameOnline');
nameInput.focus();
} else {
error.textContent = t('sessionFailed');
}
} else {
error.textContent = t('sessionFailed');
}
error.hidden = false;
} finally {
submit.toggleAttribute('disabled', false);
}
}
function paint(): void {
title.textContent = t('sessionTitle');
passwordLabel.querySelector('.field__label')!.textContent = t('sessionPassword');
passwordInput.placeholder = t('sessionPasswordPlaceholder');
nameLabel.querySelector('.field__label')!.textContent = t('sessionUserName');
nameInput.placeholder = t('sessionUserNamePlaceholder');
submit.textContent = nameLabel.hidden ? t('sessionContinue') : t('sessionEnter');
}
passwordInput.focus();
});
}
@@ -0,0 +1,78 @@
using HSchool.Server.Session;
namespace HSchool.Server.Api;
internal static class SessionEndpoints
{
public static void MapSessionEndpoints(this IEndpointRouteBuilder builder)
{
var group = builder.MapGroup("/api/session");
group.MapPost("/", LoginAsync);
group.MapGet("/", GetAsync);
group.MapDelete("/", LogoutAsync);
}
private static IResult LoginAsync(
LoginRequest request,
HttpContext context,
SessionService sessions)
{
if (!sessions.VerifyPassword(request.Password))
{
return Problem(StatusCodes.Status401Unauthorized, "bad-password", "The alpha password is wrong.");
}
if (!sessions.TryNormalizeUserName(request.UserName, out var normalized))
{
return Problem(
StatusCodes.Status400BadRequest,
"invalid-name",
"The name must be 140 characters after trimming, with no control characters.");
}
if (sessions.IsNameOnline(normalized))
{
return Problem(
StatusCodes.Status409Conflict,
"name-online",
"Someone with that name is already connected.");
}
var canonical = sessions.RegisterUser(normalized, normalized);
var token = sessions.CreateSessionToken(canonical);
context.Response.Cookies.Append(SessionService.CookieName, token, sessions.BuildCookieOptions(context));
return Results.Json(new SessionResponse(canonical));
}
private static IResult GetAsync(HttpContext context, SessionService sessions)
{
if (!sessions.TryGetUserName(context, out var userName))
{
return Results.Unauthorized();
}
return Results.Json(new SessionResponse(userName));
}
private static IResult LogoutAsync(HttpContext context, SessionService sessions)
{
context.Response.Cookies.Delete(SessionService.CookieName, new CookieOptions
{
HttpOnly = true,
SameSite = SameSiteMode.Lax,
Path = "/",
});
return Results.NoContent();
}
private static IResult Problem(int statusCode, string code, string detail)
{
var extensions = new Dictionary<string, object?> { ["code"] = code };
return Results.Problem(detail: detail, statusCode: statusCode, title: code, extensions: extensions);
}
private sealed record LoginRequest(string Password, string UserName);
private sealed record SessionResponse(string UserName);
}
+107
View File
@@ -0,0 +1,107 @@
using System.Text.Json;
namespace HSchool.Server.Game;
/// <summary>One registered player name, stored exactly as typed on first login.</summary>
internal sealed record UserRecord(string Name);
/// <summary>Persistent user list beside school saves.</summary>
internal sealed class UserStore
{
private const string UsersFileName = "users.json";
private static readonly JsonSerializerOptions Json = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
PropertyNameCaseInsensitive = true,
WriteIndented = true,
};
private readonly object _gate = new();
private readonly ILogger<UserStore> _logger;
private readonly string _path;
private List<UserRecord> _users = [];
public UserStore(SchoolStore schools, ILogger<UserStore> logger)
{
_logger = logger;
_path = Path.Combine(schools.DirectoryPath, UsersFileName);
Load();
}
/// <summary>
/// Returns the canonical spelling for <paramref name="normalized"/> or registers
/// <paramref name="displayName"/> on first use.
/// </summary>
public string ResolveOrRegister(string normalized, string displayName)
{
lock (_gate)
{
var existing = FindCanonicalLocked(normalized);
if (existing is not null)
{
return existing;
}
_users.Add(new UserRecord(displayName));
SaveLocked();
return displayName;
}
}
public bool TryFindCanonical(string normalized, out string canonical)
{
lock (_gate)
{
canonical = FindCanonicalLocked(normalized) ?? "";
return canonical.Length > 0;
}
}
private string? FindCanonicalLocked(string normalized)
{
foreach (var user in _users)
{
if (string.Equals(user.Name, normalized, StringComparison.OrdinalIgnoreCase))
{
return user.Name;
}
}
return null;
}
private void Load()
{
if (!File.Exists(_path))
{
return;
}
try
{
var document = JsonSerializer.Deserialize<UserDocument>(File.ReadAllText(_path), Json);
_users = document?.Users?.ToList() ?? [];
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Could not read {Path}; starting with an empty user list.", _path);
_users = [];
}
}
private void SaveLocked()
{
WriteAtomic(_path, new UserDocument(_users));
}
private static void WriteAtomic(string path, UserDocument document)
{
var json = JsonSerializer.Serialize(document, Json);
var temp = path + ".tmp";
File.WriteAllText(temp, json);
File.Move(temp, path, overwrite: true);
}
private sealed record UserDocument(IReadOnlyList<UserRecord> Users);
}
+16
View File
@@ -0,0 +1,16 @@
namespace HSchool.Server;
/// <summary>Server-wide options that are not part of the simulation.</summary>
internal sealed class HSchoolOptions
{
public const string SectionName = "HSchool";
/// <summary>Shared alpha gate password. Empty means the process must not start.</summary>
public string AlphaPassword { get; set; } = "";
/// <summary>How long a session cookie lives without re-login.</summary>
public int SessionCookieDays { get; set; } = 14;
/// <summary>When true, dev reload/dump endpoints are mapped. Off in production by default.</summary>
public bool AllowSaveReload { get; set; }
}
+18
View File
@@ -25,4 +25,22 @@ internal sealed class ClientRegistry
public GameClient? Find(uint playerId) => _clients.GetValueOrDefault(playerId); public GameClient? Find(uint playerId) => _clients.GetValueOrDefault(playerId);
public void Remove(uint playerId) => _clients.TryRemove(playerId, out _); public void Remove(uint playerId) => _clients.TryRemove(playerId, out _);
public bool IsUserNameOnline(string normalizedUserName)
{
foreach (var client in _clients.Values)
{
if (client.UserName is null)
{
continue;
}
if (string.Equals(client.NormalizedUserName, normalizedUserName, StringComparison.OrdinalIgnoreCase))
{
return true;
}
}
return false;
}
} }
+14
View File
@@ -30,11 +30,25 @@ internal sealed class GameClient(uint playerId, WebSocket socket)
private bool _ready; private bool _ready;
private int _openSchoolId; private int _openSchoolId;
private int _locale; private int _locale;
private string? _userName;
private string? _normalizedUserName;
public uint PlayerId { get; } = playerId; public uint PlayerId { get; } = playerId;
public WebSocket Socket { get; } = socket; public WebSocket Socket { get; } = socket;
/// <summary>Display name from the session cookie, set before the welcome frame goes out.</summary>
public string? UserName => Volatile.Read(ref _userName);
/// <summary>Case-insensitive key used for the online-name check.</summary>
public string? NormalizedUserName => Volatile.Read(ref _normalizedUserName);
public void SetUserName(string userName)
{
Volatile.Write(ref _userName, userName);
Volatile.Write(ref _normalizedUserName, userName);
}
/// <summary> /// <summary>
/// Set once the welcome frame is out. Clock frames are only queued for ready clients, so a /// Set once the welcome frame is out. Clock frames are only queued for ready clients, so a
/// connection never sees game state before the handshake finished. /// connection never sees game state before the handshake finished.
+2 -1
View File
@@ -19,9 +19,10 @@ internal sealed class GameSocketHandler(
{ {
private static readonly TimeSpan HandshakeTimeout = TimeSpan.FromSeconds(5); private static readonly TimeSpan HandshakeTimeout = TimeSpan.FromSeconds(5);
public async Task HandleAsync(WebSocket socket, CancellationToken cancellationToken) public async Task HandleAsync(WebSocket socket, string userName, CancellationToken cancellationToken)
{ {
var client = clients.Add(socket); var client = clients.Add(socket);
client.SetUserName(userName);
var buffer = ArrayPool<byte>.Shared.Rent(ProtocolConstants.MaxMessageSize); var buffer = ArrayPool<byte>.Shared.Rent(ProtocolConstants.MaxMessageSize);
using var connectionCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); using var connectionCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
+38 -3
View File
@@ -3,6 +3,7 @@ using HSchool.Server;
using HSchool.Server.Api; using HSchool.Server.Api;
using HSchool.Server.Game; using HSchool.Server.Game;
using HSchool.Server.Net; using HSchool.Server.Net;
using HSchool.Server.Session;
using HSchool.Simulation; using HSchool.Simulation;
using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
@@ -12,6 +13,14 @@ var builder = WebApplication.CreateBuilder(args);
builder.AddServiceDefaults(); builder.AddServiceDefaults();
builder.Services.AddProblemDetails(); builder.Services.AddProblemDetails();
builder.Services.AddOpenApi(); builder.Services.AddOpenApi();
builder.Services.AddDataProtection();
builder.Services
.AddOptions<HSchoolOptions>()
.Bind(builder.Configuration.GetSection(HSchoolOptions.SectionName))
.Validate(options => !string.IsNullOrWhiteSpace(options.AlphaPassword), "HSchool:AlphaPassword must be set.")
.Validate(options => options.SessionCookieDays is > 0 and <= 365, "HSchool:SessionCookieDays must be between 1 and 365.")
.ValidateOnStart();
builder.Services builder.Services
.AddOptions<SimulationOptions>() .AddOptions<SimulationOptions>()
@@ -30,6 +39,8 @@ builder.Services
builder.Services.AddSingleton<GameCommandQueue>(); builder.Services.AddSingleton<GameCommandQueue>();
builder.Services.AddSingleton<ClientRegistry>(); builder.Services.AddSingleton<ClientRegistry>();
builder.Services.AddSingleton<UserStore>();
builder.Services.AddSingleton<SessionService>();
builder.Services.AddSingleton<GameMetrics>(); builder.Services.AddSingleton<GameMetrics>();
builder.Services.AddSingleton<SchoolStore>(); builder.Services.AddSingleton<SchoolStore>();
builder.Services.AddSingleton<ModContent>(); builder.Services.AddSingleton<ModContent>();
@@ -64,6 +75,9 @@ app.UseWebSockets(new WebSocketOptions
KeepAliveInterval = TimeSpan.FromSeconds(30), KeepAliveInterval = TimeSpan.FromSeconds(30),
}); });
app.UseMiddleware<SessionAuthMiddleware>();
app.MapSessionEndpoints();
app.MapSchoolEndpoints(); app.MapSchoolEndpoints();
app.MapSettingsEndpoints(); app.MapSettingsEndpoints();
app.MapTimetableEndpoints(); app.MapTimetableEndpoints();
@@ -104,7 +118,7 @@ if (app.Configuration.GetValue("HSchool:AllowSaveReload", false))
} }
// The realtime channel: one binary frame per protocol message, see docs/protocol.md. // The realtime channel: one binary frame per protocol message, see docs/protocol.md.
app.Map("/ws/game", async (HttpContext context, GameSocketHandler handler) => app.Map("/ws/game", async (HttpContext context, GameSocketHandler handler, SessionService sessions) =>
{ {
if (!context.WebSockets.IsWebSocketRequest) if (!context.WebSockets.IsWebSocketRequest)
{ {
@@ -113,8 +127,29 @@ app.Map("/ws/game", async (HttpContext context, GameSocketHandler handler) =>
return; return;
} }
using WebSocket socket = await context.WebSockets.AcceptWebSocketAsync(); if (!sessions.TryGetUserName(context, out var userName))
await handler.HandleAsync(socket, context.RequestAborted); {
using WebSocket socket = await context.WebSockets.AcceptWebSocketAsync();
if (socket.State is WebSocketState.Open or WebSocketState.CloseReceived)
{
try
{
await socket.CloseAsync(
WebSocketCloseStatus.PolicyViolation,
"Session required.",
context.RequestAborted);
}
catch (WebSocketException)
{
// The peer may already be gone.
}
}
return;
}
using WebSocket connected = await context.WebSockets.AcceptWebSocketAsync();
await handler.HandleAsync(connected, userName, context.RequestAborted);
}); });
app.MapDefaultEndpoints(); app.MapDefaultEndpoints();
@@ -0,0 +1,38 @@
namespace HSchool.Server.Session;
/// <summary>
/// Every game HTTP route needs a session cookie. Health and the three session routes are the
/// only public exceptions.
/// </summary>
internal sealed class SessionAuthMiddleware(RequestDelegate next)
{
public async Task InvokeAsync(HttpContext context, SessionService sessions)
{
var path = context.Request.Path;
if (!path.StartsWithSegments("/api", StringComparison.OrdinalIgnoreCase)
|| IsPublicApi(path))
{
await next(context);
return;
}
if (!sessions.TryGetUserName(context, out _))
{
context.Response.StatusCode = StatusCodes.Status401Unauthorized;
return;
}
await next(context);
}
private static bool IsPublicApi(PathString path)
{
if (path.Equals("/api/session", StringComparison.OrdinalIgnoreCase))
{
return true;
}
return false;
}
}
@@ -0,0 +1,75 @@
using HSchool.Server.Game;
using HSchool.Server.Net;
using HSchool.Simulation;
using Microsoft.AspNetCore.DataProtection;
using Microsoft.Extensions.Options;
namespace HSchool.Server.Session;
/// <summary>Alpha login, signed session cookies, and online-name checks.</summary>
internal sealed class SessionService(
IDataProtectionProvider dataProtection,
UserStore users,
ClientRegistry clients,
IOptions<HSchoolOptions> options)
{
public const string CookieName = "hschool.session";
private readonly IDataProtector _protector = dataProtection.CreateProtector("HSchool.Session.v1");
private readonly HSchoolOptions _options = options.Value;
public bool TryGetUserName(HttpContext context, out string userName)
{
userName = "";
if (!context.Request.Cookies.TryGetValue(CookieName, out var token)
|| string.IsNullOrWhiteSpace(token))
{
return false;
}
try
{
userName = _protector.Unprotect(token);
return userName.Length > 0;
}
catch
{
return false;
}
}
public CookieOptions BuildCookieOptions(HttpContext context)
{
var secure = context.Request.IsHttps;
return new CookieOptions
{
HttpOnly = true,
SameSite = SameSiteMode.Lax,
Path = "/",
MaxAge = TimeSpan.FromDays(_options.SessionCookieDays),
IsEssential = true,
Secure = secure,
};
}
public string CreateSessionToken(string userName) => _protector.Protect(userName);
public bool VerifyPassword(string password) =>
string.Equals(password, _options.AlphaPassword, StringComparison.Ordinal);
public bool TryNormalizeUserName(string? userName, out string normalized)
{
if (!SchoolNames.TryNormalize(userName, out normalized))
{
return false;
}
return true;
}
public string RegisterUser(string normalized, string displayName) =>
users.ResolveOrRegister(normalized, displayName);
public bool IsNameOnline(string normalizedUserName) =>
clients.IsUserNameOnline(normalizedUserName);
}
+4
View File
@@ -6,6 +6,10 @@
} }
}, },
"AllowedHosts": "*", "AllowedHosts": "*",
"HSchool": {
"AlphaPassword": "alpha",
"SessionCookieDays": 14
},
"SwarmUi": { "SwarmUi": {
"BaseUrl": "http://127.0.0.1:7801", "BaseUrl": "http://127.0.0.1:7801",
"Authorization": "", "Authorization": "",
+40 -4
View File
@@ -431,7 +431,13 @@ public class GameSocketTests(AppHostFixture fixture)
[Fact] [Fact]
public async Task OldProtocolVersion_IsRejected() 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( await SendAsync(socket, buffer => ProtocolCodec.WriteHello(
buffer, buffer,
@@ -447,7 +453,13 @@ public class GameSocketTests(AppHostFixture fixture)
[Fact] [Fact]
public async Task VersionMismatch_IsRejected() 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( await SendAsync(socket, buffer => ProtocolCodec.WriteHello(
buffer, buffer,
@@ -474,7 +486,8 @@ public class GameSocketTests(AppHostFixture fixture)
private async Task<ClientWebSocket> OpenSchoolAsync(int schoolId, byte locale = ProtocolConstants.LocaleRussian) 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 ReceiveUntilAsync(socket, MessageType.ServerWelcome);
await SendAsync(socket, buffer => await SendAsync(socket, buffer =>
ProtocolCodec.WriteOpenSchool(buffer, new ClientOpenSchoolMessage(schoolId))); ProtocolCodec.WriteOpenSchool(buffer, new ClientOpenSchoolMessage(schoolId)));
@@ -483,7 +496,30 @@ public class GameSocketTests(AppHostFixture fixture)
private async Task<ClientWebSocket> ConnectAsync(byte locale = ProtocolConstants.LocaleRussian) 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 => await SendAsync(socket, buffer =>
ProtocolCodec.WriteHello(buffer, new ClientHelloMessage(ProtocolConstants.Version, locale))); ProtocolCodec.WriteHello(buffer, new ClientHelloMessage(ProtocolConstants.Version, locale)));
return socket; return socket;
@@ -94,7 +94,11 @@ public class PortraitApiTests(AppHostFixture fixture)
{ {
using var client = fixture.App.CreateHttpClient("server"); using var client = fixture.App.CreateHttpClient("server");
var settings = await client.GetFromJsonAsync<SwarmUiSettingsPayload>(TestContext.Current.CancellationToken); await SchoolApiTests.LoginAsync(client);
var settings = await client.GetFromJsonAsync<SwarmUiSettingsPayload>(
"/api/settings/swarmui",
TestContext.Current.CancellationToken);
Assert.NotNull(settings); Assert.NotNull(settings);
Assert.NotEmpty(settings!.Presets); Assert.NotEmpty(settings!.Presets);
Assert.False(string.IsNullOrWhiteSpace(settings.ActivePresetId)); Assert.False(string.IsNullOrWhiteSpace(settings.ActivePresetId));
@@ -110,6 +110,7 @@ public class SchoolApiTests(AppHostFixture fixture)
public async Task DeleteSchool_ThatDoesNotExist_IsNotFound() public async Task DeleteSchool_ThatDoesNotExist_IsNotFound()
{ {
using var client = fixture.App.CreateHttpClient("server"); using var client = fixture.App.CreateHttpClient("server");
await LoginAsync(client);
using var response = await client.DeleteAsync("/api/schools/999999", TestContext.Current.CancellationToken); 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() public async Task Status_ReportsTheLoopAndTheLimit()
{ {
using var client = fixture.App.CreateHttpClient("server"); using var client = fixture.App.CreateHttpClient("server");
await LoginAsync(client);
var status = await client.GetFromJsonAsync<StatusResponse>("/api/status", TestContext.Current.CancellationToken); var status = await client.GetFromJsonAsync<StatusResponse>("/api/status", TestContext.Current.CancellationToken);
@@ -510,8 +512,72 @@ public class SchoolApiTests(AppHostFixture fixture)
Assert.True(restored.Running); 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) internal static async Task ResetAsync(HttpClient client)
{ {
await LoginAsync(client);
var state = await GetSchoolsAsync(client); var state = await GetSchoolsAsync(client);
foreach (var school in state.Schools) foreach (var school in state.Schools)
@@ -117,6 +117,7 @@ public class ServiceabilityTests(AppHostFixture fixture)
public async Task Dump_UnknownSchool_IsNotFound() public async Task Dump_UnknownSchool_IsNotFound()
{ {
using var client = fixture.App.CreateHttpClient("server"); 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); using var response = await client.GetAsync("/api/dev/schools/999999/dump", TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); 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);
}