forked from Mike/freno-dev
analytics: make each weekly funnel stage separately measurable (FRE-366)
Gap 1 — click and served share one label: trackDownload now takes a
stage ("click" | "served") carried on a new VisitorAnalytics.stage
column; the Nook door and the tRPC proxy record clicks, the DMG file
route records served. summariseDownloads() and the /analytics downloads
block are untouched — the stage is a separate column, so the path
grouping cannot change.
Gap 2 — checkout start records nothing: the checkout route records a
created Stripe session through the shared trackRequest writer (same
referrer, campaign, country dimensions as every door) under its own
path, and STAGES.checkout reads it instead of the /checkout page proxy.
Gap 3 — loopback addresses, no host dimension: every writer now
resolves the client address through resolveClientIp, which trusts
x-forwarded-for only when the direct peer is the loopback production
proxy (trust boundary stated in request-client.ts), and records the
host on each row through the same site-context extractor routing uses.
stage and host reach the table through VISITOR_ANALYTICS_ADDED_COLUMNS;
the weekly job applies the same bootstrap before reading so it renders
seven stages against a database no deploy has migrated yet.
The weekly read splits downloadClick/dmgServed, scopes visits to
nook.freno.me, and computes its caveats from the window: pre-instrumentation
rows are stated and excluded, and the loopback warning now fires only
when rows recorded since the instrumentation still carry it.
This commit is contained in:
78
src/server/request-client.test.ts
Normal file
78
src/server/request-client.test.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import { beforeEach, describe, expect, it, mock } from "bun:test";
|
||||
|
||||
// The trust boundary of FRE-366: x-forwarded-for is client-controlled text,
|
||||
// believed only when the direct peer is the loopback production proxy. These
|
||||
// tests pin that boundary — a regression here either re-records the proxy as
|
||||
// the visitor (unique visitors collapses to one host) or lets any client
|
||||
// spoof its address.
|
||||
|
||||
let directIp: string | null = null;
|
||||
mock.module("vinxi/http", () => ({
|
||||
getRequestIP: () => directIp
|
||||
}));
|
||||
|
||||
const { resolveClientIp, isLoopbackAddress, requestHost } = await import(
|
||||
"./request-client"
|
||||
);
|
||||
|
||||
function event(
|
||||
headers: Record<string, string> = {},
|
||||
url = "https://nook.freno.me/downloads"
|
||||
) {
|
||||
return {
|
||||
request: new Request(url, { headers }),
|
||||
nativeEvent: {}
|
||||
} as never;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
directIp = null;
|
||||
});
|
||||
|
||||
describe("resolveClientIp", () => {
|
||||
it("takes the first forwarded hop when the direct peer is the loopback proxy", () => {
|
||||
directIp = "127.0.0.1";
|
||||
expect(
|
||||
resolveClientIp(event({ "x-forwarded-for": "198.51.100.9, 10.0.0.1" }))
|
||||
).toBe("198.51.100.9");
|
||||
|
||||
directIp = "::1";
|
||||
expect(
|
||||
resolveClientIp(event({ "x-forwarded-for": "198.51.100.10" }))
|
||||
).toBe("198.51.100.10");
|
||||
});
|
||||
|
||||
it("ignores a forwarded header from a real peer — it is spoofable", () => {
|
||||
directIp = "203.0.113.7";
|
||||
expect(
|
||||
resolveClientIp(event({ "x-forwarded-for": "198.51.100.9" }))
|
||||
).toBe("203.0.113.7");
|
||||
});
|
||||
|
||||
it("keeps the loopback peer when the proxy set no forwarded header", () => {
|
||||
directIp = "::ffff:127.0.0.1";
|
||||
expect(resolveClientIp(event())).toBe("::ffff:127.0.0.1");
|
||||
});
|
||||
|
||||
it("records nothing rather than inventing an address", () => {
|
||||
expect(resolveClientIp(event())).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("isLoopbackAddress", () => {
|
||||
it("covers the v4 range, the shorthand and the v6 forms", () => {
|
||||
expect(isLoopbackAddress("127.0.0.1")).toBe(true);
|
||||
expect(isLoopbackAddress("127.0.0.53")).toBe(true);
|
||||
expect(isLoopbackAddress("::1")).toBe(true);
|
||||
expect(isLoopbackAddress("::ffff:127.0.0.1")).toBe(true);
|
||||
expect(isLoopbackAddress("203.0.113.7")).toBe(false);
|
||||
expect(isLoopbackAddress("10.0.0.1")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("requestHost", () => {
|
||||
it("reads the host the client addressed from the request URL", () => {
|
||||
expect(requestHost(event())).toBe("nook.freno.me");
|
||||
expect(requestHost(event({}, "https://freno.me/"))).toBe("freno.me");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user