1
mirror of https://github.com/jakejarvis/hoot.git synced 2025-10-18 14:24:26 -04:00

Fix unknown providers after first load by creating new provider IDs when discovered

This commit is contained in:
2025-10-18 00:29:24 -04:00
parent 1a9437030e
commit 288c32b777
7 changed files with 172 additions and 20 deletions

View File

@@ -44,3 +44,34 @@ export async function resolveProviderId(
}
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);
}
}

View File

@@ -101,9 +101,11 @@ describe("getCertificates", () => {
const out = await getCertificates("example.com");
expect(out.length).toBeGreaterThan(0);
// Verify DB persistence
// Verify DB persistence and CA provider creation
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 d = await db
.select({ id: domains.id })
@@ -116,6 +118,14 @@ describe("getCertificates", () => {
.where(eq(certificates.domainId, d[0].id));
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
const prevCalls = (tlsMock.socketMock.getPeerCertificate as unknown as Mock)
.mock.calls.length;

View File

@@ -10,7 +10,7 @@ import { certificates as certTable } from "@/server/db/schema";
import { ttlForCertificates } from "@/server/db/ttl";
import { replaceCertificates } from "@/server/repos/certificates";
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[]> {
console.debug("[certificates] start", { domain });
@@ -132,7 +132,7 @@ export async function getCertificates(domain: string): Promise<Certificate[]> {
if (d) {
const chainWithIds = await Promise.all(
out.map(async (c) => {
const caProviderId = await resolveProviderId({
const caProviderId = await resolveOrCreateProviderId({
category: "ca",
domain: c.caProvider.domain,
name: c.caProvider.name,

View File

@@ -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 () => {
vi.resetModules();
const { makePGliteDb } = await import("@/server/db/pglite");
@@ -179,4 +195,71 @@ describe("detectHosting", () => {
expect(result.emailProvider.domain).toBe("example.com");
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);
});
});

View File

@@ -17,7 +17,7 @@ import {
import { ttlForHosting } from "@/server/db/ttl";
import { upsertDomain } from "@/server/repos/domains";
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 { probeHeaders } from "@/server/services/headers";
import { lookupIpMeta } from "@/server/services/ip";
@@ -157,10 +157,10 @@ export async function detectHosting(domain: string): Promise<Hosting> {
// Hosting provider detection with fallback:
// - If no A record/IP → unset → "Not configured"
// - Else if unknown → try IP ownership org/ISP
const hosting = detectHostingProvider(headers);
const hostingDetected = detectHostingProvider(headers);
let hostingName = hosting.name;
let hostingIconDomain = hosting.domain;
let hostingName = hostingDetected.name;
let hostingIconDomain = hostingDetected.domain;
if (!ip) {
hostingName = "Not configured";
hostingIconDomain = null;
@@ -170,17 +170,17 @@ export async function detectHosting(domain: string): Promise<Hosting> {
}
// Determine email provider, using "Not configured" when MX is unset
const email =
const emailDetected =
mx.length === 0
? { name: "Not configured", domain: null }
: detectEmailProvider(mx.map((m) => m.value));
let emailName = email.name;
let emailIconDomain = email.domain;
let emailName = emailDetected.name;
let emailIconDomain = emailDetected.domain;
// DNS provider from nameservers
const dnsResult = detectDnsProvider(nsRecords.map((n) => n.value));
let dnsName = dnsResult.name;
let dnsIconDomain = dnsResult.domain;
const dnsDetected = detectDnsProvider(nsRecords.map((n) => n.value));
let dnsName = dnsDetected.name;
let dnsIconDomain = dnsDetected.domain;
// 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) {
@@ -220,17 +220,17 @@ export async function detectHosting(domain: string): Promise<Hosting> {
if (d) {
const [hostingProviderId, emailProviderId, dnsProviderId] =
await Promise.all([
resolveProviderId({
resolveOrCreateProviderId({
category: "hosting",
domain: hostingIconDomain,
name: hostingName,
}),
resolveProviderId({
resolveOrCreateProviderId({
category: "email",
domain: emailIconDomain,
name: emailName,
}),
resolveProviderId({
resolveOrCreateProviderId({
category: "dns",
domain: dnsIconDomain,
name: dnsName,

View File

@@ -85,12 +85,40 @@ describe("getRegistration", () => {
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();
const { getRegistration } = await import("./registration");
const rec = await getRegistration("example.com");
expect(rec.isRegistered).toBe(true);
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 () => {

View File

@@ -12,7 +12,7 @@ import {
} from "@/server/db/schema";
import { ttlForRegistration } from "@/server/db/ttl";
import { upsertDomain } from "@/server/repos/domains";
import { resolveProviderId } from "@/server/repos/providers";
import { resolveOrCreateProviderId } from "@/server/repos/providers";
import { upsertRegistration } from "@/server/repos/registrations";
/**
@@ -167,7 +167,7 @@ export async function getRegistration(domain: string): Promise<Registration> {
// Persist snapshot
if (d) {
const fetchedAt = new Date();
const registrarProviderId = await resolveProviderId({
const registrarProviderId = await resolveOrCreateProviderId({
category: "registrar",
domain: registrarDomain,
name: registrarName,