Skip to content
43 changes: 43 additions & 0 deletions app/controllers/passwords_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
class PasswordsController < ApplicationController
before_action :find_user_by_email, only: [:create]
before_action :find_user_by_token, only: [:update]
skip_before_action :authenticate_request!, only: [:create, :update]

# POST /password_resets
def create
if @user
token = @user.generate_token_for(:password_reset)
UserMailer.send_password_reset_email(token).deliver_later
end

# Always return a 200 OK to prevent email enumeration attacks
render json: { message: "If the email exists, a reset link has been sent." }, status: :ok
end

# PATCH/PUT /password_resets/:token
def update
if @user.update(password_params)
render json: { message: "Password successfully updated." }, status: :ok
else
render json: { errors: @user.errors.full_messages }, status: :unprocessable_entity
end
end

private

def find_user_by_email
@user = User.find_by(email: params[:email])
end

def find_user_by_token
@user = User.find_by_token_for(:password_reset, params[:token])

unless @user
render json: { error: "The token has expired or is invalid." }, status: :unprocessable_entity
end
end

def password_params
params.require(:user).permit(:password, :password_confirmation)
end
end
8 changes: 8 additions & 0 deletions app/mailers/user_mailer.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
class UserMailer < ApplicationMailer
default from: "expertizamailer@gmail.com"

def send_password_reset_email(token)
@reset_url = "http://localhost:3000/password_edit/check_reset_url?token=#{token}"
mail(to: @user.email, subject: 'Expertiza password reset')
end
end
4 changes: 4 additions & 0 deletions app/models/user.rb
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,10 @@ def self.instantiate(record)
end
end

generates_token_for :password_reset, expires_in: 15.minutes do
password_salt&.last(10) || updated_at.to_s
end

# Welcome email to be sent to the user after they sign up
def welcome_email; end

Expand Down
14 changes: 14 additions & 0 deletions app/views/user_mailer/send_password_reset_email.html.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
<html>
<head>
<title>Expertiza password reset</title>
</head>

<body>
<p>Hi ,</p>
<p>Reset your password, and we'll get you on your way.</p>
<p>To change your password, click or paste the following link into your browser:</p>
<p><a href="<%= @reset_url %>"><%= @reset_url %></a></p>
<p>The link will expire in 24 hours, so be sure to use it right away.</p>
</body>
</html>

14 changes: 14 additions & 0 deletions config/environments/development.rb
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,20 @@
# Highlight code that triggered database queries in logs.
config.active_record.verbose_query_logs = true

config.action_mailer.delivery_method = :smtp
config.action_mailer.perform_deliveries = true
config.action_mailer.raise_delivery_errors = true
config.action_mailer.smtp_settings = {
address: 'smtp.gmail.com',
port: 587,
domain: 'localhost',
user_name: 'expertiza.mailer@gmail.com',
password: 'xdgmnehqevkevkqy', # This password should come from a .env file
authentication: 'plain',
enable_starttls_auto: true
}
config.action_mailer.default_url_options = { host: 'localhost', port: 3000 }

# Raises error for missing translations.
# config.i18n.raise_on_missing_translations = true

Expand Down
4 changes: 3 additions & 1 deletion config/routes.rb
Original file line number Diff line number Diff line change
Expand Up @@ -172,4 +172,6 @@
resources :assignments do
resources :duties, controller: 'assignments_duties', only: [:index, :create, :destroy]
end
end

resources :password_resets, only: [:create, :update], controller: "passwords", param: :token
end
90 changes: 90 additions & 0 deletions spec/controllers/api/v1/passwords_controller_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
require 'rails_helper'

RSpec.describe Api::V1::PasswordsController, type: :controller do
let(:user) { create(:user) }
let(:valid_password_params) { { user: { password: 'newpassword123', password_confirmation: 'newpassword123' } } }
let(:invalid_password_params) { { user: { password: 'short', password_confirmation: 'short' } } }

describe 'PasswordsController' do
describe '#create' do
context 'when the email exists' do
before do
allow(UserMailer).to receive_message_chain(:send_password_reset_email, :deliver_later)
post :create, params: { email: user.email }
end

it 'generates a password reset token' do
user.reload
expect(user.reset_password_token).to be_present
end

it 'sends a password reset email' do
expect(UserMailer).to have_received(:send_password_reset_email).with(user)
end

it 'returns a success message' do
expect(response).to have_http_status(:ok)
expect(JSON.parse(response.body)['message']).to eq("If the email exists, a reset link has been sent.")
end
end

context 'when the email does not exist' do
before do
post :create, params: { email: 'nonexistent@example.com' }
end

it 'returns an error message' do
expect(response).to have_http_status(:not_found)
expect(JSON.parse(response.body)['error']).to eq("No account is associated with the e-mail address: nonexistent@example.com. Please try again.")
end
end
end

describe '#update' do
context 'when the token is valid' do
before do
user.generate_password_reset_token!
put :update, params: { token: user.reset_password_token }.merge(valid_password_params)
end

it 'updates the password' do
user.reload
expect(user.authenticate('newpassword123')).to be_truthy
end

it 'clears the password reset token' do
user.reload
expect(user.reset_password_token).to be_nil
end

it 'returns a success message' do
expect(response).to have_http_status(:ok)
expect(JSON.parse(response.body)['message']).to eq("Password successfully updated.")
end
end

context 'when the token is invalid or expired' do
before do
put :update, params: { token: 'invalidtoken' }.merge(valid_password_params)
end

it 'returns an error message' do
expect(response).to have_http_status(:unprocessable_entity)
expect(JSON.parse(response.body)['error']).to eq("Invalid or expired token.")
end
end

context 'when the password is invalid' do
before do
user.generate_password_reset_token!
put :update, params: { token: user.reset_password_token }.merge(invalid_password_params)
end

it 'returns validation errors' do
expect(response).to have_http_status(:unprocessable_entity)
expect(JSON.parse(response.body)['errors']).to include("Password is too short (minimum is 8 characters)")
end
end
end
end
end
14 changes: 14 additions & 0 deletions spec/factories/users.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
# spec/factories/users.rb
FactoryBot.factories.clear
FactoryBot.define do
factory :user do
email { Faker::Internet.email }
password { 'password123' }
password_confirmation { 'password123' }
name { Faker::Name.first_name }
full_name { Faker::Name.name }
association :role
reset_password_sent_at { nil }
end
end

146 changes: 146 additions & 0 deletions spec/models/user_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
# spec/models/user_spec.rb
require 'rails_helper'

RSpec.describe User, type: :model do

let(:role) { create(:role, :student) }
let(:institution) { create(:institution) }

let(:user) { create(:user, role: role, institution: institution) }

describe 'validations' do
it 'validates presence of name' do
user.name = nil
expect(user).not_to be_valid
expect(user.errors[:name]).to include("can't be blank")
end

it 'validates uniqueness of name' do
duplicate_user = user.dup
duplicate_user.name = user.name
duplicate_user.save
expect(duplicate_user.errors[:name]).to include('has already been taken')
end

it 'validates presence of email' do
user.email = nil
expect(user).not_to be_valid
expect(user.errors[:email]).to include("can't be blank")
end

it 'validates email format' do
user.email = 'invalid_email'
expect(user).not_to be_valid
expect(user.errors[:email]).to include('is invalid')
end

it 'validates password length' do
user.password = 'short'
expect(user).not_to be_valid
expect(user.errors[:password]).to include('is too short (minimum is 6 characters)')
end

it 'validates presence of full_name' do
user.full_name = nil
expect(user).not_to be_valid
expect(user.errors[:full_name]).to include("can't be blank")
end
end

describe 'associations' do
it { should belong_to(:role) }
it { should belong_to(:institution).optional }
it { should belong_to(:parent).class_name('User').optional }
it { should have_many(:users).dependent(:nullify) }
it { should have_many(:invitations) }
it { should have_many(:assignments) }
it { should have_many(:teams_users).dependent(:destroy) }
it { should have_many(:teams).through(:teams_users) }
it { should have_many(:participants) }
end

describe 'callbacks' do
it 'sets default values on initialization' do
new_user = User.new
expect(new_user.is_new_user).to be true
expect(new_user.copy_of_emails).to be false
expect(new_user.email_on_review).to be false
expect(new_user.email_on_submission).to be false
expect(new_user.email_on_review_of_review).to be false
expect(new_user.etc_icons_on_homepage).to be true
end
end

describe '#login_user' do
it 'returns a user when login is email' do
result = User.login_user(user.email)
expect(result).to eq(user)
end

it 'returns a user when login is name' do
result = User.login_user(user.name)
expect(result).to eq(user)
end

it 'returns nil if no user is found' do
result = User.login_user('nonexistent_user')
expect(result).to be_nil
end
end

describe '#reset_password' do
it 'resets the password and saves the user' do
old_password_digest = user.password_digest
user.reset_password
expect(user.password_digest).not_to eq(old_password_digest)
expect(user.save).to be_truthy
end
end

describe '#instructor_id' do
it 'returns the user id if the user is an instructor' do
user.update(role: create(:role, :instructor))
expect(user.instructor_id).to eq(user.id)
end

it 'returns the instructor id if the user is a teaching assistant' do
ta_user = create(:user, role: create(:role, :ta))
instructor = create(:user, role: create(:role, :instructor))
ta_user.update(parent: instructor)
expect(ta_user.instructor_id).to eq(instructor.id)
end
end

describe '#generate_password_reset_token!' do
it 'generates and saves a reset password token' do
expect(user.reset_password_token).to be_nil
user.generate_password_reset_token!
expect(user.reset_password_token).not_to be_nil
expect(user.reset_password_sent_at).not_to be_nil
end
end

describe '#clear_password_reset_token!' do
it 'clears the reset password token' do
user.generate_password_reset_token!
token = user.reset_password_token
user.clear_password_reset_token!
expect(user.reset_password_token).to be_nil
expect(user.reset_password_sent_at).to be_nil
end
end

describe '#password_reset_valid?' do
it 'returns true if the reset password token is valid (within 24 hours)' do
user.generate_password_reset_token!
user.reset_password_sent_at = Time.zone.now - 1.hour
expect(user.password_reset_valid?).to be true
end

it 'returns false if the reset password token is expired (older than 24 hours)' do
user.generate_password_reset_token!
user.reset_password_sent_at = Time.zone.now - 25.hours
expect(user.password_reset_valid?).to be false
end
end
end