Testing Otto applications
This guide covers the smallest useful testing workflow for an Otto application.
It favors requests through a real Otto instance and links to Otto's maintained
specs for implementation-level details.
Prerequisites and commands
Otto supports Ruby >= 3.2, < 4.1. From an Otto source checkout, enable the
optional development and test groups before installing dependencies:
bundle config set --local with 'development test'
bundle install
bundle exec rspec
Run one file while developing:
bundle exec rspec spec/otto/security/route_auth_wrapper_spec.rb
The root Rakefile also makes bundle exec rake run the specs when RSpec is
installed. Otto's CI runs bundle exec rspec; use that command when checking the
same test entry point locally.
Applications consuming Otto need RSpec and rack-test in their own test bundle
if they use the examples below.
Minimal test setup
Use Rack's environment builder rather than constructing partial Rack hashes by hand:
# spec/spec_helper.rb
require 'bundler/setup'
require 'json'
require 'rack/mock'
require 'rspec'
require 'tempfile'
require 'otto'
require 'otto/testing'
module OttoAppSpecHelpers
def rack_env(path = '/', method: 'GET', headers: {}, params: {})
env = Rack::MockRequest.env_for(path, method: method, params: params)
headers.each do |name, value|
rack_name = name.upcase.tr('-', '_')
# Rack keeps Content-Type and Content-Length unprefixed; everything else
# is HTTP_-prefixed. Without this, a JSON request never reaches Otto's
# JSON parser.
key = %w[CONTENT_TYPE CONTENT_LENGTH].include?(rack_name) ? rack_name : "HTTP_#{rack_name}"
env[key] = value
end
env
end
def build_otto(route_lines, **)
file = Tempfile.new(['routes', '.txt'])
file.write(route_lines.join("\n") + "\n")
file.close
(@route_files ||= []) << file
Otto.new(file.path, )
end
end
RSpec.configure do |config|
config.include OttoAppSpecHelpers
config.before do
Otto::Testing.reset!
end
config.after do
Array(@route_files).each(&:unlink)
end
end
Within Otto itself, use the existing helpers in
spec/support/test_helpers.rb instead of
copying this application-level helper.
Reset Otto's process-global state between tests
require 'otto' does not load otto/testing. Require it from the test helper;
it does not depend on RSpec.
Otto.new pins Rack::Request.forwarded_priority from trusted_proxy_header
whenever an application configures proxy trust or names a header. Rack keeps one
priority per process, so Otto records the family and raises ArgumentError
when a later application in the same process chooses a different one. A suite
that builds one application with trusted_proxy_header: 'Forwarded' and
another with trusted_proxies: fails or passes depending on test order unless
the record is cleared between tests. Otto::Testing.reset! clears it and
restores Rack's priority to the value Otto saw at load time.
Call it before every test:
# RSpec
RSpec.configure { |config| config.before { Otto::Testing.reset! } }
# Minitest
class Minitest::Test
def before_setup
super
Otto::Testing.reset!
end
end
# Tryouts: at the start of each test case that builds an Otto app
## a depth-mode app reading Forwarded
Otto::Testing.reset!
Otto.new(nil, trusted_proxy_depth: 1, trusted_proxy_header: 'Forwarded')
Tryouts runs a file's setup section once, before all of its test cases, so a reset placed there does not separate the cases from each other.
Otto::Security::Config.reset_rack_forwarding_family_for_testing! does the
same but raises unless RSpec is loaded. It remains for existing callers.
Test Logic classes as plain Ruby objects
Logic classes receive an authentication result, merged parameters, and a locale. Test business rules without a Rack request when request parsing is not part of the behavior under test:
RSpec.describe Products::Show do
let(:context) do
Otto::Security::Authentication::StrategyResult.new(
session: { 'user_id' => 7 },
user: { id: 7, roles: ['customer'] },
auth_method: 'session',
metadata: {},
strategy_name: 'session'
)
end
it 'rejects a product owned by another user' do
product = instance_double(Product, owner_id: 9)
allow(Product).to receive(:find).with('42').and_return(product)
logic = described_class.new(context, { id: '42' }, 'en')
expect { logic.raise_concerns }
.to raise_error(Otto::Security::AuthorizationError)
end
end
Constructing a StrategyResult directly is appropriate in an isolated unit
test. Application request handling should use the result Otto places in
env['otto.strategy_result'].
Otto invokes raise_concerns before process. For response=json, the JSON
handler may call an optional response_data formatting hook after process.
Do not implement response_data by rerunning mutating business logic.
See spec/otto/route_handlers_spec.rb
for lifecycle, parameter, locale, and JSON-body coverage.
Test route definitions against the current contract
RouteDefinition uses symbols for verbs and handler kinds. Multi-value accessors
return arrays:
RSpec.describe Otto::RouteDefinition do
it 'parses authentication, roles, and a Logic target' do
route = described_class.new(
'GET',
'/admin',
'Admin::Dashboard auth=session,api_key role=admin,editor response=json'
)
expect(route.verb).to eq(:GET)
expect(route.kind).to eq(:logic)
expect(route.klass_name).to eq('Admin::Dashboard')
expect(route.method_name).to eq('Dashboard')
expect(route.auth_requirement).to eq('session')
expect(route.auth_requirements).to eq(%w[session api_key])
expect(route.role_requirement).to eq('admin,editor')
expect(route.role_requirements).to eq(%w[admin editor])
expect(route.response_type).to eq('json')
end
end
Handler kinds are :class, :instance, :logic, and :lambda. The complete
parser contract is covered by
spec/otto/route_definition_spec.rb.
Test a strategy directly
A strategy unit test should cover successful credentials, missing credentials, and rejected credentials. Explicit credentials that were examined and rejected should normally produce a terminal failure so a later anonymous strategy cannot accept the request.
RSpec.describe Otto::Security::Authentication::Strategies::APIKeyStrategy do
subject(:strategy) { described_class.new(api_keys: ['test-key']) }
it 'authenticates the configured header value without exposing it' do
result = strategy.authenticate(
rack_env('/', headers: { 'X-API-Key' => 'test-key' }),
'api_key'
)
expect(result).to be_authenticated
expect(result.[:api_key_fingerprint]).to be_a(String)
expect(result.to_h.inspect).not_to include('test-key')
end
it 'rejects an invalid explicit key terminally' do
result = strategy.authenticate(
rack_env('/', headers: { 'X-API-Key' => 'wrong-key' }),
'api_key'
)
expect(result).to be_a(Otto::Security::Authentication::AuthFailure)
expect(result.failure_reason).to eq('Invalid API key')
expect(result).to be_terminal
end
end
Query/form API keys are ignored unless the strategy is created with
param_name:. Prefer header authentication because URLs are commonly logged.
The complete static-list and resolver contract is covered by
spec/otto/security/authentication/strategies/api_key_strategy_spec.rb.
For a custom strategy, subclass
Otto::Security::Authentication::AuthStrategy and test the result returned by
authenticate(env, requirement). Use failure(reason, terminal: true) only
when an explicit credential was presented and rejected. See the
authentication guide for the chain semantics.
Test a complete request through Otto
A full-stack test should exercise a real route file, handler factory, authentication wrapper, response handler, and Rack response tuple. A registered lambda keeps this fixture self-contained:
RSpec.describe 'a protected endpoint' do
let(:otto) do
app = build_otto(
['GET /protected &protected auth=session response=json'],
lambda_handlers: {
protected: ->(_req, _res, _path_params) { { ok: true } },
}
)
app.add_auth_strategy(
'session',
Otto::Security::Authentication::Strategies::SessionStrategy.new
)
app
end
it 'returns JSON for an authenticated session' do
env = rack_env('/protected')
env['rack.session'] = { 'user_id' => 7 }
status, headers, body = otto.call(env)
expect(status).to eq(200)
expect(headers['Content-Type']).to eq('application/json')
expect(JSON.parse(body.join)).to eq('ok' => true)
end
it 'returns a JSON 401 without a session' do
status, headers, body = otto.call(
rack_env('/protected', headers: { 'Accept' => 'application/json' })
)
expect(status).to eq(401)
expect(headers['content-type']).to eq('application/json')
expect(JSON.parse(body.join)['error']).to eq('Authentication Required')
end
end
For API-key failures, the JSON response uses "Authentication Required" in
error and places the specific reason, such as "Invalid API key", in
message.
Use a role-aware strategy when testing role=. The built-in SessionStrategy
exposes the user ID but does not copy roles from rack.session; role= checks
the successful strategy result, not the session independently. See
spec/otto/security/authentication/route_auth_wrapper/role_authorization_spec.rb
for supported user shapes.
Test JSON request parsing through a handler
Do not manually merge JSON and query hashes when the behavior under test is
Otto's parser. Send an application/json body through a real Otto instance or
LogicClassHandler, then assert on the parameters received by the Logic object.
Current behavior to cover explicitly:
- a JSON object is merged into the Logic parameters below path captures, the query string and any form body, which all win over it;
- a JSON body on
GETorHEADis ignored; - a valid non-object JSON value is ignored;
- malformed JSON is logged and the Logic class continues with other parameters;
- non-JSON bodies are not parsed by the Logic handler.
The maintained executable examples are in the “JSON request body parsing”
context of
spec/otto/route_handlers_spec.rb.
Test configuration freezing explicitly
Otto#call does not automatically freeze configuration while RSpec is
defined. Freeze the instance directly in tests that assert the production boot
boundary:
RSpec.describe 'configuration freezing' do
it 'rejects later strategy registration' do
otto = build_otto(['GET / &health'], lambda_handlers: {
health: ->(_req, res, _path_params) { res.body = 'ok' },
})
otto.freeze_configuration!
expect(otto.frozen_configuration?).to be(true)
expect {
otto.add_auth_strategy('other', Object.new)
}.to raise_error(FrozenError, /Cannot modify frozen configuration/)
end
end
Use a fresh Otto instance after an explicit freeze. Otto.unfreeze_for_testing
only resets an internal flag; it cannot unfreeze nested Ruby objects. See the
configuration-freezing guide and
spec/otto/configuration_freezing_spec.rb.
Test CSRF at the correct layers
CSRF has two distinct components:
Otto::Security::Middleware::CSRFMiddlewareinjects generated tokens into HTML responses containing a<head>element.Otto::Security::CSRFEnforcementWrappervalidates unsafe requests after route matching, where it can honorcsrf=exempt.
Enable CSRF on an Otto::Security::Config before testing either component.
Valid request tokens must come from config.generate_csrf_token(session_id) and
must use the matching session ID; an arbitrary value copied into the session and
header is not a valid token.
Use these maintained specs as executable examples:
spec/security_csrf_spec.rb— response injection.spec/otto/security/csrf_enforcement_wrapper_spec.rb— safe methods, unsafe methods, valid tokens, andcsrf=exempt.spec/otto/security/csrf_validation_spec.rb— token and session extraction.
Test IP privacy with the application configuration
The middleware class is
Otto::Security::Middleware::IPPrivacyMiddleware. It receives a security
configuration object, not masking_level: or skip_private_ips: keywords:
RSpec.describe Otto::Security::Middleware::IPPrivacyMiddleware do
it 'masks a public IPv4 address' do
inner = ->(_env) { [200, {}, ['ok']] }
security_config = Otto::Security::Config.new
middleware = described_class.new(inner, security_config)
env = rack_env('/')
env['REMOTE_ADDR'] = '203.0.113.45'
middleware.call(env)
expect(env['REMOTE_ADDR']).to eq('203.0.113.0')
end
end
Set security_config.ip_privacy_config.octet_precision = 2 to mask two IPv4
octets, or set mask_private_ips = true to include private and localhost
addresses. For application-facing tests, prefer a real Otto instance configured
through configure_ip_privacy.
Hand a harness a resolved client IP
Code that runs behind IPPrivacyMiddleware reads env['otto.client_ip'] and,
for access decisions, env['otto.ip_match']. Do not write otto.client_ip by
hand. The middleware treats its presence as a sign that it already ran, so it
never builds otto.ip_match from the full address. Instead it installs a check
that returns false for every range and logs a warning. Allowlist tests then
deny, and when the application uses CIDR proxy trust it also treats the peer as
untrusted.
Otto::Testing.env_for runs the middleware over a Rack env for a request
arriving directly from client_ip, so both keys come from one resolution:
env = Otto::Testing.env_for('/admin', client_ip: '203.0.113.9',
security_config: otto.security_config)
env['otto.client_ip'] # => "203.0.113.0" (masked)
env['otto.ip_match'].call(['203.0.113.9/32']) # => true
security_config: is required. The application's own middleware keeps what
this resolution produced, so pass otto.security_config to get the masking and
proxy trust of the application under test; nil means an unconfigured
middleware (public addresses masked, no proxy trust). client_ip: nil builds a
request with no resolvable client IP: env['otto.client_ip'] is nil and
otto.ip_match denies every range. Other keywords and String env keys go to
Rack::MockRequest.env_for.
env_for models a direct request, so it raises ArgumentError when given
X-Forwarded-For, X-Real-IP, X-Client-IP or Forwarded: under a
configuration that trusts the peer, those would resolve an address other than
client_ip. For a request relayed by a proxy, build the env with REMOTE_ADDR
and the forwarded headers, then resolve it under the application's
configuration:
env = Rack::MockRequest.env_for('/admin', 'REMOTE_ADDR' => '10.0.0.5',
'HTTP_X_FORWARDED_FOR' => '203.0.113.9')
Otto::Testing.resolve_client_ip!(env, otto.security_config)
Resolved under a different configuration, the proxy can become the client and
the application keeps that answer. resolve_client_ip! raises on an env that
already carries otto.client_ip or otto.ip_match for the same reason.
See the privacy guide and the maintained privacy specs:
Test expected errors through the public request path
Register expected business errors before the freeze boundary, trigger them from
a route, and assert the returned status and response format. This verifies route
content negotiation and centralized error handling together. Direct calls to
private methods such as handle_error are useful for Otto's own unit tests but
should not be the main application-level pattern.
The maintained coverage is in:
Security-header expectations
Otto's default route responses include:
x-content-type-options: nosniffx-xss-protection: 1; mode=blockreferrer-policy: strict-origin-when-cross-origin
Configure exactly one W3C policy token when constructing the application; Otto applies it to routed responses, static files, and authentication failures:
otto = Otto.new('routes.txt', referrer_policy: 'no-referrer')
The same setting is available as
otto.security_config.referrer_policy = 'no-referrer' and
otto.security.referrer_policy = 'no-referrer' during boot, before the first
request freezes configuration. An unknown policy token raises ArgumentError
at configuration time. Comma-separated policy fallback lists are not accepted.
Existing applications that pass referrer-policy
through security_headers remain supported and receive the same validation.
A route handler that explicitly sets res['referrer-policy'] keeps its
response-specific value.
x-frame-options is not a default header. Call
otto.enable_frame_protection! before the first request if a test should expect
x-frame-options: SAMEORIGIN.
Test security behavior at the narrowest useful level, but do not require every
unrelated unit test to repeat header and privacy assertions. Keep those checks in
focused middleware or request specs. When testing a custom referrer policy,
exercise a complete response from each response family the application uses
(for example, routed HTML, Rack::Files, and an authentication failure), rather
than asserting only against security_config.security_headers.
Maintained examples by task
- Authentication responses and route roles:
spec/otto/security/route_auth_wrapper_spec.rb - Terminal API-key behavior:
spec/otto/security/authentication/api_key_fail_closed_integration_spec.rb - Registered lambda routes and response types:
spec/otto/lambda_routes_integration_spec.rb - Response selection:
spec/otto/response_integration_spec.rb - Static files after freezing:
spec/otto/static_file_freezing_spec.rb