feat(geo): send a one-shot admin notification when the offline databases are missing

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`.
This commit is contained in:
Marc-André Bombeck committed 2026-05-15 22:15:48 +02:00
1 parent 1fe424da0b
commit 53c9e43795
9 files changed
+305 -7

No files matched your search

+3 -1
View File
@@ -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."
+3 -1
View File
@@ -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."
+3 -1
View File
@@ -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."
+3 -1
View File
@@ -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."
+3 -1
View File
@@ -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."
+3 -1
View File
@@ -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."
+3
View File
@@ -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",
],
},
{
@@ -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<void> {
// 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);
});
});
+98 -1
View File
@@ -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<AsnResponse> | 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<void> {
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;