From c37d76937a83314ee3ad0a2889f3e4a9ec5e50ce Mon Sep 17 00:00:00 2001 From: Fedorov Vladimir Date: Sun, 11 Oct 2026 13:47:50 +0700 Subject: [PATCH] up --- README.md | 36 ++++++++++++++++++++ docs/agent/TASKS.md | 7 ++-- tests/venue/README.md | 20 +++++++++++ tests/venue/device_presence.py | 60 +++++++++++++++++++++++++++++++++ tests/venue/flow.py | 2 ++ tests/venue/flow_browser.py | 61 ++++++++++++++++++++++++++++++++++ 6 files changed, 184 insertions(+), 2 deletions(-) create mode 100644 tests/venue/device_presence.py diff --git a/README.md b/README.md index a1ea4b8..83de0f0 100644 --- a/README.md +++ b/README.md @@ -133,6 +133,42 @@ python3 tests/venue/smoke.py --verify Тестовые данные остаются в локальной базе; повторное создание запускайте только когда нужен новый набор. Дождитесь ответа `/api/test/ping` перед проверкой. +### Учёт устройств (этап 12) + +Venue автоматически учитывает браузеры игроков, организаторов и экранов: +`POST /api/devices/heartbeat` публично регистрирует подключение, а +`GET /api/devices?gameID=` возвращает сгруппированные устройства организатору, +администратору или разработчику. Фильтр необязателен; при его использовании +список содержит только подключения выбранной игры. Панель состояния — этап 13. + +UUID браузерного профиля хранится отдельно от авторизации в localStorage; +каждая вкладка и перезагрузка страницы создают новый UUID подключения. +Без localStorage устройство получает временный UUID. Контекст страницы +полностью заменяется при heartbeat и очищается при навигации. Организатор +проверяется по JWT и правам игры, команда — по паролю и неотменённому участию, +экран — по существующей сессии; игру команды и экрана определяет сервер. +Credentials используются только для проверки и не сохраняются в реестре. + +Heartbeat отправляется сразу и каждые 20 секунд без параллельных запросов, +а также при возвращении вкладки и восстановлении сети. Ошибки не блокируют +игру и не показывают toast; повтор выполняется по интервалу. В prod ответ +содержит `enabled=false`, после чего браузер прекращает heartbeat; +список устройств в prod недоступен. + +Реестр хранится только в памяти backend: UUID, серверные времена, +последние IP/User-Agent и проверенные ID игры/команды/экрана. IP не участвует +в идентификации и авторизации. Связь актуальна строго менее 60 секунд; +устройство доступно, если доступно хотя бы одно его подключение в списке. +Подключения удаляются через 15 минут отсутствия при heartbeat или чтении. +Предел — 1024 подключения; новые отклоняются с `ResourceExhausted`, если +после очистки нет места. После перезапуска открытые браузеры регистрируются +заново. Времена устройства вычисляются по сохранённым подключениям. + +«Нет связи» означает отсутствие heartbeat сайта, а не отключение от Wi-Fi. +UUID обозначает браузерный профиль, а не физический телефон; очистка +localStorage создаёт новое устройство. История посещений, полный URL, +имена игроков, email и credentials в список не попадают. + ### Прежние домены и HTTPS (этап 3) Подготовка в репозитории готова; проверка реального Cudy и Android/iPhone без WAN diff --git a/docs/agent/TASKS.md b/docs/agent/TASKS.md index cb05ac3..e836549 100644 --- a/docs/agent/TASKS.md +++ b/docs/agent/TASKS.md @@ -27,7 +27,7 @@ ### ED-029 — Автономная площадка и панель организатора -- Статус: `IN_PROGRESS` — этап 3 принят пользователем без WAN; этапы 4–10 завершены; аппаратная приёмка этапа 8 подтверждена пользователем. +- Статус: `IN_PROGRESS` — этап 3 принят пользователем без WAN; этапы 4–12 завершены; аппаратная приёмка этапа 8 подтверждена пользователем. - Репозитории: корневой, `evening_detective_server`, `evening_detective_frontend`. - Цель: реализовать принятый пользователем план из 15 последовательных этапов: автономное окружение; локальные организаторы; прежние адреса через Cudy; пакет игры; экспорт со статусом офлайн-режима; локальный импорт; интерфейс передачи; полный билетный и игровой flow; пакет результатов; возврат онлайн; интерфейс возврата; устройства; панель состояния; сбор данных Cudy; Wi-Fi и испытание площадки. - Критерии: проверяемый результат каждого этапа; цикл «онлайн → ноутбук → проведение → онлайн» через интерфейс с сохранением QR, билетов и связей; повторные импорты без дублей; мониторинг устройств и Wi-Fi; полный прогон без WAN, 100 клиентов и проверка покрытия. @@ -78,7 +78,10 @@ - Ограничение этапа 10: подлинность ZIP криптографически не подтверждается. `nextval` PostgreSQL не откатывается: большие разрывы venue-ID расходуют sequence даже при отказе. Продвижение выполняется пакетами с пределом пять секунд, затем данные откатываются и выдаётся безопасная повторяемая ошибка; INT32-потолок отклоняется. Перед поддержкой таких площадок требуется расширение типов ID; лимит отмечен `shortcut:` в коде и контракте. - Результат этапа 11 (2026-10-11): подключены сервисы подготовки/применения, protobuf/OpenAPI и отдельные HTTP handlers `results-package/download` и `results-package/apply`. Venue выдаёт свежий ZIP завершённой импортированной игры после полной подготовки в файле 0600; prod принимает ограниченный поток до 1 ГиБ без обязательного Content-Length. Положительный ID и совпадение с manifest проверяются до повтора и изменений; временные файлы очищаются. Ответ содержит только gameID/packageID/appliedAt; безопасные ошибки 400/413/409/503, права организатора/администратора/разработчика. В настройках игры добавлены скачивание и применение ZIP, явное предупреждение о перезаписи данных/сценария и сохранении оформления/музыки, клиентская валидация, обновление авторизации, загрузка и блокировка повторного запроса. После успеха страница пересоздаётся и перечитывает данные без старых ответов/черновиков; повтор сообщает «Результаты приняты». Возврат остаётся доступен после снятия офлайн-отметки. Обновлены репозиторные proxy configs (1 ГиБ/30 минут), контракт и изолированный стенд; работающие серверы не менялись. - Проверки этапа 11: штатная генерация обоих репозиториев, gofmt, `go test ./...`, PostgreSQL/race-регрессии return repo/results/export service и HTTP поверх реального сервиса, frontend type-check/lint/lint:css/build, diff-проверки и ручное ревью по обоим стандартам прошли. Независимое ревью не выявило замечаний. `python3 tests/venue/flow.py --apply --browser` прошёл полный цикл реальных API/PostgreSQL/RustFS/Firefox для online (1440 px) и offline (390 px): кнопка свежего ZIP → страница prod → результаты, прежние ссылки/билеты, сохранённые название/описание, снятая офлайн-отметка, исправленный балл, перезаписанный онлайн-ответ, повтор старого ZIP после нового и перезапуска. Проверены чужой ID до/после применения, права, MIME, заявленный размер, поток без Content-Length, безопасные ошибки и очистка файлов. Аппаратный прогон на телефонах не повторялся. -- Следующий шаг: этап 12. Этап 11 завершён после контрольного прогона; общая задача остаётся `IN_PROGRESS` и не переносится в архив. ZIP остаётся неподписанным. Фактический ZIP 1 ГиБ, нагрузка на 100 клиентов и покрытие Wi-Fi не проверялись; развёртывание, commit/push не выполнялись. +- Результат этапа 12 (2026-10-11): добавлены потокобезопасный сервис присутствия в памяти, wiring, protobuf/OpenAPI `POST /api/devices/heartbeat` и `GET /api/devices` с фильтром игры. Проверяются UUID, взаимоисключающие контексты, JWT/существующие права игры, пароль команды и неотменённое участие без загрузки сценария/прогресса, существующая сессия экрана; ID игры команды/экрана определяет сервер. Реестр хранит только проверенные связи, UUID, серверные времена и последние IP/User-Agent; credentials не сохраняются и не выдаются. Полная замена/очистка контекста, граница связи строго менее 60 секунд, удаление через 15 минут при обновлении/чтении, предел 1024 с `ResourceExhausted` без вытеснения существующих записей. В prod heartbeat явно возвращает `enabled=false`, список недоступен. +- Браузер этапа 12: общий heartbeat в App сразу/каждые 20 секунд без параллельных запросов, при видимости/восстановлении сети и смене контекста. UUID профиля отдельно от авторизации в localStorage с временным fallback, UUID подключения на экземпляр приложения. Контекст страницы очищается при уходе, отозванный lifecycle не может восстановить старые связи; смена игры/пароля пересоздаёт page-компонент. Generated client и обновление JWT организатора переиспользованы; публичные credentials команды/экрана не запускают обновление JWT. После `enabled=false` polling прекращается. Ошибки не показывают toast и повторяются по интервалу. UI списка устройств не добавлен. +- Проверки этапа 12: штатная генерация обоих репозиториев, gofmt, `go test ./...`, race для presence service/app, frontend type-check/lint/lint:css/build, self-check UUID/store/composable и diff-проверки прошли. Unit-тесты с управляемыми часами покрывают повтор/вкладки/смену и очистку связей, границы 60 секунд/15 минут, лимит и конкурентное чтение/обновление; API-тесты — роли, режимы, safe errors и отсутствие секретов. `python3 tests/venue/flow.py --browser` прошёл изолированные реальные API/PostgreSQL/RustFS/Firefox: credentials/отменённое участие, UUID/поля, группировку вкладок игрока/организатора/экрана, фильтр и привязку экрана, SPA-навигацию, прекращение heartbeat и восстановление сети, повторную регистрацию штатным интервалом после перезапуска backend. Независимое ревью выявило два замечания (публичный heartbeat не должен обновлять JWT, обрезка User-Agent должна сохранять UTF-8); оба исправлены и покрыты регрессиями. Итоговое ручное ревью по Go/Frontend стандартам выполнено; замечаний не осталось. +- Следующий шаг: этап 13 — панель состояния площадки. Этап 12 завершён; общая задача остаётся `IN_PROGRESS` и не переносится в архив. ZIP остаётся неподписанным. Фактический ZIP 1 ГиБ, нагрузка на 100 клиентов и покрытие Wi-Fi не проверялись; развёртывание, commit/push не выполнялись. ### ED-024 — Общая дизайн-система фронтенда diff --git a/tests/venue/README.md b/tests/venue/README.md index cf6ef0d..4cbc359 100644 --- a/tests/venue/README.md +++ b/tests/venue/README.md @@ -117,3 +117,23 @@ backend, имитируя смену DNS; API-ответы не подменяю ответа применяется только для проверки блокировки кнопки. До пользовательских flow запускаются PostgreSQL/race-регрессии возврата и реального HTTP-применения. Работающие серверы не меняются; фактическая загрузка ZIP 1 ГиБ не проверяется. + +### Учёт устройств (этап 12) + +`python3 tests/venue/flow.py --browser` проверяет реальный публичный heartbeat, +права чтения, prod/venue, UUID и сочетания полей, неверные credentials, +отменённое участие, смену/очистку контекста, фильтр игры и отсутствие секретов. +Firefox проверяет игрока, организатора и экран в отдельных вкладках одного +профиля, привязку экрана, SPA-навигацию, новые подключения при перезагрузке, +остановку heartbeat, восстановление сети и повторную регистрацию через +интервал после перезапуска backend. Прогон изолирован от работающих серверов. + +Границы 60 секунд/15 минут, лимит 1024, конкурентное чтение/обновление и +безопасное ограничение User-Agent проверяются тестами сервиса с управляемыми +часами (`go test -race ./internal/services/device_presence_service ./internal/app`). +Фронтенд self-check: `node src/utils/__tests__/deviceID.test.ts`, +`node src/stores/__tests__/devicePresence.test.ts` и +`node src/composables/__tests__/devicePresence.test.ts`: временный UUID, +отозванные lifecycle-контексты, отсутствие параллельных heartbeat, +остановка после `enabled=false`, повтор ошибок по интервалу и независимость +публичных credentials от JWT. Нагрузочный и аппаратный прогоны не включены. diff --git a/tests/venue/device_presence.py b/tests/venue/device_presence.py new file mode 100644 index 0000000..3e0a521 --- /dev/null +++ b/tests/venue/device_presence.py @@ -0,0 +1,60 @@ +"""Stage 12 API checks in the disposable real venue.""" +import json +import uuid + +from flow import API, run, wait_for + + +def check_devices(stand, data): + venue, prod = stand.apis['venue'], stand.apis['prod'] + public = API(venue.base) + request = {'deviceID': str(uuid.uuid4()), 'connectionID': str(uuid.uuid4())} + disabled = API(prod.base).request('/api/devices/heartbeat', {}) + assert disabled['enabled'] is False and disabled['serverTime'] + prod.request('/api/devices', expected=400) + public.request('/api/devices', expected=401) + assert public.request('/api/devices/heartbeat', request)['enabled'] is True + public.request('/api/devices/heartbeat', request) + devices = venue.request('/api/devices')['devices'] + assert len(devices) == 1 and len(devices[0]['connections']) == 1 + original = devices[0]['connections'][0] + public.request('/api/devices/heartbeat', {**request, 'organizerGameID': data['game']}, expected=401) + public.request('/api/devices/heartbeat', {**request, 'deviceID': 'bad'}, expected=400) + for fields in ({'teamID': 0, 'password': 'secret'}, {'password': 'secret'}, {'screenID': '3'}, + {'organizerGameID': data['game'], 'teamID': 1, 'password': 'secret'}, + {'screenID': 'not-an-ID', 'accessToken': 'secret'}): + public.request('/api/devices/heartbeat', {**request, **fields}, expected=400) + team = data['teams'][0] + public.request('/api/devices/heartbeat', {**request, 'teamID': team['ID'], 'password': 'invalid'}, expected=404) + cancelled = data['teams'][2] + public.request('/api/devices/heartbeat', {**request, 'teamID': cancelled['ID'], 'password': cancelled['password']}, expected=404) + public.request('/api/devices/heartbeat', {**request, 'screenID': '2147483647', 'accessToken': 'invalid'}, expected=401) + assert venue.request('/api/devices')['devices'][0]['connections'][0] == original + public.request('/api/devices/heartbeat', {**request, 'teamID': team['ID'], 'password': team['password']}) + tab = {**request, 'connectionID': str(uuid.uuid4()), 'organizerGameID': data['game']} + venue.request('/api/devices/heartbeat', tab) + screen = public.request('/api/screens', {}) + venue.request(f'/api/screens/{screen["screenID"]}/bind', {**screen, 'gameID': data['game']}) + screen_tab = {**request, 'connectionID': str(uuid.uuid4()), **screen} + public.request('/api/devices/heartbeat', screen_tab) + devices = venue.request('/api/devices?gameID=' + str(data['game']))['devices'] + assert len(devices) == 1 and len(devices[0]['connections']) == 3 + assert {c['kind'] for c in devices[0]['connections']} == {'organizer', 'team', 'screen'} + serialized = json.dumps(devices) + for secret in (team['password'], screen['accessToken'], 'password', 'accessToken', 'email', 'URL'): + assert secret not in serialized + public.request('/api/devices/heartbeat', request) + assert len(venue.request('/api/devices?gameID=' + str(data['game']))['devices'][0]['connections']) == 2 + browser = next(c for c in venue.request('/api/devices')['devices'][0]['connections'] if c['ID'] == request['connectionID']) + assert browser['kind'] == 'browser' and not any(browser.get(key) for key in ('gameID', 'teamID', 'screenID')) + # The stand uses the same test-only JWT signing key for both servers. + reader = API(venue.base) + reader.token = prod.token + assert reader.request('/api/devices')['devices'] + run('docker', 'stop', stand.name + '-venue-app') + run('docker', 'start', stand.name + '-venue-app') + wait_for(lambda: public.request('/api/test/ping') == {}, 'presence backend restart') + assert not venue.request('/api/devices')['devices'] + public.request('/api/devices/heartbeat', request) + assert len(venue.request('/api/devices')['devices']) == 1 + print('PASS: stage 12 public API, UUID/context validation, credentials/cancellation, tabs/filter/clearing, safe output, prod/venue and backend restart', flush=True) diff --git a/tests/venue/flow.py b/tests/venue/flow.py index 32357cb..4bc7165 100644 --- a/tests/venue/flow.py +++ b/tests/venue/flow.py @@ -406,6 +406,8 @@ def main(): (stand.directory / f'game-{data["game"]}.zip').write_bytes(package) imported = stand.apis['venue'].request('/api/games/import', package) assert imported['gameID'] == data['game'] and not imported['repeated'] + from device_presence import check_devices + check_devices(stand, fixtures[0]) for data in fixtures: exercise(stand, data) if args.results: diff --git a/tests/venue/flow_browser.py b/tests/venue/flow_browser.py index b336b6e..0ec8cfb 100644 --- a/tests/venue/flow_browser.py +++ b/tests/venue/flow_browser.py @@ -98,6 +98,65 @@ def check_browser_flow(stand, fixtures): click('Вход') wait("location.pathname==='/office'", 'login') + def check_presence(data): + from flow import run, wait_for + api = stand.apis['venue'] + device_id = js("return localStorage.getItem('evening-detective-device-ID')") + assert device_id + + def connections(): + devices = api.request('/api/devices')['devices'] + return next((d['connections'] for d in devices if d['ID'] == device_id), []) + + def has_kind(kind): + return any(c['kind'] == kind for c in connections()) + + wait_for(lambda: has_kind('organizer'), 'organizer presence') + organizer_window = command('WebDriver:GetWindowHandle') + if isinstance(organizer_window, dict): + organizer_window = organizer_window['value'] + team_window = command('WebDriver:NewWindow', {'type': 'tab'})['handle'] + command('WebDriver:SwitchToWindow', {'handle': team_window}) + captain = data['teams'][0] + navigate(f'/team-story/{captain["ID"]}?password={urllib.parse.quote(captain["password"])}') + wait_for(lambda: has_kind('team'), 'player presence') + screen_window = command('WebDriver:NewWindow', {'type': 'tab'})['handle'] + command('WebDriver:SwitchToWindow', {'handle': screen_window}) + navigate('/screen') + wait_for(lambda: has_kind('screen'), 'screen presence') + screen_connection = next(c for c in connections() if c['kind'] == 'screen') + # Bind the actual browser session, whose credentials stay inside the browser. + saved = js("return JSON.parse(sessionStorage.getItem('evening-detective-screen-session'))") + assert saved['screenID'] == str(screen_connection['screenID']) + api.request(f'/api/screens/{saved["screenID"]}/bind', {**saved, 'gameID': data['game']}) + js("window.dispatchEvent(new Event('online'));return true;") + wait_for(lambda: any(c['ID'] == screen_connection['ID'] and c.get('gameID') == data['game'] for c in connections()), 'bound screen presence') + assert {c['kind'] for c in connections()} >= {'organizer', 'team', 'screen'} + command('WebDriver:CloseWindow') + command('WebDriver:SwitchToWindow', {'handle': team_window}) + command('WebDriver:CloseWindow') + command('WebDriver:SwitchToWindow', {'handle': organizer_window}) + old_id = next(c['ID'] for c in connections() if c['kind'] == 'organizer') + click('Главная') + wait("location.pathname==='/'", 'SPA navigation') + wait_for(lambda: any(c['ID'] == old_id and c['kind'] == 'browser' for c in connections()), 'SPA clears context on same connection') + # Drop only heartbeat requests; gameplay and authorization remain live. + js("window.presenceFetch=window.fetch;window.fetch=(url,options)=>String(url).includes('/devices/heartbeat')?Promise.reject(new Error('test offline')):window.presenceFetch(url,options);return true;") + before = next(c['lastSeenAt'] for c in connections() if c['ID'] == old_id) + time.sleep(22) + assert next(c['lastSeenAt'] for c in connections() if c['ID'] == old_id) == before + js("window.fetch=window.presenceFetch;window.dispatchEvent(new Event('online'));return true;") + wait_for(lambda: next(c['lastSeenAt'] for c in connections() if c['ID'] == old_id) != before, 'network recovery') + run('docker', 'stop', stand.name + '-venue-app') + run('docker', 'start', stand.name + '-venue-app') + wait_for(lambda: api.request('/api/test/ping') == {}, 'browser backend restart') + wait_for(lambda: any(c['ID'] == old_id for c in connections()), 'automatic interval re-registration', 30) + navigate(f'/games/{data["game"]}') + wait_for(lambda: has_kind('organizer'), 'reloaded organizer presence') + new_id = next(c['ID'] for c in connections() if c['kind'] == 'organizer') + assert new_id != old_id and js("return localStorage.getItem('evening-detective-device-ID')") == device_id + print('PASS: stage 12 real Firefox organizer/player/screen, grouped tabs, SPA context clearing, reload UUID, stopped heartbeat/network recovery and interval re-registration after restart', flush=True) + command('WebDriver:NewSession', {'capabilities': {'alwaysMatch': {'acceptInsecureCerts': True}}}) for width, data in zip((1440, 390), fixtures, strict=True): game, captain = data['game'], data['teams'][0] @@ -132,6 +191,8 @@ def check_browser_flow(stand, fixtures): assert js("return document.querySelector('[role=dialog]').contains(document.activeElement)"), 'Modal lost keyboard focus' click('Открыть игру') wait(f"location.pathname==='/games/{game}'", 'open imported game') + if width == 1440: + check_presence(data) navigate(f'/games/{game}?tab=payment') wait("document.body.innerText.includes('Проверить билет')", 'ticket checking') click('Проверить билет')