otto 2.9.0 → 2.11.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 (100) hide show
  1. checksums.yaml +4 -4
  2. data/.github/dependabot.yml +5 -0
  3. data/.github/workflows/ci.yml +11 -8
  4. data/.github/workflows/claude-code-review.yml +38 -13
  5. data/.github/workflows/claude.yml +10 -8
  6. data/.github/workflows/code-smells.yml +5 -5
  7. data/.github/workflows/release-gem.yml +2 -2
  8. data/.github/workflows/ruby-lint.yml +3 -3
  9. data/.github/workflows/yardoc.yml +5 -5
  10. data/.gitignore +1 -5
  11. data/.rubocop_todo.yml +8 -5
  12. data/AGENTS.md +40 -1
  13. data/CHANGELOG.rst +235 -0
  14. data/Gemfile +3 -3
  15. data/Gemfile.lock +11 -15
  16. data/README.md +75 -37
  17. data/docs/README.md +166 -0
  18. data/docs/adr/README.md +16 -0
  19. data/docs/adr/adr-001-route-authentication-at-handler-boundary.md +38 -0
  20. data/docs/adr/adr-002-multi-strategy-authentication-and-authorization.md +50 -0
  21. data/docs/adr/adr-003-caddy-tls-route-based-integration.md +48 -0
  22. data/docs/adr/adr-004-separate-compatibility-from-security-maintenance.md +58 -0
  23. data/docs/guides/authentication.md +377 -0
  24. data/docs/guides/caddy-tls.md +205 -0
  25. data/docs/guides/configuration_freezing.md +157 -0
  26. data/docs/guides/enrichment.md +161 -0
  27. data/docs/guides/forwarded-authority.md +249 -0
  28. data/docs/guides/geo-country.md +168 -0
  29. data/docs/guides/ip_privacy.md +39 -0
  30. data/docs/guides/ipaddr-encoding-quirk.md +56 -0
  31. data/docs/guides/mcp.md +282 -0
  32. data/docs/guides/privacy.md +193 -0
  33. data/docs/guides/routing.md +293 -0
  34. data/docs/guides/structured_logging.md +281 -0
  35. data/docs/guides/testing-guide.md +391 -0
  36. data/docs/maintainers/github-actions.md +41 -0
  37. data/docs/maintainers/investigations/.gitignore +2 -0
  38. data/docs/migrating/v2.0.0.md +337 -0
  39. data/docs/reference/authentication.md +290 -0
  40. data/docs/reference/route-syntax.md +181 -0
  41. data/docs/reference/runtime-and-dependency-security.md +86 -0
  42. data/examples/advanced_routes/README.md +43 -57
  43. data/examples/authentication_strategies/README.md +37 -196
  44. data/examples/basic/README.md +24 -39
  45. data/examples/basic/config.ru +0 -1
  46. data/examples/caddy_tls_demo/README.md +8 -2
  47. data/examples/lambda_handlers/README.md +11 -2
  48. data/examples/mcp_demo/README.md +75 -161
  49. data/examples/mcp_demo/config.ru +1 -0
  50. data/examples/security_features/README.md +36 -234
  51. data/lib/otto/caddy_tls/localhost_guard.rb +22 -24
  52. data/lib/otto/core/configuration.rb +40 -23
  53. data/lib/otto/core/error_handler.rb +40 -2
  54. data/lib/otto/core/file_safety.rb +105 -31
  55. data/lib/otto/core/middleware_stack.rb +36 -36
  56. data/lib/otto/core/router.rb +94 -18
  57. data/lib/otto/core/static_mounts.rb +172 -0
  58. data/lib/otto/core.rb +1 -0
  59. data/lib/otto/env_keys.rb +20 -2
  60. data/lib/otto/mcp/auth/token.rb +10 -4
  61. data/lib/otto/mcp/core.rb +23 -5
  62. data/lib/otto/mcp/endpoint.rb +41 -0
  63. data/lib/otto/mcp/errors.rb +15 -0
  64. data/lib/otto/mcp/options.rb +292 -0
  65. data/lib/otto/mcp/protocol.rb +52 -22
  66. data/lib/otto/mcp/rate_limiting.rb +175 -97
  67. data/lib/otto/mcp/registry.rb +14 -14
  68. data/lib/otto/mcp/schema_validation.rb +20 -12
  69. data/lib/otto/mcp/server.rb +131 -32
  70. data/lib/otto/optional_dependency.rb +57 -0
  71. data/lib/otto/privacy/config.rb +10 -8
  72. data/lib/otto/security/authentication/route_auth_wrapper/role_authorization.rb +22 -5
  73. data/lib/otto/security/authentication/strategies/api_key_strategy.rb +181 -18
  74. data/lib/otto/security/authentication/strategies/permission_strategy.rb +1 -0
  75. data/lib/otto/security/authentication/strategies/role_strategy.rb +1 -0
  76. data/lib/otto/security/authentication/strategies/session_strategy.rb +1 -0
  77. data/lib/otto/security/authentication/strategy_result.rb +58 -28
  78. data/lib/otto/security/config.rb +328 -14
  79. data/lib/otto/security/configurator.rb +36 -6
  80. data/lib/otto/security/core.rb +19 -1
  81. data/lib/otto/security/middleware/ip_privacy_middleware.rb +105 -4
  82. data/lib/otto/security/middleware/rate_limit_middleware.rb +1 -6
  83. data/lib/otto/security/rate_limiter.rb +81 -46
  84. data/lib/otto/static.rb +38 -0
  85. data/lib/otto/utils.rb +50 -0
  86. data/lib/otto/version.rb +1 -1
  87. data/lib/otto.rb +130 -13
  88. data/otto.gemspec +0 -2
  89. metadata +32 -41
  90. data/docs/.gitignore +0 -10
  91. data/docs/1108-STREAMING_ARCHITECTURE_ANALYSIS.md +0 -1105
  92. data/docs/1108-STREAMING_SUPPORT_SUMMARY.md +0 -376
  93. data/docs/enrichment.md +0 -128
  94. data/docs/geo-country.md +0 -181
  95. data/docs/ipaddr-encoding-quirk.md +0 -34
  96. data/docs/migrating/v2.0.0-pre1.md +0 -276
  97. data/docs/migrating/v2.0.0-pre2.md +0 -338
  98. data/docs/modern-authentication-authorization-landscape.md +0 -558
  99. data/docs/multi-strategy-authentication-design.md +0 -1401
  100. data/docs/reverse-proxy-network-services.md +0 -371
@@ -0,0 +1,281 @@
1
+ # Structured logging
2
+
3
+ Otto provides explicit logging helpers for request context, microsecond timing,
4
+ and backtrace path reduction. The helpers do not sanitize arbitrary metadata;
5
+ callers remain responsible for excluding secrets and personal data.
6
+
7
+ ## Logging helpers
8
+
9
+ Otto provides several helper methods for consistent logging:
10
+
11
+ ```ruby
12
+ # Request context extraction
13
+ Otto::LoggingHelpers.request_context(env)
14
+ # May include: { method:, path:, ip:, country:, user_agent: }
15
+
16
+ # Timed operation logging
17
+ Otto::LoggingHelpers.log_timed_operation(level, message, env, **metadata) { block }
18
+ ```
19
+
20
+ ## Logging patterns
21
+
22
+ For request-scoped structured logging, use `Otto.structured_log` with
23
+ `LoggingHelpers.request_context(env).merge()`:
24
+
25
+ `request_context(env)` creates a new context hash, and `.merge` creates another
26
+ hash rather than mutating shared state. Treat the request `env` and values inside
27
+ it as request-owned data; the helper does not deep-copy mutable strings.
28
+
29
+ ```ruby
30
+ # Route logging
31
+ Otto.structured_log(:debug, "Route matched",
32
+ Otto::LoggingHelpers.request_context(env).merge(
33
+ type: 'literal',
34
+ handler: route.route_definition.definition,
35
+ auth_strategy: route.route_definition.auth_requirement || 'none'
36
+ )
37
+ )
38
+
39
+ # Authentication logging
40
+ Otto.structured_log(:info, "Auth strategy result",
41
+ Otto::LoggingHelpers.request_context(env).merge(
42
+ strategy: strategy.class.name.split('::').last.downcase.gsub('strategy', ''),
43
+ success: true,
44
+ user_id: result.user_id,
45
+ duration: duration_μs
46
+ )
47
+ )
48
+ ```
49
+
50
+ For operations that need timing, use `log_timed_operation` which wraps `structured_log` with automatic timing:
51
+
52
+ ```ruby
53
+ # Template compilation with timing
54
+ result = Otto::LoggingHelpers.log_timed_operation(:info, "Template compiled", env,
55
+ template_type: 'handlebars',
56
+ cached: false
57
+ ) do
58
+ compile_template(template_path)
59
+ end
60
+
61
+ # Database operation with timing
62
+ Otto::LoggingHelpers.log_timed_operation(:debug, "User lookup", env,
63
+ user_id: user_id,
64
+ cache_hit: false
65
+ ) do
66
+ User.find(user_id)
67
+ end
68
+ ```
69
+
70
+ ## Timing conventions
71
+
72
+ Otto uses **microseconds** for all timing measurements via `Otto::Utils.now_in_μs`:
73
+
74
+ ```ruby
75
+ # Manual timing
76
+ start_time = Otto::Utils.now_in_μs
77
+ result = perform_operation()
78
+ duration = Otto::Utils.now_in_μs - start_time
79
+
80
+ Otto.structured_log(:info, "Operation completed",
81
+ Otto::LoggingHelpers.request_context(env).merge(
82
+ operation: 'user_creation',
83
+ duration: duration # Always in microseconds
84
+ )
85
+ )
86
+
87
+ # Automatic timing with error handling
88
+ Otto::LoggingHelpers.log_timed_operation(:info, "Database query", env,
89
+ table: 'users',
90
+ query_type: 'SELECT'
91
+ ) do
92
+ database.execute(query)
93
+ end
94
+ ```
95
+
96
+ ## Request fields
97
+
98
+ Request-scoped events should normally merge `request_context(env)`, which adds
99
+ available values for:
100
+
101
+ - **method** - HTTP method (`GET`, `POST`, etc.)
102
+ - **path** - request path
103
+ - **ip** - canonical client IP under the configured privacy profile
104
+ - **country** - resolved country when available
105
+ - **user_agent** - current request user agent, truncated to 100 characters
106
+
107
+ Unavailable values are omitted. Non-request events, such as configuration or
108
+ startup logs, should include only relevant event-specific fields. Timed events
109
+ also include **duration** in microseconds.
110
+
111
+ Additional fields such as `user_id`, `handler`, `error`, or `error_class` may be
112
+ useful, but Otto does not redact them. Do not log credentials, session tokens,
113
+ raw request parameters, or exception messages that may contain secrets.
114
+
115
+ ## Error handling in timed operations
116
+
117
+ `log_timed_operation` automatically handles exceptions:
118
+
119
+ ```ruby
120
+ # Successful operation
121
+ Otto::LoggingHelpers.log_timed_operation(:info, "Template compiled", env, template: 'user') do
122
+ compile_template('user')
123
+ end
124
+ # Logs: Template compiled: method=GET path=/users template=user duration=15230
125
+
126
+ # Failed operation (automatic error logging + re-raise)
127
+ Otto::LoggingHelpers.log_timed_operation(:info, "Template compiled", env, template: 'user') do
128
+ raise StandardError, "Template not found"
129
+ end
130
+ # Logs: Template compiled failed: method=GET path=/users template=user duration=1520 error="Template not found" error_class=StandardError
131
+ # Then re-raises the original exception
132
+ ```
133
+
134
+ ## Privacy behavior
135
+
136
+ `request_context` prefers `env['otto.client_ip']`, the canonical value produced
137
+ by `IPPrivacyMiddleware`:
138
+
139
+ - Under the normal privacy profiles, public addresses are masked.
140
+ - Under the `:audit` profile or after `disable_ip_privacy!`, the canonical public
141
+ address is raw and `request_context` logs it unchanged.
142
+ - Private and localhost addresses remain raw under the default `:masked` profile;
143
+ the `:anonymous` profile masks them too.
144
+ - Outside Otto's middleware, when `otto.client_ip` is absent,
145
+ `privacy_safe_ip` masks a public `REMOTE_ADDR` using the default precision and
146
+ returns `[redacted]` for an unparseable address.
147
+ - `request_context` truncates the current `HTTP_USER_AGENT` to 100 characters.
148
+ It is anonymized only if the privacy middleware has already anonymized it.
149
+
150
+ The helper does not make arbitrary metadata privacy-safe. Review every field
151
+ added by the caller.
152
+
153
+ ## Backtrace path reduction
154
+
155
+ Otto reduces filesystem details in recognized Ruby backtrace lines. This is
156
+ defense in depth, not a confidentiality boundary: custom or unrecognized lines
157
+ are returned unchanged.
158
+
159
+ **Risks of raw backtraces:**
160
+ - Expose absolute paths revealing usernames (`/Users/alice/`, `/home/admin/`)
161
+ - Reveal project structure and internal organization
162
+ - Show gem installation paths and Ruby versions
163
+ - Leak system architecture details
164
+
165
+ **Automatic path reduction in `Otto.structured_log`:**
166
+
167
+ `Otto.structured_log` applies the backtrace sanitizer only when the metadata is
168
+ a hash whose exact `:backtrace` key contains an array. Other fields and arrays
169
+ are passed unchanged.
170
+
171
+ **Path-reduction rules:**
172
+
173
+ ```ruby
174
+ # Project files → relative paths only
175
+ "/Users/alice/myapp/app/controllers/users_controller.rb:42:in `create'"
176
+ # ↓ SANITIZED TO:
177
+ "app/controllers/users_controller.rb:42:in `create'"
178
+
179
+ # Bundler gems → [GEM] tag with gem name only
180
+ "/Users/alice/.rbenv/versions/3.4.7/lib/ruby/gems/3.4.0/bundler/gems/otto-34f285412a44/lib/otto/route.rb:142"
181
+ # ↓ SANITIZED TO:
182
+ "[GEM] otto/lib/otto/route.rb:142"
183
+
184
+ # Regular gems → [GEM] tag, version stripped
185
+ "/opt/ruby/gems/3.4.0/gems/rack-3.2.4/lib/rack/builder.rb:310"
186
+ # ↓ SANITIZED TO:
187
+ "[GEM] rack/lib/rack/builder.rb:310"
188
+
189
+ # Ruby stdlib → [RUBY] tag with filename only
190
+ "/Users/alice/.rbenv/versions/3.4.7/lib/ruby/3.4.0/logger.rb:310"
191
+ # ↓ SANITIZED TO:
192
+ "[RUBY] logger.rb:310"
193
+
194
+ # Unknown/external → filename only
195
+ "/some/unknown/path/file.rb:50"
196
+ # ↓ SANITIZED TO:
197
+ "[EXTERNAL] file.rb:50"
198
+ ```
199
+
200
+ **Usage:**
201
+
202
+ Path reduction happens automatically when using `log_backtrace`. This helper
203
+ logs at `:error` and limits the backtrace to its first 20 lines:
204
+
205
+ ```ruby
206
+ # In error handlers (Otto does this automatically)
207
+ Otto::LoggingHelpers.log_backtrace(error,
208
+ Otto::LoggingHelpers.request_context(env).merge(
209
+ error_id: error_id,
210
+ handler: 'UserController#create'
211
+ )
212
+ )
213
+
214
+ # Manual usage if needed
215
+ sanitized = Otto::LoggingHelpers.sanitize_backtrace(error.backtrace)
216
+ ```
217
+
218
+ ## Anti-patterns
219
+
220
+ **❌ Don't create event classes:**
221
+ ```ruby
222
+ # NO - Adds unnecessary abstraction
223
+ event = RouteMatchEvent.new(type: :literal, method: http_verb, path: path)
224
+ Otto.structured_log(event.level, event.message, event.to_h)
225
+ ```
226
+
227
+ **❌ Don't create helper wrappers:**
228
+ ```ruby
229
+ # NO - Hides what's being logged
230
+ Otto::Logging.log_route_match(type: :literal, method: http_verb, path: path, env: env)
231
+ ```
232
+
233
+ **❌ Don't mix timing units:**
234
+ ```ruby
235
+ # NO - Inconsistent units
236
+ duration_ms = (Otto::Utils.now_in_μs - start_time) / 1000 # Converting to milliseconds
237
+ Otto.structured_log(:info, "Operation done", { duration_ms: duration_ms })
238
+ ```
239
+
240
+ **✅ Do use explicit inline logging:**
241
+ ```ruby
242
+ # YES - Clear, simple, explicit
243
+ Otto.structured_log(:debug, "Route matched",
244
+ Otto::LoggingHelpers.request_context(env).merge(
245
+ type: 'literal',
246
+ handler: 'App#index'
247
+ )
248
+ )
249
+
250
+ # YES - Consistent microsecond timing
251
+ Otto.structured_log(:info, "Operation completed",
252
+ Otto::LoggingHelpers.request_context(env).merge(
253
+ operation: 'user_lookup',
254
+ duration: Otto::Utils.now_in_μs - start_time
255
+ )
256
+ )
257
+ ```
258
+
259
+ ## Output behavior
260
+
261
+ `Otto.logger` defaults to Ruby's `Logger`. With that logger, metadata is rendered
262
+ inside a formatted string:
263
+
264
+ ```text
265
+ I, [2025-01-21T14:39:39.462833 #82244] INFO -- : [Otto] Template compiled -- {method: "GET", path: "/users", ip: "192.0.2.0", template_type: "handlebars", cached: false, duration: 68}
266
+ ```
267
+
268
+ For a logger whose level method has a fixed arity greater than one, Otto calls
269
+ that method as `logger.info(message, metadata)`. Other logger APIs use the
270
+ formatted-string fallback. Verify the adapter for a third-party structured
271
+ logger before relying on separately indexed fields; Otto does not ship or test
272
+ a SemanticLogger adapter.
273
+
274
+ ## Rationale
275
+
276
+ - **Simplicity**: Direct logging calls are easier to understand than abstraction layers
277
+ - **Explicitness**: You can see exactly what's being logged at the call site
278
+ - **Flexibility**: Easy to add one-off fields without modifying event classes
279
+ - **Performance**: Disabled debug events are not sent to the logger; guard expensive metadata construction with `if Otto.debug` because method arguments are evaluated first
280
+ - **Consistency**: All timing in microseconds, automatic error handling for timed operations
281
+ - **Maintainability**: One helper file vs multiple event classes/helpers
@@ -0,0 +1,391 @@
1
+ # Testing Otto applications
2
+
3
+ This guide covers the smallest useful testing workflow for an Otto application.
4
+ It favors requests through a real `Otto` instance and links to Otto's maintained
5
+ specs for implementation-level details.
6
+
7
+ ## Prerequisites and commands
8
+
9
+ Otto supports Ruby `>= 3.2, < 4.1`. From an Otto source checkout, enable the
10
+ optional development and test groups before installing dependencies:
11
+
12
+ ```sh
13
+ bundle config set --local with 'development test'
14
+ bundle install
15
+ bundle exec rspec
16
+ ```
17
+
18
+ Run one file while developing:
19
+
20
+ ```sh
21
+ bundle exec rspec spec/otto/security/route_auth_wrapper_spec.rb
22
+ ```
23
+
24
+ The root `Rakefile` also makes `bundle exec rake` run the specs when RSpec is
25
+ installed. Otto's CI runs `bundle exec rspec`; use that command when checking the
26
+ same test entry point locally.
27
+
28
+ Applications consuming Otto need RSpec and `rack-test` in their own test bundle
29
+ if they use the examples below.
30
+
31
+ ## Minimal test setup
32
+
33
+ Use Rack's environment builder rather than constructing partial Rack hashes by
34
+ hand:
35
+
36
+ ```ruby
37
+ # spec/spec_helper.rb
38
+ require 'bundler/setup'
39
+ require 'json'
40
+ require 'rack/mock'
41
+ require 'rspec'
42
+ require 'tempfile'
43
+ require 'otto'
44
+
45
+ module OttoAppSpecHelpers
46
+ def rack_env(path = '/', method: 'GET', headers: {}, params: {})
47
+ env = Rack::MockRequest.env_for(path, method: method, params: params)
48
+ headers.each do |name, value|
49
+ rack_name = name.upcase.tr('-', '_')
50
+ # Rack keeps Content-Type and Content-Length unprefixed; everything else
51
+ # is HTTP_-prefixed. Without this, a JSON request never reaches Otto's
52
+ # JSON parser.
53
+ key = %w[CONTENT_TYPE CONTENT_LENGTH].include?(rack_name) ? rack_name : "HTTP_#{rack_name}"
54
+ env[key] = value
55
+ end
56
+ env
57
+ end
58
+
59
+ def build_otto(route_lines, **options)
60
+ file = Tempfile.new(['routes', '.txt'])
61
+ file.write(route_lines.join("\n") + "\n")
62
+ file.close
63
+ (@route_files ||= []) << file
64
+
65
+ Otto.new(file.path, options)
66
+ end
67
+ end
68
+
69
+ RSpec.configure do |config|
70
+ config.include OttoAppSpecHelpers
71
+
72
+ config.after do
73
+ Array(@route_files).each(&:unlink)
74
+ end
75
+ end
76
+ ```
77
+
78
+ Within Otto itself, use the existing helpers in
79
+ [`spec/support/test_helpers.rb`](../../spec/support/test_helpers.rb) instead of
80
+ copying this application-level helper.
81
+
82
+ ## Test Logic classes as plain Ruby objects
83
+
84
+ Logic classes receive an authentication result, merged parameters, and a locale.
85
+ Test business rules without a Rack request when request parsing is not part of
86
+ the behavior under test:
87
+
88
+ ```ruby
89
+ RSpec.describe Products::Show do
90
+ let(:context) do
91
+ Otto::Security::Authentication::StrategyResult.new(
92
+ session: { 'user_id' => 7 },
93
+ user: { id: 7, roles: ['customer'] },
94
+ auth_method: 'session',
95
+ metadata: {},
96
+ strategy_name: 'session'
97
+ )
98
+ end
99
+
100
+ it 'rejects a product owned by another user' do
101
+ product = instance_double(Product, owner_id: 9)
102
+ allow(Product).to receive(:find).with('42').and_return(product)
103
+
104
+ logic = described_class.new(context, { id: '42' }, 'en')
105
+
106
+ expect { logic.raise_concerns }
107
+ .to raise_error(Otto::Security::AuthorizationError)
108
+ end
109
+ end
110
+ ```
111
+
112
+ Constructing a `StrategyResult` directly is appropriate in an isolated unit
113
+ test. Application request handling should use the result Otto places in
114
+ `env['otto.strategy_result']`.
115
+
116
+ Otto invokes `raise_concerns` before `process`. For `response=json`, the JSON
117
+ handler may call an optional `response_data` formatting hook after `process`.
118
+ Do not implement `response_data` by rerunning mutating business logic.
119
+
120
+ See [`spec/otto/route_handlers_spec.rb`](../../spec/otto/route_handlers_spec.rb)
121
+ for lifecycle, parameter, locale, and JSON-body coverage.
122
+
123
+ ## Test route definitions against the current contract
124
+
125
+ `RouteDefinition` uses symbols for verbs and handler kinds. Multi-value accessors
126
+ return arrays:
127
+
128
+ ```ruby
129
+ RSpec.describe Otto::RouteDefinition do
130
+ it 'parses authentication, roles, and a Logic target' do
131
+ route = described_class.new(
132
+ 'GET',
133
+ '/admin',
134
+ 'Admin::Dashboard auth=session,api_key role=admin,editor response=json'
135
+ )
136
+
137
+ expect(route.verb).to eq(:GET)
138
+ expect(route.kind).to eq(:logic)
139
+ expect(route.klass_name).to eq('Admin::Dashboard')
140
+ expect(route.method_name).to eq('Dashboard')
141
+ expect(route.auth_requirement).to eq('session')
142
+ expect(route.auth_requirements).to eq(%w[session api_key])
143
+ expect(route.role_requirement).to eq('admin,editor')
144
+ expect(route.role_requirements).to eq(%w[admin editor])
145
+ expect(route.response_type).to eq('json')
146
+ end
147
+ end
148
+ ```
149
+
150
+ Handler kinds are `:class`, `:instance`, `:logic`, and `:lambda`. The complete
151
+ parser contract is covered by
152
+ [`spec/otto/route_definition_spec.rb`](../../spec/otto/route_definition_spec.rb).
153
+
154
+ ## Test a strategy directly
155
+
156
+ A strategy unit test should cover successful credentials, missing credentials,
157
+ and rejected credentials. Explicit credentials that were examined and rejected
158
+ should normally produce a terminal failure so a later anonymous strategy cannot
159
+ accept the request.
160
+
161
+ ```ruby
162
+ RSpec.describe Otto::Security::Authentication::Strategies::APIKeyStrategy do
163
+ subject(:strategy) { described_class.new(api_keys: ['test-key']) }
164
+
165
+ it 'authenticates the configured header value without exposing it' do
166
+ result = strategy.authenticate(
167
+ rack_env('/', headers: { 'X-API-Key' => 'test-key' }),
168
+ 'api_key'
169
+ )
170
+
171
+ expect(result).to be_authenticated
172
+ expect(result.metadata[:api_key_fingerprint]).to be_a(String)
173
+ expect(result.to_h.inspect).not_to include('test-key')
174
+ end
175
+
176
+ it 'rejects an invalid explicit key terminally' do
177
+ result = strategy.authenticate(
178
+ rack_env('/', headers: { 'X-API-Key' => 'wrong-key' }),
179
+ 'api_key'
180
+ )
181
+
182
+ expect(result).to be_a(Otto::Security::Authentication::AuthFailure)
183
+ expect(result.failure_reason).to eq('Invalid API key')
184
+ expect(result).to be_terminal
185
+ end
186
+ end
187
+ ```
188
+
189
+ Query/form API keys are ignored unless the strategy is created with
190
+ `param_name:`. Prefer header authentication because URLs are commonly logged.
191
+ The complete static-list and resolver contract is covered by
192
+ [`spec/otto/security/authentication/strategies/api_key_strategy_spec.rb`](../../spec/otto/security/authentication/strategies/api_key_strategy_spec.rb).
193
+
194
+ For a custom strategy, subclass
195
+ `Otto::Security::Authentication::AuthStrategy` and test the result returned by
196
+ `authenticate(env, requirement)`. Use `failure(reason, terminal: true)` only
197
+ when an explicit credential was presented and rejected. See the
198
+ [authentication guide](authentication.md) for the chain semantics.
199
+
200
+ ## Test a complete request through Otto
201
+
202
+ A full-stack test should exercise a real route file, handler factory,
203
+ authentication wrapper, response handler, and Rack response tuple. A registered
204
+ lambda keeps this fixture self-contained:
205
+
206
+ ```ruby
207
+ RSpec.describe 'a protected endpoint' do
208
+ let(:otto) do
209
+ app = build_otto(
210
+ ['GET /protected &protected auth=session response=json'],
211
+ lambda_handlers: {
212
+ protected: ->(_req, _res, _path_params) { { ok: true } },
213
+ }
214
+ )
215
+ app.add_auth_strategy(
216
+ 'session',
217
+ Otto::Security::Authentication::Strategies::SessionStrategy.new
218
+ )
219
+ app
220
+ end
221
+
222
+ it 'returns JSON for an authenticated session' do
223
+ env = rack_env('/protected')
224
+ env['rack.session'] = { 'user_id' => 7 }
225
+
226
+ status, headers, body = otto.call(env)
227
+
228
+ expect(status).to eq(200)
229
+ expect(headers['Content-Type']).to eq('application/json')
230
+ expect(JSON.parse(body.join)).to eq('ok' => true)
231
+ end
232
+
233
+ it 'returns a JSON 401 without a session' do
234
+ status, headers, body = otto.call(
235
+ rack_env('/protected', headers: { 'Accept' => 'application/json' })
236
+ )
237
+
238
+ expect(status).to eq(401)
239
+ expect(headers['content-type']).to eq('application/json')
240
+ expect(JSON.parse(body.join)['error']).to eq('Authentication Required')
241
+ end
242
+ end
243
+ ```
244
+
245
+ For API-key failures, the JSON response uses `"Authentication Required"` in
246
+ `error` and places the specific reason, such as `"Invalid API key"`, in
247
+ `message`.
248
+
249
+ Use a role-aware strategy when testing `role=`. The built-in `SessionStrategy`
250
+ exposes the user ID but does not copy roles from `rack.session`; `role=` checks
251
+ the successful strategy result, not the session independently. See
252
+ [`spec/otto/security/authentication/route_auth_wrapper/role_authorization_spec.rb`](../../spec/otto/security/authentication/route_auth_wrapper/role_authorization_spec.rb)
253
+ for supported user shapes.
254
+
255
+ ## Test JSON request parsing through a handler
256
+
257
+ Do not manually merge JSON and query hashes when the behavior under test is
258
+ Otto's parser. Send an `application/json` body through a real Otto instance or
259
+ `LogicClassHandler`, then assert on the parameters received by the Logic object.
260
+
261
+ Current behavior to cover explicitly:
262
+
263
+ - a JSON object is merged into the Logic parameters;
264
+ - a valid non-object JSON value is ignored;
265
+ - malformed JSON is logged and the Logic class continues with other parameters;
266
+ - non-JSON bodies are not parsed by the Logic handler.
267
+
268
+ The maintained executable examples are in the “JSON request body parsing”
269
+ context of
270
+ [`spec/otto/route_handlers_spec.rb`](../../spec/otto/route_handlers_spec.rb).
271
+
272
+ ## Test configuration freezing explicitly
273
+
274
+ `Otto#call` does not automatically freeze configuration while `RSpec` is
275
+ defined. Freeze the instance directly in tests that assert the production boot
276
+ boundary:
277
+
278
+ ```ruby
279
+ RSpec.describe 'configuration freezing' do
280
+ it 'rejects later strategy registration' do
281
+ otto = build_otto(['GET / &health'], lambda_handlers: {
282
+ health: ->(_req, res, _path_params) { res.body = 'ok' },
283
+ })
284
+ otto.freeze_configuration!
285
+
286
+ expect(otto.frozen_configuration?).to be(true)
287
+ expect {
288
+ otto.add_auth_strategy('other', Object.new)
289
+ }.to raise_error(FrozenError, /Cannot modify frozen configuration/)
290
+ end
291
+ end
292
+ ```
293
+
294
+ Use a fresh Otto instance after an explicit freeze. `Otto.unfreeze_for_testing`
295
+ only resets an internal flag; it cannot unfreeze nested Ruby objects. See the
296
+ [configuration-freezing guide](configuration_freezing.md) and
297
+ [`spec/otto/configuration_freezing_spec.rb`](../../spec/otto/configuration_freezing_spec.rb).
298
+
299
+ ## Test CSRF at the correct layers
300
+
301
+ CSRF has two distinct components:
302
+
303
+ - `Otto::Security::Middleware::CSRFMiddleware` injects generated tokens into
304
+ HTML responses containing a `<head>` element.
305
+ - `Otto::Security::CSRFEnforcementWrapper` validates unsafe requests after route
306
+ matching, where it can honor `csrf=exempt`.
307
+
308
+ Enable CSRF on an `Otto::Security::Config` before testing either component.
309
+ Valid request tokens must come from `config.generate_csrf_token(session_id)` and
310
+ must use the matching session ID; an arbitrary value copied into the session and
311
+ header is not a valid token.
312
+
313
+ Use these maintained specs as executable examples:
314
+
315
+ - [`spec/security_csrf_spec.rb`](../../spec/security_csrf_spec.rb) — response injection.
316
+ - [`spec/otto/security/csrf_enforcement_wrapper_spec.rb`](../../spec/otto/security/csrf_enforcement_wrapper_spec.rb) — safe methods, unsafe methods, valid tokens, and `csrf=exempt`.
317
+ - [`spec/otto/security/csrf_validation_spec.rb`](../../spec/otto/security/csrf_validation_spec.rb) — token and session extraction.
318
+
319
+ ## Test IP privacy with the application configuration
320
+
321
+ The middleware class is
322
+ `Otto::Security::Middleware::IPPrivacyMiddleware`. It receives a security
323
+ configuration object, not `masking_level:` or `skip_private_ips:` keywords:
324
+
325
+ ```ruby
326
+ RSpec.describe Otto::Security::Middleware::IPPrivacyMiddleware do
327
+ it 'masks a public IPv4 address' do
328
+ inner = ->(_env) { [200, {}, ['ok']] }
329
+ security_config = Otto::Security::Config.new
330
+ middleware = described_class.new(inner, security_config)
331
+ env = rack_env('/')
332
+ env['REMOTE_ADDR'] = '203.0.113.45'
333
+
334
+ middleware.call(env)
335
+
336
+ expect(env['REMOTE_ADDR']).to eq('203.0.113.0')
337
+ end
338
+ end
339
+ ```
340
+
341
+ Set `security_config.ip_privacy_config.octet_precision = 2` to mask two IPv4
342
+ octets, or set `mask_private_ips = true` to include private and localhost
343
+ addresses. For application-facing tests, prefer a real Otto instance configured
344
+ through `configure_ip_privacy`.
345
+
346
+ See the [privacy guide](privacy.md) and the maintained privacy specs:
347
+
348
+ - [`spec/otto/ip_privacy_spec.rb`](../../spec/otto/ip_privacy_spec.rb)
349
+ - [`spec/otto/ip_precision_capability_spec.rb`](../../spec/otto/ip_precision_capability_spec.rb)
350
+
351
+ ## Test expected errors through the public request path
352
+
353
+ Register expected business errors before the freeze boundary, trigger them from
354
+ a route, and assert the returned status and response format. This verifies route
355
+ content negotiation and centralized error handling together. Direct calls to
356
+ private methods such as `handle_error` are useful for Otto's own unit tests but
357
+ should not be the main application-level pattern.
358
+
359
+ The maintained coverage is in:
360
+
361
+ - [`spec/otto/error_handler_registration_spec.rb`](../../spec/otto/error_handler_registration_spec.rb)
362
+ - [`spec/otto/error_handling_spec.rb`](../../spec/otto/error_handling_spec.rb)
363
+
364
+ ## Security-header expectations
365
+
366
+ Otto's default route responses include:
367
+
368
+ - `x-content-type-options: nosniff`
369
+ - `x-xss-protection: 1; mode=block`
370
+ - `referrer-policy: strict-origin-when-cross-origin`
371
+
372
+ `x-frame-options` is not a default header. Call
373
+ `otto.enable_frame_protection!` before the first request if a test should expect
374
+ `x-frame-options: SAMEORIGIN`.
375
+
376
+ Test security behavior at the narrowest useful level, but do not require every
377
+ unrelated unit test to repeat header and privacy assertions. Keep those checks in
378
+ focused middleware or request specs.
379
+
380
+ ## Maintained examples by task
381
+
382
+ - Authentication responses and route roles:
383
+ [`spec/otto/security/route_auth_wrapper_spec.rb`](../../spec/otto/security/route_auth_wrapper_spec.rb)
384
+ - Terminal API-key behavior:
385
+ [`spec/otto/security/authentication/api_key_fail_closed_integration_spec.rb`](../../spec/otto/security/authentication/api_key_fail_closed_integration_spec.rb)
386
+ - Registered lambda routes and response types:
387
+ [`spec/otto/lambda_routes_integration_spec.rb`](../../spec/otto/lambda_routes_integration_spec.rb)
388
+ - Response selection:
389
+ [`spec/otto/response_integration_spec.rb`](../../spec/otto/response_integration_spec.rb)
390
+ - Static files after freezing:
391
+ [`spec/otto/static_file_freezing_spec.rb`](../../spec/otto/static_file_freezing_spec.rb)