diff --git a/.circleci/config.yml b/.circleci/config.yml index e377232..9332a87 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -41,6 +41,11 @@ jobs: POSTGRES_USER: "circleci" POSTGRES_DB: "safer_rails_console_test" POSTGRES_HOST_AUTH_METHOD: "trust" + - image: cimg/mysql:8.0 + environment: + MYSQL_DATABASE: "safer_rails_console_test" + MYSQL_ROOT_HOST: "%" + MYSQL_ALLOW_EMPTY_PASSWORD: "yes" working_directory: ~/safer_rails_console steps: - checkout @@ -60,6 +65,9 @@ jobs: paths: - "vendor/bundle" - "gemfiles/vendor/bundle" + - run: + name: Wait for Mysql + command: dockerize -wait tcp://localhost:3306 -timeout 1m - run: name: Run Tests command: | diff --git a/.gitignore b/.gitignore index 4bcfe49..1ee9064 100644 --- a/.gitignore +++ b/.gitignore @@ -15,3 +15,5 @@ /gemfiles/*.gemfile.lock out *.sqlite3 + +.idea diff --git a/CHANGELOG.md b/CHANGELOG.md index 76335b8..03c49c3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,8 @@ # Changelog +## v0.9.0 +- Add MySql support + ## v0.8.0 - Drop support for Ruby 2.7. - Drop support for Rails 6.0. diff --git a/README.md b/README.md index 4c058a8..b63ef4f 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ [![Build Status](https://circleci.com/gh/salsify/safer_rails_console.svg?style=svg)](https://circleci.com/gh/salsify/safer_rails_console) [![Gem Version](https://badge.fury.io/rb/safer_rails_console.svg)](https://badge.fury.io/rb/safer_rails_console) -This gem makes Rails console sessions less dangerous in specified environments by warning, color-coding, and auto-sandboxing PostgreSQL connections. In the future we'd like to extend this to make other external connections read-only too (e.g. disable job queueing, non-GET HTTP requests, etc.) +This gem makes Rails console sessions less dangerous in specified environments by warning, color-coding, and auto-sandboxing PostgreSQL and MySQL connections. In the future we'd like to extend this to make other external connections read-only too (e.g. disable job queueing, non-GET HTTP requests, etc.) ## Installation diff --git a/lib/safer_rails_console/patches/sandbox/auto_rollback.rb b/lib/safer_rails_console/patches/sandbox/auto_rollback.rb index 1fbdfb8..b1d90a4 100644 --- a/lib/safer_rails_console/patches/sandbox/auto_rollback.rb +++ b/lib/safer_rails_console/patches/sandbox/auto_rollback.rb @@ -11,8 +11,8 @@ def self.rollback_and_begin_new_transaction connection.begin_db_transaction end - def self.handle_and_reraise_exception(error) - if error.message.include?('PG::ReadOnlySqlTransaction') + def self.handle_and_reraise_exception(error, message = 'PG::ReadOnlySqlTransaction') + if error.message.include?(message) puts SaferRailsConsole::Colors.color_text( # rubocop:disable Rails/Output 'An operation could not be completed due to read-only mode.', SaferRailsConsole::Colors::RED @@ -28,13 +28,27 @@ module PostgreSQLAdapterPatch def execute_and_clear(...) super rescue StandardError => e - SaferRailsConsole::Patches::Sandbox::AutoRollback.handle_and_reraise_exception(e) + # rubocop:disable Layout/LineLength + SaferRailsConsole::Patches::Sandbox::AutoRollback.handle_and_reraise_exception(e, 'PG::ReadOnlySqlTransaction') + # rubocop:enable Layout/LineLength end end if defined?(::ActiveRecord::ConnectionAdapters::PostgreSQLAdapter) ::ActiveRecord::ConnectionAdapters::PostgreSQLAdapter.prepend(PostgreSQLAdapterPatch) end + + module MySQLPatch + def execute_and_free(...) + super + rescue StandardError => e + SaferRailsConsole::Patches::Sandbox::AutoRollback.handle_and_reraise_exception(e, 'READ ONLY transaction') + end + end + + if defined?(::ActiveRecord::ConnectionAdapters::AbstractMysqlAdapter) + ::ActiveRecord::ConnectionAdapters::AbstractMysqlAdapter.prepend(MySQLPatch) + end end end end diff --git a/lib/safer_rails_console/patches/sandbox/transaction_read_only.rb b/lib/safer_rails_console/patches/sandbox/transaction_read_only.rb index 37dba89..220a1fa 100644 --- a/lib/safer_rails_console/patches/sandbox/transaction_read_only.rb +++ b/lib/safer_rails_console/patches/sandbox/transaction_read_only.rb @@ -11,6 +11,13 @@ def begin_db_transaction end end + module MySQLPatch + def begin_db_transaction + execute 'SET TRANSACTION READ ONLY' + super + end + end + if defined?(::ActiveRecord::ConnectionAdapters::PostgreSQLAdapter) ::ActiveRecord::ConnectionAdapters::PostgreSQLAdapter.prepend(PostgreSQLAdapterPatch) @@ -18,6 +25,13 @@ def begin_db_transaction connection = ::ActiveRecord::Base.connection connection.execute 'SET TRANSACTION READ ONLY' if connection.open_transactions > 0 end + + if defined?(::ActiveRecord::ConnectionAdapters::AbstractMysqlAdapter) + ::ActiveRecord::ConnectionAdapters::AbstractMysqlAdapter.prepend(MySQLPatch) + + # Not possible to change a running transaction to read-only in MySQL + # https://dev.mysql.com/doc/refman/8.4/en/set-transaction.html + end end end end diff --git a/lib/safer_rails_console/version.rb b/lib/safer_rails_console/version.rb index 9158721..812400b 100644 --- a/lib/safer_rails_console/version.rb +++ b/lib/safer_rails_console/version.rb @@ -1,5 +1,5 @@ # frozen_string_literal: true module SaferRailsConsole - VERSION = '0.8.0' + VERSION = '0.9.0' end diff --git a/safer_rails_console.gemspec b/safer_rails_console.gemspec index 7140809..5765c13 100644 --- a/safer_rails_console.gemspec +++ b/safer_rails_console.gemspec @@ -39,6 +39,7 @@ Gem::Specification.new do |spec| spec.add_development_dependency 'bundler', '~> 2.0' spec.add_development_dependency 'climate_control', '~> 0.2.0' spec.add_development_dependency 'mixlib-shellout', '~> 2.2' + spec.add_development_dependency 'mysql2', '~> 0.5' spec.add_development_dependency 'overcommit', '~> 0.39.0' spec.add_development_dependency 'pg', '~> 1.1' spec.add_development_dependency 'rake', '~> 12.0' diff --git a/spec/contexts/db_sandbox.rb b/spec/contexts/db_sandbox.rb new file mode 100644 index 0000000..e07e34b --- /dev/null +++ b/spec/contexts/db_sandbox.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +shared_context "db sandbox context" do + let(:adapter) {} + + shared_examples_for "auto_rollback" do + it "automatically executes rollback and begins a new transaction after executing a invalid SQL statement" do + run_console_commands('Model.create!', 'Model.where(invalid: :statement)', 'Model.create!') + + # Run a new console session to ensure the database changes were not saved + result = run_console_commands("puts \"Model Count = \#{Model.count}\"") + expect(result.stdout).to include('Model Count = 0') + end + end + + shared_examples_for "read_only" do + it "enforces a read_only transaction" do + # Run a console session that makes some database changes + run_console_commands('Model.create!', 'Model.create!') + + # Run a new console session to ensure the database changes were not saved + result = run_console_commands("puts \"Model Count = \#{Model.count}\"") + expect(result.stdout).to include('Model Count = 0') + end + + it "lets the user know that an operation could not be completed" do + result = run_console_commands('Model.create!') + expect(result.stdout).to include('An operation could not be completed due to read-only mode.') + end + end + + def run_console_commands(*commands) + commands += ['exit'] + environment = "development#{adapter.nil? ? '' : "-#{adapter}"}" + run_console('--sandbox', input: commands.join("\n"), rails_env: environment) + end +end diff --git a/spec/integration/patches/sandbox_spec.rb b/spec/integration/patches/sandbox_spec.rb index e35438e..064f40a 100644 --- a/spec/integration/patches/sandbox_spec.rb +++ b/spec/integration/patches/sandbox_spec.rb @@ -1,34 +1,19 @@ # frozen_string_literal: true +require_relative '../../contexts/db_sandbox' + describe "Integration: patches/sandbox" do - context "auto_rollback" do - it "automatically executes rollback and begins a new transaction after executing a invalid SQL statement" do - run_console_commands('Model.create!', 'Model.where(invalid: :statement)', 'Model.create!') + include_context "db sandbox context" - # Run a new console session to ensure the database changes were not saved - result = run_console_commands('puts "Model Count = #{Model.count}"') # rubocop:disable Lint/InterpolationCheck - expect(result.stdout).to include('Model Count = 0') - end + context "for PostgreSQL" do + it_behaves_like "auto_rollback" + it_behaves_like "read_only" end - context "read_only" do - it "enforces a read_only transaction" do - # Run a console session that makes some database changes - run_console_commands('Model.create!', 'Model.create!') - - # Run a new console session to ensure the database changes were not saved - result = run_console_commands('puts "Model Count = #{Model.count}"') # rubocop:disable Lint/InterpolationCheck - expect(result.stdout).to include('Model Count = 0') - end - - it "lets the user know that an operation could not be completed" do - result = run_console_commands('Model.create!') - expect(result.stdout).to include('An operation could not be completed due to read-only mode.') - end - end + context "for Mysql" do + let(:adapter) { :mysql2 } - def run_console_commands(*commands) - commands += ['exit'] - run_console('--sandbox', input: commands.join("\n")) + it_behaves_like "auto_rollback" + it_behaves_like "read_only" end end diff --git a/spec/internal/rails_6_1/Gemfile b/spec/internal/rails_6_1/Gemfile index 548533e..7ad8983 100644 --- a/spec/internal/rails_6_1/Gemfile +++ b/spec/internal/rails_6_1/Gemfile @@ -2,6 +2,7 @@ source 'https://rubygems.org' +gem 'mysql2' gem 'pg' gem 'rails', '~> 6.1.7' diff --git a/spec/internal/rails_6_1/config/application.rb b/spec/internal/rails_6_1/config/application.rb index 0106462..47b17f3 100644 --- a/spec/internal/rails_6_1/config/application.rb +++ b/spec/internal/rails_6_1/config/application.rb @@ -33,5 +33,8 @@ class Application < Rails::Application # # config.time_zone = "Central Time (US & Canada)" # config.eager_load_paths << Rails.root.join("extras") + + # Do not eager load code on boot. + config.eager_load = false end end diff --git a/spec/internal/rails_6_1/config/database.yml b/spec/internal/rails_6_1/config/database.yml index a51500f..a098109 100644 --- a/spec/internal/rails_6_1/config/database.yml +++ b/spec/internal/rails_6_1/config/database.yml @@ -7,10 +7,22 @@ default: &default username: <%= ENV['DB_USER'] %> password: <%= ENV['DB_PASSWORD'] %> +mysql2: &mysql2 + adapter: mysql2 + timeout: 5000 + port: <%= ENV['MYSQL_DB_PORT'] || 3306 %> + host: <%= ENV['MYSQL_DB_HOST'] || '127.0.0.1' %> + username: <%= ENV['MYSQL_DB_USER'] || 'root' %> + password: <%= ENV['MYSQL_DB_PASSWORD'] %> + development: <<: *default database: safer_rails_console_development +development-mysql2: + <<: *mysql2 + database: safer_rails_console_development + test: <<: *default database: safer_rails_console_test diff --git a/spec/internal/rails_7_0/Gemfile b/spec/internal/rails_7_0/Gemfile index 00c6faa..67f1fa8 100644 --- a/spec/internal/rails_7_0/Gemfile +++ b/spec/internal/rails_7_0/Gemfile @@ -6,6 +6,7 @@ source 'https://rubygems.org' +gem 'mysql2' gem 'pg' gem 'rails', '~> 7.0.8' diff --git a/spec/internal/rails_7_0/config/application.rb b/spec/internal/rails_7_0/config/application.rb index 7275310..efc0dde 100644 --- a/spec/internal/rails_7_0/config/application.rb +++ b/spec/internal/rails_7_0/config/application.rb @@ -33,6 +33,9 @@ class Application < Rails::Application # config.time_zone = "Central Time (US & Canada)" # config.eager_load_paths << Rails.root.join("extras") + # Do not eager load code on boot. + config.eager_load = false + # Don't generate system test files. config.generators.system_tests = nil end diff --git a/spec/internal/rails_7_0/config/database.yml b/spec/internal/rails_7_0/config/database.yml index a51500f..a098109 100644 --- a/spec/internal/rails_7_0/config/database.yml +++ b/spec/internal/rails_7_0/config/database.yml @@ -7,10 +7,22 @@ default: &default username: <%= ENV['DB_USER'] %> password: <%= ENV['DB_PASSWORD'] %> +mysql2: &mysql2 + adapter: mysql2 + timeout: 5000 + port: <%= ENV['MYSQL_DB_PORT'] || 3306 %> + host: <%= ENV['MYSQL_DB_HOST'] || '127.0.0.1' %> + username: <%= ENV['MYSQL_DB_USER'] || 'root' %> + password: <%= ENV['MYSQL_DB_PASSWORD'] %> + development: <<: *default database: safer_rails_console_development +development-mysql2: + <<: *mysql2 + database: safer_rails_console_development + test: <<: *default database: safer_rails_console_test diff --git a/spec/internal/rails_7_1/Gemfile b/spec/internal/rails_7_1/Gemfile index 88a58db..7808d1d 100644 --- a/spec/internal/rails_7_1/Gemfile +++ b/spec/internal/rails_7_1/Gemfile @@ -6,6 +6,7 @@ source 'https://rubygems.org' +gem 'mysql2' gem 'pg' gem 'rails', '~> 7.1.2' diff --git a/spec/internal/rails_7_1/config/application.rb b/spec/internal/rails_7_1/config/application.rb index e8517f3..7387407 100644 --- a/spec/internal/rails_7_1/config/application.rb +++ b/spec/internal/rails_7_1/config/application.rb @@ -28,6 +28,9 @@ class Application < Rails::Application # Common ones are `templates`, `generators`, or `middleware`, for example. config.autoload_lib(ignore: %w(assets tasks)) + # Do not eager load code on boot. + config.eager_load = false + # Configuration for the application, engines, and railties goes here. # # These settings can be overridden in specific environments using the files diff --git a/spec/internal/rails_7_1/config/database.yml b/spec/internal/rails_7_1/config/database.yml index d02cd4e..612f2b5 100644 --- a/spec/internal/rails_7_1/config/database.yml +++ b/spec/internal/rails_7_1/config/database.yml @@ -24,10 +24,22 @@ default: &default username: <%= ENV['DB_USER'] %> password: <%= ENV['DB_PASSWORD'] %> +mysql2: &mysql2 + adapter: mysql2 + timeout: 5000 + port: <%= ENV['MYSQL_DB_PORT'] || 3306 %> + host: <%= ENV['MYSQL_DB_HOST'] || '127.0.0.1' %> + username: <%= ENV['MYSQL_DB_USER'] || 'root' %> + password: <%= ENV['MYSQL_DB_PASSWORD'] %> + development: <<: *default database: safer_rails_console_development +development-mysql2: + <<: *mysql2 + database: safer_rails_console_development + test: <<: *default database: safer_rails_console_test diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 2f1fbc7..deea98b 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -5,6 +5,8 @@ require 'mixlib/shellout' require 'safer_rails_console' +DB_ADAPTERS = [:postgresql, :mysql2].freeze + RSpec.configure do |config| # Enable flags like --only-failures and --next-failure config.example_status_persistence_file_path = '.rspec_status' @@ -14,9 +16,10 @@ end config.before(:suite) do - system!("export RAILS_ENV=development && cd #{rails_root} && rake db:drop && rake db:setup && rake db:test:prepare") - system!('export SECRET_KEY_BASE_DUMMY=1 RAILS_ENV=production && '\ - "cd #{rails_root} && rake db:drop && rake db:setup && rake db:test:prepare") + migrate_all_dbs + system!("cd #{rails_root} && rake db:test:prepare") + system!('export SECRET_KEY_BASE_DUMMY=1 RAILS_ENV=production && ' \ + "cd #{rails_root} && rake db:drop && rake db:setup && rake db:test:prepare") end config.before do @@ -47,4 +50,15 @@ def with_modified_env(options, &block) def system!(command) raise "Command failed with exit code #{$CHILD_STATUS}: #{command}" unless system(command) end + + def migrate_all_dbs + DB_ADAPTERS.each { |adapter| migrate(adapter: adapter) } + end + + def migrate(adapter:) + env = 'development' + env += "-#{adapter}" if adapter && adapter != :postgresql + system!("export SECRET_KEY_BASE_DUMMY=1 && export RAILS_ENV=#{env} && " \ + "cd #{rails_root} && rake db:drop && rake db:setup") + end end