emailTemplateTest.js 12 KB

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