feat: Add new endpoints for fetching user IPs and recap statistics

- Implemented `fetch_users_ips` and `get_fetch_users_ips_result` in `IpControlController`.
- Added `get_recap` endpoint in `SystemController`.
- Introduced new models for user IP fetching and recap statistics in `models/ip_control.py` and `models/system.py`.
- Updated existing models and enums to accommodate new features.
- Added tests for new endpoints and model validations.
This commit is contained in:
Artem 2026-03-28 19:51:37 +01:00
parent a880da073f
commit 3e6be1f4b4
No known key found for this signature in database
GPG key ID: 833485276B7902CE
18 changed files with 818 additions and 35 deletions

View file

@ -8,6 +8,8 @@ from remnawave.models import (
DropConnectionsResponseDto,
FetchIpsResponseDto,
FetchIpsResultResponseDto,
FetchUsersIpsResponseDto,
FetchUsersIpsResultResponseDto,
)
from remnawave.rapid import BaseController, get, post
@ -41,6 +43,33 @@ class IpControlController(BaseController):
"""
...
@post("/ip-control/fetch-users-ips/{nodeUuid}", response_class=FetchUsersIpsResponseDto)
async def fetch_users_ips(
self,
nodeUuid: Annotated[str, Path(description="UUID of the node")],
) -> FetchUsersIpsResponseDto:
"""Request IP List for all users on a node.
Starts a background job that queries the specified node for the IPs
of all connected users. The returned ``job_id`` must be passed to
:meth:`get_fetch_users_ips_result` to retrieve the actual list once
the job is complete.
"""
...
@get("/ip-control/fetch-users-ips/result/{jobId}", response_class=FetchUsersIpsResultResponseDto)
async def get_fetch_users_ips_result(
self,
jobId: Annotated[str, Path(description="Job ID returned by fetch_users_ips")],
) -> FetchUsersIpsResultResponseDto:
"""Get Users IP List Result by Job ID.
Poll this endpoint after calling :meth:`fetch_users_ips`. When
``is_completed`` is ``True`` the ``result`` field contains per-user
IP lists.
"""
...
@post("/ip-control/drop-connections", response_class=DropConnectionsResponseDto)
async def drop_connections(
self,

View file

@ -11,7 +11,8 @@ from remnawave.models import (
EncryptHappCryptoLinkResponseDto,
DebugSrrMatcherRequestDto,
DebugSrrMatcherResponseDto,
GetMetadataResponseDto
GetMetadataResponseDto,
GetRecapResponseDto,
)
from remnawave.rapid import BaseController, get, post
@ -80,4 +81,11 @@ class SystemController(BaseController):
body: Annotated[DebugSrrMatcherRequestDto, PydanticBody()],
) -> DebugSrrMatcherResponseDto:
"""Test SRR Matcher"""
...
@get("/system/stats/recap", response_class=GetRecapResponseDto)
async def get_recap(
self,
) -> GetRecapResponseDto:
"""Get Recap"""
...

View file

@ -21,6 +21,12 @@ from remnawave.models import (
UpdateUserRequestDto,
UpdateUserResponseDto,
RevokeUserRequestDto,
RevokeUserSubscriptionResponseDto,
DisableUserResponseDto,
EnableUserResponseDto,
ResetUserTrafficResponseDto,
ResolveUserRequestBodyDto,
ResolveUserResponseDto,
)
from remnawave.rapid import BaseController, delete, get, patch, post
@ -65,36 +71,36 @@ class UsersController(BaseController):
"""Delete user"""
...
@post("/users/{uuid}/actions/revoke", response_class=UpdateUserResponseDto)
@post("/users/{uuid}/actions/revoke", response_class=RevokeUserSubscriptionResponseDto)
async def revoke_user_subscription(
self,
uuid: Annotated[str, Path(description="UUID of the user")],
body: Optional[Annotated[RevokeUserRequestDto, PydanticBody()]] = None,
) -> UpdateUserResponseDto:
) -> RevokeUserSubscriptionResponseDto:
"""Revoke User Subscription"""
...
@post("/users/{uuid}/actions/disable", response_class=UpdateUserResponseDto)
@post("/users/{uuid}/actions/disable", response_class=DisableUserResponseDto)
async def disable_user(
self,
uuid: Annotated[str, Path(description="UUID of the user")],
) -> UpdateUserResponseDto:
) -> DisableUserResponseDto:
"""Disable User"""
...
@post("/users/{uuid}/actions/enable", response_class=UpdateUserResponseDto)
@post("/users/{uuid}/actions/enable", response_class=EnableUserResponseDto)
async def enable_user(
self,
uuid: Annotated[str, Path(description="UUID of the user")],
) -> UpdateUserResponseDto:
) -> EnableUserResponseDto:
"""Enable User"""
...
@post("/users/{uuid}/actions/reset-traffic", response_class=UpdateUserResponseDto)
@post("/users/{uuid}/actions/reset-traffic", response_class=ResetUserTrafficResponseDto)
async def reset_user_traffic(
self,
uuid: Annotated[str, Path(description="UUID of the user")],
) -> UpdateUserResponseDto:
) -> ResetUserTrafficResponseDto:
"""Reset User Traffic"""
...
@ -186,4 +192,12 @@ class UsersController(BaseController):
tag: Annotated[str, Path(description="Tag of the user")],
) -> TagUserResponseDto:
"""Get Users By Tag"""
...
@post("/users/resolve", response_class=ResolveUserResponseDto)
async def resolve_user(
self,
body: Annotated[ResolveUserRequestBodyDto, PydanticBody()],
) -> ResolveUserResponseDto:
"""Resolve user by any identifier (uuid, id, shortUuid, username)"""
...

View file

@ -6,7 +6,7 @@ from .security_layer import SecurityLayer
from .template_type import TemplateType
from .users import TrafficLimitStrategy, UserStatus
from .webhook import (
TCRMEvents, TErrorsEvents, TNodeEvents, TResetPeriods, TServiceEvents, TUserEvents, TUserHwidDevicesEvents, TUsersStatus
TCRMEvents, TErrorsEvents, TNodeEvents, TResetPeriods, TServiceEvents, TUserEvents, TUserHwidDevicesEvents, TUsersStatus, TTorrentBlockerEvents
)
from .auth import OAuth2Provider
from .subscriptions_settings import (
@ -41,4 +41,5 @@ __all__ = [
"TUserHwidDevicesEvents",
"TResetPeriods",
"TUsersStatus",
"TTorrentBlockerEvents",
]

View file

@ -2,7 +2,9 @@ from enum import StrEnum
class OAuth2Provider(StrEnum):
"""OAuth2 Provider enum"""
TELEGRAM = "telegram"
GITHUB = "github"
POCKETID = "pocketid"
YANDEX = "yandex"
KEYCLOAK = "keycloak"
KEYCLOAK = "keycloak"
GENERIC = "generic"

View file

@ -7,4 +7,5 @@ class TemplateType(StrEnum):
SINGBOX_LEGACY = "SINGBOX_LEGACY"
MIHOMO = "MIHOMO"
XRAY_JSON = "XRAY_JSON"
XRAY_BASE64 = "XRAY_BASE64"
CLASH = "CLASH"

View file

@ -13,3 +13,4 @@ class TrafficLimitStrategy(StrEnum):
DAY = "DAY"
WEEK = "WEEK"
MONTH = "MONTH"
MONTH_ROLLING = "MONTH_ROLLING"

View file

@ -57,5 +57,9 @@ TUserHwidDevicesEvents = Literal[
"user_hwid_devices.deleted",
]
TResetPeriods = Literal["NO_RESET", "DAY", "WEEK", "MONTH"]
TTorrentBlockerEvents = Literal[
"torrent_blocker.report",
]
TResetPeriods = Literal["NO_RESET", "DAY", "WEEK", "MONTH", "MONTH_ROLLING"]
TUsersStatus = Literal["DISABLED", "LIMITED", "EXPIRED", "ACTIVE"]

View file

@ -236,6 +236,7 @@ from .subscriptions_settings import (
ResponseRule,
ResponseRuleCondition,
ResponseRules,
ResponseRulesSettings,
SubscriptionSettingsResponseDto,
SubscriptionType,
UpdateSubscriptionSettingsRequestDto,
@ -276,19 +277,24 @@ from .system import (
StatusCounts,
UsersStatistic,
GetNodesMetricsResponseDto,
GetX25519KeyPairResponseDto,
GetX25519KeyPairResponseDto,
X25519KeyPair,
DebugSrrMatcherRequestDto,
DebugSrrMatcherResponseDto,
EncryptHappCryptoLinkRequestDto,
EncryptHappCryptoLinkResponseDto,
GetMetadataResponseDto
GetMetadataResponseDto,
GetRecapResponseDto,
RecapThisMonth,
RecapTotal,
)
from .users import (
# Request DTOs
CreateUserRequestDto,
UpdateUserRequestDto,
RevokeUserRequestDto,
ResolveUserRequestBodyDto,
ResolveUserResponseDto,
# Response DTOs - Single User
CreateUserResponseDto,
@ -378,7 +384,7 @@ from .subscription_request_history import (
SubscriptionRequestHistoryStatsData
)
from .webhook import (
UserEventDto,
UserEventDto,
UserHwidDeviceEventDto,
HwidUserDeviceDto,
LastConnectedNodeDto,
@ -394,8 +400,15 @@ from .webhook import (
NodeEventDto,
CustomErrorEventDto,
CrmEventDto,
TorrentBlockerEventDto,
TorrentBlockerReportDto,
WebhookPayloadDto,
UserTrafficDto
UserTrafficDto,
NodeSystemDto,
NodeSystemInfoDto,
NodeSystemStatsDto,
NodeSystemInterfaceDto,
NodeVersionsDto,
)
from .passkeys import (
DeletePasskeyRequestDto,
@ -518,6 +531,8 @@ from .ip_control import (
# Response DTOs
FetchIpsResponseDto,
FetchIpsResultResponseDto,
FetchUsersIpsResponseDto,
FetchUsersIpsResultResponseDto,
DropConnectionsResponseDto,
# Data models
FetchIpsJobData,
@ -525,6 +540,11 @@ from .ip_control import (
FetchIpsNodeResult,
FetchIpsResult,
FetchIpsResultData,
FetchUsersIpsJobData,
FetchUsersIpsUserIp,
FetchUsersIpsUser,
FetchUsersIpsResult,
FetchUsersIpsResultData,
DropConnectionsResponseData,
)
@ -546,10 +566,6 @@ __all__ = [
"VerifyPasskeyAuthenticationRequestDto",
"VerifyPasskeyAuthenticationResponseDto",
"GetPasskeyAuthenticationOptionsResponseDto",
"AuthenticationSettings",
"PasskeyAuthenticationSettings",
"OAuth2ProvidersSettings",
"PasswordAuthenticationSettings",
"BrandingSettings",
# Nodes models
"CreateNodeRequestDto",
@ -639,6 +655,7 @@ __all__ = [
"ResponseRule",
"ResponseRuleCondition",
"ResponseRules",
"ResponseRulesSettings",
# Subscription template models
"GetTemplateResponseDto",
"TemplateResponseDto",
@ -675,6 +692,9 @@ __all__ = [
"EncryptHappCryptoLinkRequestDto",
"EncryptHappCryptoLinkResponseDto",
"GetMetadataResponseDto",
"GetRecapResponseDto",
"RecapThisMonth",
"RecapTotal",
# XRay config models
"ConfigResponseDto", # Legacy alias
"GetConfigResponseDto",
@ -741,6 +761,8 @@ __all__ = [
"CreateUserRequestDto",
"UpdateUserRequestDto",
"RevokeUserRequestDto",
"ResolveUserRequestBodyDto",
"ResolveUserResponseDto",
"CreateUserResponseDto",
"UpdateUserResponseDto",
"GetUserByUuidResponseDto",
@ -907,6 +929,17 @@ __all__ = [
# CRM EVENTS
"CrmEventDto",
# TORRENT BLOCKER EVENTS
"TorrentBlockerEventDto",
"TorrentBlockerReportDto",
# NODE SYSTEM/VERSIONS
"NodeSystemDto",
"NodeSystemInfoDto",
"NodeSystemStatsDto",
"NodeSystemInterfaceDto",
"NodeVersionsDto",
# WEBHOOK PAYLOAD
"WebhookPayloadDto",
@ -1001,6 +1034,13 @@ __all__ = [
"FetchIpsNodeResult",
"FetchIpsResult",
"FetchIpsResultData",
"FetchUsersIpsResponseDto",
"FetchUsersIpsResultResponseDto",
"FetchUsersIpsJobData",
"FetchUsersIpsUserIp",
"FetchUsersIpsUser",
"FetchUsersIpsResult",
"FetchUsersIpsResultData",
"DropConnectionsResponseData",
# Metadata models

View file

@ -122,11 +122,9 @@ class DeleteInfraProviderByUuidResponseDto(BaseModel):
# Billing History models
class CreateInfraBillingHistoryRecordRequestDto(BaseModel):
"""Модель для создания записи истории биллинга"""
node_uuid: UUID = Field(serialization_alias="nodeUuid")
provider_uuid: UUID = Field(serialization_alias="providerUuid")
amount: float
description: Optional[str] = None
payment_date: datetime = Field(serialization_alias="paymentDate")
amount: float = Field(ge=0)
billed_at: datetime = Field(serialization_alias="billedAt")
class CreateInfraBillingHistoryRecordResponseDto(InfraBillingHistoryDto):
@ -150,7 +148,7 @@ class DeleteInfraBillingHistoryRecordByUuidResponseDto(BaseModel):
class CreateInfraBillingNodeRequestDto(BaseModel):
node_uuid: UUID = Field(serialization_alias="nodeUuid")
provider_uuid: UUID = Field(serialization_alias="providerUuid")
next_billing_at: datetime = Field(serialization_alias="nextBillingAt")
next_billing_at: Optional[datetime] = Field(None, serialization_alias="nextBillingAt")
# ИСПРАВЛЕНО: API возвращает список всех billing nodes после создания, а не один созданный

View file

@ -1,3 +1,4 @@
from datetime import datetime
from typing import Annotated, List, Literal, Optional, Union
from uuid import UUID
@ -114,6 +115,55 @@ TargetNodes = Annotated[
]
# ─────────────────────────────────────────────────────────────────────────────
# Fetch Users IPs step 1: start the job
# ─────────────────────────────────────────────────────────────────────────────
class FetchUsersIpsJobData(BaseModel):
"""Returned job ID after requesting users IP fetch"""
job_id: str = Field(alias="jobId")
class FetchUsersIpsResponseDto(FetchUsersIpsJobData):
"""Response for POST /api/ip-control/fetch-users-ips/{nodeUuid}"""
pass
# ─────────────────────────────────────────────────────────────────────────────
# Fetch Users IPs step 2: poll the job result
# ─────────────────────────────────────────────────────────────────────────────
class FetchUsersIpsUserIp(BaseModel):
"""IP entry with last seen timestamp"""
ip: str
last_seen: datetime = Field(alias="lastSeen")
class FetchUsersIpsUser(BaseModel):
"""Per-user IP list"""
user_id: str = Field(alias="userId")
ips: List[FetchUsersIpsUserIp]
class FetchUsersIpsResult(BaseModel):
"""Full result payload when the job is completed"""
success: bool
node_uuid: UUID = Field(alias="nodeUuid")
users: List[FetchUsersIpsUser]
class FetchUsersIpsResultData(BaseModel):
"""Job state + optional result"""
is_completed: bool = Field(alias="isCompleted")
is_failed: bool = Field(alias="isFailed")
result: Optional[FetchUsersIpsResult] = None
class FetchUsersIpsResultResponseDto(FetchUsersIpsResultData):
"""Response for GET /api/ip-control/fetch-users-ips/result/{jobId}"""
pass
class DropConnectionsRequestDto(BaseModel):
"""Request body for POST /api/ip-control/drop-connections"""
drop_by: DropBy = Field(

View file

@ -74,10 +74,20 @@ class ResponseRule(BaseModel):
)
class ResponseRulesSettings(BaseModel):
"""Settings for response rules"""
model_config = {"populate_by_name": True}
disable_subscription_access_by_path: Optional[bool] = Field(
None, alias="disableSubscriptionAccessByPath"
)
class ResponseRules(BaseModel):
"""Response rules configuration"""
version: ResponseRuleVersion
rules: List[ResponseRule]
settings: Optional[ResponseRulesSettings] = None
class CustomRemarksDto(BaseModel):

View file

@ -213,6 +213,27 @@ class DebugSrrMatcherData(BaseModel):
class DebugSrrMatcherResponseDto(DebugSrrMatcherData):
pass
class RecapThisMonth(BaseModel):
users: float
traffic: str
class RecapTotal(BaseModel):
users: float
nodes: float
traffic: str
nodes_ram: str = Field(alias="nodesRam")
nodes_cpu_cores: float = Field(alias="nodesCpuCores")
distinct_countries: float = Field(alias="distinctCountries")
class GetRecapResponseDto(BaseModel):
this_month: RecapThisMonth = Field(alias="thisMonth")
total: RecapTotal
version: str
init_date: datetime.datetime = Field(alias="initDate")
class BuildInfo(BaseModel):
"""Build information"""
time: str

View file

@ -203,6 +203,22 @@ class RevokeUserRequestDto(BaseModel):
description="Optional. If true, only passwords will be revoked without changing the short UUID.",
)
class ResolveUserRequestBodyDto(BaseModel):
"""Request DTO for resolving a user by any identifier"""
uuid: Optional[UUID] = None
id: Optional[int] = None
short_uuid: Optional[str] = Field(None, serialization_alias="shortUuid")
username: Optional[str] = None
class ResolveUserResponseDto(BaseModel):
"""Response DTO for resolved user"""
uuid: UUID
username: str
id: int
short_uuid: str = Field(alias="shortUuid")
class SubscriptionRequestRecord(BaseModel):
"""Subscription request history record"""
id: int

View file

@ -4,7 +4,7 @@ from uuid import UUID
from pydantic import BaseModel, Field
from pydantic.alias_generators import to_camel
from remnawave.enums import (
TUsersStatus, TUserEvents, TUserHwidDevicesEvents, TServiceEvents, TNodeEvents, TErrorsEvents, TCRMEvents, TResetPeriods
TUsersStatus, TUserEvents, TUserHwidDevicesEvents, TServiceEvents, TNodeEvents, TErrorsEvents, TCRMEvents, TTorrentBlockerEvents, TResetPeriods
)
# ---------------- USER ---------------- #
@ -223,6 +223,55 @@ class WebhookNodeConfigProfileDto(BaseModel):
model_config = {"alias_generator": to_camel, "populate_by_name": True}
class NodeSystemInfoDto(BaseModel):
arch: str
cpus: int
cpu_model: str
memory_total: float
hostname: str
platform: str
release: str
type: str
version: str
network_interfaces: List[str]
model_config = {"alias_generator": to_camel, "populate_by_name": True}
class NodeSystemInterfaceDto(BaseModel):
interface: str
rx_bytes_per_sec: float
tx_bytes_per_sec: float
rx_total: float
tx_total: float
model_config = {"alias_generator": to_camel, "populate_by_name": True}
class NodeSystemStatsDto(BaseModel):
memory_free: float
memory_used: float
uptime: float
load_avg: List[float]
interface: Optional[NodeSystemInterfaceDto] = None
model_config = {"alias_generator": to_camel, "populate_by_name": True}
class NodeSystemDto(BaseModel):
info: NodeSystemInfoDto
stats: NodeSystemStatsDto
model_config = {"alias_generator": to_camel, "populate_by_name": True}
class NodeVersionsDto(BaseModel):
xray: str
node: str
model_config = {"alias_generator": to_camel, "populate_by_name": True}
class NodeDto(BaseModel):
uuid: UUID
name: str
@ -234,11 +283,8 @@ class NodeDto(BaseModel):
last_status_change: Optional[datetime] = None
last_status_message: Optional[str] = None
xray_version: Optional[str] = None
node_version: Optional[str] = None
xray_uptime: str
users_online: Optional[int] = None
xray_uptime: float = 0
users_online: Optional[float] = None
is_traffic_tracking_active: bool
traffic_reset_day: Optional[int] = None
@ -252,10 +298,6 @@ class NodeDto(BaseModel):
tags: List[str] = Field(default_factory=list)
cpu_count: Optional[int] = None
cpu_model: Optional[str] = None
total_ram: Optional[str] = None
created_at: datetime
updated_at: datetime
@ -264,6 +306,10 @@ class NodeDto(BaseModel):
provider_uuid: Optional[UUID] = None
provider: Optional[InfraProviderDto] = None
active_plugin_uuid: Optional[UUID] = None
system: Optional[NodeSystemDto] = None
versions: Optional[NodeVersionsDto] = None
model_config = {"alias_generator": to_camel, "populate_by_name": True}
# Backward-compat shims for code that used the flat fields directly
@ -318,6 +364,21 @@ class CrmEventDto(BaseModel):
model_config = {"alias_generator": to_camel, "populate_by_name": True}
# ---------------- TORRENT BLOCKER EVENTS ---------------- #
class TorrentBlockerReportDto(BaseModel):
node: NodeDto
model_config = {"alias_generator": to_camel, "populate_by_name": True}
class TorrentBlockerEventDto(BaseModel):
event_name: TTorrentBlockerEvents
data: TorrentBlockerReportDto
model_config = {"alias_generator": to_camel, "populate_by_name": True}
# ---------------- WEBHOOK PAYLOAD ---------------- #
class WebhookPayloadDto(BaseModel):

View file

@ -0,0 +1,119 @@
"""Tests that all required endpoints exist in controllers."""
import pytest
import inspect
from remnawave.controllers.users import UsersController
from remnawave.controllers.system import SystemController
from remnawave.controllers.ip_control import IpControlController
class TestUsersControllerEndpoints:
def test_has_resolve_user(self):
assert hasattr(UsersController, "resolve_user")
assert callable(getattr(UsersController, "resolve_user"))
def test_has_revoke_user_subscription(self):
assert hasattr(UsersController, "revoke_user_subscription")
def test_has_disable_user(self):
assert hasattr(UsersController, "disable_user")
def test_has_enable_user(self):
assert hasattr(UsersController, "enable_user")
def test_has_reset_user_traffic(self):
assert hasattr(UsersController, "reset_user_traffic")
def test_has_create_user(self):
assert hasattr(UsersController, "create_user")
def test_has_update_user(self):
assert hasattr(UsersController, "update_user")
def test_has_delete_user(self):
assert hasattr(UsersController, "delete_user")
def test_has_get_all_users(self):
assert hasattr(UsersController, "get_all_users")
def test_has_get_user_by_uuid(self):
assert hasattr(UsersController, "get_user_by_uuid")
def test_has_get_user_by_short_uuid(self):
assert hasattr(UsersController, "get_user_by_short_uuid")
def test_has_get_user_by_username(self):
assert hasattr(UsersController, "get_user_by_username")
def test_has_get_user_by_id(self):
assert hasattr(UsersController, "get_user_by_id")
def test_has_get_users_by_telegram_id(self):
assert hasattr(UsersController, "get_users_by_telegram_id")
def test_has_get_users_by_email(self):
assert hasattr(UsersController, "get_users_by_email")
def test_has_get_users_by_tag(self):
assert hasattr(UsersController, "get_users_by_tag")
def test_has_get_all_tags(self):
assert hasattr(UsersController, "get_all_tags")
def test_has_get_user_accessible_nodes(self):
assert hasattr(UsersController, "get_user_accessible_nodes")
def test_has_get_user_subscription_request_history(self):
assert hasattr(UsersController, "get_user_subscription_request_history")
class TestSystemControllerEndpoints:
def test_has_get_recap(self):
assert hasattr(SystemController, "get_recap")
assert callable(getattr(SystemController, "get_recap"))
def test_has_get_metadata(self):
assert hasattr(SystemController, "get_metadata")
def test_has_get_stats(self):
assert hasattr(SystemController, "get_stats")
def test_has_get_bandwidth_stats(self):
assert hasattr(SystemController, "get_bandwidth_stats")
def test_has_get_nodes_statistics(self):
assert hasattr(SystemController, "get_nodes_statistics")
def test_has_get_health(self):
assert hasattr(SystemController, "get_health")
def test_has_get_nodes_metrics(self):
assert hasattr(SystemController, "get_nodes_metrics")
def test_has_get_x25519_key_pair(self):
assert hasattr(SystemController, "get_x25519_key_pair")
def test_has_encrypt_happ_crypto_link(self):
assert hasattr(SystemController, "encrypt_happ_crypto_link")
def test_has_debug_srr_matcher(self):
assert hasattr(SystemController, "debug_srr_matcher")
class TestIpControlControllerEndpoints:
def test_has_fetch_user_ips(self):
assert hasattr(IpControlController, "fetch_user_ips")
def test_has_get_fetch_ips_result(self):
assert hasattr(IpControlController, "get_fetch_ips_result")
def test_has_fetch_users_ips(self):
assert hasattr(IpControlController, "fetch_users_ips")
assert callable(getattr(IpControlController, "fetch_users_ips"))
def test_has_get_fetch_users_ips_result(self):
assert hasattr(IpControlController, "get_fetch_users_ips_result")
assert callable(getattr(IpControlController, "get_fetch_users_ips_result"))
def test_has_drop_connections(self):
assert hasattr(IpControlController, "drop_connections")

131
tests/test_enums.py Normal file
View file

@ -0,0 +1,131 @@
"""Tests for enum completeness against the OpenAPI spec."""
import pytest
from remnawave.enums import (
ALPN,
ClientType,
Fingerprint,
OAuth2Provider,
SecurityLayer,
TemplateType,
TrafficLimitStrategy,
UserStatus,
ResponseRuleConditionOperator,
ResponseRuleOperator,
ResponseRuleVersion,
ResponseType,
SubscriptionType,
TTorrentBlockerEvents,
)
class TestOAuth2Provider:
def test_has_telegram(self):
assert OAuth2Provider.TELEGRAM == "telegram"
def test_has_generic(self):
assert OAuth2Provider.GENERIC == "generic"
def test_has_github(self):
assert OAuth2Provider.GITHUB == "github"
def test_has_pocketid(self):
assert OAuth2Provider.POCKETID == "pocketid"
def test_has_yandex(self):
assert OAuth2Provider.YANDEX == "yandex"
def test_has_keycloak(self):
assert OAuth2Provider.KEYCLOAK == "keycloak"
def test_all_values(self):
expected = {"telegram", "github", "pocketid", "yandex", "keycloak", "generic"}
actual = {v.value for v in OAuth2Provider}
assert actual == expected
class TestTemplateType:
def test_has_xray_base64(self):
assert TemplateType.XRAY_BASE64 == "XRAY_BASE64"
def test_has_xray_json(self):
assert TemplateType.XRAY_JSON == "XRAY_JSON"
def test_all_api_values(self):
api_values = {"XRAY_JSON", "XRAY_BASE64", "MIHOMO", "STASH", "CLASH", "SINGBOX"}
actual = {v.value for v in TemplateType}
assert api_values.issubset(actual)
class TestTrafficLimitStrategy:
def test_has_month_rolling(self):
assert TrafficLimitStrategy.MONTH_ROLLING == "MONTH_ROLLING"
def test_all_api_values(self):
api_values = {"NO_RESET", "DAY", "WEEK", "MONTH", "MONTH_ROLLING"}
actual = {v.value for v in TrafficLimitStrategy}
assert api_values == actual
class TestUserStatus:
def test_all_values(self):
expected = {"ACTIVE", "DISABLED", "LIMITED", "EXPIRED"}
actual = {v.value for v in UserStatus}
assert actual == expected
class TestClientType:
def test_api_values_present(self):
api_values = {"stash", "singbox", "mihomo", "json", "v2ray-json", "clash"}
actual = {v.value for v in ClientType}
assert api_values.issubset(actual)
class TestSecurityLayer:
def test_all_values(self):
expected = {"DEFAULT", "TLS", "NONE"}
actual = {v.value for v in SecurityLayer}
assert actual == expected
class TestFingerprint:
def test_all_api_values(self):
api_values = {"chrome", "firefox", "safari", "ios", "android", "edge", "qq", "random", "randomized"}
actual = {v.value for v in Fingerprint}
assert api_values == actual
class TestALPN:
def test_has_h3(self):
assert "h3" in {v.value for v in ALPN}
def test_has_h2(self):
assert "h2" in {v.value for v in ALPN}
class TestResponseRuleOperator:
def test_all_values(self):
expected = {"AND", "OR"}
actual = {v.value for v in ResponseRuleOperator}
assert actual == expected
class TestResponseRuleConditionOperator:
def test_all_values(self):
expected = {
"EQUALS", "NOT_EQUALS", "CONTAINS", "NOT_CONTAINS",
"STARTS_WITH", "NOT_STARTS_WITH", "ENDS_WITH", "NOT_ENDS_WITH",
"REGEX", "NOT_REGEX",
}
actual = {v.value for v in ResponseRuleConditionOperator}
assert actual == expected
class TestResponseType:
def test_all_values(self):
expected = {
"XRAY_JSON", "XRAY_BASE64", "MIHOMO", "STASH", "CLASH", "SINGBOX",
"BROWSER", "BLOCK", "STATUS_CODE_404", "STATUS_CODE_451", "SOCKET_DROP",
}
actual = {v.value for v in ResponseType}
assert actual == expected

View file

@ -0,0 +1,277 @@
"""Tests for model field validation and serialization."""
import pytest
from datetime import datetime, timezone
from uuid import uuid4
from remnawave.models import (
# Users
ResolveUserRequestBodyDto,
ResolveUserResponseDto,
RevokeUserRequestDto,
# System
GetRecapResponseDto,
RecapThisMonth,
RecapTotal,
# IP Control
FetchUsersIpsResponseDto,
FetchUsersIpsResultResponseDto,
FetchUsersIpsUserIp,
FetchUsersIpsUser,
FetchUsersIpsResult,
DropConnectionsRequestDto,
DropByUserUuids,
DropByIpAddresses,
TargetAllNodes,
TargetSpecificNodes,
# Infra Billing
CreateInfraBillingHistoryRecordRequestDto,
CreateInfraBillingNodeRequestDto,
# Subscription Settings
ResponseRules,
ResponseRulesSettings,
# Webhook
NodeSystemDto,
NodeSystemInfoDto,
NodeSystemStatsDto,
NodeVersionsDto,
)
from remnawave.enums import ResponseRuleVersion
class TestResolveUserRequestBodyDto:
def test_create_with_uuid(self):
uid = uuid4()
dto = ResolveUserRequestBodyDto(uuid=uid)
assert dto.uuid == uid
assert dto.id is None
assert dto.username is None
def test_create_with_username(self):
dto = ResolveUserRequestBodyDto(username="testuser")
assert dto.username == "testuser"
assert dto.uuid is None
def test_create_with_short_uuid(self):
dto = ResolveUserRequestBodyDto(short_uuid="abc123")
assert dto.short_uuid == "abc123"
def test_serialization_alias(self):
dto = ResolveUserRequestBodyDto(short_uuid="abc123")
data = dto.model_dump(by_alias=True)
assert "shortUuid" in data
def test_create_with_id(self):
dto = ResolveUserRequestBodyDto(id=42)
assert dto.id == 42
class TestResolveUserResponseDto:
def test_from_api_response(self):
uid = uuid4()
dto = ResolveUserResponseDto(
uuid=uid,
username="testuser",
id=1,
shortUuid="abc123",
)
assert dto.uuid == uid
assert dto.username == "testuser"
assert dto.id == 1
assert dto.short_uuid == "abc123"
class TestGetRecapResponseDto:
def test_from_api_response(self):
dto = GetRecapResponseDto(
thisMonth={"users": 10, "traffic": "1.5 GB"},
total={
"users": 100,
"nodes": 5,
"traffic": "500 GB",
"nodesRam": "32 GB",
"nodesCpuCores": 16,
"distinctCountries": 3,
},
version="1.11.0",
initDate="2025-01-01T00:00:00Z",
)
assert dto.this_month.users == 10
assert dto.this_month.traffic == "1.5 GB"
assert dto.total.nodes == 5
assert dto.total.nodes_ram == "32 GB"
assert dto.total.nodes_cpu_cores == 16
assert dto.total.distinct_countries == 3
assert dto.version == "1.11.0"
assert isinstance(dto.init_date, datetime)
class TestFetchUsersIpsModels:
def test_response_dto(self):
dto = FetchUsersIpsResponseDto(jobId="job-123")
assert dto.job_id == "job-123"
def test_result_not_completed(self):
dto = FetchUsersIpsResultResponseDto(
isCompleted=False,
isFailed=False,
result=None,
)
assert dto.is_completed is False
assert dto.is_failed is False
assert dto.result is None
def test_result_completed(self):
uid = uuid4()
dto = FetchUsersIpsResultResponseDto(
isCompleted=True,
isFailed=False,
result={
"success": True,
"nodeUuid": str(uid),
"users": [
{
"userId": "user-1",
"ips": [
{"ip": "1.2.3.4", "lastSeen": "2025-01-01T00:00:00Z"},
],
}
],
},
)
assert dto.is_completed is True
assert dto.result.success is True
assert dto.result.node_uuid == uid
assert len(dto.result.users) == 1
assert dto.result.users[0].user_id == "user-1"
assert dto.result.users[0].ips[0].ip == "1.2.3.4"
class TestCreateInfraBillingHistoryRecordRequestDto:
def test_fields_match_spec(self):
uid = uuid4()
now = datetime.now(tz=timezone.utc)
dto = CreateInfraBillingHistoryRecordRequestDto(
provider_uuid=uid,
amount=29.99,
billed_at=now,
)
assert dto.provider_uuid == uid
assert dto.amount == 29.99
assert dto.billed_at == now
def test_serialization(self):
uid = uuid4()
now = datetime.now(tz=timezone.utc)
dto = CreateInfraBillingHistoryRecordRequestDto(
provider_uuid=uid,
amount=10.0,
billed_at=now,
)
data = dto.model_dump(by_alias=True)
assert "providerUuid" in data
assert "billedAt" in data
assert "amount" in data
def test_no_old_fields(self):
"""Ensure removed fields don't exist."""
assert not hasattr(CreateInfraBillingHistoryRecordRequestDto, "node_uuid")
assert not hasattr(CreateInfraBillingHistoryRecordRequestDto, "payment_date")
assert not hasattr(CreateInfraBillingHistoryRecordRequestDto, "description")
class TestCreateInfraBillingNodeRequestDto:
def test_next_billing_at_optional(self):
dto = CreateInfraBillingNodeRequestDto(
node_uuid=uuid4(),
provider_uuid=uuid4(),
)
assert dto.next_billing_at is None
def test_next_billing_at_provided(self):
now = datetime.now(tz=timezone.utc)
dto = CreateInfraBillingNodeRequestDto(
node_uuid=uuid4(),
provider_uuid=uuid4(),
next_billing_at=now,
)
assert dto.next_billing_at == now
class TestResponseRulesSettings:
def test_settings_field_exists(self):
rules = ResponseRules(
version=ResponseRuleVersion.V1,
rules=[],
settings=ResponseRulesSettings(
disable_subscription_access_by_path=True,
),
)
assert rules.settings is not None
assert rules.settings.disable_subscription_access_by_path is True
def test_settings_optional(self):
rules = ResponseRules(
version=ResponseRuleVersion.V1,
rules=[],
)
assert rules.settings is None
def test_settings_deserialization(self):
rules = ResponseRules.model_validate({
"version": "1",
"rules": [],
"settings": {"disableSubscriptionAccessByPath": False},
})
assert rules.settings.disable_subscription_access_by_path is False
class TestWebhookNodeDto:
def test_system_field(self):
system = NodeSystemDto.model_validate({
"info": {
"arch": "x64",
"cpus": 4,
"cpuModel": "Intel Core i7",
"memoryTotal": 16384,
"hostname": "node-1",
"platform": "linux",
"release": "5.15.0",
"type": "Linux",
"version": "#1 SMP",
"networkInterfaces": ["eth0", "lo"],
},
"stats": {
"memoryFree": 8192,
"memoryUsed": 8192,
"uptime": 3600,
"loadAvg": [0.5, 0.3, 0.1],
"interface": None,
},
})
assert system.info.arch == "x64"
assert system.info.cpus == 4
assert system.info.cpu_model == "Intel Core i7"
assert system.stats.memory_free == 8192
assert system.stats.uptime == 3600
def test_versions_field(self):
versions = NodeVersionsDto.model_validate({
"xray": "1.8.6",
"node": "0.5.0",
})
assert versions.xray == "1.8.6"
assert versions.node == "0.5.0"
def test_node_dto_has_new_fields(self):
from remnawave.models.webhook import NodeDto
fields = NodeDto.model_fields
assert "active_plugin_uuid" in fields
assert "system" in fields
assert "versions" in fields
def test_node_dto_xray_uptime_is_float(self):
from remnawave.models.webhook import NodeDto
field = NodeDto.model_fields["xray_uptime"]
assert field.annotation == float or field.annotation is float