games-development-ai/deploy/tests/test-dogfood-reviewer-security.py

242 lines
9.4 KiB
Python
Executable File

#!/usr/bin/env python3
"""内部狗粮审核账号与 RBAC 安全边界测试。"""
from pathlib import Path
import importlib.util
import os
import stat
import tempfile
import unittest
ROOT = Path(__file__).resolve().parents[2]
TOOL = ROOT / "deploy" / "manage-dogfood-reviewers.py"
STAGING_DB = ROOT / "game-cloud" / "huijing-server" / "src" / "main" / "resources" / "db" / "staging"
STAGING_CONFIG = ROOT / "game-cloud" / "huijing-server" / "src" / "main" / "resources" / "application-staging.yaml"
class ReviewerSecurityTest(unittest.TestCase):
@staticmethod
def load_module():
spec = importlib.util.spec_from_file_location("dogfood_reviewers", TOOL)
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
def test_staging_explicitly_disables_mock_authentication(self):
config = STAGING_CONFIG.read_text(encoding="utf-8")
self.assertIn("mock-enable: false", config)
self.assertNotIn("mock-enable: true", config)
def test_rbac_seed_is_repeatable_and_contains_no_login_credentials(self):
self.assertFalse((STAGING_DB / "V34.0.0__seed_dogfood_review_rbac.sql").exists())
migration = STAGING_DB / "R__seed_dogfood_review_rbac.sql"
sql = migration.read_text(encoding="utf-8")
self.assertNotIn("INSERT INTO system_users", sql)
self.assertNotIn("$2a$", sql)
self.assertIn("dogfood_reviewer", sql)
self.assertIn("dogfood_readonly", sql)
def test_rotation_uses_exact_roles_and_invalidates_old_tokens(self):
module = self.load_module()
api = FakeApi(module)
result = module.rotate_reviewers(api, module.generate_password)
self.assertEqual({"dogfood_reviewer", "dogfood_readonly", "admin"}, set(result["accounts"]))
self.assertEqual([201], api.assigned_roles[101])
self.assertEqual([202], api.assigned_roles[102])
self.assertEqual([1, 0], api.status_updates[101])
self.assertEqual([1, 0], api.status_updates[102])
self.assertEqual([1], api.status_updates[1])
self.assertEqual(("status", 1, 1), api.events[-2])
self.assertEqual(("login-rejected", "admin", "admin123"), api.events[-1])
self.assertNotEqual(result["accounts"]["dogfood_reviewer"],
result["accounts"]["dogfood_readonly"])
for password in result["accounts"].values():
self.assertEqual(16, len(password))
self.assertNotEqual("admin123", password)
self.assertTrue(any(char.islower() for char in password))
self.assertTrue(any(char.isupper() for char in password))
self.assertTrue(any(char.isdigit() for char in password))
def test_rotation_creates_missing_accounts_before_fail_closed_status_cycle(self):
module = self.load_module()
api = FakeApi(module, existing=False)
result = module.rotate_reviewers(api, module.generate_password)
self.assertEqual(["dogfood_reviewer", "dogfood_readonly"], api.created_users)
self.assertEqual([1], api.updated_password_users)
self.assertEqual([1, 0], api.status_updates[101])
self.assertEqual([1, 0], api.status_updates[102])
self.assertEqual([1], api.status_updates[1])
self.assertEqual(3, len(result["evidenceAccounts"]))
def test_api_rbac_verification_reads_full_menu_permissions(self):
module = self.load_module()
responses = FakeRbacResponses()
api = module.ApiClient("test-admin-token")
api._request = responses.request
roles = api.verify_rbac()
self.assertEqual(set(module.REVIEWER_PERMISSIONS), roles["dogfood_reviewer"]["permissions"])
self.assertIn(("GET", "/system/menu/list"), responses.calls)
self.assertNotIn(("GET", "/system/menu/simple-list"), responses.calls)
evidence = module.build_rbac_evidence(roles)
self.assertEqual(["dogfood_readonly", "dogfood_reviewer"],
[item["roleCode"] for item in evidence])
def test_sensitive_files_require_private_permissions_and_no_overwrite(self):
module = self.load_module()
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
token_file = root / "admin.token"
token_file.write_text("secret-token\n", encoding="utf-8")
os.chmod(token_file, 0o600)
self.assertEqual("secret-token", module.require_private_file(token_file))
output = root / "credentials.json"
module.reserve_private_file(output)
self.assertEqual(0o600, stat.S_IMODE(output.stat().st_mode))
with self.assertRaises(FileExistsError):
module.reserve_private_file(output)
os.chmod(token_file, 0o644)
with self.assertRaises(module.ReviewerProvisionError):
module.require_private_file(token_file)
symlink = root / "admin-token-link"
symlink.symlink_to(token_file)
with self.assertRaises(module.ReviewerProvisionError):
module.require_private_file(symlink)
def test_default_admin_negative_login_is_anonymous_and_fail_closed(self):
module = self.load_module()
captured_requests = []
original_urlopen = module.urlopen
def rejected_urlopen(request, timeout):
captured_requests.append(request)
return FakeHttpResponse({"code": 1_002_000_001, "msg": "账号被禁用", "data": None})
module.urlopen = rejected_urlopen
try:
api = module.ApiClient("bootstrap-token")
self.assertEqual(1_002_000_001,
api.verify_password_login_rejected("admin", "admin123"))
self.assertIsNone(captured_requests[0].get_header("Authorization"))
module.urlopen = lambda request, timeout: FakeHttpResponse({
"code": 0,
"msg": "",
"data": {"accessToken": "unexpected"},
})
with self.assertRaises(module.ReviewerProvisionError):
api.verify_password_login_rejected("admin", "admin123")
finally:
module.urlopen = original_urlopen
class FakeApi:
"""仅模拟账号轮换所需接口,断言调用结果而非 HTTP 实现细节。"""
def __init__(self, module, existing=True):
self.module = module
self.existing = existing
self.assigned_roles = {}
self.status_updates = {1: [], 101: [], 102: []}
self.created_users = []
self.updated_password_users = []
self.events = []
def verify_rbac(self):
return {
"dogfood_reviewer": {"id": 201, "permissions": set(self.module.REVIEWER_PERMISSIONS)},
"dogfood_readonly": {"id": 202, "permissions": set(self.module.READONLY_PERMISSIONS)},
}
def find_user(self, username):
if username == "admin":
return {"id": 1, "username": username}
if not self.existing:
return None
return {"id": 101 if username == "dogfood_reviewer" else 102, "username": username}
def create_user(self, username, nickname, password):
if self.existing:
raise AssertionError("测试账号已存在,不应走创建分支")
self.assert_password(password)
self.created_users.append(username)
return 101 if username == "dogfood_reviewer" else 102
def update_password(self, user_id, password):
self.assert_password(password)
self.updated_password_users.append(user_id)
self.events.append(("password", user_id))
def assign_user_roles(self, user_id, role_ids):
self.assigned_roles[user_id] = role_ids
def update_user_status(self, user_id, status):
self.status_updates[user_id].append(status)
self.events.append(("status", user_id, status))
def get_user_role_ids(self, user_id):
return set(self.assigned_roles[user_id])
def verify_password_login_rejected(self, username, password):
self.events.append(("login-rejected", username, password))
return 1_002_000_000
@staticmethod
def assert_password(password):
if len(password) != 16:
raise AssertionError("随机密码长度不符合约束")
class FakeRbacResponses:
"""模拟管理 API 响应,校验工具使用真实包含 permission 的菜单入口。"""
def __init__(self):
self.calls = []
def request(self, method, path, **kwargs):
self.calls.append((method, path))
if path == "/system/role/simple-list":
return [
{"id": 201, "code": "dogfood_reviewer"},
{"id": 202, "code": "dogfood_readonly"},
]
if path == "/system/menu/list":
return [
{"id": 301, "permission": "project:review:query"},
{"id": 302, "permission": "project:review:approve"},
{"id": 303, "permission": "telemetry:game-stat:query"},
]
role_id = int(kwargs["query"]["roleId"])
return [301, 302, 303] if role_id == 201 else [301, 303]
class FakeHttpResponse:
"""提供 urllib 响应所需的最小上下文管理协议。"""
def __init__(self, payload):
import json
self.body = json.dumps(payload).encode("utf-8")
def __enter__(self):
return self
def __exit__(self, exc_type, exc_value, traceback):
return False
def read(self):
return self.body
if __name__ == "__main__":
unittest.main()