diff --git a/CHANGELOG.md b/CHANGELOG.md index 04a681101..bcab33ff4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,12 @@ Unreleased - ⚠️ [Breaking] Removes `ShopifyApp::JWTMiddleware` and `ShopifyApp::JWT` See [Upgrading](/docs/Upgrading.md) for more migration. [1960](https://github.com/Shopify/shopify_app/pull/1960) - ⚠️ [Breaking] Removed deprecated `CallbackController` methods. `perform_after_authenticate_job`, `install_webhooks`, and `perform_post_authenticate_jobs` have been removed. [#1961](https://github.com/Shopify/shopify_app/pull/1961) - ⚠️ [Breaking] Bumps minimum supported Ruby version to 3.1 [#1959](https://github.com/Shopify/shopify_app/pull/1959) +- Adds support for expiring offline access tokens with automatic refresh for `ShopSessionStorage`. Apps can now opt-in to expiring offline tokens via `ShopifyAPI::Context.setup(offline_access_token_expires: true)`, and `ShopSessionStorage` will automatically refresh expired tokens when using `with_shopify_session`. **See [migration guide](/docs/shopify_app/sessions.md#migrating-to-expiring-offline-access-tokens) for setup instructions.** [#2027](https://github.com/Shopify/shopify_app/pull/2027) + - `ShopSessionStorage` now automatically stores `access_scopes`, `expires_at`, `refresh_token`, and `refresh_token_expires_at` from auth sessions + - `UserSessionStorage` now automatically stores `access_scopes` and `expires_at` from auth sessions (refresh tokens not applicable for online/user tokens) + - Adds `refresh_token_if_expired!` public method to `ShopSessionStorage` for manual token refresh + - Adds `RefreshTokenExpiredError` exception raised when refresh token itself is expired + - Mark deprecation for `ShopSessionStorageWithScopes` and `UserSessionStorageWithScopes` in favor of `ShopSessionStorage` and `UserSessionStorage`. - Adds a `script_tag_manager` that will automatically create script tags when the app is installed. [1948](https://github.com/Shopify/shopify_app/pull/1948) - Handle invalid token when adding redirection headers [#1945](https://github.com/Shopify/shopify_app/pull/1945) - Handle invalid record error for concurrent token exchange calls [#1966](https://github.com/Shopify/shopify_app/pull/1966) diff --git a/README.md b/README.md index f00677c2f..ed3809542 100644 --- a/README.md +++ b/README.md @@ -189,7 +189,6 @@ You can find documentation on gem usage, concepts, mixins, installation, and mor * [Controller Concerns](/docs/shopify_app/controller-concerns.md) * [Generators](/docs/shopify_app/generators.md) * [Sessions](/docs/shopify_app/sessions.md) - * [Handling changes in access scopes](/docs/shopify_app/handling-access-scopes-changes.md) * [Testing](/docs/shopify_app/testing.md) * [Webhooks](/docs/shopify_app/webhooks.md) * [Content Security Policy](/docs/shopify_app/content-security-policy.md) @@ -243,8 +242,7 @@ ShopifyApp.configure do |config| config.embedded_app = true config.new_embedded_auth_strategy = true - # If your app is configured to use online sessions, you can enable session expiry date check so a new access token - # is fetched automatically when the session expires. + # You can enable session expiry date check so a new access token is fetched automatically when the session expires. # See expiry date check docs: https://github.com/Shopify/shopify_app/blob/main/docs/shopify_app/sessions.md#expiry-date config.check_session_expiry_date = true ... diff --git a/docs/Upgrading.md b/docs/Upgrading.md index 0bd0bf2c1..8c6e0b074 100644 --- a/docs/Upgrading.md +++ b/docs/Upgrading.md @@ -80,6 +80,42 @@ require 'shopify_app/jobs/webhooks_manager_job' - **sprockets-rails**: Now a required runtime dependency. Most Rails apps already include this, but if your app uses an alternative asset pipeline (e.g., Propshaft), you may need to add `sprockets-rails` to your Gemfile. +#### (v23.0.0) - ShopSessionStorageWithScopes and UserSessionStorageWithScopes are deprecated + +`ShopSessionStorageWithScopes` and `UserSessionStorageWithScopes` are now marked as deprecated and will be removed in v24.0.0 in favor of `ShopSessionStorage` and `UserSessionStorage`, which handle all session attributes automatically (including `access_scopes`, `expires_at`, `refresh_token`, and `refresh_token_expires_at` for shops). + +**Migration:** + +1. Update your Shop model to use `ShopSessionStorage`: +```ruby +# Before +class Shop < ActiveRecord::Base + include ShopifyApp::ShopSessionStorageWithScopes +end + +# After +class Shop < ActiveRecord::Base + include ShopifyApp::ShopSessionStorage +end +``` + +2. Update your User model to use `UserSessionStorage`: +```ruby +# Before +class User < ActiveRecord::Base + include ShopifyApp::UserSessionStorageWithScopes +end + +# After +class User < ActiveRecord::Base + include ShopifyApp::UserSessionStorage +end +``` + +3. **Optional:** You can now opt-in to using expiring offline access tokens with automatic refresh. See the [Sessions documentation](/docs/shopify_app/sessions.md#offline-access-tokens) for setup instructions. + +**Note:** If you had custom `access_scopes=` or `access_scopes` methods in your models, these are no longer needed. The base concerns now handle these attributes automatically. + #### (v23.0.0) - Deprecated methods in CallbackController The following methods from `ShopifyApp::CallbackController` have been deprecated in `v23.0.0` - `perform_after_authenticate_job` diff --git a/docs/shopify_app/sessions.md b/docs/shopify_app/sessions.md index b9390ae9d..1351b81ee 100644 --- a/docs/shopify_app/sessions.md +++ b/docs/shopify_app/sessions.md @@ -23,6 +23,7 @@ Sessions are used to make contextual API calls for either a shop (offline sessio - [Access scopes](#access-scopes) - [`ShopifyApp::ShopSessionStorageWithScopes`](#shopifyappshopsessionstoragewithscopes) - [`ShopifyApp::UserSessionStorageWithScopes`](#shopifyappusersessionstoragewithscopes) + - [Migrating to Expiring Offline Access Tokens](#migrating-to-expiring-offline-access-tokens) - [Migrating from shop-based to user-based token strategy](#migrating-from-shop-based-to-user-based-token-strategy) - [Migrating from `ShopifyApi::Auth::SessionStorage` to `ShopifyApp::SessionStorage`](#migrating-from-shopifyapiauthsessionstorage-to-shopifyappsessionstorage) @@ -129,11 +130,11 @@ These methods are already implemented as a part of the `User` and `Shop` models Simply include these concerns if you want to use the implementation, and overwrite methods for custom implementation - `Shop` storage - - [ShopSessionStorageWithScopes](https://github.com/Shopify/shopify_app/blob/main/lib/shopify_app/session/shop_session_storage_with_scopes.rb) + - [ShopSessionStorageWithScopes](https://github.com/Shopify/shopify_app/blob/main/lib/shopify_app/session/shop_session_storage_with_scopes.rb) (Deprecated in 23.0.0) - [ShopSessionStorage](https://github.com/Shopify/shopify_app/blob/main/lib/shopify_app/session/shop_session_storage.rb) - `User` storage - - [UserSessionStorageWithScopes](https://github.com/Shopify/shopify_app/blob/main/lib/shopify_app/session/user_session_storage_with_scopes.rb) + - [UserSessionStorageWithScopes](https://github.com/Shopify/shopify_app/blob/main/lib/shopify_app/session/user_session_storage_with_scopes.rb) (Deprecated in 23.0.0) - [UserSessionStorage](https://github.com/Shopify/shopify_app/blob/main/lib/shopify_app/session/user_session_storage.rb) ### Loading Sessions @@ -206,6 +207,18 @@ user.with_shopify_session do end ``` +**Automatic Token Refresh for Shop Sessions:** + +When using `Shop` models with [expiring offline access tokens](#migrating-to-expiring-offline-access-tokens) configured, `with_shopify_session` will automatically refresh expired tokens before executing the block. This ensures your API calls always use valid credentials without manual intervention. + +To disable automatic refresh, pass `auto_refresh: false`: + +```ruby +shop.with_shopify_session(auto_refresh: false) do + # Token will NOT be refreshed even if expired +end +``` + #### Re-fetching an access token when API returns Unauthorized When using `ShopifyApp::EnsureHasSession` and the `new_embedded_auth_strategy` configuration, any **unhandled** Unauthorized `ShopifyAPI::Errors::HttpResponseError` will cause the app to perform token exchange to fetch a new access token from Shopify and the action to be executed again. This will update and store the new access token to the current session instance. @@ -300,39 +313,153 @@ class MyController < ApplicationController end ``` -## Access scopes -If you want to customize how access scopes are stored for shops and users, you can implement the `access_scopes` getters and setters in the models that include `ShopifyApp::ShopSessionStorageWithScopes` and `ShopifyApp::UserSessionStorageWithScopes` as shown: +## Expiry date +When the configuration flag `check_session_expiry_date` is set to true, the session expiry date will be checked to trigger a re-auth and get a fresh token when it is expired. +This requires the `ShopifyAPI::Auth::Session` `expires` attribute to be stored. + +**Online access tokens (User sessions):** +- When the `User` model includes the `UserSessionStorage` concern, a DB migration can be generated with `rails generate shopify_app:user_model --skip` to add the `expires_at` attribute to the model +- Online access tokens cannot be refreshed, so when the token is expired, the user must go through the OAuth flow again to get a new token + +**Offline access tokens (Shop sessions):** +- Offline access tokens can optionally be configured to expire and support automatic refresh. See [Migrating to Expiring Offline Access Tokens](#migrating-to-expiring-offline-access-tokens) for detailed setup instructions + +## Migrating to Expiring Offline Access Tokens + +You can opt-in to expiring offline access tokens for enhanced security. When enabled, Shopify will issue offline access tokens with an expiration date and a refresh token. `ShopSessionStorage` will then automatically refresh expired tokens when using `with_shopify_session`. + +**1. Database Migration:** + +Run the shop model generator (use `--skip` to avoid regenerating the Shop model if it already exists): + +```bash +rails generate shopify_app:shop_model --skip +``` + +The generator will prompt you to create a migration that adds the `expires_at`, `refresh_token`, and `refresh_token_expires_at` columns. Alternatively, you can create the migration manually: + +```ruby +class AddShopAccessTokenExpiryColumns < ActiveRecord::Migration[7.0] + def change + add_column :shops, :expires_at, :datetime + add_column :shops, :refresh_token, :string + add_column :shops, :refresh_token_expires_at, :datetime + end +end +``` + +**2. Update Model Concern:** + +If your Shop model is using the deprecated `ShopSessionStorageWithScopes` concern, update it to use `ShopSessionStorage`: -### `ShopifyApp::ShopSessionStorageWithScopes` ```ruby +# app/models/shop.rb class Shop < ActiveRecord::Base - include ShopifyApp::ShopSessionStorageWithScopes + include ShopifyApp::ShopSessionStorage # Change from ShopSessionStorageWithScopes +end +``` - def access_scopes=(scopes) - # Store access scopes - end - def access_scopes - # Find access scopes - end +`ShopSessionStorage` now automatically handles `access_scopes`, `expires_at`, `refresh_token`, and `refresh_token_expires_at` - no additional concerns needed. + +**3. Configuration:** + +```ruby +# config/initializers/shopify_app.rb +ShopifyApp.configure do |config| + # ... other configuration + + # Enable automatic reauthentication when session is expired + config.check_session_expiry_date = true end + +# For ShopifyAPI Context - enable requesting expiring offline tokens +ShopifyAPI::Context.setup( + # ... other configuration + expiring_offline_access_tokens: true, # Opt-in to start requesting expiring offline tokens +) ``` -### `ShopifyApp::UserSessionStorageWithScopes` +**4. Refreshing Expired Tokens:** + +With the configuration enabled, expired tokens are automatically handled differently based on the flow: + +**For user-facing requests (OAuth/Token Exchange flow):** +When `check_session_expiry_date` is enabled, expired sessions trigger automatic re-authentication through the OAuth flow. This happens transparently when using controller concerns like `EnsureHasSession`. + +**For background jobs and non-user interactions:** +Tokens are automatically refreshed when using `with_shopify_session` from `ShopSessionStorage`: + ```ruby -class User < ActiveRecord::Base - include ShopifyApp::UserSessionStorageWithScopes +shop = Shop.find_by(shopify_domain: "example.myshopify.com") - def access_scopes=(scopes) - # Store access scopes - end - def access_scopes - # Find access scopes +# Automatic refresh (default behavior) +shop.with_shopify_session do + # If the token is expired, it will be automatically refreshed before making API calls +end + +# Disable automatic refresh if needed +shop.with_shopify_session(auto_refresh: false) do + # Token will NOT be refreshed even if expired +end + +# Manual refresh +begin + shop.refresh_token_if_expired! +rescue ShopifyApp::RefreshTokenExpiredError + # Handle case where refresh token itself has expired + # App needs to go through OAuth flow again +end +``` + +**Error Handling:** +- `ShopifyApp::RefreshTokenExpiredError` is raised when the refresh token itself is expired +- When this happens, the user must interact with the app to go through the OAuth flow again to get new tokens +- The refresh process uses database row-level locking to prevent race conditions from concurrent requests + +**5. Migrating Existing Shop Installations:** + +⚠️ **Important:** When you enable `offline_access_token_expires: true`, only **new shop installations** will automatically receive expiring tokens during the OAuth flow. Existing shop installations with non-expiring tokens will continue using their current tokens until manually migrated. + +To migrate existing shops to expiring tokens, use the `ShopifyAPI::Auth::TokenExchange.migrate_to_expiring_token` method. Here's an example background job to migrate all existing shops: + +```ruby +# app/jobs/migrate_shops_to_expiring_tokens_job.rb +class MigrateShopsToExpiringTokensJob < ActiveJob::Base + queue_as :default + + def perform + # Find shops that haven't been migrated yet (no refresh_token or expires_at) + shops_to_migrate = Shop.where(expires_at: nil, refresh_token: nil, refresh_token_expires_at: nil) + + shops_to_migrate.find_each do |shop| + begin + # Migrate to expiring token + new_session = ShopifyAPI::Auth::TokenExchange.migrate_to_expiring_token( + shop: shop.shopify_domain, + non_expiring_offline_token: shop.shopify_token + ) + + # Store the new session with expiring token and refresh token + Shop.store(new_session) + + Rails.logger.info("Successfully migrated #{shop.shopify_domain} to expiring token") + rescue ShopifyAPI::Errors::HttpResponseError => e + # Handle migration errors (e.g., shop uninstalled, network issues) + Rails.logger.error("Failed to migrate #{shop.shopify_domain}: #{e.message}") + rescue => e + Rails.logger.error("Unexpected error migrating #{shop.shopify_domain}: #{e.message}") + end + end end end ``` -## Expiry date -When the configuration flag `check_session_expiry_date` is set to true, the user session expiry date will be checked to trigger a re-auth and get a fresh user token when it is expired. This requires the `ShopifyAPI::Auth::Session` `expires` attribute to be stored. When the `User` model includes the `UserSessionStorageWithScopes` concern, a DB migration can be generated with `rails generate shopify_app:user_model --skip` to add the `expires_at` attribute to the model. +**Migration notes:** +- This is a **one-time, irreversible operation** per shop +- The shop must have the app installed and have a valid access token +- After migration, the shop's offline token will have an expiration date and a refresh token + +**Note:** If you choose not to enable expiring offline tokens, the `expires_at`, `refresh_token`, and `refresh_token_expires_at` columns will remain `NULL` and no automatic refresh will occur. Refresh tokens are only available for offline (shop) access tokens. Online (user) access tokens do not support refresh and must be re-authorized through OAuth when expired. ## Migrating from shop-based to user-based token strategy diff --git a/lib/generators/shopify_app/shop_model/shop_model_generator.rb b/lib/generators/shopify_app/shop_model/shop_model_generator.rb index f3caacf31..7f5693776 100644 --- a/lib/generators/shopify_app/shop_model/shop_model_generator.rb +++ b/lib/generators/shopify_app/shop_model/shop_model_generator.rb @@ -40,6 +40,24 @@ def create_shop_with_access_scopes_migration end end + def create_shop_with_token_refresh_migration + token_refresh_prompt = <<~PROMPT + To support expiring offline access token with refresh, your Shop model needs to store \ + token expiration dates and refresh tokens. + + The following migration will add `expires_at`, `refresh_token`, and \ + `refresh_token_expires_at` columns to the Shop model. \ + Do you want to include this migration? [y/n] + PROMPT + + if new_shopify_cli_app? || Rails.env.test? || yes?(token_refresh_prompt) + migration_template( + "db/migrate/add_shop_access_token_expiry_columns.erb", + "db/migrate/add_shop_access_token_expiry_columns.rb", + ) + end + end + def update_shopify_app_initializer gsub_file("config/initializers/shopify_app.rb", "ShopifyApp::InMemoryShopSessionStore", "Shop") end diff --git a/lib/generators/shopify_app/shop_model/templates/db/migrate/add_shop_access_token_expiry_columns.erb b/lib/generators/shopify_app/shop_model/templates/db/migrate/add_shop_access_token_expiry_columns.erb new file mode 100644 index 000000000..25d8b1bbb --- /dev/null +++ b/lib/generators/shopify_app/shop_model/templates/db/migrate/add_shop_access_token_expiry_columns.erb @@ -0,0 +1,7 @@ +class AddShopAccessTokenExpiryColumns < ActiveRecord::Migration[<%= rails_migration_version %>] + def change + add_column :shops, :expires_at, :datetime + add_column :shops, :refresh_token, :string + add_column :shops, :refresh_token_expires_at, :datetime + end +end diff --git a/lib/generators/shopify_app/shop_model/templates/shop.rb b/lib/generators/shopify_app/shop_model/templates/shop.rb index 4a23866b8..59ba7692f 100644 --- a/lib/generators/shopify_app/shop_model/templates/shop.rb +++ b/lib/generators/shopify_app/shop_model/templates/shop.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true class Shop < ActiveRecord::Base - include ShopifyApp::ShopSessionStorageWithScopes + include ShopifyApp::ShopSessionStorage def api_version ShopifyApp.configuration.api_version diff --git a/lib/generators/shopify_app/user_model/templates/user.rb b/lib/generators/shopify_app/user_model/templates/user.rb index 2ed254a41..f847d9983 100644 --- a/lib/generators/shopify_app/user_model/templates/user.rb +++ b/lib/generators/shopify_app/user_model/templates/user.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true class User < ActiveRecord::Base - include ShopifyApp::UserSessionStorageWithScopes + include ShopifyApp::UserSessionStorage def api_version ShopifyApp.configuration.api_version diff --git a/lib/shopify_app/errors.rb b/lib/shopify_app/errors.rb index 4f8068fc5..7669e983a 100644 --- a/lib/shopify_app/errors.rb +++ b/lib/shopify_app/errors.rb @@ -31,4 +31,6 @@ class MissingWebhookJobError < StandardError; end class ShopifyDomainNotFound < StandardError; end class ShopifyHostNotFound < StandardError; end + + class RefreshTokenExpiredError < StandardError; end end diff --git a/lib/shopify_app/session/shop_session_storage.rb b/lib/shopify_app/session/shop_session_storage.rb index f3cc08171..66541c7c0 100644 --- a/lib/shopify_app/session/shop_session_storage.rb +++ b/lib/shopify_app/session/shop_session_storage.rb @@ -9,10 +9,46 @@ module ShopSessionStorage validates :shopify_domain, presence: true, uniqueness: { case_sensitive: false } end + def with_shopify_session(auto_refresh: true, &block) + refresh_token_if_expired! if auto_refresh + super(&block) + end + + def refresh_token_if_expired! + return unless should_refresh? + raise RefreshTokenExpiredError if refresh_token_expired? + + # Acquire row lock to prevent concurrent refreshes + with_lock do + reload + # Check again after lock - token might have been refreshed by another process + return unless should_refresh? + + perform_token_refresh! + end + end + class_methods do def store(auth_session, *_args) shop = find_or_initialize_by(shopify_domain: auth_session.shop) shop.shopify_token = auth_session.access_token + + if shop.has_attribute?(:access_scopes) + shop.access_scopes = auth_session.scope.to_s + end + + if shop.has_attribute?(:expires_at) + shop.expires_at = auth_session.expires + end + + if shop.has_attribute?(:refresh_token) + shop.refresh_token = auth_session.refresh_token + end + + if shop.has_attribute?(:refresh_token_expires_at) + shop.refresh_token_expires_at = auth_session.refresh_token_expires + end + shop.save! shop.id end @@ -36,11 +72,57 @@ def destroy_by_shopify_domain(domain) def construct_session(shop) return unless shop - ShopifyAPI::Auth::Session.new( + session_attrs = { shop: shop.shopify_domain, access_token: shop.shopify_token, - ) + } + + if shop.has_attribute?(:access_scopes) + session_attrs[:scope] = shop.access_scopes + end + + if shop.has_attribute?(:expires_at) + session_attrs[:expires] = shop.expires_at + end + + if shop.has_attribute?(:refresh_token) + session_attrs[:refresh_token] = shop.refresh_token + end + + if shop.has_attribute?(:refresh_token_expires_at) + session_attrs[:refresh_token_expires] = shop.refresh_token_expires_at + end + + ShopifyAPI::Auth::Session.new(**session_attrs) end end + + def perform_token_refresh! + new_session = ShopifyAPI::Auth::RefreshToken.refresh_access_token( + shop: shopify_domain, + refresh_token: refresh_token, + ) + + update!( + shopify_token: new_session.access_token, + expires_at: new_session.expires, + refresh_token: new_session.refresh_token, + refresh_token_expires_at: new_session.refresh_token_expires, + ) + end + + def should_refresh? + return false unless has_attribute?(:expires_at) && expires_at.present? + return false unless has_attribute?(:refresh_token) && refresh_token.present? + return false unless has_attribute?(:refresh_token_expires_at) && refresh_token_expires_at.present? + + expires_at <= Time.now + end + + def refresh_token_expired? + return false unless has_attribute?(:refresh_token_expires_at) && refresh_token_expires_at.present? + + refresh_token_expires_at <= Time.now + end end end diff --git a/lib/shopify_app/session/shop_session_storage_with_scopes.rb b/lib/shopify_app/session/shop_session_storage_with_scopes.rb index 24458e421..57031f1f5 100644 --- a/lib/shopify_app/session/shop_session_storage_with_scopes.rb +++ b/lib/shopify_app/session/shop_session_storage_with_scopes.rb @@ -6,6 +6,12 @@ module ShopSessionStorageWithScopes include ::ShopifyApp::SessionStorage included do + ShopifyApp::Logger.deprecated( + "ShopSessionStorageWithScopes is deprecated and will be removed in v24.0.0. " \ + "Use ShopSessionStorage instead, which now handles access_scopes, expires_at, " \ + "refresh_token, and refresh_token_expires_at automatically.", + "23.0.0", + ) validates :shopify_domain, presence: true, uniqueness: { case_sensitive: false } end diff --git a/lib/shopify_app/session/user_session_storage.rb b/lib/shopify_app/session/user_session_storage.rb index 1cba9b2bf..3fc32c89c 100644 --- a/lib/shopify_app/session/user_session_storage.rb +++ b/lib/shopify_app/session/user_session_storage.rb @@ -14,6 +14,15 @@ def store(auth_session, user) user = find_or_initialize_by(shopify_user_id: user.id) user.shopify_token = auth_session.access_token user.shopify_domain = auth_session.shop + + if user.has_attribute?(:access_scopes) + user.access_scopes = auth_session.scope.to_s + end + + if user.has_attribute?(:expires_at) + user.expires_at = auth_session.expires + end + user.save! user.id end @@ -48,11 +57,21 @@ def construct_session(user) collaborator: false, ) - ShopifyAPI::Auth::Session.new( + session_attrs = { shop: user.shopify_domain, access_token: user.shopify_token, associated_user: associated_user, - ) + } + + if user.has_attribute?(:access_scopes) + session_attrs[:scope] = user.access_scopes + end + + if user.has_attribute?(:expires_at) + session_attrs[:expires] = user.expires_at + end + + ShopifyAPI::Auth::Session.new(**session_attrs) end end end diff --git a/lib/shopify_app/session/user_session_storage_with_scopes.rb b/lib/shopify_app/session/user_session_storage_with_scopes.rb index cf19a166d..c7f804000 100644 --- a/lib/shopify_app/session/user_session_storage_with_scopes.rb +++ b/lib/shopify_app/session/user_session_storage_with_scopes.rb @@ -6,6 +6,12 @@ module UserSessionStorageWithScopes include ::ShopifyApp::SessionStorage included do + ShopifyApp::Logger.deprecated( + "UserSessionStorageWithScopes is deprecated and will be removed in v24.0.0. " \ + "Use UserSessionStorage instead, which now handles access_scopes and expires_at automatically.", + "23.0.0", + ) + validates :shopify_domain, presence: true end diff --git a/test/generators/shop_model_generator_test.rb b/test/generators/shop_model_generator_test.rb index d53ca9570..f317ff77d 100644 --- a/test/generators/shop_model_generator_test.rb +++ b/test/generators/shop_model_generator_test.rb @@ -16,7 +16,7 @@ class ShopModelGeneratorTest < Rails::Generators::TestCase run_generator assert_file "app/models/shop.rb" do |shop| assert_match "class Shop < ActiveRecord::Base", shop - assert_match "include ShopifyApp::ShopSessionStorageWithScopes", shop + assert_match "include ShopifyApp::ShopSessionStorage", shop assert_match(/def api_version\n\s*ShopifyApp\.configuration\.api_version\n\s*end/, shop) end end @@ -46,6 +46,28 @@ class ShopModelGeneratorTest < Rails::Generators::TestCase end end + test "create shop with access token expiry columns migration in a test environment" do + run_generator + assert_migration "db/migrate/add_shop_access_token_expiry_columns.rb" do |migration| + assert_match "add_column :shops, :expires_at, :datetime", migration + assert_match "add_column :shops, :refresh_token, :string", migration + assert_match "add_column :shops, :refresh_token_expires_at, :datetime", migration + end + end + + test "create shop with access token expiry columns migration with --new-shopify-cli-app flag provided" do + Rails.env = "mock_environment" + + run_generator ["--new-shopify-cli-app"] + Rails.env = "test" # Change this back for subsequent tests + + assert_migration "db/migrate/add_shop_access_token_expiry_columns.rb" do |migration| + assert_match "add_column :shops, :expires_at, :datetime", migration + assert_match "add_column :shops, :refresh_token, :string", migration + assert_match "add_column :shops, :refresh_token_expires_at, :datetime", migration + end + end + test "updates the shopify_app initializer" do run_generator assert_file "config/initializers/shopify_app.rb" do |file| diff --git a/test/generators/user_model_generator_test.rb b/test/generators/user_model_generator_test.rb index ca76b8415..8363006f0 100644 --- a/test/generators/user_model_generator_test.rb +++ b/test/generators/user_model_generator_test.rb @@ -16,7 +16,7 @@ class UserModelGeneratorTest < Rails::Generators::TestCase run_generator assert_file "app/models/user.rb" do |user| assert_match "class User < ActiveRecord::Base", user - assert_match "include ShopifyApp::UserSessionStorageWithScopes", user + assert_match "include ShopifyApp::UserSessionStorage", user assert_match(/def api_version\n\s*ShopifyApp\.configuration\.api_version\n\s*end/, user) end end diff --git a/test/shopify_app/session/shop_session_storage_test.rb b/test/shopify_app/session/shop_session_storage_test.rb index 93b7fe1ad..57ad0d4c3 100644 --- a/test/shopify_app/session/shop_session_storage_test.rb +++ b/test/shopify_app/session/shop_session_storage_test.rb @@ -76,5 +76,472 @@ class ShopSessionStorageTest < ActiveSupport::TestCase refute ShopMockSessionStore.retrieve_by_shopify_domain(shop_domain) end + + test ".store saves access_scopes when column exists" do + mock_shop_instance = MockShopInstance.new( + id: 12345, + scopes: nil, + available_attributes: [:id, :shopify_domain, :shopify_token, :access_scopes], + ) + mock_shop_instance.stubs(:save!).returns(true) + + ShopMockSessionStore.stubs(:find_or_initialize_by).returns(mock_shop_instance) + + mock_auth_hash = mock + mock_auth_hash.stubs(:shop).returns(TEST_SHOPIFY_DOMAIN) + mock_auth_hash.stubs(:access_token).returns("a-new-token!") + mock_auth_hash.stubs(:scope).returns(ShopifyAPI::Auth::AuthScopes.new("read_products,write_orders")) + + ShopMockSessionStore.store(mock_auth_hash) + + assert_equal "read_products,write_orders", mock_shop_instance.access_scopes + end + + test ".store does not save access_scopes when column does not exist" do + mock_shop_instance = MockShopInstance.new( + id: 12345, + scopes: "old_scopes", + available_attributes: [:id, :shopify_domain, :shopify_token], + ) + mock_shop_instance.stubs(:save!).returns(true) + + ShopMockSessionStore.stubs(:find_or_initialize_by).returns(mock_shop_instance) + + mock_auth_hash = mock + mock_auth_hash.stubs(:shop).returns(TEST_SHOPIFY_DOMAIN) + mock_auth_hash.stubs(:access_token).returns("a-new-token!") + mock_auth_hash.stubs(:scope).returns(ShopifyAPI::Auth::AuthScopes.new("read_products,write_orders")) + + ShopMockSessionStore.store(mock_auth_hash) + + # access_scopes should remain unchanged since column doesn't exist + assert_equal "old_scopes", mock_shop_instance.access_scopes + end + + test ".store saves expires_at when column exists" do + expiry_time = Time.now + 1.day + mock_shop_instance = MockShopInstance.new( + id: 12345, + expires_at: nil, + available_attributes: [:id, :shopify_domain, :shopify_token, :expires_at], + ) + mock_shop_instance.stubs(:save!).returns(true) + + ShopMockSessionStore.stubs(:find_or_initialize_by).returns(mock_shop_instance) + + mock_auth_hash = mock + mock_auth_hash.stubs(:shop).returns(TEST_SHOPIFY_DOMAIN) + mock_auth_hash.stubs(:access_token).returns("a-new-token!") + mock_auth_hash.stubs(:expires).returns(expiry_time) + + ShopMockSessionStore.store(mock_auth_hash) + + assert_equal expiry_time, mock_shop_instance.expires_at + end + + test ".store does not save expires_at when column does not exist" do + old_expiry = Time.now + 2.days + mock_shop_instance = MockShopInstance.new( + id: 12345, + expires_at: old_expiry, + available_attributes: [:id, :shopify_domain, :shopify_token], + ) + mock_shop_instance.stubs(:save!).returns(true) + + ShopMockSessionStore.stubs(:find_or_initialize_by).returns(mock_shop_instance) + + mock_auth_hash = mock + mock_auth_hash.stubs(:shop).returns(TEST_SHOPIFY_DOMAIN) + mock_auth_hash.stubs(:access_token).returns("a-new-token!") + mock_auth_hash.stubs(:expires).returns(Time.now + 1.day) + + ShopMockSessionStore.store(mock_auth_hash) + + # expires_at should remain unchanged since column doesn't exist + assert_equal old_expiry, mock_shop_instance.expires_at + end + + test ".store saves refresh_token when column exists" do + mock_shop_instance = MockShopInstance.new( + id: 12345, + refresh_token: nil, + available_attributes: [:id, :shopify_domain, :shopify_token, :refresh_token], + ) + mock_shop_instance.stubs(:save!).returns(true) + + ShopMockSessionStore.stubs(:find_or_initialize_by).returns(mock_shop_instance) + + mock_auth_hash = mock + mock_auth_hash.stubs(:shop).returns(TEST_SHOPIFY_DOMAIN) + mock_auth_hash.stubs(:access_token).returns("a-new-token!") + mock_auth_hash.stubs(:refresh_token).returns("new-refresh-token") + + ShopMockSessionStore.store(mock_auth_hash) + + assert_equal "new-refresh-token", mock_shop_instance.refresh_token + end + + test ".store does not save refresh_token when column does not exist" do + mock_shop_instance = MockShopInstance.new( + id: 12345, + refresh_token: "old-refresh-token", + available_attributes: [:id, :shopify_domain, :shopify_token], + ) + mock_shop_instance.stubs(:save!).returns(true) + + ShopMockSessionStore.stubs(:find_or_initialize_by).returns(mock_shop_instance) + + mock_auth_hash = mock + mock_auth_hash.stubs(:shop).returns(TEST_SHOPIFY_DOMAIN) + mock_auth_hash.stubs(:access_token).returns("a-new-token!") + mock_auth_hash.stubs(:refresh_token).returns("new-refresh-token") + + ShopMockSessionStore.store(mock_auth_hash) + + # refresh_token should remain unchanged since column doesn't exist + assert_equal "old-refresh-token", mock_shop_instance.refresh_token + end + + test ".store saves refresh_token_expires_at when column exists" do + refresh_expiry_time = Time.now + 7.days + mock_shop_instance = MockShopInstance.new( + id: 12345, + refresh_token_expires_at: nil, + available_attributes: [:id, :shopify_domain, :shopify_token, :refresh_token_expires_at], + ) + mock_shop_instance.stubs(:save!).returns(true) + + ShopMockSessionStore.stubs(:find_or_initialize_by).returns(mock_shop_instance) + + mock_auth_hash = mock + mock_auth_hash.stubs(:shop).returns(TEST_SHOPIFY_DOMAIN) + mock_auth_hash.stubs(:access_token).returns("a-new-token!") + mock_auth_hash.stubs(:refresh_token_expires).returns(refresh_expiry_time) + + ShopMockSessionStore.store(mock_auth_hash) + + assert_equal refresh_expiry_time, mock_shop_instance.refresh_token_expires_at + end + + test ".store does not save refresh_token_expires_at when column does not exist" do + old_refresh_expiry = Time.now + 14.days + mock_shop_instance = MockShopInstance.new( + id: 12345, + refresh_token_expires_at: old_refresh_expiry, + available_attributes: [:id, :shopify_domain, :shopify_token], + ) + mock_shop_instance.stubs(:save!).returns(true) + + ShopMockSessionStore.stubs(:find_or_initialize_by).returns(mock_shop_instance) + + mock_auth_hash = mock + mock_auth_hash.stubs(:shop).returns(TEST_SHOPIFY_DOMAIN) + mock_auth_hash.stubs(:access_token).returns("a-new-token!") + mock_auth_hash.stubs(:refresh_token_expires).returns(Time.now + 7.days) + + ShopMockSessionStore.store(mock_auth_hash) + + # refresh_token_expires_at should remain unchanged since column doesn't exist + assert_equal old_refresh_expiry, mock_shop_instance.refresh_token_expires_at + end + + test ".retrieve constructs session with all optional attributes when columns exist" do + expiry_time = Time.now + 1.day + refresh_expiry_time = Time.now + 7.days + + mock_shop_instance = MockShopInstance.new( + shopify_domain: TEST_SHOPIFY_DOMAIN, + shopify_token: TEST_SHOPIFY_TOKEN, + scopes: "read_products,write_orders", + expires_at: expiry_time, + refresh_token: "refresh-token-value", + refresh_token_expires_at: refresh_expiry_time, + available_attributes: [ + :shopify_domain, + :shopify_token, + :access_scopes, + :expires_at, + :refresh_token, + :refresh_token_expires_at, + ], + ) + + ShopMockSessionStore.stubs(:find_by).with(id: 1).returns(mock_shop_instance) + + session = ShopMockSessionStore.retrieve(1) + + assert_equal TEST_SHOPIFY_DOMAIN, session.shop + assert_equal TEST_SHOPIFY_TOKEN, session.access_token + assert_equal "read_products,write_orders", session.scope.to_s + assert_equal expiry_time, session.expires + assert_equal "refresh-token-value", session.refresh_token + assert_equal refresh_expiry_time, session.refresh_token_expires + end + + test ".retrieve constructs session without optional attributes when columns do not exist" do + mock_shop_instance = MockShopInstance.new( + shopify_domain: TEST_SHOPIFY_DOMAIN, + shopify_token: TEST_SHOPIFY_TOKEN, + available_attributes: [:shopify_domain, :shopify_token], + scopes: "old_scopes", + expires_at: Time.now + 2.days, + refresh_token: "old-refresh-token", + refresh_token_expires_at: Time.now + 14.days, + ) + + ShopMockSessionStore.stubs(:find_by).with(id: 1).returns(mock_shop_instance) + + session = ShopMockSessionStore.retrieve(1) + + assert_equal TEST_SHOPIFY_DOMAIN, session.shop + assert_equal TEST_SHOPIFY_TOKEN, session.access_token + # Optional attributes should not be present + assert_empty session.scope.to_a + assert_nil session.expires + assert_nil session.refresh_token + assert_nil session.refresh_token_expires + end + + test "#refresh_token_if_expired! does nothing when token is not expired" do + shop = MockShopInstance.new( + shopify_domain: TEST_SHOPIFY_DOMAIN, + shopify_token: TEST_SHOPIFY_TOKEN, + expires_at: 1.day.from_now, + refresh_token: "refresh-token", + refresh_token_expires_at: 30.days.from_now, + available_attributes: [:shopify_domain, :shopify_token, :expires_at, :refresh_token, :refresh_token_expires_at], + ) + + ShopifyAPI::Auth::RefreshToken.expects(:refresh_access_token).never + shop.expects(:update!).never + + shop.refresh_token_if_expired! + + # Token should remain unchanged + assert_equal TEST_SHOPIFY_TOKEN, shop.shopify_token + end + + test "#refresh_token_if_expired! refreshes when token is expired" do + expired_time = 1.hour.ago + new_expiry = 1.day.from_now + new_refresh_token_expiry = 30.days.from_now + + shop = MockShopInstance.new( + shopify_domain: TEST_SHOPIFY_DOMAIN, + shopify_token: "old-token", + expires_at: expired_time, + refresh_token: "refresh-token", + refresh_token_expires_at: 30.days.from_now, + available_attributes: [:shopify_domain, :shopify_token, :expires_at, :refresh_token, :refresh_token_expires_at], + ) + + # Mock the refresh response + new_session = mock + new_session.stubs(:access_token).returns("new-token") + new_session.stubs(:expires).returns(new_expiry) + new_session.stubs(:refresh_token).returns("new-refresh-token") + new_session.stubs(:refresh_token_expires).returns(new_refresh_token_expiry) + + ShopifyAPI::Auth::RefreshToken.expects(:refresh_access_token) + .with(shop: TEST_SHOPIFY_DOMAIN, refresh_token: "refresh-token") + .returns(new_session) + + shop.refresh_token_if_expired! + + # Verify the token was updated + assert_equal "new-token", shop.shopify_token + assert_equal new_expiry, shop.expires_at + assert_equal "new-refresh-token", shop.refresh_token + assert_equal new_refresh_token_expiry, shop.refresh_token_expires_at + end + + test "#refresh_token_if_expired! raises error when refresh token is expired" do + shop = MockShopInstance.new( + shopify_domain: TEST_SHOPIFY_DOMAIN, + shopify_token: "old-token", + expires_at: 1.hour.ago, + refresh_token: "refresh-token", + refresh_token_expires_at: 1.hour.ago, + available_attributes: [:shopify_domain, :shopify_token, :expires_at, :refresh_token, :refresh_token_expires_at], + ) + + ShopifyAPI::Auth::RefreshToken.expects(:refresh_access_token).never + + assert_raises(ShopifyApp::RefreshTokenExpiredError) do + shop.refresh_token_if_expired! + end + end + + test "#refresh_token_if_expired! does nothing when refresh_token column doesn't exist" do + shop = MockShopInstance.new( + shopify_domain: TEST_SHOPIFY_DOMAIN, + shopify_token: TEST_SHOPIFY_TOKEN, + expires_at: 1.hour.ago, + refresh_token_expires_at: 30.days.from_now, + available_attributes: [:shopify_domain, :shopify_token, :expires_at, :refresh_token_expires_at], + ) + + ShopifyAPI::Auth::RefreshToken.expects(:refresh_access_token).never + shop.expects(:update!).never + + shop.refresh_token_if_expired! + + assert_equal TEST_SHOPIFY_TOKEN, shop.shopify_token + end + + test "#refresh_token_if_expired! does nothing when refresh_token is empty" do + shop = MockShopInstance.new( + shopify_domain: TEST_SHOPIFY_DOMAIN, + shopify_token: TEST_SHOPIFY_TOKEN, + expires_at: 1.hour.ago, + refresh_token: "", + refresh_token_expires_at: 30.days.from_now, + available_attributes: [:shopify_domain, :shopify_token, :expires_at, :refresh_token, :refresh_token_expires_at], + ) + + ShopifyAPI::Auth::RefreshToken.expects(:refresh_access_token).never + shop.expects(:update!).never + + shop.refresh_token_if_expired! + + assert_equal TEST_SHOPIFY_TOKEN, shop.shopify_token + end + + test "#refresh_token_if_expired! does nothing when expires_at column doesn't exist" do + shop = MockShopInstance.new( + shopify_domain: TEST_SHOPIFY_DOMAIN, + shopify_token: TEST_SHOPIFY_TOKEN, + refresh_token: "refresh-token", + refresh_token_expires_at: 30.days.from_now, + available_attributes: [:shopify_domain, :shopify_token, :refresh_token, :refresh_token_expires_at], + ) + + ShopifyAPI::Auth::RefreshToken.expects(:refresh_access_token).never + shop.expects(:update!).never + + shop.refresh_token_if_expired! + + assert_equal TEST_SHOPIFY_TOKEN, shop.shopify_token + end + + test "#refresh_token_if_expired! does nothing when expires_at is nil" do + shop = MockShopInstance.new( + shopify_domain: TEST_SHOPIFY_DOMAIN, + shopify_token: TEST_SHOPIFY_TOKEN, + expires_at: nil, + refresh_token: "refresh-token", + refresh_token_expires_at: 30.days.from_now, + available_attributes: [:shopify_domain, :shopify_token, :expires_at, :refresh_token, :refresh_token_expires_at], + ) + + ShopifyAPI::Auth::RefreshToken.expects(:refresh_access_token).never + shop.expects(:update!).never + + shop.refresh_token_if_expired! + + assert_equal TEST_SHOPIFY_TOKEN, shop.shopify_token + end + + test "#refresh_token_if_expired! does nothing when refresh_token_expires_at column doesn't exist" do + shop = MockShopInstance.new( + shopify_domain: TEST_SHOPIFY_DOMAIN, + shopify_token: TEST_SHOPIFY_TOKEN, + expires_at: 1.hour.ago, + refresh_token: "refresh-token", + available_attributes: [:shopify_domain, :shopify_token, :expires_at, :refresh_token], + ) + + ShopifyAPI::Auth::RefreshToken.expects(:refresh_access_token).never + shop.expects(:update!).never + + shop.refresh_token_if_expired! + + assert_equal TEST_SHOPIFY_TOKEN, shop.shopify_token + end + + test "#refresh_token_if_expired! does nothing when refresh_token_expires_at is nil" do + shop = MockShopInstance.new( + shopify_domain: TEST_SHOPIFY_DOMAIN, + shopify_token: TEST_SHOPIFY_TOKEN, + expires_at: 1.hour.ago, + refresh_token: "refresh-token", + refresh_token_expires_at: nil, + available_attributes: [:shopify_domain, :shopify_token, :expires_at, :refresh_token, :refresh_token_expires_at], + ) + + ShopifyAPI::Auth::RefreshToken.expects(:refresh_access_token).never + shop.expects(:update!).never + + shop.refresh_token_if_expired! + + assert_equal TEST_SHOPIFY_TOKEN, shop.shopify_token + end + + test "#refresh_token_if_expired! handles race condition with double-check" do + expired_time = 1.hour.ago + refreshed_time = 1.day.from_now + + shop = MockShopInstance.new( + shopify_domain: TEST_SHOPIFY_DOMAIN, + shopify_token: "old-token", + expires_at: expired_time, + refresh_token: "refresh-token", + refresh_token_expires_at: 30.days.from_now, + available_attributes: [:shopify_domain, :shopify_token, :expires_at, :refresh_token, :refresh_token_expires_at], + ) + + # Simulate another process already refreshed the token + shop.expects(:reload).once.with do + shop.expires_at = refreshed_time + shop.shopify_token = "already-refreshed-token" + true + end.returns(shop) + ShopifyAPI::Auth::RefreshToken.expects(:refresh_access_token).never + + shop.refresh_token_if_expired! + + assert_equal "already-refreshed-token", shop.shopify_token + end + + test "#with_shopify_session calls refresh_token_if_expired! by default" do + shop = MockShopInstance.new( + shopify_domain: TEST_SHOPIFY_DOMAIN, + shopify_token: TEST_SHOPIFY_TOKEN, + available_attributes: [:shopify_domain, :shopify_token], + ) + + shop.expects(:refresh_token_if_expired!).once + + block_executed = false + shop.with_shopify_session do + block_executed = true + end + + assert block_executed, "Block should have been executed" + end + + test "#with_shopify_session skips refresh when auto_refresh is false" do + expired_time = 1.hour.ago + + shop = MockShopInstance.new( + shopify_domain: TEST_SHOPIFY_DOMAIN, + shopify_token: "old-token", + expires_at: expired_time, + refresh_token: "refresh-token", + available_attributes: [:shopify_domain, :shopify_token, :expires_at, :refresh_token], + ) + + # Should NOT refresh even though token is expired + shop.expects(:refresh_token_if_expired!).never + ShopifyAPI::Auth::RefreshToken.expects(:refresh_access_token).never + + block_executed = false + + shop.with_shopify_session(auto_refresh: false) do + block_executed = true + end + + assert block_executed, "Block should have been executed" + end end end diff --git a/test/shopify_app/session/user_session_storage_test.rb b/test/shopify_app/session/user_session_storage_test.rb index 67364a1e3..3e784a25f 100644 --- a/test/shopify_app/session/user_session_storage_test.rb +++ b/test/shopify_app/session/user_session_storage_test.rb @@ -84,6 +84,128 @@ class UserSessionStorageTest < ActiveSupport::TestCase refute UserMockSessionStore.retrieve_by_shopify_user_id(user_id) end + test ".store saves access_scopes when column exists" do + mock_user_instance = MockUserInstance.new( + id: 12345, + shopify_user_id: TEST_SHOPIFY_USER_ID, + scopes: nil, + available_attributes: [:id, :shopify_user_id, :shopify_domain, :shopify_token, :access_scopes], + ) + mock_user_instance.stubs(:save!).returns(true) + + UserMockSessionStore.stubs(:find_or_initialize_by).returns(mock_user_instance) + + UserMockSessionStore.store( + mock_session(shop: TEST_SHOPIFY_DOMAIN, scope: "read_products,write_orders"), + mock_associated_user, + ) + + assert_equal "read_products,write_orders", mock_user_instance.access_scopes + end + + test ".store does not save access_scopes when column does not exist" do + mock_user_instance = MockUserInstance.new( + id: 12345, + shopify_user_id: TEST_SHOPIFY_USER_ID, + scopes: "old_scopes", + available_attributes: [:id, :shopify_user_id, :shopify_domain, :shopify_token], + ) + mock_user_instance.stubs(:save!).returns(true) + + UserMockSessionStore.stubs(:find_or_initialize_by).returns(mock_user_instance) + + UserMockSessionStore.store( + mock_session(shop: TEST_SHOPIFY_DOMAIN, scope: "read_products,write_orders"), + mock_associated_user, + ) + + # access_scopes should remain unchanged since column doesn't exist + assert_equal "old_scopes", mock_user_instance.access_scopes + end + + test ".store saves expires_at when column exists" do + expiry_time = Time.now + 1.day + mock_user_instance = MockUserInstance.new( + id: 12345, + shopify_user_id: TEST_SHOPIFY_USER_ID, + expires_at: nil, + available_attributes: [:id, :shopify_user_id, :shopify_domain, :shopify_token, :expires_at], + ) + mock_user_instance.stubs(:save!).returns(true) + + UserMockSessionStore.stubs(:find_or_initialize_by).returns(mock_user_instance) + + mock_auth_session = mock_session(shop: TEST_SHOPIFY_DOMAIN, expires: expiry_time) + + UserMockSessionStore.store(mock_auth_session, mock_associated_user) + + assert_equal expiry_time, mock_user_instance.expires_at + end + + test ".store does not save expires_at when column does not exist" do + old_expiry = Time.now + 2.days + mock_user_instance = MockUserInstance.new( + id: 12345, + shopify_user_id: TEST_SHOPIFY_USER_ID, + expires_at: old_expiry, + available_attributes: [:id, :shopify_user_id, :shopify_domain, :shopify_token], + ) + mock_user_instance.stubs(:save!).returns(true) + + UserMockSessionStore.stubs(:find_or_initialize_by).returns(mock_user_instance) + + UserMockSessionStore.store( + mock_session(shop: TEST_SHOPIFY_DOMAIN, expires: Time.now + 1.day), + mock_associated_user, + ) + + # expires_at should remain unchanged since column doesn't exist + assert_equal old_expiry, mock_user_instance.expires_at + end + + test ".retrieve constructs session with all optional attributes when columns exist" do + expiry_time = Time.now + 1.day + + mock_user_instance = MockUserInstance.new( + shopify_user_id: TEST_SHOPIFY_USER_ID, + shopify_domain: TEST_SHOPIFY_DOMAIN, + shopify_token: TEST_SHOPIFY_USER_TOKEN, + scopes: "read_products,write_orders", + expires_at: expiry_time, + available_attributes: [:shopify_user_id, :shopify_domain, :shopify_token, :access_scopes, :expires_at], + ) + + UserMockSessionStore.stubs(:find_by).with(id: 1).returns(mock_user_instance) + + session = UserMockSessionStore.retrieve(1) + + assert_equal TEST_SHOPIFY_DOMAIN, session.shop + assert_equal TEST_SHOPIFY_USER_TOKEN, session.access_token + assert_equal "read_products,write_orders", session.scope.to_s + assert_equal expiry_time, session.expires + end + + test ".retrieve constructs session without optional attributes when columns do not exist" do + mock_user_instance = MockUserInstance.new( + shopify_user_id: TEST_SHOPIFY_USER_ID, + shopify_domain: TEST_SHOPIFY_DOMAIN, + shopify_token: TEST_SHOPIFY_USER_TOKEN, + available_attributes: [:shopify_user_id, :shopify_domain, :shopify_token], + scopes: "old_scopes", + expires_at: Time.now + 2.days, + ) + + UserMockSessionStore.stubs(:find_by).with(id: 1).returns(mock_user_instance) + + session = UserMockSessionStore.retrieve(1) + + assert_equal TEST_SHOPIFY_DOMAIN, session.shop + assert_equal TEST_SHOPIFY_USER_TOKEN, session.access_token + # Optional attributes should not be present + assert_empty session.scope.to_a + assert_nil session.expires + end + private def mock_associated_user @@ -98,5 +220,14 @@ def mock_associated_user collaborator: true, ) end + + def mock_session(shop:, scope: nil, expires: nil) + mock_auth_hash = mock + mock_auth_hash.stubs(:shop).returns(shop) + mock_auth_hash.stubs(:access_token).returns("a-new-user_token!") + mock_auth_hash.stubs(:scope).returns(scope.is_a?(String) ? ShopifyAPI::Auth::AuthScopes.new(scope) : scope) + mock_auth_hash.stubs(:expires).returns(expires) + mock_auth_hash + end end end diff --git a/test/support/session_store_strategy_test_helpers.rb b/test/support/session_store_strategy_test_helpers.rb index ceebe937d..d2d51ac52 100644 --- a/test/support/session_store_strategy_test_helpers.rb +++ b/test/support/session_store_strategy_test_helpers.rb @@ -2,21 +2,60 @@ module SessionStoreStrategyTestHelpers class MockShopInstance - attr_reader :id, :shopify_domain, :shopify_token, :api_version, :access_scopes - attr_writer :shopify_token, :access_scopes + # Stub ActiveRecord validation method before including concern + def self.validates(*args); end + + include ShopifyApp::ShopSessionStorage + + attr_reader :id, + :shopify_domain, + :shopify_token, + :api_version, + :access_scopes, + :expires_at, + :refresh_token, + :refresh_token_expires_at + attr_writer :shopify_token, :access_scopes, :expires_at, :refresh_token, :refresh_token_expires_at def initialize( id: 1, shopify_domain: "example.myshopify.com", shopify_token: "abcd-shop-token", api_version: ShopifyApp.configuration.api_version, - scopes: "read_products" + scopes: "read_products", + expires_at: nil, + refresh_token: nil, + refresh_token_expires_at: nil, + available_attributes: [:id, :shopify_domain, :shopify_token, :api_version] ) @id = id @shopify_domain = shopify_domain @shopify_token = shopify_token @api_version = api_version @access_scopes = scopes + @expires_at = expires_at + @refresh_token = refresh_token + @refresh_token_expires_at = refresh_token_expires_at + @available_attributes = available_attributes + end + + def has_attribute?(attribute) + @available_attributes.include?(attribute.to_sym) + end + + # Stub ActiveRecord methods that the concern uses + def with_lock + yield + end + + def reload + self + end + + def update!(attrs) + attrs.each do |key, value| + send("#{key}=", value) + end end end @@ -31,7 +70,8 @@ def initialize( shopify_token: "1234-user-token", api_version: ShopifyApp.configuration.api_version, scopes: "read_products", - expires_at: nil + expires_at: nil, + available_attributes: [:id, :shopify_user_id, :shopify_domain, :shopify_token, :api_version] ) @id = id @shopify_user_id = shopify_user_id @@ -40,6 +80,11 @@ def initialize( @api_version = api_version @access_scopes = scopes @expires_at = expires_at + @available_attributes = available_attributes + end + + def has_attribute?(attribute) + @available_attributes.include?(attribute.to_sym) end end end