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:
@@ -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);
|
||||
}
|
||||
}
|
||||
|
@@ -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;
|
||||
|
@@ -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,
|
||||
|
@@ -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);
|
||||
});
|
||||
});
|
||||
|
@@ -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,
|
||||
|
@@ -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 () => {
|
||||
|
@@ -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,
|
||||
|
Reference in New Issue
Block a user