Files
remnawave-bedolaga-telegram…/tests/webserver/test_remnawave_webhook.py
T
Fringg 977950b97f fix: address review issues in PR #2829 webhook intentional deletion guard
5 issues found by review agents and fixed:

1. Intentional deletion guard was at top of process_event, skipping ALL
   cleanup (subscription URLs, server counts, expired marking). Moved
   check into _handle_user_deleted so cleanup still runs but re-creation
   is suppressed via subscription_still_valid=False.

2. Variable shadowing: telegram_id loop var in any() generator shadowed
   the outer telegram_id local. Renamed to tid/uid.

3. No hard cap on in-memory dicts: added _MAX_INTENTIONAL_ENTRIES=10000
   with early return in mark_intentional_panel_deletion.

4. mark_intentional_panel_deletion called inside for-loop with single
   UUID — race window if webhook from first delete arrives before
   second UUID is marked. Moved to before the loop with all UUIDs.

5. Tests: @pytest.mark.anyio → @pytest.mark.anyio('asyncio') for
   consistency. Replaced process_event(db=None) test with direct
   mark+detect unit tests + hard cap test.
2026-04-02 06:34:35 +03:00

140 lines
4.8 KiB
Python

import hashlib
import hmac
import json
from types import SimpleNamespace
from typing import Any
from unittest.mock import AsyncMock
import pytest
from starlette.requests import Request
from app.config import settings
from app.services.remnawave_webhook_service import RemnaWaveWebhookService
from app.webserver.remnawave_webhook import create_remnawave_webhook_router
@pytest.fixture(autouse=True)
def reset_remnawave_webhook_settings(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(settings, 'REMNAWAVE_WEBHOOK_ENABLED', True, raising=False)
monkeypatch.setattr(settings, 'REMNAWAVE_WEBHOOK_PATH', '/remnawave-webhook', raising=False)
monkeypatch.setattr(
settings,
'REMNAWAVE_WEBHOOK_SECRET',
'0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef',
raising=False,
)
RemnaWaveWebhookService._intentional_panel_deletions_by_uuid.clear()
RemnaWaveWebhookService._intentional_panel_deletions_by_telegram_id.clear()
def _get_route(router, path: str, method: str = 'POST'):
for route in router.routes:
if getattr(route, 'path', '') == path and method in getattr(route, 'methods', set()):
return route
raise AssertionError(f'Route {path} with method {method} not found')
def _build_request(path: str, body: bytes, headers: dict[str, str] | None = None) -> Request:
scope = {
'type': 'http',
'asgi': {'version': '3.0'},
'method': 'POST',
'path': path,
'headers': [(k.lower().encode('latin-1'), v.encode('latin-1')) for k, v in (headers or {}).items()],
}
async def receive() -> dict[str, Any]:
return {'type': 'http.request', 'body': body, 'more_body': False}
return Request(scope, receive)
def _signature(body: bytes) -> str:
secret = settings.REMNAWAVE_WEBHOOK_SECRET or ''
return hmac.new(secret.encode('utf-8'), body, hashlib.sha256).hexdigest()
@pytest.mark.anyio('asyncio')
async def test_remnawave_webhook_accepts_event_without_scope(monkeypatch: pytest.MonkeyPatch) -> None:
bot = AsyncMock()
process_event = AsyncMock(return_value=True)
service = SimpleNamespace(
process_event=process_event,
is_admin_event=lambda _event_name: True,
)
monkeypatch.setattr(
'app.webserver.remnawave_webhook.RemnaWaveWebhookService',
lambda _bot: service,
)
payload = {
'event': 'user.modified',
'data': {'uuid': 'user-123'},
'timestamp': '2026-03-30T12:00:00.000Z',
}
raw_body = json.dumps(payload).encode('utf-8')
router = create_remnawave_webhook_router(bot)
path = settings.REMNAWAVE_WEBHOOK_PATH
route = _get_route(router, path)
request = _build_request(
path,
raw_body,
headers={'X-Remnawave-Signature': _signature(raw_body)},
)
response = await route.endpoint(request)
assert response.status_code == 200
process_event.assert_awaited_once_with(None, 'user.modified', {'uuid': 'user-123'})
@pytest.mark.anyio('asyncio')
async def test_remnawave_webhook_rejects_payload_without_event() -> None:
bot = AsyncMock()
payload = {'data': {'uuid': 'user-123'}}
raw_body = json.dumps(payload).encode('utf-8')
router = create_remnawave_webhook_router(bot)
path = settings.REMNAWAVE_WEBHOOK_PATH
route = _get_route(router, path)
request = _build_request(
path,
raw_body,
headers={'X-Remnawave-Signature': _signature(raw_body)},
)
response = await route.endpoint(request)
assert response.status_code == 400
assert json.loads(response.body.decode('utf-8')) == {'status': 'error', 'reason': 'missing_event'}
def test_intentional_panel_deletion_guard_marks_and_detects() -> None:
"""Verify that mark + is_intentional round-trip works correctly."""
RemnaWaveWebhookService.mark_intentional_panel_deletion(
panel_uuids=['panel-user-123'],
telegram_id=8368498066,
)
assert RemnaWaveWebhookService._is_intentional_panel_deletion_event(
{'uuid': 'panel-user-123', 'telegramId': 8368498066}
)
# Unknown UUID should not match
assert not RemnaWaveWebhookService._is_intentional_panel_deletion_event(
{'uuid': 'unknown-uuid', 'telegramId': 99999}
)
def test_intentional_panel_deletion_guard_respects_hard_cap(monkeypatch: pytest.MonkeyPatch) -> None:
"""Verify that the guard stops accepting entries after hitting the cap."""
monkeypatch.setattr(RemnaWaveWebhookService, '_MAX_INTENTIONAL_ENTRIES', 3)
RemnaWaveWebhookService.mark_intentional_panel_deletion(panel_uuids=['a', 'b', 'c'])
# 3 entries — at capacity
RemnaWaveWebhookService.mark_intentional_panel_deletion(panel_uuids=['d'])
# 'd' should NOT be stored (cap reached)
assert 'd' not in RemnaWaveWebhookService._intentional_panel_deletions_by_uuid