import { describe, expect, it, vi } from "vitest";
import { assertWebhookUrlAllowed } from "../lib/ssrf-guard.js";

// Mock dns.lookup so tests do not require real DNS resolution.
vi.mock("node:dns/promises", () => ({
  lookup: vi.fn(async (hostname: string) => {
    const mapping: Record<string, string> = {
      "example.com": "93.184.216.34",
      "hooks.external.io": "52.1.2.3",
    };
    const ip = mapping[hostname];
    if (!ip) throw new Error(`ENOTFOUND ${hostname}`);
    return { address: ip, family: 4 };
  }),
}));

describe("assertWebhookUrlAllowed — SSRF guard", () => {
  // ── Allowed targets ─────────────────────────────────────────────────────────

  it("allows a valid HTTPS URL that resolves to a public IP", async () => {
    await expect(
      assertWebhookUrlAllowed("https://example.com/webhook"),
    ).resolves.toBeUndefined();
  });

  it("allows an HTTPS URL with a path and query string", async () => {
    await expect(
      assertWebhookUrlAllowed("https://hooks.external.io/events?source=capakraken"),
    ).resolves.toBeUndefined();
  });

  // ── Rejected schemes ─────────────────────────────────────────────────────────

  it("rejects an HTTP URL (only HTTPS allowed)", async () => {
    await expect(
      assertWebhookUrlAllowed("http://example.com/webhook"),
    ).rejects.toMatchObject({ code: "BAD_REQUEST" });
  });

  it("rejects an FTP URL", async () => {
    await expect(
      assertWebhookUrlAllowed("ftp://example.com/file"),
    ).rejects.toMatchObject({ code: "BAD_REQUEST" });
  });

  it("rejects a completely invalid URL", async () => {
    await expect(
      assertWebhookUrlAllowed("not-a-url"),
    ).rejects.toMatchObject({ code: "BAD_REQUEST" });
  });

  // ── Blocked hostnames ────────────────────────────────────────────────────────

  it("rejects localhost by hostname", async () => {
    await expect(
      assertWebhookUrlAllowed("https://localhost/callback"),
    ).rejects.toMatchObject({ code: "BAD_REQUEST" });
  });

  it("rejects the AWS cloud metadata endpoint by hostname", async () => {
    await expect(
      assertWebhookUrlAllowed("https://169.254.169.254/latest/meta-data"),
    ).rejects.toMatchObject({ code: "BAD_REQUEST" });
  });

  it("rejects Google cloud metadata by hostname", async () => {
    await expect(
      assertWebhookUrlAllowed("https://metadata.google.internal/computeMetadata/v1"),
    ).rejects.toMatchObject({ code: "BAD_REQUEST" });
  });

  // ── Blocked IP ranges (direct IP addresses as hostname) ─────────────────────

  it("rejects IPv4 loopback 127.0.0.1", async () => {
    await expect(
      assertWebhookUrlAllowed("https://127.0.0.1/callback"),
    ).rejects.toMatchObject({ code: "BAD_REQUEST" });
  });

  it("rejects IPv4 loopback 127.1.2.3 (full /8 block)", async () => {
    await expect(
      assertWebhookUrlAllowed("https://127.1.2.3/callback"),
    ).rejects.toMatchObject({ code: "BAD_REQUEST" });
  });

  it("rejects RFC 1918 private address 10.0.0.1", async () => {
    await expect(
      assertWebhookUrlAllowed("https://10.0.0.1/callback"),
    ).rejects.toMatchObject({ code: "BAD_REQUEST" });
  });

  it("rejects RFC 1918 private address 172.16.0.1", async () => {
    await expect(
      assertWebhookUrlAllowed("https://172.16.0.1/callback"),
    ).rejects.toMatchObject({ code: "BAD_REQUEST" });
  });

  it("rejects RFC 1918 private address 192.168.1.100", async () => {
    await expect(
      assertWebhookUrlAllowed("https://192.168.1.100/callback"),
    ).rejects.toMatchObject({ code: "BAD_REQUEST" });
  });

  it("rejects link-local address 169.254.1.1", async () => {
    await expect(
      assertWebhookUrlAllowed("https://169.254.1.1/callback"),
    ).rejects.toMatchObject({ code: "BAD_REQUEST" });
  });

  // ── DNS fail-closed behaviour ────────────────────────────────────────────────

  it("rejects a hostname that cannot be resolved (fail-closed)", async () => {
    // "unresolvable.internal" is not in the mock DNS table — lookup throws ENOTFOUND.
    await expect(
      assertWebhookUrlAllowed("https://unresolvable.internal/hook"),
    ).rejects.toMatchObject({ code: "BAD_REQUEST" });
  });

  // ── DNS-rebinding protection ──────────────────────────────────────────────────

  it("rejects a public hostname that resolves to a private IP (DNS rebinding)", async () => {
    const { lookup } = await import("node:dns/promises");
    vi.mocked(lookup).mockResolvedValueOnce({ address: "192.168.0.1", family: 4 });

    await expect(
      assertWebhookUrlAllowed("https://rebind.example.com/hook"),
    ).rejects.toMatchObject({ code: "BAD_REQUEST" });
  });
});
