From 4e8f431ae36590d861d57b5845a16a308fa71ab6 Mon Sep 17 00:00:00 2001 From: Lola <35248818+Argocyte@users.noreply.github.com> Date: Sun, 12 Apr 2026 08:13:58 +0100 Subject: [PATCH] test(cooperative-api): add integration tests, mock services, TESTING.md, and env template MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds 5 new integration tests for the Iskander Cooperative API (the Python FastAPI service that upstream OpenClaw's Iskander-specific skills will call into — see Discussion #190 for the architectural relationship): - Health endpoint returns status ok - Mattermost webhook rejects invalid tokens (hmac.compare_digest) - Mattermost webhook rejects empty tokens - Bot loop prevention (messages from BOT_USER_ID return empty) - Loomio webhook rejects missing/invalid HMAC signatures - Rate limiting triggers after configurable threshold Also adds: - tests/mock_services.py: FastAPI app emulating Mattermost, Loomio, Decision Recorder, and provisioner APIs for local development - tests/env.test: environment template with all 9 required env vars pointing at mock services on port 9000 - TESTING.md: setup guide for Alyssa covering unit tests, local dev with mocks, and full-stack integration with upstream OpenClaw Total test count: 24 (15 existing + 5 new integration + 4 from existing meeting-prep and provisioning suites). All passing. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/IskanderOS/openclaw/TESTING.md | 150 +++++++++ src/IskanderOS/openclaw/tests/env.test | 33 ++ .../openclaw/tests/mock_services.py | 286 ++++++++++++++++++ .../openclaw/tests/test_integration.py | 131 ++++++++ 4 files changed, 600 insertions(+) create mode 100644 src/IskanderOS/openclaw/TESTING.md create mode 100644 src/IskanderOS/openclaw/tests/env.test create mode 100644 src/IskanderOS/openclaw/tests/mock_services.py create mode 100644 src/IskanderOS/openclaw/tests/test_integration.py diff --git a/src/IskanderOS/openclaw/TESTING.md b/src/IskanderOS/openclaw/TESTING.md new file mode 100644 index 0000000..cfa898d --- /dev/null +++ b/src/IskanderOS/openclaw/TESTING.md @@ -0,0 +1,150 @@ +# Testing the Iskander Cooperative API + +This document explains how to test the Python FastAPI service at `src/IskanderOS/openclaw/`. This service is the **Iskander Cooperative API** — the governance layer that the upstream [OpenClaw](https://github.com/openclaw/openclaw) platform's Iskander-specific skills will call into. See [Discussion #190](https://github.com/Argocyte/Iskander/discussions/190) for the architectural relationship. + +## Quick start — run existing unit tests + +```bash +cd src/IskanderOS/openclaw +pip install -r requirements.txt +pip install pytest httpx # test dependencies +python -m pytest tests/ -v +``` + +All 15 existing tests should pass. They mock external HTTP calls — no running services needed. + +## Local development — run the API with mock external services + +The Cooperative API depends on external services (Mattermost, Loomio, Decision Recorder) that are complex to set up. For local development, a mock server emulates all of them. + +### Step 1: Start the mock services + +```bash +cd src/IskanderOS/openclaw +pip install uvicorn +python -m uvicorn tests.mock_services:app --port 9000 +``` + +This runs a single FastAPI app on port 9000 that emulates: +- Mattermost API at `http://localhost:9000/mattermost/` +- Loomio API at `http://localhost:9000/loomio/` +- Decision Recorder at `http://localhost:9000/decision-recorder/` +- Member provisioner at `http://localhost:9000/provisioner/` + +All responses are canned (no real data). All requests are logged in memory for inspection. + +### Step 2: Configure environment + +```bash +# Copy the test environment template +cp tests/env.test .env + +# Edit .env — the defaults point at the mock services on port 9000. +# For integration tests with a REAL Claude API call, replace ANTHROPIC_API_KEY +# with your actual key. For unit-level testing, the test key is fine. +``` + +### Step 3: Run the Cooperative API + +```bash +python -m uvicorn main:app --port 8000 --reload +``` + +The API is now running at `http://localhost:8000`. Health check: `curl http://localhost:8000/health` + +### Step 4: Send a test webhook + +```bash +curl -X POST http://localhost:8000/webhook/mattermost \ + -H "Content-Type: application/json" \ + -d '{ + "token": "test_webhook_token_do_not_use_in_production", + "team_id": "test_team", + "team_domain": "test", + "channel_id": "test_channel", + "channel_name": "town-square", + "timestamp": 1700000000, + "user_id": "member_001", + "user_name": "alice", + "post_id": "post_001", + "text": "@clerk What proposals are open?", + "trigger_word": "@clerk" + }' +``` + +**With a real ANTHROPIC_API_KEY:** this will make a real Claude API call, the Clerk agent will run its tool-use loop, call the mock Loomio API for proposals, and return a response. + +**With the test key:** this will fail at the Anthropic API call (expected — see "Fully mocked testing" below). + +## Fully mocked testing (no API keys needed) + +For CI and for testing without any API keys, the existing unit tests in `tests/` mock the Anthropic client at the Python level. To add new tests that exercise the full webhook → agent → tool flow without real API calls: + +```python +# tests/test_integration.py +from unittest.mock import patch, MagicMock +from fastapi.testclient import TestClient +import os + +# Set required env vars BEFORE importing main +os.environ["MATTERMOST_OUTGOING_WEBHOOK_TOKEN"] = "test_token" +os.environ["MATTERMOST_BOT_USER_ID"] = "bot_001" +os.environ["LOOMIO_WEBHOOK_SECRET"] = "test_secret" +os.environ["ANTHROPIC_API_KEY"] = "test_key" + +from main import app + +client = TestClient(app) + + +def test_health(): + response = client.get("/health") + assert response.status_code == 200 + assert response.json()["status"] == "ok" + + +def test_webhook_rejects_bad_token(): + response = client.post("/webhook/mattermost", json={ + "token": "wrong_token", + "team_id": "t", "team_domain": "t", + "channel_id": "c", "channel_name": "c", + "timestamp": 0, "user_id": "u", + "user_name": "u", "post_id": "p", + "text": "hello", "trigger_word": "" + }) + assert response.status_code == 403 + + +def test_webhook_ignores_bot_messages(): + response = client.post("/webhook/mattermost", json={ + "token": "test_token", + "team_id": "t", "team_domain": "t", + "channel_id": "c", "channel_name": "c", + "timestamp": 0, "user_id": "bot_001", # same as BOT_USER_ID + "user_name": "clerk", "post_id": "p", + "text": "hello", "trigger_word": "" + }) + assert response.status_code == 200 + assert response.json() == {} # empty = ignored +``` + +## Testing with the upstream OpenClaw Gateway + +Once the upstream [OpenClaw](https://github.com/openclaw/openclaw) is installed alongside this API, the full integration test is: + +1. Run the upstream OpenClaw Gateway (their docker-compose, port 18789) +2. Run the Iskander Cooperative API (this service, port 8000) +3. Run mock external services (port 9000) OR real Loomio + Decision Recorder +4. Install Iskander-specific skills into OpenClaw that route to `http://cooperative-api:8000` +5. Send a message through a connected channel (Mattermost) and verify the full flow + +This full-stack integration test is a Phase C milestone (M-C4 in the NLnet application). The skill-bridge design is tracked in [Discussion #190](https://github.com/Argocyte/Iskander/discussions/190). + +## What each test level covers + +| Level | What it tests | External deps | When to run | +|---|---|---|---| +| Unit tests (`pytest tests/`) | Individual tools, Glass Box enforcement, write-tool guards | None (all mocked) | Every commit | +| Local dev (mock services) | Full webhook → agent → tool flow | Mock services on port 9000 + real or mock Claude API | During development | +| Integration (with upstream OpenClaw) | Channel → Gateway → Skill → API → external services | Upstream OpenClaw + this API + real or mock externals | Pre-deployment | +| Pilot (Trans Lives Housing Coop) | Real cooperative members using the full system | Everything real | Milestone M-C8 | diff --git a/src/IskanderOS/openclaw/tests/env.test b/src/IskanderOS/openclaw/tests/env.test new file mode 100644 index 0000000..a1e2076 --- /dev/null +++ b/src/IskanderOS/openclaw/tests/env.test @@ -0,0 +1,33 @@ +# OpenClaw Test Environment +# Copy to .env or export these before running OpenClaw locally. +# These point at the mock services running on port 9000. + +# Required — Mattermost webhook authentication +MATTERMOST_OUTGOING_WEBHOOK_TOKEN=test_webhook_token_do_not_use_in_production +MATTERMOST_BOT_USER_ID=bot_user_test_001 + +# Required — Loomio webhook signature verification +LOOMIO_WEBHOOK_SECRET=test_loomio_secret_do_not_use_in_production + +# Required — Claude API (use your real key for integration tests, +# or set to "test_key" for unit tests that mock the Anthropic client) +ANTHROPIC_API_KEY=test_key_replace_with_real_for_integration + +# Optional — Mattermost bot posting (governance channel bridge) +MATTERMOST_BOT_TOKEN=test_bot_token +MATTERMOST_GOVERNANCE_CHANNEL_ID=mock_governance_channel +MATTERMOST_URL=http://localhost:9000/mattermost + +# Optional — Loomio API base +LOOMIO_URL=http://localhost:9000/loomio + +# Optional — Decision Recorder base +DECISION_RECORDER_URL=http://localhost:9000/decision-recorder + +# Optional — Member provisioner +PROVISIONER_URL=http://localhost:9000/provisioner + +# Optional — Clerk model (defaults to haiku for cost efficiency in testing) +CLERK_MODEL=claude-haiku-4-5-20251001 +CLERK_MAX_TOKENS=2048 +CLERK_RATE_LIMIT_PER_MINUTE=100 diff --git a/src/IskanderOS/openclaw/tests/mock_services.py b/src/IskanderOS/openclaw/tests/mock_services.py new file mode 100644 index 0000000..a2a498d --- /dev/null +++ b/src/IskanderOS/openclaw/tests/mock_services.py @@ -0,0 +1,286 @@ +""" +Mock external services for OpenClaw local testing. + +Provides a single FastAPI app that emulates the external APIs OpenClaw +depends on: Mattermost, Loomio, and Decision Recorder. All responses are +canned — no real data is stored or processed. The mock records all +received requests in memory for test assertions. + +Run standalone: + python -m uvicorn tests.mock_services:app --port 9000 + +Or import in pytest fixtures: + from tests.mock_services import app, request_log, reset_log +""" +from __future__ import annotations + +import json +import logging +from collections import defaultdict +from typing import Any + +from fastapi import FastAPI, Header, Request +from fastapi.responses import JSONResponse + +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger("mock_services") + +app = FastAPI(title="OpenClaw Mock Services", version="0.1.0") + +# --------------------------------------------------------------------------- +# Request log — tests can inspect what OpenClaw sent to the mocks +# --------------------------------------------------------------------------- + +request_log: dict[str, list[dict]] = defaultdict(list) + + +def reset_log() -> None: + """Clear all recorded requests between tests.""" + request_log.clear() + + +async def _log_request(service: str, path: str, request: Request) -> dict: + """Record a request for later inspection.""" + body = {} + try: + body = await request.json() + except Exception: + pass + entry = { + "method": request.method, + "path": path, + "headers": dict(request.headers), + "body": body, + } + request_log[service].append(entry) + return entry + + +# --------------------------------------------------------------------------- +# Mock Mattermost API +# --------------------------------------------------------------------------- + +@app.post("/mattermost/api/v4/posts") +async def mm_create_post(request: Request) -> JSONResponse: + entry = await _log_request("mattermost", "/api/v4/posts", request) + logger.info("Mock Mattermost: post created in channel %s", + entry["body"].get("channel_id", "unknown")) + return JSONResponse(content={ + "id": "mock_post_001", + "channel_id": entry["body"].get("channel_id", ""), + "message": entry["body"].get("message", ""), + "create_at": 1700000000000, + }) + + +@app.get("/mattermost/api/v4/users/{user_id}") +async def mm_get_user(user_id: str) -> JSONResponse: + return JSONResponse(content={ + "id": user_id, + "username": f"mock_user_{user_id[:8]}", + "email": f"mock_{user_id[:8]}@iskander.coop", + "roles": "system_user", + }) + + +# --------------------------------------------------------------------------- +# Mock Loomio API +# --------------------------------------------------------------------------- + +MOCK_LOOMIO_GROUP = { + "id": 1, + "key": "mock_cooperative", + "name": "Mock Cooperative", + "description": "A test cooperative for OpenClaw development", + "memberships_count": 5, +} + +MOCK_LOOMIO_DISCUSSION = { + "id": 100, + "key": "mock_discussion_100", + "title": "Test Discussion: Should we adopt solar panels?", + "description": "A proposal to install solar panels on the cooperative building.", + "group_id": 1, + "author_id": 1, + "items_count": 3, + "created_at": "2026-04-01T10:00:00Z", +} + +MOCK_LOOMIO_PROPOSAL = { + "id": 200, + "key": "mock_poll_200", + "title": "Consent: Solar panel installation", + "poll_type": "proposal", + "group_id": 1, + "author_id": 1, + "stance_counts": {"agree": 3, "abstain": 1, "disagree": 0, "block": 0}, + "created_at": "2026-04-05T14:00:00Z", + "closing_at": "2026-04-12T14:00:00Z", +} + + +@app.get("/loomio/api/b1/polls") +async def loomio_list_polls(group_key: str | None = None) -> JSONResponse: + return JSONResponse(content={"polls": [MOCK_LOOMIO_PROPOSAL]}) + + +@app.get("/loomio/api/b1/polls/{poll_id}") +async def loomio_get_poll(poll_id: int) -> JSONResponse: + return JSONResponse(content={"poll": MOCK_LOOMIO_PROPOSAL}) + + +@app.get("/loomio/api/b1/discussions") +async def loomio_list_discussions(group_key: str | None = None, per: int = 10) -> JSONResponse: + return JSONResponse(content={"discussions": [MOCK_LOOMIO_DISCUSSION]}) + + +@app.get("/loomio/api/b1/discussions/{discussion_id}") +async def loomio_get_discussion(discussion_id: int) -> JSONResponse: + return JSONResponse(content={"discussion": MOCK_LOOMIO_DISCUSSION}) + + +@app.get("/loomio/api/b1/search") +async def loomio_search(q: str = "") -> JSONResponse: + return JSONResponse(content={"search_results": [MOCK_LOOMIO_DISCUSSION]}) + + +@app.post("/loomio/api/b1/discussions") +async def loomio_create_discussion(request: Request) -> JSONResponse: + entry = await _log_request("loomio", "/api/b1/discussions", request) + return JSONResponse(content={ + "discussion": { + **MOCK_LOOMIO_DISCUSSION, + "title": entry["body"].get("title", "New Discussion"), + "description": entry["body"].get("description", ""), + } + }) + + +@app.get("/loomio/api/b1/memberships") +async def loomio_memberships(group_key: str = "", user_id: str | None = None) -> JSONResponse: + return JSONResponse(content={ + "memberships": [{"user_id": 1, "group_id": 1, "admin": False}] + }) + + +# --------------------------------------------------------------------------- +# Mock Decision Recorder API +# --------------------------------------------------------------------------- + +@app.post("/decision-recorder/glass-box") +async def dr_glass_box(request: Request) -> JSONResponse: + entry = await _log_request("decision_recorder", "/glass-box", request) + logger.info("Mock Glass Box: action=%s target=%s", + entry["body"].get("action"), entry["body"].get("target")) + return JSONResponse(content={ + "id": "gb_mock_001", + "action": entry["body"].get("action", ""), + "actor_user_id": entry["body"].get("actor_user_id", ""), + "target": entry["body"].get("target", ""), + "reasoning": entry["body"].get("reasoning", ""), + "timestamp": "2026-04-12T10:00:00Z", + }) + + +@app.get("/decision-recorder/glass-box/decisions") +async def dr_list_decisions(group_key: str | None = None) -> JSONResponse: + return JSONResponse(content=[{ + "id": 1, + "title": "Solar panel installation approved", + "status": "passed", + "created_at": "2026-04-05T14:00:00Z", + }]) + + +@app.get("/decision-recorder/reviews/due") +async def dr_reviews_due(days_ahead: int = 30) -> JSONResponse: + return JSONResponse(content=[{ + "decision_id": 1, + "title": "Solar panel installation approved", + "review_date": "2026-05-05", + "circle": "infrastructure", + }]) + + +@app.get("/decision-recorder/tensions") +async def dr_list_tensions(limit: int = 10) -> JSONResponse: + return JSONResponse(content=[{ + "id": 1, + "description": "The cooperative website needs updating", + "status": "open", + "raised_by": "member_001", + "created_at": "2026-04-10T09:00:00Z", + }]) + + +@app.post("/decision-recorder/tensions") +async def dr_log_tension(request: Request) -> JSONResponse: + entry = await _log_request("decision_recorder", "/tensions", request) + return JSONResponse(content={ + "id": 2, + "description": entry["body"].get("description", ""), + "status": "open", + "raised_by": entry["body"].get("actor_user_id", ""), + }) + + +@app.patch("/decision-recorder/tensions/{tension_id}") +async def dr_update_tension(tension_id: int, request: Request) -> JSONResponse: + entry = await _log_request("decision_recorder", f"/tensions/{tension_id}", request) + return JSONResponse(content={"id": tension_id, "status": entry["body"].get("status", "open")}) + + +@app.post("/decision-recorder/tensions/{tension_id}/review-date") +async def dr_set_review_date(tension_id: int, request: Request) -> JSONResponse: + entry = await _log_request("decision_recorder", f"/tensions/{tension_id}/review-date", request) + return JSONResponse(content={"id": tension_id, "review_date": entry["body"].get("review_date", "")}) + + +@app.post("/decision-recorder/accountability") +async def dr_update_accountability(request: Request) -> JSONResponse: + entry = await _log_request("decision_recorder", "/accountability", request) + return JSONResponse(content={"status": "updated"}) + + +@app.post("/decision-recorder/labour") +async def dr_log_labour(request: Request) -> JSONResponse: + entry = await _log_request("decision_recorder", "/labour", request) + return JSONResponse(content={ + "id": "labour_mock_001", + "value_type": entry["body"].get("value_type", "productive"), + "hours": entry["body"].get("hours", 0), + }) + + +@app.get("/decision-recorder/labour/summary") +async def dr_labour_summary(member_id: str | None = None) -> JSONResponse: + return JSONResponse(content={ + "productive": 40.0, + "reproductive": 10.0, + "care": 5.0, + "commons": 8.0, + "total": 63.0, + }) + + +# --------------------------------------------------------------------------- +# Mock member provisioning +# --------------------------------------------------------------------------- + +@app.post("/provisioner/provision") +async def provision_member(request: Request) -> JSONResponse: + entry = await _log_request("provisioner", "/provision", request) + return JSONResponse(content={ + "status": "provisioned", + "username": entry["body"].get("username", "new_member"), + "services": ["authentik", "loomio", "mattermost", "nextcloud"], + }) + + +# --------------------------------------------------------------------------- +# Health check +# --------------------------------------------------------------------------- + +@app.get("/health") +async def health() -> dict: + return {"status": "ok", "service": "mock_services", "endpoints_active": True} diff --git a/src/IskanderOS/openclaw/tests/test_integration.py b/src/IskanderOS/openclaw/tests/test_integration.py new file mode 100644 index 0000000..4f5aff4 --- /dev/null +++ b/src/IskanderOS/openclaw/tests/test_integration.py @@ -0,0 +1,131 @@ +""" +Integration tests for the Iskander Cooperative API. + +These test the webhook endpoints directly using FastAPI's TestClient, +without needing any external services or API keys running. +""" +from __future__ import annotations + +import os +import sys + +# Ensure openclaw is importable as a package +_openclaw_parent = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +if _openclaw_parent not in sys.path: + sys.path.insert(0, _openclaw_parent) + +# Set ALL required env vars BEFORE importing the app +# main.py requires: +os.environ.setdefault("MATTERMOST_OUTGOING_WEBHOOK_TOKEN", "test_token") +os.environ.setdefault("MATTERMOST_BOT_USER_ID", "bot_001") +os.environ.setdefault("LOOMIO_WEBHOOK_SECRET", "test_secret") +os.environ.setdefault("ANTHROPIC_API_KEY", "test_key") +# clerk/tools.py requires: +os.environ.setdefault("LOOMIO_URL", "http://localhost:9000/loomio") +os.environ.setdefault("LOOMIO_API_KEY", "test_loomio_api_key") +os.environ.setdefault("MATTERMOST_URL", "http://localhost:9000/mattermost") +os.environ.setdefault("MATTERMOST_BOT_TOKEN", "test_bot_token") +# sentry/tools.py requires: +os.environ.setdefault("MATTERMOST_OPS_CHANNEL_ID", "mock_ops_channel") +# steward/tools.py requires: +os.environ.setdefault("MATTERMOST_GOVERNANCE_CHANNEL_ID", "mock_governance_channel") +# decision-recorder base (used by tools.py internally): +os.environ.setdefault("DECISION_RECORDER_URL", "http://localhost:9000/decision-recorder") + +# Import via the package so relative imports in main.py resolve correctly +from openclaw.main import app # noqa: E402 + +from fastapi.testclient import TestClient # noqa: E402 + +client = TestClient(app) + +VALID_WEBHOOK = { + "token": "test_token", + "team_id": "test_team", + "team_domain": "test", + "channel_id": "test_channel", + "channel_name": "town-square", + "timestamp": 1700000000, + "user_id": "member_001", + "user_name": "alice", + "post_id": "post_001", + "text": "@clerk hello", + "trigger_word": "@clerk", +} + + +class TestHealthEndpoint: + def test_returns_ok(self): + response = client.get("/health") + assert response.status_code == 200 + assert response.json()["status"] == "ok" + + def test_names_clerk_agent(self): + response = client.get("/health") + assert response.json()["agent"] == "clerk" + + +class TestMattermostWebhookAuth: + def test_rejects_invalid_token(self): + payload = {**VALID_WEBHOOK, "token": "wrong_token"} + response = client.post("/webhook/mattermost", json=payload) + assert response.status_code == 403 + + def test_rejects_empty_token(self): + payload = {**VALID_WEBHOOK, "token": ""} + response = client.post("/webhook/mattermost", json=payload) + assert response.status_code == 403 + + def test_ignores_bot_messages(self): + """Messages from the bot user itself should return empty (prevent loops).""" + payload = {**VALID_WEBHOOK, "user_id": "bot_001"} + response = client.post("/webhook/mattermost", json=payload) + assert response.status_code == 200 + assert response.json() == {} + + def test_empty_message_returns_help(self): + """Empty message after trigger word stripping returns a help prompt.""" + payload = {**VALID_WEBHOOK, "text": "@clerk", "trigger_word": "@clerk"} + response = client.post("/webhook/mattermost", json=payload) + assert response.status_code == 200 + assert "help" in response.json().get("text", "").lower() + + +class TestLoomioWebhookAuth: + def test_rejects_missing_signature(self): + response = client.post( + "/webhook/loomio-decision", + json={"poll": {}, "outcome": {}}, + ) + assert response.status_code == 403 + + def test_rejects_invalid_signature(self): + response = client.post( + "/webhook/loomio-decision", + json={"poll": {}, "outcome": {}}, + headers={"X-Loomio-Signature": "sha256=invalid"}, + ) + assert response.status_code == 403 + + +class TestRateLimiting: + def test_rate_limit_triggers_after_max_requests(self): + """Sending more than CLERK_RATE_LIMIT_PER_MINUTE requests should 429.""" + from openclaw import main as openclaw_main + original_max = openclaw_main._RATE_LIMIT_MAX + openclaw_main._RATE_LIMIT_MAX = 3 + openclaw_main._rate_counters.clear() + + try: + for i in range(3): + payload = {**VALID_WEBHOOK, "user_id": "ratelimit_test_user"} + response = client.post("/webhook/mattermost", json=payload) + assert response.status_code in (200, 500) + + # The 4th request should be rate limited + payload = {**VALID_WEBHOOK, "user_id": "ratelimit_test_user"} + response = client.post("/webhook/mattermost", json=payload) + assert response.status_code == 429 + finally: + openclaw_main._RATE_LIMIT_MAX = original_max + openclaw_main._rate_counters.clear()