CP-10370 authentication (#15)

* Add external_id fields to accounts and users tables

Adds external_account_id and external_user_id fields to support
integration with external ATS systems. These fields will map
DocuSeal accounts/users to their corresponding ATS entities.

* Add external ID support to Account and User models

Implements find_or_create_by_external_id methods for both Account
and User models to support automatic provisioning from external
ATS systems. Users now have access tokens for authentication.

* Add external authentication API endpoint

Creates /api/external_auth/get_user_token endpoint for external API systems
to authenticate users and receive access tokens.

* Refactor authentication to support token-based login

Replaces demo user authentication and setup redirect logic with token-based authentication via params, session, or X-Auth-Token header.

Users do not login, they are just authenticated via token.

* Replace authenticate_user! with authenticate_via_token!

Refactored controllers to use authenticate_via_token! instead of authenticate_user! for authentication. Added authenticate_via_token! method to ApiBaseController.

* Update controller authentication and authorization logic

Removed and replaced several before_action and authorization checks in ExportController, SetupController, and TemplateDocumentsController.

* Add external authentication API endpoint

* Add IframeAuthentication concern for AJAX requests in iframe context

* Create shared concern to handle authentication from HTTP referer
* Extracts auth token from referer URL when AJAX requests don't include token
* Supports Vue component requests within iframes

* Remove old user authentication from dashboard controller

* Quick fix for request changes

Now that we have scoped users, we're changing this to compare to the template authot

* rubocop fixes

* Add and update authentication and model specs

Introduces new specs for iframe authentication, account, user, application controller, and external auth API.

* add safe navigation and remove dead method
pull/544/head
Ryan Arakawa 4 months ago committed by GitHub
parent ba325ec5a4
commit 4ec9e7fc5e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -4,7 +4,7 @@ module Api
class ActiveStorageBlobsProxyController < ApiBaseController
include ActiveStorage::Streaming
skip_before_action :authenticate_user!
skip_before_action :authenticate_via_token!
skip_authorization_check
before_action :set_cors_headers

@ -4,7 +4,7 @@ module Api
class ActiveStorageBlobsProxyLegacyController < ApiBaseController
include ActiveStorage::Streaming
skip_before_action :authenticate_user!
skip_before_action :authenticate_via_token!
skip_authorization_check
before_action :set_cors_headers

@ -12,7 +12,7 @@ module Api
wrap_parameters false
before_action :authenticate_user!
before_action :authenticate_via_token!
check_authorization
rescue_from Params::BaseValidator::InvalidParameterError do |e|
@ -81,7 +81,7 @@ module Api
result
end
def authenticate_user!
def authenticate_via_token!
render json: { error: 'Not authenticated' }, status: :unauthorized unless current_user
end

@ -0,0 +1,32 @@
# frozen_string_literal: true
module Api
class ExternalAuthController < Api::ApiBaseController
skip_before_action :authenticate_via_token!
skip_authorization_check
def user_token
account = Account.find_or_create_by_external_id(
params[:account][:external_id]&.to_i,
name: params[:account][:name],
locale: params[:account][:locale] || 'en-US',
timezone: params[:account][:timezone] || 'UTC'
)
user = User.find_or_create_by_external_id(
account,
params[:user][:external_id]&.to_i,
email: params[:user][:email],
first_name: params[:user][:first_name],
last_name: params[:user][:last_name],
role: 'admin'
)
render json: { access_token: user.access_token.token }
rescue StandardError => e
Rails.logger.error("External auth error: #{e.message}")
Rollbar.error(e) if defined?(Rollbar)
render json: { error: 'Internal server error' }, status: :internal_server_error
end
end
end

@ -2,7 +2,7 @@
module Api
class SubmitterEmailClicksController < ApiBaseController
skip_before_action :authenticate_user!
skip_before_action :authenticate_via_token!
skip_authorization_check
def create

@ -2,7 +2,7 @@
module Api
class SubmitterFormViewsController < ApiBaseController
skip_before_action :authenticate_user!
skip_before_action :authenticate_via_token!
skip_authorization_check
def create

@ -6,14 +6,12 @@ class ApplicationController < ActionController::Base
include ActiveStorage::SetCurrent
include Pagy::Backend
before_action :ensure_demo_user_signed_in
check_authorization unless: :devise_controller?
around_action :with_locale
# before_action :sign_in_for_demo, if: -> { Docuseal.demo? }
before_action :maybe_redirect_to_setup, unless: :signed_in?
before_action :authenticate_user!, unless: :devise_controller?
before_action :maybe_authenticate_via_token
before_action :authenticate_via_token!, unless: :devise_controller?
helper_method :button_title,
:current_account,
@ -102,34 +100,42 @@ class ApplicationController < ActionController::Base
current_user&.account
end
def maybe_redirect_to_setup
# Skip setup redirect for iframe embedding - create demo user instead
return if ensure_demo_user_signed_in
def maybe_authenticate_via_token
return if signed_in?
redirect_to setup_index_path unless User.exists?
end
# Check for token in params, session, or X-Auth-Token header
token = params[:auth_token] || session[:auth_token] || request.headers['X-Auth-Token']
return if token.blank?
# Try to find user by token and sign them in
sha256 = Digest::SHA256.hexdigest(token)
user = User.joins(:access_token).active.find_by(access_token: { sha256: sha256 })
def ensure_demo_user_signed_in
return true if signed_in?
return unless user
user = find_or_create_demo_user
sign_in(user)
true
end
def find_or_create_demo_user
User.find_by(email: 'demo@docuseal.local') || begin
account = Account.create!(name: 'Demo Account', locale: 'en', timezone: 'UTC')
User.create!(
email: 'demo@docuseal.local',
password: 'password123',
password_confirmation: 'password123',
first_name: 'Demo',
last_name: 'User',
account: account,
role: 'admin'
)
session[:auth_token] = token
end
# Enhanced authentication that tries token auth and fails with error if no user found
# Use this when you need to enforce authentication with better token handling
def authenticate_via_token!
return if signed_in?
token = params[:auth_token] || session[:auth_token] || request.headers['X-Auth-Token']
if token.present?
sha256 = Digest::SHA256.hexdigest(token)
user = User.joins(:access_token).active.find_by(access_token: { sha256: sha256 })
if user
sign_in(user)
session[:auth_token] = token
return
end
end
render json: { error: 'Authentication required. Please provide a valid auth_token.' }, status: :unauthorized
end
def button_title(title: I18n.t('submit'), disabled_with: I18n.t('submitting'), title_class: '', icon: nil,

@ -0,0 +1,37 @@
# frozen_string_literal: true
module IframeAuthentication
extend ActiveSupport::Concern
private
# Custom authentication for iframe context
# AJAX requests from Vue components don't include the auth token that's in the iframe URL,
# so we extract it from the HTTP referer header as a fallback
def authenticate_from_referer
return if signed_in?
token = params[:auth_token] || session[:auth_token] || request.headers['X-Auth-Token']
# If no token found, extract from referer URL (iframe page has the token)
if token.blank? && request.referer.present?
referer_uri = URI.parse(request.referer)
referer_params = CGI.parse(referer_uri.query || '')
token = referer_params['auth_token']&.first
end
if token.present?
sha256 = Digest::SHA256.hexdigest(token)
user = User.joins(:access_token).active.find_by(access_token: { sha256: sha256 })
return unless user
sign_in(user)
session[:auth_token] = token
return
end
Rails.logger.error "#{self.class.name}: Authentication failed"
render json: { error: 'Authentication required' }, status: :unauthorized
end
end

@ -1,7 +1,7 @@
# frozen_string_literal: true
class ConsoleRedirectController < ApplicationController
skip_before_action :authenticate_user!
skip_before_action :authenticate_via_token!
skip_authorization_check
def index

@ -1,7 +1,7 @@
# frozen_string_literal: true
class DashboardController < ApplicationController
skip_before_action :authenticate_user!, only: %i[index]
skip_before_action :authenticate_via_token!, only: %i[index]
before_action :maybe_redirect_product_url
before_action :maybe_render_landing

@ -1,7 +1,7 @@
# frozen_string_literal: true
class EnquiriesController < ApplicationController
skip_before_action :authenticate_user!
skip_before_action :authenticate_via_token!
skip_authorization_check
def create

@ -4,7 +4,6 @@ require 'faraday'
class ExportController < ApplicationController
skip_authorization_check
skip_before_action :maybe_redirect_to_setup
skip_before_action :verify_authenticity_token
# Send template to third party.

@ -3,7 +3,7 @@
class SendSubmissionEmailController < ApplicationController
layout 'form'
skip_before_action :authenticate_user!
skip_before_action :authenticate_via_token!
skip_before_action :verify_authenticity_token
skip_authorization_check

@ -1,8 +1,7 @@
# frozen_string_literal: true
class SetupController < ApplicationController
skip_before_action :maybe_redirect_to_setup
skip_before_action :authenticate_user!
skip_before_action :authenticate_via_token!
skip_authorization_check
before_action :redirect_to_root_if_signed, if: :signed_in?

@ -3,7 +3,7 @@
class StartFormController < ApplicationController
layout 'form'
skip_before_action :authenticate_user!
skip_before_action :authenticate_via_token!
skip_authorization_check
around_action :with_browser_locale, only: %i[show completed]

@ -3,7 +3,7 @@
class SubmissionsDebugController < ApplicationController
layout 'plain'
skip_before_action :authenticate_user!
skip_before_action :authenticate_via_token!
skip_authorization_check
def index

@ -1,7 +1,7 @@
# frozen_string_literal: true
class SubmissionsDownloadController < ApplicationController
skip_before_action :authenticate_user!
skip_before_action :authenticate_via_token!
skip_authorization_check
TTL = 40.minutes

@ -2,7 +2,7 @@
class SubmissionsPreviewController < ApplicationController
around_action :with_browser_locale
skip_before_action :authenticate_user!
skip_before_action :authenticate_via_token!
skip_authorization_check
prepend_before_action :maybe_redirect_com, only: %i[show completed]

@ -4,7 +4,7 @@ class SubmitFormController < ApplicationController
layout 'form'
around_action :with_browser_locale, only: %i[show completed success]
skip_before_action :authenticate_user!
skip_before_action :authenticate_via_token!
skip_authorization_check
skip_before_action :verify_authenticity_token, only: :update

@ -1,7 +1,7 @@
# frozen_string_literal: true
class SubmitFormDeclineController < ApplicationController
skip_before_action :authenticate_user!
skip_before_action :authenticate_via_token!
skip_authorization_check
def create

@ -1,7 +1,7 @@
# frozen_string_literal: true
class SubmitFormDownloadController < ApplicationController
skip_before_action :authenticate_user!
skip_before_action :authenticate_via_token!
skip_authorization_check
FILES_TTL = 5.minutes

@ -4,7 +4,7 @@ class SubmitFormDrawSignatureController < ApplicationController
layout false
around_action :with_browser_locale, only: %i[show]
skip_before_action :authenticate_user!
skip_before_action :authenticate_via_token!
skip_authorization_check
def show

@ -1,7 +1,7 @@
# frozen_string_literal: true
class SubmitFormInviteController < ApplicationController
skip_before_action :authenticate_user!
skip_before_action :authenticate_via_token!
skip_authorization_check
def create

@ -1,7 +1,7 @@
# frozen_string_literal: true
class SubmitFormValuesController < ApplicationController
skip_before_action :authenticate_user!
skip_before_action :authenticate_via_token!
skip_authorization_check
def index

@ -1,8 +1,12 @@
# frozen_string_literal: true
class SubmittersRequestChangesController < ApplicationController
before_action :load_submitter
include IframeAuthentication
skip_before_action :verify_authenticity_token, only: :request_changes
skip_before_action :authenticate_via_token!, only: :request_changes
before_action :authenticate_from_referer, only: :request_changes
before_action :load_submitter
def request_changes
if request.get? || request.head?
@ -48,9 +52,9 @@ class SubmittersRequestChangesController < ApplicationController
end
def can_request_changes?
# Only the user who created the submission can request changes
# Only the template author (manager) can request changes from submitters
# Only for completed submissions that haven't been declined
current_user == @submitter.submission.created_by_user &&
current_user == @submitter.submission.template.author &&
@submitter.completed_at? &&
!@submitter.declined_at? &&
!@submitter.changes_requested_at?

@ -1,8 +1,13 @@
# frozen_string_literal: true
class TemplateDocumentsController < ApplicationController
include IframeAuthentication
skip_before_action :verify_authenticity_token
load_and_authorize_resource :template
skip_before_action :authenticate_via_token!
before_action :authenticate_from_referer
load_and_authorize_resource :template, id_param: :template_id
def create
if params[:blobs].blank? && params[:files].blank?

@ -2,12 +2,13 @@
class TemplatesController < ApplicationController
include PrefillFieldsHelper
include IframeAuthentication
skip_before_action :maybe_redirect_to_setup
skip_before_action :verify_authenticity_token
skip_before_action :authenticate_via_token!, only: [:update]
before_action :authenticate_from_referer, only: [:update]
load_and_authorize_resource :template
before_action :load_base_template, only: %i[new create]
def show
@ -67,6 +68,7 @@ class TemplatesController < ApplicationController
name: params.dig(:template, :name),
folder_name: params[:folder_name])
else
@template = Template.new(template_params) if @template.nil?
@template.author = current_user
@template.folder = TemplateFolders.find_or_create_by_name(current_user, params[:folder_name])
end

@ -1,10 +1,6 @@
# frozen_string_literal: true
class TemplatesDashboardController < ApplicationController
before_action :ensure_demo_user_signed_in
skip_before_action :authenticate_user!
skip_before_action :maybe_redirect_to_setup
load_and_authorize_resource :template_folder, parent: false
load_and_authorize_resource :template, parent: false

@ -4,18 +4,20 @@
#
# Table name: accounts
#
# id :bigint not null, primary key
# archived_at :datetime
# locale :string not null
# name :string not null
# timezone :string not null
# uuid :string not null
# created_at :datetime not null
# updated_at :datetime not null
# id :bigint not null, primary key
# archived_at :datetime
# locale :string not null
# name :string not null
# timezone :string not null
# uuid :string not null
# created_at :datetime not null
# updated_at :datetime not null
# external_account_id :integer
#
# Indexes
#
# index_accounts_on_uuid (uuid) UNIQUE
# index_accounts_on_external_account_id (external_account_id) UNIQUE
# index_accounts_on_uuid (uuid) UNIQUE
#
class Account < ApplicationRecord
attribute :uuid, :string, default: -> { SecureRandom.uuid }
@ -53,8 +55,15 @@ class Account < ApplicationRecord
attribute :timezone, :string, default: 'UTC'
attribute :locale, :string, default: 'en-US'
validates :external_account_id, uniqueness: true, allow_nil: true
scope :active, -> { where(archived_at: nil) }
def self.find_or_create_by_external_id(external_id, attributes = {})
find_by(external_account_id: external_id) ||
create!(attributes.merge(external_account_id: external_id))
end
def testing?
linked_account_account&.testing?
end

@ -29,11 +29,13 @@
# created_at :datetime not null
# updated_at :datetime not null
# account_id :integer not null
# external_user_id :integer
#
# Indexes
#
# index_users_on_account_id (account_id)
# index_users_on_email (email) UNIQUE
# index_users_on_external_user_id (external_user_id) UNIQUE
# index_users_on_reset_password_token (reset_password_token) UNIQUE
# index_users_on_unlock_token (unlock_token) UNIQUE
# index_users_on_uuid (uuid) UNIQUE
@ -74,6 +76,17 @@ class User < ApplicationRecord
scope :admins, -> { where(role: ADMIN_ROLE) }
validates :email, format: { with: /\A[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\z/ }
validates :external_user_id, uniqueness: true, allow_nil: true
def self.find_or_create_by_external_id(account, external_id, attributes = {})
account.users.find_by(external_user_id: external_id) ||
account.users.create!(
attributes.merge(
external_user_id: external_id,
password: SecureRandom.hex(16)
)
)
end
def access_token
super || build_access_token.tap(&:save!)

@ -235,7 +235,7 @@
<%= button_to t('resubmit'), submitters_resubmit_path(submitter), method: :put, class: 'btn btn-sm btn-primary w-full', form: { target: '_blank' }, data: { turbo: false } %>
</div>
<% end %>
<% if signed_in? && submitter && submitter.completed_at? && !submitter.declined_at? && !submitter.changes_requested_at? && current_user == @submission.created_by_user %>
<% if signed_in? && submitter && submitter.completed_at? && !submitter.declined_at? && !submitter.changes_requested_at? && current_user == @submission.template.author %>
<div class="mt-2 mb-1">
<%= link_to 'Request Changes', request_changes_submitter_path(submitter.slug),
class: 'btn btn-sm btn-warning w-full',

@ -56,6 +56,11 @@ Rails.application.routes.draw do
resources :form_events, only: %i[index], path: 'form/:type'
resources :submission_events, only: %i[index], path: 'submission/:type'
end
resources :external_auth, only: [] do
collection do
post :user_token
end
end
end
resources :export, controller: 'export' do

@ -0,0 +1,9 @@
class AddExternalIdsToAccountsAndUsers < ActiveRecord::Migration[8.0]
def change
add_column :accounts, :external_account_id, :integer
add_column :users, :external_user_id, :integer
add_index :accounts, :external_account_id, unique: true
add_index :users, :external_user_id, unique: true
end
end

@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[8.0].define(version: 2025_08_11_211829) do
ActiveRecord::Schema[8.0].define(version: 2025_08_14_214357) do
# These are extensions that must be enabled in order to support this database
enable_extension "btree_gin"
enable_extension "pg_catalog.plpgsql"
@ -62,6 +62,8 @@ ActiveRecord::Schema[8.0].define(version: 2025_08_11_211829) do
t.datetime "updated_at", null: false
t.string "uuid", null: false
t.datetime "archived_at"
t.integer "external_account_id"
t.index ["external_account_id"], name: "index_accounts_on_external_account_id", unique: true
t.index ["uuid"], name: "index_accounts_on_uuid", unique: true
end
@ -440,8 +442,10 @@ ActiveRecord::Schema[8.0].define(version: 2025_08_11_211829) do
t.string "otp_secret"
t.integer "consumed_timestep"
t.boolean "otp_required_for_login", default: false, null: false
t.integer "external_user_id"
t.index ["account_id"], name: "index_users_on_account_id"
t.index ["email"], name: "index_users_on_email", unique: true
t.index ["external_user_id"], name: "index_users_on_external_user_id", unique: true
t.index ["reset_password_token"], name: "index_users_on_reset_password_token", unique: true
t.index ["unlock_token"], name: "index_users_on_unlock_token", unique: true
t.index ["uuid"], name: "index_users_on_uuid", unique: true

@ -0,0 +1,75 @@
# frozen_string_literal: true
describe IframeAuthentication do
let(:account) { create(:account) }
let(:user) { create(:user, account: account) }
let(:token) { user.access_token.token }
let(:controller_class) do
Class.new(ApplicationController) do
include IframeAuthentication
end
end
let(:controller) { controller_class.new }
let(:request_double) { instance_double(ActionDispatch::Request, headers: {}, referer: nil) }
before do
allow(controller).to receive_messages(
request: request_double,
params: {},
session: {},
signed_in?: false,
sign_in: nil,
render: nil
)
allow(Rails.logger).to receive(:error)
end
describe '#authenticate_from_referer' do
it 'does nothing when already signed in' do
allow(controller).to receive(:signed_in?).and_return(true)
controller.send(:authenticate_from_referer)
expect(controller).not_to have_received(:sign_in)
end
it 'authenticates with valid params token' do
allow(controller).to receive(:params).and_return({ auth_token: token })
controller.send(:authenticate_from_referer)
expect(controller).to have_received(:sign_in).with(user)
end
it 'authenticates with valid session token' do
allow(controller).to receive(:session).and_return({ auth_token: token })
controller.send(:authenticate_from_referer)
expect(controller).to have_received(:sign_in).with(user)
end
it 'authenticates with valid header token' do
allow(request_double).to receive(:headers).and_return({ 'X-Auth-Token' => token })
controller.send(:authenticate_from_referer)
expect(controller).to have_received(:sign_in).with(user)
end
it 'authenticates with token from referer URL' do
allow(request_double).to receive(:referer).and_return("https://example.com?auth_token=#{token}")
controller.send(:authenticate_from_referer)
expect(controller).to have_received(:sign_in).with(user)
end
it 'does nothing with invalid token' do
allow(controller).to receive(:params).and_return({ auth_token: 'invalid' })
controller.send(:authenticate_from_referer)
expect(controller).not_to have_received(:sign_in)
expect(controller).not_to have_received(:render)
end
it 'renders error with no token' do
controller.send(:authenticate_from_referer)
expect(controller).to have_received(:render).with(
json: { error: 'Authentication required' },
status: :unauthorized
)
end
end
end

@ -0,0 +1,59 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe Account do
describe 'validations' do
it 'is valid with valid attributes' do
account = build(:account)
expect(account).to be_valid
end
it 'validates uniqueness of external_account_id when present' do
create(:account, external_account_id: 123)
duplicate = build(:account, external_account_id: 123)
expect(duplicate).not_to be_valid
end
end
describe '.find_or_create_by_external_id' do
let(:external_id) { 123 }
let(:attributes) { { name: 'Test Account' } }
it 'finds existing account by external_account_id' do
existing_account = create(:account, external_account_id: external_id)
result = described_class.find_or_create_by_external_id(external_id, attributes)
expect(result).to eq(existing_account)
end
it 'creates new account when none exists' do
result = described_class.find_or_create_by_external_id(external_id, attributes)
expect(result.external_account_id).to eq(external_id)
expect(result.name).to eq('Test Account')
end
end
describe '#testing?' do
let(:account) { create(:account) }
it 'delegates to linked_account_account' do
linked_account_account = instance_double(AccountLinkedAccount, testing?: true)
allow(account).to receive(:linked_account_account).and_return(linked_account_account)
expect(account.testing?).to be true
end
end
describe '#default_template_folder' do
it 'creates default folder when none exists' do
account = create(:account)
create(:user, account: account)
expect do
folder = account.default_template_folder
expect(folder.name).to eq(TemplateFolder::DEFAULT_NAME)
expect(folder).to be_persisted
end.to change(account.template_folders, :count).by(1)
end
end
end

@ -1,5 +1,45 @@
# frozen_string_literal: true
# == Schema Information
#
# Table name: submitters
#
# id :bigint not null, primary key
# changes_requested_at :datetime
# completed_at :datetime
# declined_at :datetime
# email :string
# ip :string
# metadata :text not null
# name :string
# opened_at :datetime
# phone :string
# preferences :text not null
# sent_at :datetime
# slug :string not null
# timezone :string
# ua :string
# uuid :string not null
# values :text not null
# created_at :datetime not null
# updated_at :datetime not null
# account_id :integer not null
# external_id :string
# submission_id :integer not null
#
# Indexes
#
# index_submitters_on_account_id_and_id (account_id,id)
# index_submitters_on_completed_at_and_account_id (completed_at,account_id)
# index_submitters_on_email (email)
# index_submitters_on_external_id (external_id)
# index_submitters_on_slug (slug) UNIQUE
# index_submitters_on_submission_id (submission_id)
#
# Foreign Keys
#
# fk_rails_... (submission_id => submissions.id)
#
require 'rails_helper'
RSpec.describe Submitter do

@ -0,0 +1,99 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe User do
describe 'validations' do
it 'is valid with valid attributes' do
user = build(:user)
expect(user).to be_valid
end
it 'validates email format' do
user = build(:user, email: 'invalid-email')
expect(user).not_to be_valid
end
it 'validates uniqueness of external_user_id when present' do
account = create(:account)
create(:user, account: account, external_user_id: 123)
duplicate = build(:user, account: account, external_user_id: 123)
expect(duplicate).not_to be_valid
end
end
describe '.find_or_create_by_external_id' do
let(:account) { create(:account) }
let(:external_id) { 123 }
let(:attributes) { { first_name: 'Test', last_name: 'User', email: 'test@example.com' } }
it 'finds existing user by external_user_id' do
existing_user = create(:user, account: account, external_user_id: external_id)
result = described_class.find_or_create_by_external_id(account, external_id, attributes)
expect(result).to eq(existing_user)
end
it 'creates new user when none exists' do
result = described_class.find_or_create_by_external_id(account, external_id, attributes)
expect(result.external_user_id).to eq(external_id)
expect(result.first_name).to eq('Test')
expect(result.email).to eq('test@example.com')
expect(result.password).to be_present
end
end
describe '#active_for_authentication?' do
let(:account) { create(:account) }
let(:user) { create(:user, account: account) }
it 'returns true when user and account are active' do
expect(user.active_for_authentication?).to be true
end
it 'returns false when user is archived' do
user.update!(archived_at: 1.day.ago)
expect(user.active_for_authentication?).to be false
end
it 'returns false when account is archived' do
account.update!(archived_at: 1.day.ago)
expect(user.active_for_authentication?).to be false
end
end
describe '#initials' do
it 'returns initials from first and last name' do
user = build(:user, first_name: 'John', last_name: 'Doe')
expect(user.initials).to eq('JD')
end
it 'handles missing names' do
user = build(:user, first_name: 'John', last_name: nil)
expect(user.initials).to eq('J')
end
end
describe '#full_name' do
it 'combines first and last name' do
user = build(:user, first_name: 'John', last_name: 'Doe')
expect(user.full_name).to eq('John Doe')
end
it 'handles missing names' do
user = build(:user, first_name: 'John', last_name: nil)
expect(user.full_name).to eq('John')
end
end
describe '#friendly_name' do
it 'returns formatted name with email when full name present' do
user = build(:user, first_name: 'John', last_name: 'Doe', email: 'john@example.com')
expect(user.friendly_name).to eq('"John Doe" <john@example.com>')
end
it 'returns just email when no full name' do
user = build(:user, first_name: nil, last_name: nil, email: 'john@example.com')
expect(user.friendly_name).to eq('john@example.com')
end
end
end

@ -0,0 +1,98 @@
# frozen_string_literal: true
describe 'ApplicationController' do
let(:account) { create(:account) }
let(:user) { create(:user, account: account) }
let(:token) { user.access_token.token }
describe 'token authentication methods' do
let(:controller) { ApplicationController.new }
let(:request_double) { instance_double(ActionDispatch::Request, headers: {}) }
before do
allow(controller).to receive_messages(
request: request_double,
params: {},
session: {},
signed_in?: false
)
end
describe '#maybe_authenticate_via_token' do
it 'signs in user with valid token in header' do
request_double_with_token = instance_double(ActionDispatch::Request, headers: { 'X-Auth-Token' => token })
allow(controller).to receive(:request).and_return(request_double_with_token)
allow(controller).to receive(:sign_in)
controller.send(:maybe_authenticate_via_token)
expect(controller).to have_received(:sign_in).with(user)
end
it 'does nothing with invalid token' do
request_double_with_invalid = instance_double(ActionDispatch::Request, headers: { 'X-Auth-Token' => 'invalid' })
allow(controller).to receive(:request).and_return(request_double_with_invalid)
allow(controller).to receive(:sign_in)
controller.send(:maybe_authenticate_via_token)
expect(controller).not_to have_received(:sign_in)
end
end
describe '#authenticate_via_token!' do
it 'renders error with no token' do
allow(controller).to receive(:render)
controller.send(:authenticate_via_token!)
expect(controller).to have_received(:render).with(
json: { error: 'Authentication required. Please provide a valid auth_token.' },
status: :unauthorized
)
end
it 'renders error with invalid token' do
request_double_with_invalid = instance_double(ActionDispatch::Request, headers: { 'X-Auth-Token' => 'invalid' })
allow(controller).to receive(:request).and_return(request_double_with_invalid)
allow(controller).to receive(:render)
controller.send(:authenticate_via_token!)
expect(controller).to have_received(:render).with(
json: { error: 'Authentication required. Please provide a valid auth_token.' },
status: :unauthorized
)
end
it 'does not render error with valid token' do
request_double_with_token = instance_double(ActionDispatch::Request, headers: { 'X-Auth-Token' => token })
allow(controller).to receive(:request).and_return(request_double_with_token)
allow(controller).to receive_messages(sign_in: nil, render: nil)
controller.send(:authenticate_via_token!)
expect(controller).not_to have_received(:render)
expect(controller).to have_received(:sign_in).with(user)
end
end
end
describe 'API authentication' do
context 'with valid token' do
it 'authenticates user' do
get '/api/submissions', headers: { 'X-Auth-Token': token }
expect(response).to have_http_status(:ok)
end
end
context 'with invalid token' do
it 'returns API-specific error message' do
get '/api/submissions', headers: { 'X-Auth-Token': 'invalid_token' }
expect(response).to have_http_status(:unauthorized)
expect(response.parsed_body).to eq({ 'error' => 'Not authenticated' })
end
end
end
end

@ -0,0 +1,36 @@
# frozen_string_literal: true
describe 'External Auth API' do
describe 'POST /api/external_auth/user_token' do
let(:valid_params) do
{
account: {
external_id: '123',
name: 'Test Company'
},
user: {
external_id: '456',
email: 'test@example.com',
first_name: 'John',
last_name: 'Doe'
}
}
end
it 'returns success with access token' do
post '/api/external_auth/user_token', params: valid_params, as: :json
expect(response).to have_http_status(:ok)
expect(response.parsed_body).to have_key('access_token')
end
it 'returns error when params cause exception' do
allow(Account).to receive(:find_or_create_by_external_id).and_raise(StandardError.new('Test error'))
post '/api/external_auth/user_token', params: valid_params, as: :json
expect(response).to have_http_status(:internal_server_error)
expect(response.parsed_body).to eq({ 'error' => 'Internal server error' })
end
end
end

@ -59,4 +59,18 @@ module SigningFormHelper
def template_field(template, field_name)
template.fields.find { |f| f['name'] == field_name || f['title'] == field_name } || {}
end
# Waits for a job to be queued in Sidekiq for the specified job class.
def wait_for_job_to_queue(job_class, timeout: 5)
initial_count = job_class.jobs.size
Timeout.timeout(timeout) do
loop do
break if job_class.jobs.size > initial_count
sleep 0.1
end
end
rescue Timeout::Error
# If timeout occurs, just continue - the test will fail with a more descriptive message
end
end

@ -654,6 +654,7 @@ RSpec.describe 'Signing Form' do
expect do
click_on 'Sign and Complete'
wait_for_job_to_queue(ProcessSubmitterCompletionJob)
end.to change(ProcessSubmitterCompletionJob.jobs, :size).by(1)
end
end

Loading…
Cancel
Save