From a444fc011e2ba5436e6eca9af4d5085829ca978a Mon Sep 17 00:00:00 2001 From: Leonid Pershin Date: Thu, 20 Aug 2026 07:17:18 +0300 Subject: [PATCH 1/2] Add alpha session gate with cookie auth and login UI. --- docs/phases/38-session.md | 26 +-- docs/protocol.md | 39 ++++ src/HSchool.AppHost/AppHost.cs | 1 + src/HSchool.Client/src/i18n/strings.ts | 26 +++ src/HSchool.Client/src/main.ts | 22 +- src/HSchool.Client/src/net/api.ts | 22 +- src/HSchool.Client/src/ui/mainMenu.ts | 9 +- src/HSchool.Client/src/ui/sessionGate.test.ts | 40 ++++ src/HSchool.Client/src/ui/sessionGate.ts | 120 ++++++++++ src/HSchool.Server/Api/SessionEndpoints.cs | 78 +++++++ src/HSchool.Server/Game/UserStore.cs | 107 +++++++++ src/HSchool.Server/HSchoolOptions.cs | 16 ++ src/HSchool.Server/Net/ClientRegistry.cs | 18 ++ src/HSchool.Server/Net/GameClient.cs | 14 ++ src/HSchool.Server/Net/GameSocketHandler.cs | 3 +- src/HSchool.Server/Program.cs | 41 +++- .../Session/SessionAuthMiddleware.cs | 38 +++ src/HSchool.Server/Session/SessionService.cs | 75 ++++++ src/HSchool.Server/appsettings.json | 4 + .../HSchool.AppHost.Tests/GameSocketTests.cs | 44 +++- .../HSchool.AppHost.Tests/PortraitApiTests.cs | 6 +- tests/HSchool.AppHost.Tests/SchoolApiTests.cs | 66 ++++++ .../ServiceabilityTests.cs | 1 + .../HSchool.AppHost.Tests/SessionApiTests.cs | 216 ++++++++++++++++++ 24 files changed, 1005 insertions(+), 27 deletions(-) create mode 100644 src/HSchool.Client/src/ui/sessionGate.test.ts create mode 100644 src/HSchool.Client/src/ui/sessionGate.ts create mode 100644 src/HSchool.Server/Api/SessionEndpoints.cs create mode 100644 src/HSchool.Server/Game/UserStore.cs create mode 100644 src/HSchool.Server/HSchoolOptions.cs create mode 100644 src/HSchool.Server/Session/SessionAuthMiddleware.cs create mode 100644 src/HSchool.Server/Session/SessionService.cs create mode 100644 tests/HSchool.AppHost.Tests/SessionApiTests.cs diff --git a/docs/phases/38-session.md b/docs/phases/38-session.md index edf6415..365cf5e 100644 --- a/docs/phases/38-session.md +++ b/docs/phases/38-session.md @@ -13,28 +13,28 @@ ## Задачи -- [ ] `HSchool:AlphaPassword` в конфиге; пустая строка — процесс не стартует. В `appsettings.json` +- [x] `HSchool:AlphaPassword` в конфиге; пустая строка — процесс не стартует. В `appsettings.json` заглушка для локальной игры; headless AppHost задаёт тестовый пароль окружением -- [ ] `POST /api/session` `{ password, userName }`: неверный пароль — `401` `bad-password`; +- [x] `POST /api/session` `{ password, userName }`: неверный пароль — `401` `bad-password`; кривое имя — `400` `invalid-name` (те же правила, что у школы); имя с живым сокетом — `409` `name-online`. Успех ставит HttpOnly-куку и при первом разе пишет имя в `saves/users.json` (занятость без учёта регистра) -- [ ] `GET /api/session` — текущее имя или `401`. `DELETE /api/session` снимает куку -- [ ] Все игровые HTTP без куки — `401`. Живут без неё: `/health` и три ручки сессии -- [ ] WebSocket без куки не шлёт Welcome, закрывается. Hello по-прежнему версия + локаль -- [ ] Клиент: шаг пароля, шаг имени; при живой куке оба пропускаются. Сокет — после сессии. +- [x] `GET /api/session` — текущее имя или `401`. `DELETE /api/session` снимает куку +- [x] Все игровые HTTP без куки — `401`. Живут без неё: `/health` и три ручки сессии +- [x] WebSocket без куки не шлёт Welcome, закрывается. Hello по-прежнему версия + локаль +- [x] Клиент: шаг пароля, шаг имени; при живой куке оба пропускаются. Сокет — после сессии. Кнопка выхода на меню -- [ ] Хостовые тесты логинятся в общем месте (`ResetAsync` или рядом), не в каждом факте -- [ ] `docs/protocol.md` — HTTP сессии в том же коммите. Раскладку сокета и версию не трогать +- [x] Хостовые тесты логинятся в общем месте (`ResetAsync` или рядом), не в каждом факте +- [x] `docs/protocol.md` — HTTP сессии в том же коммите. Раскладку сокета и версию не трогать ## Тесты, без которых фаза не закрыта -- [ ] Без сессии `GET /api/schools` — `401`; после `POST /api/session` — `200` -- [ ] Верный пароль и новое имя пишут `users.json`; повтор с тем же именем (другой регистр) — +- [x] Без сессии `GET /api/schools` — `401`; после `POST /api/session` — `200` +- [x] Верный пароль и новое имя пишут `users.json`; повтор с тем же именем (другой регистр) — тот же человек, не вторая запись -- [ ] Пока сокет имени жив, второй `POST /api/session` с ним — `409` `name-online` -- [ ] Сокет без куки закрывается, Welcome не приходит -- [ ] Клиентский тест: без куки виден ввод пароля, не сетка школ +- [x] Пока сокет имени жив, второй `POST /api/session` с ним — `409` `name-online` +- [x] Сокет без куки закрывается, Welcome не приходит +- [x] Клиентский тест: без куки виден ввод пароля, не сетка школ ## Критерий готовности diff --git a/docs/protocol.md b/docs/protocol.md index c7e95ab..736da3b 100644 --- a/docs/protocol.md +++ b/docs/protocol.md @@ -25,9 +25,48 @@ closes connections whose hello carries a different version with `1002 ProtocolEr ## 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 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, 1–40 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` Everything the main menu needs in one request. `schoolWeekDays` is 5–7 working days counted diff --git a/src/HSchool.AppHost/AppHost.cs b/src/HSchool.AppHost/AppHost.cs index 58f7738..16ca242 100644 --- a/src/HSchool.AppHost/AppHost.cs +++ b/src/HSchool.AppHost/AppHost.cs @@ -16,6 +16,7 @@ if (headless) server .WithEnvironment("Simulation__SavesDirectory", saves) .WithEnvironment("HSchool__AllowSaveReload", "true") + .WithEnvironment("HSchool__AlphaPassword", "test-alpha") .WithEnvironment("SwarmUi__BaseUrl", ""); } diff --git a/src/HSchool.Client/src/i18n/strings.ts b/src/HSchool.Client/src/i18n/strings.ts index 2018591..d27ad6c 100644 --- a/src/HSchool.Client/src/i18n/strings.ts +++ b/src/HSchool.Client/src/i18n/strings.ts @@ -9,6 +9,19 @@ const ru = { pingPlaceholder: '-- мс', language: 'Язык', + sessionTitle: 'Вход', + sessionPassword: 'Пароль альфы', + sessionPasswordPlaceholder: 'Пароль', + sessionUserName: 'Ваше имя', + sessionUserNamePlaceholder: 'Имя в сети', + sessionContinue: 'Продолжить', + sessionEnter: 'Войти', + sessionBadPassword: 'Неверный пароль.', + sessionNameOnline: 'Это имя уже в сети.', + sessionInvalidName: 'Имя должно быть от 1 до 40 символов.', + sessionFailed: 'Не удалось войти. Попробуйте ещё раз.', + sessionLogout: 'Выйти', + schoolsTitle: 'Школы', createSchool: 'Создать школу', settings: 'Настройки', @@ -338,6 +351,19 @@ const en: Messages = { pingPlaceholder: '-- ms', 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 1–40 characters.', + sessionFailed: 'Could not sign in. Try again.', + sessionLogout: 'Sign out', + schoolsTitle: 'Schools', createSchool: 'Create school', settings: 'Settings', diff --git a/src/HSchool.Client/src/main.ts b/src/HSchool.Client/src/main.ts index 3e67a1f..aceb488 100644 --- a/src/HSchool.Client/src/main.ts +++ b/src/HSchool.Client/src/main.ts @@ -1,9 +1,11 @@ import { GameConnection, gameSocketUrl, type ConnectionStatus } from './net/connection.ts'; +import { logoutSession } from './net/api.ts'; import { getLocale, onLocaleChange } from './i18n/locale.ts'; import { t, type MessageKey } from './i18n/strings.ts'; import { GameScreen } from './ui/gameScreen.ts'; import { localeSwitch } from './ui/localeSwitch.ts'; import { MainMenu } from './ui/mainMenu.ts'; +import { ensureSession } from './ui/sessionGate.ts'; import type { School } from './net/api.ts'; import './style.css'; @@ -15,7 +17,7 @@ const STATUS_KEYS: Record = { }; /** Wires the two screens to one WebSocket connection. */ -function bootstrap(): void { +async function bootstrap(): Promise { const app = requireElement('#app'); const footer = requireElement('#status'); const statusLabel = document.querySelector('[data-status="connection"]'); @@ -23,11 +25,16 @@ function bootstrap(): void { footer.prepend(localeSwitch()); + await ensureSession(); + let openSchool: School | null = null; let connectionStatus: ConnectionStatus = 'connecting'; 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({ onLeave: () => leaveSchool(), onSetRunning: (running) => connection.setRunning(running), @@ -97,6 +104,15 @@ function bootstrap(): void { menu.start(); } + async function handleLogout(): Promise { + connection.close(); + menu.stop(); + await logoutSession(); + await ensureSession(); + connection.connect(); + showMenu(); + } + onLocaleChange(() => { paintChrome(); menu.localize(); @@ -120,4 +136,4 @@ function requireElement(selector: string): HTMLElement { return element; } -bootstrap(); +void bootstrap(); diff --git a/src/HSchool.Client/src/net/api.ts b/src/HSchool.Client/src/net/api.ts index 9601e68..32e3751 100644 --- a/src/HSchool.Client/src/net/api.ts +++ b/src/HSchool.Client/src/net/api.ts @@ -402,6 +402,26 @@ export async function fetchGameStatus(): Promise { return request('/api/status'); } +export interface SessionInfo { + readonly userName: string; +} + +export async function fetchSession(): Promise { + return request('/api/session'); +} + +export async function loginSession(password: string, userName: string): Promise { + return request('/api/session', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ password, userName }), + }); +} + +export async function logoutSession(): Promise { + await request('/api/session', { method: 'DELETE' }, { expectBody: false }); +} + export interface SwarmUiKindPreset { width: number; height: number; @@ -764,7 +784,7 @@ async function request( init?: RequestInit, options: { expectBody?: boolean } = {}, ): Promise { - const response = await fetch(url, init); + const response = await fetch(url, { credentials: 'include', ...init }); if (!response.ok) { throw await toApiError(response); diff --git a/src/HSchool.Client/src/ui/mainMenu.ts b/src/HSchool.Client/src/ui/mainMenu.ts index 50c94e5..fb289a4 100644 --- a/src/HSchool.Client/src/ui/mainMenu.ts +++ b/src/HSchool.Client/src/ui/mainMenu.ts @@ -16,6 +16,7 @@ import { SchoolCard } from './schoolCard.ts'; interface MainMenuOptions { readonly onOpenSchool: (school: School) => void; + readonly onLogout: () => void; } /** @@ -40,6 +41,10 @@ export class MainMenu { class: 'button', type: 'button', }); + private readonly logoutButton = el('button', { + class: 'button', + type: 'button', + }); private readonly limitHint = el('p', { class: 'hint' }); private readonly status = el('p', { class: 'hint hint--error' }); @@ -53,6 +58,7 @@ export class MainMenu { constructor(private readonly options: MainMenuOptions) { this.createButton.addEventListener('click', () => void this.openCreateDialog()); this.settingsButton.addEventListener('click', () => void swarmUiSettingsDialog()); + this.logoutButton.addEventListener('click', () => this.options.onLogout()); this.status.hidden = true; this.root.append( @@ -60,7 +66,7 @@ export class MainMenu { 'header', { class: 'screen__header' }, 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.status, @@ -80,6 +86,7 @@ export class MainMenu { this.title.textContent = t('schoolsTitle'); this.createButton.textContent = t('createSchool'); this.settingsButton.textContent = t('settings'); + this.logoutButton.textContent = t('sessionLogout'); this.emptyHint.textContent = t('emptySchools'); for (const card of this.cards.values()) { diff --git a/src/HSchool.Client/src/ui/sessionGate.test.ts b/src/HSchool.Client/src/ui/sessionGate.test.ts new file mode 100644 index 0000000..365c473 --- /dev/null +++ b/src/HSchool.Client/src/ui/sessionGate.test.ts @@ -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(); + return { + ...actual, + fetchSession: vi.fn(), + fetchSchools: vi.fn(), + loginSession: vi.fn(), + }; +}); + +describe('session gate', () => { + beforeEach(() => { + document.body.innerHTML = '
'; + }); + + 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); + }); +}); diff --git a/src/HSchool.Client/src/ui/sessionGate.ts b/src/HSchool.Client/src/ui/sessionGate.ts new file mode 100644 index 0000000..2ac39d4 --- /dev/null +++ b/src/HSchool.Client/src/ui/sessionGate.ts @@ -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 { + try { + const session = await fetchSession(); + return session.userName; + } catch (error) { + if (!(error instanceof ApiError) || error.status !== 401) { + throw error; + } + } + + return showSessionGate(); +} + +function showSessionGate(): Promise { + const app = document.querySelector('#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 { + 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(); + }); +} diff --git a/src/HSchool.Server/Api/SessionEndpoints.cs b/src/HSchool.Server/Api/SessionEndpoints.cs new file mode 100644 index 0000000..bd40b63 --- /dev/null +++ b/src/HSchool.Server/Api/SessionEndpoints.cs @@ -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 1–40 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 { ["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); +} diff --git a/src/HSchool.Server/Game/UserStore.cs b/src/HSchool.Server/Game/UserStore.cs new file mode 100644 index 0000000..ab4ab7a --- /dev/null +++ b/src/HSchool.Server/Game/UserStore.cs @@ -0,0 +1,107 @@ +using System.Text.Json; + +namespace HSchool.Server.Game; + +/// One registered player name, stored exactly as typed on first login. +internal sealed record UserRecord(string Name); + +/// Persistent user list beside school saves. +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 _logger; + private readonly string _path; + private List _users = []; + + public UserStore(SchoolStore schools, ILogger logger) + { + _logger = logger; + _path = Path.Combine(schools.DirectoryPath, UsersFileName); + Load(); + } + + /// + /// Returns the canonical spelling for or registers + /// on first use. + /// + 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(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 Users); +} diff --git a/src/HSchool.Server/HSchoolOptions.cs b/src/HSchool.Server/HSchoolOptions.cs new file mode 100644 index 0000000..e24eacc --- /dev/null +++ b/src/HSchool.Server/HSchoolOptions.cs @@ -0,0 +1,16 @@ +namespace HSchool.Server; + +/// Server-wide options that are not part of the simulation. +internal sealed class HSchoolOptions +{ + public const string SectionName = "HSchool"; + + /// Shared alpha gate password. Empty means the process must not start. + public string AlphaPassword { get; set; } = ""; + + /// How long a session cookie lives without re-login. + public int SessionCookieDays { get; set; } = 14; + + /// When true, dev reload/dump endpoints are mapped. Off in production by default. + public bool AllowSaveReload { get; set; } +} diff --git a/src/HSchool.Server/Net/ClientRegistry.cs b/src/HSchool.Server/Net/ClientRegistry.cs index ed0ed04..0388cc4 100644 --- a/src/HSchool.Server/Net/ClientRegistry.cs +++ b/src/HSchool.Server/Net/ClientRegistry.cs @@ -25,4 +25,22 @@ internal sealed class ClientRegistry public GameClient? Find(uint playerId) => _clients.GetValueOrDefault(playerId); 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; + } } diff --git a/src/HSchool.Server/Net/GameClient.cs b/src/HSchool.Server/Net/GameClient.cs index a15b3bf..29c6659 100644 --- a/src/HSchool.Server/Net/GameClient.cs +++ b/src/HSchool.Server/Net/GameClient.cs @@ -30,11 +30,25 @@ internal sealed class GameClient(uint playerId, WebSocket socket) private bool _ready; private int _openSchoolId; private int _locale; + private string? _userName; + private string? _normalizedUserName; public uint PlayerId { get; } = playerId; public WebSocket Socket { get; } = socket; + /// Display name from the session cookie, set before the welcome frame goes out. + public string? UserName => Volatile.Read(ref _userName); + + /// Case-insensitive key used for the online-name check. + public string? NormalizedUserName => Volatile.Read(ref _normalizedUserName); + + public void SetUserName(string userName) + { + Volatile.Write(ref _userName, userName); + Volatile.Write(ref _normalizedUserName, userName); + } + /// /// 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. diff --git a/src/HSchool.Server/Net/GameSocketHandler.cs b/src/HSchool.Server/Net/GameSocketHandler.cs index fe3844c..2f62fd5 100644 --- a/src/HSchool.Server/Net/GameSocketHandler.cs +++ b/src/HSchool.Server/Net/GameSocketHandler.cs @@ -19,9 +19,10 @@ internal sealed class GameSocketHandler( { 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); + client.SetUserName(userName); var buffer = ArrayPool.Shared.Rent(ProtocolConstants.MaxMessageSize); using var connectionCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); diff --git a/src/HSchool.Server/Program.cs b/src/HSchool.Server/Program.cs index f80e7cd..83f199a 100644 --- a/src/HSchool.Server/Program.cs +++ b/src/HSchool.Server/Program.cs @@ -3,6 +3,7 @@ using HSchool.Server; using HSchool.Server.Api; using HSchool.Server.Game; using HSchool.Server.Net; +using HSchool.Server.Session; using HSchool.Simulation; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Options; @@ -12,6 +13,14 @@ var builder = WebApplication.CreateBuilder(args); builder.AddServiceDefaults(); builder.Services.AddProblemDetails(); builder.Services.AddOpenApi(); +builder.Services.AddDataProtection(); + +builder.Services + .AddOptions() + .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 .AddOptions() @@ -30,6 +39,8 @@ builder.Services builder.Services.AddSingleton(); builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); @@ -64,6 +75,9 @@ app.UseWebSockets(new WebSocketOptions KeepAliveInterval = TimeSpan.FromSeconds(30), }); +app.UseMiddleware(); + +app.MapSessionEndpoints(); app.MapSchoolEndpoints(); app.MapSettingsEndpoints(); 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. -app.Map("/ws/game", async (HttpContext context, GameSocketHandler handler) => +app.Map("/ws/game", async (HttpContext context, GameSocketHandler handler, SessionService sessions) => { if (!context.WebSockets.IsWebSocketRequest) { @@ -113,8 +127,29 @@ app.Map("/ws/game", async (HttpContext context, GameSocketHandler handler) => return; } - using WebSocket socket = await context.WebSockets.AcceptWebSocketAsync(); - await handler.HandleAsync(socket, context.RequestAborted); + if (!sessions.TryGetUserName(context, out var userName)) + { + 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(); diff --git a/src/HSchool.Server/Session/SessionAuthMiddleware.cs b/src/HSchool.Server/Session/SessionAuthMiddleware.cs new file mode 100644 index 0000000..ddd6383 --- /dev/null +++ b/src/HSchool.Server/Session/SessionAuthMiddleware.cs @@ -0,0 +1,38 @@ +namespace HSchool.Server.Session; + +/// +/// Every game HTTP route needs a session cookie. Health and the three session routes are the +/// only public exceptions. +/// +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; + } +} diff --git a/src/HSchool.Server/Session/SessionService.cs b/src/HSchool.Server/Session/SessionService.cs new file mode 100644 index 0000000..4ac6c77 --- /dev/null +++ b/src/HSchool.Server/Session/SessionService.cs @@ -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; + +/// Alpha login, signed session cookies, and online-name checks. +internal sealed class SessionService( + IDataProtectionProvider dataProtection, + UserStore users, + ClientRegistry clients, + IOptions 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); +} diff --git a/src/HSchool.Server/appsettings.json b/src/HSchool.Server/appsettings.json index cbb9ff0..4e2a6cb 100644 --- a/src/HSchool.Server/appsettings.json +++ b/src/HSchool.Server/appsettings.json @@ -6,6 +6,10 @@ } }, "AllowedHosts": "*", + "HSchool": { + "AlphaPassword": "alpha", + "SessionCookieDays": 14 + }, "SwarmUi": { "BaseUrl": "http://127.0.0.1:7801", "Authorization": "", diff --git a/tests/HSchool.AppHost.Tests/GameSocketTests.cs b/tests/HSchool.AppHost.Tests/GameSocketTests.cs index 0965a05..a02cb3f 100644 --- a/tests/HSchool.AppHost.Tests/GameSocketTests.cs +++ b/tests/HSchool.AppHost.Tests/GameSocketTests.cs @@ -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 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 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 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; diff --git a/tests/HSchool.AppHost.Tests/PortraitApiTests.cs b/tests/HSchool.AppHost.Tests/PortraitApiTests.cs index 50070ea..f36c34c 100644 --- a/tests/HSchool.AppHost.Tests/PortraitApiTests.cs +++ b/tests/HSchool.AppHost.Tests/PortraitApiTests.cs @@ -94,7 +94,11 @@ public class PortraitApiTests(AppHostFixture fixture) { using var client = fixture.App.CreateHttpClient("server"); - var settings = await client.GetFromJsonAsync(TestContext.Current.CancellationToken); + await SchoolApiTests.LoginAsync(client); + + var settings = await client.GetFromJsonAsync( + "/api/settings/swarmui", + TestContext.Current.CancellationToken); Assert.NotNull(settings); Assert.NotEmpty(settings!.Presets); Assert.False(string.IsNullOrWhiteSpace(settings.ActivePresetId)); diff --git a/tests/HSchool.AppHost.Tests/SchoolApiTests.cs b/tests/HSchool.AppHost.Tests/SchoolApiTests.cs index 8b05060..153963d 100644 --- a/tests/HSchool.AppHost.Tests/SchoolApiTests.cs +++ b/tests/HSchool.AppHost.Tests/SchoolApiTests.cs @@ -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("/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 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) diff --git a/tests/HSchool.AppHost.Tests/ServiceabilityTests.cs b/tests/HSchool.AppHost.Tests/ServiceabilityTests.cs index 4905727..2dee6c3 100644 --- a/tests/HSchool.AppHost.Tests/ServiceabilityTests.cs +++ b/tests/HSchool.AppHost.Tests/ServiceabilityTests.cs @@ -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); diff --git a/tests/HSchool.AppHost.Tests/SessionApiTests.cs b/tests/HSchool.AppHost.Tests/SessionApiTests.cs new file mode 100644 index 0000000..83e786b --- /dev/null +++ b/tests/HSchool.AppHost.Tests/SessionApiTests.cs @@ -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( + 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("/api/session", TestContext.Current.CancellationToken); + Assert.NotNull(session); + Assert.Equal(unique, session.UserName); + + var reread = JsonSerializer.Deserialize( + 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 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 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 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 SavesDirectoryAsync(HttpClient client) + { + var payload = await client.GetFromJsonAsync( + "/api/dev/saves-directory", + TestContext.Current.CancellationToken); + Assert.NotNull(payload); + return payload.Path; + } + + private sealed record SessionResponse(string UserName); + + private sealed record UsersDocument(IReadOnlyList Users); + + private sealed record UserRecord(string Name); + + private sealed record PathResponse(string Path); +} From fbd18a646f883d58f88c0ae06ea178e7d8032024 Mon Sep 17 00:00:00 2001 From: Leonid Pershin Date: Thu, 20 Aug 2026 07:17:52 +0300 Subject: [PATCH 2/2] Mark phase 38 session complete. --- docs/phases/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/phases/README.md b/docs/phases/README.md index 164da76..74efd44 100644 --- a/docs/phases/README.md +++ b/docs/phases/README.md @@ -167,7 +167,7 @@ | Фаза | Статус | Зачем | | --- | --- | --- | -| [38. Сессия](38-session.md) | ⬜ | Пароль альфы, имя, кука, сокет после входа | +| [38. Сессия](38-session.md) | ✅ | Пароль альфы, имя, кука, сокет после входа | **Этап B — чьи школы.** Хозяин, слоты, гость только смотрит.