diff --git a/app/controllers/api/external_auth_controller.rb b/app/controllers/api/external_auth_controller.rb index 497a4a8f..b02c29e7 100644 --- a/app/controllers/api/external_auth_controller.rb +++ b/app/controllers/api/external_auth_controller.rb @@ -6,23 +6,10 @@ module Api 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' - ) + service = ExternalAuthService.new(params) + access_token = service.authenticate_user - 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 } + render json: { access_token: access_token } rescue StandardError => e Rails.logger.error("External auth error: #{e.message}") Rollbar.error(e) if defined?(Rollbar) diff --git a/app/controllers/templates_controller.rb b/app/controllers/templates_controller.rb index 180b082e..f8908f27 100644 --- a/app/controllers/templates_controller.rb +++ b/app/controllers/templates_controller.rb @@ -70,25 +70,17 @@ class TemplatesController < ApplicationController 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 - if params[:account_id].present? && authorized_clone_account_id?(params[:account_id]) - @template.account_id = params[:account_id] - @template.folder = @template.account.default_template_folder if @template.account_id != current_account.id - else - @template.account = current_account + TemplateService.new(@template, current_user, params).assign_ownership end - if @template.save - Templates::CloneAttachments.call(template: @template, original_template: @base_template) if @base_template - - SearchEntries.enqueue_reindex(@template) - - enqueue_template_created_webhooks(@template) + handle_account_override if params[:account_id].present? + if @template.save + handle_successful_template_creation maybe_redirect_to_template(@template) else + Rails.logger.error "Template save failed: #{@template.errors.full_messages.join(', ')}" render turbo_stream: turbo_stream.replace(:modal, template: 'templates/new'), status: :unprocessable_entity end end @@ -126,7 +118,7 @@ class TemplatesController < ApplicationController def template_params params.require(:template).permit( - :name, + :name, :external_id, { schema: [[:attachment_uuid, :name, { conditions: [%i[field_uuid value action operation]] }]], submitters: [%i[name uuid is_requester linked_to_uuid invite_by_uuid optional_invite_by_uuid email]], external_data_fields: {}, @@ -168,6 +160,20 @@ class TemplatesController < ApplicationController end end + def handle_account_override + return unless authorized_clone_account_id?(params[:account_id]) + + @template.account_id = params[:account_id] + @template.account_group = nil + @template.folder = @template.account.default_template_folder if @template.account_id != current_account&.id + end + + def handle_successful_template_creation + Templates::CloneAttachments.call(template: @template, original_template: @base_template) if @base_template + SearchEntries.enqueue_reindex(@template) + enqueue_template_created_webhooks(@template) + end + def load_base_template return if params[:base_template_id].blank? diff --git a/app/models/account.rb b/app/models/account.rb index 4a1b7701..fc9803b5 100644 --- a/app/models/account.rb +++ b/app/models/account.rb @@ -12,16 +12,23 @@ # uuid :string not null # created_at :datetime not null # updated_at :datetime not null +# account_group_id :bigint # external_account_id :integer # # Indexes # +# index_accounts_on_account_group_id (account_group_id) # index_accounts_on_external_account_id (external_account_id) UNIQUE # index_accounts_on_uuid (uuid) UNIQUE # +# Foreign Keys +# +# fk_rails_... (account_group_id => account_groups.id) +# class Account < ApplicationRecord attribute :uuid, :string, default: -> { SecureRandom.uuid } + belongs_to :account_group, optional: true has_many :users, dependent: :destroy has_many :encrypted_configs, dependent: :destroy has_many :account_configs, dependent: :destroy diff --git a/app/models/account_group.rb b/app/models/account_group.rb new file mode 100644 index 00000000..e2bde3f0 --- /dev/null +++ b/app/models/account_group.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +# == Schema Information +# +# Table name: account_groups +# +# id :bigint not null, primary key +# name :string not null +# created_at :datetime not null +# updated_at :datetime not null +# external_account_group_id :integer not null +# +# Indexes +# +# index_account_groups_on_external_account_group_id (external_account_group_id) UNIQUE +# +class AccountGroup < ApplicationRecord + has_many :accounts, dependent: :nullify + has_many :users, dependent: :nullify + has_many :templates, dependent: :destroy + has_many :template_folders, dependent: :destroy + + validates :external_account_group_id, presence: true, uniqueness: true + validates :name, presence: true + + def self.find_or_create_by_external_id(external_id, attributes = {}) + find_by(external_account_group_id: external_id) || + create!(attributes.merge(external_account_group_id: external_id)) + end + + def default_template_folder + template_folders.find_by(name: TemplateFolder::DEFAULT_NAME) || + template_folders.create!(name: TemplateFolder::DEFAULT_NAME, + author_id: users.minimum(:id)) + end +end diff --git a/app/models/concerns/account_group_validation.rb b/app/models/concerns/account_group_validation.rb new file mode 100644 index 00000000..028e03f8 --- /dev/null +++ b/app/models/concerns/account_group_validation.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +module AccountGroupValidation + extend ActiveSupport::Concern + + included do + validate :must_belong_to_account_or_account_group + end + + private + + def must_belong_to_account_or_account_group + if account.blank? && account_group.blank? + errors.add(:base, 'Must belong to either an account or account group') + elsif account.present? && account_group.present? + errors.add(:base, 'Cannot belong to both account and account group') + end + end +end diff --git a/app/models/template.rb b/app/models/template.rb index 315c580c..34cffdba 100644 --- a/app/models/template.rb +++ b/app/models/template.rb @@ -17,13 +17,15 @@ # submitters :text not null # created_at :datetime not null # updated_at :datetime not null -# account_id :integer not null +# account_group_id :bigint +# account_id :integer # author_id :integer not null # external_id :string # folder_id :integer not null # # Indexes # +# index_templates_on_account_group_id (account_group_id) # index_templates_on_account_id (account_id) # index_templates_on_account_id_and_folder_id_and_id (account_id,folder_id,id) WHERE (archived_at IS NULL) # index_templates_on_account_id_and_id_archived (account_id,id) WHERE (archived_at IS NOT NULL) @@ -34,15 +36,19 @@ # # Foreign Keys # +# fk_rails_... (account_group_id => account_groups.id) # fk_rails_... (account_id => accounts.id) # fk_rails_... (author_id => users.id) # fk_rails_... (folder_id => template_folders.id) # class Template < ApplicationRecord + include AccountGroupValidation + DEFAULT_SUBMITTER_NAME = 'Employee' belongs_to :author, class_name: 'User' - belongs_to :account + belongs_to :account, optional: true + belongs_to :account_group, optional: true belongs_to :folder, class_name: 'TemplateFolder' has_one :search_entry, as: :record, inverse_of: :record, dependent: :destroy @@ -84,6 +90,10 @@ class Template < ApplicationRecord private def maybe_set_default_folder - self.folder ||= account.default_template_folder + if account.present? + self.folder ||= account.default_template_folder + elsif account_group.present? + self.folder ||= account_group.default_template_folder + end end end diff --git a/app/models/template_folder.rb b/app/models/template_folder.rb index c470b221..c06ffeed 100644 --- a/app/models/template_folder.rb +++ b/app/models/template_folder.rb @@ -4,29 +4,35 @@ # # Table name: template_folders # -# id :bigint not null, primary key -# archived_at :datetime -# name :string not null -# created_at :datetime not null -# updated_at :datetime not null -# account_id :integer not null -# author_id :integer not null +# id :bigint not null, primary key +# archived_at :datetime +# name :string not null +# created_at :datetime not null +# updated_at :datetime not null +# account_group_id :bigint +# account_id :integer +# author_id :integer not null # # Indexes # -# index_template_folders_on_account_id (account_id) -# index_template_folders_on_author_id (author_id) +# index_template_folders_on_account_group_id (account_group_id) +# index_template_folders_on_account_id (account_id) +# index_template_folders_on_author_id (author_id) # # Foreign Keys # +# fk_rails_... (account_group_id => account_groups.id) # fk_rails_... (account_id => accounts.id) # fk_rails_... (author_id => users.id) # class TemplateFolder < ApplicationRecord + include AccountGroupValidation + DEFAULT_NAME = 'Default' belongs_to :author, class_name: 'User' - belongs_to :account + belongs_to :account, optional: true + belongs_to :account_group, optional: true has_many :templates, dependent: :destroy, foreign_key: :folder_id, inverse_of: :folder has_many :active_templates, -> { where(archived_at: nil) }, diff --git a/app/models/user.rb b/app/models/user.rb index 542924ce..0649504b 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -28,11 +28,13 @@ # uuid :string not null # created_at :datetime not null # updated_at :datetime not null -# account_id :integer not null +# account_group_id :bigint +# account_id :integer # external_user_id :integer # # Indexes # +# index_users_on_account_group_id (account_group_id) # index_users_on_account_id (account_id) # index_users_on_email (email) UNIQUE # index_users_on_external_user_id (external_user_id) UNIQUE @@ -42,9 +44,12 @@ # # Foreign Keys # +# fk_rails_... (account_group_id => account_groups.id) # fk_rails_... (account_id => accounts.id) # class User < ApplicationRecord + include AccountGroupValidation + ROLES = [ ADMIN_ROLE = 'admin' ].freeze @@ -57,7 +62,9 @@ class User < ApplicationRecord has_one_attached :signature has_one_attached :initials - belongs_to :account + belongs_to :account, optional: true + belongs_to :account_group, optional: true + has_one :access_token, dependent: :destroy has_many :access_tokens, dependent: :destroy has_many :templates, dependent: :destroy, foreign_key: :author_id, inverse_of: :author @@ -88,12 +95,24 @@ class User < ApplicationRecord ) end + def self.find_or_create_by_external_group_id(account_group, external_id, attributes = {}) + account_group.users.find_by(external_user_id: external_id) || + account_group.users.create!( + attributes.merge( + external_user_id: external_id, + password: SecureRandom.hex(16) + ) + ) + end + def access_token super || build_access_token.tap(&:save!) end def active_for_authentication? - super && !archived_at? && !account.archived_at? + return false unless account.present? || account_group.present? + + super && !archived_at? && !account&.archived_at? end def remember_me diff --git a/app/services/external_auth_service.rb b/app/services/external_auth_service.rb new file mode 100644 index 00000000..569f85ca --- /dev/null +++ b/app/services/external_auth_service.rb @@ -0,0 +1,58 @@ +# frozen_string_literal: true + +class ExternalAuthService + def initialize(params) + @params = params + end + + def authenticate_user + user = if @params[:account].present? + find_or_create_user_with_account + elsif @params[:account_group].present? + find_or_create_user_with_account_group + else + raise ArgumentError, 'Either account or account_group params must be provided' + end + + user.access_token.token + end + + private + + def find_or_create_user_with_account + 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.find_or_create_by_external_id( + account, + @params[:user][:external_id]&.to_i, + user_attributes + ) + end + + def find_or_create_user_with_account_group + account_group = AccountGroup.find_or_create_by_external_id( + @params[:account_group][:external_id], + name: @params[:account_group][:name] + ) + + User.find_or_create_by_external_group_id( + account_group, + @params[:user][:external_id]&.to_i, + user_attributes + ) + end + + def user_attributes + { + email: @params[:user][:email], + first_name: @params[:user][:first_name], + last_name: @params[:user][:last_name], + role: 'admin' + } + end +end diff --git a/app/services/template_service.rb b/app/services/template_service.rb new file mode 100644 index 00000000..a91b967b --- /dev/null +++ b/app/services/template_service.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +class TemplateService + def initialize(template, user, params = {}) + @template = template + @user = user + @params = params + end + + def assign_ownership + if @user.account_group.present? + @template.account_group = @user.account_group + @template.folder = @user.account_group.default_template_folder + elsif @user.account.present? + @template.account = @user.account + @template.folder = TemplateFolders.find_or_create_by_name(@user, @params[:folder_name]) + end + end +end diff --git a/config/database.yml b/config/database.yml index 35795896..27fc97c2 100644 --- a/config/database.yml +++ b/config/database.yml @@ -9,6 +9,7 @@ default: &default database: <%= ENV['DB_NAME'] %> development: + <<: *default database: docuseal_development pool: 5 username: postgres @@ -16,6 +17,7 @@ development: host: localhost test: + <<: *default database: docuseal_test pool: 5 username: postgres diff --git a/db/migrate/20250910191227_create_account_groups_and_add_relationships.rb b/db/migrate/20250910191227_create_account_groups_and_add_relationships.rb new file mode 100644 index 00000000..4ceebdc2 --- /dev/null +++ b/db/migrate/20250910191227_create_account_groups_and_add_relationships.rb @@ -0,0 +1,21 @@ +class CreateAccountGroupsAndAddRelationships < ActiveRecord::Migration[8.0] + def change + create_table :account_groups do |t| + t.integer :external_account_group_id, null: false + t.string :name, null: false + + t.timestamps + end + add_index :account_groups, :external_account_group_id, unique: true + + add_reference :accounts, :account_group, foreign_key: true + add_reference :templates, :account_group, foreign_key: true + add_reference :users, :account_group, foreign_key: true + add_reference :template_folders, :account_group, foreign_key: true + + # Make account_ids nullable since records can belong to either account or account_group + change_column_null :templates, :account_id, true + change_column_null :users, :account_id, true + change_column_null :template_folders, :account_id, true + end +end diff --git a/db/schema.rb b/db/schema.rb index ef23e9e3..626e9dac 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -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_14_214357) do +ActiveRecord::Schema[8.0].define(version: 2025_09_10_191227) do # These are extensions that must be enabled in order to support this database enable_extension "btree_gin" enable_extension "pg_catalog.plpgsql" @@ -43,6 +43,14 @@ ActiveRecord::Schema[8.0].define(version: 2025_08_14_214357) do t.index ["account_id"], name: "index_account_configs_on_account_id" end + create_table "account_groups", force: :cascade do |t| + t.integer "external_account_group_id", null: false + t.string "name", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["external_account_group_id"], name: "index_account_groups_on_external_account_group_id", unique: true + end + create_table "account_linked_accounts", force: :cascade do |t| t.integer "account_id", null: false t.integer "linked_account_id", null: false @@ -63,6 +71,8 @@ ActiveRecord::Schema[8.0].define(version: 2025_08_14_214357) do t.string "uuid", null: false t.datetime "archived_at" t.integer "external_account_id" + t.bigint "account_group_id" + t.index ["account_group_id"], name: "index_accounts_on_account_group_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 @@ -363,10 +373,12 @@ ActiveRecord::Schema[8.0].define(version: 2025_08_14_214357) do create_table "template_folders", force: :cascade do |t| t.string "name", null: false t.integer "author_id", null: false - t.integer "account_id", null: false + t.integer "account_id" t.datetime "archived_at" t.datetime "created_at", null: false t.datetime "updated_at", null: false + t.bigint "account_group_id" + t.index ["account_group_id"], name: "index_template_folders_on_account_group_id" t.index ["account_id"], name: "index_template_folders_on_account_id" t.index ["author_id"], name: "index_template_folders_on_author_id" end @@ -388,7 +400,7 @@ ActiveRecord::Schema[8.0].define(version: 2025_08_14_214357) do t.text "fields", null: false t.text "submitters", null: false t.integer "author_id", null: false - t.integer "account_id", null: false + t.integer "account_id" t.datetime "archived_at" t.datetime "created_at", null: false t.datetime "updated_at", null: false @@ -398,6 +410,8 @@ ActiveRecord::Schema[8.0].define(version: 2025_08_14_214357) do t.text "preferences", null: false t.boolean "shared_link", default: false, null: false t.text "external_data_fields" + t.bigint "account_group_id" + t.index ["account_group_id"], name: "index_templates_on_account_group_id" t.index ["account_id", "folder_id", "id"], name: "index_templates_on_account_id_and_folder_id_and_id", where: "(archived_at IS NULL)" t.index ["account_id", "id"], name: "index_templates_on_account_id_and_id_archived", where: "(archived_at IS NOT NULL)" t.index ["account_id"], name: "index_templates_on_account_id" @@ -423,7 +437,7 @@ ActiveRecord::Schema[8.0].define(version: 2025_08_14_214357) do t.string "email", null: false t.string "role", null: false t.string "encrypted_password", null: false - t.integer "account_id", null: false + t.integer "account_id" t.string "reset_password_token" t.datetime "reset_password_sent_at" t.datetime "remember_created_at" @@ -443,6 +457,8 @@ ActiveRecord::Schema[8.0].define(version: 2025_08_14_214357) do t.integer "consumed_timestep" t.boolean "otp_required_for_login", default: false, null: false t.integer "external_user_id" + t.bigint "account_group_id" + t.index ["account_group_id"], name: "index_users_on_account_group_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 @@ -468,6 +484,7 @@ ActiveRecord::Schema[8.0].define(version: 2025_08_14_214357) do add_foreign_key "account_configs", "accounts" add_foreign_key "account_linked_accounts", "accounts" add_foreign_key "account_linked_accounts", "accounts", column: "linked_account_id" + add_foreign_key "accounts", "account_groups" add_foreign_key "active_storage_attachments", "active_storage_blobs", column: "blob_id" add_foreign_key "active_storage_variant_records", "active_storage_blobs", column: "blob_id" add_foreign_key "document_generation_events", "submitters" @@ -486,13 +503,16 @@ ActiveRecord::Schema[8.0].define(version: 2025_08_14_214357) do add_foreign_key "submissions", "users", column: "created_by_user_id" add_foreign_key "submitters", "submissions" add_foreign_key "template_accesses", "templates" + add_foreign_key "template_folders", "account_groups" add_foreign_key "template_folders", "accounts" add_foreign_key "template_folders", "users", column: "author_id" add_foreign_key "template_sharings", "templates" + add_foreign_key "templates", "account_groups" add_foreign_key "templates", "accounts" add_foreign_key "templates", "template_folders", column: "folder_id" add_foreign_key "templates", "users", column: "author_id" add_foreign_key "user_configs", "users" + add_foreign_key "users", "account_groups" add_foreign_key "users", "accounts" add_foreign_key "webhook_urls", "accounts" end diff --git a/lib/abilities/template_conditions.rb b/lib/abilities/template_conditions.rb index 32afd8a3..6fa54ba1 100644 --- a/lib/abilities/template_conditions.rb +++ b/lib/abilities/template_conditions.rb @@ -5,21 +5,32 @@ module Abilities module_function def collection(user, ability: nil) - templates = Template.where(account_id: user.account_id) + if user.account_id.present? + templates = Template.where(account_id: user.account_id) - return templates unless user.account.testing? + return templates unless user.account.testing? - shared_ids = - TemplateSharing.where({ ability:, account_id: [user.account_id, TemplateSharing::ALL_ID] }.compact) - .select(:template_id) + shared_ids = + TemplateSharing.where({ ability:, account_id: [user.account_id, TemplateSharing::ALL_ID] }.compact) + .select(:template_id) - Template.where(Template.arel_table[:id].in(Arel::Nodes::Union.new(templates.select(:id).arel, shared_ids.arel))) + Template.where(Template.arel_table[:id].in(Arel::Nodes::Union.new(templates.select(:id).arel, shared_ids.arel))) + elsif user.account_group_id.present? + Template.where(account_group_id: user.account_group_id) + else + Template.none + end end def entity(template, user:, ability: nil) - return true if template.account_id.blank? + return true if template.account_id.blank? && template.account_group_id.blank? + + # Handle account group templates + return template.account_group_id == user.account_group_id if template.account_group_id.present? + + # Handle regular account templates return true if template.account_id == user.account_id - return false unless user.account.linked_account_account + 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] diff --git a/lib/ability.rb b/lib/ability.rb index 79942e35..a721e089 100644 --- a/lib/ability.rb +++ b/lib/ability.rb @@ -4,9 +4,6 @@ class Ability include CanCan::Ability def initialize(user) - # Allow template creation for any authenticated user (for testing) - can :create, Template - can %i[read create update], Template, Abilities::TemplateConditions.collection(user) do |template| Abilities::TemplateConditions.entity(template, user:, ability: 'manage') end diff --git a/spec/factories/account_groups.rb b/spec/factories/account_groups.rb new file mode 100644 index 00000000..4c34d847 --- /dev/null +++ b/spec/factories/account_groups.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :account_group do + external_account_group_id { Faker::Number.unique.number(digits: 8) } + name { Faker::Company.name } + end +end diff --git a/spec/models/account_group_spec.rb b/spec/models/account_group_spec.rb new file mode 100644 index 00000000..bc291c26 --- /dev/null +++ b/spec/models/account_group_spec.rb @@ -0,0 +1,56 @@ +# frozen_string_literal: true + +# == Schema Information +# +# Table name: account_groups +# +# id :bigint not null, primary key +# name :string not null +# created_at :datetime not null +# updated_at :datetime not null +# external_account_group_id :integer not null +# +# Indexes +# +# index_account_groups_on_external_account_group_id (external_account_group_id) UNIQUE +# +describe AccountGroup do + let(:account_group) { create(:account_group) } + + describe 'associations' do + it 'has many accounts' do + expect(account_group).to respond_to(:accounts) + end + end + + describe 'validations' do + it 'validates presence of external_account_group_id' do + account_group = build(:account_group, external_account_group_id: nil) + expect(account_group).not_to be_valid + expect(account_group.errors[:external_account_group_id]).to include("can't be blank") + end + + it 'validates uniqueness of external_account_group_id' do + create(:account_group, external_account_group_id: 123) + duplicate = build(:account_group, external_account_group_id: 123) + expect(duplicate).not_to be_valid + expect(duplicate.errors[:external_account_group_id]).to include('has already been taken') + end + + it 'validates presence of name' do + account_group = build(:account_group, name: nil) + expect(account_group).not_to be_valid + expect(account_group.errors[:name]).to include("can't be blank") + end + end + + describe 'when account group is destroyed' do + it 'nullifies accounts account_group_id' do + account = create(:account, account_group: account_group) + + account_group.destroy + + expect(account.reload.account_group).to be_nil + end + end +end diff --git a/spec/models/account_spec.rb b/spec/models/account_spec.rb index aa23cda7..f9986e52 100644 --- a/spec/models/account_spec.rb +++ b/spec/models/account_spec.rb @@ -1,5 +1,30 @@ # frozen_string_literal: true +# == Schema Information +# +# 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 +# account_group_id :bigint +# external_account_id :integer +# +# Indexes +# +# index_accounts_on_account_group_id (account_group_id) +# index_accounts_on_external_account_id (external_account_id) UNIQUE +# index_accounts_on_uuid (uuid) UNIQUE +# +# Foreign Keys +# +# fk_rails_... (account_group_id => account_groups.id) +# require 'rails_helper' RSpec.describe Account do @@ -44,6 +69,17 @@ RSpec.describe Account do end end + describe 'account_group association' do + it 'belongs to account_group optionally' do + account = create(:account) + expect(account.account_group).to be_nil + + account_group = create(:account_group) + account.update!(account_group: account_group) + expect(account.reload.account_group).to eq(account_group) + end + end + describe '#default_template_folder' do it 'creates default folder when none exists' do account = create(:account) diff --git a/spec/models/concerns/account_group_validation_spec.rb b/spec/models/concerns/account_group_validation_spec.rb new file mode 100644 index 00000000..e0d74e1d --- /dev/null +++ b/spec/models/concerns/account_group_validation_spec.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe AccountGroupValidation do + # Test with User model since it includes the concern + describe 'validation' do + context 'with account only' do + it 'is valid' do + user = build(:user, account: create(:account), account_group: nil) + expect(user).to be_valid + end + end + + context 'with account_group only' do + it 'is valid' do + user = build(:user, account: nil, account_group: create(:account_group)) + expect(user).to be_valid + end + end + + context 'with neither account nor account_group' do + it 'is invalid' do + user = build(:user, account: nil, account_group: nil) + expect(user).not_to be_valid + expect(user.errors[:base]).to include('Must belong to either an account or account group') + end + end + + context 'with both account and account_group' do + it 'is invalid' do + user = build(:user, account: create(:account), account_group: create(:account_group)) + expect(user).not_to be_valid + expect(user.errors[:base]).to include('Cannot belong to both account and account group') + end + end + end +end diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb index 009f02fd..2fe25f43 100644 --- a/spec/models/user_spec.rb +++ b/spec/models/user_spec.rb @@ -1,5 +1,52 @@ # frozen_string_literal: true +# == Schema Information +# +# Table name: users +# +# id :bigint not null, primary key +# archived_at :datetime +# consumed_timestep :integer +# current_sign_in_at :datetime +# current_sign_in_ip :string +# email :string not null +# encrypted_password :string not null +# failed_attempts :integer default(0), not null +# first_name :string +# last_name :string +# last_sign_in_at :datetime +# last_sign_in_ip :string +# locked_at :datetime +# otp_required_for_login :boolean default(FALSE), not null +# otp_secret :string +# remember_created_at :datetime +# reset_password_sent_at :datetime +# reset_password_token :string +# role :string not null +# sign_in_count :integer default(0), not null +# unlock_token :string +# uuid :string not null +# created_at :datetime not null +# updated_at :datetime not null +# account_group_id :bigint +# account_id :integer +# external_user_id :integer +# +# Indexes +# +# index_users_on_account_group_id (account_group_id) +# 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 +# +# Foreign Keys +# +# fk_rails_... (account_group_id => account_groups.id) +# fk_rails_... (account_id => accounts.id) +# require 'rails_helper' RSpec.describe User do @@ -96,4 +143,26 @@ RSpec.describe User do expect(user.friendly_name).to eq('john@example.com') end end + + describe '.find_or_create_by_external_group_id' do + let(:account_group) { create(:account_group) } + let(:attributes) { { email: 'test@example.com', first_name: 'John' } } + + it 'finds existing user by external_user_id and account_group' do + existing_user = create(:user, account: nil, account_group: account_group, external_user_id: 123) + + result = described_class.find_or_create_by_external_group_id(account_group, 123, attributes) + + expect(result).to eq(existing_user) + end + + it 'creates new user when not found' do + result = described_class.find_or_create_by_external_group_id(account_group, 456, attributes) + + expect(result.account_group).to eq(account_group) + expect(result.external_user_id).to eq(456) + expect(result.email).to eq('test@example.com') + expect(result.password).to be_present + end + end end diff --git a/spec/services/external_auth_service_spec.rb b/spec/services/external_auth_service_spec.rb new file mode 100644 index 00000000..b1422177 --- /dev/null +++ b/spec/services/external_auth_service_spec.rb @@ -0,0 +1,68 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe ExternalAuthService do + describe '#authenticate_user' do + let(:user_params) do + { + external_id: 123, + email: 'test@example.com', + first_name: 'John', + last_name: 'Doe' + } + end + + context 'with account params' do + let(:params) do + { + account: { external_id: 456, name: 'Test Account' }, + user: user_params + } + end + + it 'returns access token for new account and user' do + token = described_class.new(params).authenticate_user + + expect(token).to be_present + expect(Account.last.external_account_id).to eq(456) + expect(User.last.external_user_id).to eq(123) + end + + it 'returns access token for existing user' do + account = create(:account, external_account_id: 456) + user = create(:user, account: account, external_user_id: 123) + + token = described_class.new(params).authenticate_user + + expect(token).to eq(user.access_token.token) + end + end + + context 'with account_group params' do + let(:params) do + { + account_group: { external_id: 789, name: 'Test Group' }, + user: user_params + } + end + + it 'returns access token for new account_group and user' do + token = described_class.new(params).authenticate_user + + expect(token).to be_present + expect(AccountGroup.last.external_account_group_id).to eq(789) + expect(User.last.external_user_id).to eq(123) + end + end + + context 'with invalid params' do + it 'raises error when neither account nor account_group provided' do + params = { user: user_params } + + expect { described_class.new(params).authenticate_user } + .to raise_error(ArgumentError, 'Either account or account_group params must be provided') + end + end + end +end diff --git a/spec/services/template_service_spec.rb b/spec/services/template_service_spec.rb new file mode 100644 index 00000000..141ac9c5 --- /dev/null +++ b/spec/services/template_service_spec.rb @@ -0,0 +1,48 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe TemplateService do + describe '#assign_ownership' do + let(:template) { build(:template, account: nil, account_group: nil) } + let(:params) { { folder_name: 'Custom Folder' } } + + context 'with account_group user' do + let(:account_group) { create(:account_group) } + let(:user) { create(:user, account: nil, account_group: account_group) } + + it 'assigns account_group and default folder' do + service = described_class.new(template, user, params) + service.assign_ownership + + expect(template.account_group).to eq(account_group) + expect(template.folder).to eq(account_group.default_template_folder) + end + end + + context 'with account user' do + let(:account) { create(:account) } + let(:user) { create(:user, account: account, account_group: nil) } + + it 'assigns account and finds/creates folder' do + service = described_class.new(template, user, params) + service.assign_ownership + + expect(template.account).to eq(account) + expect(template.folder).to be_present + end + end + + context 'with user having neither account nor account_group' do + let(:user) { build(:user, account: nil, account_group: nil) } + + it 'does not assign ownership' do + service = described_class.new(template, user, params) + service.assign_ownership + + expect(template.account).to be_nil + expect(template.account_group).to be_nil + end + end + end +end