Some checks failed
verify / verify (push) Has been cancelled
The backend can now be inspected through a loopback-only gateway while the database-aware API remains on the internal data network. A governed synthetic demo proves readiness, pgvector retrieval, reranking, and citation output through real HTTP without invoking cloud models. Constraint: The previously exposed Bailian key is compromised and cannot be used for live validation Constraint: The API must be locally reachable while retaining no internet egress Rejected: Attach the API directly to the ingress network | a real socket test proved that configuration still had egress Rejected: Publish a port from the internal-only network | Docker Desktop did not expose the host port Confidence: high Scope-risk: moderate Reversibility: clean Directive: Keep model and database credentials out of the gateway; do not relax the fixed demo identity/profile filters Tested: make verify; 63 pytest tests; strict mypy; Ruff; Secret scan; Compose config; three backend image builds; API/DB/gateway healthy; migration exit 0; Swagger browser check; live/ready/meta/status/search HTTP; 20/20/20 index; API egress ENETUNREACH; empty gateway mounts and business environment Not-tested: Live Bailian calls require a newly rotated key; full generated-answer flow and React UI are not implemented
225 lines
7.1 KiB
Python
225 lines
7.1 KiB
Python
from __future__ import annotations
|
|
|
|
from dataclasses import dataclass
|
|
|
|
import httpx
|
|
import psycopg
|
|
import pytest
|
|
from fastapi import FastAPI
|
|
|
|
from app.api.v1.demo import (
|
|
DemoCandidate,
|
|
DemoCounts,
|
|
DemoRepository,
|
|
get_demo_repository,
|
|
make_citation_id,
|
|
make_page_label,
|
|
router,
|
|
)
|
|
|
|
|
|
@dataclass
|
|
class StubRepository:
|
|
count_result: DemoCounts
|
|
candidates: list[DemoCandidate]
|
|
failure: psycopg.Error | None = None
|
|
|
|
def counts(self) -> DemoCounts:
|
|
if self.failure is not None:
|
|
raise self.failure
|
|
return self.count_result
|
|
|
|
def search(self, query_vector: tuple[float, ...], *, limit: int) -> list[DemoCandidate]:
|
|
if self.failure is not None:
|
|
raise self.failure
|
|
assert len(query_vector) == 1024
|
|
return self.candidates[:limit]
|
|
|
|
|
|
def create_app(repository: DemoRepository) -> FastAPI:
|
|
app = FastAPI()
|
|
app.include_router(router)
|
|
app.dependency_overrides[get_demo_repository] = lambda: repository
|
|
return app
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_status_reports_only_public_synthetic_counts() -> None:
|
|
app = create_app(
|
|
StubRepository(
|
|
count_result=DemoCounts(chunks=20, vectors=20, searchable=20),
|
|
candidates=[],
|
|
)
|
|
)
|
|
async with httpx.AsyncClient(
|
|
transport=httpx.ASGITransport(app=app), base_url="http://test"
|
|
) as client:
|
|
response = await client.get("/api/v1/demo/status")
|
|
|
|
assert response.status_code == 200
|
|
assert response.json() == {
|
|
"status": "ready",
|
|
"dataset": "synthetic-demo",
|
|
"counts": {"chunks": 20, "vectors": 20, "searchable": 20},
|
|
}
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_status_explicitly_reports_empty_dataset() -> None:
|
|
app = create_app(
|
|
StubRepository(
|
|
count_result=DemoCounts(chunks=0, vectors=0, searchable=0),
|
|
candidates=[],
|
|
)
|
|
)
|
|
async with httpx.AsyncClient(
|
|
transport=httpx.ASGITransport(app=app), base_url="http://test"
|
|
) as client:
|
|
response = await client.get("/api/v1/demo/status")
|
|
|
|
assert response.status_code == 200
|
|
assert response.json()["status"] == "empty_dataset"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_status_does_not_mark_a_partial_demo_index_ready() -> None:
|
|
app = create_app(
|
|
StubRepository(
|
|
count_result=DemoCounts(chunks=20, vectors=19, searchable=18),
|
|
candidates=[],
|
|
)
|
|
)
|
|
async with httpx.AsyncClient(
|
|
transport=httpx.ASGITransport(app=app), base_url="http://test"
|
|
) as client:
|
|
response = await client.get("/api/v1/demo/status")
|
|
|
|
assert response.status_code == 200
|
|
assert response.json()["status"] == "incomplete_dataset"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_search_embeds_reranks_and_returns_only_safe_fields() -> None:
|
|
repository = StubRepository(
|
|
count_result=DemoCounts(chunks=2, vectors=2, searchable=2),
|
|
candidates=[
|
|
DemoCandidate(
|
|
source_key="internal-chunk-one",
|
|
title="西岭铜矿化合成记录",
|
|
text="该合成记录描述花岗斑岩接触带,未包含真实矿权或人员信息。",
|
|
page_start=3,
|
|
page_end=3,
|
|
),
|
|
DemoCandidate(
|
|
source_key="internal-chunk-two",
|
|
title="北谷金异常合成记录",
|
|
text="北谷合成样点出现低强度金异常,仅用于离线验证。",
|
|
page_start=7,
|
|
page_end=8,
|
|
),
|
|
],
|
|
)
|
|
app = create_app(repository)
|
|
async with httpx.AsyncClient(
|
|
transport=httpx.ASGITransport(app=app), base_url="http://test"
|
|
) as client:
|
|
response = await client.post(
|
|
"/api/v1/demo/search",
|
|
json={"query": "花岗斑岩 铜矿化", "top_k": 1},
|
|
)
|
|
|
|
assert response.status_code == 200
|
|
payload = response.json()
|
|
assert payload["status"] == "ok"
|
|
assert payload["dataset"] == "synthetic-demo"
|
|
assert len(payload["results"]) == 1
|
|
result = payload["results"][0]
|
|
assert set(result) == {"title", "snippet", "page_label", "score", "citation_id"}
|
|
assert result["title"].startswith("合成资料|")
|
|
assert result["page_label"] == "第 3 页"
|
|
assert result["citation_id"] == make_citation_id("internal-chunk-one")
|
|
assert "internal-chunk-one" not in str(payload)
|
|
assert "manifest" not in str(payload).lower()
|
|
assert "sha256" not in str(payload).lower()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_search_returns_explicit_empty_dataset_state() -> None:
|
|
app = create_app(
|
|
StubRepository(
|
|
count_result=DemoCounts(chunks=0, vectors=0, searchable=0),
|
|
candidates=[],
|
|
)
|
|
)
|
|
async with httpx.AsyncClient(
|
|
transport=httpx.ASGITransport(app=app), base_url="http://test"
|
|
) as client:
|
|
response = await client.post(
|
|
"/api/v1/demo/search",
|
|
json={"query": "不存在的合成资料", "top_k": 3},
|
|
)
|
|
|
|
assert response.status_code == 200
|
|
assert response.json() == {
|
|
"status": "empty_dataset",
|
|
"dataset": "synthetic-demo",
|
|
"results": [],
|
|
}
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize(
|
|
"body",
|
|
[
|
|
{"query": " ", "top_k": 3},
|
|
{"query": "x" * 501, "top_k": 3},
|
|
{"query": "铜矿", "top_k": 0},
|
|
{"query": "铜矿", "top_k": 11},
|
|
],
|
|
)
|
|
async def test_search_rejects_invalid_bounded_input(body: dict[str, object]) -> None:
|
|
app = create_app(
|
|
StubRepository(
|
|
count_result=DemoCounts(chunks=0, vectors=0, searchable=0),
|
|
candidates=[],
|
|
)
|
|
)
|
|
async with httpx.AsyncClient(
|
|
transport=httpx.ASGITransport(app=app), base_url="http://test"
|
|
) as client:
|
|
response = await client.post("/api/v1/demo/search", json=body)
|
|
|
|
assert response.status_code == 422
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize("path", ["/api/v1/demo/status", "/api/v1/demo/search"])
|
|
async def test_database_failure_is_sanitized_as_503(path: str) -> None:
|
|
app = create_app(
|
|
StubRepository(
|
|
count_result=DemoCounts(chunks=0, vectors=0, searchable=0),
|
|
candidates=[],
|
|
failure=psycopg.OperationalError("host=db password=must-not-leak"),
|
|
)
|
|
)
|
|
async with httpx.AsyncClient(
|
|
transport=httpx.ASGITransport(app=app), base_url="http://test"
|
|
) as client:
|
|
if path.endswith("search"):
|
|
response = await client.post(path, json={"query": "铜矿", "top_k": 3})
|
|
else:
|
|
response = await client.get(path)
|
|
|
|
assert response.status_code == 503
|
|
assert response.json() == {"detail": "database unavailable"}
|
|
assert "must-not-leak" not in response.text
|
|
|
|
|
|
def test_citation_and_page_labels_are_stable_and_opaque() -> None:
|
|
first = make_citation_id("private-database-uuid")
|
|
assert first == make_citation_id("private-database-uuid")
|
|
assert first != make_citation_id("another-private-database-uuid")
|
|
assert "private" not in first
|
|
assert make_page_label(None, None) == "页码未知"
|
|
assert make_page_label(2, 4) == "第 2-4 页"
|