emailTemplateTest.js 9.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254
  1. 'use strict';
  2. require('../common');
  3. // DOM builder that mirrors bootstrap5.php navbar
  4. function buildEmailDomNoShortUrl() {
  5. document.documentElement.innerHTML =
  6. // TopNav expects initially hidden #emaillink BUTTON.
  7. '<nav><div id="navbar"><ul>' +
  8. '<li>' +
  9. '<button id="clonebutton" type="button" class="btn btn-warning navbar-btn">' +
  10. '<span class="glyphicon glyphicon-duplicate" aria-hidden="true"></span> Clone' +
  11. '</button>' +
  12. '</li>' +
  13. '<li>' +
  14. '<button id="emaillink" type="button" class="hidden btn btn-secondary">Email</button>' +
  15. '</li>' +
  16. '</ul></div></nav>' +
  17. '<input id="burnafterreadingoption" type="checkbox">' +
  18. // include dummy email confirm modal for sendEmail
  19. '<div id="emailconfirmmodal" class="hidden">' +
  20. '<div id="emailconfirm-timezone-current"></div>' +
  21. '<div id="emailconfirm-timezone-utc"></div>' +
  22. '</div>'
  23. }
  24. // DOM builder that adds the shortener result block
  25. function buildEmailDomWithShortUrl() {
  26. buildEmailDomNoShortUrl();
  27. document.documentElement.innerHTML =
  28. // TopNav expectsinitially hidden #emaillink BUTTON.
  29. '<nav><div id="navbar"><ul>' +
  30. '<li>' +
  31. '<button id="clonebutton" type="button" class="btn btn-warning navbar-btn">' +
  32. '<span class="glyphicon glyphicon-duplicate" aria-hidden="true"></span> Clone' +
  33. '</button>' +
  34. '</li>' +
  35. '<li>' +
  36. '<button id="emaillink" type="button" class="hidden btn btn-secondary">Email</button>' +
  37. '</li>' +
  38. '</ul></div></nav>' +
  39. '<input id="burnafterreadingoption" type="checkbox">' +
  40. '<div id="pastelink">Your document is ' +
  41. '<a id="pasteurl" href="https://short.example/xYz">https://short.example/xYz</a> ' +
  42. '<span id="copyhint">(Hit <kbd>Ctrl</kbd>+<kbd>c</kbd> to copy)</span>' +
  43. '</div>' +
  44. // add a minimal email confirmation modal so sendEmail does not crash
  45. '<div id="emailconfirmmodal" class="hidden">' +
  46. '<div id="emailconfirm-timezone-current"></div>' +
  47. '<div id="emailconfirm-timezone-utc"></div>' +
  48. '</div>'
  49. }
  50. function makeWindowOpenMock() {
  51. const originalOpen = window.open;
  52. let openedUrl = null;
  53. let mockRestoreFn = null;
  54. if (typeof jest !== 'undefined' && typeof jest.spyOn === 'function') {
  55. const spy = jest.spyOn(window, 'open').mockImplementation((url) => {
  56. openedUrl = url;
  57. return {};
  58. });
  59. mockRestoreFn = () => spy.mockRestore();
  60. } else {
  61. window.open = function (url) {
  62. openedUrl = url;
  63. return {};
  64. };
  65. mockRestoreFn = () => { window.open = originalOpen; };
  66. }
  67. return {
  68. getUrl: () => openedUrl,
  69. restore: () => {
  70. if (mockRestoreFn) {
  71. mockRestoreFn();
  72. }
  73. }
  74. };
  75. }
  76. // Extract and decode the body from a "mailto:?subject=...&body=..." URL.
  77. function extractMailtoBody(mailtoUrl) {
  78. assert.match(mailtoUrl, /^mailto:\?/, 'expected a mailto: URL');
  79. const match = mailtoUrl.match(/[?&]body=([^&]*)/);
  80. assert.ok(match, 'expected the mailto: URL to have a body= parameter');
  81. return decodeURIComponent(match[1]);
  82. }
  83. // Extract and decode the subject from a "mailto:?subject=...&body=..." URL.
  84. function extractMailtoSubject(mailtoUrl) {
  85. assert.match(mailtoUrl, /^mailto:\?/, 'expected a mailto: URL');
  86. const match = mailtoUrl.match(/[?&]subject=([^&]*)/);
  87. assert.ok(match, 'expected the mailto: URL to have a subject= parameter');
  88. return decodeURIComponent(match[1]);
  89. }
  90. describe('Email - mail body content (short URL vs. fallback)', function () {
  91. beforeEach(function () {
  92. cleanup(); // provided by common
  93. });
  94. it('Uses the short URL when #pasteurl is present and never includes "undefined"', function () {
  95. buildEmailDomWithShortUrl(); // with #pastelink/#pasteurl
  96. PrivateBin.TopNav.init();
  97. PrivateBin.TopNav.showEmailButton(0);
  98. const emailBtn = document.getElementById('emaillink');
  99. assert.ok(!emailBtn.classList.contains('hidden'), '#emaillink should be visible after showEmailButton');
  100. const { getUrl, restore } = makeWindowOpenMock();
  101. try {
  102. emailBtn.click();
  103. document.getElementById('emailconfirm-timezone-current').click();
  104. const openedUrl = getUrl();
  105. assert.ok(openedUrl, 'window.open should have been called');
  106. const body = extractMailtoBody(openedUrl);
  107. assert.match(body, /https:\/\/short\.example\/xYz/, 'email body should include the short URL');
  108. assert.doesNotMatch(body, /undefined/, 'email body must not contain "undefined"');
  109. } finally {
  110. restore();
  111. }
  112. });
  113. it('Falls back to window.location.href when #pasteurl is absent and never includes "undefined"', function () {
  114. buildEmailDomNoShortUrl(); // No #pasteurl
  115. PrivateBin.TopNav.init();
  116. PrivateBin.TopNav.showEmailButton(0);
  117. const emailBtn = document.getElementById('emaillink');
  118. assert.ok(!emailBtn.classList.contains('hidden'), '#emaillink should be visible after showEmailButton');
  119. const { getUrl, restore } = makeWindowOpenMock();
  120. try {
  121. emailBtn.click();
  122. document.getElementById('emailconfirm-timezone-current').click();
  123. const openedUrl = getUrl();
  124. assert.ok(openedUrl, 'window.open should have been called');
  125. const body = extractMailtoBody(openedUrl);
  126. assert.match(body, new RegExp(window.location.href), 'email body should include the fallback page URL');
  127. assert.doesNotMatch(body, /undefined/, 'email body must not contain "undefined"');
  128. } finally {
  129. restore();
  130. }
  131. });
  132. it('Uses the viewed paste burn-after-reading state instead of the instance default', function () {
  133. buildEmailDomNoShortUrl();
  134. const burnAfterReading = document.createElement('input');
  135. burnAfterReading.id = 'burnafterreading';
  136. burnAfterReading.type = 'checkbox';
  137. burnAfterReading.checked = true;
  138. document.body.appendChild(burnAfterReading);
  139. PrivateBin.TopNav.init();
  140. PrivateBin.TopNav.showEmailButton(0, false);
  141. const { getUrl, restore } = makeWindowOpenMock();
  142. try {
  143. document.getElementById('emaillink').click();
  144. document.getElementById('emailconfirm-timezone-current').click();
  145. const body = extractMailtoBody(getUrl());
  146. assert.doesNotMatch(
  147. body,
  148. /only be accessed once/,
  149. 'email body must use the viewed paste metadata'
  150. );
  151. } finally {
  152. restore();
  153. }
  154. });
  155. });
  156. describe('Email - mail subject', function () {
  157. beforeEach(function () {
  158. cleanup(); // provided by common
  159. });
  160. it('Includes a non-empty subject naming the instance, with no expiration confirmation step', function () {
  161. buildEmailDomNoShortUrl();
  162. // buildEmailDomNoShortUrl() replaces documentElement.innerHTML (dropping <title>),
  163. // so document.title must be (re-)set after calling it, not before.
  164. document.title = 'My PrivateBin Instance';
  165. PrivateBin.TopNav.init();
  166. PrivateBin.TopNav.showEmailButton(0);
  167. const { getUrl, restore } = makeWindowOpenMock();
  168. try {
  169. document.getElementById('emaillink').click();
  170. const openedUrl = getUrl();
  171. assert.ok(openedUrl, 'window.open should have been called');
  172. const subject = extractMailtoSubject(openedUrl);
  173. assert.match(subject, /My PrivateBin Instance/, 'subject should name the instance');
  174. } finally {
  175. restore();
  176. }
  177. });
  178. it('Includes the same subject after the expiration confirmation step', function () {
  179. buildEmailDomWithShortUrl();
  180. document.title = 'My PrivateBin Instance';
  181. PrivateBin.TopNav.init();
  182. // a non-zero remaining time routes through the timezone confirmation modal
  183. PrivateBin.TopNav.showEmailButton(3600);
  184. const { getUrl, restore } = makeWindowOpenMock();
  185. try {
  186. document.getElementById('emaillink').click();
  187. document.getElementById('emailconfirm-timezone-current').click();
  188. const openedUrl = getUrl();
  189. assert.ok(openedUrl, 'window.open should have been called');
  190. const subject = extractMailtoSubject(openedUrl);
  191. assert.match(subject, /My PrivateBin Instance/, 'subject should name the instance');
  192. } finally {
  193. restore();
  194. }
  195. });
  196. it('Percent-encodes the subject so a space-containing instance name does not break the mailto URL', function () {
  197. buildEmailDomNoShortUrl();
  198. document.title = 'My Cool Instance';
  199. PrivateBin.TopNav.init();
  200. PrivateBin.TopNav.showEmailButton(0);
  201. const { getUrl, restore } = makeWindowOpenMock();
  202. try {
  203. document.getElementById('emaillink').click();
  204. const openedUrl = getUrl();
  205. const rawSubjectParam = openedUrl.split('&body=')[0];
  206. assert.strictEqual(
  207. rawSubjectParam,
  208. `mailto:?subject=${encodeURIComponent('Encrypted note on My Cool Instance')}`,
  209. 'raw subject parameter should be exactly the encoded translated string'
  210. );
  211. assert.doesNotMatch(rawSubjectParam, / /, 'raw mailto URL must not contain a literal space');
  212. const subject = extractMailtoSubject(openedUrl);
  213. assert.strictEqual(subject, 'Encrypted note on My Cool Instance', 'decoded subject should round-trip exactly');
  214. } finally {
  215. restore();
  216. }
  217. });
  218. });