diff --git a/server/repos/providers.ts b/server/repos/providers.ts index aa41aa8..07c068b 100644 --- a/server/repos/providers.ts +++ b/server/repos/providers.ts @@ -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 { + 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); + } +} diff --git a/server/services/certificates.test.ts b/server/services/certificates.test.ts index dd27814..bb04ce8 100644 --- a/server/services/certificates.test.ts +++ b/server/services/certificates.test.ts @@ -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; diff --git a/server/services/certificates.ts b/server/services/certificates.ts index e4d025b..24cf826 100644 --- a/server/services/certificates.ts +++ b/server/services/certificates.ts @@ -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 { console.debug("[certificates] start", { domain }); @@ -132,7 +132,7 @@ export async function getCertificates(domain: string): Promise { 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, diff --git a/server/services/hosting.test.ts b/server/services/hosting.test.ts index da2f66a..85e8e82 100644 --- a/server/services/hosting.test.ts +++ b/server/services/hosting.test.ts @@ -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(); + 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); + }); }); diff --git a/server/services/hosting.ts b/server/services/hosting.ts index bbf4a70..e736ffd 100644 --- a/server/services/hosting.ts +++ b/server/services/hosting.ts @@ -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 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 { } // 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 { 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, diff --git a/server/services/registration.test.ts b/server/services/registration.test.ts index 1023a80..58e2242 100644 --- a/server/services/registration.test.ts +++ b/server/services/registration.test.ts @@ -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 () => { diff --git a/server/services/registration.ts b/server/services/registration.ts index 38f2520..8e6acab 100644 --- a/server/services/registration.ts +++ b/server/services/registration.ts @@ -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 { // Persist snapshot if (d) { const fetchedAt = new Date(); - const registrarProviderId = await resolveProviderId({ + const registrarProviderId = await resolveOrCreateProviderId({ category: "registrar", domain: registrarDomain, name: registrarName,