import dns from 'dns';
import { resolveAndValidate, BlockedHostError } from '../ssrf-guard';

jest.mock('dns', () => ({
    promises: {
        resolve4: jest.fn(),
        resolve6: jest.fn(),
    },
}));

const mockResolve4 = dns.promises.resolve4 as jest.MockedFunction<typeof dns.promises.resolve4>;
const mockResolve6 = dns.promises.resolve6 as jest.MockedFunction<typeof dns.promises.resolve6>;

beforeEach(() => {
    mockResolve4.mockReset();
    mockResolve6.mockReset();
});

describe('resolveAndValidate', () => {
    describe('IP literals (no DNS round-trip)', () => {
        it('passes for public IPv4 literal', async () => {
            const result = await resolveAndValidate('8.8.8.8');
            expect(result).toEqual({ ip: '8.8.8.8', family: 4, hostname: '8.8.8.8' });
            expect(mockResolve4).not.toHaveBeenCalled();
            expect(mockResolve6).not.toHaveBeenCalled();
        });

        it('passes for public IPv6 literal (with brackets)', async () => {
            const result = await resolveAndValidate('[2606:4700:4700::1111]');
            expect(result.ip).toBe('2606:4700:4700::1111');
            expect(result.family).toBe(6);
            expect(mockResolve4).not.toHaveBeenCalled();
        });

        it('rejects 127.0.0.1', async () => {
            await expect(resolveAndValidate('127.0.0.1')).rejects.toBeInstanceOf(BlockedHostError);
        });

        it('rejects cloud metadata IP 169.254.169.254', async () => {
            await expect(resolveAndValidate('169.254.169.254')).rejects.toBeInstanceOf(BlockedHostError);
        });

        it('rejects IPv6 loopback ::1', async () => {
            await expect(resolveAndValidate('::1')).rejects.toBeInstanceOf(BlockedHostError);
        });

        it('allows RFC1918 IP when allowPrivate=true', async () => {
            // AUDIT-2 #2 (2026-05-24): switched from 127.0.0.1 to 10.0.0.5.
            // Loopback now needs its own allowLoopback flag (see tests
            // below); RFC1918 intranet ranges remain the canonical
            // allowPrivate target.
            const result = await resolveAndValidate('10.0.0.5', { allowPrivate: true });
            expect(result.ip).toBe('10.0.0.5');
        });

        it('STILL rejects cloud metadata even with allowPrivate=true', async () => {
            // allowPrivate cannot unlock the always-blocked set — protects
            // cloud-hosted deployments from one-typo IAM-credential leaks.
            await expect(
                resolveAndValidate('169.254.169.254', { allowPrivate: true })
            ).rejects.toBeInstanceOf(BlockedHostError);
        });

        it('STILL rejects IPv6 link-local even with allowPrivate=true', async () => {
            await expect(
                resolveAndValidate('fe80::1', { allowPrivate: true })
            ).rejects.toBeInstanceOf(BlockedHostError);
        });

        // AUDIT-2 #2 (2026-05-24): on multi-container hosts, ALLOW_PRIVATE_NETWORK
        // was unlocking same-host private networks (loopback, ULA) the operator
        // probably did not intend to reach. Need a second-tier override
        // (ALLOW_LOOPBACK_PROBES / opts.allowLoopback) before these unlock.
        it('rejects IPv4 loopback 127.0.0.1 with allowPrivate=true but allowLoopback=false', async () => {
            await expect(
                resolveAndValidate('127.0.0.1', { allowPrivate: true, allowLoopback: false })
            ).rejects.toBeInstanceOf(BlockedHostError);
        });

        it('rejects IPv6 loopback ::1 with allowPrivate=true but allowLoopback=false', async () => {
            await expect(
                resolveAndValidate('::1', { allowPrivate: true, allowLoopback: false })
            ).rejects.toBeInstanceOf(BlockedHostError);
        });

        it('rejects ULA fc00::/7 with allowPrivate=true but allowLoopback=false', async () => {
            await expect(
                resolveAndValidate('fc00::1', { allowPrivate: true, allowLoopback: false })
            ).rejects.toBeInstanceOf(BlockedHostError);
        });

        it('allows loopback with allowPrivate=true AND allowLoopback=true (explicit opt-in)', async () => {
            const result = await resolveAndValidate('127.0.0.1', { allowPrivate: true, allowLoopback: true });
            expect(result.ip).toBe('127.0.0.1');
        });

        it('rejects ULA with allowPrivate=false (also covered by private-range check)', async () => {
            await expect(
                resolveAndValidate('fc00::1')
            ).rejects.toBeInstanceOf(BlockedHostError);
        });
    });

    describe('hostnames (DNS resolution)', () => {
        it('passes when DNS returns only public IPv4', async () => {
            mockResolve4.mockResolvedValue(['8.8.8.8']);
            mockResolve6.mockRejectedValue(new Error('ENOTFOUND'));
            const result = await resolveAndValidate('dns.google');
            expect(result.ip).toBe('8.8.8.8');
            expect(result.family).toBe(4);
        });

        it('passes when only AAAA available (IPv6 fallback)', async () => {
            mockResolve4.mockRejectedValue(new Error('ENODATA'));
            mockResolve6.mockResolvedValue(['2606:4700:4700::1111']);
            const result = await resolveAndValidate('one.one.one.one');
            expect(result.ip).toBe('2606:4700:4700::1111');
            expect(result.family).toBe(6);
        });

        it('prefers IPv4 when both families resolve to public IPs', async () => {
            mockResolve4.mockResolvedValue(['8.8.8.8']);
            mockResolve6.mockResolvedValue(['2001:4860:4860::8888']);
            const result = await resolveAndValidate('dns.google');
            expect(result.ip).toBe('8.8.8.8');
        });

        it('rejects when DNS returns a private IPv4', async () => {
            mockResolve4.mockResolvedValue(['127.0.0.1']);
            mockResolve6.mockRejectedValue(new Error('ENODATA'));
            await expect(resolveAndValidate('attacker.example.com')).rejects.toBeInstanceOf(BlockedHostError);
        });

        it('rejects on mixed public + private response (DNS rebinding defence)', async () => {
            mockResolve4.mockResolvedValue(['8.8.8.8', '10.0.0.1']);
            mockResolve6.mockRejectedValue(new Error('ENODATA'));
            const err = await resolveAndValidate('mixed.example.com').catch(e => e);
            expect(err).toBeInstanceOf(BlockedHostError);
            expect(err.ip).toBe('10.0.0.1');
        });

        it('rejects when only AAAA resolves to a private IPv6', async () => {
            mockResolve4.mockRejectedValue(new Error('ENODATA'));
            mockResolve6.mockResolvedValue(['::1']);
            await expect(resolveAndValidate('lo.example.com')).rejects.toBeInstanceOf(BlockedHostError);
        });

        it('rejects when neither family resolves (unresolvable host)', async () => {
            mockResolve4.mockRejectedValue(new Error('ENOTFOUND'));
            mockResolve6.mockRejectedValue(new Error('ENOTFOUND'));
            await expect(resolveAndValidate('does-not-exist.invalid')).rejects.toBeInstanceOf(BlockedHostError);
        });

        it('bypasses the check when allowPrivate=true', async () => {
            mockResolve4.mockResolvedValue(['10.0.0.5']);
            mockResolve6.mockRejectedValue(new Error('ENODATA'));
            const result = await resolveAndValidate('internal.intranet', { allowPrivate: true });
            expect(result.ip).toBe('10.0.0.5');
        });

        it('STILL rejects DNS-resolved cloud metadata even with allowPrivate=true', async () => {
            mockResolve4.mockResolvedValue(['169.254.169.254']);
            mockResolve6.mockRejectedValue(new Error('ENODATA'));
            await expect(
                resolveAndValidate('aws-metadata.evil.example', { allowPrivate: true })
            ).rejects.toBeInstanceOf(BlockedHostError);
        });
    });

    describe('BlockedHostError shape', () => {
        it('carries hostname and offending ip', async () => {
            mockResolve4.mockResolvedValue(['10.0.0.5']);
            mockResolve6.mockRejectedValue(new Error('ENODATA'));
            const err = await resolveAndValidate('evil.example').catch(e => e);
            expect(err).toBeInstanceOf(BlockedHostError);
            expect(err.hostname).toBe('evil.example');
            expect(err.ip).toBe('10.0.0.5');
            expect(err.status).toBe(400);
            expect(err.message).toMatch(/Blocked/);
        });
    });
});
