Merge branch 'phase/38-session'
# Conflicts: # docs/phases/README.md # tests/HSchool.AppHost.Tests/PortraitApiTests.cs
This commit is contained in:
+13
-13
@@ -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] Клиентский тест: без куки виден ввод пароля, не сетка школ
|
||||||
|
|
||||||
## Критерий готовности
|
## Критерий готовности
|
||||||
|
|
||||||
|
|||||||
@@ -167,7 +167,7 @@
|
|||||||
|
|
||||||
| Фаза | Статус | Зачем |
|
| Фаза | Статус | Зачем |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| [38. Сессия](38-session.md) | 🔄 | Пароль альфы, имя, кука, сокет после входа |
|
| [38. Сессия](38-session.md) | ✅ | Пароль альфы, имя, кука, сокет после входа |
|
||||||
|
|
||||||
**Этап B — чьи школы.** Хозяин, слоты, гость только смотрит.
|
**Этап B — чьи школы.** Хозяин, слоты, гость только смотрит.
|
||||||
|
|
||||||
|
|||||||
@@ -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, 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`
|
### `GET /api/schools`
|
||||||
|
|
||||||
Everything the main menu needs in one request. `schoolWeekDays` is 5–7 working days counted
|
Everything the main menu needs in one request. `schoolWeekDays` is 5–7 working days counted
|
||||||
|
|||||||
@@ -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", "");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 1–40 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',
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 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<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);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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; }
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!sessions.TryGetUserName(context, out var userName))
|
||||||
|
{
|
||||||
using WebSocket socket = await context.WebSockets.AcceptWebSocketAsync();
|
using WebSocket socket = await context.WebSockets.AcceptWebSocketAsync();
|
||||||
await handler.HandleAsync(socket, context.RequestAborted);
|
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);
|
||||||
|
}
|
||||||
@@ -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": "",
|
||||||
|
|||||||
@@ -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,6 +94,8 @@ public class PortraitApiTests(AppHostFixture fixture)
|
|||||||
{
|
{
|
||||||
using var client = fixture.App.CreateHttpClient("server");
|
using var client = fixture.App.CreateHttpClient("server");
|
||||||
|
|
||||||
|
await SchoolApiTests.LoginAsync(client);
|
||||||
|
|
||||||
var settings = await client.GetFromJsonAsync<SwarmUiSettingsPayload>(
|
var settings = await client.GetFromJsonAsync<SwarmUiSettingsPayload>(
|
||||||
"/api/settings/swarmui",
|
"/api/settings/swarmui",
|
||||||
TestContext.Current.CancellationToken);
|
TestContext.Current.CancellationToken);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user