#!/usr/bin/env python3 # -*- coding: utf-8 -*- """本批 OpenAPI 契约对照脚本的**反向测试**。 通过在**临时副本**中分别制造下列变异,证明严格对照脚本在每种情况下都会失败 (进程退出码非 0);**不修改正式契约**来制造这些测试: 1. 删除一个约束(JobCreateRequest.experience.maxLength) 2. 改错类型(JobCreateRequest.salaryMonths.type: integer → string) 3. 改错范围(JobCreateRequest.salaryMonths.maximum: 24 → 12) 4. 移除字段(JobCreateRequest.properties.education) 5. 加入敏感字段(CompanyPublicView.properties.ownerUserId) 6. 改错可空性(JobCreateRequest.experience.type 去掉 "null") 7. 改错数组元素长度(benefits.items.maxLength: 40 → 41) 8. 改错响应引用(/jobs/public/{jobId} 200 引用改为 ApiResponseJobDetailViewDoc) 用法: python b01_contract_negative_tests.py [--runtime PATH] [--static PATH] 退出码:0=全部(基线通过 + 各变异均失败);1=存在不符合预期的结果。 """ import argparse import copy import json import subprocess import sys import tempfile from pathlib import Path HERE = Path(__file__).resolve().parent ROOT = HERE.parents[2] COMPARE = HERE / "b01_openapi_compare.py" DEFAULT_STATIC = ROOT / "docs" / "openapi-rc.json" DEFAULT_RUNTIME = HERE / "runtime-openapi.json" results = [] def run_compare(static_path, runtime_path, out_path): proc = subprocess.run( [sys.executable, str(COMPARE), "--static", str(static_path), "--runtime", str(runtime_path), "--out", str(out_path), "--runtime-out", str(out_path)], capture_output=True, text=True, encoding="utf-8", errors="replace") return proc.returncode, proc.stdout def mutate_remove_max_length(doc): doc["components"]["schemas"]["JobCreateRequest"]["properties"]["experience"].pop("maxLength", None) def mutate_wrong_type(doc): doc["components"]["schemas"]["JobCreateRequest"]["properties"]["salaryMonths"]["type"] = ["string", "null"] def mutate_wrong_range(doc): doc["components"]["schemas"]["JobCreateRequest"]["properties"]["salaryMonths"]["maximum"] = 12 def mutate_remove_field(doc): doc["components"]["schemas"]["JobCreateRequest"]["properties"].pop("education", None) def mutate_sensitive_field(doc): doc["components"]["schemas"]["CompanyPublicView"]["properties"]["ownerUserId"] = {"type": "integer"} def mutate_nullability(doc): doc["components"]["schemas"]["JobCreateRequest"]["properties"]["experience"]["type"] = "string" def mutate_item_length(doc): doc["components"]["schemas"]["JobCreateRequest"]["properties"]["benefits"]["x-normalization"]["itemMaxLength"] = 41 def mutate_normalization_removed(doc): doc["components"]["schemas"]["JobCreateRequest"]["properties"]["benefits"].pop("x-normalization") def mutate_trim(doc): doc["components"]["schemas"]["JobCreateRequest"]["properties"]["benefits"]["x-normalization"]["trimItems"] = False def mutate_raw_limit(doc): doc["components"]["schemas"]["JobCreateRequest"]["properties"]["benefits"]["maxItems"] = 20 def mutate_benefits_null(doc): doc["components"]["schemas"]["JobCreateRequest"]["properties"]["benefits"]["type"] = "array" def mutate_response_ref(doc): doc["paths"]["/jobs/public/{jobId}"]["get"]["responses"]["200"]["content"]["*/*"]["schema"]["$ref"] = \ "#/components/schemas/ApiResponseJobDetailViewDoc" MUTATIONS = [ ("① 删除约束 experience.maxLength", mutate_remove_max_length), ("② 改错类型 salaryMonths→string", mutate_wrong_type), ("③ 改错范围 salaryMonths.maximum=12", mutate_wrong_range), ("④ 移除字段 education", mutate_remove_field), ("⑤ 加入敏感字段 ownerUserId", mutate_sensitive_field), ("⑥ 改错可空性 experience 去掉 null", mutate_nullability), ("⑦ 改错数组元素长度 items.maxLength=41", mutate_item_length), ("⑧ 改错响应引用 public job", mutate_response_ref), ("⑨ 移除归一化规则", mutate_normalization_removed), ("⑩ 错误关闭trim", mutate_trim), ("⑪ 错误限制原始请求20项", mutate_raw_limit), ("⑫ 错误拒绝福利null", mutate_benefits_null), ] def main(): parser = argparse.ArgumentParser() parser.add_argument("--static", default=str(DEFAULT_STATIC)) parser.add_argument("--runtime", default=str(DEFAULT_RUNTIME)) args = parser.parse_args() baseline = json.load(open(args.static, encoding="utf-8")) with tempfile.TemporaryDirectory() as tmp: tmp = Path(tmp) # 0) 基线(未变异)必须通过 out0 = tmp / "compare-baseline.json" rc, _ = run_compare(args.static, args.runtime, out0) results.append(("基线(正式契约 + 运行时)通过", rc == 0, "rc=%d" % rc)) # 各变异必须失败(rc != 0) for name, mutate in MUTATIONS: doc = copy.deepcopy(baseline) mutate(doc) mutated = tmp / "static-mutated.json" mutated.write_text(json.dumps(doc, ensure_ascii=False, indent=2), encoding="utf-8") out = tmp / "compare.json" rc, stdout = run_compare(mutated, args.runtime, out) results.append(("%s → 对照脚本失败" % name, rc != 0, "rc=%d" % rc)) bad = [r for r in results if not r[1]] for name, ok, detail in results: print("[%s] %-46s %s" % ("PASS" if ok else "FAIL", name, detail)) print("\n== CONTRACT NEGATIVE SUMMARY: total=%d fail=%d ==" % (len(results), len(bad))) return 1 if bad else 0 if __name__ == "__main__": sys.exit(main())