mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 21:10:59 +00:00
Merge w10/settingsmisc into release/v1.18.10
Align the Vorsorge settings card to the canonical pattern, neutralize the danger zone card, mark the bug-report feature deprecated, make ipwho.is the default geo resolver, and keep the mobile More-hub labels on one line.
This commit is contained in:
commit
28dd3b10af
12 files changed
+266
-162
No files matched your search
@@ -4934,6 +4934,8 @@
|
||||
"bugReportGithubDescription": "Hinterlege GitHub-Issue-Token und Repository für eingehende Bugreports.",
|
||||
"bugReportEnabledLabel": "Bug-Reports aktiviert",
|
||||
"bugReportEnabledDescription": "Wenn deaktiviert, ist der Report-Button im Frontend nicht mehr sichtbar — Token und Repository bleiben gespeichert.",
|
||||
"bugReportDeprecatedBadge": "Veraltet",
|
||||
"bugReportDeprecatedNote": "Diese Funktion ist veraltet und wird in einer künftigen Version entfernt.",
|
||||
"assistant": {
|
||||
"title": "Assistenz-Oberflächen",
|
||||
"description": "Jeder Toggle versteckt eine modellgestützte Oberfläche app-weit, ohne die Provider-Konfiguration zu entfernen. Der Hauptschalter erzwingt jede Unter-Option aus.",
|
||||
@@ -6473,6 +6475,7 @@
|
||||
"viewDescription": "Lege fest, ob die Einträge als Karten oder als kompakte Liste angezeigt werden.",
|
||||
"reorder": {
|
||||
"heading": "Reihenfolge",
|
||||
"description": "Ziehe die Einträge, um ihre Reihenfolge festzulegen.",
|
||||
"empty": "Noch keine Einträge zum Sortieren."
|
||||
}
|
||||
},
|
||||
|
||||
@@ -4934,6 +4934,8 @@
|
||||
"bugReportGithubDescription": "Configure GitHub issue token and repository for incoming bug reports.",
|
||||
"bugReportEnabledLabel": "Bug reports enabled",
|
||||
"bugReportEnabledDescription": "When off, the in-app report button hides — token and repository stay configured.",
|
||||
"bugReportDeprecatedBadge": "Deprecated",
|
||||
"bugReportDeprecatedNote": "This feature is deprecated and will be removed in a future version.",
|
||||
"assistant": {
|
||||
"title": "Assistant Surfaces",
|
||||
"description": "Each toggle hides a model-driven surface app-wide without removing the provider configuration. The master kills every sub-flag at once.",
|
||||
@@ -6473,6 +6475,7 @@
|
||||
"viewDescription": "Choose whether entries show as cards or as a compact list.",
|
||||
"reorder": {
|
||||
"heading": "Order",
|
||||
"description": "Drag the entries to set the order they appear in.",
|
||||
"empty": "No entries to reorder yet."
|
||||
}
|
||||
},
|
||||
|
||||
@@ -4934,6 +4934,8 @@
|
||||
"bugReportGithubDescription": "Configura el token de issues de GitHub y el repositorio para los informes de errores entrantes.",
|
||||
"bugReportEnabledLabel": "Informes de errores activados",
|
||||
"bugReportEnabledDescription": "Cuando está desactivado, el botón de informe en la app se oculta; el token y el repositorio permanecen configurados.",
|
||||
"bugReportDeprecatedBadge": "Obsoleto",
|
||||
"bugReportDeprecatedNote": "Esta función está obsoleta y se eliminará en una versión futura.",
|
||||
"assistant": {
|
||||
"title": "Superficies del asistente",
|
||||
"description": "Cada conmutador oculta una superficie impulsada por el modelo en toda la aplicación sin retirar la configuración del proveedor. El interruptor maestro fuerza cada opción secundaria a off.",
|
||||
@@ -6473,6 +6475,7 @@
|
||||
"viewDescription": "Elige si las entradas se muestran como tarjetas o como lista compacta.",
|
||||
"reorder": {
|
||||
"heading": "Orden",
|
||||
"description": "Arrastra las entradas para definir el orden en que aparecen.",
|
||||
"empty": "Aún no hay entradas para ordenar."
|
||||
}
|
||||
},
|
||||
|
||||
@@ -4934,6 +4934,8 @@
|
||||
"bugReportGithubDescription": "Configurez le jeton d'issues GitHub et le dépôt pour les rapports de bug entrants.",
|
||||
"bugReportEnabledLabel": "Rapports de bug activés",
|
||||
"bugReportEnabledDescription": "Lorsque cette option est désactivée, le bouton de signalement dans l'app est masqué — le jeton et le dépôt restent configurés.",
|
||||
"bugReportDeprecatedBadge": "Obsolète",
|
||||
"bugReportDeprecatedNote": "Cette fonctionnalité est obsolète et sera supprimée dans une version future.",
|
||||
"assistant": {
|
||||
"title": "Surfaces d'assistance",
|
||||
"description": "Chaque bascule masque une surface pilotée par le modèle dans toute l'application sans retirer la configuration du fournisseur. Le commutateur principal force chaque option secondaire à off.",
|
||||
@@ -6473,6 +6475,7 @@
|
||||
"viewDescription": "Choisissez si les entrées s'affichent en cartes ou en liste compacte.",
|
||||
"reorder": {
|
||||
"heading": "Ordre",
|
||||
"description": "Faites glisser les entrées pour définir leur ordre d'affichage.",
|
||||
"empty": "Aucune entrée à réordonner pour l'instant."
|
||||
}
|
||||
},
|
||||
|
||||
@@ -4934,6 +4934,8 @@
|
||||
"bugReportGithubDescription": "Configura il token delle issue di GitHub e il repository per le segnalazioni di bug in arrivo.",
|
||||
"bugReportEnabledLabel": "Segnalazioni di bug attive",
|
||||
"bugReportEnabledDescription": "Se disattivato, il pulsante di segnalazione nell'app viene nascosto: token e repository restano configurati.",
|
||||
"bugReportDeprecatedBadge": "Obsoleto",
|
||||
"bugReportDeprecatedNote": "Questa funzione è obsoleta e verrà rimossa in una versione futura.",
|
||||
"assistant": {
|
||||
"title": "Superfici dell'assistente",
|
||||
"description": "Ogni interruttore nasconde una superficie guidata dal modello in tutta l'app senza rimuovere la configurazione del provider. Il master forza ogni opzione secondaria su off.",
|
||||
@@ -6473,6 +6475,7 @@
|
||||
"viewDescription": "Scegli se le voci appaiono come schede o come elenco compatto.",
|
||||
"reorder": {
|
||||
"heading": "Ordine",
|
||||
"description": "Trascina le voci per definire l'ordine in cui appaiono.",
|
||||
"empty": "Nessuna voce da riordinare."
|
||||
}
|
||||
},
|
||||
|
||||
@@ -4934,6 +4934,8 @@
|
||||
"bugReportGithubDescription": "Skonfiguruj token zgłoszeń GitHub i repozytorium dla przychodzących zgłoszeń błędów.",
|
||||
"bugReportEnabledLabel": "Zgłaszanie błędów włączone",
|
||||
"bugReportEnabledDescription": "Gdy wyłączone, przycisk zgłaszania w aplikacji jest ukryty — token i repozytorium pozostają skonfigurowane.",
|
||||
"bugReportDeprecatedBadge": "Wycofane",
|
||||
"bugReportDeprecatedNote": "Ta funkcja jest wycofywana i zostanie usunięta w przyszłej wersji.",
|
||||
"assistant": {
|
||||
"title": "Powierzchnie asystenta",
|
||||
"description": "Każdy przełącznik ukrywa powierzchnię opartą na modelu w całej aplikacji bez usuwania konfiguracji dostawcy. Główny wyłącznik wymusza wyłączenie każdej opcji podrzędnej.",
|
||||
@@ -6473,6 +6475,7 @@
|
||||
"viewDescription": "Wybierz, czy pozycje mają być pokazywane jako karty czy jako zwarta lista.",
|
||||
"reorder": {
|
||||
"heading": "Kolejność",
|
||||
"description": "Przeciągnij pozycje, aby ustawić kolejność ich wyświetlania.",
|
||||
"empty": "Brak pozycji do uporządkowania."
|
||||
}
|
||||
},
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useState } from "react";
|
||||
import { Bug, Loader2 } from "lucide-react";
|
||||
import { SettingsCardHeader } from "@/components/settings/_card-header";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
@@ -54,7 +55,26 @@ export function BugReportSection() {
|
||||
<SettingsCardHeader
|
||||
icon={Bug}
|
||||
title={t("admin.bugReportGithub")}
|
||||
description={t("admin.bugReportGithubDescription")}
|
||||
titleAccessory={
|
||||
// v1.18.10 (W7) — the feedback / bug-report feature is deprecated.
|
||||
// The badge + note signal the upcoming removal without pulling the
|
||||
// functionality yet, so existing operators keep the surface working.
|
||||
<Badge
|
||||
variant="outline"
|
||||
data-testid="bug-report-deprecated-badge"
|
||||
className="border-amber-500/50 text-amber-600 dark:text-amber-400"
|
||||
>
|
||||
{t("admin.bugReportDeprecatedBadge")}
|
||||
</Badge>
|
||||
}
|
||||
description={
|
||||
<>
|
||||
<p>{t("admin.bugReportGithubDescription")}</p>
|
||||
<p className="text-amber-600 dark:text-amber-400">
|
||||
{t("admin.bugReportDeprecatedNote")}
|
||||
</p>
|
||||
</>
|
||||
}
|
||||
status={configured ? <ConfiguredBadge /> : null}
|
||||
/>
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { SettingsCard } from "@/components/settings/settings-card";
|
||||
import { SettingsCardHeader } from "@/components/settings/_card-header";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { apiDelete } from "@/lib/api/api-fetch";
|
||||
@@ -83,13 +84,13 @@ export function DangerZoneSection() {
|
||||
});
|
||||
|
||||
return (
|
||||
// v1.18.1 E4 — adopt the shared card shape (rounded-xl border + the
|
||||
// `SettingsCardHeader` icon/title/description/status layout) so the
|
||||
// Danger Zone reads like every other admin section. The destructive
|
||||
// tint + AlertTriangle icon keep it visually flagged as dangerous;
|
||||
// the delete affordance now sits in the header status slot, properly
|
||||
// aligned beside its label rather than floating below a lone icon.
|
||||
<div className="bg-destructive/5 border-destructive/30 rounded-xl border p-4 sm:p-6">
|
||||
// v1.18.10 (W7) — the card SURFACE is neutral like every other admin /
|
||||
// settings section (`SettingsCard`: bg-card + border-border): a red card
|
||||
// edge reads as alarming, not premium. The danger signal lives ONLY in
|
||||
// the destructive delete button + its typed-confirmation dialog, which
|
||||
// are alarm enough. The header keeps the `AlertTriangle` icon so the
|
||||
// section still flags its intent.
|
||||
<SettingsCard>
|
||||
<SettingsCardHeader
|
||||
icon={AlertTriangle}
|
||||
title={t("admin.deleteAllData")}
|
||||
@@ -176,6 +177,6 @@ export function DangerZoneSection() {
|
||||
{wipeMsg}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</SettingsCard>
|
||||
);
|
||||
}
|
||||
@@ -247,15 +247,18 @@ export function BottomNav() {
|
||||
)}
|
||||
>
|
||||
<item.icon className="h-5 w-5 shrink-0" aria-hidden="true" />
|
||||
{/* v1.18.0 — the label must stay inside the tile across all
|
||||
six locales. The longest single-word label (German
|
||||
"Benachrichtigungen") has no natural break opportunity, so
|
||||
`min-w-0` alone is not enough — the unbroken word forces the
|
||||
span past the grid track and overflows the tile. `min-w-0`
|
||||
lets the flex child shrink below its content width,
|
||||
`break-words` introduces a break inside the long word, and
|
||||
the auto-height tile (`min-h-14`) absorbs the second line. */}
|
||||
<span className="min-w-0 text-sm leading-tight font-medium break-words">
|
||||
{/* v1.18.10 (W7) — keep every label on ONE line. The longest
|
||||
single-word label (German "Benachrichtigungen") used to
|
||||
wrap to two lines via `break-words`, which left the tile
|
||||
visually unbalanced beside its single-line neighbours.
|
||||
`min-w-0` lets the flex child shrink below its content
|
||||
width and `truncate` (nowrap + ellipsis) trims the
|
||||
overflow instead of breaking the word. `title` keeps the
|
||||
full label discoverable on hover/long-press. */}
|
||||
<span
|
||||
className="min-w-0 truncate text-sm leading-tight font-medium"
|
||||
title={t(item.tKey)}
|
||||
>
|
||||
{t(item.tKey)}
|
||||
</span>
|
||||
</Link>
|
||||
|
||||
@@ -10,10 +10,14 @@
|
||||
* `useModuleListPrefs("vorsorge")`; the toggles persist through the existing
|
||||
* reminder PATCH route.
|
||||
*/
|
||||
import { ArrowUpDown, LayoutGrid, ListChecks } from "lucide-react";
|
||||
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { SettingsCard } from "@/components/settings/settings-card";
|
||||
import { SettingsCardHeader } from "@/components/settings/_card-header";
|
||||
import { ModuleViewToggle } from "@/components/module-list/module-view-toggle";
|
||||
import {
|
||||
ModuleOrderEditor,
|
||||
@@ -52,82 +56,89 @@ export function VorsorgeSettings() {
|
||||
: t("measurementReminders.selfPlanned"),
|
||||
}));
|
||||
|
||||
// v1.18.10 (W7) — the three Vorsorge settings groups adopt the shared
|
||||
// `SettingsCard` + `SettingsCardHeader` contract (rounded-xl bordered
|
||||
// surface, neutral muted icon, `text-lg` title, short muted description)
|
||||
// so this surface reads like every other Settings section instead of bare
|
||||
// `<section>` blocks with `text-sm` headings.
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<section className="space-y-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<h2 className="text-sm font-semibold">
|
||||
{t("moduleList.viewHeading")}
|
||||
</h2>
|
||||
<ModuleViewToggle view={prefs.view} onChange={setView} />
|
||||
</div>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("moduleList.viewDescription")}
|
||||
</p>
|
||||
</section>
|
||||
<div className="space-y-6">
|
||||
<SettingsCard>
|
||||
<SettingsCardHeader
|
||||
icon={LayoutGrid}
|
||||
title={t("moduleList.viewHeading")}
|
||||
description={t("moduleList.viewDescription")}
|
||||
status={<ModuleViewToggle view={prefs.view} onChange={setView} />}
|
||||
/>
|
||||
</SettingsCard>
|
||||
|
||||
<section className="space-y-3">
|
||||
<h2 className="text-sm font-semibold">
|
||||
{t("moduleList.reorder.heading")}
|
||||
</h2>
|
||||
{isLoading ? (
|
||||
<div className="space-y-2">
|
||||
{Array.from({ length: 3 }, (_, i) => (
|
||||
<Skeleton key={i} className="h-12 w-full" />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<ModuleOrderEditor items={reorderItems} onChange={setOrder} />
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="space-y-3">
|
||||
<h2 className="text-sm font-semibold">
|
||||
{t("measurementReminders.manage.title")}
|
||||
</h2>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("measurementReminders.manage.description")}
|
||||
</p>
|
||||
{isLoading ? (
|
||||
<Skeleton className="h-24 w-full" />
|
||||
) : (reminders?.length ?? 0) === 0 ? (
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("measurementReminders.manage.empty")}
|
||||
</p>
|
||||
) : (
|
||||
<Card>
|
||||
<CardContent className="divide-border divide-y p-0">
|
||||
{ordered.map((reminder) => (
|
||||
<div
|
||||
key={reminder.id}
|
||||
className="flex items-center justify-between gap-3 px-4 py-3"
|
||||
>
|
||||
<span
|
||||
id={`vorsorge-reminder-label-${reminder.id}`}
|
||||
className="min-w-0 truncate text-sm"
|
||||
>
|
||||
{resolveLabel(reminder, t)}
|
||||
</span>
|
||||
<Switch
|
||||
checked={reminder.enabled}
|
||||
onCheckedChange={(next) =>
|
||||
update.mutate({
|
||||
id: reminder.id,
|
||||
body: { enabled: next },
|
||||
})
|
||||
}
|
||||
disabled={update.isPending}
|
||||
// L3 — label each row's Switch by its own name span so SR
|
||||
// users hear the reminder name (plus the switch role's
|
||||
// on/off state) instead of N identical generic labels.
|
||||
aria-labelledby={`vorsorge-reminder-label-${reminder.id}`}
|
||||
/>
|
||||
</div>
|
||||
<SettingsCard>
|
||||
<SettingsCardHeader
|
||||
icon={ArrowUpDown}
|
||||
title={t("moduleList.reorder.heading")}
|
||||
description={t("moduleList.reorder.description")}
|
||||
/>
|
||||
<div className="mt-4 pl-7">
|
||||
{isLoading ? (
|
||||
<div className="space-y-2">
|
||||
{Array.from({ length: 3 }, (_, i) => (
|
||||
<Skeleton key={i} className="h-12 w-full" />
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
) : (
|
||||
<ModuleOrderEditor items={reorderItems} onChange={setOrder} />
|
||||
)}
|
||||
</div>
|
||||
</SettingsCard>
|
||||
|
||||
<SettingsCard>
|
||||
<SettingsCardHeader
|
||||
icon={ListChecks}
|
||||
title={t("measurementReminders.manage.title")}
|
||||
description={t("measurementReminders.manage.description")}
|
||||
/>
|
||||
<div className="mt-4 pl-7">
|
||||
{isLoading ? (
|
||||
<Skeleton className="h-24 w-full" />
|
||||
) : (reminders?.length ?? 0) === 0 ? (
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("measurementReminders.manage.empty")}
|
||||
</p>
|
||||
) : (
|
||||
<Card>
|
||||
<CardContent className="divide-border divide-y p-0">
|
||||
{ordered.map((reminder) => (
|
||||
<div
|
||||
key={reminder.id}
|
||||
className="flex items-center justify-between gap-3 px-4 py-3"
|
||||
>
|
||||
<span
|
||||
id={`vorsorge-reminder-label-${reminder.id}`}
|
||||
className="min-w-0 truncate text-sm"
|
||||
>
|
||||
{resolveLabel(reminder, t)}
|
||||
</span>
|
||||
<Switch
|
||||
checked={reminder.enabled}
|
||||
onCheckedChange={(next) =>
|
||||
update.mutate({
|
||||
id: reminder.id,
|
||||
body: { enabled: next },
|
||||
})
|
||||
}
|
||||
disabled={update.isPending}
|
||||
// L3 — label each row's Switch by its own name span so SR
|
||||
// users hear the reminder name (plus the switch role's
|
||||
// on/off state) instead of N identical generic labels.
|
||||
aria-labelledby={`vorsorge-reminder-label-${reminder.id}`}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</SettingsCard>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
+31
-24
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in new issue
Block a user