diff --git a/app/controllers/api/v1/auth_controller.rb b/app/controllers/api/v1/auth_controller.rb new file mode 100644 index 00000000..9e5e2768 --- /dev/null +++ b/app/controllers/api/v1/auth_controller.rb @@ -0,0 +1,210 @@ +module Api + module V1 + class AuthController < BaseController + include Invitable + + skip_before_action :authenticate_request! + skip_before_action :check_api_key_rate_limit + skip_before_action :log_api_access + + def signup + # Check if invite code is required + if invite_code_required? && params[:invite_code].blank? + render json: { error: "Invite code is required" }, status: :forbidden + return + end + + # Validate invite code if provided + if params[:invite_code].present? && !InviteCode.exists?(token: params[:invite_code]&.downcase) + render json: { error: "Invalid invite code" }, status: :forbidden + return + end + + # Validate password + password_errors = validate_password(params[:user][:password]) + if password_errors.any? + render json: { errors: password_errors }, status: :unprocessable_entity + return + end + + # Validate device info + unless valid_device_info? + render json: { error: "Device information is required" }, status: :bad_request + return + end + + user = User.new(user_signup_params) + + # Create family for new user + family = Family.new + user.family = family + user.role = :admin + + if user.save + # Claim invite code if provided + InviteCode.claim!(params[:invite_code]) if params[:invite_code].present? + + # Create device and OAuth token + device = create_or_update_device(user) + token_response = create_oauth_token_for_device(user, device) + + render json: token_response.merge( + user: { + id: user.id, + email: user.email, + first_name: user.first_name, + last_name: user.last_name + } + ), status: :created + else + render json: { errors: user.errors.full_messages }, status: :unprocessable_entity + end + end + + def login + user = User.find_by(email: params[:email]) + + if user&.authenticate(params[:password]) + # Check MFA if enabled + if user.otp_required? + unless params[:otp_code].present? && user.verify_otp?(params[:otp_code]) + render json: { + error: "Two-factor authentication required", + mfa_required: true + }, status: :unauthorized + return + end + end + + # Validate device info + unless valid_device_info? + render json: { error: "Device information is required" }, status: :bad_request + return + end + + # Create device and OAuth token + device = create_or_update_device(user) + token_response = create_oauth_token_for_device(user, device) + + render json: token_response.merge( + user: { + id: user.id, + email: user.email, + first_name: user.first_name, + last_name: user.last_name + } + ) + else + render json: { error: "Invalid email or password" }, status: :unauthorized + end + end + + def refresh + # Find the refresh token + refresh_token = params[:refresh_token] + + unless refresh_token.present? + render json: { error: "Refresh token is required" }, status: :bad_request + return + end + + # Find the access token associated with this refresh token + access_token = Doorkeeper::AccessToken.by_refresh_token(refresh_token) + + if access_token.nil? || access_token.revoked? + render json: { error: "Invalid refresh token" }, status: :unauthorized + return + end + + # Create new access token + new_token = Doorkeeper::AccessToken.create!( + application: access_token.application, + resource_owner_id: access_token.resource_owner_id, + expires_in: 30.days.to_i, + scopes: access_token.scopes, + use_refresh_token: true + ) + + # Revoke old access token + access_token.revoke + + # Update device last seen + user = User.find(access_token.resource_owner_id) + device = user.mobile_devices.find_by(device_id: params[:device][:device_id]) + device&.update_last_seen! + + render json: { + access_token: new_token.plaintext_token, + refresh_token: new_token.plaintext_refresh_token, + token_type: "Bearer", + expires_in: new_token.expires_in, + created_at: new_token.created_at.to_i + } + end + + private + + def user_signup_params + params.require(:user).permit(:email, :password, :first_name, :last_name) + end + + def validate_password(password) + errors = [] + + if password.blank? + errors << "Password can't be blank" + return errors + end + + errors << "Password must be at least 8 characters" if password.length < 8 + errors << "Password must include both uppercase and lowercase letters" unless password.match?(/[A-Z]/) && password.match?(/[a-z]/) + errors << "Password must include at least one number" unless password.match?(/\d/) + errors << "Password must include at least one special character" unless password.match?(/[!@#$%^&*(),.?":{}|<>]/) + + errors + end + + def valid_device_info? + device = params[:device] + return false if device.nil? + + required_fields = %w[device_id device_name device_type os_version app_version] + required_fields.all? { |field| device[field].present? } + end + + def create_or_update_device(user) + # Handle both string and symbol keys + device_data = params[:device].permit(:device_id, :device_name, :device_type, :os_version, :app_version) + + device = user.mobile_devices.find_or_initialize_by(device_id: device_data[:device_id]) + device.update!(device_data.merge(last_seen_at: Time.current)) + device + end + + def create_oauth_token_for_device(user, device) + # Create OAuth application for this device if needed + oauth_app = device.create_oauth_application! + + # Revoke any existing tokens for this device + device.revoke_all_tokens! + + # Create new access token with 30-day expiration + access_token = Doorkeeper::AccessToken.create!( + application: oauth_app, + resource_owner_id: user.id, + expires_in: 30.days.to_i, + scopes: "read_write", + use_refresh_token: true + ) + + { + access_token: access_token.plaintext_token, + refresh_token: access_token.plaintext_refresh_token, + token_type: "Bearer", + expires_in: access_token.expires_in, + created_at: access_token.created_at.to_i + } + end + end + end +end diff --git a/app/controllers/api/v1/base_controller.rb b/app/controllers/api/v1/base_controller.rb index afc3f4f3..a9b577d1 100644 --- a/app/controllers/api/v1/base_controller.rb +++ b/app/controllers/api/v1/base_controller.rb @@ -6,6 +6,9 @@ class Api::V1::BaseController < ApplicationController # Skip regular session-based authentication for API skip_authentication + # Skip CSRF protection for API endpoints + skip_before_action :verify_authenticity_token + # Skip onboarding requirements for API endpoints skip_before_action :require_onboarding_and_upgrade diff --git a/app/models/api_key.rb b/app/models/api_key.rb index 474712fa..e966c7f6 100644 --- a/app/models/api_key.rb +++ b/app/models/api_key.rb @@ -4,12 +4,16 @@ class ApiKey < ApplicationRecord # Use Rails built-in encryption for secure storage encrypts :display_key, deterministic: true + # Constants + SOURCES = [ "web", "mobile" ].freeze + # Validations validates :display_key, presence: true, uniqueness: true validates :name, presence: true validates :scopes, presence: true + validates :source, presence: true, inclusion: { in: SOURCES } validate :scopes_not_empty - validate :one_active_key_per_user, on: :create + validate :one_active_key_per_user_per_source, on: :create # Callbacks before_validation :set_display_key @@ -82,9 +86,9 @@ class ApiKey < ApplicationRecord end end - def one_active_key_per_user - if user&.api_keys&.active&.where&.not(id: id)&.exists? - errors.add(:user, "can only have one active API key") + def one_active_key_per_user_per_source + if user&.api_keys&.active&.where(source: source)&.where&.not(id: id)&.exists? + errors.add(:user, "can only have one active API key per source (#{source})") end end end diff --git a/app/models/mobile_device.rb b/app/models/mobile_device.rb new file mode 100644 index 00000000..106b2928 --- /dev/null +++ b/app/models/mobile_device.rb @@ -0,0 +1,55 @@ +class MobileDevice < ApplicationRecord + belongs_to :user + belongs_to :oauth_application, class_name: "Doorkeeper::Application", optional: true + + validates :device_id, presence: true, uniqueness: { scope: :user_id } + validates :device_name, presence: true + validates :device_type, presence: true, inclusion: { in: %w[ios android] } + + before_validation :set_last_seen_at, on: :create + + scope :active, -> { where("last_seen_at > ?", 90.days.ago) } + + def active? + last_seen_at > 90.days.ago + end + + def update_last_seen! + update_column(:last_seen_at, Time.current) + end + + def create_oauth_application! + return oauth_application if oauth_application.present? + + app = Doorkeeper::Application.create!( + name: "Mobile App - #{device_id}", + redirect_uri: "maybe://oauth/callback", # Custom scheme for mobile + scopes: "read_write", # Use the configured scope + confidential: false # Public client for mobile + ) + + # Store the association + update!(oauth_application: app) + app + end + + def active_tokens + return Doorkeeper::AccessToken.none unless oauth_application + + Doorkeeper::AccessToken + .where(application: oauth_application) + .where(resource_owner_id: user_id) + .where(revoked_at: nil) + .where("expires_in IS NULL OR created_at + expires_in * interval '1 second' > ?", Time.current) + end + + def revoke_all_tokens! + active_tokens.update_all(revoked_at: Time.current) + end + + private + + def set_last_seen_at + self.last_seen_at ||= Time.current + end +end diff --git a/app/models/user.rb b/app/models/user.rb index d391e62b..501e95b8 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -6,6 +6,7 @@ class User < ApplicationRecord has_many :sessions, dependent: :destroy has_many :chats, dependent: :destroy has_many :api_keys, dependent: :destroy + has_many :mobile_devices, dependent: :destroy has_many :invitations, foreign_key: :inviter_id, dependent: :destroy has_many :impersonator_support_sessions, class_name: "ImpersonationSession", foreign_key: :impersonator_id, dependent: :destroy has_many :impersonated_support_sessions, class_name: "ImpersonationSession", foreign_key: :impersonated_id, dependent: :destroy diff --git a/config/initializers/doorkeeper.rb b/config/initializers/doorkeeper.rb index 2a400288..9fe01849 100644 --- a/config/initializers/doorkeeper.rb +++ b/config/initializers/doorkeeper.rb @@ -255,7 +255,7 @@ Doorkeeper.configure do # NOTE: you must also run the rails g doorkeeper:application_owner generator # to provide the necessary support # - # enable_application_owner confirmation: false + enable_application_owner confirmation: false # Define access token scopes for your provider # For more information go to diff --git a/config/routes.rb b/config/routes.rb index d352a5a8..8d0976c8 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -185,6 +185,11 @@ Rails.application.routes.draw do # API routes namespace :api do namespace :v1 do + # Authentication endpoints + post "auth/signup", to: "auth#signup" + post "auth/login", to: "auth#login" + post "auth/refresh", to: "auth#refresh" + # Production API endpoints resources :accounts, only: [ :index ] resources :transactions, only: [ :index, :show, :create, :update, :destroy ] diff --git a/db/migrate/20250618104425_add_source_to_api_keys.rb b/db/migrate/20250618104425_add_source_to_api_keys.rb new file mode 100644 index 00000000..e773494d --- /dev/null +++ b/db/migrate/20250618104425_add_source_to_api_keys.rb @@ -0,0 +1,6 @@ +class AddSourceToApiKeys < ActiveRecord::Migration[7.2] + def change + add_column :api_keys, :source, :string, default: "web" + add_index :api_keys, [:user_id, :source] + end +end diff --git a/db/migrate/20250618110104_create_mobile_devices.rb b/db/migrate/20250618110104_create_mobile_devices.rb new file mode 100644 index 00000000..6875e978 --- /dev/null +++ b/db/migrate/20250618110104_create_mobile_devices.rb @@ -0,0 +1,17 @@ +class CreateMobileDevices < ActiveRecord::Migration[7.2] + def change + create_table :mobile_devices, id: :uuid do |t| + t.references :user, null: false, foreign_key: true, type: :uuid + t.string :device_id + t.string :device_name + t.string :device_type + t.string :os_version + t.string :app_version + t.datetime :last_seen_at + + t.timestamps + end + add_index :mobile_devices, :device_id, unique: true + add_index :mobile_devices, [:user_id, :device_id], unique: true + end +end diff --git a/db/migrate/20250618110736_add_owner_to_oauth_applications.rb b/db/migrate/20250618110736_add_owner_to_oauth_applications.rb new file mode 100644 index 00000000..26b03049 --- /dev/null +++ b/db/migrate/20250618110736_add_owner_to_oauth_applications.rb @@ -0,0 +1,7 @@ +class AddOwnerToOauthApplications < ActiveRecord::Migration[7.2] + def change + add_column :oauth_applications, :owner_id, :uuid + add_column :oauth_applications, :owner_type, :string + add_index :oauth_applications, [:owner_id, :owner_type] + end +end diff --git a/db/migrate/20250618120703_add_oauth_application_to_mobile_devices.rb b/db/migrate/20250618120703_add_oauth_application_to_mobile_devices.rb new file mode 100644 index 00000000..391f8c81 --- /dev/null +++ b/db/migrate/20250618120703_add_oauth_application_to_mobile_devices.rb @@ -0,0 +1,6 @@ +class AddOauthApplicationToMobileDevices < ActiveRecord::Migration[7.2] + def change + add_column :mobile_devices, :oauth_application_id, :integer + add_index :mobile_devices, :oauth_application_id + end +end diff --git a/db/schema.rb b/db/schema.rb index 95ed2ec6..e817a168 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[7.2].define(version: 2025_06_13_152743) do +ActiveRecord::Schema[7.2].define(version: 2025_06_18_110736) do # These are extensions that must be enabled in order to support this database enable_extension "pgcrypto" enable_extension "plpgsql" @@ -98,8 +98,10 @@ ActiveRecord::Schema[7.2].define(version: 2025_06_13_152743) do t.datetime "created_at", null: false t.datetime "updated_at", null: false t.string "display_key", null: false + t.string "source", default: "web" t.index ["display_key"], name: "index_api_keys_on_display_key", unique: true t.index ["revoked_at"], name: "index_api_keys_on_revoked_at" + t.index ["user_id", "source"], name: "index_api_keys_on_user_id_and_source" t.index ["user_id"], name: "index_api_keys_on_user_id" end @@ -429,6 +431,21 @@ ActiveRecord::Schema[7.2].define(version: 2025_06_13_152743) do t.index ["chat_id"], name: "index_messages_on_chat_id" end + create_table "mobile_devices", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.uuid "user_id", null: false + t.string "device_id" + t.string "device_name" + t.string "device_type" + t.string "os_version" + t.string "app_version" + t.datetime "last_seen_at" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["device_id"], name: "index_mobile_devices_on_device_id", unique: true + t.index ["user_id", "device_id"], name: "index_mobile_devices_on_user_id_and_device_id", unique: true + t.index ["user_id"], name: "index_mobile_devices_on_user_id" + end + create_table "oauth_access_grants", force: :cascade do |t| t.string "resource_owner_id", null: false t.bigint "application_id", null: false @@ -468,6 +485,9 @@ ActiveRecord::Schema[7.2].define(version: 2025_06_13_152743) do t.boolean "confidential", default: true, null: false t.datetime "created_at", null: false t.datetime "updated_at", null: false + t.uuid "owner_id" + t.string "owner_type" + t.index ["owner_id", "owner_type"], name: "index_oauth_applications_on_owner_id_and_owner_type" t.index ["uid"], name: "index_oauth_applications_on_uid", unique: true end @@ -802,6 +822,7 @@ ActiveRecord::Schema[7.2].define(version: 2025_06_13_152743) do add_foreign_key "invitations", "users", column: "inviter_id" add_foreign_key "merchants", "families" add_foreign_key "messages", "chats" + add_foreign_key "mobile_devices", "users" add_foreign_key "oauth_access_grants", "oauth_applications", column: "application_id" add_foreign_key "oauth_access_tokens", "oauth_applications", column: "application_id" add_foreign_key "plaid_accounts", "plaid_items" diff --git a/test/controllers/api/v1/auth_controller_test.rb b/test/controllers/api/v1/auth_controller_test.rb new file mode 100644 index 00000000..9c021387 --- /dev/null +++ b/test/controllers/api/v1/auth_controller_test.rb @@ -0,0 +1,410 @@ +require "test_helper" + +class Api::V1::AuthControllerTest < ActionDispatch::IntegrationTest + setup do + # Clean up any existing invite codes + InviteCode.destroy_all + @device_info = { + device_id: "test-device-123", + device_name: "Test iPhone", + device_type: "ios", + os_version: "17.0", + app_version: "1.0.0" + } + end + + test "should signup new user and return OAuth tokens" do + assert_difference("User.count", 1) do + assert_difference("MobileDevice.count", 1) do + assert_difference("Doorkeeper::Application.count", 1) do + assert_difference("Doorkeeper::AccessToken.count", 1) do + post "/api/v1/auth/signup", params: { + user: { + email: "newuser@example.com", + password: "SecurePass123!", + first_name: "New", + last_name: "User" + }, + device: @device_info + } + end + end + end + end + + assert_response :created + response_data = JSON.parse(response.body) + + assert response_data["user"]["id"].present? + assert_equal "newuser@example.com", response_data["user"]["email"] + assert_equal "New", response_data["user"]["first_name"] + assert_equal "User", response_data["user"]["last_name"] + + # OAuth token assertions + assert response_data["access_token"].present? + assert response_data["refresh_token"].present? + assert_equal "Bearer", response_data["token_type"] + assert_equal 2592000, response_data["expires_in"] # 30 days + assert response_data["created_at"].present? + + # Verify the device was created + new_user = User.find(response_data["user"]["id"]) + device = new_user.mobile_devices.first + assert_equal @device_info[:device_id], device.device_id + assert_equal @device_info[:device_name], device.device_name + assert_equal @device_info[:device_type], device.device_type + end + + test "should not signup without device info" do + assert_no_difference("User.count") do + post "/api/v1/auth/signup", params: { + user: { + email: "newuser@example.com", + password: "SecurePass123!", + first_name: "New", + last_name: "User" + } + } + end + + assert_response :bad_request + response_data = JSON.parse(response.body) + assert_equal "Device information is required", response_data["error"] + end + + test "should not signup with invalid password" do + assert_no_difference("User.count") do + post "/api/v1/auth/signup", params: { + user: { + email: "newuser@example.com", + password: "weak", + first_name: "New", + last_name: "User" + }, + device: @device_info + } + end + + assert_response :unprocessable_entity + response_data = JSON.parse(response.body) + assert response_data["errors"].include?("Password must be at least 8 characters") + end + + test "should not signup with duplicate email" do + existing_user = users(:family_admin) + + assert_no_difference("User.count") do + post "/api/v1/auth/signup", params: { + user: { + email: existing_user.email, + password: "SecurePass123!", + first_name: "Duplicate", + last_name: "User" + }, + device: @device_info + } + end + + assert_response :unprocessable_entity + end + + test "should create user with admin role and family" do + post "/api/v1/auth/signup", params: { + user: { + email: "newuser@example.com", + password: "SecurePass123!", + first_name: "New", + last_name: "User" + }, + device: @device_info + } + + assert_response :created + response_data = JSON.parse(response.body) + + new_user = User.find(response_data["user"]["id"]) + assert_equal "admin", new_user.role + assert new_user.family.present? + end + + test "should require invite code when enabled" do + # Mock invite code requirement + Api::V1::AuthController.any_instance.stubs(:invite_code_required?).returns(true) + + assert_no_difference("User.count") do + post "/api/v1/auth/signup", params: { + user: { + email: "newuser@example.com", + password: "SecurePass123!", + first_name: "New", + last_name: "User" + }, + device: @device_info + } + end + + assert_response :forbidden + response_data = JSON.parse(response.body) + assert_equal "Invite code is required", response_data["error"] + end + + test "should signup with valid invite code when required" do + # Create a valid invite code + invite_code = InviteCode.create! + + # Mock invite code requirement + Api::V1::AuthController.any_instance.stubs(:invite_code_required?).returns(true) + + assert_difference("User.count", 1) do + assert_difference("InviteCode.count", -1) do + post "/api/v1/auth/signup", params: { + user: { + email: "newuser@example.com", + password: "SecurePass123!", + first_name: "New", + last_name: "User" + }, + device: @device_info, + invite_code: invite_code.token + } + end + end + + assert_response :created + end + + test "should reject invalid invite code" do + # Mock invite code requirement + Api::V1::AuthController.any_instance.stubs(:invite_code_required?).returns(false) + + assert_no_difference("User.count") do + post "/api/v1/auth/signup", params: { + user: { + email: "newuser@example.com", + password: "SecurePass123!", + first_name: "New", + last_name: "User" + }, + device: @device_info, + invite_code: "invalid_code" + } + end + + assert_response :forbidden + response_data = JSON.parse(response.body) + assert_equal "Invalid invite code", response_data["error"] + end + + test "should login existing user and return OAuth tokens" do + user = users(:family_admin) + password = user_password_test + + # Ensure user has no mobile devices + user.mobile_devices.destroy_all + + assert_difference("MobileDevice.count", 1) do + assert_difference("Doorkeeper::AccessToken.count", 1) do + post "/api/v1/auth/login", params: { + email: user.email, + password: password, + device: @device_info + } + end + end + + assert_response :success + response_data = JSON.parse(response.body) + + assert_equal user.id.to_s, response_data["user"]["id"] + assert_equal user.email, response_data["user"]["email"] + + # OAuth token assertions + assert response_data["access_token"].present? + assert response_data["refresh_token"].present? + assert_equal "Bearer", response_data["token_type"] + assert_equal 2592000, response_data["expires_in"] # 30 days + + # Verify the device + device = user.mobile_devices.where(device_id: @device_info[:device_id]).first + assert device.present? + assert device.active? + end + + test "should require MFA when enabled" do + user = users(:family_admin) + password = user_password_test + + # Enable MFA for user + user.setup_mfa! + user.enable_mfa! + + post "/api/v1/auth/login", params: { + email: user.email, + password: password, + device: @device_info + } + + assert_response :unauthorized + response_data = JSON.parse(response.body) + assert_equal "Two-factor authentication required", response_data["error"] + assert response_data["mfa_required"] + end + + test "should login with valid MFA code" do + user = users(:family_admin) + password = user_password_test + + # Enable MFA for user + user.setup_mfa! + user.enable_mfa! + totp = ROTP::TOTP.new(user.otp_secret) + + assert_difference("Doorkeeper::AccessToken.count", 1) do + post "/api/v1/auth/login", params: { + email: user.email, + password: password, + otp_code: totp.now, + device: @device_info + } + end + + assert_response :success + response_data = JSON.parse(response.body) + assert response_data["access_token"].present? + end + + test "should revoke existing tokens for same device on login" do + user = users(:family_admin) + password = user_password_test + + # Create an existing device and token + device = user.mobile_devices.create!(@device_info) + oauth_app = device.create_oauth_application! + existing_token = Doorkeeper::AccessToken.create!( + application: oauth_app, + resource_owner_id: user.id, + expires_in: 30.days.to_i, + scopes: "read_write" + ) + + assert existing_token.accessible? + + post "/api/v1/auth/login", params: { + email: user.email, + password: password, + device: @device_info + } + + assert_response :success + + # Check that old token was revoked + existing_token.reload + assert existing_token.revoked? + end + + test "should not login with invalid password" do + user = users(:family_admin) + + assert_no_difference("Doorkeeper::AccessToken.count") do + post "/api/v1/auth/login", params: { + email: user.email, + password: "wrong_password", + device: @device_info + } + end + + assert_response :unauthorized + response_data = JSON.parse(response.body) + assert_equal "Invalid email or password", response_data["error"] + end + + test "should not login with non-existent email" do + assert_no_difference("Doorkeeper::AccessToken.count") do + post "/api/v1/auth/login", params: { + email: "nonexistent@example.com", + password: user_password_test, + device: @device_info + } + end + + assert_response :unauthorized + response_data = JSON.parse(response.body) + assert_equal "Invalid email or password", response_data["error"] + end + + test "should not login without device info" do + user = users(:family_admin) + + assert_no_difference("Doorkeeper::AccessToken.count") do + post "/api/v1/auth/login", params: { + email: user.email, + password: user_password_test + } + end + + assert_response :bad_request + response_data = JSON.parse(response.body) + assert_equal "Device information is required", response_data["error"] + end + + test "should refresh access token with valid refresh token" do + user = users(:family_admin) + device = user.mobile_devices.create!(@device_info) + oauth_app = device.create_oauth_application! + + # Create initial token + initial_token = Doorkeeper::AccessToken.create!( + application: oauth_app, + resource_owner_id: user.id, + expires_in: 30.days.to_i, + scopes: "read_write", + use_refresh_token: true + ) + + # Wait to ensure different timestamps + sleep 0.1 + + assert_difference("Doorkeeper::AccessToken.count", 1) do + post "/api/v1/auth/refresh", params: { + refresh_token: initial_token.refresh_token, + device: @device_info + } + end + + assert_response :success + response_data = JSON.parse(response.body) + + # New token assertions + assert response_data["access_token"].present? + assert response_data["refresh_token"].present? + assert_not_equal initial_token.token, response_data["access_token"] + assert_equal 2592000, response_data["expires_in"] + + # Old token should be revoked + initial_token.reload + assert initial_token.revoked? + end + + test "should not refresh with invalid refresh token" do + assert_no_difference("Doorkeeper::AccessToken.count") do + post "/api/v1/auth/refresh", params: { + refresh_token: "invalid_token", + device: @device_info + } + end + + assert_response :unauthorized + response_data = JSON.parse(response.body) + assert_equal "Invalid refresh token", response_data["error"] + end + + test "should not refresh without refresh token" do + post "/api/v1/auth/refresh", params: { + device: @device_info + } + + assert_response :bad_request + response_data = JSON.parse(response.body) + assert_equal "Refresh token is required", response_data["error"] + end +end \ No newline at end of file diff --git a/test/fixtures/mobile_devices.yml b/test/fixtures/mobile_devices.yml new file mode 100644 index 00000000..28dc37ea --- /dev/null +++ b/test/fixtures/mobile_devices.yml @@ -0,0 +1,3 @@ +# Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html + +# Empty fixtures to avoid conflicts \ No newline at end of file diff --git a/test/models/mobile_device_test.rb b/test/models/mobile_device_test.rb new file mode 100644 index 00000000..79bb8f62 --- /dev/null +++ b/test/models/mobile_device_test.rb @@ -0,0 +1,7 @@ +require "test_helper" + +class MobileDeviceTest < ActiveSupport::TestCase + # test "the truth" do + # assert true + # end +end