diff --git a/CHANGELOG.md b/CHANGELOG.md index 294771d7c..782e14f79 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ Note: For changes to the API, see https://shopify.dev/changelog?filter=api ## Unreleased +- [#1443](https://github.com/Shopify/shopify-api-ruby/pull/1443) Add `ShopifyAPI::Utils::ShopValidator` (module) with `sanitize_shop_domain` and `sanitize!`. +- [#1443](https://github.com/Shopify/shopify-api-ruby/pull/1443) `ShopifyAPI::Auth::TokenExchange.exchange_token` always uses the session token's `dest` claim, instead of the `shop` parameter, that is now deprecated. It will show a deprecation warning and the argument will be removed in the next major version. ## 16.2.0 (2026-04-13) - [#1442](https://github.com/Shopify/shopify-api-ruby/pull/1442) Add support for 2026-04 API version diff --git a/Gemfile.lock b/Gemfile.lock index 71b6934df..27740b31c 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -3,6 +3,7 @@ PATH specs: shopify_api (16.2.0) activesupport + addressable (~> 2.7) concurrent-ruby hash_diff httparty diff --git a/docs/usage/oauth.md b/docs/usage/oauth.md index 0e22cff04..6a459a68e 100644 --- a/docs/usage/oauth.md +++ b/docs/usage/oauth.md @@ -72,9 +72,9 @@ exchange a [session token](https://shopify.dev/docs/apps/auth/session-tokens) (S #### Input | Parameter | Type | Required? | Default Value | Notes | | -------------- | ---------------------- | :-------: | :-----------: | ----------------------------------------------------------------------------------------------------------- | -| `shop` | `String` | Yes | - | A Shopify domain name in the form `{exampleshop}.myshopify.com`. | -| `session_token` | `String` | Yes| - | The session token (Shopify Id Token) provided by App Bridge in either the request 'Authorization' header or URL param when the app is loaded in Admin. | +| `session_token` | `String` | Yes| - | The session token (Shopify Id Token) provided by App Bridge in either the request 'Authorization' header or URL param when the app is loaded in Admin. Its `dest` claim determines which shop receives the token exchange request. | | `requested_token_type` | `TokenExchange::RequestedTokenType` | Yes | - | The type of token requested. Online: `TokenExchange::RequestedTokenType::ONLINE_ACCESS_TOKEN` or offline: `TokenExchange::RequestedTokenType::OFFLINE_ACCESS_TOKEN`. | +| `shop` | `String` | No | `nil` | **Deprecated**, will be removed in v17.0.0. Ignored for the request host; the shop always comes from the session token `dest` claim. If passed, logs a deprecation warning. | #### Output This method returns the new `ShopifyAPI::Auth::Session` object from the token exchange, @@ -83,14 +83,13 @@ your app should store this `Session` object to be used later [when making authen #### Example ```ruby -# `shop` is the shop domain name - "this-is-my-example-shop.myshopify.com" # `session_token` is the session token provided by App Bridge either in: # - the request 'Authorization' header as `Bearer this-is-the-session_token` # - or as a URL param `id_token=this-is-the-session_token` +# The shop is taken from the token's `dest` claim (see session token documentation). -def authenticate(shop, session_token) +def authenticate(session_token) session = ShopifyAPI::Auth::TokenExchange.exchange_token( - shop: shop, session_token: session_token, requested_token_type: ShopifyAPI::Auth::TokenExchange::RequestedTokenType::OFFLINE_ACCESS_TOKEN, # or if you're requesting an online access token: diff --git a/lib/shopify_api/auth/client_credentials.rb b/lib/shopify_api/auth/client_credentials.rb index 5775c9686..b1973d9aa 100644 --- a/lib/shopify_api/auth/client_credentials.rb +++ b/lib/shopify_api/auth/client_credentials.rb @@ -22,7 +22,8 @@ def client_credentials(shop:) "ShopifyAPI::Context not setup, please call ShopifyAPI::Context.setup" end - shop_session = ShopifyAPI::Auth::Session.new(shop: shop) + validated_shop = Utils::ShopValidator.sanitize!(shop) + shop_session = ShopifyAPI::Auth::Session.new(shop: validated_shop) body = { client_id: ShopifyAPI::Context.api_key, client_secret: ShopifyAPI::Context.api_secret_key, @@ -42,7 +43,7 @@ def client_credentials(shop:) response_hash = T.cast(response.body, T::Hash[String, T.untyped]).to_h Session.from( - shop: shop, + shop: validated_shop, access_token_response: Oauth::AccessTokenResponse.from_hash(response_hash), ) end diff --git a/lib/shopify_api/auth/refresh_token.rb b/lib/shopify_api/auth/refresh_token.rb index 0d52fd032..8b91b054e 100644 --- a/lib/shopify_api/auth/refresh_token.rb +++ b/lib/shopify_api/auth/refresh_token.rb @@ -21,7 +21,8 @@ def refresh_access_token(shop:, refresh_token:) "ShopifyAPI::Context not setup, please call ShopifyAPI::Context.setup" end - shop_session = ShopifyAPI::Auth::Session.new(shop:) + validated_shop = Utils::ShopValidator.sanitize!(shop) + shop_session = ShopifyAPI::Auth::Session.new(shop: validated_shop) body = { client_id: ShopifyAPI::Context.api_key, client_secret: ShopifyAPI::Context.api_secret_key, @@ -47,7 +48,7 @@ def refresh_access_token(shop:, refresh_token:) session_params = T.cast(response.body, T::Hash[String, T.untyped]).to_h Session.from( - shop:, + shop: validated_shop, access_token_response: Oauth::AccessTokenResponse.from_hash(session_params), ) end diff --git a/lib/shopify_api/auth/token_exchange.rb b/lib/shopify_api/auth/token_exchange.rb index 4741ae0d8..5406a6c78 100644 --- a/lib/shopify_api/auth/token_exchange.rb +++ b/lib/shopify_api/auth/token_exchange.rb @@ -21,12 +21,12 @@ class << self sig do params( - shop: String, session_token: String, requested_token_type: RequestedTokenType, + shop: T.nilable(String), ).returns(ShopifyAPI::Auth::Session) end - def exchange_token(shop:, session_token:, requested_token_type:) + def exchange_token(session_token:, requested_token_type:, shop: nil) unless ShopifyAPI::Context.setup? raise ShopifyAPI::Errors::ContextNotSetupError, "ShopifyAPI::Context not setup, please call ShopifyAPI::Context.setup" @@ -36,10 +36,19 @@ def exchange_token(shop:, session_token:, requested_token_type:) raise ShopifyAPI::Errors::UnsupportedOauthError, "Cannot perform OAuth Token Exchange for non embedded apps." unless ShopifyAPI::Context.embedded? - # Validate the session token content - ShopifyAPI::Auth::JwtPayload.new(session_token) + # Validate the session token and use the shop from the token's `dest` claim + jwt_payload = ShopifyAPI::Auth::JwtPayload.new(session_token) + dest_shop = jwt_payload.shop + + if shop + ShopifyAPI::Logger.deprecated( + "The `shop` parameter for `exchange_token` is deprecated and will be removed in v17. " \ + "The shop is now always taken from the session token's `dest` claim.", + "17.0.0", + ) + end - shop_session = ShopifyAPI::Auth::Session.new(shop: shop) + shop_session = ShopifyAPI::Auth::Session.new(shop: dest_shop) body = { client_id: ShopifyAPI::Context.api_key, client_secret: ShopifyAPI::Context.api_secret_key, @@ -74,7 +83,7 @@ def exchange_token(shop:, session_token:, requested_token_type:) session_params = T.cast(response.body, T::Hash[String, T.untyped]).to_h Session.from( - shop: shop, + shop: dest_shop, access_token_response: Oauth::AccessTokenResponse.from_hash(session_params), ) end @@ -91,7 +100,8 @@ def migrate_to_expiring_token(shop:, non_expiring_offline_token:) "ShopifyAPI::Context not setup, please call ShopifyAPI::Context.setup" end - shop_session = ShopifyAPI::Auth::Session.new(shop: shop) + validated_shop = Utils::ShopValidator.sanitize!(shop) + shop_session = ShopifyAPI::Auth::Session.new(shop: validated_shop) body = { client_id: ShopifyAPI::Context.api_key, client_secret: ShopifyAPI::Context.api_secret_key, @@ -120,7 +130,7 @@ def migrate_to_expiring_token(shop:, non_expiring_offline_token:) session_params = T.cast(response.body, T::Hash[String, T.untyped]).to_h Session.from( - shop: shop, + shop: validated_shop, access_token_response: Oauth::AccessTokenResponse.from_hash(session_params), ) end diff --git a/lib/shopify_api/clients/graphql/storefront.rb b/lib/shopify_api/clients/graphql/storefront.rb index 671b3f2f9..09485a104 100644 --- a/lib/shopify_api/clients/graphql/storefront.rb +++ b/lib/shopify_api/clients/graphql/storefront.rb @@ -19,9 +19,10 @@ def initialize(shop, private_token: nil, public_token: nil, api_version: nil) raise ArgumentError, "Storefront client requires either private_token or public_token to be provided" end + validated_shop = Utils::ShopValidator.sanitize!(shop) session = Auth::Session.new( - id: shop, - shop: shop, + id: validated_shop, + shop: validated_shop, access_token: "", is_online: false, ) diff --git a/lib/shopify_api/errors/invalid_shop_error.rb b/lib/shopify_api/errors/invalid_shop_error.rb new file mode 100644 index 000000000..277dc2ea8 --- /dev/null +++ b/lib/shopify_api/errors/invalid_shop_error.rb @@ -0,0 +1,9 @@ +# typed: strict +# frozen_string_literal: true + +module ShopifyAPI + module Errors + class InvalidShopError < StandardError + end + end +end diff --git a/lib/shopify_api/utils/shop_validator.rb b/lib/shopify_api/utils/shop_validator.rb new file mode 100644 index 000000000..860fcb846 --- /dev/null +++ b/lib/shopify_api/utils/shop_validator.rb @@ -0,0 +1,118 @@ +# typed: strict +# frozen_string_literal: true + +require "addressable/uri" + +module ShopifyAPI + module Utils + module ShopValidator + TRUSTED_SHOPIFY_DOMAINS = T.let( + [ + "shopify.com", + "myshopify.io", + "myshopify.com", + "spin.dev", + "shop.dev", + ].freeze, + T::Array[String], + ) + + class << self + extend T::Sig + + sig do + params( + shop_domain: String, + myshopify_domain: T.nilable(String), + ).returns(T.nilable(String)) + end + def sanitize_shop_domain(shop_domain, myshopify_domain: nil) + uri = uri_from_shop_domain(shop_domain, myshopify_domain) + return nil if uri.nil? || uri.host.nil? || uri.host.empty? + + trusted_domains(myshopify_domain).each do |trusted_domain| + host = T.cast(uri.host, String) + uri_domain = uri.domain + next if uri_domain.nil? + + no_shop_name_in_subdomain = host == trusted_domain + from_trusted_domain = trusted_domain == uri_domain + + if unified_admin?(uri) && from_trusted_domain + return myshopify_domain_from_unified_admin(uri) + end + return nil if no_shop_name_in_subdomain || host.empty? + return host if from_trusted_domain + end + nil + end + + sig do + params( + shop: String, + myshopify_domain: T.nilable(String), + ).returns(String) + end + def sanitize!(shop, myshopify_domain: nil) + host = sanitize_shop_domain(shop, myshopify_domain: myshopify_domain) + if host.nil? || host.empty? + raise Errors::InvalidShopError, + "shop must be a trusted Shopify domain (see ShopValidator::TRUSTED_SHOPIFY_DOMAINS), got: #{shop.inspect}" + end + + host + end + + private + + sig { params(myshopify_domain: T.nilable(String)).returns(T::Array[String]) } + def trusted_domains(myshopify_domain) + trusted = TRUSTED_SHOPIFY_DOMAINS.dup + if myshopify_domain && !myshopify_domain.to_s.empty? + trusted << myshopify_domain + trusted.uniq! + end + trusted + end + + sig do + params( + shop_domain: String, + myshopify_domain: T.nilable(String), + ).returns(T.nilable(Addressable::URI)) + end + def uri_from_shop_domain(shop_domain, myshopify_domain) + name = shop_domain.to_s.downcase.strip + return nil if name.empty? + return nil if name.include?("@") + + if myshopify_domain && !myshopify_domain.to_s.empty? && + !name.include?(myshopify_domain.to_s) && !name.include?(".") + name += ".#{myshopify_domain}" + end + + uri = Addressable::URI.parse(name) + if uri.scheme.nil? + name = "https://#{name}" + uri = Addressable::URI.parse(name) + end + + uri + rescue Addressable::URI::InvalidURIError + nil + end + + sig { params(uri: Addressable::URI).returns(T::Boolean) } + def unified_admin?(uri) + T.cast(uri.host, String).split(".").first == "admin" + end + + sig { params(uri: Addressable::URI).returns(String) } + def myshopify_domain_from_unified_admin(uri) + shop = uri.path.to_s.split("/").last + "#{shop}.myshopify.com" + end + end + end + end +end diff --git a/shopify_api.gemspec b/shopify_api.gemspec index 7001741af..aa33ec4dd 100644 --- a/shopify_api.gemspec +++ b/shopify_api.gemspec @@ -33,6 +33,7 @@ Gem::Specification.new do |s| s.required_ruby_version = ">= 3.2" s.add_runtime_dependency("activesupport") + s.add_runtime_dependency("addressable", "~> 2.7") s.add_runtime_dependency("concurrent-ruby") s.add_runtime_dependency("hash_diff") s.add_runtime_dependency("httparty") diff --git a/test/auth/client_credentials_test.rb b/test/auth/client_credentials_test.rb index 29e26f049..b5c6995d8 100644 --- a/test/auth/client_credentials_test.rb +++ b/test/auth/client_credentials_test.rb @@ -30,6 +30,12 @@ def test_client_credentials_context_not_setup end end + def test_client_credentials_rejects_non_shopify_domain + assert_raises(ShopifyAPI::Errors::InvalidShopError) do + ShopifyAPI::Auth::ClientCredentials.client_credentials(shop: "attacker.example") + end + end + def test_client_credentials_offline_token stub_request(:post, "https://#{@shop}/admin/oauth/access_token") .with(body: @client_credentials_request) diff --git a/test/auth/refresh_token_test.rb b/test/auth/refresh_token_test.rb index 9d5a7391d..6b1fd8310 100644 --- a/test/auth/refresh_token_test.rb +++ b/test/auth/refresh_token_test.rb @@ -29,6 +29,15 @@ def setup } end + def test_refresh_access_token_rejects_non_shopify_domain + assert_raises(ShopifyAPI::Errors::InvalidShopError) do + ShopifyAPI::Auth::RefreshToken.refresh_access_token( + shop: "attacker.example", + refresh_token: @refresh_token, + ) + end + end + def test_refresh_access_token_success stub_request(:post, "https://#{@shop}/admin/oauth/access_token") .with(body: @refresh_token_request) diff --git a/test/auth/token_exchange_test.rb b/test/auth/token_exchange_test.rb index c3989bf8b..239a3620b 100644 --- a/test/auth/token_exchange_test.rb +++ b/test/auth/token_exchange_test.rb @@ -86,7 +86,6 @@ def test_exchange_token_context_not_setup assert_raises(ShopifyAPI::Errors::ContextNotSetupError) do ShopifyAPI::Auth::TokenExchange.exchange_token( - shop: @shop, session_token: @session_token, requested_token_type: ShopifyAPI::Auth::TokenExchange::RequestedTokenType::OFFLINE_ACCESS_TOKEN, ) @@ -98,7 +97,6 @@ def test_exchange_token_private_app assert_raises(ShopifyAPI::Errors::UnsupportedOauthError) do ShopifyAPI::Auth::TokenExchange.exchange_token( - shop: @shop, session_token: @session_token, requested_token_type: ShopifyAPI::Auth::TokenExchange::RequestedTokenType::OFFLINE_ACCESS_TOKEN, ) @@ -110,7 +108,6 @@ def test_exchange_token_not_embedded_app assert_raises(ShopifyAPI::Errors::UnsupportedOauthError) do ShopifyAPI::Auth::TokenExchange.exchange_token( - shop: @shop, session_token: @session_token, requested_token_type: ShopifyAPI::Auth::TokenExchange::RequestedTokenType::OFFLINE_ACCESS_TOKEN, ) @@ -122,7 +119,6 @@ def test_exchange_token_invalid_session_token assert_raises(ShopifyAPI::Errors::InvalidJwtTokenError) do ShopifyAPI::Auth::TokenExchange.exchange_token( - shop: @shop, session_token: "invalid", requested_token_type: ShopifyAPI::Auth::TokenExchange::RequestedTokenType::OFFLINE_ACCESS_TOKEN, ) @@ -141,13 +137,46 @@ def test_exchange_token_rejected_session_token assert_raises(ShopifyAPI::Errors::InvalidJwtTokenError) do ShopifyAPI::Auth::TokenExchange.exchange_token( - shop: @shop, session_token: @session_token, requested_token_type: ShopifyAPI::Auth::TokenExchange::RequestedTokenType::OFFLINE_ACCESS_TOKEN, ) end end + def test_exchange_token_deprecates_shop_argument + modify_context(is_embedded: true, expiring_offline_access_tokens: false) + ShopifyAPI::Logger.expects(:deprecated).with( + regexp_matches(/`shop` parameter for `exchange_token`/), + "17.0.0", + ).once + + stub_request(:post, "https://#{@shop}/admin/oauth/access_token") + .with(body: @non_expiring_offline_token_exchange_request) + .to_return(body: @offline_token_response.to_json, headers: { content_type: "application/json" }) + + session = ShopifyAPI::Auth::TokenExchange.exchange_token( + shop: @shop, + session_token: @session_token, + requested_token_type: ShopifyAPI::Auth::TokenExchange::RequestedTokenType::OFFLINE_ACCESS_TOKEN, + ) + + assert_equal(@shop, session.shop) + end + + def test_exchange_token_without_shop_does_not_log_deprecation + modify_context(is_embedded: true, expiring_offline_access_tokens: false) + ShopifyAPI::Logger.expects(:deprecated).never + + stub_request(:post, "https://#{@shop}/admin/oauth/access_token") + .with(body: @non_expiring_offline_token_exchange_request) + .to_return(body: @offline_token_response.to_json, headers: { content_type: "application/json" }) + + ShopifyAPI::Auth::TokenExchange.exchange_token( + session_token: @session_token, + requested_token_type: ShopifyAPI::Auth::TokenExchange::RequestedTokenType::OFFLINE_ACCESS_TOKEN, + ) + end + def test_exchange_token_offline_token modify_context(is_embedded: true, expiring_offline_access_tokens: false) stub_request(:post, "https://#{@shop}/admin/oauth/access_token") @@ -166,7 +195,6 @@ def test_exchange_token_offline_token ) session = ShopifyAPI::Auth::TokenExchange.exchange_token( - shop: @shop, session_token: @session_token, requested_token_type: ShopifyAPI::Auth::TokenExchange::RequestedTokenType::OFFLINE_ACCESS_TOKEN, ) @@ -193,7 +221,6 @@ def test_exchange_token_expiring_offline_token session = Time.stub(:now, @stubbed_time_now) do ShopifyAPI::Auth::TokenExchange.exchange_token( - shop: @shop, session_token: @session_token, requested_token_type: ShopifyAPI::Auth::TokenExchange::RequestedTokenType::OFFLINE_ACCESS_TOKEN, ) @@ -220,7 +247,6 @@ def test_exchange_token_online_token session = Time.stub(:now, @stubbed_time_now) do ShopifyAPI::Auth::TokenExchange.exchange_token( - shop: @shop, session_token: @session_token, requested_token_type: ShopifyAPI::Auth::TokenExchange::RequestedTokenType::ONLINE_ACCESS_TOKEN, ) @@ -229,6 +255,15 @@ def test_exchange_token_online_token assert_equal(expected_session, session) end + def test_migrate_to_expiring_token_rejects_non_shopify_domain + assert_raises(ShopifyAPI::Errors::InvalidShopError) do + ShopifyAPI::Auth::TokenExchange.migrate_to_expiring_token( + shop: "attacker.example", + non_expiring_offline_token: "old-offline-token-123", + ) + end + end + def test_migrate_to_expiring_token_context_not_setup modify_context(api_key: "", api_secret_key: "", host: "") diff --git a/test/clients/base_rest_resource_test.rb b/test/clients/base_rest_resource_test.rb index fd5e83204..78a2e19c8 100644 --- a/test/clients/base_rest_resource_test.rb +++ b/test/clients/base_rest_resource_test.rb @@ -8,10 +8,11 @@ module ShopifyAPITest module Rest class BaseTest < Test::Unit::TestCase def setup + super + @session = ShopifyAPI::Auth::Session.new(shop: "test-shop.myshopify.com", access_token: SecureRandom.alphanumeric(10)) @prefix = "https://#{@session.shop}/admin/api/#{ShopifyAPI::Context.api_version}" - ShopifyAPI::Context.load_rest_resources(api_version: ShopifyAPI::Context.api_version) end def test_rest_disabled diff --git a/test/clients/graphql/storefront_test.rb b/test/clients/graphql/storefront_test.rb index 2df7d3a57..9f6c10c3d 100644 --- a/test/clients/graphql/storefront_test.rb +++ b/test/clients/graphql/storefront_test.rb @@ -32,6 +32,12 @@ def build_client end end + def test_rejects_non_shopify_domain + assert_raises(ShopifyAPI::Errors::InvalidShopError) do + ShopifyAPI::Clients::Graphql::Storefront.new("attacker.example", public_token: "token") + end + end + def test_can_query_using_private_token query = <<~QUERY { diff --git a/test/utils/shop_validator_test.rb b/test/utils/shop_validator_test.rb new file mode 100644 index 000000000..60c66a641 --- /dev/null +++ b/test/utils/shop_validator_test.rb @@ -0,0 +1,104 @@ +# typed: false +# frozen_string_literal: true + +require_relative "../test_helper" + +module ShopifyAPITest + module Utils + class ShopValidatorTest < Test::Unit::TestCase + def test_accepts_valid_myshopify_com_domain + assert_equal("test-shop.myshopify.com", ShopifyAPI::Utils::ShopValidator.sanitize!("test-shop.myshopify.com")) + end + + def test_accepts_valid_myshopify_io_domain + assert_equal("test-shop.myshopify.io", ShopifyAPI::Utils::ShopValidator.sanitize!("test-shop.myshopify.io")) + end + + def test_strips_https_scheme + assert_equal("test-shop.myshopify.com", ShopifyAPI::Utils::ShopValidator.sanitize!("https://test-shop.myshopify.com")) + end + + def test_strips_http_scheme + assert_equal("test-shop.myshopify.com", ShopifyAPI::Utils::ShopValidator.sanitize!("http://test-shop.myshopify.com")) + end + + def test_strips_trailing_slash + assert_equal("test-shop.myshopify.com", ShopifyAPI::Utils::ShopValidator.sanitize!("test-shop.myshopify.com/")) + end + + def test_normalizes_to_lowercase + assert_equal("test-shop.myshopify.com", ShopifyAPI::Utils::ShopValidator.sanitize!("Test-Shop.MyShopify.com")) + end + + def test_strips_whitespace + result = ShopifyAPI::Utils::ShopValidator.sanitize!(" test-shop.myshopify.com ") + assert_equal("test-shop.myshopify.com", result) + end + + def test_rejects_attacker_controlled_domain + assert_raises(ShopifyAPI::Errors::InvalidShopError) do + ShopifyAPI::Utils::ShopValidator.sanitize!("attacker.example") + end + end + + def test_rejects_empty_string + assert_raises(ShopifyAPI::Errors::InvalidShopError) do + ShopifyAPI::Utils::ShopValidator.sanitize!("") + end + end + + def test_rejects_non_shopify_domain + assert_raises(ShopifyAPI::Errors::InvalidShopError) do + ShopifyAPI::Utils::ShopValidator.sanitize!("evil.com") + end + end + + def test_rejects_shopify_suffix_as_subdomain_of_attacker + assert_raises(ShopifyAPI::Errors::InvalidShopError) do + ShopifyAPI::Utils::ShopValidator.sanitize!("myshopify.com.evil.com") + end + end + + def test_rejects_similar_looking_domain + assert_raises(ShopifyAPI::Errors::InvalidShopError) do + ShopifyAPI::Utils::ShopValidator.sanitize!("test-shop.notmyshopify.com") + end + end + + def test_rejects_path_that_suffix_matches_myshopify_host + assert_raises(ShopifyAPI::Errors::InvalidShopError) do + ShopifyAPI::Utils::ShopValidator.sanitize!("attacker.com/.myshopify.com") + end + end + + def test_rejects_userinfo_before_at_sign + assert_raises(ShopifyAPI::Errors::InvalidShopError) do + ShopifyAPI::Utils::ShopValidator.sanitize!("shop.myshopify.com@evil.com") + end + end + + def test_sanitize_shop_domain_returns_nil_for_invalid + assert_nil(ShopifyAPI::Utils::ShopValidator.sanitize_shop_domain("evil.com")) + assert_nil(ShopifyAPI::Utils::ShopValidator.sanitize_shop_domain("myshopify.com")) + end + + def test_unified_admin_store_url_maps_to_myshopify_host + assert_equal( + "cool-shop.myshopify.com", + ShopifyAPI::Utils::ShopValidator.sanitize!("https://admin.shopify.com/store/cool-shop"), + ) + assert_equal( + "cool-shop.myshopify.com", + ShopifyAPI::Utils::ShopValidator.sanitize_shop_domain("https://admin.shopify.com/store/cool-shop"), + ) + end + + def test_sanitize_shop_domain_with_custom_myshopify_domain + assert_equal( + "mystore.myshopify.com", + ShopifyAPI::Utils::ShopValidator.sanitize_shop_domain("mystore", myshopify_domain: "myshopify.com"), + ) + end + end + end +end