From a8f028164dc332c1a953e30d7778e7a6c9aea5a6 Mon Sep 17 00:00:00 2001 From: Ryan Lue Date: Fri, 9 Jul 2021 16:38:23 +0000 Subject: [PATCH] Refactoring: Migrate store_test to RSpec. --- .rubocop/todo.rspec.yml | 12 + app/models/store.rb | 33 +- app/models/store/file.rb | 27 +- app/models/store/provider/file.rb | 106 ++---- spec/models/store/file_spec.rb | 95 +++++ spec/models/store/provider/file_spec.rb | 124 +++++++ spec/models/store_spec.rb | 468 ++++++++++++++++++++++++ test/unit/store_test.rb | 424 --------------------- 8 files changed, 734 insertions(+), 555 deletions(-) create mode 100644 spec/models/store/file_spec.rb create mode 100644 spec/models/store/provider/file_spec.rb create mode 100644 spec/models/store_spec.rb delete mode 100644 test/unit/store_test.rb diff --git a/.rubocop/todo.rspec.yml b/.rubocop/todo.rspec.yml index 24179d7b2..0c376cbea 100644 --- a/.rubocop/todo.rspec.yml +++ b/.rubocop/todo.rspec.yml @@ -122,6 +122,9 @@ RSpec/ContextWording: - 'spec/models/role_spec.rb' - 'spec/models/scheduler_spec.rb' - 'spec/models/smime_certificate_spec.rb' + - 'spec/models/store/file_spec.rb' + - 'spec/models/store/provider/file_spec.rb' + - 'spec/models/store_spec.rb' - 'spec/models/tag/item_spec.rb' - 'spec/models/tag_spec.rb' - 'spec/models/taskbar_spec.rb' @@ -256,6 +259,9 @@ RSpec/ExampleLength: - 'spec/models/role_spec.rb' - 'spec/models/scheduler_spec.rb' - 'spec/models/sla/has_escalation_calculation_impact_examples.rb' + - 'spec/models/store/file_spec.rb' + - 'spec/models/store/provider/file_spec.rb' + - 'spec/models/store_spec.rb' - 'spec/models/taskbar_spec.rb' - 'spec/models/ticket/article_spec.rb' - 'spec/models/ticket/article/has_ticket_contact_attributes_impact_examples.rb' @@ -537,6 +543,9 @@ RSpec/MultipleExpectations: - 'spec/models/session_spec.rb' - 'spec/models/sla/has_escalation_calculation_impact_examples.rb' - 'spec/models/smime_certificate_spec.rb' + - 'spec/models/store/file_spec.rb' + - 'spec/models/store/provider/file_spec.rb' + - 'spec/models/store_spec.rb' - 'spec/models/taskbar_spec.rb' - 'spec/models/ticket/article_spec.rb' - 'spec/models/ticket/article/has_ticket_contact_attributes_impact_examples.rb' @@ -617,6 +626,9 @@ RSpec/NestedGroups: - 'spec/models/channel/driver/twitter_spec.rb' - 'spec/models/channel/email_parser_spec.rb' - 'spec/models/job_spec.rb' + - 'spec/models/store/file_spec.rb' + - 'spec/models/store/provider/file_spec.rb' + - 'spec/models/store_spec.rb' - 'spec/models/token_spec.rb' - 'spec/models/trigger_spec.rb' - 'spec/models/user/has_ticket_create_screen_impact_examples.rb' diff --git a/app/models/store.rb b/app/models/store.rb index f87885177..38075e302 100644 --- a/app/models/store.rb +++ b/app/models/store.rb @@ -5,6 +5,8 @@ class Store < ApplicationModel belongs_to :store_object, class_name: 'Store::Object', optional: true belongs_to :store_file, class_name: 'Store::File', optional: true + delegate :content, to: :store_file + delegate :provider, to: :store_file validates :filename, presence: true @@ -142,28 +144,6 @@ remove one attachment from storage =begin -get content of file - - store = Store.find(store_id) - content_as_string = store.content - -returns - - content_as_string - -=end - - def content - file = Store::File.find_by(id: store_file_id) - if !file - raise "No such file #{store_file_id}!" - end - - file.content - end - -=begin - get content of file in preview size store = Store.find(store_id) @@ -241,15 +221,6 @@ returns slice :id, :filename, :size, :preferences end - def provider - file = Store::File.find_by(id: store_file_id) - if !file - raise "No such file #{store_file_id}!" - end - - file.provider - end - RESIZABLE_MIME_REGEXP = %r{image/(jpeg|jpg|png)}i.freeze def self.resizable_mime?(input) diff --git a/app/models/store/file.rb b/app/models/store/file.rb index c9b709684..ee8c77ff8 100644 --- a/app/models/store/file.rb +++ b/app/models/store/file.rb @@ -78,11 +78,8 @@ in case of fixing sha hash use: def self.verify(fix_it = nil) success = true - file_ids = Store::File.all.pluck(:id) - file_ids.each do |item_id| - item = Store::File.find(item_id) - content = item.content - sha = Digest::SHA256.hexdigest(content) + Store::File.find_each(batch_size: 10) do |item| + sha = Digest::SHA256.hexdigest(item.content) logger.info "CHECK: Store::File.find(#{item.id})" next if sha == item.sha @@ -90,9 +87,7 @@ in case of fixing sha hash use: logger.error "DIFF: sha diff of Store::File.find(#{item.id}) current:#{sha}/db:#{item.sha}/provider:#{item.provider}" store = Store.find_by(store_file_id: item.id) logger.error "STORE: #{store.inspect}" - if fix_it - item.update_attribute(:sha, sha) # rubocop:disable Rails/SkipsModelValidations - end + item.update_attribute(:sha, sha) if fix_it # rubocop:disable Rails/SkipsModelValidations end success end @@ -119,26 +114,16 @@ nice move to keep system responsive adapter_source = "Store::Provider::#{source}".constantize adapter_target = "Store::Provider::#{target}".constantize - file_ids = Store::File.all.pluck(:id) - file_ids.each do |item_id| - item = Store::File.find(item_id) - next if item.provider == target - - content = item.content - - # add to new provider - adapter_target.add(content, item.sha) - - # update meta data + Store::File.where(provider: source).find_each(batch_size: 10) do |item| + adapter_target.add(item.content, item.sha) item.update_attribute(:provider, target) # rubocop:disable Rails/SkipsModelValidations - - # remove from old provider adapter_source.delete(item.sha) logger.info "Moved file #{item.sha} from #{source} to #{target}" sleep delay if delay end + true end diff --git a/app/models/store/provider/file.rb b/app/models/store/provider/file.rb index 0336ca3b7..542cf5237 100644 --- a/app/models/store/provider/file.rb +++ b/app/models/store/provider/file.rb @@ -4,117 +4,65 @@ class Store::Provider::File # write file to fs def self.add(data, sha) - - # install file location = get_location(sha) - permission = '600' - - # verify if file already is in file system and if it's not corrupt - if File.exist?(location) - begin - get(sha) - rescue - delete(sha) - end - end # write file to file system if !File.exist?(location) - Rails.logger.debug { "storage write '#{location}' (#{permission})" } - file = File.new(location, 'wb') - file.write(data) - file.close - end - File.chmod(permission.to_i(8), location) - - # check sha - local_sha = Digest::SHA256.hexdigest(get(sha)) - if sha != local_sha - raise "Corrupt file in fs #{location}, sha should be #{sha} but is #{local_sha}" + Rails.logger.debug { "storge write '#{location}' (600)" } + File.binwrite(location, data) end - true + File.chmod(0o600, location) + + validate_file(sha) + rescue # .validate_file will raise an error if contents do not match SHA + delete(sha) + + fail_count ||= 0 + fail_count.zero? ? (fail_count += 1) && retry : raise end # read file from fs def self.get(sha) location = get_location(sha) - Rails.logger.debug { "read from fs #{location}" } - if !File.exist?(location) - raise "No such file #{location}" - end - data = File.open(location, 'rb') - content = data.read + Rails.logger.debug { "read from fs #{location}" } + content = File.binread(location) + local_sha = Digest::SHA256.hexdigest(content) # check sha - local_sha = Digest::SHA256.hexdigest(content) - if local_sha != sha - raise "Corrupt file in fs #{location}, sha should be #{sha} but is #{local_sha}" - end + raise "File corrupted: path #{location} does not match SHA digest (#{local_sha})" if local_sha != sha content end + class << self + alias validate_file get + end + # unlink file from fs def self.delete(sha) location = get_location(sha) + if File.exist?(location) Rails.logger.info "storage remove '#{location}'" File.delete(location) end - # check if dir need to be removed - locations = location.split('/') - (0..locations.count).reverse_each do |count| - local_location = locations[0, count].join('/') - break if local_location.match?(%r{storage/fs/{0,4}$}) - break if Dir["#{local_location}/*"].present? - next if !Dir.exist?(local_location) + # remove empty ancestor directories + storage_fs_path = Rails.root.join('storage/fs') + location.parent.ascend do |path| + break if !Dir.empty?(path) + break if path == storage_fs_path - FileUtils.rmdir(local_location) + Dir.rmdir(path) end end # generate file location def self.get_location(sha) - - # generate directory - base = Rails.root.join('storage/fs').to_s - parts = [] - length1 = 4 - length2 = 5 - length3 = 7 - last_position = 0 - - # rubocop:disable Style/CombinableLoops - (0..1).each do |_count| - end_position = last_position + length1 - parts.push sha[last_position, length1] - last_position = end_position - end - (0..1).each do |_count| - end_position = last_position + length2 - parts.push sha[last_position, length2] - last_position = end_position - end - (0..1).each do |_count| - end_position = last_position + length3 - parts.push sha[last_position, length3] - last_position = end_position - end - # rubocop:enable Style/CombinableLoops - - path = "#{parts[ 0..6 ].join('/')}/" - file = sha[last_position, sha.length] - location = "#{base}/#{path}" - - # create directory if not exists - if !File.exist?(location) - FileUtils.mkdir_p(location) - end - full_path = location + file - full_path.gsub('//', '/') + parts = sha.scan(%r{^(.{4})(.{4})(.{5})(.{5})(.{7})(.{7})(.*)}).first + Rails.root.join('storage/fs', *parts).tap { |path| FileUtils.mkdir_p(path.parent) } end end diff --git a/spec/models/store/file_spec.rb b/spec/models/store/file_spec.rb new file mode 100644 index 000000000..54df8f42c --- /dev/null +++ b/spec/models/store/file_spec.rb @@ -0,0 +1,95 @@ +# Copyright (C) 2012-2021 Zammad Foundation, http://zammad-foundation.org/ + +require 'rails_helper' + +RSpec.describe Store::File, type: :model do + subject(:file) { described_class.add('foo') } + + describe '.add' do + context 'with no preconfigured storage provider' do + before { Setting.set('storage_provider', nil) } + + it 'defaults to the "DB" provider' do + expect(file.provider).to eq('DB') + end + end + + context 'with a preconfigured storage provider' do + before { Setting.set('storage_provider', 'File') } + + after { Store::Provider::File.delete(Digest::SHA256.hexdigest('foo')) } + + it 'defaults to the "DB" provider' do + expect(file.provider).to eq('File') + end + end + end + + describe '.verify' do + context 'when no Store::File records exist' do + it 'returns true' do + expect(described_class.verify).to be(true) + end + end + + context 'when all Store::File records have matching #content / #sha attributes' do + before do + file # create Store::File record + end + + it 'returns true' do + expect(described_class.verify).to be(true) + end + end + + context 'when at least one Store::File record’s #content / #sha attributes do not match' do + before do + file # create Store::File record + Store::Provider::DB.last.update(data: 'bar') + end + + it 'returns false' do + expect(described_class.verify).to be(false) + end + end + end + + describe '.move' do + before { Setting.set('storage_provider', nil) } + + after { Store::Provider::File.delete(Digest::SHA256.hexdigest('foo')) } + + let(:storage_path) { Rails.root.join('storage/fs') } + + it 'replaces all Store::Provider::{source} records with Store::Provider::{target} ones' do + file # create Store::File record + + expect { described_class.move('DB', 'File') } + .to change { file.reload.provider }.to('File') + .and change { Store::Provider::DB.count }.by(-1) + .and change { Dir[storage_path.join('**', '*')].select { |entry| File.file?(entry) }.count }.by(1) + end + + context 'when no Store::File records of the source type exist' do + it 'makes no changes and returns true' do + file # create Store::File record + + expect { described_class.move('File', 'DB') } + .not_to change { file.reload.provider } + end + end + + context 'when moving from "File" adapter to "DB"' do + before { Setting.set('storage_provider', 'File') } + + it 'removes stored files from filesystem' do + file # create Store::File record + + expect { described_class.move('File', 'DB') } + .to change { file.reload.provider }.to('DB') + .and change { Store::Provider::DB.count }.by(1) + .and change { Dir[storage_path.join('*')].count }.by(-1) + end + end + end +end diff --git a/spec/models/store/provider/file_spec.rb b/spec/models/store/provider/file_spec.rb new file mode 100644 index 000000000..ef7b83685 --- /dev/null +++ b/spec/models/store/provider/file_spec.rb @@ -0,0 +1,124 @@ +# Copyright (C) 2012-2021 Zammad Foundation, http://zammad-foundation.org/ + +require 'rails_helper' + +RSpec.describe Store::Provider::File do + before { FileUtils.rm_rf(Rails.root.join('storage/fs', sha[0, 4])) } + + after { FileUtils.rm_rf(Rails.root.join('storage/fs', sha[0, 4])) } + + let(:data) { 'foo' } + let(:sha) { Digest::SHA256.hexdigest(data) } + let(:filepath) { Rails.root.join('storage/fs/2c26/b46b/68ffc/68ff9/9b453c1/d304134/13422d706483bfa0f98a5e886266e7ae') } + + describe '.get_location' do + context 'with a valid SHA256 digest' do + let(:sha) { '0000111122222333334444444555555566666666666666666666666666666666' } + + it 'returns a Pathname matching the SHA digest (split into chunks of 4, 4, 5, 5, 7, 7, & 32 chars)' do + expect(described_class.get_location(sha)) + .to eq(Rails.root.join('storage/fs/0000/1111/22222/33333/4444444/5555555/66666666666666666666666666666666')) + end + end + end + + describe '.add' do + context 'when no matching file exists' do + it 'writes the file to disk' do + expect { described_class.add(data, sha) } + .to change { File.exist?(filepath) }.to(true) + + expect(File.read(filepath)).to eq(data) + end + + it 'sets permissions on the new file to 600' do + described_class.add(data, sha) + + expect(File.stat(filepath).mode & 0o777).to eq(0o600) + end + end + + context 'when a matching file exists' do + before { FileUtils.mkdir_p(filepath.parent) } + + context 'and its contents match the SHA digest of its filepath' do + before do + File.write(filepath, 'foo') + File.chmod(0o755, filepath) + end + + it 'sets file permissions to 600' do + expect { described_class.add(data, sha) } + .to change { File.stat(filepath).mode & 0o777 }.to(0o600) + end + end + + context 'and its contents do NOT match the SHA digest of its filepath' do + before { File.write(filepath, 'bar') } + + it 'replaces the corrupt file with the specified contents' do + expect { described_class.add(data, sha) } + .to change { File.read(filepath) }.to('foo') + end + end + end + end + + describe '.get' do + context 'when a file exists for the given SHA digest' do + before { FileUtils.mkdir_p(filepath.parent) } + + context 'and its contents match the digest' do + before { File.write(filepath, data) } + + it 'returns the contents of the file' do + expect(described_class.get(sha)).to eq('foo') + end + end + + context 'and its contents do NOT match the digest' do + before { File.write(filepath, 'bar') } + + it 'raises an error' do + expect { described_class.get(sha) } + .to raise_error(StandardError) + end + end + end + + context 'when NO file exists for the given SHA digest' do + it 'raises an error' do + expect { described_class.get(sha) } + .to raise_error(Errno::ENOENT) + end + end + end + + describe '.delete' do + before do + FileUtils.mkdir_p(filepath.parent) + File.write(filepath, data) + end + + it 'deletes the file' do + expect { described_class.delete(sha) } + .to change { File.exist?(filepath) }.to(false) + end + + context 'when the file’s parent directories contain other files' do + before { FileUtils.touch(filepath.parent.join('baz')) } + + it 'leaves non-empty subdirectories in place' do + expect { described_class.delete(sha) } + .not_to change { Dir.exist?(filepath.parent) } + end + end + + context 'when the file’s parent directories contain no other files' do + it 'deletes empty parent subdirectories, up to /storage/fs' do + expect { described_class.delete(sha) } + .to change { Dir.empty?(Rails.root.join('storage/fs')) }.to(true) + end + end + end +end diff --git a/spec/models/store_spec.rb b/spec/models/store_spec.rb new file mode 100644 index 000000000..2713f4847 --- /dev/null +++ b/spec/models/store_spec.rb @@ -0,0 +1,468 @@ +# Copyright (C) 2012-2021 Zammad Foundation, http://zammad-foundation.org/ + +require 'rails_helper' + +# NOTE: This class uses custom .add & .remove methods +# to create and destroy records. +# This pattern is a strong candidate for refactoring +# to make use of Rails' native ActiveRecord + callbacks functionality. +RSpec.describe Store, type: :model do + subject(:store) { described_class.add(**attributes) } + + let(:attributes) do + { + object: 'Test', + o_id: 1, + data: data, + filename: filename, + preferences: preferences, + created_by_id: 1, + } + end + + let(:data) { 'hello world' } + let(:filename) { 'test.txt' } + let(:preferences) { {} } + + describe 'Class methods:' do + describe '.add' do + it 'creates a new Store record' do + expect { described_class.add(**attributes) }.to change(described_class, :count).by(1) + end + + it 'returns the newly created Store record' do + expect(described_class.add(**attributes)).to eq(described_class.last) + end + + it 'saves data to #content attribute' do + expect { described_class.add(**attributes) } + .to change { described_class.last&.content }.to('hello world') + end + + it 'saves filename to #filename attribute' do + expect { described_class.add(**attributes) } + .to change { described_class.last&.filename }.to('test.txt') + end + + it 'sets #provider attribute to "DB"' do + expect { described_class.add(**attributes) } + .to change { described_class.last&.provider }.to('DB') + end + + context 'with UTF-8 (non-ASCII) characters in text' do + let(:data) { 'hello world äöüß' } + + it 'stores data as binary string to #content attribute' do + expect { described_class.add(**attributes) } + .to change { described_class.last&.content }.to('hello world äöüß'.force_encoding('ASCII-8BIT')) + end + end + + context 'with UTF-8 (non-ASCII) characters in filename' do + let(:filename) { 'testäöüß.txt' } + + it 'stores filename verbatim to #filename attribute' do + expect { described_class.add(**attributes) } + .to change { described_class.last&.filename }.to('testäöüß.txt') + end + end + + context 'with binary data' do + let(:data) { File.binread(Rails.root.join('test/data/pdf/test1.pdf')) } + + it 'stores data as binary string to #content attribute' do + expect { described_class.add(**attributes) } + .to change { described_class.last&.content&.class }.to(String) + .and change { described_class.last&.content }.to(data) + end + + it 'saves filename to #filename attribute' do + expect { described_class.add(**attributes) } + .to change { described_class.last&.filename }.to('test.txt') + end + + it 'sets #provider attribute to "DB"' do + expect { described_class.add(**attributes) } + .to change { described_class.last&.provider }.to('DB') + end + + context 'when an identical file has been stored before under a different name' do + before { described_class.add(**attributes) } + + it 'creates a new (duplicate) described_class record' do + expect { described_class.add(**attributes.merge(filename: 'test-again.pdf')) } + .to change(described_class, :count).by(1) + .and change { described_class.last&.filename }.to('test-again.pdf') + .and not_change { described_class.last&.content&.class } + .and not_change { described_class.last&.content } + end + end + end + + context 'with an image (jpeg/jpg/png)' do + let(:data) { File.binread(Rails.root.join('test/data/upload/upload2.jpg')) } + let(:preferences) { { content_type: 'image/jpg' } } + + it 'generates previews' do + described_class.add(**attributes) + + expect(described_class.last.preferences) + .to include(resizable: true, content_inline: true, content_preview: true) + end + + context 'when system is in import mode' do + before { Setting.set('import_mode', true) } + + it 'does not generate previews' do + described_class.add(**attributes) + + expect(described_class.last.preferences) + .not_to include(resizable: true, content_inline: true, content_preview: true) + end + end + end + end + + describe '.remove' do + before { described_class.add(**attributes) } + + it 'destroys the specified Store record' do + expect { described_class.remove(object: 'Test', o_id: 1) } + .to change(described_class, :count).by(-1) + end + + it 'destroys the associated Store::File record' do + expect { described_class.remove(object: 'Test', o_id: 1) } + .to change { described_class::File.count }.by(-1) + end + + context 'with the same file stored under multiple o_ids' do + before { described_class.add(**attributes.merge(o_id: 2)) } + + it 'destroys only the specified Store record' do + expect { described_class.remove(object: 'Test', o_id: 1) } + .to change(described_class, :count).by(-1) + end + + it 'does not destroy the associated Store::File record (because it is referenced by another Store)' do + expect { described_class.remove(object: 'Test', o_id: 1) } + .not_to change { Store::File.count } + end + end + + context 'with multiple files stored under the same o_id' do + before { described_class.add(**attributes.merge(data: 'bar')) } + + it 'destroys all matching Store records' do + expect { described_class.remove(object: 'Test', o_id: 1) } + .to change(described_class, :count).by(-2) + end + + it 'destroys all associated Store::File records' do + expect { described_class.remove(object: 'Test', o_id: 1) } + .to change { Store::File.count }.by(-2) + end + end + end + + describe '.list' do + let!(:store) do + described_class.add( + object: 'Test', + o_id: 1, + data: 'hello world', + filename: 'test.txt', + preferences: {}, + created_by_id: 1, + ) + end + + it 'runs a Store.where query for :object / :o_id parameters (:object is Store::Object association name)' do + expect(described_class.list(object: 'Test', o_id: 1)) + .to eq([store]) + end + + context 'without a Store::Object name' do + it 'returns an empty ActiveRecord::Relation' do + expect(described_class.list(o_id: 1)) + .to be_an(ActiveRecord::Relation).and be_empty + end + end + + context 'without a #o_id' do + it 'returns an empty ActiveRecord::Relation' do + expect(described_class.list(object: 'Test')) + .to be_an(ActiveRecord::Relation).and be_empty + end + end + end + end + + describe 'Instance methods:' do + describe 'image previews (#content_inline / #content_preview)' do + let(:attributes) do + { + object: 'Test', + o_id: 1, + data: data, + filename: 'test1.pdf', + preferences: { + content_type: content_type, + content_id: 234, + }, + created_by_id: 1, + } + end + + let(:resized_inline_image) do + File.binwrite(temp_file, store.content_inline) + Rszr::Image.load(temp_file) + end + + let(:resized_preview_image) do + File.binwrite(temp_file.next, store.content_preview) + Rszr::Image.load(temp_file.next) + end + + let(:temp_file) { Tempfile.new.path } + + context 'with content_type: "text/plain"' do + let(:content_type) { 'text/plain' } + + context 'and text content' do + let(:data) { 'foo' } + + it 'cannot be resized (neither inlined nor previewed)' do + expect { store.content_inline } + .to raise_error('Unable to generate inline') + + expect { store.content_preview } + .to raise_error('Unable to generate preview') + + expect(store.preferences) + .to not_include(resizable: true) + .and not_include(content_inline: true) + .and not_include(content_preview: true) + end + end + end + + context 'with content_type: "image/*"' do + context 'and text content' do + let(:content_type) { 'image/jpeg' } + let(:data) { 'foo' } + + it 'cannot be resized (neither inlined nor previewed)' do + expect { store.content_inline } + .to raise_error('Unable to generate inline') + + expect { store.content_preview } + .to raise_error('Unable to generate preview') + + expect(store.preferences) + .to not_include(resizable: true) + .and not_include(content_inline: true) + .and not_include(content_preview: true) + end + end + + context 'with image content (width > 1800px)' do + context 'width <= 200px' do + let(:content_type) { 'image/png' } + let(:data) { File.binread(Rails.root.join('test/data/image/1x1.png')) } + + it 'cannot be resized (neither inlined nor previewed)' do + expect { store.content_inline } + .to raise_error('Unable to generate inline') + + expect { store.content_preview } + .to raise_error('Unable to generate preview') + + expect(store.preferences) + .to not_include(resizable: true) + .and not_include(content_inline: true) + .and not_include(content_preview: true) + end + end + + context '200px < width <= 1800px)' do + let(:content_type) { 'image/png' } + let(:data) { File.binread(Rails.root.join('test/data/image/1000x1000.png')) } + + it 'can be resized (previewed but not inlined)' do + expect { store.content_inline } + .to raise_error('Unable to generate inline') + + expect(resized_preview_image.width).to eq(200) + + expect(store.preferences) + .to include(resizable: true) + .and not_include(content_inline: true) + .and include(content_preview: true) + end + end + + context '1800px < width' do + let(:content_type) { 'image/jpeg' } + let(:data) { File.binread(Rails.root.join('test/data/upload/upload2.jpg')) } + + it 'can be resized (inlined @ 1800px wide or previewed @ 200px wide)' do + expect(resized_inline_image.width).to eq(1800) + expect(resized_preview_image.width).to eq(200) + + expect(store.preferences) + .to include(resizable: true) + .and include(content_inline: true) + .and include(content_preview: true) + end + + context 'kind of wide/short: 8000x300' do + let(:data) { File.binread(Rails.root.join('test/data/image/8000x300.jpg')) } + + it 'can be resized (inlined @ 1800px wide or previewed @ 200px wide)' do + expect(resized_inline_image.width).to eq(1800) + expect(resized_preview_image.width).to eq(200) + + expect(store.preferences) + .to include(resizable: true) + .and include(content_inline: true) + .and include(content_preview: true) + end + end + + context 'very wide/short: 4000x1; i.e., <= 6px vertically per 200px (preview) or 1800px (inline) horizontally' do + let(:data) { File.binread(Rails.root.join('test/data/image/4000x1.jpg')) } + + it 'cannot be resized (neither inlined nor previewed)' do + expect { store.content_inline } + .to raise_error('Unable to generate inline') + + expect { store.content_preview } + .to raise_error('Unable to generate preview') + + expect(store.preferences) + .to not_include(resizable: true) + .and not_include(content_inline: true) + .and not_include(content_preview: true) + end + end + + context 'very wide/short: 8000x25; i.e., <= 6px vertically per 200px (preview) or 1800px (inline) horizontally' do + let(:data) { File.binread(Rails.root.join('test/data/image/8000x25.jpg')) } + + it 'cannot be resized (neither inlined nor previewed)' do + expect { store.content_inline } + .to raise_error('Unable to generate inline') + + expect { store.content_preview } + .to raise_error('Unable to generate preview') + + expect(store.preferences) + .to not_include(resizable: true) + .and not_include(content_inline: true) + .and not_include(content_preview: true) + end + end + end + end + end + end + end + + context 'when preferences exceed storage size' do + + let(:valid_entries) do + { + content_type: 'text/plain', + content_id: 234, + } + end + + shared_examples 'keeps other entries' do + + context 'when other entries are present' do + + let(:preferences) do + super().merge(valid_entries) + end + + it 'keeps these entries' do + expect(store.preferences).to include(valid_entries) + end + end + end + + context 'when single content is oversized' do + + let(:preferences) do + { + oversized_content: '0' * 2500, + } + end + + it 'removes that entry' do + expect(store.preferences).not_to have_key(:oversized_content) + end + + include_examples 'keeps other entries' + end + + context 'when the sum of multiple contents is oversized' do + + let(:preferences) do + { + oversized_content1: '0' * 2000, + oversized_content2: '0' * 2000, + } + end + + it 'removes first entry' do + expect(store.preferences).not_to have_key(:oversized_content1) + end + + it 'keeps second entry' do + expect(store.preferences).to have_key(:oversized_content2) + end + + include_examples 'keeps other entries' + end + + context 'when single key is oversized' do + + let(:oversized_key) { '0' * 2500 } + let(:preferences) do + { + oversized_key => 'valid content', + } + end + + it 'removes that entry' do + expect(store.preferences).not_to have_key(oversized_key) + end + + include_examples 'keeps other entries' + end + + context 'when the sum of multiple keys is oversized' do + + let(:oversized_key1) { '0' * 1500 } + let(:oversized_key2) { '1' * 1500 } + let(:preferences) do + { + oversized_key1 => 'valid content', + oversized_key2 => 'valid content', + } + end + + it 'removes first entry' do + expect(store.preferences).not_to have_key(oversized_key1) + end + + it 'keeps second entry' do + expect(store.preferences).to have_key(oversized_key2) + end + + include_examples 'keeps other entries' + end + end +end diff --git a/test/unit/store_test.rb b/test/unit/store_test.rb deleted file mode 100644 index 3d00bc7a8..000000000 --- a/test/unit/store_test.rb +++ /dev/null @@ -1,424 +0,0 @@ -# Copyright (C) 2012-2021 Zammad Foundation, http://zammad-foundation.org/ - -require 'test_helper' - -class StoreTest < ActiveSupport::TestCase - test 'store fs - get_location' do - sha = 'ed7002b439e9ac845f22357d822bac1444730fbdb6016d3ec9432297b9ec9f73' - location = Store::Provider::File.get_location(sha) - assert_equal(Rails.root.join('storage/fs/ed70/02b4/39e9a/c845f/22357d8/22bac14/44730fbdb6016d3ec9432297b9ec9f73').to_s, location) - end - - test 'store fs - empty dir remove' do - sha = 'ed7002b439e9ac845f22357d822bac1444730fbdb6016d3ec9432297b9ec9f73' - content = 'content' - location = Store::Provider::File.get_location(sha) - result = Store::Provider::File.add(content, sha) - assert(result) - exists = File.exist?(location) - assert(exists) - Store::Provider::File.delete(sha) - exists = File.exist?(location) - assert_not(exists) - exists = File.exist?(Rails.root.join('storage/fs/ed70/02b4')) - assert_not(exists) - exists = File.exist?(Rails.root.join('storage/fs/ed70')) - assert_not(exists) - exists = File.exist?(Rails.root.join('storage/fs')) - assert(exists) - exists = File.exist?(Rails.root.join('storage')) - assert(exists) - end - - test 'store attachment and move it between backends' do - files = [ - { - data: 'hello world', - filename: 'test.txt', - o_id: 1, - }, - { - data: 'hello world äöüß', - filename: 'testäöüß.txt', - o_id: 2, - }, - { - data: File.binread(Rails.root.join('test/data/pdf/test1.pdf')), - filename: 'test.pdf', - o_id: 3, - }, - { - data: File.binread(Rails.root.join('test/data/pdf/test1.pdf')), - filename: 'test-again.pdf', - o_id: 4, - }, - ] - - files.each do |file| - sha = Digest::SHA256.hexdigest(file[:data]) - - # add attachments - store = Store.add( - object: 'Test', - o_id: file[:o_id], - data: file[:data], - filename: file[:filename], - preferences: {}, - created_by_id: 1, - ) - assert store - - # get list of attachments - attachments = Store.list( - object: 'Test', - o_id: file[:o_id], - ) - assert attachments - - # sha check - sha_new = Digest::SHA256.hexdigest(attachments[0].content) - assert_equal(sha, sha_new, "check file #{file[:filename]}") - - # filename check - assert_equal(file[:filename], attachments[0].filename) - - # provider check - assert_equal('DB', attachments[0].provider) - end - - success = Store::File.verify - assert success, 'verify ok' - - Store::File.move('DB', 'File') - - files.each do |file| - sha = Digest::SHA256.hexdigest(file[:data]) - - # get list of attachments - attachments = Store.list( - object: 'Test', - o_id: file[:o_id], - ) - assert attachments - - # sha check - sha_new = Digest::SHA256.hexdigest(attachments[0].content) - assert_equal(sha, sha_new, "check file #{file[:filename]}") - - # filename check - assert_equal(file[:filename], attachments[0].filename) - - # provider check - assert_equal('File', attachments[0].provider) - end - - success = Store::File.verify - assert success, 'verify ok' - - Store::File.move('File', 'DB') - - files.each do |file| - sha = Digest::SHA256.hexdigest(file[:data]) - - # get list of attachments - attachments = Store.list( - object: 'Test', - o_id: file[:o_id], - ) - assert(attachments) - assert_equal(attachments.count, 1) - - # sha check - sha_new = Digest::SHA256.hexdigest(attachments[0].content) - assert_equal(sha, sha_new, "check file #{file[:filename]}") - - # filename check - assert_equal(file[:filename], attachments[0].filename) - - # provider check - assert_equal('DB', attachments[0].provider) - - # delete attachments - success = Store.remove( - object: 'Test', - o_id: file[:o_id], - ) - assert(success) - - # check attachments again - attachments = Store.list( - object: 'Test', - o_id: file[:o_id], - ) - assert_not(attachments[0]) - end - end - - test 'test resizable' do - - # not possible - store = Store.add( - object: 'SomeObject1', - o_id: rand(1_234_567_890), - data: File.binread(Rails.root.join('test/data/upload/upload1.txt')), - filename: 'test1.pdf', - preferences: { - content_type: 'text/plain', - content_id: 234, - }, - created_by_id: 1, - ) - assert_not(store.preferences.key?(:resizable)) - assert_not(store.preferences.key?(:content_inline)) - assert_not(store.preferences.key?(:content_preview)) - assert_raises(RuntimeError) do - store.content_inline - end - assert_raises(RuntimeError) do - store.content_preview - end - - # not possible - store = Store.add( - object: 'SomeObject2', - o_id: rand(1_234_567_890), - data: File.binread(Rails.root.join('test/data/upload/upload1.txt')), - filename: 'test1.pdf', - preferences: { - content_type: 'image/jpg', - content_id: 234, - }, - created_by_id: 1, - ) - assert_equal(store.preferences[:resizable], false) - assert_not(store.preferences.key?(:content_inline)) - assert_not(store.preferences.key?(:content_preview)) - assert_raises(RuntimeError) do - store.content_inline - end - assert_raises(RuntimeError) do - store.content_preview - end - - # possible (preview and inline) - store = Store.add( - object: 'SomeObject3', - o_id: rand(1_234_567_890), - data: File.binread(Rails.root.join('test/data/upload/upload2.jpg')), - filename: 'test1.pdf', - preferences: { - content_type: 'image/jpg', - content_id: 234, - }, - created_by_id: 1, - ) - assert_equal(store.preferences[:resizable], true) - assert_equal(store.preferences[:content_inline], true) - assert_equal(store.preferences[:content_preview], true) - - temp_file = ::Tempfile.new.path - File.binwrite(temp_file, store.content_inline) - image = Rszr::Image.load(temp_file) - assert_equal(image.width, 1800) - - temp_file = ::Tempfile.new.path - File.binwrite(temp_file, store.content_preview) - image = Rszr::Image.load(temp_file) - assert_equal(image.width, 200) - - # possible (preview only) - store = Store.add( - object: 'SomeObject4', - o_id: rand(1_234_567_890), - data: File.binread(Rails.root.join('test/data/image/1000x1000.png')), - filename: 'test1.png', - preferences: { - content_type: 'image/png', - content_id: 234, - }, - created_by_id: 1, - ) - assert_equal(store.preferences[:resizable], true) - assert_nil(store.preferences[:content_inline]) - assert_equal(store.preferences[:content_preview], true) - assert_raises(RuntimeError) do - store.content_inline - end - - temp_file = ::Tempfile.new.path - File.binwrite(temp_file, store.content_preview) - image = Rszr::Image.load(temp_file) - assert_equal(image.width, 200) - - # no preview or inline needed - store = Store.add( - object: 'SomeObject5', - o_id: rand(1_234_567_890), - data: File.binread(Rails.root.join('test/data/image/1x1.png')), - filename: 'test1.png', - preferences: { - content_type: 'image/png', - content_id: 234, - }, - created_by_id: 1, - ) - assert_nil(store.preferences[:resizable]) - assert_nil(store.preferences[:content_inline]) - assert_nil(store.preferences[:content_preview]) - assert_raises(RuntimeError) do - store.content_inline - end - assert_raises(RuntimeError) do - store.content_preview - end - - # no preview or inline needed - store = Store.add( - object: 'SomeObject6', - o_id: rand(1_234_567_890), - data: File.binread(Rails.root.join('test/data/image/4000x1.jpg')), - filename: 'test1.jpg', - preferences: { - content_type: 'image/jpg', - content_id: 234, - }, - created_by_id: 1, - ) - assert_nil(store.preferences[:resizable]) - assert_nil(store.preferences[:content_inline]) - assert_nil(store.preferences[:content_preview]) - - # possible (no preview or inline needed) - store = Store.add( - object: 'SomeObject7', - o_id: rand(1_234_567_890), - data: File.binread(Rails.root.join('test/data/image/8000x25.jpg')), - filename: 'test1.jpg', - preferences: { - content_type: 'image/jpg', - content_id: 234, - }, - created_by_id: 1, - ) - assert_nil(store.preferences[:resizable]) - assert_nil(store.preferences[:content_inline]) - assert_nil(store.preferences[:content_preview]) - - # possible (preview and inline) - store = Store.add( - object: 'SomeObject8', - o_id: rand(1_234_567_890), - data: File.binread(Rails.root.join('test/data/image/8000x300.jpg')), - filename: 'test1.jpg', - preferences: { - content_type: 'image/jpg', - content_id: 234, - }, - created_by_id: 1, - ) - assert_equal(store.preferences[:resizable], true) - assert_equal(store.preferences[:content_inline], true) - assert_equal(store.preferences[:content_preview], true) - - # possible, but skipped (preview and inline) - Setting.set('import_mode', true) - store = Store.add( - object: 'SomeObject3', - o_id: rand(1_234_567_890), - data: File.binread(Rails.root.join('test/data/upload/upload2.jpg')), - filename: 'test1.pdf', - preferences: { - content_type: 'image/jpg', - content_id: 234, - }, - created_by_id: 1, - ) - assert_nil(store.preferences[:resizable]) - assert_nil(store.preferences[:content_inline]) - assert_nil(store.preferences[:content_preview]) - end - - test 'test maximal preferences size check with one oversized content' do - store = Store.add( - object: 'SomeObject1', - o_id: rand(1_234_567_890), - data: File.binread(Rails.root.join('test/data/upload/upload1.txt')), - filename: 'test1.pdf', - preferences: { - content_type: 'text/plain', - content_id: 234, - some_key: '0' * 2500, - }, - created_by_id: 1, - ) - assert_not(store.preferences.key?(:some_key)) - assert(store.preferences.key?(:content_id)) - assert(store.preferences.key?(:content_type)) - end - - test 'test maximal preferences size check with two oversized content' do - store = Store.add( - object: 'SomeObject1', - o_id: rand(1_234_567_890), - data: File.binread(Rails.root.join('test/data/upload/upload1.txt')), - filename: 'test1.pdf', - preferences: { - content_type: 'text/plain', - content_id: 234, - some_key1: '0' * 2000, - some_key2: '0' * 2000, - }, - created_by_id: 1, - ) - assert_not(store.preferences.key?(:some_key1)) - assert(store.preferences.key?(:some_key2)) - assert(store.preferences.key?(:content_id)) - assert(store.preferences.key?(:content_type)) - end - - test 'test maximal preferences size check with one oversized key' do - preferences = { - content_type: 'text/plain', - content_id: 234, - } - some_key1 = '0' * 2500 - preferences[some_key1] = 'some content' - - store = Store.add( - object: 'SomeObject1', - o_id: rand(1_234_567_890), - data: File.binread(Rails.root.join('test/data/upload/upload1.txt')), - filename: 'test1.pdf', - preferences: preferences, - created_by_id: 1, - ) - assert_not(store.preferences.key?(some_key1)) - assert(store.preferences.key?(:content_id)) - assert(store.preferences.key?(:content_type)) - end - - test 'test maximal preferences size check with two oversized key' do - preferences = { - content_type: 'text/plain', - content_id: 234, - } - some_key1 = '0' * 1500 - preferences[some_key1] = 'some content' - some_key2 = '1' * 1500 - preferences[some_key2] = 'some content' - - store = Store.add( - object: 'SomeObject1', - o_id: rand(1_234_567_890), - data: File.binread(Rails.root.join('test/data/upload/upload1.txt')), - filename: 'test1.pdf', - preferences: preferences, - created_by_id: 1, - ) - assert_not(store.preferences.key?(some_key1)) - assert(store.preferences.key?(some_key2)) - assert(store.preferences.key?(:content_id)) - assert(store.preferences.key?(:content_type)) - end - -end