Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions .github/workflows/combined.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -33,3 +33,29 @@ jobs:
- uses: hacs/action@main
with:
category: integration

test:
name: Tests (HA ${{ matrix.name }})
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
include:
# Minimum supported version (keep in sync with hacs.json)
- name: "2025.1"
ha-constraint: "homeassistant~=2025.1.0"
# Newest HA supported by pytest-homeassistant-custom-component
- name: "latest"
ha-constraint: "homeassistant"
steps:
- uses: actions/checkout@v6
- uses: astral-sh/setup-uv@v7
with:
python-version: "3.13"
- name: Install with matrix Home Assistant version
run: |
uv venv
# HA pins pre-release dependencies (e.g. aiohasupervisor betas)
uv pip install --prerelease=allow -e . --group dev "${{ matrix.ha-constraint }}"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Constrain Home Assistant to stable releases in CI

In the new test job, this install command combines an unconstrained homeassistant matrix entry with --prerelease=allow. Uv applies that prerelease policy to all requirements, so during Home Assistant beta weeks the latest leg can resolve to a *.b* Home Assistant release instead of the latest stable, making required CI fail on an unsupported pre-release core even when the PR is fine. If prerelease transitive dependencies are needed, constrain the direct HA requirement to stable/pinned versions or avoid globally allowing prereleases for it.

Useful? React with 👍 / 👎.

- name: Run tests
run: uv run --no-sync pytest tests/
3 changes: 2 additions & 1 deletion hacs.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
{
"name": "evnex",
"content_in_root": false,
"render_readme": true
"render_readme": true,
"homeassistant": "2025.1.0"
}
8 changes: 7 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,8 @@ dev = [
"pytest >=8.1.1,<10",
"ruff<1.0.0,>=0.1.4",
"pre-commit>=4.3.0",
"pre-commit-uv>=4.1.4"
"pre-commit-uv>=4.1.4",
"pytest-homeassistant-custom-component>=0.13.100",
]

[build-system]
Expand All @@ -31,3 +32,8 @@ explicit_package_bases = true
namespace_packages = true
mypy_path = "."


[tool.pytest.ini_options]
asyncio_mode = "auto"
asyncio_default_fixture_loop_scope = "function"
testpaths = ["tests"]
Empty file added tests/__init__.py
Empty file.
55 changes: 55 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
"""Shared fixtures for evnex integration tests."""

from unittest.mock import AsyncMock, MagicMock, patch

import pytest
from pycognito.exceptions import SoftwareTokenMFAChallengeException

pytest_plugins = "pytest_homeassistant_custom_component"

USER_ID = "4c8aea03-56bf-47e7-9e7a-383a00793420"


@pytest.fixture(autouse=True)
def auto_enable_custom_integrations(enable_custom_integrations):
yield


def make_user_detail(name="Test User"):
user = MagicMock()
user.id = USER_ID
user.name = name
user.email = "user@example.com"
user.organisations = []
return user


@pytest.fixture
def mock_evnex_client():
"""A mocked Evnex client that authenticates without MFA."""
client = MagicMock()
client.authenticate = MagicMock()
client.respond_to_mfa_challenge = MagicMock()
client.get_user_detail = AsyncMock(return_value=make_user_detail())
client.org_id = "org-1"
client.id_token = "id-0"
client.access_token = "access-0"
client.refresh_token = "refresh-0"
return client


@pytest.fixture
def mock_evnex(mock_evnex_client):
"""Patch the Evnex class used by the config flow."""
with patch(
"custom_components.evnex.config_flow.Evnex",
return_value=mock_evnex_client,
):
yield mock_evnex_client


def mfa_challenge():
return SoftwareTokenMFAChallengeException(
"Do Software Token MFA",
{"ChallengeName": "SOFTWARE_TOKEN_MFA", "Session": "opaque-session"},
)
152 changes: 152 additions & 0 deletions tests/test_config_flow.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
"""Tests for the evnex config flow, including MFA and reauthentication."""

from homeassistant import config_entries
from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResultType
from pytest_homeassistant_custom_component.common import MockConfigEntry

from custom_components.evnex.const import DOMAIN

from .conftest import USER_ID, mfa_challenge

CREDS = {"username": "user@example.com", "password": "hunter2"}


async def start_user_flow(hass: HomeAssistant):
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
assert result["type"] == FlowResultType.FORM
assert result["step_id"] == "user"
return result


async def test_user_flow_without_mfa(hass: HomeAssistant, mock_evnex) -> None:
result = await start_user_flow(hass)
result = await hass.config_entries.flow.async_configure(result["flow_id"], CREDS)

assert result["type"] == FlowResultType.CREATE_ENTRY
assert result["title"] == "Test User"
assert result["data"]["access_token"] == "access-0"
assert result["data"]["refresh_token"] == "refresh-0"
assert result["data"]["user_id"] == USER_ID
mock_evnex.authenticate.assert_called_once()


async def test_user_flow_with_mfa(hass: HomeAssistant, mock_evnex) -> None:
mock_evnex.authenticate.side_effect = mfa_challenge()

result = await start_user_flow(hass)
result = await hass.config_entries.flow.async_configure(result["flow_id"], CREDS)

assert result["type"] == FlowResultType.FORM
assert result["step_id"] == "mfa"

result = await hass.config_entries.flow.async_configure(
result["flow_id"], {"mfa_code": "123456"}
)

assert result["type"] == FlowResultType.CREATE_ENTRY
assert result["title"] == "Test User"
mock_evnex.respond_to_mfa_challenge.assert_called_once_with("123456", "TOTP")


async def test_mfa_wrong_code_shows_error_and_recovers(
hass: HomeAssistant, mock_evnex
) -> None:
from evnex.errors import NotAuthorizedException

mock_evnex.authenticate.side_effect = mfa_challenge()
mock_evnex.respond_to_mfa_challenge.side_effect = [
NotAuthorizedException("Wrong code"),
None,
]

result = await start_user_flow(hass)
result = await hass.config_entries.flow.async_configure(result["flow_id"], CREDS)
result = await hass.config_entries.flow.async_configure(
result["flow_id"], {"mfa_code": "000000"}
)

assert result["type"] == FlowResultType.FORM
assert result["step_id"] == "mfa"
assert result["errors"] == {"base": "invalid_mfa_code"}

result = await hass.config_entries.flow.async_configure(
result["flow_id"], {"mfa_code": "123456"}
)
assert result["type"] == FlowResultType.CREATE_ENTRY


async def test_user_flow_invalid_credentials(hass: HomeAssistant, mock_evnex) -> None:
from evnex.errors import NotAuthorizedException

mock_evnex.authenticate.side_effect = NotAuthorizedException("Bad creds")

result = await start_user_flow(hass)
result = await hass.config_entries.flow.async_configure(result["flow_id"], CREDS)

assert result["type"] == FlowResultType.FORM
assert result["errors"] == {"base": "invalid_credentials"}


async def test_duplicate_account_aborts(hass: HomeAssistant, mock_evnex) -> None:
MockConfigEntry(
domain=DOMAIN, unique_id=USER_ID, data=CREDS, minor_version=3
).add_to_hass(hass)

result = await start_user_flow(hass)
result = await hass.config_entries.flow.async_configure(result["flow_id"], CREDS)

assert result["type"] == FlowResultType.ABORT
assert result["reason"] == "already_configured"


async def test_reauth_flow_with_mfa(hass: HomeAssistant, mock_evnex) -> None:
entry = MockConfigEntry(
domain=DOMAIN,
unique_id=USER_ID,
minor_version=3,
data={**CREDS, "user_id": USER_ID, "access_token": None},
)
entry.add_to_hass(hass)
mock_evnex.authenticate.side_effect = mfa_challenge()

entry.async_start_reauth(hass)
await hass.async_block_till_done()

flows = hass.config_entries.flow.async_progress_by_handler(DOMAIN)
assert len(flows) == 1
result = await hass.config_entries.flow.async_configure(
flows[0]["flow_id"], {"password": "hunter2"}
)
assert result["type"] == FlowResultType.FORM
assert result["step_id"] == "mfa"

result = await hass.config_entries.flow.async_configure(
result["flow_id"], {"mfa_code": "123456"}
)
assert result["type"] == FlowResultType.ABORT
assert result["reason"] == "reauth_successful"
assert entry.data["access_token"] == "access-0"


async def test_reauth_wrong_account_aborts(hass: HomeAssistant, mock_evnex) -> None:
entry = MockConfigEntry(
domain=DOMAIN,
unique_id="different-user",
minor_version=3,
data={**CREDS, "user_id": "different-user"},
)
entry.add_to_hass(hass)

entry.async_start_reauth(hass)
await hass.async_block_till_done()

flows = hass.config_entries.flow.async_progress_by_handler(DOMAIN)
result = await hass.config_entries.flow.async_configure(
flows[0]["flow_id"], {"password": "hunter2"}
)

assert result["type"] == FlowResultType.ABORT
assert result["reason"] == "wrong_account"
Loading
Loading