Merge branch 'review/slice-8-session'
This commit is contained in:
@@ -353,3 +353,34 @@
|
||||
- `PhysicalEducationBeforeLesson_…` не гоняет полный цикл «зал с формой» — это в
|
||||
`AppropriatenessSimulationTests.PeLesson_…`.
|
||||
- Портреты/SwarmUI — вне среза 7, но `PortraitApiTests` чинился, чтобы AppHost-сборка не падала.
|
||||
|
||||
## Срез 8. Сессия и темп
|
||||
|
||||
- **Фазы:** 38–40
|
||||
- **Проверен на:** `1c3ae9a`, 2026-08-20
|
||||
- **Пути:** `src/HSchool.Server/{Session,Api/SessionEndpoints.cs,Api/SchoolAccess.cs,Game/SchoolOwnership.cs,Game/UserStore.cs,Net/ClientRegistry.cs,Net/GameSocketHandler.cs,Game/GameLoopService.cs}`, `src/HSchool.Simulation/{ClockSpeed.cs,GameClock.cs,School.cs,SimulationOptions.cs}`, `src/HSchool.Client/src/{ui/sessionGate.ts,ui/mainMenu.ts,ui/gameScreen.ts,ui/schoolCard.ts,net/protocol.ts,net/api.ts,main.ts}`, `docs/protocol.md`, `docs/design/session.md`, `tests/HSchool.AppHost.Tests/{SessionApiTests.cs,SchoolOwnerApiTests.cs,GameSocketTests.cs}`, `tests/HSchool.Simulation.Tests/{ClockTempoTests.cs,GameClockTests.cs}`, `src/HSchool.Client/src/ui/{sessionGate,gameScreen,schoolCard,mainMenu}.test.ts`
|
||||
- **Итог:** обещания 38–40 на месте; дописано 4 теста; вторая вкладка с той же кукой больше не получает Welcome. Срез 9 (44/45/47) в работе — не ревьюился
|
||||
|
||||
Что подтверждено:
|
||||
|
||||
- **38:** `HSchool:AlphaPassword` (пустой — `ValidateOnStart`); `POST/GET/DELETE /api/session`; кука HttpOnly, `SameSite=Lax`, `Path=/`, `Max-Age` из `SessionCookieDays` (14); `users.json` каноническое имя; повтор другим регистром — та же запись; игровой HTTP без куки — `401`; `/health` и три ручки сессии — без неё; сокет без куки — close `PolicyViolation`, без Welcome; Hello по-прежнему версия + локаль; клиент — сначала сессия, потом сокет; хостовые тесты логинятся в `ResetAsync`/`LoginAsync`. Версия протокола не бампилась (v8).
|
||||
- **39:** `MaxSchools` = 2 на игрока, `MaxSchoolsTotal` = 16 (в AppHost-тестах Total = 7); create пишет `owner`; `limit-reached` в коде — `school-limit-reached` (как в `protocol.md`); `server-full`; `GET /api/schools` — `schools` / `others`, `mine`, `owner` или `null`; Welcome.`MaxSchools` — слоты игрока; гость: найм `403` `not-owner`, `SetRunning` не меняет `running`; бесхозная в `others`, удаляется вторым; меню «Мои»/«Чужие»; в чужой школе нет «Управление» и кнопок часов. Портретный POST без `RequireManage`. Формат сейва не бампился.
|
||||
- **40:** база 1 игровая минута/с; таблица `0.5, 1, 2, 5, 10` в `ClockSpeed`, `protocol.ts` и `protocol.md`; 20 импульсов ×1 = 1 минута, индексы 3/4 = 5 и 10 минут; на ×10 тяжёлые 10 раз с квантом 1; на ×5 — 5 раз; индекс 5 игнорируется; воркер по-прежнему 20 Гц и потолок догона 5; пять кнопок на экране. `HSchool.Simulation` без ASP.NET.
|
||||
|
||||
Дописано:
|
||||
|
||||
- `SessionApiTests.Login_SetsHttpOnlyLaxSessionCookie`
|
||||
- `SessionApiTests.SecondWebSocket_ForAnOnlineName_ClosesWithoutWelcome`
|
||||
- `ClockTempoTests.AtX5_TwentyImpulses_RunHeavyFiveTimesAndAdvanceFiveGameMinutes` (и календарь +1 на ×1 в `AtX1_…`)
|
||||
- `mainMenu.test.ts` — чужая карточка без удаления, бесхозная с удалением
|
||||
|
||||
Исправлено:
|
||||
|
||||
- Второй WebSocket того же имени принимался и слал Welcome: `POST` проверял «онлайн», upgrade — нет. Две вкладки с живой кукой обходили `409` `name-online`. Теперь `ClientRegistry.TryClaimUserName` под тем же замком, второй сокет закрывается `PolicyViolation` без Welcome.
|
||||
|
||||
Открыто:
|
||||
|
||||
- `POST /api/session` с другим именем при живой куке перезаписывает сессию без `DELETE`. Дизайн: «Без выхода второе имя с той же куки не взять». Кода ошибки нет — новый HTTP-код молча не вводился.
|
||||
- `AddDataProtection()` без ключей рядом с `saves/`. Рестарт того же профиля Windows куки, скорее всего, не сбрасывает; новый контейнер — может.
|
||||
- Текст фазы 39 пишет `limit-reached`; живой код и `protocol.md` — `school-limit-reached` (так было до среза). Не менялось.
|
||||
- Бесхозная карточка показывает «Хозяин: —», не голый прочерк. `GET /api/schools/{id}` из дизайна нет: `mine`/`owner` живут в списке меню.
|
||||
|
||||
+3
-2
@@ -64,8 +64,9 @@ Returns `{ "userName": "Leo" }` when the cookie is valid, otherwise `401`.
|
||||
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).
|
||||
closes the connection with a policy violation and never sends Welcome. A second socket for a name
|
||||
that already has a live connection is closed the same way; `POST /api/session` for that name
|
||||
returns `409` `name-online`. Hello is unchanged (version + locale only).
|
||||
|
||||
### `GET /api/schools`
|
||||
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* @vitest-environment happy-dom
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { fetchSchools, type SchoolsResponse } from '../net/api.ts';
|
||||
import { t } from '../i18n/strings.ts';
|
||||
import { MainMenu } from './mainMenu.ts';
|
||||
|
||||
vi.mock('../net/api.ts', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('../net/api.ts')>();
|
||||
return {
|
||||
...actual,
|
||||
fetchSchools: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
describe('MainMenu ownership', () => {
|
||||
beforeEach(() => {
|
||||
document.body.innerHTML = '';
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('hides delete on another owners card and shows it on an ownerless one', async () => {
|
||||
vi.mocked(fetchSchools).mockResolvedValue(menuState());
|
||||
|
||||
const menu = new MainMenu({
|
||||
onOpenSchool: () => {},
|
||||
onLogout: () => {},
|
||||
});
|
||||
document.body.append(menu.element);
|
||||
await menu.refresh();
|
||||
|
||||
const foreign = menu.element.querySelector('[data-school-id="2"]');
|
||||
const orphan = menu.element.querySelector('[data-school-id="3"]');
|
||||
expect((foreign?.querySelector('.button--danger') as HTMLElement).hidden).toBe(true);
|
||||
expect((orphan?.querySelector('.button--danger') as HTMLElement).hidden).toBe(false);
|
||||
expect(foreign?.textContent).toContain('Bob');
|
||||
expect(orphan?.textContent).toContain(t('schoolOwnerless'));
|
||||
});
|
||||
});
|
||||
|
||||
function menuState(): SchoolsResponse {
|
||||
return {
|
||||
maxSchools: 2,
|
||||
maxSchoolsTotal: 16,
|
||||
defaultStartDate: '2012-03-31T06:00:00.000Z',
|
||||
gameMinutesPerRealSecond: 1,
|
||||
schoolWeekDays: 5,
|
||||
schools: [],
|
||||
others: [
|
||||
{
|
||||
id: 2,
|
||||
name: 'Foreign',
|
||||
gameTime: '2012-03-31T06:00:00.000Z',
|
||||
running: true,
|
||||
speedIndex: 1,
|
||||
seed: 1,
|
||||
owner: 'Bob',
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
name: 'Orphan',
|
||||
gameTime: '2012-03-31T06:00:00.000Z',
|
||||
running: true,
|
||||
speedIndex: 1,
|
||||
seed: 2,
|
||||
owner: null,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
@@ -7,6 +7,7 @@ namespace HSchool.Server.Net;
|
||||
internal sealed class ClientRegistry
|
||||
{
|
||||
private readonly ConcurrentDictionary<uint, GameClient> _clients = new();
|
||||
private readonly object _names = new();
|
||||
private uint _nextPlayerId;
|
||||
|
||||
public int Count => _clients.Count;
|
||||
@@ -28,14 +29,40 @@ internal sealed class ClientRegistry
|
||||
|
||||
public bool IsUserNameOnline(string normalizedUserName)
|
||||
{
|
||||
foreach (var client in _clients.Values)
|
||||
lock (_names)
|
||||
{
|
||||
if (client.UserName is null)
|
||||
return HasUserName(normalizedUserName);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// One live socket per name. Checked under the same lock as <see cref="IsUserNameOnline"/>
|
||||
/// so a second tab cannot sneak a Welcome in between the HTTP check and SetUserName.
|
||||
/// </summary>
|
||||
public bool TryClaimUserName(GameClient client, string userName)
|
||||
{
|
||||
lock (_names)
|
||||
{
|
||||
if (HasUserName(userName))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
client.SetUserName(userName);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
private bool HasUserName(string normalizedUserName)
|
||||
{
|
||||
foreach (var existing in _clients.Values)
|
||||
{
|
||||
if (existing.UserName is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (string.Equals(client.NormalizedUserName, normalizedUserName, StringComparison.OrdinalIgnoreCase))
|
||||
if (string.Equals(existing.NormalizedUserName, normalizedUserName, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -23,7 +23,6 @@ internal sealed class GameSocketHandler(
|
||||
public async Task HandleAsync(WebSocket socket, string userName, CancellationToken cancellationToken)
|
||||
{
|
||||
var client = clients.Add(socket);
|
||||
client.SetUserName(userName);
|
||||
var buffer = ArrayPool<byte>.Shared.Rent(ProtocolConstants.MaxMessageSize);
|
||||
using var connectionCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||
|
||||
@@ -32,6 +31,16 @@ internal sealed class GameSocketHandler(
|
||||
|
||||
try
|
||||
{
|
||||
if (!clients.TryClaimUserName(client, userName))
|
||||
{
|
||||
await CloseAsync(
|
||||
socket,
|
||||
WebSocketCloseStatus.PolicyViolation,
|
||||
"Name is already online.",
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
using var handshakeCts = CancellationTokenSource.CreateLinkedTokenSource(connectionCts.Token);
|
||||
handshakeCts.CancelAfter(HandshakeTimeout);
|
||||
|
||||
|
||||
@@ -111,6 +111,26 @@ public class SessionApiTests(AppHostFixture fixture)
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Login_SetsHttpOnlyLaxSessionCookie()
|
||||
{
|
||||
using var client = fixture.App.CreateHttpClient("server");
|
||||
using var response = await client.PostAsJsonAsync(
|
||||
"/api/session",
|
||||
new { password = SchoolApiTests.TestPassword, userName = $"Cookie-{Guid.NewGuid():N}"[..14] },
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
response.EnsureSuccessStatusCode();
|
||||
Assert.True(response.Headers.TryGetValues("Set-Cookie", out var values));
|
||||
var cookie = Assert.Single(values, value =>
|
||||
value.StartsWith("hschool.session=", StringComparison.OrdinalIgnoreCase));
|
||||
var parts = cookie.Split(';', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries);
|
||||
Assert.Contains(parts, part => part.Equals("httponly", StringComparison.OrdinalIgnoreCase));
|
||||
Assert.Contains(parts, part => part.Equals("samesite=lax", StringComparison.OrdinalIgnoreCase));
|
||||
Assert.Contains(parts, part => part.Equals("path=/", StringComparison.OrdinalIgnoreCase));
|
||||
Assert.Contains(parts, part => part.Equals("max-age=1209600", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task WebSocket_WithoutSession_ClosesWithoutWelcome()
|
||||
{
|
||||
@@ -123,6 +143,40 @@ public class SessionApiTests(AppHostFixture fixture)
|
||||
Assert.Equal(WebSocketCloseStatus.PolicyViolation, socket.CloseStatus);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SecondWebSocket_ForAnOnlineName_ClosesWithoutWelcome()
|
||||
{
|
||||
using var client = fixture.App.CreateHttpClient("server");
|
||||
await SchoolApiTests.ResetAsync(client);
|
||||
|
||||
var name = $"DupWs-{Guid.NewGuid():N}"[..14];
|
||||
var cookie = await SchoolApiTests.LoginAndGetCookieAsync(client, name);
|
||||
|
||||
var first = new ClientWebSocket();
|
||||
first.Options.SetRequestHeader("Cookie", cookie);
|
||||
var http = fixture.App.GetEndpoint("server", "http");
|
||||
var uri = new UriBuilder(http) { Scheme = "ws", Path = "/ws/game" }.Uri;
|
||||
await first.ConnectAsync(uri, TestContext.Current.CancellationToken).WaitAsync(DefaultTimeout);
|
||||
try
|
||||
{
|
||||
await SendHelloAsync(first);
|
||||
await ReceiveWelcomeAsync(first);
|
||||
|
||||
using var second = new ClientWebSocket();
|
||||
second.Options.SetRequestHeader("Cookie", cookie);
|
||||
await second.ConnectAsync(uri, TestContext.Current.CancellationToken).WaitAsync(DefaultTimeout);
|
||||
await SendHelloAsync(second);
|
||||
|
||||
var frame = await ReceiveOneFrameAsync(second, TimeSpan.FromSeconds(5));
|
||||
Assert.Equal(WebSocketMessageType.Close, frame.MessageType);
|
||||
Assert.Equal(WebSocketCloseStatus.PolicyViolation, second.CloseStatus);
|
||||
}
|
||||
finally
|
||||
{
|
||||
first.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
private HttpClient CreateAnonymousClient()
|
||||
{
|
||||
var http = fixture.App.GetEndpoint("server", "http").ToString();
|
||||
|
||||
@@ -34,9 +34,26 @@ public class ClockTempoTests
|
||||
school.Tick(OneTwentiethOfASecond, GameMinutesPerRealSecond);
|
||||
}
|
||||
|
||||
Assert.Equal(Start.AddMinutes(1), school.Clock.Time);
|
||||
Assert.Equal(20, school.HeavySystemsInvocations);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AtX5_TwentyImpulses_RunHeavyFiveTimesAndAdvanceFiveGameMinutes()
|
||||
{
|
||||
using var school = School.Create(1, "Страйд ×5", Start);
|
||||
school.Clock.SpeedIndex = 3;
|
||||
|
||||
for (var i = 0; i < 20; i++)
|
||||
{
|
||||
school.Tick(OneTwentiethOfASecond, GameMinutesPerRealSecond);
|
||||
}
|
||||
|
||||
Assert.Equal(Start.AddMinutes(5), school.Clock.Time);
|
||||
Assert.Equal(5, school.HeavySystemsInvocations);
|
||||
Assert.Equal(1d, school.LastHeavyGameMinutes);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(3, 5d)]
|
||||
[InlineData(4, 10d)]
|
||||
|
||||
Reference in New Issue
Block a user