# frozen_string_literal: true RSpec.describe Admin::BackupsController do fab!(:admin) fab!(:moderator) fab!(:user) let(:backup_filename) { "2014-02-10-065935.tar.gz" } let(:backup_filename2) { "2014-02-11-065935.tar.gz" } def create_backup_files(*filenames) @paths = filenames.map do |filename| path = backup_path(filename) File.open(path, "w") { |f| f.write("test backup") } path end end def backup_path(filename) File.join(BackupRestore::LocalBackupStore.base_directory, filename) end def map_preloaded controller .instance_variable_get("@preloaded") .map { |key, value| [key, JSON.parse(value)] } .to_h end before { SiteSetting.backup_location = BackupLocationSiteSetting::LOCAL } after do Discourse.redis.flushdb @paths&.each { |path| File.delete(path) if File.exist?(path) } @paths = nil end describe "#index" do context "when logged in as an admin" do before { sign_in(admin) } it "raises an error when backups are disabled" do SiteSetting.enable_backups = false get "/admin/backups.json" expect(response.status).to eq(403) end context "with html format" do it "preloads important data" do get "/admin/backups.html" expect(response.status).to eq(200) preloaded = map_preloaded expect(preloaded["operations_status"].symbolize_keys).to eq( BackupRestore.operations_status, ) expect(preloaded["logs"].size).to eq(BackupRestore.logs.size) end end context "with json format" do it "returns a list of all the backups" do begin create_backup_files(backup_filename, backup_filename2) get "/admin/backups.json" expect(response.status).to eq(200) filenames = response.parsed_body.map { |backup| backup["filename"] } expect(filenames).to include(backup_filename) expect(filenames).to include(backup_filename2) end end end end shared_examples "backups inaccessible" do it "denies access with a 404 response" do get "/admin/backups.html" expect(response.status).to eq(404) get "/admin/backups.json" expect(response.status).to eq(404) expect(response.parsed_body["errors"]).to include(I18n.t("not_found")) end end context "when logged in as a moderator" do before { sign_in(moderator) } include_examples "backups inaccessible" end context "when logged in as a non-staff user" do before { sign_in(user) } include_examples "backups inaccessible" end end describe "#status" do context "when logged in as an admin" do before { sign_in(admin) } it "returns the current backups status" do get "/admin/backups/status.json" expect(response.body).to eq(BackupRestore.operations_status.to_json) expect(response.status).to eq(200) end end shared_examples "status inaccessible" do it "denies access with a 404 response" do get "/admin/backups/status.json" expect(response.status).to eq(404) expect(response.parsed_body["errors"]).to include(I18n.t("not_found")) end end context "when logged in as a moderator" do before { sign_in(moderator) } include_examples "status inaccessible" end context "when logged in as a non-staff user" do before { sign_in(user) } include_examples "status inaccessible" end end describe "#create" do context "when logged in as an admin" do before do sign_in(admin) BackupRestore.stubs(:backup!) end it "starts a backup" do BackupRestore.expects(:backup!).with( admin.id, { publish_to_message_bus: true, with_uploads: false, client_id: "foo" }, ) post "/admin/backups.json", params: { with_uploads: false, client_id: "foo" } expect(response.status).to eq(200) end context "with rate limiting enabled" do before { RateLimiter.enable } after { RateLimiter.disable } it "is rate limited" do post "/admin/backups.json", params: { with_uploads: false, client_id: "foo" } post "/admin/backups.json", params: { with_uploads: false, client_id: "foo" } expect(response).to have_http_status :too_many_requests end end end shared_examples "backups creation not allowed" do it "prevents backups creation with a 404 response" do post "/admin/backups.json", params: { with_uploads: false, client_id: "foo" } expect(response.status).to eq(404) expect(response.parsed_body["errors"]).to include(I18n.t("not_found")) end end context "when logged in as a moderator" do before { sign_in(moderator) } include_examples "backups creation not allowed" end context "when logged in as a non-staff user" do before { sign_in(user) } include_examples "backups creation not allowed" end end describe "#show" do context "when logged in as an admin" do before { sign_in(admin) } it "uses send_file to transmit the backup" do begin token = EmailBackupToken.set(admin.id) create_backup_files(backup_filename) expect do get "/admin/backups/#{backup_filename}.json", params: { token: token } end.to change { UserHistory.where(action: UserHistory.actions[:backup_download]).count }.by(1) expect(response.headers["Content-Length"]).to eq("11") expect(response.headers["Content-Disposition"]).to match(/attachment; filename/) end end it "returns 422 when token is bad" do begin get "/admin/backups/#{backup_filename}.json", params: { token: "bad_value" } expect(response.status).to eq(422) expect(response.headers["Content-Disposition"]).not_to match(/attachment; filename/) expect(response.body).to include(I18n.t("download_backup_mailer.no_token")) end end it "returns 404 when the backup does not exist" do token = EmailBackupToken.set(admin.id) get "/admin/backups/#{backup_filename}.json", params: { token: token } expect(response.status).to eq(404) end end shared_examples "backup inaccessible" do it "denies access with a 404 response" do begin token = EmailBackupToken.set(admin.id) create_backup_files(backup_filename) expect do get "/admin/backups/#{backup_filename}.json", params: { token: token } end.not_to change { UserHistory.where(action: UserHistory.actions[:backup_download]).count } expect(response.status).to eq(404) expect(response.parsed_body["errors"]).to include(I18n.t("not_found")) expect(response.headers["Content-Disposition"]).not_to match(/attachment; filename/) end end end context "when logged in as a moderator" do before { sign_in(moderator) } include_examples "backup inaccessible" end context "when logged in as a non-staff user" do before { sign_in(user) } include_examples "backup inaccessible" end end describe "#destroy" do context "when logged in as an admin" do before { sign_in(admin) } it "removes the backup if found" do begin path = backup_path(backup_filename) create_backup_files(backup_filename) expect(File.exist?(path)).to eq(true) expect do delete "/admin/backups/#{backup_filename}.json" end.to change { UserHistory.where(action: UserHistory.actions[:backup_destroy]).count }.by(1) expect(response.status).to eq(200) expect(File.exist?(path)).to eq(false) end end it "doesn't remove the backup if not found" do delete "/admin/backups/#{backup_filename}.json" expect(response.status).to eq(404) end end shared_examples "backup deletion not allowed" do it "prevents deletion with a 404 response" do begin path = backup_path(backup_filename) create_backup_files(backup_filename) expect(File.exist?(path)).to eq(true) expect do delete "/admin/backups/#{backup_filename}.json" end.not_to change { UserHistory.where(action: UserHistory.actions[:backup_destroy]).count } expect(response.status).to eq(404) expect(response.parsed_body["errors"]).to include(I18n.t("not_found")) expect(File.exist?(path)).to eq(true) end end end context "when logged in as a moderator" do before { sign_in(moderator) } include_examples "backup deletion not allowed" end context "when logged in as a non-staff user" do before { sign_in(user) } include_examples "backup deletion not allowed" end end describe "#logs" do context "when logged in as an admin" do before { sign_in(admin) } it "preloads important data" do get "/admin/backups/logs.html" expect(response.status).to eq(200) preloaded = map_preloaded expect(preloaded["operations_status"].symbolize_keys).to eq(BackupRestore.operations_status) expect(preloaded["logs"].size).to eq(BackupRestore.logs.size) end end shared_examples "backup logs inaccessible" do it "denies access with a 404 response" do get "/admin/backups/logs.html" expect(response.status).to eq(404) end end context "when logged in as a moderator" do before { sign_in(moderator) } include_examples "backup logs inaccessible" end context "when logged in as a non-staff user" do before { sign_in(user) } include_examples "backup logs inaccessible" end end describe "#restore" do context "when logged in as an admin" do before { sign_in(admin) } it "starts a restore" do BackupRestore.expects(:restore!).with( admin.id, { filename: backup_filename, publish_to_message_bus: true, client_id: "foo" }, ) post "/admin/backups/#{backup_filename}/restore.json", params: { client_id: "foo" } expect(response.status).to eq(200) end end shared_examples "backup restoration not allowed" do it "prevents restoration with a 404 response" do post "/admin/backups/#{backup_filename}/restore.json", params: { client_id: "foo" } expect(response.status).to eq(404) expect(response.parsed_body["errors"]).to include(I18n.t("not_found")) end end context "when logged in as a moderator" do before { sign_in(moderator) } include_examples "backup restoration not allowed" end context "when logged in as a non-staff user" do before { sign_in(user) } include_examples "backup restoration not allowed" end end describe "#readonly" do context "when logged in as an admin" do before { sign_in(admin) } it "enables readonly mode" do expect(Discourse.readonly_mode?).to eq(false) expect { put "/admin/backups/readonly.json", params: { enable: true } }.to change { UserHistory.where( action: UserHistory.actions[:change_readonly_mode], new_value: "t", ).count }.by(1) expect(Discourse.readonly_mode?).to eq(true) expect(response.status).to eq(200) end it "disables readonly mode" do Discourse.enable_readonly_mode(Discourse::USER_READONLY_MODE_KEY) expect(Discourse.readonly_mode?).to eq(true) expect { put "/admin/backups/readonly.json", params: { enable: false } }.to change { UserHistory.where( action: UserHistory.actions[:change_readonly_mode], new_value: "f", ).count }.by(1) expect(response.status).to eq(200) expect(Discourse.readonly_mode?).to eq(false) end end shared_examples "enabling readonly mode not allowed" do it "prevents enabling readonly mode with a 404 response" do expect(Discourse.readonly_mode?).to eq(false) expect do put "/admin/backups/readonly.json", params: { enable: true } end.not_to change { UserHistory.where( action: UserHistory.actions[:change_readonly_mode], new_value: "t", ).count } expect(response.status).to eq(404) expect(response.parsed_body["errors"]).to include(I18n.t("not_found")) expect(Discourse.readonly_mode?).to eq(false) end end context "when logged in as a moderator" do before { sign_in(moderator) } include_examples "enabling readonly mode not allowed" end context "when logged in as a non-staff user" do before { sign_in(user) } include_examples "enabling readonly mode not allowed" end end describe "#upload_backup_chunk" do context "when logged in as an admin" do before { sign_in(admin) } describe "when filename contains invalid characters" do it "should raise an error" do ["灰色.tar.gz", '; echo \'haha\'.tar.gz'].each do |invalid_filename| described_class.any_instance.expects(:has_enough_space_on_disk?).returns(true) post "/admin/backups/upload", params: { resumableFilename: invalid_filename, resumableTotalSize: 1, resumableIdentifier: "test", } expect(response.status).to eq(415) expect(response.body).to eq(I18n.t("backup.invalid_filename")) end end end describe "when resumableIdentifier is invalid" do it "should raise an error" do filename = "test_site-0123456789.tar.gz" @paths = [backup_path(File.join("tmp", "test", "#{filename}.part1"))] post "/admin/backups/upload.json", params: { resumableFilename: filename, resumableTotalSize: 1, resumableIdentifier: "../test", resumableChunkNumber: "1", resumableChunkSize: "1", resumableCurrentChunkSize: "1", file: fixture_file_upload(Tempfile.new), } expect(response.status).to eq(400) end end describe "when filename is valid" do it "should upload the file successfully" do freeze_time described_class.any_instance.expects(:has_enough_space_on_disk?).returns(true) filename = "test_Site-0123456789.tar.gz" post "/admin/backups/upload.json", params: { resumableFilename: filename, resumableTotalSize: 1, resumableIdentifier: "test", resumableChunkNumber: "1", resumableChunkSize: "1", resumableCurrentChunkSize: "1", file: fixture_file_upload(Tempfile.new), } expect_job_enqueued( job: :backup_chunks_merger, args: { filename: filename, identifier: "test", chunks: 1, }, at: 5.seconds.from_now, ) expect(response.status).to eq(200) expect(response.body).to eq("") end end describe "completing an upload by enqueuing backup_chunks_merger" do let(:filename) { "test_Site-0123456789.tar.gz" } it "works with a single chunk" do freeze_time described_class.any_instance.expects(:has_enough_space_on_disk?).returns(true) # 2MB file, 2MB chunks = 1x 2MB chunk post "/admin/backups/upload.json", params: { resumableFilename: filename, resumableTotalSize: "2097152", resumableIdentifier: "test", resumableChunkNumber: "1", resumableChunkSize: "2097152", resumableCurrentChunkSize: "2097152", file: fixture_file_upload(Tempfile.new), } expect_job_enqueued( job: :backup_chunks_merger, args: { filename: filename, identifier: "test", chunks: 1, }, at: 5.seconds.from_now, ) end it "works with multiple chunks when the final chunk is chunk_size + remainder" do freeze_time described_class.any_instance.expects(:has_enough_space_on_disk?).twice.returns(true) # 5MB file, 2MB chunks = 1x 2MB chunk + 1x 3MB chunk with resumable.js post "/admin/backups/upload.json", params: { resumableFilename: filename, resumableTotalSize: "5242880", resumableIdentifier: "test", resumableChunkNumber: "1", resumableChunkSize: "2097152", resumableCurrentChunkSize: "2097152", file: fixture_file_upload(Tempfile.new), } post "/admin/backups/upload.json", params: { resumableFilename: filename, resumableTotalSize: "5242880", resumableIdentifier: "test", resumableChunkNumber: "2", resumableChunkSize: "2097152", resumableCurrentChunkSize: "3145728", file: fixture_file_upload(Tempfile.new), } expect_job_enqueued( job: :backup_chunks_merger, args: { filename: filename, identifier: "test", chunks: 2, }, at: 5.seconds.from_now, ) end it "works with multiple chunks when the final chunk is just the remainder" do freeze_time described_class.any_instance.expects(:has_enough_space_on_disk?).times(3).returns(true) # 5MB file, 2MB chunks = 2x 2MB chunk + 1x 1MB chunk with uppy.js post "/admin/backups/upload.json", params: { resumableFilename: filename, resumableTotalSize: "5242880", resumableIdentifier: "test", resumableChunkNumber: "1", resumableChunkSize: "2097152", resumableCurrentChunkSize: "2097152", file: fixture_file_upload(Tempfile.new), } post "/admin/backups/upload.json", params: { resumableFilename: filename, resumableTotalSize: "5242880", resumableIdentifier: "test", resumableChunkNumber: "2", resumableChunkSize: "2097152", resumableCurrentChunkSize: "2097152", file: fixture_file_upload(Tempfile.new), } post "/admin/backups/upload.json", params: { resumableFilename: filename, resumableTotalSize: "5242880", resumableIdentifier: "test", resumableChunkNumber: "3", resumableChunkSize: "2097152", resumableCurrentChunkSize: "1048576", file: fixture_file_upload(Tempfile.new), } expect_job_enqueued( job: :backup_chunks_merger, args: { filename: filename, identifier: "test", chunks: 3, }, at: 5.seconds.from_now, ) end end end shared_examples "uploading backup chunk not allowed" do it "prevents uploading of backup chunk with a 404 response" do freeze_time filename = "test_Site-0123456789.tar.gz" post "/admin/backups/upload.json", params: { resumableFilename: filename, resumableTotalSize: 1, resumableIdentifier: "test", resumableChunkNumber: "1", resumableChunkSize: "1", resumableCurrentChunkSize: "1", file: fixture_file_upload(Tempfile.new), } expect_not_enqueued_with( job: :backup_chunks_merger, args: { filename: filename, identifier: "test", chunks: 1, }, at: 5.seconds.from_now, ) expect(response.status).to eq(404) expect(response.parsed_body["errors"]).to include(I18n.t("not_found")) end end context "when logged in as a moderator" do before { sign_in(moderator) } include_examples "uploading backup chunk not allowed" end context "when logged in as a non-staff user" do before { sign_in(user) } include_examples "uploading backup chunk not allowed" end end describe "#check_backup_chunk" do context "when logged in as an admin" do before { sign_in(admin) } describe "when resumableIdentifier is invalid" do it "should raise an error" do get "/admin/backups/upload", params: { resumableidentifier: "../some_file", resumablefilename: "test_site-0123456789.tar.gz", resumablechunknumber: "1", resumablecurrentchunksize: "1", } expect(response.status).to eq(400) end end end shared_examples "checking backup chunk not allowed" do it "denies access with a 404 response" do get "/admin/backups/upload", params: { resumableidentifier: "../some_file", resumablefilename: "test_site-0123456789.tar.gz", resumablechunknumber: "1", resumablecurrentchunksize: "1", } expect(response.status).to eq(404) end end context "when logged in as a moderator" do before { sign_in(moderator) } include_examples "checking backup chunk not allowed" end context "when logged in as a non-staff user" do before { sign_in(user) } include_examples "checking backup chunk not allowed" end end describe "#rollback" do context "when logged in as an admin" do before { sign_in(admin) } it "should rollback the restore" do BackupRestore.expects(:rollback!) post "/admin/backups/rollback.json" expect(response.status).to eq(200) end it "should not allow rollback via a GET request" do get "/admin/backups/rollback.json" expect(response.status).to eq(404) end end shared_examples "backup rollback not allowed" do it "prevents rollbacks with a 404 response" do post "/admin/backups/rollback.json" expect(response.status).to eq(404) expect(response.parsed_body["errors"]).to include(I18n.t("not_found")) end end context "when logged in as a moderator" do before { sign_in(moderator) } include_examples "backup rollback not allowed" end context "when logged in as a non-staff user" do before { sign_in(user) } include_examples "backup rollback not allowed" end end describe "#cancel" do context "when logged in as an admin" do before { sign_in(admin) } it "should cancel an backup" do BackupRestore.expects(:cancel!) delete "/admin/backups/cancel.json" expect(response.status).to eq(200) end it "should not allow cancel via a GET request" do get "/admin/backups/cancel.json" expect(response.status).to eq(404) end end shared_examples "backup cancellation not allowed" do it "prevents cancellation with a 404 response" do delete "/admin/backups/cancel.json" expect(response.status).to eq(404) expect(response.parsed_body["errors"]).to include(I18n.t("not_found")) end end context "when logged in as a moderator" do before { sign_in(moderator) } include_examples "backup cancellation not allowed" end context "when logged in as a non-staff user" do before { sign_in(user) } include_examples "backup cancellation not allowed" end end describe "#email" do context "when logged in as an admin" do before { sign_in(admin) } it "enqueues email job" do # might as well test this here if we really want www.example.com SiteSetting.force_hostname = "www.example.com" create_backup_files(backup_filename) expect { put "/admin/backups/#{backup_filename}.json" }.to change { Jobs::DownloadBackupEmail.jobs.size }.by(1) job_args = Jobs::DownloadBackupEmail.jobs.last["args"].first expect(job_args["user_id"]).to eq(admin.id) expect(job_args["backup_file_path"]).to eq( "http://www.example.com/admin/backups/#{backup_filename}", ) expect(response.status).to eq(200) end it "returns 404 when the backup does not exist" do put "/admin/backups/#{backup_filename}.json" expect(response).to be_not_found end end shared_examples "backup emails not allowed" do it "prevents sending backup emails with a 404 response" do SiteSetting.force_hostname = "www.example.com" create_backup_files(backup_filename) expect do put "/admin/backups/#{backup_filename}.json" end.not_to change { Jobs::DownloadBackupEmail.jobs.size } expect(response.status).to eq(404) expect(response.parsed_body["errors"]).to include(I18n.t("not_found")) end end context "when logged in as a moderator" do before { sign_in(moderator) } include_examples "backup emails not allowed" end context "when logged in as a non-staff user" do before { sign_in(user) } include_examples "backup emails not allowed" end end describe "S3 multipart uploads" do let(:upload_type) { "backup" } let(:test_bucket_prefix) { "test_#{ENV["TEST_ENV_NUMBER"].presence || "0"}" } let(:backup_file_exists_response) { { status: 404 } } let(:mock_multipart_upload_id) do "ibZBv_75gd9r8lH_gqXatLdxMVpAlj6CFTR.OwyF3953YdwbcQnMA2BLGn8Lx12fQNICtMw5KyteFeHw.Sjng--" end before do setup_s3 SiteSetting.enable_direct_s3_uploads = true SiteSetting.s3_backup_bucket = "s3-backup-bucket" SiteSetting.backup_location = BackupLocationSiteSetting::S3 stub_request( :head, "https://s3-backup-bucket.s3.dualstack.us-west-1.amazonaws.com/", ).to_return(status: 200, body: "", headers: {}) stub_request( :head, "https://s3-backup-bucket.s3.dualstack.us-west-1.amazonaws.com/default/test.tar.gz", ).to_return(backup_file_exists_response) end shared_examples "multipart uploads not allowed" do it "prevents multipart uploads with a 404 response" do post "/admin/backups/create-multipart.json", params: { file_name: "test.tar.gz", upload_type: upload_type, file_size: 4098, } expect(response.status).to eq(404) end end context "when logged in as a moderator" do before { sign_in(moderator) } include_examples "multipart uploads not allowed" end context "when logged in as a non-staff user" do before { sign_in(user) } include_examples "multipart uploads not allowed" end context "when the user is admin" do before { sign_in(admin) } def stub_create_multipart_backup_request BackupRestore::S3BackupStore .any_instance .stubs(:temporary_upload_path) .returns( "temp/default/#{test_bucket_prefix}/28fccf8259bbe75b873a2bd2564b778c/2u98j832nx93272x947823.gz", ) create_multipart_result = <<~XML \n s3-backup-bucket temp/default/#{test_bucket_prefix}/28fccf8259bbe75b873a2bd2564b778c/2u98j832nx93272x947823.gz #{mock_multipart_upload_id} XML stub_request( :post, "https://s3-backup-bucket.s3.dualstack.us-west-1.amazonaws.com/temp/default/#{test_bucket_prefix}/28fccf8259bbe75b873a2bd2564b778c/2u98j832nx93272x947823.gz?uploads", ).to_return(status: 200, body: create_multipart_result) end it "creates the multipart upload" do stub_create_multipart_backup_request post "/admin/backups/create-multipart.json", params: { file_name: "test.tar.gz", upload_type: upload_type, file_size: 4098, } expect(response.status).to eq(200) result = response.parsed_body external_upload_stub = ExternalUploadStub.where( unique_identifier: result["unique_identifier"], original_filename: "test.tar.gz", created_by: admin, upload_type: upload_type, key: result["key"], multipart: true, ) expect(external_upload_stub.exists?).to eq(true) end context "when backup of same filename already exists" do let(:backup_file_exists_response) { { status: 200, body: "" } } it "throws an error" do post "/admin/backups/create-multipart.json", params: { file_name: "test.tar.gz", upload_type: upload_type, file_size: 4098, } expect(response.status).to eq(422) expect(response.parsed_body["errors"]).to include(I18n.t("backup.file_exists")) end end context "when filename is invalid" do it "throws an error" do post "/admin/backups/create-multipart.json", params: { file_name: "blah $$##.tar.gz", upload_type: upload_type, file_size: 4098, } expect(response.status).to eq(422) expect(response.parsed_body["errors"]).to include(I18n.t("backup.invalid_filename")) end end context "when extension is invalid" do it "throws an error" do post "/admin/backups/create-multipart.json", params: { file_name: "test.png", upload_type: upload_type, file_size: 4098, } expect(response.status).to eq(422) expect(response.parsed_body["errors"]).to include( I18n.t("backup.backup_file_should_be_tar_gz"), ) end end end end end