article_spec.rb 20 KB

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