import { describe, expect, it } from 'vitest';
import { canonicalizeHostname, isPrivateAddress } from '../../security/ip-canon.js';
import { resolveAndCheck, validateWebhookUrl } from '../../security/webhook.js';
describe('canonicalizeHostname — IPv4 encodings', () => {
    it.each([
        ['127.0.0.1', '127.0.0.1'],
        ['10.0.0.1', '10.0.0.1'],
        ['192.168.1.1', '192.168.1.1'],
        // Decimal: 127*2^24 = 2130706432 → 127.0.0.0; +1 = 127.0.0.1
        ['2130706433', '127.0.0.1'],
        // Octal (0177 = 127, 0 = 0, 0 = 0, 01 = 1)
        ['0177.0.0.1', '127.0.0.1'],
        ['0177.0.0.01', '127.0.0.1'],
        // Hex
        ['0x7f.0.0.1', '127.0.0.1'],
        ['0x7f000001', '127.0.0.1'],
        // Short forms
        ['127.1', '127.0.0.1'],
        ['10.1', '10.0.0.1'],
        ['127.0.1', '127.0.0.1'],
    ])('canonicalizes %s → %s', (input, expected) => {
        const c = canonicalizeHostname(input);
        expect(c.isHostname).toBe(false);
        expect(c.ipv4).toBe(expected);
    });
    it('rejects malformed IPv4 (out-of-range octet)', () => {
        const c = canonicalizeHostname('256.0.0.1');
        expect(c.isHostname).toBe(true); // falls through to "treat as hostname"
    });
    it('rejects malformed octal (digit 8 is invalid)', () => {
        const c = canonicalizeHostname('0888.0.0.1');
        expect(c.isHostname).toBe(true);
    });
});
describe('canonicalizeHostname — IPv6 encodings', () => {
    it.each([
        ['::1', '::ffff:127.0.0.1 OR plain'], // checked separately below
    ])('detects IPv6 forms', () => {
        expect(canonicalizeHostname('::1').isHostname).toBe(false);
    });
    it('canonicalizes ::ffff:127.0.0.1 to underlying IPv4', () => {
        const c = canonicalizeHostname('::ffff:127.0.0.1');
        expect(c.ipv4).toBe('127.0.0.1');
    });
    it('canonicalizes ::ffff:7f00:1 (hex form) to underlying IPv4', () => {
        const c = canonicalizeHostname('::ffff:7f00:1');
        expect(c.ipv4).toBe('127.0.0.1');
    });
    it('canonicalizes ::127.0.0.1 (deprecated IPv4-compatible) to underlying IPv4', () => {
        const c = canonicalizeHostname('::127.0.0.1');
        expect(c.ipv4).toBe('127.0.0.1');
    });
    it('handles bracketed IPv6 (URL form)', () => {
        const c = canonicalizeHostname('[::1]');
        expect(c.ipv6).toBeDefined();
    });
    it('rejects IPv6 with multiple :: compressions', () => {
        const c = canonicalizeHostname('::1::1');
        expect(c.isHostname).toBe(false);
        expect(c.isValidIp).toBe(false); // <- Bugbot #3: explicit malformed flag
        expect(c.ipv6).toBeUndefined();
        expect(c.ipv4).toBeUndefined();
    });
});
describe('canonicalizeHostname — malformed IP literals', () => {
    // Bugbot review #1/#2/#3 (PR #40): every input that looks like an IP
    // literal but doesn't parse must be flagged with `isValidIp: false` so
    // SSRF gates can fail closed instead of misreading "no IP returned" as
    // "no private IP found".
    it.each([
        '::1::1', // multiple :: compressions
        '::garbage', // non-hex hextet
        'fe80:::1', // triple colon
        '::1:gg::', // bad chars + multiple ::
        '1:2:3:4:5:6:7:8:9', // too many groups
        'abcd::1234::5678', // multiple ::
    ])('marks %s as a non-hostname AND non-valid IP', (input) => {
        const c = canonicalizeHostname(input);
        expect(c.isHostname).toBe(false);
        expect(c.isValidIp).toBe(false);
        expect(c.ipv4).toBeUndefined();
        expect(c.ipv6).toBeUndefined();
    });
    it('isPrivateAddress fails closed on the malformed shape', () => {
        // Defense in depth: even if a caller forgets to check `isValidIp`,
        // calling `isPrivateAddress` on a malformed shape returns a non-null
        // reason instead of `null` (which would mean "safe to fetch").
        const c = canonicalizeHostname('::1::1');
        const result = isPrivateAddress(c);
        expect(result).not.toBeNull();
        expect(result?.reason).toMatch(/malformed/i);
    });
});
describe('canonicalizeHostname — DNS hostnames pass through', () => {
    it.each(['example.com', 'sub.example.com', 'localhost-but-not-quite.com', 'a-b-c.example'])('leaves %s as a hostname', (input) => {
        const c = canonicalizeHostname(input);
        expect(c.isHostname).toBe(true);
        expect(c.ipv4).toBeUndefined();
        expect(c.ipv6).toBeUndefined();
    });
});
describe('isPrivateAddress', () => {
    it.each([
        '127.0.0.1',
        '10.0.0.1',
        '172.16.0.1',
        '172.31.255.254',
        '192.168.1.1',
        '169.254.169.254', // AWS metadata
        '100.64.0.1', // CGNAT
        '0.0.0.0',
        '198.18.0.1', // benchmark range
        '224.0.0.1', // multicast
        '255.255.255.255', // broadcast
    ])('flags %s as private', (ip) => {
        const c = canonicalizeHostname(ip);
        expect(isPrivateAddress(c)).not.toBeNull();
    });
    it.each(['8.8.8.8', '1.1.1.1', '93.184.216.34', '203.0.0.1'])('allows public IP %s', (ip) => {
        const c = canonicalizeHostname(ip);
        expect(isPrivateAddress(c)).toBeNull();
    });
    it.each([
        ['::1', '::1 (IPv6 loopback)'],
        ['::', ':: (unspecified)'],
        ['fe80::1', 'fe80::/10 (IPv6 link-local)'],
        ['fc00::1', 'fc00::/7 (IPv6 unique local)'],
        ['fd12::1', 'fc00::/7 (IPv6 unique local)'],
        ['ff02::1', 'ff00::/8 (IPv6 multicast)'],
    ])('flags IPv6 %s as private', (ip) => {
        const c = canonicalizeHostname(ip);
        expect(isPrivateAddress(c)).not.toBeNull();
    });
});
describe('validateWebhookUrl — bypass forms', () => {
    // These are the bypass forms the prior regex-based implementation missed.
    // Each one would have happily POSTed to localhost / metadata IP / RFC1918.
    it.each([
        ['decimal IP', 'http://2130706433/'],
        ['octal IP', 'http://0177.0.0.1/'],
        ['hex IP', 'http://0x7f.0.0.1/'],
        ['mixed hex flat', 'http://0x7f000001/'],
        ['short form', 'http://127.1/'],
        ['IPv4-mapped IPv6', 'http://[::ffff:127.0.0.1]/'],
        ['IPv4-compatible IPv6 (deprecated)', 'http://[::127.0.0.1]/'],
        ['bracketed IPv6 loopback', 'http://[::1]/'],
        ['IPv6 link-local', 'http://[fe80::1]/'],
        ['IPv6 unique-local fd00', 'http://[fd00::1]/'],
        ['CGNAT range', 'http://100.64.0.1/'],
        ['AWS metadata', 'http://169.254.169.254/latest/meta-data/'],
        ['benchmark range', 'http://198.18.0.1/'],
        ['localhost hostname', 'http://localhost/'],
        ['metadata.google.internal', 'http://metadata.google.internal/'],
        ['arbitrary .internal hostname', 'http://my-service.internal/'],
    ])('rejects %s (%s)', (_label, url) => {
        const result = validateWebhookUrl(url);
        expect(result.valid).toBe(false);
        expect(result.error).toBeDefined();
    });
    it.each([
        ['regular https', 'https://example.com/webhook'],
        ['public IP literal', 'http://8.8.8.8/'],
        ['hostname with port', 'https://api.example.com:8443/hooks/123'],
    ])('allows %s (%s)', (_label, url) => {
        const result = validateWebhookUrl(url);
        expect(result.valid).toBe(true);
    });
    it('rejects non-http(s) protocols', () => {
        expect(validateWebhookUrl('file:///etc/passwd').valid).toBe(false);
        expect(validateWebhookUrl('gopher://example.com/').valid).toBe(false);
        expect(validateWebhookUrl('ftp://example.com/').valid).toBe(false);
        expect(validateWebhookUrl('javascript:alert(1)').valid).toBe(false);
    });
    it('rejects malformed URLs', () => {
        expect(validateWebhookUrl('not a url').valid).toBe(false);
        expect(validateWebhookUrl('').valid).toBe(false);
    });
    // Bugbot review #1 (PR #40): malformed IP-shaped hostnames previously
    // slipped through because canonicalizeHostname returned `{isHostname:
    // false}` with no IP fields, and isPrivateAddress returned null —
    // which the validator interpreted as "safe". They must fail closed.
    it.each([
        'http://[::1::1]/', // multi :: IPv6
        'http://[fe80:::1]/', // triple colon
        'http://[abcd::1234::5678]/', // multi ::
    ])('rejects malformed IP literal in URL: %s', (url) => {
        const result = validateWebhookUrl(url);
        expect(result.valid).toBe(false);
        expect(result.error).toMatch(/malformed|invalid/i);
    });
});
describe('resolveAndCheck — IP literal short-circuit', () => {
    it('rejects malformed IPv6 literal without DNS lookup', async () => {
        // Bugbot review #2 (PR #40): the IP-literal short-circuit path also
        // had the malformed-IP fail-open bug. Verify it now fails closed.
        const result = await resolveAndCheck('::1::1');
        expect(result.safe).toBe(false);
        expect(result.error).toMatch(/malformed/i);
    });
    it('still allows public IP literals', async () => {
        const result = await resolveAndCheck('8.8.8.8');
        expect(result.safe).toBe(true);
    });
    it('still rejects private IP literals', async () => {
        const result = await resolveAndCheck('127.0.0.1');
        expect(result.safe).toBe(false);
    });
});
//# sourceMappingURL=ssrf.test.js.map