Skip to content

Commit 65865c5

Browse files
authored
Merge pull request #14 from CoMPaTech/testing
Add tests and station reconnect
2 parents 539c060 + d70288b commit 65865c5

File tree

4 files changed

+142
-7
lines changed

4 files changed

+142
-7
lines changed

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,3 +10,4 @@ tests/__pycache__
1010
.vscode
1111
.coverage
1212
tmp
13+
todo

airos/airos8.py

Lines changed: 53 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44

55
import json
66
import logging
7-
from urllib.parse import urlparse
7+
from urllib.parse import quote, urlparse
88

99
import aiohttp
1010

@@ -47,6 +47,7 @@ def __init__(
4747

4848
self._login_url = f"{self.base_url}/api/auth" # AirOS 8
4949
self._status_cgi_url = f"{self.base_url}/status.cgi" # AirOS 8
50+
self._stakick_cgi_url = f"{self.base_url}/stakick.cgi" # AirOS 8
5051
self.current_csrf_token = None
5152

5253
self._use_json_for_login_post = False
@@ -147,19 +148,19 @@ async def login(self) -> bool:
147148
# Re-check cookies in self.session.cookie_jar AFTER potential manual injection
148149
airos_cookie_found = False
149150
ok_cookie_found = False
150-
if not self.session.cookie_jar:
151+
if not self.session.cookie_jar: # pragma: no cover
151152
logger.exception(
152153
"COOKIE JAR IS EMPTY after login POST. This is a major issue."
153154
)
154155
raise ConnectionSetupError from None
155-
for cookie in self.session.cookie_jar:
156+
for cookie in self.session.cookie_jar: # pragma: no cover
156157
if cookie.key.startswith("AIROS_"):
157158
airos_cookie_found = True
158159
if cookie.key == "ok":
159160
ok_cookie_found = True
160161

161162
if not airos_cookie_found and not ok_cookie_found:
162-
raise ConnectionSetupError from None
163+
raise ConnectionSetupError from None # pragma: no cover
163164

164165
response_text = await response.text()
165166

@@ -176,7 +177,10 @@ async def login(self) -> bool:
176177
log = f"Login failed with status {response.status}. Full Response: {response.text}"
177178
logger.error(log)
178179
raise ConnectionAuthenticationError from None
179-
except aiohttp.ClientError as err:
180+
except (
181+
aiohttp.ClientError,
182+
aiohttp.client_exceptions.ConnectionTimeoutError,
183+
) as err:
180184
logger.exception("Error during login")
181185
raise DeviceConnectionError from err
182186

@@ -208,6 +212,49 @@ async def status(self) -> dict:
208212
else:
209213
log = f"Authenticated status.cgi failed: {response.status}. Response: {response_text}"
210214
logger.error(log)
211-
except aiohttp.ClientError as err:
215+
except (
216+
aiohttp.ClientError,
217+
aiohttp.client_exceptions.ConnectionTimeoutError,
218+
) as err:
212219
logger.exception("Error during authenticated status.cgi call")
213220
raise DeviceConnectionError from err
221+
222+
async def stakick(self, mac_address: str = None) -> bool:
223+
"""Reconnect client station."""
224+
if not self.connected:
225+
logger.error("Not connected, login first")
226+
raise DeviceConnectionError from None
227+
if not mac_address:
228+
logger.error("Device mac-address missing")
229+
raise DataMissingError from None
230+
231+
# --- Step 2: Verify authenticated access by fetching status.cgi ---
232+
kick_request_headers = {**self._common_headers}
233+
if self.current_csrf_token:
234+
kick_request_headers["X-CSRF-ID"] = self.current_csrf_token
235+
236+
kick_payload = {
237+
"staif": "ath0",
238+
"staid": quote(mac_address.upper(), safe=""),
239+
}
240+
241+
kick_request_headers["Content-Type"] = (
242+
"application/x-www-form-urlencoded; charset=UTF-8"
243+
)
244+
post_data = kick_payload
245+
246+
try:
247+
async with self.session.post(
248+
self._stakick_cgi_url,
249+
headers=kick_request_headers,
250+
data=post_data,
251+
) as response:
252+
if response.status == 200:
253+
return True
254+
return False
255+
except (
256+
aiohttp.ClientError,
257+
aiohttp.client_exceptions.ConnectionTimeoutError,
258+
) as err:
259+
logger.exception("Error during reconnect stakick.cgi call")
260+
raise DeviceConnectionError from err

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
44

55
[project]
66
name = "airos"
7-
version = "0.0.8"
7+
version = "0.0.9"
88
license = "MIT"
99
description = "Ubiquity airOS module(s) for Python 3."
1010
readme = "README.md"

tests/test_stations.py

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,11 @@
55
import os
66
from unittest.mock import AsyncMock, MagicMock, patch
77

8+
import airos.exceptions
89
import pytest
910

1011
import aiofiles
12+
import aiohttp
1113

1214

1315
async def _read_fixture(fixture: str = "ap-ptp"):
@@ -56,3 +58,88 @@ async def test_ap(airos_device, base_url, mode):
5658

5759
# Verify the fixture returns the correct mode
5860
assert status.get("wireless", {}).get("mode") == mode
61+
62+
63+
@pytest.mark.asyncio
64+
async def test_reconnect(airos_device, base_url):
65+
"""Test reconnect client."""
66+
# --- Prepare fake POST /api/stakick response ---
67+
mock_stakick_response = MagicMock()
68+
mock_stakick_response.__aenter__.return_value = mock_stakick_response
69+
mock_stakick_response.status = 200
70+
71+
with (
72+
patch.object(airos_device.session, "post", return_value=mock_stakick_response),
73+
patch.object(airos_device, "connected", True),
74+
):
75+
assert await airos_device.stakick("01:23:45:67:89:aB")
76+
77+
78+
@pytest.mark.asyncio
79+
async def test_ap_corners(airos_device, base_url, mode="ap-ptp"):
80+
"""Test device operation."""
81+
cookie = SimpleCookie()
82+
cookie["session_id"] = "test-cookie"
83+
cookie["AIROS_TOKEN"] = "abc123"
84+
85+
# --- Prepare fake POST /api/auth response with cookies ---
86+
mock_login_response = MagicMock()
87+
mock_login_response.__aenter__.return_value = mock_login_response
88+
mock_login_response.text = AsyncMock(return_value="{}")
89+
mock_login_response.status = 200
90+
mock_login_response.cookies = cookie
91+
mock_login_response.headers = {"X-CSRF-ID": "test-csrf-token"}
92+
93+
with (
94+
patch.object(airos_device.session, "post", return_value=mock_login_response),
95+
patch.object(airos_device, "_use_json_for_login_post", return_value=True),
96+
):
97+
assert await airos_device.login()
98+
99+
mock_login_response.cookies = {}
100+
with (
101+
patch.object(airos_device.session, "post", return_value=mock_login_response),
102+
):
103+
try:
104+
assert await airos_device.login()
105+
assert False
106+
except airos.exceptions.ConnectionSetupError:
107+
assert True
108+
109+
mock_login_response.cookies = cookie
110+
mock_login_response.headers = {}
111+
with (
112+
patch.object(airos_device.session, "post", return_value=mock_login_response),
113+
):
114+
result = await airos_device.login()
115+
assert result is None
116+
117+
mock_login_response.headers = {"X-CSRF-ID": "test-csrf-token"}
118+
mock_login_response.text = AsyncMock(return_value="abc123")
119+
with (
120+
patch.object(airos_device.session, "post", return_value=mock_login_response),
121+
):
122+
try:
123+
assert await airos_device.login()
124+
assert False
125+
except airos.exceptions.DataMissingError:
126+
assert True
127+
128+
mock_login_response.text = AsyncMock(return_value="{}")
129+
mock_login_response.status = 400
130+
with (
131+
patch.object(airos_device.session, "post", return_value=mock_login_response),
132+
):
133+
try:
134+
assert await airos_device.login()
135+
assert False
136+
except airos.exceptions.ConnectionAuthenticationError:
137+
assert True
138+
139+
mock_login_response.status = 200
140+
with patch.object(airos_device.session, "post", side_effect=aiohttp.ClientError):
141+
try:
142+
assert await airos_device.login()
143+
assert False
144+
except airos.exceptions.DeviceConnectionError:
145+
assert True

0 commit comments

Comments
 (0)