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