From 0396f442350c5b83b1718ead2c12c147a4d7f40e Mon Sep 17 00:00:00 2001 From: Aditya <2005akjha@gmail.com> Date: Wed, 1 Oct 2025 00:14:49 +0530 Subject: [PATCH 1/2] X OAuth Added --- .env.sample | 4 +++ Gemfile | 3 ++ README.md | 11 +++++++ app/controllers/oauth_controller.rb | 32 +++++++++++++++++++ app/models/user.rb | 2 ++ app/views/sessions/new.html.erb | 7 ++++ app/views/users/profiles/show.html.erb | 10 ++++++ config/initializers/omniauth.rb | 3 ++ config/routes.rb | 2 ++ ...0251001000001_add_oauth_fields_to_users.rb | 6 ++++ 10 files changed, 80 insertions(+) create mode 100644 app/controllers/oauth_controller.rb create mode 100644 config/initializers/omniauth.rb create mode 100644 db/migrate/20251001000001_add_oauth_fields_to_users.rb diff --git a/.env.sample b/.env.sample index f6352d66..e68ee465 100644 --- a/.env.sample +++ b/.env.sample @@ -9,3 +9,7 @@ VAPID_PRIVATE_KEY= # Vimeo access token required for Library downloads. Required scopes: "private video_files public" VIMEO_ACCESS_TOKEN= + +# Twitter OAuth Configuration +TWITTER_CLIENT_ID=your_twitter_client_id_here +TWITTER_CLIENT_SECRET=your_twitter_client_secret_here diff --git a/Gemfile b/Gemfile index 3ace1841..9afb1a6d 100644 --- a/Gemfile +++ b/Gemfile @@ -44,6 +44,9 @@ gem "kredis" gem "platform_agent" gem "thruster" gem "faraday" +gem "omniauth" +gem "omniauth-twitter2" +gem "omniauth-rails_csrf_protection" group :development, :test do gem "debug" diff --git a/README.md b/README.md index c91f03b0..7db34c29 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,17 @@ And if you're not part of the [Small Bets](https://smallbets.com) community yet, bin/setup bin/rails server +### Twitter/X OAuth Setup (Optional) + +To enable Twitter/X login and account linking: + +1. Create a Twitter Developer account at https://developer.twitter.com +2. Create a new app and get your Client ID and Client Secret +3. Add these to your environment: + ``` + TWITTER_CLIENT_ID=your_client_id + TWITTER_CLIENT_SECRET=your_client_secret + ``` The `bin/setup` script will install dependencies, prepare the database, and configure the application. ## Running in production diff --git a/app/controllers/oauth_controller.rb b/app/controllers/oauth_controller.rb new file mode 100644 index 00000000..24df103c --- /dev/null +++ b/app/controllers/oauth_controller.rb @@ -0,0 +1,32 @@ +class OauthController < ApplicationController + allow_unauthenticated_access only: [:callback] + + def callback + auth_info = request.env["omniauth.auth"] + + if signed_in? + # User is connecting their X account + Current.user.update!(twitter_uid: auth_info.uid) + redirect_to user_profile_path, notice: "X account connected successfully!" + else + # User is signing in with X + user = User.find_by(twitter_uid: auth_info.uid) || + User.find_by(email_address: auth_info.info.email) + + if user + user.update!(twitter_uid: auth_info.uid) unless user.twitter_uid + start_new_session_for(user) + redirect_to post_authenticating_url + else + redirect_to new_session_path, alert: "No account found. Please sign up first or use email login." + end + end + rescue + redirect_to signed_in? ? user_profile_path : new_session_path, alert: "Authentication failed." + end + + def disconnect + Current.user.update!(twitter_uid: nil) + redirect_to user_profile_path, notice: "X account disconnected." + end +end \ No newline at end of file diff --git a/app/models/user.rb b/app/models/user.rb index fdad10f3..17520012 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -34,6 +34,8 @@ class User < ApplicationRecord validates_presence_of :email_address, if: :person? normalizes :email_address, with: ->(email_address) { email_address.downcase } + validates :twitter_uid, uniqueness: true, allow_nil: true + scope :without_default_names, -> { where.not(name: DEFAULT_NAME) } scope :non_suspended, -> { where(suspended_at: nil) } scope :unclaimed_gumroad_imports, -> { where.not(order_id: nil).where(last_authenticated_at: nil) } diff --git a/app/views/sessions/new.html.erb b/app/views/sessions/new.html.erb index a488eda2..d2da9a0f 100644 --- a/app/views/sessions/new.html.erb +++ b/app/views/sessions/new.html.erb @@ -35,6 +35,13 @@ Go <% end %> <% end %> + +
+ <%= link_to "/auth/twitter2", class: "btn btn--primary center txt-medium" do %> + <%= image_tag "social/twitter-outline.svg", role: "presentation", size: 20, class: "colorize--white" %> + Sign in with X + <% end %> +
<% end %> diff --git a/app/views/users/profiles/show.html.erb b/app/views/users/profiles/show.html.erb index a1aef960..4ab3d051 100644 --- a/app/views/users/profiles/show.html.erb +++ b/app/views/users/profiles/show.html.erb @@ -83,6 +83,16 @@ <%= form.text_field :twitter_url, class: "input txt-medium ", placeholder: "Your X profile", required: false %> <%= image_tag "social/twitter-outline.svg", role: "presentation", size: 24, class: "colorize--black" %> + + <% if @user.twitter_uid.present? %> + <%= button_to "/auth/twitter2/disconnect", method: :post, class: "btn btn--negative txt-small" do %> + Disconnect X + <% end %> + <% else %> + <%= link_to "/auth/twitter2", class: "btn btn--primary txt-small" do %> + Connect X + <% end %> + <% end %>
diff --git a/config/initializers/omniauth.rb b/config/initializers/omniauth.rb new file mode 100644 index 00000000..142f6258 --- /dev/null +++ b/config/initializers/omniauth.rb @@ -0,0 +1,3 @@ +Rails.application.config.middleware.use OmniAuth::Builder do + provider :twitter2, ENV['TWITTER_CLIENT_ID'], ENV['TWITTER_CLIENT_SECRET'] +end \ No newline at end of file diff --git a/config/routes.rb b/config/routes.rb index bf168746..ed5de5c8 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -36,6 +36,8 @@ resource :validations, only: %i[new create] end get "auth_tokens/validate/:token", to: "auth_tokens/validations#create", as: :sign_in_with_token + get "/auth/:provider/callback", to: "oauth#callback" + post "/auth/:provider/disconnect", to: "oauth#disconnect" resource :account do scope module: "accounts" do diff --git a/db/migrate/20251001000001_add_oauth_fields_to_users.rb b/db/migrate/20251001000001_add_oauth_fields_to_users.rb new file mode 100644 index 00000000..88d73ae6 --- /dev/null +++ b/db/migrate/20251001000001_add_oauth_fields_to_users.rb @@ -0,0 +1,6 @@ +class AddOauthFieldsToUsers < ActiveRecord::Migration[8.0] + def change + add_column :users, :twitter_uid, :string + add_index :users, :twitter_uid, unique: true + end +end \ No newline at end of file From 25d601763693cd1aab90f79a6306ade916b39c02 Mon Sep 17 00:00:00 2001 From: Aditya <2005akjha@gmail.com> Date: Wed, 1 Oct 2025 00:34:12 +0530 Subject: [PATCH 2/2] added tests --- README.md | 13 +++ app/controllers/oauth_controller.rb | 2 - config/initializers/omniauth.rb | 6 +- config/initializers/omniauth_test.rb | 8 ++ test/controllers/oauth_controller_test.rb | 97 +++++++++++++++++++++++ test/fixtures/users.yml | 8 +- test/integration/oauth_routes_test.rb | 17 ++++ test/integration/oauth_user_flow_test.rb | 53 +++++++++++++ test/models/user_oauth_test.rb | 37 +++++++++ test/system/oauth_system_test.rb | 42 ++++++++++ 10 files changed, 278 insertions(+), 5 deletions(-) create mode 100644 config/initializers/omniauth_test.rb create mode 100644 test/controllers/oauth_controller_test.rb create mode 100644 test/integration/oauth_routes_test.rb create mode 100644 test/integration/oauth_user_flow_test.rb create mode 100644 test/models/user_oauth_test.rb create mode 100644 test/system/oauth_system_test.rb diff --git a/README.md b/README.md index 7db34c29..ded4df99 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,19 @@ To enable Twitter/X login and account linking: TWITTER_CLIENT_ID=your_client_id TWITTER_CLIENT_SECRET=your_client_secret ``` +### Running Tests + +To run the OAuth-related tests: + + rails test test/controllers/oauth_controller_test.rb + rails test test/models/user_oauth_test.rb + rails test test/system/oauth_system_test.rb + rails test test/integration/oauth_routes_test.rb + +Or run all tests: + + rails test + The `bin/setup` script will install dependencies, prepare the database, and configure the application. ## Running in production diff --git a/app/controllers/oauth_controller.rb b/app/controllers/oauth_controller.rb index 24df103c..4553b40a 100644 --- a/app/controllers/oauth_controller.rb +++ b/app/controllers/oauth_controller.rb @@ -5,11 +5,9 @@ def callback auth_info = request.env["omniauth.auth"] if signed_in? - # User is connecting their X account Current.user.update!(twitter_uid: auth_info.uid) redirect_to user_profile_path, notice: "X account connected successfully!" else - # User is signing in with X user = User.find_by(twitter_uid: auth_info.uid) || User.find_by(email_address: auth_info.info.email) diff --git a/config/initializers/omniauth.rb b/config/initializers/omniauth.rb index 142f6258..833504a1 100644 --- a/config/initializers/omniauth.rb +++ b/config/initializers/omniauth.rb @@ -1,3 +1,5 @@ -Rails.application.config.middleware.use OmniAuth::Builder do - provider :twitter2, ENV['TWITTER_CLIENT_ID'], ENV['TWITTER_CLIENT_SECRET'] +unless Rails.env.test? + Rails.application.config.middleware.use OmniAuth::Builder do + provider :twitter2, ENV['TWITTER_CLIENT_ID'], ENV['TWITTER_CLIENT_SECRET'] + end end \ No newline at end of file diff --git a/config/initializers/omniauth_test.rb b/config/initializers/omniauth_test.rb new file mode 100644 index 00000000..d37b97fc --- /dev/null +++ b/config/initializers/omniauth_test.rb @@ -0,0 +1,8 @@ +OmniAuth.config.test_mode = false +OmniAuth.config.logger = Rails.logger + +if Rails.env.test? + Rails.application.config.middleware.use OmniAuth::Builder do + provider :twitter2, 'test_client_id', 'test_client_secret' + end +end \ No newline at end of file diff --git a/test/controllers/oauth_controller_test.rb b/test/controllers/oauth_controller_test.rb new file mode 100644 index 00000000..078df1d0 --- /dev/null +++ b/test/controllers/oauth_controller_test.rb @@ -0,0 +1,97 @@ +require "test_helper" + +class OauthControllerTest < ActionDispatch::IntegrationTest + setup do + @user = users(:david) + @auth_hash = { + "uid" => "12345", + "info" => { + "email" => @user.email_address, + "nickname" => "david_twitter" + } + } + end + + test "should connect X account when user is signed in" do + sign_in @user + + OmniAuth.config.test_mode = true + OmniAuth.config.mock_auth[:twitter2] = OmniAuth::AuthHash.new(@auth_hash) + + get "/auth/twitter2/callback" + + @user.reload + assert_equal "12345", @user.twitter_uid + assert_redirected_to user_profile_path + assert_equal "X account connected successfully!", flash[:notice] + end + + test "should sign in existing user with X account" do + @user.update!(twitter_uid: "12345") + + OmniAuth.config.test_mode = true + OmniAuth.config.mock_auth[:twitter2] = OmniAuth::AuthHash.new(@auth_hash) + + get "/auth/twitter2/callback" + + assert_redirected_to root_path + assert @user.sessions.exists? + end + + test "should link X account to existing user by email during sign in" do + assert_nil @user.twitter_uid + + OmniAuth.config.test_mode = true + OmniAuth.config.mock_auth[:twitter2] = OmniAuth::AuthHash.new(@auth_hash) + + get "/auth/twitter2/callback" + + @user.reload + assert_equal "12345", @user.twitter_uid + assert_redirected_to root_path + end + + test "should redirect to signup when no user found" do + @auth_hash["info"]["email"] = "newuser@example.com" + + OmniAuth.config.test_mode = true + OmniAuth.config.mock_auth[:twitter2] = OmniAuth::AuthHash.new(@auth_hash) + + get "/auth/twitter2/callback" + + assert_redirected_to new_session_path + assert_equal "No account found. Please sign up first or use email login.", flash[:alert] + end + + test "should handle authentication failure gracefully" do + OmniAuth.config.test_mode = true + OmniAuth.config.mock_auth[:twitter2] = :invalid_credentials + + get "/auth/twitter2/callback" + + assert_redirected_to new_session_path + assert_equal "Authentication failed.", flash[:alert] + end + + test "should disconnect X account" do + sign_in @user + @user.update!(twitter_uid: "12345") + + post "/auth/twitter2/disconnect" + + @user.reload + assert_nil @user.twitter_uid + assert_redirected_to user_profile_path + assert_equal "X account disconnected.", flash[:notice] + end + + test "should require authentication for disconnect" do + post "/auth/twitter2/disconnect" + assert_redirected_to new_session_path + end + + teardown do + OmniAuth.config.test_mode = false + OmniAuth.config.mock_auth[:twitter2] = nil + end +end \ No newline at end of file diff --git a/test/fixtures/users.yml b/test/fixtures/users.yml index 6c23225e..cd838c08 100644 --- a/test/fixtures/users.yml +++ b/test/fixtures/users.yml @@ -1,4 +1,4 @@ -<% password_digest = BCrypt::Password.create("secret123456") %> +# <% password_digest = BCrypt::Password.create("secret123456") %> david: name: David @@ -24,6 +24,12 @@ kevin: password_digest: <%= password_digest %> bio: Programmer +twitter_user: + name: Twitter User + email_address: twitter@example.com + password_digest: <%= password_digest %> + twitter_uid: "existing_twitter_uid" + bender: name: Bender Bot bot_token: <%= User.generate_bot_token %> diff --git a/test/integration/oauth_routes_test.rb b/test/integration/oauth_routes_test.rb new file mode 100644 index 00000000..15ab5090 --- /dev/null +++ b/test/integration/oauth_routes_test.rb @@ -0,0 +1,17 @@ +require "test_helper" + +class OauthRoutesTest < ActionDispatch::IntegrationTest + test "oauth callback route exists" do + assert_routing "/auth/twitter2/callback", { controller: "oauth", action: "callback", provider: "twitter2" } + end + + test "oauth disconnect route exists" do + assert_routing({ method: "post", path: "/auth/twitter2/disconnect" }, + { controller: "oauth", action: "disconnect", provider: "twitter2" }) + end + + test "twitter oauth redirect works" do + get "/auth/twitter2" + assert_response :redirect + end +end \ No newline at end of file diff --git a/test/integration/oauth_user_flow_test.rb b/test/integration/oauth_user_flow_test.rb new file mode 100644 index 00000000..aaaf29dd --- /dev/null +++ b/test/integration/oauth_user_flow_test.rb @@ -0,0 +1,53 @@ +require "test_helper" + +class OauthUserFlowTest < ActionDispatch::IntegrationTest + setup do + @user = users(:david) + @twitter_auth = { + "uid" => "twitter_12345", + "info" => { + "email" => @user.email_address, + "nickname" => "david_x" + } + } + end + + test "complete user flow: connect X account then sign in with X" do + sign_in @user + + OmniAuth.config.test_mode = true + OmniAuth.config.mock_auth[:twitter2] = OmniAuth::AuthHash.new(@twitter_auth) + + get "/auth/twitter2/callback" + + @user.reload + assert_equal "twitter_12345", @user.twitter_uid + assert_equal "X account connected successfully!", flash[:notice] + + delete session_path + assert_redirected_to unauthenticated_root_path + + get "/auth/twitter2/callback" + + assert_redirected_to root_path + + assert @user.sessions.exists? + end + + test "user without X account tries to sign in with X" do + @twitter_auth["info"]["email"] = "newuser@example.com" + + OmniAuth.config.test_mode = true + OmniAuth.config.mock_auth[:twitter2] = OmniAuth::AuthHash.new(@twitter_auth) + + get "/auth/twitter2/callback" + + assert_redirected_to new_session_path + assert_equal "No account found. Please sign up first or use email login.", flash[:alert] + end + + teardown do + OmniAuth.config.test_mode = false + OmniAuth.config.mock_auth[:twitter2] = nil + end +end \ No newline at end of file diff --git a/test/models/user_oauth_test.rb b/test/models/user_oauth_test.rb new file mode 100644 index 00000000..aed1fc0a --- /dev/null +++ b/test/models/user_oauth_test.rb @@ -0,0 +1,37 @@ +require "test_helper" + +class UserOauthTest < ActiveSupport::TestCase + setup do + @user = users(:david) + end + + test "should validate twitter_uid uniqueness" do + @user.update!(twitter_uid: "12345") + + duplicate_user = users(:jason) + duplicate_user.twitter_uid = "12345" + + assert_not duplicate_user.valid? + assert_includes duplicate_user.errors[:twitter_uid], "has already been taken" + end + + test "should allow nil twitter_uid" do + @user.twitter_uid = nil + assert @user.valid? + end + + test "should allow multiple users with nil twitter_uid" do + users(:david).update!(twitter_uid: nil) + users(:jason).update!(twitter_uid: nil) + + assert users(:david).valid? + assert users(:jason).valid? + end + + test "should save twitter_uid successfully" do + @user.update!(twitter_uid: "twitter_12345") + @user.reload + + assert_equal "twitter_12345", @user.twitter_uid + end +end \ No newline at end of file diff --git a/test/system/oauth_system_test.rb b/test/system/oauth_system_test.rb new file mode 100644 index 00000000..65b70c67 --- /dev/null +++ b/test/system/oauth_system_test.rb @@ -0,0 +1,42 @@ +require "application_system_test_case" + +class OauthSystemTest < ApplicationSystemTestCase + setup do + @user = users(:david) + end + + test "should show Connect X button when user has no twitter_uid" do + sign_in @user + visit user_profile_path + + assert_text "Connect X" + assert_no_text "Disconnect X" + end + + test "should show Disconnect X button when user has twitter_uid" do + @user.update!(twitter_uid: "12345") + sign_in @user + visit user_profile_path + + assert_text "Disconnect X" + assert_no_text "Connect X" + end + + test "should show Sign in with X button on login page" do + visit new_session_path + + assert_text "Sign in with X" + assert_selector "a[href='/auth/twitter2']" + end + + test "should maintain twitter_url field functionality" do + sign_in @user + visit user_profile_path + + fill_in "user[twitter_url]", with: "https://x.com/david" + click_button "✓" + + @user.reload + assert_equal "https://x.com/david", @user.twitter_url + end +end \ No newline at end of file