report
This commit is contained in:
Binary file not shown.
Binary file not shown.
+53
@@ -0,0 +1,53 @@
|
||||
from ddms_compliance_suite.test_framework_core import BaseAPITestCase, TestSeverity, ValidationResult, APIRequestContext, APIResponseContext
|
||||
import logging
|
||||
from typing import Dict, Any, Optional # 确保引入 Optional
|
||||
|
||||
class StatusCode200Check(BaseAPITestCase):
|
||||
# 1. 元数据
|
||||
id = "TC-STATUS-001"
|
||||
name = "基本状态码 200 检查"
|
||||
description = "验证 API 响应状态码是否为 200 OK。"
|
||||
severity = TestSeverity.CRITICAL
|
||||
tags = ["status_code", "smoke_test"]
|
||||
# 适用于所有方法和路径 (默认)
|
||||
# applicable_methods = None
|
||||
# applicable_paths_regex = None
|
||||
execution_order = 1 # 执行顺序
|
||||
is_critical_setup_test = True
|
||||
# use_llm_for_body: bool = True
|
||||
# use_llm_for_path_params: bool = True
|
||||
# use_llm_for_query_params: bool = True
|
||||
# use_llm_for_headers: bool = True
|
||||
|
||||
def __init__(self, endpoint_spec: Dict[str, Any], global_api_spec: Dict[str, Any], json_schema_validator: Optional[Any] = None, llm_service: Optional[Any] = None):
|
||||
super().__init__(endpoint_spec, global_api_spec, json_schema_validator=json_schema_validator, llm_service=llm_service)
|
||||
self.logger.info(f"测试用例 {self.id} ({self.name}) 已针对端点 '{self.endpoint_spec.get('method')} {self.endpoint_spec.get('path')}' 初始化。")
|
||||
|
||||
def validate_response(self, response_context: APIResponseContext, request_context: APIRequestContext) -> list[ValidationResult]:
|
||||
results = []
|
||||
expected_status_code = 200
|
||||
actual_status_code = response_context.status_code
|
||||
|
||||
if actual_status_code == expected_status_code:
|
||||
results.append(
|
||||
ValidationResult(
|
||||
passed=True,
|
||||
message=f"响应状态码为 {actual_status_code},符合预期 {expected_status_code}。"
|
||||
)
|
||||
)
|
||||
self.logger.info(f"状态码验证通过: {actual_status_code} == {expected_status_code} for {request_context.url}")
|
||||
else:
|
||||
results.append(
|
||||
ValidationResult(
|
||||
passed=False,
|
||||
message=f"期望状态码 {expected_status_code},但收到 {actual_status_code}。",
|
||||
details={
|
||||
"expected_status": expected_status_code,
|
||||
"actual_status": actual_status_code,
|
||||
"request_url": request_context.url,
|
||||
"response_body_sample": (response_context.text_content or "")[:200] # 包含部分响应体以帮助诊断
|
||||
}
|
||||
)
|
||||
)
|
||||
self.logger.warning(f"状态码验证失败: 期望 {expected_status_code}, 实际 {actual_status_code} for {request_context.url}")
|
||||
return results
|
||||
@@ -0,0 +1 @@
|
||||
# This file marks the compliance_catalog directory as a Python package.
|
||||
@@ -0,0 +1 @@
|
||||
# This file marks the core_functionality directory as a Python package.
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
Vendored
+81
@@ -0,0 +1,81 @@
|
||||
from typing import Dict, Any, List, Optional
|
||||
from ddms_compliance_suite.test_framework_core import BaseAPITestCase, TestSeverity, ValidationResult, APIRequestContext
|
||||
from ddms_compliance_suite.utils.response_utils import extract_data_for_validation
|
||||
from ddms_compliance_suite.utils.schema_provider import SchemaProvider
|
||||
import logging
|
||||
|
||||
class FlexibleSchemaValidationCase(BaseAPITestCase):
|
||||
"""
|
||||
一个灵活的Schema验证测试用例,能够处理非标准的响应结构和动态的Schema来源。
|
||||
"""
|
||||
id = "TC-CORE-FUNC-002"
|
||||
name = "灵活的返回体JSON Schema验证"
|
||||
description = (
|
||||
"验证API响应体是否符合预期的JSON Schema。此用例能够智能处理被包装的响应(如{code, data}),"
|
||||
"并支持从列表响应中验证每个元素。它依赖于SchemaProvider获取schema,并设计为处理需要动态获取schema的场景。"
|
||||
)
|
||||
severity = TestSeverity.CRITICAL
|
||||
tags = ["core-functionality", "schema-validation", "flexible"]
|
||||
execution_order = 110 # 略高于标准Schema验证,以便在适用时优先执行
|
||||
|
||||
def __init__(self, endpoint_spec: Dict[str, Any], global_api_spec: Dict[str, Any], json_schema_validator: Optional[Any] = None, llm_service: Optional[Any] = None):
|
||||
super().__init__(endpoint_spec, global_api_spec, json_schema_validator, llm_service)
|
||||
# We need to initialize the schema_provider here, as it's no longer injected.
|
||||
self.schema_provider = SchemaProvider(global_api_spec) if global_api_spec else None
|
||||
self.logger.info(f"测试用例 '{self.id}' 已为端点 '{self.endpoint_spec.get('method')} {self.endpoint_spec.get('path')}' 初始化。")
|
||||
|
||||
|
||||
def execute(self, request_context: APIRequestContext) -> List[ValidationResult]:
|
||||
"""
|
||||
执行灵活的schema验证。
|
||||
"""
|
||||
results = []
|
||||
response_context = self.api_caller.call_api(request_context)
|
||||
|
||||
if not response_context:
|
||||
return [self.failed("API调用失败,无法获取响应进行验证。")]
|
||||
|
||||
# 1. 使用 SchemaProvider 获取 Schema
|
||||
if not self.schema_provider:
|
||||
return [self.failed("SchemaProvider 未被初始化,无法执行此测试用例。")]
|
||||
|
||||
expected_schema = self.schema_provider.get_schema(self.endpoint_spec, response_context.status_code)
|
||||
|
||||
if not expected_schema:
|
||||
# 如果是成功响应但找不到schema,这可能是一个问题
|
||||
if 200 <= response_context.status_code < 300:
|
||||
return [self.failed(f"成功响应(状态码 {response_context.status_code}),但无法为其找到或生成JSON Schema。")]
|
||||
else:
|
||||
return [self.passed(f"非成功响应(状态码 {response_context.status_code})且未定义Schema,跳过验证。")]
|
||||
|
||||
# 2. 使用 response_utils 提取待验证的数据列表
|
||||
if not response_context.json_content:
|
||||
return [self.failed(f"响应内容不是有效的JSON格式,无法进行Schema验证。响应文本: {response_context.text_content[:200]}...")]
|
||||
|
||||
data_to_validate_list = extract_data_for_validation(response_context.json_content)
|
||||
|
||||
if not data_to_validate_list:
|
||||
# extract_data_for_validation 在找不到数据或遇到空列表时返回空列表
|
||||
return [self.passed("未从响应中提取到需要验证的数据项(可能为空列表),跳过验证。")]
|
||||
|
||||
# 3. 遍历列表,对每个数据项进行验证
|
||||
all_items_passed = True
|
||||
for i, item in enumerate(data_to_validate_list):
|
||||
item_context_prefix = f"响应列表中的第 {i+1} 个元素"
|
||||
validation_results = self.validate_data_against_schema(
|
||||
data_to_validate=item,
|
||||
schema_definition=expected_schema,
|
||||
context_message_prefix=item_context_prefix
|
||||
)
|
||||
|
||||
for res in validation_results:
|
||||
if not res.passed:
|
||||
all_items_passed = False
|
||||
# 为错误信息添加更多上下文
|
||||
res.message = f"{item_context_prefix} {res.message}"
|
||||
results.append(res)
|
||||
|
||||
if all_items_passed:
|
||||
results.append(self.passed(f"成功验证了响应中的 {len(data_to_validate_list)} 个数据项,均符合Schema。"))
|
||||
|
||||
return results
|
||||
+53
@@ -0,0 +1,53 @@
|
||||
from typing import Dict, Any, Optional, List
|
||||
from ddms_compliance_suite.test_framework_core import BaseAPITestCase, TestSeverity, ValidationResult, APIRequestContext, APIResponseContext
|
||||
import json
|
||||
from ddms_compliance_suite.utils.schema_provider import SchemaProvider
|
||||
|
||||
class ResponseSchemaValidationCase(BaseAPITestCase):
|
||||
id = "TC-CORE-FUNC-001"
|
||||
name = "返回体JSON Schema验证"
|
||||
description = "验证API响应体是否符合API规范中定义的JSON Schema。"
|
||||
severity = TestSeverity.CRITICAL
|
||||
tags = ["core-functionality", "schema-validation", "output-format"]
|
||||
execution_order = 100 # Default, can be adjusted
|
||||
|
||||
|
||||
def __init__(self, endpoint_spec: Dict[str, Any], global_api_spec: Dict[str, Any], json_schema_validator: Optional[Any] = None, llm_service: Optional[Any] = None):
|
||||
super().__init__(endpoint_spec, global_api_spec, json_schema_validator, llm_service=llm_service)
|
||||
self.schema_provider = SchemaProvider(global_api_spec) if global_api_spec else None
|
||||
self.logger.info(f"测试用例 '{self.id}' 已为端点 '{self.endpoint_spec.get('method')} {self.endpoint_spec.get('path')}' 初始化。")
|
||||
|
||||
def validate_response(self, response_context: APIResponseContext, request_context: APIRequestContext) -> List[ValidationResult]:
|
||||
results = []
|
||||
method = request_context.method.upper()
|
||||
status_code = response_context.status_code
|
||||
|
||||
if not self.schema_provider:
|
||||
return [self.failed("SchemaProvider 未被初始化,无法执行此测试用例。")]
|
||||
|
||||
expected_schema = self.schema_provider.get_schema(self.endpoint_spec, status_code)
|
||||
|
||||
if expected_schema and response_context.json_content is not None:
|
||||
self.logger.info(f"将根据从API规范中获取的schema验证响应体。")
|
||||
schema_validation_results = self.validate_data_against_schema(
|
||||
data_to_validate=response_context.json_content,
|
||||
schema_definition=expected_schema,
|
||||
context_message_prefix=f"针对 {method} {request_context.url} (状态码 {status_code}) 的响应体"
|
||||
)
|
||||
results.extend(schema_validation_results)
|
||||
elif response_context.json_content is None and method not in ["DELETE", "HEAD", "OPTIONS"] and status_code in [200, 201, 202]:
|
||||
if expected_schema:
|
||||
results.append(self.failed(
|
||||
message=f"根据schema期望一个JSON响应体,但未收到可解析的JSON内容。",
|
||||
details={"status_code": status_code, "response_text_sample": (response_context.text_content or "")[:200]}
|
||||
))
|
||||
self.logger.warning(f"期望 {method} {request_context.url} 返回JSON响应体,但未收到或非JSON格式。")
|
||||
elif not expected_schema and response_context.json_content is not None and status_code // 100 == 2:
|
||||
self.logger.info(f"响应包含JSON体,但在API规范中未找到针对状态码 {status_code} 的JSON schema。跳过schema验证。")
|
||||
elif not expected_schema and response_context.json_content is None:
|
||||
self.logger.info(f"状态码 {status_code} 的响应无JSON体也无定义的schema。跳过schema验证。")
|
||||
|
||||
if not results:
|
||||
results.append(self.passed("标准Schema验证步骤完成(未发现问题,或schema不适用/未为此响应定义)。"))
|
||||
|
||||
return results
|
||||
@@ -0,0 +1 @@
|
||||
# This file marks the error_handling directory as a Python package.
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
+196
@@ -0,0 +1,196 @@
|
||||
from typing import Dict, Any, Optional, List, Union, Tuple
|
||||
from ddms_compliance_suite.test_framework_core import BaseAPITestCase, TestSeverity, ValidationResult, APIRequestContext, APIResponseContext
|
||||
import logging
|
||||
from ddms_compliance_suite.utils import schema_utils
|
||||
import random
|
||||
import string
|
||||
|
||||
class InvalidEnumValueCase(BaseAPITestCase):
|
||||
id = "TC-ERROR-4006"
|
||||
name = "非法枚举值检查"
|
||||
description = "测试当发送的参数值不在指定的枚举范围内时,API是否按预期返回code=4006的错误。"
|
||||
severity = TestSeverity.MEDIUM
|
||||
tags = ["error-handling", "appendix-b", "4006", "invalid-enum"]
|
||||
execution_order = 204 # 在数值越界之后执行
|
||||
|
||||
@staticmethod
|
||||
def _endpoint_has_enum_field(endpoint_spec: Dict[str, Any], logger: logging.Logger) -> bool:
|
||||
"""
|
||||
静态辅助方法,检查端点规范中是否有任何字段(body或parameters)包含枚举定义。
|
||||
"""
|
||||
# 1. 检查请求体 (Body)
|
||||
if endpoint_spec.get("requestBody"):
|
||||
# 注意:这里需要一个可以解析$ref的上下文,但applies_to是静态的。
|
||||
# 这是一个简化检查,它只检查顶级schema。
|
||||
# 更完整的检查需要一个能够解析$ref的schema工具。
|
||||
# 为了简单起见,我们假设测试用例的 _get_resolved_request_body_schema 可以在实例化后使用。
|
||||
# 这里的检查可能不完整,但可以覆盖大部分情况。
|
||||
if schema_utils.util_find_enum_field_recursive(endpoint_spec["requestBody"].get("content", {}).get("application/json", {}).get("schema", {}), [], logger):
|
||||
return True
|
||||
|
||||
# 2. 检查查询参数, 路径参数, 请求头
|
||||
if 'parameters' in endpoint_spec:
|
||||
for param in endpoint_spec['parameters']:
|
||||
param_desc = param.get('description', '')
|
||||
if schema_utils.util_extract_enum_from_description(param_desc, logger):
|
||||
return True
|
||||
return False
|
||||
|
||||
@classmethod
|
||||
def applies_to(cls, endpoint_spec: Dict[str, Any], **kwargs) -> bool:
|
||||
"""
|
||||
此测试用例仅适用于那些在请求体或参数中定义了枚举(enum)的端点。
|
||||
"""
|
||||
# 创建一个临时logger用于静态方法
|
||||
temp_logger = logging.getLogger(f"applies_to.{cls.id}")
|
||||
return cls._endpoint_has_enum_field(endpoint_spec, temp_logger)
|
||||
|
||||
def __init__(self, endpoint_spec: Dict[str, Any], global_api_spec: Dict[str, Any], json_schema_validator: Optional[Any] = None, llm_service: Optional[any] = None):
|
||||
super().__init__(endpoint_spec, global_api_spec, json_schema_validator, llm_service=llm_service)
|
||||
self.logger.setLevel(logging.INFO)
|
||||
|
||||
self.target_field_location: Optional[str] = None
|
||||
self.target_field_path: Optional[List[Union[str, int]]] = None
|
||||
self.target_field_name: Optional[str] = None
|
||||
self.valid_enums: Optional[List[str]] = None
|
||||
self.invalid_enum_value: Optional[str] = None
|
||||
|
||||
self._initialize_test_case()
|
||||
|
||||
def _find_first_enum_field(self) -> Optional[Tuple[str, List[Union[str, int]], str, List[str], str]]:
|
||||
"""
|
||||
按优先级(body -> query -> path -> header)查找第一个具有枚举值限制的字段。
|
||||
"""
|
||||
self.logger.info(f"[{self.id}] Searching for a field with enum constraints...")
|
||||
|
||||
# 1. 检查请求体 (Body)
|
||||
body_schema = self._get_resolved_request_body_schema()
|
||||
if body_schema:
|
||||
self.logger.debug(f"[{self.id}] Checking for enum field in request body.")
|
||||
result = schema_utils.util_find_enum_field_recursive(body_schema, [], self.logger)
|
||||
if result:
|
||||
path, valid_enums, desc = result
|
||||
name = path[-1] if path else "body_root"
|
||||
self.logger.info(f"[{self.id}] Found enum field in BODY at path: {'.'.join(map(str, path))}")
|
||||
return "body", path, name, valid_enums, desc
|
||||
|
||||
# 2. 检查查询参数, 路径参数, 请求头
|
||||
for location in ["query", "path", "header"]:
|
||||
if 'parameters' in self.endpoint_spec:
|
||||
self.logger.debug(f"[{self.id}] Checking for enum field in {location} parameters.")
|
||||
for param in self.endpoint_spec['parameters']:
|
||||
if param.get('in') == location:
|
||||
param_desc = param.get('description', '')
|
||||
param_name = param.get('name')
|
||||
|
||||
valid_enums = schema_utils.util_extract_enum_from_description(param_desc, self.logger)
|
||||
|
||||
if valid_enums:
|
||||
self.logger.info(f"[{self.id}] Found enum field in {location.upper()}: '{param_name}'")
|
||||
return location, [param_name], param_name, valid_enums, param_desc
|
||||
|
||||
self.logger.info(f"[{self.id}] No field with enum constraints found in any location.")
|
||||
return None
|
||||
|
||||
def _initialize_test_case(self):
|
||||
"""初始化测试用例。"""
|
||||
found_field = self._find_first_enum_field()
|
||||
|
||||
if found_field:
|
||||
location, path, name, valid_enums, description = found_field
|
||||
|
||||
self.target_field_location = location
|
||||
self.target_field_path = path
|
||||
self.target_field_name = name
|
||||
self.valid_enums = valid_enums
|
||||
|
||||
self.logger.info(
|
||||
f"[{self.id}] Target found in '{location}' at path '{'.'.join(map(str, path))}'. "
|
||||
f"Valid enums: {valid_enums}, Desc: '{description}'"
|
||||
)
|
||||
|
||||
self._set_invalid_enum_value()
|
||||
else:
|
||||
self.logger.info(f"[{self.id}] No suitable enum field found for this endpoint.")
|
||||
|
||||
def _set_invalid_enum_value(self):
|
||||
"""生成一个非法的枚举值。"""
|
||||
random_suffix = ''.join(random.choices(string.ascii_lowercase + string.digits, k=6))
|
||||
self.invalid_enum_value = f"invalid_enum_{random_suffix}"
|
||||
self.logger.info(f"[{self.id}] Setting invalid enum value to '{self.invalid_enum_value}' (valid are {self.valid_enums}).")
|
||||
|
||||
def _modify_params(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""通用函数,用于修改参数字典中的值。"""
|
||||
if self.invalid_enum_value is None or not self.target_field_path:
|
||||
return params
|
||||
|
||||
modified_params, success = schema_utils.util_set_value_at_path(
|
||||
data_container=params,
|
||||
path=self.target_field_path,
|
||||
new_value=self.invalid_enum_value
|
||||
)
|
||||
|
||||
if success:
|
||||
self.logger.debug(f"[{self.id}] Successfully injected invalid enum value into parameters.")
|
||||
return modified_params
|
||||
|
||||
self.logger.error(f"[{self.id}] Failed to set invalid enum value in parameters.")
|
||||
return params
|
||||
|
||||
def generate_query_params(self, current_query_params: Dict[str, Any]) -> Dict[str, Any]:
|
||||
if self.target_field_location == "query":
|
||||
return self._modify_params(current_query_params)
|
||||
return current_query_params
|
||||
|
||||
def generate_path_params(self, current_path_params: Dict[str, Any]) -> Dict[str, Any]:
|
||||
if self.target_field_location == "path":
|
||||
return self._modify_params(current_path_params)
|
||||
return current_path_params
|
||||
|
||||
def generate_headers(self, current_headers: Dict[str, str]) -> Dict[str, str]:
|
||||
if self.target_field_location == "header":
|
||||
return self._modify_params(current_headers)
|
||||
return current_headers
|
||||
|
||||
def generate_request_body(self, current_body: Optional[Any]) -> Optional[Any]:
|
||||
if self.target_field_location == "body":
|
||||
return self._modify_params(current_body)
|
||||
return current_body
|
||||
|
||||
def validate_response(self, response_context: APIResponseContext, request_context: APIRequestContext) -> List[ValidationResult]:
|
||||
"""验证API是否正确返回code=4006错误(状态码为200)。"""
|
||||
results = []
|
||||
|
||||
if self.invalid_enum_value is None:
|
||||
return [self.passed("跳过测试:未找到具有明确枚举值限制的字段。")]
|
||||
|
||||
expected_http_status_code = 200
|
||||
expected_business_error_code = -1
|
||||
|
||||
context_msg_prefix = (
|
||||
f"当 {self.target_field_location} 字段 '{self.target_field_name}' "
|
||||
f"值为 '{self.invalid_enum_value}' (合法值为: {self.valid_enums}) 时, "
|
||||
)
|
||||
|
||||
# 检查状态码
|
||||
if response_context.status_code != expected_http_status_code:
|
||||
return [self.failed(
|
||||
message=f"{context_msg_prefix}API应返回状态码 {expected_http_status_code},但实际为 {response_context.status_code}。",
|
||||
details={"expected_status": expected_http_status_code, "actual_status": response_context.status_code}
|
||||
)]
|
||||
|
||||
# 检查业务错误码
|
||||
json_content = response_context.json_content
|
||||
if not isinstance(json_content, dict):
|
||||
return [self.failed(f"{context_msg_prefix}API响应体不是一个有效的JSON对象。")]
|
||||
|
||||
actual_business_code = json_content.get("code")
|
||||
if actual_business_code != expected_business_error_code:
|
||||
return [self.failed(
|
||||
message=f"{context_msg_prefix}业务错误码应为 {expected_business_error_code},但实际为 {actual_business_code}。",
|
||||
details={"expected_code": expected_business_error_code, "actual_code": actual_business_code, "response_body": json_content}
|
||||
)]
|
||||
|
||||
return [self.passed(
|
||||
f"{context_msg_prefix}API正确返回了状态码 {expected_http_status_code} 和业务错误码 {expected_business_error_code}。"
|
||||
)]
|
||||
Vendored
+98
@@ -0,0 +1,98 @@
|
||||
from typing import Dict, Any, Optional, List, Union
|
||||
from ddms_compliance_suite.test_framework_core import BaseAPITestCase, TestSeverity, ValidationResult, APIRequestContext, APIResponseContext
|
||||
import logging
|
||||
from ddms_compliance_suite.utils import schema_utils # Keep this import for util_remove_value_at_path
|
||||
|
||||
class MissingRequiredFieldBodyCase(BaseAPITestCase):
|
||||
id = "TC-ERROR-4003-BODY"
|
||||
name = "缺失必填请求体字段检查"
|
||||
description = "测试当请求体中缺少API规范定义的必填字段时,API是否按预期返回code=4003的错误。"
|
||||
severity = TestSeverity.HIGH
|
||||
tags = ["error-handling", "appendix-b", "4003", "required-fields", "request-body"]
|
||||
execution_order = 210
|
||||
|
||||
@classmethod
|
||||
def applies_to(cls, endpoint_spec: Dict[str, Any], **kwargs) -> bool:
|
||||
"""
|
||||
此测试用例仅适用于那些在API规范中明确定义了请求体(requestBody)的端点。
|
||||
"""
|
||||
# 如果 'requestBody' 存在且其内容不为空,则认为此测试用例适用。
|
||||
return bool(endpoint_spec.get("requestBody"))
|
||||
|
||||
def __init__(self, endpoint_spec: Dict[str, Any], global_api_spec: Dict[str, Any], json_schema_validator: Optional[Any] = None, llm_service: Optional[Any] = None):
|
||||
super().__init__(endpoint_spec, global_api_spec, json_schema_validator, llm_service)
|
||||
self.logger = logging.getLogger(f"testcase.{self.id}") # Already set in super, but can be re-set if specific sub-logger is needed. Better to rely on super's logger.
|
||||
self.original_value_at_path: Any = None
|
||||
|
||||
# Use new helper methods from BaseAPITestCase
|
||||
body_schema = self._get_resolved_request_body_schema()
|
||||
if body_schema:
|
||||
self.removed_field_path = self._find_removable_field_path(
|
||||
schema_to_search=body_schema,
|
||||
schema_name_for_log="request body"
|
||||
)
|
||||
if not self.removed_field_path:
|
||||
self.logger.info('在请求体 schema 中未找到可用于测试 "必填字段缺失" 的字段(通过基类方法)。')
|
||||
else:
|
||||
self.logger.info('此端点规范中未定义或找到请求体 schema(通过基类方法)。')
|
||||
self.removed_field_path = None
|
||||
|
||||
def generate_query_params(self, current_query_params: Dict[str, Any]) -> Dict[str, Any]:
|
||||
self.logger.debug(f"{self.id} is focused on request body, generate_query_params will not modify query parameters.")
|
||||
return current_query_params
|
||||
|
||||
def generate_request_body(self, current_body: Optional[Any]) -> Optional[Any]:
|
||||
if not self.removed_field_path:
|
||||
self.logger.debug("No field path identified for removal in request body. Returning original body.")
|
||||
return current_body
|
||||
|
||||
modified_body, self.original_value_at_path, success = schema_utils.util_remove_value_at_path(
|
||||
data_container=current_body,
|
||||
path=self.removed_field_path
|
||||
)
|
||||
|
||||
if success:
|
||||
self.logger.info(f"为进行必填字段缺失测试,已通过工具方法从请求体中移除字段路径 '{'.'.join(map(str, self.removed_field_path))}'。")
|
||||
return modified_body
|
||||
else:
|
||||
self.logger.error(f"使用工具方法移除请求体字段路径 '{'.'.join(map(str, self.removed_field_path))}' 失败。将返回原始请求体。")
|
||||
# Restore original_value_at_path to None since removal failed
|
||||
self.original_value_at_path = None
|
||||
return current_body
|
||||
|
||||
def validate_response(self, response_context: APIResponseContext, request_context: APIRequestContext) -> List[ValidationResult]:
|
||||
"""验证API是否正确返回code=4003错误(状态码为200)。"""
|
||||
results = []
|
||||
if not self.removed_field_path:
|
||||
results.append(self.passed("跳过测试:在API规范中未找到合适的必填请求体字段用于移除测试。"))
|
||||
self.logger.info("由于未识别到可移除的必填请求体字段,跳过此测试用例的验证。")
|
||||
return results
|
||||
|
||||
expected_http_status_code = 200
|
||||
expected_business_error_code = -1
|
||||
|
||||
removed_field_str = '.'.join(map(str, self.removed_field_path))
|
||||
context_msg_prefix = f"当移除必填请求体字段 '{removed_field_str}' 时, "
|
||||
|
||||
# 检查状态码
|
||||
if response_context.status_code != expected_http_status_code:
|
||||
return [self.failed(
|
||||
message=f"{context_msg_prefix}API应返回状态码 {expected_http_status_code},但实际为 {response_context.status_code}。",
|
||||
details={"expected_status": expected_http_status_code, "actual_status": response_context.status_code}
|
||||
)]
|
||||
|
||||
# 检查业务错误码
|
||||
json_content = response_context.json_content
|
||||
if not isinstance(json_content, dict):
|
||||
return [self.failed(f"{context_msg_prefix}API响应体不是一个有效的JSON对象。")]
|
||||
|
||||
actual_business_code = json_content.get("code")
|
||||
if actual_business_code != expected_business_error_code:
|
||||
return [self.failed(
|
||||
message=f"{context_msg_prefix}业务错误码应为 {expected_business_error_code},但实际为 {actual_business_code}。",
|
||||
details={"expected_code": expected_business_error_code, "actual_code": actual_business_code, "response_body": json_content}
|
||||
)]
|
||||
|
||||
return [self.passed(
|
||||
f"{context_msg_prefix}API正确返回了状态码 {expected_http_status_code} 和业务错误码 {expected_business_error_code}。"
|
||||
)]
|
||||
Vendored
+92
@@ -0,0 +1,92 @@
|
||||
from typing import Dict, Any, Optional, List
|
||||
from ddms_compliance_suite.test_framework_core import BaseAPITestCase, TestSeverity, ValidationResult, APIRequestContext, APIResponseContext
|
||||
import copy
|
||||
|
||||
class MissingRequiredFieldQueryCase(BaseAPITestCase):
|
||||
id = "TC-ERROR-4003-QUERY"
|
||||
name = "缺失必填查询参数检查"
|
||||
description = "测试当请求中缺少API规范定义的必填查询参数时,API是否按预期返回code=4003的错误。"
|
||||
severity = TestSeverity.HIGH
|
||||
tags = ["error-handling", "appendix-b", "4003", "required-fields", "query-parameters"]
|
||||
execution_order = 211 # After body, before original combined one might have been
|
||||
|
||||
@classmethod
|
||||
def applies_to(cls, endpoint_spec: Dict[str, Any], **kwargs) -> bool:
|
||||
"""
|
||||
此测试用例仅适用于那些在API规范中定义了查询参数(parameters with 'in' == 'query')的端点。
|
||||
"""
|
||||
# 如果 'parameters' 字段不存在,则不适用
|
||||
if not endpoint_spec.get("parameters"):
|
||||
return False
|
||||
|
||||
# 遍历所有参数,如果发现任何一个参数的 "in" 字段是 "query",则此测试用例适用。
|
||||
return any(param.get("in") == "query" for param in endpoint_spec["parameters"])
|
||||
|
||||
def __init__(self, endpoint_spec: Dict[str, Any], global_api_spec: Dict[str, Any], json_schema_validator: Optional[Any] = None, llm_service: Optional[Any] = None):
|
||||
super().__init__(endpoint_spec, global_api_spec, json_schema_validator, llm_service=llm_service)
|
||||
self.target_param_name: Optional[str] = None
|
||||
# Location is always 'query' for this class
|
||||
self.target_param_location: str = "query"
|
||||
self.original_query_params: Optional[Dict[str, Any]] = None
|
||||
|
||||
# Call the simplified method to find the target parameter
|
||||
self._try_find_removable_query_param()
|
||||
self.logger.info(f"测试用例 {self.id} ({self.name}) 已针对端点 '{self.endpoint_spec.get('method')} {self.endpoint_spec.get('path')}' 初始化。Target param to remove: {self.target_param_name}")
|
||||
|
||||
def _try_find_removable_query_param(self):
|
||||
"""Uses the base class helper to find a required query parameter."""
|
||||
self.target_param_name = self._find_required_parameter_name("query")
|
||||
# Logging about success/failure is handled by the base class method and the __init__ method.
|
||||
|
||||
def generate_request_body(self, current_body: Optional[Any]) -> Optional[Any]:
|
||||
# This test case focuses on query parameters, so it does not modify the request body.
|
||||
self.logger.debug(f"{self.id} is focused on query parameters, generate_request_body will not modify the request body.")
|
||||
return current_body
|
||||
|
||||
def generate_query_params(self, current_query_params: Dict[str, Any]) -> Dict[str, Any]:
|
||||
if self.target_param_name and isinstance(current_query_params, dict):
|
||||
if self.target_param_name in current_query_params:
|
||||
new_params = copy.deepcopy(current_query_params)
|
||||
original_value = new_params.pop(self.target_param_name) # 移除参数
|
||||
self.logger.info(f"为进行必填查询参数缺失测试,已从查询参数中移除 '{self.target_param_name}' (原值: '{original_value}')。")
|
||||
return new_params
|
||||
else:
|
||||
self.logger.warning(f"计划移除的查询参数 '{self.target_param_name}' 在当前查询参数中未找到。")
|
||||
return current_query_params
|
||||
|
||||
def validate_response(self, response_context: APIResponseContext, request_context: APIRequestContext) -> List[ValidationResult]:
|
||||
"""验证API是否正确返回code=4003错误(状态码为200)。"""
|
||||
results = []
|
||||
|
||||
if not self.target_param_name:
|
||||
results.append(self.passed("跳过测试:在API规范中未找到合适的必填查询参数用于移除测试。"))
|
||||
self.logger.info("由于未识别到可移除的必填查询参数,跳过此测试用例。")
|
||||
return results
|
||||
|
||||
expected_http_status_code = 200
|
||||
expected_business_error_code = -1
|
||||
|
||||
context_msg_prefix = f"当移除必填查询参数 '{self.target_param_name}' 时, "
|
||||
|
||||
# 检查状态码
|
||||
if response_context.status_code != expected_http_status_code:
|
||||
return [self.failed(
|
||||
message=f"{context_msg_prefix}API应返回状态码 {expected_http_status_code},但实际为 {response_context.status_code}。",
|
||||
details={"expected_status": expected_http_status_code, "actual_status": response_context.status_code}
|
||||
)]
|
||||
|
||||
# 检查业务错误码
|
||||
json_content = response_context.json_content
|
||||
if not isinstance(json_content, dict):
|
||||
return [self.failed(f"{context_msg_prefix}API响应体不是一个有效的JSON对象。")]
|
||||
|
||||
actual_business_code = json_content.get("code")
|
||||
if actual_business_code != expected_business_error_code:
|
||||
return [self.failed(
|
||||
message=f"{context_msg_prefix}业务错误码应为 {expected_business_error_code},但实际为 {actual_business_code}。",
|
||||
details={"expected_code": expected_business_error_code, "actual_code": actual_business_code, "response_body": json_content}
|
||||
)]
|
||||
|
||||
return [self.passed(
|
||||
f"{context_msg_prefix}API正确返回了状态码 {expected_http_status_code} 和业务错误码 {expected_business_error_code}。"
|
||||
)]
|
||||
+227
@@ -0,0 +1,227 @@
|
||||
from typing import Dict, Any, Optional, List, Union, Tuple
|
||||
from ddms_compliance_suite.test_framework_core import BaseAPITestCase, TestSeverity, ValidationResult, APIRequestContext, APIResponseContext
|
||||
import logging
|
||||
from ddms_compliance_suite.utils import schema_utils
|
||||
|
||||
class NumberOutOfRangeCase(BaseAPITestCase):
|
||||
id = "TC-ERROR-4002"
|
||||
name = "数值参数越界检查"
|
||||
description = "测试当发送的数值参数超出范围限制时,API是否按预期返回code=4002的错误。"
|
||||
severity = TestSeverity.MEDIUM
|
||||
tags = ["error-handling", "appendix-b", "4002", "out-of-range"]
|
||||
execution_order = 203 # 在类型不匹配测试之后执行
|
||||
|
||||
@staticmethod
|
||||
def _endpoint_has_ranged_field(endpoint_spec: Dict[str, Any], logger: logging.Logger) -> bool:
|
||||
"""
|
||||
静态辅助方法,检查端点规范中是否有任何字段(body或parameters)包含范围定义。
|
||||
"""
|
||||
# 1. 检查请求体 (Body)
|
||||
if endpoint_spec.get("requestBody"):
|
||||
if schema_utils.util_find_ranged_field_recursive(endpoint_spec["requestBody"].get("content", {}).get("application/json", {}).get("schema", {}), [], logger):
|
||||
return True
|
||||
|
||||
# 2. 检查查询参数, 路径参数, 请求头
|
||||
if 'parameters' in endpoint_spec:
|
||||
for param in endpoint_spec['parameters']:
|
||||
param_schema = param.get('schema', {})
|
||||
param_desc = param.get('description', '')
|
||||
|
||||
min_val, max_val = schema_utils.util_extract_range_from_description(param_desc, logger)
|
||||
|
||||
if min_val is None and max_val is None:
|
||||
min_val = param_schema.get('minimum')
|
||||
max_val = param_schema.get('maximum')
|
||||
|
||||
if min_val is not None or max_val is not None:
|
||||
return True
|
||||
return False
|
||||
|
||||
@classmethod
|
||||
def applies_to(cls, endpoint_spec: Dict[str, Any], **kwargs) -> bool:
|
||||
"""
|
||||
此测试用例仅适用于那些在请求体或参数中定义了数值范围的端点。
|
||||
"""
|
||||
temp_logger = logging.getLogger(f"applies_to.{cls.id}")
|
||||
return cls._endpoint_has_ranged_field(endpoint_spec, temp_logger)
|
||||
|
||||
def __init__(self, endpoint_spec: Dict[str, Any], global_api_spec: Dict[str, Any], json_schema_validator: Optional[Any] = None, llm_service: Optional[Any] = None):
|
||||
super().__init__(endpoint_spec, global_api_spec, json_schema_validator, llm_service=llm_service)
|
||||
self.logger.setLevel(logging.DEBUG)
|
||||
|
||||
self.target_field_location: Optional[str] = None
|
||||
self.target_field_path: Optional[List[Union[str, int]]] = None
|
||||
self.target_field_schema: Optional[Dict[str, Any]] = None
|
||||
self.target_field_name: Optional[str] = None
|
||||
self.target_field_type: Optional[str] = None
|
||||
self.min_value: Optional[float] = None
|
||||
self.max_value: Optional[float] = None
|
||||
self.out_of_range_value: Optional[Union[int, float]] = None
|
||||
|
||||
self._initialize_test_case()
|
||||
|
||||
def _find_first_ranged_numeric_field(self) -> Optional[Tuple[str, List[Union[str, int]], Dict[str, Any], str, Optional[str], Optional[float], Optional[float], str]]:
|
||||
"""
|
||||
按优先级(body -> query -> path -> header)查找第一个具有范围限制的数值字段。
|
||||
"""
|
||||
self.logger.info(f"[{self.id}] Searching for a ranged numeric field...")
|
||||
|
||||
# 1. 检查请求体 (Body)
|
||||
body_schema = self._get_resolved_request_body_schema()
|
||||
if body_schema:
|
||||
self.logger.debug(f"[{self.id}] Checking for ranged numeric field in request body.")
|
||||
result = schema_utils.util_find_ranged_field_recursive(body_schema, [], self.logger)
|
||||
if result:
|
||||
path, schema, type, min_val, max_val, desc = result
|
||||
name = path[-1] if path else "body_root"
|
||||
self.logger.info(f"[{self.id}] Found ranged numeric field in BODY at path: {'.'.join(map(str, path))}")
|
||||
return "body", path, schema, name, type, min_val, max_val, desc
|
||||
|
||||
# 2. 检查查询参数, 路径参数, 请求头
|
||||
for location in ["query", "path", "header"]:
|
||||
if 'parameters' in self.endpoint_spec:
|
||||
self.logger.debug(f"[{self.id}] Checking for ranged numeric field in {location} parameters.")
|
||||
for param in self.endpoint_spec['parameters']:
|
||||
if param.get('in') == location:
|
||||
param_schema = param.get('schema', {})
|
||||
param_desc = param.get('description', '')
|
||||
param_name = param.get('name')
|
||||
|
||||
min_val, max_val = schema_utils.util_extract_range_from_description(param_desc, self.logger)
|
||||
|
||||
if min_val is None and max_val is None:
|
||||
min_val = param_schema.get('minimum')
|
||||
max_val = param_schema.get('maximum')
|
||||
|
||||
if min_val is not None or max_val is not None:
|
||||
param_type = param_schema.get('type')
|
||||
self.logger.info(f"[{self.id}] Found ranged numeric field in {location.upper()}: '{param_name}'")
|
||||
return location, [param_name], param_schema, param_name, param_type, min_val, max_val, param_desc
|
||||
|
||||
self.logger.info(f"[{self.id}] No ranged numeric field found in any location.")
|
||||
return None
|
||||
|
||||
def _initialize_test_case(self):
|
||||
"""使用框架辅助方法初始化测试用例。"""
|
||||
found_field = self._find_first_ranged_numeric_field()
|
||||
|
||||
if found_field:
|
||||
location, path, schema, name, field_type, min_val, max_val, description = found_field
|
||||
|
||||
self.target_field_location = location
|
||||
self.target_field_path = path
|
||||
self.target_field_schema = schema
|
||||
self.target_field_name = name
|
||||
# 如果'type'未定义,我们根据是否存在小数来推断
|
||||
self.target_field_type = field_type if field_type else ('number' if isinstance(min_val, float) or isinstance(max_val, float) else 'integer')
|
||||
self.min_value = min_val
|
||||
self.max_value = max_val
|
||||
|
||||
self.logger.info(
|
||||
f"[{self.id}] Target found in '{location}' at path '{'.'.join(map(str, path))}'. "
|
||||
f"Type: {self.target_field_type}, Min: {min_val}, Max: {max_val}, Desc: '{description}'"
|
||||
)
|
||||
|
||||
self._set_out_of_range_value()
|
||||
else:
|
||||
self.logger.info(f"[{self.id}] No suitable ranged numeric field found for this endpoint.")
|
||||
|
||||
def _set_out_of_range_value(self):
|
||||
"""根据提取的范围信息设置越界值。"""
|
||||
# 优先选择超过最大值
|
||||
if self.max_value is not None:
|
||||
if self.target_field_type == 'integer':
|
||||
self.out_of_range_value = int(self.max_value) + 1
|
||||
else:
|
||||
self.out_of_range_value = self.max_value + 1.0
|
||||
self.logger.info(f"[{self.id}] Setting out-of-range value to {self.out_of_range_value} (max is {self.max_value}).")
|
||||
return
|
||||
|
||||
# 其次选择小于最小值
|
||||
if self.min_value is not None:
|
||||
if self.target_field_type == 'integer':
|
||||
self.out_of_range_value = int(self.min_value) - 1
|
||||
else:
|
||||
self.out_of_range_value = self.min_value - 1.0
|
||||
self.logger.info(f"[{self.id}] Setting out-of-range value to {self.out_of_range_value} (min is {self.min_value}).")
|
||||
|
||||
def _modify_params(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""通用函数,用于修改参数字典中的值。"""
|
||||
if self.out_of_range_value is None or not self.target_field_path:
|
||||
return params
|
||||
|
||||
modified_params, success = schema_utils.util_set_value_at_path(
|
||||
data_container=params,
|
||||
path=self.target_field_path,
|
||||
new_value=self.out_of_range_value
|
||||
)
|
||||
|
||||
if success:
|
||||
self.logger.debug(f"[{self.id}] Successfully injected out-of-range value into parameters.")
|
||||
return modified_params
|
||||
|
||||
self.logger.error(f"[{self.id}] Failed to set out-of-range value in parameters.")
|
||||
return params
|
||||
|
||||
def generate_query_params(self, current_query_params: Dict[str, Any]) -> Dict[str, Any]:
|
||||
if self.target_field_location == "query":
|
||||
return self._modify_params(current_query_params)
|
||||
return current_query_params
|
||||
|
||||
def generate_path_params(self, current_path_params: Dict[str, Any]) -> Dict[str, Any]:
|
||||
if self.target_field_location == "path":
|
||||
return self._modify_params(current_path_params)
|
||||
return current_path_params
|
||||
|
||||
def generate_headers(self, current_headers: Dict[str, str]) -> Dict[str, str]:
|
||||
if self.target_field_location == "header":
|
||||
# Header values must be strings
|
||||
original_value = self.out_of_range_value
|
||||
self.out_of_range_value = str(self.out_of_range_value)
|
||||
headers = self._modify_params(current_headers)
|
||||
self.out_of_range_value = original_value # revert for logging
|
||||
return headers
|
||||
return current_headers
|
||||
|
||||
def generate_request_body(self, current_body: Optional[Any]) -> Optional[Any]:
|
||||
if self.target_field_location == "body":
|
||||
return self._modify_params(current_body)
|
||||
return current_body
|
||||
|
||||
def validate_response(self, response_context: APIResponseContext, request_context: APIRequestContext) -> List[ValidationResult]:
|
||||
"""验证API是否正确返回code=4002错误(状态码为200)。"""
|
||||
results = []
|
||||
|
||||
if self.out_of_range_value is None:
|
||||
return [self.passed("跳过测试:未找到具有明确范围限制的数值字段。")]
|
||||
|
||||
expected_http_status_code = 200
|
||||
expected_business_error_code = -1
|
||||
|
||||
context_msg_prefix = (
|
||||
f"当 {self.target_field_location} 字段 '{self.target_field_name}' "
|
||||
f"值为 {self.out_of_range_value} (超出范围: min={self.min_value}, max={self.max_value}) 时, "
|
||||
)
|
||||
|
||||
# 检查状态码
|
||||
if response_context.status_code != expected_http_status_code:
|
||||
return [self.failed(
|
||||
message=f"{context_msg_prefix}API应返回状态码 {expected_http_status_code},但实际为 {response_context.status_code}。",
|
||||
details={"expected_status": expected_http_status_code, "actual_status": response_context.status_code}
|
||||
)]
|
||||
|
||||
# 检查业务错误码
|
||||
json_content = response_context.json_content
|
||||
if not isinstance(json_content, dict):
|
||||
return [self.failed(f"{context_msg_prefix}API响应体不是一个有效的JSON对象。")]
|
||||
|
||||
actual_business_code = json_content.get("code")
|
||||
if actual_business_code != expected_business_error_code:
|
||||
return [self.failed(
|
||||
message=f"{context_msg_prefix}业务错误码应为 {expected_business_error_code},但实际为 {actual_business_code}。",
|
||||
details={"expected_code": expected_business_error_code, "actual_code": actual_business_code, "response_body": json_content}
|
||||
)]
|
||||
|
||||
return [self.passed(
|
||||
f"{context_msg_prefix}API正确返回了状态码 {expected_http_status_code} 和业务错误码 {expected_business_error_code}。"
|
||||
)]
|
||||
+125
@@ -0,0 +1,125 @@
|
||||
from typing import Dict, Any, Optional, List, Union
|
||||
from ddms_compliance_suite.test_framework_core import BaseAPITestCase, TestSeverity, ValidationResult, APIRequestContext, APIResponseContext
|
||||
import copy
|
||||
import logging
|
||||
from ddms_compliance_suite.utils import schema_utils
|
||||
|
||||
class TypeMismatchBodyCase(BaseAPITestCase):
|
||||
id = "TC-ERROR-4001-BODY"
|
||||
name = "请求体字段类型不匹配检查"
|
||||
description = "测试当发送的请求体中字段的数据类型与API规范定义不符时,API是否按预期返回code=4001的错误。"
|
||||
severity = TestSeverity.MEDIUM
|
||||
tags = ["error-handling", "appendix-b", "4001", "request-body"]
|
||||
execution_order = 202 # Slightly after query param one
|
||||
|
||||
@classmethod
|
||||
def applies_to(cls, endpoint_spec: Dict[str, Any], **kwargs) -> bool:
|
||||
"""
|
||||
此测试用例仅适用于那些在API规范中明确定义了请求体(requestBody)的端点。
|
||||
"""
|
||||
# 如果 'requestBody' 存在且其内容不为空,则认为此测试用例适用。
|
||||
return bool(endpoint_spec.get("requestBody"))
|
||||
|
||||
def __init__(self, endpoint_spec: Dict[str, Any], global_api_spec: Dict[str, Any], json_schema_validator: Optional[Any] = None, llm_service: Optional[Any] = None):
|
||||
super().__init__(endpoint_spec, global_api_spec, json_schema_validator, llm_service=llm_service)
|
||||
self.logger.setLevel(logging.DEBUG)
|
||||
self.target_field_path: Optional[List[str]] = None
|
||||
self.original_field_type: Optional[str] = None
|
||||
# Location is always 'body' for this class
|
||||
self.target_field_location: str = "body"
|
||||
self.target_field_schema: Optional[Dict[str, Any]] = None
|
||||
self.json_schema_validator = json_schema_validator
|
||||
self.original_value_at_path: Any = None
|
||||
self.mismatched_value: Any = None
|
||||
self._try_find_mismatch_target_in_body()
|
||||
|
||||
def _try_find_mismatch_target_in_body(self):
|
||||
self.logger.info(f"[{self.id}] Initializing: Looking for a simple type field in request body for type mismatch test.")
|
||||
|
||||
body_schema_to_check = self._get_resolved_request_body_schema()
|
||||
|
||||
if body_schema_to_check:
|
||||
found_target = self._find_simple_type_field_in_schema(body_schema_to_check, "request body")
|
||||
if found_target:
|
||||
self.target_field_path, self.original_field_type, self.target_field_schema = found_target
|
||||
self.logger.info(f"[{self.id}] Target field for type mismatch (body): {'.'.join(map(str, self.target_field_path))}, Original Type: {self.original_field_type}")
|
||||
else:
|
||||
self.logger.info(f"[{self.id}] No suitable simple type field found in request body schema for type mismatch test.")
|
||||
else:
|
||||
self.logger.info(f"[{self.id}] No request body schema found for endpoint. Skipping type mismatch target search.")
|
||||
|
||||
if not self.target_field_path:
|
||||
self.logger.info(f"[{self.id}] Conclusion: No target field identified for request body type mismatch test.")
|
||||
|
||||
def generate_query_params(self, current_query_params: Dict[str, Any]) -> Dict[str, Any]:
|
||||
self.logger.debug(f"{self.id} is focused on request body, generate_query_params will not modify query parameters.")
|
||||
return current_query_params
|
||||
|
||||
def generate_request_body(self, current_body: Optional[Any]) -> Optional[Any]:
|
||||
if not self.target_field_path or not self.original_field_type:
|
||||
self.logger.info(f"[{self.id}] No target field or original type identified for body type mismatch. Skipping body modification.")
|
||||
return current_body
|
||||
|
||||
self.logger.debug(f"[{self.id}] Preparing to modify request body for type mismatch. Target path: {self.target_field_path}, Original type: {self.original_field_type}")
|
||||
|
||||
# Get the original value at path for logging/context if needed (optional)
|
||||
# current_val_at_path, _ = schema_utils.util_get_value_at_path(current_body, self.target_field_path) # Assuming a get_value_at_path util exists or is added
|
||||
# For now, we don't strictly need original_value for generate_mismatched_value, but it takes it as an arg.
|
||||
|
||||
mismatched_value = schema_utils.generate_mismatched_value(
|
||||
original_type=self.original_field_type,
|
||||
original_value=None, # Placeholder, as current generate_mismatched_value doesn't use it heavily yet
|
||||
field_schema=self.target_field_schema,
|
||||
logger_param=self.logger
|
||||
)
|
||||
|
||||
self.logger.info(f"[{self.id}] Generated mismatched value '{mismatched_value}' for original type '{self.original_field_type}' at path '{'.'.join(map(str, self.target_field_path))}'.")
|
||||
|
||||
modified_body, success = schema_utils.util_set_value_at_path(
|
||||
data_container=current_body,
|
||||
path=self.target_field_path,
|
||||
new_value=mismatched_value
|
||||
)
|
||||
|
||||
if success:
|
||||
self.logger.debug(f"[{self.id}] Successfully set mismatched value at path using util_set_value_at_path.")
|
||||
return modified_body
|
||||
else:
|
||||
self.logger.error(f"[{self.id}] Failed to set mismatched value at path using util_set_value_at_path. Returning original body.")
|
||||
return current_body
|
||||
|
||||
def validate_response(self, response_context: APIResponseContext, request_context: APIRequestContext) -> List[ValidationResult]:
|
||||
"""验证API是否正确返回code=4001错误(状态码为200)。"""
|
||||
results = []
|
||||
if not self.target_field_path:
|
||||
self.logger.info(f"[{self.id}] Skipped type mismatch (body) validation: No target field was identified.")
|
||||
return [self.passed("跳过测试:在请求体中未找到合适的字段来测试类型不匹配。")]
|
||||
|
||||
expected_http_status_code = 200
|
||||
expected_business_error_code = -1
|
||||
|
||||
field_path_str = '.'.join(map(str, self.target_field_path))
|
||||
context_msg_prefix = f"当请求体字段 '{field_path_str}' 类型不匹配时, "
|
||||
|
||||
# 检查状态码
|
||||
if response_context.status_code != expected_http_status_code:
|
||||
return [self.failed(
|
||||
message=f"{context_msg_prefix}API应返回状态码 {expected_http_status_code},但实际为 {response_context.status_code}。",
|
||||
details={"expected_status": expected_http_status_code, "actual_status": response_context.status_code}
|
||||
)]
|
||||
|
||||
# 检查业务错误码
|
||||
json_content = response_context.json_content
|
||||
if not isinstance(json_content, dict):
|
||||
return [self.failed(f"{context_msg_prefix}API响应体不是一个有效的JSON对象。")]
|
||||
|
||||
actual_business_code = json_content.get("code")
|
||||
if actual_business_code != expected_business_error_code:
|
||||
return [self.failed(
|
||||
message=f"{context_msg_prefix}业务错误码应为 {expected_business_error_code},但实际为 {actual_business_code}。",
|
||||
details={"expected_code": expected_business_error_code, "actual_code": actual_business_code, "response_body": json_content}
|
||||
)]
|
||||
|
||||
return [self.passed(
|
||||
f"{context_msg_prefix}API正确返回了状态码 {expected_http_status_code} 和业务错误码 {expected_business_error_code}。"
|
||||
)]
|
||||
Vendored
+132
@@ -0,0 +1,132 @@
|
||||
from typing import Dict, Any, Optional, List, Tuple, Union
|
||||
from ddms_compliance_suite.test_framework_core import BaseAPITestCase, TestSeverity, ValidationResult, APIRequestContext, APIResponseContext
|
||||
import copy
|
||||
import logging
|
||||
from ddms_compliance_suite.utils import schema_utils
|
||||
|
||||
class TypeMismatchQueryParamCase(BaseAPITestCase):
|
||||
id = "TC-ERROR-4001-QUERY"
|
||||
name = "查询参数类型不匹配检查"
|
||||
description = "测试当发送的查询参数数据类型与API规范定义不符时,API是否按预期返回code=4001的错误。"
|
||||
severity = TestSeverity.MEDIUM
|
||||
tags = ["error-handling", "appendix-b", "4001", "query-parameters"]
|
||||
execution_order = 201 # Slightly after the combined one might have been
|
||||
|
||||
@classmethod
|
||||
def applies_to(cls, endpoint_spec: Dict[str, Any], **kwargs) -> bool:
|
||||
"""
|
||||
此测试用例仅适用于那些在API规范中定义了查询参数(parameters with 'in' == 'query')的端点。
|
||||
"""
|
||||
# 如果 'parameters' 字段不存在,则不适用
|
||||
if not endpoint_spec.get("parameters"):
|
||||
return False
|
||||
|
||||
# 遍历所有参数,如果发现任何一个参数的 "in" 字段是 "query",则此测试用例适用。
|
||||
return any(param.get("in") == "query" for param in endpoint_spec["parameters"])
|
||||
|
||||
def __init__(self, endpoint_spec: Dict[str, Any], global_api_spec: Dict[str, Any], json_schema_validator: Optional[Any] = None, llm_service: Optional[Any] = None):
|
||||
super().__init__(endpoint_spec, global_api_spec, json_schema_validator, llm_service=llm_service)
|
||||
self.logger.setLevel(logging.DEBUG)
|
||||
self.target_field_path: Optional[List[str]] = None
|
||||
self.original_field_type: Optional[str] = None
|
||||
# Location is always 'query' for this class
|
||||
self.target_field_location: str = "query"
|
||||
self.target_field_schema: Optional[Dict[str, Any]] = None
|
||||
self.target_param_name: Optional[str] = None
|
||||
self.json_schema_validator = json_schema_validator
|
||||
self.original_value_at_path: Any = None
|
||||
self.mismatched_value: Any = None
|
||||
|
||||
# 调用新方法来查找目标字段
|
||||
self._try_find_mismatch_target_in_query()
|
||||
|
||||
def _try_find_mismatch_target_in_query(self):
|
||||
self.logger.info(f"[{self.id}] Initializing: Looking for a simple type query parameter for type mismatch test.")
|
||||
|
||||
found_target_param = self._find_first_simple_type_parameter(param_location="query")
|
||||
|
||||
if found_target_param:
|
||||
full_path, param_type, param_schema, top_level_param_name = found_target_param
|
||||
self.target_field_path = full_path
|
||||
self.original_field_type = param_type
|
||||
self.target_field_schema = param_schema
|
||||
self.target_param_name = top_level_param_name # Store the top-level parameter name
|
||||
self.logger.info(f"[{self.id}] Target for type mismatch (query): Param='{self.target_param_name}', Path='{'.'.join(map(str,self.target_field_path))}', Type='{self.original_field_type}'")
|
||||
else:
|
||||
self.logger.info(f"[{self.id}] No suitable simple type query parameter found for type mismatch test.")
|
||||
|
||||
def generate_request_body(self, current_body: Optional[Any]) -> Optional[Any]:
|
||||
self.logger.debug(f"{self.id} is focused on query parameters, generate_request_body will not modify the body.")
|
||||
return current_body
|
||||
|
||||
def generate_query_params(self, current_query_params: Dict[str, Any]) -> Dict[str, Any]:
|
||||
if not self.target_field_path or not self.original_field_type:
|
||||
self.logger.info(f"[{self.id}] No target field or original type identified for query param type mismatch. Skipping query param modification.")
|
||||
return current_query_params
|
||||
|
||||
self.logger.debug(f"[{self.id}] Preparing to modify query params for type mismatch. Target path: {self.target_field_path}, Original type: {self.original_field_type}")
|
||||
|
||||
mismatched_value = schema_utils.generate_mismatched_value(
|
||||
original_type=self.original_field_type,
|
||||
original_value=None, # Placeholder
|
||||
field_schema=self.target_field_schema,
|
||||
logger_param=self.logger
|
||||
)
|
||||
|
||||
self.logger.info(f"[{self.id}] Generated mismatched value '{mismatched_value}' for original type '{self.original_field_type}' at query path '{'.'.join(map(str, self.target_field_path))}'.")
|
||||
|
||||
# Query parameters are typically a flat dictionary, but util_set_value_at_path can handle nested paths if needed (e.g. for object-style query params)
|
||||
modified_params, success = schema_utils.util_set_value_at_path(
|
||||
data_container=current_query_params,
|
||||
path=self.target_field_path, # Path might be like ['paramName'] or ['paramName', 'nestedKey']
|
||||
new_value=mismatched_value
|
||||
)
|
||||
|
||||
if success:
|
||||
self.logger.debug(f"[{self.id}] Successfully set mismatched value in query params using util_set_value_at_path.")
|
||||
return modified_params
|
||||
else:
|
||||
self.logger.error(f"[{self.id}] Failed to set mismatched value in query params using util_set_value_at_path. Returning original params.")
|
||||
return current_query_params
|
||||
|
||||
def validate_response(self, response_context: APIResponseContext, request_context: APIRequestContext) -> List[ValidationResult]:
|
||||
"""验证API是否正确返回code=4001错误(状态码为200)。"""
|
||||
results = []
|
||||
if not self.target_field_path:
|
||||
self.logger.info(f"[{self.id}] Skipped type mismatch (query) validation: No target query parameter was identified.")
|
||||
return [self.passed("跳过测试:在查询参数中未找到合适的字段来测试类型不匹配。")]
|
||||
|
||||
expected_http_status_code = 200
|
||||
expected_business_error_code = -1
|
||||
|
||||
context_param_identifier = self.target_param_name or '.'.join(map(str, self.target_field_path))
|
||||
context_msg_prefix = f"当查询参数 '{context_param_identifier}' (路径: '{'.'.join(map(str, self.target_field_path))}') 类型不匹配时, "
|
||||
|
||||
# 检查状态码
|
||||
if response_context.status_code != expected_http_status_code:
|
||||
return [self.failed(
|
||||
message=f"{context_msg_prefix}API应返回状态码 {expected_http_status_code},但实际为 {response_context.status_code}。",
|
||||
details={"expected_status": expected_http_status_code, "actual_status": response_context.status_code}
|
||||
)]
|
||||
|
||||
# 检查业务错误码
|
||||
json_content = response_context.json_content
|
||||
if not isinstance(json_content, dict):
|
||||
return [self.failed(f"{context_msg_prefix}API响应体不是一个有效的JSON对象。")]
|
||||
|
||||
actual_business_code = json_content.get("code")
|
||||
if actual_business_code != expected_business_error_code:
|
||||
return [self.failed(
|
||||
message=f"{context_msg_prefix}业务错误码应为 {expected_business_error_code},但实际为 {actual_business_code}。",
|
||||
details={"expected_code": expected_business_error_code, "actual_code": actual_business_code, "response_body": json_content}
|
||||
)]
|
||||
|
||||
return [self.passed(
|
||||
f"{context_msg_prefix}API正确返回了状态码 {expected_http_status_code} 和业务错误码 {expected_business_error_code}。"
|
||||
)]
|
||||
|
||||
def generate_path_params(self, current_path_params: Dict[str, Any]) -> Dict[str, Any]:
|
||||
# ... existing code ...
|
||||
pass
|
||||
|
||||
return current_path_params
|
||||
@@ -0,0 +1 @@
|
||||
# This file marks the normative_spec directory as a Python package.
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
+67
@@ -0,0 +1,67 @@
|
||||
from typing import Dict, Any, Optional, List
|
||||
from ddms_compliance_suite.test_framework_core import BaseAPITestCase, TestSeverity, ValidationResult, APIRequestContext, APIResponseContext
|
||||
|
||||
# class HTTPMethodUsageCase(BaseAPITestCase):
|
||||
# id = "TC-NORMATIVE-001"
|
||||
# name = "HTTP Method Usage Verification"
|
||||
# description = "验证API是否恰当使用HTTP方法(例如,GET用于检索,POST用于创建)。目前不测试对不支持方法的405响应。"
|
||||
# severity = TestSeverity.MEDIUM
|
||||
# tags = ["normative-spec", "http", "restful"]
|
||||
# execution_order = 110
|
||||
|
||||
# # 此测试通常适用。
|
||||
# # 检查对不支持方法的405响应会比较复杂,需要知道哪些方法对于每个路径是明确不支持的,
|
||||
# # 或者尝试所有其他方法,这在API规范中并不总是明确的。
|
||||
|
||||
# def __init__(self, endpoint_spec: Dict[str, Any], global_api_spec: Dict[str, Any], json_schema_validator: Optional[Any] = None):
|
||||
# super().__init__(endpoint_spec, global_api_spec, json_schema_validator)
|
||||
# self.logger.info(f"测试用例 '{self.id}' 已为端点 '{self.endpoint_spec.get('method')} {self.endpoint_spec.get('path')}' 初始化。")
|
||||
|
||||
# def validate_response(self, response_context: APIResponseContext, request_context: APIRequestContext) -> List[ValidationResult]:
|
||||
# results = []
|
||||
# method = request_context.method.upper()
|
||||
# status_code = response_context.status_code
|
||||
|
||||
# # 基于常见RESTful约定的基本检查
|
||||
# # 这些是通用指南,可能需要根据具体的API设计进行调整。
|
||||
|
||||
# if method == "GET":
|
||||
# if status_code // 100 == 2: # 成功的GET
|
||||
# results.append(self.passed(f"GET请求 {request_context.url} 返回了成功的状态码 {status_code}。"))
|
||||
# elif status_code == 404:
|
||||
# results.append(self.passed(f"GET请求 {request_context.url} 返回404,如果资源不存在则这是有效的响应。"))
|
||||
# # GET请求的其他状态码可能是错误或此处未覆盖的特定条件。
|
||||
|
||||
# elif method == "POST":
|
||||
# if status_code == 201: # 已创建
|
||||
# results.append(self.passed(f"POST请求 {request_context.url} 返回201 Created,符合资源创建的预期。"))
|
||||
# elif status_code == 200 or status_code == 202: # OK或已接受(例如,用于异步任务)
|
||||
# results.append(self.passed(f"POST请求 {request_context.url} 返回{status_code},这可以是有效的响应。"))
|
||||
# # 可添加对400(错误请求,例如payload无效)等的检查。
|
||||
|
||||
# elif method == "PUT":
|
||||
# if status_code == 200: # OK(已更新)
|
||||
# results.append(self.passed(f"PUT请求 {request_context.url} 返回200 OK,符合资源更新的预期。"))
|
||||
# elif status_code == 201: # 已创建(如果PUT在资源不存在时创建资源)
|
||||
# results.append(self.passed(f"PUT请求 {request_context.url} 返回201 Created,这可以是有效的响应。"))
|
||||
# elif status_code == 204: # 无内容(已更新,不返回响应体)
|
||||
# results.append(self.passed(f"PUT请求 {request_context.url} 返回204 No Content,这可以是有效的响应。"))
|
||||
# # 可添加对404(未找到,如果要更新的资源不存在,除非PUT会创建)的检查。
|
||||
|
||||
# elif method == "DELETE":
|
||||
# if status_code == 200 or status_code == 202 or status_code == 204: # OK、已接受或无内容
|
||||
# results.append(self.passed(f"DELETE请求 {request_context.url} 返回{status_code},表示成功删除。"))
|
||||
# # 可添加对404(未找到,如果要删除的资源不存在)的检查。
|
||||
|
||||
# # 405(方法不允许)检查的占位符 - 这比较复杂
|
||||
# # 要测试405,通常需要:
|
||||
# # 1. 知道哪些方法是此路径明确不允许的。
|
||||
# # 2. 或者,尝试使用其他常见方法(OPTIONS, PATCH等)发送请求,
|
||||
# # 如果这些方法未在此路径的规范中定义,则期望405。
|
||||
# # 这通常需要更具针对性的测试用例或不同的方法。
|
||||
# # self.logger.info("此通用测试用例未实现405(方法不允许)检查。")
|
||||
|
||||
# if not results: # 如果没有为该方法触发特定的验证
|
||||
# results.append(self.passed(f"针对 {method} {request_context.url} 的HTTP方法使用检查完成(基于通用约定未发现特定问题)。"))
|
||||
|
||||
# return results
|
||||
+247
@@ -0,0 +1,247 @@
|
||||
# import re
|
||||
# import json # 确保导入json
|
||||
# from typing import Dict, Any, Optional, List
|
||||
# from ddms_compliance_suite.test_framework_core import BaseAPITestCase, TestSeverity, ValidationResult, APIRequestContext
|
||||
# # LLMService的导入路径需要根据您的项目结构确认
|
||||
# # 假设 LLMService 在 ddms_compliance_suite.llm_utils.llm_service
|
||||
# try:
|
||||
# from ddms_compliance_suite.llm_utils.llm_service import LLMService
|
||||
# except ImportError:
|
||||
# LLMService = None
|
||||
# # print("LLMService not found, PathVerbNounCheckCase will be skipped or limited.")
|
||||
|
||||
# class ComprehensiveURLCheckLLMCase(BaseAPITestCase):
|
||||
# id = "TC-NORMATIVE-URL-LLM-COMPREHENSIVE-001"
|
||||
# name = "综合URL规范与RESTful风格检查 (LLM)"
|
||||
# description = (
|
||||
# "使用LLM统一评估API路径是否符合以下规范:\n"
|
||||
# "1. 路径参数命名 (例如,全小写蛇形命名法)。\n"
|
||||
# "2. URL路径结构 (例如,/{领域}/{版本号}/资源类型)。\n"
|
||||
# "3. URL版本号嵌入 (例如,包含 /v1/)。\n"
|
||||
# "4. RESTful风格与可读性 (名词表示资源,HTTP方法表示动作,易理解性)。"
|
||||
# )
|
||||
# severity = TestSeverity.MEDIUM # 综合性检查,可能包含不同严重级别的问题
|
||||
# tags = ["normative-spec", "url", "restful", "llm", "readability", "naming-convention", "structure", "versioning", "static-check"]
|
||||
# execution_order = 60 # 更新执行顺序
|
||||
|
||||
# # 此测试用例可以覆盖所有路径,但其有效性依赖LLM
|
||||
# # applicable_methods = None
|
||||
# # applicable_paths_regex = None
|
||||
|
||||
# # 这个标志可以用来在测试用例级别控制是否实际调用LLM,即使全局LLM服务可用
|
||||
# # 如果您希望总是尝试(只要LLMService能初始化),可以不设置这个,或者在逻辑中检查 self.llm_service 是否存在
|
||||
# # use_llm_for_path_analysis: bool = True
|
||||
|
||||
# def __init__(self, endpoint_spec: Dict[str, Any], global_api_spec: Dict[str, Any], json_schema_validator: Optional[Any] = None, llm_service: Optional[LLMService] = None):
|
||||
# super().__init__(endpoint_spec, global_api_spec, json_schema_validator)
|
||||
# self.llm_service = llm_service # 存储注入的 LLMService 实例
|
||||
# if not self.llm_service:
|
||||
# self.logger.warning(f"LLMService 未注入或初始化失败,测试用例 {self.id} 将无法执行LLM路径分析。")
|
||||
|
||||
# def _get_llm_service_from_orchestrator(self) -> Optional[Any]:
|
||||
# # 在实际框架中,测试用例可能无法直接访问编排器来获取LLM服务。
|
||||
# # 这种依赖注入通常在测试用例实例化时或方法调用时处理。
|
||||
# # 此处为一个占位符,理想情况下APITestOrchestrator会将llm_service实例传给需要它的测试用例,
|
||||
# # 或测试用例通过某种服务定位器获取。
|
||||
# # 暂时我们假设,如果全局配置了LLM,它就能用。
|
||||
# # 真实的实现需要APITestOrchestrator在执行此测试用例前,将llm_service实例注入。
|
||||
# # 为了能运行,我们先返回None,并在下面逻辑中处理。
|
||||
# # 或者,修改 Orchestrator 将其注入到 self.global_api_spec 或 self.endpoint_spec (不推荐)
|
||||
# # 最好的方式是在 __init__ 中接收一个 llm_service: Optional[LLMService] 参数。
|
||||
# # 但这需要修改 BaseAPITestCase 和 APITestOrchestrator 的 __init__ 和调用逻辑。
|
||||
|
||||
# # 临时的解决方法:依赖 APITestOrchestrator 初始化时是否成功创建了 LLMService。
|
||||
# # 这仍然是一个间接的检查。一个更直接的方式是在Orchestrator执行此测试用例时传入。
|
||||
# if hasattr(self, '_orchestrator_llm_service_instance') and self._orchestrator_llm_service_instance:
|
||||
# return self._orchestrator_llm_service_instance
|
||||
|
||||
# # 如果没有明确注入,我们只能依赖全局LLMService是否被加载
|
||||
# if LLMService is not None:
|
||||
# # 这里不能直接实例化一个新的LLMService,因为它需要API Key等配置,这些配置在Orchestrator那里。
|
||||
# # 这个测试用例需要依赖Orchestrator来提供一个已经配置好的LLMService实例。
|
||||
# # 此处返回一个指示:如果LLM功能应该被使用,则需要Orchestrator提供服务。
|
||||
# return "NEEDS_INJECTION"
|
||||
# return None
|
||||
|
||||
# def _extract_path_param_names(self, path_template: str) -> List[str]:
|
||||
# """从路径模板中提取路径参数名称。例如 /users/{user_id}/items/{item_id} -> ['user_id', 'item_id']"""
|
||||
# return re.findall(r'\{([^}]+)\}', path_template)
|
||||
|
||||
# def validate_request_url(self, url: str, request_context: APIRequestContext) -> List[ValidationResult]:
|
||||
# results: List[ValidationResult] = []
|
||||
# path_template = self.endpoint_spec.get('path', '')
|
||||
# http_method = request_context.method.upper()
|
||||
# operation_id = self.endpoint_spec.get('operationId', self.endpoint_spec.get('title', '')) # 获取operationId或title
|
||||
|
||||
# if not self.llm_service:
|
||||
# results.append(ValidationResult(
|
||||
# passed=True, # 标记为通过以避免阻塞,但消息表明跳过
|
||||
# message=f"路径 '{path_template}' 的LLM综合URL检查已跳过:LLM服务不可用。",
|
||||
# details={"path_template": path_template, "http_method": http_method, "reason": "LLM Service not available or not injected."}
|
||||
# ))
|
||||
# self.logger.warning(f"LLM综合URL检查已跳过对路径 '{path_template}' 的检查:LLM服务不可用。")
|
||||
# return results
|
||||
|
||||
# path_param_names = self._extract_path_param_names(path_template)
|
||||
# path_params_str = ", ".join(path_param_names) if path_param_names else "无"
|
||||
|
||||
# # - 接口名称 (OperationId 或 Title): {operation_id if operation_id else '请你自己从路径模板中提取'}
|
||||
|
||||
# # 构建给LLM的Prompt,要求JSON输出
|
||||
# prompt_instruction = f"""
|
||||
# 请扮演一位资深的API设计评审员。我将提供一个API端点的路径模板、HTTP方法以及可能的接口名称。
|
||||
# 请根据以下石油行业API设计规范评估此API端点,并以严格的JSON格式返回您的评估结果。
|
||||
# JSON对象应包含一个名为 "assessments" 的键,其值为一个对象列表,每个对象代表对一个标准的评估,包含 "standard_name" (字符串), "is_compliant" (布尔值), 和 "reason" (字符串) 三个键。
|
||||
|
||||
# API端点信息:
|
||||
# - HTTP方法: {http_method}
|
||||
# - 路径模板: {path_template}
|
||||
# - 路径中提取的参数名: [{path_params_str}]
|
||||
|
||||
# 评估标准:
|
||||
|
||||
# 1. **接口名称规范 (接口名称需要你从路径模板中提取,一般是路径中除了参数名以外的最后的一个单词)**:
|
||||
# - 规则: 采用'动词+名词'结构,明确业务语义 (例如: GetWellLog, SubmitSeismicJob)。
|
||||
# - standard_name: "interface_naming_convention"
|
||||
|
||||
# 2. **HTTP方法使用规范**:
|
||||
# - 规则: 遵循RESTful规范:GET用于数据检索, POST用于创建资源, PUT用于更新资源, DELETE用于删除资源。
|
||||
# - standard_name: "http_method_usage"
|
||||
|
||||
# 3. **URL路径结构规范**:
|
||||
# - 规则: 格式为 `<前缀>/<专业领域>/v<版本号>/<资源类型>` (例如: /logging/v1.2/wells, /seismicprospecting/v1.0/datasets)。
|
||||
# - 前缀: 示例: /api/dms
|
||||
# - 专业领域: 专业领域示例: seismicprospecting, welllogging, reservoirevaluation
|
||||
# - 版本号: 语义化版本,例如 v1, v1.0, v2.1.3。
|
||||
# - 资源类型: 通常为名词复数。
|
||||
# - standard_name: "url_path_structure"
|
||||
|
||||
|
||||
# 4. **URL路径参数命名规范**:
|
||||
# - 规则: 路径参数(如果存在)必须使用全小写字母(可以是一个单词)或小写字母加下划线命名(这是多个单词的情况),并能反映资源的唯一标识 (例如: {{well_id}},还有{{version}},{{schema}}也是合规的,比一定非要{{version_id}})。
|
||||
# - standard_name: "url_path_parameter_naming"
|
||||
|
||||
|
||||
# 5. **资源命名规范 (在路径中)**:
|
||||
# - 规则: 资源集合应使用名词的复数形式表示 (例如 `/wells`, `/logs`);应优先使用石油行业的标准术语 (例如用 `trajectory` 而非 `path` 来表示井轨迹)。
|
||||
# - standard_name: "resource_naming_in_path"
|
||||
|
||||
|
||||
|
||||
|
||||
# 请确保您的输出是一个可以被 `json.loads()` 直接解析的JSON对象。
|
||||
# 例如:
|
||||
# {{
|
||||
# "assessments": [
|
||||
# {{
|
||||
# "standard_name": "interface_naming_convention",
|
||||
# "is_compliant": true,
|
||||
# "reason": "接口名称 'GetWellboreTrajectory' 符合动词+名词结构。"
|
||||
# }},
|
||||
# {{
|
||||
# "standard_name": "http_method_usage",
|
||||
# "is_compliant": true,
|
||||
# "reason": "GET方法用于检索资源,符合规范。"
|
||||
# }}
|
||||
# // ... 其他标准的评估 ...
|
||||
# ]
|
||||
# }}
|
||||
# """
|
||||
|
||||
# # 6. **路径可读性与整体RESTful风格**:
|
||||
# # - 规则: 路径整体是否具有良好的可读性、易于理解其功能,并且符合RESTful设计原则?(综合评估,可参考前面几点)
|
||||
# # - standard_name: "general_readability_and_restfulness"
|
||||
|
||||
# messages = [
|
||||
# {"role": "system", "content": "你是一位API设计评审专家,专注于评估API的URL规范性和RESTful风格。你的输出必须是严格的JSON格式。"},
|
||||
# {"role": "user", "content": prompt_instruction}
|
||||
# ]
|
||||
|
||||
# self.logger.info(f"向LLM发送请求,评估路径: {path_template} ({http_method})")
|
||||
# # 假设 _execute_chat_completion_request 支持 response_format={"type": "json_object"} (如果LLM API支持)
|
||||
# # 否则,我们需要解析文本输出。为简化,这里假设LLM会遵循JSON格式指令。
|
||||
# llm_response_str = self.llm_service._execute_chat_completion_request(
|
||||
# messages=messages,
|
||||
# max_tokens=1024, # 根据评估结果的复杂度调整
|
||||
# temperature=0.2 # 低温以获得更确定的、结构化的输出
|
||||
# )
|
||||
|
||||
# if not llm_response_str:
|
||||
# results.append(ValidationResult(
|
||||
# passed=False, # 执行失败
|
||||
# message=f"未能从LLM获取对路径 '{path_template}' 的评估。",
|
||||
# details={"path_template": path_template, "http_method": http_method, "reason": "LLM did not return a response."}
|
||||
# ))
|
||||
# self.logger.error(f"LLM对路径 '{path_template}' 的评估请求未返回任何内容。")
|
||||
# return results
|
||||
|
||||
# self.logger.debug(f"LLM对路径 '{path_template}' 的原始响应: {llm_response_str}")
|
||||
|
||||
# try:
|
||||
# # 尝试清理并解析LLM响应
|
||||
# # 有时LLM可能在JSON前后添加 "```json" 和 "```"
|
||||
# cleaned_response_str = llm_response_str.strip()
|
||||
# if cleaned_response_str.startswith("```json"):
|
||||
# cleaned_response_str = cleaned_response_str[7:]
|
||||
# if cleaned_response_str.endswith("```"):
|
||||
# cleaned_response_str = cleaned_response_str[:-3]
|
||||
|
||||
# llm_assessment_data = json.loads(cleaned_response_str)
|
||||
|
||||
# if "assessments" not in llm_assessment_data or not isinstance(llm_assessment_data["assessments"], list):
|
||||
# raise ValueError("LLM响应JSON中缺少 'assessments' 列表或格式不正确。")
|
||||
|
||||
# found_assessments = False
|
||||
# for assessment in llm_assessment_data["assessments"]:
|
||||
# standard_name = assessment.get("standard_name", "未知标准")
|
||||
# is_compliant = assessment.get("is_compliant", False)
|
||||
# reason = assessment.get("reason", "LLM未提供原因。")
|
||||
# found_assessments = True
|
||||
|
||||
# results.append(ValidationResult(
|
||||
# passed=is_compliant,
|
||||
# message=f"LLM评估 - {standard_name}: {reason}",
|
||||
# details={
|
||||
# "standard_name": standard_name,
|
||||
# "is_compliant_by_llm": is_compliant,
|
||||
# "llm_reason": reason,
|
||||
# "path_template": path_template,
|
||||
# "http_method": http_method
|
||||
# }
|
||||
# ))
|
||||
# log_level = self.logger.info if is_compliant else self.logger.warning
|
||||
# log_level(f"LLM评估 - 标准 '{standard_name}' for '{path_template}': {'符合' if is_compliant else '不符合'}。原因: {reason}")
|
||||
|
||||
# if not found_assessments:
|
||||
# results.append(ValidationResult(
|
||||
# passed=False,
|
||||
# message=f"LLM返回的评估结果中不包含任何有效的评估项。",
|
||||
# details={"path_template": path_template, "http_method": http_method, "raw_llm_response": llm_response_str}
|
||||
# ))
|
||||
|
||||
|
||||
# except json.JSONDecodeError as e_json:
|
||||
# results.append(ValidationResult(
|
||||
# passed=False, # 执行失败
|
||||
# message=f"无法将LLM对路径 '{path_template}' 的评估响应解析为JSON: {e_json}",
|
||||
# details={"path_template": path_template, "http_method": http_method, "raw_llm_response": llm_response_str, "error": str(e_json)}
|
||||
# ))
|
||||
# self.logger.error(f"LLM对路径 '{path_template}' 的响应JSON解析失败: {e_json}. Raw response: {llm_response_str}")
|
||||
# except ValueError as e_val: # 自定义错误,如缺少 'assessments'
|
||||
# results.append(ValidationResult(
|
||||
# passed=False, # 执行失败
|
||||
# message=f"LLM对路径 '{path_template}' 的评估响应JSON结构不符合预期: {e_val}",
|
||||
# details={"path_template": path_template, "http_method": http_method, "raw_llm_response": llm_response_str, "error": str(e_val)}
|
||||
# ))
|
||||
# self.logger.error(f"LLM对路径 '{path_template}' 的响应JSON结构错误: {e_val}. Raw response: {llm_response_str}")
|
||||
# except Exception as e_generic:
|
||||
# results.append(ValidationResult(
|
||||
# passed=False, # 执行失败
|
||||
# message=f"处理LLM对路径 '{path_template}' 的评估响应时发生未知错误: {e_generic}",
|
||||
# details={"path_template": path_template, "http_method": http_method, "raw_llm_response": llm_response_str, "error": str(e_generic)}
|
||||
# ))
|
||||
# self.logger.error(f"处理LLM对路径 '{path_template}' 的响应时发生未知错误: {e_generic}", exc_info=True)
|
||||
|
||||
# return results
|
||||
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
# This file marks the security directory as a Python package.
|
||||
BIN
Binary file not shown.
+84
@@ -0,0 +1,84 @@
|
||||
from typing import Dict, Any, Optional, List
|
||||
from ddms_compliance_suite.test_framework_core import BaseAPITestCase, TestSeverity, ValidationResult, APIRequestContext, APIResponseContext
|
||||
import urllib.parse
|
||||
|
||||
class HTTPSMandatoryCase(BaseAPITestCase):
|
||||
id = "TC-SECURITY-001"
|
||||
name = "HTTPS 协议强制性检查"
|
||||
description = "验证API端点是否通过HTTPS提供服务,以及HTTP请求是否被拒绝或重定向到HTTPS。"
|
||||
severity = TestSeverity.HIGH
|
||||
tags = ["security", "https", "transport-security"]
|
||||
execution_order = 120
|
||||
|
||||
# 此测试会修改URL为HTTP,应适用于大多数端点。
|
||||
|
||||
def __init__(self, endpoint_spec: Dict[str, Any], global_api_spec: Dict[str, Any], json_schema_validator: Optional[Any] = None, llm_service: Optional[Any] = None):
|
||||
super().__init__(endpoint_spec, global_api_spec, json_schema_validator, llm_service=llm_service)
|
||||
self.logger.info(f"测试用例 '{self.id}' 已为端点 '{self.endpoint_spec.get('method')} {self.endpoint_spec.get('path')}' 初始化。")
|
||||
|
||||
def modify_request_url(self, current_url: str) -> str:
|
||||
parsed_url = urllib.parse.urlparse(current_url)
|
||||
if parsed_url.scheme.lower() == "https":
|
||||
# 将 https 替换为 http
|
||||
modified_url = parsed_url._replace(scheme="http").geturl()
|
||||
self.logger.info(f"为进行HTTPS检查修改URL:原始 '{current_url}', 修改为 '{modified_url}'")
|
||||
return modified_url
|
||||
else:
|
||||
self.logger.warning(f"原始URL '{current_url}' 不是HTTPS。跳过此测试用例的URL修改。")
|
||||
# 如果原始URL不是HTTPS,此测试可能无效,
|
||||
# 或者暗示基础URL本身可能未正确配置以进行HTTPS测试。
|
||||
return current_url # 如果不是HTTPS则返回原始URL
|
||||
|
||||
def validate_response(self, response_context: APIResponseContext, request_context: APIRequestContext) -> List[ValidationResult]:
|
||||
results = []
|
||||
status_code = response_context.status_code
|
||||
# request_context.url 是调用 modify_request_url 之后,APICaller实际发送的URL
|
||||
request_url_scheme = urllib.parse.urlparse(request_context.url).scheme
|
||||
|
||||
# 检查URL是否确实被此测试的钩子修改为了HTTP
|
||||
if request_url_scheme.lower() != "http":
|
||||
results.append(self.passed(
|
||||
message=f"测试已跳过,因为发送的URL已经是 {request_url_scheme.upper()}(可能是由于原始基础URL非HTTPS或测试设置问题)。"
|
||||
))
|
||||
self.logger.info("HTTPS强制性检查已跳过,因为有效URL不是HTTP。")
|
||||
return results
|
||||
|
||||
# 如果请求是通过HTTP发出的,我们期望几种结果:
|
||||
# 1. 拒绝(例如400、403,或连接被拒绝 - 尽管APICaller可能在此之前处理连接拒绝)
|
||||
# 2. 重定向到HTTPS(例如301、302、307、308,并带有指向HTTPS的Location头)
|
||||
|
||||
if status_code in [301, 302, 307, 308]: # 重定向状态码
|
||||
location_header = response_context.headers.get("Location")
|
||||
if location_header and urllib.parse.urlparse(location_header).scheme.lower() == "httpss":
|
||||
results.append(self.passed(
|
||||
message=f"对 {request_context.url} 的HTTP请求被正确重定向到HTTPS ({location_header}),状态码 {status_code}。"
|
||||
))
|
||||
self.logger.info(f"HTTP被正确重定向到HTTPS: {location_header}")
|
||||
else:
|
||||
results.append(self.failed(
|
||||
message=f"对 {request_context.url} 的HTTP请求被重定向(状态码 {status_code}),但Location头 '{location_header}' 未指向HTTPS URL。",
|
||||
details={"status_code": status_code, "location_header": location_header}
|
||||
))
|
||||
self.logger.warning(f"HTTP被重定向,状态码 {status_code},但Location '{location_header}' 不是HTTPS。")
|
||||
elif status_code // 100 == 4: # 客户端错误(例如400错误请求,403禁止访问)
|
||||
results.append(self.passed(
|
||||
message=f"对 {request_context.url} 的HTTP请求被客户端错误(状态码 {status_code})拒绝,表明不允许HTTP访问。"
|
||||
))
|
||||
self.logger.info(f"HTTP请求被客户端错误 {status_code} 拒绝。")
|
||||
elif status_code // 100 == 2: # 通过HTTP成功返回2xx响应
|
||||
results.append(self.failed(
|
||||
message=f"API通过HTTP ({request_context.url}) 响应了成功的状态码 {status_code},这违反了HTTPS强制策略。",
|
||||
details={"status_code": status_code}
|
||||
))
|
||||
self.logger.error(f"安全漏洞:API允许通过HTTP成功响应 ({status_code})。")
|
||||
else:
|
||||
# 其他状态码(例如5xx)可能表示与HTTPS强制执行无关的服务器错误,
|
||||
# 或者连接在更底层被拒绝(APICaller可能会抛出异常)。
|
||||
results.append(ValidationResult(
|
||||
passed=False, # 或True,取决于严格程度 - 5xx不是通过,但不一定是HTTPS失败
|
||||
message=f"对 {request_context.url} 的HTTP请求返回了意外的状态码 {status_code}。需要手动调查。",
|
||||
details={"status_code": status_code, "response_text_sample": (response_context.text_content or "")[:200]}
|
||||
))
|
||||
self.logger.warning(f"HTTP请求返回意外状态码 {status_code}。潜在问题或服务器错误。")
|
||||
|
||||
return results
|
||||
BIN
Binary file not shown.
@@ -0,0 +1,2 @@
|
||||
[
|
||||
]
|
||||
@@ -0,0 +1,9 @@
|
||||
[
|
||||
"API接口应该遵循RESTful设计规范,URL应使用名词而非动词",
|
||||
"API响应格式应统一,包含状态码、消息和数据三个字段",
|
||||
"API应该妥善处理错误情况,返回适当的错误代码和说明",
|
||||
"API应该使用正确的HTTP方法:GET用于检索,POST用于创建,PUT用于更新,DELETE用于删除",
|
||||
"API响应中的时间字段应符合ISO 8601标准格式",
|
||||
"API路径结构应遵循'<前缀>/<专业领域>/v<版本号>/<资源类型>'格式",
|
||||
"API应提供适当的缓存控制机制"
|
||||
]
|
||||
@@ -0,0 +1,128 @@
|
||||
import os
|
||||
import json
|
||||
from typing import Dict, Any, Optional, List
|
||||
from ddms_compliance_suite.test_framework_core import BaseAPITestCase, TestSeverity, ValidationResult, APIRequestContext, APIResponseContext
|
||||
|
||||
class LLMComplianceCheckTestCase(BaseAPITestCase):
|
||||
id = "TC-LLM-COMPLIANCE-001"
|
||||
name = "LLM合规性综合检查"
|
||||
description = "读取固定的合规性标准列表,将API所有关键信息(url、headers、params、query、body、示例响应等)发送给大模型,让其判断是否通过并给出理由。"
|
||||
severity = TestSeverity.MEDIUM
|
||||
tags = ["llm", "compliance", "auto-eval"]
|
||||
execution_order = 99
|
||||
|
||||
def __init__(self, endpoint_spec: Dict[str, Any], global_api_spec: Dict[str, Any], json_schema_validator: Optional[Any] = None, llm_service: Optional[Any] = None):
|
||||
super().__init__(endpoint_spec, global_api_spec, json_schema_validator=json_schema_validator, llm_service=llm_service)
|
||||
# 读取合规性标准
|
||||
criteria_path = os.path.join(os.path.dirname(__file__), "compliance_criteria.json")
|
||||
with open(criteria_path, "r", encoding="utf-8") as f:
|
||||
self.compliance_criteria = json.load(f)
|
||||
self.logger.info(f"已加载合规性标准: {self.compliance_criteria}")
|
||||
|
||||
def validate_response(self, response_context: APIResponseContext, request_context: APIRequestContext) -> List[ValidationResult]:
|
||||
results = []
|
||||
|
||||
# 如果合规性标准列表为空,则跳过测试
|
||||
if not self.compliance_criteria:
|
||||
return [ValidationResult(
|
||||
passed=True,
|
||||
message="合规性标准列表为空,跳过LLM合规性检查。",
|
||||
details={"reason": "compliance_criteria.json is empty or contains an empty list."}
|
||||
)]
|
||||
|
||||
# 收集API所有关键信息,包括实例数据和Schema定义
|
||||
api_info = {
|
||||
# API元数据
|
||||
"path_template": self.endpoint_spec.get("path"),
|
||||
"method": request_context.method,
|
||||
# "operationId": self.endpoint_spec.get("operationId"),
|
||||
"title": self.endpoint_spec.get("summary") or self.endpoint_spec.get("title"),
|
||||
"description": self.endpoint_spec.get("description") or self.endpoint_spec.get("desc"),
|
||||
"tags": self.endpoint_spec.get("tags"),
|
||||
|
||||
# API Schema 定义 (从 endpoint_spec 获取)
|
||||
"schema_parameters": self.endpoint_spec.get("parameters"),
|
||||
"schema_request_body": self.endpoint_spec.get("requestBody"),
|
||||
"schema_responses": self.endpoint_spec.get("responses"),
|
||||
|
||||
# API 调用实例数据 (从 request_context 和 response_context 获取)
|
||||
"instance_url": request_context.url,
|
||||
"instance_request_headers": dict(request_context.headers) if hasattr(request_context, "headers") else {},
|
||||
"instance_query_params": getattr(request_context, "query_params", {}),
|
||||
"instance_path_params": getattr(request_context, "path_params", {}),
|
||||
"instance_request_body": getattr(request_context, "body", None),
|
||||
"instance_response_status": response_context.status_code,
|
||||
"instance_response_headers": dict(response_context.headers) if hasattr(response_context, "headers") else {},
|
||||
"instance_response_body": response_context.text_content if hasattr(response_context, "text_content") else None
|
||||
}
|
||||
# 日志打印所有API信息
|
||||
self.logger.info("LLM合规性检查-API信息收集: " + json.dumps(api_info, ensure_ascii=False, indent=2))
|
||||
self.logger.info("LLM合规性检查-标准: " + json.dumps(self.compliance_criteria, ensure_ascii=False, indent=2))
|
||||
|
||||
if not self.llm_service:
|
||||
results.append(ValidationResult(
|
||||
passed=True,
|
||||
message="LLM服务不可用,跳过本用例。",
|
||||
details={"reason": "llm_service is None"}
|
||||
))
|
||||
return results
|
||||
|
||||
# 构建prompt
|
||||
prompt = f"""
|
||||
你是一位API合规性专家。请根据以下合规性标准,对给定的API调用信息进行逐条评估。每条标准请给出是否通过(true/false)和理由。
|
||||
|
||||
合规性标准:
|
||||
{json.dumps(self.compliance_criteria, ensure_ascii=False, indent=2)}
|
||||
|
||||
API信息:
|
||||
{json.dumps(api_info, ensure_ascii=False, indent=2)}
|
||||
|
||||
请以如下JSON格式输出:
|
||||
[
|
||||
{{"criterion": "标准内容", "passed": true/false, "reason": "理由"}},
|
||||
...
|
||||
]
|
||||
"""
|
||||
messages = [
|
||||
{"role": "system", "content": "你是一位API合规性专家,输出必须是严格的JSON数组。"},
|
||||
{"role": "user", "content": prompt}
|
||||
]
|
||||
self.logger.info("发送给LLM的prompt: " + prompt)
|
||||
llm_response_str = self.llm_service._execute_chat_completion_request(
|
||||
messages=messages,
|
||||
max_tokens=2048,
|
||||
temperature=0.2
|
||||
)
|
||||
if not llm_response_str:
|
||||
results.append(ValidationResult(
|
||||
passed=False,
|
||||
message="未能从LLM获取响应。",
|
||||
details={"prompt": prompt}
|
||||
))
|
||||
return results
|
||||
self.logger.info(f"LLM原始响应: {llm_response_str}")
|
||||
try:
|
||||
cleaned = llm_response_str.strip()
|
||||
if cleaned.startswith("```json"):
|
||||
cleaned = cleaned[7:]
|
||||
if cleaned.endswith("```"):
|
||||
cleaned = cleaned[:-3]
|
||||
llm_result = json.loads(cleaned)
|
||||
if not isinstance(llm_result, list):
|
||||
raise ValueError("LLM返回的不是JSON数组")
|
||||
for item in llm_result:
|
||||
criterion = item.get("criterion", "未知标准")
|
||||
passed = item.get("passed", False)
|
||||
reason = item.get("reason", "无理由")
|
||||
results.append(ValidationResult(
|
||||
passed=passed,
|
||||
message=f"[{criterion}] {'通过' if passed else '不通过'}: {reason}",
|
||||
details={"criterion": criterion, "llm_reason": reason}
|
||||
))
|
||||
except Exception as e:
|
||||
results.append(ValidationResult(
|
||||
passed=False,
|
||||
message=f"LLM响应解析失败: {e}",
|
||||
details={"raw_llm_response": llm_response_str}
|
||||
))
|
||||
return results
|
||||
+286
@@ -0,0 +1,286 @@
|
||||
from typing import Dict, Any, Optional, List, Union, Set
|
||||
import re
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from ddms_compliance_suite.test_framework_core import BaseAPITestCase, TestSeverity, ValidationResult, APIRequestContext, APIResponseContext
|
||||
from ddms_compliance_suite.utils import schema_utils
|
||||
|
||||
class EncryptedFieldsCheck(BaseAPITestCase):
|
||||
"""
|
||||
检查API响应中的敏感字段是否已加密。
|
||||
该测试用例允许用户定义一组需要加密的敏感字段名称,然后检查响应中这些字段是否为明文。
|
||||
|
||||
支持多种方式配置敏感字段列表:
|
||||
1. 使用默认预定义的敏感字段集合
|
||||
2. 通过API规范中的x-sensitive-fields扩展字段指定
|
||||
3. 通过环境变量SENSITIVE_FIELDS指定(逗号分隔的字符串)
|
||||
"""
|
||||
# 元数据
|
||||
id = "TC-SECURITY-002"
|
||||
name = "敏感字段加密检查"
|
||||
description = "验证API响应中的敏感字段是否已加密,而非明文。"
|
||||
severity = TestSeverity.HIGH
|
||||
tags = ["security", "encryption", "sensitive_data"]
|
||||
execution_order = 50 # 在基本状态码检查之后执行
|
||||
|
||||
# 默认需要检查的敏感字段名称集合
|
||||
DEFAULT_SENSITIVE_FIELDS = {
|
||||
"coord","location","position"
|
||||
}
|
||||
|
||||
# 加密字符串的正则表达式模式
|
||||
# 这里假设加密字符串通常是base64或hex编码的字符串
|
||||
ENCRYPTED_PATTERNS = [
|
||||
# Base64编码模式 (标准base64字符集,长度通常是4的倍数且较长)
|
||||
r'^[A-Za-z0-9+/]{20,}={0,2}$',
|
||||
# Hex编码模式 (16进制字符,长度通常是偶数且较长)
|
||||
r'^[0-9a-fA-F]{16,}$',
|
||||
# JWT Token模式 (三段base64编码,用点分隔)
|
||||
r'^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$',
|
||||
# 通用哈希值模式 (固定长度的hex字符串,如MD5, SHA1, SHA256等)
|
||||
r'^[0-9a-fA-F]{32}$|^[0-9a-fA-F]{40}$|^[0-9a-fA-F]{64}$',
|
||||
# 带前缀的加密字符串 (如bcrypt, PBKDF2等)
|
||||
r'^\$2[ayb]\$.{56}$|^\$pbkdf2-sha\d+\$',
|
||||
]
|
||||
|
||||
# 明文检测模式 (如果匹配这些模式,则可能是明文)
|
||||
PLAINTEXT_PATTERNS = [
|
||||
# 常见的明文密码模式 (字母数字特殊字符组合,长度通常在6-20之间)
|
||||
r'^[A-Za-z0-9!@#$%^&*()_+\-=\[\]{};\':"\\|,.<>\/?]{6,20}$',
|
||||
# 手机号模式 (中国手机号)
|
||||
r'^1[3-9]\d{9}$',
|
||||
# 身份证号模式 (中国身份证号)
|
||||
r'^\d{17}[\dXx]$|^\d{15}$',
|
||||
# 银行卡号模式 (数字,通常12-19位)
|
||||
r'^\d{12,19}$',
|
||||
# 邮箱模式
|
||||
r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$',
|
||||
]
|
||||
|
||||
def __init__(self, endpoint_spec: Dict[str, Any], global_api_spec: Dict[str, Any], json_schema_validator: Optional[Any] = None, llm_service: Optional[Any] = None):
|
||||
super().__init__(endpoint_spec, global_api_spec, json_schema_validator, llm_service)
|
||||
|
||||
# 获取敏感字段列表,优先级:API规范扩展字段 > 环境变量 > 默认值
|
||||
self.sensitive_fields = self._get_sensitive_fields(endpoint_spec)
|
||||
self.logger.info(f"测试用例 {self.id} ({self.name}) 已初始化,将检查以下敏感字段是否加密: {self.sensitive_fields}")
|
||||
|
||||
def _get_sensitive_fields(self, endpoint_spec: Dict[str, Any]) -> Set[str]:
|
||||
return self.DEFAULT_SENSITIVE_FIELDS
|
||||
"""
|
||||
获取敏感字段列表,按以下优先级:
|
||||
1. API规范中的x-sensitive-fields扩展字段
|
||||
2. 环境变量SENSITIVE_FIELDS
|
||||
3. 默认敏感字段列表DEFAULT_SENSITIVE_FIELDS
|
||||
|
||||
Args:
|
||||
endpoint_spec: API端点规范
|
||||
|
||||
Returns:
|
||||
敏感字段集合
|
||||
"""
|
||||
custom_fields = set()
|
||||
|
||||
# 1. 从API规范中的x-sensitive-fields扩展字段获取
|
||||
if "x-sensitive-fields" in endpoint_spec:
|
||||
try:
|
||||
fields = endpoint_spec["x-sensitive-fields"]
|
||||
if isinstance(fields, list):
|
||||
custom_fields.update(fields)
|
||||
elif isinstance(fields, str):
|
||||
# 如果是JSON字符串,尝试解析
|
||||
try:
|
||||
parsed_fields = json.loads(fields)
|
||||
if isinstance(parsed_fields, list):
|
||||
custom_fields.update(parsed_fields)
|
||||
elif isinstance(parsed_fields, dict) and "fields" in parsed_fields:
|
||||
# 支持 {"fields": ["field1", "field2"]} 格式
|
||||
if isinstance(parsed_fields["fields"], list):
|
||||
custom_fields.update(parsed_fields["fields"])
|
||||
except json.JSONDecodeError:
|
||||
# 如果不是JSON,假设是逗号分隔的字符串
|
||||
custom_fields.update([f.strip() for f in fields.split(",")])
|
||||
except Exception as e:
|
||||
self.logger.warning(f"解析API规范中的自定义敏感字段时出错: {e}")
|
||||
|
||||
# 2. 如果API规范中没有定义,尝试从环境变量获取
|
||||
if not custom_fields:
|
||||
env_fields = os.environ.get("SENSITIVE_FIELDS")
|
||||
if env_fields:
|
||||
try:
|
||||
# 尝试解析为JSON
|
||||
try:
|
||||
parsed_fields = json.loads(env_fields)
|
||||
if isinstance(parsed_fields, list):
|
||||
custom_fields.update(parsed_fields)
|
||||
except json.JSONDecodeError:
|
||||
# 如果不是JSON,假设是逗号分隔的字符串
|
||||
custom_fields.update([f.strip() for f in env_fields.split(",")])
|
||||
except Exception as e:
|
||||
self.logger.warning(f"解析环境变量中的自定义敏感字段时出错: {e}")
|
||||
|
||||
# 3. 如果以上方式都没有获取到自定义字段,使用默认值
|
||||
if not custom_fields:
|
||||
self.logger.info("未找到自定义敏感字段配置,使用默认敏感字段列表")
|
||||
return self.DEFAULT_SENSITIVE_FIELDS
|
||||
else:
|
||||
self.logger.info(f"使用自定义敏感字段列表: {custom_fields}")
|
||||
return custom_fields
|
||||
|
||||
def is_likely_encrypted(self, value: str) -> bool:
|
||||
"""
|
||||
判断一个字符串是否可能是加密的。
|
||||
|
||||
Args:
|
||||
value: 要检查的字符串值
|
||||
|
||||
Returns:
|
||||
如果字符串可能是加密的,则返回True;否则返回False
|
||||
"""
|
||||
# 如果值太短,可能不是有效的加密值
|
||||
if len(value) < 16:
|
||||
return False
|
||||
|
||||
# 检查是否匹配任何加密模式
|
||||
for pattern in self.ENCRYPTED_PATTERNS:
|
||||
if re.match(pattern, value):
|
||||
return True
|
||||
|
||||
# 检查是否匹配任何明文模式
|
||||
for pattern in self.PLAINTEXT_PATTERNS:
|
||||
if re.match(pattern, value):
|
||||
return False
|
||||
|
||||
# 如果没有匹配任何模式,默认认为是加密的(保守策略)
|
||||
return True
|
||||
|
||||
def find_sensitive_fields_in_response(self, response_data: Any, path: List[Union[str, int]] = None) -> Dict[str, List[Union[str, int]]]:
|
||||
"""
|
||||
递归搜索响应数据中的敏感字段。
|
||||
|
||||
Args:
|
||||
response_data: 响应数据(可能是字典、列表或基本类型)
|
||||
path: 当前路径
|
||||
|
||||
Returns:
|
||||
包含敏感字段路径的字典 {字段名: 路径}
|
||||
"""
|
||||
if path is None:
|
||||
path = []
|
||||
|
||||
sensitive_fields_found = {}
|
||||
self.logger.info(f"response_data:{response_data}")
|
||||
if isinstance(response_data, dict):
|
||||
for key, value in response_data.items():
|
||||
current_path = path + [key]
|
||||
|
||||
# 检查当前字段名是否包含任何敏感关键词,不再限制值必须是字符串类型
|
||||
key_lower = key.lower()
|
||||
for sensitive_field in self.sensitive_fields:
|
||||
sensitive_field_lower = sensitive_field.lower()
|
||||
# 修改为包含匹配而不是严格匹配
|
||||
if sensitive_field_lower in key_lower or key_lower in sensitive_field_lower:
|
||||
# 移除对值类型的检查,允许任何类型的敏感字段
|
||||
sensitive_fields_found[key] = current_path
|
||||
self.logger.info(f"找到敏感字段: {key}, 匹配关键词: {sensitive_field}")
|
||||
break
|
||||
|
||||
# 递归检查子字段
|
||||
if isinstance(value, (dict, list)):
|
||||
nested_fields = self.find_sensitive_fields_in_response(value, current_path)
|
||||
sensitive_fields_found.update(nested_fields)
|
||||
|
||||
elif isinstance(response_data, list):
|
||||
for i, item in enumerate(response_data):
|
||||
current_path = path + [i]
|
||||
if isinstance(item, (dict, list)):
|
||||
nested_fields = self.find_sensitive_fields_in_response(item, current_path)
|
||||
sensitive_fields_found.update(nested_fields)
|
||||
|
||||
return sensitive_fields_found
|
||||
|
||||
def validate_response(self, response_context: APIResponseContext, request_context: APIRequestContext) -> List[ValidationResult]:
|
||||
"""
|
||||
验证响应中的敏感字段是否已加密。
|
||||
|
||||
Args:
|
||||
response_context: API响应上下文
|
||||
request_context: API请求上下文
|
||||
|
||||
Returns:
|
||||
验证结果列表
|
||||
"""
|
||||
results = []
|
||||
|
||||
# 如果响应不是JSON格式,则跳过检查
|
||||
if not response_context.json_content:
|
||||
results.append(self.passed("响应不是JSON格式,跳过敏感字段加密检查。"))
|
||||
return results
|
||||
self.logger.info(f"response_context.json_content: {response_context.json_content}")
|
||||
# 查找响应中的敏感字段
|
||||
sensitive_fields_found = self.find_sensitive_fields_in_response(response_context.json_content)
|
||||
|
||||
if not sensitive_fields_found:
|
||||
self.logger.info(f"未在响应中找到需要检查的敏感字段。")
|
||||
results.append(self.passed("未在响应中找到需要检查的敏感字段。"))
|
||||
return results
|
||||
|
||||
# 检查每个敏感字段是否已加密
|
||||
for field_name, field_path in sensitive_fields_found.items():
|
||||
# 获取字段值
|
||||
current_data = response_context.json_content
|
||||
try:
|
||||
for path_part in field_path:
|
||||
current_data = current_data[path_part]
|
||||
except (KeyError, IndexError, TypeError):
|
||||
self.logger.warning(f"无法访问路径 {field_path} 的值")
|
||||
continue
|
||||
|
||||
self.logger.info(f"检查敏感字段: {field_name}, 路径: {field_path}, 值类型: {type(current_data)}, 值: {current_data}")
|
||||
|
||||
# 根据字段值类型进行不同处理
|
||||
if isinstance(current_data, str):
|
||||
# 字符串类型:检查是否已加密
|
||||
if self.is_likely_encrypted(current_data):
|
||||
results.append(self.passed(
|
||||
f"敏感字段 '{'.'.join(map(str, field_path))}' 已正确加密。",
|
||||
{"field_path": field_path, "field_name": field_name}
|
||||
))
|
||||
else:
|
||||
results.append(self.failed(
|
||||
f"敏感字段 '{'.'.join(map(str, field_path))}' 可能未加密,存在安全风险。",
|
||||
{
|
||||
"field_path": field_path,
|
||||
"field_name": field_name,
|
||||
"value_preview": current_data[:10] + "..." if len(current_data) > 10 else current_data
|
||||
}
|
||||
))
|
||||
elif current_data is None:
|
||||
# 空值:跳过检查
|
||||
results.append(self.passed(
|
||||
f"敏感字段 '{'.'.join(map(str, field_path))}' 的值为空,跳过加密检查。",
|
||||
{"field_path": field_path, "field_name": field_name}
|
||||
))
|
||||
else:
|
||||
# 非字符串类型:警告可能存在安全风险
|
||||
results.append(self.failed(
|
||||
f"敏感字段 '{'.'.join(map(str, field_path))}' 的值为非字符串类型 ({type(current_data).__name__}),可能存在安全风险。",
|
||||
{
|
||||
"field_path": field_path,
|
||||
"field_name": field_name,
|
||||
"field_type": type(current_data).__name__,
|
||||
"value": str(current_data)
|
||||
}
|
||||
))
|
||||
|
||||
return results
|
||||
|
||||
@staticmethod
|
||||
def passed(message: str, details: Optional[Dict[str, Any]] = None) -> ValidationResult:
|
||||
"""创建通过的验证结果"""
|
||||
return ValidationResult(passed=True, message=message, details=details)
|
||||
|
||||
@staticmethod
|
||||
def failed(message: str, details: Optional[Dict[str, Any]] = None) -> ValidationResult:
|
||||
"""创建失败的验证结果"""
|
||||
return ValidationResult(passed=False, message=message, details=details)
|
||||
@@ -0,0 +1,44 @@
|
||||
# 设置检查测试用例
|
||||
|
||||
本目录包含执行在实际API调用之前的设置检查测试用例。
|
||||
|
||||
## 必需请求头Schema验证
|
||||
|
||||
`required_headers_check.py` 实现了一个测试用例,用于验证API规范中是否包含所有必需的请求头:
|
||||
|
||||
- X-Tenant-ID (也接受 tenant-id 作为变体)
|
||||
- X-Data-Domain (也接受 data-domain 作为变体)
|
||||
- Authorization
|
||||
|
||||
该测试用例不会发送实际的API请求,只会验证API规范的定义是否符合要求。
|
||||
|
||||
### 工作原理
|
||||
|
||||
1. 测试用例检查每个API端点的规范定义
|
||||
2. 验证是否包含所有必需的请求头
|
||||
3. 验证这些请求头是否被标记为必需 (required="1")
|
||||
4. 生成详细的验证结果,包括哪些请求头缺失或未标记为必需
|
||||
|
||||
### 使用方法
|
||||
|
||||
这个测试用例会自动被测试框架发现并应用到所有API端点。由于其`execution_order = 0`设置,它会在其他测试用例之前执行。
|
||||
|
||||
如果发现API规范中缺少必需的请求头,测试会失败并提供详细的错误信息,指出哪些请求头缺失或未标记为必需。
|
||||
|
||||
### 示例结果
|
||||
|
||||
成功情况:
|
||||
```
|
||||
✅ 测试通过: 所有必需的请求头都已正确定义
|
||||
```
|
||||
|
||||
失败情况:
|
||||
```
|
||||
❌ 测试失败: 缺少必需的请求头 X-Data-Domain
|
||||
❌ 测试失败: 请求头 tenant-id 存在但未标记为必需
|
||||
```
|
||||
|
||||
### 注意事项
|
||||
|
||||
1. 此测试用例接受请求头名称的不同变体(如`X-Tenant-ID`和`tenant-id`)
|
||||
2. 如果API规范设计时有意不包含某些请求头,可能需要修改测试用例的`required_headers`配置
|
||||
Vendored
BIN
Binary file not shown.
BIN
Binary file not shown.
@@ -0,0 +1,127 @@
|
||||
from ddms_compliance_suite.test_framework_core import BaseAPITestCase, TestSeverity, ValidationResult, APIRequestContext, APIResponseContext
|
||||
import logging
|
||||
import json
|
||||
from typing import Dict, Any, Optional, List
|
||||
|
||||
class RequiredHeadersSchemaCheck(BaseAPITestCase):
|
||||
"""验证API规范中是否包含必需的请求头"""
|
||||
|
||||
# 1. 元数据
|
||||
id = "TC-HEADER-001"
|
||||
name = "必需请求头Schema验证"
|
||||
description = "验证API规范中是否包含必需的请求头(X-Tenant-ID、X-Data-Domain和Authorization)"
|
||||
severity = TestSeverity.HIGH
|
||||
tags = ["headers", "schema", "compliance"]
|
||||
execution_order = 2 # 优先执行
|
||||
# is_critical_setup_test = True
|
||||
|
||||
def __init__(self, endpoint_spec: Dict[str, Any], global_api_spec: Dict[str, Any], json_schema_validator: Optional[Any] = None, llm_service: Optional[Any] = None):
|
||||
super().__init__(endpoint_spec, global_api_spec, json_schema_validator=json_schema_validator, llm_service=llm_service)
|
||||
self.logger = logging.getLogger(self.__class__.__name__)
|
||||
self.logger.info(f"测试用例 {self.id} ({self.name}) 已针对端点 '{self.endpoint_spec.get('method')} {self.endpoint_spec.get('path')}' 初始化。")
|
||||
|
||||
# 定义必需的请求头和可能的命名变体
|
||||
# self.required_headers = {
|
||||
# 'X-Tenant-ID': ['X-Tenant-ID', 'tenant-id', 'X-TENANT-ID', 'TENANT-ID'],
|
||||
# 'X-Data-Domain': ['X-Data-Domain', 'data-domain', 'X-DATA-DOMAIN', 'DATA-DOMAIN'],
|
||||
# 'Authorization': ['Authorization', 'authorization', 'AUTHORIZATION']
|
||||
# }
|
||||
self.required_headers = {
|
||||
'X-Tenant-ID': ['X-Tenant-ID'],
|
||||
'X-Data-Domain': ['X-Data-Domain'],
|
||||
'Authorization': ['Authorization']
|
||||
}
|
||||
|
||||
def pre_request(self, request_context: APIRequestContext) -> APIRequestContext:
|
||||
"""这个测试用例不需要发送实际请求"""
|
||||
# 设置一个标志,表示不需要发送请求
|
||||
self._skip_request = True
|
||||
return request_context
|
||||
|
||||
def validate_response(self, response_context: APIResponseContext, request_context: APIRequestContext) -> List[ValidationResult]:
|
||||
"""验证API规范中是否包含所有必需的请求头"""
|
||||
results = []
|
||||
|
||||
# 从parameters数组中获取header类型的参数
|
||||
parameters = self.endpoint_spec.get('parameters', [])
|
||||
header_params = [p for p in parameters if p.get('in') == 'header']
|
||||
|
||||
# 记录调试信息
|
||||
self.logger.info(f"API端点: {self.endpoint_spec.get('method')} {self.endpoint_spec.get('path')}")
|
||||
self.logger.info(f"发现的header参数: {json.dumps(header_params, ensure_ascii=False)}")
|
||||
|
||||
# 记录找到的请求头,方便调试
|
||||
found_headers = {}
|
||||
|
||||
# 检查每个必需的请求头
|
||||
for header_key, possible_names in self.required_headers.items():
|
||||
header_found = False
|
||||
required_found = False
|
||||
found_name = None
|
||||
|
||||
# 检查是否存在任何变体的请求头名称
|
||||
for name in possible_names:
|
||||
for header in header_params:
|
||||
header_name = header.get('name', '')
|
||||
if header_name.lower() == name.lower():
|
||||
header_found = True
|
||||
found_name = header_name
|
||||
# 检查是否被标记为必需
|
||||
if header.get('required') is True:
|
||||
required_found = True
|
||||
break
|
||||
if header_found:
|
||||
break
|
||||
|
||||
found_headers[header_key] = {
|
||||
'found': header_found,
|
||||
'required': required_found,
|
||||
'name': found_name
|
||||
}
|
||||
|
||||
# 记录检查结果
|
||||
self.logger.info(f"检查请求头 {header_key}: 找到={header_found}, 必需={required_found}, 名称={found_name}")
|
||||
|
||||
# 根据检查结果添加验证结果
|
||||
if not header_found:
|
||||
results.append(
|
||||
ValidationResult(
|
||||
passed=False,
|
||||
message=f"缺少必需的请求头 {header_key}",
|
||||
details={
|
||||
'header': header_key,
|
||||
'possible_names': possible_names,
|
||||
'endpoint': f"{self.endpoint_spec.get('method')} {self.endpoint_spec.get('path')}"
|
||||
}
|
||||
)
|
||||
)
|
||||
self.logger.warning(f"规范验证失败: 缺少必需的请求头 {header_key}")
|
||||
elif not required_found:
|
||||
results.append(
|
||||
ValidationResult(
|
||||
passed=False,
|
||||
message=f"请求头 {found_name} 存在但未标记为必需",
|
||||
details={
|
||||
'header': header_key,
|
||||
'found_name': found_name,
|
||||
'endpoint': f"{self.endpoint_spec.get('method')} {self.endpoint_spec.get('path')}"
|
||||
}
|
||||
)
|
||||
)
|
||||
self.logger.warning(f"规范验证失败: 请求头 {found_name} 存在但未标记为必需")
|
||||
|
||||
# 如果没有失败的验证结果,添加一个成功的结果
|
||||
if not [r for r in results if not r.passed]:
|
||||
results.append(
|
||||
ValidationResult(
|
||||
passed=True,
|
||||
message=f"所有必需的请求头都已正确定义",
|
||||
details={
|
||||
'found_headers': found_headers,
|
||||
'endpoint': f"{self.endpoint_spec.get('method')} {self.endpoint_spec.get('path')}"
|
||||
}
|
||||
)
|
||||
)
|
||||
self.logger.info(f"规范验证通过: 所有必需的请求头都已正确定义")
|
||||
|
||||
return results
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
+139
@@ -0,0 +1,139 @@
|
||||
from typing import Dict, Any, List, Optional
|
||||
|
||||
from ddms_compliance_suite.test_framework_core import BaseAPITestCase, ValidationResult, APIResponseContext, APIRequestContext, TestSeverity
|
||||
|
||||
class ResponseSchemaFormatCheck(BaseAPITestCase):
|
||||
"""
|
||||
检查API响应的schema格式是否符合{"code":int or number or string,"message":"","data": any}的标准格式
|
||||
"""
|
||||
id = "TC-DMS-CORE-SCHEMA-001"
|
||||
name = "DMS核心存储服务API响应格式检查"
|
||||
description = "验证API响应的schema是否符合标准格式:{'code':int or number or string, 'message':string, 'data': any}"
|
||||
severity = TestSeverity.HIGH
|
||||
tags = ["schema", "format", "dms-core", "response"]
|
||||
|
||||
def __init__(self, endpoint_spec: Dict[str, Any], global_api_spec: Dict[str, Any], json_schema_validator: Optional[Any] = None, llm_service: Optional[Any] = None):
|
||||
super().__init__(endpoint_spec, global_api_spec, json_schema_validator, llm_service=llm_service)
|
||||
|
||||
def validate_response(self, response_context: APIResponseContext, request_context: APIRequestContext) -> List[ValidationResult]:
|
||||
"""
|
||||
仅验证API响应schema的格式,不验证实际响应
|
||||
"""
|
||||
results = []
|
||||
|
||||
# 获取规范中的响应schema
|
||||
status_codes_to_check = ["200", "201", "default"]
|
||||
|
||||
for status_code in status_codes_to_check:
|
||||
response_schema = self._get_response_schema_for_status(status_code)
|
||||
if response_schema:
|
||||
validation_results = self._validate_response_schema_format(response_schema, status_code)
|
||||
results.extend(validation_results)
|
||||
# 找到一个有效的响应schema后就不再继续检查
|
||||
break
|
||||
|
||||
# 如果没有找到任何响应schema,记录为失败
|
||||
if not results:
|
||||
results.append(self.failed(
|
||||
message="无法找到API响应的schema定义,无法验证响应格式。",
|
||||
details={"endpoint": self.endpoint_spec.get("path", "未知")}
|
||||
))
|
||||
|
||||
return results
|
||||
|
||||
def _get_response_schema_for_status(self, status_code: str) -> Optional[Dict[str, Any]]:
|
||||
"""获取指定状态码的响应schema"""
|
||||
responses = self.endpoint_spec.get("responses", {})
|
||||
|
||||
if status_code not in responses:
|
||||
return None
|
||||
|
||||
response_spec = responses[status_code]
|
||||
return self._get_resolved_response_schema(response_spec, status_code)
|
||||
|
||||
def _validate_response_schema_format(self, schema: Dict[str, Any], status_code: str) -> List[ValidationResult]:
|
||||
"""验证响应schema是否符合标准格式"""
|
||||
results = []
|
||||
|
||||
# 如果schema为空,则记录为失败
|
||||
if not schema or not isinstance(schema, dict):
|
||||
results.append(self.failed(
|
||||
message=f"响应schema不是有效的对象: {schema}",
|
||||
details={"status_code": status_code}
|
||||
))
|
||||
return results
|
||||
|
||||
# 解析schema,确保它是一个已解析的schema
|
||||
schema = self._get_resolved_schema(schema)
|
||||
|
||||
# 检查schema是否有properties
|
||||
if "properties" not in schema:
|
||||
results.append(self.failed(
|
||||
message=f"响应schema中缺少'properties'定义",
|
||||
details={"status_code": status_code, "schema": schema}
|
||||
))
|
||||
return results
|
||||
|
||||
properties = schema.get("properties", {})
|
||||
required_fields = schema.get("required", [])
|
||||
|
||||
# 检查必须的字段: code, message, data
|
||||
expected_fields = ["code", "message", "data"]
|
||||
missing_fields = [field for field in expected_fields if field not in properties]
|
||||
|
||||
if missing_fields:
|
||||
results.append(self.failed(
|
||||
message=f"响应schema中缺少必要字段: {', '.join(missing_fields)}",
|
||||
details={
|
||||
"status_code": status_code,
|
||||
"available_fields": list(properties.keys()),
|
||||
"missing_fields": missing_fields
|
||||
}
|
||||
))
|
||||
else:
|
||||
# 检查字段类型
|
||||
type_errors = []
|
||||
|
||||
# 检查code字段类型
|
||||
code_schema = properties.get("code", {})
|
||||
if not( code_schema.get("type") == "integer" or code_schema.get("type") == "number" or code_schema.get("type") == "string"):
|
||||
type_errors.append(f"'code'字段应为integer类型,实际为{code_schema.get('type')}")
|
||||
|
||||
# 检查message字段类型
|
||||
message_schema = properties.get("message", {})
|
||||
if message_schema.get("type") != "string":
|
||||
type_errors.append(f"'message'字段应为string类型,实际为{message_schema.get('type')}")
|
||||
|
||||
# # 检查data字段类型
|
||||
data_schema = properties.get("data", {})
|
||||
# if data_schema.get("type") != "object" and data_schema.get("type") is not None:
|
||||
# type_errors.append(f"'data'字段应为object类型,实际为{data_schema.get('type')}")
|
||||
|
||||
if type_errors:
|
||||
results.append(self.failed(
|
||||
message=f"响应schema中字段类型不符合要求: {'; '.join(type_errors)}",
|
||||
details={
|
||||
"status_code": status_code,
|
||||
"code_schema": code_schema,
|
||||
"message_schema": message_schema,
|
||||
"data_schema": data_schema
|
||||
}
|
||||
))
|
||||
|
||||
# 检查必填字段
|
||||
for field in expected_fields:
|
||||
if field not in required_fields:
|
||||
results.append(ValidationResult(
|
||||
passed=True, # 作为警告而非错误
|
||||
message=f"字段'{field}'在schema中未标记为必填(required)",
|
||||
details={"status_code": status_code, "required_fields": required_fields}
|
||||
))
|
||||
|
||||
# 如果没有错误,则记录为通过
|
||||
if not any(not result.passed for result in results):
|
||||
results.append(self.passed(
|
||||
message="响应schema符合标准格式: {'code':int or number or string, 'message':string, 'data': any}",
|
||||
details={"status_code": status_code}
|
||||
))
|
||||
|
||||
return results
|
||||
@@ -0,0 +1,128 @@
|
||||
import re
|
||||
from typing import Dict, Any, List, Optional
|
||||
|
||||
from ddms_compliance_suite.test_framework_core import BaseAPITestCase, ValidationResult, APIResponseContext, APIRequestContext, TestSeverity
|
||||
|
||||
class URLVersionCheckCase(BaseAPITestCase):
|
||||
"""
|
||||
检查API URL是否包含版本号(如v1, api/v2, v3.0等)并以/api开头
|
||||
"""
|
||||
id = "TC-DMS-URL-VERSION-001"
|
||||
name = "DMS API URL版本号检查"
|
||||
description = "检查API URL是否包含标准格式的版本号,支持的格式包括:v1, api/v2, v3.0, version/1, 1.0等,并且路径需要以/api开头"
|
||||
severity = TestSeverity.MEDIUM
|
||||
tags = ["url", "version", "dms-core", "api-design"]
|
||||
|
||||
# 这个测试用例不需要发送实际请求
|
||||
skip_execution = True
|
||||
|
||||
def __init__(self, endpoint_spec: Dict[str, Any], global_api_spec: Dict[str, Any], json_schema_validator: Optional[Any] = None, llm_service: Optional[Any] = None):
|
||||
super().__init__(endpoint_spec, global_api_spec, json_schema_validator, llm_service=llm_service)
|
||||
|
||||
def validate_response(self, response_context: APIResponseContext, request_context: APIRequestContext) -> List[ValidationResult]:
|
||||
"""
|
||||
检查API URL是否以/api开头并包含版本号
|
||||
"""
|
||||
results = []
|
||||
|
||||
# 获取API路径
|
||||
path = self.endpoint_spec.get('path', '')
|
||||
if not path:
|
||||
results.append(self.failed(
|
||||
message="无法获取API路径",
|
||||
details={"endpoint_spec_keys": list(self.endpoint_spec.keys())}
|
||||
))
|
||||
return results
|
||||
|
||||
# 检查是否是系统级API(可能不需要遵循标准路径格式)
|
||||
is_system_api = re.match(r'^/(health|ping|status|metrics|system)(/|$)', path)
|
||||
|
||||
# 1. 检查路径是否以/api开头
|
||||
starts_with_api = path.startswith('/api/')
|
||||
|
||||
if not starts_with_api and not is_system_api:
|
||||
results.append(self.failed(
|
||||
message=f"API路径 '{path}' 不是以'/api/'开头",
|
||||
details={"full_path": path, "requirement": "路径必须以'/api/'开头"}
|
||||
))
|
||||
elif starts_with_api:
|
||||
results.append(self.passed(
|
||||
message=f"API路径 '{path}' 正确以'/api/'开头",
|
||||
details={"full_path": path}
|
||||
))
|
||||
|
||||
# 2. 检查路径中是否包含版本号
|
||||
version_patterns = [
|
||||
# 标准版本格式: /v1/, /v2/, /v3/ 等
|
||||
r'/v\d+/',
|
||||
# 带小数点的版本: /v1.0/, /v2.1/ 等
|
||||
r'/v\d+\.\d+/',
|
||||
# 使用 'version' 单词: /version/1/, /version/2/ 等
|
||||
r'/version/\d+/',
|
||||
# API前缀版本: /api/v1/, /api/v2/ 等
|
||||
r'/api/v\d+/',
|
||||
# 直接数字版本: /1/, /2/ (仅在特定位置)
|
||||
r'/api/\d+/',
|
||||
# 特殊格式: 如 /v1-beta/, /v2-alpha/ 等
|
||||
r'/v\d+[\-_](alpha|beta|rc\d*)/',
|
||||
# 年份版本: /2023/, /2024/ 等 (仅在特定位置)
|
||||
r'/20\d{2}/',
|
||||
]
|
||||
|
||||
# 检查是否包含版本号
|
||||
matched_pattern = None
|
||||
version_str = None
|
||||
|
||||
for pattern in version_patterns:
|
||||
match = re.search(pattern, path)
|
||||
if match:
|
||||
matched_pattern = pattern
|
||||
version_str = match.group(0).strip('/')
|
||||
break
|
||||
|
||||
if matched_pattern and version_str:
|
||||
results.append(self.passed(
|
||||
message=f"API路径 '{path}' 包含版本标识: '{version_str}'",
|
||||
details={
|
||||
"pattern_matched": matched_pattern,
|
||||
"version_string": version_str,
|
||||
"full_path": path
|
||||
}
|
||||
))
|
||||
else:
|
||||
# 特殊情况:检查是否是根API或系统级API(可能不需要版本号)
|
||||
if is_system_api:
|
||||
results.append(self.passed(
|
||||
message=f"API路径 '{path}' 是系统级API,不需要版本号",
|
||||
details={"full_path": path, "api_type": "system"}
|
||||
))
|
||||
else:
|
||||
results.append(self.failed(
|
||||
message=f"API路径 '{path}' 不包含任何已知格式的版本标识",
|
||||
details={
|
||||
"full_path": path,
|
||||
"supported_patterns": [p.replace('\\d+', 'N').replace('\\d{2}', 'NN') for p in version_patterns]
|
||||
}
|
||||
))
|
||||
|
||||
# 提供改进建议
|
||||
# 确保建议路径始终以/api开头并包含版本号
|
||||
base_path_parts = path.split('/')
|
||||
base_path_parts = [p for p in base_path_parts if p] # 移除空字符串
|
||||
|
||||
if not starts_with_api:
|
||||
# 如果不是以/api开头,建议路径应该是/api/v1/原始路径
|
||||
suggested_path = f"/api/v1/{'/'.join(base_path_parts)}"
|
||||
else:
|
||||
# 如果已经以/api开头但缺少版本号,插入v1在api之后
|
||||
suggested_path = "/api/v1"
|
||||
if len(base_path_parts) > 1: # 有api后面的部分
|
||||
suggested_path += f"/{'/'.join(base_path_parts[1:])}"
|
||||
|
||||
results.append(ValidationResult(
|
||||
passed=False,
|
||||
message=f"建议将路径修改为符合规范的格式,例如: '{suggested_path}'",
|
||||
details={"original_path": path, "suggested_path": suggested_path}
|
||||
))
|
||||
|
||||
return results
|
||||
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
@@ -0,0 +1,40 @@
|
||||
from ddms_compliance_suite.test_framework_core import BaseAPITestCase, ValidationResult, APIResponseContext, APIRequestContext, TestSeverity
|
||||
import re
|
||||
from typing import Dict, Any, List, Optional
|
||||
|
||||
class ResourcePathNounCheckTestCase(BaseAPITestCase):
|
||||
id = "TC-RESTful-002"
|
||||
name = "资源路径名词检查"
|
||||
description = "验证API路径中是否使用名词而非动词来表示资源。"
|
||||
severity = TestSeverity.MEDIUM
|
||||
tags = ["normative", "restful", "url-structure"]
|
||||
def __init__(self, endpoint_spec: Dict[str, Any], global_api_spec: Dict[str, Any], json_schema_validator: Optional[Any] = None, llm_service: Optional[Any] = None):
|
||||
super().__init__(endpoint_spec, global_api_spec, json_schema_validator, llm_service=llm_service)
|
||||
|
||||
# 简单的动词列表,可以根据需要扩展
|
||||
self.common_verbs = {"get", "create", "update", "delete", "post", "put", "add", "remove", "set"}
|
||||
|
||||
def validate_response(self, response_context: APIResponseContext, request_context: APIRequestContext) -> List[ValidationResult]:
|
||||
path = self.endpoint_spec['path']
|
||||
path_segments = [seg for seg in path.split('/') if seg and '{' not in seg]
|
||||
|
||||
is_valid = True
|
||||
offending_verbs = []
|
||||
|
||||
for segment in path_segments:
|
||||
# 移除版本号等非资源路径部分
|
||||
if re.match(r'v\d+', segment):
|
||||
continue
|
||||
|
||||
# 检查分段是否像动词
|
||||
# 为了避免误判 (e.g., /dataset),只对完全匹配的动词进行判断
|
||||
if segment.lower() in self.common_verbs:
|
||||
is_valid = False
|
||||
offending_verbs.append(segment)
|
||||
|
||||
if not is_valid:
|
||||
message = f"路径 '{path}' 中可能包含动词: {', '.join(offending_verbs)},RESTful风格建议资源路径使用名词。"
|
||||
return [self.failed(message, details={'path': path, 'detected_verbs': offending_verbs})]
|
||||
|
||||
message = f"路径 '{path}' 符合资源名词命名规范。"
|
||||
return [self.passed(message)]
|
||||
@@ -0,0 +1,94 @@
|
||||
from ddms_compliance_suite.test_framework_core import BaseAPITestCase, ValidationResult, APIResponseContext, APIRequestContext, TestSeverity
|
||||
import re
|
||||
from typing import Dict, Any, List, Optional
|
||||
from ddms_compliance_suite.utils import schema_utils
|
||||
|
||||
class TimeFormatCheckTestCase(BaseAPITestCase):
|
||||
id = "TC-RESTful-003"
|
||||
name = "时间字段ISO 8601格式检查"
|
||||
description = "验证返回的时间字段是否遵循 ISO 8601 格式。此检查为静态检查,会检查规范中 `format` 为 `date-time` 的字段,以及常见的时间字段名(如 createTime, update_time 等),是否包含推荐的 `pattern`。"
|
||||
severity = TestSeverity.MEDIUM
|
||||
tags = ["normative", "schema", "time-format"]
|
||||
|
||||
def __init__(self, endpoint_spec: Dict[str, Any], global_api_spec: Dict[str, Any], json_schema_validator: Optional[Any] = None, llm_service: Optional[Any] = None):
|
||||
super().__init__(endpoint_spec, global_api_spec, json_schema_validator, llm_service=llm_service)
|
||||
# 推荐的 pattern
|
||||
self.recommended_iso_8601_pattern = r'^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}([+-]\d{2}:\d{2}|Z)$'
|
||||
# 常见时间字段名称(小写,用于不区分大小写匹配)
|
||||
self.time_field_names = {
|
||||
"createtime", "updatetime", "starttime", "endtime", "publishtime", "timestamp",
|
||||
"created_at", "updated_at", "create_time", "update_time", "start_time", "end_time",
|
||||
"gmtcreate", "gmtmodified", "datetime", "date_time", "time"
|
||||
}
|
||||
|
||||
def validate_response(self, response_context: APIResponseContext, request_context: APIRequestContext) -> List[ValidationResult]:
|
||||
results = []
|
||||
# 此检查为静态检查,分析API规范中的响应部分
|
||||
responses = self.endpoint_spec.get('responses', {})
|
||||
for status_code, response_spec in responses.items():
|
||||
# 通常只关心成功的响应
|
||||
if not status_code.startswith('2'):
|
||||
continue
|
||||
|
||||
content = self._get_resolved_schema(response_spec.get('content', {}))
|
||||
if content:
|
||||
for media_type, media_spec in content.items():
|
||||
if 'schema' in media_spec:
|
||||
self._check_schema_properties(media_spec['schema'], results)
|
||||
|
||||
if not results:
|
||||
return [self.passed("在API规范中未找到可供静态检查的时间相关字段(如 format: date-time 或 常见时间字段名)。")]
|
||||
|
||||
return results
|
||||
|
||||
def _check_schema_properties(self, schema, results, path=""):
|
||||
if not schema or not isinstance(schema, dict):
|
||||
return
|
||||
|
||||
# 处理 allOf, oneOf, anyOf
|
||||
for keyword in ['allOf', 'oneOf', 'anyOf']:
|
||||
if keyword in schema:
|
||||
for sub_schema in schema[keyword]:
|
||||
self._check_schema_properties(sub_schema, results, path)
|
||||
|
||||
if 'properties' in schema:
|
||||
for prop_name, prop_spec in schema['properties'].items():
|
||||
prop_spec = self._get_resolved_schema(prop_spec)
|
||||
current_path = f"{path}.{prop_name}" if path else prop_name
|
||||
|
||||
# 检查是否为时间相关字段
|
||||
is_datetime_format = prop_spec.get('format') == 'date-time'
|
||||
is_common_time_name = prop_name.lower() in self.time_field_names
|
||||
|
||||
# 必须是string类型,且满足 (format是date-time) 或 (字段名在常见列表里)
|
||||
if prop_spec.get('type') == 'string' and (is_datetime_format or is_common_time_name):
|
||||
pattern = prop_spec.get('pattern')
|
||||
|
||||
# 确定字段来源以提供更清晰的消息
|
||||
source_reason = ""
|
||||
if is_datetime_format and is_common_time_name:
|
||||
source_reason = f"(format: date-time, name: '{prop_name}')"
|
||||
elif is_datetime_format:
|
||||
source_reason = f"(format: date-time)"
|
||||
else: # is_common_time_name
|
||||
source_reason = f"(name: '{prop_name}')"
|
||||
|
||||
message = f"时间字段 '{current_path}' {source_reason} "
|
||||
if not pattern:
|
||||
results.append(self.failed(
|
||||
message + f"缺少建议的 `pattern` ({self.recommended_iso_8601_pattern}) 来强制执行ISO 8601格式。",
|
||||
details={'field': current_path}
|
||||
))
|
||||
elif pattern != self.recommended_iso_8601_pattern:
|
||||
results.append(self.failed(
|
||||
message + f"其 `pattern` ('{pattern}') 与建议的模式不完全匹配。",
|
||||
details={'field': current_path, 'current_pattern': pattern, 'recommended': self.recommended_iso_8601_pattern}
|
||||
))
|
||||
else:
|
||||
results.append(self.passed(message + "已定义了建议的 `pattern` 用于格式校验。"))
|
||||
|
||||
# 递归检查
|
||||
if 'properties' in prop_spec or 'allOf' in prop_spec or 'oneOf' in prop_spec or 'anyOf' in prop_spec:
|
||||
self._check_schema_properties(prop_spec, results, current_path)
|
||||
elif prop_spec.get('type') == 'array' and 'items' in prop_spec:
|
||||
self._check_schema_properties(self._get_resolved_schema(prop_spec['items']), results, f"{current_path}[]")
|
||||
@@ -0,0 +1,59 @@
|
||||
from ddms_compliance_suite.test_framework_core import BaseAPITestCase, ValidationResult, APIResponseContext, APIRequestContext, TestSeverity
|
||||
import re
|
||||
from typing import Dict, Any, List, Optional
|
||||
# TODO 获取资源的时候复数(get方法list)
|
||||
class ResourceCollectionPluralCheckTestCase(BaseAPITestCase):
|
||||
id = "TC-RESTful-004"
|
||||
name = "资源集合复数命名检查"
|
||||
description = "验证表示资源集合的路径是否使用复数形式。动词(如push、send等)不需要使用复数形式。"
|
||||
severity = TestSeverity.MEDIUM
|
||||
tags = ["normative", "restful", "url-structure"]
|
||||
|
||||
def __init__(self, endpoint_spec: Dict[str, Any], global_api_spec: Dict[str, Any], json_schema_validator: Optional[Any] = None, llm_service: Optional[Any] = None):
|
||||
super().__init__(endpoint_spec, global_api_spec, json_schema_validator, llm_service=llm_service)
|
||||
# 常见的API路径中的动词,这些不需要使用复数形式
|
||||
self.common_verbs = {
|
||||
"push", "send", "publish", "subscribe", "create", "update", "delete",
|
||||
"get", "set", "add", "remove", "search", "query", "find", "calculate",
|
||||
"process", "validate", "verify", "check", "analyze", "export", "import",
|
||||
"upload", "download", "sync", "login", "logout", "register", "activate",
|
||||
"deactivate", "approve", "reject", "cancel", "confirm", "notify"
|
||||
}
|
||||
|
||||
# 已知的单数形式名词,即使不以's'结尾也是正确的
|
||||
self.known_singulars = {
|
||||
"status", "gas", "analysis", "data", "info", "metadata", "media",
|
||||
"equipment", "staff", "fish", "sheep", "deer", "series", "species",
|
||||
"aircraft", "offspring", "feedback", "content", "news"
|
||||
}
|
||||
|
||||
def validate_response(self, response_context: APIResponseContext, request_context: APIRequestContext) -> List[ValidationResult]:
|
||||
path = self.endpoint_spec['path']
|
||||
method = self.endpoint_spec['method']
|
||||
|
||||
# 这个检查通常适用于返回列表的GET请求,或者创建资源的POST请求
|
||||
if method.lower() not in ['get', 'post']:
|
||||
return [self.passed(f"跳过检查:{method} 方法,不适用于资源集合复数检查。")]
|
||||
|
||||
path_segments = [seg for seg in path.strip('/').split('/') if '{' not in seg and not re.match(r'v\d+', seg)]
|
||||
|
||||
if not path_segments:
|
||||
return [self.passed("跳过检查:路径不含有效分段。")]
|
||||
|
||||
resource_segment = path_segments[-1]
|
||||
|
||||
# 检查是否为动词
|
||||
if resource_segment.lower() in self.common_verbs:
|
||||
return [self.passed(f"路径 '{path}' 的最后一个路径分段 '{resource_segment}' 是动词,不需要使用复数形式。")]
|
||||
|
||||
# 检查是否为已知的单数形式名词
|
||||
if resource_segment.lower() in self.known_singulars:
|
||||
return [self.passed(f"路径 '{path}' 的资源名 '{resource_segment}' 是已知的单数形式名词,符合规范。")]
|
||||
|
||||
# 对于其他名词,检查是否使用复数形式
|
||||
if not resource_segment.endswith('s'):
|
||||
message = f"路径 '{path}' 的最后一个路径分段 '{resource_segment}' 可能不是复数形式,建议对资源集合使用复数命名。"
|
||||
return [self.failed(message, details={'path': path, 'segment': resource_segment})]
|
||||
|
||||
message = f"路径 '{path}' 的资源集合命名 '{resource_segment}' 符合复数命名规范。"
|
||||
return [self.passed(message)]
|
||||
@@ -0,0 +1,185 @@
|
||||
import re
|
||||
from typing import Dict, Any, List, Optional
|
||||
|
||||
from ddms_compliance_suite.test_framework_core import BaseAPITestCase, ValidationResult, APIResponseContext, APIRequestContext, TestSeverity
|
||||
from ddms_compliance_suite.utils.common_utils import is_camel_case
|
||||
from ddms_compliance_suite.utils import schema_utils
|
||||
|
||||
class CoreNamingStructureTestCase(BaseAPITestCase):
|
||||
id = "TC-RESTful-001"
|
||||
name = "核心命名与结构规范检查"
|
||||
description = "统一验证API的命名与结构是否遵循规范。包括:1)模块名全小写且用中划线连接;2)URL路径参数使用下划线命名法(snake_case);3)查询参数和请求体字段使用小驼峰命名法(camelCase);4)响应中的空数组为[]而非null;5)数组类型数据被包裹在list字段中。"
|
||||
severity = TestSeverity.HIGH
|
||||
tags = ["normative", "restful", "structure", "naming-convention"]
|
||||
|
||||
def __init__(self, endpoint_spec: Dict[str, Any], global_api_spec: Dict[str, Any], json_schema_validator: Optional[Any] = None, llm_service: Optional[Any] = None):
|
||||
super().__init__(endpoint_spec, global_api_spec, json_schema_validator, llm_service=llm_service)
|
||||
|
||||
def validate_response(self, response_context: APIResponseContext, request_context: APIRequestContext) -> List[ValidationResult]:
|
||||
results = []
|
||||
|
||||
# 静态检查,只分析API规范
|
||||
self._check_module_name(results)
|
||||
self._check_request_params_camel_case(results)
|
||||
|
||||
# 动态检查,需要分析实际响应
|
||||
# 规则 "响应中的空数组为[]而非null" 和 "数组类型数据被包裹在list字段中" 需要在实际调用后进行
|
||||
# 这里我们只对成功的响应进行检查
|
||||
if 200 <= response_context.status_code < 300:
|
||||
self._check_response_array_format(response_context, results)
|
||||
else:
|
||||
results.append(self.passed("跳过响应体检查:非成功状态码,不适用数组格式检查。"))
|
||||
|
||||
return results
|
||||
|
||||
def _check_module_name(self, results: List[ValidationResult]):
|
||||
"""检查模块名是否为全小写且用中划线连接"""
|
||||
path = self.endpoint_spec['path']
|
||||
module_name_match = re.search(r'/api/([^/]+)/', path)
|
||||
if module_name_match:
|
||||
module_name = module_name_match.group(1)
|
||||
# 模块名可以只是小写字母,但如果包含连接符,必须是中划线
|
||||
is_valid = all(c.islower() or c.isdigit() or c == '-' for c in module_name) and '_' not in module_name
|
||||
if is_valid:
|
||||
results.append(self.passed(f"模块名 '{module_name}' 格式正确 (全小写/数字/中划线)。"))
|
||||
else:
|
||||
results.append(self.failed(f"模块名 '{module_name}' 格式不正确。应为全小写字母、数字和中划线的组合。",
|
||||
details={'path': path, 'module': module_name}))
|
||||
else:
|
||||
results.append(self.failed(f"无法从路径 '{path}' 中提取模块名(格式应为 /api/module-name/...)。",
|
||||
details={'path': path}))
|
||||
|
||||
def _check_request_params_camel_case(self, results: List[ValidationResult]):
|
||||
"""检查请求参数命名规范:
|
||||
- URL路径参数应使用下划线命名法(snake_case)
|
||||
- 请求体和查询参数应使用小驼峰命名法(camelCase)
|
||||
- HTTP头部有特殊规范,不检查
|
||||
"""
|
||||
parameters = self.endpoint_spec.get('parameters', [])
|
||||
|
||||
# 定义HTTP头部例外(不检查)
|
||||
header_exceptions = ['Authorization', 'X-Tenant-ID', 'X-Data-Domain', 'tenant-id', 'Content-Type']
|
||||
|
||||
for param in parameters:
|
||||
param_name = param.get('name')
|
||||
param_in = param.get('in')
|
||||
|
||||
# 跳过HTTP头部参数
|
||||
if param_in == 'header' and param_name in header_exceptions:
|
||||
continue
|
||||
|
||||
# 路径参数使用下划线命名法(snake_case)
|
||||
if param_in == 'path':
|
||||
# 下划线命名法:全小写字母、数字和下划线,不允许连续下划线,不能以下划线开头或结尾
|
||||
is_valid_snake_case = re.match(r'^[a-z][a-z0-9_]*$', param_name) is not None and '__' not in param_name and not param_name.endswith('_')
|
||||
if not is_valid_snake_case:
|
||||
results.append(self.failed(f"路径参数 '{param_name}' 不符合下划线命名法(snake_case)规范。应为小写字母、数字和单下划线组合,不能以下划线结尾。",
|
||||
details={'parameter': param_name, 'location': param_in}))
|
||||
# 查询参数使用小驼峰命名法(camelCase)
|
||||
elif param_in == 'query':
|
||||
if not is_camel_case(param_name):
|
||||
results.append(self.failed(f"查询参数 '{param_name}' 不是小驼峰格式。",
|
||||
details={'parameter': param_name, 'location': param_in}))
|
||||
|
||||
# 检查请求体
|
||||
body_schema = self._get_resolved_request_body_schema()
|
||||
if body_schema:
|
||||
self._check_schema_properties_camel_case(body_schema, results)
|
||||
|
||||
def _check_schema_properties_camel_case(self, schema, results, path=""):
|
||||
if not schema or not isinstance(schema, dict):
|
||||
return
|
||||
|
||||
if 'properties' in schema:
|
||||
for prop_name, prop_spec in schema['properties'].items():
|
||||
if not is_camel_case(prop_name):
|
||||
full_path = f"{path}.{prop_name}" if path else prop_name
|
||||
results.append(self.failed(f"请求体字段 '{full_path}' 不是小驼峰格式。",
|
||||
details={'field': full_path}))
|
||||
|
||||
prop_spec_resolved = self._get_resolved_schema(prop_spec)
|
||||
if 'properties' in prop_spec_resolved or 'items' in prop_spec_resolved:
|
||||
self._check_schema_properties_camel_case(prop_spec_resolved, results, f"{path}.{prop_name}" if path else prop_name)
|
||||
|
||||
def _check_response_array_format(self, response_context: APIResponseContext, results: List[ValidationResult]):
|
||||
"""检查响应中的空数组和数组包裹"""
|
||||
json_content = response_context.json_content
|
||||
if json_content is None:
|
||||
# 如果响应体为空,则跳过检查
|
||||
results.append(self.passed("响应体为空,跳过数组格式检查。"))
|
||||
return
|
||||
|
||||
# 检查 "数组类型数据被包裹在list字段中"
|
||||
# 这条规则比较模糊,这里理解为:如果响应体是一个以数组为核心的列表,那么这个数组的key应该是'list'
|
||||
if isinstance(json_content, dict) and len(json_content) > 0:
|
||||
list_keys = [k for k, v in json_content.items() if isinstance(v, list)]
|
||||
if len(list_keys) == 1 and list_keys[0] != 'list':
|
||||
results.append(self.failed(f"响应中包含一个主列表,但其键名 '{list_keys[0]}' 不是 'list'。",
|
||||
details={'keys': list(json_content.keys())}))
|
||||
elif len(list_keys) > 1 and 'list' not in list_keys:
|
||||
results.append(self.failed(f"响应中包含多个列表,但没有一个的键名是 'list'。",
|
||||
details={'keys': list(json_content.keys())}))
|
||||
|
||||
# 检查 "响应中的空数组为[]而非null"
|
||||
# 查找匹配的响应定义时,需要兼容 status_code, status_code_family (e.g., 2XX), 和 default
|
||||
responses = self.endpoint_spec.get('responses', {})
|
||||
self.logger.info(f"responses: {responses}")
|
||||
status_code = response_context.status_code
|
||||
print("status_code: ", status_code)
|
||||
status_code_str = str(status_code)
|
||||
status_code_family = f"{status_code_str[0]}XX" # e.g., "2XX"
|
||||
self.logger.info(f"检查响应定义: status_code={status_code}, 可用响应定义={list(responses.keys())}")
|
||||
|
||||
# 按优先级查找响应定义:精确状态码 > 状态码族 > 默认状态码200 > default
|
||||
response_spec = None
|
||||
print("responses: ", responses)
|
||||
if status_code_str in responses:
|
||||
response_spec = responses[status_code_str]
|
||||
self.logger.info(f"找到精确状态码 {status_code_str} 的响应定义")
|
||||
elif status_code_family in responses:
|
||||
response_spec = responses[status_code_family]
|
||||
self.logger.info(f"找到状态码族 {status_code_family} 的响应定义")
|
||||
elif '200' in responses and 200 <= status_code < 300: # 对于2XX成功响应,尝试使用200定义
|
||||
response_spec = responses['200']
|
||||
self.logger.info(f"未找到状态码 {status_code_str} 的响应定义,使用默认成功状态码200的定义")
|
||||
elif 'default' in responses:
|
||||
response_spec = responses['default']
|
||||
self.logger.info(f"使用default响应定义")
|
||||
|
||||
if not response_spec:
|
||||
results.append(self.passed(f"规范中未找到响应码 {status_code} 或其类别({status_code_family}, default)的匹配定义,跳过空数组与null的检查。"))
|
||||
return
|
||||
|
||||
# 使用基类中定义好的工具函数获取响应schema
|
||||
schema = self._get_resolved_response_schema(response_spec=response_spec)
|
||||
|
||||
if not schema:
|
||||
results.append(self.passed(f"规范中响应码 {status_code} 的定义中未找到Schema,跳过空数组与null的检查。"))
|
||||
return
|
||||
|
||||
self._validate_null_for_array(json_content, schema, results, "")
|
||||
|
||||
def _validate_null_for_array(self, data: Any, schema: Dict[str, Any], results: List[ValidationResult], path: str):
|
||||
if not schema:
|
||||
return
|
||||
|
||||
schema = self._get_resolved_schema(schema)
|
||||
|
||||
if schema.get('type') == 'array' and data is None:
|
||||
results.append(self.failed(f"响应中字段 '{path}' 的值为 null,但其在规范中定义为数组,应返回 []。", details={'field': path}))
|
||||
return
|
||||
|
||||
if isinstance(data, dict) and 'properties' in schema:
|
||||
for prop_name, prop_schema in schema['properties'].items():
|
||||
if prop_name in data:
|
||||
new_path = f"{path}.{prop_name}" if path else prop_name
|
||||
self._validate_null_for_array(data[prop_name], prop_schema, results, new_path)
|
||||
|
||||
elif isinstance(data, list) and 'items' in schema:
|
||||
item_schema = schema.get('items')
|
||||
for i, item in enumerate(data):
|
||||
new_path = f"{path}[{i}]"
|
||||
self._validate_null_for_array(item, item_schema, results, new_path)
|
||||
|
||||
|
||||
|
||||
Vendored
BIN
Binary file not shown.
@@ -0,0 +1,129 @@
|
||||
from ddms_compliance_suite.test_framework_core import BaseAPITestCase, ValidationResult, APIResponseContext, APIRequestContext, TestSeverity
|
||||
from typing import Dict, Any, List, Optional
|
||||
import re
|
||||
|
||||
class PaginationParamsCheckTestCase(BaseAPITestCase):
|
||||
"""
|
||||
检查API请求中是否包含标准分页参数:pageNo、pageSize和isSearchCount
|
||||
只有名称含有"查询"一类,并且不含有"详情"一类的API才应用这个验证
|
||||
"""
|
||||
id = "TC-DMS-PAGINATION-001"
|
||||
name = "分页参数检查"
|
||||
description = "检查API请求参数中是否包含标准分页参数:pageNo、pageSize和isSearchCount。只有名称含有'查询'、'列表'等并且不含有'详情'一类的API才应用此验证。"
|
||||
severity = TestSeverity.MEDIUM
|
||||
tags = ["pagination", "params", "backend-guide"]
|
||||
|
||||
# 这个测试用例不需要发送实际请求
|
||||
skip_execution = True
|
||||
|
||||
@classmethod
|
||||
def applies_to(cls, endpoint_spec: Dict[str, Any], **kwargs) -> bool:
|
||||
"""
|
||||
此测试用例仅适用于满足以下条件的端点:
|
||||
1. 是GET或POST方法。
|
||||
2. API描述中包含分页相关的关键词(如'查询', '列表')。
|
||||
3. API描述中不包含排除的关键词(如'详情')。
|
||||
"""
|
||||
path = endpoint_spec.get('path', '')
|
||||
method = endpoint_spec.get('method', '').lower()
|
||||
|
||||
if method not in ['get', 'post']:
|
||||
return False
|
||||
|
||||
summary = endpoint_spec.get('summary', '')
|
||||
description = endpoint_spec.get('description', '')
|
||||
operation_id = endpoint_spec.get('operationId', '')
|
||||
|
||||
include_keywords = ["查询", "列表", "分页", "page", "list", "query", "search", "find"]
|
||||
exclude_keywords = ["详情", "明细", "detail", "info", "get", "查看"]
|
||||
|
||||
api_description_text = f"{summary} {description} {operation_id} {path}".lower()
|
||||
|
||||
contains_include_keyword = any(keyword.lower() in api_description_text for keyword in include_keywords)
|
||||
contains_exclude_keyword = any(keyword.lower() in api_description_text for keyword in exclude_keywords)
|
||||
|
||||
return contains_include_keyword and not contains_exclude_keyword
|
||||
|
||||
def __init__(self, endpoint_spec: Dict[str, Any], global_api_spec: Dict[str, Any], json_schema_validator: Optional[Any] = None, llm_service: Optional[Any] = None):
|
||||
super().__init__(endpoint_spec, global_api_spec, json_schema_validator, llm_service=llm_service)
|
||||
|
||||
def validate_response(self, response_context: APIResponseContext, request_context: APIRequestContext) -> List[ValidationResult]:
|
||||
"""
|
||||
检查API请求中是否包含标准分页参数
|
||||
"""
|
||||
results = []
|
||||
path = self.endpoint_spec.get('path', '')
|
||||
method = self.endpoint_spec.get('method', '').lower()
|
||||
|
||||
# 初始化检查结果
|
||||
found_page_no = False
|
||||
found_page_size = False
|
||||
found_is_search_count = False
|
||||
|
||||
# 检查查询参数
|
||||
parameters = self.endpoint_spec.get('parameters', [])
|
||||
for param in parameters:
|
||||
param_name = param.get('name', '')
|
||||
param_in = param.get('in', '')
|
||||
|
||||
if param_in == 'query':
|
||||
if param_name == 'pageNo':
|
||||
found_page_no = True
|
||||
elif param_name == 'pageSize':
|
||||
found_page_size = True
|
||||
elif param_name == 'isSearchCount':
|
||||
found_is_search_count = True
|
||||
|
||||
# 检查请求体(如果是POST请求)
|
||||
if method == 'post':
|
||||
request_body = self.endpoint_spec.get('requestBody', {})
|
||||
content = request_body.get('content', {})
|
||||
|
||||
for media_type, media_content in content.items():
|
||||
if 'schema' in media_content:
|
||||
schema = self._get_resolved_schema(media_content['schema'])
|
||||
if 'properties' in schema:
|
||||
properties = schema['properties']
|
||||
|
||||
# 检查请求体属性
|
||||
if 'pageNo' in properties:
|
||||
found_page_no = True
|
||||
|
||||
if 'pageSize' in properties:
|
||||
found_page_size = True
|
||||
|
||||
if 'isSearchCount' in properties:
|
||||
found_is_search_count = True
|
||||
|
||||
# 汇总检查结果
|
||||
if found_page_no and found_page_size and found_is_search_count:
|
||||
results.append(self.passed(
|
||||
message=f"API请求包含所有标准分页参数:pageNo、pageSize和isSearchCount",
|
||||
details={"path": path, "method": method.upper()}
|
||||
))
|
||||
else:
|
||||
# 计算缺失的参数
|
||||
missing_params = []
|
||||
if not found_page_no:
|
||||
missing_params.append("pageNo")
|
||||
if not found_page_size:
|
||||
missing_params.append("pageSize")
|
||||
if not found_is_search_count:
|
||||
missing_params.append("isSearchCount")
|
||||
|
||||
if missing_params:
|
||||
results.append(self.failed(
|
||||
message=f"API请求缺少标准分页参数:{', '.join(missing_params)}",
|
||||
details={
|
||||
"path": path,
|
||||
"method": method.upper(),
|
||||
"missing_params": missing_params,
|
||||
"found_params": {
|
||||
"pageNo": found_page_no,
|
||||
"pageSize": found_page_size,
|
||||
"isSearchCount": found_is_search_count
|
||||
}
|
||||
}
|
||||
))
|
||||
|
||||
return results
|
||||
Reference in New Issue
Block a user