添加error 测试用例,但是测试用例太复杂,还需要优化框架
This commit is contained in:
parent
0e3e721bc0
commit
4180a0ce81
239
README.md
239
README.md
@ -1,239 +0,0 @@
|
||||
# DDMS 合规性验证软件
|
||||
|
||||
本项目旨在开发一套DDMS(领域数据管理服务)合规性验证软件。该软件能够自动化地对注册到DMS平台的第三方DDMS进行一系列检查,包括API接口行为、数据模型的规范性、业务流程的正确性、以及数据质量等,确保其符合DMS平台定义的数据共享标准和技术规范。
|
||||
|
||||
## 项目结构
|
||||
|
||||
```
|
||||
ddms_compliance_suite/
|
||||
├── ddms_compliance_suite/ # Python 包的根目录 (应用核心代码)
|
||||
│ ├── __init__.py
|
||||
│ ├── main.py # 应用主入口
|
||||
│ │
|
||||
│ ├── api_caller/ # API 调用模块
|
||||
│ │ ├── __init__.py
|
||||
│ │ └── caller.py # API 调用逻辑
|
||||
│ │
|
||||
│ ├── assertion_engine/ # 断言引擎模块
|
||||
│ │ ├── __init__.py
|
||||
│ │ └── engine.py # 断言逻辑
|
||||
│ │
|
||||
│ ├── config/ # 配置管理模块
|
||||
│ │ ├── __init__.py
|
||||
│ │ └── manager.py # 配置加载与管理
|
||||
│ │
|
||||
│ ├── input_parser/ # 输入解析模块
|
||||
│ │ ├── __init__.py
|
||||
│ │ └── parser.py # 输入文件解析逻辑
|
||||
│ │
|
||||
│ ├── json_schema_validator/ # JSON Schema 验证模块
|
||||
│ │ ├── __init__.py
|
||||
│ │ └── validator.py # Schema 验证逻辑
|
||||
│ │
|
||||
│ ├── logging_service/ # 日志服务模块
|
||||
│ │ ├── __init__.py
|
||||
│ │ └── logger.py # 日志配置与记录
|
||||
│ │
|
||||
│ ├── models/ # Pydantic 数据模型
|
||||
│ │ ├── __init__.py
|
||||
│ │ ├── base_models.py # 基础模型或通用模型
|
||||
│ │ ├── config_models.py # 应用配置相关模型
|
||||
│ │ ├── rule_models.py # 规则库相关模型
|
||||
│ │ └── validation_models.py # 验证输入/输出相关模型
|
||||
│ │
|
||||
│ ├── report_generator/ # 报告生成模块
|
||||
│ │ ├── __init__.py
|
||||
│ │ └── generator.py # 报告生成逻辑
|
||||
│ │
|
||||
│ ├── rule_repository/ # 规则库模块
|
||||
│ │ ├── __init__.py
|
||||
│ │ ├── repository.py # 规则库核心逻辑
|
||||
│ │ └── adapters/ # 规则存储适配器
|
||||
│ │ ├── __init__.py
|
||||
│ │ ├── base_adapter.py # 适配器基类
|
||||
│ │ └── filesystem_adapter.py # 文件系统适配器
|
||||
│ │
|
||||
│ └── test_executor/ # 测试执行器模块
|
||||
│ ├── __init__.py
|
||||
│ └── executor.py # 测试编排与执行逻辑
|
||||
│
|
||||
├── configs/ # 配置文件存放目录
|
||||
│ └── config.yaml.example # 示例配置文件
|
||||
│
|
||||
├── rules/ # 规则文件存放目录 (JSON格式)
|
||||
│ ├── api_linting_rules/ # API Linting 规则
|
||||
│ │ └── api-naming-convention/
|
||||
│ │ └── 1.0.0.json # 版本化的规则文件
|
||||
│ ├── business_logic_rules/ # 业务逻辑规则/断言模板
|
||||
│ │ └── well-id-format-rule/
|
||||
│ │ └── 1.0.0.json
|
||||
│ ├── data_quality/ # 数据质量校验规则
|
||||
│ │ └── well-depth-range-check/
|
||||
│ │ └── 1.0.0.json
|
||||
│ └── json_schemas/ # JSON Schema 文件
|
||||
│ └── well-data-schema/
|
||||
│ └── 1.0.0.json
|
||||
│
|
||||
├── tests/ # 测试代码目录
|
||||
│ ├── __init__.py
|
||||
│ ├── test_api_caller.py
|
||||
│ ├── test_json_schema_validator.py
|
||||
│ └── test_rule_repository.py
|
||||
│
|
||||
├── README.md # 项目说明文件
|
||||
└── requirements.txt # Python 依赖库列表
|
||||
```
|
||||
|
||||
## 安装
|
||||
|
||||
1. 克隆仓库
|
||||
2. 创建并激活虚拟环境 (推荐):
|
||||
```bash
|
||||
python -m venv venv
|
||||
source venv/bin/activate # macOS/Linux
|
||||
# venv\Scripts\activate # Windows
|
||||
```
|
||||
3. 安装依赖:
|
||||
```bash
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
## 配置
|
||||
|
||||
编辑 `configs/config.yaml` (可以从 `configs/config.yaml.example` 复制开始)。这个配置文件包含了各个模块的设置,特别是规则库模块的配置,例如规则文件的存储路径等。
|
||||
|
||||
## 规则库
|
||||
|
||||
规则库是本系统的核心组件之一,它使用JSON文件格式存储各类规则,包括:
|
||||
|
||||
- **JSON Schema 规则**: 用于验证API请求/响应的数据结构
|
||||
- **业务逻辑断言**: 定义业务规则和验证逻辑
|
||||
- **API Linting 规则**: 用于检查API设计是否符合规范
|
||||
- **数据质量规则**: 用于验证数据的质量和准确性
|
||||
- **Python 代码规则**: 使用 Python 代码实现复杂的验证逻辑
|
||||
|
||||
### 规则文件结构
|
||||
|
||||
规则文件按照类别和版本存储在 `rules/` 目录下。每个规则都有一个唯一的ID和版本号,使用以下目录结构:
|
||||
|
||||
```
|
||||
rules/
|
||||
<规则类别>/
|
||||
<规则ID>/
|
||||
<版本号>.json
|
||||
```
|
||||
|
||||
### 添加新规则
|
||||
|
||||
要添加新规则,只需在适当的目录下创建新的JSON文件并遵循规则模型的结构。每个规则必须包含`id`, `name`, `category`, `version`等基本字段,以及该类别特定的字段。
|
||||
|
||||
参考 `rules/` 目录下的示例规则文件以了解格式要求。
|
||||
|
||||
### Python 代码规则
|
||||
|
||||
Python 代码规则允许您使用 Python 代码编写复杂的验证逻辑,是一种非常灵活的规则类型。示例:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "complex-validation-rule",
|
||||
"name": "复杂验证规则",
|
||||
"description": "使用Python代码实现复杂的验证逻辑",
|
||||
"category": "PythonCode",
|
||||
"version": "1.0.0",
|
||||
"severity": "error",
|
||||
"is_enabled": true,
|
||||
"tags": ["validation"],
|
||||
"target_type": "DataObject",
|
||||
"target_identifier": "Example",
|
||||
"allow_imports": true,
|
||||
"allowed_modules": ["math", "re", "json", "datetime"],
|
||||
"code": "def validate():\n # In this location write validation logic\n return {'is_valid': True, 'message': '验证通过'}"
|
||||
}
|
||||
```
|
||||
|
||||
Python 代码规则的主要字段:
|
||||
|
||||
- **code**: 包含 Python 代码的字符串
|
||||
- **code_file**: 外部Python代码文件的路径(作为code的替代选项)
|
||||
- **entry_function**: 入口函数名(默认为 `validate`)
|
||||
- **expected_parameters**: 规则执行时需要的参数列表
|
||||
- **allow_imports**: 是否允许导入外部模块
|
||||
- **allowed_modules**: 允许导入的模块列表
|
||||
- **timeout**: 代码执行超时时间(秒)
|
||||
|
||||
出于安全考虑,Python 代码在受控环境中执行,对可访问的资源和操作进行限制。代码应定义一个与 `entry_function` 指定名称相同的函数,并在参数列表中包含 `expected_parameters` 中的所有参数。
|
||||
|
||||
#### 从外部文件加载Python代码
|
||||
|
||||
对于较长的复杂Python代码,可以将代码单独存储在外部文件中,而不是嵌入到JSON规则文件里。这样可以提高代码的可读性和维护性。方法如下:
|
||||
|
||||
1. 创建具有 `code_file` 属性而非 `code` 属性的规则文件:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "well-coordinates-validation",
|
||||
"name": "井坐标复杂验证规则",
|
||||
"description": "验证井的坐标数据,包括检查是否在特定区域内、坐标系转换等",
|
||||
"category": "PythonCode",
|
||||
"version": "1.0.0",
|
||||
"severity": "error",
|
||||
"is_enabled": true,
|
||||
"target_type": "DataObject",
|
||||
"target_identifier": "Well",
|
||||
"allow_imports": true,
|
||||
"allowed_modules": ["math", "re", "json", "datetime"],
|
||||
"entry_function": "validate",
|
||||
"expected_parameters": ["well_data"],
|
||||
"timeout": 5,
|
||||
"code_file": "python_code/well-coordinates-validation/1.0.0.py"
|
||||
}
|
||||
```
|
||||
|
||||
2. 在对应的路径创建Python代码文件(相对于rules目录):
|
||||
|
||||
```python
|
||||
# 在 rules/python_code/well-coordinates-validation/1.0.0.py 文件中
|
||||
import math
|
||||
|
||||
def validate():
|
||||
# 在这里编写复杂验证逻辑
|
||||
return {'is_valid': True, 'message': '验证通过'}
|
||||
```
|
||||
|
||||
3. 系统会自动加载并执行外部Python文件中的代码:
|
||||
- 如果规则中指定了`id`和`version`,系统会查找 `rules/python_code/{id}/{version}.py`
|
||||
- 否则,系统会使用 `code_file` 属性中指定的相对路径
|
||||
|
||||
使用外部文件的主要优势:
|
||||
- 更好的代码可读性和组织结构
|
||||
- 支持代码编辑器的语法高亮和自动完成
|
||||
- 便于版本控制和代码审查
|
||||
- 可以更轻松地处理复杂逻辑和较长的代码
|
||||
|
||||
## 运行
|
||||
|
||||
```bash
|
||||
python -m ddms_compliance_suite.main
|
||||
```
|
||||
|
||||
您也可以通过导入模块方式在其他Python代码中使用本软件的功能:
|
||||
|
||||
```python
|
||||
from ddms_compliance_suite.config.manager import ConfigurationManager
|
||||
from ddms_compliance_suite.rule_repository.repository import RuleRepository
|
||||
|
||||
# 加载配置
|
||||
config_manager = ConfigurationManager(config_path="path/to/config.yaml")
|
||||
config = config_manager.get_config()
|
||||
|
||||
# 初始化规则库
|
||||
rule_repo = RuleRepository(config.rule_repository)
|
||||
|
||||
# 查询规则
|
||||
from ddms_compliance_suite.models.rule_models import RuleQuery, TargetType
|
||||
query = RuleQuery(target_type=TargetType.API_RESPONSE, target_identifier="getWellData")
|
||||
rules = rule_repo.query_rules(query)
|
||||
|
||||
# 输出查询到的规则
|
||||
for rule in rules:
|
||||
print(f"Rule: {rule.name} (ID: {rule.id}, Version: {rule.version})")
|
||||
42210
assets/doc/井筒API示例swagger_fixed_noref_processed.json
Normal file
42210
assets/doc/井筒API示例swagger_fixed_noref_processed.json
Normal file
File diff suppressed because it is too large
Load Diff
1225
assets/doc/井筒API示例swagger_fixed_simple_noref_processed.json
Normal file
1225
assets/doc/井筒API示例swagger_fixed_simple_noref_processed.json
Normal file
File diff suppressed because it is too large
Load Diff
36
assets/remove_refs.py
Normal file
36
assets/remove_refs.py
Normal file
@ -0,0 +1,36 @@
|
||||
import json
|
||||
|
||||
def remove_refs_from_dict(data):
|
||||
if isinstance(data, dict):
|
||||
# Create a copy of keys to iterate over, as we might modify the dict
|
||||
keys = list(data.keys())
|
||||
for key in keys:
|
||||
if key == "$ref" or key == "$$ref":
|
||||
del data[key]
|
||||
else:
|
||||
remove_refs_from_dict(data[key])
|
||||
elif isinstance(data, list):
|
||||
for item in data:
|
||||
remove_refs_from_dict(item)
|
||||
return data
|
||||
|
||||
input_file = "doc/井筒API示例swagger_fixed_simple.json"
|
||||
output_file = "doc/井筒API示例swagger_fixed_simple_noref_processed.json" # 输出到新文件
|
||||
|
||||
try:
|
||||
with open(input_file, 'r', encoding='utf-8') as f:
|
||||
json_data = json.load(f)
|
||||
|
||||
processed_data = remove_refs_from_dict(json_data)
|
||||
|
||||
with open(output_file, 'w', encoding='utf-8') as f:
|
||||
json.dump(processed_data, f, ensure_ascii=False, indent=2)
|
||||
|
||||
print(f"成功处理文件,移除了 '$ref' 和 '$$ref' 字段。结果已保存到: {output_file}")
|
||||
|
||||
except FileNotFoundError:
|
||||
print(f"错误: 输入文件未找到: {input_file}")
|
||||
except json.JSONDecodeError:
|
||||
print(f"错误: 输入文件 '{input_file}' 不是有效的 JSON 格式。")
|
||||
except Exception as e:
|
||||
print(f"处理过程中发生错误: {e}")
|
||||
1
custom_testcases/compliance_catalog/__init__.py
Normal file
1
custom_testcases/compliance_catalog/__init__.py
Normal file
@ -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.
|
||||
Binary file not shown.
@ -0,0 +1,81 @@
|
||||
from typing import Dict, Any, Optional, List
|
||||
from ddms_compliance_suite.test_framework_core import BaseAPITestCase, TestSeverity, ValidationResult, APIRequestContext, APIResponseContext
|
||||
import json
|
||||
|
||||
class ResponseSchemaValidationCase(BaseAPITestCase):
|
||||
id = "TC-CORE-FUNC-001"
|
||||
name = "Response Body JSON Schema Validation"
|
||||
description = "验证API响应体是否符合API规范中定义的JSON Schema。"
|
||||
severity = TestSeverity.CRITICAL
|
||||
tags = ["core-functionality", "schema-validation", "output-format"]
|
||||
execution_order = 100 # Default, can be adjusted
|
||||
|
||||
# This test is generally applicable, especially for GET requests or successful POST/PUT.
|
||||
# It might need refinement based on specific endpoint characteristics (e.g., no response body for DELETE)
|
||||
|
||||
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
|
||||
|
||||
# Determine the expected response schema based on method and status code
|
||||
# This logic might need to be more sophisticated depending on how schemas are structured in your API spec (YAPI/Swagger)
|
||||
expected_schema = None
|
||||
response_spec_key = None
|
||||
|
||||
if 'responses' in self.endpoint_spec: # OpenAPI/Swagger style
|
||||
if str(status_code) in self.endpoint_spec['responses']:
|
||||
response_def = self.endpoint_spec['responses'][str(status_code)]
|
||||
if 'content' in response_def and 'application/json' in response_def['content']:
|
||||
expected_schema = response_def['content']['application/json'].get('schema')
|
||||
response_spec_key = f"responses.{status_code}.content.application/json.schema"
|
||||
elif 'default' in self.endpoint_spec['responses']: # Fallback to default response
|
||||
response_def = self.endpoint_spec['responses']['default']
|
||||
if 'content' in response_def and 'application/json' in response_def['content']:
|
||||
expected_schema = response_def['content']['application/json'].get('schema')
|
||||
response_spec_key = f"responses.default.content.application/json.schema"
|
||||
elif 'res_body_type' in self.endpoint_spec and self.endpoint_spec['res_body_type'] == 'json': # YAPI style (simplified)
|
||||
if 'res_body_is_json_schema' in self.endpoint_spec and self.endpoint_spec['res_body_is_json_schema']:
|
||||
if self.endpoint_spec.get('res_body'):
|
||||
try:
|
||||
# YAPI often stores schema as a JSON string
|
||||
expected_schema = json.loads(self.endpoint_spec['res_body'])
|
||||
response_spec_key = "res_body (从JSON字符串解析)"
|
||||
except json.JSONDecodeError as e:
|
||||
self.logger.error(f"从YAPI res_body解析JSON schema失败: {e}")
|
||||
results.append(self.failed(f"无法从YAPI规范解析响应schema: {e}"))
|
||||
return results
|
||||
|
||||
# Only proceed with schema validation if we have a schema and a JSON response body
|
||||
if expected_schema and response_context.json_content is not None:
|
||||
self.logger.info(f"将根据路径 '{response_spec_key or '未知位置'}' 的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 we expected a JSON body (e.g. for successful GET/POST) but got none
|
||||
if expected_schema: # and if a schema was defined
|
||||
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:
|
||||
# If there is a JSON body but no schema was found for successful responses
|
||||
self.logger.info(f"响应包含JSON体,但在API规范中未找到针对状态码 {status_code} 的JSON schema。跳过schema验证。")
|
||||
# Optionally, add an informational validation result:
|
||||
# results.append(ValidationResult(passed=True, message="Response has JSON body, but no schema defined for validation.", details={"status_code": status_code}))
|
||||
elif not expected_schema and response_context.json_content is None:
|
||||
self.logger.info(f"状态码 {status_code} 的响应无JSON体也无定义的schema。跳过schema验证。")
|
||||
|
||||
if not results: # If no specific validation was added (e.g. schema not found but not an error)
|
||||
results.append(self.passed("Schema验证步骤完成(未发现问题,或schema不适用/未为此响应定义)。"))
|
||||
|
||||
return results
|
||||
@ -0,0 +1 @@
|
||||
# This file marks the error_handling directory as a Python package.
|
||||
Binary file not shown.
Binary file not shown.
@ -0,0 +1,296 @@
|
||||
from typing import Dict, Any, Optional, List
|
||||
from ddms_compliance_suite.test_framework_core import BaseAPITestCase, TestSeverity, ValidationResult, APIRequestContext, APIResponseContext
|
||||
import copy
|
||||
import logging
|
||||
|
||||
class MissingRequiredFieldBodyCase(BaseAPITestCase):
|
||||
id = "TC-ERROR-4003-BODY"
|
||||
name = "Error Code 4003 - Missing Required Request Body Field Validation"
|
||||
description = "测试当请求体中缺少API规范定义的必填字段时,API是否按预期返回类似4003的错误(或通用400错误)。"
|
||||
severity = TestSeverity.HIGH
|
||||
tags = ["error-handling", "appendix-b", "4003", "required-fields", "request-body"]
|
||||
execution_order = 210 # Before query, same as original combined
|
||||
|
||||
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.setLevel(logging.DEBUG) # Ensure detailed logging for this class
|
||||
self.removed_field_path: Optional[List[str]] = None # Path to the removed field, e.g., ['level1', 'level2_field']
|
||||
self.original_body_schema: Optional[Dict[str, Any]] = None
|
||||
self._try_find_removable_body_field()
|
||||
|
||||
def _resolve_ref_if_present(self, schema_to_resolve: Dict[str, Any]) -> Dict[str, Any]:
|
||||
ref_value = None
|
||||
if isinstance(schema_to_resolve, dict):
|
||||
if "$ref" in schema_to_resolve:
|
||||
ref_value = schema_to_resolve["$ref"]
|
||||
elif "$$ref" in schema_to_resolve:
|
||||
ref_value = schema_to_resolve["$$ref"]
|
||||
|
||||
if ref_value:
|
||||
self.logger.debug(f"发现引用 '{ref_value}',尝试解析...")
|
||||
try:
|
||||
actual_global_spec_dict = None
|
||||
if hasattr(self.global_api_spec, 'spec') and isinstance(self.global_api_spec.spec, dict):
|
||||
actual_global_spec_dict = self.global_api_spec.spec
|
||||
elif isinstance(self.global_api_spec, dict):
|
||||
actual_global_spec_dict = self.global_api_spec
|
||||
|
||||
if not actual_global_spec_dict:
|
||||
self.logger.warning(f"无法从 self.global_api_spec (类型: {type(self.global_api_spec)}) 获取用于解析引用的字典。")
|
||||
return schema_to_resolve
|
||||
|
||||
resolved_schema = None
|
||||
if ref_value.startswith("#/components/schemas/"):
|
||||
schema_name = ref_value.split("/")[-1]
|
||||
components = actual_global_spec_dict.get("components")
|
||||
if components and isinstance(components.get("schemas"), dict):
|
||||
resolved_schema = components["schemas"].get(schema_name)
|
||||
if resolved_schema and isinstance(resolved_schema, dict):
|
||||
self.logger.info(f"成功从 #/components/schemas/ 解析引用 '{ref_value}'。")
|
||||
return resolved_schema
|
||||
else:
|
||||
self.logger.warning(f"解析引用 '{ref_value}' (路径: #/components/schemas/) 失败:未找到或找到的不是字典: {schema_name}")
|
||||
else:
|
||||
self.logger.warning(f"尝试从 #/components/schemas/ 解析引用 '{ref_value}' 失败:无法找到 'components.schemas' 结构。")
|
||||
|
||||
# 如果从 #/components/schemas/ 未成功解析,尝试 #/definitions/
|
||||
if not resolved_schema and ref_value.startswith("#/definitions/"):
|
||||
schema_name = ref_value.split("/")[-1]
|
||||
definitions = actual_global_spec_dict.get("definitions")
|
||||
if definitions and isinstance(definitions, dict):
|
||||
resolved_schema = definitions.get(schema_name)
|
||||
if resolved_schema and isinstance(resolved_schema, dict):
|
||||
self.logger.info(f"成功从 #/definitions/ 解析引用 '{ref_value}'。")
|
||||
return resolved_schema
|
||||
else:
|
||||
self.logger.warning(f"解析引用 '{ref_value}' (路径: #/definitions/) 失败:未找到或找到的不是字典: {schema_name}")
|
||||
else:
|
||||
self.logger.warning(f"尝试从 #/definitions/ 解析引用 '{ref_value}' 失败:无法找到 'definitions' 结构。")
|
||||
|
||||
if not resolved_schema:
|
||||
self.logger.warning(f"最终未能通过任一已知路径 (#/components/schemas/ 或 #/definitions/) 解析引用 '{ref_value}'。")
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"解析引用 '{ref_value}' 时发生错误: {e}", exc_info=True)
|
||||
return schema_to_resolve # 返回原始 schema 如果不是 ref 或者所有解析尝试都失败
|
||||
|
||||
def _find_required_field_in_schema_recursive(self, current_schema: Dict[str, Any], current_path: List[str]) -> Optional[List[str]]:
|
||||
"""递归查找第一个可移除的必填字段的路径。
|
||||
现在也会查找数组内对象中必填的字段。"""
|
||||
resolved_schema = self._resolve_ref_if_present(current_schema)
|
||||
|
||||
if not isinstance(resolved_schema, dict) or resolved_schema.get("type") != "object":
|
||||
# If not an object schema, cannot have 'required' or 'properties' in the way we expect.
|
||||
return None
|
||||
|
||||
required_fields_at_current_level = resolved_schema.get("required", [])
|
||||
properties = resolved_schema.get("properties", {})
|
||||
self.logger.debug(f"递归查找路径: {current_path}, 当前层级必填字段: {required_fields_at_current_level}, 属性: {list(properties.keys())}")
|
||||
|
||||
# 策略1: 查找当前层级直接声明的必填字段 (简单类型或复杂类型均可)
|
||||
if required_fields_at_current_level and properties:
|
||||
for field_name in required_fields_at_current_level:
|
||||
if field_name in properties:
|
||||
# 任何在 'required' 数组中列出的字段,无论其类型,都可以作为目标
|
||||
# (例如,移除一个必填的整个对象或数组也是一种有效的测试场景)
|
||||
self.logger.info(f"策略1: 在路径 {'.'.join(current_path) if current_path else 'root'} 找到可直接移除的必填字段: '{field_name}'")
|
||||
return current_path + [field_name]
|
||||
|
||||
# 策略2: 如果当前层级没有直接的必填字段可移除,则查找数组属性,看其内部item是否有必填字段
|
||||
# 这种情况下,数组本身可能不是必填的,但如果提供了数组,其item需要满足条件
|
||||
if properties: # 确保有属性可迭代
|
||||
for prop_name, prop_schema_orig in properties.items():
|
||||
prop_schema = self._resolve_ref_if_present(prop_schema_orig) # 解析属性自身的schema (可能也是ref)
|
||||
if isinstance(prop_schema, dict) and prop_schema.get("type") == "array":
|
||||
items_schema_orig = prop_schema.get("items")
|
||||
if isinstance(items_schema_orig, dict):
|
||||
items_schema = self._resolve_ref_if_present(items_schema_orig) # 解析 items 的 schema
|
||||
if isinstance(items_schema, dict) and items_schema.get("type") == "object":
|
||||
item_required_fields = items_schema.get("required", [])
|
||||
item_properties = items_schema.get("properties", {})
|
||||
if item_required_fields and item_properties:
|
||||
first_required_field_in_item = None
|
||||
for req_item_field in item_required_fields:
|
||||
if req_item_field in item_properties: # 确保该必填字段在属性中定义
|
||||
first_required_field_in_item = req_item_field
|
||||
break
|
||||
|
||||
if first_required_field_in_item:
|
||||
self.logger.info(f"策略2: 在数组属性 '{prop_name}' (路径 {'.'.join(current_path) if current_path else 'root'}) 的元素内找到必填字段: '{first_required_field_in_item}'. 将尝试移除路径: {current_path + [prop_name, 0, first_required_field_in_item]}")
|
||||
# 将路径指向数组的第一个元素 (index 0) 内的那个必填字段
|
||||
return current_path + [prop_name, 0, first_required_field_in_item]
|
||||
|
||||
# 策略3: (可选,如果需要更深层次的普通对象递归)
|
||||
# 如果以上策略都未找到,并且希望深入到非必填的子对象中查找,可以启用以下逻辑。
|
||||
# 但这通常不用于"顶层必填字段缺失"的测试目的,除非测试用例目标是验证任意深度的必填。
|
||||
# for prop_name, prop_schema_orig_for_recurse in properties.items():
|
||||
# prop_schema_for_recurse = self._resolve_ref_if_present(prop_schema_orig_for_recurse)
|
||||
# if isinstance(prop_schema_for_recurse, dict) and prop_schema_for_recurse.get("type") == "object":
|
||||
# # 确保不陷入无限循环,例如,如果一个对象属性是可选的但其内部有必填字段
|
||||
# # 这里需要小心,因为我们可能已经检查过当前级别的required字段
|
||||
# # 主要用于当某个对象不是顶层必填,但如果提供了它,它内部又有必填项的场景
|
||||
# # 但这与当前测试用例的 primary goal 可能不完全一致
|
||||
# self.logger.debug(f"策略3: 尝试递归进入对象属性 '{prop_name}' (路径 {'.'.join(current_path)}) (此对象本身在当前层级非必填或已检查)")
|
||||
# found_path_deeper = self._find_required_field_in_schema_recursive(prop_schema_for_recurse, current_path + [prop_name])
|
||||
# if found_path_deeper:
|
||||
# # 确保返回的路径确实比当前路径深,并且该深层路径的父级(即prop_name)不是当前层级已知的必填字段
|
||||
# # (以避免重复发现已被策略1覆盖的场景)
|
||||
# # if prop_name not in required_fields_at_current_level:
|
||||
# self.logger.info(f"策略3: 递归在对象属性 '{prop_name}' (路径 {'.'.join(current_path)}) 中找到必填字段路径: {found_path_deeper}")
|
||||
# return found_path_deeper
|
||||
|
||||
self.logger.debug(f"在路径 {'.'.join(current_path) if current_path else 'root'} 未通过任何策略找到可移除的必填字段。")
|
||||
return None
|
||||
def _try_find_removable_body_field(self):
|
||||
body_schema_to_check: Optional[Dict[str, Any]] = None
|
||||
request_body_spec = self.endpoint_spec.get("requestBody")
|
||||
if request_body_spec and isinstance(request_body_spec, dict):
|
||||
content = request_body_spec.get("content", {})
|
||||
json_schema_entry = content.get("application/json")
|
||||
if json_schema_entry and isinstance(json_schema_entry, dict) and isinstance(json_schema_entry.get("schema"), dict):
|
||||
body_schema_to_check = json_schema_entry["schema"]
|
||||
|
||||
if not body_schema_to_check:
|
||||
parameters = self.endpoint_spec.get("parameters", [])
|
||||
if isinstance(parameters, list):
|
||||
for param in parameters:
|
||||
if isinstance(param, dict) and param.get("in") == "body":
|
||||
if isinstance(param.get("schema"), dict):
|
||||
body_schema_to_check = param["schema"]
|
||||
break
|
||||
|
||||
if body_schema_to_check:
|
||||
self.original_body_schema = copy.deepcopy(body_schema_to_check)
|
||||
self.removed_field_path = self._find_required_field_in_schema_recursive(self.original_body_schema, [])
|
||||
if self.removed_field_path:
|
||||
self.logger.info(f"必填字段缺失测试的目标字段 (请求体): '{'.'.join(map(str, self.removed_field_path))}'")
|
||||
self.field_to_remove_details = {
|
||||
"path": self.removed_field_path,
|
||||
# ... existing code ...
|
||||
}
|
||||
else:
|
||||
self.logger.info('在请求体 schema 中未找到可用于测试 "必填字段缺失" 的字段。')
|
||||
else:
|
||||
self.logger.info('此端点规范中未定义请求体 schema。')
|
||||
|
||||
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.")
|
||||
return current_body
|
||||
|
||||
if current_body is None:
|
||||
self.logger.debug("current_body is None. Orchestrator should ideally provide a base body. Attempting to build minimal structure for removal.")
|
||||
new_body = {}
|
||||
else:
|
||||
new_body = copy.deepcopy(current_body)
|
||||
|
||||
temp_obj_ref = new_body
|
||||
|
||||
try:
|
||||
for i, key_or_index in enumerate(self.removed_field_path):
|
||||
is_last_element = (i == len(self.removed_field_path) - 1)
|
||||
|
||||
if is_last_element:
|
||||
if isinstance(key_or_index, str): # Key for a dictionary (field name)
|
||||
if isinstance(temp_obj_ref, dict) and key_or_index in temp_obj_ref:
|
||||
original_value = temp_obj_ref.pop(key_or_index)
|
||||
self.logger.info(f"为进行必填字段缺失测试,已从请求体中移除字段路径 '{'.'.join(map(str,self.removed_field_path))}' (原值: '{original_value}')。")
|
||||
return new_body
|
||||
elif isinstance(temp_obj_ref, dict): # Key not in dict, but it's a dict
|
||||
self.logger.warning(f"计划移除的请求体字段路径的最后一部分 '{key_or_index}' (string key) 在对象中未找到,但该对象是字典。可能该字段本就是可选的或不存在于提供的current_body。路径: {'.'.join(map(str,self.removed_field_path))}")
|
||||
return new_body
|
||||
else: # temp_obj_ref is not a dict
|
||||
self.logger.warning(f"计划移除的请求体字段路径的最后一部分 '{key_or_index}' (string key) 期望父级是字典,但找到 {type(temp_obj_ref)}。路径: {'.'.join(map(str,self.removed_field_path))}")
|
||||
return current_body
|
||||
else: # Last element of path is an index - this should not happen as we remove a *field name*
|
||||
self.logger.error(f"路径的最后一部分 '{key_or_index}' 预期为字符串字段名,但类型为 {type(key_or_index)}. Path: {'.'.join(map(str,self.removed_field_path))}")
|
||||
return current_body
|
||||
else: # Not the last element, so we are traversing or building the structure
|
||||
next_key_or_index = self.removed_field_path[i+1]
|
||||
|
||||
if isinstance(key_or_index, str): # Current path part is a dictionary key
|
||||
if not isinstance(temp_obj_ref, dict):
|
||||
self.logger.warning(f"路径期望字典,但在 '{key_or_index}' (父级)处找到 {type(temp_obj_ref)}. Path: {'.'.join(map(str,self.removed_field_path))}. 如果current_body为None,则尝试创建字典。")
|
||||
if temp_obj_ref is new_body and not new_body :
|
||||
temp_obj_ref = {}
|
||||
else:
|
||||
return current_body
|
||||
|
||||
if isinstance(next_key_or_index, int):
|
||||
if key_or_index not in temp_obj_ref or not isinstance(temp_obj_ref.get(key_or_index), list):
|
||||
self.logger.debug(f"路径 '{key_or_index}' 需要是列表 (为索引 {next_key_or_index} 做准备),但未找到或类型不符。将创建空列表。")
|
||||
temp_obj_ref[key_or_index] = []
|
||||
temp_obj_ref = temp_obj_ref[key_or_index]
|
||||
else:
|
||||
if key_or_index not in temp_obj_ref or not isinstance(temp_obj_ref.get(key_or_index), dict):
|
||||
self.logger.debug(f"路径 '{key_or_index}' 需要是字典 (为键 '{next_key_or_index}' 做准备),但未找到或类型不符。将创建空字典。")
|
||||
temp_obj_ref[key_or_index] = {}
|
||||
temp_obj_ref = temp_obj_ref[key_or_index]
|
||||
|
||||
elif isinstance(key_or_index, int):
|
||||
if not isinstance(temp_obj_ref, list):
|
||||
self.logger.error(f"路径期望列表以应用索引 '{key_or_index}',但找到 {type(temp_obj_ref)}. Path: {'.'.join(map(str,self.removed_field_path))}")
|
||||
return current_body
|
||||
|
||||
while len(temp_obj_ref) <= key_or_index:
|
||||
self.logger.debug(f"数组在索引 {key_or_index} 处需要元素,将添加空字典作为占位符(因为后续预期是字段名)。")
|
||||
temp_obj_ref.append({})
|
||||
|
||||
if isinstance(next_key_or_index, str):
|
||||
if not isinstance(temp_obj_ref[key_or_index], dict):
|
||||
self.logger.debug(f"数组项 at index {key_or_index} 需要是字典 (为键 '{next_key_or_index}' 做准备)。如果它是其他类型,将被替换为空字典。")
|
||||
temp_obj_ref[key_or_index] = {}
|
||||
|
||||
temp_obj_ref = temp_obj_ref[key_or_index]
|
||||
|
||||
else:
|
||||
self.logger.error(f"路径部分 '{key_or_index}' 类型未知 ({type(key_or_index)}). Path: {'.'.join(map(str,self.removed_field_path))}")
|
||||
return current_body
|
||||
except Exception as e: # Ensuring the try has an except
|
||||
self.logger.error(f"在准备移除字段路径 '{'.'.join(map(str,self.removed_field_path))}' 时发生错误: {e}", exc_info=True)
|
||||
return current_body
|
||||
|
||||
self.logger.error(f"generate_request_body 未能在循环内按预期返回。路径: {'.'.join(map(str,self.removed_field_path))}")
|
||||
return current_body
|
||||
|
||||
def validate_response(self, response_context: APIResponseContext, request_context: APIRequestContext) -> List[ValidationResult]:
|
||||
results = []
|
||||
if not self.removed_field_path:
|
||||
results.append(self.passed("跳过测试:在API规范中未找到合适的必填请求体字段用于移除测试。"))
|
||||
self.logger.info("由于未识别到可移除的必填请求体字段,跳过此测试用例。")
|
||||
return results
|
||||
|
||||
status_code = response_context.status_code
|
||||
json_content = response_context.json_content
|
||||
expected_status_codes = [400, 422]
|
||||
specific_error_code_from_appendix_b = "4003"
|
||||
removed_field_str = '.'.join(map(str, self.removed_field_path))
|
||||
|
||||
msg_prefix = f"当移除必填请求体字段 '{removed_field_str}' 时,"
|
||||
|
||||
if status_code in expected_status_codes:
|
||||
status_msg = f"{msg_prefix}API响应了预期的错误状态码 {status_code}。"
|
||||
if json_content and isinstance(json_content, dict) and str(json_content.get("code")) == specific_error_code_from_appendix_b:
|
||||
results.append(self.passed(f"{status_msg} 且响应体中包含特定的错误码 '{specific_error_code_from_appendix_b}'。"))
|
||||
self.logger.info(f"正确接收到状态码 {status_code} 和错误码 '{specific_error_code_from_appendix_b}'。")
|
||||
elif json_content and isinstance(json_content, dict) and "code" in json_content:
|
||||
results.append(ValidationResult(passed=True,
|
||||
message=f"{status_msg} 响应体中的错误码为 '{json_content.get('code')}' (期望或类似 '{specific_error_code_from_appendix_b}')。",
|
||||
details=json_content
|
||||
))
|
||||
self.logger.warning(f"接收到状态码 {status_code},但错误码是 '{json_content.get('code')}' 而不是期望的 '{specific_error_code_from_appendix_b}'。此结果仍标记为通过,因状态码正确。")
|
||||
else:
|
||||
results.append(self.passed(f"{status_msg} 但响应体中未找到特定的错误码字段或响应体结构不符合预期。"))
|
||||
self.logger.info(f"正确接收到状态码 {status_code},但在响应体中未找到错误码字段或预期结构。")
|
||||
else:
|
||||
results.append(self.failed(
|
||||
message=f"{msg_prefix}期望API返回状态码 {expected_status_codes} 中的一个,但实际收到 {status_code}。",
|
||||
details={"status_code": status_code, "response_body": json_content, "removed_field": f"body.{removed_field_str}"}
|
||||
))
|
||||
self.logger.warning(f"必填请求体字段缺失测试失败:期望状态码 {expected_status_codes},实际为 {status_code}。移除的字段:'body.{removed_field_str}'")
|
||||
|
||||
return results
|
||||
@ -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 copy
|
||||
|
||||
class MissingRequiredFieldQueryCase(BaseAPITestCase):
|
||||
id = "TC-ERROR-4003-QUERY"
|
||||
name = "Error Code 4003 - Missing Required Query Parameter Validation"
|
||||
description = "测试当请求中缺少API规范定义的必填查询参数时,API是否按预期返回类似4003的错误(或通用400错误)。"
|
||||
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
|
||||
|
||||
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.removed_field_name: Optional[str] = None
|
||||
self._try_find_removable_query_param()
|
||||
|
||||
def _try_find_removable_query_param(self):
|
||||
query_params_spec_list = self.endpoint_spec.get("parameters", [])
|
||||
if query_params_spec_list:
|
||||
self.logger.debug(f"检查查询参数的必填字段,总共 {len(query_params_spec_list)} 个参数定义。")
|
||||
for param_spec in query_params_spec_list:
|
||||
if isinstance(param_spec, dict) and param_spec.get("in") == "query" and param_spec.get("required") is True:
|
||||
field_name = param_spec.get("name")
|
||||
if field_name:
|
||||
self.removed_field_name = field_name
|
||||
self.logger.info(f"必填字段缺失测试的目标字段 (查询参数): '{self.removed_field_name}'")
|
||||
return
|
||||
self.logger.info('在此端点规范中未找到可用于测试 "必填查询参数缺失" 的字段。')
|
||||
|
||||
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.removed_field_name and isinstance(current_query_params, dict):
|
||||
if self.removed_field_name in current_query_params:
|
||||
new_params = copy.deepcopy(current_query_params)
|
||||
original_value = new_params.pop(self.removed_field_name) # 移除参数
|
||||
self.logger.info(f"为进行必填查询参数缺失测试,已从查询参数中移除 '{self.removed_field_name}' (原值: '{original_value}')。")
|
||||
return new_params
|
||||
else:
|
||||
self.logger.warning(f"计划移除的查询参数 '{self.removed_field_name}' 在当前查询参数中未找到。")
|
||||
return current_query_params
|
||||
|
||||
def validate_response(self, response_context: APIResponseContext, request_context: APIRequestContext) -> List[ValidationResult]:
|
||||
results = []
|
||||
|
||||
if not self.removed_field_name:
|
||||
results.append(self.passed("跳过测试:在API规范中未找到合适的必填查询参数用于移除测试。"))
|
||||
self.logger.info("由于未识别到可移除的必填查询参数,跳过此测试用例。")
|
||||
return results
|
||||
|
||||
status_code = response_context.status_code
|
||||
json_content = response_context.json_content
|
||||
|
||||
expected_status_codes = [400, 422]
|
||||
specific_error_code_from_appendix_b = "4003"
|
||||
|
||||
msg_prefix = f"当移除必填查询参数 '{self.removed_field_name}' 时,"
|
||||
|
||||
if status_code in expected_status_codes:
|
||||
status_msg = f"{msg_prefix}API响应了预期的错误状态码 {status_code}。"
|
||||
if json_content and isinstance(json_content, dict) and str(json_content.get("code")) == specific_error_code_from_appendix_b:
|
||||
results.append(self.passed(f"{status_msg} 且响应体中包含特定的错误码 '{specific_error_code_from_appendix_b}'。"))
|
||||
self.logger.info(f"正确接收到状态码 {status_code} 和错误码 '{specific_error_code_from_appendix_b}'。")
|
||||
elif json_content and isinstance(json_content, dict) and "code" in json_content:
|
||||
results.append(ValidationResult(passed=True,
|
||||
message=f"{status_msg} 响应体中的错误码为 '{json_content.get('code')}' (期望或类似 '{specific_error_code_from_appendix_b}')。",
|
||||
details=json_content
|
||||
))
|
||||
self.logger.warning(f"接收到状态码 {status_code},但错误码是 '{json_content.get('code')}' 而不是期望的 '{specific_error_code_from_appendix_b}'。此结果仍标记为通过,因状态码正确。")
|
||||
else:
|
||||
results.append(self.passed(f"{status_msg} 但响应体中未找到特定的错误码字段或响应体结构不符合预期。"))
|
||||
self.logger.info(f"正确接收到状态码 {status_code},但在响应体中未找到错误码字段或预期结构。")
|
||||
else:
|
||||
results.append(self.failed(
|
||||
message=f"{msg_prefix}期望API返回状态码 {expected_status_codes} 中的一个,但实际收到 {status_code}。",
|
||||
details={"status_code": status_code, "response_body": json_content, "removed_field": f"query.{self.removed_field_name}"}
|
||||
))
|
||||
self.logger.warning(f"必填查询参数缺失测试失败:期望状态码 {expected_status_codes},实际为 {status_code}。移除的参数:'{self.removed_field_name}'")
|
||||
|
||||
return results
|
||||
@ -0,0 +1,386 @@
|
||||
from typing import Dict, Any, Optional, List
|
||||
from ddms_compliance_suite.test_framework_core import BaseAPITestCase, TestSeverity, ValidationResult, APIRequestContext, APIResponseContext
|
||||
import copy
|
||||
import logging
|
||||
|
||||
class TypeMismatchBodyCase(BaseAPITestCase):
|
||||
id = "TC-ERROR-4001-BODY"
|
||||
name = "Error Code 4001 - Request Body Type Mismatch Validation"
|
||||
description = "测试当发送的请求体中字段的数据类型与API规范定义不符时,API是否按预期返回类似4001的错误(或通用400错误)。"
|
||||
severity = TestSeverity.MEDIUM
|
||||
tags = ["error-handling", "appendix-b", "4001", "request-body"]
|
||||
execution_order = 202 # Slightly after query param one
|
||||
|
||||
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.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.logger.critical(f"{self.id} __INIT__ >>> STARTED")
|
||||
self.logger.debug(f"开始为端点 {self.endpoint_spec.get('method')} {self.endpoint_spec.get('path')} 初始化请求体类型不匹配测试的目标字段查找。")
|
||||
|
||||
body_schema_to_check: Optional[Dict[str, Any]] = None
|
||||
|
||||
# 优先尝试从顶层 'requestBody' (OpenAPI 3.0 style) 获取 schema
|
||||
request_body_spec = self.endpoint_spec.get("requestBody")
|
||||
if request_body_spec and isinstance(request_body_spec, dict):
|
||||
content = request_body_spec.get("content", {})
|
||||
json_schema_entry = content.get("application/json") # 或者其他相关mime-type
|
||||
if json_schema_entry and isinstance(json_schema_entry, dict) and isinstance(json_schema_entry.get("schema"), dict):
|
||||
body_schema_to_check = json_schema_entry["schema"]
|
||||
self.logger.debug(f"从顶层 'requestBody' 中获取到 schema: {list(body_schema_to_check.keys())}")
|
||||
|
||||
# 如果顶层 'requestBody' 未提供有效 schema,则尝试从 'parameters' 列表 (Swagger 2.0 style for 'in: body') 查找
|
||||
if not body_schema_to_check:
|
||||
self.logger.debug(f"未从顶层 'requestBody' 找到 schema,尝试从 'parameters' 列表查找 'in: body' 参数。")
|
||||
parameters = self.endpoint_spec.get("parameters", [])
|
||||
if isinstance(parameters, list):
|
||||
for param in parameters:
|
||||
if isinstance(param, dict) and param.get("in") == "body":
|
||||
if isinstance(param.get("schema"), dict):
|
||||
body_schema_to_check = param["schema"]
|
||||
self.logger.debug(f"从 'parameters' 列表中找到 'in: body' 参数的 schema: {list(body_schema_to_check.keys())}")
|
||||
break # 找到第一个 'in: body' 参数即可
|
||||
else:
|
||||
self.logger.warning(f"找到 'in: body' 参数 '{param.get('name', 'N/A')}',但其 'schema' 字段无效或缺失。")
|
||||
else:
|
||||
self.logger.warning("'parameters' 字段不是列表或不存在。")
|
||||
|
||||
|
||||
if body_schema_to_check:
|
||||
self.logger.debug(f"最终用于检查的请求体 schema: {list(body_schema_to_check.keys())}")
|
||||
if self._find_target_field_in_schema(body_schema_to_check, base_path_for_log=""): # base_path_for_log 为空字符串代表 schema 的根
|
||||
self.logger.info(f"类型不匹配测试的目标字段(请求体): {'.'.join(str(p) for p in self.target_field_path) if self.target_field_path else 'N/A'},原始类型: {self.original_field_type}")
|
||||
else:
|
||||
self.logger.debug(f"在提供的请求体 schema ({list(body_schema_to_check.keys())}) 中未找到适合类型不匹配测试的字段。")
|
||||
else:
|
||||
self.logger.debug("在此端点规范中未找到有效的请求体 schema 定义 (无论是通过 'requestBody' 还是 'parameters' in:body)。")
|
||||
|
||||
if not self.target_field_path:
|
||||
self.logger.info(f"最终,在端点 {self.endpoint_spec.get('method')} {self.endpoint_spec.get('path')} 的请求体中,均未找到可用于测试类型不匹配的字段。")
|
||||
|
||||
def _resolve_ref_if_present(self, schema_to_resolve: Dict[str, Any]) -> Dict[str, Any]:
|
||||
ref_value = None
|
||||
if isinstance(schema_to_resolve, dict):
|
||||
if "$ref" in schema_to_resolve:
|
||||
ref_value = schema_to_resolve["$ref"]
|
||||
elif "$$ref" in schema_to_resolve:
|
||||
ref_value = schema_to_resolve["$$ref"]
|
||||
|
||||
if ref_value:
|
||||
self.logger.debug(f"发现引用 '{ref_value}',尝试解析...")
|
||||
try:
|
||||
actual_global_spec_dict = None
|
||||
if hasattr(self.global_api_spec, 'spec') and isinstance(self.global_api_spec.spec, dict):
|
||||
actual_global_spec_dict = self.global_api_spec.spec
|
||||
elif isinstance(self.global_api_spec, dict):
|
||||
actual_global_spec_dict = self.global_api_spec
|
||||
|
||||
if not actual_global_spec_dict:
|
||||
self.logger.warning(f"无法从 self.global_api_spec (类型: {type(self.global_api_spec)}) 获取用于解析引用的字典。")
|
||||
return schema_to_resolve
|
||||
|
||||
resolved_schema = None
|
||||
if ref_value.startswith("#/components/schemas/"):
|
||||
schema_name = ref_value.split("/")[-1]
|
||||
components = actual_global_spec_dict.get("components")
|
||||
if components and isinstance(components.get("schemas"), dict):
|
||||
resolved_schema = components["schemas"].get(schema_name)
|
||||
if resolved_schema and isinstance(resolved_schema, dict):
|
||||
self.logger.info(f"成功从 #/components/schemas/ 解析引用 '{ref_value}'。")
|
||||
return resolved_schema
|
||||
else:
|
||||
self.logger.warning(f"解析引用 '{ref_value}' (路径: #/components/schemas/) 失败:未找到或找到的不是字典: {schema_name}")
|
||||
else:
|
||||
self.logger.warning(f"尝试从 #/components/schemas/ 解析引用 '{ref_value}' 失败:无法找到 'components.schemas' 结构。")
|
||||
|
||||
if not resolved_schema and ref_value.startswith("#/definitions/"):
|
||||
schema_name = ref_value.split("/")[-1]
|
||||
definitions = actual_global_spec_dict.get("definitions")
|
||||
if definitions and isinstance(definitions, dict):
|
||||
resolved_schema = definitions.get(schema_name)
|
||||
if resolved_schema and isinstance(resolved_schema, dict):
|
||||
self.logger.info(f"成功从 #/definitions/ 解析引用 '{ref_value}'。")
|
||||
return resolved_schema
|
||||
else:
|
||||
self.logger.warning(f"解析引用 '{ref_value}' (路径: #/definitions/) 失败:未找到或找到的不是字典: {schema_name}")
|
||||
else:
|
||||
self.logger.warning(f"尝试从 #/definitions/ 解析引用 '{ref_value}' 失败:无法找到 'definitions' 结构。")
|
||||
|
||||
if not resolved_schema:
|
||||
self.logger.warning(f"最终未能通过任一已知路径 (#/components/schemas/ 或 #/definitions/) 解析引用 '{ref_value}'。")
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"解析引用 '{ref_value}' 时发生错误: {e}", exc_info=True)
|
||||
return schema_to_resolve
|
||||
|
||||
def _find_target_field_in_schema(self, schema_to_search: Dict[str, Any], base_path_for_log: str) -> bool:
|
||||
"""
|
||||
Recursively searches for a simple type field (string, integer, number, boolean) within a schema.
|
||||
Sets self.target_field_path, self.original_field_type, and self.target_field_schema if found.
|
||||
base_path_for_log is used to build the full path for logging.
|
||||
Returns True if a field is found, False otherwise.
|
||||
"""
|
||||
self.logger.debug(f"Enter _find_target_field_in_schema for base_path: '{base_path_for_log}', schema_to_search keys: {list(schema_to_search.keys()) if isinstance(schema_to_search, dict) else 'Not a dict'}")
|
||||
resolved_schema = self._resolve_ref_if_present(schema_to_search)
|
||||
|
||||
if not isinstance(resolved_schema, dict):
|
||||
self.logger.debug(f"_find_target_field_in_schema: Schema at '{base_path_for_log}' is not a dict after resolution. Schema: {resolved_schema}")
|
||||
return False
|
||||
|
||||
schema_type = resolved_schema.get("type")
|
||||
self.logger.debug(f"Path: '{base_path_for_log}', Resolved Schema Type: '{schema_type}', Keys: {list(resolved_schema.keys())}")
|
||||
|
||||
if schema_type == "object":
|
||||
properties = resolved_schema.get("properties", {})
|
||||
self.logger.debug(f"Path: '{base_path_for_log}', Type is 'object'. Checking properties: {list(properties.keys())}")
|
||||
for name, prop_schema_orig in properties.items():
|
||||
current_path_str = f"{base_path_for_log}.{name}" if base_path_for_log else name
|
||||
self.logger.debug(f"Path: '{current_path_str}', Property Schema (Original): {prop_schema_orig}")
|
||||
prop_schema_resolved = self._resolve_ref_if_present(prop_schema_orig)
|
||||
self.logger.debug(f"Path: '{current_path_str}', Property Schema (Resolved): {prop_schema_resolved}")
|
||||
|
||||
if not isinstance(prop_schema_resolved, dict):
|
||||
self.logger.debug(f"Path: '{current_path_str}', Resolved schema is not a dict. Skipping.")
|
||||
continue
|
||||
|
||||
prop_type = prop_schema_resolved.get("type")
|
||||
self.logger.debug(f"Path: '{current_path_str}', Resolved Property Type: '{prop_type}'")
|
||||
|
||||
if prop_type in ["string", "integer", "number", "boolean"]:
|
||||
# Construct path relative to the initial body schema
|
||||
path_parts = base_path_for_log.split('.') if base_path_for_log else []
|
||||
if path_parts == ['']: path_parts = [] # Handle initial empty base_path
|
||||
self.target_field_path = path_parts + [name]
|
||||
self.original_field_type = prop_type
|
||||
self.target_field_schema = prop_schema_resolved
|
||||
self.logger.info(f"目标字段(请求体): '{current_path_str}' (原始类型: '{prop_type}') FOUND!")
|
||||
return True
|
||||
elif prop_type == "object":
|
||||
self.logger.debug(f"Path: '{current_path_str}', Type is 'object'. Recursing...")
|
||||
if self._find_target_field_in_schema(prop_schema_resolved, current_path_str):
|
||||
return True
|
||||
self.logger.debug(f"Path: '{current_path_str}', Recursion for object did not find target.")
|
||||
elif prop_type == "array":
|
||||
self.logger.debug(f"Path: '{current_path_str}', Type is 'array'. Inspecting items...")
|
||||
items_schema = prop_schema_resolved.get("items")
|
||||
if isinstance(items_schema, dict):
|
||||
self.logger.debug(f"Path: '{current_path_str}', Array items schema is a dict. Resolving and checking item type.")
|
||||
items_schema_resolved = self._resolve_ref_if_present(items_schema)
|
||||
item_type = items_schema_resolved.get("type")
|
||||
self.logger.debug(f"Path: '{current_path_str}[*]', Resolved Item Type: '{item_type}'")
|
||||
if item_type in ["string", "integer", "number", "boolean"]:
|
||||
path_parts = base_path_for_log.split('.') if base_path_for_log else []
|
||||
if path_parts == ['']: path_parts = []
|
||||
self.target_field_path = path_parts + [name, 0] # Path like field.array_field.0
|
||||
self.original_field_type = item_type
|
||||
self.target_field_schema = items_schema_resolved # schema for the item, not the array
|
||||
self.logger.info(f"目标字段(请求体 - 数组内简单类型): '{current_path_str}[0]' (原始类型: '{item_type}') FOUND!")
|
||||
return True
|
||||
elif item_type == "object":
|
||||
self.logger.debug(f"Path: '{current_path_str}[*]', Item type is 'object'. Recursing into array item schema...")
|
||||
# Path for recursion: current_path_str + ".0" (representing first item)
|
||||
if self._find_target_field_in_schema(items_schema_resolved, f"{current_path_str}.0"):
|
||||
# self.target_field_path would be set by recursive call.
|
||||
# The path logic in _find_target_field_in_schema needs to correctly prepend array index if it comes from array item recursion.
|
||||
# Let's ensure the path construction at "FOUND!" handles this.
|
||||
# If current_path_str was "field.array.0" and recursion found "nested_prop",
|
||||
# the path should become "field.array.0.nested_prop".
|
||||
# The recursive call sets target_field_path starting from its base_path_for_log.
|
||||
# So if base_path_for_log was "field.array.0", and it found "item_prop",
|
||||
# self.target_field_path will be ["field", "array", 0, "item_prop"]. This seems correct.
|
||||
self.logger.info(f"目标字段(请求体 - 数组内对象属性) found via recursion from '{current_path_str}.0'")
|
||||
return True
|
||||
self.logger.debug(f"Path: '{current_path_str}[*]', Recursion for array item object did not find target.")
|
||||
else:
|
||||
self.logger.debug(f"Path: '{current_path_str}[*]', Item type '{item_type}' is not simple or object.")
|
||||
else:
|
||||
self.logger.debug(f"Path: '{current_path_str}', Array items schema is not a dict or missing. Items: {items_schema}")
|
||||
else:
|
||||
self.logger.debug(f"Path: '{current_path_str}', Property type '{prop_type}' is not a simple type, object, or array. Skipping further processing for this property.")
|
||||
elif schema_type == "array":
|
||||
self.logger.debug(f"Path: '{base_path_for_log}', Top-level schema type is 'array'. Inspecting items...")
|
||||
items_schema = resolved_schema.get("items")
|
||||
if isinstance(items_schema, dict):
|
||||
items_schema_resolved = self._resolve_ref_if_present(items_schema)
|
||||
item_type = items_schema_resolved.get("type")
|
||||
self.logger.debug(f"Path: '{base_path_for_log}[*]', Resolved Item Type: '{item_type}'")
|
||||
if item_type in ["string", "integer", "number", "boolean"]:
|
||||
# This means the body itself is an array of simple types.
|
||||
# We target the first item. Path will be [0] if base_path_for_log is empty.
|
||||
path_parts = base_path_for_log.split('.') if base_path_for_log else []
|
||||
if path_parts == ['']: path_parts = []
|
||||
# If base_path_for_log is empty (root schema is array), path is just [0]
|
||||
# If base_path_for_log is "field.array_prop", this case shouldn't be hit here, but in object prop loop.
|
||||
# This branch is for when the *entire request body schema* is an array.
|
||||
self.target_field_path = path_parts + [0] # if root is array, path_parts is [], so path is [0]
|
||||
self.original_field_type = item_type
|
||||
self.target_field_schema = items_schema_resolved
|
||||
self.logger.info(f"目标字段(请求体 - 根为简单类型数组): '{base_path_for_log}[0]' (原始类型: '{item_type}') FOUND!")
|
||||
return True
|
||||
elif item_type == "object":
|
||||
self.logger.debug(f"Path: '{base_path_for_log}[*]', Item type is 'object'. Recursing into root array item schema...")
|
||||
# Path for recursion: base_path_for_log + ".0" or just "0" if base_path is empty
|
||||
new_base_path = f"{base_path_for_log}.0" if base_path_for_log else "0"
|
||||
if self._find_target_field_in_schema(items_schema_resolved, new_base_path):
|
||||
self.logger.info(f"目标字段(请求体 - 根为对象数组,属性在对象内) found via recursion from '{new_base_path}'")
|
||||
return True
|
||||
self.logger.debug(f"Path: '{base_path_for_log}[*]', Recursion for root array item object did not find target.")
|
||||
else:
|
||||
self.logger.debug(f"Path: '{base_path_for_log}[*]', Item type '{item_type}' is not simple or object.")
|
||||
else:
|
||||
self.logger.debug(f"Path: '{base_path_for_log}', Root array items schema is not a dict or missing. Items: {items_schema}")
|
||||
else:
|
||||
self.logger.debug(f"Path: '{base_path_for_log}', Schema type is '{schema_type}', not 'object' or 'array'. Cannot find properties here.")
|
||||
|
||||
|
||||
self.logger.debug(f"Exit _find_target_field_in_schema for base_path: '{base_path_for_log if base_path_for_log else 'root'}'. Target NOT found in this path.")
|
||||
return False
|
||||
|
||||
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: # target_field_location is always "body"
|
||||
return current_body
|
||||
|
||||
self.logger.debug(f"准备修改请求体以测试类型不匹配。目标路径: {self.target_field_path}, 原始类型: {self.original_field_type}")
|
||||
|
||||
modified_body = copy.deepcopy(current_body) if current_body is not None else {}
|
||||
|
||||
# Ensure body is a dict if path is not empty, or if it's empty and body is None, init to {}
|
||||
if self.target_field_path and not isinstance(modified_body, dict):
|
||||
if not modified_body and not self.target_field_path[0]: # Path is effectively root, and body is None/empty
|
||||
modified_body = {} # Initialize if targeting root of an empty body
|
||||
else:
|
||||
self.logger.warning(f"请求体不是字典类型 (is {type(modified_body)}),但目标字段路径为 {self.target_field_path}。无法安全应用修改。")
|
||||
return current_body
|
||||
elif not self.target_field_path and not modified_body: # No path (targeting root) and body is None
|
||||
self.logger.warning(f"目标字段路径为空 (表示根对象) 但当前请求体也为空,无法确定如何修改。")
|
||||
return current_body
|
||||
|
||||
|
||||
temp_obj_ref = modified_body
|
||||
try:
|
||||
for i, key_or_index in enumerate(self.target_field_path):
|
||||
is_last_part = (i == len(self.target_field_path) - 1)
|
||||
|
||||
if isinstance(key_or_index, int): # Array index
|
||||
if not isinstance(temp_obj_ref, list) or key_or_index >= len(temp_obj_ref):
|
||||
self.logger.warning(f"路径 {self.target_field_path[:i+1]} 指向数组索引,但当前对象不是列表或索引 ({key_or_index}) 越界 (len: {len(temp_obj_ref) if isinstance(temp_obj_ref, list) else 'N/A'})。")
|
||||
# Attempt to create list/elements if they don't exist up to this point (for safety, only if current is None or empty list)
|
||||
if isinstance(temp_obj_ref, list) and key_or_index == 0 and not temp_obj_ref: # Empty list, trying to set first element
|
||||
temp_obj_ref.append({}) # Add a dict placeholder for the first element
|
||||
elif temp_obj_ref is None and key_or_index == 0: # If parent was None, can't proceed here unless path logic is very robust for creation
|
||||
return current_body # Cannot proceed
|
||||
else:
|
||||
return current_body # Cannot proceed
|
||||
|
||||
if is_last_part:
|
||||
original_value = temp_obj_ref[key_or_index]
|
||||
new_value = self._get_mismatched_value(self.original_field_type, original_value, self.target_field_schema)
|
||||
self.logger.info(f"在路径 {self.target_field_path} (数组索引 {key_or_index}) 处,将值从 '{original_value}' 修改为 '{new_value}' (原始类型: {self.original_field_type})")
|
||||
temp_obj_ref[key_or_index] = new_value
|
||||
else:
|
||||
temp_obj_ref = temp_obj_ref[key_or_index]
|
||||
|
||||
elif isinstance(temp_obj_ref, dict): # Dictionary key
|
||||
if key_or_index not in temp_obj_ref and not is_last_part:
|
||||
self.logger.debug(f"路径 {self.target_field_path[:i+1]} 中的键 '{key_or_index}' 在当前对象中不存在,将创建它。")
|
||||
temp_obj_ref[key_or_index] = {} # Create path if not exists
|
||||
|
||||
if is_last_part:
|
||||
original_value = temp_obj_ref.get(key_or_index)
|
||||
new_value = self._get_mismatched_value(self.original_field_type, original_value, self.target_field_schema)
|
||||
self.logger.info(f"在路径 {self.target_field_path} (键 '{key_or_index}') 处,将值从 '{original_value}' 修改为 '{new_value}' (原始类型: {self.original_field_type})")
|
||||
temp_obj_ref[key_or_index] = new_value
|
||||
else:
|
||||
temp_obj_ref = temp_obj_ref[key_or_index]
|
||||
if temp_obj_ref is None and not is_last_part:
|
||||
self.logger.warning(f"路径 {self.target_field_path[:i+1]} 的值在深入时变为None。创建空字典继续。")
|
||||
# This part is tricky, if temp_obj_ref was a key in parent, parent[key_or_index] is None.
|
||||
# We need to set parent[key_or_index] = {} and then temp_obj_ref = parent[key_or_index]
|
||||
# This requires knowing the parent. Let's simplify: if it becomes None, we might not be able to proceed unless it's the dict itself.
|
||||
# The current logic `temp_obj_ref = temp_obj_ref[key_or_index]` means if `temp_obj_ref` was `obj[key]`, now `temp_obj_ref` IS `obj[key]`s value.
|
||||
# If this value is None, and we are not at the end, we should create a dict there if the next part of path is a string key.
|
||||
# This modification is done in the check `if key_or_index not in temp_obj_ref and not is_last_part:`
|
||||
# If it's None AFTER that, it means the schema might be complex (e.g. anyOf, oneOf) or data is unexpectedly null.
|
||||
# For robustness, if it's None and not the last part, we can assume we need a dict for the next key.
|
||||
# The path creation `temp_obj_ref[key_or_index] = {}` for the *next* key happens at the start of the loop for that next key.
|
||||
pass # Already handled by creation logic at the start of the loop iteration for the next key
|
||||
else:
|
||||
self.logger.warning(f"尝试访问路径 {self.target_field_path[:i+1]} 时,当前对象 ({type(temp_obj_ref)}) 不是字典或列表。")
|
||||
return current_body
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"在根据路径 {self.target_field_path} 修改请求体时发生错误: {e}", exc_info=True)
|
||||
return current_body
|
||||
|
||||
return modified_body
|
||||
|
||||
def _get_mismatched_value(self, original_type: Optional[str], original_value: Any, field_schema: Optional[Dict[str, Any]]) -> Any:
|
||||
if original_type == "string":
|
||||
if field_schema and "enum" in field_schema and isinstance(field_schema["enum"], list):
|
||||
if 123 not in field_schema["enum"]: return 123
|
||||
if False not in field_schema["enum"]: return False
|
||||
return 12345
|
||||
elif original_type == "integer":
|
||||
if field_schema and "enum" in field_schema and isinstance(field_schema["enum"], list):
|
||||
if "not-an-integer" not in field_schema["enum"]: return "not-an-integer"
|
||||
if 3.14 not in field_schema["enum"]: return 3.14
|
||||
return "not-an-integer"
|
||||
elif original_type == "number":
|
||||
if field_schema and "enum" in field_schema and isinstance(field_schema["enum"], list):
|
||||
if "not-a-number" not in field_schema["enum"]: return "not-a-number"
|
||||
return "not-a-number"
|
||||
elif original_type == "boolean":
|
||||
if field_schema and "enum" in field_schema and isinstance(field_schema["enum"], list):
|
||||
if "not-a-boolean" not in field_schema["enum"]: return "not-a-boolean"
|
||||
if 1 not in field_schema["enum"]: return 1
|
||||
return "not-a-boolean"
|
||||
elif original_type == "array":
|
||||
return {"value": "not-an-array"}
|
||||
elif original_type == "object":
|
||||
return ["not", "an", "object"]
|
||||
|
||||
self.logger.warning(f"类型不匹配测试(请求体):原始类型 '{original_type}' 未知或无法生成不匹配值,将返回固定字符串 'mismatch_test'。")
|
||||
return "mismatch_test" # Fallback
|
||||
|
||||
def validate_response(self, response_context: APIResponseContext, request_context: APIRequestContext) -> List[ValidationResult]:
|
||||
results = []
|
||||
status_code = response_context.status_code
|
||||
json_content = response_context.json_content
|
||||
|
||||
if not self.target_field_path:
|
||||
results.append(self.passed("跳过测试:在请求体中未找到合适的字段来测试类型不匹配。"))
|
||||
self.logger.info(f"{self.id}: 由于未识别到目标请求体字段,跳过类型不匹配测试。")
|
||||
return results
|
||||
|
||||
expected_status_codes = [400, 422]
|
||||
specific_error_code_from_appendix_b = "4001" # Example
|
||||
|
||||
if status_code in expected_status_codes:
|
||||
msg = f"API对请求体字段 '{'.'.join(str(p) for p in self.target_field_path)}' 的类型不匹配响应了 {status_code},符合预期。"
|
||||
error_code_in_response = json_content.get("code") if isinstance(json_content, dict) else None
|
||||
if error_code_in_response == specific_error_code_from_appendix_b:
|
||||
results.append(self.passed(f"{msg} 并成功接收到特定错误码 '{specific_error_code_from_appendix_b}'。"))
|
||||
elif error_code_in_response:
|
||||
results.append(ValidationResult(passed=True,
|
||||
message=f"{msg} 但响应体中的错误码是 '{error_code_in_response}' (期望类似 '{specific_error_code_from_appendix_b}')。",
|
||||
details=json_content if isinstance(json_content, dict) else {"raw_response": str(json_content)}
|
||||
))
|
||||
else:
|
||||
results.append(self.passed(f"{msg} 响应体中未找到错误码或结构不符合预期。"))
|
||||
else:
|
||||
results.append(self.failed(
|
||||
message=f"对请求体字段 '{'.'.join(str(p) for p in self.target_field_path)}' 的类型不匹配测试期望状态码为 {expected_status_codes} 之一,但收到 {status_code}。",
|
||||
details={"status_code": status_code, "response_body": json_content}
|
||||
))
|
||||
self.logger.warning(f"{self.id}: 类型不匹配测试失败。字段: body.{'.'.join(str(p) for p in self.target_field_path)}, 期望状态码: {expected_status_codes}, 实际: {status_code}。")
|
||||
|
||||
return results
|
||||
@ -0,0 +1,229 @@
|
||||
from typing import Dict, Any, Optional, List
|
||||
from ddms_compliance_suite.test_framework_core import BaseAPITestCase, TestSeverity, ValidationResult, APIRequestContext, APIResponseContext
|
||||
import copy
|
||||
import logging
|
||||
|
||||
class TypeMismatchQueryParamCase(BaseAPITestCase):
|
||||
id = "TC-ERROR-4001-QUERY"
|
||||
name = "Error Code 4001 - Query Parameter Type Mismatch Validation"
|
||||
description = "测试当发送的查询参数数据类型与API规范定义不符时,API是否按预期返回类似4001的错误(或通用400错误)。"
|
||||
severity = TestSeverity.MEDIUM
|
||||
tags = ["error-handling", "appendix-b", "4001", "query-parameters"]
|
||||
execution_order = 201 # Slightly after the combined one might have been
|
||||
|
||||
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.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.logger.critical(f"{self.id} __INIT__ >>> STARTED")
|
||||
self.logger.debug(f"开始为端点 {self.endpoint_spec.get('method')} {self.endpoint_spec.get('path')} 初始化查询参数类型不匹配测试的目标字段查找。")
|
||||
|
||||
parameters = self.endpoint_spec.get("parameters", [])
|
||||
self.logger.critical(f"{self.id} __INIT__ >>> Parameters to be processed: {parameters}")
|
||||
self.logger.debug(f"传入的参数列表 (在 {self.id}中): {parameters}")
|
||||
|
||||
for param_spec in parameters:
|
||||
if param_spec.get("in") == "query":
|
||||
param_name = param_spec.get("name")
|
||||
if not param_name:
|
||||
self.logger.warning("发现一个没有名称的查询参数定义,已跳过。")
|
||||
continue
|
||||
|
||||
self.logger.debug(f"检查查询参数: '{param_name}'")
|
||||
|
||||
param_type = param_spec.get("type")
|
||||
param_schema = param_spec.get("schema")
|
||||
|
||||
# Scenario 1: Simple type directly in param_spec (e.g., type: string)
|
||||
if param_type in ["string", "number", "integer", "boolean"]:
|
||||
self.target_field_path = [param_name]
|
||||
self.original_field_type = param_type
|
||||
self.target_field_schema = param_spec
|
||||
self.logger.info(f"目标字段(查询参数 - 简单类型): {param_name},原始类型: {self.original_field_type}")
|
||||
break
|
||||
# Scenario 2: Schema defined for the query parameter (OpenAPI 3.0 style, or complex objects in query)
|
||||
elif isinstance(param_schema, dict):
|
||||
self.logger.debug(f"查询参数 '{param_name}' 包含嵌套 schema,尝试在其内部查找简单类型字段。")
|
||||
# We need to find a simple type *within* this schema.
|
||||
# _find_target_field_in_schema is designed for requestBody, let's adapt or simplify.
|
||||
# For query parameters, complex objects are less common or might be flattened.
|
||||
# Let's try to find a simple type property directly within this schema if it's an object.
|
||||
resolved_param_schema = self._resolve_ref_if_present(param_schema)
|
||||
if resolved_param_schema.get("type") == "object":
|
||||
properties = resolved_param_schema.get("properties", {})
|
||||
for prop_name, prop_details_orig in properties.items():
|
||||
prop_details = self._resolve_ref_if_present(prop_details_orig)
|
||||
if prop_details.get("type") in ["string", "number", "integer", "boolean"]:
|
||||
self.target_field_path = [param_name, prop_name] # Path will be param_name.prop_name
|
||||
self.original_field_type = prop_details.get("type")
|
||||
self.target_field_schema = prop_details
|
||||
self.logger.info(f"目标字段(查询参数 - 对象属性): {param_name}.{prop_name},原始类型: {self.original_field_type}")
|
||||
break # Found a suitable property
|
||||
if self.target_field_path: break # Break outer loop if found
|
||||
elif resolved_param_schema.get("type") in ["string", "number", "integer", "boolean"]: # Schema itself is simple after ref resolution
|
||||
self.target_field_path = [param_name]
|
||||
self.original_field_type = resolved_param_schema.get("type")
|
||||
self.target_field_schema = resolved_param_schema
|
||||
self.logger.info(f"目标字段(查询参数 - schema为简单类型): {param_name},原始类型: {self.original_field_type}")
|
||||
break
|
||||
|
||||
else:
|
||||
self.logger.debug(f"查询参数 '{param_name}' (type: {param_type}, schema: {param_schema}) 不是直接的简单类型,也无直接可用的对象型 schema 属性。")
|
||||
|
||||
if not self.target_field_path:
|
||||
self.logger.info(f"最终,在端点 {self.endpoint_spec.get('method')} {self.endpoint_spec.get('path')} 的查询参数中,均未找到可用于测试类型不匹配的字段。")
|
||||
|
||||
def _resolve_ref_if_present(self, schema_to_resolve: Dict[str, Any]) -> Dict[str, Any]:
|
||||
ref_value = None
|
||||
if isinstance(schema_to_resolve, dict):
|
||||
if "$ref" in schema_to_resolve:
|
||||
ref_value = schema_to_resolve["$ref"]
|
||||
elif "$$ref" in schema_to_resolve:
|
||||
ref_value = schema_to_resolve["$$ref"]
|
||||
|
||||
if ref_value:
|
||||
self.logger.debug(f"发现引用 '{ref_value}',尝试解析...")
|
||||
try:
|
||||
actual_global_spec_dict = None
|
||||
if hasattr(self.global_api_spec, 'spec') and isinstance(self.global_api_spec.spec, dict):
|
||||
actual_global_spec_dict = self.global_api_spec.spec
|
||||
elif isinstance(self.global_api_spec, dict):
|
||||
actual_global_spec_dict = self.global_api_spec
|
||||
|
||||
if not actual_global_spec_dict:
|
||||
self.logger.warning(f"无法从 self.global_api_spec (类型: {type(self.global_api_spec)}) 获取用于解析引用的字典。")
|
||||
return schema_to_resolve
|
||||
|
||||
resolved_schema = None
|
||||
if ref_value.startswith("#/components/schemas/"):
|
||||
schema_name = ref_value.split("/")[-1]
|
||||
components = actual_global_spec_dict.get("components")
|
||||
if components and isinstance(components.get("schemas"), dict):
|
||||
resolved_schema = components["schemas"].get(schema_name)
|
||||
if resolved_schema and isinstance(resolved_schema, dict):
|
||||
self.logger.info(f"成功从 #/components/schemas/ 解析引用 '{ref_value}'。")
|
||||
return resolved_schema
|
||||
else:
|
||||
self.logger.warning(f"解析引用 '{ref_value}' (路径: #/components/schemas/) 失败:未找到或找到的不是字典: {schema_name}")
|
||||
else:
|
||||
self.logger.warning(f"尝试从 #/components/schemas/ 解析引用 '{ref_value}' 失败:无法找到 'components.schemas' 结构。")
|
||||
|
||||
if not resolved_schema and ref_value.startswith("#/definitions/"):
|
||||
schema_name = ref_value.split("/")[-1]
|
||||
definitions = actual_global_spec_dict.get("definitions")
|
||||
if definitions and isinstance(definitions, dict):
|
||||
resolved_schema = definitions.get(schema_name)
|
||||
if resolved_schema and isinstance(resolved_schema, dict):
|
||||
self.logger.info(f"成功从 #/definitions/ 解析引用 '{ref_value}'。")
|
||||
return resolved_schema
|
||||
else:
|
||||
self.logger.warning(f"解析引用 '{ref_value}' (路径: #/definitions/) 失败:未找到或找到的不是字典: {schema_name}")
|
||||
else:
|
||||
self.logger.warning(f"尝试从 #/definitions/ 解析引用 '{ref_value}' 失败:无法找到 'definitions' 结构。")
|
||||
|
||||
if not resolved_schema:
|
||||
self.logger.warning(f"最终未能通过任一已知路径 (#/components/schemas/ 或 #/definitions/) 解析引用 '{ref_value}'。")
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"解析引用 '{ref_value}' 时发生错误: {e}", exc_info=True)
|
||||
return schema_to_resolve
|
||||
|
||||
# No generate_request_body, or it simply returns current_body
|
||||
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: # target_field_location is always "query"
|
||||
return current_query_params
|
||||
|
||||
self.logger.debug(f"准备修改查询参数以测试类型不匹配。目标路径: {self.target_field_path}, 原始类型: {self.original_field_type}")
|
||||
|
||||
modified_params = copy.deepcopy(current_query_params) if current_query_params is not None else {}
|
||||
|
||||
temp_obj_ref = modified_params
|
||||
try:
|
||||
for i, key in enumerate(self.target_field_path):
|
||||
is_last_part = (i == len(self.target_field_path) - 1)
|
||||
if is_last_part:
|
||||
original_value = temp_obj_ref.get(key)
|
||||
new_value = self._get_mismatched_value(self.original_field_type, original_value, self.target_field_schema)
|
||||
self.logger.info(f"在查询参数路径 {self.target_field_path} (键 '{key}') 处,将值从 '{original_value}' 修改为 '{new_value}' (原始类型: {self.original_field_type})")
|
||||
temp_obj_ref[key] = new_value
|
||||
else: # Navigating a nested structure within a query param (e.g. filter[field]=value)
|
||||
if key not in temp_obj_ref or not isinstance(temp_obj_ref[key], dict):
|
||||
# If path expects a dict but it's not there, create it.
|
||||
# This is crucial for structured query params like "filter[name]=value"
|
||||
# where target_field_path might be ["filter", "name"].
|
||||
temp_obj_ref[key] = {}
|
||||
temp_obj_ref = temp_obj_ref[key]
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"在根据路径 {self.target_field_path} 修改查询参数时发生错误: {e}", exc_info=True)
|
||||
return current_query_params
|
||||
|
||||
return modified_params
|
||||
|
||||
def _get_mismatched_value(self, original_type: Optional[str], original_value: Any, field_schema: Optional[Dict[str, Any]]) -> Any:
|
||||
if original_type == "string":
|
||||
if field_schema and "enum" in field_schema and isinstance(field_schema["enum"], list):
|
||||
if 123 not in field_schema["enum"]: return 123
|
||||
if False not in field_schema["enum"]: return False
|
||||
return 12345
|
||||
elif original_type == "integer":
|
||||
if field_schema and "enum" in field_schema and isinstance(field_schema["enum"], list):
|
||||
if "not-an-integer" not in field_schema["enum"]: return "not-an-integer"
|
||||
if 3.14 not in field_schema["enum"]: return 3.14
|
||||
return "not-an-integer"
|
||||
elif original_type == "number":
|
||||
if field_schema and "enum" in field_schema and isinstance(field_schema["enum"], list):
|
||||
if "not-a-number" not in field_schema["enum"]: return "not-a-number"
|
||||
return "not-a-number"
|
||||
elif original_type == "boolean":
|
||||
if field_schema and "enum" in field_schema and isinstance(field_schema["enum"], list):
|
||||
if "not-a-boolean" not in field_schema["enum"]: return "not-a-boolean"
|
||||
if 1 not in field_schema["enum"]: return 1
|
||||
return "not-a-boolean"
|
||||
|
||||
self.logger.warning(f"类型不匹配测试(查询参数):原始类型 '{original_type}' 未知或无法生成不匹配值,将返回固定字符串 'mismatch_test'。")
|
||||
return "mismatch_test" # Fallback for other types or if logic is incomplete
|
||||
|
||||
def validate_response(self, response_context: APIResponseContext, request_context: APIRequestContext) -> List[ValidationResult]:
|
||||
results = []
|
||||
status_code = response_context.status_code
|
||||
json_content = response_context.json_content
|
||||
|
||||
if not self.target_field_path:
|
||||
results.append(self.passed("跳过测试:在查询参数中未找到合适的字段来测试类型不匹配。"))
|
||||
self.logger.info(f"{self.id}: 由于未识别到目标查询参数字段,跳过类型不匹配测试。")
|
||||
return results
|
||||
|
||||
expected_status_codes = [400, 422]
|
||||
specific_error_code_from_appendix_b = "4001" # Example
|
||||
|
||||
if status_code in expected_status_codes:
|
||||
msg = f"API对查询参数 '{'.'.join(self.target_field_path)}' 的类型不匹配响应了 {status_code},符合预期。"
|
||||
# Further check for specific error code in body if applicable
|
||||
error_code_in_response = json_content.get("code") if isinstance(json_content, dict) else None
|
||||
if error_code_in_response == specific_error_code_from_appendix_b:
|
||||
results.append(self.passed(f"{msg} 并成功接收到特定错误码 '{specific_error_code_from_appendix_b}'。"))
|
||||
elif error_code_in_response:
|
||||
results.append(ValidationResult(passed=True,
|
||||
message=f"{msg} 但响应体中的错误码是 '{error_code_in_response}' (期望类似 '{specific_error_code_from_appendix_b}')。",
|
||||
details=json_content if isinstance(json_content, dict) else {"raw_response": str(json_content)}
|
||||
))
|
||||
else:
|
||||
results.append(self.passed(f"{msg} 响应体中未找到错误码或结构不符合预期。"))
|
||||
else:
|
||||
results.append(self.failed(
|
||||
message=f"对查询参数 '{'.'.join(self.target_field_path)}' 的类型不匹配测试期望状态码为 {expected_status_codes} 之一,但收到 {status_code}。",
|
||||
details={"status_code": status_code, "response_body": json_content}
|
||||
))
|
||||
self.logger.warning(f"{self.id}: 类型不匹配测试失败。字段: query.{'.'.join(self.target_field_path)}, 期望状态码: {expected_status_codes}, 实际: {status_code}。")
|
||||
|
||||
return results
|
||||
@ -0,0 +1 @@
|
||||
# This file marks the normative_spec directory as a Python package.
|
||||
Binary file not shown.
@ -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
|
||||
1
custom_testcases/compliance_catalog/security/__init__.py
Normal file
1
custom_testcases/compliance_catalog/security/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
# This file marks the security directory as a Python package.
|
||||
Binary file not shown.
@ -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 Protocol Mandatory Verification"
|
||||
description = "验证API端点是否通过HTTPS提供服务,以及HTTP请求是否被拒绝或重定向到HTTPS。"
|
||||
severity = TestSeverity.CRITICAL
|
||||
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):
|
||||
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 modify_request_url(self, current_url: str) -> str:
|
||||
parsed_url = urllib.parse.urlparse(current_url)
|
||||
if parsed_url.scheme.lower() == "httpss":
|
||||
# 将 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
custom_testcases1/__pycache__/basic_checks.cpython-312.pyc
Normal file
BIN
custom_testcases1/__pycache__/basic_checks.cpython-312.pyc
Normal file
Binary file not shown.
1
custom_testcases1/compliance_catalog/__init__.py
Normal file
1
custom_testcases1/compliance_catalog/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
# This file marks the compliance_catalog directory as a Python package.
|
||||
@ -0,0 +1 @@
|
||||
# This file marks the error_handling directory as a Python package.
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@ -0,0 +1,296 @@
|
||||
from typing import Dict, Any, Optional, List
|
||||
from ddms_compliance_suite.test_framework_core import BaseAPITestCase, TestSeverity, ValidationResult, APIRequestContext, APIResponseContext
|
||||
import copy
|
||||
import logging
|
||||
|
||||
class MissingRequiredFieldBodyCase(BaseAPITestCase):
|
||||
id = "TC-ERROR-4003-BODY"
|
||||
name = "Error Code 4003 - Missing Required Request Body Field Validation"
|
||||
description = "测试当请求体中缺少API规范定义的必填字段时,API是否按预期返回类似4003的错误(或通用400错误)。"
|
||||
severity = TestSeverity.HIGH
|
||||
tags = ["error-handling", "appendix-b", "4003", "required-fields", "request-body"]
|
||||
execution_order = 210 # Before query, same as original combined
|
||||
|
||||
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.setLevel(logging.DEBUG) # Ensure detailed logging for this class
|
||||
self.removed_field_path: Optional[List[str]] = None # Path to the removed field, e.g., ['level1', 'level2_field']
|
||||
self.original_body_schema: Optional[Dict[str, Any]] = None
|
||||
self._try_find_removable_body_field()
|
||||
|
||||
def _resolve_ref_if_present(self, schema_to_resolve: Dict[str, Any]) -> Dict[str, Any]:
|
||||
ref_value = None
|
||||
if isinstance(schema_to_resolve, dict):
|
||||
if "$ref" in schema_to_resolve:
|
||||
ref_value = schema_to_resolve["$ref"]
|
||||
elif "$$ref" in schema_to_resolve:
|
||||
ref_value = schema_to_resolve["$$ref"]
|
||||
|
||||
if ref_value:
|
||||
self.logger.debug(f"发现引用 '{ref_value}',尝试解析...")
|
||||
try:
|
||||
actual_global_spec_dict = None
|
||||
if hasattr(self.global_api_spec, 'spec') and isinstance(self.global_api_spec.spec, dict):
|
||||
actual_global_spec_dict = self.global_api_spec.spec
|
||||
elif isinstance(self.global_api_spec, dict):
|
||||
actual_global_spec_dict = self.global_api_spec
|
||||
|
||||
if not actual_global_spec_dict:
|
||||
self.logger.warning(f"无法从 self.global_api_spec (类型: {type(self.global_api_spec)}) 获取用于解析引用的字典。")
|
||||
return schema_to_resolve
|
||||
|
||||
resolved_schema = None
|
||||
if ref_value.startswith("#/components/schemas/"):
|
||||
schema_name = ref_value.split("/")[-1]
|
||||
components = actual_global_spec_dict.get("components")
|
||||
if components and isinstance(components.get("schemas"), dict):
|
||||
resolved_schema = components["schemas"].get(schema_name)
|
||||
if resolved_schema and isinstance(resolved_schema, dict):
|
||||
self.logger.info(f"成功从 #/components/schemas/ 解析引用 '{ref_value}'。")
|
||||
return resolved_schema
|
||||
else:
|
||||
self.logger.warning(f"解析引用 '{ref_value}' (路径: #/components/schemas/) 失败:未找到或找到的不是字典: {schema_name}")
|
||||
else:
|
||||
self.logger.warning(f"尝试从 #/components/schemas/ 解析引用 '{ref_value}' 失败:无法找到 'components.schemas' 结构。")
|
||||
|
||||
# 如果从 #/components/schemas/ 未成功解析,尝试 #/definitions/
|
||||
if not resolved_schema and ref_value.startswith("#/definitions/"):
|
||||
schema_name = ref_value.split("/")[-1]
|
||||
definitions = actual_global_spec_dict.get("definitions")
|
||||
if definitions and isinstance(definitions, dict):
|
||||
resolved_schema = definitions.get(schema_name)
|
||||
if resolved_schema and isinstance(resolved_schema, dict):
|
||||
self.logger.info(f"成功从 #/definitions/ 解析引用 '{ref_value}'。")
|
||||
return resolved_schema
|
||||
else:
|
||||
self.logger.warning(f"解析引用 '{ref_value}' (路径: #/definitions/) 失败:未找到或找到的不是字典: {schema_name}")
|
||||
else:
|
||||
self.logger.warning(f"尝试从 #/definitions/ 解析引用 '{ref_value}' 失败:无法找到 'definitions' 结构。")
|
||||
|
||||
if not resolved_schema:
|
||||
self.logger.warning(f"最终未能通过任一已知路径 (#/components/schemas/ 或 #/definitions/) 解析引用 '{ref_value}'。")
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"解析引用 '{ref_value}' 时发生错误: {e}", exc_info=True)
|
||||
return schema_to_resolve # 返回原始 schema 如果不是 ref 或者所有解析尝试都失败
|
||||
|
||||
def _find_required_field_in_schema_recursive(self, current_schema: Dict[str, Any], current_path: List[str]) -> Optional[List[str]]:
|
||||
"""递归查找第一个可移除的必填字段的路径。
|
||||
现在也会查找数组内对象中必填的字段。"""
|
||||
resolved_schema = self._resolve_ref_if_present(current_schema)
|
||||
|
||||
if not isinstance(resolved_schema, dict) or resolved_schema.get("type") != "object":
|
||||
# If not an object schema, cannot have 'required' or 'properties' in the way we expect.
|
||||
return None
|
||||
|
||||
required_fields_at_current_level = resolved_schema.get("required", [])
|
||||
properties = resolved_schema.get("properties", {})
|
||||
self.logger.debug(f"递归查找路径: {current_path}, 当前层级必填字段: {required_fields_at_current_level}, 属性: {list(properties.keys())}")
|
||||
|
||||
# 策略1: 查找当前层级直接声明的必填字段 (简单类型或复杂类型均可)
|
||||
if required_fields_at_current_level and properties:
|
||||
for field_name in required_fields_at_current_level:
|
||||
if field_name in properties:
|
||||
# 任何在 'required' 数组中列出的字段,无论其类型,都可以作为目标
|
||||
# (例如,移除一个必填的整个对象或数组也是一种有效的测试场景)
|
||||
self.logger.info(f"策略1: 在路径 {'.'.join(current_path) if current_path else 'root'} 找到可直接移除的必填字段: '{field_name}'")
|
||||
return current_path + [field_name]
|
||||
|
||||
# 策略2: 如果当前层级没有直接的必填字段可移除,则查找数组属性,看其内部item是否有必填字段
|
||||
# 这种情况下,数组本身可能不是必填的,但如果提供了数组,其item需要满足条件
|
||||
if properties: # 确保有属性可迭代
|
||||
for prop_name, prop_schema_orig in properties.items():
|
||||
prop_schema = self._resolve_ref_if_present(prop_schema_orig) # 解析属性自身的schema (可能也是ref)
|
||||
if isinstance(prop_schema, dict) and prop_schema.get("type") == "array":
|
||||
items_schema_orig = prop_schema.get("items")
|
||||
if isinstance(items_schema_orig, dict):
|
||||
items_schema = self._resolve_ref_if_present(items_schema_orig) # 解析 items 的 schema
|
||||
if isinstance(items_schema, dict) and items_schema.get("type") == "object":
|
||||
item_required_fields = items_schema.get("required", [])
|
||||
item_properties = items_schema.get("properties", {})
|
||||
if item_required_fields and item_properties:
|
||||
first_required_field_in_item = None
|
||||
for req_item_field in item_required_fields:
|
||||
if req_item_field in item_properties: # 确保该必填字段在属性中定义
|
||||
first_required_field_in_item = req_item_field
|
||||
break
|
||||
|
||||
if first_required_field_in_item:
|
||||
self.logger.info(f"策略2: 在数组属性 '{prop_name}' (路径 {'.'.join(current_path) if current_path else 'root'}) 的元素内找到必填字段: '{first_required_field_in_item}'. 将尝试移除路径: {current_path + [prop_name, 0, first_required_field_in_item]}")
|
||||
# 将路径指向数组的第一个元素 (index 0) 内的那个必填字段
|
||||
return current_path + [prop_name, 0, first_required_field_in_item]
|
||||
|
||||
# 策略3: (可选,如果需要更深层次的普通对象递归)
|
||||
# 如果以上策略都未找到,并且希望深入到非必填的子对象中查找,可以启用以下逻辑。
|
||||
# 但这通常不用于"顶层必填字段缺失"的测试目的,除非测试用例目标是验证任意深度的必填。
|
||||
# for prop_name, prop_schema_orig_for_recurse in properties.items():
|
||||
# prop_schema_for_recurse = self._resolve_ref_if_present(prop_schema_orig_for_recurse)
|
||||
# if isinstance(prop_schema_for_recurse, dict) and prop_schema_for_recurse.get("type") == "object":
|
||||
# # 确保不陷入无限循环,例如,如果一个对象属性是可选的但其内部有必填字段
|
||||
# # 这里需要小心,因为我们可能已经检查过当前级别的required字段
|
||||
# # 主要用于当某个对象不是顶层必填,但如果提供了它,它内部又有必填项的场景
|
||||
# # 但这与当前测试用例的 primary goal 可能不完全一致
|
||||
# self.logger.debug(f"策略3: 尝试递归进入对象属性 '{prop_name}' (路径 {'.'.join(current_path)}) (此对象本身在当前层级非必填或已检查)")
|
||||
# found_path_deeper = self._find_required_field_in_schema_recursive(prop_schema_for_recurse, current_path + [prop_name])
|
||||
# if found_path_deeper:
|
||||
# # 确保返回的路径确实比当前路径深,并且该深层路径的父级(即prop_name)不是当前层级已知的必填字段
|
||||
# # (以避免重复发现已被策略1覆盖的场景)
|
||||
# # if prop_name not in required_fields_at_current_level:
|
||||
# self.logger.info(f"策略3: 递归在对象属性 '{prop_name}' (路径 {'.'.join(current_path)}) 中找到必填字段路径: {found_path_deeper}")
|
||||
# return found_path_deeper
|
||||
|
||||
self.logger.debug(f"在路径 {'.'.join(current_path) if current_path else 'root'} 未通过任何策略找到可移除的必填字段。")
|
||||
return None
|
||||
def _try_find_removable_body_field(self):
|
||||
body_schema_to_check: Optional[Dict[str, Any]] = None
|
||||
request_body_spec = self.endpoint_spec.get("requestBody")
|
||||
if request_body_spec and isinstance(request_body_spec, dict):
|
||||
content = request_body_spec.get("content", {})
|
||||
json_schema_entry = content.get("application/json")
|
||||
if json_schema_entry and isinstance(json_schema_entry, dict) and isinstance(json_schema_entry.get("schema"), dict):
|
||||
body_schema_to_check = json_schema_entry["schema"]
|
||||
|
||||
if not body_schema_to_check:
|
||||
parameters = self.endpoint_spec.get("parameters", [])
|
||||
if isinstance(parameters, list):
|
||||
for param in parameters:
|
||||
if isinstance(param, dict) and param.get("in") == "body":
|
||||
if isinstance(param.get("schema"), dict):
|
||||
body_schema_to_check = param["schema"]
|
||||
break
|
||||
|
||||
if body_schema_to_check:
|
||||
self.original_body_schema = copy.deepcopy(body_schema_to_check)
|
||||
self.removed_field_path = self._find_required_field_in_schema_recursive(self.original_body_schema, [])
|
||||
if self.removed_field_path:
|
||||
self.logger.info(f"必填字段缺失测试的目标字段 (请求体): '{'.'.join(map(str, self.removed_field_path))}'")
|
||||
self.field_to_remove_details = {
|
||||
"path": self.removed_field_path,
|
||||
# ... existing code ...
|
||||
}
|
||||
else:
|
||||
self.logger.info('在请求体 schema 中未找到可用于测试 "必填字段缺失" 的字段。')
|
||||
else:
|
||||
self.logger.info('此端点规范中未定义请求体 schema。')
|
||||
|
||||
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.")
|
||||
return current_body
|
||||
|
||||
if current_body is None:
|
||||
self.logger.debug("current_body is None. Orchestrator should ideally provide a base body. Attempting to build minimal structure for removal.")
|
||||
new_body = {}
|
||||
else:
|
||||
new_body = copy.deepcopy(current_body)
|
||||
|
||||
temp_obj_ref = new_body
|
||||
|
||||
try:
|
||||
for i, key_or_index in enumerate(self.removed_field_path):
|
||||
is_last_element = (i == len(self.removed_field_path) - 1)
|
||||
|
||||
if is_last_element:
|
||||
if isinstance(key_or_index, str): # Key for a dictionary (field name)
|
||||
if isinstance(temp_obj_ref, dict) and key_or_index in temp_obj_ref:
|
||||
original_value = temp_obj_ref.pop(key_or_index)
|
||||
self.logger.info(f"为进行必填字段缺失测试,已从请求体中移除字段路径 '{'.'.join(map(str,self.removed_field_path))}' (原值: '{original_value}')。")
|
||||
return new_body
|
||||
elif isinstance(temp_obj_ref, dict): # Key not in dict, but it's a dict
|
||||
self.logger.warning(f"计划移除的请求体字段路径的最后一部分 '{key_or_index}' (string key) 在对象中未找到,但该对象是字典。可能该字段本就是可选的或不存在于提供的current_body。路径: {'.'.join(map(str,self.removed_field_path))}")
|
||||
return new_body
|
||||
else: # temp_obj_ref is not a dict
|
||||
self.logger.warning(f"计划移除的请求体字段路径的最后一部分 '{key_or_index}' (string key) 期望父级是字典,但找到 {type(temp_obj_ref)}。路径: {'.'.join(map(str,self.removed_field_path))}")
|
||||
return current_body
|
||||
else: # Last element of path is an index - this should not happen as we remove a *field name*
|
||||
self.logger.error(f"路径的最后一部分 '{key_or_index}' 预期为字符串字段名,但类型为 {type(key_or_index)}. Path: {'.'.join(map(str,self.removed_field_path))}")
|
||||
return current_body
|
||||
else: # Not the last element, so we are traversing or building the structure
|
||||
next_key_or_index = self.removed_field_path[i+1]
|
||||
|
||||
if isinstance(key_or_index, str): # Current path part is a dictionary key
|
||||
if not isinstance(temp_obj_ref, dict):
|
||||
self.logger.warning(f"路径期望字典,但在 '{key_or_index}' (父级)处找到 {type(temp_obj_ref)}. Path: {'.'.join(map(str,self.removed_field_path))}. 如果current_body为None,则尝试创建字典。")
|
||||
if temp_obj_ref is new_body and not new_body :
|
||||
temp_obj_ref = {}
|
||||
else:
|
||||
return current_body
|
||||
|
||||
if isinstance(next_key_or_index, int):
|
||||
if key_or_index not in temp_obj_ref or not isinstance(temp_obj_ref.get(key_or_index), list):
|
||||
self.logger.debug(f"路径 '{key_or_index}' 需要是列表 (为索引 {next_key_or_index} 做准备),但未找到或类型不符。将创建空列表。")
|
||||
temp_obj_ref[key_or_index] = []
|
||||
temp_obj_ref = temp_obj_ref[key_or_index]
|
||||
else:
|
||||
if key_or_index not in temp_obj_ref or not isinstance(temp_obj_ref.get(key_or_index), dict):
|
||||
self.logger.debug(f"路径 '{key_or_index}' 需要是字典 (为键 '{next_key_or_index}' 做准备),但未找到或类型不符。将创建空字典。")
|
||||
temp_obj_ref[key_or_index] = {}
|
||||
temp_obj_ref = temp_obj_ref[key_or_index]
|
||||
|
||||
elif isinstance(key_or_index, int):
|
||||
if not isinstance(temp_obj_ref, list):
|
||||
self.logger.error(f"路径期望列表以应用索引 '{key_or_index}',但找到 {type(temp_obj_ref)}. Path: {'.'.join(map(str,self.removed_field_path))}")
|
||||
return current_body
|
||||
|
||||
while len(temp_obj_ref) <= key_or_index:
|
||||
self.logger.debug(f"数组在索引 {key_or_index} 处需要元素,将添加空字典作为占位符(因为后续预期是字段名)。")
|
||||
temp_obj_ref.append({})
|
||||
|
||||
if isinstance(next_key_or_index, str):
|
||||
if not isinstance(temp_obj_ref[key_or_index], dict):
|
||||
self.logger.debug(f"数组项 at index {key_or_index} 需要是字典 (为键 '{next_key_or_index}' 做准备)。如果它是其他类型,将被替换为空字典。")
|
||||
temp_obj_ref[key_or_index] = {}
|
||||
|
||||
temp_obj_ref = temp_obj_ref[key_or_index]
|
||||
|
||||
else:
|
||||
self.logger.error(f"路径部分 '{key_or_index}' 类型未知 ({type(key_or_index)}). Path: {'.'.join(map(str,self.removed_field_path))}")
|
||||
return current_body
|
||||
except Exception as e: # Ensuring the try has an except
|
||||
self.logger.error(f"在准备移除字段路径 '{'.'.join(map(str,self.removed_field_path))}' 时发生错误: {e}", exc_info=True)
|
||||
return current_body
|
||||
|
||||
self.logger.error(f"generate_request_body 未能在循环内按预期返回。路径: {'.'.join(map(str,self.removed_field_path))}")
|
||||
return current_body
|
||||
|
||||
def validate_response(self, response_context: APIResponseContext, request_context: APIRequestContext) -> List[ValidationResult]:
|
||||
results = []
|
||||
if not self.removed_field_path:
|
||||
results.append(self.passed("跳过测试:在API规范中未找到合适的必填请求体字段用于移除测试。"))
|
||||
self.logger.info("由于未识别到可移除的必填请求体字段,跳过此测试用例。")
|
||||
return results
|
||||
|
||||
status_code = response_context.status_code
|
||||
json_content = response_context.json_content
|
||||
expected_status_codes = [400, 422]
|
||||
specific_error_code_from_appendix_b = "4003"
|
||||
removed_field_str = '.'.join(map(str, self.removed_field_path))
|
||||
|
||||
msg_prefix = f"当移除必填请求体字段 '{removed_field_str}' 时,"
|
||||
|
||||
if status_code in expected_status_codes:
|
||||
status_msg = f"{msg_prefix}API响应了预期的错误状态码 {status_code}。"
|
||||
if json_content and isinstance(json_content, dict) and str(json_content.get("code")) == specific_error_code_from_appendix_b:
|
||||
results.append(self.passed(f"{status_msg} 且响应体中包含特定的错误码 '{specific_error_code_from_appendix_b}'。"))
|
||||
self.logger.info(f"正确接收到状态码 {status_code} 和错误码 '{specific_error_code_from_appendix_b}'。")
|
||||
elif json_content and isinstance(json_content, dict) and "code" in json_content:
|
||||
results.append(ValidationResult(passed=True,
|
||||
message=f"{status_msg} 响应体中的错误码为 '{json_content.get('code')}' (期望或类似 '{specific_error_code_from_appendix_b}')。",
|
||||
details=json_content
|
||||
))
|
||||
self.logger.warning(f"接收到状态码 {status_code},但错误码是 '{json_content.get('code')}' 而不是期望的 '{specific_error_code_from_appendix_b}'。此结果仍标记为通过,因状态码正确。")
|
||||
else:
|
||||
results.append(self.passed(f"{status_msg} 但响应体中未找到特定的错误码字段或响应体结构不符合预期。"))
|
||||
self.logger.info(f"正确接收到状态码 {status_code},但在响应体中未找到错误码字段或预期结构。")
|
||||
else:
|
||||
results.append(self.failed(
|
||||
message=f"{msg_prefix}期望API返回状态码 {expected_status_codes} 中的一个,但实际收到 {status_code}。",
|
||||
details={"status_code": status_code, "response_body": json_content, "removed_field": f"body.{removed_field_str}"}
|
||||
))
|
||||
self.logger.warning(f"必填请求体字段缺失测试失败:期望状态码 {expected_status_codes},实际为 {status_code}。移除的字段:'body.{removed_field_str}'")
|
||||
|
||||
return results
|
||||
@ -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 copy
|
||||
|
||||
class MissingRequiredFieldQueryCase(BaseAPITestCase):
|
||||
id = "TC-ERROR-4003-QUERY"
|
||||
name = "Error Code 4003 - Missing Required Query Parameter Validation"
|
||||
description = "测试当请求中缺少API规范定义的必填查询参数时,API是否按预期返回类似4003的错误(或通用400错误)。"
|
||||
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
|
||||
|
||||
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.removed_field_name: Optional[str] = None
|
||||
self._try_find_removable_query_param()
|
||||
|
||||
def _try_find_removable_query_param(self):
|
||||
query_params_spec_list = self.endpoint_spec.get("parameters", [])
|
||||
if query_params_spec_list:
|
||||
self.logger.debug(f"检查查询参数的必填字段,总共 {len(query_params_spec_list)} 个参数定义。")
|
||||
for param_spec in query_params_spec_list:
|
||||
if isinstance(param_spec, dict) and param_spec.get("in") == "query" and param_spec.get("required") is True:
|
||||
field_name = param_spec.get("name")
|
||||
if field_name:
|
||||
self.removed_field_name = field_name
|
||||
self.logger.info(f"必填字段缺失测试的目标字段 (查询参数): '{self.removed_field_name}'")
|
||||
return
|
||||
self.logger.info('在此端点规范中未找到可用于测试 "必填查询参数缺失" 的字段。')
|
||||
|
||||
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.removed_field_name and isinstance(current_query_params, dict):
|
||||
if self.removed_field_name in current_query_params:
|
||||
new_params = copy.deepcopy(current_query_params)
|
||||
original_value = new_params.pop(self.removed_field_name) # 移除参数
|
||||
self.logger.info(f"为进行必填查询参数缺失测试,已从查询参数中移除 '{self.removed_field_name}' (原值: '{original_value}')。")
|
||||
return new_params
|
||||
else:
|
||||
self.logger.warning(f"计划移除的查询参数 '{self.removed_field_name}' 在当前查询参数中未找到。")
|
||||
return current_query_params
|
||||
|
||||
def validate_response(self, response_context: APIResponseContext, request_context: APIRequestContext) -> List[ValidationResult]:
|
||||
results = []
|
||||
|
||||
if not self.removed_field_name:
|
||||
results.append(self.passed("跳过测试:在API规范中未找到合适的必填查询参数用于移除测试。"))
|
||||
self.logger.info("由于未识别到可移除的必填查询参数,跳过此测试用例。")
|
||||
return results
|
||||
|
||||
status_code = response_context.status_code
|
||||
json_content = response_context.json_content
|
||||
|
||||
expected_status_codes = [400, 422]
|
||||
specific_error_code_from_appendix_b = "4003"
|
||||
|
||||
msg_prefix = f"当移除必填查询参数 '{self.removed_field_name}' 时,"
|
||||
|
||||
if status_code in expected_status_codes:
|
||||
status_msg = f"{msg_prefix}API响应了预期的错误状态码 {status_code}。"
|
||||
if json_content and isinstance(json_content, dict) and str(json_content.get("code")) == specific_error_code_from_appendix_b:
|
||||
results.append(self.passed(f"{status_msg} 且响应体中包含特定的错误码 '{specific_error_code_from_appendix_b}'。"))
|
||||
self.logger.info(f"正确接收到状态码 {status_code} 和错误码 '{specific_error_code_from_appendix_b}'。")
|
||||
elif json_content and isinstance(json_content, dict) and "code" in json_content:
|
||||
results.append(ValidationResult(passed=True,
|
||||
message=f"{status_msg} 响应体中的错误码为 '{json_content.get('code')}' (期望或类似 '{specific_error_code_from_appendix_b}')。",
|
||||
details=json_content
|
||||
))
|
||||
self.logger.warning(f"接收到状态码 {status_code},但错误码是 '{json_content.get('code')}' 而不是期望的 '{specific_error_code_from_appendix_b}'。此结果仍标记为通过,因状态码正确。")
|
||||
else:
|
||||
results.append(self.passed(f"{status_msg} 但响应体中未找到特定的错误码字段或响应体结构不符合预期。"))
|
||||
self.logger.info(f"正确接收到状态码 {status_code},但在响应体中未找到错误码字段或预期结构。")
|
||||
else:
|
||||
results.append(self.failed(
|
||||
message=f"{msg_prefix}期望API返回状态码 {expected_status_codes} 中的一个,但实际收到 {status_code}。",
|
||||
details={"status_code": status_code, "response_body": json_content, "removed_field": f"query.{self.removed_field_name}"}
|
||||
))
|
||||
self.logger.warning(f"必填查询参数缺失测试失败:期望状态码 {expected_status_codes},实际为 {status_code}。移除的参数:'{self.removed_field_name}'")
|
||||
|
||||
return results
|
||||
Binary file not shown.
Binary file not shown.
@ -26,45 +26,58 @@ class TestCaseRegistry:
|
||||
|
||||
def discover_test_cases(self):
|
||||
"""
|
||||
扫描指定目录,动态导入模块,并注册所有继承自 BaseAPITestCase 的类。
|
||||
扫描指定目录及其所有子目录,动态导入模块,并注册所有继承自 BaseAPITestCase 的类。
|
||||
"""
|
||||
if not os.path.isdir(self.test_cases_dir):
|
||||
self.logger.warning(f"测试用例目录不存在或不是一个目录: {self.test_cases_dir}")
|
||||
return
|
||||
|
||||
self.logger.info(f"开始从目录 '{self.test_cases_dir}' 发现测试用例...")
|
||||
self.logger.info(f"开始从目录 '{self.test_cases_dir}' 及其子目录发现测试用例...")
|
||||
found_count = 0
|
||||
for filename in os.listdir(self.test_cases_dir):
|
||||
if filename.endswith(".py") and not filename.startswith("__"):
|
||||
module_name = filename[:-3]
|
||||
file_path = os.path.join(self.test_cases_dir, filename)
|
||||
try:
|
||||
# 动态导入模块
|
||||
spec = importlib.util.spec_from_file_location(module_name, file_path)
|
||||
if spec and spec.loader:
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
self.logger.debug(f"成功导入模块: {module_name} 从 {file_path}")
|
||||
# 使用 os.walk 进行递归扫描
|
||||
for root_dir, _, files in os.walk(self.test_cases_dir):
|
||||
for filename in files:
|
||||
if filename.endswith(".py") and not filename.startswith("__"):
|
||||
module_name = filename[:-3]
|
||||
file_path = os.path.join(root_dir, filename)
|
||||
try:
|
||||
# 动态导入模块
|
||||
spec = importlib.util.spec_from_file_location(module_name, file_path)
|
||||
if spec and spec.loader:
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
self.logger.debug(f"成功导入模块: {module_name} 从 {file_path}")
|
||||
|
||||
# 在模块中查找 BaseAPITestCase 的子类
|
||||
for name, obj in inspect.getmembers(module):
|
||||
if inspect.isclass(obj) and issubclass(obj, BaseAPITestCase) and obj is not BaseAPITestCase:
|
||||
if obj.id in self._registry:
|
||||
self.logger.warning(f"发现重复的测试用例 ID: '{obj.id}' (来自类 '{obj.__name__}' in {file_path})。之前的定义将被覆盖。")
|
||||
|
||||
self._registry[obj.id] = obj
|
||||
if obj not in self._test_case_classes: # 避免重复添加同一个类对象
|
||||
# 在模块中查找 BaseAPITestCase 的子类
|
||||
for name, obj in inspect.getmembers(module):
|
||||
if inspect.isclass(obj) and issubclass(obj, BaseAPITestCase) and obj is not BaseAPITestCase:
|
||||
if not hasattr(obj, 'id') or not obj.id:
|
||||
self.logger.error(f"测试用例类 '{obj.__name__}' 在文件 '{file_path}' 中缺少有效的 'id' 属性,已跳过注册。")
|
||||
continue
|
||||
|
||||
if obj.id in self._registry:
|
||||
self.logger.warning(f"发现重复的测试用例 ID: '{obj.id}' (来自类 '{obj.__name__}' in {file_path})。之前的定义将被覆盖。")
|
||||
|
||||
self._registry[obj.id] = obj
|
||||
# 更新 _test_case_classes 列表:如果已存在相同ID的类,替换它;否则添加。
|
||||
# 这确保了排序时使用的是最新的类定义,以防ID冲突。
|
||||
existing_class_indices = [i for i, tc_class in enumerate(self._test_case_classes) if tc_class.id == obj.id]
|
||||
if existing_class_indices:
|
||||
for index in sorted(existing_class_indices, reverse=True): # 从后往前删除,避免索引问题
|
||||
self.logger.debug(f"从 _test_case_classes 列表中移除旧的同ID ('{obj.id}') 测试用例类: {self._test_case_classes[index].__name__}")
|
||||
del self._test_case_classes[index]
|
||||
|
||||
self._test_case_classes.append(obj)
|
||||
found_count += 1
|
||||
self.logger.info(f"已注册测试用例: '{obj.id}' ({obj.name}) 来自类 '{obj.__name__}'")
|
||||
else:
|
||||
self.logger.error(f"无法为文件 '{file_path}' 创建模块规范。")
|
||||
except ImportError as e:
|
||||
self.logger.error(f"导入模块 '{module_name}' 从 '{file_path}' 失败: {e}", exc_info=True)
|
||||
except AttributeError as e:
|
||||
self.logger.error(f"在模块 '{module_name}' ({file_path}) 中查找测试用例时出错 (可能是缺少必要的元数据如 'id'): {e}", exc_info=True)
|
||||
except Exception as e:
|
||||
self.logger.error(f"处理文件 '{file_path}' 时发生未知错误: {e}", exc_info=True)
|
||||
found_count += 1
|
||||
self.logger.info(f"已注册测试用例: '{obj.id}' ({getattr(obj, 'name', 'N/A')}) 来自类 '{obj.__name__}' (路径: {file_path})")
|
||||
else:
|
||||
self.logger.error(f"无法为文件 '{file_path}' 创建模块规范。")
|
||||
except ImportError as e:
|
||||
self.logger.error(f"导入模块 '{module_name}' 从 '{file_path}' 失败: {e}", exc_info=True)
|
||||
except AttributeError as e:
|
||||
self.logger.error(f"在模块 '{module_name}' ({file_path}) 中查找测试用例时出错: {e}", exc_info=True)
|
||||
except Exception as e:
|
||||
self.logger.error(f"处理文件 '{file_path}' 时发生未知错误: {e}", exc_info=True)
|
||||
|
||||
# 根据 execution_order 对收集到的测试用例类进行排序
|
||||
try:
|
||||
|
||||
@ -13,6 +13,7 @@ from enum import Enum
|
||||
import datetime
|
||||
import datetime as dt
|
||||
from uuid import UUID
|
||||
from dataclasses import asdict as dataclass_asdict, is_dataclass # New import
|
||||
|
||||
from pydantic import BaseModel, Field, create_model
|
||||
from pydantic.networks import EmailStr
|
||||
@ -621,54 +622,182 @@ class APITestOrchestrator:
|
||||
global_api_spec: Union[ParsedYAPISpec, ParsedSwaggerSpec] # 整个API的规格
|
||||
) -> ExecutedTestCaseResult:
|
||||
"""
|
||||
实例化并执行单个APITestCase。
|
||||
"""
|
||||
tc_start_time = time.time()
|
||||
validation_points: List[ValidationResult] = []
|
||||
test_case_instance: Optional[BaseAPITestCase] = None
|
||||
|
||||
endpoint_spec_dict: Dict[str, Any]
|
||||
# 确保 endpoint_spec 转换为字典,以便在测试用例和请求上下文中统一使用
|
||||
if hasattr(endpoint_spec, 'to_dict') and callable(endpoint_spec.to_dict):
|
||||
endpoint_spec_dict = endpoint_spec.to_dict()
|
||||
elif isinstance(endpoint_spec, dict): # 如果它已经是字典 (例如从 OpenAPI 解析器直接过来)
|
||||
endpoint_spec_dict = endpoint_spec
|
||||
elif isinstance(endpoint_spec, (YAPIEndpoint, SwaggerEndpoint)): # 作为后备,从特定类型提取
|
||||
self.logger.debug(f"Manually converting endpoint_spec of type {type(endpoint_spec).__name__} to dict.")
|
||||
endpoint_spec_dict = {
|
||||
"method": getattr(endpoint_spec, 'method', 'UNKNOWN_METHOD'),
|
||||
"path": getattr(endpoint_spec, 'path', 'UNKNOWN_PATH'),
|
||||
"title": getattr(endpoint_spec, 'title', getattr(endpoint_spec, 'summary', '')),
|
||||
"summary": getattr(endpoint_spec, 'summary', ''),
|
||||
"description": getattr(endpoint_spec, 'description', ''),
|
||||
"operationId": getattr(endpoint_spec, 'operation_id',
|
||||
f"{getattr(endpoint_spec, 'method', '').upper()}_{getattr(endpoint_spec, 'path', '').replace('/', '_')}"),
|
||||
# 尝试提取参数和请求体 (简化版)
|
||||
"parameters": getattr(endpoint_spec, 'parameters', []) if isinstance(endpoint_spec, SwaggerEndpoint) else (getattr(endpoint_spec, 'req_query', []) + getattr(endpoint_spec, 'req_headers', [])),
|
||||
"requestBody": getattr(endpoint_spec, 'request_body', None) if isinstance(endpoint_spec, SwaggerEndpoint) else getattr(endpoint_spec, 'req_body_other', None),
|
||||
"_original_object_type": type(endpoint_spec).__name__
|
||||
}
|
||||
else:
|
||||
endpoint_spec_dict = {}
|
||||
self.logger.warning(f"endpoint_spec无法转换为字典,实际类型: {type(endpoint_spec)}")
|
||||
执行单个测试用例。
|
||||
|
||||
global_api_spec_dict: Dict[str, Any]
|
||||
if hasattr(global_api_spec, 'to_dict') and callable(global_api_spec.to_dict):
|
||||
global_api_spec_dict = global_api_spec.to_dict()
|
||||
elif isinstance(global_api_spec, dict):
|
||||
global_api_spec_dict = global_api_spec
|
||||
else:
|
||||
global_api_spec_dict = {}
|
||||
self.logger.warning(f"global_api_spec无法转换为字典,实际类型: {type(global_api_spec)}")
|
||||
流程:
|
||||
1. 准备请求数据 (路径参数, 查询参数, 请求头, 请求体)。
|
||||
- 首先尝试从测试用例的 generate_xxx 方法获取。
|
||||
- 如果测试用例未覆盖或返回None,则尝试从API spec生成默认数据。
|
||||
- 如果开启了LLM,并且测试用例允许,则使用LLM生成。
|
||||
2. (如果适用) 调用测试用例的 modify_request_url 钩子。
|
||||
3. (如果适用) 调用测试用例的 validate_request_url, validate_request_headers, validate_request_body 钩子。
|
||||
4. 发送API请求。
|
||||
5. 记录响应。
|
||||
6. 调用测试用例的 validate_response 和 check_performance 钩子。
|
||||
7. 汇总验证结果,确定测试用例状态。
|
||||
"""
|
||||
start_time = time.monotonic()
|
||||
validation_results: List[ValidationResult] = []
|
||||
overall_status: ExecutedTestCaseResult.Status
|
||||
execution_message = ""
|
||||
|
||||
# 将 endpoint_spec 转换为字典,如果它还不是的话
|
||||
endpoint_spec_dict: Dict[str, Any]
|
||||
if isinstance(endpoint_spec, dict):
|
||||
endpoint_spec_dict = endpoint_spec
|
||||
self.logger.debug(f"endpoint_spec 已经是字典类型。")
|
||||
elif hasattr(endpoint_spec, 'to_dict') and callable(endpoint_spec.to_dict):
|
||||
try:
|
||||
endpoint_spec_dict = endpoint_spec.to_dict()
|
||||
self.logger.debug(f"成功通过 to_dict() 方法将类型为 {type(endpoint_spec)} 的 endpoint_spec 转换为字典。")
|
||||
if not endpoint_spec_dict: # 如果 to_dict() 返回空字典
|
||||
self.logger.warning(f"endpoint_spec.to_dict() (类型: {type(endpoint_spec)}) 返回了一个空字典。")
|
||||
# 尝试备用转换
|
||||
if isinstance(endpoint_spec, (YAPIEndpoint, SwaggerEndpoint)):
|
||||
self.logger.debug(f"尝试从 {type(endpoint_spec).__name__} 对象的属性手动构建 endpoint_spec_dict。")
|
||||
endpoint_spec_dict = {
|
||||
"method": getattr(endpoint_spec, 'method', 'UNKNOWN_METHOD').upper(),
|
||||
"path": getattr(endpoint_spec, 'path', 'UNKNOWN_PATH'),
|
||||
"title": getattr(endpoint_spec, 'title', getattr(endpoint_spec, 'summary', '')),
|
||||
"summary": getattr(endpoint_spec, 'summary', ''),
|
||||
"description": getattr(endpoint_spec, 'description', ''),
|
||||
"operationId": getattr(endpoint_spec, 'operation_id', f"{getattr(endpoint_spec, 'method', '').upper()}_{getattr(endpoint_spec, 'path', '').replace('/', '_')}"),
|
||||
"parameters": getattr(endpoint_spec, 'parameters', []) if hasattr(endpoint_spec, 'parameters') else (getattr(endpoint_spec, 'req_query', []) + getattr(endpoint_spec, 'req_headers', [])),
|
||||
"requestBody": getattr(endpoint_spec, 'request_body', None) if hasattr(endpoint_spec, 'request_body') else getattr(endpoint_spec, 'req_body_other', None),
|
||||
"_original_object_type": type(endpoint_spec).__name__
|
||||
}
|
||||
if not any(endpoint_spec_dict.values()): # 如果手动构建后仍基本为空
|
||||
self.logger.error(f"手动从属性构建 endpoint_spec_dict (类型: {type(endpoint_spec)}) 后仍然为空或无效。")
|
||||
endpoint_spec_dict = {} # 重置为空,触发下方错误处理
|
||||
except Exception as e:
|
||||
self.logger.error(f"调用 endpoint_spec (类型: {type(endpoint_spec)}) 的 to_dict() 方法时出错: {e}。尝试备用转换。")
|
||||
if isinstance(endpoint_spec, (YAPIEndpoint, SwaggerEndpoint)):
|
||||
self.logger.debug(f"尝试从 {type(endpoint_spec).__name__} 对象的属性手动构建 endpoint_spec_dict。")
|
||||
endpoint_spec_dict = {
|
||||
"method": getattr(endpoint_spec, 'method', 'UNKNOWN_METHOD').upper(),
|
||||
"path": getattr(endpoint_spec, 'path', 'UNKNOWN_PATH'),
|
||||
"title": getattr(endpoint_spec, 'title', getattr(endpoint_spec, 'summary', '')),
|
||||
"summary": getattr(endpoint_spec, 'summary', ''),
|
||||
"description": getattr(endpoint_spec, 'description', ''),
|
||||
"operationId": getattr(endpoint_spec, 'operation_id', f"{getattr(endpoint_spec, 'method', '').upper()}_{getattr(endpoint_spec, 'path', '').replace('/', '_')}"),
|
||||
"parameters": getattr(endpoint_spec, 'parameters', []) if hasattr(endpoint_spec, 'parameters') else (getattr(endpoint_spec, 'req_query', []) + getattr(endpoint_spec, 'req_headers', [])),
|
||||
"requestBody": getattr(endpoint_spec, 'request_body', None) if hasattr(endpoint_spec, 'request_body') else getattr(endpoint_spec, 'req_body_other', None),
|
||||
"_original_object_type": type(endpoint_spec).__name__
|
||||
}
|
||||
if not any(endpoint_spec_dict.values()): # 如果手动构建后仍基本为空
|
||||
self.logger.error(f"手动从属性构建 endpoint_spec_dict (类型: {type(endpoint_spec)}) 后仍然为空或无效。")
|
||||
endpoint_spec_dict = {} # 重置为空,触发下方错误处理
|
||||
else:
|
||||
endpoint_spec_dict = {} # 转换失败
|
||||
elif hasattr(endpoint_spec, 'data') and isinstance(getattr(endpoint_spec, 'data'), dict): # 兼容 YAPIEndpoint 结构
|
||||
endpoint_spec_dict = getattr(endpoint_spec, 'data')
|
||||
self.logger.debug(f"使用了类型为 {type(endpoint_spec)} 的 endpoint_spec 的 .data 属性。")
|
||||
else: # 如果没有 to_dict, 也不是已知可直接访问 .data 的类型,则尝试最后的通用转换或手动构建
|
||||
if isinstance(endpoint_spec, (YAPIEndpoint, SwaggerEndpoint)):
|
||||
self.logger.debug(f"类型为 {type(endpoint_spec).__name__} 的 endpoint_spec 没有 to_dict() 或 data,尝试从属性手动构建。")
|
||||
endpoint_spec_dict = {
|
||||
"method": getattr(endpoint_spec, 'method', 'UNKNOWN_METHOD').upper(),
|
||||
"path": getattr(endpoint_spec, 'path', 'UNKNOWN_PATH'),
|
||||
"title": getattr(endpoint_spec, 'title', getattr(endpoint_spec, 'summary', '')),
|
||||
"summary": getattr(endpoint_spec, 'summary', ''),
|
||||
"description": getattr(endpoint_spec, 'description', ''),
|
||||
"operationId": getattr(endpoint_spec, 'operation_id', f"{getattr(endpoint_spec, 'method', '').upper()}_{getattr(endpoint_spec, 'path', '').replace('/', '_')}"),
|
||||
"parameters": getattr(endpoint_spec, 'parameters', []) if hasattr(endpoint_spec, 'parameters') else (getattr(endpoint_spec, 'req_query', []) + getattr(endpoint_spec, 'req_headers', [])),
|
||||
"requestBody": getattr(endpoint_spec, 'request_body', None) if hasattr(endpoint_spec, 'request_body') else getattr(endpoint_spec, 'req_body_other', None),
|
||||
"_original_object_type": type(endpoint_spec).__name__
|
||||
}
|
||||
if not any(endpoint_spec_dict.values()): # 如果手动构建后仍基本为空
|
||||
self.logger.error(f"手动从属性构建 endpoint_spec_dict (类型: {type(endpoint_spec)}) 后仍然为空或无效。")
|
||||
endpoint_spec_dict = {} # 重置为空,触发下方错误处理
|
||||
else:
|
||||
try:
|
||||
endpoint_spec_dict = dict(endpoint_spec)
|
||||
self.logger.warning(f"直接将类型为 {type(endpoint_spec)} 的 endpoint_spec 转换为字典。这可能是一个浅拷贝,并且可能不完整。")
|
||||
except TypeError:
|
||||
self.logger.error(f"无法将 endpoint_spec (类型: {type(endpoint_spec)}) 转换为字典,也未找到有效的转换方法。")
|
||||
endpoint_spec_dict = {}
|
||||
|
||||
if not endpoint_spec_dict or not endpoint_spec_dict.get("path") or endpoint_spec_dict.get("path") == 'UNKNOWN_PATH': # 如果转换后仍为空或无效
|
||||
self.logger.error(f"Endpoint spec (原始类型: {type(endpoint_spec)}) 无法有效转换为包含有效路径的字典,测试用例执行可能受影响。最终 endpoint_spec_dict: {endpoint_spec_dict}")
|
||||
# 创建一个最小的 endpoint_spec_dict 以允许测试用例实例化,但它将缺少大部分信息
|
||||
endpoint_spec_dict = {
|
||||
'method': endpoint_spec_dict.get('method', 'UNKNOWN_METHOD'), # 保留已解析的方法
|
||||
'path': 'UNKNOWN_PATH_CONVERSION_FAILED',
|
||||
'title': f"Unknown endpoint due to spec conversion error for original type {type(endpoint_spec)}",
|
||||
'parameters': [], # 确保有空的 parameters 和 requestBody
|
||||
'requestBody': None
|
||||
}
|
||||
|
||||
# 确保 global_api_spec (应该是 ParsedSwaggerSpec 或 ParsedYAPISpec 实例) 被转换为字典
|
||||
global_spec_dict: Dict[str, Any] = {}
|
||||
converted_by_method: Optional[str] = None
|
||||
|
||||
if hasattr(global_api_spec, 'spec') and isinstance(getattr(global_api_spec, 'spec', None), dict) and getattr(global_api_spec, 'spec', None):
|
||||
global_spec_dict = global_api_spec.spec # type: ignore
|
||||
converted_by_method = ".spec attribute"
|
||||
elif is_dataclass(global_api_spec) and not isinstance(global_api_spec, type): # Ensure it's an instance, not the class itself
|
||||
try:
|
||||
candidate_spec = dataclass_asdict(global_api_spec)
|
||||
if isinstance(candidate_spec, dict) and candidate_spec:
|
||||
global_spec_dict = candidate_spec
|
||||
converted_by_method = "dataclasses.asdict()"
|
||||
except Exception as e:
|
||||
self.logger.debug(f"Calling dataclasses.asdict() on {type(global_api_spec)} failed: {e}, trying other methods.")
|
||||
|
||||
if not global_spec_dict and hasattr(global_api_spec, 'model_dump') and callable(global_api_spec.model_dump):
|
||||
try:
|
||||
candidate_spec = global_api_spec.model_dump()
|
||||
if isinstance(candidate_spec, dict) and candidate_spec:
|
||||
global_spec_dict = candidate_spec
|
||||
converted_by_method = ".model_dump()"
|
||||
except Exception as e:
|
||||
self.logger.debug(f"Calling .model_dump() on {type(global_api_spec)} failed: {e}, trying other methods.")
|
||||
|
||||
if not global_spec_dict and hasattr(global_api_spec, 'dict') and callable(global_api_spec.dict):
|
||||
try:
|
||||
candidate_spec = global_api_spec.dict()
|
||||
if isinstance(candidate_spec, dict) and candidate_spec:
|
||||
global_spec_dict = candidate_spec
|
||||
converted_by_method = ".dict()"
|
||||
except Exception as e:
|
||||
self.logger.debug(f"Calling .dict() on {type(global_api_spec)} failed: {e}, trying other methods.")
|
||||
|
||||
if not global_spec_dict and hasattr(global_api_spec, 'to_dict') and callable(global_api_spec.to_dict):
|
||||
try:
|
||||
candidate_spec = global_api_spec.to_dict()
|
||||
if isinstance(candidate_spec, dict) and candidate_spec:
|
||||
global_spec_dict = candidate_spec
|
||||
converted_by_method = ".to_dict()"
|
||||
except Exception as e:
|
||||
self.logger.debug(f"Calling .to_dict() on {type(global_api_spec)} failed: {e}, trying other methods.")
|
||||
|
||||
if not global_spec_dict and isinstance(global_api_spec, dict) and global_api_spec:
|
||||
global_spec_dict = global_api_spec
|
||||
converted_by_method = "direct dict"
|
||||
self.logger.warning(f"global_api_spec was already a dictionary. This might be unexpected if an object was anticipated.")
|
||||
|
||||
if global_spec_dict and converted_by_method:
|
||||
self.logger.debug(f"Successfully converted/retrieved global_api_spec (type: {type(global_api_spec)}) to dict using {converted_by_method}.")
|
||||
elif not global_spec_dict :
|
||||
self.logger.error(
|
||||
f"Failed to convert global_api_spec (type: {type(global_api_spec)}) to a non-empty dictionary using .spec, dataclasses.asdict(), .model_dump(), .dict(), or .to_dict(). "
|
||||
f"It's also not a non-empty dictionary itself. JSON reference resolution will be severely limited or fail. Using empty global_spec_dict."
|
||||
)
|
||||
global_spec_dict = {}
|
||||
|
||||
# 将 global_spec_dict 注入到 endpoint_spec_dict 中,供可能的内部解析使用 (如果 to_dict 未包含它)
|
||||
if '_global_api_spec_for_resolution' not in endpoint_spec_dict and global_spec_dict:
|
||||
endpoint_spec_dict['_global_api_spec_for_resolution'] = global_spec_dict
|
||||
|
||||
|
||||
try:
|
||||
self.logger.debug(f"准备实例化测试用例类: {test_case_class.__name__} 使用 endpoint_spec (keys: {list(endpoint_spec_dict.keys()) if endpoint_spec_dict else 'None'}) 和 global_api_spec (keys: {list(global_spec_dict.keys()) if global_spec_dict else 'None'})")
|
||||
test_case_instance = test_case_class(
|
||||
endpoint_spec=endpoint_spec_dict,
|
||||
global_api_spec=global_api_spec_dict,
|
||||
json_schema_validator=self.validator # <--- 注入 JSONSchemaValidator
|
||||
endpoint_spec=endpoint_spec_dict,
|
||||
global_api_spec=global_spec_dict,
|
||||
json_schema_validator=self.validator
|
||||
)
|
||||
test_case_instance.logger.info(f"开始执行测试用例 '{test_case_instance.id}' for endpoint '{endpoint_spec_dict.get('method')} {endpoint_spec_dict.get('path')}'")
|
||||
self.logger.info(f"开始执行测试用例 '{test_case_instance.id}' ({test_case_instance.name}) for endpoint '{endpoint_spec_dict.get('method', 'N/A')} {endpoint_spec_dict.get('path', 'N/A')}'")
|
||||
|
||||
# 调用 _prepare_initial_request_data 时传递 test_case_instance
|
||||
# 并直接解包返回的元组
|
||||
@ -722,26 +851,26 @@ class APITestOrchestrator:
|
||||
endpoint_spec=endpoint_spec_dict
|
||||
)
|
||||
|
||||
validation_points.extend(test_case_instance.validate_request_url(api_request_context.url, api_request_context))
|
||||
validation_points.extend(test_case_instance.validate_request_headers(api_request_context.headers, api_request_context))
|
||||
validation_points.extend(test_case_instance.validate_request_body(api_request_context.body, api_request_context))
|
||||
validation_results.extend(test_case_instance.validate_request_url(api_request_context.url, api_request_context))
|
||||
validation_results.extend(test_case_instance.validate_request_headers(api_request_context.headers, api_request_context))
|
||||
validation_results.extend(test_case_instance.validate_request_body(api_request_context.body, api_request_context))
|
||||
|
||||
critical_pre_validation_failure = False
|
||||
failure_messages = []
|
||||
for vp in validation_points:
|
||||
for vp in validation_results:
|
||||
if not vp.passed and test_case_instance.severity in [TestSeverity.CRITICAL, TestSeverity.HIGH]: # Check severity of the Test Case for pre-validation
|
||||
critical_pre_validation_failure = True
|
||||
failure_messages.append(vp.message)
|
||||
|
||||
if critical_pre_validation_failure:
|
||||
self.logger.warning(f"测试用例 '{test_case_instance.id}' 因请求预校验失败而中止 (TC严重级别: {test_case_instance.severity.value})。失败信息: {'; '.join(failure_messages)}")
|
||||
tc_duration = time.time() - tc_start_time
|
||||
tc_duration = time.monotonic() - start_time
|
||||
return ExecutedTestCaseResult(
|
||||
test_case_id=test_case_instance.id,
|
||||
test_case_name=test_case_instance.name,
|
||||
test_case_severity=test_case_instance.severity,
|
||||
status=ExecutedTestCaseResult.Status.FAILED,
|
||||
validation_points=validation_points,
|
||||
validation_points=validation_results,
|
||||
message=f"请求预校验失败: {'; '.join(failure_messages)}",
|
||||
duration=tc_duration
|
||||
)
|
||||
@ -781,32 +910,32 @@ class APITestOrchestrator:
|
||||
request_context=api_request_context
|
||||
)
|
||||
|
||||
validation_points.extend(test_case_instance.validate_response(api_response_context, api_request_context))
|
||||
validation_points.extend(test_case_instance.check_performance(api_response_context, api_request_context))
|
||||
validation_results.extend(test_case_instance.validate_response(api_response_context, api_request_context))
|
||||
validation_results.extend(test_case_instance.check_performance(api_response_context, api_request_context))
|
||||
|
||||
final_status = ExecutedTestCaseResult.Status.PASSED
|
||||
if any(not vp.passed for vp in validation_points):
|
||||
if any(not vp.passed for vp in validation_results):
|
||||
final_status = ExecutedTestCaseResult.Status.FAILED
|
||||
|
||||
tc_duration = time.time() - tc_start_time
|
||||
tc_duration = time.monotonic() - start_time
|
||||
return ExecutedTestCaseResult(
|
||||
test_case_id=test_case_instance.id,
|
||||
test_case_name=test_case_instance.name,
|
||||
test_case_severity=test_case_instance.severity,
|
||||
status=final_status,
|
||||
validation_points=validation_points,
|
||||
validation_points=validation_results,
|
||||
duration=tc_duration
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"执行测试用例 '{test_case_class.id if test_case_instance else test_case_class.__name__}' 时发生严重错误: {e}", exc_info=True)
|
||||
tc_duration = time.time() - tc_start_time
|
||||
tc_duration = time.monotonic() - start_time
|
||||
return ExecutedTestCaseResult(
|
||||
test_case_id=test_case_instance.id if test_case_instance else test_case_class.id if hasattr(test_case_class, 'id') else "unknown_tc_id",
|
||||
test_case_name=test_case_instance.name if test_case_instance else test_case_class.name if hasattr(test_case_class, 'name') else "Unknown Test Case Name",
|
||||
test_case_severity=test_case_instance.severity if test_case_instance else TestSeverity.CRITICAL,
|
||||
status=ExecutedTestCaseResult.Status.ERROR,
|
||||
validation_points=validation_points,
|
||||
validation_points=validation_results,
|
||||
message=f"测试用例执行时发生内部错误: {str(e)}",
|
||||
duration=tc_duration
|
||||
)
|
||||
|
||||
314
test_report.json
314
test_report.json
@ -1,73 +1,64 @@
|
||||
{
|
||||
"summary_metadata": {
|
||||
"start_time": "2025-05-21T18:30:56.368119",
|
||||
"end_time": "2025-05-21T18:30:56.681215",
|
||||
"duration_seconds": "0.31"
|
||||
"start_time": "2025-05-23T12:04:50.246346",
|
||||
"end_time": "2025-05-23T12:04:51.085891",
|
||||
"duration_seconds": "0.84"
|
||||
},
|
||||
"endpoint_stats": {
|
||||
"total_defined": 6,
|
||||
"total_tested": 6,
|
||||
"passed": 0,
|
||||
"failed": 0,
|
||||
"partial_success": 6,
|
||||
"passed": 3,
|
||||
"failed": 3,
|
||||
"partial_success": 0,
|
||||
"error": 0,
|
||||
"skipped": 0,
|
||||
"success_rate_percentage": "0.00"
|
||||
"success_rate_percentage": "50.00"
|
||||
},
|
||||
"test_case_stats": {
|
||||
"total_applicable": 12,
|
||||
"total_executed": 12,
|
||||
"passed": 6,
|
||||
"failed": 6,
|
||||
"passed": 8,
|
||||
"failed": 4,
|
||||
"error_in_execution": 0,
|
||||
"skipped_during_endpoint_execution": 0,
|
||||
"success_rate_percentage": "50.00"
|
||||
"success_rate_percentage": "66.67"
|
||||
},
|
||||
"detailed_results": [
|
||||
{
|
||||
"endpoint_id": "POST /api/dms/{dms_instance_code}/v1/message/push/{schema}/{version}",
|
||||
"endpoint_name": "数据推送接口",
|
||||
"overall_status": "部分成功",
|
||||
"duration_seconds": 0.089518,
|
||||
"start_time": "2025-05-21T18:30:56.368227",
|
||||
"end_time": "2025-05-21T18:30:56.457745",
|
||||
"overall_status": "通过",
|
||||
"duration_seconds": 0.208552,
|
||||
"start_time": "2025-05-23T12:04:50.246523",
|
||||
"end_time": "2025-05-23T12:04:50.455075",
|
||||
"executed_test_cases": [
|
||||
{
|
||||
"test_case_id": "TC-HEADER-001",
|
||||
"test_case_name": "检查响应中是否存在 'X-Request-ID' 头",
|
||||
"test_case_severity": "中",
|
||||
"status": "失败",
|
||||
"test_case_id": "TC-ERROR-4003-BODY",
|
||||
"test_case_name": "Error Code 4003 - Missing Required Request Body Field Validation",
|
||||
"test_case_severity": "高",
|
||||
"status": "通过",
|
||||
"message": "",
|
||||
"duration_seconds": 0.06334280967712402,
|
||||
"timestamp": "2025-05-21T18:30:56.431606",
|
||||
"duration_seconds": 0.14286641706712544,
|
||||
"timestamp": "2025-05-23T12:04:50.389451",
|
||||
"validation_points": [
|
||||
{
|
||||
"expected_header": "X-Request-ID",
|
||||
"actual_headers": [
|
||||
"Vary",
|
||||
"Access-Control-Allow-Origin",
|
||||
"Content-Type",
|
||||
"Content-Length",
|
||||
"success",
|
||||
"Date",
|
||||
"Connection",
|
||||
"Keep-Alive"
|
||||
]
|
||||
"passed": true,
|
||||
"message": "跳过测试:在API规范中未找到合适的必填请求体字段用于移除测试。"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"test_case_id": "TC-STATUS-001",
|
||||
"test_case_name": "基本状态码 200 检查",
|
||||
"test_case_severity": "严重",
|
||||
"test_case_id": "TC-ERROR-4003-QUERY",
|
||||
"test_case_name": "Error Code 4003 - Missing Required Query Parameter Validation",
|
||||
"test_case_severity": "高",
|
||||
"status": "通过",
|
||||
"message": "",
|
||||
"duration_seconds": 0.026033878326416016,
|
||||
"timestamp": "2025-05-21T18:30:56.457700",
|
||||
"duration_seconds": 0.06538325012661517,
|
||||
"timestamp": "2025-05-23T12:04:50.454968",
|
||||
"validation_points": [
|
||||
{
|
||||
"passed": true,
|
||||
"message": "响应状态码为 200,符合预期 200。"
|
||||
"message": "跳过测试:在API规范中未找到合适的必填查询参数用于移除测试。"
|
||||
}
|
||||
]
|
||||
}
|
||||
@ -76,47 +67,38 @@
|
||||
{
|
||||
"endpoint_id": "POST /api/dms/{dms_instance_code}/v1/cd_geo_unit/{version}",
|
||||
"endpoint_name": "地质单元列表查询",
|
||||
"overall_status": "部分成功",
|
||||
"duration_seconds": 0.041726,
|
||||
"start_time": "2025-05-21T18:30:56.457776",
|
||||
"end_time": "2025-05-21T18:30:56.499502",
|
||||
"overall_status": "通过",
|
||||
"duration_seconds": 0.149931,
|
||||
"start_time": "2025-05-23T12:04:50.455128",
|
||||
"end_time": "2025-05-23T12:04:50.605059",
|
||||
"executed_test_cases": [
|
||||
{
|
||||
"test_case_id": "TC-HEADER-001",
|
||||
"test_case_name": "检查响应中是否存在 'X-Request-ID' 头",
|
||||
"test_case_severity": "中",
|
||||
"status": "失败",
|
||||
"test_case_id": "TC-ERROR-4003-BODY",
|
||||
"test_case_name": "Error Code 4003 - Missing Required Request Body Field Validation",
|
||||
"test_case_severity": "高",
|
||||
"status": "通过",
|
||||
"message": "",
|
||||
"duration_seconds": 0.022691011428833008,
|
||||
"timestamp": "2025-05-21T18:30:56.480802",
|
||||
"duration_seconds": 0.0675940418150276,
|
||||
"timestamp": "2025-05-23T12:04:50.522809",
|
||||
"validation_points": [
|
||||
{
|
||||
"expected_header": "X-Request-ID",
|
||||
"actual_headers": [
|
||||
"Vary",
|
||||
"Access-Control-Allow-Origin",
|
||||
"Content-Type",
|
||||
"Content-Length",
|
||||
"success",
|
||||
"Date",
|
||||
"Connection",
|
||||
"Keep-Alive"
|
||||
]
|
||||
"passed": true,
|
||||
"message": "跳过测试:在API规范中未找到合适的必填请求体字段用于移除测试。"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"test_case_id": "TC-STATUS-001",
|
||||
"test_case_name": "基本状态码 200 检查",
|
||||
"test_case_severity": "严重",
|
||||
"test_case_id": "TC-ERROR-4003-QUERY",
|
||||
"test_case_name": "Error Code 4003 - Missing Required Query Parameter Validation",
|
||||
"test_case_severity": "高",
|
||||
"status": "通过",
|
||||
"message": "",
|
||||
"duration_seconds": 0.01860499382019043,
|
||||
"timestamp": "2025-05-21T18:30:56.499463",
|
||||
"duration_seconds": 0.08191158319823444,
|
||||
"timestamp": "2025-05-23T12:04:50.604854",
|
||||
"validation_points": [
|
||||
{
|
||||
"passed": true,
|
||||
"message": "响应状态码为 200,符合预期 200。"
|
||||
"message": "跳过测试:在API规范中未找到合适的必填查询参数用于移除测试。"
|
||||
}
|
||||
]
|
||||
}
|
||||
@ -125,47 +107,48 @@
|
||||
{
|
||||
"endpoint_id": "PUT /api/dms/{dms_instance_code}/v1/cd_geo_unit",
|
||||
"endpoint_name": "地质单元数据修改",
|
||||
"overall_status": "部分成功",
|
||||
"duration_seconds": 0.03492,
|
||||
"start_time": "2025-05-21T18:30:56.499530",
|
||||
"end_time": "2025-05-21T18:30:56.534450",
|
||||
"overall_status": "失败",
|
||||
"duration_seconds": 0.093809,
|
||||
"start_time": "2025-05-23T12:04:50.605172",
|
||||
"end_time": "2025-05-23T12:04:50.698981",
|
||||
"executed_test_cases": [
|
||||
{
|
||||
"test_case_id": "TC-HEADER-001",
|
||||
"test_case_name": "检查响应中是否存在 'X-Request-ID' 头",
|
||||
"test_case_severity": "中",
|
||||
"test_case_id": "TC-ERROR-4003-BODY",
|
||||
"test_case_name": "Error Code 4003 - Missing Required Request Body Field Validation",
|
||||
"test_case_severity": "高",
|
||||
"status": "失败",
|
||||
"message": "",
|
||||
"duration_seconds": 0.01642608642578125,
|
||||
"timestamp": "2025-05-21T18:30:56.515996",
|
||||
"duration_seconds": 0.05135583318769932,
|
||||
"timestamp": "2025-05-23T12:04:50.656709",
|
||||
"validation_points": [
|
||||
{
|
||||
"expected_header": "X-Request-ID",
|
||||
"actual_headers": [
|
||||
"Vary",
|
||||
"Access-Control-Allow-Origin",
|
||||
"Content-Type",
|
||||
"Content-Length",
|
||||
"success",
|
||||
"Date",
|
||||
"Connection",
|
||||
"Keep-Alive"
|
||||
]
|
||||
"status_code": 200,
|
||||
"response_body": {
|
||||
"code": 46,
|
||||
"message": "id sint voluptate dolor amet",
|
||||
"data": false
|
||||
},
|
||||
"removed_field": "body.data.0.bsflag"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"test_case_id": "TC-STATUS-001",
|
||||
"test_case_name": "基本状态码 200 检查",
|
||||
"test_case_severity": "严重",
|
||||
"status": "通过",
|
||||
"test_case_id": "TC-ERROR-4003-QUERY",
|
||||
"test_case_name": "Error Code 4003 - Missing Required Query Parameter Validation",
|
||||
"test_case_severity": "高",
|
||||
"status": "失败",
|
||||
"message": "",
|
||||
"duration_seconds": 0.018361806869506836,
|
||||
"timestamp": "2025-05-21T18:30:56.534410",
|
||||
"duration_seconds": 0.04197045904584229,
|
||||
"timestamp": "2025-05-23T12:04:50.698867",
|
||||
"validation_points": [
|
||||
{
|
||||
"passed": true,
|
||||
"message": "响应状态码为 200,符合预期 200。"
|
||||
"status_code": 200,
|
||||
"response_body": {
|
||||
"code": 67,
|
||||
"message": "Duis sint in",
|
||||
"data": false
|
||||
},
|
||||
"removed_field": "query.id"
|
||||
}
|
||||
]
|
||||
}
|
||||
@ -174,47 +157,43 @@
|
||||
{
|
||||
"endpoint_id": "DELETE /api/dms/{dms_instance_code}/v1/cd_geo_unit",
|
||||
"endpoint_name": "地质单元数据删除",
|
||||
"overall_status": "部分成功",
|
||||
"duration_seconds": 0.035115,
|
||||
"start_time": "2025-05-21T18:30:56.534480",
|
||||
"end_time": "2025-05-21T18:30:56.569595",
|
||||
"overall_status": "失败",
|
||||
"duration_seconds": 0.085489,
|
||||
"start_time": "2025-05-23T12:04:50.699033",
|
||||
"end_time": "2025-05-23T12:04:50.784522",
|
||||
"executed_test_cases": [
|
||||
{
|
||||
"test_case_id": "TC-HEADER-001",
|
||||
"test_case_name": "检查响应中是否存在 'X-Request-ID' 头",
|
||||
"test_case_severity": "中",
|
||||
"status": "失败",
|
||||
"test_case_id": "TC-ERROR-4003-BODY",
|
||||
"test_case_name": "Error Code 4003 - Missing Required Request Body Field Validation",
|
||||
"test_case_severity": "高",
|
||||
"status": "通过",
|
||||
"message": "",
|
||||
"duration_seconds": 0.019882917404174805,
|
||||
"timestamp": "2025-05-21T18:30:56.554426",
|
||||
"duration_seconds": 0.04377300012856722,
|
||||
"timestamp": "2025-05-23T12:04:50.742882",
|
||||
"validation_points": [
|
||||
{
|
||||
"expected_header": "X-Request-ID",
|
||||
"actual_headers": [
|
||||
"Vary",
|
||||
"Access-Control-Allow-Origin",
|
||||
"Content-Type",
|
||||
"Content-Length",
|
||||
"success",
|
||||
"Date",
|
||||
"Connection",
|
||||
"Keep-Alive"
|
||||
]
|
||||
"passed": true,
|
||||
"message": "跳过测试:在API规范中未找到合适的必填请求体字段用于移除测试。"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"test_case_id": "TC-STATUS-001",
|
||||
"test_case_name": "基本状态码 200 检查",
|
||||
"test_case_severity": "严重",
|
||||
"status": "通过",
|
||||
"test_case_id": "TC-ERROR-4003-QUERY",
|
||||
"test_case_name": "Error Code 4003 - Missing Required Query Parameter Validation",
|
||||
"test_case_severity": "高",
|
||||
"status": "失败",
|
||||
"message": "",
|
||||
"duration_seconds": 0.015078067779541016,
|
||||
"timestamp": "2025-05-21T18:30:56.569559",
|
||||
"duration_seconds": 0.04126858292147517,
|
||||
"timestamp": "2025-05-23T12:04:50.784281",
|
||||
"validation_points": [
|
||||
{
|
||||
"passed": true,
|
||||
"message": "响应状态码为 200,符合预期 200。"
|
||||
"status_code": 200,
|
||||
"response_body": {
|
||||
"code": 95,
|
||||
"message": "deserunt et enim",
|
||||
"data": true
|
||||
},
|
||||
"removed_field": "query.id"
|
||||
}
|
||||
]
|
||||
}
|
||||
@ -223,47 +202,43 @@
|
||||
{
|
||||
"endpoint_id": "POST /api/dms/{dms_instance_code}/v1/cd_geo_unit",
|
||||
"endpoint_name": "地质单元数据添加",
|
||||
"overall_status": "部分成功",
|
||||
"duration_seconds": 0.033789,
|
||||
"start_time": "2025-05-21T18:30:56.569621",
|
||||
"end_time": "2025-05-21T18:30:56.603410",
|
||||
"overall_status": "失败",
|
||||
"duration_seconds": 0.167005,
|
||||
"start_time": "2025-05-23T12:04:50.784657",
|
||||
"end_time": "2025-05-23T12:04:50.951662",
|
||||
"executed_test_cases": [
|
||||
{
|
||||
"test_case_id": "TC-HEADER-001",
|
||||
"test_case_name": "检查响应中是否存在 'X-Request-ID' 头",
|
||||
"test_case_severity": "中",
|
||||
"test_case_id": "TC-ERROR-4003-BODY",
|
||||
"test_case_name": "Error Code 4003 - Missing Required Request Body Field Validation",
|
||||
"test_case_severity": "高",
|
||||
"status": "失败",
|
||||
"message": "",
|
||||
"duration_seconds": 0.01661515235900879,
|
||||
"timestamp": "2025-05-21T18:30:56.586277",
|
||||
"duration_seconds": 0.06845587491989136,
|
||||
"timestamp": "2025-05-23T12:04:50.853268",
|
||||
"validation_points": [
|
||||
{
|
||||
"expected_header": "X-Request-ID",
|
||||
"actual_headers": [
|
||||
"Vary",
|
||||
"Access-Control-Allow-Origin",
|
||||
"Content-Type",
|
||||
"Content-Length",
|
||||
"success",
|
||||
"Date",
|
||||
"Connection",
|
||||
"Keep-Alive"
|
||||
]
|
||||
"status_code": 200,
|
||||
"response_body": {
|
||||
"code": 76,
|
||||
"message": "et sunt deserunt",
|
||||
"data": false
|
||||
},
|
||||
"removed_field": "body.data.0.bsflag"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"test_case_id": "TC-STATUS-001",
|
||||
"test_case_name": "基本状态码 200 检查",
|
||||
"test_case_severity": "严重",
|
||||
"test_case_id": "TC-ERROR-4003-QUERY",
|
||||
"test_case_name": "Error Code 4003 - Missing Required Query Parameter Validation",
|
||||
"test_case_severity": "高",
|
||||
"status": "通过",
|
||||
"message": "",
|
||||
"duration_seconds": 0.017055988311767578,
|
||||
"timestamp": "2025-05-21T18:30:56.603376",
|
||||
"duration_seconds": 0.09789391607046127,
|
||||
"timestamp": "2025-05-23T12:04:50.951331",
|
||||
"validation_points": [
|
||||
{
|
||||
"passed": true,
|
||||
"message": "响应状态码为 200,符合预期 200。"
|
||||
"message": "跳过测试:在API规范中未找到合适的必填查询参数用于移除测试。"
|
||||
}
|
||||
]
|
||||
}
|
||||
@ -272,47 +247,38 @@
|
||||
{
|
||||
"endpoint_id": "GET /api/dms/{dms_instance_code}/v1/cd_geo_unit/{version}/{id}",
|
||||
"endpoint_name": "地质单元查询详情",
|
||||
"overall_status": "部分成功",
|
||||
"duration_seconds": 0.077762,
|
||||
"start_time": "2025-05-21T18:30:56.603434",
|
||||
"end_time": "2025-05-21T18:30:56.681196",
|
||||
"overall_status": "通过",
|
||||
"duration_seconds": 0.134108,
|
||||
"start_time": "2025-05-23T12:04:50.951739",
|
||||
"end_time": "2025-05-23T12:04:51.085847",
|
||||
"executed_test_cases": [
|
||||
{
|
||||
"test_case_id": "TC-HEADER-001",
|
||||
"test_case_name": "检查响应中是否存在 'X-Request-ID' 头",
|
||||
"test_case_severity": "中",
|
||||
"status": "失败",
|
||||
"test_case_id": "TC-ERROR-4003-BODY",
|
||||
"test_case_name": "Error Code 4003 - Missing Required Request Body Field Validation",
|
||||
"test_case_severity": "高",
|
||||
"status": "通过",
|
||||
"message": "",
|
||||
"duration_seconds": 0.020492076873779297,
|
||||
"timestamp": "2025-05-21T18:30:56.623967",
|
||||
"duration_seconds": 0.08160599996335804,
|
||||
"timestamp": "2025-05-23T12:04:51.033450",
|
||||
"validation_points": [
|
||||
{
|
||||
"expected_header": "X-Request-ID",
|
||||
"actual_headers": [
|
||||
"Vary",
|
||||
"Access-Control-Allow-Origin",
|
||||
"Content-Type",
|
||||
"Content-Length",
|
||||
"success",
|
||||
"Date",
|
||||
"Connection",
|
||||
"Keep-Alive"
|
||||
]
|
||||
"passed": true,
|
||||
"message": "跳过测试:在API规范中未找到合适的必填请求体字段用于移除测试。"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"test_case_id": "TC-STATUS-001",
|
||||
"test_case_name": "基本状态码 200 检查",
|
||||
"test_case_severity": "严重",
|
||||
"test_case_id": "TC-ERROR-4003-QUERY",
|
||||
"test_case_name": "Error Code 4003 - Missing Required Query Parameter Validation",
|
||||
"test_case_severity": "高",
|
||||
"status": "通过",
|
||||
"message": "",
|
||||
"duration_seconds": 0.05701398849487305,
|
||||
"timestamp": "2025-05-21T18:30:56.681140",
|
||||
"duration_seconds": 0.05193216586485505,
|
||||
"timestamp": "2025-05-23T12:04:51.085646",
|
||||
"validation_points": [
|
||||
{
|
||||
"passed": true,
|
||||
"message": "响应状态码为 200,符合预期 200。"
|
||||
"message": "跳过测试:在API规范中未找到合适的必填查询参数用于移除测试。"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user