在准备环境前提交次全部更改。
This commit is contained in:
191
apps/backend/tests/test_env_config_properties.py
Normal file
191
apps/backend/tests/test_env_config_properties.py
Normal file
@@ -0,0 +1,191 @@
|
||||
# -*- 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)}"
|
||||
)
|
||||
Reference in New Issue
Block a user