mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +00:00
feat(audit): wire ASN+carrier into the audit-log create/update path
Completes the v1.4.27 B3 audit-log work that landed in e8fb0a75. The
prior commit shipped the backfill helper but the schema + migration
+ audit.ts write-path were dropped during a concurrent staging
window. This commit puts them back.
`prisma/schema.prisma` grows two nullable columns on AuditLog (`asn`
+ `carrier`), the matching idempotent `IF NOT EXISTS`-guarded
migration lands at `0061_audit_log_carrier`, and the auditLog helper
resolves location + ASN row in parallel (single 3 s race, single
`prisma.auditLog.update` carries whichever fields the resolvers
filled).
Test coverage: five new cases on audit.test.ts cover all four
combinations of location/ASN hit-or-miss plus a row with no
carrier organisation.
This commit is contained in:
1 parent
c0ec1fecfb
commit
1d8b80698e
4 files changed
+156
-10
No files matched your search
@@ -0,0 +1,20 @@
|
||||
-- v1.4.27 B3 — Persist the ASN + carrier on login audit rows.
|
||||
--
|
||||
-- The bundled MaxMind GeoLite2-ASN MMDB resolves an autonomous-system
|
||||
-- number and the organisation string ("Deutsche Telekom AG") at
|
||||
-- audit-creation time. Both columns are nullable so the schema stays
|
||||
-- valid for older rows, for private/loopback IPs that skip the
|
||||
-- lookup entirely, and for offline-miss rows that the resolver could
|
||||
-- not match.
|
||||
--
|
||||
-- asn — INTEGER, the autonomous-system number from the MMDB.
|
||||
-- carrier — TEXT, the autonomous-system organisation string.
|
||||
--
|
||||
-- The `IF NOT EXISTS` guards make the migration idempotent — the
|
||||
-- demo server may already carry hand-edited columns from a prior
|
||||
-- partial run, and the same migration must apply cleanly to a fresh
|
||||
-- database. Same posture as 0058_user_research_mode.
|
||||
|
||||
ALTER TABLE "audit_logs"
|
||||
ADD COLUMN IF NOT EXISTS "asn" INTEGER,
|
||||
ADD COLUMN IF NOT EXISTS "carrier" TEXT;
|
||||
@@ -1232,6 +1232,13 @@ model AuditLog {
|
||||
details String? // JSON string, no sensitive data
|
||||
ipAddress String? @map("ip_address")
|
||||
location String? // e.g. "Berlin, DE" — resolved from IP at log time
|
||||
// v1.4.27 B3 — resolved at audit-creation time from the bundled
|
||||
// GeoLite2-ASN MMDB. `asn` is the autonomous-system number,
|
||||
// `carrier` is the organisation string (e.g. "Deutsche Telekom AG").
|
||||
// Both nullable so older rows, private-IP rows, and offline-miss
|
||||
// rows stay valid.
|
||||
asn Int? // autonomous-system number
|
||||
carrier String? // autonomous-system organisation
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
||||
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
|
||||
|
||||
@@ -11,11 +11,12 @@ vi.mock("@/lib/db", () => ({
|
||||
|
||||
vi.mock("@/lib/geo", () => ({
|
||||
lookupIpLocation: vi.fn(),
|
||||
lookupIpAsn: vi.fn(),
|
||||
}));
|
||||
|
||||
import { auditLog } from "../audit";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { lookupIpLocation } from "@/lib/geo";
|
||||
import { lookupIpAsn, lookupIpLocation } from "@/lib/geo";
|
||||
|
||||
const ENTRY = { id: "audit-1" };
|
||||
|
||||
@@ -23,6 +24,9 @@ beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
vi.mocked(prisma.auditLog.create).mockResolvedValue(ENTRY as never);
|
||||
vi.mocked(prisma.auditLog.update).mockResolvedValue(ENTRY as never);
|
||||
// Default: no ASN row resolves. Tests that exercise carrier
|
||||
// resolution override this with `mockReturnValueOnce`.
|
||||
vi.mocked(lookupIpAsn).mockReturnValue(null);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -183,4 +187,97 @@ describe("auditLog", () => {
|
||||
|
||||
expect(prisma.auditLog.update).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
// ── v1.4.27 B3 — ASN + carrier resolution ────────────────────────
|
||||
it("writes asn + carrier alongside location when both resolvers fire", async () => {
|
||||
vi.mocked(lookupIpLocation).mockResolvedValueOnce("Berlin, DE");
|
||||
vi.mocked(lookupIpAsn).mockReturnValueOnce({
|
||||
asn: 3320,
|
||||
carrier: "Deutsche Telekom AG",
|
||||
});
|
||||
|
||||
await auditLog("auth.login.password", {
|
||||
userId: "user-14",
|
||||
ipAddress: "84.131.0.1",
|
||||
});
|
||||
await flush();
|
||||
|
||||
expect(lookupIpAsn).toHaveBeenCalledWith("84.131.0.1");
|
||||
expect(prisma.auditLog.update).toHaveBeenCalledTimes(1);
|
||||
expect(prisma.auditLog.update).toHaveBeenCalledWith({
|
||||
where: { id: "audit-1" },
|
||||
data: {
|
||||
location: "Berlin, DE",
|
||||
asn: 3320,
|
||||
carrier: "Deutsche Telekom AG",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("writes asn + carrier even when the location lookup returns null", async () => {
|
||||
vi.mocked(lookupIpLocation).mockResolvedValueOnce(null);
|
||||
vi.mocked(lookupIpAsn).mockReturnValueOnce({
|
||||
asn: 3209,
|
||||
carrier: "Vodafone GmbH",
|
||||
});
|
||||
|
||||
await auditLog("auth.login.passkey", {
|
||||
userId: "user-15",
|
||||
ipAddress: "139.7.0.1",
|
||||
});
|
||||
await flush();
|
||||
|
||||
expect(prisma.auditLog.update).toHaveBeenCalledWith({
|
||||
where: { id: "audit-1" },
|
||||
data: {
|
||||
asn: 3209,
|
||||
carrier: "Vodafone GmbH",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("writes location only when the ASN lookup misses", async () => {
|
||||
vi.mocked(lookupIpLocation).mockResolvedValueOnce("Berlin, DE");
|
||||
vi.mocked(lookupIpAsn).mockReturnValueOnce(null);
|
||||
|
||||
await auditLog("auth.login.password", {
|
||||
userId: "user-16",
|
||||
ipAddress: "192.0.2.1",
|
||||
});
|
||||
await flush();
|
||||
|
||||
expect(prisma.auditLog.update).toHaveBeenCalledWith({
|
||||
where: { id: "audit-1" },
|
||||
data: { location: "Berlin, DE" },
|
||||
});
|
||||
});
|
||||
|
||||
it("does NOT update when both resolvers miss", async () => {
|
||||
vi.mocked(lookupIpLocation).mockResolvedValueOnce(null);
|
||||
vi.mocked(lookupIpAsn).mockReturnValueOnce(null);
|
||||
|
||||
await auditLog("auth.login.password", {
|
||||
userId: "user-17",
|
||||
ipAddress: "192.0.2.2",
|
||||
});
|
||||
await flush();
|
||||
|
||||
expect(prisma.auditLog.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("carries a null carrier through when the ASN row has no organisation", async () => {
|
||||
vi.mocked(lookupIpLocation).mockResolvedValueOnce(null);
|
||||
vi.mocked(lookupIpAsn).mockReturnValueOnce({ asn: 64500, carrier: null });
|
||||
|
||||
await auditLog("auth.login.password", {
|
||||
userId: "user-18",
|
||||
ipAddress: "192.0.2.3",
|
||||
});
|
||||
await flush();
|
||||
|
||||
expect(prisma.auditLog.update).toHaveBeenCalledWith({
|
||||
where: { id: "audit-1" },
|
||||
data: { asn: 64500, carrier: null },
|
||||
});
|
||||
});
|
||||
});
|
||||
+31
-9
@@ -1,5 +1,5 @@
|
||||
import { prisma } from "@/lib/db";
|
||||
import { lookupIpLocation } from "@/lib/geo";
|
||||
import { lookupIpAsn, lookupIpLocation } from "@/lib/geo";
|
||||
|
||||
export async function auditLog(
|
||||
action: string,
|
||||
@@ -18,20 +18,42 @@ export async function auditLog(
|
||||
},
|
||||
});
|
||||
|
||||
// Fire-and-forget: resolve IP location and update the entry (3s timeout)
|
||||
// Fire-and-forget: resolve IP location + carrier and update the entry
|
||||
// with whichever fields the resolver could fill. v1.4.27 B3 — carrier
|
||||
// resolution is synchronous offline MMDB read so it cannot outrun the
|
||||
// location-lookup 3 s race; we run both inside the same race so a
|
||||
// hung online provider still bounds the await window.
|
||||
if (opts.ipAddress && action.startsWith("auth.")) {
|
||||
const ipAddress = opts.ipAddress;
|
||||
const timeout = new Promise<null>((resolve) =>
|
||||
setTimeout(() => resolve(null), 3000),
|
||||
);
|
||||
|
||||
Promise.race([lookupIpLocation(opts.ipAddress), timeout])
|
||||
.then((location) => {
|
||||
if (location) {
|
||||
return prisma.auditLog.update({
|
||||
where: { id: entry.id },
|
||||
data: { location },
|
||||
});
|
||||
Promise.race([
|
||||
Promise.all([
|
||||
lookupIpLocation(ipAddress),
|
||||
Promise.resolve(lookupIpAsn(ipAddress)),
|
||||
]),
|
||||
timeout,
|
||||
])
|
||||
.then((result) => {
|
||||
if (!result) return;
|
||||
const [location, asnRow] = result;
|
||||
const data: {
|
||||
location?: string;
|
||||
asn?: number;
|
||||
carrier?: string | null;
|
||||
} = {};
|
||||
if (location) data.location = location;
|
||||
if (asnRow) {
|
||||
data.asn = asnRow.asn;
|
||||
data.carrier = asnRow.carrier;
|
||||
}
|
||||
if (Object.keys(data).length === 0) return;
|
||||
return prisma.auditLog.update({
|
||||
where: { id: entry.id },
|
||||
data,
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
// Silently ignore geo lookup failures
|
||||
|
||||
Reference in new issue
Block a user