From fe09489c7c0843558799201c7916ae9c0390acdc Mon Sep 17 00:00:00 2001 From: Francois Buys Date: Thu, 29 Jan 2026 23:44:46 +0200 Subject: [PATCH 1/5] IIRR-30(auth): Enforce user token check on all controllers Add a before_action in ApplicationController to check for the presence of a user token in the session, rendering the login page if missing. Remove redundant token check from PuzzlesController. Allow SessionsController to skip this check for authentication purposes. Ref: https://ombulabs.atlassian.net/browse/IIRR-30 --- app/controllers/application_controller.rb | 7 +++++++ app/controllers/puzzles_controller.rb | 4 ---- app/controllers/sessions_controller.rb | 4 +++- config/environments/test.rb | 5 +++++ test/controllers/puzzles_controller_test.rb | 3 +++ test/test_helper.rb | 22 +++++++++++++++++++++ 6 files changed, 40 insertions(+), 5 deletions(-) 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_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/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/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 From 470bfcae02797a2d8f30b640aaa88c806dce3475 Mon Sep 17 00:00:00 2001 From: Francois Buys Date: Fri, 30 Jan 2026 00:06:16 +0200 Subject: [PATCH 2/5] IIRR-31(puzzles): Add clone action for puzzles Implement a clone action in Puzzles::ClonesController to duplicate existing puzzles. Add a "Clone" button to the archived puzzles table. Include tests for cloning functionality and authentication checks. Ref: https://ombulabs.atlassian.net/browse/IIRR-31 --- app/controllers/puzzles/clones_controller.rb | 13 ++++++++ app/views/puzzles/_puzzles_table.html.erb | 1 + config/routes.rb | 1 + .../puzzles/clones_controller_test.rb | 33 +++++++++++++++++++ 4 files changed, 48 insertions(+) create mode 100644 app/controllers/puzzles/clones_controller.rb create mode 100644 test/controllers/puzzles/clones_controller_test.rb diff --git a/app/controllers/puzzles/clones_controller.rb b/app/controllers/puzzles/clones_controller.rb new file mode 100644 index 0000000..4ed7243 --- /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(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/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/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/test/controllers/puzzles/clones_controller_test.rb b/test/controllers/puzzles/clones_controller_test.rb new file mode 100644 index 0000000..9838f28 --- /dev/null +++ b/test/controllers/puzzles/clones_controller_test.rb @@ -0,0 +1,33 @@ +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.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 From 2cede1a21bad99a407b3e1d4fcff4b03344da343 Mon Sep 17 00:00:00 2001 From: Francois Buys Date: Fri, 30 Jan 2026 00:19:15 +0200 Subject: [PATCH 3/5] fix(ci): Bump brakeman from 7.1.0 to 8.0.0 To fix CI. --- Gemfile.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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) From 38ceffee173b743df5b0d5a8201e787bd0c0f119 Mon Sep 17 00:00:00 2001 From: Francois Buys Date: Fri, 30 Jan 2026 15:23:32 +0200 Subject: [PATCH 4/5] IIRR-30(auto): Skip user token check for Slack controllers Added skip_before_action :check_user_token to Slack::ApplicationController to bypass user token verification for Slack requests. This ensures that Slack webhooks and interactions are not blocked by user authentication requirements. Ref: https://ombulabs.atlassian.net/browse/IIRR-30 --- app/controllers/slack/application_controller.rb | 2 ++ 1 file changed, 2 insertions(+) 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 From 277248fd8c1563e002a15c396e789bdec6e39cde Mon Sep 17 00:00:00 2001 From: Francois Buys Date: Fri, 30 Jan 2026 22:19:13 +0200 Subject: [PATCH 5/5] IIRR-31(puzzle): Add original_puzzle reference for clones Added original_puzzle association to Puzzle model and migration to support tracking the source of cloned puzzles. Updated clone controller and tests to set and verify original_puzzle_id on clone creation. Will be handy when we want to calculate how many people got it right or wrong, if we ever want to show stats for the puzzles. Ref: https://ombulabs.atlassian.net/browse/IIRR-31 --- app/controllers/puzzles/clones_controller.rb | 2 +- app/models/puzzle.rb | 2 ++ .../20260130200200_add_original_puzzle_to_puzzles_table.rb | 5 +++++ db/schema.rb | 5 ++++- test/controllers/puzzles/clones_controller_test.rb | 1 + 5 files changed, 13 insertions(+), 2 deletions(-) create mode 100644 db/migrate/20260130200200_add_original_puzzle_to_puzzles_table.rb diff --git a/app/controllers/puzzles/clones_controller.rb b/app/controllers/puzzles/clones_controller.rb index 4ed7243..fffa9c4 100644 --- a/app/controllers/puzzles/clones_controller.rb +++ b/app/controllers/puzzles/clones_controller.rb @@ -2,7 +2,7 @@ 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(state: params.fetch(:state, "pending"))) + 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." 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/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 index 9838f28..87446d7 100644 --- a/test/controllers/puzzles/clones_controller_test.rb +++ b/test/controllers/puzzles/clones_controller_test.rb @@ -12,6 +12,7 @@ class Puzzles::ClonesControllerTest < ActionDispatch::IntegrationTest 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