article_spec.rb 21 KB

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