@consilioweb/payload-support 1.1.1 → 2.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (110) hide show
  1. package/README.md +74 -3
  2. package/dist/components/TicketConversation/components/AISummaryPanel.cjs +22 -14
  3. package/dist/components/TicketConversation/components/AISummaryPanel.js +23 -16
  4. package/dist/components/TicketConversation/components/ActionPanels.cjs +5 -5
  5. package/dist/components/TicketConversation/components/ActionPanels.js +5 -5
  6. package/dist/components/TicketConversation/components/ClientBar.cjs +1 -1
  7. package/dist/components/TicketConversation/components/ClientBar.js +1 -1
  8. package/dist/components/TicketConversation/components/QuickActions.cjs +2 -2
  9. package/dist/components/TicketConversation/components/QuickActions.js +2 -2
  10. package/dist/components/TicketConversation/components/TicketHeader.cjs +1 -1
  11. package/dist/components/TicketConversation/components/TicketHeader.js +1 -1
  12. package/dist/components/TicketConversation/hooks/useTranslation.cjs +25 -2
  13. package/dist/components/TicketConversation/hooks/useTranslation.js +22 -3
  14. package/dist/components/TicketConversation/index.cjs +10 -10
  15. package/dist/components/TicketConversation/index.js +10 -10
  16. package/dist/index.cjs +709 -173
  17. package/dist/index.d.cts +165 -3
  18. package/dist/index.d.ts +165 -3
  19. package/dist/index.js +702 -175
  20. package/dist/styles/ChatView.module.scss +1 -1
  21. package/dist/styles/TicketDetail.module.scss +3 -3
  22. package/dist/styles/_tokens.scss +1 -1
  23. package/dist/styles/theme.css +6 -6
  24. package/dist/views/SupportDashboardView/client.cjs +1 -1
  25. package/dist/views/SupportDashboardView/client.js +1 -1
  26. package/dist/views/TicketingSettingsView/client.cjs +4 -4
  27. package/dist/views/TicketingSettingsView/client.js +4 -4
  28. package/dist/views/shared/locales/en.json +708 -0
  29. package/dist/views/shared/locales/fr.json +708 -0
  30. package/package.json +19 -19
  31. package/src/__tests__/aiSummaryRendering.test.ts +21 -0
  32. package/src/__tests__/authResponses.test.ts +69 -0
  33. package/src/__tests__/capabilities.test.ts +71 -0
  34. package/src/__tests__/generateTrackingToken.test.ts +18 -4
  35. package/src/__tests__/integration/core-behaviors.test.ts +23 -0
  36. package/src/__tests__/rateLimiter.test.ts +60 -66
  37. package/src/__tests__/trackOpenEndpoint.test.ts +54 -0
  38. package/src/__tests__/translations.test.ts +67 -0
  39. package/src/__tests__/webhookSecurity.test.ts +58 -0
  40. package/src/collections/PendingEmails.ts +2 -1
  41. package/src/collections/SupportClients.ts +43 -1
  42. package/src/collections/SupportCounters.ts +14 -0
  43. package/src/collections/SupportRateLimits.ts +20 -0
  44. package/src/collections/TicketMessages.ts +76 -0
  45. package/src/collections/Tickets.ts +179 -39
  46. package/src/collections/index.ts +2 -0
  47. package/src/components/TicketConversation/components/AISummaryPanel.tsx +26 -13
  48. package/src/components/TicketConversation/components/ActionPanels.tsx +5 -5
  49. package/src/components/TicketConversation/components/ClientBar.tsx +1 -1
  50. package/src/components/TicketConversation/components/QuickActions.tsx +2 -2
  51. package/src/components/TicketConversation/components/TicketHeader.tsx +1 -1
  52. package/src/components/TicketConversation/hooks/useTranslation.ts +38 -4
  53. package/src/components/TicketConversation/index.tsx +10 -10
  54. package/src/endpoints/admin-chat.ts +4 -4
  55. package/src/endpoints/ai-agent.ts +6 -1
  56. package/src/endpoints/ai.ts +10 -5
  57. package/src/endpoints/auth-2fa.ts +6 -7
  58. package/src/endpoints/auto-close.ts +2 -1
  59. package/src/endpoints/capabilities.ts +144 -0
  60. package/src/endpoints/channels.ts +2 -1
  61. package/src/endpoints/chat.ts +6 -6
  62. package/src/endpoints/chatbot.ts +4 -4
  63. package/src/endpoints/client-intelligence.ts +13 -4
  64. package/src/endpoints/import-conversation.ts +6 -5
  65. package/src/endpoints/index.ts +35 -14
  66. package/src/endpoints/invite-collaborator.ts +4 -4
  67. package/src/endpoints/login.ts +4 -5
  68. package/src/endpoints/oauth-google.ts +1 -1
  69. package/src/endpoints/process-digests.ts +2 -1
  70. package/src/endpoints/process-scheduled.ts +2 -1
  71. package/src/endpoints/process-snooze.ts +2 -1
  72. package/src/endpoints/resend-notification.ts +4 -4
  73. package/src/endpoints/send-reminder.ts +4 -4
  74. package/src/endpoints/ticket-synthesis.ts +17 -2
  75. package/src/endpoints/track-open.ts +44 -21
  76. package/src/endpoints/transfer-ticket.ts +4 -4
  77. package/src/index.ts +7 -0
  78. package/src/plugin.ts +16 -1
  79. package/src/portal/LiveChat.tsx +1 -1
  80. package/src/portal/auth/ChatWidget.tsx +4 -4
  81. package/src/portal/auth/ChatbotWidget.tsx +4 -4
  82. package/src/portal/auth/dashboard/DashboardClient.tsx +2 -2
  83. package/src/portal/auth/faq/FAQSearch.tsx +1 -1
  84. package/src/portal/auth/faq/page.tsx +2 -2
  85. package/src/portal/auth/profile/page.tsx +17 -17
  86. package/src/portal/auth/tickets/detail/CloseTicketButton.tsx +1 -1
  87. package/src/portal/auth/tickets/detail/MarkSolutionButton.tsx +1 -1
  88. package/src/portal/auth/tickets/detail/ReopenTicketButton.tsx +1 -1
  89. package/src/portal/auth/tickets/detail/SatisfactionForm.tsx +3 -3
  90. package/src/portal/auth/tickets/detail/TicketReplyForm.tsx +3 -3
  91. package/src/portal/auth/tickets/detail/page.tsx +5 -5
  92. package/src/portal/auth/tickets/new/page.tsx +8 -8
  93. package/src/portal/forgot-password/page.tsx +3 -3
  94. package/src/portal/layout.tsx +5 -2
  95. package/src/portal/login/page.tsx +7 -7
  96. package/src/portal/page.tsx +9 -9
  97. package/src/portal/register/page.tsx +4 -4
  98. package/src/portal/reset-password/page.tsx +3 -3
  99. package/src/styles/ChatView.module.scss +1 -1
  100. package/src/styles/TicketDetail.module.scss +3 -3
  101. package/src/styles/_tokens.scss +1 -1
  102. package/src/styles/theme.css +6 -6
  103. package/src/types.ts +71 -0
  104. package/src/utils/rateLimiter.ts +131 -38
  105. package/src/utils/slugs.ts +4 -0
  106. package/src/utils/webhookSecurity.ts +81 -0
  107. package/src/views/SupportDashboardView/client.tsx +1 -1
  108. package/src/views/TicketingSettingsView/client.tsx +4 -4
  109. package/src/views/shared/locales/en.json +2 -2
  110. package/src/views/shared/locales/fr.json +2 -2
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@consilioweb/payload-support",
3
- "version": "1.1.1",
3
+ "version": "2.0.1",
4
4
  "description": "Payload CMS plugin — professional support & ticketing system with AI, SLA, time tracking, live chat, and more",
5
5
  "type": "module",
6
6
  "main": "./dist/index.cjs",
@@ -49,15 +49,6 @@
49
49
  "README.md",
50
50
  "LICENSE"
51
51
  ],
52
- "scripts": {
53
- "build": "tsup",
54
- "typecheck": "tsc --noEmit",
55
- "prepublishOnly": "npm run build",
56
- "test": "vitest run",
57
- "test:watch": "vitest",
58
- "test:e2e": "playwright test",
59
- "test:e2e:ui": "playwright test --ui"
60
- },
61
52
  "keywords": [
62
53
  "payload",
63
54
  "payload-cms",
@@ -124,22 +115,23 @@
124
115
  "node": ">=18"
125
116
  },
126
117
  "devDependencies": {
127
- "@payloadcms/db-sqlite": "^3.85.1",
118
+ "@payloadcms/db-sqlite": "^3.86.0",
119
+ "@payloadcms/graphql": "^3.86.0",
120
+ "@payloadcms/next": "^3.86.0",
121
+ "@payloadcms/richtext-lexical": "^3.86.0",
122
+ "@payloadcms/translations": "^3.86.0",
123
+ "@payloadcms/ui": "^3.86.0",
128
124
  "@playwright/test": "^1.49.0",
129
- "@payloadcms/graphql": "^3.85.1",
130
- "@payloadcms/next": "^3.85.1",
131
- "@payloadcms/richtext-lexical": "^3.85.1",
132
- "@payloadcms/translations": "^3.85.1",
133
- "@payloadcms/ui": "^3.85.1",
134
125
  "@types/pdfkit": "^0.17.6",
135
126
  "@types/react": "^19.0.0",
136
127
  "@types/sanitize-html": "^2.16.1",
137
128
  "@types/web-push": "^3.6.4",
138
129
  "esbuild-sass-plugin": "^3.7.0",
139
- "next": "^15.5.14",
140
- "payload": "^3.85.1",
130
+ "next": "^16.2.9",
131
+ "payload": "^3.86.0",
141
132
  "react": "^19.2.7",
142
133
  "react-dom": "^19.2.7",
134
+ "sharp": "0.34.5",
143
135
  "tsup": "^8.0.0",
144
136
  "typescript": "^5.5.0",
145
137
  "vitest": "^4.1.9"
@@ -148,5 +140,13 @@
148
140
  "pdfkit": "^0.19.1",
149
141
  "sanitize-html": "^2.17.5",
150
142
  "web-push": "^3.6.7"
143
+ },
144
+ "scripts": {
145
+ "build": "tsup",
146
+ "typecheck": "tsc --noEmit",
147
+ "test": "vitest run",
148
+ "test:watch": "vitest",
149
+ "test:e2e": "playwright test",
150
+ "test:e2e:ui": "playwright test --ui"
151
151
  }
152
- }
152
+ }
@@ -0,0 +1,21 @@
1
+ import React from 'react'
2
+ import { describe, expect, it } from 'vitest'
3
+ import { renderAiSummary } from '../components/TicketConversation/components/AISummaryPanel'
4
+
5
+ describe('AI summary rendering', () => {
6
+ it('escapes model-provided HTML instead of interpreting it', () => {
7
+ const nodes = renderAiSummary('<img src=x onerror=alert(1)>')
8
+ expect(nodes).toEqual(['<img src=x onerror=alert(1)>'])
9
+ expect(React.isValidElement(nodes[0])).toBe(false)
10
+ })
11
+
12
+ it('preserves bold markdown and line breaks without raw HTML', () => {
13
+ const nodes = renderAiSummary('**Résumé**\nSuite')
14
+ expect(React.isValidElement(nodes[0])).toBe(true)
15
+ expect((nodes[0] as React.ReactElement).type).toBe('strong')
16
+ expect((nodes[0] as React.ReactElement<{ children: string }>).props.children).toBe('Résumé')
17
+ expect(React.isValidElement(nodes[1])).toBe(true)
18
+ expect((nodes[1] as React.ReactElement).type).toBe('br')
19
+ expect(nodes[2]).toBe('Suite')
20
+ })
21
+ })
@@ -0,0 +1,69 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
2
+ import { createLoginEndpoint } from '../endpoints/login'
3
+ import { createOAuthGoogleEndpoint } from '../endpoints/oauth-google'
4
+ import { resolveSlugs } from '../utils/slugs'
5
+
6
+ const slugs = resolveSlugs()
7
+
8
+ afterEach(() => {
9
+ vi.restoreAllMocks()
10
+ })
11
+
12
+ describe('authentication responses', () => {
13
+ it('keeps the login JWT exclusively in the HttpOnly cookie', async () => {
14
+ const endpoint = createLoginEndpoint(slugs)
15
+ const response = await endpoint.handler({
16
+ headers: new Headers(),
17
+ json: async () => ({ email: 'client@example.com', password: 'secret' }),
18
+ payload: {
19
+ login: vi.fn(async () => ({ token: 'jwt-secret', exp: 123, user: { id: 1 } })),
20
+ create: vi.fn(async () => ({})),
21
+ },
22
+ } as never)
23
+
24
+ expect(response.headers.get('set-cookie')).toContain('payload-token=jwt-secret')
25
+ expect(response.headers.get('set-cookie')).toContain('HttpOnly')
26
+ await expect(response.json()).resolves.toEqual({
27
+ message: 'Login successful',
28
+ user: { id: 1 },
29
+ exp: 123,
30
+ })
31
+ })
32
+
33
+ it('keeps the OAuth JWT exclusively in the HttpOnly cookie', async () => {
34
+ process.env.GOOGLE_OAUTH_CLIENT_ID = 'google-client'
35
+ process.env.GOOGLE_OAUTH_CLIENT_SECRET = 'google-secret'
36
+ process.env.NEXT_PUBLIC_SERVER_URL = 'https://example.test'
37
+ process.env.PAYLOAD_SECRET = 'payload-secret'
38
+
39
+ vi.spyOn(globalThis, 'fetch')
40
+ .mockResolvedValueOnce(new Response(JSON.stringify({ access_token: 'google-token' })))
41
+ .mockResolvedValueOnce(new Response(JSON.stringify({
42
+ id: 'google-id',
43
+ email: 'client@example.com',
44
+ verified_email: true,
45
+ })))
46
+
47
+ const endpoint = createOAuthGoogleEndpoint(slugs)
48
+ const response = await endpoint.handler({
49
+ json: async () => ({ code: 'code', state: 'state', cookieState: 'state' }),
50
+ payload: {
51
+ collections: {
52
+ [slugs.supportClients]: {
53
+ config: { auth: { tokenExpiration: 7200 }, fields: [] },
54
+ },
55
+ },
56
+ find: vi.fn(async () => ({
57
+ docs: [{ id: 1, email: 'client@example.com' }],
58
+ totalDocs: 1,
59
+ })),
60
+ },
61
+ } as never)
62
+
63
+ expect(response.status).toBe(200)
64
+ expect(response.headers.get('set-cookie')).toMatch(/^payload-token=/)
65
+ const body = await response.json() as Record<string, unknown>
66
+ expect(body).not.toHaveProperty('token')
67
+ expect(body).toHaveProperty('user')
68
+ })
69
+ })
@@ -0,0 +1,71 @@
1
+ import type { Config, Field } from 'payload'
2
+ import { describe, expect, it, vi } from 'vitest'
3
+ import { supportPlugin } from '../plugin'
4
+
5
+ function fieldNames(fields: Field[]): string[] {
6
+ return fields.flatMap((field) => {
7
+ const own = 'name' in field && typeof field.name === 'string' ? [field.name] : []
8
+ const nested = 'fields' in field && Array.isArray(field.fields) ? fieldNames(field.fields) : []
9
+ return [...own, ...nested]
10
+ })
11
+ }
12
+
13
+ function configuredPlugin() {
14
+ return supportPlugin({
15
+ capabilities: {
16
+ aiSummaries: { generate: vi.fn(async () => undefined) },
17
+ aiTitles: { generate: vi.fn(async () => undefined) },
18
+ detailedBilling: true,
19
+ inboundEmail: {
20
+ secret: 'test-secret',
21
+ handle: vi.fn(async () => Response.json({ success: true })),
22
+ },
23
+ projectSuggestions: {
24
+ suggest: vi.fn(async () => Response.json({ suggestions: [] })),
25
+ },
26
+ sms: {
27
+ adapter: { send: vi.fn(async () => ({ sent: true })) },
28
+ },
29
+ threadCleanup: { clean: vi.fn(async () => undefined) },
30
+ volunteering: true,
31
+ },
32
+ rateLimitStore: 'payload',
33
+ })({ collections: [], endpoints: [] } as unknown as Config) as Config
34
+ }
35
+
36
+ describe('Support capabilities', () => {
37
+ it('owns the optional ticket, message and client fields', () => {
38
+ const config = configuredPlugin()
39
+ const collection = (slug: string) => config.collections?.find((item) => item.slug === slug)
40
+
41
+ expect(fieldNames(collection('tickets')!.fields)).toEqual(expect.arrayContaining([
42
+ 'billingLines',
43
+ 'billedAt',
44
+ 'displayTitle',
45
+ 'displayTitleStatus',
46
+ 'volunteer',
47
+ 'volunteerValue',
48
+ 'waitingSince',
49
+ ]))
50
+ expect(fieldNames(collection('ticket-messages')!.fields)).toEqual(expect.arrayContaining([
51
+ 'notifyBySms',
52
+ 'smsTo',
53
+ 'smsMessage',
54
+ 'isMarkedAsNoise',
55
+ 'emailBodyOriginal',
56
+ ]))
57
+ expect(fieldNames(collection('support-clients')!.fields)).toEqual(expect.arrayContaining([
58
+ 'notifyBySmsChannel',
59
+ 'preferredFormality',
60
+ 'preferredTone',
61
+ ]))
62
+ })
63
+
64
+ it('registers protected deployment adapter endpoints in the module', () => {
65
+ const paths = configuredPlugin().endpoints?.map((endpoint) => endpoint.path)
66
+ expect(paths).toContain('/support-webhook/inbound-email')
67
+ expect(paths).toContain('/support/suggest-projects')
68
+ expect(paths).toContain('/support/ticket-title')
69
+ expect(paths).toContain('/support/generate-missing-titles')
70
+ })
71
+ })
@@ -1,5 +1,5 @@
1
1
  import { describe, it, expect } from 'vitest'
2
- import { generateTrackingToken } from '../endpoints/track-open'
2
+ import { generateTrackingToken, verifyTrackingToken } from '../endpoints/track-open'
3
3
 
4
4
  describe('generateTrackingToken — determinism', () => {
5
5
  it('returns the same token for identical inputs (deterministic)', () => {
@@ -8,14 +8,28 @@ describe('generateTrackingToken — determinism', () => {
8
8
  expect(a).toBe(b)
9
9
  })
10
10
 
11
- it('always returns exactly 16 characters', () => {
11
+ it('returns the complete SHA-256 HMAC', () => {
12
12
  const token = generateTrackingToken('1', '2', 'secret')
13
- expect(token).toHaveLength(16)
13
+ expect(token).toHaveLength(64)
14
14
  })
15
15
 
16
16
  it('returns only hex characters (0-9 a-f)', () => {
17
17
  const token = generateTrackingToken('100', '200', 'anothersecret')
18
- expect(token).toMatch(/^[0-9a-f]{16}$/)
18
+ expect(token).toMatch(/^[0-9a-f]{64}$/)
19
+ })
20
+ })
21
+
22
+ describe('verifyTrackingToken', () => {
23
+ it('accepts the matching ticket and message signature', () => {
24
+ const token = generateTrackingToken('42', '7', 'secret')
25
+ expect(verifyTrackingToken('42', '7', token, 'secret')).toBe(true)
26
+ })
27
+
28
+ it('rejects malformed and mismatched signatures without throwing', () => {
29
+ const token = generateTrackingToken('42', '7', 'secret')
30
+ expect(verifyTrackingToken('42', '8', token, 'secret')).toBe(false)
31
+ expect(verifyTrackingToken('42', '7', 'not-hex', 'secret')).toBe(false)
32
+ expect(verifyTrackingToken('42', '7', token.slice(2), 'secret')).toBe(false)
19
33
  })
20
34
  })
21
35
 
@@ -23,6 +23,29 @@ describe('core ticketing behaviours', () => {
23
23
  expect(t1.ticketNumber).not.toBe(t2.ticketNumber)
24
24
  }, 60_000)
25
25
 
26
+ it('allocates sequential numbers under concurrent creation and after deletion', async () => {
27
+ const payload = await buildTestPayload()
28
+ const c = await makeClient(payload, 'concurrent-num@example.com')
29
+ const tickets = await Promise.all(
30
+ Array.from({ length: 12 }, (_, index) => payload.create({
31
+ collection: 'tickets',
32
+ data: { subject: `concurrent-${index}`, client: c.id, status: 'open', priority: 'normal' },
33
+ overrideAccess: true,
34
+ })),
35
+ )
36
+ const numbers = tickets.map((ticket) => Number(String(ticket.ticketNumber).replace('TK-', '')))
37
+ expect(new Set(numbers)).toHaveLength(12)
38
+
39
+ const highest = Math.max(...numbers)
40
+ await payload.delete({ collection: 'tickets', id: tickets[0].id, overrideAccess: true })
41
+ const next = await payload.create({
42
+ collection: 'tickets',
43
+ data: { subject: 'after-delete', client: c.id, status: 'open', priority: 'normal' },
44
+ overrideAccess: true,
45
+ })
46
+ expect(Number(String(next.ticketNumber).replace('TK-', ''))).toBe(highest + 1)
47
+ }, 60_000)
48
+
26
49
  it('moves status to waiting_client on admin reply and back to open on client reply', async () => {
27
50
  const payload = await buildTestPayload()
28
51
  const c = await makeClient(payload, 'status@example.com')
@@ -1,89 +1,83 @@
1
- import { describe, it, expect, vi, afterEach } from 'vitest'
2
- import { RateLimiter } from '../utils/rateLimiter'
1
+ import { afterEach, describe, expect, it, vi } from 'vitest'
2
+ import { MemoryRateLimitStore, PayloadRateLimitStore, RateLimiter, type RateLimitStore } from '../utils/rateLimiter'
3
3
 
4
4
  afterEach(() => {
5
5
  vi.useRealTimers()
6
6
  })
7
7
 
8
- describe('RateLimiter — under the limit', () => {
9
- it('allows the first request', () => {
10
- const limiter = new RateLimiter(60_000, 5)
11
- expect(limiter.check('user:1')).toBe(false)
12
- })
13
-
14
- it('allows requests up to the maximum (maxRequests = 3)', () => {
15
- const limiter = new RateLimiter(60_000, 3)
16
- expect(limiter.check('key')).toBe(false) // count = 1
17
- expect(limiter.check('key')).toBe(false) // count = 2
18
- expect(limiter.check('key')).toBe(false) // count = 3
19
- })
20
- })
21
-
22
- describe('RateLimiter — exceeding the limit', () => {
23
- it('blocks the request when count exceeds maxRequests', () => {
8
+ describe('RateLimiter', () => {
9
+ it('allows requests up to the maximum and then blocks', async () => {
24
10
  const limiter = new RateLimiter(60_000, 2)
25
- limiter.check('key') // count = 1
26
- limiter.check('key') // count = 2
27
- // count = 3 > maxRequests(2) → blocked
28
- expect(limiter.check('key')).toBe(true)
11
+ await expect(limiter.check('key')).resolves.toBe(false)
12
+ await expect(limiter.check('key')).resolves.toBe(false)
13
+ await expect(limiter.check('key')).resolves.toBe(true)
14
+ await expect(limiter.check('key')).resolves.toBe(true)
29
15
  })
30
16
 
31
- it('keeps blocking subsequent requests after limit exceeded', () => {
17
+ it('resets only the targeted key', async () => {
32
18
  const limiter = new RateLimiter(60_000, 1)
33
- limiter.check('key') // count = 1, passes
34
- expect(limiter.check('key')).toBe(true) // count = 2 → blocked
35
- expect(limiter.check('key')).toBe(true) // count = 3 → still blocked
36
- })
37
- })
19
+ await limiter.check('key-a')
20
+ await limiter.check('key-b')
21
+ await expect(limiter.check('key-a')).resolves.toBe(true)
22
+ await expect(limiter.check('key-b')).resolves.toBe(true)
38
23
 
39
- describe('RateLimiter reset', () => {
40
- it('allows requests again after manual reset', () => {
41
- const limiter = new RateLimiter(60_000, 1)
42
- limiter.check('key') // count = 1
43
- expect(limiter.check('key')).toBe(true) // blocked
44
- limiter.reset('key')
45
- expect(limiter.check('key')).toBe(false) // reset → allowed again
24
+ await limiter.reset('key-a')
25
+
26
+ await expect(limiter.check('key-a')).resolves.toBe(false)
27
+ await expect(limiter.check('key-b')).resolves.toBe(true)
46
28
  })
47
29
 
48
- it('reset only affects the targeted key', () => {
49
- const limiter = new RateLimiter(60_000, 1)
50
- limiter.check('keyA')
51
- limiter.check('keyA') // keyA blocked
52
- limiter.check('keyB')
53
- limiter.check('keyB') // keyB blocked
30
+ it('opens a new window after expiry', async () => {
31
+ vi.useFakeTimers()
32
+ const limiter = new RateLimiter(1_000, 1)
33
+ await limiter.check('key')
34
+ await expect(limiter.check('key')).resolves.toBe(true)
54
35
 
55
- limiter.reset('keyA')
56
- expect(limiter.check('keyA')).toBe(false) // keyA allowed
57
- expect(limiter.check('keyB')).toBe(true) // keyB still blocked
36
+ vi.advanceTimersByTime(1_001)
37
+
38
+ await expect(limiter.check('key')).resolves.toBe(false)
58
39
  })
59
- })
60
40
 
61
- describe('RateLimiter window expiry', () => {
62
- it('resets the counter automatically after the window expires', () => {
63
- vi.useFakeTimers()
64
- const windowMs = 1_000
65
- const limiter = new RateLimiter(windowMs, 2)
41
+ it('shares counters between limiter instances using the same store', async () => {
42
+ const store = new MemoryRateLimitStore()
43
+ const firstProcess = new RateLimiter(60_000, 1, store)
44
+ const secondProcess = new RateLimiter(60_000, 1, store)
66
45
 
67
- limiter.check('key') // count = 1
68
- limiter.check('key') // count = 2
69
- expect(limiter.check('key')).toBe(true) // blocked
46
+ await expect(firstProcess.check('shared')).resolves.toBe(false)
47
+ await expect(secondProcess.check('shared')).resolves.toBe(true)
48
+ })
70
49
 
71
- // Advance past the window
72
- vi.advanceTimersByTime(windowMs + 1)
50
+ it('supports an asynchronous persistent store adapter', async () => {
51
+ const increment = vi.fn(async () => ({ count: 2, resetAt: Date.now() + 1_000 }))
52
+ const store: RateLimitStore = {
53
+ increment,
54
+ reset: vi.fn(async () => undefined),
55
+ }
56
+ const limiter = new RateLimiter(1_000, 1, store)
73
57
 
74
- // The next check should open a new window
75
- expect(limiter.check('key')).toBe(false) // new window, count = 1
58
+ await expect(limiter.check('client:42')).resolves.toBe(true)
59
+ expect(increment).toHaveBeenCalledWith('client:42', 1_000)
76
60
  })
77
- })
78
61
 
79
- describe('RateLimiter key independence', () => {
80
- it('tracks different keys independently', () => {
81
- const limiter = new RateLimiter(60_000, 1)
82
- // key-A hits limit
83
- limiter.check('key-A') // count = 1
84
- expect(limiter.check('key-A')).toBe(true) // blocked
62
+ it('persists counters through the Payload adapter', async () => {
63
+ let record: { id: number; key: string; count: number; resetAt: string } | undefined
64
+ const payload = {
65
+ find: vi.fn(async () => ({ docs: record ? [record] : [] })),
66
+ create: vi.fn(async ({ data }: { data: typeof record }) => {
67
+ record = { ...data!, id: 1 }
68
+ return record
69
+ }),
70
+ update: vi.fn(async ({ data }: { data: Partial<typeof record> }) => {
71
+ record = { ...record!, ...data }
72
+ return record
73
+ }),
74
+ delete: vi.fn(async () => undefined),
75
+ }
76
+ const store = new PayloadRateLimitStore()
77
+ const firstProcess = new RateLimiter(60_000, 1, store)
78
+ const secondProcess = new RateLimiter(60_000, 1, store)
85
79
 
86
- // key-B untouched
87
- expect(limiter.check('key-B')).toBe(false) // fresh key, allowed
80
+ await expect(firstProcess.check('shared', payload)).resolves.toBe(false)
81
+ await expect(secondProcess.check('shared', payload)).resolves.toBe(true)
88
82
  })
89
83
  })
@@ -0,0 +1,54 @@
1
+ import { describe, expect, it, vi } from 'vitest'
2
+ import { createTrackOpenEndpoint, generateTrackingToken } from '../endpoints/track-open'
3
+ import { resolveSlugs } from '../utils/slugs'
4
+
5
+ const secret = 'test-payload-secret'
6
+
7
+ function createRequest(ticketId: string, messageId: string, signature: string) {
8
+ const findByID = vi.fn(async ({ collection }: { collection: string }) => {
9
+ if (collection === 'ticket-messages') {
10
+ return { id: Number(messageId), ticket: Number(ticketId) + 1, emailOpenedAt: null }
11
+ }
12
+ return { id: Number(ticketId), lastClientReadAt: null }
13
+ })
14
+ const update = vi.fn()
15
+ return {
16
+ req: {
17
+ url: `https://example.test/api/support/track-open?t=${ticketId}&m=${messageId}&sig=${signature}`,
18
+ payload: { findByID, update },
19
+ },
20
+ findByID,
21
+ update,
22
+ }
23
+ }
24
+
25
+ describe('track-open endpoint', () => {
26
+ it('does not query the database for signed non-numeric identifiers', async () => {
27
+ process.env.PAYLOAD_SECRET = secret
28
+ const ticketId = 'not-a-ticket'
29
+ const messageId = 'not-a-message'
30
+ const signature = generateTrackingToken(ticketId, messageId, secret)
31
+ const { req, findByID, update } = createRequest(ticketId, messageId, signature)
32
+ const endpoint = createTrackOpenEndpoint(resolveSlugs())
33
+
34
+ const response = await endpoint.handler(req as never)
35
+
36
+ expect(response.status).toBe(200)
37
+ expect(findByID).not.toHaveBeenCalled()
38
+ expect(update).not.toHaveBeenCalled()
39
+ })
40
+
41
+ it('does not mutate when the signed message belongs to another ticket', async () => {
42
+ process.env.PAYLOAD_SECRET = secret
43
+ const ticketId = '10'
44
+ const messageId = '20'
45
+ const signature = generateTrackingToken(ticketId, messageId, secret)
46
+ const { req, update } = createRequest(ticketId, messageId, signature)
47
+ const endpoint = createTrackOpenEndpoint(resolveSlugs())
48
+
49
+ const response = await endpoint.handler(req as never)
50
+
51
+ expect(response.status).toBe(200)
52
+ expect(update).not.toHaveBeenCalled()
53
+ })
54
+ })
@@ -0,0 +1,67 @@
1
+ import { readdirSync, readFileSync } from 'node:fs'
2
+ import { join } from 'node:path'
3
+ import { describe, expect, it } from 'vitest'
4
+ import { getNestedValue, translations } from '../components/TicketConversation/hooks/useTranslation'
5
+
6
+ function collectSourceFiles(directory: string): string[] {
7
+ return readdirSync(directory, { withFileTypes: true }).flatMap((entry) => {
8
+ const path = join(directory, entry.name)
9
+
10
+ if (entry.isDirectory()) return collectSourceFiles(path)
11
+ return /\.(?:ts|tsx)$/.test(entry.name) ? [path] : []
12
+ })
13
+ }
14
+
15
+ function collectTranslationKeys(): string[] {
16
+ const keys = new Set<string>()
17
+ const sourceDirectories = [join(process.cwd(), 'src/components'), join(process.cwd(), 'src/views')]
18
+ const translationCall = /\b(?:t|tFn)\(\s*(['"`])([^'"`]+)\1/g
19
+
20
+ for (const file of sourceDirectories.flatMap(collectSourceFiles)) {
21
+ const source = readFileSync(file, 'utf8')
22
+
23
+ for (const match of source.matchAll(translationCall)) {
24
+ keys.add(match[2])
25
+ }
26
+ }
27
+
28
+ return [...keys].sort()
29
+ }
30
+
31
+ function flattenCatalog(value: Record<string, unknown>, prefix = ''): string[] {
32
+ return Object.entries(value).flatMap(([key, entry]) => {
33
+ const path = prefix ? `${prefix}.${key}` : key
34
+
35
+ if (entry != null && typeof entry === 'object' && !Array.isArray(entry)) {
36
+ return flattenCatalog(entry as Record<string, unknown>, path)
37
+ }
38
+
39
+ return [path]
40
+ })
41
+ }
42
+
43
+ describe('support translations', () => {
44
+ const usedKeys = collectTranslationKeys()
45
+
46
+ it.each(['fr', 'en'] as const)('defines every literal key used by support views in %s', (locale) => {
47
+ const catalogKeys = new Set(flattenCatalog(translations[locale]))
48
+ const missingKeys = usedKeys.filter((key) => !catalogKeys.has(key))
49
+
50
+ expect(missingKeys).toEqual([])
51
+ })
52
+
53
+ it('keeps the French and English catalogs aligned', () => {
54
+ expect(flattenCatalog(translations.fr).sort()).toEqual(flattenCatalog(translations.en).sort())
55
+ })
56
+
57
+ it.each([
58
+ 'ticket.status.pending',
59
+ 'inbox.timeAgo.now',
60
+ 'detail.addTime',
61
+ 'detail.addTagBtn',
62
+ 'detail.billing.title',
63
+ ])('resolves the regression key %s', (key) => {
64
+ expect(getNestedValue(translations.fr, key)).not.toBe(key)
65
+ expect(getNestedValue(translations.en, key)).not.toBe(key)
66
+ })
67
+ })
@@ -0,0 +1,58 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import {
3
+ DEFAULT_INBOUND_EMAIL_LIMITS,
4
+ validateInboundEmailPayload,
5
+ verifySecret,
6
+ } from '../utils/webhookSecurity'
7
+ import { createProcessDigestsEndpoint } from '../endpoints/process-digests'
8
+ import { resolveSlugs } from '../utils/slugs'
9
+
10
+ describe('verifySecret', () => {
11
+ it('accepts an exact secret and rejects missing, malformed or different values', () => {
12
+ expect(verifySecret('expected-secret', 'expected-secret')).toBe(true)
13
+ expect(verifySecret(undefined, 'expected-secret')).toBe(false)
14
+ expect(verifySecret('short', 'expected-secret')).toBe(false)
15
+ expect(verifySecret('different-secret', 'expected-secret')).toBe(false)
16
+ })
17
+ })
18
+
19
+ describe('cron endpoint authentication', () => {
20
+ it('rejects missing, forged and query-string secrets', async () => {
21
+ process.env.CRON_SECRET = 'expected-secret'
22
+ const endpoint = createProcessDigestsEndpoint(resolveSlugs())
23
+ for (const request of [
24
+ { headers: new Headers(), url: 'http://localhost/api/support/process-digests' },
25
+ { headers: new Headers({ 'x-cron-secret': 'forged' }), url: 'http://localhost/api/support/process-digests' },
26
+ { headers: new Headers(), url: 'http://localhost/api/support/process-digests?secret=expected-secret' },
27
+ ]) {
28
+ const response = await endpoint.handler(request as never)
29
+ expect(response.status).toBe(401)
30
+ }
31
+ })
32
+ })
33
+
34
+ describe('inbound email limits', () => {
35
+ const base = { senderEmail: 'client@example.com', subject: 'Help', body: 'Hello' }
36
+
37
+ it('rejects requests larger than 25 MiB', () => {
38
+ expect(validateInboundEmailPayload(base, DEFAULT_INBOUND_EMAIL_LIMITS.maxRequestBytes + 1))
39
+ .toEqual({ code: 'request_too_large', status: 413 })
40
+ })
41
+
42
+ it('rejects more than 10 attachments', () => {
43
+ const attachments = Array.from({ length: 11 }, (_, index) => ({ filename: `${index}.txt`, size: 1 }))
44
+ expect(validateInboundEmailPayload({ ...base, attachments }))
45
+ .toEqual({ code: 'too_many_attachments', status: 413 })
46
+ })
47
+
48
+ it('rejects an attachment larger than 10 MiB', () => {
49
+ const attachments = [{ filename: 'large.zip', size: DEFAULT_INBOUND_EMAIL_LIMITS.maxAttachmentBytes + 1 }]
50
+ expect(validateInboundEmailPayload({ ...base, attachments }))
51
+ .toEqual({ code: 'attachment_too_large', status: 413 })
52
+ })
53
+
54
+ it('rejects oversized text fields', () => {
55
+ expect(validateInboundEmailPayload({ ...base, subject: 'x'.repeat(1_001) }))
56
+ .toEqual({ code: 'text_field_too_large', status: 413 })
57
+ })
58
+ })
@@ -1,5 +1,6 @@
1
1
  import type { CollectionConfig } from 'payload'
2
2
  import type { CollectionSlugs } from '../utils/slugs'
3
+ import { verifySecret } from '../utils/webhookSecurity'
3
4
 
4
5
  // ─── Collection factory ──────────────────────────────────
5
6
 
@@ -20,7 +21,7 @@ export function createPendingEmailsCollection(slugs: CollectionSlugs): Collectio
20
21
  // Allow admin users or webhook calls with secret header
21
22
  if (req.user?.collection === slugs.users) return true
22
23
  const webhookSecret = req.headers.get('x-webhook-secret')
23
- if (webhookSecret && process.env.SUPPORT_WEBHOOK_SECRET && webhookSecret === process.env.SUPPORT_WEBHOOK_SECRET) return true
24
+ if (verifySecret(webhookSecret, process.env.SUPPORT_WEBHOOK_SECRET)) return true
24
25
  return false
25
26
  },
26
27
  },