Files
wehub-resource-sync 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
chore: import upstream snapshot with attribution
2026-07-13 13:08:55 +08:00

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"