mirror of
https://github.com/python-kasa/python-kasa.git
synced 2026-07-11 09:52:04 +00:00
tests: add type annotations to transport tests (#1684)
Some checks failed
CI / Perform Lint Checks (3.14) (push) Has been cancelled
CI / Python 3.11 on macos-latest (push) Has been cancelled
CI / Python 3.12 on macos-latest (push) Has been cancelled
CI / Python 3.13 on macos-latest (push) Has been cancelled
CI / Python 3.14 on macos-latest (push) Has been cancelled
CI / Python 3.11 on ubuntu-latest (push) Has been cancelled
CI / Python 3.12 on ubuntu-latest (push) Has been cancelled
CI / Python 3.13 on ubuntu-latest (push) Has been cancelled
CI / Python 3.14 on ubuntu-latest (push) Has been cancelled
CI / Python 3.11 on windows-latest (push) Has been cancelled
CI / Python 3.12 on windows-latest (push) Has been cancelled
CI / Python 3.13 on windows-latest (push) Has been cancelled
CI / Python 3.14 on windows-latest (push) Has been cancelled
CodeQL Checks / Analyze (python) (push) Has been cancelled
Some checks failed
CI / Perform Lint Checks (3.14) (push) Has been cancelled
CI / Python 3.11 on macos-latest (push) Has been cancelled
CI / Python 3.12 on macos-latest (push) Has been cancelled
CI / Python 3.13 on macos-latest (push) Has been cancelled
CI / Python 3.14 on macos-latest (push) Has been cancelled
CI / Python 3.11 on ubuntu-latest (push) Has been cancelled
CI / Python 3.12 on ubuntu-latest (push) Has been cancelled
CI / Python 3.13 on ubuntu-latest (push) Has been cancelled
CI / Python 3.14 on ubuntu-latest (push) Has been cancelled
CI / Python 3.11 on windows-latest (push) Has been cancelled
CI / Python 3.12 on windows-latest (push) Has been cancelled
CI / Python 3.13 on windows-latest (push) Has been cancelled
CI / Python 3.14 on windows-latest (push) Has been cancelled
CodeQL Checks / Analyze (python) (push) Has been cancelled
type annotations and parameter type annotations to all test functions and helper functions across the 5 transport test files. This enables mypy to check function bodies, catching type errors that were previously hidden.
This commit is contained in:
@@ -6,6 +6,7 @@ import logging
|
|||||||
import random
|
import random
|
||||||
import string
|
import string
|
||||||
import time
|
import time
|
||||||
|
from contextlib import AbstractContextManager
|
||||||
from contextlib import nullcontext as does_not_raise
|
from contextlib import nullcontext as does_not_raise
|
||||||
from json import dumps as json_dumps
|
from json import dumps as json_dumps
|
||||||
from json import loads as json_loads
|
from json import loads as json_loads
|
||||||
@@ -17,10 +18,11 @@ from cryptography.hazmat.primitives import serialization
|
|||||||
from cryptography.hazmat.primitives.asymmetric import padding as asymmetric_padding
|
from cryptography.hazmat.primitives.asymmetric import padding as asymmetric_padding
|
||||||
from cryptography.hazmat.primitives.asymmetric.rsa import RSAPublicKey
|
from cryptography.hazmat.primitives.asymmetric.rsa import RSAPublicKey
|
||||||
from freezegun.api import FrozenDateTimeFactory
|
from freezegun.api import FrozenDateTimeFactory
|
||||||
|
from pytest_mock import MockerFixture
|
||||||
from yarl import URL
|
from yarl import URL
|
||||||
|
|
||||||
from kasa.credentials import Credentials
|
from kasa.credentials import Credentials
|
||||||
from kasa.deviceconfig import DeviceConfig
|
from kasa.deviceconfig import DeviceConfig, KeyPairDict
|
||||||
from kasa.exceptions import (
|
from kasa.exceptions import (
|
||||||
AuthenticationError,
|
AuthenticationError,
|
||||||
KasaException,
|
KasaException,
|
||||||
@@ -43,7 +45,7 @@ iv = b"9=\xf8\x1bS\xcd0\xb5\x89i\xba\xfd^9\x9f\xfa"
|
|||||||
KEY_IV = key + iv
|
KEY_IV = key + iv
|
||||||
|
|
||||||
|
|
||||||
def test_encrypt():
|
def test_encrypt() -> None:
|
||||||
encryption_session = AesEncyptionSession(KEY_IV[:16], KEY_IV[16:])
|
encryption_session = AesEncyptionSession(KEY_IV[:16], KEY_IV[16:])
|
||||||
|
|
||||||
d = json.dumps({"foo": 1, "bar": 2})
|
d = json.dumps({"foo": 1, "bar": 2})
|
||||||
@@ -69,8 +71,12 @@ status_parameters = pytest.mark.parametrize(
|
|||||||
|
|
||||||
@status_parameters
|
@status_parameters
|
||||||
async def test_handshake(
|
async def test_handshake(
|
||||||
mocker, status_code, error_code, inner_error_code, expectation
|
mocker: MockerFixture,
|
||||||
):
|
status_code: int,
|
||||||
|
error_code: int,
|
||||||
|
inner_error_code: int,
|
||||||
|
expectation: AbstractContextManager,
|
||||||
|
) -> None:
|
||||||
host = "127.0.0.1"
|
host = "127.0.0.1"
|
||||||
mock_aes_device = MockAesDevice(host, status_code, error_code, inner_error_code)
|
mock_aes_device = MockAesDevice(host, status_code, error_code, inner_error_code)
|
||||||
mocker.patch.object(aiohttp.ClientSession, "post", side_effect=mock_aes_device.post)
|
mocker.patch.object(aiohttp.ClientSession, "post", side_effect=mock_aes_device.post)
|
||||||
@@ -87,12 +93,12 @@ async def test_handshake(
|
|||||||
assert transport._state is TransportState.LOGIN_REQUIRED
|
assert transport._state is TransportState.LOGIN_REQUIRED
|
||||||
|
|
||||||
|
|
||||||
async def test_handshake_with_keys(mocker):
|
async def test_handshake_with_keys(mocker: MockerFixture) -> None:
|
||||||
host = "127.0.0.1"
|
host = "127.0.0.1"
|
||||||
mock_aes_device = MockAesDevice(host)
|
mock_aes_device = MockAesDevice(host)
|
||||||
mocker.patch.object(aiohttp.ClientSession, "post", side_effect=mock_aes_device.post)
|
mocker.patch.object(aiohttp.ClientSession, "post", side_effect=mock_aes_device.post)
|
||||||
|
|
||||||
test_keys = {
|
test_keys: KeyPairDict = {
|
||||||
"private": "MIICdQIBADANBgkqhkiG9w0BAQEFAASCAl8wggJbAgEAAoGBAMo/JQpXIbP2M3bLOKyfEVCURFCxHIXv4HDME8J58AL4BwGDXf0oQycgj9nV+T/MzgEd/4iVysYuYfLuIEKXADP7Lby6AfA/dbcinZZ7bLUNMNa7TaylIvVKtSfR0LV8AmG0jdQYkr4cTzLAEd+AEs/wG3nMQNEcoQRVY+svLPDjAgMBAAECgYBCsDOch0KbvrEVmMklUoY5Fcq4+M249HIDf6d8VwznTbWxsAmL8nzCKCCG6eF4QiYjhCrAdPQaCS1PF2oXywbLhngid/9W9gz4CKKDJChs1X8KvLi+TLg1jgJUXvq9yVNh1CB+lS2ho4gdDDCbVmiVOZR5TDfEf0xeJ+Zz3zlUEQJBAPkhuNdc3yRue8huFZbrWwikURQPYBxLOYfVTDsfV9mZGSkGoWS1FPDsxrqSXugTmcTRuw+lrXKDabJ72kqywA8CQQDP0oaGh5r7F12Xzcwb7X9JkTvyr+rO8YgVtKNBaNVOPabAzysNwOlvH/sNCVQcRj8rn5LNXitgLx6T+Q5uqa3tAkA7J0elUzbkhps7ju/vYri9x448zh3K+g2R9BJio2GPmCuCM0HVEK4FOqNBH4oLXsQPGKFq6LLTUuKg74l4XRL/AkBHBO6r8pNn0yhMxCtIL/UbsuIFoVBgv/F9WWmg5K5gOnlN0n4oCRC8xPUKE3IG54qW4cVNIS05hWCxuJ7R+nJRAkByt/+kX1nQxis2wIXj90fztXG3oSmoVaieYxaXPxlWvX3/Q5kslFF5UsGy9gcK0v2PXhqjTbhud3/X0Er6YP4v",
|
"private": "MIICdQIBADANBgkqhkiG9w0BAQEFAASCAl8wggJbAgEAAoGBAMo/JQpXIbP2M3bLOKyfEVCURFCxHIXv4HDME8J58AL4BwGDXf0oQycgj9nV+T/MzgEd/4iVysYuYfLuIEKXADP7Lby6AfA/dbcinZZ7bLUNMNa7TaylIvVKtSfR0LV8AmG0jdQYkr4cTzLAEd+AEs/wG3nMQNEcoQRVY+svLPDjAgMBAAECgYBCsDOch0KbvrEVmMklUoY5Fcq4+M249HIDf6d8VwznTbWxsAmL8nzCKCCG6eF4QiYjhCrAdPQaCS1PF2oXywbLhngid/9W9gz4CKKDJChs1X8KvLi+TLg1jgJUXvq9yVNh1CB+lS2ho4gdDDCbVmiVOZR5TDfEf0xeJ+Zz3zlUEQJBAPkhuNdc3yRue8huFZbrWwikURQPYBxLOYfVTDsfV9mZGSkGoWS1FPDsxrqSXugTmcTRuw+lrXKDabJ72kqywA8CQQDP0oaGh5r7F12Xzcwb7X9JkTvyr+rO8YgVtKNBaNVOPabAzysNwOlvH/sNCVQcRj8rn5LNXitgLx6T+Q5uqa3tAkA7J0elUzbkhps7ju/vYri9x448zh3K+g2R9BJio2GPmCuCM0HVEK4FOqNBH4oLXsQPGKFq6LLTUuKg74l4XRL/AkBHBO6r8pNn0yhMxCtIL/UbsuIFoVBgv/F9WWmg5K5gOnlN0n4oCRC8xPUKE3IG54qW4cVNIS05hWCxuJ7R+nJRAkByt/+kX1nQxis2wIXj90fztXG3oSmoVaieYxaXPxlWvX3/Q5kslFF5UsGy9gcK0v2PXhqjTbhud3/X0Er6YP4v",
|
||||||
"public": "MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDKPyUKVyGz9jN2yzisnxFQlERQsRyF7+BwzBPCefAC+AcBg139KEMnII/Z1fk/zM4BHf+IlcrGLmHy7iBClwAz+y28ugHwP3W3Ip2We2y1DTDWu02spSL1SrUn0dC1fAJhtI3UGJK+HE8ywBHfgBLP8Bt5zEDRHKEEVWPrLyzw4wIDAQAB",
|
"public": "MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDKPyUKVyGz9jN2yzisnxFQlERQsRyF7+BwzBPCefAC+AcBg139KEMnII/Z1fk/zM4BHf+IlcrGLmHy7iBClwAz+y28ugHwP3W3Ip2We2y1DTDWu02spSL1SrUn0dC1fAJhtI3UGJK+HE8ywBHfgBLP8Bt5zEDRHKEEVWPrLyzw4wIDAQAB",
|
||||||
}
|
}
|
||||||
@@ -106,12 +112,19 @@ async def test_handshake_with_keys(mocker):
|
|||||||
assert transport._state is TransportState.HANDSHAKE_REQUIRED
|
assert transport._state is TransportState.HANDSHAKE_REQUIRED
|
||||||
|
|
||||||
await transport.perform_handshake()
|
await transport.perform_handshake()
|
||||||
|
assert transport._key_pair is not None
|
||||||
assert transport._key_pair.private_key_der_b64 == test_keys["private"]
|
assert transport._key_pair.private_key_der_b64 == test_keys["private"]
|
||||||
assert transport._key_pair.public_key_der_b64 == test_keys["public"]
|
assert transport._key_pair.public_key_der_b64 == test_keys["public"]
|
||||||
|
|
||||||
|
|
||||||
@status_parameters
|
@status_parameters
|
||||||
async def test_login(mocker, status_code, error_code, inner_error_code, expectation):
|
async def test_login(
|
||||||
|
mocker: MockerFixture,
|
||||||
|
status_code: int,
|
||||||
|
error_code: int,
|
||||||
|
inner_error_code: int,
|
||||||
|
expectation: AbstractContextManager,
|
||||||
|
) -> None:
|
||||||
host = "127.0.0.1"
|
host = "127.0.0.1"
|
||||||
mock_aes_device = MockAesDevice(host, status_code, error_code, inner_error_code)
|
mock_aes_device = MockAesDevice(host, status_code, error_code, inner_error_code)
|
||||||
mocker.patch.object(aiohttp.ClientSession, "post", side_effect=mock_aes_device.post)
|
mocker.patch.object(aiohttp.ClientSession, "post", side_effect=mock_aes_device.post)
|
||||||
@@ -157,7 +170,12 @@ async def test_login(mocker, status_code, error_code, inner_error_code, expectat
|
|||||||
"LOGIN_ERROR-SESSION_TIMEOUT_ERROR",
|
"LOGIN_ERROR-SESSION_TIMEOUT_ERROR",
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
async def test_login_errors(mocker, inner_error_codes, expectation, call_count):
|
async def test_login_errors(
|
||||||
|
mocker: MockerFixture,
|
||||||
|
inner_error_codes: list[int],
|
||||||
|
expectation: AbstractContextManager,
|
||||||
|
call_count: int,
|
||||||
|
) -> None:
|
||||||
host = "127.0.0.1"
|
host = "127.0.0.1"
|
||||||
mock_aes_device = MockAesDevice(host, 200, 0, inner_error_codes)
|
mock_aes_device = MockAesDevice(host, 200, 0, inner_error_codes)
|
||||||
post_mock = mocker.patch.object(
|
post_mock = mocker.patch.object(
|
||||||
@@ -190,7 +208,13 @@ async def test_login_errors(mocker, inner_error_codes, expectation, call_count):
|
|||||||
|
|
||||||
|
|
||||||
@status_parameters
|
@status_parameters
|
||||||
async def test_send(mocker, status_code, error_code, inner_error_code, expectation):
|
async def test_send(
|
||||||
|
mocker: MockerFixture,
|
||||||
|
status_code: int,
|
||||||
|
error_code: int,
|
||||||
|
inner_error_code: int,
|
||||||
|
expectation: AbstractContextManager,
|
||||||
|
) -> None:
|
||||||
host = "127.0.0.1"
|
host = "127.0.0.1"
|
||||||
mock_aes_device = MockAesDevice(host, status_code, error_code, inner_error_code)
|
mock_aes_device = MockAesDevice(host, status_code, error_code, inner_error_code)
|
||||||
mocker.patch.object(aiohttp.ClientSession, "post", side_effect=mock_aes_device.post)
|
mocker.patch.object(aiohttp.ClientSession, "post", side_effect=mock_aes_device.post)
|
||||||
@@ -198,7 +222,7 @@ async def test_send(mocker, status_code, error_code, inner_error_code, expectati
|
|||||||
transport = AesTransport(
|
transport = AesTransport(
|
||||||
config=DeviceConfig(host, credentials=Credentials("foo", "bar"))
|
config=DeviceConfig(host, credentials=Credentials("foo", "bar"))
|
||||||
)
|
)
|
||||||
transport._handshake_done = True
|
transport._state = TransportState.ESTABLISHED
|
||||||
transport._session_expire_at = time.time() + 86400
|
transport._session_expire_at = time.time() + 86400
|
||||||
transport._encryption_session = mock_aes_device.encryption_session
|
transport._encryption_session = mock_aes_device.encryption_session
|
||||||
transport._token_url = transport._app_url.with_query(
|
transport._token_url = transport._app_url.with_query(
|
||||||
@@ -218,7 +242,9 @@ async def test_send(mocker, status_code, error_code, inner_error_code, expectati
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.xdist_group(name="caplog")
|
@pytest.mark.xdist_group(name="caplog")
|
||||||
async def test_unencrypted_response(mocker, caplog):
|
async def test_unencrypted_response(
|
||||||
|
mocker: MockerFixture, caplog: pytest.LogCaptureFixture
|
||||||
|
) -> None:
|
||||||
host = "127.0.0.1"
|
host = "127.0.0.1"
|
||||||
mock_aes_device = MockAesDevice(host, 200, 0, 0, do_not_encrypt_response=True)
|
mock_aes_device = MockAesDevice(host, 200, 0, 0, do_not_encrypt_response=True)
|
||||||
mocker.patch.object(aiohttp.ClientSession, "post", side_effect=mock_aes_device.post)
|
mocker.patch.object(aiohttp.ClientSession, "post", side_effect=mock_aes_device.post)
|
||||||
@@ -249,7 +275,9 @@ async def test_unencrypted_response(mocker, caplog):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
async def test_unencrypted_response_invalid_json(mocker, caplog):
|
async def test_unencrypted_response_invalid_json(
|
||||||
|
mocker: MockerFixture, caplog: pytest.LogCaptureFixture
|
||||||
|
) -> None:
|
||||||
host = "127.0.0.1"
|
host = "127.0.0.1"
|
||||||
mock_aes_device = MockAesDevice(
|
mock_aes_device = MockAesDevice(
|
||||||
host, 200, 0, 0, do_not_encrypt_response=True, send_response=b"Foobar"
|
host, 200, 0, 0, do_not_encrypt_response=True, send_response=b"Foobar"
|
||||||
@@ -283,14 +311,16 @@ ERRORS = [e for e in SmartErrorCode if e != 0]
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("error_code", ERRORS, ids=lambda e: e.name)
|
@pytest.mark.parametrize("error_code", ERRORS, ids=lambda e: e.name)
|
||||||
async def test_passthrough_errors(mocker, error_code):
|
async def test_passthrough_errors(
|
||||||
|
mocker: MockerFixture, error_code: SmartErrorCode
|
||||||
|
) -> None:
|
||||||
host = "127.0.0.1"
|
host = "127.0.0.1"
|
||||||
mock_aes_device = MockAesDevice(host, 200, error_code, 0)
|
mock_aes_device = MockAesDevice(host, 200, error_code, 0)
|
||||||
mocker.patch.object(aiohttp.ClientSession, "post", side_effect=mock_aes_device.post)
|
mocker.patch.object(aiohttp.ClientSession, "post", side_effect=mock_aes_device.post)
|
||||||
|
|
||||||
config = DeviceConfig(host, credentials=Credentials("foo", "bar"))
|
config = DeviceConfig(host, credentials=Credentials("foo", "bar"))
|
||||||
transport = AesTransport(config=config)
|
transport = AesTransport(config=config)
|
||||||
transport._handshake_done = True
|
transport._state = TransportState.ESTABLISHED
|
||||||
transport._session_expire_at = time.time() + 86400
|
transport._session_expire_at = time.time() + 86400
|
||||||
transport._encryption_session = mock_aes_device.encryption_session
|
transport._encryption_session = mock_aes_device.encryption_session
|
||||||
transport._token_url = transport._app_url.with_query(
|
transport._token_url = transport._app_url.with_query(
|
||||||
@@ -309,14 +339,14 @@ async def test_passthrough_errors(mocker, error_code):
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("error_code", [-13333, 13333])
|
@pytest.mark.parametrize("error_code", [-13333, 13333])
|
||||||
async def test_unknown_errors(mocker, error_code):
|
async def test_unknown_errors(mocker: MockerFixture, error_code: int) -> None:
|
||||||
host = "127.0.0.1"
|
host = "127.0.0.1"
|
||||||
mock_aes_device = MockAesDevice(host, 200, error_code, 0)
|
mock_aes_device = MockAesDevice(host, 200, error_code, 0)
|
||||||
mocker.patch.object(aiohttp.ClientSession, "post", side_effect=mock_aes_device.post)
|
mocker.patch.object(aiohttp.ClientSession, "post", side_effect=mock_aes_device.post)
|
||||||
|
|
||||||
config = DeviceConfig(host, credentials=Credentials("foo", "bar"))
|
config = DeviceConfig(host, credentials=Credentials("foo", "bar"))
|
||||||
transport = AesTransport(config=config)
|
transport = AesTransport(config=config)
|
||||||
transport._handshake_done = True
|
transport._state = TransportState.ESTABLISHED
|
||||||
transport._session_expire_at = time.time() + 86400
|
transport._session_expire_at = time.time() + 86400
|
||||||
transport._encryption_session = mock_aes_device.encryption_session
|
transport._encryption_session = mock_aes_device.encryption_session
|
||||||
transport._token_url = transport._app_url.with_query(
|
transport._token_url = transport._app_url.with_query(
|
||||||
@@ -335,7 +365,7 @@ async def test_unknown_errors(mocker, error_code):
|
|||||||
assert res is SmartErrorCode.INTERNAL_UNKNOWN_ERROR
|
assert res is SmartErrorCode.INTERNAL_UNKNOWN_ERROR
|
||||||
|
|
||||||
|
|
||||||
async def test_port_override():
|
async def test_port_override() -> None:
|
||||||
"""Test that port override sets the app_url."""
|
"""Test that port override sets the app_url."""
|
||||||
host = "127.0.0.1"
|
host = "127.0.0.1"
|
||||||
config = DeviceConfig(
|
config = DeviceConfig(
|
||||||
@@ -356,11 +386,11 @@ async def test_port_override():
|
|||||||
],
|
],
|
||||||
)
|
)
|
||||||
async def test_device_closes_connection(
|
async def test_device_closes_connection(
|
||||||
mocker,
|
mocker: MockerFixture,
|
||||||
freezer: FrozenDateTimeFactory,
|
freezer: FrozenDateTimeFactory,
|
||||||
device_delay_required,
|
device_delay_required: float,
|
||||||
should_error,
|
should_error: bool,
|
||||||
should_succeed,
|
should_succeed: bool,
|
||||||
):
|
):
|
||||||
"""Test the delay logic in http client to deal with devices that close connections after each request.
|
"""Test the delay logic in http client to deal with devices that close connections after each request.
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,17 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import re
|
import re
|
||||||
import secrets
|
import secrets
|
||||||
import time
|
import time
|
||||||
|
from collections.abc import Callable
|
||||||
|
from contextlib import AbstractContextManager
|
||||||
from contextlib import nullcontext as does_not_raise
|
from contextlib import nullcontext as does_not_raise
|
||||||
|
|
||||||
import aiohttp
|
import aiohttp
|
||||||
import pytest
|
import pytest
|
||||||
|
from pytest_mock import MockerFixture
|
||||||
from yarl import URL
|
from yarl import URL
|
||||||
|
|
||||||
from kasa.credentials import DEFAULT_CREDENTIALS, Credentials, get_default_credentials
|
from kasa.credentials import DEFAULT_CREDENTIALS, Credentials, get_default_credentials
|
||||||
@@ -63,8 +68,13 @@ class _mock_response:
|
|||||||
@pytest.mark.parametrize("protocol_class", [IotProtocol, SmartProtocol])
|
@pytest.mark.parametrize("protocol_class", [IotProtocol, SmartProtocol])
|
||||||
@pytest.mark.parametrize("retry_count", [1, 3, 5])
|
@pytest.mark.parametrize("retry_count", [1, 3, 5])
|
||||||
async def test_protocol_retries_via_client_session(
|
async def test_protocol_retries_via_client_session(
|
||||||
mocker, retry_count, protocol_class, transport_class, error, retry_expectation
|
mocker: MockerFixture,
|
||||||
):
|
retry_count: int,
|
||||||
|
protocol_class: type,
|
||||||
|
transport_class: type,
|
||||||
|
error: Exception,
|
||||||
|
retry_expectation: bool,
|
||||||
|
) -> None:
|
||||||
host = "127.0.0.1"
|
host = "127.0.0.1"
|
||||||
conn = mocker.patch.object(aiohttp.ClientSession, "post", side_effect=error)
|
conn = mocker.patch.object(aiohttp.ClientSession, "post", side_effect=error)
|
||||||
|
|
||||||
@@ -91,8 +101,13 @@ async def test_protocol_retries_via_client_session(
|
|||||||
@pytest.mark.parametrize("protocol_class", [IotProtocol, SmartProtocol])
|
@pytest.mark.parametrize("protocol_class", [IotProtocol, SmartProtocol])
|
||||||
@pytest.mark.parametrize("retry_count", [1, 3, 5])
|
@pytest.mark.parametrize("retry_count", [1, 3, 5])
|
||||||
async def test_protocol_retries_via_httpclient(
|
async def test_protocol_retries_via_httpclient(
|
||||||
mocker, retry_count, protocol_class, transport_class, error, retry_expectation
|
mocker: MockerFixture,
|
||||||
):
|
retry_count: int,
|
||||||
|
protocol_class: type,
|
||||||
|
transport_class: type,
|
||||||
|
error: Exception,
|
||||||
|
retry_expectation: bool,
|
||||||
|
) -> None:
|
||||||
host = "127.0.0.1"
|
host = "127.0.0.1"
|
||||||
conn = mocker.patch.object(HttpClient, "post", side_effect=error)
|
conn = mocker.patch.object(HttpClient, "post", side_effect=error)
|
||||||
mocker.patch.object(protocol_class, "BACKOFF_SECONDS_AFTER_TIMEOUT", 0)
|
mocker.patch.object(protocol_class, "BACKOFF_SECONDS_AFTER_TIMEOUT", 0)
|
||||||
@@ -110,8 +125,8 @@ async def test_protocol_retries_via_httpclient(
|
|||||||
@pytest.mark.parametrize("transport_class", [AesTransport, KlapTransport])
|
@pytest.mark.parametrize("transport_class", [AesTransport, KlapTransport])
|
||||||
@pytest.mark.parametrize("protocol_class", [IotProtocol, SmartProtocol])
|
@pytest.mark.parametrize("protocol_class", [IotProtocol, SmartProtocol])
|
||||||
async def test_protocol_no_retry_on_connection_error(
|
async def test_protocol_no_retry_on_connection_error(
|
||||||
mocker, protocol_class, transport_class
|
mocker: MockerFixture, protocol_class: type, transport_class: type
|
||||||
):
|
) -> None:
|
||||||
host = "127.0.0.1"
|
host = "127.0.0.1"
|
||||||
conn = mocker.patch.object(
|
conn = mocker.patch.object(
|
||||||
aiohttp.ClientSession,
|
aiohttp.ClientSession,
|
||||||
@@ -131,8 +146,8 @@ async def test_protocol_no_retry_on_connection_error(
|
|||||||
@pytest.mark.parametrize("transport_class", [AesTransport, KlapTransport])
|
@pytest.mark.parametrize("transport_class", [AesTransport, KlapTransport])
|
||||||
@pytest.mark.parametrize("protocol_class", [IotProtocol, SmartProtocol])
|
@pytest.mark.parametrize("protocol_class", [IotProtocol, SmartProtocol])
|
||||||
async def test_protocol_retry_recoverable_error(
|
async def test_protocol_retry_recoverable_error(
|
||||||
mocker, protocol_class, transport_class
|
mocker: MockerFixture, protocol_class: type, transport_class: type
|
||||||
):
|
) -> None:
|
||||||
host = "127.0.0.1"
|
host = "127.0.0.1"
|
||||||
conn = mocker.patch.object(
|
conn = mocker.patch.object(
|
||||||
aiohttp.ClientSession,
|
aiohttp.ClientSession,
|
||||||
@@ -152,7 +167,9 @@ async def test_protocol_retry_recoverable_error(
|
|||||||
@pytest.mark.parametrize("transport_class", [AesTransport, KlapTransport])
|
@pytest.mark.parametrize("transport_class", [AesTransport, KlapTransport])
|
||||||
@pytest.mark.parametrize("protocol_class", [IotProtocol, SmartProtocol])
|
@pytest.mark.parametrize("protocol_class", [IotProtocol, SmartProtocol])
|
||||||
@pytest.mark.parametrize("retry_count", [1, 3, 5])
|
@pytest.mark.parametrize("retry_count", [1, 3, 5])
|
||||||
async def test_protocol_reconnect(mocker, retry_count, protocol_class, transport_class):
|
async def test_protocol_reconnect(
|
||||||
|
mocker: MockerFixture, retry_count: int, protocol_class: type, transport_class: type
|
||||||
|
) -> None:
|
||||||
host = "127.0.0.1"
|
host = "127.0.0.1"
|
||||||
remaining = retry_count
|
remaining = retry_count
|
||||||
mock_response = {"result": {"great": "success"}, "error_code": 0}
|
mock_response = {"result": {"great": "success"}, "error_code": 0}
|
||||||
@@ -185,12 +202,13 @@ async def test_protocol_reconnect(mocker, retry_count, protocol_class, transport
|
|||||||
|
|
||||||
@pytest.mark.parametrize("log_level", [logging.WARNING, logging.DEBUG])
|
@pytest.mark.parametrize("log_level", [logging.WARNING, logging.DEBUG])
|
||||||
@pytest.mark.xdist_group(name="caplog")
|
@pytest.mark.xdist_group(name="caplog")
|
||||||
async def test_protocol_logging(mocker, caplog, log_level):
|
async def test_protocol_logging(
|
||||||
|
mocker: MockerFixture, caplog: pytest.LogCaptureFixture, log_level: int
|
||||||
|
) -> None:
|
||||||
caplog.set_level(log_level)
|
caplog.set_level(log_level)
|
||||||
logging.getLogger("kasa").setLevel(log_level)
|
logging.getLogger("kasa").setLevel(log_level)
|
||||||
|
|
||||||
def _return_encrypted(*_, **__):
|
def _return_encrypted(*_, **__):
|
||||||
nonlocal encryption_session
|
|
||||||
# Do the encrypt just before returning the value so the incrementing sequence number is correct
|
# Do the encrypt just before returning the value so the incrementing sequence number is correct
|
||||||
encrypted, seq = encryption_session.encrypt('{"great":"success"}')
|
encrypted, seq = encryption_session.encrypt('{"great":"success"}')
|
||||||
return 200, encrypted
|
return 200, encrypted
|
||||||
@@ -200,11 +218,12 @@ async def test_protocol_logging(mocker, caplog, log_level):
|
|||||||
encryption_session = KlapEncryptionSession(seed, seed, auth_hash)
|
encryption_session = KlapEncryptionSession(seed, seed, auth_hash)
|
||||||
|
|
||||||
config = DeviceConfig("127.0.0.1")
|
config = DeviceConfig("127.0.0.1")
|
||||||
protocol = IotProtocol(transport=KlapTransport(config=config))
|
transport = KlapTransport(config=config)
|
||||||
|
protocol = IotProtocol(transport=transport)
|
||||||
|
|
||||||
protocol._transport._handshake_done = True
|
transport._handshake_done = True
|
||||||
protocol._transport._session_expire_at = time.time() + 86400
|
transport._session_expire_at = time.time() + 86400
|
||||||
protocol._transport._encryption_session = encryption_session
|
transport._encryption_session = encryption_session
|
||||||
mocker.patch.object(HttpClient, "post", side_effect=_return_encrypted)
|
mocker.patch.object(HttpClient, "post", side_effect=_return_encrypted)
|
||||||
|
|
||||||
response = await protocol.query({})
|
response = await protocol.query({})
|
||||||
@@ -215,7 +234,7 @@ async def test_protocol_logging(mocker, caplog, log_level):
|
|||||||
assert "success" not in caplog.text
|
assert "success" not in caplog.text
|
||||||
|
|
||||||
|
|
||||||
def test_encrypt():
|
def test_encrypt() -> None:
|
||||||
d = json.dumps({"foo": 1, "bar": 2})
|
d = json.dumps({"foo": 1, "bar": 2})
|
||||||
|
|
||||||
seed = secrets.token_bytes(16)
|
seed = secrets.token_bytes(16)
|
||||||
@@ -227,7 +246,7 @@ def test_encrypt():
|
|||||||
assert d == encryption_session.decrypt(encrypted)
|
assert d == encryption_session.decrypt(encrypted)
|
||||||
|
|
||||||
|
|
||||||
def test_encrypt_unicode():
|
def test_encrypt_unicode() -> None:
|
||||||
d = "{'snowman': '\u2603'}"
|
d = "{'snowman': '\u2603'}"
|
||||||
|
|
||||||
seed = secrets.token_bytes(16)
|
seed = secrets.token_bytes(16)
|
||||||
@@ -241,7 +260,7 @@ def test_encrypt_unicode():
|
|||||||
assert d == decrypted
|
assert d == decrypted
|
||||||
|
|
||||||
|
|
||||||
async def test_transport_decrypt(mocker):
|
async def test_transport_decrypt(mocker: MockerFixture) -> None:
|
||||||
"""Test transport decryption."""
|
"""Test transport decryption."""
|
||||||
d = {"great": "success"}
|
d = {"great": "success"}
|
||||||
|
|
||||||
@@ -255,6 +274,7 @@ async def test_transport_decrypt(mocker):
|
|||||||
transport._encryption_session = encryption_session
|
transport._encryption_session = encryption_session
|
||||||
|
|
||||||
async def _return_response(url: URL, params=None, data=None, *_, **__):
|
async def _return_response(url: URL, params=None, data=None, *_, **__):
|
||||||
|
assert transport._encryption_session is not None
|
||||||
encryption_session = KlapEncryptionSession(
|
encryption_session = KlapEncryptionSession(
|
||||||
transport._encryption_session.local_seed,
|
transport._encryption_session.local_seed,
|
||||||
transport._encryption_session.remote_seed,
|
transport._encryption_session.remote_seed,
|
||||||
@@ -272,7 +292,9 @@ async def test_transport_decrypt(mocker):
|
|||||||
assert d == resp
|
assert d == resp
|
||||||
|
|
||||||
|
|
||||||
async def test_transport_decrypt_error(mocker, caplog):
|
async def test_transport_decrypt_error(
|
||||||
|
mocker: MockerFixture, caplog: pytest.LogCaptureFixture
|
||||||
|
) -> None:
|
||||||
"""Test that a decryption error raises a kasa exception."""
|
"""Test that a decryption error raises a kasa exception."""
|
||||||
d = {"great": "success"}
|
d = {"great": "success"}
|
||||||
|
|
||||||
@@ -286,6 +308,7 @@ async def test_transport_decrypt_error(mocker, caplog):
|
|||||||
transport._encryption_session = encryption_session
|
transport._encryption_session = encryption_session
|
||||||
|
|
||||||
async def _return_response(url: URL, params=None, data=None, *_, **__):
|
async def _return_response(url: URL, params=None, data=None, *_, **__):
|
||||||
|
assert transport._encryption_session is not None
|
||||||
encryption_session = KlapEncryptionSession(
|
encryption_session = KlapEncryptionSession(
|
||||||
secrets.token_bytes(16),
|
secrets.token_bytes(16),
|
||||||
transport._encryption_session.remote_seed,
|
transport._encryption_session.remote_seed,
|
||||||
@@ -330,10 +353,19 @@ async def test_transport_decrypt_error(mocker, caplog):
|
|||||||
],
|
],
|
||||||
)
|
)
|
||||||
async def test_handshake1(
|
async def test_handshake1(
|
||||||
mocker, device_credentials, expectation, transport_class, seed_auth_hash_calc
|
mocker: MockerFixture,
|
||||||
):
|
device_credentials: Credentials,
|
||||||
|
expectation: AbstractContextManager,
|
||||||
|
transport_class: type[KlapTransport],
|
||||||
|
seed_auth_hash_calc: Callable[..., bytes],
|
||||||
|
) -> None:
|
||||||
|
client_seed = None
|
||||||
|
server_seed = secrets.token_bytes(16)
|
||||||
|
client_credentials = Credentials("foo", "bar")
|
||||||
|
device_auth_hash = transport_class.generate_auth_hash(device_credentials)
|
||||||
|
|
||||||
async def _return_handshake1_response(url, params=None, data=None, *_, **__):
|
async def _return_handshake1_response(url, params=None, data=None, *_, **__):
|
||||||
nonlocal client_seed, server_seed, device_auth_hash
|
nonlocal client_seed
|
||||||
|
|
||||||
client_seed = data
|
client_seed = data
|
||||||
seed_auth_hash = _sha256(
|
seed_auth_hash = _sha256(
|
||||||
@@ -341,24 +373,20 @@ async def test_handshake1(
|
|||||||
)
|
)
|
||||||
return _mock_response(200, server_seed + seed_auth_hash)
|
return _mock_response(200, server_seed + seed_auth_hash)
|
||||||
|
|
||||||
client_seed = None
|
|
||||||
server_seed = secrets.token_bytes(16)
|
|
||||||
client_credentials = Credentials("foo", "bar")
|
|
||||||
device_auth_hash = transport_class.generate_auth_hash(device_credentials)
|
|
||||||
|
|
||||||
mocker.patch.object(
|
mocker.patch.object(
|
||||||
aiohttp.ClientSession, "post", side_effect=_return_handshake1_response
|
aiohttp.ClientSession, "post", side_effect=_return_handshake1_response
|
||||||
)
|
)
|
||||||
|
|
||||||
config = DeviceConfig("127.0.0.1", credentials=client_credentials)
|
config = DeviceConfig("127.0.0.1", credentials=client_credentials)
|
||||||
protocol = IotProtocol(transport=transport_class(config=config))
|
transport = transport_class(config=config)
|
||||||
|
protocol = IotProtocol(transport=transport)
|
||||||
|
|
||||||
with expectation:
|
with expectation:
|
||||||
(
|
(
|
||||||
local_seed,
|
local_seed,
|
||||||
device_remote_seed,
|
device_remote_seed,
|
||||||
auth_hash,
|
auth_hash,
|
||||||
) = await protocol._transport.perform_handshake1()
|
) = await transport.perform_handshake1()
|
||||||
|
|
||||||
assert local_seed == client_seed
|
assert local_seed == client_seed
|
||||||
assert device_remote_seed == server_seed
|
assert device_remote_seed == server_seed
|
||||||
@@ -381,15 +409,18 @@ async def test_handshake1(
|
|||||||
],
|
],
|
||||||
)
|
)
|
||||||
async def test_handshake(
|
async def test_handshake(
|
||||||
mocker, transport_class, seed_auth_hash_calc1, seed_auth_hash_calc2
|
mocker: MockerFixture,
|
||||||
):
|
transport_class: type[KlapTransport],
|
||||||
|
seed_auth_hash_calc1: Callable[..., bytes],
|
||||||
|
seed_auth_hash_calc2: Callable[..., bytes],
|
||||||
|
) -> None:
|
||||||
client_seed = None
|
client_seed = None
|
||||||
server_seed = secrets.token_bytes(16)
|
server_seed = secrets.token_bytes(16)
|
||||||
client_credentials = Credentials("foo", "bar")
|
client_credentials = Credentials("foo", "bar")
|
||||||
device_auth_hash = transport_class.generate_auth_hash(client_credentials)
|
device_auth_hash = transport_class.generate_auth_hash(client_credentials)
|
||||||
|
|
||||||
async def _return_handshake_response(url: URL, params=None, data=None, *_, **__):
|
async def _return_handshake_response(url: URL, params=None, data=None, *_, **__):
|
||||||
nonlocal client_seed, server_seed, device_auth_hash
|
nonlocal client_seed
|
||||||
|
|
||||||
if url == URL("http://127.0.0.1:80/app/handshake1"):
|
if url == URL("http://127.0.0.1:80/app/handshake1"):
|
||||||
client_seed = data
|
client_seed = data
|
||||||
@@ -410,20 +441,21 @@ async def test_handshake(
|
|||||||
)
|
)
|
||||||
|
|
||||||
config = DeviceConfig("127.0.0.1", credentials=client_credentials)
|
config = DeviceConfig("127.0.0.1", credentials=client_credentials)
|
||||||
protocol = IotProtocol(transport=transport_class(config=config))
|
transport = transport_class(config=config)
|
||||||
|
protocol = IotProtocol(transport=transport)
|
||||||
|
|
||||||
response_status = 200
|
response_status = 200
|
||||||
await protocol._transport.perform_handshake()
|
await transport.perform_handshake()
|
||||||
assert protocol._transport._handshake_done is True
|
assert transport._handshake_done is True
|
||||||
|
|
||||||
response_status = 403
|
response_status = 403
|
||||||
with pytest.raises(KasaException):
|
with pytest.raises(KasaException):
|
||||||
await protocol._transport.perform_handshake()
|
await transport.perform_handshake()
|
||||||
assert protocol._transport._handshake_done is False
|
assert transport._handshake_done is False
|
||||||
await protocol.close()
|
await protocol.close()
|
||||||
|
|
||||||
|
|
||||||
async def test_query(mocker):
|
async def test_query(mocker: MockerFixture) -> None:
|
||||||
client_seed = None
|
client_seed = None
|
||||||
last_seq = None
|
last_seq = None
|
||||||
seq = None
|
seq = None
|
||||||
@@ -432,7 +464,7 @@ async def test_query(mocker):
|
|||||||
device_auth_hash = KlapTransport.generate_auth_hash(client_credentials)
|
device_auth_hash = KlapTransport.generate_auth_hash(client_credentials)
|
||||||
|
|
||||||
async def _return_response(url: URL, params=None, data=None, *_, **__):
|
async def _return_response(url: URL, params=None, data=None, *_, **__):
|
||||||
nonlocal client_seed, server_seed, device_auth_hash, seq
|
nonlocal client_seed, seq
|
||||||
|
|
||||||
if url == URL("http://127.0.0.1:80/app/handshake1"):
|
if url == URL("http://127.0.0.1:80/app/handshake1"):
|
||||||
client_seed = data
|
client_seed = data
|
||||||
@@ -442,10 +474,11 @@ async def test_query(mocker):
|
|||||||
elif url == URL("http://127.0.0.1:80/app/handshake2"):
|
elif url == URL("http://127.0.0.1:80/app/handshake2"):
|
||||||
return _mock_response(200, b"")
|
return _mock_response(200, b"")
|
||||||
elif url == URL("http://127.0.0.1:80/app/request"):
|
elif url == URL("http://127.0.0.1:80/app/request"):
|
||||||
|
assert transport._encryption_session is not None
|
||||||
encryption_session = KlapEncryptionSession(
|
encryption_session = KlapEncryptionSession(
|
||||||
protocol._transport._encryption_session.local_seed,
|
transport._encryption_session.local_seed,
|
||||||
protocol._transport._encryption_session.remote_seed,
|
transport._encryption_session.remote_seed,
|
||||||
protocol._transport._encryption_session.user_hash,
|
transport._encryption_session.user_hash,
|
||||||
)
|
)
|
||||||
seq = params.get("seq")
|
seq = params.get("seq")
|
||||||
encryption_session._seq = seq - 1
|
encryption_session._seq = seq - 1
|
||||||
@@ -456,7 +489,8 @@ async def test_query(mocker):
|
|||||||
mocker.patch.object(aiohttp.ClientSession, "post", side_effect=_return_response)
|
mocker.patch.object(aiohttp.ClientSession, "post", side_effect=_return_response)
|
||||||
|
|
||||||
config = DeviceConfig("127.0.0.1", credentials=client_credentials)
|
config = DeviceConfig("127.0.0.1", credentials=client_credentials)
|
||||||
protocol = IotProtocol(transport=KlapTransport(config=config))
|
transport = KlapTransport(config=config)
|
||||||
|
protocol = IotProtocol(transport=transport)
|
||||||
|
|
||||||
for _ in range(10):
|
for _ in range(10):
|
||||||
resp = await protocol.query({})
|
resp = await protocol.query({})
|
||||||
@@ -508,8 +542,11 @@ async def test_query(mocker):
|
|||||||
],
|
],
|
||||||
)
|
)
|
||||||
async def test_authentication_failures(
|
async def test_authentication_failures(
|
||||||
mocker, response_status, credentials_match, expectation
|
mocker: MockerFixture,
|
||||||
):
|
response_status: tuple[int, ...],
|
||||||
|
credentials_match: bool | bytes,
|
||||||
|
expectation: AbstractContextManager,
|
||||||
|
) -> None:
|
||||||
client_seed = None
|
client_seed = None
|
||||||
|
|
||||||
server_seed = secrets.token_bytes(16)
|
server_seed = secrets.token_bytes(16)
|
||||||
@@ -520,12 +557,7 @@ async def test_authentication_failures(
|
|||||||
device_auth_hash = KlapTransport.generate_auth_hash(device_credentials)
|
device_auth_hash = KlapTransport.generate_auth_hash(device_credentials)
|
||||||
|
|
||||||
async def _return_response(url: URL, params=None, data=None, *_, **__):
|
async def _return_response(url: URL, params=None, data=None, *_, **__):
|
||||||
nonlocal \
|
nonlocal client_seed
|
||||||
client_seed, \
|
|
||||||
server_seed, \
|
|
||||||
device_auth_hash, \
|
|
||||||
response_status, \
|
|
||||||
credentials_match
|
|
||||||
|
|
||||||
if url == URL("http://127.0.0.1:80/app/handshake1"):
|
if url == URL("http://127.0.0.1:80/app/handshake1"):
|
||||||
client_seed = data
|
client_seed = data
|
||||||
@@ -553,7 +585,7 @@ async def test_authentication_failures(
|
|||||||
await protocol.query({})
|
await protocol.query({})
|
||||||
|
|
||||||
|
|
||||||
async def test_port_override():
|
async def test_port_override() -> None:
|
||||||
"""Test that port override sets the app_url."""
|
"""Test that port override sets the app_url."""
|
||||||
host = "127.0.0.1"
|
host = "127.0.0.1"
|
||||||
config = DeviceConfig(
|
config = DeviceConfig(
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ from unittest.mock import ANY
|
|||||||
|
|
||||||
import aiohttp
|
import aiohttp
|
||||||
import pytest
|
import pytest
|
||||||
|
from pytest_mock import MockerFixture
|
||||||
from yarl import URL
|
from yarl import URL
|
||||||
|
|
||||||
from kasa.credentials import DEFAULT_CREDENTIALS, Credentials, get_default_credentials
|
from kasa.credentials import DEFAULT_CREDENTIALS, Credentials, get_default_credentials
|
||||||
@@ -18,7 +19,7 @@ KASACAM_RESPONSE_ERROR = '{"smartlife.cam.ipcamera.cloud": {"get_inf": {"err_cod
|
|||||||
KASA_DEFAULT_CREDENTIALS_HASH = "YWRtaW46MjEyMzJmMjk3YTU3YTVhNzQzODk0YTBlNGE4MDFmYzM="
|
KASA_DEFAULT_CREDENTIALS_HASH = "YWRtaW46MjEyMzJmMjk3YTU3YTVhNzQzODk0YTBlNGE4MDFmYzM="
|
||||||
|
|
||||||
|
|
||||||
async def test_working(mocker):
|
async def test_working(mocker: MockerFixture) -> None:
|
||||||
"""No errors with an expected request/response."""
|
"""No errors with an expected request/response."""
|
||||||
host = "127.0.0.1"
|
host = "127.0.0.1"
|
||||||
mock_linkie_device = MockLinkieDevice(host)
|
mock_linkie_device = MockLinkieDevice(host)
|
||||||
@@ -35,7 +36,7 @@ async def test_working(mocker):
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
async def test_credentials_hash(mocker):
|
async def test_credentials_hash(mocker: MockerFixture) -> None:
|
||||||
"""Ensure the default credentials are always passed as Basic Auth."""
|
"""Ensure the default credentials are always passed as Basic Auth."""
|
||||||
# Test without credentials input
|
# Test without credentials input
|
||||||
|
|
||||||
@@ -92,7 +93,9 @@ async def test_credentials_hash(mocker):
|
|||||||
(200, KASACAM_RESPONSE_ERROR, "Unsupported API call"),
|
(200, KASACAM_RESPONSE_ERROR, "Unsupported API call"),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
async def test_exceptions(mocker, return_status, return_data, expected):
|
async def test_exceptions(
|
||||||
|
mocker: MockerFixture, return_status: int, return_data: str, expected: str
|
||||||
|
) -> None:
|
||||||
"""Test a variety of possible responses from the device."""
|
"""Test a variety of possible responses from the device."""
|
||||||
host = "127.0.0.1"
|
host = "127.0.0.1"
|
||||||
transport = LinkieTransportV2(config=DeviceConfig(host))
|
transport = LinkieTransportV2(config=DeviceConfig(host))
|
||||||
@@ -107,7 +110,7 @@ async def test_exceptions(mocker, return_status, return_data, expected):
|
|||||||
await transport.send(KASACAM_REQUEST_PLAINTEXT)
|
await transport.send(KASACAM_REQUEST_PLAINTEXT)
|
||||||
|
|
||||||
|
|
||||||
def _generate_kascam_basic_auth():
|
def _generate_kascam_basic_auth() -> str:
|
||||||
creds = get_default_credentials(DEFAULT_CREDENTIALS["KASACAMERA"])
|
creds = get_default_credentials(DEFAULT_CREDENTIALS["KASACAMERA"])
|
||||||
creds_combined = f"{creds.username}:{creds.password}"
|
creds_combined = f"{creds.username}:{creds.password}"
|
||||||
return base64.b64encode(creds_combined.encode()).decode()
|
return base64.b64encode(creds_combined.encode()).decode()
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ from __future__ import annotations
|
|||||||
import base64
|
import base64
|
||||||
import logging
|
import logging
|
||||||
import secrets
|
import secrets
|
||||||
|
from contextlib import AbstractContextManager
|
||||||
from contextlib import nullcontext as does_not_raise
|
from contextlib import nullcontext as does_not_raise
|
||||||
from json import dumps as json_dumps
|
from json import dumps as json_dumps
|
||||||
from json import loads as json_loads
|
from json import loads as json_loads
|
||||||
@@ -10,6 +11,7 @@ from typing import Any
|
|||||||
|
|
||||||
import aiohttp
|
import aiohttp
|
||||||
import pytest
|
import pytest
|
||||||
|
from pytest_mock import MockerFixture
|
||||||
from yarl import URL
|
from yarl import URL
|
||||||
|
|
||||||
from kasa.credentials import DEFAULT_CREDENTIALS, Credentials, get_default_credentials
|
from kasa.credentials import DEFAULT_CREDENTIALS, Credentials, get_default_credentials
|
||||||
@@ -107,14 +109,14 @@ MOCK_UNENCRYPTED_PASSTHROUGH_STOK = "32charLowerCaseHexStok"
|
|||||||
],
|
],
|
||||||
)
|
)
|
||||||
async def test_handshake(
|
async def test_handshake(
|
||||||
mocker,
|
mocker: MockerFixture,
|
||||||
status_code,
|
status_code: int,
|
||||||
username,
|
username: str,
|
||||||
password,
|
password: str,
|
||||||
wants_default_user,
|
wants_default_user: bool,
|
||||||
digest_password_fail,
|
digest_password_fail: bool,
|
||||||
expectation,
|
expectation: AbstractContextManager,
|
||||||
):
|
) -> None:
|
||||||
host = "127.0.0.1"
|
host = "127.0.0.1"
|
||||||
mock_ssl_aes_device = MockSslAesDevice(
|
mock_ssl_aes_device = MockSslAesDevice(
|
||||||
host,
|
host,
|
||||||
@@ -142,7 +144,9 @@ async def test_handshake(
|
|||||||
("wants_default_user"),
|
("wants_default_user"),
|
||||||
[pytest.param(False, id="username"), pytest.param(True, id="default")],
|
[pytest.param(False, id="username"), pytest.param(True, id="default")],
|
||||||
)
|
)
|
||||||
async def test_credentials_hash(mocker, wants_default_user):
|
async def test_credentials_hash(
|
||||||
|
mocker: MockerFixture, wants_default_user: bool
|
||||||
|
) -> None:
|
||||||
host = "127.0.0.1"
|
host = "127.0.0.1"
|
||||||
mock_ssl_aes_device = MockSslAesDevice(
|
mock_ssl_aes_device = MockSslAesDevice(
|
||||||
host, want_default_username=wants_default_user
|
host, want_default_username=wants_default_user
|
||||||
@@ -167,7 +171,7 @@ async def test_credentials_hash(mocker, wants_default_user):
|
|||||||
assert transport.credentials_hash == creds_hash
|
assert transport.credentials_hash == creds_hash
|
||||||
|
|
||||||
|
|
||||||
async def test_send(mocker):
|
async def test_send(mocker: MockerFixture) -> None:
|
||||||
host = "127.0.0.1"
|
host = "127.0.0.1"
|
||||||
mock_ssl_aes_device = MockSslAesDevice(host, want_default_username=False)
|
mock_ssl_aes_device = MockSslAesDevice(host, want_default_username=False)
|
||||||
mocker.patch.object(
|
mocker.patch.object(
|
||||||
@@ -187,7 +191,9 @@ async def test_send(mocker):
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.xdist_group(name="caplog")
|
@pytest.mark.xdist_group(name="caplog")
|
||||||
async def test_unencrypted_response(mocker, caplog):
|
async def test_unencrypted_response(
|
||||||
|
mocker: MockerFixture, caplog: pytest.LogCaptureFixture
|
||||||
|
) -> None:
|
||||||
host = "127.0.0.1"
|
host = "127.0.0.1"
|
||||||
mock_ssl_aes_device = MockSslAesDevice(host, do_not_encrypt_response=True)
|
mock_ssl_aes_device = MockSslAesDevice(host, do_not_encrypt_response=True)
|
||||||
mocker.patch.object(
|
mocker.patch.object(
|
||||||
@@ -213,7 +219,9 @@ async def test_unencrypted_response(mocker, caplog):
|
|||||||
|
|
||||||
@pytest.mark.parametrize(("want_default"), [True, False])
|
@pytest.mark.parametrize(("want_default"), [True, False])
|
||||||
@pytest.mark.xdist_group(name="caplog")
|
@pytest.mark.xdist_group(name="caplog")
|
||||||
async def test_unencrypted_passthrough(mocker, caplog, want_default):
|
async def test_unencrypted_passthrough(
|
||||||
|
mocker: MockerFixture, caplog: pytest.LogCaptureFixture, want_default: bool
|
||||||
|
) -> None:
|
||||||
host = "127.0.0.1"
|
host = "127.0.0.1"
|
||||||
mock_ssl_aes_device = MockSslAesDevice(
|
mock_ssl_aes_device = MockSslAesDevice(
|
||||||
host, unencrypted_passthrough=True, want_default_username=want_default
|
host, unencrypted_passthrough=True, want_default_username=want_default
|
||||||
@@ -240,7 +248,9 @@ async def test_unencrypted_passthrough(mocker, caplog, want_default):
|
|||||||
|
|
||||||
@pytest.mark.parametrize(("want_default"), [True, False])
|
@pytest.mark.parametrize(("want_default"), [True, False])
|
||||||
@pytest.mark.xdist_group(name="caplog")
|
@pytest.mark.xdist_group(name="caplog")
|
||||||
async def test_unencrypted_passthrough_errors(mocker, caplog, want_default):
|
async def test_unencrypted_passthrough_errors(
|
||||||
|
mocker: MockerFixture, caplog: pytest.LogCaptureFixture, want_default: bool
|
||||||
|
) -> None:
|
||||||
host = "127.0.0.1"
|
host = "127.0.0.1"
|
||||||
request = {
|
request = {
|
||||||
"method": "getDeviceInfo",
|
"method": "getDeviceInfo",
|
||||||
@@ -329,7 +339,7 @@ async def test_unencrypted_passthrough_errors(mocker, caplog, want_default):
|
|||||||
await transport.send(json_dumps(request))
|
await transport.send(json_dumps(request))
|
||||||
|
|
||||||
|
|
||||||
async def test_device_blocked_response(mocker):
|
async def test_device_blocked_response(mocker: MockerFixture) -> None:
|
||||||
host = "127.0.0.1"
|
host = "127.0.0.1"
|
||||||
mock_ssl_aes_device = MockSslAesDevice(host, device_blocked=True)
|
mock_ssl_aes_device = MockSslAesDevice(host, device_blocked=True)
|
||||||
mocker.patch.object(
|
mocker.patch.object(
|
||||||
@@ -360,7 +370,9 @@ async def test_device_blocked_response(mocker):
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
async def test_device_500_error(mocker, response, expected_msg):
|
async def test_device_500_error(
|
||||||
|
mocker: MockerFixture, response: dict | bytes, expected_msg: str
|
||||||
|
) -> None:
|
||||||
"""Test 500 error raises retryable exception."""
|
"""Test 500 error raises retryable exception."""
|
||||||
host = "127.0.0.1"
|
host = "127.0.0.1"
|
||||||
mock_ssl_aes_device = MockSslAesDevice(host)
|
mock_ssl_aes_device = MockSslAesDevice(host)
|
||||||
@@ -387,7 +399,7 @@ async def test_device_500_error(mocker, response, expected_msg):
|
|||||||
await transport.send(json_dumps(request))
|
await transport.send(json_dumps(request))
|
||||||
|
|
||||||
|
|
||||||
async def test_port_override():
|
async def test_port_override() -> None:
|
||||||
"""Test that port override sets the app_url."""
|
"""Test that port override sets the app_url."""
|
||||||
host = "127.0.0.1"
|
host = "127.0.0.1"
|
||||||
port_override = 12345
|
port_override = 12345
|
||||||
@@ -420,8 +432,8 @@ async def test_port_override():
|
|||||||
],
|
],
|
||||||
)
|
)
|
||||||
async def test_login_version_default_credentials(
|
async def test_login_version_default_credentials(
|
||||||
mocker, login_version, expected_password_b64
|
mocker: MockerFixture, login_version: int | None, expected_password_b64: str
|
||||||
):
|
) -> None:
|
||||||
"""Test that login_version=3 uses TAPOCAMERA_LV3 credentials while other versions use TAPOCAMERA."""
|
"""Test that login_version=3 uses TAPOCAMERA_LV3 credentials while other versions use TAPOCAMERA."""
|
||||||
host = "127.0.0.1"
|
host = "127.0.0.1"
|
||||||
tapo_family = DeviceFamily.SmartIpCamera
|
tapo_family = DeviceFamily.SmartIpCamera
|
||||||
|
|||||||
@@ -2,11 +2,13 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import logging
|
import logging
|
||||||
from base64 import b64encode
|
from base64 import b64encode
|
||||||
|
from contextlib import AbstractContextManager
|
||||||
from contextlib import nullcontext as does_not_raise
|
from contextlib import nullcontext as does_not_raise
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import aiohttp
|
import aiohttp
|
||||||
import pytest
|
import pytest
|
||||||
|
from pytest_mock import MockerFixture
|
||||||
from yarl import URL
|
from yarl import URL
|
||||||
|
|
||||||
from kasa.credentials import DEFAULT_CREDENTIALS, Credentials, get_default_credentials
|
from kasa.credentials import DEFAULT_CREDENTIALS, Credentials, get_default_credentials
|
||||||
@@ -122,13 +124,13 @@ _LOGGER = logging.getLogger(__name__)
|
|||||||
],
|
],
|
||||||
)
|
)
|
||||||
async def test_login(
|
async def test_login(
|
||||||
mocker,
|
mocker: MockerFixture,
|
||||||
status_code,
|
status_code: int,
|
||||||
error_code,
|
error_code: SmartErrorCode | list[SmartErrorCode],
|
||||||
username,
|
username: str,
|
||||||
password,
|
password: str,
|
||||||
expectation,
|
expectation: AbstractContextManager,
|
||||||
):
|
) -> None:
|
||||||
host = "127.0.0.1"
|
host = "127.0.0.1"
|
||||||
mock_ssl_aes_device = MockSslDevice(
|
mock_ssl_aes_device = MockSslDevice(
|
||||||
host,
|
host,
|
||||||
@@ -151,7 +153,7 @@ async def test_login(
|
|||||||
await transport.close()
|
await transport.close()
|
||||||
|
|
||||||
|
|
||||||
async def test_credentials_hash(mocker):
|
async def test_credentials_hash(mocker: MockerFixture) -> None:
|
||||||
host = "127.0.0.1"
|
host = "127.0.0.1"
|
||||||
mock_ssl_aes_device = MockSslDevice(host)
|
mock_ssl_aes_device = MockSslDevice(host)
|
||||||
mocker.patch.object(
|
mocker.patch.object(
|
||||||
@@ -174,7 +176,7 @@ async def test_credentials_hash(mocker):
|
|||||||
await transport.close()
|
await transport.close()
|
||||||
|
|
||||||
|
|
||||||
async def test_send(mocker):
|
async def test_send(mocker: MockerFixture) -> None:
|
||||||
host = "127.0.0.1"
|
host = "127.0.0.1"
|
||||||
mock_ssl_aes_device = MockSslDevice(host, send_error_code=SmartErrorCode.SUCCESS)
|
mock_ssl_aes_device = MockSslDevice(host, send_error_code=SmartErrorCode.SUCCESS)
|
||||||
mocker.patch.object(
|
mocker.patch.object(
|
||||||
@@ -203,7 +205,7 @@ async def test_send(mocker):
|
|||||||
await transport.close()
|
await transport.close()
|
||||||
|
|
||||||
|
|
||||||
async def test_no_credentials(mocker):
|
async def test_no_credentials(mocker: MockerFixture) -> None:
|
||||||
"""Test transport without credentials."""
|
"""Test transport without credentials."""
|
||||||
host = "127.0.0.1"
|
host = "127.0.0.1"
|
||||||
mock_ssl_aes_device = MockSslDevice(
|
mock_ssl_aes_device = MockSslDevice(
|
||||||
@@ -225,7 +227,7 @@ async def test_no_credentials(mocker):
|
|||||||
await transport.close()
|
await transport.close()
|
||||||
|
|
||||||
|
|
||||||
async def test_reset(mocker):
|
async def test_reset(mocker: MockerFixture) -> None:
|
||||||
"""Test that transport state adjusts correctly for reset."""
|
"""Test that transport state adjusts correctly for reset."""
|
||||||
host = "127.0.0.1"
|
host = "127.0.0.1"
|
||||||
mock_ssl_aes_device = MockSslDevice(host, send_error_code=SmartErrorCode.SUCCESS)
|
mock_ssl_aes_device = MockSslDevice(host, send_error_code=SmartErrorCode.SUCCESS)
|
||||||
@@ -249,7 +251,7 @@ async def test_reset(mocker):
|
|||||||
assert str(transport._app_url) == "https://127.0.0.1:4433/app"
|
assert str(transport._app_url) == "https://127.0.0.1:4433/app"
|
||||||
|
|
||||||
|
|
||||||
async def test_port_override():
|
async def test_port_override() -> None:
|
||||||
"""Test that port override sets the app_url."""
|
"""Test that port override sets the app_url."""
|
||||||
host = "127.0.0.1"
|
host = "127.0.0.1"
|
||||||
port_override = 12345
|
port_override = 12345
|
||||||
|
|||||||
Reference in New Issue
Block a user