import { afterEach, describe, expect, test } from "bun:test";
import { isPrivateAddress, safeFetch, sanitizeUrl } from "../src/index.ts";

const realFetch = globalThis.fetch;
afterEach(() => {
  globalThis.fetch = realFetch;
});

/** Capture what each hop actually receives, and script the redirect chain. */
function stubFetch(chain: (string | null)[]) {
  const seen: { url: string; host: string | null; authorization: string | null }[] = [];
  let hop = 0;
  globalThis.fetch = (async (input: unknown, init?: RequestInit) => {
    const headers = new Headers(init?.headers);
    seen.push({
      url: String(input),
      host: headers.get("host"),
      authorization: headers.get("authorization"),
    });
    const location = chain[hop++];
    return location
      ? new Response(null, { status: 302, headers: { location } })
      : new Response("done", { status: 200 });
  }) as typeof fetch;
  return seen;
}

describe("safeFetch redirect credential handling", () => {
  test("does not re-attach credentials after leaving the original origin", async () => {
    const seen = stubFetch(["https://b.example/x", "https://b.example/final", null]);

    await safeFetch("https://a.example/start", {
      headers: { authorization: "Bearer SECRET", cookie: "sid=1" },
      resolver: async () => ["93.184.216.34"],
    });

    expect(seen).toHaveLength(3);
    expect(seen[0]!.authorization).toBe("Bearer SECRET");
    // Both hops on b.example are off-origin, including the b→b one.
    expect(seen[1]!.authorization).toBeNull();
    expect(seen[2]!.authorization).toBeNull();
  });

  test("keeps credentials across same-origin redirects", async () => {
    const seen = stubFetch(["https://a.example/next", null]);

    await safeFetch("https://a.example/start", {
      headers: { authorization: "Bearer SECRET" },
      resolver: async () => ["93.184.216.34"],
    });

    expect(seen[1]!.authorization).toBe("Bearer SECRET");
  });

  test("forwardSensitiveHeaders opts back in", async () => {
    const seen = stubFetch(["https://b.example/x", null]);

    await safeFetch("https://a.example/start", {
      headers: { authorization: "Bearer SECRET" },
      forwardSensitiveHeaders: true,
      resolver: async () => ["93.184.216.34"],
    });

    expect(seen[1]!.authorization).toBe("Bearer SECRET");
  });
});

describe("safeFetch DNS pinning", () => {
  test("connects to the validated address and preserves the Host header", async () => {
    const seen = stubFetch([null]);

    await safeFetch("https://rebind.example/data", {
      resolver: async () => ["93.184.216.34"],
    });

    // The connection targets the address we checked, so a second DNS answer
    // cannot redirect it at an internal host.
    expect(seen[0]!.url).toBe("https://93.184.216.34/data");
    expect(seen[0]!.host).toBe("rebind.example");
  });

  test("pinDns:false restores hostname dialling", async () => {
    const seen = stubFetch([null]);

    await safeFetch("https://rebind.example/data", {
      pinDns: false,
      resolver: async () => ["93.184.216.34"],
    });

    expect(seen[0]!.url).toBe("https://rebind.example/data");
  });

  test("still rejects hosts that resolve into private space", async () => {
    stubFetch([null]);

    await expect(
      safeFetch("https://rebind.example/data", { resolver: async () => ["169.254.169.254"] }),
    ).rejects.toThrow(/blocked address/);
  });
});

describe("isPrivateAddress coverage", () => {
  test("catches non-canonical IPv4-mapped and site-local IPv6", () => {
    expect(isPrivateAddress("0:0:0:0:0:ffff:127.0.0.1")).toBe(true);
    expect(isPrivateAddress("::ffff:7f00:1")).toBe(true);
    expect(isPrivateAddress("fec0::1")).toBe(true);
    expect(isPrivateAddress("0:0:0:0:0:0:0:1")).toBe(true);
    expect(isPrivateAddress("198.18.0.1")).toBe(true);
    expect(isPrivateAddress("192.0.0.192")).toBe(true);
  });

  test("leaves public addresses alone", () => {
    expect(isPrivateAddress("93.184.216.34")).toBe(false);
    expect(isPrivateAddress("2606:2800:220:1:248:1893:25c8:1946")).toBe(false);
  });
});

describe("sanitizeUrl protocol-relative handling", () => {
  test("does not hand back a protocol-relative URL verbatim", () => {
    // "//evil.com" in an href navigates cross-origin; returning it unchanged
    // would bypass every host check validateUrl just performed.
    expect(sanitizeUrl("//evil.com")).toBe("http://evil.com/");
    expect(sanitizeUrl("//evil.com/path?a=b")).toBe("http://evil.com/path?a=b");
    // Backslash spellings resolve the same way rather than passing through.
    expect(sanitizeUrl("\\\\evil.com")).toBe("http://evil.com/");
    expect(sanitizeUrl("/\\evil.com")).toBe("http://evil.com/");
  });

  test("honours host policy for protocol-relative input", () => {
    expect(sanitizeUrl("//evil.com", { allowedHosts: ["good.com"] })).toBe("about:blank");
    expect(sanitizeUrl("//good.com/x", { allowedHosts: ["good.com"] })).toBe("http://good.com/x");
  });

  test("genuine relative paths round-trip unchanged", () => {
    expect(sanitizeUrl("/safe/path")).toBe("/safe/path");
    expect(sanitizeUrl("./rel")).toBe("./rel");
    expect(sanitizeUrl("?q=1")).toBe("?q=1");
    expect(sanitizeUrl("#frag")).toBe("#frag");
  });
});
