337 lines
13 KiB
Python
337 lines
13 KiB
Python
# -*- 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()
|