This allows you to analyze all queries issued during testing, and see which ones were parameterized, which ones were not parameterized, and possible queries where parameterization was missed. To see this analysis, run "rake check_query_parameterization". This will run the specs logging all queries, then run a script that analyzes the logged queries. You can view the analysis in the created sql_query_parameterization_analysis.txt. The first line in this analysis has summary data: ``` Summary: Missed: 11|11, Parameterized: 1034|71461, Not Parameterized: 94|33003 ``` `Missed: 11|11` means 11 total and 11 unique queries were issued that you would expect to be parameterized but were not (missed parameterization). `Parameterized: 1034|71461` means there were 71461 total and 1034 unique parameterized queries. `Not Parameterized: 94:33003` means there were 33003 total and 94 unique queries that were not parameterized and not expected to be parameterized. The rest of the file lists the unique queries for each of the three types. It can be useful to review all sections to see the queries in use, in case any appear odd and worthy of further review. By default, Sequel::Model does not parameterize model lookups and deletes for scalar primary keys, for performance reasons. Those would show up as missed parameterization. This disables that optimization when running the query parameterization check. Disabling the optimization broke one of the specs, because SequelExtensions#delete did not handle the case where Model#delete called Dataset#delete. Fix it by adding another caller check. Also, only call caller once instead of twice, probably making this faster than it was before. Additionally, I found that logging SQL in any capacity broke about 8 specs that mocked Time.now and expected a specific number of Time.now calls. Such assertions on the number of Time.now calls in the specs are questionable, but work around the issue in this particular case by setting Logger::Time to an object where now returns a static value instead of calling Time.now. While here, fix the rodaauth typo in SequelExtensions#delete.
350 lines
11 KiB
Ruby
350 lines
11 KiB
Ruby
# frozen_string_literal: true
|
|
|
|
require "sequel"
|
|
|
|
use_auto_parallel_tests = nil
|
|
auto_parallel_tests_file = ".auto-parallel-tests"
|
|
|
|
auto_parallel_tests = lambda do
|
|
if use_auto_parallel_tests.nil?
|
|
use_auto_parallel_tests = File.file?(auto_parallel_tests_file) && File.binread(auto_parallel_tests_file) == "1"
|
|
end
|
|
|
|
use_auto_parallel_tests
|
|
end
|
|
|
|
ncpu = nil
|
|
nproc = lambda do
|
|
return ncpu if ncpu
|
|
require "etc"
|
|
# Limit to 6 processes, as higher number results in more time
|
|
ncpu = Etc.nprocessors.clamp(1, 6).to_s
|
|
end
|
|
|
|
clone_test_database = lambda do
|
|
Sequel::DATABASES.each(&:disconnect)
|
|
nproc.call.to_i.times do |i|
|
|
database_name = "clover_test#{i + 1}"
|
|
sh "dropdb --if-exists -U postgres #{database_name}"
|
|
sh "createdb -U postgres -O clover -T clover_test #{database_name}"
|
|
end
|
|
end
|
|
|
|
# Migrate
|
|
migrate = lambda do |env, version|
|
|
ENV["RACK_ENV"] = env
|
|
require "bundler"
|
|
Bundler.setup(:default, :development)
|
|
require "logger"
|
|
require_relative "db"
|
|
Sequel.extension :migration
|
|
|
|
DB.extension :pg_enum
|
|
|
|
DB.loggers << Logger.new($stdout) if DB.loggers.empty?
|
|
if version.is_a?(String) && File.file?(version)
|
|
Sequel::TimestampMigrator.new(DB, "migrate").run_single(version, :down)
|
|
else
|
|
Sequel::TimestampMigrator.apply(DB, "migrate", version)
|
|
end
|
|
|
|
# Check if the alternate-user password hash user needs to run
|
|
# migrations. It's desirable to avoid always connecting to run
|
|
# migrations, since, almost always, there will be nothing to do and
|
|
# it gluts output.
|
|
case DB[<<SQL].get
|
|
SELECT count(*)
|
|
FROM pg_class
|
|
WHERE relnamespace = 'public'::regnamespace AND relname = 'account_password_hashes'
|
|
SQL
|
|
when 0
|
|
user = DB.get(Sequel.lit("current_user"))
|
|
ph_user = "#{user}_password"
|
|
|
|
# NB: this grant/revoke cannot be transaction-isolated, so, in
|
|
# sensitive settings, it would be good to check role access.
|
|
DB["GRANT CREATE ON SCHEMA public TO ?", ph_user.to_sym].get
|
|
Sequel.postgres(**DB.opts.merge(user: ph_user)) do |ph_db|
|
|
ph_db.loggers << Logger.new($stdout) if ph_db.loggers.empty?
|
|
Sequel::Migrator.run(ph_db, "migrate/ph", table: "schema_migrations_password")
|
|
end
|
|
DB["REVOKE ALL ON SCHEMA public FROM ?", ph_user.to_sym].get
|
|
when 1
|
|
# Already ran the "ph" migration as the alternate user. This
|
|
# branch is taken nearly all the time in a production situation.
|
|
else
|
|
fail "BUG: account_password_hashes table probing query should return 0 or 1"
|
|
end
|
|
end
|
|
|
|
desc "Migrate test database to latest version"
|
|
task test_up: [:_test_up, :refresh_sequel_caches, :annotate]
|
|
|
|
desc "Migrate test database down. Must specify VERSION environment variable."
|
|
task test_down: [:_test_down, :refresh_sequel_caches, :annotate]
|
|
|
|
# rubocop:disable Rake/Desc
|
|
task :_test_up do
|
|
migrate.call("test", nil)
|
|
clone_test_database.call if auto_parallel_tests.call
|
|
end
|
|
|
|
migrate_version = lambda do |env|
|
|
last_irreversible_migration = 20241011
|
|
version = ENV["VERSION"]
|
|
unless version && File.file?(version)
|
|
version = version.to_i
|
|
unless version >= last_irreversible_migration
|
|
raise "Must provide VERSION environment variable >= #{last_irreversible_migration} (or a migration filename) to migrate down"
|
|
end
|
|
end
|
|
migrate.call(env, version)
|
|
end
|
|
|
|
task :_test_down do
|
|
migrate_version.call("test")
|
|
clone_test_database.call if auto_parallel_tests.call
|
|
end
|
|
# rubocop:enable Rake/Desc
|
|
|
|
desc "Migrate development database to latest version"
|
|
task :dev_up do
|
|
migrate.call("development", nil)
|
|
end
|
|
|
|
desc "Migrate development database down. Must specify VERSION environment variable."
|
|
task :dev_down do
|
|
migrate_version.call("development")
|
|
end
|
|
|
|
desc "Migrate production database to latest version"
|
|
task :prod_up do
|
|
migrate.call("production", nil)
|
|
end
|
|
|
|
desc "Refresh schema and index caches"
|
|
task :refresh_sequel_caches do
|
|
%w[schema index static_cache].each do |type|
|
|
filename = "cache/#{type}.cache"
|
|
File.delete(filename) if File.file?(filename)
|
|
end
|
|
|
|
sh({"RACK_ENV" => "test", "FORCE_AUTOLOAD" => "1"}, "bundle", "exec", "ruby", "-r", "./loader", "-e", <<~END)
|
|
DB.dump_schema_cache("cache/schema.cache")
|
|
DB.dump_index_cache("cache/index.cache")
|
|
Sequel::Model.dump_static_cache_cache
|
|
Sequel::Model.dump_pg_auto_constraint_validations_cache
|
|
END
|
|
end
|
|
|
|
# Database setup
|
|
|
|
desc "Setup database"
|
|
task :setup_database, [:env, :parallel] do |_, args|
|
|
raise "env must be test or development" unless ["test", "development"].include?(args[:env])
|
|
parallel = args[:parallel] && args[:parallel] != "false"
|
|
raise "parallel can only be used in test" if parallel && args[:env] != "test"
|
|
File.binwrite(auto_parallel_tests_file, "1") if parallel && !File.file?(auto_parallel_tests_file)
|
|
|
|
database_name = "clover_#{args[:env]}"
|
|
sh "dropdb --if-exists -U postgres #{database_name}"
|
|
sh "createdb -U postgres -O clover #{database_name}"
|
|
sh "psql -U postgres -c 'CREATE EXTENSION citext; CREATE EXTENSION btree_gist;' #{database_name}"
|
|
migrate.call(args[:env], nil)
|
|
clone_test_database.call if parallel
|
|
end
|
|
|
|
desc "Generate a new .env.rb"
|
|
task :overwrite_envrb do
|
|
require "securerandom"
|
|
|
|
File.write(".env.rb", <<ENVRB)
|
|
# frozen_string_literal: true
|
|
|
|
case ENV["RACK_ENV"] ||= "development"
|
|
when "test"
|
|
ENV["CLOVER_SESSION_SECRET"] ||= "#{SecureRandom.base64(64)}"
|
|
ENV["CLOVER_DATABASE_URL"] ||= "postgres:///clover_test\#{ENV["TEST_ENV_NUMBER"]}?user=clover"
|
|
ENV["CLOVER_COLUMN_ENCRYPTION_KEY"] ||= "#{SecureRandom.base64(32)}"
|
|
else
|
|
ENV["CLOVER_SESSION_SECRET"] ||= "#{SecureRandom.base64(64)}"
|
|
ENV["CLOVER_DATABASE_URL"] ||= "postgres:///clover_development?user=clover"
|
|
ENV["CLOVER_COLUMN_ENCRYPTION_KEY"] ||= "#{SecureRandom.base64(32)}"
|
|
end
|
|
ENVRB
|
|
end
|
|
|
|
# Specs
|
|
|
|
desc "Run specs in with coverage in unfrozen mode, and without coverage in frozen mode"
|
|
task default: [:coverage, :frozen_spec]
|
|
|
|
rspec = lambda do |env|
|
|
sh(env.merge("RUBYOPT" => "-w", "RACK_ENV" => "test", "FORCE_AUTOLOAD" => "1"), "bundle", "exec", "rspec", "spec")
|
|
end
|
|
|
|
turbo_tests = lambda do |env|
|
|
sh(env.merge("RUBYOPT" => "-w", "RACK_ENV" => "test", "FORCE_AUTOLOAD" => "1"), "bundle", "exec", "turbo_tests", "-n", nproc.call)
|
|
end
|
|
|
|
spec = lambda do |env|
|
|
(auto_parallel_tests.call ? turbo_tests : rspec).call(env)
|
|
end
|
|
|
|
desc "Run specs with coverage"
|
|
task "coverage" => [:coverage_spec]
|
|
|
|
{
|
|
"sspec" => [" in serial", rspec],
|
|
"pspec" => [" in parallel", turbo_tests],
|
|
"spec" => ["", spec]
|
|
}.each do |task_suffix, (desc_suffix, block)|
|
|
desc "Run specs#{desc_suffix}"
|
|
task task_suffix do
|
|
block.call({})
|
|
end
|
|
|
|
desc "Run specs#{desc_suffix} with frozen core, Database, and models (similar to production)"
|
|
task "frozen_#{task_suffix}" do
|
|
block.call("CLOVER_FREEZE" => "1")
|
|
end
|
|
end
|
|
|
|
coverage_setup = lambda do
|
|
FileUtils.rm_rf("coverage/views")
|
|
FileUtils.mkdir_p("coverage/views")
|
|
end
|
|
|
|
desc "Run specs with coverage"
|
|
task "coverage_spec" do
|
|
Rake::Task[auto_parallel_tests.call ? "coverage_pspec" : "coverage_sspec"].invoke
|
|
end
|
|
|
|
desc "Run specs in serial with coverage"
|
|
task "coverage_sspec" do
|
|
coverage_setup.call
|
|
rspec.call("COVERAGE" => "1", "RODA_RENDER_COMPILED_METHOD_SUPPORT" => "no")
|
|
end
|
|
|
|
desc "Run specs in parallel with coverage"
|
|
task "coverage_pspec" do
|
|
output_file = "coverage/output.txt"
|
|
coverage_setup.call
|
|
command = "bundle exec turbo_tests -n #{nproc.call} 2>&1 | tee #{output_file}"
|
|
sh({"RUBYOPT" => "-w", "RACK_ENV" => "test", "FORCE_AUTOLOAD" => "1", "COVERAGE" => "1", "RODA_RENDER_COMPILED_METHOD_SUPPORT" => "no"}, command)
|
|
command_output = File.binread(output_file)
|
|
unless command_output.include?("Line Coverage: 100.0%") && command_output.include?("Branch Coverage: 100.0%")
|
|
warn "SimpleCov failed with exit 2 due to a coverage related error"
|
|
exit(2)
|
|
end
|
|
exit(1) if command_output.include?("\nFailures:\n")
|
|
ensure
|
|
File.delete(output_file) if File.file?(output_file)
|
|
end
|
|
|
|
# Other
|
|
|
|
desc "Check generated SQL for parameterization"
|
|
task "check_query_parameterization" do
|
|
require "rbconfig"
|
|
system({"CHECK_LOGGED_SQL" => "1"}, RbConfig.ruby, "-S", "rake", "frozen_sspec")
|
|
system(RbConfig.ruby, "bin/check_for_parameters", out: "sql_query_parameterization_analysis.txt")
|
|
end
|
|
|
|
desc "Check that model files work when required separately"
|
|
task "check_separate_requires" do
|
|
require "rbconfig"
|
|
system({"RACK_ENV" => "test", "LOAD_FILES_SEPARATELY_CHECK" => "1"}, RbConfig.ruby, "-r", "./loader", "-e", "")
|
|
end
|
|
|
|
desc "Run each spec file in a separate process"
|
|
task :spec_separate do
|
|
require "rbconfig"
|
|
|
|
failures = []
|
|
Dir["spec/**/*_spec.rb"].each do |file|
|
|
failures << file unless system(RbConfig.ruby, "-w", "-S", "rspec", file)
|
|
end
|
|
|
|
if failures.empty?
|
|
puts "All files passed"
|
|
else
|
|
puts "Failures in:", failures
|
|
end
|
|
end
|
|
|
|
desc "Regenerate screenshots for documentation site"
|
|
task "screenshots" do
|
|
sh("bundle", "exec", "ruby", "bin/regen-screenshots")
|
|
end
|
|
|
|
desc "Annotate Sequel models"
|
|
task "annotate" do
|
|
ENV["RACK_ENV"] = "development"
|
|
require_relative "loader"
|
|
require_relative "model"
|
|
DB.loggers.clear
|
|
require "sequel/annotate"
|
|
Sequel::Annotate.annotate(Dir["model/**/*.rb"])
|
|
end
|
|
|
|
desc "Emit assets before deploying"
|
|
task "assets:precompile" do
|
|
sh("npm", "install")
|
|
sh("npm", "run", "prod")
|
|
end
|
|
|
|
desc "Open a new shell allowing use of by for speeding up tests"
|
|
task "by" do
|
|
by_path = "bin/by"
|
|
|
|
require "rbconfig"
|
|
|
|
by_content = File.binread(Gem.activate_bin_path("by", "by"))
|
|
by_content.sub!(/\A#!.*/, "#!#{RbConfig.ruby} --disable-gems")
|
|
File.binwrite(by_path, by_content)
|
|
ENV["PATH"] = "#{__dir__}/bin:#{ENV["PATH"]}"
|
|
sh("bundle", "exec", "by-session", "./.by-session-setup.rb")
|
|
ensure
|
|
File.delete(by_path) if File.file?(by_path)
|
|
end
|
|
|
|
namespace :linter do
|
|
desc "Run Rubocop"
|
|
task :rubocop do
|
|
sh "BUNDLE_WITH=rubocop bundle exec rubocop"
|
|
end
|
|
|
|
desc "Run Brakeman"
|
|
task :brakeman do
|
|
require "bundler"
|
|
Bundler.setup(:lint)
|
|
puts "Running Brakeman..."
|
|
require "brakeman"
|
|
Brakeman.run app_path: ".", quiet: true, force_scan: true, print_report: true, run_all_checks: true
|
|
end
|
|
|
|
desc "Run ERB::Formatter"
|
|
task :erb_formatter do
|
|
# "fdr/erb-formatter" can't be required without bundler setup because of custom repository.
|
|
require "bundler"
|
|
Bundler.setup(:lint)
|
|
puts "Running ERB::Formatter..."
|
|
require "erb/formatter/command_line"
|
|
files = Dir.glob("views/**/[!icon]*.erb").entries
|
|
files.delete("views/components/form/select.erb")
|
|
files.delete("views/github/runner.erb")
|
|
ERB::Formatter::CommandLine.new(files + ["--write", "--print-width", "120"]).run
|
|
end
|
|
|
|
desc "Validate, lint, format OpenAPI YAML file"
|
|
task :openapi do
|
|
sh "npx redocly lint openapi/openapi.yml --config openapi/redocly.yml"
|
|
sh "npx @stoplight/spectral-cli lint openapi/openapi.yml --fail-severity=warn --ruleset openapi/.spectral.yml"
|
|
sh "npx openapi-format openapi/openapi.yml --configFile openapi/openapi_format.yml"
|
|
end
|
|
end
|
|
|
|
desc "Run all linters"
|
|
task linter: ["rubocop", "brakeman", "erb_formatter", "openapi"].map { "linter:#{_1}" }
|