test: add Pact consumer contract for astrolabe credentials status (ADR-029)

Introduce consumer-driven contract testing between nextcloud-mcp-server and the
astrolabe Nextcloud app, published to the homelab Pact Broker and verified in CI.

- pact-python dev dep + `contract` pytest marker
- tests/contract/test_astrolabe_credentials_consumer.py: consumer pact for the
  background-sync *status* call (provisioned -> has_background_access:true,
  sync_type:"app_password", integer provisioned_at; unprovisioned -> false/null)
- tests/contract/test_mcp_provider_verification.py: env-gated Verifier harness
  for this server's /api/v1/* provider role (provider-state handlers stubbed
  pending astrolabe's published pacts)
- .github/workflows/pact.yml: join tailnet -> publish pacts -> provider verify
  -> can-i-deploy; broker steps skip when PACT_BROKER is unset (forks)
- docs/ADR-029-pact-contract-testing.md

Fix astrolabe_client.get_background_sync_status: it previously read a
non-existent `app_password` field (always reporting no-access). Rewrite it to
read the real status contract (has_background_access / sync_type /
provisioned_at) and drop the unsatisfiable get_user_app_password.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Chris Coutinho
2026-06-10 20:22:07 +02:00
co-authored by Claude Opus 4.8
parent 56ed28b421
commit d33832aba9
10 changed files with 603 additions and 33 deletions
+109
View File
@@ -0,0 +1,109 @@
# ADR-029: Pact contract testing with astrolabe
## Status
Accepted — 2026-06-10
## Context
`nextcloud-mcp-server` (Python/FastMCP) and the `astrolabe` Nextcloud app (PHP)
integrate over HTTP **in both directions**:
- **MCP server → astrolabe** (one call): the background vector-sync reads a
user's provisioning **status** via
`GET /apps/astrolabe/api/v1/background-sync/credentials/{user_id}`
(`nextcloud_mcp_server/auth/astrolabe_client.py`). This returns status only —
`{success, user_id, has_background_access, sync_type, provisioned_at}`, always
HTTP 200, and **never the password**. The app password itself flows the other
way: astrolabe pushes it to the MCP server's `/api/v1/users/{uid}/app-password`.
- **astrolabe → MCP server** (~10 calls): astrolabe's `McpServerClient` consumes
this server's `/api/v1/*` HTTP API — `status`, `vector-sync/status`, `search`,
`vector-viz/search`, `webhooks` (GET/POST/DELETE), `apps`, `chunk-context`,
`pdf-preview` (`nextcloud_mcp_server/app.py` route table, `api/webhooks.py`).
Today nothing guarantees the two stay wire-compatible. A renamed JSON field or a
changed status code on either side is only caught — if at all — by the heavy
integration matrix (docker-compose, real Nextcloud, minutes per run). We want a
fast, focused signal that fails the moment the contract drifts, runnable in
unit-style CI on either repo independently.
## Decision
Adopt **consumer-driven contract testing with Pact**, with a self-hosted **Pact
Broker in the homelab** as the system of record. GitHub Actions reach the broker
**over Tailscale** (the broker is not publicly exposed).
Because the integration is bidirectional, each repo plays **both** Pact roles:
| Contract | Consumer | Provider | Consumer tooling | Provider tooling |
|----------|----------|----------|------------------|------------------|
| credentials API | nextcloud-mcp-server | astrolabe | `pact-python` | `pact-php` verifier |
| `/api/v1/*` API | astrolabe | nextcloud-mcp-server | `pact-php` | `pact-python` `Verifier` |
### This repo (Python side)
- `pact-python` (v3 API, `from pact import Pact, Verifier, match`) is a **dev**
dependency.
- Contract tests live under `tests/contract/` behind a `contract` pytest marker,
kept out of the default `unit` run.
- **Consumer** (`test_astrolabe_credentials_consumer.py`): drives the real
`AstrolabeClient` against a Pact mock server, pinning the request shape and
the two status responses it branches on — provisioned
(`has_background_access: true`, `sync_type: "app_password"`, integer
`provisioned_at`) and unprovisioned (`has_background_access: false`). The
OAuth token fetch is stubbed so only the credentials call is exercised.
Interactions merge into `tests/contract/pacts/` (git-ignored — pacts are
published to the broker, not committed).
- **Provider** (`test_mcp_provider_verification.py`): a `Verifier` harness that
replays astrolabe's published pacts against a running MCP server. It is
**environment-gated** (`PACT_PROVIDER_URL` + a pact source) so it skips in
the consumer-only job and in local runs. Provider-state handlers are
registered by `given(...)` string in `_PROVIDER_STATES` and filled in as
astrolabe publishes its consumer pacts; unknown states no-op so state-less
interactions (`/api/v1/status`, `/api/v1/vector-sync/status`) verify
immediately.
### CI (`.github/workflows/pact.yml`)
Three jobs, each joining the tailnet with the shared `tag:github-runner` OAuth
client before touching the broker:
1. **consumer** — generate pacts, publish to the broker tagged with the branch
and commit SHA.
2. **provider** — stand up the MCP server (single-user compose profile), verify
astrolabe's pacts against it, publish verification results (master only).
3. **can-i-deploy** — gate `master` on `pact-broker can-i-deploy … --pacticipant
nextcloud-mcp-server --to-environment production`.
Broker-dependent steps are skipped when `PACT_BROKER` is unset (forks).
### Infrastructure (other repos / sessions)
- **Broker**: `pactfoundation/pact-broker` deployed via ArgoCD
(`homelab-argocd`), backed by the shared Zalando Postgres, reachable at
`pact-broker.internal.coutinho.io`. Single basic-auth credential from AWS
Secrets Manager via external-secrets.
- **CI access**: jobs join the tailnet (`tag:github-runner`) and reach the
broker via the `PACT_BROKER` secret (its Tailscale host). No new Terraform is
required.
### Required secrets (this repo)
`TS_OAUTH_CLIENT_ID`, `TS_OAUTH_SECRET` (Tailscale github-runner OAuth client),
`PACT_BROKER` (base URL), `PACT_USERNAME`, `PACT_PASSWORD` (broker basic auth).
## Consequences
- **Fast, independent signal**: either side detects an incompatible change in a
~seconds-long job instead of waiting on the integration matrix.
- **Participant names are load-bearing**: the consumer/provider names
(`nextcloud-mcp-server`, `astrolabe`) must match exactly across both repos and
the broker. They live in `tests/contract/conftest.py` here and must mirror the
astrolabe pact tests.
- **Provider states are deferred work**: the `/api/v1/*` provider verification is
only as complete as the state handlers that seed its backends (webhooks DB,
Qdrant). These land incrementally as astrolabe publishes its consumer pacts.
- **Broker is a homelab dependency**: contract publication/verification needs the
tailnet and the broker up. Steps degrade to skipped (not failed) when the
broker is unreachable from a fork; on `master` an outage will fail
`can-i-deploy`.