├── Gemfile ├── .yardopts ├── spec ├── rails_app │ ├── app │ │ └── assets │ │ │ └── config │ │ │ └── manifest.js │ └── config │ │ ├── routes.rb │ │ ├── initializers │ │ ├── inflections.rb │ │ ├── secret_token.rb │ │ └── backtrace_silencers.rb │ │ ├── environment.rb │ │ ├── database.yml │ │ ├── application.rb │ │ └── environments │ │ └── test.rb ├── support │ └── active_record │ │ ├── postgres │ │ ├── 1_change_audited_changes_type_to_json.rb │ │ └── 2_change_audited_changes_type_to_jsonb.rb │ │ ├── schema.rb │ │ └── models.rb ├── audited_spec.rb ├── spec_helper.rb ├── audited_spec_helpers.rb └── audited │ ├── rspec_matchers_spec.rb │ ├── sweeper_spec.rb │ ├── audit_spec.rb │ └── auditor_spec.rb ├── lib ├── audited │ ├── version.rb │ ├── railtie.rb │ ├── sweeper.rb │ ├── audit.rb │ ├── rspec_matchers.rb │ └── auditor.rb ├── audited-rspec.rb ├── generators │ └── audited │ │ ├── migration_helper.rb │ │ ├── templates │ │ ├── add_comment_to_audits.rb │ │ ├── add_remote_address_to_audits.rb │ │ ├── rename_changes_to_audited_changes.rb │ │ ├── add_request_uuid_to_audits.rb │ │ ├── add_association_to_audits.rb │ │ ├── rename_parent_to_association.rb │ │ ├── add_version_to_auditable_index.rb │ │ ├── revert_polymorphic_indexes_order.rb │ │ ├── rename_association_to_associated.rb │ │ └── install.rb │ │ ├── migration.rb │ │ ├── install_generator.rb │ │ └── upgrade_generator.rb └── audited.rb ├── .standard.yml ├── .gitignore ├── gemfiles ├── rails70.gemfile ├── rails71.gemfile ├── rails80.gemfile ├── rails60.gemfile ├── rails61.gemfile ├── rails50.gemfile ├── rails51.gemfile └── rails52.gemfile ├── .github └── workflows │ ├── buildlight.yml │ ├── publish_gem.yml │ └── ci.yml ├── Rakefile ├── test ├── test_helper.rb ├── db │ ├── version_5.rb │ ├── version_1.rb │ ├── version_2.rb │ ├── version_3.rb │ ├── version_6.rb │ └── version_4.rb ├── install_generator_test.rb └── upgrade_generator_test.rb ├── LICENSE ├── audited.gemspec ├── Appraisals ├── README.md └── CHANGELOG.md /Gemfile: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | 3 | gemspec name: "audited" 4 | -------------------------------------------------------------------------------- /.yardopts: -------------------------------------------------------------------------------- 1 | --no-private 2 | --title acts_as_audited 3 | --exclude lib/generators 4 | -------------------------------------------------------------------------------- /spec/rails_app/app/assets/config/manifest.js: -------------------------------------------------------------------------------- 1 | //= link application.js 2 | //= link application.css 3 | -------------------------------------------------------------------------------- /spec/rails_app/config/routes.rb: -------------------------------------------------------------------------------- 1 | Rails.application.routes.draw do 2 | resources :audits 3 | end 4 | -------------------------------------------------------------------------------- /lib/audited/version.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Audited 4 | VERSION = "5.6.0" 5 | end 6 | -------------------------------------------------------------------------------- /spec/rails_app/config/initializers/inflections.rb: -------------------------------------------------------------------------------- 1 | ActiveSupport::Inflector.inflections do |inflect| 2 | end 3 | -------------------------------------------------------------------------------- /.standard.yml: -------------------------------------------------------------------------------- 1 | ruby_version: 2.3 2 | ignore: 3 | - lib/generators/audited/templates/**/* 4 | - vendor/bundle/**/* 5 | - gemfiles/vendor/bundle/**/* 6 | -------------------------------------------------------------------------------- /lib/audited-rspec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "audited/rspec_matchers" 4 | module RSpec::Matchers 5 | include Audited::RspecMatchers 6 | end 7 | -------------------------------------------------------------------------------- /spec/rails_app/config/environment.rb: -------------------------------------------------------------------------------- 1 | # Load the rails application 2 | require File.expand_path("../application", __FILE__) 3 | 4 | # Initialize the rails application 5 | RailsApp::Application.initialize! 6 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.iml 2 | *.ipr 3 | *.iws 4 | *.log 5 | *.swp 6 | .bundle 7 | .rakeTasks 8 | .ruby-gemset 9 | .ruby-version 10 | .rvmrc 11 | .yardoc 12 | doc/ 13 | Gemfile.lock 14 | gemfiles/*.lock 15 | pkg 16 | tmp/* 17 | audited_test.sqlite3.db 18 | -------------------------------------------------------------------------------- /gemfiles/rails70.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "rails", ">= 7.0.0", "< 7.1" 6 | gem "mysql2", ">= 0.4.4" 7 | gem "pg", ">= 1.1" 8 | gem "sqlite3", ">= 1.4" 9 | 10 | gemspec name: "audited", path: "../" 11 | -------------------------------------------------------------------------------- /gemfiles/rails71.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "rails", ">= 7.1.0.beta1", "< 7.2" 6 | gem "mysql2", ">= 0.4.4" 7 | gem "pg", ">= 1.1" 8 | gem "sqlite3", ">= 1.4" 9 | 10 | gemspec name: "audited", path: "../" 11 | -------------------------------------------------------------------------------- /gemfiles/rails80.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "rails", ">= 7.1.0.beta1", "< 8.1" 6 | gem "mysql2", ">= 0.4.4" 7 | gem "pg", ">= 1.1" 8 | gem "sqlite3", ">= 1.4" 9 | 10 | gemspec name: "audited", path: "../" 11 | -------------------------------------------------------------------------------- /gemfiles/rails60.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "rails", ">= 6.0.0", "< 6.1" 6 | gem "mysql2", ">= 0.4.4" 7 | gem "pg", ">= 0.18", "< 2.0" 8 | gem "sqlite3", "~> 1.4" 9 | 10 | gemspec name: "audited", path: "../" 11 | -------------------------------------------------------------------------------- /gemfiles/rails61.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "rails", ">= 6.1.0", "< 6.2" 6 | gem "mysql2", ">= 0.4.4" 7 | gem "pg", ">= 1.1", "< 2.0" 8 | gem "sqlite3", "~> 1.4" 9 | 10 | gemspec name: "audited", path: "../" 11 | -------------------------------------------------------------------------------- /.github/workflows/buildlight.yml: -------------------------------------------------------------------------------- 1 | name: Buildlight 2 | 3 | on: 4 | workflow_run: 5 | workflows: 6 | - CI 7 | branches: 8 | - main 9 | 10 | jobs: 11 | webhook: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - name: Webhook 15 | uses: collectiveidea/buildlight@main 16 | -------------------------------------------------------------------------------- /lib/generators/audited/migration_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Audited 4 | module Generators 5 | module MigrationHelper 6 | def migration_parent 7 | "ActiveRecord::Migration[#{ActiveRecord::Migration.current_version}]" 8 | end 9 | end 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /lib/generators/audited/templates/add_comment_to_audits.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class <%= migration_class_name %> < <%= migration_parent %> 4 | def self.up 5 | add_column :audits, :comment, :string 6 | end 7 | 8 | def self.down 9 | remove_column :audits, :comment 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /gemfiles/rails50.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "rails", "~> 5.0.0" 6 | gem "mysql2", ">= 0.3.18", "< 0.6.0" 7 | gem "pg", ">= 0.18", "< 2.0" 8 | gem "sqlite3", "~> 1.3.6" 9 | gem "psych", "~> 3.1" 10 | gem "loofah", "2.20.0" 11 | 12 | gemspec name: "audited", path: "../" 13 | -------------------------------------------------------------------------------- /gemfiles/rails51.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "rails", "~> 5.1.4" 6 | gem "mysql2", ">= 0.3.18", "< 0.6.0" 7 | gem "pg", ">= 0.18", "< 2.0" 8 | gem "sqlite3", "~> 1.3.6" 9 | gem "psych", "~> 3.1" 10 | gem "loofah", "2.20.0" 11 | 12 | gemspec name: "audited", path: "../" 13 | -------------------------------------------------------------------------------- /lib/generators/audited/templates/add_remote_address_to_audits.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class <%= migration_class_name %> < <%= migration_parent %> 4 | def self.up 5 | add_column :audits, :remote_address, :string 6 | end 7 | 8 | def self.down 9 | remove_column :audits, :remote_address 10 | end 11 | end 12 | 13 | -------------------------------------------------------------------------------- /spec/rails_app/config/initializers/secret_token.rb: -------------------------------------------------------------------------------- 1 | Rails.application.config.secret_token = "ea942c41850d502f2c8283e26bdc57829f471bb18224ddff0a192c4f32cdf6cb5aa0d82b3a7a7adbeb640c4b06f3aa1cd5f098162d8240f669b39d6b49680571" 2 | Rails.application.config.session_store :cookie_store, key: "_my_app" 3 | Rails.application.config.secret_key_base = "secret value" 4 | -------------------------------------------------------------------------------- /gemfiles/rails52.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "rails", ">= 5.2.8.1", "< 5.3" 6 | gem "mysql2", ">= 0.4.4", "< 0.6.0" 7 | gem "pg", ">= 0.18", "< 2.0" 8 | gem "sqlite3", "~> 1.3.6" 9 | gem "psych", "~> 3.1" 10 | gem "loofah", "2.20.0" 11 | 12 | gemspec name: "audited", path: "../" 13 | -------------------------------------------------------------------------------- /lib/generators/audited/templates/rename_changes_to_audited_changes.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class <%= migration_class_name %> < <%= migration_parent %> 4 | def self.up 5 | rename_column :audits, :changes, :audited_changes 6 | end 7 | 8 | def self.down 9 | rename_column :audits, :audited_changes, :changes 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /lib/generators/audited/templates/add_request_uuid_to_audits.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class <%= migration_class_name %> < <%= migration_parent %> 4 | def self.up 5 | add_column :audits, :request_uuid, :string 6 | add_index :audits, :request_uuid 7 | end 8 | 9 | def self.down 10 | remove_column :audits, :request_uuid 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env rake 2 | 3 | require "bundler/gem_tasks" 4 | require "rspec/core/rake_task" 5 | require "rake/testtask" 6 | require "appraisal" 7 | 8 | RSpec::Core::RakeTask.new(:spec) 9 | 10 | Rake::TestTask.new do |t| 11 | t.libs << "test" 12 | t.test_files = FileList["test/**/*_test.rb"] 13 | t.verbose = true 14 | end 15 | 16 | task default: [:spec, :test] 17 | -------------------------------------------------------------------------------- /spec/support/active_record/postgres/1_change_audited_changes_type_to_json.rb: -------------------------------------------------------------------------------- 1 | class ChangeAuditedChangesTypeToJson < ActiveRecord::Migration[5.0] 2 | def self.up 3 | remove_column :audits, :audited_changes 4 | add_column :audits, :audited_changes, :json 5 | end 6 | 7 | def self.down 8 | remove_column :audits, :audited_changes 9 | add_column :audits, :audited_changes, :text 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /spec/support/active_record/postgres/2_change_audited_changes_type_to_jsonb.rb: -------------------------------------------------------------------------------- 1 | class ChangeAuditedChangesTypeToJsonb < ActiveRecord::Migration[5.0] 2 | def self.up 3 | remove_column :audits, :audited_changes 4 | add_column :audits, :audited_changes, :jsonb 5 | end 6 | 7 | def self.down 8 | remove_column :audits, :audited_changes 9 | add_column :audits, :audited_changes, :text 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /lib/generators/audited/templates/add_association_to_audits.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class <%= migration_class_name %> < <%= migration_parent %> 4 | def self.up 5 | add_column :audits, :association_id, :integer 6 | add_column :audits, :association_type, :string 7 | end 8 | 9 | def self.down 10 | remove_column :audits, :association_type 11 | remove_column :audits, :association_id 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /spec/rails_app/config/initializers/backtrace_silencers.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # You can add backtrace silencers for libraries that you're using but don't wish to see in your backtraces. 4 | # Rails.backtrace_cleaner.add_silencer { |line| line =~ /my_noisy_library/ } 5 | 6 | # You can also remove all the silencers if you're trying to debug a problem that might stem from framework code. 7 | Rails.backtrace_cleaner.remove_silencers! 8 | -------------------------------------------------------------------------------- /test/test_helper.rb: -------------------------------------------------------------------------------- 1 | ENV["RAILS_ENV"] = "test" 2 | 3 | $LOAD_PATH.unshift File.dirname(__FILE__) 4 | 5 | require File.expand_path("../../spec/rails_app/config/environment", __FILE__) 6 | require "rails/test_help" 7 | 8 | require "audited" 9 | 10 | class ActiveSupport::TestCase 11 | setup do 12 | ActiveRecord::Migration.verbose = false 13 | end 14 | 15 | def load_schema(version) 16 | load File.dirname(__FILE__) + "/db/version_#{version}.rb" 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /lib/generators/audited/templates/rename_parent_to_association.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class <%= migration_class_name %> < <%= migration_parent %> 4 | def self.up 5 | rename_column :audits, :auditable_parent_id, :association_id 6 | rename_column :audits, :auditable_parent_type, :association_type 7 | end 8 | 9 | def self.down 10 | rename_column :audits, :association_type, :auditable_parent_type 11 | rename_column :audits, :association_id, :auditable_parent_id 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /lib/audited/railtie.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Audited 4 | class Railtie < Rails::Railtie 5 | initializer "audited.sweeper" do 6 | ActiveSupport.on_load(:action_controller) do 7 | if defined?(ActionController::Base) 8 | ActionController::Base.around_action Audited::Sweeper.new 9 | end 10 | if defined?(ActionController::API) 11 | ActionController::API.around_action Audited::Sweeper.new 12 | end 13 | end 14 | end 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /spec/rails_app/config/database.yml: -------------------------------------------------------------------------------- 1 | sqlite3mem: &SQLITE3MEM 2 | adapter: sqlite3 3 | database: ":memory:" 4 | 5 | sqlite3: &SQLITE 6 | adapter: sqlite3 7 | database: audited_test.sqlite3.db 8 | 9 | postgresql: &POSTGRES 10 | adapter: postgresql 11 | username: postgres 12 | password: postgres 13 | host: localhost 14 | database: audited_test 15 | min_messages: ERROR 16 | 17 | mysql: &MYSQL 18 | adapter: mysql2 19 | host: localhost 20 | username: root 21 | password: root 22 | database: audited_test 23 | charset: utf8 24 | 25 | test: 26 | <<: *<%= ENV['DB'] || 'SQLITE3MEM' %> 27 | -------------------------------------------------------------------------------- /test/db/version_5.rb: -------------------------------------------------------------------------------- 1 | ActiveRecord::Schema.define do 2 | create_table :audits, force: true do |t| 3 | t.column :auditable_id, :integer 4 | t.column :auditable_type, :string 5 | t.column :user_id, :integer 6 | t.column :user_type, :string 7 | t.column :username, :string 8 | t.column :action, :string 9 | t.column :audited_changes, :text 10 | t.column :version, :integer, default: 0 11 | t.column :comment, :string 12 | t.column :created_at, :datetime 13 | t.column :remote_address, :string 14 | t.column :association_id, :integer 15 | t.column :association_type, :string 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /.github/workflows/publish_gem.yml: -------------------------------------------------------------------------------- 1 | name: Publish Gem 2 | 3 | on: 4 | push: 5 | tags: 6 | - v* 7 | 8 | jobs: 9 | push: 10 | if: github.repository == 'collectiveidea/audited' 11 | runs-on: ubuntu-latest 12 | environment: publishing 13 | 14 | permissions: 15 | contents: write 16 | id-token: write 17 | 18 | steps: 19 | # Set up 20 | - uses: actions/checkout@v4 21 | - name: Set up Ruby 22 | uses: ruby/setup-ruby@v1 23 | with: 24 | bundler-cache: true 25 | ruby-version: ruby 26 | 27 | # Release 28 | - uses: rubygems/release-gem@v1 29 | -------------------------------------------------------------------------------- /test/db/version_1.rb: -------------------------------------------------------------------------------- 1 | ActiveRecord::Schema.define do 2 | create_table :audits, force: true do |t| 3 | t.column :auditable_id, :integer 4 | t.column :auditable_type, :string 5 | t.column :user_id, :integer 6 | t.column :user_type, :string 7 | t.column :username, :string 8 | t.column :action, :string 9 | t.column :changes, :text 10 | t.column :version, :integer, default: 0 11 | t.column :created_at, :datetime 12 | end 13 | 14 | add_index :audits, [:auditable_id, :auditable_type], name: "auditable_index" 15 | add_index :audits, [:user_id, :user_type], name: "user_index" 16 | add_index :audits, :created_at 17 | end 18 | -------------------------------------------------------------------------------- /spec/audited_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | 3 | describe Audited do 4 | describe "#store" do 5 | describe "maintains state of store" do 6 | let(:current_user) { Models::ActiveRecord::User.new(name: 'Some User', username: 'some_username') } 7 | 8 | it "can store and retrieve current_user" do 9 | expect(Audited.store[:current_user]).to be_nil 10 | 11 | Audited.store[:current_user] = current_user 12 | 13 | expect(Audited.store[:current_user]).to eq(current_user) 14 | end 15 | 16 | it "checks store is not nil" do 17 | expect(Audited.store).not_to be_nil 18 | end 19 | end 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /test/db/version_2.rb: -------------------------------------------------------------------------------- 1 | ActiveRecord::Schema.define do 2 | create_table :audits, force: true do |t| 3 | t.column :auditable_id, :integer 4 | t.column :auditable_type, :string 5 | t.column :user_id, :integer 6 | t.column :user_type, :string 7 | t.column :username, :string 8 | t.column :action, :string 9 | t.column :changes, :text 10 | t.column :version, :integer, default: 0 11 | t.column :comment, :string 12 | t.column :created_at, :datetime 13 | end 14 | 15 | add_index :audits, [:auditable_id, :auditable_type], name: "auditable_index" 16 | add_index :audits, [:user_id, :user_type], name: "user_index" 17 | add_index :audits, :created_at 18 | end 19 | -------------------------------------------------------------------------------- /test/db/version_3.rb: -------------------------------------------------------------------------------- 1 | ActiveRecord::Schema.define do 2 | create_table :audits, force: true do |t| 3 | t.column :auditable_id, :integer 4 | t.column :auditable_type, :string 5 | t.column :user_id, :integer 6 | t.column :user_type, :string 7 | t.column :username, :string 8 | t.column :action, :string 9 | t.column :audited_changes, :text 10 | t.column :version, :integer, default: 0 11 | t.column :comment, :string 12 | t.column :created_at, :datetime 13 | end 14 | 15 | add_index :audits, [:auditable_id, :auditable_type], name: "auditable_index" 16 | add_index :audits, [:user_id, :user_type], name: "user_index" 17 | add_index :audits, :created_at 18 | end 19 | -------------------------------------------------------------------------------- /test/db/version_6.rb: -------------------------------------------------------------------------------- 1 | ActiveRecord::Schema.define do 2 | create_table :audits, force: true do |t| 3 | t.column :auditable_id, :integer 4 | t.column :auditable_type, :string 5 | t.column :user_id, :integer 6 | t.column :user_type, :string 7 | t.column :username, :string 8 | t.column :action, :string 9 | t.column :audited_changes, :text 10 | t.column :version, :integer, default: 0 11 | t.column :comment, :string 12 | t.column :created_at, :datetime 13 | t.column :remote_address, :string 14 | t.column :associated_id, :integer 15 | t.column :associated_type, :string 16 | end 17 | 18 | add_index :audits, [:auditable_type, :auditable_id], name: "auditable_index" 19 | end 20 | -------------------------------------------------------------------------------- /test/db/version_4.rb: -------------------------------------------------------------------------------- 1 | ActiveRecord::Schema.define do 2 | create_table :audits, force: true do |t| 3 | t.column :auditable_id, :integer 4 | t.column :auditable_type, :string 5 | t.column :user_id, :integer 6 | t.column :user_type, :string 7 | t.column :username, :string 8 | t.column :action, :string 9 | t.column :audited_changes, :text 10 | t.column :version, :integer, default: 0 11 | t.column :comment, :string 12 | t.column :created_at, :datetime 13 | t.column :remote_address, :string 14 | end 15 | 16 | add_index :audits, [:auditable_id, :auditable_type], name: "auditable_index" 17 | add_index :audits, [:user_id, :user_type], name: "user_index" 18 | add_index :audits, :created_at 19 | end 20 | -------------------------------------------------------------------------------- /lib/generators/audited/templates/add_version_to_auditable_index.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class <%= migration_class_name %> < <%= migration_parent %> 4 | def self.up 5 | if index_exists?(:audits, [:auditable_type, :auditable_id], name: index_name) 6 | remove_index :audits, name: index_name 7 | add_index :audits, [:auditable_type, :auditable_id, :version], name: index_name 8 | end 9 | end 10 | 11 | def self.down 12 | if index_exists?(:audits, [:auditable_type, :auditable_id, :version], name: index_name) 13 | remove_index :audits, name: index_name 14 | add_index :audits, [:auditable_type, :auditable_id], name: index_name 15 | end 16 | end 17 | 18 | private 19 | 20 | def index_name 21 | 'auditable_index' 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /lib/generators/audited/templates/revert_polymorphic_indexes_order.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class <%= migration_class_name %> < <%= migration_parent %> 4 | def self.up 5 | fix_index_order_for [:associated_id, :associated_type], 'associated_index' 6 | fix_index_order_for [:auditable_id, :auditable_type], 'auditable_index' 7 | end 8 | 9 | def self.down 10 | fix_index_order_for [:associated_type, :associated_id], 'associated_index' 11 | fix_index_order_for [:auditable_type, :auditable_id], 'auditable_index' 12 | end 13 | 14 | private 15 | 16 | def fix_index_order_for(columns, index_name) 17 | if index_exists? :audits, columns, name: index_name 18 | remove_index :audits, name: index_name 19 | add_index :audits, columns.reverse, name: index_name 20 | end 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /lib/generators/audited/migration.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Audited 4 | module Generators 5 | module Migration 6 | # Implement the required interface for Rails::Generators::Migration. 7 | def next_migration_number(dirname) # :nodoc: 8 | next_migration_number = current_migration_number(dirname) + 1 9 | if timestamped_migrations? 10 | [Time.now.utc.strftime("%Y%m%d%H%M%S"), "%.14d" % next_migration_number].max 11 | else 12 | "%.3d" % next_migration_number 13 | end 14 | end 15 | 16 | private 17 | 18 | def timestamped_migrations? 19 | (Rails.gem_version >= Gem::Version.new("7.0")) ? 20 | ::ActiveRecord.timestamped_migrations : 21 | ::ActiveRecord::Base.timestamped_migrations 22 | end 23 | end 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | ENV["RAILS_ENV"] = "test" 2 | require "bundler/setup" 3 | require "single_cov" 4 | SingleCov.setup :rspec 5 | 6 | if Bundler.definition.dependencies.map(&:name).include?("protected_attributes") 7 | require "protected_attributes" 8 | end 9 | require "rails_app/config/environment" 10 | require "rspec/rails" 11 | require "audited" 12 | require "audited-rspec" 13 | require "audited_spec_helpers" 14 | require "support/active_record/models" 15 | 16 | SPEC_ROOT = Pathname.new(File.expand_path("../", __FILE__)) 17 | 18 | Dir[SPEC_ROOT.join("support/*.rb")].sort.each { |f| require f } 19 | 20 | RSpec.configure do |config| 21 | config.include AuditedSpecHelpers 22 | config.use_transactional_fixtures = false if Rails.version.start_with?("4.") 23 | config.use_transactional_tests = false if config.respond_to?(:use_transactional_tests=) 24 | end 25 | 26 | -------------------------------------------------------------------------------- /lib/generators/audited/install_generator.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "rails/generators" 4 | require "rails/generators/migration" 5 | require "active_record" 6 | require "rails/generators/active_record" 7 | require "generators/audited/migration" 8 | require "generators/audited/migration_helper" 9 | 10 | module Audited 11 | module Generators 12 | class InstallGenerator < Rails::Generators::Base 13 | include Rails::Generators::Migration 14 | include Audited::Generators::MigrationHelper 15 | extend Audited::Generators::Migration 16 | 17 | class_option :audited_changes_column_type, type: :string, default: "text", required: false 18 | class_option :audited_user_id_column_type, type: :string, default: "integer", required: false 19 | 20 | source_root File.expand_path("../templates", __FILE__) 21 | 22 | def copy_migration 23 | migration_template "install.rb", "db/migrate/install_audited.rb" 24 | end 25 | end 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /lib/generators/audited/templates/rename_association_to_associated.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class <%= migration_class_name %> < <%= migration_parent %> 4 | def self.up 5 | if index_exists? :audits, [:association_id, :association_type], :name => 'association_index' 6 | remove_index :audits, :name => 'association_index' 7 | end 8 | 9 | rename_column :audits, :association_id, :associated_id 10 | rename_column :audits, :association_type, :associated_type 11 | 12 | add_index :audits, [:associated_id, :associated_type], :name => 'associated_index' 13 | end 14 | 15 | def self.down 16 | if index_exists? :audits, [:associated_id, :associated_type], :name => 'associated_index' 17 | remove_index :audits, :name => 'associated_index' 18 | end 19 | 20 | rename_column :audits, :associated_type, :association_type 21 | rename_column :audits, :associated_id, :association_id 22 | 23 | add_index :audits, [:association_id, :association_type], :name => 'association_index' 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /lib/audited/sweeper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Audited 4 | class Sweeper 5 | STORED_DATA = { 6 | current_remote_address: :remote_ip, 7 | current_request_uuid: :request_uuid, 8 | current_user: :current_user 9 | } 10 | 11 | delegate :store, to: ::Audited 12 | 13 | def around(controller) 14 | self.controller = controller 15 | STORED_DATA.each { |k, m| store[k] = send(m) } 16 | yield 17 | ensure 18 | self.controller = nil 19 | STORED_DATA.keys.each { |k| store.delete(k) } 20 | end 21 | 22 | def current_user 23 | lambda { controller.send(Audited.current_user_method) if controller.respond_to?(Audited.current_user_method, true) } 24 | end 25 | 26 | def remote_ip 27 | controller.try(:request).try(:remote_ip) 28 | end 29 | 30 | def request_uuid 31 | controller.try(:request).try(:uuid) 32 | end 33 | 34 | def controller 35 | store[:current_controller] 36 | end 37 | 38 | def controller=(value) 39 | store[:current_controller] = value 40 | end 41 | end 42 | end 43 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright © 2010 Brandon Keepers - Collective Idea 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in 11 | all copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | THE SOFTWARE. 20 | -------------------------------------------------------------------------------- /lib/generators/audited/templates/install.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class <%= migration_class_name %> < <%= migration_parent %> 4 | def self.up 5 | create_table :audits, :force => true do |t| 6 | t.column :auditable_id, :integer 7 | t.column :auditable_type, :string 8 | t.column :associated_id, :integer 9 | t.column :associated_type, :string 10 | t.column :user_id, :<%= options[:audited_user_id_column_type] %> 11 | t.column :user_type, :string 12 | t.column :username, :string 13 | t.column :action, :string 14 | t.column :audited_changes, :<%= options[:audited_changes_column_type] %> 15 | t.column :version, :integer, :default => 0 16 | t.column :comment, :string 17 | t.column :remote_address, :string 18 | t.column :request_uuid, :string 19 | t.column :created_at, :datetime 20 | end 21 | 22 | add_index :audits, [:auditable_type, :auditable_id, :version], :name => 'auditable_index' 23 | add_index :audits, [:associated_type, :associated_id], :name => 'associated_index' 24 | add_index :audits, [:user_id, :user_type], :name => 'user_index' 25 | add_index :audits, :request_uuid 26 | add_index :audits, :created_at 27 | end 28 | 29 | def self.down 30 | drop_table :audits 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /spec/audited_spec_helpers.rb: -------------------------------------------------------------------------------- 1 | module AuditedSpecHelpers 2 | def create_user(attrs = {}) 3 | Models::ActiveRecord::User.create({name: "Brandon", username: "brandon", password: "password", favourite_device: "Android Phone"}.merge(attrs)) 4 | end 5 | 6 | def create_user_with_readonly_attrs(attrs = {}) 7 | Models::ActiveRecord::UserWithReadOnlyAttrs.create({name: "Brandon", username: "brandon", password: "password", favourite_device: "Android Phone"}.merge(attrs)) 8 | end 9 | 10 | def build_user(attrs = {}) 11 | Models::ActiveRecord::User.new({name: "darth", username: "darth", password: "noooooooo"}.merge(attrs)) 12 | end 13 | 14 | def create_versions(n = 2, attrs = {}) 15 | Models::ActiveRecord::User.create(name: "Foobar 1", **attrs).tap do |u| 16 | (n - 1).times do |i| 17 | u.update_attribute :name, "Foobar #{i + 2}" 18 | end 19 | u.reload 20 | end 21 | end 22 | 23 | def run_migrations(direction, migrations_paths, target_version = nil) 24 | if rails_below?("5.2.0.rc1") 25 | ActiveRecord::Migrator.send(direction, migrations_paths, target_version) 26 | elsif rails_below?("6.0.0.rc1") 27 | ActiveRecord::MigrationContext.new(migrations_paths).send(direction, target_version) 28 | else 29 | ActiveRecord::MigrationContext.new(migrations_paths, ActiveRecord::SchemaMigration).send(direction, target_version) 30 | end 31 | end 32 | 33 | def rails_below?(rails_version) 34 | Gem::Version.new(Rails::VERSION::STRING) < Gem::Version.new(rails_version) 35 | end 36 | end 37 | -------------------------------------------------------------------------------- /audited.gemspec: -------------------------------------------------------------------------------- 1 | $:.push File.expand_path("../lib", __FILE__) 2 | require "audited/version" 3 | 4 | Gem::Specification.new do |gem| 5 | gem.name = "audited" 6 | gem.version = Audited::VERSION 7 | 8 | gem.authors = ["Brandon Keepers", "Kenneth Kalmer", "Daniel Morrison", "Brian Ryckbost", "Steve Richert", "Ryan Glover"] 9 | gem.email = "info@collectiveidea.com" 10 | gem.description = "Log all changes to your models" 11 | gem.summary = gem.description 12 | gem.homepage = "https://github.com/collectiveidea/audited" 13 | gem.license = "MIT" 14 | 15 | gem.files = `git ls-files`.split($\).reject { |f| f =~ /^(\.gemspec|\.git|\.standard|\.yard|gemfiles|test|spec)/ } 16 | 17 | gem.required_ruby_version = ">= 2.3.0" 18 | 19 | gem.add_dependency "activerecord", ">= 5.2", "< 8.1" 20 | gem.add_dependency "activesupport", ">= 5.2", "< 8.1" 21 | 22 | gem.add_development_dependency "appraisal" 23 | gem.add_development_dependency "rails", ">= 5.2", "< 8.1" 24 | gem.add_development_dependency "rspec-rails" 25 | gem.add_development_dependency "standard" 26 | gem.add_development_dependency "single_cov" 27 | 28 | # JRuby support for the test ENV 29 | if defined?(JRUBY_VERSION) 30 | gem.add_development_dependency "activerecord-jdbcsqlite3-adapter", "~> 1.3" 31 | gem.add_development_dependency "activerecord-jdbcpostgresql-adapter", "~> 1.3" 32 | gem.add_development_dependency "activerecord-jdbcmysql-adapter", "~> 1.3" 33 | else 34 | gem.add_development_dependency "sqlite3", ">= 1.3.6" 35 | gem.add_development_dependency "mysql2", ">= 0.3.20" 36 | gem.add_development_dependency "pg", ">= 0.18", "< 2.0" 37 | end 38 | end 39 | -------------------------------------------------------------------------------- /spec/rails_app/config/application.rb: -------------------------------------------------------------------------------- 1 | require "active_record/railtie" 2 | 3 | module RailsApp 4 | class Application < Rails::Application 5 | config.root = File.expand_path("../../", __FILE__) 6 | config.i18n.enforce_available_locales = true 7 | 8 | if Rails.version.start_with?("7.1") && config.active_record.respond_to?(:yaml_column_permitted_classes=) 9 | config.active_record.yaml_column_permitted_classes = [ 10 | String, 11 | Symbol, 12 | Integer, 13 | NilClass, 14 | Float, 15 | Time, 16 | Date, 17 | FalseClass, 18 | Hash, 19 | Array, 20 | DateTime, 21 | TrueClass, 22 | BigDecimal, 23 | ActiveSupport::TimeWithZone, 24 | ActiveSupport::TimeZone, 25 | ActiveSupport::HashWithIndifferentAccess 26 | ] 27 | elsif !Rails.version.start_with?("5.0") && !Rails.version.start_with?("5.1") && config.active_record.respond_to?(:yaml_column_permitted_classes=) 28 | config.active_record.yaml_column_permitted_classes = 29 | %w[String Symbol Integer NilClass Float Time Date FalseClass Hash Array DateTime TrueClass BigDecimal 30 | ActiveSupport::TimeWithZone ActiveSupport::TimeZone ActiveSupport::HashWithIndifferentAccess] 31 | end 32 | 33 | if Rails.gem_version >= Gem::Version.new("7.1") 34 | config.active_support.cache_format_version = 7.1 35 | end 36 | end 37 | end 38 | 39 | require "active_record/connection_adapters/sqlite3_adapter" 40 | if ActiveRecord::ConnectionAdapters::SQLite3Adapter.respond_to?(:represent_boolean_as_integer) 41 | ActiveRecord::ConnectionAdapters::SQLite3Adapter.represent_boolean_as_integer = true 42 | end 43 | -------------------------------------------------------------------------------- /Appraisals: -------------------------------------------------------------------------------- 1 | # Include DB adapters matching the version requirements in 2 | # rails/activerecord/lib/active_record/connection_adapters/*adapter.rb 3 | 4 | appraise "rails50" do 5 | gem "rails", "~> 5.0.0" 6 | gem "mysql2", ">= 0.3.18", "< 0.6.0" 7 | gem "pg", ">= 0.18", "< 2.0" 8 | gem "sqlite3", "~> 1.3.6" 9 | gem "psych", "~> 3.1" 10 | gem "loofah", "2.20.0" 11 | end 12 | 13 | appraise "rails51" do 14 | gem "rails", "~> 5.1.4" 15 | gem "mysql2", ">= 0.3.18", "< 0.6.0" 16 | gem "pg", ">= 0.18", "< 2.0" 17 | gem "sqlite3", "~> 1.3.6" 18 | gem "psych", "~> 3.1" 19 | gem "loofah", "2.20.0" 20 | end 21 | 22 | appraise "rails52" do 23 | gem "rails", ">= 5.2.8.1", "< 5.3" 24 | gem "mysql2", ">= 0.4.4", "< 0.6.0" 25 | gem "pg", ">= 0.18", "< 2.0" 26 | gem "sqlite3", "~> 1.3.6" 27 | gem "psych", "~> 3.1" 28 | gem "loofah", "2.20.0" 29 | end 30 | 31 | appraise "rails60" do 32 | gem "rails", ">= 6.0.0", "< 6.1" 33 | gem "mysql2", ">= 0.4.4" 34 | gem "pg", ">= 0.18", "< 2.0" 35 | gem "sqlite3", "~> 1.4" 36 | end 37 | 38 | appraise "rails61" do 39 | gem "rails", ">= 6.1.0", "< 6.2" 40 | gem "mysql2", ">= 0.4.4" 41 | gem "pg", ">= 1.1", "< 2.0" 42 | gem "sqlite3", "~> 1.4" 43 | end 44 | 45 | appraise "rails70" do 46 | gem "rails", ">= 7.0.0", "< 7.1" 47 | gem "mysql2", ">= 0.4.4" 48 | gem "pg", ">= 1.1" 49 | gem "sqlite3", ">= 1.4" 50 | end 51 | 52 | appraise "rails71" do 53 | gem "rails", ">= 7.1.0.beta1", "< 7.2" 54 | gem "mysql2", ">= 0.4.4" 55 | gem "pg", ">= 1.1" 56 | gem "sqlite3", ">= 1.4" 57 | end 58 | 59 | appraise "rails80" do 60 | gem "rails", ">= 7.1.0.beta1", "< 8.1" 61 | gem "mysql2", ">= 0.4.4" 62 | gem "pg", ">= 1.1" 63 | gem "sqlite3", ">= 1.4" 64 | end 65 | -------------------------------------------------------------------------------- /lib/audited.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "active_record" 4 | 5 | module Audited 6 | # Wrapper around ActiveSupport::CurrentAttributes 7 | class RequestStore < ActiveSupport::CurrentAttributes 8 | attribute :audited_store 9 | end 10 | 11 | class << self 12 | attr_accessor \ 13 | :auditing_enabled, 14 | :current_user_method, 15 | :ignored_attributes, 16 | :ignored_default_callbacks, 17 | :max_audits, 18 | :store_synthesized_enums 19 | attr_writer :audit_class 20 | 21 | def audit_class 22 | # The audit_class is set as String in the initializer. It can not be constantized during initialization and must 23 | # be constantized at runtime. See https://github.com/collectiveidea/audited/issues/608 24 | @audit_class = @audit_class.safe_constantize if @audit_class.is_a?(String) 25 | @audit_class ||= Audited::Audit 26 | end 27 | 28 | # remove audit_model in next major version it was only shortly present in 5.1.0 29 | alias_method :audit_model, :audit_class 30 | deprecate audit_model: "use Audited.audit_class instead of Audited.audit_model. This method will be removed.", 31 | deprecator: ActiveSupport::Deprecation.new('6.0.0', 'Audited') 32 | 33 | def store 34 | RequestStore.audited_store ||= {} 35 | end 36 | 37 | def config 38 | yield(self) 39 | end 40 | end 41 | 42 | @ignored_attributes = %w[lock_version created_at updated_at created_on updated_on] 43 | @ignored_default_callbacks = [] 44 | 45 | @current_user_method = :current_user 46 | @auditing_enabled = true 47 | @store_synthesized_enums = false 48 | end 49 | 50 | require "audited/auditor" 51 | 52 | ActiveSupport.on_load :active_record do 53 | require "audited/audit" 54 | include Audited::Auditor 55 | end 56 | 57 | require "audited/sweeper" 58 | require "audited/railtie" if Audited.const_defined?(:Rails) 59 | -------------------------------------------------------------------------------- /lib/generators/audited/upgrade_generator.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "rails/generators" 4 | require "rails/generators/migration" 5 | require "active_record" 6 | require "rails/generators/active_record" 7 | require "generators/audited/migration" 8 | require "generators/audited/migration_helper" 9 | 10 | module Audited 11 | module Generators 12 | class UpgradeGenerator < Rails::Generators::Base 13 | include Rails::Generators::Migration 14 | include Audited::Generators::MigrationHelper 15 | extend Audited::Generators::Migration 16 | 17 | source_root File.expand_path("../templates", __FILE__) 18 | 19 | def copy_templates 20 | migrations_to_be_applied do |m| 21 | migration_template "#{m}.rb", "db/migrate/#{m}.rb" 22 | end 23 | end 24 | 25 | private 26 | 27 | def migrations_to_be_applied 28 | Audited::Audit.reset_column_information 29 | columns = Audited::Audit.columns.map(&:name) 30 | indexes = Audited::Audit.connection.indexes(Audited::Audit.table_name) 31 | 32 | yield :add_comment_to_audits unless columns.include?("comment") 33 | 34 | if columns.include?("changes") 35 | yield :rename_changes_to_audited_changes 36 | end 37 | 38 | unless columns.include?("remote_address") 39 | yield :add_remote_address_to_audits 40 | end 41 | 42 | unless columns.include?("request_uuid") 43 | yield :add_request_uuid_to_audits 44 | end 45 | 46 | unless columns.include?("association_id") 47 | if columns.include?("auditable_parent_id") 48 | yield :rename_parent_to_association 49 | else 50 | unless columns.include?("associated_id") 51 | yield :add_association_to_audits 52 | end 53 | end 54 | end 55 | 56 | if columns.include?("association_id") 57 | yield :rename_association_to_associated 58 | end 59 | 60 | if indexes.any? { |i| i.columns == %w[associated_id associated_type] } 61 | yield :revert_polymorphic_indexes_order 62 | end 63 | 64 | if indexes.any? { |i| i.columns == %w[auditable_type auditable_id] } 65 | yield :add_version_to_auditable_index 66 | end 67 | end 68 | end 69 | end 70 | end 71 | -------------------------------------------------------------------------------- /spec/rails_app/config/environments/test.rb: -------------------------------------------------------------------------------- 1 | RailsApp::Application.configure do 2 | # Settings specified here will take precedence over those in config/application.rb. 3 | 4 | # The test environment is used exclusively to run your application's 5 | # test suite. You never need to work with it otherwise. Remember that 6 | # your test database is "scratch space" for the test suite and is wiped 7 | # and recreated between test runs. Don't rely on the data there! 8 | config.cache_classes = true 9 | 10 | # Do not eager load code on boot. This avoids loading your whole application 11 | # just for the purpose of running a single test. If you are using a tool that 12 | # preloads Rails for running tests, you may have to set it to true. 13 | config.eager_load = false 14 | 15 | # Configure static file server for tests with Cache-Control for performance. 16 | if config.respond_to?(:public_file_server) 17 | config.public_file_server.enabled = true 18 | config.public_file_server.headers = {"Cache-Control" => "public, max-age=3600"} 19 | else 20 | config.static_cache_control = "public, max-age=3600" 21 | config.serve_static_files = true 22 | end 23 | 24 | # Show full error reports and disable caching. 25 | config.consider_all_requests_local = true 26 | # config.action_controller.perform_caching = false 27 | 28 | # Raise exceptions instead of rendering exception templates. 29 | config.action_dispatch.show_exceptions = false 30 | 31 | # Disable request forgery protection in test environment. 32 | # config.action_controller.allow_forgery_protection = false 33 | 34 | # Tell Action Mailer not to deliver emails to the real world. 35 | # The :test delivery method accumulates sent emails in the 36 | # ActionMailer::Base.deliveries array. 37 | # config.action_mailer.delivery_method = :test 38 | 39 | # Randomize the order test cases are executed. 40 | config.active_support.test_order = :random 41 | 42 | # Print deprecation notices to the stderr. 43 | config.active_support.deprecation = :stderr 44 | 45 | # Raises error for missing translations 46 | # config.action_view.raise_on_missing_translations = true 47 | 48 | if ::ActiveRecord::VERSION::MAJOR >= 7 49 | config.active_record.encryption.key_derivation_salt = SecureRandom.hex 50 | config.active_record.encryption.primary_key = SecureRandom.hex 51 | end 52 | end 53 | -------------------------------------------------------------------------------- /test/install_generator_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | require "generators/audited/install_generator" 4 | 5 | class InstallGeneratorTest < Rails::Generators::TestCase 6 | destination File.expand_path("../../tmp", __FILE__) 7 | setup :prepare_destination 8 | tests Audited::Generators::InstallGenerator 9 | 10 | test "generate migration with 'text' type for audited_changes column" do 11 | run_generator 12 | 13 | assert_migration "db/migrate/install_audited.rb" do |content| 14 | assert_includes(content, "class InstallAudited") 15 | assert_includes(content, "t.column :audited_changes, :text") 16 | end 17 | end 18 | 19 | test "generate migration with 'jsonb' type for audited_changes column" do 20 | run_generator %w[--audited-changes-column-type jsonb] 21 | 22 | assert_migration "db/migrate/install_audited.rb" do |content| 23 | assert_includes(content, "class InstallAudited") 24 | assert_includes(content, "t.column :audited_changes, :jsonb") 25 | end 26 | end 27 | 28 | test "generate migration with 'json' type for audited_changes column" do 29 | run_generator %w[--audited-changes-column-type json] 30 | 31 | assert_migration "db/migrate/install_audited.rb" do |content| 32 | assert_includes(content, "class InstallAudited") 33 | assert_includes(content, "t.column :audited_changes, :json") 34 | end 35 | end 36 | 37 | test "generate migration with 'string' type for user_id column" do 38 | run_generator %w[--audited-user-id-column-type string] 39 | 40 | assert_migration "db/migrate/install_audited.rb" do |content| 41 | assert_includes(content, "class InstallAudited") 42 | assert_includes(content, "t.column :user_id, :string") 43 | end 44 | end 45 | 46 | test "generate migration with 'uuid' type for user_id column" do 47 | run_generator %w[--audited-user-id-column-type uuid] 48 | 49 | assert_migration "db/migrate/install_audited.rb" do |content| 50 | assert_includes(content, "class InstallAudited") 51 | assert_includes(content, "t.column :user_id, :uuid") 52 | end 53 | end 54 | 55 | test "generate migration with correct AR migration parent" do 56 | run_generator 57 | 58 | assert_migration "db/migrate/install_audited.rb" do |content| 59 | assert_includes(content, "class InstallAudited < ActiveRecord::Migration[#{ActiveRecord::Migration.current_version}]\n") 60 | end 61 | end 62 | end 63 | -------------------------------------------------------------------------------- /spec/audited/rspec_matchers_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | 3 | describe Models::ActiveRecord::UserExceptPassword do 4 | let(:non_audited_columns) { subject.class.non_audited_columns } 5 | 6 | it { should_not be_audited.only(non_audited_columns) } 7 | it { should be_audited.except(:password) } 8 | it { should_not be_audited.requires_comment } 9 | it { should be_audited.on(:create, :update, :destroy) } 10 | # test chaining 11 | it { should be_audited.except(:password).on(:create, :update, :destroy) } 12 | end 13 | 14 | describe Models::ActiveRecord::UserOnlyPassword do 15 | let(:audited_columns) { subject.class.audited_columns } 16 | 17 | it { should be_audited.only(:password) } 18 | it { should_not be_audited.except(audited_columns) } 19 | it { should_not be_audited.requires_comment } 20 | it { should be_audited.on(:create, :update, :destroy) } 21 | it { should be_audited.only(:password).on(:create, :update, :destroy) } 22 | end 23 | 24 | describe Models::ActiveRecord::CommentRequiredUser do 25 | let(:audited_columns) { subject.class.audited_columns } 26 | let(:non_audited_columns) { subject.class.non_audited_columns } 27 | 28 | it { should_not be_audited.only(non_audited_columns) } 29 | it { should_not be_audited.except(audited_columns) } 30 | it { should be_audited.requires_comment } 31 | it { should be_audited.on(:create, :update, :destroy) } 32 | it { should be_audited.requires_comment.on(:create, :update, :destroy) } 33 | end 34 | 35 | describe Models::ActiveRecord::OnCreateCommentRequiredUser do 36 | let(:audited_columns) { subject.class.audited_columns } 37 | let(:non_audited_columns) { subject.class.non_audited_columns } 38 | 39 | it { should_not be_audited.only(non_audited_columns) } 40 | it { should_not be_audited.except(audited_columns) } 41 | it { should be_audited.requires_comment } 42 | it { should be_audited.on(:create) } 43 | it { should_not be_audited.on(:update, :destroy) } 44 | it { should be_audited.requires_comment.on(:create) } 45 | end 46 | 47 | describe Models::ActiveRecord::OnUpdateCommentRequiredUser do 48 | let(:audited_columns) { subject.class.audited_columns } 49 | let(:non_audited_columns) { subject.class.non_audited_columns } 50 | 51 | it { should_not be_audited.only(non_audited_columns) } 52 | it { should_not be_audited.except(audited_columns) } 53 | it { should be_audited.requires_comment } 54 | it { should be_audited.on(:update) } 55 | it { should_not be_audited.on(:create, :destroy) } 56 | it { should be_audited.requires_comment.on(:update) } 57 | end 58 | 59 | describe Models::ActiveRecord::OnDestroyCommentRequiredUser do 60 | let(:audited_columns) { subject.class.audited_columns } 61 | let(:non_audited_columns) { subject.class.non_audited_columns } 62 | 63 | it { should_not be_audited.only(non_audited_columns) } 64 | it { should_not be_audited.except(audited_columns) } 65 | it { should be_audited.requires_comment } 66 | it { should be_audited.on(:destroy) } 67 | it { should_not be_audited.on(:create, :update) } 68 | it { should be_audited.requires_comment.on(:destroy) } 69 | end 70 | -------------------------------------------------------------------------------- /spec/support/active_record/schema.rb: -------------------------------------------------------------------------------- 1 | require "active_record" 2 | require "logger" 3 | 4 | begin 5 | if ActiveRecord.version >= Gem::Version.new("6.1.0") 6 | db_config = ActiveRecord::Base.configurations.configs_for(env_name: Rails.env).first 7 | ActiveRecord::Tasks::DatabaseTasks.create(db_config) 8 | else 9 | db_config = ActiveRecord::Base.configurations[Rails.env].clone 10 | db_type = db_config["adapter"] 11 | db_name = db_config.delete("database") 12 | raise StandardError.new("No database name specified.") if db_name.blank? 13 | if db_type == "sqlite3" 14 | db_file = Pathname.new(__FILE__).dirname.join(db_name) 15 | db_file.unlink if db_file.file? 16 | else 17 | if defined?(JRUBY_VERSION) 18 | db_config.symbolize_keys! 19 | db_config[:configure_connection] = false 20 | end 21 | adapter = ActiveRecord::Base.send("#{db_type}_connection", db_config) 22 | adapter.recreate_database db_name, db_config.slice("charset").symbolize_keys 23 | adapter.disconnect! 24 | end 25 | end 26 | rescue => e 27 | Kernel.warn e 28 | end 29 | 30 | logfile = Pathname.new(__FILE__).dirname.join("debug.log") 31 | logfile.unlink if logfile.file? 32 | ActiveRecord::Base.logger = Logger.new(logfile) 33 | 34 | ActiveRecord::Migration.verbose = false 35 | ActiveRecord::Base.establish_connection 36 | 37 | ActiveRecord::Schema.define do 38 | create_table :users do |t| 39 | t.column :name, :string 40 | t.column :username, :string 41 | t.column :password, :string 42 | t.column :activated, :boolean 43 | t.column :status, :integer, default: 0 44 | t.column :suspended_at, :datetime 45 | t.column :logins, :integer, default: 0 46 | t.column :created_at, :datetime 47 | t.column :updated_at, :datetime 48 | t.column :favourite_device, :string 49 | t.column :ssn, :integer 50 | t.column :phone_numbers, :string 51 | end 52 | 53 | create_table :companies do |t| 54 | t.column :name, :string 55 | t.column :owner_id, :integer 56 | t.column :type, :string 57 | end 58 | 59 | create_table :authors do |t| 60 | t.column :name, :string 61 | end 62 | 63 | create_table :books do |t| 64 | t.column :authord_id, :integer 65 | t.column :title, :string 66 | end 67 | 68 | create_table :audits do |t| 69 | t.column :auditable_id, :integer 70 | t.column :auditable_type, :string 71 | t.column :associated_id, :integer 72 | t.column :associated_type, :string 73 | t.column :user_id, :integer 74 | t.column :user_type, :string 75 | t.column :username, :string 76 | t.column :action, :string 77 | t.column :audited_changes, :text 78 | t.column :version, :integer, default: 0 79 | t.column :comment, :string 80 | t.column :remote_address, :string 81 | t.column :request_uuid, :string 82 | t.column :created_at, :datetime 83 | end 84 | 85 | add_index :audits, [:auditable_id, :auditable_type], name: "auditable_index" 86 | add_index :audits, [:associated_id, :associated_type], name: "associated_index" 87 | add_index :audits, [:user_id, :user_type], name: "user_index" 88 | add_index :audits, :request_uuid 89 | add_index :audits, :created_at 90 | end 91 | -------------------------------------------------------------------------------- /test/upgrade_generator_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | require "generators/audited/upgrade_generator" 4 | 5 | class UpgradeGeneratorTest < Rails::Generators::TestCase 6 | destination File.expand_path("../../tmp", __FILE__) 7 | setup :prepare_destination 8 | tests Audited::Generators::UpgradeGenerator 9 | self.use_transactional_tests = false 10 | 11 | test "should add 'comment' to audits table" do 12 | load_schema 1 13 | 14 | run_generator %w[upgrade] 15 | 16 | assert_migration "db/migrate/add_comment_to_audits.rb" do |content| 17 | assert_match(/add_column :audits, :comment, :string/, content) 18 | end 19 | 20 | assert_migration "db/migrate/rename_changes_to_audited_changes.rb" 21 | end 22 | 23 | test "should rename 'changes' to 'audited_changes'" do 24 | load_schema 2 25 | 26 | run_generator %w[upgrade] 27 | 28 | assert_no_migration "db/migrate/add_comment_to_audits.rb" 29 | 30 | assert_migration "db/migrate/rename_changes_to_audited_changes.rb" do |content| 31 | assert_match(/rename_column :audits, :changes, :audited_changes/, content) 32 | end 33 | end 34 | 35 | test "should add a 'remote_address' to audits table" do 36 | load_schema 3 37 | 38 | run_generator %w[upgrade] 39 | 40 | assert_migration "db/migrate/add_remote_address_to_audits.rb" do |content| 41 | assert_match(/add_column :audits, :remote_address, :string/, content) 42 | end 43 | end 44 | 45 | test "should add 'association_id' and 'association_type' to audits table" do 46 | load_schema 4 47 | 48 | run_generator %w[upgrade] 49 | 50 | assert_migration "db/migrate/add_association_to_audits.rb" do |content| 51 | assert_match(/add_column :audits, :association_id, :integer/, content) 52 | assert_match(/add_column :audits, :association_type, :string/, content) 53 | end 54 | end 55 | 56 | test "should rename 'association_id' to 'associated_id' and 'association_type' to 'associated_type'" do 57 | load_schema 5 58 | 59 | run_generator %w[upgrade] 60 | 61 | assert_migration "db/migrate/rename_association_to_associated.rb" do |content| 62 | assert_match(/rename_column :audits, :association_id, :associated_id/, content) 63 | assert_match(/rename_column :audits, :association_type, :associated_type/, content) 64 | end 65 | end 66 | 67 | test "should add 'request_uuid' to audits table" do 68 | load_schema 6 69 | 70 | run_generator %w[upgrade] 71 | 72 | assert_migration "db/migrate/add_request_uuid_to_audits.rb" do |content| 73 | assert_match(/add_column :audits, :request_uuid, :string/, content) 74 | assert_match(/add_index :audits, :request_uuid/, content) 75 | end 76 | end 77 | 78 | test "should add 'version' to auditable_index" do 79 | load_schema 6 80 | 81 | run_generator %w[upgrade] 82 | 83 | assert_migration "db/migrate/add_version_to_auditable_index.rb" do |content| 84 | assert_match(/add_index :audits, \[:auditable_type, :auditable_id, :version\]/, content) 85 | end 86 | end 87 | 88 | test "generate migration with correct AR migration parent" do 89 | load_schema 1 90 | 91 | run_generator %w[upgrade] 92 | 93 | assert_migration "db/migrate/add_comment_to_audits.rb" do |content| 94 | assert_includes(content, "class AddCommentToAudits < ActiveRecord::Migration[#{ActiveRecord::Migration.current_version}]\n") 95 | end 96 | end 97 | end 98 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | - pull_request 5 | - push 6 | 7 | jobs: 8 | build: 9 | runs-on: ubuntu-latest 10 | strategy: 11 | fail-fast: false 12 | matrix: 13 | ruby: [2.3, 2.4, 2.5, 2.6, 2.7, 3.0, 3.1, 3.2] 14 | appraisal: 15 | - rails52 16 | - rails60 17 | - rails61 18 | - rails70 19 | - rails71 20 | - rails80 21 | db: [POSTGRES, MYSQL, SQLITE] 22 | exclude: 23 | # MySQL has issues on Ruby 2.3 24 | # https://github.com/ruby/setup-ruby/issues/150 25 | - ruby: 2.3 26 | db: MYSQL 27 | 28 | # PostgreSQL is segfaulting on 2.3 29 | # Doesn't seem worth solving. 30 | - ruby: 2.3 31 | db: POSTGRES 32 | 33 | # Rails 5.2 supports Ruby 2.2-2.5 34 | - appraisal: rails52 35 | ruby: 2.6 36 | - appraisal: rails52 37 | ruby: 2.7 38 | - appraisal: rails52 39 | ruby: 3.0 40 | - appraisal: rails52 41 | ruby: 3.1 42 | - appraisal: rails52 43 | ruby: 3.2 44 | 45 | # Rails 6.0 supports Ruby 2.5-2.7 46 | - appraisal: rails60 47 | ruby: 2.3 48 | - appraisal: rails60 49 | ruby: 2.4 50 | - appraisal: rails60 51 | ruby: 3.0 52 | - appraisal: rails60 53 | ruby: 3.1 54 | - appraisal: rails60 55 | ruby: 3.2 56 | 57 | # Rails 6.1 supports Ruby 2.5+ 58 | - appraisal: rails61 59 | ruby: 2.3 60 | - appraisal: rails61 61 | ruby: 2.4 62 | 63 | # Rails 7 supports Ruby 2.7+ 64 | - appraisal: rails70 65 | ruby: 2.3 66 | - appraisal: rails70 67 | ruby: 2.4 68 | - appraisal: rails70 69 | ruby: 2.5 70 | - appraisal: rails70 71 | ruby: 2.6 72 | 73 | # Rails 7.1 supports Ruby 2.7+ 74 | - appraisal: rails71 75 | ruby: 2.3 76 | - appraisal: rails71 77 | ruby: 2.4 78 | - appraisal: rails71 79 | ruby: 2.5 80 | - appraisal: rails71 81 | ruby: 2.6 82 | - appraisal: rails80 83 | ruby: 2.6 84 | 85 | services: 86 | postgres: 87 | image: postgres 88 | env: 89 | POSTGRES_USER: postgres 90 | POSTGRES_PASSWORD: postgres 91 | POSTGRES_DB: audited_test 92 | ports: 93 | - 5432:5432 94 | # needed because the postgres container does not provide a healthcheck 95 | options: --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5 96 | 97 | env: 98 | DB_DATABASE: audited_test 99 | DB_USER: root 100 | DB_PASSWORD: 'root' 101 | DB_HOST: localhost 102 | 103 | steps: 104 | - name: Setup MySQL 105 | run: | 106 | sudo /etc/init.d/mysql start 107 | mysql -e 'CREATE DATABASE audited_test;' -uroot -proot 108 | mysql -e 'SHOW DATABASES;' -uroot -proot 109 | - uses: actions/checkout@v4 110 | - name: Copy Gemfile 111 | run: sed 's/\.\././' gemfiles/${{ matrix.appraisal }}.gemfile > Gemfile 112 | - name: Set up Ruby ${{ matrix.ruby }} 113 | uses: ruby/setup-ruby@v1 114 | with: 115 | ruby-version: ${{ matrix.ruby }} 116 | bundler-cache: true 117 | - name: Run tests 118 | env: 119 | DB: ${{ matrix.db }} 120 | run: bundle exec rake 121 | -------------------------------------------------------------------------------- /spec/audited/sweeper_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | 3 | SingleCov.covered! 4 | 5 | class AuditsController < ActionController::Base 6 | before_action :populate_user 7 | 8 | attr_reader :company 9 | 10 | def create 11 | @company = Models::ActiveRecord::Company.create 12 | head :ok 13 | end 14 | 15 | def update 16 | current_user.update!(password: "foo") 17 | head :ok 18 | end 19 | 20 | private 21 | 22 | attr_accessor :current_user 23 | attr_accessor :custom_user 24 | 25 | def populate_user 26 | end 27 | end 28 | 29 | describe AuditsController do 30 | include RSpec::Rails::ControllerExampleGroup 31 | render_views 32 | 33 | before do 34 | Audited::Railtie.initializers.each(&:run) 35 | Audited.current_user_method = :current_user 36 | end 37 | 38 | let(:user) { create_user } 39 | 40 | describe "POST audit" do 41 | it "should audit user" do 42 | controller.send(:current_user=, user) 43 | expect { 44 | post :create 45 | }.to change(Audited::Audit, :count) 46 | 47 | expect(controller.company.audits.last.user).to eq(user) 48 | end 49 | 50 | it "does not audit when method is not found" do 51 | controller.send(:current_user=, user) 52 | Audited.current_user_method = :nope 53 | expect { 54 | post :create 55 | }.to change(Audited::Audit, :count) 56 | expect(controller.company.audits.last.user).to eq(nil) 57 | end 58 | 59 | it "should support custom users for sweepers" do 60 | controller.send(:custom_user=, user) 61 | Audited.current_user_method = :custom_user 62 | 63 | expect { 64 | post :create 65 | }.to change(Audited::Audit, :count) 66 | 67 | expect(controller.company.audits.last.user).to eq(user) 68 | end 69 | 70 | it "should record the remote address responsible for the change" do 71 | request.env["REMOTE_ADDR"] = "1.2.3.4" 72 | controller.send(:current_user=, user) 73 | 74 | post :create 75 | 76 | expect(controller.company.audits.last.remote_address).to eq("1.2.3.4") 77 | end 78 | 79 | it "should record a UUID for the web request responsible for the change" do 80 | allow_any_instance_of(ActionDispatch::Request).to receive(:uuid).and_return("abc123") 81 | controller.send(:current_user=, user) 82 | 83 | post :create 84 | 85 | expect(controller.company.audits.last.request_uuid).to eq("abc123") 86 | end 87 | 88 | it "should call current_user after controller callbacks" do 89 | expect(controller).to receive(:populate_user) do 90 | controller.send(:current_user=, user) 91 | end 92 | 93 | expect { 94 | post :create 95 | }.to change(Audited::Audit, :count) 96 | 97 | expect(controller.company.audits.last.user).to eq(user) 98 | end 99 | end 100 | 101 | describe "PUT update" do 102 | it "should not save blank audits" do 103 | controller.send(:current_user=, user) 104 | 105 | expect { 106 | put :update, params: {id: 123} 107 | }.to_not change(Audited::Audit, :count) 108 | end 109 | end 110 | end 111 | 112 | describe Audited::Sweeper do 113 | it "should be thread-safe" do 114 | instance = Audited::Sweeper.new 115 | 116 | t1 = Thread.new do 117 | sleep 0.5 118 | instance.controller = "thread1 controller instance" 119 | expect(instance.controller).to eq("thread1 controller instance") 120 | end 121 | 122 | t2 = Thread.new do 123 | instance.controller = "thread2 controller instance" 124 | sleep 1 125 | expect(instance.controller).to eq("thread2 controller instance") 126 | end 127 | 128 | t1.join 129 | t2.join 130 | 131 | expect(instance.controller).to be_nil 132 | end 133 | end 134 | -------------------------------------------------------------------------------- /spec/support/active_record/models.rb: -------------------------------------------------------------------------------- 1 | require "cgi" 2 | require File.expand_path("../schema", __FILE__) 3 | 4 | module Models 5 | module ActiveRecord 6 | class User < ::ActiveRecord::Base 7 | audited except: :password 8 | attribute :non_column_attr if Rails.gem_version >= Gem::Version.new("5.1") 9 | attr_protected :logins if respond_to?(:attr_protected) 10 | enum status: {active: 0, reliable: 1, banned: 2} 11 | 12 | if Rails.gem_version >= Gem::Version.new("7.1") 13 | serialize :phone_numbers, type: Array 14 | else 15 | serialize :phone_numbers, Array 16 | end 17 | 18 | def name=(val) 19 | write_attribute(:name, CGI.escapeHTML(val)) 20 | end 21 | end 22 | 23 | class UserExceptPassword < ::ActiveRecord::Base 24 | self.table_name = :users 25 | audited except: :password 26 | end 27 | 28 | class UserOnlyPassword < ::ActiveRecord::Base 29 | self.table_name = :users 30 | attribute :non_column_attr if Rails.gem_version >= Gem::Version.new("5.1") 31 | audited only: :password 32 | end 33 | 34 | class UserRedactedPassword < ::ActiveRecord::Base 35 | self.table_name = :users 36 | audited redacted: :password 37 | end 38 | 39 | class UserMultipleRedactedAttributes < ::ActiveRecord::Base 40 | self.table_name = :users 41 | audited redacted: [:password, :ssn] 42 | end 43 | 44 | class UserRedactedPasswordCustomRedaction < ::ActiveRecord::Base 45 | self.table_name = :users 46 | audited redacted: :password, redaction_value: ["My", "Custom", "Value", 7] 47 | end 48 | 49 | if ::ActiveRecord::VERSION::MAJOR >= 7 50 | class UserWithEncryptedPassword < ::ActiveRecord::Base 51 | self.table_name = :users 52 | audited 53 | encrypts :password 54 | end 55 | end 56 | 57 | class UserWithReadOnlyAttrs < ::ActiveRecord::Base 58 | self.table_name = :users 59 | audited 60 | attr_readonly :status 61 | end 62 | 63 | class CommentRequiredUser < ::ActiveRecord::Base 64 | self.table_name = :users 65 | audited except: :password, comment_required: true 66 | end 67 | 68 | class OnCreateCommentRequiredUser < ::ActiveRecord::Base 69 | self.table_name = :users 70 | audited comment_required: true, on: :create 71 | end 72 | 73 | class OnUpdateCommentRequiredUser < ::ActiveRecord::Base 74 | self.table_name = :users 75 | audited comment_required: true, on: :update 76 | end 77 | 78 | class OnDestroyCommentRequiredUser < ::ActiveRecord::Base 79 | self.table_name = :users 80 | audited comment_required: true, on: :destroy 81 | end 82 | 83 | class NoUpdateWithCommentOnlyUser < ::ActiveRecord::Base 84 | self.table_name = :users 85 | audited update_with_comment_only: false 86 | end 87 | 88 | class AccessibleAfterDeclarationUser < ::ActiveRecord::Base 89 | self.table_name = :users 90 | audited 91 | attr_accessible :name, :username, :password if respond_to?(:attr_accessible) 92 | end 93 | 94 | class AccessibleBeforeDeclarationUser < ::ActiveRecord::Base 95 | self.table_name = :users 96 | attr_accessible :name, :username, :password if respond_to?(:attr_accessible) # declare attr_accessible before calling aaa 97 | audited 98 | end 99 | 100 | class NoAttributeProtectionUser < ::ActiveRecord::Base 101 | self.table_name = :users 102 | audited 103 | end 104 | 105 | class UserWithAfterAudit < ::ActiveRecord::Base 106 | self.table_name = :users 107 | audited 108 | attr_accessor :bogus_attr, :around_attr 109 | 110 | private 111 | 112 | def after_audit 113 | self.bogus_attr = "do something" 114 | end 115 | 116 | def around_audit 117 | self.around_attr = yield 118 | end 119 | end 120 | 121 | class MaxAuditsUser < ::ActiveRecord::Base 122 | self.table_name = :users 123 | audited max_audits: 5 124 | end 125 | 126 | class Company < ::ActiveRecord::Base 127 | audited 128 | end 129 | 130 | class Company::STICompany < Company 131 | end 132 | 133 | class Owner < ::ActiveRecord::Base 134 | self.table_name = "users" 135 | audited 136 | has_associated_audits 137 | has_many :companies, class_name: "OwnedCompany", dependent: :destroy 138 | accepts_nested_attributes_for :companies 139 | enum status: {active: 0, reliable: 1, banned: 2} 140 | end 141 | 142 | class OwnedCompany < ::ActiveRecord::Base 143 | self.table_name = "companies" 144 | belongs_to :owner, class_name: "Owner", touch: true 145 | attr_accessible :name, :owner if respond_to?(:attr_accessible) # declare attr_accessible before calling aaa 146 | audited associated_with: :owner 147 | end 148 | 149 | class OwnedCompany::STICompany < OwnedCompany 150 | end 151 | 152 | class OnUpdateDestroy < ::ActiveRecord::Base 153 | self.table_name = "companies" 154 | audited on: [:update, :destroy] 155 | end 156 | 157 | class OnCreateDestroy < ::ActiveRecord::Base 158 | self.table_name = "companies" 159 | audited on: [:create, :destroy] 160 | end 161 | 162 | class OnCreateDestroyUser < ::ActiveRecord::Base 163 | self.table_name = "users" 164 | audited on: [:create, :destroy] 165 | end 166 | 167 | class OnCreateDestroyExceptName < ::ActiveRecord::Base 168 | self.table_name = "companies" 169 | audited except: :name, on: [:create, :destroy] 170 | end 171 | 172 | class OnCreateUpdate < ::ActiveRecord::Base 173 | self.table_name = "companies" 174 | audited on: [:create, :update] 175 | end 176 | 177 | class OnTouchOnly < ::ActiveRecord::Base 178 | self.table_name = "users" 179 | audited on: [:touch] 180 | end 181 | end 182 | end 183 | -------------------------------------------------------------------------------- /lib/audited/audit.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "set" 4 | 5 | module Audited 6 | # Audit saves the changes to ActiveRecord models. It has the following attributes: 7 | # 8 | # * auditable: the ActiveRecord model that was changed 9 | # * user: the user that performed the change; a string or an ActiveRecord model 10 | # * action: one of create, update, or delete 11 | # * audited_changes: a hash of all the changes 12 | # * comment: a comment set with the audit 13 | # * version: the version of the model 14 | # * request_uuid: a uuid based that allows audits from the same controller request 15 | # * created_at: Time that the change was performed 16 | # 17 | 18 | class YAMLIfTextColumnType 19 | class << self 20 | def load(obj) 21 | if text_column? 22 | ActiveRecord::Coders::YAMLColumn.new(Object).load(obj) 23 | else 24 | obj 25 | end 26 | end 27 | 28 | def dump(obj) 29 | if text_column? 30 | ActiveRecord::Coders::YAMLColumn.new(Object).dump(obj) 31 | else 32 | obj 33 | end 34 | end 35 | 36 | def text_column? 37 | Audited.audit_class.columns_hash["audited_changes"].type.to_s == "text" 38 | end 39 | end 40 | end 41 | 42 | class Audit < ::ActiveRecord::Base 43 | belongs_to :auditable, polymorphic: true 44 | belongs_to :user, polymorphic: true 45 | belongs_to :associated, polymorphic: true 46 | 47 | before_create :set_version_number, :set_audit_user, :set_request_uuid, :set_remote_address 48 | 49 | cattr_accessor :audited_class_names 50 | self.audited_class_names = Set.new 51 | 52 | if Rails.gem_version >= Gem::Version.new("7.1") 53 | serialize :audited_changes, coder: YAMLIfTextColumnType 54 | else 55 | serialize :audited_changes, YAMLIfTextColumnType 56 | end 57 | 58 | scope :ascending, -> { reorder(version: :asc) } 59 | scope :descending, -> { reorder(version: :desc) } 60 | scope :creates, -> { where(action: "create") } 61 | scope :updates, -> { where(action: "update") } 62 | scope :destroys, -> { where(action: "destroy") } 63 | 64 | scope :up_until, ->(date_or_time) { where("created_at <= ?", date_or_time) } 65 | scope :from_version, ->(version) { where("version >= ?", version) } 66 | scope :to_version, ->(version) { where("version <= ?", version) } 67 | scope :auditable_finder, ->(auditable_id, auditable_type) { where(auditable_id: auditable_id, auditable_type: auditable_type) } 68 | # Return all audits older than the current one. 69 | def ancestors 70 | self.class.ascending.auditable_finder(auditable_id, auditable_type).to_version(version) 71 | end 72 | 73 | # Return an instance of what the object looked like at this revision. If 74 | # the object has been destroyed, this will be a new record. 75 | def revision 76 | clazz = auditable_type.constantize 77 | (clazz.find_by_id(auditable_id) || clazz.new).tap do |m| 78 | self.class.assign_revision_attributes(m, self.class.reconstruct_attributes(ancestors).merge(audit_version: version)) 79 | end 80 | end 81 | 82 | # Returns a hash of the changed attributes with the new values 83 | def new_attributes 84 | (audited_changes || {}).each_with_object({}.with_indifferent_access) do |(attr, values), attrs| 85 | attrs[attr] = (action == "update") ? values.last : values 86 | end 87 | end 88 | 89 | # Returns a hash of the changed attributes with the old values 90 | def old_attributes 91 | (audited_changes || {}).each_with_object({}.with_indifferent_access) do |(attr, values), attrs| 92 | attrs[attr] = (action == "update") ? values.first : values 93 | end 94 | end 95 | 96 | # Allows user to undo changes 97 | def undo 98 | case action 99 | when "create" 100 | # destroys a newly created record 101 | auditable.destroy! 102 | when "destroy" 103 | # creates a new record with the destroyed record attributes 104 | auditable_type.constantize.create!(audited_changes) 105 | when "update" 106 | # changes back attributes 107 | auditable.update!(audited_changes.transform_values(&:first)) 108 | else 109 | raise StandardError, "invalid action given #{action}" 110 | end 111 | end 112 | 113 | # Allows user to be set to either a string or an ActiveRecord object 114 | # @private 115 | def user_as_string=(user) 116 | # reset both either way 117 | self.user_as_model = self.username = nil 118 | user.is_a?(::ActiveRecord::Base) ? 119 | self.user_as_model = user : 120 | self.username = user 121 | end 122 | alias_method :user_as_model=, :user= 123 | alias_method :user=, :user_as_string= 124 | 125 | # @private 126 | def user_as_string 127 | user_as_model || username 128 | end 129 | alias_method :user_as_model, :user 130 | alias_method :user, :user_as_string 131 | 132 | # Returns the list of classes that are being audited 133 | def self.audited_classes 134 | audited_class_names.map(&:constantize) 135 | end 136 | 137 | # All audits made during the block called will be recorded as made 138 | # by +user+. This method is hopefully threadsafe, making it ideal 139 | # for background operations that require audit information. 140 | def self.as_user(user) 141 | last_audited_user = ::Audited.store[:audited_user] 142 | ::Audited.store[:audited_user] = user 143 | yield 144 | ensure 145 | ::Audited.store[:audited_user] = last_audited_user 146 | end 147 | 148 | # @private 149 | def self.reconstruct_attributes(audits) 150 | audits.each_with_object({}) do |audit, all| 151 | all.merge!(audit.new_attributes) 152 | all[:audit_version] = audit.version 153 | end 154 | end 155 | 156 | # @private 157 | def self.assign_revision_attributes(record, attributes) 158 | attributes.each do |attr, val| 159 | record = record.dup if record.frozen? 160 | 161 | if record.respond_to?("#{attr}=") 162 | record.attributes.key?(attr.to_s) ? 163 | record[attr] = val : 164 | record.send("#{attr}=", val) 165 | end 166 | end 167 | record 168 | end 169 | 170 | # use created_at as timestamp cache key 171 | def self.collection_cache_key(collection = all, *) 172 | super(collection, :created_at) 173 | end 174 | 175 | private 176 | 177 | def set_version_number 178 | if action == "create" 179 | self.version = 1 180 | else 181 | collection = (ActiveRecord::VERSION::MAJOR >= 6) ? self.class.unscoped : self.class 182 | max = collection.auditable_finder(auditable_id, auditable_type).maximum(:version) || 0 183 | self.version = max + 1 184 | end 185 | end 186 | 187 | def set_audit_user 188 | self.user ||= ::Audited.store[:audited_user] # from .as_user 189 | self.user ||= ::Audited.store[:current_user].try!(:call) # from Sweeper 190 | nil # prevent stopping callback chains 191 | end 192 | 193 | def set_request_uuid 194 | self.request_uuid ||= ::Audited.store[:current_request_uuid] 195 | self.request_uuid ||= SecureRandom.uuid 196 | end 197 | 198 | def set_remote_address 199 | self.remote_address ||= ::Audited.store[:current_remote_address] 200 | end 201 | end 202 | end 203 | -------------------------------------------------------------------------------- /lib/audited/rspec_matchers.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Audited 4 | module RspecMatchers 5 | # Ensure that the model is audited. 6 | # 7 | # Options: 8 | # * associated_with - tests that the audit makes use of the associated_with option 9 | # * only - tests that the audit makes use of the only option *Overrides except option* 10 | # * except - tests that the audit makes use of the except option 11 | # * requires_comment - if specified, then the audit must require comments through the audit_comment attribute 12 | # * on - tests that the audit makes use of the on option with specified parameters 13 | # 14 | # Example: 15 | # it { should be_audited } 16 | # it { should be_audited.associated_with(:user) } 17 | # it { should be_audited.only(:field_name) } 18 | # it { should be_audited.except(:password) } 19 | # it { should be_audited.requires_comment } 20 | # it { should be_audited.on(:create).associated_with(:user).except(:password) } 21 | # 22 | def be_audited 23 | AuditMatcher.new 24 | end 25 | 26 | # Ensure that the model has associated audits 27 | # 28 | # Example: 29 | # it { should have_associated_audits } 30 | # 31 | def have_associated_audits 32 | AssociatedAuditMatcher.new 33 | end 34 | 35 | class AuditMatcher # :nodoc: 36 | def initialize 37 | @options = {} 38 | end 39 | 40 | def associated_with(model) 41 | @options[:associated_with] = model 42 | self 43 | end 44 | 45 | def only(*fields) 46 | @options[:only] = fields.flatten.map(&:to_s) 47 | self 48 | end 49 | 50 | def except(*fields) 51 | @options[:except] = fields.flatten.map(&:to_s) 52 | self 53 | end 54 | 55 | def requires_comment 56 | @options[:comment_required] = true 57 | self 58 | end 59 | 60 | def on(*actions) 61 | @options[:on] = actions.flatten.map(&:to_sym) 62 | self 63 | end 64 | 65 | def matches?(subject) 66 | @subject = subject 67 | auditing_enabled? && required_checks_for_options_satisfied? 68 | end 69 | 70 | def failure_message 71 | "Expected #{@expectation}" 72 | end 73 | 74 | def negative_failure_message 75 | "Did not expect #{@expectation}" 76 | end 77 | 78 | alias_method :failure_message_when_negated, :negative_failure_message 79 | 80 | def description 81 | description = "audited" 82 | description += " associated with #{@options[:associated_with]}" if @options.key?(:associated_with) 83 | description += " only => #{@options[:only].join ", "}" if @options.key?(:only) 84 | description += " except => #{@options[:except].join(", ")}" if @options.key?(:except) 85 | description += " requires audit_comment" if @options.key?(:comment_required) 86 | 87 | description 88 | end 89 | 90 | protected 91 | 92 | def expects(message) 93 | @expectation = message 94 | end 95 | 96 | def auditing_enabled? 97 | expects "#{model_class} to be audited" 98 | model_class.respond_to?(:auditing_enabled) && model_class.auditing_enabled 99 | end 100 | 101 | def model_class 102 | @subject.class 103 | end 104 | 105 | def associated_with_model? 106 | expects "#{model_class} to record audits to associated model #{@options[:associated_with]}" 107 | model_class.audit_associated_with == @options[:associated_with] 108 | end 109 | 110 | def records_changes_to_specified_fields? 111 | ignored_fields = build_ignored_fields_from_options 112 | 113 | expects "non audited columns (#{model_class.non_audited_columns.inspect}) to match (#{ignored_fields})" 114 | model_class.non_audited_columns.to_set == ignored_fields.to_set 115 | end 116 | 117 | def comment_required_valid? 118 | expects "to require audit_comment before #{model_class.audited_options[:on]} when comment required" 119 | validate_callbacks_include_presence_of_comment? && destroy_callbacks_include_comment_required? 120 | end 121 | 122 | def only_audit_on_designated_callbacks? 123 | { 124 | create: [:after, :audit_create], 125 | update: [:before, :audit_update], 126 | destroy: [:before, :audit_destroy] 127 | }.map do |(action, kind_callback)| 128 | kind, callback = kind_callback 129 | callbacks_for(action, kind: kind).include?(callback) if @options[:on].include?(action) 130 | end.compact.all? 131 | end 132 | 133 | def validate_callbacks_include_presence_of_comment? 134 | if @options[:comment_required] && audited_on_create_or_update? 135 | callbacks_for(:validate).include?(:presence_of_audit_comment) 136 | else 137 | true 138 | end 139 | end 140 | 141 | def audited_on_create_or_update? 142 | model_class.audited_options[:on].include?(:create) || model_class.audited_options[:on].include?(:update) 143 | end 144 | 145 | def destroy_callbacks_include_comment_required? 146 | if @options[:comment_required] && model_class.audited_options[:on].include?(:destroy) 147 | callbacks_for(:destroy).include?(:require_comment) 148 | else 149 | true 150 | end 151 | end 152 | 153 | def requires_comment_before_callbacks? 154 | [:create, :update, :destroy].map do |action| 155 | if @options[:comment_required] && model_class.audited_options[:on].include?(action) 156 | callbacks_for(action).include?(:require_comment) 157 | end 158 | end.compact.all? 159 | end 160 | 161 | def callbacks_for(action, kind: :before) 162 | model_class.send("_#{action}_callbacks").select { |cb| cb.kind == kind }.map(&:filter) 163 | end 164 | 165 | def build_ignored_fields_from_options 166 | default_ignored_attributes = model_class.default_ignored_attributes 167 | 168 | if @options[:only].present? 169 | (default_ignored_attributes | model_class.column_names) - @options[:only] 170 | elsif @options[:except].present? 171 | default_ignored_attributes | @options[:except] 172 | else 173 | default_ignored_attributes 174 | end 175 | end 176 | 177 | def required_checks_for_options_satisfied? 178 | { 179 | only: :records_changes_to_specified_fields?, 180 | except: :records_changes_to_specified_fields?, 181 | comment_required: :comment_required_valid?, 182 | associated_with: :associated_with_model?, 183 | on: :only_audit_on_designated_callbacks? 184 | }.map do |(option, check)| 185 | send(check) if @options[option].present? 186 | end.compact.all? 187 | end 188 | end 189 | 190 | class AssociatedAuditMatcher # :nodoc: 191 | def matches?(subject) 192 | @subject = subject 193 | 194 | association_exists? 195 | end 196 | 197 | def failure_message 198 | "Expected #{model_class} to have associated audits" 199 | end 200 | 201 | def negative_failure_message 202 | "Expected #{model_class} to not have associated audits" 203 | end 204 | 205 | alias_method :failure_message_when_negated, :negative_failure_message 206 | 207 | def description 208 | "has associated audits" 209 | end 210 | 211 | protected 212 | 213 | def model_class 214 | @subject.class 215 | end 216 | 217 | def reflection 218 | model_class.reflect_on_association(:associated_audits) 219 | end 220 | 221 | def association_exists? 222 | !reflection.nil? && 223 | reflection.macro == :has_many && 224 | reflection.options[:class_name] == Audited.audit_class.name 225 | end 226 | end 227 | end 228 | end 229 | -------------------------------------------------------------------------------- /spec/audited/audit_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | 3 | SingleCov.covered! uncovered: 2 # Rails version check 4 | 5 | class CustomAudit < Audited::Audit 6 | def custom_method 7 | "I'm custom!" 8 | end 9 | end 10 | 11 | class TempModel1 < ::ActiveRecord::Base 12 | self.table_name = :companies 13 | end 14 | 15 | class TempModel2 < ::ActiveRecord::Base 16 | self.table_name = :companies 17 | end 18 | 19 | class Models::ActiveRecord::CustomUser < ::ActiveRecord::Base 20 | end 21 | 22 | class Models::ActiveRecord::CustomUserSubclass < Models::ActiveRecord::CustomUser 23 | audited 24 | end 25 | 26 | describe Audited::Audit do 27 | let(:user) { Models::ActiveRecord::User.new name: "Testing" } 28 | 29 | describe "audit class" do 30 | around(:example) do |example| 31 | original_audit_class = Audited.audit_class 32 | 33 | example.run 34 | 35 | Audited.config { |config| config.audit_class = original_audit_class } 36 | end 37 | 38 | context "when a custom audit class is configured" do 39 | it "should be used in place of #{described_class}" do 40 | Audited.config { |config| config.audit_class = "CustomAudit" } 41 | TempModel1.audited 42 | 43 | record = TempModel1.create 44 | 45 | audit = record.audits.first 46 | expect(audit).to be_a CustomAudit 47 | expect(audit.custom_method).to eq "I'm custom!" 48 | end 49 | end 50 | 51 | context "when a custom audit class is not configured" do 52 | it "should default to #{described_class}" do 53 | TempModel2.audited 54 | 55 | record = TempModel2.create 56 | 57 | audit = record.audits.first 58 | expect(audit).to be_a Audited::Audit 59 | expect(audit.respond_to?(:custom_method)).to be false 60 | end 61 | end 62 | end 63 | 64 | describe "#audited_changes" do 65 | let(:audit) { Audited.audit_class.new } 66 | 67 | it "can unserialize yaml from text columns" do 68 | audit.audited_changes = {foo: "bar"} 69 | expect(audit.audited_changes).to eq foo: "bar" 70 | end 71 | 72 | it "does not unserialize from binary columns" do 73 | allow(Audited::YAMLIfTextColumnType).to receive(:text_column?).and_return(false) 74 | audit.audited_changes = {foo: "bar"} 75 | expect(audit.audited_changes).to eq "{:foo=>\"bar\"}" 76 | end 77 | end 78 | 79 | describe "#undo" do 80 | let(:user) { Models::ActiveRecord::User.create(name: "John") } 81 | 82 | it "undos changes" do 83 | user.update_attribute(:name, "Joe") 84 | user.audits.last.undo 85 | user.reload 86 | expect(user.name).to eq("John") 87 | end 88 | 89 | it "undos destroy" do 90 | user.destroy 91 | user.audits.last.undo 92 | user = Models::ActiveRecord::User.find_by(name: "John") 93 | expect(user.name).to eq("John") 94 | end 95 | 96 | it "undos creation" do 97 | user # trigger create 98 | expect { user.audits.last.undo }.to change(Models::ActiveRecord::User, :count).by(-1) 99 | end 100 | 101 | it "fails when trying to undo unknown" do 102 | audit = user.audits.last 103 | audit.action = "oops" 104 | expect { audit.undo }.to raise_error("invalid action given oops") 105 | end 106 | end 107 | 108 | describe "user=" do 109 | it "should be able to set the user to a model object" do 110 | subject.user = user 111 | expect(subject.user).to eq(user) 112 | end 113 | 114 | it "should be able to set the user to nil" do 115 | subject.user_id = 1 116 | subject.user_type = "Models::ActiveRecord::User" 117 | subject.username = "joe" 118 | 119 | subject.user = nil 120 | 121 | expect(subject.user).to be_nil 122 | expect(subject.user_id).to be_nil 123 | expect(subject.user_type).to be_nil 124 | expect(subject.username).to be_nil 125 | end 126 | 127 | it "should be able to set the user to a string" do 128 | subject.user = "test" 129 | expect(subject.user).to eq("test") 130 | end 131 | 132 | it "should clear model when setting to a string" do 133 | subject.user = user 134 | subject.user = "testing" 135 | expect(subject.user_id).to be_nil 136 | expect(subject.user_type).to be_nil 137 | end 138 | 139 | it "should clear the username when setting to a model" do 140 | subject.username = "test" 141 | subject.user = user 142 | expect(subject.username).to be_nil 143 | end 144 | end 145 | 146 | describe "revision" do 147 | it "should recreate attributes" do 148 | user = Models::ActiveRecord::User.create name: "1" 149 | 5.times { |i| user.update_attribute :name, (i + 2).to_s } 150 | 151 | user.audits.each do |audit| 152 | expect(audit.revision.name).to eq(audit.version.to_s) 153 | end 154 | end 155 | 156 | it "should set protected attributes" do 157 | u = Models::ActiveRecord::User.create(name: "Brandon") 158 | u.update_attribute :logins, 1 159 | u.update_attribute :logins, 2 160 | 161 | expect(u.audits[2].revision.logins).to eq(2) 162 | expect(u.audits[1].revision.logins).to eq(1) 163 | expect(u.audits[0].revision.logins).to eq(0) 164 | end 165 | 166 | it "should bypass attribute assignment wrappers" do 167 | u = Models::ActiveRecord::User.create(name: "") 168 | expect(u.audits.first.revision.name).to eq("<Joe>") 169 | end 170 | 171 | it "should work for deleted records" do 172 | user = Models::ActiveRecord::User.create name: "1" 173 | user.destroy 174 | revision = user.audits.last.revision 175 | expect(revision.name).to eq(user.name) 176 | expect(revision).to be_a_new_record 177 | end 178 | end 179 | 180 | describe ".collection_cache_key" do 181 | if ActiveRecord::VERSION::MAJOR >= 5 182 | it "uses created at" do 183 | Audited::Audit.delete_all 184 | audit = Models::ActiveRecord::User.create(name: "John").audits.last 185 | audit.update_columns(created_at: Time.zone.parse("2018-01-01")) 186 | expect(Audited::Audit.collection_cache_key).to match(/-20180101\d+$/) 187 | end 188 | else 189 | it "is not defined" do 190 | expect { Audited::Audit.collection_cache_key }.to raise_error(NoMethodError) 191 | end 192 | end 193 | end 194 | 195 | describe ".assign_revision_attributes" do 196 | it "dups when frozen" do 197 | user.freeze 198 | assigned = Audited::Audit.assign_revision_attributes(user, name: "Bar") 199 | expect(assigned.name).to eq "Bar" 200 | end 201 | 202 | it "ignores unassignable attributes" do 203 | assigned = Audited::Audit.assign_revision_attributes(user, oops: "Bar") 204 | expect(assigned.name).to eq "Testing" 205 | end 206 | end 207 | 208 | it "should set the version number on create" do 209 | user = Models::ActiveRecord::User.create! name: "Set Version Number" 210 | expect(user.audits.first.version).to eq(1) 211 | user.update_attribute :name, "Set to 2" 212 | expect(user.audits.reload.first.version).to eq(1) 213 | expect(user.audits.reload.last.version).to eq(2) 214 | user.destroy 215 | expect(Audited::Audit.where(auditable_type: "Models::ActiveRecord::User", auditable_id: user.id).last.version).to eq(3) 216 | end 217 | 218 | it "should set the request uuid on create" do 219 | user = Models::ActiveRecord::User.create! name: "Set Request UUID" 220 | expect(user.audits.reload.first.request_uuid).not_to be_blank 221 | end 222 | 223 | describe "reconstruct_attributes" do 224 | it "should work with the old way of storing just the new value" do 225 | audits = Audited::Audit.reconstruct_attributes([Audited::Audit.new(audited_changes: {"attribute" => "value"})]) 226 | expect(audits["attribute"]).to eq("value") 227 | end 228 | end 229 | 230 | describe "audited_classes" do 231 | it "should include audited classes" do 232 | expect(Audited::Audit.audited_classes).to include(Models::ActiveRecord::User) 233 | end 234 | 235 | it "should include subclasses" do 236 | expect(Audited::Audit.audited_classes).to include(Models::ActiveRecord::CustomUserSubclass) 237 | end 238 | end 239 | 240 | describe "new_attributes" do 241 | it "should return the audited_changes without modification for create" do 242 | new_attributes = Audited::Audit.new(audited_changes: {int: 1, array: [1]}, action: :create).new_attributes 243 | expect(new_attributes).to eq({"int" => 1, "array" => [1]}) 244 | end 245 | 246 | it "should return a hash that contains the after values of each attribute" do 247 | new_attributes = Audited::Audit.new(audited_changes: {a: [1, 2], b: [3, 4]}, action: :update).new_attributes 248 | expect(new_attributes).to eq({"a" => 2, "b" => 4}) 249 | end 250 | 251 | it "should return the audited_changes without modification for destroy" do 252 | new_attributes = Audited::Audit.new(audited_changes: {int: 1, array: [1]}, action: :destroy).new_attributes 253 | expect(new_attributes).to eq({"int" => 1, "array" => [1]}) 254 | end 255 | end 256 | 257 | describe "old_attributes" do 258 | it "should return the audited_changes without modification for create" do 259 | old_attributes = Audited::Audit.new(audited_changes: {int: 1, array: [1]}, action: :create).new_attributes 260 | expect(old_attributes).to eq({"int" => 1, "array" => [1]}) 261 | end 262 | 263 | it "should return a hash that contains the before values of each attribute" do 264 | old_attributes = Audited::Audit.new(audited_changes: {a: [1, 2], b: [3, 4]}, action: :update).old_attributes 265 | expect(old_attributes).to eq({"a" => 1, "b" => 3}) 266 | end 267 | 268 | it "should return the audited_changes without modification for destroy" do 269 | old_attributes = Audited::Audit.new(audited_changes: {int: 1, array: [1]}, action: :destroy).old_attributes 270 | expect(old_attributes).to eq({"int" => 1, "array" => [1]}) 271 | end 272 | end 273 | 274 | describe "as_user" do 275 | it "should record user objects" do 276 | Audited::Audit.as_user(user) do 277 | company = Models::ActiveRecord::Company.create name: "The auditors" 278 | company.name = "The Auditors, Inc" 279 | company.save 280 | 281 | company.audits.each do |audit| 282 | expect(audit.user).to eq(user) 283 | end 284 | end 285 | end 286 | 287 | it "should support nested as_user" do 288 | Audited::Audit.as_user("sidekiq") do 289 | company = Models::ActiveRecord::Company.create name: "The auditors" 290 | company.name = "The Auditors, Inc" 291 | company.save 292 | expect(company.audits[-1].user).to eq("sidekiq") 293 | 294 | Audited::Audit.as_user(user) do 295 | company.name = "NEW Auditors, Inc" 296 | company.save 297 | expect(company.audits[-1].user).to eq(user) 298 | end 299 | 300 | company.name = "LAST Auditors, Inc" 301 | company.save 302 | expect(company.audits[-1].user).to eq("sidekiq") 303 | end 304 | end 305 | 306 | it "should record usernames" do 307 | Audited::Audit.as_user(user.name) do 308 | company = Models::ActiveRecord::Company.create name: "The auditors" 309 | company.name = "The Auditors, Inc" 310 | company.save 311 | 312 | company.audits.each do |audit| 313 | expect(audit.username).to eq(user.name) 314 | end 315 | end 316 | end 317 | 318 | if ActiveRecord::Base.connection.adapter_name != "SQLite" 319 | it "should be thread safe" do 320 | expect(user.save).to eq(true) 321 | 322 | t1 = Thread.new do 323 | Audited::Audit.as_user(user) do 324 | sleep 1 325 | expect(Models::ActiveRecord::Company.create(name: "The Auditors, Inc").audits.first.user).to eq(user) 326 | end 327 | end 328 | 329 | t2 = Thread.new do 330 | Audited::Audit.as_user(user.name) do 331 | expect(Models::ActiveRecord::Company.create(name: "The Competing Auditors, LLC").audits.first.username).to eq(user.name) 332 | sleep 0.5 333 | end 334 | end 335 | 336 | t1.join 337 | t2.join 338 | end 339 | end 340 | 341 | it "should return the value from the yield block" do 342 | result = Audited::Audit.as_user("foo") do 343 | 42 344 | end 345 | expect(result).to eq(42) 346 | end 347 | 348 | it "should reset audited_user when the yield block raises an exception" do 349 | expect { 350 | Audited::Audit.as_user("foo") do 351 | raise StandardError.new("expected") 352 | end 353 | }.to raise_exception("expected") 354 | expect(Audited.store[:audited_user]).to be_nil 355 | end 356 | end 357 | end 358 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Audited 2 | [![Gem Version](https://img.shields.io/gem/v/audited.svg)](http://rubygems.org/gems/audited) 3 | ![Build Status](https://github.com/collectiveidea/audited/actions/workflows/ci.yml/badge.svg) 4 | [![Code Climate](https://codeclimate.com/github/collectiveidea/audited.svg)](https://codeclimate.com/github/collectiveidea/audited) 5 | [![Ruby Style Guide](https://img.shields.io/badge/code_style-standard-brightgreen.svg)](https://github.com/testdouble/standard) 6 | ======= 7 | 8 | **Audited** (previously acts_as_audited) is an ORM extension that logs all changes to your models. Audited can also record who made those changes, save comments and associate models related to the changes. 9 | 10 | 11 | Audited currently (5.6) works with Rails 7.1, 7.0, 6.1, 6.0, 5.2. 12 | 13 | For Rails 5.0 & 5.1, use gem version 5.4.3 14 | For Rails 4, use gem version 4.x 15 | For Rails 3, use gem version 3.0 or see the [3.0-stable branch](https://github.com/collectiveidea/audited/tree/3.0-stable). 16 | 17 | ## Supported Rubies 18 | 19 | Audited supports and is [tested against](https://github.com/collectiveidea/audited/actions/workflows/ci.yml) the following Ruby versions: 20 | 21 | * 2.3 (only tested on Sqlite due to testing issues with other DBs) 22 | * 2.4 23 | * 2.5 24 | * 2.6 25 | * 2.7 26 | * 3.0 27 | * 3.1 28 | * 3.2 29 | 30 | Audited may work just fine with a Ruby version not listed above, but we can't guarantee that it will. If you'd like to maintain a Ruby that isn't listed, please let us know with a [pull request](https://github.com/collectiveidea/audited/pulls). 31 | 32 | ## Supported ORMs 33 | 34 | Audited is currently ActiveRecord-only. In a previous life, Audited worked with MongoMapper. Use the [4.2-stable branch](https://github.com/collectiveidea/audited/tree/4.2-stable) if you need MongoMapper. 35 | 36 | ## Installation 37 | 38 | Add the gem to your Gemfile: 39 | 40 | ```ruby 41 | gem "audited" 42 | ``` 43 | 44 | And if you're using ```require: false``` you must add initializers like this: 45 | 46 | ```ruby 47 | #./config/initializers/audited.rb 48 | require "audited" 49 | 50 | Audited::Railtie.initializers.each(&:run) 51 | ``` 52 | 53 | Then, from your Rails app directory, create the `audits` table: 54 | 55 | ```bash 56 | $ rails generate audited:install 57 | $ rake db:migrate 58 | ``` 59 | 60 | By default changes are stored in YAML format. If you're using PostgreSQL, then you can use `rails generate audited:install --audited-changes-column-type jsonb` (or `json` for MySQL 5.7+ and Rails 5+) to store audit changes natively with database JSON column types. 61 | 62 | If you're using something other than integer primary keys (e.g. UUID) for your User model, then you can use `rails generate audited:install --audited-user-id-column-type uuid` to customize the `audits` table `user_id` column type. 63 | 64 | #### Upgrading 65 | 66 | If you're already using Audited (or acts_as_audited), your `audits` table may require additional columns. After every upgrade, please run: 67 | 68 | ```bash 69 | $ rails generate audited:upgrade 70 | $ rake db:migrate 71 | ``` 72 | 73 | Upgrading will only make changes if changes are needed. 74 | 75 | 76 | ## Usage 77 | 78 | Simply call `audited` on your models: 79 | 80 | ```ruby 81 | class User < ActiveRecord::Base 82 | audited 83 | end 84 | ``` 85 | 86 | By default, whenever a user is created, updated or destroyed, a new audit is created. 87 | 88 | ```ruby 89 | user = User.create!(name: "Steve") 90 | user.audits.count # => 1 91 | user.update!(name: "Ryan") 92 | user.audits.count # => 2 93 | user.destroy 94 | user.audits.count # => 3 95 | ``` 96 | 97 | Audits contain information regarding what action was taken on the model and what changes were made. 98 | 99 | ```ruby 100 | user.update!(name: "Ryan") 101 | audit = user.audits.last 102 | audit.action # => "update" 103 | audit.audited_changes # => {"name"=>["Steve", "Ryan"]} 104 | ``` 105 | 106 | You can get previous versions of a record by index or date, or list all 107 | revisions. 108 | 109 | ```ruby 110 | user.revisions 111 | user.revision(1) 112 | user.revision_at(Date.parse("2016-01-01")) 113 | ``` 114 | 115 | ### Specifying columns 116 | 117 | By default, a new audit is created for any attribute changes. You can, however, limit the columns to be considered. 118 | 119 | ```ruby 120 | class User < ActiveRecord::Base 121 | # All fields 122 | # audited 123 | 124 | # Single field 125 | # audited only: :name 126 | 127 | # Multiple fields 128 | # audited only: [:name, :address] 129 | 130 | # All except certain fields 131 | # audited except: :password 132 | end 133 | ``` 134 | 135 | ### Specifying callbacks 136 | 137 | By default, a new audit is created for any Create, Update, Touch (Rails 6+) or Destroy action. You can, however, limit the actions audited. 138 | 139 | ```ruby 140 | class User < ActiveRecord::Base 141 | # All fields and actions 142 | # audited 143 | 144 | # Single field, only audit Update and Destroy (not Create or Touch) 145 | # audited only: :name, on: [:update, :destroy] 146 | end 147 | ``` 148 | 149 | You can ignore the default callbacks globally unless the callback action is specified in your model using the `:on` option. To configure default callback exclusion, put the following in an initializer file (`config/initializers/audited.rb`): 150 | 151 | ```ruby 152 | Audited.ignored_default_callbacks = [:create, :update] # ignore callbacks create and update 153 | ``` 154 | 155 | ### Comments 156 | 157 | You can attach comments to each audit using an `audit_comment` attribute on your model. 158 | 159 | ```ruby 160 | user.update!(name: "Ryan", audit_comment: "Changing name, just because") 161 | user.audits.last.comment # => "Changing name, just because" 162 | ``` 163 | 164 | You can optionally add the `:comment_required` option to your `audited` call to require comments for all audits. 165 | 166 | ```ruby 167 | class User < ActiveRecord::Base 168 | audited :comment_required => true 169 | end 170 | ``` 171 | 172 | You can update an audit only if audit_comment is present. You can optionally add the `:update_with_comment_only` option set to `false` to your `audited` call to turn this behavior off for all audits. 173 | 174 | ```ruby 175 | class User < ActiveRecord::Base 176 | audited :update_with_comment_only => false 177 | end 178 | ``` 179 | 180 | ### Limiting stored audits 181 | 182 | You can limit the number of audits stored for your model. To configure limiting for all audited models, put the following in an initializer file (`config/initializers/audited.rb`): 183 | 184 | ```ruby 185 | Audited.max_audits = 10 # keep only 10 latest audits 186 | ``` 187 | 188 | or customize per model: 189 | 190 | ```ruby 191 | class User < ActiveRecord::Base 192 | audited max_audits: 2 193 | end 194 | ``` 195 | 196 | Whenever an object is updated or destroyed, extra audits are combined with newer ones and the old ones are destroyed. 197 | 198 | ```ruby 199 | user = User.create!(name: "Steve") 200 | user.audits.count # => 1 201 | user.update!(name: "Ryan") 202 | user.audits.count # => 2 203 | user.destroy 204 | user.audits.count # => 2 205 | ``` 206 | 207 | ### Current User Tracking 208 | 209 | If you're using Audited in a Rails application, all audited changes made within a request will automatically be attributed to the current user. By default, Audited uses the `current_user` method in your controller. 210 | 211 | ```ruby 212 | class PostsController < ApplicationController 213 | def create 214 | current_user # => # 215 | @post = Post.create(params[:post]) 216 | @post.audits.last.user # => # 217 | end 218 | end 219 | ``` 220 | 221 | To use a method other than `current_user`, put the following in an initializer file (`config/initializers/audited.rb`): 222 | 223 | ```ruby 224 | Audited.current_user_method = :authenticated_user 225 | ``` 226 | 227 | Outside of a request, Audited can still record the user with the `as_user` method: 228 | 229 | ```ruby 230 | Audited.audit_class.as_user(User.find(1)) do 231 | post.update!(title: "Hello, world!") 232 | end 233 | post.audits.last.user # => # 234 | ``` 235 | 236 | The standard Audited install assumes your User model has an integer primary key type. If this isn't true (e.g. you're using UUID primary keys), you'll need to create a migration to update the `audits` table `user_id` column type. (See Installation above for generator flags if you'd like to regenerate the install migration.) 237 | 238 | #### Custom Audit User 239 | 240 | You might need to use a custom auditor from time to time. This can be done by simply passing in a string: 241 | 242 | ```ruby 243 | class ApplicationController < ActionController::Base 244 | def authenticated_user 245 | if current_user 246 | current_user 247 | else 248 | 'Alexander Fleming' 249 | end 250 | end 251 | end 252 | ``` 253 | 254 | `as_user` also accepts a string, which can be useful for auditing updates made in a CLI environment: 255 | 256 | ```rb 257 | Audited.audit_class.as_user("console-user-#{ENV['SSH_USER']}") do 258 | post.update_attributes!(title: "Hello, world!") 259 | end 260 | post.audits.last.user # => 'console-user-username' 261 | ``` 262 | 263 | If you want to set a specific user as the auditor of the commands in a CLI environment, whether that is a string or an ActiveRecord object, you can use the following command: 264 | 265 | ```rb 266 | Audited.store[:audited_user] = "username" 267 | 268 | # or 269 | 270 | Audited.store[:audited_user] = User.find(1) 271 | ``` 272 | 273 | ### Associated Audits 274 | 275 | Sometimes it's useful to associate an audit with a model other than the one being changed. For instance, given the following models: 276 | 277 | ```ruby 278 | class User < ActiveRecord::Base 279 | belongs_to :company 280 | audited 281 | end 282 | 283 | class Company < ActiveRecord::Base 284 | has_many :users 285 | end 286 | ``` 287 | 288 | Every change to a user is audited, but what if you want to grab all of the audits of users belonging to a particular company? You can add the `:associated_with` option to your `audited` call: 289 | 290 | ```ruby 291 | class User < ActiveRecord::Base 292 | belongs_to :company 293 | audited associated_with: :company 294 | end 295 | 296 | class Company < ActiveRecord::Base 297 | audited 298 | has_many :users 299 | has_associated_audits 300 | end 301 | ``` 302 | 303 | Now, when an audit is created for a user, that user's company is also saved alongside the audit. This makes it much easier (and faster) to access audits indirectly related to a company. 304 | 305 | ```ruby 306 | company = Company.create!(name: "Collective Idea") 307 | user = company.users.create!(name: "Steve") 308 | user.update!(name: "Steve Richert") 309 | user.audits.last.associated # => # 310 | company.associated_audits.last.auditable # => # 311 | ``` 312 | 313 | You can access records' own audits and associated audits in one go: 314 | ```ruby 315 | company.own_and_associated_audits 316 | ``` 317 | 318 | ### Conditional auditing 319 | 320 | If you want to audit only under specific conditions, you can provide conditional options (similar to ActiveModel callbacks) that will ensure your model is only audited for these conditions. 321 | 322 | ```ruby 323 | class User < ActiveRecord::Base 324 | audited if: :active? 325 | 326 | def active? 327 | last_login > 6.months.ago 328 | end 329 | end 330 | ``` 331 | 332 | Just like in ActiveModel, you can use an inline Proc in your conditions: 333 | 334 | ```ruby 335 | class User < ActiveRecord::Base 336 | audited unless: Proc.new { |u| u.ninja? } 337 | end 338 | ``` 339 | 340 | In the above case, the user will only be audited when `User#ninja` is `false`. 341 | 342 | ### Disabling auditing 343 | 344 | If you want to disable auditing temporarily doing certain tasks, there are a few 345 | methods available. 346 | 347 | To disable auditing on a save: 348 | 349 | ```ruby 350 | @user.save_without_auditing 351 | ``` 352 | 353 | or: 354 | 355 | ```ruby 356 | @user.without_auditing do 357 | @user.save 358 | end 359 | ``` 360 | 361 | To disable auditing on a column: 362 | 363 | ```ruby 364 | User.non_audited_columns = [:first_name, :last_name] 365 | ``` 366 | 367 | To disable auditing on an entire model: 368 | 369 | ```ruby 370 | User.auditing_enabled = false 371 | ``` 372 | 373 | To disable auditing on all models: 374 | 375 | ```ruby 376 | Audited.auditing_enabled = false 377 | ``` 378 | 379 | If you have auditing disabled by default on your model you can enable auditing 380 | temporarily. 381 | 382 | ```ruby 383 | User.auditing_enabled = false 384 | @user.save_with_auditing 385 | ``` 386 | 387 | or: 388 | 389 | ```ruby 390 | User.auditing_enabled = false 391 | @user.with_auditing do 392 | @user.save 393 | end 394 | ``` 395 | 396 | ### Encrypted attributes 397 | 398 | If you're using ActiveRecord's encryption (available from Rails 7) to encrypt some attributes, Audited will automatically filter values of these attributes. No additional configuration is required. Changes to encrypted attributes will be logged as `[FILTERED]`. 399 | 400 | ```ruby 401 | class User < ActiveRecord::Base 402 | audited 403 | encrypts :password 404 | end 405 | ``` 406 | 407 | ### Custom `Audit` model 408 | 409 | If you want to extend or modify the audit model, create a new class that 410 | inherits from `Audited::Audit`: 411 | ```ruby 412 | class CustomAudit < Audited::Audit 413 | def some_custom_behavior 414 | "Hiya!" 415 | end 416 | end 417 | ``` 418 | Then set it in an initializer: 419 | ```ruby 420 | # config/initializers/audited.rb 421 | 422 | Audited.config do |config| 423 | config.audit_class = "CustomAudit" 424 | end 425 | ``` 426 | 427 | ### Enum Storage 428 | 429 | In 4.10, the default behavior for enums changed from storing the value synthesized by Rails to the value stored in the DB. You can restore the previous behavior by setting the store_synthesized_enums configuration value: 430 | 431 | ```ruby 432 | # config/initializers/audited.rb 433 | 434 | Audited.store_synthesized_enums = true 435 | ``` 436 | 437 | ## Support 438 | 439 | You can find documentation at: https://www.rubydoc.info/gems/audited 440 | 441 | Or join the [mailing list](http://groups.google.com/group/audited) to get help or offer suggestions. 442 | 443 | ## Contributing 444 | 445 | In the spirit of [free software](http://www.fsf.org/licensing/essays/free-sw.html), **everyone** is encouraged to help improve this project. Here are a few ways _you_ can pitch in: 446 | 447 | * Use prerelease versions of Audited. 448 | * [Report bugs](https://github.com/collectiveidea/audited/issues). 449 | * Fix bugs and submit [pull requests](http://github.com/collectiveidea/audited/pulls). 450 | * Write, clarify or fix documentation. 451 | * Refactor code. 452 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Audited ChangeLog 2 | 3 | ### 5.6.0 (2024-04-05) 4 | 5 | - Removed support for Rails 5.0 and 5.1. 6 | - Replace RequestStore with ActiveSupport::CurrentAttributes - @punkisdead 7 | [#702](https://github.com/collectiveidea/audited/pull/702) 8 | 9 | ### 5.5.0 (2024-04-02) 10 | 11 | - Bad release. Same code as 5.4.1. Use 5.6.0 for updated features. 12 | 13 | ### 5.4.3 (2024-01-11) 14 | 15 | - Ignore readonly columns in audit - @sriddbs 16 | [#692](https://github.com/collectiveidea/audited/pull/692) 17 | - Robustify Rails version checks - @blaet 18 | [#689](https://github.com/collectiveidea/audited/pull/689) 19 | - Ignore callbacks if not specifed on the model 20 | [#679](https://github.com/collectiveidea/audited/pull/679) 21 | 22 | ## 5.4.2 (2023-11-30) 23 | 24 | - Revert replacing RequetStore with ActiveSupport::CurrentAttributes until it is fully tested. 25 | 26 | ## 5.4.1 (2023-11-30) 27 | 28 | - Replace RequestStore with ActiveSupport::CurrentAttributes - @the-spectator 29 | [#673](https://github.com/collectiveidea/audited/pull/673/) 30 | - Don't require railtie when used outside of Rails - @nicduke38degrees 31 | [#665](https://github.com/collectiveidea/audited/pull/665) 32 | 33 | ## 5.4.0 (2023-09-30) 34 | 35 | - Add Rails 7.1 support - @yuki24 36 | [#686](https://github.com/collectiveidea/audited/pull/686) 37 | 38 | ## 5.3.3 (2023-03-24) 39 | 40 | - Use RequestStore instead of Thread.current for thread-safe requests - @tiagocassio 41 | [#669](https://github.com/collectiveidea/audited/pull/669) 42 | - Clean up Touch audits - @mcyoung, @akostadinov 43 | [#668](https://github.com/collectiveidea/audited/pull/668) 44 | 45 | ## 5.3.2 (2023-02-22) 46 | 47 | - Touch audit bug fixes - @mcyoung 48 | [#662](https://github.com/collectiveidea/audited/pull/662) 49 | 50 | ## 5.3.1 (2023-02-21) 51 | 52 | - Ensure touch support doesn't cause double audits - @mcyoung 53 | [#660](https://github.com/collectiveidea/audited/pull/660) 54 | - Testing Improvements - @vlad-psh 55 | [#628](https://github.com/collectiveidea/audited/pull/628) 56 | - Testing Improvements - @mcyoung 57 | [#658](https://github.com/collectiveidea/audited/pull/658) 58 | 59 | ## 5.3.0 (2023-02-14) 60 | 61 | - Audit touch calls - @mcyoung 62 | [#657](https://github.com/collectiveidea/audited/pull/657) 63 | - Allow using with Padrino and other non-Rails projects - @nicduke38degrees 64 | [#655](https://github.com/collectiveidea/audited/pull/655) 65 | - Testing updates - @jdufresne 66 | [#652](https://github.com/collectiveidea/audited/pull/652) 67 | [#653](https://github.com/collectiveidea/audited/pull/653) 68 | 69 | ## 5.2.0 (2023-01-23) 70 | 71 | Improved 72 | 73 | - config.audit_class can take a string or constant - @rocket-turtle 74 | Fixes overzealous change in 5.1.0 where it only took a string. 75 | [#648](https://github.com/collectiveidea/audited/pull/648) 76 | - README link fix - @jeremiahlukus 77 | [#646](https://github.com/collectiveidea/audited/pull/646) 78 | - Typo fix in GitHub Actions - @jdufresne 79 | [#644](https://github.com/collectiveidea/audited/pull/644) 80 | 81 | ## 5.1.0 (2022-12-23) 82 | 83 | Changed 84 | 85 | - config.audit_class takes a string - @simmerz 86 | [#609](https://github.com/collectiveidea/audited/pull/609) 87 | - Filter encrypted attributes automatically - @vlad-psh 88 | [#630](https://github.com/collectiveidea/audited/pull/630) 89 | 90 | Improved 91 | 92 | - README improvements - @jess, @mstroming 93 | [#605](https://github.com/collectiveidea/audited/pull/605) 94 | [#640](https://github.com/collectiveidea/audited/issues/640) 95 | - Ignore deadlocks in concurrent audit combinations - @Crammaman 96 | [#621](https://github.com/collectiveidea/audited/pull/621) 97 | - Fix timestamped_migrations deprecation warning - @shouichi 98 | [#624](https://github.com/collectiveidea/audited/pull/624) 99 | - Ensure audits are re-enabled after blocks - @dcorlett 100 | [#632](https://github.com/collectiveidea/audited/pull/632) 101 | - Replace raw string where clause with query methods - @macowie 102 | [#642](https://github.com/collectiveidea/audited/pull/642) 103 | - Test against more Ruby/Rails Versions - @enomotodev, @danielmorrison 104 | [#610](https://github.com/collectiveidea/audited/pull/610) 105 | [#643](https://github.com/collectiveidea/audited/pull/643) 106 | 107 | ## 5.0.2 (2021-09-16) 108 | 109 | Added 110 | 111 | - Relax ActiveRecord version constraint to support Rails 7 112 | [#597](https://github.com/collectiveidea/audited/pull/597) 113 | 114 | Improved 115 | 116 | - Improve loading - @mvastola 117 | [#592](https://github.com/collectiveidea/audited/pull/592) 118 | - Update README - @danirod, @clement1234 119 | [#596](https://github.com/collectiveidea/audited/pull/596) 120 | [#594](https://github.com/collectiveidea/audited/pull/594) 121 | 122 | 123 | ## 5.0.1 (2021-06-11) 124 | 125 | Improved 126 | 127 | - Don't load associated model when auditing is disabled - @nut4k1 128 | [#584](https://github.com/collectiveidea/audited/pull/584) 129 | 130 | ## 5.0.0 (2021-06-10) 131 | 132 | Improved 133 | 134 | - Fixes an issue where array attributes were not deserialized properly - @cfeckardt, @yuki24 135 | [#448](https://github.com/collectiveidea/audited/pull/448) 136 | [#576](https://github.com/collectiveidea/audited/pull/576) 137 | - Improve error message on audit_comment and allow for i18n override - @james 138 | [#523](https://github.com/collectiveidea/audited/pull/523/) 139 | - Don't require a comment if only non-audited fields are changed - @james 140 | [#522](https://github.com/collectiveidea/audited/pull/522/) 141 | - Readme updates - @gourshete 142 | [#525](https://github.com/collectiveidea/audited/pull/525) 143 | - Allow restoring previous enum behavior with flag - @travisofthenorth 144 | [#526](https://github.com/collectiveidea/audited/pull/526) 145 | - Follow Rails Autoloading conventions - @duncanjbrown 146 | [#532](https://github.com/collectiveidea/audited/pull/532) 147 | - Fix own_and_associated_audits for STI Models - @eric-hemasystems 148 | [#533](https://github.com/collectiveidea/audited/pull/533) 149 | - Rails 6.1 Improvements - @okuramasafumi, @marcrohloff 150 | [#563](https://github.com/collectiveidea/audited/pull/563) 151 | [#544](https://github.com/collectiveidea/audited/pull/544) 152 | - Use Thread local variables instead of Fibers - @arathunku 153 | [#568](https://github.com/collectiveidea/audited/pull/568) 154 | 155 | Changed 156 | 157 | - Drop support for Rails 4 - @travisofthenorth 158 | [#527](https://github.com/collectiveidea/audited/pull/527) 159 | 160 | ## 4.10.0 (2021-01-07) 161 | 162 | Added 163 | 164 | - Add redacted option 165 | [#485](https://github.com/collectiveidea/audited/pull/485) 166 | - Rails 6.1. support 167 | [#554](https://github.com/collectiveidea/audited/pull/554) 168 | [#559](https://github.com/collectiveidea/audited/pull/559) 169 | 170 | Improved 171 | 172 | - Avoid extra query on first audit version 173 | [#513](https://github.com/collectiveidea/audited/pull/513) 174 | 175 | 176 | ## 4.9.0 (2019-07-17) 177 | 178 | Breaking changes 179 | 180 | - removed block support for `Audit.reconstruct_attributes` 181 | [#437](https://github.com/collectiveidea/audited/pull/437) 182 | - removed `audited_columns`, `non_audited_columns`, `auditing_enabled=` instance methods, 183 | use class methods instead 184 | [#424](https://github.com/collectiveidea/audited/pull/424) 185 | - removed rails 4.1 and 4.0 support 186 | [#431](https://github.com/collectiveidea/audited/pull/431) 187 | 188 | Added 189 | 190 | - Add `with_auditing` methods to enable temporarily 191 | [#502](https://github.com/collectiveidea/audited/pull/502) 192 | - Add `update_with_comment_only` option to control audit creation with only comments 193 | [#327](https://github.com/collectiveidea/audited/pull/327) 194 | - Support for Rails 6.0 and Ruby 2.6 195 | [#494](https://github.com/collectiveidea/audited/pull/494) 196 | 197 | Changed 198 | 199 | - None 200 | 201 | Fixed 202 | 203 | - Ensure enum changes are stored consistently 204 | [#429](https://github.com/collectiveidea/audited/pull/429) 205 | 206 | ## 4.8.0 (2018-08-19) 207 | 208 | Breaking changes 209 | 210 | - None 211 | 212 | Added 213 | 214 | - Add ability to globally disable auditing 215 | [#426](https://github.com/collectiveidea/audited/pull/426) 216 | - Add `own_and_associated_audits` method to auditable models 217 | [#428](https://github.com/collectiveidea/audited/pull/428) 218 | - Ability to nest `as_user` within itself 219 | [#450](https://github.com/collectiveidea/audited/pull/450) 220 | - Private methods can now be used for conditional auditing 221 | [#454](https://github.com/collectiveidea/audited/pull/454) 222 | 223 | Changed 224 | 225 | - Add version to `auditable_index` 226 | [#427](https://github.com/collectiveidea/audited/pull/427) 227 | - Rename audited resource revision `version` attribute to `audit_version` and deprecate `version` attribute 228 | [#443](https://github.com/collectiveidea/audited/pull/443) 229 | 230 | Fixed 231 | 232 | - None 233 | 234 | ## 4.7.1 (2018-04-10) 235 | 236 | Breaking changes 237 | 238 | - None 239 | 240 | Added 241 | 242 | - None 243 | 244 | Changed 245 | 246 | - None 247 | 248 | Fixed 249 | 250 | - Allow use with Rails 5.2 final 251 | 252 | ## 4.7.0 (2018-03-14) 253 | 254 | Breaking changes 255 | 256 | - None 257 | 258 | Added 259 | 260 | - Add `inverse_of: auditable` definition to audit relation 261 | [#413](https://github.com/collectiveidea/audited/pull/413) 262 | - Add functionality to conditionally audit models 263 | [#414](https://github.com/collectiveidea/audited/pull/414) 264 | - Allow limiting number of audits stored 265 | [#405](https://github.com/collectiveidea/audited/pull/405) 266 | 267 | Changed 268 | 269 | - Reduced db calls in `#revisions` method 270 | [#402](https://github.com/collectiveidea/audited/pull/402) 271 | [#403](https://github.com/collectiveidea/audited/pull/403) 272 | - Update supported Ruby and Rails versions 273 | [#404](https://github.com/collectiveidea/audited/pull/404) 274 | [#409](https://github.com/collectiveidea/audited/pull/409) 275 | [#415](https://github.com/collectiveidea/audited/pull/415) 276 | [#416](https://github.com/collectiveidea/audited/pull/416) 277 | 278 | Fixed 279 | 280 | - Ensure that `on` and `except` options jive with `comment_required: true` 281 | [#419](https://github.com/collectiveidea/audited/pull/419) 282 | - Fix RSpec matchers 283 | [#420](https://github.com/collectiveidea/audited/pull/420) 284 | 285 | ## 4.6.0 (2018-01-10) 286 | 287 | Breaking changes 288 | 289 | - None 290 | 291 | Added 292 | 293 | - Add functionality to undo specific audit 294 | [#381](https://github.com/collectiveidea/audited/pull/381) 295 | 296 | Changed 297 | 298 | - Removed duplicate declaration of `non_audited_columns` method 299 | [#365](https://github.com/collectiveidea/audited/pull/365) 300 | - Updated `audited_changes` calculation to support Rails>=5.1 change syntax 301 | [#377](https://github.com/collectiveidea/audited/pull/377) 302 | - Improve index ordering for polymorphic indexes 303 | [#385](https://github.com/collectiveidea/audited/pull/385) 304 | - Update CI to test on newer versions of Ruby and Rails 305 | [#386](https://github.com/collectiveidea/audited/pull/386) 306 | [#387](https://github.com/collectiveidea/audited/pull/387) 307 | [#388](https://github.com/collectiveidea/audited/pull/388) 308 | - Simplify `audited_columns` calculation 309 | [#391](https://github.com/collectiveidea/audited/pull/391) 310 | - Simplify `audited_changes` calculation 311 | [#389](https://github.com/collectiveidea/audited/pull/389) 312 | - Normalize options passed to `audited` method 313 | [#397](https://github.com/collectiveidea/audited/pull/397) 314 | 315 | Fixed 316 | 317 | - Fixed typo in rspec causing incorrect test failure 318 | [#360](https://github.com/collectiveidea/audited/pull/360) 319 | - Allow running specs using rake 320 | [#390](https://github.com/collectiveidea/audited/pull/390) 321 | - Passing an invalid version to `revision` returns `nil` instead of last version 322 | [#384](https://github.com/collectiveidea/audited/pull/384) 323 | - Fix duplicate deceleration warnings 324 | [#399](https://github.com/collectiveidea/audited/pull/399) 325 | 326 | 327 | ## 4.5.0 (2017-05-22) 328 | 329 | Breaking changes 330 | 331 | - None 332 | 333 | Added 334 | 335 | - Support for `user_id` column to be a `uuid` type 336 | [#333](https://github.com/collectiveidea/audited/pull/333) 337 | 338 | Fixed 339 | 340 | - Fix retrieval of user from controller when populated in before callbacks 341 | [#336](https://github.com/collectiveidea/audited/issues/336) 342 | - Fix column type check in serializer for Oracle DB adapter 343 | [#335](https://github.com/collectiveidea/audited/pull/335) 344 | - Fix `non_audited_columns` to allow symbol names 345 | [#351](https://github.com/collectiveidea/audited/pull/351) 346 | 347 | ## 4.4.1 (2017-03-29) 348 | 349 | Fixed 350 | 351 | - Fix ActiveRecord gem dependency to permit 5.1 352 | [#332](https://github.com/collectiveidea/audited/pull/332) 353 | 354 | ## 4.4.0 (2017-03-29) 355 | 356 | Breaking changes 357 | 358 | - None 359 | 360 | Added 361 | 362 | - Support for `audited_changes` to be a `json` or `jsonb` column in PostgreSQL 363 | [#216](https://github.com/collectiveidea/audited/issues/216) 364 | - Allow `Audited::Audit` to be subclassed by configuring `Audited.audit_class` 365 | [#314](https://github.com/collectiveidea/audited/issues/314) 366 | - Support for Ruby on Rails 5.1 367 | [#329](https://github.com/collectiveidea/audited/issues/329) 368 | - Support for Ruby 2.4 369 | [#329](https://github.com/collectiveidea/audited/issues/329) 370 | 371 | Changed 372 | 373 | - Remove rails-observer dependency 374 | [#325](https://github.com/collectiveidea/audited/issues/325) 375 | - Undeprecated `Audited.audit_class` reader 376 | [#314](https://github.com/collectiveidea/audited/issues/314) 377 | 378 | Fixed 379 | 380 | - SQL error in Rails Conditional GET (304 caching) 381 | [#295](https://github.com/collectiveidea/audited/pull/295) 382 | - Fix missing non_audited_columns= configuration setter 383 | [#320](https://github.com/collectiveidea/audited/issues/320) 384 | - Fix migration generators to specify AR migration version 385 | [#329](https://github.com/collectiveidea/audited/issues/329) 386 | 387 | ## 4.3.0 (2016-09-17) 388 | 389 | Breaking changes 390 | 391 | - None 392 | 393 | Added 394 | 395 | - Support singular arguments for options: `on` and `only` 396 | 397 | Fixed 398 | 399 | - Fix auditing instance attributes if "only" option specified 400 | - Allow private / protected callback declarations 401 | - Do not eagerly connect to database 402 | 403 | ## 4.2.2 (2016-08-01) 404 | 405 | - Correct auditing_enabled for STI models 406 | - Properly set table name for mongomapper 407 | 408 | ## 4.2.1 (2016-07-29) 409 | 410 | - Fix bug when only: is a single field. 411 | - update gemspec to use mongomapper 0.13 412 | - sweeper need not run observer for mongomapper 413 | - Make temporary disabling of auditing threadsafe 414 | - Centralize `Audited.store` as thread safe variable store 415 | 416 | ## 4.2.0 (2015-03-31) 417 | 418 | Not yet documented. 419 | 420 | ## 4.0.0 (2014-09-04) 421 | 422 | Not yet documented. 423 | 424 | ## 4.0.0.rc1 (2014-07-30) 425 | 426 | Not yet documented. 427 | 428 | ## 3.0.0 (2012-09-25) 429 | 430 | Not yet documented. 431 | 432 | ## 3.0.0.rc2 (2012-07-09) 433 | 434 | Not yet documented. 435 | 436 | ## 3.0.0.rc1 (2012-04-25) 437 | 438 | Not yet documented. 439 | 440 | ## 2012-04-10 441 | 442 | - Add Audit scopes for creates, updates and destroys [chriswfx] 443 | 444 | ## 2011-10-25 445 | 446 | - Made ignored_attributes configurable [senny] 447 | 448 | ## 2011-09-09 449 | 450 | - Rails 3.x support 451 | - Support for associated audits 452 | - Support for remote IP address storage 453 | - Plenty of bug fixes and refactoring 454 | - [kennethkalmer, ineu, PatrickMa, jrozner, dwarburton, bsiggelkow, dgm] 455 | 456 | ## 2009-01-27 457 | 458 | - Store old and new values for updates, and store all attributes on destroy. 459 | - Refactored revisioning methods to work as expected 460 | 461 | ## 2008-10-10 462 | 463 | - changed to make it work in development mode 464 | 465 | ## 2008-09-24 466 | 467 | - Add ability to record parent record of the record being audited [Kenneth Kalmer] 468 | 469 | ## 2008-04-19 470 | 471 | - refactored to make compatible with dirty tracking in edge rails 472 | and to stop storing both old and new values in a single audit 473 | 474 | ## 2008-04-18 475 | 476 | - Fix NoMethodError when trying to access the :previous revision 477 | on a model that doesn't have previous revisions [Alex Soto] 478 | 479 | ## 2008-03-21 480 | 481 | - added #changed_attributes to get access to the changes before a 482 | save [Chris Parker] 483 | 484 | ## 2007-12-16 485 | 486 | - Added #revision_at for retrieving a revision from a specific 487 | time [Jacob Atzen] 488 | 489 | ## 2007-12-16 490 | 491 | - Fix error when getting revision from audit with no changes 492 | [Geoffrey Wiseman] 493 | 494 | ## 2007-12-16 495 | 496 | - Remove dependency on acts_as_list 497 | 498 | ## 2007-06-17 499 | 500 | - Added support getting previous revisions 501 | 502 | ## 2006-11-17 503 | 504 | - Replaced use of singleton User.current_user with cache sweeper 505 | implementation for auditing the user that made the change 506 | 507 | ## 2006-11-17 508 | 509 | - added migration generator 510 | 511 | ## 2006-08-14 512 | 513 | - incorporated changes from Michael Schuerig to write_attribute 514 | that saves the new value after every change and not just the 515 | first, and performs proper type-casting before doing comparisons 516 | 517 | ## 2006-08-14 518 | 519 | - The "changes" are now saved as a serialized hash 520 | 521 | ## 2006-07-21 522 | 523 | - initial version 524 | -------------------------------------------------------------------------------- /lib/audited/auditor.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Audited 4 | # Specify this act if you want changes to your model to be saved in an 5 | # audit table. This assumes there is an audits table ready. 6 | # 7 | # class User < ActiveRecord::Base 8 | # audited 9 | # end 10 | # 11 | # To store an audit comment set model.audit_comment to your comment before 12 | # a create, update or destroy operation. 13 | # 14 | # See Audited::Auditor::ClassMethods#audited 15 | # for configuration options 16 | module Auditor # :nodoc: 17 | extend ActiveSupport::Concern 18 | 19 | CALLBACKS = [:audit_create, :audit_update, :audit_destroy] 20 | 21 | module ClassMethods 22 | # == Configuration options 23 | # 24 | # 25 | # * +only+ - Only audit the given attributes 26 | # * +except+ - Excludes fields from being saved in the audit log. 27 | # By default, Audited will audit all but these fields: 28 | # 29 | # [self.primary_key, inheritance_column, 'lock_version', 'created_at', 'updated_at'] 30 | # You can add to those by passing one or an array of fields to skip. 31 | # 32 | # class User < ActiveRecord::Base 33 | # audited except: :password 34 | # end 35 | # 36 | # * +require_comment+ - Ensures that audit_comment is supplied before 37 | # any create, update or destroy operation. 38 | # * +max_audits+ - Limits the number of stored audits. 39 | 40 | # * +redacted+ - Changes to these fields will be logged, but the values 41 | # will not. This is useful, for example, if you wish to audit when a 42 | # password is changed, without saving the actual password in the log. 43 | # To store values as something other than '[REDACTED]', pass an argument 44 | # to the redaction_value option. 45 | # 46 | # class User < ActiveRecord::Base 47 | # audited redacted: :password, redaction_value: SecureRandom.uuid 48 | # end 49 | # 50 | # * +if+ - Only audit the model when the given function returns true 51 | # * +unless+ - Only audit the model when the given function returns false 52 | # 53 | # class User < ActiveRecord::Base 54 | # audited :if => :active? 55 | # 56 | # def active? 57 | # self.status == 'active' 58 | # end 59 | # end 60 | # 61 | def audited(options = {}) 62 | # don't allow multiple calls 63 | return if included_modules.include?(Audited::Auditor::AuditedInstanceMethods) 64 | 65 | extend Audited::Auditor::AuditedClassMethods 66 | include Audited::Auditor::AuditedInstanceMethods 67 | 68 | class_attribute :audit_associated_with, instance_writer: false 69 | class_attribute :audited_options, instance_writer: false 70 | attr_accessor :audit_version, :audit_comment 71 | 72 | self.audited_options = options 73 | normalize_audited_options 74 | 75 | self.audit_associated_with = audited_options[:associated_with] 76 | 77 | if audited_options[:comment_required] 78 | validate :presence_of_audit_comment 79 | before_destroy :require_comment if audited_options[:on].include?(:destroy) 80 | end 81 | 82 | has_many :audits, -> { order(version: :asc) }, as: :auditable, class_name: Audited.audit_class.name, inverse_of: :auditable 83 | Audited.audit_class.audited_class_names << to_s 84 | 85 | after_create :audit_create if audited_options[:on].include?(:create) 86 | before_update :audit_update if audited_options[:on].include?(:update) 87 | after_touch :audit_touch if audited_options[:on].include?(:touch) && ::ActiveRecord::VERSION::MAJOR >= 6 88 | before_destroy :audit_destroy if audited_options[:on].include?(:destroy) 89 | 90 | # Define and set after_audit and around_audit callbacks. This might be useful if you want 91 | # to notify a party after the audit has been created or if you want to access the newly-created 92 | # audit. 93 | define_callbacks :audit 94 | set_callback :audit, :after, :after_audit, if: lambda { respond_to?(:after_audit, true) } 95 | set_callback :audit, :around, :around_audit, if: lambda { respond_to?(:around_audit, true) } 96 | 97 | enable_auditing 98 | end 99 | 100 | def has_associated_audits 101 | has_many :associated_audits, as: :associated, class_name: Audited.audit_class.name 102 | end 103 | end 104 | 105 | module AuditedInstanceMethods 106 | REDACTED = "[REDACTED]" 107 | 108 | # Temporarily turns off auditing while saving. 109 | def save_without_auditing 110 | without_auditing { save } 111 | end 112 | 113 | # Executes the block with the auditing callbacks disabled. 114 | # 115 | # @foo.without_auditing do 116 | # @foo.save 117 | # end 118 | # 119 | def without_auditing(&block) 120 | self.class.without_auditing(&block) 121 | end 122 | 123 | # Temporarily turns on auditing while saving. 124 | def save_with_auditing 125 | with_auditing { save } 126 | end 127 | 128 | # Executes the block with the auditing callbacks enabled. 129 | # 130 | # @foo.with_auditing do 131 | # @foo.save 132 | # end 133 | # 134 | def with_auditing(&block) 135 | self.class.with_auditing(&block) 136 | end 137 | 138 | # Gets an array of the revisions available 139 | # 140 | # user.revisions.each do |revision| 141 | # user.name 142 | # user.version 143 | # end 144 | # 145 | def revisions(from_version = 1) 146 | return [] unless audits.from_version(from_version).exists? 147 | 148 | all_audits = audits.select([:audited_changes, :version, :action]).to_a 149 | targeted_audits = all_audits.select { |audit| audit.version >= from_version } 150 | 151 | previous_attributes = reconstruct_attributes(all_audits - targeted_audits) 152 | 153 | targeted_audits.map do |audit| 154 | previous_attributes.merge!(audit.new_attributes) 155 | revision_with(previous_attributes.merge!(version: audit.version)) 156 | end 157 | end 158 | 159 | # Get a specific revision specified by the version number, or +:previous+ 160 | # Returns nil for versions greater than revisions count 161 | def revision(version) 162 | if version == :previous || audits.last.version >= version 163 | revision_with Audited.audit_class.reconstruct_attributes(audits_to(version)) 164 | end 165 | end 166 | 167 | # Find the oldest revision recorded prior to the date/time provided. 168 | def revision_at(date_or_time) 169 | audits = self.audits.up_until(date_or_time) 170 | revision_with Audited.audit_class.reconstruct_attributes(audits) unless audits.empty? 171 | end 172 | 173 | # List of attributes that are audited. 174 | def audited_attributes 175 | audited_attributes = attributes.except(*self.class.non_audited_columns) 176 | audited_attributes = redact_values(audited_attributes) 177 | audited_attributes = filter_encrypted_attrs(audited_attributes) 178 | normalize_enum_changes(audited_attributes) 179 | end 180 | 181 | # Returns a list combined of record audits and associated audits. 182 | def own_and_associated_audits 183 | Audited.audit_class.unscoped.where(auditable: self) 184 | .or(Audited.audit_class.unscoped.where(associated: self)) 185 | .order(created_at: :desc) 186 | end 187 | 188 | # Combine multiple audits into one. 189 | def combine_audits(audits_to_combine) 190 | combine_target = audits_to_combine.last 191 | combine_target.audited_changes = audits_to_combine.pluck(:audited_changes).reduce(&:merge) 192 | combine_target.comment = "#{combine_target.comment}\nThis audit is the result of multiple audits being combined." 193 | 194 | transaction do 195 | begin 196 | combine_target.save! 197 | audits_to_combine.unscope(:limit).where("version < ?", combine_target.version).delete_all 198 | rescue ActiveRecord::Deadlocked 199 | # Ignore Deadlocks, if the same record is getting its old audits combined more than once at the same time then 200 | # both combining operations will be the same. Ignoring this error allows one of the combines to go through successfully. 201 | end 202 | end 203 | end 204 | 205 | protected 206 | 207 | def revision_with(attributes) 208 | dup.tap do |revision| 209 | revision.id = id 210 | revision.send :instance_variable_set, "@new_record", destroyed? 211 | revision.send :instance_variable_set, "@persisted", !destroyed? 212 | revision.send :instance_variable_set, "@readonly", false 213 | revision.send :instance_variable_set, "@destroyed", false 214 | revision.send :instance_variable_set, "@_destroyed", false 215 | revision.send :instance_variable_set, "@marked_for_destruction", false 216 | Audited.audit_class.assign_revision_attributes(revision, attributes) 217 | 218 | # Remove any association proxies so that they will be recreated 219 | # and reference the correct object for this revision. The only way 220 | # to determine if an instance variable is a proxy object is to 221 | # see if it responds to certain methods, as it forwards almost 222 | # everything to its target. 223 | revision.instance_variables.each do |ivar| 224 | proxy = revision.instance_variable_get ivar 225 | if !proxy.nil? && proxy.respond_to?(:proxy_respond_to?) 226 | revision.instance_variable_set ivar, nil 227 | end 228 | end 229 | end 230 | end 231 | 232 | private 233 | 234 | def audited_changes(for_touch: false, exclude_readonly_attrs: false) 235 | all_changes = if for_touch 236 | previous_changes 237 | elsif respond_to?(:changes_to_save) 238 | changes_to_save 239 | else 240 | changes 241 | end 242 | 243 | all_changes = all_changes.except(*self.class.readonly_attributes.to_a) if exclude_readonly_attrs 244 | 245 | filtered_changes = \ 246 | if audited_options[:only].present? 247 | all_changes.slice(*self.class.audited_columns) 248 | else 249 | all_changes.except(*self.class.non_audited_columns) 250 | end 251 | 252 | filtered_changes = normalize_enum_changes(filtered_changes) 253 | 254 | if for_touch && (last_audit = audits.last&.audited_changes) 255 | filtered_changes.reject! do |k, v| 256 | last_audit[k].to_json == v.to_json || 257 | last_audit[k].to_json == v[1].to_json 258 | end 259 | end 260 | 261 | filtered_changes = redact_values(filtered_changes) 262 | filtered_changes = filter_encrypted_attrs(filtered_changes) 263 | filtered_changes.to_hash 264 | end 265 | 266 | def normalize_enum_changes(changes) 267 | return changes if Audited.store_synthesized_enums 268 | 269 | self.class.defined_enums.each do |name, values| 270 | if changes.has_key?(name) 271 | changes[name] = \ 272 | if changes[name].is_a?(Array) 273 | changes[name].map { |v| values[v] } 274 | elsif rails_below?("5.0") 275 | changes[name] 276 | else 277 | values[changes[name]] 278 | end 279 | end 280 | end 281 | changes 282 | end 283 | 284 | def redact_values(filtered_changes) 285 | filter_attr_values( 286 | audited_changes: filtered_changes, 287 | attrs: Array(audited_options[:redacted]).map(&:to_s), 288 | placeholder: audited_options[:redaction_value] || REDACTED 289 | ) 290 | end 291 | 292 | def filter_encrypted_attrs(filtered_changes) 293 | filter_attr_values( 294 | audited_changes: filtered_changes, 295 | attrs: respond_to?(:encrypted_attributes) ? Array(encrypted_attributes).map(&:to_s) : [] 296 | ) 297 | end 298 | 299 | # Replace values for given attrs to a placeholder and return modified hash 300 | # 301 | # @param audited_changes [Hash] Hash of changes to be saved to audited version record 302 | # @param attrs [Array] Array of attrs, values of which will be replaced to placeholder value 303 | # @param placeholder [String] Placeholder to replace original attr values 304 | def filter_attr_values(audited_changes: {}, attrs: [], placeholder: "[FILTERED]") 305 | attrs.each do |attr| 306 | next unless audited_changes.key?(attr) 307 | 308 | changes = audited_changes[attr] 309 | values = changes.is_a?(Array) ? changes.map { placeholder } : placeholder 310 | 311 | audited_changes[attr] = values 312 | end 313 | 314 | audited_changes 315 | end 316 | 317 | def rails_below?(rails_version) 318 | Gem::Version.new(Rails::VERSION::STRING) < Gem::Version.new(rails_version) 319 | end 320 | 321 | def audits_to(version = nil) 322 | if version == :previous 323 | version = if audit_version 324 | audit_version - 1 325 | else 326 | previous = audits.descending.offset(1).first 327 | previous ? previous.version : 1 328 | end 329 | end 330 | audits.to_version(version) 331 | end 332 | 333 | def audit_create 334 | write_audit(action: "create", audited_changes: audited_attributes, 335 | comment: audit_comment) 336 | end 337 | 338 | def audit_update 339 | unless (changes = audited_changes(exclude_readonly_attrs: true)).empty? && (audit_comment.blank? || audited_options[:update_with_comment_only] == false) 340 | write_audit(action: "update", audited_changes: changes, 341 | comment: audit_comment) 342 | end 343 | end 344 | 345 | def audit_touch 346 | unless (changes = audited_changes(for_touch: true, exclude_readonly_attrs: true)).empty? 347 | write_audit(action: "update", audited_changes: changes, 348 | comment: audit_comment) 349 | end 350 | end 351 | 352 | def audit_destroy 353 | unless new_record? 354 | write_audit(action: "destroy", audited_changes: audited_attributes, 355 | comment: audit_comment) 356 | end 357 | end 358 | 359 | def write_audit(attrs) 360 | self.audit_comment = nil 361 | 362 | if auditing_enabled 363 | attrs[:associated] = send(audit_associated_with) unless audit_associated_with.nil? 364 | 365 | run_callbacks(:audit) { 366 | audit = audits.create(attrs) 367 | combine_audits_if_needed if attrs[:action] != "create" 368 | audit 369 | } 370 | end 371 | end 372 | 373 | def presence_of_audit_comment 374 | if comment_required_state? 375 | errors.add(:audit_comment, :blank) unless audit_comment.present? 376 | end 377 | end 378 | 379 | def comment_required_state? 380 | auditing_enabled && 381 | audited_changes.present? && 382 | ((audited_options[:on].include?(:create) && new_record?) || 383 | (audited_options[:on].include?(:update) && persisted? && changed?)) 384 | end 385 | 386 | def combine_audits_if_needed 387 | max_audits = evaluate_max_audits 388 | 389 | if max_audits && (extra_count = audits.count - max_audits) > 0 390 | audits_to_combine = audits.limit(extra_count + 1) 391 | combine_audits(audits_to_combine) 392 | end 393 | end 394 | 395 | def evaluate_max_audits 396 | max_audits = case (option = audited_options[:max_audits]) 397 | when Proc then option.call 398 | when Symbol then send(option) 399 | else 400 | option 401 | end 402 | 403 | Integer(max_audits).abs if max_audits 404 | end 405 | 406 | def require_comment 407 | if auditing_enabled && audit_comment.blank? 408 | errors.add(:audit_comment, :blank) 409 | throw(:abort) 410 | end 411 | end 412 | 413 | CALLBACKS.each do |attr_name| 414 | alias_method "#{attr_name}_callback".to_sym, attr_name 415 | end 416 | 417 | def auditing_enabled 418 | run_conditional_check(audited_options[:if]) && 419 | run_conditional_check(audited_options[:unless], matching: false) && 420 | self.class.auditing_enabled 421 | end 422 | 423 | def run_conditional_check(condition, matching: true) 424 | return true if condition.blank? 425 | return condition.call(self) == matching if condition.respond_to?(:call) 426 | return send(condition) == matching if respond_to?(condition.to_sym, true) 427 | 428 | true 429 | end 430 | 431 | def reconstruct_attributes(audits) 432 | attributes = {} 433 | audits.each { |audit| attributes.merge!(audit.new_attributes) } 434 | attributes 435 | end 436 | end 437 | 438 | module AuditedClassMethods 439 | # Returns an array of columns that are audited. See non_audited_columns 440 | def audited_columns 441 | @audited_columns ||= column_names - non_audited_columns 442 | end 443 | 444 | # We have to calculate this here since column_names may not be available when `audited` is called 445 | def non_audited_columns 446 | @non_audited_columns ||= calculate_non_audited_columns 447 | end 448 | 449 | def non_audited_columns=(columns) 450 | @audited_columns = nil # reset cached audited columns on assignment 451 | @non_audited_columns = columns.map(&:to_s) 452 | end 453 | 454 | # Executes the block with auditing disabled. 455 | # 456 | # Foo.without_auditing do 457 | # @foo.save 458 | # end 459 | # 460 | def without_auditing 461 | auditing_was_enabled = class_auditing_enabled 462 | disable_auditing 463 | yield 464 | ensure 465 | enable_auditing if auditing_was_enabled 466 | end 467 | 468 | # Executes the block with auditing enabled. 469 | # 470 | # Foo.with_auditing do 471 | # @foo.save 472 | # end 473 | # 474 | def with_auditing 475 | auditing_was_enabled = class_auditing_enabled 476 | enable_auditing 477 | yield 478 | ensure 479 | disable_auditing unless auditing_was_enabled 480 | end 481 | 482 | def disable_auditing 483 | self.auditing_enabled = false 484 | end 485 | 486 | def enable_auditing 487 | self.auditing_enabled = true 488 | end 489 | 490 | # All audit operations during the block are recorded as being 491 | # made by +user+. This is not model specific, the method is a 492 | # convenience wrapper around 493 | # @see Audit#as_user. 494 | def audit_as(user, &block) 495 | Audited.audit_class.as_user(user, &block) 496 | end 497 | 498 | def auditing_enabled 499 | class_auditing_enabled && Audited.auditing_enabled 500 | end 501 | 502 | def auditing_enabled=(val) 503 | Audited.store["#{table_name}_auditing_enabled"] = val 504 | end 505 | 506 | def default_ignored_attributes 507 | [primary_key, inheritance_column] | Audited.ignored_attributes 508 | end 509 | 510 | protected 511 | 512 | def normalize_audited_options 513 | audited_options[:on] = Array.wrap(audited_options[:on]) 514 | audited_options[:on] = ([:create, :update, :touch, :destroy] - Audited.ignored_default_callbacks) if audited_options[:on].empty? 515 | audited_options[:only] = Array.wrap(audited_options[:only]).map(&:to_s) 516 | audited_options[:except] = Array.wrap(audited_options[:except]).map(&:to_s) 517 | audited_options[:max_audits] ||= Audited.max_audits 518 | end 519 | 520 | def calculate_non_audited_columns 521 | if audited_options[:only].present? 522 | (column_names | default_ignored_attributes) - audited_options[:only] 523 | elsif audited_options[:except].present? 524 | default_ignored_attributes | audited_options[:except] 525 | else 526 | default_ignored_attributes 527 | end 528 | end 529 | 530 | def class_auditing_enabled 531 | Audited.store.fetch("#{table_name}_auditing_enabled", true) 532 | end 533 | end 534 | end 535 | end 536 | -------------------------------------------------------------------------------- /spec/audited/auditor_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | 3 | # not testing proxy_respond_to? hack / 2 methods / deprecation of `version` 4 | # also, an additional 6 around `after_touch` for Versions before 6. 5 | uncovered = (ActiveRecord::VERSION::MAJOR < 6) ? 15 : 9 6 | SingleCov.covered! uncovered: uncovered 7 | 8 | class ConditionalPrivateCompany < ::ActiveRecord::Base 9 | self.table_name = "companies" 10 | 11 | audited if: :foo? 12 | 13 | private def foo? 14 | true 15 | end 16 | end 17 | 18 | class ConditionalCompany < ::ActiveRecord::Base 19 | self.table_name = "companies" 20 | 21 | audited if: :public? 22 | 23 | def public? 24 | end 25 | end 26 | 27 | class ExclusiveCompany < ::ActiveRecord::Base 28 | self.table_name = "companies" 29 | audited if: proc { false } 30 | end 31 | 32 | class ExclusionaryCompany < ::ActiveRecord::Base 33 | self.table_name = "companies" 34 | 35 | audited unless: :non_profit? 36 | 37 | def non_profit? 38 | end 39 | end 40 | 41 | class ExclusionaryCompany2 < ::ActiveRecord::Base 42 | self.table_name = "companies" 43 | audited unless: proc { |c| c.exclusive? } 44 | 45 | def exclusive? 46 | true 47 | end 48 | end 49 | 50 | class InclusiveCompany < ::ActiveRecord::Base 51 | self.table_name = "companies" 52 | audited if: proc { true } 53 | end 54 | 55 | class InclusiveCompany2 < ::ActiveRecord::Base 56 | self.table_name = "companies" 57 | audited unless: proc { false } 58 | end 59 | 60 | class Secret < ::ActiveRecord::Base 61 | audited 62 | end 63 | 64 | class Secret2 < ::ActiveRecord::Base 65 | audited 66 | self.non_audited_columns = ["delta", "top_secret", "created_at"] 67 | end 68 | 69 | describe Audited::Auditor do 70 | describe "configuration" do 71 | it "should include instance methods" do 72 | expect(Models::ActiveRecord::User.new).to be_a_kind_of(Audited::Auditor::AuditedInstanceMethods) 73 | end 74 | 75 | it "should include class methods" do 76 | expect(Models::ActiveRecord::User).to be_a_kind_of(Audited::Auditor::AuditedClassMethods) 77 | end 78 | 79 | ["created_at", "updated_at", "created_on", "updated_on", "lock_version", "id", "password"].each do |column| 80 | it "should not audit #{column}" do 81 | expect(Models::ActiveRecord::User.non_audited_columns).to include(column) 82 | end 83 | end 84 | 85 | context "should be configurable which conditions are audited" do 86 | subject { ConditionalCompany.new.send(:auditing_enabled) } 87 | 88 | context "when condition method is private" do 89 | subject { ConditionalPrivateCompany.new.send(:auditing_enabled) } 90 | 91 | it { is_expected.to be_truthy } 92 | end 93 | 94 | context "when passing a method name" do 95 | context "when conditions are true" do 96 | before { allow_any_instance_of(ConditionalCompany).to receive(:public?).and_return(true) } 97 | it { is_expected.to be_truthy } 98 | end 99 | 100 | context "when conditions are false" do 101 | before { allow_any_instance_of(ConditionalCompany).to receive(:public?).and_return(false) } 102 | it { is_expected.to be_falsey } 103 | end 104 | end 105 | 106 | context "when passing a Proc" do 107 | context "when conditions are true" do 108 | subject { InclusiveCompany.new.send(:auditing_enabled) } 109 | 110 | it { is_expected.to be_truthy } 111 | end 112 | 113 | context "when conditions are false" do 114 | subject { ExclusiveCompany.new.send(:auditing_enabled) } 115 | it { is_expected.to be_falsey } 116 | end 117 | end 118 | end 119 | 120 | context "should be configurable which conditions aren't audited" do 121 | context "when using a method name" do 122 | subject { ExclusionaryCompany.new.send(:auditing_enabled) } 123 | 124 | context "when conditions are true" do 125 | before { allow_any_instance_of(ExclusionaryCompany).to receive(:non_profit?).and_return(true) } 126 | it { is_expected.to be_falsey } 127 | end 128 | 129 | context "when conditions are false" do 130 | before { allow_any_instance_of(ExclusionaryCompany).to receive(:non_profit?).and_return(false) } 131 | it { is_expected.to be_truthy } 132 | end 133 | end 134 | 135 | context "when using a proc" do 136 | context "when conditions are true" do 137 | subject { ExclusionaryCompany2.new.send(:auditing_enabled) } 138 | it { is_expected.to be_falsey } 139 | end 140 | 141 | context "when conditions are false" do 142 | subject { InclusiveCompany2.new.send(:auditing_enabled) } 143 | it { is_expected.to be_truthy } 144 | end 145 | end 146 | end 147 | 148 | it "should be configurable which attributes are not audited via ignored_attributes" do 149 | Audited.ignored_attributes = ["delta", "top_secret", "created_at", "updated_at"] 150 | 151 | expect(Secret.non_audited_columns).to include("delta", "top_secret", "created_at") 152 | end 153 | 154 | it "should be configurable which attributes are not audited via non_audited_columns=" do 155 | expect(Secret2.non_audited_columns).to include("delta", "top_secret", "created_at") 156 | end 157 | 158 | it "should not save non-audited columns" do 159 | previous = Models::ActiveRecord::User.non_audited_columns 160 | begin 161 | Models::ActiveRecord::User.non_audited_columns += [:favourite_device] 162 | 163 | expect(create_user.audits.first.audited_changes.keys.any? { |col| ["favourite_device", "created_at", "updated_at", "password"].include?(col) }).to eq(false) 164 | ensure 165 | Models::ActiveRecord::User.non_audited_columns = previous 166 | end 167 | end 168 | 169 | it "should not save other columns than specified in 'only' option" do 170 | user = Models::ActiveRecord::UserOnlyPassword.create 171 | user.instance_eval do 172 | def non_column_attr 173 | @non_column_attr 174 | end 175 | 176 | def non_column_attr=(val) 177 | attribute_will_change!("non_column_attr") 178 | @non_column_attr = val 179 | end 180 | end 181 | 182 | user.password = "password" 183 | user.non_column_attr = "some value" 184 | user.save! 185 | expect(user.audits.last.audited_changes.keys).to eq(%w[password]) 186 | end 187 | 188 | it "should save attributes not specified in 'except' option" do 189 | user = Models::ActiveRecord::User.create 190 | user.instance_eval do 191 | def non_column_attr 192 | @non_column_attr 193 | end 194 | 195 | def non_column_attr=(val) 196 | attribute_will_change!("non_column_attr") 197 | @non_column_attr = val 198 | end 199 | end 200 | 201 | user.password = "password" 202 | user.non_column_attr = "some value" 203 | user.save! 204 | expect(user.audits.last.audited_changes.keys).to eq(%w[non_column_attr]) 205 | end 206 | 207 | it "should redact columns specified in 'redacted' option" do 208 | redacted = Audited::Auditor::AuditedInstanceMethods::REDACTED 209 | user = Models::ActiveRecord::UserRedactedPassword.create(password: "password") 210 | user.save! 211 | expect(user.audits.last.audited_changes["password"]).to eq(redacted) 212 | user.password = "new_password" 213 | user.save! 214 | expect(user.audits.last.audited_changes["password"]).to eq([redacted, redacted]) 215 | end 216 | 217 | it "should redact columns specified in 'redacted' option when there are multiple specified" do 218 | redacted = Audited::Auditor::AuditedInstanceMethods::REDACTED 219 | user = 220 | Models::ActiveRecord::UserMultipleRedactedAttributes.create( 221 | password: "password" 222 | ) 223 | user.save! 224 | expect(user.audits.last.audited_changes["password"]).to eq(redacted) 225 | # Saving '[REDACTED]' value for 'ssn' even if value wasn't set explicitly when record was created 226 | expect(user.audits.last.audited_changes["ssn"]).to eq(redacted) 227 | 228 | user.password = "new_password" 229 | user.ssn = 987654321 230 | user.save! 231 | expect(user.audits.last.audited_changes["password"]).to eq([redacted, redacted]) 232 | expect(user.audits.last.audited_changes["ssn"]).to eq([redacted, redacted]) 233 | 234 | # If we haven't changed any attrs from 'redacted' list, audit should not contain these keys 235 | user.name = "new name" 236 | user.save! 237 | expect(user.audits.last.audited_changes).to have_key("name") 238 | expect(user.audits.last.audited_changes).not_to have_key("password") 239 | expect(user.audits.last.audited_changes).not_to have_key("ssn") 240 | end 241 | 242 | it "should redact columns in 'redacted' column with custom option" do 243 | user = Models::ActiveRecord::UserRedactedPasswordCustomRedaction.create(password: "password") 244 | user.save! 245 | expect(user.audits.last.audited_changes["password"]).to eq(["My", "Custom", "Value", 7]) 246 | end 247 | 248 | context "when ignored_default_callbacks is set" do 249 | before { Audited.ignored_default_callbacks = [:create] } 250 | after { Audited.ignored_default_callbacks = [] } 251 | 252 | it "should remove create callback" do 253 | class DefaultCallback < ::ActiveRecord::Base 254 | audited 255 | end 256 | 257 | expect(DefaultCallback.audited_options[:on]).to eq([:update, :touch, :destroy]) 258 | end 259 | 260 | it "should keep create callback if specified" do 261 | class CallbacksSpecified < ::ActiveRecord::Base 262 | audited on: [:create, :update, :destroy] 263 | end 264 | 265 | expect(CallbacksSpecified.audited_options[:on]).to eq([:create, :update, :destroy]) 266 | end 267 | end 268 | 269 | if ::ActiveRecord::VERSION::MAJOR >= 7 270 | it "should filter encrypted attributes" do 271 | user = Models::ActiveRecord::UserWithEncryptedPassword.create(password: "password") 272 | user.save 273 | expect(user.audits.last.audited_changes["password"]).to eq("[FILTERED]") 274 | end 275 | end 276 | 277 | if ActiveRecord::Base.connection.adapter_name == "PostgreSQL" 278 | describe "'json' and 'jsonb' audited_changes column type" do 279 | let(:migrations_path) { SPEC_ROOT.join("support/active_record/postgres") } 280 | 281 | after do 282 | run_migrations(:down, migrations_path) 283 | end 284 | 285 | it "should work if column type is 'json'" do 286 | run_migrations(:up, migrations_path, 1) 287 | Audited::Audit.reset_column_information 288 | expect(Audited::Audit.columns_hash["audited_changes"].sql_type).to eq("json") 289 | 290 | user = Models::ActiveRecord::User.create 291 | user.name = "new name" 292 | user.save! 293 | expect(user.audits.last.audited_changes).to eq({"name" => [nil, "new name"]}) 294 | end 295 | 296 | it "should work if column type is 'jsonb'" do 297 | run_migrations(:up, migrations_path, 2) 298 | Audited::Audit.reset_column_information 299 | expect(Audited::Audit.columns_hash["audited_changes"].sql_type).to eq("jsonb") 300 | 301 | user = Models::ActiveRecord::User.create 302 | user.name = "new name" 303 | user.save! 304 | expect(user.audits.last.audited_changes).to eq({"name" => [nil, "new name"]}) 305 | end 306 | end 307 | end 308 | end 309 | 310 | describe :new do 311 | it "should allow mass assignment of all unprotected attributes" do 312 | yesterday = 1.day.ago 313 | 314 | u = Models::ActiveRecord::NoAttributeProtectionUser.new(name: "name", 315 | username: "username", 316 | password: "password", 317 | activated: true, 318 | suspended_at: yesterday, 319 | logins: 2) 320 | 321 | expect(u.name).to eq("name") 322 | expect(u.username).to eq("username") 323 | expect(u.password).to eq("password") 324 | expect(u.activated).to eq(true) 325 | expect(u.suspended_at.to_i).to eq(yesterday.to_i) 326 | expect(u.logins).to eq(2) 327 | end 328 | end 329 | 330 | describe "on create" do 331 | let(:user) { create_user status: :reliable, audit_comment: "Create" } 332 | 333 | it "should change the audit count" do 334 | expect { 335 | user 336 | }.to change(Audited::Audit, :count).by(1) 337 | end 338 | 339 | it "should create associated audit" do 340 | expect(user.audits.count).to eq(1) 341 | end 342 | 343 | it "should set the action to create" do 344 | expect(user.audits.first.action).to eq("create") 345 | expect(Audited::Audit.creates.order(:id).last).to eq(user.audits.first) 346 | expect(user.audits.creates.count).to eq(1) 347 | expect(user.audits.updates.count).to eq(0) 348 | expect(user.audits.destroys.count).to eq(0) 349 | end 350 | 351 | it "should store all the audited attributes" do 352 | expect(user.audits.first.audited_changes).to eq(user.audited_attributes) 353 | end 354 | 355 | it "should store enum value" do 356 | expect(user.audits.first.audited_changes["status"]).to eq(1) 357 | end 358 | 359 | context "when store_synthesized_enums is set to true" do 360 | before { Audited.store_synthesized_enums = true } 361 | after { Audited.store_synthesized_enums = false } 362 | 363 | it "should store enum value as Rails synthesized value" do 364 | expect(user.audits.first.audited_changes["status"]).to eq("reliable") 365 | end 366 | end 367 | 368 | it "should store comment" do 369 | expect(user.audits.first.comment).to eq("Create") 370 | end 371 | 372 | it "should not audit an attribute which is excepted if specified on create or destroy" do 373 | on_create_destroy_except_name = Models::ActiveRecord::OnCreateDestroyExceptName.create(name: "Bart") 374 | expect(on_create_destroy_except_name.audits.first.audited_changes.keys.any? { |col| ["name"].include? col }).to eq(false) 375 | end 376 | 377 | it "should not save an audit if only specified on update/destroy" do 378 | expect { 379 | Models::ActiveRecord::OnUpdateDestroy.create!(name: "Bart") 380 | }.to_not change(Audited::Audit, :count) 381 | end 382 | 383 | it "should save readonly columns" do 384 | expect { 385 | Models::ActiveRecord::UserWithReadOnlyAttrs.create!(name: "Bart") 386 | }.to change(Audited::Audit, :count) 387 | end 388 | end 389 | 390 | describe "on update" do 391 | before do 392 | @user = create_user(name: "Brandon", status: :active, audit_comment: "Update") 393 | end 394 | 395 | it "should save an audit" do 396 | expect { 397 | @user.update_attribute(:name, "Someone") 398 | }.to change(Audited::Audit, :count).by(1) 399 | expect { 400 | @user.update_attribute(:name, "Someone else") 401 | }.to change(Audited::Audit, :count).by(1) 402 | end 403 | 404 | it "should set the action to 'update'" do 405 | @user.update! name: "Changed" 406 | expect(@user.audits.last.action).to eq("update") 407 | expect(Audited::Audit.updates.order(:id).last).to eq(@user.audits.last) 408 | expect(@user.audits.updates.last).to eq(@user.audits.last) 409 | end 410 | 411 | it "should store the changed attributes" do 412 | @user.update! name: "Changed" 413 | expect(@user.audits.last.audited_changes).to eq({"name" => ["Brandon", "Changed"]}) 414 | end 415 | 416 | it "should store changed enum values" do 417 | @user.update! status: 1 418 | expect(@user.audits.last.audited_changes["status"]).to eq([0, 1]) 419 | end 420 | 421 | it "should store audit comment" do 422 | expect(@user.audits.last.comment).to eq("Update") 423 | end 424 | 425 | it "should not save an audit if only specified on create/destroy" do 426 | on_create_destroy = Models::ActiveRecord::OnCreateDestroy.create(name: "Bart") 427 | expect { 428 | on_create_destroy.update! name: "Changed" 429 | }.to_not change(Audited::Audit, :count) 430 | end 431 | 432 | it "should not save an audit if the value doesn't change after type casting" do 433 | @user.update! logins: 0, activated: true 434 | expect { @user.update_attribute :logins, "0" }.to_not change(Audited::Audit, :count) 435 | expect { @user.update_attribute :activated, 1 }.to_not change(Audited::Audit, :count) 436 | expect { @user.update_attribute :activated, "1" }.to_not change(Audited::Audit, :count) 437 | end 438 | 439 | context "with readonly attributes" do 440 | before do 441 | @user = create_user_with_readonly_attrs(status: "active") 442 | end 443 | 444 | it "should not save readonly columns" do 445 | expect { @user.update! status: "banned" }.to_not change(Audited::Audit, :count) 446 | end 447 | end 448 | 449 | describe "with no dirty changes" do 450 | it "does not create an audit if the record is not changed" do 451 | expect { 452 | @user.save! 453 | }.to_not change(Audited::Audit, :count) 454 | end 455 | 456 | it "creates an audit when an audit comment is present" do 457 | expect { 458 | @user.audit_comment = "Comment" 459 | @user.save! 460 | }.to change(Audited::Audit, :count) 461 | end 462 | end 463 | end 464 | 465 | if ::ActiveRecord::VERSION::MAJOR >= 6 466 | describe "on touch" do 467 | before do 468 | @user = create_user(name: "Brandon", status: :active) 469 | end 470 | 471 | it "should save an audit" do 472 | expect { @user.touch(:suspended_at) }.to change(Audited::Audit, :count).by(1) 473 | end 474 | 475 | it "should set the action to 'update'" do 476 | @user.touch(:suspended_at) 477 | expect(@user.audits.last.action).to eq("update") 478 | expect(Audited::Audit.updates.order(:id).last).to eq(@user.audits.last) 479 | expect(@user.audits.updates.last).to eq(@user.audits.last) 480 | end 481 | 482 | it "should store the changed attributes" do 483 | @user.touch(:suspended_at) 484 | expect(@user.audits.last.audited_changes["suspended_at"][0]).to be_nil 485 | expect(Time.parse(@user.audits.last.audited_changes["suspended_at"][1].to_s)).to be_within(2.seconds).of(Time.current) 486 | end 487 | 488 | it "should store audit comment" do 489 | @user.audit_comment = "Here exists a touch comment" 490 | @user.touch(:suspended_at) 491 | expect(@user.audits.last.action).to eq("update") 492 | expect(@user.audits.last.comment).to eq("Here exists a touch comment") 493 | end 494 | 495 | it "should not save an audit if only specified on create/destroy" do 496 | on_create_destroy = Models::ActiveRecord::OnCreateDestroyUser.create(name: "Bart") 497 | expect { 498 | on_create_destroy.touch(:suspended_at) 499 | }.to_not change(Audited::Audit, :count) 500 | end 501 | 502 | it "should store an audit if touch is the only audit" do 503 | on_touch = Models::ActiveRecord::OnTouchOnly.create(name: "Bart") 504 | expect { 505 | on_touch.update(name: "NotBart") 506 | }.to_not change(Audited::Audit, :count) 507 | expect { 508 | on_touch.touch(:suspended_at) 509 | }.to change(on_touch.audits, :count).from(0).to(1) 510 | 511 | @user.audits.destroy_all 512 | expect(@user.audits).to be_empty 513 | expect { 514 | @user.touch(:suspended_at) 515 | }.to change(@user.audits, :count).from(0).to(1) 516 | end 517 | 518 | context "don't double audit" do 519 | let(:user) { Models::ActiveRecord::Owner.create(name: "OwnerUser", suspended_at: 1.month.ago, companies_attributes: [{name: "OwnedCompany"}]) } 520 | let(:company) { user.companies.first } 521 | 522 | it "should only create 1 (create) audit for object" do 523 | expect(user.audits.count).to eq(1) 524 | expect(user.audits.first.action).to eq("create") 525 | end 526 | 527 | it "should only create 1 (create) audit for nested resource" do 528 | expect(company.audits.count).to eq(1) 529 | expect(company.audits.first.action).to eq("create") 530 | end 531 | 532 | context "after creating" do 533 | it "updating / touching nested resource shouldn't save touch audit on parent object" do 534 | expect { company.touch(:type) }.not_to change(user.audits, :count) 535 | expect { company.update(type: "test") }.not_to change(user.audits, :count) 536 | end 537 | 538 | it "updating / touching parent object shouldn't save previous data" do 539 | expect { user.touch(:suspended_at) }.to change(user.audits, :count).from(1).to(2) 540 | expect(user.audits.last.action).to eq("update") 541 | expect(user.audits.last.audited_changes.keys).to eq(%w[suspended_at]) 542 | end 543 | 544 | it "updating nested resource through parent while changing an enum on parent shouldn't double audit" do 545 | user.status = :reliable 546 | user.companies_attributes = [{name: "test"}] 547 | expect { user.save }.to change(user.audits, :count).from(1).to(2) 548 | expect(user.audits.last.action).to eq("update") 549 | expect(user.audits.last.audited_changes.keys).to eq(%w[status]) 550 | end 551 | end 552 | 553 | context "after updating" do 554 | it "changing nested resource shouldn't audit owner" do 555 | expect { user.update(username: "test") }.to change(user.audits, :count).from(1).to(2) 556 | expect { company.update(type: "test") }.not_to change(user.audits, :count) 557 | 558 | expect { user.touch(:suspended_at) }.to change(user.audits, :count).from(2).to(3) 559 | expect { company.update(type: "another_test") }.not_to change(user.audits, :count) 560 | end 561 | end 562 | end 563 | end 564 | end 565 | 566 | describe "on destroy" do 567 | before do 568 | @user = create_user(status: :active) 569 | end 570 | 571 | it "should save an audit" do 572 | expect { 573 | @user.destroy 574 | }.to change(Audited::Audit, :count) 575 | 576 | expect(@user.audits.size).to eq(2) 577 | end 578 | 579 | it "should set the action to 'destroy'" do 580 | @user.destroy 581 | 582 | expect(@user.audits.last.action).to eq("destroy") 583 | expect(Audited::Audit.destroys.order(:id).last).to eq(@user.audits.last) 584 | expect(@user.audits.destroys.last).to eq(@user.audits.last) 585 | end 586 | 587 | it "should store all of the audited attributes" do 588 | @user.destroy 589 | 590 | expect(@user.audits.last.audited_changes).to eq(@user.audited_attributes) 591 | end 592 | 593 | it "should store enum value" do 594 | @user.destroy 595 | expect(@user.audits.last.audited_changes["status"]).to eq(0) 596 | end 597 | 598 | it "should be able to reconstruct a destroyed record without history" do 599 | @user.audits.delete_all 600 | @user.destroy 601 | 602 | revision = @user.audits.first.revision 603 | expect(revision.name).to eq(@user.name) 604 | end 605 | 606 | it "should not save an audit if only specified on create/update" do 607 | on_create_update = Models::ActiveRecord::OnCreateUpdate.create!(name: "Bart") 608 | 609 | expect { 610 | on_create_update.destroy 611 | }.to_not change(Audited::Audit, :count) 612 | end 613 | 614 | it "should audit dependent destructions" do 615 | owner = Models::ActiveRecord::Owner.create! 616 | company = owner.companies.create! 617 | 618 | expect { 619 | owner.destroy 620 | }.to change(Audited::Audit, :count) 621 | 622 | expect(company.audits.map { |a| a.action }).to eq(["create", "destroy"]) 623 | end 624 | end 625 | 626 | describe "on destroy with unsaved object" do 627 | let(:user) { Models::ActiveRecord::User.new } 628 | 629 | it "should not audit on 'destroy'" do 630 | expect { 631 | user.destroy 632 | }.to_not raise_error 633 | 634 | expect(user.audits).to be_empty 635 | end 636 | end 637 | 638 | describe "associated with" do 639 | let(:owner) { Models::ActiveRecord::Owner.create(name: "Models::ActiveRecord::Owner") } 640 | let(:owned_company) { Models::ActiveRecord::OwnedCompany.create!(name: "The auditors", owner: owner) } 641 | 642 | it "should record the associated object on create" do 643 | expect(owned_company.audits.first.associated).to eq(owner) 644 | end 645 | 646 | it "should store the associated object on update" do 647 | owned_company.update_attribute(:name, "The Auditors") 648 | expect(owned_company.audits.last.associated).to eq(owner) 649 | end 650 | 651 | it "should store the associated object on destroy" do 652 | owned_company.destroy 653 | expect(owned_company.audits.last.associated).to eq(owner) 654 | end 655 | end 656 | 657 | describe "has associated audits" do 658 | let!(:owner) { Models::ActiveRecord::Owner.create!(name: "Models::ActiveRecord::Owner") } 659 | let!(:owned_company) { Models::ActiveRecord::OwnedCompany.create!(name: "The auditors", owner: owner) } 660 | 661 | it "should list the associated audits" do 662 | expect(owner.associated_audits.length).to eq(1) 663 | expect(owner.associated_audits.first.auditable).to eq(owned_company) 664 | end 665 | end 666 | 667 | describe "max_audits" do 668 | it "should respect global setting" do 669 | stub_global_max_audits(10) do 670 | expect(Models::ActiveRecord::User.audited_options[:max_audits]).to eq(10) 671 | end 672 | end 673 | 674 | it "should respect per model setting" do 675 | stub_global_max_audits(10) do 676 | expect(Models::ActiveRecord::MaxAuditsUser.audited_options[:max_audits]).to eq(5) 677 | end 678 | end 679 | 680 | it "should delete old audits when keeped amount exceeded" do 681 | stub_global_max_audits(2) do 682 | user = create_versions(2) 683 | user.update(name: "John") 684 | expect(user.audits.pluck(:version)).to eq([2, 3]) 685 | end 686 | end 687 | 688 | it "should not delete old audits when keeped amount not exceeded" do 689 | stub_global_max_audits(3) do 690 | user = create_versions(2) 691 | user.update(name: "John") 692 | expect(user.audits.pluck(:version)).to eq([1, 2, 3]) 693 | end 694 | end 695 | 696 | it "should delete old extra audits after introducing limit" do 697 | stub_global_max_audits(nil) do 698 | user = Models::ActiveRecord::User.create!(name: "Brandon", username: "brandon") 699 | user.update!(name: "Foobar") 700 | user.update!(name: "Awesome", username: "keepers") 701 | user.update!(activated: true) 702 | 703 | Audited.max_audits = 3 704 | Models::ActiveRecord::User.send(:normalize_audited_options) 705 | user.update!(favourite_device: "Android Phone") 706 | audits = user.audits 707 | 708 | expect(audits.count).to eq(3) 709 | expect(audits[0].audited_changes).to include({"name" => ["Foobar", "Awesome"], "username" => ["brandon", "keepers"]}) 710 | expect(audits[1].audited_changes).to eq({"activated" => [nil, true]}) 711 | expect(audits[2].audited_changes).to eq({"favourite_device" => [nil, "Android Phone"]}) 712 | end 713 | end 714 | 715 | it "should add comment line for combined audit" do 716 | stub_global_max_audits(2) do 717 | user = Models::ActiveRecord::User.create!(name: "Foobar 1") 718 | user.update(name: "Foobar 2", audit_comment: "First audit comment") 719 | user.update(name: "Foobar 3", audit_comment: "Second audit comment") 720 | expect(user.audits.first.comment).to match(/First audit comment.+is the result of multiple/m) 721 | end 722 | end 723 | 724 | def stub_global_max_audits(max_audits) 725 | previous_max_audits = Audited.max_audits 726 | previous_user_audited_options = Models::ActiveRecord::User.audited_options.dup 727 | begin 728 | Audited.max_audits = max_audits 729 | Models::ActiveRecord::User.send(:normalize_audited_options) # reloads audited_options 730 | yield 731 | ensure 732 | Audited.max_audits = previous_max_audits 733 | Models::ActiveRecord::User.audited_options = previous_user_audited_options 734 | end 735 | end 736 | end 737 | 738 | describe "revisions" do 739 | let(:user) { create_versions } 740 | 741 | it "should return an Array of Users" do 742 | expect(user.revisions).to be_a_kind_of(Array) 743 | user.revisions.each { |version| expect(version).to be_a_kind_of Models::ActiveRecord::User } 744 | end 745 | 746 | it "should have one revision for a new record" do 747 | expect(create_user.revisions.size).to eq(1) 748 | end 749 | 750 | it "should have one revision for each audit" do 751 | expect(user.audits.size).to eql(user.revisions.size) 752 | end 753 | 754 | it "should set the attributes for each revision" do 755 | u = Models::ActiveRecord::User.create(name: "Brandon", username: "brandon") 756 | u.update! name: "Foobar" 757 | u.update! name: "Awesome", username: "keepers" 758 | 759 | expect(u.revisions.size).to eql(3) 760 | 761 | expect(u.revisions[0].name).to eql("Brandon") 762 | expect(u.revisions[0].username).to eql("brandon") 763 | 764 | expect(u.revisions[1].name).to eql("Foobar") 765 | expect(u.revisions[1].username).to eql("brandon") 766 | 767 | expect(u.revisions[2].name).to eql("Awesome") 768 | expect(u.revisions[2].username).to eql("keepers") 769 | end 770 | 771 | it "access to only recent revisions" do 772 | u = Models::ActiveRecord::User.create(name: "Brandon", username: "brandon") 773 | u.update! name: "Foobar" 774 | u.update! name: "Awesome", username: "keepers" 775 | 776 | expect(u.revisions(2).size).to eq(2) 777 | 778 | expect(u.revisions(2)[0].name).to eq("Foobar") 779 | expect(u.revisions(2)[0].username).to eq("brandon") 780 | 781 | expect(u.revisions(2)[1].name).to eq("Awesome") 782 | expect(u.revisions(2)[1].username).to eq("keepers") 783 | end 784 | 785 | it "should be empty if no audits exist" do 786 | user.audits.delete_all 787 | expect(user.revisions).to be_empty 788 | end 789 | 790 | it "should ignore attributes that have been deleted" do 791 | user.audits.last.update! audited_changes: {old_attribute: "old value"} 792 | expect { user.revisions }.to_not raise_error 793 | end 794 | end 795 | 796 | describe "revisions" do 797 | let(:user) { create_versions(5) } 798 | 799 | it "should maintain identity" do 800 | expect(user.revision(1)).to eq(user) 801 | end 802 | 803 | it "should find the given revision" do 804 | revision = user.revision(3) 805 | expect(revision).to be_a_kind_of(Models::ActiveRecord::User) 806 | expect(revision.audit_version).to eq(3) 807 | expect(revision.name).to eq("Foobar 3") 808 | end 809 | 810 | it "should find the previous revision with :previous" do 811 | revision = user.revision(:previous) 812 | expect(revision.audit_version).to eq(4) 813 | # expect(revision).to eq(user.revision(4)) 814 | expect(revision.attributes).to eq(user.revision(4).attributes) 815 | end 816 | 817 | it "should be able to get the previous revision repeatedly" do 818 | previous = user.revision(:previous) 819 | expect(previous.audit_version).to eq(4) 820 | expect(previous.revision(:previous).audit_version).to eq(3) 821 | end 822 | 823 | it "should be able to set protected attributes" do 824 | u = Models::ActiveRecord::User.create(name: "Brandon") 825 | u.update_attribute :logins, 1 826 | u.update_attribute :logins, 2 827 | 828 | expect(u.revision(3).logins).to eq(2) 829 | expect(u.revision(2).logins).to eq(1) 830 | expect(u.revision(1).logins).to eq(0) 831 | end 832 | 833 | it "should set attributes directly" do 834 | u = Models::ActiveRecord::User.create(name: "") 835 | expect(u.revision(1).name).to eq("<Joe>") 836 | end 837 | 838 | it "should set the attributes for each revision" do 839 | u = Models::ActiveRecord::User.create(name: "Brandon", username: "brandon") 840 | u.update! name: "Foobar" 841 | u.update! name: "Awesome", username: "keepers" 842 | 843 | expect(u.revision(3).name).to eq("Awesome") 844 | expect(u.revision(3).username).to eq("keepers") 845 | 846 | expect(u.revision(2).name).to eq("Foobar") 847 | expect(u.revision(2).username).to eq("brandon") 848 | 849 | expect(u.revision(1).name).to eq("Brandon") 850 | expect(u.revision(1).username).to eq("brandon") 851 | end 852 | 853 | it "should correctly restore revision with enum" do 854 | u = Models::ActiveRecord::User.create(status: :active) 855 | u.update_attribute(:status, :reliable) 856 | u.update_attribute(:status, :banned) 857 | 858 | expect(u.revision(3)).to be_banned 859 | expect(u.revision(2)).to be_reliable 860 | expect(u.revision(1)).to be_active 861 | end 862 | 863 | it "should be able to get time for first revision" do 864 | suspended_at = Time.zone.now 865 | u = Models::ActiveRecord::User.create(suspended_at: suspended_at) 866 | expect(u.revision(1).suspended_at.to_s).to eq(suspended_at.to_s) 867 | end 868 | 869 | it "should not raise an error when no previous audits exist" do 870 | user.audits.destroy_all 871 | expect { user.revision(:previous) }.to_not raise_error 872 | end 873 | 874 | it "should mark revision's attributes as changed" do 875 | expect(user.revision(1).name_changed?).to eq(true) 876 | end 877 | 878 | it "should record new audit when saving revision" do 879 | expect { 880 | user.revision(1).save! 881 | }.to change(user.audits, :count).by(1) 882 | end 883 | 884 | it "should re-insert destroyed records" do 885 | user.destroy 886 | expect { 887 | user.revision(1).save! 888 | }.to change(Models::ActiveRecord::User, :count).by(1) 889 | end 890 | 891 | it "should return nil for values greater than the number of revisions" do 892 | expect(user.revision(user.revisions.count + 1)).to be_nil 893 | end 894 | 895 | it "should work with array attributes" do 896 | u = Models::ActiveRecord::User.create!(phone_numbers: ["+1 800-444-4444"]) 897 | u.update!(phone_numbers: ["+1 804-222-1111", "+1 317 222-2222"]) 898 | 899 | expect(u.revision(0).phone_numbers).to eq(["+1 804-222-1111", "+1 317 222-2222"]) 900 | expect(u.revision(1).phone_numbers).to eq(["+1 800-444-4444"]) 901 | end 902 | end 903 | 904 | describe "revision_at" do 905 | let(:user) { create_user } 906 | 907 | it "should find the latest revision before the given time" do 908 | audit = user.audits.first 909 | audit.created_at = 1.hour.ago 910 | audit.save! 911 | user.update! name: "updated" 912 | expect(user.revision_at(2.minutes.ago).audit_version).to eq(1) 913 | end 914 | 915 | it "should be nil if given a time before audits" do 916 | expect(user.revision_at(1.week.ago)).to be_nil 917 | end 918 | end 919 | 920 | describe "own_and_associated_audits" do 921 | it "should return audits for self and associated audits" do 922 | owner = Models::ActiveRecord::Owner.create! 923 | company = owner.companies.create! 924 | company.update!(name: "Collective Idea") 925 | 926 | other_owner = Models::ActiveRecord::Owner.create! 927 | other_owner.companies.create! 928 | 929 | expect(owner.own_and_associated_audits).to match_array(owner.audits + company.audits) 930 | end 931 | 932 | it "should return audits for STI classes" do 933 | # Where parent is STI 934 | sti_company = Models::ActiveRecord::Company::STICompany.create! 935 | sti_company.update!(name: "Collective Idea") 936 | expect(sti_company.own_and_associated_audits).to match_array(sti_company.audits) 937 | 938 | # Where associated is STI 939 | owner = Models::ActiveRecord::Owner.create! 940 | company = owner.companies.create! type: "Models::ActiveRecord::OwnedCompany::STICompany" 941 | company.update!(name: "Collective Idea") 942 | expect(owner.own_and_associated_audits).to match_array(owner.audits + company.audits) 943 | end 944 | 945 | it "should order audits by creation time" do 946 | owner = Models::ActiveRecord::Owner.create! 947 | first_audit = owner.audits.first 948 | first_audit.update_column(:created_at, 1.year.ago) 949 | 950 | company = owner.companies.create! 951 | second_audit = company.audits.first 952 | second_audit.update_column(:created_at, 1.month.ago) 953 | 954 | company.update!(name: "Collective Idea") 955 | third_audit = company.audits.last 956 | expect(owner.own_and_associated_audits.to_a).to eq([third_audit, second_audit, first_audit]) 957 | end 958 | end 959 | 960 | describe "without auditing" do 961 | it "should not save an audit when calling #save_without_auditing" do 962 | expect { 963 | u = Models::ActiveRecord::User.new(name: "Brandon") 964 | expect(u.save_without_auditing).to eq(true) 965 | }.to_not change(Audited::Audit, :count) 966 | end 967 | 968 | it "should not save an audit inside of the #without_auditing block" do 969 | expect { 970 | Models::ActiveRecord::User.without_auditing { Models::ActiveRecord::User.create!(name: "Brandon") } 971 | }.to_not change(Audited::Audit, :count) 972 | end 973 | 974 | context "when global audits are disabled" do 975 | it "should re-enable class audits after #without_auditing block" do 976 | Audited.auditing_enabled = false 977 | Models::ActiveRecord::User.without_auditing {} 978 | Audited.auditing_enabled = true 979 | expect(Models::ActiveRecord::User.auditing_enabled).to eql(true) 980 | end 981 | end 982 | 983 | it "should reset auditing status even it raises an exception" do 984 | begin 985 | Models::ActiveRecord::User.without_auditing { raise } 986 | rescue 987 | nil 988 | end 989 | expect(Models::ActiveRecord::User.auditing_enabled).to eq(true) 990 | end 991 | 992 | it "should be thread safe using a #without_auditing block" do 993 | skip if Models::ActiveRecord::User.connection.class.name.include?("SQLite") 994 | 995 | t1 = Thread.new do 996 | expect(Models::ActiveRecord::User.auditing_enabled).to eq(true) 997 | Models::ActiveRecord::User.without_auditing do 998 | expect(Models::ActiveRecord::User.auditing_enabled).to eq(false) 999 | Models::ActiveRecord::User.create!(name: "Bart") 1000 | sleep 1 1001 | expect(Models::ActiveRecord::User.auditing_enabled).to eq(false) 1002 | end 1003 | expect(Models::ActiveRecord::User.auditing_enabled).to eq(true) 1004 | end 1005 | 1006 | t2 = Thread.new do 1007 | sleep 0.5 1008 | expect(Models::ActiveRecord::User.auditing_enabled).to eq(true) 1009 | Models::ActiveRecord::User.create!(name: "Lisa") 1010 | end 1011 | t1.join 1012 | t2.join 1013 | 1014 | expect(Models::ActiveRecord::User.find_by_name("Bart").audits.count).to eq(0) 1015 | expect(Models::ActiveRecord::User.find_by_name("Lisa").audits.count).to eq(1) 1016 | end 1017 | 1018 | it "should not save an audit when auditing is globally disabled" do 1019 | expect(Audited.auditing_enabled).to eq(true) 1020 | Audited.auditing_enabled = false 1021 | expect(Models::ActiveRecord::User.auditing_enabled).to eq(false) 1022 | 1023 | user = create_user 1024 | expect(user.audits.count).to eq(0) 1025 | 1026 | Audited.auditing_enabled = true 1027 | expect(Models::ActiveRecord::User.auditing_enabled).to eq(true) 1028 | 1029 | user.update!(name: "Test") 1030 | expect(user.audits.count).to eq(1) 1031 | Models::ActiveRecord::User.enable_auditing 1032 | end 1033 | end 1034 | 1035 | describe "with auditing" do 1036 | it "should save an audit when calling #save_with_auditing" do 1037 | expect { 1038 | u = Models::ActiveRecord::User.new(name: "Brandon") 1039 | Models::ActiveRecord::User.auditing_enabled = false 1040 | expect(u.save_with_auditing).to eq(true) 1041 | Models::ActiveRecord::User.auditing_enabled = true 1042 | }.to change(Audited::Audit, :count).by(1) 1043 | end 1044 | 1045 | it "should save an audit inside of the #with_auditing block" do 1046 | expect { 1047 | Models::ActiveRecord::User.auditing_enabled = false 1048 | Models::ActiveRecord::User.with_auditing { Models::ActiveRecord::User.create!(name: "Brandon") } 1049 | Models::ActiveRecord::User.auditing_enabled = true 1050 | }.to change(Audited::Audit, :count).by(1) 1051 | end 1052 | 1053 | context "when global audits are disabled" do 1054 | it "should re-enable class audits after #with_auditing block" do 1055 | Audited.auditing_enabled = false 1056 | Models::ActiveRecord::User.with_auditing {} 1057 | Audited.auditing_enabled = true 1058 | expect(Models::ActiveRecord::User.auditing_enabled).to eql(true) 1059 | end 1060 | end 1061 | 1062 | it "should reset auditing status even it raises an exception" do 1063 | Models::ActiveRecord::User.disable_auditing 1064 | begin 1065 | Models::ActiveRecord::User.with_auditing { raise } 1066 | rescue 1067 | nil 1068 | end 1069 | expect(Models::ActiveRecord::User.auditing_enabled).to eq(false) 1070 | Models::ActiveRecord::User.enable_auditing 1071 | end 1072 | 1073 | it "should be thread safe using a #with_auditing block" do 1074 | skip if Models::ActiveRecord::User.connection.class.name.include?("SQLite") 1075 | 1076 | t1 = Thread.new do 1077 | Models::ActiveRecord::User.disable_auditing 1078 | expect(Models::ActiveRecord::User.auditing_enabled).to eq(false) 1079 | Models::ActiveRecord::User.with_auditing do 1080 | expect(Models::ActiveRecord::User.auditing_enabled).to eq(true) 1081 | 1082 | Models::ActiveRecord::User.create!(name: "Shaggy") 1083 | sleep 1 1084 | expect(Models::ActiveRecord::User.auditing_enabled).to eq(true) 1085 | end 1086 | expect(Models::ActiveRecord::User.auditing_enabled).to eq(false) 1087 | Models::ActiveRecord::User.enable_auditing 1088 | end 1089 | 1090 | t2 = Thread.new do 1091 | sleep 0.5 1092 | Models::ActiveRecord::User.disable_auditing 1093 | expect(Models::ActiveRecord::User.auditing_enabled).to eq(false) 1094 | Models::ActiveRecord::User.create!(name: "Scooby") 1095 | Models::ActiveRecord::User.enable_auditing 1096 | end 1097 | t1.join 1098 | t2.join 1099 | 1100 | Models::ActiveRecord::User.enable_auditing 1101 | expect(Models::ActiveRecord::User.find_by_name("Shaggy").audits.count).to eq(1) 1102 | expect(Models::ActiveRecord::User.find_by_name("Scooby").audits.count).to eq(0) 1103 | end 1104 | end 1105 | 1106 | describe "comment required" do 1107 | describe "on create" do 1108 | it "should not validate when audit_comment is not supplied when initialized" do 1109 | expect(Models::ActiveRecord::CommentRequiredUser.new(name: "Foo")).not_to be_valid 1110 | end 1111 | 1112 | it "should not validate when audit_comment is not supplied trying to create" do 1113 | expect(Models::ActiveRecord::CommentRequiredUser.create(name: "Foo")).not_to be_valid 1114 | end 1115 | 1116 | it "should validate when audit_comment is supplied" do 1117 | expect(Models::ActiveRecord::CommentRequiredUser.create(name: "Foo", audit_comment: "Create")).to be_valid 1118 | end 1119 | 1120 | it "should validate when audit_comment is not supplied, and creating is not being audited" do 1121 | expect(Models::ActiveRecord::OnUpdateCommentRequiredUser.create(name: "Foo")).to be_valid 1122 | expect(Models::ActiveRecord::OnDestroyCommentRequiredUser.create(name: "Foo")).to be_valid 1123 | end 1124 | 1125 | it "should validate when audit_comment is not supplied, and auditing is disabled" do 1126 | Models::ActiveRecord::CommentRequiredUser.disable_auditing 1127 | expect(Models::ActiveRecord::CommentRequiredUser.create(name: "Foo")).to be_valid 1128 | Models::ActiveRecord::CommentRequiredUser.enable_auditing 1129 | end 1130 | 1131 | it "should validate when audit_comment is not supplied, and only excluded attributes changed" do 1132 | expect(Models::ActiveRecord::CommentRequiredUser.new(password: "Foo")).to be_valid 1133 | end 1134 | end 1135 | 1136 | describe "on update" do 1137 | let(:user) { Models::ActiveRecord::CommentRequiredUser.create!(audit_comment: "Create") } 1138 | let(:on_create_user) { Models::ActiveRecord::OnDestroyCommentRequiredUser.create } 1139 | let(:on_destroy_user) { Models::ActiveRecord::OnDestroyCommentRequiredUser.create } 1140 | 1141 | it "should not validate when audit_comment is not supplied" do 1142 | expect(user.update(name: "Test")).to eq(false) 1143 | end 1144 | 1145 | it "should validate when audit_comment is not supplied, and updating is not being audited" do 1146 | expect(on_create_user.update(name: "Test")).to eq(true) 1147 | expect(on_destroy_user.update(name: "Test")).to eq(true) 1148 | end 1149 | 1150 | it "should validate when audit_comment is supplied" do 1151 | expect(user.update(name: "Test", audit_comment: "Update")).to eq(true) 1152 | end 1153 | 1154 | it "should validate when audit_comment is not supplied, and auditing is disabled" do 1155 | Models::ActiveRecord::CommentRequiredUser.disable_auditing 1156 | expect(user.update(name: "Test")).to eq(true) 1157 | Models::ActiveRecord::CommentRequiredUser.enable_auditing 1158 | end 1159 | 1160 | it "should validate when audit_comment is not supplied, and only excluded attributes changed" do 1161 | expect(user.update(password: "Test")).to eq(true) 1162 | end 1163 | end 1164 | 1165 | describe "on destroy" do 1166 | let(:user) { Models::ActiveRecord::CommentRequiredUser.create!(audit_comment: "Create") } 1167 | let(:on_create_user) { Models::ActiveRecord::OnCreateCommentRequiredUser.create!(audit_comment: "Create") } 1168 | let(:on_update_user) { Models::ActiveRecord::OnUpdateCommentRequiredUser.create } 1169 | 1170 | it "should not validate when audit_comment is not supplied" do 1171 | expect(user.destroy).to eq(false) 1172 | end 1173 | 1174 | it "should validate when audit_comment is supplied" do 1175 | user.audit_comment = "Destroy" 1176 | expect(user.destroy).to eq(user) 1177 | end 1178 | 1179 | it "should validate when audit_comment is not supplied, and destroying is not being audited" do 1180 | expect(on_create_user.destroy).to eq(on_create_user) 1181 | expect(on_update_user.destroy).to eq(on_update_user) 1182 | end 1183 | 1184 | it "should validate when audit_comment is not supplied, and auditing is disabled" do 1185 | Models::ActiveRecord::CommentRequiredUser.disable_auditing 1186 | expect(user.destroy).to eq(user) 1187 | Models::ActiveRecord::CommentRequiredUser.enable_auditing 1188 | end 1189 | end 1190 | end 1191 | 1192 | describe "no update with comment only" do 1193 | let(:user) { Models::ActiveRecord::NoUpdateWithCommentOnlyUser.create } 1194 | 1195 | it "does not create an audit when only an audit_comment is present" do 1196 | user.audit_comment = "Comment" 1197 | expect { user.save! }.to_not change(Audited::Audit, :count) 1198 | end 1199 | end 1200 | 1201 | describe "attr_protected and attr_accessible" do 1202 | it "should not raise error when attr_accessible is set and protected is false" do 1203 | expect { 1204 | Models::ActiveRecord::AccessibleAfterDeclarationUser.new(name: "No fail!") 1205 | }.to_not raise_error 1206 | end 1207 | 1208 | it "should not rause an error when attr_accessible is declared before audited" do 1209 | expect { 1210 | Models::ActiveRecord::AccessibleAfterDeclarationUser.new(name: "No fail!") 1211 | }.to_not raise_error 1212 | end 1213 | end 1214 | 1215 | describe "audit_as" do 1216 | let(:user) { Models::ActiveRecord::User.create name: "Testing" } 1217 | 1218 | it "should record user objects" do 1219 | Models::ActiveRecord::Company.audit_as(user) do 1220 | company = Models::ActiveRecord::Company.create name: "The auditors" 1221 | company.update! name: "The Auditors" 1222 | 1223 | company.audits.each do |audit| 1224 | expect(audit.user).to eq(user) 1225 | end 1226 | end 1227 | end 1228 | 1229 | it "should record usernames" do 1230 | Models::ActiveRecord::Company.audit_as(user.name) do 1231 | company = Models::ActiveRecord::Company.create name: "The auditors" 1232 | company.update! name: "The Auditors" 1233 | 1234 | company.audits.each do |audit| 1235 | expect(audit.user).to eq(user.name) 1236 | end 1237 | end 1238 | end 1239 | end 1240 | 1241 | describe "after_audit" do 1242 | let(:user) { Models::ActiveRecord::UserWithAfterAudit.new } 1243 | 1244 | it "should invoke after_audit callback on create" do 1245 | expect(user.bogus_attr).to be_nil 1246 | expect(user.save).to eq(true) 1247 | expect(user.bogus_attr).to eq("do something") 1248 | end 1249 | end 1250 | 1251 | describe "around_audit" do 1252 | let(:user) { Models::ActiveRecord::UserWithAfterAudit.new } 1253 | 1254 | it "should invoke around_audit callback on create" do 1255 | expect(user.around_attr).to be_nil 1256 | expect(user.save).to eq(true) 1257 | expect(user.around_attr).to eq(user.audits.last) 1258 | end 1259 | end 1260 | 1261 | describe "STI auditing" do 1262 | it "should correctly disable auditing when using STI" do 1263 | company = Models::ActiveRecord::Company::STICompany.create name: "The auditors" 1264 | expect(company.type).to eq("Models::ActiveRecord::Company::STICompany") 1265 | expect { 1266 | Models::ActiveRecord::Company.auditing_enabled = false 1267 | company.update! name: "STI auditors" 1268 | Models::ActiveRecord::Company.auditing_enabled = true 1269 | }.to_not change(Audited::Audit, :count) 1270 | end 1271 | end 1272 | end 1273 | --------------------------------------------------------------------------------