test_platform_management.py 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319
  1. import asyncio
  2. import hashlib
  3. import hmac
  4. import ipaddress
  5. import json
  6. import os
  7. import ssl
  8. import threading
  9. from datetime import UTC, datetime, timedelta
  10. from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
  11. from pathlib import Path
  12. from uuid import UUID, uuid4
  13. import pytest
  14. import pytest_asyncio
  15. from cryptography import x509
  16. from cryptography.hazmat.primitives import hashes, serialization
  17. from cryptography.hazmat.primitives.asymmetric import rsa
  18. from cryptography.x509.oid import NameOID
  19. from sqlalchemy import delete, func, select
  20. from test_after_sales import make_proposal, scenario # noqa: F401
  21. from zhifu.modules.agent_runtime.models import TokenAccount, TokenPolicy
  22. from zhifu.modules.identity.auth import now, password_hasher
  23. from zhifu.modules.identity.models import Tenant, TenantQuota
  24. from zhifu.modules.merchant.models import ChannelApplication
  25. from zhifu.modules.platform.delivery import enqueue, execute_one
  26. from zhifu.modules.platform.models import (
  27. ChannelDelivery,
  28. PlatformAccount,
  29. PlatformAudit,
  30. PlatformCommand,
  31. PlatformSession,
  32. TenantRegistration,
  33. )
  34. from zhifu.modules.platform.schemas import PlatformState
  35. pytestmark = pytest.mark.integration
  36. BASE = "/openapi/v1/platform"
  37. @pytest_asyncio.fixture
  38. async def operator(scenario): # noqa: F811
  39. sessions = scenario["app"].state.sessions
  40. actor_id = uuid4()
  41. username = "platform-" + actor_id.hex
  42. password = "integration-" + uuid4().hex
  43. created = []
  44. async with sessions.begin() as session:
  45. session.add(PlatformAccount(
  46. id=actor_id.bytes, username=username,
  47. password_hash=password_hasher.hash(password), status="ACTIVE",
  48. permissions=["platform:manage"], created_at=now(),
  49. ))
  50. client = scenario["client"]
  51. response = await client.post(BASE + "/auth/sessions", json={
  52. "username": username, "password": password,
  53. })
  54. assert response.status_code == 200, response.text
  55. token = response.json()["access_token"]
  56. async def call(method, path, body=None, key=None):
  57. return await client.request(method, BASE + path, json=body, headers={
  58. "Authorization": "Bearer " + token, "Idempotency-Key": key or str(uuid4()),
  59. })
  60. try:
  61. yield {
  62. "call": call, "actor_id": actor_id, "created": created,
  63. "username": username, "password": password,
  64. }
  65. finally:
  66. async with sessions.begin() as session:
  67. await session.execute(delete(ChannelDelivery).where(
  68. ChannelDelivery.tenant_id == scenario["tenant"].bytes,
  69. ))
  70. for model in (PlatformCommand, PlatformAudit):
  71. await session.execute(delete(model).where(model.actor_id == actor_id.bytes))
  72. await session.execute(delete(PlatformSession).where(
  73. PlatformSession.account_id == actor_id.bytes,
  74. ))
  75. await session.execute(delete(PlatformAccount).where(
  76. PlatformAccount.id == actor_id.bytes,
  77. ))
  78. if created:
  79. await session.execute(delete(TenantRegistration).where(
  80. TenantRegistration.tenant_id.in_(created),
  81. ))
  82. await session.execute(delete(Tenant).where(Tenant.id.in_(created)))
  83. async def test_platform_identity_quota_and_audit(scenario, operator): # noqa: F811
  84. call = operator["call"]
  85. client = scenario["client"]
  86. sessions = scenario["app"].state.sessions
  87. assert (await client.get(BASE + "/state")).status_code == 401
  88. foreign = await client.get(BASE + "/state", headers=scenario["headers"]["agent"])
  89. assert foreign.status_code == 401
  90. wrong = await client.post(BASE + "/auth/sessions", json={
  91. "username": operator["username"], "password": "wrong-password",
  92. })
  93. assert wrong.status_code == 401
  94. identity = await call("GET", "/auth/me")
  95. assert identity.json()["kind"] == "PLATFORM"
  96. initial = await call("GET", "/state")
  97. assert initial.status_code == 200, initial.text
  98. PlatformState.model_validate(initial.json())
  99. assert len(initial.json()["runtime"]["trend"]) == 7
  100. assert initial.json()["runtime"]["metrics"]["tenants"] >= 2
  101. name = "平台接口验收-" + uuid4().hex
  102. body = {"name": name, "contact": "接口验收负责人"}
  103. key = str(uuid4())
  104. first, second = await asyncio.gather(
  105. call("POST", "/tenants", body, key), call("POST", "/tenants", body, key),
  106. )
  107. assert first.status_code == second.status_code == 201
  108. assert first.json() == second.json()
  109. tenant_id = UUID(first.json()["id"])
  110. operator["created"].append(tenant_id.bytes)
  111. assert first.json()["status"] == "PENDING"
  112. assert (await call("POST", "/tenants", body)).status_code == 409
  113. changed = await call("POST", "/tenants", {**body, "contact": "另一位负责人"}, key)
  114. assert changed.status_code == 409
  115. pending_status = await call("PUT", f"/tenants/{tenant_id}/status", {
  116. "expected_version": 1, "status": "ENABLED",
  117. })
  118. assert pending_status.json()["code"] == "TENANT_NOT_READY"
  119. original_id = scenario["tenant"]
  120. status_path = f"/tenants/{original_id}/status"
  121. disabled = await call("PUT", status_path, {"expected_version": 1, "status": "DISABLED"})
  122. assert disabled.status_code == 200, disabled.text
  123. assert (await scenario["call"]("GET", "/openapi/v1/auth/me")).status_code == 401
  124. stale = await call("PUT", status_path, {"expected_version": 1, "status": "ENABLED"})
  125. assert stale.status_code == 409
  126. enabled = await call("PUT", status_path, {"expected_version": 2, "status": "ENABLED"})
  127. assert enabled.status_code == 200
  128. assert (await scenario["call"]("GET", "/openapi/v1/auth/me")).status_code == 200
  129. capacity_path = f"/tenants/{tenant_id}/capacity"
  130. change = {
  131. "expected_version": 1, "expected_token_version": 1, "interactive": 3,
  132. "reserved": 0, "background": 1, "tokens": 900000, "reason": "真实额度验证",
  133. }
  134. saved = await call("PUT", capacity_path, change)
  135. assert saved.status_code == 200, saved.text
  136. assert saved.json()["tokens"] == 900000
  137. assert (await call("PUT", capacity_path, change)).status_code == 409
  138. excess = await call("PUT", capacity_path, {
  139. **change, "expected_version": 2, "expected_token_version": 2,
  140. "interactive": 8, "reserved": 8,
  141. })
  142. assert excess.json()["code"] == "CAPACITY_EXCEEDED"
  143. async with sessions.begin() as session:
  144. assert (await session.get(TenantQuota, tenant_id.bytes)).interactive_limit == 3
  145. assert (await session.get(TokenPolicy, tenant_id.bytes)).token_limit == 900000
  146. session.add(TokenAccount(
  147. tenant_id=tenant_id.bytes, period=now().date(), token_limit=900000,
  148. reserved=25, consumed=50,
  149. ))
  150. smaller = await call("PUT", capacity_path, {
  151. **change, "expected_version": 2, "expected_token_version": 2, "tokens": 0,
  152. })
  153. assert smaller.status_code == 200
  154. async with sessions() as session:
  155. account = await session.get(TokenAccount, (tenant_id.bytes, now().date()))
  156. assert (account.token_limit, account.reserved, account.consumed) == (0, 25, 50)
  157. count = await session.scalar(select(func.count()).select_from(PlatformAudit).where(
  158. PlatformAudit.actor_id == operator["actor_id"].bytes,
  159. PlatformAudit.action == "登记商家",
  160. ))
  161. assert count == 1
  162. audits = (await call("GET", "/state")).json()["audit"]
  163. assert any("真实额度验证" in row["action"] for row in audits)
  164. async with sessions.begin() as session:
  165. actor = await session.get(PlatformAccount, operator["actor_id"].bytes)
  166. actor.status = "SUSPENDED"
  167. assert (await call("GET", "/auth/me")).status_code == 401
  168. disabled_login = await client.post(BASE + "/auth/sessions", json={
  169. "username": operator["username"], "password": operator["password"],
  170. })
  171. assert disabled_login.status_code == 401
  172. async with sessions.begin() as session:
  173. actor = await session.get(PlatformAccount, operator["actor_id"].bytes)
  174. actor.status = "ACTIVE"
  175. actor.permissions = []
  176. assert (await call("GET", "/state")).status_code == 403
  177. unauthorized_login = await client.post(BASE + "/auth/sessions", json={
  178. "username": operator["username"], "password": operator["password"],
  179. })
  180. assert unauthorized_login.status_code == 401
  181. async with sessions.begin() as session:
  182. actor = await session.get(PlatformAccount, operator["actor_id"].bytes)
  183. actor.permissions = ["platform:manage"]
  184. assert (await call("DELETE", "/auth/sessions/current")).status_code == 204
  185. assert (await call("GET", "/auth/me")).status_code == 401
  186. assert (await scenario["call"]("GET", "/openapi/v1/auth/me")).status_code == 200
  187. @pytest.fixture
  188. def receiver():
  189. folder = Path(".work/platform-receiver") / uuid4().hex
  190. folder.mkdir(parents=True)
  191. private = rsa.generate_private_key(public_exponent=65537, key_size=2048)
  192. subject = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, "platform-local-receiver")])
  193. clock = datetime.now(UTC)
  194. certificate = (
  195. x509.CertificateBuilder().subject_name(subject).issuer_name(subject)
  196. .public_key(private.public_key()).serial_number(x509.random_serial_number())
  197. .not_valid_before(clock - timedelta(minutes=1))
  198. .not_valid_after(clock + timedelta(days=1))
  199. .add_extension(x509.SubjectAlternativeName([
  200. x509.IPAddress(ipaddress.ip_address("127.0.0.1")),
  201. ]), critical=False).sign(private, hashes.SHA256())
  202. )
  203. cert = folder / "certificate.pem"
  204. key = folder / "private.pem"
  205. cert.write_bytes(certificate.public_bytes(serialization.Encoding.PEM))
  206. key.write_bytes(private.private_bytes(
  207. serialization.Encoding.PEM, serialization.PrivateFormat.PKCS8,
  208. serialization.NoEncryption(),
  209. ))
  210. secret = uuid4().hex + uuid4().hex
  211. received = []
  212. accepted = threading.Event()
  213. class Handler(BaseHTTPRequestHandler):
  214. def do_POST(self):
  215. content = self.rfile.read(int(self.headers["Content-Length"]))
  216. timestamp = self.headers["X-Zhifu-Timestamp"]
  217. signature = hmac.new(
  218. secret.encode(), timestamp.encode() + b"." + content, hashlib.sha256,
  219. ).hexdigest()
  220. assert hmac.compare_digest(signature, self.headers["X-Zhifu-Signature"])
  221. received.append(json.loads(content))
  222. # 接收器维护期间真实返回 HTTP 503,解除维护后才接受并确认通知。
  223. self.send_response(204 if accepted.is_set() else 503)
  224. self.send_header("Content-Length", "0")
  225. self.end_headers()
  226. def log_message(self, *_):
  227. pass
  228. server = ThreadingHTTPServer(("127.0.0.1", 0), Handler)
  229. context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
  230. context.load_cert_chain(cert, key)
  231. server.socket = context.wrap_socket(server.socket, server_side=True)
  232. thread = threading.Thread(target=server.serve_forever, daemon=True)
  233. thread.start()
  234. prior_ca = os.environ.get("SSL_CERT_FILE")
  235. credential_env = "PLATFORM_TEST_SIGNING_" + uuid4().hex
  236. os.environ["SSL_CERT_FILE"] = str(cert.resolve())
  237. os.environ[credential_env] = secret
  238. try:
  239. yield {
  240. "url": f"https://127.0.0.1:{server.server_port}/events",
  241. "credential_env": credential_env, "received": received, "accepted": accepted,
  242. }
  243. finally:
  244. server.shutdown()
  245. server.server_close()
  246. thread.join(timeout=5)
  247. if prior_ca is None:
  248. os.environ.pop("SSL_CERT_FILE", None)
  249. else:
  250. os.environ["SSL_CERT_FILE"] = prior_ca
  251. os.environ.pop(credential_env, None)
  252. async def test_real_tls_delivery_failure_retry_and_receipt(scenario, operator, receiver): # noqa: F811
  253. await make_proposal(scenario)
  254. sessions = scenario["app"].state.sessions
  255. async with sessions.begin() as session:
  256. session.add(ChannelApplication(
  257. tenant_id=scenario["tenant"].bytes, store_id=scenario["store"].bytes,
  258. name="本地 TLS 验收接收器", callback_url=receiver["url"],
  259. credential_env=receiver["credential_env"], enabled=True, version=1,
  260. scopes=["tickets:results"], updated_at=now(),
  261. ))
  262. assert await enqueue(sessions, tenant_id=scenario["tenant"].bytes) == 1
  263. assert await enqueue(sessions, tenant_id=scenario["tenant"].bytes) == 0
  264. async with sessions() as session:
  265. row = await session.scalar(select(ChannelDelivery).where(
  266. ChannelDelivery.tenant_id == scenario["tenant"].bytes,
  267. ))
  268. delivery_id = row.id
  269. # 实际使用 30 秒重试间隔,整个测试最多等待 75 秒,生成模型调用次数为 0。
  270. async with asyncio.timeout(75):
  271. while True:
  272. await execute_one(sessions, delivery_id)
  273. async with sessions() as session:
  274. row = await session.get(ChannelDelivery, delivery_id)
  275. if row.status == "FAILED":
  276. break
  277. await asyncio.sleep(1)
  278. assert row.attempts == 3 and row.last_error == "HTTP_503"
  279. assert len(receiver["received"]) == 3
  280. event_id = receiver["received"][0]["event_id"]
  281. path = f"/deliveries/{UUID(bytes=delivery_id)}/retry"
  282. call = operator["call"]
  283. key = str(uuid4())
  284. body = {"expected_version": row.version}
  285. first = await call("POST", path, body, key)
  286. repeated = await call("POST", path, body, key)
  287. assert first.status_code == repeated.status_code == 200
  288. assert first.json() == repeated.json()
  289. assert first.json()["status"] == "QUEUED"
  290. assert (await call("POST", path, body)).status_code == 409
  291. receiver["accepted"].set()
  292. await execute_one(sessions, delivery_id)
  293. async with sessions() as session:
  294. row = await session.get(ChannelDelivery, delivery_id)
  295. assert row.status == "SENT" and row.response_status == 204 and row.attempts == 4
  296. assert {entry["event_id"] for entry in receiver["received"]} == {event_id}
  297. assert {entry["delivery_id"] for entry in receiver["received"]} == {
  298. str(UUID(bytes=delivery_id)),
  299. }
  300. assert await execute_one(sessions, delivery_id) is False