diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile new file mode 100644 index 0000000..b429c19 --- /dev/null +++ b/.devcontainer/Dockerfile @@ -0,0 +1,21 @@ +# See here for image contents: https://github.com/microsoft/vscode-dev-containers/tree/v0.245.2/containers/python-3/.devcontainer/base.Dockerfile + +# [Choice] Python version (use -bullseye variants on local arm64/Apple Silicon): 3, 3.10, 3.9, 3.8, 3.7, 3.6, 3-bullseye, 3.10-bullseye, 3.9-bullseye, 3.8-bullseye, 3.7-bullseye, 3.6-bullseye, 3-buster, 3.10-buster, 3.9-buster, 3.8-buster, 3.7-buster, 3.6-buster +ARG VARIANT="3.10-bullseye" +FROM mcr.microsoft.com/vscode/devcontainers/python:0-${VARIANT} + +# [Choice] Node.js version: none, lts/*, 16, 14, 12, 10 +ARG NODE_VERSION="none" +RUN if [ "${NODE_VERSION}" != "none" ]; then su vscode -c "umask 0002 && . /usr/local/share/nvm/nvm.sh && nvm install ${NODE_VERSION} 2>&1"; fi + +# [Optional] If your pip requirements rarely change, uncomment this section to add them to the image. +# COPY requirements.txt /tmp/pip-tmp/ +# RUN pip3 --disable-pip-version-check --no-cache-dir install -r /tmp/pip-tmp/requirements.txt \ +# && rm -rf /tmp/pip-tmp + +# [Optional] Uncomment this section to install additional OS packages. +# RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \ +# && apt-get -y install --no-install-recommends + +# [Optional] Uncomment this line to install global node packages. +# RUN su vscode -c "source /usr/local/share/nvm/nvm.sh && npm install -g " 2>&1 \ No newline at end of file diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 0000000..6f4d8e8 --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,54 @@ +// For format details, see https://aka.ms/devcontainer.json. For config options, see the README at: +// https://github.com/microsoft/vscode-dev-containers/tree/v0.245.2/containers/python-3 +{ + "name": "Python 3", + "build": { + "dockerfile": "Dockerfile", + "context": "..", + "args": { + // Update 'VARIANT' to pick a Python version: 3, 3.10, 3.9, 3.8, 3.7, 3.6 + // Append -bullseye or -buster to pin to an OS version. + // Use -bullseye variants on local on arm64/Apple Silicon. + "VARIANT": "3.10-bullseye", + // Options + "NODE_VERSION": "lts/*" + } + }, + + // Configure tool-specific properties. + "customizations": { + // Configure properties specific to VS Code. + "vscode": { + // Set *default* container specific settings.json values on container create. + "settings": { + "python.defaultInterpreterPath": "/usr/local/bin/python", + "python.linting.enabled": true, + "python.linting.pylintEnabled": true, + "python.formatting.autopep8Path": "/usr/local/py-utils/bin/autopep8", + "python.formatting.blackPath": "/usr/local/py-utils/bin/black", + "python.formatting.yapfPath": "/usr/local/py-utils/bin/yapf", + "python.linting.banditPath": "/usr/local/py-utils/bin/bandit", + "python.linting.flake8Path": "/usr/local/py-utils/bin/flake8", + "python.linting.mypyPath": "/usr/local/py-utils/bin/mypy", + "python.linting.pycodestylePath": "/usr/local/py-utils/bin/pycodestyle", + "python.linting.pydocstylePath": "/usr/local/py-utils/bin/pydocstyle", + "python.linting.pylintPath": "/usr/local/py-utils/bin/pylint" + }, + + // Add the IDs of extensions you want installed when the container is created. + "extensions": [ + "ms-python.python", + "ms-python.vscode-pylance" + ] + } + }, + + // Use 'forwardPorts' to make a list of ports inside the container available locally. + // "forwardPorts": [], + + // Use 'postCreateCommand' to run commands after the container is created. + // "postCreateCommand": "pip3 install --user -r requirements.txt", + + // Comment out to connect as root instead. More info: https://aka.ms/vscode-remote/containers/non-root. + "remoteUser": "vscode" +} diff --git a/.github/workflows/build_release.yml b/.github/workflows/build_release.yml index a8cb773..270bc9e 100644 --- a/.github/workflows/build_release.yml +++ b/.github/workflows/build_release.yml @@ -14,12 +14,18 @@ jobs: python-version: 3.9 - name: Install requirements run: pip install --upgrade . build twine pytest pytest-asyncio - - name: Run unit tests - run: python -m pytest --asyncio-mode=auto + # - name: Run unit tests + # run: python -m pytest --asyncio-mode=auto - name: Build run: python -m build - - name: Publish to PyPI + - name: Publish package to TestPyPI + if: ${{ !startsWith(github.ref, 'refs/tags') }} + uses: pypa/gh-action-pypi-publish@release/v1 + with: + password: ${{ secrets.TEST_PYPI_API_TOKEN }} + repository_url: https://test.pypi.org/legacy/ + - name: Publish package to PyPI if: startsWith(github.ref, 'refs/tags') - uses: pypa/gh-action-pypi-publish@master + uses: pypa/gh-action-pypi-publish@release/v1 with: password: ${{ secrets.PYPI_API_TOKEN }} diff --git a/.gitignore b/.gitignore index 1cb969e..d5a59f6 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ # Byte-compiled / optimized / DLL files +test.py __pycache__/ *.py[cod] *$py.class diff --git a/.vscode/launch.json b/.vscode/launch.json index 670a9ce..bc8bb2e 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -3,13 +3,5 @@ // Hover to view descriptions of existing attributes. // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 "version": "0.2.0", - "configurations": [ - { - "name": "Pytest", - "type": "python", - "request": "launch", - "module": "pytest", - "args": ["--asyncio-mode=auto"], - } - ], + "configurations": [], } diff --git a/MANIFEST.in b/MANIFEST.in index 9051076..1ade348 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,6 +1,5 @@ include LICENSE include README.rst -recursive-include tests * recursive-exclude * __pycache__ recursive-exclude * *.py[co] diff --git a/README.md b/README.md index 7965fa8..2a17923 100644 --- a/README.md +++ b/README.md @@ -1,50 +1,39 @@ # Bold Smart Lock Python Package - This package implements the Bold Smart Lock API to authenticate and unlock a Bold smart lock. Usage of this API requires a Bold Connect. ## Installation - -To install dependencies during development run `pip install .` from the project directory. - -## Run tests -Run `pip install pytest pytest-asyncio` to install the dependencies for testing. -Run `python -m pytest --asyncio-mode=auto` from the project directory. +To install dependencies during development run ```pip install .``` from the project directory. +Optionally use the included VSCode Dev Container to get a preconfigured envirionment. ## Usage ```python import asyncio import aiohttp +from bold_smart_lock.auth import AbstractAuth from bold_smart_lock.bold_smart_lock import BoldSmartLock +class TestAuth(AbstractAuth): + async def async_get_access_token(self) -> str: + return "00000000-0000-0000-0000-000000000000" # Obtain an access token with oAuth2 and specify it here + async def main(): async with aiohttp.ClientSession() as session: - bold = BoldSmartLock(session) - - # Request a validation id and validation code for the account e-mail address - request_validation_id_response = await bold.request_validation_id("john.doe@email.com"); - print(request_validation_id_response) - - # Authenticate with email, password, validation code (from email) and validation id (from output) - authenticate_response = await bold.authenticate("john.doe@email.com", "password", "01234", "00000000-0000-0000-0000-000000000000"); - print(authenticate_response) - - # E.g. for testing purpose you can set a token - token = "00000000-0000-0000-0000-000000000000" - bold.set_token(token) + auth = TestAuth(session) + bold = BoldSmartLock(auth) # Get the devices and device permissions get_device_permissions_response = await bold.get_device_permissions() print(get_device_permissions_response) - # Active the smart lock by device id + # Activate the smart lock by device id remote_activation_response = await bold.remote_activation(12345) print(remote_activation_response) - # Re-login / update token - relogin_response = await bold.re_login() - print(relogin_response) + # Deactivate the smart lock by device id + remote_deactivation_response = await bold.remote_deactivation(12345) + print(remote_deactivation_response) asyncio.run(main()) ``` diff --git a/bold_smart_lock/auth.py b/bold_smart_lock/auth.py index bb7d9c1..f6599c6 100644 --- a/bold_smart_lock/auth.py +++ b/bold_smart_lock/auth.py @@ -1,154 +1,33 @@ -"""Bold Smart Lock authentication.""" -from __future__ import annotations -from aiohttp.web import HTTPUnauthorized -from bold_smart_lock.exceptions import AuthenticateFailed, EmailOrPhoneNotSpecified, InvalidEmail, InvalidPhone, InvalidValidationCode, InvalidValidationId, InvalidValidationResponse, MissingValidationId, TokenMissing, VerificationNotFound -from .const import ( - API_URI, - INVALID_EMAIL_ERROR, - INVALID_PHONE_ERROR, - POST_HEADERS, - VALIDATIONS_ENDPOINT, - AUTHENTICATIONS_ENDPOINT, -) -import aiohttp - - -class Auth: - """Authorization class for Bold Smart Lock""" - - def __init__(self, session: aiohttp.ClientSession): - self._session = session - self._token: str = None - self._validation_id: str = None - - async def authenticate( - self, email: str, password: str, verification_code: str, validation_id: str, language: str = "en" - ): - """Authenticate with the login details, validation_id and validation_code""" - verified = await self.__verify_validation_id(verification_code, validation_id) - - if verified and email and password and self._validation_id: - request_json = { - "language": language, - "clientLocale": "en-US", - "validationId": self._validation_id, - } - headers = self.headers() +"""Authentication library, implemented by users of the API.""" - try: - async with self._session.post( - API_URI + AUTHENTICATIONS_ENDPOINT, - headers=headers, - auth=aiohttp.BasicAuth(email, password), - json=request_json, - raise_for_status=False - ) as response: - if response.status == 401: - raise HTTPUnauthorized - elif response.status == 404: - raise VerificationNotFound - elif response.content_type == "application/json": - response_json = await response.json() - if "token" in response_json: - self.set_token(response_json["token"]) - return response_json - except Exception as exception: - raise exception - raise AuthenticateFailed - - def headers(self, output_text: bool = False): - """Get the required request headers""" - headers = {} if output_text else POST_HEADERS - token = self.token() - - if token: - headers["X-Auth-Token"] = token - return headers - - async def re_login(self): - """Re-login / refresh the token""" - if self.token(): - async with self._session.put( - API_URI + AUTHENTICATIONS_ENDPOINT + "/" + self.token(), - headers=self.headers(), - raise_for_status=False - ) as response: - response_json = await response.json() - - if "token" in response_json: - self.set_token(response_json["token"]) - return response_json - raise TokenMissing - - async def request_validation_id(self, email: str = None, phone: str = None): - """Request a validation id and receive a validation code by email or phone""" - request_json = None +from __future__ import annotations - if email: - request_json = {"email": email} - elif phone: - request_json = {"phone": phone} +from abc import ABC, abstractmethod +from aiohttp import ClientResponse, ClientSession - if request_json: - try: - async with self._session.post( - API_URI + VALIDATIONS_ENDPOINT, - json=request_json, - headers=self.headers(), - raise_for_status=False - ) as response: - if response.content_type == "application/json": - response_json = await response.json() - if "errorCode" in response_json: - if response_json["errorCode"] == INVALID_EMAIL_ERROR: - raise InvalidEmail - elif response_json["errorCode"] == INVALID_PHONE_ERROR: - raise InvalidPhone - elif response_json["status"] == 400: - raise EmailOrPhoneNotSpecified +class AbstractAuth(ABC): + """Abstract class to make authenticated requests.""" - if "id" in response_json: - self._validation_id = response_json["id"] - return response_json - except Exception as exception: - raise exception - raise EmailOrPhoneNotSpecified + def __init__(self, websession: ClientSession): + """Initialize the auth.""" + self.websession = websession - def set_token(self, token: str): - """Update the token""" - self._token = token + @abstractmethod + async def async_get_access_token(self) -> str: + """Return a valid access token.""" - def token(self): - """Get the token and update it when needed""" - if self._token: - return self._token + async def request(self, method, url, **kwargs) -> ClientResponse: + """Make a request.""" + headers = kwargs.get("headers") - async def __verify_validation_id( - self, verification_code: str, validation_id: str = None - ) -> bool: - """Verify an e-mail with the validation_id and validation_code""" - if validation_id: - self._validation_id = validation_id + if headers is None: + headers = {} + else: + headers = dict(headers) - if self._validation_id and verification_code: - try: - async with await self._session.post( - API_URI + VALIDATIONS_ENDPOINT + "/" + self._validation_id, - json={"code": verification_code}, - headers=self.headers(), - raise_for_status=False - ) as response: - if response.status == 200: - return True - if response.status == 400: - raise InvalidValidationCode - elif response.status == 404: - raise InvalidValidationId - elif response.status == 405: - raise MissingValidationId - else: - raise InvalidValidationResponse - except Exception as exception: - raise exception + access_token = await self.async_get_access_token() + headers["authorization"] = f"Bearer {access_token}" - return False + return await self.websession.request( + method, f"{url}", **kwargs, headers=headers, + ) diff --git a/bold_smart_lock/bold_smart_lock.py b/bold_smart_lock/bold_smart_lock.py index 971e707..fe162e5 100644 --- a/bold_smart_lock/bold_smart_lock.py +++ b/bold_smart_lock/bold_smart_lock.py @@ -1,68 +1,50 @@ -"""Bold Smart Lock API wrapper""" +"""Library to access the Bold Smart Lock API.""" + from __future__ import annotations -from .auth import Auth -from .const import ( - API_URI, - REMOTE_ACTIVATION_ENDPOINT, - EFFECTIVE_DEVICE_PERMISSIONS_ENDPOINT, -) -import aiohttp -class BoldSmartLock: - """A Python Abstraction object to Bold Smart Lock""" +from .auth import AbstractAuth +from .const import API_URL, DEVICE_SERVICE, EFFECTIVE_DEVICE_PERMISSIONS_SERVICE +from .exceptions import ActivationError, DeactivationError, DeviceFirmwareOutdatedError - def __init__(self, session: aiohttp.ClientSession): - """Initialize the Bold Smart Lock object.""" - self._session = session - self._auth = Auth(session) +class BoldSmartLock: + """Class to communicate with the Bold Smart Lock API.""" - async def authenticate( - self, - email: str, - password: str, - validation_code: str, - validation_id: str = None, - ): - """Authenticate with account data, validation id and validation code""" - return await self._auth.authenticate( - email, password, validation_code, validation_id - ) + def __init__(self, auth: AbstractAuth): + self._auth = auth async def get_device_permissions(self): - """Get the device data and permissions""" - headers = self._auth.headers(True) - + """Get all effective device permissions.""" try: - async with self._session.get( - API_URI + EFFECTIVE_DEVICE_PERMISSIONS_ENDPOINT, headers=headers, raise_for_status=True - ) as response: - response_json = await response.json() - return response_json + response = await self._auth.request("GET", f"{API_URL}{EFFECTIVE_DEVICE_PERMISSIONS_SERVICE}") + response_json = await response.json() + return response_json except Exception as exception: raise exception - async def re_login(self): - """Re-login / refresh token""" - return await self._auth.re_login() - async def remote_activation(self, device_id: int): - """Activate the device remotely""" - headers = self._auth.headers(True) - + """Remotely activate a device, using a gateway.""" try: - async with self._session.post( - API_URI + REMOTE_ACTIVATION_ENDPOINT.format(device_id), headers=headers, raise_for_status=True - ) as response: - response_json = await response.json() - return response_json + response = await self._auth.request("POST", f"{API_URL}{DEVICE_SERVICE}/{device_id}/remote-activation") + response_json = await response.json() + + if response_json["errorCode"] != "OK": + raise ActivationError + + return response_json except Exception as exception: raise exception - def set_token(self, token: str): - """Set the token""" - self._auth.set_token(token) + async def remote_deactivation(self, device_id: int): + """Remotely deactivate a device, using a gateway.""" + try: + response = await self._auth.request("POST", f"{API_URL}{DEVICE_SERVICE}/{device_id}/remote-deactivation") + response_json = await response.json() - async def request_validation_id(self, email: str = None, phone: str = None): - """Request a validation id and receive a validation code by e-mail or phone""" - return await self._auth.request_validation_id(email, phone) + if response_json["errorCode"] == "DeviceFirmwareOutdated": + raise DeviceFirmwareOutdatedError + elif response_json["errorCode"] != "OK": + raise DeactivationError + return response_json + except Exception as exception: + raise exception diff --git a/bold_smart_lock/const.py b/bold_smart_lock/const.py index 95cc6a8..4555330 100644 --- a/bold_smart_lock/const.py +++ b/bold_smart_lock/const.py @@ -1,15 +1,5 @@ -"""Constants""" +"""Consts used in the library Bold Smart Lock API library.""" -# API endpoints -API_URI = "https://api.sesamtechnology.com/v1/" -VALIDATIONS_ENDPOINT = "validations" -AUTHENTICATIONS_ENDPOINT = "authentications" -REMOTE_ACTIVATION_ENDPOINT = "devices/{}/remote-activation" -EFFECTIVE_DEVICE_PERMISSIONS_ENDPOINT = "effective-device-permissions?size=1000" - -# Default headers -POST_HEADERS = {"Content-Type": "application/json"} - -# Response body error codes -INVALID_EMAIL_ERROR = "invalidEmailError" -INVALID_PHONE_ERROR = "invalidPhoneError" +API_URL = "https://api.boldsmartlock.com" +DEVICE_SERVICE = "/v1/devices" +EFFECTIVE_DEVICE_PERMISSIONS_SERVICE = "/v1/effective-device-permissions" diff --git a/bold_smart_lock/enums.py b/bold_smart_lock/enums.py new file mode 100644 index 0000000..b04adc1 --- /dev/null +++ b/bold_smart_lock/enums.py @@ -0,0 +1,9 @@ +"""Enums used in the library Bold Smart Lock API library.""" + +from enum import Enum + +class DeviceType(Enum): + """All possible device types.""" + LOCK = 1 + GATEWAY = 2 + KEYFOB = 3 diff --git a/bold_smart_lock/exceptions.py b/bold_smart_lock/exceptions.py index 0abfe41..3f7408b 100644 --- a/bold_smart_lock/exceptions.py +++ b/bold_smart_lock/exceptions.py @@ -1,35 +1,10 @@ -class AuthenticateFailed(Exception): - """AuthenticateFailed exception for Bold.""" +"""Exceptions used in the library Bold Smart Lock API library.""" -class InvalidEmail(Exception): - """InvalidEmail exception for Bold.""" +class ActivationError(Exception): + """ActivationError exception for Bold.""" -class InvalidPhone(Exception): - """InvalidPhone exception for Bold.""" +class DeactivationError(Exception): + """DeactivationError exception for Bold.""" -class EmailOrPhoneNotSpecified(Exception): - """EmailOrPhoneNotSpecified exception for Bold.""" - -class EmailOrPhoneNotSpecified(Exception): - """EmailOrPhoneNotSpecified exception for Bold.""" - -class MissingValidationId(Exception): - """MissingValidationId exception for Bold.""" - -class InvalidValidationId(Exception): - """InvalidValidationId exception for Bold.""" - -class InvalidValidationCode(Exception): - """InvalidValidationCode exception for Bold.""" - -class InvalidValidationResponse(Exception): - """InvalidValidationResponse exception for Bold.""" - -class VerificationNotFound(Exception): - """VerificationNotFound exception for Bold.""" - -class Forbidden(Exception): - """Forbidden exception for Bold.""" - -class TokenMissing(Exception): - """TokenMissing exception for Bold.""" +class DeviceFirmwareOutdatedError(Exception): + """DeviceFirmwareOutdated exception for Bold.""" diff --git a/setup.cfg b/setup.cfg index f2de07b..93ce5c9 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,10 +1,6 @@ [metadata] description-file = README.md -[tool:pytest] -testpaths = tests -norecursedirs = .git - [flake8] exclude = .venv,.git,.tox,docs,venv,bin,lib,deps,build doctests = True diff --git a/setup.py b/setup.py index bdf47e1..2578bbf 100644 --- a/setup.py +++ b/setup.py @@ -1,20 +1,19 @@ # coding=utf-8 """Python Bold Smart Lock setup script.""" +from pathlib import Path from setuptools import setup -_VERSION = "0.2.3" - - -def readme(): - with open("README.md") as desc: - return desc.read() +_VERSION = "0.3.8" +long_description = (Path(__file__).parent / "README.md").read_text() setup( name="bold_smart_lock", packages=["bold_smart_lock"], version=_VERSION, - description="A Python library to communicate with Bold Smart Lock (https://boldsmartlock.com)", + description="Python library to communicate with Bold Smart Lock (https://boldsmartlock.com)", + long_description=long_description, + long_description_content_type="text/markdown", author="Westenberg", author_email="lauren@westenberg.dev", url="https://github.com/westenberg/bold_smart_lock", @@ -23,9 +22,7 @@ def readme(): install_requires=[ "aiohttp", "asyncio", - "aioresponses", ], - test_suite="tests", keywords=[ "bold", "smart lock", diff --git a/tests/__init__.py b/tests/__init__.py deleted file mode 100644 index e21c7be..0000000 --- a/tests/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Tests for Bold Smart Lock components.""" diff --git a/tests/fixtures/authenticate_request.json b/tests/fixtures/authenticate_request.json deleted file mode 100644 index bbfab6b..0000000 --- a/tests/fixtures/authenticate_request.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "email": "john.doe@email.com", - "phone": "0612345678", - "password": "test123456789", - "validation_id": "00000000-0000-0000-0000-000000000000", - "validation_code": "12345" -} diff --git a/tests/fixtures/authenticate_response.json b/tests/fixtures/authenticate_response.json deleted file mode 100644 index 1429f97..0000000 --- a/tests/fixtures/authenticate_response.json +++ /dev/null @@ -1,47 +0,0 @@ -{ - "token": "00000000-0000-0000-0000-001234567890", - "accountId": 1, - "expirationTime": "2022-01-01T00:00:00.000000Z", - "firstName": "John", - "lastName": "Doe", - "phoneNumber": "+31123456789", - "account": { - "id": 1, - "firstName": "John", - "lastName": "Doe", - "email": "john.doe@email.com", - "phone": "+31123456789", - "emailIsVerified": false, - "language": "en", - "isSystemAccount": false, - "isSupportAccount": false, - "isSystemIntegration": false, - "dateCreated": "2021-01-01T00:00:00.000Z", - "dateModified": "2021-01-01T00:00:00.000Z", - "datePreliminary": "2021-01-01T00:00:00.000Z", - "users": [ - { - "id": 1, - "organization": { - "id": 1, - "name": "Personal organization of John Doe", - "subscription": "FREE", - "dateCreated": "2021-01-01T00:00:00.000Z", - "personal": true - }, - "isSuperUser": false, - "roles": [], - "permissions": [] - } - ], - "agreements": [ - { - "agreementType": "TERMS_OF_SERVICE", - "version": 1, - "url": "https://boldsmartlock.com/terms-of-service/", - "startDate": "2021-08-12T09:21:18.446Z", - "accepted": false - } - ] - } -} diff --git a/tests/fixtures/get_device_permissions_invalid_response.json b/tests/fixtures/get_device_permissions_invalid_response.json deleted file mode 100644 index dc7b5e7..0000000 --- a/tests/fixtures/get_device_permissions_invalid_response.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "errorMessage": "Authentication is possible but has failed or not yet been provided." -} diff --git a/tests/fixtures/get_device_permissions_response.json b/tests/fixtures/get_device_permissions_response.json deleted file mode 100644 index 33049e1..0000000 --- a/tests/fixtures/get_device_permissions_response.json +++ /dev/null @@ -1,69 +0,0 @@ -[ - { - "id": 11111, - "serial": "0000011111", - "deviceId": 11111, - "owner": { - "organizationId": 1, - "accountId": 1, - "name": "John Doe", - "firstName": "John", - "lastName": "Doe" - }, - "name": "Front Door", - "organizationId": 1, - "model": { - "id": 3, - "make": "Bold", - "model": "Smart Cylinder Long Range", - "name": "Smart Cylinder Long Range", - "isCertified": true, - "deviceType": { - "id": 1, - "name": "Lock", - "description": "Lock" - } - }, - "type": { - "id": 1, - "name": "Lock", - "description": "Lock" - }, - "actualFirmwareVersion": 92, - "requiredFirmwareVersion": 92, - "dateCreated": "2021-1-01T00:00:00.000Z", - "dateModified": "2021-1-01T00:00:00.000Z", - "timeZone": "Europe/Amsterdam", - "batteryLevel": "Excellent", - "settingsAcknowledged": true, - "permissions": [ - { - "devicePermission": "UseDevice", - "schedule": [] - } - ], - "permissionAdministrate": false, - "permissionRemoteActivate": false, - "permissionAssignKeyfob": false, - "permissionHash": "", - "gateway": { - "gatewayId": 11111, - "gatewayRssi": -70, - "gatewayLastSeen": "2021-01-01T00:00:00Z", - "gatewayName": "Gateway" - }, - "settings": { - "activationTime": 30, - "soundVolume": 10, - "pressButtonActivation": true, - "controllerFunctionality": false, - "acknowledged": true - }, - "featureSet": { - "isActivatable": true, - "storeDeviceEvents": true - }, - "synced": true, - "secure": true - } -] diff --git a/tests/fixtures/re_login_response.json b/tests/fixtures/re_login_response.json deleted file mode 100644 index 244fd10..0000000 --- a/tests/fixtures/re_login_response.json +++ /dev/null @@ -1,47 +0,0 @@ -{ - "token": "10000000-0000-0000-0000-001234567890", - "accountId": 1, - "expirationTime": "2022-01-01T00:00:00.000000Z", - "firstName": "John", - "lastName": "Doe", - "phoneNumber": "+31123456789", - "account": { - "id": 1, - "firstName": "John", - "lastName": "Doe", - "email": "john.doe@email.com", - "phone": "+31123456789", - "emailIsVerified": false, - "language": "en", - "isSystemAccount": false, - "isSupportAccount": false, - "isSystemIntegration": false, - "dateCreated": "2021-01-01T00:00:00.000Z", - "dateModified": "2021-01-01T00:00:00.000Z", - "datePreliminary": "2021-01-01T00:00:00.000Z", - "users": [ - { - "id": 1, - "organization": { - "id": 1, - "name": "Personal organization of John Doe", - "subscription": "FREE", - "dateCreated": "2021-01-01T00:00:00.000Z", - "personal": true - }, - "isSuperUser": false, - "roles": [], - "permissions": [] - } - ], - "agreements": [ - { - "agreementType": "TERMS_OF_SERVICE", - "version": 1, - "url": "https://boldsmartlock.com/terms-of-service/", - "startDate": "2021-08-12T09:21:18.446Z", - "accepted": false - } - ] - } -} diff --git a/tests/fixtures/remote_activation_response.json b/tests/fixtures/remote_activation_response.json deleted file mode 100644 index 6faab21..0000000 --- a/tests/fixtures/remote_activation_response.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "deviceId": 1, - "errorCode": "OK", - "errorMessage": "OK", - "activationTime": 30 -} diff --git a/tests/fixtures/request_validation_id_invalid_email.json b/tests/fixtures/request_validation_id_invalid_email.json deleted file mode 100644 index af55bae..0000000 --- a/tests/fixtures/request_validation_id_invalid_email.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "errorMessage": "Invalid email address", - "errorCode": "invalidEmailError" -} diff --git a/tests/fixtures/request_validation_id_invalid_phone.json b/tests/fixtures/request_validation_id_invalid_phone.json deleted file mode 100644 index 324e7ec..0000000 --- a/tests/fixtures/request_validation_id_invalid_phone.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "errorMessage": "Invalid phone number", - "errorCode": "invalidPhoneError" -} diff --git a/tests/fixtures/request_validation_id_response.json b/tests/fixtures/request_validation_id_response.json deleted file mode 100644 index ac358b1..0000000 --- a/tests/fixtures/request_validation_id_response.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "id": "00000000-0000-0000-0000-000000000000", - "email": "john.doe@email.com", - "expirationTime": "2022-01-01T00:00:00.000Z", - "isVerified": false, - "numberOfAttempts": 0, - "isExpired": false, - "moreAttemptsPossible": true, - "registrationRequired": false -} diff --git a/tests/fixtures/verify_validation_id_request.json b/tests/fixtures/verify_validation_id_request.json deleted file mode 100644 index 4284fd5..0000000 --- a/tests/fixtures/verify_validation_id_request.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "email": "john.doe@email.com" -} diff --git a/tests/fixtures/verify_validation_id_response.json b/tests/fixtures/verify_validation_id_response.json deleted file mode 100644 index 969b68c..0000000 --- a/tests/fixtures/verify_validation_id_response.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "id": "00000000-0000-0000-0000-000000000000", - "email": "john.doe@email.com", - "expirationTime": "2022-01-01T00:00:00.000Z", - "isVerified": true, - "numberOfAttempts": 0, - "isExpired": false, - "moreAttemptsPossible": true, - "registrationRequired": false -} diff --git a/tests/helpers.py b/tests/helpers.py deleted file mode 100644 index 98c5903..0000000 --- a/tests/helpers.py +++ /dev/null @@ -1,92 +0,0 @@ -"""Helper methods for Bold Smart Lock tests.""" -from __future__ import annotations -from aioresponses import aioresponses -from bold_smart_lock.auth import Auth -from bold_smart_lock.const import API_URI, AUTHENTICATIONS_ENDPOINT, POST_HEADERS, VALIDATIONS_ENDPOINT - -import aiohttp -import json -import os - - -def load_fixture(filename: str, raw: bool = False): - """Load a fixture.""" - path = os.path.join(os.path.dirname(__file__), "fixtures", filename) - with open(path) as fdp: - return fdp.read() if raw else json.loads(fdp.read()) - -fixture_authenticate_request: dict[str, str] = load_fixture("authenticate_request.json") -fixture_request_validation_id_response: dict[str, str] = load_fixture("request_validation_id_response.json") -fixture_verify_validation_id_response: dict[str, str] = load_fixture("verify_validation_id_response.json") -fixture_authenticate_response: dict[str, str] = load_fixture("authenticate_response.json") - -async def mock_auth_authenticate( - mock_request_validation_id_response: list = None, - mock_fixture_verify_validation_id_response: list = None, - mock_authenticate_reponse: list = None, - mock_auth: Auth = None, - mock_session: aiohttp.ClientSession = None -) -> dict[str, str]: - """Helper to set mocking for request+verify validation id and authenticate calls""" - with aioresponses() as m: - if mock_request_validation_id_response: - m.post( - API_URI + VALIDATIONS_ENDPOINT, - headers=POST_HEADERS, - status=mock_request_validation_id_response[0] or 200, - payload=mock_request_validation_id_response[1] or fixture_request_validation_id_response, - ) - if mock_fixture_verify_validation_id_response: - m.post( - API_URI + VALIDATIONS_ENDPOINT + "/" + fixture_request_validation_id_response["id"], - headers=POST_HEADERS, - status=mock_fixture_verify_validation_id_response[0] or 200, - payload=mock_fixture_verify_validation_id_response[1] or fixture_verify_validation_id_response, - ) - if mock_authenticate_reponse: - m.post( - API_URI + AUTHENTICATIONS_ENDPOINT, - headers=POST_HEADERS, - status=mock_authenticate_reponse[0] or 200, - payload=mock_authenticate_reponse[1] or fixture_authenticate_response, - ) - - try: - session = mock_session or aiohttp.ClientSession() - auth = mock_auth or Auth(session) - return await auth.authenticate( - fixture_authenticate_request["email"], - fixture_authenticate_request["password"], - fixture_authenticate_request["validation_code"], - fixture_authenticate_request["validation_id"] - ) - except Exception as exception: - raise exception - finally: - await session.close() - -async def mock_auth_request_validation_id( - status: int = 200, - verification_method: str = "email", - headers: str = "application/json", - response: dict[str, str] = fixture_request_validation_id_response, -) -> dict[str, str]: - """Helper to set mocking for request_validation_id calls""" - with aioresponses() as m: - m.post( - API_URI + VALIDATIONS_ENDPOINT, - headers=headers or POST_HEADERS, - status=status, - payload=response, - ) - - try: - session = aiohttp.ClientSession() - auth = Auth(session) - return await auth.request_validation_id( - fixture_authenticate_request["email"] if verification_method == "email" else fixture_authenticate_request["phone"], - ) - except Exception as exception: - raise exception - finally: - await session.close() diff --git a/tests/test_auth.py b/tests/test_auth.py deleted file mode 100644 index 4651311..0000000 --- a/tests/test_auth.py +++ /dev/null @@ -1,159 +0,0 @@ -"""Tests for Bold Smart Lock.""" -from __future__ import annotations -from aiohttp.web import HTTPUnauthorized -from aioresponses import aioresponses -from bold_smart_lock.auth import Auth -from bold_smart_lock.const import API_URI, AUTHENTICATIONS_ENDPOINT, POST_HEADERS -from bold_smart_lock.exceptions import AuthenticateFailed, EmailOrPhoneNotSpecified, InvalidEmail, InvalidPhone, InvalidValidationCode, InvalidValidationId, InvalidValidationResponse, MissingValidationId, TokenMissing, VerificationNotFound - -from tests.helpers import load_fixture, mock_auth_authenticate, mock_auth_request_validation_id - -import aiohttp -import pytest - -fixture_re_login_response: dict[str, str] = load_fixture("re_login_response.json") -fixture_request_validation_id_invalid_email: dict[str, str] = load_fixture("request_validation_id_invalid_email.json") -fixture_request_validation_id_invalid_phone: dict[str, str] = load_fixture("request_validation_id_invalid_phone.json") - - -@pytest.mark.asyncio -async def test_authenticate(): - """Test if a valid request returns the json payload and sets the token""" - session = aiohttp.ClientSession() - auth = Auth(session) - initial_token = auth.token() - auth_response = await mock_auth_authenticate([None, None], [None, None], [None, None], auth, session) - verification_token = auth.token() - assert initial_token is None and verification_token != initial_token and auth_response["token"] == verification_token - -@pytest.mark.asyncio -async def test_authenticate_invalid(): - """Test if an invalid request returns an AuthenticateFailed error""" - try: - await mock_auth_authenticate([None, None], [None, None], [201, None]) - except AuthenticateFailed: - assert True - -@pytest.mark.asyncio -async def test_authenticate_invalid_body(): - """Test if a 401 error coming from an invalid post body or login results in a HTTPUnauthorized error""" - try: - await mock_auth_authenticate([None, None], [None, None], [401, None]) - except HTTPUnauthorized: - assert True - -@pytest.mark.asyncio -async def test_authenticate_invalid_body(): - """Test if a 404 error coming from an invalid validation id results in a VerificationNotFound error""" - try: - await mock_auth_authenticate([None, None], [None, None], [404, None]) - except VerificationNotFound: - assert True - -@pytest.mark.asyncio -async def test_headers_text_output(): - """Test if headers with output type text returns an empty object""" - session = aiohttp.ClientSession() - auth = Auth(session) - headers = auth.headers(True) - assert bool(headers) is False - -@pytest.mark.asyncio -async def test_headers_json_output(): - """Test if headers with output type json returns POST headers""" - session = aiohttp.ClientSession() - auth = Auth(session) - headers = auth.headers(False) - assert headers == POST_HEADERS - -@pytest.mark.asyncio -async def test_re_login(): - """Test if a succesful relogin returns data with a token""" - token = "00000000-0000-0000-0000-000000000000" - session = aiohttp.ClientSession() - auth = Auth(session) - auth.set_token(token) - - with aioresponses() as m: - m.put( - API_URI + AUTHENTICATIONS_ENDPOINT + "/" + token, - headers=POST_HEADERS, - status=200, - payload=fixture_re_login_response - ) - - response: dict[str, str] = await auth.re_login() - await session.close() - assert response["token"] == "10000000-0000-0000-0000-001234567890" - -@pytest.mark.asyncio -async def test_re_login_invalid(): - """Test if a relogin without token returns TokenMissing error""" - session = aiohttp.ClientSession() - auth = Auth(session) - - with aioresponses() as m: - m.put( - API_URI + AUTHENTICATIONS_ENDPOINT + "/", - status=401, - ) - try: - await auth.re_login() - except TokenMissing: - assert True - -@pytest.mark.asyncio -async def test_verify_validation_id_missing_validation_id(): - """Test if verify validation id returns an MissingValidationId error when no validation id was passed""" - try: - await mock_auth_authenticate([None, None], [405, None]) - except MissingValidationId: - assert True - -@pytest.mark.asyncio -async def test_verify_validation_id_invalid_validation_id(): - """Test if verify validation id returns an InvalidValidationId error when no validation id was passed""" - try: - await mock_auth_authenticate([None, None], [404, None]) - except InvalidValidationId: - assert True - -@pytest.mark.asyncio -async def test_verify_validation_id_missing_validation_code(): - """Test if verify validation id returns an MissingValidationCode error when no validation code was passed""" - try: - await mock_auth_authenticate([None, None], [400, None]) - except InvalidValidationCode: - assert True - -@pytest.mark.asyncio -async def test_verify_validation_id_invalid_response(): - """Test if verify validation id returns an InvalidValidationResponse error for uncatched response statusses""" - try: - await mock_auth_authenticate([None, None], [202, None]) - except InvalidValidationResponse: - assert True - -@pytest.mark.asyncio -async def test_request_validation_id_email_or_phone_not_specified(): - """Test if the authentication returns an EmailOrPhoneNotSpecified when not passing email and phone""" - try: - await mock_auth_request_validation_id(400, "email", {"Content-Type": "text/plain"}) - except EmailOrPhoneNotSpecified: - assert True - -@pytest.mark.asyncio -async def test_request_validation_id_phone_not_specified(): - """Test if the authentication returns an InvalidPhone when not passing a phone number""" - try: - await mock_auth_request_validation_id(400, "phone", None, fixture_request_validation_id_invalid_phone) - except InvalidPhone: - assert True - -@pytest.mark.asyncio -async def test_request_validation_id_phone_not_specified(): - """Test if the authentication returns an InvalidEmail when not passing an email address""" - try: - await mock_auth_request_validation_id(400, "email", None, fixture_request_validation_id_invalid_email) - except InvalidEmail: - assert True diff --git a/tests/test_bold_smart_lock.py b/tests/test_bold_smart_lock.py deleted file mode 100644 index bf9f4e3..0000000 --- a/tests/test_bold_smart_lock.py +++ /dev/null @@ -1,99 +0,0 @@ -"""Tests for Bold Smart Lock.""" -from __future__ import annotations -from aioresponses import aioresponses -from bold_smart_lock.const import ( - API_URI, - EFFECTIVE_DEVICE_PERMISSIONS_ENDPOINT, - POST_HEADERS, - REMOTE_ACTIVATION_ENDPOINT -) -from bold_smart_lock.bold_smart_lock import BoldSmartLock -from tests.helpers import load_fixture - -import aiohttp -import json -import pytest - -fixture_authenticate_request: dict[str, str] = load_fixture("authenticate_request.json") -fixture_authenticate_response: dict[str, str] = load_fixture("authenticate_response.json") -fixture_get_device_permissions_response: dict[str, str] = load_fixture("get_device_permissions_response.json") -fixture_get_device_permissions_invalid_response: dict[str, str] = load_fixture("get_device_permissions_invalid_response.json") -fixture_remote_activation_response: dict[str, str] = load_fixture("remote_activation_response.json") - - -@pytest.mark.asyncio -async def test_get_device_permissions(): - """Test if the device permissions endpoint is called""" - session = aiohttp.ClientSession() - bold = BoldSmartLock(session) - bold.set_token(fixture_authenticate_response["token"]) - - with aioresponses() as m: - m.get( - API_URI + EFFECTIVE_DEVICE_PERMISSIONS_ENDPOINT, - payload=fixture_get_device_permissions_response - ) - - get_device_permissions_response: dict[str, str] = await bold.get_device_permissions() - assert get_device_permissions_response == fixture_get_device_permissions_response - - await session.close() - -@pytest.mark.asyncio -async def test_get_device_permissions_exceptions(): - """Test if get_device_permissions returns exceptions""" - session = aiohttp.ClientSession() - bold = BoldSmartLock(session) - bold.set_token(fixture_authenticate_response["token"]) - - with aioresponses() as m: - m.get( - API_URI + EFFECTIVE_DEVICE_PERMISSIONS_ENDPOINT, - headers=POST_HEADERS, - payload=fixture_get_device_permissions_invalid_response, - status=401 - ) - - try: - await bold.get_device_permissions() - except aiohttp.ClientError: - assert True - finally: - await session.close() - -@pytest.mark.asyncio -async def test_remote_activation(): - """Test if the remote_activation endpoint is called""" - session = aiohttp.ClientSession() - bold = BoldSmartLock(session) - bold.set_token(fixture_authenticate_response["token"]) - - with aioresponses() as m: - m.post( - API_URI + REMOTE_ACTIVATION_ENDPOINT.format(1), - payload=fixture_remote_activation_response - ) - - remote_activation_response: dict[str, str] = await bold.remote_activation(1) - assert remote_activation_response == fixture_remote_activation_response - await session.close() - -@pytest.mark.asyncio -async def test_remote_activation_invalid(): - """Test if the remote_activation endpoint returns errors""" - session = aiohttp.ClientSession() - bold = BoldSmartLock(session) - bold.set_token(fixture_authenticate_response["token"]) - - with aioresponses() as m: - m.post( - API_URI + REMOTE_ACTIVATION_ENDPOINT.format(1), - status=403 - ) - - try: - await bold.remote_activation(1) - except aiohttp.ClientError: - assert True - finally: - await session.close()