trabajo-afectivo/spec/models/ticket/article_spec.rb

577 lines
20 KiB
Ruby
Raw Normal View History

2018-12-13 09:06:44 +00:00
require 'rails_helper'
require 'models/application_model_examples'
require 'models/concerns/can_be_imported_examples'
require 'models/concerns/can_csv_import_examples'
require 'models/concerns/has_history_examples'
require 'models/concerns/has_object_manager_attributes_validation_examples'
RSpec.describe Ticket::Article, type: :model do
it_behaves_like 'ApplicationModel'
it_behaves_like 'CanBeImported'
it_behaves_like 'CanCsvImport'
it_behaves_like 'HasHistory'
it_behaves_like 'HasObjectManagerAttributesValidation'
2018-12-13 09:06:44 +00:00
subject(:article) { create(:ticket_article) }
describe 'Callbacks, Observers, & Async Transactions -' do
describe 'NULL byte handling (via ChecksAttributeValuesAndLength concern):' do
it 'removes them from #subject on creation, if necessary (postgres doesnt like them)' do
expect(create(:ticket_article, subject: "com test 1\u0000"))
.to be_persisted
end
it 'removes them from #body on creation, if necessary (postgres doesnt like them)' do
expect(create(:ticket_article, body: "some\u0000message 123"))
.to be_persisted
end
end
describe 'Setting of ticket.create_article_{sender,type}' do
let!(:ticket) { create(:ticket) }
context 'on creation' do
context 'of first article on a ticket' do
subject(:article) do
create(:ticket_article, ticket: ticket, sender_name: 'Agent', type_name: 'email')
end
it 'sets ticket sender/type attributes based on article sender/type' do
expect { article }
.to change { ticket.reload.create_article_sender&.name }.to('Agent')
.and change { ticket.reload.create_article_type&.name }.to('email')
end
end
context 'of subsequent articles on a ticket' do
subject(:article) do
create(:ticket_article, ticket: ticket, sender_name: 'Customer', type_name: 'twitter status')
end
let!(:first_article) do
create(:ticket_article, ticket: ticket, sender_name: 'Agent', type_name: 'email')
end
it 'does not modify tickets sender/type attributes' do
expect { article }
.to not_change { ticket.reload.create_article_sender.name }
.and not_change { ticket.reload.create_article_type.name }
end
end
end
end
describe 'XSS protection:' do
subject(:article) { create(:ticket_article, body: body, content_type: 'text/html') }
context 'when body contains only injected JS' do
let(:body) { <<~RAW.chomp }
<script type="text/javascript">alert("XSS!");</script>
RAW
it 'removes <script> tags' do
expect(article.body).to eq('alert("XSS!");')
end
end
context 'when body contains injected JS amidst other text' do
let(:body) { <<~RAW.chomp }
please tell me this doesn't work: <script type="text/javascript">alert("XSS!");</script>
RAW
it 'removes <script> tags' do
expect(article.body).to eq(<<~SANITIZED.chomp)
please tell me this doesn't work: alert("XSS!");
SANITIZED
end
end
context 'when body contains invalid HTML tags' do
let(:body) { '<some_not_existing>ABC</some_not_existing>' }
it 'removes invalid tags' do
expect(article.body).to eq('ABC')
end
end
context 'when body contains restricted HTML attributes' do
let(:body) { '<div class="adasd" id="123" data-abc="123"></div>' }
it 'removes restricted attributes' do
expect(article.body).to eq('<div></div>')
end
end
context 'when body contains JS injected into href attribute' do
let(:body) { '<a href="javascript:someFunction()">LINK</a>' }
it 'removes <a> tags' do
expect(article.body).to eq('LINK')
end
end
context 'when body contains an unclosed <div> element' do
let(:body) { '<div>foo' }
it 'closes it' do
expect(article.body).to eq('<div>foo</div>')
end
end
context 'when body contains a plain link (<a> element)' do
let(:body) { '<a href="https://example.com">foo</a>' }
it 'adds sanitization attributes' do
expect(article.body).to eq(<<~SANITIZED.chomp)
<a href="https://example.com" rel="nofollow noreferrer noopener" target="_blank" title="https://example.com">foo</a>
SANITIZED
end
end
context 'for all cases above, combined' do
let(:body) { <<~RAW.chomp }
please tell me this doesn't work: <table>ada<tr></tr></table>
<div class="adasd" id="123" data-abc="123"></div>
<div>
<a href="javascript:someFunction()">LINK</a>
<a href="http://lalal.de">aa</a>
<some_not_existing>ABC</some_not_existing>
RAW
it 'performs all sanitizations' do
expect(article.body).to eq(<<~SANITIZED.chomp)
please tell me this doesn't work: <table>ada<tr></tr>
</table>
<div></div>
<div>
LINK
<a href="http://lalal.de" rel="nofollow noreferrer noopener" target="_blank" title="http://lalal.de">aa</a>
ABC</div>
SANITIZED
end
end
context 'for content_type: "text/plain"' do
subject(:article) { create(:ticket_article, body: body, content_type: 'text/plain') }
let(:body) { <<~RAW.chomp }
please tell me this doesn't work: <table>ada<tr></tr></table>
<div class="adasd" id="123" data-abc="123"></div>
<div>
<a href="javascript:someFunction()">LINK</a>
<a href="http://lalal.de">aa</a>
<some_not_existing>ABC</some_not_existing>
RAW
it 'performs no sanitizations' do
expect(article.body).to eq(<<~SANITIZED.chomp)
please tell me this doesn't work: <table>ada<tr></tr></table>
<div class="adasd" id="123" data-abc="123"></div>
<div>
<a href="javascript:someFunction()">LINK</a>
<a href="http://lalal.de">aa</a>
<some_not_existing>ABC</some_not_existing>
SANITIZED
end
end
context 'when body contains <video> element' do
let(:body) { <<~RAW.chomp }
please tell me this doesn't work: <video>some video</video><foo>alal</foo>
RAW
it 'leaves it as-is' do
expect(article.body).to eq(<<~SANITIZED.chomp)
please tell me this doesn't work: <video>some video</video>alal
SANITIZED
end
end
context 'when body contains CSS in style attribute' do
context 'for cid-style email attachment' do
let(:body) { <<~RAW.chomp }
<img style="width: 85.5px; height: 49.5px" src="cid:15.274327094.140938@zammad.example.com">
asdasd
<img src="cid:15.274327094.140939@zammad.example.com">
RAW
it 'adds terminal semicolons to style rules' do
expect(article.body).to eq(<<~SANITIZED.chomp)
<img style="width: 85.5px; height: 49.5px;" src="cid:15.274327094.140938@zammad.example.com">
asdasd
<img src="cid:15.274327094.140939@zammad.example.com">
SANITIZED
end
end
context 'for relative-path-style email attachment' do
let(:body) { <<~RAW.chomp }
<img style="width: 85.5px; height: 49.5px" src="api/v1/ticket_attachment/123/123/123">
asdasd
<img src="api/v1/ticket_attachment/123/123/123">
RAW
it 'adds terminal semicolons to style rules' do
expect(article.body).to eq(<<~SANITIZED.chomp)
<img style="width: 85.5px; height: 49.5px;" src="api/v1/ticket_attachment/123/123/123">
asdasd
<img src="api/v1/ticket_attachment/123/123/123">
SANITIZED
end
end
end
context 'when body contains <body> elements' do
let(:body) { '<body>123</body>' }
it 'removes <body> tags' do
expect(article.body).to eq('123')
end
end
context 'when body contains onclick attributes in <a> elements' do
let(:body) { <<~RAW.chomp }
<a href="#" onclick="some_function();">abc</a>
<a href="https://example.com" oNclIck="some_function();">123</a>
RAW
it 'removes onclick attributes' do
expect(article.body).to eq(<<~SANITIZED.chomp)
<a href="#">abc</a>
<a href="https://example.com" rel="nofollow noreferrer noopener" target="_blank" title="https://example.com">123</a>
SANITIZED
end
end
end
describe 'DoS protection:' do
context 'when #body exceeds 1.5MB' do
subject(:article) { create(:ticket_article, body: body) }
let(:body) { 'a' * 2_000_000 }
context 'for "web" thread', application_handle: 'web' do
it 'raises an Unprocessable Entity error' do
expect { article }.to raise_error(Exceptions::UnprocessableEntity)
end
end
context 'for "test.postmaster" thread', application_handle: 'test.postmaster' do
it 'truncates body to 1.5 million chars' do
expect(article.body.length).to eq(1_500_000)
end
context 'with NULL bytes' do
let(:body) { "\u0000" + 'a' * 2_000_000 }
it 'still removes them, if necessary (postgres doesnt like them)' do
expect(article).to be_persisted
end
it 'still truncates body' do
expect(article.body.length).to eq(1_500_000)
end
end
end
end
end
describe 'Cti::Log syncing:' do
context 'with existing Log records' do
context 'for an incoming call from an unknown number' do
let!(:log) { create(:'cti/log', :with_preferences, from: '491111222222', direction: 'in') }
context 'with that number in #body' do
subject(:article) { build(:ticket_article, body: <<~BODY) }
some message
+49 1111 222222
BODY
it 'does not modify any Log records (because CallerIds from article bodies have #level "maybe")' do
expect do
article.save
Observer::Transaction.commit
Scheduler.worker(true)
end.not_to change { log.reload.attributes }
end
end
end
end
end
Refactoring: Automatic RSpec VCR cassette name helper This commit was prepared to support upcoming additions to the test suite (specifically, better coverage for existing Twitter functionality). These upcoming changes will depend heavily on VCR.[0] (VCR is a Ruby gem that makes it easier to write and run tests that call out to external services over HTTP by "recording" HTTP transactions to a YAML file and "replaying" them later.) VCR is widely-used (4600 GitHub stars), but its API is a little clumsy-- You have to manually specify the name of a "cassette" file every time: it 'does something' do VCR.use_cassette('path/to/cassette') do ... end end This commit adds an RSpec metadata config option as a shorthand for the syntax above: it 'does something', :use_vcr do ... end This config option automatically generates a cassette filename based on the description of the example it's applied to. === Analysis of alternative approaches Ideally, these auto-generated cassette filenames should be unique: if filenames collide, multiple examples will share the same cassette. A first attempt generated names based on `example.full_description`, but that led to errors: Errno::ENAMETOOLONG: File name too long @ rb_sysopen - /opt/zammad/test/data/vcr_cassettes/models/ticket/article/ticket_article_callbacks_observers_async_transactions_-_auto-setting_of_outgoing_twitter_article_attributes_via_bg_jobs_when_the_original_channel_specified_in_ticket_preferences_was_deleted_but_a_new_one_with_the_same_screen_name_exists_sets_appropriate_status_attributes_on_the_new_channel.yml Another idea was to use MD5 digests of the above, but in fact both of these approaches share another problem: even minor changes to the description could break tests (unless the committer remembers to rename the cassette file to match): an altered description means VCR will record a new cassette file instead of replaying from the original. (Normally, this would only slow down the test instead of breaking it, but sometimes we modify tests and cassettes after recording them to hide sensitive data like API keys or login credentials.) The approach taken by this commit was to use partial descriptions, combining the parent `describe`/`context` label with the `it` label. This does not guarantee uniqueness-- even in the present refactoring, it produced a filename collision-- but it's a good middle ground. [0]: https://relishapp.com/vcr/vcr/docs
2019-11-12 08:17:21 +00:00
describe 'Auto-setting of outgoing Twitter article attributes (via bg jobs):', use_vcr: :with_oauth_headers do
subject!(:twitter_article) { create(:twitter_article, sender_name: 'Agent') }
let(:channel) { Channel.find(twitter_article.ticket.preferences[:channel_id]) }
Refactoring: Automatic RSpec VCR cassette name helper This commit was prepared to support upcoming additions to the test suite (specifically, better coverage for existing Twitter functionality). These upcoming changes will depend heavily on VCR.[0] (VCR is a Ruby gem that makes it easier to write and run tests that call out to external services over HTTP by "recording" HTTP transactions to a YAML file and "replaying" them later.) VCR is widely-used (4600 GitHub stars), but its API is a little clumsy-- You have to manually specify the name of a "cassette" file every time: it 'does something' do VCR.use_cassette('path/to/cassette') do ... end end This commit adds an RSpec metadata config option as a shorthand for the syntax above: it 'does something', :use_vcr do ... end This config option automatically generates a cassette filename based on the description of the example it's applied to. === Analysis of alternative approaches Ideally, these auto-generated cassette filenames should be unique: if filenames collide, multiple examples will share the same cassette. A first attempt generated names based on `example.full_description`, but that led to errors: Errno::ENAMETOOLONG: File name too long @ rb_sysopen - /opt/zammad/test/data/vcr_cassettes/models/ticket/article/ticket_article_callbacks_observers_async_transactions_-_auto-setting_of_outgoing_twitter_article_attributes_via_bg_jobs_when_the_original_channel_specified_in_ticket_preferences_was_deleted_but_a_new_one_with_the_same_screen_name_exists_sets_appropriate_status_attributes_on_the_new_channel.yml Another idea was to use MD5 digests of the above, but in fact both of these approaches share another problem: even minor changes to the description could break tests (unless the committer remembers to rename the cassette file to match): an altered description means VCR will record a new cassette file instead of replaying from the original. (Normally, this would only slow down the test instead of breaking it, but sometimes we modify tests and cassettes after recording them to hide sensitive data like API keys or login credentials.) The approach taken by this commit was to use partial descriptions, combining the parent `describe`/`context` label with the `it` label. This does not guarantee uniqueness-- even in the present refactoring, it produced a filename collision-- but it's a good middle ground. [0]: https://relishapp.com/vcr/vcr/docs
2019-11-12 08:17:21 +00:00
let(:run_bg_jobs) { -> { Scheduler.worker(true) } }
2018-12-13 09:06:44 +00:00
it 'sets #from to senders Twitter handle' do
expect(&run_bg_jobs)
.to change { twitter_article.reload.from }
.to('@example')
end
2018-12-13 09:06:44 +00:00
it 'sets #to to recipients Twitter handle' do
expect(&run_bg_jobs)
.to change { twitter_article.reload.to }
.to('') # Tweet in VCR cassette is addressed to no one
end
2018-12-13 09:06:44 +00:00
it 'sets #message_id to tweet ID (https://twitter.com/_/status/<id>)' do
expect(&run_bg_jobs)
.to change { twitter_article.reload.message_id }
.to('1069382411899817990')
end
2018-12-13 09:06:44 +00:00
it 'sets #preferences with tweet metadata' do
expect(&run_bg_jobs)
.to change { twitter_article.reload.preferences }
.to(hash_including('twitter', 'links'))
expect(twitter_article.preferences[:links].first)
.to include(
'name' => 'on Twitter',
'target' => '_blank',
'url' => "https://twitter.com/_/status/#{twitter_article.message_id}"
)
end
2018-12-13 09:06:44 +00:00
it 'does not change #cc' do
expect(&run_bg_jobs).not_to change { twitter_article.reload.cc }
end
2018-12-13 09:06:44 +00:00
it 'does not change #subject' do
expect(&run_bg_jobs).not_to change { twitter_article.reload.subject }
end
2018-12-13 09:06:44 +00:00
it 'does not change #content_type' do
expect(&run_bg_jobs).not_to change { twitter_article.reload.content_type }
end
2018-12-13 09:06:44 +00:00
it 'does not change #body' do
expect(&run_bg_jobs).not_to change { twitter_article.reload.body }
end
2018-12-13 09:06:44 +00:00
it 'does not change #sender' do
expect(&run_bg_jobs).not_to change { twitter_article.reload.sender }
end
it 'does not change #type' do
expect(&run_bg_jobs).not_to change { twitter_article.reload.type }
end
it 'sets appropriate status attributes on the tickets channel' do
expect(&run_bg_jobs)
.to change { channel.reload.attributes }
.to hash_including(
'status_in' => nil,
'last_log_in' => nil,
'status_out' => 'ok',
'last_log_out' => ''
)
end
2018-12-13 09:06:44 +00:00
context 'when the original channel (specified in ticket.preferences) was deleted' do
context 'but a new one with the same screen_name exists' do
let(:new_channel) { create(:twitter_channel) }
before do
channel.destroy
expect(new_channel.options[:user][:screen_name])
.to eq(channel.options[:user][:screen_name])
2018-12-13 09:06:44 +00:00
end
it 'sets appropriate status attributes on the new channel' do
2018-12-13 09:06:44 +00:00
expect(&run_bg_jobs)
.to change { new_channel.reload.attributes }
2018-12-13 09:06:44 +00:00
.to hash_including(
'status_in' => nil,
'last_log_in' => nil,
'status_out' => 'ok',
'last_log_out' => ''
2018-12-13 09:06:44 +00:00
)
end
end
end
end
describe 'Sending of outgoing emails', performs_jobs: true do
subject(:article) { create(:ticket_article, type_name: type, sender_name: sender) }
shared_examples 'sends email' do
it 'dispatches an email on creation (via TicketArticleCommunicateEmailJob)' do
expect { article }
.to have_enqueued_job(TicketArticleCommunicateEmailJob)
end
end
shared_examples 'does not send email' do
it 'does not dispatch an email' do
expect { article }
.not_to have_enqueued_job(TicketArticleCommunicateEmailJob)
end
end
context 'with "application_server" application handle', application_handle: 'application_server' do
context 'for type: "email"' do
let(:type) { 'email' }
context 'from sender: "Agent"' do
let(:sender) { 'Agent' }
include_examples 'sends email'
end
context 'from sender: "Customer"' do
let(:sender) { 'Customer' }
include_examples 'does not send email'
end
end
context 'for any other type' do
let(:type) { 'sms' }
context 'from any sender' do
let(:sender) { 'Agent' }
include_examples 'does not send email'
end
end
end
context 'with "*.postmaster" application handle', application_handle: 'scheduler.postmaster' do
context 'for any type' do
let(:type) { 'email' }
context 'from any sender' do
let(:sender) { 'Agent' }
include_examples 'does not send email'
end
end
end
end
2018-12-13 09:06:44 +00:00
end
describe 'clone attachments' do
context 'of forwarded article' do
context 'via email' do
it 'only need to clone attached attachments' do
article_parent = create(:ticket_article,
type: Ticket::Article::Type.find_by(name: 'email'),
content_type: 'text/html',
body: '<img src="cid:15.274327094.140938@zammad.example.com"> some text',)
Store.add(
object: 'Ticket::Article',
o_id: article_parent.id,
data: 'content_file1_normally_should_be_an_image',
filename: 'some_file1.jpg',
preferences: {
'Content-Type' => 'image/jpeg',
'Mime-Type' => 'image/jpeg',
'Content-ID' => '15.274327094.140938@zammad.example.com',
'Content-Disposition' => 'inline',
},
created_by_id: 1,
)
Store.add(
object: 'Ticket::Article',
o_id: article_parent.id,
data: 'content_file2_normally_should_be_an_image',
filename: 'some_file2.jpg',
preferences: {
'Content-Type' => 'image/jpeg',
'Mime-Type' => 'image/jpeg',
'Content-ID' => '15.274327094.140938_not_reffered@zammad.example.com',
'Content-Disposition' => 'inline',
},
created_by_id: 1,
)
Store.add(
object: 'Ticket::Article',
o_id: article_parent.id,
data: 'content_file3_normally_should_be_an_image',
filename: 'some_file3.jpg',
preferences: {
'Content-Type' => 'image/jpeg',
'Mime-Type' => 'image/jpeg',
'Content-Disposition' => 'attached',
},
created_by_id: 1,
)
article_new = create(:ticket_article)
UserInfo.current_user_id = 1
attachments = article_parent.clone_attachments(article_new.class.name, article_new.id, only_attached_attachments: true)
expect(attachments.count).to eq(2)
expect(attachments[0].filename).to eq('some_file2.jpg')
expect(attachments[1].filename).to eq('some_file3.jpg')
end
end
end
context 'of trigger' do
context 'via email notifications' do
it 'only need to clone inline attachments used in body' do
article_parent = create(:ticket_article,
type: Ticket::Article::Type.find_by(name: 'email'),
content_type: 'text/html',
body: '<img src="cid:15.274327094.140938@zammad.example.com"> some text',)
Store.add(
object: 'Ticket::Article',
o_id: article_parent.id,
data: 'content_file1_normally_should_be_an_image',
filename: 'some_file1.jpg',
preferences: {
'Content-Type' => 'image/jpeg',
'Mime-Type' => 'image/jpeg',
'Content-ID' => '15.274327094.140938@zammad.example.com',
'Content-Disposition' => 'inline',
},
created_by_id: 1,
)
Store.add(
object: 'Ticket::Article',
o_id: article_parent.id,
data: 'content_file2_normally_should_be_an_image',
filename: 'some_file2.jpg',
preferences: {
'Content-Type' => 'image/jpeg',
'Mime-Type' => 'image/jpeg',
'Content-ID' => '15.274327094.140938_not_reffered@zammad.example.com',
'Content-Disposition' => 'inline',
},
created_by_id: 1,
)
# #2483 - #{article.body_as_html} now includes attachments (e.g. PDFs)
# Regular attachments do not get assigned a Content-ID, and should not be copied in this use case
Store.add(
object: 'Ticket::Article',
o_id: article_parent.id,
data: 'content_file3_with_no_content_id',
filename: 'some_file3.jpg',
preferences: {
'Content-Type' => 'image/jpeg',
'Mime-Type' => 'image/jpeg',
},
created_by_id: 1,
)
article_new = create(:ticket_article)
UserInfo.current_user_id = 1
attachments = article_parent.clone_attachments(article_new.class.name, article_new.id, only_inline_attachments: true )
expect(attachments.count).to eq(1)
expect(attachments[0].filename).to eq('some_file1.jpg')
end
end
end
end
2018-12-13 09:06:44 +00:00
end