From e0f0c21b8afbd6862e34812248c3959f4137ea9f Mon Sep 17 00:00:00 2001 From: Vishal Katyal Date: Fri, 18 Sep 2026 10:08:12 -0400 Subject: [PATCH] fix(rest/python): resolve the shop id before the profile is cached 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 unsubstituted. get_payment_handlers returns that cached copy and checkout_service embeds it, so every checkout envelope advertised the literal placeholder: GET /.well-known/ucp shop_id a real uuid POST /checkout-sessions shop_id {{SHOP_ID}} A platform that reads the handler config from the checkout response, rather than from discovery, therefore gets a value the business never advertised. The shop id is the one placeholder in that template which does not depend on the request, so config now owns it and resolves it as the profile is loaded. routes/discovery.py reuses that value rather than generating a second one, so the two documents agree by construction. {{ENDPOINT}} stays in discovery.py because it is derived from the request base URL. The cached profile still holds that placeholder, but no accessor returns the services block, so nothing can reach a client today. The test asserts three things: that the checkout envelope carries no template markers, that its payment_handlers equal the advertised ones, and that each shop_id parses as a uuid. The last one matters because equality alone would accept any constant both sides happened to share. Local: pytest 249 passed, was 245. The one new test is collected 4 times across the 3 modules that define or import IntegrationTest. Reverting only the substitution, with the test kept, turns it red, as does setting the shop id to a fixed non uuid string. pre-commit run --all-files passes. --- rest/python/server/config.py | 12 +++++- rest/python/server/integration_test.py | 51 ++++++++++++++++++++++++++ rest/python/server/routes/discovery.py | 7 ++-- 3 files changed, 66 insertions(+), 4 deletions(-) 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(