article_spec.rb 23 KB

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