otto 2.9.0 → 2.10.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 +7 -5
- data/AGENTS.md +22 -1
- data/CHANGELOG.rst +203 -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 +146 -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 +181 -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 +36 -22
- data/lib/otto/core/file_safety.rb +89 -31
- data/lib/otto/core/middleware_stack.rb +36 -36
- data/lib/otto/core/router.rb +62 -19
- 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/utils.rb +50 -0
- data/lib/otto/version.rb +1 -1
- data/lib/otto.rb +9 -11
- data/otto.gemspec +0 -2
- metadata +31 -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,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)
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# GitHub Actions
|
|
2
|
+
|
|
3
|
+
## Every `uses:` is pinned to a commit SHA
|
|
4
|
+
|
|
5
|
+
All third-party actions in `.github/workflows/` are pinned to a full 40-character
|
|
6
|
+
commit SHA with a trailing version comment:
|
|
7
|
+
|
|
8
|
+
```yaml
|
|
9
|
+
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
Tags and branches are mutable — an upstream force-push silently changes what runs
|
|
13
|
+
with our secrets. The SHA is the security boundary; the comment is only for humans.
|
|
14
|
+
Do not merge a workflow that references `@v4`, `@main`, or `@beta`.
|
|
15
|
+
|
|
16
|
+
Keep one version per action across the repo so the pins converge.
|
|
17
|
+
|
|
18
|
+
## Resolving a tag to a SHA
|
|
19
|
+
|
|
20
|
+
```sh
|
|
21
|
+
gh api repos/actions/checkout/commits/v7.0.1 --jq .sha
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
This dereferences annotated tags for you. To see what tags exist:
|
|
25
|
+
`gh api repos/OWNER/REPO/releases --jq '.[].tag_name' | head`.
|
|
26
|
+
|
|
27
|
+
## Updating pins
|
|
28
|
+
|
|
29
|
+
Dependabot (`.github/dependabot.yml`) checks weekly and rewrites both the SHA and
|
|
30
|
+
the version comment. The `github-actions` group means all bumps arrive in a single
|
|
31
|
+
PR rather than one per action. Review the upstream changelog before merging;
|
|
32
|
+
a SHA bump is arbitrary code execution in CI.
|
|
33
|
+
|
|
34
|
+
## claude-code-action
|
|
35
|
+
|
|
36
|
+
Pinned to `v1.0.216`. `@beta` is a branch, not a release, so it is not permitted
|
|
37
|
+
here. The v1 line dropped several v0 inputs — `model`, `fallback_model`,
|
|
38
|
+
`direct_prompt` — which now go through `claude_args` (CLI flags) and `prompt`.
|
|
39
|
+
When bumping, diff the new tag's `action.yml` `inputs:` against the `with:` keys
|
|
40
|
+
in `claude.yml` and `claude-code-review.yml`; undeclared inputs are ignored
|
|
41
|
+
silently rather than failing the run.
|