diff --git a/docs/phases/reviewed.md b/docs/phases/reviewed.md index 43c397f..3ef5ada 100644 --- a/docs/phases/reviewed.md +++ b/docs/phases/reviewed.md @@ -353,3 +353,34 @@ - `PhysicalEducationBeforeLesson_…` не гоняет полный цикл «зал с формой» — это в `AppropriatenessSimulationTests.PeLesson_…`. - Портреты/SwarmUI — вне среза 7, но `PortraitApiTests` чинился, чтобы AppHost-сборка не падала. + +## Срез 8. Сессия и темп + +- **Фазы:** 38–40 +- **Проверен на:** `pending`, 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` живут в списке меню. diff --git a/docs/protocol.md b/docs/protocol.md index 392d8e0..a2d1802 100644 --- a/docs/protocol.md +++ b/docs/protocol.md @@ -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` diff --git a/src/HSchool.Client/src/ui/mainMenu.test.ts b/src/HSchool.Client/src/ui/mainMenu.test.ts new file mode 100644 index 0000000..292b22b --- /dev/null +++ b/src/HSchool.Client/src/ui/mainMenu.test.ts @@ -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(); + 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, + }, + ], + }; +} diff --git a/src/HSchool.Server/Net/ClientRegistry.cs b/src/HSchool.Server/Net/ClientRegistry.cs index 0388cc4..3ffb88e 100644 --- a/src/HSchool.Server/Net/ClientRegistry.cs +++ b/src/HSchool.Server/Net/ClientRegistry.cs @@ -7,6 +7,7 @@ namespace HSchool.Server.Net; internal sealed class ClientRegistry { private readonly ConcurrentDictionary _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); + } + } + + /// + /// One live socket per name. Checked under the same lock as + /// so a second tab cannot sneak a Welcome in between the HTTP check and SetUserName. + /// + 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; } diff --git a/src/HSchool.Server/Net/GameSocketHandler.cs b/src/HSchool.Server/Net/GameSocketHandler.cs index 5e09ebc..5258709 100644 --- a/src/HSchool.Server/Net/GameSocketHandler.cs +++ b/src/HSchool.Server/Net/GameSocketHandler.cs @@ -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.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); diff --git a/tests/HSchool.AppHost.Tests/SessionApiTests.cs b/tests/HSchool.AppHost.Tests/SessionApiTests.cs index 83e786b..cd9253a 100644 --- a/tests/HSchool.AppHost.Tests/SessionApiTests.cs +++ b/tests/HSchool.AppHost.Tests/SessionApiTests.cs @@ -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(); diff --git a/tests/HSchool.Simulation.Tests/ClockTempoTests.cs b/tests/HSchool.Simulation.Tests/ClockTempoTests.cs index 24e2e46..c6bfdf5 100644 --- a/tests/HSchool.Simulation.Tests/ClockTempoTests.cs +++ b/tests/HSchool.Simulation.Tests/ClockTempoTests.cs @@ -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)]