From 53c9e43795c826bd8ca37538e211a943014648dc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 15 May 2026 22:15:48 +0200 Subject: [PATCH] feat(geo): send a one-shot admin notification when the offline databases are missing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The runtime gate for the resilient GeoLite2 fallback. On the first `lookupIpLocation` or `lookupIpAsn` call that has to leave the offline tier, the resolver checks `offlineGeoReady()` — false if the geo asset directory carries the `.empty` marker or the City MMDB is absent — and fires `dispatchLocalisedNotification` to every admin user, exactly once per process lifetime, with a body that points the maintainer at the GitHub Actions secrets page where `MAXMIND_LICENSE_KEY` belongs. Adds the two translation keys `notifications.admin.offlineGeoUnavailableTitle` / `offlineGeoUnavailableBody` to every locale bundle, locks them down in the drift-guard, and covers the latch, the marker, the no-admin path and the ASN fallback with 8 new vitest cases under `geo-offline-detection.test.ts`. --- messages/de.json | 4 +- messages/en.json | 4 +- messages/es.json | 4 +- messages/fr.json | 4 +- messages/it.json | 4 +- messages/pl.json | 4 +- src/__tests__/i18n-drift-guard.test.ts | 3 + .../__tests__/geo-offline-detection.test.ts | 186 ++++++++++++++++++ src/lib/geo.ts | 99 +++++++++- 9 files changed, 305 insertions(+), 7 deletions(-) create mode 100644 src/lib/__tests__/geo-offline-detection.test.ts diff --git a/messages/de.json b/messages/de.json index 8bbc4178f..f95878e3d 100644 --- a/messages/de.json +++ b/messages/de.json @@ -2650,7 +2650,9 @@ "reminderCheckMissedTitle": "Erinnerung verpasst: {medication}", "reminderCheckMissedBody": "{medication} ({dose}) im Zeitfenster {window} wurde nicht geloggt.", "reminderCheckOverdueTitle": "Einnahme überfällig: {medication}", - "reminderCheckOverdueBody": "{medication} ({dose}) im Zeitfenster {window} ist seit {minutes} Minuten überfällig." + "reminderCheckOverdueBody": "{medication} ({dose}) im Zeitfenster {window} ist seit {minutes} Minuten überfällig.", + "offlineGeoUnavailableTitle": "Offline-Geodatenbanken nicht geladen", + "offlineGeoUnavailableBody": "HealthLog löst Login-Standorte aktuell über den Online-Fallback ipwho.is auf. Um die Offline-Datenbanken GeoLite2-City und GeoLite2-ASN zu aktivieren, setze MAXMIND_LICENSE_KEY in den GitHub-Actions-Secrets unter {secretsUrl} und deploye neu. Bis dahin bleibt die Carrier-Spalte in der Login-Übersicht leer." }, "user": { "telegramTestBody": "HealthLog: Verbindung hergestellt. Telegram-Benachrichtigungen sind aktiv." diff --git a/messages/en.json b/messages/en.json index 82651a8a5..389a70156 100644 --- a/messages/en.json +++ b/messages/en.json @@ -2650,7 +2650,9 @@ "reminderCheckMissedTitle": "Reminder missed: {medication}", "reminderCheckMissedBody": "{medication} ({dose}) was not logged in the {window} window.", "reminderCheckOverdueTitle": "Dose overdue: {medication}", - "reminderCheckOverdueBody": "{medication} ({dose}) in the {window} window is {minutes} minutes overdue." + "reminderCheckOverdueBody": "{medication} ({dose}) in the {window} window is {minutes} minutes overdue.", + "offlineGeoUnavailableTitle": "Offline geolocation databases not loaded", + "offlineGeoUnavailableBody": "HealthLog is resolving login locations through the online ipwho.is fallback. To enable the offline GeoLite2-City + GeoLite2-ASN databases, set MAXMIND_LICENSE_KEY in GitHub Actions secrets at {secretsUrl} and redeploy. Until then the carrier column on the login overview stays empty." }, "user": { "telegramTestBody": "HealthLog: connection successful. Telegram notifications are active." diff --git a/messages/es.json b/messages/es.json index 02c067f0a..76e836274 100644 --- a/messages/es.json +++ b/messages/es.json @@ -2650,7 +2650,9 @@ "reminderCheckMissedTitle": "Recordatorio perdido: {medication}", "reminderCheckMissedBody": "{medication} ({dose}) no se registró en la ventana {window}.", "reminderCheckOverdueTitle": "Dosis atrasada: {medication}", - "reminderCheckOverdueBody": "{medication} ({dose}) en la ventana {window} lleva {minutes} minutos de retraso." + "reminderCheckOverdueBody": "{medication} ({dose}) en la ventana {window} lleva {minutes} minutos de retraso.", + "offlineGeoUnavailableTitle": "Bases de datos de geolocalización sin conexión no cargadas", + "offlineGeoUnavailableBody": "HealthLog está resolviendo las ubicaciones de inicio de sesión mediante el respaldo en línea ipwho.is. Para habilitar las bases sin conexión GeoLite2-City y GeoLite2-ASN, configura MAXMIND_LICENSE_KEY en los secrets de GitHub Actions en {secretsUrl} y vuelve a desplegar. Hasta entonces, la columna de operador en el resumen de inicios de sesión permanece vacía." }, "user": { "telegramTestBody": "HealthLog: conexión correcta. Las notificaciones de Telegram están activas." diff --git a/messages/fr.json b/messages/fr.json index 4577abb37..65c94975d 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -2650,7 +2650,9 @@ "reminderCheckMissedTitle": "Rappel manqué : {medication}", "reminderCheckMissedBody": "{medication} ({dose}) n'a pas été enregistré dans la fenêtre {window}.", "reminderCheckOverdueTitle": "Prise en retard : {medication}", - "reminderCheckOverdueBody": "{medication} ({dose}) dans la fenêtre {window} est en retard de {minutes} minutes." + "reminderCheckOverdueBody": "{medication} ({dose}) dans la fenêtre {window} est en retard de {minutes} minutes.", + "offlineGeoUnavailableTitle": "Bases de géolocalisation hors ligne non chargées", + "offlineGeoUnavailableBody": "HealthLog résout les localisations de connexion via le fallback en ligne ipwho.is. Pour activer les bases hors ligne GeoLite2-City et GeoLite2-ASN, définissez MAXMIND_LICENSE_KEY dans les secrets GitHub Actions à {secretsUrl} puis redéployez. En attendant, la colonne opérateur dans l'aperçu des connexions reste vide." }, "user": { "telegramTestBody": "HealthLog: connexion réussie. Les notifications Telegram sont actives." diff --git a/messages/it.json b/messages/it.json index 2de7e6e77..21534cb01 100644 --- a/messages/it.json +++ b/messages/it.json @@ -2650,7 +2650,9 @@ "reminderCheckMissedTitle": "Promemoria perso: {medication}", "reminderCheckMissedBody": "{medication} ({dose}) non è stato registrato nella finestra {window}.", "reminderCheckOverdueTitle": "Dose in ritardo: {medication}", - "reminderCheckOverdueBody": "{medication} ({dose}) nella finestra {window} è in ritardo di {minutes} minuti." + "reminderCheckOverdueBody": "{medication} ({dose}) nella finestra {window} è in ritardo di {minutes} minuti.", + "offlineGeoUnavailableTitle": "Database di geolocalizzazione offline non caricati", + "offlineGeoUnavailableBody": "HealthLog sta risolvendo le posizioni di accesso tramite il fallback online ipwho.is. Per abilitare i database offline GeoLite2-City e GeoLite2-ASN, imposta MAXMIND_LICENSE_KEY nei secret di GitHub Actions su {secretsUrl} e ridistribuisci. Fino ad allora la colonna operatore nella panoramica degli accessi rimane vuota." }, "user": { "telegramTestBody": "HealthLog: connessione riuscita. Le notifiche Telegram sono attive." diff --git a/messages/pl.json b/messages/pl.json index 0b05fb647..ed0bfc772 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -2650,7 +2650,9 @@ "reminderCheckMissedTitle": "Pominięte przypomnienie: {medication}", "reminderCheckMissedBody": "{medication} ({dose}) nie zostało zarejestrowane w przedziale {window}.", "reminderCheckOverdueTitle": "Dawka opóźniona: {medication}", - "reminderCheckOverdueBody": "{medication} ({dose}) w przedziale {window} jest opóźnione o {minutes} minut." + "reminderCheckOverdueBody": "{medication} ({dose}) w przedziale {window} jest opóźnione o {minutes} minut.", + "offlineGeoUnavailableTitle": "Bazy geolokalizacji offline nie zostały załadowane", + "offlineGeoUnavailableBody": "HealthLog rozwiązuje lokalizacje logowania przez awaryjne źródło online ipwho.is. Aby włączyć bazy offline GeoLite2-City i GeoLite2-ASN, ustaw MAXMIND_LICENSE_KEY w sekretach GitHub Actions pod adresem {secretsUrl} i ponownie wdroż. Do tego czasu kolumna operatora w przeglądzie logowań pozostaje pusta." }, "user": { "telegramTestBody": "HealthLog: połączenie udane. Powiadomienia Telegram są aktywne." diff --git a/src/__tests__/i18n-drift-guard.test.ts b/src/__tests__/i18n-drift-guard.test.ts index ba2077866..10b6d1f08 100644 --- a/src/__tests__/i18n-drift-guard.test.ts +++ b/src/__tests__/i18n-drift-guard.test.ts @@ -122,6 +122,9 @@ const REQUIRED_KEY_SETS: Array<{ "notifications.admin.reminderCheckMissedBody", "notifications.admin.reminderCheckOverdueTitle", "notifications.admin.reminderCheckOverdueBody", + // v1.4.27 R5 — offline-geo fallback notifications. + "notifications.admin.offlineGeoUnavailableTitle", + "notifications.admin.offlineGeoUnavailableBody", ], }, { diff --git a/src/lib/__tests__/geo-offline-detection.test.ts b/src/lib/__tests__/geo-offline-detection.test.ts new file mode 100644 index 000000000..787b64819 --- /dev/null +++ b/src/lib/__tests__/geo-offline-detection.test.ts @@ -0,0 +1,186 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +/** + * v1.4.27 R5 — runtime detection + one-shot admin notification for the + * offline GeoLite2 tier. + * + * The CI workflow drops an `.empty` marker into `assets/geolite2/` + * whenever `MAXMIND_LICENSE_KEY` is unset, so the Docker COPY still + * lands a non-empty directory in `/opt/geolite2/`. The runtime resolver + * detects the marker on first fallback, fires a single localised + * notification to every admin, and stays silent for the rest of the + * process lifetime. + * + * The tests cover the three states the design pins: + * + * 1. `.empty` marker present, City MMDB absent → notification fires + * on first public-IP lookup. + * 2. No marker, City MMDB present → no notification fires. + * 3. Marker present, repeat lookups → notification fires once and + * never again. + * + * `mmdb-lib` is stubbed via the same shape the existing + * `geo-asn.test.ts` uses; `fs` is left real because `offlineGeoReady` + * reads real files at the temp-dir path picked per test, and the + * `dispatchLocalisedNotification` + `prisma.user.findMany` are mocked + * so the test never reaches a database or a sender. + */ + +vi.mock("mmdb-lib", () => ({ + Reader: class { + constructor(_buf: Buffer) { + void _buf; + } + get(ip: string): unknown { + void ip; + return null; + } + }, +})); + +const dispatchSpy = vi.fn(async () => undefined); +vi.mock("@/lib/notifications/dispatch-localised", () => ({ + dispatchLocalisedNotification: dispatchSpy, +})); + +const findManySpy = vi.fn(async () => [{ id: "admin-1" }]); +vi.mock("@/lib/db", () => ({ + prisma: { + user: { findMany: findManySpy }, + }, +})); + +const ORIGINAL_ENV = { ...process.env }; +let tmpRoot: string; + +async function flushMicrotasks(): Promise { + // The notification path is fire-and-forget — `void notify…()` — + // and pulls in `@/lib/db` + `@/lib/notifications/dispatch-localised` + // through dynamic `import()` calls, so each dispatch needs several + // microtask + macrotask flushes before the spy registers the call. + for (let i = 0; i < 20; i++) { + await Promise.resolve(); + await new Promise((r) => setTimeout(r, 0)); + } +} + +beforeEach(() => { + process.env = { ...ORIGINAL_ENV }; + delete process.env.IP_GEO_LOOKUP_DISABLED; + tmpRoot = mkdtempSync(join(tmpdir(), "healthlog-geo-r5-")); + process.env.GEOLITE2_DIR = tmpRoot; + dispatchSpy.mockClear(); + findManySpy.mockClear(); + findManySpy.mockResolvedValue([{ id: "admin-1" }]); + vi.resetModules(); +}); + +afterEach(() => { + rmSync(tmpRoot, { recursive: true, force: true }); + process.env = ORIGINAL_ENV; + vi.restoreAllMocks(); + vi.unstubAllGlobals(); +}); + +describe("offline-geo runtime detection (v1.4.27 R5)", () => { + it("offlineGeoReady() is false when the .empty marker is present", async () => { + writeFileSync(join(tmpRoot, ".empty"), ""); + const { offlineGeoReady } = await import("../geo"); + expect(offlineGeoReady()).toBe(false); + }); + + it("offlineGeoReady() is false when the City MMDB is absent", async () => { + const { offlineGeoReady } = await import("../geo"); + expect(offlineGeoReady()).toBe(false); + }); + + it("offlineGeoReady() is true when the City MMDB is present and no marker", async () => { + writeFileSync(join(tmpRoot, "GeoLite2-City.mmdb"), Buffer.from("city-stub")); + const { offlineGeoReady } = await import("../geo"); + expect(offlineGeoReady()).toBe(true); + }); + + it("fires the one-shot admin notification when the marker is present and lookupIpLocation falls back", async () => { + writeFileSync(join(tmpRoot, ".empty"), ""); + // Disable the online fallback so the test does not hit the network. + process.env.IP_GEO_LOOKUP_DISABLED = "1"; + const { lookupIpLocation } = await import("../geo"); + + await lookupIpLocation("8.8.8.8"); + await flushMicrotasks(); + + expect(findManySpy).toHaveBeenCalledTimes(1); + expect(dispatchSpy).toHaveBeenCalledTimes(1); + expect(dispatchSpy).toHaveBeenCalledWith( + expect.objectContaining({ + userId: "admin-1", + titleKey: "notifications.admin.offlineGeoUnavailableTitle", + messageKey: "notifications.admin.offlineGeoUnavailableBody", + params: expect.objectContaining({ + secretsUrl: expect.stringContaining( + "github.com/MBombeck/HealthLog/settings/secrets/actions", + ), + }), + metadata: expect.objectContaining({ source: "geo-offline-detection" }), + }), + ); + }); + + it("does NOT fire the notification when the City MMDB is present", async () => { + writeFileSync(join(tmpRoot, "GeoLite2-City.mmdb"), Buffer.from("city-stub")); + // The stubbed reader returns null for every IP, which is fine — the + // lookup will still go through the online path, but the offline + // check sees a healthy directory so the notification stays silent. + process.env.IP_GEO_LOOKUP_DISABLED = "1"; + const { lookupIpLocation } = await import("../geo"); + + await lookupIpLocation("8.8.8.8"); + await flushMicrotasks(); + + expect(dispatchSpy).not.toHaveBeenCalled(); + expect(findManySpy).not.toHaveBeenCalled(); + }); + + it("fires the notification exactly once across repeated fallbacks (process-level latch)", async () => { + writeFileSync(join(tmpRoot, ".empty"), ""); + process.env.IP_GEO_LOOKUP_DISABLED = "1"; + const { lookupIpLocation, lookupIpAsn } = await import("../geo"); + + await lookupIpLocation("8.8.8.8"); + await flushMicrotasks(); + await lookupIpLocation("1.1.1.1"); + await flushMicrotasks(); + lookupIpAsn("9.9.9.9"); + await flushMicrotasks(); + + // Three fallbacks, exactly one dispatch. + expect(dispatchSpy).toHaveBeenCalledTimes(1); + }); + + it("falls back silently when no admin user is configured", async () => { + writeFileSync(join(tmpRoot, ".empty"), ""); + process.env.IP_GEO_LOOKUP_DISABLED = "1"; + findManySpy.mockResolvedValueOnce([]); + const { lookupIpLocation } = await import("../geo"); + + await lookupIpLocation("8.8.8.8"); + await flushMicrotasks(); + + expect(findManySpy).toHaveBeenCalledTimes(1); + expect(dispatchSpy).not.toHaveBeenCalled(); + }); + + it("lookupIpAsn fires the notification when the ASN reader is missing and the marker is set", async () => { + writeFileSync(join(tmpRoot, ".empty"), ""); + const { lookupIpAsn } = await import("../geo"); + + const result = lookupIpAsn("8.8.8.8"); + await flushMicrotasks(); + + expect(result).toBeNull(); + expect(dispatchSpy).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/lib/geo.ts b/src/lib/geo.ts index ab632f6a7..887217682 100644 --- a/src/lib/geo.ts +++ b/src/lib/geo.ts @@ -48,6 +48,14 @@ * The previous path lost umlauts in production for at least one * maintainer-flagged login row that rendered as "Nrnberg" in * /admin/login-overview — see `docs/audit/v1416-summary.md`. + * + * v1.4.27 R5: the build no longer hard-fails on a missing + * `MAXMIND_LICENSE_KEY` secret — the CI workflow drops an `.empty` + * marker into the geo asset directory when the key is unset. + * `offlineGeoReady()` is the canonical check used by `/api/version` + * and the admin status surface, and the lookup paths fire a one-shot + * admin notification on the first fallback so the maintainer hears + * about the gap from the running app. */ import fs from "node:fs"; import path from "node:path"; @@ -56,6 +64,7 @@ import type { AsnResponse, CityResponse, } from "mmdb-lib/lib/reader/response"; +import { getEvent } from "@/lib/logging/context"; interface IpwhoIsResponse { success?: boolean; @@ -133,6 +142,78 @@ function getAsnReader(): MmdbReader | null { export function __resetGeoLite2CacheForTests(): void { cache.city = undefined; cache.asn = undefined; + notifiedThisProcess = false; +} + +// ── Offline readiness + one-shot admin notification ───────────────── +// +// The CI workflow drops an `.empty` marker into the geo asset directory +// when the maintainer has not configured `MAXMIND_LICENSE_KEY`, in +// which case the City + ASN MMDBs are absent and every lookup hits the +// online fallback. `offlineGeoReady()` is the canonical truth used by +// `/api/version` and the admin status surface so the two paths cannot +// disagree. +// +// `notifiedThisProcess` is a module-level latch so the admin only +// receives the "offline geo is disabled" notification once per worker +// boot — every subsequent fallback hit is silent. Test-only reset is +// folded into the cache reset above. + +let notifiedThisProcess = false; + +export function offlineGeoReady(): boolean { + try { + const dir = geoLiteDir(); + if (fs.existsSync(path.join(dir, ".empty"))) return false; + return fs.existsSync(path.join(dir, "GeoLite2-City.mmdb")); + } catch { + return false; + } +} + +async function notifyOfflineGeoUnavailable(): Promise { + if (notifiedThisProcess) return; + notifiedThisProcess = true; + // Defer the resolve so we don't pay the cost of pulling the + // Prisma client into the import graph until the first fallback — + // most calls are cache hits or private-IP short-circuits. + try { + const [{ prisma }, { dispatchLocalisedNotification }] = await Promise.all([ + import("@/lib/db"), + import("@/lib/notifications/dispatch-localised"), + ]); + const admins = await prisma.user.findMany({ + where: { role: "ADMIN" }, + select: { id: true }, + }); + if (admins.length === 0) { + getEvent()?.addWarning( + "geo: offline databases unavailable, no admin user configured to notify", + ); + return; + } + getEvent()?.addWarning( + "geo: offline databases unavailable, falling back to ipwho.is — notifying admins", + ); + for (const admin of admins) { + await dispatchLocalisedNotification({ + userId: admin.id, + titleKey: "notifications.admin.offlineGeoUnavailableTitle", + messageKey: "notifications.admin.offlineGeoUnavailableBody", + params: { + secretsUrl: + "https://github.com/MBombeck/HealthLog/settings/secrets/actions", + }, + metadata: { source: "geo-offline-detection" }, + }); + } + } catch (err) { + // Never let a notification failure propagate — the auth-audit + // caller is fire-and-forget. + getEvent()?.addWarning( + `geo: offline-geo notification failed: ${err instanceof Error ? err.message : String(err)}`, + ); + } } /** @@ -244,6 +325,14 @@ export async function lookupIpLocation( const offline = lookupIpLocationOffline(ip); if (offline) return offline; + // First public-IP lookup that has to leave the offline tier — if the + // offline DBs are missing entirely, send the one-shot admin alert so + // the maintainer can wire `MAXMIND_LICENSE_KEY` when convenient. The + // notification is fire-and-forget so the audit-log path stays fast. + if (!offlineGeoReady()) { + void notifyOfflineGeoUnavailable(); + } + return lookupIpLocationOnline(ip); } @@ -262,7 +351,15 @@ export function lookupIpAsn( ): { asn: number; carrier: string | null } | null { if (!ip || PRIVATE_IP.test(ip)) return null; const reader = getAsnReader(); - if (!reader) return null; + if (!reader) { + // No offline ASN data — alert once per process. There is no online + // fallback for ASN, but the maintainer still wants to know the + // carrier chip is going to stay empty until the secret is set. + if (!offlineGeoReady()) { + void notifyOfflineGeoUnavailable(); + } + return null; + } try { const row = reader.get(ip); if (!row) return null;