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 <noreply@anthropic.com>
This commit is contained in:
@@ -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 };
|
||||
|
||||
+240
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user