Files
gh-christianlouis-docuelevate/tests/test_notification.py

484 lines
18 KiB
Python

"""
Tests for app/utils/notification.py
Tests notification utilities and URL masking.
"""
from unittest.mock import MagicMock, patch
import pytest
_TEST_CREDENTIAL_URL = "https://user:password@example.com/notify" # noqa: S105
_TEST_QUERY_URL = "https://example.com/api?key=secret1&password=secret2&public=visible" # noqa: S105
@pytest.mark.unit
class TestNotificationUrlMasking:
"""Test URL masking for security"""
def test_mask_sensitive_url_basic_auth(self):
"""Test masking of basic auth URLs"""
from app.utils.notification import _mask_sensitive_url
url = _TEST_CREDENTIAL_URL
masked = _mask_sensitive_url(url)
# Password should be masked
assert "password" not in masked
assert "****" in masked
assert "user" in masked
assert "example.com" in masked
def test_mask_sensitive_url_discord(self):
"""Test masking of Discord webhook URLs"""
from app.utils.notification import _mask_sensitive_url
url = "discord://webhook_id/webhook_token/channel_id"
masked = _mask_sensitive_url(url)
# Token should be masked
assert "webhook_token" not in masked
assert "****" in masked
assert "discord://" in masked
def test_mask_sensitive_url_telegram(self):
"""Test masking of Telegram URLs"""
from app.utils.notification import _mask_sensitive_url
url = "tgram://bot_token/chat_id"
masked = _mask_sensitive_url(url)
# Bot token should be masked
assert "bot_token" not in masked or "****" in masked
assert "tgram://" in masked
def test_mask_sensitive_url_with_token_parameter(self):
"""Test masking of URLs with token query parameters"""
from app.utils.notification import _mask_sensitive_url
url = "https://example.com/notify?token=secret_token_123&other=value"
masked = _mask_sensitive_url(url)
# Token value should be masked
assert "secret_token_123" not in masked
assert "token=****" in masked or "****" in masked
assert "other=value" in masked
def test_mask_sensitive_url_with_api_key(self):
"""Test masking of URLs with api_key parameter"""
from app.utils.notification import _mask_sensitive_url
url = "https://example.com/api?api_key=my_api_key_here"
masked = _mask_sensitive_url(url)
# API key should be masked
assert "my_api_key_here" not in masked
assert "****" in masked
def test_mask_sensitive_url_with_multiple_params(self):
"""Test masking with multiple sensitive parameters"""
from app.utils.notification import _mask_sensitive_url
url = _TEST_QUERY_URL
masked = _mask_sensitive_url(url)
# Sensitive params should be masked
assert "secret1" not in masked
assert "secret2" not in masked
assert "****" in masked
assert "public=visible" in masked
def test_mask_sensitive_url_no_sensitive_data(self):
"""Test masking of URLs without sensitive data"""
from app.utils.notification import _mask_sensitive_url
url = "https://example.com/notify?id=123&name=test"
masked = _mask_sensitive_url(url)
# Should return similar URL (no masking needed)
assert "example.com" in masked
assert "id=123" in masked or "****" not in masked or "****" in masked
def test_mask_sensitive_url_empty_string(self):
"""Test masking of empty string"""
from app.utils.notification import _mask_sensitive_url
url = ""
masked = _mask_sensitive_url(url)
assert masked == ""
def test_mask_sensitive_url_various_formats(self):
"""Test masking with various URL formats"""
from app.utils.notification import _mask_sensitive_url
test_urls = [
"mailto://user:password@gmail.com",
"slack://token@workspace",
"https://api.example.com?secret=hidden",
]
for url in test_urls:
masked = _mask_sensitive_url(url)
# All should return strings
assert isinstance(masked, str)
# Most should have masking applied
assert len(masked) > 0
@pytest.mark.unit
class TestAppriseInitialization:
"""Test Apprise initialization"""
@patch("app.utils.notification.apprise.Apprise")
@patch("app.utils.notification.settings")
def test_init_apprise_with_configured_urls(self, mock_settings, mock_apprise_class):
"""Test Apprise initialization with configured URLs"""
import app.utils.notification
from app.utils.notification import init_apprise
# Reset global
app.utils.notification._apprise = None
mock_settings.notification_urls = [
"https://example.com/notify1",
"https://example.com/notify2",
]
mock_apprise_instance = MagicMock()
mock_apprise_class.return_value = mock_apprise_instance
result = init_apprise()
# Should create Apprise instance
mock_apprise_class.assert_called_once()
# Should add configured URLs
assert mock_apprise_instance.add.call_count == 2
# Should return the instance
assert result == mock_apprise_instance
@patch("app.utils.notification.apprise.Apprise")
@patch("app.utils.notification.settings")
def test_init_apprise_no_urls_configured(self, mock_settings, mock_apprise_class):
"""Test Apprise initialization without configured URLs"""
import app.utils.notification
from app.utils.notification import init_apprise
# Reset global
app.utils.notification._apprise = None
mock_settings.notification_urls = []
mock_apprise_instance = MagicMock()
mock_apprise_class.return_value = mock_apprise_instance
result = init_apprise()
# Should still create Apprise instance
mock_apprise_class.assert_called_once()
# Should not add any URLs
mock_apprise_instance.add.assert_not_called()
# Should return the instance
assert result == mock_apprise_instance
@patch("app.utils.notification.apprise.Apprise")
@patch("app.utils.notification.settings")
def test_init_apprise_caches_instance(self, mock_settings, mock_apprise_class):
"""Test that Apprise instance is cached"""
import app.utils.notification
from app.utils.notification import init_apprise
# Reset global
app.utils.notification._apprise = None
mock_settings.notification_urls = []
mock_apprise_instance = MagicMock()
mock_apprise_class.return_value = mock_apprise_instance
# First call
result1 = init_apprise()
# Second call
result2 = init_apprise()
# Should only create once (cached)
mock_apprise_class.assert_called_once()
# Both should return same instance
assert result1 == result2
@patch("app.utils.notification.apprise.Apprise")
@patch("app.utils.notification.settings")
def test_init_apprise_handles_add_failure(self, mock_settings, mock_apprise_class):
"""Test handling when adding notification URL fails"""
import app.utils.notification
from app.utils.notification import init_apprise
# Reset global
app.utils.notification._apprise = None
mock_settings.notification_urls = ["invalid://url"]
mock_apprise_instance = MagicMock()
mock_apprise_instance.add.side_effect = Exception("Invalid URL format")
mock_apprise_class.return_value = mock_apprise_instance
# Should not raise exception, just log error
result = init_apprise()
# Should still return instance
assert result == mock_apprise_instance
@pytest.mark.unit
class TestSendNotification:
"""Test send_notification function"""
@patch("app.utils.notification.init_apprise")
@patch("app.utils.notification.settings")
def test_send_notification_no_urls_configured(self, mock_settings, mock_init_apprise):
"""Test sending notification when no URLs are configured."""
from app.utils.notification import send_notification
mock_settings.notification_urls = []
result = send_notification("Test", "Test message")
assert result is False
mock_init_apprise.assert_not_called()
@patch("app.utils.notification.init_apprise")
@patch("app.utils.notification.settings")
def test_send_notification_success_type(self, mock_settings, mock_init_apprise):
"""Test notification with success type."""
from app.utils.notification import send_notification
mock_settings.notification_urls = ["https://example.com/notify"]
mock_apprise = MagicMock()
mock_server = MagicMock()
mock_server.notify.return_value = True
mock_apprise.servers = [mock_server]
mock_init_apprise.return_value = mock_apprise
result = send_notification("Test", "Message", notification_type="success")
assert result is True
mock_server.notify.assert_called_once()
# Check that SUCCESS type was used
call_kwargs = mock_server.notify.call_args[1]
assert "notify_type" in call_kwargs
@patch("app.utils.notification.init_apprise")
@patch("app.utils.notification.settings")
def test_send_notification_warning_type(self, mock_settings, mock_init_apprise):
"""Test notification with warning type."""
from app.utils.notification import send_notification
mock_settings.notification_urls = ["https://example.com/notify"]
mock_apprise = MagicMock()
mock_server = MagicMock()
mock_server.notify.return_value = True
mock_apprise.servers = [mock_server]
mock_init_apprise.return_value = mock_apprise
result = send_notification("Test", "Message", notification_type="warn")
assert result is True
@patch("app.utils.notification.init_apprise")
@patch("app.utils.notification.settings")
def test_send_notification_failure_type(self, mock_settings, mock_init_apprise):
"""Test notification with failure type."""
from app.utils.notification import send_notification
mock_settings.notification_urls = ["https://example.com/notify"]
mock_apprise = MagicMock()
mock_server = MagicMock()
mock_server.notify.return_value = True
mock_apprise.servers = [mock_server]
mock_init_apprise.return_value = mock_apprise
result = send_notification("Test", "Message", notification_type="failed")
assert result is True
@patch("app.utils.notification.init_apprise")
@patch("app.utils.notification.settings")
def test_send_notification_no_servers(self, mock_settings, mock_init_apprise):
"""Test notification when no servers are available."""
from app.utils.notification import send_notification
mock_settings.notification_urls = ["https://example.com/notify"]
mock_apprise = MagicMock()
mock_apprise.servers = []
mock_init_apprise.return_value = mock_apprise
result = send_notification("Test", "Message")
assert result is False
@patch("app.utils.notification.init_apprise")
@patch("app.utils.notification.settings")
def test_send_notification_partial_success(self, mock_settings, mock_init_apprise):
"""Test notification with multiple servers, some failing."""
from app.utils.notification import send_notification
mock_settings.notification_urls = ["https://example.com/notify"]
mock_apprise = MagicMock()
mock_server1 = MagicMock()
mock_server1.notify.return_value = True
mock_server2 = MagicMock()
mock_server2.notify.return_value = False
mock_apprise.servers = [mock_server1, mock_server2]
mock_init_apprise.return_value = mock_apprise
result = send_notification("Test", "Message")
assert result is True # At least one succeeded
@patch("app.utils.notification.init_apprise")
@patch("app.utils.notification.settings")
def test_send_notification_with_attachments(self, mock_settings, mock_init_apprise):
"""Test notification with file attachments."""
from app.utils.notification import send_notification
mock_settings.notification_urls = ["https://example.com/notify"]
mock_apprise = MagicMock()
mock_server = MagicMock()
mock_server.notify.return_value = True
mock_apprise.servers = [mock_server]
mock_init_apprise.return_value = mock_apprise
result = send_notification("Test", "Message", attachments=["/path/to/file.pdf"])
assert result is True
call_kwargs = mock_server.notify.call_args[1]
assert call_kwargs["attach"] == ["/path/to/file.pdf"]
@patch("app.utils.notification.init_apprise")
@patch("app.utils.notification.settings")
def test_send_notification_exception_handling(self, mock_settings, mock_init_apprise):
"""Test that exceptions are caught and logged."""
from app.utils.notification import send_notification
mock_settings.notification_urls = ["https://example.com/notify"]
mock_init_apprise.side_effect = Exception("Connection error")
result = send_notification("Test", "Message")
assert result is False
@pytest.mark.unit
class TestNotificationHelpers:
"""Test notification helper functions."""
@patch("app.utils.notification.send_notification")
@patch("app.utils.notification.settings")
def test_notify_celery_failure_disabled(self, mock_settings, mock_send):
"""Test Celery failure notification when disabled."""
from app.utils.notification import notify_celery_failure
mock_settings.notify_on_task_failure = False
result = notify_celery_failure("test_task", "task-123", Exception("Error"), [], {})
assert result is False
mock_send.assert_not_called()
@patch("app.utils.notification.send_notification")
@patch("app.utils.notification.settings")
def test_notify_celery_failure_enabled(self, mock_settings, mock_send):
"""Test Celery failure notification when enabled."""
from app.utils.notification import notify_celery_failure
mock_settings.notify_on_task_failure = True
mock_send.return_value = True
result = notify_celery_failure("test_task", "task-123", Exception("Error"), [], {})
assert result is True
mock_send.assert_called_once()
call_args = mock_send.call_args[1]
assert call_args["notification_type"] == "failure"
@patch("app.utils.notification.send_notification")
@patch("app.utils.notification.settings")
def test_notify_credential_failure_disabled(self, mock_settings, mock_send):
"""Test credential failure notification when disabled."""
from app.utils.notification import notify_credential_failure
mock_settings.notify_on_credential_failure = False
result = notify_credential_failure("Dropbox", "Invalid token")
assert result is False
mock_send.assert_not_called()
@patch("app.utils.notification.send_notification")
@patch("app.utils.notification.settings")
def test_notify_startup_disabled(self, mock_settings, mock_send):
"""Test startup notification when disabled."""
from app.utils.notification import notify_startup
mock_settings.notify_on_startup = False
result = notify_startup()
assert result is False
mock_send.assert_not_called()
@patch("app.utils.notification.send_notification")
@patch("app.utils.notification.settings")
def test_notify_shutdown_disabled(self, mock_settings, mock_send):
"""Test shutdown notification when disabled."""
from app.utils.notification import notify_shutdown
mock_settings.notify_on_shutdown = False
result = notify_shutdown()
assert result is False
mock_send.assert_not_called()
@patch("app.utils.notification.send_notification")
@patch("app.utils.notification.settings")
def test_notify_file_processed_disabled(self, mock_settings, mock_send):
"""Test file processed notification when disabled."""
from app.utils.notification import notify_file_processed
mock_settings.notify_on_file_processed = False
result = notify_file_processed("test.pdf", 1024000, {}, [])
assert result is False
mock_send.assert_not_called()
@patch("app.utils.notification.send_notification")
@patch("app.utils.notification.settings")
def test_notify_file_processed_with_metadata(self, mock_settings, mock_send):
"""Test file processed notification with metadata."""
from app.utils.notification import notify_file_processed
mock_settings.notify_on_file_processed = True
mock_send.return_value = True
metadata = {"document_type": "invoice", "tags": ["important", "finance"]}
result = notify_file_processed("test.pdf", 2048000, metadata, ["dropbox", "s3"])
assert result is True
mock_send.assert_called_once()
# Check message contains expected info
# Access keyword arguments instead of positional
call_kwargs = mock_send.call_args.kwargs
message = call_kwargs.get("message") or call_kwargs.get("body") or mock_send.call_args[0][1]
# 2048000 bytes = 1.953125 MB, which displays as 1.95 MB
assert "1.95 MB" in message or "2.00 MB" in message or "2048000" in message
assert "invoice" in message
assert "important, finance" in message
assert "dropbox, s3" in message