@consilioweb/payload-support 1.1.0 → 2.0.0

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 (99) hide show
  1. package/README.md +4 -0
  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/index.cjs +10 -10
  13. package/dist/components/TicketConversation/index.js +10 -10
  14. package/dist/index.cjs +710 -174
  15. package/dist/index.d.cts +165 -3
  16. package/dist/index.d.ts +165 -3
  17. package/dist/index.js +703 -176
  18. package/dist/styles/ChatView.module.scss +1 -1
  19. package/dist/styles/TicketDetail.module.scss +3 -3
  20. package/dist/styles/_tokens.scss +1 -1
  21. package/dist/styles/theme.css +6 -6
  22. package/dist/views/TicketingSettingsView/client.cjs +3 -3
  23. package/dist/views/TicketingSettingsView/client.js +3 -3
  24. package/package.json +9 -9
  25. package/src/__tests__/aiSummaryRendering.test.ts +21 -0
  26. package/src/__tests__/authResponses.test.ts +69 -0
  27. package/src/__tests__/capabilities.test.ts +71 -0
  28. package/src/__tests__/generateTrackingToken.test.ts +18 -4
  29. package/src/__tests__/integration/core-behaviors.test.ts +23 -0
  30. package/src/__tests__/rateLimiter.test.ts +60 -66
  31. package/src/__tests__/trackOpenEndpoint.test.ts +54 -0
  32. package/src/__tests__/webhookSecurity.test.ts +58 -0
  33. package/src/collections/PendingEmails.ts +2 -1
  34. package/src/collections/SupportClients.ts +43 -1
  35. package/src/collections/SupportCounters.ts +14 -0
  36. package/src/collections/SupportRateLimits.ts +20 -0
  37. package/src/collections/TicketMessages.ts +76 -0
  38. package/src/collections/Tickets.ts +183 -40
  39. package/src/collections/index.ts +2 -0
  40. package/src/components/TicketConversation/components/AISummaryPanel.tsx +26 -13
  41. package/src/components/TicketConversation/components/ActionPanels.tsx +5 -5
  42. package/src/components/TicketConversation/components/ClientBar.tsx +1 -1
  43. package/src/components/TicketConversation/components/QuickActions.tsx +2 -2
  44. package/src/components/TicketConversation/components/TicketHeader.tsx +1 -1
  45. package/src/components/TicketConversation/index.tsx +10 -10
  46. package/src/endpoints/admin-chat.ts +4 -4
  47. package/src/endpoints/ai-agent.ts +6 -1
  48. package/src/endpoints/ai.ts +10 -5
  49. package/src/endpoints/auth-2fa.ts +6 -7
  50. package/src/endpoints/auto-close.ts +2 -1
  51. package/src/endpoints/capabilities.ts +144 -0
  52. package/src/endpoints/channels.ts +2 -1
  53. package/src/endpoints/chat.ts +6 -6
  54. package/src/endpoints/chatbot.ts +4 -4
  55. package/src/endpoints/client-intelligence.ts +13 -4
  56. package/src/endpoints/import-conversation.ts +6 -5
  57. package/src/endpoints/index.ts +35 -14
  58. package/src/endpoints/invite-collaborator.ts +4 -4
  59. package/src/endpoints/login.ts +4 -5
  60. package/src/endpoints/oauth-google.ts +1 -1
  61. package/src/endpoints/process-digests.ts +2 -1
  62. package/src/endpoints/process-scheduled.ts +2 -1
  63. package/src/endpoints/process-snooze.ts +2 -1
  64. package/src/endpoints/resend-notification.ts +4 -4
  65. package/src/endpoints/send-reminder.ts +4 -4
  66. package/src/endpoints/ticket-synthesis.ts +17 -2
  67. package/src/endpoints/track-open.ts +44 -21
  68. package/src/endpoints/transfer-ticket.ts +4 -4
  69. package/src/index.ts +7 -0
  70. package/src/plugin.ts +16 -1
  71. package/src/portal/LiveChat.tsx +1 -1
  72. package/src/portal/auth/ChatWidget.tsx +4 -4
  73. package/src/portal/auth/ChatbotWidget.tsx +4 -4
  74. package/src/portal/auth/dashboard/DashboardClient.tsx +2 -2
  75. package/src/portal/auth/faq/FAQSearch.tsx +1 -1
  76. package/src/portal/auth/faq/page.tsx +2 -2
  77. package/src/portal/auth/profile/page.tsx +17 -17
  78. package/src/portal/auth/tickets/detail/CloseTicketButton.tsx +1 -1
  79. package/src/portal/auth/tickets/detail/MarkSolutionButton.tsx +1 -1
  80. package/src/portal/auth/tickets/detail/ReopenTicketButton.tsx +1 -1
  81. package/src/portal/auth/tickets/detail/SatisfactionForm.tsx +3 -3
  82. package/src/portal/auth/tickets/detail/TicketReplyForm.tsx +3 -3
  83. package/src/portal/auth/tickets/detail/page.tsx +5 -5
  84. package/src/portal/auth/tickets/new/page.tsx +8 -8
  85. package/src/portal/forgot-password/page.tsx +3 -3
  86. package/src/portal/layout.tsx +5 -2
  87. package/src/portal/login/page.tsx +7 -7
  88. package/src/portal/page.tsx +9 -9
  89. package/src/portal/register/page.tsx +4 -4
  90. package/src/portal/reset-password/page.tsx +3 -3
  91. package/src/styles/ChatView.module.scss +1 -1
  92. package/src/styles/TicketDetail.module.scss +3 -3
  93. package/src/styles/_tokens.scss +1 -1
  94. package/src/styles/theme.css +6 -6
  95. package/src/types.ts +71 -0
  96. package/src/utils/rateLimiter.ts +131 -38
  97. package/src/utils/slugs.ts +4 -0
  98. package/src/utils/webhookSecurity.ts +81 -0
  99. package/src/views/TicketingSettingsView/client.tsx +3 -3
@@ -37,7 +37,7 @@ $font-stack: -apple-system, BlinkMacSystemFont, 'Inter', 'Segoe UI', system-ui,
37
37
  .container {
38
38
  display: flex;
39
39
  gap: 16px;
40
- height: calc(100vh - 220px);
40
+ height: calc(100dvh - 220px);
41
41
  min-height: 500px;
42
42
  }
43
43
 
@@ -531,7 +531,7 @@
531
531
  gap: 8px;
532
532
  padding: 8px 14px;
533
533
  font-size: 12px;
534
- color: #7c3aed;
534
+ color: #17807c;
535
535
  font-weight: 500;
536
536
  }
537
537
 
@@ -539,7 +539,7 @@
539
539
  display: flex;
540
540
  gap: 3px;
541
541
  span {
542
- width: 5px; height: 5px; border-radius: 50%; background: #7c3aed;
542
+ width: 5px; height: 5px; border-radius: 50%; background: #17807c;
543
543
  animation: bounce 1.2s infinite;
544
544
  &:nth-child(2) { animation-delay: 150ms; }
545
545
  &:nth-child(3) { animation-delay: 300ms; }
@@ -612,7 +612,7 @@
612
612
  &:disabled { opacity: 0.4; cursor: not-allowed; }
613
613
  }
614
614
 
615
- // Bouton IA — accent purple, pill style
615
+ // AI buttonteal accent, pill style
616
616
  .toolbarBtnAi {
617
617
  background: var(--theme-accent-50, color-mix(in srgb, var(--theme-accent-500, #6b46e8) 10%, transparent));
618
618
  border-color: color-mix(in srgb, var(--theme-accent-500, #6b46e8) 25%, transparent);
@@ -18,7 +18,7 @@ $green: #16a34a;
18
18
  $amber: #d97706;
19
19
  $orange: #ea580c;
20
20
  $red: #dc2626;
21
- $purple: #7c3aed;
21
+ $teal: #17807c;
22
22
  $cyan: #0891b2;
23
23
 
24
24
  // Mixins
@@ -136,7 +136,7 @@
136
136
  --support-cat-core: #2563eb;
137
137
  --support-cat-communication: #16a34a;
138
138
  --support-cat-productivity: #d97706;
139
- --support-cat-advanced: #7c3aed;
139
+ --support-cat-advanced: #17807c;
140
140
 
141
141
  /* ------------------------------------------
142
142
  * 8. PROJECT STATUS COLORS
@@ -385,7 +385,7 @@
385
385
  --support-activity-line-color: var(--support-border-color);
386
386
  --support-activity-dot-default: var(--support-text-muted);
387
387
  --support-activity-dot-status: var(--support-primary);
388
- --support-activity-dot-assign: #7c3aed;
388
+ --support-activity-dot-assign: #17807c;
389
389
  --support-activity-dot-merge: #0891b2;
390
390
 
391
391
  /* ------------------------------------------
@@ -465,10 +465,10 @@
465
465
  * Indicator for messages scheduled to be
466
466
  * sent at a future date/time.
467
467
  * ------------------------------------------ */
468
- --support-scheduled-bg: #f5f3ff;
468
+ --support-scheduled-bg: #eef8f7;
469
469
  --support-scheduled-border: #a78bfa;
470
470
  --support-scheduled-text: #5b21b6;
471
- --support-scheduled-icon: #7c3aed;
471
+ --support-scheduled-icon: #17807c;
472
472
 
473
473
  /* ------------------------------------------
474
474
  * 34. EMAIL TRACKING
@@ -615,8 +615,8 @@
615
615
 
616
616
  /* Scheduled */
617
617
  --support-scheduled-bg: #2e1065;
618
- --support-scheduled-border: #7c3aed;
619
- --support-scheduled-text: #c4b5fd;
618
+ --support-scheduled-border: #17807c;
619
+ --support-scheduled-text: #80bbb7;
620
620
 
621
621
  /* Email tracking */
622
622
  --support-email-pending: #64748b;
@@ -129,7 +129,7 @@ function getCategories(t) {
129
129
  { key: "core", label: t("settingsView.categories.core"), color: adminTokens.V.blue },
130
130
  { key: "communication", label: t("settingsView.categories.communication"), color: adminTokens.V.green },
131
131
  { key: "productivity", label: t("settingsView.categories.productivity"), color: adminTokens.V.amber },
132
- { key: "advanced", label: t("settingsView.categories.advanced"), color: "#7c3aed" }
132
+ { key: "advanced", label: t("settingsView.categories.advanced"), color: "#17807c" }
133
133
  ];
134
134
  }
135
135
  const Toggle = ({ checked, onChange, color = adminTokens.V.blue, size = "md" }) => {
@@ -421,7 +421,7 @@ const TicketingSettingsClient = () => {
421
421
  {
422
422
  title: t("settingsView.aiTitle"),
423
423
  icon: /* @__PURE__ */ jsxRuntime.jsx(lucideReact.Bot, { size: 16 }),
424
- color: "#7c3aed",
424
+ color: "#17807c",
425
425
  defaultOpen: false,
426
426
  badge: features.ai ? /* @__PURE__ */ jsxRuntime.jsx("span", { className: ts__default.default.badge, style: { backgroundColor: "#dcfce7", color: "#166534" }, children: t("settingsView.aiBadgeActive") }) : /* @__PURE__ */ jsxRuntime.jsx("span", { className: ts__default.default.badge, style: { backgroundColor: "#fee2e2", color: "#991b1b" }, children: t("settingsView.aiBadgeInactive") }),
427
427
  children: [
@@ -484,7 +484,7 @@ const TicketingSettingsClient = () => {
484
484
  {
485
485
  checked: settings.ai[item.key],
486
486
  onChange: () => updateAI(item.key, !settings.ai[item.key]),
487
- color: "#7c3aed",
487
+ color: "#17807c",
488
488
  size: "sm"
489
489
  }
490
490
  ),
@@ -123,7 +123,7 @@ function getCategories(t) {
123
123
  { key: "core", label: t("settingsView.categories.core"), color: V.blue },
124
124
  { key: "communication", label: t("settingsView.categories.communication"), color: V.green },
125
125
  { key: "productivity", label: t("settingsView.categories.productivity"), color: V.amber },
126
- { key: "advanced", label: t("settingsView.categories.advanced"), color: "#7c3aed" }
126
+ { key: "advanced", label: t("settingsView.categories.advanced"), color: "#17807c" }
127
127
  ];
128
128
  }
129
129
  const Toggle = ({ checked, onChange, color = V.blue, size = "md" }) => {
@@ -415,7 +415,7 @@ const TicketingSettingsClient = () => {
415
415
  {
416
416
  title: t("settingsView.aiTitle"),
417
417
  icon: /* @__PURE__ */ jsx(Bot, { size: 16 }),
418
- color: "#7c3aed",
418
+ color: "#17807c",
419
419
  defaultOpen: false,
420
420
  badge: features.ai ? /* @__PURE__ */ jsx("span", { className: ts.badge, style: { backgroundColor: "#dcfce7", color: "#166534" }, children: t("settingsView.aiBadgeActive") }) : /* @__PURE__ */ jsx("span", { className: ts.badge, style: { backgroundColor: "#fee2e2", color: "#991b1b" }, children: t("settingsView.aiBadgeInactive") }),
421
421
  children: [
@@ -478,7 +478,7 @@ const TicketingSettingsClient = () => {
478
478
  {
479
479
  checked: settings.ai[item.key],
480
480
  onChange: () => updateAI(item.key, !settings.ai[item.key]),
481
- color: "#7c3aed",
481
+ color: "#17807c",
482
482
  size: "sm"
483
483
  }
484
484
  ),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@consilioweb/payload-support",
3
- "version": "1.1.0",
3
+ "version": "2.0.0",
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",
@@ -124,20 +124,20 @@
124
124
  "node": ">=18"
125
125
  },
126
126
  "devDependencies": {
127
- "@payloadcms/db-sqlite": "^3.85.1",
127
+ "@payloadcms/db-sqlite": "^3.86.0",
128
128
  "@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",
129
+ "@payloadcms/graphql": "^3.86.0",
130
+ "@payloadcms/next": "^3.86.0",
131
+ "@payloadcms/richtext-lexical": "^3.86.0",
132
+ "@payloadcms/translations": "^3.86.0",
133
+ "@payloadcms/ui": "^3.86.0",
134
134
  "@types/pdfkit": "^0.17.6",
135
135
  "@types/react": "^19.0.0",
136
136
  "@types/sanitize-html": "^2.16.1",
137
137
  "@types/web-push": "^3.6.4",
138
138
  "esbuild-sass-plugin": "^3.7.0",
139
- "next": "^15.5.14",
140
- "payload": "^3.85.1",
139
+ "next": "^16.2.9",
140
+ "payload": "^3.86.0",
141
141
  "react": "^19.2.7",
142
142
  "react-dom": "^19.2.7",
143
143
  "tsup": "^8.0.0",
@@ -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
+ })