diff --git a/Gemfile.lock b/Gemfile.lock index 93d0737..aad5163 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -101,7 +101,7 @@ GEM bootstrap-sass (3.4.1) autoprefixer-rails (>= 5.2.1) sassc (>= 2.0.0) - brakeman (7.1.0) + brakeman (8.0.1) racc builder (3.3.0) capybara (3.40.0) diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 95c324a..7410138 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -2,9 +2,16 @@ class ApplicationController < ActionController::Base # Only allow modern browsers supporting webp images, web push, badges, import maps, CSS nesting, and CSS :has. allow_browser versions: :modern before_action :check_session_expiry + before_action :check_user_token private + def check_user_token + unless session[:user_token] + render "puzzles/login" + end + end + def check_session_expiry if session[:expires_at].present? && Time.current > session[:expires_at] reset_session diff --git a/app/controllers/puzzles/clones_controller.rb b/app/controllers/puzzles/clones_controller.rb new file mode 100644 index 0000000..fffa9c4 --- /dev/null +++ b/app/controllers/puzzles/clones_controller.rb @@ -0,0 +1,13 @@ +class Puzzles::ClonesController < ApplicationController + def create + original_puzzle = Puzzle.find(params[:puzzle_id]) + attributes = original_puzzle.attributes.slice("question", "answer", "explanation", "link", "suggested_by") + cloned_puzzle = Puzzle.new(attributes.merge(original_puzzle:, state: params.fetch(:state, "pending"))) + + if cloned_puzzle.save + redirect_to puzzles_path, notice: "Puzzle cloned. You can now edit the new puzzle." + else + redirect_to puzzles_path, alert: "Failed to clone puzzle." + end + end +end diff --git a/app/controllers/puzzles_controller.rb b/app/controllers/puzzles_controller.rb index 8771524..91b84ba 100644 --- a/app/controllers/puzzles_controller.rb +++ b/app/controllers/puzzles_controller.rb @@ -1,9 +1,5 @@ class PuzzlesController < ApplicationController def index - unless session[:user_token] - render "login" - end - @pending_puzzles = Puzzle.pending @approved_puzzles = Puzzle.approved @rejected_puzzles = Puzzle.rejected diff --git a/app/controllers/sessions_controller.rb b/app/controllers/sessions_controller.rb index 1fdcd65..6b15682 100644 --- a/app/controllers/sessions_controller.rb +++ b/app/controllers/sessions_controller.rb @@ -1,9 +1,11 @@ class SessionsController < ApplicationController + skip_before_action :check_user_token + def create auth = request.env["omniauth.auth"] user_email = auth.info.email - domain_allowlist = ENV.fetch("DOMAIN_ALLOWLIST").split(",").map(&:strip) + domain_allowlist = ENV.fetch("DOMAIN_ALLOWLIST", "").split(",").map(&:strip) if domain_allowlist.present? unless domain_allowlist.any? { |domain| user_email.end_with?("@#{domain}") } reset_session diff --git a/app/controllers/slack/application_controller.rb b/app/controllers/slack/application_controller.rb index 49a23ca..e587022 100644 --- a/app/controllers/slack/application_controller.rb +++ b/app/controllers/slack/application_controller.rb @@ -1,5 +1,7 @@ class Slack::ApplicationController < ApplicationController skip_before_action :verify_authenticity_token + skip_before_action :check_user_token + before_action :valid_slack_request? private diff --git a/app/models/puzzle.rb b/app/models/puzzle.rb index ebb5c64..23af534 100644 --- a/app/models/puzzle.rb +++ b/app/models/puzzle.rb @@ -3,6 +3,8 @@ class Puzzle < ApplicationRecord enum :state, { approved: 0, rejected: 1, pending: 2, archived: 3 } has_many :answers + belongs_to :original_puzzle, class_name: "Puzzle", optional: true + validates :question, presence: true scope :archived, -> { where(state: :archived).order(sent_at: :desc) } diff --git a/app/views/puzzles/_puzzles_table.html.erb b/app/views/puzzles/_puzzles_table.html.erb index 71b03f7..8b2b9e2 100644 --- a/app/views/puzzles/_puzzles_table.html.erb +++ b/app/views/puzzles/_puzzles_table.html.erb @@ -42,6 +42,7 @@ <%= button_to 'Pending', puzzle_state_path(puzzle, state: :pending), method: :patch, form_class: 'inline-form', class: 'btn pending-btn' %> <% elsif actions == :archived %> <%= button_to 'Un-Archive', puzzle_state_path(puzzle, state: :pending), method: :patch, form_class: 'inline-form', class: 'btn pending-btn' %> + <%= button_to 'Clone', puzzle_clone_path(puzzle, state: :pending), method: :post, form_class: 'inline-form', class: 'btn pending-btn' %> <% end %> diff --git a/config/environments/test.rb b/config/environments/test.rb index c2095b1..cfef366 100644 --- a/config/environments/test.rb +++ b/config/environments/test.rb @@ -50,4 +50,9 @@ # Raise error when a before_action's only/except options reference missing actions. config.action_controller.raise_on_missing_callback_actions = true + + # Once you have enabled test mode, all requests to OmniAuth will be short circuited to use + # the mock authentication hash. + # See: https://github.com/omniauth/omniauth/wiki/Integration-Testing + OmniAuth.config.test_mode = true end diff --git a/config/routes.rb b/config/routes.rb index 96801d9..b6a4459 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -1,6 +1,7 @@ Rails.application.routes.draw do resources :puzzles, only: [ :index, :edit, :update ] do resource :state, only: [ :update ], module: :puzzles + resource :clone, only: [ :create ], module: :puzzles end resources :sessions, only: [ :create, :destroy ] diff --git a/db/migrate/20260130200200_add_original_puzzle_to_puzzles_table.rb b/db/migrate/20260130200200_add_original_puzzle_to_puzzles_table.rb new file mode 100644 index 0000000..dc0d4c1 --- /dev/null +++ b/db/migrate/20260130200200_add_original_puzzle_to_puzzles_table.rb @@ -0,0 +1,5 @@ +class AddOriginalPuzzleToPuzzlesTable < ActiveRecord::Migration[8.0] + def change + add_reference :puzzles, :original_puzzle, foreign_key: { to_table: :puzzles }, index: true, null: true + end +end diff --git a/db/schema.rb b/db/schema.rb index f01d438..e9cc450 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.0].define(version: 2025_09_17_170711) do +ActiveRecord::Schema[8.0].define(version: 2026_01_30_200200) do # These are extensions that must be enabled in order to support this database enable_extension "pg_catalog.plpgsql" @@ -47,6 +47,8 @@ t.string "link" t.integer "state", default: 2, null: false t.string "suggested_by" + t.bigint "original_puzzle_id" + t.index ["original_puzzle_id"], name: "index_puzzles_on_original_puzzle_id" end create_table "servers", force: :cascade do |t| @@ -81,6 +83,7 @@ add_foreign_key "answers", "servers" add_foreign_key "answers", "users" add_foreign_key "channels", "servers" + add_foreign_key "puzzles", "puzzles", column: "original_puzzle_id" add_foreign_key "users_servers", "servers" add_foreign_key "users_servers", "users" end diff --git a/test/controllers/puzzles/clones_controller_test.rb b/test/controllers/puzzles/clones_controller_test.rb new file mode 100644 index 0000000..87446d7 --- /dev/null +++ b/test/controllers/puzzles/clones_controller_test.rb @@ -0,0 +1,34 @@ +require "test_helper" + +class Puzzles::ClonesControllerTest < ActionDispatch::IntegrationTest + test "creates a cloned puzzle and redirects to index" do + original = puzzles(:one) + + sign_in + assert_difference("Puzzle.count", 1) do + post puzzle_clone_path(original) + end + + cloned = Puzzle.order(:id).last + assert_redirected_to puzzles_path + + assert_equal original.id, cloned.original_puzzle_id + assert_equal original.question, cloned.question + assert_equal original.answer, cloned.answer + assert_equal original.explanation, cloned.explanation + assert_equal original.link, cloned.link + assert_equal original.suggested_by, cloned.suggested_by + assert_nil cloned.sent_at + assert_equal "pending", cloned.state + end + + test "does not allow unauthenticated users to create a clone" do + original = puzzles(:one) + + assert_no_difference("Puzzle.count") do + post puzzle_clone_path(original) + end + + assert_dom "p", "Log in to access the Ruby or Rails admin panel." + end +end diff --git a/test/controllers/puzzles_controller_test.rb b/test/controllers/puzzles_controller_test.rb index d7aefe6..6f64d3a 100644 --- a/test/controllers/puzzles_controller_test.rb +++ b/test/controllers/puzzles_controller_test.rb @@ -2,6 +2,7 @@ class PuzzlesControllerTest < ActionDispatch::IntegrationTest test "should get index" do + sign_in get puzzles_path assert_response :success end @@ -9,6 +10,7 @@ class PuzzlesControllerTest < ActionDispatch::IntegrationTest test "should show error message when editing puzzle with invalid data" do puzzle = puzzles(:one) + sign_in patch puzzle_path(puzzle), params: { puzzle: { question: "", @@ -27,6 +29,7 @@ class PuzzlesControllerTest < ActionDispatch::IntegrationTest test "should successfully update puzzle with valid data" do puzzle = puzzles(:one) + sign_in patch puzzle_path(puzzle), params: { puzzle: { question: "Updated question", diff --git a/test/test_helper.rb b/test/test_helper.rb index 0c22470..182c802 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -10,6 +10,28 @@ class TestCase # Setup all fixtures in test/fixtures/*.yml for all tests in alphabetical order. fixtures :all + # Global setup to be run before each test + setup do + OmniAuth.config.mock_auth[:google] = nil + end + # Add more helper methods to be used by all tests here... + def sign_in + auth = { + provider: "google_oauth2", + uid: "123456789", + info: { + email: "cooper@ombulabs.com" + }, + credentials: { + token: "token" + } + } + + OmniAuth.config.add_mock(:google, auth) + Rails.application.env_config["omniauth.auth"] = OmniAuth.config.mock_auth[:google] + + post sessions_path, params: { provider: :google } + end end end