EverOS/tests/integration/test_api/test_knowledge_gates.py

387 lines
14 KiB
Python

"""Verify per-endpoint scoping of the knowledge capability gate.
Task 11 (``cascade.registry.build_handlers``) gates the knowledge
cascade handlers off as an atomic pair when embed + rerank aren't
both available — a doc queued for a gated-off kind gets marked
permanently failed by the worker instead of ever reaching LanceDB.
Before this router gate existed, the HTTP layer had no idea any of
that happened: a Tier 1 user's upload returned 200 (the md write
always succeeds) while the document silently never became
searchable.
An earlier iteration attached the gate router-wide, which regressed
downgraded users: a client who dropped from Tier 3 → Tier 2/1 could
no longer even GET or DELETE their own docs, because the router-wide
dependency fired on every request. This module pins the corrected
scoping — writes and search stay gated (they can't succeed end-to-
end without the providers) while reads/deletes stay reachable so
users can inspect and clean up state they already have on disk.
Uses ``httpx.AsyncClient`` + ``ASGITransport`` (not
``fastapi.testclient.TestClient``) to match the project's established
exception-handler test convention (see ``tests/integration/test_api/
test_provider_error_mapping.py``). The knowledge router is mounted
standalone (no lifespan / full app) since the gate fires before any
body is even parsed — sub-dependency resolution runs ahead of
body/query/path validation in FastAPI's request handler, so a bare
request with no payload is enough to reach the gate on every route,
including the multipart upload endpoints.
For endpoints without a gate we stub the service call so the test
proves the request reached the handler (i.e. no 422 from the
missing-provider check), independent of MemoryRoot / SQLite setup.
"""
from __future__ import annotations
from collections.abc import AsyncIterator
from typing import Any
import pytest
from fastapi import FastAPI
from httpx import ASGITransport, AsyncClient
import everos.component.embedding.accessor as embedding_accessor
import everos.component.rerank.accessor as rerank_accessor
from everos.component.embedding import EmbeddingCapability
from everos.component.rerank import RerankCapability
from everos.entrypoints.api.exception_handlers import register_handlers
from everos.entrypoints.api.routes import knowledge as knowledge_routes
# Endpoints that MUST return 422 when embed or rerank is missing.
# These paths either write new md that cascade will need to embed
# (POST/PUT) or run a search that requires both providers
# (POST /search). PATCH is deliberately NOT here — see the ungated
# list below.
_GATED_ENDPOINTS: list[tuple[str, str]] = [
("POST", "/api/v1/knowledge/documents"),
("PUT", "/api/v1/knowledge/documents/d_abcdef123456"),
("POST", "/api/v1/knowledge/search"),
]
# Endpoints that MUST stay reachable even when both capabilities are
# missing. All of these touch only md + SQLite state that already
# exists on disk — none of them need the embedding or rerank provider.
# PATCH is here because ``patch_document`` only rewrites md frontmatter
# and moves the doc directory when category changes; no embed or rerank
# code runs on that path. A user who downgrades from Tier 3 → Tier 1/2
# must still be able to rename or recategorize the documents they
# created while providers were configured.
_UNGATED_ENDPOINTS: list[tuple[str, str]] = [
("DELETE", "/api/v1/knowledge/documents/d_abcdef123456"),
("GET", "/api/v1/knowledge/documents"),
("GET", "/api/v1/knowledge/documents/d_abcdef123456"),
("GET", "/api/v1/knowledge/topics/d_abcdef123456_0"),
("GET", "/api/v1/knowledge/categories"),
]
def _build_app() -> FastAPI:
app = FastAPI()
register_handlers(app)
# Match production mounting: create_app() mounts the router under
# /api/v1 (and /api/v2). The router's own prefix is /knowledge, so
# the effective paths become /api/v1/knowledge/... — same shape the
# _GATED / _UNGATED constants below use.
app.include_router(knowledge_routes.router, prefix="/api/v1")
return app
@pytest.fixture
async def client() -> AsyncIterator[AsyncClient]:
app = _build_app()
transport = ASGITransport(app=app, raise_app_exceptions=False)
async with AsyncClient(transport=transport, base_url="http://test") as c:
yield c
@pytest.fixture(autouse=True)
def _capabilities_unavailable_by_default(monkeypatch: pytest.MonkeyPatch) -> None:
"""Default both singletons to unavailable; tests opt into availability
per-capability via the fixtures below. Mirrors ``tests/unit/test_memory
/test_search/test_validate_components.py``.
"""
monkeypatch.setattr(
embedding_accessor, "_capability", EmbeddingCapability(provider=None)
)
monkeypatch.setattr(rerank_accessor, "_capability", RerankCapability(provider=None))
@pytest.fixture
def embed_available(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(
embedding_accessor, "_capability", EmbeddingCapability(provider=object())
)
@pytest.fixture
def rerank_available(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(
rerank_accessor, "_capability", RerankCapability(provider=object())
)
@pytest.fixture
def _stub_read_services(monkeypatch: pytest.MonkeyPatch) -> None:
"""Replace every read/delete/list service call with a benign stub.
The tests in this module care only about whether the capability
gate fires. Ungated endpoints must reach their handler even when
providers are missing — but reaching the handler with no
MemoryRoot / SQLite setup would 500. Stubbing keeps the assertion
focused on the gate, not on end-to-end service behavior.
"""
from everos.service import knowledge as knowledge_service
async def _fake_delete_document(*args: Any, **kwargs: Any) -> Any:
return knowledge_service.DeleteResult(doc_id="d_abcdef123456", deleted_topics=0)
async def _fake_list_documents(*args: Any, **kwargs: Any) -> Any:
return knowledge_service.DocumentListResult(
documents=[], total=0, page=1, page_size=20
)
async def _fake_get_document(*args: Any, **kwargs: Any) -> Any:
from everos.component.utils.datetime import get_utc_now
now = get_utc_now()
return knowledge_service.DocumentDetail(
doc_id="d_abcdef123456",
category_id="General",
title="stub",
summary="stub",
source_name=None,
source_type=None,
original_file_path=None,
topics=[],
created_at=now,
updated_at=now,
)
async def _fake_get_topic(*args: Any, **kwargs: Any) -> Any:
from everos.component.utils.datetime import get_utc_now
now = get_utc_now()
return knowledge_service.TopicDetail(
topic_id="d_abcdef123456_0",
doc_id="d_abcdef123456",
category_id="General",
topic_name="stub",
topic_path="stub",
depth=0,
summary="stub",
content="",
content_labels=[],
parent_topic_id=None,
children_topic_ids=[],
created_at=now,
updated_at=now,
)
async def _fake_list_categories(*args: Any, **kwargs: Any) -> list[Any]:
return []
monkeypatch.setattr(knowledge_routes, "delete_document", _fake_delete_document)
monkeypatch.setattr(knowledge_routes, "list_documents", _fake_list_documents)
monkeypatch.setattr(knowledge_routes, "get_document", _fake_get_document)
monkeypatch.setattr(knowledge_routes, "get_topic", _fake_get_topic)
monkeypatch.setattr(knowledge_routes, "list_categories", _fake_list_categories)
@pytest.fixture
def _stub_patch_document(monkeypatch: pytest.MonkeyPatch) -> None:
"""Stub ``patch_document`` to echo the requested title back.
Mirrors the real service contract: a title change lands in
``updated_fields`` and the caller sees the mutation reflected in
the response envelope. Keeps the assertion focused on the gate
(or lack of it) instead of on md / SQLite plumbing.
"""
from everos.component.utils.datetime import get_utc_now
from everos.service import knowledge as knowledge_service
captured: dict[str, Any] = {}
async def _fake_patch_document(
doc_id: str,
app_id: str,
project_id: str,
*,
title: str | None = None,
category_id: str | None = None,
) -> Any:
captured["title"] = title
captured["category_id"] = category_id
updated_fields: list[str] = []
if title is not None:
updated_fields.append("title")
if category_id is not None:
updated_fields.append("category_id")
return knowledge_service.PatchResult(
doc_id=doc_id,
updated_fields=updated_fields,
updated_at=get_utc_now(),
)
monkeypatch.setattr(knowledge_routes, "patch_document", _fake_patch_document)
async def _call(client: AsyncClient, method: str, path: str) -> Any:
return await client.request(method, path)
# ── Gated endpoints: 422 when embed missing (rerank also missing) ────────
@pytest.mark.parametrize("method,path", _GATED_ENDPOINTS)
async def test_gated_endpoint_422_when_embed_missing(
client: AsyncClient, method: str, path: str
) -> None:
"""Write/search endpoints must 422 with an embedding-specific message
when embedding is missing (check-embedding-first order per Task 12/13).
"""
resp = await _call(client, method, path)
assert resp.status_code == 422
body = resp.json()
assert body["error"]["code"] == "PROVIDER_NOT_CONFIGURED"
assert "embedding" in body["error"]["message"]
assert "knowledge" in body["error"]["message"]
# ── Gated endpoints: 422 when only rerank missing ───────────────────────
@pytest.mark.parametrize("method,path", _GATED_ENDPOINTS)
async def test_gated_endpoint_422_when_rerank_missing(
client: AsyncClient, method: str, path: str, embed_available: None
) -> None:
"""Write/search endpoints must 422 with a rerank-specific message
when only rerank is missing.
"""
resp = await _call(client, method, path)
assert resp.status_code == 422
body = resp.json()
assert body["error"]["code"] == "PROVIDER_NOT_CONFIGURED"
assert "rerank" in body["error"]["message"]
assert "knowledge" in body["error"]["message"]
# ── Ungated endpoints: must NOT 422 even with both capabilities missing ─
@pytest.mark.parametrize("method,path", _UNGATED_ENDPOINTS)
async def test_ungated_endpoint_not_422_without_any_capability(
client: AsyncClient,
method: str,
path: str,
_stub_read_services: None,
) -> None:
"""Read/list/delete endpoints must stay reachable even when neither
embed nor rerank is configured — a Tier-3 → Tier-1 downgrade must
not lock users out of inspecting and cleaning up their own state.
"""
resp = await _call(client, method, path)
assert resp.status_code != 422, (
f"{method} {path} returned 422 despite the gate being scoped away "
f"from read/delete endpoints; body={resp.text}"
)
# ── Ungated endpoints: also fine with only embed (Tier-2 downgrade) ─────
@pytest.mark.parametrize("method,path", _UNGATED_ENDPOINTS)
async def test_ungated_endpoint_not_422_with_embed_only(
client: AsyncClient,
method: str,
path: str,
embed_available: None,
_stub_read_services: None,
) -> None:
"""Tier-2 scenario (embed present, rerank missing): read/delete paths
must stay reachable — this is the concrete regression from the
router-wide-gate era that this fix targets.
"""
resp = await _call(client, method, path)
assert resp.status_code != 422, (
f"{method} {path} returned 422 despite rerank not being needed; "
f"body={resp.text}"
)
# ── Gated endpoints: pass the gate when both are available ──────────────
async def test_search_not_422_when_both_available(
client: AsyncClient,
monkeypatch: pytest.MonkeyPatch,
embed_available: None,
rerank_available: None,
) -> None:
"""Both capabilities available: the gate must not fire on /search.
Stub the service call so the assertion is about the gate, not the
rest of the search pipeline (out of scope for this task).
"""
from everos.service import knowledge as knowledge_service
stub_result = knowledge_service.SearchKnowledgeResult(hits=[], total=0, took_ms=0.0)
async def _fake_search_knowledge(**kwargs: Any) -> Any:
return stub_result
monkeypatch.setattr(knowledge_routes, "search_knowledge", _fake_search_knowledge)
resp = await client.post(
"/api/v1/knowledge/search",
json={"query": "hello"},
)
assert resp.status_code != 422
# ── PATCH: metadata-only, must succeed regardless of provider config ────
_PATCH_BODY: dict[str, str] = {
"app_id": "app1",
"project_id": "proj1",
"title": "renamed",
}
async def test_knowledge_patch_document_succeeds_without_rerank(
client: AsyncClient,
embed_available: None,
_stub_patch_document: None,
) -> None:
"""Tier-2 scenario (embed configured, rerank missing): PATCH must
still land. Renaming an existing document does not touch rerank —
it only rewrites md frontmatter and upserts SQLite.
"""
resp = await client.patch(
"/api/v1/knowledge/documents/d_abcdef123456",
json=_PATCH_BODY,
)
assert resp.status_code == 200, resp.text
body = resp.json()
assert body["data"]["doc_id"] == "d_abcdef123456"
assert "title" in body["data"]["updated_fields"]
async def test_knowledge_patch_document_succeeds_without_any_capability(
client: AsyncClient,
_stub_patch_document: None,
) -> None:
"""Tier-1 scenario (no embed, no rerank): PATCH must still land.
Metadata-only edits are a cleanup verb — locking users out of
renaming their own docs after a full downgrade contradicts the
per-endpoint-gate contract documented on
``_require_knowledge_capabilities``.
"""
resp = await client.patch(
"/api/v1/knowledge/documents/d_abcdef123456",
json=_PATCH_BODY,
)
assert resp.status_code == 200, resp.text
body = resp.json()
assert body["data"]["doc_id"] == "d_abcdef123456"
assert "title" in body["data"]["updated_fields"]