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.
- package/CHANGELOG.md +43 -0
- package/CLAUDE.md +110 -0
- package/README.md +25 -3
- package/TEST_SUMMARY.md +152 -0
- package/dist/.tsbuildinfo +1 -1
- package/dist/collections/NewsletterSettings.d.ts.map +1 -1
- package/dist/collections/Subscribers.d.ts.map +1 -1
- package/dist/src/__tests__/fixtures/newsletter-settings.js +41 -0
- package/dist/src/__tests__/fixtures/newsletter-settings.js.map +1 -0
- package/dist/src/__tests__/fixtures/subscribers.js +70 -0
- package/dist/src/__tests__/fixtures/subscribers.js.map +1 -0
- package/dist/src/__tests__/integration/collections/subscriber-hooks.test.js +356 -0
- package/dist/src/__tests__/integration/collections/subscriber-hooks.test.js.map +1 -0
- package/dist/src/__tests__/integration/endpoints/preferences.test.js +266 -0
- package/dist/src/__tests__/integration/endpoints/preferences.test.js.map +1 -0
- package/dist/src/__tests__/integration/endpoints/subscribe.test.js +280 -0
- package/dist/src/__tests__/integration/endpoints/subscribe.test.js.map +1 -0
- package/dist/src/__tests__/integration/endpoints/unsubscribe.test.js +187 -0
- package/dist/src/__tests__/integration/endpoints/unsubscribe.test.js.map +1 -0
- package/dist/src/__tests__/integration/endpoints/verify-magic-link.test.js +188 -0
- package/dist/src/__tests__/integration/endpoints/verify-magic-link.test.js.map +1 -0
- package/dist/src/__tests__/mocks/email-providers.js +153 -0
- package/dist/src/__tests__/mocks/email-providers.js.map +1 -0
- package/dist/src/__tests__/mocks/payload.js +244 -0
- package/dist/src/__tests__/mocks/payload.js.map +1 -0
- package/dist/src/__tests__/security/csrf-protection.test.js +309 -0
- package/dist/src/__tests__/security/csrf-protection.test.js.map +1 -0
- package/dist/src/__tests__/security/settings-access.test.js +204 -0
- package/dist/src/__tests__/security/settings-access.test.js.map +1 -0
- package/dist/src/__tests__/security/subscriber-access.test.js +210 -0
- package/dist/src/__tests__/security/subscriber-access.test.js.map +1 -0
- package/dist/src/__tests__/security/xss-prevention.test.js +305 -0
- package/dist/src/__tests__/security/xss-prevention.test.js.map +1 -0
- package/dist/src/__tests__/setup/integration.setup.js +38 -0
- package/dist/src/__tests__/setup/integration.setup.js.map +1 -0
- package/dist/src/__tests__/setup/unit.setup.js +41 -0
- package/dist/src/__tests__/setup/unit.setup.js.map +1 -0
- package/dist/src/__tests__/unit/utils/access.test.js +116 -0
- package/dist/src/__tests__/unit/utils/access.test.js.map +1 -0
- package/dist/src/__tests__/unit/utils/jwt.test.js +238 -0
- package/dist/src/__tests__/unit/utils/jwt.test.js.map +1 -0
- package/dist/src/collections/NewsletterSettings.js +4 -3
- package/dist/src/collections/NewsletterSettings.js.map +1 -1
- package/dist/src/collections/Subscribers.js +4 -39
- package/dist/src/collections/Subscribers.js.map +1 -1
- package/dist/src/types/index.js.map +1 -1
- package/dist/src/utils/access.js +80 -0
- package/dist/src/utils/access.js.map +1 -0
- package/dist/src/utils/validation.js +9 -1
- package/dist/src/utils/validation.js.map +1 -1
- package/dist/types/index.d.ts +11 -0
- package/dist/types/index.d.ts.map +1 -1
- package/dist/utils/access.d.ts +15 -0
- package/dist/utils/access.d.ts.map +1 -0
- package/dist/utils/validation.d.ts.map +1 -1
- 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":"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"}
|