This commit is contained in:
2026-10-11 13:47:50 +07:00
parent dad1e76680
commit c37d76937a
6 changed files with 184 additions and 2 deletions
+20
View File
@@ -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. Нагрузочный и аппаратный прогоны не включены.
+60
View File
@@ -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)
+2
View File
@@ -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:
+61
View File
@@ -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('Проверить билет')