From a71547562efc7685f0469d56b6b8a6e0d3f64ecc Mon Sep 17 00:00:00 2001 From: Ulas Kalayci Date: Mon, 4 May 2026 12:54:16 +0200 Subject: [PATCH] feat(contacts): add multi-value array validators Add validatePhones, validateEmails, validateAddresses for CardDAV multi-value contact fields. Validates array structure, required fields, type checks, and max lengths. Co-Authored-By: Claude Opus 4.7 --- server/routes/contacts.js | 88 ++++++++++++++ test-carddav.js | 240 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 328 insertions(+) diff --git a/server/routes/contacts.js b/server/routes/contacts.js index 1eba2b3..6f49a55 100644 --- a/server/routes/contacts.js +++ b/server/routes/contacts.js @@ -16,6 +16,93 @@ const router = express.Router(); const VALID_CATEGORIES = ['Arzt', 'Schule/Kita', 'Behörde', 'Versicherung', 'Handwerker', 'Notfall', 'Sonstiges']; +/** + * Validates phones array for multi-value contact fields. + * @param {Array} phones - Array of { label, value, isPrimary? } + * @returns {{ valid: boolean, error?: string }} + */ +function validatePhones(phones) { + if (!Array.isArray(phones)) return { valid: false, error: 'Phones must be an array' }; + if (phones.length > 20) return { valid: false, error: 'Too many phone entries (max 20)' }; + for (const p of phones) { + if (!p || typeof p !== 'object') return { valid: false, error: 'Phone entry must be an object' }; + if (!p.label || !p.value) return { valid: false, error: 'Phone requires label and value' }; + if (typeof p.label !== 'string' || p.label.trim().length === 0 || p.label.length > 50) { + return { valid: false, error: 'Phone label invalid or too long' }; + } + if (typeof p.value !== 'string' || p.value.trim().length === 0 || p.value.length > 50) { + return { valid: false, error: 'Phone value invalid or too long' }; + } + if (p.isPrimary !== undefined && typeof p.isPrimary !== 'boolean') { + return { valid: false, error: 'Phone isPrimary must be boolean' }; + } + } + return { valid: true }; +} + +/** + * Validates emails array for multi-value contact fields. + * @param {Array} emails - Array of { label, value, isPrimary? } + * @returns {{ valid: boolean, error?: string }} + */ +function validateEmails(emails) { + if (!Array.isArray(emails)) return { valid: false, error: 'Emails must be an array' }; + if (emails.length > 20) return { valid: false, error: 'Too many email entries (max 20)' }; + for (const e of emails) { + if (!e || typeof e !== 'object') return { valid: false, error: 'Email entry must be an object' }; + if (!e.label || !e.value) return { valid: false, error: 'Email requires label and value' }; + if (typeof e.label !== 'string' || e.label.trim().length === 0 || e.label.length > 50) { + return { valid: false, error: 'Email label invalid or too long' }; + } + if (typeof e.value !== 'string' || e.value.trim().length === 0 || e.value.length > 255) { + return { valid: false, error: 'Email value invalid or too long' }; + } + if (!/^.+@.+$/.test(e.value)) { + return { valid: false, error: 'Email value must be a valid email address' }; + } + if (e.isPrimary !== undefined && typeof e.isPrimary !== 'boolean') { + return { valid: false, error: 'Email isPrimary must be boolean' }; + } + } + return { valid: true }; +} + +/** + * Validates addresses array for multi-value contact fields. + * @param {Array} addresses - Array of { label, street?, city?, state?, postalCode?, country?, isPrimary? } + * @returns {{ valid: boolean, error?: string }} + */ +function validateAddresses(addresses) { + if (!Array.isArray(addresses)) return { valid: false, error: 'Addresses must be an array' }; + if (addresses.length > 20) return { valid: false, error: 'Too many address entries (max 20)' }; + for (const a of addresses) { + if (!a || typeof a !== 'object') return { valid: false, error: 'Address entry must be an object' }; + if (!a.label) return { valid: false, error: 'Address requires label' }; + if (typeof a.label !== 'string' || a.label.trim().length === 0 || a.label.length > 50) { + return { valid: false, error: 'Address label invalid or too long' }; + } + if (a.street !== undefined && (typeof a.street !== 'string' || a.street.length > 255)) { + return { valid: false, error: 'Address street invalid or too long' }; + } + if (a.city !== undefined && (typeof a.city !== 'string' || a.city.length > 255)) { + return { valid: false, error: 'Address city invalid or too long' }; + } + if (a.state !== undefined && (typeof a.state !== 'string' || a.state.length > 255)) { + return { valid: false, error: 'Address state invalid or too long' }; + } + if (a.postalCode !== undefined && (typeof a.postalCode !== 'string' || a.postalCode.length > 255)) { + return { valid: false, error: 'Address postalCode invalid or too long' }; + } + if (a.country !== undefined && (typeof a.country !== 'string' || a.country.length > 255)) { + return { valid: false, error: 'Address country invalid or too long' }; + } + if (a.isPrimary !== undefined && typeof a.isPrimary !== 'boolean') { + return { valid: false, error: 'Address isPrimary must be boolean' }; + } + } + return { valid: true }; +} + /** * GET /api/v1/contacts * Alle Kontakte, optional nach Kategorie gefiltert und nach Name gesucht. @@ -208,3 +295,4 @@ router.get('/:id/vcard', (req, res) => { }); export default router; +export { validatePhones, validateEmails, validateAddresses }; diff --git a/test-carddav.js b/test-carddav.js index eaccb7d..825851c 100644 --- a/test-carddav.js +++ b/test-carddav.js @@ -1227,3 +1227,243 @@ END:VCARD`; }); }); }); + +// ======================================== +// Multi-Value Validators +// ======================================== + +describe('Multi-Value Validators', () => { + let validatePhones, validateEmails, validateAddresses; + + before(async () => { + const validators = await import('./server/routes/contacts.js'); + validatePhones = validators.validatePhones; + validateEmails = validators.validateEmails; + validateAddresses = validators.validateAddresses; + }); + + describe('validatePhones', () => { + it('should reject non-array input', () => { + const result = validatePhones('not-an-array'); + assert.strictEqual(result.valid, false); + assert.strictEqual(result.error, 'Phones must be an array'); + }); + + it('should reject null element', () => { + const result = validatePhones([null]); + assert.strictEqual(result.valid, false); + assert.strictEqual(result.error, 'Phone entry must be an object'); + }); + + it('should reject primitive element', () => { + const result = validatePhones(['string']); + assert.strictEqual(result.valid, false); + assert.strictEqual(result.error, 'Phone entry must be an object'); + }); + + it('should reject phone without label', () => { + const result = validatePhones([{ value: '+123' }]); + assert.strictEqual(result.valid, false); + assert.strictEqual(result.error, 'Phone requires label and value'); + }); + + it('should reject phone with whitespace-only label', () => { + const result = validatePhones([{ label: ' ', value: '+123' }]); + assert.strictEqual(result.valid, false); + assert.strictEqual(result.error, 'Phone label invalid or too long'); + }); + + it('should reject phone with whitespace-only value', () => { + const result = validatePhones([{ label: 'mobile', value: ' ' }]); + assert.strictEqual(result.valid, false); + assert.strictEqual(result.error, 'Phone value invalid or too long'); + }); + + it('should reject phone with too long label', () => { + const result = validatePhones([{ label: 'x'.repeat(51), value: '+123' }]); + assert.strictEqual(result.valid, false); + assert.ok(result.error.includes('Phone label invalid or too long')); + }); + + it('should reject non-boolean isPrimary', () => { + const result = validatePhones([{ label: 'mobile', value: '+123', isPrimary: 'true' }]); + assert.strictEqual(result.valid, false); + assert.strictEqual(result.error, 'Phone isPrimary must be boolean'); + }); + + it('should reject array exceeding max length', () => { + const phones = Array(21).fill({ label: 'mobile', value: '+123' }); + const result = validatePhones(phones); + assert.strictEqual(result.valid, false); + assert.strictEqual(result.error, 'Too many phone entries (max 20)'); + }); + + it('should accept valid phones array', () => { + const result = validatePhones([ + { label: 'mobile', value: '+1234567890', isPrimary: true }, + { label: 'work', value: '+0987654321' } + ]); + assert.strictEqual(result.valid, true); + }); + + it('should accept phones at max array length', () => { + const phones = Array(20).fill({ label: 'mobile', value: '+123' }); + const result = validatePhones(phones); + assert.strictEqual(result.valid, true); + }); + }); + + describe('validateEmails', () => { + it('should reject non-array input', () => { + const result = validateEmails('not-an-array'); + assert.strictEqual(result.valid, false); + assert.strictEqual(result.error, 'Emails must be an array'); + }); + + it('should reject null element', () => { + const result = validateEmails([null]); + assert.strictEqual(result.valid, false); + assert.strictEqual(result.error, 'Email entry must be an object'); + }); + + it('should reject primitive element', () => { + const result = validateEmails([42]); + assert.strictEqual(result.valid, false); + assert.strictEqual(result.error, 'Email entry must be an object'); + }); + + it('should reject email without value', () => { + const result = validateEmails([{ label: 'work' }]); + assert.strictEqual(result.valid, false); + assert.strictEqual(result.error, 'Email requires label and value'); + }); + + it('should reject email with whitespace-only label', () => { + const result = validateEmails([{ label: ' ', value: 'test@example.com' }]); + assert.strictEqual(result.valid, false); + assert.strictEqual(result.error, 'Email label invalid or too long'); + }); + + it('should reject email with whitespace-only value', () => { + const result = validateEmails([{ label: 'work', value: ' ' }]); + assert.strictEqual(result.valid, false); + assert.strictEqual(result.error, 'Email value invalid or too long'); + }); + + it('should reject invalid email format', () => { + const result = validateEmails([{ label: 'work', value: 'notanemail' }]); + assert.strictEqual(result.valid, false); + assert.strictEqual(result.error, 'Email value must be a valid email address'); + }); + + it('should reject email with too long value', () => { + const result = validateEmails([{ label: 'work', value: 'x'.repeat(256) }]); + assert.strictEqual(result.valid, false); + assert.ok(result.error.includes('Email value invalid or too long')); + }); + + it('should reject non-boolean isPrimary', () => { + const result = validateEmails([{ label: 'work', value: 'test@example.com', isPrimary: 1 }]); + assert.strictEqual(result.valid, false); + assert.strictEqual(result.error, 'Email isPrimary must be boolean'); + }); + + it('should reject array exceeding max length', () => { + const emails = Array(21).fill({ label: 'work', value: 'test@example.com' }); + const result = validateEmails(emails); + assert.strictEqual(result.valid, false); + assert.strictEqual(result.error, 'Too many email entries (max 20)'); + }); + + it('should accept valid emails array', () => { + const result = validateEmails([ + { label: 'work', value: 'john@work.com', isPrimary: true }, + { label: 'home', value: 'john@home.com' } + ]); + assert.strictEqual(result.valid, true); + }); + + it('should accept emails at max array length', () => { + const emails = Array(20).fill({ label: 'work', value: 'test@example.com' }); + const result = validateEmails(emails); + assert.strictEqual(result.valid, true); + }); + }); + + describe('validateAddresses', () => { + it('should reject non-array input', () => { + const result = validateAddresses('not-an-array'); + assert.strictEqual(result.valid, false); + assert.strictEqual(result.error, 'Addresses must be an array'); + }); + + it('should reject null element', () => { + const result = validateAddresses([null]); + assert.strictEqual(result.valid, false); + assert.strictEqual(result.error, 'Address entry must be an object'); + }); + + it('should reject undefined element', () => { + const result = validateAddresses([undefined]); + assert.strictEqual(result.valid, false); + assert.strictEqual(result.error, 'Address entry must be an object'); + }); + + it('should reject address without label', () => { + const result = validateAddresses([{ street: '123 Main St' }]); + assert.strictEqual(result.valid, false); + assert.strictEqual(result.error, 'Address requires label'); + }); + + it('should reject address with whitespace-only label', () => { + const result = validateAddresses([{ label: '\t\n ', street: '123 Main St' }]); + assert.strictEqual(result.valid, false); + assert.strictEqual(result.error, 'Address label invalid or too long'); + }); + + it('should reject address with too long street', () => { + const result = validateAddresses([{ label: 'home', street: 'x'.repeat(256) }]); + assert.strictEqual(result.valid, false); + assert.ok(result.error.includes('Address street invalid or too long')); + }); + + it('should reject non-boolean isPrimary', () => { + const result = validateAddresses([{ label: 'home', street: '123 Main', isPrimary: 'yes' }]); + assert.strictEqual(result.valid, false); + assert.strictEqual(result.error, 'Address isPrimary must be boolean'); + }); + + it('should reject array exceeding max length', () => { + const addresses = Array(21).fill({ label: 'home' }); + const result = validateAddresses(addresses); + assert.strictEqual(result.valid, false); + assert.strictEqual(result.error, 'Too many address entries (max 20)'); + }); + + it('should accept valid addresses array', () => { + const result = validateAddresses([ + { + label: 'home', + street: '123 Main St', + city: 'Springfield', + state: 'IL', + postalCode: '62701', + country: 'USA', + isPrimary: true + }, + { + label: 'work', + street: '456 Office Blvd', + city: 'Metropolis' + } + ]); + assert.strictEqual(result.valid, true); + }); + + it('should accept addresses at max array length', () => { + const addresses = Array(20).fill({ label: 'home' }); + const result = validateAddresses(addresses); + assert.strictEqual(result.valid, true); + }); + }); +});