diff --git a/.flake8 b/.flake8 new file mode 100644 index 0000000..2bcd70e --- /dev/null +++ b/.flake8 @@ -0,0 +1,2 @@ +[flake8] +max-line-length = 88 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..c4d4878 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,61 @@ +name: CI + +on: + pull_request: + push: + branches: main + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + test: + runs-on: ubuntu-latest + strategy: + matrix: + python: ['3.10', '3.12', '3.13'] + steps: + - name: Checkout code + uses: actions/checkout@v4 + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python }} + cache: 'pip' + - name: Install dependencies + run: pip install -e ".[dev]" + - name: Run tests + run: pytest -v + + lint: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.12' + cache: 'pip' + - name: Install dependencies + run: pip install -e ".[dev]" + - name: Lint code + run: flake8 accessgrid/ tests/ + + format: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.12' + cache: 'pip' + - name: Install dependencies + run: pip install -e ".[dev]" + - name: Check formatting + run: black --check accessgrid/ tests/ + - name: Check import order + run: isort --check-only accessgrid/ tests/ diff --git a/.isort.cfg b/.isort.cfg new file mode 100644 index 0000000..c76db01 --- /dev/null +++ b/.isort.cfg @@ -0,0 +1,2 @@ +[isort] +profile = black diff --git a/.tool-versions b/.tool-versions new file mode 100644 index 0000000..409e34c --- /dev/null +++ b/.tool-versions @@ -0,0 +1 @@ +python 3.12.13 diff --git a/accessgrid/__init__.py b/accessgrid/__init__.py index 681309b..a2e1040 100644 --- a/accessgrid/__init__.py +++ b/accessgrid/__init__.py @@ -19,13 +19,13 @@ # Import all public components from .client import ( + AccessCard, AccessGrid, AccessGridError, AuthenticationError, - AccessCard, - UnifiedAccessPass, + Org, Template, - Org + UnifiedAccessPass, ) # Version of the accessgrid package @@ -33,11 +33,11 @@ # List of public objects that will be exported with "from accessgrid import *" __all__ = [ - 'AccessGrid', - 'AccessGridError', - 'AuthenticationError', - 'AccessCard', - 'UnifiedAccessPass', - 'Template', - 'Org' -] \ No newline at end of file + "AccessGrid", + "AccessGridError", + "AuthenticationError", + "AccessCard", + "UnifiedAccessPass", + "Template", + "Org", +] diff --git a/accessgrid/client.py b/accessgrid/client.py index 0a6da72..08c37c1 100644 --- a/accessgrid/client.py +++ b/accessgrid/client.py @@ -1,125 +1,156 @@ import base64 -import hmac import hashlib +import hmac import json +from typing import Any, Dict, List, Optional, Union + import requests -from datetime import datetime, timezone -from urllib.parse import quote -from typing import Optional, Dict, Any, List, Union try: from importlib.metadata import version + __version__ = version("accessgrid") -except: +except Exception: __version__ = "unknown" + class AccessGridError(Exception): """Base exception for AccessGrid SDK""" + pass + class AuthenticationError(AccessGridError): """Raised when authentication fails""" + pass + class AccessCard: def __init__(self, client, data: Dict[str, Any]): self._client = client - self.id = data.get('id') - self.url = data.get('install_url') - self.install_url = data.get('install_url') - self.details = data.get('details') - self.state = data.get('state') - self.full_name = data.get('full_name') - self.expiration_date = data.get('expiration_date') - self.card_template_id = data.get('card_template_id') - self.card_number = data.get('card_number') - self.site_code = data.get('site_code') - self.file_data = data.get('file_data') - self.direct_install_url = data.get('direct_install_url') - self.devices = data.get('devices', []) - self.metadata = data.get('metadata', {}) - + self.id = data.get("id") + self.url = data.get("install_url") + self.install_url = data.get("install_url") + self.details = data.get("details") + self.state = data.get("state") + self.full_name = data.get("full_name") + self.expiration_date = data.get("expiration_date") + self.card_template_id = data.get("card_template_id") + self.card_number = data.get("card_number") + self.site_code = data.get("site_code") + self.file_data = data.get("file_data") + self.direct_install_url = data.get("direct_install_url") + self.devices = data.get("devices", []) + self.metadata = data.get("metadata", {}) + def __str__(self) -> str: - return f"AccessCard(name='{self.full_name}', id='{self.id}', state='{self.state}', card_template_id='{self.card_template_id}')" + return ( + f"AccessCard(name='{self.full_name}', id='{self.id}', " + f"state='{self.state}', " + f"card_template_id='{self.card_template_id}')" + ) def __repr__(self) -> str: return self.__str__() + class UnifiedAccessPass: def __init__(self, client, data: Dict[str, Any]): self._client = client - self.id = data.get('id') - self.url = data.get('install_url') - self.install_url = data.get('install_url') - self.state = data.get('state') - self.status = data.get('status') - self.details = [AccessCard(client, item) for item in data.get('details', [])] + self.id = data.get("id") + self.url = data.get("install_url") + self.install_url = data.get("install_url") + self.state = data.get("state") + self.status = data.get("status") + self.details = [AccessCard(client, item) for item in data.get("details", [])] def __str__(self) -> str: - return f"UnifiedAccessPass(id='{self.id}', state='{self.state}', cards={len(self.details)})" + return ( + f"UnifiedAccessPass(id='{self.id}', " + f"state='{self.state}', cards={len(self.details)})" + ) def __repr__(self) -> str: return self.__str__() + class Template: def __init__(self, client, data: Dict[str, Any]): self._client = client - self.id = data.get('id') - self.name = data.get('name') - self.platform = data.get('platform') - self.use_case = data.get('use_case') - self.protocol = data.get('protocol') - self.created_at = data.get('created_at') - self.last_published_at = data.get('last_published_at') - self.issued_keys_count = data.get('issued_keys_count') - self.active_keys_count = data.get('active_keys_count') - self.allowed_device_counts = data.get('allowed_device_counts') - self.support_settings = data.get('support_settings') - self.terms_settings = data.get('terms_settings') - self.style_settings = data.get('style_settings') + self.id = data.get("id") + self.name = data.get("name") + self.platform = data.get("platform") + self.use_case = data.get("use_case") + self.protocol = data.get("protocol") + self.created_at = data.get("created_at") + self.last_published_at = data.get("last_published_at") + self.issued_keys_count = data.get("issued_keys_count") + self.active_keys_count = data.get("active_keys_count") + self.allowed_device_counts = data.get("allowed_device_counts") + self.support_settings = data.get("support_settings") + self.terms_settings = data.get("terms_settings") + self.style_settings = data.get("style_settings") + class Org: def __init__(self, client, data: Dict[str, Any]): self._client = client - self.id = data.get('id') - self.name = data.get('name') - self.slug = data.get('slug') - self.status = data.get('status') - self.full_address = data.get('full_address') - self.phone = data.get('phone') - self.first_name = data.get('first_name') - self.last_name = data.get('last_name') - self.email = data.get('email') - self.created_at = data.get('created_at') - self.updated_at = data.get('updated_at') + self.id = data.get("id") + self.name = data.get("name") + self.slug = data.get("slug") + self.status = data.get("status") + self.full_address = data.get("full_address") + self.phone = data.get("phone") + self.first_name = data.get("first_name") + self.last_name = data.get("last_name") + self.email = data.get("email") + self.created_at = data.get("created_at") + self.updated_at = data.get("updated_at") def __str__(self) -> str: - return f"Org(name='{self.name}', id='{self.id}', slug='{self.slug}', status='{self.status}')" + return ( + f"Org(name='{self.name}', id='{self.id}', " + f"slug='{self.slug}', status='{self.status}')" + ) def __repr__(self) -> str: return self.__str__() + class TemplateInfo: def __init__(self, client, data: Dict[str, Any]): self._client = client - self.id = data.get('id') - self.name = data.get('name') - self.platform = data.get('platform') + self.id = data.get("id") + self.name = data.get("name") + self.platform = data.get("platform") def __str__(self) -> str: - return f"TemplateInfo(id='{self.id}', name='{self.name}', platform='{self.platform}')" + return ( + f"TemplateInfo(id='{self.id}', " + f"name='{self.name}', platform='{self.platform}')" + ) def __repr__(self) -> str: return self.__str__() + class PassTemplatePair: def __init__(self, client, data: Dict[str, Any]): self._client = client - self.id = data.get('id') - self.name = data.get('name') - self.created_at = data.get('created_at') - self.android_template = TemplateInfo(client, data['android_template']) if data.get('android_template') else None - self.ios_template = TemplateInfo(client, data['ios_template']) if data.get('ios_template') else None + self.id = data.get("id") + self.name = data.get("name") + self.created_at = data.get("created_at") + self.android_template = ( + TemplateInfo(client, data["android_template"]) + if data.get("android_template") + else None + ) + self.ios_template = ( + TemplateInfo(client, data["ios_template"]) + if data.get("ios_template") + else None + ) def __str__(self) -> str: return f"PassTemplatePair(id='{self.id}', name='{self.name}')" @@ -127,72 +158,76 @@ def __str__(self) -> str: def __repr__(self) -> str: return self.__str__() + class AccessCards: def __init__(self, client): self._client = client def issue(self, **kwargs) -> Union[AccessCard, UnifiedAccessPass]: """Issue a new access card or unified access pass""" - response = self._client._post('/v1/key-cards', kwargs) - if 'details' in response: + response = self._client._post("/v1/key-cards", kwargs) + if "details" in response: return UnifiedAccessPass(self._client, response) return AccessCard(self._client, response) - + def provision(self, **kwargs) -> Union[AccessCard, UnifiedAccessPass]: """Alias for issue() method to maintain backwards compatibility""" return self.issue(**kwargs) def get(self, card_id: str) -> AccessCard: """Get details about a specific issued Access Pass""" - response = self._client._get(f'/v1/key-cards/{card_id}') + response = self._client._get(f"/v1/key-cards/{card_id}") return AccessCard(self._client, response) def update(self, card_id: str, **kwargs) -> AccessCard: """Update an existing access card""" - response = self._client._patch(f'/v1/key-cards/{card_id}', kwargs) + response = self._client._patch(f"/v1/key-cards/{card_id}", kwargs) return AccessCard(self._client, response) - def list(self, template_id: Optional[str] = None, state: Optional[str] = None) -> List[AccessCard]: + def list( + self, template_id: Optional[str] = None, state: Optional[str] = None + ) -> List[AccessCard]: """ List NFC keys provisioned for a particular card template. - + Args: template_id: The card template ID to list keys for (optional) state: Filter keys by state (active, suspended, unlink, deleted) - + Returns: List of AccessCard objects """ params = {} if template_id: - params['template_id'] = template_id + params["template_id"] = template_id if state: - params['state'] = state - - response = self._client._get('/v1/key-cards', params=params) - return [AccessCard(self._client, item) for item in response.get('keys', [])] + params["state"] = state + + response = self._client._get("/v1/key-cards", params=params) + return [AccessCard(self._client, item) for item in response.get("keys", [])] def manage(self, card_id: str, action: str) -> AccessCard: """Manage card state (suspend/resume/unlink)""" - response = self._client._post(f'/v1/key-cards/{card_id}/{action}', {}) + response = self._client._post(f"/v1/key-cards/{card_id}/{action}", {}) return AccessCard(self._client, response) def suspend(self, card_id: str) -> AccessCard: """Suspend an access card""" - return self.manage(card_id, 'suspend') + return self.manage(card_id, "suspend") def resume(self, card_id: str) -> AccessCard: """Resume a suspended access card""" - return self.manage(card_id, 'resume') + return self.manage(card_id, "resume") def unlink(self, card_id: str) -> AccessCard: """Unlink an access card""" - return self.manage(card_id, 'unlink') + return self.manage(card_id, "unlink") def delete(self, card_id: str) -> AccessCard: """Delete an access card""" - return self.manage(card_id, 'delete') + return self.manage(card_id, "delete") + class HIDOrgs: def __init__(self, client): @@ -209,25 +244,23 @@ def activate(self, email: str, password: str) -> Org: Returns: Org object with registration details """ - data = { - 'email': email, - 'password': password - } - response = self._client._post('/v1/console/hid/orgs/activate', data) + data = {"email": email, "password": password} + response = self._client._post("/v1/console/hid/orgs/activate", data) return Org(self._client, response) - def list(self) -> List['Org']: + def list(self) -> List["Org"]: """ List all HID organizations. Returns: List of Org objects """ - response = self._client._get('/v1/console/hid/orgs') - return [Org(self._client, org) for org in response.get('orgs', [])] + response = self._client._get("/v1/console/hid/orgs") + return [Org(self._client, org) for org in response.get("orgs", [])] - def create(self, name: str, full_address: str, phone: str, - first_name: str, last_name: str) -> Org: + def create( + self, name: str, full_address: str, phone: str, first_name: str, last_name: str + ) -> Org: """ Create a new HID organization. @@ -242,20 +275,22 @@ def create(self, name: str, full_address: str, phone: str, Org object with creation details """ data = { - 'name': name, - 'full_address': full_address, - 'phone': phone, - 'first_name': first_name, - 'last_name': last_name + "name": name, + "full_address": full_address, + "phone": phone, + "first_name": first_name, + "last_name": last_name, } - response = self._client._post('/v1/console/hid/orgs', data) + response = self._client._post("/v1/console/hid/orgs", data) return Org(self._client, response) + class HID: def __init__(self, client): self._client = client self.orgs = HIDOrgs(client) + class Console: def __init__(self, client): self._client = client @@ -263,24 +298,28 @@ def __init__(self, client): def create_template(self, **kwargs) -> Template: """Create a new card template""" - response = self._client._post('/v1/console/card-templates', kwargs) + response = self._client._post("/v1/console/card-templates", kwargs) return Template(self._client, response) def update_template(self, template_id: str, **kwargs) -> Template: """Update an existing card template""" - response = self._client._put(f'/v1/console/card-templates/{template_id}', kwargs) + response = self._client._put( + f"/v1/console/card-templates/{template_id}", kwargs + ) return Template(self._client, response) def read_template(self, template_id: str) -> Union[Template, List[Template]]: "Read card template by id or list the card template pairs" - response = self._client._get(f'/v1/console/card-templates/{template_id}') - if 'templates' in response: - return [Template(self._client, item) for item in response['templates']] + response = self._client._get(f"/v1/console/card-templates/{template_id}") + if "templates" in response: + return [Template(self._client, item) for item in response["templates"]] return Template(self._client, response) def get_logs(self, template_id: str, **kwargs) -> Dict[str, Any]: """Get event logs for a card template""" - return self._client._get(f'/v1/console/card-templates/{template_id}/logs', params=kwargs) + return self._client._get( + f"/v1/console/card-templates/{template_id}/logs", params=kwargs + ) def list_pass_template_pairs(self, **kwargs) -> Dict[str, Any]: """ @@ -293,18 +332,24 @@ def list_pass_template_pairs(self, **kwargs) -> Dict[str, Any]: Returns: Dict containing pass_template_pairs list and pagination info """ - response = self._client._get('/v1/console/pass-template-pairs', params=kwargs) + response = self._client._get("/v1/console/pass-template-pairs", params=kwargs) - if 'pass_template_pairs' in response: - response['pass_template_pairs'] = [ + if "pass_template_pairs" in response: + response["pass_template_pairs"] = [ PassTemplatePair(self._client, pair) - for pair in response['pass_template_pairs'] + for pair in response["pass_template_pairs"] ] return response + class AccessGrid: - def __init__(self, account_id: str, secret_key: str, base_url: str = 'https://api.accessgrid.com'): + def __init__( + self, + account_id: str, + secret_key: str, + base_url: str = "https://api.accessgrid.com", + ): if not account_id: raise ValueError("Account ID is required") if not secret_key: @@ -312,8 +357,8 @@ def __init__(self, account_id: str, secret_key: str, base_url: str = 'https://ap self.account_id = account_id self.secret_key = secret_key - self.base_url = base_url.rstrip('/') - + self.base_url = base_url.rstrip("/") + # Initialize API clients self.access_cards = AccessCards(self) self.console = Console(self) @@ -322,47 +367,53 @@ def _generate_signature(self, payload: str) -> str: """ Generate HMAC signature for the payload according to the shared secret scheme: SHA256.update(shared_secret + base64.encode(payload)).hexdigest() - - For requests with no payload (like GET, or actions like suspend/unlink/resume), - caller should provide a payload with {"id": "{resource_id}"} + + For requests with no payload (like GET, or actions like + suspend/unlink/resume), caller should provide a payload + with {"id": "{resource_id}"} """ # Base64 encode the payload payload_bytes = payload.encode() encoded_payload = base64.b64encode(payload_bytes) - - # Create HMAC using the shared secret as the key and the base64 encoded payload as the message + + # Create HMAC using the shared secret as the key + # and the base64 encoded payload as the message signature = hmac.new( - self.secret_key.encode(), - encoded_payload, - hashlib.sha256 + self.secret_key.encode(), encoded_payload, hashlib.sha256 ).hexdigest() - + return signature - def _make_request(self, method: str, endpoint: str, - data: Optional[Dict] = None, - params: Optional[Dict] = None) -> Dict[str, Any]: + def _make_request( + self, + method: str, + endpoint: str, + data: Optional[Dict] = None, + params: Optional[Dict] = None, + ) -> Dict[str, Any]: """Make an HTTP request to the API""" url = f"{self.base_url}{endpoint}" - + # Extract resource ID from the endpoint if needed for signature resource_id = None - if method == 'GET' or (method == 'POST' and (not data or data == {})): - # Extract the ID from the endpoint - patterns like /resource/{id} or /resource/{id}/action - parts = endpoint.strip('/').split('/') + if method == "GET" or (method == "POST" and (not data or data == {})): + # Extract ID from endpoint patterns like + # /resource/{id} or /resource/{id}/action + parts = endpoint.strip("/").split("/") if len(parts) >= 2: - # For actions like unlink/suspend/resume, get the card ID (second to last part) - if parts[-1] in ['suspend', 'resume', 'unlink', 'delete']: + # For actions like unlink/suspend/resume, + # get the card ID (second to last part) + if parts[-1] in ["suspend", "resume", "unlink", "delete"]: resource_id = parts[-2] else: # Otherwise, the ID is typically the last part of the path resource_id = parts[-1] - + # Special handling for requests with no payload: # 1. POST requests with empty body (like unlink/suspend/resume) # 2. GET requests - if (method == 'POST' and not data) or method == 'GET': - # For these requests, use {"id": "card_id"} as the payload for signature generation + if (method == "POST" and not data) or method == "GET": + # Use {"id": "card_id"} as the signature payload if resource_id: payload = json.dumps({"id": resource_id}) else: @@ -370,44 +421,40 @@ def _make_request(self, method: str, endpoint: str, else: # For normal POST/PUT/PATCH with body, use the actual payload payload = json.dumps(data) if data else "" - - # Generate signature - we don't need to pass resource_id separately since we've already - # incorporated it into the payload when needed + + # Generate signature — resource_id is already + # incorporated into the payload when needed signature = self._generate_signature(payload) - + headers = { - 'X-ACCT-ID': self.account_id, - 'X-PAYLOAD-SIG': signature, - 'Content-Type': 'application/json', - 'User-Agent': f'accessgrid.py @ v{__version__}' + "X-ACCT-ID": self.account_id, + "X-PAYLOAD-SIG": signature, + "Content-Type": "application/json", + "User-Agent": f"accessgrid.py @ v{__version__}", } - # For GET requests, we don't need to add sig_payload here anymore + # For GET requests, we don't need to add sig_payload here anymore # as it's handled in the request section below try: - # For requests with empty bodies (GET or action endpoints like unlink/suspend/resume), - # we need to include the sig_payload parameter - if method == 'GET' or (method == 'POST' and not data): + # For empty-body requests (GET or actions), + # include the sig_payload parameter + if method == "GET" or (method == "POST" and not data): if not params: params = {} # Include the ID payload in the query params if resource_id: # The server expects the raw JSON string, not URL-encoded - params['sig_payload'] = json.dumps({"id": resource_id}) - + params["sig_payload"] = json.dumps({"id": resource_id}) + # For POST/PUT/PATCH with empty body, don't include a JSON body # as the server uses request.raw_post which would be empty - json_data = data if data and method != 'GET' else None - + json_data = data if data and method != "GET" else None + response = requests.request( - method=method, - url=url, - headers=headers, - json=json_data, - params=params + method=method, url=url, headers=headers, json=json_data, params=params ) - + if response.status_code == 401: raise AuthenticationError("Invalid credentials") elif response.status_code == 402: @@ -415,7 +462,7 @@ def _make_request(self, method: str, endpoint: str, elif not 200 <= response.status_code < 300: print(f"response.status_code: {response.status_code}") error_data = response.json() if response.text else {} - error_message = error_data.get('message', response.text) + error_message = error_data.get("message", response.text) raise AccessGridError(f"API request failed: {error_message}") return response.json() @@ -425,16 +472,16 @@ def _make_request(self, method: str, endpoint: str, def _get(self, endpoint: str, params: Optional[Dict] = None) -> Dict[str, Any]: """Make a GET request""" - return self._make_request('GET', endpoint, params=params) + return self._make_request("GET", endpoint, params=params) def _post(self, endpoint: str, data: Dict) -> Dict[str, Any]: """Make a POST request""" - return self._make_request('POST', endpoint, data=data) + return self._make_request("POST", endpoint, data=data) def _put(self, endpoint: str, data: Dict) -> Dict[str, Any]: """Make a PUT request""" - return self._make_request('PUT', endpoint, data=data) + return self._make_request("PUT", endpoint, data=data) def _patch(self, endpoint: str, data: Dict) -> Dict[str, Any]: """Make a PATCH request""" - return self._make_request('PATCH', endpoint, data=data) + return self._make_request("PATCH", endpoint, data=data) diff --git a/tests/test_accessgrid.py b/tests/test_accessgrid.py index 5f86639..8449a75 100644 --- a/tests/test_accessgrid.py +++ b/tests/test_accessgrid.py @@ -1,68 +1,143 @@ +from unittest.mock import Mock, patch + import pytest -from unittest.mock import patch, Mock + from accessgrid import AccessGrid, AccessGridError, AuthenticationError -MOCK_ACCOUNT_ID = 'test-account-id' -MOCK_SECRET_KEY = 'test-secret-key' +MOCK_ACCOUNT_ID = "test-account-id" +MOCK_SECRET_KEY = "test-secret-key" + @pytest.fixture def client(): return AccessGrid(MOCK_ACCOUNT_ID, MOCK_SECRET_KEY) + @pytest.fixture def mock_response(): mock = Mock() - mock.json.return_value = {'status': 'success'} + mock.json.return_value = {"status": "success"} mock.status_code = 200 mock.text = '{"status": "success"}' return mock + class TestAccessGrid: def test_constructor_missing_account_id(self): - with pytest.raises(ValueError, match='Account ID is required'): + with pytest.raises(ValueError, match="Account ID is required"): AccessGrid(None, MOCK_SECRET_KEY) def test_constructor_missing_secret_key(self): - with pytest.raises(ValueError, match='Secret Key is required'): + with pytest.raises(ValueError, match="Secret Key is required"): AccessGrid(MOCK_ACCOUNT_ID, None) def test_constructor_with_custom_base_url(self): - custom_url = 'https://custom.api.com' + custom_url = "https://custom.api.com" client = AccessGrid(MOCK_ACCOUNT_ID, MOCK_SECRET_KEY, base_url=custom_url) - assert client.base_url == custom_url.rstrip('/') + assert client.base_url == custom_url.rstrip("/") + class TestAccessCards: @pytest.fixture def mock_provision_params(self): return { - 'card_template_id': '0xd3adb00b5', - 'employee_id': '123456789', - 'tag_id': 'DDEADB33FB00B5', - 'full_name': 'Employee name', - 'email': 'employee@yourwebsite.com', - 'phone_number': '+19547212241', - 'classification': 'full_time', - 'start_date': '2025-01-31T22:46:25.601Z', - 'expiration_date': '2025-04-30T22:46:25.601Z', - 'employee_photo': 'base64photo' + "card_template_id": "0xd3adb00b5", + "employee_id": "123456789", + "tag_id": "DDEADB33FB00B5", + "full_name": "Employee name", + "email": "employee@yourwebsite.com", + "phone_number": "+19547212241", + "classification": "full_time", + "start_date": "2025-01-31T22:46:25.601Z", + "expiration_date": "2025-04-30T22:46:25.601Z", + "employee_photo": "base64photo", } - @patch('requests.request') - def test_provision_card(self, mock_request, client, mock_response, mock_provision_params): + @patch("requests.request") + def test_provision_card( + self, mock_request, client, mock_response, mock_provision_params + ): mock_request.return_value = mock_response - card = client.access_cards.provision(**mock_provision_params) + client.access_cards.provision(**mock_provision_params) mock_request.assert_called_once() call_args = mock_request.call_args[1] - assert call_args['method'] == 'POST' - assert call_args['url'] == f"{client.base_url}/api/v1/nfc_keys/issue" - assert call_args['json'] == mock_provision_params - assert call_args['headers']['X-ACCT-ID'] == MOCK_ACCOUNT_ID - assert 'X-PAYLOAD-SIG' in call_args['headers'] - assert call_args['headers']['Content-Type'] == 'application/json' + assert call_args["method"] == "POST" + assert call_args["url"] == f"{client.base_url}/v1/key-cards" + assert call_args["json"] == mock_provision_params + assert call_args["headers"]["X-ACCT-ID"] == MOCK_ACCOUNT_ID + assert "X-PAYLOAD-SIG" in call_args["headers"] + assert call_args["headers"]["Content-Type"] == "application/json" + + @patch("requests.request") + def test_issue_returns_unified_access_pass( + self, mock_request, client, mock_provision_params + ): + mock_resp = Mock() + mock_resp.status_code = 200 + mock_resp.json.return_value = { + "id": "uap-1", + "install_url": "https://example.com/install/uap-1", + "state": "active", + "status": "issued", + "details": [ + { + "id": "card-ios", + "state": "active", + "full_name": "John Doe", + "install_url": "https://example.com/install/card-ios", + }, + { + "id": "card-android", + "state": "active", + "full_name": "John Doe", + "install_url": "https://example.com/install/card-android", + }, + ], + } + mock_request.return_value = mock_resp + + result = client.access_cards.issue(**mock_provision_params) + + assert type(result).__name__ == "UnifiedAccessPass" + assert result.id == "uap-1" + assert result.state == "active" + assert result.status == "issued" + assert len(result.details) == 2 + assert result.details[0].id == "card-ios" + assert result.details[1].id == "card-android" + expected_str = ( + "UnifiedAccessPass(id='uap-1', state='active', cards=2)" # noqa: E501 + ) + assert str(result) == expected_str + assert repr(result) == expected_str + + @patch("requests.request") + def test_provision_card_auth_error( + self, mock_request, client, mock_provision_params + ): + error_response = Mock() + error_response.status_code = 401 + error_response.text = "Unauthorized" + mock_request.return_value = error_response + + with pytest.raises(AuthenticationError, match="Invalid credentials"): + client.access_cards.provision(**mock_provision_params) + + @patch("requests.request") + def test_provision_card_balance_error( + self, mock_request, client, mock_provision_params + ): + error_response = Mock() + error_response.status_code = 402 + error_response.text = "Payment required" + mock_request.return_value = error_response + + with pytest.raises(AccessGridError, match="Insufficient account balance"): + client.access_cards.provision(**mock_provision_params) - @patch('requests.request') + @patch("requests.request") def test_provision_card_error(self, mock_request, client, mock_provision_params): error_response = Mock() error_response.status_code = 400 @@ -70,151 +145,382 @@ def test_provision_card_error(self, mock_request, client, mock_provision_params) error_response.json.return_value = {"message": "Invalid template ID"} mock_request.return_value = error_response - with pytest.raises(AccessGridError, match='API request failed: Invalid template ID'): + with pytest.raises( + AccessGridError, match="API request failed: Invalid template ID" + ): client.access_cards.provision(**mock_provision_params) - @patch('requests.request') + @patch("requests.request") + def test_get_card(self, mock_request, client): + mock_resp = Mock() + mock_resp.status_code = 200 + mock_resp.json.return_value = { + "id": "card-123", + "full_name": "John Doe", + "state": "active", + "install_url": "https://example.com/install/card-123", + "card_template_id": "tmpl-456", + "expiration_date": "2025-12-31", + } + mock_request.return_value = mock_resp + + card = client.access_cards.get("card-123") + + call_args = mock_request.call_args[1] + assert call_args["method"] == "GET" + assert call_args["url"] == f"{client.base_url}/v1/key-cards/card-123" + assert card.id == "card-123" + assert card.full_name == "John Doe" + assert card.state == "active" + assert card.install_url == "https://example.com/install/card-123" + assert card.card_template_id == "tmpl-456" + expected_str = "AccessCard(name='John Doe', id='card-123', state='active', card_template_id='tmpl-456')" # noqa: E501 + assert str(card) == expected_str + assert repr(card) == expected_str + + @patch("requests.request") def test_update_card(self, mock_request, client, mock_response): mock_request.return_value = mock_response + card_id = "0xc4rd1d" update_params = { - 'card_id': '0xc4rd1d', - 'employee_id': '987654321', - 'full_name': 'Updated Employee Name', - 'classification': 'contractor', - 'expiration_date': '2025-02-22T21:04:03.664Z' + "employee_id": "987654321", + "full_name": "Updated Employee Name", + "classification": "contractor", + "expiration_date": "2025-02-22T21:04:03.664Z", } - - card = client.access_cards.update(**update_params) - + + client.access_cards.update(card_id, **update_params) + mock_request.assert_called_once() call_args = mock_request.call_args[1] - assert call_args['method'] == 'POST' - assert call_args['url'] == f"{client.base_url}/api/v1/nfc_keys/update" - assert call_args['json'] == update_params + assert call_args["method"] == "PATCH" + assert call_args["url"] == f"{client.base_url}/v1/key-cards/{card_id}" + assert call_args["json"] == update_params - @patch('requests.request') + @patch("requests.request") def test_manage_operations(self, mock_request, client, mock_response): mock_request.return_value = mock_response - card_id = '0xc4rd1d' + card_id = "0xc4rd1d" # Test suspend client.access_cards.suspend(card_id) call_args = mock_request.call_args[1] - assert call_args['method'] == 'POST' - assert call_args['url'] == f"{client.base_url}/api/v1/nfc_keys/manage" - assert call_args['json'] == {'card_id': card_id, 'manage_action': 'suspend'} + assert call_args["method"] == "POST" + assert call_args["url"] == f"{client.base_url}/v1/key-cards/{card_id}/suspend" + assert call_args["json"] is None # Test resume client.access_cards.resume(card_id) call_args = mock_request.call_args[1] - assert call_args['json'] == {'card_id': card_id, 'manage_action': 'resume'} + assert call_args["method"] == "POST" + assert call_args["url"] == f"{client.base_url}/v1/key-cards/{card_id}/resume" + assert call_args["json"] is None # Test unlink client.access_cards.unlink(card_id) call_args = mock_request.call_args[1] - assert call_args['json'] == {'card_id': card_id, 'manage_action': 'unlink'} - - @patch('requests.request') + assert call_args["method"] == "POST" + assert call_args["url"] == f"{client.base_url}/v1/key-cards/{card_id}/unlink" + assert call_args["json"] is None + + # Test delete + client.access_cards.delete(card_id) + call_args = mock_request.call_args[1] + assert call_args["method"] == "POST" + assert call_args["url"] == f"{client.base_url}/v1/key-cards/{card_id}/delete" + assert call_args["json"] is None + + @patch("requests.request") def test_list_keys(self, mock_request, client, mock_response): mock_response.json.return_value = { - 'items': [ + "keys": [ { - 'id': 'key1', - 'state': 'active', - 'full_name': 'John Doe', - 'install_url': 'https://example.com/install/key1', - 'expiration_date': '2025-12-31' + "id": "key1", + "state": "active", + "full_name": "John Doe", + "install_url": "https://example.com/install/key1", + "expiration_date": "2025-12-31", }, { - 'id': 'key2', - 'state': 'suspended', - 'full_name': 'Jane Smith', - 'install_url': 'https://example.com/install/key2', - 'expiration_date': '2025-12-31' - } + "id": "key2", + "state": "suspended", + "full_name": "Jane Smith", + "install_url": "https://example.com/install/key2", + "expiration_date": "2025-12-31", + }, ] } mock_request.return_value = mock_response - template_id = '0xd3adb00b5' - + template_id = "0xd3adb00b5" + # Test list with template_id only keys = client.access_cards.list(template_id=template_id) assert len(keys) == 2 - assert keys[0].id == 'key1' - assert keys[0].state == 'active' - assert keys[0].full_name == 'John Doe' - - call_args = mock_request.call_args[1] - assert call_args['method'] == 'GET' - assert call_args['url'] == f"{client.base_url}/v1/key-cards" - assert call_args['params'] == {'template_id': template_id, 'sig_payload': '{}'} - + assert keys[0].id == "key1" + assert keys[0].state == "active" + assert keys[0].full_name == "John Doe" + + call_args = mock_request.call_args[1] + assert call_args["method"] == "GET" + assert call_args["url"] == f"{client.base_url}/v1/key-cards" + assert call_args["params"]["template_id"] == template_id + assert "sig_payload" in call_args["params"] + # Test list with template_id and state - keys = client.access_cards.list(template_id=template_id, state='active') + keys = client.access_cards.list(template_id=template_id, state="active") call_args = mock_request.call_args[1] - assert call_args['params'] == {'template_id': template_id, 'state': 'active', 'sig_payload': '{}'} + assert call_args["params"]["template_id"] == template_id + assert call_args["params"]["state"] == "active" + assert "sig_payload" in call_args["params"] + class TestConsole: @pytest.fixture def mock_template_params(self): return { - 'name': 'Employee NFC key', - 'platform': 'apple', - 'use_case': 'employee_badge', - 'protocol': 'desfire', - 'allow_on_multiple_devices': True, - 'watch_count': 2, - 'iphone_count': 3, - 'design': { - 'background_color': '#FFFFFF', - 'label_color': '#000000', - 'label_secondary_color': '#333333' + "name": "Employee NFC key", + "platform": "apple", + "use_case": "employee_badge", + "protocol": "desfire", + "allow_on_multiple_devices": True, + "watch_count": 2, + "iphone_count": 3, + "design": { + "background_color": "#FFFFFF", + "label_color": "#000000", + "label_secondary_color": "#333333", + }, + "support_info": { + "support_url": "https://help.yourcompany.com", + "support_phone_number": "+1-555-123-4567", + "support_email": "support@yourcompany.com", + "privacy_policy_url": "https://yourcompany.com/privacy", + "terms_and_conditions_url": "https://yourcompany.com/terms", }, - 'support_info': { - 'support_url': 'https://help.yourcompany.com', - 'support_phone_number': '+1-555-123-4567', - 'support_email': 'support@yourcompany.com', - 'privacy_policy_url': 'https://yourcompany.com/privacy', - 'terms_and_conditions_url': 'https://yourcompany.com/terms' - } } - @patch('requests.request') - def test_create_template(self, mock_request, client, mock_response, mock_template_params): + @patch("requests.request") + def test_create_template( + self, mock_request, client, mock_response, mock_template_params + ): + mock_request.return_value = mock_response + + client.console.create_template(**mock_template_params) + + call_args = mock_request.call_args[1] + assert call_args["method"] == "POST" + assert call_args["url"] == f"{client.base_url}/v1/console/card-templates" + assert call_args["json"] == mock_template_params + + @patch("requests.request") + def test_update_template(self, mock_request, client, mock_response): mock_request.return_value = mock_response - - template = client.console.create_template(**mock_template_params) - + template_id = "0xd3adb00b5" + update_params = { + "name": "Updated Template", + "allow_on_multiple_devices": False, + "watch_count": 1, + "iphone_count": 2, + } + + client.console.update_template(template_id, **update_params) + call_args = mock_request.call_args[1] - assert call_args['method'] == 'POST' - assert call_args['url'] == f"{client.base_url}/api/v1/enterprise/create_template" - assert call_args['json'] == mock_template_params + assert call_args["method"] == "PUT" + assert ( + call_args["url"] + == f"{client.base_url}/v1/console/card-templates/{template_id}" + ) + assert call_args["json"] == update_params - @patch('requests.request') + @patch("requests.request") def test_read_template(self, mock_request, client, mock_response): mock_request.return_value = mock_response - template_id = '0xd3adb00b5' - - template = client.console.read_template(template_id) - + template_id = "0xd3adb00b5" + + client.console.read_template(template_id) + + call_args = mock_request.call_args[1] + assert call_args["method"] == "GET" + assert ( + call_args["url"] + == f"{client.base_url}/v1/console/card-templates/{template_id}" + ) + + @patch("requests.request") + def test_list_pass_template_pairs(self, mock_request, client): + mock_resp = Mock() + mock_resp.status_code = 200 + mock_resp.json.return_value = { + "pass_template_pairs": [ + { + "id": "pair-1", + "name": "Employee Badge", + "created_at": "2025-01-15T10:00:00Z", + "android_template": { + "id": "tmpl-android-1", + "name": "Android Employee Badge", + "platform": "google", + }, + "ios_template": { + "id": "tmpl-ios-1", + "name": "iOS Employee Badge", + "platform": "apple", + }, + }, + { + "id": "pair-2", + "name": "Visitor Pass", + "created_at": "2025-02-01T12:00:00Z", + "android_template": None, + "ios_template": { + "id": "tmpl-ios-2", + "name": "iOS Visitor Pass", + "platform": "apple", + }, + }, + ], + "page": 1, + "per_page": 50, + } + mock_request.return_value = mock_resp + + result = client.console.list_pass_template_pairs(page=1, per_page=50) + + call_args = mock_request.call_args[1] + assert call_args["method"] == "GET" + assert call_args["url"] == f"{client.base_url}/v1/console/pass-template-pairs" + assert call_args["params"]["page"] == 1 + assert call_args["params"]["per_page"] == 50 + + pairs = result["pass_template_pairs"] + assert len(pairs) == 2 + + # First pair — both platforms + assert pairs[0].id == "pair-1" + assert pairs[0].name == "Employee Badge" + assert pairs[0].android_template.id == "tmpl-android-1" + assert pairs[0].android_template.platform == "google" + expected_ti = "TemplateInfo(id='tmpl-android-1', name='Android Employee Badge', platform='google')" # noqa: E501 + assert str(pairs[0].android_template) == expected_ti + assert repr(pairs[0].android_template) == expected_ti + assert pairs[0].ios_template.id == "tmpl-ios-1" + assert pairs[0].ios_template.platform == "apple" + + # Second pair — android_template is None + assert pairs[1].id == "pair-2" + assert pairs[1].android_template is None + assert pairs[1].ios_template.id == "tmpl-ios-2" + + +class TestHIDOrgs: + @patch("requests.request") + def test_create_org(self, mock_request, client): + mock_resp = Mock() + mock_resp.status_code = 200 + mock_resp.json.return_value = { + "id": "org-1", + "name": "Acme Corp", + "slug": "acme-corp", + "status": "pending", + "full_address": "123 Main St", + "phone": "+1-555-0000", + "first_name": "Jane", + "last_name": "Doe", + "created_at": "2025-01-15T10:00:00Z", + } + mock_request.return_value = mock_resp + + org = client.console.hid.orgs.create( + name="Acme Corp", + full_address="123 Main St", + phone="+1-555-0000", + first_name="Jane", + last_name="Doe", + ) + call_args = mock_request.call_args[1] - assert call_args['method'] == 'GET' - assert call_args['url'] == f"{client.base_url}/api/v1/enterprise/read_template/{template_id}" + assert call_args["method"] == "POST" + assert call_args["url"] == f"{client.base_url}/v1/console/hid/orgs" + assert call_args["json"]["name"] == "Acme Corp" + assert call_args["json"]["first_name"] == "Jane" + assert org.id == "org-1" + assert org.name == "Acme Corp" + assert org.slug == "acme-corp" + assert org.status == "pending" + expected_str = "Org(name='Acme Corp', id='org-1', slug='acme-corp', status='pending')" # noqa: E501 + assert str(org) == expected_str + assert repr(org) == expected_str + + @patch("requests.request") + def test_activate_org(self, mock_request, client): + mock_resp = Mock() + mock_resp.status_code = 200 + mock_resp.json.return_value = { + "id": "org-1", + "name": "Acme Corp", + "slug": "acme-corp", + "status": "active", + "email": "admin@acme.com", + } + mock_request.return_value = mock_resp - @patch('requests.request') - def test_event_log(self, mock_request, client, mock_response): + org = client.console.hid.orgs.activate( + email="admin@acme.com", password="hid-registration-pw" + ) + + call_args = mock_request.call_args[1] + assert call_args["method"] == "POST" + assert call_args["url"] == f"{client.base_url}/v1/console/hid/orgs/activate" + assert call_args["json"]["email"] == "admin@acme.com" + assert call_args["json"]["password"] == "hid-registration-pw" + assert org.id == "org-1" + assert org.status == "active" + + @patch("requests.request") + def test_list_orgs(self, mock_request, client): + mock_resp = Mock() + mock_resp.status_code = 200 + mock_resp.json.return_value = { + "orgs": [ + {"id": "org-1", "name": "Acme Corp", "status": "active"}, + {"id": "org-2", "name": "Globex", "status": "pending"}, + ] + } + mock_request.return_value = mock_resp + + orgs = client.console.hid.orgs.list() + + call_args = mock_request.call_args[1] + assert call_args["method"] == "GET" + assert call_args["url"] == f"{client.base_url}/v1/console/hid/orgs" + assert len(orgs) == 2 + assert orgs[0].id == "org-1" + assert orgs[0].name == "Acme Corp" + assert orgs[1].status == "pending" + + +class TestConsoleLogs: + @patch("requests.request") + def test_get_logs(self, mock_request, client, mock_response): mock_request.return_value = mock_response - template_id = '0xd3adb00b5' + template_id = "0xd3adb00b5" filters = { - 'device': 'mobile', - 'start_date': '2025-01-01T00:00:00Z', - 'end_date': '2025-02-01T00:00:00Z', - 'event_type': 'install' + "device": "mobile", + "start_date": "2025-01-01T00:00:00Z", + "end_date": "2025-02-01T00:00:00Z", + "event_type": "install", } - - events = client.console.event_log(template_id, filters=filters) - + + client.console.get_logs(template_id, **filters) + call_args = mock_request.call_args[1] - assert call_args['method'] == 'GET' - assert call_args['url'] == f"{client.base_url}/api/v1/enterprise/logs/{template_id}" - assert call_args['params'] == {'filters': filters, 'page': 1, 'per_page': 50} \ No newline at end of file + assert call_args["method"] == "GET" + assert ( + call_args["url"] + == f"{client.base_url}/v1/console/card-templates/{template_id}/logs" + ) + assert call_args["params"]["device"] == "mobile" + assert call_args["params"]["start_date"] == "2025-01-01T00:00:00Z" + assert call_args["params"]["end_date"] == "2025-02-01T00:00:00Z" + assert call_args["params"]["event_type"] == "install" diff --git a/tests/test_python_versions.py b/tests/test_python_versions.py new file mode 100644 index 0000000..005fb41 --- /dev/null +++ b/tests/test_python_versions.py @@ -0,0 +1,94 @@ +import re +from pathlib import Path + +import pytest + +# ══════════════════════════════════════════════════════════════════════════════ +# TARGET VERSION - Update this when upgrading Python +# ══════════════════════════════════════════════════════════════════════════════ +TARGET_PYTHON = "3.12.13" + +ROOT = Path(__file__).resolve().parent.parent + + +def read_tool_versions(): + content = (ROOT / ".tool-versions").read_text() + versions = {} + for line in content.strip().splitlines(): + parts = line.split(None, 1) + if len(parts) == 2: + versions[parts[0]] = parts[1] + return versions + + +def read_ci_jobs(): + ci_path = ROOT / ".github" / "workflows" / "ci.yml" + content = ci_path.read_text() + + jobs = {} + # Split into job blocks by top-level job keys (2-space indent) + job_blocks = re.split(r"\n (?=\w[\w-]*:)", content) + + for block in job_blocks: + name_match = re.match(r"^([\w-]+):", block) + if not name_match: + continue + job_name = name_match.group(1) + + versions = [] + + # Matrix arrays: python: ['3.10', '3.12', '3.13'] + matrix_match = re.search(r"python:\s*\[([^\]]+)\]", block) + if matrix_match: + for v in matrix_match.group(1).split(","): + versions.append(v.strip().strip("'\"")) + + # Standalone: python-version: '3.12' + for m in re.finditer(r"python-version:\s*['\"]?([\d.]+)['\"]?", block): + if m.group(1) not in versions: + versions.append(m.group(1)) + + if versions: + jobs[job_name] = versions + + return jobs + + +def major_minor(version): + parts = version.split(".") + return f"{parts[0]}.{parts[1]}" + + +class TestToolVersions: + def test_python_version_matches_target(self): + tool_versions = read_tool_versions() + assert tool_versions["python"] == TARGET_PYTHON + + +class TestCIWorkflow: + @pytest.fixture(scope="class") + def jobs(self): + return read_ci_jobs() + + def test_every_job_includes_target_minor(self, jobs): + target_minor = major_minor(TARGET_PYTHON) + for job_name, versions in jobs.items(): + assert target_minor in versions, ( + f"Job '{job_name}' is missing target " + f"version {target_minor}: {versions}" + ) + + def test_every_version_is_valid_format(self, jobs): + for job_name, versions in jobs.items(): + for v in versions: + assert re.match(r"^\d+\.\d+$", v), ( + f"Job '{job_name}' has invalid " f"version format: {v}" + ) + + def test_tool_versions_minor_in_every_job(self, jobs): + tool_minor = major_minor(read_tool_versions()["python"]) + for job_name, versions in jobs.items(): + assert tool_minor in versions, ( + f"Job '{job_name}' doesn't test " + f".tool-versions minor {tool_minor}: {versions}" + )