clavis 0.7.1

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 (74) hide show
  1. checksums.yaml +7 -0
  2. data/.actrc +4 -0
  3. data/.cursor/rules/ruby-gem.mdc +49 -0
  4. data/.gemignore +6 -0
  5. data/.rspec +3 -0
  6. data/.rubocop.yml +88 -0
  7. data/.vscode/settings.json +22 -0
  8. data/CHANGELOG.md +127 -0
  9. data/CODE_OF_CONDUCT.md +3 -0
  10. data/LICENSE.txt +21 -0
  11. data/README.md +838 -0
  12. data/Rakefile +341 -0
  13. data/UPGRADE.md +57 -0
  14. data/app/assets/stylesheets/clavis.css +133 -0
  15. data/app/controllers/clavis/auth_controller.rb +133 -0
  16. data/config/database.yml +16 -0
  17. data/config/routes.rb +49 -0
  18. data/docs/SECURITY.md +340 -0
  19. data/docs/TESTING.md +78 -0
  20. data/docs/integration.md +272 -0
  21. data/error_handling.md +355 -0
  22. data/file_structure.md +221 -0
  23. data/gemfiles/rails_80.gemfile +17 -0
  24. data/gemfiles/rails_80.gemfile.lock +286 -0
  25. data/implementation_plan.md +523 -0
  26. data/lib/clavis/configuration.rb +196 -0
  27. data/lib/clavis/controllers/concerns/authentication.rb +232 -0
  28. data/lib/clavis/controllers/concerns/session_management.rb +117 -0
  29. data/lib/clavis/engine.rb +191 -0
  30. data/lib/clavis/errors.rb +205 -0
  31. data/lib/clavis/logging.rb +116 -0
  32. data/lib/clavis/models/concerns/oauth_authenticatable.rb +169 -0
  33. data/lib/clavis/oauth_identity.rb +174 -0
  34. data/lib/clavis/providers/apple.rb +135 -0
  35. data/lib/clavis/providers/base.rb +432 -0
  36. data/lib/clavis/providers/custom_provider_example.rb +57 -0
  37. data/lib/clavis/providers/facebook.rb +84 -0
  38. data/lib/clavis/providers/generic.rb +63 -0
  39. data/lib/clavis/providers/github.rb +87 -0
  40. data/lib/clavis/providers/google.rb +98 -0
  41. data/lib/clavis/providers/microsoft.rb +57 -0
  42. data/lib/clavis/security/csrf_protection.rb +79 -0
  43. data/lib/clavis/security/https_enforcer.rb +90 -0
  44. data/lib/clavis/security/input_validator.rb +192 -0
  45. data/lib/clavis/security/parameter_filter.rb +64 -0
  46. data/lib/clavis/security/rate_limiter.rb +109 -0
  47. data/lib/clavis/security/redirect_uri_validator.rb +124 -0
  48. data/lib/clavis/security/session_manager.rb +220 -0
  49. data/lib/clavis/security/token_storage.rb +114 -0
  50. data/lib/clavis/user_info_normalizer.rb +74 -0
  51. data/lib/clavis/utils/nonce_store.rb +14 -0
  52. data/lib/clavis/utils/secure_token.rb +17 -0
  53. data/lib/clavis/utils/state_store.rb +18 -0
  54. data/lib/clavis/version.rb +6 -0
  55. data/lib/clavis/view_helpers.rb +260 -0
  56. data/lib/clavis.rb +132 -0
  57. data/lib/generators/clavis/controller/controller_generator.rb +48 -0
  58. data/lib/generators/clavis/controller/templates/controller.rb.tt +137 -0
  59. data/lib/generators/clavis/controller/templates/views/login.html.erb.tt +145 -0
  60. data/lib/generators/clavis/install_generator.rb +182 -0
  61. data/lib/generators/clavis/templates/add_oauth_to_users.rb +28 -0
  62. data/lib/generators/clavis/templates/clavis.css +133 -0
  63. data/lib/generators/clavis/templates/initializer.rb +47 -0
  64. data/lib/generators/clavis/templates/initializer.rb.tt +76 -0
  65. data/lib/generators/clavis/templates/migration.rb +18 -0
  66. data/lib/generators/clavis/templates/migration.rb.tt +16 -0
  67. data/lib/generators/clavis/user_method/user_method_generator.rb +219 -0
  68. data/lib/tasks/provider_verification.rake +77 -0
  69. data/llms.md +487 -0
  70. data/log/development.log +20 -0
  71. data/log/test.log +0 -0
  72. data/sig/clavis.rbs +4 -0
  73. data/testing_plan.md +710 -0
  74. metadata +258 -0
data/testing_plan.md ADDED
@@ -0,0 +1,710 @@
1
+ # Clavis - Testing Plan
2
+
3
+ ## Testing Philosophy
4
+
5
+ The Clavis testing strategy follows these principles:
6
+
7
+ 1. **Spec Compliance**: Ensure strict adherence to OAuth 2.0 and OIDC specifications
8
+ 2. **Security First**: Prioritize testing of security-related components
9
+ 3. **Comprehensive Coverage**: Test all flows and edge cases
10
+ 4. **Test Isolation**: Unit tests should not depend on external services
11
+ 5. **Integration Verification**: End-to-end tests should verify complete flows
12
+
13
+ ## Test Types
14
+
15
+ ### 1. Unit Tests
16
+
17
+ Unit tests will validate individual components in isolation:
18
+
19
+ - Provider implementations
20
+ - Token handling
21
+ - Configuration management
22
+ - Helper methods
23
+ - View components
24
+
25
+ ### 2. Integration Tests
26
+
27
+ Integration tests will verify components working together:
28
+
29
+ - Complete authorization flows
30
+ - Rails engine integration
31
+ - Generator functionality
32
+
33
+ ### 3. Security Tests
34
+
35
+ Dedicated security tests will verify:
36
+
37
+ - Token validation
38
+ - State parameter protection
39
+ - CSRF mitigation
40
+ - Proper error handling
41
+
42
+ ## OAuth and OIDC Compliance Testing
43
+
44
+ ### Mock Provider Infrastructure
45
+
46
+ Create a standardized mock provider infrastructure that simulates real OAuth/OIDC providers:
47
+
48
+ ```ruby
49
+ # spec/support/mock_oauth_provider.rb
50
+ class MockOAuthProvider
51
+ attr_reader :requests
52
+
53
+ def initialize(options = {})
54
+ @options = {
55
+ issuer: "https://mock-provider.example.com",
56
+ authorization_endpoint: "/authorize",
57
+ token_endpoint: "/token",
58
+ jwks_uri: "/jwks",
59
+ userinfo_endpoint: "/userinfo"
60
+ }.merge(options)
61
+
62
+ @requests = []
63
+ @tokens = {}
64
+ @codes = {}
65
+ @jwks = generate_jwks
66
+ end
67
+
68
+ def handle_request(method, path, params)
69
+ @requests << { method: method, path: path, params: params }
70
+
71
+ case path
72
+ when @options[:authorization_endpoint]
73
+ handle_authorization_request(params)
74
+ when @options[:token_endpoint]
75
+ handle_token_request(params)
76
+ when @options[:userinfo_endpoint]
77
+ handle_userinfo_request(params)
78
+ when @options[:jwks_uri]
79
+ handle_jwks_request(params)
80
+ else
81
+ [404, {}, ["Not Found"]]
82
+ end
83
+ end
84
+
85
+ private
86
+
87
+ def handle_authorization_request(params)
88
+ # Validate client_id, redirect_uri, scope, etc.
89
+ # Generate code and store with associated parameters
90
+ # Return redirect to redirect_uri with code
91
+ end
92
+
93
+ def handle_token_request(params)
94
+ # Validate code, client_id, client_secret, etc.
95
+ # Generate tokens and return JSON response
96
+ end
97
+
98
+ # Additional handler methods...
99
+ end
100
+ ```
101
+
102
+ ### Authorization Flow Testing
103
+
104
+ Test the complete authorization code flow:
105
+
106
+ ```ruby
107
+ # spec/integration/authorization_flow_spec.rb
108
+ RSpec.describe "Authorization Code Flow", type: :integration do
109
+ let(:mock_provider) { MockOAuthProvider.new }
110
+
111
+ before do
112
+ # Configure provider in Clavis
113
+ Clavis.configure do |config|
114
+ config.providers = {
115
+ mock: {
116
+ client_id: "test-client-id",
117
+ client_secret: "test-client-secret",
118
+ redirect_uri: "http://localhost/auth/mock/callback"
119
+ }
120
+ }
121
+ end
122
+
123
+ # Configure Faraday to use the mock provider
124
+ allow_any_instance_of(Faraday::Connection).to receive(:get) do |_, url, params|
125
+ uri = URI(url)
126
+ mock_provider.handle_request(:get, uri.path, params)
127
+ end
128
+
129
+ allow_any_instance_of(Faraday::Connection).to receive(:post) do |_, url, params|
130
+ uri = URI(url)
131
+ mock_provider.handle_request(:post, uri.path, params)
132
+ end
133
+ end
134
+
135
+ it "successfully completes the authorization flow" do
136
+ # 1. Initiate authorization
137
+ auth_url = Clavis.provider(:mock).authorize_url(
138
+ state: "test-state",
139
+ nonce: "test-nonce",
140
+ scope: "openid email profile"
141
+ )
142
+
143
+ # 2. Verify authorization URL parameters
144
+ expect(auth_url).to include("response_type=code")
145
+ expect(auth_url).to include("client_id=test-client-id")
146
+ expect(auth_url).to include("redirect_uri=")
147
+ expect(auth_url).to include("scope=openid+email+profile")
148
+ expect(auth_url).to include("state=test-state")
149
+ expect(auth_url).to include("nonce=test-nonce")
150
+
151
+ # 3. Simulate authorization response
152
+ callback_params = { code: "test-auth-code", state: "test-state" }
153
+
154
+ # 4. Exchange code for tokens
155
+ auth_response = Clavis.provider(:mock).token_exchange(
156
+ code: callback_params[:code],
157
+ expected_state: "test-state"
158
+ )
159
+
160
+ # 5. Verify token response
161
+ expect(auth_response).to include(:access_token)
162
+ expect(auth_response).to include(:id_token)
163
+ expect(auth_response).to include(:token_type)
164
+ expect(auth_response[:token_type]).to eq("Bearer")
165
+
166
+ # 6. Verify ID token claims
167
+ id_token = auth_response[:id_token]
168
+ parsed_token = Clavis.provider(:mock).parse_id_token(id_token)
169
+
170
+ expect(parsed_token["iss"]).to eq(mock_provider.options[:issuer])
171
+ expect(parsed_token["sub"]).to be_present
172
+ expect(parsed_token["aud"]).to eq("test-client-id")
173
+ expect(parsed_token["nonce"]).to eq("test-nonce")
174
+ end
175
+ end
176
+ ```
177
+
178
+ ### ID Token Validation Testing
179
+
180
+ Test proper validation of ID tokens according to OIDC spec:
181
+
182
+ ```ruby
183
+ # spec/providers/base_spec.rb
184
+ RSpec.describe Clavis::Providers::Base do
185
+ describe "#validate_id_token" do
186
+ let(:mock_provider) { MockOAuthProvider.new }
187
+ let(:valid_token_payload) do
188
+ {
189
+ iss: mock_provider.options[:issuer],
190
+ sub: "test-subject",
191
+ aud: "test-client-id",
192
+ exp: Time.now.to_i + 3600,
193
+ iat: Time.now.to_i,
194
+ nonce: "test-nonce"
195
+ }
196
+ end
197
+
198
+ it "accepts valid tokens" do
199
+ token = JWT.encode(valid_token_payload, mock_provider.signing_key, 'RS256')
200
+ expect {
201
+ subject.validate_id_token(token, nonce: "test-nonce")
202
+ }.not_to raise_error
203
+ end
204
+
205
+ it "rejects tokens with invalid signature" do
206
+ # Test with wrong signing key
207
+ token = JWT.encode(valid_token_payload, OpenSSL::PKey::RSA.new(2048), 'RS256')
208
+ expect {
209
+ subject.validate_id_token(token, nonce: "test-nonce")
210
+ }.to raise_error(Clavis::InvalidToken)
211
+ end
212
+
213
+ it "rejects expired tokens" do
214
+ expired_payload = valid_token_payload.merge(exp: Time.now.to_i - 3600)
215
+ token = JWT.encode(expired_payload, mock_provider.signing_key, 'RS256')
216
+ expect {
217
+ subject.validate_id_token(token, nonce: "test-nonce")
218
+ }.to raise_error(Clavis::InvalidToken)
219
+ end
220
+
221
+ it "rejects tokens with incorrect audience" do
222
+ wrong_aud_payload = valid_token_payload.merge(aud: "wrong-client-id")
223
+ token = JWT.encode(wrong_aud_payload, mock_provider.signing_key, 'RS256')
224
+ expect {
225
+ subject.validate_id_token(token, nonce: "test-nonce")
226
+ }.to raise_error(Clavis::InvalidToken)
227
+ end
228
+
229
+ it "rejects tokens with incorrect nonce" do
230
+ token = JWT.encode(valid_token_payload, mock_provider.signing_key, 'RS256')
231
+ expect {
232
+ subject.validate_id_token(token, nonce: "wrong-nonce")
233
+ }.to raise_error(Clavis::InvalidToken)
234
+ end
235
+ end
236
+ end
237
+ ```
238
+
239
+ ### State Parameter Testing
240
+
241
+ Test proper handling of the state parameter for CSRF protection:
242
+
243
+ ```ruby
244
+ # spec/controllers/concerns/authentication_spec.rb
245
+ RSpec.describe Clavis::Controllers::Concerns::Authentication do
246
+ describe "#oauth_callback" do
247
+ let(:mock_controller) do
248
+ Class.new(ActionController::Base) do
249
+ include Clavis::Controllers::Concerns::Authentication
250
+ end.new
251
+ end
252
+
253
+ before do
254
+ allow(mock_controller).to receive(:params).and_return({
255
+ provider: "mock",
256
+ code: "test-auth-code",
257
+ state: "test-state"
258
+ })
259
+
260
+ allow(mock_controller).to receive(:session).and_return({})
261
+ end
262
+
263
+ it "rejects callbacks with mismatched state parameter" do
264
+ mock_controller.session[:oauth_state] = "different-state"
265
+
266
+ expect {
267
+ mock_controller.oauth_callback
268
+ }.to raise_error(Clavis::InvalidState)
269
+ end
270
+
271
+ it "rejects callbacks with missing state parameter" do
272
+ # No state in session
273
+
274
+ expect {
275
+ mock_controller.oauth_callback
276
+ }.to raise_error(Clavis::MissingState)
277
+ end
278
+
279
+ it "accepts callbacks with matching state parameter" do
280
+ mock_controller.session[:oauth_state] = "test-state"
281
+
282
+ # Mock the provider callback
283
+ allow(Clavis).to receive_message_chain(:provider, :process_callback)
284
+ .and_return({provider: "mock", uid: "123"})
285
+
286
+ # Mock user creation
287
+ allow(mock_controller).to receive(:find_or_create_user_from_oauth)
288
+ .and_return(double("User"))
289
+
290
+ expect {
291
+ mock_controller.oauth_callback { |user, auth| }
292
+ }.not_to raise_error
293
+ end
294
+ end
295
+ end
296
+ ```
297
+
298
+ ### Error Handling Tests
299
+
300
+ Test how the system handles various OAuth/OIDC error responses:
301
+
302
+ ```ruby
303
+ # spec/integration/error_handling_spec.rb
304
+ RSpec.describe "OAuth Error Handling", type: :integration do
305
+ describe "authorization errors" do
306
+ it "handles access_denied errors" do
307
+ # Simulate user cancellation
308
+ params = { error: "access_denied", error_description: "User denied access" }
309
+
310
+ # Handle in controller
311
+ controller = ApplicationController.new
312
+ controller.params = params
313
+
314
+ expect {
315
+ controller.oauth_callback
316
+ }.to raise_error(Clavis::AuthorizationDenied)
317
+ end
318
+
319
+ it "handles invalid_request errors" do
320
+ params = { error: "invalid_request", error_description: "Missing required parameter" }
321
+
322
+ # Test handling
323
+ end
324
+
325
+ # Test other standard OAuth error codes
326
+ end
327
+
328
+ describe "token endpoint errors" do
329
+ it "handles invalid_grant errors" do
330
+ # Simulate provider response for expired code
331
+ response = {
332
+ error: "invalid_grant",
333
+ error_description: "Authorization code has expired"
334
+ }
335
+
336
+ # Mock Faraday to return this error
337
+ allow_any_instance_of(Faraday::Connection).to receive(:post)
338
+ .and_return(double(status: 400, body: response.to_json))
339
+
340
+ expect {
341
+ Clavis.provider(:mock).token_exchange(code: "expired-code")
342
+ }.to raise_error(Clavis::InvalidGrant)
343
+ end
344
+
345
+ # Test other token endpoint errors
346
+ end
347
+ end
348
+ ```
349
+
350
+ ### Provider-Specific Tests
351
+
352
+ Create dedicated tests for each supported provider's unique behaviors:
353
+
354
+ ```ruby
355
+ # spec/providers/google_spec.rb
356
+ RSpec.describe Clavis::Providers::Google do
357
+ it "uses the correct endpoints" do
358
+ provider = described_class.new(client_id: "test", client_secret: "test")
359
+
360
+ expect(provider.authorization_endpoint).to eq("https://accounts.google.com/o/oauth2/v2/auth")
361
+ expect(provider.token_endpoint).to eq("https://oauth2.googleapis.com/token")
362
+ expect(provider.userinfo_endpoint).to eq("https://openidconnect.googleapis.com/v1/userinfo")
363
+ end
364
+
365
+ it "requests the correct scopes" do
366
+ provider = described_class.new(client_id: "test", client_secret: "test")
367
+ url = provider.authorize_url(state: "test", nonce: "test", scope: nil)
368
+
369
+ # Google should default to these scopes
370
+ expect(url).to include("scope=openid+email+profile")
371
+ end
372
+
373
+ # Add tests for Google-specific behavior or responses
374
+ end
375
+
376
+ # Similar tests for other providers
377
+ ```
378
+
379
+ ### UserInfo Endpoint Testing
380
+
381
+ Test the retrieval and processing of claims from the UserInfo endpoint:
382
+
383
+ ```ruby
384
+ # spec/integration/userinfo_spec.rb
385
+ RSpec.describe "UserInfo Endpoint", type: :integration do
386
+ let(:mock_provider) { MockOAuthProvider.new }
387
+
388
+ before do
389
+ # Setup mocks
390
+ end
391
+
392
+ it "retrieves user info with a valid access token" do
393
+ # Mock userinfo response
394
+ userinfo = {
395
+ sub: "12345",
396
+ name: "Test User",
397
+ email: "test@example.com",
398
+ email_verified: true
399
+ }
400
+
401
+ allow_any_instance_of(Faraday::Connection).to receive(:get)
402
+ .with(mock_provider.options[:userinfo_endpoint], anything)
403
+ .and_return(double(status: 200, body: userinfo.to_json))
404
+
405
+ # Get user info
406
+ provider = Clavis.provider(:mock)
407
+ result = provider.get_user_info("valid-access-token")
408
+
409
+ # Verify
410
+ expect(result[:sub]).to eq("12345")
411
+ expect(result[:email]).to eq("test@example.com")
412
+ expect(result[:name]).to eq("Test User")
413
+ end
414
+
415
+ it "handles unauthorized access token" do
416
+ allow_any_instance_of(Faraday::Connection).to receive(:get)
417
+ .and_return(double(status: 401, body: { error: "invalid_token" }.to_json))
418
+
419
+ expect {
420
+ Clavis.provider(:mock).get_user_info("invalid-token")
421
+ }.to raise_error(Clavis::InvalidAccessToken)
422
+ end
423
+ end
424
+ ```
425
+
426
+ ### Testing Compliance with Specific OIDC Requirements
427
+
428
+ Test specific requirements from the OIDC spec:
429
+
430
+ ```ruby
431
+ # spec/compliance/oidc_spec.rb
432
+ RSpec.describe "OIDC Compliance", type: :compliance do
433
+ describe "ID Token requirements" do
434
+ it "validates all required claims" do
435
+ # Test that id_token validation checks all required claims
436
+ # (iss, sub, aud, exp, iat)
437
+ end
438
+
439
+ it "validates optional claims when present" do
440
+ # Test validation of optional claims like auth_time, nonce, etc.
441
+ end
442
+ end
443
+
444
+ describe "Authorization Request requirements" do
445
+ it "includes all required parameters" do
446
+ # Test that auth requests include response_type, client_id, redirect_uri
447
+ end
448
+
449
+ it "supports all response_type values" do
450
+ # Test support for "code" response_type
451
+ end
452
+ end
453
+
454
+ # Add more spec compliance tests
455
+ end
456
+ ```
457
+
458
+ ## Test Data and Fixtures
459
+
460
+ ### Sample JWT Tokens
461
+
462
+ Create fixtures with sample valid and invalid JWTs for testing:
463
+
464
+ ```ruby
465
+ # spec/fixtures/tokens/valid_id_token.json
466
+ {
467
+ "header": {
468
+ "alg": "RS256",
469
+ "kid": "test-key-id",
470
+ "typ": "JWT"
471
+ },
472
+ "payload": {
473
+ "iss": "https://accounts.example.com",
474
+ "sub": "123456789",
475
+ "aud": "client-id",
476
+ "exp": 1699999999,
477
+ "iat": 1600000000,
478
+ "auth_time": 1600000000,
479
+ "nonce": "test-nonce",
480
+ "name": "Test User",
481
+ "email": "test@example.com"
482
+ },
483
+ "signature": "..."
484
+ }
485
+ ```
486
+
487
+ ### Sample Provider Responses
488
+
489
+ Create fixtures with sample responses from various endpoints:
490
+
491
+ ```ruby
492
+ # spec/fixtures/auth_responses/google_success.json
493
+ {
494
+ "access_token": "ya29.a0AfH6SMBx-...",
495
+ "expires_in": 3599,
496
+ "refresh_token": "1//04DvKh...",
497
+ "scope": "openid https://www.googleapis.com/auth/userinfo.profile",
498
+ "token_type": "Bearer",
499
+ "id_token": "eyJhbGciOiJSUzI1..."
500
+ }
501
+
502
+ # spec/fixtures/auth_responses/google_error.json
503
+ {
504
+ "error": "invalid_grant",
505
+ "error_description": "Invalid authorization code"
506
+ }
507
+ ```
508
+
509
+ ## Security Testing
510
+
511
+ ### XSS Protection
512
+
513
+ Test that all user-provided data is properly escaped in views:
514
+
515
+ ```ruby
516
+ # spec/view_helpers_spec.rb
517
+ RSpec.describe Clavis::ViewHelpers do
518
+ describe "#clavis_oauth_button" do
519
+ it "properly escapes button text" do
520
+ # Test with potential XSS string
521
+ result = helper.clavis_oauth_button(:google, text: "<script>alert('XSS')</script>")
522
+
523
+ # Verify it's escaped
524
+ expect(result).not_to include("<script>")
525
+ expect(result).to include("&lt;script&gt;")
526
+ end
527
+ end
528
+ end
529
+ ```
530
+
531
+ ### CSRF Protection
532
+
533
+ Test CSRF protection mechanisms:
534
+
535
+ ```ruby
536
+ # spec/security/csrf_spec.rb
537
+ RSpec.describe "CSRF Protection", type: :security do
538
+ it "generates unique state parameters for each request" do
539
+ states = []
540
+ 10.times do
541
+ states << Clavis::Utils::SecureToken.generate_state
542
+ end
543
+
544
+ # Verify all states are unique
545
+ expect(states.uniq.count).to eq(10)
546
+
547
+ # Verify states are sufficiently random
548
+ states.each do |state|
549
+ expect(state.length).to be >= 32
550
+ end
551
+ end
552
+
553
+ it "validates state parameter on callback" do
554
+ # Similar to previous state parameter tests
555
+ end
556
+ end
557
+ ```
558
+
559
+ ## Continuous Integration Testing
560
+
561
+ Set up GitHub Actions workflow to automate testing:
562
+
563
+ ```yaml
564
+ # .github/workflows/test.yml
565
+ name: Tests
566
+
567
+ on:
568
+ push:
569
+ branches: [ main ]
570
+ pull_request:
571
+ branches: [ main ]
572
+
573
+ jobs:
574
+ test:
575
+ runs-on: ubuntu-latest
576
+ strategy:
577
+ matrix:
578
+ ruby-version: ['3.1', '3.2']
579
+ rails-version: ['7.0', '8.0']
580
+
581
+ steps:
582
+ - uses: actions/checkout@v3
583
+ - name: Set up Ruby
584
+ uses: ruby/setup-ruby@v1
585
+ with:
586
+ ruby-version: ${{ matrix.ruby-version }}
587
+ bundler-cache: true
588
+ - name: Install dependencies
589
+ run: |
590
+ gem install bundler
591
+ bundle install --jobs 4 --retry 3
592
+ - name: Run tests
593
+ run: bundle exec rake
594
+ ```
595
+
596
+ ## Coverage Tracking
597
+
598
+ Set up test coverage tracking:
599
+
600
+ ```ruby
601
+ # spec/spec_helper.rb
602
+ require 'simplecov'
603
+ SimpleCov.start 'rails' do
604
+ add_filter '/spec/'
605
+ add_filter '/lib/generators/'
606
+
607
+ add_group 'Providers', 'lib/clavis/providers'
608
+ add_group 'Controllers', 'lib/clavis/controllers'
609
+ add_group 'Models', 'lib/clavis/models'
610
+ add_group 'Utils', 'lib/clavis/utils'
611
+ end
612
+ ```
613
+
614
+ ## Test Environment Setup
615
+
616
+ ```ruby
617
+ # spec/spec_helper.rb
618
+ RSpec.configure do |config|
619
+ # Setup mocks and stubs for HTTP requests
620
+ config.before(:each) do
621
+ # Setup Faraday stubbing
622
+ allow(Faraday).to receive(:new).and_return(double('Faraday::Connection'))
623
+ end
624
+
625
+ # Load support files
626
+ Dir[File.expand_path("support/**/*.rb", __dir__)].each { |f| require f }
627
+
628
+ # Other RSpec configuration
629
+ end
630
+ ```
631
+
632
+ ## Testing Generators
633
+
634
+ ```ruby
635
+ # spec/generators/install_generator_spec.rb
636
+ require 'generators/clavis/install_generator'
637
+
638
+ RSpec.describe Clavis::InstallGenerator, type: :generator do
639
+ destination File.expand_path("../tmp", __dir__)
640
+
641
+ before do
642
+ prepare_destination
643
+ # Setup fake Rails app structure
644
+ FileUtils.mkdir_p "#{destination_root}/app/models"
645
+ File.write "#{destination_root}/app/models/user.rb", "class User < ApplicationRecord\nend"
646
+ end
647
+
648
+ context "with default options" do
649
+ before { run_generator }
650
+
651
+ it "creates an initializer" do
652
+ assert_file "config/initializers/clavis.rb"
653
+ end
654
+
655
+ it "creates a migration" do
656
+ assert_migration "db/migrate/add_oauth_to_users.rb"
657
+ end
658
+ end
659
+
660
+ context "with specific providers" do
661
+ before { run_generator %w(--providers=google github) }
662
+
663
+ it "configures specified providers in the initializer" do
664
+ assert_file "config/initializers/clavis.rb" do |content|
665
+ assert_match(/config\.providers = {/, content)
666
+ assert_match(/google:/, content)
667
+ assert_match(/github:/, content)
668
+ end
669
+ end
670
+ end
671
+ end
672
+ ```
673
+
674
+ ## Rails Controller Testing
675
+
676
+ ```ruby
677
+ # spec/controllers/auth_controller_spec.rb
678
+ RSpec.describe Clavis::AuthController, type: :controller do
679
+ routes { Clavis::Engine.routes }
680
+
681
+ describe "GET #authorize" do
682
+ it "redirects to the provider authorization URL" do
683
+ get :authorize, params: { provider: "google" }
684
+
685
+ expect(response).to have_http_status(:redirect)
686
+ expect(response.location).to start_with("https://accounts.google.com/o/oauth2/v2/auth")
687
+ end
688
+
689
+ it "stores the state in the session" do
690
+ get :authorize, params: { provider: "google" }
691
+
692
+ expect(session[:oauth_state]).not_to be_nil
693
+ end
694
+ end
695
+
696
+ describe "GET #callback" do
697
+ before do
698
+ # Setup test data
699
+ end
700
+
701
+ it "exchanges the code for tokens" do
702
+ # Test successful callback
703
+ end
704
+
705
+ it "handles error responses" do
706
+ # Test error handling
707
+ end
708
+ end
709
+ end
710
+ ```