在准备环境前提交次全部更改。
This commit is contained in:
336
apps/backend/tests/test_site_isolation_properties.py
Normal file
336
apps/backend/tests/test_site_isolation_properties.py
Normal file
@@ -0,0 +1,336 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""门店隔离属性测试(Property-Based Testing)。
|
||||
|
||||
Property 20: 对于任意两个不同 site_id 的 Operator,一个 Operator 查询
|
||||
队列/调度/执行历史时,结果中不应包含另一个 site_id 的数据。
|
||||
|
||||
Validates: Requirements 1.3
|
||||
|
||||
测试策略:
|
||||
- 通过 mock 数据库交互,验证 API 路由在不同 site_id 下的数据隔离
|
||||
- 队列隔离:为 site_id_a 入队任务,用 site_id_b 的 JWT 查询队列,结果应为空
|
||||
- 调度隔离:为 site_id_a 创建调度任务,用 site_id_b 的 JWT 查询调度列表,结果应为空
|
||||
- 执行历史隔离:site_id_a 的执行历史,用 site_id_b 的 JWT 查询不到
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
os.environ.setdefault("JWT_SECRET_KEY", "test-secret-key-for-isolation")
|
||||
|
||||
import json
|
||||
import uuid
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from hypothesis import given, settings, assume
|
||||
from hypothesis import strategies as st
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.auth.dependencies import CurrentUser, get_current_user
|
||||
from app.main import app
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 通用策略(Strategies)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_site_id_st = st.integers(min_value=1, max_value=2**31 - 1)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 辅助函数
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _make_mock_user(site_id: int) -> CurrentUser:
|
||||
"""构造指定 site_id 的 mock 用户。"""
|
||||
return CurrentUser(user_id=1, site_id=site_id)
|
||||
|
||||
|
||||
def _make_queue_rows(site_id: int, count: int) -> list[tuple]:
|
||||
"""生成 count 条属于 site_id 的队列行。"""
|
||||
rows = []
|
||||
for i in range(count):
|
||||
rows.append((
|
||||
str(uuid.uuid4()), # id
|
||||
site_id, # site_id
|
||||
json.dumps({"tasks": ["ODS_MEMBER"], "pipeline": "api_ods"}), # config
|
||||
"pending", # status
|
||||
i + 1, # position
|
||||
datetime(2024, 1, 1, tzinfo=timezone.utc), # created_at
|
||||
None, # started_at
|
||||
None, # finished_at
|
||||
None, # exit_code
|
||||
None, # error_message
|
||||
))
|
||||
return rows
|
||||
|
||||
|
||||
def _make_schedule_rows(site_id: int, count: int) -> list[tuple]:
|
||||
"""生成 count 条属于 site_id 的调度行。"""
|
||||
now = datetime.now(timezone.utc)
|
||||
rows = []
|
||||
for i in range(count):
|
||||
rows.append((
|
||||
str(uuid.uuid4()), # id
|
||||
site_id, # site_id
|
||||
f"调度任务_{i}", # name
|
||||
["ODS_MEMBER"], # task_codes
|
||||
{"tasks": ["ODS_MEMBER"], "pipeline": "api_ods"}, # task_config
|
||||
{"schedule_type": "daily", "daily_time": "04:00", # schedule_config
|
||||
"interval_value": 1, "interval_unit": "hours",
|
||||
"weekly_days": [1], "weekly_time": "04:00",
|
||||
"cron_expression": "0 4 * * *", "enabled": True,
|
||||
"start_date": None, "end_date": None},
|
||||
True, # enabled
|
||||
None, # last_run_at
|
||||
now + timedelta(hours=1), # next_run_at
|
||||
0, # run_count
|
||||
None, # last_status
|
||||
now, # created_at
|
||||
now, # updated_at
|
||||
))
|
||||
return rows
|
||||
|
||||
|
||||
def _make_history_rows(site_id: int, count: int) -> list[tuple]:
|
||||
"""生成 count 条属于 site_id 的执行历史行。"""
|
||||
base_time = datetime(2024, 1, 1, tzinfo=timezone.utc)
|
||||
rows = []
|
||||
for i in range(count):
|
||||
rows.append((
|
||||
str(uuid.uuid4()), # id
|
||||
site_id, # site_id
|
||||
["ODS_MEMBER"], # task_codes
|
||||
"success", # status
|
||||
base_time + timedelta(hours=i), # started_at
|
||||
base_time + timedelta(hours=i, minutes=30), # finished_at
|
||||
0, # exit_code
|
||||
1800000, # duration_ms
|
||||
"python -m cli.main", # command
|
||||
None, # summary
|
||||
))
|
||||
return rows
|
||||
|
||||
|
||||
def _mock_conn_returning(rows: list[tuple]) -> MagicMock:
|
||||
"""构造一个 mock connection,其 cursor.fetchall 返回指定行。"""
|
||||
mock_cursor = MagicMock()
|
||||
mock_cursor.fetchall.return_value = rows
|
||||
mock_conn = MagicMock()
|
||||
mock_conn.cursor.return_value.__enter__ = MagicMock(return_value=mock_cursor)
|
||||
mock_conn.cursor.return_value.__exit__ = MagicMock(return_value=False)
|
||||
return mock_conn
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Property 20.1: 队列隔离
|
||||
# **Validates: Requirements 1.3**
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@settings(max_examples=100, deadline=None)
|
||||
@given(
|
||||
site_id_a=_site_id_st,
|
||||
site_id_b=_site_id_st,
|
||||
queue_count=st.integers(min_value=1, max_value=5),
|
||||
)
|
||||
@patch("app.services.task_queue.get_connection")
|
||||
def test_queue_isolation(mock_get_conn, site_id_a, site_id_b, queue_count):
|
||||
"""Property 20.1: 队列隔离。
|
||||
|
||||
为 site_id_a 入队若干任务后,用 site_id_b 的身份查询队列,
|
||||
结果应为空——不同门店的队列数据互不可见。
|
||||
"""
|
||||
assume(site_id_a != site_id_b)
|
||||
|
||||
# site_id_a 的队列数据
|
||||
rows_a = _make_queue_rows(site_id_a, queue_count)
|
||||
|
||||
# 核心隔离逻辑:根据查询时传入的 site_id 过滤
|
||||
# list_pending 内部 SQL: WHERE site_id = %s AND status = 'pending'
|
||||
def conn_for_site(querying_site_id):
|
||||
"""模拟数据库行为:只返回匹配 site_id 的行。"""
|
||||
if querying_site_id == site_id_a:
|
||||
return rows_a
|
||||
return [] # site_id_b 查不到 site_id_a 的数据
|
||||
|
||||
captured_params = {}
|
||||
|
||||
def make_mock_conn():
|
||||
mock_cursor = MagicMock()
|
||||
|
||||
def execute_side_effect(sql, params=None):
|
||||
if params:
|
||||
captured_params["site_id"] = params[0]
|
||||
# 根据 SQL 中的 site_id 参数返回对应数据
|
||||
mock_cursor.fetchall.return_value = conn_for_site(params[0])
|
||||
|
||||
mock_cursor.execute = MagicMock(side_effect=execute_side_effect)
|
||||
mock_cursor.__enter__ = MagicMock(return_value=mock_cursor)
|
||||
mock_cursor.__exit__ = MagicMock(return_value=False)
|
||||
|
||||
mock_conn = MagicMock()
|
||||
mock_conn.cursor.return_value = mock_cursor
|
||||
return mock_conn
|
||||
|
||||
mock_get_conn.return_value = make_mock_conn()
|
||||
|
||||
# 用 site_id_b 的身份查询队列
|
||||
app.dependency_overrides[get_current_user] = lambda: _make_mock_user(site_id_b)
|
||||
try:
|
||||
client = TestClient(app)
|
||||
resp = client.get("/api/execution/queue")
|
||||
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
|
||||
# 验证:site_id_b 查不到 site_id_a 的任何数据
|
||||
assert len(data) == 0, (
|
||||
f"site_id_b={site_id_b} 不应看到 site_id_a={site_id_a} 的队列数据,"
|
||||
f"但返回了 {len(data)} 条记录"
|
||||
)
|
||||
|
||||
# 额外验证:即使有数据返回,也不应包含 site_id_a 的记录
|
||||
for item in data:
|
||||
assert item.get("site_id") != site_id_a, (
|
||||
f"结果中不应包含 site_id_a={site_id_a} 的数据"
|
||||
)
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Property 20.2: 调度隔离
|
||||
# **Validates: Requirements 1.3**
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@settings(max_examples=100)
|
||||
@given(
|
||||
site_id_a=_site_id_st,
|
||||
site_id_b=_site_id_st,
|
||||
schedule_count=st.integers(min_value=1, max_value=5),
|
||||
)
|
||||
@patch("app.routers.schedules.get_connection")
|
||||
def test_schedule_isolation(mock_get_conn, site_id_a, site_id_b, schedule_count):
|
||||
"""Property 20.2: 调度隔离。
|
||||
|
||||
为 site_id_a 创建若干调度任务后,用 site_id_b 的身份查询调度列表,
|
||||
结果应为空——不同门店的调度数据互不可见。
|
||||
"""
|
||||
assume(site_id_a != site_id_b)
|
||||
|
||||
# site_id_a 的调度数据
|
||||
rows_a = _make_schedule_rows(site_id_a, schedule_count)
|
||||
|
||||
def make_mock_conn():
|
||||
mock_cursor = MagicMock()
|
||||
|
||||
def execute_side_effect(sql, params=None):
|
||||
if params:
|
||||
querying_site_id = params[0]
|
||||
# 只返回匹配 site_id 的行
|
||||
if querying_site_id == site_id_a:
|
||||
mock_cursor.fetchall.return_value = rows_a
|
||||
else:
|
||||
mock_cursor.fetchall.return_value = []
|
||||
|
||||
mock_cursor.execute = MagicMock(side_effect=execute_side_effect)
|
||||
mock_cursor.__enter__ = MagicMock(return_value=mock_cursor)
|
||||
mock_cursor.__exit__ = MagicMock(return_value=False)
|
||||
|
||||
mock_conn = MagicMock()
|
||||
mock_conn.cursor.return_value = mock_cursor
|
||||
return mock_conn
|
||||
|
||||
mock_get_conn.return_value = make_mock_conn()
|
||||
|
||||
# 用 site_id_b 的身份查询调度列表
|
||||
app.dependency_overrides[get_current_user] = lambda: _make_mock_user(site_id_b)
|
||||
try:
|
||||
client = TestClient(app)
|
||||
resp = client.get("/api/schedules")
|
||||
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
|
||||
# 验证:site_id_b 查不到 site_id_a 的任何调度数据
|
||||
assert len(data) == 0, (
|
||||
f"site_id_b={site_id_b} 不应看到 site_id_a={site_id_a} 的调度数据,"
|
||||
f"但返回了 {len(data)} 条记录"
|
||||
)
|
||||
|
||||
# 额外验证:即使有数据返回,也不应包含 site_id_a 的记录
|
||||
for item in data:
|
||||
assert item.get("site_id") != site_id_a, (
|
||||
f"结果中不应包含 site_id_a={site_id_a} 的调度数据"
|
||||
)
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Property 20.3: 执行历史隔离
|
||||
# **Validates: Requirements 1.3**
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@settings(max_examples=100, deadline=None)
|
||||
@given(
|
||||
site_id_a=_site_id_st,
|
||||
site_id_b=_site_id_st,
|
||||
history_count=st.integers(min_value=1, max_value=10),
|
||||
)
|
||||
@patch("app.routers.execution.get_connection")
|
||||
def test_execution_history_isolation(mock_get_conn, site_id_a, site_id_b, history_count):
|
||||
"""Property 20.3: 执行历史隔离。
|
||||
|
||||
site_id_a 有若干执行历史记录,用 site_id_b 的身份查询执行历史,
|
||||
结果应为空——不同门店的执行历史互不可见。
|
||||
"""
|
||||
assume(site_id_a != site_id_b)
|
||||
|
||||
# site_id_a 的执行历史数据
|
||||
rows_a = _make_history_rows(site_id_a, history_count)
|
||||
|
||||
def make_mock_conn():
|
||||
mock_cursor = MagicMock()
|
||||
|
||||
def execute_side_effect(sql, params=None):
|
||||
if params:
|
||||
querying_site_id = params[0]
|
||||
# 只返回匹配 site_id 的行
|
||||
if querying_site_id == site_id_a:
|
||||
mock_cursor.fetchall.return_value = rows_a
|
||||
else:
|
||||
mock_cursor.fetchall.return_value = []
|
||||
|
||||
mock_cursor.execute = MagicMock(side_effect=execute_side_effect)
|
||||
mock_cursor.__enter__ = MagicMock(return_value=mock_cursor)
|
||||
mock_cursor.__exit__ = MagicMock(return_value=False)
|
||||
|
||||
mock_conn = MagicMock()
|
||||
mock_conn.cursor.return_value = mock_cursor
|
||||
return mock_conn
|
||||
|
||||
mock_get_conn.return_value = make_mock_conn()
|
||||
|
||||
# 用 site_id_b 的身份查询执行历史
|
||||
app.dependency_overrides[get_current_user] = lambda: _make_mock_user(site_id_b)
|
||||
try:
|
||||
client = TestClient(app)
|
||||
resp = client.get("/api/execution/history")
|
||||
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
|
||||
# 验证:site_id_b 查不到 site_id_a 的任何执行历史
|
||||
assert len(data) == 0, (
|
||||
f"site_id_b={site_id_b} 不应看到 site_id_a={site_id_a} 的执行历史,"
|
||||
f"但返回了 {len(data)} 条记录"
|
||||
)
|
||||
|
||||
# 额外验证:即使有数据返回,也不应包含 site_id_a 的记录
|
||||
for item in data:
|
||||
assert item.get("site_id") != site_id_a, (
|
||||
f"结果中不应包含 site_id_a={site_id_a} 的执行历史"
|
||||
)
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
Reference in New Issue
Block a user