Merge pull request #883 from cbcoutinho/worktree-tender-stargazing-sundae
test: Pact consumer contract for astrolabe credentials status (ADR-029)
This commit is contained in:
@@ -0,0 +1,161 @@
|
|||||||
|
name: Pact contract tests
|
||||||
|
|
||||||
|
# Consumer-driven contract testing against the homelab-hosted Pact Broker
|
||||||
|
# (ADR-029). The broker is only reachable over Tailscale, so every job that
|
||||||
|
# talks to it first joins the tailnet with the shared github-runner OAuth
|
||||||
|
# client. Jobs no-op when the broker secrets are absent (e.g. on forks).
|
||||||
|
#
|
||||||
|
# Required repo/org secrets:
|
||||||
|
# TS_OAUTH_CLIENT_ID / TS_OAUTH_SECRET - Tailscale github-runner OAuth client
|
||||||
|
# PACT_BROKER - broker base URL (https://pact-broker.internal.coutinho.io)
|
||||||
|
# PACT_USERNAME / PACT_PASSWORD - broker basic-auth credentials
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
branches:
|
||||||
|
- master
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- master
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: pact-${{ github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
env:
|
||||||
|
PACT_BROKER: ${{ secrets.PACT_BROKER }}
|
||||||
|
PACT_USERNAME: ${{ secrets.PACT_USERNAME }}
|
||||||
|
PACT_PASSWORD: ${{ secrets.PACT_PASSWORD }}
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
consumer:
|
||||||
|
name: Consumer pacts (mcp -> astrolabe)
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
# Skip on forks / when broker is not configured.
|
||||||
|
if: ${{ github.event_name == 'push' || github.event.pull_request.head.repo.full_name == github.repository }}
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||||
|
|
||||||
|
- name: Install the latest version of uv
|
||||||
|
uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0
|
||||||
|
|
||||||
|
- name: Generate consumer pacts
|
||||||
|
run: uv run pytest -v -m contract tests/contract/
|
||||||
|
|
||||||
|
# Only publish from non-fork builds that have the broker secrets.
|
||||||
|
- name: Join tailnet
|
||||||
|
if: ${{ env.PACT_BROKER != '' }}
|
||||||
|
uses: tailscale/github-action@6cae46e2d796f265265cfcf628b72a32b4d7cade # v3
|
||||||
|
with:
|
||||||
|
oauth-client-id: ${{ secrets.TS_OAUTH_CLIENT_ID }}
|
||||||
|
oauth-secret: ${{ secrets.TS_OAUTH_SECRET }}
|
||||||
|
tags: tag:github-runner
|
||||||
|
|
||||||
|
- name: Install Pact CLI
|
||||||
|
if: ${{ env.PACT_BROKER != '' }}
|
||||||
|
run: |
|
||||||
|
curl -fsSL https://raw.githubusercontent.com/pact-foundation/pact-ruby-standalone/f03e620e7552239b6ca59438c9beed9d1038c949/install.sh | bash # v2.6.1
|
||||||
|
echo "$PWD/pact/bin" >> "$GITHUB_PATH"
|
||||||
|
|
||||||
|
- name: Publish pacts to broker
|
||||||
|
if: ${{ env.PACT_BROKER != '' }}
|
||||||
|
run: |
|
||||||
|
pact-broker publish tests/contract/pacts \
|
||||||
|
--broker-base-url "$PACT_BROKER" \
|
||||||
|
--broker-username "$PACT_USERNAME" \
|
||||||
|
--broker-password "$PACT_PASSWORD" \
|
||||||
|
--consumer-app-version "${{ github.sha }}" \
|
||||||
|
--branch "${{ github.head_ref || github.ref_name }}"
|
||||||
|
|
||||||
|
provider:
|
||||||
|
name: Provider verification (astrolabe -> mcp)
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
if: ${{ github.event_name == 'push' || github.event.pull_request.head.repo.full_name == github.repository }}
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||||
|
|
||||||
|
- name: Install the latest version of uv
|
||||||
|
uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0
|
||||||
|
|
||||||
|
# Stand up the MCP server. NOTE: single-user only exposes the *public*
|
||||||
|
# management endpoints (/api/v1/status, /api/v1/vector-sync/status) — see
|
||||||
|
# app.py:2248. The authenticated surface astrolabe also consumes
|
||||||
|
# (/api/v1/search, /webhooks, /apps, /chunk-context, /pdf-preview) needs
|
||||||
|
# an OAuth-capable profile (login-flow) plus Bearer-token injection into
|
||||||
|
# the verifier (Verifier.add_custom_header). That is the phase-4 follow-up;
|
||||||
|
# this job currently verifies the public-endpoint pacts.
|
||||||
|
- name: Generate ephemeral TOKEN_ENCRYPTION_KEY
|
||||||
|
run: |
|
||||||
|
KEY=$(openssl rand -base64 32 | tr '+/' '-_')
|
||||||
|
echo "TOKEN_ENCRYPTION_KEY=${KEY}" >> "$GITHUB_ENV"
|
||||||
|
|
||||||
|
- name: Start MCP server
|
||||||
|
uses: hoverkraft-tech/compose-action@11beaa1c2dae4e8ed7b1665aa074723b6cecb0e4 # v3.0.0
|
||||||
|
with:
|
||||||
|
compose-file: "./docker-compose.yml"
|
||||||
|
compose-flags: "--profile single-user"
|
||||||
|
up-flags: "--build"
|
||||||
|
env:
|
||||||
|
TOKEN_ENCRYPTION_KEY: ${{ env.TOKEN_ENCRYPTION_KEY }}
|
||||||
|
|
||||||
|
- name: Wait for MCP server
|
||||||
|
run: |
|
||||||
|
for i in $(seq 1 30); do
|
||||||
|
code=$(curl -o /dev/null -s -w "%{http_code}" http://localhost:8000/api/v1/status || true)
|
||||||
|
[ "$code" = "200" ] && echo "ready" && exit 0
|
||||||
|
sleep 5
|
||||||
|
done
|
||||||
|
docker compose --profile single-user logs mcp
|
||||||
|
exit 1
|
||||||
|
|
||||||
|
- name: Join tailnet
|
||||||
|
if: ${{ env.PACT_BROKER != '' }}
|
||||||
|
uses: tailscale/github-action@6cae46e2d796f265265cfcf628b72a32b4d7cade # v3
|
||||||
|
with:
|
||||||
|
oauth-client-id: ${{ secrets.TS_OAUTH_CLIENT_ID }}
|
||||||
|
oauth-secret: ${{ secrets.TS_OAUTH_SECRET }}
|
||||||
|
tags: tag:github-runner
|
||||||
|
|
||||||
|
- name: Verify provider against broker pacts
|
||||||
|
if: ${{ env.PACT_BROKER != '' }}
|
||||||
|
env:
|
||||||
|
PACT_PROVIDER_URL: http://localhost:8000
|
||||||
|
PACT_PROVIDER_VERSION: ${{ github.sha }}
|
||||||
|
PACT_PROVIDER_BRANCH: ${{ github.head_ref || github.ref_name }}
|
||||||
|
# Publish results only from master so PR runs don't pollute the matrix.
|
||||||
|
PACT_PUBLISH_RESULTS: ${{ github.ref == 'refs/heads/master' }}
|
||||||
|
run: uv run pytest -v -m contract tests/contract/test_mcp_provider_verification.py
|
||||||
|
|
||||||
|
can-i-deploy:
|
||||||
|
name: can-i-deploy
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: [consumer, provider]
|
||||||
|
# Only the `github` context is available in a job-level `if`, so the broker
|
||||||
|
# guard lives on each step below (the pact-broker CLI errors on an empty
|
||||||
|
# --broker-base-url, e.g. after a secret rotation or on a fork).
|
||||||
|
if: ${{ github.ref == 'refs/heads/master' }}
|
||||||
|
steps:
|
||||||
|
- name: Join tailnet
|
||||||
|
if: ${{ env.PACT_BROKER != '' }}
|
||||||
|
uses: tailscale/github-action@6cae46e2d796f265265cfcf628b72a32b4d7cade # v3
|
||||||
|
with:
|
||||||
|
oauth-client-id: ${{ secrets.TS_OAUTH_CLIENT_ID }}
|
||||||
|
oauth-secret: ${{ secrets.TS_OAUTH_SECRET }}
|
||||||
|
tags: tag:github-runner
|
||||||
|
|
||||||
|
- name: Install Pact CLI
|
||||||
|
if: ${{ env.PACT_BROKER != '' }}
|
||||||
|
run: |
|
||||||
|
curl -fsSL https://raw.githubusercontent.com/pact-foundation/pact-ruby-standalone/f03e620e7552239b6ca59438c9beed9d1038c949/install.sh | bash # v2.6.1
|
||||||
|
echo "$PWD/pact/bin" >> "$GITHUB_PATH"
|
||||||
|
|
||||||
|
- name: Can I deploy nextcloud-mcp-server?
|
||||||
|
if: ${{ env.PACT_BROKER != '' }}
|
||||||
|
run: |
|
||||||
|
pact-broker can-i-deploy \
|
||||||
|
--broker-base-url "$PACT_BROKER" \
|
||||||
|
--broker-username "$PACT_USERNAME" \
|
||||||
|
--broker-password "$PACT_PASSWORD" \
|
||||||
|
--pacticipant nextcloud-mcp-server \
|
||||||
|
--version "${{ github.sha }}" \
|
||||||
|
--to-environment production
|
||||||
@@ -24,3 +24,6 @@ docker-compose.override.yml
|
|||||||
|
|
||||||
# RAG Evaluation
|
# RAG Evaluation
|
||||||
tests/rag_evaluation/fixtures/
|
tests/rag_evaluation/fixtures/
|
||||||
|
|
||||||
|
# Pact contract tests — generated pacts are published to the broker, not committed
|
||||||
|
tests/contract/pacts/
|
||||||
|
|||||||
@@ -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`.
|
||||||
@@ -7,7 +7,6 @@ and retrieve user app passwords for background sync operations.
|
|||||||
|
|
||||||
import logging
|
import logging
|
||||||
import time
|
import time
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
from ..http import nextcloud_httpx_client
|
from ..http import nextcloud_httpx_client
|
||||||
|
|
||||||
@@ -38,7 +37,7 @@ class AstrolabeClient:
|
|||||||
self.nextcloud_host = nextcloud_host.rstrip("/")
|
self.nextcloud_host = nextcloud_host.rstrip("/")
|
||||||
self.client_id = client_id
|
self.client_id = client_id
|
||||||
self.client_secret = client_secret
|
self.client_secret = client_secret
|
||||||
self._token_cache: Optional[dict] = None # {access_token, expires_at}
|
self._token_cache: dict | None = None # {access_token, expires_at}
|
||||||
|
|
||||||
async def get_access_token(self) -> str:
|
async def get_access_token(self) -> str:
|
||||||
"""
|
"""
|
||||||
@@ -91,24 +90,32 @@ class AstrolabeClient:
|
|||||||
logger.info("Obtained Astrolabe API token (expires in %ss)", expires_in)
|
logger.info("Obtained Astrolabe API token (expires in %ss)", expires_in)
|
||||||
return data["access_token"]
|
return data["access_token"]
|
||||||
|
|
||||||
async def get_user_app_password(self, user_id: str) -> Optional[str]:
|
async def get_background_sync_status(self, user_id: str) -> dict:
|
||||||
"""
|
"""
|
||||||
Retrieve user's app password for background sync.
|
Get background sync provisioning status for a user.
|
||||||
|
|
||||||
|
Queries Astrolabe's admin credentials-metadata endpoint, which returns
|
||||||
|
presence/timestamps only — never the app password itself. The password
|
||||||
|
is delivered to the MCP server out-of-band (Astrolabe pushes it to
|
||||||
|
``POST /api/v1/users/{user_id}/app-password``), so this endpoint exposes
|
||||||
|
only ``has_background_access`` / ``sync_type`` / ``provisioned_at``.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
user_id: Nextcloud user ID
|
user_id: Nextcloud user ID
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
App password string, or None if user hasn't provisioned
|
Dict with keys: has_access (bool), credential_type (str | None),
|
||||||
|
provisioned_at (int | None) — Unix seconds, not an ISO string.
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
httpx.HTTPError: If API request fails (except 404)
|
httpx.HTTPError: If the API request fails (except 404, treated as
|
||||||
|
"not provisioned").
|
||||||
"""
|
"""
|
||||||
token = await self.get_access_token()
|
token = await self.get_access_token()
|
||||||
url = f"{self.nextcloud_host}/apps/astrolabe/api/v1/background-sync/credentials/{user_id}"
|
url = f"{self.nextcloud_host}/apps/astrolabe/api/v1/background-sync/credentials/{user_id}"
|
||||||
|
|
||||||
async with nextcloud_httpx_client() as client:
|
async with nextcloud_httpx_client() as client:
|
||||||
logger.debug("Retrieving app password for user: %s", user_id)
|
logger.debug("Fetching background-sync status for user: %s", user_id)
|
||||||
|
|
||||||
response = await client.get(
|
response = await client.get(
|
||||||
url,
|
url,
|
||||||
@@ -117,38 +124,24 @@ class AstrolabeClient:
|
|||||||
)
|
)
|
||||||
|
|
||||||
if response.status_code == 404:
|
if response.status_code == 404:
|
||||||
logger.debug("No app password configured for user: %s", user_id)
|
logger.debug("No background-sync credentials for user: %s", user_id)
|
||||||
return None
|
return {
|
||||||
|
"has_access": False,
|
||||||
|
"credential_type": None,
|
||||||
|
"provisioned_at": None,
|
||||||
|
}
|
||||||
|
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
data = response.json()
|
data = response.json()
|
||||||
|
|
||||||
|
has_access = bool(data.get("has_background_access"))
|
||||||
logger.info(
|
logger.info(
|
||||||
"Retrieved app password for user: %s (type: %s)",
|
"Background-sync status for user %s: has_access=%s",
|
||||||
user_id,
|
user_id,
|
||||||
data.get("credential_type"),
|
has_access,
|
||||||
)
|
)
|
||||||
return data.get("app_password")
|
return {
|
||||||
|
"has_access": has_access,
|
||||||
async def get_background_sync_status(self, user_id: str) -> dict:
|
"credential_type": data.get("sync_type"),
|
||||||
"""
|
"provisioned_at": data.get("provisioned_at"),
|
||||||
Get background sync status for a user.
|
}
|
||||||
|
|
||||||
Args:
|
|
||||||
user_id: Nextcloud user ID
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Dict with keys: has_access, credential_type, provisioned_at
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
httpx.HTTPError: If API request fails
|
|
||||||
"""
|
|
||||||
# For now, check if app password exists
|
|
||||||
# In the future, this could query a dedicated status endpoint
|
|
||||||
app_password = await self.get_user_app_password(user_id)
|
|
||||||
|
|
||||||
return {
|
|
||||||
"has_access": app_password is not None,
|
|
||||||
"credential_type": "app_password" if app_password else None,
|
|
||||||
"provisioned_at": None, # TODO: Get from API if available
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -118,7 +118,13 @@ async def _get_provisioning_status(ctx: Context, user_id: str) -> ProvisioningSt
|
|||||||
" get_provisioning_status: app password FOUND for user_id=%s",
|
" get_provisioning_status: app password FOUND for user_id=%s",
|
||||||
user_id,
|
user_id,
|
||||||
)
|
)
|
||||||
provisioned_at_str = status.get("provisioned_at")
|
# Astrolabe returns provisioned_at as Unix seconds (see the
|
||||||
|
# contract pact); convert to the ISO string the model expects.
|
||||||
|
provisioned_at_str = None
|
||||||
|
provisioned_at_raw = status.get("provisioned_at")
|
||||||
|
if provisioned_at_raw:
|
||||||
|
dt = datetime.fromtimestamp(provisioned_at_raw, tz=timezone.utc)
|
||||||
|
provisioned_at_str = dt.isoformat()
|
||||||
return ProvisioningStatus(
|
return ProvisioningStatus(
|
||||||
is_provisioned=True,
|
is_provisioned=True,
|
||||||
provisioned_at=provisioned_at_str,
|
provisioned_at=provisioned_at_str,
|
||||||
|
|||||||
@@ -81,6 +81,7 @@ markers = [
|
|||||||
"login_flow: Login Flow v2 integration tests (ADR-022)",
|
"login_flow: Login Flow v2 integration tests (ADR-022)",
|
||||||
"multi_user_basic: Multi-user BasicAuth pass-through tests (ADR-020)",
|
"multi_user_basic: Multi-user BasicAuth pass-through tests (ADR-020)",
|
||||||
"postgres: Tests requiring the docker-compose postgres-test service (ADR-026)",
|
"postgres: Tests requiring the docker-compose postgres-test service (ADR-026)",
|
||||||
|
"contract: Pact consumer/provider contract tests (ADR-029)",
|
||||||
]
|
]
|
||||||
testpaths = [
|
testpaths = [
|
||||||
"tests",
|
"tests",
|
||||||
@@ -128,6 +129,7 @@ dev = [
|
|||||||
"pytest-otel>=2.0.1",
|
"pytest-otel>=2.0.1",
|
||||||
"procrastinate>=3.8",
|
"procrastinate>=3.8",
|
||||||
"psycopg[binary,pool]>=3.2",
|
"psycopg[binary,pool]>=3.2",
|
||||||
|
"pact-python>=3.4.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.scripts]
|
[project.scripts]
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
"""Pact consumer/provider contract tests (ADR-029).
|
||||||
|
|
||||||
|
This package contains the Pact contract tests that keep ``nextcloud-mcp-server``
|
||||||
|
and the ``astrolabe`` Nextcloud app wire-compatible:
|
||||||
|
|
||||||
|
- **Consumer** (this server -> astrolabe): ``test_astrolabe_credentials_consumer``
|
||||||
|
generates a pact for the background-sync credentials API that the
|
||||||
|
``AstrolabeClient`` consumes.
|
||||||
|
- **Provider** (astrolabe -> this server): ``test_mcp_provider_verification``
|
||||||
|
verifies the ``/api/v1/*`` HTTP API this server exposes against pacts that
|
||||||
|
the astrolabe app publishes to the broker.
|
||||||
|
"""
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
"""Fixtures shared by the Pact contract tests (ADR-029).
|
||||||
|
|
||||||
|
The consumer tests build a fresh ``Pact`` per test and merge their interaction
|
||||||
|
into a single pact file under ``tests/contract/pacts/``. A session-scoped
|
||||||
|
autouse fixture wipes that directory once at the start of a run so merged pacts
|
||||||
|
never accumulate stale interactions across runs (``write_file(overwrite=False)``
|
||||||
|
merges into whatever is already on disk).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import shutil
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from pact import Pact
|
||||||
|
|
||||||
|
# Pact participant names. These MUST match the names used on the astrolabe side
|
||||||
|
# and in the broker, so keep them in sync with the astrolabe repo's pact tests.
|
||||||
|
CONSUMER = "nextcloud-mcp-server"
|
||||||
|
PROVIDER = "astrolabe"
|
||||||
|
|
||||||
|
PACT_DIR = Path(__file__).parent / "pacts"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="session", autouse=True)
|
||||||
|
def _clean_pact_dir():
|
||||||
|
"""Start each session from an empty pacts directory."""
|
||||||
|
if PACT_DIR.exists():
|
||||||
|
shutil.rmtree(PACT_DIR)
|
||||||
|
PACT_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
yield
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def consumer_pact():
|
||||||
|
"""A fresh Pact (consumer=nextcloud-mcp-server, provider=astrolabe).
|
||||||
|
|
||||||
|
The interaction added by each test is merged into the shared pact file on
|
||||||
|
teardown.
|
||||||
|
"""
|
||||||
|
pact = Pact(CONSUMER, PROVIDER).with_specification("V4")
|
||||||
|
yield pact
|
||||||
|
pact.write_file(PACT_DIR, overwrite=False)
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
"""Consumer contract: nextcloud-mcp-server -> astrolabe credentials status API.
|
||||||
|
|
||||||
|
The MCP server checks a user's background-sync provisioning status via
|
||||||
|
:meth:`AstrolabeClient.get_background_sync_status`, which calls astrolabe's
|
||||||
|
admin credentials-metadata endpoint
|
||||||
|
(``GET /apps/astrolabe/api/v1/background-sync/credentials/{user_id}``). That
|
||||||
|
endpoint returns **presence/timestamps only — never the app password itself**
|
||||||
|
(the password reaches the MCP server out-of-band, pushed by astrolabe to
|
||||||
|
``POST /api/v1/users/{user_id}/app-password``).
|
||||||
|
|
||||||
|
This pact pins the request shape and the two states the consumer branches on:
|
||||||
|
|
||||||
|
- provisioned -> ``has_background_access: true``, ``sync_type: "app_password"``
|
||||||
|
- unprovisioned -> ``has_background_access: false``, ``sync_type: null``
|
||||||
|
|
||||||
|
The OAuth token fetch (:meth:`AstrolabeClient.get_access_token`) is stubbed so
|
||||||
|
only the status call hits the Pact mock server.
|
||||||
|
|
||||||
|
The client's 404 branch is internal defensive handling (astrolabe always returns
|
||||||
|
200 for this endpoint), not a contract obligation, so it is covered by a unit
|
||||||
|
test (``tests/unit/test_astrolabe_client.py``) rather than a pact interaction.
|
||||||
|
|
||||||
|
See ADR-029 for the overall contract-testing architecture.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from pact import match
|
||||||
|
|
||||||
|
from nextcloud_mcp_server.auth.astrolabe_client import AstrolabeClient
|
||||||
|
|
||||||
|
pytestmark = pytest.mark.contract
|
||||||
|
|
||||||
|
# Matches the ``Authorization: Bearer <token>`` header the client always sends.
|
||||||
|
_BEARER = match.regex("Bearer test-token", regex=r"Bearer .+")
|
||||||
|
|
||||||
|
|
||||||
|
async def test_status_reports_access_for_provisioned_user(consumer_pact, mocker):
|
||||||
|
"""A provisioned user is reported as having background access."""
|
||||||
|
(
|
||||||
|
consumer_pact.upon_receiving(
|
||||||
|
"a request for a provisioned user's background-sync status"
|
||||||
|
)
|
||||||
|
.given("user alice has provisioned background-sync credentials")
|
||||||
|
.with_request("GET", "/apps/astrolabe/api/v1/background-sync/credentials/alice")
|
||||||
|
.with_header("Authorization", _BEARER)
|
||||||
|
.will_respond_with(200)
|
||||||
|
.with_body(
|
||||||
|
{
|
||||||
|
"success": True,
|
||||||
|
"user_id": "alice",
|
||||||
|
"has_background_access": True,
|
||||||
|
"sync_type": "app_password",
|
||||||
|
# Unix seconds (astrolabe BackgroundSyncCredentialStorage::getProvisionedAt),
|
||||||
|
# not an ISO string.
|
||||||
|
"provisioned_at": match.integer(1717000000),
|
||||||
|
},
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
with consumer_pact.serve() as srv:
|
||||||
|
client = AstrolabeClient(
|
||||||
|
nextcloud_host=str(srv.url), client_id="mcp", client_secret="secret"
|
||||||
|
)
|
||||||
|
mocker.patch.object(client, "get_access_token", return_value="test-token")
|
||||||
|
|
||||||
|
status = await client.get_background_sync_status("alice")
|
||||||
|
|
||||||
|
assert status["has_access"] is True
|
||||||
|
assert status["credential_type"] == "app_password"
|
||||||
|
assert status["provisioned_at"] == 1717000000
|
||||||
|
|
||||||
|
|
||||||
|
async def test_status_reports_no_access_for_unprovisioned_user(consumer_pact, mocker):
|
||||||
|
"""An unprovisioned user is reported as having no background access."""
|
||||||
|
(
|
||||||
|
consumer_pact.upon_receiving(
|
||||||
|
"a request for an unprovisioned user's background-sync status"
|
||||||
|
)
|
||||||
|
.given("user bob has no background-sync credentials")
|
||||||
|
.with_request("GET", "/apps/astrolabe/api/v1/background-sync/credentials/bob")
|
||||||
|
.with_header("Authorization", _BEARER)
|
||||||
|
.will_respond_with(200)
|
||||||
|
.with_body(
|
||||||
|
{
|
||||||
|
"success": True,
|
||||||
|
"user_id": "bob",
|
||||||
|
"has_background_access": False,
|
||||||
|
"sync_type": None,
|
||||||
|
"provisioned_at": None,
|
||||||
|
},
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
with consumer_pact.serve() as srv:
|
||||||
|
client = AstrolabeClient(
|
||||||
|
nextcloud_host=str(srv.url), client_id="mcp", client_secret="secret"
|
||||||
|
)
|
||||||
|
mocker.patch.object(client, "get_access_token", return_value="test-token")
|
||||||
|
|
||||||
|
status = await client.get_background_sync_status("bob")
|
||||||
|
|
||||||
|
assert status["has_access"] is False
|
||||||
|
assert status["credential_type"] is None
|
||||||
|
assert status["provisioned_at"] is None
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
"""Provider verification: astrolabe -> nextcloud-mcp-server /api/v1 API.
|
||||||
|
|
||||||
|
The astrolabe Nextcloud app consumes this server's ``/api/v1/*`` HTTP API
|
||||||
|
(``lib/Service/McpServerClient.php``: ``search``, ``webhooks`` CRUD, ``apps``,
|
||||||
|
``status``, ``vector-sync/status``, ``chunk-context``, ``pdf-preview``,
|
||||||
|
``vector-viz/search``). This test plays the **provider** role: it pulls the
|
||||||
|
pacts astrolabe published to the broker and replays each interaction against a
|
||||||
|
running MCP server, failing if a response no longer matches the contract.
|
||||||
|
|
||||||
|
It is **environment-gated** and skips unless a running provider and a pact
|
||||||
|
source are configured, so it is a no-op in the consumer-only job and in local
|
||||||
|
unit runs. Wire it into CI against the integration docker stack (see
|
||||||
|
``.github/workflows/pact.yml``).
|
||||||
|
|
||||||
|
Required environment:
|
||||||
|
- ``PACT_PROVIDER_URL`` — base URL of the running MCP server to verify against
|
||||||
|
(e.g. ``http://localhost:8000``).
|
||||||
|
- One pact source, either:
|
||||||
|
- ``PACT_BROKER`` (+ ``PACT_USERNAME`` / ``PACT_PASSWORD``) — verify against
|
||||||
|
pacts in the broker, or
|
||||||
|
- ``PACT_PROVIDER_PACT_DIR`` — verify against a local directory of pacts.
|
||||||
|
|
||||||
|
Optional:
|
||||||
|
- ``PACT_PROVIDER_VERSION`` — provider version (git SHA) to publish results under.
|
||||||
|
- ``PACT_PROVIDER_BRANCH`` — provider branch for the published results.
|
||||||
|
- ``PACT_PUBLISH_RESULTS=true`` — publish verification results to the broker.
|
||||||
|
|
||||||
|
See ADR-029.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
from collections.abc import Callable
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from pact import Verifier
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
PROVIDER_NAME = "nextcloud-mcp-server"
|
||||||
|
|
||||||
|
_PROVIDER_URL = os.environ.get("PACT_PROVIDER_URL")
|
||||||
|
_BROKER_URL = os.environ.get("PACT_BROKER")
|
||||||
|
_BROKER_USERNAME = os.environ.get("PACT_USERNAME")
|
||||||
|
_BROKER_PASSWORD = os.environ.get("PACT_PASSWORD")
|
||||||
|
_LOCAL_PACT_DIR = os.environ.get("PACT_PROVIDER_PACT_DIR")
|
||||||
|
|
||||||
|
# A usable broker source needs the URL *and* its basic-auth credentials; gating
|
||||||
|
# on all three keeps a misconfigured CI (broker set, creds missing) a clean skip
|
||||||
|
# rather than a confusing KeyError at verify time.
|
||||||
|
_BROKER_READY = bool(_BROKER_URL and _BROKER_USERNAME and _BROKER_PASSWORD)
|
||||||
|
|
||||||
|
# Skip the whole module unless we have a provider to hit AND a pact source.
|
||||||
|
pytestmark = [
|
||||||
|
pytest.mark.contract,
|
||||||
|
pytest.mark.skipif(
|
||||||
|
not _PROVIDER_URL or not (_BROKER_READY or _LOCAL_PACT_DIR),
|
||||||
|
reason=(
|
||||||
|
"Provider verification needs PACT_PROVIDER_URL and a pact source: "
|
||||||
|
"PACT_BROKER (+ PACT_USERNAME/PACT_PASSWORD) or "
|
||||||
|
"PACT_PROVIDER_PACT_DIR. Skipped outside CI."
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
# Map astrolabe-side provider-state strings -> setup callables. astrolabe's
|
||||||
|
# consumer pacts declare the ``given(...)`` provider states; add one handler per
|
||||||
|
# state name here as those pacts are written (seeding webhooks DB, qdrant
|
||||||
|
# fixtures, etc.). Keep the keys identical to the astrolabe ``given(...)``
|
||||||
|
# strings. Unhandled states fall through to ``_dispatch_state`` which logs and
|
||||||
|
# no-ops, so state-less interactions still verify.
|
||||||
|
_PROVIDER_STATES: dict[str, Callable[[], None]] = {
|
||||||
|
# "a webhook is registered for user alice": _state_webhook_registered,
|
||||||
|
# "vector sync has indexed documents": _state_vector_sync_ran,
|
||||||
|
# "the search index returns a hit for 'budget'": _state_search_has_hit,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _dispatch_state(state: str, **kwargs) -> None:
|
||||||
|
"""Provider-state dispatcher passed to the verifier.
|
||||||
|
|
||||||
|
Looks up a registered handler by state name; logs and no-ops for unknown
|
||||||
|
states so contracts that don't require seeded state (``/api/v1/status``,
|
||||||
|
``/api/v1/vector-sync/status``) verify without a handler.
|
||||||
|
"""
|
||||||
|
handler = _PROVIDER_STATES.get(state)
|
||||||
|
if handler is None:
|
||||||
|
# Log any params astrolabe passed so they're visible once real handlers
|
||||||
|
# need them (e.g. given("user X exists", params={"user_id": ...})).
|
||||||
|
logger.warning(
|
||||||
|
"No provider-state handler registered for %r (params=%s); no-op",
|
||||||
|
state,
|
||||||
|
kwargs,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
handler()
|
||||||
|
|
||||||
|
|
||||||
|
def test_verify_astrolabe_consumer_pacts() -> None:
|
||||||
|
"""Verify the MCP server honours every interaction astrolabe published."""
|
||||||
|
verifier = Verifier(PROVIDER_NAME).add_transport(url=_PROVIDER_URL)
|
||||||
|
verifier.state_handler(_dispatch_state, teardown=True)
|
||||||
|
|
||||||
|
if _BROKER_URL and _BROKER_USERNAME and _BROKER_PASSWORD:
|
||||||
|
verifier.broker_source(
|
||||||
|
_BROKER_URL, username=_BROKER_USERNAME, password=_BROKER_PASSWORD
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
assert _LOCAL_PACT_DIR is not None # guaranteed by module skipif
|
||||||
|
verifier.add_source(_LOCAL_PACT_DIR)
|
||||||
|
|
||||||
|
if os.environ.get("PACT_PUBLISH_RESULTS", "").lower() == "true":
|
||||||
|
version = os.environ.get("PACT_PROVIDER_VERSION", "dev")
|
||||||
|
verifier.set_publish_options(
|
||||||
|
version=version,
|
||||||
|
branch=os.environ.get("PACT_PROVIDER_BRANCH"),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Raises (failing the test) if any interaction does not match.
|
||||||
|
verifier.verify()
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
"""Unit tests for ``AstrolabeClient.get_background_sync_status`` (ADR-029).
|
||||||
|
|
||||||
|
The Pact consumer contract (``tests/contract/``) pins the wire shape; these
|
||||||
|
mocked tests pin the *field mapping* that the original silent bug got wrong —
|
||||||
|
it read a non-existent ``app_password`` field, so ``has_access`` was always
|
||||||
|
``False``. A regression here now fails fast at the unit layer.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from unittest.mock import AsyncMock, MagicMock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from nextcloud_mcp_server.auth.astrolabe_client import AstrolabeClient
|
||||||
|
|
||||||
|
pytestmark = pytest.mark.unit
|
||||||
|
|
||||||
|
|
||||||
|
def _patch_outbound_client(mocker, response: MagicMock) -> MagicMock:
|
||||||
|
"""Patch the httpx client used by AstrolabeClient; return the mock client."""
|
||||||
|
mock_client = AsyncMock()
|
||||||
|
mock_client.get = AsyncMock(return_value=response)
|
||||||
|
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
|
||||||
|
mock_client.__aexit__ = AsyncMock(return_value=False)
|
||||||
|
mocker.patch(
|
||||||
|
"nextcloud_mcp_server.auth.astrolabe_client.nextcloud_httpx_client",
|
||||||
|
MagicMock(return_value=mock_client),
|
||||||
|
)
|
||||||
|
return mock_client
|
||||||
|
|
||||||
|
|
||||||
|
def _client(mocker) -> AstrolabeClient:
|
||||||
|
client = AstrolabeClient(
|
||||||
|
nextcloud_host="https://cloud.example.com",
|
||||||
|
client_id="mcp",
|
||||||
|
client_secret="secret",
|
||||||
|
)
|
||||||
|
mocker.patch.object(client, "get_access_token", AsyncMock(return_value="tok"))
|
||||||
|
return client
|
||||||
|
|
||||||
|
|
||||||
|
async def test_provisioned_user_maps_status_fields(mocker):
|
||||||
|
"""200 + has_background_access=True maps to has_access/credential_type/provisioned_at."""
|
||||||
|
response = MagicMock()
|
||||||
|
response.status_code = 200
|
||||||
|
response.json.return_value = {
|
||||||
|
"success": True,
|
||||||
|
"user_id": "alice",
|
||||||
|
"has_background_access": True,
|
||||||
|
"sync_type": "app_password",
|
||||||
|
"provisioned_at": 1717000000,
|
||||||
|
}
|
||||||
|
mock_client = _patch_outbound_client(mocker, response)
|
||||||
|
client = _client(mocker)
|
||||||
|
|
||||||
|
status = await client.get_background_sync_status("alice")
|
||||||
|
|
||||||
|
assert status == {
|
||||||
|
"has_access": True,
|
||||||
|
"credential_type": "app_password",
|
||||||
|
"provisioned_at": 1717000000,
|
||||||
|
}
|
||||||
|
response.raise_for_status.assert_called_once()
|
||||||
|
# The bearer token from get_access_token is forwarded on the request.
|
||||||
|
_, kwargs = mock_client.get.call_args
|
||||||
|
assert kwargs["headers"]["Authorization"] == "Bearer tok"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_provisioned_false_reports_no_access(mocker):
|
||||||
|
"""200 + has_background_access=False reports no access (the bug's regression guard)."""
|
||||||
|
response = MagicMock()
|
||||||
|
response.status_code = 200
|
||||||
|
response.json.return_value = {
|
||||||
|
"success": True,
|
||||||
|
"user_id": "bob",
|
||||||
|
"has_background_access": False,
|
||||||
|
"sync_type": None,
|
||||||
|
"provisioned_at": None,
|
||||||
|
}
|
||||||
|
_patch_outbound_client(mocker, response)
|
||||||
|
client = _client(mocker)
|
||||||
|
|
||||||
|
status = await client.get_background_sync_status("bob")
|
||||||
|
|
||||||
|
assert status["has_access"] is False
|
||||||
|
assert status["credential_type"] is None
|
||||||
|
assert status["provisioned_at"] is None
|
||||||
|
|
||||||
|
|
||||||
|
async def test_missing_credentials_404_returns_no_access(mocker):
|
||||||
|
"""404 short-circuits to no-access without touching raise_for_status/json."""
|
||||||
|
response = MagicMock()
|
||||||
|
response.status_code = 404
|
||||||
|
_patch_outbound_client(mocker, response)
|
||||||
|
client = _client(mocker)
|
||||||
|
|
||||||
|
status = await client.get_background_sync_status("carol")
|
||||||
|
|
||||||
|
assert status == {
|
||||||
|
"has_access": False,
|
||||||
|
"credential_type": None,
|
||||||
|
"provisioned_at": None,
|
||||||
|
}
|
||||||
|
response.raise_for_status.assert_not_called()
|
||||||
|
response.json.assert_not_called()
|
||||||
@@ -8,6 +8,7 @@ read and clear that store too, otherwise they report "not provisioned" while
|
|||||||
tools still work, and "nothing to revoke" while the credential persists.
|
tools still work, and "nothing to revoke" while the credential persists.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
from unittest.mock import AsyncMock, MagicMock
|
from unittest.mock import AsyncMock, MagicMock
|
||||||
|
|
||||||
@@ -58,6 +59,39 @@ async def test_status_reports_provisioned_for_app_password_store(
|
|||||||
storage.get_refresh_token.assert_not_awaited() # app password short-circuits
|
storage.get_refresh_token.assert_not_awaited() # app password short-circuits
|
||||||
|
|
||||||
|
|
||||||
|
async def test_status_converts_astrolabe_int_timestamp_to_iso(mocker):
|
||||||
|
"""Astrolabe returns provisioned_at as Unix seconds (per the contract pact),
|
||||||
|
but ProvisioningStatus.provisioned_at is an ISO string. The int must be
|
||||||
|
converted at the boundary, else constructing the model raises ValidationError
|
||||||
|
for every provisioned user."""
|
||||||
|
mocker.patch.object(
|
||||||
|
oauth_tools,
|
||||||
|
"get_settings",
|
||||||
|
return_value=SimpleNamespace(
|
||||||
|
oidc_client_id="mcp",
|
||||||
|
oidc_client_secret="secret",
|
||||||
|
nextcloud_host="https://cloud.example.com",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
astrolabe = MagicMock()
|
||||||
|
astrolabe.get_background_sync_status = AsyncMock(
|
||||||
|
return_value={
|
||||||
|
"has_access": True,
|
||||||
|
"credential_type": "app_password",
|
||||||
|
"provisioned_at": 1717000000,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
mocker.patch.object(oauth_tools, "AstrolabeClient", return_value=astrolabe)
|
||||||
|
|
||||||
|
status = await _get_provisioning_status(MagicMock(), "alice")
|
||||||
|
|
||||||
|
assert status.is_provisioned is True
|
||||||
|
assert status.credential_type == "app_password"
|
||||||
|
# Converted from Unix seconds to an ISO-8601 string that round-trips back.
|
||||||
|
assert isinstance(status.provisioned_at, str)
|
||||||
|
assert datetime.fromisoformat(status.provisioned_at).timestamp() == 1717000000
|
||||||
|
|
||||||
|
|
||||||
async def test_revoke_deletes_app_password(mocker, _no_astrolabe_settings):
|
async def test_revoke_deletes_app_password(mocker, _no_astrolabe_settings):
|
||||||
"""Revoke must delete the app password from storage (not just refresh tokens)."""
|
"""Revoke must delete the app password from storage (not just refresh tokens)."""
|
||||||
storage = MagicMock()
|
storage = MagicMock()
|
||||||
|
|||||||
@@ -2236,6 +2236,7 @@ dev = [
|
|||||||
{ name = "commitizen" },
|
{ name = "commitizen" },
|
||||||
{ name = "datasets" },
|
{ name = "datasets" },
|
||||||
{ name = "ipython" },
|
{ name = "ipython" },
|
||||||
|
{ name = "pact-python" },
|
||||||
{ name = "playwright" },
|
{ name = "playwright" },
|
||||||
{ name = "procrastinate" },
|
{ name = "procrastinate" },
|
||||||
{ name = "psycopg", extra = ["binary", "pool"] },
|
{ name = "psycopg", extra = ["binary", "pool"] },
|
||||||
@@ -2299,6 +2300,7 @@ dev = [
|
|||||||
{ name = "commitizen", specifier = ">=4.8.2" },
|
{ name = "commitizen", specifier = ">=4.8.2" },
|
||||||
{ name = "datasets", specifier = ">=3.3.0" },
|
{ name = "datasets", specifier = ">=3.3.0" },
|
||||||
{ name = "ipython", specifier = ">=9.2.0" },
|
{ name = "ipython", specifier = ">=9.2.0" },
|
||||||
|
{ name = "pact-python", specifier = ">=3.4.0" },
|
||||||
{ name = "playwright", specifier = ">=1.49.1" },
|
{ name = "playwright", specifier = ">=1.49.1" },
|
||||||
{ name = "procrastinate", specifier = ">=3.8" },
|
{ name = "procrastinate", specifier = ">=3.8" },
|
||||||
{ name = "psycopg", extras = ["binary", "pool"], specifier = ">=3.2" },
|
{ name = "psycopg", extras = ["binary", "pool"], specifier = ">=3.2" },
|
||||||
@@ -2718,6 +2720,39 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" },
|
{ url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pact-python"
|
||||||
|
version = "3.4.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "pact-python-ffi" },
|
||||||
|
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
|
||||||
|
{ name = "yarl" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/41/d0/0f7b44586c232e86383a6224eb9f6bfb511d2c9630b692312506a879ac40/pact_python-3.4.0.tar.gz", hash = "sha256:e4a500fa00e1fd586cecf473a74db1e604af5e1be97a312d88eced46c25395c0", size = 82088, upload-time = "2026-05-04T09:40:06.947Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3a/63/fb5c76d6c23504d5838f4da4219c81399b68211735939d1657ad568a016b/pact_python-3.4.0-py3-none-any.whl", hash = "sha256:76e8fb55c403c2c87d45052b270535cd88af2cd4b60d7d17ae5714eb9d8a872a", size = 97691, upload-time = "2026-05-04T09:40:05.502Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pact-python-ffi"
|
||||||
|
version = "0.5.4.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "cffi" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/a0/85/199a6d800563bbf6402b93d29b10542d2b0b70a07aa6e7a6c7b7382b48ac/pact_python_ffi-0.5.4.0.tar.gz", hash = "sha256:dd0d4eeab8fc183800bce0f499a90d1b8306b2f3122f95ab0c34d6dd1f066986", size = 43703, upload-time = "2026-05-04T09:40:05.348Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ed/7c/4c09c720a7b1c5842c7c4ffb6442170b901074fb1617227851014f6a5994/pact_python_ffi-0.5.4.0-cp310-abi3-macosx_12_0_arm64.whl", hash = "sha256:64ca6ff1b6054cc233e9543e6976609ad32e415ade993855cf4f167741c2b753", size = 5476847, upload-time = "2026-05-04T09:39:48.055Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c4/60/cb32cd3473af25bcd8766644bb98995916eed5a0928cd35ba1bc2da6423a/pact_python_ffi-0.5.4.0-cp310-abi3-macosx_12_0_x86_64.whl", hash = "sha256:7b90c0b5b17b70852b17fa1321d46a3272c9fd7fae7cf6dff2f3610dd6b30251", size = 5662311, upload-time = "2026-05-04T09:39:50.619Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ad/a9/e0aceaa45a0b58a57d4bb0319d841f1215805f5e4775727a20d7dd1967a1/pact_python_ffi-0.5.4.0-cp310-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:42238b9c2f96e541733f766fb1975e63f90b71dad7353ae6b1d8ba972f69f424", size = 6392810, upload-time = "2026-05-04T09:39:52.562Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1a/14/1b898aca91cd2d59cc8ae8142f519df4131f250ca13edad678693d09e5a6/pact_python_ffi-0.5.4.0-cp310-abi3-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f7b34209976695551bd630a642f775cfb7b4bd510a1f03a658ec701e2a620d3", size = 6616030, upload-time = "2026-05-04T09:39:54.08Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a0/7b/91e436dc1b5b12aacccb1c8b24b7453afe9e54b76d3e4d8d009d6774bc8c/pact_python_ffi-0.5.4.0-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:563ed5af2e61fae9d2f9d3704056f0ac93d581174815433ee94eff60d7d63fc5", size = 15034468, upload-time = "2026-05-04T09:39:56.283Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/aa/14/6fb062da39c3277a28727a41eeea3f5f9f88ca332f867f850da20bd2d6d9/pact_python_ffi-0.5.4.0-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:3afddaf461df078f94256157993680ea2bcbc8be215499bd2f54c3aa2f54afd3", size = 14285546, upload-time = "2026-05-04T09:39:58.634Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/4a/f4/25e676f4414102dc3852ccc0aab894e91e8a627b6cc463686523064b527c/pact_python_ffi-0.5.4.0-cp310-abi3-win_amd64.whl", hash = "sha256:192761cc8a668e08b189f517417cdb71b846da09b20812fac9d690d7b9a5b152", size = 13278456, upload-time = "2026-05-04T09:40:00.908Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3d/1a/bbdb8ddf844b58dc6cb76c8ab0e27f965e3633d5fbc956d5f76a2296a8c3/pact_python_ffi-0.5.4.0-cp310-abi3-win_arm64.whl", hash = "sha256:94876f6a5a19b2f66214dcdd793ce7c911b88649dfabb98fddfcb10e40fed740", size = 10945281, upload-time = "2026-05-04T09:40:03.278Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pandas"
|
name = "pandas"
|
||||||
version = "2.3.3"
|
version = "2.3.3"
|
||||||
|
|||||||
Reference in New Issue
Block a user