diff --git a/app/controllers/state_file/archived_intakes/archived_intake_controller.rb b/app/controllers/state_file/archived_intakes/archived_intake_controller.rb new file mode 100644 index 0000000000..06e097b8eb --- /dev/null +++ b/app/controllers/state_file/archived_intakes/archived_intake_controller.rb @@ -0,0 +1,23 @@ +module StateFile + module ArchivedIntakes + class ArchivedIntakeController < ApplicationController + def current_request + StateFileArchivedIntakeRequest.find_by(ip_address: ip_for_irs, email_address: session[:email_address]) + end + + def create_state_file_access_log(event_type) + StateFileArchivedIntakeAccessLog.create!( + event_type: event_type, + state_file_archived_intake_request: current_request + ) + end + + def check_feature_flag + unless Flipper.enabled?(:get_your_pdf) + # this redirect to be changed when we have an offboarding page + redirect_to root_path + end + end + end + end +end \ No newline at end of file diff --git a/app/controllers/state_file/archived_intakes/email_address_controller.rb b/app/controllers/state_file/archived_intakes/email_address_controller.rb new file mode 100644 index 0000000000..bf76e8e19d --- /dev/null +++ b/app/controllers/state_file/archived_intakes/email_address_controller.rb @@ -0,0 +1,31 @@ +module StateFile + module ArchivedIntakes + class EmailAddressController < ArchivedIntakeController + before_action :check_feature_flag + def edit + @form = EmailAddressForm.new + end + + def update + @form = EmailAddressForm.new(email_address_form_params) + + if @form.valid? + archived_intake = StateFileArchivedIntake.find_by(email_address: @form.email_address) + session[:email_address] = @form.email_address + StateFileArchivedIntakeRequest.find_or_create_by(email_address: @form.email_address, ip_address: ip_for_irs, state_file_archived_intakes_id: archived_intake&.id ) + create_state_file_access_log("issued_email_challenge") + + redirect_to state_file_archived_intakes_edit_verification_code_path + else + render :edit + end + end + + private + + def email_address_form_params + params.require(:state_file_archived_intakes_email_address_form).permit(:email_address) + end + end + end +end diff --git a/app/controllers/state_file/archived_intakes/verification_code_controller.rb b/app/controllers/state_file/archived_intakes/verification_code_controller.rb new file mode 100644 index 0000000000..9df5320a27 --- /dev/null +++ b/app/controllers/state_file/archived_intakes/verification_code_controller.rb @@ -0,0 +1,48 @@ +module StateFile + module ArchivedIntakes + class VerificationCodeController < ArchivedIntakeController + before_action :check_feature_flag + def edit + if current_request.access_locked? + # this redirect to be changed when we have an offboarding page + redirect_to root_path + return + end + @form = VerificationCodeForm.new(email_address: current_request.email_address) + @email_address = current_request.email_address + ArchivedIntakeEmailVerificationCodeJob.perform_later( + email_address: @email_address, + locale: I18n.locale + ) + end + + def update + @form = VerificationCodeForm.new(verification_code_form_params, email_address: current_request.email_address) + @email_address = current_request.email_address + + if @form.valid? + create_state_file_access_log("correct_email_code") + current_request.reset_failed_attempts! + # this should take us to the ssn page + redirect_to root_path + else + create_state_file_access_log("incorrect_email_code") + current_request.increment_failed_attempts + if current_request.access_locked? + create_state_file_access_log("client_lockout_begin") + # this redirect to be changed when we have an offboarding page + redirect_to root_path + return + end + render :edit + end + end + + private + + def verification_code_form_params + params.require(:state_file_archived_intakes_verification_code_form).permit(:verification_code) + end + end + end +end diff --git a/app/forms/state_file/archived_intakes/email_address_form.rb b/app/forms/state_file/archived_intakes/email_address_form.rb new file mode 100644 index 0000000000..a9d4f958aa --- /dev/null +++ b/app/forms/state_file/archived_intakes/email_address_form.rb @@ -0,0 +1,10 @@ +module StateFile + module ArchivedIntakes + class EmailAddressForm < Form + attr_accessor :email_address + + validates :email_address, presence: true, 'valid_email_2/email': true + + end + end +end diff --git a/app/forms/state_file/archived_intakes/verification_code_form.rb b/app/forms/state_file/archived_intakes/verification_code_form.rb new file mode 100644 index 0000000000..27c9e39806 --- /dev/null +++ b/app/forms/state_file/archived_intakes/verification_code_form.rb @@ -0,0 +1,23 @@ +module StateFile + module ArchivedIntakes + class VerificationCodeForm < Form + attr_accessor :verification_code, :email_address + + validates :verification_code, presence: true + def initialize(attributes = {}, email_address: nil) + super(attributes) + @email_address = email_address + end + + def valid? + hashed_verification_code = VerificationCodeService.hash_verification_code_with_contact_info(@email_address, verification_code) + + valid_code = EmailAccessToken.lookup(hashed_verification_code).exists? + + errors.add(:verification_code, I18n.t("state_file.archived_intakes.verification_code.edit.error_message")) unless valid_code + + valid_code.present? + end + end + end +end diff --git a/app/jobs/archived_intake_email_verification_code_job.rb b/app/jobs/archived_intake_email_verification_code_job.rb new file mode 100644 index 0000000000..589e2d0070 --- /dev/null +++ b/app/jobs/archived_intake_email_verification_code_job.rb @@ -0,0 +1,11 @@ +class ArchivedIntakeEmailVerificationCodeJob < ApplicationJob + retry_on Mailgun::CommunicationError + + def priority + PRIORITY_HIGH - 1 # Subtracting one to push to the top of the queue + end + + def perform(email_address:, locale:) + ArchivedIntakeEmailVerificationCodeService.request_code(email_address: email_address, locale: locale) + end +end diff --git a/app/jobs/request_verification_code_for_previous_year_job.rb b/app/jobs/request_verification_code_for_previous_year_job.rb deleted file mode 100644 index d5d7f58842..0000000000 --- a/app/jobs/request_verification_code_for_previous_year_job.rb +++ /dev/null @@ -1,11 +0,0 @@ -class RequestVerificationCodeForPreviousYearJob < ApplicationJob - retry_on Mailgun::CommunicationError - - def priority - PRIORITY_HIGH - 1 # Subtracting one to push to the top of the queue - end - - def perform(email_address: nil, locale:) - VerificationCodeMailer.archived_intake_verification_code(to: email_address, locale: locale, verification_code: 'todo-generate-code').deliver_now - end -end diff --git a/app/models/state_file_archived_intake.rb b/app/models/state_file_archived_intake.rb index e5c464a926..e4ea6a41db 100644 --- a/app/models/state_file_archived_intake.rb +++ b/app/models/state_file_archived_intake.rb @@ -17,5 +17,5 @@ # class StateFileArchivedIntake < ApplicationRecord has_one_attached :submission_pdf - has_many :access_logs, class_name: 'StateFileArchivedIntakeAccessLog' + has_many :intake_requests, class_name: 'StateFileArchivedIntakeRequest' end diff --git a/app/models/state_file_archived_intake_access_log.rb b/app/models/state_file_archived_intake_access_log.rb index 4968350060..157fe5616d 100644 --- a/app/models/state_file_archived_intake_access_log.rb +++ b/app/models/state_file_archived_intake_access_log.rb @@ -2,24 +2,19 @@ # # Table name: state_file_archived_intake_access_logs # -# id :bigint not null, primary key -# details :jsonb -# event_type :integer -# ip_address :string -# created_at :datetime not null -# updated_at :datetime not null -# state_file_archived_intakes_id :bigint -# -# Indexes -# -# idx_on_state_file_archived_intakes_id_e878049c06 (state_file_archived_intakes_id) +# id :bigint not null, primary key +# details :jsonb +# event_type :integer +# created_at :datetime not null +# updated_at :datetime not null +# state_file_archived_intake_request_id :bigint # # Foreign Keys # -# fk_rails_... (state_file_archived_intakes_id => state_file_archived_intakes.id) +# fk_rails_... (state_file_archived_intake_request_id => state_file_archived_intake_requests.id) # class StateFileArchivedIntakeAccessLog < ApplicationRecord - belongs_to :state_file_archived_intake + belongs_to :state_file_archived_intake_request, optional: true enum event_type: { issued_email_challenge: 0, correct_email_code: 1, diff --git a/app/models/state_file_archived_intake_request.rb b/app/models/state_file_archived_intake_request.rb new file mode 100644 index 0000000000..bad5df02ec --- /dev/null +++ b/app/models/state_file_archived_intake_request.rb @@ -0,0 +1,36 @@ +# == Schema Information +# +# Table name: state_file_archived_intake_requests +# +# id :bigint not null, primary key +# email_address :string +# failed_attempts :integer default(0), not null +# ip_address :string +# locked_at :datetime +# created_at :datetime not null +# updated_at :datetime not null +# state_file_archived_intakes_id :bigint +# +# Indexes +# +# idx_on_state_file_archived_intakes_id_31501c23f8 (state_file_archived_intakes_id) +# +# Foreign Keys +# +# fk_rails_... (state_file_archived_intakes_id => state_file_archived_intakes.id) +# +class StateFileArchivedIntakeRequest < ApplicationRecord + devise :lockable, unlock_in: 60.minutes, unlock_strategy: :time + has_many :access_logs, class_name: 'StateFileArchivedIntakeAccessLog' + + def self.maximum_attempts + 2 + end + + def increment_failed_attempts + super + if attempts_exceeded? && !access_locked? + lock_access! + end + end +end diff --git a/app/services/archived_intake_email_verification_code_service.rb b/app/services/archived_intake_email_verification_code_service.rb new file mode 100644 index 0000000000..55391c2c4f --- /dev/null +++ b/app/services/archived_intake_email_verification_code_service.rb @@ -0,0 +1,19 @@ +class ArchivedIntakeEmailVerificationCodeService + def initialize(email_address: , locale: :en) + @email_address = email_address + @locale = locale + end + + def request_code + verification_code, = EmailAccessToken.generate!(email_address: @email_address) + VerificationCodeMailer.archived_intake_verification_code( + to: @email_address, + verification_code: verification_code, + locale: @locale + ).deliver_now + end + + def self.request_code(**args) + new(**args).request_code + end +end \ No newline at end of file diff --git a/app/views/state_file/archived_intakes/email_address/edit.html.erb b/app/views/state_file/archived_intakes/email_address/edit.html.erb new file mode 100644 index 0000000000..f221bc5556 --- /dev/null +++ b/app/views/state_file/archived_intakes/email_address/edit.html.erb @@ -0,0 +1,21 @@ +<% title = t(".enter_email") %> +<% content_for :page_title, title %> +
-outer"> +
+
+

<%= title %>

+ <%= form_with model: @form, url: state_file_archived_intakes_email_address_path, local: true, method: :patch, builder: VitaMinFormBuilder do |f| %> +
+ <%= f.cfa_input_field(:email_address, t("state_file.questions.email_address.edit.email_address_label"), help_text: 'example@email.com', classes: ["form-width--long"]) %> +
+ +
+ +
+ <% end %> +
+
+
+ diff --git a/app/views/state_file/archived_intakes/verification_code/edit.html.erb b/app/views/state_file/archived_intakes/verification_code/edit.html.erb new file mode 100644 index 0000000000..ec54d2dd84 --- /dev/null +++ b/app/views/state_file/archived_intakes/verification_code/edit.html.erb @@ -0,0 +1,23 @@ +<% title = t(".title_html", email_address: @email_address) %> +
-outer"> +
+
+
+ <% content_for :page_title, title %> + <%= form_with model: @form, url: { action: :update }, local: true, method: :patch, builder: VitaMinFormBuilder do |f| %> +

<%= title %>

+

<%= t(".subtitle_html") %>

+

<%= t(".subtitle_html_2") %>

+
+ <%= f.cfa_input_field(:verification_code, t("state_file.questions.verification_code.edit.verification_code_label"), classes: ["form-width--long"]) %> +
+
+ +
+ <% end %> +
+
+
+
diff --git a/app/views/state_file/state_file_pages/about_page.html.erb b/app/views/state_file/state_file_pages/about_page.html.erb index 81b260d81e..923abdc04d 100644 --- a/app/views/state_file/state_file_pages/about_page.html.erb +++ b/app/views/state_file/state_file_pages/about_page.html.erb @@ -45,4 +45,13 @@ <% end %> -<% end %> \ No newline at end of file +<% end %> + +<% if Flipper.enabled?(:get_your_pdf) %> +
+ <%= t(".looking_for_return_html")%> +
+ <%= link_to t(".tax_return_link"), state_file_archived_intakes_edit_email_address_path %> +
+
+<% end %> diff --git a/config/locales/en.yml b/config/locales/en.yml index 7aaececb6f..3ed440700b 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -2063,6 +2063,17 @@ en: 04_detail_links_html: See our Terms and Conditions and Privacy Policy. subheader: Please sign up here to receive a notification when we open in January. state_file: + archived_intakes: + email_address: + edit: + enter_email: Enter the email address linked to your account + verification_code: + edit: + error_message: Incorrect verification code. After 2 failed attempts, accounts are locked. + subtitle_html: If you didn’t receive a code, please check your spam folder. + subtitle_html_2: If you still need help resubscribing, chat with us. + title_html: We’ve sent your code to %{email_address} + verify: Verify code faq: index: title: Common questions from %{state} taxpayers @@ -3900,6 +3911,7 @@ en: Already filed your state taxes with us? You can download a copy of your state return until December 31, 2024

header: A free state filing service for taxpayers using IRS Direct File helper_heading_html: "How does this service work? " + looking_for_return_html: "Looking for your 2023 Arizona or New York State Tax Return?" section1_html: |
  1. File and submit your federal return with IRS Direct File before using this service.
  2. @@ -3919,6 +3931,7 @@ en: In 2025, this service will support filing state tax returns in Arizona, Idaho, North Carolina, New Jersey, and Maryland. subheader_2_html: To start filing your federal return, go to directfile.irs.gov. subheader_3_html: "Already filed your federal return with IRS Direct File and need to complete your state return? Sign in here." + tax_return_link: Click here to access your tax return card_postscript: responses_saved_html: Your responses are saved. If you need a break, you can come back and log in to your account at fileyourstatetaxes.org. coming_soon: diff --git a/config/locales/es.yml b/config/locales/es.yml index 2bf0feb258..d5cc23a966 100644 --- a/config/locales/es.yml +++ b/config/locales/es.yml @@ -2015,6 +2015,17 @@ es: 04_detail_links_html: Favor de consultar nuestras Condiciones generales y Política de privacidad. subheader: Regístrese aquí para recibir una notificación cuando abramos en enero. state_file: + archived_intakes: + email_address: + edit: + enter_email: Ingresa la dirección de correo electrónico vinculada a tu cuenta + verification_code: + edit: + error_message: Después de 2 intentos fallidos, las cuentas serán bloqueadas. + subtitle_html: Si no recibiste un código, revisa tu carpeta de spam. Si aún necesitas ayuda para volver a suscribirte, chatea con nosotros. + subtitle_html_2: Si aún necesitas ayuda para resuscribirte, chatea con nosotros. + title_html: Hemos enviado tu código a %{email_address} + verify: Verificar el código faq: index: title: 'Preguntas frecuentes de los contribuyentes de %{state}:' @@ -3874,6 +3885,7 @@ es: ¿Ya presentó tus impuestos estatales con nosotros? Puedes descargar una copia de tu declaración estatal hasta el 31 de diciembre de 2024

    header: Una herramienta sin costo para presentar impuestos estatales para los contribuyentes utilizando IRS Direct File helper_heading_html: "¿Cómo funciona este servicio?" + looking_for_return_html: "¿Buscas tu declaración de impuestos de Arizona o del estado de Nueva York de 2023?" section1_html: |
    1. Presenta y envía tu declaración federal con IRS Direct File antes de usar esta herramienta.
    2. @@ -3893,6 +3905,7 @@ es: En 2025, este servicio admitirá la presentación de declaraciones de impuestos estatales en Arizona, Idaho, Carolina del Norte, Nueva Jersey y Maryland. subheader_2_html: Para comenzar a presentar tu declaración federal, visita directfile.irs.gov. subheader_3_html: "¿Ya presentaste tu declaración federal con IRS Direct File y necesitas completar tu declaración estatal? Inicia sesión aquí." + tax_return_link: Haga clic aquí para acceder a su declaración de impuestos. card_postscript: responses_saved_html: Tus respuestas han sido guardadas. Si necesitas hacer una pausa, puedes regresar e iniciar sesión en tu cuenta en fileyourstatetaxes.org. coming_soon: @@ -4051,7 +4064,7 @@ es: body_text: | Hello! - Your six-digit verification code for %{service_name} is: %{verification_code}. This code will expire after 30 minutes. + Tu codigo de verificacion de seis digitos para %{service_name}: %{verification_code}. This code will expire after 30 minutes. Did you receive this code without signing up? Email us at help@%{service_name_lower}.org diff --git a/config/routes.rb b/config/routes.rb index 6d167bb0ce..78fa450c93 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -552,9 +552,17 @@ def scoped_navigation_routes(context, navigation) get '/.well-known/pki-validation/:id', to: 'public_pages#pki_validation' end + devise_for :state_file_archived_intake_requests + constraints(Routes::StateFileDomain.new) do scope '(:locale)', locale: /#{I18n.available_locales.join('|')}/ do namespace :state_file do + namespace :archived_intakes do + get 'email_address/edit', to: 'email_address#edit', as: 'edit_email_address' + patch 'email_address', to: 'email_address#update' + get 'verification_code/edit', to: 'verification_code#edit', as: 'edit_verification_code' + patch 'verification_code', to: 'verification_code#update' + end namespace :questions do get "show_xml", to: "confirmation#show_xml" get "explain_calculations", to: "confirmation#explain_calculations" diff --git a/db/migrate/20250108221853_state_file_archived_intake_requests.rb b/db/migrate/20250108221853_state_file_archived_intake_requests.rb new file mode 100644 index 0000000000..51487edc81 --- /dev/null +++ b/db/migrate/20250108221853_state_file_archived_intake_requests.rb @@ -0,0 +1,13 @@ +class StateFileArchivedIntakeRequests < ActiveRecord::Migration[7.1] + def change + create_table :state_file_archived_intake_requests do |t| + t.belongs_to :state_file_archived_intakes, foreign_key: true + t.string 'ip_address' + t.string 'email_address' + t.timestamps + end + + remove_foreign_key :state_file_archived_intake_access_logs, :state_file_archived_intakes + safety_assured { remove_column :state_file_archived_intake_access_logs, :state_file_archived_intakes_id } + end +end diff --git a/db/migrate/20250108223733_add_devise_to_state_file_archived_intake_requests.rb b/db/migrate/20250108223733_add_devise_to_state_file_archived_intake_requests.rb new file mode 100644 index 0000000000..4f453ec1fb --- /dev/null +++ b/db/migrate/20250108223733_add_devise_to_state_file_archived_intake_requests.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +class AddDeviseToStateFileArchivedIntakeRequests < ActiveRecord::Migration[7.1] + def change + add_column :state_file_archived_intake_requests, :failed_attempts, :integer, default: 0, null: false + add_column :state_file_archived_intake_requests, :locked_at, :datetime + end +end diff --git a/db/migrate/20250108230315_add_foreign_keyto_state_file_archived_intake_access_logs.rb b/db/migrate/20250108230315_add_foreign_keyto_state_file_archived_intake_access_logs.rb new file mode 100644 index 0000000000..062afa8263 --- /dev/null +++ b/db/migrate/20250108230315_add_foreign_keyto_state_file_archived_intake_access_logs.rb @@ -0,0 +1,6 @@ +class AddForeignKeytoStateFileArchivedIntakeAccessLogs < ActiveRecord::Migration[7.1] + def change + add_column :state_file_archived_intake_access_logs, :state_file_archived_intake_request_id, :bigint + add_foreign_key :state_file_archived_intake_access_logs, :state_file_archived_intake_requests, column: :state_file_archived_intake_request_id, validate: false + end +end diff --git a/db/migrate/20250108231212_validate_foreign_keyfor_state_file_archived_intake_access_logs.rb b/db/migrate/20250108231212_validate_foreign_keyfor_state_file_archived_intake_access_logs.rb new file mode 100644 index 0000000000..0f849150dc --- /dev/null +++ b/db/migrate/20250108231212_validate_foreign_keyfor_state_file_archived_intake_access_logs.rb @@ -0,0 +1,5 @@ +class ValidateForeignKeyforStateFileArchivedIntakeAccessLogs < ActiveRecord::Migration[7.1] + def change + validate_foreign_key :state_file_archived_intake_access_logs, :state_file_archived_intake_requests + end +end diff --git a/db/migrate/20250113222716_remove_state_file_archived_intake_requests_fields.rb b/db/migrate/20250113222716_remove_state_file_archived_intake_requests_fields.rb new file mode 100644 index 0000000000..b9df5450a7 --- /dev/null +++ b/db/migrate/20250113222716_remove_state_file_archived_intake_requests_fields.rb @@ -0,0 +1,7 @@ +class RemoveStateFileArchivedIntakeRequestsFields < ActiveRecord::Migration[7.1] + def change + safety_assured do + remove_column :state_file_archived_intake_access_logs, :ip_address + end + end +end diff --git a/db/schema.rb b/db/schema.rb index 8f0ae6dbfa..f7d1c6ccdb 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.1].define(version: 2025_01_08_233940) do +ActiveRecord::Schema[7.1].define(version: 2025_01_13_222716) do # These are extensions that must be enabled in order to support this database enable_extension "citext" enable_extension "plpgsql" @@ -1688,10 +1688,19 @@ t.datetime "created_at", null: false t.jsonb "details", default: "{}" t.integer "event_type" + t.bigint "state_file_archived_intake_request_id" + t.datetime "updated_at", null: false + end + + create_table "state_file_archived_intake_requests", force: :cascade do |t| + t.datetime "created_at", null: false + t.string "email_address" + t.integer "failed_attempts", default: 0, null: false t.string "ip_address" + t.datetime "locked_at" t.bigint "state_file_archived_intakes_id" t.datetime "updated_at", null: false - t.index ["state_file_archived_intakes_id"], name: "idx_on_state_file_archived_intakes_id_e878049c06" + t.index ["state_file_archived_intakes_id"], name: "idx_on_state_file_archived_intakes_id_31501c23f8" end create_table "state_file_archived_intakes", force: :cascade do |t| @@ -2829,7 +2838,8 @@ add_foreign_key "site_coordinator_roles_vita_partners", "site_coordinator_roles" add_foreign_key "site_coordinator_roles_vita_partners", "vita_partners" add_foreign_key "source_parameters", "vita_partners" - add_foreign_key "state_file_archived_intake_access_logs", "state_file_archived_intakes", column: "state_file_archived_intakes_id" + add_foreign_key "state_file_archived_intake_access_logs", "state_file_archived_intake_requests" + add_foreign_key "state_file_archived_intake_requests", "state_file_archived_intakes", column: "state_file_archived_intakes_id" add_foreign_key "state_routing_fractions", "state_routing_targets" add_foreign_key "state_routing_fractions", "vita_partners" add_foreign_key "system_notes", "clients" diff --git a/spec/controllers/state_file/archived_intake/archived_intake_controller_spec.rb b/spec/controllers/state_file/archived_intake/archived_intake_controller_spec.rb new file mode 100644 index 0000000000..440e2075f2 --- /dev/null +++ b/spec/controllers/state_file/archived_intake/archived_intake_controller_spec.rb @@ -0,0 +1,76 @@ +require 'rails_helper' + +describe StateFile::ArchivedIntakes::ArchivedIntakeController, type: :controller do + let(:ip_address) { '192.168.0.1' } + let(:email_address) { 'test@example.com' } + let!(:request_instance) { create :state_file_archived_intake_request, ip_address: ip_address, email_address: email_address } + before do + allow(controller).to receive(:ip_for_irs).and_return(ip_address) + session[:email_address] = email_address + end + + describe '#current_request' do + it 'finds the StateFileArchivedIntakeRequest by IP and email address' do + expect(controller.current_request).to eq(request_instance) + end + + it 'returns nil if no request is found' do + session[:email_address] = "non_existant_email@bad.com" + + expect(controller.current_request).to be_nil + end + end + + describe '#create_state_file_access_log' do + let(:event_type) { 'incorrect_ssn_challenge' } + + before do + allow(controller).to receive(:current_request).and_return(request_instance) + end + + it 'creates a StateFileArchivedIntakeAccessLog with the correct attributes' do + result = controller.create_state_file_access_log(event_type) + + expect(result).to be_a(StateFileArchivedIntakeAccessLog) + expect(result.event_type).to eq event_type + expect(result.state_file_archived_intake_request).to eq request_instance + end + + context 'when current return is nil' do + before do + allow(controller).to receive(:current_request).and_return(nil) + end + it 'create a StateFileArchivedIntakeAccessLog' do + result = controller.create_state_file_access_log(event_type) + + expect(result).to be_a(StateFileArchivedIntakeAccessLog) + expect(result.event_type).to eq event_type + expect(result.state_file_archived_intake_request).to eq nil + end + end + + describe '#check_feature_flag' do + context 'when the feature flag is enabled' do + before do + allow(Flipper).to receive(:enabled?).with(:get_your_pdf).and_return(true) + end + + it 'does not redirect' do + expect(controller).not_to receive(:redirect_to) + controller.check_feature_flag + end + end + + context 'when the feature flag is disabled' do + before do + allow(Flipper).to receive(:enabled?).with(:get_your_pdf).and_return(false) + end + + it 'redirects to the root path' do + expect(controller).to receive(:redirect_to).with(root_path) + controller.check_feature_flag + end + end + end + end +end diff --git a/spec/controllers/state_file/archived_intake/email_address_controller_spec.rb b/spec/controllers/state_file/archived_intake/email_address_controller_spec.rb new file mode 100644 index 0000000000..84a0cd9d4d --- /dev/null +++ b/spec/controllers/state_file/archived_intake/email_address_controller_spec.rb @@ -0,0 +1,85 @@ +require "rails_helper" + +RSpec.describe StateFile::ArchivedIntakes::EmailAddressController, type: :controller do + before do + Flipper.enable(:get_your_pdf) + end + describe "GET #edit" do + it "renders the edit template with a new EmailAddressForm" do + get :edit + + expect(assigns(:form)).to be_a(StateFile::ArchivedIntakes::EmailAddressForm) + expect(response).to render_template(:edit) + end + end + + describe "POST #update" do + let(:valid_email_address) { "test@example.com" } + let(:invalid_email_address) { "" } + let(:ip_address) { "127.0.0.1" } + + before do + allow(controller).to receive(:ip_for_irs).and_return(ip_address) + end + + context "when the form is valid" do + context "and a archived intake exists with the email address" do + let!(:archived_intake) { create :state_file_archived_intake, email_address: valid_email_address } + it "creates an access log create a request and redirects to the verification code page" do + post :update, params: { + state_file_archived_intakes_email_address_form: { email_address: valid_email_address } + } + expect(assigns(:form)).to be_valid + + request = StateFileArchivedIntakeRequest.last + expect(request.ip_address).to eq(ip_address) + expect(request.email_address).to eq(valid_email_address) + expect(request.state_file_archived_intakes_id).to eq(archived_intake.id) + + log = StateFileArchivedIntakeAccessLog.last + expect(log.state_file_archived_intake_request_id).to eq(request.id) + expect(log.event_type).to eq("issued_email_challenge") + + expect(response).to redirect_to( + state_file_archived_intakes_edit_verification_code_path + ) + end + end + + context "and a archived does not exist with the email address" do + it "creates an access log create a request and redirects to the verification code page" do + post :update, params: { + state_file_archived_intakes_email_address_form: { email_address: valid_email_address } + } + expect(assigns(:form)).to be_valid + + request = StateFileArchivedIntakeRequest.last + expect(request.ip_address).to eq(ip_address) + expect(request.email_address).to eq(valid_email_address) + expect(request.state_file_archived_intakes_id).to eq(nil) + + log = StateFileArchivedIntakeAccessLog.last + expect(log.state_file_archived_intake_request_id).to eq(request.id) + expect(log.event_type).to eq("issued_email_challenge") + + expect(response).to redirect_to( + state_file_archived_intakes_edit_verification_code_path + ) + end + end + end + context "when the form is invalid" do + it "renders the edit template" do + post :update, params: { + state_file_archived_intakes_email_address_form: { email_address: invalid_email_address } + } + + expect(assigns(:form)).not_to be_valid + + expect(StateFileArchivedIntakeAccessLog.count).to eq(0) + + expect(response).to render_template(:edit) + end + end + end +end diff --git a/spec/controllers/state_file/archived_intake/verification_code_controller_spec.rb b/spec/controllers/state_file/archived_intake/verification_code_controller_spec.rb new file mode 100644 index 0000000000..f9c2c91929 --- /dev/null +++ b/spec/controllers/state_file/archived_intake/verification_code_controller_spec.rb @@ -0,0 +1,100 @@ +require "rails_helper" + +RSpec.describe StateFile::ArchivedIntakes::VerificationCodeController, type: :controller do + let(:current_request) { create(:state_file_archived_intake_request, email_address:email_address, failed_attempts: 0) } + let(:email_address) { "test@example.com" } + let(:valid_verification_code) { "123456" } + let(:invalid_verification_code) { "654321" } + + before do + Flipper.enable(:get_your_pdf) + allow(controller).to receive(:current_request).and_return(current_request) + allow(I18n).to receive(:locale).and_return(:en) + end + + describe "GET #edit" do + context "when the request is locked" do + before do + allow(current_request).to receive(:access_locked?).and_return(true) + end + + it "redirects to the root path" do + get :edit + + expect(response).to redirect_to(root_path) + end + end + + context "when the request is not locked" do + before do + allow(current_request).to receive(:access_locked?).and_return(false) + end + + it "renders the edit template with a new VerificationCodeForm and queues a job" do + expect{ + get :edit + }.to have_enqueued_job(ArchivedIntakeEmailVerificationCodeJob).with( + email_address: email_address, + locale: :en + ) + + expect(assigns(:form)).to be_a(StateFile::ArchivedIntakes::VerificationCodeForm) + expect(assigns(:email_address)).to eq(email_address) + expect(response).to render_template(:edit) + end + end + end + + describe "POST #update" do + context "with a valid verification code" do + before do + allow_any_instance_of(StateFile::ArchivedIntakes::VerificationCodeForm).to receive(:valid?).and_return(true) + end + + it "creates a success access log and does not increment failed_attempts" do + expect { + post :update, params: { state_file_archived_intakes_verification_code_form: { verification_code: valid_verification_code } } + }.to change(StateFileArchivedIntakeAccessLog, :count).by(1) + + log = StateFileArchivedIntakeAccessLog.last + expect(log.event_type).to eq("correct_email_code") + expect(current_request.failed_attempts).to eq(0) + expect(response).to redirect_to(root_path) + end + end + + context "with an invalid verification code" do + before do + allow_any_instance_of(StateFile::ArchivedIntakes::VerificationCodeForm).to receive(:valid?).and_return(false) + end + + it "creates a failure access log, increments failed_attempts, and re-renders edit on first failed attempt" do + expect { + post :update, params: { state_file_archived_intakes_verification_code_form: { verification_code: invalid_verification_code } } + }.to change(StateFileArchivedIntakeAccessLog, :count).by(1) + + log = StateFileArchivedIntakeAccessLog.last + expect(log.event_type).to eq("incorrect_email_code") + + expect(current_request.reload.failed_attempts).to eq(1) + expect(assigns(:form)).to be_a(StateFile::ArchivedIntakes::VerificationCodeForm) + expect(response).to render_template(:edit) + end + + it "locks the account and redirects to root path after multiple failed attempts" do + current_request.update!(failed_attempts: 1) + + expect { + post :update, params: { state_file_archived_intakes_verification_code_form: { verification_code: invalid_verification_code } } + }.to change(StateFileArchivedIntakeAccessLog, :count).by(2) + + log = StateFileArchivedIntakeAccessLog.last + expect(log.event_type).to eq("client_lockout_begin") + + expect(current_request.reload.failed_attempts).to eq(2) + expect(current_request.reload.access_locked?).to be_truthy + expect(response).to redirect_to(root_path) + end + end + end +end diff --git a/spec/factories/state_file_archived_intake_access_logs.rb b/spec/factories/state_file_archived_intake_access_logs.rb index 38637da7b9..6c094cd6dd 100644 --- a/spec/factories/state_file_archived_intake_access_logs.rb +++ b/spec/factories/state_file_archived_intake_access_logs.rb @@ -2,21 +2,16 @@ # # Table name: state_file_archived_intake_access_logs # -# id :bigint not null, primary key -# details :jsonb -# event_type :integer -# ip_address :string -# created_at :datetime not null -# updated_at :datetime not null -# state_file_archived_intakes_id :bigint -# -# Indexes -# -# idx_on_state_file_archived_intakes_id_e878049c06 (state_file_archived_intakes_id) +# id :bigint not null, primary key +# details :jsonb +# event_type :integer +# created_at :datetime not null +# updated_at :datetime not null +# state_file_archived_intake_request_id :bigint # # Foreign Keys # -# fk_rails_... (state_file_archived_intakes_id => state_file_archived_intakes.id) +# fk_rails_... (state_file_archived_intake_request_id => state_file_archived_intake_requests.id) # FactoryBot.define do factory :state_file_archived_intake_access_log do diff --git a/spec/factories/state_file_archived_intake_requests.rb b/spec/factories/state_file_archived_intake_requests.rb new file mode 100644 index 0000000000..b74af2e44a --- /dev/null +++ b/spec/factories/state_file_archived_intake_requests.rb @@ -0,0 +1,32 @@ +# == Schema Information +# +# Table name: state_file_archived_intake_requests +# +# id :bigint not null, primary key +# email_address :string +# failed_attempts :integer default(0), not null +# ip_address :string +# locked_at :datetime +# created_at :datetime not null +# updated_at :datetime not null +# state_file_archived_intakes_id :bigint +# +# Indexes +# +# idx_on_state_file_archived_intakes_id_31501c23f8 (state_file_archived_intakes_id) +# +# Foreign Keys +# +# fk_rails_... (state_file_archived_intakes_id => state_file_archived_intakes.id) +# +FactoryBot.define do + factory :state_file_archived_intake_request do + email_address { "geddy_lee@gmail.com" } + failed_attempts { 0 } + locked_at { nil } + + trait :locked do + locked_at { 5.minutes.ago } + end + end +end diff --git a/spec/factories/state_file_archived_intakes.rb b/spec/factories/state_file_archived_intakes.rb index 19391c29f0..d7e3c35201 100644 --- a/spec/factories/state_file_archived_intakes.rb +++ b/spec/factories/state_file_archived_intakes.rb @@ -17,6 +17,15 @@ # FactoryBot.define do factory :state_file_archived_intake do + email_address { "geddy_lee@example.com" } + hashed_ssn { "hashed_ssn_value" } + mailing_apartment { "Apt 1" } + mailing_city { "Test City" } + mailing_state { "CA" } + mailing_street { "123 Test Street" } + mailing_zip { "12345" } + state_code { "CA" } + tax_year { 2023 } submission_pdf { nil } transient do @@ -26,20 +35,22 @@ after(:create) do |archived_intake, evaluator| intake = evaluator.intake - archiver = evaluator.archiver - archived_intake.update( - email_address: intake&.email_address, - hashed_ssn: intake&.hashed_ssn, - mailing_apartment: intake&.direct_file_data&.mailing_apartment, - mailing_city: intake&.direct_file_data&.mailing_city, - mailing_state: intake&.direct_file_data&.mailing_state, - mailing_street: intake&.direct_file_data&.mailing_street, - mailing_zip: intake&.direct_file_data&.mailing_zip, - tax_year: archiver&.tax_year, - state_code: archiver&.state_code, - ) - archived_intake.submission_pdf.attach(intake&.submission_pdf&.blob) - archived_intake.save! + unless intake.nil? + archiver = evaluator.archiver + archived_intake.update( + email_address: intake&.email_address, + hashed_ssn: intake&.hashed_ssn, + mailing_apartment: intake&.direct_file_data&.mailing_apartment, + mailing_city: intake&.direct_file_data&.mailing_city, + mailing_state: intake&.direct_file_data&.mailing_state, + mailing_street: intake&.direct_file_data&.mailing_street, + mailing_zip: intake&.direct_file_data&.mailing_zip, + tax_year: archiver&.tax_year, + state_code: archiver&.state_code, + ) + archived_intake.submission_pdf.attach(intake&.submission_pdf&.blob) + archived_intake.save! + end end end end diff --git a/spec/features/state_file/prior_year_access_spec.rb b/spec/features/state_file/prior_year_access_spec.rb new file mode 100644 index 0000000000..ef53ef901a --- /dev/null +++ b/spec/features/state_file/prior_year_access_spec.rb @@ -0,0 +1,35 @@ +require "rails_helper" + +RSpec.feature "accessing a prior year PDF", active_job: true do + + before do + allow_any_instance_of(Routes::StateFileDomain).to receive(:matches?).and_return(true) + end + + context "get_your_pdf flag is enabled" do + before do + Flipper.enable(:get_your_pdf) + end + + it "has content" do + visit "/" + click_on I18n.t("state_file.state_file_pages.about_page.tax_return_link") + fill_in I18n.t("state_file.questions.email_address.edit.email_address_label"), with: "someone@example.com" + click_on I18n.t("state_file.questions.email_address.edit.action") + expect(page).to have_text "We’ve sent your code to someone@example.com" + perform_enqueued_jobs + mail = ActionMailer::Base.deliveries.last + code = mail.html_part.body.to_s.match(%r{ (\d{6})\.})[1] + fill_in "Enter the 6-digit code", with: code + click_on I18n.t("state_file.archived_intakes.verification_code.edit.verify") + expect(current_path).to eq(root_path) + end + end + + context "get_your_pdf flag is not enabled" do + it "has content" do + visit "/" + expect(page).to_not have_text I18n.t("state_file.state_file_pages.about_page.tax_return_link") + end + end +end diff --git a/spec/forms/state_file/archived_intakes/email_address_form_spec.rb b/spec/forms/state_file/archived_intakes/email_address_form_spec.rb new file mode 100644 index 0000000000..25dcd098cd --- /dev/null +++ b/spec/forms/state_file/archived_intakes/email_address_form_spec.rb @@ -0,0 +1,37 @@ +require "rails_helper" + +RSpec.describe StateFile::ArchivedIntakes::EmailAddressForm do + describe "#valid?" do + context "when the email address is valid" do + it "returns true" do + form = StateFile::ArchivedIntakes::EmailAddressForm.new(email_address: "test@example.com") + + expect(form.valid?).to be true + end + end + + context "when the email address is invalid" do + it "returns false for an improperly formatted email" do + form = StateFile::ArchivedIntakes::EmailAddressForm.new(email_address: "invalid-email") + + expect(form.valid?).to be false + expect(form.errors[:email_address]).to include("Please enter a valid email address.") + end + + it "returns false when the email is blank" do + form = StateFile::ArchivedIntakes::EmailAddressForm.new(email_address: "") + + expect(form.valid?).to be false + expect(form.errors[:email_address]).to include("Can't be blank.") + end + end + end + + describe "#initialize" do + it "assigns attributes correctly" do + form = StateFile::ArchivedIntakes::EmailAddressForm.new(email_address: "test@example.com") + + expect(form.email_address).to eq("test@example.com") + end + end +end diff --git a/spec/forms/state_file/archived_intakes/verification_code_form_spec.rb b/spec/forms/state_file/archived_intakes/verification_code_form_spec.rb new file mode 100644 index 0000000000..123921ad6f --- /dev/null +++ b/spec/forms/state_file/archived_intakes/verification_code_form_spec.rb @@ -0,0 +1,64 @@ +require "rails_helper" + +RSpec.describe StateFile::ArchivedIntakes::VerificationCodeForm do + describe "#valid?" do + context "when the verification code is present and valid" do + it "returns true" do + allow(VerificationCodeService).to receive(:hash_verification_code_with_contact_info) + .with("test@example.com", "123456") + .and_return("hashed_code") + + allow(EmailAccessToken).to receive_message_chain(:lookup, :exists?).and_return(true) + + form = StateFile::ArchivedIntakes::VerificationCodeForm.new( + { verification_code: "123456" }, + email_address: "test@example.com" + ) + + expect(form.valid?).to be true + end + end + + context "when the verification code is present but invalid" do + it "adds an error and returns false" do + allow(VerificationCodeService).to receive(:hash_verification_code_with_contact_info) + .with("test@example.com", "123456") + .and_return("hashed_code") + + allow(EmailAccessToken).to receive_message_chain(:lookup, :exists?).and_return(false) + + form = StateFile::ArchivedIntakes::VerificationCodeForm.new( + { verification_code: "123456" }, + email_address: "test@example.com" + ) + + expect(form.valid?).to be false + expect(form.errors[:verification_code]).to include("Incorrect verification code. After 2 failed attempts, accounts are locked.") + end + end + + context "when the verification code is blank" do + it "adds an error and returns false" do + form = StateFile::ArchivedIntakes::VerificationCodeForm.new( + { verification_code: "" }, + email_address: "test@example.com" + ) + + expect(form.valid?).to be false + expect(form.errors[:verification_code]).to include("Incorrect verification code. After 2 failed attempts, accounts are locked.") + end + end + end + + describe "#initialize" do + it "assigns attributes correctly" do + form = StateFile::ArchivedIntakes::VerificationCodeForm.new( + { verification_code: "123456" }, + email_address: "test@example.com" + ) + + expect(form.verification_code).to eq("123456") + expect(form.email_address).to eq("test@example.com") + end + end +end diff --git a/spec/jobs/archived_intake_email_verification_code_job_spec.rb b/spec/jobs/archived_intake_email_verification_code_job_spec.rb new file mode 100644 index 0000000000..465c871d87 --- /dev/null +++ b/spec/jobs/archived_intake_email_verification_code_job_spec.rb @@ -0,0 +1,20 @@ +require "rails_helper" + +RSpec.describe ArchivedIntakeEmailVerificationCodeJob, type: :job do + before do + allow(ArchivedIntakeEmailVerificationCodeService).to receive(:request_code) + end + + describe "#perform" do + context "with email_address, visitor_id, and locale params" do + it "requests a verification code by email using those params" do + ArchivedIntakeEmailVerificationCodeJob.perform_now(email_address: "client@example.com", locale: "es") + + expect(ArchivedIntakeEmailVerificationCodeService).to have_received(:request_code).with( + email_address: "client@example.com", + locale: "es" + ) + end + end + end +end diff --git a/spec/models/state_file_archived_intake_access_log_spec.rb b/spec/models/state_file_archived_intake_access_log_spec.rb index 0c8b0e1989..b65c56e7bd 100644 --- a/spec/models/state_file_archived_intake_access_log_spec.rb +++ b/spec/models/state_file_archived_intake_access_log_spec.rb @@ -2,21 +2,16 @@ # # Table name: state_file_archived_intake_access_logs # -# id :bigint not null, primary key -# details :jsonb -# event_type :integer -# ip_address :string -# created_at :datetime not null -# updated_at :datetime not null -# state_file_archived_intakes_id :bigint -# -# Indexes -# -# idx_on_state_file_archived_intakes_id_e878049c06 (state_file_archived_intakes_id) +# id :bigint not null, primary key +# details :jsonb +# event_type :integer +# created_at :datetime not null +# updated_at :datetime not null +# state_file_archived_intake_request_id :bigint # # Foreign Keys # -# fk_rails_... (state_file_archived_intakes_id => state_file_archived_intakes.id) +# fk_rails_... (state_file_archived_intake_request_id => state_file_archived_intake_requests.id) # require 'rails_helper' diff --git a/spec/models/state_file_archived_intake_request_spec.rb b/spec/models/state_file_archived_intake_request_spec.rb new file mode 100644 index 0000000000..4aea07c2d6 --- /dev/null +++ b/spec/models/state_file_archived_intake_request_spec.rb @@ -0,0 +1,35 @@ +# == Schema Information +# +# Table name: state_file_archived_intake_requests +# +# id :bigint not null, primary key +# email_address :string +# failed_attempts :integer default(0), not null +# ip_address :string +# locked_at :datetime +# created_at :datetime not null +# updated_at :datetime not null +# state_file_archived_intakes_id :bigint +# +# Indexes +# +# idx_on_state_file_archived_intakes_id_31501c23f8 (state_file_archived_intakes_id) +# +# Foreign Keys +# +# fk_rails_... (state_file_archived_intakes_id => state_file_archived_intakes.id) +# +require "rails_helper" + +describe StateFileArchivedIntakeRequest do + describe "#increment_failed_attempts" do + let!(:request_instance) { create :state_file_archived_intake_request, failed_attempts: 1 } + it "locks access when failed attempts is incremented to 2" do + expect(request_instance.access_locked?).to eq(false) + + request_instance.increment_failed_attempts + + expect(request_instance.access_locked?).to eq(true) + end + end +end diff --git a/spec/services/archived_intake_email_verification_code_service_spec.rb b/spec/services/archived_intake_email_verification_code_service_spec.rb new file mode 100644 index 0000000000..9786b489f9 --- /dev/null +++ b/spec/services/archived_intake_email_verification_code_service_spec.rb @@ -0,0 +1,49 @@ +require "rails_helper" + +describe ArchivedIntakeEmailVerificationCodeService do + let(:email_address) { "example@example.com" } + let(:locale) { "en" } + + let(:params) do + { + email_address: email_address, + locale: locale + } + end + + describe ".request_code" do + let(:mailer_double) { double VerificationCodeMailer } + let(:access_token_double) { double EmailAccessToken } + let(:mocked_job_response) { double } + before do + allow(EmailAccessToken).to receive(:generate!).and_return(["123456", access_token_double]) + allow(VerificationEmail).to receive(:create!) + allow(mailer_double).to receive(:perform_now).and_return(mocked_job_response) + allow_any_instance_of(Mail::Message).to receive(:message_id).and_return("mocked_mailer_id") + allow(DatadogApi).to receive(:increment) + end + + + context "when locale is English" do + let(:service_type) { :statefile } + + it "sends an email that includes 'FileYourStateTaxes'" do + described_class.request_code(**params) + email = ActionMailer::Base.deliveries.last + expect(email.to).to eq [email_address] + expect(email.body.encoded).to include "Your six-digit verification code for FileYourStateTaxes is: 123456" + end + end + + context 'when locale is Spanish' do + let(:locale) { :es } + + it "sends an email that includes 'FileYourStateTaxes'" do + described_class.request_code(**params) + email = ActionMailer::Base.deliveries.last + expect(email.to).to eq [email_address] + expect(email.body.encoded).to include ("Tu codigo de verificacion de seis digitos para FileYourStateTaxes:") + end + end + end +end