lex-identity 0.2.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 +7 -0
- data/Gemfile +8 -0
- data/lex-identity.gemspec +30 -0
- data/lib/legion/extensions/identity/actors/orphan_check.rb +48 -0
- data/lib/legion/extensions/identity/client.rb +23 -0
- data/lib/legion/extensions/identity/helpers/dimensions.rb +71 -0
- data/lib/legion/extensions/identity/helpers/fingerprint.rb +166 -0
- data/lib/legion/extensions/identity/helpers/vault_secrets.rb +76 -0
- data/lib/legion/extensions/identity/local_migrations/20260316000030_create_fingerprint.rb +20 -0
- data/lib/legion/extensions/identity/runners/entra.rb +223 -0
- data/lib/legion/extensions/identity/runners/identity.rb +86 -0
- data/lib/legion/extensions/identity/version.rb +9 -0
- data/lib/legion/extensions/identity.rb +24 -0
- data/spec/legion/extensions/identity/actors/orphan_check_spec.rb +104 -0
- data/spec/legion/extensions/identity/client_spec.rb +32 -0
- data/spec/legion/extensions/identity/helpers/dimensions_spec.rb +51 -0
- data/spec/legion/extensions/identity/helpers/fingerprint_spec.rb +66 -0
- data/spec/legion/extensions/identity/runners/entra_spec.rb +405 -0
- data/spec/legion/extensions/identity/runners/identity_spec.rb +61 -0
- data/spec/local_persistence_spec.rb +329 -0
- data/spec/spec_helper.rb +33 -0
- metadata +95 -0
|
@@ -0,0 +1,405 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'legion/extensions/identity/runners/entra'
|
|
4
|
+
|
|
5
|
+
# Minimal stubs for Legion::Logging so the runner can call it without the full framework
|
|
6
|
+
unless defined?(Legion::Logging)
|
|
7
|
+
module Legion
|
|
8
|
+
module Logging
|
|
9
|
+
module_function
|
|
10
|
+
|
|
11
|
+
def debug(*); end
|
|
12
|
+
def info(*); end
|
|
13
|
+
def warn(*); end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
RSpec.describe Legion::Extensions::Identity::Runners::Entra do
|
|
19
|
+
# Thin host class that includes the runner module so we can call runner methods directly
|
|
20
|
+
let(:host_class) do
|
|
21
|
+
Class.new do
|
|
22
|
+
include Legion::Extensions::Identity::Runners::Entra
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
let(:client) { host_class.new }
|
|
27
|
+
|
|
28
|
+
# A reusable worker record hash returned by the model double
|
|
29
|
+
let(:worker_record) do
|
|
30
|
+
{
|
|
31
|
+
worker_id: 'worker-abc',
|
|
32
|
+
entra_app_id: 'app-id-123',
|
|
33
|
+
entra_object_id: 'obj-id-456',
|
|
34
|
+
owner_msid: 'alice@example.com',
|
|
35
|
+
lifecycle_state: 'active'
|
|
36
|
+
}
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Helper: build a model double that responds to .first and .where(...).all
|
|
40
|
+
def build_model_double(worker_hash: worker_record, active_all: [])
|
|
41
|
+
worker_double = instance_double('DigitalWorker')
|
|
42
|
+
allow(worker_double).to receive(:to_hash).and_return(worker_hash)
|
|
43
|
+
allow(worker_double).to receive(:update)
|
|
44
|
+
|
|
45
|
+
scope_double = double('Scope')
|
|
46
|
+
allow(scope_double).to receive(:all).and_return(active_all)
|
|
47
|
+
|
|
48
|
+
model_double = double('DigitalWorker model')
|
|
49
|
+
allow(model_double).to receive(:first).and_return(worker_double)
|
|
50
|
+
allow(model_double).to receive(:where).and_return(scope_double)
|
|
51
|
+
|
|
52
|
+
model_double
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Helper: stub Legion::Data and the DigitalWorker model constant so `defined?` guards pass
|
|
56
|
+
def stub_data_model(model_double)
|
|
57
|
+
stub_const('Legion::Data', Module.new)
|
|
58
|
+
stub_const('Legion::Data::Model', Module.new)
|
|
59
|
+
stub_const('Legion::Data::Model::DigitalWorker', model_double)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# ---------------------------------------------------------------------------
|
|
63
|
+
# validate_worker_identity
|
|
64
|
+
# ---------------------------------------------------------------------------
|
|
65
|
+
|
|
66
|
+
describe '#validate_worker_identity' do
|
|
67
|
+
context 'when the worker exists with an entra_app_id' do
|
|
68
|
+
it 'returns valid: true with identity fields' do
|
|
69
|
+
stub_data_model(build_model_double)
|
|
70
|
+
|
|
71
|
+
result = client.validate_worker_identity(worker_id: 'worker-abc')
|
|
72
|
+
|
|
73
|
+
expect(result[:valid]).to be true
|
|
74
|
+
expect(result[:worker_id]).to eq('worker-abc')
|
|
75
|
+
expect(result[:entra_app_id]).to eq('app-id-123')
|
|
76
|
+
expect(result[:owner_msid]).to eq('alice@example.com')
|
|
77
|
+
expect(result[:lifecycle]).to eq('active')
|
|
78
|
+
expect(result[:validated_at]).to be_a(Time)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
it 'uses the caller-supplied entra_app_id when provided' do
|
|
82
|
+
stub_data_model(build_model_double)
|
|
83
|
+
|
|
84
|
+
result = client.validate_worker_identity(worker_id: 'worker-abc', entra_app_id: 'override-app-id')
|
|
85
|
+
|
|
86
|
+
expect(result[:valid]).to be true
|
|
87
|
+
expect(result[:entra_app_id]).to eq('override-app-id')
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
context 'when the worker does not exist' do
|
|
92
|
+
it 'returns valid: false with an error message' do
|
|
93
|
+
model_double = double('DigitalWorker model')
|
|
94
|
+
allow(model_double).to receive(:first).and_return(nil)
|
|
95
|
+
stub_data_model(model_double)
|
|
96
|
+
|
|
97
|
+
result = client.validate_worker_identity(worker_id: 'no-such-worker')
|
|
98
|
+
|
|
99
|
+
expect(result[:valid]).to be false
|
|
100
|
+
expect(result[:error]).to eq('worker not found')
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
context 'when Legion::Data is not available' do
|
|
105
|
+
it 'returns valid: false because find_worker returns nil' do
|
|
106
|
+
result = client.validate_worker_identity(worker_id: 'worker-abc')
|
|
107
|
+
|
|
108
|
+
expect(result[:valid]).to be false
|
|
109
|
+
expect(result[:error]).to eq('worker not found')
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
context 'when the worker record has no entra_app_id' do
|
|
114
|
+
it 'returns valid: false with no entra_app_id error' do
|
|
115
|
+
worker_without_app = worker_record.merge(entra_app_id: nil)
|
|
116
|
+
stub_data_model(build_model_double(worker_hash: worker_without_app))
|
|
117
|
+
|
|
118
|
+
result = client.validate_worker_identity(worker_id: 'worker-abc')
|
|
119
|
+
|
|
120
|
+
expect(result[:valid]).to be false
|
|
121
|
+
expect(result[:error]).to eq('no entra_app_id')
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# ---------------------------------------------------------------------------
|
|
127
|
+
# validate_worker_identity — JWKS token validation
|
|
128
|
+
# ---------------------------------------------------------------------------
|
|
129
|
+
|
|
130
|
+
describe '#validate_worker_identity (JWKS)' do
|
|
131
|
+
context 'without token' do
|
|
132
|
+
it 'returns valid without token validation' do
|
|
133
|
+
stub_data_model(build_model_double)
|
|
134
|
+
|
|
135
|
+
result = client.validate_worker_identity(worker_id: 'worker-abc')
|
|
136
|
+
expect(result[:valid]).to be true
|
|
137
|
+
expect(result).not_to have_key(:claims)
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
context 'with token and JWKS support' do
|
|
142
|
+
let(:claims) { { sub: 'worker-abc', iss: 'https://login.microsoftonline.com/tenant-1/v2.0' } }
|
|
143
|
+
|
|
144
|
+
before do
|
|
145
|
+
stub_data_model(build_model_double)
|
|
146
|
+
jwt_mod = Module.new do
|
|
147
|
+
def self.verify_with_jwks(*, **)
|
|
148
|
+
nil
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
stub_const('Legion::Crypt::JWT', jwt_mod)
|
|
152
|
+
allow(Legion::Crypt::JWT).to receive(:verify_with_jwks).and_return(claims)
|
|
153
|
+
allow(client).to receive(:resolve_tenant_id).and_return('tenant-1')
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
it 'validates the token via JWKS' do
|
|
157
|
+
result = client.validate_worker_identity(worker_id: 'worker-abc', token: 'jwt-token')
|
|
158
|
+
expect(result[:valid]).to be true
|
|
159
|
+
expect(result[:claims]).to eq(claims)
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
it 'passes correct JWKS URL and issuer' do
|
|
163
|
+
expect(Legion::Crypt::JWT).to receive(:verify_with_jwks).with(
|
|
164
|
+
'jwt-token',
|
|
165
|
+
jwks_url: 'https://login.microsoftonline.com/tenant-1/discovery/v2.0/keys',
|
|
166
|
+
issuers: ['https://login.microsoftonline.com/tenant-1/v2.0'],
|
|
167
|
+
audience: 'app-id-123'
|
|
168
|
+
)
|
|
169
|
+
client.validate_worker_identity(worker_id: 'worker-abc', token: 'jwt-token')
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
context 'when token is expired' do
|
|
174
|
+
before do
|
|
175
|
+
stub_data_model(build_model_double)
|
|
176
|
+
jwt_mod = Module.new do
|
|
177
|
+
def self.verify_with_jwks(*, **)
|
|
178
|
+
nil
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
stub_const('Legion::Crypt::JWT', jwt_mod)
|
|
182
|
+
stub_const('Legion::Crypt::JWT::Error', Class.new(StandardError))
|
|
183
|
+
stub_const('Legion::Crypt::JWT::ExpiredTokenError', Class.new(Legion::Crypt::JWT::Error))
|
|
184
|
+
allow(Legion::Crypt::JWT).to receive(:verify_with_jwks)
|
|
185
|
+
.and_raise(Legion::Crypt::JWT::ExpiredTokenError, 'token has expired')
|
|
186
|
+
allow(client).to receive(:resolve_tenant_id).and_return('tenant-1')
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
it 'returns valid false with token_expired error' do
|
|
190
|
+
result = client.validate_worker_identity(worker_id: 'worker-abc', token: 'expired-jwt')
|
|
191
|
+
expect(result[:valid]).to be false
|
|
192
|
+
expect(result[:error]).to eq('token_expired')
|
|
193
|
+
end
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
context 'when token signature is invalid' do
|
|
197
|
+
before do
|
|
198
|
+
stub_data_model(build_model_double)
|
|
199
|
+
jwt_mod = Module.new do
|
|
200
|
+
def self.verify_with_jwks(*, **)
|
|
201
|
+
nil
|
|
202
|
+
end
|
|
203
|
+
end
|
|
204
|
+
stub_const('Legion::Crypt::JWT', jwt_mod)
|
|
205
|
+
stub_const('Legion::Crypt::JWT::Error', Class.new(StandardError))
|
|
206
|
+
stub_const('Legion::Crypt::JWT::ExpiredTokenError', Class.new(Legion::Crypt::JWT::Error))
|
|
207
|
+
stub_const('Legion::Crypt::JWT::InvalidTokenError', Class.new(Legion::Crypt::JWT::Error))
|
|
208
|
+
allow(Legion::Crypt::JWT).to receive(:verify_with_jwks)
|
|
209
|
+
.and_raise(Legion::Crypt::JWT::InvalidTokenError, 'signature verification failed')
|
|
210
|
+
allow(client).to receive(:resolve_tenant_id).and_return('tenant-1')
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
it 'returns valid false with token_invalid error' do
|
|
214
|
+
result = client.validate_worker_identity(worker_id: 'worker-abc', token: 'bad-jwt')
|
|
215
|
+
expect(result[:valid]).to be false
|
|
216
|
+
expect(result[:error]).to eq('token_invalid')
|
|
217
|
+
end
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
context 'when no tenant_id configured' do
|
|
221
|
+
before do
|
|
222
|
+
stub_data_model(build_model_double)
|
|
223
|
+
jwt_mod = Module.new do
|
|
224
|
+
def self.verify_with_jwks(*, **)
|
|
225
|
+
nil
|
|
226
|
+
end
|
|
227
|
+
end
|
|
228
|
+
stub_const('Legion::Crypt::JWT', jwt_mod)
|
|
229
|
+
allow(client).to receive(:resolve_tenant_id).and_return(nil)
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
it 'returns valid false with no tenant_id error' do
|
|
233
|
+
result = client.validate_worker_identity(worker_id: 'worker-abc', token: 'jwt-token')
|
|
234
|
+
expect(result[:valid]).to be false
|
|
235
|
+
expect(result[:error]).to eq('no tenant_id configured')
|
|
236
|
+
end
|
|
237
|
+
end
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
# ---------------------------------------------------------------------------
|
|
241
|
+
# sync_owner
|
|
242
|
+
# ---------------------------------------------------------------------------
|
|
243
|
+
|
|
244
|
+
describe '#sync_owner' do
|
|
245
|
+
context 'when the worker exists' do
|
|
246
|
+
it 'returns synced: true with current owner info from local record' do
|
|
247
|
+
stub_data_model(build_model_double)
|
|
248
|
+
|
|
249
|
+
result = client.sync_owner(worker_id: 'worker-abc')
|
|
250
|
+
|
|
251
|
+
expect(result[:synced]).to be true
|
|
252
|
+
expect(result[:worker_id]).to eq('worker-abc')
|
|
253
|
+
expect(result[:owner_msid]).to eq('alice@example.com')
|
|
254
|
+
expect(result[:source]).to eq(:local)
|
|
255
|
+
expect(result[:synced_at]).to be_a(Time)
|
|
256
|
+
end
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
context 'when the worker does not exist' do
|
|
260
|
+
it 'returns synced: false with error' do
|
|
261
|
+
model_double = double('DigitalWorker model')
|
|
262
|
+
allow(model_double).to receive(:first).and_return(nil)
|
|
263
|
+
stub_data_model(model_double)
|
|
264
|
+
|
|
265
|
+
result = client.sync_owner(worker_id: 'no-such-worker')
|
|
266
|
+
|
|
267
|
+
expect(result[:synced]).to be false
|
|
268
|
+
expect(result[:error]).to eq('worker not found')
|
|
269
|
+
end
|
|
270
|
+
end
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
# ---------------------------------------------------------------------------
|
|
274
|
+
# transfer_ownership
|
|
275
|
+
# ---------------------------------------------------------------------------
|
|
276
|
+
|
|
277
|
+
describe '#transfer_ownership' do
|
|
278
|
+
context 'when the worker exists and the new owner differs' do
|
|
279
|
+
it 'returns transferred: true with full audit fields' do
|
|
280
|
+
model_double = build_model_double
|
|
281
|
+
stub_data_model(model_double)
|
|
282
|
+
|
|
283
|
+
result = client.transfer_ownership(
|
|
284
|
+
worker_id: 'worker-abc',
|
|
285
|
+
new_owner_msid: 'bob@example.com',
|
|
286
|
+
transferred_by: 'admin@example.com',
|
|
287
|
+
reason: 'role change'
|
|
288
|
+
)
|
|
289
|
+
|
|
290
|
+
expect(result[:transferred]).to be true
|
|
291
|
+
expect(result[:event]).to eq(:ownership_transferred)
|
|
292
|
+
expect(result[:from_owner]).to eq('alice@example.com')
|
|
293
|
+
expect(result[:to_owner]).to eq('bob@example.com')
|
|
294
|
+
expect(result[:transferred_by]).to eq('admin@example.com')
|
|
295
|
+
expect(result[:reason]).to eq('role change')
|
|
296
|
+
expect(result[:at]).to be_a(Time)
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
it 'emits a Legion::Events event when Legion::Events is available' do
|
|
300
|
+
model_double = build_model_double
|
|
301
|
+
stub_data_model(model_double)
|
|
302
|
+
events_double = double('Legion::Events')
|
|
303
|
+
stub_const('Legion::Events', events_double)
|
|
304
|
+
expect(events_double).to receive(:emit).with('worker.ownership_transferred', hash_including(event: :ownership_transferred))
|
|
305
|
+
|
|
306
|
+
client.transfer_ownership(
|
|
307
|
+
worker_id: 'worker-abc',
|
|
308
|
+
new_owner_msid: 'bob@example.com',
|
|
309
|
+
transferred_by: 'admin@example.com'
|
|
310
|
+
)
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
it 'updates the database record when Legion::Data is available' do
|
|
314
|
+
worker_double = instance_double('DigitalWorker')
|
|
315
|
+
allow(worker_double).to receive(:to_hash).and_return(worker_record)
|
|
316
|
+
expect(worker_double).to receive(:update).with(hash_including(owner_msid: 'bob@example.com'))
|
|
317
|
+
|
|
318
|
+
model_double = double('DigitalWorker model')
|
|
319
|
+
allow(model_double).to receive(:first).and_return(worker_double)
|
|
320
|
+
stub_data_model(model_double)
|
|
321
|
+
|
|
322
|
+
client.transfer_ownership(
|
|
323
|
+
worker_id: 'worker-abc',
|
|
324
|
+
new_owner_msid: 'bob@example.com',
|
|
325
|
+
transferred_by: 'admin@example.com'
|
|
326
|
+
)
|
|
327
|
+
end
|
|
328
|
+
end
|
|
329
|
+
|
|
330
|
+
context 'when new_owner_msid is the same as the current owner' do
|
|
331
|
+
it 'returns transferred: false with same owner error' do
|
|
332
|
+
stub_data_model(build_model_double)
|
|
333
|
+
|
|
334
|
+
result = client.transfer_ownership(
|
|
335
|
+
worker_id: 'worker-abc',
|
|
336
|
+
new_owner_msid: 'alice@example.com',
|
|
337
|
+
transferred_by: 'admin@example.com'
|
|
338
|
+
)
|
|
339
|
+
|
|
340
|
+
expect(result[:transferred]).to be false
|
|
341
|
+
expect(result[:error]).to eq('same owner')
|
|
342
|
+
end
|
|
343
|
+
end
|
|
344
|
+
|
|
345
|
+
context 'when the worker does not exist' do
|
|
346
|
+
it 'returns transferred: false with worker not found error' do
|
|
347
|
+
model_double = double('DigitalWorker model')
|
|
348
|
+
allow(model_double).to receive(:first).and_return(nil)
|
|
349
|
+
stub_data_model(model_double)
|
|
350
|
+
|
|
351
|
+
result = client.transfer_ownership(
|
|
352
|
+
worker_id: 'no-such-worker',
|
|
353
|
+
new_owner_msid: 'bob@example.com',
|
|
354
|
+
transferred_by: 'admin@example.com'
|
|
355
|
+
)
|
|
356
|
+
|
|
357
|
+
expect(result[:transferred]).to be false
|
|
358
|
+
expect(result[:error]).to eq('worker not found')
|
|
359
|
+
end
|
|
360
|
+
end
|
|
361
|
+
end
|
|
362
|
+
|
|
363
|
+
# ---------------------------------------------------------------------------
|
|
364
|
+
# check_orphans
|
|
365
|
+
# ---------------------------------------------------------------------------
|
|
366
|
+
|
|
367
|
+
describe '#check_orphans' do
|
|
368
|
+
context 'when Legion::Data is not available' do
|
|
369
|
+
it 'returns empty orphans with source: :unavailable' do
|
|
370
|
+
result = client.check_orphans
|
|
371
|
+
|
|
372
|
+
expect(result[:orphans]).to eq([])
|
|
373
|
+
expect(result[:checked]).to eq(0)
|
|
374
|
+
expect(result[:source]).to eq(:unavailable)
|
|
375
|
+
end
|
|
376
|
+
end
|
|
377
|
+
|
|
378
|
+
context 'when there are no active workers' do
|
|
379
|
+
it 'returns empty orphans list with checked count of 0' do
|
|
380
|
+
stub_data_model(build_model_double(active_all: []))
|
|
381
|
+
|
|
382
|
+
result = client.check_orphans
|
|
383
|
+
|
|
384
|
+
expect(result[:orphans]).to eq([])
|
|
385
|
+
expect(result[:checked]).to eq(0)
|
|
386
|
+
expect(result[:source]).to eq(:local)
|
|
387
|
+
expect(result[:checked_at]).to be_a(Time)
|
|
388
|
+
end
|
|
389
|
+
end
|
|
390
|
+
|
|
391
|
+
context 'when there are active workers' do
|
|
392
|
+
it 'returns the count of workers scanned and an empty orphans list (pending Entra validation)' do
|
|
393
|
+
active_worker = double('DigitalWorker', worker_id: 'worker-abc', owner_msid: 'alice@example.com',
|
|
394
|
+
entra_app_id: 'entra-app-abc')
|
|
395
|
+
stub_data_model(build_model_double(active_all: [active_worker]))
|
|
396
|
+
|
|
397
|
+
result = client.check_orphans
|
|
398
|
+
|
|
399
|
+
expect(result[:checked]).to eq(1)
|
|
400
|
+
expect(result[:orphans]).to eq([])
|
|
401
|
+
expect(result[:source]).to eq(:local)
|
|
402
|
+
end
|
|
403
|
+
end
|
|
404
|
+
end
|
|
405
|
+
end
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'legion/extensions/identity/client'
|
|
4
|
+
|
|
5
|
+
RSpec.describe Legion::Extensions::Identity::Runners::Identity do
|
|
6
|
+
let(:client) { Legion::Extensions::Identity::Client.new }
|
|
7
|
+
|
|
8
|
+
describe '#observe_behavior' do
|
|
9
|
+
it 'records a single observation' do
|
|
10
|
+
result = client.observe_behavior(dimension: :communication_cadence, value: 0.7)
|
|
11
|
+
expect(result[:recorded]).to be true
|
|
12
|
+
expect(result[:observation_count]).to eq(1)
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
describe '#observe_all' do
|
|
17
|
+
it 'records multiple observations' do
|
|
18
|
+
result = client.observe_all(observations: {
|
|
19
|
+
communication_cadence: 0.6,
|
|
20
|
+
vocabulary_patterns: 0.7,
|
|
21
|
+
emotional_response: 0.5
|
|
22
|
+
})
|
|
23
|
+
expect(result[:dimensions_observed].size).to eq(3)
|
|
24
|
+
expect(result[:observation_count]).to eq(3)
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
describe '#check_entropy' do
|
|
29
|
+
it 'returns entropy classification' do
|
|
30
|
+
result = client.check_entropy
|
|
31
|
+
expect(result[:entropy]).to be_between(0.0, 1.0)
|
|
32
|
+
expect(result).to have_key(:classification)
|
|
33
|
+
expect(result).to have_key(:trend)
|
|
34
|
+
expect(result).to have_key(:in_range)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
it 'warns on high entropy observations' do
|
|
38
|
+
# Build baseline
|
|
39
|
+
20.times { client.observe_behavior(dimension: :communication_cadence, value: 0.5) }
|
|
40
|
+
# Check with very different observation
|
|
41
|
+
result = client.check_entropy(observations: { communication_cadence: 10.0 })
|
|
42
|
+
expect(result[:warning]).to eq(:possible_impersonation_or_drift) if result[:classification] == :high_entropy
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
describe '#identity_status' do
|
|
47
|
+
it 'returns model state' do
|
|
48
|
+
status = client.identity_status
|
|
49
|
+
expect(status).to have_key(:model)
|
|
50
|
+
expect(status).to have_key(:maturity)
|
|
51
|
+
expect(status).to have_key(:observation_count)
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
describe '#identity_maturity' do
|
|
56
|
+
it 'returns maturity level' do
|
|
57
|
+
result = client.identity_maturity
|
|
58
|
+
expect(result[:maturity]).to eq(:nascent)
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|