article_spec.rb 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626
  1. # Copyright (C) 2012-2021 Zammad Foundation, http://zammad-foundation.org/
  2. require 'rails_helper'
  3. require 'models/application_model_examples'
  4. require 'models/concerns/can_be_imported_examples'
  5. require 'models/concerns/can_csv_import_examples'
  6. require 'models/concerns/has_history_examples'
  7. require 'models/concerns/has_object_manager_attributes_validation_examples'
  8. require 'models/ticket/article/has_ticket_contact_attributes_impact_examples'
  9. RSpec.describe Ticket::Article, type: :model do
  10. subject(:article) { create(:ticket_article) }
  11. it_behaves_like 'ApplicationModel'
  12. it_behaves_like 'CanBeImported'
  13. it_behaves_like 'CanCsvImport'
  14. it_behaves_like 'HasHistory'
  15. it_behaves_like 'HasObjectManagerAttributesValidation'
  16. it_behaves_like 'Ticket::Article::HasTicketContactAttributesImpact'
  17. describe 'Callbacks, Observers, & Async Transactions -' do
  18. describe 'NULL byte handling (via ChecksAttributeValuesAndLength concern):' do
  19. it 'removes them from #subject on creation, if necessary (postgres doesn’t like them)' do
  20. expect(create(:ticket_article, subject: "com test 1\u0000"))
  21. .to be_persisted
  22. end
  23. it 'removes them from #body on creation, if necessary (postgres doesn’t like them)' do
  24. expect(create(:ticket_article, body: "some\u0000message 123"))
  25. .to be_persisted
  26. end
  27. end
  28. describe 'Setting of ticket_define_email_from' do
  29. subject(:article) do
  30. create(:ticket_article, sender_name: 'Agent', type_name: 'email')
  31. end
  32. context 'when AgentName' do
  33. before do
  34. Setting.set('ticket_define_email_from', 'AgentName')
  35. end
  36. it 'sets the from based on the setting' do
  37. expect(article.reload.from).to eq("\"#{article.created_by.firstname} #{article.created_by.lastname}\" <#{article.ticket.group.email_address.email}>")
  38. end
  39. end
  40. end
  41. describe 'Setting of ticket.create_article_{sender,type}' do
  42. let!(:ticket) { create(:ticket) }
  43. context 'on creation' do
  44. context 'of first article on a ticket' do
  45. subject(:article) do
  46. create(:ticket_article, ticket: ticket, sender_name: 'Agent', type_name: 'email')
  47. end
  48. it 'sets ticket sender/type attributes based on article sender/type' do
  49. expect { article }
  50. .to change { ticket.reload.create_article_sender&.name }.to('Agent')
  51. .and change { ticket.reload.create_article_type&.name }.to('email')
  52. end
  53. end
  54. context 'of subsequent articles on a ticket' do
  55. subject(:article) do
  56. create(:ticket_article, ticket: ticket, sender_name: 'Customer', type_name: 'twitter status')
  57. end
  58. let!(:first_article) do
  59. create(:ticket_article, ticket: ticket, sender_name: 'Agent', type_name: 'email')
  60. end
  61. it 'does not modify ticket’s sender/type attributes' do
  62. expect { article }
  63. .to not_change { ticket.reload.create_article_sender.name }
  64. .and not_change { ticket.reload.create_article_type.name }
  65. end
  66. end
  67. end
  68. end
  69. describe 'XSS protection:' do
  70. subject(:article) { create(:ticket_article, body: body, content_type: 'text/html') }
  71. context 'when body contains only injected JS' do
  72. let(:body) { <<~RAW.chomp }
  73. <script type="text/javascript">alert("XSS!");</script>
  74. RAW
  75. it 'removes <script> tags' do
  76. expect(article.body).to eq('alert("XSS!");')
  77. end
  78. end
  79. context 'when body contains injected JS amidst other text' do
  80. let(:body) { <<~RAW.chomp }
  81. please tell me this doesn't work: <script type="text/javascript">alert("XSS!");</script>
  82. RAW
  83. it 'removes <script> tags' do
  84. expect(article.body).to eq(<<~SANITIZED.chomp)
  85. please tell me this doesn't work: alert("XSS!");
  86. SANITIZED
  87. end
  88. end
  89. context 'when body contains invalid HTML tags' do
  90. let(:body) { '<some_not_existing>ABC</some_not_existing>' }
  91. it 'removes invalid tags' do
  92. expect(article.body).to eq('ABC')
  93. end
  94. end
  95. context 'when body contains restricted HTML attributes' do
  96. let(:body) { '<div class="adasd" id="123" data-abc="123"></div>' }
  97. it 'removes restricted attributes' do
  98. expect(article.body).to eq('<div></div>')
  99. end
  100. end
  101. context 'when body contains JS injected into href attribute' do
  102. let(:body) { '<a href="javascript:someFunction()">LINK</a>' }
  103. it 'removes <a> tags' do
  104. expect(article.body).to eq('LINK')
  105. end
  106. end
  107. context 'when body contains an unclosed <div> element' do
  108. let(:body) { '<div>foo' }
  109. it 'closes it' do
  110. expect(article.body).to eq('<div>foo</div>')
  111. end
  112. end
  113. context 'when body contains a plain link (<a> element)' do
  114. let(:body) { '<a href="https://example.com">foo</a>' }
  115. it 'adds sanitization attributes' do
  116. expect(article.body).to eq(<<~SANITIZED.chomp)
  117. <a href="https://example.com" rel="nofollow noreferrer noopener" target="_blank" title="https://example.com">foo</a>
  118. SANITIZED
  119. end
  120. context 'when a sanitization attribute is present' do
  121. # ATTENTION: We use `target` here because re-sanitization would change the order of attributes
  122. let(:body) { '<a href="https://example.com" target="_blank">foo</a>' }
  123. it 'adds sanitization attributes' do
  124. expect(article.body).to eq(<<~SANITIZED.chomp)
  125. <a href="https://example.com" rel="nofollow noreferrer noopener" target="_blank" title="https://example.com">foo</a>
  126. SANITIZED
  127. end
  128. context 'when changing an unrelated attribute' do
  129. it "doesn't re-sanitizes the body" do
  130. expect { article.update!(message_id: 'test') }.not_to change { article.reload.body }
  131. end
  132. end
  133. end
  134. end
  135. context 'for all cases above, combined' do
  136. let(:body) { <<~RAW.chomp }
  137. please tell me this doesn't work: <table>ada<tr></tr></table>
  138. <div class="adasd" id="123" data-abc="123"></div>
  139. <div>
  140. <a href="javascript:someFunction()">LINK</a>
  141. <a href="http://lalal.de">aa</a>
  142. <some_not_existing>ABC</some_not_existing>
  143. RAW
  144. it 'performs all sanitizations' do
  145. expect(article.body).to eq(<<~SANITIZED.chomp)
  146. please tell me this doesn't work: <table>ada<tr></tr>
  147. </table>
  148. <div></div>
  149. <div>
  150. LINK
  151. <a href="http://lalal.de" rel="nofollow noreferrer noopener" target="_blank" title="http://lalal.de">aa</a>
  152. ABC
  153. </div>
  154. SANITIZED
  155. end
  156. end
  157. context 'for content_type: "text/plain"' do
  158. subject(:article) { create(:ticket_article, body: body, content_type: 'text/plain') }
  159. let(:body) { <<~RAW.chomp }
  160. please tell me this doesn't work: <table>ada<tr></tr></table>
  161. <div class="adasd" id="123" data-abc="123"></div>
  162. <div>
  163. <a href="javascript:someFunction()">LINK</a>
  164. <a href="http://lalal.de">aa</a>
  165. <some_not_existing>ABC</some_not_existing>
  166. RAW
  167. it 'performs no sanitizations' do
  168. expect(article.body).to eq(<<~SANITIZED.chomp)
  169. please tell me this doesn't work: <table>ada<tr></tr></table>
  170. <div class="adasd" id="123" data-abc="123"></div>
  171. <div>
  172. <a href="javascript:someFunction()">LINK</a>
  173. <a href="http://lalal.de">aa</a>
  174. <some_not_existing>ABC</some_not_existing>
  175. SANITIZED
  176. end
  177. end
  178. context 'when body contains <video> element' do
  179. let(:body) { <<~RAW.chomp }
  180. please tell me this doesn't work: <video>some video</video><foo>alal</foo>
  181. RAW
  182. it 'leaves it as-is' do
  183. expect(article.body).to eq(<<~SANITIZED.chomp)
  184. please tell me this doesn't work: <video>some video</video>alal
  185. SANITIZED
  186. end
  187. end
  188. context 'when body contains CSS in style attribute' do
  189. context 'for cid-style email attachment' do
  190. let(:body) { <<~RAW.chomp }
  191. <img style="width: 85.5px; height: 49.5px" src="cid:15.274327094.140938@zammad.example.com">
  192. asdasd
  193. <img src="cid:15.274327094.140939@zammad.example.com">
  194. RAW
  195. it 'adds terminal semicolons to style rules' do
  196. expect(article.body).to eq(<<~SANITIZED.chomp)
  197. <img style="width: 85.5px; height: 49.5px;" src="cid:15.274327094.140938@zammad.example.com">
  198. asdasd
  199. <img src="cid:15.274327094.140939@zammad.example.com">
  200. SANITIZED
  201. end
  202. end
  203. context 'for relative-path-style email attachment' do
  204. let(:body) { <<~RAW.chomp }
  205. <img style="width: 85.5px; height: 49.5px" src="api/v1/ticket_attachment/123/123/123">
  206. asdasd
  207. <img src="api/v1/ticket_attachment/123/123/123">
  208. RAW
  209. it 'adds terminal semicolons to style rules' do
  210. expect(article.body).to eq(<<~SANITIZED.chomp)
  211. <img style="width: 85.5px; height: 49.5px;" src="api/v1/ticket_attachment/123/123/123">
  212. asdasd
  213. <img src="api/v1/ticket_attachment/123/123/123">
  214. SANITIZED
  215. end
  216. end
  217. end
  218. context 'when body contains <body> elements' do
  219. let(:body) { '<body>123</body>' }
  220. it 'removes <body> tags' do
  221. expect(article.body).to eq('123')
  222. end
  223. end
  224. context 'when body contains onclick attributes in <a> elements' do
  225. let(:body) { <<~RAW.chomp }
  226. <a href="#" onclick="some_function();">abc</a>
  227. <a href="https://example.com" oNclIck="some_function();">123</a>
  228. RAW
  229. it 'removes onclick attributes' do
  230. expect(article.body).to eq(<<~SANITIZED.chomp)
  231. <a href="#">abc</a>
  232. <a href="https://example.com" rel="nofollow noreferrer noopener" target="_blank" title="https://example.com">123</a>
  233. SANITIZED
  234. end
  235. end
  236. end
  237. describe 'DoS protection:' do
  238. context 'when #body exceeds 1.5MB' do
  239. subject(:article) { create(:ticket_article, body: body) }
  240. let(:body) { 'a' * 2_000_000 }
  241. context 'for "web" thread', application_handle: 'web' do
  242. it 'raises an Unprocessable Entity error' do
  243. expect { article }.to raise_error(Exceptions::UnprocessableEntity)
  244. end
  245. end
  246. context 'for import' do
  247. before do
  248. Setting.set('import_mode', true)
  249. end
  250. it 'truncates body to 1.5 million chars' do
  251. expect(article.body.length).to eq(1_500_000)
  252. end
  253. end
  254. context 'for "test.postmaster" thread', application_handle: 'test.postmaster' do
  255. it 'truncates body to 1.5 million chars' do
  256. expect(article.body.length).to eq(1_500_000)
  257. end
  258. context 'with NULL bytes' do
  259. let(:body) { "\u0000#{'a' * 2_000_000}" }
  260. it 'still removes them, if necessary (postgres doesn’t like them)' do
  261. expect(article).to be_persisted
  262. end
  263. it 'still truncates body' do
  264. expect(article.body.length).to eq(1_500_000)
  265. end
  266. end
  267. end
  268. end
  269. end
  270. describe 'Cti::Log syncing:' do
  271. context 'with existing Log records' do
  272. context 'for an incoming call from an unknown number' do
  273. let!(:log) { create(:'cti/log', :with_preferences, from: '491111222222', direction: 'in') }
  274. context 'with that number in #body' do
  275. subject(:article) { build(:ticket_article, body: <<~BODY) }
  276. some message
  277. +49 1111 222222
  278. BODY
  279. it 'does not modify any Log records (because CallerIds from article bodies have #level "maybe")' do
  280. expect do
  281. article.save
  282. TransactionDispatcher.commit
  283. Scheduler.worker(true)
  284. end.not_to change { log.reload.attributes }
  285. end
  286. end
  287. end
  288. end
  289. end
  290. describe 'Auto-setting of outgoing Twitter article attributes (via bg jobs):', use_vcr: :with_oauth_headers, required_envs: %w[TWITTER_CONSUMER_KEY TWITTER_CONSUMER_SECRET TWITTER_OAUTH_TOKEN TWITTER_OAUTH_TOKEN_SECRET] do
  291. subject!(:twitter_article) { create(:twitter_article, sender_name: 'Agent') }
  292. let(:channel) { Channel.find(twitter_article.ticket.preferences[:channel_id]) }
  293. let(:run_bg_jobs) { -> { Scheduler.worker(true) } }
  294. it 'sets #from to sender’s Twitter handle' do
  295. expect(&run_bg_jobs)
  296. .to change { twitter_article.reload.from }
  297. .to('@ZammadTesting')
  298. end
  299. it 'sets #to to recipient’s Twitter handle' do
  300. expect(&run_bg_jobs)
  301. .to change { twitter_article.reload.to }
  302. .to('') # Tweet in VCR cassette is addressed to no one
  303. end
  304. it 'sets #message_id to tweet ID (https://twitter.com/_/status/<id>)' do
  305. expect(&run_bg_jobs)
  306. .to change { twitter_article.reload.message_id }
  307. .to('1410130368498372609')
  308. end
  309. it 'sets #preferences with tweet metadata' do
  310. expect(&run_bg_jobs)
  311. .to change { twitter_article.reload.preferences }
  312. .to(hash_including('twitter', 'links'))
  313. expect(twitter_article.preferences[:links].first)
  314. .to include(
  315. 'name' => 'on Twitter',
  316. 'target' => '_blank',
  317. 'url' => "https://twitter.com/_/status/#{twitter_article.message_id}"
  318. )
  319. end
  320. it 'does not change #cc' do
  321. expect(&run_bg_jobs).not_to change { twitter_article.reload.cc }
  322. end
  323. it 'does not change #subject' do
  324. expect(&run_bg_jobs).not_to change { twitter_article.reload.subject }
  325. end
  326. it 'does not change #content_type' do
  327. expect(&run_bg_jobs).not_to change { twitter_article.reload.content_type }
  328. end
  329. it 'does not change #body' do
  330. expect(&run_bg_jobs).not_to change { twitter_article.reload.body }
  331. end
  332. it 'does not change #sender' do
  333. expect(&run_bg_jobs).not_to change { twitter_article.reload.sender }
  334. end
  335. it 'does not change #type' do
  336. expect(&run_bg_jobs).not_to change { twitter_article.reload.type }
  337. end
  338. it 'sets appropriate status attributes on the ticket’s channel' do
  339. expect(&run_bg_jobs)
  340. .to change { channel.reload.attributes }
  341. .to hash_including(
  342. 'status_in' => nil,
  343. 'last_log_in' => nil,
  344. 'status_out' => 'ok',
  345. 'last_log_out' => ''
  346. )
  347. end
  348. context 'when the original channel (specified in ticket.preferences) was deleted' do
  349. context 'but a new one with the same screen_name exists' do
  350. let(:new_channel) { create(:twitter_channel) }
  351. before do
  352. channel.destroy
  353. expect(new_channel.options[:user][:screen_name])
  354. .to eq(channel.options[:user][:screen_name])
  355. end
  356. it 'sets appropriate status attributes on the new channel' do
  357. expect(&run_bg_jobs)
  358. .to change { new_channel.reload.attributes }
  359. .to hash_including(
  360. 'status_in' => nil,
  361. 'last_log_in' => nil,
  362. 'status_out' => 'ok',
  363. 'last_log_out' => ''
  364. )
  365. end
  366. end
  367. end
  368. end
  369. describe 'Sending of outgoing emails', performs_jobs: true do
  370. subject(:article) { create(:ticket_article, type_name: type, sender_name: sender) }
  371. shared_examples 'sends email' do
  372. it 'dispatches an email on creation (via TicketArticleCommunicateEmailJob)' do
  373. expect { article }
  374. .to have_enqueued_job(TicketArticleCommunicateEmailJob)
  375. end
  376. end
  377. shared_examples 'does not send email' do
  378. it 'does not dispatch an email' do
  379. expect { article }
  380. .not_to have_enqueued_job(TicketArticleCommunicateEmailJob)
  381. end
  382. end
  383. context 'with "application_server" application handle', application_handle: 'application_server' do
  384. context 'for type: "email"' do
  385. let(:type) { 'email' }
  386. context 'from sender: "Agent"' do
  387. let(:sender) { 'Agent' }
  388. include_examples 'sends email'
  389. end
  390. context 'from sender: "Customer"' do
  391. let(:sender) { 'Customer' }
  392. include_examples 'does not send email'
  393. end
  394. end
  395. context 'for any other type' do
  396. let(:type) { 'sms' }
  397. context 'from any sender' do
  398. let(:sender) { 'Agent' }
  399. include_examples 'does not send email'
  400. end
  401. end
  402. end
  403. context 'with "*.postmaster" application handle', application_handle: 'scheduler.postmaster' do
  404. context 'for any type' do
  405. let(:type) { 'email' }
  406. context 'from any sender' do
  407. let(:sender) { 'Agent' }
  408. include_examples 'does not send email'
  409. end
  410. end
  411. end
  412. end
  413. end
  414. describe 'clone attachments' do
  415. context 'of forwarded article' do
  416. context 'via email' do
  417. it 'only need to clone attached attachments' do
  418. article_parent = create(:ticket_article,
  419. type: Ticket::Article::Type.find_by(name: 'email'),
  420. content_type: 'text/html',
  421. body: '<img src="cid:15.274327094.140938@zammad.example.com"> some text',)
  422. Store.add(
  423. object: 'Ticket::Article',
  424. o_id: article_parent.id,
  425. data: 'content_file1_normally_should_be_an_image',
  426. filename: 'some_file1.jpg',
  427. preferences: {
  428. 'Content-Type' => 'image/jpeg',
  429. 'Mime-Type' => 'image/jpeg',
  430. 'Content-ID' => '15.274327094.140938@zammad.example.com',
  431. 'Content-Disposition' => 'inline',
  432. },
  433. created_by_id: 1,
  434. )
  435. Store.add(
  436. object: 'Ticket::Article',
  437. o_id: article_parent.id,
  438. data: 'content_file2_normally_should_be_an_image',
  439. filename: 'some_file2.jpg',
  440. preferences: {
  441. 'Content-Type' => 'image/jpeg',
  442. 'Mime-Type' => 'image/jpeg',
  443. 'Content-ID' => '15.274327094.140938_not_reffered@zammad.example.com',
  444. 'Content-Disposition' => 'inline',
  445. },
  446. created_by_id: 1,
  447. )
  448. Store.add(
  449. object: 'Ticket::Article',
  450. o_id: article_parent.id,
  451. data: 'content_file3_normally_should_be_an_image',
  452. filename: 'some_file3.jpg',
  453. preferences: {
  454. 'Content-Type' => 'image/jpeg',
  455. 'Mime-Type' => 'image/jpeg',
  456. 'Content-Disposition' => 'attached',
  457. },
  458. created_by_id: 1,
  459. )
  460. article_new = create(:ticket_article)
  461. UserInfo.current_user_id = 1
  462. attachments = article_parent.clone_attachments(article_new.class.name, article_new.id, only_attached_attachments: true)
  463. expect(attachments.count).to eq(2)
  464. expect(attachments[0].filename).to eq('some_file2.jpg')
  465. expect(attachments[1].filename).to eq('some_file3.jpg')
  466. end
  467. end
  468. end
  469. context 'of trigger' do
  470. context 'via email notifications' do
  471. it 'only need to clone inline attachments used in body' do
  472. article_parent = create(:ticket_article,
  473. type: Ticket::Article::Type.find_by(name: 'email'),
  474. content_type: 'text/html',
  475. body: '<img src="cid:15.274327094.140938@zammad.example.com"> some text',)
  476. Store.add(
  477. object: 'Ticket::Article',
  478. o_id: article_parent.id,
  479. data: 'content_file1_normally_should_be_an_image',
  480. filename: 'some_file1.jpg',
  481. preferences: {
  482. 'Content-Type' => 'image/jpeg',
  483. 'Mime-Type' => 'image/jpeg',
  484. 'Content-ID' => '15.274327094.140938@zammad.example.com',
  485. 'Content-Disposition' => 'inline',
  486. },
  487. created_by_id: 1,
  488. )
  489. Store.add(
  490. object: 'Ticket::Article',
  491. o_id: article_parent.id,
  492. data: 'content_file2_normally_should_be_an_image',
  493. filename: 'some_file2.jpg',
  494. preferences: {
  495. 'Content-Type' => 'image/jpeg',
  496. 'Mime-Type' => 'image/jpeg',
  497. 'Content-ID' => '15.274327094.140938_not_reffered@zammad.example.com',
  498. 'Content-Disposition' => 'inline',
  499. },
  500. created_by_id: 1,
  501. )
  502. # #2483 - #{article.body_as_html} now includes attachments (e.g. PDFs)
  503. # Regular attachments do not get assigned a Content-ID, and should not be copied in this use case
  504. Store.add(
  505. object: 'Ticket::Article',
  506. o_id: article_parent.id,
  507. data: 'content_file3_with_no_content_id',
  508. filename: 'some_file3.jpg',
  509. preferences: {
  510. 'Content-Type' => 'image/jpeg',
  511. 'Mime-Type' => 'image/jpeg',
  512. },
  513. created_by_id: 1,
  514. )
  515. article_new = create(:ticket_article)
  516. UserInfo.current_user_id = 1
  517. attachments = article_parent.clone_attachments(article_new.class.name, article_new.id, only_inline_attachments: true )
  518. expect(attachments.count).to eq(1)
  519. expect(attachments[0].filename).to eq('some_file1.jpg')
  520. end
  521. end
  522. end
  523. end
  524. end