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:
- 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:
post:
operationId: "post_chat"
@@ -370,7 +479,7 @@ paths:
200:
description: "OK"
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-auth-tier: "admin"
security:
@@ -384,7 +493,7 @@ paths:
200:
description: "OK"
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-auth-tier: "admin"
security:
@@ -398,7 +507,7 @@ paths:
200:
description: "OK"
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-auth-tier: "admin"
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/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/`
+25 -28
View File
@@ -3,7 +3,28 @@ import re
from dataclasses import asdict, dataclass, field
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
@@ -93,16 +114,6 @@ class ParamPatch:
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
class WebhookJobRequest:
"""
@@ -138,13 +149,7 @@ class WebhookJobRequest:
raise ValueError(f"profile_id exceeds max length ({MAX_PROFILE_ID_LENGTH})")
# Validate inputs (only allowed keys, string length limits)
allowed_input_keys = {
"requirements",
"goal",
"seed",
"positive_prompt",
"negative_prompt",
}
allowed_input_keys = set(WEBHOOK_JOB_REQUEST_CONTRACT["allowed_input_keys"])
for key, value in self.inputs.items():
if key not in allowed_input_keys:
raise ValueError(f"Unknown input key: {key}")
@@ -157,21 +162,13 @@ class WebhookJobRequest:
def from_dict(cls, data: Dict[str, Any]) -> "WebhookJobRequest":
"""Parse and validate from dict."""
# 1. Check for unknown keys (Strict validation)
allowed_top_level = {
"version",
"template_id",
"profile_id",
"inputs",
"job_id",
"trace_id",
"callback",
}
allowed_top_level = set(WEBHOOK_JOB_REQUEST_CONTRACT["allowed_top_level"])
unknown = set(data.keys()) - allowed_top_level
if unknown:
raise ValueError(f"Unknown fields: {unknown}")
# 2. Check required fields
required = {"version", "template_id", "profile_id"}
required = set(WEBHOOK_JOB_REQUEST_CONTRACT["required_top_level"])
missing = required - set(data.keys())
if 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:
parts = [
p
for p in str(text or "").replace("\\", "/").split("/")
if p not in {"", ".", ".."}
]
raw_parts = str(text or "").replace("\\", "/").split("/")
if any(part in {".", ".."} for part in raw_parts if part):
# CRITICAL: reject traversal markers instead of stripping them; silent cleanup weakens import-boundary guarantees.
raise ModelManagerError(
"invalid_destination",
"destination_subdir must not contain traversal segments",
)
parts = [p for p in raw_parts if p]
if not parts:
raise ModelManagerError("invalid_destination", "destination_subdir is required")
cleaned = []
+11 -3
View File
@@ -14,6 +14,11 @@ import uuid
from pathlib import Path
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:
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]:
if not isinstance(provenance, dict):
raise manager._error("invalid_provenance", "provenance must be an object")
note_max_chars = int(MODEL_MANAGER_PROVENANCE_CONTRACT["note_max_chars"])
out = {
"publisher": str(provenance.get("publisher") or "").strip(),
"license": str(provenance.get("license") 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(
"invalid_provenance",
"provenance.publisher, provenance.license, provenance.source_url are required",
@@ -450,6 +457,7 @@ def import_downloaded_model(
pass
raise
safe_tags: List[str] = []
max_tags = int(MODEL_MANAGER_IMPORT_CONTRACT["tags"]["max_items"])
for item in tags or []:
if not isinstance(item, str):
continue
@@ -457,7 +465,7 @@ def import_downloaded_model(
if not clean or clean in safe_tags:
continue
safe_tags.append(clean)
if len(safe_tags) >= 24:
if len(safe_tags) >= max_tags:
break
rec = {
"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 services.model_manager import ModelManager
from services.request_contracts import R144_IO_BOUNDARY_MATRIX
@unittest.skipIf(web is None, "aiohttp not installed")
@@ -70,14 +71,8 @@ class TestModelManagerAPI(AioHTTPTestCase):
time.sleep(0.02)
self.fail(f"Task {task_id} did not finish")
@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):
payload = b"api-model-bytes"
async def _create_completed_task(self, *, model_id: str = "api-model") -> str:
payload = f"{model_id}-bytes".encode("utf-8")
digest = hashlib.sha256(payload).hexdigest()
def fake_download(task, _cancel_event):
@@ -92,26 +87,35 @@ class TestModelManagerAPI(AioHTTPTestCase):
create_resp = await self.client.post(
"/openclaw/models/downloads",
json={
"model_id": "api-model",
"model_id": model_id,
"name": "API Model",
"model_type": "checkpoint",
"source": "catalog",
"source_label": "Catalog",
"download_url": "https://example.com/api-model.safetensors",
"download_url": f"https://example.com/{model_id}.safetensors",
"expected_sha256": digest,
"provenance": {
"publisher": "OpenClaw",
"license": "OpenRAIL",
"source_url": "https://example.com/api-model",
"source_url": f"https://example.com/{model_id}",
},
},
)
self.assertEqual(create_resp.status, 201)
created = await create_resp.json()
task_id = created["task"]["task_id"]
done = await self._wait_task_terminal(task_id)
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(
"/openclaw/models/import", json={"task_id": task_id}
@@ -142,6 +146,62 @@ class TestModelManagerAPI(AioHTTPTestCase):
resp = await self.client.get("/openclaw/models/search")
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
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 models.schemas import WebhookJobRequest
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")
@@ -157,9 +177,10 @@ class TestWebhookValidateContract(AioHTTPTestCase):
@unittest_run_loop
async def test_payload_too_large_413_body(self):
"""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.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(
"/validate",
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()
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
async def test_payload_too_large_413_render(self):
@@ -219,6 +260,7 @@ class TestWebhookValidateContract(AioHTTPTestCase):
@unittest_run_loop
async def test_invalid_json_400(self):
"""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.check_rate_limit", return_value=True):
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()
self.assertFalse(data["ok"])
self.assertEqual(data["error"], "invalid_json")
self.assertEqual(data["error"], case["expected_error"])
@unittest_run_loop
async def test_validation_error_400(self):