diff --git a/.env.example b/.env.example index 837ef684..76c02d8a 100644 --- a/.env.example +++ b/.env.example @@ -88,3 +88,7 @@ ALLOW_VERSIONS_IN_PAGE_RESPONSES='false' # Set to 'true' to require versions to have valid mime/content/media type fields # in order to be automatically analyzed after importing. # ANALYSIS_REQUIRE_MEDIA_TYPE='false' + +# Put the API into read-only mode, where imports, annotations, and other changes +# to data are blocked. +# API_READ_ONLY='true' diff --git a/app/controllers/api/v0/annotations_controller.rb b/app/controllers/api/v0/annotations_controller.rb index 6324f987..f08960b5 100644 --- a/app/controllers/api/v0/annotations_controller.rb +++ b/app/controllers/api/v0/annotations_controller.rb @@ -37,6 +37,8 @@ def show end def create + raise Api::ReadOnlyError if Rails.configuration.read_only + begin data = JSON.parse(request.body.read) rescue JSON::ParserError diff --git a/app/controllers/api/v0/imports_controller.rb b/app/controllers/api/v0/imports_controller.rb index c0ff55a3..35aae8f8 100644 --- a/app/controllers/api/v0/imports_controller.rb +++ b/app/controllers/api/v0/imports_controller.rb @@ -10,6 +10,8 @@ def show end def create + raise Api::ReadOnlyError if Rails.configuration.read_only + update_behavior = params[:update] || :skip unless Import.update_behaviors.key?(update_behavior) raise Api::InputError, "'#{update_behavior}' is not a valid update behavior. Use one of: #{Import.update_behaviors.join(', ')}" diff --git a/app/controllers/api/v0/maintainers_controller.rb b/app/controllers/api/v0/maintainers_controller.rb index de6b500e..50878b3f 100644 --- a/app/controllers/api/v0/maintainers_controller.rb +++ b/app/controllers/api/v0/maintainers_controller.rb @@ -30,6 +30,8 @@ def show end def create + raise Api::ReadOnlyError if Rails.configuration.read_only + data = JSON.parse(request.body.read) if data['uuid'].nil? && data['name'].nil? raise Api::InputError, 'You must specify either a `uuid` or `name` for the maintainer to add.' @@ -64,6 +66,8 @@ def create end def update + raise Api::ReadOnlyError if Rails.configuration.read_only + @maintainer = (page ? page.maintainers : Maintainer).find(params[:id]) data = JSON.parse(request.body.read).slice('name', 'parent_id') @maintainer.update!(data) @@ -71,6 +75,8 @@ def update end def destroy + raise Api::ReadOnlyError if Rails.configuration.read_only + # NOTE: this assumes you can only get here in the context of a page page.remove_maintainer(Maintainer.find(params[:id])) redirect_to(api_v0_page_maintainers_url(page)) diff --git a/app/controllers/api/v0/tags_controller.rb b/app/controllers/api/v0/tags_controller.rb index 76fb91a6..02c33b90 100644 --- a/app/controllers/api/v0/tags_controller.rb +++ b/app/controllers/api/v0/tags_controller.rb @@ -24,6 +24,8 @@ def show end def create + raise Api::ReadOnlyError if Rails.configuration.read_only + data = JSON.parse(request.body.read) if data['uuid'].nil? && data['name'].nil? raise Api::InputError, 'You must specify either a `uuid` or `name` for the tag to add.' @@ -43,6 +45,8 @@ def create end def update + raise Api::ReadOnlyError if Rails.configuration.read_only + @tag = (page ? page.tags : Tag).find(params[:id]) data = JSON.parse(request.body.read) @tag.update(name: data['name']) @@ -50,6 +54,8 @@ def update end def destroy + raise Api::ReadOnlyError if Rails.configuration.read_only + # NOTE: this assumes you can only get here in the context of a page page.untag(Tag.find(params[:id])) redirect_to(api_v0_page_tags_url(page)) diff --git a/app/controllers/api/v0/urls_controller.rb b/app/controllers/api/v0/urls_controller.rb index 974632ec..bae56a7f 100644 --- a/app/controllers/api/v0/urls_controller.rb +++ b/app/controllers/api/v0/urls_controller.rb @@ -22,6 +22,8 @@ def show end def create + raise Api::ReadOnlyError if Rails.configuration.read_only + @page_url = page.urls.create!(url_params) show rescue ActiveRecord::RecordNotUnique @@ -29,6 +31,8 @@ def create end def update + raise Api::ReadOnlyError if Rails.configuration.read_only + updates = url_params if updates.key?(:url) raise Api::UnprocessableError, 'You cannot change a URL\'s `url`' @@ -40,6 +44,8 @@ def update end def destroy + raise Api::ReadOnlyError if Rails.configuration.read_only + @page_url ||= page.urls.find(params[:id]) # You cannot delete the canonical URL. if @page_url.url == page.url diff --git a/app/controllers/api/v0/versions_controller.rb b/app/controllers/api/v0/versions_controller.rb index 3b47f8a1..d64c93bb 100644 --- a/app/controllers/api/v0/versions_controller.rb +++ b/app/controllers/api/v0/versions_controller.rb @@ -132,6 +132,8 @@ def raw end def create + raise Api::ReadOnlyError if Rails.configuration.read_only + authorize(:api, :import?) # TODO: unify this with import code in ImportVersionsJob#import_record diff --git a/config/application.rb b/config/application.rb index 692e68f0..6f5659bc 100644 --- a/config/application.rb +++ b/config/application.rb @@ -66,5 +66,9 @@ class Application < Rails::Application config.allow_public_view = ActiveModel::Type::Boolean.new.cast( ENV.fetch('ALLOW_PUBLIC_VIEW', 'true') ).present? + + config.read_only = ActiveModel::Type::Boolean.new.cast( + ENV.fetch('API_READ_ONLY', 'true') + ).present? end end diff --git a/lib/api/api.rb b/lib/api/api.rb index 0dfff41b..6587bb29 100644 --- a/lib/api/api.rb +++ b/lib/api/api.rb @@ -62,4 +62,14 @@ def initialize(url, hash) super("Response body for '#{url}' did not match expected hash (#{hash})") end end + + class ReadOnlyError < ApiError + def status_code + 423 + end + + def initialize(message = 'This API is read-only; you cannot add or update data.') + super(message) + end + end end diff --git a/test/controllers/api/v0/annotations_controller_test.rb b/test/controllers/api/v0/annotations_controller_test.rb index 97dbc67e..9da60fec 100644 --- a/test/controllers/api/v0/annotations_controller_test.rb +++ b/test/controllers/api/v0/annotations_controller_test.rb @@ -49,6 +49,22 @@ class Api::V0::AnnotationsControllerTest < ActionDispatch::IntegrationTest assert_equal annotation, body['data']['annotation'] end + test 'cannot annotate in read-only mode' do + page = pages(:home_page) + annotation = { 'test_key' => 'test_value' } + + with_rails_configuration(:read_only, true) do + sign_in users(:alice) + post( + api_v0_page_change_annotations_path(page, "..#{page.versions[0].uuid}"), + as: :json, + params: annotation + ) + + assert_response :locked + end + end + test 'posting a new annotation updates previous annotations by the same user' do page = pages(:home_page) annotation1 = { 'test_key' => 'test_value' } diff --git a/test/controllers/api/v0/imports_controller_test.rb b/test/controllers/api/v0/imports_controller_test.rb index c308ffa7..23b24032 100644 --- a/test/controllers/api/v0/imports_controller_test.rb +++ b/test/controllers/api/v0/imports_controller_test.rb @@ -149,6 +149,49 @@ def teardown assert_equal(import_data[1][:page_url], versions[0].url) end + test 'cannot import in read-only mode' do + import_data = [ + { + page_url: 'http://testsite.com/', + title: 'Example Page', + page_maintainers: ['The Federal Example Agency'], + page_tags: ['Example Site'], + capture_time: '2017-05-01T12:33:01Z', + body_url: 'https://test-bucket.s3.amazonaws.com/example-v1', + body_hash: 'f366e89639758cd7f75d21e5026c04fb1022853844ff471865004b3274059686', + source_type: 'some_source', + source_metadata: { test_meta: 'data' } + }, + { + page_url: 'http://testsite.com/', + title: 'Example Page', + page_maintainers: ['The Federal Example Agency'], + page_tags: ['Test', 'Home Page'], + capture_time: '2017-05-02T12:33:01Z', + body_url: 'https://test-bucket.s3.amazonaws.com/example-v2', + body_hash: 'f366e89639758cd7f75d21e5026c04fb1022853844ff471865004b3274059687', + source_type: 'some_source', + source_metadata: { test_meta: 'data' } + } + ] + + start_version_count = Version.count + + with_rails_configuration(:read_only, true) do + sign_in users(:alice) + perform_enqueued_jobs do + post( + api_v0_imports_path, + headers: { 'Content-Type': 'application/x-json-stream' }, + params: import_data.map(&:to_json).join("\n") + ) + end + + assert_response :locked + assert_equal Version.count, start_version_count + end + end + test 'does not add or modify a version if it already exists' do page_versions_count = pages(:home_page).versions.count original_data = versions(:page1_v1).as_json diff --git a/test/controllers/api/v0/maintainers_controller_test.rb b/test/controllers/api/v0/maintainers_controller_test.rb index 6aec541f..74eda26d 100644 --- a/test/controllers/api/v0/maintainers_controller_test.rb +++ b/test/controllers/api/v0/maintainers_controller_test.rb @@ -114,6 +114,18 @@ class Api::V0::MaintainersControllerTest < ActionDispatch::IntegrationTest assert_response(:forbidden) end + test 'cannot add a maintainer in read-only mode' do + with_rails_configuration(:read_only, true) do + sign_in users(:alice) + post( + api_v0_maintainers_path, + as: :json, + params: { name: 'EPA' } + ) + assert_response(:locked) + end + end + test 'can add a maintainer to a page' do sign_in users(:alice) post( diff --git a/test/controllers/api/v0/tags_controller_test.rb b/test/controllers/api/v0/tags_controller_test.rb index 028bcf26..011db251 100644 --- a/test/controllers/api/v0/tags_controller_test.rb +++ b/test/controllers/api/v0/tags_controller_test.rb @@ -77,6 +77,18 @@ class Api::V0::TagsControllerTest < ActionDispatch::IntegrationTest assert_response(:forbidden) end + test 'cannot add a tag in read-only mode' do + with_rails_configuration(:read_only, true) do + sign_in users(:alice) + post( + api_v0_page_tags_path(pages(:home_page)), + as: :json, + params: { name: 'Page of wonderment' } + ) + assert_response(:locked) + end + end + test 'can add a tag to a page' do sign_in users(:alice) post( diff --git a/test/controllers/api/v0/urls_controller_test.rb b/test/controllers/api/v0/urls_controller_test.rb index 4ad77565..6b72068f 100644 --- a/test/controllers/api/v0/urls_controller_test.rb +++ b/test/controllers/api/v0/urls_controller_test.rb @@ -47,6 +47,18 @@ class Api::V0::UrlsControllerTest < ActionDispatch::IntegrationTest assert_response(:forbidden) end + test 'cannot create new urls in read-only mode' do + with_rails_configuration(:read_only, true) do + sign_in users(:alice) + post( + api_v0_page_urls_path(pages(:home_page)), + as: :json, + params: { page_url: { url: 'https://example.gov/new_url' } } + ) + assert_response :locked + end + end + test 'can create new urls' do sign_in users(:alice) post( diff --git a/test/test_helper.rb b/test/test_helper.rb index b145fd91..ecc32625 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -7,6 +7,11 @@ class ActiveSupport::TestCase # Setup all fixtures in test/fixtures/*.yml for all tests in alphabetical order. fixtures :all + def setup + # Assume writability. Individual tests can opt-in to read-only. + Rails.configuration.read_only = false + end + # Add more helper methods to be used by all tests here... def assert_ordered(list, reverse: false, name: 'Items') sorted = list.sort