Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 11 additions & 1 deletion rest/python/server/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,12 @@

_PROFILE_CACHE = None

# The shop id this server instance advertises. discovery_profile.json is a
# template, and this is the one value substituted into it that does not depend
# on the request, so it is resolved here where every reader of the profile sees
# the same value. routes/discovery.py substitutes {{ENDPOINT}} per request.
SHOP_ID = str(uuid.uuid4())

# checkout.json annotates `currency` with `ucp_request: omit` and describes
# it as "reflecting the merchant's market determination ... buyers provide
# signals, merchants determine currency". A conformant platform therefore does
Expand All @@ -48,7 +54,11 @@ def _get_profile() -> dict:
profile_path = current_dir / "routes" / "discovery_profile.json"

with profile_path.open(encoding="utf-8") as f:
_PROFILE_CACHE = json.load(f)
template = f.read()

# Resolve the request independent placeholder before parsing, so callers
# such as get_payment_handlers never hand a template value to a platform.
_PROFILE_CACHE = json.loads(template.replace("{{SHOP_ID}}", SHOP_ID))
return _PROFILE_CACHE


Expand Down
51 changes: 51 additions & 0 deletions rest/python/server/integration_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -2224,6 +2224,57 @@ def test_checkout_readiness_lifecycle(self) -> None:
self.assertEqual(complete_res.status_code, 200, complete_res.text)
self.assertEqual(complete_res.json()["status"], "completed")

def test_checkout_payment_handler_config_matches_discovery(self) -> None:
"""The checkout envelope and discovery must advertise the same shop id.

discovery_profile.json is a template. routes/discovery.py reads it and
substitutes {{SHOP_ID}} before serving /.well-known/ucp, but
config._get_profile reads the same file separately and caches it raw, so
every checkout envelope carried the literal placeholder while discovery
carried a real value. A platform reading the handler config from the
checkout response therefore saw a different shop id than the one the
business advertises.
"""
with self.client:
discovery = self.client.get("/.well-known/ucp")
self.assertEqual(discovery.status_code, 200, discovery.text)
advertised = discovery.json()["ucp"]["payment_handlers"]

payload = self._create_checkout_payload(
"shop_id_1", [("rose", "Red Rose", 1000, 1)]
)
response = self.client.post(
"/checkout-sessions",
headers=self._get_headers(idempotency_key="sid1", request_id="sid1"),
json=payload.model_dump(mode="json", exclude_none=True),
)
self.assertEqual(response.status_code, 201, response.text)
in_checkout = response.json()["ucp"]["payment_handlers"]

serialized = json.dumps(in_checkout)
self.assertNotIn(
"{{",
serialized,
msg="the checkout envelope must not carry template placeholders",
)
self.assertEqual(
in_checkout,
advertised,
msg="checkout payment_handlers must match the advertised profile",
)

# Equality alone would accept any shared constant, so pin the shape of
# the substituted value rather than only its agreement.
shop_ids = [
handler["config"]["shop_id"]
for entries in in_checkout.values()
for handler in entries
if "shop_id" in handler.get("config", {})
]
self.assertNotEmpty(shop_ids, msg="expected a handler carrying a shop_id")
for shop_id in shop_ids:
uuid.UUID(shop_id)


if __name__ == "__main__":
absltest.main()
7 changes: 4 additions & 3 deletions rest/python/server/routes/discovery.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@

import json
import pathlib
import uuid
import config
from fastapi import APIRouter
from fastapi import Request
from fastapi import Response
Expand All @@ -31,8 +31,9 @@
# of at least 60 seconds, and never `private`/`no-store`/`no-cache`.
PROFILE_CACHE_CONTROL = "public, max-age=3600"

# Generate a unique shop ID for this server instance
SHOP_ID = str(uuid.uuid4())
# The shop id is owned by config so that the discovery document and every
# other reader of the profile advertise the same value.
SHOP_ID = config.SHOP_ID


@router.get(
Expand Down
Loading