import json import os import re import sys import uuid from collections import OrderedDict from typing import Any, Dict, List, Optional, Tuple from pydantic import BaseModel import requests import yaml from environs import Env from llmops.utils.json_utils import format_json_string_by_ai sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) class Auth(BaseModel): auth_type: str content: Optional[str] def model_dump(self, **kwargs): data = super().model_dump(**kwargs) data["content"] = ( format_json_string_by_ai(self.content) if self.content else "{}" ) return data class ReusableStep(BaseModel): # case_id: str operation_id: str # case_type: str request_type: str request: str expect: str | None save: str | None description: str max_retries: int delay_after: int name: str continue_on_unexpected: bool stage: str meta: str generation_id: str order: int class SwaggerTestGenerator: def __init__(self): self.env = Env() self.env.read_env() self.github_token = self.env.str("GITHUB_TOKEN") def get_github_file_content(self, url: str) -> Dict[str, Any]: """ Fetch content from GitHub URL and return as dict Example: https://github.com/tidbcloud/account/blob/main/central/docs/service.swagger.json """ parts = url.replace("https://github.com/", "").split("/") owner = parts[0] repo = parts[1] path = "/".join(parts[4:]) # skip "blob/main" or "blob/branch" branch = parts[3] api_url = f"https://api.github.com/repos/{owner}/{repo}/contents/{path}" headers = { "Authorization": f"token {self.github_token}", "Accept": "application/vnd.github.v3.raw", } params = {"ref": branch} response = requests.get(api_url, headers=headers, params=params, timeout=30) if response.status_code == 200: if url.endswith(".json"): return json.loads(response.text) return {"content": response.text} raise Exception( f"Failed to fetch file: {response.status_code} - {response.text}" ) def extract_operations( self, swagger_spec: Dict[str, Any] ) -> List[Tuple[str, str, str]]: """ Extract all operations (path, method, operationId) from swagger spec Handles basePath logic: - If no basePath: use path as-is - If basePath exists and not in first path: prepend basePath to path - If basePath exists and already in first path: use path as-is Returns list of tuples: (path, method, operationId) """ operations = [] base_path = swagger_spec.get("basePath", "") paths = swagger_spec.get("paths", {}) # Check if basePath should be prepended should_prepend_base_path = False if base_path and paths: # Get the first path to check if basePath is already included first_path = next(iter(paths.keys())) if not first_path.startswith(base_path): should_prepend_base_path = True for path, path_info in paths.items(): # Apply basePath logic final_path = path if should_prepend_base_path: final_path = base_path.rstrip("/") + "/" + path.lstrip("/") for method, operation_info in path_info.items(): if isinstance(operation_info, dict): # Use operationId if available, otherwise generate from method_path if "operationId" in operation_info: operation_id = operation_info["operationId"] else: # Generate operationId from method_path pattern path_clean = re.sub( r"[^a-zA-Z0-9_]", "_", final_path.strip("/") ) path_clean = re.sub(r"_+", "_", path_clean).strip("_") operation_id = ( f"{method.lower()}_{path_clean}" if path_clean else method.lower() ) operations.append((final_path, method.upper(), operation_id)) return operations def replace_path_parameters(self, path: str, placeholder: str = "123") -> str: """ Replace path parameters with placeholder value '123' Supports two formats: 1. {xxx} format: /clusters/{clusterId}/nodes/{nodeId} -> /clusters/123/nodes/123 2. :xxx format: /clusters/:clusterId/nodes/:nodeId -> /clusters/123/nodes/123 """ # Replace {xxx} format path = re.sub(r"\{[^}]+\}", placeholder, path) # Replace :xxx format (only after /) path = re.sub(r"/:([a-zA-Z_][a-zA-Z0-9_]*)", f"/{placeholder}", path) return path def replace_placeholders(self, path: str) -> str: # 定义特殊映射 mapping = { "org_id": "1369847559692509562", "project_id": "1369847559694040704", "cluster_id": "10514057901025967609", "clusterId": "10514057901025967609", "tidbId": "10514057901025967609", "cluster.clusterId": "10514057901025967609", "app_id": "1207102", "external_app_id": "dataapp-kgQBETHJ", } # 匹配 {xxx} 的正则 pattern = re.compile(r"\{(\w+)\}") def replacer(match): key = match.group(1) # 提取括号里的内容 return mapping.get(key, "123") # 特殊的用 mapping,其他默认 123 return pattern.sub(replacer, path) def generate_403_test_cases( self, path: str, method: str, operation_id: str, generation_id: str, api_type: str, ) -> List[ReusableStep]: """ Generate multiple 403 test cases for given operation with different X-Auth-Method headers For basic method: 3 cases (no X-Auth-Type, X-Auth-Type: openapi, X-Auth-Type: console) For digest/bearer methods: 1 case each """ # auth_methods = ["digest", "bearer"] # auth_types = ["openapi", "console"] test_cases = [] case_counter = 1 # Replace path parameters with placeholder values # processed_path = self.replace_path_parameters(path) processed_path = self.replace_placeholders(path) if api_type == "openapi": # For basic method, generate 3 cases # Case 1: No X-Auth-Type case_id = f"{generation_id}_{case_counter}" headers = { "accept": "application/json, text/plain, */*", "accept-language": "zh-CN,zh;q=0.9", "X-Auth-Method": "digest", } test_case = self._create_test_case( case_id, operation_id, generation_id, method, processed_path, headers, f"Test 403 unauthorized access for {operation_id} with X-Auth-Method: digest", f"test_403_{operation_id.lower()}_digest_no_type", "digest", case_counter, ) test_cases.append(test_case) case_counter += 1 else: # For digest/bearer methods, generate 1 case each case_id = f"{generation_id}_{case_counter}" headers = { "accept": "application/json, text/plain, */*", "accept-language": "zh-CN,zh;q=0.9", "X-Auth-Method": "bearer", } test_case = self._create_test_case( case_id, operation_id, generation_id, method, processed_path, headers, f"Test 403 unauthorized access for {operation_id} with X-Auth-Method: 'bearer'", f"test_403_{operation_id.lower()}_bearer", "bearer", case_counter, ) test_cases.append(test_case) case_counter += 1 return test_cases def _create_test_case( self, case_id: str, operation_id: str, generation_id: str, method: str, path: str, headers: Dict[str, str], description: str, name: str, auth_method: str, order: int = 1, ) -> ReusableStep: """ Helper method to create a test case using base.py ReusableStep model """ # Create Auth object with formatted JSON content if auth_method == "bearer": auth = Auth( auth_type="bearer_token", content=json.dumps({"token": "placeholder"}, indent=2), ) # Build request dict directly for better YAML formatting request_dict = { "auth": {"auth_type": auth.auth_type}, "method": method, "path": path, "path_params": {}, "query_params": {}, "headers": headers, "body": {}, } else: auth_content = {"public_key": "placeholder", "private_key": "placeholder"} auth = Auth(auth_type="digest", content=json.dumps(auth_content, indent=2)) # Build request dict directly for better YAML formatting request_dict = { "auth": {"auth_type": auth.auth_type, "content": auth_content}, "method": method, "path": path, "path_params": {}, "query_params": {}, "headers": headers, "body": {}, } # # Create HttpRequest object # http_request = HttpRequest( # auth=auth, # method=HttpMethod(method), # path=path, # path_params="{}", # query_params="{}", # headers=json.dumps(headers, indent=2), # body="{}" # ) # Create ReusableStep object step = ReusableStep( operation_id=operation_id, request_type="http", request=json.dumps(request_dict, indent=2), expect=json.dumps({"status_code": 403, "cel": ""}, indent=2), save=None, description=description, max_retries=0, delay_after=0, name=name, continue_on_unexpected=True, # False for regression. Temporarily set to True to collect statistics on non-403 responses stage="execute", meta="{}", generation_id=generation_id, order=order, ) return step def generate_scenario_yaml( self, json_key: str, swagger_url: str, output_path: str = None ) -> str: """ Generate complete scenario YAML with 403 test cases for all operations """ swagger_spec = self.get_github_file_content(swagger_url) operations = self.extract_operations(swagger_spec) scenario_id = str(uuid.uuid4()).replace("-", "") api_type = "openapi" if "openapi" in json_key else "console" scenario = { "scenario": { "id": scenario_id, "context": { "auth": {"type": "customized"}, "base_path": None, "host": "us-west-2.staging.shared.aws.tidbcloud.com", }, "description": "need continue_on_unexpected and export unmatched case", # tricky for executor output csv "name": json_key + '.403', }, "steps": [], } order_counter = 1 for path, method, operation_id in operations: test_cases = self.generate_403_test_cases( path, method, operation_id, scenario_id, api_type ) for test_case in test_cases: # Convert ReusableStep to dict and add additional fields for YAML step_dict = test_case.model_dump() step_dict["case_id"] = f"{scenario_id}_{order_counter}" step_dict["case_type"] = "typical_negative" step_dict["step_type"] = "generated" step_dict["order"] = order_counter scenario["steps"].append(step_dict) order_counter += 1 # Custom YAML dumper for better formatting class CustomDumper(yaml.SafeDumper): def ignore_aliases(self, data): return True def represent_scalar(self, tag, value, style=None): if isinstance(value, str) and "\n" in value: return yaml.ScalarNode(tag, value, style="|") return super().represent_scalar(tag, value, style) yaml.add_representer( OrderedDict, yaml.representer.SafeRepresenter.represent_dict, Dumper=CustomDumper, ) if output_path: with open(output_path, "w", encoding="utf-8") as yaml_file: yaml.dump( scenario, yaml_file, sort_keys=False, default_flow_style=False, allow_unicode=True, Dumper=CustomDumper, ) # Also return as string yaml_content = yaml.dump( scenario, sort_keys=False, default_flow_style=False, allow_unicode=True, Dumper=CustomDumper, ) return yaml_content def process_all_swagger_files(self): """ Process all swagger files from unexpected_auth.json and generate YAML files """ # Load JSON configuration json_path = os.path.join(os.path.dirname(__file__), "unexpected_auth.json") with open(json_path, "r", encoding="utf-8") as f: swagger_configs = json.load(f) success_count = 0 total_count = len(swagger_configs) for json_key, swagger_url in swagger_configs.items(): try: print(f"Processing {json_key}...") output_filename = f"{json_key}.403.yaml" output_path = os.path.join(os.path.dirname(__file__), output_filename) self.generate_scenario_yaml(json_key,swagger_url, output_path) print(f"✅ Generated: {output_filename}") success_count += 1 except Exception as e: print(f"❌ Failed to process {json_key}: {str(e)}") continue print( f"\n📊 Summary: {success_count}/{total_count} files processed successfully" ) if __name__ == "__main__": generator = SwaggerTestGenerator() # Process all swagger files from JSON configuration generator.process_all_swagger_files()