diff --git a/rest/python/server/config.py b/rest/python/server/config.py index 81ce012..683e848 100644 --- a/rest/python/server/config.py +++ b/rest/python/server/config.py @@ -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 @@ -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 diff --git a/rest/python/server/integration_test.py b/rest/python/server/integration_test.py index 6f9d095..03c64da 100644 --- a/rest/python/server/integration_test.py +++ b/rest/python/server/integration_test.py @@ -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() diff --git a/rest/python/server/routes/discovery.py b/rest/python/server/routes/discovery.py index 207a04d..1c94da9 100644 --- a/rest/python/server/routes/discovery.py +++ b/rest/python/server/routes/discovery.py @@ -16,7 +16,7 @@ import json import pathlib -import uuid +import config from fastapi import APIRouter from fastapi import Request from fastapi import Response @@ -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(