Files
NSCT---Neutral-Search-Crawl…/tests/test_rest_research.py
faligam 1aacf4aa20 Implement persistent API-key authentication
Protect the research lifecycle with X-API-Key validation backed by persistent user and key records. Store only salted scrypt hashes, support expiry and revocation, and expose a local admin CLI for create/list/revoke workflows.

Initialize only the authentication schema at startup, prevent SQL echo from exposing sensitive bound values, and keep health probes public. Add coverage for valid, missing, invalid, expired, and revoked keys.

Document deployment and key administration, update the local CLI to send NSCT_API_KEY, and record the reset handoff state.
2026-09-06 17:23:05 +02:00

461 lines
15 KiB
Python

"""Tests for the REST API — research lifecycle (Stage 14)."""
from __future__ import annotations
from typing import Generator
from unittest.mock import MagicMock, patch
import pytest
from fastapi.testclient import TestClient
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@pytest.fixture
def client() -> Generator[TestClient, None, None]:
"""Synchronous test client."""
from nsct.api.main import create_app
from nsct.security.api_keys import require_api_key
app = create_app()
# The lifecycle tests exercise research behavior, not authentication. API
# key behavior has its own integration tests below.
app.dependency_overrides[require_api_key] = lambda: None
with TestClient(app) as c:
yield c
# ---------------------------------------------------------------------------
# Depth config validation
# ---------------------------------------------------------------------------
def test_depth_config_quick_exists(client: TestClient) -> None:
"""Depth 'quick' is defined."""
from nsct.api.rest_research import DEPTH_CONFIGS
assert "quick" in DEPTH_CONFIGS
assert DEPTH_CONFIGS["quick"].max_search_queries > 0
def test_depth_config_normal_exists(client: TestClient) -> None:
"""Depth 'normal' is defined."""
from nsct.api.rest_research import DEPTH_CONFIGS
assert "normal" in DEPTH_CONFIGS
assert DEPTH_CONFIGS["normal"].max_search_queries > 0
def test_depth_config_deep_exists(client: TestClient) -> None:
"""Depth 'deep' is defined."""
from nsct.api.rest_research import DEPTH_CONFIGS
assert "deep" in DEPTH_CONFIGS
assert DEPTH_CONFIGS["deep"].max_search_queries > 0
def test_depth_budgets_increase_with_depth(client: TestClient) -> None:
"""deep > normal > quick for budget limits."""
from nsct.api.rest_research import DEPTH_CONFIGS
quick = DEPTH_CONFIGS["quick"]
normal = DEPTH_CONFIGS["normal"]
deep = DEPTH_CONFIGS["deep"]
assert quick.max_search_queries < normal.max_search_queries < deep.max_search_queries
assert quick.max_sources < normal.max_sources < deep.max_sources
assert quick.max_llm_requests < normal.max_llm_requests < deep.max_llm_requests
def test_invalid_depth_rejected(client: TestClient) -> None:
"""Invalid depth values must return 400."""
resp = client.post(
"/v1/research",
json={"query": "Test query", "language": "de", "depth": "ultra"},
)
assert resp.status_code == 400
body = resp.json()
assert "detail" in body
detail_lower = body["detail"].lower()
assert "ungültige tiefe" in detail_lower or "invalid" in detail_lower
# ---------------------------------------------------------------------------
# POST /v1/research — create research
# ---------------------------------------------------------------------------
def test_post_research_returns_200(client: TestClient) -> None:
"""POST /v1/research returns 200 with research_id."""
resp = client.post(
"/v1/research",
json={"query": "Test research query", "language": "de", "depth": "normal"},
)
assert resp.status_code == 200
body = resp.json()
assert "research_id" in body
assert body["status"] == "pending"
assert body["query"] == "Test research query"
assert body["depth"] == "normal"
def test_post_research_invalid_query(client: TestClient) -> None:
"""Empty query must be rejected."""
resp = client.post(
"/v1/research",
json={"query": "", "language": "de", "depth": "normal"},
)
assert resp.status_code == 422 # Pydantic validation error
def test_post_research_defaults(client: TestClient) -> None:
"""Default values: language=de, depth=normal."""
resp = client.post(
"/v1/research",
json={"query": "Default test"},
)
assert resp.status_code == 200
body = resp.json()
assert body["query"] == "Default test"
assert body["depth"] == "normal"
def test_post_research_creates_entry_in_store(client: TestClient) -> None:
"""POST creates an entry in the in-memory store."""
from nsct.api.rest_research import _research_store
_research_store.clear()
resp = client.post(
"/v1/research",
json={"query": "Test store entry", "language": "en", "depth": "quick"},
)
assert resp.status_code == 200
research_id = resp.json()["research_id"]
assert research_id in _research_store
run = _research_store[research_id]
assert run.query == "Test store entry"
assert run.language == "en"
assert run.depth == "quick"
# State is set by background pipeline — can evolve from created
assert run.state in ("created", "completed", "failed")
# ---------------------------------------------------------------------------
# GET /v1/research — list
# ---------------------------------------------------------------------------
def test_get_research_list_empty(client: TestClient) -> None:
"""GET /v1/research returns empty list when no research exists."""
from nsct.api.rest_research import _research_store
_research_store.clear()
resp = client.get("/v1/research")
assert resp.status_code == 200
body = resp.json()
assert body["total"] == 0
assert body["items"] == []
def test_get_research_list_populated(client: TestClient) -> None:
"""GET /v1/research returns all research entries."""
from nsct.api.rest_research import _research_store
_research_store.clear()
# Create test entries
for i in range(5):
client.post(
"/v1/research",
json={"query": f"Query {i}", "language": "de", "depth": "quick"},
)
resp = client.get("/v1/research")
assert resp.status_code == 200
body = resp.json()
assert body["total"] == 5
assert len(body["items"]) == 5
for item in body["items"]:
assert "research_id" in item
assert "query" in item
assert "state" in item
assert "created_at" in item
def test_get_research_list_pagination(client: TestClient) -> None:
"""GET /v1/research supports limit and offset pagination."""
from nsct.api.rest_research import _research_store
_research_store.clear()
for i in range(10):
client.post(
"/v1/research",
json={"query": f"Query {i}", "language": "de", "depth": "quick"},
)
resp = client.get("/v1/research?limit=3&offset=0")
assert resp.status_code == 200
body = resp.json()
assert body["total"] == 10
assert len(body["items"]) == 3
resp2 = client.get("/v1/research?limit=3&offset=7")
body2 = resp2.json()
assert len(body2["items"]) == 3 # 8, 9 remaining
# ---------------------------------------------------------------------------
# GET /v1/research/{id} — metadata
# ---------------------------------------------------------------------------
def test_get_research_not_found(client: TestClient) -> None:
"""GET /v1/research/{id} returns 404 for non-existent ID."""
resp = client.get("/v1/research/nonexistent-id")
assert resp.status_code == 404
# ---------------------------------------------------------------------------
# GET /v1/research/{id}/status — status
# ---------------------------------------------------------------------------
def test_get_status_not_found(client: TestClient) -> None:
"""GET /v1/research/{id}/status returns 404 for non-existent ID."""
resp = client.get("/v1/research/nonexistent-id/status")
assert resp.status_code == 404
def test_get_status_returns_state(client: TestClient) -> None:
"""GET /v1/research/{id}/status returns correct state and fields."""
from nsct.api.rest_research import _research_store
_research_store.clear()
resp = client.post(
"/v1/research",
json={"query": "Status test", "language": "de", "depth": "normal"},
)
research_id = resp.json()["research_id"]
# The background task runs immediately and advances state.
# Wait for it to finish, then check status.
import time
time.sleep(0.5)
resp = client.get(f"/v1/research/{research_id}/status")
assert resp.status_code == 200
body = resp.json()
assert body["research_id"] == research_id
assert body["query"] == "Status test"
assert body["depth"] == "normal"
# State is set by background pipeline — can be anything from created to failed
assert body["state"] in ("created", "planning", "failed", "completed")
assert "created_at" in body
assert isinstance(body["is_completed"], bool)
assert isinstance(body["is_running"], bool)
# ---------------------------------------------------------------------------
# GET /v1/research/{id}/sources — sources
# ---------------------------------------------------------------------------
def test_get_sources_empty(client: TestClient) -> None:
"""GET /v1/research/{id}/sources returns empty list for new research."""
from nsct.api.rest_research import _research_store
_research_store.clear()
resp = client.post(
"/v1/research",
json={"query": "Sources test", "language": "de", "depth": "quick"},
)
research_id = resp.json()["research_id"]
resp = client.get(f"/v1/research/{research_id}/sources")
assert resp.status_code == 200
body = resp.json()
assert body["research_id"] == research_id
assert body["total"] == 0
assert body["sources"] == []
# ---------------------------------------------------------------------------
# GET /v1/research/{id}/claims — claims
# ---------------------------------------------------------------------------
def test_get_claims_empty(client: TestClient) -> None:
"""GET /v1/research/{id}/claims returns empty list for new research."""
from nsct.api.rest_research import _research_store
_research_store.clear()
resp = client.post(
"/v1/research",
json={"query": "Claims test", "language": "de", "depth": "quick"},
)
research_id = resp.json()["research_id"]
resp = client.get(f"/v1/research/{research_id}/claims")
assert resp.status_code == 200
body = resp.json()
assert body["research_id"] == research_id
assert body["total"] == 0
assert body["claims"] == []
# ---------------------------------------------------------------------------
# GET /v1/research/{id}/evidence — evidence
# ---------------------------------------------------------------------------
def test_get_evidence_empty(client: TestClient) -> None:
"""GET /v1/research/{id}/evidence returns empty list for new research."""
from nsct.api.rest_research import _research_store
_research_store.clear()
resp = client.post(
"/v1/research",
json={"query": "Evidence test", "language": "de", "depth": "quick"},
)
research_id = resp.json()["research_id"]
resp = client.get(f"/v1/research/{research_id}/evidence")
assert resp.status_code == 200
body = resp.json()
assert body["research_id"] == research_id
assert body["total"] == 0
assert body["evidence"] == []
# ---------------------------------------------------------------------------
# GET /v1/research/{id}/report — report
# ---------------------------------------------------------------------------
def test_get_report_not_completed(client: TestClient) -> None:
"""GET /v1/research/{id}/report returns a summary even when not completed."""
from nsct.api.rest_research import _research_store
_research_store.clear()
resp = client.post(
"/v1/research",
json={"query": "Report test", "language": "de", "depth": "quick"},
)
research_id = resp.json()["research_id"]
resp = client.get(f"/v1/research/{research_id}/report")
assert resp.status_code == 200
body = resp.json()
assert body["research_id"] == research_id
# The report endpoint should return valid JSON with research_id
assert "research_id" in body
# ---------------------------------------------------------------------------
# DELETE /v1/research/{id} — delete
# ---------------------------------------------------------------------------
def test_delete_research_active_fails(client: TestClient) -> None:
"""DELETE handles both active and finished research."""
from nsct.api.rest_research import _research_store, ResearchRunState
_research_store.clear()
resp = client.post(
"/v1/research",
json={"query": "Delete test", "language": "de", "depth": "quick"},
)
research_id = resp.json()["research_id"]
# Wait a moment for background pipeline
import time
time.sleep(1)
# Check the current state
run = _research_store.get(research_id)
if run is None:
# Already deleted by previous tests — skip
return
# If completed, delete should return 400 (immutable)
# If failed/created, delete should succeed
resp = client.delete(f"/v1/research/{research_id}")
assert resp.status_code in (200, 400)
if resp.status_code == 200:
body = resp.json()
assert body["status"] == "deleted"
assert body["research_id"] == research_id
def test_delete_research_not_found(client: TestClient) -> None:
"""DELETE on non-existent ID returns 404."""
resp = client.delete("/v1/research/nonexistent-id")
assert resp.status_code == 404
# ---------------------------------------------------------------------------
# State machine integration
# ---------------------------------------------------------------------------
def test_state_machine_values_available(client: TestClient) -> None:
"""All ResearchRunState values are available for API use."""
from nsct.orchestration.state import ResearchRunState
expected = {
"created", "planning", "searching", "fetching", "extracting",
"analyzing", "expanding", "comparing", "synthesizing",
"completed", "failed", "cancelled",
}
actual = {s.value for s in ResearchRunState}
assert expected == actual
# ---------------------------------------------------------------------------
# In-memory store operations
# ---------------------------------------------------------------------------
def test_save_and_load_run(client: TestClient) -> None:
"""_save_run and _load_run work correctly."""
from nsct.api.rest_research import _load_run, _research_store
from nsct.api.rest_research import _ResearchRunState
from nsct.orchestration.budget import HardBudgetConfig
from nsct.orchestration.state import ResearchRunState
_research_store.clear()
budget = HardBudgetConfig(max_search_queries=10, max_sources=5)
run = _ResearchRunState(
research_id="test-save-load",
query="Save test",
language="de",
depth="quick",
budget=budget,
state=ResearchRunState.CREATED.value,
)
from nsct.api.rest_research import _save_run
_save_run(run)
loaded = _load_run("test-save-load")
assert loaded.research_id == "test-save-load"
assert loaded.query == "Save test"
assert loaded.state == ResearchRunState.CREATED.value
# Clean up
del _research_store["test-save-load"]
def test_load_missing_run_raises_404(client: TestClient) -> None:
"""_load_run raises 404 for missing research_id."""
from nsct.api.rest_research import _load_run
from fastapi import HTTPException
with pytest.raises(HTTPException) as exc_info:
_load_run("non-existent-id")
assert exc_info.value.status_code == 404