# -*- coding: utf-8 -*- """环境配置属性测试(Property-Based Testing)。 使用 hypothesis 验证环境配置管理的通用正确性属性: - Property 15: .env 解析与敏感值掩码 - Property 16: .env 写入往返一致性 测试策略: - Property 15: 生成随机 .env 内容(含敏感和非敏感键),验证 _parse_env + _is_sensitive 对敏感值掩码 - Property 16: 生成随机键值对,序列化为 .env 格式后再解析,验证往返一致性 """ import os os.environ.setdefault("JWT_SECRET_KEY", "test-secret-key-for-env-config-properties") from hypothesis import given, settings, assume from hypothesis import strategies as st from app.routers.env_config import _parse_env, _is_sensitive, _MASK, _SENSITIVE_KEYWORDS # --------------------------------------------------------------------------- # 通用策略(Strategies) # --------------------------------------------------------------------------- # 合法的环境变量键名:字母或下划线开头,后跟字母、数字、下划线 _key_start_char = st.sampled_from( list("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz_") ) _key_rest_char = st.sampled_from( list("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789_") ) _env_key_st = st.builds( lambda first, rest: first + rest, first=_key_start_char, rest=st.text(alphabet=_key_rest_char, min_size=0, max_size=30), ) # 值:不含换行符的可打印字符串(排除引号以避免解析歧义) _env_value_st = st.text( alphabet=st.characters( whitelist_categories=("L", "N", "P", "S"), blacklist_characters='\n\r"\'#', ), min_size=0, max_size=50, ) # 敏感键:在随机键名中嵌入敏感关键词 _sensitive_keyword_st = st.sampled_from(list(_SENSITIVE_KEYWORDS)) _sensitive_key_st = st.builds( lambda prefix, kw, suffix: prefix + kw + suffix, prefix=st.text(alphabet=_key_rest_char, min_size=0, max_size=10), kw=_sensitive_keyword_st, suffix=st.text(alphabet=_key_rest_char, min_size=0, max_size=10), ).filter(lambda k: len(k) > 0 and k[0].isalpha() or k[0] == "_") # 确保敏感键以字母或下划线开头 _safe_sensitive_key_st = st.builds( lambda prefix, kw: prefix + "_" + kw, prefix=st.sampled_from(["DB", "API", "ETL", "APP", "MY"]), kw=_sensitive_keyword_st, ) # --------------------------------------------------------------------------- # Feature: admin-web-console, Property 15: .env 解析与敏感值掩码 # **Validates: Requirements 6.1, 6.3** # --------------------------------------------------------------------------- @settings(max_examples=100) @given( sensitive_keys=st.lists(_safe_sensitive_key_st, min_size=1, max_size=5, unique=True), sensitive_values=st.lists( st.text(min_size=1, max_size=30, alphabet=st.characters( whitelist_categories=("L", "N"), )), min_size=1, max_size=5, ), normal_keys=st.lists(_env_key_st, min_size=1, max_size=5, unique=True), normal_values=st.lists(_env_value_st, min_size=1, max_size=5), ) def test_sensitive_values_masked(sensitive_keys, sensitive_values, normal_keys, normal_values): """Property 15: .env 解析与敏感值掩码。 包含敏感键(PASSWORD、TOKEN、SECRET、DSN)的 .env 文件内容, API 返回的键值对列表中这些键的值应被掩码替换,不包含原始敏感值。 """ # 确保敏感键和普通键不重叠 normal_keys_filtered = [k for k in normal_keys if k not in sensitive_keys] assume(len(normal_keys_filtered) >= 1) # 对齐列表长度 s_vals = (sensitive_values * ((len(sensitive_keys) // len(sensitive_values)) + 1))[:len(sensitive_keys)] n_vals = (normal_values * ((len(normal_keys_filtered) // len(normal_values)) + 1))[:len(normal_keys_filtered)] # 构造 .env 内容 lines = [] for k, v in zip(sensitive_keys, s_vals): lines.append(f"{k}={v}") for k, v in zip(normal_keys_filtered, n_vals): lines.append(f"{k}={v}") env_content = "\n".join(lines) + "\n" # 解析 parsed = _parse_env(env_content) entries = [line for line in parsed if line["type"] == "entry"] # 模拟 GET 端点的掩码逻辑 masked_entries = {} for entry in entries: if _is_sensitive(entry["key"]): masked_entries[entry["key"]] = _MASK else: masked_entries[entry["key"]] = entry["value"] # 验证:敏感键的值应被掩码 for k, v in zip(sensitive_keys, s_vals): assert k in masked_entries, f"敏感键 {k} 应出现在解析结果中" assert masked_entries[k] == _MASK, ( f"敏感键 {k} 的值应为掩码 '{_MASK}',实际为 '{masked_entries[k]}'" ) # 原始敏感值不应出现在掩码后的结果中 assert masked_entries[k] != v, ( f"敏感键 {k} 的原始值 '{v}' 不应出现在掩码结果中" ) # 验证:非敏感键的值应保持原样 for k, v in zip(normal_keys_filtered, n_vals): if not _is_sensitive(k): assert k in masked_entries, f"普通键 {k} 应出现在解析结果中" assert masked_entries[k] == v, ( f"普通键 {k} 的值应为 '{v}',实际为 '{masked_entries[k]}'" ) # --------------------------------------------------------------------------- # Feature: admin-web-console, Property 16: .env 写入往返一致性 # **Validates: Requirements 6.2** # --------------------------------------------------------------------------- @settings(max_examples=100) @given( entries=st.lists( st.tuples(_env_key_st, _env_value_st), min_size=1, max_size=10, unique_by=lambda t: t[0], # 键唯一 ), ) def test_env_write_read_round_trip(entries): """Property 16: .env 写入往返一致性。 有效的键值对集合(不含注释和空行),写入 .env 文件后再读取解析, 应得到与原始集合等价的键值对。 """ # 过滤掉值中可能导致解析歧义的情况(值前后空白会被 strip) clean_entries = [(k, v.strip()) for k, v in entries] # 排除空键(策略已保证非空,但防御性检查) clean_entries = [(k, v) for k, v in clean_entries if k] assume(len(clean_entries) >= 1) # 模拟写入:构造 .env 文件内容 lines = [f"{k}={v}" for k, v in clean_entries] env_content = "\n".join(lines) + "\n" # 解析 parsed = _parse_env(env_content) parsed_entries = { line["key"]: line["value"] for line in parsed if line["type"] == "entry" } # 验证往返一致性:每个写入的键值对都应在解析结果中 for k, v in clean_entries: assert k in parsed_entries, ( f"键 '{k}' 应出现在解析结果中,实际键集合: {list(parsed_entries.keys())}" ) assert parsed_entries[k] == v, ( f"键 '{k}' 的值不一致:写入='{v}',解析='{parsed_entries[k]}'" ) # 验证:解析结果的键数量应与写入的一致 assert len(parsed_entries) == len(clean_entries), ( f"解析结果键数量 {len(parsed_entries)} 应等于写入数量 {len(clean_entries)}" )