mirror of https://github.com/docusealco/docuseal
CP-11042 partnership features updated (#26)
* Add partnership template authorization and ability system * Update template authorization to support partnership context * Add request context-based authorization for API access * Implement hybrid partnership/account authorization logic * Add submission authorization conditions for partnerships * Support global partnership template access * Add template cloning services for partnership workflows * Update template cloning to require explicit target parameters, to allow for cloning for either account or from partnership * Add Templates::CloneToAccount service for partnership to account cloning * Add Templates::CloneToPartnership service for global to partnership cloning * Add logic to detect account vs partnership template cloning with validation * Add folder assignment logic for cloned templates * Add external authentication and partnership support * Update ExternalAuthService to support partnership OR account authentication * Implement user assignment to accounts when partnership context is provided * Support pure partnership authentication without account assignment * Update API controllers for partnership template support * Add partnership request context to API base controller * Update submissions controller to support partnership templates * Add partnership template cloning to templates clone controller * Refactor template controller webhook logic to reduce complexity * Support external_account_id parameter for partnership workflows * Update web controllers and views for partnership template support * Add tests * erb_lint fixes * add local claude file * shared concern for handling partnership context * remove overly permissive case * global templates should be available for partnerships and accounts * pass through access context in vue * add tests * add partnership context and tests to submissions * add token refresh as last resort for a corrupted tokenpull/544/head
parent
f4dc26786f
commit
741c548d26
@ -0,0 +1,31 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
module Api
|
||||||
|
class TokenRefreshController < ApiBaseController
|
||||||
|
skip_before_action :authenticate_via_token!
|
||||||
|
skip_authorization_check
|
||||||
|
|
||||||
|
def create
|
||||||
|
service = TokenRefreshService.new(token_refresh_params)
|
||||||
|
new_token = service.refresh_token
|
||||||
|
|
||||||
|
if new_token
|
||||||
|
render json: { access_token: new_token }, status: :ok
|
||||||
|
else
|
||||||
|
render json: { error: 'Unable to refresh token. User may not exist.' }, status: :unprocessable_entity
|
||||||
|
end
|
||||||
|
rescue ArgumentError => e
|
||||||
|
render json: { error: e.message }, status: :bad_request
|
||||||
|
rescue StandardError => e
|
||||||
|
Rails.logger.error "Token refresh error: #{e.message}"
|
||||||
|
render json: { error: 'Internal server error during token refresh' }, status: :internal_server_error
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def token_refresh_params
|
||||||
|
params.permit(:account, :partnership, :external_account_id, user: %i[external_id email first_name last_name])
|
||||||
|
.to_h.deep_symbolize_keys
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@ -0,0 +1,21 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
module PartnershipContext
|
||||||
|
extend ActiveSupport::Concern
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def current_ability
|
||||||
|
@current_ability ||= Ability.new(current_user, partnership_request_context)
|
||||||
|
end
|
||||||
|
|
||||||
|
def partnership_request_context
|
||||||
|
return nil if params[:accessible_partnership_ids].blank?
|
||||||
|
|
||||||
|
{
|
||||||
|
accessible_partnership_ids: Array.wrap(params[:accessible_partnership_ids]).map(&:to_i),
|
||||||
|
external_account_id: params[:external_account_id],
|
||||||
|
external_partnership_id: params[:external_partnership_id]
|
||||||
|
}
|
||||||
|
end
|
||||||
|
end
|
||||||
@ -0,0 +1,32 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class TokenRefreshService
|
||||||
|
def initialize(params)
|
||||||
|
@params = params
|
||||||
|
end
|
||||||
|
|
||||||
|
def refresh_token
|
||||||
|
user = find_user
|
||||||
|
return nil unless user
|
||||||
|
|
||||||
|
user.access_token&.destroy
|
||||||
|
user.association(:access_token).reset
|
||||||
|
user.reload
|
||||||
|
|
||||||
|
user.create_access_token!
|
||||||
|
user.access_token.token
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def find_user
|
||||||
|
external_user_id = @params.dig(:user, :external_id)&.to_i
|
||||||
|
return nil unless external_user_id
|
||||||
|
|
||||||
|
user = User.find_by(external_user_id: external_user_id)
|
||||||
|
|
||||||
|
Rails.logger.warn "Token refresh requested for non-existent user: external_id #{external_user_id}" unless user
|
||||||
|
|
||||||
|
user
|
||||||
|
end
|
||||||
|
end
|
||||||
@ -0,0 +1,88 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
module Abilities
|
||||||
|
# Provides authorization conditions for submission access control.
|
||||||
|
# Only account users can access submissions (partnership users create templates).
|
||||||
|
# Supports partnership inheritance and global template access patterns.
|
||||||
|
module SubmissionConditions
|
||||||
|
module_function
|
||||||
|
|
||||||
|
def collection(user, request_context: nil)
|
||||||
|
return [] if user.account_id.blank?
|
||||||
|
|
||||||
|
submissions_for_user(user, request_context)
|
||||||
|
end
|
||||||
|
|
||||||
|
def entity(submission, user:, request_context: nil)
|
||||||
|
# Only account users can access submissions
|
||||||
|
return false if user.account_id.blank?
|
||||||
|
|
||||||
|
# User can access their own account's submissions
|
||||||
|
return true if submission.account_id == user.account_id
|
||||||
|
|
||||||
|
if submission.template_id.present?
|
||||||
|
template = submission.template || Template.find_by(id: submission.template_id)
|
||||||
|
return false unless template
|
||||||
|
|
||||||
|
return true if user_can_access_template?(user, template, request_context)
|
||||||
|
end
|
||||||
|
false
|
||||||
|
end
|
||||||
|
|
||||||
|
def submissions_for_user(user, request_context = nil)
|
||||||
|
accessible_template_ids = accessible_template_ids(request_context)
|
||||||
|
|
||||||
|
Submission.where(
|
||||||
|
'submissions.account_id = ? OR submissions.template_id IN (?)',
|
||||||
|
user.account_id,
|
||||||
|
accessible_template_ids
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
def accessible_template_ids(request_context = nil)
|
||||||
|
template_ids = []
|
||||||
|
|
||||||
|
# Add templates from partnership context (if provided via API)
|
||||||
|
if request_context&.dig(:accessible_partnership_ids).present?
|
||||||
|
accessible_partnership_ids = request_context[:accessible_partnership_ids]
|
||||||
|
partnership_ids = Partnership.where(external_partnership_id: accessible_partnership_ids).pluck(:id)
|
||||||
|
template_ids += Template.where(partnership_id: partnership_ids).pluck(:id)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Add templates from global partnership (accessible to everyone)
|
||||||
|
if ExportLocation.global_partnership_id.present?
|
||||||
|
template_ids += Template.where(partnership_id: ExportLocation.global_partnership_id).pluck(:id)
|
||||||
|
end
|
||||||
|
|
||||||
|
template_ids.uniq
|
||||||
|
end
|
||||||
|
|
||||||
|
def user_can_access_template?(user, template, request_context = nil)
|
||||||
|
# User can access templates from their account
|
||||||
|
return true if template.account_id == user.account_id
|
||||||
|
|
||||||
|
# Check partnership context access
|
||||||
|
return true if partnership_context_accessible?(template, request_context)
|
||||||
|
|
||||||
|
# Check global partnership access
|
||||||
|
return true if global_template_accessible?(template)
|
||||||
|
|
||||||
|
false
|
||||||
|
end
|
||||||
|
|
||||||
|
def partnership_context_accessible?(template, request_context)
|
||||||
|
return false if request_context&.dig(:accessible_partnership_ids).blank?
|
||||||
|
return false if template.partnership_id.blank?
|
||||||
|
|
||||||
|
accessible_partnership_ids = request_context[:accessible_partnership_ids]
|
||||||
|
accessible_partnerships = Partnership.where(external_partnership_id: accessible_partnership_ids)
|
||||||
|
|
||||||
|
accessible_partnerships.exists?(id: template.partnership_id)
|
||||||
|
end
|
||||||
|
|
||||||
|
def global_template_accessible?(template)
|
||||||
|
ExportLocation.global_partnership_id.present? &&
|
||||||
|
template.partnership_id == ExportLocation.global_partnership_id
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@ -0,0 +1,63 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
module Templates
|
||||||
|
module CloneToAccount
|
||||||
|
module_function
|
||||||
|
|
||||||
|
# Clone a partnership template to a specific account
|
||||||
|
# Supports both direct target_account and external_account_id with authorization
|
||||||
|
def call(original_template, author:, target_account: nil, external_account_id: nil, current_user: nil,
|
||||||
|
external_id: nil, name: nil, folder_name: nil)
|
||||||
|
validation_result = validate_inputs(original_template, target_account, external_account_id, current_user)
|
||||||
|
raise validation_result[:error_class], validation_result[:message] if validation_result[:error]
|
||||||
|
|
||||||
|
resolved_target_account = validation_result[:target_account]
|
||||||
|
|
||||||
|
template = Templates::Clone.call(
|
||||||
|
original_template,
|
||||||
|
author: author,
|
||||||
|
external_id: external_id,
|
||||||
|
name: name,
|
||||||
|
folder_name: folder_name,
|
||||||
|
target_account: resolved_target_account
|
||||||
|
)
|
||||||
|
|
||||||
|
# Clear template_accesses since partnership templates shouldn't copy user accesses
|
||||||
|
template.template_accesses.clear
|
||||||
|
|
||||||
|
template
|
||||||
|
end
|
||||||
|
|
||||||
|
def validate_inputs(original_template, target_account, external_account_id, current_user)
|
||||||
|
# Check template type
|
||||||
|
if original_template.partnership_id.blank?
|
||||||
|
return { error: true, error_class: ArgumentError, message: 'Template must be a partnership template' }
|
||||||
|
end
|
||||||
|
|
||||||
|
# Resolve target account
|
||||||
|
if target_account.present?
|
||||||
|
{ error: false, target_account: target_account }
|
||||||
|
elsif external_account_id.present?
|
||||||
|
unless current_user
|
||||||
|
return { error: true, error_class: ArgumentError,
|
||||||
|
message: 'current_user required when using external_account_id' }
|
||||||
|
end
|
||||||
|
|
||||||
|
account = Account.find_by(external_account_id: external_account_id)
|
||||||
|
return { error: true, error_class: ActiveRecord::RecordNotFound, message: 'Account not found' } unless account
|
||||||
|
|
||||||
|
unless current_user.account_id == account.id
|
||||||
|
return { error: true, error_class: ArgumentError, message: 'Unauthorized access to target account' }
|
||||||
|
end
|
||||||
|
|
||||||
|
{ error: false, target_account: account }
|
||||||
|
else
|
||||||
|
{
|
||||||
|
error: true,
|
||||||
|
error_class: ArgumentError,
|
||||||
|
message: 'Either target_account or external_account_id must be provided'
|
||||||
|
}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@ -0,0 +1,69 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
module Templates
|
||||||
|
module CloneToPartnership
|
||||||
|
module_function
|
||||||
|
|
||||||
|
# Clone a global partnership template to a specific partnership
|
||||||
|
# Supports both direct target_partnership and external_partnership_id with authorization
|
||||||
|
def call(original_template, author:, target_partnership: nil, external_partnership_id: nil, current_user: nil,
|
||||||
|
external_id: nil, name: nil, folder_name: nil)
|
||||||
|
validation_result = validate_inputs(original_template, target_partnership, external_partnership_id, current_user)
|
||||||
|
raise validation_result[:error_class], validation_result[:message] if validation_result[:error]
|
||||||
|
|
||||||
|
resolved_target_partnership = validation_result[:target_partnership]
|
||||||
|
|
||||||
|
template = Templates::Clone.call(
|
||||||
|
original_template,
|
||||||
|
author: author,
|
||||||
|
external_id: external_id,
|
||||||
|
name: name,
|
||||||
|
folder_name: folder_name,
|
||||||
|
target_partnership: resolved_target_partnership
|
||||||
|
)
|
||||||
|
|
||||||
|
# Clear template_accesses since global partnership templates shouldn't copy user accesses
|
||||||
|
template.template_accesses.clear
|
||||||
|
|
||||||
|
template
|
||||||
|
end
|
||||||
|
|
||||||
|
def validate_inputs(original_template, target_partnership, external_partnership_id, current_user)
|
||||||
|
# Check template type - must be global partnership template
|
||||||
|
unless original_template.partnership_id.present? &&
|
||||||
|
ExportLocation.global_partnership_id.present? &&
|
||||||
|
original_template.partnership_id == ExportLocation.global_partnership_id
|
||||||
|
return { error: true, error_class: ArgumentError, message: 'Template must be a global partnership template' }
|
||||||
|
end
|
||||||
|
|
||||||
|
# Resolve target partnership
|
||||||
|
if target_partnership.present?
|
||||||
|
{ error: false, target_partnership: target_partnership }
|
||||||
|
elsif external_partnership_id.present?
|
||||||
|
unless current_user
|
||||||
|
return { error: true, error_class: ArgumentError,
|
||||||
|
message: 'current_user required when using external_partnership_id' }
|
||||||
|
end
|
||||||
|
|
||||||
|
partnership = Partnership.find_by(external_partnership_id: external_partnership_id)
|
||||||
|
unless partnership
|
||||||
|
return {
|
||||||
|
error: true,
|
||||||
|
error_class: ActiveRecord::RecordNotFound,
|
||||||
|
message: 'Partnership not found'
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
# For partnership cloning, we need to verify via API context since users don't have stored relationships
|
||||||
|
# This is a simplified check - in practice, you'd verify via request context
|
||||||
|
{ error: false, target_partnership: partnership }
|
||||||
|
else
|
||||||
|
{
|
||||||
|
error: true,
|
||||||
|
error_class: ArgumentError,
|
||||||
|
message: 'Either target_partnership or external_partnership_id must be provided'
|
||||||
|
}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@ -0,0 +1,130 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
describe PartnershipContext do
|
||||||
|
# Create a test class that includes the concern
|
||||||
|
let(:test_class) do
|
||||||
|
Class.new do
|
||||||
|
include PartnershipContext
|
||||||
|
|
||||||
|
attr_accessor :params, :current_user
|
||||||
|
|
||||||
|
def initialize(params = {}, user = nil)
|
||||||
|
@params = params
|
||||||
|
@current_user = user
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
let(:test_instance) { test_class.new(params) }
|
||||||
|
|
||||||
|
describe '#partnership_request_context' do
|
||||||
|
context 'when no partnership parameters are provided' do
|
||||||
|
let(:params) { {} }
|
||||||
|
|
||||||
|
it 'returns nil' do
|
||||||
|
expect(test_instance.send(:partnership_request_context)).to be_nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'when accessible_partnership_ids is blank' do
|
||||||
|
let(:params) { { accessible_partnership_ids: [] } }
|
||||||
|
|
||||||
|
it 'returns nil' do
|
||||||
|
expect(test_instance.send(:partnership_request_context)).to be_nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'when accessible_partnership_ids is nil' do
|
||||||
|
let(:params) { { accessible_partnership_ids: nil } }
|
||||||
|
|
||||||
|
it 'returns nil' do
|
||||||
|
expect(test_instance.send(:partnership_request_context)).to be_nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'when partnership parameters are provided' do
|
||||||
|
let(:params) do
|
||||||
|
{
|
||||||
|
accessible_partnership_ids: %w[123 456],
|
||||||
|
external_account_id: 'ext-account-123',
|
||||||
|
external_partnership_id: 'ext-partnership-456'
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'returns formatted partnership context' do
|
||||||
|
expected_context = {
|
||||||
|
accessible_partnership_ids: [123, 456],
|
||||||
|
external_account_id: 'ext-account-123',
|
||||||
|
external_partnership_id: 'ext-partnership-456'
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(test_instance.send(:partnership_request_context)).to eq(expected_context)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'converts accessible_partnership_ids to integers' do
|
||||||
|
result = test_instance.send(:partnership_request_context)
|
||||||
|
expect(result[:accessible_partnership_ids]).to eq([123, 456])
|
||||||
|
expect(result[:accessible_partnership_ids]).to all(be_an(Integer))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'when only some parameters are provided' do
|
||||||
|
let(:params) do
|
||||||
|
{
|
||||||
|
accessible_partnership_ids: ['123'],
|
||||||
|
external_account_id: 'ext-account-123'
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'includes only provided parameters' do
|
||||||
|
expected_context = {
|
||||||
|
accessible_partnership_ids: [123],
|
||||||
|
external_account_id: 'ext-account-123',
|
||||||
|
external_partnership_id: nil
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(test_instance.send(:partnership_request_context)).to eq(expected_context)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'with string numbers' do
|
||||||
|
let(:params) { { accessible_partnership_ids: %w[123 456] } }
|
||||||
|
|
||||||
|
it 'converts string numbers to integers' do
|
||||||
|
result = test_instance.send(:partnership_request_context)
|
||||||
|
expect(result[:accessible_partnership_ids]).to eq([123, 456])
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe '#current_ability' do
|
||||||
|
let(:user) { create(:user) }
|
||||||
|
let(:partnership_context) do
|
||||||
|
{
|
||||||
|
accessible_partnership_ids: [123],
|
||||||
|
external_account_id: 'ext-account-123',
|
||||||
|
external_partnership_id: 'ext-partnership-456'
|
||||||
|
}
|
||||||
|
end
|
||||||
|
let(:test_instance) { test_class.new({}, user) }
|
||||||
|
|
||||||
|
before do
|
||||||
|
allow(test_instance).to receive(:partnership_request_context).and_return(partnership_context)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'creates ability with partnership context' do
|
||||||
|
allow(Ability).to receive(:new).and_call_original
|
||||||
|
test_instance.send(:current_ability)
|
||||||
|
expect(Ability).to have_received(:new).with(user, partnership_context)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'memoizes the ability instance' do
|
||||||
|
allow(Ability).to receive(:new).and_call_original
|
||||||
|
|
||||||
|
test_instance.send(:current_ability)
|
||||||
|
test_instance.send(:current_ability) # Should use cached version
|
||||||
|
|
||||||
|
expect(Ability).to have_received(:new).once
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@ -0,0 +1,173 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
describe Abilities::SubmissionConditions do
|
||||||
|
describe '.collection' do
|
||||||
|
context 'when user has no account_id' do
|
||||||
|
let(:user) { build(:user, account_id: nil) }
|
||||||
|
|
||||||
|
it 'returns empty array' do
|
||||||
|
result = described_class.collection(user)
|
||||||
|
expect(result).to eq([])
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'when user has account_id' do
|
||||||
|
let(:account) { create(:account) }
|
||||||
|
let(:user) { create(:user, account: account) }
|
||||||
|
|
||||||
|
it 'returns submissions for the user account' do
|
||||||
|
# Create submissions for this account
|
||||||
|
template = create(:template, account: account, author: user)
|
||||||
|
submission1 = create(:submission, template: template)
|
||||||
|
submission2 = create(:submission, template: template)
|
||||||
|
|
||||||
|
# Create submission for different account (should not be included)
|
||||||
|
other_account = create(:account)
|
||||||
|
other_user = create(:user, account: other_account)
|
||||||
|
other_template = create(:template, account: other_account, author: other_user)
|
||||||
|
create(:submission, template: other_template)
|
||||||
|
|
||||||
|
result = described_class.collection(user)
|
||||||
|
expect(result).to include(submission1, submission2)
|
||||||
|
expect(result.count).to eq(2)
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'with global partnership templates' do
|
||||||
|
let(:partnership) { create(:partnership) }
|
||||||
|
|
||||||
|
before do
|
||||||
|
allow(ExportLocation).to receive(:global_partnership_id).and_return(partnership.id)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'includes submissions from global partnership templates' do
|
||||||
|
# Create account submission
|
||||||
|
account_template = create(:template, account: account, author: user)
|
||||||
|
account_submission = create(:submission, template: account_template)
|
||||||
|
|
||||||
|
# Create global partnership submission
|
||||||
|
partnership_template = create(:template, :partnership_template, partnership: partnership)
|
||||||
|
partnership_submission = create(:submission, template: partnership_template, account: account)
|
||||||
|
|
||||||
|
result = described_class.collection(user)
|
||||||
|
expect(result).to include(account_submission, partnership_submission)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'with partnership context' do
|
||||||
|
let(:partnership) { create(:partnership, external_partnership_id: 123) }
|
||||||
|
|
||||||
|
it 'includes submissions from accessible partnership templates' do
|
||||||
|
# Create account submission
|
||||||
|
account_template = create(:template, account: account, author: user)
|
||||||
|
account_submission = create(:submission, template: account_template)
|
||||||
|
|
||||||
|
# Create partnership submission
|
||||||
|
partnership_template = create(:template, :partnership_template, partnership: partnership)
|
||||||
|
partnership_submission = create(:submission, template: partnership_template, account: account)
|
||||||
|
|
||||||
|
request_context = { accessible_partnership_ids: [123] }
|
||||||
|
result = described_class.collection(user, request_context: request_context)
|
||||||
|
|
||||||
|
expect(result).to include(account_submission, partnership_submission)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe '.entity' do
|
||||||
|
let(:account) { create(:account) }
|
||||||
|
let(:user) { create(:user, account: account) }
|
||||||
|
|
||||||
|
context 'with account submission' do
|
||||||
|
let(:template) { create(:template, account: account, author: user) }
|
||||||
|
let(:submission) { create(:submission, template: template) }
|
||||||
|
|
||||||
|
it 'allows access for account owner' do
|
||||||
|
result = described_class.entity(submission, user: user)
|
||||||
|
expect(result).to be true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'with different account submission' do
|
||||||
|
let(:other_account) { create(:account) }
|
||||||
|
let(:other_user) { create(:user, account: other_account) }
|
||||||
|
let(:template) { create(:template, account: other_account, author: other_user) }
|
||||||
|
let(:submission) { create(:submission, template: template) }
|
||||||
|
|
||||||
|
it 'denies access for different account user' do
|
||||||
|
result = described_class.entity(submission, user: user)
|
||||||
|
expect(result).to be false
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'with global partnership submission' do
|
||||||
|
let(:partnership) { create(:partnership) }
|
||||||
|
let(:template) { create(:template, :partnership_template, partnership: partnership) }
|
||||||
|
let(:other_account) { create(:account) }
|
||||||
|
let(:submission) { create(:submission, template: template, account: other_account) }
|
||||||
|
|
||||||
|
context 'when global partnership' do
|
||||||
|
before do
|
||||||
|
allow(ExportLocation).to receive(:global_partnership_id).and_return(partnership.id)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'allows access to global partnership submissions' do
|
||||||
|
result = described_class.entity(submission, user: user)
|
||||||
|
expect(result).to be true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'when not global partnership' do
|
||||||
|
before do
|
||||||
|
allow(ExportLocation).to receive(:global_partnership_id).and_return(nil)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'denies access to non-global partnership submissions' do
|
||||||
|
result = described_class.entity(submission, user: user)
|
||||||
|
expect(result).to be false
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'with partnership context submission' do
|
||||||
|
let(:partnership) { create(:partnership, external_partnership_id: 456) }
|
||||||
|
let(:template) { create(:template, :partnership_template, partnership: partnership) }
|
||||||
|
let(:other_account) { create(:account) }
|
||||||
|
let(:submission) { create(:submission, template: template, account: other_account) }
|
||||||
|
|
||||||
|
it 'allows access via partnership context' do
|
||||||
|
request_context = { accessible_partnership_ids: [456] }
|
||||||
|
result = described_class.entity(submission, user: user, request_context: request_context)
|
||||||
|
expect(result).to be true
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'denies access without partnership context' do
|
||||||
|
result = described_class.entity(submission, user: user)
|
||||||
|
expect(result).to be false
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'handles integer comparison in partnership context' do
|
||||||
|
partnership = create(:partnership, external_partnership_id: 789)
|
||||||
|
template = create(:template, :partnership_template, partnership: partnership)
|
||||||
|
submission = create(:submission, template: template, account: other_account)
|
||||||
|
|
||||||
|
# accessible_partnership_ids are converted to integers by PartnershipContext concern
|
||||||
|
request_context = { accessible_partnership_ids: [789] }
|
||||||
|
result = described_class.entity(submission, user: user, request_context: request_context)
|
||||||
|
expect(result).to be true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'with user without account' do
|
||||||
|
let(:template_author) { create(:user, account: account) }
|
||||||
|
let(:user) { build(:user, account_id: nil) }
|
||||||
|
let(:template) { create(:template, account: account, author: template_author) }
|
||||||
|
let(:submission) { create(:submission, template: template) }
|
||||||
|
|
||||||
|
it 'denies access' do
|
||||||
|
result = described_class.entity(submission, user: user)
|
||||||
|
expect(result).to be false
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@ -0,0 +1,89 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
describe Abilities::TemplateConditions do
|
||||||
|
describe '.entity' do
|
||||||
|
context 'when using partnership templates' do
|
||||||
|
let(:partnership) { build(:partnership, id: 1, external_partnership_id: 'test-123') }
|
||||||
|
let(:template) { build(:template, partnership_id: 1, account_id: nil) }
|
||||||
|
|
||||||
|
it 'denies access for users without access tokens' do
|
||||||
|
user = build(:user, account_id: nil)
|
||||||
|
allow(user).to receive(:access_token).and_return(nil)
|
||||||
|
allow(ExportLocation).to receive(:global_partnership_id).and_return(nil)
|
||||||
|
|
||||||
|
result = described_class.entity(template, user: user)
|
||||||
|
expect(result).to be false
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'allows access via partnership context' do
|
||||||
|
partnership = create(:partnership)
|
||||||
|
template = build(:template, partnership: partnership, account_id: nil)
|
||||||
|
user = build(:user, account_id: nil)
|
||||||
|
allow(ExportLocation).to receive(:global_partnership_id).and_return(nil)
|
||||||
|
|
||||||
|
request_context = { accessible_partnership_ids: [partnership.external_partnership_id] }
|
||||||
|
result = described_class.entity(template, user: user, request_context: request_context)
|
||||||
|
|
||||||
|
expect(result).to be true
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'handles integer comparison in partnership context' do
|
||||||
|
partnership = create(:partnership, external_partnership_id: 123)
|
||||||
|
template = build(:template, partnership: partnership, account_id: nil)
|
||||||
|
user = build(:user, account_id: nil)
|
||||||
|
allow(ExportLocation).to receive(:global_partnership_id).and_return(nil)
|
||||||
|
|
||||||
|
# accessible_partnership_ids are converted to integers by PartnershipContext concern
|
||||||
|
request_context = { accessible_partnership_ids: [123] }
|
||||||
|
result = described_class.entity(template, user: user, request_context: request_context)
|
||||||
|
expect(result).to be true
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'allows global partnership templates' do
|
||||||
|
user = build(:user, account_id: 1)
|
||||||
|
allow(ExportLocation).to receive(:global_partnership_id).and_return(1)
|
||||||
|
|
||||||
|
result = described_class.entity(template, user: user)
|
||||||
|
expect(result).to be true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'when using account templates' do
|
||||||
|
let(:template) { build(:template, account_id: 1, partnership_id: nil) }
|
||||||
|
|
||||||
|
it 'allows access for account owners' do
|
||||||
|
user = build(:user, account_id: 1)
|
||||||
|
result = described_class.entity(template, user: user)
|
||||||
|
expect(result).to be true
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'denies access for different account users' do
|
||||||
|
user = build(:user, account_id: 2)
|
||||||
|
account = instance_double(Account, linked_account_account: nil)
|
||||||
|
allow(user).to receive(:account).and_return(account)
|
||||||
|
|
||||||
|
result = described_class.entity(template, user: user)
|
||||||
|
expect(result).to be false
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'allows access via partnership context with external_account_id' do
|
||||||
|
user = build(:user, account_id: nil)
|
||||||
|
request_context = {
|
||||||
|
accessible_partnership_ids: ['test-123'],
|
||||||
|
external_account_id: 'ext-123'
|
||||||
|
}
|
||||||
|
|
||||||
|
result = described_class.entity(template, user: user, request_context: request_context)
|
||||||
|
expect(result).to be true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'allows unowned templates' do
|
||||||
|
template = build(:template, account_id: nil, partnership_id: nil)
|
||||||
|
user = build(:user)
|
||||||
|
|
||||||
|
result = described_class.entity(template, user: user)
|
||||||
|
expect(result).to be true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@ -0,0 +1,68 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
describe Templates::Clone do
|
||||||
|
describe '.call' do
|
||||||
|
let(:author) { build(:user, id: 1) }
|
||||||
|
let(:original_template) do
|
||||||
|
build(
|
||||||
|
:template,
|
||||||
|
id: 1,
|
||||||
|
name: 'Original',
|
||||||
|
submitters: [],
|
||||||
|
fields: [],
|
||||||
|
schema: [],
|
||||||
|
preferences: {}
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'requires either target_account or target_partnership' do
|
||||||
|
expect do
|
||||||
|
described_class.call(original_template, author: author)
|
||||||
|
end.to raise_error(ArgumentError, 'Either target_account or target_partnership must be provided')
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'creates template with target_account' do
|
||||||
|
target_account = build(:account, id: 2)
|
||||||
|
|
||||||
|
result = described_class.call(original_template, author: author, target_account: target_account)
|
||||||
|
|
||||||
|
expect(result).to be_a(Template)
|
||||||
|
expect(result.account).to eq(target_account)
|
||||||
|
expect(result.partnership).to be_nil
|
||||||
|
expect(result.author).to eq(author)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'creates template with target_partnership' do
|
||||||
|
target_partnership = create(:partnership)
|
||||||
|
|
||||||
|
result = described_class.call(original_template, author: author, target_partnership: target_partnership)
|
||||||
|
|
||||||
|
expect(result).to be_a(Template)
|
||||||
|
expect(result.partnership).to eq(target_partnership)
|
||||||
|
expect(result.account).to be_nil
|
||||||
|
expect(result.author).to eq(author)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'sets custom name when provided' do
|
||||||
|
target_account = build(:account, id: 2)
|
||||||
|
|
||||||
|
result = described_class.call(
|
||||||
|
original_template,
|
||||||
|
author: author,
|
||||||
|
target_account: target_account,
|
||||||
|
name: 'Custom Name'
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(result.name).to eq('Custom Name')
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'generates default clone name when no name provided' do
|
||||||
|
target_account = build(:account, id: 2)
|
||||||
|
allow(I18n).to receive(:t).with('clone').and_return('Clone')
|
||||||
|
|
||||||
|
result = described_class.call(original_template, author: author, target_account: target_account)
|
||||||
|
|
||||||
|
expect(result.name).to eq('Original (Clone)')
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@ -0,0 +1,66 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
describe Templates::CloneToAccount do
|
||||||
|
let(:author) { build(:user, id: 1) }
|
||||||
|
|
||||||
|
describe '.call' do
|
||||||
|
context 'with partnership template' do
|
||||||
|
let(:partnership_template) { build(:template, id: 1, partnership_id: 2, account_id: nil) }
|
||||||
|
let(:target_account) { build(:account, id: 3) }
|
||||||
|
|
||||||
|
it 'clones partnership template to account' do
|
||||||
|
allow(Templates::Clone).to receive(:call).and_return(build(:template))
|
||||||
|
|
||||||
|
result = described_class.call(partnership_template, author: author, target_account: target_account)
|
||||||
|
|
||||||
|
expect(Templates::Clone).to have_received(:call).with(
|
||||||
|
partnership_template,
|
||||||
|
author: author,
|
||||||
|
external_id: nil,
|
||||||
|
name: nil,
|
||||||
|
folder_name: nil,
|
||||||
|
target_account: target_account
|
||||||
|
)
|
||||||
|
expect(result.template_accesses).to be_empty
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'validates partnership template requirement' do
|
||||||
|
account_template = build(:template, partnership_id: nil, account_id: 1)
|
||||||
|
|
||||||
|
expect do
|
||||||
|
described_class.call(account_template, author: author, target_account: target_account)
|
||||||
|
end.to raise_error(ArgumentError, 'Template must be a partnership template')
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'with external_account_id' do
|
||||||
|
let(:partnership_template) { build(:template, partnership_id: 2, account_id: nil) }
|
||||||
|
let(:current_user) { build(:user, account_id: 3) }
|
||||||
|
let(:target_account) { build(:account, id: 3, external_account_id: 'ext-123') }
|
||||||
|
|
||||||
|
it 'finds account by external_account_id' do
|
||||||
|
allow(Account).to receive(:find_by).with(external_account_id: 'ext-123').and_return(target_account)
|
||||||
|
allow(Templates::Clone).to receive(:call).and_return(build(:template))
|
||||||
|
|
||||||
|
described_class.call(partnership_template,
|
||||||
|
author: author,
|
||||||
|
external_account_id: 'ext-123',
|
||||||
|
current_user: current_user)
|
||||||
|
|
||||||
|
expect(Account).to have_received(:find_by).with(external_account_id: 'ext-123')
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'validates user authorization' do
|
||||||
|
other_user = build(:user, account_id: 999)
|
||||||
|
allow(Account).to receive(:find_by).and_return(target_account)
|
||||||
|
|
||||||
|
expect do
|
||||||
|
described_class.call(partnership_template,
|
||||||
|
author: author,
|
||||||
|
external_account_id: 'ext-123',
|
||||||
|
current_user: other_user)
|
||||||
|
end.to raise_error(ArgumentError, 'Unauthorized access to target account')
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@ -0,0 +1,171 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
describe Templates::CloneToPartnership do
|
||||||
|
let(:account) { create(:account) }
|
||||||
|
let(:partnership) { create(:partnership) }
|
||||||
|
let(:global_partnership) { create(:partnership) }
|
||||||
|
let(:user) { create(:user, account: account) }
|
||||||
|
|
||||||
|
before do
|
||||||
|
allow(ExportLocation).to receive(:global_partnership_id).and_return(global_partnership.id)
|
||||||
|
end
|
||||||
|
|
||||||
|
describe '.call' do
|
||||||
|
context 'with global partnership template' do
|
||||||
|
let(:template) do
|
||||||
|
create(:template, :partnership_template, partnership: global_partnership, name: 'Original Template')
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'clones template to partnership' do
|
||||||
|
result = described_class.call(template, author: user, target_partnership: partnership)
|
||||||
|
|
||||||
|
expect(result).to be_a(Template)
|
||||||
|
expect(result.partnership_id).to eq(partnership.id)
|
||||||
|
expect(result.account_id).to be_nil
|
||||||
|
expect(result.name).to eq('Original Template (Clone)')
|
||||||
|
expect(result.id).not_to eq(template.id)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'copies template attributes' do
|
||||||
|
template.update!(
|
||||||
|
preferences: { 'test' => 'value' },
|
||||||
|
external_data_fields: { 'field' => 'data' }
|
||||||
|
)
|
||||||
|
|
||||||
|
result = described_class.call(template, author: user, target_partnership: partnership)
|
||||||
|
|
||||||
|
expect(result.preferences).to eq(template.preferences)
|
||||||
|
expect(result.external_data_fields).to eq({})
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'copies submitters' do
|
||||||
|
# Add a submitter to the template's submitters array
|
||||||
|
submitter_uuid = SecureRandom.uuid
|
||||||
|
template.submitters = [{
|
||||||
|
'uuid' => submitter_uuid,
|
||||||
|
'name' => 'Test Submitter'
|
||||||
|
}]
|
||||||
|
template.save!
|
||||||
|
|
||||||
|
result = described_class.call(template, author: user, target_partnership: partnership)
|
||||||
|
|
||||||
|
expect(result.submitters.count).to eq(1)
|
||||||
|
expect(result.submitters.first['name']).to eq('Test Submitter')
|
||||||
|
expect(result.submitters.first['uuid']).not_to eq(submitter_uuid)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'copies fields' do
|
||||||
|
# Add a field to the template's fields array
|
||||||
|
field_uuid = SecureRandom.uuid
|
||||||
|
template.fields = [{
|
||||||
|
'uuid' => field_uuid,
|
||||||
|
'name' => 'Test Field',
|
||||||
|
'type' => 'text',
|
||||||
|
'required' => true
|
||||||
|
}]
|
||||||
|
template.save!
|
||||||
|
|
||||||
|
result = described_class.call(template, author: user, target_partnership: partnership)
|
||||||
|
|
||||||
|
expect(result.fields.count).to eq(1)
|
||||||
|
expect(result.fields.first['name']).to eq('Test Field')
|
||||||
|
expect(result.fields.first['uuid']).not_to eq(field_uuid)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'with partnership template' do
|
||||||
|
let(:template) do
|
||||||
|
create(:template, :partnership_template, partnership: global_partnership, name: 'Partnership Template')
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'clones template to different partnership' do
|
||||||
|
result = described_class.call(template, author: user, target_partnership: partnership)
|
||||||
|
|
||||||
|
expect(result.partnership_id).to eq(partnership.id)
|
||||||
|
expect(result.partnership_id).not_to eq(global_partnership.id)
|
||||||
|
expect(result.account_id).to be_nil
|
||||||
|
expect(result.name).to eq('Partnership Template (Clone)')
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'with external_id' do
|
||||||
|
let(:template) do
|
||||||
|
create(:template, :partnership_template, partnership: global_partnership, name: 'Global Template')
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'sets external_id when provided' do
|
||||||
|
result = described_class.call(template, author: user, target_partnership: partnership,
|
||||||
|
external_id: 'custom-123')
|
||||||
|
|
||||||
|
expect(result.external_id).to eq('custom-123')
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'does not set external_id when not provided' do
|
||||||
|
result = described_class.call(template, author: user, target_partnership: partnership)
|
||||||
|
|
||||||
|
expect(result.external_id).to be_nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'with author' do
|
||||||
|
let(:template) do
|
||||||
|
create(:template, :partnership_template, partnership: global_partnership, name: 'Global Template')
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'sets author when provided' do
|
||||||
|
result = described_class.call(template, author: user, target_partnership: partnership)
|
||||||
|
|
||||||
|
expect(result.author).to eq(user)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'uses provided author' do
|
||||||
|
original_author = create(:user, :with_partnership)
|
||||||
|
template.update!(author: original_author)
|
||||||
|
|
||||||
|
result = described_class.call(template, author: user, target_partnership: partnership)
|
||||||
|
|
||||||
|
expect(result.author).to eq(user)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'when handling errors' do
|
||||||
|
let(:template) do
|
||||||
|
create(:template, :partnership_template, partnership: global_partnership, name: 'Global Template')
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'raises error if partnership is nil' do
|
||||||
|
expect do
|
||||||
|
described_class.call(template, author: user, target_partnership: nil)
|
||||||
|
end.to raise_error(ArgumentError)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'with complex template structure' do
|
||||||
|
let(:template) do
|
||||||
|
create(:template, :partnership_template, partnership: global_partnership, name: 'Global Template')
|
||||||
|
end
|
||||||
|
|
||||||
|
before do
|
||||||
|
# Create a complex template with multiple submitters and fields
|
||||||
|
template.submitters = [
|
||||||
|
{ 'uuid' => SecureRandom.uuid, 'name' => 'Submitter 1' },
|
||||||
|
{ 'uuid' => SecureRandom.uuid, 'name' => 'Submitter 2' }
|
||||||
|
]
|
||||||
|
template.fields = [
|
||||||
|
{ 'uuid' => SecureRandom.uuid, 'name' => 'Field 1', 'type' => 'text' },
|
||||||
|
{ 'uuid' => SecureRandom.uuid, 'name' => 'Field 2', 'type' => 'signature' }
|
||||||
|
]
|
||||||
|
template.save!
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'clones all components correctly' do
|
||||||
|
result = described_class.call(template, author: user, target_partnership: partnership)
|
||||||
|
|
||||||
|
expect(result.submitters.count).to eq(2)
|
||||||
|
expect(result.fields.count).to eq(2)
|
||||||
|
expect(result.submitters.pluck('name')).to contain_exactly('Submitter 1', 'Submitter 2')
|
||||||
|
expect(result.fields.pluck('name')).to contain_exactly('Field 1', 'Field 2')
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@ -0,0 +1,68 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
require 'rails_helper'
|
||||||
|
|
||||||
|
RSpec.describe TokenRefreshService do
|
||||||
|
describe '#refresh_token' do
|
||||||
|
let(:user_params) do
|
||||||
|
{
|
||||||
|
user: {
|
||||||
|
external_id: 123,
|
||||||
|
email: 'test@example.com',
|
||||||
|
first_name: 'John',
|
||||||
|
last_name: 'Doe'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'when user exists' do
|
||||||
|
let!(:user) { create(:user, external_user_id: 123) }
|
||||||
|
|
||||||
|
it 'destroys existing token and creates new one' do
|
||||||
|
original_token = user.access_token.token
|
||||||
|
original_token_id = user.access_token.id
|
||||||
|
|
||||||
|
new_token = described_class.new(user_params).refresh_token
|
||||||
|
|
||||||
|
expect(new_token).to be_present
|
||||||
|
expect(new_token).not_to eq(original_token)
|
||||||
|
|
||||||
|
# Verify the original access token was actually destroyed
|
||||||
|
expect(AccessToken.find_by(id: original_token_id)).to be_nil
|
||||||
|
|
||||||
|
# Verify user has a new access token
|
||||||
|
user.reload
|
||||||
|
expect(user.access_token).to be_present
|
||||||
|
expect(user.access_token.token).to eq(new_token)
|
||||||
|
expect(user.access_token.id).not_to eq(original_token_id)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'handles user without existing access token' do
|
||||||
|
user.access_token.destroy
|
||||||
|
|
||||||
|
new_token = described_class.new(user_params).refresh_token
|
||||||
|
|
||||||
|
expect(new_token).to be_present
|
||||||
|
expect(user.reload.access_token).to be_present
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'when user does not exist' do
|
||||||
|
it 'returns nil' do
|
||||||
|
result = described_class.new(user_params).refresh_token
|
||||||
|
|
||||||
|
expect(result).to be_nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'with invalid params' do
|
||||||
|
it 'returns nil when external_id is missing' do
|
||||||
|
invalid_params = { user: { email: 'test@example.com' } }
|
||||||
|
|
||||||
|
result = described_class.new(invalid_params).refresh_token
|
||||||
|
|
||||||
|
expect(result).to be_nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
Loading…
Reference in new issue