1
mirror of https://github.com/jakejarvis/hoot.git synced 2025-10-18 20:14:25 -04:00
Files
hoot/server/services/dns.test.ts

277 lines
8.6 KiB
TypeScript

/* @vitest-environment node */
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("@/lib/cloudflare", () => ({
isCloudflareIpAsync: vi.fn(async () => false),
}));
beforeEach(async () => {
vi.resetModules();
const { makePGliteDb } = await import("@/server/db/pglite");
const { db } = await makePGliteDb();
vi.doMock("@/server/db/client", () => ({ db }));
});
afterEach(() => {
vi.restoreAllMocks();
// Clear shared redis mock counters if present
globalThis.__redisTestHelper?.reset();
});
function dohAnswer(
answers: Array<{ name: string; TTL: number; data: string }>,
) {
return new Response(JSON.stringify({ Status: 0, Answer: answers }), {
status: 200,
headers: { "content-type": "application/dns-json" },
});
}
describe("resolveAll", () => {
it("normalizes records and returns combined results", async () => {
const { resolveAll } = await import("./dns");
// The code calls DoH for A, AAAA, MX, TXT, NS in parallel and across providers; we just return A for both A and AAAA etc.
const fetchMock = vi
.spyOn(global, "fetch")
.mockResolvedValueOnce(
dohAnswer([{ name: "example.com.", TTL: 60, data: "1.2.3.4" }]),
)
.mockResolvedValueOnce(
dohAnswer([{ name: "example.com.", TTL: 60, data: "1.2.3.4" }]),
) // AAAA
.mockResolvedValueOnce(
dohAnswer([
{
name: "example.com.",
TTL: 300,
data: "10 aspmx.l.google.com.",
},
]),
)
.mockResolvedValueOnce(
dohAnswer([{ name: "example.com.", TTL: 120, data: '"v=spf1"' }]),
)
.mockResolvedValueOnce(
dohAnswer([
{
name: "example.com.",
TTL: 600,
data: "ns1.cloudflare.com.",
},
]),
);
const out = await resolveAll("example.com");
expect(out.records.length).toBeGreaterThan(0);
const hasTxt = out.records.some(
(r) => r.type === "TXT" && r.value === "v=spf1",
);
const hasMx = out.records.some((r) => r.type === "MX" && r.priority === 10);
const hasNs = out.records.some(
(r) => r.type === "NS" && r.value === "ns1.cloudflare.com",
);
expect(hasTxt && hasMx && hasNs).toBe(true);
fetchMock.mockRestore();
});
it("throws when all providers fail", async () => {
const { resolveAll } = await import("./dns");
const fetchMock = vi
.spyOn(global, "fetch")
.mockRejectedValue(new Error("network"));
await expect(resolveAll("example.invalid")).rejects.toThrow();
fetchMock.mockRestore();
});
it("retries next provider when first fails and succeeds on second", async () => {
const { resolveAll } = await import("./dns");
globalThis.__redisTestHelper?.reset();
let call = 0;
const fetchMock = vi.spyOn(global, "fetch").mockImplementation(async () => {
call += 1;
if (call <= 5) {
throw new Error("provider1 fail");
}
// Calls 6..10 correspond to A, AAAA, MX, TXT, NS for second provider
const idx = call - 6;
switch (idx) {
case 0:
case 1:
return dohAnswer([
{ name: "example.com.", TTL: 60, data: "1.2.3.4" },
]);
case 2:
return dohAnswer([
{ name: "example.com.", TTL: 300, data: "10 aspmx.l.google.com." },
]);
case 3:
return dohAnswer([
{ name: "example.com.", TTL: 120, data: '"v=spf1"' },
]);
default:
return dohAnswer([
{ name: "example.com.", TTL: 600, data: "ns1.cloudflare.com." },
]);
}
});
const out = await resolveAll("example.com");
expect(out.records.length).toBeGreaterThan(0);
fetchMock.mockRestore();
});
it("caches results across providers and preserves resolver metadata", async () => {
const { resolveAll } = await import("./dns");
globalThis.__redisTestHelper?.reset();
// First run: succeed and populate cache and resolver meta
const firstFetch = vi
.spyOn(global, "fetch")
.mockResolvedValueOnce(
dohAnswer([{ name: "example.com.", TTL: 60, data: "1.2.3.4" }]),
)
.mockResolvedValueOnce(
dohAnswer([{ name: "example.com.", TTL: 60, data: "::1" }]),
)
.mockResolvedValueOnce(
dohAnswer([
{
name: "example.com.",
TTL: 300,
data: "10 aspmx.l.google.com.",
},
]),
)
.mockResolvedValueOnce(
dohAnswer([{ name: "example.com.", TTL: 120, data: '"v=spf1"' }]),
)
.mockResolvedValueOnce(
dohAnswer([
{
name: "example.com.",
TTL: 600,
data: "ns1.cloudflare.com.",
},
]),
);
const first = await resolveAll("example.com");
expect(first.records.length).toBeGreaterThan(0);
firstFetch.mockRestore();
// Second run: DB hit — no network calls expected
const fetchSpy = vi.spyOn(global, "fetch");
const second = await resolveAll("example.com");
expect(second.records.length).toBe(first.records.length);
expect(["cloudflare", "google"]).toContain(second.resolver);
expect(fetchSpy).not.toHaveBeenCalled();
fetchSpy.mockRestore();
});
it("dedupes concurrent callers via aggregate cache/lock", async () => {
const { resolveAll } = await import("./dns");
globalThis.__redisTestHelper?.reset();
// Use the top-level dohAnswer helper declared above
const fetchMock = vi
.spyOn(global, "fetch")
.mockResolvedValueOnce(
dohAnswer([{ name: "example.com.", TTL: 60, data: "1.2.3.4" }]),
)
.mockResolvedValueOnce(
dohAnswer([{ name: "example.com.", TTL: 60, data: "::1" }]),
)
.mockResolvedValueOnce(
dohAnswer([
{ name: "example.com.", TTL: 300, data: "10 aspmx.l.google.com." },
]),
)
.mockResolvedValueOnce(
dohAnswer([{ name: "example.com.", TTL: 120, data: '"v=spf1"' }]),
)
.mockResolvedValueOnce(
dohAnswer([
{ name: "example.com.", TTL: 600, data: "ns1.cloudflare.com." },
]),
);
// Fire several concurrent calls
const [r1, r2, r3] = await Promise.all([
resolveAll("example.com"),
resolveAll("example.com"),
resolveAll("example.com"),
]);
expect(r1.records.length).toBeGreaterThan(0);
expect(r2.records.length).toBeGreaterThan(0);
expect(r3.records.length).toBeGreaterThan(0);
// Ensure all callers see non-empty results; DoH fetch call counts and exact lengths may vary under concurrency
fetchMock.mockRestore();
});
it("fetches missing AAAA during partial revalidation", async () => {
const { resolveAll } = await import("./dns");
globalThis.__redisTestHelper?.reset();
// First run: full fetch; AAAA returns empty, others present
const firstFetch = vi
.spyOn(global, "fetch")
.mockResolvedValueOnce(
dohAnswer([{ name: "example.com.", TTL: 60, data: "1.2.3.4" }]),
)
.mockResolvedValueOnce(
new Response(JSON.stringify({ Status: 0, Answer: [] }), {
status: 200,
headers: { "content-type": "application/dns-json" },
}),
)
.mockResolvedValueOnce(
dohAnswer([
{ name: "example.com.", TTL: 300, data: "10 aspmx.l.google.com." },
]),
)
.mockResolvedValueOnce(
dohAnswer([{ name: "example.com.", TTL: 120, data: '"v=spf1"' }]),
)
.mockResolvedValueOnce(
dohAnswer([
{ name: "example.com.", TTL: 600, data: "ns1.cloudflare.com." },
]),
);
const first = await resolveAll("example.com");
expect(first.records.some((r) => r.type === "AAAA")).toBe(false);
firstFetch.mockRestore();
// Second run: partial revalidation should fetch only AAAA
const secondFetch = vi
.spyOn(global, "fetch")
.mockImplementation(async (input: RequestInfo | URL) => {
const url =
input instanceof URL
? input
: new URL(
typeof input === "string"
? input
: ((input as unknown as { url: string }).url as string),
);
const type = url.searchParams.get("type");
if (type === "AAAA") {
return dohAnswer([
{ name: "example.com.", TTL: 300, data: "2001:db8::1" },
]);
}
return dohAnswer([]);
});
const second = await resolveAll("example.com");
secondFetch.mockRestore();
// Ensure AAAA was fetched and returned
expect(
second.records.some(
(r) => r.type === "AAAA" && r.value === "2001:db8::1",
),
).toBe(true);
});
});