mirror of
https://github.com/ZhuLinsen/daily_stock_analysis
synced 2026-09-20 10:53:33 +08:00
* feat: add intelligence source ingestion baseline * feat: feed local intelligence into analysis contexts * fix(review-feedback-1708): Reject DNS names that resolve privately and Validate redirect targets * fix(review-feedback-1709): 处理大盘复盘本地资讯可能被搜索结果截断掉的问题,并澄清结构化检测到的外部模型/API 或运行时配置风险是否为真实变更 * fix(review-feedback-1708): Pin DNS resolution before fetching and Stream feeds before enforcing * fix(review-feedback-1709): filter by published at for analysis evidence, or keep missing publish * fix(review-feedback-1708): Sanitize fetch errors before returning them and Normalize nullable * fix(review-feedback-1709): Normalize symbol scope before lookup * fix(review-feedback-1708): Avoid rolling back prior item inserts on duplicate races and Reject * fix(review-feedback-1709): Use the effective news window for local evidence and add the plain * fix(review-feedback-1708): 落地可配置 RSS/Atom 情报源、存储、查询、retention 和基础安全边界 * fix(review-feedback-1709): 补充“revert 本 PR 或移除本地资讯接入入口/清退本地资讯源配置数据”级别说明即可 * fix(review-feedback-1708): 落地 RSS/Atom 情报源的存储、拉取、查询、retention 和基础安全边界 * fix(review-feedback-1709): 补 Refs 1707 * fix(review-feedback-1709): 解决冲突后再合入 * fix(review-feedback-1709): src/services/intelligence service.py 回退了资讯源 URL 安全防护:移除了 hostname DNS * fix: harden intelligence source ingestion * fix(review-feedback-1709): Sanitize upstream fetch errors before returning them * feat: add NewsNow intelligence sources * fix(review-feedback-1709): 确认并修复 * fix(review-feedback-1709): 补一个未命中敏感规则的异常回归测试 * fix(review-feedback-1709): 处理 * docs: enhance NEWSNOW_BASE_URL compatibility guidance with official links - Add official NewsNow GitHub deployment guide link to .env.example - Include curl-based API contract verification example for production validation - Update CHANGELOG.md with official repository reference and deployment recommendation - Clarify risk of public example instance and necessity for self-hosted in production - All HTTP 500 responses already use sanitize_diagnostic_text for privacy Ref #1707 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(review-feedback-1709): 补齐官方 NewsNow 实例链接文档或 API 契约确认,明确指出公开实例风险(需结合当前部署指南文档,更新 .env.example --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
412 lines
19 KiB
Python
412 lines
19 KiB
Python
# -*- coding: utf-8 -*-
|
|
"""Tests for configurable persisted intelligence sources."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
import json
|
|
import socket
|
|
import tempfile
|
|
import unittest
|
|
from datetime import datetime, timedelta
|
|
from urllib.parse import quote
|
|
from unittest.mock import Mock, patch
|
|
|
|
import requests
|
|
|
|
from src.config import Config
|
|
from src.repositories.intelligence_repo import IntelligenceRepository
|
|
from src.services.intelligence_service import IntelligenceService, IntelligenceServiceError
|
|
from src.storage import DatabaseManager, IntelligenceItem, INTELLIGENCE_ITEM_NULL_SCOPE_VALUE
|
|
|
|
RSS_FIXTURE = b'<?xml version="1.0" encoding="UTF-8"?>\n<rss version="2.0"><channel>\n<item><title>Policy support lifts AI supply chain</title><link>https://news.example.com/a</link><description>Market-level catalyst with evidence link.</description><pubDate>Wed, 17 Jun 2026 08:00:00 GMT</pubDate></item>\n<item><title>Second item</title><link>https://news.example.com/b</link><description>Second summary.</description></item>\n</channel></rss>'
|
|
NO_URL_LINK_FIXTURE = b'<?xml version="1.0" encoding="UTF-8"?>\n<rss version="2.0"><channel>\n<item><title>Anonymous item</title><description>No link in this item.</description></item>\n</channel></rss>'
|
|
BAD_ITEM_LINK_FIXTURE = b'<?xml version="1.0" encoding="UTF-8"?>\n<rss version="2.0"><channel>\n<item><title>Bad mail link</title><link>mailto:tips@example.com</link><description>Should be skipped.</description></item>\n<item><title>Good public link</title><link>https://news.example.com/good</link><description>Should be saved.</description></item>\n</channel></rss>'
|
|
NEWSNOW_FIXTURE = {
|
|
"status": "success",
|
|
"id": "cls-hot",
|
|
"updatedTime": 1781760000000,
|
|
"items": [
|
|
{
|
|
"id": "1",
|
|
"title": "A-share AI hardware theme heats up",
|
|
"url": "https://news.example.com/newsnow-a",
|
|
"pubDate": 1781760000000,
|
|
"extra": {"info": "Capital market hot topic from NewsNow."},
|
|
},
|
|
{
|
|
"id": "2",
|
|
"title": "Second NewsNow item",
|
|
"url": "https://news.example.com/newsnow-b",
|
|
"extra": {"hover": "Fallback summary."},
|
|
},
|
|
],
|
|
}
|
|
|
|
|
|
class IntelligenceServiceTestCase(unittest.TestCase):
|
|
def setUp(self) -> None:
|
|
self._temp_dir = tempfile.TemporaryDirectory()
|
|
os.environ["DATABASE_PATH"] = os.path.join(self._temp_dir.name, "intelligence.db")
|
|
os.environ["NEWS_INTEL_RETENTION_DAYS"] = "30"
|
|
os.environ["NEWS_INTEL_MAX_ITEMS_PER_SOURCE"] = "50"
|
|
os.environ["NEWS_INTEL_FETCH_TIMEOUT_SEC"] = "3"
|
|
Config._instance = None
|
|
DatabaseManager.reset_instance()
|
|
self.service = IntelligenceService()
|
|
self._dns_patcher = patch(
|
|
"src.services.intelligence_service.socket.getaddrinfo",
|
|
side_effect=self._mock_getaddrinfo,
|
|
)
|
|
self._dns_patcher.start()
|
|
self.addCleanup(self._dns_patcher.stop)
|
|
|
|
def _mock_getaddrinfo(self, host, *_args, **_kwargs):
|
|
host = (host or "").lower().strip()
|
|
if host in {"localhost", "localhost.localdomain"}:
|
|
return [
|
|
(socket.AF_INET, socket.SOCK_STREAM, 6, "", ("127.0.0.1", 0)),
|
|
]
|
|
if host == "shared.example.com":
|
|
return [
|
|
(socket.AF_INET, socket.SOCK_STREAM, 6, "", ("100.64.0.1", 0)),
|
|
]
|
|
return [
|
|
(socket.AF_INET, socket.SOCK_STREAM, 6, "", ("93.184.216.34", 0)),
|
|
]
|
|
|
|
def _feed_fixture(self, source_url: str) -> bytes:
|
|
key = quote(source_url.replace("://", "_").replace("/", "_"))
|
|
return RSS_FIXTURE.replace(
|
|
b"https://news.example.com/a",
|
|
f"https://news.example.com/{key}.a".encode("utf-8"),
|
|
).replace(
|
|
b"https://news.example.com/b",
|
|
f"https://news.example.com/{key}.b".encode("utf-8"),
|
|
)
|
|
|
|
def tearDown(self) -> None:
|
|
DatabaseManager.reset_instance()
|
|
Config._instance = None
|
|
for key in ["DATABASE_PATH", "NEWS_INTEL_RETENTION_DAYS", "NEWS_INTEL_MAX_ITEMS_PER_SOURCE", "NEWS_INTEL_FETCH_TIMEOUT_SEC"]:
|
|
os.environ.pop(key, None)
|
|
self._temp_dir.cleanup()
|
|
|
|
def _mock_response(self, source_url: str = "https://feeds.example.com/rss.xml"):
|
|
response = Mock()
|
|
response.status_code = 200
|
|
response.url = source_url
|
|
response.headers = {}
|
|
response.raise_for_status.return_value = None
|
|
response.iter_content.return_value = [self._feed_fixture(source_url)]
|
|
return response
|
|
|
|
def _mock_json_response(self, payload=NEWSNOW_FIXTURE, source_url: str = "https://newsnow.example.com/api/s?id=cls-hot"):
|
|
response = Mock()
|
|
response.status_code = 200
|
|
response.url = source_url
|
|
response.headers = {}
|
|
response.raise_for_status.return_value = None
|
|
response.iter_content.return_value = [json.dumps(payload).encode("utf-8")]
|
|
return response
|
|
|
|
def _mock_response_with_redirects(self, source_url: str = "https://feeds.example.com/rss.xml", next_url: str = "https://feeds.example.com/rss.xml"):
|
|
response = Mock()
|
|
response.url = source_url
|
|
response.raise_for_status.return_value = None
|
|
response.headers = {"Location": next_url}
|
|
response.status_code = 302
|
|
return response
|
|
|
|
def _mock_http_error_response(self, source_url: str):
|
|
response = Mock()
|
|
response.status_code = 403
|
|
response.url = source_url
|
|
response.headers = {}
|
|
response.raise_for_status.side_effect = requests.HTTPError(
|
|
f"403 Client Error: Forbidden for url: {source_url}"
|
|
)
|
|
response.iter_content.return_value = []
|
|
return response
|
|
|
|
def test_create_fetch_and_deduplicate_rss_source(self) -> None:
|
|
source = self.service.create_source({
|
|
"name": "market-feed", "url": "https://feeds.example.com/rss.xml",
|
|
"source_type": "rss", "scope_type": "market", "market": "cn",
|
|
})
|
|
with patch("src.services.intelligence_service.requests.get", return_value=self._mock_response()):
|
|
first = self.service.fetch_source(source["id"])
|
|
second = self.service.fetch_source(source["id"])
|
|
self.assertEqual(first["fetched_count"], 2)
|
|
self.assertEqual(first["saved_count"], 2)
|
|
self.assertEqual(second["saved_count"], 0)
|
|
items = self.service.list_items(scope_type="market", market="cn")
|
|
self.assertEqual(items["total"], 2)
|
|
self.assertEqual(items["items"][0]["scope_type"], "market")
|
|
self.assertTrue(items["items"][0]["url"].startswith("https://news.example.com/"))
|
|
|
|
def test_fetch_http_error_does_not_expose_source_query_secret(self) -> None:
|
|
secret_url = "https://feeds.example.com/rss.xml?token=super-secret"
|
|
source = self.service.create_source({
|
|
"name": "secret-feed", "url": secret_url, "scope_type": "market",
|
|
})
|
|
|
|
with patch("src.services.intelligence_service.requests.get", return_value=self._mock_http_error_response(secret_url)):
|
|
with self.assertRaises(IntelligenceServiceError) as ctx:
|
|
self.service.fetch_source(source["id"])
|
|
|
|
message = str(ctx.exception)
|
|
self.assertEqual(message, "fetch failed: upstream request failed")
|
|
self.assertNotIn(secret_url, message)
|
|
self.assertNotIn("token=", message)
|
|
self.assertNotIn("super-secret", message)
|
|
saved_source = self.service.repo.get_source(source["id"])
|
|
self.assertIsNotNone(saved_source)
|
|
self.assertEqual(saved_source.last_error, "fetch failed: upstream request failed")
|
|
|
|
def test_fetch_newsnow_http_error_does_not_expose_source_query_secret(self) -> None:
|
|
secret_url = "https://newsnow.example.com/api/s?id=cls-hot&token=super-secret"
|
|
source = self.service.create_source({
|
|
"name": "newsnow-secret-feed",
|
|
"url": secret_url,
|
|
"source_type": "newsnow",
|
|
"scope_type": "market",
|
|
"market": "cn",
|
|
})
|
|
|
|
with patch("src.services.intelligence_service.requests.get", return_value=self._mock_http_error_response(secret_url)):
|
|
with self.assertRaises(IntelligenceServiceError) as ctx:
|
|
self.service.fetch_source(source["id"])
|
|
|
|
message = str(ctx.exception)
|
|
self.assertEqual(message, "fetch failed: upstream request failed")
|
|
self.assertNotIn(secret_url, message)
|
|
self.assertNotIn("token=", message)
|
|
self.assertNotIn("super-secret", message)
|
|
|
|
def test_private_network_url_is_rejected(self) -> None:
|
|
with self.assertRaises(IntelligenceServiceError):
|
|
self.service.create_source({"name": "bad", "url": "http://127.0.0.1:8000/rss.xml", "scope_type": "market"})
|
|
|
|
def test_shared_address_space_url_is_rejected(self) -> None:
|
|
with self.assertRaises(IntelligenceServiceError):
|
|
self.service.create_source({"name": "shared", "url": "https://shared.example.com/rss.xml", "scope_type": "market"})
|
|
|
|
def test_duplicate_source_name_is_validation_error(self) -> None:
|
|
payload = {"name": "dupe", "url": "https://feeds.example.com/rss.xml", "scope_type": "market"}
|
|
self.service.create_source(payload)
|
|
with self.assertRaises(IntelligenceServiceError):
|
|
self.service.create_source(payload)
|
|
|
|
def test_fetch_enabled_sources_is_fail_open(self) -> None:
|
|
self.service.create_source({"name": "good-feed", "url": "https://feeds.example.com/rss.xml", "scope_type": "market"})
|
|
bad = self.service.create_source({"name": "bad-feed", "url": "https://bad.example.com/rss.xml", "scope_type": "market"})
|
|
|
|
def fake_get(url, **kwargs):
|
|
self.assertNotIn("trust_env", kwargs)
|
|
self.assertEqual(kwargs.get("proxies"), {"http": None, "https": None})
|
|
if "bad" in url:
|
|
raise RuntimeError("network token=secret should not leak")
|
|
return self._mock_response()
|
|
with patch("src.services.intelligence_service.requests.get", side_effect=fake_get):
|
|
result = self.service.fetch_enabled_sources()
|
|
self.assertEqual(result["source_count"], 2)
|
|
self.assertEqual(result["saved_count"], 2)
|
|
failures = [item for item in result["results"] if not item["ok"]]
|
|
self.assertEqual(len(failures), 1)
|
|
self.assertEqual(failures[0]["source_id"], bad["id"])
|
|
self.assertNotIn("token=secret", failures[0]["error"])
|
|
self.assertNotIn("secret", failures[0]["error"])
|
|
|
|
def test_fetch_enabled_sources_paginates_all_enabled_sources(self) -> None:
|
|
for index in range(150):
|
|
self.service.create_source({
|
|
"name": f"feed-{index}",
|
|
"url": "https://feeds.example.com/rss.xml",
|
|
"scope_type": "market",
|
|
"market": "cn",
|
|
})
|
|
|
|
with patch("src.services.intelligence_service.requests.get", side_effect=lambda *_args, **_kwargs: self._mock_response()):
|
|
result = self.service.fetch_enabled_sources()
|
|
|
|
self.assertEqual(result["source_count"], 150)
|
|
self.assertEqual(len(result["results"]), 150)
|
|
self.assertEqual(result["saved_count"], 300)
|
|
self.assertTrue(all(item["ok"] for item in result["results"]))
|
|
|
|
def test_fetch_entry_redacts_no_url_link_with_placeholder(self) -> None:
|
|
source = self.service.create_source({
|
|
"name": "market-no-link",
|
|
"url": "https://feeds.example.com/rss.xml",
|
|
"scope_type": "market",
|
|
})
|
|
|
|
response = Mock()
|
|
response.status_code = 200
|
|
response.url = "https://feeds.example.com/rss.xml"
|
|
response.headers = {}
|
|
response.raise_for_status.return_value = None
|
|
response.iter_content.return_value = [NO_URL_LINK_FIXTURE]
|
|
|
|
with patch("src.services.intelligence_service.requests.get", return_value=response):
|
|
result = self.service.fetch_source(source["id"])
|
|
|
|
self.assertEqual(result["fetched_count"], 1)
|
|
self.assertEqual(result["saved_count"], 1)
|
|
self.assertIn("no-url:intel:", result["sample_items"][0]["url"])
|
|
|
|
def test_bad_feed_item_link_is_skipped_without_failing_source(self) -> None:
|
|
source = self.service.create_source({
|
|
"name": "mixed-link-feed",
|
|
"url": "https://feeds.example.com/rss.xml",
|
|
"scope_type": "market",
|
|
})
|
|
|
|
response = Mock()
|
|
response.status_code = 200
|
|
response.url = "https://feeds.example.com/rss.xml"
|
|
response.headers = {}
|
|
response.raise_for_status.return_value = None
|
|
response.iter_content.return_value = [BAD_ITEM_LINK_FIXTURE]
|
|
|
|
with patch("src.services.intelligence_service.requests.get", return_value=response):
|
|
result = self.service.fetch_source(source["id"])
|
|
|
|
self.assertEqual(result["fetched_count"], 1)
|
|
self.assertEqual(result["saved_count"], 1)
|
|
self.assertEqual(result["sample_items"][0]["url"], "https://news.example.com/good")
|
|
|
|
def test_source_templates_can_create_disabled_source(self) -> None:
|
|
templates = self.service.list_source_templates(market="hk")
|
|
self.assertGreaterEqual(templates["total"], 1)
|
|
created = self.service.create_source_from_template("hkex-news", {"enabled": False, "name": "hkex-template-copy"})
|
|
self.assertEqual(created["name"], "hkex-template-copy")
|
|
self.assertEqual(created["market"], "hk")
|
|
self.assertFalse(created["enabled"])
|
|
|
|
def test_newsnow_source_fetches_json_items(self) -> None:
|
|
source = self.service.create_source({
|
|
"name": "newsnow-cls",
|
|
"url": "https://newsnow.example.com/api/s?id=cls-hot",
|
|
"source_type": "newsnow",
|
|
"scope_type": "market",
|
|
"market": "cn",
|
|
})
|
|
|
|
with patch("src.services.intelligence_service.requests.get", return_value=self._mock_json_response()):
|
|
result = self.service.fetch_source(source["id"])
|
|
|
|
self.assertEqual(result["fetched_count"], 2)
|
|
self.assertEqual(result["saved_count"], 2)
|
|
items = self.service.list_items(market="cn")
|
|
self.assertEqual(items["total"], 2)
|
|
self.assertEqual(items["items"][0]["source_type"], "newsnow")
|
|
self.assertEqual(result["sample_items"][0]["source"], "newsnow-cls")
|
|
self.assertEqual(result["sample_items"][0]["summary"], "Capital market hot topic from NewsNow.")
|
|
|
|
def test_create_default_sources_is_idempotent(self) -> None:
|
|
first = self.service.create_default_sources({"enabled": False})
|
|
second = self.service.create_default_sources({"enabled": False})
|
|
|
|
self.assertGreaterEqual(first["created_count"], 5)
|
|
self.assertEqual(second["created_count"], 0)
|
|
self.assertEqual(first["total"], second["total"])
|
|
sources = self.service.list_sources(source_type="newsnow", market="cn")
|
|
self.assertGreaterEqual(sources["total"], 3)
|
|
self.assertTrue(all(not item["source"]["enabled"] for item in first["items"]))
|
|
|
|
def test_create_default_sources_are_disabled_by_default(self) -> None:
|
|
first = self.service.create_default_sources()
|
|
sources = self.service.list_sources()
|
|
self.assertEqual(first["created_count"], first["total"])
|
|
self.assertEqual(sources["total"], first["total"])
|
|
self.assertTrue(all(not item["enabled"] for item in sources["items"]))
|
|
|
|
def test_same_url_can_be_saved_for_different_scopes(self) -> None:
|
|
market = self.service.create_source({
|
|
"name": "market-feed",
|
|
"url": "https://feeds.example.com/shared.xml",
|
|
"scope_type": "market",
|
|
"market": "cn",
|
|
})
|
|
symbol = self.service.create_source({
|
|
"name": "symbol-feed",
|
|
"url": "https://feeds.example.com/shared.xml",
|
|
"scope_type": "symbol",
|
|
"scope_value": "600519",
|
|
"market": "cn",
|
|
})
|
|
response = self._mock_response(source_url="https://feeds.example.com/shared.xml")
|
|
with patch("src.services.intelligence_service.requests.get", return_value=response):
|
|
market_result = self.service.fetch_source(market["id"])
|
|
symbol_result = self.service.fetch_source(symbol["id"])
|
|
|
|
self.assertEqual(market_result["saved_count"], 2)
|
|
self.assertEqual(symbol_result["saved_count"], 2)
|
|
rows, total = IntelligenceRepository().list_items()
|
|
self.assertEqual(total, 4)
|
|
scope_pairs = {(row.scope_type, row.scope_value) for row in rows}
|
|
self.assertIn(("market", INTELLIGENCE_ITEM_NULL_SCOPE_VALUE), scope_pairs)
|
|
self.assertIn(("symbol", "600519"), scope_pairs)
|
|
|
|
def test_redirect_to_private_network_is_blocked(self) -> None:
|
|
source = self.service.create_source({
|
|
"name": "redirected-feed",
|
|
"url": "https://feeds.example.com/rss.xml",
|
|
"scope_type": "market",
|
|
})
|
|
|
|
with patch("src.services.intelligence_service.requests.get", side_effect=[
|
|
self._mock_response_with_redirects(source_url="https://feeds.example.com/rss.xml", next_url="http://localhost/evil.xml"),
|
|
self._mock_response(source_url="http://localhost/evil.xml"),
|
|
]):
|
|
with self.assertRaises(IntelligenceServiceError):
|
|
self.service.fetch_source(source["id"])
|
|
|
|
def test_redirect_is_followed_after_dns_validation(self) -> None:
|
|
source = self.service.create_source({
|
|
"name": "follow-redirect-feed",
|
|
"url": "https://feeds.example.com/rss.xml",
|
|
"scope_type": "market",
|
|
})
|
|
|
|
with patch("src.services.intelligence_service.requests.get", side_effect=[
|
|
self._mock_response_with_redirects(source_url="https://feeds.example.com/rss.xml", next_url="/next.xml"),
|
|
self._mock_response(source_url="https://feeds.example.com/next.xml"),
|
|
]):
|
|
result = self.service.fetch_source(source["id"])
|
|
|
|
self.assertEqual(result["fetched_count"], 2)
|
|
self.assertEqual(result["saved_count"], 2)
|
|
|
|
def test_feed_response_size_limit_is_enforced(self) -> None:
|
|
source = self.service.create_source({
|
|
"name": "large-feed",
|
|
"url": "https://feeds.example.com/rss.xml",
|
|
"scope_type": "market",
|
|
})
|
|
|
|
large_response = Mock()
|
|
large_response.status_code = 200
|
|
large_response.url = "https://feeds.example.com/rss.xml"
|
|
large_response.headers = {}
|
|
large_response.raise_for_status.return_value = None
|
|
large_response.iter_content.return_value = [b"x" * (2 * 1024 * 1024 + 1)]
|
|
|
|
with patch("src.services.intelligence_service.requests.get", return_value=large_response):
|
|
with self.assertRaises(IntelligenceServiceError):
|
|
self.service.fetch_source(source["id"])
|
|
|
|
def test_retention_removes_old_items(self) -> None:
|
|
repo = IntelligenceRepository()
|
|
old_time = datetime.now() - timedelta(days=60)
|
|
repo.upsert_items([{"source_name": "legacy", "source_type": "rss", "title": "old", "summary": "old item", "url": "https://news.example.com/old", "source": "legacy", "published_at": old_time, "fetched_at": old_time, "scope_type": "market", "scope_value": None, "market": "cn"}])
|
|
self.assertEqual(repo.apply_retention(30), 1)
|
|
with DatabaseManager.get_instance().get_session() as session:
|
|
self.assertEqual(session.query(IntelligenceItem).count(), 0)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|