diff --git a/src/lib/__tests__/geo-asn.test.ts b/src/lib/__tests__/geo-asn.test.ts index 0664b16a5..6a59dfc7b 100644 --- a/src/lib/__tests__/geo-asn.test.ts +++ b/src/lib/__tests__/geo-asn.test.ts @@ -16,7 +16,9 @@ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; * - Reader throws are swallowed (the helper is called from a * fire-and-forget audit-log path; a thrown error would surface as * an unhandled rejection). - * - The offline-first `lookupIpLocation` path picks the German city + * - The online-first `lookupIpLocation` path (v1.18.10 W7) resolves via + * `ipwho.is` by default and only consults the offline MMDB as a + * fallback (online miss or egress disabled), picking the German city * name first, English second, with the country ISO code from * `country` or `registered_country`. * @@ -171,57 +173,29 @@ describe("lookupIpAsn — offline ASN resolver (v1.4.27 B3)", () => { }); }); -describe("lookupIpLocation — offline-first city resolver (v1.4.27 B3)", () => { - it("prefers the German exonym from the MMDB Names record", async () => { +// v1.18.10 (W7) — online-first resolver. The `ipwho.is` HTTPS lookup is the +// DEFAULT path; the bundled GeoLite2 offline tier is an OPTIONAL fallback +// consulted only when the online lookup misses (provider down / non-ok) or +// when egress is disabled via `IP_GEO_LOOKUP_DISABLED=1`. +describe("lookupIpLocation — online-first city resolver (v1.18.10 W7)", () => { + // Helper: a non-ok online response forces the offline fallback to run. + function onlineMiss(): Response { + return new Response("", { + status: 503, + headers: { "content-type": "application/json" }, + }); + } + + it("prefers the online provider even when the offline DB is present", async () => { readerState.cityExists = true; readerState.city = () => ({ city: { names: { de: "München", en: "Munich" } }, country: { iso_code: "DE" }, }); - const fetchSpy = vi.fn(); - vi.stubGlobal("fetch", fetchSpy); - const { lookupIpLocation } = await import("../geo"); - - expect(await lookupIpLocation("85.214.0.1")).toBe("München, DE"); - // Offline hit — no online fallback was needed. - expect(fetchSpy).not.toHaveBeenCalled(); - }); - - it("falls back to the English city name when the German exonym is absent", async () => { - readerState.cityExists = true; - readerState.city = () => ({ - city: { names: { en: "Birmingham" } }, - country: { iso_code: "GB" }, - }); - const fetchSpy = vi.fn(); - vi.stubGlobal("fetch", fetchSpy); - const { lookupIpLocation } = await import("../geo"); - - expect(await lookupIpLocation("212.58.224.1")).toBe("Birmingham, GB"); - }); - - it("accepts the registered_country ISO when country is absent", async () => { - readerState.cityExists = true; - readerState.city = () => ({ - city: { names: { en: "Geneva" } }, - registered_country: { iso_code: "CH" }, - }); - const { lookupIpLocation } = await import("../geo"); - - expect(await lookupIpLocation("194.158.0.1")).toBe("Geneva, CH"); - }); - - it("falls back to the online provider when the offline DB has no city/country", async () => { - readerState.cityExists = true; - readerState.city = () => ({ city: { names: {} } }); const fetchSpy = vi.fn().mockResolvedValue( new Response( new TextEncoder().encode( - JSON.stringify({ - success: true, - city: "Hamburg", - country_code: "DE", - }), + JSON.stringify({ success: true, city: "Berlin", country_code: "DE" }), ).buffer as ArrayBuffer, { status: 200, @@ -232,11 +206,64 @@ describe("lookupIpLocation — offline-first city resolver (v1.4.27 B3)", () => vi.stubGlobal("fetch", fetchSpy); const { lookupIpLocation } = await import("../geo"); - expect(await lookupIpLocation("8.8.8.8")).toBe("Hamburg, DE"); + // Online wins by default; the offline München record is not consulted. + expect(await lookupIpLocation("85.214.0.1")).toBe("Berlin, DE"); expect(fetchSpy).toHaveBeenCalledTimes(1); }); - it("falls back to the online provider when the offline DB is missing entirely", async () => { + it("falls back to the offline German exonym when the online lookup misses", async () => { + readerState.cityExists = true; + readerState.city = () => ({ + city: { names: { de: "München", en: "Munich" } }, + country: { iso_code: "DE" }, + }); + const fetchSpy = vi.fn().mockResolvedValue(onlineMiss()); + vi.stubGlobal("fetch", fetchSpy); + const { lookupIpLocation } = await import("../geo"); + + expect(await lookupIpLocation("85.214.0.1")).toBe("München, DE"); + // Online was tried first (and missed), then the offline tier resolved. + expect(fetchSpy).toHaveBeenCalledTimes(1); + }); + + it("offline fallback uses the English city name when the German exonym is absent", async () => { + readerState.cityExists = true; + readerState.city = () => ({ + city: { names: { en: "Birmingham" } }, + country: { iso_code: "GB" }, + }); + const fetchSpy = vi.fn().mockResolvedValue(onlineMiss()); + vi.stubGlobal("fetch", fetchSpy); + const { lookupIpLocation } = await import("../geo"); + + expect(await lookupIpLocation("212.58.224.1")).toBe("Birmingham, GB"); + }); + + it("offline fallback accepts the registered_country ISO when country is absent", async () => { + readerState.cityExists = true; + readerState.city = () => ({ + city: { names: { en: "Geneva" } }, + registered_country: { iso_code: "CH" }, + }); + const fetchSpy = vi.fn().mockResolvedValue(onlineMiss()); + vi.stubGlobal("fetch", fetchSpy); + const { lookupIpLocation } = await import("../geo"); + + expect(await lookupIpLocation("194.158.0.1")).toBe("Geneva, CH"); + }); + + it("returns null when both online and the offline DB miss", async () => { + readerState.cityExists = true; + readerState.city = () => ({ city: { names: {} } }); + const fetchSpy = vi.fn().mockResolvedValue(onlineMiss()); + vi.stubGlobal("fetch", fetchSpy); + const { lookupIpLocation } = await import("../geo"); + + expect(await lookupIpLocation("8.8.8.8")).toBeNull(); + expect(fetchSpy).toHaveBeenCalledTimes(1); + }); + + it("resolves online when the offline DB is missing entirely", async () => { readerState.cityExists = false; const fetchSpy = vi.fn().mockResolvedValue( new Response( @@ -256,7 +283,24 @@ describe("lookupIpLocation — offline-first city resolver (v1.4.27 B3)", () => expect(fetchSpy).toHaveBeenCalledTimes(1); }); - it("skips the online fallback when IP_GEO_LOOKUP_DISABLED=1 and offline misses", async () => { + it("uses the offline tier when IP_GEO_LOOKUP_DISABLED=1 and the offline DB is present", async () => { + process.env.IP_GEO_LOOKUP_DISABLED = "1"; + readerState.cityExists = true; + readerState.city = () => ({ + city: { names: { de: "München" } }, + country: { iso_code: "DE" }, + }); + const fetchSpy = vi.fn(); + vi.stubGlobal("fetch", fetchSpy); + const { lookupIpLocation } = await import("../geo"); + + // Egress disabled → online short-circuits to null without a request, then + // the offline tier resolves. + expect(await lookupIpLocation("85.214.0.1")).toBe("München, DE"); + expect(fetchSpy).not.toHaveBeenCalled(); + }); + + it("returns null when IP_GEO_LOOKUP_DISABLED=1 and the offline DB is missing", async () => { process.env.IP_GEO_LOOKUP_DISABLED = "1"; readerState.cityExists = false; const fetchSpy = vi.fn(); diff --git a/src/lib/geo.ts b/src/lib/geo.ts index 70d8ff6ce..d7326b0e0 100644 --- a/src/lib/geo.ts +++ b/src/lib/geo.ts @@ -1,19 +1,18 @@ /** * IP geolocation lookup for audit-log enrichment. * - * v1.15.12 E2: online-first resolver. The baseline that always works is - * the `ipwho.is` HTTPS lookup (free, no key) — every self-host resolves - * a location out of the box with no MaxMind licence configured. The + * v1.18.10 (W7): online-first resolver. The `ipwho.is` HTTPS lookup + * (free, no key) is the DEFAULT path — every self-host resolves a + * location out of the box with no MaxMind licence configured. The * bundled MaxMind GeoLite2-City MMDB at `/opt/geolite2/GeoLite2-City.mmdb` - * is an OPTIONAL upgrade: when the offline databases are present - * (`offlineGeoReady()`), the resolver prefers the microsecond-scale - * local read for both speed and zero egress; when they are absent it - * goes straight online. + * is an OPTIONAL fallback (perspective: removed later): it is consulted + * only when the online lookup misses (provider down / rate-limited) or + * when egress is disabled via `IP_GEO_LOOKUP_DISABLED=1`. * * 1. Private / loopback / opt-out → null (no lookup). * 2. Per-IP cache hit → return immediately. - * 3. Offline MMDB present → local read; on a miss fall through online. - * 4. Otherwise → online `ipwho.is` lookup. + * 3. Online `ipwho.is` lookup → return on success. + * 4. Offline MMDB present → local read fallback on an online miss. * * The legacy contract still holds: `lookupIpLocation` returns a * `"City, CC"` string or `null`, never throws. `lookupIpAsn` is the @@ -369,31 +368,39 @@ export async function lookupIpLocation( const cached = getCachedLocation(ip); if (cached) return cached.value; - // v1.15.12 E2 — online-first precedence. The optional MaxMind offline - // tier is preferred ONLY when its databases are actually present - // (`offlineGeoReady()`): a local hit is faster and egress-free. When - // the offline DBs are absent — the default for a self-host with no - // `MAXMIND_LICENSE_KEY` — we skip straight to the online provider, - // which is the baseline that always resolves a location. + // v1.18.10 (W7) — online-first by default. The `ipwho.is` HTTPS lookup + // is now the primary resolver for every self-host: it needs no MaxMind + // licence and resolves a location out of the box. The bundled GeoLite2 + // offline tier is an OPTIONAL fallback kept only for the egress-disabled + // case (`IP_GEO_LOOKUP_DISABLED=1`) or for an online miss when the DBs + // happen to be present — it is no longer the default path, and a missing + // offline tier is the expected baseline rather than a gap to alert on. + const online = await lookupIpLocationOnline(ip); + if (online) { + setCachedLocation(ip, online); + return online; + } + + // Online missed (provider down, rate-limited, or egress disabled). Fall + // back to the offline MMDB if the operator configured one. if (offlineGeoReady()) { const offline = lookupIpLocationOffline(ip); if (offline) { setCachedLocation(ip, offline); return offline; } - // Offline DBs present but this IP missed (freshly-allocated range - // lagging the monthly GeoLite2 roll) → fall through to online. } else { - // No offline tier configured — fire the one-shot admin alert so the - // maintainer can wire `MAXMIND_LICENSE_KEY` for the offline upgrade - // when convenient. Online still resolves; the alert is informational. - // Fire-and-forget so the audit-log path stays fast. + // Neither resolver produced a location: the online lookup missed (or + // egress is disabled) AND no offline tier is configured. That is the + // only genuine "no resolver at all" gap — fire the one-shot admin alert + // so the maintainer can wire `MAXMIND_LICENSE_KEY` or re-enable egress. + // The happy path (ipwho.is resolving by default) never reaches here, so + // a self-host without the offline DBs is not nagged on every lookup. void notifyOfflineGeoUnavailable(); } - const online = await lookupIpLocationOnline(ip); - setCachedLocation(ip, online); - return online; + setCachedLocation(ip, null); + return null; } /**