verifio
Version:
Smart validation and verification library for URLs, with future support for emails and more
957 lines (839 loc) • 32.8 kB
text/typescript
import {
VerifioURL,
VerifioURLErrorCode,
VerifioURLValidityResult,
VerifioDomainErrorCode,
} from '..';
// Mock fetch for expansion tests
global.fetch = jest.fn();
const expectValidURL = (result: VerifioURLValidityResult): void => {
expect(result.isValid).toBe(true);
expect(result.errors).toBeUndefined();
};
const expectInvalidURL = (
result: VerifioURLValidityResult,
expectedCode: VerifioURLErrorCode
): void => {
expect(result.isValid).toBe(false);
expect(result.errors).toBeDefined();
expect(result.errors).toContainEqual(
expect.objectContaining({
code: expect.stringMatching(`^(${expectedCode}|${VerifioURLErrorCode.MALFORMED_URL})$`),
})
);
};
describe('VerifioURL', () => {
beforeEach(() => {
(global.fetch as jest.Mock).mockClear();
});
describe('isValid', () => {
describe('Basic URL Validation', () => {
const validURLs = [
'https://example.com',
'http://example.com',
'ftp://files.example.com',
'sftp://secure.example.com',
'https://www.example.com',
'https://sub1.sub2.example.com',
'https://example.com:8080',
'https://example.com/path',
'https://example.com/path?query=value',
'https://example.com/path#fragment',
'https://example.com/path?query=value#fragment',
'https://user:pass@example.com',
'https://example.com/path/to/resource.html',
'https://example.co.uk',
'https://xn--bcher-kva.example.com', // Punycode
// miscellaneous
'http://example.com/path?',
'http://example.com/path?&',
'http://example.com/%2e%2e',
'http://example.com/..%2fm',
'http://example.com/%2e%2e%2f',
'HTTP://example.com',
'Http://example.com',
'http://example.com/\x0A',
// Excessive components
'http://a.b.c.d.e.f.g.h.i.j.k.l.m.n.o.p.q.r.s.t.u.v.w.x.y.z.com',
'http://example.com/' + 'a'.repeat(2048),
// Whitespace cases
' https://example.com',
'https://example.com ',
' https://example.com ',
'https://example.com\n',
'https://example.com\t',
'https://example.com\r',
'\nhttps://example.com',
'\thttps://example.com',
'\rhttps://example.com',
' \n\t\rhttps://example.com\n\t\r ',
];
test.each(validURLs)('should validate correct URL: %s', (url) => {
const result = VerifioURL.isValid(url);
expectValidURL(result);
});
const invalidURLs = [
// Empty or null-like values
'',
' ',
'undefined',
'null',
// Invalid protocols and formats
'not-a-url',
'http://',
'http://.',
'http://..',
'http://../',
'http://?',
'http://??',
'http://??/',
'http://#',
'http://##',
'http://##/',
'http://foo.bar?q=Spaces should be encoded',
'//',
'//a',
'///a',
'http:///a',
// Spaces and special characters
'http://foo.bar?q=Spaces should be encoded',
'http://exa mple.com',
'https://example. com',
'https://example.com/ space',
'http://example.com/path with spaces',
'http://example.com/path/with/spa ces/',
'http://example.com?query= space',
'http://example.com#frag ment',
'http://user name@example.com',
// Invalid characters in hostname
'http://example!.com',
'http://example*.com',
'http://example(.com',
'http://example).com',
'http://exa\\mple.com',
'http://exam$ple.com',
'http://example`.com',
'http://example{.com',
'http://example}.com',
'http://example<.com',
'http://example>.com',
// Invalid TLD formats
'http://example.c',
'http://example.',
'http://example.com.',
'http://example..com',
'http://example.com..',
'http://example.c_m',
'http://example.123',
// Invalid domain formats
'http://.example.com',
'http://example-.com',
'http://-example.com',
'http://example.-com',
'http://example.com-',
// Invalid credentials format
'http://@example.com',
'http://user@:password@example.com',
'http://:password@example.com',
'http://user:pass:word@example.com',
// Invalid port formats
'http://example.com:',
'http://example.com:abc',
'http://example.com:123:456',
'http://example.com:65536',
// Malformed paths and queries
'http://example.com/path??',
'http://example.com/path#?',
'http://example.com/path#fragment#another',
// Unicode and special characters
'http://exämple.com',
'http://example.com/päth',
'http://example.com/path™',
'http://example.com/path©',
'http://example.com/path®',
'http://example.com/path°',
// Control characters
'http://example.com/\x00',
'http://example.com/\x1F',
// Invalid IP formats
'http://127.0.0',
'http://[::1',
'http://[]',
// Scheme confusion
'javascript:alert(1)',
'data:text/html,<script>alert(1)</script>',
'vbscript:msgbox(1)',
// Invalid Punycodes:
'https://xn--.example.com',
'https://xn--@@.example.com',
// Various edge cases
'http://..',
'http://...',
'http://example..com',
'http://example...com',
'http://*example.com',
'http://example.com:65536',
'http://example.com:999999',
];
test.each(invalidURLs)('should invalidate incorrect URL: %s', (url) => {
const result = VerifioURL.isValid(url);
expectInvalidURL(result, VerifioURLErrorCode.INVALID_URL);
});
});
describe('Protocol Validation', () => {
const invalidProtocols = [
'gopher://example.com',
'ws://example.com',
'wss://example.com',
'file://example.com',
];
test.each(invalidProtocols)('should reject invalid protocol: %s', (url) => {
const result = VerifioURL.isValid(url);
expectInvalidURL(result, VerifioURLErrorCode.INVALID_PROTOCOL);
});
});
describe('IP Address Validation', () => {
const validIPs = [
'http://192.168.1.1',
'https://[2001:0db8:85a3:0000:0000:8a2e:0370:7334]',
'http://127.0.0.1:8080',
'https://[::1]',
'https://[::ffff:192.0.2.1]', // IPv4-mapped IPv6 address
'https://[2001:db8::1]', // Compressed IPv6
'https://[fe80::1]', // Link-local address
'https://[::ffff:192.168.1.1]', // IPv4-mapped address
];
test.each(validIPs)('should validate correct IP address: %s', (url) => {
const result = VerifioURL.isValid(url);
expectValidURL(result);
});
const invalidIPs = [
// Invalid IPv4 formats
'http://1.2.3.256',
'http://1.2.3.4.5', // Too many IPv4 octets'
'http://127.0.0.0.1',
'http://256.256.256.256', //Invalid IPv4 octets
// Invalid IPv6 formats
'http://[1:2:3:4:5:6:7]',
'http://[1:2:3:4:5:6:7:8:9]',
'https://[2001:0db8:85a3:0000:0000:8a2e:0370:7334:7334]', // Too many IPv6 segments
'https://[:::1]', // invalid IPv6 compression
'https://[2001:0db8:85a3:::1]', // Multiple compression markers
'https://[2001:0db8:85a3:0000:0000:8a2e:0370:]', // Trailing colon
];
test.each(invalidIPs)('should invalidate %s', (url) => {
const result = VerifioURL.isValid(url);
expectInvalidURL(result, VerifioURLErrorCode.INVALID_IP);
});
});
describe('Domain Length Validation', () => {
test('should reject domain exceeding maximum length', () => {
const longDomain = 'https://' + 'a'.repeat(256) + '.com';
const result = VerifioURL.isValid(longDomain);
expectInvalidURL(result, VerifioURLErrorCode.INVALID_DOMAIN_LENGTH);
});
test('should reject URL exceeding maximum length', () => {
const longURL = 'https://example.com/' + 'a'.repeat(2084);
const result = VerifioURL.isValid(longURL);
expectInvalidURL(result, VerifioURLErrorCode.URL_TOO_LONG);
});
});
describe('Punycode Validation', () => {
const validPunycode = ['https://xn--mnchen-3ya.de', 'https://xn--bcher-kva.example.com'];
test.each(validPunycode)('should validate correct Punycode: %s', (url) => {
const result = VerifioURL.isValid(url);
expectValidURL(result);
});
});
describe('TLD Validation', () => {
const validTLDs = [
'https://example.com',
'https://example.co.uk',
'https://example.travel',
'https://example.museum',
'https://example.photography',
];
test.each(validTLDs)('should validate correct TLD: %s', (url) => {
const result = VerifioURL.isValid(url);
expectValidURL(result);
});
const invalidTLDs = [
{
url: 'https://example.c',
code: VerifioURLErrorCode.INVALID_TLD,
desc: 'TLD too short',
},
{
url: 'https://example.' + 'a'.repeat(64),
code: VerifioURLErrorCode.INVALID_TLD,
desc: 'TLD too long',
},
{
url: 'https://example.123',
code: VerifioURLErrorCode.INVALID_TLD,
desc: 'Numeric TLD',
},
{
url: 'https://example.c_m',
code: VerifioURLErrorCode.INVALID_TLD,
desc: 'TLD with underscore',
},
{
url: 'https://example.com-',
code: VerifioURLErrorCode.INVALID_LABEL_FORMAT,
desc: 'TLD ending with hyphen',
},
];
test.each(invalidTLDs)('should invalidate $desc: $url', ({ url, code }) => {
const result = VerifioURL.isValid(url);
expectInvalidURL(result, code);
});
});
describe('Port Validation', () => {
const validPorts = [
'http://example.com:1',
'http://example.com:8080',
'http://example.com:65535',
'http://127.0.0.1:8080',
'https://[::1]:8080',
];
test.each(validPorts)('should validate correct port: %s', (url) => {
const result = VerifioURL.isValid(url);
expectValidURL(result);
});
const invalidPorts = [
{
url: 'http://example.com:0',
desc: 'Port zero',
},
{
url: 'http://example.com:65536',
desc: 'Port exceeds maximum',
},
{
url: 'http://example.com:-1',
desc: 'Negative port',
},
{
url: 'http://example.com:port',
desc: 'Non-numeric port',
},
{
url: 'http://example.com:8080:80',
desc: 'Multiple ports',
},
];
test.each(invalidPorts)('should invalidate $desc: $url', ({ url }) => {
const result = VerifioURL.isValid(url);
expectInvalidURL(result, VerifioURLErrorCode.INVALID_PORT);
});
});
describe('Domain Label Validation', () => {
const validLabels = [
'https://sub1.example.com',
'https://sub-1.example.com',
'https://sub1-sub2.example.com',
'https://' + 'a'.repeat(63) + '.example.com',
// Whitespace cases
' https://sub1.example.com',
'https://sub1.example.com ',
' https://sub1.example.com ',
'https://sub1.example.com\n',
'https://sub1.example.com\t',
'https://sub1.example.com\r',
'\nhttps://sub1.example.com',
'\thttps://sub1.example.com',
'\rhttps://sub1.example.com',
' \n\t\rhttps://sub1.example.com\n\t\r ',
];
test.each(validLabels)('should validate correct domain label: %s', (url) => {
const result = VerifioURL.isValid(url);
expectValidURL(result);
});
const invalidLabels = [
{
url: 'https://' + 'a'.repeat(64) + '.example.com',
code: VerifioURLErrorCode.INVALID_LABEL_LENGTH,
desc: 'Label too long',
},
{
url: 'https://-sub.example.com',
code: VerifioURLErrorCode.INVALID_LABEL_FORMAT,
desc: 'Label starts with hyphen',
},
{
url: 'https://sub-.example.com',
code: VerifioURLErrorCode.INVALID_LABEL_FORMAT,
desc: 'Label ends with hyphen',
},
{
url: 'https://sub_1.example.com',
code: VerifioURLErrorCode.INVALID_HOSTNAME_CHARS,
desc: 'Label contains underscore',
},
{
url: ' https://-sub.example.com ',
code: VerifioURLErrorCode.INVALID_LABEL_FORMAT,
desc: 'Label starts with hyphen with whitespace',
},
{
url: '\thttps://sub_1.example.com\n',
code: VerifioURLErrorCode.INVALID_HOSTNAME_CHARS,
desc: 'Label contains underscore with whitespace',
},
];
test.each(invalidLabels)('should invalidate $desc: $url', ({ url, code }) => {
const result = VerifioURL.isValid(url);
expectInvalidURL(result, code);
});
});
});
describe('expand', () => {
beforeEach(() => {
jest.clearAllMocks();
// Silence console.error during tests
jest.spyOn(console, 'error').mockImplementation(() => {});
});
afterEach(() => {
(console.error as jest.Mock).mockRestore();
});
test('should expand shortened URL successfully', async () => {
const shortUrl = 'https://short.url/abc123';
const expandedUrl = 'https://example.com/full-path';
(global.fetch as jest.Mock).mockImplementationOnce(() =>
Promise.resolve({
ok: true,
url: expandedUrl,
})
);
const result = await VerifioURL.expand(shortUrl);
expect(result).toBe(expandedUrl);
expect(global.fetch).toHaveBeenCalledWith(
shortUrl,
expect.objectContaining({
method: 'HEAD',
redirect: 'follow',
})
);
});
test('should handle expansion failure', async () => {
const shortUrl = 'https://short.url/invalid';
(global.fetch as jest.Mock).mockImplementationOnce(() =>
Promise.resolve({
ok: false,
status: 404,
})
);
const result = await VerifioURL.expand(shortUrl);
expect(result).toBeNull();
});
test('should handle timeout', async () => {
const shortUrl = 'https://short.url/timeout';
// Mock fetch to reject immediately with AbortError
(global.fetch as jest.Mock).mockImplementationOnce(() =>
Promise.reject(new DOMException('The operation was aborted', 'AbortError'))
);
const result = await VerifioURL.expand(shortUrl, 100);
expect(result).toBeNull();
}, 1000);
});
describe('verify', () => {
beforeEach(() => {
jest.clearAllMocks();
jest.spyOn(console, 'error').mockImplementation(() => {});
});
afterEach(() => {
(console.error as jest.Mock).mockRestore();
});
test('should verify valid and accessible URL', async () => {
const url = 'https://example.com';
(global.fetch as jest.Mock).mockImplementationOnce(() =>
Promise.resolve({
ok: true,
url: url,
})
);
const result = await VerifioURL.verify(url);
expect(result).toEqual({
originalURL: url,
validity: {
isValid: true,
normalizedURL: url.toLowerCase().trim(),
},
expandedURL: url,
isAccessible: true,
});
});
test('should handle invalid URL', async () => {
const invalidUrl = 'not-a-url';
const result = await VerifioURL.verify(invalidUrl);
expect(result.validity.isValid).toBe(false);
expect(result.validity.errors).toBeDefined();
expect(result.validity.errors!.length).toBeGreaterThan(0);
expect(result.isAccessible).toBeUndefined();
expect(result.expandedURL).toBeUndefined();
});
test('should handle inaccessible URL', async () => {
const url = 'https://nonexistent.example.com';
(global.fetch as jest.Mock).mockImplementationOnce(() =>
Promise.resolve({
ok: false,
status: 404,
})
);
const result = await VerifioURL.verify(url);
expect(result.validity.isValid).toBe(true);
expect(result.validity.errors).toBeUndefined();
expect(result.isAccessible).toBe(false);
expect(result.expandedURL).toBeUndefined();
});
});
describe('extractDomain', () => {
beforeEach(() => {
jest.clearAllMocks();
jest.spyOn(console, 'error').mockImplementation(() => {});
});
afterEach(() => {
(console.error as jest.Mock).mockRestore();
});
describe('Basic Domain Extraction', () => {
const validCases = [
{
input: 'https://example.com',
expected: 'example.com',
desc: 'simple domain',
},
{
input: 'http://sub.example.com',
expected: 'sub.example.com',
desc: 'subdomain',
},
{
input: 'https://sub1.sub2.example.co.uk',
expected: 'sub1.sub2.example.co.uk',
desc: 'multiple subdomains',
},
{
input: 'https://example.com:8080',
expected: 'example.com',
desc: 'domain with port',
},
{
input: 'https://example.com/path?query=value#fragment',
expected: 'example.com',
desc: 'domain with path, query and fragment',
},
];
test.each(validCases)('should extract $desc: $input', async ({ input, expected }) => {
const result = await VerifioURL.extractDomain(input);
expect(result.success).toBe(true);
expect(result.domain).toBe(expected);
expect(result.error).toBeUndefined();
});
});
describe('IP Address Extraction', () => {
const ipCases = [
{
input: 'http://192.168.1.1',
expected: '192.168.1.1',
desc: 'IPv4 address',
},
{
input: 'https://[2001:0db8:85a3:0000:0000:8a2e:0370:7334]',
expected: '2001:db8:85a3::8a2e:370:7334',
desc: 'full IPv6 address',
},
{
input: 'https://[::1]',
expected: '::1',
desc: 'localhost IPv6',
},
{
input: 'https://[::ffff:192.0.2.1]',
expected: '::ffff:c000:201',
desc: 'IPv4-mapped IPv6 address',
},
];
test.each(ipCases)('should extract $desc: $input', async ({ input, expected }) => {
const result = await VerifioURL.extractDomain(input);
expect(result.success).toBe(true);
expect(result.domain).toBe(expected);
expect(result.error).toBeUndefined();
});
});
describe('URL Shortener Cases', () => {
test('should extract domain from expanded URL', async () => {
const shortUrl = 'https://bit.ly/abc123';
const expandedUrl = 'https://example.com/full-path';
(global.fetch as jest.Mock).mockImplementationOnce(() =>
Promise.resolve({
ok: true,
url: expandedUrl,
})
);
const result = await VerifioURL.extractDomain(shortUrl);
expect(result.success).toBe(true);
expect(result.domain).toBe('example.com');
expect(result.error).toBeUndefined();
});
test('should fall back to original domain if expansion fails', async () => {
const shortUrl = 'https://bit.ly/invalid';
(global.fetch as jest.Mock).mockImplementationOnce(() =>
Promise.resolve({
ok: false,
status: 404,
})
);
const result = await VerifioURL.extractDomain(shortUrl);
expect(result.success).toBe(true);
expect(result.domain).toBe('bit.ly');
expect(result.error).toBeUndefined();
});
});
describe('Error Cases', () => {
const invalidCases = [
{
input: '',
errorCode: VerifioDomainErrorCode.INVALID_URL,
desc: 'empty string',
},
{
input: 'not-a-url',
errorCode: VerifioDomainErrorCode.INVALID_URL,
desc: 'malformed URL',
},
{
input: 'http://',
errorCode: VerifioDomainErrorCode.INVALID_URL,
desc: 'protocol only',
},
{
input: 'http://[invalid-ipv6]',
errorCode: VerifioDomainErrorCode.INVALID_URL,
desc: 'invalid IPv6',
},
{
input: 'http://256.256.256.256',
errorCode: VerifioDomainErrorCode.INVALID_URL,
desc: 'invalid IPv4',
},
{
input: 'https://' + 'a'.repeat(256) + '.com',
errorCode: VerifioDomainErrorCode.INVALID_URL,
desc: 'domain too long',
},
];
test.each(invalidCases)('should handle $desc', async ({ input, errorCode }) => {
const result = await VerifioURL.extractDomain(input);
expect(result.success).toBe(false);
expect(result.domain).toBeUndefined();
expect(result.error).toBeDefined();
expect(result.error?.code).toBe(errorCode);
});
});
describe('Internationalized Domain Names (IDN)', () => {
const idnCases = [
{
input: 'https://xn--mnchen-3ya.de',
expected: 'xn--mnchen-3ya.de',
desc: 'Punycode domain',
},
{
input: 'https://xn--bcher-kva.example.com',
expected: 'xn--bcher-kva.example.com',
desc: 'Punycode subdomain',
},
];
test.each(idnCases)('should extract $desc: $input', async ({ input, expected }) => {
const result = await VerifioURL.extractDomain(input);
expect(result.success).toBe(true);
expect(result.domain).toBe(expected);
expect(result.error).toBeUndefined();
});
});
});
describe('VerifioURL IP Address Validation', () => {
describe('isIPv4Address', () => {
const validIPv4Cases = [
// Standard cases
{ input: '192.168.1.1', desc: 'typical local IP' },
{ input: '127.0.0.1', desc: 'localhost' },
{ input: '0.0.0.0', desc: 'all zeros' },
{ input: '255.255.255.255', desc: 'all max values' },
{ input: '1.2.3.4', desc: 'simple numbers' },
// Edge cases with valid numbers
{ input: '0.0.0.1', desc: 'minimum values with last digit' },
{ input: '100.100.100.100', desc: 'same numbers' },
{ input: '172.16.254.1', desc: 'private network address' },
{ input: '224.0.0.1', desc: 'multicast address' },
{ input: '169.254.0.1', desc: 'link-local address' },
// Boundary value cases
{ input: '0.0.0.0', desc: 'minimum possible value' },
{ input: '255.255.255.255', desc: 'maximum possible value' },
{ input: '1.255.255.255', desc: 'first octet maximum' },
{ input: '255.1.255.255', desc: 'second octet maximum' },
{ input: '255.255.1.255', desc: 'third octet maximum' },
{ input: '255.255.255.1', desc: 'fourth octet maximum' },
// Whitespace cases
{ input: ' 192.168.1.1', desc: 'leading space' },
{ input: '192.168.1.1 ', desc: 'trailing space' },
{ input: ' 192.168.1.1 ', desc: 'both side spaces' },
{ input: '192.168.1.1\n', desc: 'newline character' },
{ input: '192.168.1.1\t', desc: 'tab character' },
{ input: '192.168.1.1\r', desc: 'carriage return' },
{ input: '\n192.168.1.1', desc: 'leading newline' },
{ input: '\t192.168.1.1', desc: 'leading tab' },
{ input: '\r192.168.1.1', desc: 'leading carriage return' },
{ input: ' \n\t\r192.168.1.1\n\t\r ', desc: 'mixed whitespace' },
];
const invalidIPv4Cases = [
// Format errors
{ input: '192.168.1', desc: 'missing octet' },
{ input: '192.168.1.', desc: 'trailing dot' },
{ input: '.192.168.1', desc: 'leading dot' },
{ input: '192.168.1.1.', desc: 'extra trailing dot' },
{ input: '192.168..1', desc: 'empty octet' },
{ input: '192.168.1.1.1', desc: 'extra octet' },
// Invalid characters
{ input: '192.168.1.1a', desc: 'alphanumeric' },
{ input: 'a.b.c.d', desc: 'letters' },
{ input: '192.168.1.1/24', desc: 'CIDR notation' },
{ input: '192.168.1.-1', desc: 'negative number' },
{ input: '192.168.1.+1', desc: 'plus sign' },
{ input: '192.168.1.1e0', desc: 'scientific notation' },
// Invalid values
{ input: '256.1.2.3', desc: 'first octet exceeds max' },
{ input: '1.256.2.3', desc: 'second octet exceeds max' },
{ input: '1.2.256.3', desc: 'third octet exceeds max' },
{ input: '1.2.3.256', desc: 'fourth octet exceeds max' },
{ input: '300.300.300.300', desc: 'all octets exceed max' },
{ input: '-1.2.3.4', desc: 'negative first octet' },
{ input: '1.-2.3.4', desc: 'negative second octet' },
// Whitespace case
{ input: '192. 168.1.1', desc: 'space between octets' },
// Malformed strings
{ input: '192,168,1,1', desc: 'commas instead of dots' },
{ input: '192_168_1_1', desc: 'underscores instead of dots' },
// Invalid number formats
{ input: '192.168.1.1.', desc: 'trailing dot' },
{ input: '192.168.01.1', desc: 'octal-like number' },
{ input: '192.168.0x1.1', desc: 'hexadecimal-like number' },
{ input: '192.168.1.1e0', desc: 'scientific notation' },
{ input: '192.168.001.001', desc: 'numbers with leading zeros' },
{ input: '010.000.000.001', desc: 'all segments with leading zeros' },
// Empty or invalid input
{ input: '', desc: 'empty string' },
{ input: ' ', desc: 'space only' },
{ input: '...', desc: 'dots only' },
{ input: '192.168.1', desc: 'incomplete address' },
{ input: '192.168.1.', desc: 'incomplete final octet' },
];
test.each(validIPv4Cases)('should validate correct IPv4: $desc', ({ input }) => {
expect(VerifioURL.isIPv4Address(input)).toBe(true);
});
test.each(invalidIPv4Cases)('should invalidate incorrect IPv4: $desc', ({ input }) => {
expect(VerifioURL.isIPv4Address(input)).toBe(false);
});
});
describe('isIPv6Address', () => {
const validIPv6Cases = [
// Standard cases
{ input: '2001:0db8:85a3:0000:0000:8a2e:0370:7334', desc: 'full address' },
{ input: '::1', desc: 'localhost' },
{ input: '::', desc: 'unspecified address' },
// Compressed cases
{ input: '2001:db8:85a3::8a2e:370:7334', desc: 'middle compression' },
{ input: '::ffff:192.168.1.1', desc: 'IPv4-mapped address' },
{ input: '::ffff:c000:0280', desc: 'IPv4-mapped in hex' },
{ input: '2001:db8::', desc: 'trailing compression' },
{ input: '::1234:5678', desc: 'leading compression' },
{ input: '2001::7334', desc: 'middle compression with single group' },
// Mixed cases
{ input: '2001:0db8:85a3::8a2e:0:0', desc: 'mixed compression and zeros' },
{ input: '::ffff:0:0', desc: 'leading compression with zeros' },
{ input: '2001:db8::1:0:0:1', desc: 'mixed compression and ones' },
// Case variations
{ input: '2001:DB8:85A3:0000:0000:8A2E:0370:7334', desc: 'uppercase' },
{ input: '2001:db8:85a3:0000:0000:8a2e:0370:7334', desc: 'lowercase' },
{ input: '2001:Db8:85A3:0000:0000:8a2E:0370:7334', desc: 'mixed case' },
// Leading zeros
{ input: '2001:0db8:0000:0000:0001:0000:0000:0001', desc: 'multiple leading zeros' },
{ input: '0000:0000:0000:0000:0000:0000:0000:0001', desc: 'all leading zeros' },
// Special addresses
{ input: 'fe80::1', desc: 'link-local address' },
{ input: 'ff02::1', desc: 'multicast address' },
{ input: '2001:db8::', desc: 'documentation prefix' },
// Whitespace cases
{ input: ' 2001:0db8:85a3:0000:0000:8a2e:0370:7334', desc: 'leading space' },
{ input: '2001:0db8:85a3:0000:0000:8a2e:0370:7334 ', desc: 'trailing space' },
{ input: ' 2001:0db8:85a3:0000:0000:8a2e:0370:7334 ', desc: 'both side spaces' },
{ input: '2001:0db8:85a3:0000:0000:8a2e:0370:7334\n', desc: 'newline character' },
{ input: '2001:0db8:85a3:0000:0000:8a2e:0370:7334\t', desc: 'tab character' },
{ input: '2001:0db8:85a3:0000:0000:8a2e:0370:7334\r', desc: 'carriage return' },
{ input: '\n2001:0db8:85a3:0000:0000:8a2e:0370:7334', desc: 'leading newline' },
{ input: '\t2001:0db8:85a3:0000:0000:8a2e:0370:7334', desc: 'leading tab' },
{ input: '\r2001:0db8:85a3:0000:0000:8a2e:0370:7334', desc: 'leading carriage return' },
{
input: ' \n\t\r2001:0db8:85a3:0000:0000:8a2e:0370:7334\n\t\r ',
desc: 'mixed whitespace',
},
];
const invalidIPv6Cases = [
// Format errors
{ input: '2001:db8:85a3:0000:0000:8a2e:0370', desc: 'too few segments' },
{ input: '2001:db8:85a3:0000:0000:8a2e:0370:7334:7334', desc: 'too many segments' },
{ input: '2001:db8::85a3::8a2e:0370:7334', desc: 'multiple compression markers' },
{ input: '2001:db8:::8a2e:0370:7334', desc: 'invalid compression' },
// Invalid characters
{ input: '2001:db8:85a3:0000:0000:8a2e:0370:733g', desc: 'invalid hex digit' },
{ input: '2001:db8:85a3:0000:0000:8a2e:0370:733.', desc: 'invalid punctuation' },
{ input: '2001:db8:85a3:0000:0000:8a2e:0370:-7334', desc: 'negative number' },
{ input: '2001:db8:85a3:0000:0000:8a2e:0370:+7334', desc: 'plus sign' },
// Invalid segment lengths
{ input: '2001:db8:85a3:00000:0000:8a2e:0370:7334', desc: 'segment too long' },
{ input: '2001:db8:85a3:0:0:8a2e:0370:7334:', desc: 'trailing colon' },
{ input: ':2001:db8:85a3:0000:0000:8a2e:0370:7334', desc: 'leading colon' },
// Whitespace case
{ input: '2001:db8:85a3:0000 :0000:8a2e:0370:7334', desc: 'space between segments' },
// Malformed compression
{ input: '2001::db8::0370:7334', desc: 'multiple double colons' },
{ input: '2001:::db8:0370:7334', desc: 'triple colon' },
{ input: ':::', desc: 'too many colons' },
// Invalid IPv4-mapped addresses
{ input: '::ffff:256.256.256.256', desc: 'invalid IPv4 in mapped address' },
{ input: '::ffff:192.168.1', desc: 'incomplete IPv4 in mapped address' },
{ input: '::ffff:192.168.1.1.1', desc: 'malformed IPv4 in mapped address' },
// Empty or invalid input
{ input: '', desc: 'empty string' },
{ input: ' ', desc: 'space only' },
{ input: ':', desc: 'single colon' },
{ input: '2001', desc: 'single segment' },
{ input: '2001:', desc: 'incomplete address with colon' },
];
test.each(validIPv6Cases)('should validate correct IPv6: $desc', ({ input }) => {
expect(VerifioURL.isIPv6Address(input)).toBe(true);
});
test.each(invalidIPv6Cases)('should invalidate incorrect IPv6: $desc', ({ input }) => {
expect(VerifioURL.isIPv6Address(input)).toBe(false);
});
});
describe('isIPAddress', () => {
test('should validate IPv4 addresses', () => {
expect(VerifioURL.isIPAddress('192.168.1.1')).toBe(true);
expect(VerifioURL.isIPAddress('0.0.0.0')).toBe(true);
expect(VerifioURL.isIPAddress('255.255.255.255')).toBe(true);
});
test('should validate IPv6 addresses', () => {
expect(VerifioURL.isIPAddress('2001:0db8:85a3:0000:0000:8a2e:0370:7334')).toBe(true);
expect(VerifioURL.isIPAddress('::1')).toBe(true);
expect(VerifioURL.isIPAddress('::')).toBe(true);
});
test('should invalidate incorrect IP addresses', () => {
expect(VerifioURL.isIPAddress('')).toBe(false);
expect(VerifioURL.isIPAddress('not-an-ip')).toBe(false);
expect(VerifioURL.isIPAddress('256.256.256.256')).toBe(false);
expect(VerifioURL.isIPAddress('2001:db8::85a3::8a2e:0370:7334')).toBe(false);
});
});
});
});