mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-07 04:12:04 +00:00
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:
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:");
|
||||
});
|
||||
});
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user