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

574 lines
19 KiB
Python

"""
Deep behavioral tests for all news exception classes.
Tests status codes, error codes, to_dict, details, and inheritance.
"""
import pytest
from local_deep_research.news.exceptions import (
DatabaseAccessException,
InvalidLimitException,
InvalidParameterException,
NewsAPIException,
NewsFeatureDisabledException,
NewsFeedGenerationException,
NotImplementedException,
ResearchProcessingException,
SchedulerNotificationException,
SubscriptionCreationException,
SubscriptionDeletionException,
SubscriptionNotFoundException,
SubscriptionUpdateException,
)
# --- Base exception ---
class TestNewsAPIException:
"""Tests for base NewsAPIException."""
def test_message_stored(self):
e = NewsAPIException("something broke")
assert e.message == "something broke"
def test_default_status_code_500(self):
e = NewsAPIException("err")
assert e.status_code == 500
def test_custom_status_code(self):
e = NewsAPIException("err", status_code=400)
assert e.status_code == 400
def test_error_code_defaults_to_class_name(self):
e = NewsAPIException("err")
assert e.error_code == "NewsAPIException"
def test_custom_error_code(self):
e = NewsAPIException("err", error_code="CUSTOM")
assert e.error_code == "CUSTOM"
def test_details_defaults_empty(self):
e = NewsAPIException("err")
assert e.details == {}
def test_custom_details(self):
e = NewsAPIException("err", details={"key": "val"})
assert e.details == {"key": "val"}
def test_to_dict_structure(self):
e = NewsAPIException("broken", status_code=400, error_code="BAD")
d = e.to_dict()
assert d["error"] == "broken"
assert d["error_code"] == "BAD"
assert d["status_code"] == 400
def test_to_dict_excludes_empty_details(self):
e = NewsAPIException("err")
d = e.to_dict()
assert "details" not in d
def test_to_dict_includes_details_when_present(self):
e = NewsAPIException("err", details={"info": "extra"})
d = e.to_dict()
assert d["details"] == {"info": "extra"}
def test_is_exception(self):
e = NewsAPIException("err")
assert isinstance(e, Exception)
def test_str_representation(self):
e = NewsAPIException("message here")
assert str(e) == "message here"
class TestToDictMessageSanitization:
"""`to_dict()` runs `message` through the central client sanitizer.
This is a boundary backstop: the primary defence is curated messages at
the `news/api.py` raise sites. These tests lock in that the backstop
scrubs credential-bearing text while leaving legitimate messages — the
class of content a denylist would have false-positived on — untouched.
"""
def test_benign_message_passes_through_unchanged(self):
# An egress-policy rejection reason is legitimate, user-facing text.
# It must reach the client verbatim (the central sanitizer only
# redacts credential patterns, so it does not fire here).
msg = (
"Failed to create subscription: search engine 'brave' is not "
"permitted under the current egress policy"
)
assert NewsAPIException(msg).to_dict()["error"] == msg
def test_credential_keyword_in_content_not_scrubbed(self):
# Plain prose containing "password" is not a credential and must
# survive — the denylist approach wrongly nuked this.
msg = "how do I reset my password?"
assert NewsAPIException(msg).to_dict()["error"] == msg
def test_bearer_token_redacted(self):
e = NewsAPIException(
"auth failed: Authorization: Bearer sk-abc123DEF456ghi789"
)
error = e.to_dict()["error"]
# The load-bearing assertion is that the secret is gone. The redaction
# sentinel varies (`[REDACTED]` / `[REDACTED_KEY]`), so match on the
# order-independent substring rather than an exact bracket form.
assert "sk-abc123DEF456ghi789" not in error
assert "REDACTED" in error
def test_api_key_query_param_redacted(self):
e = NewsAPIException(
"call failed: ?api_key=AIzaSyD-EXAMPLE_KEY_1234567890abcd"
)
error = e.to_dict()["error"]
assert "AIzaSyD-EXAMPLE_KEY_1234567890abcd" not in error
assert "REDACTED" in error
def test_long_message_truncated_to_client_cap(self):
# sanitize_error_for_client caps the client-facing message at 200 chars.
# Locks in that to_dict uses the truncating client sanitizer (not the
# no-cap variant) so an over-long future message can't ship in full.
msg = "A" * 300
error = NewsAPIException(msg).to_dict()["error"]
assert len(error) <= 200
assert error.endswith("...")
def test_stored_message_attribute_is_not_mutated(self):
# Only the serialized output is scrubbed; the raw message stays intact
# for server-side diagnosis via logger.exception.
raw = "boom: Authorization: Bearer sk-secret999value000"
e = NewsAPIException(raw)
assert e.to_dict()["error"] != raw
assert e.message == raw
def test_error_code_and_status_code_untouched(self):
e = NewsAPIException(
"Bearer sk-abc123DEF456ghi789",
status_code=502,
error_code="UPSTREAM",
)
d = e.to_dict()
assert d["error_code"] == "UPSTREAM"
assert d["status_code"] == 502
def test_benign_details_pass_through_unchanged(self):
# Structured values (IDs, ints) and benign free text in details are
# not credential shapes, so they survive verbatim.
e = NewsAPIException(
"err",
details={
"subscription_id": "abc-123",
"min_limit": 1,
"query": "how do I reset my password?",
},
)
assert e.to_dict()["details"] == {
"subscription_id": "abc-123",
"min_limit": 1,
"query": "how do I reset my password?",
}
def test_credential_in_details_string_leaf_redacted(self):
# details can carry caller-supplied free text (e.g. the user query at
# api.py:1071/1134); a credential shape in a string leaf is scrubbed.
e = NewsAPIException(
"err",
details={
"query": "Authorization: Bearer sk-abc123DEF456ghi789",
"count": 3,
},
)
d = e.to_dict()["details"]
assert "sk-abc123DEF456ghi789" not in d["query"]
assert "REDACTED" in d["query"]
# Non-string leaves pass through untouched.
assert d["count"] == 3
def test_nested_details_are_sanitized_recursively(self):
e = NewsAPIException(
"err",
details={
"outer": {"inner": ["?api_key=AIzaSyD-EXAMPLE_1234567890"]}
},
)
leaf = e.to_dict()["details"]["outer"]["inner"][0]
assert "AIzaSyD-EXAMPLE_1234567890" not in leaf
assert "REDACTED" in leaf
def test_stored_details_attribute_is_not_mutated(self):
raw = {"query": "Bearer sk-secret999value000"}
e = NewsAPIException("err", details=raw)
assert "REDACTED" in e.to_dict()["details"]["query"]
assert e.details["query"] == "Bearer sk-secret999value000"
def test_tuple_detail_normalizes_to_list(self):
# A tuple leaf serializes to a JSON array anyway; sanitize returns a
# plain list so identity is irrelevant downstream.
e = NewsAPIException("err", details={"pair": ("a", "b")})
assert e.to_dict()["details"]["pair"] == ["a", "b"]
def test_namedtuple_detail_does_not_crash(self):
# Regression guard: `type(value)(<generator>)` would raise on a
# namedtuple (constructor isn't `(iterable) -> instance`), which inside
# the error handler would degrade a structured error into a bare 500.
from collections import namedtuple
Pair = namedtuple("Pair", ["a", "b"])
e = NewsAPIException(
"err", details={"pair": Pair("Bearer sk-abc123DEF456ghi789", "x")}
)
d = e.to_dict()["details"]["pair"]
assert d == ["Bearer [REDACTED]", "x"]
def test_subclass_message_sanitized_via_inherited_to_dict(self):
# A subclass that interpolates a caller-supplied string still gets the
# backstop, because to_dict() is inherited.
e = SubscriptionCreationException(
"Authorization: Bearer sk-abc123DEF456ghi789"
)
error = e.to_dict()["error"]
assert "sk-abc123DEF456ghi789" not in error
assert error.startswith("Failed to create subscription:")
# --- Specific exceptions ---
class TestNewsFeatureDisabledException:
"""Tests for disabled feature exception."""
def test_default_message(self):
e = NewsFeatureDisabledException()
assert e.message == "News system is disabled"
def test_custom_message(self):
e = NewsFeatureDisabledException("Feature X disabled")
assert e.message == "Feature X disabled"
def test_status_code_503(self):
e = NewsFeatureDisabledException()
assert e.status_code == 503
def test_error_code(self):
e = NewsFeatureDisabledException()
assert e.error_code == "NEWS_DISABLED"
def test_inherits_base(self):
e = NewsFeatureDisabledException()
assert isinstance(e, NewsAPIException)
class TestInvalidLimitException:
"""Tests for invalid limit exception."""
def test_message_includes_limit(self):
e = InvalidLimitException(0)
assert "0" in e.message
def test_status_code_400(self):
e = InvalidLimitException(-1)
assert e.status_code == 400
def test_error_code(self):
e = InvalidLimitException(0)
assert e.error_code == "INVALID_LIMIT"
def test_details_include_provided_limit(self):
e = InvalidLimitException(-5)
assert e.details["provided_limit"] == -5
def test_details_include_min_limit(self):
e = InvalidLimitException(0)
assert e.details["min_limit"] == 1
def test_to_dict_has_details(self):
e = InvalidLimitException(0)
d = e.to_dict()
assert d["details"]["provided_limit"] == 0
def test_inherits_base(self):
assert issubclass(InvalidLimitException, NewsAPIException)
class TestSubscriptionNotFoundException:
"""Tests for subscription not found exception."""
def test_message_includes_id(self):
e = SubscriptionNotFoundException("sub-123")
assert "sub-123" in e.message
def test_status_code_404(self):
e = SubscriptionNotFoundException("sub-1")
assert e.status_code == 404
def test_error_code(self):
e = SubscriptionNotFoundException("sub-1")
assert e.error_code == "SUBSCRIPTION_NOT_FOUND"
def test_details_include_subscription_id(self):
e = SubscriptionNotFoundException("sub-abc")
assert e.details["subscription_id"] == "sub-abc"
class TestSubscriptionCreationException:
"""Tests for subscription creation exception."""
def test_message_prefixed(self):
e = SubscriptionCreationException("query too long")
assert "Failed to create subscription" in e.message
assert "query too long" in e.message
def test_status_code_500(self):
e = SubscriptionCreationException("err")
assert e.status_code == 500
def test_error_code(self):
e = SubscriptionCreationException("err")
assert e.error_code == "SUBSCRIPTION_CREATE_FAILED"
def test_custom_details(self):
e = SubscriptionCreationException("err", details={"query": "test"})
assert e.details == {"query": "test"}
def test_none_details_becomes_empty(self):
e = SubscriptionCreationException("err", details=None)
assert e.details == {}
class TestSubscriptionUpdateException:
"""Tests for subscription update exception."""
def test_message_includes_id_and_reason(self):
e = SubscriptionUpdateException("sub-1", "invalid interval")
assert "sub-1" in e.message
assert "invalid interval" in e.message
def test_status_code_500(self):
e = SubscriptionUpdateException("sub-1", "err")
assert e.status_code == 500
def test_error_code(self):
e = SubscriptionUpdateException("sub-1", "err")
assert e.error_code == "SUBSCRIPTION_UPDATE_FAILED"
def test_details_include_subscription_id(self):
e = SubscriptionUpdateException("sub-42", "err")
assert e.details["subscription_id"] == "sub-42"
class TestSubscriptionDeletionException:
"""Tests for subscription deletion exception."""
def test_message_includes_id(self):
e = SubscriptionDeletionException("sub-1", "db locked")
assert "sub-1" in e.message
def test_status_code_500(self):
e = SubscriptionDeletionException("sub-1", "err")
assert e.status_code == 500
def test_error_code(self):
e = SubscriptionDeletionException("sub-1", "err")
assert e.error_code == "SUBSCRIPTION_DELETE_FAILED"
def test_details_include_subscription_id(self):
e = SubscriptionDeletionException("sub-7", "err")
assert e.details["subscription_id"] == "sub-7"
class TestDatabaseAccessException:
"""Tests for database access exception."""
def test_message_includes_operation(self):
e = DatabaseAccessException("INSERT", "table locked")
assert "INSERT" in e.message
def test_status_code_500(self):
e = DatabaseAccessException("SELECT", "err")
assert e.status_code == 500
def test_error_code(self):
e = DatabaseAccessException("DELETE", "err")
assert e.error_code == "DATABASE_ERROR"
def test_details_include_operation(self):
e = DatabaseAccessException("UPDATE", "err")
assert e.details["operation"] == "UPDATE"
class TestNewsFeedGenerationException:
"""Tests for feed generation exception."""
def test_message_prefixed(self):
e = NewsFeedGenerationException("timeout")
assert "Failed to generate news feed" in e.message
def test_status_code_500(self):
e = NewsFeedGenerationException("err")
assert e.status_code == 500
def test_error_code(self):
e = NewsFeedGenerationException("err")
assert e.error_code == "FEED_GENERATION_FAILED"
def test_no_user_id_means_empty_details(self):
e = NewsFeedGenerationException("err")
assert e.details == {}
def test_user_id_in_details(self):
e = NewsFeedGenerationException("err", user_id="user-42")
assert e.details["user_id"] == "user-42"
class TestResearchProcessingException:
"""Tests for research processing exception."""
def test_message_prefixed(self):
e = ResearchProcessingException("parse failed")
assert "Failed to process research item" in e.message
def test_error_code(self):
e = ResearchProcessingException("err")
assert e.error_code == "RESEARCH_PROCESSING_FAILED"
def test_no_research_id_means_empty_details(self):
e = ResearchProcessingException("err")
assert e.details == {}
def test_research_id_in_details(self):
e = ResearchProcessingException("err", research_id="res-1")
assert e.details["research_id"] == "res-1"
class TestNotImplementedException:
"""Tests for not implemented exception."""
def test_message_includes_feature(self):
e = NotImplementedException("live streaming")
assert "live streaming" in e.message
def test_status_code_501(self):
e = NotImplementedException("x")
assert e.status_code == 501
def test_error_code(self):
e = NotImplementedException("x")
assert e.error_code == "NOT_IMPLEMENTED"
def test_details_include_feature(self):
e = NotImplementedException("video analysis")
assert e.details["feature"] == "video analysis"
class TestInvalidParameterException:
"""Tests for invalid parameter exception."""
def test_message_includes_parameter_name(self):
e = InvalidParameterException("limit", -1, "must be positive")
assert "limit" in e.message
def test_message_includes_description(self):
e = InvalidParameterException("limit", -1, "must be positive")
assert "must be positive" in e.message
def test_status_code_400(self):
e = InvalidParameterException("p", "v", "m")
assert e.status_code == 400
def test_error_code(self):
e = InvalidParameterException("p", "v", "m")
assert e.error_code == "INVALID_PARAMETER"
def test_details_include_parameter(self):
e = InvalidParameterException("limit", -1, "bad")
assert e.details["parameter"] == "limit"
def test_details_include_value(self):
e = InvalidParameterException("limit", -1, "bad")
assert e.details["value"] == -1
class TestSchedulerNotificationException:
"""Tests for scheduler notification exception."""
def test_message_includes_action(self):
e = SchedulerNotificationException("user_login", "timeout")
assert "user_login" in e.message
def test_status_code_500(self):
e = SchedulerNotificationException("a", "m")
assert e.status_code == 500
def test_error_code(self):
e = SchedulerNotificationException("a", "m")
assert e.error_code == "SCHEDULER_NOTIFICATION_FAILED"
def test_details_include_action(self):
e = SchedulerNotificationException("refresh", "m")
assert e.details["action"] == "refresh"
# --- Cross-cutting tests ---
class TestExceptionHierarchy:
"""Test that all exceptions inherit from NewsAPIException."""
@pytest.mark.parametrize(
"cls",
[
NewsFeatureDisabledException,
InvalidLimitException,
SubscriptionNotFoundException,
SubscriptionCreationException,
SubscriptionUpdateException,
SubscriptionDeletionException,
DatabaseAccessException,
NewsFeedGenerationException,
ResearchProcessingException,
NotImplementedException,
InvalidParameterException,
SchedulerNotificationException,
],
)
def test_inherits_from_base(self, cls):
assert issubclass(cls, NewsAPIException)
@pytest.mark.parametrize(
"cls",
[
NewsFeatureDisabledException,
InvalidLimitException,
SubscriptionNotFoundException,
SubscriptionCreationException,
SubscriptionUpdateException,
SubscriptionDeletionException,
DatabaseAccessException,
NewsFeedGenerationException,
ResearchProcessingException,
NotImplementedException,
InvalidParameterException,
SchedulerNotificationException,
],
)
def test_inherits_from_exception(self, cls):
assert issubclass(cls, Exception)
class TestExceptionCatchability:
"""Test that exceptions can be caught by their base class."""
def test_catch_by_base_class(self):
with pytest.raises(NewsAPIException):
raise SubscriptionNotFoundException("x")
def test_catch_by_exception(self):
with pytest.raises(Exception):
raise DatabaseAccessException("op", "msg")
def test_specific_catch_works(self):
with pytest.raises(InvalidLimitException):
raise InvalidLimitException(0)