This release significantly expands internationalization support and adds comprehensive identity management features. Internationalization (i18n): - Add 5 new languages: Spanish, Italian, German, Dutch, Portuguese - Expand from 3 to 8 total supported languages - Redesign language switcher for better scalability (dropdown UI) - Complete translations for all features across all languages Identity Management: - Multiple sender identities with per-identity signatures - Sub-addressing support (user+tag@domain.com) - Context-aware tag suggestions for sub-addresses - Identity badges in email viewer and list - Full CRUD operations for managing identities Newsletter Management: - RFC 2369 List-Unsubscribe support (one-click unsubscribe) - HTTP and mailto unsubscribe methods - Security validation prevents XSS attacks - Two-step confirmation with persistent dismissal Security & Accessibility: - Dark mode email readability (intelligent color transformation) - WCAG 2.0 Level AA color contrast compliance - Comprehensive XSS prevention with validation utilities - Unit test coverage for security-critical code (57 validation tests) Testing: - Add unit tests for validation utilities - Add unit tests for email sanitization - Add unit tests for color transformation - Full test coverage for XSS attack vectors
362 lines
14 KiB
TypeScript
362 lines
14 KiB
TypeScript
import { describe, it, expect } from 'vitest';
|
|
import {
|
|
isValidEmail,
|
|
validateEmailList,
|
|
getEmailValidationError,
|
|
isValidUnsubscribeUrl,
|
|
parseUnsubscribeUrls,
|
|
} from '../validation';
|
|
|
|
describe('validation', () => {
|
|
describe('isValidEmail', () => {
|
|
it('should accept valid basic emails', () => {
|
|
expect(isValidEmail('user@example.com')).toBe(true);
|
|
expect(isValidEmail('john.doe@company.co.uk')).toBe(true);
|
|
expect(isValidEmail('test_user@subdomain.example.com')).toBe(true);
|
|
});
|
|
|
|
it('should accept emails with plus addressing', () => {
|
|
expect(isValidEmail('user+tag@example.com')).toBe(true);
|
|
expect(isValidEmail('user+shopping@example.com')).toBe(true);
|
|
});
|
|
|
|
it('should accept various valid formats', () => {
|
|
expect(isValidEmail('a@b.co')).toBe(true);
|
|
expect(isValidEmail('user123@test-domain.com')).toBe(true);
|
|
expect(isValidEmail('first.last+tag@example.co.uk')).toBe(true);
|
|
});
|
|
|
|
it('should reject emails without @ symbol', () => {
|
|
expect(isValidEmail('userexample.com')).toBe(false);
|
|
expect(isValidEmail('user')).toBe(false);
|
|
});
|
|
|
|
it('should reject emails without domain', () => {
|
|
expect(isValidEmail('user@')).toBe(false);
|
|
expect(isValidEmail('@example.com')).toBe(false);
|
|
});
|
|
|
|
it('should reject header injection attempts', () => {
|
|
expect(isValidEmail('test\r\nBcc:evil@example.com')).toBe(false);
|
|
expect(isValidEmail('test\rBcc:evil@example.com')).toBe(false);
|
|
expect(isValidEmail('test\nBcc:evil@example.com')).toBe(false);
|
|
});
|
|
|
|
it('should reject emails with dangerous characters', () => {
|
|
expect(isValidEmail('test<script>@example.com')).toBe(false);
|
|
expect(isValidEmail('test>evil@example.com')).toBe(false);
|
|
expect(isValidEmail('test@evil>.com')).toBe(false);
|
|
});
|
|
|
|
it('should reject overly long emails', () => {
|
|
const longLocal = 'a'.repeat(256);
|
|
expect(isValidEmail(`${longLocal}@example.com`)).toBe(false);
|
|
});
|
|
|
|
it('should reject emails with local part > 64 chars', () => {
|
|
const longLocal = 'a'.repeat(65);
|
|
expect(isValidEmail(`${longLocal}@example.com`)).toBe(false);
|
|
});
|
|
|
|
it('should reject emails with domain > 255 chars', () => {
|
|
const longDomain = 'a'.repeat(256) + '.com';
|
|
expect(isValidEmail(`user@${longDomain}`)).toBe(false);
|
|
});
|
|
|
|
it('should reject domains starting or ending with dot', () => {
|
|
expect(isValidEmail('user@.example.com')).toBe(false);
|
|
expect(isValidEmail('user@example.com.')).toBe(false);
|
|
});
|
|
|
|
it('should reject domains with consecutive dots', () => {
|
|
expect(isValidEmail('user@example..com')).toBe(false);
|
|
expect(isValidEmail('user@sub..domain.com')).toBe(false);
|
|
});
|
|
|
|
it('should reject empty or null input', () => {
|
|
expect(isValidEmail('')).toBe(false);
|
|
expect(isValidEmail(' ')).toBe(false);
|
|
});
|
|
|
|
it('should handle edge cases', () => {
|
|
expect(isValidEmail('user@localhost')).toBe(true); // Valid per RFC
|
|
expect(isValidEmail('user@192.168.1.1')).toBe(true); // IP address domain
|
|
});
|
|
});
|
|
|
|
describe('validateEmailList', () => {
|
|
it('should validate single valid email', () => {
|
|
const result = validateEmailList('user@example.com');
|
|
expect(result.valid).toBe(true);
|
|
expect(result.invalidEmails).toEqual([]);
|
|
});
|
|
|
|
it('should validate multiple valid emails', () => {
|
|
const result = validateEmailList('user1@example.com, user2@test.com, user3@domain.co.uk');
|
|
expect(result.valid).toBe(true);
|
|
expect(result.invalidEmails).toEqual([]);
|
|
});
|
|
|
|
it('should handle whitespace around emails', () => {
|
|
const result = validateEmailList(' user1@example.com , user2@test.com ');
|
|
expect(result.valid).toBe(true);
|
|
expect(result.invalidEmails).toEqual([]);
|
|
});
|
|
|
|
it('should reject list with one invalid email', () => {
|
|
const result = validateEmailList('user1@example.com, invalid-email, user3@domain.com');
|
|
expect(result.valid).toBe(false);
|
|
expect(result.invalidEmails).toEqual(['invalid-email']);
|
|
});
|
|
|
|
it('should identify all invalid emails', () => {
|
|
const result = validateEmailList('user1@example.com, bad1, user2@test.com, bad2@');
|
|
expect(result.valid).toBe(false);
|
|
expect(result.invalidEmails).toContain('bad1');
|
|
expect(result.invalidEmails).toContain('bad2@');
|
|
expect(result.invalidEmails).toHaveLength(2);
|
|
});
|
|
|
|
it('should handle empty string', () => {
|
|
const result = validateEmailList('');
|
|
expect(result.valid).toBe(true);
|
|
expect(result.invalidEmails).toEqual([]);
|
|
});
|
|
|
|
it('should handle whitespace-only string', () => {
|
|
const result = validateEmailList(' ');
|
|
expect(result.valid).toBe(true);
|
|
expect(result.invalidEmails).toEqual([]);
|
|
});
|
|
|
|
it('should filter out empty entries from commas', () => {
|
|
const result = validateEmailList('user1@example.com,,user2@test.com,');
|
|
expect(result.valid).toBe(true);
|
|
expect(result.invalidEmails).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe('getEmailValidationError', () => {
|
|
it('should return null for valid email', () => {
|
|
expect(getEmailValidationError('user@example.com')).toBeNull();
|
|
expect(getEmailValidationError('user+tag@example.com')).toBeNull();
|
|
});
|
|
|
|
it('should return error for empty email', () => {
|
|
const error = getEmailValidationError('');
|
|
expect(error).not.toBeNull();
|
|
expect(error).toContain('required');
|
|
});
|
|
|
|
it('should return error for whitespace-only email', () => {
|
|
const error = getEmailValidationError(' ');
|
|
expect(error).not.toBeNull();
|
|
expect(error).toContain('required');
|
|
});
|
|
|
|
it('should return error for overly long email', () => {
|
|
const longEmail = 'a'.repeat(256) + '@example.com';
|
|
const error = getEmailValidationError(longEmail);
|
|
expect(error).not.toBeNull();
|
|
expect(error).toContain('too long');
|
|
expect(error).toContain('254');
|
|
});
|
|
|
|
it('should return error for dangerous characters', () => {
|
|
const error = getEmailValidationError('test\r\nBcc:evil@example.com');
|
|
expect(error).not.toBeNull();
|
|
expect(error).toContain('invalid characters');
|
|
});
|
|
|
|
it('should return error for invalid format', () => {
|
|
const error = getEmailValidationError('not-an-email');
|
|
expect(error).not.toBeNull();
|
|
expect(error).toContain('valid email');
|
|
});
|
|
|
|
it('should provide user-friendly messages', () => {
|
|
const error1 = getEmailValidationError('test@');
|
|
const error2 = getEmailValidationError('@example.com');
|
|
const error3 = getEmailValidationError('no-at-sign');
|
|
|
|
expect(error1).toContain('valid');
|
|
expect(error2).toContain('valid');
|
|
expect(error3).toContain('valid');
|
|
});
|
|
});
|
|
|
|
describe('isValidUnsubscribeUrl', () => {
|
|
describe('HTTP/HTTPS URLs', () => {
|
|
it('should accept valid HTTP URLs', () => {
|
|
expect(isValidUnsubscribeUrl('http://example.com/unsubscribe')).toBe(true);
|
|
expect(isValidUnsubscribeUrl('http://newsletter.example.com/unsub?id=123')).toBe(true);
|
|
});
|
|
|
|
it('should accept valid HTTPS URLs', () => {
|
|
expect(isValidUnsubscribeUrl('https://example.com/unsubscribe')).toBe(true);
|
|
expect(isValidUnsubscribeUrl('https://example.com/unsub?token=abc123')).toBe(true);
|
|
expect(isValidUnsubscribeUrl('https://sub.domain.com/unsubscribe')).toBe(true);
|
|
});
|
|
|
|
it('should accept URLs with paths and query params', () => {
|
|
expect(isValidUnsubscribeUrl('https://example.com/path/to/unsub?id=123&token=abc')).toBe(true);
|
|
expect(isValidUnsubscribeUrl('http://example.com/unsub#section')).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('mailto URLs', () => {
|
|
it('should accept valid mailto URLs', () => {
|
|
expect(isValidUnsubscribeUrl('mailto:unsubscribe@example.com')).toBe(true);
|
|
expect(isValidUnsubscribeUrl('mailto:unsub@newsletter.com')).toBe(true);
|
|
});
|
|
|
|
it('should accept mailto with query params', () => {
|
|
expect(isValidUnsubscribeUrl('mailto:unsub@example.com?subject=Unsubscribe')).toBe(true);
|
|
expect(isValidUnsubscribeUrl('mailto:unsub@example.com?subject=Remove&body=Please%20remove')).toBe(true);
|
|
});
|
|
|
|
it('should reject mailto with invalid email', () => {
|
|
expect(isValidUnsubscribeUrl('mailto:invalid-email')).toBe(false);
|
|
expect(isValidUnsubscribeUrl('mailto:@example.com')).toBe(false);
|
|
expect(isValidUnsubscribeUrl('mailto:user@')).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('XSS attack vectors', () => {
|
|
it('should reject javascript: protocol', () => {
|
|
expect(isValidUnsubscribeUrl('javascript:alert(1)')).toBe(false);
|
|
expect(isValidUnsubscribeUrl('javascript:alert(document.cookie)')).toBe(false);
|
|
expect(isValidUnsubscribeUrl('javascript:void(0)')).toBe(false);
|
|
});
|
|
|
|
it('should reject data: protocol', () => {
|
|
expect(isValidUnsubscribeUrl('data:text/html,<script>alert(1)</script>')).toBe(false);
|
|
expect(isValidUnsubscribeUrl('data:text/html;base64,PHNjcmlwdD5hbGVydCgxKTwvc2NyaXB0Pg==')).toBe(false);
|
|
});
|
|
|
|
it('should reject file: protocol', () => {
|
|
expect(isValidUnsubscribeUrl('file:///etc/passwd')).toBe(false);
|
|
expect(isValidUnsubscribeUrl('file://C:/Windows/System32/config')).toBe(false);
|
|
});
|
|
|
|
it('should reject vbscript: protocol', () => {
|
|
expect(isValidUnsubscribeUrl('vbscript:msgbox(1)')).toBe(false);
|
|
});
|
|
|
|
it('should reject about: protocol', () => {
|
|
expect(isValidUnsubscribeUrl('about:blank')).toBe(false);
|
|
});
|
|
|
|
it('should reject ftp: protocol', () => {
|
|
expect(isValidUnsubscribeUrl('ftp://example.com/file')).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('edge cases', () => {
|
|
it('should reject empty or null input', () => {
|
|
expect(isValidUnsubscribeUrl('')).toBe(false);
|
|
expect(isValidUnsubscribeUrl(' ')).toBe(false);
|
|
});
|
|
|
|
it('should reject malformed URLs', () => {
|
|
expect(isValidUnsubscribeUrl('not-a-url')).toBe(false);
|
|
expect(isValidUnsubscribeUrl('example.com/unsub')).toBe(false);
|
|
expect(isValidUnsubscribeUrl('//example.com')).toBe(false);
|
|
});
|
|
|
|
it('should reject relative URLs', () => {
|
|
expect(isValidUnsubscribeUrl('/unsubscribe')).toBe(false);
|
|
expect(isValidUnsubscribeUrl('../unsub')).toBe(false);
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('parseUnsubscribeUrls', () => {
|
|
it('should parse single HTTP URL', () => {
|
|
const result = parseUnsubscribeUrls('<https://example.com/unsub>');
|
|
expect(result.http).toBe('https://example.com/unsub');
|
|
expect(result.mailto).toBeUndefined();
|
|
expect(result.preferred).toBe('http');
|
|
});
|
|
|
|
it('should parse single mailto URL', () => {
|
|
const result = parseUnsubscribeUrls('<mailto:unsub@example.com>');
|
|
expect(result.http).toBeUndefined();
|
|
expect(result.mailto).toBe('mailto:unsub@example.com');
|
|
expect(result.preferred).toBe('mailto');
|
|
});
|
|
|
|
it('should parse multiple URLs and prefer HTTP', () => {
|
|
const result = parseUnsubscribeUrls('<https://example.com/unsub>, <mailto:unsub@example.com>');
|
|
expect(result.http).toBe('https://example.com/unsub');
|
|
expect(result.mailto).toBe('mailto:unsub@example.com');
|
|
expect(result.preferred).toBe('http');
|
|
});
|
|
|
|
it('should prefer HTTP over mailto when both present', () => {
|
|
const result = parseUnsubscribeUrls('<mailto:unsub@example.com>, <https://example.com/unsub>');
|
|
expect(result.http).toBe('https://example.com/unsub');
|
|
expect(result.mailto).toBe('mailto:unsub@example.com');
|
|
expect(result.preferred).toBe('http');
|
|
});
|
|
|
|
it('should handle URLs with query parameters', () => {
|
|
const result = parseUnsubscribeUrls('<https://example.com/unsub?token=abc123&id=456>');
|
|
expect(result.http).toBe('https://example.com/unsub?token=abc123&id=456');
|
|
expect(result.preferred).toBe('http');
|
|
});
|
|
|
|
it('should handle mailto with query parameters', () => {
|
|
const result = parseUnsubscribeUrls('<mailto:unsub@example.com?subject=Unsubscribe&body=Remove>');
|
|
expect(result.mailto).toBe('mailto:unsub@example.com?subject=Unsubscribe&body=Remove');
|
|
expect(result.preferred).toBe('mailto');
|
|
});
|
|
|
|
it('should filter out invalid URLs', () => {
|
|
const result = parseUnsubscribeUrls('<javascript:alert(1)>, <https://example.com/unsub>');
|
|
expect(result.http).toBe('https://example.com/unsub');
|
|
expect(result.preferred).toBe('http');
|
|
});
|
|
|
|
it('should return empty object for all invalid URLs', () => {
|
|
const result = parseUnsubscribeUrls('<javascript:alert(1)>, <data:text/html,<script>>');
|
|
expect(result.http).toBeUndefined();
|
|
expect(result.mailto).toBeUndefined();
|
|
expect(result.preferred).toBeUndefined();
|
|
});
|
|
|
|
it('should handle empty or null input', () => {
|
|
expect(parseUnsubscribeUrls('')).toEqual({});
|
|
expect(parseUnsubscribeUrls(' ')).toEqual({});
|
|
});
|
|
|
|
it('should handle malformed headers without angle brackets', () => {
|
|
const result = parseUnsubscribeUrls('https://example.com/unsub');
|
|
expect(result).toEqual({});
|
|
});
|
|
|
|
it('should handle whitespace in headers', () => {
|
|
const result = parseUnsubscribeUrls(' <https://example.com/unsub> , <mailto:unsub@example.com> ');
|
|
expect(result.http).toBe('https://example.com/unsub');
|
|
expect(result.mailto).toBe('mailto:unsub@example.com');
|
|
expect(result.preferred).toBe('http');
|
|
});
|
|
|
|
it('should handle three or more URLs', () => {
|
|
const result = parseUnsubscribeUrls(
|
|
'<https://example.com/unsub>, <http://backup.com/unsub>, <mailto:unsub@example.com>'
|
|
);
|
|
expect(result.http).toBeDefined();
|
|
expect(result.mailto).toBe('mailto:unsub@example.com');
|
|
expect(result.preferred).toBe('http');
|
|
});
|
|
|
|
it('should validate email addresses in mailto URLs', () => {
|
|
const result = parseUnsubscribeUrls('<mailto:invalid-email>, <https://example.com/unsub>');
|
|
expect(result.mailto).toBeUndefined();
|
|
expect(result.http).toBe('https://example.com/unsub');
|
|
expect(result.preferred).toBe('http');
|
|
});
|
|
});
|
|
});
|