diff --git a/README b/README index e69de29..8778825 100644 --- a/README +++ b/README @@ -0,0 +1,34 @@ +This module provides a class-level method for specifying that certain +actions are guarded against being called without certain prerequisites +being met. This is essentially a special kind of before_filter. + +An action may be guarded against being invoked without certain request +parameters being set, or without certain session values existing. + +When a verification is violated, values may be inserted into the flash, and +a specified redirection is triggered. If no specific action is configured, +verification failures will by default result in a 400 Bad Request response. + +Usage: + + class GlobalController < ActionController::Base + # Prevent the #update_settings action from being invoked unless + # the 'admin_privileges' request parameter exists. The + # settings action will be redirected to in current controller + # if verification fails. + verify :params => "admin_privileges", :only => :update_post, + :redirect_to => { :action => "settings" } + + # Disallow a post from being updated if there was no information + # submitted with the post, and if there is no active post in the + # session, and if there is no "note" key in the flash. The route + # named category_url will be redirected to if verification fails. + + verify :params => "post", :session => "post", "flash" => "note", + :only => :update_post, + :add_flash => { "alert" => "Failed to create your message" }, + :redirect_to => :category_url + +Note that these prerequisites are not business rules. They do not examine +the content of the session or the parameters. That level of validation should +be encapsulated by your domain model or helper methods in the controller. \ No newline at end of file diff --git a/Rakefile b/Rakefile new file mode 100644 index 0000000..ba626d2 --- /dev/null +++ b/Rakefile @@ -0,0 +1,22 @@ +require 'rake' +require 'rake/testtask' +require 'rake/rdoctask' + +desc 'Default: run unit tests.' +task :default => :test + +desc 'Test the verification plugin.' +Rake::TestTask.new(:test) do |t| + t.libs << 'lib' + t.pattern = 'test/**/*_test.rb' + t.verbose = true +end + +desc 'Generate documentation for the verification plugin.' +Rake::RDocTask.new(:rdoc) do |rdoc| + rdoc.rdoc_dir = 'rdoc' + rdoc.title = 'Verification' + rdoc.options << '--line-numbers' << '--inline-source' + rdoc.rdoc_files.include('README') + rdoc.rdoc_files.include('lib/**/*.rb') +end diff --git a/init.rb b/init.rb new file mode 100644 index 0000000..8f76f37 --- /dev/null +++ b/init.rb @@ -0,0 +1 @@ +require 'action_controller/verification' \ No newline at end of file diff --git a/lib/action_controller/verification.rb b/lib/action_controller/verification.rb new file mode 100644 index 0000000..28ae9ca --- /dev/null +++ b/lib/action_controller/verification.rb @@ -0,0 +1,132 @@ +module ActionController #:nodoc: + module Verification #:nodoc: + extend ActiveSupport::Concern + + include AbstractController::Callbacks, Flash, Rendering + + # This module provides a class-level method for specifying that certain + # actions are guarded against being called without certain prerequisites + # being met. This is essentially a special kind of before_filter. + # + # An action may be guarded against being invoked without certain request + # parameters being set, or without certain session values existing. + # + # When a verification is violated, values may be inserted into the flash, and + # a specified redirection is triggered. If no specific action is configured, + # verification failures will by default result in a 400 Bad Request response. + # + # Usage: + # + # class GlobalController < ActionController::Base + # # Prevent the #update_settings action from being invoked unless + # # the 'admin_privileges' request parameter exists. The + # # settings action will be redirected to in current controller + # # if verification fails. + # verify :params => "admin_privileges", :only => :update_post, + # :redirect_to => { :action => "settings" } + # + # # Disallow a post from being updated if there was no information + # # submitted with the post, and if there is no active post in the + # # session, and if there is no "note" key in the flash. The route + # # named category_url will be redirected to if verification fails. + # + # verify :params => "post", :session => "post", "flash" => "note", + # :only => :update_post, + # :add_flash => { "alert" => "Failed to create your message" }, + # :redirect_to => :category_url + # + # Note that these prerequisites are not business rules. They do not examine + # the content of the session or the parameters. That level of validation should + # be encapsulated by your domain model or helper methods in the controller. + module ClassMethods + # Verify the given actions so that if certain prerequisites are not met, + # the user is redirected to a different action. The +options+ parameter + # is a hash consisting of the following key/value pairs: + # + # :params:: + # a single key or an array of keys that must be in the params + # hash in order for the action(s) to be safely called. + # :session:: + # a single key or an array of keys that must be in the session + # in order for the action(s) to be safely called. + # :flash:: + # a single key or an array of keys that must be in the flash in order + # for the action(s) to be safely called. + # :method:: + # a single key or an array of keys--any one of which must match the + # current request method in order for the action(s) to be safely called. + # (The key should be a symbol: :get or :post, for + # example.) + # :xhr:: + # true/false option to ensure that the request is coming from an Ajax + # call or not. + # :add_flash:: + # a hash of name/value pairs that should be merged into the session's + # flash if the prerequisites cannot be satisfied. + # :add_headers:: + # a hash of name/value pairs that should be merged into the response's + # headers hash if the prerequisites cannot be satisfied. + # :redirect_to:: + # the redirection parameters to be used when redirecting if the + # prerequisites cannot be satisfied. You can redirect either to named + # route or to the action in some controller. + # :render:: + # the render parameters to be used when the prerequisites cannot be satisfied. + # :only:: + # only apply this verification to the actions specified in the associated + # array (may also be a single value). + # :except:: + # do not apply this verification to the actions specified in the associated + # array (may also be a single value). + def verify(options={}) + before_filter :only => options[:only], :except => options[:except] do + verify_action options + end + end + end + + private + + def verify_action(options) #:nodoc: + if prereqs_invalid?(options) + flash.update(options[:add_flash]) if options[:add_flash] + response.headers.merge!(options[:add_headers]) if options[:add_headers] + apply_remaining_actions(options) unless performed? + end + end + + def prereqs_invalid?(options) # :nodoc: + verify_presence_of_keys_in_hash_flash_or_params(options) || + verify_method(options) || + verify_request_xhr_status(options) + end + + def verify_presence_of_keys_in_hash_flash_or_params(options) # :nodoc: + [*options[:params] ].find { |v| v && params[v.to_sym].nil? } || + [*options[:session]].find { |v| session[v].nil? } || + [*options[:flash] ].find { |v| flash[v].nil? } + end + + def verify_method(options) # :nodoc: + [*options[:method]].all? { |v| request.method != v.to_sym } if options[:method] + end + + def verify_request_xhr_status(options) # :nodoc: + request.xhr? != options[:xhr] unless options[:xhr].nil? + end + + def apply_redirect_to(redirect_to_option) # :nodoc: + (redirect_to_option.is_a?(Symbol) && redirect_to_option != :back) ? self.__send__(redirect_to_option) : redirect_to_option + end + + def apply_remaining_actions(options) # :nodoc: + case + when options[:render] ; render(options[:render]) + when options[:redirect_to] ; redirect_to(apply_redirect_to(options[:redirect_to])) + else head(:bad_request) + end + end + end +end + +ActionController::Base.send :include, ActionController::Verification diff --git a/test/verification_test.rb b/test/verification_test.rb new file mode 100644 index 0000000..11d0d10 --- /dev/null +++ b/test/verification_test.rb @@ -0,0 +1,270 @@ +require 'abstract_unit' + +class VerificationTest < ActionController::TestCase + class TestController < ActionController::Base + verify :only => :guarded_one, :params => "one", + :add_flash => { :error => 'unguarded' }, + :redirect_to => { :action => "unguarded" } + + verify :only => :guarded_two, :params => %w( one two ), + :redirect_to => { :action => "unguarded" } + + verify :only => :guarded_with_flash, :params => "one", + :add_flash => { :notice => "prereqs failed" }, + :redirect_to => { :action => "unguarded" } + + verify :only => :guarded_in_session, :session => "one", + :redirect_to => { :action => "unguarded" } + + verify :only => [:multi_one, :multi_two], :session => %w( one two ), + :redirect_to => { :action => "unguarded" } + + verify :only => :guarded_by_method, :method => :post, + :redirect_to => { :action => "unguarded" } + + verify :only => :guarded_by_xhr, :xhr => true, + :redirect_to => { :action => "unguarded" } + + verify :only => :guarded_by_not_xhr, :xhr => false, + :redirect_to => { :action => "unguarded" } + + before_filter :unconditional_redirect, :only => :two_redirects + verify :only => :two_redirects, :method => :post, + :redirect_to => { :action => "unguarded" } + + verify :only => :must_be_post, :method => :post, :render => { :status => 405, :text => "Must be post" }, :add_headers => { "Allow" => "POST" } + + verify :only => :guarded_one_for_named_route_test, :params => "one", + :redirect_to => :foo_url + + verify :only => :no_default_action, :params => "santa" + + verify :only => :guarded_with_back, :method => :post, + :redirect_to => :back + + def guarded_one + render :text => "#{params[:one]}" + end + + def guarded_one_for_named_route_test + render :text => "#{params[:one]}" + end + + def guarded_with_flash + render :text => "#{params[:one]}" + end + + def guarded_two + render :text => "#{params[:one]}:#{params[:two]}" + end + + def guarded_in_session + render :text => "#{session["one"]}" + end + + def multi_one + render :text => "#{session["one"]}:#{session["two"]}" + end + + def multi_two + render :text => "#{session["two"]}:#{session["one"]}" + end + + def guarded_by_method + render :text => "#{request.method}" + end + + def guarded_by_xhr + render :text => "#{request.xhr?}" + end + + def guarded_by_not_xhr + render :text => "#{request.xhr?}" + end + + def unguarded + render :text => "#{params[:one]}" + end + + def two_redirects + render :nothing => true + end + + def must_be_post + render :text => "Was a post!" + end + + def guarded_with_back + render :text => "#{params[:one]}" + end + + def no_default_action + # Will never run + end + + protected + + def unconditional_redirect + redirect_to :action => "unguarded" + end + end + + tests TestController + + def test_using_symbol_back_with_no_referrer + assert_raise(ActionController::RedirectBackError) { get :guarded_with_back } + end + + def test_using_symbol_back_redirects_to_referrer + @request.env["HTTP_REFERER"] = "/foo" + get :guarded_with_back + assert_redirected_to '/foo' + end + + def test_no_deprecation_warning_for_named_route + assert_not_deprecated do + with_routing do |set| + set.draw do |map| + match 'foo', :to => 'test#foo', :as => :foo + match 'verification_test/:action', :to => ::VerificationTest::TestController + end + get :guarded_one_for_named_route_test, :two => "not one" + assert_redirected_to '/foo' + end + end + end + + def test_guarded_one_with_prereqs + get :guarded_one, :one => "here" + assert_equal "here", @response.body + end + + def test_guarded_one_without_prereqs + get :guarded_one + assert_redirected_to :action => "unguarded" + assert_equal 'unguarded', flash[:error] + end + + def test_guarded_with_flash_with_prereqs + get :guarded_with_flash, :one => "here" + assert_equal "here", @response.body + assert flash.empty? + end + + def test_guarded_with_flash_without_prereqs + get :guarded_with_flash + assert_redirected_to :action => "unguarded" + assert_equal "prereqs failed", flash[:notice] + end + + def test_guarded_two_with_prereqs + get :guarded_two, :one => "here", :two => "there" + assert_equal "here:there", @response.body + end + + def test_guarded_two_without_prereqs_one + get :guarded_two, :two => "there" + assert_redirected_to :action => "unguarded" + end + + def test_guarded_two_without_prereqs_two + get :guarded_two, :one => "here" + assert_redirected_to :action => "unguarded" + end + + def test_guarded_two_without_prereqs_both + get :guarded_two + assert_redirected_to :action => "unguarded" + end + + def test_unguarded_with_params + get :unguarded, :one => "here" + assert_equal "here", @response.body + end + + def test_unguarded_without_params + get :unguarded + assert @response.body.blank? + end + + def test_guarded_in_session_with_prereqs + get :guarded_in_session, {}, "one" => "here" + assert_equal "here", @response.body + end + + def test_guarded_in_session_without_prereqs + get :guarded_in_session + assert_redirected_to :action => "unguarded" + end + + def test_multi_one_with_prereqs + get :multi_one, {}, "one" => "here", "two" => "there" + assert_equal "here:there", @response.body + end + + def test_multi_one_without_prereqs + get :multi_one + assert_redirected_to :action => "unguarded" + end + + def test_multi_two_with_prereqs + get :multi_two, {}, "one" => "here", "two" => "there" + assert_equal "there:here", @response.body + end + + def test_multi_two_without_prereqs + get :multi_two + assert_redirected_to :action => "unguarded" + end + + def test_guarded_by_method_with_prereqs + post :guarded_by_method + assert_equal "post", @response.body + end + + def test_guarded_by_method_without_prereqs + get :guarded_by_method + assert_redirected_to :action => "unguarded" + end + + def test_guarded_by_xhr_with_prereqs + xhr :post, :guarded_by_xhr + assert_equal "true", @response.body + end + + def test_guarded_by_xhr_without_prereqs + get :guarded_by_xhr + assert_redirected_to :action => "unguarded" + end + + def test_guarded_by_not_xhr_with_prereqs + get :guarded_by_not_xhr + assert_equal "false", @response.body + end + + def test_guarded_by_not_xhr_without_prereqs + xhr :post, :guarded_by_not_xhr + assert_redirected_to :action => "unguarded" + end + + def test_guarded_post_and_calls_render_succeeds + post :must_be_post + assert_equal "Was a post!", @response.body + end + + def test_default_failure_should_be_a_bad_request + post :no_default_action + assert_response :bad_request + end + + def test_guarded_post_and_calls_render_fails_and_sets_allow_header + get :must_be_post + assert_response 405 + assert_equal "Must be post", @response.body + assert_equal "POST", @response.headers["Allow"] + end + + def test_second_redirect + assert_nothing_raised { get :two_redirects } + end +end