Merge pull request #912 from cbcoutinho/test/pact-searchable-sources
test(contract): pin the OCS-capabilities consumer contract with astrolabe
This commit is contained in:
@@ -0,0 +1,115 @@
|
|||||||
|
"""Consumer contract: nextcloud-mcp-server -> astrolabe OCS capabilities.
|
||||||
|
|
||||||
|
The MCP server reads which content sources an admin has approved for semantic
|
||||||
|
search via :func:`nextcloud_mcp_server.capabilities.allowed_doc_types`, which
|
||||||
|
calls :meth:`NextcloudClient.capabilities` →
|
||||||
|
``GET /ocs/v2.php/cloud/capabilities`` and parses
|
||||||
|
``ocs.data.capabilities.astrolabe.semantic_search.enabled_doc_types`` (the
|
||||||
|
``OCA\\Astrolabe\\Capabilities`` provider on the astrolabe side).
|
||||||
|
|
||||||
|
This pact pins the request shape and the two states the consumer branches on:
|
||||||
|
|
||||||
|
- some sources approved -> ``enabled_doc_types`` is a non-empty list, parsed to
|
||||||
|
the corresponding frozenset (the search/scan/purge gates restrict to it)
|
||||||
|
- every source disabled -> ``enabled_doc_types`` is ``[]``, parsed to an empty
|
||||||
|
frozenset (an all-disabled admin config — distinct from "no restriction")
|
||||||
|
|
||||||
|
The "no astrolabe block" / fail-open (``None``) case is internal defensive
|
||||||
|
handling for older astrolabe versions, not a contract obligation of the current
|
||||||
|
provider, so it is covered by a unit test (``tests/unit/test_capabilities.py``)
|
||||||
|
rather than a pact interaction.
|
||||||
|
|
||||||
|
Only the capability block astrolabe owns is pinned — the real OCS response also
|
||||||
|
carries many other apps' capabilities, which pact ignores (unspecified keys are
|
||||||
|
allowed in the provider response).
|
||||||
|
|
||||||
|
See ADR-029 for the overall contract-testing architecture.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from httpx import BasicAuth
|
||||||
|
|
||||||
|
from nextcloud_mcp_server.capabilities import allowed_doc_types, clear_cache
|
||||||
|
from nextcloud_mcp_server.client import NextcloudClient
|
||||||
|
|
||||||
|
pytestmark = pytest.mark.contract
|
||||||
|
|
||||||
|
|
||||||
|
def _ocs_capabilities(enabled_doc_types: list[str]) -> dict:
|
||||||
|
"""Minimal OCS envelope carrying just astrolabe's semantic_search block.
|
||||||
|
|
||||||
|
Intentionally omits the rest of a real OCS response (other apps'
|
||||||
|
capabilities, ``meta.statuscode``/``message``, etc.): Pact V4 allows extra
|
||||||
|
provider-side keys, so pinning only the block this consumer reads keeps the
|
||||||
|
contract focused on what astrolabe owns without coupling to Nextcloud-core
|
||||||
|
envelope fields.
|
||||||
|
"""
|
||||||
|
return {
|
||||||
|
"ocs": {
|
||||||
|
"meta": {"status": "ok"},
|
||||||
|
"data": {
|
||||||
|
"capabilities": {
|
||||||
|
"astrolabe": {
|
||||||
|
"semantic_search": {
|
||||||
|
"enabled_doc_types": enabled_doc_types,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def test_capabilities_report_admin_approved_doc_types(consumer_pact):
|
||||||
|
"""Approved sources are returned and parsed into the allow-set."""
|
||||||
|
clear_cache()
|
||||||
|
(
|
||||||
|
consumer_pact.upon_receiving("a request for OCS capabilities")
|
||||||
|
.given("astrolabe has approved file and note for semantic search")
|
||||||
|
.with_request("GET", "/ocs/v2.php/cloud/capabilities")
|
||||||
|
.with_header("OCS-APIRequest", "true")
|
||||||
|
.will_respond_with(200)
|
||||||
|
.with_body(
|
||||||
|
_ocs_capabilities(["file", "note"]),
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
with consumer_pact.serve() as srv:
|
||||||
|
client = NextcloudClient(
|
||||||
|
base_url=str(srv.url),
|
||||||
|
username="admin",
|
||||||
|
auth=BasicAuth("admin", "app-password"),
|
||||||
|
)
|
||||||
|
allowed = await allowed_doc_types(client, "admin")
|
||||||
|
|
||||||
|
assert allowed == frozenset({"file", "note"})
|
||||||
|
|
||||||
|
|
||||||
|
async def test_capabilities_report_all_sources_disabled(consumer_pact):
|
||||||
|
"""An empty allow-set (admin disabled everything) is distinct from None."""
|
||||||
|
clear_cache()
|
||||||
|
(
|
||||||
|
consumer_pact.upon_receiving(
|
||||||
|
"a request for OCS capabilities with every source disabled"
|
||||||
|
)
|
||||||
|
.given("astrolabe has disabled all sources for semantic search")
|
||||||
|
.with_request("GET", "/ocs/v2.php/cloud/capabilities")
|
||||||
|
.with_header("OCS-APIRequest", "true")
|
||||||
|
.will_respond_with(200)
|
||||||
|
.with_body(
|
||||||
|
_ocs_capabilities([]),
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
with consumer_pact.serve() as srv:
|
||||||
|
client = NextcloudClient(
|
||||||
|
base_url=str(srv.url),
|
||||||
|
username="admin",
|
||||||
|
auth=BasicAuth("admin", "app-password"),
|
||||||
|
)
|
||||||
|
allowed = await allowed_doc_types(client, "admin")
|
||||||
|
|
||||||
|
# Present-but-empty => empty frozenset (restrict everything), NOT None.
|
||||||
|
assert allowed == frozenset()
|
||||||
@@ -70,7 +70,22 @@ pytestmark = [
|
|||||||
# fixtures, etc.). Keep the keys identical to the astrolabe ``given(...)``
|
# fixtures, etc.). Keep the keys identical to the astrolabe ``given(...)``
|
||||||
# strings. Unhandled states fall through to ``_dispatch_state`` which logs and
|
# strings. Unhandled states fall through to ``_dispatch_state`` which logs and
|
||||||
# no-ops, so state-less interactions still verify.
|
# no-ops, so state-less interactions still verify.
|
||||||
|
def _state_admin_can_purge() -> None:
|
||||||
|
"""Provider state for astrolabe's consent-purge pact
|
||||||
|
(``POST /api/v1/vector-sync/purge``).
|
||||||
|
|
||||||
|
Full verification of this authenticated endpoint (admin OAuth token +
|
||||||
|
Nextcloud admin-group check + Qdrant delete) needs the live-stack auth
|
||||||
|
test-hook that is the ADR-029 phase-4 follow-up. Until then the interaction
|
||||||
|
rides the broker's pending flow (see ``include_pending`` below); this handler
|
||||||
|
is registered so the dispatcher recognises the state by name rather than
|
||||||
|
logging an "unhandled state" warning.
|
||||||
|
"""
|
||||||
|
# Intentionally empty: no live-stack state to set up yet (phase 4).
|
||||||
|
|
||||||
|
|
||||||
_PROVIDER_STATES: dict[str, Callable[[], None]] = {
|
_PROVIDER_STATES: dict[str, Callable[[], None]] = {
|
||||||
|
"an admin can purge indexed documents": _state_admin_can_purge,
|
||||||
# "a webhook is registered for user alice": _state_webhook_registered,
|
# "a webhook is registered for user alice": _state_webhook_registered,
|
||||||
# "vector sync has indexed documents": _state_vector_sync_ran,
|
# "vector sync has indexed documents": _state_vector_sync_ran,
|
||||||
# "the search index returns a hit for 'budget'": _state_search_has_hit,
|
# "the search index returns a hit for 'budget'": _state_search_has_hit,
|
||||||
@@ -102,10 +117,24 @@ def test_verify_astrolabe_consumer_pacts() -> None:
|
|||||||
verifier = Verifier(PROVIDER_NAME).add_transport(url=_PROVIDER_URL)
|
verifier = Verifier(PROVIDER_NAME).add_transport(url=_PROVIDER_URL)
|
||||||
verifier.state_handler(_dispatch_state, teardown=True)
|
verifier.state_handler(_dispatch_state, teardown=True)
|
||||||
|
|
||||||
if _BROKER_URL and _BROKER_USERNAME and _BROKER_PASSWORD:
|
if _BROKER_READY:
|
||||||
verifier.broker_source(
|
# selector=True to opt into pending pacts: a new/authenticated contract
|
||||||
_BROKER_URL, username=_BROKER_USERNAME, password=_BROKER_PASSWORD
|
# (e.g. the consent-purge endpoint) reports as *pending* instead of
|
||||||
|
# failing this build until provider verification of the authenticated
|
||||||
|
# surface is stood up (ADR-029 phase 4). Already-verified interactions
|
||||||
|
# (GET /api/v1/status) stay blocking. Empty consumer selectors keep the
|
||||||
|
# default "latest pacts for this provider" fetch.
|
||||||
|
broker = verifier.broker_source(
|
||||||
|
_BROKER_URL,
|
||||||
|
username=_BROKER_USERNAME,
|
||||||
|
password=_BROKER_PASSWORD,
|
||||||
|
selector=True,
|
||||||
)
|
)
|
||||||
|
broker.include_pending()
|
||||||
|
provider_branch = os.environ.get("PACT_PROVIDER_BRANCH")
|
||||||
|
if provider_branch:
|
||||||
|
broker.provider_branch(provider_branch)
|
||||||
|
broker.build()
|
||||||
else:
|
else:
|
||||||
assert _LOCAL_PACT_DIR is not None # guaranteed by module skipif
|
assert _LOCAL_PACT_DIR is not None # guaranteed by module skipif
|
||||||
verifier.add_source(_LOCAL_PACT_DIR)
|
verifier.add_source(_LOCAL_PACT_DIR)
|
||||||
|
|||||||
Reference in New Issue
Block a user