payload-plugin-newsletter 0.3.2 → 0.4.5

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 (180) hide show
  1. package/CHANGELOG.md +44 -1
  2. package/CLAUDE.md +31 -19
  3. package/dist/client.cjs +899 -0
  4. package/dist/client.cjs.map +1 -0
  5. package/dist/client.d.cts +52 -0
  6. package/dist/client.d.ts +52 -0
  7. package/dist/client.js +867 -0
  8. package/dist/client.js.map +1 -0
  9. package/dist/components.cjs +899 -0
  10. package/dist/components.cjs.map +1 -0
  11. package/dist/components.d.cts +4 -0
  12. package/dist/components.d.ts +4 -0
  13. package/dist/components.js +867 -0
  14. package/dist/components.js.map +1 -0
  15. package/dist/index.cjs +2004 -0
  16. package/dist/index.cjs.map +1 -0
  17. package/dist/index.d.cts +11 -0
  18. package/dist/index.d.ts +6 -5
  19. package/dist/index.js +1967 -0
  20. package/dist/index.js.map +1 -0
  21. package/dist/types.cjs +19 -0
  22. package/dist/types.cjs.map +1 -0
  23. package/dist/{types/index.d.ts → types.d.cts} +19 -17
  24. package/dist/types.d.ts +350 -0
  25. package/dist/types.js +1 -0
  26. package/dist/types.js.map +1 -0
  27. package/package.json +48 -25
  28. package/dist/.tsbuildinfo +0 -1
  29. package/dist/collections/NewsletterSettings.d.ts +0 -4
  30. package/dist/collections/NewsletterSettings.d.ts.map +0 -1
  31. package/dist/collections/Subscribers.d.ts +0 -4
  32. package/dist/collections/Subscribers.d.ts.map +0 -1
  33. package/dist/components/MagicLinkVerify.d.ts +0 -27
  34. package/dist/components/MagicLinkVerify.d.ts.map +0 -1
  35. package/dist/components/NewsletterForm.d.ts +0 -5
  36. package/dist/components/NewsletterForm.d.ts.map +0 -1
  37. package/dist/components/PreferencesForm.d.ts +0 -5
  38. package/dist/components/PreferencesForm.d.ts.map +0 -1
  39. package/dist/components/index.d.ts +0 -5
  40. package/dist/components/index.d.ts.map +0 -1
  41. package/dist/endpoints/index.d.ts +0 -4
  42. package/dist/endpoints/index.d.ts.map +0 -1
  43. package/dist/endpoints/preferences.d.ts +0 -5
  44. package/dist/endpoints/preferences.d.ts.map +0 -1
  45. package/dist/endpoints/subscribe.d.ts +0 -4
  46. package/dist/endpoints/subscribe.d.ts.map +0 -1
  47. package/dist/endpoints/unsubscribe.d.ts +0 -4
  48. package/dist/endpoints/unsubscribe.d.ts.map +0 -1
  49. package/dist/endpoints/verify-magic-link.d.ts +0 -4
  50. package/dist/endpoints/verify-magic-link.d.ts.map +0 -1
  51. package/dist/exports/client.d.ts +0 -6
  52. package/dist/exports/client.d.ts.map +0 -1
  53. package/dist/exports/components.d.ts +0 -2
  54. package/dist/exports/components.d.ts.map +0 -1
  55. package/dist/exports/types.d.ts +0 -2
  56. package/dist/exports/types.d.ts.map +0 -1
  57. package/dist/fields/newsletterScheduling.d.ts +0 -4
  58. package/dist/fields/newsletterScheduling.d.ts.map +0 -1
  59. package/dist/hooks/useNewsletterAuth.d.ts +0 -16
  60. package/dist/hooks/useNewsletterAuth.d.ts.map +0 -1
  61. package/dist/index.d.ts.map +0 -1
  62. package/dist/providers/broadcast.d.ts +0 -19
  63. package/dist/providers/broadcast.d.ts.map +0 -1
  64. package/dist/providers/index.d.ts +0 -23
  65. package/dist/providers/index.d.ts.map +0 -1
  66. package/dist/providers/resend.d.ts +0 -20
  67. package/dist/providers/resend.d.ts.map +0 -1
  68. package/dist/providers/types.d.ts +0 -46
  69. package/dist/providers/types.d.ts.map +0 -1
  70. package/dist/src/__tests__/fixtures/newsletter-settings.js +0 -41
  71. package/dist/src/__tests__/fixtures/newsletter-settings.js.map +0 -1
  72. package/dist/src/__tests__/fixtures/subscribers.js +0 -70
  73. package/dist/src/__tests__/fixtures/subscribers.js.map +0 -1
  74. package/dist/src/__tests__/integration/collections/subscriber-hooks.test.js +0 -356
  75. package/dist/src/__tests__/integration/collections/subscriber-hooks.test.js.map +0 -1
  76. package/dist/src/__tests__/integration/endpoints/preferences.test.js +0 -266
  77. package/dist/src/__tests__/integration/endpoints/preferences.test.js.map +0 -1
  78. package/dist/src/__tests__/integration/endpoints/subscribe.test.js +0 -280
  79. package/dist/src/__tests__/integration/endpoints/subscribe.test.js.map +0 -1
  80. package/dist/src/__tests__/integration/endpoints/unsubscribe.test.js +0 -187
  81. package/dist/src/__tests__/integration/endpoints/unsubscribe.test.js.map +0 -1
  82. package/dist/src/__tests__/integration/endpoints/verify-magic-link.test.js +0 -188
  83. package/dist/src/__tests__/integration/endpoints/verify-magic-link.test.js.map +0 -1
  84. package/dist/src/__tests__/mocks/email-providers.js +0 -153
  85. package/dist/src/__tests__/mocks/email-providers.js.map +0 -1
  86. package/dist/src/__tests__/mocks/payload.js +0 -244
  87. package/dist/src/__tests__/mocks/payload.js.map +0 -1
  88. package/dist/src/__tests__/security/csrf-protection.test.js +0 -309
  89. package/dist/src/__tests__/security/csrf-protection.test.js.map +0 -1
  90. package/dist/src/__tests__/security/settings-access.test.js +0 -204
  91. package/dist/src/__tests__/security/settings-access.test.js.map +0 -1
  92. package/dist/src/__tests__/security/subscriber-access.test.js +0 -210
  93. package/dist/src/__tests__/security/subscriber-access.test.js.map +0 -1
  94. package/dist/src/__tests__/security/xss-prevention.test.js +0 -305
  95. package/dist/src/__tests__/security/xss-prevention.test.js.map +0 -1
  96. package/dist/src/__tests__/setup/integration.setup.js +0 -38
  97. package/dist/src/__tests__/setup/integration.setup.js.map +0 -1
  98. package/dist/src/__tests__/setup/unit.setup.js +0 -41
  99. package/dist/src/__tests__/setup/unit.setup.js.map +0 -1
  100. package/dist/src/__tests__/unit/utils/access.test.js +0 -116
  101. package/dist/src/__tests__/unit/utils/access.test.js.map +0 -1
  102. package/dist/src/__tests__/unit/utils/jwt.test.js +0 -238
  103. package/dist/src/__tests__/unit/utils/jwt.test.js.map +0 -1
  104. package/dist/src/collections/NewsletterSettings.js +0 -390
  105. package/dist/src/collections/NewsletterSettings.js.map +0 -1
  106. package/dist/src/collections/Subscribers.js +0 -309
  107. package/dist/src/collections/Subscribers.js.map +0 -1
  108. package/dist/src/components/MagicLinkVerify.js +0 -180
  109. package/dist/src/components/MagicLinkVerify.js.map +0 -1
  110. package/dist/src/components/NewsletterForm.js +0 -326
  111. package/dist/src/components/NewsletterForm.js.map +0 -1
  112. package/dist/src/components/PreferencesForm.js +0 -524
  113. package/dist/src/components/PreferencesForm.js.map +0 -1
  114. package/dist/src/components/index.js +0 -5
  115. package/dist/src/components/index.js.map +0 -1
  116. package/dist/src/endpoints/index.js +0 -17
  117. package/dist/src/endpoints/index.js.map +0 -1
  118. package/dist/src/endpoints/preferences.js +0 -136
  119. package/dist/src/endpoints/preferences.js.map +0 -1
  120. package/dist/src/endpoints/subscribe.js +0 -151
  121. package/dist/src/endpoints/subscribe.js.map +0 -1
  122. package/dist/src/endpoints/unsubscribe.js +0 -105
  123. package/dist/src/endpoints/unsubscribe.js.map +0 -1
  124. package/dist/src/endpoints/verify-magic-link.js +0 -103
  125. package/dist/src/endpoints/verify-magic-link.js.map +0 -1
  126. package/dist/src/exports/client.js +0 -7
  127. package/dist/src/exports/client.js.map +0 -1
  128. package/dist/src/exports/components.js +0 -6
  129. package/dist/src/exports/components.js.map +0 -1
  130. package/dist/src/exports/types.js +0 -3
  131. package/dist/src/exports/types.js.map +0 -1
  132. package/dist/src/fields/newsletterScheduling.js +0 -195
  133. package/dist/src/fields/newsletterScheduling.js.map +0 -1
  134. package/dist/src/hooks/useNewsletterAuth.js +0 -112
  135. package/dist/src/hooks/useNewsletterAuth.js.map +0 -1
  136. package/dist/src/index.js +0 -130
  137. package/dist/src/index.js.map +0 -1
  138. package/dist/src/providers/broadcast.js +0 -158
  139. package/dist/src/providers/broadcast.js.map +0 -1
  140. package/dist/src/providers/index.js +0 -63
  141. package/dist/src/providers/index.js.map +0 -1
  142. package/dist/src/providers/resend.js +0 -122
  143. package/dist/src/providers/resend.js.map +0 -1
  144. package/dist/src/providers/types.js +0 -12
  145. package/dist/src/providers/types.js.map +0 -1
  146. package/dist/src/templates/BaseTemplate.js +0 -105
  147. package/dist/src/templates/BaseTemplate.js.map +0 -1
  148. package/dist/src/templates/MagicLinkTemplate.js +0 -178
  149. package/dist/src/templates/MagicLinkTemplate.js.map +0 -1
  150. package/dist/src/templates/NewsletterTemplate.js +0 -150
  151. package/dist/src/templates/NewsletterTemplate.js.map +0 -1
  152. package/dist/src/templates/WelcomeTemplate.js +0 -192
  153. package/dist/src/templates/WelcomeTemplate.js.map +0 -1
  154. package/dist/src/templates/index.js +0 -6
  155. package/dist/src/templates/index.js.map +0 -1
  156. package/dist/src/types/index.js +0 -3
  157. package/dist/src/types/index.js.map +0 -1
  158. package/dist/src/utils/access.js +0 -80
  159. package/dist/src/utils/access.js.map +0 -1
  160. package/dist/src/utils/jwt.js +0 -91
  161. package/dist/src/utils/jwt.js.map +0 -1
  162. package/dist/src/utils/validation.js +0 -74
  163. package/dist/src/utils/validation.js.map +0 -1
  164. package/dist/templates/BaseTemplate.d.ts +0 -45
  165. package/dist/templates/BaseTemplate.d.ts.map +0 -1
  166. package/dist/templates/MagicLinkTemplate.d.ts +0 -67
  167. package/dist/templates/MagicLinkTemplate.d.ts.map +0 -1
  168. package/dist/templates/NewsletterTemplate.d.ts +0 -112
  169. package/dist/templates/NewsletterTemplate.d.ts.map +0 -1
  170. package/dist/templates/WelcomeTemplate.d.ts +0 -55
  171. package/dist/templates/WelcomeTemplate.d.ts.map +0 -1
  172. package/dist/templates/index.d.ts +0 -7
  173. package/dist/templates/index.d.ts.map +0 -1
  174. package/dist/types/index.d.ts.map +0 -1
  175. package/dist/utils/access.d.ts +0 -15
  176. package/dist/utils/access.d.ts.map +0 -1
  177. package/dist/utils/jwt.d.ts +0 -32
  178. package/dist/utils/jwt.d.ts.map +0 -1
  179. package/dist/utils/validation.d.ts +0 -25
  180. package/dist/utils/validation.d.ts.map +0 -1
@@ -1,356 +0,0 @@
1
- import { describe, it, expect, beforeEach, vi } from 'vitest';
2
- import { createPayloadRequestMock, seedCollection, clearCollections, createMockAdminUser } from '../../mocks/payload';
3
- import { mockSubscribers } from '../../fixtures/subscribers';
4
- import { createResendMock } from '../../mocks/email-providers';
5
- // Mock email service
6
- vi.mock('../../../services/email', ()=>({
7
- getEmailService: vi.fn()
8
- }));
9
- import { getEmailService } from '../../../services/email';
10
- describe('Subscriber Collection Hooks Security', ()=>{
11
- let mockReq;
12
- let mockEmailService;
13
- const config = {
14
- subscribersSlug: 'subscribers'
15
- };
16
- beforeEach(()=>{
17
- clearCollections();
18
- seedCollection('subscribers', mockSubscribers);
19
- const payloadMock = createPayloadRequestMock();
20
- mockReq = {
21
- payload: payloadMock.payload,
22
- user: null
23
- };
24
- mockEmailService = createResendMock();
25
- getEmailService.mockResolvedValue(mockEmailService);
26
- vi.clearAllMocks();
27
- });
28
- describe('beforeChange Hook', ()=>{
29
- it('should normalize email addresses', async ()=>{
30
- const beforeChangeHook = async ({ data, req, operation })=>{
31
- if (operation === 'create' && data.email) {
32
- data.email = data.email.trim().toLowerCase();
33
- }
34
- return data;
35
- };
36
- const result = await beforeChangeHook({
37
- data: {
38
- email: ' USER@EXAMPLE.COM ',
39
- name: 'Test'
40
- },
41
- req: mockReq,
42
- operation: 'create',
43
- originalDoc: null
44
- });
45
- expect(result.email).toBe('user@example.com');
46
- });
47
- it('should prevent email changes on update', async ()=>{
48
- const beforeChangeHook = async ({ data, req, operation, originalDoc })=>{
49
- if (operation === 'update' && data.email && originalDoc?.email !== data.email) {
50
- throw new Error('Email cannot be changed');
51
- }
52
- return data;
53
- };
54
- await expect(beforeChangeHook({
55
- data: {
56
- email: 'newemail@example.com'
57
- },
58
- req: mockReq,
59
- operation: 'update',
60
- originalDoc: {
61
- email: 'oldemail@example.com'
62
- }
63
- })).rejects.toThrow('Email cannot be changed');
64
- });
65
- it('should validate subscription status transitions', async ()=>{
66
- const beforeChangeHook = async ({ data, req, operation, originalDoc })=>{
67
- if (operation === 'update' && data.subscriptionStatus) {
68
- const oldStatus = originalDoc?.subscriptionStatus;
69
- const newStatus = data.subscriptionStatus;
70
- // Validate allowed transitions
71
- const allowedTransitions = {
72
- pending: [
73
- 'active',
74
- 'unsubscribed'
75
- ],
76
- active: [
77
- 'unsubscribed'
78
- ],
79
- unsubscribed: [
80
- 'pending'
81
- ]
82
- };
83
- if (!allowedTransitions[oldStatus]?.includes(newStatus)) {
84
- throw new Error(`Invalid status transition from ${oldStatus} to ${newStatus}`);
85
- }
86
- }
87
- return data;
88
- };
89
- // Valid transition
90
- const result1 = await beforeChangeHook({
91
- data: {
92
- subscriptionStatus: 'active'
93
- },
94
- req: mockReq,
95
- operation: 'update',
96
- originalDoc: {
97
- subscriptionStatus: 'pending'
98
- }
99
- });
100
- expect(result1.subscriptionStatus).toBe('active');
101
- // Invalid transition
102
- await expect(beforeChangeHook({
103
- data: {
104
- subscriptionStatus: 'pending'
105
- },
106
- req: mockReq,
107
- operation: 'update',
108
- originalDoc: {
109
- subscriptionStatus: 'active'
110
- }
111
- })).rejects.toThrow('Invalid status transition');
112
- });
113
- it('should set timestamps appropriately', async ()=>{
114
- const beforeChangeHook = async ({ data, req, operation })=>{
115
- const now = new Date();
116
- if (operation === 'update') {
117
- // Set unsubscribedAt when unsubscribing
118
- if (data.subscriptionStatus === 'unsubscribed' && !data.unsubscribedAt) {
119
- data.unsubscribedAt = now;
120
- } else if (data.subscriptionStatus === 'pending' && data.unsubscribedAt !== undefined) {
121
- data.unsubscribedAt = null;
122
- }
123
- }
124
- return data;
125
- };
126
- // Unsubscribing
127
- const result1 = await beforeChangeHook({
128
- data: {
129
- subscriptionStatus: 'unsubscribed'
130
- },
131
- req: mockReq,
132
- operation: 'update',
133
- originalDoc: {
134
- subscriptionStatus: 'active'
135
- }
136
- });
137
- expect(result1.unsubscribedAt).toBeInstanceOf(Date);
138
- // Re-subscribing
139
- const result2 = await beforeChangeHook({
140
- data: {
141
- subscriptionStatus: 'pending',
142
- unsubscribedAt: new Date()
143
- },
144
- req: mockReq,
145
- operation: 'update',
146
- originalDoc: {
147
- subscriptionStatus: 'unsubscribed'
148
- }
149
- });
150
- expect(result2.unsubscribedAt).toBeNull();
151
- });
152
- it('should prevent duplicate emails on create', async ()=>{
153
- const beforeChangeHook = async ({ data, req, operation })=>{
154
- if (operation === 'create' && data.email) {
155
- const existing = await req.payload.find({
156
- collection: 'subscribers',
157
- where: {
158
- email: {
159
- equals: data.email
160
- }
161
- }
162
- });
163
- if (existing.docs.length > 0) {
164
- throw new Error('Email already exists');
165
- }
166
- }
167
- return data;
168
- };
169
- seedCollection('subscribers', [
170
- {
171
- id: 'existing',
172
- email: 'existing@example.com',
173
- subscriptionStatus: 'active'
174
- }
175
- ]);
176
- await expect(beforeChangeHook({
177
- data: {
178
- email: 'existing@example.com'
179
- },
180
- req: mockReq,
181
- operation: 'create',
182
- originalDoc: null
183
- })).rejects.toThrow('Email already exists');
184
- });
185
- });
186
- describe('afterChange Hook', ()=>{
187
- it('should sync with email provider on create', async ()=>{
188
- const afterChangeHook = async ({ doc, req, operation })=>{
189
- if (operation === 'create') {
190
- const emailService = await getEmailService({
191
- provider: 'resend',
192
- apiKey: 'test-key',
193
- fromEmail: 'test@example.com',
194
- fromName: 'Test'
195
- });
196
- await emailService.emails.send({
197
- from: 'test@example.com',
198
- to: [
199
- doc.email
200
- ],
201
- subject: 'Welcome',
202
- html: '<p>Welcome!</p>'
203
- });
204
- }
205
- return doc;
206
- };
207
- await afterChangeHook({
208
- doc: {
209
- id: 'new-sub',
210
- email: 'new@example.com'
211
- },
212
- req: mockReq,
213
- operation: 'create',
214
- previousDoc: null
215
- });
216
- expect(mockEmailService.emails.send).toHaveBeenCalledWith(expect.objectContaining({
217
- to: [
218
- 'new@example.com'
219
- ]
220
- }));
221
- });
222
- it('should handle email provider sync failures gracefully', async ()=>{
223
- mockEmailService.emails.send.mockRejectedValueOnce(new Error('Provider error'));
224
- const afterChangeHook = async ({ doc, req, operation })=>{
225
- if (operation === 'create') {
226
- try {
227
- const emailService = await getEmailService({
228
- provider: 'resend',
229
- apiKey: 'test-key',
230
- fromEmail: 'test@example.com',
231
- fromName: 'Test'
232
- });
233
- await emailService.emails.send({
234
- from: 'test@example.com',
235
- to: [
236
- doc.email
237
- ],
238
- subject: 'Welcome',
239
- html: '<p>Welcome!</p>'
240
- });
241
- } catch (error) {
242
- // Log error but don't fail the operation
243
- console.error('Failed to sync with email provider:', error);
244
- // Could update a sync status field
245
- doc.emailProviderSyncStatus = 'failed';
246
- }
247
- }
248
- return doc;
249
- };
250
- const result = await afterChangeHook({
251
- doc: {
252
- id: 'new-sub',
253
- email: 'new@example.com'
254
- },
255
- req: mockReq,
256
- operation: 'create',
257
- previousDoc: null
258
- });
259
- expect(result.emailProviderSyncStatus).toBe('failed');
260
- });
261
- it('should clean up magic link tokens after verification', async ()=>{
262
- const afterChangeHook = async ({ doc, req, operation, previousDoc })=>{
263
- if (operation === 'update' && previousDoc?.subscriptionStatus === 'pending' && doc.subscriptionStatus === 'active') {
264
- // Clear any magic link tokens
265
- await req.payload.update({
266
- collection: 'subscribers',
267
- id: doc.id,
268
- data: {
269
- magicLinkToken: null,
270
- magicLinkTokenExpiry: null
271
- }
272
- });
273
- }
274
- return doc;
275
- };
276
- mockReq.payload.update.mockResolvedValueOnce({
277
- success: true
278
- });
279
- await afterChangeHook({
280
- doc: {
281
- id: 'sub-123',
282
- subscriptionStatus: 'active'
283
- },
284
- req: mockReq,
285
- operation: 'update',
286
- previousDoc: {
287
- subscriptionStatus: 'pending'
288
- }
289
- });
290
- expect(mockReq.payload.update).toHaveBeenCalledWith(expect.objectContaining({
291
- data: {
292
- magicLinkToken: null,
293
- magicLinkTokenExpiry: null
294
- }
295
- }));
296
- });
297
- });
298
- describe('beforeDelete Hook', ()=>{
299
- it('should prevent deletion of active subscribers by non-admins', async ()=>{
300
- const beforeDeleteHook = async ({ req, id })=>{
301
- const subscriber = await req.payload.findByID({
302
- collection: 'subscribers',
303
- id
304
- });
305
- if (subscriber?.subscriptionStatus === 'active' && !req.user?.roles?.includes('admin')) {
306
- throw new Error('Only admins can delete active subscribers');
307
- }
308
- };
309
- mockReq.user = {
310
- id: 'user-123',
311
- collection: 'users'
312
- };
313
- await expect(beforeDeleteHook({
314
- req: mockReq,
315
- id: 'sub-1'
316
- })).rejects.toThrow('Only admins can delete active subscribers');
317
- // Admin should be allowed
318
- mockReq.user = createMockAdminUser();
319
- await expect(beforeDeleteHook({
320
- req: mockReq,
321
- id: 'sub-1'
322
- })).resolves.not.toThrow();
323
- });
324
- it('should clean up email provider data on delete', async ()=>{
325
- const afterDeleteHook = async ({ doc, req })=>{
326
- try {
327
- const emailService = await getEmailService({
328
- provider: 'broadcast',
329
- apiKey: 'test-key',
330
- fromEmail: 'test@example.com',
331
- fromName: 'Test'
332
- });
333
- // Remove from email provider
334
- if (doc.emailProviderId) {
335
- await emailService.contacts.delete(doc.emailProviderId);
336
- }
337
- } catch (error) {
338
- console.error('Failed to remove from email provider:', error);
339
- }
340
- };
341
- const mockBroadcast = createBroadcastMock();
342
- getEmailService.mockResolvedValue(mockBroadcast);
343
- await afterDeleteHook({
344
- doc: {
345
- id: 'sub-123',
346
- email: 'test@example.com',
347
- emailProviderId: 'contact-123'
348
- },
349
- req: mockReq
350
- });
351
- expect(mockBroadcast.contacts.delete).toHaveBeenCalledWith('contact-123');
352
- });
353
- });
354
- });
355
-
356
- //# sourceMappingURL=subscriber-hooks.test.js.map
@@ -1 +0,0 @@
1
- {"version":3,"sources":["../../../../../src/__tests__/integration/collections/subscriber-hooks.test.ts"],"sourcesContent":["import { describe, it, expect, beforeEach, vi } from 'vitest'\nimport type { CollectionBeforeChangeHook, CollectionAfterChangeHook } from 'payload'\nimport { createPayloadRequestMock, seedCollection, clearCollections, createMockAdminUser } from '../../mocks/payload'\nimport { mockSubscribers } from '../../fixtures/subscribers'\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('Subscriber Collection Hooks Security', () => {\n let mockReq: any\n let mockEmailService: any\n const config: NewsletterPluginConfig = {\n subscribersSlug: 'subscribers',\n }\n\n beforeEach(() => {\n clearCollections()\n seedCollection('subscribers', mockSubscribers)\n \n const payloadMock = createPayloadRequestMock()\n mockReq = {\n payload: payloadMock.payload,\n user: null,\n }\n \n mockEmailService = createResendMock()\n ;(getEmailService as any).mockResolvedValue(mockEmailService)\n \n vi.clearAllMocks()\n })\n\n describe('beforeChange Hook', () => {\n it('should normalize email addresses', async () => {\n const beforeChangeHook: CollectionBeforeChangeHook = async ({ data, req, operation }) => {\n if (operation === 'create' && data.email) {\n data.email = data.email.trim().toLowerCase()\n }\n return data\n }\n\n const result = await beforeChangeHook({\n data: { email: ' USER@EXAMPLE.COM ', name: 'Test' },\n req: mockReq,\n operation: 'create',\n originalDoc: null,\n })\n\n expect(result.email).toBe('user@example.com')\n })\n\n it('should prevent email changes on update', async () => {\n const beforeChangeHook: CollectionBeforeChangeHook = async ({ data, req, operation, originalDoc }) => {\n if (operation === 'update' && data.email && originalDoc?.email !== data.email) {\n throw new Error('Email cannot be changed')\n }\n return data\n }\n\n await expect(\n beforeChangeHook({\n data: { email: 'newemail@example.com' },\n req: mockReq,\n operation: 'update',\n originalDoc: { email: 'oldemail@example.com' },\n })\n ).rejects.toThrow('Email cannot be changed')\n })\n\n it('should validate subscription status transitions', async () => {\n const beforeChangeHook: CollectionBeforeChangeHook = async ({ data, req, operation, originalDoc }) => {\n if (operation === 'update' && data.subscriptionStatus) {\n const oldStatus = originalDoc?.subscriptionStatus\n const newStatus = data.subscriptionStatus\n \n // Validate allowed transitions\n const allowedTransitions: Record<string, string[]> = {\n pending: ['active', 'unsubscribed'],\n active: ['unsubscribed'],\n unsubscribed: ['pending'], // Re-subscription\n }\n \n if (!allowedTransitions[oldStatus]?.includes(newStatus)) {\n throw new Error(`Invalid status transition from ${oldStatus} to ${newStatus}`)\n }\n }\n return data\n }\n\n // Valid transition\n const result1 = await beforeChangeHook({\n data: { subscriptionStatus: 'active' },\n req: mockReq,\n operation: 'update',\n originalDoc: { subscriptionStatus: 'pending' },\n })\n expect(result1.subscriptionStatus).toBe('active')\n\n // Invalid transition\n await expect(\n beforeChangeHook({\n data: { subscriptionStatus: 'pending' },\n req: mockReq,\n operation: 'update',\n originalDoc: { subscriptionStatus: 'active' },\n })\n ).rejects.toThrow('Invalid status transition')\n })\n\n it('should set timestamps appropriately', async () => {\n const beforeChangeHook: CollectionBeforeChangeHook = async ({ data, req, operation }) => {\n const now = new Date()\n \n if (operation === 'update') {\n // Set unsubscribedAt when unsubscribing\n if (data.subscriptionStatus === 'unsubscribed' && !data.unsubscribedAt) {\n data.unsubscribedAt = now\n }\n // Clear unsubscribedAt when re-subscribing\n else if (data.subscriptionStatus === 'pending' && data.unsubscribedAt !== undefined) {\n data.unsubscribedAt = null\n }\n }\n \n return data\n }\n\n // Unsubscribing\n const result1 = await beforeChangeHook({\n data: { subscriptionStatus: 'unsubscribed' },\n req: mockReq,\n operation: 'update',\n originalDoc: { subscriptionStatus: 'active' },\n })\n expect(result1.unsubscribedAt).toBeInstanceOf(Date)\n\n // Re-subscribing\n const result2 = await beforeChangeHook({\n data: { subscriptionStatus: 'pending', unsubscribedAt: new Date() },\n req: mockReq,\n operation: 'update',\n originalDoc: { subscriptionStatus: 'unsubscribed' },\n })\n expect(result2.unsubscribedAt).toBeNull()\n })\n\n it('should prevent duplicate emails on create', async () => {\n const beforeChangeHook: CollectionBeforeChangeHook = async ({ data, req, operation }) => {\n if (operation === 'create' && data.email) {\n const existing = await req.payload.find({\n collection: 'subscribers',\n where: {\n email: { equals: data.email },\n },\n })\n \n if (existing.docs.length > 0) {\n throw new Error('Email already exists')\n }\n }\n return data\n }\n\n seedCollection('subscribers', [{\n id: 'existing',\n email: 'existing@example.com',\n subscriptionStatus: 'active',\n }])\n\n await expect(\n beforeChangeHook({\n data: { email: 'existing@example.com' },\n req: mockReq,\n operation: 'create',\n originalDoc: null,\n })\n ).rejects.toThrow('Email already exists')\n })\n })\n\n describe('afterChange Hook', () => {\n it('should sync with email provider on create', async () => {\n const afterChangeHook: CollectionAfterChangeHook = async ({ doc, req, operation }) => {\n if (operation === 'create') {\n const emailService = await getEmailService({\n provider: 'resend',\n apiKey: 'test-key',\n fromEmail: 'test@example.com',\n fromName: 'Test',\n })\n \n await emailService.emails.send({\n from: 'test@example.com',\n to: [doc.email],\n subject: 'Welcome',\n html: '<p>Welcome!</p>',\n })\n }\n return doc\n }\n\n await afterChangeHook({\n doc: { id: 'new-sub', email: 'new@example.com' },\n req: mockReq,\n operation: 'create',\n previousDoc: null,\n })\n\n expect(mockEmailService.emails.send).toHaveBeenCalledWith(\n expect.objectContaining({\n to: ['new@example.com'],\n })\n )\n })\n\n it('should handle email provider sync failures gracefully', async () => {\n mockEmailService.emails.send.mockRejectedValueOnce(new Error('Provider error'))\n\n const afterChangeHook: CollectionAfterChangeHook = async ({ doc, req, operation }) => {\n if (operation === 'create') {\n try {\n const emailService = await getEmailService({\n provider: 'resend',\n apiKey: 'test-key',\n fromEmail: 'test@example.com',\n fromName: 'Test',\n })\n \n await emailService.emails.send({\n from: 'test@example.com',\n to: [doc.email],\n subject: 'Welcome',\n html: '<p>Welcome!</p>',\n })\n } catch (error) {\n // Log error but don't fail the operation\n console.error('Failed to sync with email provider:', error)\n // Could update a sync status field\n doc.emailProviderSyncStatus = 'failed'\n }\n }\n return doc\n }\n\n const result = await afterChangeHook({\n doc: { id: 'new-sub', email: 'new@example.com' },\n req: mockReq,\n operation: 'create',\n previousDoc: null,\n })\n\n expect(result.emailProviderSyncStatus).toBe('failed')\n })\n\n it('should clean up magic link tokens after verification', async () => {\n const afterChangeHook: CollectionAfterChangeHook = async ({ doc, req, operation, previousDoc }) => {\n if (operation === 'update' && \n previousDoc?.subscriptionStatus === 'pending' && \n doc.subscriptionStatus === 'active') {\n // Clear any magic link tokens\n await req.payload.update({\n collection: 'subscribers',\n id: doc.id,\n data: {\n magicLinkToken: null,\n magicLinkTokenExpiry: null,\n },\n })\n }\n return doc\n }\n\n mockReq.payload.update.mockResolvedValueOnce({ success: true })\n\n await afterChangeHook({\n doc: { id: 'sub-123', subscriptionStatus: 'active' },\n req: mockReq,\n operation: 'update',\n previousDoc: { subscriptionStatus: 'pending' },\n })\n\n expect(mockReq.payload.update).toHaveBeenCalledWith(\n expect.objectContaining({\n data: {\n magicLinkToken: null,\n magicLinkTokenExpiry: null,\n },\n })\n )\n })\n })\n\n describe('beforeDelete Hook', () => {\n it('should prevent deletion of active subscribers by non-admins', async () => {\n const beforeDeleteHook = async ({ req, id }: any) => {\n const subscriber = await req.payload.findByID({\n collection: 'subscribers',\n id,\n })\n \n if (subscriber?.subscriptionStatus === 'active' && !req.user?.roles?.includes('admin')) {\n throw new Error('Only admins can delete active subscribers')\n }\n }\n\n mockReq.user = { id: 'user-123', collection: 'users' }\n\n await expect(\n beforeDeleteHook({\n req: mockReq,\n id: 'sub-1', // Active subscriber\n })\n ).rejects.toThrow('Only admins can delete active subscribers')\n\n // Admin should be allowed\n mockReq.user = createMockAdminUser()\n await expect(\n beforeDeleteHook({\n req: mockReq,\n id: 'sub-1',\n })\n ).resolves.not.toThrow()\n })\n\n it('should clean up email provider data on delete', async () => {\n const afterDeleteHook = async ({ doc, req }: any) => {\n try {\n const emailService = await getEmailService({\n provider: 'broadcast',\n apiKey: 'test-key',\n fromEmail: 'test@example.com',\n fromName: 'Test',\n })\n \n // Remove from email provider\n if (doc.emailProviderId) {\n await emailService.contacts.delete(doc.emailProviderId)\n }\n } catch (error) {\n console.error('Failed to remove from email provider:', error)\n }\n }\n\n const mockBroadcast = createBroadcastMock()\n ;(getEmailService as any).mockResolvedValue(mockBroadcast)\n\n await afterDeleteHook({\n doc: { id: 'sub-123', email: 'test@example.com', emailProviderId: 'contact-123' },\n req: mockReq,\n })\n\n expect(mockBroadcast.contacts.delete).toHaveBeenCalledWith('contact-123')\n })\n })\n})"],"names":["describe","it","expect","beforeEach","vi","createPayloadRequestMock","seedCollection","clearCollections","createMockAdminUser","mockSubscribers","createResendMock","mock","getEmailService","fn","mockReq","mockEmailService","config","subscribersSlug","payloadMock","payload","user","mockResolvedValue","clearAllMocks","beforeChangeHook","data","req","operation","email","trim","toLowerCase","result","name","originalDoc","toBe","Error","rejects","toThrow","subscriptionStatus","oldStatus","newStatus","allowedTransitions","pending","active","unsubscribed","includes","result1","now","Date","unsubscribedAt","undefined","toBeInstanceOf","result2","toBeNull","existing","find","collection","where","equals","docs","length","id","afterChangeHook","doc","emailService","provider","apiKey","fromEmail","fromName","emails","send","from","to","subject","html","previousDoc","toHaveBeenCalledWith","objectContaining","mockRejectedValueOnce","error","console","emailProviderSyncStatus","update","magicLinkToken","magicLinkTokenExpiry","mockResolvedValueOnce","success","beforeDeleteHook","subscriber","findByID","roles","resolves","not","afterDeleteHook","emailProviderId","contacts","delete","mockBroadcast","createBroadcastMock"],"mappings":"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"}
@@ -1,266 +0,0 @@
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