Skip to content

Commit 2bc499b

Browse files
committed
feat(validation): enhance isPort function and add isHostname validation utility
1 parent 01e407b commit 2bc499b

2 files changed

Lines changed: 297 additions & 5 deletions

File tree

src/validation/validation.utils.ts

Lines changed: 71 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -18,18 +18,21 @@ const TLD_REGEX = /^[A-Za-z]{2,63}$/;
1818
/**
1919
* Checks if a string is a valid port number.
2020
*
21-
* @param str - The input string or number.
21+
* @param value - The input string or number.
22+
* @param allowZero - Whether to allow zero as a valid port number.
2223
* @returns True if valid port number, else false.
2324
*/
24-
export function isPort(value: string | number): boolean {
25-
if (typeof value === 'number') return Number.isInteger(value) && value > 0 && value <= 65535;
25+
export function isPort(value: string | number, allowZero: boolean = false): boolean {
26+
const minPort = allowZero ? 0 : 1;
27+
const maxPort = 65535;
28+
if (typeof value === 'number') return Number.isInteger(value) && value >= minPort && value <= maxPort;
2629
if (!value || typeof value !== 'string') return false;
2730

2831
const str = value.trim();
2932
if (!PORT_REGEX.test(str)) return false;
3033

3134
const port = Number(str);
32-
return Number.isInteger(port) && port > 0 && port <= 65535;
35+
return Number.isInteger(port) && port >= minPort && port <= maxPort;
3336
}
3437

3538
/**
@@ -232,7 +235,7 @@ export function isIPv4(str: string): boolean {
232235
const parts = str.split('.');
233236
if (parts.length !== 4) return false;
234237
return parts.every(p => {
235-
if (p.length > 3 || (p.startsWith('0') && p.length > 1)) return false;
238+
if (!p || p.length > 3 || (p.startsWith('0') && p.length > 1)) return false;
236239
const n = Number(p);
237240
return Number.isInteger(n) && n >= 0 && n <= 255;
238241
});
@@ -253,6 +256,69 @@ export function isIPv6(str: string): boolean {
253256
}
254257
}
255258

259+
/**
260+
* Checks if a string is a valid hostname or IP address.
261+
*
262+
* Validates:
263+
* - IPv4 addresses (e.g., '192.168.1.1')
264+
* - IPv6 addresses (e.g., '::1', '2001:db8::1')
265+
* - Domain names (e.g., 'example.com', 'sub.example.com')
266+
* - Localhost variants ('localhost', '0.0.0.0', '127.0.0.1')
267+
*
268+
* @param {string} str - The hostname or IP address to validate.
269+
* @returns {boolean} True if valid hostname or IP address.
270+
*
271+
* @example
272+
* ```typescript
273+
* isHostname('localhost'); // true
274+
* isHostname('0.0.0.0'); // true
275+
* isHostname('192.168.1.1'); // true
276+
* isHostname('example.com'); // true
277+
* isHostname('sub.example.com'); // true
278+
* isHostname('::1'); // true
279+
* isHostname('invalid..com'); // false
280+
* isHostname(''); // false
281+
* ```
282+
*/
283+
export function isHostname(str: string): boolean {
284+
if (!str || typeof str !== 'string') return false;
285+
286+
let input = str.trim();
287+
if (!input || input.length > 255) return false;
288+
289+
// Normalize trailing dot (FQDN)
290+
if (input.endsWith('.')) {
291+
input = input.slice(0, -1);
292+
}
293+
294+
// IPv4
295+
if (isIPv4(input)) return true;
296+
297+
// IPv6
298+
if (isIPv6(input)) return true;
299+
300+
// Localhost
301+
if (input === 'localhost') return true;
302+
303+
// Reject IPv4-like numeric patterns
304+
if (/^\d+(\.\d+){3,}$/.test(input)) {
305+
return false;
306+
}
307+
308+
// Reject consecutive dots
309+
if (input.includes('..')) return false;
310+
311+
const labels = input.split('.');
312+
if (labels.length === 0) return false;
313+
314+
const labelRegex = /^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$/i;
315+
316+
return labels.every(label => {
317+
if (!label || label.length > 63) return false;
318+
return labelRegex.test(label);
319+
});
320+
}
321+
256322
/**
257323
* Validates a credit card number using the Luhn algorithm.
258324
*

tests/utils/validation.utils.test.ts

Lines changed: 226 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import {
1313
isStrongPassword,
1414
isIPv4,
1515
isIPv6,
16+
isHostname,
1617
isCreditCard,
1718
isValidJSON,
1819
isArray,
@@ -302,6 +303,231 @@ describe('ValidationUtils', () => {
302303
});
303304
});
304305

306+
describe('isHostname', () => {
307+
describe('IPv4 addresses', () => {
308+
it('accepts valid IPv4 addresses', () => {
309+
expect(isHostname('127.0.0.1')).toBe(true);
310+
expect(isHostname('0.0.0.0')).toBe(true);
311+
expect(isHostname('192.168.1.1')).toBe(true);
312+
expect(isHostname('10.0.0.1')).toBe(true);
313+
expect(isHostname('255.255.255.255')).toBe(true);
314+
expect(isHostname('8.8.8.8')).toBe(true);
315+
});
316+
317+
it('rejects invalid IPv4 addresses', () => {
318+
expect(isHostname('256.0.0.1')).toBe(false);
319+
expect(isHostname('192.168.1.256')).toBe(false);
320+
expect(isHostname('192.168.1.1.1')).toBe(false);
321+
});
322+
});
323+
324+
describe('IPv6 addresses', () => {
325+
it('accepts valid IPv6 addresses', () => {
326+
expect(isHostname('::1')).toBe(true);
327+
expect(isHostname('::')).toBe(true);
328+
expect(isHostname('2001:0db8:85a3:0000:0000:8a2e:0370:7334')).toBe(true);
329+
expect(isHostname('2001:db8::1')).toBe(true);
330+
expect(isHostname('fe80::1')).toBe(true);
331+
expect(isHostname('::ffff:192.0.2.1')).toBe(true); // IPv4-mapped IPv6
332+
});
333+
334+
it('rejects invalid IPv6 addresses', () => {
335+
expect(isHostname('gggg::1')).toBe(false);
336+
expect(isHostname(':::')).toBe(false);
337+
expect(isHostname('not:valid:ipv6')).toBe(false);
338+
});
339+
});
340+
341+
describe('localhost', () => {
342+
it('accepts localhost', () => {
343+
expect(isHostname('localhost')).toBe(true);
344+
});
345+
});
346+
347+
describe('valid domain names', () => {
348+
it('accepts standard domain names', () => {
349+
expect(isHostname('example.com')).toBe(true);
350+
expect(isHostname('sub.example.com')).toBe(true);
351+
expect(isHostname('deep.sub.example.com')).toBe(true);
352+
expect(isHostname('api.github.com')).toBe(true);
353+
expect(isHostname('test-server.example.org')).toBe(true);
354+
});
355+
356+
it('accepts single-label hostnames', () => {
357+
expect(isHostname('server1')).toBe(true);
358+
expect(isHostname('my-server')).toBe(true);
359+
expect(isHostname('app123')).toBe(true);
360+
});
361+
362+
it('accepts hostnames with numbers', () => {
363+
expect(isHostname('server1.example.com')).toBe(true);
364+
expect(isHostname('api2.example.com')).toBe(true);
365+
expect(isHostname('123server.com')).toBe(true);
366+
});
367+
368+
it('accepts maximum length labels (63 chars)', () => {
369+
const maxLabel = 'a'.repeat(63);
370+
expect(isHostname(`${maxLabel}.com`)).toBe(true);
371+
});
372+
373+
it('accepts exactly 255 character hostname', () => {
374+
const label = 'a'.repeat(63);
375+
const hostname = `${label}.${label}.${label}.${label}`;
376+
expect(hostname.length).toBe(255);
377+
expect(isHostname(hostname)).toBe(true);
378+
});
379+
380+
it('accepts trailing dot (FQDN)', () => {
381+
expect(isHostname('example.com.')).toBe(true);
382+
});
383+
384+
it('accepts punycode domains', () => {
385+
expect(isHostname('xn--d1acpjx3f.xn--p1ai')).toBe(true);
386+
});
387+
388+
it('handles IPv6 with zone index', () => {
389+
expect(isHostname('fe80::1%eth0')).toBe(true);
390+
});
391+
392+
it('accepts uppercase domains', () => {
393+
expect(isHostname('EXAMPLE.COM')).toBe(true);
394+
expect(isHostname('Sub.Domain.COM')).toBe(true);
395+
});
396+
});
397+
398+
describe('invalid cases', () => {
399+
it('rejects empty or whitespace strings', () => {
400+
expect(isHostname('')).toBe(false);
401+
expect(isHostname(' ')).toBe(false);
402+
expect(isHostname(' ')).toBe(false);
403+
expect(isHostname('\t')).toBe(false);
404+
});
405+
406+
it('rejects null and undefined', () => {
407+
expect(isHostname(null as any)).toBe(false);
408+
expect(isHostname(undefined as any)).toBe(false);
409+
});
410+
411+
it('rejects non-string values', () => {
412+
expect(isHostname(123 as any)).toBe(false);
413+
expect(isHostname({} as any)).toBe(false);
414+
expect(isHostname([] as any)).toBe(false);
415+
});
416+
417+
it('rejects hostnames with consecutive dots', () => {
418+
expect(isHostname('example..com')).toBe(false);
419+
expect(isHostname('..example.com')).toBe(false);
420+
expect(isHostname('example.com..')).toBe(false);
421+
expect(isHostname('sub..example.com')).toBe(false);
422+
});
423+
424+
it('rejects hostnames starting or ending with hyphen', () => {
425+
expect(isHostname('-example.com')).toBe(false);
426+
expect(isHostname('example-.com')).toBe(false);
427+
expect(isHostname('sub.-example.com')).toBe(false);
428+
expect(isHostname('sub.example-.com')).toBe(false);
429+
});
430+
431+
it('rejects hostnames with invalid characters', () => {
432+
expect(isHostname('exa_mple.com')).toBe(false);
433+
expect(isHostname('exa!mple.com')).toBe(false);
434+
expect(isHostname('exa@mple.com')).toBe(false);
435+
expect(isHostname('exa#mple.com')).toBe(false);
436+
expect(isHostname('exa$mple.com')).toBe(false);
437+
expect(isHostname('exa%mple.com')).toBe(false);
438+
expect(isHostname('exa mple.com')).toBe(false);
439+
expect(isHostname('exa\tmple.com')).toBe(false);
440+
});
441+
442+
it('rejects hostnames exceeding 255 characters', () => {
443+
const tooLong = 'a'.repeat(256);
444+
expect(isHostname(tooLong)).toBe(false);
445+
446+
const longHostname = 'a'.repeat(200) + '.com';
447+
expect(isHostname(longHostname)).toBe(false);
448+
});
449+
450+
it('rejects labels exceeding 63 characters', () => {
451+
const tooLongLabel = 'a'.repeat(64);
452+
expect(isHostname(`${tooLongLabel}.com`)).toBe(false);
453+
expect(isHostname(`sub.${tooLongLabel}.com`)).toBe(false);
454+
});
455+
456+
it('rejects double hyphen edge misuse (optional strict)', () => {
457+
expect(isHostname('a--.com')).toBe(false);
458+
});
459+
460+
it('rejects hostnames starting with dots', () => {
461+
expect(isHostname('.example.com')).toBe(false);
462+
expect(isHostname('.com')).toBe(false);
463+
});
464+
465+
it('rejects hostnames ending with more than one dot', () => {
466+
expect(isHostname('example.com..')).toBe(false);
467+
expect(isHostname('sub.example.com..')).toBe(false);
468+
});
469+
470+
it('rejects empty labels', () => {
471+
expect(isHostname('example..com')).toBe(false);
472+
expect(isHostname('.com')).toBe(false);
473+
expect(isHostname('example..')).toBe(false);
474+
});
475+
476+
it('rejects hostnames with only special characters', () => {
477+
expect(isHostname('...')).toBe(false);
478+
expect(isHostname('___')).toBe(false);
479+
expect(isHostname('-')).toBe(false);
480+
expect(isHostname('a-')).toBe(false); // ends with hyphen
481+
expect(isHostname('-a')).toBe(false); // starts with hyphen
482+
});
483+
484+
it('rejects invalid IPv4-like but numeric hostnames', () => {
485+
expect(isHostname('256.256.256.256')).toBe(false);
486+
expect(isHostname('999.1.1.1')).toBe(false);
487+
});
488+
});
489+
490+
describe('edge cases', () => {
491+
it('handles trimmed whitespace', () => {
492+
expect(isHostname(' example.com ')).toBe(true);
493+
expect(isHostname('\tlocalhost\t')).toBe(true);
494+
expect(isHostname(' 192.168.1.1 ')).toBe(true);
495+
});
496+
497+
it('accepts single character labels', () => {
498+
expect(isHostname('a.b.c')).toBe(true);
499+
expect(isHostname('x')).toBe(true);
500+
});
501+
502+
it('accepts all-numeric single label (hostname, not domain)', () => {
503+
expect(isHostname('123')).toBe(true);
504+
expect(isHostname('456789')).toBe(true);
505+
});
506+
507+
it('rejects label with only hyphens', () => {
508+
expect(isHostname('a-b')).toBe(true); // valid
509+
expect(isHostname('-')).toBe(false); // only hyphen
510+
});
511+
});
512+
513+
describe('real-world examples', () => {
514+
it('accepts common server hostnames', () => {
515+
expect(isHostname('web-server-01')).toBe(true);
516+
expect(isHostname('db1.prod.internal')).toBe(true);
517+
expect(isHostname('api-gateway.eu-west-1.aws.com')).toBe(true);
518+
});
519+
520+
it('accepts common TLDs', () => {
521+
expect(isHostname('example.com')).toBe(true);
522+
expect(isHostname('example.org')).toBe(true);
523+
expect(isHostname('example.net')).toBe(true);
524+
expect(isHostname('example.io')).toBe(true);
525+
expect(isHostname('example.dev')).toBe(true);
526+
expect(isHostname('example.app')).toBe(true);
527+
});
528+
});
529+
});
530+
305531
describe('isCreditCard', () => {
306532
it('returns true for valid credit card numbers', () => {
307533
expect(isCreditCard('4111 1111 1111 1111')).toBe(true);

0 commit comments

Comments
 (0)