support-request.test.ts 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321
  1. /**
  2. * Tests for what a support request is allowed to contain, and for what an
  3. * administrator ends up reading.
  4. *
  5. * The submitted text lands in an HTML email, so escaping is a correctness
  6. * requirement rather than a nicety: a workshop reporting a problem with a page
  7. * will quite reasonably paste markup into the description.
  8. */
  9. import { describe, it, expect } from 'vitest'
  10. import {
  11. ALLOWED_ATTACHMENT_TYPES,
  12. ATTACHMENT_ACCEPT,
  13. MAX_ATTACHMENTS,
  14. MAX_MESSAGE_LENGTH,
  15. MAX_REQUEST_BYTES,
  16. MAX_SUBJECT_LENGTH,
  17. MAX_TOTAL_ATTACHMENT_BYTES,
  18. buildSupportEmailHtml,
  19. escapeHtml,
  20. exceedsRequestLimit,
  21. sanitizeFilename,
  22. validateSupportRequest,
  23. supportReplyToAddress,
  24. } from '@/features/support/Lib/supportRequest'
  25. const attachment = (
  26. overrides: Partial<{ filename: string; contentType: string; size: number }> = {}
  27. ) => ({
  28. filename: 'screenshot.jpg',
  29. contentType: 'image/jpeg',
  30. size: 1024,
  31. ...overrides,
  32. })
  33. const request = (overrides: Partial<Parameters<typeof validateSupportRequest>[0]> = {}) => ({
  34. subject: 'Invoice will not save',
  35. message: 'Pressing save does nothing on the invoice page.',
  36. attachments: [],
  37. ...overrides,
  38. })
  39. describe('validateSupportRequest', () => {
  40. it('accepts a normal request', () => {
  41. const result = validateSupportRequest(request())
  42. expect(result.ok).toBe(true)
  43. })
  44. it('trims the submitted text', () => {
  45. const result = validateSupportRequest(request({ subject: ' Padded ', message: ' Body ' }))
  46. expect(result).toEqual({ ok: true, subject: 'Padded', message: 'Body' })
  47. })
  48. it('rejects whitespace-only text as empty', () => {
  49. // Required attributes on the inputs stop the empty case in a browser, but
  50. // the endpoint is reachable without one.
  51. expect(validateSupportRequest(request({ subject: ' ' }))).toEqual({
  52. ok: false,
  53. reason: 'subject-required',
  54. })
  55. expect(validateSupportRequest(request({ message: '\n\t ' }))).toEqual({
  56. ok: false,
  57. reason: 'message-required',
  58. })
  59. })
  60. it('rejects text beyond the stated limits', () => {
  61. expect(
  62. validateSupportRequest(request({ subject: 'x'.repeat(MAX_SUBJECT_LENGTH + 1) }))
  63. ).toEqual({
  64. ok: false,
  65. reason: 'subject-too-long',
  66. })
  67. expect(
  68. validateSupportRequest(request({ message: 'x'.repeat(MAX_MESSAGE_LENGTH + 1) }))
  69. ).toEqual({
  70. ok: false,
  71. reason: 'message-too-long',
  72. })
  73. })
  74. it('accepts text exactly at the limits', () => {
  75. expect(
  76. validateSupportRequest(
  77. request({
  78. subject: 'x'.repeat(MAX_SUBJECT_LENGTH),
  79. message: 'x'.repeat(MAX_MESSAGE_LENGTH),
  80. })
  81. ).ok
  82. ).toBe(true)
  83. })
  84. it('rejects more attachments than allowed', () => {
  85. const attachments = Array.from({ length: MAX_ATTACHMENTS + 1 }, () => attachment())
  86. expect(validateSupportRequest(request({ attachments }))).toEqual({
  87. ok: false,
  88. reason: 'too-many-attachments',
  89. })
  90. })
  91. it('accepts exactly the allowed number of attachments', () => {
  92. const attachments = Array.from({ length: MAX_ATTACHMENTS }, () => attachment())
  93. expect(validateSupportRequest(request({ attachments })).ok).toBe(true)
  94. })
  95. it('rejects file types that have no business in an inbox', () => {
  96. for (const contentType of [
  97. 'application/x-msdownload',
  98. 'application/zip',
  99. 'text/html',
  100. 'application/octet-stream',
  101. '',
  102. ]) {
  103. expect(
  104. validateSupportRequest(request({ attachments: [attachment({ contentType })] }))
  105. ).toEqual({
  106. ok: false,
  107. reason: 'attachment-type-not-allowed',
  108. })
  109. }
  110. })
  111. it('accepts every type on the allow list', () => {
  112. for (const contentType of ALLOWED_ATTACHMENT_TYPES) {
  113. expect(
  114. validateSupportRequest(request({ attachments: [attachment({ contentType })] })).ok
  115. ).toBe(true)
  116. }
  117. })
  118. it('offers the picker exactly the types the server accepts', () => {
  119. // These were written out separately once and drifted: the picker allowed
  120. // image/*, so an SVG or an iPhone HEIC could be chosen and was only refused
  121. // after the upload had already happened.
  122. expect(ATTACHMENT_ACCEPT.split(',')).toEqual(ALLOWED_ATTACHMENT_TYPES)
  123. for (const offered of ATTACHMENT_ACCEPT.split(',')) {
  124. expect(
  125. validateSupportRequest(request({ attachments: [attachment({ contentType: offered })] })).ok
  126. ).toBe(true)
  127. }
  128. expect(ATTACHMENT_ACCEPT).not.toContain('image/*')
  129. })
  130. it('rejects attachments whose combined size exceeds the budget', () => {
  131. // Each file is individually fine; only the total is over. Checking them one
  132. // at a time would let this through.
  133. const attachments = [
  134. attachment({ size: MAX_TOTAL_ATTACHMENT_BYTES * 0.6 }),
  135. attachment({ size: MAX_TOTAL_ATTACHMENT_BYTES * 0.6 }),
  136. ]
  137. expect(validateSupportRequest(request({ attachments }))).toEqual({
  138. ok: false,
  139. reason: 'attachments-too-large',
  140. })
  141. })
  142. it('accepts attachments exactly at the budget', () => {
  143. const attachments = [attachment({ size: MAX_TOTAL_ATTACHMENT_BYTES })]
  144. expect(validateSupportRequest(request({ attachments })).ok).toBe(true)
  145. })
  146. })
  147. /**
  148. * The only check that runs before the body is read. Everything else in this
  149. * file operates on a parsed FormData, and parsing has already buffered the
  150. * whole upload into memory by then.
  151. */
  152. describe('exceedsRequestLimit', () => {
  153. it('refuses a request that declares more than the ceiling', () => {
  154. expect(exceedsRequestLimit(String(MAX_REQUEST_BYTES + 1))).toBe(true)
  155. expect(exceedsRequestLimit(String(1024 * 1024 * 1024))).toBe(true)
  156. })
  157. it('allows a request at or under the ceiling', () => {
  158. expect(exceedsRequestLimit(String(MAX_REQUEST_BYTES))).toBe(false)
  159. expect(exceedsRequestLimit(String(MAX_TOTAL_ATTACHMENT_BYTES))).toBe(false)
  160. expect(exceedsRequestLimit('0')).toBe(false)
  161. })
  162. it('leaves room above the attachment budget for the form fields themselves', () => {
  163. // A request carrying the maximum attachments plus a full-length subject and
  164. // message must not be refused by the very limit meant to allow it.
  165. expect(MAX_REQUEST_BYTES).toBeGreaterThan(
  166. MAX_TOTAL_ATTACHMENT_BYTES + MAX_SUBJECT_LENGTH + MAX_MESSAGE_LENGTH
  167. )
  168. })
  169. it('allows a chunked upload through to the byte counter', () => {
  170. // No Content-Length means the size is not knowable up front. Refusing these
  171. // outright would break any client that streams.
  172. expect(exceedsRequestLimit(null)).toBe(false)
  173. expect(exceedsRequestLimit('')).toBe(false)
  174. })
  175. it('does not refuse on a header it cannot parse', () => {
  176. expect(exceedsRequestLimit('not-a-number')).toBe(false)
  177. })
  178. })
  179. describe('escapeHtml', () => {
  180. it('neutralises markup so a pasted snippet renders as text', () => {
  181. expect(escapeHtml('<script>alert("x")</script>')).toBe(
  182. '&lt;script&gt;alert(&quot;x&quot;)&lt;/script&gt;'
  183. )
  184. })
  185. it('escapes ampersands before anything else, so entities are not doubled oddly', () => {
  186. expect(escapeHtml('Tom & Jerry <b>')).toBe('Tom &amp; Jerry &lt;b&gt;')
  187. })
  188. it('escapes quotes that could break out of an attribute', () => {
  189. expect(escapeHtml(`" onload='x'`)).toBe('&quot; onload=&#39;x&#39;')
  190. })
  191. })
  192. describe('sanitizeFilename', () => {
  193. it('keeps an ordinary name', () => {
  194. expect(sanitizeFilename('screenshot 1.jpg', 'fallback')).toBe('screenshot 1.jpg')
  195. })
  196. it('strips directory components', () => {
  197. expect(sanitizeFilename('../../etc/passwd', 'fallback')).toBe('passwd')
  198. expect(sanitizeFilename('C:\\Users\\me\\report.pdf', 'fallback')).toBe('report.pdf')
  199. })
  200. it('removes characters that could break a MIME header', () => {
  201. // The allow list is word characters, dot, hyphen and space, so the CRLF and
  202. // the colon that would forge a header are collapsed, and so is the @.
  203. expect(sanitizeFilename('bad"name\r\nBcc: x@y.z.pdf', 'fallback')).toBe(
  204. 'bad_name_Bcc_ x_y.z.pdf'
  205. )
  206. expect(sanitizeFilename('report\r\nBcc: a@b.c', 'fallback')).not.toMatch(/[\r\n:]/)
  207. })
  208. it('falls back when nothing usable is left', () => {
  209. expect(sanitizeFilename('', 'attachment-1')).toBe('attachment-1')
  210. expect(sanitizeFilename('...', 'attachment-2')).toBe('attachment-2')
  211. expect(sanitizeFilename('/', 'attachment-3')).toBe('attachment-3')
  212. })
  213. it('caps the length', () => {
  214. expect(sanitizeFilename(`${'a'.repeat(400)}.jpg`, 'fallback')).toHaveLength(120)
  215. })
  216. })
  217. describe('buildSupportEmailHtml', () => {
  218. const context = {
  219. organizationName: 'Bergen Bil',
  220. organizationId: 'org_123',
  221. userName: 'Kari',
  222. userEmail: 'kari@example.com',
  223. pageUrl: 'https://app.torqvoice.com/vehicles/1',
  224. userAgent: 'Mozilla/5.0',
  225. appVersion: '1.4.2',
  226. submittedAt: '2026-07-28T09:00:00.000Z',
  227. }
  228. it('carries the context needed to act on the ticket', () => {
  229. const html = buildSupportEmailHtml('Cannot save', 'It fails', context)
  230. expect(html).toContain('Bergen Bil')
  231. expect(html).toContain('org_123')
  232. expect(html).toContain('kari@example.com')
  233. expect(html).toContain('https://app.torqvoice.com/vehicles/1')
  234. expect(html).toContain('1.4.2')
  235. })
  236. it("escapes the user's own text", () => {
  237. const html = buildSupportEmailHtml('<b>subject</b>', '<img src=x onerror=alert(1)>', context)
  238. expect(html).not.toContain('<b>subject</b>')
  239. expect(html).not.toContain('<img src=x')
  240. expect(html).toContain('&lt;b&gt;subject&lt;/b&gt;')
  241. })
  242. it('escapes context values, which are not all under our control', () => {
  243. // Organization name and user agent are both attacker-influenced.
  244. const html = buildSupportEmailHtml('s', 'm', {
  245. ...context,
  246. organizationName: '<script>x</script>',
  247. userAgent: '<img onerror=1>',
  248. })
  249. expect(html).not.toContain('<script>x</script>')
  250. expect(html).not.toContain('<img onerror=1>')
  251. })
  252. it('omits rows it has no value for rather than printing blanks', () => {
  253. const html = buildSupportEmailHtml('s', 'm', {
  254. ...context,
  255. pageUrl: null,
  256. appVersion: null,
  257. userAgent: null,
  258. })
  259. expect(html).not.toContain('Page')
  260. expect(html).not.toContain('App version')
  261. expect(html).not.toContain('Browser')
  262. expect(html).toContain('Organization')
  263. })
  264. it('falls back to the email alone when the account has no name', () => {
  265. const html = buildSupportEmailHtml('s', 'm', { ...context, userName: null })
  266. expect(html).toContain('kari@example.com')
  267. })
  268. })
  269. describe('supportReplyToAddress', () => {
  270. it('pairs the name with the address so the inbox shows who is asking', () => {
  271. expect(supportReplyToAddress('Kari Nordmann', 'kari@example.com')).toBe(
  272. '"Kari Nordmann" <kari@example.com>'
  273. )
  274. })
  275. it('falls back to the bare address when there is no usable name', () => {
  276. expect(supportReplyToAddress(null, 'kari@example.com')).toBe('kari@example.com')
  277. expect(supportReplyToAddress(' ', 'kari@example.com')).toBe('kari@example.com')
  278. })
  279. it('strips characters that would break the header', () => {
  280. expect(supportReplyToAddress('Evil" <boss@example.com>\r\nBcc: x', 'kari@example.com')).toBe(
  281. '"Evil boss@example.com Bcc: x" <kari@example.com>'
  282. )
  283. })
  284. })