import json import os import re import sys import uuid from collections import OrderedDict from typing import Any, Dict, List, Tuple import requests import yaml from environs import Env sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) # Add parent directory to path to import base.py from llmops.base import Auth # Direct import from log-to-reusable directory sys.path.append( os.path.join( os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "log-to-reusable" ) ) from base import ReusableStep 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 generate_401_test_cases( self, path: str, method: str, operation_id: str, generation_id: str ) -> List[ReusableStep]: """ Generate multiple 401 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 = ["basic", "digest", "bearer"] auth_types = ["openapi", "console"] test_cases = [] case_counter = 1 # Replace path parameters with placeholder values processed_path = self.replace_path_parameters(path) for auth_method in auth_methods: if auth_method == "basic": # 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": auth_method, } test_case = self._create_test_case( case_id, operation_id, generation_id, method, processed_path, headers, f"Test 401 unauthorized access for {operation_id} with X-Auth-Method: {auth_method}", f"test_401_{operation_id.lower()}_{auth_method}_no_type", case_counter, ) test_cases.append(test_case) case_counter += 1 # Cases 2-3: With X-Auth-Type for auth_type in auth_types: case_id = f"{generation_id}_{case_counter}" headers = { "accept": "application/json, text/plain, */*", "accept-language": "zh-CN,zh;q=0.9", "X-Auth-Method": auth_method, "X-Auth-Type": auth_type, } test_case = self._create_test_case( case_id, operation_id, generation_id, method, processed_path, headers, f"Test 401 unauthorized access for {operation_id} with X-Auth-Method: {auth_method}, X-Auth-Type: {auth_type}", f"test_401_{operation_id.lower()}_{auth_method}_{auth_type}", 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": auth_method, } test_case = self._create_test_case( case_id, operation_id, generation_id, method, processed_path, headers, f"Test 401 unauthorized access for {operation_id} with X-Auth-Method: {auth_method}", f"test_401_{operation_id.lower()}_{auth_method}", 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, order: int = 1, ) -> ReusableStep: """ Helper method to create a test case using base.py ReusableStep model """ # Create Auth object with formatted JSON content auth_content = {"public_key": "placeholder", "private_key": "placeholder"} auth = Auth(auth_type="customized", content=json.dumps(auth_content, indent=2)) # # Create HttpRequest object # http_request = HttpRequest( # auth=auth, # method=HttpMethod(method), # path=path, # path_params="{}", # query_params="{}", # headers=json.dumps(headers, indent=2), # body="{}" # ) # 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 ReusableStep object step = ReusableStep( operation_id=operation_id, request_type="http", request=json.dumps(request_dict, indent=2), expect=json.dumps({"status_code": 401, "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-401 responses stage="execute", meta="{}", generation_id=generation_id, order=order, ) return step def generate_scenario_yaml(self, swagger_url: str, output_path: str = None) -> str: """ Generate complete scenario YAML with 401 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("-", "") scenario = { "scenario": { "id": scenario_id, "context": { "auth": {"type": "customized"}, "base_path": None, "host": "us-west-2.nightly.shared.aws.tidbcloud.com", }, "description": "need continue_on_unexpected and export unmatched case", # tricky for executor output csv "name": output_path.split(".")[0].split("/")[-1], }, "steps": [], } order_counter = 1 for path, method, operation_id in operations: test_cases = self.generate_401_test_cases( path, method, operation_id, scenario_id ) 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}.401.yaml" output_path = os.path.join(os.path.dirname(__file__), output_filename) self.generate_scenario_yaml(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()