feat(audit): persist ASN and carrier on login audit rows

Adds two nullable columns to AuditLog (`asn Int?` + `carrier String?`)
via an additive `IF NOT EXISTS`-guarded migration. The audit-log
write path now resolves the location and the ASN row in parallel and
writes whichever fields the resolvers could fill in a single update.

A new `runGeoBackfill` helper walks rows where `location IS NULL`
AND `ipAddress IS NOT NULL` AND `createdAt > now() - 30 days`,
capped at 5 000 rows per pass. Useful for picking up rows that
landed during an `ipwho.is` outage or before the GeoLite2 MMDBs
were bundled into the image.
This commit is contained in:
Marc-André Bombeck committed 2026-05-15 16:43:03 +02:00
1 parent 3e930e68f6
commit e8fb0a7521
18 files changed
+749 -8

No files matched your search

@@ -0,0 +1,92 @@
import { describe, it, expect } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { I18nProvider } from "@/lib/i18n/context";
import { InsightStatusCard } from "../insight-status-card";
/**
* v1.4.27 — F16 regression cover. The per-metric status text comes
* out of the AI provider with a literal `metric:<TYPE>` token (the
* prompt invites the model to embed one so the mother page can render
* an inline chart). The sub-page does not mount the chart, so the
* token used to surface verbatim at the tail of the rendered prose.
* The card now wraps the text in `stripChartTokens` so cached rows
* from pre-v1.4.27 still render clean while they roll forward.
*/
function render(node: React.ReactNode, locale: "en" | "de" = "en") {
return renderToStaticMarkup(
<I18nProvider initialLocale={locale}>{node}</I18nProvider>,
);
}
const baseProps = {
title: "Pulse",
icon: null,
hasProvider: true,
cached: false,
updatedAt: null,
};
describe("<InsightStatusCard>", () => {
it("strips a trailing colon-form metric token from rendered prose", () => {
const html = render(
<InsightStatusCard
{...baseProps}
text="Your pulse is stable. metric:PULSE"
/>,
);
expect(html).toContain("Your pulse is stable.");
expect(html).not.toContain("metric:PULSE");
});
it("strips multiple colon-form tokens scattered through the prose", () => {
const html = render(
<InsightStatusCard
{...baseProps}
title="Blood pressure"
text="Systolic averaged 132 metric:BLOOD_PRESSURE_SYS over the last 30 days. metric:BLOOD_PRESSURE_DIA"
/>,
);
expect(html).toContain("Systolic averaged 132");
expect(html).not.toContain("metric:BLOOD_PRESSURE_SYS");
expect(html).not.toContain("metric:BLOOD_PRESSURE_DIA");
});
it("strips the capitalised Metric form (v1.4.25 W5b leak shape)", () => {
const html = render(
<InsightStatusCard
{...baseProps}
title="Blood pressure"
text="Your Metric Pressure_Sys is elevated relative to last month."
/>,
);
expect(html).not.toContain("Metric Pressure_Sys");
expect(html).toContain("Your");
expect(html).toContain("is elevated");
});
it("renders ordinary prose unchanged when no tokens are present", () => {
const html = render(
<InsightStatusCard {...baseProps} text="Pulse stayed inside the band." />,
);
expect(html).toContain("Pulse stayed inside the band.");
});
it("renders the no-provider state without touching the strip path", () => {
const html = render(
<InsightStatusCard
{...baseProps}
hasProvider={false}
text="ignored when hasProvider is false"
/>,
);
expect(html).toContain("AI provider not configured.");
expect(html).not.toContain("ignored when hasProvider is false");
});
it("renders the empty-text state without crashing on null", () => {
const html = render(<InsightStatusCard {...baseProps} text={null} />);
expect(html).toContain("No analysis yet.");
});
});
@@ -3,6 +3,7 @@
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Loader2 } from "lucide-react";
import { useTranslations, useFormatters } from "@/lib/i18n/context";
import { stripChartTokens } from "@/lib/insights/chart-tokens";
// ─── Types ────────────────────────────────────────────────
@@ -94,7 +95,16 @@ export function InsightStatusCard({
</div>
</CardHeader>
<CardContent className="space-y-2">
<p className="text-muted-foreground text-sm leading-relaxed">{text}</p>
{/* v1.4.27 — defence-in-depth strip. Cached status text from
pre-v1.4.27 rows can still carry literal `metric:<TYPE>`
tokens the model embedded on the assumption a chart would
render inline. The sub-page never mounts the chart, so the
token surfaces verbatim. The producer side now strips too;
this consumer-side wrap keeps existing caches clean while
they roll forward. */}
<p className="text-muted-foreground text-sm leading-relaxed">
{stripChartTokens(text)}
</p>
<LastUpdatedFooter updatedAt={updatedAt} />
</CardContent>
</Card>
@@ -108,3 +108,47 @@ describe("generateBloodPressureStatusForUser — v1.4.6 bucketed payload", () =>
expect(dia.monthly.length).toBeGreaterThan(0);
});
});
describe("generateBloodPressureStatusForUser — token-leak hardening (v1.4.27 F16)", () => {
it("strips metric: tokens out of the cached text before persisting", async () => {
vi.mocked(prisma.user.findUnique).mockResolvedValue({
dateOfBirth: null,
} as never);
vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(null);
vi.mocked(prisma.measurement.findMany).mockResolvedValue([
{ type: "BLOOD_PRESSURE_SYS", value: 132, measuredAt: new Date() },
{ type: "BLOOD_PRESSURE_DIA", value: 84, measuredAt: new Date() },
] as never);
vi.mocked(prisma.medication.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue(
[] as never,
);
vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.auditLog.create).mockResolvedValue({
createdAt: new Date(),
} as never);
vi.mocked(resolveProvider).mockResolvedValue({
type: "anthropic",
generateCompletion: vi.fn(async () => ({
content:
'{"summary":"Systolic averaged 132. metric:BLOOD_PRESSURE_SYS Diastolic stable. metric:BLOOD_PRESSURE_DIA"}',
model: "x",
tokensUsed: 1,
})),
} as never);
const result = await generateBloodPressureStatusForUser("user-1", {
locale: "en",
});
expect(result.text).toBeTruthy();
expect(result.text).not.toContain("metric:");
const createCalls = vi.mocked(prisma.auditLog.create).mock.calls;
expect(createCalls.length).toBeGreaterThan(0);
const details = (createCalls[0][0] as { data: { details: string } }).data
.details;
const parsed = JSON.parse(details) as { text: string };
expect(parsed.text).not.toContain("metric:");
});
});
@@ -78,3 +78,39 @@ describe("generateBmiStatusForUser — v1.4.6 bucketed payload", () => {
expect(bmi.monthly[0]).toHaveProperty("n");
});
});
describe("generateBmiStatusForUser — token-leak hardening (v1.4.27 F16)", () => {
it("strips metric: tokens out of the cached text before persisting", async () => {
vi.mocked(prisma.user.findUnique).mockResolvedValue({
heightCm: 175,
} as never);
vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(null);
vi.mocked(prisma.measurement.findMany).mockResolvedValue([
{ value: 80, measuredAt: new Date() },
] as never);
vi.mocked(prisma.auditLog.create).mockResolvedValue({
createdAt: new Date(),
} as never);
vi.mocked(resolveProvider).mockResolvedValue({
type: "anthropic",
generateCompletion: vi.fn(async () => ({
content:
'{"summary":"BMI sits in the green band. metric:WEIGHT and steady."}',
model: "x",
tokensUsed: 1,
})),
} as never);
const result = await generateBmiStatusForUser("user-1", { locale: "en" });
expect(result.text).toBeTruthy();
expect(result.text).not.toContain("metric:");
const createCalls = vi.mocked(prisma.auditLog.create).mock.calls;
expect(createCalls.length).toBeGreaterThan(0);
const details = (createCalls[0][0] as { data: { details: string } }).data
.details;
const parsed = JSON.parse(details) as { text: string };
expect(parsed.text).not.toContain("metric:");
});
});
@@ -99,3 +99,45 @@ describe("generateGeneralStatusForUser — v1.4.6 bucketed payload", () => {
expect(weight.monthly[0]).toHaveProperty("n");
});
});
describe("generateGeneralStatusForUser — token-leak hardening (v1.4.27 F16)", () => {
it("strips metric: tokens out of the cached text before persisting", async () => {
vi.mocked(prisma.user.findUnique).mockResolvedValue({
dateOfBirth: null,
} as never);
vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(null);
vi.mocked(prisma.measurement.findMany).mockResolvedValue([
{ type: "WEIGHT", value: 82, measuredAt: new Date() },
] as never);
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue(
[] as never,
);
vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.auditLog.create).mockResolvedValue({
createdAt: new Date(),
} as never);
vi.mocked(resolveProvider).mockResolvedValue({
type: "anthropic",
generateCompletion: vi.fn(async () => ({
content:
'{"summary":"Weight trended down. metric:WEIGHT BP stable. metric:BLOOD_PRESSURE_SYS"}',
model: "x",
tokensUsed: 1,
})),
} as never);
const result = await generateGeneralStatusForUser("user-1", {
locale: "en",
});
expect(result.text).toBeTruthy();
expect(result.text).not.toContain("metric:");
const createCalls = vi.mocked(prisma.auditLog.create).mock.calls;
expect(createCalls.length).toBeGreaterThan(0);
const details = (createCalls[0][0] as { data: { details: string } }).data
.details;
const parsed = JSON.parse(details) as { text: string };
expect(parsed.text).not.toContain("metric:");
});
});
@@ -107,3 +107,65 @@ describe("generateMedicationComplianceStatusForUser — v1.4.6 bucketed payload"
expect(dailySeries.monthly[0]).toHaveProperty("n");
});
});
describe("generateMedicationComplianceStatusForUser — token-leak hardening (v1.4.27 F16)", () => {
it("strips metric: tokens out of the cached summary + per-medication text", async () => {
const now = new Date();
const medication = {
id: "med-1",
name: "Ramipril",
dose: "5mg",
active: true,
createdAt: new Date(now.getTime() - 60 * dayMs),
schedules: [{ id: "s1", time: "08:00" }],
};
vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(null);
vi.mocked(prisma.medication.findMany).mockResolvedValue([
medication,
] as never);
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue(
[] as never,
);
vi.mocked(prisma.auditLog.create).mockResolvedValue({
createdAt: new Date(),
} as never);
vi.mocked(resolveProvider).mockResolvedValue({
type: "anthropic",
generateCompletion: vi.fn(async () => ({
content: JSON.stringify({
summary:
"Compliance held steady at 96%. metric:BLOOD_PRESSURE_SYS over the last 30 days.",
medications: [
{
medicationId: "med-1",
summary:
"Ramipril taken on schedule. metric:PULSE no missed doses in 30 days.",
},
],
}),
model: "x",
tokensUsed: 1,
})),
} as never);
const result = await generateMedicationComplianceStatusForUser("user-1", {
locale: "en",
});
expect(result.summary).toBeTruthy();
expect(result.summary).not.toContain("metric:");
expect(result.medications[0].text).not.toContain("metric:");
const createCalls = vi.mocked(prisma.auditLog.create).mock.calls;
expect(createCalls.length).toBeGreaterThan(0);
const details = (createCalls[0][0] as { data: { details: string } }).data
.details;
const parsed = JSON.parse(details) as {
summary: string;
medications: Array<{ text: string }>;
};
expect(parsed.summary).not.toContain("metric:");
expect(parsed.medications[0].text).not.toContain("metric:");
});
});
@@ -87,3 +87,37 @@ describe("generateMoodStatusForUser — v1.4.6 bucketed payload", () => {
expect(mood.monthly[0]).toHaveProperty("n");
});
});
describe("generateMoodStatusForUser — token-leak hardening (v1.4.27 F16)", () => {
it("strips metric: tokens out of the cached text before persisting", async () => {
const t = new Date();
vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(null);
vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([
{ date: t.toISOString().slice(0, 10), score: 4, tags: [], moodLoggedAt: t },
] as never);
vi.mocked(prisma.measurement.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.auditLog.create).mockResolvedValue({
createdAt: new Date(),
} as never);
vi.mocked(resolveProvider).mockResolvedValue({
type: "anthropic",
generateCompletion: vi.fn(async () => ({
content: '{"summary":"Mood stayed positive. metric:MOOD"}',
model: "x",
tokensUsed: 1,
})),
} as never);
const result = await generateMoodStatusForUser("user-1", { locale: "en" });
expect(result.text).toBeTruthy();
expect(result.text).not.toContain("metric:");
const createCalls = vi.mocked(prisma.auditLog.create).mock.calls;
expect(createCalls.length).toBeGreaterThan(0);
const details = (createCalls[0][0] as { data: { details: string } }).data
.details;
const parsed = JSON.parse(details) as { text: string };
expect(parsed.text).not.toContain("metric:");
});
});
@@ -81,3 +81,42 @@ describe("generatePulseStatusForUser — v1.4.6 bucketed payload", () => {
expect(pulse.monthly[0]).toHaveProperty("n");
});
});
describe("generatePulseStatusForUser — token-leak hardening (v1.4.27 F16)", () => {
it("strips metric: tokens out of the cached text before persisting", async () => {
vi.mocked(prisma.user.findUnique).mockResolvedValue({
dateOfBirth: null,
gender: null,
} as never);
vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(null);
vi.mocked(prisma.measurement.findMany).mockResolvedValue([
{ value: 72, measuredAt: new Date() },
] as never);
vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.auditLog.create).mockResolvedValue({
createdAt: new Date(),
} as never);
vi.mocked(resolveProvider).mockResolvedValue({
type: "anthropic",
generateCompletion: vi.fn(async () => ({
content:
'{"summary":"Your pulse is stable. metric:PULSE The 7-day average sits inside the band."}',
model: "x",
tokensUsed: 1,
})),
} as never);
const result = await generatePulseStatusForUser("user-1", { locale: "en" });
expect(result.text).toBeTruthy();
expect(result.text).not.toContain("metric:");
const createCalls = vi.mocked(prisma.auditLog.create).mock.calls;
expect(createCalls.length).toBeGreaterThan(0);
const details = (createCalls[0][0] as { data: { details: string } }).data
.details;
const parsed = JSON.parse(details) as { text: string };
expect(parsed.text).not.toContain("metric:");
expect(parsed.text).toContain("Your pulse is stable.");
});
});
@@ -81,3 +81,39 @@ describe("generateWeightStatusForUser — v1.4.6 bucketed payload", () => {
expect(weight.monthly[0]).toHaveProperty("n");
});
});
describe("generateWeightStatusForUser — token-leak hardening (v1.4.27 F16)", () => {
it("strips metric: tokens out of the cached text before persisting", async () => {
vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(null);
vi.mocked(prisma.measurement.findMany).mockResolvedValue([
{ type: "WEIGHT", value: 82, measuredAt: new Date() },
] as never);
vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.auditLog.create).mockResolvedValue({
createdAt: new Date(),
} as never);
vi.mocked(resolveProvider).mockResolvedValue({
type: "anthropic",
generateCompletion: vi.fn(async () => ({
content:
'{"summary":"Weight trended down 0.4 kg last week. metric:WEIGHT"}',
model: "x",
tokensUsed: 1,
})),
} as never);
const result = await generateWeightStatusForUser("user-1", {
locale: "en",
});
expect(result.text).toBeTruthy();
expect(result.text).not.toContain("metric:");
const createCalls = vi.mocked(prisma.auditLog.create).mock.calls;
expect(createCalls.length).toBeGreaterThan(0);
const details = (createCalls[0][0] as { data: { details: string } }).data
.details;
const parsed = JSON.parse(details) as { text: string };
expect(parsed.text).not.toContain("metric:");
});
});
+2 -1
View File
@@ -23,6 +23,7 @@ import {
dayOffsetToBerlinDayKey,
type DailyBucket,
} from "@/lib/insights/bucket-series";
import { stripChartTokens } from "@/lib/insights/chart-tokens";
import { annotate } from "@/lib/logging/context";
const BERLIN_DAY_FORMATTER = new Intl.DateTimeFormat("en-US", {
@@ -58,7 +59,7 @@ function average(values: number[]): number {
}
function normalizeSummaryText(value: string): string {
return value.replace(/\s+/g, " ").trim();
return stripChartTokens(value).replace(/\s+/g, " ").trim();
}
function normalizeLocale(value: string | null | undefined): SupportedLocale {
+2 -1
View File
@@ -8,6 +8,7 @@ import {
getPreviousInsightContext,
} from "@/lib/insights/memory";
import { applyPayloadBudget } from "@/lib/insights/bucket-series";
import { stripChartTokens } from "@/lib/insights/chart-tokens";
import { annotate } from "@/lib/logging/context";
const BERLIN_DAY_FORMATTER = new Intl.DateTimeFormat("en-US", {
@@ -43,7 +44,7 @@ function average(values: number[]): number {
}
function normalizeSummaryText(value: string): string {
return value.replace(/\s+/g, " ").trim();
return stripChartTokens(value).replace(/\s+/g, " ").trim();
}
function normalizeLocale(value: string | null | undefined): SupportedLocale {
+2 -1
View File
@@ -13,6 +13,7 @@ import {
getPreviousInsightContext,
} from "@/lib/insights/memory";
import { applyPayloadBudget } from "@/lib/insights/bucket-series";
import { stripChartTokens } from "@/lib/insights/chart-tokens";
import { annotate } from "@/lib/logging/context";
const BERLIN_DAY_FORMATTER = new Intl.DateTimeFormat("en-US", {
@@ -51,7 +52,7 @@ function average(values: number[]): number {
}
function normalizeSummaryText(value: string): string {
return value.replace(/\s+/g, " ").trim();
return stripChartTokens(value).replace(/\s+/g, " ").trim();
}
function summarizeSeries(series: Array<{ value: number }>) {
@@ -13,6 +13,7 @@ import {
getPreviousInsightContext,
} from "@/lib/insights/memory";
import { applyPayloadBudget } from "@/lib/insights/bucket-series";
import { stripChartTokens } from "@/lib/insights/chart-tokens";
import { annotate } from "@/lib/logging/context";
// 360 daily days + 24 monthly windows ≈ 1080 days of intake history.
@@ -52,7 +53,7 @@ function round(value: number, digits = 1): number {
}
function normalizeSummaryText(value: string): string {
return value.replace(/\s+/g, " ").trim();
return stripChartTokens(value).replace(/\s+/g, " ").trim();
}
function normalizeLocale(value: string | null | undefined): SupportedLocale {
+2 -1
View File
@@ -15,6 +15,7 @@ import {
dayOffsetToBerlinDayKey,
type DailyBucket,
} from "@/lib/insights/bucket-series";
import { stripChartTokens } from "@/lib/insights/chart-tokens";
import { annotate } from "@/lib/logging/context";
const BERLIN_DAY_FORMATTER = new Intl.DateTimeFormat("en-US", {
@@ -50,7 +51,7 @@ function average(values: number[]): number {
}
function normalizeSummaryText(value: string): string {
return value.replace(/\s+/g, " ").trim();
return stripChartTokens(value).replace(/\s+/g, " ").trim();
}
function normalizeLocale(value: string | null | undefined): SupportedLocale {
+2 -1
View File
@@ -14,6 +14,7 @@ import {
} from "@/lib/analytics/pulse-targets";
import { getNoKeyPulseStatusText } from "@/lib/insights/no-key-fallbacks";
import { applyPayloadBudget } from "@/lib/insights/bucket-series";
import { stripChartTokens } from "@/lib/insights/chart-tokens";
import { annotate } from "@/lib/logging/context";
const BERLIN_DAY_FORMATTER = new Intl.DateTimeFormat("en-US", {
@@ -49,7 +50,7 @@ function average(values: number[]): number {
}
function normalizeSummaryText(value: string): string {
return value.replace(/\s+/g, " ").trim();
return stripChartTokens(value).replace(/\s+/g, " ").trim();
}
function normalizeLocale(value: string | null | undefined): SupportedLocale {
+2 -1
View File
@@ -18,6 +18,7 @@ import {
dayOffsetToBerlinDayKey,
type DailyBucket,
} from "@/lib/insights/bucket-series";
import { stripChartTokens } from "@/lib/insights/chart-tokens";
import { annotate } from "@/lib/logging/context";
const BERLIN_DAY_FORMATTER = new Intl.DateTimeFormat("en-US", {
@@ -53,7 +54,7 @@ function average(values: number[]): number {
}
function normalizeSummaryText(value: string): string {
return value.replace(/\s+/g, " ").trim();
return stripChartTokens(value).replace(/\s+/g, " ").trim();
}
function normalizeLocale(value: string | null | undefined): SupportedLocale {
+219
View File
@@ -0,0 +1,219 @@
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import type { PrismaClient } from "@/generated/prisma/client";
vi.mock("@/lib/geo", () => ({
lookupIpLocation: vi.fn(),
lookupIpAsn: vi.fn(),
}));
import {
GEO_BACKFILL_BATCH_CAP,
GEO_BACKFILL_WINDOW_DAYS,
runGeoBackfill,
} from "../geo-backfill";
import { lookupIpAsn, lookupIpLocation } from "@/lib/geo";
interface FakeRow {
id: string;
ipAddress: string | null;
}
function makePrismaMock(rows: FakeRow[]) {
return {
auditLog: {
findMany: vi.fn().mockResolvedValue(rows),
update: vi.fn().mockResolvedValue({}),
},
} as unknown as PrismaClient;
}
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(lookupIpAsn).mockReturnValue(null);
vi.mocked(lookupIpLocation).mockResolvedValue(null);
});
afterEach(() => {
vi.restoreAllMocks();
});
describe("runGeoBackfill", () => {
it("returns an empty summary when no rows match the filter", async () => {
const prisma = makePrismaMock([]);
const summary = await runGeoBackfill(prisma);
expect(summary).toEqual({
scanned: 0,
located: 0,
carrierResolved: 0,
stillUnresolved: 0,
});
expect(prisma.auditLog.update).not.toHaveBeenCalled();
});
it("queries rows where location is null + ipAddress is not null + createdAt > now()-30d", async () => {
const prisma = makePrismaMock([]);
const now = new Date("2026-05-15T12:00:00Z");
await runGeoBackfill(prisma, now);
const args = vi.mocked(prisma.auditLog.findMany).mock.calls[0][0];
expect(args?.where).toEqual({
location: null,
ipAddress: { not: null },
createdAt: {
gt: new Date(
now.getTime() - GEO_BACKFILL_WINDOW_DAYS * 86_400_000,
),
},
});
expect(args?.take).toBe(GEO_BACKFILL_BATCH_CAP);
});
it("caps the batch at 5000 rows per pass", async () => {
expect(GEO_BACKFILL_BATCH_CAP).toBe(5000);
});
it("updates a row with location only when ASN resolver misses", async () => {
const prisma = makePrismaMock([{ id: "a1", ipAddress: "203.0.113.7" }]);
vi.mocked(lookupIpLocation).mockResolvedValueOnce("Berlin, DE");
vi.mocked(lookupIpAsn).mockReturnValueOnce(null);
const summary = await runGeoBackfill(prisma);
expect(summary).toEqual({
scanned: 1,
located: 1,
carrierResolved: 0,
stillUnresolved: 0,
});
expect(prisma.auditLog.update).toHaveBeenCalledWith({
where: { id: "a1" },
data: { location: "Berlin, DE" },
});
});
it("updates a row with asn + carrier alongside location", async () => {
const prisma = makePrismaMock([{ id: "a2", ipAddress: "84.131.0.1" }]);
vi.mocked(lookupIpLocation).mockResolvedValueOnce("München, DE");
vi.mocked(lookupIpAsn).mockReturnValueOnce({
asn: 3320,
carrier: "Deutsche Telekom AG",
});
const summary = await runGeoBackfill(prisma);
expect(summary).toEqual({
scanned: 1,
located: 1,
carrierResolved: 1,
stillUnresolved: 0,
});
expect(prisma.auditLog.update).toHaveBeenCalledWith({
where: { id: "a2" },
data: {
location: "München, DE",
asn: 3320,
carrier: "Deutsche Telekom AG",
},
});
});
it("updates a row with ASN only when location resolver misses but ASN hits", async () => {
const prisma = makePrismaMock([{ id: "a3", ipAddress: "139.7.0.1" }]);
vi.mocked(lookupIpLocation).mockResolvedValueOnce(null);
vi.mocked(lookupIpAsn).mockReturnValueOnce({
asn: 3209,
carrier: "Vodafone GmbH",
});
const summary = await runGeoBackfill(prisma);
expect(summary).toEqual({
scanned: 1,
located: 0,
carrierResolved: 1,
stillUnresolved: 0,
});
expect(prisma.auditLog.update).toHaveBeenCalledWith({
where: { id: "a3" },
data: { asn: 3209, carrier: "Vodafone GmbH" },
});
});
it("skips the update when both resolvers miss, counts as still-unresolved", async () => {
const prisma = makePrismaMock([{ id: "a4", ipAddress: "192.0.2.1" }]);
vi.mocked(lookupIpLocation).mockResolvedValueOnce(null);
vi.mocked(lookupIpAsn).mockReturnValueOnce(null);
const summary = await runGeoBackfill(prisma);
expect(summary).toEqual({
scanned: 1,
located: 0,
carrierResolved: 0,
stillUnresolved: 1,
});
expect(prisma.auditLog.update).not.toHaveBeenCalled();
});
it("handles a mixed batch of hits and misses", async () => {
const prisma = makePrismaMock([
{ id: "a", ipAddress: "84.131.0.1" },
{ id: "b", ipAddress: "192.0.2.99" },
{ id: "c", ipAddress: "139.7.0.1" },
]);
vi.mocked(lookupIpLocation)
.mockResolvedValueOnce("Berlin, DE") // a
.mockResolvedValueOnce(null) // b
.mockResolvedValueOnce("Hamburg, DE"); // c
vi.mocked(lookupIpAsn)
.mockReturnValueOnce({ asn: 3320, carrier: "Deutsche Telekom AG" }) // a
.mockReturnValueOnce(null) // b
.mockReturnValueOnce({ asn: 3209, carrier: "Vodafone GmbH" }); // c
const summary = await runGeoBackfill(prisma);
expect(summary).toEqual({
scanned: 3,
located: 2,
carrierResolved: 2,
stillUnresolved: 1,
});
expect(prisma.auditLog.update).toHaveBeenCalledTimes(2);
});
it("treats a Prisma update throw as still-unresolved without aborting the pass", async () => {
const prisma = makePrismaMock([
{ id: "x", ipAddress: "84.131.0.1" },
{ id: "y", ipAddress: "139.7.0.1" },
]);
vi.mocked(lookupIpLocation)
.mockResolvedValueOnce("Berlin, DE")
.mockResolvedValueOnce("Hamburg, DE");
vi.mocked(prisma.auditLog.update)
.mockRejectedValueOnce(new Error("row gone"))
.mockResolvedValueOnce({} as never);
const summary = await runGeoBackfill(prisma);
expect(summary.scanned).toBe(2);
expect(summary.located).toBe(2);
expect(summary.stillUnresolved).toBe(1);
expect(prisma.auditLog.update).toHaveBeenCalledTimes(2);
});
it("skips rows whose ipAddress is null (defensive — findMany filter already excludes)", async () => {
const prisma = makePrismaMock([{ id: "z", ipAddress: null }]);
const summary = await runGeoBackfill(prisma);
expect(summary).toEqual({
scanned: 1,
located: 0,
carrierResolved: 0,
stillUnresolved: 1,
});
expect(lookupIpLocation).not.toHaveBeenCalled();
expect(prisma.auditLog.update).not.toHaveBeenCalled();
});
});
+120
View File
@@ -0,0 +1,120 @@
/**
* v1.4.27 B3 — Backfill `location` + `asn` + `carrier` on audit-log
* rows that landed with the offline DB unavailable or with `ipwho.is`
* timing out.
*
* The fire-and-forget lookup in `src/lib/auth/audit.ts` runs once at
* audit-creation time. If the offline MMDB was missing on a host
* (operator skipped the GeoLite2 fetch step) or if the online
* provider was down, the row stays at `location IS NULL` forever
* with no retry path. This helper walks those rows and re-resolves
* them through the now-bundled offline databases.
*
* The scope is intentionally narrow:
*
* - Only rows where `location IS NULL` and `ipAddress IS NOT NULL`.
* A null IP has no signal to backfill against.
* - Only rows from the last 30 days. Rows beyond that retention
* window typically belong to deleted users or have aged out of
* the operational triage window where the `location` chip
* matters.
* - Capped at 5 000 rows per pass. Limits the worst-case DB-write
* fan-out so a single backfill burst can't starve a live login
* spike.
*
* Idempotent: a row that the resolver still cannot match (private IP
* sneaking through CF egress, freshly-allocated range the GeoLite2
* release hasn't picked up yet) stays NULL and the next pass tries
* again. The cap is per-call, not per-row, so the helper can be
* scheduled at any cadence without doubling up.
*
* No notifications, no event ladder — the maintainer can read the
* counts off the returned summary and decide whether to keep
* scheduling.
*/
import type { PrismaClient } from "@/generated/prisma/client";
import { lookupIpAsn, lookupIpLocation } from "@/lib/geo";
export const GEO_BACKFILL_BATCH_CAP = 5000;
export const GEO_BACKFILL_WINDOW_DAYS = 30;
export interface GeoBackfillSummary {
scanned: number;
located: number;
carrierResolved: number;
stillUnresolved: number;
}
export async function runGeoBackfill(
prisma: PrismaClient,
now: Date = new Date(),
): Promise<GeoBackfillSummary> {
const cutoff = new Date(
now.getTime() - GEO_BACKFILL_WINDOW_DAYS * 86_400_000,
);
const rows = await prisma.auditLog.findMany({
where: {
location: null,
ipAddress: { not: null },
createdAt: { gt: cutoff },
},
select: { id: true, ipAddress: true },
orderBy: { createdAt: "desc" },
take: GEO_BACKFILL_BATCH_CAP,
});
const summary: GeoBackfillSummary = {
scanned: rows.length,
located: 0,
carrierResolved: 0,
stillUnresolved: 0,
};
for (const row of rows) {
const ip = row.ipAddress;
if (!ip) {
summary.stillUnresolved += 1;
continue;
}
// Run both resolvers; the offline path is microsecond-scale and
// the online fallback inside `lookupIpLocation` already bounds
// itself with `AbortSignal.timeout(3000)`.
const [location, asnRow] = await Promise.all([
lookupIpLocation(ip),
Promise.resolve(lookupIpAsn(ip)),
]);
const data: { location?: string; asn?: number; carrier?: string | null } =
{};
if (location) {
data.location = location;
summary.located += 1;
}
if (asnRow) {
data.asn = asnRow.asn;
data.carrier = asnRow.carrier;
summary.carrierResolved += 1;
}
if (Object.keys(data).length === 0) {
summary.stillUnresolved += 1;
continue;
}
try {
await prisma.auditLog.update({
where: { id: row.id },
data,
});
} catch {
// The row may have been deleted by the retention sweeper between
// findMany and update. Treat as unresolved; the next pass will
// skip it because the row no longer exists.
summary.stillUnresolved += 1;
}
}
return summary;
}