Skip to content

Commit 1258e01

Browse files
authored
Merge pull request #6456 from alexmalik/fix-bug-2855-cookie-overflow
Fix: #2855: Cookie Overflow when trying to import CasaCase CSV
2 parents f3f353e + 8ed5b2f commit 1258e01

File tree

8 files changed

+406
-61
lines changed

8 files changed

+406
-61
lines changed

app/controllers/imports_controller.rb

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ class ImportsController < ApplicationController
22
require "csv"
33

44
include ActionView::Helpers::UrlHelper
5+
before_action :failed_csv_service, only: [:create, :download_failed]
56
after_action :verify_authorized
67

78
ERR_FAILED_IMPORT_NOTE = "Note: An additional 'error' column has been added to the file. " \
@@ -23,14 +24,17 @@ def index
2324

2425
def create
2526
authorize :import
27+
@failed_csv_service.cleanup
28+
2629
import = import_from_csv(params[:import_type], params[:sms_opt_in], params[:file], current_user.casa_org_id)
2730
message = import[:message]
2831

2932
# If there were failed imports
3033
if import[:exported_rows]
31-
message << "<p class='mt-4'>" + link_to("Click here to download failed rows.", download_failed_imports_path) +
34+
@failed_csv_service.failed_rows = import[:exported_rows]
35+
@failed_csv_service.store
36+
message << "<p class='mt-4'>" + link_to("Click here to download failed rows.", download_failed_imports_path(import_type: params[:import_type])) +
3237
"</p>" + "<p>#{ERR_FAILED_IMPORT_NOTE}</p>"
33-
session[:exported_rows] = import[:exported_rows]
3438
end
3539

3640
if import[:type] == :error
@@ -47,13 +51,21 @@ def create
4751

4852
def download_failed
4953
authorize :import
50-
data = session[:exported_rows]
51-
session[:exported_rows] = nil
52-
send_data data, format: :csv, filename: "failed_rows.csv"
54+
csv_contents = @failed_csv_service.read
55+
56+
send_data csv_contents, format: :csv, filename: "failed_rows.csv"
5357
end
5458

5559
private
5660

61+
def failed_csv_service(failed_rows: "")
62+
@failed_csv_service = FailedImportCsvService.new(
63+
failed_rows: failed_rows,
64+
import_type: params[:import_type],
65+
user: current_user
66+
)
67+
end
68+
5769
def header
5870
{
5971
"volunteer" => VolunteerImporter::IMPORT_HEADER,
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
require "digest"
2+
3+
class FailedImportCsvService
4+
attr_accessor :failed_rows
5+
attr_reader :import_type, :user, :csv_filepath
6+
7+
MAX_FILE_SIZE_BYTES = 250.kilobytes
8+
EXPIRATION_TIME = 24.hours
9+
10+
def initialize(import_type:, user:, failed_rows: "", filepath: nil)
11+
@failed_rows = failed_rows
12+
@import_type = import_type
13+
@user = user
14+
@csv_filepath = filepath || generate_filepath
15+
end
16+
17+
def store
18+
if failed_rows.bytesize > MAX_FILE_SIZE_BYTES
19+
Rails.logger.warn("CSV too large to save for user=#{user.id}, size=#{failed_rows.bytesize}")
20+
@failed_rows = max_size_warning
21+
end
22+
23+
FileUtils.mkdir_p(File.dirname(csv_filepath))
24+
File.write(csv_filepath, failed_rows)
25+
end
26+
27+
def read
28+
return exists_warning unless csv_exists?
29+
30+
if expired?
31+
remove_csv
32+
return expired_warning
33+
end
34+
35+
File.read(csv_filepath)
36+
end
37+
38+
def cleanup
39+
return unless csv_exists?
40+
41+
log_info("Removing old failed rows CSV")
42+
remove_csv
43+
end
44+
45+
private
46+
47+
def log_info(msg)
48+
Rails.logger.info("User=#{user.id}, Type=#{import_type}: #{msg}")
49+
end
50+
51+
def exists_warning
52+
"No failed import file found. Please upload a #{humanised_import_type} CSV."
53+
end
54+
55+
def expired_warning
56+
"The failed import file has expired. Please upload a new #{humanised_import_type} CSV."
57+
end
58+
59+
def max_size_warning
60+
"The file was too large to save. Please make sure your CSV is smaller than 250 KB and try again."
61+
end
62+
63+
def generate_filepath
64+
Pathname.new(Rails.root.join("tmp", import_type.to_s, filename)).cleanpath
65+
end
66+
67+
def csv_exists?
68+
File.exist?(csv_filepath)
69+
end
70+
71+
def expired?
72+
csv_exists? && File.mtime(csv_filepath) < Time.current - EXPIRATION_TIME
73+
end
74+
75+
def remove_csv
76+
FileUtils.rm_f(csv_filepath)
77+
end
78+
79+
def filename
80+
short_hash = Digest::SHA256.hexdigest(user.id.to_s)[0..15]
81+
"failed_rows_userid_#{short_hash}.csv"
82+
end
83+
84+
def humanised_import_type
85+
I18n.t("imports.labels.#{import_type}", default: import_type.to_s.humanize.downcase)
86+
end
87+
end

config/locales/en.yml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,3 +79,8 @@ en:
7979
long: "%B %d, %Y"
8080
short: "%b %d"
8181
year_first: "%Y-%m-%d"
82+
imports:
83+
labels:
84+
casa_case: "CASA Case"
85+
volunteer: "Volunteer"
86+
supervisor: "Supervisor"
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
email,display_name,supervisor_volunteers,phone_number
2+
3+
4+
[email protected],,,33333333333
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
display_name,email,phone_number
2+
3+
,[email protected],22222222222
4+
,[email protected],33333333333

spec/requests/imports_spec.rb

Lines changed: 94 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -170,8 +170,101 @@
170170
}.not_to change(CasaCase, :count)
171171

172172
expect(request.session[:import_error]).to include("Not all rows were imported.")
173-
expect(request.session[:exported_rows]).to include("Case CINA-00-0000 already exists, but is inactive. Reactivate the CASA case instead.")
174173
expect(response).to redirect_to(imports_url(import_type: "casa_case"))
174+
175+
failed_csv_path = FailedImportCsvService.new(import_type: :casa_case, user: casa_admin).csv_filepath
176+
expect(File.exist?(failed_csv_path)).to be true
177+
178+
file_contents = File.read(failed_csv_path)
179+
expect(file_contents).to include("Case CINA-00-0000 already exists, but is inactive. Reactivate the CASA case instead.")
180+
181+
FileUtils.rm_f(failed_csv_path)
182+
end
183+
184+
it "calls FailedImportCsvService#store when there are failed rows from the import" do
185+
sign_in casa_admin
186+
187+
csv_service_double = instance_double(FailedImportCsvService)
188+
allow(csv_service_double).to receive(:failed_rows=)
189+
allow(csv_service_double).to receive(:store)
190+
allow(csv_service_double).to receive(:cleanup)
191+
allow(FailedImportCsvService).to receive(:new).and_return(csv_service_double)
192+
193+
allow(CaseImporter).to receive(:import_cases).and_return({
194+
message: "Some cases were not imported.",
195+
exported_rows: "Case CINA-00-0000 already exists, but is inactive. Reactivate the CASA case instead.",
196+
type: :error
197+
})
198+
199+
expect(csv_service_double).to receive(:failed_rows=).with("Case CINA-00-0000 already exists, but is inactive. Reactivate the CASA case instead.")
200+
expect(csv_service_double).to receive(:store)
201+
expect(csv_service_double).to receive(:cleanup)
202+
203+
post imports_url,
204+
params: {
205+
import_type: "casa_case",
206+
file: fixture_file_upload("existing_casa_case.csv", "text/csv")
207+
}
208+
209+
expect(request.session[:import_error]).to include("Click here to download failed rows.")
210+
expect(response).to redirect_to(imports_url(import_type: "casa_case"))
211+
end
212+
213+
it "writes a fallback message when exported rows exceed max size" do
214+
sign_in casa_admin
215+
216+
large_exported_content = "a" * (FailedImportCsvService::MAX_FILE_SIZE_BYTES + 1)
217+
218+
allow(CaseImporter).to receive(:import_cases).and_return({
219+
message: "Some rows were too large.",
220+
exported_rows: large_exported_content,
221+
type: :error
222+
})
223+
224+
post imports_url,
225+
params: {
226+
import_type: "casa_case",
227+
file: case_file
228+
}
229+
230+
failed_csv_path = FailedImportCsvService.new(import_type: :casa_case, user: casa_admin).csv_filepath
231+
expect(File.exist?(failed_csv_path)).to be true
232+
233+
written_content = File.read(failed_csv_path)
234+
expect(written_content).to include("The file was too large to save")
235+
236+
expect(request.session[:import_error]).to include("Click here to download failed rows.")
237+
238+
FileUtils.rm_f(failed_csv_path)
239+
end
240+
241+
it "shows a fallback error message when the failed import CSV was never created" do
242+
sign_in casa_admin
243+
244+
get download_failed_imports_path(import_type: "casa_case")
245+
246+
expect(response.body).to include("Please upload a CASA Case CSV")
247+
expect(response.headers["Content-Disposition"]).to include("attachment; filename=\"failed_rows.csv\"")
248+
end
249+
250+
it "shows a fallback error message when the failed import CSV expired" do
251+
sign_in casa_admin
252+
253+
service = FailedImportCsvService.new(
254+
import_type: :casa_case,
255+
user: casa_admin,
256+
failed_rows: "some content",
257+
filepath: path
258+
)
259+
service.store
260+
261+
File.utime(2.days.ago.to_time, 2.days.ago.to_time, service.csv_filepath)
262+
263+
get download_failed_imports_path(import_type: "casa_case")
264+
265+
expect(response.body).to include("Please upload a CASA Case CSV")
266+
expect(response.headers["Content-Disposition"]).to include("attachment; filename=\"failed_rows.csv\"")
267+
expect(File.exist?(path)).to be_falsey
175268
end
176269
end
177270
end
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
require "rails_helper"
2+
require "fileutils"
3+
require "csv"
4+
5+
RSpec.describe FailedImportCsvService do
6+
let(:import_type) { "casa_case" }
7+
let(:user) { create(:casa_admin) }
8+
let(:csv_string) { "case_number,birth_month_year_youth\n12345,2001-04\n" }
9+
let(:user_id_hash) { Digest::SHA256.hexdigest(user.id.to_s)[0..15] }
10+
let(:csv_path) { Rails.root.join("tmp", import_type, "failed_rows_userid_#{user_id_hash}.csv") }
11+
12+
subject(:service) { described_class.new(failed_rows: failed_rows, import_type: import_type, user: user) }
13+
14+
before { FileUtils.rm_f(csv_path) }
15+
after { FileUtils.rm_f(csv_path) }
16+
17+
def create_file(content: csv_string, mtime: Time.current)
18+
FileUtils.mkdir_p(File.dirname(csv_path))
19+
File.write(csv_path, content)
20+
File.utime(mtime.to_time, mtime.to_time, csv_path)
21+
end
22+
23+
describe "#store" do
24+
context "when file is within size limit" do
25+
let(:failed_rows) { csv_string }
26+
27+
it "writes the CSV content to the tmp file" do
28+
service.store
29+
30+
expect(File.exist?(csv_path)).to be true
31+
expect(File.read(csv_path)).to eq csv_string
32+
end
33+
end
34+
35+
context "when file exceeds size limit" do
36+
let(:failed_rows) { "a" * (described_class::MAX_FILE_SIZE_BYTES + 1) }
37+
38+
it "logs a warning and stores a warning" do
39+
expect(Rails.logger).to receive(:warn).with(/CSV too large to save for user/)
40+
service.store
41+
42+
expect(File.read(csv_path)).to match(/The file was too large to save/)
43+
end
44+
end
45+
end
46+
47+
describe "#read" do
48+
let(:failed_rows) { "" }
49+
50+
context "when file exists and has not expired" do
51+
before { create_file }
52+
53+
it "returns the contents" do
54+
expect(service.read).to eq csv_string
55+
end
56+
end
57+
58+
context "when file is expired" do
59+
let(:failed_rows) { "The failed import file has expired. Please upload a new CSV." }
60+
before { create_file(mtime: 2.days.ago.to_time) }
61+
62+
it "deletes the file and returns fallback message" do
63+
expect(File.exist?(csv_path)).to be true
64+
expect(service.read).to include("The failed import file has expired")
65+
expect(File.exist?(csv_path)).to be false
66+
end
67+
end
68+
69+
context "when file never existed" do
70+
it "returns fallback message" do
71+
expect(service.read).to include("No failed import file found")
72+
end
73+
end
74+
end
75+
76+
describe "#cleanup" do
77+
let(:failed_rows) { "" }
78+
79+
context "when file exists" do
80+
before { create_file }
81+
82+
it "removes the file" do
83+
expect(File.exist?(csv_path)).to be true
84+
expect(Rails.logger).to receive(:info).with(/Removing old failed rows CSV/)
85+
service.cleanup
86+
expect(File.exist?(csv_path)).to be false
87+
end
88+
end
89+
90+
context "when file does not exist" do
91+
it "does nothing" do
92+
expect { service.cleanup }.not_to raise_error
93+
end
94+
end
95+
end
96+
end

0 commit comments

Comments
 (0)