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',
|
const VALID_CATEGORIES = ['Arzt', 'Schule/Kita', 'Behörde', 'Versicherung',
|
||||||
'Handwerker', 'Notfall', 'Sonstiges'];
|
'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
|
* GET /api/v1/contacts
|
||||||
* Alle Kontakte, optional nach Kategorie gefiltert und nach Name gesucht.
|
* Alle Kontakte, optional nach Kategorie gefiltert und nach Name gesucht.
|
||||||
@@ -208,3 +295,4 @@ router.get('/:id/vcard', (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
export default router;
|
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