Files
core/tests/components/splunk/test_init.py
T

537 lines
17 KiB
Python

"""Test the Splunk integration init."""
from http import HTTPStatus
import logging
from unittest.mock import AsyncMock, MagicMock
from aiohttp import ClientConnectionError, ClientResponseError
from hass_splunk import SplunkPayloadError
import pytest
from homeassistant.components.splunk.const import CONF_FILTER, DOMAIN
from homeassistant.config_entries import ConfigEntryState
from homeassistant.const import CONF_HOST, CONF_PORT, CONF_SSL, CONF_TOKEN
from homeassistant.core import HomeAssistant
from homeassistant.helpers.typing import ConfigType
from homeassistant.setup import async_setup_component
from tests.common import MockConfigEntry
YAML_FILTER = {"include_domains": ["sensor"]}
async def test_setup_entry_success(
hass: HomeAssistant, mock_hass_splunk: AsyncMock, mock_config_entry: MockConfigEntry
) -> None:
"""Test successful setup from config entry."""
mock_config_entry.add_to_hass(hass)
assert await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
assert mock_config_entry.state is ConfigEntryState.LOADED
# Verify client was created and checked
assert mock_hass_splunk.check.call_count == 2
# First call checks connectivity
mock_hass_splunk.check.assert_any_call(connectivity=True, token=False, busy=False)
# Second call checks token
mock_hass_splunk.check.assert_any_call(connectivity=False, token=True, busy=False)
# Verify startup event was queued
assert mock_hass_splunk.queue.call_count == 1
@pytest.mark.parametrize(
("side_effect", "expected_state", "expected_error_key"),
[
([False, False], ConfigEntryState.SETUP_RETRY, "cannot_connect"),
(
ClientConnectionError("Connection failed"),
ConfigEntryState.SETUP_RETRY,
"cannot_connect",
),
(TimeoutError(), ConfigEntryState.SETUP_RETRY, "timeout_connect"),
(
Exception("Unexpected error"),
ConfigEntryState.SETUP_RETRY,
"unexpected_connect_error",
),
([True, False], ConfigEntryState.SETUP_ERROR, "invalid_auth"),
],
)
async def test_setup_entry_error(
hass: HomeAssistant,
mock_hass_splunk: AsyncMock,
mock_config_entry: MockConfigEntry,
side_effect: Exception | list[bool],
expected_state: ConfigEntryState,
expected_error_key: str,
) -> None:
"""Test setup with various errors results in appropriate states."""
mock_config_entry.add_to_hass(hass)
mock_hass_splunk.check.side_effect = side_effect
await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
assert mock_config_entry.state is expected_state
assert mock_config_entry.error_reason_translation_key == expected_error_key
async def test_unload_entry(
hass: HomeAssistant, mock_hass_splunk: AsyncMock, mock_config_entry: MockConfigEntry
) -> None:
"""Test unloading a config entry."""
mock_config_entry.add_to_hass(hass)
assert await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
assert mock_config_entry.state is ConfigEntryState.LOADED
assert await hass.config_entries.async_unload(mock_config_entry.entry_id)
await hass.async_block_till_done()
assert mock_config_entry.state is ConfigEntryState.NOT_LOADED
async def test_setup_without_yaml(
hass: HomeAssistant, mock_hass_splunk: AsyncMock
) -> None:
"""Test setup without YAML succeeds."""
assert await async_setup_component(hass, DOMAIN, {})
await hass.async_block_till_done()
@pytest.mark.parametrize(
"yaml_config",
[
pytest.param({CONF_FILTER: YAML_FILTER}, id="filter_only"),
pytest.param(
{
CONF_TOKEN: "yaml-token",
CONF_HOST: "yaml-host",
CONF_PORT: 8089,
CONF_SSL: False,
CONF_FILTER: YAML_FILTER,
},
id="with_removed_connection_settings",
),
],
)
async def test_event_listener_with_filter(
hass: HomeAssistant,
mock_hass_splunk: AsyncMock,
mock_config_entry: MockConfigEntry,
yaml_config: ConfigType,
) -> None:
"""Test event listener respects entity filter from YAML."""
mock_config_entry.add_to_hass(hass)
assert await async_setup_component(hass, DOMAIN, {DOMAIN: yaml_config})
await hass.async_block_till_done()
assert hass.config_entries.async_entries(DOMAIN) == [mock_config_entry]
assert mock_config_entry.state is ConfigEntryState.LOADED
# Reset queue call count after startup event
mock_hass_splunk.queue.reset_mock()
hass.states.async_set("sensor.test", "123")
await hass.async_block_till_done()
assert mock_hass_splunk.queue.call_count == 1
mock_hass_splunk.queue.reset_mock()
hass.states.async_set("light.test", "on")
await hass.async_block_till_done()
assert mock_hass_splunk.queue.call_count == 0
async def test_yaml_connection_settings_not_imported(
hass: HomeAssistant,
caplog: pytest.LogCaptureFixture,
) -> None:
"""Test YAML connection settings are ignored and no config entry is created."""
assert await async_setup_component(
hass,
DOMAIN,
{
DOMAIN: {
CONF_TOKEN: "yaml-token",
CONF_HOST: "yaml-host",
CONF_PORT: 8089,
CONF_SSL: False,
}
},
)
await hass.async_block_till_done()
assert hass.config_entries.async_entries(DOMAIN) == []
assert (
"The 'token' option has been removed, please remove it from your configuration"
in caplog.text
)
async def test_event_listener_unauthorized(
hass: HomeAssistant, mock_hass_splunk: AsyncMock, mock_config_entry: MockConfigEntry
) -> None:
"""Test event listener triggers reauth on unauthorized error."""
mock_config_entry.add_to_hass(hass)
assert await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
# Create a real state first
hass.states.async_set("sensor.test", "123")
await hass.async_block_till_done()
# Simulate unauthorized error when sending event
mock_hass_splunk.queue.side_effect = SplunkPayloadError(
0, "Unauthorized", HTTPStatus.UNAUTHORIZED
)
# Change the state to trigger an event
hass.states.async_set("sensor.test", "456")
await hass.async_block_till_done()
# Verify reauth flow was started
flows = hass.config_entries.flow.async_progress_by_handler(DOMAIN)
assert len(flows) == 1
assert flows[0]["context"]["source"] == "reauth"
@pytest.mark.parametrize(
("error", "expected_log_level", "expected_message"),
[
(
ClientConnectionError("Connection failed"),
logging.DEBUG,
"Connection error sending to Splunk",
),
(
TimeoutError(),
logging.DEBUG,
"Timeout sending to Splunk",
),
(
ClientResponseError(
request_info=MagicMock(),
history=(),
status=500,
message="Internal Server Error",
),
logging.WARNING,
"Splunk response error: Internal Server Error",
),
(
SplunkPayloadError(0, "Bad request", HTTPStatus.BAD_REQUEST),
logging.WARNING,
"Splunk payload error: Bad request",
),
],
)
async def test_event_listener_error_handling(
hass: HomeAssistant,
mock_hass_splunk: AsyncMock,
mock_config_entry: MockConfigEntry,
caplog: pytest.LogCaptureFixture,
error: Exception,
expected_log_level: int,
expected_message: str,
) -> None:
"""Test event listener handles various errors gracefully."""
mock_config_entry.add_to_hass(hass)
assert await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
# Create a real state first
hass.states.async_set("sensor.test", "123")
await hass.async_block_till_done()
# Simulate error when sending event
mock_hass_splunk.queue.side_effect = error
# Change the state to trigger an event - should not raise
with caplog.at_level(logging.DEBUG):
hass.states.async_set("sensor.test", "456")
await hass.async_block_till_done()
assert any(
record.levelno == expected_log_level and expected_message in record.message
for record in caplog.records
)
@pytest.mark.parametrize(
("error", "expected_log_level", "expected_message", "expected_traceback"),
[
(
ClientResponseError(
request_info=MagicMock(),
history=(),
status=500,
message="Internal Server Error",
),
logging.WARNING,
"Splunk response error: Internal Server Error",
False,
),
(
SplunkPayloadError(0, "Unauthorized", HTTPStatus.UNAUTHORIZED),
logging.ERROR,
"Splunk token unauthorized",
False,
),
(
ValueError("boom"),
logging.ERROR,
"Unexpected error sending event to Splunk",
True,
),
],
)
async def test_event_listener_repeated_failures_log_at_debug(
hass: HomeAssistant,
mock_hass_splunk: AsyncMock,
mock_config_entry: MockConfigEntry,
caplog: pytest.LogCaptureFixture,
error: Exception,
expected_log_level: int,
expected_message: str,
expected_traceback: bool,
) -> None:
"""Test the first failure logs at its level and the repeats log at debug."""
mock_config_entry.add_to_hass(hass)
assert await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
mock_hass_splunk.queue.side_effect = error
with caplog.at_level(logging.DEBUG):
for i in range(5):
hass.states.async_set("sensor.test", str(i))
await hass.async_block_till_done()
matching_records = [
record for record in caplog.records if expected_message in record.message
]
assert len(matching_records) == 5
assert matching_records[0].levelno == expected_log_level
# A traceback is worth one record per outage, never one per state change.
assert bool(matching_records[0].exc_info) is expected_traceback
assert all(record.levelno == logging.DEBUG for record in matching_records[1:])
assert not any(record.exc_info for record in matching_records[1:])
@pytest.mark.parametrize(
("first_error", "first_message", "first_level", "second_error", "second_message"),
[
pytest.param(
ClientConnectionError("Connection failed"),
"Connection error sending to Splunk",
logging.DEBUG,
SplunkPayloadError(0, "Unauthorized", HTTPStatus.UNAUTHORIZED),
"Splunk token unauthorized",
id="more_severe_second_failure",
),
pytest.param(
SplunkPayloadError(0, "Unauthorized", HTTPStatus.UNAUTHORIZED),
"Splunk token unauthorized",
logging.ERROR,
ClientResponseError(
request_info=MagicMock(),
history=(),
status=500,
message="Internal Server Error",
),
"Splunk response error: Internal Server Error",
id="less_severe_second_failure",
),
],
)
async def test_event_listener_different_failure_during_outage_logs_at_debug(
hass: HomeAssistant,
mock_hass_splunk: AsyncMock,
mock_config_entry: MockConfigEntry,
caplog: pytest.LogCaptureFixture,
first_error: Exception,
first_message: str,
first_level: int,
second_error: Exception,
second_message: str,
) -> None:
"""Test a different failure during an outage logs at debug, whatever its level."""
mock_config_entry.add_to_hass(hass)
assert await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
with caplog.at_level(logging.DEBUG):
mock_hass_splunk.queue.side_effect = first_error
hass.states.async_set("sensor.test", "outage")
await hass.async_block_till_done()
mock_hass_splunk.queue.side_effect = second_error
for i in range(2):
hass.states.async_set("sensor.test", f"different-{i}")
await hass.async_block_till_done()
first_records = [
record for record in caplog.records if first_message in record.message
]
assert len(first_records) == 1
assert first_records[0].levelno == first_level
second_records = [
record for record in caplog.records if second_message in record.message
]
assert len(second_records) == 2
assert all(record.levelno == logging.DEBUG for record in second_records)
async def test_event_listener_recovery_logs_once_at_info(
hass: HomeAssistant,
mock_hass_splunk: AsyncMock,
mock_config_entry: MockConfigEntry,
caplog: pytest.LogCaptureFixture,
) -> None:
"""Test a recovered outage logs a single info record."""
mock_config_entry.add_to_hass(hass)
assert await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
mock_hass_splunk.queue.side_effect = ClientConnectionError("Connection failed")
with caplog.at_level(logging.DEBUG):
for i in range(2):
hass.states.async_set("sensor.test", str(i))
await hass.async_block_till_done()
mock_hass_splunk.queue.side_effect = None
for i in range(2):
hass.states.async_set("sensor.test", f"recovered-{i}")
await hass.async_block_till_done()
recovery_records = [
record
for record in caplog.records
if "Sending events to Splunk has recovered" in record.message
]
assert len(recovery_records) == 1
assert recovery_records[0].levelno == logging.INFO
async def test_event_listener_failure_after_recovery_logs_at_natural_level(
hass: HomeAssistant,
mock_hass_splunk: AsyncMock,
mock_config_entry: MockConfigEntry,
caplog: pytest.LogCaptureFixture,
) -> None:
"""Test a new outage after a recovery logs at its natural level again."""
mock_config_entry.add_to_hass(hass)
assert await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
mock_hass_splunk.queue.side_effect = ClientConnectionError("Connection failed")
with caplog.at_level(logging.DEBUG):
hass.states.async_set("sensor.test", "first-outage")
await hass.async_block_till_done()
mock_hass_splunk.queue.side_effect = None
hass.states.async_set("sensor.test", "recovered")
await hass.async_block_till_done()
caplog.clear()
mock_hass_splunk.queue.side_effect = ClientResponseError(
request_info=MagicMock(),
history=(),
status=500,
message="Internal Server Error",
)
hass.states.async_set("sensor.test", "second-outage")
await hass.async_block_till_done()
assert any(
record.levelno == logging.WARNING
and "Splunk response error: Internal Server Error" in record.message
for record in caplog.records
)
async def test_event_listener_coalesced_send_is_not_a_recovery(
hass: HomeAssistant,
mock_hass_splunk: AsyncMock,
mock_config_entry: MockConfigEntry,
caplog: pytest.LogCaptureFixture,
) -> None:
"""Test a send coalesced into an in-flight send isn't treated as success.
hass_splunk.queue() returns False, without raising, when a send is already
in flight, which says nothing about whether this event reached Splunk.
"""
mock_config_entry.add_to_hass(hass)
assert await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
error = ClientResponseError(
request_info=MagicMock(),
history=(),
status=500,
message="Internal Server Error",
)
mock_hass_splunk.queue.side_effect = [error, False, error]
with caplog.at_level(logging.DEBUG):
for i in range(3):
hass.states.async_set("sensor.test", str(i))
await hass.async_block_till_done()
assert not any(
"Sending events to Splunk has recovered" in record.message
for record in caplog.records
)
# Still demoted, so the coalesced send did not clear the outage either.
failure_records = [
record
for record in caplog.records
if "Splunk response error: Internal Server Error" in record.message
]
assert len(failure_records) == 2
assert failure_records[0].levelno == logging.WARNING
assert failure_records[1].levelno == logging.DEBUG
async def test_event_listener_no_recovery_message_without_prior_failure(
hass: HomeAssistant,
mock_hass_splunk: AsyncMock,
mock_config_entry: MockConfigEntry,
caplog: pytest.LogCaptureFixture,
) -> None:
"""Test that a successful send without prior failures logs no recovery message."""
mock_config_entry.add_to_hass(hass)
assert await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
with caplog.at_level(logging.DEBUG):
hass.states.async_set("sensor.test", "123")
await hass.async_block_till_done()
assert not any(
"Sending events to Splunk has recovered" in record.message
for record in caplog.records
)