refactor: unify schema and io boundary fixtures

This commit is contained in:
rookiestar28
2026-03-27 15:57:15 +08:00
parent fd0967ff78
commit b46dbbebae
9 changed files with 515 additions and 57 deletions
+112 -3
View File
@@ -362,6 +362,115 @@ paths:
security: security:
- OpenClawAdminToken: - OpenClawAdminToken:
[] []
/models/search:
get:
operationId: "get_models_search"
summary: "Search normalized model entries across managed installs and catalog sources."
responses:
200:
description: "OK"
x-openclaw-auth: "Admin"
x-openclaw-section: "1.3C Model Management & Installations"
x-openclaw-legacy-path: "/moltbot/models/search"
x-openclaw-auth-tier: "admin"
security:
- OpenClawAdminToken:
[]
/models/downloads:
post:
operationId: "post_models_downloads"
summary: "Create a managed model download task with progress/cancel lifecycle."
responses:
200:
description: "OK"
x-openclaw-auth: "Admin"
x-openclaw-section: "1.3C Model Management & Installations"
x-openclaw-legacy-path: "/moltbot/models/downloads"
x-openclaw-auth-tier: "admin"
security:
- OpenClawAdminToken:
[]
get:
operationId: "get_models_downloads"
summary: "List model download tasks with snapshot or delta cursor semantics (`since_seq`)."
responses:
200:
description: "OK"
x-openclaw-auth: "Admin"
x-openclaw-section: "1.3C Model Management & Installations"
x-openclaw-legacy-path: "/moltbot/models/downloads"
x-openclaw-auth-tier: "admin"
security:
- OpenClawAdminToken:
[]
/models/downloads/{task_id}:
get:
operationId: "get_models_downloads_task_id"
summary: "Get one model download task by id."
responses:
200:
description: "OK"
x-openclaw-auth: "Admin"
x-openclaw-section: "1.3C Model Management & Installations"
x-openclaw-legacy-path: "/moltbot/models/downloads/{task_id}"
x-openclaw-auth-tier: "admin"
security:
- OpenClawAdminToken:
[]
parameters:
- name: "task_id"
in: "path"
required: true
schema:
type: "string"
/models/downloads/{task_id}/cancel:
post:
operationId: "post_models_downloads_task_id_cancel"
summary: "Cancel a queued or running model download task."
responses:
200:
description: "OK"
x-openclaw-auth: "Admin"
x-openclaw-section: "1.3C Model Management & Installations"
x-openclaw-legacy-path: "/moltbot/models/downloads/{task_id}/cancel"
x-openclaw-auth-tier: "admin"
security:
- OpenClawAdminToken:
[]
parameters:
- name: "task_id"
in: "path"
required: true
schema:
type: "string"
/models/import:
post:
operationId: "post_models_import"
summary: "Import a completed managed download into the bounded install root after provenance and hash verification."
responses:
200:
description: "OK"
x-openclaw-auth: "Admin"
x-openclaw-section: "1.3C Model Management & Installations"
x-openclaw-legacy-path: "/moltbot/models/import"
x-openclaw-auth-tier: "admin"
security:
- OpenClawAdminToken:
[]
/models/installations:
get:
operationId: "get_models_installations"
summary: "List managed model installations."
responses:
200:
description: "OK"
x-openclaw-auth: "Admin"
x-openclaw-section: "1.3C Model Management & Installations"
x-openclaw-legacy-path: "/moltbot/models/installations"
x-openclaw-auth-tier: "admin"
security:
- OpenClawAdminToken:
[]
/chat: /chat:
post: post:
operationId: "post_chat" operationId: "post_chat"
@@ -370,7 +479,7 @@ paths:
200: 200:
description: "OK" description: "OK"
x-openclaw-auth: "Admin/Local" x-openclaw-auth: "Admin/Local"
x-openclaw-section: "1.3C LLM Management & Chat" x-openclaw-section: "1.3D LLM Management & Chat"
x-openclaw-legacy-path: "/moltbot/llm/chat" x-openclaw-legacy-path: "/moltbot/llm/chat"
x-openclaw-auth-tier: "admin" x-openclaw-auth-tier: "admin"
security: security:
@@ -384,7 +493,7 @@ paths:
200: 200:
description: "OK" description: "OK"
x-openclaw-auth: "Admin" x-openclaw-auth: "Admin"
x-openclaw-section: "1.3C LLM Management & Chat" x-openclaw-section: "1.3D LLM Management & Chat"
x-openclaw-legacy-path: "/moltbot/llm/test" x-openclaw-legacy-path: "/moltbot/llm/test"
x-openclaw-auth-tier: "admin" x-openclaw-auth-tier: "admin"
security: security:
@@ -398,7 +507,7 @@ paths:
200: 200:
description: "OK" description: "OK"
x-openclaw-auth: "Admin" x-openclaw-auth: "Admin"
x-openclaw-section: "1.3C LLM Management & Chat" x-openclaw-section: "1.3D LLM Management & Chat"
x-openclaw-legacy-path: "/moltbot/llm/models" x-openclaw-legacy-path: "/moltbot/llm/models"
x-openclaw-auth-tier: "admin" x-openclaw-auth-tier: "admin"
security: security:
+21 -1
View File
@@ -104,7 +104,27 @@ Connector diagnostics contract notes:
- `/connector/installations` diagnostics may include aggregate `health_counts` in addition to lifecycle `status_counts` - `/connector/installations` diagnostics may include aggregate `health_counts` in addition to lifecycle `status_counts`
- `/connector/installations/resolve` may expose a stable `health_code` alongside the legacy `reject_reason` so clients can distinguish `workspace_unbound` vs token-health failures without parsing status text - `/connector/installations/resolve` may expose a stable `health_code` alongside the legacy `reject_reason` so clients can distinguish `workspace_unbound` vs token-health failures without parsing status text
### 1.3C LLM Management & Chat ### 1.3C Model Management & Installations
**Base Path**: `/openclaw/`
**Auth**: Admin Token Required
| Method | Path | Legacy Path | Auth | Description |
| :--- | :--- | :--- | :--- | :--- |
| `GET` | `/models/search` | `/moltbot/models/search` | Admin | Search normalized model entries across managed installs and catalog sources. |
| `POST` | `/models/downloads` | `/moltbot/models/downloads` | Admin | Create a managed model download task with progress/cancel lifecycle. |
| `GET` | `/models/downloads` | `/moltbot/models/downloads` | Admin | List model download tasks with snapshot or delta cursor semantics (`since_seq`). |
| `GET` | `/models/downloads/{task_id}` | `/moltbot/models/downloads/{task_id}` | Admin | Get one model download task by id. |
| `POST` | `/models/downloads/{task_id}/cancel` | `/moltbot/models/downloads/{task_id}/cancel` | Admin | Cancel a queued or running model download task. |
| `POST` | `/models/import` | `/moltbot/models/import` | Admin | Import a completed managed download into the bounded install root after provenance and hash verification. |
| `GET` | `/models/installations` | `/moltbot/models/installations` | Admin | List managed model installations. |
Model-manager contract notes:
- `/models/downloads` supports `since_seq` cursor polling and may return deterministic delta metadata (`requested_since_seq`, `effective_since_seq`, `next_since_seq`, truncation/reset hints) alongside the task list
- download creation requires structured provenance metadata (`publisher`, `license`, `source_url`) and a 64-char `expected_sha256`
- import keeps fail-closed destination/filename validation and re-checks the staged file hash before activation
### 1.3D LLM Management & Chat
**LLM Base Path**: `/openclaw/llm/` **LLM Base Path**: `/openclaw/llm/`
+25 -28
View File
@@ -3,7 +3,28 @@ import re
from dataclasses import asdict, dataclass, field from dataclasses import asdict, dataclass, field
from typing import Any, Dict, List, Optional, Union from typing import Any, Dict, List, Optional, Union
SCHEMA_VERSION = "260127" if __package__ and "." in __package__:
from ..services.request_contracts import (
MAX_BODY_SIZE,
MAX_INPUT_STRING_LENGTH,
MAX_JOB_ID_LENGTH,
MAX_PROFILE_ID_LENGTH,
MAX_TEMPLATE_ID_LENGTH,
MAX_TRACE_ID_LENGTH,
SCHEMA_VERSION,
WEBHOOK_JOB_REQUEST_CONTRACT,
)
else: # pragma: no cover - top-level test import mode
from services.request_contracts import ( # type: ignore
MAX_BODY_SIZE,
MAX_INPUT_STRING_LENGTH,
MAX_JOB_ID_LENGTH,
MAX_PROFILE_ID_LENGTH,
MAX_TEMPLATE_ID_LENGTH,
MAX_TRACE_ID_LENGTH,
SCHEMA_VERSION,
WEBHOOK_JOB_REQUEST_CONTRACT,
)
@dataclass @dataclass
@@ -93,16 +114,6 @@ class ParamPatch:
reason: Optional[str] = None reason: Optional[str] = None
# S2: Webhook input schema
# Maximum field lengths for security
MAX_JOB_ID_LENGTH = 64
MAX_TEMPLATE_ID_LENGTH = 64
MAX_PROFILE_ID_LENGTH = 64
MAX_INPUT_STRING_LENGTH = 2048
MAX_BODY_SIZE = 65536 # 64KB
MAX_TRACE_ID_LENGTH = 64
@dataclass @dataclass
class WebhookJobRequest: class WebhookJobRequest:
""" """
@@ -138,13 +149,7 @@ class WebhookJobRequest:
raise ValueError(f"profile_id exceeds max length ({MAX_PROFILE_ID_LENGTH})") raise ValueError(f"profile_id exceeds max length ({MAX_PROFILE_ID_LENGTH})")
# Validate inputs (only allowed keys, string length limits) # Validate inputs (only allowed keys, string length limits)
allowed_input_keys = { allowed_input_keys = set(WEBHOOK_JOB_REQUEST_CONTRACT["allowed_input_keys"])
"requirements",
"goal",
"seed",
"positive_prompt",
"negative_prompt",
}
for key, value in self.inputs.items(): for key, value in self.inputs.items():
if key not in allowed_input_keys: if key not in allowed_input_keys:
raise ValueError(f"Unknown input key: {key}") raise ValueError(f"Unknown input key: {key}")
@@ -157,21 +162,13 @@ class WebhookJobRequest:
def from_dict(cls, data: Dict[str, Any]) -> "WebhookJobRequest": def from_dict(cls, data: Dict[str, Any]) -> "WebhookJobRequest":
"""Parse and validate from dict.""" """Parse and validate from dict."""
# 1. Check for unknown keys (Strict validation) # 1. Check for unknown keys (Strict validation)
allowed_top_level = { allowed_top_level = set(WEBHOOK_JOB_REQUEST_CONTRACT["allowed_top_level"])
"version",
"template_id",
"profile_id",
"inputs",
"job_id",
"trace_id",
"callback",
}
unknown = set(data.keys()) - allowed_top_level unknown = set(data.keys()) - allowed_top_level
if unknown: if unknown:
raise ValueError(f"Unknown fields: {unknown}") raise ValueError(f"Unknown fields: {unknown}")
# 2. Check required fields # 2. Check required fields
required = {"version", "template_id", "profile_id"} required = set(WEBHOOK_JOB_REQUEST_CONTRACT["required_top_level"])
missing = required - set(data.keys()) missing = required - set(data.keys())
if missing: if missing:
raise ValueError(f"Missing required fields: {missing}") raise ValueError(f"Missing required fields: {missing}")
+8 -5
View File
@@ -252,11 +252,14 @@ def _is_sha256(value: str) -> bool:
def _sanitize_subdir(text: str) -> str: def _sanitize_subdir(text: str) -> str:
parts = [ raw_parts = str(text or "").replace("\\", "/").split("/")
p if any(part in {".", ".."} for part in raw_parts if part):
for p in str(text or "").replace("\\", "/").split("/") # CRITICAL: reject traversal markers instead of stripping them; silent cleanup weakens import-boundary guarantees.
if p not in {"", ".", ".."} raise ModelManagerError(
] "invalid_destination",
"destination_subdir must not contain traversal segments",
)
parts = [p for p in raw_parts if p]
if not parts: if not parts:
raise ModelManagerError("invalid_destination", "destination_subdir is required") raise ModelManagerError("invalid_destination", "destination_subdir is required")
cleaned = [] cleaned = []
+11 -3
View File
@@ -14,6 +14,11 @@ import uuid
from pathlib import Path from pathlib import Path
from typing import Any, Dict, List, Optional from typing import Any, Dict, List, Optional
from .request_contracts import (
MODEL_MANAGER_IMPORT_CONTRACT,
MODEL_MANAGER_PROVENANCE_CONTRACT,
)
def validate_url_policy(*, manager: Any, url: str) -> None: def validate_url_policy(*, manager: Any, url: str) -> None:
if not str(url or "").strip(): if not str(url or "").strip():
@@ -37,13 +42,15 @@ def validate_url_policy(*, manager: Any, url: str) -> None:
def validate_provenance(*, manager: Any, provenance: Dict[str, Any]) -> Dict[str, Any]: def validate_provenance(*, manager: Any, provenance: Dict[str, Any]) -> Dict[str, Any]:
if not isinstance(provenance, dict): if not isinstance(provenance, dict):
raise manager._error("invalid_provenance", "provenance must be an object") raise manager._error("invalid_provenance", "provenance must be an object")
note_max_chars = int(MODEL_MANAGER_PROVENANCE_CONTRACT["note_max_chars"])
out = { out = {
"publisher": str(provenance.get("publisher") or "").strip(), "publisher": str(provenance.get("publisher") or "").strip(),
"license": str(provenance.get("license") or "").strip(), "license": str(provenance.get("license") or "").strip(),
"source_url": str(provenance.get("source_url") or "").strip(), "source_url": str(provenance.get("source_url") or "").strip(),
"note": str(provenance.get("note") or "").strip()[:500], "note": str(provenance.get("note") or "").strip()[:note_max_chars],
} }
if not out["publisher"] or not out["license"] or not out["source_url"]: required_fields = list(MODEL_MANAGER_PROVENANCE_CONTRACT["required_fields"])
if any(not out[field] for field in required_fields):
raise manager._error( raise manager._error(
"invalid_provenance", "invalid_provenance",
"provenance.publisher, provenance.license, provenance.source_url are required", "provenance.publisher, provenance.license, provenance.source_url are required",
@@ -450,6 +457,7 @@ def import_downloaded_model(
pass pass
raise raise
safe_tags: List[str] = [] safe_tags: List[str] = []
max_tags = int(MODEL_MANAGER_IMPORT_CONTRACT["tags"]["max_items"])
for item in tags or []: for item in tags or []:
if not isinstance(item, str): if not isinstance(item, str):
continue continue
@@ -457,7 +465,7 @@ def import_downloaded_model(
if not clean or clean in safe_tags: if not clean or clean in safe_tags:
continue continue
safe_tags.append(clean) safe_tags.append(clean)
if len(safe_tags) >= 24: if len(safe_tags) >= max_tags:
break break
rec = { rec = {
"id": str(uuid.uuid4()), "id": str(uuid.uuid4()),
+188
View File
@@ -0,0 +1,188 @@
"""
R144 shared JSON-serializable request/schema fixtures.
These fixtures are consumed by runtime validators and contract tests so route
behavior, schema limits, and public docs do not drift independently.
"""
from __future__ import annotations
from copy import deepcopy
from typing import Any, Dict
SCHEMA_VERSION = "260127"
MAX_JOB_ID_LENGTH = 64
MAX_TEMPLATE_ID_LENGTH = 64
MAX_PROFILE_ID_LENGTH = 64
MAX_INPUT_STRING_LENGTH = 2048
MAX_BODY_SIZE = 65536
MAX_TRACE_ID_LENGTH = 64
WEBHOOK_JOB_REQUEST_CONTRACT: Dict[str, Any] = {
"schema_id": "webhook_job_request_v1",
"version": 1,
"required_top_level": ["version", "template_id", "profile_id"],
"allowed_top_level": [
"version",
"template_id",
"profile_id",
"inputs",
"job_id",
"trace_id",
"callback",
],
"allowed_input_keys": [
"requirements",
"goal",
"seed",
"positive_prompt",
"negative_prompt",
],
"limits": {
"job_id_max_length": MAX_JOB_ID_LENGTH,
"template_id_max_length": MAX_TEMPLATE_ID_LENGTH,
"profile_id_max_length": MAX_PROFILE_ID_LENGTH,
"input_string_max_length": MAX_INPUT_STRING_LENGTH,
"body_max_bytes": MAX_BODY_SIZE,
"trace_id_max_length": MAX_TRACE_ID_LENGTH,
},
"trace_id_pattern": "^[a-zA-Z0-9_-]+$",
}
MODEL_MANAGER_PROVENANCE_CONTRACT: Dict[str, Any] = {
"schema_id": "model_manager_provenance_v1",
"required_fields": ["publisher", "license", "source_url"],
"optional_fields": ["note"],
"note_max_chars": 500,
}
MODEL_MANAGER_IMPORT_CONTRACT: Dict[str, Any] = {
"schema_id": "model_manager_import_v1",
"required_fields": ["task_id"],
"optional_fields": ["destination_subdir", "filename", "tags"],
"tags": {
"max_items": 24,
"normalize": "strip_lower_dedupe",
},
}
R144_ROUTE_FIXTURES: Dict[str, Any] = {
"webhook": [
{
"method": "POST",
"path": "/webhook",
"legacy_path": "/moltbot/webhook",
"auth": "Webhook Secret",
},
{
"method": "POST",
"path": "/webhook/submit",
"legacy_path": "/moltbot/webhook/submit",
"auth": "Webhook Secret",
},
{
"method": "POST",
"path": "/webhook/validate",
"legacy_path": "/moltbot/webhook/validate",
"auth": "Webhook Secret",
},
],
"model_manager": [
{
"method": "GET",
"path": "/models/search",
"legacy_path": "/moltbot/models/search",
"auth": "Admin",
},
{
"method": "POST",
"path": "/models/downloads",
"legacy_path": "/moltbot/models/downloads",
"auth": "Admin",
},
{
"method": "GET",
"path": "/models/downloads",
"legacy_path": "/moltbot/models/downloads",
"auth": "Admin",
},
{
"method": "GET",
"path": "/models/downloads/{task_id}",
"legacy_path": "/moltbot/models/downloads/{task_id}",
"auth": "Admin",
},
{
"method": "POST",
"path": "/models/downloads/{task_id}/cancel",
"legacy_path": "/moltbot/models/downloads/{task_id}/cancel",
"auth": "Admin",
},
{
"method": "POST",
"path": "/models/import",
"legacy_path": "/moltbot/models/import",
"auth": "Admin",
},
{
"method": "GET",
"path": "/models/installations",
"legacy_path": "/moltbot/models/installations",
"auth": "Admin",
},
],
}
R144_IO_BOUNDARY_MATRIX: Dict[str, Any] = {
"webhook_validate": [
{
"case_id": "body_gt_limit_rejected",
"limit_bytes": MAX_BODY_SIZE,
"expected_status": 413,
"expected_error": "payload_too_large",
},
{
"case_id": "body_eq_limit_not_rejected_by_size_gate",
"limit_bytes": MAX_BODY_SIZE,
"expected_status": 400,
"expected_error": "validation_error",
},
{
"case_id": "malformed_json_rejected",
"expected_status": 400,
"expected_error": "invalid_json",
},
],
"model_manager_download_create": [
{
"case_id": "provenance_must_be_object",
"expected_status": 400,
"expected_error": "invalid_provenance",
}
],
"model_manager_import": [
{
"case_id": "invalid_filename_extension_rejected",
"expected_status": 400,
"expected_error": "invalid_filename",
},
{
"case_id": "invalid_destination_rejected",
"expected_status": 400,
"expected_error": "invalid_destination",
},
],
}
def get_serializable_contract_bundle() -> Dict[str, Any]:
return deepcopy(
{
"webhook_job_request": WEBHOOK_JOB_REQUEST_CONTRACT,
"model_manager_provenance": MODEL_MANAGER_PROVENANCE_CONTRACT,
"model_manager_import": MODEL_MANAGER_IMPORT_CONTRACT,
"route_fixtures": R144_ROUTE_FIXTURES,
"io_boundary_matrix": R144_IO_BOUNDARY_MATRIX,
}
)
+72 -12
View File
@@ -18,6 +18,7 @@ except Exception: # pragma: no cover
from api import model_manager as mm_api from api import model_manager as mm_api
from services.model_manager import ModelManager from services.model_manager import ModelManager
from services.request_contracts import R144_IO_BOUNDARY_MATRIX
@unittest.skipIf(web is None, "aiohttp not installed") @unittest.skipIf(web is None, "aiohttp not installed")
@@ -70,14 +71,8 @@ class TestModelManagerAPI(AioHTTPTestCase):
time.sleep(0.02) time.sleep(0.02)
self.fail(f"Task {task_id} did not finish") self.fail(f"Task {task_id} did not finish")
@patch("api.model_manager.require_admin_token", return_value=(True, None)) async def _create_completed_task(self, *, model_id: str = "api-model") -> str:
@patch( payload = f"{model_id}-bytes".encode("utf-8")
"services.model_manager.validate_outbound_url",
return_value=("https", "example.com", 443, ["1.1.1.1"]),
)
@unittest_run_loop
async def test_download_and_import_contract(self, _mock_validate, _mock_admin):
payload = b"api-model-bytes"
digest = hashlib.sha256(payload).hexdigest() digest = hashlib.sha256(payload).hexdigest()
def fake_download(task, _cancel_event): def fake_download(task, _cancel_event):
@@ -92,26 +87,35 @@ class TestModelManagerAPI(AioHTTPTestCase):
create_resp = await self.client.post( create_resp = await self.client.post(
"/openclaw/models/downloads", "/openclaw/models/downloads",
json={ json={
"model_id": "api-model", "model_id": model_id,
"name": "API Model", "name": "API Model",
"model_type": "checkpoint", "model_type": "checkpoint",
"source": "catalog", "source": "catalog",
"source_label": "Catalog", "source_label": "Catalog",
"download_url": "https://example.com/api-model.safetensors", "download_url": f"https://example.com/{model_id}.safetensors",
"expected_sha256": digest, "expected_sha256": digest,
"provenance": { "provenance": {
"publisher": "OpenClaw", "publisher": "OpenClaw",
"license": "OpenRAIL", "license": "OpenRAIL",
"source_url": "https://example.com/api-model", "source_url": f"https://example.com/{model_id}",
}, },
}, },
) )
self.assertEqual(create_resp.status, 201) self.assertEqual(create_resp.status, 201)
created = await create_resp.json() created = await create_resp.json()
task_id = created["task"]["task_id"] task_id = created["task"]["task_id"]
done = await self._wait_task_terminal(task_id) done = await self._wait_task_terminal(task_id)
self.assertEqual(done["state"], "completed") self.assertEqual(done["state"], "completed")
return task_id
@patch("api.model_manager.require_admin_token", return_value=(True, None))
@patch(
"services.model_manager.validate_outbound_url",
return_value=("https", "example.com", 443, ["1.1.1.1"]),
)
@unittest_run_loop
async def test_download_and_import_contract(self, _mock_validate, _mock_admin):
task_id = await self._create_completed_task(model_id="api-model")
import_resp = await self.client.post( import_resp = await self.client.post(
"/openclaw/models/import", json={"task_id": task_id} "/openclaw/models/import", json={"task_id": task_id}
@@ -142,6 +146,62 @@ class TestModelManagerAPI(AioHTTPTestCase):
resp = await self.client.get("/openclaw/models/search") resp = await self.client.get("/openclaw/models/search")
self.assertEqual(resp.status, 403) self.assertEqual(resp.status, 403)
@patch("api.model_manager.require_admin_token", return_value=(True, None))
@patch(
"services.model_manager.validate_outbound_url",
return_value=("https", "example.com", 443, ["1.1.1.1"]),
)
@unittest_run_loop
async def test_r144_download_create_boundary_matrix(
self, _mock_validate, _mock_admin
):
case = R144_IO_BOUNDARY_MATRIX["model_manager_download_create"][0]
resp = await self.client.post(
"/openclaw/models/downloads",
json={
"model_id": "bad-provenance",
"name": "Bad Provenance",
"model_type": "checkpoint",
"source": "catalog",
"source_label": "Catalog",
"download_url": "https://example.com/bad-provenance.safetensors",
"expected_sha256": "a" * 64,
"provenance": "bad",
},
)
self.assertEqual(resp.status, case["expected_status"])
body = await resp.json()
self.assertEqual(body["error"], case["expected_error"])
@patch("api.model_manager.require_admin_token", return_value=(True, None))
@patch(
"services.model_manager.validate_outbound_url",
return_value=("https", "example.com", 443, ["1.1.1.1"]),
)
@unittest_run_loop
async def test_r144_import_boundary_matrix(self, _mock_validate, _mock_admin):
task_id = await self._create_completed_task(model_id="boundary-model")
cases = R144_IO_BOUNDARY_MATRIX["model_manager_import"]
payloads = {
"invalid_filename_extension_rejected": {
"task_id": task_id,
"filename": "bad.txt",
},
"invalid_destination_rejected": {
"task_id": task_id,
"destination_subdir": "../escape",
},
}
for case in cases:
with self.subTest(case=case["case_id"]):
resp = await self.client.post(
"/openclaw/models/import",
json=payloads[case["case_id"]],
)
self.assertEqual(resp.status, case["expected_status"])
body = await resp.json()
self.assertEqual(body["error"], case["expected_error"])
if __name__ == "__main__": # pragma: no cover if __name__ == "__main__": # pragma: no cover
unittest.main() unittest.main()
+31
View File
@@ -0,0 +1,31 @@
import json
import unittest
from services.openapi_generation import parse_api_contract_markdown
from services.request_contracts import get_serializable_contract_bundle
class TestR144ContractMatrix(unittest.TestCase):
def test_contract_bundle_is_json_serializable(self):
bundle = get_serializable_contract_bundle()
encoded = json.dumps(bundle, sort_keys=True)
self.assertIn("webhook_job_request_v1", encoded)
self.assertIn("model_manager_import_v1", encoded)
def test_documented_routes_cover_r144_fixture_paths(self):
documented = {
(route.method, route.path)
for route in parse_api_contract_markdown()
}
bundle = get_serializable_contract_bundle()
missing = []
for family in bundle["route_fixtures"].values():
for route in family:
key = (route["method"], route["path"])
if key not in documented:
missing.append(key)
self.assertEqual(missing, [], f"Undocumented R144 route fixtures: {missing}")
if __name__ == "__main__":
unittest.main()
+47 -5
View File
@@ -37,6 +37,26 @@ sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
from api.webhook_validate import webhook_validate_handler from api.webhook_validate import webhook_validate_handler
from models.schemas import WebhookJobRequest from models.schemas import WebhookJobRequest
from services.execution_budgets import BudgetExceededError from services.execution_budgets import BudgetExceededError
from services.request_contracts import MAX_BODY_SIZE, R144_IO_BOUNDARY_MATRIX
def _json_payload_with_exact_size(target_bytes: int) -> str:
base = {"version": 1, "template_id": "t", "profile_id": "p", "pad": ""}
text = json.dumps(base, separators=(",", ":"))
overhead = len(text.encode("utf-8"))
filler = max(0, target_bytes - overhead)
base["pad"] = "x" * filler
text = json.dumps(base, separators=(",", ":"))
delta = target_bytes - len(text.encode("utf-8"))
if delta > 0:
base["pad"] += "x" * delta
text = json.dumps(base, separators=(",", ":"))
elif delta < 0:
base["pad"] = base["pad"][:delta]
text = json.dumps(base, separators=(",", ":"))
if len(text.encode("utf-8")) != target_bytes:
raise AssertionError("failed to build exact-size JSON payload")
return text
@unittest.skipUnless(_AIOHTTP_AVAILABLE, "aiohttp not installed") @unittest.skipUnless(_AIOHTTP_AVAILABLE, "aiohttp not installed")
@@ -157,9 +177,10 @@ class TestWebhookValidateContract(AioHTTPTestCase):
@unittest_run_loop @unittest_run_loop
async def test_payload_too_large_413_body(self): async def test_payload_too_large_413_body(self):
"""Should return 413 for large body.""" """Should return 413 for large body."""
case = R144_IO_BOUNDARY_MATRIX["webhook_validate"][0]
with patch("api.webhook_validate.require_auth", return_value=(True, None)): with patch("api.webhook_validate.require_auth", return_value=(True, None)):
with patch("api.webhook_validate.check_rate_limit", return_value=True): with patch("api.webhook_validate.check_rate_limit", return_value=True):
large_payload = {"data": "x" * (2 * 1024 * 1024)} # > MAX_BODY_SIZE large_payload = {"data": "x" * (2 * MAX_BODY_SIZE)}
resp = await self.client.post( resp = await self.client.post(
"/validate", "/validate",
json=large_payload, json=large_payload,
@@ -169,10 +190,30 @@ class TestWebhookValidateContract(AioHTTPTestCase):
}, },
) )
self.assertEqual(resp.status, 413) self.assertEqual(resp.status, case["expected_status"])
data = await resp.json() data = await resp.json()
self.assertFalse(data["ok"]) self.assertFalse(data["ok"])
self.assertEqual(data["error"], "payload_too_large") self.assertEqual(data["error"], case["expected_error"])
@unittest_run_loop
async def test_r144_boundary_equal_body_is_not_rejected_by_size_gate(self):
"""R144: payload exactly at the byte cap should pass the size gate."""
case = R144_IO_BOUNDARY_MATRIX["webhook_validate"][1]
with patch("api.webhook_validate.require_auth", return_value=(True, None)):
with patch("api.webhook_validate.check_rate_limit", return_value=True):
payload = _json_payload_with_exact_size(case["limit_bytes"])
resp = await self.client.post(
"/validate",
data=payload.encode("utf-8"),
headers={
"Authorization": "Bearer token",
"Content-Type": "application/json",
},
)
self.assertEqual(resp.status, case["expected_status"])
data = await resp.json()
self.assertEqual(data["error"], case["expected_error"])
@unittest_run_loop @unittest_run_loop
async def test_payload_too_large_413_render(self): async def test_payload_too_large_413_render(self):
@@ -219,6 +260,7 @@ class TestWebhookValidateContract(AioHTTPTestCase):
@unittest_run_loop @unittest_run_loop
async def test_invalid_json_400(self): async def test_invalid_json_400(self):
"""Should return 400 for malformed JSON.""" """Should return 400 for malformed JSON."""
case = R144_IO_BOUNDARY_MATRIX["webhook_validate"][2]
with patch("api.webhook_validate.require_auth", return_value=(True, None)): with patch("api.webhook_validate.require_auth", return_value=(True, None)):
with patch("api.webhook_validate.check_rate_limit", return_value=True): with patch("api.webhook_validate.check_rate_limit", return_value=True):
resp = await self.client.post( resp = await self.client.post(
@@ -230,10 +272,10 @@ class TestWebhookValidateContract(AioHTTPTestCase):
}, },
) )
self.assertEqual(resp.status, 400) self.assertEqual(resp.status, case["expected_status"])
data = await resp.json() data = await resp.json()
self.assertFalse(data["ok"]) self.assertFalse(data["ok"])
self.assertEqual(data["error"], "invalid_json") self.assertEqual(data["error"], case["expected_error"])
@unittest_run_loop @unittest_run_loop
async def test_validation_error_400(self): async def test_validation_error_400(self):