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 token
pull/544/head
Ryan Arakawa 2 months ago committed by GitHub
parent f4dc26786f
commit 741c548d26
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

1
.gitignore vendored

@ -39,3 +39,4 @@ yarn-debug.log*
dump.rdb
.aider*
.kilocode/*
CLAUDE.local.md

@ -4,6 +4,7 @@ module Api
class ApiBaseController < ActionController::API
include ActiveStorage::SetCurrent
include Pagy::Backend
include PartnershipContext
DEFAULT_LIMIT = 10
MAX_LIMIT = 100
@ -45,11 +46,13 @@ module Api
return 'Not authorized' unless error.subject.respond_to?(:account_id)
linked_account_record_exists =
if current_user.account.testing?
if current_user.account&.testing?
current_user.account.linked_account_accounts.where(account_type: 'testing')
.exists?(account_id: error.subject.account_id)
else
elsif current_user.account
current_user.account.testing_accounts.exists?(id: error.subject.account_id)
else
false
end
return 'Not authorized' unless linked_account_record_exists

@ -187,7 +187,7 @@ module Api
def submissions_params
permitted_attrs = [
:send_email, :send_sms, :bcc_completed, :completed_redirect_url, :reply_to, :go_to_last,
:expire_at, :name,
:expire_at, :name, :external_account_id,
{
message: %i[subject body],
submitters: [[:send_email, :send_sms, :completed_redirect_url, :uuid, :name, :email, :role,

@ -5,37 +5,91 @@ module Api
load_and_authorize_resource :template
def create
# Handle cloning from partnership templates to specific accounts
if params[:external_account_id].present? && @template.partnership_id.present?
return clone_from_partnership_to_account
end
authorize!(:create, @template)
cloned_template = clone_template_with_service(Templates::Clone, @template)
finalize_and_render_response(cloned_template)
end
private
def clone_from_partnership_to_account
cloned_template = Templates::CloneToAccount.call(
@template,
external_account_id: params[:external_account_id],
current_user: current_user,
author: current_user,
name: params[:name],
external_id: params[:external_id].presence || params[:application_key],
folder_name: params[:folder_name]
)
cloned_template.source = :api
finalize_and_render_response(cloned_template)
rescue ArgumentError => e
if e.message.include?('Unauthorized')
render json: { error: e.message }, status: :forbidden
elsif e.message.include?('must be a partnership template')
render json: { error: e.message }, status: :unprocessable_entity
else
render json: { error: e.message }, status: :bad_request
end
rescue ActiveRecord::RecordNotFound => e
render json: { error: e.message }, status: :not_found
end
def clone_template_with_service(service_class, template, **extra_args)
ActiveRecord::Associations::Preloader.new(
records: [@template],
records: [template],
associations: [schema_documents: :preview_images_attachments]
).call
cloned_template = Templates::Clone.call(
@template,
# Determine target for same-type cloning (clone to same ownership type as original)
target_args = if template.account_id.present?
{ target_account: template.account }
elsif template.partnership_id.present?
{ target_partnership: template.partnership }
else
{}
end
cloned_template = service_class.call(
template,
author: current_user,
name: params[:name],
external_id: params[:external_id].presence || params[:application_key],
folder_name: params[:folder_name]
folder_name: params[:folder_name],
**target_args,
**extra_args
)
cloned_template.source = :api
cloned_template
end
def finalize_and_render_response(cloned_template)
schema_documents = Templates::CloneAttachments.call(template: cloned_template,
original_template: @template,
documents: params[:documents])
cloned_template.save!
WebhookUrls.for_account_id(cloned_template.account_id, 'template.created').each do |webhook_url|
SendTemplateCreatedWebhookRequestJob.perform_async('template_id' => cloned_template.id,
'webhook_url_id' => webhook_url.id)
end
enqueue_webhooks(cloned_template)
SearchEntries.enqueue_reindex(cloned_template)
render json: Templates::SerializeForApi.call(cloned_template, schema_documents)
end
def enqueue_webhooks(template)
WebhookUrls.for_account_id(template.account_id, 'template.created').each do |webhook_url|
SendTemplateCreatedWebhookRequestJob.perform_async('template_id' => template.id,
'webhook_url_id' => webhook_url.id)
end
end
end
end

@ -70,11 +70,7 @@ module Api
@template.update!(template_params)
SearchEntries.enqueue_reindex(@template)
WebhookUrls.for_account_id(@template.account_id, 'template.updated').each do |webhook_url|
SendTemplateUpdatedWebhookRequestJob.perform_async('template_id' => @template.id,
'webhook_url_id' => webhook_url.id)
end
enqueue_template_updated_webhooks
render json: @template.as_json(only: %i[id updated_at])
end
@ -151,13 +147,30 @@ module Api
def build_template
template = Template.new
template.account = current_account
template.author = current_user
template.folder = TemplateFolders.find_or_create_by_name(current_user, params[:folder_name])
template.name = params[:name] || 'Untitled Template'
template.external_id = params[:external_id] if params[:external_id].present?
template.source = :api
template.submitters = params[:submitters] if params[:submitters].present?
# Handle partnership vs account template creation
if params[:external_partnership_id].present?
partnership = Partnership.find_by(external_partnership_id: params[:external_partnership_id])
if partnership.blank?
raise ActiveRecord::RecordNotFound, "Partnership not found: #{params[:external_partnership_id]}"
end
template.partnership = partnership
template.folder = TemplateFolders.find_or_create_by_name(
current_user,
params[:folder_name],
partnership: partnership
)
else
template.account = current_account
template.folder = TemplateFolders.find_or_create_by_name(current_user, params[:folder_name])
end
template
end
@ -199,9 +212,18 @@ module Api
end
def enqueue_template_created_webhooks(template)
WebhookUrls.for_account_id(template.account_id, 'template.created').each do |webhook_url|
SendTemplateCreatedWebhookRequestJob.perform_async('template_id' => template.id,
'webhook_url_id' => webhook_url.id)
enqueue_template_webhooks(template, 'template.created', SendTemplateCreatedWebhookRequestJob)
end
def enqueue_template_updated_webhooks
enqueue_template_webhooks(@template, 'template.updated', SendTemplateUpdatedWebhookRequestJob)
end
def enqueue_template_webhooks(template, event_type, job_class)
return if template.account_id.blank?
WebhookUrls.for_account_id(template.account_id, event_type).each do |webhook_url|
job_class.perform_async('template_id' => template.id, 'webhook_url_id' => webhook_url.id)
end
end

@ -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

@ -2,6 +2,7 @@
class TemplateDocumentsController < ApplicationController
include IframeAuthentication
include PartnershipContext
skip_before_action :verify_authenticity_token
skip_before_action :authenticate_via_token!

@ -3,6 +3,7 @@
class TemplatesController < ApplicationController
include PrefillFieldsHelper
include IframeAuthentication
include PartnershipContext
skip_before_action :verify_authenticity_token
skip_before_action :authenticate_via_token!, only: [:update]
@ -51,7 +52,8 @@ class TemplatesController < ApplicationController
methods: %i[metadata signed_uuid],
include: { preview_images: { methods: %i[url metadata filename] } }
),
available_prefill_fields: @available_prefill_fields
available_prefill_fields: @available_prefill_fields,
partnership_context: partnership_request_context
).to_json
render :edit, layout: 'plain'
@ -64,9 +66,20 @@ class TemplatesController < ApplicationController
associations: [schema_documents: :preview_images_attachments]
).call
@template = Templates::Clone.call(@base_template, author: current_user,
name: params.dig(:template, :name),
folder_name: params[:folder_name])
# Determine target for same-type cloning (clone to same ownership type as original)
target_args = if @base_template.account_id.present?
{ target_account: @base_template.account }
elsif @base_template.partnership_id.present?
{ target_partnership: @base_template.partnership }
else
{}
end
@template = Templates::Clone.call(@base_template,
author: current_user,
name: params.dig(:template, :name),
folder_name: params[:folder_name],
**target_args)
else
@template = Template.new(template_params) if @template.nil?
@template.author = current_user

@ -1,8 +1,13 @@
# frozen_string_literal: true
class TemplatesFormPreviewController < ApplicationController
include IframeAuthentication
include PartnershipContext
layout 'form'
skip_before_action :authenticate_via_token!
before_action :authenticate_from_referer
load_and_authorize_resource :template
def show

@ -64,7 +64,7 @@
/>
<template v-else>
<a
:href="`/templates/${template.id}/form`"
:href="formPreviewUrl"
data-turbo="false"
class="primary-button"
>
@ -349,7 +349,6 @@ import { IconPlus, IconUsersPlus, IconDeviceFloppy, IconChevronDown, IconEye, Ic
import { v4 } from 'uuid'
import { ref, computed, toRaw } from 'vue'
import * as i18n from './i18n'
export default {
name: 'TemplateBuilder',
components: {
@ -690,6 +689,26 @@ export default {
return this.template.schema.map((item) => {
return this.template.documents.find(doc => doc.uuid === item.attachment_uuid)
})
},
formPreviewUrl () {
let url = `/templates/${this.template.id}/form`
if (this.template.partnership_context) {
const params = new URLSearchParams()
const context = this.template.partnership_context
if (context.accessible_partnership_ids) {
context.accessible_partnership_ids.forEach(id => {
params.append('accessible_partnership_ids[]', id)
})
}
if (context.external_partnership_id) {
params.append('external_partnership_id', context.external_partnership_id)
}
if (context.external_account_id) {
params.append('external_account_id', context.external_account_id)
}
url += `?${params.toString()}`
}
return url
}
},
created () {
@ -1449,6 +1468,22 @@ export default {
const formData = new FormData()
formData.append('files[]', file)
// Add partnership context if available
if (this.template.partnership_context) {
const context = this.template.partnership_context
if (context.accessible_partnership_ids) {
context.accessible_partnership_ids.forEach(id => {
formData.append('accessible_partnership_ids[]', id)
})
}
if (context.external_partnership_id) {
formData.append('external_partnership_id', context.external_partnership_id)
}
if (context.external_account_id) {
formData.append('external_account_id', context.external_account_id)
}
}
this.baseFetch(`/templates/${this.template.id}/documents`, {
method: 'POST',
body: formData
@ -1722,7 +1757,26 @@ export default {
this.pushUndo()
return this.baseFetch(`/templates/${this.template.id}`, {
// Build URL with partnership context if available
let url = `/templates/${this.template.id}`
if (this.template.partnership_context) {
const params = new URLSearchParams()
const context = this.template.partnership_context
if (context.accessible_partnership_ids) {
context.accessible_partnership_ids.forEach(id => {
params.append('accessible_partnership_ids[]', id)
})
}
if (context.external_partnership_id) {
params.append('external_partnership_id', context.external_partnership_id)
}
if (context.external_account_id) {
params.append('external_account_id', context.external_account_id)
}
url += `?${params.toString()}`
}
return this.baseFetch(url, {
method: 'PUT',
body: JSON.stringify({
template: {

@ -73,7 +73,7 @@ export default {
IconFileSymlink,
IconFiles
},
inject: ['baseFetch', 't'],
inject: ['baseFetch', 't', 'template'],
props: {
templateId: {
type: [Number, String],

@ -48,7 +48,7 @@ export default {
IconUpload,
IconInnerShadowTop
},
inject: ['baseFetch', 't'],
inject: ['baseFetch', 't', 'template'],
props: {
templateId: {
type: [Number, String],
@ -78,10 +78,28 @@ export default {
async upload () {
this.isLoading = true
const formData = new FormData(this.$refs.form)
// Add partnership context if available
if (this.template?.partnership_context) {
const context = this.template.partnership_context
if (context.accessible_partnership_ids) {
context.accessible_partnership_ids.forEach(id => {
formData.append('accessible_partnership_ids[]', id)
})
}
if (context.external_partnership_id) {
formData.append('external_partnership_id', context.external_partnership_id)
}
if (context.external_account_id) {
formData.append('external_account_id', context.external_account_id)
}
}
this.baseFetch(this.uploadUrl, {
method: 'POST',
headers: { Accept: 'application/json' },
body: new FormData(this.$refs.form)
body: formData
}).then((resp) => {
if (resp.ok) {
resp.json().then((data) => {
@ -96,6 +114,22 @@ export default {
formData.append('password', prompt(this.t('enter_pdf_password')))
// Add partnership context if available
if (this.template?.partnership_context) {
const context = this.template.partnership_context
if (context.accessible_partnership_ids) {
context.accessible_partnership_ids.forEach(id => {
formData.append('accessible_partnership_ids[]', id)
})
}
if (context.external_partnership_id) {
formData.append('external_partnership_id', context.external_partnership_id)
}
if (context.external_account_id) {
formData.append('external_account_id', context.external_account_id)
}
}
this.baseFetch(this.uploadUrl, {
method: 'POST',
body: formData

@ -24,4 +24,8 @@ class ExportLocation < ApplicationRecord
def self.default_location
where(default_location: true).first || ExportLocation.first
end
def self.global_partnership_id
default_location&.global_partnership_id
end
end

@ -9,7 +9,12 @@ class ExternalAuthService
user = if @params[:account].present?
find_or_create_user_with_account
elsif @params[:partnership].present?
find_or_create_user_with_partnership
# Check if account context is also provided for account-level operations
if @params[:external_account_id].present?
find_or_create_user_with_partnership_and_account
else
find_or_create_user_with_partnership
end
else
raise ArgumentError, 'Either account or partnership params must be provided'
end
@ -43,15 +48,18 @@ class ExternalAuthService
# For partnership users, we don't store any partnership relationship
# They get authorized via API request context (accessible_partnership_ids)
# Just ensure the user exists in DocuSeal for authentication
User.find_by(external_user_id: @params[:user][:external_id]&.to_i) ||
User.create!(
user_attributes.merge(
external_user_id: @params[:user][:external_id]&.to_i,
password: SecureRandom.hex(16)
# NOTE: No account_id or partnership_id - authorization comes from API context
)
)
find_or_create_user_by_external_id(account: nil)
end
def find_or_create_user_with_partnership_and_account
# Hybrid approach: partnership authentication with account context
ensure_partnerships_exist
# Find the target account by external_account_id
account = Account.find_by(external_account_id: @params[:external_account_id])
raise ArgumentError, "Account not found for external_account_id: #{@params[:external_account_id]}" unless account
find_or_create_user_by_external_id(account: account)
end
def ensure_partnerships_exist
@ -64,6 +72,27 @@ class ExternalAuthService
)
end
def find_or_create_user_by_external_id(account: nil)
external_user_id = @params[:user][:external_id]&.to_i
user = User.find_by(external_user_id: external_user_id)
if user.present?
# If user exists and we have an account context, assign them to the account if they don't have one
user.update!(account: account) if account.present? && user.account_id.blank?
return user
end
# Create new user
create_attributes = user_attributes.merge(
external_user_id: external_user_id,
password: SecureRandom.hex(16)
)
create_attributes[:account] = account if account.present?
User.create!(create_attributes)
end
def user_attributes
{
email: @params[:user][:email],

@ -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

@ -1,5 +1,5 @@
<% content_for(:html_title, "#{@submitter.submission.name || @submitter.submission.template.name} | DocuSeal") %>
<% content_for(:html_description, "#{@submitter.account.name} has invited you to fill and sign documents online effortlessly with a secure, fast, and user-friendly digital document signing solution.") %>
<% content_for(:html_description, "#{@submitter.account&.name || 'You have been invited'} to fill and sign documents online effortlessly with a secure, fast, and user-friendly digital document signing solution.") %>
<% template_fields = @submitter.submission.template_fields || @submitter.submission.template.fields %>
<% fields_index = Templates.build_field_areas_index(template_fields) %>
<% submitter_values = @submitter.submission.submitters.reduce({}) { |acc, sub| acc.merge(sub.values) } %>
@ -84,14 +84,25 @@
<% next if field['conditions'].present? && values[field['uuid']].blank? && field['submitter_uuid'] != @submitter.uuid %>
<% next if field['conditions'].present? && field['submitter_uuid'] == @submitter.uuid %>
<% next if field.dig('preferences', 'formula').present? && field['submitter_uuid'] == @submitter.uuid %>
<%= render 'submissions/value', area:, field:, attachments_index: @attachments_index, value: field.dig('preferences', 'mask').present? ? TextUtils.mask_value(value, field.dig('preferences', 'mask')) : value, locale: @submitter.account.locale, timezone: @submitter.account.timezone, submitter: submitters_index[field['submitter_uuid']], with_signature_id: @form_configs[:with_signature_id] %>
<%= render 'submissions/value',
area:,
field:,
attachments_index: @attachments_index,
value: field.dig('preferences', 'mask').present? ? TextUtils.mask_value(value, field.dig('preferences', 'mask')) : value,
locale: @submitter.account&.locale || 'en',
timezone: @submitter.account&.timezone || 'UTC',
submitter: submitters_index[field['submitter_uuid']],
with_signature_id: @form_configs[:with_signature_id] %>
<% end %>
</div>
</div>
<% end %>
</div>
<% end %>
<%= render 'shared/attribution', link_path: '/start', account: @submitter.account, with_style: false %>
<%# Partnership not supported %>
<% if @submitter.account %>
<%= render 'shared/attribution', link_path: '/start', account: @submitter.account, with_style: false %>
<% end %>
<% if @form_configs[:policy_links].present? %>
<div class="text-center md:text-neutral-500 md:pr-3 md:pb-3 md:text-sm md:text-left mt-2 md:mt-0 md:fixed md:bottom-0 md:right-0">
<div class="md:max-w-[8rem] flex flex-wrap md:flex-col justify-center md:justify-start md:items-start space-x-2 md:space-x-0">

@ -5,7 +5,7 @@
</div>
<div class="flex items-center" style="margin-left: 20px; flex-shrink: 0">
<% if @template.submitters.size > 1 %>
<form action="<%= template_form_path(@template) %>" method="get" class="mr-3">
<form action="<%= template_form_path(@template, auth_token: params[:auth_token] || session[:auth_token]) %>" method="get" class="mr-3">
<select onchange="this.form.submit()" name="uuid" class="select base-input text-center font-normal" style="width: 180px; flex-shrink: 0;">
<% @template.submitters.each do |submitter| %>
<%= tag.option(value: submitter['uuid'], selected: submitter['uuid'] == @submitter.uuid) do %>
@ -15,7 +15,7 @@
</select>
</form>
<% end %>
<a href="<%= edit_template_path(@template) %>" class="base-button" data-turbo="false" style="flex-shrink: 0; padding: 0px 24px;">
<a href="<%= edit_template_path(@template, auth_token: params[:auth_token] || session[:auth_token]) %>" class="base-button" data-turbo="false" style="flex-shrink: 0; padding: 0px 24px;">
<%= t('exit_preview') %>
</a>
</div>

@ -61,6 +61,12 @@ Rails.application.routes.draw do
post :user_token
end
end
resources :token_refresh, only: [] do
collection do
post :create
end
end
end
resources :export, controller: 'export' do

@ -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

@ -13,6 +13,13 @@ module Abilities
if user.account_id.present?
templates = Template.where(account_id: user.account_id)
# Add global partnership templates if configured
if ExportLocation.global_partnership_id.present?
global_templates = Template.where(partnership_id: ExportLocation.global_partnership_id)
template_ids = templates.pluck(:id) + global_templates.pluck(:id)
templates = Template.where(id: template_ids.uniq)
end
return templates unless user.account.testing?
shared_ids =
@ -32,30 +39,30 @@ module Abilities
partnership_ids = Partnership.where(external_partnership_id: accessible_partnership_ids).pluck(:id)
Template.where(partnership_id: partnership_ids)
# Add global partnership if configured
partnership_ids << ExportLocation.global_partnership_id if ExportLocation.global_partnership_id.present?
Template.where(partnership_id: partnership_ids.uniq)
end
def entity(template, user:, ability: nil, request_context: nil)
return true if template.account_id.blank? && template.partnership_id.blank?
# Check request context first (from API params)
# If the template is a partnership template, we need to check the partnership context
if request_context && request_context[:accessible_partnership_ids].present?
return authorize_via_partnership_context(template, request_context)
end
# Handle partnership templates - users don't have stored relationships anymore
# This should not be reached for partnership users since they use API context
# Handle regular account templates
return true if template.account_id == user.account_id
return false unless user.account&.linked_account_account
return false if template.template_sharings.to_a.blank?
account_ids = [user.account_id, TemplateSharing::ALL_ID]
# Handle partnership templates for account users (no API context)
if template.partnership_id.present?
return true if global_partnership_template?(template)
template.template_sharings.to_a.any? do |e|
e.account_id.in?(account_ids) && (ability.nil? || e.ability == 'manage' || e.ability == ability)
return false
end
# Handle regular account templates
authorize_account_template(template, user, ability)
end
def authorize_via_partnership_context(template, request_context)
@ -63,10 +70,12 @@ module Abilities
# Handle partnership templates - check if user has access to the partnership
if template.partnership_id.present?
return true if global_partnership_template?(template)
partnership = Partnership.find_by(id: template.partnership_id)
return false unless partnership
return accessible_partnership_ids.include?(partnership.external_partnership_id)
return accessible_partnership_ids.include?(partnership.external_partnership_id.to_i)
end
# Handle account templates - check if user has access via partnership context
@ -76,5 +85,23 @@ module Abilities
false
end
def authorize_account_template(template, user, ability)
return true if template.account_id == user.account_id
return false unless user.account&.linked_account_account
return false if template.template_sharings.to_a.blank?
account_ids = [user.account_id, TemplateSharing::ALL_ID]
template.template_sharings.to_a.any? do |sharing|
sharing.account_id.in?(account_ids) &&
(ability.nil? || sharing.ability == 'manage' || sharing.ability == ability)
end
end
def global_partnership_template?(template)
ExportLocation.global_partnership_id.present? &&
template.partnership_id == ExportLocation.global_partnership_id
end
end
end

@ -3,9 +3,15 @@
class Ability
include CanCan::Ability
def initialize(user)
can %i[read create update], Template, Abilities::TemplateConditions.collection(user) do |template|
Abilities::TemplateConditions.entity(template, user:, ability: 'manage')
def initialize(user, request_context = nil)
can %i[read create update], Template,
Abilities::TemplateConditions.collection(user, request_context: request_context) do |template|
Abilities::TemplateConditions.entity(
template,
user: user,
ability: 'manage',
request_context: request_context
)
end
can :destroy, Template, account_id: user.account_id

@ -18,7 +18,9 @@ module Submitters
module_function
def call(submitter, keys = [])
configs = submitter.submission.account.account_configs.where(key: DEFAULT_KEYS + keys)
# Allow empty config keys for partnership users
account = submitter.submission.account
configs = account&.account_configs&.where(key: DEFAULT_KEYS + keys) || []
completed_button = find_safe_value(configs, AccountConfig::FORM_COMPLETED_BUTTON_KEY) || {}
completed_message = find_safe_value(configs, AccountConfig::FORM_COMPLETED_MESSAGE_KEY) || {}

@ -5,19 +5,31 @@ module Templates
module_function
# rubocop:disable Metrics, Style/CombinableLoops
def call(original_template, author:, external_id: nil, name: nil, folder_name: nil)
template = original_template.account.templates.new
def call(original_template, author:, external_id: nil, name: nil, folder_name: nil,
target_account: nil, target_partnership: nil)
# Determine the target for the cloned template
template = Template.new
if target_account.present?
template.assign_attributes(account: target_account, partnership: nil)
elsif target_partnership.present?
template.assign_attributes(partnership: target_partnership, account: nil)
else
raise ArgumentError, 'Either target_account or target_partnership must be provided'
end
template.external_id = external_id
template.shared_link = original_template.shared_link
template.author = author
template.name = name.presence || "#{original_template.name} (#{I18n.t('clone')})"
if folder_name.present?
template.folder = TemplateFolders.find_or_create_by_name(author, folder_name)
else
template.folder_id = original_template.folder_id
end
template.folder = determine_template_folder(
original_template,
target_account,
target_partnership,
author,
folder_name
)
template.submitters, template.fields, template.schema, template.preferences =
update_submitters_and_fields_and_schema(original_template.submitters.deep_dup,
@ -99,6 +111,41 @@ module Templates
[cloned_submitters, cloned_fields, cloned_schema, cloned_preferences]
end
def determine_template_folder(original_template, target_account, target_partnership, author, folder_name)
if folder_name.present?
create_named_folder(author, folder_name, target_partnership)
elsif cloning_between_account_and_partnership?(original_template, target_account, target_partnership)
create_default_folder_for_target(target_account, target_partnership, author)
else
return nil if original_template.folder_id.blank?
original_template.folder || original_template.folder_id
end
end
def create_named_folder(author, folder_name, target_partnership)
if target_partnership.present?
TemplateFolders.find_or_create_by_name(author, folder_name, partnership: target_partnership)
else
TemplateFolders.find_or_create_by_name(author, folder_name)
end
end
def cloning_between_account_and_partnership?(original_template, target_account, target_partnership)
# When cloning across entity types (partnership → account or account → partnership),
# we need to create default folders since folder structures don't transfer
(target_account.present? && original_template.partnership.present?) ||
(target_partnership.present? && original_template.account.present?)
end
def create_default_folder_for_target(target_account, target_partnership, author)
if target_partnership.present?
target_partnership.default_template_folder(author)
else
target_account.default_template_folder
end
end
# rubocop:enable Metrics, Style/CombinableLoops
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

@ -6,7 +6,8 @@ FactoryBot.define do
created_by_user factory: %i[user]
before(:create) do |submission, _|
submission.account_id = submission.template.account_id
# Set account_id from template if not already set
submission.account_id ||= submission.template.account_id
submission.template_fields = submission.template.fields
submission.template_schema = submission.template.schema
submission.template_submitters = submission.template.submitters

@ -357,5 +357,23 @@ FactoryBot.define do
create(:template_access, template:, user: ev.private_access_user || template.author)
end
end
trait :partnership_template do
account { nil }
transient do
partnership { nil }
end
partnership_id do |template|
if template.partnership
template.partnership.id
else
create(:partnership).id
end
end
author { create(:user, :with_partnership) }
end
end
end

@ -8,5 +8,9 @@ FactoryBot.define do
password { 'password' }
role { User::ADMIN_ROLE }
email { Faker::Internet.email }
trait :with_partnership do
account { nil }
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

@ -32,5 +32,44 @@ describe 'External Auth API' do
expect(response).to have_http_status(:internal_server_error)
expect(response.parsed_body).to eq({ 'error' => 'Internal server error' })
end
context 'when partnership authentication is used' do
let(:partnership_params) do
{
partnership: { external_id: 'partnership-123', name: 'Test Partnership' },
user: { external_id: '456', email: 'test@example.com', first_name: 'John', last_name: 'Doe' }
}
end
it 'creates user without account for pure partnership auth' do
post '/api/external_auth/user_token', params: partnership_params, as: :json
expect(response).to have_http_status(:ok)
expect(response.parsed_body).to have_key('access_token')
user = User.find_by(external_user_id: 456)
expect(user.account_id).to be_nil
end
it 'creates user with account for hybrid partnership+account auth' do
account = create(:account, external_account_id: 789)
hybrid_params = partnership_params.merge(external_account_id: 789)
post '/api/external_auth/user_token', params: hybrid_params, as: :json
expect(response).to have_http_status(:ok)
user = User.find_by(external_user_id: 456)
expect(user.account_id).to eq(account.id)
end
it 'returns error when account not found' do
hybrid_params = partnership_params.merge(external_account_id: 999)
post '/api/external_auth/user_token', params: hybrid_params, as: :json
expect(response).to have_http_status(:internal_server_error)
end
end
end
end

@ -173,6 +173,41 @@ describe 'Templates API' do
expect(cloned_template.external_id).to eq('123456')
expect(response.parsed_body).to eq(JSON.parse(clone_template_body(cloned_template).to_json))
end
context 'when cloning a template' do
it 'preserves partnership ownership' do
global_partnership = create(:partnership)
allow(ExportLocation).to receive(:global_partnership_id).and_return(global_partnership.id)
partnership_template = create(
:template,
partnership: global_partnership,
account: nil, author: create(:user, account: nil)
)
expect do
post "/api/templates/#{partnership_template.id}/clone",
headers: { 'x-auth-token': partnership_template.author.access_token.token }
end.to change(Template, :count)
cloned_template = Template.last
expect(cloned_template.partnership_id).to eq(partnership_template.partnership_id)
expect(cloned_template.account_id).to be_nil
end
it 'preserves account ownership' do
account_template = create(:template, account: account, author: author)
expect do
post "/api/templates/#{account_template.id}/clone",
headers: { 'x-auth-token': author.access_token.token }
end.to change(Template, :count)
cloned_template = Template.last
expect(cloned_template.account_id).to eq(account.id)
expect(cloned_template.partnership_id).to be_nil
end
end
end
private

@ -58,6 +58,55 @@ RSpec.describe ExternalAuthService do
expect(Partnership.last.external_partnership_id).to eq(789)
expect(User.last.external_user_id).to eq(123)
end
it 'returns access token for existing partnership and user' do
user = create(:user, account: nil, external_user_id: 123)
token = described_class.new(params).authenticate_user
expect(token).to eq(user.access_token.token)
end
end
context 'with partnership and account params' do
let(:params) do
{
partnership: {
external_id: '789', name: 'Test Group', locale: 'en-US', timezone: 'UTC', entity_type: 'Partnership'
},
external_account_id: '456',
user: user_params
}
end
it 'creates partnership user with account context' do
account = create(:account, external_account_id: 456)
token = described_class.new(params).authenticate_user
expect(token).to be_present
expect(Partnership.last.external_partnership_id).to eq(789)
expect(User.last.external_user_id).to eq(123)
expect(User.last.account_id).to eq(account.id)
end
it 'finds existing partnership user with account context' do
create(:account, external_account_id: 456)
user = create(:user, account: nil, external_user_id: 123)
token = described_class.new(params).authenticate_user
expect(token).to eq(user.access_token.token)
expect(User.count).to eq(1)
end
it 'handles external_account_id for account-level operations' do
account = create(:account, external_account_id: 456)
token = described_class.new(params).authenticate_user
expect(token).to be_present
expect(User.last.account_id).to eq(account.id)
end
end
context 'with invalid params' do

@ -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…
Cancel
Save