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
365 lines
14 KiB
Python
365 lines
14 KiB
Python
"""API contract tests for the report-content display routes.
|
|
|
|
Locks the public shape of report content so a future change can't silently
|
|
regress it (#3665):
|
|
|
|
* ``content`` is the *assembled* legacy view — answer + ``## Sources`` (from
|
|
the ``research_resources`` table) + ``## Research Metrics`` (from
|
|
``research_meta``) — across every display/export route.
|
|
* ``GET /api/report/<id>`` exposes a ``sources`` field populated from
|
|
``research_resources``. Before the fix it read the dead
|
|
``all_links_of_system`` metadata key, which the post-refactor save path
|
|
never writes, so it returned ``[]`` for every new research (Fix A).
|
|
|
|
These exercise the *real* assembly path (``assemble_full_report`` /
|
|
``get_research_source_links_batch``) against real seeded rows; only auth and
|
|
the per-user-DB plumbing are stubbed.
|
|
"""
|
|
|
|
from contextlib import contextmanager
|
|
from datetime import datetime, UTC
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
import pytest
|
|
from sqlalchemy import create_engine
|
|
from sqlalchemy.orm import sessionmaker
|
|
|
|
from local_deep_research.database.models import Base
|
|
from local_deep_research.database.models.chat import (
|
|
ChatMessage,
|
|
ChatMessageType,
|
|
ChatRole,
|
|
ChatSession,
|
|
)
|
|
from local_deep_research.database.models.research import (
|
|
ResearchHistory,
|
|
ResearchResource,
|
|
)
|
|
|
|
RESEARCH_ID = "contract-research-1"
|
|
# A second research with zero research_resources rows: the assembled view
|
|
# must omit the ``## Sources`` block entirely rather than emit an empty one
|
|
# (item 7 regression fence).
|
|
RESEARCH_ID_NO_SOURCES = "contract-research-nosrc"
|
|
# A chat session whose assistant message is the answer-only report_content —
|
|
# chat is the ONE consumer that must NOT receive the assembled view (item 8,
|
|
# architecture invariant of the chat-mode-v2 refactor).
|
|
CHAT_SESSION_ID = "contract-chat-1"
|
|
ANSWER_ONLY = (
|
|
"X is explained here, citing [1](https://src1.example) "
|
|
"and [2](https://src2.example)."
|
|
)
|
|
# Deliberately > the batch helper's default top-N (3): the report API must
|
|
# return *every* source (no cap), so a reintroduced default limit would fail
|
|
# test_api_report_sources_field_matches_research_resources_count.
|
|
N_SOURCES = 5
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def mock_db_manager():
|
|
"""Satisfy the auth middleware/decorators without a real encrypted DB.
|
|
|
|
Mirrors tests/test_followup_api.py: every module-level ``db_manager``
|
|
binding used by the Flask before_request middleware and ``login_required``
|
|
is mocked so an authenticated session passes. ``has_encryption=False``
|
|
skips the encrypted-DB password check.
|
|
"""
|
|
with (
|
|
patch("local_deep_research.web.auth.decorators.db_manager") as mock_db,
|
|
patch("local_deep_research.database.encrypted_db.db_manager"),
|
|
patch("local_deep_research.web.auth.database_middleware.db_manager"),
|
|
patch("local_deep_research.web.auth.session_cleanup.db_manager"),
|
|
patch("local_deep_research.web.auth.queue_middleware.db_manager"),
|
|
):
|
|
mock_db.connections = {"testuser": MagicMock()}
|
|
mock_db.has_encryption = False
|
|
mock_db.is_user_connected.return_value = True
|
|
yield mock_db
|
|
|
|
|
|
@pytest.fixture
|
|
def mock_auth_client(app):
|
|
"""Test client authenticated via a mock session.
|
|
|
|
Uses the shared ``app`` fixture (tests/conftest.py) but authenticates by
|
|
seeding the session directly rather than a real register/login, because
|
|
auth is stubbed via ``mock_db_manager`` and the per-user DB is patched to
|
|
a seeded SQLite file (``seeded_db``). The real ``authenticated_client``
|
|
fixture would create a real encrypted DB we couldn't seed sources into.
|
|
"""
|
|
client = app.test_client()
|
|
with client.session_transaction() as sess:
|
|
sess["username"] = "testuser"
|
|
sess["authenticated"] = True
|
|
return client
|
|
|
|
|
|
@pytest.fixture
|
|
def seeded_db(tmp_path):
|
|
"""Real temp-file SQLite seeded with one completed research + sources.
|
|
|
|
Patches ``get_user_db_session`` in both route modules to yield a fresh
|
|
session bound to the seeded DB (a fresh session per request, like the
|
|
real per-request session). A file DB (not ``:memory:``) so every
|
|
``SessionLocal()`` connection sees the same seeded rows.
|
|
"""
|
|
engine = create_engine(f"sqlite:///{tmp_path / 'report_contract.db'}")
|
|
Base.metadata.create_all(engine)
|
|
SessionLocal = sessionmaker(bind=engine)
|
|
|
|
seed = SessionLocal()
|
|
seed.add(
|
|
ResearchHistory(
|
|
id=RESEARCH_ID,
|
|
query="What is X?",
|
|
mode="quick",
|
|
status="completed",
|
|
created_at="2026-04-25T12:00:00+00:00",
|
|
completed_at="2026-04-25T12:05:00+00:00",
|
|
report_content=(
|
|
"X is explained here, citing [1](https://src1.example) "
|
|
"and [2](https://src2.example)."
|
|
),
|
|
research_meta={
|
|
"iterations": 2,
|
|
"generated_at": "2026-04-25T12:05:00+00:00",
|
|
# settings_snapshot is persisted in research_meta but must never
|
|
# be returned by an API response (it holds API keys / tokens) —
|
|
# see test_history_report_strips_settings_snapshot.
|
|
"settings_snapshot": {
|
|
"llm.openai.api_key": "sk-SECRET-LEAKED-KEY",
|
|
},
|
|
},
|
|
)
|
|
)
|
|
for i in range(1, N_SOURCES + 1):
|
|
url = f"https://src{i}.example"
|
|
seed.add(
|
|
ResearchResource(
|
|
research_id=RESEARCH_ID,
|
|
title=f"Source {i}",
|
|
url=url,
|
|
source_type="web",
|
|
resource_metadata={
|
|
"original_data": {
|
|
"index": str(i),
|
|
"url": url,
|
|
"title": f"Source {i}",
|
|
}
|
|
},
|
|
created_at=datetime.now(UTC).isoformat(),
|
|
)
|
|
)
|
|
|
|
# A completed research with NO research_resources rows (item 7).
|
|
seed.add(
|
|
ResearchHistory(
|
|
id=RESEARCH_ID_NO_SOURCES,
|
|
query="What is Y?",
|
|
mode="quick",
|
|
status="completed",
|
|
created_at="2026-04-25T13:00:00+00:00",
|
|
completed_at="2026-04-25T13:05:00+00:00",
|
|
report_content="Y has no catalogued sources but still has an answer.",
|
|
research_meta={
|
|
"iterations": 1,
|
|
"generated_at": "2026-04-25T13:05:00+00:00",
|
|
},
|
|
)
|
|
)
|
|
|
|
# A chat session with one assistant response message whose content is the
|
|
# answer-only report_content — never the assembled view (item 8).
|
|
seed.add(
|
|
ChatSession(
|
|
id=CHAT_SESSION_ID,
|
|
title="Contract chat",
|
|
)
|
|
)
|
|
seed.add(
|
|
ChatMessage(
|
|
id="contract-chat-msg-1",
|
|
session_id=CHAT_SESSION_ID,
|
|
research_id=RESEARCH_ID,
|
|
role=ChatRole.ASSISTANT,
|
|
message_type=ChatMessageType.RESPONSE,
|
|
content=ANSWER_ONLY,
|
|
sequence_number=1,
|
|
)
|
|
)
|
|
seed.commit()
|
|
seed.close()
|
|
|
|
@contextmanager
|
|
def _fake_user_db(username=None, password=None):
|
|
db = SessionLocal()
|
|
try:
|
|
yield db
|
|
finally:
|
|
db.close()
|
|
|
|
with (
|
|
patch(
|
|
"local_deep_research.web.routes.research_routes.get_user_db_session",
|
|
_fake_user_db,
|
|
),
|
|
patch(
|
|
"local_deep_research.web.routes.history_routes.get_user_db_session",
|
|
_fake_user_db,
|
|
),
|
|
# ChatService resolves its session through the chat.service binding,
|
|
# so the chat-route test (item 8) needs the same seeded DB.
|
|
patch(
|
|
"local_deep_research.chat.service.get_user_db_session",
|
|
_fake_user_db,
|
|
),
|
|
):
|
|
yield
|
|
|
|
engine.dispose()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Assembled-view shape across display routes (locks the legacy report shape)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_history_report_includes_sources_and_metrics(
|
|
mock_auth_client, seeded_db
|
|
):
|
|
resp = mock_auth_client.get(f"/history/report/{RESEARCH_ID}")
|
|
assert resp.status_code == 200, resp.data
|
|
content = resp.get_json()["content"]
|
|
assert "## Sources\n" in content
|
|
assert "## Research Metrics\n" in content
|
|
# Sources block references the structured resources.
|
|
assert "src1.example" in content
|
|
|
|
|
|
def test_history_report_strips_settings_snapshot(mock_auth_client, seeded_db):
|
|
"""GET /history/report/<id> must NOT return settings_snapshot (API keys,
|
|
tokens, base URLs) in its metadata — it is stripped like the sibling
|
|
/details and /api/research/<id> routes (sensitive-data exposure)."""
|
|
resp = mock_auth_client.get(f"/history/report/{RESEARCH_ID}")
|
|
assert resp.status_code == 200, resp.data
|
|
body = resp.get_json()
|
|
assert "settings_snapshot" not in body["metadata"]
|
|
# Belt-and-suspenders: the secret value appears nowhere in the response.
|
|
assert "sk-SECRET-LEAKED-KEY" not in resp.get_data(as_text=True)
|
|
# Non-sensitive metadata fields are still preserved.
|
|
assert body["metadata"].get("iterations") == 2
|
|
|
|
|
|
def test_history_markdown_includes_sources_and_metrics(
|
|
mock_auth_client, seeded_db
|
|
):
|
|
resp = mock_auth_client.get(f"/history/markdown/{RESEARCH_ID}")
|
|
assert resp.status_code == 200, resp.data
|
|
content = resp.get_json()["content"]
|
|
assert "## Sources\n" in content
|
|
assert "## Research Metrics\n" in content
|
|
|
|
|
|
def test_api_report_content_includes_sources_and_metrics(
|
|
mock_auth_client, seeded_db
|
|
):
|
|
resp = mock_auth_client.get(f"/api/report/{RESEARCH_ID}")
|
|
assert resp.status_code == 200, resp.data
|
|
content = resp.get_json()["content"]
|
|
assert "## Sources\n" in content
|
|
assert "## Research Metrics\n" in content
|
|
|
|
|
|
def test_export_latex_content_includes_sources_and_metrics(
|
|
mock_auth_client, seeded_db
|
|
):
|
|
# LaTeX is the registered plain-text export format (markdown is served by
|
|
# /history/markdown; quarto is a .zip; pdf/odt are binary). LaTeX
|
|
# conversion drops the markdown `##` prefix but keeps the header words and
|
|
# body text. "Research Metrics" / "Search Iterations" exist ONLY in the
|
|
# assembled view, never in the answer-only report_content, so finding them
|
|
# proves the export path goes through assemble_full_report.
|
|
resp = mock_auth_client.post(f"/api/v1/research/{RESEARCH_ID}/export/latex")
|
|
assert resp.status_code == 200, resp.data
|
|
body = resp.data.decode("utf-8")
|
|
assert "Research Metrics" in body
|
|
assert "Search Iterations" in body
|
|
assert "Sources" in body
|
|
assert "src1.example" in body
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Fix A regression fence: /api/report sources field from research_resources
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_api_report_sources_field_populated_from_research_resources(
|
|
mock_auth_client, seeded_db
|
|
):
|
|
resp = mock_auth_client.get(f"/api/report/{RESEARCH_ID}")
|
|
assert resp.status_code == 200, resp.data
|
|
sources = resp.get_json()["sources"]
|
|
assert isinstance(sources, list)
|
|
assert sources, "sources must not be empty (Fix A regression)"
|
|
for entry in sources:
|
|
assert entry.get("url")
|
|
assert entry.get("title")
|
|
|
|
|
|
def test_api_report_sources_field_matches_research_resources_count(
|
|
mock_auth_client, seeded_db
|
|
):
|
|
resp = mock_auth_client.get(f"/api/report/{RESEARCH_ID}")
|
|
assert resp.status_code == 200, resp.data
|
|
sources = resp.get_json()["sources"]
|
|
assert len(sources) == N_SOURCES
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Graceful empty-sources shape (item 7)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_response_with_no_sources_omits_sources_block_gracefully(
|
|
mock_auth_client, seeded_db
|
|
):
|
|
# A research with zero research_resources rows must still render a valid
|
|
# report — answer (+ metrics) — with NO empty ``## Sources`` section, and
|
|
# an empty ``sources`` list rather than an error.
|
|
resp = mock_auth_client.get(f"/api/report/{RESEARCH_ID_NO_SOURCES}")
|
|
assert resp.status_code == 200, resp.data
|
|
payload = resp.get_json()
|
|
content = payload["content"]
|
|
assert content, "content must be non-empty even with no sources"
|
|
assert "no catalogued sources" in content
|
|
assert "## Sources\n" not in content
|
|
assert payload["sources"] == []
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Chat is the one answer-only consumer (item 8) — architecture invariant
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_chat_message_response_returns_answer_only(mock_auth_client, seeded_db):
|
|
# The chat messages endpoint must return the stored ``report_content``
|
|
# verbatim (answer-only). If a future change routed it through
|
|
# assemble_full_report, a ``## Sources`` / ``## Research Metrics`` block
|
|
# would leak in — this fences that off.
|
|
resp = mock_auth_client.get(
|
|
f"/api/chat/sessions/{CHAT_SESSION_ID}/messages"
|
|
)
|
|
assert resp.status_code == 200, resp.data
|
|
payload = resp.get_json()
|
|
assert payload["success"] is True
|
|
responses = [
|
|
m
|
|
for m in payload["messages"]
|
|
if m.get("message_type") == ChatMessageType.RESPONSE.value
|
|
]
|
|
assert len(responses) == 1, payload["messages"]
|
|
content = responses[0]["content"]
|
|
assert content == ANSWER_ONLY
|
|
assert "## Sources\n" not in content
|
|
assert "## Research Metrics\n" not in content
|