diff --git a/.github/workflows/ci_pipeline.yml b/.github/workflows/ci_pipeline.yml index a625276..7303e38 100644 --- a/.github/workflows/ci_pipeline.yml +++ b/.github/workflows/ci_pipeline.yml @@ -1,76 +1,76 @@ -name: ✅ CI Pipeline - -on: - pull_request: - branches: [dev, main] - schedule: - - cron: "0 3 * * 1" # Läuft jeden Montag um 03:00 UTC für automatische Updates - -permissions: - contents: read - -concurrency: - # Bei Push: "CI-push-dev", bei PR zu main: "CI-pr-123" - group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.event_name == 'push' && github.ref_name || github.event.pull_request.number }} - cancel-in-progress: true - -jobs: - lint: - name: "🔍 Linting & Static Analysis" - runs-on: ubuntu-latest - steps: - - name: Checkout Repository - uses: actions/checkout@v4 - - - name: Set up Python - uses: actions/setup-python@v4 - with: - python-version: "3.11" - - - name: Install dependencies - run: | - pip install black pylint - - - name: Autoformat with Black - run: black . --line-length=88 --fast - - - name: Run Pylint - run: | - pylint custom_components/diveracontrol --exit-zero - - security_checks: - name: "🔒 Security Checks" - runs-on: ubuntu-latest - steps: - - name: 📥 Checkout Code - uses: actions/checkout@v3 - - - name: 🐍 Set up Python - uses: actions/setup-python@v4 - with: - python-version: "3.13" - - - name: 🔒 Install Security Tools - run: pip install bandit safety - - - name: 🔍 Run Bandit (Code Security) - run: bandit -r custom_components/ -ll -i - - - name: 🛡️ Run Safety (Dependency Security) - run: safety check --full-report - - secrets_scan: - name: "🕵️ Secret Scanning" - runs-on: ubuntu-latest - steps: - - name: 📥 Checkout Code (Full History) - uses: actions/checkout@v3 - with: - fetch-depth: 0 - - - name: 🐽 TruffleHog OSS - uses: trufflesecurity/trufflehog@v3.88.6 - with: - base: HEAD~50 - head: HEAD - extra_args: "--only-verified --json" +name: ✅ CI Pipeline + +on: + pull_request: + branches: [dev, main] + schedule: + - cron: "0 3 * * 1" # Läuft jeden Montag um 03:00 UTC für automatische Updates + +permissions: + contents: read + +concurrency: + # Bei Push: "CI-push-dev", bei PR zu main: "CI-pr-123" + group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.event_name == 'push' && github.ref_name || github.event.pull_request.number }} + cancel-in-progress: true + +jobs: + lint: + name: "🔍 Linting & Static Analysis" + runs-on: ubuntu-latest + steps: + - name: Checkout Repository + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v4 + with: + python-version: "3.11" + + - name: Install dependencies + run: | + pip install black pylint + + - name: Autoformat with Black + run: black . --line-length=88 --fast + + - name: Run Pylint + run: | + pylint custom_components/diveracontrol --exit-zero + + security_checks: + name: "🔒 Security Checks" + runs-on: ubuntu-latest + steps: + - name: 📥 Checkout Code + uses: actions/checkout@v3 + + - name: 🐍 Set up Python + uses: actions/setup-python@v4 + with: + python-version: "3.13" + + - name: 🔒 Install Security Tools + run: pip install bandit safety + + - name: 🔍 Run Bandit (Code Security) + run: bandit -r custom_components/ -ll -i + + - name: 🛡️ Run Safety (Dependency Security) + run: safety check --full-report + + secrets_scan: + name: "🕵️ Secret Scanning" + runs-on: ubuntu-latest + steps: + - name: 📥 Checkout Code (Full History) + uses: actions/checkout@v3 + with: + fetch-depth: 0 + + - name: 🐽 TruffleHog OSS + uses: trufflesecurity/trufflehog@v3.88.6 + with: + base: HEAD~50 + head: HEAD + extra_args: "--only-verified --json" diff --git a/.github/workflows/hacs.yml b/.github/workflows/hacs.yml index b3ee8b0..2f6d146 100644 --- a/.github/workflows/hacs.yml +++ b/.github/workflows/hacs.yml @@ -1,27 +1,27 @@ -name: HACS Quality Checks - -on: - pull_request: - branches: [dev, main] - schedule: - - cron: "0 0 * * *" - -permissions: - contents: read - -concurrency: - # Bei Push: "HACS-push-dev", bei PR zu main: "HACS-pr-123" - group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.event_name == 'push' && github.ref_name || github.event.pull_request.number }} - cancel-in-progress: true - -jobs: - hacs: - name: HACS - runs-on: "ubuntu-latest" - steps: - - name: Checkout Code - uses: actions/checkout@v3 - - name: HACS validation - uses: "hacs/action@main" - with: - category: "integration" +name: HACS Quality Checks + +on: + pull_request: + branches: [dev, main] + schedule: + - cron: "0 0 * * *" + +permissions: + contents: read + +concurrency: + # Bei Push: "HACS-push-dev", bei PR zu main: "HACS-pr-123" + group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.event_name == 'push' && github.ref_name || github.event.pull_request.number }} + cancel-in-progress: true + +jobs: + hacs: + name: HACS + runs-on: "ubuntu-latest" + steps: + - name: Checkout Code + uses: actions/checkout@v3 + - name: HACS validation + uses: "hacs/action@main" + with: + category: "integration" diff --git a/.github/workflows/hass.yml b/.github/workflows/hass.yml index ed85f2c..3dee269 100644 --- a/.github/workflows/hass.yml +++ b/.github/workflows/hass.yml @@ -1,23 +1,23 @@ -name: Home Assistant Quality Checks - -on: - pull_request: - branches: [dev, main] - schedule: - - cron: "0 0 * * *" - -permissions: - contents: read - -concurrency: - # Bei Push: "HASS-push-dev", bei PR zu main: "HASS-pr-123" - group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.event_name == 'push' && github.ref_name || github.event.pull_request.number }} - cancel-in-progress: true - -jobs: - hass: - name: HASS - runs-on: "ubuntu-latest" - steps: - - uses: "actions/checkout@v4" - - uses: "home-assistant/actions/hassfest@master" +name: Home Assistant Quality Checks + +on: + pull_request: + branches: [dev, main] + schedule: + - cron: "0 0 * * *" + +permissions: + contents: read + +concurrency: + # Bei Push: "HASS-push-dev", bei PR zu main: "HASS-pr-123" + group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.event_name == 'push' && github.ref_name || github.event.pull_request.number }} + cancel-in-progress: true + +jobs: + hass: + name: HASS + runs-on: "ubuntu-latest" + steps: + - uses: "actions/checkout@v4" + - uses: "home-assistant/actions/hassfest@master" diff --git a/.vscode/settings.json b/.vscode/settings.json index 9b38853..a820fba 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,7 +1,12 @@ { - "python.testing.pytestArgs": [ - "tests" - ], - "python.testing.unittestEnabled": false, - "python.testing.pytestEnabled": true -} \ No newline at end of file + "python.testing.pytestArgs": ["tests"], + "python.testing.unittestEnabled": false, + "python.testing.pytestEnabled": true, + "python-envs.pythonProjects": [ + { + "path": ".", + "envManager": "ms-python.python:venv", + "packageManager": "ms-python.python:pip" + } + ] +} diff --git a/README.en.md b/README.en.md index 9e3318c..1a4f53d 100644 --- a/README.en.md +++ b/README.en.md @@ -48,7 +48,7 @@ This is where **DiveraControl** comes in: it provides the interface to alerting - **Owners** and **administrators** of a Divera unit - **Curious users** who want to explore the possibilities of the Divera API -> **Note:** The integration also works with limited permissions, but with reduced functionality. For regular users of a unit without extended permissions, the existing [Divera 24/7 Integration for Home Assistant](https://github.com/fwmarcel/home-assistant-divera) is recommended. +> **Note:** The integration also works with limited permissions, but with reduced functionality. For regular users of a unit without extended permissions, the existing [Divera 24/7 Integration for Home Assistant](https://github.com/moehrem/ha-divera) is recommended. --- @@ -204,6 +204,8 @@ More details on actions and required/optional parameters are available in Home A Actions that modify existing data (for example vehicle position) also update local integration data. This keeps Home Assistant up to date without waiting for the next Divera sync. This does not apply to new records: new alarms or messages are always created in Divera first and then synchronized to Home Assistant. +> **Note:** Actions are subject to permissions granted! Each unit (registered as device) shows only such actions the user is allowed to execute. Despite that a permission check is done every time an action is executed. If permission is not granted, the action will abort. + #### Technical service IDs For YAML, automations, and developer tools, these service IDs are available: diff --git a/README.md b/README.md index ac2ed37..8a99088 100644 --- a/README.md +++ b/README.md @@ -14,10 +14,6 @@ [![GitHub Release](https://img.shields.io/github/v/release/moehrem/DiveraControl?sort=semver)](https://github.com/moehrem/DiveraControl/releases) - - - ![GitHub commit activity](https://img.shields.io/github/commit-activity/m/moehrem/DiveraControl) ![GitHub last commit](https://img.shields.io/github/last-commit/moehrem/DiveraControl) ![GitHub issues](https://img.shields.io/github/issues/moehrem/DiveraControl) @@ -52,7 +48,7 @@ Hier kommt **DiveraControl** ins Spiel: Es stellt die Schnittstelle zur Alarmier - **Besitzer** und **Administratoren** einer Divera-Einheit - **Neugierige Nutzer**, die die Möglichkeiten der Divera-API erkunden wollen -> **Hinweis:** Die Integration funktioniert auch mit eingeschränkten Rechten, dann allerdings mit reduziertem Funktionsumfang. Für reine Nutzer einer Einheit ohne erweiterte Berechtigungen empfiehlt sich die bestehende [Divera 24/7 Integration for Home Assistant](https://github.com/fwmarcel/home-assistant-divera). +> **Hinweis:** Die Integration funktioniert auch mit eingeschränkten Rechten, dann allerdings mit reduziertem Funktionsumfang. Für reine Nutzer einer Einheit ohne erweiterte Berechtigungen empfiehlt sich die [Divera 24/7 Integration for Home Assistant](https://github.com/moehrem/ha-divera). --- @@ -201,12 +197,14 @@ Zur Interaktion mit Divera sind verschiedene Aktionen in HomeAssistant implement - Erstellen einer neuen Mitteilung Alle Aktionen sind geräteabhängig. Das heißt, dass jeder Ausführung die anzusprechende Einheit mitgegeben werden muss. Über Automationen und im Frontend kann als Auslöser einfach eine Einheit gewählt und die gewünschte Aktion ausgeführt werden. Hier sind viele Felder auch mit Auswahlhilfen versehen. -In den Entwickleroptionen oder bei anderer Implementierung der Aktionen muss ein target in Form der device_id eingegeben werden. Aus technischen Gründen können hier einige Felder nicht mit Auswahlhilfen versorgt werden. Das betrifft alle änderbaren Wertehilfen, z.B. Alarme, Fahrzeuge, Besatzung, Gruppen etc. In die Felder muss stattdessen die entsprechende ID, bei mehreren IDs durch Komma getrennt, eingegeben werden. +In den Entwickleroptionen oder bei anderer Implementierung der Aktionen muss ein target in Form der device_id eingegeben werden. Aus technischen Gründen können hier einige Felder nicht mit Auswahlhilfen versorgt werden. Das betrifft alle änderbaren Wertehilfen, z.B. Alarme, Fahrzeuge, Besatzung, Gruppen etc. In diese Felder muss stattdessen die entsprechende ID, bei mehreren IDs durch Komma getrennt, eingegeben werden. Weitere Details zu den Aktionen, insbesondere zu obligatorischen und optionalen Parametern, können im HomeAssistant unter "Entwicklungswerkzeuge" -> "Aktionen" eingesehen werden. Die Umsetzung umfasst sowohl den "YAML-Modus" als auch den "UI-Modus". Alle Aktionen beginnen mit "DiveraControl: ", gefolgt vom Namen und einer kurzen Beschreibung. Es ist dort außerdem möglich, Aktionen manuell zu testen. Weitere Informationen zur Funktionsweise und dem Einsatz von Aktionen [sind hier zu finden](https://www.home-assistant.io/docs/scripts/perform-actions/). Aktionen, die bestehende Daten ändern, z.B. eine Fahrzeugposition, tun dies auch bei den lokalen Daten. Somit ist HomeAssistant immer aktuell und muss nicht auf eine Aktualisierung von Divera warten. Dies gilt jedoch nicht für neue Datensätze! So wird z.B. ein neuer Alarm oder eine neue Nachricht immer bei Divera erstellt und erst danach mit HomeAssistant synchronisiert. +> **Hinweis**: Aktionen sind berechtigungsabhängig! Zu jeder Einheit (abgebildet als Gerät) werden nur solche Aktionen angeboten, für die der Nutzer Berechtigungen hat. Unabhängig davon wird vor jeder Aktionsausführung die Berechtigung geprüft und die Aktion ggf. abgebrochen. + #### Technische Service-IDs Für YAML, Automationen und Entwicklerwerkzeuge sind folgende Service-IDs relevant: diff --git a/custom_components/diveracontrol/config_flow.py b/custom_components/diveracontrol/config_flow.py index e93aedc..17b6473 100644 --- a/custom_components/diveracontrol/config_flow.py +++ b/custom_components/diveracontrol/config_flow.py @@ -21,7 +21,6 @@ D_UPDATE_INTERVAL_ALARM, D_UPDATE_INTERVAL_DATA, D_USE_WEBHOOKS, - D_WEBHOOK_ID, DOMAIN, MINOR_VERSION, PATCH_VERSION, @@ -77,7 +76,7 @@ async def async_step_user( user_input: The user input data of step "user". Returns: - ConfigFLowResult: The result of the config flow step "user". + ConfigFlowResult: The result of the config flow step "user". """ @@ -100,7 +99,11 @@ async def async_step_user( if method == "api_key": return await self.async_step_api_key() - return self.async_abort(reason="unknown_step") + return self.async_show_form( + step_id="user", + data_schema=get_entry_form_schema(), + errors=self.errors, + ) async def async_step_login( self, @@ -112,7 +115,7 @@ async def async_step_login( user_input: The user input data of step "login". Returns: - ConfigFLowResult: The result of the config flow step "login". + ConfigFlowResult: The result of the config flow step "login". """ @@ -136,7 +139,7 @@ async def async_step_api_key( user_input: The user input data of step "api_key". Returns: - ConfigFLowResult: The result of the config flow step "api_key". + ConfigFlowResult: The result of the config flow step "api_key". """ diff --git a/custom_components/diveracontrol/coordinator.py b/custom_components/diveracontrol/coordinator.py index f901d9a..4d7650c 100644 --- a/custom_components/diveracontrol/coordinator.py +++ b/custom_components/diveracontrol/coordinator.py @@ -83,7 +83,8 @@ async def _async_update_data(self) -> dict[str, Any]: try: # read data from Divera API - new_cluster_data = await update_data(self.api, self.data) + raw_ucr_data = await self.api.get_ucr_data() + new_cluster_data = await update_data(self.api, raw_ucr_data, self.data) # dynamically change update interval self.update_interval = set_update_interval( diff --git a/custom_components/diveracontrol/device_action.py b/custom_components/diveracontrol/device_action.py index 81630dd..b93ed9c 100644 --- a/custom_components/diveracontrol/device_action.py +++ b/custom_components/diveracontrol/device_action.py @@ -18,9 +18,9 @@ NumberSelectorMode, SelectOptionDict, ) +from homeassistant.exceptions import HomeAssistantError from .const import ( - D_UCR_ID, DOMAIN, PERM_ALARM, PERM_MANAGEMENT, @@ -28,7 +28,7 @@ PERM_NEWS, PERM_STATUS_VEHICLE, ) -from .utils import get_coordinator_key_from_device, get_translation, permission_check +from .utils import get_coordinator_key_from_device, get_translation ACTION_TYPES: tuple[str, ...] = ( "post_vehicle_status", @@ -186,19 +186,17 @@ async def async_get_actions( if not device or not device.config_entries: return [] - action_types: list[str] = [] - - # check action type permissions - entry_id = next(iter(device.config_entries)) - config_entry = hass.config_entries.async_get_entry(entry_id) - if not config_entry: + try: + api = get_coordinator_key_from_device(hass, device_id, "api") + except HomeAssistantError: return [] - ucr_id: str | None = config_entry.data.get(D_UCR_ID) - if not ucr_id: - return [] + permissions = api.permissions - if permission_check(hass, ucr_id, PERM_MANAGEMENT): + action_types: list[str] = [] + + try: + permissions.check(PERM_MANAGEMENT) action_types = [ "post_vehicle_status", "post_alarm", @@ -209,9 +207,9 @@ async def async_get_actions( "post_using_vehicle_crew", "post_news", ] - - else: - if permission_check(hass, ucr_id, PERM_STATUS_VEHICLE): + except HomeAssistantError: + try: + permissions.check(PERM_STATUS_VEHICLE) action_types.extend( [ "post_vehicle_status", @@ -219,12 +217,23 @@ async def async_get_actions( "post_using_vehicle_crew", ] ) - if permission_check(hass, ucr_id, PERM_ALARM): + except HomeAssistantError: + pass + try: + permissions.check(PERM_ALARM) action_types.extend(["post_alarm", "put_alarm", "post_close_alarm"]) - if permission_check(hass, ucr_id, PERM_MESSAGES): + except HomeAssistantError: + pass + try: + permissions.check(PERM_MESSAGES) action_types.append("post_message") - if permission_check(hass, ucr_id, PERM_NEWS): + except HomeAssistantError: + pass + try: + permissions.check(PERM_NEWS) action_types.append("post_news") + except HomeAssistantError: + pass return [ {"domain": DOMAIN, "type": action_type, "device_id": device_id} diff --git a/custom_components/diveracontrol/divera_api.py b/custom_components/diveracontrol/divera_api.py index 4443038..8aab6ca 100644 --- a/custom_components/diveracontrol/divera_api.py +++ b/custom_components/diveracontrol/divera_api.py @@ -23,7 +23,6 @@ API_USING_VEHICLE_CREW, API_USING_VEHICLE_PROP, API_USING_VEHICLE_SET_SINGLE, - BASE_API_URL, BASE_API_V2_URL, D_UCR, PERM_ALARM, @@ -31,7 +30,7 @@ PERM_NEWS, PERM_STATUS_VEHICLE, ) -from .utils import permission_check +from .divera_permissions import DiveraPermissions _LOGGER = logging.getLogger(__name__) @@ -62,6 +61,8 @@ def __init__( self.ucr_id = ucr_id self.hass = hass self.base_url = base_url + self.permissions = DiveraPermissions() + self.permissions.ucr_id = self.ucr_id self.session = async_get_clientsession(hass) @@ -161,7 +162,8 @@ async def api_request( ) from err except ClientError as err: - url = self._redact_url(err.url.path_qs) + request_url = getattr(getattr(err, "request_info", None), "real_url", None) + url = self._redact_url(str(request_url)) if request_url else "unknown" raise HomeAssistantError( f"Failed to connect to Divera API at URL: {url}" ) from err @@ -171,7 +173,7 @@ async def close(self) -> None: async def get_ucr_data( self, - ) -> dict[str, str]: + ) -> dict[str, Any]: """GET all data for user cluster relation from the Divera API. No permission check. Args: @@ -184,7 +186,12 @@ async def get_ucr_data( _LOGGER.debug("Fetching all data for cluster %s", self.ucr_id) part_url = f"{BASE_API_V2_URL}{API_PULL_ALL}" method = "GET" - return await self.api_request(part_url, method) + data = await self.api_request(part_url, method) + + if data.get("success", False): + self.permissions.replace_permissions_from_ucr_data(data) + + return data async def post_vehicle_status( self, @@ -200,7 +207,7 @@ async def post_vehicle_status( """ _LOGGER.debug("Posting vehicle status and data for cluster %s", self.ucr_id) - permission_check(self.hass, self.ucr_id, PERM_STATUS_VEHICLE) + self.permissions.check(PERM_STATUS_VEHICLE) part_url = f"{BASE_API_V2_URL}{API_USING_VEHICLE_SET_SINGLE}/{vehicle_id}" method = "POST" @@ -219,7 +226,7 @@ async def post_alarms( """ _LOGGER.debug("Posting alarms for unit %s", self.ucr_id) - permission_check(self.hass, self.ucr_id, PERM_ALARM) + self.permissions.check(PERM_ALARM) part_url = f"{BASE_API_V2_URL}{API_ALARM}" method = "POST" @@ -242,7 +249,7 @@ async def put_alarms( "Putting changes to alarm %s for cluster %s", alarm_id, self.ucr_id ) - permission_check(self.hass, self.ucr_id, PERM_ALARM) + self.permissions.check(PERM_ALARM) part_url = f"{BASE_API_V2_URL}{API_ALARM}/{alarm_id}" method = "PUT" @@ -264,7 +271,7 @@ async def post_close_alarm( _LOGGER.debug("Posting to close alarm %s for cluster %s", alarm_id, self.ucr_id) - permission_check(self.hass, self.ucr_id, PERM_ALARM) + self.permissions.check(PERM_ALARM) part_url = f"{BASE_API_V2_URL}{API_ALARM}/close/{alarm_id}" method = "POST" @@ -283,7 +290,7 @@ async def post_message( """ _LOGGER.debug("Posting message for cluster %s", self.ucr_id) - permission_check(self.hass, self.ucr_id, PERM_MESSAGES) + self.permissions.check(PERM_MESSAGES) part_url = f"{BASE_API_V2_URL}{API_MESSAGES}" method = "POST" @@ -307,7 +314,7 @@ async def get_vehicle_property( "Getting individual vehicle properties for vehicle id %s", vehicle_id ) - permission_check(self.hass, self.ucr_id, PERM_STATUS_VEHICLE) + self.permissions.check(PERM_STATUS_VEHICLE) part_url = f"{BASE_API_V2_URL}{API_USING_VEHICLE_PROP}/get/{vehicle_id}" method = "GET" @@ -330,7 +337,7 @@ async def post_using_vehicle_property( "Posting individual vehicle properties for cluster %s", self.ucr_id ) - permission_check(self.hass, self.ucr_id, PERM_STATUS_VEHICLE) + self.permissions.check(PERM_STATUS_VEHICLE) part_url = f"{BASE_API_V2_URL}{API_USING_VEHICLE_PROP}/set/{vehicle_id}" method = "POST" @@ -365,7 +372,7 @@ async def post_using_vehicle_crew( self.ucr_id, ) - permission_check(self.hass, self.ucr_id, PERM_STATUS_VEHICLE) + self.permissions.check(PERM_STATUS_VEHICLE) part_url = f"{BASE_API_V2_URL}{API_USING_VEHICLE_CREW}/{mode}/{vehicle_id}" if mode in {"add", "remove"}: @@ -394,7 +401,7 @@ async def post_news( self.ucr_id, ) - permission_check(self.hass, self.ucr_id, PERM_NEWS) + self.permissions.check(PERM_NEWS) part_url = f"{BASE_API_V2_URL}{API_NEWS}" method = "POST" diff --git a/custom_components/diveracontrol/divera_data.py b/custom_components/diveracontrol/divera_data.py index 003ba1e..c783d83 100644 --- a/custom_components/diveracontrol/divera_data.py +++ b/custom_components/diveracontrol/divera_data.py @@ -3,7 +3,6 @@ import logging from typing import Any -from aiohttp import ClientError from homeassistant.exceptions import HomeAssistantError from .const import ( @@ -58,22 +57,30 @@ def _convert_empty_lists_to_dicts(data: dict[str, Any]) -> dict[str, Any]: return result -async def update_data(api: DiveraAPI, cluster_data: dict[str, Any]) -> dict[str, Any]: - """Update operational data from the Divera API. +async def update_data( + api: DiveraAPI, + raw_ucr_data: dict[str, Any], + cluster_data: dict[str, Any], +) -> dict[str, Any]: + """Update operational data using a pre-fetched Divera API payload. - This method fetches all short live data from the Divera API and updates - the given data dictionary accordingly. + This coroutine takes the already fetched short-lived data (e.g. from the + Divera ``pull/all`` endpoint) provided via ``raw_ucr_data`` and merges it + into the given ``cluster_data`` dictionary. It does not perform any network + requests itself; the network call must be done outside this function. Args: - api (DiveraAPI): The API instance used to communicate with Divera. - cluster_data (dict): A dictionary to store and update Divera operational data. + api (DiveraAPI): The API instance associated with this data (no I/O here). + raw_ucr_data (dict): Raw response payload from the Divera ``pull/all`` endpoint. + cluster_data (dict): Existing Divera operational data structure to initialize + (if empty) and update with values from ``raw_ucr_data``. - Exceptions: - Logs errors for network issues, invalid data, or missing keys in API responses. - Sets alarm and vehicle data to empty if any issues occur. + Logging: + Logs errors for invalid data or missing keys in the provided payload. + Sets alarm and vehicle data to empty if any issues occur while updating. Returns: - cluster_data (dict): The updated data dictionary with the latest Divera information. + dict: The updated ``cluster_data`` dictionary with the latest Divera information. """ @@ -98,19 +105,11 @@ async def update_data(api: DiveraAPI, cluster_data: dict[str, Any]) -> dict[str, D_STATUSPLAN: {}, } - # request divera data - try: - raw_ucr_data = await api.get_ucr_data() - - if not raw_ucr_data.get("success", False): - _LOGGER.error( - "Unexpected data format or API request failed: %s", - raw_ucr_data, - ) - return cluster_data - - except (ClientError, ValueError, KeyError) as e: - _LOGGER.error("Error in data request: %s", e) + if not raw_ucr_data.get("success", False): + _LOGGER.error( + "Unexpected data format or API request failed: %s", + raw_ucr_data, + ) return cluster_data # set local data @@ -150,10 +149,7 @@ async def update_data(api: DiveraAPI, cluster_data: dict[str, Any]) -> dict[str, for key in raw_cluster.get(D_VEHICLE, {}): try: raw_vehicle_property = await api.get_vehicle_property(key) - except HomeAssistantError as e: - _LOGGER.error( - "Error fetching vehicle property for vehicle id '%s': %s", key, e - ) + except HomeAssistantError: continue if raw_vehicle_property: diff --git a/custom_components/diveracontrol/divera_permissions.py b/custom_components/diveracontrol/divera_permissions.py new file mode 100644 index 0000000..f535b0e --- /dev/null +++ b/custom_components/diveracontrol/divera_permissions.py @@ -0,0 +1,85 @@ +"""Divera permission handler.""" + +import logging +from typing import Any + +from homeassistant.exceptions import HomeAssistantError + +from .const import D_ACCESS, D_DATA, D_USER, PERM_MANAGEMENT + +_LOGGER = logging.getLogger(__name__) + + +class DiveraPermissions: + """Handles permissions for Divera API.""" + + def __init__(self) -> None: + """Initialize DiveraPermissions.""" + self.permissions: dict[str, bool] = {} + self.ucr_id: str | None = None + + def replace_permissions_from_ucr_data(self, raw_ucr_data: dict[str, Any]) -> None: + """Replace permission cache using current pull/all response data.""" + raw_access = raw_ucr_data.get(D_DATA, {}).get(D_USER, {}).get(D_ACCESS, {}) + + if not isinstance(raw_access, dict): + self.permissions = {} + _LOGGER.debug( + "Permission cache reset for cluster %s, access data missing or invalid", + self.ucr_id, + ) + return + + self.permissions = { + key: bool(value) + for key, value in raw_access.items() + if isinstance(key, str) + } + _LOGGER.debug( + "Permission cache refreshed for cluster %s with %s entries", + self.ucr_id, + len(self.permissions), + ) + + def check(self, perm_key: str) -> None: + """Raise when a permission is not granted by the current permission cache. + + This is used when we need to fail-fast if a permission is missing. + Logging and error raising are handled directly by this method. + + Args: + perm_key: The permission key to check for. + + Raises: + HomeAssistantError: When permission is denied. + + """ + + if not self.permissions: + _LOGGER.debug( + "No permission data available yet for cluster %s, permission '%s' denied", + self.ucr_id, + perm_key, + ) + raise HomeAssistantError( + f"No permission data available yet for cluster '{self.ucr_id}'" + ) + + if self.permissions.get(PERM_MANAGEMENT): + _LOGGER.debug( + "Management permission granted for cluster %s, bypassing specific permission check for '%s'", + self.ucr_id, + perm_key, + ) + return + + has_perm = self.permissions.get(perm_key, False) + if not has_perm: + _LOGGER.debug( + "Permission '%s' denied for cluster %s", + perm_key, + self.ucr_id, + ) + raise HomeAssistantError( + f"Permission '{perm_key}' denied for cluster '{self.ucr_id}'" + ) diff --git a/custom_components/diveracontrol/manifest.json b/custom_components/diveracontrol/manifest.json index 6cd9b47..e37dce9 100644 --- a/custom_components/diveracontrol/manifest.json +++ b/custom_components/diveracontrol/manifest.json @@ -9,5 +9,5 @@ "iot_class": "cloud_polling", "issue_tracker": "https://github.com/moehrem/DiveraControl/issues", "requirements": [], - "version": "1.4.0" + "version": "1.4.1" } diff --git a/custom_components/diveracontrol/service.py b/custom_components/diveracontrol/service.py index a10dc93..cd5950e 100644 --- a/custom_components/diveracontrol/service.py +++ b/custom_components/diveracontrol/service.py @@ -339,7 +339,7 @@ async def handle_post_vehicle_status( {"vehicle": vehicle, "err": str(err)}, ) _LOGGER.error(error_msg) - continue + raise async def handle_post_alarm( @@ -386,6 +386,7 @@ async def handle_post_alarm( {"title": data.get("title"), "err": str(err)}, ) _LOGGER.error(error_msg) + raise async def handle_put_alarm( @@ -429,6 +430,7 @@ async def handle_put_alarm( {"alarm_id": alarm_id, "err": str(err)}, ) _LOGGER.error(error_msg) + raise async def handle_post_close_alarm( @@ -472,7 +474,7 @@ async def handle_post_close_alarm( {"alarm_id": alarm_id, "err": str(err)}, ) _LOGGER.error(error_msg) - return + raise async def handle_post_message( @@ -547,7 +549,7 @@ async def handle_post_message( {"message_channel_id": message_channel_id, "err": str(err)}, ) _LOGGER.error(error_msg) - return + raise async def handle_post_using_vehicle_property( @@ -602,7 +604,7 @@ async def handle_post_using_vehicle_property( {"vehicle": vehicle, "err": str(err)}, ) _LOGGER.error(error_msg) - continue + raise async def handle_post_using_vehicle_crew( @@ -667,7 +669,7 @@ async def handle_post_using_vehicle_crew( {"vehicle": vehicle, "err": str(err)}, ) _LOGGER.error(error_msg) - continue + raise async def handle_post_news( @@ -722,6 +724,7 @@ async def handle_post_news( {"title": title, "err": str(err)}, ) _LOGGER.error(error_msg) + raise def async_register_services(hass: HomeAssistant, domain: str) -> None: diff --git a/custom_components/diveracontrol/utils.py b/custom_components/diveracontrol/utils.py index 856dabd..ff0633e 100644 --- a/custom_components/diveracontrol/utils.py +++ b/custom_components/diveracontrol/utils.py @@ -12,7 +12,6 @@ from .const import ( BASE_API_URL, - D_ACCESS, D_ALARM, D_CLUSTER, D_CLUSTER_NAME, @@ -20,61 +19,17 @@ D_OPEN_ALARMS, D_UPDATE_INTERVAL_ALARM, D_UPDATE_INTERVAL_DATA, - D_USER, D_VEHICLE, DOMAIN, MANUFACTURER, MINOR_VERSION, PATCH_VERSION, - PERM_MANAGEMENT, VERSION, ) _LOGGER = logging.getLogger(__name__) -def permission_check( - hass: HomeAssistant, - ucr_id: str, - perm_key: str, -) -> bool: - """Check permission and return success. - - Args: - hass: HomeAssistant instance. - ucr_id: User cluster relation ID. - perm_key: Permission key to check. - - Returns: - True if permission granted, False if denied. - """ - coordinator = hass.data.get(DOMAIN, {}).get(ucr_id, {}).get(D_COORDINATOR) - if not coordinator: - raise HomeAssistantError("No permission data available yet, permission denied") - - cluster_data = coordinator.data - - if cluster_data is None: - raise HomeAssistantError("No permission data available yet, permission denied") - - user_access = cluster_data.get(D_USER, {}).get(D_ACCESS, {}) - - # Management permission grants all access - if user_access.get(PERM_MANAGEMENT): - _LOGGER.debug( - "Management permission granted for cluster '%s'", - coordinator.cluster_name, - ) - return True - - if not user_access.get(perm_key): - raise HomeAssistantError( - f"Permission '{perm_key}' denied for cluster '{coordinator.cluster_name}'" - ) - - return False - - def get_device_info(cluster_name: str) -> DeviceInfo: """Return device information, used in sensor and tracker base classes.""" return { diff --git a/tests/test_coordinator.py b/tests/test_coordinator.py index 40e2bba..e84754e 100644 --- a/tests/test_coordinator.py +++ b/tests/test_coordinator.py @@ -24,9 +24,9 @@ async def test_coordinator_update_failed( This test covers lines 91-92 (the except Exception block). """ - # Create a mock API that raises an exception + # Create a mock API that returns a valid raw payload mock_api = AsyncMock(spec=DiveraAPI) - mock_api.get_ucr_data = AsyncMock(side_effect=Exception("API connection failed")) + mock_api.get_ucr_data = AsyncMock(return_value={"success": True, "data": {}}) # Create coordinator with the failing API coordinator = DiveraCoordinator( diff --git a/tests/test_device_action.py b/tests/test_device_action.py index 5ab655b..66df012 100644 --- a/tests/test_device_action.py +++ b/tests/test_device_action.py @@ -7,6 +7,7 @@ from homeassistant.components.device_automation.exceptions import ( InvalidDeviceAutomationConfig, ) +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import device_registry as dr from custom_components.diveracontrol.const import ( @@ -348,12 +349,18 @@ async def test_dynamic_options_default_label_format( class TestAsyncGetActions: """Test async_get_actions function.""" - @patch("custom_components.diveracontrol.device_action.permission_check") + @patch( + "custom_components.diveracontrol.device_action.get_coordinator_key_from_device" + ) async def test_get_actions_with_management_permission( - self, mock_permission, hass, mock_device, mock_config_entry + self, mock_get_coordinator, hass, mock_device, mock_config_entry ): """Test getting actions with management permission.""" - mock_permission.return_value = True + mock_permissions = MagicMock() + mock_permissions.check.side_effect = lambda perm: None + mock_api = MagicMock() + mock_api.permissions = mock_permissions + mock_get_coordinator.return_value = mock_api actions = await async_get_actions(hass, mock_device.id) @@ -361,22 +368,28 @@ async def test_get_actions_with_management_permission( assert all(action["domain"] == DOMAIN for action in actions) assert all(action["device_id"] == mock_device.id for action in actions) - @patch("custom_components.diveracontrol.device_action.permission_check") + @patch( + "custom_components.diveracontrol.device_action.get_coordinator_key_from_device" + ) async def test_get_actions_with_specific_permissions( - self, mock_permission, hass, mock_device, mock_config_entry + self, mock_get_coordinator, hass, mock_device, mock_config_entry ): """Test getting actions with specific permissions.""" - def permission_side_effect(hass, ucr_id, perm): + def permission_side_effect(perm): if perm == PERM_MANAGEMENT: - return False + raise HomeAssistantError("Denied") if perm == PERM_STATUS_VEHICLE: - return True + return None if perm == PERM_ALARM: - return True - return False + return None + raise HomeAssistantError("Denied") - mock_permission.side_effect = permission_side_effect + mock_permissions = MagicMock() + mock_permissions.check.side_effect = permission_side_effect + mock_api = MagicMock() + mock_api.permissions = mock_permissions + mock_get_coordinator.return_value = mock_api actions = await async_get_actions(hass, mock_device.id) @@ -385,12 +398,18 @@ def permission_side_effect(hass, ucr_id, perm): assert "post_alarm" in action_types assert "post_message" not in action_types # No PERM_MESSAGES - @patch("custom_components.diveracontrol.device_action.permission_check") + @patch( + "custom_components.diveracontrol.device_action.get_coordinator_key_from_device" + ) async def test_get_actions_no_permissions( - self, mock_permission, hass, mock_device, mock_config_entry + self, mock_get_coordinator, hass, mock_device, mock_config_entry ): """Test getting actions with no permissions.""" - mock_permission.return_value = False + mock_permissions = MagicMock() + mock_permissions.check.side_effect = HomeAssistantError("Denied") + mock_api = MagicMock() + mock_api.permissions = mock_permissions + mock_get_coordinator.return_value = mock_api actions = await async_get_actions(hass, mock_device.id) diff --git a/tests/test_divera_api.py b/tests/test_divera_api.py index 9b77c97..6129307 100644 --- a/tests/test_divera_api.py +++ b/tests/test_divera_api.py @@ -250,16 +250,13 @@ async def test_post_vehicle_status( vehicle_id = 789 payload = {"status_id": 3, "fms_real": 1} - # Mock permission check to pass with ( - patch( - "custom_components.diveracontrol.divera_api.permission_check" - ) as mock_perm, + patch.object(api_client.permissions, "check") as mock_perm, patch.object(api_client, "api_request") as mock_request, ): await api_client.post_vehicle_status(vehicle_id, payload) - mock_perm.assert_called_once() + mock_perm.assert_called_once_with("status_vehicle") mock_request.assert_called_once() call_args = mock_request.call_args assert API_USING_VEHICLE_SET_SINGLE in call_args[0][0] @@ -274,14 +271,12 @@ async def test_post_alarms( payload = {"title": "Test Alarm", "priority": 1} with ( - patch( - "custom_components.diveracontrol.divera_api.permission_check" - ) as mock_perm, + patch.object(api_client.permissions, "check") as mock_perm, patch.object(api_client, "api_request") as mock_request, ): await api_client.post_alarms(payload) - mock_perm.assert_called_once() + mock_perm.assert_called_once_with("alarm") mock_request.assert_called_once() call_args = mock_request.call_args assert API_ALARM in call_args[0][0] @@ -294,14 +289,12 @@ async def test_put_alarms(self, hass: HomeAssistant, api_client: DiveraAPI) -> N payload = {"title": "Updated Alarm"} with ( - patch( - "custom_components.diveracontrol.divera_api.permission_check" - ) as mock_perm, + patch.object(api_client.permissions, "check") as mock_perm, patch.object(api_client, "api_request") as mock_request, ): await api_client.put_alarms(alarm_id, payload) - mock_perm.assert_called_once() + mock_perm.assert_called_once_with("alarm") mock_request.assert_called_once() call_args = mock_request.call_args assert API_ALARM in call_args[0][0] @@ -317,14 +310,12 @@ async def test_post_close_alarm( payload = {"text": "Closing alarm"} with ( - patch( - "custom_components.diveracontrol.divera_api.permission_check" - ) as mock_perm, + patch.object(api_client.permissions, "check") as mock_perm, patch.object(api_client, "api_request") as mock_request, ): await api_client.post_close_alarm(payload, alarm_id) - mock_perm.assert_called_once() + mock_perm.assert_called_once_with("alarm") mock_request.assert_called_once() call_args = mock_request.call_args assert API_ALARM in call_args[0][0] @@ -340,14 +331,12 @@ async def test_post_message( payload = {"title": "Test Message", "text": "Message content"} with ( - patch( - "custom_components.diveracontrol.divera_api.permission_check" - ) as mock_perm, + patch.object(api_client.permissions, "check") as mock_perm, patch.object(api_client, "api_request") as mock_request, ): await api_client.post_message(payload) - mock_perm.assert_called_once() + mock_perm.assert_called_once_with("messages") mock_request.assert_called_once() call_args = mock_request.call_args assert API_MESSAGES in call_args[0][0] @@ -362,9 +351,7 @@ async def test_get_vehicle_property( expected_data = {"properties": {"water": 1000}} with ( - patch( - "custom_components.diveracontrol.divera_api.permission_check" - ) as mock_perm, + patch.object(api_client.permissions, "check") as mock_perm, patch.object( api_client, "api_request", return_value=expected_data ) as mock_request, @@ -372,7 +359,7 @@ async def test_get_vehicle_property( result = await api_client.get_vehicle_property(vehicle_id) assert result == expected_data - mock_perm.assert_called_once() + mock_perm.assert_called_once_with("status_vehicle") mock_request.assert_called_once() call_args = mock_request.call_args assert API_USING_VEHICLE_PROP in call_args[0][0] @@ -388,14 +375,12 @@ async def test_post_using_vehicle_property( payload = {"water": 800} with ( - patch( - "custom_components.diveracontrol.divera_api.permission_check" - ) as mock_perm, + patch.object(api_client.permissions, "check") as mock_perm, patch.object(api_client, "api_request") as mock_request, ): await api_client.post_using_vehicle_property(vehicle_id, payload) - mock_perm.assert_called_once() + mock_perm.assert_called_once_with("status_vehicle") mock_request.assert_called_once() call_args = mock_request.call_args assert API_USING_VEHICLE_PROP in call_args[0][0] @@ -412,14 +397,12 @@ async def test_post_using_vehicle_crew_add( payload = {"user_ids": [1, 2, 3]} with ( - patch( - "custom_components.diveracontrol.divera_api.permission_check" - ) as mock_perm, + patch.object(api_client.permissions, "check") as mock_perm, patch.object(api_client, "api_request") as mock_request, ): await api_client.post_using_vehicle_crew(vehicle_id, "add", payload) - mock_perm.assert_called_once() + mock_perm.assert_called_once_with("status_vehicle") mock_request.assert_called_once() call_args = mock_request.call_args assert API_USING_VEHICLE_CREW in call_args[0][0] @@ -436,14 +419,12 @@ async def test_post_using_vehicle_crew_remove( payload = {"user_ids": [1]} with ( - patch( - "custom_components.diveracontrol.divera_api.permission_check" - ) as mock_perm, + patch.object(api_client.permissions, "check") as mock_perm, patch.object(api_client, "api_request") as mock_request, ): await api_client.post_using_vehicle_crew(vehicle_id, "remove", payload) - mock_perm.assert_called_once() + mock_perm.assert_called_once_with("status_vehicle") mock_request.assert_called_once() call_args = mock_request.call_args assert "remove" in call_args[0][0] @@ -457,14 +438,12 @@ async def test_post_using_vehicle_crew_reset( payload = {} with ( - patch( - "custom_components.diveracontrol.divera_api.permission_check" - ) as mock_perm, + patch.object(api_client.permissions, "check") as mock_perm, patch.object(api_client, "api_request") as mock_request, ): await api_client.post_using_vehicle_crew(vehicle_id, "reset", payload) - mock_perm.assert_called_once() + mock_perm.assert_called_once_with("status_vehicle") mock_request.assert_called_once() call_args = mock_request.call_args assert "reset" in call_args[0][0] @@ -477,7 +456,7 @@ async def test_post_using_vehicle_crew_invalid_mode( vehicle_id = 789 payload = {} - with patch("custom_components.diveracontrol.divera_api.permission_check"): + with patch.object(api_client.permissions, "check"): with pytest.raises(HomeAssistantError) as exc_info: await api_client.post_using_vehicle_crew( vehicle_id, "invalid_mode", payload @@ -491,14 +470,12 @@ async def test_post_news(self, hass: HomeAssistant, api_client: DiveraAPI) -> No payload = {"title": "Important News", "text": "News content"} with ( - patch( - "custom_components.diveracontrol.divera_api.permission_check" - ) as mock_perm, + patch.object(api_client.permissions, "check") as mock_perm, patch.object(api_client, "api_request") as mock_request, ): await api_client.post_news(payload) - mock_perm.assert_called_once() + mock_perm.assert_called_once_with("news") mock_request.assert_called_once() call_args = mock_request.call_args assert API_NEWS in call_args[0][0] diff --git a/tests/test_divera_data.py b/tests/test_divera_data.py index ba02214..a7721fb 100644 --- a/tests/test_divera_data.py +++ b/tests/test_divera_data.py @@ -3,7 +3,6 @@ from unittest.mock import AsyncMock, MagicMock, patch import pytest -from aiohttp import ClientError from homeassistant.exceptions import HomeAssistantError from custom_components.diveracontrol.const import ( @@ -117,7 +116,6 @@ class TestUpdateData: def mock_api(self) -> MagicMock: """Create a mock API instance.""" api = MagicMock() - api.get_ucr_data = AsyncMock() api.get_vehicle_property = AsyncMock() return api @@ -132,7 +130,7 @@ async def test_update_data_initializes_empty_cluster_data( """Test that update_data initializes empty cluster_data correctly.""" cluster_data = {} - mock_api.get_ucr_data.return_value = { + raw_ucr_data = { "success": True, D_DATA: { D_UCR: {"ucr_data": "test"}, @@ -141,7 +139,7 @@ async def test_update_data_initializes_empty_cluster_data( }, } - result = await update_data(mock_api, cluster_data) + result = await update_data(mock_api, raw_ucr_data, cluster_data) # Check that all required keys are initialized required_keys = [ @@ -170,7 +168,7 @@ async def test_update_data_successful_update(self, mock_api: MagicMock) -> None: """Test successful data update from API.""" cluster_data = {} - mock_api.get_ucr_data.return_value = { + raw_ucr_data = { "success": True, D_DATA: { D_UCR: {"ucr_id": "12345"}, @@ -198,7 +196,7 @@ async def test_update_data_successful_update(self, mock_api: MagicMock) -> None: {D_DATA: {"property2": "value2"}}, ] - result = await update_data(mock_api, cluster_data) + result = await update_data(mock_api, raw_ucr_data, cluster_data) # Check that data was updated assert result[D_UCR] == {"ucr_id": "12345"} @@ -220,21 +218,21 @@ async def test_update_data_api_failure_no_success( """Test handling of API response without success flag.""" cluster_data = {D_UCR: {"existing": "data"}} - mock_api.get_ucr_data.return_value = {"success": False, "error": "API Error"} + raw_ucr_data = {"success": False, "error": "API Error"} - result = await update_data(mock_api, cluster_data) + result = await update_data(mock_api, raw_ucr_data, cluster_data) # Should return unchanged cluster_data assert result == cluster_data mock_api.get_vehicle_property.assert_not_called() - async def test_update_data_api_exception(self, mock_api: MagicMock) -> None: - """Test handling of API exceptions.""" + async def test_update_data_invalid_payload(self, mock_api: MagicMock) -> None: + """Test handling of invalid raw payloads.""" cluster_data = {D_UCR: {"existing": "data"}} - mock_api.get_ucr_data.side_effect = ClientError("Network error") + raw_ucr_data = {} - result = await update_data(mock_api, cluster_data) + result = await update_data(mock_api, raw_ucr_data, cluster_data) # Should return unchanged cluster_data assert result == cluster_data @@ -246,7 +244,7 @@ async def test_update_data_converts_empty_lists_to_dicts( """Test that empty lists in API response are converted to dicts.""" cluster_data = {} - mock_api.get_ucr_data.return_value = { + raw_ucr_data = { "success": True, D_DATA: { D_USER: [], # Empty list should become empty dict @@ -257,7 +255,7 @@ async def test_update_data_converts_empty_lists_to_dicts( }, } - result = await update_data(mock_api, cluster_data) + result = await update_data(mock_api, raw_ucr_data, cluster_data) assert result[D_USER] == {} assert result[D_STATUS] == {"status": "active"} @@ -269,7 +267,7 @@ async def test_update_data_handles_missing_data_keys( """Test handling of missing keys in API response.""" cluster_data = {} - mock_api.get_ucr_data.return_value = { + raw_ucr_data = { "success": True, D_DATA: { D_UCR: {"ucr_id": "12345"} @@ -277,7 +275,7 @@ async def test_update_data_handles_missing_data_keys( }, } - result = await update_data(mock_api, cluster_data) + result = await update_data(mock_api, raw_ucr_data, cluster_data) # Existing key should be updated assert result[D_UCR] == {"ucr_id": "12345"} @@ -292,7 +290,7 @@ async def test_update_data_vehicle_property_error( """Test handling of vehicle property fetch errors.""" cluster_data = {} - mock_api.get_ucr_data.return_value = { + raw_ucr_data = { "success": True, D_DATA: { D_CLUSTER: { @@ -310,7 +308,7 @@ async def test_update_data_vehicle_property_error( HomeAssistantError("Property fetch failed"), ] - result = await update_data(mock_api, cluster_data) + result = await update_data(mock_api, raw_ucr_data, cluster_data) # First vehicle should have properties assert result[D_CLUSTER][D_VEHICLE]["vehicle1"]["property1"] == "value1" @@ -325,7 +323,7 @@ async def test_update_data_vehicle_property_unexpected_format( """Test handling of unexpected vehicle property format.""" cluster_data = {} - mock_api.get_ucr_data.return_value = { + raw_ucr_data = { "success": True, D_DATA: {D_CLUSTER: {D_VEHICLE: {"vehicle1": {"name": "Ambulance 1"}}}}, } @@ -333,7 +331,7 @@ async def test_update_data_vehicle_property_unexpected_format( # Return non-dict data mock_api.get_vehicle_property.return_value = {D_DATA: "unexpected_string"} - result = await update_data(mock_api, cluster_data) + result = await update_data(mock_api, raw_ucr_data, cluster_data) # Vehicle should still exist assert "vehicle1" in result[D_CLUSTER][D_VEHICLE] @@ -344,7 +342,7 @@ async def test_update_data_alarm_processing_error( """Test handling of alarm processing errors.""" cluster_data = {} - mock_api.get_ucr_data.return_value = { + raw_ucr_data = { "success": True, D_DATA: { D_ALARM: { @@ -353,7 +351,7 @@ async def test_update_data_alarm_processing_error( }, } - result = await update_data(mock_api, cluster_data) + result = await update_data(mock_api, raw_ucr_data, cluster_data) # Should handle the error gracefully assert D_ALARM in result @@ -362,14 +360,14 @@ async def test_update_data_alarm_no_items(self, mock_api: MagicMock) -> None: """Test alarm processing when no alarm items exist.""" cluster_data = {} - mock_api.get_ucr_data.return_value = { + raw_ucr_data = { "success": True, D_DATA: { D_ALARM: {} # No items key }, } - result = await update_data(mock_api, cluster_data) + result = await update_data(mock_api, raw_ucr_data, cluster_data) # Should set open_alarms to 0 assert result[D_ALARM].get(D_OPEN_ALARMS) == 0 @@ -378,7 +376,7 @@ async def test_update_data_alarm_empty_items(self, mock_api: MagicMock) -> None: """Test alarm processing with empty alarm items.""" cluster_data = {} - mock_api.get_ucr_data.return_value = { + raw_ucr_data = { "success": True, D_DATA: { D_ALARM: { @@ -387,23 +385,23 @@ async def test_update_data_alarm_empty_items(self, mock_api: MagicMock) -> None: }, } - result = await update_data(mock_api, cluster_data) + result = await update_data(mock_api, raw_ucr_data, cluster_data) # Should set open_alarms to 0 assert result[D_ALARM][D_OPEN_ALARMS] == 0 - async def test_update_data_preserves_existing_data_on_error( + async def test_update_data_preserves_existing_data_on_invalid_payload( self, mock_api: MagicMock ) -> None: - """Test that existing data is preserved when API call fails.""" + """Test that existing data is preserved when payload is invalid.""" cluster_data = { D_UCR: {"existing_ucr": "data"}, D_USER: {"existing_user": "data"}, } - mock_api.get_ucr_data.side_effect = ClientError("Network error") + raw_ucr_data = {"success": False} - result = await update_data(mock_api, cluster_data) + result = await update_data(mock_api, raw_ucr_data, cluster_data) # Should return original data unchanged assert result == cluster_data @@ -414,15 +412,15 @@ async def test_update_data_partial_update_with_errors( """Test partial updates when some operations succeed and others fail.""" cluster_data = {} - mock_api.get_ucr_data.return_value = { + raw_ucr_data = { "success": True, D_DATA: {D_UCR: {"ucr_id": "12345"}, D_USER: {"user_id": "67890"}}, } # Vehicle property fetch fails - mock_api.get_vehicle_property.side_effect = ClientError("Property error") + mock_api.get_vehicle_property.side_effect = HomeAssistantError("Property error") - result = await update_data(mock_api, cluster_data) + result = await update_data(mock_api, raw_ucr_data, cluster_data) # Basic data should still be updated assert result[D_UCR] == {"ucr_id": "12345"} diff --git a/tests/test_service.py b/tests/test_service.py index bde04b4..666982e 100644 --- a/tests/test_service.py +++ b/tests/test_service.py @@ -221,8 +221,8 @@ async def test_api_error( ) mock_translation.return_value = "Error message" - # Should not raise, just log - await handle_post_vehicle_status(mock_hass, mock_service_call) + with pytest.raises(HomeAssistantError): + await handle_post_vehicle_status(mock_hass, mock_service_call) mock_translation.assert_called_once() @@ -285,7 +285,8 @@ async def test_api_error( mock_api_instance.post_alarms.side_effect = HomeAssistantError("API Error") mock_translation.return_value = "Error message" - await handle_post_alarm(mock_hass, mock_service_call) + with pytest.raises(HomeAssistantError): + await handle_post_alarm(mock_hass, mock_service_call) mock_translation.assert_called_once() diff --git a/tests/test_utils.py b/tests/test_utils.py index 9a220aa..b9943d7 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -14,6 +14,7 @@ D_CLUSTER, D_CLUSTER_NAME, D_COORDINATOR, + D_DATA, D_OPEN_ALARMS, D_UPDATE_INTERVAL_ALARM, D_UPDATE_INTERVAL_DATA, @@ -26,78 +27,58 @@ MINOR_VERSION, PATCH_VERSION, ) +from custom_components.diveracontrol.divera_permissions import DiveraPermissions from custom_components.diveracontrol.utils import ( get_coordinator_key_from_device, get_device_info, get_translation, handle_entity, - permission_check, set_update_interval, ) -class TestPermissionCheck: - """Test the permission_check function.""" +class TestDiveraPermissions: + """Test DiveraPermissions behavior.""" - def test_permission_check_management_granted(self, hass: HomeAssistant) -> None: + def test_management_granted(self, hass: HomeAssistant) -> None: """Test permission check with management permission granted.""" - mock_coordinator = MagicMock() - mock_coordinator.cluster_name = "Test Cluster" - mock_coordinator.data = { - D_USER: {D_ACCESS: {PERM_MANAGEMENT: True, "some_perm": False}} - } - - hass.data[DOMAIN] = {"123": {D_COORDINATOR: mock_coordinator}} - - result = permission_check(hass, "123", "some_perm") + permissions = DiveraPermissions() + permissions.ucr_id = "123" + permissions.replace_permissions_from_ucr_data( + {D_DATA: {D_USER: {D_ACCESS: {PERM_MANAGEMENT: True, "some_perm": False}}}} + ) - assert result is True + permissions.check("some_perm") - def test_permission_check_specific_granted(self, hass: HomeAssistant) -> None: + def test_specific_granted(self, hass: HomeAssistant) -> None: """Test permission check with specific permission granted.""" - mock_coordinator = MagicMock() - mock_coordinator.cluster_name = "Test Cluster" - mock_coordinator.data = { - D_USER: {D_ACCESS: {PERM_MANAGEMENT: False, "test_perm": True}} - } - - hass.data[DOMAIN] = {"123": {D_COORDINATOR: mock_coordinator}} + permissions = DiveraPermissions() + permissions.ucr_id = "123" + permissions.replace_permissions_from_ucr_data( + {D_DATA: {D_USER: {D_ACCESS: {PERM_MANAGEMENT: False, "test_perm": True}}}} + ) - result = permission_check(hass, "123", "test_perm") + permissions.check("test_perm") - assert result is False # Function returns False when permission granted - - def test_permission_check_denied(self, hass: HomeAssistant) -> None: + def test_permission_denied(self, hass: HomeAssistant) -> None: """Test permission check with permission denied.""" - mock_coordinator = MagicMock() - mock_coordinator.cluster_name = "Test Cluster" - mock_coordinator.data = { - D_USER: {D_ACCESS: {PERM_MANAGEMENT: False, "test_perm": False}} - } - - hass.data[DOMAIN] = {"123": {D_COORDINATOR: mock_coordinator}} + permissions = DiveraPermissions() + permissions.ucr_id = "123" + permissions.replace_permissions_from_ucr_data( + {D_DATA: {D_USER: {D_ACCESS: {PERM_MANAGEMENT: False, "test_perm": False}}}} + ) with pytest.raises(HomeAssistantError, match="Permission 'test_perm' denied"): - permission_check(hass, "123", "test_perm") - - def test_permission_check_no_coordinator(self, hass: HomeAssistant) -> None: - """Test permission check with no coordinator available.""" - with pytest.raises( - HomeAssistantError, match="No permission data available yet" - ): - permission_check(hass, "123", "test_perm") + permissions.check("test_perm") - def test_permission_check_no_data(self, hass: HomeAssistant) -> None: - """Test permission check with coordinator but no data.""" - mock_coordinator = MagicMock() - mock_coordinator.data = None - - hass.data[DOMAIN] = {"123": {D_COORDINATOR: mock_coordinator}} + def test_no_permission_data(self, hass: HomeAssistant) -> None: + """Test permission check with no cached permissions.""" + permissions = DiveraPermissions() with pytest.raises( HomeAssistantError, match="No permission data available yet" ): - permission_check(hass, "123", "test_perm") + permissions.check("test_perm") class TestGetDeviceInfo: