@consilioweb/payload-support 1.1.1 → 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.
- package/README.md +4 -0
- package/dist/components/TicketConversation/components/AISummaryPanel.cjs +22 -14
- package/dist/components/TicketConversation/components/AISummaryPanel.js +23 -16
- package/dist/components/TicketConversation/components/ActionPanels.cjs +5 -5
- package/dist/components/TicketConversation/components/ActionPanels.js +5 -5
- package/dist/components/TicketConversation/components/ClientBar.cjs +1 -1
- package/dist/components/TicketConversation/components/ClientBar.js +1 -1
- package/dist/components/TicketConversation/components/QuickActions.cjs +2 -2
- package/dist/components/TicketConversation/components/QuickActions.js +2 -2
- package/dist/components/TicketConversation/components/TicketHeader.cjs +1 -1
- package/dist/components/TicketConversation/components/TicketHeader.js +1 -1
- package/dist/components/TicketConversation/index.cjs +10 -10
- package/dist/components/TicketConversation/index.js +10 -10
- package/dist/index.cjs +709 -173
- package/dist/index.d.cts +165 -3
- package/dist/index.d.ts +165 -3
- package/dist/index.js +702 -175
- package/dist/styles/ChatView.module.scss +1 -1
- package/dist/styles/TicketDetail.module.scss +3 -3
- package/dist/styles/_tokens.scss +1 -1
- package/dist/styles/theme.css +6 -6
- package/dist/views/TicketingSettingsView/client.cjs +3 -3
- package/dist/views/TicketingSettingsView/client.js +3 -3
- package/package.json +9 -9
- package/src/__tests__/aiSummaryRendering.test.ts +21 -0
- package/src/__tests__/authResponses.test.ts +69 -0
- package/src/__tests__/capabilities.test.ts +71 -0
- package/src/__tests__/generateTrackingToken.test.ts +18 -4
- package/src/__tests__/integration/core-behaviors.test.ts +23 -0
- package/src/__tests__/rateLimiter.test.ts +60 -66
- package/src/__tests__/trackOpenEndpoint.test.ts +54 -0
- package/src/__tests__/webhookSecurity.test.ts +58 -0
- package/src/collections/PendingEmails.ts +2 -1
- package/src/collections/SupportClients.ts +43 -1
- package/src/collections/SupportCounters.ts +14 -0
- package/src/collections/SupportRateLimits.ts +20 -0
- package/src/collections/TicketMessages.ts +76 -0
- package/src/collections/Tickets.ts +179 -39
- package/src/collections/index.ts +2 -0
- package/src/components/TicketConversation/components/AISummaryPanel.tsx +26 -13
- package/src/components/TicketConversation/components/ActionPanels.tsx +5 -5
- package/src/components/TicketConversation/components/ClientBar.tsx +1 -1
- package/src/components/TicketConversation/components/QuickActions.tsx +2 -2
- package/src/components/TicketConversation/components/TicketHeader.tsx +1 -1
- package/src/components/TicketConversation/index.tsx +10 -10
- package/src/endpoints/admin-chat.ts +4 -4
- package/src/endpoints/ai-agent.ts +6 -1
- package/src/endpoints/ai.ts +10 -5
- package/src/endpoints/auth-2fa.ts +6 -7
- package/src/endpoints/auto-close.ts +2 -1
- package/src/endpoints/capabilities.ts +144 -0
- package/src/endpoints/channels.ts +2 -1
- package/src/endpoints/chat.ts +6 -6
- package/src/endpoints/chatbot.ts +4 -4
- package/src/endpoints/client-intelligence.ts +13 -4
- package/src/endpoints/import-conversation.ts +6 -5
- package/src/endpoints/index.ts +35 -14
- package/src/endpoints/invite-collaborator.ts +4 -4
- package/src/endpoints/login.ts +4 -5
- package/src/endpoints/oauth-google.ts +1 -1
- package/src/endpoints/process-digests.ts +2 -1
- package/src/endpoints/process-scheduled.ts +2 -1
- package/src/endpoints/process-snooze.ts +2 -1
- package/src/endpoints/resend-notification.ts +4 -4
- package/src/endpoints/send-reminder.ts +4 -4
- package/src/endpoints/ticket-synthesis.ts +17 -2
- package/src/endpoints/track-open.ts +44 -21
- package/src/endpoints/transfer-ticket.ts +4 -4
- package/src/index.ts +7 -0
- package/src/plugin.ts +16 -1
- package/src/portal/LiveChat.tsx +1 -1
- package/src/portal/auth/ChatWidget.tsx +4 -4
- package/src/portal/auth/ChatbotWidget.tsx +4 -4
- package/src/portal/auth/dashboard/DashboardClient.tsx +2 -2
- package/src/portal/auth/faq/FAQSearch.tsx +1 -1
- package/src/portal/auth/faq/page.tsx +2 -2
- package/src/portal/auth/profile/page.tsx +17 -17
- package/src/portal/auth/tickets/detail/CloseTicketButton.tsx +1 -1
- package/src/portal/auth/tickets/detail/MarkSolutionButton.tsx +1 -1
- package/src/portal/auth/tickets/detail/ReopenTicketButton.tsx +1 -1
- package/src/portal/auth/tickets/detail/SatisfactionForm.tsx +3 -3
- package/src/portal/auth/tickets/detail/TicketReplyForm.tsx +3 -3
- package/src/portal/auth/tickets/detail/page.tsx +5 -5
- package/src/portal/auth/tickets/new/page.tsx +8 -8
- package/src/portal/forgot-password/page.tsx +3 -3
- package/src/portal/layout.tsx +5 -2
- package/src/portal/login/page.tsx +7 -7
- package/src/portal/page.tsx +9 -9
- package/src/portal/register/page.tsx +4 -4
- package/src/portal/reset-password/page.tsx +3 -3
- package/src/styles/ChatView.module.scss +1 -1
- package/src/styles/TicketDetail.module.scss +3 -3
- package/src/styles/_tokens.scss +1 -1
- package/src/styles/theme.css +6 -6
- package/src/types.ts +71 -0
- package/src/utils/rateLimiter.ts +131 -38
- package/src/utils/slugs.ts +4 -0
- package/src/utils/webhookSecurity.ts +81 -0
- package/src/views/TicketingSettingsView/client.tsx +3 -3
|
@@ -531,7 +531,7 @@
|
|
|
531
531
|
gap: 8px;
|
|
532
532
|
padding: 8px 14px;
|
|
533
533
|
font-size: 12px;
|
|
534
|
-
color: #
|
|
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: #
|
|
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
|
-
//
|
|
615
|
+
// AI button — teal 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);
|
package/dist/styles/_tokens.scss
CHANGED
package/dist/styles/theme.css
CHANGED
|
@@ -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: #
|
|
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: #
|
|
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: #
|
|
468
|
+
--support-scheduled-bg: #eef8f7;
|
|
469
469
|
--support-scheduled-border: #a78bfa;
|
|
470
470
|
--support-scheduled-text: #5b21b6;
|
|
471
|
-
--support-scheduled-icon: #
|
|
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: #
|
|
619
|
-
--support-scheduled-text: #
|
|
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: "#
|
|
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: "#
|
|
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: "#
|
|
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: "#
|
|
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: "#
|
|
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: "#
|
|
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": "
|
|
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.
|
|
127
|
+
"@payloadcms/db-sqlite": "^3.86.0",
|
|
128
128
|
"@playwright/test": "^1.49.0",
|
|
129
|
-
"@payloadcms/graphql": "^3.
|
|
130
|
-
"@payloadcms/next": "^3.
|
|
131
|
-
"@payloadcms/richtext-lexical": "^3.
|
|
132
|
-
"@payloadcms/translations": "^3.
|
|
133
|
-
"@payloadcms/ui": "^3.
|
|
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": "^
|
|
140
|
-
"payload": "^3.
|
|
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('
|
|
11
|
+
it('returns the complete SHA-256 HMAC', () => {
|
|
12
12
|
const token = generateTrackingToken('1', '2', 'secret')
|
|
13
|
-
expect(token).toHaveLength(
|
|
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]{
|
|
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 {
|
|
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
|
|
9
|
-
it('allows the
|
|
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')
|
|
26
|
-
limiter.check('key')
|
|
27
|
-
|
|
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('
|
|
17
|
+
it('resets only the targeted key', async () => {
|
|
32
18
|
const limiter = new RateLimiter(60_000, 1)
|
|
33
|
-
limiter.check('key')
|
|
34
|
-
|
|
35
|
-
expect(limiter.check('key')).toBe(true)
|
|
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
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
limiter.check('key')
|
|
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('
|
|
49
|
-
|
|
50
|
-
limiter
|
|
51
|
-
limiter.check('
|
|
52
|
-
limiter.check('
|
|
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
|
-
|
|
56
|
-
|
|
57
|
-
expect(limiter.check('
|
|
36
|
+
vi.advanceTimersByTime(1_001)
|
|
37
|
+
|
|
38
|
+
await expect(limiter.check('key')).resolves.toBe(false)
|
|
58
39
|
})
|
|
59
|
-
})
|
|
60
40
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
const
|
|
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
|
-
|
|
68
|
-
|
|
69
|
-
|
|
46
|
+
await expect(firstProcess.check('shared')).resolves.toBe(false)
|
|
47
|
+
await expect(secondProcess.check('shared')).resolves.toBe(true)
|
|
48
|
+
})
|
|
70
49
|
|
|
71
|
-
|
|
72
|
-
vi.
|
|
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
|
-
|
|
75
|
-
expect(
|
|
58
|
+
await expect(limiter.check('client:42')).resolves.toBe(true)
|
|
59
|
+
expect(increment).toHaveBeenCalledWith('client:42', 1_000)
|
|
76
60
|
})
|
|
77
|
-
})
|
|
78
61
|
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
const
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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
|
-
|
|
87
|
-
expect(
|
|
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
|
+
})
|