article_spec.rb 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594
  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. it_behaves_like 'ApplicationModel'
  9. it_behaves_like 'CanBeImported'
  10. it_behaves_like 'CanCsvImport'
  11. it_behaves_like 'HasHistory'
  12. it_behaves_like 'HasObjectManagerAttributesValidation'
  13. subject(:article) { create(:ticket_article) }
  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 "test.postmaster" thread', application_handle: 'test.postmaster' do
  230. it 'truncates body to 1.5 million chars' do
  231. expect(article.body.length).to eq(1_500_000)
  232. end
  233. context 'with NULL bytes' do
  234. let(:body) { "\u0000" + 'a' * 2_000_000 }
  235. it 'still removes them, if necessary (postgres doesn’t like them)' do
  236. expect(article).to be_persisted
  237. end
  238. it 'still truncates body' do
  239. expect(article.body.length).to eq(1_500_000)
  240. end
  241. end
  242. end
  243. end
  244. end
  245. describe 'Cti::Log syncing:' do
  246. context 'with existing Log records' do
  247. context 'for an incoming call from an unknown number' do
  248. let!(:log) { create(:'cti/log', :with_preferences, from: '491111222222', direction: 'in') }
  249. context 'with that number in #body' do
  250. subject(:article) { build(:ticket_article, body: <<~BODY) }
  251. some message
  252. +49 1111 222222
  253. BODY
  254. it 'does not modify any Log records (because CallerIds from article bodies have #level "maybe")' do
  255. expect do
  256. article.save
  257. Observer::Transaction.commit
  258. Scheduler.worker(true)
  259. end.not_to change { log.reload.attributes }
  260. end
  261. end
  262. end
  263. end
  264. end
  265. describe 'Auto-setting of outgoing Twitter article attributes (via bg jobs):', use_vcr: :with_oauth_headers do
  266. subject!(:twitter_article) { create(:twitter_article, sender_name: 'Agent') }
  267. let(:channel) { Channel.find(twitter_article.ticket.preferences[:channel_id]) }
  268. let(:run_bg_jobs) { -> { Scheduler.worker(true) } }
  269. it 'sets #from to sender’s Twitter handle' do
  270. expect(&run_bg_jobs)
  271. .to change { twitter_article.reload.from }
  272. .to('@example')
  273. end
  274. it 'sets #to to recipient’s Twitter handle' do
  275. expect(&run_bg_jobs)
  276. .to change { twitter_article.reload.to }
  277. .to('') # Tweet in VCR cassette is addressed to no one
  278. end
  279. it 'sets #message_id to tweet ID (https://twitter.com/_/status/<id>)' do
  280. expect(&run_bg_jobs)
  281. .to change { twitter_article.reload.message_id }
  282. .to('1069382411899817990')
  283. end
  284. it 'sets #preferences with tweet metadata' do
  285. expect(&run_bg_jobs)
  286. .to change { twitter_article.reload.preferences }
  287. .to(hash_including('twitter', 'links'))
  288. expect(twitter_article.preferences[:links].first)
  289. .to include(
  290. 'name' => 'on Twitter',
  291. 'target' => '_blank',
  292. 'url' => "https://twitter.com/_/status/#{twitter_article.message_id}"
  293. )
  294. end
  295. it 'does not change #cc' do
  296. expect(&run_bg_jobs).not_to change { twitter_article.reload.cc }
  297. end
  298. it 'does not change #subject' do
  299. expect(&run_bg_jobs).not_to change { twitter_article.reload.subject }
  300. end
  301. it 'does not change #content_type' do
  302. expect(&run_bg_jobs).not_to change { twitter_article.reload.content_type }
  303. end
  304. it 'does not change #body' do
  305. expect(&run_bg_jobs).not_to change { twitter_article.reload.body }
  306. end
  307. it 'does not change #sender' do
  308. expect(&run_bg_jobs).not_to change { twitter_article.reload.sender }
  309. end
  310. it 'does not change #type' do
  311. expect(&run_bg_jobs).not_to change { twitter_article.reload.type }
  312. end
  313. it 'sets appropriate status attributes on the ticket’s channel' do
  314. expect(&run_bg_jobs)
  315. .to change { channel.reload.attributes }
  316. .to hash_including(
  317. 'status_in' => nil,
  318. 'last_log_in' => nil,
  319. 'status_out' => 'ok',
  320. 'last_log_out' => ''
  321. )
  322. end
  323. context 'when the original channel (specified in ticket.preferences) was deleted' do
  324. context 'but a new one with the same screen_name exists' do
  325. let(:new_channel) { create(:twitter_channel) }
  326. before do
  327. channel.destroy
  328. expect(new_channel.options[:user][:screen_name])
  329. .to eq(channel.options[:user][:screen_name])
  330. end
  331. it 'sets appropriate status attributes on the new channel' do
  332. expect(&run_bg_jobs)
  333. .to change { new_channel.reload.attributes }
  334. .to hash_including(
  335. 'status_in' => nil,
  336. 'last_log_in' => nil,
  337. 'status_out' => 'ok',
  338. 'last_log_out' => ''
  339. )
  340. end
  341. end
  342. end
  343. end
  344. describe 'Sending of outgoing emails', performs_jobs: true do
  345. subject(:article) { create(:ticket_article, type_name: type, sender_name: sender) }
  346. shared_examples 'sends email' do
  347. it 'dispatches an email on creation (via TicketArticleCommunicateEmailJob)' do
  348. expect { article }
  349. .to have_enqueued_job(TicketArticleCommunicateEmailJob)
  350. end
  351. end
  352. shared_examples 'does not send email' do
  353. it 'does not dispatch an email' do
  354. expect { article }
  355. .not_to have_enqueued_job(TicketArticleCommunicateEmailJob)
  356. end
  357. end
  358. context 'with "application_server" application handle', application_handle: 'application_server' do
  359. context 'for type: "email"' do
  360. let(:type) { 'email' }
  361. context 'from sender: "Agent"' do
  362. let(:sender) { 'Agent' }
  363. include_examples 'sends email'
  364. end
  365. context 'from sender: "Customer"' do
  366. let(:sender) { 'Customer' }
  367. include_examples 'does not send email'
  368. end
  369. end
  370. context 'for any other type' do
  371. let(:type) { 'sms' }
  372. context 'from any sender' do
  373. let(:sender) { 'Agent' }
  374. include_examples 'does not send email'
  375. end
  376. end
  377. end
  378. context 'with "*.postmaster" application handle', application_handle: 'scheduler.postmaster' do
  379. context 'for any type' do
  380. let(:type) { 'email' }
  381. context 'from any sender' do
  382. let(:sender) { 'Agent' }
  383. include_examples 'does not send email'
  384. end
  385. end
  386. end
  387. end
  388. end
  389. describe 'clone attachments' do
  390. context 'of forwarded article' do
  391. context 'via email' do
  392. it 'only need to clone attached attachments' do
  393. article_parent = create(:ticket_article,
  394. type: Ticket::Article::Type.find_by(name: 'email'),
  395. content_type: 'text/html',
  396. body: '<img src="cid:15.274327094.140938@zammad.example.com"> some text',)
  397. Store.add(
  398. object: 'Ticket::Article',
  399. o_id: article_parent.id,
  400. data: 'content_file1_normally_should_be_an_image',
  401. filename: 'some_file1.jpg',
  402. preferences: {
  403. 'Content-Type' => 'image/jpeg',
  404. 'Mime-Type' => 'image/jpeg',
  405. 'Content-ID' => '15.274327094.140938@zammad.example.com',
  406. 'Content-Disposition' => 'inline',
  407. },
  408. created_by_id: 1,
  409. )
  410. Store.add(
  411. object: 'Ticket::Article',
  412. o_id: article_parent.id,
  413. data: 'content_file2_normally_should_be_an_image',
  414. filename: 'some_file2.jpg',
  415. preferences: {
  416. 'Content-Type' => 'image/jpeg',
  417. 'Mime-Type' => 'image/jpeg',
  418. 'Content-ID' => '15.274327094.140938_not_reffered@zammad.example.com',
  419. 'Content-Disposition' => 'inline',
  420. },
  421. created_by_id: 1,
  422. )
  423. Store.add(
  424. object: 'Ticket::Article',
  425. o_id: article_parent.id,
  426. data: 'content_file3_normally_should_be_an_image',
  427. filename: 'some_file3.jpg',
  428. preferences: {
  429. 'Content-Type' => 'image/jpeg',
  430. 'Mime-Type' => 'image/jpeg',
  431. 'Content-Disposition' => 'attached',
  432. },
  433. created_by_id: 1,
  434. )
  435. article_new = create(:ticket_article)
  436. UserInfo.current_user_id = 1
  437. attachments = article_parent.clone_attachments(article_new.class.name, article_new.id, only_attached_attachments: true)
  438. expect(attachments.count).to eq(2)
  439. expect(attachments[0].filename).to eq('some_file2.jpg')
  440. expect(attachments[1].filename).to eq('some_file3.jpg')
  441. end
  442. end
  443. end
  444. context 'of trigger' do
  445. context 'via email notifications' do
  446. it 'only need to clone inline attachments used in body' do
  447. article_parent = create(:ticket_article,
  448. type: Ticket::Article::Type.find_by(name: 'email'),
  449. content_type: 'text/html',
  450. body: '<img src="cid:15.274327094.140938@zammad.example.com"> some text',)
  451. Store.add(
  452. object: 'Ticket::Article',
  453. o_id: article_parent.id,
  454. data: 'content_file1_normally_should_be_an_image',
  455. filename: 'some_file1.jpg',
  456. preferences: {
  457. 'Content-Type' => 'image/jpeg',
  458. 'Mime-Type' => 'image/jpeg',
  459. 'Content-ID' => '15.274327094.140938@zammad.example.com',
  460. 'Content-Disposition' => 'inline',
  461. },
  462. created_by_id: 1,
  463. )
  464. Store.add(
  465. object: 'Ticket::Article',
  466. o_id: article_parent.id,
  467. data: 'content_file2_normally_should_be_an_image',
  468. filename: 'some_file2.jpg',
  469. preferences: {
  470. 'Content-Type' => 'image/jpeg',
  471. 'Mime-Type' => 'image/jpeg',
  472. 'Content-ID' => '15.274327094.140938_not_reffered@zammad.example.com',
  473. 'Content-Disposition' => 'inline',
  474. },
  475. created_by_id: 1,
  476. )
  477. # #2483 - #{article.body_as_html} now includes attachments (e.g. PDFs)
  478. # Regular attachments do not get assigned a Content-ID, and should not be copied in this use case
  479. Store.add(
  480. object: 'Ticket::Article',
  481. o_id: article_parent.id,
  482. data: 'content_file3_with_no_content_id',
  483. filename: 'some_file3.jpg',
  484. preferences: {
  485. 'Content-Type' => 'image/jpeg',
  486. 'Mime-Type' => 'image/jpeg',
  487. },
  488. created_by_id: 1,
  489. )
  490. article_new = create(:ticket_article)
  491. UserInfo.current_user_id = 1
  492. attachments = article_parent.clone_attachments(article_new.class.name, article_new.id, only_inline_attachments: true )
  493. expect(attachments.count).to eq(1)
  494. expect(attachments[0].filename).to eq('some_file1.jpg')
  495. end
  496. end
  497. end
  498. end
  499. end