Files
Neo-ZQYY/apps/backend/tests/test_env_config_properties.py

192 lines
7.1 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- 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)}"
)