payload-plugin-newsletter 0.3.0 → 0.3.2

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 (56) hide show
  1. package/CHANGELOG.md +43 -0
  2. package/CLAUDE.md +110 -0
  3. package/README.md +25 -3
  4. package/TEST_SUMMARY.md +152 -0
  5. package/dist/.tsbuildinfo +1 -1
  6. package/dist/collections/NewsletterSettings.d.ts.map +1 -1
  7. package/dist/collections/Subscribers.d.ts.map +1 -1
  8. package/dist/src/__tests__/fixtures/newsletter-settings.js +41 -0
  9. package/dist/src/__tests__/fixtures/newsletter-settings.js.map +1 -0
  10. package/dist/src/__tests__/fixtures/subscribers.js +70 -0
  11. package/dist/src/__tests__/fixtures/subscribers.js.map +1 -0
  12. package/dist/src/__tests__/integration/collections/subscriber-hooks.test.js +356 -0
  13. package/dist/src/__tests__/integration/collections/subscriber-hooks.test.js.map +1 -0
  14. package/dist/src/__tests__/integration/endpoints/preferences.test.js +266 -0
  15. package/dist/src/__tests__/integration/endpoints/preferences.test.js.map +1 -0
  16. package/dist/src/__tests__/integration/endpoints/subscribe.test.js +280 -0
  17. package/dist/src/__tests__/integration/endpoints/subscribe.test.js.map +1 -0
  18. package/dist/src/__tests__/integration/endpoints/unsubscribe.test.js +187 -0
  19. package/dist/src/__tests__/integration/endpoints/unsubscribe.test.js.map +1 -0
  20. package/dist/src/__tests__/integration/endpoints/verify-magic-link.test.js +188 -0
  21. package/dist/src/__tests__/integration/endpoints/verify-magic-link.test.js.map +1 -0
  22. package/dist/src/__tests__/mocks/email-providers.js +153 -0
  23. package/dist/src/__tests__/mocks/email-providers.js.map +1 -0
  24. package/dist/src/__tests__/mocks/payload.js +244 -0
  25. package/dist/src/__tests__/mocks/payload.js.map +1 -0
  26. package/dist/src/__tests__/security/csrf-protection.test.js +309 -0
  27. package/dist/src/__tests__/security/csrf-protection.test.js.map +1 -0
  28. package/dist/src/__tests__/security/settings-access.test.js +204 -0
  29. package/dist/src/__tests__/security/settings-access.test.js.map +1 -0
  30. package/dist/src/__tests__/security/subscriber-access.test.js +210 -0
  31. package/dist/src/__tests__/security/subscriber-access.test.js.map +1 -0
  32. package/dist/src/__tests__/security/xss-prevention.test.js +305 -0
  33. package/dist/src/__tests__/security/xss-prevention.test.js.map +1 -0
  34. package/dist/src/__tests__/setup/integration.setup.js +38 -0
  35. package/dist/src/__tests__/setup/integration.setup.js.map +1 -0
  36. package/dist/src/__tests__/setup/unit.setup.js +41 -0
  37. package/dist/src/__tests__/setup/unit.setup.js.map +1 -0
  38. package/dist/src/__tests__/unit/utils/access.test.js +116 -0
  39. package/dist/src/__tests__/unit/utils/access.test.js.map +1 -0
  40. package/dist/src/__tests__/unit/utils/jwt.test.js +238 -0
  41. package/dist/src/__tests__/unit/utils/jwt.test.js.map +1 -0
  42. package/dist/src/collections/NewsletterSettings.js +4 -3
  43. package/dist/src/collections/NewsletterSettings.js.map +1 -1
  44. package/dist/src/collections/Subscribers.js +4 -39
  45. package/dist/src/collections/Subscribers.js.map +1 -1
  46. package/dist/src/types/index.js.map +1 -1
  47. package/dist/src/utils/access.js +80 -0
  48. package/dist/src/utils/access.js.map +1 -0
  49. package/dist/src/utils/validation.js +9 -1
  50. package/dist/src/utils/validation.js.map +1 -1
  51. package/dist/types/index.d.ts +11 -0
  52. package/dist/types/index.d.ts.map +1 -1
  53. package/dist/utils/access.d.ts +15 -0
  54. package/dist/utils/access.d.ts.map +1 -0
  55. package/dist/utils/validation.d.ts.map +1 -1
  56. package/package.json +22 -4
@@ -0,0 +1,266 @@
1
+ import { describe, it, expect, beforeEach, vi } from 'vitest';
2
+ import { createPreferencesEndpoint } from '../../../endpoints/preferences';
3
+ import { createPayloadRequestMock, seedCollection, clearCollections, createMockAdminUser } from '../../mocks/payload';
4
+ import { mockSubscribers } from '../../fixtures/subscribers';
5
+ describe('Preferences Endpoint Security', ()=>{
6
+ let endpoint;
7
+ let mockReq;
8
+ let mockRes;
9
+ const config = {
10
+ subscribersSlug: 'subscribers'
11
+ };
12
+ beforeEach(()=>{
13
+ clearCollections();
14
+ seedCollection('subscribers', mockSubscribers);
15
+ endpoint = createPreferencesEndpoint(config);
16
+ const payloadMock = createPayloadRequestMock();
17
+ mockReq = {
18
+ payload: payloadMock.payload,
19
+ body: {},
20
+ user: null,
21
+ method: 'GET'
22
+ };
23
+ mockRes = {
24
+ status: vi.fn().mockReturnThis(),
25
+ json: vi.fn()
26
+ };
27
+ vi.clearAllMocks();
28
+ });
29
+ describe('GET - Read Preferences', ()=>{
30
+ it('should require authentication', async ()=>{
31
+ await endpoint.handler(mockReq, mockRes);
32
+ expect(mockRes.status).toHaveBeenCalledWith(401);
33
+ expect(mockRes.json).toHaveBeenCalledWith({
34
+ success: false,
35
+ error: 'Authentication required'
36
+ });
37
+ });
38
+ it('should allow subscribers to read their own preferences', async ()=>{
39
+ mockReq.user = {
40
+ id: 'sub-1',
41
+ email: 'active@example.com',
42
+ collection: 'subscribers'
43
+ };
44
+ await endpoint.handler(mockReq, mockRes);
45
+ expect(mockRes.status).toHaveBeenCalledWith(200);
46
+ expect(mockRes.json).toHaveBeenCalledWith({
47
+ success: true,
48
+ preferences: expect.objectContaining({
49
+ email: 'active@example.com',
50
+ emailPreferences: {
51
+ newsletter: true,
52
+ announcements: true
53
+ }
54
+ })
55
+ });
56
+ });
57
+ it('should prevent reading other subscribers preferences', async ()=>{
58
+ mockReq.user = {
59
+ id: 'sub-1',
60
+ email: 'active@example.com',
61
+ collection: 'subscribers'
62
+ };
63
+ mockReq.query = {
64
+ subscriberId: 'sub-2'
65
+ };
66
+ await endpoint.handler(mockReq, mockRes);
67
+ expect(mockRes.status).toHaveBeenCalledWith(403);
68
+ expect(mockRes.json).toHaveBeenCalledWith({
69
+ success: false,
70
+ error: 'You can only access your own preferences'
71
+ });
72
+ });
73
+ it('should allow admins to read any preferences', async ()=>{
74
+ mockReq.user = createMockAdminUser();
75
+ mockReq.query = {
76
+ subscriberId: 'sub-2'
77
+ };
78
+ await endpoint.handler(mockReq, mockRes);
79
+ expect(mockRes.status).toHaveBeenCalledWith(200);
80
+ expect(mockRes.json).toHaveBeenCalledWith({
81
+ success: true,
82
+ preferences: expect.objectContaining({
83
+ email: 'pending@example.com'
84
+ })
85
+ });
86
+ });
87
+ });
88
+ describe('POST - Update Preferences', ()=>{
89
+ beforeEach(()=>{
90
+ mockReq.method = 'POST';
91
+ });
92
+ it('should require authentication', async ()=>{
93
+ mockReq.body = {
94
+ emailPreferences: {
95
+ newsletter: false
96
+ }
97
+ };
98
+ await endpoint.handler(mockReq, mockRes);
99
+ expect(mockRes.status).toHaveBeenCalledWith(401);
100
+ expect(mockRes.json).toHaveBeenCalledWith({
101
+ success: false,
102
+ error: 'Authentication required'
103
+ });
104
+ });
105
+ it('should allow subscribers to update their own preferences', async ()=>{
106
+ mockReq.user = {
107
+ id: 'sub-1',
108
+ email: 'active@example.com',
109
+ collection: 'subscribers'
110
+ };
111
+ mockReq.body = {
112
+ emailPreferences: {
113
+ newsletter: false,
114
+ announcements: true
115
+ }
116
+ };
117
+ await endpoint.handler(mockReq, mockRes);
118
+ expect(mockReq.payload.update).toHaveBeenCalledWith({
119
+ collection: 'subscribers',
120
+ id: 'sub-1',
121
+ data: {
122
+ emailPreferences: {
123
+ newsletter: false,
124
+ announcements: true
125
+ }
126
+ },
127
+ overrideAccess: false,
128
+ user: mockReq.user
129
+ });
130
+ expect(mockRes.status).toHaveBeenCalledWith(200);
131
+ });
132
+ it('should prevent updating other subscribers preferences', async ()=>{
133
+ mockReq.user = {
134
+ id: 'sub-1',
135
+ email: 'active@example.com',
136
+ collection: 'subscribers'
137
+ };
138
+ mockReq.body = {
139
+ subscriberId: 'sub-2',
140
+ emailPreferences: {
141
+ newsletter: false
142
+ }
143
+ };
144
+ await endpoint.handler(mockReq, mockRes);
145
+ expect(mockRes.status).toHaveBeenCalledWith(403);
146
+ expect(mockRes.json).toHaveBeenCalledWith({
147
+ success: false,
148
+ error: 'You can only update your own preferences'
149
+ });
150
+ });
151
+ it('should validate preference structure', async ()=>{
152
+ mockReq.user = {
153
+ id: 'sub-1',
154
+ email: 'active@example.com',
155
+ collection: 'subscribers'
156
+ };
157
+ mockReq.body = {
158
+ emailPreferences: {
159
+ newsletter: 'yes',
160
+ unknownField: true
161
+ }
162
+ };
163
+ await endpoint.handler(mockReq, mockRes);
164
+ expect(mockRes.status).toHaveBeenCalledWith(400);
165
+ expect(mockRes.json).toHaveBeenCalledWith({
166
+ success: false,
167
+ error: 'Invalid preference values'
168
+ });
169
+ });
170
+ it('should prevent updating protected fields', async ()=>{
171
+ mockReq.user = {
172
+ id: 'sub-1',
173
+ email: 'active@example.com',
174
+ collection: 'subscribers'
175
+ };
176
+ mockReq.body = {
177
+ email: 'newemail@example.com',
178
+ subscriptionStatus: 'active',
179
+ emailPreferences: {
180
+ newsletter: false
181
+ }
182
+ };
183
+ await endpoint.handler(mockReq, mockRes);
184
+ // Should only update emailPreferences
185
+ expect(mockReq.payload.update).toHaveBeenCalledWith(expect.objectContaining({
186
+ data: {
187
+ emailPreferences: {
188
+ newsletter: false,
189
+ announcements: true
190
+ }
191
+ }
192
+ }));
193
+ // Should not include protected fields
194
+ const updateData = mockReq.payload.update.mock.calls[0][0].data;
195
+ expect(updateData).not.toHaveProperty('email');
196
+ expect(updateData).not.toHaveProperty('subscriptionStatus');
197
+ });
198
+ });
199
+ describe('Error Handling', ()=>{
200
+ it('should handle non-existent subscribers', async ()=>{
201
+ mockReq.user = {
202
+ id: 'sub-999',
203
+ email: 'ghost@example.com',
204
+ collection: 'subscribers'
205
+ };
206
+ await endpoint.handler(mockReq, mockRes);
207
+ expect(mockRes.status).toHaveBeenCalledWith(404);
208
+ expect(mockRes.json).toHaveBeenCalledWith({
209
+ success: false,
210
+ error: 'Subscriber not found'
211
+ });
212
+ });
213
+ it('should handle database errors gracefully', async ()=>{
214
+ mockReq.user = {
215
+ id: 'sub-1',
216
+ email: 'active@example.com',
217
+ collection: 'subscribers'
218
+ };
219
+ mockReq.payload.findByID.mockRejectedValueOnce(new Error('Database error'));
220
+ await endpoint.handler(mockReq, mockRes);
221
+ expect(mockRes.status).toHaveBeenCalledWith(500);
222
+ expect(mockRes.json).toHaveBeenCalledWith({
223
+ success: false,
224
+ error: 'Failed to retrieve preferences'
225
+ });
226
+ });
227
+ });
228
+ describe('Unsubscribed Users', ()=>{
229
+ it('should allow unsubscribed users to view preferences', async ()=>{
230
+ mockReq.user = {
231
+ id: 'sub-3',
232
+ email: 'unsubscribed@example.com',
233
+ collection: 'subscribers'
234
+ };
235
+ await endpoint.handler(mockReq, mockRes);
236
+ expect(mockRes.status).toHaveBeenCalledWith(200);
237
+ expect(mockRes.json).toHaveBeenCalledWith({
238
+ success: true,
239
+ preferences: expect.objectContaining({
240
+ subscriptionStatus: 'unsubscribed'
241
+ })
242
+ });
243
+ });
244
+ it('should prevent unsubscribed users from updating preferences', async ()=>{
245
+ mockReq.method = 'POST';
246
+ mockReq.user = {
247
+ id: 'sub-3',
248
+ email: 'unsubscribed@example.com',
249
+ collection: 'subscribers'
250
+ };
251
+ mockReq.body = {
252
+ emailPreferences: {
253
+ newsletter: true
254
+ }
255
+ };
256
+ await endpoint.handler(mockReq, mockRes);
257
+ expect(mockRes.status).toHaveBeenCalledWith(403);
258
+ expect(mockRes.json).toHaveBeenCalledWith({
259
+ success: false,
260
+ error: 'Cannot update preferences for unsubscribed users'
261
+ });
262
+ });
263
+ });
264
+ });
265
+
266
+ //# sourceMappingURL=preferences.test.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../../../src/__tests__/integration/endpoints/preferences.test.ts"],"sourcesContent":["import { describe, it, expect, beforeEach, vi } from 'vitest'\nimport { createPreferencesEndpoint } from '../../../endpoints/preferences'\nimport { createPayloadRequestMock, seedCollection, clearCollections, createMockUser, createMockAdminUser } from '../../mocks/payload'\nimport { mockSubscribers } from '../../fixtures/subscribers'\nimport type { NewsletterPluginConfig } from '../../../types'\n\ndescribe('Preferences Endpoint Security', () => {\n let endpoint: any\n let mockReq: any\n let mockRes: any\n const config: NewsletterPluginConfig = {\n subscribersSlug: 'subscribers',\n }\n\n beforeEach(() => {\n clearCollections()\n seedCollection('subscribers', mockSubscribers)\n \n endpoint = createPreferencesEndpoint(config)\n const payloadMock = createPayloadRequestMock()\n \n mockReq = {\n payload: payloadMock.payload,\n body: {},\n user: null,\n method: 'GET',\n }\n \n mockRes = {\n status: vi.fn().mockReturnThis(),\n json: vi.fn(),\n }\n \n vi.clearAllMocks()\n })\n\n describe('GET - Read Preferences', () => {\n it('should require authentication', async () => {\n await endpoint.handler(mockReq, mockRes)\n \n expect(mockRes.status).toHaveBeenCalledWith(401)\n expect(mockRes.json).toHaveBeenCalledWith({\n success: false,\n error: 'Authentication required',\n })\n })\n\n it('should allow subscribers to read their own preferences', async () => {\n mockReq.user = {\n id: 'sub-1',\n email: 'active@example.com',\n collection: 'subscribers',\n }\n \n await endpoint.handler(mockReq, mockRes)\n \n expect(mockRes.status).toHaveBeenCalledWith(200)\n expect(mockRes.json).toHaveBeenCalledWith({\n success: true,\n preferences: expect.objectContaining({\n email: 'active@example.com',\n emailPreferences: {\n newsletter: true,\n announcements: true,\n },\n }),\n })\n })\n\n it('should prevent reading other subscribers preferences', async () => {\n mockReq.user = {\n id: 'sub-1',\n email: 'active@example.com',\n collection: 'subscribers',\n }\n mockReq.query = { subscriberId: 'sub-2' }\n \n await endpoint.handler(mockReq, mockRes)\n \n expect(mockRes.status).toHaveBeenCalledWith(403)\n expect(mockRes.json).toHaveBeenCalledWith({\n success: false,\n error: 'You can only access your own preferences',\n })\n })\n\n it('should allow admins to read any preferences', async () => {\n mockReq.user = createMockAdminUser()\n mockReq.query = { subscriberId: 'sub-2' }\n \n await endpoint.handler(mockReq, mockRes)\n \n expect(mockRes.status).toHaveBeenCalledWith(200)\n expect(mockRes.json).toHaveBeenCalledWith({\n success: true,\n preferences: expect.objectContaining({\n email: 'pending@example.com',\n }),\n })\n })\n })\n\n describe('POST - Update Preferences', () => {\n beforeEach(() => {\n mockReq.method = 'POST'\n })\n\n it('should require authentication', async () => {\n mockReq.body = {\n emailPreferences: {\n newsletter: false,\n },\n }\n \n await endpoint.handler(mockReq, mockRes)\n \n expect(mockRes.status).toHaveBeenCalledWith(401)\n expect(mockRes.json).toHaveBeenCalledWith({\n success: false,\n error: 'Authentication required',\n })\n })\n\n it('should allow subscribers to update their own preferences', async () => {\n mockReq.user = {\n id: 'sub-1',\n email: 'active@example.com',\n collection: 'subscribers',\n }\n mockReq.body = {\n emailPreferences: {\n newsletter: false,\n announcements: true,\n },\n }\n \n await endpoint.handler(mockReq, mockRes)\n \n expect(mockReq.payload.update).toHaveBeenCalledWith({\n collection: 'subscribers',\n id: 'sub-1',\n data: {\n emailPreferences: {\n newsletter: false,\n announcements: true,\n },\n },\n overrideAccess: false,\n user: mockReq.user,\n })\n \n expect(mockRes.status).toHaveBeenCalledWith(200)\n })\n\n it('should prevent updating other subscribers preferences', async () => {\n mockReq.user = {\n id: 'sub-1',\n email: 'active@example.com',\n collection: 'subscribers',\n }\n mockReq.body = {\n subscriberId: 'sub-2',\n emailPreferences: {\n newsletter: false,\n },\n }\n \n await endpoint.handler(mockReq, mockRes)\n \n expect(mockRes.status).toHaveBeenCalledWith(403)\n expect(mockRes.json).toHaveBeenCalledWith({\n success: false,\n error: 'You can only update your own preferences',\n })\n })\n\n it('should validate preference structure', async () => {\n mockReq.user = {\n id: 'sub-1',\n email: 'active@example.com',\n collection: 'subscribers',\n }\n mockReq.body = {\n emailPreferences: {\n newsletter: 'yes', // Should be boolean\n unknownField: true, // Should be filtered out\n },\n }\n \n await endpoint.handler(mockReq, mockRes)\n \n expect(mockRes.status).toHaveBeenCalledWith(400)\n expect(mockRes.json).toHaveBeenCalledWith({\n success: false,\n error: 'Invalid preference values',\n })\n })\n\n it('should prevent updating protected fields', async () => {\n mockReq.user = {\n id: 'sub-1',\n email: 'active@example.com',\n collection: 'subscribers',\n }\n mockReq.body = {\n email: 'newemail@example.com', // Should not be allowed\n subscriptionStatus: 'active', // Should not be allowed\n emailPreferences: {\n newsletter: false,\n },\n }\n \n await endpoint.handler(mockReq, mockRes)\n \n // Should only update emailPreferences\n expect(mockReq.payload.update).toHaveBeenCalledWith(\n expect.objectContaining({\n data: {\n emailPreferences: {\n newsletter: false,\n announcements: true, // Preserves existing value\n },\n },\n })\n )\n \n // Should not include protected fields\n const updateData = mockReq.payload.update.mock.calls[0][0].data\n expect(updateData).not.toHaveProperty('email')\n expect(updateData).not.toHaveProperty('subscriptionStatus')\n })\n })\n\n describe('Error Handling', () => {\n it('should handle non-existent subscribers', async () => {\n mockReq.user = {\n id: 'sub-999',\n email: 'ghost@example.com',\n collection: 'subscribers',\n }\n \n await endpoint.handler(mockReq, mockRes)\n \n expect(mockRes.status).toHaveBeenCalledWith(404)\n expect(mockRes.json).toHaveBeenCalledWith({\n success: false,\n error: 'Subscriber not found',\n })\n })\n\n it('should handle database errors gracefully', async () => {\n mockReq.user = {\n id: 'sub-1',\n email: 'active@example.com',\n collection: 'subscribers',\n }\n \n mockReq.payload.findByID.mockRejectedValueOnce(new Error('Database error'))\n \n await endpoint.handler(mockReq, mockRes)\n \n expect(mockRes.status).toHaveBeenCalledWith(500)\n expect(mockRes.json).toHaveBeenCalledWith({\n success: false,\n error: 'Failed to retrieve preferences',\n })\n })\n })\n\n describe('Unsubscribed Users', () => {\n it('should allow unsubscribed users to view preferences', async () => {\n mockReq.user = {\n id: 'sub-3',\n email: 'unsubscribed@example.com',\n collection: 'subscribers',\n }\n \n await endpoint.handler(mockReq, mockRes)\n \n expect(mockRes.status).toHaveBeenCalledWith(200)\n expect(mockRes.json).toHaveBeenCalledWith({\n success: true,\n preferences: expect.objectContaining({\n subscriptionStatus: 'unsubscribed',\n }),\n })\n })\n\n it('should prevent unsubscribed users from updating preferences', async () => {\n mockReq.method = 'POST'\n mockReq.user = {\n id: 'sub-3',\n email: 'unsubscribed@example.com',\n collection: 'subscribers',\n }\n mockReq.body = {\n emailPreferences: {\n newsletter: true,\n },\n }\n \n await endpoint.handler(mockReq, mockRes)\n \n expect(mockRes.status).toHaveBeenCalledWith(403)\n expect(mockRes.json).toHaveBeenCalledWith({\n success: false,\n error: 'Cannot update preferences for unsubscribed users',\n })\n })\n })\n})"],"names":["describe","it","expect","beforeEach","vi","createPreferencesEndpoint","createPayloadRequestMock","seedCollection","clearCollections","createMockAdminUser","mockSubscribers","endpoint","mockReq","mockRes","config","subscribersSlug","payloadMock","payload","body","user","method","status","fn","mockReturnThis","json","clearAllMocks","handler","toHaveBeenCalledWith","success","error","id","email","collection","preferences","objectContaining","emailPreferences","newsletter","announcements","query","subscriberId","update","data","overrideAccess","unknownField","subscriptionStatus","updateData","mock","calls","not","toHaveProperty","findByID","mockRejectedValueOnce","Error"],"mappings":"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"}
@@ -0,0 +1,280 @@
1
+ import { describe, it, expect, beforeEach, vi } from 'vitest';
2
+ import { createSubscribeEndpoint } from '../../../endpoints/subscribe';
3
+ import { createPayloadRequestMock, seedCollection, clearCollections } from '../../mocks/payload';
4
+ import { mockNewsletterSettings } from '../../fixtures/newsletter-settings';
5
+ import { createResendMock } from '../../mocks/email-providers';
6
+ // Mock email service
7
+ vi.mock('../../../services/email', ()=>({
8
+ getEmailService: vi.fn()
9
+ }));
10
+ import { getEmailService } from '../../../services/email';
11
+ describe('Subscribe Endpoint Security', ()=>{
12
+ let endpoint;
13
+ let mockReq;
14
+ let mockRes;
15
+ let mockEmailService;
16
+ const config = {
17
+ subscribersSlug: 'subscribers',
18
+ settingsSlug: 'newsletter-settings'
19
+ };
20
+ beforeEach(()=>{
21
+ clearCollections();
22
+ seedCollection('newsletter-settings', [
23
+ mockNewsletterSettings
24
+ ]);
25
+ endpoint = createSubscribeEndpoint(config);
26
+ const payloadMock = createPayloadRequestMock();
27
+ mockReq = {
28
+ payload: payloadMock.payload,
29
+ body: {},
30
+ ip: '127.0.0.1'
31
+ };
32
+ mockRes = {
33
+ status: vi.fn().mockReturnThis(),
34
+ json: vi.fn()
35
+ };
36
+ // Setup email service mock
37
+ mockEmailService = createResendMock();
38
+ getEmailService.mockResolvedValue(mockEmailService);
39
+ vi.clearAllMocks();
40
+ });
41
+ describe('Input Validation', ()=>{
42
+ it('should reject requests without email', async ()=>{
43
+ await endpoint.handler(mockReq, mockRes);
44
+ expect(mockRes.status).toHaveBeenCalledWith(400);
45
+ expect(mockRes.json).toHaveBeenCalledWith({
46
+ success: false,
47
+ error: 'Email is required'
48
+ });
49
+ });
50
+ it('should reject invalid email formats', async ()=>{
51
+ const invalidEmails = [
52
+ 'notanemail',
53
+ '@example.com',
54
+ 'user@',
55
+ 'user @example.com',
56
+ 'user@example',
57
+ '<script>alert("xss")</script>@example.com'
58
+ ];
59
+ for (const email of invalidEmails){
60
+ mockReq.body = {
61
+ email
62
+ };
63
+ await endpoint.handler(mockReq, mockRes);
64
+ expect(mockRes.status).toHaveBeenCalledWith(400);
65
+ expect(mockRes.json).toHaveBeenCalledWith({
66
+ success: false,
67
+ error: 'Invalid email format'
68
+ });
69
+ }
70
+ });
71
+ it('should sanitize email input', async ()=>{
72
+ mockReq.body = {
73
+ email: ' User@EXAMPLE.com ',
74
+ name: '<script>alert("xss")</script>Test User'
75
+ };
76
+ await endpoint.handler(mockReq, mockRes);
77
+ // Check that create was called with sanitized data
78
+ expect(mockReq.payload.create).toHaveBeenCalledWith(expect.objectContaining({
79
+ data: expect.objectContaining({
80
+ email: 'user@example.com',
81
+ name: 'Test User'
82
+ })
83
+ }));
84
+ });
85
+ });
86
+ describe('Rate Limiting', ()=>{
87
+ it('should enforce max subscribers per IP', async ()=>{
88
+ // Create max subscribers from same IP
89
+ const maxSubscribers = mockNewsletterSettings.subscriptionSettings.maxSubscribersPerIP;
90
+ for(let i = 0; i < maxSubscribers; i++){
91
+ seedCollection('subscribers', [
92
+ {
93
+ id: `sub-ip-${i}`,
94
+ email: `user${i}@example.com`,
95
+ ip: '127.0.0.1',
96
+ subscriptionStatus: 'active'
97
+ }
98
+ ]);
99
+ }
100
+ mockReq.body = {
101
+ email: 'newuser@example.com'
102
+ };
103
+ await endpoint.handler(mockReq, mockRes);
104
+ expect(mockRes.status).toHaveBeenCalledWith(429);
105
+ expect(mockRes.json).toHaveBeenCalledWith({
106
+ success: false,
107
+ error: 'Too many subscription attempts from this IP address'
108
+ });
109
+ });
110
+ it('should not count unsubscribed users in rate limit', async ()=>{
111
+ // Create some unsubscribed users
112
+ for(let i = 0; i < 5; i++){
113
+ seedCollection('subscribers', [
114
+ {
115
+ id: `sub-unsub-${i}`,
116
+ email: `unsub${i}@example.com`,
117
+ ip: '127.0.0.1',
118
+ subscriptionStatus: 'unsubscribed'
119
+ }
120
+ ]);
121
+ }
122
+ mockReq.body = {
123
+ email: 'newuser@example.com'
124
+ };
125
+ await endpoint.handler(mockReq, mockRes);
126
+ expect(mockRes.status).toHaveBeenCalledWith(200);
127
+ });
128
+ });
129
+ describe('Domain Restrictions', ()=>{
130
+ it('should enforce allowed domains when configured', async ()=>{
131
+ // Update settings to restrict domains
132
+ const restrictedSettings = {
133
+ ...mockNewsletterSettings,
134
+ subscriptionSettings: {
135
+ ...mockNewsletterSettings.subscriptionSettings,
136
+ allowedDomains: [
137
+ {
138
+ domain: 'allowed.com'
139
+ },
140
+ {
141
+ domain: 'company.com'
142
+ }
143
+ ]
144
+ }
145
+ };
146
+ clearCollections();
147
+ seedCollection('newsletter-settings', [
148
+ restrictedSettings
149
+ ]);
150
+ // Test blocked domain
151
+ mockReq.body = {
152
+ email: 'user@blocked.com'
153
+ };
154
+ await endpoint.handler(mockReq, mockRes);
155
+ expect(mockRes.status).toHaveBeenCalledWith(403);
156
+ expect(mockRes.json).toHaveBeenCalledWith({
157
+ success: false,
158
+ error: 'Email domain not allowed'
159
+ });
160
+ // Test allowed domain
161
+ mockReq.body = {
162
+ email: 'user@allowed.com'
163
+ };
164
+ await endpoint.handler(mockReq, mockRes);
165
+ expect(mockRes.status).toHaveBeenCalledWith(200);
166
+ });
167
+ });
168
+ describe('Duplicate Prevention', ()=>{
169
+ it('should handle existing active subscribers', async ()=>{
170
+ seedCollection('subscribers', [
171
+ {
172
+ id: 'existing-sub',
173
+ email: 'existing@example.com',
174
+ subscriptionStatus: 'active'
175
+ }
176
+ ]);
177
+ mockReq.body = {
178
+ email: 'existing@example.com'
179
+ };
180
+ await endpoint.handler(mockReq, mockRes);
181
+ expect(mockRes.status).toHaveBeenCalledWith(409);
182
+ expect(mockRes.json).toHaveBeenCalledWith({
183
+ success: false,
184
+ error: 'This email is already subscribed'
185
+ });
186
+ });
187
+ it('should allow resubscription of unsubscribed users', async ()=>{
188
+ seedCollection('subscribers', [
189
+ {
190
+ id: 'unsub-user',
191
+ email: 'comeback@example.com',
192
+ subscriptionStatus: 'unsubscribed'
193
+ }
194
+ ]);
195
+ mockReq.body = {
196
+ email: 'comeback@example.com'
197
+ };
198
+ await endpoint.handler(mockReq, mockRes);
199
+ expect(mockReq.payload.update).toHaveBeenCalledWith(expect.objectContaining({
200
+ id: 'unsub-user',
201
+ data: expect.objectContaining({
202
+ subscriptionStatus: 'pending',
203
+ unsubscribedAt: null
204
+ })
205
+ }));
206
+ expect(mockRes.status).toHaveBeenCalledWith(200);
207
+ });
208
+ });
209
+ describe('Double Opt-In', ()=>{
210
+ it('should send confirmation email when double opt-in is enabled', async ()=>{
211
+ mockReq.body = {
212
+ email: 'newuser@example.com'
213
+ };
214
+ await endpoint.handler(mockReq, mockRes);
215
+ expect(mockEmailService.emails.send).toHaveBeenCalledWith(expect.objectContaining({
216
+ to: [
217
+ 'newuser@example.com'
218
+ ],
219
+ subject: expect.stringContaining('Welcome')
220
+ }));
221
+ expect(mockRes.json).toHaveBeenCalledWith({
222
+ success: true,
223
+ message: 'Please check your email to confirm your subscription',
224
+ requiresConfirmation: true
225
+ });
226
+ });
227
+ it('should activate immediately when double opt-in is disabled', async ()=>{
228
+ // Disable double opt-in
229
+ const noDoubleOptIn = {
230
+ ...mockNewsletterSettings,
231
+ subscriptionSettings: {
232
+ ...mockNewsletterSettings.subscriptionSettings,
233
+ requireDoubleOptIn: false
234
+ }
235
+ };
236
+ clearCollections();
237
+ seedCollection('newsletter-settings', [
238
+ noDoubleOptIn
239
+ ]);
240
+ mockReq.body = {
241
+ email: 'instant@example.com'
242
+ };
243
+ await endpoint.handler(mockReq, mockRes);
244
+ expect(mockReq.payload.create).toHaveBeenCalledWith(expect.objectContaining({
245
+ data: expect.objectContaining({
246
+ subscriptionStatus: 'active'
247
+ })
248
+ }));
249
+ });
250
+ });
251
+ describe('Error Handling', ()=>{
252
+ it('should handle email service failures gracefully', async ()=>{
253
+ mockEmailService.emails.send.mockRejectedValueOnce(new Error('Email service down'));
254
+ mockReq.body = {
255
+ email: 'test@example.com'
256
+ };
257
+ await endpoint.handler(mockReq, mockRes);
258
+ expect(mockRes.status).toHaveBeenCalledWith(500);
259
+ expect(mockRes.json).toHaveBeenCalledWith({
260
+ success: false,
261
+ error: 'Failed to process subscription. Please try again later.'
262
+ });
263
+ });
264
+ it('should not leak internal errors to users', async ()=>{
265
+ mockReq.payload.create.mockRejectedValueOnce(new Error('Database connection failed'));
266
+ mockReq.body = {
267
+ email: 'test@example.com'
268
+ };
269
+ await endpoint.handler(mockReq, mockRes);
270
+ expect(mockRes.status).toHaveBeenCalledWith(500);
271
+ expect(mockRes.json).toHaveBeenCalledWith({
272
+ success: false,
273
+ error: 'Failed to process subscription. Please try again later.'
274
+ });
275
+ // Should not expose database error details
276
+ });
277
+ });
278
+ });
279
+
280
+ //# sourceMappingURL=subscribe.test.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../../../src/__tests__/integration/endpoints/subscribe.test.ts"],"sourcesContent":["import { describe, it, expect, beforeEach, vi } from 'vitest'\nimport { createSubscribeEndpoint } from '../../../endpoints/subscribe'\nimport { createPayloadRequestMock, seedCollection, clearCollections } from '../../mocks/payload'\nimport { mockNewsletterSettings } from '../../fixtures/newsletter-settings'\nimport { createResendMock } from '../../mocks/email-providers'\nimport type { NewsletterPluginConfig } from '../../../types'\n\n// Mock email service\nvi.mock('../../../services/email', () => ({\n getEmailService: vi.fn(),\n}))\n\nimport { getEmailService } from '../../../services/email'\n\ndescribe('Subscribe Endpoint Security', () => {\n let endpoint: any\n let mockReq: any\n let mockRes: any\n let mockEmailService: any\n const config: NewsletterPluginConfig = {\n subscribersSlug: 'subscribers',\n settingsSlug: 'newsletter-settings',\n }\n\n beforeEach(() => {\n clearCollections()\n seedCollection('newsletter-settings', [mockNewsletterSettings])\n \n endpoint = createSubscribeEndpoint(config)\n const payloadMock = createPayloadRequestMock()\n \n mockReq = {\n payload: payloadMock.payload,\n body: {},\n ip: '127.0.0.1',\n }\n \n mockRes = {\n status: vi.fn().mockReturnThis(),\n json: vi.fn(),\n }\n \n // Setup email service mock\n mockEmailService = createResendMock()\n ;(getEmailService as any).mockResolvedValue(mockEmailService)\n \n vi.clearAllMocks()\n })\n\n describe('Input Validation', () => {\n it('should reject requests without email', async () => {\n await endpoint.handler(mockReq, mockRes)\n \n expect(mockRes.status).toHaveBeenCalledWith(400)\n expect(mockRes.json).toHaveBeenCalledWith({\n success: false,\n error: 'Email is required',\n })\n })\n\n it('should reject invalid email formats', async () => {\n const invalidEmails = [\n 'notanemail',\n '@example.com',\n 'user@',\n 'user @example.com',\n 'user@example',\n '<script>alert(\"xss\")</script>@example.com',\n ]\n\n for (const email of invalidEmails) {\n mockReq.body = { email }\n await endpoint.handler(mockReq, mockRes)\n \n expect(mockRes.status).toHaveBeenCalledWith(400)\n expect(mockRes.json).toHaveBeenCalledWith({\n success: false,\n error: 'Invalid email format',\n })\n }\n })\n\n it('should sanitize email input', async () => {\n mockReq.body = { \n email: ' User@EXAMPLE.com ',\n name: '<script>alert(\"xss\")</script>Test User',\n }\n \n await endpoint.handler(mockReq, mockRes)\n \n // Check that create was called with sanitized data\n expect(mockReq.payload.create).toHaveBeenCalledWith(\n expect.objectContaining({\n data: expect.objectContaining({\n email: 'user@example.com', // Trimmed and lowercased\n name: 'Test User', // XSS stripped\n }),\n })\n )\n })\n })\n\n describe('Rate Limiting', () => {\n it('should enforce max subscribers per IP', async () => {\n // Create max subscribers from same IP\n const maxSubscribers = mockNewsletterSettings.subscriptionSettings.maxSubscribersPerIP\n \n for (let i = 0; i < maxSubscribers; i++) {\n seedCollection('subscribers', [{\n id: `sub-ip-${i}`,\n email: `user${i}@example.com`,\n ip: '127.0.0.1',\n subscriptionStatus: 'active',\n }])\n }\n\n mockReq.body = { email: 'newuser@example.com' }\n await endpoint.handler(mockReq, mockRes)\n \n expect(mockRes.status).toHaveBeenCalledWith(429)\n expect(mockRes.json).toHaveBeenCalledWith({\n success: false,\n error: 'Too many subscription attempts from this IP address',\n })\n })\n\n it('should not count unsubscribed users in rate limit', async () => {\n // Create some unsubscribed users\n for (let i = 0; i < 5; i++) {\n seedCollection('subscribers', [{\n id: `sub-unsub-${i}`,\n email: `unsub${i}@example.com`,\n ip: '127.0.0.1',\n subscriptionStatus: 'unsubscribed',\n }])\n }\n\n mockReq.body = { email: 'newuser@example.com' }\n await endpoint.handler(mockReq, mockRes)\n \n expect(mockRes.status).toHaveBeenCalledWith(200)\n })\n })\n\n describe('Domain Restrictions', () => {\n it('should enforce allowed domains when configured', async () => {\n // Update settings to restrict domains\n const restrictedSettings = {\n ...mockNewsletterSettings,\n subscriptionSettings: {\n ...mockNewsletterSettings.subscriptionSettings,\n allowedDomains: [\n { domain: 'allowed.com' },\n { domain: 'company.com' },\n ],\n },\n }\n clearCollections()\n seedCollection('newsletter-settings', [restrictedSettings])\n\n // Test blocked domain\n mockReq.body = { email: 'user@blocked.com' }\n await endpoint.handler(mockReq, mockRes)\n \n expect(mockRes.status).toHaveBeenCalledWith(403)\n expect(mockRes.json).toHaveBeenCalledWith({\n success: false,\n error: 'Email domain not allowed',\n })\n\n // Test allowed domain\n mockReq.body = { email: 'user@allowed.com' }\n await endpoint.handler(mockReq, mockRes)\n \n expect(mockRes.status).toHaveBeenCalledWith(200)\n })\n })\n\n describe('Duplicate Prevention', () => {\n it('should handle existing active subscribers', async () => {\n seedCollection('subscribers', [{\n id: 'existing-sub',\n email: 'existing@example.com',\n subscriptionStatus: 'active',\n }])\n\n mockReq.body = { email: 'existing@example.com' }\n await endpoint.handler(mockReq, mockRes)\n \n expect(mockRes.status).toHaveBeenCalledWith(409)\n expect(mockRes.json).toHaveBeenCalledWith({\n success: false,\n error: 'This email is already subscribed',\n })\n })\n\n it('should allow resubscription of unsubscribed users', async () => {\n seedCollection('subscribers', [{\n id: 'unsub-user',\n email: 'comeback@example.com',\n subscriptionStatus: 'unsubscribed',\n }])\n\n mockReq.body = { email: 'comeback@example.com' }\n await endpoint.handler(mockReq, mockRes)\n \n expect(mockReq.payload.update).toHaveBeenCalledWith(\n expect.objectContaining({\n id: 'unsub-user',\n data: expect.objectContaining({\n subscriptionStatus: 'pending',\n unsubscribedAt: null,\n }),\n })\n )\n expect(mockRes.status).toHaveBeenCalledWith(200)\n })\n })\n\n describe('Double Opt-In', () => {\n it('should send confirmation email when double opt-in is enabled', async () => {\n mockReq.body = { email: 'newuser@example.com' }\n await endpoint.handler(mockReq, mockRes)\n \n expect(mockEmailService.emails.send).toHaveBeenCalledWith(\n expect.objectContaining({\n to: ['newuser@example.com'],\n subject: expect.stringContaining('Welcome'),\n })\n )\n \n expect(mockRes.json).toHaveBeenCalledWith({\n success: true,\n message: 'Please check your email to confirm your subscription',\n requiresConfirmation: true,\n })\n })\n\n it('should activate immediately when double opt-in is disabled', async () => {\n // Disable double opt-in\n const noDoubleOptIn = {\n ...mockNewsletterSettings,\n subscriptionSettings: {\n ...mockNewsletterSettings.subscriptionSettings,\n requireDoubleOptIn: false,\n },\n }\n clearCollections()\n seedCollection('newsletter-settings', [noDoubleOptIn])\n\n mockReq.body = { email: 'instant@example.com' }\n await endpoint.handler(mockReq, mockRes)\n \n expect(mockReq.payload.create).toHaveBeenCalledWith(\n expect.objectContaining({\n data: expect.objectContaining({\n subscriptionStatus: 'active',\n }),\n })\n )\n })\n })\n\n describe('Error Handling', () => {\n it('should handle email service failures gracefully', async () => {\n mockEmailService.emails.send.mockRejectedValueOnce(new Error('Email service down'))\n\n mockReq.body = { email: 'test@example.com' }\n await endpoint.handler(mockReq, mockRes)\n \n expect(mockRes.status).toHaveBeenCalledWith(500)\n expect(mockRes.json).toHaveBeenCalledWith({\n success: false,\n error: 'Failed to process subscription. Please try again later.',\n })\n })\n\n it('should not leak internal errors to users', async () => {\n mockReq.payload.create.mockRejectedValueOnce(new Error('Database connection failed'))\n\n mockReq.body = { email: 'test@example.com' }\n await endpoint.handler(mockReq, mockRes)\n \n expect(mockRes.status).toHaveBeenCalledWith(500)\n expect(mockRes.json).toHaveBeenCalledWith({\n success: false,\n error: 'Failed to process subscription. Please try again later.',\n })\n // Should not expose database error details\n })\n })\n})"],"names":["describe","it","expect","beforeEach","vi","createSubscribeEndpoint","createPayloadRequestMock","seedCollection","clearCollections","mockNewsletterSettings","createResendMock","mock","getEmailService","fn","endpoint","mockReq","mockRes","mockEmailService","config","subscribersSlug","settingsSlug","payloadMock","payload","body","ip","status","mockReturnThis","json","mockResolvedValue","clearAllMocks","handler","toHaveBeenCalledWith","success","error","invalidEmails","email","name","create","objectContaining","data","maxSubscribers","subscriptionSettings","maxSubscribersPerIP","i","id","subscriptionStatus","restrictedSettings","allowedDomains","domain","update","unsubscribedAt","emails","send","to","subject","stringContaining","message","requiresConfirmation","noDoubleOptIn","requireDoubleOptIn","mockRejectedValueOnce","Error"],"mappings":"AAAA,SAASA,QAAQ,EAAEC,EAAE,EAAEC,MAAM,EAAEC,UAAU,EAAEC,EAAE,QAAQ,SAAQ;AAC7D,SAASC,uBAAuB,QAAQ,+BAA8B;AACtE,SAASC,wBAAwB,EAAEC,cAAc,EAAEC,gBAAgB,QAAQ,sBAAqB;AAChG,SAASC,sBAAsB,QAAQ,qCAAoC;AAC3E,SAASC,gBAAgB,QAAQ,8BAA6B;AAG9D,qBAAqB;AACrBN,GAAGO,IAAI,CAAC,2BAA2B,IAAO,CAAA;QACxCC,iBAAiBR,GAAGS,EAAE;IACxB,CAAA;AAEA,SAASD,eAAe,QAAQ,0BAAyB;AAEzDZ,SAAS,+BAA+B;IACtC,IAAIc;IACJ,IAAIC;IACJ,IAAIC;IACJ,IAAIC;IACJ,MAAMC,SAAiC;QACrCC,iBAAiB;QACjBC,cAAc;IAChB;IAEAjB,WAAW;QACTK;QACAD,eAAe,uBAAuB;YAACE;SAAuB;QAE9DK,WAAWT,wBAAwBa;QACnC,MAAMG,cAAcf;QAEpBS,UAAU;YACRO,SAASD,YAAYC,OAAO;YAC5BC,MAAM,CAAC;YACPC,IAAI;QACN;QAEAR,UAAU;YACRS,QAAQrB,GAAGS,EAAE,GAAGa,cAAc;YAC9BC,MAAMvB,GAAGS,EAAE;QACb;QAEA,2BAA2B;QAC3BI,mBAAmBP;QACjBE,gBAAwBgB,iBAAiB,CAACX;QAE5Cb,GAAGyB,aAAa;IAClB;IAEA7B,SAAS,oBAAoB;QAC3BC,GAAG,wCAAwC;YACzC,MAAMa,SAASgB,OAAO,CAACf,SAASC;YAEhCd,OAAOc,QAAQS,MAAM,EAAEM,oBAAoB,CAAC;YAC5C7B,OAAOc,QAAQW,IAAI,EAAEI,oBAAoB,CAAC;gBACxCC,SAAS;gBACTC,OAAO;YACT;QACF;QAEAhC,GAAG,uCAAuC;YACxC,MAAMiC,gBAAgB;gBACpB;gBACA;gBACA;gBACA;gBACA;gBACA;aACD;YAED,KAAK,MAAMC,SAASD,cAAe;gBACjCnB,QAAQQ,IAAI,GAAG;oBAAEY;gBAAM;gBACvB,MAAMrB,SAASgB,OAAO,CAACf,SAASC;gBAEhCd,OAAOc,QAAQS,MAAM,EAAEM,oBAAoB,CAAC;gBAC5C7B,OAAOc,QAAQW,IAAI,EAAEI,oBAAoB,CAAC;oBACxCC,SAAS;oBACTC,OAAO;gBACT;YACF;QACF;QAEAhC,GAAG,+BAA+B;YAChCc,QAAQQ,IAAI,GAAG;gBACbY,OAAO;gBACPC,MAAM;YACR;YAEA,MAAMtB,SAASgB,OAAO,CAACf,SAASC;YAEhC,mDAAmD;YACnDd,OAAOa,QAAQO,OAAO,CAACe,MAAM,EAAEN,oBAAoB,CACjD7B,OAAOoC,gBAAgB,CAAC;gBACtBC,MAAMrC,OAAOoC,gBAAgB,CAAC;oBAC5BH,OAAO;oBACPC,MAAM;gBACR;YACF;QAEJ;IACF;IAEApC,SAAS,iBAAiB;QACxBC,GAAG,yCAAyC;YAC1C,sCAAsC;YACtC,MAAMuC,iBAAiB/B,uBAAuBgC,oBAAoB,CAACC,mBAAmB;YAEtF,IAAK,IAAIC,IAAI,GAAGA,IAAIH,gBAAgBG,IAAK;gBACvCpC,eAAe,eAAe;oBAAC;wBAC7BqC,IAAI,CAAC,OAAO,EAAED,GAAG;wBACjBR,OAAO,CAAC,IAAI,EAAEQ,EAAE,YAAY,CAAC;wBAC7BnB,IAAI;wBACJqB,oBAAoB;oBACtB;iBAAE;YACJ;YAEA9B,QAAQQ,IAAI,GAAG;gBAAEY,OAAO;YAAsB;YAC9C,MAAMrB,SAASgB,OAAO,CAACf,SAASC;YAEhCd,OAAOc,QAAQS,MAAM,EAAEM,oBAAoB,CAAC;YAC5C7B,OAAOc,QAAQW,IAAI,EAAEI,oBAAoB,CAAC;gBACxCC,SAAS;gBACTC,OAAO;YACT;QACF;QAEAhC,GAAG,qDAAqD;YACtD,iCAAiC;YACjC,IAAK,IAAI0C,IAAI,GAAGA,IAAI,GAAGA,IAAK;gBAC1BpC,eAAe,eAAe;oBAAC;wBAC7BqC,IAAI,CAAC,UAAU,EAAED,GAAG;wBACpBR,OAAO,CAAC,KAAK,EAAEQ,EAAE,YAAY,CAAC;wBAC9BnB,IAAI;wBACJqB,oBAAoB;oBACtB;iBAAE;YACJ;YAEA9B,QAAQQ,IAAI,GAAG;gBAAEY,OAAO;YAAsB;YAC9C,MAAMrB,SAASgB,OAAO,CAACf,SAASC;YAEhCd,OAAOc,QAAQS,MAAM,EAAEM,oBAAoB,CAAC;QAC9C;IACF;IAEA/B,SAAS,uBAAuB;QAC9BC,GAAG,kDAAkD;YACnD,sCAAsC;YACtC,MAAM6C,qBAAqB;gBACzB,GAAGrC,sBAAsB;gBACzBgC,sBAAsB;oBACpB,GAAGhC,uBAAuBgC,oBAAoB;oBAC9CM,gBAAgB;wBACd;4BAAEC,QAAQ;wBAAc;wBACxB;4BAAEA,QAAQ;wBAAc;qBACzB;gBACH;YACF;YACAxC;YACAD,eAAe,uBAAuB;gBAACuC;aAAmB;YAE1D,sBAAsB;YACtB/B,QAAQQ,IAAI,GAAG;gBAAEY,OAAO;YAAmB;YAC3C,MAAMrB,SAASgB,OAAO,CAACf,SAASC;YAEhCd,OAAOc,QAAQS,MAAM,EAAEM,oBAAoB,CAAC;YAC5C7B,OAAOc,QAAQW,IAAI,EAAEI,oBAAoB,CAAC;gBACxCC,SAAS;gBACTC,OAAO;YACT;YAEA,sBAAsB;YACtBlB,QAAQQ,IAAI,GAAG;gBAAEY,OAAO;YAAmB;YAC3C,MAAMrB,SAASgB,OAAO,CAACf,SAASC;YAEhCd,OAAOc,QAAQS,MAAM,EAAEM,oBAAoB,CAAC;QAC9C;IACF;IAEA/B,SAAS,wBAAwB;QAC/BC,GAAG,6CAA6C;YAC9CM,eAAe,eAAe;gBAAC;oBAC7BqC,IAAI;oBACJT,OAAO;oBACPU,oBAAoB;gBACtB;aAAE;YAEF9B,QAAQQ,IAAI,GAAG;gBAAEY,OAAO;YAAuB;YAC/C,MAAMrB,SAASgB,OAAO,CAACf,SAASC;YAEhCd,OAAOc,QAAQS,MAAM,EAAEM,oBAAoB,CAAC;YAC5C7B,OAAOc,QAAQW,IAAI,EAAEI,oBAAoB,CAAC;gBACxCC,SAAS;gBACTC,OAAO;YACT;QACF;QAEAhC,GAAG,qDAAqD;YACtDM,eAAe,eAAe;gBAAC;oBAC7BqC,IAAI;oBACJT,OAAO;oBACPU,oBAAoB;gBACtB;aAAE;YAEF9B,QAAQQ,IAAI,GAAG;gBAAEY,OAAO;YAAuB;YAC/C,MAAMrB,SAASgB,OAAO,CAACf,SAASC;YAEhCd,OAAOa,QAAQO,OAAO,CAAC2B,MAAM,EAAElB,oBAAoB,CACjD7B,OAAOoC,gBAAgB,CAAC;gBACtBM,IAAI;gBACJL,MAAMrC,OAAOoC,gBAAgB,CAAC;oBAC5BO,oBAAoB;oBACpBK,gBAAgB;gBAClB;YACF;YAEFhD,OAAOc,QAAQS,MAAM,EAAEM,oBAAoB,CAAC;QAC9C;IACF;IAEA/B,SAAS,iBAAiB;QACxBC,GAAG,gEAAgE;YACjEc,QAAQQ,IAAI,GAAG;gBAAEY,OAAO;YAAsB;YAC9C,MAAMrB,SAASgB,OAAO,CAACf,SAASC;YAEhCd,OAAOe,iBAAiBkC,MAAM,CAACC,IAAI,EAAErB,oBAAoB,CACvD7B,OAAOoC,gBAAgB,CAAC;gBACtBe,IAAI;oBAAC;iBAAsB;gBAC3BC,SAASpD,OAAOqD,gBAAgB,CAAC;YACnC;YAGFrD,OAAOc,QAAQW,IAAI,EAAEI,oBAAoB,CAAC;gBACxCC,SAAS;gBACTwB,SAAS;gBACTC,sBAAsB;YACxB;QACF;QAEAxD,GAAG,8DAA8D;YAC/D,wBAAwB;YACxB,MAAMyD,gBAAgB;gBACpB,GAAGjD,sBAAsB;gBACzBgC,sBAAsB;oBACpB,GAAGhC,uBAAuBgC,oBAAoB;oBAC9CkB,oBAAoB;gBACtB;YACF;YACAnD;YACAD,eAAe,uBAAuB;gBAACmD;aAAc;YAErD3C,QAAQQ,IAAI,GAAG;gBAAEY,OAAO;YAAsB;YAC9C,MAAMrB,SAASgB,OAAO,CAACf,SAASC;YAEhCd,OAAOa,QAAQO,OAAO,CAACe,MAAM,EAAEN,oBAAoB,CACjD7B,OAAOoC,gBAAgB,CAAC;gBACtBC,MAAMrC,OAAOoC,gBAAgB,CAAC;oBAC5BO,oBAAoB;gBACtB;YACF;QAEJ;IACF;IAEA7C,SAAS,kBAAkB;QACzBC,GAAG,mDAAmD;YACpDgB,iBAAiBkC,MAAM,CAACC,IAAI,CAACQ,qBAAqB,CAAC,IAAIC,MAAM;YAE7D9C,QAAQQ,IAAI,GAAG;gBAAEY,OAAO;YAAmB;YAC3C,MAAMrB,SAASgB,OAAO,CAACf,SAASC;YAEhCd,OAAOc,QAAQS,MAAM,EAAEM,oBAAoB,CAAC;YAC5C7B,OAAOc,QAAQW,IAAI,EAAEI,oBAAoB,CAAC;gBACxCC,SAAS;gBACTC,OAAO;YACT;QACF;QAEAhC,GAAG,4CAA4C;YAC7Cc,QAAQO,OAAO,CAACe,MAAM,CAACuB,qBAAqB,CAAC,IAAIC,MAAM;YAEvD9C,QAAQQ,IAAI,GAAG;gBAAEY,OAAO;YAAmB;YAC3C,MAAMrB,SAASgB,OAAO,CAACf,SAASC;YAEhCd,OAAOc,QAAQS,MAAM,EAAEM,oBAAoB,CAAC;YAC5C7B,OAAOc,QAAQW,IAAI,EAAEI,oBAAoB,CAAC;gBACxCC,SAAS;gBACTC,OAAO;YACT;QACA,2CAA2C;QAC7C;IACF;AACF"}