7a0da7932b
OSV-Scanner (Scheduled) / scan-scheduled (push) Failing after 0s
Create Release / test-gate (push) Has been cancelled
Create Release / release-gate (push) Has been cancelled
Create Release / ci-gate (push) Has been cancelled
Create Release / version-check (push) Has been cancelled
Create Release / e2e-test-gate (push) Has been cancelled
Create Release / responsive-test-gate (push) Has been cancelled
Create Release / compat-test-gate (push) Has been cancelled
Create Release / compose-integration-gate (push) Has been cancelled
Create Release / vulture-gate (push) Has been cancelled
Create Release / build (push) Has been cancelled
Create Release / provenance (push) Has been cancelled
Create Release / prerelease-docker (push) Has been cancelled
Create Release / publish-docker (push) Has been cancelled
Create Release / create-release (push) Has been cancelled
Create Release / cleanup-changelog (push) Has been cancelled
Create Release / trigger-pypi (push) Has been cancelled
Create Release / monitor-pypi (push) Has been cancelled
Create Release / Clean up orphan prerelease tags and signatures (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-form] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-metrics] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-workflow] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [settings-core] (push) Has been cancelled
CodeQL Advanced / Analyze (javascript-typescript) (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [history-news] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [library] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [link-analytics] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [chat-core] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [chat-lifecycle] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [error-benchmark] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [settings-pages] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) (push) Has been cancelled
Docker Tests (Consolidated) / Accessibility Tests (push) Has been cancelled
Docker Tests (Consolidated) / LLM Unit Tests (push) Has been cancelled
Docker Tests (Consolidated) / LLM Example Tests (push) Has been cancelled
Docker Tests (Consolidated) / Production Image Smoke Test (push) Has been cancelled
Docker Tests (Consolidated) / Infrastructure Tests (push) Has been cancelled
OSSF Scorecard / OSSF Security Scorecard Analysis (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [mobile] (push) Has been cancelled
Backwards Compatibility / Verify Encryption Constants (push) Has been cancelled
Backwards Compatibility / PyPI Version Compatibility (push) Has been cancelled
Backwards Compatibility / Database Migration Tests (push) Has been cancelled
CodeQL Advanced / Analyze (python) (push) Has been cancelled
Docker Tests (Consolidated) / detect-changes (push) Has been cancelled
Docker Tests (Consolidated) / Build Test Image (push) Has been cancelled
Docker Tests (Consolidated) / All Pytest Tests + Coverage (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [accessibility] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [api-crud] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-login] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-pages] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-register] (push) Has been cancelled
1310 lines
49 KiB
Python
1310 lines
49 KiB
Python
"""
|
|
Comprehensive tests for notification-related modules.
|
|
|
|
Covers:
|
|
- NotificationURLValidator (SSRF prevention, scheme blocking, private IP detection)
|
|
- NotificationService (send logic, event sending, service testing, URL validation)
|
|
- NotificationManager (rate limiting, settings snapshot, event routing)
|
|
- RateLimiter (per-user limits, hourly/daily windows, cleanup)
|
|
- NotificationTemplate (Jinja2 rendering, custom templates, fallback)
|
|
- build_notification_url (URL building from settings)
|
|
- Queue helpers (send_queue_notification, send_queue_failed_notification)
|
|
- mask_sensitive_url (credential masking)
|
|
- Exception hierarchy
|
|
"""
|
|
|
|
import threading
|
|
from collections import deque
|
|
from datetime import datetime, timedelta, timezone
|
|
from unittest.mock import patch
|
|
|
|
import pytest
|
|
|
|
from local_deep_research.notifications.exceptions import (
|
|
NotificationError,
|
|
RateLimitError,
|
|
SendError,
|
|
ServiceError,
|
|
)
|
|
from local_deep_research.notifications.manager import (
|
|
NotificationManager,
|
|
RateLimiter,
|
|
)
|
|
from local_deep_research.notifications.service import NotificationService
|
|
from local_deep_research.notifications.templates import (
|
|
EventType,
|
|
NotificationTemplate,
|
|
)
|
|
from local_deep_research.notifications.url_builder import build_notification_url
|
|
from local_deep_research.notifications.queue_helpers import (
|
|
send_queue_notification,
|
|
send_queue_failed_notification,
|
|
)
|
|
from local_deep_research.security.notification_validator import (
|
|
NotificationURLValidationError,
|
|
NotificationURLValidator,
|
|
)
|
|
from local_deep_research.security.url_builder import mask_sensitive_url
|
|
from local_deep_research.security.url_validator import (
|
|
URLValidationError,
|
|
URLValidator,
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Fixtures
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _reset_shared_rate_limiter():
|
|
"""Reset the singleton rate limiter between tests to avoid cross-contamination."""
|
|
NotificationManager._shared_rate_limiter = None
|
|
yield
|
|
NotificationManager._shared_rate_limiter = None
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _enable_outbound_notifications(monkeypatch):
|
|
"""Module-level autouse: open the operator-level env gate so tests in
|
|
this file that exercise the inner notification logic don't bail at
|
|
NotificationManager.__init__ / NotificationService.send. Tests of the
|
|
gate itself (none in this file) should override with
|
|
monkeypatch.delenv. See SECURITY.md "Notification Webhook SSRF".
|
|
"""
|
|
monkeypatch.setenv("LDR_NOTIFICATIONS_ALLOW_OUTBOUND", "true")
|
|
|
|
|
|
@pytest.fixture()
|
|
def default_settings():
|
|
"""Settings snapshot that enables notifications with a valid URL."""
|
|
return {
|
|
"notifications.service_url": "discord://webhook_id/token",
|
|
"notifications.on_research_completed": True,
|
|
"notifications.on_research_failed": True,
|
|
"notifications.on_research_queued": True,
|
|
"notifications.rate_limit_per_hour": 10,
|
|
"notifications.rate_limit_per_day": 50,
|
|
"notifications.allow_private_ips": False,
|
|
}
|
|
|
|
|
|
@pytest.fixture()
|
|
def disabled_settings():
|
|
"""Settings snapshot with notifications disabled."""
|
|
return {
|
|
"notifications.service_url": "",
|
|
"notifications.on_research_completed": False,
|
|
"notifications.on_research_failed": False,
|
|
"notifications.rate_limit_per_hour": 10,
|
|
"notifications.rate_limit_per_day": 50,
|
|
}
|
|
|
|
|
|
# ===================================================================
|
|
# NotificationURLValidator
|
|
# ===================================================================
|
|
|
|
|
|
class TestNotificationURLValidator:
|
|
"""Tests for SSRF prevention and scheme validation."""
|
|
|
|
# -- Allowed schemes --------------------------------------------------
|
|
|
|
@pytest.mark.parametrize(
|
|
"url",
|
|
[
|
|
"discord://webhook_id/token",
|
|
"slack://token_a/token_b/token_c",
|
|
"mailto://user:pass@smtp.example.com",
|
|
"https://hooks.example.com/webhook",
|
|
"http://hooks.example.com/webhook",
|
|
"telegram://bot_token/chat_id",
|
|
"gotify://gotify.example.com/token",
|
|
"pushover://user_key/token",
|
|
"ntfy://ntfy.sh/topic",
|
|
"ntfys://ntfy.sh/topic",
|
|
"json://hooks.example.com/endpoint",
|
|
"xml://hooks.example.com/endpoint",
|
|
"form://hooks.example.com/endpoint",
|
|
"matrix://user:pass@matrix.org/room",
|
|
"mattermost://hooks.example.com/token",
|
|
"rocketchat://hooks.example.com/token",
|
|
"teams://token_a/token_b/token_c",
|
|
],
|
|
)
|
|
def test_allowed_schemes_pass(self, url):
|
|
is_valid, err = NotificationURLValidator.validate_service_url(url)
|
|
assert is_valid is True, f"Expected valid for {url}, got error: {err}"
|
|
assert err is None
|
|
|
|
# -- Blocked schemes ---------------------------------------------------
|
|
|
|
@pytest.mark.parametrize(
|
|
"url",
|
|
[
|
|
"file:///etc/passwd",
|
|
"ftp://evil.com/payload",
|
|
"ftps://evil.com/payload",
|
|
"data:text/html,<script>alert(1)</script>",
|
|
"javascript:alert(1)",
|
|
"vbscript:msgbox",
|
|
"about:blank",
|
|
"blob:http://evil.com/obj",
|
|
],
|
|
)
|
|
def test_blocked_schemes_rejected(self, url):
|
|
is_valid, err = NotificationURLValidator.validate_service_url(url)
|
|
assert is_valid is False
|
|
assert err is not None
|
|
# The error should mention 'protocol' or 'Blocked'
|
|
assert "protocol" in err.lower() or "blocked" in err.lower()
|
|
|
|
# -- Unknown scheme ----------------------------------------------------
|
|
|
|
def test_unknown_scheme_rejected(self):
|
|
is_valid, err = NotificationURLValidator.validate_service_url(
|
|
"gopher://evil.com"
|
|
)
|
|
assert is_valid is False
|
|
assert "unsupported" in err.lower() or "protocol" in err.lower()
|
|
|
|
# -- Empty / invalid inputs --------------------------------------------
|
|
|
|
@pytest.mark.parametrize("url", ["", None, 123, " "])
|
|
def test_empty_or_invalid_input(self, url):
|
|
is_valid, err = NotificationURLValidator.validate_service_url(url)
|
|
assert is_valid is False
|
|
|
|
def test_url_without_scheme(self):
|
|
is_valid, err = NotificationURLValidator.validate_service_url(
|
|
"example.com/webhook"
|
|
)
|
|
assert is_valid is False
|
|
|
|
# -- Private IP blocking -----------------------------------------------
|
|
|
|
@pytest.mark.parametrize(
|
|
"url",
|
|
[
|
|
"http://localhost:5000/webhook",
|
|
"http://127.0.0.1:8080/hook",
|
|
"https://0.0.0.0/hook",
|
|
"http://[::1]/hook",
|
|
],
|
|
)
|
|
def test_private_ip_blocked_by_default(self, url):
|
|
is_valid, err = NotificationURLValidator.validate_service_url(url)
|
|
assert is_valid is False
|
|
assert "private" in err.lower() or "blocked" in err.lower()
|
|
|
|
@pytest.mark.parametrize(
|
|
"url",
|
|
[
|
|
"http://localhost:5000/webhook",
|
|
"http://127.0.0.1:8080/hook",
|
|
],
|
|
)
|
|
def test_private_ip_allowed_when_flag_set(self, url):
|
|
is_valid, err = NotificationURLValidator.validate_service_url(
|
|
url, allow_private_ips=True
|
|
)
|
|
assert is_valid is True
|
|
|
|
# -- Private IP detection helper ----------------------------------------
|
|
|
|
@pytest.mark.parametrize(
|
|
"hostname,expected",
|
|
[
|
|
("localhost", True),
|
|
("127.0.0.1", True),
|
|
("::1", True),
|
|
("0.0.0.0", True),
|
|
("::", True),
|
|
("10.0.0.1", True),
|
|
("172.16.0.1", True),
|
|
("192.168.1.1", True),
|
|
("8.8.8.8", False),
|
|
("hooks.slack.com", False),
|
|
],
|
|
)
|
|
def test_is_private_ip(self, hostname, expected):
|
|
assert NotificationURLValidator._is_private_ip(hostname) is expected
|
|
|
|
# -- validate_multiple_urls ---------------------------------------------
|
|
|
|
def test_validate_multiple_urls_all_valid(self):
|
|
urls = "discord://id/token, slack://a/b/c"
|
|
is_valid, err = NotificationURLValidator.validate_multiple_urls(urls)
|
|
assert is_valid is True
|
|
assert err is None
|
|
|
|
def test_validate_multiple_urls_one_invalid(self):
|
|
urls = "discord://id/token, file:///etc/passwd"
|
|
is_valid, err = NotificationURLValidator.validate_multiple_urls(urls)
|
|
assert is_valid is False
|
|
assert err is not None
|
|
|
|
def test_validate_multiple_urls_empty(self):
|
|
is_valid, err = NotificationURLValidator.validate_multiple_urls("")
|
|
assert is_valid is False
|
|
|
|
def test_validate_multiple_urls_only_whitespace(self):
|
|
is_valid, err = NotificationURLValidator.validate_multiple_urls(" , ")
|
|
assert is_valid is False
|
|
|
|
# -- validate_service_url_strict ----------------------------------------
|
|
|
|
def test_strict_validation_raises_on_invalid(self):
|
|
with pytest.raises(NotificationURLValidationError):
|
|
NotificationURLValidator.validate_service_url_strict(
|
|
"file:///etc/passwd"
|
|
)
|
|
|
|
def test_strict_validation_returns_true_on_valid(self):
|
|
assert (
|
|
NotificationURLValidator.validate_service_url_strict(
|
|
"discord://id/token"
|
|
)
|
|
is True
|
|
)
|
|
|
|
|
|
# ===================================================================
|
|
# NotificationService
|
|
# ===================================================================
|
|
|
|
|
|
class TestNotificationService:
|
|
"""Tests for the low-level notification service wrapping Apprise."""
|
|
|
|
def test_init_defaults(self):
|
|
svc = NotificationService(outbound_allowed=True)
|
|
assert svc.allow_private_ips is False
|
|
|
|
def test_init_allow_private_ips(self):
|
|
svc = NotificationService(allow_private_ips=True, outbound_allowed=True)
|
|
assert svc.allow_private_ips is True
|
|
|
|
# -- _validate_url (static) -------------------------------------------
|
|
|
|
def test_validate_url_rejects_empty(self):
|
|
with pytest.raises(ServiceError, match="non-empty"):
|
|
NotificationService._validate_url("")
|
|
|
|
def test_validate_url_rejects_none(self):
|
|
with pytest.raises(ServiceError, match="non-empty"):
|
|
NotificationService._validate_url(None)
|
|
|
|
def test_validate_url_rejects_no_scheme(self):
|
|
with pytest.raises(ServiceError, match="Invalid URL"):
|
|
NotificationService._validate_url("example.com/webhook")
|
|
|
|
def test_validate_url_accepts_valid(self):
|
|
# Should not raise
|
|
NotificationService._validate_url("discord://id/token")
|
|
|
|
# -- get_service_type --------------------------------------------------
|
|
|
|
def test_get_service_type_known(self):
|
|
svc = NotificationService(outbound_allowed=True)
|
|
assert svc.get_service_type("mailto://user@example.com") == "email"
|
|
assert svc.get_service_type("discord://id/token") == "discord"
|
|
assert svc.get_service_type("slack://a/b/c") == "slack"
|
|
assert svc.get_service_type("tgram://bot_token/chat_id") == "telegram"
|
|
assert svc.get_service_type("smtp://mail.example.com") == "smtp"
|
|
assert svc.get_service_type("smtps://mail.example.com") == "smtp"
|
|
|
|
def test_get_service_type_unknown(self):
|
|
svc = NotificationService(outbound_allowed=True)
|
|
assert svc.get_service_type("https://hooks.example.com") == "unknown"
|
|
|
|
# -- send (mocked Apprise) ---------------------------------------------
|
|
|
|
@patch("local_deep_research.notifications.service.NotificationURLValidator")
|
|
@patch("local_deep_research.notifications.service.apprise.Apprise")
|
|
def test_send_with_service_urls_success(self, MockApprise, MockValidator):
|
|
MockValidator.validate_multiple_urls.return_value = (True, None)
|
|
mock_instance = MockApprise.return_value
|
|
mock_instance.add.return_value = True
|
|
mock_instance.notify.return_value = True
|
|
|
|
svc = NotificationService(outbound_allowed=True)
|
|
result = svc.send(
|
|
title="Test",
|
|
body="Body",
|
|
service_urls="discord://id/token",
|
|
)
|
|
assert result is True
|
|
mock_instance.notify.assert_called_once()
|
|
|
|
@patch("local_deep_research.notifications.service.NotificationURLValidator")
|
|
@patch("local_deep_research.notifications.service.apprise.Apprise")
|
|
def test_send_with_invalid_url_raises_service_error(
|
|
self, MockApprise, MockValidator
|
|
):
|
|
MockValidator.validate_multiple_urls.return_value = (
|
|
False,
|
|
"bad url",
|
|
)
|
|
|
|
svc = NotificationService(outbound_allowed=True)
|
|
with pytest.raises(ServiceError):
|
|
svc.send(
|
|
title="Test",
|
|
body="Body",
|
|
service_urls="file:///etc/passwd",
|
|
)
|
|
|
|
@patch("local_deep_research.notifications.service.NotificationURLValidator")
|
|
@patch("local_deep_research.notifications.service.apprise.Apprise")
|
|
def test_send_apprise_add_failure_returns_false(
|
|
self, MockApprise, MockValidator
|
|
):
|
|
MockValidator.validate_multiple_urls.return_value = (True, None)
|
|
mock_instance = MockApprise.return_value
|
|
mock_instance.add.return_value = False
|
|
|
|
svc = NotificationService(outbound_allowed=True)
|
|
# When add() returns False the original apprise instance is used,
|
|
# but the temp one is used here so it returns False immediately.
|
|
result = svc.send(
|
|
title="Test", body="Body", service_urls="discord://id/token"
|
|
)
|
|
assert result is False
|
|
|
|
def test_send_no_urls_no_configured_services_returns_false(self):
|
|
svc = NotificationService(outbound_allowed=True)
|
|
result = svc.send(title="Test", body="Body")
|
|
assert result is False
|
|
|
|
# -- send_event --------------------------------------------------------
|
|
|
|
@patch.object(NotificationService, "send", return_value=True)
|
|
def test_send_event_uses_template(self, mock_send):
|
|
svc = NotificationService(outbound_allowed=True)
|
|
result = svc.send_event(
|
|
event_type=EventType.RESEARCH_COMPLETED,
|
|
context={"query": "test query", "research_id": "abc123"},
|
|
service_urls="discord://id/token",
|
|
)
|
|
assert result is True
|
|
mock_send.assert_called_once()
|
|
call_kwargs = mock_send.call_args
|
|
# The title and body should come from template formatting
|
|
assert call_kwargs[1]["service_urls"] == "discord://id/token"
|
|
|
|
# -- test_service (mocked Apprise) ------------------------------------
|
|
|
|
@patch("local_deep_research.notifications.service.NotificationURLValidator")
|
|
@patch("local_deep_research.notifications.service.apprise.Apprise")
|
|
def test_test_service_success(self, MockApprise, MockValidator):
|
|
MockValidator.validate_service_url.return_value = (True, None)
|
|
mock_instance = MockApprise.return_value
|
|
mock_instance.add.return_value = True
|
|
mock_instance.notify.return_value = True
|
|
|
|
svc = NotificationService(outbound_allowed=True)
|
|
result = svc.test_service("discord://id/token")
|
|
assert result["success"] is True
|
|
|
|
@patch("local_deep_research.notifications.service.NotificationURLValidator")
|
|
def test_test_service_invalid_url(self, MockValidator):
|
|
MockValidator.validate_service_url.return_value = (False, "blocked")
|
|
|
|
svc = NotificationService(outbound_allowed=True)
|
|
result = svc.test_service("file:///etc/passwd")
|
|
assert result["success"] is False
|
|
assert "error" in result
|
|
|
|
@patch("local_deep_research.notifications.service.NotificationURLValidator")
|
|
def test_test_service_invalid_url_surfaces_validator_reason(
|
|
self, MockValidator
|
|
):
|
|
# Validator's specific reason (e.g. "Blocked unsafe protocol: file")
|
|
# must reach the user so they can fix it instead of seeing a
|
|
# generic "Invalid notification service URL." Without this, an
|
|
# IPv6-only operator hitting the NAT64 block has no signal at
|
|
# all that there's a server-side opt-in to flip.
|
|
MockValidator.validate_service_url.return_value = (
|
|
False,
|
|
"Blocked unsafe protocol: file",
|
|
)
|
|
|
|
svc = NotificationService(outbound_allowed=True)
|
|
result = svc.test_service("file:///etc/passwd")
|
|
assert result["success"] is False
|
|
assert "Blocked unsafe protocol: file" in result["error"]
|
|
|
|
# ------------------------------------------------------------------
|
|
# IP rejection matrix — single source of truth for which IPs trigger
|
|
# which user-visible behavior. Add new IP categories here rather
|
|
# than scattering fixtures across multiple tests. End-to-end against
|
|
# the REAL NotificationURLValidator (no mock) so validator wording
|
|
# drift fails here too — see NotificationURLValidator.validate_service_url
|
|
# docstring for the contract.
|
|
# ------------------------------------------------------------------
|
|
|
|
@pytest.mark.parametrize(
|
|
"url,expect_hint",
|
|
[
|
|
# --- Always-blocked: admin hint MUST be suppressed ---
|
|
# (none of these are unblockable via LDR_NOTIFICATIONS_ALLOW_PRIVATE_IPS
|
|
# or LDR_SECURITY_ALLOW_NAT64, so naming those env vars
|
|
# would mislead the user.)
|
|
# Cloud-metadata IPv4 (AWS/Azure/OCI/DO IMDS, ECS, Tencent, Alibaba)
|
|
pytest.param(
|
|
"http://169.254.169.254/latest/meta-data/", False, id="aws-imds"
|
|
),
|
|
pytest.param("http://169.254.170.2/", False, id="aws-ecs-v3"),
|
|
pytest.param("http://169.254.170.23/", False, id="aws-ecs-v4"),
|
|
pytest.param("http://169.254.0.23/", False, id="tencent-metadata"),
|
|
pytest.param(
|
|
"http://100.100.100.200/", False, id="alibaba-metadata"
|
|
),
|
|
# NAT64-wrapped metadata
|
|
pytest.param(
|
|
"http://[64:ff9b::a9fe:a9fe]/",
|
|
False,
|
|
id="nat64-rfc6052-wrap-of-imds",
|
|
),
|
|
pytest.param(
|
|
"http://[64:ff9b:1::a9fe:a9fe]/",
|
|
False,
|
|
id="nat64-rfc8215-wrap-of-imds",
|
|
),
|
|
# IPv6 transition prefixes (non-NAT64, always blocked)
|
|
pytest.param(
|
|
"http://[2002:7f00:1::]/",
|
|
False,
|
|
id="6to4-wrap-of-loopback",
|
|
),
|
|
pytest.param("http://[2001::]/", False, id="teredo-range"),
|
|
pytest.param("http://[100::]/", False, id="discard-prefix"),
|
|
pytest.param(
|
|
"http://[::ffff:169.254.169.254]/",
|
|
False,
|
|
id="ipv4-mapped-ipv6-of-imds",
|
|
),
|
|
# Plugin-scheme metadata (different validator prefix)
|
|
pytest.param(
|
|
"signal://169.254.169.254:8080/path",
|
|
False,
|
|
id="plugin-scheme-signal-to-imds",
|
|
),
|
|
pytest.param(
|
|
"discord://169.254.169.254/path",
|
|
False,
|
|
id="plugin-scheme-discord-to-imds",
|
|
),
|
|
# --- Recoverable: admin hint MUST appear ---
|
|
# (LDR_NOTIFICATIONS_ALLOW_PRIVATE_IPS=true would actually
|
|
# unblock these, so the hint is actionable.)
|
|
pytest.param("http://127.0.0.1/", True, id="ipv4-loopback"),
|
|
pytest.param("http://10.0.0.1/", True, id="rfc1918-10"),
|
|
pytest.param("http://172.16.0.1/", True, id="rfc1918-172-16"),
|
|
pytest.param("http://192.168.1.1/", True, id="rfc1918-192-168"),
|
|
pytest.param("http://100.64.0.1/", True, id="cgnat-100-64"),
|
|
pytest.param("http://[::1]/", True, id="ipv6-loopback"),
|
|
pytest.param("http://[fc00::1]/", True, id="ula-fc00"),
|
|
pytest.param("http://[fe80::1]/", True, id="link-local-fe80"),
|
|
pytest.param("http://localhost/", True, id="localhost-dns"),
|
|
# NAT64-wrapped NON-metadata IPv4 — recoverable ONLY via
|
|
# LDR_SECURITY_ALLOW_NAT64=true (the private-IPs flag cannot
|
|
# unblock the NAT64 prefixes). 64:ff9b::5db8:d822 and the
|
|
# RFC8215 64:ff9b:1:: variant both wrap 93.184.216.34, a public
|
|
# IPv4. Guards against the NAT64 hint silently going dead — the
|
|
# exact IPv6-only/NAT64 deployment the hint exists to help.
|
|
pytest.param(
|
|
"http://[64:ff9b::5db8:d822]/",
|
|
True,
|
|
id="nat64-rfc6052-wrap-of-public",
|
|
),
|
|
pytest.param(
|
|
"http://[64:ff9b:1::5db8:d822]/",
|
|
True,
|
|
id="nat64-rfc8215-wrap-of-public",
|
|
),
|
|
],
|
|
)
|
|
def test_test_service_ip_rejection_matrix(self, url, expect_hint):
|
|
"""End-to-end against the REAL validator: pin the user-visible
|
|
behavior for every IP category. If ``expect_hint`` is True, the
|
|
URL targets a recoverable destination that
|
|
LDR_NOTIFICATIONS_ALLOW_PRIVATE_IPS=true (private IPs) or
|
|
LDR_SECURITY_ALLOW_NAT64=true (NAT64-wrapped non-metadata IPv4)
|
|
would unblock, so the admin hint MUST appear. If False, the URL
|
|
targets an
|
|
always-blocked category (cloud-metadata, 6to4, Teredo, discard,
|
|
IPv4-mapped IPv6 of metadata, NAT64-wrapped metadata,
|
|
plugin-scheme metadata) and the hint MUST be suppressed — the
|
|
env var cannot help.
|
|
|
|
Single source of truth: add new IP categories to this matrix
|
|
rather than scattering fixtures across multiple tests. See
|
|
NotificationURLValidator.validate_service_url docstring.
|
|
"""
|
|
svc = NotificationService(outbound_allowed=True)
|
|
result = svc.test_service(url)
|
|
assert result["success"] is False, f"Expected block for {url}"
|
|
assert result["error"], f"Empty error for {url}"
|
|
|
|
if expect_hint:
|
|
assert "LDR_NOTIFICATIONS_ALLOW_PRIVATE_IPS" in result["error"], (
|
|
f"Expected admin hint for {url}, got: {result['error']!r}"
|
|
)
|
|
assert "LDR_SECURITY_ALLOW_NAT64" in result["error"], (
|
|
f"Expected NAT64 hint for {url}, got: {result['error']!r}"
|
|
)
|
|
else:
|
|
assert (
|
|
"LDR_NOTIFICATIONS_ALLOW_PRIVATE_IPS" not in result["error"]
|
|
), f"Hint should be suppressed for {url}, got: {result['error']!r}"
|
|
assert "LDR_SECURITY_ALLOW_NAT64" not in result["error"], (
|
|
f"NAT64 hint should be suppressed for {url}, "
|
|
f"got: {result['error']!r}"
|
|
)
|
|
|
|
@patch("local_deep_research.notifications.service.NotificationURLValidator")
|
|
@patch("local_deep_research.notifications.service.apprise.Apprise")
|
|
def test_test_service_add_failure(self, MockApprise, MockValidator):
|
|
MockValidator.validate_service_url.return_value = (True, None)
|
|
mock_instance = MockApprise.return_value
|
|
mock_instance.add.return_value = False
|
|
|
|
svc = NotificationService(outbound_allowed=True)
|
|
result = svc.test_service("discord://id/token")
|
|
assert result["success"] is False
|
|
assert "Failed to add" in result["error"]
|
|
|
|
@patch("local_deep_research.notifications.service.NotificationURLValidator")
|
|
@patch("local_deep_research.notifications.service.apprise.Apprise")
|
|
def test_test_service_notify_failure(self, MockApprise, MockValidator):
|
|
MockValidator.validate_service_url.return_value = (True, None)
|
|
mock_instance = MockApprise.return_value
|
|
mock_instance.add.return_value = True
|
|
mock_instance.notify.return_value = False
|
|
|
|
svc = NotificationService(outbound_allowed=True)
|
|
result = svc.test_service("discord://id/token")
|
|
assert result["success"] is False
|
|
assert "Failed to send" in result["error"]
|
|
|
|
@patch("local_deep_research.notifications.service.NotificationURLValidator")
|
|
def test_test_service_exception(self, MockValidator):
|
|
MockValidator.validate_service_url.side_effect = RuntimeError("boom")
|
|
|
|
svc = NotificationService(outbound_allowed=True)
|
|
result = svc.test_service("discord://id/token")
|
|
assert result["success"] is False
|
|
|
|
@patch("local_deep_research.notifications.service.NotificationURLValidator")
|
|
def test_test_service_empty_error_msg_uses_fallback(self, MockValidator):
|
|
# Defensive fallback: if the validator ever returned (False, "")
|
|
# (today it never does — every False branch sets a reason), the
|
|
# user still sees an actionable message rather than an empty
|
|
# string. Pins the `error_msg or "Invalid..."` guard.
|
|
MockValidator.validate_service_url.return_value = (False, "")
|
|
|
|
svc = NotificationService(outbound_allowed=True)
|
|
result = svc.test_service("https://example.com/hook")
|
|
assert result["success"] is False
|
|
assert result["error"] == "Invalid notification service URL."
|
|
|
|
def test_test_service_master_switch_off_short_circuits(self):
|
|
# When LDR_NOTIFICATIONS_ALLOW_OUTBOUND=False (the default),
|
|
# test_service must short-circuit BEFORE the URL is inspected by
|
|
# the validator. Pins the operator master-switch gate so a
|
|
# future refactor cannot accidentally route around it.
|
|
svc = NotificationService() # outbound_allowed defaults to False
|
|
result = svc.test_service("http://10.0.0.1/hook")
|
|
assert result["success"] is False
|
|
assert "LDR_NOTIFICATIONS_ALLOW_OUTBOUND" in result["error"]
|
|
# The URL itself was never inspected, so its details must not
|
|
# leak into the user-facing message:
|
|
assert "10.0.0.1" not in result["error"]
|
|
|
|
# -- SERVICE_PATTERNS --------------------------------------------------
|
|
|
|
def test_service_patterns_are_valid_regexes(self):
|
|
import re
|
|
|
|
for name, pattern in NotificationService.SERVICE_PATTERNS.items():
|
|
re.compile(pattern) # Should not raise
|
|
|
|
|
|
# ===================================================================
|
|
# RateLimiter
|
|
# ===================================================================
|
|
|
|
|
|
class TestRateLimiter:
|
|
"""Tests for the in-memory per-user rate limiter."""
|
|
|
|
def test_basic_allow(self):
|
|
rl = RateLimiter(max_per_hour=5, max_per_day=20)
|
|
assert rl.is_allowed("user1") is True
|
|
|
|
def test_hourly_limit_exceeded(self):
|
|
rl = RateLimiter(max_per_hour=3, max_per_day=100)
|
|
for _ in range(3):
|
|
assert rl.is_allowed("user1") is True
|
|
assert rl.is_allowed("user1") is False
|
|
|
|
def test_daily_limit_exceeded(self):
|
|
rl = RateLimiter(max_per_hour=100, max_per_day=3)
|
|
for _ in range(3):
|
|
assert rl.is_allowed("user1") is True
|
|
assert rl.is_allowed("user1") is False
|
|
|
|
def test_per_user_isolation(self):
|
|
rl = RateLimiter(max_per_hour=2, max_per_day=100)
|
|
# Exhaust user1's hourly limit
|
|
rl.is_allowed("user1")
|
|
rl.is_allowed("user1")
|
|
assert rl.is_allowed("user1") is False
|
|
# user2 should still be allowed
|
|
assert rl.is_allowed("user2") is True
|
|
|
|
def test_set_user_limits(self):
|
|
rl = RateLimiter(max_per_hour=10, max_per_day=50)
|
|
rl.set_user_limits("user1", max_per_hour=2, max_per_day=5)
|
|
|
|
limits = rl.get_user_limits("user1")
|
|
assert limits == (2, 5)
|
|
|
|
# User with custom limits
|
|
rl.is_allowed("user1")
|
|
rl.is_allowed("user1")
|
|
assert rl.is_allowed("user1") is False
|
|
|
|
# Default user still has 10/hour
|
|
for _ in range(10):
|
|
assert rl.is_allowed("default_user") is True
|
|
assert rl.is_allowed("default_user") is False
|
|
|
|
def test_get_user_limits_defaults(self):
|
|
rl = RateLimiter(max_per_hour=10, max_per_day=50)
|
|
assert rl.get_user_limits("unknown") == (10, 50)
|
|
|
|
def test_reset_single_user(self):
|
|
rl = RateLimiter(max_per_hour=2, max_per_day=100)
|
|
rl.is_allowed("user1")
|
|
rl.is_allowed("user1")
|
|
assert rl.is_allowed("user1") is False
|
|
|
|
rl.reset("user1")
|
|
assert rl.is_allowed("user1") is True
|
|
|
|
def test_reset_all_users(self):
|
|
rl = RateLimiter(max_per_hour=1, max_per_day=100)
|
|
rl.is_allowed("user1")
|
|
rl.is_allowed("user2")
|
|
assert rl.is_allowed("user1") is False
|
|
assert rl.is_allowed("user2") is False
|
|
|
|
rl.reset()
|
|
assert rl.is_allowed("user1") is True
|
|
assert rl.is_allowed("user2") is True
|
|
|
|
def test_old_entries_cleaned(self):
|
|
rl = RateLimiter(max_per_hour=2, max_per_day=100)
|
|
now = datetime.now(timezone.utc)
|
|
old = now - timedelta(hours=2)
|
|
|
|
# Manually inject old entries
|
|
rl._hourly_counts["user1"] = deque([old, old])
|
|
rl._daily_counts["user1"] = deque([old, old])
|
|
|
|
# After cleaning old entries, the user should be allowed again
|
|
assert rl.is_allowed("user1") is True
|
|
|
|
def test_cleanup_inactive_users(self):
|
|
rl = RateLimiter(
|
|
max_per_hour=10, max_per_day=50, cleanup_interval_hours=0
|
|
)
|
|
now = datetime.now(timezone.utc)
|
|
old = now - timedelta(days=8)
|
|
|
|
# Force a cleanup by setting last cleanup to long ago
|
|
rl._last_cleanup = now - timedelta(hours=25)
|
|
rl._hourly_counts["stale_user"] = deque([old])
|
|
rl._daily_counts["stale_user"] = deque([old])
|
|
|
|
# Trigger cleanup via is_allowed
|
|
rl.is_allowed("active_user")
|
|
|
|
assert "stale_user" not in rl._hourly_counts
|
|
assert "stale_user" not in rl._daily_counts
|
|
|
|
def test_thread_safety(self):
|
|
"""Multiple threads hitting the rate limiter concurrently."""
|
|
rl = RateLimiter(max_per_hour=100, max_per_day=1000)
|
|
results = []
|
|
errors = []
|
|
|
|
def hit():
|
|
try:
|
|
for _ in range(50):
|
|
rl.is_allowed("concurrent_user")
|
|
results.append(True)
|
|
except Exception as e:
|
|
errors.append(e)
|
|
|
|
threads = [threading.Thread(target=hit) for _ in range(4)]
|
|
for t in threads:
|
|
t.start()
|
|
for t in threads:
|
|
t.join(timeout=10)
|
|
|
|
assert len(errors) == 0
|
|
assert len(results) == 4
|
|
|
|
|
|
# ===================================================================
|
|
# NotificationManager
|
|
# ===================================================================
|
|
|
|
|
|
class TestNotificationManager:
|
|
"""Tests for the high-level notification manager."""
|
|
|
|
# Module-level autouse fixture _enable_outbound_notifications opens
|
|
# the operator-level env gate for all tests in this file.
|
|
|
|
def _make_manager(self, settings, user_id="test_user"):
|
|
return NotificationManager(settings_snapshot=settings, user_id=user_id)
|
|
|
|
# -- Initialization ---------------------------------------------------
|
|
|
|
def test_init_creates_shared_rate_limiter(self, default_settings):
|
|
mgr = self._make_manager(default_settings)
|
|
assert NotificationManager._shared_rate_limiter is not None
|
|
assert mgr._rate_limiter is NotificationManager._shared_rate_limiter
|
|
|
|
def test_init_reuses_shared_rate_limiter(self, default_settings):
|
|
mgr1 = self._make_manager(default_settings, "user_a")
|
|
mgr2 = self._make_manager(default_settings, "user_b")
|
|
assert mgr1._rate_limiter is mgr2._rate_limiter
|
|
|
|
def test_init_configures_per_user_limits(self, default_settings):
|
|
mgr = self._make_manager(default_settings, "user_a")
|
|
limits = mgr._rate_limiter.get_user_limits("user_a")
|
|
assert limits == (10, 50)
|
|
|
|
# -- _get_setting ------------------------------------------------------
|
|
|
|
def test_get_setting_returns_value(self, default_settings):
|
|
mgr = self._make_manager(default_settings)
|
|
assert mgr._get_setting("notifications.rate_limit_per_hour") == 10
|
|
|
|
def test_get_setting_returns_default(self, default_settings):
|
|
mgr = self._make_manager(default_settings)
|
|
assert (
|
|
mgr._get_setting("nonexistent.key", default="fallback")
|
|
== "fallback"
|
|
)
|
|
|
|
# -- _should_notify ----------------------------------------------------
|
|
|
|
def test_should_notify_enabled(self, default_settings):
|
|
mgr = self._make_manager(default_settings)
|
|
assert mgr._should_notify(EventType.RESEARCH_COMPLETED) is True
|
|
|
|
def test_should_notify_disabled(self, disabled_settings):
|
|
mgr = self._make_manager(disabled_settings)
|
|
assert mgr._should_notify(EventType.RESEARCH_COMPLETED) is False
|
|
|
|
def test_should_notify_missing_key(self, default_settings):
|
|
mgr = self._make_manager(default_settings)
|
|
# EventType.TEST is not in settings, should default to False
|
|
assert mgr._should_notify(EventType.TEST) is False
|
|
|
|
# -- send_notification -------------------------------------------------
|
|
|
|
@patch.object(NotificationService, "send_event", return_value=True)
|
|
def test_send_notification_success(self, mock_send_event, default_settings):
|
|
mgr = self._make_manager(default_settings)
|
|
result = mgr.send_notification(
|
|
EventType.RESEARCH_COMPLETED,
|
|
{"query": "test", "research_id": "123"},
|
|
)
|
|
assert result is True
|
|
mock_send_event.assert_called_once()
|
|
|
|
def test_send_notification_disabled_returns_false(self, disabled_settings):
|
|
mgr = self._make_manager(disabled_settings)
|
|
result = mgr.send_notification(
|
|
EventType.RESEARCH_COMPLETED,
|
|
{"query": "test"},
|
|
)
|
|
assert result is False
|
|
|
|
def test_send_notification_no_service_url_returns_false(self):
|
|
settings = {
|
|
"notifications.service_url": "",
|
|
"notifications.on_research_completed": True,
|
|
"notifications.rate_limit_per_hour": 10,
|
|
"notifications.rate_limit_per_day": 50,
|
|
}
|
|
mgr = self._make_manager(settings)
|
|
result = mgr.send_notification(
|
|
EventType.RESEARCH_COMPLETED,
|
|
{"query": "test"},
|
|
)
|
|
assert result is False
|
|
|
|
def test_send_notification_rate_limited(self):
|
|
settings = {
|
|
"notifications.service_url": "discord://id/token",
|
|
"notifications.on_research_completed": True,
|
|
"notifications.rate_limit_per_hour": 1,
|
|
"notifications.rate_limit_per_day": 50,
|
|
}
|
|
mgr = self._make_manager(settings, "rate_test_user")
|
|
|
|
with patch.object(NotificationService, "send_event", return_value=True):
|
|
mgr.send_notification(
|
|
EventType.RESEARCH_COMPLETED,
|
|
{"query": "first"},
|
|
)
|
|
|
|
with pytest.raises(RateLimitError):
|
|
mgr.send_notification(
|
|
EventType.RESEARCH_COMPLETED,
|
|
{"query": "second"},
|
|
)
|
|
|
|
@patch.object(NotificationService, "send_event", return_value=True)
|
|
def test_send_notification_force_bypasses_rate_limit(self, mock_send):
|
|
settings = {
|
|
"notifications.service_url": "discord://id/token",
|
|
"notifications.on_research_completed": True,
|
|
"notifications.rate_limit_per_hour": 1,
|
|
"notifications.rate_limit_per_day": 50,
|
|
}
|
|
mgr = self._make_manager(settings, "force_user")
|
|
|
|
mgr.send_notification(EventType.RESEARCH_COMPLETED, {"query": "first"})
|
|
# Would normally be rate-limited, but force=True
|
|
result = mgr.send_notification(
|
|
EventType.RESEARCH_COMPLETED, {"query": "second"}, force=True
|
|
)
|
|
assert result is True
|
|
|
|
@patch.object(NotificationService, "send_event", return_value=True)
|
|
def test_send_notification_force_bypasses_disabled(self, mock_send):
|
|
settings = {
|
|
"notifications.service_url": "discord://id/token",
|
|
"notifications.on_research_completed": False,
|
|
"notifications.rate_limit_per_hour": 10,
|
|
"notifications.rate_limit_per_day": 50,
|
|
}
|
|
mgr = self._make_manager(settings, "force_user2")
|
|
result = mgr.send_notification(
|
|
EventType.RESEARCH_COMPLETED, {"query": "forced"}, force=True
|
|
)
|
|
assert result is True
|
|
|
|
@patch.object(
|
|
NotificationService, "send_event", side_effect=Exception("boom")
|
|
)
|
|
def test_send_notification_exception_returns_false(
|
|
self, mock_send, default_settings
|
|
):
|
|
mgr = self._make_manager(default_settings)
|
|
result = mgr.send_notification(
|
|
EventType.RESEARCH_COMPLETED,
|
|
{"query": "test"},
|
|
)
|
|
assert result is False
|
|
|
|
# -- test_service via manager ------------------------------------------
|
|
|
|
@patch.object(
|
|
NotificationService, "test_service", return_value={"success": True}
|
|
)
|
|
def test_manager_test_service_delegates(self, mock_test, default_settings):
|
|
mgr = self._make_manager(default_settings)
|
|
result = mgr.test_service("discord://id/token")
|
|
assert result["success"] is True
|
|
mock_test.assert_called_once_with("discord://id/token")
|
|
|
|
# -- _log_notification -------------------------------------------------
|
|
|
|
def test_log_notification_with_query(self, default_settings):
|
|
mgr = self._make_manager(default_settings)
|
|
# Should not raise
|
|
mgr._log_notification(
|
|
EventType.RESEARCH_COMPLETED,
|
|
{"query": "test query"},
|
|
)
|
|
|
|
def test_log_notification_with_subscription(self, default_settings):
|
|
mgr = self._make_manager(default_settings)
|
|
mgr._log_notification(
|
|
EventType.SUBSCRIPTION_UPDATE,
|
|
{"subscription_name": "my sub"},
|
|
)
|
|
|
|
def test_log_notification_empty_context(self, default_settings):
|
|
mgr = self._make_manager(default_settings)
|
|
mgr._log_notification(EventType.TEST, {})
|
|
|
|
|
|
# ===================================================================
|
|
# NotificationTemplate
|
|
# ===================================================================
|
|
|
|
|
|
class TestNotificationTemplate:
|
|
"""Tests for Jinja2-based notification templates."""
|
|
|
|
def test_format_returns_title_and_body(self):
|
|
result = NotificationTemplate.format(
|
|
EventType.RESEARCH_COMPLETED,
|
|
{
|
|
"query": "quantum computing",
|
|
"research_id": "abc",
|
|
"summary": "results",
|
|
"url": "http://example.com",
|
|
},
|
|
)
|
|
assert "title" in result
|
|
assert "body" in result
|
|
assert isinstance(result["title"], str)
|
|
assert isinstance(result["body"], str)
|
|
|
|
def test_format_with_custom_template(self):
|
|
custom = {
|
|
"title": "Custom: {query}",
|
|
"body": "Research {research_id} is done.",
|
|
}
|
|
result = NotificationTemplate.format(
|
|
EventType.RESEARCH_COMPLETED,
|
|
{"query": "test", "research_id": "123"},
|
|
custom_template=custom,
|
|
)
|
|
assert result["title"] == "Custom: test"
|
|
assert result["body"] == "Research 123 is done."
|
|
|
|
def test_format_custom_template_missing_var(self):
|
|
custom = {
|
|
"title": "{missing_var}",
|
|
"body": "Body",
|
|
}
|
|
result = NotificationTemplate.format(
|
|
EventType.RESEARCH_COMPLETED,
|
|
{},
|
|
custom_template=custom,
|
|
)
|
|
assert (
|
|
"Missing variable" in result["body"]
|
|
or "error" in result["body"].lower()
|
|
)
|
|
|
|
def test_format_custom_template_sanitizes_context(self):
|
|
"""Ensure format string attacks are prevented."""
|
|
custom = {
|
|
"title": "{query}",
|
|
"body": "Body",
|
|
}
|
|
result = NotificationTemplate.format(
|
|
EventType.RESEARCH_COMPLETED,
|
|
{"query": "{__class__.__mro__}"},
|
|
custom_template=custom,
|
|
)
|
|
# The attack payload should be treated as a plain string
|
|
assert "__class__" in result["title"]
|
|
|
|
def test_fallback_template(self):
|
|
result = NotificationTemplate._get_fallback_template(
|
|
EventType.RESEARCH_COMPLETED,
|
|
{"query": "test"},
|
|
)
|
|
assert "title" in result
|
|
assert "body" in result
|
|
assert "Research Completed" in result["title"]
|
|
|
|
def test_event_type_values(self):
|
|
"""All EventType values are strings."""
|
|
for et in EventType:
|
|
assert isinstance(et.value, str)
|
|
|
|
def test_get_required_context_returns_list(self):
|
|
result = NotificationTemplate.get_required_context(
|
|
EventType.RESEARCH_COMPLETED
|
|
)
|
|
assert isinstance(result, list)
|
|
|
|
def test_format_unknown_event_type(self):
|
|
"""Event type without a template file should produce a generic message."""
|
|
# RATE_LIMIT_WARNING has no template file in TEMPLATE_FILES
|
|
result = NotificationTemplate.format(
|
|
EventType.RATE_LIMIT_WARNING,
|
|
{"detail": "too many requests"},
|
|
)
|
|
assert "title" in result
|
|
assert "body" in result
|
|
|
|
|
|
# ===================================================================
|
|
# build_notification_url
|
|
# ===================================================================
|
|
|
|
|
|
class TestBuildNotificationUrl:
|
|
"""Tests for notification callback URL building."""
|
|
|
|
def test_with_external_url(self):
|
|
snapshot = {"app.external_url": "https://myapp.example.com"}
|
|
url = build_notification_url(
|
|
"/research/123",
|
|
settings_snapshot=snapshot,
|
|
)
|
|
assert url == "https://myapp.example.com/research/123"
|
|
|
|
def test_with_host_and_port(self):
|
|
snapshot = {
|
|
"app.external_url": "",
|
|
"app.host": "0.0.0.0",
|
|
"app.port": 5000,
|
|
}
|
|
url = build_notification_url(
|
|
"/research/456",
|
|
settings_snapshot=snapshot,
|
|
)
|
|
assert url == "http://localhost:5000/research/456"
|
|
|
|
def test_fallback_url(self):
|
|
url = build_notification_url(
|
|
"/research/789",
|
|
settings_snapshot={},
|
|
fallback_base="http://localhost:5000",
|
|
)
|
|
assert url == "http://localhost:5000/research/789"
|
|
|
|
def test_validation_disabled(self):
|
|
url = build_notification_url(
|
|
"/research/abc",
|
|
settings_snapshot={},
|
|
validate=False,
|
|
)
|
|
assert "/research/abc" in url
|
|
|
|
def test_validation_catches_bad_url(self):
|
|
with pytest.raises(URLValidationError):
|
|
build_notification_url(
|
|
"/research/123",
|
|
settings_snapshot={"app.external_url": "ftp://bad.example.com"},
|
|
validate=True,
|
|
)
|
|
|
|
|
|
# ===================================================================
|
|
# Queue helpers
|
|
# ===================================================================
|
|
|
|
|
|
class TestQueueHelpers:
|
|
"""Tests for queue notification helper functions."""
|
|
|
|
@patch(
|
|
"local_deep_research.notifications.queue_helpers.NotificationManager"
|
|
)
|
|
def test_send_queue_notification_success(self, MockManager):
|
|
mock_instance = MockManager.return_value
|
|
mock_instance.send_notification.return_value = True
|
|
|
|
settings = {
|
|
"notifications.rate_limit_per_hour": 10,
|
|
"notifications.rate_limit_per_day": 50,
|
|
}
|
|
result = send_queue_notification(
|
|
username="alice",
|
|
research_id="r1",
|
|
query="test query",
|
|
settings_snapshot=settings,
|
|
position=3,
|
|
)
|
|
assert result is True
|
|
mock_instance.send_notification.assert_called_once()
|
|
|
|
@patch(
|
|
"local_deep_research.notifications.queue_helpers.NotificationManager"
|
|
)
|
|
def test_send_queue_notification_rate_limited(self, MockManager):
|
|
mock_instance = MockManager.return_value
|
|
mock_instance.send_notification.side_effect = RateLimitError("limited")
|
|
|
|
result = send_queue_notification(
|
|
username="alice",
|
|
research_id="r1",
|
|
query="test",
|
|
settings_snapshot={},
|
|
)
|
|
assert result is False
|
|
|
|
@patch(
|
|
"local_deep_research.notifications.queue_helpers.NotificationManager"
|
|
)
|
|
def test_send_queue_notification_exception(self, MockManager):
|
|
MockManager.side_effect = Exception("boom")
|
|
|
|
result = send_queue_notification(
|
|
username="alice",
|
|
research_id="r1",
|
|
query="test",
|
|
settings_snapshot={},
|
|
)
|
|
assert result is False
|
|
|
|
def test_send_queue_failed_no_snapshot(self):
|
|
result = send_queue_failed_notification(
|
|
username="alice",
|
|
research_id="r1",
|
|
query="test",
|
|
settings_snapshot=None,
|
|
)
|
|
assert result is False
|
|
|
|
@patch(
|
|
"local_deep_research.notifications.queue_helpers.NotificationManager"
|
|
)
|
|
def test_send_queue_failed_success(self, MockManager):
|
|
mock_instance = MockManager.return_value
|
|
mock_instance.send_notification.return_value = True
|
|
|
|
result = send_queue_failed_notification(
|
|
username="bob",
|
|
research_id="r2",
|
|
query="failed query",
|
|
error_message="timeout",
|
|
settings_snapshot={"notifications.rate_limit_per_hour": 10},
|
|
)
|
|
assert result is True
|
|
|
|
@patch(
|
|
"local_deep_research.notifications.queue_helpers.NotificationManager"
|
|
)
|
|
def test_send_queue_failed_rate_limited(self, MockManager):
|
|
mock_instance = MockManager.return_value
|
|
mock_instance.send_notification.side_effect = RateLimitError("limited")
|
|
|
|
result = send_queue_failed_notification(
|
|
username="bob",
|
|
research_id="r2",
|
|
query="test",
|
|
settings_snapshot={"x": 1},
|
|
)
|
|
assert result is False
|
|
|
|
|
|
# ===================================================================
|
|
# mask_sensitive_url
|
|
# ===================================================================
|
|
|
|
|
|
class TestMaskSensitiveUrl:
|
|
"""Tests for URL credential masking."""
|
|
|
|
def test_masks_password(self):
|
|
url = "https://user:secretpassword@example.com/path"
|
|
masked = mask_sensitive_url(url)
|
|
assert "secretpassword" not in masked
|
|
assert "***" in masked
|
|
|
|
def test_masks_long_tokens_in_path(self):
|
|
url = "discord://12345678901234567890123456/abcdefghijklmnopqrstuvwxyz"
|
|
masked = mask_sensitive_url(url)
|
|
# Long alphanumeric tokens should be masked
|
|
assert "***" in masked
|
|
|
|
def test_masks_query_string(self):
|
|
url = "https://hooks.example.com/webhook?token=secret123"
|
|
masked = mask_sensitive_url(url)
|
|
assert "secret123" not in masked
|
|
|
|
def test_preserves_scheme(self):
|
|
url = "https://example.com/path"
|
|
masked = mask_sensitive_url(url)
|
|
assert masked.startswith("https://")
|
|
|
|
def test_handles_invalid_url(self):
|
|
# Should not raise, returns something with ***
|
|
result = mask_sensitive_url("not a url at all")
|
|
assert isinstance(result, str)
|
|
|
|
def test_simple_url_no_secrets(self):
|
|
url = "https://example.com/short"
|
|
masked = mask_sensitive_url(url)
|
|
assert "example.com" in masked
|
|
|
|
|
|
# ===================================================================
|
|
# Exception hierarchy
|
|
# ===================================================================
|
|
|
|
|
|
class TestExceptionHierarchy:
|
|
"""Verify exception inheritance is correct."""
|
|
|
|
def test_service_error_is_notification_error(self):
|
|
assert issubclass(ServiceError, NotificationError)
|
|
|
|
def test_send_error_is_notification_error(self):
|
|
assert issubclass(SendError, NotificationError)
|
|
|
|
def test_rate_limit_error_is_notification_error(self):
|
|
assert issubclass(RateLimitError, NotificationError)
|
|
|
|
def test_notification_error_is_exception(self):
|
|
assert issubclass(NotificationError, Exception)
|
|
|
|
def test_notification_url_validation_error_is_value_error(self):
|
|
assert issubclass(NotificationURLValidationError, ValueError)
|
|
|
|
def test_url_validation_error_is_value_error(self):
|
|
assert issubclass(URLValidationError, ValueError)
|
|
|
|
def test_exceptions_carry_message(self):
|
|
e = SendError("test message")
|
|
assert str(e) == "test message"
|
|
|
|
|
|
# ===================================================================
|
|
# URLValidator (callback URL validation)
|
|
# ===================================================================
|
|
|
|
|
|
class TestURLValidatorHTTP:
|
|
"""Tests for validate_http_url used in notification callback URLs."""
|
|
|
|
def test_valid_https(self):
|
|
assert (
|
|
URLValidator.validate_http_url("https://example.com/path") is True
|
|
)
|
|
|
|
def test_valid_http(self):
|
|
assert URLValidator.validate_http_url("http://example.com/path") is True
|
|
|
|
def test_rejects_ftp(self):
|
|
with pytest.raises(URLValidationError):
|
|
URLValidator.validate_http_url("ftp://example.com")
|
|
|
|
def test_rejects_empty(self):
|
|
with pytest.raises(URLValidationError):
|
|
URLValidator.validate_http_url("")
|
|
|
|
def test_rejects_none(self):
|
|
with pytest.raises(URLValidationError):
|
|
URLValidator.validate_http_url(None)
|
|
|
|
def test_rejects_no_scheme(self):
|
|
with pytest.raises(URLValidationError):
|
|
URLValidator.validate_http_url("example.com/path")
|
|
|
|
def test_rejects_javascript(self):
|
|
with pytest.raises(URLValidationError):
|
|
URLValidator.validate_http_url("javascript:alert(1)")
|