7a0da7932b
Backwards Compatibility / Verify Encryption Constants (push) Waiting to run
Backwards Compatibility / PyPI Version Compatibility (push) Waiting to run
Backwards Compatibility / Database Migration Tests (push) Waiting to run
CodeQL Advanced / Analyze (javascript-typescript) (push) Waiting to run
CodeQL Advanced / Analyze (python) (push) Waiting to run
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-form] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-metrics] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-workflow] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [settings-core] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [settings-pages] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [history-news] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [library] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [link-analytics] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [mobile] (push) Blocked by required conditions
Docker Tests (Consolidated) / detect-changes (push) Waiting to run
Docker Tests (Consolidated) / Build Test Image (push) Waiting to run
Docker Tests (Consolidated) / All Pytest Tests + Coverage (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [accessibility] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [api-crud] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-login] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-pages] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-register] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [chat-core] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [chat-lifecycle] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [error-benchmark] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) (push) Blocked by required conditions
Docker Tests (Consolidated) / Accessibility Tests (push) Blocked by required conditions
Docker Tests (Consolidated) / LLM Unit Tests (push) Blocked by required conditions
Docker Tests (Consolidated) / LLM Example Tests (push) Blocked by required conditions
Docker Tests (Consolidated) / Production Image Smoke Test (push) Blocked by required conditions
Docker Tests (Consolidated) / Infrastructure Tests (push) Blocked by required conditions
OSSF Scorecard / OSSF Security Scorecard Analysis (push) Waiting to run
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
450 lines
15 KiB
Python
450 lines
15 KiB
Python
"""Extra coverage tests for notifications — service, manager, and url_builder gaps."""
|
|
|
|
from collections import deque
|
|
from datetime import datetime, timedelta, UTC
|
|
from unittest.mock import MagicMock
|
|
|
|
import pytest
|
|
|
|
from local_deep_research.notifications.manager import (
|
|
NotificationManager,
|
|
RateLimiter,
|
|
)
|
|
from local_deep_research.notifications.templates import EventType
|
|
from local_deep_research.security.url_builder import (
|
|
build_base_url_from_settings,
|
|
build_full_url,
|
|
mask_sensitive_url,
|
|
normalize_bind_address,
|
|
validate_constructed_url,
|
|
URLBuilderError,
|
|
)
|
|
|
|
SVC_MODULE = "local_deep_research.notifications.service"
|
|
MGR_MODULE = "local_deep_research.notifications.manager"
|
|
|
|
|
|
# ===========================================================================
|
|
# NotificationManager._should_notify exception path
|
|
# ===========================================================================
|
|
|
|
|
|
class TestShouldNotifyException:
|
|
def test_exception_returns_false(self):
|
|
"""_should_notify returns False on exception to avoid loops."""
|
|
mgr = MagicMock(spec=NotificationManager)
|
|
mgr._get_setting = MagicMock(side_effect=RuntimeError("db fail"))
|
|
|
|
# Call the real method
|
|
result = NotificationManager._should_notify(
|
|
mgr, EventType.RESEARCH_COMPLETED
|
|
)
|
|
assert result is False
|
|
|
|
def test_enabled_returns_true(self):
|
|
mgr = MagicMock(spec=NotificationManager)
|
|
mgr._get_setting = MagicMock(return_value=True)
|
|
|
|
result = NotificationManager._should_notify(
|
|
mgr, EventType.RESEARCH_COMPLETED
|
|
)
|
|
assert result is True
|
|
|
|
def test_disabled_returns_false(self):
|
|
mgr = MagicMock(spec=NotificationManager)
|
|
mgr._get_setting = MagicMock(return_value=False)
|
|
|
|
result = NotificationManager._should_notify(
|
|
mgr, EventType.RESEARCH_COMPLETED
|
|
)
|
|
assert result is False
|
|
|
|
|
|
# ===========================================================================
|
|
# NotificationManager._log_notification exception path
|
|
# ===========================================================================
|
|
|
|
|
|
class TestLogNotificationException:
|
|
def test_exception_swallowed(self):
|
|
"""_log_notification swallows exceptions gracefully."""
|
|
mgr = MagicMock(spec=NotificationManager)
|
|
mgr._user_id = "testuser"
|
|
|
|
# context.get raising should be caught
|
|
bad_context = MagicMock()
|
|
bad_context.get.side_effect = RuntimeError("context error")
|
|
|
|
NotificationManager._log_notification(
|
|
mgr, EventType.RESEARCH_COMPLETED, bad_context
|
|
)
|
|
|
|
def test_logs_query_from_context(self):
|
|
mgr = MagicMock(spec=NotificationManager)
|
|
mgr._user_id = "testuser"
|
|
|
|
NotificationManager._log_notification(
|
|
mgr,
|
|
EventType.RESEARCH_COMPLETED,
|
|
{"query": "test query"},
|
|
)
|
|
|
|
|
|
# ===========================================================================
|
|
# RateLimiter._clean_old_entries
|
|
# ===========================================================================
|
|
|
|
|
|
class TestRateLimiterCleanOldEntries:
|
|
def test_cleans_hourly_entries(self):
|
|
limiter = RateLimiter(max_per_hour=10, max_per_day=100)
|
|
user = "testuser"
|
|
|
|
now = datetime.now(UTC)
|
|
old = now - timedelta(hours=2)
|
|
|
|
limiter._hourly_counts[user] = deque([old, old, now])
|
|
limiter._daily_counts[user] = deque([now])
|
|
|
|
limiter._clean_old_entries(user, now)
|
|
|
|
assert len(limiter._hourly_counts[user]) == 1
|
|
|
|
def test_cleans_daily_entries(self):
|
|
limiter = RateLimiter(max_per_hour=10, max_per_day=100)
|
|
user = "testuser"
|
|
|
|
now = datetime.now(UTC)
|
|
old = now - timedelta(days=2)
|
|
|
|
limiter._hourly_counts[user] = deque([now])
|
|
limiter._daily_counts[user] = deque([old, old, now])
|
|
|
|
limiter._clean_old_entries(user, now)
|
|
|
|
assert len(limiter._daily_counts[user]) == 1
|
|
|
|
def test_cleans_all_old_entries(self):
|
|
limiter = RateLimiter(max_per_hour=10, max_per_day=100)
|
|
user = "testuser"
|
|
|
|
now = datetime.now(UTC)
|
|
old_h = now - timedelta(hours=2)
|
|
old_d = now - timedelta(days=2)
|
|
|
|
limiter._hourly_counts[user] = deque([old_h])
|
|
limiter._daily_counts[user] = deque([old_d])
|
|
|
|
limiter._clean_old_entries(user, now)
|
|
|
|
assert len(limiter._hourly_counts[user]) == 0
|
|
assert len(limiter._daily_counts[user]) == 0
|
|
|
|
|
|
# ===========================================================================
|
|
# RateLimiter._cleanup_inactive_users_if_needed
|
|
# ===========================================================================
|
|
|
|
|
|
class TestRateLimiterCleanupInactiveUsers:
|
|
def test_skips_if_not_due(self):
|
|
limiter = RateLimiter(max_per_hour=10, max_per_day=100)
|
|
now = datetime.now(UTC)
|
|
limiter._last_cleanup = now # just cleaned up
|
|
|
|
limiter._hourly_counts["old_user"] = deque()
|
|
limiter._cleanup_inactive_users_if_needed(now)
|
|
|
|
# Should not have cleaned up (too soon)
|
|
assert "old_user" in limiter._hourly_counts
|
|
|
|
def test_removes_inactive_users(self):
|
|
limiter = RateLimiter(
|
|
max_per_hour=10, max_per_day=100, cleanup_interval_hours=1
|
|
)
|
|
now = datetime.now(UTC)
|
|
# Force cleanup to be due
|
|
limiter._last_cleanup = now - timedelta(hours=2)
|
|
|
|
# Old inactive user
|
|
old_time = now - timedelta(days=8)
|
|
limiter._hourly_counts["inactive"] = deque([old_time])
|
|
limiter._daily_counts["inactive"] = deque([old_time])
|
|
|
|
# Recent active user
|
|
limiter._hourly_counts["active"] = deque([now])
|
|
limiter._daily_counts["active"] = deque([now])
|
|
|
|
limiter._cleanup_inactive_users_if_needed(now)
|
|
|
|
assert "inactive" not in limiter._hourly_counts
|
|
assert "active" in limiter._hourly_counts
|
|
|
|
def test_empty_queues_treated_as_inactive(self):
|
|
limiter = RateLimiter(
|
|
max_per_hour=10, max_per_day=100, cleanup_interval_hours=1
|
|
)
|
|
now = datetime.now(UTC)
|
|
limiter._last_cleanup = now - timedelta(hours=2)
|
|
|
|
limiter._hourly_counts["empty_user"] = deque()
|
|
limiter._daily_counts["empty_user"] = deque()
|
|
|
|
limiter._cleanup_inactive_users_if_needed(now)
|
|
|
|
assert "empty_user" not in limiter._hourly_counts
|
|
|
|
|
|
# ===========================================================================
|
|
# RateLimiter.reset
|
|
# ===========================================================================
|
|
|
|
|
|
class TestRateLimiterReset:
|
|
def test_reset_specific_user(self):
|
|
limiter = RateLimiter(max_per_hour=10, max_per_day=100)
|
|
limiter._hourly_counts["user1"] = deque([datetime.now(UTC)])
|
|
limiter._hourly_counts["user2"] = deque([datetime.now(UTC)])
|
|
|
|
limiter.reset("user1")
|
|
|
|
assert "user1" not in limiter._hourly_counts
|
|
assert "user2" in limiter._hourly_counts
|
|
|
|
def test_reset_all_users(self):
|
|
limiter = RateLimiter(max_per_hour=10, max_per_day=100)
|
|
limiter._hourly_counts["user1"] = deque([datetime.now(UTC)])
|
|
limiter._hourly_counts["user2"] = deque([datetime.now(UTC)])
|
|
|
|
limiter.reset()
|
|
|
|
assert len(limiter._hourly_counts) == 0
|
|
assert len(limiter._daily_counts) == 0
|
|
|
|
|
|
# ===========================================================================
|
|
# NotificationService._validate_url
|
|
# ===========================================================================
|
|
|
|
|
|
class TestValidateUrl:
|
|
def test_empty_url_raises(self):
|
|
from local_deep_research.notifications.service import (
|
|
NotificationService,
|
|
ServiceError,
|
|
)
|
|
|
|
with pytest.raises(ServiceError, match="non-empty"):
|
|
NotificationService._validate_url("")
|
|
|
|
def test_none_url_raises(self):
|
|
from local_deep_research.notifications.service import (
|
|
NotificationService,
|
|
ServiceError,
|
|
)
|
|
|
|
with pytest.raises(ServiceError, match="non-empty"):
|
|
NotificationService._validate_url(None)
|
|
|
|
def test_no_scheme_raises(self):
|
|
from local_deep_research.notifications.service import (
|
|
NotificationService,
|
|
ServiceError,
|
|
)
|
|
|
|
with pytest.raises(ServiceError, match="Invalid URL"):
|
|
NotificationService._validate_url("no-scheme-here")
|
|
|
|
def test_valid_url_passes(self):
|
|
from local_deep_research.notifications.service import (
|
|
NotificationService,
|
|
)
|
|
|
|
NotificationService._validate_url("discord://webhook/token")
|
|
|
|
|
|
# ===========================================================================
|
|
# NotificationService.get_service_type
|
|
# ===========================================================================
|
|
|
|
|
|
class TestGetServiceType:
|
|
def test_discord_detected(self):
|
|
from local_deep_research.notifications.service import (
|
|
NotificationService,
|
|
)
|
|
|
|
svc = MagicMock(spec=NotificationService)
|
|
svc.SERVICE_PATTERNS = NotificationService.SERVICE_PATTERNS
|
|
result = NotificationService.get_service_type(
|
|
svc, "discord://webhook_id/webhook_token"
|
|
)
|
|
assert result == "discord"
|
|
|
|
def test_unknown_url(self):
|
|
from local_deep_research.notifications.service import (
|
|
NotificationService,
|
|
)
|
|
|
|
svc = MagicMock(spec=NotificationService)
|
|
svc.SERVICE_PATTERNS = NotificationService.SERVICE_PATTERNS
|
|
result = NotificationService.get_service_type(svc, "custom://something")
|
|
assert result == "unknown"
|
|
|
|
|
|
# ===========================================================================
|
|
# url_builder — mask_sensitive_url
|
|
# ===========================================================================
|
|
|
|
|
|
class TestMaskSensitiveUrl:
|
|
def test_masks_password(self):
|
|
result = mask_sensitive_url("https://user:secret123@host.com/path")
|
|
assert "secret123" not in result
|
|
assert "***" in result
|
|
|
|
def test_masks_long_path_tokens(self):
|
|
result = mask_sensitive_url(
|
|
"https://discord.com/api/webhooks/abcdefghijklmnopqrstuvwxyz123456"
|
|
)
|
|
assert "/***" in result
|
|
|
|
def test_masks_query_params(self):
|
|
result = mask_sensitive_url(
|
|
"https://host.com/path?token=secret&key=api"
|
|
)
|
|
assert "?***" in result
|
|
|
|
def test_no_password_no_mask(self):
|
|
result = mask_sensitive_url("https://host.com/short")
|
|
assert "***" not in result or result == "https://host.com/short"
|
|
|
|
def test_invalid_url_returns_scheme_mask(self):
|
|
result = mask_sensitive_url("badscheme://something")
|
|
assert isinstance(result, str)
|
|
|
|
def test_empty_path(self):
|
|
result = mask_sensitive_url("https://host.com")
|
|
assert "host.com" in result
|
|
|
|
|
|
# ===========================================================================
|
|
# url_builder — build_base_url_from_settings
|
|
# ===========================================================================
|
|
|
|
|
|
class TestBuildBaseUrlFromSettings:
|
|
def test_external_url_priority(self):
|
|
result = build_base_url_from_settings(
|
|
external_url="https://myapp.com",
|
|
host="localhost",
|
|
port=5000,
|
|
)
|
|
assert result == "https://myapp.com"
|
|
|
|
def test_external_url_strips_trailing_slash(self):
|
|
result = build_base_url_from_settings(external_url="https://myapp.com/")
|
|
assert result == "https://myapp.com"
|
|
|
|
def test_host_port_construction(self):
|
|
result = build_base_url_from_settings(host="myhost", port=8080)
|
|
assert result == "http://myhost:8080"
|
|
|
|
def test_bind_address_normalized(self):
|
|
result = build_base_url_from_settings(host="0.0.0.0", port=5000)
|
|
assert result == "http://localhost:5000"
|
|
|
|
def test_fallback(self):
|
|
result = build_base_url_from_settings(
|
|
fallback_base="http://custom:9999"
|
|
)
|
|
assert result == "http://custom:9999"
|
|
|
|
def test_empty_external_url_ignored(self):
|
|
result = build_base_url_from_settings(
|
|
external_url=" ",
|
|
host="myhost",
|
|
port=3000,
|
|
)
|
|
assert result == "http://myhost:3000"
|
|
|
|
|
|
# ===========================================================================
|
|
# url_builder — validate_constructed_url
|
|
# ===========================================================================
|
|
|
|
|
|
class TestValidateConstructedUrl:
|
|
def test_valid_url(self):
|
|
assert validate_constructed_url("https://example.com/path") is True
|
|
|
|
def test_empty_url_raises(self):
|
|
with pytest.raises(URLBuilderError, match="non-empty"):
|
|
validate_constructed_url("")
|
|
|
|
def test_none_raises(self):
|
|
with pytest.raises(URLBuilderError, match="non-empty"):
|
|
validate_constructed_url(None)
|
|
|
|
def test_no_scheme_raises(self):
|
|
with pytest.raises(URLBuilderError, match="scheme"):
|
|
validate_constructed_url("example.com/path")
|
|
|
|
def test_disallowed_scheme_raises(self):
|
|
with pytest.raises(URLBuilderError, match="not in allowed"):
|
|
validate_constructed_url(
|
|
"ftp://example.com", allowed_schemes=["http", "https"]
|
|
)
|
|
|
|
def test_no_hostname_raises(self):
|
|
with pytest.raises(URLBuilderError, match="hostname"):
|
|
validate_constructed_url("http://")
|
|
|
|
|
|
# ===========================================================================
|
|
# url_builder — build_full_url
|
|
# ===========================================================================
|
|
|
|
|
|
class TestBuildFullUrl:
|
|
def test_basic(self):
|
|
result = build_full_url("https://myapp.com", "/research/123")
|
|
assert result == "https://myapp.com/research/123"
|
|
|
|
def test_adds_leading_slash(self):
|
|
result = build_full_url(
|
|
"https://myapp.com", "research/123", validate=False
|
|
)
|
|
assert result == "https://myapp.com/research/123"
|
|
|
|
def test_strips_trailing_slash_from_base(self):
|
|
result = build_full_url("https://myapp.com/", "/path", validate=False)
|
|
assert result == "https://myapp.com/path"
|
|
|
|
def test_validation_enabled(self):
|
|
result = build_full_url("https://myapp.com", "/path")
|
|
assert result == "https://myapp.com/path"
|
|
|
|
def test_validation_fails_raises(self):
|
|
with pytest.raises(URLBuilderError):
|
|
build_full_url("", "/path")
|
|
|
|
|
|
# ===========================================================================
|
|
# url_builder — normalize_bind_address
|
|
# ===========================================================================
|
|
|
|
|
|
class TestNormalizeBindAddress:
|
|
def test_bind_all_ipv4(self):
|
|
assert normalize_bind_address("0.0.0.0") == "localhost"
|
|
|
|
def test_bind_all_ipv6(self):
|
|
assert normalize_bind_address("::") == "localhost"
|
|
|
|
def test_normal_host_unchanged(self):
|
|
assert normalize_bind_address("myhost") == "myhost"
|
|
|
|
def test_localhost_unchanged(self):
|
|
assert normalize_bind_address("localhost") == "localhost"
|