feat: 实现清理失效账号安全方案(包含预览、SSE测试、安全删除以及本地标记清空)
This commit is contained in:
@@ -0,0 +1,291 @@
|
||||
import json
|
||||
import pytest
|
||||
import httpx
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from sqlalchemy.pool import StaticPool
|
||||
|
||||
from app.database import Base
|
||||
from app.models.upstream import Upstream
|
||||
from app.models.upstream_key import UpstreamGeneratedKey
|
||||
from app.models.website import Website
|
||||
from app.routers.websites import preview_cleanup_invalid_accounts, execute_cleanup_invalid_accounts
|
||||
from app.services.website_client import Sub2ApiWebsiteClient
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def db_session():
|
||||
engine = create_engine(
|
||||
"sqlite://",
|
||||
connect_args={"check_same_thread": False},
|
||||
poolclass=StaticPool,
|
||||
)
|
||||
from app.models import admin_user, upstream, snapshot, webhook_config, notification_log, custom_page, website, revoked_token, upstream_key
|
||||
Base.metadata.create_all(bind=engine)
|
||||
TestingSessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||
db = TestingSessionLocal()
|
||||
try:
|
||||
yield db
|
||||
finally:
|
||||
db.close()
|
||||
Base.metadata.drop_all(bind=engine)
|
||||
|
||||
|
||||
class MockResponse:
|
||||
def __init__(self, status_code, lines, raise_exc=None):
|
||||
self.status_code = status_code
|
||||
self._lines = lines
|
||||
self._raise_exc = raise_exc
|
||||
|
||||
def __enter__(self):
|
||||
if self._raise_exc:
|
||||
raise self._raise_exc
|
||||
return self
|
||||
|
||||
def __exit__(self, *args):
|
||||
pass
|
||||
|
||||
def iter_lines(self):
|
||||
return self._lines
|
||||
|
||||
|
||||
def mock_stream(status_code, lines, raise_exc=None):
|
||||
def mock_fn(*args, **kwargs):
|
||||
return MockResponse(status_code, lines, raise_exc)
|
||||
return mock_fn
|
||||
|
||||
|
||||
def test_website_client_test_account_sse_scenarios(monkeypatch):
|
||||
"""测试 Sub2ApiWebsiteClient.test_account 在各种 SSE 场景下的解析和处理。"""
|
||||
c = Sub2ApiWebsiteClient(
|
||||
base_url="http://w1",
|
||||
api_prefix="api/v1",
|
||||
auth_type="bearer",
|
||||
auth_config={"token": "t1"},
|
||||
)
|
||||
|
||||
# 1. success=true
|
||||
monkeypatch.setattr(c._client, "stream", mock_stream(200, ["event: test_complete", "data: {\"success\": true}"]))
|
||||
res = c.test_account("acc1")
|
||||
assert res["status"] == "success"
|
||||
assert res["cleanup_allowed"] is False
|
||||
|
||||
# 2. success=false
|
||||
monkeypatch.setattr(c._client, "stream", mock_stream(200, ["event: test_complete", "data: {\"success\": false}"]))
|
||||
res = c.test_account("acc1")
|
||||
assert res["status"] == "failed"
|
||||
assert res["cleanup_allowed"] is True
|
||||
|
||||
# 3. HTTP 404
|
||||
monkeypatch.setattr(c._client, "stream", mock_stream(404, []))
|
||||
res = c.test_account("acc1")
|
||||
assert res["status"] == "404"
|
||||
assert res["cleanup_allowed"] is True
|
||||
|
||||
# 4. error event
|
||||
monkeypatch.setattr(c._client, "stream", mock_stream(200, ["event: error", "data: \"internal error\""]))
|
||||
res = c.test_account("acc1")
|
||||
assert res["status"] == "error"
|
||||
assert res["cleanup_allowed"] is False
|
||||
|
||||
# 5. 超时
|
||||
monkeypatch.setattr(c._client, "stream", mock_stream(200, [], raise_exc=httpx.TimeoutException("Connection timed out")))
|
||||
res = c.test_account("acc1")
|
||||
assert res["status"] == "timeout"
|
||||
assert res["cleanup_allowed"] is False
|
||||
|
||||
# 6. 非法 SSE (没有 test_complete)
|
||||
monkeypatch.setattr(c._client, "stream", mock_stream(200, ["data: hello world"]))
|
||||
res = c.test_account("acc1")
|
||||
assert res["status"] == "invalid_sse"
|
||||
assert res["cleanup_allowed"] is False
|
||||
|
||||
|
||||
def test_cleanup_invalid_preview_and_execute(db_session, monkeypatch):
|
||||
"""测试预览与执行清理的完整工作流。
|
||||
- 包含本地 orphaned 键。
|
||||
- 包含远端 SmartUp 备注孤立账号。
|
||||
- 包含无 SmartUp 备注的手工账号(不参与候选)。
|
||||
- 包含测试通过与失败的不同流程分支。
|
||||
"""
|
||||
w = Website(
|
||||
name="W1",
|
||||
site_type="sub2api",
|
||||
base_url="http://w1",
|
||||
enabled=True,
|
||||
auth_config_json="{}",
|
||||
timeout_seconds=30
|
||||
)
|
||||
u1 = Upstream(name="U1", base_url="http://u1")
|
||||
db_session.add_all([w, u1])
|
||||
db_session.commit()
|
||||
db_session.refresh(w)
|
||||
db_session.refresh(u1)
|
||||
|
||||
# 1. 本地 orphaned 键 k1:对应的远端账号测试将失败 (可清理)
|
||||
k1 = UpstreamGeneratedKey(
|
||||
upstream_id=u1.id,
|
||||
group_id="G1",
|
||||
group_name="G1-Name",
|
||||
key_name="Key-G1",
|
||||
key_value="sk-g1",
|
||||
status="orphaned",
|
||||
imported_website_id=w.id,
|
||||
imported_account_id="acc-k1",
|
||||
error="original-error-k1"
|
||||
)
|
||||
# 2. 本地 orphaned 键 k2:对应的远端账号测试将成功 (不可清理)
|
||||
k2 = UpstreamGeneratedKey(
|
||||
upstream_id=u1.id,
|
||||
group_id="G2",
|
||||
group_name="G2-Name",
|
||||
key_name="Key-G2",
|
||||
key_value="sk-g2",
|
||||
status="orphaned",
|
||||
imported_website_id=w.id,
|
||||
imported_account_id="acc-k2",
|
||||
error="original-error-k2"
|
||||
)
|
||||
# 3. 本地 imported 正常键 k3(不进入候选)
|
||||
k3 = UpstreamGeneratedKey(
|
||||
upstream_id=u1.id,
|
||||
group_id="G3",
|
||||
group_name="G3-Name",
|
||||
key_name="Key-G3",
|
||||
key_value="sk-g3",
|
||||
status="imported",
|
||||
imported_website_id=w.id,
|
||||
imported_account_id="acc-k3"
|
||||
)
|
||||
db_session.add_all([k1, k2, k3])
|
||||
db_session.commit()
|
||||
|
||||
deleted_accounts = []
|
||||
test_calls = {}
|
||||
|
||||
class MockClient:
|
||||
def __init__(self, **kwargs): pass
|
||||
def __enter__(self): return self
|
||||
def __exit__(self, *a): pass
|
||||
|
||||
def list_accounts(self):
|
||||
# 远端列表:
|
||||
# acc-k1 (Condition A, k1)
|
||||
# acc-k2 (Condition A, k2)
|
||||
# acc-k3 (k3 正常)
|
||||
# acc-orphan-remote (Condition B, notes 带备注,本地不存在关联)
|
||||
# acc-manual (无备注手工账号,notes 没匹配上,不应进候选)
|
||||
return [
|
||||
{"id": "acc-k1", "name": "Acc-K1", "notes": f"Imported by SmartUp from upstream key #{k1.id}"},
|
||||
{"id": "acc-k2", "name": "Acc-K2", "notes": f"Imported by SmartUp from upstream key #{k2.id}"},
|
||||
{"id": "acc-k3", "name": "Acc-K3", "notes": f"Imported by SmartUp from upstream key #{k3.id}"},
|
||||
{"id": "acc-orphan-remote", "name": "Orphan-Remote", "notes": "Imported by SmartUp from upstream key #9999"},
|
||||
{"id": "acc-manual", "name": "Manual-Acc", "notes": "Manually created account"},
|
||||
]
|
||||
|
||||
def extract_id(self, val):
|
||||
return val.get("id") if isinstance(val, dict) else str(val)
|
||||
|
||||
def test_account(self, account_id):
|
||||
test_calls[account_id] = test_calls.get(account_id, 0) + 1
|
||||
if account_id in ("acc-k1", "acc-orphan-remote"):
|
||||
# 测试失败 -> 可删除
|
||||
return {"status": "failed", "cleanup_allowed": True, "message": "API check failed"}
|
||||
else:
|
||||
# 其他测试成功
|
||||
return {"status": "success", "cleanup_allowed": False, "message": "API check OK"}
|
||||
|
||||
def delete_account(self, account_id):
|
||||
deleted_accounts.append(account_id)
|
||||
return {"status": "deleted", "message": f"Account {account_id} has been deleted"}
|
||||
|
||||
monkeypatch.setattr("app.routers.websites._client", lambda row: MockClient())
|
||||
|
||||
# ── 1. 测试预览 ──
|
||||
preview_res = preview_cleanup_invalid_accounts(wid=w.id, db=db_session)
|
||||
assert preview_res.success is True
|
||||
# 候选账号应为 3 个:acc-k1, acc-k2, acc-orphan-remote (acc-k3 正常, acc-manual 无备注,均不进入候选)
|
||||
items = preview_res.items
|
||||
assert len(items) == 3
|
||||
|
||||
item_k1 = next(i for i in items if i.account_id == "acc-k1")
|
||||
assert item_k1.cleanup_allowed is True
|
||||
assert item_k1.test_status == "failed"
|
||||
|
||||
item_k2 = next(i for i in items if i.account_id == "acc-k2")
|
||||
assert item_k2.cleanup_allowed is False
|
||||
assert item_k2.test_status == "test_passed"
|
||||
|
||||
item_orphan = next(i for i in items if i.account_id == "acc-orphan-remote")
|
||||
assert item_orphan.cleanup_allowed is True
|
||||
assert item_orphan.test_status == "failed"
|
||||
assert item_orphan.upstream_key_id == 9999
|
||||
|
||||
# 确认没有调用过删除
|
||||
assert len(deleted_accounts) == 0
|
||||
|
||||
# ── 2. 测试执行 ──
|
||||
# 重置调用计数
|
||||
test_calls.clear()
|
||||
execute_res = execute_cleanup_invalid_accounts(wid=w.id, db=db_session)
|
||||
assert execute_res.success is True
|
||||
|
||||
# 验证是否重新跑了测试
|
||||
assert "acc-k1" in test_calls
|
||||
assert "acc-k2" in test_calls
|
||||
assert "acc-orphan-remote" in test_calls
|
||||
|
||||
# 验证被删除的账号 (只有测试失败/可清理的才删:acc-k1 和 acc-orphan-remote)
|
||||
assert len(deleted_accounts) == 2
|
||||
assert "acc-k1" in deleted_accounts
|
||||
assert "acc-orphan-remote" in deleted_accounts
|
||||
assert "acc-k2" not in deleted_accounts
|
||||
|
||||
# 验证本地数据库的 DB key 清空逻辑:
|
||||
db_session.refresh(k1)
|
||||
db_session.refresh(k2)
|
||||
db_session.refresh(k3)
|
||||
|
||||
# k1 应该被清空 imported 字段,但保留 status='orphaned' 及原始 error
|
||||
assert k1.imported_website_id is None
|
||||
assert k1.imported_account_id is None
|
||||
assert k1.status == "orphaned"
|
||||
assert k1.error == "original-error-k1"
|
||||
|
||||
# k2 由于测试成功跳过删除,DB 不变
|
||||
assert k2.imported_website_id == w.id
|
||||
assert k2.imported_account_id == "acc-k2"
|
||||
assert k2.status == "orphaned"
|
||||
|
||||
|
||||
def test_cleanup_list_accounts_failure(db_session, monkeypatch):
|
||||
"""测试 list_accounts() 失败时,整个清理预览与执行直接阻断报错。"""
|
||||
w = Website(
|
||||
name="W1",
|
||||
site_type="sub2api",
|
||||
base_url="http://w1",
|
||||
enabled=True,
|
||||
auth_config_json="{}",
|
||||
timeout_seconds=30
|
||||
)
|
||||
db_session.add(w)
|
||||
db_session.commit()
|
||||
|
||||
class FailClient:
|
||||
def __init__(self, **kwargs): pass
|
||||
def __enter__(self): return self
|
||||
def __exit__(self, *a): pass
|
||||
def list_accounts(self):
|
||||
return None # 模拟获取失败
|
||||
|
||||
monkeypatch.setattr("app.routers.websites._client", lambda row: FailClient())
|
||||
|
||||
preview_res = preview_cleanup_invalid_accounts(wid=w.id, db=db_session)
|
||||
assert preview_res.success is False
|
||||
assert "拉取远端账号列表失败" in preview_res.message
|
||||
assert len(preview_res.items) == 0
|
||||
|
||||
execute_res = execute_cleanup_invalid_accounts(wid=w.id, db=db_session)
|
||||
assert execute_res.success is False
|
||||
assert "拉取远端账号列表失败" in execute_res.message
|
||||
assert len(execute_res.items) == 0
|
||||
Reference in New Issue
Block a user