payload-plugin-newsletter 0.3.1 → 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 +25 -0
- package/CLAUDE.md +110 -0
- package/README.md +3 -3
- package/TEST_SUMMARY.md +152 -0
- package/dist/.tsbuildinfo +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/utils/access.js +28 -4
- package/dist/src/utils/access.js.map +1 -1
- package/dist/src/utils/validation.js +9 -1
- package/dist/src/utils/validation.js.map +1 -1
- package/dist/utils/access.d.ts.map +1 -1
- package/dist/utils/validation.d.ts.map +1 -1
- package/package.json +22 -4
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
|
2
|
+
import jwt from 'jsonwebtoken';
|
|
3
|
+
import { generateMagicLinkToken, verifyMagicLinkToken, generateSessionToken, verifySessionToken, generateMagicLinkURL } from '../../../utils/jwt';
|
|
4
|
+
// Mock jsonwebtoken
|
|
5
|
+
vi.mock('jsonwebtoken', ()=>({
|
|
6
|
+
default: {
|
|
7
|
+
sign: vi.fn(),
|
|
8
|
+
verify: vi.fn(),
|
|
9
|
+
TokenExpiredError: class TokenExpiredError extends Error {
|
|
10
|
+
constructor(message, expiredAt){
|
|
11
|
+
super(message);
|
|
12
|
+
this.name = 'TokenExpiredError';
|
|
13
|
+
}
|
|
14
|
+
},
|
|
15
|
+
JsonWebTokenError: class JsonWebTokenError extends Error {
|
|
16
|
+
constructor(message){
|
|
17
|
+
super(message);
|
|
18
|
+
this.name = 'JsonWebTokenError';
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
}));
|
|
23
|
+
describe('JWT Utilities Security', ()=>{
|
|
24
|
+
const mockSecret = 'test-secret-key';
|
|
25
|
+
const mockConfig = {};
|
|
26
|
+
beforeEach(()=>{
|
|
27
|
+
process.env.JWT_SECRET = mockSecret;
|
|
28
|
+
vi.clearAllMocks();
|
|
29
|
+
});
|
|
30
|
+
describe('generateMagicLinkToken', ()=>{
|
|
31
|
+
it('should generate token with correct payload', ()=>{
|
|
32
|
+
const mockToken = 'mock-magic-link-token';
|
|
33
|
+
jwt.sign.mockReturnValue(mockToken);
|
|
34
|
+
const token = generateMagicLinkToken('sub-123', 'test@example.com', mockConfig);
|
|
35
|
+
expect(jwt.sign).toHaveBeenCalledWith({
|
|
36
|
+
subscriberId: 'sub-123',
|
|
37
|
+
email: 'test@example.com',
|
|
38
|
+
type: 'magic-link'
|
|
39
|
+
}, mockSecret, {
|
|
40
|
+
expiresIn: '7d',
|
|
41
|
+
issuer: 'payload-newsletter-plugin'
|
|
42
|
+
});
|
|
43
|
+
expect(token).toBe(mockToken);
|
|
44
|
+
});
|
|
45
|
+
it('should respect custom token expiration', ()=>{
|
|
46
|
+
const customConfig = {
|
|
47
|
+
auth: {
|
|
48
|
+
tokenExpiration: '24h'
|
|
49
|
+
}
|
|
50
|
+
};
|
|
51
|
+
generateMagicLinkToken('sub-123', 'test@example.com', customConfig);
|
|
52
|
+
const options = jwt.sign.mock.calls[0][2];
|
|
53
|
+
expect(options.expiresIn).toBe('24h');
|
|
54
|
+
});
|
|
55
|
+
it('should include correct token type', ()=>{
|
|
56
|
+
generateMagicLinkToken('sub-123', 'test@example.com', mockConfig);
|
|
57
|
+
const payload = jwt.sign.mock.calls[0][0];
|
|
58
|
+
expect(payload.type).toBe('magic-link');
|
|
59
|
+
expect(payload).not.toHaveProperty('action'); // No action field in actual implementation
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
describe('verifyMagicLinkToken', ()=>{
|
|
63
|
+
it('should verify and return valid token payload', ()=>{
|
|
64
|
+
const mockPayload = {
|
|
65
|
+
subscriberId: 'sub-123',
|
|
66
|
+
email: 'test@example.com',
|
|
67
|
+
type: 'magic-link'
|
|
68
|
+
};
|
|
69
|
+
jwt.verify.mockReturnValue(mockPayload);
|
|
70
|
+
const payload = verifyMagicLinkToken('valid-token');
|
|
71
|
+
expect(jwt.verify).toHaveBeenCalledWith('valid-token', mockSecret, {
|
|
72
|
+
issuer: 'payload-newsletter-plugin'
|
|
73
|
+
});
|
|
74
|
+
expect(payload).toEqual(mockPayload);
|
|
75
|
+
});
|
|
76
|
+
it('should reject tokens with wrong type', ()=>{
|
|
77
|
+
const wrongTypePayload = {
|
|
78
|
+
subscriberId: 'sub-123',
|
|
79
|
+
email: 'test@example.com',
|
|
80
|
+
type: 'session'
|
|
81
|
+
};
|
|
82
|
+
jwt.verify.mockReturnValue(wrongTypePayload);
|
|
83
|
+
expect(()=>verifyMagicLinkToken('wrong-type-token')).toThrow('Invalid token type');
|
|
84
|
+
});
|
|
85
|
+
it('should handle expired tokens', ()=>{
|
|
86
|
+
;
|
|
87
|
+
jwt.verify.mockImplementation(()=>{
|
|
88
|
+
const error = new Error('Token expired');
|
|
89
|
+
error.name = 'TokenExpiredError';
|
|
90
|
+
throw error;
|
|
91
|
+
});
|
|
92
|
+
expect(()=>verifyMagicLinkToken('expired-token')).toThrow('Magic link has expired');
|
|
93
|
+
});
|
|
94
|
+
it('should handle malformed tokens', ()=>{
|
|
95
|
+
;
|
|
96
|
+
jwt.verify.mockImplementation(()=>{
|
|
97
|
+
const error = new Error('Malformed token');
|
|
98
|
+
error.name = 'JsonWebTokenError';
|
|
99
|
+
throw error;
|
|
100
|
+
});
|
|
101
|
+
expect(()=>verifyMagicLinkToken('malformed-token')).toThrow('Invalid magic link token');
|
|
102
|
+
});
|
|
103
|
+
it('should validate required fields', ()=>{
|
|
104
|
+
const incompletePayload = {
|
|
105
|
+
subscriberId: 'sub-123',
|
|
106
|
+
// Missing email
|
|
107
|
+
type: 'magic-link'
|
|
108
|
+
};
|
|
109
|
+
jwt.verify.mockReturnValue(incompletePayload);
|
|
110
|
+
// The actual implementation doesn't validate fields, just type
|
|
111
|
+
const payload = verifyMagicLinkToken('incomplete-token');
|
|
112
|
+
expect(payload).toEqual(incompletePayload);
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
describe('generateSessionToken', ()=>{
|
|
116
|
+
it('should generate session token with correct payload', ()=>{
|
|
117
|
+
const mockToken = 'mock-session-token';
|
|
118
|
+
jwt.sign.mockReturnValue(mockToken);
|
|
119
|
+
const token = generateSessionToken('sub-123', 'test@example.com');
|
|
120
|
+
expect(jwt.sign).toHaveBeenCalledWith({
|
|
121
|
+
subscriberId: 'sub-123',
|
|
122
|
+
email: 'test@example.com',
|
|
123
|
+
type: 'session'
|
|
124
|
+
}, mockSecret, {
|
|
125
|
+
expiresIn: '30d',
|
|
126
|
+
issuer: 'payload-newsletter-plugin'
|
|
127
|
+
});
|
|
128
|
+
expect(token).toBe(mockToken);
|
|
129
|
+
});
|
|
130
|
+
it('should set correct token type', ()=>{
|
|
131
|
+
generateSessionToken('sub-123', 'test@example.com');
|
|
132
|
+
const payload = jwt.sign.mock.calls[0][0];
|
|
133
|
+
expect(payload.type).toBe('session');
|
|
134
|
+
expect(payload.subscriberId).toBe('sub-123');
|
|
135
|
+
});
|
|
136
|
+
it('should set longer expiration for sessions', ()=>{
|
|
137
|
+
generateSessionToken('sub-123', 'test@example.com');
|
|
138
|
+
const options = jwt.sign.mock.calls[0][2];
|
|
139
|
+
expect(options.expiresIn).toBe('30d');
|
|
140
|
+
});
|
|
141
|
+
});
|
|
142
|
+
describe('verifySessionToken', ()=>{
|
|
143
|
+
it('should verify and return valid session payload', ()=>{
|
|
144
|
+
const mockPayload = {
|
|
145
|
+
subscriberId: 'sub-123',
|
|
146
|
+
email: 'test@example.com',
|
|
147
|
+
type: 'session'
|
|
148
|
+
};
|
|
149
|
+
jwt.verify.mockReturnValue(mockPayload);
|
|
150
|
+
const payload = verifySessionToken('valid-session');
|
|
151
|
+
expect(jwt.verify).toHaveBeenCalledWith('valid-session', mockSecret, {
|
|
152
|
+
issuer: 'payload-newsletter-plugin'
|
|
153
|
+
});
|
|
154
|
+
expect(payload).toEqual(mockPayload);
|
|
155
|
+
});
|
|
156
|
+
it('should reject non-session tokens', ()=>{
|
|
157
|
+
const magicLinkPayload = {
|
|
158
|
+
subscriberId: 'sub-123',
|
|
159
|
+
email: 'test@example.com',
|
|
160
|
+
type: 'magic-link'
|
|
161
|
+
};
|
|
162
|
+
jwt.verify.mockReturnValue(magicLinkPayload);
|
|
163
|
+
expect(()=>verifySessionToken('magic-link-token')).toThrow('Invalid token type');
|
|
164
|
+
});
|
|
165
|
+
it('should handle expired session tokens', ()=>{
|
|
166
|
+
;
|
|
167
|
+
jwt.verify.mockImplementation(()=>{
|
|
168
|
+
const error = new Error('Token expired');
|
|
169
|
+
error.name = 'TokenExpiredError';
|
|
170
|
+
throw error;
|
|
171
|
+
});
|
|
172
|
+
expect(()=>verifySessionToken('expired-token')).toThrow('Session has expired');
|
|
173
|
+
});
|
|
174
|
+
});
|
|
175
|
+
describe('Environment Configuration', ()=>{
|
|
176
|
+
it('should use fallback secret if JWT_SECRET is not set', ()=>{
|
|
177
|
+
delete process.env.JWT_SECRET;
|
|
178
|
+
delete process.env.PAYLOAD_SECRET;
|
|
179
|
+
// Should not throw, uses development placeholder
|
|
180
|
+
expect(()=>generateMagicLinkToken('sub-123', 'test@example.com', mockConfig)).not.toThrow();
|
|
181
|
+
// Check that it uses the placeholder secret
|
|
182
|
+
expect(jwt.sign).toHaveBeenCalledWith(expect.any(Object), 'INSECURE_DEVELOPMENT_SECRET_PLEASE_SET_JWT_SECRET', expect.any(Object));
|
|
183
|
+
});
|
|
184
|
+
it('should use PAYLOAD_SECRET as fallback', ()=>{
|
|
185
|
+
delete process.env.JWT_SECRET;
|
|
186
|
+
process.env.PAYLOAD_SECRET = 'payload-secret';
|
|
187
|
+
generateMagicLinkToken('sub-123', 'test@example.com', mockConfig);
|
|
188
|
+
expect(jwt.sign).toHaveBeenCalledWith(expect.any(Object), 'payload-secret', expect.any(Object));
|
|
189
|
+
});
|
|
190
|
+
it('should not expose secret in error messages', ()=>{
|
|
191
|
+
process.env.JWT_SECRET = 'super-secret-key-12345';
|
|
192
|
+
jwt.verify.mockImplementation(()=>{
|
|
193
|
+
throw new Error('invalid signature');
|
|
194
|
+
});
|
|
195
|
+
try {
|
|
196
|
+
verifyMagicLinkToken('bad-token');
|
|
197
|
+
} catch (error) {
|
|
198
|
+
expect(error.message).not.toContain('super-secret-key-12345');
|
|
199
|
+
}
|
|
200
|
+
});
|
|
201
|
+
});
|
|
202
|
+
describe('Token Security', ()=>{
|
|
203
|
+
it('should not allow algorithm switching attacks', ()=>{
|
|
204
|
+
// Ensure tokens are verified with the expected algorithm
|
|
205
|
+
const maliciousToken = 'eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.eyJzdWJzY3JpYmVySWQiOiJzdWItMTIzIn0.';
|
|
206
|
+
jwt.verify.mockImplementation(()=>{
|
|
207
|
+
const error = new Error('invalid algorithm');
|
|
208
|
+
error.name = 'JsonWebTokenError';
|
|
209
|
+
throw error;
|
|
210
|
+
});
|
|
211
|
+
expect(()=>verifyMagicLinkToken(maliciousToken)).toThrow('Invalid magic link token');
|
|
212
|
+
});
|
|
213
|
+
});
|
|
214
|
+
describe('generateMagicLinkURL', ()=>{
|
|
215
|
+
it('should generate valid magic link URLs', ()=>{
|
|
216
|
+
const token = 'test-token-123';
|
|
217
|
+
const baseURL = 'https://example.com';
|
|
218
|
+
const url = generateMagicLinkURL(token, baseURL, mockConfig);
|
|
219
|
+
expect(url).toBe('https://example.com/newsletter/verify?token=test-token-123');
|
|
220
|
+
});
|
|
221
|
+
it('should use custom path from config', ()=>{
|
|
222
|
+
const customConfig = {
|
|
223
|
+
auth: {
|
|
224
|
+
magicLinkPath: '/auth/magic'
|
|
225
|
+
}
|
|
226
|
+
};
|
|
227
|
+
const url = generateMagicLinkURL('token-123', 'https://example.com', customConfig);
|
|
228
|
+
expect(url).toBe('https://example.com/auth/magic?token=token-123');
|
|
229
|
+
});
|
|
230
|
+
it('should properly encode token in URL', ()=>{
|
|
231
|
+
const tokenWithSpecialChars = 'token+with/special=chars';
|
|
232
|
+
const url = generateMagicLinkURL(tokenWithSpecialChars, 'https://example.com', mockConfig);
|
|
233
|
+
expect(url).toContain(encodeURIComponent(tokenWithSpecialChars));
|
|
234
|
+
});
|
|
235
|
+
});
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
//# sourceMappingURL=jwt.test.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../../../../src/__tests__/unit/utils/jwt.test.ts"],"sourcesContent":["import { describe, it, expect, beforeEach, vi } from 'vitest'\nimport jwt from 'jsonwebtoken'\nimport {\n generateMagicLinkToken,\n verifyMagicLinkToken,\n generateSessionToken,\n verifySessionToken,\n generateMagicLinkURL,\n} from '../../../utils/jwt'\nimport type { NewsletterPluginConfig } from '../../../types'\n\n// Mock jsonwebtoken\nvi.mock('jsonwebtoken', () => ({\n default: {\n sign: vi.fn(),\n verify: vi.fn(),\n TokenExpiredError: class TokenExpiredError extends Error {\n constructor(message: string, expiredAt: Date) {\n super(message)\n this.name = 'TokenExpiredError'\n }\n },\n JsonWebTokenError: class JsonWebTokenError extends Error {\n constructor(message: string) {\n super(message)\n this.name = 'JsonWebTokenError'\n }\n },\n },\n}))\n\ndescribe('JWT Utilities Security', () => {\n const mockSecret = 'test-secret-key'\n const mockConfig: NewsletterPluginConfig = {}\n \n beforeEach(() => {\n process.env.JWT_SECRET = mockSecret\n vi.clearAllMocks()\n })\n\n describe('generateMagicLinkToken', () => {\n it('should generate token with correct payload', () => {\n const mockToken = 'mock-magic-link-token'\n ;(jwt.sign as any).mockReturnValue(mockToken)\n\n const token = generateMagicLinkToken('sub-123', 'test@example.com', mockConfig)\n\n expect(jwt.sign).toHaveBeenCalledWith(\n {\n subscriberId: 'sub-123',\n email: 'test@example.com',\n type: 'magic-link',\n },\n mockSecret,\n {\n expiresIn: '7d',\n issuer: 'payload-newsletter-plugin',\n }\n )\n expect(token).toBe(mockToken)\n })\n\n it('should respect custom token expiration', () => {\n const customConfig: NewsletterPluginConfig = {\n auth: {\n tokenExpiration: '24h',\n },\n }\n \n generateMagicLinkToken('sub-123', 'test@example.com', customConfig)\n\n const options = (jwt.sign as any).mock.calls[0][2]\n expect(options.expiresIn).toBe('24h')\n })\n\n it('should include correct token type', () => {\n generateMagicLinkToken('sub-123', 'test@example.com', mockConfig)\n\n const payload = (jwt.sign as any).mock.calls[0][0]\n expect(payload.type).toBe('magic-link')\n expect(payload).not.toHaveProperty('action') // No action field in actual implementation\n })\n })\n\n describe('verifyMagicLinkToken', () => {\n it('should verify and return valid token payload', () => {\n const mockPayload = {\n subscriberId: 'sub-123',\n email: 'test@example.com',\n type: 'magic-link',\n }\n ;(jwt.verify as any).mockReturnValue(mockPayload)\n\n const payload = verifyMagicLinkToken('valid-token')\n\n expect(jwt.verify).toHaveBeenCalledWith('valid-token', mockSecret, {\n issuer: 'payload-newsletter-plugin',\n })\n expect(payload).toEqual(mockPayload)\n })\n\n it('should reject tokens with wrong type', () => {\n const wrongTypePayload = {\n subscriberId: 'sub-123',\n email: 'test@example.com',\n type: 'session', // Wrong type\n }\n ;(jwt.verify as any).mockReturnValue(wrongTypePayload)\n\n expect(() => verifyMagicLinkToken('wrong-type-token')).toThrow('Invalid token type')\n })\n\n it('should handle expired tokens', () => {\n ;(jwt.verify as any).mockImplementation(() => {\n const error = new Error('Token expired')\n error.name = 'TokenExpiredError'\n throw error\n })\n\n expect(() => verifyMagicLinkToken('expired-token')).toThrow('Magic link has expired')\n })\n\n it('should handle malformed tokens', () => {\n ;(jwt.verify as any).mockImplementation(() => {\n const error = new Error('Malformed token')\n error.name = 'JsonWebTokenError'\n throw error\n })\n\n expect(() => verifyMagicLinkToken('malformed-token')).toThrow('Invalid magic link token')\n })\n\n it('should validate required fields', () => {\n const incompletePayload = {\n subscriberId: 'sub-123',\n // Missing email\n type: 'magic-link',\n }\n ;(jwt.verify as any).mockReturnValue(incompletePayload)\n\n // The actual implementation doesn't validate fields, just type\n const payload = verifyMagicLinkToken('incomplete-token')\n expect(payload).toEqual(incompletePayload)\n })\n })\n\n describe('generateSessionToken', () => {\n it('should generate session token with correct payload', () => {\n const mockToken = 'mock-session-token'\n ;(jwt.sign as any).mockReturnValue(mockToken)\n\n const token = generateSessionToken('sub-123', 'test@example.com')\n\n expect(jwt.sign).toHaveBeenCalledWith(\n {\n subscriberId: 'sub-123',\n email: 'test@example.com',\n type: 'session',\n },\n mockSecret,\n {\n expiresIn: '30d',\n issuer: 'payload-newsletter-plugin',\n }\n )\n expect(token).toBe(mockToken)\n })\n\n it('should set correct token type', () => {\n generateSessionToken('sub-123', 'test@example.com')\n\n const payload = (jwt.sign as any).mock.calls[0][0]\n expect(payload.type).toBe('session')\n expect(payload.subscriberId).toBe('sub-123')\n })\n\n it('should set longer expiration for sessions', () => {\n generateSessionToken('sub-123', 'test@example.com')\n\n const options = (jwt.sign as any).mock.calls[0][2]\n expect(options.expiresIn).toBe('30d')\n })\n })\n\n describe('verifySessionToken', () => {\n it('should verify and return valid session payload', () => {\n const mockPayload = {\n subscriberId: 'sub-123',\n email: 'test@example.com',\n type: 'session',\n }\n ;(jwt.verify as any).mockReturnValue(mockPayload)\n\n const payload = verifySessionToken('valid-session')\n\n expect(jwt.verify).toHaveBeenCalledWith('valid-session', mockSecret, {\n issuer: 'payload-newsletter-plugin',\n })\n expect(payload).toEqual(mockPayload)\n })\n\n it('should reject non-session tokens', () => {\n const magicLinkPayload = {\n subscriberId: 'sub-123',\n email: 'test@example.com',\n type: 'magic-link', // Wrong type for session\n }\n ;(jwt.verify as any).mockReturnValue(magicLinkPayload)\n\n expect(() => verifySessionToken('magic-link-token')).toThrow('Invalid token type')\n })\n\n it('should handle expired session tokens', () => {\n ;(jwt.verify as any).mockImplementation(() => {\n const error = new Error('Token expired')\n error.name = 'TokenExpiredError'\n throw error\n })\n\n expect(() => verifySessionToken('expired-token')).toThrow('Session has expired')\n })\n })\n\n describe('Environment Configuration', () => {\n it('should use fallback secret if JWT_SECRET is not set', () => {\n delete process.env.JWT_SECRET\n delete process.env.PAYLOAD_SECRET\n\n // Should not throw, uses development placeholder\n expect(() => generateMagicLinkToken('sub-123', 'test@example.com', mockConfig)).not.toThrow()\n \n // Check that it uses the placeholder secret\n expect(jwt.sign).toHaveBeenCalledWith(\n expect.any(Object),\n 'INSECURE_DEVELOPMENT_SECRET_PLEASE_SET_JWT_SECRET',\n expect.any(Object)\n )\n })\n\n it('should use PAYLOAD_SECRET as fallback', () => {\n delete process.env.JWT_SECRET\n process.env.PAYLOAD_SECRET = 'payload-secret'\n\n generateMagicLinkToken('sub-123', 'test@example.com', mockConfig)\n\n expect(jwt.sign).toHaveBeenCalledWith(\n expect.any(Object),\n 'payload-secret',\n expect.any(Object)\n )\n })\n\n it('should not expose secret in error messages', () => {\n process.env.JWT_SECRET = 'super-secret-key-12345'\n ;(jwt.verify as any).mockImplementation(() => {\n throw new Error('invalid signature')\n })\n\n try {\n verifyMagicLinkToken('bad-token')\n } catch (error: any) {\n expect(error.message).not.toContain('super-secret-key-12345')\n }\n })\n })\n\n describe('Token Security', () => {\n it('should not allow algorithm switching attacks', () => {\n // Ensure tokens are verified with the expected algorithm\n const maliciousToken = 'eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.eyJzdWJzY3JpYmVySWQiOiJzdWItMTIzIn0.'\n \n ;(jwt.verify as any).mockImplementation(() => {\n const error = new Error('invalid algorithm')\n error.name = 'JsonWebTokenError'\n throw error\n })\n\n expect(() => verifyMagicLinkToken(maliciousToken)).toThrow('Invalid magic link token')\n })\n })\n\n describe('generateMagicLinkURL', () => {\n it('should generate valid magic link URLs', () => {\n const token = 'test-token-123'\n const baseURL = 'https://example.com'\n \n const url = generateMagicLinkURL(token, baseURL, mockConfig)\n \n expect(url).toBe('https://example.com/newsletter/verify?token=test-token-123')\n })\n\n it('should use custom path from config', () => {\n const customConfig: NewsletterPluginConfig = {\n auth: {\n magicLinkPath: '/auth/magic',\n },\n }\n \n const url = generateMagicLinkURL('token-123', 'https://example.com', customConfig)\n \n expect(url).toBe('https://example.com/auth/magic?token=token-123')\n })\n\n it('should properly encode token in URL', () => {\n const tokenWithSpecialChars = 'token+with/special=chars'\n \n const url = generateMagicLinkURL(tokenWithSpecialChars, 'https://example.com', mockConfig)\n \n expect(url).toContain(encodeURIComponent(tokenWithSpecialChars))\n })\n })\n})"],"names":["describe","it","expect","beforeEach","vi","jwt","generateMagicLinkToken","verifyMagicLinkToken","generateSessionToken","verifySessionToken","generateMagicLinkURL","mock","default","sign","fn","verify","TokenExpiredError","Error","message","expiredAt","name","JsonWebTokenError","mockSecret","mockConfig","process","env","JWT_SECRET","clearAllMocks","mockToken","mockReturnValue","token","toHaveBeenCalledWith","subscriberId","email","type","expiresIn","issuer","toBe","customConfig","auth","tokenExpiration","options","calls","payload","not","toHaveProperty","mockPayload","toEqual","wrongTypePayload","toThrow","mockImplementation","error","incompletePayload","magicLinkPayload","PAYLOAD_SECRET","any","Object","toContain","maliciousToken","baseURL","url","magicLinkPath","tokenWithSpecialChars","encodeURIComponent"],"mappings":"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"}
|
package/dist/src/utils/access.js
CHANGED
|
@@ -37,16 +37,40 @@
|
|
|
37
37
|
* Create admin or owner access control
|
|
38
38
|
*/ export const adminOrSelf = (config)=>({ req, id })=>{
|
|
39
39
|
const user = req.user;
|
|
40
|
+
// No user = no access
|
|
41
|
+
if (!user) {
|
|
42
|
+
// For list operations without ID, return impossible condition
|
|
43
|
+
if (!id) {
|
|
44
|
+
return {
|
|
45
|
+
id: {
|
|
46
|
+
equals: 'unauthorized-no-access'
|
|
47
|
+
}
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
return false;
|
|
51
|
+
}
|
|
40
52
|
// Admins can access everything
|
|
41
53
|
if (isAdmin(user, config)) {
|
|
42
54
|
return true;
|
|
43
55
|
}
|
|
44
|
-
//
|
|
45
|
-
|
|
46
|
-
|
|
56
|
+
// Synthetic users (subscribers from magic link) can access their own data
|
|
57
|
+
if (user.collection === 'subscribers') {
|
|
58
|
+
// For list operations, scope to their own data
|
|
59
|
+
if (!id) {
|
|
60
|
+
return {
|
|
61
|
+
id: {
|
|
62
|
+
equals: user.id
|
|
63
|
+
}
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
// For specific document access, check if it's their own
|
|
67
|
+
return id === user.id;
|
|
68
|
+
}
|
|
69
|
+
// Regular users cannot access subscriber data
|
|
70
|
+
if (!id) {
|
|
47
71
|
return {
|
|
48
72
|
id: {
|
|
49
|
-
equals:
|
|
73
|
+
equals: 'unauthorized-no-access'
|
|
50
74
|
}
|
|
51
75
|
};
|
|
52
76
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../src/utils/access.ts"],"sourcesContent":["import type { Access, AccessArgs } from 'payload'\nimport type { NewsletterPluginConfig } from '../types'\n\n/**\n * Check if a user is an admin based on the plugin configuration\n */\nexport const isAdmin = (user: any, config?: NewsletterPluginConfig): boolean => {\n if (!user || user.collection !== 'users') {\n return false\n }\n\n // If custom admin check is provided, use it\n if (config?.access?.isAdmin) {\n return config.access.isAdmin(user)\n }\n\n // Default checks for common admin patterns\n // 1. Check for admin role\n if (user.roles?.includes('admin')) {\n return true\n }\n\n // 2. Check for isAdmin boolean field\n if (user.isAdmin === true) {\n return true\n }\n\n // 3. Check for role field with admin value\n if (user.role === 'admin') {\n return true\n }\n\n // 4. Check for admin collection relationship\n if (user.admin === true) {\n return true\n }\n\n return false\n}\n\n/**\n * Create admin-only access control\n */\nexport const adminOnly = (config?: NewsletterPluginConfig): Access => \n ({ req }: AccessArgs) => {\n const user = req.user\n return isAdmin(user, config)\n }\n\n/**\n * Create admin or owner access control\n */\nexport const adminOrSelf = (config?: NewsletterPluginConfig): Access => \n ({ req, id }: AccessArgs) => {\n const user = req.user\n \n // Admins can access everything\n if (isAdmin(user, config)) {\n return true\n }\n \n //
|
|
1
|
+
{"version":3,"sources":["../../../src/utils/access.ts"],"sourcesContent":["import type { Access, AccessArgs } from 'payload'\nimport type { NewsletterPluginConfig } from '../types'\n\n/**\n * Check if a user is an admin based on the plugin configuration\n */\nexport const isAdmin = (user: any, config?: NewsletterPluginConfig): boolean => {\n if (!user || user.collection !== 'users') {\n return false\n }\n\n // If custom admin check is provided, use it\n if (config?.access?.isAdmin) {\n return config.access.isAdmin(user)\n }\n\n // Default checks for common admin patterns\n // 1. Check for admin role\n if (user.roles?.includes('admin')) {\n return true\n }\n\n // 2. Check for isAdmin boolean field\n if (user.isAdmin === true) {\n return true\n }\n\n // 3. Check for role field with admin value\n if (user.role === 'admin') {\n return true\n }\n\n // 4. Check for admin collection relationship\n if (user.admin === true) {\n return true\n }\n\n return false\n}\n\n/**\n * Create admin-only access control\n */\nexport const adminOnly = (config?: NewsletterPluginConfig): Access => \n ({ req }: AccessArgs) => {\n const user = req.user\n return isAdmin(user, config)\n }\n\n/**\n * Create admin or owner access control\n */\nexport const adminOrSelf = (config?: NewsletterPluginConfig): Access => \n ({ req, id }: AccessArgs) => {\n const user = req.user\n \n // No user = no access\n if (!user) {\n // For list operations without ID, return impossible condition\n if (!id) {\n return {\n id: {\n equals: 'unauthorized-no-access',\n },\n }\n }\n return false\n }\n \n // Admins can access everything\n if (isAdmin(user, config)) {\n return true\n }\n \n // Synthetic users (subscribers from magic link) can access their own data\n if (user.collection === 'subscribers') {\n // For list operations, scope to their own data\n if (!id) {\n return {\n id: {\n equals: user.id,\n },\n }\n }\n // For specific document access, check if it's their own\n return id === user.id\n }\n \n // Regular users cannot access subscriber data\n if (!id) {\n return {\n id: {\n equals: 'unauthorized-no-access',\n },\n }\n }\n return false\n }"],"names":["isAdmin","user","config","collection","access","roles","includes","role","admin","adminOnly","req","adminOrSelf","id","equals"],"mappings":"AAGA;;CAEC,GACD,OAAO,MAAMA,UAAU,CAACC,MAAWC;IACjC,IAAI,CAACD,QAAQA,KAAKE,UAAU,KAAK,SAAS;QACxC,OAAO;IACT;IAEA,4CAA4C;IAC5C,IAAID,QAAQE,QAAQJ,SAAS;QAC3B,OAAOE,OAAOE,MAAM,CAACJ,OAAO,CAACC;IAC/B;IAEA,2CAA2C;IAC3C,0BAA0B;IAC1B,IAAIA,KAAKI,KAAK,EAAEC,SAAS,UAAU;QACjC,OAAO;IACT;IAEA,qCAAqC;IACrC,IAAIL,KAAKD,OAAO,KAAK,MAAM;QACzB,OAAO;IACT;IAEA,2CAA2C;IAC3C,IAAIC,KAAKM,IAAI,KAAK,SAAS;QACzB,OAAO;IACT;IAEA,6CAA6C;IAC7C,IAAIN,KAAKO,KAAK,KAAK,MAAM;QACvB,OAAO;IACT;IAEA,OAAO;AACT,EAAC;AAED;;CAEC,GACD,OAAO,MAAMC,YAAY,CAACP,SACxB,CAAC,EAAEQ,GAAG,EAAc;QAClB,MAAMT,OAAOS,IAAIT,IAAI;QACrB,OAAOD,QAAQC,MAAMC;IACvB,EAAC;AAEH;;CAEC,GACD,OAAO,MAAMS,cAAc,CAACT,SAC1B,CAAC,EAAEQ,GAAG,EAAEE,EAAE,EAAc;QACtB,MAAMX,OAAOS,IAAIT,IAAI;QAErB,sBAAsB;QACtB,IAAI,CAACA,MAAM;YACT,8DAA8D;YAC9D,IAAI,CAACW,IAAI;gBACP,OAAO;oBACLA,IAAI;wBACFC,QAAQ;oBACV;gBACF;YACF;YACA,OAAO;QACT;QAEA,+BAA+B;QAC/B,IAAIb,QAAQC,MAAMC,SAAS;YACzB,OAAO;QACT;QAEA,0EAA0E;QAC1E,IAAID,KAAKE,UAAU,KAAK,eAAe;YACrC,+CAA+C;YAC/C,IAAI,CAACS,IAAI;gBACP,OAAO;oBACLA,IAAI;wBACFC,QAAQZ,KAAKW,EAAE;oBACjB;gBACF;YACF;YACA,wDAAwD;YACxD,OAAOA,OAAOX,KAAKW,EAAE;QACvB;QAEA,8CAA8C;QAC9C,IAAI,CAACA,IAAI;YACP,OAAO;gBACLA,IAAI;oBACFC,QAAQ;gBACV;YACF;QACF;QACA,OAAO;IACT,EAAC"}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import DOMPurify from 'isomorphic-dompurify';
|
|
1
2
|
/**
|
|
2
3
|
* Validate email address format
|
|
3
4
|
*/ export function isValidEmail(email) {
|
|
@@ -18,7 +19,14 @@
|
|
|
18
19
|
/**
|
|
19
20
|
* Sanitize user input to prevent XSS
|
|
20
21
|
*/ export function sanitizeInput(input) {
|
|
21
|
-
|
|
22
|
+
if (!input) return '';
|
|
23
|
+
// Remove all HTML tags and scripts
|
|
24
|
+
const cleaned = DOMPurify.sanitize(input, {
|
|
25
|
+
ALLOWED_TAGS: [],
|
|
26
|
+
ALLOWED_ATTR: [],
|
|
27
|
+
KEEP_CONTENT: true
|
|
28
|
+
});
|
|
29
|
+
return cleaned.trim();
|
|
22
30
|
}
|
|
23
31
|
/**
|
|
24
32
|
* Extract UTM parameters from URL search params
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../src/utils/validation.ts"],"sourcesContent":["/**\n * Validate email address format\n */\nexport function isValidEmail(email: string): boolean {\n const emailRegex = /^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$/\n return emailRegex.test(email)\n}\n\n/**\n * Check if email domain is allowed\n */\nexport function isDomainAllowed(\n email: string,\n allowedDomains?: string[]\n): boolean {\n // If no domains specified, allow all\n if (!allowedDomains || allowedDomains.length === 0) {\n return true\n }\n\n const domain = email.split('@')[1]?.toLowerCase()\n if (!domain) return false\n\n return allowedDomains.some(\n allowedDomain => domain === allowedDomain.toLowerCase()\n )\n}\n\n/**\n * Sanitize user input to prevent XSS\n */\nexport function sanitizeInput(input: string): string {\n
|
|
1
|
+
{"version":3,"sources":["../../../src/utils/validation.ts"],"sourcesContent":["import DOMPurify from 'isomorphic-dompurify'\n\n/**\n * Validate email address format\n */\nexport function isValidEmail(email: string): boolean {\n const emailRegex = /^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$/\n return emailRegex.test(email)\n}\n\n/**\n * Check if email domain is allowed\n */\nexport function isDomainAllowed(\n email: string,\n allowedDomains?: string[]\n): boolean {\n // If no domains specified, allow all\n if (!allowedDomains || allowedDomains.length === 0) {\n return true\n }\n\n const domain = email.split('@')[1]?.toLowerCase()\n if (!domain) return false\n\n return allowedDomains.some(\n allowedDomain => domain === allowedDomain.toLowerCase()\n )\n}\n\n/**\n * Sanitize user input to prevent XSS\n */\nexport function sanitizeInput(input: string): string {\n if (!input) return ''\n \n // Remove all HTML tags and scripts\n const cleaned = DOMPurify.sanitize(input, { \n ALLOWED_TAGS: [],\n ALLOWED_ATTR: [],\n KEEP_CONTENT: true\n })\n \n return cleaned.trim()\n}\n\n/**\n * Extract UTM parameters from URL search params\n */\nexport function extractUTMParams(searchParams: URLSearchParams): Record<string, string> {\n const utmParams: Record<string, string> = {}\n const utmKeys = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_content', 'utm_term']\n\n utmKeys.forEach(key => {\n const value = searchParams.get(key)\n if (value) {\n // Remove 'utm_' prefix for storage\n const shortKey = key.replace('utm_', '')\n utmParams[shortKey] = value\n }\n })\n\n return utmParams\n}\n\n/**\n * Validate subscriber data before creation\n */\nexport interface ValidateSubscriberResult {\n valid: boolean\n errors: string[]\n}\n\nexport function validateSubscriberData(data: any): ValidateSubscriberResult {\n const errors: string[] = []\n\n // Email validation\n if (!data.email) {\n errors.push('Email is required')\n } else if (!isValidEmail(data.email)) {\n errors.push('Invalid email format')\n }\n\n // Name validation (optional but if provided, should be reasonable)\n if (data.name && data.name.length > 100) {\n errors.push('Name is too long (max 100 characters)')\n }\n\n // Source validation\n if (data.source && data.source.length > 50) {\n errors.push('Source is too long (max 50 characters)')\n }\n\n return {\n valid: errors.length === 0,\n errors,\n }\n}"],"names":["DOMPurify","isValidEmail","email","emailRegex","test","isDomainAllowed","allowedDomains","length","domain","split","toLowerCase","some","allowedDomain","sanitizeInput","input","cleaned","sanitize","ALLOWED_TAGS","ALLOWED_ATTR","KEEP_CONTENT","trim","extractUTMParams","searchParams","utmParams","utmKeys","forEach","key","value","get","shortKey","replace","validateSubscriberData","data","errors","push","name","source","valid"],"mappings":"AAAA,OAAOA,eAAe,uBAAsB;AAE5C;;CAEC,GACD,OAAO,SAASC,aAAaC,KAAa;IACxC,MAAMC,aAAa;IACnB,OAAOA,WAAWC,IAAI,CAACF;AACzB;AAEA;;CAEC,GACD,OAAO,SAASG,gBACdH,KAAa,EACbI,cAAyB;IAEzB,qCAAqC;IACrC,IAAI,CAACA,kBAAkBA,eAAeC,MAAM,KAAK,GAAG;QAClD,OAAO;IACT;IAEA,MAAMC,SAASN,MAAMO,KAAK,CAAC,IAAI,CAAC,EAAE,EAAEC;IACpC,IAAI,CAACF,QAAQ,OAAO;IAEpB,OAAOF,eAAeK,IAAI,CACxBC,CAAAA,gBAAiBJ,WAAWI,cAAcF,WAAW;AAEzD;AAEA;;CAEC,GACD,OAAO,SAASG,cAAcC,KAAa;IACzC,IAAI,CAACA,OAAO,OAAO;IAEnB,mCAAmC;IACnC,MAAMC,UAAUf,UAAUgB,QAAQ,CAACF,OAAO;QACxCG,cAAc,EAAE;QAChBC,cAAc,EAAE;QAChBC,cAAc;IAChB;IAEA,OAAOJ,QAAQK,IAAI;AACrB;AAEA;;CAEC,GACD,OAAO,SAASC,iBAAiBC,YAA6B;IAC5D,MAAMC,YAAoC,CAAC;IAC3C,MAAMC,UAAU;QAAC;QAAc;QAAc;QAAgB;QAAe;KAAW;IAEvFA,QAAQC,OAAO,CAACC,CAAAA;QACd,MAAMC,QAAQL,aAAaM,GAAG,CAACF;QAC/B,IAAIC,OAAO;YACT,mCAAmC;YACnC,MAAME,WAAWH,IAAII,OAAO,CAAC,QAAQ;YACrCP,SAAS,CAACM,SAAS,GAAGF;QACxB;IACF;IAEA,OAAOJ;AACT;AAUA,OAAO,SAASQ,uBAAuBC,IAAS;IAC9C,MAAMC,SAAmB,EAAE;IAE3B,mBAAmB;IACnB,IAAI,CAACD,KAAK9B,KAAK,EAAE;QACf+B,OAAOC,IAAI,CAAC;IACd,OAAO,IAAI,CAACjC,aAAa+B,KAAK9B,KAAK,GAAG;QACpC+B,OAAOC,IAAI,CAAC;IACd;IAEA,mEAAmE;IACnE,IAAIF,KAAKG,IAAI,IAAIH,KAAKG,IAAI,CAAC5B,MAAM,GAAG,KAAK;QACvC0B,OAAOC,IAAI,CAAC;IACd;IAEA,oBAAoB;IACpB,IAAIF,KAAKI,MAAM,IAAIJ,KAAKI,MAAM,CAAC7B,MAAM,GAAG,IAAI;QAC1C0B,OAAOC,IAAI,CAAC;IACd;IAEA,OAAO;QACLG,OAAOJ,OAAO1B,MAAM,KAAK;QACzB0B;IACF;AACF"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"access.d.ts","sourceRoot":"","sources":["../../src/utils/access.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,MAAM,EAAc,MAAM,SAAS,CAAA;AACjD,OAAO,KAAK,EAAE,sBAAsB,EAAE,MAAM,UAAU,CAAA;AAEtD;;GAEG;AACH,eAAO,MAAM,OAAO,GAAI,MAAM,GAAG,EAAE,SAAS,sBAAsB,KAAG,OAgCpE,CAAA;AAED;;GAEG;AACH,eAAO,MAAM,SAAS,GAAI,SAAS,sBAAsB,KAAG,MAIzD,CAAA;AAEH;;GAEG;AACH,eAAO,MAAM,WAAW,GAAI,SAAS,sBAAsB,KAAG,
|
|
1
|
+
{"version":3,"file":"access.d.ts","sourceRoot":"","sources":["../../src/utils/access.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,MAAM,EAAc,MAAM,SAAS,CAAA;AACjD,OAAO,KAAK,EAAE,sBAAsB,EAAE,MAAM,UAAU,CAAA;AAEtD;;GAEG;AACH,eAAO,MAAM,OAAO,GAAI,MAAM,GAAG,EAAE,SAAS,sBAAsB,KAAG,OAgCpE,CAAA;AAED;;GAEG;AACH,eAAO,MAAM,SAAS,GAAI,SAAS,sBAAsB,KAAG,MAIzD,CAAA;AAEH;;GAEG;AACH,eAAO,MAAM,WAAW,GAAI,SAAS,sBAAsB,KAAG,MA6C3D,CAAA"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"validation.d.ts","sourceRoot":"","sources":["../../src/utils/validation.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"validation.d.ts","sourceRoot":"","sources":["../../src/utils/validation.ts"],"names":[],"mappings":"AAEA;;GAEG;AACH,wBAAgB,YAAY,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAGnD;AAED;;GAEG;AACH,wBAAgB,eAAe,CAC7B,KAAK,EAAE,MAAM,EACb,cAAc,CAAC,EAAE,MAAM,EAAE,GACxB,OAAO,CAYT;AAED;;GAEG;AACH,wBAAgB,aAAa,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CAWnD;AAED;;GAEG;AACH,wBAAgB,gBAAgB,CAAC,YAAY,EAAE,eAAe,GAAG,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CActF;AAED;;GAEG;AACH,MAAM,WAAW,wBAAwB;IACvC,KAAK,EAAE,OAAO,CAAA;IACd,MAAM,EAAE,MAAM,EAAE,CAAA;CACjB;AAED,wBAAgB,sBAAsB,CAAC,IAAI,EAAE,GAAG,GAAG,wBAAwB,CAwB1E"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "payload-plugin-newsletter",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.2",
|
|
4
4
|
"description": "Complete newsletter management plugin for Payload CMS with subscriber management, magic link authentication, and email service integration",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./src/index.ts",
|
|
@@ -35,6 +35,13 @@
|
|
|
35
35
|
"clean": "rimraf dist",
|
|
36
36
|
"lint": "eslint src",
|
|
37
37
|
"typecheck": "tsc --noEmit",
|
|
38
|
+
"test": "vitest",
|
|
39
|
+
"test:unit": "vitest run",
|
|
40
|
+
"test:integration": "vitest run --config vitest.integration.config.ts",
|
|
41
|
+
"test:security": "vitest run --config vitest.integration.config.ts src/__tests__/security",
|
|
42
|
+
"test:watch": "vitest watch",
|
|
43
|
+
"test:coverage": "vitest run --coverage",
|
|
44
|
+
"test:ui": "vitest --ui",
|
|
38
45
|
"generate:types": "tsc --emitDeclarationOnly --outDir dist",
|
|
39
46
|
"prepublishOnly": "bun run clean && bun run build",
|
|
40
47
|
"release:patch": "./scripts/release.sh patch",
|
|
@@ -72,34 +79,45 @@
|
|
|
72
79
|
},
|
|
73
80
|
"homepage": "https://github.com/aniketpanjwani/payload-plugin-email-newsletter#readme",
|
|
74
81
|
"dependencies": {
|
|
82
|
+
"@payloadcms/richtext-lexical": "^3.0.0",
|
|
75
83
|
"@payloadcms/translations": "^3.0.0",
|
|
76
84
|
"@payloadcms/ui": "^3.0.0",
|
|
77
|
-
"@payloadcms/richtext-lexical": "^3.0.0",
|
|
78
85
|
"@react-email/components": "^0.0.25",
|
|
86
|
+
"isomorphic-dompurify": "^2.25.0",
|
|
79
87
|
"jsonwebtoken": "^9.0.2",
|
|
80
88
|
"resend": "^4.0.0"
|
|
81
89
|
},
|
|
82
90
|
"devDependencies": {
|
|
83
91
|
"@eslint/js": "^9.29.0",
|
|
92
|
+
"@playwright/test": "^1.53.0",
|
|
84
93
|
"@swc/cli": "^0.4.0",
|
|
85
94
|
"@swc/core": "^1.7.0",
|
|
95
|
+
"@testing-library/jest-dom": "^6.6.3",
|
|
96
|
+
"@testing-library/react": "^16.3.0",
|
|
97
|
+
"@testing-library/user-event": "^14.6.1",
|
|
98
|
+
"@types/dompurify": "^3.2.0",
|
|
86
99
|
"@types/express": "^4.17.21",
|
|
87
100
|
"@types/jsonwebtoken": "^9.0.6",
|
|
88
|
-
"@types/node": "^
|
|
101
|
+
"@types/node": "^24.0.1",
|
|
89
102
|
"@types/react": "^18.3.0",
|
|
90
103
|
"@types/react-dom": "^18.3.0",
|
|
91
104
|
"@typescript-eslint/eslint-plugin": "^8.0.0",
|
|
92
105
|
"@typescript-eslint/parser": "^8.0.0",
|
|
106
|
+
"@vitest/coverage-v8": "^3.2.3",
|
|
107
|
+
"@vitest/ui": "^3.2.3",
|
|
93
108
|
"eslint": "^9.0.0",
|
|
94
109
|
"eslint-config-prettier": "^9.1.0",
|
|
95
110
|
"eslint-plugin-react": "^7.34.0",
|
|
96
111
|
"eslint-plugin-react-hooks": "^4.6.0",
|
|
112
|
+
"happy-dom": "^18.0.1",
|
|
113
|
+
"mongodb-memory-server": "^10.1.4",
|
|
97
114
|
"payload": "^3.0.0",
|
|
98
115
|
"prettier": "^3.3.0",
|
|
99
116
|
"react": "^18.3.0",
|
|
100
117
|
"react-dom": "^18.3.0",
|
|
101
118
|
"rimraf": "^6.0.0",
|
|
102
|
-
"typescript": "^5.5.0"
|
|
119
|
+
"typescript": "^5.5.0",
|
|
120
|
+
"vitest": "^3.2.3"
|
|
103
121
|
},
|
|
104
122
|
"peerDependencies": {
|
|
105
123
|
"payload": "^3.0.0",
|