lex-identity 0.2.0 → 0.3.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.
Files changed (32) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +27 -0
  3. data/Gemfile +6 -3
  4. data/LICENSE +21 -0
  5. data/README.md +230 -0
  6. data/lex-identity.gemspec +15 -12
  7. data/lib/legion/extensions/identity/helpers/browser.rb +44 -0
  8. data/lib/legion/extensions/identity/helpers/callback_server.rb +170 -0
  9. data/lib/legion/extensions/identity/helpers/oauth.rb +62 -0
  10. data/lib/legion/extensions/identity/helpers/pkce.rb +20 -0
  11. data/lib/legion/extensions/identity/helpers/provider.rb +52 -0
  12. data/lib/legion/extensions/identity/runners/base/oauth.rb +140 -0
  13. data/lib/legion/extensions/identity/version.rb +1 -1
  14. data/lib/legion/extensions/identity.rb +8 -14
  15. data/spec/support/provider_contract.rb +49 -0
  16. metadata +67 -33
  17. data/lib/legion/extensions/identity/actors/orphan_check.rb +0 -48
  18. data/lib/legion/extensions/identity/client.rb +0 -23
  19. data/lib/legion/extensions/identity/helpers/dimensions.rb +0 -71
  20. data/lib/legion/extensions/identity/helpers/fingerprint.rb +0 -166
  21. data/lib/legion/extensions/identity/helpers/vault_secrets.rb +0 -76
  22. data/lib/legion/extensions/identity/local_migrations/20260316000030_create_fingerprint.rb +0 -20
  23. data/lib/legion/extensions/identity/runners/entra.rb +0 -223
  24. data/lib/legion/extensions/identity/runners/identity.rb +0 -86
  25. data/spec/legion/extensions/identity/actors/orphan_check_spec.rb +0 -104
  26. data/spec/legion/extensions/identity/client_spec.rb +0 -32
  27. data/spec/legion/extensions/identity/helpers/dimensions_spec.rb +0 -51
  28. data/spec/legion/extensions/identity/helpers/fingerprint_spec.rb +0 -66
  29. data/spec/legion/extensions/identity/runners/entra_spec.rb +0 -405
  30. data/spec/legion/extensions/identity/runners/identity_spec.rb +0 -61
  31. data/spec/local_persistence_spec.rb +0 -329
  32. data/spec/spec_helper.rb +0 -33
@@ -1,166 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require 'json'
4
- require 'time'
5
-
6
- module Legion
7
- module Extensions
8
- module Identity
9
- module Helpers
10
- class Fingerprint
11
- attr_reader :model, :observation_count, :entropy_history
12
-
13
- def initialize
14
- @model = Dimensions.new_identity_model
15
- @observation_count = 0
16
- @entropy_history = []
17
- load_from_local
18
- end
19
-
20
- def observe(dimension, value)
21
- return unless Dimensions::IDENTITY_DIMENSIONS.include?(dimension)
22
-
23
- dim = @model[dimension]
24
- dim[:observations] += 1
25
- @observation_count += 1
26
-
27
- alpha = Dimensions::OBSERVATION_ALPHA
28
- old_mean = dim[:mean]
29
- dim[:mean] = (alpha * value) + ((1.0 - alpha) * old_mean)
30
- deviation = (value - dim[:mean]).abs
31
- dim[:variance] = (alpha * deviation) + ((1.0 - alpha) * dim[:variance])
32
- dim[:last_observed] = Time.now.utc
33
- end
34
-
35
- def observe_all(observations)
36
- observations.each { |dim, value| observe(dim, value) }
37
- end
38
-
39
- def current_entropy(observations = {})
40
- entropy = Dimensions.compute_entropy(observations, @model)
41
- @entropy_history << { entropy: entropy, at: Time.now.utc }
42
- @entropy_history.shift while @entropy_history.size > 200
43
- entropy
44
- end
45
-
46
- def entropy_trend(window: 10)
47
- recent = @entropy_history.last(window)
48
- return :stable if recent.size < 2
49
-
50
- values = recent.map { |e| e[:entropy] }
51
- first_half = values[0...(values.size / 2)]
52
- second_half = values[(values.size / 2)..]
53
-
54
- diff = (second_half.sum / second_half.size) - (first_half.sum / first_half.size)
55
- if diff > 0.1
56
- :rising
57
- elsif diff < -0.1
58
- :falling
59
- else
60
- :stable
61
- end
62
- end
63
-
64
- def maturity
65
- if @observation_count < 10
66
- :nascent
67
- elsif @observation_count < 100
68
- :developing
69
- elsif @observation_count < 1000
70
- :established
71
- else
72
- :mature
73
- end
74
- end
75
-
76
- def to_h
77
- {
78
- model: @model,
79
- observation_count: @observation_count,
80
- maturity: maturity,
81
- entropy_history_size: @entropy_history.size
82
- }
83
- end
84
-
85
- def save_to_local
86
- return unless local_available?
87
-
88
- db = Legion::Data::Local.connection
89
-
90
- @model.each do |dimension, data|
91
- existing = db[:identity_fingerprint].where(dimension: dimension.to_s).first
92
- row = {
93
- dimension: dimension.to_s,
94
- mean: data[:mean],
95
- variance: data[:variance],
96
- observations: data[:observations],
97
- last_observed: data[:last_observed]
98
- }
99
- if existing
100
- db[:identity_fingerprint].where(dimension: dimension.to_s).update(row)
101
- else
102
- db[:identity_fingerprint].insert(row)
103
- end
104
- end
105
-
106
- history_json = ::JSON.generate(@entropy_history.map { |e| { entropy: e[:entropy], at: e[:at].iso8601 } })
107
- meta = db[:identity_meta].first
108
- if meta
109
- db[:identity_meta].where(id: meta[:id]).update(
110
- observation_count: @observation_count,
111
- entropy_history: history_json
112
- )
113
- else
114
- db[:identity_meta].insert(
115
- observation_count: @observation_count,
116
- entropy_history: history_json
117
- )
118
- end
119
-
120
- true
121
- rescue StandardError => e
122
- Legion::Logging.warn "lex-identity: save_to_local failed: #{e.message}" if defined?(Legion::Logging)
123
- false
124
- end
125
-
126
- def load_from_local
127
- return unless local_available?
128
-
129
- db = Legion::Data::Local.connection
130
-
131
- db[:identity_fingerprint].each do |row|
132
- dim = row[:dimension].to_sym
133
- next unless @model.key?(dim)
134
-
135
- @model[dim][:mean] = row[:mean].to_f
136
- @model[dim][:variance] = row[:variance].to_f
137
- @model[dim][:observations] = row[:observations].to_i
138
- @model[dim][:last_observed] = row[:last_observed]
139
- end
140
-
141
- meta = db[:identity_meta].first
142
- if meta
143
- @observation_count = meta[:observation_count].to_i
144
- raw = meta[:entropy_history]
145
- if raw && !raw.empty?
146
- parsed = ::JSON.parse(raw)
147
- @entropy_history = parsed.map { |e| { entropy: e['entropy'].to_f, at: Time.parse(e['at']) } }
148
- end
149
- end
150
-
151
- true
152
- rescue StandardError => e
153
- Legion::Logging.warn "lex-identity: load_from_local failed: #{e.message}" if defined?(Legion::Logging)
154
- false
155
- end
156
-
157
- private
158
-
159
- def local_available?
160
- defined?(Legion::Data::Local) && Legion::Data::Local.connected?
161
- end
162
- end
163
- end
164
- end
165
- end
166
- end
@@ -1,76 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Legion
4
- module Extensions
5
- module Identity
6
- module Helpers
7
- # Vault secret path conventions for Digital Worker Entra ID credentials.
8
- #
9
- # Secrets are stored in Vault KV v2 under a well-known path:
10
- # secret/data/legion/workers/{worker_id}/entra
11
- #
12
- # Legion uses legion-crypt for Vault access. If Vault is not connected,
13
- # methods return nil/false gracefully.
14
- module VaultSecrets
15
- VAULT_PATH_PREFIX = 'secret/data/legion/workers'
16
-
17
- def self.secret_path(worker_id)
18
- "#{VAULT_PATH_PREFIX}/#{worker_id}/entra"
19
- end
20
-
21
- # Store Entra app client_secret in Vault.
22
- # Returns true on success, false if Vault is unavailable.
23
- def self.store_client_secret(worker_id:, client_secret:, entra_app_id: nil)
24
- return false unless vault_available?
25
-
26
- path = secret_path(worker_id)
27
- data = { client_secret: client_secret }
28
- data[:entra_app_id] = entra_app_id if entra_app_id
29
-
30
- Legion::Crypt.write(path, data)
31
- Legion::Logging.info "[identity:vault] stored Entra credentials for worker=#{worker_id}"
32
- true
33
- rescue StandardError => e
34
- Legion::Logging.error "[identity:vault] failed to store credentials for worker=#{worker_id}: #{e.message}"
35
- false
36
- end
37
-
38
- # Read Entra app client_secret from Vault.
39
- # Returns the secret hash on success, nil if unavailable or not found.
40
- def self.read_client_secret(worker_id:)
41
- return nil unless vault_available?
42
-
43
- path = secret_path(worker_id)
44
- result = Legion::Crypt.read(path)
45
- result&.dig(:data, :data) || result&.dig(:data)
46
- rescue StandardError => e
47
- Legion::Logging.error "[identity:vault] failed to read credentials for worker=#{worker_id}: #{e.message}"
48
- nil
49
- end
50
-
51
- # Delete Entra app credentials from Vault (used during worker termination).
52
- # Returns true on success, false if Vault is unavailable.
53
- def self.delete_client_secret(worker_id:)
54
- return false unless vault_available?
55
-
56
- path = secret_path(worker_id)
57
- Legion::Crypt.delete(path)
58
- Legion::Logging.info "[identity:vault] deleted Entra credentials for worker=#{worker_id}"
59
- true
60
- rescue StandardError => e
61
- Legion::Logging.error "[identity:vault] failed to delete credentials for worker=#{worker_id}: #{e.message}"
62
- false
63
- end
64
-
65
- def self.vault_available?
66
- defined?(Legion::Crypt) &&
67
- defined?(Legion::Settings) &&
68
- Legion::Settings[:crypt][:vault][:connected] == true
69
- rescue StandardError
70
- false
71
- end
72
- end
73
- end
74
- end
75
- end
76
- end
@@ -1,20 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- Sequel.migration do
4
- change do
5
- create_table(:identity_fingerprint) do
6
- primary_key :id
7
- String :dimension, null: false, unique: true
8
- Float :mean, default: 0.0
9
- Float :variance, default: 0.0
10
- Integer :observations, default: 0
11
- DateTime :last_observed
12
- end
13
-
14
- create_table(:identity_meta) do
15
- primary_key :id
16
- Integer :observation_count, default: 0
17
- String :entropy_history, text: true
18
- end
19
- end
20
- end
@@ -1,223 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Legion
4
- module Extensions
5
- module Identity
6
- module Runners
7
- # Entra ID Application identity integration for Digital Workers.
8
- #
9
- # Permission model:
10
- # - Entra app CREATION is done by the human owner (requires Application.ReadWrite.All
11
- # which Legion does not have and should not have)
12
- # - Legion gets Application.Read.All or Directory.Read.All for read operations
13
- # - OIDC token validation uses the public JWKS endpoint (no special permission)
14
- # - Write operations (transfer ownership, disable apps) update the Legion DB
15
- # and emit events; the human completes the Entra side manually
16
- module Entra
17
- GRAPH_API_BASE = 'https://graph.microsoft.com/v1.0'
18
- ENTRA_JWKS_URL_TEMPLATE = 'https://login.microsoftonline.com/%<tenant_id>s/discovery/v2.0/keys'
19
- ENTRA_ISSUER_TEMPLATE = 'https://login.microsoftonline.com/%<tenant_id>s/v2.0'
20
-
21
- # Validate a worker's identity by checking its Entra app registration exists
22
- # and its OIDC token is valid.
23
- # OIDC validation uses the public JWKS endpoint — no Graph API permission needed.
24
- def validate_worker_identity(worker_id:, entra_app_id: nil, token: nil, tenant_id: nil, **)
25
- worker = find_worker(worker_id)
26
- return { valid: false, error: 'worker not found' } unless worker
27
-
28
- app_id = entra_app_id || worker[:entra_app_id]
29
- return { valid: false, error: 'no entra_app_id' } unless app_id
30
-
31
- # If a token is provided and legion-crypt has JWKS support, validate it
32
- if token && defined?(Legion::Crypt::JWT) && Legion::Crypt::JWT.respond_to?(:verify_with_jwks)
33
- tid = tenant_id || resolve_tenant_id
34
- return { valid: false, error: 'no tenant_id configured' } unless tid
35
-
36
- jwks_url = format(ENTRA_JWKS_URL_TEMPLATE, tenant_id: tid)
37
- issuer = format(ENTRA_ISSUER_TEMPLATE, tenant_id: tid)
38
-
39
- claims = Legion::Crypt::JWT.verify_with_jwks(
40
- token,
41
- jwks_url: jwks_url,
42
- issuers: [issuer],
43
- audience: app_id
44
- )
45
-
46
- Legion::Logging.debug "[identity:entra] token validated: worker=#{worker_id} sub=#{claims[:sub]}"
47
-
48
- return {
49
- valid: true,
50
- worker_id: worker_id,
51
- entra_app_id: app_id,
52
- owner_msid: worker[:owner_msid],
53
- lifecycle: worker[:lifecycle_state],
54
- claims: claims,
55
- validated_at: Time.now.utc
56
- }
57
- end
58
-
59
- # No token provided — return identity info without token validation
60
- Legion::Logging.debug "[identity:entra] validate (no token): worker=#{worker_id} entra_app=#{app_id}"
61
-
62
- {
63
- valid: true,
64
- worker_id: worker_id,
65
- entra_app_id: app_id,
66
- owner_msid: worker[:owner_msid],
67
- lifecycle: worker[:lifecycle_state],
68
- validated_at: Time.now.utc
69
- }
70
- rescue Legion::Crypt::JWT::ExpiredTokenError => e
71
- { valid: false, error: 'token_expired', message: e.message }
72
- rescue Legion::Crypt::JWT::InvalidTokenError => e
73
- { valid: false, error: 'token_invalid', message: e.message }
74
- rescue Legion::Crypt::JWT::Error => e
75
- { valid: false, error: 'token_error', message: e.message }
76
- end
77
-
78
- # Sync the worker's owner from Entra app ownership.
79
- # Requires: Application.Read.All or Directory.Read.All (read-only)
80
- def sync_owner(worker_id:, **)
81
- worker = find_worker(worker_id)
82
- return { synced: false, error: 'worker not found' } unless worker
83
-
84
- # TODO: With Application.Read.All, call:
85
- # GET #{GRAPH_API_BASE}/applications/#{worker[:entra_object_id]}/owners
86
- # Parse owner MSID from response, update local record
87
-
88
- Legion::Logging.debug "[identity:entra] sync_owner: worker=#{worker_id} current_owner=#{worker[:owner_msid]}"
89
-
90
- {
91
- synced: true,
92
- worker_id: worker_id,
93
- owner_msid: worker[:owner_msid],
94
- source: :local, # will be :graph_api when read permission granted
95
- synced_at: Time.now.utc
96
- }
97
- end
98
-
99
- # Transfer ownership of a digital worker to a new human.
100
- # Updates the Legion DB record and emits an audit event.
101
- # The Entra app ownership change must be done by the human owner
102
- # (requires Application.ReadWrite.All which Legion intentionally does not have).
103
- def transfer_ownership(worker_id:, new_owner_msid:, transferred_by:, reason: nil, **)
104
- worker = find_worker(worker_id)
105
- return { transferred: false, error: 'worker not found' } unless worker
106
-
107
- old_owner = worker[:owner_msid]
108
- return { transferred: false, error: 'same owner' } if old_owner == new_owner_msid
109
-
110
- # Update local record — this is the Legion side of the transfer
111
- if defined?(Legion::Data) && defined?(Legion::Data::Model::DigitalWorker)
112
- dw = Legion::Data::Model::DigitalWorker.first(worker_id: worker_id)
113
- dw&.update(owner_msid: new_owner_msid, updated_at: Time.now.utc)
114
- end
115
-
116
- # Entra app ownership change requires Application.ReadWrite.All.
117
- # Legion does not have this permission by design — the human owner
118
- # must update Entra app ownership separately via Azure Portal or CLI.
119
-
120
- audit = {
121
- event: :ownership_transferred,
122
- worker_id: worker_id,
123
- from_owner: old_owner,
124
- to_owner: new_owner_msid,
125
- transferred_by: transferred_by,
126
- reason: reason,
127
- entra_action_required: 'update Entra app ownership via Azure Portal or az CLI',
128
- at: Time.now.utc
129
- }
130
-
131
- Legion::Events.emit('worker.ownership_transferred', audit) if defined?(Legion::Events)
132
- Legion::Logging.info "[identity:entra] ownership transferred (Legion DB): worker=#{worker_id} " \
133
- "from=#{old_owner} to=#{new_owner_msid} by=#{transferred_by}"
134
- Legion::Logging.warn '[identity:entra] Entra app ownership must be updated manually (requires Application.ReadWrite.All)'
135
-
136
- { transferred: true }.merge(audit)
137
- end
138
-
139
- # Scan for orphaned workers: Entra apps that are disabled or owners no longer active.
140
- # Requires: Application.Read.All or Directory.Read.All (read-only)
141
- # Orphan REMEDIATION (disabling apps) requires human action since Legion
142
- # does not have Application.ReadWrite.All.
143
- def check_orphans(**)
144
- return { orphans: [], checked: 0, source: :unavailable } unless defined?(Legion::Data) && defined?(Legion::Data::Model::DigitalWorker)
145
-
146
- active_workers = Legion::Data::Model::DigitalWorker.where(lifecycle_state: 'active').all
147
- orphans = []
148
- skipped = 0
149
-
150
- active_workers.each do |worker|
151
- # Skip auto-registered extension workers without real Entra apps
152
- if system_placeholder?(worker.entra_app_id, worker.worker_id)
153
- skipped += 1
154
- next
155
- end
156
-
157
- # TODO: With Application.Read.All, check:
158
- # GET #{GRAPH_API_BASE}/applications/#{entra_object_id} — is app disabled?
159
- # GET #{GRAPH_API_BASE}/users/#{owner_msid} — is owner active?
160
- # If either is disabled/deleted:
161
- # orphans << worker
162
- # auto_pause_orphan(worker, reason: :entra_app_disabled)
163
- end
164
-
165
- Legion::Logging.debug "[identity:entra] orphan check: scanned #{active_workers.size} active workers, skipped #{skipped} system workers"
166
-
167
- {
168
- orphans: orphans.map { |w| { worker_id: w.worker_id, owner_msid: w.owner_msid, reason: :pending_entra_validation } },
169
- checked: active_workers.size - skipped,
170
- skipped: skipped,
171
- source: :local,
172
- checked_at: Time.now.utc
173
- }
174
- end
175
-
176
- private
177
-
178
- def find_worker(worker_id)
179
- if defined?(Legion::Data) && defined?(Legion::Data::Model::DigitalWorker)
180
- worker = Legion::Data::Model::DigitalWorker.first(worker_id: worker_id)
181
- return worker.to_hash if worker
182
- end
183
- nil
184
- end
185
-
186
- def system_placeholder?(entra_app_id, worker_id)
187
- return true if entra_app_id.nil? || entra_app_id == 'system'
188
- return true if entra_app_id == worker_id
189
- return true if entra_app_id.start_with?('lex-')
190
-
191
- false
192
- end
193
-
194
- def resolve_tenant_id
195
- if defined?(Legion::Settings) &&
196
- Legion::Settings[:identity]&.dig(:entra, :tenant_id)
197
- return Legion::Settings[:identity][:entra][:tenant_id]
198
- end
199
-
200
- nil
201
- end
202
-
203
- def auto_pause_orphan(worker, reason:)
204
- worker.update(lifecycle_state: 'paused', updated_at: Time.now.utc)
205
-
206
- if defined?(Legion::Events)
207
- Legion::Events.emit('worker.orphan_detected', {
208
- worker_id: worker.worker_id,
209
- owner_msid: worker.owner_msid,
210
- reason: reason,
211
- action: :auto_paused,
212
- remediation: 'disable or reassign Entra app via Azure Portal',
213
- at: Time.now.utc
214
- })
215
- end
216
-
217
- Legion::Logging.warn "[identity:entra] orphan detected: worker=#{worker.worker_id} reason=#{reason} — auto-paused"
218
- end
219
- end
220
- end
221
- end
222
- end
223
- end
@@ -1,86 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Legion
4
- module Extensions
5
- module Identity
6
- module Runners
7
- module Identity
8
- include Legion::Extensions::Helpers::Lex if Legion::Extensions.const_defined?(:Helpers) &&
9
- Legion::Extensions::Helpers.const_defined?(:Lex)
10
-
11
- def observe_behavior(dimension:, value:, **)
12
- fingerprint = identity_fingerprint
13
- fingerprint.observe(dimension, value)
14
-
15
- Legion::Logging.debug "[identity] observe: dim=#{dimension} val=#{value.round(2)} " \
16
- "obs=#{fingerprint.observation_count} maturity=#{fingerprint.maturity}"
17
- {
18
- dimension: dimension,
19
- recorded: true,
20
- observation_count: fingerprint.observation_count,
21
- maturity: fingerprint.maturity
22
- }
23
- end
24
-
25
- def observe_all(observations:, **)
26
- fingerprint = identity_fingerprint
27
- fingerprint.observe_all(observations)
28
-
29
- Legion::Logging.debug "[identity] observe_all: dims=#{observations.keys.join(',')} " \
30
- "obs=#{fingerprint.observation_count} maturity=#{fingerprint.maturity}"
31
- {
32
- dimensions_observed: observations.keys,
33
- observation_count: fingerprint.observation_count,
34
- maturity: fingerprint.maturity
35
- }
36
- end
37
-
38
- def check_entropy(observations: {}, **)
39
- fingerprint = identity_fingerprint
40
- entropy = fingerprint.current_entropy(observations)
41
- classification = Helpers::Dimensions.classify_entropy(entropy)
42
- trend = fingerprint.entropy_trend
43
-
44
- result = {
45
- entropy: entropy,
46
- classification: classification,
47
- trend: trend,
48
- in_range: Helpers::Dimensions::OPTIMAL_ENTROPY_RANGE.cover?(entropy)
49
- }
50
-
51
- case classification
52
- when :high_entropy
53
- result[:warning] = :possible_impersonation_or_drift
54
- result[:action] = :enter_caution_mode
55
- Legion::Logging.warn "[identity] high entropy detected: #{entropy.round(3)} trend=#{trend} - possible impersonation"
56
- when :low_entropy
57
- result[:warning] = :possible_automation
58
- result[:action] = :trigger_verification
59
- Legion::Logging.warn "[identity] low entropy detected: #{entropy.round(3)} trend=#{trend} - possible automation"
60
- else
61
- Legion::Logging.debug "[identity] entropy check: #{entropy.round(3)} classification=#{classification} trend=#{trend}"
62
- end
63
-
64
- result
65
- end
66
-
67
- def identity_status(**)
68
- fingerprint = identity_fingerprint
69
- Legion::Logging.debug "[identity] status: maturity=#{fingerprint.maturity} observations=#{fingerprint.observation_count}"
70
- fingerprint.to_h
71
- end
72
-
73
- def identity_maturity(**)
74
- { maturity: identity_fingerprint.maturity }
75
- end
76
-
77
- private
78
-
79
- def identity_fingerprint
80
- @identity_fingerprint ||= Helpers::Fingerprint.new
81
- end
82
- end
83
- end
84
- end
85
- end
86
- end
@@ -1,104 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- # Stub the framework actor base class since legionio gem is not available in test
4
- module Legion
5
- module Extensions
6
- module Actors
7
- class Every # rubocop:disable Lint/EmptyClass
8
- end
9
- end
10
- end
11
- end
12
-
13
- # Intercept the require in the actor file so it doesn't fail
14
- $LOADED_FEATURES << 'legion/extensions/actors/every'
15
-
16
- require 'legion/extensions/identity/actors/orphan_check'
17
-
18
- RSpec.describe Legion::Extensions::Identity::Actor::OrphanCheck do
19
- subject(:actor) { described_class.new }
20
-
21
- describe 'ORPHAN_CHECK_INTERVAL' do
22
- it 'is 14400 seconds (4 hours)' do
23
- expect(described_class::ORPHAN_CHECK_INTERVAL).to eq(14_400)
24
- end
25
- end
26
-
27
- describe '#runner_class' do
28
- it 'returns the Entra module' do
29
- expect(actor.runner_class).to eq(Legion::Extensions::Identity::Runners::Entra)
30
- end
31
- end
32
-
33
- describe '#runner_function' do
34
- it 'returns check_orphans' do
35
- expect(actor.runner_function).to eq('check_orphans')
36
- end
37
- end
38
-
39
- describe '#time' do
40
- it 'returns 14400 seconds using the constant' do
41
- expect(actor.time).to eq(Legion::Extensions::Identity::Actor::OrphanCheck::ORPHAN_CHECK_INTERVAL)
42
- end
43
-
44
- it 'returns 14400' do
45
- expect(actor.time).to eq(14_400)
46
- end
47
- end
48
-
49
- describe '#use_runner?' do
50
- it 'returns false' do
51
- expect(actor.use_runner?).to be false
52
- end
53
- end
54
-
55
- describe '#check_subtask?' do
56
- it 'returns false' do
57
- expect(actor.check_subtask?).to be false
58
- end
59
- end
60
-
61
- describe '#generate_task?' do
62
- it 'returns false' do
63
- expect(actor.generate_task?).to be false
64
- end
65
- end
66
-
67
- describe '#enabled?' do
68
- context 'when Legion::Data is not defined' do
69
- it 'returns false' do
70
- hide_const('Legion::Data') if defined?(Legion::Data)
71
- expect(actor.enabled?).to be_falsey
72
- end
73
- end
74
-
75
- context 'when Legion::Data is defined and data is connected' do
76
- it 'returns truthy' do
77
- stub_const('Legion::Data', Module.new)
78
- stub_const('Legion::Settings', Class.new)
79
- settings_double = { connected: true }
80
- allow(Legion::Settings).to receive(:[]).with(:data).and_return(settings_double)
81
- expect(actor.enabled?).to be_truthy
82
- end
83
- end
84
-
85
- context 'when Legion::Data is defined but connected is false' do
86
- it 'returns false' do
87
- stub_const('Legion::Data', Module.new)
88
- stub_const('Legion::Settings', Class.new)
89
- settings_double = { connected: false }
90
- allow(Legion::Settings).to receive(:[]).with(:data).and_return(settings_double)
91
- expect(actor.enabled?).to be false
92
- end
93
- end
94
-
95
- context 'when Legion::Settings raises an error' do
96
- it 'returns false' do
97
- stub_const('Legion::Data', Module.new)
98
- stub_const('Legion::Settings', Class.new)
99
- allow(Legion::Settings).to receive(:[]).with(:data).and_raise(StandardError)
100
- expect(actor.enabled?).to be false
101
- end
102
- end
103
- end
104
- end