import type { Logger } from '@n8n/backend-common';
import { SsrfProtectionConfig } from '@n8n/config';
import type { LookupAddress } from 'node:dns';
import { mock } from 'vitest-mock-extended';

import type { DnsResolver } from '../../dns';
import { SsrfBlockedHostnameError } from '../ssrf-blocked-hostname.error';
import { SsrfBlockedIpError } from '../ssrf-blocked-ip.error';
import { SsrfProtectionService } from '../ssrf-protection.service';

function createConfig(overrides: Partial<SsrfProtectionConfig> = {}): SsrfProtectionConfig {
	const config = new SsrfProtectionConfig();
	Object.assign(config, overrides);
	return config;
}

const mockScopedLogger = mock<Logger>();
const mockLogger = mock<Logger>({ scoped: vi.fn().mockReturnValue(mockScopedLogger) });

function createMockDnsResolver() {
	const resolver = mock<DnsResolver>();
	resolver.lookup.mockResolvedValue([]);
	return resolver;
}

function createService(
	configOverrides: Partial<SsrfProtectionConfig> = {},
	dnsResolver?: ReturnType<typeof createMockDnsResolver>,
) {
	const config = createConfig(configOverrides);
	const resolver = dnsResolver ?? createMockDnsResolver();
	return {
		service: new SsrfProtectionService(config, resolver, mockLogger),
		dnsResolver: resolver,
	};
}

const expectBlocked = (result: unknown) => {
	expect(result).toEqual({ ok: false, error: expect.any(SsrfBlockedIpError) as Error });
};

const expectAllowed = (result: unknown) => {
	expect(result).toEqual({ ok: true, result: undefined });
};

const expectBlockedHostname = (result: unknown) => {
	expect(result).toEqual({ ok: false, error: expect.any(SsrfBlockedHostnameError) as Error });
};

const asHostnames = (values: string[]) =>
	values as unknown as SsrfProtectionConfig['blockedHostnames'];

describe('SsrfProtectionService', () => {
	beforeEach(() => {
		vi.clearAllMocks();
	});

	describe('validateIp', () => {
		describe('blocked private ranges (RFC 1918)', () => {
			it.each([
				['10.0.0.1', '10.0.0.0/8'],
				['10.255.255.255', '10.0.0.0/8'],
				['172.16.0.1', '172.16.0.0/12'],
				['172.31.255.255', '172.16.0.0/12'],
				['192.168.0.1', '192.168.0.0/16'],
				['192.168.255.255', '192.168.0.0/16'],
			])('should block %s (in %s)', (ip) => {
				const { service } = createService();
				const result = service.validateIp(ip);
				expectBlocked(result);
			});
		});

		describe('blocked loopback addresses', () => {
			it.each(['127.0.0.1', '127.0.0.2', '127.255.255.255'])(
				'should block IPv4 loopback %s',
				(ip) => {
					const { service } = createService();
					expectBlocked(service.validateIp(ip));
				},
			);

			it('should block IPv6 loopback ::1', () => {
				const { service } = createService();
				expectBlocked(service.validateIp('::1'));
			});
		});

		describe('blocked link-local addresses', () => {
			it('should block IPv4 link-local 169.254.1.1', () => {
				const { service } = createService();
				expectBlocked(service.validateIp('169.254.1.1'));
			});

			it('should block IPv6 link-local fe80::1', () => {
				const { service } = createService();
				expectBlocked(service.validateIp('fe80::1'));
			});
		});

		describe('blocked metadata/special addresses', () => {
			it.each(['0.0.0.0', '192.0.0.1', '192.0.2.1', '198.18.0.1', '198.51.100.1', '203.0.113.1'])(
				'should block special address %s',
				(ip) => {
					const { service } = createService();
					expectBlocked(service.validateIp(ip));
				},
			);
		});

		describe('allowed public addresses', () => {
			it.each(['8.8.8.8', '1.1.1.1', '93.184.216.34', '2606:4700::6810:85e5'])(
				'should allow public IP %s',
				(ip) => {
					const { service } = createService();
					expectAllowed(service.validateIp(ip));
				},
			);
		});

		describe('allowlist overrides blocklist', () => {
			it('should allow a blocked IP if it is in the allowlist', () => {
				const { service } = createService({
					allowedIpRanges: ['10.0.0.0/8'] as unknown as SsrfProtectionConfig['allowedIpRanges'],
				});

				expectAllowed(service.validateIp('10.0.0.1'));
			});

			it('should allow a specific blocked IP in the allowlist', () => {
				const { service } = createService({
					allowedIpRanges: ['127.0.0.1/32'] as unknown as SsrfProtectionConfig['allowedIpRanges'],
				});

				expectAllowed(service.validateIp('127.0.0.1'));
				// Other loopback IPs should still be blocked
				expectBlocked(service.validateIp('127.0.0.2'));
			});
		});

		it('should throw for non-IP strings', () => {
			const { service } = createService();
			expect(() => service.validateIp('not-an-ip')).toThrow('Invalid IP address');
		});
	});

	describe('validateUrl', () => {
		it('should reject invalid URLs', async () => {
			const { service } = createService();
			const result = await service.validateUrl('not-a-url');
			expect(result).toEqual({
				ok: false,
				error: expect.objectContaining({ message: 'Invalid URL: not-a-url' }) as Error,
			});
		});

		it('should validate direct IPv4 addresses in URLs', async () => {
			const { service } = createService();

			const result = await service.validateUrl('http://127.0.0.1/admin');

			expectBlocked(result);
		});

		it('should validate direct IPv6 addresses in URLs', async () => {
			const { service } = createService();

			const result = await service.validateUrl('http://[::1]/admin');

			expectBlocked(result);
		});

		it('should allow public IPs in URLs', async () => {
			const { service } = createService();

			const result = await service.validateUrl('http://8.8.8.8/');

			expectAllowed(result);
		});

		it('should resolve hostnames and validate resolved IPs', async () => {
			const dnsResolver = createMockDnsResolver();
			dnsResolver.lookup.mockResolvedValue([{ address: '93.184.216.34', family: 4 }]);

			const { service } = createService({}, dnsResolver);
			const result = await service.validateUrl('http://example.com/api');

			expectAllowed(result);
		});

		it('should block if hostname resolves to blocked IP', async () => {
			const dnsResolver = createMockDnsResolver();
			dnsResolver.lookup.mockResolvedValue([{ address: '10.0.0.1', family: 4 }]);

			const { service } = createService({}, dnsResolver);
			const result = await service.validateUrl('http://malicious.com/');

			expectBlocked(result);
		});

		it('should block if any resolved IP is blocked', async () => {
			const dnsResolver = createMockDnsResolver();
			dnsResolver.lookup.mockResolvedValue([
				{ address: '93.184.216.34', family: 4 },
				{ address: '127.0.0.1', family: 4 },
			]);

			const { service } = createService({}, dnsResolver);
			const result = await service.validateUrl('http://multi-ip.example.com/');

			expectBlocked(result);
		});

		it('should block if any resolved IP is blocked across IPv4/IPv6', async () => {
			const dnsResolver = createMockDnsResolver();
			dnsResolver.lookup.mockResolvedValue([
				{ address: '93.184.216.34', family: 4 },
				{ address: '::1', family: 6 },
			]);

			const { service } = createService({}, dnsResolver);
			const result = await service.validateUrl('http://mixed-family.example.com/');

			expectBlocked(result);
		});

		it('should throw when DNS resolution returns no results', async () => {
			const dnsResolver = createMockDnsResolver();
			dnsResolver.lookup.mockResolvedValue([]);

			const { service } = createService({}, dnsResolver);

			await expect(service.validateUrl('http://nonexistent.example.com/')).rejects.toThrow(
				'DNS lookup for nonexistent.example.com returned no results',
			);
		});

		it('should bubble up DNS resolver errors', async () => {
			const dnsResolver = createMockDnsResolver();
			dnsResolver.lookup.mockRejectedValue(new Error('ENOTFOUND'));

			const { service } = createService({}, dnsResolver);

			await expect(service.validateUrl('http://nonexistent.example.com/')).rejects.toThrow(
				'ENOTFOUND',
			);
		});

		it('should bypass IP checks when hostname is in allowlist', async () => {
			const dnsResolver = createMockDnsResolver();
			dnsResolver.lookup.mockResolvedValue([{ address: '10.0.0.1', family: 4 }]);

			const { service } = createService(
				{
					allowedHostnames: [
						'*.internal.n8n.io',
					] as unknown as SsrfProtectionConfig['allowedHostnames'],
				},
				dnsResolver,
			);

			const result = await service.validateUrl('http://api.internal.n8n.io/health');

			expectAllowed(result);
		});

		it('should accept URL objects', async () => {
			const { service } = createService();
			const result = await service.validateUrl(new URL('http://127.0.0.1'));
			expectBlocked(result);
		});

		it('should use DNS resolver for hostname lookups', async () => {
			const dnsResolver = createMockDnsResolver();
			dnsResolver.lookup.mockResolvedValue([{ address: '93.184.216.34', family: 4 }]);

			const { service } = createService({}, dnsResolver);
			const result = await service.validateUrl('http://cached.example.com/');

			expectAllowed(result);
			expect(dnsResolver.lookup).toHaveBeenCalledWith('cached.example.com', { all: true });
		});
	});

	describe('validateConnectionHost', () => {
		it('should block a direct IP-literal target', () => {
			const { service } = createService();

			expectBlocked(service.validateConnectionHost('169.254.169.254'));
			expectBlocked(service.validateConnectionHost('10.0.0.1'));
		});

		it('should allow a public direct IP target', () => {
			const { service } = createService();

			expectAllowed(service.validateConnectionHost('93.184.216.34'));
		});

		it('should normalize IPv6 bracket notation before validating', () => {
			const { service } = createService();

			expectBlocked(service.validateConnectionHost('[::1]'));
		});

		it('should allow hostnames (deferred to the secure lookup)', () => {
			const dnsResolver = createMockDnsResolver();
			const { service } = createService({}, dnsResolver);

			expectAllowed(service.validateConnectionHost('example.com'));
			// No DNS resolution happens here — hostnames are validated by the lookup.
			expect(dnsResolver.lookup).not.toHaveBeenCalled();
		});

		it('should emit a connect_time event for IP-literal targets', () => {
			const { service } = createService();
			const blocked = vi.fn();
			const allowed = vi.fn();
			service.events.on('ssrf.blocked', blocked);
			service.events.on('ssrf.allowed', allowed);

			service.validateConnectionHost('10.0.0.1');
			service.validateConnectionHost('93.184.216.34');

			expect(blocked).toHaveBeenCalledWith(
				expect.objectContaining({ phase: 'connect_time', reason: 'blocked_ip' }),
			);
			expect(allowed).toHaveBeenCalledWith(expect.objectContaining({ phase: 'connect_time' }));
		});
	});

	describe('validateRedirectSync', () => {
		it('should block direct-IP redirect targets', () => {
			const { service } = createService();

			expect(() => service.validateRedirectSync('http://127.0.0.1/admin')).toThrow(
				'The request was blocked because it resolves to a restricted IP address',
			);
		});

		it('should block redirect chains from public to private', async () => {
			const dnsResolver = createMockDnsResolver();
			dnsResolver.lookup.mockResolvedValue([{ address: '93.184.216.34', family: 4 }]);

			const { service } = createService({}, dnsResolver);

			// Initial URL is public (allowed)
			const initial = await service.validateUrl('http://public.example.com');
			expectAllowed(initial);

			// Redirect target is private (blocked)
			expect(() => service.validateRedirectSync('http://192.168.1.1/admin')).toThrow(
				'The request was blocked because it resolves to a restricted IP address',
			);
		});

		it('should ignore invalid redirect URLs', () => {
			const { service } = createService();

			expect(() => service.validateRedirectSync('not-a-url')).not.toThrow();
		});
	});

	describe('createSecureLookup', () => {
		it('should resolve and validate IPs at connection time', async () => {
			const dnsResolver = createMockDnsResolver();
			dnsResolver.lookup.mockResolvedValue([{ address: '93.184.216.34', family: 4 }]);

			const { service } = createService({}, dnsResolver);
			const lookup = service.createSecureLookup();

			const [error, address, family] = await new Promise<
				[Error | null, string, number | undefined]
			>((resolve) =>
				lookup('example.com', { all: false }, (lookupError, addr, fam) =>
					resolve([lookupError, addr as string, fam]),
				),
			);

			expect(error).toBeNull();
			expect(address).toBe('93.184.216.34');
			expect(family).toBe(4);
		});

		it('should reject blocked IPs during lookup', async () => {
			const dnsResolver = createMockDnsResolver();
			dnsResolver.lookup.mockResolvedValue([{ address: '10.0.0.1', family: 4 }]);

			const { service } = createService({}, dnsResolver);
			const lookup = service.createSecureLookup();

			await new Promise<void>((resolve, reject) => {
				lookup('evil.com', { all: false }, (lookupError) => {
					try {
						expect(lookupError).toBeTruthy();
						expect(lookupError?.message).toContain(
							'The request was blocked because it resolves to a restricted IP address',
						);
						resolve();
					} catch (error) {
						reject(error as Error);
					}
				});
			});
		});

		it('should return all addresses when all=true', async () => {
			const dnsResolver = createMockDnsResolver();
			dnsResolver.lookup.mockResolvedValue([
				{ address: '93.184.216.34', family: 4 },
				{ address: '93.184.216.35', family: 4 },
			]);

			const { service } = createService({}, dnsResolver);
			const lookup = service.createSecureLookup();

			const [error, addresses] = await new Promise<[Error | null, LookupAddress[]]>((resolve) =>
				lookup('multi.example.com', { all: true }, (lookupError, addrs) =>
					resolve([lookupError, addrs as LookupAddress[]]),
				),
			);

			expect(error).toBeNull();
			expect(addresses).toEqual([
				{ address: '93.184.216.34', family: 4 },
				{ address: '93.184.216.35', family: 4 },
			]);
		});

		it('should respect family=6 and return only IPv6 addresses', async () => {
			const dnsResolver = createMockDnsResolver();
			dnsResolver.lookup.mockResolvedValue([{ address: '2606:4700::6810:85e5', family: 6 }]);

			const { service } = createService({}, dnsResolver);
			const lookup = service.createSecureLookup();

			const [error, address, family] = await new Promise<
				[Error | null, string, number | undefined]
			>((resolve) =>
				lookup('dualstack.example.com', { all: false, family: 6 }, (lookupError, addr, fam) =>
					resolve([lookupError, addr as string, fam]),
				),
			);

			expect(error).toBeNull();
			expect(address).toBe('2606:4700::6810:85e5');
			expect(family).toBe(6);
			expect(dnsResolver.lookup).toHaveBeenCalledWith('dualstack.example.com', {
				all: false,
				family: 6,
			});
		});

		it('should fail when requested family has no resolved addresses', async () => {
			const dnsResolver = createMockDnsResolver();
			dnsResolver.lookup.mockResolvedValue([]);

			const { service } = createService({}, dnsResolver);
			const lookup = service.createSecureLookup();

			const [error, address, family] = await new Promise<
				[Error | null, string, number | undefined]
			>((resolve) =>
				lookup('ipv4-only.example.com', { all: false, family: 6 }, (lookupError, addr, fam) =>
					resolve([lookupError, addr as string, fam]),
				),
			);

			expect(error).toBeTruthy();
			expect(address).toBe('');
			expect(family).toBeUndefined();
			expect(dnsResolver.lookup).toHaveBeenCalledWith('ipv4-only.example.com', {
				all: false,
				family: 6,
			});
		});

		it('should allow hostnames in the allowlist without IP validation', async () => {
			const dnsResolver = createMockDnsResolver();
			dnsResolver.lookup.mockResolvedValue([{ address: '10.0.0.1', family: 4 }]);

			const { service } = createService(
				{
					allowedHostnames: [
						'*.internal.n8n.io',
					] as unknown as SsrfProtectionConfig['allowedHostnames'],
				},
				dnsResolver,
			);
			const lookup = service.createSecureLookup();

			const [error, address] = await new Promise<[Error | null, string]>((resolve) =>
				lookup('api.internal.n8n.io', { all: false }, (lookupError, addr) =>
					resolve([lookupError, addr as string]),
				),
			);

			expect(error).toBeNull();
			expect(address).toBe('10.0.0.1');
		});

		it('should pass empty string and undefined family on non-all lookup errors', async () => {
			const dnsResolver = createMockDnsResolver();
			dnsResolver.lookup.mockRejectedValue(new Error('ENOTFOUND'));

			const { service } = createService({}, dnsResolver);
			const lookup = service.createSecureLookup();

			const [error, address, family] = await new Promise<
				[Error | null, string, number | undefined]
			>((resolve) =>
				lookup('failing.example.com', { all: false }, (lookupError, addr, fam) =>
					resolve([lookupError, addr as string, fam]),
				),
			);

			expect(error).toBeTruthy();
			expect(address).toBe('');
			expect(family).toBeUndefined();
		});

		it('should pass empty array and undefined family on all=true lookup errors', async () => {
			const dnsResolver = createMockDnsResolver();
			dnsResolver.lookup.mockRejectedValue(new Error('ENOTFOUND'));

			const { service } = createService({}, dnsResolver);
			const lookup = service.createSecureLookup();

			const [error, addresses, family] = await new Promise<
				[Error | null, LookupAddress[], number | undefined]
			>((resolve) =>
				lookup('failing.example.com', { all: true }, (lookupError, addrs, fam) =>
					resolve([lookupError, addrs as LookupAddress[], fam]),
				),
			);

			expect(error).toBeTruthy();
			expect(addresses).toEqual([]);
			expect(family).toBeUndefined();
		});
	});

	describe('blocked hostnames', () => {
		it('should block a request whose hostname is on the deny-list', async () => {
			const dnsResolver = createMockDnsResolver();
			const { service } = createService(
				{ blockedHostnames: asHostnames(['exfil.example.com']) },
				dnsResolver,
			);

			const result = await service.validateUrl('http://exfil.example.com/data');

			expectBlockedHostname(result);
		});

		it('should deny by name before DNS resolution runs', async () => {
			const dnsResolver = createMockDnsResolver();
			const { service } = createService(
				{ blockedHostnames: asHostnames(['exfil.example.com']) },
				dnsResolver,
			);

			await service.validateUrl('http://exfil.example.com/data');

			expect(dnsResolver.lookup).not.toHaveBeenCalled();
		});

		it('should deny even when the hostname resolves to a public IP', async () => {
			const dnsResolver = createMockDnsResolver();
			dnsResolver.lookup.mockResolvedValue([{ address: '93.184.216.34', family: 4 }]);
			const { service } = createService(
				{ blockedHostnames: asHostnames(['exfil.example.com']) },
				dnsResolver,
			);

			expectBlockedHostname(await service.validateUrl('http://exfil.example.com/data'));
		});

		it('should match the deny-list case-insensitively', async () => {
			const { service } = createService({
				blockedHostnames: asHostnames(['EXFIL.Example.COM']),
			});

			expectBlockedHostname(await service.validateUrl('http://exfil.example.com/data'));
		});

		describe('wildcard patterns', () => {
			it('should block subdomains of a wildcard pattern', async () => {
				const { service } = createService({
					blockedHostnames: asHostnames(['*.tracker.example']),
				});

				expectBlockedHostname(await service.validateUrl('http://a.tracker.example/'));
			});

			it('should not block the bare domain of a wildcard pattern', async () => {
				const dnsResolver = createMockDnsResolver();
				dnsResolver.lookup.mockResolvedValue([{ address: '93.184.216.34', family: 4 }]);
				const { service } = createService(
					{ blockedHostnames: asHostnames(['*.tracker.example']) },
					dnsResolver,
				);

				expectAllowed(await service.validateUrl('http://tracker.example/'));
			});
		});

		describe('allow-list wins over deny-list', () => {
			it('should allow a hostname present in both lists', async () => {
				const dnsResolver = createMockDnsResolver();
				dnsResolver.lookup.mockResolvedValue([{ address: '93.184.216.34', family: 4 }]);
				const { service } = createService(
					{
						allowedHostnames: asHostnames(['api.example.com']),
						blockedHostnames: asHostnames(['*.example.com']),
					},
					dnsResolver,
				);

				expectAllowed(await service.validateUrl('http://api.example.com/'));
			});

			it('should still block siblings not carved out by the allow-list', async () => {
				const { service } = createService({
					allowedHostnames: asHostnames(['api.example.com']),
					blockedHostnames: asHostnames(['*.example.com']),
				});

				expectBlockedHostname(await service.validateUrl('http://other.example.com/'));
			});
		});

		describe('connect-time secure lookup', () => {
			it('should reject a deny-listed hostname during lookup', async () => {
				const dnsResolver = createMockDnsResolver();
				dnsResolver.lookup.mockResolvedValue([{ address: '93.184.216.34', family: 4 }]);
				const { service } = createService(
					{ blockedHostnames: asHostnames(['exfil.example.com']) },
					dnsResolver,
				);
				const lookup = service.createSecureLookup();

				const error = await new Promise<Error | null>((resolve) =>
					lookup('exfil.example.com', { all: false }, (lookupError) => resolve(lookupError)),
				);

				expect(error).toBeInstanceOf(SsrfBlockedHostnameError);
				// Denied by name before DNS resolution, even on the TOCTOU-critical path.
				expect(dnsResolver.lookup).not.toHaveBeenCalled();
			});
		});

		describe('redirects', () => {
			it('should block a redirect to a deny-listed hostname', () => {
				const { service } = createService({
					blockedHostnames: asHostnames(['exfil.example.com']),
				});

				expect(() => service.validateRedirectSync('http://exfil.example.com/data')).toThrow(
					SsrfBlockedHostnameError,
				);
			});

			it('should allow a redirect to a hostname carved out by the allow-list', () => {
				const { service } = createService({
					allowedHostnames: asHostnames(['api.example.com']),
					blockedHostnames: asHostnames(['*.example.com']),
				});

				expect(() => service.validateRedirectSync('http://api.example.com/')).not.toThrow();
			});
		});

		describe('events', () => {
			it('should emit ssrf.blocked with reason blocked_hostname on pre-flight', async () => {
				const { service } = createService({
					blockedHostnames: asHostnames(['exfil.example.com']),
				});
				const blocked = vi.fn();
				service.events.on('ssrf.blocked', blocked);

				await service.validateUrl('http://exfil.example.com/data');

				expect(blocked).toHaveBeenCalledWith(
					expect.objectContaining({ phase: 'pre_flight', reason: 'blocked_hostname' }),
				);
			});

			it('should emit ssrf.blocked with reason blocked_hostname on redirect', () => {
				const { service } = createService({
					blockedHostnames: asHostnames(['exfil.example.com']),
				});
				const blocked = vi.fn();
				service.events.on('ssrf.blocked', blocked);

				expect(() => service.validateRedirectSync('http://exfil.example.com/')).toThrow();

				expect(blocked).toHaveBeenCalledWith(
					expect.objectContaining({ phase: 'redirect', reason: 'blocked_hostname' }),
				);
			});
		});
	});

	describe('bypass prevention', () => {
		describe('URL encoding tricks', () => {
			it('should handle percent-encoded hostnames', async () => {
				const { service } = createService();

				// %31%32%37%2e%30%2e%30%2e%31 = 127.0.0.1 percent-encoded
				// URL constructor normalizes this back to 127.0.0.1
				const result = await service.validateUrl('http://%31%32%37%2e%30%2e%30%2e%31/');
				expectBlocked(result);
			});

			it('should handle URLs with encoded path components', async () => {
				const { service } = createService();

				const result = await service.validateUrl('http://127.0.0.1/%61%64%6d%69%6e');
				expectBlocked(result);
			});
		});

		describe('decimal/octal/hex IP representations', () => {
			it('should block decimal IP representation for loopback', async () => {
				// new URL('http://2130706433/').hostname === '127.0.0.1' — normalized before lookup
				const { service } = createService();
				expectBlocked(await service.validateUrl('http://2130706433/'));
			});

			it('should block decimal IP representation for RFC-1918', async () => {
				// 167772161 === 10.0.0.1
				const { service } = createService();
				expectBlocked(await service.validateUrl('http://167772161/'));
			});

			it('should block hex IP representation for loopback', async () => {
				// 0x7f000001 === 127.0.0.1
				const { service } = createService();
				expectBlocked(await service.validateUrl('http://0x7f000001/'));
			});

			it('should block hex IP representation for cloud metadata', async () => {
				// 0xa9fea9fe === 169.254.169.254
				const { service } = createService();
				expectBlocked(await service.validateUrl('http://0xa9fea9fe/'));
			});
		});

		describe('IPv6-mapped IPv4 addresses', () => {
			it('should block ::ffff:127.0.0.1 (dotted form)', () => {
				const { service } = createService();
				expectBlocked(service.validateIp('::ffff:127.0.0.1'));
			});

			it('should block ::ffff:10.0.0.1 (dotted form)', () => {
				const { service } = createService();
				expectBlocked(service.validateIp('::ffff:10.0.0.1'));
			});

			it('should block ::ffff:7f00:1 (hex-compressed form for 127.0.0.1)', () => {
				// new URL auto-compresses ::ffff:127.0.0.1 to this form
				const { service } = createService();
				expectBlocked(service.validateIp('::ffff:7f00:1'));
			});

			it('should block ::ffff:a9fe:a9fe (hex-compressed form for 169.254.169.254)', () => {
				const { service } = createService();
				expectBlocked(service.validateIp('::ffff:a9fe:a9fe'));
			});

			it('should block validateUrl for bracketed IPv4-mapped IPv6 loopback', async () => {
				const { service } = createService();
				expectBlocked(await service.validateUrl('http://[::ffff:127.0.0.1]/'));
			});

			it('should block validateUrl for bracketed IPv4-mapped IPv6 metadata', async () => {
				const { service } = createService();
				expectBlocked(await service.validateUrl('http://[::ffff:169.254.169.254]/'));
			});

			it('should allow ::ffff: with public IP', () => {
				const { service } = createService();
				expectAllowed(service.validateIp('::ffff:8.8.8.8'));
			});

			it('should allow ::ffff:808:808 (hex-compressed public IP)', () => {
				const { service } = createService();
				expectAllowed(service.validateIp('::ffff:808:808'));
			});
		});

		describe('DNS rebinding prevention (TOCTOU)', () => {
			it('should validate IPs at connection time via secure lookup', async () => {
				const dnsResolver = createMockDnsResolver();
				dnsResolver.lookup.mockResolvedValueOnce([{ address: '10.0.0.1', family: 4 }]);

				const { service } = createService({}, dnsResolver);
				const lookup = service.createSecureLookup();

				await new Promise<void>((resolve, reject) => {
					lookup('rebinding.evil.com', { all: false }, (lookupError) => {
						try {
							expect(lookupError).toBeTruthy();
							expect(lookupError?.message).toContain(
								'The request was blocked because it resolves to a restricted IP address',
							);
							resolve();
						} catch (error) {
							reject(error as Error);
						}
					});
				});
			});
		});

		describe('redirect chains', () => {
			it('should block redirect from public to private IP', () => {
				const { service } = createService();

				expect(() => service.validateRedirectSync('http://10.0.0.1/internal')).toThrow(
					'The request was blocked because it resolves to a restricted IP address',
				);
			});

			it('should block redirect to loopback', () => {
				const { service } = createService();

				expect(() => service.validateRedirectSync('http://[::1]/admin')).toThrow(
					'The request was blocked because it resolves to a restricted IP address',
				);
			});
		});

		describe('IPv6 unique local addresses', () => {
			it('should block fc00:: addresses', () => {
				const { service } = createService();
				expectBlocked(service.validateIp('fc00::1'));
			});

			it('should block fd00:: addresses', () => {
				const { service } = createService();
				expectBlocked(service.validateIp('fd00::1'));
			});
		});
	});

	describe('warning logs', () => {
		it('should log a warning for invalid IP range entries', () => {
			createService({
				blockedIpRanges: ['not-a-valid-range'],
			});

			expect(mockScopedLogger.warn).toHaveBeenCalledWith(
				"Invalid value 'not-a-valid-range' in N8N_SSRF_BLOCKED_IP_RANGES: Invalid CIDR notation",
			);
		});
	});

	describe('events', () => {
		it('should emit ssrf.allowed for an allowed URL', async () => {
			const dnsResolver = createMockDnsResolver();
			dnsResolver.lookup.mockResolvedValue([{ address: '93.184.216.34', family: 4 }]);
			const { service } = createService({}, dnsResolver);
			const allowed = vi.fn();
			service.events.on('ssrf.allowed', allowed);

			await service.validateUrl('http://example.com/');

			expect(allowed).toHaveBeenCalledWith(
				expect.objectContaining({ phase: 'pre_flight', durationMs: expect.any(Number) as number }),
			);
		});

		it('should emit ssrf.blocked with reason blocked_ip for a blocked URL', async () => {
			const { service } = createService();
			const blocked = vi.fn();
			service.events.on('ssrf.blocked', blocked);

			await service.validateUrl('http://127.0.0.1/');

			expect(blocked).toHaveBeenCalledWith(
				expect.objectContaining({ phase: 'pre_flight', reason: 'blocked_ip' }),
			);
		});

		it('should emit ssrf.blocked with reason invalid_url for an unparseable URL', async () => {
			const { service } = createService();
			const blocked = vi.fn();
			service.events.on('ssrf.blocked', blocked);

			await service.validateUrl('not a url');

			expect(blocked).toHaveBeenCalledWith(
				expect.objectContaining({ phase: 'pre_flight', reason: 'invalid_url' }),
			);
		});

		it('should emit ssrf.blocked with reason dns_error when DNS resolution rejects', async () => {
			const dnsResolver = createMockDnsResolver();
			dnsResolver.lookup.mockRejectedValue(new Error('ENOTFOUND'));
			const { service } = createService({}, dnsResolver);
			const blocked = vi.fn();
			service.events.on('ssrf.blocked', blocked);

			await expect(service.validateUrl('http://nonexistent.example.com/')).rejects.toThrow(
				'ENOTFOUND',
			);

			expect(blocked).toHaveBeenCalledWith(
				expect.objectContaining({ phase: 'pre_flight', reason: 'dns_error' }),
			);
		});

		it('should emit ssrf.blocked with reason blocked_ip for a blocked redirect', () => {
			const { service } = createService();
			const blocked = vi.fn();
			service.events.on('ssrf.blocked', blocked);

			expect(() => service.validateRedirectSync('http://127.0.0.1/admin')).toThrow();

			expect(blocked).toHaveBeenCalledWith(
				expect.objectContaining({ phase: 'redirect', reason: 'blocked_ip' }),
			);
		});
	});
});
