rails_audit_log-graphql 0.6.1 → 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/CHANGELOG.md +13 -0
- data/README.md +79 -0
- data/ROADMAP.md +1 -7
- data/lib/rails_audit_log/graphql/queries/audit_log_entries_query_mixin.rb +39 -0
- data/lib/rails_audit_log/graphql/schema_plugin.rb +22 -0
- data/lib/rails_audit_log/graphql/sources/record_by_id_source.rb +14 -0
- data/lib/rails_audit_log/graphql/subscriptions/audit_log_subscriptions_mixin.rb +19 -0
- data/lib/rails_audit_log/graphql/subscriptions/broadcaster.rb +31 -0
- data/lib/rails_audit_log/graphql/testing/minitest_assertions.rb +87 -0
- data/lib/rails_audit_log/graphql/testing/rspec_matchers.rb +121 -0
- data/lib/rails_audit_log/graphql/testing.rb +30 -0
- data/lib/rails_audit_log/graphql/version.rb +1 -1
- data/lib/rails_audit_log/graphql.rb +34 -1
- data/sig/rails_audit_log/graphql.rbs +33 -0
- metadata +4 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 681be68e65f4b6f543de2ce47434d6b547db12299d15c2f136d94f1db5b15355
|
|
4
|
+
data.tar.gz: 184994ef369aea31293293d01e22663bb1e3dc48f3e958aec6f523997ec9ed7b
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 5d06f3473ed427c34208a6067d4e77a445a10068e8ffc269f4725a858092c1a55c9dd12b504632df53e260f5e5b3b0b6c3aa8eab3a4499c475bdb165165d9403
|
|
7
|
+
data.tar.gz: a9c2936cc464d8288a815f1345b12c23918aaf7a70a15241e77e50ccf90ce0daefd27e172f1f554e7861d7d0e3d6bb9a34557cde7c614f162652795d5288a287
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,18 @@
|
|
|
1
1
|
## [Unreleased]
|
|
2
2
|
|
|
3
|
+
## [1.0.0] - 2026-06-04
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
|
|
7
|
+
- **RSpec matcher** — `have_graphql_audit_entry(:update).touching(:title).for_type("Post")` for asserting audit entries in `Schema.execute` responses; include `RailsAuditLog::Graphql::Testing::RSpecMatchers` in your RSpec config
|
|
8
|
+
- **Minitest assertions** — `assert_graphql_audit_entry` and `refute_graphql_audit_entry` with the same filter interface; include `RailsAuditLog::Graphql::Testing::MinitestAssertions` in your test class
|
|
9
|
+
- Full YARD documentation on all public modules, classes, and methods
|
|
10
|
+
- RBS type signatures for `Testing::RSpecMatchers` and `Testing::MinitestAssertions`
|
|
11
|
+
|
|
12
|
+
### Changed
|
|
13
|
+
|
|
14
|
+
- API stability guarantee — no breaking changes to public interfaces without a major version bump
|
|
15
|
+
|
|
3
16
|
## [0.6.1] - 2026-06-04
|
|
4
17
|
|
|
5
18
|
### Added
|
data/README.md
CHANGED
|
@@ -29,6 +29,9 @@ A [graphql-ruby](https://graphql-ruby.org) API layer for the [`rails_audit_log`]
|
|
|
29
29
|
- [AuditLogSubscriptionsMixin](#auditlogsubscriptionsmixin)
|
|
30
30
|
- [auditLogEntryCreated](#auditlogentrycreated)
|
|
31
31
|
- [Broadcaster](#broadcaster)
|
|
32
|
+
- [Testing](#testing)
|
|
33
|
+
- [RSpec matchers](#rspec-matchers)
|
|
34
|
+
- [Minitest assertions](#minitest-assertions)
|
|
32
35
|
- [Development](#development)
|
|
33
36
|
- [Contributing](#contributing)
|
|
34
37
|
- [License](#license)
|
|
@@ -388,6 +391,82 @@ For each entry, the broadcaster triggers:
|
|
|
388
391
|
|
|
389
392
|
[↑ Back to top](#table-of-contents)
|
|
390
393
|
|
|
394
|
+
## Testing
|
|
395
|
+
|
|
396
|
+
`rails_audit_log-graphql` ships test helpers for both RSpec and Minitest so you can
|
|
397
|
+
assert that your mutations actually produce the expected audit log entries.
|
|
398
|
+
|
|
399
|
+
Require the testing helpers from your test helper:
|
|
400
|
+
|
|
401
|
+
```ruby
|
|
402
|
+
# spec/spec_helper.rb or test/test_helper.rb
|
|
403
|
+
require "rails_audit_log/graphql/testing"
|
|
404
|
+
```
|
|
405
|
+
|
|
406
|
+
### RSpec matchers
|
|
407
|
+
|
|
408
|
+
Include `RailsAuditLog::Graphql::Testing::RSpecMatchers` in your configuration:
|
|
409
|
+
|
|
410
|
+
```ruby
|
|
411
|
+
RSpec.configure do |config|
|
|
412
|
+
config.include RailsAuditLog::Graphql::Testing::RSpecMatchers
|
|
413
|
+
end
|
|
414
|
+
```
|
|
415
|
+
|
|
416
|
+
Then use `have_graphql_audit_entry` against any `GraphQL::Query::Result` (or plain hash) returned by `Schema.execute`:
|
|
417
|
+
|
|
418
|
+
```ruby
|
|
419
|
+
result = MySchema.execute('{ auditLogEntries { event diff { attribute } } }')
|
|
420
|
+
|
|
421
|
+
# Assert an entry with a specific event exists
|
|
422
|
+
expect(result).to have_graphql_audit_entry(:update)
|
|
423
|
+
|
|
424
|
+
# Assert the update entry touched the :title attribute
|
|
425
|
+
# (requires diff { attribute } in the query)
|
|
426
|
+
expect(result).to have_graphql_audit_entry(:update).touching(:title)
|
|
427
|
+
|
|
428
|
+
# Scope to a specific model
|
|
429
|
+
expect(result).to have_graphql_audit_entry(:create).for_type("Post")
|
|
430
|
+
|
|
431
|
+
# Combine chains
|
|
432
|
+
expect(result).to have_graphql_audit_entry(:update).for_type("Post").touching(:title)
|
|
433
|
+
```
|
|
434
|
+
|
|
435
|
+
The matcher searches `auditLogEntries` and `auditLogEntriesConnection.nodes` in the
|
|
436
|
+
response data.
|
|
437
|
+
|
|
438
|
+
[↑ Back to top](#table-of-contents)
|
|
439
|
+
|
|
440
|
+
### Minitest assertions
|
|
441
|
+
|
|
442
|
+
Include `RailsAuditLog::Graphql::Testing::MinitestAssertions` in your test class:
|
|
443
|
+
|
|
444
|
+
```ruby
|
|
445
|
+
class ActiveSupport::TestCase
|
|
446
|
+
include RailsAuditLog::Graphql::Testing::MinitestAssertions
|
|
447
|
+
end
|
|
448
|
+
```
|
|
449
|
+
|
|
450
|
+
Use `assert_graphql_audit_entry` and `refute_graphql_audit_entry`:
|
|
451
|
+
|
|
452
|
+
```ruby
|
|
453
|
+
result = MySchema.execute('{ auditLogEntries { event diff { attribute } } }')
|
|
454
|
+
|
|
455
|
+
# Assert an update entry exists
|
|
456
|
+
assert_graphql_audit_entry result, event: :update
|
|
457
|
+
|
|
458
|
+
# Assert an update entry that touched :title
|
|
459
|
+
assert_graphql_audit_entry result, event: :update, touching: :title
|
|
460
|
+
|
|
461
|
+
# Assert no destroy entry exists
|
|
462
|
+
refute_graphql_audit_entry result, event: :destroy
|
|
463
|
+
|
|
464
|
+
# Custom failure message
|
|
465
|
+
assert_graphql_audit_entry result, event: :update, message: "mutation should have created an update entry"
|
|
466
|
+
```
|
|
467
|
+
|
|
468
|
+
[↑ Back to top](#table-of-contents)
|
|
469
|
+
|
|
391
470
|
## Development
|
|
392
471
|
|
|
393
472
|
```bash
|
data/ROADMAP.md
CHANGED
|
@@ -4,10 +4,4 @@ This gem adds a GraphQL API layer on top of [`rails_audit_log`](https://github.c
|
|
|
4
4
|
|
|
5
5
|
---
|
|
6
6
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
- Full YARD documentation
|
|
10
|
-
- **RSpec matchers** — `expect(response).to have_graphql_audit_entry(:update).touching(:title)`
|
|
11
|
-
- **Minitest assertions** — `assert_graphql_audit_entry`
|
|
12
|
-
- API stability guarantee — no breaking changes without a major version bump
|
|
13
|
-
- Complete README with setup guide, examples, and schema reference
|
|
7
|
+
_No planned milestones at this time._
|
|
@@ -3,7 +3,24 @@
|
|
|
3
3
|
module RailsAuditLog
|
|
4
4
|
module Graphql
|
|
5
5
|
module Queries
|
|
6
|
+
# Mixin that adds all audit log query fields to a host application's +QueryType+.
|
|
7
|
+
#
|
|
8
|
+
# Include in your +QueryType+:
|
|
9
|
+
#
|
|
10
|
+
# class Types::QueryType < Types::BaseObject
|
|
11
|
+
# include RailsAuditLog::Graphql::Queries::AuditLogEntriesQueryMixin
|
|
12
|
+
# end
|
|
13
|
+
#
|
|
14
|
+
# This adds the following fields to the schema:
|
|
15
|
+
# - +auditLogEntry(id:)+ — fetch a single entry by ID
|
|
16
|
+
# - +auditLogEntries(...)+ — offset-paginated list with filters
|
|
17
|
+
# - +auditLogEntriesConnection(...)+ — cursor-paginated Relay connection
|
|
18
|
+
# - +auditLogEntriesCount(...)+ — count matching entries
|
|
19
|
+
# - +auditLogReify(itemType:, itemId:, at:)+ — reconstruct record state at a point in time
|
|
20
|
+
#
|
|
21
|
+
# @see https://github.com/eclectic-coding/rails_audit_log-graphql README
|
|
6
22
|
module AuditLogEntriesQueryMixin
|
|
23
|
+
# @api private
|
|
7
24
|
def self.included(base)
|
|
8
25
|
base.field(
|
|
9
26
|
:audit_log_entry,
|
|
@@ -90,6 +107,11 @@ module RailsAuditLog
|
|
|
90
107
|
end
|
|
91
108
|
end
|
|
92
109
|
|
|
110
|
+
# Resolves the +auditLogEntry+ field.
|
|
111
|
+
#
|
|
112
|
+
# @param id [String] the entry ID
|
|
113
|
+
# @param for_tenant [String, nil] optional tenant scope
|
|
114
|
+
# @return [RailsAuditLog::AuditLogEntry, nil]
|
|
93
115
|
def resolve_audit_log_entry(id:, for_tenant: nil)
|
|
94
116
|
check_authentication!
|
|
95
117
|
tenant_id = for_tenant || RailsAuditLog.current_tenant&.call
|
|
@@ -97,17 +119,31 @@ module RailsAuditLog
|
|
|
97
119
|
base.find_by(id: id)
|
|
98
120
|
end
|
|
99
121
|
|
|
122
|
+
# Resolves the +auditLogEntries+ field.
|
|
123
|
+
#
|
|
124
|
+
# @return [ActiveRecord::Relation]
|
|
100
125
|
def resolve_audit_log_entries(event: nil, item_type: nil, item_id: nil, actor_id: nil, actor_type: nil, since: nil, until_time: nil, touching: nil, order_by: nil, for_tenant: nil, page: 1, per_page: 25)
|
|
101
126
|
check_authentication!
|
|
102
127
|
scope = build_scope(event: event, item_type: item_type, item_id: item_id, actor_id: actor_id, actor_type: actor_type, since: since, until_time: until_time, touching: touching, order_by: order_by, for_tenant: for_tenant)
|
|
103
128
|
scope.limit(per_page).offset((page - 1) * per_page)
|
|
104
129
|
end
|
|
105
130
|
|
|
131
|
+
# Resolves the +auditLogEntriesConnection+ field.
|
|
132
|
+
#
|
|
133
|
+
# @return [ActiveRecord::Relation]
|
|
106
134
|
def resolve_audit_log_entries_connection(event: nil, item_type: nil, item_id: nil, actor_id: nil, actor_type: nil, since: nil, until_time: nil, touching: nil, order_by: nil, for_tenant: nil)
|
|
107
135
|
check_authentication!
|
|
108
136
|
build_scope(event: event, item_type: item_type, item_id: item_id, actor_id: actor_id, actor_type: actor_type, since: since, until_time: until_time, touching: touching, order_by: order_by, for_tenant: for_tenant)
|
|
109
137
|
end
|
|
110
138
|
|
|
139
|
+
# Resolves the +auditLogReify+ field. Returns the reconstructed attribute
|
|
140
|
+
# hash for +item_type+/+item_id+ as of +at+, or +nil+ when no entry exists.
|
|
141
|
+
#
|
|
142
|
+
# @param item_type [String]
|
|
143
|
+
# @param item_id [String]
|
|
144
|
+
# @param at [Time]
|
|
145
|
+
# @param for_tenant [String, nil]
|
|
146
|
+
# @return [Hash, nil]
|
|
111
147
|
def resolve_audit_log_reify(item_type:, item_id:, at:, for_tenant: nil)
|
|
112
148
|
check_authentication!
|
|
113
149
|
tenant_id = for_tenant || RailsAuditLog.current_tenant&.call
|
|
@@ -121,6 +157,9 @@ module RailsAuditLog
|
|
|
121
157
|
entry.object.present? ? entry.object.merge(to_attrs) : to_attrs
|
|
122
158
|
end
|
|
123
159
|
|
|
160
|
+
# Resolves the +auditLogEntriesCount+ field.
|
|
161
|
+
#
|
|
162
|
+
# @return [Integer]
|
|
124
163
|
def resolve_audit_log_entries_count(event: nil, item_type: nil, actor_type: nil, since: nil, for_tenant: nil)
|
|
125
164
|
check_authentication!
|
|
126
165
|
scope = RailsAuditLog::AuditLogEntry.all
|
|
@@ -2,7 +2,29 @@
|
|
|
2
2
|
|
|
3
3
|
module RailsAuditLog
|
|
4
4
|
module Graphql
|
|
5
|
+
# Schema-level plugin that applies query-protection limits and enables
|
|
6
|
+
# dataloader batching in one +include+.
|
|
7
|
+
#
|
|
8
|
+
# Include in your GraphQL schema class:
|
|
9
|
+
#
|
|
10
|
+
# class MySchema < GraphQL::Schema
|
|
11
|
+
# include RailsAuditLog::Graphql::SchemaPlugin
|
|
12
|
+
# query Types::QueryType
|
|
13
|
+
# end
|
|
14
|
+
#
|
|
15
|
+
# This applies the following defaults (all overridable via
|
|
16
|
+
# {RailsAuditLog::Graphql} class-level accessors):
|
|
17
|
+
#
|
|
18
|
+
# | Setting | Default | Description |
|
|
19
|
+
# |------------------------|---------|--------------------------------------------------|
|
|
20
|
+
# | +max_complexity+ | 200 | Reject queries whose complexity exceeds this |
|
|
21
|
+
# | +max_depth+ | 10 | Reject queries nested deeper than this |
|
|
22
|
+
# | +default_max_page_size+| 25 | Page-size assumption for connection complexity |
|
|
23
|
+
#
|
|
24
|
+
# Also enables +GraphQL::Dataloader+ for N+1-free batch loading of
|
|
25
|
+
# +actor.record+ and +auditedResource.record+ fields.
|
|
5
26
|
module SchemaPlugin
|
|
27
|
+
# @api private
|
|
6
28
|
def self.included(base)
|
|
7
29
|
base.max_complexity(RailsAuditLog::Graphql.max_complexity)
|
|
8
30
|
base.max_depth(RailsAuditLog::Graphql.max_depth)
|
|
@@ -3,11 +3,25 @@
|
|
|
3
3
|
module RailsAuditLog
|
|
4
4
|
module Graphql
|
|
5
5
|
module Sources
|
|
6
|
+
# Dataloader source that batch-loads ActiveRecord records by class name and ID,
|
|
7
|
+
# returning their +attributes+ hash.
|
|
8
|
+
#
|
|
9
|
+
# Used internally by {Types::ActorType} and {Types::AuditedResourceType} to
|
|
10
|
+
# resolve the +record+ field without N+1 queries.
|
|
11
|
+
#
|
|
12
|
+
# @example Manual use in a custom resolver
|
|
13
|
+
# dataloader.with(RecordByIdSource, "User").load("42")
|
|
6
14
|
class RecordByIdSource < GraphQL::Dataloader::Source
|
|
15
|
+
# @param class_name [String] the ActiveRecord model class name to load from
|
|
7
16
|
def initialize(class_name)
|
|
8
17
|
@class_name = class_name
|
|
9
18
|
end
|
|
10
19
|
|
|
20
|
+
# Batch-loads records for the given IDs.
|
|
21
|
+
#
|
|
22
|
+
# @param ids [Array<String>] record IDs to load
|
|
23
|
+
# @return [Array<Hash, nil>] +attributes+ hash for each ID, or +nil+ when
|
|
24
|
+
# the record does not exist or the class cannot be constantized
|
|
11
25
|
def fetch(ids)
|
|
12
26
|
klass = @class_name.safe_constantize
|
|
13
27
|
return ids.map { nil } unless klass
|
|
@@ -3,7 +3,26 @@
|
|
|
3
3
|
module RailsAuditLog
|
|
4
4
|
module Graphql
|
|
5
5
|
module Subscriptions
|
|
6
|
+
# Mixin that adds the +auditLogEntryCreated+ subscription field to a host
|
|
7
|
+
# application's +SubscriptionType+.
|
|
8
|
+
#
|
|
9
|
+
# Include in your +SubscriptionType+:
|
|
10
|
+
#
|
|
11
|
+
# class Types::SubscriptionType < Types::BaseObject
|
|
12
|
+
# include RailsAuditLog::Graphql::Subscriptions::AuditLogSubscriptionsMixin
|
|
13
|
+
# end
|
|
14
|
+
#
|
|
15
|
+
# The schema must also use +GraphQL::Subscriptions::ActionCableSubscriptions+:
|
|
16
|
+
#
|
|
17
|
+
# class MySchema < GraphQL::Schema
|
|
18
|
+
# subscription Types::SubscriptionType
|
|
19
|
+
# use GraphQL::Subscriptions::ActionCableSubscriptions
|
|
20
|
+
# end
|
|
21
|
+
#
|
|
22
|
+
# @see AuditLogEntryCreated
|
|
23
|
+
# @see Broadcaster
|
|
6
24
|
module AuditLogSubscriptionsMixin
|
|
25
|
+
# @api private
|
|
7
26
|
def self.included(base)
|
|
8
27
|
base.field(
|
|
9
28
|
:audit_log_entry_created,
|
|
@@ -3,25 +3,56 @@
|
|
|
3
3
|
module RailsAuditLog
|
|
4
4
|
module Graphql
|
|
5
5
|
module Subscriptions
|
|
6
|
+
# Bridges +ActiveSupport::Notifications+ events emitted by
|
|
7
|
+
# +rails_audit_log+ to GraphQL subscription triggers.
|
|
8
|
+
#
|
|
9
|
+
# Start it once in an initializer after the schema is defined:
|
|
10
|
+
#
|
|
11
|
+
# Rails.application.config.after_initialize do
|
|
12
|
+
# RailsAuditLog::Graphql::Subscriptions::Broadcaster.new(schema: MySchema).start
|
|
13
|
+
# end
|
|
14
|
+
#
|
|
15
|
+
# For each +rails_audit_log.entry_created+ notification the broadcaster
|
|
16
|
+
# fires two subscription triggers:
|
|
17
|
+
# - +auditLogEntryCreated(itemType:, itemId:)+ for record-specific subscribers
|
|
18
|
+
# - +auditLogEntryCreated(actorId:)+ for actor-specific subscribers (when an
|
|
19
|
+
# actor is present on the entry)
|
|
6
20
|
class Broadcaster
|
|
21
|
+
# @api private
|
|
7
22
|
EVENT = "rails_audit_log.entry_created"
|
|
8
23
|
|
|
24
|
+
# @param schema [Class] the GraphQL schema class (must use
|
|
25
|
+
# +GraphQL::Subscriptions::ActionCableSubscriptions+)
|
|
9
26
|
def initialize(schema:)
|
|
10
27
|
@schema = schema
|
|
11
28
|
@subscriber = nil
|
|
12
29
|
end
|
|
13
30
|
|
|
31
|
+
# Subscribe to +rails_audit_log.entry_created+ notifications and begin
|
|
32
|
+
# broadcasting to GraphQL subscribers. Idempotent — calling +start+ a
|
|
33
|
+
# second time replaces the previous subscriber.
|
|
34
|
+
#
|
|
35
|
+
# @return [void]
|
|
14
36
|
def start
|
|
15
37
|
@subscriber = ActiveSupport::Notifications.subscribe(EVENT) do |*, payload|
|
|
16
38
|
broadcast(payload[:entry])
|
|
17
39
|
end
|
|
18
40
|
end
|
|
19
41
|
|
|
42
|
+
# Unsubscribe from +ActiveSupport::Notifications+. After calling +stop+,
|
|
43
|
+
# no further subscription triggers will fire until {#start} is called again.
|
|
44
|
+
#
|
|
45
|
+
# @return [void]
|
|
20
46
|
def stop
|
|
21
47
|
ActiveSupport::Notifications.unsubscribe(@subscriber) if @subscriber
|
|
22
48
|
@subscriber = nil
|
|
23
49
|
end
|
|
24
50
|
|
|
51
|
+
# Trigger GraphQL subscriptions for +entry+. Fires both the
|
|
52
|
+
# record-scoped and actor-scoped variants.
|
|
53
|
+
#
|
|
54
|
+
# @param entry [RailsAuditLog::AuditLogEntry] the newly created entry
|
|
55
|
+
# @return [void]
|
|
25
56
|
def broadcast(entry)
|
|
26
57
|
@schema.subscriptions.trigger(
|
|
27
58
|
"audit_log_entry_created",
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsAuditLog
|
|
4
|
+
module Graphql
|
|
5
|
+
module Testing
|
|
6
|
+
# Minitest assertions for GraphQL audit log entries.
|
|
7
|
+
#
|
|
8
|
+
# Include this module in a test class or in a shared support file:
|
|
9
|
+
#
|
|
10
|
+
# class ActiveSupport::TestCase
|
|
11
|
+
# include RailsAuditLog::Graphql::Testing::MinitestAssertions
|
|
12
|
+
# end
|
|
13
|
+
#
|
|
14
|
+
# The assertions inspect the +auditLogEntries+ and +auditLogEntriesConnection.nodes+
|
|
15
|
+
# keys in the response data. To use the +touching:+ option, include +diff { attribute }+
|
|
16
|
+
# in your GraphQL query.
|
|
17
|
+
module MinitestAssertions
|
|
18
|
+
# Asserts that the GraphQL response contains at least one audit log entry
|
|
19
|
+
# matching the given criteria.
|
|
20
|
+
#
|
|
21
|
+
# @param response [Hash, GraphQL::Query::Result] the result of +Schema.execute+
|
|
22
|
+
# @param event [Symbol, String] expected event type (:create, :update, :destroy)
|
|
23
|
+
# @param touching [Symbol, String, nil] when given, requires the entry's +diff+ to
|
|
24
|
+
# include an attribute with this name (query must include +diff { attribute }+)
|
|
25
|
+
# @param item_type [String, nil] when given, requires the entry's +itemType+ to match
|
|
26
|
+
# @param message [String, nil] custom failure message
|
|
27
|
+
#
|
|
28
|
+
# @example Assert an update entry exists
|
|
29
|
+
# result = MySchema.execute('{ auditLogEntries { event } }')
|
|
30
|
+
# assert_graphql_audit_entry result, event: :update
|
|
31
|
+
#
|
|
32
|
+
# @example Assert an update entry that touched :title
|
|
33
|
+
# result = MySchema.execute('{ auditLogEntries { event diff { attribute } } }')
|
|
34
|
+
# assert_graphql_audit_entry result, event: :update, touching: :title
|
|
35
|
+
def assert_graphql_audit_entry(response, event:, touching: nil, item_type: nil, message: nil)
|
|
36
|
+
matched = filter_entries(response, event: event, touching: touching, item_type: item_type)
|
|
37
|
+
default_msg = build_message("Expected", event, touching, item_type)
|
|
38
|
+
assert matched.any?, message || default_msg
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Asserts that the GraphQL response does NOT contain an audit log entry
|
|
42
|
+
# matching the given criteria.
|
|
43
|
+
#
|
|
44
|
+
# @param response [Hash, GraphQL::Query::Result] the result of +Schema.execute+
|
|
45
|
+
# @param event [Symbol, String] the event type to check
|
|
46
|
+
# @param touching [Symbol, String, nil] attribute name to match against +diff+
|
|
47
|
+
# @param item_type [String, nil] model class name to match against +itemType+
|
|
48
|
+
# @param message [String, nil] custom failure message
|
|
49
|
+
def refute_graphql_audit_entry(response, event:, touching: nil, item_type: nil, message: nil)
|
|
50
|
+
matched = filter_entries(response, event: event, touching: touching, item_type: item_type)
|
|
51
|
+
default_msg = build_message("Expected no", event, touching, item_type)
|
|
52
|
+
refute matched.any?, message || default_msg
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
private
|
|
56
|
+
|
|
57
|
+
def filter_entries(response, event:, touching:, item_type:)
|
|
58
|
+
entries = extract_graphql_entries(response)
|
|
59
|
+
matched = entries.select { |e| e["event"] == event.to_s }
|
|
60
|
+
matched = matched.select { |e| e["itemType"] == item_type.to_s } if item_type
|
|
61
|
+
if touching
|
|
62
|
+
matched = matched.select { |e| e["diff"]&.any? { |d| d["attribute"] == touching.to_s } }
|
|
63
|
+
end
|
|
64
|
+
matched
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def build_message(prefix, event, touching, item_type)
|
|
68
|
+
msg = "#{prefix} GraphQL audit entry with event #{event.inspect}"
|
|
69
|
+
msg += " touching #{touching.inspect}" if touching
|
|
70
|
+
msg += " for type #{item_type.inspect}" if item_type
|
|
71
|
+
msg
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def extract_graphql_entries(response)
|
|
75
|
+
data = response.respond_to?(:[]) ? (response["data"] || response[:data]) : nil
|
|
76
|
+
return [] unless data
|
|
77
|
+
|
|
78
|
+
entries = []
|
|
79
|
+
entries += Array(data["auditLogEntries"])
|
|
80
|
+
entries += Array(data.dig("auditLogEntriesConnection", "nodes"))
|
|
81
|
+
entries += Array(data.dig("auditLogEntriesConnection", "edges")).filter_map { |e| e["node"] }
|
|
82
|
+
entries
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsAuditLog
|
|
4
|
+
module Graphql
|
|
5
|
+
module Testing
|
|
6
|
+
# RSpec matchers for asserting GraphQL audit log entries in a query response.
|
|
7
|
+
#
|
|
8
|
+
# Include this module in your RSpec configuration to use the {#have_graphql_audit_entry}
|
|
9
|
+
# matcher:
|
|
10
|
+
#
|
|
11
|
+
# RSpec.configure do |config|
|
|
12
|
+
# config.include RailsAuditLog::Graphql::Testing::RSpecMatchers
|
|
13
|
+
# end
|
|
14
|
+
#
|
|
15
|
+
# Or include it in a single example group:
|
|
16
|
+
#
|
|
17
|
+
# RSpec.describe "audit logging" do
|
|
18
|
+
# include RailsAuditLog::Graphql::Testing::RSpecMatchers
|
|
19
|
+
# end
|
|
20
|
+
#
|
|
21
|
+
# The matcher inspects the +auditLogEntries+ and +auditLogEntriesConnection.nodes+
|
|
22
|
+
# keys in the response data. To use the +touching+ chain, include +diff { attribute }+
|
|
23
|
+
# in your GraphQL query.
|
|
24
|
+
module RSpecMatchers
|
|
25
|
+
# Returns a matcher that asserts a GraphQL response contains an audit log
|
|
26
|
+
# entry with the given event type.
|
|
27
|
+
#
|
|
28
|
+
# @param event [Symbol, String] expected event type (:create, :update, :destroy)
|
|
29
|
+
# @return [HaveGraphqlAuditEntry]
|
|
30
|
+
#
|
|
31
|
+
# @example Assert an update entry exists
|
|
32
|
+
# result = MySchema.execute('{ auditLogEntries { event } }')
|
|
33
|
+
# expect(result).to have_graphql_audit_entry(:update)
|
|
34
|
+
#
|
|
35
|
+
# @example Assert an update entry that touched :title
|
|
36
|
+
# result = MySchema.execute('{ auditLogEntries { event diff { attribute } } }')
|
|
37
|
+
# expect(result).to have_graphql_audit_entry(:update).touching(:title)
|
|
38
|
+
#
|
|
39
|
+
# @example Assert a create entry for a specific model
|
|
40
|
+
# expect(result).to have_graphql_audit_entry(:create).for_type("Post")
|
|
41
|
+
def have_graphql_audit_entry(event)
|
|
42
|
+
HaveGraphqlAuditEntry.new(event.to_s)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# @api private
|
|
46
|
+
class HaveGraphqlAuditEntry
|
|
47
|
+
# @param event [String] the required event value
|
|
48
|
+
def initialize(event)
|
|
49
|
+
@event = event
|
|
50
|
+
@touching = nil
|
|
51
|
+
@item_type = nil
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Requires at least one matching entry's +diff+ to contain +attribute+.
|
|
55
|
+
# The query must include +diff { attribute }+ for this chain to work.
|
|
56
|
+
#
|
|
57
|
+
# @param attribute [Symbol, String] the changed attribute name
|
|
58
|
+
# @return [self]
|
|
59
|
+
def touching(attribute)
|
|
60
|
+
@touching = attribute.to_s
|
|
61
|
+
self
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Requires all matching entries to have +itemType+ equal to +item_type+.
|
|
65
|
+
#
|
|
66
|
+
# @param item_type [String] the ActiveRecord model class name
|
|
67
|
+
# @return [self]
|
|
68
|
+
def for_type(item_type)
|
|
69
|
+
@item_type = item_type.to_s
|
|
70
|
+
self
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# @api private
|
|
74
|
+
def matches?(response)
|
|
75
|
+
@response = response
|
|
76
|
+
matched = extract_entries(response).select { |e| e["event"] == @event }
|
|
77
|
+
matched = matched.select { |e| e["itemType"] == @item_type } if @item_type
|
|
78
|
+
if @touching
|
|
79
|
+
matched = matched.select { |e| e["diff"]&.any? { |d| d["attribute"] == @touching } }
|
|
80
|
+
end
|
|
81
|
+
matched.any?
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# @api private
|
|
85
|
+
def failure_message
|
|
86
|
+
"expected GraphQL response to include an audit entry with event #{@event.inspect}" \
|
|
87
|
+
"#{touching_clause}#{type_clause}, but it did not.\n" \
|
|
88
|
+
"Entries found: #{extract_entries(@response).map { |e| e["event"] }.inspect}"
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# @api private
|
|
92
|
+
def failure_message_when_negated
|
|
93
|
+
"expected GraphQL response not to include an audit entry with event #{@event.inspect}" \
|
|
94
|
+
"#{touching_clause}#{type_clause}, but one was found."
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
private
|
|
98
|
+
|
|
99
|
+
def touching_clause
|
|
100
|
+
@touching ? " touching #{@touching.inspect}" : ""
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def type_clause
|
|
104
|
+
@item_type ? " for type #{@item_type.inspect}" : ""
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def extract_entries(response)
|
|
108
|
+
data = response.respond_to?(:[]) ? (response["data"] || response[:data]) : nil
|
|
109
|
+
return [] unless data
|
|
110
|
+
|
|
111
|
+
entries = []
|
|
112
|
+
entries += Array(data["auditLogEntries"])
|
|
113
|
+
entries += Array(data.dig("auditLogEntriesConnection", "nodes"))
|
|
114
|
+
entries += Array(data.dig("auditLogEntriesConnection", "edges")).filter_map { |e| e["node"] }
|
|
115
|
+
entries
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "testing/rspec_matchers"
|
|
4
|
+
require_relative "testing/minitest_assertions"
|
|
5
|
+
|
|
6
|
+
module RailsAuditLog
|
|
7
|
+
module Graphql
|
|
8
|
+
# Test helpers for asserting GraphQL audit log entries in query responses.
|
|
9
|
+
#
|
|
10
|
+
# Require this file from your test helper to load both RSpec matchers and
|
|
11
|
+
# Minitest assertions:
|
|
12
|
+
#
|
|
13
|
+
# # spec/spec_helper.rb or test/test_helper.rb
|
|
14
|
+
# require "rails_audit_log/graphql/testing"
|
|
15
|
+
#
|
|
16
|
+
# Then include the appropriate module for your test framework:
|
|
17
|
+
#
|
|
18
|
+
# # RSpec
|
|
19
|
+
# RSpec.configure do |config|
|
|
20
|
+
# config.include RailsAuditLog::Graphql::Testing::RSpecMatchers
|
|
21
|
+
# end
|
|
22
|
+
#
|
|
23
|
+
# # Minitest
|
|
24
|
+
# class ActiveSupport::TestCase
|
|
25
|
+
# include RailsAuditLog::Graphql::Testing::MinitestAssertions
|
|
26
|
+
# end
|
|
27
|
+
module Testing
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -19,7 +19,24 @@ require_relative "graphql/subscriptions/audit_log_subscriptions_mixin"
|
|
|
19
19
|
require_relative "graphql/subscriptions/broadcaster"
|
|
20
20
|
require_relative "graphql/schema_plugin"
|
|
21
21
|
|
|
22
|
+
# The top-level namespace for the rails_audit_log gem.
|
|
22
23
|
module RailsAuditLog
|
|
24
|
+
# GraphQL API layer for rails_audit_log.
|
|
25
|
+
#
|
|
26
|
+
# Provides ready-made types, queries, subscriptions, and test helpers for
|
|
27
|
+
# exposing {https://github.com/eclectic-coding/rails_audit_log rails_audit_log}
|
|
28
|
+
# audit log entries through a graphql-ruby schema.
|
|
29
|
+
#
|
|
30
|
+
# == Configuration
|
|
31
|
+
#
|
|
32
|
+
# Override query-protection defaults in an initializer:
|
|
33
|
+
#
|
|
34
|
+
# RailsAuditLog::Graphql.max_complexity = 500
|
|
35
|
+
# RailsAuditLog::Graphql.max_depth = 15
|
|
36
|
+
# RailsAuditLog::Graphql.default_max_page_size = 50
|
|
37
|
+
#
|
|
38
|
+
# These values are picked up by {SchemaPlugin} when it is included in the
|
|
39
|
+
# host schema.
|
|
23
40
|
module Graphql
|
|
24
41
|
class Error < StandardError; end
|
|
25
42
|
|
|
@@ -28,7 +45,23 @@ module RailsAuditLog
|
|
|
28
45
|
@default_max_page_size = 25
|
|
29
46
|
|
|
30
47
|
class << self
|
|
31
|
-
|
|
48
|
+
# Maximum allowed query complexity score.
|
|
49
|
+
# Queries whose field-complexity sum exceeds this value are rejected.
|
|
50
|
+
# Default: +200+.
|
|
51
|
+
# @return [Integer]
|
|
52
|
+
attr_accessor :max_complexity
|
|
53
|
+
|
|
54
|
+
# Maximum allowed query depth.
|
|
55
|
+
# Queries nested deeper than this value are rejected.
|
|
56
|
+
# Default: +10+.
|
|
57
|
+
# @return [Integer]
|
|
58
|
+
attr_accessor :max_depth
|
|
59
|
+
|
|
60
|
+
# Default maximum page size for Relay connections.
|
|
61
|
+
# Used by graphql-ruby when calculating connection complexity.
|
|
62
|
+
# Default: +25+.
|
|
63
|
+
# @return [Integer]
|
|
64
|
+
attr_accessor :default_max_page_size
|
|
32
65
|
end
|
|
33
66
|
end
|
|
34
67
|
end
|
|
@@ -168,5 +168,38 @@ module RailsAuditLog
|
|
|
168
168
|
def check_authentication!: () -> void
|
|
169
169
|
end
|
|
170
170
|
end
|
|
171
|
+
|
|
172
|
+
module Testing
|
|
173
|
+
module RSpecMatchers
|
|
174
|
+
def have_graphql_audit_entry: (Symbol | String event) -> HaveGraphqlAuditEntry
|
|
175
|
+
|
|
176
|
+
class HaveGraphqlAuditEntry
|
|
177
|
+
def initialize: (String event) -> void
|
|
178
|
+
def touching: (Symbol | String attribute) -> self
|
|
179
|
+
def for_type: (String item_type) -> self
|
|
180
|
+
def matches?: (untyped response) -> bool
|
|
181
|
+
def failure_message: () -> String
|
|
182
|
+
def failure_message_when_negated: () -> String
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
module MinitestAssertions
|
|
187
|
+
def assert_graphql_audit_entry: (
|
|
188
|
+
untyped response,
|
|
189
|
+
event: Symbol | String,
|
|
190
|
+
?touching: (Symbol | String)?,
|
|
191
|
+
?item_type: String?,
|
|
192
|
+
?message: String?
|
|
193
|
+
) -> void
|
|
194
|
+
|
|
195
|
+
def refute_graphql_audit_entry: (
|
|
196
|
+
untyped response,
|
|
197
|
+
event: Symbol | String,
|
|
198
|
+
?touching: (Symbol | String)?,
|
|
199
|
+
?item_type: String?,
|
|
200
|
+
?message: String?
|
|
201
|
+
) -> void
|
|
202
|
+
end
|
|
203
|
+
end
|
|
171
204
|
end
|
|
172
205
|
end
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: rails_audit_log-graphql
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 1.0.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Chuck Smith
|
|
@@ -91,6 +91,9 @@ files:
|
|
|
91
91
|
- lib/rails_audit_log/graphql/subscriptions/audit_log_entry_created.rb
|
|
92
92
|
- lib/rails_audit_log/graphql/subscriptions/audit_log_subscriptions_mixin.rb
|
|
93
93
|
- lib/rails_audit_log/graphql/subscriptions/broadcaster.rb
|
|
94
|
+
- lib/rails_audit_log/graphql/testing.rb
|
|
95
|
+
- lib/rails_audit_log/graphql/testing/minitest_assertions.rb
|
|
96
|
+
- lib/rails_audit_log/graphql/testing/rspec_matchers.rb
|
|
94
97
|
- lib/rails_audit_log/graphql/types/actor_type.rb
|
|
95
98
|
- lib/rails_audit_log/graphql/types/audit_log_entry_sort_field_enum.rb
|
|
96
99
|
- lib/rails_audit_log/graphql/types/audit_log_entry_type.rb
|