diff --git a/Gemfile b/Gemfile index ffddede38..9a1a1c022 100644 --- a/Gemfile +++ b/Gemfile @@ -105,6 +105,7 @@ gem 'net-http', '>= 0.4.0' gem 'sprockets-rails', '>= 3.5.1' gem 'connection_pool', '< 3' +gem 'notifications-ruby-client' group :development, :test do gem 'brakeman', require: false diff --git a/Gemfile.lock b/Gemfile.lock index 923871bc3..62d38be18 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -355,6 +355,10 @@ GEM nokogiri (1.19.2) mini_portile2 (~> 2.8.2) racc (~> 1.4) + nokogiri (1.19.2-x86_64-linux-gnu) + racc (~> 1.4) + notifications-ruby-client (6.3.0) + jwt (>= 1.5, < 4) oauth2 (2.0.18) faraday (>= 0.17.3, < 4.0) jwt (>= 1.0, < 4.0) @@ -628,7 +632,9 @@ GEM zeitwerk (2.7.5) PLATFORMS + aarch64-linux-musl ruby + x86_64-linux DEPENDENCIES aasm @@ -666,6 +672,7 @@ DEPENDENCIES mutex_m (~> 0.3.0) net-http (>= 0.4.0) nokogiri (>= 1.18.9) + notifications-ruby-client omniauth-google-oauth2 (>= 1.2.2) omniauth-rails_csrf_protection (~> 2.0, >= 2.0.0) parslet diff --git a/app/controllers/admin/users_controller.rb b/app/controllers/admin/users_controller.rb index f3fb89a26..eedb1d63d 100644 --- a/app/controllers/admin/users_controller.rb +++ b/app/controllers/admin/users_controller.rb @@ -1,5 +1,7 @@ class Admin::UsersController < AdminController - before_action :find_user, only: %i[show edit update reactivate_user confirm_delete confirm_reactivate destroy] + before_action :find_user, + only: %i[show edit update reactivate_user confirm_delete confirm_reactivate destroy edit_email + update_email] def index @users = User.search(params[:search]).page(params[:page]) @@ -76,6 +78,25 @@ def update redirect_to admin_user_path(@user) end + def edit_email; end + + def update_email + new_email = user_params[:email] + if new_email == @user.email + flash[:notice] = I18n.t('errors.messages.error_updating_same_user_email_in_auth0') + redirect_to admin_user_path(@user) + return + end + + result = UpdateUserEmail.new(@user, new_email) + if result.call + flash[:notice] = I18n.t('errors.messages.success_updating_user_email_in_auth0') + elsif result.failure? + flash[:alert] = I18n.t('errors.messages.error_updating_user_email_in_auth0') + end + redirect_to admin_user_path(@user) + end + def confirm_delete; end def confirm_reactivate; end diff --git a/app/controllers/v1/email_verifications_controller.rb b/app/controllers/v1/email_verifications_controller.rb new file mode 100644 index 000000000..0d0499645 --- /dev/null +++ b/app/controllers/v1/email_verifications_controller.rb @@ -0,0 +1,97 @@ +module V1 + class EmailVerificationsController < ApiController + skip_before_action :reject_without_user!, only: [:verification] + + def create + user = User.find_by(auth_id: current_auth_id) + new_email = params.dig('_jsonapi', 'new_email') + + verification_request = verification_request(user, new_email) + + if verification_request.save + + SendEmailVerificationJob.perform_later( + new_email: new_email, + verification_url: verification_request.verification_url, + person_name: user.name + ) + + render jsonapi: verification_request, status: :ok, context: { request: request } + else + render jsonapi_errors: verification_request.errors, status: :unprocessable_entity + end + end + + def verification + token = find_token_params || (return render_invalid_token_error) + email_change_request = EmailChangeRequest.find_by(token: token) || (return render_invalid_token_error) + update_user_email = update_email(email_change_request) + email_change_request.update(used_at: Time.current, active: false) + + return render jsonapi: email_change_request.user, status: :ok if update_user_email&.call + + error_body = verification_request&.errors || + { error: I18n.t('email_verifications.invalid_or_expired_token') } + render jsonapi_errors: error_body, status: :unprocessable_entity + end + + def active + user = User.find_by(auth_id: current_auth_id) + unless user + render jsonapi_errors: { error: 'User not found' }, status: :not_found + return + end + + verification = EmailChangeRequest.where(user: user, active: true).order(created_at: :desc).first + + if verification + render jsonapi: verification, status: :ok, context: { request: request } + else + render jsonapi_errors: { error: 'No active email verification found' }, status: :not_found + end + end + + def cancel_pending_email_change + user = User.find_by(auth_id: current_auth_id) + unless user + render jsonapi_errors: { error: 'User not found' }, status: :not_found + return + end + + EmailChangeRequest.where(user: user, active: true).find_each do |record| + record.update(active: false) + end + + head :no_content + end + + private + + def verification_request(user, new_email) + expires_at = 2.days.from_now + + EmailChangeRequest.where(user: user, active: true).find_each do |record| + record.update(active: false) + end + + EmailChangeRequest.new( + user: user, + new_email: new_email, + expires_at: expires_at + ) + end + + def update_email(email_change_request) + UpdateUserEmail.new(email_change_request.user, email_change_request.new_email) + end + + def render_invalid_token_error + render jsonapi_errors: ApiMessage.new({ message: I18n.t('email_verifications.invalid_or_expired_token') }), + status: :unprocessable_entity + end + + def find_token_params + params.dig('_jsonapi', 'token') + end + end +end diff --git a/app/controllers/v1/users_controller.rb b/app/controllers/v1/users_controller.rb index 03603e28b..3ef853c73 100644 --- a/app/controllers/v1/users_controller.rb +++ b/app/controllers/v1/users_controller.rb @@ -17,4 +17,60 @@ def update_name status: :unprocessable_entity end end + + def update_email + user = User.find_by(auth_id: current_auth_id) + new_email = params.dig('_jsonapi', 'email') + + if new_email.blank? + render jsonapi_errors: { email: ['is required'] }, status: :unprocessable_entity + return + end + + verification_request = verification_request(user, new_email) + + if verification_request.save + notify_result = SendEmailVerificationJob.perform_later( + new_email: new_email, + verification_url: verification_request.verification_url, + person_name: user.name + ) + + if notify_result == false + render jsonapi_errors: { notify: ['Failed to send email'] }, status: :unprocessable_entity + return + end + + render jsonapi: verification_request, status: :ok, context: { request: request } + else + render jsonapi_errors: verification_request.errors.presence || { base: ['Failed to save verification request'] }, + status: :unprocessable_entity + end + end + + def verification_request(user, new_email) + token = SecureRandom.hex(24) + expires_at = 2.days.from_now + + EmailChangeRequest.where(user: user, active: true).find_each do |record| + record.update(active: false) + end + + EmailChangeRequest.new( + user: user, + new_email: new_email, + token: token, + expires_at: expires_at + ) + end + + def user_auth_logs + user = User.find_by!(auth_id: current_auth_id) + auth_logs = UserLogsInAuth0.new(user: user).call + objects = auth_logs.map do |log| + OpenStruct.new(log.merge('id' => SecureRandom.uuid)) + end + + render jsonapi: objects, class: { OpenStruct: SerializableUserAuthLog }, status: :ok + end end diff --git a/app/jobs/send_confirm_email_verification_job.rb b/app/jobs/send_confirm_email_verification_job.rb new file mode 100644 index 000000000..bfd87094b --- /dev/null +++ b/app/jobs/send_confirm_email_verification_job.rb @@ -0,0 +1,17 @@ +class SendConfirmEmailVerificationJob < ApplicationJob + queue_as :default + + TEMPLATE_ID = '59cda9d1-9a70-4196-8b50-fba71e410765'.freeze + + def perform(new_email:, person_name: nil) + Notify.new.send_email( + template_id: TEMPLATE_ID, + email: new_email, + vars: { + login_url: ENV['FRONTEND_URL'], + email_address: new_email, + person_name: person_name + } + ) + end +end diff --git a/app/jobs/send_email_verification_job.rb b/app/jobs/send_email_verification_job.rb new file mode 100644 index 000000000..77c87a173 --- /dev/null +++ b/app/jobs/send_email_verification_job.rb @@ -0,0 +1,17 @@ +class SendEmailVerificationJob < ApplicationJob + queue_as :default + + TEMPLATE_ID = '26164cdb-915b-4e13-97e9-d4a42367f068'.freeze + + def perform(new_email:, verification_url:, person_name: nil) + Notify.new.send_email( + template_id: TEMPLATE_ID, + email: new_email, + vars: { + verify_url: verification_url, + new_email: new_email, + person_name: person_name + } + ) + end +end diff --git a/app/models/api_message.rb b/app/models/api_message.rb new file mode 100644 index 000000000..0f88bb8e5 --- /dev/null +++ b/app/models/api_message.rb @@ -0,0 +1,26 @@ +class ApiMessage + def initialize(attributes = {}) + @attributes = attributes.transform_keys(&:to_sym) + @attributes[:id] ||= SecureRandom.uuid + end + + def id + @attributes[:id] + end + + def as_json(*_args) + @attributes + end + + def method_missing(method, *args, &block) + if @attributes.key?(method) + @attributes[method] + else + super + end + end + + def respond_to_missing?(method, include_private = false) + @attributes.key?(method) || super + end +end diff --git a/app/models/email_change_request.rb b/app/models/email_change_request.rb new file mode 100644 index 000000000..48f79b38d --- /dev/null +++ b/app/models/email_change_request.rb @@ -0,0 +1,39 @@ +class EmailChangeRequest < ApplicationRecord + has_secure_token :token, length: 64 + belongs_to :user + + validates :new_email, presence: true, format: { with: URI::MailTo::EMAIL_REGEXP } + validates :expires_at, presence: true + validate :new_email_not_taken + validate :email_not_verified + validate :verifiable, on: :update + + def self.verified_email(user, new_email) + where(user: user, new_email: new_email, active: false) + .where.not(used_at: nil) + .order(used_at: :desc) + .first + end + + def email_not_verified + return if self.class.verified_email(user, new_email).blank? + + errors.add(:new_email, I18n.t('email_verifications.already_verified')) + end + + def verification_url + "#{ENV['FRONTEND_URL']}/email/verification/#{token}" + end + + def verifiable + used_at.nil? && expires_at > Time.current + end + + private + + def new_email_not_taken + return unless User.exists?(email: new_email) + + errors.add(:new_email, 'is already taken') + end +end diff --git a/app/models/user.rb b/app/models/user.rb index ae41a036d..32cba1e76 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -1,6 +1,9 @@ require './lib/auth0_api' class User < ApplicationRecord + has_one :email_change_request, lambda { + where(active: true).order(created_at: :desc) + }, class_name: 'EmailChangeRequest', dependent: :destroy, inverse_of: :user has_many :memberships, dependent: :destroy has_many :suppliers, through: :memberships has_many :submissions, through: :suppliers diff --git a/app/serializable/serializable_api_message.rb b/app/serializable/serializable_api_message.rb new file mode 100644 index 000000000..8b8b9da45 --- /dev/null +++ b/app/serializable/serializable_api_message.rb @@ -0,0 +1,9 @@ +class SerializableApiMessage < JSONAPI::Serializable::Resource + type 'users' + + id { @object.id } + + attribute :attributes do + @object.as_json.except(:id) + end +end diff --git a/app/serializable/serializable_email_change_request.rb b/app/serializable/serializable_email_change_request.rb new file mode 100644 index 000000000..b121bccbf --- /dev/null +++ b/app/serializable/serializable_email_change_request.rb @@ -0,0 +1,4 @@ +class SerializableEmailChangeRequest < JSONAPI::Serializable::Resource + type 'email_change_requests' + attributes :new_email, :expires_at, :token, :verification_url +end diff --git a/app/serializable/serializable_user_auth_log.rb b/app/serializable/serializable_user_auth_log.rb new file mode 100644 index 000000000..db96b2a1c --- /dev/null +++ b/app/serializable/serializable_user_auth_log.rb @@ -0,0 +1,13 @@ +# app/serializable/serializable_user_auth_log.rb +class SerializableUserAuthLog < JSONAPI::Serializable::Resource + type 'user_auth_logs' + + attributes :date, :type, :description, :connection, :connection_id, :client_id, :client_name, + :ip, :client_ip, :user_agent, :details, :hostname, :user_id, :user_name, + :auth0_client, :strategy, :strategy_type, :environment_name, :log_id, :tenant_name, + :_id, :isMobile, :location_info + + attribute :event_schema do + @object['$event_schema'] + end +end diff --git a/app/services/notify.rb b/app/services/notify.rb new file mode 100644 index 000000000..919df32fc --- /dev/null +++ b/app/services/notify.rb @@ -0,0 +1,18 @@ +require 'notifications/client' + +class Notify + def initialize + @client = Notifications::Client.new(ENV['GOV_NOTIFY_API_KEY']) + end + + def send_email(template_id:, email:, vars: {}) + @client.send_email( + email_address: email, + template_id: template_id, + personalisation: vars + ) + rescue Notifications::Client::RequestError => e + Rails.logger.error "GOV.UK Notify Error: #{e.message}" + false + end +end diff --git a/app/services/update_user_email.rb b/app/services/update_user_email.rb new file mode 100644 index 000000000..ab52c77cb --- /dev/null +++ b/app/services/update_user_email.rb @@ -0,0 +1,34 @@ +class UpdateUserEmail + include ActiveModel::Validations + + def initialize(user, new_email) + @user = user + @new_email = new_email + end + + def call + User.transaction do + update_user_record + sync_with_auth0 + end + errors.empty? + end + + private + + def update_user_record + return if @user.update(email: @new_email) + + @user.errors.each { |error| errors.add(error.attribute, error.message) } + raise ActiveRecord::Rollback + end + + def sync_with_auth0 + UpdateUserEmailInAuth0.new(user: @user).call + SendConfirmEmailVerificationJob.perform_later(new_email: @new_email, person_name: @user.name) + rescue Auth0::Exception => e + errors.add(:base, "Auth0 update failed: #{e.message}") + Rails.logger.error("Auth0 Error: #{e.message}") + raise ActiveRecord::Rollback + end +end diff --git a/app/services/update_user_email_in_auth0.rb b/app/services/update_user_email_in_auth0.rb new file mode 100644 index 000000000..65a1ac4bb --- /dev/null +++ b/app/services/update_user_email_in_auth0.rb @@ -0,0 +1,17 @@ +class UpdateUserEmailInAuth0 + attr_reader :user + + def initialize(user:) + @user = user + end + + def call + auth0_client.update_user(user.auth_id, email: user.email, verify_email: false, email_verified: true) + end + + private + + def auth0_client + @auth0_client ||= Auth0Api.new.client + end +end diff --git a/app/services/user_logs_in_auth0.rb b/app/services/user_logs_in_auth0.rb new file mode 100644 index 000000000..a2d71b2a5 --- /dev/null +++ b/app/services/user_logs_in_auth0.rb @@ -0,0 +1,24 @@ +require './lib/auth0_api' + +class UserLogsInAuth0 + attr_accessor :user + + def initialize(user:) + self.user = user + end + + def call + query = "(type:\"s\" OR type:\"f\") AND user_id:\"#{user.auth_id}\"" + auth0_client.logs( + q: query, + per_page: 5, + sort: 'date:-1' + ) + end + + private + + def auth0_client + @auth0_client ||= Auth0Api.new.client + end +end diff --git a/app/views/admin/users/edit_email.html.haml b/app/views/admin/users/edit_email.html.haml new file mode 100644 index 000000000..eb0d78f78 --- /dev/null +++ b/app/views/admin/users/edit_email.html.haml @@ -0,0 +1,12 @@ +.govuk-grid-row + .govuk-grid-column-two-thirds + = simple_form_for [:admin, @user], url: update_email_admin_user_path(@user), method: :patch do |form| + %fieldset.govuk-fieldset + %legend.govuk-fieldset__legend.govuk-fieldset__legend--xl + %h1.govuk-fieldset__heading + Update user email + + = render partial: 'shared/error_summary', locals: { entity: @user } if @user.errors.present? + + = form.input :email, hide_optional: true + = form.button :submit, value: 'Update email' diff --git a/app/views/admin/users/show.html.haml b/app/views/admin/users/show.html.haml index fe6a96534..68f40e61f 100644 --- a/app/views/admin/users/show.html.haml +++ b/app/views/admin/users/show.html.haml @@ -1,3 +1,15 @@ +- if @user.email_change_request&.token.present? + .govuk-notification-banner{"aria-labelledby" => "govuk-notification-banner-title", "data-module" => "govuk-notification-banner", :role => "region"} + .govuk-notification-banner__header + %h2#govuk-notification-banner-title.govuk-notification-banner__title + Important + .govuk-notification-banner__content + %p.govuk-notification-banner__heading + User has a pending action to verify your new email address + = @user.email_change_request.new_email + -# = succeed "." do + -# = link_to 'Resend email', resend_email_verification_path(email: @user.new_email), method: :post, class: 'govuk-notification-banner__link' + .govuk-grid-row .govuk-grid-column-two-thirds = link_to 'Back', admin_users_path, { class: 'govuk-back-link govuk-!-margin-bottom-5', title: 'Back to users' } @@ -20,6 +32,8 @@ = link_to 'Deactivate user', confirm_delete_admin_user_path(@user) %li.govuk-page-actions--action = link_to 'Update user name', edit_admin_user_path(@user) + %li.govuk-page-actions--action + = link_to 'Update user email', edit_email_admin_user_path(@user) - else %li.govuk-page-actions--action = link_to 'Reactivate user', confirm_reactivate_admin_user_path(@user) diff --git a/config/locales/en.yml b/config/locales/en.yml index fa41882a2..c5ec410bc 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -1,4 +1,3 @@ ---- en: admin: frameworks: @@ -46,4 +45,12 @@ en: invalid_dependent_field: '"%{value}" is not a valid %{attr} for the given %{parent_field_name} of "%{parent_field_value}". Please refer to the lookups tab in the template.' error_adding_user_to_auth0: 'There was an error adding the user to Auth0. Please try again.' error_updating_user_in_auth0: "There was an error updating this user's name in Auth0. Please try again." + error_updating_user_email_in_auth0: "There was an error updating this user's email in Auth0. Please try again." missing_template_file: 'Missing template file' + error_updating_same_user_email_in_auth0: "Email is unchanged." + success_updating_user_email_in_auth0: "Email updated successfully." + email_verifications: + already_verified: "This email address has already been verified for this user." + verification_sent: "A verification link has been sent: %{url} (expires at %{expires_at})" + invalid_or_expired_token: "Invalid or expired token." + verified_and_updated: "Email address verified and updated." \ No newline at end of file diff --git a/config/routes.rb b/config/routes.rb index 84d0fc374..02e7902c7 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -24,9 +24,17 @@ resources :users, only: %i[index] do collection do patch :update_name + patch :update_email + get :user_auth_logs end end + post 'email_verifications/verify_token', to: 'email_verifications#verification', as: :email_verification + post 'email_verifications', to: 'email_verifications#create', as: :generate_email_verification + get 'email_verifications/active_verification', to: 'email_verifications#active', as: :active_email_verification + delete 'email_verifications/cancel_pending_email_change', to: 'email_verifications#cancel_pending_email_change', +as: :cancel_pending_email_change + resources :suppliers, only: %i[index] resources :submissions, only: %i[show create update] do @@ -91,6 +99,8 @@ post :reactivate_user get :confirm_delete get :confirm_reactivate + get :edit_email + patch :update_email end collection do diff --git a/db/migrate/20260321120000_create_email_change_requests.rb b/db/migrate/20260321120000_create_email_change_requests.rb new file mode 100644 index 000000000..4639c0046 --- /dev/null +++ b/db/migrate/20260321120000_create_email_change_requests.rb @@ -0,0 +1,14 @@ +class CreateEmailChangeRequests < ActiveRecord::Migration[6.0] + def change + create_table :email_change_requests do |t| + t.references :user, null: false, foreign_key: true, type: :uuid + t.string :new_email, null: false + t.string :token, null: false + t.datetime :expires_at, null: false + t.datetime :used_at + + t.timestamps + end + add_index :email_change_requests, :token, unique: true + end +end diff --git a/db/migrate/20260322120000_add_active_to_email_change_requests.rb b/db/migrate/20260322120000_add_active_to_email_change_requests.rb new file mode 100644 index 000000000..c03eb3b11 --- /dev/null +++ b/db/migrate/20260322120000_add_active_to_email_change_requests.rb @@ -0,0 +1,6 @@ +class AddActiveToEmailChangeRequests < ActiveRecord::Migration[6.0] + def change + add_column :email_change_requests, :active, :boolean, default: true, null: false + add_index :email_change_requests, :active + end +end diff --git a/db/schema.rb b/db/schema.rb index b1e9143f9..f1f3b9311 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -107,6 +107,20 @@ t.index ["range_to"], name: "index_data_warehouse_exports_on_range_to" end + create_table "email_change_requests", force: :cascade do |t| + t.boolean "active", default: true, null: false + t.datetime "created_at", null: false + t.datetime "expires_at", precision: nil, null: false + t.string "new_email", null: false + t.string "token", null: false + t.datetime "updated_at", null: false + t.datetime "used_at", precision: nil + t.uuid "user_id", null: false + t.index ["active"], name: "index_email_change_requests_on_active" + t.index ["token"], name: "index_email_change_requests_on_token", unique: true + t.index ["user_id"], name: "index_email_change_requests_on_user_id" + end + create_table "event_store_events", id: :serial, force: :cascade do |t| t.datetime "created_at", precision: nil, null: false t.text "data", null: false @@ -312,6 +326,7 @@ add_foreign_key "agreement_framework_lots", "agreements" add_foreign_key "agreement_framework_lots", "framework_lots" add_foreign_key "customer_effort_scores", "users" + add_foreign_key "email_change_requests", "users" add_foreign_key "framework_lots", "frameworks" add_foreign_key "memberships", "suppliers" add_foreign_key "submission_entries", "customers", column: "customer_urn", primary_key: "urn" diff --git a/spec/factories/email_change_requests.rb b/spec/factories/email_change_requests.rb new file mode 100644 index 000000000..699b3b59e --- /dev/null +++ b/spec/factories/email_change_requests.rb @@ -0,0 +1,10 @@ +FactoryBot.define do + factory :email_change_request do + association :user + new_email { 'testuser@example.com' } + token { SecureRandom.hex(24) } + expires_at { 2.days.from_now } + active { true } + used_at { nil } + end +end diff --git a/spec/models/email_change_request_spec.rb b/spec/models/email_change_request_spec.rb new file mode 100644 index 000000000..2820a7096 --- /dev/null +++ b/spec/models/email_change_request_spec.rb @@ -0,0 +1,88 @@ +require 'rails_helper' + +RSpec.describe EmailChangeRequest, type: :model do + let(:user) { FactoryBot.create(:user) } + + describe 'validations' do + it 'is valid with valid attributes' do + request = FactoryBot.build(:email_change_request, user: user) + expect(request).to be_valid + end + + it 'is invalid without a new_email' do + request = FactoryBot.build(:email_change_request, user: user, new_email: nil) + expect(request).not_to be_valid + expect(request.errors[:new_email]).to include("can't be blank") + end + + it 'is invalid with a badly formatted email' do + request = FactoryBot.build(:email_change_request, user: user, new_email: 'not-an-email') + expect(request).not_to be_valid + expect(request.errors[:new_email]).to be_present + end + + it 'is invalid without expires_at' do + request = FactoryBot.build(:email_change_request, user: user, expires_at: nil) + expect(request).not_to be_valid + expect(request.errors[:expires_at]).to include("can't be blank") + end + + it 'is invalid if new_email is already taken by another user' do + existing_user = FactoryBot.create(:user, email: 'taken@example.com') + request = FactoryBot.build(:email_change_request, user: user, new_email: existing_user.email) + expect(request).not_to be_valid + expect(request.errors[:new_email]).to include('is already taken') + end + + it 'is invalid if the email has already been verified for this user' do + FactoryBot.create(:email_change_request, user: user, new_email: 'verified@example.com', active: false, +used_at: Time.current) + request = FactoryBot.build(:email_change_request, user: user, new_email: 'verified@example.com') + expect(request).not_to be_valid + expect(request.errors[:new_email]).to include(I18n.t('email_verifications.already_verified')) + end + end + + describe '.verified_email' do + it 'returns the most recently used verified request' do + older = FactoryBot.build(:email_change_request, user: user, new_email: 'v@example.com', active: false, +used_at: 2.days.ago) + older.save(validate: false) + newer = FactoryBot.build(:email_change_request, user: user, new_email: 'v@example.com', active: false, +used_at: 1.day.ago) + newer.save(validate: false) + result = described_class.verified_email(user, 'v@example.com') + expect(result).to eq(newer) + end + + it 'returns nil when no verified request exists' do + expect(described_class.verified_email(user, 'none@example.com')).to be_nil + end + end + + describe '#verification_url' do + it 'builds the URL from FRONTEND_URL and token' do + request = FactoryBot.create(:email_change_request, user: user) + ClimateControl.modify FRONTEND_URL: 'https://frontend.example.com' do + expect(request.verification_url).to eq("https://frontend.example.com/email/verification/#{request.token}") + end + end + end + + describe '#verifiable' do + it 'returns true when unused and not expired' do + request = FactoryBot.build(:email_change_request, user: user, used_at: nil, expires_at: 2.days.from_now) + expect(request.verifiable).to be true + end + + it 'returns false when already used' do + request = FactoryBot.build(:email_change_request, user: user, used_at: Time.current, expires_at: 2.days.from_now) + expect(request.verifiable).to be false + end + + it 'returns false when expired' do + request = FactoryBot.build(:email_change_request, user: user, used_at: nil, expires_at: 1.day.ago) + expect(request.verifiable).to be false + end + end +end diff --git a/spec/requests/admin/users_controller_spec.rb b/spec/requests/admin/users_controller_spec.rb new file mode 100644 index 000000000..50b580707 --- /dev/null +++ b/spec/requests/admin/users_controller_spec.rb @@ -0,0 +1,39 @@ +require 'rails_helper' + +RSpec.describe Admin::UsersController, type: :request do + let(:admin) { FactoryBot.create(:user) } + let(:user) { FactoryBot.create(:user) } + + before do + allow_any_instance_of(AdminController).to receive(:ensure_user_signed_in).and_return(true) + allow_any_instance_of(AdminController).to receive(:current_user).and_return({ 'email' => admin.email }) + stub_auth0_token_request + stub_auth0_update_user_request(user) + end + + describe 'GET /admin/users/:id/edit_email' do + it 'renders the edit email form' do + get edit_email_admin_user_path(user) + expect(response).to have_http_status(:ok) + expect(response.body).to include('Update user email') + end + end + + describe 'PATCH /admin/users/:id/update_email' do + it 'updates the user email if changed' do + patch update_email_admin_user_path(user), + params: { user: { email: 'newadmin@example.com' } } + expect(response).to redirect_to(admin_user_path(user)) + follow_redirect! + expect(response.body).to include('Email updated successfully') + end + + it 'does not update if email is unchanged' do + patch update_email_admin_user_path(user), + params: { user: { email: user.email } } + expect(response).to redirect_to(admin_user_path(user)) + follow_redirect! + expect(response.body).to include('Email is unchanged') + end + end +end diff --git a/spec/requests/v1/email_verifications_controller_spec.rb b/spec/requests/v1/email_verifications_controller_spec.rb new file mode 100644 index 000000000..9ae350c94 --- /dev/null +++ b/spec/requests/v1/email_verifications_controller_spec.rb @@ -0,0 +1,70 @@ +require 'rails_helper' + +RSpec.describe V1::EmailVerificationsController, type: :request do + let(:user) { FactoryBot.create(:user) } + let(:auth_headers) { { 'X-Auth-Id' => JWT.encode(user.auth_id, 'test') } } + + describe 'POST /v1/email_verifications' do + it 'creates a new email verification request' do + post '/v1/email_verifications', + headers: auth_headers, + params: { _jsonapi: { new_email: 'newuser@example.com' } } + + expect(response).to have_http_status(:ok) + expect(json['data']['type']).to eq('email_change_requests') + expect(json['data']['attributes']['new_email']).to eq('newuser@example.com') + end + + it 'returns errors if already verified' do + FactoryBot.create(:email_change_request, user: user, new_email: 'newuser@example.com', active: false, +used_at: Time.current) + post '/v1/email_verifications', + headers: auth_headers, + params: { _jsonapi: { new_email: 'newuser@example.com' } } + expect(response.status).to eq(422) + expect(json['errors']).not_to be_empty + end + end + + describe 'POST /v1/email_verifications/verify_token' do + it 'verifies a valid token and updates the user email' do + email_change = FactoryBot.create(:email_change_request, user: user, new_email: 'verified@example.com', +active: true, used_at: nil, expires_at: 2.days.from_now) + allow_any_instance_of(UpdateUserEmail).to receive(:call).and_return(true) + post '/v1/email_verifications/verify_token', + params: { _jsonapi: { token: email_change.token } } + expect(response).to have_http_status(:ok) + end + + it 'returns error for invalid or expired token' do + post '/v1/email_verifications/verify_token', + params: { _jsonapi: { token: 'invalidtoken' } } + expect(response.status).to eq(422) + expect(json['errors']).not_to be_empty + end + end + + describe 'GET /v1/email_verifications/active_verification' do + it 'returns the active email change request' do + FactoryBot.create(:email_change_request, user: user, new_email: 'pending@example.com', +active: true, expires_at: 2.days.from_now) + get '/v1/email_verifications/active_verification', headers: auth_headers + expect(response).to have_http_status(:ok) + expect(json['data']['attributes']['new_email']).to eq('pending@example.com') + end + + it 'returns not found if no active request' do + get '/v1/email_verifications/active_verification', headers: auth_headers + expect(response.status).to eq(404) + end + end + + describe 'DELETE /v1/email_verifications/cancel_pending_email_change' do + it 'cancels the pending email change' do + FactoryBot.create(:email_change_request, user: user, new_email: 'cancelme@example.com', active: true, +expires_at: 2.days.from_now) + delete '/v1/email_verifications/cancel_pending_email_change', headers: auth_headers + expect(response).to have_http_status(:no_content) + end + end +end diff --git a/spec/requests/v1/users_spec.rb b/spec/requests/v1/users_spec.rb index b23b46be9..2d52dc755 100644 --- a/spec/requests/v1/users_spec.rb +++ b/spec/requests/v1/users_spec.rb @@ -69,4 +69,60 @@ expect(response.status).to eq 422 end end + + describe 'PATCH /v1/users/update_email' do + it 'returns 422 if email param is missing' do + user = FactoryBot.create(:user) + stub_auth0_token_request + stub_auth0_update_user_request(user) + patch '/v1/users/update_email', + headers: { 'X-Auth-Id' => JWT.encode(user.auth_id, 'test') }, + params: { _jsonapi: {} } + expect(response.status).to eq 422 + expect(json['errors']).not_to be_empty + end + + it 'returns 422 if verification_request fails to save' do + user = FactoryBot.create(:user) + allow_any_instance_of(EmailChangeRequest).to receive(:save).and_return(false) + patch '/v1/users/update_email', + headers: { 'X-Auth-Id' => JWT.encode(user.auth_id, 'test') }, + params: { _jsonapi: { email: 'newuser@example.com' } } + expect(response.status).to eq 422 + expect(json['errors']).not_to be_empty + end + it 'updates the email of the current user' do + user = FactoryBot.create(:user) + stub_auth0_token_request + stub_auth0_update_user_request(user) + + patch '/v1/users/update_email', + headers: { 'X-Auth-Id' => JWT.encode(user.auth_id, 'test') }, + params: { + _jsonapi: { + email: 'newuser@example.com' + } + } + + expect(response).to be_successful + expect(json['data']['type']).to eq('email_change_requests') + expect(json['data']['attributes']['new_email']).to eq('newuser@example.com') + end + + it 'returns an error if the Auth0 update fails' do + user = FactoryBot.create(:user) + stub_auth0_token_request + stub_auth0_update_user_request_failure(user) + + patch '/v1/users/update_email', + headers: { 'X-Auth-Id' => JWT.encode(user.auth_id, 'test') }, + params: { + _jsonapi: { + email: 'newuser@example.com' + } + } + + expect(response.status).to eq 200 + end + end end diff --git a/spec/services/update_user_email_in_auth0_spec.rb b/spec/services/update_user_email_in_auth0_spec.rb new file mode 100644 index 000000000..e58fcebed --- /dev/null +++ b/spec/services/update_user_email_in_auth0_spec.rb @@ -0,0 +1,21 @@ +require 'rails_helper' + +RSpec.describe UpdateUserEmailInAuth0 do + describe '#call' do + let(:user) { create(:user) } + + subject { described_class.new(user: user).call } + + before(:each) do + stub_auth0_token_request + end + + it 'updates the user email in Auth0' do + auth0_update_call = stub_auth0_update_user_request(user) + + subject + + expect(auth0_update_call).to have_been_requested + end + end +end diff --git a/spec/services/update_user_email_spec.rb b/spec/services/update_user_email_spec.rb new file mode 100644 index 000000000..31adc4b24 --- /dev/null +++ b/spec/services/update_user_email_spec.rb @@ -0,0 +1,58 @@ +require 'rails_helper' + +RSpec.describe UpdateUserEmail do + let(:user) { FactoryBot.create(:user) } + + before(:each) do + stub_auth0_token_request + end + + describe '#call' do + it 'returns true on success' do + stub_auth0_update_user_request(user) + + result = described_class.new(user, 'newemail@example.com').call + + expect(result).to eq(true) + end + + it 'updates the user email' do + stub_auth0_update_user_request(user) + + described_class.new(user, 'newemail@example.com').call + + expect(user.reload.email).to eq('newemail@example.com') + end + + it 'enqueues a confirmation email job' do + stub_auth0_update_user_request(user) + + expect do + described_class.new(user, 'newemail@example.com').call + end.to have_enqueued_job(SendConfirmEmailVerificationJob).with(new_email: 'newemail@example.com', + person_name: user.name) + end + + context 'when Auth0 errors' do + before(:each) do + stub_auth0_update_user_request_failure(user) + end + + it 'returns false' do + result = described_class.new(user, 'newemail@example.com').call + expect(result).to eq(false) + end + + it 'does not update the user email' do + original_email = user.email + described_class.new(user, 'newemail@example.com').call + expect(user.reload.email).to eq(original_email) + end + + it 'logs a failure message' do + expect(Rails.logger).to receive(:error).with(/Auth0 Error/) + described_class.new(user, 'newemail@example.com').call + end + end + end +end diff --git a/spec/services/user_logs_in_auth0_spec.rb b/spec/services/user_logs_in_auth0_spec.rb new file mode 100644 index 000000000..2182e5679 --- /dev/null +++ b/spec/services/user_logs_in_auth0_spec.rb @@ -0,0 +1,24 @@ +require 'rails_helper' + +RSpec.describe UserLogsInAuth0 do + describe '#call' do + let(:user) { create(:user) } + + subject { described_class.new(user: user).call } + + before(:each) do + stub_auth0_token_request + end + + it 'fetches logs from Auth0' do + expected_query = "(type:\"s\" OR type:\"f\") AND user_id:\"#{user.auth_id}\"" + auth0_logs_call = stub_request(:get, 'https://testdomain/api/v2/logs') + .with(query: hash_including('q' => expected_query)) + .to_return(status: 200, body: '[]', headers: { 'Content-Type' => 'application/json' }) + + subject + + expect(auth0_logs_call).to have_been_requested + end + end +end