| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186 |
- import asyncio
- from datetime import timedelta
- from uuid import UUID
- import pytest
- from sqlalchemy import func, select
- from test_after_sales import scenario # noqa: F401
- from test_conversations import setup
- from zhifu.modules.identity.access_models import StoreGrant
- from zhifu.modules.identity.auth import now
- from zhifu.modules.merchant.models import ChannelApplication
- from zhifu.modules.notifications.models import Notification
- from zhifu.modules.storefront.models import CatalogCategory, CatalogProduct, IdentityChallenge
- pytestmark = pytest.mark.integration
- async def prepare_channel(context):
- # 独立测试租户只验证身份应用服务;回调传输由平台 TLS 接收器测试覆盖。
- async with context["app"].state.sessions.begin() as session:
- session.add(ChannelApplication(
- tenant_id=context["tenant"].bytes, store_id=context["store"].bytes,
- name="独立身份组件验证渠道", callback_url="https://127.0.0.1/channel-component",
- enabled=True, version=1, scopes=["tickets:results"], updated_at=now(),
- ))
- for permission in ["identities:verify", "tickets:read"]:
- session.add(StoreGrant(
- tenant_id=context["tenant"].bytes, store_id=context["store"].bytes,
- principal_id=context["accounts"]["connector"].bytes, permission=permission,
- ))
- async def challenge(context, actor="customer", key=None):
- response = await context["call"]("POST", "/openapi/v1/me/identity-challenges", actor,
- {"store_id": str(context["store"]), "channel": "webhook"}, key)
- assert response.status_code == 200, response.text
- return response.json()
- def proof(context, external="verified-channel-account"):
- return {"store_id": str(context["store"]), "channel": "webhook",
- "external_subject": external, "display_name": "经过渠道认证的客户"}
- async def test_catalog_order_metadata_and_visibility(scenario): # noqa: F811
- sessions = scenario["app"].state.sessions
- tenant, store = scenario["tenant"].bytes, scenario["store"].bytes
- async with sessions.begin() as session:
- session.add(CatalogCategory(tenant_id=tenant, store_id=store,
- code="keyboard", name="键盘", position=0))
- await session.flush()
- for code, status in [("keyboard-1", "ACTIVE"), ("keyboard-2", "ACTIVE"),
- ("keyboard-3", "HIDDEN")]:
- session.add(CatalogProduct(
- tenant_id=tenant, store_id=store, id=code, category_code="keyboard",
- name="办公键盘", description="日常文字输入", specifications={"款式": "标准款"},
- delivery="店铺配送", image_url="/products/keyboard-01.jpg",
- image_source="https://www.pexels.com/photo/17479949/", status=status,
- ))
- client = scenario["client"]
- path = f"/openapi/v1/stores/{scenario['store']}"
- first = await client.get(path + "/catalog?limit=1")
- assert first.status_code == 200
- assert first.json()["next_cursor"] == "keyboard-1"
- second = await client.get(path + "/catalog?limit=1&cursor=keyboard-1")
- assert second.json()["next_cursor"] is None
- assert second.json()["items"][0]["id"] == "keyboard-2"
- assert (await client.get(path + "/products/keyboard-3")).status_code == 404
- assert (await client.get(path + "/catalog?category=missing")).json()["items"] == []
- order = await scenario["call"]("GET", f"/openapi/v1/orders/{scenario['order_id']}")
- assert order.json()["product_name"] == "办公键盘"
- assert order.json()["specifications"] == {"款式": "标准款"}
- assert order.json()["fulfillment_status"] == "SIGNED"
- assert order.json()["available_quantity"] == 1
- orders_path = f"/openapi/v1/orders?store_id={scenario['store']}"
- listed = await scenario["call"]("GET", orders_path)
- assert listed.status_code == 200, listed.text
- assert listed.json()["items"][0]["order_id"] == str(scenario["order_id"])
- assert listed.json()["items"][0]["available_quantity"] == 1
- foreign = await scenario["call"]("GET", f"/openapi/v1/orders/{scenario['order_id']}", "foreign")
- assert foreign.status_code == 404
- # 来源更新到零时,列表与详情都应显示最新可售后数量。
- synchronized = await scenario["call"](
- "PUT", f"/internal/v1/orders/{scenario['order_id']}", "connector",
- {**scenario["source"], "source_version": 2, "available_quantity": 0},
- )
- assert synchronized.status_code == 200, synchronized.text
- listed = await scenario["call"]("GET", orders_path)
- order = await scenario["call"]("GET", f"/openapi/v1/orders/{scenario['order_id']}")
- assert listed.json()["items"][0]["available_quantity"] == 0
- assert order.json()["available_quantity"] == 0
- async def test_handoff_transaction_retry_and_takeover(scenario): # noqa: F811
- path = await setup(scenario)
- call = scenario["call"]
- body = {"expected_control_version": 1}
- requests = await asyncio.gather(*[
- call("POST", path + "/human-assistance", payload=body, key="handoff-same")
- for _ in range(3)
- ])
- assert all(response.status_code == 200 for response in requests)
- assert requests[0].json() == requests[1].json() == requests[2].json()
- assert requests[0].json()["requested"]
- assert (await call("GET", path + "/human-assistance")).json()["requested"]
- assert (await call("GET", path + "/human-assistance", "other")).status_code == 404
- conflict = await call("POST", path + "/human-assistance",
- payload={"expected_control_version": 9}, key="handoff-same")
- assert conflict.json()["code"] == "IDEMPOTENCY_CONFLICT"
- async with scenario["app"].state.sessions() as session:
- count = await session.scalar(select(func.count()).select_from(Notification).where(
- Notification.tenant_id == scenario["tenant"].bytes,
- ))
- assert count == 2
- assert (await call("POST", path + "/takeover", "agent", body)).status_code == 200
- assert not (await call("GET", path + "/human-assistance")).json()["requested"]
- assert (await call("POST", path + "/release", "agent",
- {"expected_control_version": 2})).status_code == 200
- assert not (await call("GET", path + "/human-assistance")).json()["requested"]
- fresh = await call("POST", path + "/human-assistance",
- payload={"expected_control_version": 3})
- assert fresh.status_code == 200 and fresh.json()["requested"]
- async def test_identity_proof_confirmation_isolation_and_conflict(scenario): # noqa: F811
- await prepare_channel(scenario)
- call = scenario["call"]
- first = await challenge(scenario, key="challenge-same")
- assert await challenge(scenario, key="challenge-same") == first
- identifier = first["challenge_id"]
- path = f"/openapi/v1/me/identity-challenges/{identifier}"
- verify = f"/openapi/v1/channel/identity-challenges/{identifier}/verify"
- assert (await call("POST", path + "/confirm")).json()["code"] == "IDENTITY_NOT_VERIFIED"
- for actor in ["other", "foreign"]:
- assert (await call("GET", path, actor)).status_code == 404
- assert (await call("POST", path + "/confirm", actor)).status_code == 404
- assert (await call("POST", verify, payload=proof(scenario))).status_code == 403
- response = await call("POST", verify, "connector", proof(scenario))
- assert response.status_code == 200 and response.json()["status"] == "VERIFIED"
- assert (await call("POST", verify, "connector", proof(scenario))).json() == response.json()
- conflict = await call("POST", verify, "connector", proof(scenario, "other-account"))
- assert conflict.status_code == 409 and conflict.json()["code"] == "IDENTITY_CONFLICT"
- confirmed = await asyncio.gather(*[call("POST", path + "/confirm") for _ in range(3)])
- assert all(item.status_code == 200 and item.json()["status"] == "LINKED"
- for item in confirmed)
- identities = await call(
- "GET", f"/openapi/v1/me/channel-identities?store_id={scenario['store']}"
- )
- assert len(identities.json()["items"]) == 1
- other = await challenge(scenario, "other")
- other_id = other["challenge_id"]
- assert (await call("POST", f"/openapi/v1/channel/identity-challenges/{other_id}/verify",
- "connector", proof(scenario))).status_code == 200
- collision = await call(
- "POST", f"/openapi/v1/me/identity-challenges/{other_id}/confirm", "other"
- )
- assert collision.status_code == 409 and collision.json()["code"] == "IDENTITY_CONFLICT"
- tickets = f"/openapi/v1/channel/identities/tickets?store_id={scenario['store']}"
- assert (await call("GET", tickets + "&external_subject=verified-channel-account",
- "connector")).status_code == 200
- missing = await call("GET", tickets + "&external_subject=unknown", "connector")
- assert missing.status_code == 404
- async def test_identity_expiration_and_channel_disabled(scenario): # noqa: F811
- await prepare_channel(scenario)
- call = scenario["call"]
- pending = await challenge(scenario)
- identifier = pending["challenge_id"]
- path = f"/openapi/v1/me/identity-challenges/{identifier}"
- verify = f"/openapi/v1/channel/identity-challenges/{identifier}/verify"
- assert (await call("POST", verify, "connector", proof(scenario))).status_code == 200
- async with scenario["app"].state.sessions.begin() as session:
- row = await session.get(IdentityChallenge, UUID(identifier).bytes)
- row.expires_at = now() - timedelta(seconds=1)
- assert (await call("POST", path + "/confirm")).json()["code"] == "CHALLENGE_EXPIRED"
- assert (await call("POST", verify, "connector",
- proof(scenario))).json()["code"] == "CHALLENGE_EXPIRED"
- async with scenario["app"].state.sessions.begin() as session:
- channel = await session.get(ChannelApplication,
- (scenario["tenant"].bytes, scenario["store"].bytes))
- channel.enabled = False
- body = {"store_id": str(scenario["store"]), "channel": "webhook"}
- disabled = await call("POST", "/openapi/v1/me/identity-challenges", payload=body)
- assert disabled.status_code == 409 and disabled.json()["code"] == "CHANNEL_UNAVAILABLE"
|