@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.
- package/README.md +74 -3
- 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/hooks/useTranslation.cjs +25 -2
- package/dist/components/TicketConversation/hooks/useTranslation.js +22 -3
- 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/SupportDashboardView/client.cjs +1 -1
- package/dist/views/SupportDashboardView/client.js +1 -1
- package/dist/views/TicketingSettingsView/client.cjs +4 -4
- package/dist/views/TicketingSettingsView/client.js +4 -4
- package/dist/views/shared/locales/en.json +708 -0
- package/dist/views/shared/locales/fr.json +708 -0
- package/package.json +19 -19
- 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__/translations.test.ts +67 -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/hooks/useTranslation.ts +38 -4
- 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/SupportDashboardView/client.tsx +1 -1
- package/src/views/TicketingSettingsView/client.tsx +4 -4
- package/src/views/shared/locales/en.json +2 -2
- 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": "
|
|
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.
|
|
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": "^
|
|
140
|
-
"payload": "^3.
|
|
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('
|
|
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
|
+
})
|
|
@@ -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
|
|
24
|
+
if (verifySecret(webhookSecret, process.env.SUPPORT_WEBHOOK_SECRET)) return true
|
|
24
25
|
return false
|
|
25
26
|
},
|
|
26
27
|
},
|