unifi-core 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- unifi_core-0.1.0/.gitignore +88 -0
- unifi_core-0.1.0/PKG-INFO +7 -0
- unifi_core-0.1.0/pyproject.toml +36 -0
- unifi_core-0.1.0/src/unifi_core/__init__.py +35 -0
- unifi_core-0.1.0/src/unifi_core/_version.py +34 -0
- unifi_core-0.1.0/src/unifi_core/auth.py +73 -0
- unifi_core-0.1.0/src/unifi_core/connection.py +23 -0
- unifi_core-0.1.0/src/unifi_core/detection.py +87 -0
- unifi_core-0.1.0/src/unifi_core/exceptions.py +21 -0
- unifi_core-0.1.0/src/unifi_core/retry.py +43 -0
- unifi_core-0.1.0/tests/__init__.py +0 -0
- unifi_core-0.1.0/tests/test_auth.py +136 -0
- unifi_core-0.1.0/tests/test_connection.py +33 -0
- unifi_core-0.1.0/tests/test_detection.py +31 -0
- unifi_core-0.1.0/tests/test_exceptions.py +13 -0
- unifi_core-0.1.0/tests/test_retry.py +58 -0
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
# Environment variables
|
|
2
|
+
.env
|
|
3
|
+
|
|
4
|
+
# Python
|
|
5
|
+
__pycache__/
|
|
6
|
+
_version.py
|
|
7
|
+
*.py[cod]
|
|
8
|
+
*$py.class
|
|
9
|
+
*.so
|
|
10
|
+
.Python
|
|
11
|
+
build/
|
|
12
|
+
develop-eggs/
|
|
13
|
+
dist/
|
|
14
|
+
downloads/
|
|
15
|
+
eggs/
|
|
16
|
+
.eggs/
|
|
17
|
+
lib/
|
|
18
|
+
lib64/
|
|
19
|
+
parts/
|
|
20
|
+
sdist/
|
|
21
|
+
var/
|
|
22
|
+
wheels/
|
|
23
|
+
*.egg-info/
|
|
24
|
+
.installed.cfg
|
|
25
|
+
*.egg
|
|
26
|
+
|
|
27
|
+
# Virtual environments
|
|
28
|
+
venv/
|
|
29
|
+
env/
|
|
30
|
+
ENV/
|
|
31
|
+
|
|
32
|
+
# IDEs and editors
|
|
33
|
+
.idea/
|
|
34
|
+
.vscode/
|
|
35
|
+
*.swp
|
|
36
|
+
*.swo
|
|
37
|
+
*.swn
|
|
38
|
+
*~
|
|
39
|
+
|
|
40
|
+
# Logs
|
|
41
|
+
*.log
|
|
42
|
+
logs/
|
|
43
|
+
|
|
44
|
+
# Docker
|
|
45
|
+
.dockerignore
|
|
46
|
+
|
|
47
|
+
# Cache
|
|
48
|
+
.cache/
|
|
49
|
+
|
|
50
|
+
# Runtime data
|
|
51
|
+
pids/
|
|
52
|
+
*.pid
|
|
53
|
+
*.seed
|
|
54
|
+
*.pid.lock
|
|
55
|
+
|
|
56
|
+
# Backup files
|
|
57
|
+
*.bak
|
|
58
|
+
*.backup
|
|
59
|
+
|
|
60
|
+
# Testing
|
|
61
|
+
.coverage
|
|
62
|
+
|
|
63
|
+
# Playwright MCP artifacts
|
|
64
|
+
.playwright-mcp/
|
|
65
|
+
|
|
66
|
+
.DS_Store
|
|
67
|
+
|
|
68
|
+
aiounifi/*
|
|
69
|
+
aiounifi
|
|
70
|
+
|
|
71
|
+
.env.template
|
|
72
|
+
claude_config.json
|
|
73
|
+
|
|
74
|
+
# open-agent-kit: Codebase Intelligence
|
|
75
|
+
.oak/ci/
|
|
76
|
+
.oak/config.*.yaml
|
|
77
|
+
|
|
78
|
+
# open-agent-kit: Issue raw JSON (local debugging only)
|
|
79
|
+
oak/issue/**/context.json
|
|
80
|
+
|
|
81
|
+
# open-agent-kit: CI hook configs (local-only, regenerated by oak ci start)
|
|
82
|
+
.claude/settings.local.json
|
|
83
|
+
|
|
84
|
+
# Specs and plans — captured in Myco vault, not the repo
|
|
85
|
+
docs/superpowers/
|
|
86
|
+
docs/plans/
|
|
87
|
+
docs/specs/
|
|
88
|
+
.superpowers/
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "unifi-core"
|
|
3
|
+
dynamic = ["version"]
|
|
4
|
+
description = "UniFi controller connectivity: auth, detection, retry, exceptions"
|
|
5
|
+
requires-python = ">=3.13"
|
|
6
|
+
dependencies = [
|
|
7
|
+
"aiohttp>=3.8.5",
|
|
8
|
+
"pyyaml>=6.0",
|
|
9
|
+
]
|
|
10
|
+
|
|
11
|
+
[dependency-groups]
|
|
12
|
+
dev = [
|
|
13
|
+
"pytest>=7.0.0",
|
|
14
|
+
"pytest-asyncio>=0.21.0",
|
|
15
|
+
"aioresponses>=0.7.0",
|
|
16
|
+
]
|
|
17
|
+
|
|
18
|
+
[build-system]
|
|
19
|
+
requires = ["hatchling", "hatch-vcs"]
|
|
20
|
+
build-backend = "hatchling.build"
|
|
21
|
+
|
|
22
|
+
[tool.hatch.version]
|
|
23
|
+
source = "vcs"
|
|
24
|
+
raw-options.root = "../.."
|
|
25
|
+
raw-options.tag_regex = "^core/v(?P<version>\\d+(?:\\.\\d+)*)(?:\\S*)$"
|
|
26
|
+
raw-options.git_describe_command = ["git", "describe", "--dirty", "--tags", "--long", "--match", "core/v*"]
|
|
27
|
+
raw-options.fallback_version = "0.0.0"
|
|
28
|
+
|
|
29
|
+
[tool.hatch.build.hooks.vcs]
|
|
30
|
+
version-file = "src/unifi_core/_version.py"
|
|
31
|
+
|
|
32
|
+
[tool.hatch.build.targets.wheel]
|
|
33
|
+
packages = ["src/unifi_core"]
|
|
34
|
+
|
|
35
|
+
[tool.pytest.ini_options]
|
|
36
|
+
asyncio_mode = "auto"
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""UniFi controller connectivity: auth, detection, retry, exceptions."""
|
|
2
|
+
|
|
3
|
+
from unifi_core.auth import AuthMethod, LocalAuthProvider, UniFiAuth
|
|
4
|
+
from unifi_core.connection import ConnectionConfig
|
|
5
|
+
from unifi_core.detection import ControllerType, detect_controller_type_by_api_probe, detect_controller_type_pre_login
|
|
6
|
+
from unifi_core.exceptions import (
|
|
7
|
+
UniFiAuthError,
|
|
8
|
+
UniFiConnectionError,
|
|
9
|
+
UniFiError,
|
|
10
|
+
UniFiPermissionError,
|
|
11
|
+
UniFiRateLimitError,
|
|
12
|
+
)
|
|
13
|
+
from unifi_core.retry import RetryPolicy, retry_with_backoff
|
|
14
|
+
|
|
15
|
+
__all__ = [
|
|
16
|
+
# auth
|
|
17
|
+
"AuthMethod",
|
|
18
|
+
"LocalAuthProvider",
|
|
19
|
+
"UniFiAuth",
|
|
20
|
+
# connection
|
|
21
|
+
"ConnectionConfig",
|
|
22
|
+
# detection
|
|
23
|
+
"ControllerType",
|
|
24
|
+
"detect_controller_type_by_api_probe",
|
|
25
|
+
"detect_controller_type_pre_login",
|
|
26
|
+
# exceptions
|
|
27
|
+
"UniFiAuthError",
|
|
28
|
+
"UniFiConnectionError",
|
|
29
|
+
"UniFiError",
|
|
30
|
+
"UniFiPermissionError",
|
|
31
|
+
"UniFiRateLimitError",
|
|
32
|
+
# retry
|
|
33
|
+
"RetryPolicy",
|
|
34
|
+
"retry_with_backoff",
|
|
35
|
+
]
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# file generated by setuptools-scm
|
|
2
|
+
# don't change, don't track in version control
|
|
3
|
+
|
|
4
|
+
__all__ = [
|
|
5
|
+
"__version__",
|
|
6
|
+
"__version_tuple__",
|
|
7
|
+
"version",
|
|
8
|
+
"version_tuple",
|
|
9
|
+
"__commit_id__",
|
|
10
|
+
"commit_id",
|
|
11
|
+
]
|
|
12
|
+
|
|
13
|
+
TYPE_CHECKING = False
|
|
14
|
+
if TYPE_CHECKING:
|
|
15
|
+
from typing import Tuple
|
|
16
|
+
from typing import Union
|
|
17
|
+
|
|
18
|
+
VERSION_TUPLE = Tuple[Union[int, str], ...]
|
|
19
|
+
COMMIT_ID = Union[str, None]
|
|
20
|
+
else:
|
|
21
|
+
VERSION_TUPLE = object
|
|
22
|
+
COMMIT_ID = object
|
|
23
|
+
|
|
24
|
+
version: str
|
|
25
|
+
__version__: str
|
|
26
|
+
__version_tuple__: VERSION_TUPLE
|
|
27
|
+
version_tuple: VERSION_TUPLE
|
|
28
|
+
commit_id: COMMIT_ID
|
|
29
|
+
__commit_id__: COMMIT_ID
|
|
30
|
+
|
|
31
|
+
__version__ = version = '0.1.0'
|
|
32
|
+
__version_tuple__ = version_tuple = (0, 1, 0)
|
|
33
|
+
|
|
34
|
+
__commit_id__ = commit_id = None
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""Dual authentication strategy for UniFi controllers."""
|
|
2
|
+
|
|
3
|
+
import enum
|
|
4
|
+
import logging
|
|
5
|
+
from typing import Protocol
|
|
6
|
+
|
|
7
|
+
import aiohttp
|
|
8
|
+
|
|
9
|
+
from unifi_core.exceptions import UniFiAuthError
|
|
10
|
+
|
|
11
|
+
logger = logging.getLogger(__name__)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class AuthMethod(enum.Enum):
|
|
15
|
+
LOCAL_ONLY = "local_only"
|
|
16
|
+
API_KEY_ONLY = "api_key_only"
|
|
17
|
+
EITHER = "either"
|
|
18
|
+
|
|
19
|
+
@classmethod
|
|
20
|
+
def from_string(cls, value: str | None) -> "AuthMethod":
|
|
21
|
+
if value is None:
|
|
22
|
+
return cls.LOCAL_ONLY
|
|
23
|
+
try:
|
|
24
|
+
return cls(value)
|
|
25
|
+
except ValueError:
|
|
26
|
+
logger.warning("[auth] Unknown auth method '%s', defaulting to local_only", value)
|
|
27
|
+
return cls.LOCAL_ONLY
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class LocalAuthProvider(Protocol):
|
|
31
|
+
"""Contract that each app fulfills for local auth."""
|
|
32
|
+
|
|
33
|
+
async def get_session(self) -> aiohttp.ClientSession: ...
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class UniFiAuth:
|
|
37
|
+
"""Dual auth: API key and/or local auth provider."""
|
|
38
|
+
|
|
39
|
+
def __init__(self, api_key: str | None = None, local_provider: LocalAuthProvider | None = None):
|
|
40
|
+
self._api_key = api_key
|
|
41
|
+
self._local_provider = local_provider
|
|
42
|
+
|
|
43
|
+
@property
|
|
44
|
+
def has_api_key(self) -> bool:
|
|
45
|
+
return self._api_key is not None and self._api_key != ""
|
|
46
|
+
|
|
47
|
+
@property
|
|
48
|
+
def has_local(self) -> bool:
|
|
49
|
+
return self._local_provider is not None
|
|
50
|
+
|
|
51
|
+
def set_local_provider(self, provider: LocalAuthProvider) -> None:
|
|
52
|
+
self._local_provider = provider
|
|
53
|
+
|
|
54
|
+
async def get_api_key_session(self) -> aiohttp.ClientSession:
|
|
55
|
+
if not self.has_api_key:
|
|
56
|
+
raise UniFiAuthError("API key authentication not configured. Set UNIFI_API_KEY environment variable.")
|
|
57
|
+
return aiohttp.ClientSession(headers={"X-API-Key": self._api_key})
|
|
58
|
+
|
|
59
|
+
async def get_local_session(self) -> aiohttp.ClientSession:
|
|
60
|
+
if not self.has_local:
|
|
61
|
+
raise UniFiAuthError("Local authentication not configured. Set UNIFI_USERNAME and UNIFI_PASSWORD.")
|
|
62
|
+
return await self._local_provider.get_session()
|
|
63
|
+
|
|
64
|
+
async def get_session(self, method: AuthMethod) -> aiohttp.ClientSession:
|
|
65
|
+
if method == AuthMethod.API_KEY_ONLY:
|
|
66
|
+
return await self.get_api_key_session()
|
|
67
|
+
elif method == AuthMethod.LOCAL_ONLY:
|
|
68
|
+
return await self.get_local_session()
|
|
69
|
+
elif method == AuthMethod.EITHER:
|
|
70
|
+
if self.has_api_key:
|
|
71
|
+
return await self.get_api_key_session()
|
|
72
|
+
return await self.get_local_session()
|
|
73
|
+
raise UniFiAuthError(f"Unknown auth method: {method}")
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""Base async connection primitives for UniFi controllers."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
|
|
5
|
+
import aiohttp # noqa: F401
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass
|
|
9
|
+
class ConnectionConfig:
|
|
10
|
+
host: str
|
|
11
|
+
port: int = 443
|
|
12
|
+
verify_ssl: bool = False
|
|
13
|
+
timeout: float = 30.0
|
|
14
|
+
|
|
15
|
+
@property
|
|
16
|
+
def url_base(self) -> str:
|
|
17
|
+
return f"https://{self.host}:{self.port}"
|
|
18
|
+
|
|
19
|
+
@property
|
|
20
|
+
def ssl_context(self):
|
|
21
|
+
if self.verify_ssl:
|
|
22
|
+
return None
|
|
23
|
+
return False
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"""UniFi controller type detection.
|
|
2
|
+
|
|
3
|
+
Determines whether the controller is a UniFi OS appliance (UDM, UDR, etc.)
|
|
4
|
+
or a standalone/self-hosted Network Application. This affects API path
|
|
5
|
+
routing: UniFi OS uses /proxy/network/... while standalone uses /api/...
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import enum
|
|
9
|
+
import logging
|
|
10
|
+
|
|
11
|
+
import aiohttp
|
|
12
|
+
|
|
13
|
+
logger = logging.getLogger(__name__)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class ControllerType(enum.Enum):
|
|
17
|
+
UNIFI_OS = "proxy"
|
|
18
|
+
STANDALONE = "direct"
|
|
19
|
+
AUTO = "auto"
|
|
20
|
+
|
|
21
|
+
@classmethod
|
|
22
|
+
def from_config(cls, value: str) -> "ControllerType":
|
|
23
|
+
mapping = {"proxy": cls.UNIFI_OS, "direct": cls.STANDALONE, "auto": cls.AUTO}
|
|
24
|
+
result = mapping.get(value.lower(), cls.AUTO)
|
|
25
|
+
if value.lower() not in mapping:
|
|
26
|
+
logger.warning("[detection] Unknown controller type '%s', falling back to auto", value)
|
|
27
|
+
return result
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
async def detect_controller_type_pre_login(
|
|
31
|
+
host: str, port: int, verify_ssl: bool = False, timeout: float = 10.0
|
|
32
|
+
) -> ControllerType | None:
|
|
33
|
+
"""Probe the controller before login to detect type.
|
|
34
|
+
|
|
35
|
+
Strategy:
|
|
36
|
+
- GET the base URL without following redirects
|
|
37
|
+
- UniFi OS returns 200 with x-csrf-token header or HTML
|
|
38
|
+
- Standalone controllers redirect (301/302) to /manage
|
|
39
|
+
|
|
40
|
+
Returns:
|
|
41
|
+
ControllerType.UNIFI_OS or ControllerType.STANDALONE if detected, None otherwise.
|
|
42
|
+
"""
|
|
43
|
+
url = f"https://{host}:{port}"
|
|
44
|
+
ssl_context = None if verify_ssl else False
|
|
45
|
+
client_timeout = aiohttp.ClientTimeout(total=timeout)
|
|
46
|
+
|
|
47
|
+
try:
|
|
48
|
+
async with aiohttp.ClientSession(timeout=client_timeout) as session:
|
|
49
|
+
async with session.get(url, ssl=ssl_context, allow_redirects=False) as resp:
|
|
50
|
+
headers = resp.headers
|
|
51
|
+
if "x-csrf-token" in headers or resp.status == 200:
|
|
52
|
+
return ControllerType.UNIFI_OS
|
|
53
|
+
if resp.status in (302, 301):
|
|
54
|
+
return ControllerType.STANDALONE
|
|
55
|
+
except Exception as e:
|
|
56
|
+
logger.debug("[detection] Pre-login probe failed: %s", e)
|
|
57
|
+
|
|
58
|
+
return None
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
async def detect_controller_type_by_api_probe(
|
|
62
|
+
session: aiohttp.ClientSession, host: str, port: int, verify_ssl: bool = False
|
|
63
|
+
) -> ControllerType | None:
|
|
64
|
+
"""Probe API endpoints to detect controller type (requires authenticated session).
|
|
65
|
+
|
|
66
|
+
Tries both UniFi OS and standalone API paths for /api/self/sites.
|
|
67
|
+
Returns the type corresponding to the first path that returns HTTP 200.
|
|
68
|
+
|
|
69
|
+
Returns:
|
|
70
|
+
ControllerType.UNIFI_OS or ControllerType.STANDALONE if detected, None otherwise.
|
|
71
|
+
"""
|
|
72
|
+
url_base = f"https://{host}:{port}"
|
|
73
|
+
ssl_context = None if verify_ssl else False
|
|
74
|
+
|
|
75
|
+
for path, expected_type in [
|
|
76
|
+
("/proxy/network/api/self/sites", ControllerType.UNIFI_OS),
|
|
77
|
+
("/api/self/sites", ControllerType.STANDALONE),
|
|
78
|
+
]:
|
|
79
|
+
try:
|
|
80
|
+
async with session.get(f"{url_base}{path}", ssl=ssl_context) as resp:
|
|
81
|
+
if resp.status == 200:
|
|
82
|
+
logger.info("[detection] Detected %s via %s", expected_type.name, path)
|
|
83
|
+
return expected_type
|
|
84
|
+
except Exception:
|
|
85
|
+
continue
|
|
86
|
+
|
|
87
|
+
return None
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""Shared exception hierarchy for UniFi MCP servers."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class UniFiError(Exception):
|
|
5
|
+
"""Base exception for all UniFi errors."""
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class UniFiAuthError(UniFiError):
|
|
9
|
+
"""Authentication failed."""
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class UniFiConnectionError(UniFiError):
|
|
13
|
+
"""Connection to controller failed."""
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class UniFiRateLimitError(UniFiError):
|
|
17
|
+
"""Rate limit exceeded."""
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class UniFiPermissionError(UniFiError):
|
|
21
|
+
"""Insufficient permissions for operation."""
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""Retry logic with exponential backoff."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import logging
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
from unifi_core.exceptions import UniFiError
|
|
8
|
+
|
|
9
|
+
logger = logging.getLogger(__name__)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass
|
|
13
|
+
class RetryPolicy:
|
|
14
|
+
max_retries: int = 3
|
|
15
|
+
base_delay: float = 1.0
|
|
16
|
+
max_delay: float = 30.0
|
|
17
|
+
backoff_factor: float = 2.0
|
|
18
|
+
retryable_exceptions: tuple = (UniFiError,)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
async def retry_with_backoff(operation, policy: RetryPolicy | None = None):
|
|
22
|
+
"""Execute operation with exponential backoff retry."""
|
|
23
|
+
if policy is None:
|
|
24
|
+
policy = RetryPolicy()
|
|
25
|
+
|
|
26
|
+
last_error = None
|
|
27
|
+
for attempt in range(policy.max_retries + 1):
|
|
28
|
+
try:
|
|
29
|
+
return await operation()
|
|
30
|
+
except policy.retryable_exceptions as e:
|
|
31
|
+
last_error = e
|
|
32
|
+
if attempt < policy.max_retries:
|
|
33
|
+
delay = min(policy.base_delay * (policy.backoff_factor ** attempt), policy.max_delay)
|
|
34
|
+
logger.warning(
|
|
35
|
+
"[retry] Attempt %d/%d failed: %s. Retrying in %.1fs",
|
|
36
|
+
attempt + 1,
|
|
37
|
+
policy.max_retries,
|
|
38
|
+
e,
|
|
39
|
+
delay,
|
|
40
|
+
)
|
|
41
|
+
await asyncio.sleep(delay)
|
|
42
|
+
|
|
43
|
+
raise last_error
|
|
File without changes
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
import aiohttp
|
|
3
|
+
from unittest.mock import AsyncMock
|
|
4
|
+
from unifi_core.auth import AuthMethod, UniFiAuth
|
|
5
|
+
from unifi_core.exceptions import UniFiAuthError
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class TestAuthMethod:
|
|
9
|
+
def test_enum_values(self):
|
|
10
|
+
assert AuthMethod.LOCAL_ONLY.value == "local_only"
|
|
11
|
+
assert AuthMethod.API_KEY_ONLY.value == "api_key_only"
|
|
12
|
+
assert AuthMethod.EITHER.value == "either"
|
|
13
|
+
|
|
14
|
+
def test_from_string_valid(self):
|
|
15
|
+
assert AuthMethod.from_string("local_only") == AuthMethod.LOCAL_ONLY
|
|
16
|
+
assert AuthMethod.from_string("api_key_only") == AuthMethod.API_KEY_ONLY
|
|
17
|
+
assert AuthMethod.from_string("either") == AuthMethod.EITHER
|
|
18
|
+
|
|
19
|
+
def test_from_string_none_defaults_to_local(self):
|
|
20
|
+
assert AuthMethod.from_string(None) == AuthMethod.LOCAL_ONLY
|
|
21
|
+
|
|
22
|
+
def test_from_string_unknown_defaults_to_local(self):
|
|
23
|
+
assert AuthMethod.from_string("unknown") == AuthMethod.LOCAL_ONLY
|
|
24
|
+
assert AuthMethod.from_string("") == AuthMethod.LOCAL_ONLY
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class TestUniFiAuthProperties:
|
|
28
|
+
def test_has_api_key_true(self):
|
|
29
|
+
auth = UniFiAuth(api_key="test-key")
|
|
30
|
+
assert auth.has_api_key is True
|
|
31
|
+
|
|
32
|
+
def test_has_api_key_false_when_none(self):
|
|
33
|
+
auth = UniFiAuth(api_key=None)
|
|
34
|
+
assert auth.has_api_key is False
|
|
35
|
+
|
|
36
|
+
def test_has_api_key_false_when_empty(self):
|
|
37
|
+
auth = UniFiAuth(api_key="")
|
|
38
|
+
assert auth.has_api_key is False
|
|
39
|
+
|
|
40
|
+
def test_has_local_true(self):
|
|
41
|
+
provider = AsyncMock()
|
|
42
|
+
auth = UniFiAuth(local_provider=provider)
|
|
43
|
+
assert auth.has_local is True
|
|
44
|
+
|
|
45
|
+
def test_has_local_false_when_none(self):
|
|
46
|
+
auth = UniFiAuth()
|
|
47
|
+
assert auth.has_local is False
|
|
48
|
+
|
|
49
|
+
def test_set_local_provider(self):
|
|
50
|
+
auth = UniFiAuth()
|
|
51
|
+
assert auth.has_local is False
|
|
52
|
+
provider = AsyncMock()
|
|
53
|
+
auth.set_local_provider(provider)
|
|
54
|
+
assert auth.has_local is True
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class TestUniFiAuthApiKeySession:
|
|
58
|
+
@pytest.mark.asyncio
|
|
59
|
+
async def test_get_api_key_session_creates_session_with_header(self):
|
|
60
|
+
auth = UniFiAuth(api_key="my-api-key")
|
|
61
|
+
session = await auth.get_api_key_session()
|
|
62
|
+
try:
|
|
63
|
+
assert isinstance(session, aiohttp.ClientSession)
|
|
64
|
+
# Check that the default headers contain the API key
|
|
65
|
+
assert session.headers.get("X-API-Key") == "my-api-key"
|
|
66
|
+
finally:
|
|
67
|
+
await session.close()
|
|
68
|
+
|
|
69
|
+
@pytest.mark.asyncio
|
|
70
|
+
async def test_get_api_key_session_raises_when_not_configured(self):
|
|
71
|
+
auth = UniFiAuth()
|
|
72
|
+
with pytest.raises(UniFiAuthError, match="API key authentication not configured"):
|
|
73
|
+
await auth.get_api_key_session()
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class TestUniFiAuthLocalSession:
|
|
77
|
+
@pytest.mark.asyncio
|
|
78
|
+
async def test_get_local_session_delegates_to_provider(self):
|
|
79
|
+
mock_session = AsyncMock(spec=aiohttp.ClientSession)
|
|
80
|
+
provider = AsyncMock()
|
|
81
|
+
provider.get_session = AsyncMock(return_value=mock_session)
|
|
82
|
+
auth = UniFiAuth(local_provider=provider)
|
|
83
|
+
session = await auth.get_local_session()
|
|
84
|
+
assert session is mock_session
|
|
85
|
+
provider.get_session.assert_awaited_once()
|
|
86
|
+
|
|
87
|
+
@pytest.mark.asyncio
|
|
88
|
+
async def test_get_local_session_raises_when_not_configured(self):
|
|
89
|
+
auth = UniFiAuth()
|
|
90
|
+
with pytest.raises(UniFiAuthError, match="Local authentication not configured"):
|
|
91
|
+
await auth.get_local_session()
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class TestUniFiAuthGetSession:
|
|
95
|
+
@pytest.mark.asyncio
|
|
96
|
+
async def test_get_session_api_key_only(self):
|
|
97
|
+
auth = UniFiAuth(api_key="test-key")
|
|
98
|
+
session = await auth.get_session(AuthMethod.API_KEY_ONLY)
|
|
99
|
+
try:
|
|
100
|
+
assert session.headers.get("X-API-Key") == "test-key"
|
|
101
|
+
finally:
|
|
102
|
+
await session.close()
|
|
103
|
+
|
|
104
|
+
@pytest.mark.asyncio
|
|
105
|
+
async def test_get_session_local_only(self):
|
|
106
|
+
mock_session = AsyncMock(spec=aiohttp.ClientSession)
|
|
107
|
+
provider = AsyncMock()
|
|
108
|
+
provider.get_session = AsyncMock(return_value=mock_session)
|
|
109
|
+
auth = UniFiAuth(local_provider=provider)
|
|
110
|
+
session = await auth.get_session(AuthMethod.LOCAL_ONLY)
|
|
111
|
+
assert session is mock_session
|
|
112
|
+
|
|
113
|
+
@pytest.mark.asyncio
|
|
114
|
+
async def test_get_session_either_prefers_api_key(self):
|
|
115
|
+
mock_session = AsyncMock(spec=aiohttp.ClientSession)
|
|
116
|
+
provider = AsyncMock()
|
|
117
|
+
provider.get_session = AsyncMock(return_value=mock_session)
|
|
118
|
+
auth = UniFiAuth(api_key="test-key", local_provider=provider)
|
|
119
|
+
session = await auth.get_session(AuthMethod.EITHER)
|
|
120
|
+
try:
|
|
121
|
+
# Should prefer API key when both are available
|
|
122
|
+
assert isinstance(session, aiohttp.ClientSession)
|
|
123
|
+
assert session.headers.get("X-API-Key") == "test-key"
|
|
124
|
+
provider.get_session.assert_not_awaited()
|
|
125
|
+
finally:
|
|
126
|
+
await session.close()
|
|
127
|
+
|
|
128
|
+
@pytest.mark.asyncio
|
|
129
|
+
async def test_get_session_either_falls_back_to_local(self):
|
|
130
|
+
mock_session = AsyncMock(spec=aiohttp.ClientSession)
|
|
131
|
+
provider = AsyncMock()
|
|
132
|
+
provider.get_session = AsyncMock(return_value=mock_session)
|
|
133
|
+
auth = UniFiAuth(local_provider=provider)
|
|
134
|
+
session = await auth.get_session(AuthMethod.EITHER)
|
|
135
|
+
assert session is mock_session
|
|
136
|
+
provider.get_session.assert_awaited_once()
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
from unifi_core.connection import ConnectionConfig
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class TestConnectionConfig:
|
|
5
|
+
def test_defaults(self):
|
|
6
|
+
cfg = ConnectionConfig(host="192.168.1.1")
|
|
7
|
+
assert cfg.host == "192.168.1.1"
|
|
8
|
+
assert cfg.port == 443
|
|
9
|
+
assert cfg.verify_ssl is False
|
|
10
|
+
assert cfg.timeout == 30.0
|
|
11
|
+
|
|
12
|
+
def test_custom_values(self):
|
|
13
|
+
cfg = ConnectionConfig(host="10.0.0.1", port=8443, verify_ssl=True, timeout=60.0)
|
|
14
|
+
assert cfg.host == "10.0.0.1"
|
|
15
|
+
assert cfg.port == 8443
|
|
16
|
+
assert cfg.verify_ssl is True
|
|
17
|
+
assert cfg.timeout == 60.0
|
|
18
|
+
|
|
19
|
+
def test_url_base(self):
|
|
20
|
+
cfg = ConnectionConfig(host="192.168.1.1")
|
|
21
|
+
assert cfg.url_base == "https://192.168.1.1:443"
|
|
22
|
+
|
|
23
|
+
def test_url_base_custom_port(self):
|
|
24
|
+
cfg = ConnectionConfig(host="10.0.0.1", port=8443)
|
|
25
|
+
assert cfg.url_base == "https://10.0.0.1:8443"
|
|
26
|
+
|
|
27
|
+
def test_ssl_context_when_verify_false(self):
|
|
28
|
+
cfg = ConnectionConfig(host="192.168.1.1", verify_ssl=False)
|
|
29
|
+
assert cfg.ssl_context is False
|
|
30
|
+
|
|
31
|
+
def test_ssl_context_when_verify_true(self):
|
|
32
|
+
cfg = ConnectionConfig(host="192.168.1.1", verify_ssl=True)
|
|
33
|
+
assert cfg.ssl_context is None
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
from unifi_core.detection import ControllerType
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class TestControllerType:
|
|
6
|
+
def test_enum_values(self):
|
|
7
|
+
assert ControllerType.UNIFI_OS.value == "proxy"
|
|
8
|
+
assert ControllerType.STANDALONE.value == "direct"
|
|
9
|
+
assert ControllerType.AUTO.value == "auto"
|
|
10
|
+
|
|
11
|
+
def test_from_config_proxy(self):
|
|
12
|
+
assert ControllerType.from_config("proxy") == ControllerType.UNIFI_OS
|
|
13
|
+
|
|
14
|
+
def test_from_config_direct(self):
|
|
15
|
+
assert ControllerType.from_config("direct") == ControllerType.STANDALONE
|
|
16
|
+
|
|
17
|
+
def test_from_config_auto(self):
|
|
18
|
+
assert ControllerType.from_config("auto") == ControllerType.AUTO
|
|
19
|
+
|
|
20
|
+
def test_from_config_case_insensitive(self):
|
|
21
|
+
assert ControllerType.from_config("PROXY") == ControllerType.UNIFI_OS
|
|
22
|
+
assert ControllerType.from_config("Direct") == ControllerType.STANDALONE
|
|
23
|
+
assert ControllerType.from_config("AUTO") == ControllerType.AUTO
|
|
24
|
+
|
|
25
|
+
def test_from_config_unknown_falls_back_to_auto(self):
|
|
26
|
+
assert ControllerType.from_config("unknown") == ControllerType.AUTO
|
|
27
|
+
assert ControllerType.from_config("") == ControllerType.AUTO
|
|
28
|
+
|
|
29
|
+
def test_enum_members_complete(self):
|
|
30
|
+
members = set(ControllerType)
|
|
31
|
+
assert members == {ControllerType.UNIFI_OS, ControllerType.STANDALONE, ControllerType.AUTO}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
from unifi_core.exceptions import UniFiError, UniFiAuthError, UniFiConnectionError, UniFiRateLimitError, UniFiPermissionError
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def test_exception_hierarchy():
|
|
5
|
+
assert issubclass(UniFiAuthError, UniFiError)
|
|
6
|
+
assert issubclass(UniFiConnectionError, UniFiError)
|
|
7
|
+
assert issubclass(UniFiRateLimitError, UniFiError)
|
|
8
|
+
assert issubclass(UniFiPermissionError, UniFiError)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def test_exception_message():
|
|
12
|
+
err = UniFiAuthError("Invalid credentials")
|
|
13
|
+
assert str(err) == "Invalid credentials"
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
from unifi_core.retry import RetryPolicy, retry_with_backoff
|
|
3
|
+
from unifi_core.exceptions import UniFiConnectionError
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
@pytest.mark.asyncio
|
|
7
|
+
async def test_retry_succeeds_after_failures():
|
|
8
|
+
call_count = 0
|
|
9
|
+
|
|
10
|
+
async def flaky_operation():
|
|
11
|
+
nonlocal call_count
|
|
12
|
+
call_count += 1
|
|
13
|
+
if call_count < 3:
|
|
14
|
+
raise UniFiConnectionError("Connection failed")
|
|
15
|
+
return "success"
|
|
16
|
+
|
|
17
|
+
policy = RetryPolicy(max_retries=3, base_delay=0.01)
|
|
18
|
+
result = await retry_with_backoff(flaky_operation, policy)
|
|
19
|
+
assert result == "success"
|
|
20
|
+
assert call_count == 3
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@pytest.mark.asyncio
|
|
24
|
+
async def test_retry_exhausted_raises():
|
|
25
|
+
async def always_fails():
|
|
26
|
+
raise UniFiConnectionError("Connection failed")
|
|
27
|
+
|
|
28
|
+
policy = RetryPolicy(max_retries=2, base_delay=0.01)
|
|
29
|
+
with pytest.raises(UniFiConnectionError):
|
|
30
|
+
await retry_with_backoff(always_fails, policy)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@pytest.mark.asyncio
|
|
34
|
+
async def test_retry_succeeds_on_first_try():
|
|
35
|
+
async def succeeds():
|
|
36
|
+
return "ok"
|
|
37
|
+
|
|
38
|
+
policy = RetryPolicy(max_retries=3, base_delay=0.01)
|
|
39
|
+
result = await retry_with_backoff(succeeds, policy)
|
|
40
|
+
assert result == "ok"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@pytest.mark.asyncio
|
|
44
|
+
async def test_retry_non_retryable_exception_not_caught():
|
|
45
|
+
async def raises_value_error():
|
|
46
|
+
raise ValueError("not retryable")
|
|
47
|
+
|
|
48
|
+
policy = RetryPolicy(max_retries=3, base_delay=0.01)
|
|
49
|
+
with pytest.raises(ValueError, match="not retryable"):
|
|
50
|
+
await retry_with_backoff(raises_value_error, policy)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def test_retry_policy_defaults():
|
|
54
|
+
policy = RetryPolicy()
|
|
55
|
+
assert policy.max_retries == 3
|
|
56
|
+
assert policy.base_delay == 1.0
|
|
57
|
+
assert policy.max_delay == 30.0
|
|
58
|
+
assert policy.backoff_factor == 2.0
|