diff --git a/.gitignore b/.gitignore
index f3bc000..0becc6e 100644
--- a/.gitignore
+++ b/.gitignore
@@ -10,6 +10,13 @@ vendor/bundle/
tmp/
*.log
+# demo user uploads
+examples/blog/public/uploads/*
+!examples/blog/public/uploads/.gitkeep
+
+# compiled asset pipeline output (regenerated by Application#enable_assets on boot)
+examples/blog/public/assets/
+
# os / editor / env
.DS_Store
.env
diff --git a/blog_demo.rb b/blog_demo.rb
index b2585c5..7add3c5 100644
--- a/blog_demo.rb
+++ b/blog_demo.rb
@@ -1,27 +1,1990 @@
$LOAD_PATH.unshift(File.expand_path('lib', __dir__))
require 'prism'
require 'net/http'
+require 'zlib'
+require 'stringio'
+require 'csv'
require_relative 'examples/blog/app'
server = Prism::Server.new(Blog, port: 0).start
port = server.port
puts "blog demo server on port #{port}"
puts "database file: #{File.join(__dir__, 'examples/blog/db/development.pstore')}"
+log_path = File.join(__dir__, 'examples/blog/log/development.log')
+log_offset_before_requests = File.exist?(log_path) ? File.size(log_path) : 0
puts '-' * 40
+def cookie_from(response)
+ lines = response.get_fields('set-cookie') || []
+ lines.map { |line| line.split(';').first }.join('; ')
+end
+
+def cookie_value_from(response, name)
+ lines = response.get_fields('set-cookie') || []
+ line = lines.find { |l| l.start_with?("#{name}=") }
+ line&.split(';')&.first
+end
+
+def csrf_token_from(html)
+ html[/name="csrf-token" content="([^"]+)"/, 1]
+end
+
+def multipart_body(boundary, fields:, files: {})
+ parts = fields.map do |name, value|
+ "--#{boundary}\r\nContent-Disposition: form-data; name=\"#{name}\"\r\n\r\n#{value}\r\n"
+ end
+ parts += files.map do |name, (filename, content_type, content)|
+ "--#{boundary}\r\nContent-Disposition: form-data; name=\"#{name}\"; filename=\"#{filename}\"\r\n" \
+ "Content-Type: #{content_type}\r\n\r\n#{content}\r\n"
+ end
+ parts.join + "--#{boundary}--\r\n"
+end
+
+# Signs in as the writer from a brand new, entirely independent cookie jar
+# (its own session cookie, its own CSRF token fetched fresh from the
+# sign-in page) - a second real HTTP client would look exactly like this,
+# unlike reusing the caller's own `cookie`/`token`. Returns the
+# remember_session_token cookie this "browser" got back, proving
+# Prism::Authenticatable's remember_sessions: hands out one independent
+# LoginSession row per device instead of one shared column.
+def sign_in_new_device(http, label, user_agent:)
+ new_session_page = http.get('/session/new', 'User-Agent' => user_agent)
+ device_cookie = cookie_from(new_session_page)
+ device_token = csrf_token_from(new_session_page.body)
+ body = URI.encode_www_form(
+ 'user[email]' => 'writer@example.com', 'user[password]' => 'correct horse battery staple',
+ 'user[remember_me]' => '1', 'authenticity_token' => device_token
+ )
+ signed_in = http.post('/session.json', body, 'Content-Type' => 'application/x-www-form-urlencoded',
+ 'Cookie' => device_cookie, 'User-Agent' => user_agent)
+ raise "expected #{label} to sign in, got #{signed_in.code}" unless signed_in.code == '200'
+ remember_cookie = cookie_value_from(signed_in, 'remember_session_token')
+ raise "expected #{label}'s sign-in to set its own remember_session_token cookie" unless remember_cookie
+ puts " #{label} signed in from its own independent cookie jar -> #{signed_in.code}, own remember-me cookie set"
+ remember_cookie
+end
+
Net::HTTP.start('127.0.0.1', port) do |http|
+ home = http.get('/')
+ cookie = cookie_from(home)
+ token = csrf_token_from(home.body)
+ puts "GET / -> #{home.code} (session cookie set, csrf token captured)"
+
+ css_href = home.body[//, 1]
+ js_src = home.body[/',
+ @context.javascript_include_tag('application.js')
+ end
+
+ def test_image_tag_with_extra_attributes
+ assert_equal '
',
+ @context.image_tag('logo.png', alt: 'Logo')
+ end
+end
diff --git a/test/test_assets_compiler.rb b/test/test_assets_compiler.rb
new file mode 100644
index 0000000..c1806c8
--- /dev/null
+++ b/test/test_assets_compiler.rb
@@ -0,0 +1,115 @@
+require 'minitest/autorun'
+require 'prism'
+require 'tmpdir'
+require 'fileutils'
+
+class AssetsCompilerTest < Minitest::Test
+ def test_compiles_files_into_content_addressed_names
+ with_source do |source, output|
+ write(source, 'application.css', 'body { color: red; }')
+
+ manifest = Prism::Assets::Compiler.new(source: source, output: output).compile!
+
+ fingerprinted = manifest.lookup('application.css')
+ assert_match(/\Aapplication-[0-9a-f]{8}\.css\z/, fingerprinted)
+ assert_equal 'body { color: red; }', File.read(File.join(output, fingerprinted))
+ end
+ end
+
+ def test_same_content_produces_the_same_fingerprint_across_compiles
+ with_source do |source, output|
+ write(source, 'app.js', 'console.log(1);')
+ compiler = Prism::Assets::Compiler.new(source: source, output: output)
+
+ first = compiler.compile!.lookup('app.js')
+ second = compiler.compile!.lookup('app.js')
+
+ assert_equal first, second
+ end
+ end
+
+ def test_changed_content_produces_a_different_fingerprint
+ with_source do |source, output|
+ write(source, 'app.js', 'console.log(1);')
+ compiler = Prism::Assets::Compiler.new(source: source, output: output)
+ first = compiler.compile!.lookup('app.js')
+
+ write(source, 'app.js', 'console.log(2);')
+ second = compiler.compile!.lookup('app.js')
+
+ refute_equal first, second
+ end
+ end
+
+ def test_stale_fingerprinted_files_are_removed_on_recompile
+ with_source do |source, output|
+ write(source, 'app.js', 'console.log(1);')
+ compiler = Prism::Assets::Compiler.new(source: source, output: output)
+ old_fingerprinted = compiler.compile!.lookup('app.js')
+ old_path = File.join(output, old_fingerprinted)
+ assert File.file?(old_path)
+
+ write(source, 'app.js', 'console.log(2);')
+ compiler.compile!
+
+ refute File.file?(old_path), 'stale fingerprinted file should have been cleaned up'
+ end
+ end
+
+ def test_unchanged_files_are_left_alone_when_a_sibling_changes
+ with_source do |source, output|
+ write(source, 'a.css', 'a')
+ write(source, 'b.css', 'b')
+ compiler = Prism::Assets::Compiler.new(source: source, output: output)
+ first = compiler.compile!
+ a_fingerprint = first.lookup('a.css')
+
+ write(source, 'b.css', 'b-changed')
+ second = compiler.compile!
+
+ assert_equal a_fingerprint, second.lookup('a.css')
+ assert File.file?(File.join(output, a_fingerprint))
+ end
+ end
+
+ def test_preserves_subdirectory_structure_as_logical_paths
+ with_source do |source, output|
+ write(source, 'css/nested/theme.css', '.x{}')
+
+ manifest = Prism::Assets::Compiler.new(source: source, output: output).compile!
+
+ fingerprinted = manifest.lookup('css/nested/theme.css')
+ assert_match(%r{\Acss/nested/theme-[0-9a-f]{8}\.css\z}, fingerprinted)
+ assert File.file?(File.join(output, fingerprinted))
+ end
+ end
+
+ def test_writes_manifest_json_to_the_output_directory
+ with_source do |source, output|
+ write(source, 'a.css', 'a')
+ Prism::Assets::Compiler.new(source: source, output: output).compile!
+
+ manifest_path = File.join(output, 'manifest.json')
+ assert File.file?(manifest_path)
+ parsed = JSON.parse(File.read(manifest_path))
+ assert_match(/\Aa-[0-9a-f]{8}\.css\z/, parsed['a.css'])
+ end
+ end
+
+ private
+
+ def with_source
+ Dir.mktmpdir do |root|
+ source = File.join(root, 'source')
+ output = File.join(root, 'output')
+ FileUtils.mkdir_p(source)
+ yield source, output
+ end
+ end
+
+ def write(source, relative, content)
+ path = File.join(source, relative)
+ FileUtils.mkdir_p(File.dirname(path))
+ File.write(path, content)
+ end
+end
diff --git a/test/test_assets_manifest.rb b/test/test_assets_manifest.rb
new file mode 100644
index 0000000..15ea035
--- /dev/null
+++ b/test/test_assets_manifest.rb
@@ -0,0 +1,43 @@
+require 'minitest/autorun'
+require 'prism'
+require 'tmpdir'
+
+class AssetsManifestTest < Minitest::Test
+ def test_lookup_returns_the_fingerprinted_path
+ manifest = Prism::Assets::Manifest.new('application.css' => 'application-deadbeef.css')
+ assert_equal 'application-deadbeef.css', manifest.lookup('application.css')
+ end
+
+ def test_lookup_accepts_symbol_or_string
+ manifest = Prism::Assets::Manifest.new('application.css' => 'application-deadbeef.css')
+ assert_equal 'application-deadbeef.css', manifest.lookup(:'application.css')
+ end
+
+ def test_lookup_raises_on_unknown_asset
+ manifest = Prism::Assets::Manifest.new
+ error = assert_raises(Prism::Assets::Manifest::AssetNotFound) { manifest.lookup('missing.css') }
+ assert_match(/missing\.css/, error.message)
+ end
+
+ def test_key_predicate
+ manifest = Prism::Assets::Manifest.new('a.js' => 'a-11111111.js')
+ assert manifest.key?('a.js')
+ refute manifest.key?('b.js')
+ end
+
+ def test_save_and_load_round_trip
+ Dir.mktmpdir do |dir|
+ path = File.join(dir, 'manifest.json')
+ original = Prism::Assets::Manifest.new('a.css' => 'a-cafef00d.css', 'b.js' => 'b-0ff1ce00.js')
+ original.save(path)
+
+ loaded = Prism::Assets::Manifest.load(path)
+ assert_equal original.to_h, loaded.to_h
+ end
+ end
+
+ def test_load_of_missing_file_returns_an_empty_manifest
+ manifest = Prism::Assets::Manifest.load('/no/such/manifest.json')
+ refute manifest.key?('anything.css')
+ end
+end
diff --git a/test/test_authenticatable.rb b/test/test_authenticatable.rb
new file mode 100644
index 0000000..7a6ff04
--- /dev/null
+++ b/test/test_authenticatable.rb
@@ -0,0 +1,600 @@
+require 'minitest/autorun'
+require 'prism'
+require 'support'
+require 'tmpdir'
+
+class AuthSessionsController < Prism::Controller
+ include Prism::Authenticatable
+ authenticate_with model: :AuthUser
+
+ def create
+ user = AuthUser.find_by(email: params['email'])
+ if user && user.authenticate(params['password'])
+ sign_in(user)
+ halt(200, after_authentication_url)
+ else
+ halt(401, 'invalid credentials')
+ end
+ end
+
+ def destroy
+ sign_out
+ halt(200, 'signed out')
+ end
+end
+
+class AuthDashboardController < Prism::Controller
+ include Prism::Authenticatable
+ authenticate_with model: :AuthUser, unauthenticated_redirect_path: '/sign_in'
+ before_action :require_authentication
+
+ def index
+ halt(200, "hello #{current_user.email}")
+ end
+end
+
+class AuthApiController < Prism::Controller
+ include Prism::Authenticatable
+ authenticate_with model: :AuthUser
+ before_action :require_authentication
+
+ def index
+ halt(200, "hello #{current_user.email}")
+ end
+end
+
+class AuthRememberSessionsController < Prism::Controller
+ include Prism::Authenticatable
+ authenticate_with model: :AuthUser, remember_cookie: :remember_token
+
+ def create
+ user = AuthUser.find_by(email: params['email'])
+ if user && user.authenticate(params['password'])
+ sign_in(user, remember: params['remember'] == '1')
+ halt(200, 'signed in')
+ else
+ halt(401, 'invalid credentials')
+ end
+ end
+
+ def destroy
+ sign_out
+ halt(200, 'signed out')
+ end
+end
+
+class AuthRememberDashboardController < Prism::Controller
+ include Prism::Authenticatable
+ authenticate_with model: :AuthUser, unauthenticated_redirect_path: '/sign_in', remember_cookie: :remember_token
+ before_action :require_authentication
+
+ def index
+ halt(200, "hello #{current_user.email}")
+ end
+end
+
+# `authenticate_with` is declared only on the base class here, the way a
+# real app declares it once on ApplicationController - proving the setting
+# (and its unauthenticated_redirect_path) reaches a subclass that never
+# calls `authenticate_with` itself.
+class AuthBaseController < Prism::Controller
+ include Prism::Authenticatable
+ authenticate_with model: :AuthUser, unauthenticated_redirect_path: '/sign_in'
+end
+
+class AuthInheritedController < AuthBaseController
+ before_action :require_authentication
+
+ def index
+ halt(200, "hello #{current_user.email}")
+ end
+end
+
+class AuthMultiSessionsController < Prism::Controller
+ include Prism::Authenticatable
+ authenticate_with model: :AuthUser, remember_sessions: :AuthLoginSession
+
+ def create
+ user = AuthUser.find_by(email: params['email'])
+ if user && user.authenticate(params['password'])
+ sign_in(user, remember: params['remember'] == '1')
+ halt(200, 'signed in')
+ else
+ halt(401, 'invalid credentials')
+ end
+ end
+
+ def destroy
+ sign_out
+ halt(200, 'signed out')
+ end
+end
+
+class AuthMultiDashboardController < Prism::Controller
+ include Prism::Authenticatable
+ authenticate_with model: :AuthUser, unauthenticated_redirect_path: '/sign_in', remember_sessions: :AuthLoginSession
+ before_action :require_authentication
+
+ def index
+ halt(200, "hello #{current_user.email}")
+ end
+
+ def other_devices
+ halt(200, other_login_sessions.count.to_s)
+ end
+
+ def sign_out_others
+ sign_out_all_other_sessions
+ halt(200, other_login_sessions.count.to_s)
+ end
+
+ def revoke
+ # Deliberately the raw String params['id'] gives, not params['id'].to_i -
+ # a real controller (see examples/blog's LoginSessionsController) has no
+ # reason to convert it itself; revoke_login_session! must do that on its
+ # own or every real HTTP request would silently no-op (see its comment).
+ ok = revoke_login_session!(params['id'])
+ halt(ok ? 200 : 404, ok ? 'revoked' : 'not found')
+ end
+end
+
+class AuthCappedSessionsController < Prism::Controller
+ include Prism::Authenticatable
+ authenticate_with model: :AuthUser, remember_sessions: :AuthLoginSession, max_remember_sessions: 2
+
+ def create
+ user = AuthUser.find_by(email: params['email'])
+ if user && user.authenticate(params['password'])
+ sign_in(user, remember: true)
+ halt(200, 'signed in')
+ else
+ halt(401, 'invalid credentials')
+ end
+ end
+end
+
+class AuthCappedDashboardController < Prism::Controller
+ include Prism::Authenticatable
+ authenticate_with model: :AuthUser, unauthenticated_redirect_path: '/sign_in',
+ remember_sessions: :AuthLoginSession, max_remember_sessions: 2
+ before_action :require_authentication
+
+ def index
+ halt(200, "hello #{current_user.email}")
+ end
+end
+
+class AuthZeroCappedSessionsController < Prism::Controller
+ include Prism::Authenticatable
+ authenticate_with model: :AuthUser, remember_sessions: :AuthLoginSession, max_remember_sessions: 0
+
+ def create
+ user = AuthUser.find_by(email: params['email'])
+ if user && user.authenticate(params['password'])
+ sign_in(user, remember: true)
+ halt(200, 'signed in')
+ else
+ halt(401, 'invalid credentials')
+ end
+ end
+end
+
+class AuthZeroCappedDashboardController < Prism::Controller
+ include Prism::Authenticatable
+ authenticate_with model: :AuthUser, unauthenticated_redirect_path: '/sign_in',
+ remember_sessions: :AuthLoginSession, max_remember_sessions: 0
+ before_action :require_authentication
+
+ def index
+ halt(200, "hello #{current_user.email}")
+ end
+end
+
+class AuthenticatableApp < Prism::Application
+ enable_sessions(secret: 'auth-secret')
+ post '/sign_in', to: 'auth_sessions#create'
+ post '/sign_out', to: 'auth_sessions#destroy'
+ get '/dashboard', to: 'auth_dashboard#index'
+ get '/api/me', to: 'auth_api#index'
+ get '/inherited/me', to: 'auth_inherited#index'
+ post '/remember/sign_in', to: 'auth_remember_sessions#create'
+ post '/remember/sign_out', to: 'auth_remember_sessions#destroy'
+ get '/remember/dashboard', to: 'auth_remember_dashboard#index'
+ post '/multi/sign_in', to: 'auth_multi_sessions#create'
+ post '/multi/sign_out', to: 'auth_multi_sessions#destroy'
+ get '/multi/dashboard', to: 'auth_multi_dashboard#index'
+ get '/multi/other_devices', to: 'auth_multi_dashboard#other_devices'
+ post '/multi/sign_out_others', to: 'auth_multi_dashboard#sign_out_others'
+ post '/multi/revoke/:id', to: 'auth_multi_dashboard#revoke'
+ post '/capped/sign_in', to: 'auth_capped_sessions#create'
+ get '/capped/dashboard', to: 'auth_capped_dashboard#index'
+ post '/zero-capped/sign_in', to: 'auth_zero_capped_sessions#create'
+ get '/zero-capped/dashboard', to: 'auth_zero_capped_dashboard#index'
+end
+
+class AuthenticatableTest < Minitest::Test
+ include TestSupport
+
+ def setup
+ @dir = Dir.mktmpdir('prism-authenticatable')
+ @store = Prism::Orm::Store.new(File.join(@dir, 'test.pstore'))
+ @store.create_table(:auth_users, %w[id email password_digest remember_token])
+ @store.create_table(:auth_login_sessions, %w[id user_id token user_agent ip_address])
+ user_class = Class.new(Prism::Orm::Model) do
+ table_name :auth_users
+ has_secure_password
+ has_secure_token :remember_token
+ end
+ user_class.store = @store
+ Object.const_set(:AuthUser, user_class)
+
+ login_session_class = Class.new(Prism::Orm::Model) do
+ table_name :auth_login_sessions
+ has_secure_token :token
+ end
+ login_session_class.store = @store
+ Object.const_set(:AuthLoginSession, login_session_class)
+
+ AuthUser.create!(email: 'foksiu@example.com', password: 'hunter2')
+ end
+
+ def teardown
+ FileUtils.remove_entry(@dir)
+ Object.send(:remove_const, :AuthUser) if Object.const_defined?(:AuthUser)
+ Object.send(:remove_const, :AuthLoginSession) if Object.const_defined?(:AuthLoginSession)
+ end
+
+ def call(method, path, **opts)
+ AuthenticatableApp.call(env_for(method, path, **opts))
+ end
+
+ def session_cookie_from(headers)
+ cookie_named(headers, 'prism.session')
+ end
+
+ def cookie_named(headers, name)
+ line = headers['Set-Cookie'].split("\n").find { |c| c.start_with?("#{name}=") }
+ line&.split(';')&.first
+ end
+
+ def sign_in_cookie
+ _, headers, = call('POST', '/sign_in',
+ headers: { 'Content-Type' => 'application/x-www-form-urlencoded' },
+ body: 'email=foksiu@example.com&password=hunter2')
+ session_cookie_from(headers)
+ end
+
+ def test_wrong_password_is_rejected
+ status, = call('POST', '/sign_in',
+ headers: { 'Content-Type' => 'application/x-www-form-urlencoded' },
+ body: 'email=foksiu@example.com&password=wrong')
+ assert_equal 401, status
+ end
+
+ def test_dashboard_without_session_redirects_to_configured_path
+ status, headers, = call('GET', '/dashboard')
+ assert_equal 302, status
+ assert_equal '/sign_in', headers['Location']
+ end
+
+ def test_api_without_session_halts_with_401
+ status, = call('GET', '/api/me')
+ assert_equal 401, status
+ end
+
+ def test_sign_in_then_dashboard_sees_current_user
+ cookie = sign_in_cookie
+ status, _, body = call('GET', '/dashboard', cookies: cookie)
+ assert_equal 200, status
+ assert_equal 'hello foksiu@example.com', body.join
+ end
+
+ def test_sign_out_forgets_current_user
+ cookie = sign_in_cookie
+ _, sign_out_headers, = call('POST', '/sign_out', cookies: cookie)
+ fresh_cookie = session_cookie_from(sign_out_headers)
+
+ status, = call('GET', '/dashboard', cookies: fresh_cookie)
+ assert_equal 302, status
+ end
+
+ def test_return_to_url_is_remembered_across_the_redirect
+ _, headers, = call('GET', '/dashboard')
+ refute_nil headers['Location']
+ cookie = session_cookie_from(headers)
+
+ _, sign_in_headers, body = call('POST', '/sign_in',
+ headers: { 'Content-Type' => 'application/x-www-form-urlencoded' },
+ cookies: cookie,
+ body: 'email=foksiu@example.com&password=hunter2')
+ assert_equal '/dashboard', body.join
+ refute_nil sign_in_headers['Set-Cookie']
+ end
+
+ def test_authenticate_with_declared_on_a_base_controller_redirects_a_subclass_without_a_session
+ status, headers, = call('GET', '/inherited/me')
+ assert_equal 302, status
+ assert_equal '/sign_in', headers['Location']
+ end
+
+ def test_authenticate_with_declared_on_a_base_controller_sees_current_user_in_a_subclass
+ cookie = sign_in_cookie
+ status, _, body = call('GET', '/inherited/me', cookies: cookie)
+ assert_equal 200, status
+ assert_equal 'hello foksiu@example.com', body.join
+ end
+
+ def test_signing_in_without_remember_sets_no_remember_cookie
+ _, headers, = call('POST', '/remember/sign_in',
+ headers: { 'Content-Type' => 'application/x-www-form-urlencoded' },
+ body: 'email=foksiu@example.com&password=hunter2')
+ assert_nil cookie_named(headers, 'remember_token')
+ end
+
+ def test_remember_cookie_alone_survives_a_lost_session_cookie
+ _, sign_in_headers, = call('POST', '/remember/sign_in',
+ headers: { 'Content-Type' => 'application/x-www-form-urlencoded' },
+ body: 'email=foksiu@example.com&password=hunter2&remember=1')
+ remember_cookie = cookie_named(sign_in_headers, 'remember_token')
+ refute_nil remember_cookie
+
+ # No session cookie at all here - simulates a brand new browser session
+ # (browser restart) that only still has the persistent cookie.
+ status, dashboard_headers, body = call('GET', '/remember/dashboard', cookies: remember_cookie)
+ assert_equal 200, status
+ assert_equal 'hello foksiu@example.com', body.join
+ # The fast path (session) is repopulated for the next request on this
+ # same browser, so it does not need to re-verify the cookie every time.
+ refute_nil session_cookie_from(dashboard_headers)
+ end
+
+ def test_dashboard_rejects_a_request_with_no_cookies_at_all
+ status, = call('GET', '/remember/dashboard')
+ assert_equal 302, status
+ end
+
+ def test_dashboard_rejects_a_tampered_remember_cookie
+ _, sign_in_headers, = call('POST', '/remember/sign_in',
+ headers: { 'Content-Type' => 'application/x-www-form-urlencoded' },
+ body: 'email=foksiu@example.com&password=hunter2&remember=1')
+ remember_cookie = cookie_named(sign_in_headers, 'remember_token')
+ tampered = remember_cookie.sub('remember_token=', 'remember_token=tampered')
+
+ status, = call('GET', '/remember/dashboard', cookies: tampered)
+ assert_equal 302, status
+ end
+
+ def test_sign_out_rotates_the_token_so_the_old_remember_cookie_stops_working
+ _, sign_in_headers, = call('POST', '/remember/sign_in',
+ headers: { 'Content-Type' => 'application/x-www-form-urlencoded' },
+ body: 'email=foksiu@example.com&password=hunter2&remember=1')
+ remember_cookie = cookie_named(sign_in_headers, 'remember_token')
+ session_cookie = session_cookie_from(sign_in_headers)
+
+ call('POST', '/remember/sign_out', cookies: session_cookie)
+
+ status, = call('GET', '/remember/dashboard', cookies: remember_cookie)
+ assert_equal 302, status
+ end
+
+ def multi_sign_in(remember: true)
+ _, headers, = call('POST', '/multi/sign_in',
+ headers: { 'Content-Type' => 'application/x-www-form-urlencoded' },
+ body: "email=foksiu@example.com&password=hunter2&remember=#{remember ? '1' : '0'}")
+ headers
+ end
+
+ def test_remember_sessions_rejects_both_options_at_once
+ error = assert_raises(ArgumentError) do
+ Class.new(Prism::Controller) do
+ include Prism::Authenticatable
+ authenticate_with model: :AuthUser, remember_cookie: :remember_token, remember_sessions: :AuthLoginSession
+ end
+ end
+ assert_match(/either remember_cookie: or remember_sessions:/, error.message)
+ end
+
+ def test_remember_sessions_creates_its_own_row_per_sign_in
+ assert_equal 0, AuthLoginSession.count
+ multi_sign_in
+ assert_equal 1, AuthLoginSession.count
+ assert_equal AuthUser.find_by(email: 'foksiu@example.com').id, AuthLoginSession.first.user_id
+ end
+
+ def test_remember_sessions_captures_user_agent_and_ip_when_the_model_has_those_columns
+ env = env_for('POST', '/multi/sign_in',
+ headers: { 'Content-Type' => 'application/x-www-form-urlencoded', 'User-Agent' => 'DeviceScanner/9.0' },
+ body: 'email=foksiu@example.com&password=hunter2&remember=1')
+ env['REMOTE_ADDR'] = '198.51.100.42'
+ AuthenticatableApp.call(env)
+
+ login_session = AuthLoginSession.first
+ assert_equal 'DeviceScanner/9.0', login_session.user_agent
+ assert_equal '198.51.100.42', login_session.ip_address
+ end
+
+ def test_remember_session_cookie_alone_survives_a_lost_session_cookie
+ headers = multi_sign_in
+ remember_cookie = cookie_named(headers, 'remember_session_token')
+ refute_nil remember_cookie
+
+ status, dashboard_headers, body = call('GET', '/multi/dashboard', cookies: remember_cookie)
+ assert_equal 200, status
+ assert_equal 'hello foksiu@example.com', body.join
+ refute_nil session_cookie_from(dashboard_headers)
+ end
+
+ def test_two_remembered_browsers_are_independent_login_sessions
+ browser_a = cookie_named(multi_sign_in, 'remember_session_token')
+ browser_b = cookie_named(multi_sign_in, 'remember_session_token')
+ refute_equal browser_a, browser_b
+ assert_equal 2, AuthLoginSession.count
+
+ status_a, = call('GET', '/multi/dashboard', cookies: browser_a)
+ status_b, = call('GET', '/multi/dashboard', cookies: browser_b)
+ assert_equal 200, status_a
+ assert_equal 200, status_b
+ end
+
+ def test_signing_out_of_one_browser_leaves_the_other_remembered_browser_signed_in
+ headers_a = multi_sign_in
+ browser_a_remember = cookie_named(headers_a, 'remember_session_token')
+ browser_a_session = session_cookie_from(headers_a)
+ browser_b_remember = cookie_named(multi_sign_in, 'remember_session_token')
+
+ call('POST', '/multi/sign_out', cookies: browser_a_session)
+
+ status_a, = call('GET', '/multi/dashboard', cookies: browser_a_remember)
+ assert_equal 302, status_a
+
+ status_b, = call('GET', '/multi/dashboard', cookies: browser_b_remember)
+ assert_equal 200, status_b
+ end
+
+ def test_other_login_sessions_excludes_the_current_browser
+ browser_a_remember = cookie_named(multi_sign_in, 'remember_session_token')
+ cookie_named(multi_sign_in, 'remember_session_token')
+ cookie_named(multi_sign_in, 'remember_session_token')
+
+ status, _, body = call('GET', '/multi/other_devices', cookies: browser_a_remember)
+ assert_equal 200, status
+ assert_equal '2', body.join
+ end
+
+ def test_sign_out_all_other_sessions_leaves_only_the_current_browser
+ browser_a_remember = cookie_named(multi_sign_in, 'remember_session_token')
+ browser_b_remember = cookie_named(multi_sign_in, 'remember_session_token')
+
+ status, _, body = call('POST', '/multi/sign_out_others', cookies: browser_a_remember)
+ assert_equal 200, status
+ assert_equal '0', body.join
+ assert_equal 1, AuthLoginSession.count
+
+ status_b, = call('GET', '/multi/dashboard', cookies: browser_b_remember)
+ assert_equal 302, status_b
+ status_a, = call('GET', '/multi/dashboard', cookies: browser_a_remember)
+ assert_equal 200, status_a
+ end
+
+ def test_revoke_login_session_by_id_signs_out_only_that_device
+ browser_a_remember = cookie_named(multi_sign_in, 'remember_session_token')
+ browser_b_remember = cookie_named(multi_sign_in, 'remember_session_token')
+ browser_b_id = AuthLoginSession.order(:id, :desc).first.id
+
+ status, _, body = call('POST', "/multi/revoke/#{browser_b_id}", cookies: browser_a_remember)
+ assert_equal 200, status
+ assert_equal 'revoked', body.join
+
+ status_b, = call('GET', '/multi/dashboard', cookies: browser_b_remember)
+ assert_equal 302, status_b
+ status_a, = call('GET', '/multi/dashboard', cookies: browser_a_remember)
+ assert_equal 200, status_a
+ end
+
+ def test_revoke_login_session_refuses_a_session_belonging_to_another_user
+ AuthUser.create!(email: 'other@example.com', password: 'hunter2')
+ other_login = AuthLoginSession.create!(user_id: AuthUser.find_by(email: 'other@example.com').id)
+ browser_a_remember = cookie_named(multi_sign_in, 'remember_session_token')
+
+ status, _, body = call('POST', "/multi/revoke/#{other_login.id}", cookies: browser_a_remember)
+ assert_equal 404, status
+ assert_equal 'not found', body.join
+ refute_nil AuthLoginSession.find_by(id: other_login.id)
+ end
+
+ def test_revoking_the_current_login_session_signs_that_browser_out_too
+ browser_a_remember = cookie_named(multi_sign_in, 'remember_session_token')
+ current_id = AuthLoginSession.first.id
+
+ call('POST', "/multi/revoke/#{current_id}", cookies: browser_a_remember)
+
+ status, = call('GET', '/multi/dashboard', cookies: browser_a_remember)
+ assert_equal 302, status
+ end
+
+ def test_signing_in_without_remember_does_not_set_a_remember_session_cookie
+ headers = multi_sign_in(remember: false)
+ assert_nil cookie_named(headers, 'remember_session_token')
+ assert_equal 0, AuthLoginSession.count
+ end
+
+ def capped_sign_in
+ _, headers, = call('POST', '/capped/sign_in',
+ headers: { 'Content-Type' => 'application/x-www-form-urlencoded' },
+ body: 'email=foksiu@example.com&password=hunter2')
+ headers
+ end
+
+ def test_max_remember_sessions_requires_remember_sessions
+ error = assert_raises(ArgumentError) do
+ Class.new(Prism::Controller) do
+ include Prism::Authenticatable
+ authenticate_with model: :AuthUser, max_remember_sessions: 2
+ end
+ end
+ assert_match(/max_remember_sessions: requires remember_sessions:/, error.message)
+ end
+
+ def test_under_the_limit_keeps_every_browser
+ browser_a = cookie_named(capped_sign_in, 'remember_session_token')
+ browser_b = cookie_named(capped_sign_in, 'remember_session_token')
+ assert_equal 2, AuthLoginSession.count
+
+ status_a, = call('GET', '/capped/dashboard', cookies: browser_a)
+ status_b, = call('GET', '/capped/dashboard', cookies: browser_b)
+ assert_equal 200, status_a
+ assert_equal 200, status_b
+ end
+
+ def test_exceeding_the_limit_signs_out_the_oldest_browser_first
+ browser_a = cookie_named(capped_sign_in, 'remember_session_token')
+ browser_b = cookie_named(capped_sign_in, 'remember_session_token')
+ browser_c = cookie_named(capped_sign_in, 'remember_session_token')
+
+ assert_equal 2, AuthLoginSession.count
+
+ status_a, = call('GET', '/capped/dashboard', cookies: browser_a)
+ assert_equal 302, status_a, 'the oldest (first) browser should have been pruned'
+
+ status_b, = call('GET', '/capped/dashboard', cookies: browser_b)
+ status_c, = call('GET', '/capped/dashboard', cookies: browser_c)
+ assert_equal 200, status_b
+ assert_equal 200, status_c
+ end
+
+ def test_repeatedly_exceeding_the_limit_keeps_it_pinned_at_the_max
+ 5.times { capped_sign_in }
+ assert_equal 2, AuthLoginSession.count
+ end
+
+ def zero_capped_sign_in
+ call('POST', '/zero-capped/sign_in',
+ headers: { 'Content-Type' => 'application/x-www-form-urlencoded' },
+ body: 'email=foksiu@example.com&password=hunter2')
+ end
+
+ # `max_remember_sessions: 0` is a deliberately-set "never actually
+ # remembered" limit (see the comment on prune_oldest_login_sessions) -
+ # unlike every positive limit above, it must prune the row `sign_in`
+ # itself just created, not just older ones.
+ def test_max_remember_sessions_of_zero_prunes_the_row_it_just_created
+ status, headers, = zero_capped_sign_in
+ assert_equal 200, status
+ assert_equal 0, AuthLoginSession.count,
+ 'a limit of 0 must prune the just-created row too, not only rows older than it'
+
+ session_cookie = session_cookie_from(headers)
+ refute_nil session_cookie, 'sign_in always sets the plain session cookie regardless of the remember-row prune'
+ status, = call('GET', '/zero-capped/dashboard', cookies: session_cookie)
+ assert_equal 200, status, 'the session cookie set by this very sign_in must still authenticate the request'
+ end
+
+ def test_max_remember_sessions_of_zero_makes_the_remember_cookie_dead_on_arrival
+ _, headers, = zero_capped_sign_in
+ remember_cookie = cookie_named(headers, 'remember_session_token')
+ refute_nil remember_cookie, 'sign_in(remember: true) sets the cookie unconditionally, before the prune runs'
+
+ status, = call('GET', '/zero-capped/dashboard', cookies: remember_cookie)
+ assert_equal 302, status, 'the remember cookie token matches no row once the limit-0 prune destroyed it'
+ end
+end
diff --git a/test/test_authorizable.rb b/test/test_authorizable.rb
new file mode 100644
index 0000000..2ebdf3d
--- /dev/null
+++ b/test/test_authorizable.rb
@@ -0,0 +1,113 @@
+require 'minitest/autorun'
+require 'prism'
+require 'support'
+require 'tmpdir'
+
+class AuthzArticlePolicy < Prism::Policy
+ def destroy?
+ user && record.user_id == user.id
+ end
+end
+
+class AuthzSessionsController < Prism::Controller
+ include Prism::Authenticatable
+ authenticate_with model: :AuthzUser
+
+ def create
+ user = AuthzUser.find_by(email: params['email'])
+ if user
+ sign_in(user)
+ halt(200, 'signed in')
+ else
+ halt(401, 'invalid credentials')
+ end
+ end
+end
+
+class ArticlesController < Prism::Controller
+ include Prism::Authenticatable
+ include Prism::Authorizable
+ authenticate_with model: :AuthzUser
+ before_action :require_authentication, only: %i[destroy]
+ rescue_from Prism::Policy::NotAuthorizedError, with: :forbidden
+
+ def destroy
+ article = AuthzArticle.find(params['id'])
+ authorize(article)
+ article.destroy
+ halt(200, 'deleted')
+ rescue Prism::Orm::RecordNotFound
+ halt(404, 'not found')
+ end
+
+ private
+
+ def forbidden(_exception)
+ halt(403, 'forbidden')
+ end
+end
+
+class AuthorizableApp < Prism::Application
+ enable_sessions(secret: 'authz-secret')
+ post '/sign_in', to: 'authz_sessions#create'
+ delete '/articles/:id', to: 'articles#destroy'
+end
+
+class AuthorizableTest < Minitest::Test
+ include TestSupport
+
+ def setup
+ @dir = Dir.mktmpdir('prism-authorizable')
+ @store = Prism::Orm::Store.new(File.join(@dir, 'test.pstore'))
+ @store.create_table(:authz_users, %w[id email])
+ @store.create_table(:authz_articles, %w[id title user_id])
+
+ user_class = Class.new(Prism::Orm::Model) { table_name :authz_users }
+ user_class.store = @store
+ Object.const_set(:AuthzUser, user_class)
+
+ article_class = Class.new(Prism::Orm::Model) { table_name :authz_articles }
+ article_class.store = @store
+ Object.const_set(:AuthzArticle, article_class)
+
+ @owner = AuthzUser.create!(email: 'owner@example.com')
+ @stranger = AuthzUser.create!(email: 'stranger@example.com')
+ @article = AuthzArticle.create!(title: 'mine', user_id: @owner.id)
+ end
+
+ def teardown
+ FileUtils.remove_entry(@dir)
+ Object.send(:remove_const, :AuthzUser) if Object.const_defined?(:AuthzUser)
+ Object.send(:remove_const, :AuthzArticle) if Object.const_defined?(:AuthzArticle)
+ end
+
+ def call(method, path, **opts)
+ AuthorizableApp.call(env_for(method, path, **opts))
+ end
+
+ def cookie_for(email)
+ _, headers, = call('POST', '/sign_in',
+ headers: { 'Content-Type' => 'application/x-www-form-urlencoded' },
+ body: URI.encode_www_form('email' => email))
+ lines = headers['Set-Cookie'].split("\n")
+ lines.find { |line| line.start_with?('prism.session=') }.split(';').first
+ end
+
+ def test_owner_may_destroy_their_own_article
+ status, _, body = call('DELETE', "/articles/#{@article.id}", cookies: cookie_for('owner@example.com'))
+ assert_equal 200, status
+ assert_equal 'deleted', body.join
+ end
+
+ def test_stranger_gets_403_instead_of_destroying_someone_elses_article
+ status, _, body = call('DELETE', "/articles/#{@article.id}", cookies: cookie_for('stranger@example.com'))
+ assert_equal 403, status
+ assert_equal 'forbidden', body.join
+ assert AuthzArticle.find_by(id: @article.id), 'the article must survive a denied authorization'
+ end
+
+ def test_signed_out_request_never_reaches_the_policy_at_all
+ status, = call('DELETE', "/articles/#{@article.id}")
+ assert_equal 401, status
+ end
+end
diff --git a/test/test_cache_helpers.rb b/test/test_cache_helpers.rb
new file mode 100644
index 0000000..437bf31
--- /dev/null
+++ b/test/test_cache_helpers.rb
@@ -0,0 +1,67 @@
+require 'minitest/autorun'
+require 'prism'
+require 'tmpdir'
+require 'fileutils'
+
+class CacheHelpersTest < Minitest::Test
+ def setup
+ Prism::Cache.store.clear
+ @dir = Dir.mktmpdir('prism-cache-helpers')
+ FileUtils.mkdir_p(File.join(@dir, 'pages'))
+ @view = Prism::View.new(@dir)
+ end
+
+ def teardown
+ FileUtils.remove_entry(@dir)
+ Prism::Cache.store.clear
+ end
+
+ def write(relative, source)
+ File.write(File.join(@dir, relative), source)
+ end
+
+ def test_cache_block_renders_and_stores_the_fragment
+ write('pages/show.erb', '<% cache("greeting") do %><%= greeting %><% end %>')
+ result = @view.render('pages/show', locals: { greeting: 'hi' })
+ assert_equal 'hi', result
+ assert_equal 'hi', Prism::Cache.store.read('greeting')
+ end
+
+ def test_cache_block_is_not_reevaluated_on_a_hit
+ calls = []
+ write('pages/show.erb', '<% cache("counter") do %><%= track! %><% end %>')
+ @view = Prism::View.new(@dir, helpers: CounterHelper.new(calls))
+
+ first = @view.render('pages/show')
+ second = @view.render('pages/show')
+
+ assert_equal first, second
+ assert_equal 1, calls.size
+ end
+
+ def test_cache_key_can_be_an_array_mixing_scope_and_a_model
+ model = Struct.new(:cache_key).new('posts/7-42')
+ write('pages/show.erb', '<% cache([:sidebar, model]) do %>rendered<% end %>')
+ @view.render('pages/show', locals: { model: model })
+ assert @view.expand_cache_key([:sidebar, model]) == 'sidebar/posts/7-42'
+ assert_equal 'rendered', Prism::Cache.store.read('sidebar/posts/7-42')
+ end
+
+ def test_expires_in_evicts_the_entry_after_the_given_time
+ write('pages/show.erb', '<% cache("temp", expires_in: 0) do %>value<% end %>')
+ @view.render('pages/show')
+ sleep 0.01
+ assert_nil Prism::Cache.store.read('temp')
+ end
+
+ class CounterHelper
+ def initialize(calls)
+ @calls = calls
+ end
+
+ def track!
+ @calls << true
+ 'x'
+ end
+ end
+end
diff --git a/test/test_cache_store.rb b/test/test_cache_store.rb
new file mode 100644
index 0000000..11884a3
--- /dev/null
+++ b/test/test_cache_store.rb
@@ -0,0 +1,116 @@
+require 'minitest/autorun'
+require 'prism'
+
+class CacheStoreTest < Minitest::Test
+ def setup
+ @store = Prism::Cache::MemoryStore.new
+ end
+
+ def test_write_then_read_round_trips
+ @store.write('key', 'value')
+ assert_equal 'value', @store.read('key')
+ end
+
+ def test_read_of_missing_key_is_nil
+ assert_nil @store.read('missing')
+ end
+
+ def test_keys_are_coerced_to_string
+ @store.write(:sym_key, 'value')
+ assert_equal 'value', @store.read('sym_key')
+ end
+
+ def test_fetch_computes_and_stores_on_miss
+ calls = 0
+ 3.times { @store.fetch('k') { calls += 1; 'computed' } }
+ assert_equal 1, calls
+ end
+
+ def test_fetch_returns_existing_value_without_yielding_on_hit
+ @store.write('k', 'preset')
+ result = @store.fetch('k') { raise 'must not be called' }
+ assert_equal 'preset', result
+ end
+
+ def test_delete_removes_the_entry
+ @store.write('k', 'v')
+ assert @store.delete('k')
+ assert_nil @store.read('k')
+ end
+
+ def test_delete_of_missing_key_returns_falsey
+ refute @store.delete('missing')
+ end
+
+ def test_exist_reflects_presence
+ refute @store.exist?('k')
+ @store.write('k', 'v')
+ assert @store.exist?('k')
+ end
+
+ def test_clear_empties_the_store
+ @store.write('a', 1)
+ @store.write('b', 2)
+ @store.clear
+ assert_equal 0, @store.size
+ end
+
+ def test_expires_in_makes_the_entry_unreadable_afterwards
+ @store.write('k', 'v', expires_in: 0)
+ sleep 0.01
+ assert_nil @store.read('k')
+ end
+
+ def test_delete_matched_removes_keys_matching_a_regexp
+ @store.write('posts/1', 'a')
+ @store.write('posts/2', 'b')
+ @store.write('users/1', 'c')
+ @store.delete_matched(/\Aposts\//)
+ assert_nil @store.read('posts/1')
+ assert_nil @store.read('posts/2')
+ assert_equal 'c', @store.read('users/1')
+ end
+
+ def test_default_store_is_a_memory_store_singleton
+ assert_same Prism::Cache.store, Prism::Cache.store
+ assert_instance_of Prism::Cache::MemoryStore, Prism::Cache.store
+ end
+
+ def test_unbounded_store_never_evicts
+ store = Prism::Cache::MemoryStore.new
+ 50.times { |i| store.write("k#{i}", i) }
+ assert_equal 50, store.size
+ end
+
+ def test_max_size_evicts_the_least_recently_used_entry_on_write
+ store = Prism::Cache::MemoryStore.new(max_size: 2)
+ store.write('a', 1)
+ store.write('b', 2)
+ store.write('c', 3)
+ assert_equal 2, store.size
+ assert_nil store.read('a')
+ assert_equal 2, store.read('b')
+ assert_equal 3, store.read('c')
+ end
+
+ def test_reading_a_key_protects_it_from_eviction
+ store = Prism::Cache::MemoryStore.new(max_size: 2)
+ store.write('a', 1)
+ store.write('b', 2)
+ store.read('a') # bump 'a' to most-recently-used, 'b' is now the LRU one
+ store.write('c', 3)
+ assert_equal 1, store.read('a')
+ assert_nil store.read('b')
+ assert_equal 3, store.read('c')
+ end
+
+ def test_rewriting_an_existing_key_does_not_grow_the_store
+ store = Prism::Cache::MemoryStore.new(max_size: 2)
+ store.write('a', 1)
+ store.write('b', 2)
+ store.write('a', 'updated')
+ assert_equal 2, store.size
+ assert_equal 'updated', store.read('a')
+ assert_equal 2, store.read('b')
+ end
+end
diff --git a/test/test_cli_assets_precompile.rb b/test/test_cli_assets_precompile.rb
new file mode 100644
index 0000000..2b2be2e
--- /dev/null
+++ b/test/test_cli_assets_precompile.rb
@@ -0,0 +1,58 @@
+require 'minitest/autorun'
+require 'prism'
+require 'tmpdir'
+require 'fileutils'
+require 'stringio'
+
+class CliAssetsPrecompileTest < Minitest::Test
+ def setup
+ @dir = Dir.mktmpdir('prism-cli-assets')
+ @cli = Prism::Cli.new(@dir)
+ @source = File.join(@dir, 'app', 'assets')
+ @output = File.join(@dir, 'public', 'assets')
+ FileUtils.mkdir_p(File.join(@source, 'stylesheets'))
+ File.write(File.join(@source, 'stylesheets', 'application.css'), 'body { color: red; }')
+ end
+
+ def teardown
+ FileUtils.remove_entry(@dir)
+ end
+
+ def test_precompiles_without_booting_an_application
+ manifest = @cli.assets_precompile(['--source=app/assets', '--output=public/assets'], out: StringIO.new)
+ logical = manifest.to_h.keys
+ assert_equal ['stylesheets/application.css'], logical
+ fingerprinted = manifest.lookup('stylesheets/application.css')
+ assert_match(/\Astylesheets\/application-[0-9a-f]{8}\.css\z/, fingerprinted)
+ assert File.exist?(File.join(@output, fingerprinted))
+ assert File.exist?(File.join(@output, 'manifest.json'))
+ end
+
+ def test_prints_a_summary_to_the_given_output
+ out = StringIO.new
+ @cli.assets_precompile(['--source=app/assets', '--output=public/assets'], out: out)
+ assert_match(/compiled 1 file\(s\) into/, out.string)
+ end
+
+ def test_recompiling_unchanged_content_reproduces_the_same_fingerprint_and_removes_nothing_stale
+ first = @cli.assets_precompile(['--source=app/assets', '--output=public/assets'], out: StringIO.new)
+ second = @cli.assets_precompile(['--source=app/assets', '--output=public/assets'], out: StringIO.new)
+ assert_equal first.to_h, second.to_h
+ end
+
+ def test_raises_usage_error_without_required_options
+ assert_raises(Prism::Cli::UsageError) { @cli.assets_precompile([]) }
+ assert_raises(Prism::Cli::UsageError) { @cli.assets_precompile(['--source=app/assets']) }
+ end
+
+ def test_run_dispatches_the_assets_colon_precompile_command
+ original_stdout = $stdout
+ captured = StringIO.new
+ $stdout = captured
+ @cli.run(['assets:precompile', '--source=app/assets', '--output=public/assets'])
+ ensure
+ $stdout = original_stdout
+ assert_match(/compiled 1 file\(s\)/, captured.string)
+ assert File.exist?(File.join(@output, 'manifest.json'))
+ end
+end
diff --git a/test/test_cli_console.rb b/test/test_cli_console.rb
new file mode 100644
index 0000000..a692d60
--- /dev/null
+++ b/test/test_cli_console.rb
@@ -0,0 +1,98 @@
+require 'minitest/autorun'
+require 'prism'
+require 'stringio'
+require 'tmpdir'
+require 'fileutils'
+
+class CliConsoleTest < Minitest::Test
+ def setup
+ @workdir = Dir.mktmpdir('prism-cli-console')
+ @cli = Prism::Cli.new(@workdir)
+ end
+
+ def teardown
+ FileUtils.remove_entry(@workdir)
+ end
+
+ def run_console(script, extra_args = [])
+ input = StringIO.new(script)
+ out = StringIO.new
+ @cli.console(extra_args, input: input, out: out)
+ out.string
+ end
+
+ def test_evaluates_a_simple_expression
+ output = run_console("1 + 1\n")
+ assert_includes output, '=> 2'
+ end
+
+ def test_prints_prompt_before_each_line_and_at_the_end
+ output = run_console("1\n2\n")
+ assert_equal 3, output.scan('prism> ').size
+ end
+
+ def test_local_variables_persist_across_lines
+ output = run_console("x = 5\nx * 2\n")
+ assert_includes output, '=> 5'
+ assert_includes output, '=> 10'
+ end
+
+ def test_custom_prompt_option
+ output = run_console("1\n", ['--prompt=blog> '])
+ assert_includes output, 'blog> '
+ refute_includes output, 'prism> '
+ end
+
+ def test_syntax_error_is_reported_without_aborting_the_session
+ output = run_console("def broken(\n1 + 1\n")
+ assert_includes output, 'Error:'
+ assert_includes output, '=> 2'
+ end
+
+ def test_runtime_error_is_reported_without_aborting_the_session
+ output = run_console("raise ArgumentError, 'boom'\n1 + 1\n")
+ assert_includes output, 'Error: ArgumentError: boom'
+ assert_includes output, '=> 2'
+ end
+
+ def test_exit_stops_the_session_before_reading_further_lines
+ output = run_console("exit\n1 + 1\n")
+ refute_includes output, '=> 2'
+ end
+
+ def test_quit_stops_the_session
+ output = run_console("quit\n")
+ refute_includes output, 'Error'
+ end
+
+ def test_blank_line_is_skipped_without_error
+ output = run_console("\n1 + 1\n")
+ refute_includes output, 'Error'
+ assert_includes output, '=> 2'
+ end
+
+ def test_unknown_option_raises_usage_error
+ assert_raises(Prism::Cli::UsageError) { @cli.console(['--bogus'], input: StringIO.new, out: StringIO.new) }
+ end
+
+ def test_require_option_loads_a_file_before_the_session_starts
+ fixture = File.join(@workdir, 'greeting.rb')
+ File.write(fixture, "CONSOLE_TEST_GREETING = 'hello from fixture'\n")
+
+ output = run_console("CONSOLE_TEST_GREETING\n", ['--require=greeting.rb'])
+ assert_includes output, '=> "hello from fixture"'
+ ensure
+ Object.send(:remove_const, :CONSOLE_TEST_GREETING) if Object.const_defined?(:CONSOLE_TEST_GREETING)
+ end
+
+ def test_run_dispatches_console_command
+ output = StringIO.new
+ input = StringIO.new("1 + 1\n")
+ @cli.console([], input: input, out: output)
+ assert_includes output.string, '=> 2'
+ end
+
+ def test_c_alias_is_recognized_by_run_dispatch
+ assert_raises(Prism::Cli::UsageError) { @cli.run(%w[c --bogus]) }
+ end
+end
diff --git a/test/test_cli_db.rb b/test/test_cli_db.rb
new file mode 100644
index 0000000..49a14a9
--- /dev/null
+++ b/test/test_cli_db.rb
@@ -0,0 +1,174 @@
+require 'minitest/autorun'
+require 'prism'
+require 'stringio'
+require 'tmpdir'
+require 'fileutils'
+
+class CliDbTest < Minitest::Test
+ def setup
+ @workdir = Dir.mktmpdir('prism-cli-db')
+ @cli = Prism::Cli.new(@workdir)
+ @database = 'db/test.pstore'
+ FileUtils.mkdir_p(File.join(@workdir, 'db', 'migrate'))
+ end
+
+ def teardown
+ FileUtils.remove_entry(@workdir)
+ end
+
+ def write_migration(version, class_name, body)
+ path = File.join(@workdir, 'db', 'migrate', "#{version}_#{Prism::Inflector.underscore(class_name)}.rb")
+ File.write(path, <<~RUBY)
+ class #{class_name} < Prism::Orm::Migration
+ #{body}
+ end
+ RUBY
+ path
+ end
+
+ def widgets_migration(version = '20260101000001')
+ write_migration(version, 'CreateWidgetsForCliDbTest', <<~RUBY)
+ def up
+ create_table :widgets_for_cli_db_test do |t|
+ t.string :name
+ end
+ end
+
+ def down
+ drop_table :widgets_for_cli_db_test
+ end
+ RUBY
+ end
+
+ def gadgets_migration(version = '20260101000002')
+ write_migration(version, 'CreateGadgetsForCliDbTest', <<~RUBY)
+ def up
+ create_table :gadgets_for_cli_db_test do |t|
+ t.string :label
+ end
+ end
+
+ def down
+ drop_table :gadgets_for_cli_db_test
+ end
+ RUBY
+ end
+
+ def store
+ Prism::Orm::Store.new(File.join(@workdir, @database))
+ end
+
+ def test_db_migrate_applies_pending_migrations_and_reports_them
+ widgets_migration
+ out = StringIO.new
+ applied = @cli.db_migrate(["--database=#{@database}"], out: out)
+
+ assert_equal ['20260101000001'], applied
+ assert_includes out.string, 'migrated 20260101000001'
+ assert store.table_exists?(:widgets_for_cli_db_test)
+ end
+
+ def test_db_migrate_with_nothing_pending_reports_that_instead_of_erroring
+ widgets_migration
+ @cli.db_migrate(["--database=#{@database}"], out: StringIO.new)
+
+ out = StringIO.new
+ applied = @cli.db_migrate(["--database=#{@database}"], out: out)
+
+ assert_empty applied
+ assert_includes out.string, 'no pending migrations'
+ end
+
+ def test_db_migrate_dispatches_through_run
+ widgets_migration
+ capture_io { @cli.run(['db:migrate', "--database=#{@database}"]) }
+ assert store.table_exists?(:widgets_for_cli_db_test)
+ end
+
+ def test_db_rollback_defaults_to_one_step
+ widgets_migration
+ gadgets_migration
+ @cli.db_migrate(["--database=#{@database}"], out: StringIO.new)
+ assert store.table_exists?(:gadgets_for_cli_db_test)
+
+ out = StringIO.new
+ reverted = @cli.db_rollback(["--database=#{@database}"], out: out)
+
+ assert_equal ['20260101000002'], reverted
+ assert_includes out.string, 'rolled back 20260101000002'
+ refute store.table_exists?(:gadgets_for_cli_db_test)
+ assert store.table_exists?(:widgets_for_cli_db_test), 'earlier migration must survive a single-step rollback'
+ end
+
+ def test_db_rollback_honors_explicit_step_count
+ widgets_migration
+ gadgets_migration
+ @cli.db_migrate(["--database=#{@database}"], out: StringIO.new)
+
+ reverted = @cli.db_rollback(["--database=#{@database}", '--step=2'], out: StringIO.new)
+
+ assert_equal ['20260101000002', '20260101000001'], reverted
+ refute store.table_exists?(:gadgets_for_cli_db_test)
+ refute store.table_exists?(:widgets_for_cli_db_test)
+ end
+
+ def test_db_rollback_with_nothing_applied_reports_that_instead_of_erroring
+ widgets_migration
+ out = StringIO.new
+ reverted = @cli.db_rollback(["--database=#{@database}"], out: out)
+
+ assert_empty reverted
+ assert_includes out.string, 'nothing to roll back'
+ end
+
+ def test_db_status_marks_applied_migrations_up_and_the_rest_down
+ widgets_migration
+ gadgets_migration
+ @cli.db_migrate(["--database=#{@database}"], out: StringIO.new)
+ @cli.db_rollback(["--database=#{@database}"], out: StringIO.new)
+
+ out = StringIO.new
+ status = @cli.db_status(["--database=#{@database}"], out: out)
+
+ assert_equal [['20260101000001', true], ['20260101000002', false]],
+ status.map { |entry, applied| [entry.version, applied] }
+ assert_match(/up\s+20260101000001\s+create_widgets_for_cli_db_test/, out.string)
+ assert_match(/down\s+20260101000002\s+create_gadgets_for_cli_db_test/, out.string)
+ end
+
+ def test_db_status_with_no_migration_files_prints_only_the_header
+ out = StringIO.new
+ status = @cli.db_status(["--database=#{@database}"], out: out)
+
+ assert_empty status
+ assert_equal "STATUS VERSION NAME\n", out.string
+ end
+
+ def test_db_commands_use_the_migrations_flag_to_find_a_custom_directory
+ FileUtils.mkdir_p(File.join(@workdir, 'other_migrations'))
+ File.write(File.join(@workdir, 'other_migrations', '20260101000003_create_thingies_for_cli_db_test.rb'), <<~RUBY)
+ class CreateThingiesForCliDbTest < Prism::Orm::Migration
+ def up
+ create_table :thingies_for_cli_db_test do |t|
+ t.string :name
+ end
+ end
+
+ def down
+ drop_table :thingies_for_cli_db_test
+ end
+ end
+ RUBY
+
+ applied = @cli.db_migrate(["--database=#{@database}", '--migrations=other_migrations'], out: StringIO.new)
+
+ assert_equal ['20260101000003'], applied
+ assert store.table_exists?(:thingies_for_cli_db_test)
+ end
+
+ def test_db_options_reject_unknown_flags
+ assert_raises(Prism::Cli::UsageError) { @cli.db_migrate(['--bogus=1']) }
+ assert_raises(Prism::Cli::UsageError) { @cli.db_rollback(['--bogus=1']) }
+ assert_raises(Prism::Cli::UsageError) { @cli.db_status(['--bogus=1']) }
+ end
+end
diff --git a/test/test_cli_db_seed.rb b/test/test_cli_db_seed.rb
new file mode 100644
index 0000000..c5688fb
--- /dev/null
+++ b/test/test_cli_db_seed.rb
@@ -0,0 +1,106 @@
+require 'minitest/autorun'
+require 'prism'
+require 'stringio'
+require 'tmpdir'
+require 'fileutils'
+
+class CliDbSeedTestWidget < Prism::Orm::Model
+ table_name 'cli_db_seed_test_widgets'
+end
+
+class CliDbSeedTest < Minitest::Test
+ def setup
+ @workdir = Dir.mktmpdir('prism-cli-db-seed')
+ @cli = Prism::Cli.new(@workdir)
+ @database = File.join(@workdir, 'db', 'test.pstore')
+ FileUtils.mkdir_p(File.join(@workdir, 'db'))
+ Prism::Orm.connect(@database)
+ store.create_table('cli_db_seed_test_widgets', %w[id name])
+ end
+
+ def teardown
+ FileUtils.remove_entry(@workdir)
+ Object.send(:remove_const, :CliDbSeedTestRequiredApp) if Object.const_defined?(:CliDbSeedTestRequiredApp)
+ end
+
+ def store
+ Prism::Orm::Store.new(@database)
+ end
+
+ def write_seeds(content, filename = 'db/seeds.rb')
+ path = File.join(@workdir, filename)
+ FileUtils.mkdir_p(File.dirname(path))
+ File.write(path, content)
+ filename
+ end
+
+ def test_db_seed_runs_the_seeds_file_against_the_already_loaded_environment
+ write_seeds("CliDbSeedTestWidget.create!(name: 'sprocket')\n")
+
+ out = StringIO.new
+ result = @cli.db_seed([], out: out)
+
+ assert_equal true, result
+ assert_includes out.string, 'seeded from db/seeds.rb'
+ assert_equal ['sprocket'], store.all('cli_db_seed_test_widgets').map { |row| row['name'] }
+ end
+
+ def test_db_seed_with_no_seeds_file_reports_that_instead_of_erroring
+ out = StringIO.new
+ result = @cli.db_seed([], out: out)
+
+ assert_equal false, result
+ assert_includes out.string, 'no seeds file at db/seeds.rb'
+ end
+
+ def test_db_seed_dispatches_through_run
+ write_seeds("CliDbSeedTestWidget.create!(name: 'via-run')\n")
+
+ capture_io { @cli.run(['db:seed']) }
+
+ assert_equal ['via-run'], store.all('cli_db_seed_test_widgets').map { |row| row['name'] }
+ end
+
+ def test_db_seed_honors_a_custom_file_path
+ write_seeds("CliDbSeedTestWidget.create!(name: 'custom')\n", 'db/custom_seeds.rb')
+
+ out = StringIO.new
+ result = @cli.db_seed(['--file=db/custom_seeds.rb'], out: out)
+
+ assert_equal true, result
+ assert_includes out.string, 'seeded from db/custom_seeds.rb'
+ assert_equal ['custom'], store.all('cli_db_seed_test_widgets').map { |row| row['name'] }
+ end
+
+ def test_db_seed_requires_the_environment_before_running_the_seeds_file
+ File.write(File.join(@workdir, 'app.rb'), <<~RUBY)
+ class CliDbSeedTestRequiredApp
+ def self.slogan
+ 'from --require'
+ end
+ end
+ RUBY
+ write_seeds("CliDbSeedTestWidget.create!(name: CliDbSeedTestRequiredApp.slogan)\n")
+
+ @cli.db_seed(['--require=app.rb'], out: StringIO.new)
+
+ assert_equal ['from --require'], store.all('cli_db_seed_test_widgets').map { |row| row['name'] }
+ end
+
+ # `load`, not `require`: a seeds file is a sequence of side effects, not a
+ # class/module definition with meaningful "already loaded" state, so a
+ # second db:seed run must re-execute it rather than silently no-op the way
+ # `require` would once that exact path had already been loaded once.
+ def test_db_seed_reruns_the_file_on_every_call_instead_of_requiring_it_once
+ write_seeds("CliDbSeedTestWidget.create!(name: 'again')\n")
+
+ @cli.db_seed([], out: StringIO.new)
+ @cli.db_seed([], out: StringIO.new)
+
+ assert_equal %w[again again], store.all('cli_db_seed_test_widgets').map { |row| row['name'] }
+ end
+
+ def test_db_seed_rejects_unknown_options
+ assert_raises(Prism::Cli::UsageError) { @cli.db_seed(['--bogus=1']) }
+ end
+end
diff --git a/test/test_cli_db_subprocess.rb b/test/test_cli_db_subprocess.rb
new file mode 100644
index 0000000..fe3c058
--- /dev/null
+++ b/test/test_cli_db_subprocess.rb
@@ -0,0 +1,149 @@
+require 'minitest/autorun'
+require 'open3'
+require 'tmpdir'
+require 'fileutils'
+require 'prism'
+
+# test_cli_db.rb exercises Prism::Cli#db_migrate/#db_rollback/#db_status
+# in-process (a bare Ruby method call against an already-loaded library) -
+# useful, but it never proves the *executable* works: it does not touch
+# `bin/prism`'s shebang, its $LOAD_PATH setup, ARGV parsing off a real
+# argv, or a real process exit code. This file spawns `bin/prism` as an
+# actual child process (Open3.capture3, a fresh `ruby` interpreter,
+# nothing shared with the test's own Ruby VM beyond the filesystem) so a
+# regression in any of that - e.g. a typo in bin/prism's require, a
+# load-path assumption that only happens to hold when required from
+# test/ - would fail here even though test_cli_db.rb stayed green.
+class CliDbSubprocessTest < Minitest::Test
+ BIN_PRISM = File.expand_path('../bin/prism', __dir__)
+
+ def setup
+ @workdir = Dir.mktmpdir('prism-cli-db-subprocess')
+ FileUtils.mkdir_p(File.join(@workdir, 'db', 'migrate'))
+ end
+
+ def teardown
+ FileUtils.remove_entry(@workdir)
+ end
+
+ def write_migration(version, class_name, body)
+ path = File.join(@workdir, 'db', 'migrate', "#{version}_#{Prism::Inflector.underscore(class_name)}.rb")
+ File.write(path, <<~RUBY)
+ class #{class_name} < Prism::Orm::Migration
+ #{body}
+ end
+ RUBY
+ path
+ end
+
+ def widgets_migration(version = '20260101000001')
+ write_migration(version, 'CreateWidgetsForCliDbSubprocessTest', <<~RUBY)
+ def up
+ create_table :widgets_for_cli_db_subprocess_test do |t|
+ t.string :name
+ end
+ end
+
+ def down
+ drop_table :widgets_for_cli_db_subprocess_test
+ end
+ RUBY
+ end
+
+ def gadgets_migration(version = '20260101000002')
+ write_migration(version, 'CreateGadgetsForCliDbSubprocessTest', <<~RUBY)
+ def up
+ create_table :gadgets_for_cli_db_subprocess_test do |t|
+ t.string :label
+ end
+ end
+
+ def down
+ drop_table :gadgets_for_cli_db_subprocess_test
+ end
+ RUBY
+ end
+
+ # Runs `bin/prism ` as a real child process, chdir'd into the
+ # scratch workdir the way a user's own shell would be when running
+ # `prism db:migrate` inside their app directory - `Cli.new` defaults its
+ # root to `Dir.pwd`, so this is the only way to exercise that default
+ # rather than the explicit root test_cli_db.rb passes to `Cli.new`.
+ def run_prism(*args)
+ Open3.capture3(RbConfig.ruby, BIN_PRISM, *args, chdir: @workdir)
+ end
+
+ def store
+ Prism::Orm::Store.new(File.join(@workdir, 'db', 'test.pstore'))
+ end
+
+ def test_db_migrate_as_a_real_child_process_applies_pending_migrations
+ widgets_migration
+
+ stdout, stderr, status = run_prism('db:migrate', '--database=db/test.pstore')
+
+ assert status.success?, "expected success, got #{status.exitstatus}, stderr: #{stderr}"
+ assert_includes stdout, 'migrated 20260101000001'
+ assert_empty stderr
+ assert store.table_exists?(:widgets_for_cli_db_subprocess_test)
+ end
+
+ def test_db_migrate_as_a_real_child_process_with_nothing_pending
+ widgets_migration
+ run_prism('db:migrate', '--database=db/test.pstore')
+
+ stdout, _stderr, status = run_prism('db:migrate', '--database=db/test.pstore')
+
+ assert status.success?
+ assert_includes stdout, 'no pending migrations'
+ end
+
+ def test_db_rollback_as_a_real_child_process_reverts_the_latest_migration
+ widgets_migration
+ gadgets_migration
+ run_prism('db:migrate', '--database=db/test.pstore')
+ assert store.table_exists?(:gadgets_for_cli_db_subprocess_test)
+
+ stdout, _stderr, status = run_prism('db:rollback', '--database=db/test.pstore')
+
+ assert status.success?
+ assert_includes stdout, 'rolled back 20260101000002'
+ refute store.table_exists?(:gadgets_for_cli_db_subprocess_test)
+ assert store.table_exists?(:widgets_for_cli_db_subprocess_test)
+ end
+
+ def test_db_status_as_a_real_child_process_lists_applied_and_pending
+ widgets_migration
+ gadgets_migration
+ run_prism('db:migrate', '--database=db/test.pstore')
+ run_prism('db:rollback', '--database=db/test.pstore')
+
+ stdout, _stderr, status = run_prism('db:status', '--database=db/test.pstore')
+
+ assert status.success?
+ assert_match(/up\s+20260101000001\s+create_widgets_for_cli_db_subprocess_test/, stdout)
+ assert_match(/down\s+20260101000002\s+create_gadgets_for_cli_db_subprocess_test/, stdout)
+ end
+
+ # bin/prism's own UsageError->exit(1) handling (`rescue ...; warn ...;
+ # exit 1`) only runs when invoked as a real process - calling
+ # Cli#db_migrate directly, as test_cli_db.rb does, raises the exception
+ # straight into the caller instead of going through that rescue at all,
+ # so it cannot prove the process exits non-zero and prints to stderr.
+ def test_unknown_flag_exits_non_zero_with_an_error_on_stderr_not_stdout
+ stdout, stderr, status = run_prism('db:migrate', '--bogus=1')
+
+ refute status.success?
+ assert_equal 1, status.exitstatus
+ assert_match(/error:/, stderr)
+ assert_empty stdout
+ end
+
+ def test_unknown_command_exits_non_zero_with_an_error
+ _stdout, stderr, status = run_prism('db:frobnicate')
+
+ refute status.success?
+ assert_equal 1, status.exitstatus
+ assert_match(/unknown command/, stderr)
+ end
+end
diff --git a/test/test_cli_jobs.rb b/test/test_cli_jobs.rb
new file mode 100644
index 0000000..4903743
--- /dev/null
+++ b/test/test_cli_jobs.rb
@@ -0,0 +1,248 @@
+require 'minitest/autorun'
+require 'prism'
+require 'tmpdir'
+require 'fileutils'
+require 'stringio'
+
+class CliJobsEchoJob
+ def self.log
+ @log ||= []
+ end
+
+ def perform(value)
+ self.class.log << value
+ end
+end
+
+class CliJobsAlwaysFailsJob
+ def perform
+ raise 'boom'
+ end
+end
+
+class CliJobsTest < Minitest::Test
+ def setup
+ @dir = Dir.mktmpdir('prism-cli-jobs')
+ @cli = Prism::Cli.new(@dir)
+ @queue_path = File.join(@dir, 'db', 'jobs.pstore')
+ CliJobsEchoJob.log.clear
+ end
+
+ def teardown
+ FileUtils.remove_entry(@dir)
+ end
+
+ def queue
+ Prism::Jobs::Queue.new(@queue_path)
+ end
+
+ def test_jobs_work_once_drains_ready_jobs_and_returns_a_single_cycle
+ queue.enqueue(job_class: 'CliJobsEchoJob', args: ['a'])
+ queue.enqueue(job_class: 'CliJobsEchoJob', args: ['b'])
+
+ out = StringIO.new
+ cycles = @cli.jobs_work(['--once', '--verbose'], out: out)
+
+ assert_equal 1, cycles
+ assert_equal %w[a b], CliJobsEchoJob.log.sort
+ assert_equal 0, queue.size
+ assert_match(/processed 2 job\(s\)/, out.string)
+ end
+
+ def test_jobs_work_respects_custom_file_option
+ custom_path = File.join(@dir, 'custom_jobs.pstore')
+ Prism::Jobs::Queue.new(custom_path).enqueue(job_class: 'CliJobsEchoJob', args: ['custom'])
+
+ @cli.jobs_work(['--once', '--file=custom_jobs.pstore'])
+
+ assert_equal ['custom'], CliJobsEchoJob.log
+ assert_equal 0, Prism::Jobs::Queue.new(custom_path).size
+ end
+
+ def test_jobs_work_scopes_to_queue_name
+ queue.enqueue(job_class: 'CliJobsEchoJob', args: ['default'])
+ queue.enqueue(job_class: 'CliJobsEchoJob', args: ['mailers'], queue_name: 'mailers')
+
+ @cli.jobs_work(['--once', '--queue=mailers'])
+
+ assert_equal ['mailers'], CliJobsEchoJob.log
+ assert_equal 1, queue.size
+ end
+
+ def test_jobs_work_stops_after_max_cycles_without_sleeping_forever
+ cycles = @cli.jobs_work(['--max-cycles=3', '--interval=0'])
+
+ assert_equal 3, cycles
+ end
+
+ def test_jobs_work_does_not_crash_on_failing_jobs_and_reports_via_error_output
+ queue.enqueue(job_class: 'CliJobsAlwaysFailsJob', args: [], max_attempts: 1)
+
+ _out, err = capture_io { @cli.jobs_work(['--once']) }
+
+ assert_match(/CliJobsAlwaysFailsJob.*failed: boom/, err)
+ assert_equal 1, queue.dead_letter_size
+ end
+
+ def test_jobs_work_requires_extra_files_before_running
+ job_file = File.join(@dir, 'extra_job.rb')
+ File.write(job_file, <<~RUBY)
+ class CliJobsRequiredJob
+ def perform(marker_path)
+ File.write(marker_path, 'ran')
+ end
+ end
+ RUBY
+ marker_path = File.join(@dir, 'marker.txt')
+ queue.enqueue(job_class: 'CliJobsRequiredJob', args: [marker_path])
+
+ @cli.jobs_work(['--once', '--require=extra_job.rb'])
+
+ assert_equal 'ran', File.read(marker_path)
+ end
+
+ def test_jobs_work_rejects_unknown_options
+ assert_raises(Prism::Cli::UsageError) { @cli.jobs_work(['--bogus']) }
+ end
+
+ def test_run_dispatches_jobs_work_through_the_public_cli_entrypoint
+ queue.enqueue(job_class: 'CliJobsEchoJob', args: ['via-run'])
+
+ @cli.run(['jobs', 'work', '--once'])
+
+ assert_equal ['via-run'], CliJobsEchoJob.log
+ end
+
+ def test_run_rejects_unknown_jobs_subcommand
+ assert_raises(Prism::Cli::UsageError) { @cli.run(['jobs', 'bogus']) }
+ end
+
+ def test_jobs_work_writes_its_own_pid_while_running_and_removes_it_on_exit
+ pidfile = File.join(@dir, 'jobs.pid')
+
+ thread = Thread.new { @cli.jobs_work(['--max-cycles=3', '--interval=0.2', "--pidfile=#{pidfile}"]) }
+ sleep 0.05 until File.exist?(pidfile)
+ assert_equal Process.pid.to_s, File.read(pidfile).strip
+
+ thread.join
+
+ refute File.exist?(pidfile), 'pidfile should be removed once the worker stops'
+ end
+
+ def test_jobs_work_refuses_to_start_while_a_live_process_holds_the_pidfile
+ pidfile = File.join(@dir, 'jobs.pid')
+ # A genuinely different, currently-alive OS process (a plain child that
+ # sleeps) - the same scenario a second real daemon would face, unlike
+ # writing our own pid, which the cleanup step would then be entitled to
+ # remove because it would truly be ours.
+ other_pid = Process.spawn('sleep', '5')
+ File.write(pidfile, other_pid.to_s)
+
+ error = assert_raises(Prism::Cli::UsageError) { @cli.jobs_work(['--once', "--pidfile=#{pidfile}"]) }
+
+ assert_match(/already running/, error.message)
+ assert_equal other_pid.to_s, File.read(pidfile).strip, "the other process's pidfile must be left untouched"
+ ensure
+ Process.kill('TERM', other_pid)
+ Process.wait(other_pid)
+ end
+
+ def test_jobs_work_reclaims_a_stale_pidfile_left_by_a_dead_process
+ pidfile = File.join(@dir, 'jobs.pid')
+ dead_pid = fork { exit }
+ Process.wait(dead_pid)
+ File.write(pidfile, dead_pid.to_s)
+
+ queue.enqueue(job_class: 'CliJobsEchoJob', args: ['after-stale-pid'])
+ @cli.jobs_work(['--once', "--pidfile=#{pidfile}"])
+
+ assert_equal ['after-stale-pid'], CliJobsEchoJob.log
+ refute File.exist?(pidfile)
+ end
+
+ # Real end-to-end daemon smoke test: runs `bin/prism jobs work` as its own
+ # OS process (no --once, no --max-cycles), lets it poll and drain a job,
+ # then sends it SIGINT the same way an operator or init system would and
+ # confirms it shuts down cleanly rather than needing to be killed.
+ def test_daemon_process_drains_jobs_and_shuts_down_cleanly_on_sigint
+ job_file = File.join(@dir, 'daemon_job.rb')
+ marker_path = File.join(@dir, 'daemon_marker.txt')
+ File.write(job_file, <<~RUBY)
+ class CliJobsDaemonJob
+ def perform(marker_path)
+ File.write(marker_path, 'ran')
+ end
+ end
+ RUBY
+ queue.enqueue(job_class: 'CliJobsDaemonJob', args: [marker_path])
+
+ bin = File.expand_path('../bin/prism', __dir__)
+ pid = Process.spawn(
+ bin, 'jobs', 'work', '--interval=0.05', '--require=daemon_job.rb',
+ chdir: @dir, out: File::NULL, err: File::NULL
+ )
+
+ deadline = Time.now + 5
+ sleep 0.05 until File.exist?(marker_path) || Time.now > deadline
+ assert File.exist?(marker_path), 'daemon did not process the job within 5s'
+ assert_equal 'ran', File.read(marker_path)
+
+ Process.kill('INT', pid)
+ _, status = Process.wait2(pid)
+ assert status.success?, "daemon did not exit cleanly after SIGINT (status: #{status})"
+ end
+
+ # Real horizontal-scaling scenario: two independent `bin/prism jobs work`
+ # daemons (separate OS processes, no pidfile since neither is meant to be
+ # "the only one") polling the SAME queue file. Jobs::Queue#claim_next! is
+ # already proven race-free at the storage layer (test_jobs_queue.rb spawns
+ # 4 bare claimer processes), but that does not by itself prove the CLI
+ # daemon loop (poll -> work_off -> sleep -> repeat, plus its own error
+ # handling/verbose logging) is safe to run in more than one copy - this
+ # drives the actual `bin/prism jobs work` entrypoint twice and checks both
+ # that every job runs exactly once AND that work genuinely got split
+ # between the two processes rather than one daemon draining the whole
+ # queue before the other gets a chance to poll.
+ def test_two_real_daemon_processes_share_the_same_queue_without_dropping_or_duplicating_jobs
+ job_count = 60
+ results_dir = File.join(@dir, 'daemon_results')
+ FileUtils.mkdir_p(results_dir)
+
+ job_file = File.join(@dir, 'multi_daemon_job.rb')
+ File.write(job_file, <<~RUBY)
+ class CliJobsMultiDaemonJob
+ def perform(id, results_dir)
+ sleep 0.02
+ File.write(File.join(results_dir, "job-\#{id}.done"), Process.pid.to_s)
+ end
+ end
+ RUBY
+
+ job_count.times { |i| queue.enqueue(job_class: 'CliJobsMultiDaemonJob', args: [i, results_dir]) }
+
+ bin = File.expand_path('../bin/prism', __dir__)
+ pids = 2.times.map do
+ Process.spawn(
+ bin, 'jobs', 'work', '--interval=0.01', '--require=multi_daemon_job.rb',
+ chdir: @dir, out: File::NULL, err: File::NULL
+ )
+ end
+
+ deadline = Time.now + 10
+ sleep 0.05 while queue.size.positive? && Time.now < deadline
+
+ pids.each { |pid| Process.kill('INT', pid) }
+ pids.each { |pid| Process.wait2(pid) }
+
+ assert_equal 0, queue.size, 'both daemons together should have drained the whole queue within the deadline'
+
+ result_files = Dir.glob(File.join(results_dir, 'job-*.done'))
+ assert_equal job_count, result_files.size, 'every job should have run exactly once (no drops, no duplicates)'
+ expected_names = (0...job_count).map { |i| "job-#{i}.done" }.sort
+ assert_equal expected_names, result_files.map { |f| File.basename(f) }.sort
+
+ processing_pids = result_files.map { |f| File.read(f).strip }.uniq
+ assert_equal pids.map(&:to_s).sort, processing_pids.sort,
+ 'both daemon processes should have actually processed jobs, not just one of them'
+ end
+end
diff --git a/test/test_cli_jobs_concurrent_daemons.rb b/test/test_cli_jobs_concurrent_daemons.rb
new file mode 100644
index 0000000..80ddfcf
--- /dev/null
+++ b/test/test_cli_jobs_concurrent_daemons.rb
@@ -0,0 +1,74 @@
+require 'minitest/autorun'
+require 'prism'
+require 'tmpdir'
+require 'fileutils'
+
+# The unit-level race (Queue#claim_next! across raw concurrent processes,
+# test_jobs_queue.rb) and the single-daemon pidfile guard (test_cli_jobs.rb)
+# were both already covered, but nothing exercised the shape a real
+# horizontally-scaled deploy actually takes: several `prism jobs work`
+# daemons - each going through the FULL Cli#jobs_work -> Jobs::Worker ->
+# job#perform -> mark_completed pipeline, each with its OWN pidfile so the
+# single-daemon guard never gets in their way - pointed at the SAME queue
+# file at the same time.
+class CliJobsConcurrentDaemonsTest < Minitest::Test
+ DAEMON_COUNT = 5
+ JOB_COUNT = 60
+
+ def setup
+ @dir = Dir.mktmpdir('prism-cli-jobs-concurrent')
+ @queue_path = File.join(@dir, 'db', 'jobs.pstore')
+ @claims_dir = File.join(@dir, 'claims')
+ @pids_dir = File.join(@dir, 'pids')
+ FileUtils.mkdir_p(@claims_dir)
+ FileUtils.mkdir_p(@pids_dir)
+ end
+
+ def teardown
+ FileUtils.remove_entry(@dir)
+ end
+
+ def test_concurrent_daemons_each_process_every_job_exactly_once_with_no_lost_or_duplicated_work
+ queue = Prism::Jobs::Queue.new(@queue_path)
+ markers = JOB_COUNT.times.map(&:to_s)
+ markers.each { |marker| queue.enqueue(job_class: 'CliJobsConcurrentClaimJob', args: [marker]) }
+
+ job_class_file = File.join(@dir, 'claim_job.rb')
+ File.write(job_class_file, <<~RUBY)
+ class CliJobsConcurrentClaimJob
+ def perform(marker)
+ sleep(rand * 0.01)
+ File.write(File.join(#{@claims_dir.inspect}, "\#{marker}.claim"), Process.pid.to_s)
+ end
+ end
+ RUBY
+
+ daemon_script = File.join(@dir, 'daemon.rb')
+ File.write(daemon_script, <<~RUBY)
+ require 'prism'
+ cli = Prism::Cli.new(#{@dir.inspect})
+ cli.jobs_work(
+ ['--once', '--file=#{@queue_path}', '--require=#{job_class_file}', "--pidfile=\#{ARGV[0]}"],
+ out: File.open(File::NULL, 'w')
+ )
+ RUBY
+
+ pids = DAEMON_COUNT.times.map do |i|
+ pidfile = File.join(@pids_dir, "worker-#{i}.pid")
+ Process.spawn(RbConfig.ruby, '-I', File.expand_path('../lib', __dir__), daemon_script, pidfile,
+ out: File::NULL, err: File::NULL)
+ end
+ pids.each { |pid| Process.waitpid(pid) }
+
+ claim_files = Dir.glob(File.join(@claims_dir, '*.claim'))
+ claimed_markers = claim_files.map { |f| File.basename(f, '.claim') }
+
+ assert_equal JOB_COUNT, claim_files.size, 'every enqueued job should have been performed exactly once'
+ assert_equal markers.sort, claimed_markers.sort, 'the exact set of enqueued jobs should have been claimed, no more, no less'
+ assert_equal claimed_markers.size, claimed_markers.uniq.size, 'no job should have been performed twice by two different daemons'
+
+ assert_equal 0, queue.size, 'the queue should be fully drained'
+ assert_equal 0, queue.dead_letter_size, 'no job should have failed'
+ assert_empty Dir.glob(File.join(@pids_dir, '*.pid')), 'every daemon should have removed its own pidfile on exit'
+ end
+end
diff --git a/test/test_cli_routes.rb b/test/test_cli_routes.rb
new file mode 100644
index 0000000..4e880c2
--- /dev/null
+++ b/test/test_cli_routes.rb
@@ -0,0 +1,146 @@
+require 'minitest/autorun'
+require 'prism'
+require 'stringio'
+require 'tmpdir'
+require 'fileutils'
+
+class CliRoutesTest < Minitest::Test
+ def setup
+ @workdir = Dir.mktmpdir('prism-cli-routes')
+ @cli = Prism::Cli.new(@workdir)
+ end
+
+ def teardown
+ FileUtils.remove_entry(@workdir)
+ %i[RoutesTestApp RoutesTestOtherApp].each do |name|
+ Object.send(:remove_const, name) if Object.const_defined?(name)
+ end
+ end
+
+ def write_fixture(filename, content)
+ path = File.join(@workdir, filename)
+ File.write(path, content)
+ filename
+ end
+
+ def run_routes(extra_args = [])
+ out = StringIO.new
+ @cli.routes(extra_args, out: out)
+ out.string
+ end
+
+ def test_lists_named_and_unnamed_routes_with_verb_path_and_target
+ write_fixture('app.rb', <<~RUBY)
+ class RoutesTestApp < Prism::Application
+ root to: 'posts#index'
+ get '/posts/:id', to: 'posts#show', as: :post
+ post '/posts', to: 'posts#create'
+ end
+ RUBY
+
+ output = run_routes(['--require=app.rb'])
+
+ assert_match(/GET\s+\/\s+posts#index\s+root_path/, output)
+ assert_match(/GET\s+\/posts\/:id\s+posts#show\s+post_path/, output)
+ assert_match(/POST\s+\/posts\s+posts#create\s*$/, output)
+ end
+
+ def test_resources_expands_into_seven_named_routes
+ write_fixture('app.rb', <<~RUBY)
+ class RoutesTestApp < Prism::Application
+ resources :posts
+ end
+ RUBY
+
+ output = run_routes(['--require=app.rb'])
+ lines = output.lines.map(&:strip)
+
+ # 7 REST actions, but :update maps to both PATCH and PUT (Rails does the
+ # same) so that is 8 route rows total.
+ assert_equal 8, lines.count { |line| line.include?('posts#') }
+ assert(lines.any? { |line| line.start_with?('GET') && line.include?('posts_path') && line.include?('posts#index') })
+ assert(lines.any? { |line| line.include?('new_post_path') && line.include?('posts#new') })
+ assert(lines.any? { |line| line.include?('edit_post_path') && line.include?('posts#edit') })
+ end
+
+ def test_inline_block_route_is_labeled_block_instead_of_controller_action
+ write_fixture('app.rb', <<~RUBY)
+ class RoutesTestApp < Prism::Application
+ get('/ping') { |req, res| res.write('pong') }
+ end
+ RUBY
+
+ output = run_routes(['--require=app.rb'])
+ assert_match(%r{GET\s+/ping\s+\(block\)}, output)
+ end
+
+ def test_mounted_apps_are_listed_separately_from_regular_routes
+ write_fixture('app.rb', <<~'RUBY')
+ class RoutesTestApp < Prism::Application
+ root to: 'posts#index'
+ mount '/admin', ->(env) { [200, {}, ['admin']] }
+ end
+ RUBY
+
+ output = run_routes(['--require=app.rb'])
+ assert_includes output, 'MOUNT /admin ->'
+ refute_match(/MOUNT.*posts#index/, output)
+ end
+
+ def test_raises_when_no_application_subclass_is_loaded
+ assert_raises(Prism::Cli::UsageError) { run_routes([]) }
+ end
+
+ def test_raises_and_names_both_classes_when_multiple_applications_are_loaded_without_dash_dash_app
+ write_fixture('app.rb', <<~RUBY)
+ class RoutesTestApp < Prism::Application
+ root to: 'posts#index'
+ end
+ class RoutesTestOtherApp < Prism::Application
+ root to: 'comments#index'
+ end
+ RUBY
+
+ error = assert_raises(Prism::Cli::UsageError) { run_routes(['--require=app.rb']) }
+ assert_includes error.message, 'RoutesTestApp'
+ assert_includes error.message, 'RoutesTestOtherApp'
+ assert_includes error.message, '--app='
+ end
+
+ def test_dash_dash_app_disambiguates_between_multiple_loaded_applications
+ write_fixture('app.rb', <<~RUBY)
+ class RoutesTestApp < Prism::Application
+ root to: 'posts#index'
+ end
+ class RoutesTestOtherApp < Prism::Application
+ root to: 'comments#index'
+ end
+ RUBY
+
+ output = run_routes(['--require=app.rb', '--app=RoutesTestOtherApp'])
+ assert_includes output, 'comments#index'
+ refute_includes output, 'posts#index'
+ end
+
+ def test_unknown_option_raises_usage_error
+ assert_raises(Prism::Cli::UsageError) { @cli.routes(['--bogus'], out: StringIO.new) }
+ end
+
+ def test_run_dispatches_routes_command
+ write_fixture('app.rb', <<~RUBY)
+ class RoutesTestApp < Prism::Application
+ root to: 'posts#index'
+ end
+ RUBY
+
+ out = StringIO.new
+ original_stdout = $stdout
+ $stdout = out
+ begin
+ @cli.run(['routes', "--require=#{File.join(@workdir, 'app.rb')}"])
+ ensure
+ $stdout = original_stdout
+ end
+ assert_includes out.string, 'posts#index'
+ end
+end
diff --git a/test/test_conditional_get.rb b/test/test_conditional_get.rb
new file mode 100644
index 0000000..395a73f
--- /dev/null
+++ b/test/test_conditional_get.rb
@@ -0,0 +1,140 @@
+require 'minitest/autorun'
+require 'prism'
+require 'support'
+
+class ArticlePagesController < Prism::Controller
+ CURRENT_ETAG = 'article-42-v3'
+ CURRENT_MODIFIED = Time.utc(2026, 1, 1, 12, 0, 0)
+
+ def show
+ fresh_when(etag: CURRENT_ETAG, last_modified: CURRENT_MODIFIED)
+ html('full article body')
+ end
+
+ def show_strong
+ fresh_when(etag: CURRENT_ETAG, weak: false)
+ html('full article body')
+ end
+
+ def edit
+ if stale?(etag: CURRENT_ETAG)
+ html('editable article body')
+ else
+ raise 'should not reach render when not stale'
+ end
+ end
+
+ def update
+ fresh_when(etag: CURRENT_ETAG, last_modified: CURRENT_MODIFIED)
+ html('updated')
+ end
+end
+
+class ConditionalGetApp < Prism::Application
+ get '/article', to: 'article_pages#show'
+ get '/article/strong', to: 'article_pages#show_strong'
+ get '/article/edit', to: 'article_pages#edit'
+ patch '/article', to: 'article_pages#update'
+end
+
+class ConditionalGetTest < Minitest::Test
+ include TestSupport
+
+ def call(method, path, **opts)
+ ConditionalGetApp.call(env_for(method, path, **opts))
+ end
+
+ def test_first_request_renders_and_sets_etag_and_last_modified
+ status, headers, body = call('GET', '/article')
+ assert_equal 200, status
+ assert_equal 'full article body', body.join
+ refute_nil headers['ETag']
+ assert_equal 'W/"' + Digest::SHA1.hexdigest('article-42-v3') + '"', headers['ETag']
+ assert_equal ArticlePagesController::CURRENT_MODIFIED.httpdate, headers['Last-Modified']
+ end
+
+ def test_matching_if_none_match_returns_304_with_empty_body
+ etag = 'W/"' + Digest::SHA1.hexdigest('article-42-v3') + '"'
+ status, _, body = call('GET', '/article', headers: { 'If-None-Match' => etag })
+ assert_equal 304, status
+ assert_equal '', body.join
+ end
+
+ def test_stale_if_none_match_still_renders_with_200
+ status, _, body = call('GET', '/article', headers: { 'If-None-Match' => 'W/"stale-value"' })
+ assert_equal 200, status
+ assert_equal 'full article body', body.join
+ end
+
+ def test_wildcard_if_none_match_is_always_fresh
+ status, = call('GET', '/article', headers: { 'If-None-Match' => '*' })
+ assert_equal 304, status
+ end
+
+ def test_if_modified_since_after_last_modified_is_fresh
+ status, = call('GET', '/article', headers: { 'If-Modified-Since' => (ArticlePagesController::CURRENT_MODIFIED + 3600).httpdate })
+ assert_equal 304, status
+ end
+
+ def test_if_modified_since_before_last_modified_is_stale
+ status, _, body = call('GET', '/article', headers: { 'If-Modified-Since' => (ArticlePagesController::CURRENT_MODIFIED - 3600).httpdate })
+ assert_equal 200, status
+ assert_equal 'full article body', body.join
+ end
+
+ def test_strong_etag_has_no_weak_prefix
+ status, headers, = call('GET', '/article/strong')
+ assert_equal 200, status
+ assert_equal '"' + Digest::SHA1.hexdigest('article-42-v3') + '"', headers['ETag']
+ end
+
+ def test_stale_predicate_renders_when_no_conditional_headers_sent
+ status, _, body = call('GET', '/article/edit')
+ assert_equal 200, status
+ assert_equal 'editable article body', body.join
+ end
+
+ def test_stale_predicate_halts_with_304_and_skips_render_when_fresh
+ etag = 'W/"' + Digest::SHA1.hexdigest('article-42-v3') + '"'
+ status, _, body = call('GET', '/article/edit', headers: { 'If-None-Match' => etag })
+ assert_equal 304, status
+ assert_equal '', body.join
+ end
+
+ def test_matching_if_match_lets_the_write_through
+ etag = 'W/"' + Digest::SHA1.hexdigest('article-42-v3') + '"'
+ status, _, body = call('PATCH', '/article', headers: { 'If-Match' => etag })
+ assert_equal 200, status
+ assert_equal 'updated', body.join
+ end
+
+ def test_stale_if_match_returns_412_and_skips_the_write
+ status, _, body = call('PATCH', '/article', headers: { 'If-Match' => 'W/"someone-elses-version"' })
+ assert_equal 412, status
+ assert_equal '', body.join
+ end
+
+ def test_wildcard_if_match_always_passes_the_precondition
+ status, = call('PATCH', '/article', headers: { 'If-Match' => '*' })
+ assert_equal 200, status
+ end
+
+ def test_if_unmodified_since_before_last_modified_returns_412
+ header = (ArticlePagesController::CURRENT_MODIFIED - 3600).httpdate
+ status, _, body = call('PATCH', '/article', headers: { 'If-Unmodified-Since' => header })
+ assert_equal 412, status
+ assert_equal '', body.join
+ end
+
+ def test_if_unmodified_since_at_or_after_last_modified_lets_the_write_through
+ header = ArticlePagesController::CURRENT_MODIFIED.httpdate
+ status, _, body = call('PATCH', '/article', headers: { 'If-Unmodified-Since' => header })
+ assert_equal 200, status
+ assert_equal 'updated', body.join
+ end
+
+ def test_no_precondition_headers_lets_the_write_through
+ status, = call('PATCH', '/article')
+ assert_equal 200, status
+ end
+end
diff --git a/test/test_conditional_get_user_scoped_etag.rb b/test/test_conditional_get_user_scoped_etag.rb
new file mode 100644
index 0000000..48d7066
--- /dev/null
+++ b/test/test_conditional_get_user_scoped_etag.rb
@@ -0,0 +1,93 @@
+require 'minitest/autorun'
+require 'prism'
+require 'support'
+
+# Session 42's documented "etag/current_user audit" debt, closed here after
+# being deferred across sessions 42-61. The audit itself: grep every
+# fresh_when call in examples/blog/app/controllers and check whether the
+# view it guards renders anything current_user-dependent. Two of the three
+# call sites already included current_user&.id in their etag components
+# (PostsController#show, CommentsController#index) - PostsController#index
+# was the one real gap, found and fixed directly in
+# examples/blog/app/controllers/posts_controller.rb: its view gates the
+# "new post" form and the layout's signed-in-as banner on
+# user_signed_in?/current_user, but the etag never accounted for viewer
+# identity, so a shared cache keying on URL+ETag alone (no per-user Vary)
+# could serve one visitor's signed-in body back as a 304 to a different, or
+# signed-out, visitor whose If-None-Match happened to match.
+#
+# This file does not require examples/blog/app.rb directly - see
+# test_integration_preview.rb's fixture comment: that file calls
+# Prism::Orm.connect as a process-wide global that would leak into every
+# other test in this suite. Instead it reproduces the exact shape of the
+# bug (buggy_index) and the fix (fixed_index) against an isolated app, the
+# same convention test_cors_conditional_get.rb already established for
+# ConditionalGet.
+class ScopedIndexController < Prism::Controller
+ def buggy_index
+ viewer = request.env['HTTP_X_TEST_USER']
+ fresh_when(etag: ['same-page-content']) # the bug: omits viewer identity
+ html(viewer ? "signed in as #{viewer}" : 'anonymous visitor')
+ end
+
+ def fixed_index
+ viewer = request.env['HTTP_X_TEST_USER']
+ fresh_when(etag: ['same-page-content', viewer]) # the fix
+ html(viewer ? "signed in as #{viewer}" : 'anonymous visitor')
+ end
+end
+
+class ScopedIndexApp < Prism::Application
+ get '/buggy', to: 'scoped_index#buggy_index'
+ get '/fixed', to: 'scoped_index#fixed_index'
+end
+
+class ConditionalGetUserScopedEtagTest < Minitest::Test
+ include TestSupport
+
+ def call(path, headers = {})
+ ScopedIndexApp.call(env_for('GET', path, headers: headers))
+ end
+
+ def test_buggy_etag_is_identical_across_different_viewers
+ _, anon_headers, = call('/buggy')
+ _, alice_headers, = call('/buggy', 'X-Test-User' => 'alice')
+
+ assert_equal anon_headers['ETag'], alice_headers['ETag'],
+ 'demonstrates the bug: rendered content differs by viewer but the etag does not'
+ end
+
+ def test_buggy_etag_lets_a_cached_signed_in_body_leak_back_to_an_anonymous_request
+ _, alice_headers, alice_body = call('/buggy', 'X-Test-User' => 'alice')
+ assert_equal 'signed in as alice', alice_body.join
+
+ status, _, anon_body = call('/buggy', 'If-None-Match' => alice_headers['ETag'])
+ assert_equal 304, status,
+ "the bug: an anonymous visitor whose If-None-Match happens to equal alice's is told nothing changed"
+ assert_equal '', anon_body.join
+ end
+
+ def test_fixed_etag_differs_by_viewer
+ _, anon_headers, = call('/fixed')
+ _, alice_headers, = call('/fixed', 'X-Test-User' => 'alice')
+
+ refute_equal anon_headers['ETag'], alice_headers['ETag']
+ end
+
+ def test_fixed_etag_never_returns_a_304_across_different_viewers
+ _, alice_headers, = call('/fixed', 'X-Test-User' => 'alice')
+
+ status, _, anon_body = call('/fixed', 'If-None-Match' => alice_headers['ETag'])
+ assert_equal 200, status, "an anonymous visitor must get a fresh render, never alice's cached 304"
+ assert_equal 'anonymous visitor', anon_body.join
+ end
+
+ def test_fixed_etag_still_returns_304_for_the_same_viewer
+ _, alice_headers, = call('/fixed', 'X-Test-User' => 'alice')
+
+ status, headers, body = call('/fixed', 'X-Test-User' => 'alice', 'If-None-Match' => alice_headers['ETag'])
+ assert_equal 304, status
+ assert_equal '', body.join
+ assert_equal alice_headers['ETag'], headers['ETag']
+ end
+end
diff --git a/test/test_content_for.rb b/test/test_content_for.rb
new file mode 100644
index 0000000..fcd78f4
--- /dev/null
+++ b/test/test_content_for.rb
@@ -0,0 +1,71 @@
+require 'minitest/autorun'
+require 'prism'
+require 'tmpdir'
+require 'fileutils'
+
+class ContentForTest < Minitest::Test
+ def setup
+ @dir = Dir.mktmpdir('prism-content-for')
+ FileUtils.mkdir_p(File.join(@dir, 'pages'))
+ FileUtils.mkdir_p(File.join(@dir, 'layouts'))
+ @view = Prism::View.new(@dir)
+ end
+
+ def teardown
+ FileUtils.remove_entry(@dir)
+ end
+
+ def write(relative, source)
+ File.write(File.join(@dir, relative), source)
+ end
+
+ def test_content_for_slot_is_readable_in_the_layout_rendered_after_it
+ write('pages/show.erb', '<% content_for(:title) do %>Custom Title<% end %>Body text')
+ write('layouts/application.erb', '[<%= content_for(:title) %>] <%== content %>')
+
+ result = @view.render('pages/show', layout: :application)
+ assert_equal '[Custom Title] Body text', result
+ end
+
+ def test_content_for_without_a_call_reads_back_empty_string
+ write('pages/show.erb', 'Body only')
+ write('layouts/application.erb', 'title=[<%= content_for(:title) %>]<%== content %>')
+
+ result = @view.render('pages/show', layout: :application)
+ assert_equal 'title=[]Body only', result
+ end
+
+ def test_content_for_question_mark_reports_presence
+ write('pages/show.erb', '<% content_for(:sidebar) do %>widgets<% end %>')
+ write('layouts/application.erb', '<% if content_for?(:sidebar) %>has-sidebar<% else %>no-sidebar<% end %>')
+
+ assert_equal 'has-sidebar', @view.render('pages/show', layout: :application)
+ end
+
+ def test_content_for_question_mark_is_false_when_never_set
+ write('pages/show.erb', 'nothing here')
+ write('layouts/application.erb', '<% if content_for?(:sidebar) %>has-sidebar<% else %>no-sidebar<% end %>')
+
+ assert_equal 'no-sidebar', @view.render('pages/show', layout: :application)
+ end
+
+ def test_multiple_content_for_calls_for_the_same_slot_accumulate
+ write('pages/show.erb', <<~ERB)
+ <% content_for(:scripts) do %>a.js<% end %>
+ <% content_for(:scripts) do %>b.js<% end %>
+ ERB
+ write('layouts/application.erb', '<%= content_for(:scripts) %>')
+
+ assert_equal 'a.jsb.js', @view.render('pages/show', layout: :application)
+ end
+
+ def test_content_for_state_does_not_leak_between_separate_render_calls
+ write('pages/one.erb', '<% content_for(:title) do %>One<% end %>')
+ write('pages/two.erb', 'no title here')
+ write('layouts/application.erb', '[<%= content_for(:title) %>]<%== content %>')
+
+ @view.render('pages/one', layout: :application)
+ result = @view.render('pages/two', layout: :application)
+ assert_equal '[]no title here', result
+ end
+end
diff --git a/test/test_controller_filters.rb b/test/test_controller_filters.rb
index 812d492..238de91 100644
--- a/test/test_controller_filters.rb
+++ b/test/test_controller_filters.rb
@@ -40,6 +40,48 @@ class FilterApp < Prism::Application
get '/public', to: 'filter#public_action'
end
+class BaseWithFiltersController < Prism::Controller
+ before_action :stamp_base, only: %i[index]
+ after_action :stamp_base_after, only: %i[index]
+
+ private
+
+ def stamp_base
+ @trail = (@trail || []) << 'base-before'
+ end
+
+ def stamp_base_after
+ self.class.trail_after << 'base-before-runs-first?'
+ end
+end
+
+class InheritedFiltersController < BaseWithFiltersController
+ before_action :stamp_sub, only: %i[index]
+
+ def self.trail_after
+ @trail_after ||= []
+ end
+
+ def index
+ json(trail: @trail)
+ end
+
+ def other
+ json(trail: @trail)
+ end
+
+ private
+
+ def stamp_sub
+ @trail << 'sub-before'
+ end
+end
+
+class InheritedFiltersApp < Prism::Application
+ get '/inherited', to: 'inherited_filters#index'
+ get '/inherited-other', to: 'inherited_filters#other'
+end
+
class ControllerFiltersTest < Minitest::Test
include TestSupport
@@ -51,6 +93,10 @@ class ControllerFiltersTest < Minitest::Test
FilterApp.call(env_for(method, path, **opts))
end
+ def call_inherited(method, path, **opts)
+ InheritedFiltersApp.call(env_for(method, path, **opts))
+ end
+
def test_inline_before_action_block_runs_and_scoped_to_only
_, _, body = call('GET', '/index?token=ok')
assert_equal ['inline-before'], JSON.parse(body.join)['log']
@@ -83,4 +129,19 @@ class ControllerFiltersTest < Minitest::Test
call('GET', '/public')
refute_includes FilterController.after_log, 'public_action'
end
+
+ def test_before_action_declared_on_a_superclass_runs_for_a_subclass_action
+ _, _, body = call_inherited('GET', '/inherited')
+ assert_equal %w[base-before sub-before], JSON.parse(body.join)['trail']
+ end
+
+ def test_after_action_declared_on_a_superclass_runs_for_a_subclass_action
+ call_inherited('GET', '/inherited')
+ assert_includes InheritedFiltersController.trail_after, 'base-before-runs-first?'
+ end
+
+ def test_inherited_before_action_only_option_still_scopes_to_the_declared_action
+ _, _, body = call_inherited('GET', '/inherited-other')
+ assert_nil JSON.parse(body.join)['trail']
+ end
end
diff --git a/test/test_controller_rescue_from.rb b/test/test_controller_rescue_from.rb
new file mode 100644
index 0000000..879bd06
--- /dev/null
+++ b/test/test_controller_rescue_from.rb
@@ -0,0 +1,104 @@
+require 'minitest/autorun'
+require 'prism'
+require 'support'
+
+class RescueFromMissingRecordError < StandardError; end
+class RescueFromSpecificError < RescueFromMissingRecordError; end
+
+class RescueFromBaseController < Prism::Controller
+ rescue_from StandardError, with: :handle_any_error
+
+ private
+
+ def handle_any_error(exception)
+ halt(500, "base handled #{exception.class}")
+ end
+end
+
+# Declares a MORE specific handler than its superclass's catch-all - proves
+# subclass handlers win even though the superclass's StandardError handler
+# would also match.
+class RescueFromController < RescueFromBaseController
+ rescue_from RescueFromMissingRecordError, with: :handle_missing_record
+ rescue_from ArgumentError do |exception|
+ halt(400, "block handled #{exception.message}")
+ end
+
+ def boom_missing
+ raise RescueFromMissingRecordError, 'gone'
+ end
+
+ def boom_specific_subclass
+ raise RescueFromSpecificError, 'gone'
+ end
+
+ def boom_argument
+ raise ArgumentError, 'bad input'
+ end
+
+ def boom_unhandled
+ raise ZeroDivisionError, 'nope'
+ end
+
+ def before_action_boom
+ raise RescueFromMissingRecordError, 'blocked before the action ran'
+ end
+
+ before_action :before_action_boom, only: %i[protected_action]
+
+ def protected_action
+ halt(200, 'should never reach here')
+ end
+
+ private
+
+ def handle_missing_record(exception)
+ halt(404, "missing #{exception.message}")
+ end
+end
+
+class RescueFromApp < Prism::Application
+ get '/missing', to: 'rescue_from#boom_missing'
+ get '/missing_subclass', to: 'rescue_from#boom_specific_subclass'
+ get '/argument', to: 'rescue_from#boom_argument'
+ get '/unhandled', to: 'rescue_from#boom_unhandled'
+ get '/protected', to: 'rescue_from#protected_action'
+end
+
+class ControllerRescueFromTest < Minitest::Test
+ include TestSupport
+
+ def call(method, path, **opts)
+ RescueFromApp.call(env_for(method, path, **opts))
+ end
+
+ def test_specific_handler_declared_on_the_subclass_wins_over_the_superclass_catch_all
+ status, _, body = call('GET', '/missing')
+ assert_equal 404, status
+ assert_equal 'missing gone', body.join
+ end
+
+ def test_is_a_matching_catches_a_subclass_of_the_registered_exception
+ status, _, body = call('GET', '/missing_subclass')
+ assert_equal 404, status
+ assert_equal 'missing gone', body.join
+ end
+
+ def test_block_handler_runs_with_the_controller_as_self
+ status, _, body = call('GET', '/argument')
+ assert_equal 400, status
+ assert_equal 'block handled bad input', body.join
+ end
+
+ def test_falls_back_to_the_superclass_catch_all_when_no_specific_handler_matches
+ status, _, body = call('GET', '/unhandled')
+ assert_equal 500, status
+ assert_equal 'base handled ZeroDivisionError', body.join
+ end
+
+ def test_rescue_from_also_catches_exceptions_raised_inside_before_actions
+ status, _, body = call('GET', '/protected')
+ assert_equal 404, status
+ assert_equal 'missing blocked before the action ran', body.join
+ end
+end
diff --git a/test/test_controller_security.rb b/test/test_controller_security.rb
new file mode 100644
index 0000000..aeed5db
--- /dev/null
+++ b/test/test_controller_security.rb
@@ -0,0 +1,118 @@
+require 'minitest/autorun'
+require 'prism'
+require 'support'
+
+class NotesController < Prism::Controller
+ protect_from_forgery except: [:token]
+
+ def self.received_log
+ @received_log ||= []
+ end
+
+ def token
+ html(csrf_token)
+ end
+
+ def create
+ self.class.received_log << params['title']
+ halt(201, JSON.generate(ok: true))
+ end
+
+ def write_cookie
+ cookies.signed[:uid] = 42
+ cookies.encrypted[:secret_note] = 'top secret'
+ halt(200, 'stored')
+ end
+
+ def read_cookie
+ halt(200, JSON.generate(uid: cookies.signed[:uid], note: cookies.encrypted[:secret_note]))
+ end
+
+ def read_raw_cookie
+ halt(200, JSON.generate(raw: cookies['uid']))
+ end
+end
+
+class SecureNotesApp < Prism::Application
+ enable_sessions(secret: 'notes-secret')
+
+ get '/notes/token', to: 'notes#token'
+ post '/notes', to: 'notes#create'
+ get '/notes/cookie', to: 'notes#write_cookie'
+ get '/notes/read_cookie', to: 'notes#read_cookie'
+ get '/notes/read_raw_cookie', to: 'notes#read_raw_cookie'
+end
+
+class ControllerSecurityTest < Minitest::Test
+ include TestSupport
+
+ def setup
+ NotesController.received_log.clear
+ end
+
+ def call(method, path, **opts)
+ SecureNotesApp.call(env_for(method, path, **opts))
+ end
+
+ def session_cookie_from(headers)
+ line = headers['Set-Cookie'].split("\n").find { |c| c.start_with?('prism.session=') }
+ line.split(';').first
+ end
+
+ def test_post_without_authenticity_token_is_rejected
+ status, = call('POST', '/notes', headers: { 'Content-Type' => 'application/x-www-form-urlencoded' },
+ body: 'title=hi')
+ assert_equal 422, status
+ assert_equal [], NotesController.received_log
+ end
+
+ def test_post_with_valid_authenticity_token_is_accepted
+ _, headers, body = call('GET', '/notes/token')
+ token = body.join
+ cookie = session_cookie_from(headers)
+
+ status, = call('POST', '/notes',
+ headers: { 'Content-Type' => 'application/x-www-form-urlencoded' },
+ cookies: cookie,
+ body: "title=hi&authenticity_token=#{CGI.escape(token)}")
+ assert_equal 201, status
+ assert_equal ['hi'], NotesController.received_log
+ end
+
+ def test_post_with_token_from_a_different_session_is_rejected
+ _, other_headers, other_body = call('GET', '/notes/token')
+ other_token = other_body.join
+ refute_nil session_cookie_from(other_headers)
+
+ _, first_headers, = call('GET', '/notes/token')
+ first_cookie = session_cookie_from(first_headers)
+
+ status, = call('POST', '/notes',
+ headers: { 'Content-Type' => 'application/x-www-form-urlencoded' },
+ cookies: first_cookie,
+ body: "title=hi&authenticity_token=#{CGI.escape(other_token)}")
+ assert_equal 422, status
+ end
+
+ def test_signed_and_encrypted_cookies_round_trip_through_a_real_request_cycle
+ _, write_headers, = call('GET', '/notes/cookie')
+ lines = write_headers['Set-Cookie'].split("\n")
+ cookie_header = lines.map { |line| line.split(';').first }.join('; ')
+
+ _, _, body = call('GET', '/notes/read_cookie', cookies: cookie_header)
+ parsed = JSON.parse(body.join)
+ assert_equal 42, parsed['uid']
+ assert_equal 'top secret', parsed['note']
+ end
+
+ def test_signed_cookie_value_is_not_stored_in_plaintext
+ _, write_headers, = call('GET', '/notes/cookie')
+ lines = write_headers['Set-Cookie'].split("\n")
+ cookie_header = lines.map { |line| line.split(';').first }.join('; ')
+
+ _, _, body = call('GET', '/notes/read_raw_cookie', cookies: cookie_header)
+ raw = JSON.parse(body.join)['raw']
+ refute_nil raw
+ refute_equal '42', raw
+ end
+end
diff --git a/test/test_controller_streaming.rb b/test/test_controller_streaming.rb
new file mode 100644
index 0000000..4ba8e7c
--- /dev/null
+++ b/test/test_controller_streaming.rb
@@ -0,0 +1,83 @@
+require 'minitest/autorun'
+require 'prism'
+require 'support'
+
+class StreamingExportController < Prism::Controller
+ after_action :log_after
+
+ def self.after_log
+ @after_log ||= []
+ end
+
+ def export
+ headers('Content-Type' => 'text/csv')
+ stream do |out|
+ out.write("id,title\n")
+ %w[one two three].each_with_index do |title, index|
+ out.write("#{index + 1},#{title}\n")
+ end
+ end
+ end
+
+ def plain
+ json(ok: true)
+ end
+
+ private
+
+ def log_after
+ self.class.after_log << action_name
+ end
+end
+
+class StreamingApp < Prism::Application
+ get '/export', to: 'streaming_export#export'
+ get '/plain', to: 'streaming_export#plain'
+end
+
+class ControllerStreamingTest < Minitest::Test
+ include TestSupport
+
+ def setup
+ StreamingExportController.after_log.clear
+ end
+
+ def call(env)
+ StreamingApp.call(env)
+ end
+
+ def test_streaming_action_returns_a_streaming_body
+ _status, _headers, body = call(env_for(:get, '/export'))
+ assert_instance_of Prism::StreamingBody, body
+ end
+
+ def test_streaming_action_produces_chunks_in_order_when_drained
+ _status, _headers, body = call(env_for(:get, '/export'))
+ collected = []
+ body.each { |chunk| collected << chunk }
+ assert_equal ["id,title\n", "1,one\n", "2,two\n", "3,three\n"], collected
+ end
+
+ def test_streaming_action_keeps_status_and_explicit_headers
+ status, headers, _body = call(env_for(:get, '/export'))
+ assert_equal 200, status
+ assert_equal 'text/csv', headers['Content-Type']
+ end
+
+ # Regression guard: finalize_body used to call `@response.body.empty?`
+ # unconditionally after every action, which raises NoMethodError against
+ # a StreamingBody (it deliberately has no #empty?, since a producer
+ # block's total chunk count is not known up front).
+ def test_after_actions_still_run_for_a_streaming_action
+ call(env_for(:get, '/export'))
+ assert_equal ['export'], StreamingExportController.after_log
+ end
+
+ # Non-streaming actions on the same controller are unaffected by the
+ # streaming? guard added to finalize_body.
+ def test_ordinary_action_on_the_same_controller_is_unaffected
+ status, _headers, body = call(env_for(:get, '/plain'))
+ assert_equal 200, status
+ assert_equal ['{"ok":true}'], body
+ end
+end
diff --git a/test/test_cookie_jar.rb b/test/test_cookie_jar.rb
new file mode 100644
index 0000000..e5bd737
--- /dev/null
+++ b/test/test_cookie_jar.rb
@@ -0,0 +1,124 @@
+require 'minitest/autorun'
+require 'prism'
+require 'stringio'
+
+class CookieJarTest < Minitest::Test
+ def setup
+ @secret = 'jar-secret-key'
+ end
+
+ def jar_for(cookie_header, secret: @secret, previous_secrets: [])
+ env = {
+ 'REQUEST_METHOD' => 'GET',
+ 'PATH_INFO' => '/',
+ 'QUERY_STRING' => '',
+ 'rack.input' => StringIO.new(''),
+ 'CONTENT_LENGTH' => '0'
+ }
+ env['HTTP_COOKIE'] = cookie_header if cookie_header
+ request = Prism::Request.new(env)
+ response = Prism::Response.new
+ [Prism::CookieJar.new(request, response, secret: secret, previous_secrets: previous_secrets), response]
+ end
+
+ def test_plain_set_and_read
+ jar, response = jar_for(nil)
+ jar[:theme] = 'dark'
+ assert_match(/theme=dark/, response['Set-Cookie'])
+ end
+
+ def test_signed_round_trip_via_two_requests
+ jar, response = jar_for(nil)
+ jar.signed[:user_id] = 42
+ cookie_header = extract_cookie_header(response, 'user_id')
+
+ reader_jar, = jar_for(cookie_header)
+ assert_equal 42, reader_jar.signed[:user_id]
+ end
+
+ def test_signed_read_rejects_tampered_cookie
+ jar, response = jar_for(nil)
+ jar.signed[:user_id] = 42
+ cookie_header = extract_cookie_header(response, 'user_id')
+ tampered = cookie_header.sub('user_id=', 'user_id=tampered')
+
+ reader_jar, = jar_for(tampered)
+ assert_nil reader_jar.signed[:user_id]
+ end
+
+ def test_encrypted_round_trip_hides_plaintext
+ jar, response = jar_for(nil)
+ jar.encrypted[:secret] = 'super-sensitive-value'
+ cookie_header = extract_cookie_header(response, 'secret')
+ refute_includes cookie_header, 'super-sensitive-value'
+
+ reader_jar, = jar_for(cookie_header)
+ assert_equal 'super-sensitive-value', reader_jar.encrypted[:secret]
+ end
+
+ def test_encrypted_read_rejects_tampered_cookie
+ jar, response = jar_for(nil)
+ jar.encrypted[:secret] = 'value'
+ cookie_header = extract_cookie_header(response, 'secret')
+ tampered = cookie_header + 'AA'
+
+ reader_jar, = jar_for(tampered)
+ assert_nil reader_jar.encrypted[:secret]
+ end
+
+ def test_missing_cookie_returns_nil_for_signed_and_encrypted
+ jar, = jar_for(nil)
+ assert_nil jar.signed[:missing]
+ assert_nil jar.encrypted[:missing]
+ end
+
+ def test_value_hash_form_passes_through_cookie_options
+ jar, response = jar_for(nil)
+ jar.signed[:remember] = { value: 'token', max_age: 3600 }
+ header = response['Set-Cookie']
+ assert_match(/Max-Age=3600/, header)
+ end
+
+ def test_signed_previous_secret_still_decodes_after_rotation
+ old_jar, response = jar_for(nil, secret: 'old-jar-secret')
+ old_jar.signed[:user_id] = 42
+ cookie_header = extract_cookie_header(response, 'user_id')
+
+ rotated_jar, = jar_for(cookie_header, secret: 'new-jar-secret', previous_secrets: ['old-jar-secret'])
+ assert_equal 42, rotated_jar.signed[:user_id]
+ end
+
+ def test_encrypted_previous_secret_still_decodes_after_rotation
+ old_jar, response = jar_for(nil, secret: 'old-jar-secret')
+ old_jar.encrypted[:secret] = 'value-from-before-rotation'
+ cookie_header = extract_cookie_header(response, 'secret')
+
+ rotated_jar, = jar_for(cookie_header, secret: 'new-jar-secret', previous_secrets: ['old-jar-secret'])
+ assert_equal 'value-from-before-rotation', rotated_jar.encrypted[:secret]
+ end
+
+ def test_signed_read_rejects_a_secret_that_was_never_current_or_previous
+ old_jar, response = jar_for(nil, secret: 'unrelated-secret')
+ old_jar.signed[:user_id] = 42
+ cookie_header = extract_cookie_header(response, 'user_id')
+
+ rotated_jar, = jar_for(cookie_header, secret: 'new-jar-secret', previous_secrets: ['old-jar-secret'])
+ assert_nil rotated_jar.signed[:user_id]
+ end
+
+ def test_writes_after_rotation_use_the_current_secret_not_a_previous_one
+ jar, response = jar_for(nil, secret: 'new-jar-secret', previous_secrets: ['old-jar-secret'])
+ jar.signed[:user_id] = 42
+ cookie_header = extract_cookie_header(response, 'user_id')
+
+ reader_on_old_secret_only, = jar_for(cookie_header, secret: 'old-jar-secret')
+ assert_nil reader_on_old_secret_only.signed[:user_id]
+ end
+
+ private
+
+ def extract_cookie_header(response, name)
+ line = response['Set-Cookie'].split("\n").find { |entry| entry.start_with?("#{name}=") }
+ line.split(';').first
+ end
+end
diff --git a/test/test_cors.rb b/test/test_cors.rb
new file mode 100644
index 0000000..a964eaa
--- /dev/null
+++ b/test/test_cors.rb
@@ -0,0 +1,118 @@
+require 'minitest/autorun'
+require 'prism'
+
+class CorsDslTest < Minitest::Test
+ def setup
+ Prism::Cors.reset!
+ end
+
+ def teardown
+ Prism::Cors.reset!
+ end
+
+ def test_matches_allowed_origin_and_resource_pattern
+ Prism::Cors.configure do |config|
+ config.allow do
+ origins 'https://app.example.com'
+ resource '/api/*', methods: %w[GET POST]
+ end
+ end
+
+ rule, resource = Prism::Cors.resource_for('https://app.example.com', '/api/posts')
+ refute_nil rule
+ assert_equal %w[GET POST], resource.methods
+ end
+
+ def test_unmatched_origin_returns_nil
+ Prism::Cors.configure do |config|
+ config.allow do
+ origins 'https://app.example.com'
+ resource '/api/*'
+ end
+ end
+
+ assert_nil Prism::Cors.resource_for('https://evil.example.com', '/api/posts')
+ end
+
+ def test_unmatched_path_returns_nil_even_with_allowed_origin
+ Prism::Cors.configure do |config|
+ config.allow do
+ origins 'https://app.example.com'
+ resource '/api/*'
+ end
+ end
+
+ assert_nil Prism::Cors.resource_for('https://app.example.com', '/admin/secrets')
+ end
+
+ def test_wildcard_origin_matches_anything
+ Prism::Cors.configure do |config|
+ config.allow do
+ origins '*'
+ resource '/public/*'
+ end
+ end
+
+ refute_nil Prism::Cors.resource_for('https://anyone.example.com', '/public/feed')
+ end
+
+ def test_regexp_origin_matches
+ Prism::Cors.configure do |config|
+ config.allow do
+ origins(/\Ahttps:\/\/.*\.example\.com\z/)
+ resource '/api/*'
+ end
+ end
+
+ refute_nil Prism::Cors.resource_for('https://sub.example.com', '/api/posts')
+ assert_nil Prism::Cors.resource_for('https://sub.example.org', '/api/posts')
+ end
+
+ def test_first_matching_rule_wins_when_multiple_rules_allow_the_origin
+ Prism::Cors.configure do |config|
+ config.allow do
+ origins '*'
+ resource '/api/*', methods: %w[GET]
+ end
+ config.allow do
+ origins '*'
+ resource '/api/*', methods: %w[GET POST DELETE]
+ end
+ end
+
+ _rule, resource = Prism::Cors.resource_for('https://x.example.com', '/api/posts')
+ assert_equal %w[GET], resource.methods
+ end
+
+ def test_reset_clears_every_rule
+ Prism::Cors.configure { |config| config.allow { origins '*'; resource '/api/*' } }
+ Prism::Cors.reset!
+ assert_empty Prism::Cors.configuration.rules
+ end
+end
+
+class CorsResourceTest < Minitest::Test
+ def test_allows_headers_any_accepts_everything
+ resource = Prism::Cors::Resource.new('/api/*', headers: :any)
+ assert resource.allows_headers?('X-Custom-Header, Authorization')
+ end
+
+ def test_allows_headers_checks_each_requested_header_case_insensitively
+ resource = Prism::Cors::Resource.new('/api/*', headers: %w[Authorization X-Request-Id])
+ assert resource.allows_headers?('authorization, x-request-id')
+ refute resource.allows_headers?('authorization, x-secret')
+ end
+
+ def test_allows_headers_with_blank_request_is_always_true
+ resource = Prism::Cors::Resource.new('/api/*', headers: %w[Authorization])
+ assert resource.allows_headers?(nil)
+ assert resource.allows_headers?('')
+ end
+
+ def test_allows_method_is_case_insensitive_on_registration
+ resource = Prism::Cors::Resource.new('/api/*', methods: %w[get post])
+ assert resource.allows_method?('GET')
+ assert resource.allows_method?('post')
+ refute resource.allows_method?('DELETE')
+ end
+end
diff --git a/test/test_cors_conditional_get.rb b/test/test_cors_conditional_get.rb
new file mode 100644
index 0000000..0d10e6f
--- /dev/null
+++ b/test/test_cors_conditional_get.rb
@@ -0,0 +1,90 @@
+require 'minitest/autorun'
+require 'prism'
+require 'support'
+
+# Session 52's documented debt: Cors and ConditionalGet were each tested in
+# isolation, but never together - and the two subsystems interact in a way
+# that is easy to get wrong. Prism::Middleware::Cors stamps headers onto
+# whatever [status, headers, body] the app below it returns
+# (lib/prism/middleware/cors.rb#call), without special-casing any status
+# code, and Controller#halt(304, '') (used by fresh_when/stale?) short-
+# circuits rendering but still returns through the SAME response path as a
+# normal 200 - so a cross-origin conditional GET that turns out fresh
+# should still come back as a CORS-tagged 304 with an empty body, not lose
+# its Access-Control-* headers just because nothing was actually rendered.
+class CorsArticlesController < Prism::Controller
+ ETAG = 'cors-article-v1'.freeze
+
+ def show
+ fresh_when(etag: ETAG)
+ html('full article body')
+ end
+end
+
+class CorsConditionalGetApp < Prism::Application
+ get '/api/articles/1', to: 'cors_articles#show'
+end
+
+class CorsConditionalGetTest < Minitest::Test
+ include TestSupport
+
+ ORIGIN = 'https://app.example.com'.freeze
+ FRESH_ETAG = 'W/"' + Digest::SHA1.hexdigest(CorsArticlesController::ETAG) + '"'
+
+ def setup
+ Prism::Cors.reset!
+ Prism::Cors.configure do |config|
+ config.allow do
+ origins ORIGIN
+ resource '/api/*', methods: %w[GET]
+ end
+ end
+ @app = Prism::Middleware::Cors.new(CorsConditionalGetApp)
+ end
+
+ def teardown
+ Prism::Cors.reset!
+ end
+
+ def call(headers)
+ @app.call(env_for('GET', '/api/articles/1', headers: headers))
+ end
+
+ def test_first_cross_origin_request_renders_and_still_gets_cors_headers
+ status, headers, body = call('Origin' => ORIGIN)
+
+ assert_equal 200, status
+ assert_equal 'full article body', body.join
+ assert_equal FRESH_ETAG, headers['ETag']
+ assert_equal ORIGIN, headers['Access-Control-Allow-Origin']
+ assert_equal 'Origin', headers['Vary']
+ end
+
+ def test_fresh_cross_origin_request_gets_304_with_cors_headers_intact
+ status, headers, body = call('Origin' => ORIGIN, 'If-None-Match' => FRESH_ETAG)
+
+ assert_equal 304, status
+ assert_equal '', body.join
+ assert_equal FRESH_ETAG, headers['ETag'], 'ETag must still be set on a 304, the client needs it to keep caching'
+ assert_equal ORIGIN, headers['Access-Control-Allow-Origin'],
+ 'a 304 short-circuited by halt() must not lose the CORS headers Cors already knows to stamp on any status'
+ assert_equal 'Origin', headers['Vary']
+ end
+
+ def test_fresh_same_origin_request_gets_304_without_cors_headers
+ status, headers, body = call('If-None-Match' => FRESH_ETAG)
+
+ assert_equal 304, status
+ assert_equal '', body.join
+ assert_equal FRESH_ETAG, headers['ETag']
+ assert_nil headers['Access-Control-Allow-Origin']
+ end
+
+ def test_stale_cross_origin_request_from_a_disallowed_origin_still_renders_without_cors_headers
+ status, headers, body = call('Origin' => 'https://evil.example.com', 'If-None-Match' => FRESH_ETAG)
+
+ assert_equal 304, status, 'ConditionalGet does not know or care about CORS - freshness alone decides the status'
+ assert_equal '', body.join
+ assert_nil headers['Access-Control-Allow-Origin']
+ end
+end
diff --git a/test/test_csrf.rb b/test/test_csrf.rb
new file mode 100644
index 0000000..7fb325e
--- /dev/null
+++ b/test/test_csrf.rb
@@ -0,0 +1,51 @@
+require 'minitest/autorun'
+require 'prism'
+
+class CsrfTest < Minitest::Test
+ def setup
+ @session = {}
+ @csrf = Prism::Csrf.new(@session)
+ end
+
+ def test_raw_token_is_persisted_in_session
+ token = @csrf.raw_token
+ assert_equal token, @session[Prism::Csrf::SESSION_KEY]
+ end
+
+ def test_raw_token_is_stable_across_calls
+ assert_equal @csrf.raw_token, @csrf.raw_token
+ end
+
+ def test_masked_token_differs_each_call
+ refute_equal @csrf.masked_token, @csrf.masked_token
+ end
+
+ def test_masked_token_is_valid
+ assert @csrf.valid_token?(@csrf.masked_token)
+ end
+
+ def test_multiple_masked_tokens_from_same_raw_token_all_validate
+ tokens = Array.new(5) { @csrf.masked_token }
+ assert tokens.all? { |token| @csrf.valid_token?(token) }
+ assert_equal 5, tokens.uniq.size
+ end
+
+ def test_token_from_different_session_is_rejected
+ other_session = {}
+ other_csrf = Prism::Csrf.new(other_session)
+ refute @csrf.valid_token?(other_csrf.masked_token)
+ end
+
+ def test_blank_or_garbage_tokens_are_rejected
+ refute @csrf.valid_token?(nil)
+ refute @csrf.valid_token?('')
+ refute @csrf.valid_token?('not-base64!!!')
+ refute @csrf.valid_token?('dGhpcyBpcyB0b28gc2hvcnQ=')
+ end
+
+ def test_tampering_with_masked_token_invalidates_it
+ masked = @csrf.masked_token
+ tampered = masked.sub(/.\z/) { |c| c == 'a' ? 'b' : 'a' }
+ refute @csrf.valid_token?(tampered)
+ end
+end
diff --git a/test/test_form_builder.rb b/test/test_form_builder.rb
new file mode 100644
index 0000000..cc70a4f
--- /dev/null
+++ b/test/test_form_builder.rb
@@ -0,0 +1,150 @@
+require 'minitest/autorun'
+require 'prism'
+
+class FormBuilderTest < Minitest::Test
+ FakeErrors = Struct.new(:store) do
+ def [](attribute)
+ store.fetch(attribute, [])
+ end
+ end
+
+ FakeModel = Struct.new(:id, :title, :published, :errors, :comments) do
+ def persisted?
+ !id.nil?
+ end
+ end
+
+ FakeComment = Struct.new(:id, :body, :errors)
+
+ def new_post(title: 'Hello', published: false, id: nil, errors: {}, comments: [])
+ FakeModel.new(id, title, published, FakeErrors.new(errors), comments)
+ end
+
+ def new_comment(id:, body:)
+ FakeComment.new(id, body, FakeErrors.new({}))
+ end
+
+ def builder_for(model, object_name: nil)
+ Prism::FormBuilder.new(model, object_name: object_name)
+ end
+
+ def test_text_field_uses_rails_style_nested_name_and_id
+ html = builder_for(new_post).text_field(:title)
+ assert_equal '', html
+ end
+
+ def test_text_field_value_can_be_overridden
+ html = builder_for(new_post).text_field(:title, value: 'Overridden')
+ assert_includes html, 'value="Overridden"'
+ end
+
+ def test_text_field_escapes_value
+ html = builder_for(new_post(title: '')).text_field(:title)
+ assert_includes html, 'value="<b>"'
+ end
+
+ def test_label_defaults_to_humanized_attribute
+ html = builder_for(new_post).label(:title)
+ assert_equal '', html
+ end
+
+ def test_label_accepts_custom_text
+ html = builder_for(new_post).label(:title, 'Post title')
+ assert_includes html, '>Post title'
+ end
+
+ def test_hidden_field
+ html = builder_for(new_post(id: 7)).hidden_field(:id)
+ assert_equal '', html
+ end
+
+ def test_text_area_renders_content_between_tags_not_as_value_attribute
+ html = builder_for(new_post(title: "multi\nline")).text_area(:title)
+ assert_equal "", html
+ end
+
+ def test_check_box_emits_hidden_fallback_and_reflects_current_value
+ html = builder_for(new_post(published: true)).check_box(:published)
+ assert_includes html, 'name="fake_model[published]" value="0"'
+ assert_includes html, 'type="checkbox"'
+ assert_includes html, 'checked="checked"'
+ end
+
+ def test_check_box_unchecked_when_value_falsey
+ html = builder_for(new_post(published: false)).check_box(:published)
+ refute_includes html, 'checked'
+ end
+
+ def test_select_marks_current_value_selected
+ html = builder_for(new_post(title: 'b')).select(:title, %w[a b c])
+ assert_includes html, ''
+ assert_includes html, ''
+ end
+
+ def test_select_with_label_value_pairs
+ html = builder_for(new_post(title: '2')).select(:title, [['One', '1'], ['Two', '2']])
+ assert_includes html, ''
+ end
+
+ def test_select_include_blank
+ html = builder_for(new_post).select(:title, %w[a b], include_blank: true)
+ assert_includes html, ''
+ end
+
+ def test_submit_default_and_custom_value
+ assert_equal '', builder_for(new_post).submit
+ assert_equal '', builder_for(new_post).submit('Go')
+ end
+
+ def test_errors_for_reads_from_object_errors
+ model = new_post(errors: { title: ['is too short'] })
+ assert_equal ['is too short'], builder_for(model).errors_for(:title)
+ end
+
+ def test_error_messages_renders_wrapper_only_when_present
+ clean = builder_for(new_post)
+ assert_equal '', clean.error_messages(:title)
+
+ dirty = builder_for(new_post(errors: { title: ['is blank', 'is too short'] }))
+ assert_equal 'is blank, is too short', dirty.error_messages(:title)
+ end
+
+ def test_object_name_can_be_overridden
+ html = builder_for(new_post, object_name: :post).text_field(:title)
+ assert_includes html, 'name="post[title]"'
+ assert_includes html, 'id="post_title"'
+ end
+
+ def test_fields_for_yields_one_nested_builder_per_record_with_indexed_names
+ post = new_post(id: 1, comments: [new_comment(id: 10, body: 'first'), new_comment(id: 11, body: 'second')])
+ builder = builder_for(post, object_name: :post)
+
+ rendered = []
+ builder.fields_for(:comments) { |f| rendered << f.text_field(:body) }
+
+ assert_equal 2, rendered.size
+ assert_equal '', rendered[0]
+ assert_equal '', rendered[1]
+ end
+
+ def test_fields_for_hidden_id_carries_the_record_id
+ post = new_post(id: 1, comments: [new_comment(id: 42, body: 'x')])
+ builder = builder_for(post, object_name: :post)
+
+ html = nil
+ builder.fields_for(:comments) { |f| html = f.hidden_field(:id) }
+ assert_equal '', html
+ end
+
+ def test_fields_for_accepts_an_explicit_collection_override
+ post = new_post(id: 1)
+ builder = builder_for(post, object_name: :post)
+
+ seen = []
+ builder.fields_for(:comments, [new_comment(id: 5, body: 'explicit')]) { |f| seen << f.object.id }
+ assert_equal [5], seen
+ end
+end
diff --git a/test/test_form_for_helper.rb b/test/test_form_for_helper.rb
new file mode 100644
index 0000000..909e8a3
--- /dev/null
+++ b/test/test_form_for_helper.rb
@@ -0,0 +1,83 @@
+require 'minitest/autorun'
+require 'prism'
+
+class FormForHelperTest < Minitest::Test
+ FakeModel = Struct.new(:id, :title) do
+ def persisted?
+ !id.nil?
+ end
+ end
+
+ HelpersWithCsrf = Struct.new(:csrf_token)
+
+ def render(source, helpers = nil)
+ Prism::Template.render(source, {}, nil, helpers)
+ end
+
+ def test_form_for_new_record_uses_post
+ html = render(<<~ERB)
+ <% form_for(FormForHelperTest::FakeModel.new, url: "/posts") do |f| %>
+ <%== f.text_field(:title) %>
+ <% end %>
+ ERB
+ assert_includes html, ''
+ end
+
+ def test_form_for_persisted_record_defaults_to_patch_via_method_override
+ html = render(<<~ERB)
+ <% form_for(FormForHelperTest::FakeModel.new(1, "hi"), url: "/posts/1") do |f| %>
+ <%== f.submit %>
+ <% end %>
+ ERB
+ assert_includes html, '