mirror of
https://github.com/jakejarvis/hoot.git
synced 2025-10-18 20:14:25 -04:00
Fix unknown providers after first load by creating new provider IDs when discovered
This commit is contained in:
@@ -44,3 +44,34 @@ export async function resolveProviderId(
|
|||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Resolve a provider id, creating a provider row when not found. */
|
||||||
|
export async function resolveOrCreateProviderId(
|
||||||
|
input: ResolveProviderInput,
|
||||||
|
): Promise<string | null> {
|
||||||
|
const existing = await resolveProviderId(input);
|
||||||
|
if (existing) return existing;
|
||||||
|
const name = input.name?.trim();
|
||||||
|
if (!name) return null;
|
||||||
|
const domain = input.domain?.toLowerCase() ?? null;
|
||||||
|
// Use a simple slug derived from name for uniqueness within category
|
||||||
|
const slug = name
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9]+/g, "-")
|
||||||
|
.replace(/(^-|-$)+/g, "");
|
||||||
|
try {
|
||||||
|
const inserted = await db
|
||||||
|
.insert(providers)
|
||||||
|
.values({
|
||||||
|
category: input.category,
|
||||||
|
name,
|
||||||
|
domain: domain ?? undefined,
|
||||||
|
slug,
|
||||||
|
})
|
||||||
|
.returning({ id: providers.id });
|
||||||
|
return inserted[0]?.id ?? null;
|
||||||
|
} catch {
|
||||||
|
// Possible race with another insert; try resolve again
|
||||||
|
return resolveProviderId(input);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@@ -101,9 +101,11 @@ describe("getCertificates", () => {
|
|||||||
const out = await getCertificates("example.com");
|
const out = await getCertificates("example.com");
|
||||||
expect(out.length).toBeGreaterThan(0);
|
expect(out.length).toBeGreaterThan(0);
|
||||||
|
|
||||||
// Verify DB persistence
|
// Verify DB persistence and CA provider creation
|
||||||
const { db } = await import("@/server/db/client");
|
const { db } = await import("@/server/db/client");
|
||||||
const { certificates, domains } = await import("@/server/db/schema");
|
const { certificates, domains, providers } = await import(
|
||||||
|
"@/server/db/schema"
|
||||||
|
);
|
||||||
const { eq } = await import("drizzle-orm");
|
const { eq } = await import("drizzle-orm");
|
||||||
const d = await db
|
const d = await db
|
||||||
.select({ id: domains.id })
|
.select({ id: domains.id })
|
||||||
@@ -116,6 +118,14 @@ describe("getCertificates", () => {
|
|||||||
.where(eq(certificates.domainId, d[0].id));
|
.where(eq(certificates.domainId, d[0].id));
|
||||||
expect(rows.length).toBeGreaterThan(0);
|
expect(rows.length).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
// Ensure a CA provider row exists for the issuer
|
||||||
|
const ca = await db
|
||||||
|
.select()
|
||||||
|
.from(providers)
|
||||||
|
.where(eq(providers.name, "Let's Encrypt"))
|
||||||
|
.limit(1);
|
||||||
|
expect(ca.length).toBe(1);
|
||||||
|
|
||||||
// Next call should use DB fast-path: no TLS listener invocation
|
// Next call should use DB fast-path: no TLS listener invocation
|
||||||
const prevCalls = (tlsMock.socketMock.getPeerCertificate as unknown as Mock)
|
const prevCalls = (tlsMock.socketMock.getPeerCertificate as unknown as Mock)
|
||||||
.mock.calls.length;
|
.mock.calls.length;
|
||||||
|
@@ -10,7 +10,7 @@ import { certificates as certTable } from "@/server/db/schema";
|
|||||||
import { ttlForCertificates } from "@/server/db/ttl";
|
import { ttlForCertificates } from "@/server/db/ttl";
|
||||||
import { replaceCertificates } from "@/server/repos/certificates";
|
import { replaceCertificates } from "@/server/repos/certificates";
|
||||||
import { upsertDomain } from "@/server/repos/domains";
|
import { upsertDomain } from "@/server/repos/domains";
|
||||||
import { resolveProviderId } from "@/server/repos/providers";
|
import { resolveOrCreateProviderId } from "@/server/repos/providers";
|
||||||
|
|
||||||
export async function getCertificates(domain: string): Promise<Certificate[]> {
|
export async function getCertificates(domain: string): Promise<Certificate[]> {
|
||||||
console.debug("[certificates] start", { domain });
|
console.debug("[certificates] start", { domain });
|
||||||
@@ -132,7 +132,7 @@ export async function getCertificates(domain: string): Promise<Certificate[]> {
|
|||||||
if (d) {
|
if (d) {
|
||||||
const chainWithIds = await Promise.all(
|
const chainWithIds = await Promise.all(
|
||||||
out.map(async (c) => {
|
out.map(async (c) => {
|
||||||
const caProviderId = await resolveProviderId({
|
const caProviderId = await resolveOrCreateProviderId({
|
||||||
category: "ca",
|
category: "ca",
|
||||||
domain: c.caProvider.domain,
|
domain: c.caProvider.domain,
|
||||||
name: c.caProvider.name,
|
name: c.caProvider.name,
|
||||||
|
@@ -26,6 +26,22 @@ vi.mock("@/server/services/ip", () => ({
|
|||||||
})),
|
})),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
// Ensure toRegistrableDomain accepts our test domains (including .example)
|
||||||
|
vi.mock("@/lib/domain-server", async (importOriginal) => {
|
||||||
|
const actual = await importOriginal<typeof import("@/lib/domain-server")>();
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
toRegistrableDomain: (input: string) => {
|
||||||
|
const v = (input ?? "").trim().toLowerCase().replace(/\.$/, "");
|
||||||
|
if (!v) return null;
|
||||||
|
const parts = v.split(".").filter(Boolean);
|
||||||
|
if (parts.length >= 2)
|
||||||
|
return `${parts[parts.length - 2]}.${parts[parts.length - 1]}`;
|
||||||
|
return v;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
vi.resetModules();
|
vi.resetModules();
|
||||||
const { makePGliteDb } = await import("@/server/db/pglite");
|
const { makePGliteDb } = await import("@/server/db/pglite");
|
||||||
@@ -179,4 +195,71 @@ describe("detectHosting", () => {
|
|||||||
expect(result.emailProvider.domain).toBe("example.com");
|
expect(result.emailProvider.domain).toBe("example.com");
|
||||||
expect(result.dnsProvider.domain).toBe("example.net");
|
expect(result.dnsProvider.domain).toBe("example.net");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("creates provider rows for DNS and Email when missing and links them", async () => {
|
||||||
|
const { resolveAll } = await import("@/server/services/dns");
|
||||||
|
const { probeHeaders } = await import("@/server/services/headers");
|
||||||
|
const { detectHosting } = await import("@/server/services/hosting");
|
||||||
|
|
||||||
|
(resolveAll as unknown as Mock).mockResolvedValue({
|
||||||
|
records: [
|
||||||
|
{ type: "A", name: "example.com", value: "1.2.3.4", ttl: 60 },
|
||||||
|
{
|
||||||
|
type: "MX",
|
||||||
|
name: "example.com",
|
||||||
|
value: "aspmx.l.google.com",
|
||||||
|
ttl: 300,
|
||||||
|
priority: 10,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "NS",
|
||||||
|
name: "example.com",
|
||||||
|
value: "ns1.cloudflare.com",
|
||||||
|
ttl: 600,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
source: "mock",
|
||||||
|
});
|
||||||
|
(probeHeaders as unknown as Mock).mockResolvedValue([]);
|
||||||
|
|
||||||
|
await detectHosting("provider-create.example");
|
||||||
|
|
||||||
|
const { db } = await import("@/server/db/client");
|
||||||
|
const { domains, hosting, providers } = await import("@/server/db/schema");
|
||||||
|
const { eq } = await import("drizzle-orm");
|
||||||
|
const d = await db
|
||||||
|
.select({ id: domains.id })
|
||||||
|
.from(domains)
|
||||||
|
.where(eq(domains.name, "provider-create.example"))
|
||||||
|
.limit(1);
|
||||||
|
const row = (
|
||||||
|
await db
|
||||||
|
.select({
|
||||||
|
emailProviderId: hosting.emailProviderId,
|
||||||
|
dnsProviderId: hosting.dnsProviderId,
|
||||||
|
})
|
||||||
|
.from(hosting)
|
||||||
|
.where(eq(hosting.domainId, d[0].id))
|
||||||
|
.limit(1)
|
||||||
|
)[0];
|
||||||
|
expect(row.emailProviderId).toBeTruthy();
|
||||||
|
expect(row.dnsProviderId).toBeTruthy();
|
||||||
|
|
||||||
|
const email = (
|
||||||
|
await db
|
||||||
|
.select({ name: providers.name })
|
||||||
|
.from(providers)
|
||||||
|
.where(eq(providers.id, row.emailProviderId as string))
|
||||||
|
.limit(1)
|
||||||
|
)[0];
|
||||||
|
const dns = (
|
||||||
|
await db
|
||||||
|
.select({ name: providers.name })
|
||||||
|
.from(providers)
|
||||||
|
.where(eq(providers.id, row.dnsProviderId as string))
|
||||||
|
.limit(1)
|
||||||
|
)[0];
|
||||||
|
expect(email?.name).toMatch(/google/i);
|
||||||
|
expect(dns?.name).toMatch(/cloudflare/i);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
@@ -17,7 +17,7 @@ import {
|
|||||||
import { ttlForHosting } from "@/server/db/ttl";
|
import { ttlForHosting } from "@/server/db/ttl";
|
||||||
import { upsertDomain } from "@/server/repos/domains";
|
import { upsertDomain } from "@/server/repos/domains";
|
||||||
import { upsertHosting } from "@/server/repos/hosting";
|
import { upsertHosting } from "@/server/repos/hosting";
|
||||||
import { resolveProviderId } from "@/server/repos/providers";
|
import { resolveOrCreateProviderId } from "@/server/repos/providers";
|
||||||
import { resolveAll } from "@/server/services/dns";
|
import { resolveAll } from "@/server/services/dns";
|
||||||
import { probeHeaders } from "@/server/services/headers";
|
import { probeHeaders } from "@/server/services/headers";
|
||||||
import { lookupIpMeta } from "@/server/services/ip";
|
import { lookupIpMeta } from "@/server/services/ip";
|
||||||
@@ -157,10 +157,10 @@ export async function detectHosting(domain: string): Promise<Hosting> {
|
|||||||
// Hosting provider detection with fallback:
|
// Hosting provider detection with fallback:
|
||||||
// - If no A record/IP → unset → "Not configured"
|
// - If no A record/IP → unset → "Not configured"
|
||||||
// - Else if unknown → try IP ownership org/ISP
|
// - Else if unknown → try IP ownership org/ISP
|
||||||
const hosting = detectHostingProvider(headers);
|
const hostingDetected = detectHostingProvider(headers);
|
||||||
|
|
||||||
let hostingName = hosting.name;
|
let hostingName = hostingDetected.name;
|
||||||
let hostingIconDomain = hosting.domain;
|
let hostingIconDomain = hostingDetected.domain;
|
||||||
if (!ip) {
|
if (!ip) {
|
||||||
hostingName = "Not configured";
|
hostingName = "Not configured";
|
||||||
hostingIconDomain = null;
|
hostingIconDomain = null;
|
||||||
@@ -170,17 +170,17 @@ export async function detectHosting(domain: string): Promise<Hosting> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Determine email provider, using "Not configured" when MX is unset
|
// Determine email provider, using "Not configured" when MX is unset
|
||||||
const email =
|
const emailDetected =
|
||||||
mx.length === 0
|
mx.length === 0
|
||||||
? { name: "Not configured", domain: null }
|
? { name: "Not configured", domain: null }
|
||||||
: detectEmailProvider(mx.map((m) => m.value));
|
: detectEmailProvider(mx.map((m) => m.value));
|
||||||
let emailName = email.name;
|
let emailName = emailDetected.name;
|
||||||
let emailIconDomain = email.domain;
|
let emailIconDomain = emailDetected.domain;
|
||||||
|
|
||||||
// DNS provider from nameservers
|
// DNS provider from nameservers
|
||||||
const dnsResult = detectDnsProvider(nsRecords.map((n) => n.value));
|
const dnsDetected = detectDnsProvider(nsRecords.map((n) => n.value));
|
||||||
let dnsName = dnsResult.name;
|
let dnsName = dnsDetected.name;
|
||||||
let dnsIconDomain = dnsResult.domain;
|
let dnsIconDomain = dnsDetected.domain;
|
||||||
|
|
||||||
// If no known match for email provider, fall back to the root domain of the first MX host
|
// If no known match for email provider, fall back to the root domain of the first MX host
|
||||||
if (emailName !== "Not configured" && !emailIconDomain && mx[0]?.value) {
|
if (emailName !== "Not configured" && !emailIconDomain && mx[0]?.value) {
|
||||||
@@ -220,17 +220,17 @@ export async function detectHosting(domain: string): Promise<Hosting> {
|
|||||||
if (d) {
|
if (d) {
|
||||||
const [hostingProviderId, emailProviderId, dnsProviderId] =
|
const [hostingProviderId, emailProviderId, dnsProviderId] =
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
resolveProviderId({
|
resolveOrCreateProviderId({
|
||||||
category: "hosting",
|
category: "hosting",
|
||||||
domain: hostingIconDomain,
|
domain: hostingIconDomain,
|
||||||
name: hostingName,
|
name: hostingName,
|
||||||
}),
|
}),
|
||||||
resolveProviderId({
|
resolveOrCreateProviderId({
|
||||||
category: "email",
|
category: "email",
|
||||||
domain: emailIconDomain,
|
domain: emailIconDomain,
|
||||||
name: emailName,
|
name: emailName,
|
||||||
}),
|
}),
|
||||||
resolveProviderId({
|
resolveOrCreateProviderId({
|
||||||
category: "dns",
|
category: "dns",
|
||||||
domain: dnsIconDomain,
|
domain: dnsIconDomain,
|
||||||
name: dnsName,
|
name: dnsName,
|
||||||
|
@@ -85,12 +85,40 @@ describe("getRegistration", () => {
|
|||||||
expect(spy).not.toHaveBeenCalled();
|
expect(spy).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("loads via rdapper and caches on miss", async () => {
|
it("loads via rdapper, creates registrar provider when missing, and caches", async () => {
|
||||||
globalThis.__redisTestHelper.reset();
|
globalThis.__redisTestHelper.reset();
|
||||||
const { getRegistration } = await import("./registration");
|
const { getRegistration } = await import("./registration");
|
||||||
const rec = await getRegistration("example.com");
|
const rec = await getRegistration("example.com");
|
||||||
expect(rec.isRegistered).toBe(true);
|
expect(rec.isRegistered).toBe(true);
|
||||||
expect(rec.registrarProvider?.name).toBe("GoDaddy");
|
expect(rec.registrarProvider?.name).toBe("GoDaddy");
|
||||||
|
|
||||||
|
// Verify provider row exists and is linked
|
||||||
|
const { db } = await import("@/server/db/client");
|
||||||
|
const { domains, providers, registrations } = await import(
|
||||||
|
"@/server/db/schema"
|
||||||
|
);
|
||||||
|
const { eq } = await import("drizzle-orm");
|
||||||
|
const d = await db
|
||||||
|
.select({ id: domains.id })
|
||||||
|
.from(domains)
|
||||||
|
.where(eq(domains.name, "example.com"))
|
||||||
|
.limit(1);
|
||||||
|
const row = (
|
||||||
|
await db
|
||||||
|
.select({ registrarProviderId: registrations.registrarProviderId })
|
||||||
|
.from(registrations)
|
||||||
|
.where(eq(registrations.domainId, d[0].id))
|
||||||
|
.limit(1)
|
||||||
|
)[0];
|
||||||
|
expect(row.registrarProviderId).toBeTruthy();
|
||||||
|
const prov = (
|
||||||
|
await db
|
||||||
|
.select({ name: providers.name })
|
||||||
|
.from(providers)
|
||||||
|
.where(eq(providers.id, row.registrarProviderId as string))
|
||||||
|
.limit(1)
|
||||||
|
)[0];
|
||||||
|
expect(prov?.name).toBe("GoDaddy");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("sets shorter TTL for unregistered domains (observed via second call)", async () => {
|
it("sets shorter TTL for unregistered domains (observed via second call)", async () => {
|
||||||
|
@@ -12,7 +12,7 @@ import {
|
|||||||
} from "@/server/db/schema";
|
} from "@/server/db/schema";
|
||||||
import { ttlForRegistration } from "@/server/db/ttl";
|
import { ttlForRegistration } from "@/server/db/ttl";
|
||||||
import { upsertDomain } from "@/server/repos/domains";
|
import { upsertDomain } from "@/server/repos/domains";
|
||||||
import { resolveProviderId } from "@/server/repos/providers";
|
import { resolveOrCreateProviderId } from "@/server/repos/providers";
|
||||||
import { upsertRegistration } from "@/server/repos/registrations";
|
import { upsertRegistration } from "@/server/repos/registrations";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -167,7 +167,7 @@ export async function getRegistration(domain: string): Promise<Registration> {
|
|||||||
// Persist snapshot
|
// Persist snapshot
|
||||||
if (d) {
|
if (d) {
|
||||||
const fetchedAt = new Date();
|
const fetchedAt = new Date();
|
||||||
const registrarProviderId = await resolveProviderId({
|
const registrarProviderId = await resolveOrCreateProviderId({
|
||||||
category: "registrar",
|
category: "registrar",
|
||||||
domain: registrarDomain,
|
domain: registrarDomain,
|
||||||
name: registrarName,
|
name: registrarName,
|
||||||
|
Reference in New Issue
Block a user