diff --git a/remnawave/controllers/ip_control.py b/remnawave/controllers/ip_control.py index ee21b3f..85b7a47 100644 --- a/remnawave/controllers/ip_control.py +++ b/remnawave/controllers/ip_control.py @@ -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, diff --git a/remnawave/controllers/system.py b/remnawave/controllers/system.py index bf1e79f..68edc2e 100644 --- a/remnawave/controllers/system.py +++ b/remnawave/controllers/system.py @@ -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""" ... \ No newline at end of file diff --git a/remnawave/controllers/users.py b/remnawave/controllers/users.py index b0417d2..8933378 100644 --- a/remnawave/controllers/users.py +++ b/remnawave/controllers/users.py @@ -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)""" ... \ No newline at end of file diff --git a/remnawave/enums/__init__.py b/remnawave/enums/__init__.py index bb01abb..55a2cc1 100644 --- a/remnawave/enums/__init__.py +++ b/remnawave/enums/__init__.py @@ -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", ] diff --git a/remnawave/enums/auth.py b/remnawave/enums/auth.py index ac62047..e79f7fd 100644 --- a/remnawave/enums/auth.py +++ b/remnawave/enums/auth.py @@ -2,7 +2,9 @@ from enum import StrEnum class OAuth2Provider(StrEnum): """OAuth2 Provider enum""" + TELEGRAM = "telegram" GITHUB = "github" POCKETID = "pocketid" YANDEX = "yandex" - KEYCLOAK = "keycloak" \ No newline at end of file + KEYCLOAK = "keycloak" + GENERIC = "generic" \ No newline at end of file diff --git a/remnawave/enums/template_type.py b/remnawave/enums/template_type.py index 74222ba..c746704 100644 --- a/remnawave/enums/template_type.py +++ b/remnawave/enums/template_type.py @@ -7,4 +7,5 @@ class TemplateType(StrEnum): SINGBOX_LEGACY = "SINGBOX_LEGACY" MIHOMO = "MIHOMO" XRAY_JSON = "XRAY_JSON" + XRAY_BASE64 = "XRAY_BASE64" CLASH = "CLASH" diff --git a/remnawave/enums/users.py b/remnawave/enums/users.py index a34ad56..8743bd9 100644 --- a/remnawave/enums/users.py +++ b/remnawave/enums/users.py @@ -13,3 +13,4 @@ class TrafficLimitStrategy(StrEnum): DAY = "DAY" WEEK = "WEEK" MONTH = "MONTH" + MONTH_ROLLING = "MONTH_ROLLING" diff --git a/remnawave/enums/webhook.py b/remnawave/enums/webhook.py index 1ab22f4..2cb7606 100644 --- a/remnawave/enums/webhook.py +++ b/remnawave/enums/webhook.py @@ -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"] diff --git a/remnawave/models/__init__.py b/remnawave/models/__init__.py index 9363a7f..07c49b5 100644 --- a/remnawave/models/__init__.py +++ b/remnawave/models/__init__.py @@ -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 diff --git a/remnawave/models/infra_billing.py b/remnawave/models/infra_billing.py index ab622c2..6f3316d 100644 --- a/remnawave/models/infra_billing.py +++ b/remnawave/models/infra_billing.py @@ -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 после создания, а не один созданный diff --git a/remnawave/models/ip_control.py b/remnawave/models/ip_control.py index 8470bd8..16cb49c 100644 --- a/remnawave/models/ip_control.py +++ b/remnawave/models/ip_control.py @@ -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( diff --git a/remnawave/models/subscriptions_settings.py b/remnawave/models/subscriptions_settings.py index 8da5bea..3fcb9f7 100644 --- a/remnawave/models/subscriptions_settings.py +++ b/remnawave/models/subscriptions_settings.py @@ -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): diff --git a/remnawave/models/system.py b/remnawave/models/system.py index 2a39ed1..69c13d8 100644 --- a/remnawave/models/system.py +++ b/remnawave/models/system.py @@ -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 diff --git a/remnawave/models/users.py b/remnawave/models/users.py index aec4b99..2499538 100644 --- a/remnawave/models/users.py +++ b/remnawave/models/users.py @@ -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 diff --git a/remnawave/models/webhook.py b/remnawave/models/webhook.py index c3b724f..1a539bd 100644 --- a/remnawave/models/webhook.py +++ b/remnawave/models/webhook.py @@ -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): diff --git a/tests/test_controllers_completeness.py b/tests/test_controllers_completeness.py new file mode 100644 index 0000000..7060224 --- /dev/null +++ b/tests/test_controllers_completeness.py @@ -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") diff --git a/tests/test_enums.py b/tests/test_enums.py new file mode 100644 index 0000000..a0f8914 --- /dev/null +++ b/tests/test_enums.py @@ -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 diff --git a/tests/test_models_validation.py b/tests/test_models_validation.py new file mode 100644 index 0000000..e3f71da --- /dev/null +++ b/tests/test_models_validation.py @@ -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