rails_audit_log 0.9.0 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/README.md +284 -55
- data/Rakefile +5 -0
- data/app/concerns/rails_audit_log/auditable.rb +57 -0
- data/app/concerns/rails_audit_log/controller.rb +29 -0
- data/app/controllers/rails_audit_log/application_controller.rb +1 -0
- data/app/controllers/rails_audit_log/audit_log_entries_controller.rb +1 -0
- data/app/controllers/rails_audit_log/resources_controller.rb +1 -0
- data/app/helpers/rails_audit_log/application_helper.rb +1 -0
- data/app/jobs/rails_audit_log/application_job.rb +1 -0
- data/app/models/rails_audit_log/application_record.rb +1 -0
- data/app/models/rails_audit_log/audit_log_entry.rb +126 -26
- data/lib/generators/rails_audit_log/migrate_from_paper_trail/migrate_from_paper_trail_generator.rb +21 -0
- data/lib/generators/rails_audit_log/migrate_from_paper_trail/templates/migrate_from_paper_trail.rb +99 -0
- data/lib/rails_audit_log/engine.rb +1 -0
- data/lib/rails_audit_log/matchers.rb +56 -0
- data/lib/rails_audit_log/minitest_assertions.rb +36 -0
- data/lib/rails_audit_log/paper_trail_compat.rb +76 -0
- data/lib/rails_audit_log/test_helpers.rb +20 -0
- data/lib/rails_audit_log/version.rb +1 -1
- data/lib/rails_audit_log.rb +171 -9
- metadata +9 -5
|
@@ -1,4 +1,23 @@
|
|
|
1
1
|
module RailsAuditLog
|
|
2
|
+
# Represents a single audited event (+create+, +update+, or +destroy+) for
|
|
3
|
+
# one ActiveRecord record.
|
|
4
|
+
#
|
|
5
|
+
# == Columns
|
|
6
|
+
#
|
|
7
|
+
# [event] One of <tt>"create"</tt>, <tt>"update"</tt>, <tt>"destroy"</tt>.
|
|
8
|
+
# [item_type] Class name of the audited record (e.g. <tt>"Article"</tt>).
|
|
9
|
+
# [item_id] Primary key of the audited record.
|
|
10
|
+
# [object_changes] JSON hash of attribute changes in <tt>[from, to]</tt> form.
|
|
11
|
+
# All three event types use the same format:
|
|
12
|
+
# +create+ stores <tt>[nil, new]</tt> for every column,
|
|
13
|
+
# +update+ stores <tt>[old, new]</tt> for changed columns only,
|
|
14
|
+
# +destroy+ stores <tt>[final, nil]</tt> for every column.
|
|
15
|
+
# [object] JSON snapshot of the record's full attributes before the
|
|
16
|
+
# change (stored when {RailsAuditLog.store_snapshot} is +true+).
|
|
17
|
+
# [whodunnit_snapshot] Display name of the actor at the time of the change.
|
|
18
|
+
# [actor_type / actor_id] Polymorphic reference to the actor record.
|
|
19
|
+
# [reason] Optional free-text reason string.
|
|
20
|
+
# [metadata] Arbitrary JSON hash (request IP, custom lambdas, etc.).
|
|
2
21
|
class AuditLogEntry < ApplicationRecord
|
|
3
22
|
self.table_name = "audit_log_entries"
|
|
4
23
|
|
|
@@ -6,33 +25,63 @@ module RailsAuditLog
|
|
|
6
25
|
BLOB_COLUMNS = %w[object_changes object metadata].freeze
|
|
7
26
|
PERIODS = { "1h" => 1.hour, "24h" => 24.hours, "7d" => 7.days }.freeze
|
|
8
27
|
|
|
28
|
+
# @api private
|
|
9
29
|
def self.configure_connection!
|
|
10
30
|
return unless (opts = RailsAuditLog.connects_to)
|
|
11
31
|
|
|
12
32
|
connects_to(**opts)
|
|
13
33
|
end
|
|
14
34
|
|
|
15
|
-
belongs_to :item,
|
|
35
|
+
belongs_to :item, polymorphic: true, optional: true
|
|
16
36
|
belongs_to :actor, polymorphic: true, optional: true
|
|
17
37
|
|
|
18
|
-
validates :event,
|
|
38
|
+
validates :event, presence: true, inclusion: { in: EVENTS }
|
|
19
39
|
validates :item_type, presence: true
|
|
20
|
-
validates :item_id,
|
|
21
|
-
validate
|
|
40
|
+
validates :item_id, presence: true
|
|
41
|
+
validate :metadata_must_be_a_hash
|
|
22
42
|
|
|
23
|
-
# Event scopes
|
|
43
|
+
# @!group Event scopes
|
|
44
|
+
|
|
45
|
+
# Entries for +create+ events.
|
|
46
|
+
# @return [ActiveRecord::Relation]
|
|
24
47
|
scope :created_events, -> { where(event: "create") }
|
|
48
|
+
|
|
49
|
+
# Entries for +update+ events.
|
|
50
|
+
# @return [ActiveRecord::Relation]
|
|
25
51
|
scope :updated_events, -> { where(event: "update") }
|
|
26
|
-
scope :destroyed_events, -> { where(event: "destroy") }
|
|
27
52
|
|
|
28
|
-
#
|
|
53
|
+
# Entries for +destroy+ events.
|
|
54
|
+
# @return [ActiveRecord::Relation]
|
|
55
|
+
scope :destroyed_events, -> { where(event: "destroy") }
|
|
29
56
|
|
|
57
|
+
# @deprecated Use {.created_events} instead.
|
|
30
58
|
scope :creates, -> { created_events }
|
|
59
|
+
# @deprecated Use {.updated_events} instead.
|
|
31
60
|
scope :updates, -> { updated_events }
|
|
61
|
+
# @deprecated Use {.destroyed_events} instead.
|
|
32
62
|
scope :destroys, -> { destroyed_events }
|
|
33
63
|
|
|
34
|
-
#
|
|
64
|
+
# @!endgroup
|
|
65
|
+
|
|
66
|
+
# @!group Actor / resource scopes
|
|
67
|
+
|
|
68
|
+
# Entries written by a specific actor.
|
|
69
|
+
#
|
|
70
|
+
# @param actor [ActiveRecord::Base] the actor record to filter by
|
|
71
|
+
# @return [ActiveRecord::Relation]
|
|
72
|
+
# @example
|
|
73
|
+
# AuditLogEntry.by_actor(current_user)
|
|
35
74
|
scope :by_actor, ->(actor) { where(actor_type: actor.class.name, actor_id: actor.id) }
|
|
75
|
+
|
|
76
|
+
# Entries for a specific resource class or instance.
|
|
77
|
+
# Pass a class to get all entries for that type; pass an instance for one record.
|
|
78
|
+
#
|
|
79
|
+
# @param resource [Class, ActiveRecord::Base]
|
|
80
|
+
# @return [ActiveRecord::Relation]
|
|
81
|
+
# @example All entries for Article
|
|
82
|
+
# AuditLogEntry.for_resource(Article)
|
|
83
|
+
# @example All entries for one article
|
|
84
|
+
# AuditLogEntry.for_resource(article)
|
|
36
85
|
scope :for_resource, lambda { |resource|
|
|
37
86
|
if resource.is_a?(Class)
|
|
38
87
|
where(item_type: resource.name)
|
|
@@ -41,16 +90,61 @@ module RailsAuditLog
|
|
|
41
90
|
end
|
|
42
91
|
}
|
|
43
92
|
|
|
44
|
-
#
|
|
45
|
-
|
|
46
|
-
|
|
93
|
+
# @!endgroup
|
|
94
|
+
|
|
95
|
+
# @!group Time scopes
|
|
96
|
+
|
|
97
|
+
# Entries created at or after +time+.
|
|
98
|
+
#
|
|
99
|
+
# @param time [Time]
|
|
100
|
+
# @return [ActiveRecord::Relation]
|
|
101
|
+
scope :since, ->(time) { where(created_at: time..) }
|
|
102
|
+
|
|
103
|
+
# Entries created at or before +time+.
|
|
104
|
+
#
|
|
105
|
+
# @param time [Time]
|
|
106
|
+
# @return [ActiveRecord::Relation]
|
|
107
|
+
scope :until, ->(time) { where(created_at: ..time) }
|
|
108
|
+
|
|
109
|
+
# Entries within a named period. Valid keys: <tt>"1h"</tt>, <tt>"24h"</tt>, <tt>"7d"</tt>.
|
|
110
|
+
#
|
|
111
|
+
# @param period [String] one of +PERIODS.keys+
|
|
112
|
+
# @return [ActiveRecord::Relation]
|
|
47
113
|
scope :for_period, ->(period) { where(created_at: PERIODS[period].ago..) }
|
|
48
114
|
|
|
49
|
-
#
|
|
115
|
+
# @!endgroup
|
|
116
|
+
|
|
117
|
+
# Omits the three JSON blob columns (+object_changes+, +object+, +metadata+)
|
|
118
|
+
# from the +SELECT+. Use on index/listing queries where blobs are not
|
|
119
|
+
# displayed to reduce I/O and avoid deserializing large payloads.
|
|
120
|
+
#
|
|
121
|
+
# @return [ActiveRecord::Relation]
|
|
50
122
|
scope :slim, -> { select(column_names - BLOB_COLUMNS) }
|
|
51
123
|
|
|
52
|
-
#
|
|
124
|
+
# Entries where +object_changes+ contains a key matching +attribute+.
|
|
125
|
+
# Uses <tt>json_extract</tt> on SQLite/MySQL and <tt>->></tt> on PostgreSQL.
|
|
126
|
+
#
|
|
127
|
+
# @param attribute [Symbol, String] the attribute name to filter on
|
|
128
|
+
# @return [ActiveRecord::Relation]
|
|
129
|
+
# @example
|
|
130
|
+
# AuditLogEntry.touching(:title)
|
|
131
|
+
# post.audit_log_entries.updated_events.touching(:published_at)
|
|
132
|
+
scope :touching, ->(attribute) {
|
|
133
|
+
if connection.adapter_name =~ /PostgreSQL/i
|
|
134
|
+
# :nocov:
|
|
135
|
+
where("object_changes->>? IS NOT NULL", attribute.to_s)
|
|
136
|
+
# :nocov:
|
|
137
|
+
else
|
|
138
|
+
where("json_extract(object_changes, ?) IS NOT NULL", "$.#{attribute}")
|
|
139
|
+
end
|
|
140
|
+
}
|
|
53
141
|
|
|
142
|
+
# Reconstructs and returns the record's state *before* this entry's change.
|
|
143
|
+
# Uses the +object+ snapshot when available; falls back to deriving prior
|
|
144
|
+
# state from +object_changes+ (or the live record for +update+ entries).
|
|
145
|
+
#
|
|
146
|
+
# @return [ActiveRecord::Base, nil] an unpersisted instance; +nil+ for
|
|
147
|
+
# +create+ entries (there is no prior state)
|
|
54
148
|
def reify
|
|
55
149
|
return nil if event == "create"
|
|
56
150
|
|
|
@@ -82,18 +176,37 @@ module RailsAuditLog
|
|
|
82
176
|
instance
|
|
83
177
|
end
|
|
84
178
|
|
|
179
|
+
# Returns the entry immediately before this one in the version chain for
|
|
180
|
+
# the same record (lower +id+), or +nil+ if this is the first entry.
|
|
181
|
+
#
|
|
182
|
+
# @return [AuditLogEntry, nil]
|
|
85
183
|
def previous
|
|
86
184
|
self.class.where(item_type: item_type, item_id: item_id).where("id < ?", id).order(id: :desc).first
|
|
87
185
|
end
|
|
88
186
|
|
|
187
|
+
# Returns the entry immediately after this one in the version chain for
|
|
188
|
+
# the same record (higher +id+), or +nil+ if this is the last entry.
|
|
189
|
+
#
|
|
190
|
+
# @return [AuditLogEntry, nil]
|
|
89
191
|
def next
|
|
90
192
|
self.class.where(item_type: item_type, item_id: item_id).where("id > ?", id).order(id: :asc).first
|
|
91
193
|
end
|
|
92
194
|
|
|
195
|
+
# Returns the list of attribute (and association) names that changed in
|
|
196
|
+
# this entry, derived from the keys of +object_changes+.
|
|
197
|
+
#
|
|
198
|
+
# @return [Array<String>]
|
|
93
199
|
def changed_attributes
|
|
94
200
|
object_changes&.keys || []
|
|
95
201
|
end
|
|
96
202
|
|
|
203
|
+
# Returns +object_changes+ in a named-key format convenient for display.
|
|
204
|
+
#
|
|
205
|
+
# @return [Hash{String => Hash}] keys are attribute names; values are
|
|
206
|
+
# hashes with +:from+ and +:to+ keys
|
|
207
|
+
# @example
|
|
208
|
+
# entry.diff
|
|
209
|
+
# # => { "title" => { from: "Old", to: "New" }, ... }
|
|
97
210
|
def diff
|
|
98
211
|
return {} unless object_changes
|
|
99
212
|
|
|
@@ -105,18 +218,5 @@ module RailsAuditLog
|
|
|
105
218
|
def metadata_must_be_a_hash
|
|
106
219
|
errors.add(:metadata, "must be a Hash") if metadata.present? && !metadata.is_a?(Hash)
|
|
107
220
|
end
|
|
108
|
-
|
|
109
|
-
public
|
|
110
|
-
|
|
111
|
-
# Attribute scope — uses json_extract (SQLite/MySQL) or ->> (PostgreSQL)
|
|
112
|
-
scope :touching, ->(attribute) {
|
|
113
|
-
if connection.adapter_name =~ /PostgreSQL/i
|
|
114
|
-
# :nocov:
|
|
115
|
-
where("object_changes->>? IS NOT NULL", attribute.to_s)
|
|
116
|
-
# :nocov:
|
|
117
|
-
else
|
|
118
|
-
where("json_extract(object_changes, ?) IS NOT NULL", "$.#{attribute}")
|
|
119
|
-
end
|
|
120
|
-
}
|
|
121
221
|
end
|
|
122
222
|
end
|
data/lib/generators/rails_audit_log/migrate_from_paper_trail/migrate_from_paper_trail_generator.rb
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
require "rails/generators"
|
|
2
|
+
require "rails/generators/active_record"
|
|
3
|
+
|
|
4
|
+
module RailsAuditLog
|
|
5
|
+
module Generators
|
|
6
|
+
class MigrateFromPaperTrailGenerator < Rails::Generators::Base
|
|
7
|
+
include ActiveRecord::Generators::Migration
|
|
8
|
+
|
|
9
|
+
source_root File.expand_path("templates", __dir__)
|
|
10
|
+
|
|
11
|
+
desc "Creates a data migration that copies PaperTrail versions to audit_log_entries."
|
|
12
|
+
|
|
13
|
+
def create_migration_file
|
|
14
|
+
migration_template(
|
|
15
|
+
"migrate_from_paper_trail.rb",
|
|
16
|
+
"db/migrate/migrate_from_paper_trail.rb"
|
|
17
|
+
)
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
data/lib/generators/rails_audit_log/migrate_from_paper_trail/templates/migrate_from_paper_trail.rb
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
require "yaml"
|
|
2
|
+
require "json"
|
|
3
|
+
|
|
4
|
+
# Data migration: copies PaperTrail `versions` rows to `audit_log_entries`.
|
|
5
|
+
#
|
|
6
|
+
# Column mapping:
|
|
7
|
+
# versions.item_type → audit_log_entries.item_type
|
|
8
|
+
# versions.item_id → audit_log_entries.item_id
|
|
9
|
+
# versions.event → audit_log_entries.event (create/update/destroy only)
|
|
10
|
+
# versions.object_changes → audit_log_entries.object_changes (YAML or JSON → JSON)
|
|
11
|
+
# versions.object → audit_log_entries.object (YAML or JSON → JSON)
|
|
12
|
+
# versions.whodunnit → audit_log_entries.whodunnit_snapshot
|
|
13
|
+
# versions.created_at → audit_log_entries.created_at
|
|
14
|
+
#
|
|
15
|
+
# actor_type / actor_id are not populated — PaperTrail stores the actor
|
|
16
|
+
# only as a string (whodunnit), so polymorphic references cannot be inferred.
|
|
17
|
+
#
|
|
18
|
+
# This migration is irreversible.
|
|
19
|
+
class MigrateFromPaperTrail < ActiveRecord::Migration[<%= ActiveRecord::Migration.current_version %>]
|
|
20
|
+
BATCH_SIZE = 1_000
|
|
21
|
+
VALID_EVENTS = %w[create update destroy].freeze
|
|
22
|
+
|
|
23
|
+
# Isolated AR classes to avoid coupling to the host app's models.
|
|
24
|
+
class Version < ActiveRecord::Base # @api private
|
|
25
|
+
self.table_name = "versions"
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
class AuditEntry < ActiveRecord::Base # @api private
|
|
29
|
+
self.table_name = "audit_log_entries"
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def up
|
|
33
|
+
unless table_exists?(:versions)
|
|
34
|
+
say " versions table not found — nothing to migrate."
|
|
35
|
+
return
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
total = Version.count
|
|
39
|
+
migrated = 0
|
|
40
|
+
skipped = 0
|
|
41
|
+
|
|
42
|
+
say_with_time "Migrating #{total} PaperTrail versions → audit_log_entries" do
|
|
43
|
+
Version.in_batches(of: BATCH_SIZE) do |batch|
|
|
44
|
+
rows = batch.filter_map do |v|
|
|
45
|
+
next unless VALID_EVENTS.include?(v.event)
|
|
46
|
+
|
|
47
|
+
{
|
|
48
|
+
event: v.event,
|
|
49
|
+
item_type: v.item_type,
|
|
50
|
+
item_id: v.item_id,
|
|
51
|
+
object_changes: parse_serialized(v.read_attribute_before_type_cast(:object_changes)),
|
|
52
|
+
object: parse_serialized(v.read_attribute_before_type_cast(:object)),
|
|
53
|
+
whodunnit_snapshot: v.whodunnit,
|
|
54
|
+
created_at: v.created_at,
|
|
55
|
+
updated_at: v.created_at
|
|
56
|
+
}
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
skipped += batch.size - rows.size
|
|
60
|
+
migrated += rows.size
|
|
61
|
+
AuditEntry.insert_all(rows) if rows.any?
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
say " Migrated: #{migrated} Skipped (unsupported event): #{skipped}"
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def down
|
|
69
|
+
raise ActiveRecord::IrreversibleMigration,
|
|
70
|
+
"Cannot reverse PaperTrail migration — rows already written to audit_log_entries " \
|
|
71
|
+
"cannot be reliably distinguished from native entries."
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
private
|
|
75
|
+
|
|
76
|
+
# Parses a PaperTrail serialized column (YAML or JSON) and returns a Hash,
|
|
77
|
+
# or nil if the value is blank or unparseable.
|
|
78
|
+
def parse_serialized(value)
|
|
79
|
+
return nil if value.nil? || value.to_s.strip.empty?
|
|
80
|
+
|
|
81
|
+
begin
|
|
82
|
+
parsed = JSON.parse(value)
|
|
83
|
+
return parsed.is_a?(Hash) ? parsed : nil
|
|
84
|
+
rescue JSON::ParserError
|
|
85
|
+
# fall through to YAML
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
begin
|
|
89
|
+
parsed = YAML.safe_load(
|
|
90
|
+
value,
|
|
91
|
+
permitted_classes: [Symbol, Date, Time, DateTime, BigDecimal,
|
|
92
|
+
ActiveSupport::TimeWithZone, ActiveSupport::Duration]
|
|
93
|
+
)
|
|
94
|
+
parsed.is_a?(Hash) ? parsed : nil
|
|
95
|
+
rescue StandardError
|
|
96
|
+
nil
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
@@ -1,24 +1,67 @@
|
|
|
1
1
|
module RailsAuditLog
|
|
2
|
+
# RSpec matchers for asserting audit log behaviour.
|
|
3
|
+
#
|
|
4
|
+
# == Setup
|
|
5
|
+
#
|
|
6
|
+
# # spec/rails_helper.rb
|
|
7
|
+
# require "rails_audit_log/matchers"
|
|
8
|
+
#
|
|
9
|
+
# RSpec.configure do |config|
|
|
10
|
+
# config.include RailsAuditLog::Matchers
|
|
11
|
+
# end
|
|
12
|
+
#
|
|
13
|
+
# == Usage
|
|
14
|
+
#
|
|
15
|
+
# # Assert a record already has an entry
|
|
16
|
+
# expect(post).to have_audit_log_entry(:update).touching(:title)
|
|
17
|
+
#
|
|
18
|
+
# # Assert a block creates a new entry
|
|
19
|
+
# expect { post.update!(title: "New") }.to create_audit_log_entry(event: :update, touching: :title)
|
|
2
20
|
module Matchers
|
|
21
|
+
# Returns a matcher that asserts the record has at least one matching
|
|
22
|
+
# {AuditLogEntry}.
|
|
23
|
+
#
|
|
24
|
+
# @param event [Symbol, String, nil] optional event filter
|
|
25
|
+
# (<tt>:create</tt>, <tt>:update</tt>, or <tt>:destroy</tt>)
|
|
26
|
+
# @return [HaveAuditLogEntry]
|
|
27
|
+
# @example
|
|
28
|
+
# expect(post).to have_audit_log_entry
|
|
29
|
+
# expect(post).to have_audit_log_entry(:update)
|
|
30
|
+
# expect(post).to have_audit_log_entry(:update).touching(:title)
|
|
3
31
|
def have_audit_log_entry(event = nil)
|
|
4
32
|
HaveAuditLogEntry.new(event)
|
|
5
33
|
end
|
|
6
34
|
|
|
35
|
+
# Returns a matcher that asserts the block creates at least one new
|
|
36
|
+
# {AuditLogEntry} matching the given filters.
|
|
37
|
+
#
|
|
38
|
+
# @param event [Symbol, String, nil] optional event filter
|
|
39
|
+
# @param touching [Symbol, String, nil] optional attribute filter
|
|
40
|
+
# @return [CreateAuditLogEntry]
|
|
41
|
+
# @example
|
|
42
|
+
# expect { post.update!(title: "x") }.to create_audit_log_entry(event: :update)
|
|
43
|
+
# expect { post.update!(title: "x") }.to create_audit_log_entry(touching: :title)
|
|
7
44
|
def create_audit_log_entry(event: nil, touching: nil)
|
|
8
45
|
CreateAuditLogEntry.new(event: event, touching: touching)
|
|
9
46
|
end
|
|
10
47
|
|
|
48
|
+
# RSpec matcher — asserts that a record already has a matching entry.
|
|
11
49
|
class HaveAuditLogEntry
|
|
12
50
|
def initialize(event)
|
|
13
51
|
@event = event
|
|
14
52
|
@touching = nil
|
|
15
53
|
end
|
|
16
54
|
|
|
55
|
+
# Chains an attribute filter onto the matcher.
|
|
56
|
+
#
|
|
57
|
+
# @param attribute [Symbol, String]
|
|
58
|
+
# @return [self]
|
|
17
59
|
def touching(attribute)
|
|
18
60
|
@touching = attribute
|
|
19
61
|
self
|
|
20
62
|
end
|
|
21
63
|
|
|
64
|
+
# @api private
|
|
22
65
|
def matches?(record)
|
|
23
66
|
@record = record
|
|
24
67
|
scope = record.audit_log_entries
|
|
@@ -27,14 +70,17 @@ module RailsAuditLog
|
|
|
27
70
|
scope.exists?
|
|
28
71
|
end
|
|
29
72
|
|
|
73
|
+
# @api private
|
|
30
74
|
def failure_message
|
|
31
75
|
"expected #{@record.class}##{@record.id} to have an audit log entry#{qualifier}"
|
|
32
76
|
end
|
|
33
77
|
|
|
78
|
+
# @api private
|
|
34
79
|
def failure_message_when_negated
|
|
35
80
|
"expected #{@record.class}##{@record.id} not to have an audit log entry#{qualifier}"
|
|
36
81
|
end
|
|
37
82
|
|
|
83
|
+
# @api private
|
|
38
84
|
def description
|
|
39
85
|
"have an audit log entry#{qualifier}"
|
|
40
86
|
end
|
|
@@ -49,21 +95,28 @@ module RailsAuditLog
|
|
|
49
95
|
end
|
|
50
96
|
end
|
|
51
97
|
|
|
98
|
+
# RSpec matcher — asserts that a block creates a new matching entry.
|
|
52
99
|
class CreateAuditLogEntry
|
|
53
100
|
def initialize(event:, touching:)
|
|
54
101
|
@event = event
|
|
55
102
|
@touching = touching
|
|
56
103
|
end
|
|
57
104
|
|
|
105
|
+
# Chains an attribute filter onto the matcher.
|
|
106
|
+
#
|
|
107
|
+
# @param attribute [Symbol, String]
|
|
108
|
+
# @return [self]
|
|
58
109
|
def touching(attribute)
|
|
59
110
|
@touching = attribute
|
|
60
111
|
self
|
|
61
112
|
end
|
|
62
113
|
|
|
114
|
+
# @api private
|
|
63
115
|
def supports_block_expectations?
|
|
64
116
|
true
|
|
65
117
|
end
|
|
66
118
|
|
|
119
|
+
# @api private
|
|
67
120
|
def matches?(block)
|
|
68
121
|
@before = matching_scope.count
|
|
69
122
|
block.call
|
|
@@ -71,14 +124,17 @@ module RailsAuditLog
|
|
|
71
124
|
@after > @before
|
|
72
125
|
end
|
|
73
126
|
|
|
127
|
+
# @api private
|
|
74
128
|
def failure_message
|
|
75
129
|
"expected block to create an audit log entry#{qualifier}, but none was created"
|
|
76
130
|
end
|
|
77
131
|
|
|
132
|
+
# @api private
|
|
78
133
|
def failure_message_when_negated
|
|
79
134
|
"expected block not to create an audit log entry#{qualifier}, but one was created"
|
|
80
135
|
end
|
|
81
136
|
|
|
137
|
+
# @api private
|
|
82
138
|
def description
|
|
83
139
|
"create an audit log entry#{qualifier}"
|
|
84
140
|
end
|
|
@@ -1,11 +1,47 @@
|
|
|
1
1
|
module RailsAuditLog
|
|
2
|
+
# Opt-in Minitest assertions for audit log expectations.
|
|
3
|
+
#
|
|
4
|
+
# == Setup
|
|
5
|
+
#
|
|
6
|
+
# # test/test_helper.rb
|
|
7
|
+
# require "rails_audit_log/minitest_assertions"
|
|
8
|
+
#
|
|
9
|
+
# class ActiveSupport::TestCase
|
|
10
|
+
# include RailsAuditLog::MinitestAssertions
|
|
11
|
+
# end
|
|
12
|
+
#
|
|
13
|
+
# == Usage
|
|
14
|
+
#
|
|
15
|
+
# assert_audit_log_entry(post, event: :update, touching: :title)
|
|
16
|
+
# refute_audit_log_entry(post, event: :create)
|
|
2
17
|
module MinitestAssertions
|
|
18
|
+
# Asserts that +record+ has at least one {AuditLogEntry} matching the
|
|
19
|
+
# given filters. Fails with a descriptive message when no entry is found.
|
|
20
|
+
#
|
|
21
|
+
# @param record [ActiveRecord::Base] the audited record to check
|
|
22
|
+
# @param event [Symbol, String, nil] optional event filter
|
|
23
|
+
# (<tt>:create</tt>, <tt>:update</tt>, or <tt>:destroy</tt>)
|
|
24
|
+
# @param touching [Symbol, String, nil] optional attribute name filter
|
|
25
|
+
# @param message [String, nil] custom failure message; auto-generated when nil
|
|
26
|
+
# @return [void]
|
|
27
|
+
# @example
|
|
28
|
+
# assert_audit_log_entry(post, event: :update, touching: :title)
|
|
3
29
|
def assert_audit_log_entry(record, event: nil, touching: nil, message: nil)
|
|
4
30
|
scope = build_scope(record, event, touching)
|
|
5
31
|
msg = message || default_message("to have", record, event, touching)
|
|
6
32
|
assert scope.exists?, msg
|
|
7
33
|
end
|
|
8
34
|
|
|
35
|
+
# Asserts that +record+ has *no* {AuditLogEntry} matching the given filters.
|
|
36
|
+
# Fails with a descriptive message when a matching entry is found.
|
|
37
|
+
#
|
|
38
|
+
# @param record [ActiveRecord::Base] the audited record to check
|
|
39
|
+
# @param event [Symbol, String, nil] optional event filter
|
|
40
|
+
# @param touching [Symbol, String, nil] optional attribute name filter
|
|
41
|
+
# @param message [String, nil] custom failure message; auto-generated when nil
|
|
42
|
+
# @return [void]
|
|
43
|
+
# @example
|
|
44
|
+
# refute_audit_log_entry(post, event: :destroy)
|
|
9
45
|
def refute_audit_log_entry(record, event: nil, touching: nil, message: nil)
|
|
10
46
|
scope = build_scope(record, event, touching)
|
|
11
47
|
msg = message || default_message("not to have", record, event, touching)
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
module RailsAuditLog
|
|
2
|
+
# Opt-in compatibility shim for gradual migration from PaperTrail.
|
|
3
|
+
# Include alongside RailsAuditLog::Auditable to keep PaperTrail's
|
|
4
|
+
# familiar API while your codebase migrates.
|
|
5
|
+
#
|
|
6
|
+
# @example
|
|
7
|
+
# class Article < ApplicationRecord
|
|
8
|
+
# include RailsAuditLog::Auditable
|
|
9
|
+
# include RailsAuditLog::PaperTrailCompat
|
|
10
|
+
# end
|
|
11
|
+
#
|
|
12
|
+
# article.versions # audit_log_entries, oldest-first
|
|
13
|
+
# article.paper_trail.version # most recent AuditLogEntry
|
|
14
|
+
# article.paper_trail.previous_version # reconstructed previous state
|
|
15
|
+
# article.paper_trail.originator # whodunnit_snapshot string
|
|
16
|
+
# article.paper_trail.version_at(1.week.ago) # time-travel reconstruction
|
|
17
|
+
module PaperTrailCompat
|
|
18
|
+
extend ActiveSupport::Concern
|
|
19
|
+
|
|
20
|
+
included do
|
|
21
|
+
has_many :versions,
|
|
22
|
+
-> { order(created_at: :asc, id: :asc) },
|
|
23
|
+
class_name: "RailsAuditLog::AuditLogEntry",
|
|
24
|
+
as: :item,
|
|
25
|
+
dependent: :destroy
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Returns a proxy object exposing PaperTrail's instance-level API.
|
|
29
|
+
#
|
|
30
|
+
# @return [Proxy]
|
|
31
|
+
def paper_trail
|
|
32
|
+
@paper_trail_proxy ||= Proxy.new(self)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Proxy providing the PaperTrail instance API surface backed by
|
|
36
|
+
# {AuditLogEntry} records.
|
|
37
|
+
class Proxy
|
|
38
|
+
# @param record [ActiveRecord::Base] the record this proxy wraps
|
|
39
|
+
def initialize(record)
|
|
40
|
+
@record = record
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# The most recent {AuditLogEntry} for the record.
|
|
44
|
+
#
|
|
45
|
+
# @return [AuditLogEntry, nil]
|
|
46
|
+
def version
|
|
47
|
+
@record.audit_log_entries.order(id: :desc).first
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Reconstructs the record's state before the most recent change.
|
|
51
|
+
# Returns +nil+ for newly created records (no prior state exists).
|
|
52
|
+
#
|
|
53
|
+
# @return [ActiveRecord::Base, nil] an unpersisted instance; or +nil+
|
|
54
|
+
def previous_version
|
|
55
|
+
version&.reify
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Display name of the actor who made the most recent change, as stored
|
|
59
|
+
# in +whodunnit_snapshot+.
|
|
60
|
+
#
|
|
61
|
+
# @return [String, nil]
|
|
62
|
+
def originator
|
|
63
|
+
version&.whodunnit_snapshot
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Reconstructs the record's state as it was at +timestamp+.
|
|
67
|
+
# Delegates to {RailsAuditLog.version_at}.
|
|
68
|
+
#
|
|
69
|
+
# @param timestamp [Time]
|
|
70
|
+
# @return [ActiveRecord::Base, nil] an unpersisted instance; or +nil+
|
|
71
|
+
def version_at(timestamp)
|
|
72
|
+
RailsAuditLog.version_at(@record, timestamp)
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
@@ -1,5 +1,25 @@
|
|
|
1
1
|
module RailsAuditLog
|
|
2
|
+
# Opt-in test helper for suppressing audit writes in test setup code.
|
|
3
|
+
#
|
|
4
|
+
# == Setup
|
|
5
|
+
#
|
|
6
|
+
# # spec/rails_helper.rb
|
|
7
|
+
# require "rails_audit_log/test_helpers"
|
|
8
|
+
#
|
|
9
|
+
# RSpec.configure do |config|
|
|
10
|
+
# config.include RailsAuditLog::TestHelpers
|
|
11
|
+
# end
|
|
12
|
+
#
|
|
13
|
+
# == Usage
|
|
14
|
+
#
|
|
15
|
+
# let(:post) { without_audit_log { Post.create!(title: "fixture") } }
|
|
2
16
|
module TestHelpers
|
|
17
|
+
# Executes the block with audit logging disabled. A prefix-free wrapper
|
|
18
|
+
# around {RailsAuditLog.disable} intended for use in FactoryBot factories,
|
|
19
|
+
# +let+ blocks, and other test setup where audit noise is unwanted.
|
|
20
|
+
#
|
|
21
|
+
# @yield executes the block without recording any audit entries
|
|
22
|
+
# @return [Object] the return value of the block
|
|
3
23
|
def without_audit_log(&block)
|
|
4
24
|
RailsAuditLog.disable(&block)
|
|
5
25
|
end
|