mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 05:42:19 +00:00
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:
1 parent
1fe424da0b
commit
53c9e43795
9 files changed
+305
-7
No files matched your search
@@ -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
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user