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.
- checksums.yaml +4 -4
- data/.github/dependabot.yml +5 -0
- data/.github/workflows/ci.yml +11 -8
- data/.github/workflows/claude-code-review.yml +38 -13
- data/.github/workflows/claude.yml +10 -8
- data/.github/workflows/code-smells.yml +5 -5
- data/.github/workflows/release-gem.yml +2 -2
- data/.github/workflows/ruby-lint.yml +3 -3
- data/.github/workflows/yardoc.yml +5 -5
- data/.gitignore +1 -5
- data/.rubocop_todo.yml +8 -5
- data/AGENTS.md +40 -1
- data/CHANGELOG.rst +235 -0
- data/Gemfile +3 -3
- data/Gemfile.lock +11 -15
- data/README.md +75 -37
- data/docs/README.md +166 -0
- data/docs/adr/README.md +16 -0
- data/docs/adr/adr-001-route-authentication-at-handler-boundary.md +38 -0
- data/docs/adr/adr-002-multi-strategy-authentication-and-authorization.md +50 -0
- data/docs/adr/adr-003-caddy-tls-route-based-integration.md +48 -0
- data/docs/adr/adr-004-separate-compatibility-from-security-maintenance.md +58 -0
- data/docs/guides/authentication.md +377 -0
- data/docs/guides/caddy-tls.md +205 -0
- data/docs/guides/configuration_freezing.md +157 -0
- data/docs/guides/enrichment.md +161 -0
- data/docs/guides/forwarded-authority.md +249 -0
- data/docs/guides/geo-country.md +168 -0
- data/docs/guides/ip_privacy.md +39 -0
- data/docs/guides/ipaddr-encoding-quirk.md +56 -0
- data/docs/guides/mcp.md +282 -0
- data/docs/guides/privacy.md +193 -0
- data/docs/guides/routing.md +293 -0
- data/docs/guides/structured_logging.md +281 -0
- data/docs/guides/testing-guide.md +391 -0
- data/docs/maintainers/github-actions.md +41 -0
- data/docs/maintainers/investigations/.gitignore +2 -0
- data/docs/migrating/v2.0.0.md +337 -0
- data/docs/reference/authentication.md +290 -0
- data/docs/reference/route-syntax.md +181 -0
- data/docs/reference/runtime-and-dependency-security.md +86 -0
- data/examples/advanced_routes/README.md +43 -57
- data/examples/authentication_strategies/README.md +37 -196
- data/examples/basic/README.md +24 -39
- data/examples/basic/config.ru +0 -1
- data/examples/caddy_tls_demo/README.md +8 -2
- data/examples/lambda_handlers/README.md +11 -2
- data/examples/mcp_demo/README.md +75 -161
- data/examples/mcp_demo/config.ru +1 -0
- data/examples/security_features/README.md +36 -234
- data/lib/otto/caddy_tls/localhost_guard.rb +22 -24
- data/lib/otto/core/configuration.rb +40 -23
- data/lib/otto/core/error_handler.rb +40 -2
- data/lib/otto/core/file_safety.rb +105 -31
- data/lib/otto/core/middleware_stack.rb +36 -36
- data/lib/otto/core/router.rb +94 -18
- data/lib/otto/core/static_mounts.rb +172 -0
- data/lib/otto/core.rb +1 -0
- data/lib/otto/env_keys.rb +20 -2
- data/lib/otto/mcp/auth/token.rb +10 -4
- data/lib/otto/mcp/core.rb +23 -5
- data/lib/otto/mcp/endpoint.rb +41 -0
- data/lib/otto/mcp/errors.rb +15 -0
- data/lib/otto/mcp/options.rb +292 -0
- data/lib/otto/mcp/protocol.rb +52 -22
- data/lib/otto/mcp/rate_limiting.rb +175 -97
- data/lib/otto/mcp/registry.rb +14 -14
- data/lib/otto/mcp/schema_validation.rb +20 -12
- data/lib/otto/mcp/server.rb +131 -32
- data/lib/otto/optional_dependency.rb +57 -0
- data/lib/otto/privacy/config.rb +10 -8
- data/lib/otto/security/authentication/route_auth_wrapper/role_authorization.rb +22 -5
- data/lib/otto/security/authentication/strategies/api_key_strategy.rb +181 -18
- data/lib/otto/security/authentication/strategies/permission_strategy.rb +1 -0
- data/lib/otto/security/authentication/strategies/role_strategy.rb +1 -0
- data/lib/otto/security/authentication/strategies/session_strategy.rb +1 -0
- data/lib/otto/security/authentication/strategy_result.rb +58 -28
- data/lib/otto/security/config.rb +328 -14
- data/lib/otto/security/configurator.rb +36 -6
- data/lib/otto/security/core.rb +19 -1
- data/lib/otto/security/middleware/ip_privacy_middleware.rb +105 -4
- data/lib/otto/security/middleware/rate_limit_middleware.rb +1 -6
- data/lib/otto/security/rate_limiter.rb +81 -46
- data/lib/otto/static.rb +38 -0
- data/lib/otto/utils.rb +50 -0
- data/lib/otto/version.rb +1 -1
- data/lib/otto.rb +130 -13
- data/otto.gemspec +0 -2
- metadata +32 -41
- data/docs/.gitignore +0 -10
- data/docs/1108-STREAMING_ARCHITECTURE_ANALYSIS.md +0 -1105
- data/docs/1108-STREAMING_SUPPORT_SUMMARY.md +0 -376
- data/docs/enrichment.md +0 -128
- data/docs/geo-country.md +0 -181
- data/docs/ipaddr-encoding-quirk.md +0 -34
- data/docs/migrating/v2.0.0-pre1.md +0 -276
- data/docs/migrating/v2.0.0-pre2.md +0 -338
- data/docs/modern-authentication-authorization-landscape.md +0 -558
- data/docs/multi-strategy-authentication-design.md +0 -1401
- 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)
|