diff --git a/checkout_sdk/oauth_scopes.py b/checkout_sdk/oauth_scopes.py index ddc6cb51..5b96e327 100644 --- a/checkout_sdk/oauth_scopes.py +++ b/checkout_sdk/oauth_scopes.py @@ -4,11 +4,36 @@ class OAuthScopes(str, Enum): + """OAuth 2.0 client credentials scopes. + + Mirrors components.securitySchemes.OAuth.flows.clientCredentials.scopes in the Checkout.com API + specification, plus the scopes that appear only in per-operation security requirements and are + never declared in that map: compliance-requests, compliance-requests:read, + compliance-requests:respond, vault:gpayme-enrollment and vault:tokens-metadata. + + Five further members -- issuing:card-mgmt, issuing:client, marketplace, middleware:gateway and + middleware:payment-context -- appear nowhere in the specification at all, but the authorization + server still grants them and callers still request them, so they are kept for backward + compatibility. Each is marked inline. Do not assume a scope is dead because the specification + omits it: the sandbox payouts client is provisioned for marketplace and answers a request for + accounts with invalid_scope. + + Members are ordered alphabetically. Note that PAYMENT_CONTEXT and GATEWAY_PAYMENT_CONTEXTS are + different scopes: the specification requires the former for GET /payment-contexts/{id} and the + latter for POST /payment-contexts. 'Payment Context' is the only scope whose wire value contains + a space and a capital letter, which looks like a specification authoring defect; it is mirrored + verbatim regardless, because that is the value the authorization server is documented to accept. + """ + ACCOUNTS = 'accounts' + AGENTIC_INVENTORY = 'agentic:inventory' BALANCES = 'balances' - BALANCES_VIEW = 'balances:view' BALANCES_TOP_UP_INSTRUCTIONS = 'balances:top-up-instructions' + BALANCES_VIEW = 'balances:view' CARD_MANAGEMENT = 'card-management' + COMPLIANCE_REQUESTS = 'compliance-requests' + COMPLIANCE_REQUESTS_READ = 'compliance-requests:read' + COMPLIANCE_REQUESTS_RESPOND = 'compliance-requests:respond' DISPUTES = 'disputes' DISPUTES_ACCEPT = 'disputes:accept' DISPUTES_PROVIDE_EVIDENCE = 'disputes:provide-evidence' @@ -39,8 +64,8 @@ class OAuthScopes(str, Enum): IDENTITY_VERIFICATION = 'identity-verification' ISSUING_CARD_MANAGEMENT_READ = 'issuing:card-management-read' ISSUING_CARD_MANAGEMENT_WRITE = 'issuing:card-management-write' - ISSUING_CARD_MGMT = 'issuing:card-mgmt' - ISSUING_CLIENT = 'issuing:client' + ISSUING_CARD_MGMT = 'issuing:card-mgmt' # not in spec; kept for backward compat + ISSUING_CLIENT = 'issuing:client' # not in spec; kept for backward compat ISSUING_CONTROLS_READ = 'issuing:controls-read' ISSUING_CONTROLS_WRITE = 'issuing:controls-write' ISSUING_DISPUTES = 'issuing-disputes' @@ -48,15 +73,15 @@ class OAuthScopes(str, Enum): ISSUING_DISPUTES_WRITE = 'issuing:disputes-write' ISSUING_TRANSACTIONS_READ = 'issuing:transactions-read' ISSUING_TRANSACTIONS_WRITE = 'issuing:transactions-write' - MARKETPLACE = 'marketplace' + MARKETPLACE = 'marketplace' # not in spec; kept for backward compat MIDDLEWARE = 'middleware' - MIDDLEWARE_GATEWAY = 'middleware:gateway' + MIDDLEWARE_GATEWAY = 'middleware:gateway' # not in spec; kept for backward compat MIDDLEWARE_MERCHANTS_PUBLIC = 'middleware:merchants-public' MIDDLEWARE_MERCHANTS_SECRET = 'middleware:merchants-secret' - MIDDLEWARE_PAYMENT_CONTEXT = 'middleware:payment-context' - PAYMENTS_SEARCH = 'payments:search' + MIDDLEWARE_PAYMENT_CONTEXT = 'middleware:payment-context' # not in spec; kept for backward compat PAYMENT_CONTEXT = 'Payment Context' PAYMENT_SESSIONS = 'payment-sessions' + PAYMENTS_SEARCH = 'payments:search' PAYOUTS_BANK_DETAILS = 'payouts:bank-details' REPORTS = 'reports' REPORTS_VIEW = 'reports:view' @@ -75,3 +100,4 @@ class OAuthScopes(str, Enum): VAULT_NETWORK_TOKENS = 'vault:network-tokens' VAULT_REAL_TIME_ACCOUNT_UPDATER = 'vault:real-time-account-updater' VAULT_TOKENIZATION = 'vault:tokenization' + VAULT_TOKENS_METADATA = 'vault:tokens-metadata' diff --git a/tests/accounts/accounts_payout_schedules_integration_test.py b/tests/accounts/accounts_payout_schedules_integration_test.py index c511eb2e..964adf97 100644 --- a/tests/accounts/accounts_payout_schedules_integration_test.py +++ b/tests/accounts/accounts_payout_schedules_integration_test.py @@ -20,7 +20,9 @@ def payout_schedules_api(): .oauth() \ .client_credentials(client_id=os.environ.get('CHECKOUT_DEFAULT_OAUTH_PAYOUT_SCHEDULE_CLIENT_ID'), client_secret=os.environ.get('CHECKOUT_DEFAULT_OAUTH_PAYOUT_SCHEDULE_CLIENT_SECRET')) \ - .scopes([OAuthScopes.MARKETPLACE]) + .scopes([OAuthScopes.ACCOUNTS]) + # The marketplace scope was retired; both payout-schedules operations document accounts as their + # OAuth requirement. # The sandbox OAuth clients are not provisioned for the merchant-specific subdomain, so the # token request would come back invalid_client. Opting out explicitly until they are. with warnings.catch_warnings(): diff --git a/tests/issuing/conftest.py b/tests/issuing/conftest.py index e8437caa..918aef0d 100644 --- a/tests/issuing/conftest.py +++ b/tests/issuing/conftest.py @@ -21,8 +21,11 @@ def issuing_checkout_api(): .oauth() \ .client_credentials(client_id=os.environ.get('CHECKOUT_DEFAULT_OAUTH_ISSUING_CLIENT_ID'), client_secret=os.environ.get('CHECKOUT_DEFAULT_OAUTH_ISSUING_CLIENT_SECRET')) \ - .scopes([OAuthScopes.ISSUING_CLIENT, OAuthScopes.ISSUING_CARD_MGMT, + .scopes([OAuthScopes.ISSUING_CARD_MANAGEMENT_READ, OAuthScopes.ISSUING_CARD_MANAGEMENT_WRITE, OAuthScopes.ISSUING_CONTROLS_READ, OAuthScopes.ISSUING_CONTROLS_WRITE]) + # issuing:card-mgmt and issuing:client were retired: neither is declared in the spec's scope map + # nor requested by any operation. The card-management pair above replaces the former; the latter + # has no documented equivalent. # The sandbox OAuth clients are not provisioned for the merchant-specific subdomain, so the # token request would come back invalid_client. Opting out explicitly until they are. with warnings.catch_warnings(): diff --git a/tests/oauth_scopes_test.py b/tests/oauth_scopes_test.py index e4d7a7bd..5a8151aa 100644 --- a/tests/oauth_scopes_test.py +++ b/tests/oauth_scopes_test.py @@ -1,3 +1,5 @@ +from collections import Counter + from checkout_sdk.oauth_scopes import OAuthScopes @@ -16,3 +18,82 @@ def test_should_expose_documented_balances_scope_values(self): assert OAuthScopes.BALANCES.value == 'balances' assert OAuthScopes.BALANCES_VIEW.value == 'balances:view' assert OAuthScopes.BALANCES_TOP_UP_INSTRUCTIONS.value == 'balances:top-up-instructions' + + def test_should_expose_documented_values_for_scopes_added_in_spec_sync(self): + """The scopes added when this enum was synced against the spec. + + None of these four are declared in clientCredentials.scopes: they appear only in the + per-operation security requirements of GET/POST /compliance-requests/{payment_id} and + GET /tokens/{tokenId}/metadata. An enum built from the declared map alone would miss them. + """ + assert OAuthScopes.COMPLIANCE_REQUESTS.value == 'compliance-requests' + assert OAuthScopes.COMPLIANCE_REQUESTS_READ.value == 'compliance-requests:read' + assert OAuthScopes.COMPLIANCE_REQUESTS_RESPOND.value == 'compliance-requests:respond' + assert OAuthScopes.VAULT_TOKENS_METADATA.value == 'vault:tokens-metadata' + + def test_should_expose_the_agentic_commerce_inventory_scope(self): + """agentic:inventory is declared in clientCredentials.scopes ("Manage agentic commerce + inventory and reservations"), and it is the OAuth requirement of the ten /inventory/* + operations behind the beta agentic-commerce inventory and reservations endpoints. + """ + assert OAuthScopes.AGENTIC_INVENTORY.value == 'agentic:inventory' + + def test_should_retain_the_legacy_scopes_the_spec_omits(self): + """These five appear nowhere in the spec, so a spec-driven sweep would delete them. + + They are kept deliberately: the authorization server still grants them and callers still + request them. marketplace is the proof -- the sandbox payouts client is provisioned for it + and answers a request for accounts with invalid_scope. + """ + assert OAuthScopes.ISSUING_CARD_MGMT.value == 'issuing:card-mgmt' + assert OAuthScopes.ISSUING_CLIENT.value == 'issuing:client' + assert OAuthScopes.MARKETPLACE.value == 'marketplace' + assert OAuthScopes.MIDDLEWARE_GATEWAY.value == 'middleware:gateway' + assert OAuthScopes.MIDDLEWARE_PAYMENT_CONTEXT.value == 'middleware:payment-context' + + def test_should_distinguish_the_two_payment_context_scopes(self): + """PAYMENT_CONTEXT and GATEWAY_PAYMENT_CONTEXTS read alike but are unrelated scopes. + + The spec requires the former for GET /payment-contexts/{id} and the latter for + POST /payment-contexts. 'Payment Context' is the only scope whose value contains a space and + a capital letter, which is almost certainly a spec authoring defect -- asserted verbatim + because that is the value the authorization server is documented to accept. + """ + assert OAuthScopes.PAYMENT_CONTEXT.value == 'Payment Context' + assert OAuthScopes.GATEWAY_PAYMENT_CONTEXTS.value == 'gateway:payment-contexts' + + def test_should_expose_a_non_blank_wire_value_for_every_member(self): + """A blank value is not caught by the assertions above, which only read members they name. + + oauth_credentials.py joins the requested scopes with a space, so a blank member would be + sent as an empty entry and the token endpoint would reject the whole request, costing the + caller every other scope it asked for. + """ + blank = [scope.name for scope in OAuthScopes if not scope.value.strip()] + assert blank == [] + + def test_should_not_reuse_a_wire_value_across_members(self): + """A duplicate wire value means one of the two members is a copy-paste error. + + Python's Enum hides this far better than the other SDKs' constructs do: the second member + to declare a value becomes an *alias* of the first rather than a member of its own, so + `OAuthScopes.VAULT_TOKENS_METADATA is OAuthScopes.VAULT_TOKENIZATION` would simply be True + and the scope the aliased name was meant to carry would be unreachable, with nothing + failing loudly. + + This must iterate __members__, not the enum: iteration *skips* aliases, so counting values + that way can never see a duplicate and the assertion would hold vacuously. + """ + duplicates = [value for value, count in + Counter(scope.value for scope in OAuthScopes.__members__.values()).items() + if count > 1] + assert duplicates == [] + + def test_should_declare_members_in_alphabetical_order(self): + """Members are kept alphabetical so the next spec sync produces a readable diff. + + Underscores are ignored when comparing, which is what puts PAYMENT_CONTEXT, + PAYMENT_SESSIONS and PAYMENTS_SEARCH in that order, matching the other Checkout SDKs. + """ + declared = [scope.name.replace('_', '').lower() for scope in OAuthScopes] + assert declared == sorted(declared)