emailTemplateTest.js 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388
  1. 'use strict';
  2. require('../common');
  3. const fc = require('fast-check');
  4. // DOM builder that mirrors bootstrap5.php navbar
  5. function buildEmailDomNoShortUrl() {
  6. document.documentElement.innerHTML =
  7. // TopNav expects initially hidden #emaillink BUTTON.
  8. '<nav><div id="navbar"><ul>' +
  9. '<li>' +
  10. '<button id="clonebutton" type="button" class="btn btn-warning navbar-btn">' +
  11. '<span class="glyphicon glyphicon-duplicate" aria-hidden="true"></span> Clone' +
  12. '</button>' +
  13. '</li>' +
  14. '<li>' +
  15. '<button id="emaillink" type="button" class="hidden btn btn-secondary">Email</button>' +
  16. '</li>' +
  17. '</ul></div></nav>' +
  18. '<input id="burnafterreadingoption" type="checkbox">' +
  19. // include dummy email confirm modal for sendEmail
  20. '<div id="emailconfirmmodal" class="hidden">' +
  21. '<div id="emailconfirm-timezone-current"></div>' +
  22. '<div id="emailconfirm-timezone-utc"></div>' +
  23. '</div>'
  24. }
  25. // DOM builder that adds the shortener result block
  26. function buildEmailDomWithShortUrl() {
  27. buildEmailDomNoShortUrl();
  28. document.documentElement.innerHTML =
  29. // TopNav expectsinitially hidden #emaillink BUTTON.
  30. '<nav><div id="navbar"><ul>' +
  31. '<li>' +
  32. '<button id="clonebutton" type="button" class="btn btn-warning navbar-btn">' +
  33. '<span class="glyphicon glyphicon-duplicate" aria-hidden="true"></span> Clone' +
  34. '</button>' +
  35. '</li>' +
  36. '<li>' +
  37. '<button id="emaillink" type="button" class="hidden btn btn-secondary">Email</button>' +
  38. '</li>' +
  39. '</ul></div></nav>' +
  40. '<input id="burnafterreadingoption" type="checkbox">' +
  41. '<div id="pastelink">Your document is ' +
  42. '<a id="pasteurl" href="https://short.example/xYz">https://short.example/xYz</a> ' +
  43. '<span id="copyhint">(Hit <kbd>Ctrl</kbd>+<kbd>c</kbd> to copy)</span>' +
  44. '</div>' +
  45. // add a minimal email confirmation modal so sendEmail does not crash
  46. '<div id="emailconfirmmodal" class="hidden">' +
  47. '<div id="emailconfirm-timezone-current"></div>' +
  48. '<div id="emailconfirm-timezone-utc"></div>' +
  49. '</div>'
  50. }
  51. function makeWindowOpenMock() {
  52. const originalOpen = window.open;
  53. let openedUrl = null;
  54. let openedCount = 0;
  55. let mockRestoreFn = null;
  56. if (typeof jest !== 'undefined' && typeof jest.spyOn === 'function') {
  57. const spy = jest.spyOn(window, 'open').mockImplementation((url) => {
  58. openedUrl = url;
  59. openedCount++;
  60. return {};
  61. });
  62. mockRestoreFn = () => spy.mockRestore();
  63. } else {
  64. window.open = function (url) {
  65. openedUrl = url;
  66. openedCount++;
  67. return {};
  68. };
  69. mockRestoreFn = () => { window.open = originalOpen; };
  70. }
  71. return {
  72. getUrl: () => openedUrl,
  73. getCount: () => openedCount,
  74. restore: () => {
  75. if (mockRestoreFn) {
  76. mockRestoreFn();
  77. }
  78. }
  79. };
  80. }
  81. // Extract and decode the body from a "mailto:?subject=...&body=..." URL.
  82. function extractMailtoBody(mailtoUrl) {
  83. assert.match(mailtoUrl, /^mailto:\?/, 'expected a mailto: URL');
  84. const match = mailtoUrl.match(/[?&]body=([^&]*)/);
  85. assert.ok(match, 'expected the mailto: URL to have a body= parameter');
  86. return decodeURIComponent(match[1]);
  87. }
  88. // Extract and decode the subject from a "mailto:?subject=...&body=..." URL.
  89. function extractMailtoSubject(mailtoUrl) {
  90. assert.match(mailtoUrl, /^mailto:\?/, 'expected a mailto: URL');
  91. const match = mailtoUrl.match(/[?&]subject=([^&]*)/);
  92. assert.ok(match, 'expected the mailto: URL to have a subject= parameter');
  93. return decodeURIComponent(match[1]);
  94. }
  95. describe('Email - mail body content (short URL vs. fallback)', function () {
  96. beforeEach(function () {
  97. cleanup(); // provided by common
  98. });
  99. it('Uses the short URL when #pasteurl is present and never includes "undefined"', function () {
  100. buildEmailDomWithShortUrl(); // with #pastelink/#pasteurl
  101. PrivateBin.TopNav.init();
  102. PrivateBin.TopNav.showEmailButton(0);
  103. const emailBtn = document.getElementById('emaillink');
  104. assert.ok(!emailBtn.classList.contains('hidden'), '#emaillink should be visible after showEmailButton');
  105. const { getUrl, restore } = makeWindowOpenMock();
  106. try {
  107. emailBtn.click();
  108. document.getElementById('emailconfirm-timezone-current').click();
  109. const openedUrl = getUrl();
  110. assert.ok(openedUrl, 'window.open should have been called');
  111. const body = extractMailtoBody(openedUrl);
  112. assert.match(body, /https:\/\/short\.example\/xYz/, 'email body should include the short URL');
  113. assert.doesNotMatch(body, /undefined/, 'email body must not contain "undefined"');
  114. } finally {
  115. restore();
  116. }
  117. });
  118. it('Falls back to window.location.href when #pasteurl is absent and never includes "undefined"', function () {
  119. buildEmailDomNoShortUrl(); // No #pasteurl
  120. PrivateBin.TopNav.init();
  121. PrivateBin.TopNav.showEmailButton(0);
  122. const emailBtn = document.getElementById('emaillink');
  123. assert.ok(!emailBtn.classList.contains('hidden'), '#emaillink should be visible after showEmailButton');
  124. const { getUrl, restore } = makeWindowOpenMock();
  125. try {
  126. emailBtn.click();
  127. document.getElementById('emailconfirm-timezone-current').click();
  128. const openedUrl = getUrl();
  129. assert.ok(openedUrl, 'window.open should have been called');
  130. const body = extractMailtoBody(openedUrl);
  131. assert.match(body, new RegExp(window.location.href), 'email body should include the fallback page URL');
  132. assert.doesNotMatch(body, /undefined/, 'email body must not contain "undefined"');
  133. } finally {
  134. restore();
  135. }
  136. });
  137. it('Uses the viewed paste burn-after-reading state instead of the instance default', function () {
  138. buildEmailDomNoShortUrl();
  139. const burnAfterReading = document.createElement('input');
  140. burnAfterReading.id = 'burnafterreading';
  141. burnAfterReading.type = 'checkbox';
  142. burnAfterReading.checked = true;
  143. document.body.appendChild(burnAfterReading);
  144. PrivateBin.TopNav.init();
  145. PrivateBin.TopNav.showEmailButton(0, false);
  146. const { getUrl, restore } = makeWindowOpenMock();
  147. try {
  148. document.getElementById('emaillink').click();
  149. document.getElementById('emailconfirm-timezone-current').click();
  150. const body = extractMailtoBody(getUrl());
  151. assert.doesNotMatch(
  152. body,
  153. /only be accessed once/,
  154. 'email body must use the viewed paste metadata'
  155. );
  156. } finally {
  157. restore();
  158. }
  159. });
  160. });
  161. describe('Email - mail subject', function () {
  162. beforeEach(function () {
  163. cleanup(); // provided by common
  164. });
  165. it('Includes a non-empty subject naming the instance, with no expiration confirmation step', function () {
  166. buildEmailDomNoShortUrl();
  167. // buildEmailDomNoShortUrl() replaces documentElement.innerHTML (dropping <title>),
  168. // so document.title must be (re-)set after calling it, not before.
  169. document.title = 'My PrivateBin Instance';
  170. PrivateBin.TopNav.init();
  171. PrivateBin.TopNav.showEmailButton(0);
  172. const { getUrl, restore } = makeWindowOpenMock();
  173. try {
  174. document.getElementById('emaillink').click();
  175. const openedUrl = getUrl();
  176. assert.ok(openedUrl, 'window.open should have been called');
  177. const subject = extractMailtoSubject(openedUrl);
  178. assert.match(subject, /My PrivateBin Instance/, 'subject should name the instance');
  179. } finally {
  180. restore();
  181. }
  182. });
  183. it('Includes the same subject after the expiration confirmation step', function () {
  184. buildEmailDomWithShortUrl();
  185. document.title = 'My PrivateBin Instance';
  186. PrivateBin.TopNav.init();
  187. // a non-zero remaining time routes through the timezone confirmation modal
  188. PrivateBin.TopNav.showEmailButton(3600);
  189. const { getUrl, restore } = makeWindowOpenMock();
  190. try {
  191. document.getElementById('emaillink').click();
  192. document.getElementById('emailconfirm-timezone-current').click();
  193. const openedUrl = getUrl();
  194. assert.ok(openedUrl, 'window.open should have been called');
  195. const subject = extractMailtoSubject(openedUrl);
  196. assert.match(subject, /My PrivateBin Instance/, 'subject should name the instance');
  197. } finally {
  198. restore();
  199. }
  200. });
  201. it('Percent-encodes the subject so a space-containing instance name does not break the mailto URL', function () {
  202. buildEmailDomNoShortUrl();
  203. document.title = 'My Cool Instance';
  204. PrivateBin.TopNav.init();
  205. PrivateBin.TopNav.showEmailButton(0);
  206. const { getUrl, restore } = makeWindowOpenMock();
  207. try {
  208. document.getElementById('emaillink').click();
  209. const openedUrl = getUrl();
  210. const rawSubjectParam = openedUrl.split('&body=')[0];
  211. assert.strictEqual(
  212. rawSubjectParam,
  213. `mailto:?subject=${encodeURIComponent('Encrypted note on My Cool Instance')}`,
  214. 'raw subject parameter should be exactly the encoded translated string'
  215. );
  216. assert.doesNotMatch(rawSubjectParam, / /, 'raw mailto URL must not contain a literal space');
  217. const subject = extractMailtoSubject(openedUrl);
  218. assert.strictEqual(subject, 'Encrypted note on My Cool Instance', 'decoded subject should round-trip exactly');
  219. } finally {
  220. restore();
  221. }
  222. });
  223. });
  224. describe('Email - click handlers', function () {
  225. beforeEach(function () {
  226. cleanup(); // provided by common
  227. });
  228. it('Sends a single e-mail after the confirmation dialog was dismissed', function () {
  229. buildEmailDomNoShortUrl();
  230. PrivateBin.TopNav.init();
  231. PrivateBin.TopNav.showEmailButton(3600);
  232. const { getCount, restore } = makeWindowOpenMock();
  233. try {
  234. // open the dialog and dismiss it without choosing a timezone
  235. document.getElementById('emaillink').click();
  236. assert.strictEqual(getCount(), 0, 'no e-mail before choosing a timezone');
  237. document.getElementById('emaillink').click();
  238. document.getElementById('emailconfirm-timezone-current').click();
  239. assert.strictEqual(getCount(), 1, 'exactly one e-mail with the current timezone');
  240. document.getElementById('emaillink').click();
  241. document.getElementById('emailconfirm-timezone-utc').click();
  242. assert.strictEqual(getCount(), 2, 'exactly one more e-mail with UTC');
  243. } finally {
  244. restore();
  245. }
  246. });
  247. it('Sends a single e-mail per click after the button was shown again', function () {
  248. buildEmailDomNoShortUrl();
  249. PrivateBin.TopNav.init();
  250. // i.e. sending a second document without reloading the page
  251. PrivateBin.TopNav.showEmailButton(0);
  252. PrivateBin.TopNav.showEmailButton(0);
  253. const { getCount, restore } = makeWindowOpenMock();
  254. try {
  255. document.getElementById('emaillink').click();
  256. assert.strictEqual(getCount(), 1, 'exactly one e-mail per click');
  257. } finally {
  258. restore();
  259. }
  260. });
  261. it('Uses the expiration of the document the button was last shown for', function () {
  262. buildEmailDomNoShortUrl();
  263. PrivateBin.TopNav.init();
  264. // first document expires, the second never does
  265. PrivateBin.TopNav.showEmailButton(3600);
  266. PrivateBin.TopNav.showEmailButton(0);
  267. const { getUrl, getCount, restore } = makeWindowOpenMock();
  268. try {
  269. document.getElementById('emaillink').click();
  270. assert.strictEqual(getCount(), 1, 'sent directly, without confirmation dialog');
  271. assert.doesNotMatch(extractMailtoBody(getUrl()), /expire/i, 'body has no expiration');
  272. document.getElementById('emailconfirm-timezone-current').click();
  273. assert.strictEqual(getCount(), 1, 'no e-mail from a stale confirmation dialog');
  274. } finally {
  275. restore();
  276. }
  277. });
  278. it('Sends no e-mail from a confirmation dialog opened for an earlier document', function () {
  279. buildEmailDomNoShortUrl();
  280. PrivateBin.TopNav.init();
  281. PrivateBin.TopNav.showEmailButton(3600);
  282. const { getCount, restore } = makeWindowOpenMock();
  283. try {
  284. // open the dialog, but start a new document instead of choosing
  285. document.getElementById('emaillink').click();
  286. PrivateBin.TopNav.hideEmailButton();
  287. PrivateBin.TopNav.showEmailButton(0);
  288. document.getElementById('emailconfirm-timezone-current').click();
  289. document.getElementById('emailconfirm-timezone-utc').click();
  290. assert.strictEqual(getCount(), 0, 'no e-mail from a stale confirmation dialog');
  291. } finally {
  292. restore();
  293. }
  294. });
  295. it('Sends no e-mail after the button was hidden', function () {
  296. buildEmailDomNoShortUrl();
  297. PrivateBin.TopNav.init();
  298. PrivateBin.TopNav.showEmailButton(0);
  299. PrivateBin.TopNav.hideEmailButton();
  300. const { getCount, restore } = makeWindowOpenMock();
  301. try {
  302. // i.e. a keyboard shortcut or assistive technology activating it
  303. document.getElementById('emaillink').click();
  304. assert.strictEqual(getCount(), 0, 'no e-mail from a hidden button');
  305. } finally {
  306. restore();
  307. }
  308. });
  309. it('Sends exactly one e-mail, regardless of how often the button was shown or the dialog dismissed', function () {
  310. fc.assert(fc.property(
  311. fc.integer({min: 1, max: 5}),
  312. fc.integer({min: 0, max: 5}),
  313. fc.constantFrom('current', 'utc'),
  314. function (shown, dismissed, timezone) {
  315. cleanup();
  316. buildEmailDomNoShortUrl();
  317. PrivateBin.TopNav.init();
  318. for (let i = 0; i < shown; ++i) {
  319. PrivateBin.TopNav.showEmailButton(3600);
  320. }
  321. const { getCount, restore } = makeWindowOpenMock();
  322. try {
  323. for (let i = 0; i < dismissed; ++i) {
  324. document.getElementById('emaillink').click();
  325. }
  326. document.getElementById('emaillink').click();
  327. document.getElementById('emailconfirm-timezone-' + timezone).click();
  328. assert.strictEqual(getCount(), 1);
  329. } finally {
  330. restore();
  331. }
  332. }
  333. ));
  334. });
  335. });