diff --git a/Gemfile.lock b/Gemfile.lock index ceb69094b..27405bbde 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -66,7 +66,7 @@ GIT GIT remote: https://github.com/ncbo/ontologies_linked_data.git - revision: 24c5a6d0a9de0ec6510d2615cc2e98c93c370a4e + revision: c6f83eba9c21513c60977f93f93c6f08af958921 branch: develop specs: ontologies_linked_data (0.0.1) diff --git a/app.rb b/app.rb index 3f73742bd..f57bb0b09 100644 --- a/app.rb +++ b/app.rb @@ -28,6 +28,7 @@ require_relative 'lib/rack/param_translator' require_relative 'lib/rack/slice_detection' require_relative 'lib/rack/request_lang' +require_relative 'lib/rack/trailing_slash_redirect' # Logging setup require_relative "config/logging" @@ -172,6 +173,10 @@ require_relative 'config/unicorn_workerkiller' end +# Canonicalize trailing slashes (301/308 redirect) before Sinatra routing. +# Innermost middleware: runs after auth/CORS but before any route or filter. +use Rack::TrailingSlashRedirect + # Add New Relic last to allow Rack middleware instrumentation require 'newrelic_rpm' diff --git a/controllers/annotator_controller.rb b/controllers/annotator_controller.rb index a3c451650..d0613766d 100644 --- a/controllers/annotator_controller.rb +++ b/controllers/annotator_controller.rb @@ -103,13 +103,13 @@ def process_annotation(params=nil) end post '/dictionary' do - error 403, "Access denied" unless current_user && current_user.admin? + admin_only! annotator = Annotator::Models::NcboAnnotator.new annotator.generate_dictionary_file() end post '/cache' do - error 403, "Access denied" unless current_user && current_user.admin? + admin_only! delete_cache = params['delete_cache'].eql?('true') annotator = Annotator::Models::NcboAnnotator.new annotator.create_term_cache(nil, delete_cache) diff --git a/controllers/slices_controller.rb b/controllers/slices_controller.rb index 727093e85..169e30a2f 100644 --- a/controllers/slices_controller.rb +++ b/controllers/slices_controller.rb @@ -17,20 +17,20 @@ class SlicesController < ApplicationController ## # Create a new slice post do - error 403, "Access denied" unless current_user && current_user.admin? + admin_only! create_slice end # Delete a slice delete '/:slice' do - error 403, "Access denied" unless current_user && current_user.admin? + admin_only! LinkedData::Models::Slice.find(params[:slice]).first.delete halt 204 end # Update an existing slice patch '/:slice' do - error 403, "Access denied" unless current_user && current_user.admin? + admin_only! slice = LinkedData::Models::Slice.find(params[:slice]).include(LinkedData::Models::Slice.attributes(:all)).first populate_from_params(slice, params) if slice.valid? @@ -44,7 +44,7 @@ class SlicesController < ApplicationController # Check to make sure each group has a corresponding slice (and ontologies match) get '/synchronize_groups' do - error 403, "Access denied" unless current_user && current_user.admin? + admin_only! groups = LinkedData::Models::Group.where.include(LinkedData::Models::Group.attributes(:all)).all groups.each do |g| diff --git a/controllers/users_controller.rb b/controllers/users_controller.rb index a35388c28..1e760712b 100644 --- a/controllers/users_controller.rb +++ b/controllers/users_controller.rb @@ -105,7 +105,7 @@ class UsersController < ApplicationController # Delete a user delete '/:username' do - error 403, "Access denied" unless current_user.admin? + admin_only! User.find(params[:username]).first.delete halt 204 end diff --git a/helpers/application_helper.rb b/helpers/application_helper.rb index 9bcc61cc0..a3eef4c10 100644 --- a/helpers/application_helper.rb +++ b/helpers/application_helper.rb @@ -471,6 +471,12 @@ def current_user env["REMOTE_USER"] || LinkedData::Models::User.new end + ## + # Halt with 403 unless the current user is an administrator + def admin_only! + error 403, "Access denied" unless current_user && current_user.admin? + end + def include_param_contains?(str) str = str.to_s unless str.is_a?(String) class_params_include = params["include_for_class"] && params["include_for_class"].include?(str.to_sym) diff --git a/init.rb b/init.rb index 0f11b3968..9846b127c 100644 --- a/init.rb +++ b/init.rb @@ -10,38 +10,5 @@ def require_dir(dir) require_dir('models') require_dir('controllers') -# Add optional trailing slash to routes -Sinatra.register do - def self.registered(app) - app.routes.each do |verb, routes| - routes.each do |route| - pattern = route[0] - next if pattern.to_s.end_with?('/') - - http_verb = verb.to_s.downcase - app.public_send(http_verb, "#{pattern}/") do - pass unless request.path_info.end_with?('/') - redirect_path = request.path_info.chomp('/') - redirect canonical_redirect_url(redirect_path), 301 - end - end - end - end -end - -helpers do - def canonical_redirect_url(path) - url = +"#{external_request_scheme}://#{request.host_with_port}#{path}" - url << "?#{request.query_string}" unless request.query_string.empty? - url - end - - # Rack 3 no longer trusts X-Forwarded-Proto on `request.scheme`, so a - # TLS-terminating proxy that forwards to the app over HTTP would otherwise - # cause us to emit `Location: http://...` for an https:// request. - def external_request_scheme - forwarded = request.get_header('HTTP_X_FORWARDED_PROTO').to_s - .split(',').first.to_s.strip.downcase - %w[http https].include?(forwarded) ? forwarded : request.scheme - end -end +# Trailing-slash canonicalization is handled by Rack::TrailingSlashRedirect +# (see app.rb / lib/rack/trailing_slash_redirect.rb). diff --git a/lib/rack/trailing_slash_redirect.rb b/lib/rack/trailing_slash_redirect.rb new file mode 100644 index 000000000..35b6a5847 --- /dev/null +++ b/lib/rack/trailing_slash_redirect.rb @@ -0,0 +1,54 @@ +module Rack + # Canonicalizes URLs by stripping a trailing slash and redirecting to the + # slash-less form (e.g. `GET /ontologies/` -> 301 `/ontologies`). + # + # Implemented as Rack middleware so the redirect happens before Sinatra + # routing and before any namespace `before` filters run -- routes therefore + # only ever see canonical paths, and no per-route trailing-slash handling is + # needed. + class TrailingSlashRedirect + def initialize(app) + @app = app + end + + def call(env) + # PATH_INFO and QUERY_STRING are required by the Rack SPEC: always + # present, possibly empty, never nil. + path = env['PATH_INFO'] + return @app.call(env) unless path.length > 1 && path.end_with?('/') + + req = Rack::Request.new(env) + # PATH_INFO keeps percent-encoding (e.g. %2F in class IRIs), so building + # the location from it round-trips encoded segments unchanged. + location = +"#{external_scheme(env, req)}://#{req.host_with_port}#{path.chomp('/')}" + query = env['QUERY_STRING'] + location << "?#{query}" unless query.empty? + + # 301 may turn POST -> GET and drop the body; 308 preserves method + body. + status = %w[GET HEAD].include?(env['REQUEST_METHOD']) ? 301 : 308 + # A HEAD response must not carry a body. + body = env['REQUEST_METHOD'] == 'HEAD' ? [] : ['Moved Permanently'] + # The Location's scheme depends on X-Forwarded-Proto, but an outer + # Rack::Cache keys entries on path+query only. Without no-store it could + # serve a cached `Location: https://...` to a plain-http client (or vice + # versa). Keep the scheme-dependent redirect out of the shared cache. + headers = { + 'location' => location, + 'content-type' => 'text/plain', + 'cache-control' => 'no-store' + } + [status, headers, body] + end + + private + + # Rack 3 no longer trusts X-Forwarded-Proto on `request.scheme`, so a + # TLS-terminating proxy that forwards to the app over HTTP would otherwise + # cause us to emit `Location: http://...` for an https:// request. Use the + # leftmost forwarded value (RFC 7239 chained proxies), else fall back. + def external_scheme(env, req) + forwarded = env['HTTP_X_FORWARDED_PROTO'].to_s.split(',').first.to_s.strip.downcase + %w[http https].include?(forwarded) ? forwarded : req.scheme + end + end +end diff --git a/test/controllers/test_admin_only_endpoints.rb b/test/controllers/test_admin_only_endpoints.rb new file mode 100644 index 000000000..41e1033f1 --- /dev/null +++ b/test/controllers/test_admin_only_endpoints.rb @@ -0,0 +1,58 @@ +require_relative '../test_case' + +# Verifies the `admin_only!` authorization gate (helpers/application_helper.rb): +# every endpoint guarded by it must return 403 for an authenticated non-admin. +# `admin_only!` is the first line of each guarded handler, so it halts before +# any endpoint work runs -- no valid payload or target resource is required. +# +# The "admin is allowed through" path is left to the existing endpoint tests +# (e.g. test_slices_controller, test_users_controller); it is not re-exercised +# here to avoid their side effects (slice/user mutation, annotator dictionary +# and cache rebuilds). +class TestAdminOnlyEndpoints < TestCase + + # [http verb, path] for every reachable handler that calls `admin_only!`. + # + # GET /slices/synchronize_groups also calls admin_only! but is unreachable -- + # the earlier `get '/:slice_id'` route shadows it -- so it is omitted here. + ADMIN_ONLY_ENDPOINTS = [ + [:post, "/slices"], + [:patch, "/slices/any"], + [:delete, "/slices/any"], + [:delete, "/users/any"], + [:post, "/annotator/dictionary"], + [:post, "/annotator/cache"] + ].freeze + + def before_suite + self.class.delete_user("test-admin-gate") + @@user = self.class.create_user("test-admin-gate") + end + + def after_suite + self.class.delete_user("test-admin-gate") + end + + def setup + # Reset the role with security off: user.save runs a write permission check + # when security is on, which denies outside an authenticated request. The + # request under test enables security itself. + with_settings(enable_security: false) do + self.class.reset_to_not_admin(@@user) + end + end + + def test_admin_only_endpoints_forbidden_for_non_admin + with_settings(enable_security: true) do + ADMIN_ONLY_ENDPOINTS.each do |verb, path| + send(verb, "#{path}?apikey=#{@@user.apikey}") + + assert_equal 403, last_response.status, + "expected 403 for #{verb.upcase} #{path} as a non-admin user, " \ + "got #{last_response.status}: #{last_response.body}" + assert_match(/access denied/i, last_response.body, + "expected an 'Access denied' body for #{verb.upcase} #{path}") + end + end + end +end diff --git a/test/controllers/test_annotator_controller.rb b/test/controllers/test_annotator_controller.rb index 2e6dada16..1e7bd242b 100644 --- a/test/controllers/test_annotator_controller.rb +++ b/test/controllers/test_annotator_controller.rb @@ -291,15 +291,14 @@ def test_recognizer_endpoint rest_recognizers = MultiJson.load(last_response.body) assert rest_recognizers.length > 0 - default_rec_setting = Annotator.settings.supported_recognizers - Annotator.settings.supported_recognizers = recognizers - - get "/annotator/recognizers" - assert last_response.ok? - rest_recognizers = MultiJson.load(last_response.body) - assert rest_recognizers.length > 0 - assert_equal recognizers.length, rest_recognizers.length - assert_equal recognizers.sort, rest_recognizers.map {|r| r.to_sym}.sort + with_settings(Annotator.settings, supported_recognizers: recognizers) do + get "/annotator/recognizers" + assert last_response.ok? + rest_recognizers = MultiJson.load(last_response.body) + assert rest_recognizers.length > 0 + assert_equal recognizers.length, rest_recognizers.length + assert_equal recognizers.sort, rest_recognizers.map {|r| r.to_sym}.sort + end end #TODO: this method is duplicated in NCBO_ANNOTATOR diff --git a/test/controllers/test_ontologies_controller.rb b/test/controllers/test_ontologies_controller.rb index 37f7adcb0..c19605e33 100644 --- a/test/controllers/test_ontologies_controller.rb +++ b/test/controllers/test_ontologies_controller.rb @@ -170,20 +170,15 @@ def test_download_ontology assert_equal(1, onts.length, msg="Failed to create 1 ontology?") ont = onts.first assert_instance_of(Ontology, ont, msg="ont is not a #{Ontology.class}") - # Clear restrictions on downloads - LinkedData::OntologiesAPI.settings.restrict_download = [] # Download the latest submission (the generic ontology download) acronym = created_ont_acronyms.first get "/ontologies/#{acronym}/download" assert_equal(200, last_response.status, msg='failed download for ontology : ' + get_errors(last_response)) - # Add restriction on download - LinkedData::OntologiesAPI.settings.restrict_download = [acronym] - # Try download - get "/ontologies/#{acronym}/download" - # download should fail with a 403 status - assert_equal(403, last_response.status, msg='failed to restrict download for ontology : ' + get_errors(last_response)) - # Clear restrictions on downloads - LinkedData::OntologiesAPI.settings.restrict_download = [] + # With the acronym restricted, the same download should fail with a 403. + with_settings(LinkedData::OntologiesAPI.settings, restrict_download: [acronym]) do + get "/ontologies/#{acronym}/download" + assert_equal(403, last_response.status, msg='failed to restrict download for ontology : ' + get_errors(last_response)) + end # see also test_ontologies_submissions_controller::test_download_submission end @@ -237,20 +232,19 @@ def test_download_acl_only ont.viewingRestriction = "private" ont.save - LinkedData.settings.enable_security = true - - get "/ontologies/#{acronym}/download?apikey=#{allowed_user.apikey}" - assert_equal(200, last_response.status, msg="User who is in ACL couldn't download ontology") + with_settings(enable_security: true) do + get "/ontologies/#{acronym}/download?apikey=#{allowed_user.apikey}" + assert_equal(200, last_response.status, msg="User who is in ACL couldn't download ontology") - get "/ontologies/#{acronym}/download?apikey=#{blocked_user.apikey}" - assert_equal(403, last_response.status, msg="User who isn't in ACL could download ontology") + get "/ontologies/#{acronym}/download?apikey=#{blocked_user.apikey}" + assert_equal(403, last_response.status, msg="User who isn't in ACL could download ontology") - admin = ont.administeredBy.first - admin.bring(:apikey) - get "/ontologies/#{acronym}/download?apikey=#{admin.apikey}" - assert_equal(200, last_response.status, msg="Admin couldn't download ontology") + admin = ont.administeredBy.first + admin.bring(:apikey) + get "/ontologies/#{acronym}/download?apikey=#{admin.apikey}" + assert_equal(200, last_response.status, msg="Admin couldn't download ontology") + end ensure - LinkedData.settings.enable_security = false del = User.find("allowed").first del.delete if del del = User.find("blocked").first @@ -276,23 +270,23 @@ def test_on_demand_ontology_pull start_server sub.pullLocation = RDF::IRI.new(@@server_url) sub.save - LinkedData.settings.enable_security = true - post "/ontologies/#{acronym}/pull?apikey=#{allowed_user.apikey}" - assert_equal(204, last_response.status, msg="The ontology admin was unable to execute the on-demand pull") - - blocked_user = User.new({ - username: "blocked", - email: "test@example.org", - password: "12345" - }) - blocked_user.save - post "/ontologies/#{acronym}/pull?apikey=#{blocked_user.apikey}" - assert_equal(403, last_response.status, msg="An unauthorized user was able to execute the on-demand pull") + with_settings(enable_security: true) do + post "/ontologies/#{acronym}/pull?apikey=#{allowed_user.apikey}" + assert_equal(204, last_response.status, msg="The ontology admin was unable to execute the on-demand pull") + + blocked_user = User.new({ + username: "blocked", + email: "test@example.org", + password: "12345" + }) + blocked_user.save + post "/ontologies/#{acronym}/pull?apikey=#{blocked_user.apikey}" + assert_equal(403, last_response.status, msg="An unauthorized user was able to execute the on-demand pull") + end ensure del = User.find("blocked").first del.delete if del stop_server - LinkedData.settings.enable_security = false del = User.find("blocked").first del.delete if del end diff --git a/test/controllers/test_ontology_submissions_controller.rb b/test/controllers/test_ontology_submissions_controller.rb index a4df6becf..5fc034f5c 100644 --- a/test/controllers/test_ontology_submissions_controller.rb +++ b/test/controllers/test_ontology_submissions_controller.rb @@ -201,20 +201,15 @@ def test_download_submission sub = ont.submissions.first sub.bring(:submissionId) assert_instance_of(OntologySubmission, sub, "sub is not a #{OntologySubmission.class}") - # Clear restrictions on downloads - LinkedData::OntologiesAPI.settings.restrict_download = [] # Download the specific submission get "/ontologies/#{ont.acronym}/submissions/#{sub.submissionId}/download" assert_equal(200, last_response.status, "failed download for specific submission : " + get_errors(last_response)) - # Add restriction on download + # With the acronym restricted, the same download should fail with a 403. acronym = created_ont_acronyms.first - LinkedData::OntologiesAPI.settings.restrict_download = [acronym] - # Try download - get "/ontologies/#{ont.acronym}/submissions/#{sub.submissionId}/download" - # download should fail with a 403 status - assert_equal(403, last_response.status, "failed to restrict download for ontology : " + get_errors(last_response)) - # Clear restrictions on downloads - LinkedData::OntologiesAPI.settings.restrict_download = [] + with_settings(LinkedData::OntologiesAPI.settings, restrict_download: [acronym]) do + get "/ontologies/#{ont.acronym}/submissions/#{sub.submissionId}/download" + assert_equal(403, last_response.status, "failed to restrict download for ontology : " + get_errors(last_response)) + end # see also test_ontologies_controller::test_download_ontology # Test downloads of nonexistent ontology @@ -265,20 +260,19 @@ def test_download_acl_only ont.viewingRestriction = "private" ont.save - LinkedData.settings.enable_security = true + with_settings(enable_security: true) do + get "/ontologies/#{acronym}/submissions/#{sub.submissionId}/download?apikey=#{allowed_user.apikey}" + assert_equal(200, last_response.status, "User who is in ACL couldn't download ontology") - get "/ontologies/#{acronym}/submissions/#{sub.submissionId}/download?apikey=#{allowed_user.apikey}" - assert_equal(200, last_response.status, "User who is in ACL couldn't download ontology") + get "/ontologies/#{acronym}/submissions/#{sub.submissionId}/download?apikey=#{blocked_user.apikey}" + assert_equal(403, last_response.status, "User who isn't in ACL could download ontology") - get "/ontologies/#{acronym}/submissions/#{sub.submissionId}/download?apikey=#{blocked_user.apikey}" - assert_equal(403, last_response.status, "User who isn't in ACL could download ontology") - - admin = ont.administeredBy.first - admin.bring(:apikey) - get "/ontologies/#{acronym}/submissions/#{sub.submissionId}/download?apikey=#{admin.apikey}" - assert_equal(200, last_response.status, "Admin couldn't download ontology") + admin = ont.administeredBy.first + admin.bring(:apikey) + get "/ontologies/#{acronym}/submissions/#{sub.submissionId}/download?apikey=#{admin.apikey}" + assert_equal(200, last_response.status, "Admin couldn't download ontology") + end ensure - LinkedData.settings.enable_security = false del = User.find("allowed").first del.delete if del del = User.find("blocked").first @@ -390,19 +384,18 @@ def test_ontology_submissions_access_controller ont.viewingRestriction = 'private' ont.save - LinkedData.settings.enable_security = true - - get "/submissions?apikey=#{allowed_user.apikey}" - assert_equal 200, last_response.status - submissions = MultiJson.load(last_response.body) - assert_equal 2, submissions.size - - get "/submissions?apikey=#{blocked_user.apikey}" - assert_equal 200, last_response.status - submissions = MultiJson.load(last_response.body) - assert_equal 1, submissions.size + with_settings(enable_security: true) do + get "/submissions?apikey=#{allowed_user.apikey}" + assert_equal 200, last_response.status + submissions = MultiJson.load(last_response.body) + assert_equal 2, submissions.size + + get "/submissions?apikey=#{blocked_user.apikey}" + assert_equal 200, last_response.status + submissions = MultiJson.load(last_response.body) + assert_equal 1, submissions.size + end ensure - LinkedData.settings.enable_security = false del = User.find('allowed').first del.delete if del del = User.find('blocked').first diff --git a/test/controllers/test_search_models_controller.rb b/test/controllers/test_search_models_controller.rb index 149b7f535..54e3dcd16 100644 --- a/test/controllers/test_search_models_controller.rb +++ b/test/controllers/test_search_models_controller.rb @@ -113,9 +113,7 @@ def test_search_security bro.viewingRestriction = "private" bro.save - begin - self.class.enable_security - + with_settings(enable_security: true) do get "/search/ontologies?query=#{bro.acronym}&apikey=#{blocked_user.apikey}" response = MultiJson.load(last_response.body)["collection"] assert_empty response.select{|x| x["ontology_acronym_text"].eql?(bro.acronym)} @@ -133,10 +131,6 @@ def test_search_security assert last_response.ok? res = MultiJson.load(last_response.body) assert_equal 1, res['totalCount'] - - self.class.reset_security(false) - ensure - self.class.disable_security end end diff --git a/test/controllers/test_slices_controller.rb b/test/controllers/test_slices_controller.rb index f4e3193ea..14b2f92f8 100644 --- a/test/controllers/test_slices_controller.rb +++ b/test/controllers/test_slices_controller.rb @@ -14,17 +14,17 @@ def before_suite password: "12345" }).save @@new_slice_data = { acronym: 'tst-c', name: "Test Slice C", ontologies: ont_acronyms} - self.class.enable_security end def after_suite LinkedData::Models::Slice.all.each(&:delete) @@user.delete - self.class.reset_security end def setup - self.class.reset_security + # Security is off at the per-test baseline (restored by the snapshot net), + # so reset_to_not_admin's save is allowed and the unauthenticated reads in + # test_all_slices work; tests that need security enable it via with_settings. self.class.reset_to_not_admin(@@user) LinkedData::Models::Slice.find(@@new_slice_data[:acronym]).first&.delete end @@ -37,43 +37,42 @@ def test_all_slices end def test_create_slices - self.class.enable_security + with_settings(enable_security: true) do + post "/slices?apikey=#{@@user.apikey}", MultiJson.dump(@@new_slice_data), "CONTENT_TYPE" => "application/json" + assert_equal 403, last_response.status - post "/slices?apikey=#{@@user.apikey}", MultiJson.dump(@@new_slice_data), "CONTENT_TYPE" => "application/json" - assert_equal 403, last_response.status + self.class.make_admin(@@user) - self.class.make_admin(@@user) + post "/slices?apikey=#{@@user.apikey}", MultiJson.dump(@@new_slice_data), "CONTENT_TYPE" => "application/json" - post "/slices?apikey=#{@@user.apikey}", MultiJson.dump(@@new_slice_data), "CONTENT_TYPE" => "application/json" + assert_equal 201, last_response.status - assert_equal 201, last_response.status - - # Verify created - get "/slices?apikey=#{@@user.apikey}" - assert_equal 200, last_response.status - slices = MultiJson.load(last_response.body) - assert_includes slices.map { |s| s["acronym"] }, @@new_slice_data[:acronym] + # Verify created + get "/slices?apikey=#{@@user.apikey}" + assert_equal 200, last_response.status + slices = MultiJson.load(last_response.body) + assert_includes slices.map { |s| s["acronym"] }, @@new_slice_data[:acronym] + end end def test_delete_slices - self.class.enable_security - # LinkedData.settings.enable_security = @@old_security_setting - self.class._create_slice(@@new_slice_data[:acronym], @@new_slice_data[:name], @@onts) + with_settings(enable_security: true) do + self.class._create_slice(@@new_slice_data[:acronym], @@new_slice_data[:name], @@onts) + delete "/slices/#{@@new_slice_data[:acronym]}?apikey=#{@@user.apikey}" + assert_equal 403, last_response.status - delete "/slices/#{@@new_slice_data[:acronym]}?apikey=#{@@user.apikey}" - assert_equal 403, last_response.status + self.class.make_admin(@@user) - self.class.make_admin(@@user) - - delete "/slices/#{@@new_slice_data[:acronym]}?apikey=#{@@user.apikey}" - assert_equal 204, last_response.status + delete "/slices/#{@@new_slice_data[:acronym]}?apikey=#{@@user.apikey}" + assert_equal 204, last_response.status # Verify deleted - get "/slices?apikey=#{@@user.apikey}" - assert_equal 200, last_response.status - slices = MultiJson.load(last_response.body) - refute_includes slices.map { |s| s["acronym"] }, @@new_slice_data[:acronym] + get "/slices?apikey=#{@@user.apikey}" + assert_equal 200, last_response.status + slices = MultiJson.load(last_response.body) + refute_includes slices.map { |s| s["acronym"] }, @@new_slice_data[:acronym] + end end private diff --git a/test/controllers/test_users_controller.rb b/test/controllers/test_users_controller.rb index aac840299..bb9555472 100644 --- a/test/controllers/test_users_controller.rb +++ b/test/controllers/test_users_controller.rb @@ -228,17 +228,17 @@ def test_update_patch_user end def test_delete_user - self.class.enable_security + with_settings(enable_security: true) do + delete "/users/ben?apikey=#{@@users.first.apikey}" + assert_equal 403, last_response.status - delete "/users/ben?apikey=#{@@users.first.apikey}" - assert_equal 403, last_response.status - - self.class.make_admin(@@users.first) - delete "/users/ben?apikey=#{@@users.first.apikey}" - assert_equal 204, last_response.status + self.class.make_admin(@@users.first) + delete "/users/ben?apikey=#{@@users.first.apikey}" + assert_equal 204, last_response.status + end @@usernames.delete("ben") - self.class.reset_security + # Security is back at baseline (off) here, so the role reset's save is allowed. self.class.reset_to_not_admin(@@users.first) get "/users/ben" diff --git a/test/helpers/test_access_control_helper.rb b/test/helpers/test_access_control_helper.rb index b008f9fcd..b2eac1e48 100644 --- a/test/helpers/test_access_control_helper.rb +++ b/test/helpers/test_access_control_helper.rb @@ -4,8 +4,7 @@ class TestAccessControlHelper < TestCaseHelpers # Class instance vars with readers/writers class << self attr_accessor :usernames, :admin, :user1, :user2, :user3, :user, - :restricted_ont, :ont, :ont_patch, :restricted_user, - :old_security_setting + :restricted_ont, :ont, :ont_patch, :restricted_user end def before_suite @@ -45,15 +44,14 @@ def before_suite self.class.user = self.class.ont.administeredBy.first self.class.user.bring_remaining - self.class.old_security_setting = LinkedData.settings.enable_security self.class.ont_patch = onts.shift.bring_remaining + # enable_security is restored by the per-suite snapshot net (see AppUnit). LinkedData.settings.enable_security = true end def after_suite self.backend_4s_delete - LinkedData.settings.enable_security = self.class.old_security_setting unless self.class.old_security_setting.nil? end def test_filtered_list diff --git a/test/helpers/test_http_cache_helper.rb b/test/helpers/test_http_cache_helper.rb index 8a88cc556..c31174830 100644 --- a/test/helpers/test_http_cache_helper.rb +++ b/test/helpers/test_http_cache_helper.rb @@ -35,12 +35,11 @@ def before_suite }) @@note_alt.save - @orig_enable_cache = LinkedData.settings.enable_http_cache + # enable_http_cache is restored by the per-suite snapshot net (see AppUnit). LinkedData.settings.enable_http_cache = true end def after_suite - LinkedData.settings.enable_http_cache = @orig_enable_cache LinkedData::HTTPCache.invalidate_all end diff --git a/test/helpers/test_slices_helper.rb b/test/helpers/test_slices_helper.rb index 92f051c91..1d7e9d025 100644 --- a/test/helpers/test_slices_helper.rb +++ b/test/helpers/test_slices_helper.rb @@ -6,7 +6,7 @@ def before_suite self.backend_4s_delete LinkedData::Models::Class.indexClear - @@orig_slices_setting = LinkedData.settings.enable_slices + # enable_slices is restored by the per-suite snapshot net (see AppUnit). LinkedData.settings.enable_slices = true @@onts = LinkedData::SampleData::Ontology.create_ontologies_and_submissions(ont_count: 5, submission_count: 0)[2] @@group_acronym = "test-group" diff --git a/test/helpers/test_users_helper.rb b/test/helpers/test_users_helper.rb index e3e4e5dcf..9a2f04ae2 100644 --- a/test/helpers/test_users_helper.rb +++ b/test/helpers/test_users_helper.rb @@ -26,14 +26,10 @@ def before_suite @@custom_ont_ids = [@@user_ont_search.id.to_s, @@user_ont.id.to_s] - @@old_security_setting = LinkedData.settings.enable_security + # enable_security is restored by the per-suite snapshot net (see AppUnit). LinkedData.settings.enable_security = true end - def after_suite - LinkedData.settings.enable_security = @@old_security_setting - end - def test_filtered_list get "/ontologies?apikey=#{@@user.apikey}" assert last_response.ok? diff --git a/test/middleware/test_rack_attack.rb b/test/middleware/test_rack_attack.rb index 540f8a983..ba9435c51 100644 --- a/test/middleware/test_rack_attack.rb +++ b/test/middleware/test_rack_attack.rb @@ -7,12 +7,8 @@ class TestRackAttack < TestCase def before_suite - # Store app settings - @@auth_setting = LinkedData.settings.enable_security - @@throttling_setting = LinkedData.settings.enable_throttling - @@req_per_sec_limit = LinkedData::OntologiesAPI.settings.req_per_second_per_ip - @@safe_ips = LinkedData::OntologiesAPI.settings.safe_ips - + # Settings are restored automatically by the per-suite snapshot net (see + # AppUnit), so before_suite just sets what the suite needs. LinkedData.settings.enable_security = true LinkedData::OntologiesAPI.settings.enable_throttling = true LinkedData::OntologiesAPI.settings.req_per_second_per_ip = 1 @@ -62,12 +58,7 @@ def before_suite end def after_suite - # Restore app settings - LinkedData.settings.enable_security = @@auth_setting - LinkedData::OntologiesAPI.settings.enable_throttling = @@throttling_setting - LinkedData::OntologiesAPI.settings.req_per_second_per_ip = @@req_per_sec_limit - LinkedData::OntologiesAPI.settings.safe_ips = @@safe_ips - + # Settings are restored by the per-suite snapshot net (see AppUnit). Process.kill("TERM", @@pid1) Process.wait(@@pid1) Process.kill("TERM", @@pid2) diff --git a/test/middleware/test_trailing_slash_redirect.rb b/test/middleware/test_trailing_slash_redirect.rb new file mode 100644 index 000000000..d32e59327 --- /dev/null +++ b/test/middleware/test_trailing_slash_redirect.rb @@ -0,0 +1,127 @@ +require 'minitest/autorun' +require 'rack' +require 'rack/mock' +require_relative '../../lib/rack/trailing_slash_redirect' + +# Backend-free unit tests for Rack::TrailingSlashRedirect. The middleware is +# driven directly with Rack::MockRequest -- no app boot, no triplestore / Solr +# / Redis required. +class TestTrailingSlashRedirect < Minitest::Test + + DOWNSTREAM = "downstream-app-response".freeze + + def setup + @app = Rack::TrailingSlashRedirect.new( + ->(_env) { [200, { 'content-type' => 'text/plain' }, [DOWNSTREAM]] } + ) + end + + # Returns [status, location_header, body_string, env]. + def call(method, path, env_overrides = {}) + env = Rack::MockRequest.env_for(path, { method: method }.merge(env_overrides)) + status, headers, body = @app.call(env) + buf = +'' + body.each { |chunk| buf << chunk } + [status, headers['location'], buf, env] + end + + def host(env) + Rack::Request.new(env).host_with_port + end + + # --- verb split: 301 for GET/HEAD, 308 for everything else ----------------- + + def test_get_trailing_slash_redirects_301 + status, location, _body, env = call('GET', '/ontologies/STY/') + assert_equal 301, status + assert_equal "http://#{host(env)}/ontologies/STY", location + end + + def test_non_get_verbs_redirect_308_preserving_location + %w[POST PUT PATCH DELETE].each do |verb| + status, location, _body, env = call(verb, '/mappings/') + assert_equal 308, status, + "#{verb} must use 308 (301 would let clients drop the method/body)" + assert_equal "http://#{host(env)}/mappings", location + end + end + + def test_head_trailing_slash_redirects_301_with_empty_body + status, location, body, env = call('HEAD', '/ontologies/STY/') + assert_equal 301, status + assert_equal "http://#{host(env)}/ontologies/STY", location + assert_equal '', body, 'a HEAD response must not carry a body' + end + + # --- pass-through: no redirect for non-trailing-slash paths or root -------- + + def test_no_trailing_slash_passes_through_untouched + status, _location, body, = call('GET', '/ontologies/STY') + assert_equal 200, status + assert_equal DOWNSTREAM, body + end + + def test_root_passes_through_untouched + status, _location, body, = call('GET', '/') + assert_equal 200, status + assert_equal DOWNSTREAM, body + end + + def test_empty_path_info_passes_through_untouched + # The Rack SPEC minimum: PATH_INFO may be the empty string (e.g. a request + # for the mount point of a mapped app), never nil. + env = Rack::MockRequest.env_for('/', method: 'GET') + env['PATH_INFO'] = '' + status, _headers, body = @app.call(env) + buf = +'' + body.each { |chunk| buf << chunk } + assert_equal 200, status + assert_equal DOWNSTREAM, buf + end + + # --- location building: query, percent-encoding, forwarded scheme ---------- + + def test_empty_query_string_appends_no_question_mark + # QUERY_STRING is the empty string (never nil) when there is no query. + _status, location, _body, env = call('GET', '/ontologies/STY/') + assert_equal '', env['QUERY_STRING'] + refute_includes location, '?' + end + + def test_query_string_preserved + _status, location, _body, env = call('GET', '/ontologies/STY/?apikey=xyz&include=all') + assert_equal "http://#{host(env)}/ontologies/STY?apikey=xyz&include=all", location + end + + def test_percent_encoded_path_segments_preserved + enc = 'http%3A%2F%2Fpurl.bioontology.org%2Fontology%2FSTY%2FT071' + _status, location, _body, env = call('GET', "/ontologies/STY/classes/#{enc}/paths_to_root/") + assert_equal "http://#{host(env)}/ontologies/STY/classes/#{enc}/paths_to_root", location + end + + def test_forwarded_proto_used_for_scheme + _status, location, _body, env = call('GET', '/ontologies/STY/', 'HTTP_X_FORWARDED_PROTO' => 'https') + assert_equal "https://#{host(env)}/ontologies/STY", location + end + + def test_chained_forwarded_proto_uses_first_value + _status, location, _body, env = call('GET', '/ontologies/STY/', 'HTTP_X_FORWARDED_PROTO' => 'https, http') + assert_equal "https://#{host(env)}/ontologies/STY", location + end + + def test_invalid_forwarded_proto_falls_back_to_request_scheme + _status, location, _body, env = call('GET', '/ontologies/STY/', 'HTTP_X_FORWARDED_PROTO' => 'javascript') + assert_equal "http://#{host(env)}/ontologies/STY", location + end + + # --- caching: the scheme-dependent redirect must not be stored by Rack::Cache. + # The Location is built from X-Forwarded-Proto, but an outer Rack::Cache keys + # entries on path+query only, so a cached redirect could leak the wrong scheme + # across http/https clients. no-store keeps it out of the shared cache. + + def test_redirect_sets_cache_control_no_store + env = Rack::MockRequest.env_for('/ontologies/STY/', method: 'GET') + _status, headers, _body = @app.call(env) + assert_equal 'no-store', headers['cache-control'] + end +end diff --git a/test/test_case.rb b/test/test_case.rb index 5a7aa7ad4..2fa3edb2a 100644 --- a/test/test_case.rb +++ b/test/test_case.rb @@ -109,14 +109,80 @@ def after_suite # code to run after the last test (gets inherited in sub-tests) end + # --- Test isolation for process-wide settings ------------------------------ + # + # Settings like enable_security live on global singletons (LinkedData.settings + # et al.), so a test or suite that flips one -- or errors after flipping it -- + # would otherwise leak the changed value into whatever runs next. Three things + # guard against that, so suites can just set what they need and never restore: + # + # 1. with_settings -- the sanctioned way to change a setting for the + # duration of a block; restores the prior value even if the block raises. + # 2. a per-test snapshot/restore net (before_setup/after_teardown). + # 3. a per-suite snapshot/restore net (before_all/after_all), so values set + # in before_suite are rolled back when the suite finishes. + + # Process-wide settings that suites flip on/off, as [settings_object, attr]. + # Resolved at call time (not a constant) so every settings singleton is loaded. + def sandboxed_settings + [ + [LinkedData.settings, :enable_security], + [LinkedData.settings, :enable_http_cache], + [LinkedData.settings, :enable_slices], + [LinkedData::OntologiesAPI.settings, :restrict_download], + [LinkedData::OntologiesAPI.settings, :enable_throttling], + [LinkedData::OntologiesAPI.settings, :req_per_second_per_ip], + [LinkedData::OntologiesAPI.settings, :safe_ips], + [Annotator.settings, :supported_recognizers] + ] + end + + def snapshot_settings + sandboxed_settings.map { |obj, attr| [obj, attr, obj.send(attr)] } + end + + def restore_settings(snapshot) + snapshot&.each { |obj, attr, value| obj.send("#{attr}=", value) } + end + + # Temporarily override settings for the duration of the block, restoring the + # prior values even if the block raises -- the sanctioned way for a test to + # change a process-wide setting (bare assignment leaks across tests). + # + # with_settings(enable_security: true) { post "/slices", ... } + # with_settings(LinkedData::OntologiesAPI.settings, enable_throttling: true) { ... } + def with_settings(settings = LinkedData.settings, **overrides) + previous = overrides.keys.to_h { |k| [k, settings.send(k)] } + overrides.each { |k, v| settings.send("#{k}=", v) } + yield + ensure + previous.each { |k, v| settings.send("#{k}=", v) } + end + + def before_setup + super + @settings_snapshot = snapshot_settings + end + + def after_teardown + restore_settings(@settings_snapshot) + super + end + def before_all super backend_4s_delete + # Snapshot before before_suite runs so suite-level settings roll back too. + self.class.instance_variable_set(:@suite_settings_snapshot, snapshot_settings) before_suite end def after_all - self.class.disable_security + # Restore settings BEFORE after_suite: teardown often does privileged + # cleanup (deleting users/ontologies) that must run with the suite's + # settings rolled back (e.g. security off). Restoring first also means a + # raising after_suite can't leak a flipped setting into the next suite. + restore_settings(self.class.instance_variable_get(:@suite_settings_snapshot)) after_suite super end @@ -210,19 +276,6 @@ def get_errors(response) return errors.strip end - def self.enable_security - @@old_security_setting = LinkedData.settings.enable_security - LinkedData.settings.enable_security = true - end - - def self.disable_security - LinkedData.settings.enable_security = false - end - - def self.reset_security(old_security = @@old_security_setting) - LinkedData.settings.enable_security = old_security - end - # Ensure a user exists; return it. Safe to call from anywhere. def self.create_user(username, email: nil, password: "password") user = User.new(username: username, email: email || "#{username}@example.org", password: password)