article_spec.rb 22 KB

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