Files
freno-dev/src/server/weekly-metrics.test.ts
Paperclip Agent 1d9287a199 analytics: fix the two FRE-369 findings in the funnel instrumentation
The security review of 39d874a (FRE-369) found the address fix recorded
the wrong thing twice. Both are fixed here; the branch is the same one
under review, this commit does not rewrite it.

Q1 — the first x-forwarded-for hop is attacker-chosen text. In
client → Cloudflare → same-host proxy → app, both proxies *append*, so
the header arrives as `<client spoof…>, <true client>, <CF edge>` and
`split(",")[0]` took the sender's own invention — the loopback-peer gate
was sound, the hop selection defeated it. Preference is now
`cf-connecting-ip` (Cloudflare overwrites it, so a client value never
survives the edge), then x-forwarded-for counted from the right with one
trusted hop discounted, then the direct peer. Every candidate must parse
as an address. h3's `getRequestIP(…, { xForwardedFor: true })` has the
same leftmost flaw and is documented as not a fix.

Q4 — the table held full client addresses indefinitely. Every analytics
entry now passes through `truncateIpAddress` in `enrichAnalyticsEntry`
(IPv4 /24, IPv6 /56, unparseable dropped), so no writer can persist a
full address by accident. Retention got the schedule it never had:
`/api/analytics/retention` on a daily Vercel cron, Bearer-authenticated
and fail-closed, with the window parameterised (the old
`cleanupOldAnalytics` spliced the number into the SQL). Default 365 days
rather than the review's 90 so enabling the cron is not itself a
destructive event — the tighter window is an operational decision, named
on the task.

Also from the review: the module doc now forbids feeding this helper into
any security decision; `isLoopbackAddress` parses instead of
string-matching (`::ffff:127.9.9.9`, `0:0:0:0:0:0:0:1`); and `host` is
normalised (lowercased, port stripped) so `nook.freno.me:443` cannot
silently miss the `host = ?` scope. The weekly loopback caveat matches
the truncated shapes writers produce now, and says plainly that "unique
visitors" counts prefixes.
2026-10-29 18:05:44 +00:00

521 lines
16 KiB
TypeScript

import { beforeEach, describe, expect, it } from "bun:test";
import { Database } from "bun:sqlite";
import {
VISITOR_ANALYTICS_ADDED_COLUMNS,
VISITOR_ANALYTICS_COLUMNS,
VISITOR_ANALYTICS_DDL,
VISITOR_ANALYTICS_INSERT
} from "./analytics-schema";
import {
NOOK_CHECKOUT_PATH,
NOOK_DOWNLOAD_PATH,
NOOK_HOST,
ensureAnalyticsSchema,
getWeeklyReport,
pickMover,
readCampaigns,
readFunnel,
readTraffic,
renderWeeklyMarkdown,
windowsFor,
type Connections,
type FunnelCounts,
type MetricsConn
} from "./weekly-metrics";
// Same reasoning as the backfill suite: the queries run real SQL, so an
// in-memory SQLite shaped like production is the only honest stand-in. A stub
// that returned canned rows would pass with a column or a label the live table
// does not have.
const NOOK_DDL = [
`CREATE TABLE trials (
fingerprint TEXT PRIMARY KEY,
started_at TEXT NOT NULL,
email TEXT
)`,
`CREATE TABLE licenses (
id TEXT PRIMARY KEY,
key TEXT UNIQUE NOT NULL,
email TEXT NOT NULL,
stripe_session_id TEXT UNIQUE NOT NULL,
created_at TEXT NOT NULL,
revoked INTEGER NOT NULL DEFAULT 0
)`,
`CREATE TABLE activations (
id TEXT PRIMARY KEY,
license_id TEXT NOT NULL,
device_fingerprint TEXT NOT NULL,
device_name TEXT NOT NULL,
activated_at TEXT NOT NULL,
deactivated_at TEXT
)`
];
function adapter(db: Database): MetricsConn {
return {
async execute(query) {
const args = (query.args ?? []) as never[];
const statement = db.query(query.sql);
if (/^\s*(select|pragma|with)\b/i.test(query.sql)) {
return { rows: statement.all(...args) as unknown[], rowsAffected: 0 };
}
return {
rows: [] as unknown[],
rowsAffected: statement.run(...args).changes
};
}
};
}
let analyticsDb: Database;
let nookDb: Database;
let conns: Connections;
const NOW = new Date("2026-10-29T16:00:00Z");
// A row as the post-FRE-366 writers record it: real client address, the host
// it addressed, and a stage label where the row is a download.
const row = (
overrides: Record<string, unknown>
): Record<string, unknown> => ({
id: crypto.randomUUID(),
user_id: null,
path: "/",
method: "GET",
referrer: null,
user_agent: "Mozilla/5.0 (Macintosh)",
// What the writers store since the FRE-369 privacy floor: a /24 (or /56 for
// IPv6), never the address itself.
ip_address: "203.0.113.0",
country: "GB",
device_type: "desktop",
browser: "safari",
os: "macos",
utm_source: null,
utm_medium: null,
utm_campaign: null,
duration_ms: null,
fcp: null,
lcp: null,
cls: null,
fid: null,
inp: null,
ttfb: null,
dom_load: null,
load_complete: null,
stage: null,
host: NOOK_HOST,
...overrides
});
function insertAnalytics(overrides: Record<string, unknown>): void {
const values = row(overrides);
analyticsDb
.prepare(VISITOR_ANALYTICS_INSERT)
.run(...(VISITOR_ANALYTICS_COLUMNS.map((column) => values[column]) as never[]));
// `created_at` is not in VISITOR_ANALYTICS_COLUMNS (it defaults to now), so
// the window each row belongs to has to be set after the insert.
if (values.created_at) {
analyticsDb
.prepare(`UPDATE VisitorAnalytics SET created_at = ? WHERE id = ?`)
.run(values.created_at as string, values.id as string);
}
}
// Inside the current window for NOW - 7 days: 2026-10-22 16:00 → 2026-10-29 16:00.
const THIS_WEEK = "2026-10-27 10:00:00";
// Inside the previous window: 2026-10-15 16:00 → 2026-10-22 16:00.
const LAST_WEEK = "2026-10-18 10:00:00";
beforeEach(() => {
analyticsDb = new Database(":memory:");
analyticsDb.run(VISITOR_ANALYTICS_DDL);
nookDb = new Database(":memory:");
for (const ddl of NOOK_DDL) nookDb.run(ddl);
conns = { analytics: adapter(analyticsDb), nook: adapter(nookDb) };
});
describe("windowsFor", () => {
it("puts the previous window directly before the current one", () => {
const windows = windowsFor(NOW, 7);
expect(windows.current).toEqual({
start: "2026-10-22 16:00:00",
end: "2026-10-29 16:00:00"
});
expect(windows.previous).toEqual({
start: "2026-10-15 16:00:00",
end: "2026-10-22 16:00:00"
});
});
});
describe("readFunnel", () => {
it("counts each stage by its own label inside its window", async () => {
// Page views on the Nook host count; other sites, API calls, legacy
// rows with no host, and the other window do not.
insertAnalytics({ path: "/", created_at: THIS_WEEK });
insertAnalytics({ path: "/downloads", created_at: THIS_WEEK });
insertAnalytics({ path: "/", host: "freno.me", created_at: THIS_WEEK });
insertAnalytics({ path: "/", host: null, created_at: THIS_WEEK });
insertAnalytics({ path: "/api/trpc/anything", created_at: THIS_WEEK });
insertAnalytics({ path: "/", created_at: LAST_WEEK });
// The old checkout proxy: a /checkout page view is a visit, not a
// started checkout.
insertAnalytics({ path: "/checkout", created_at: THIS_WEEK });
// The download label splits by stage; a row with no stage predates the
// split and counts in neither.
insertAnalytics({ path: NOOK_DOWNLOAD_PATH, stage: "click", created_at: THIS_WEEK });
insertAnalytics({ path: NOOK_DOWNLOAD_PATH, stage: "served", created_at: THIS_WEEK });
insertAnalytics({ path: NOOK_DOWNLOAD_PATH, stage: "served", created_at: THIS_WEEK });
insertAnalytics({ path: NOOK_DOWNLOAD_PATH, stage: null, created_at: THIS_WEEK });
// A created Stripe session, recorded by the checkout route itself.
insertAnalytics({
path: NOOK_CHECKOUT_PATH,
method: "POST",
created_at: THIS_WEEK
});
// The licence DB stores ISO-8601 with milliseconds.
nookDb
.prepare(`INSERT INTO trials (fingerprint, started_at) VALUES (?, ?)`)
.run("fp-1", "2026-10-28T20:28:46.806Z");
nookDb
.prepare(`INSERT INTO trials (fingerprint, started_at) VALUES (?, ?)`)
.run("fp-old", "2026-08-26T19:26:12.791Z");
nookDb
.prepare(
`INSERT INTO licenses (id, key, email, stripe_session_id, created_at)
VALUES (?, ?, ?, ?, ?)`
)
.run("lic-1", "NOOK-1", "buyer@example.com", "cs_1", "2026-10-25T12:00:00.000Z");
nookDb
.prepare(
`INSERT INTO activations (id, license_id, device_fingerprint, device_name, activated_at)
VALUES (?, ?, ?, ?, ?)`
)
.run("act-1", "lic-1", "device-1", "Hermes", "2026-10-25T12:05:00.000Z");
const window = windowsFor(NOW, 7).current;
const funnel = await readFunnel(conns, window);
expect(funnel).toEqual({
visit: 3, // "/", "/downloads" and the "/checkout" page view on the Nook host
downloadClick: 1,
dmgServed: 2,
trial: 1,
checkout: 1,
purchase: 1,
activation: 1
});
});
it("reports zeros against an empty database", async () => {
const window = windowsFor(NOW, 7).current;
expect(await readFunnel(conns, window)).toEqual({
visit: 0,
downloadClick: 0,
dmgServed: 0,
trial: 0,
checkout: 0,
purchase: 0,
activation: 0
});
});
});
describe("readTraffic", () => {
it("separates legacy rows, live loopback and automation from clean traffic", async () => {
// Post-instrumentation rows: host set. Two of them still loopback — in the
// truncated shapes the privacy floor writes — which is the shape the report
// must flag as "the fix is not live".
insertAnalytics({
path: NOOK_DOWNLOAD_PATH,
stage: "click",
created_at: THIS_WEEK
});
insertAnalytics({
path: NOOK_DOWNLOAD_PATH,
stage: "served",
ip_address: "127.0.0.0",
user_agent: "The Nook/0.13.4 Sparkle/2.9.6",
created_at: THIS_WEEK
});
insertAnalytics({
path: "/",
ip_address: "0:0:0:0:0:0:0:0",
created_at: THIS_WEEK
});
insertAnalytics({ path: "/", created_at: THIS_WEEK });
// Pre-instrumentation rows: no host, proxy's address — legacy, never
// counted as "the fix is not live".
insertAnalytics({
path: NOOK_DOWNLOAD_PATH,
stage: null,
host: null,
ip_address: "127.0.0.1",
user_agent: "curl/8.7.1",
created_at: THIS_WEEK
});
insertAnalytics({
path: "/",
host: null,
ip_address: "::1",
created_at: THIS_WEEK
});
const traffic = await readTraffic(conns, windowsFor(NOW, 7).current);
expect(traffic).toEqual({
analyticsRows: 6,
hostRows: 4,
legacyRows: 2,
hostLoopbackRows: 2,
downloadRows: 3,
downloadClickRows: 1,
downloadServedRows: 1,
downloadUnstagedRows: 1,
downloadNonBrowserRows: 2
});
});
});
describe("readCampaigns", () => {
it("groups tagged rows, excludes untagged, and orders by volume", async () => {
insertAnalytics({
path: "/",
utm_source: "hn",
utm_medium: "post",
utm_campaign: "launch",
created_at: THIS_WEEK
});
insertAnalytics({
path: "/",
utm_source: "hn",
utm_medium: "post",
utm_campaign: "launch",
created_at: THIS_WEEK
});
insertAnalytics({
path: "/",
utm_source: "r_macapps",
utm_campaign: "week2",
created_at: THIS_WEEK
});
insertAnalytics({ path: "/", created_at: THIS_WEEK });
const campaigns = await readCampaigns(conns.analytics, windowsFor(NOW, 7).current);
expect(campaigns).toEqual([
{ source: "hn", medium: "post", campaign: "launch", count: 2 },
{ source: "r_macapps", medium: null, campaign: "week2", count: 1 }
]);
});
it("caps the list at ten rows", async () => {
for (let i = 0; i < 12; i += 1) {
insertAnalytics({
path: "/",
utm_source: `source-${i}`,
created_at: THIS_WEEK
});
}
const campaigns = await readCampaigns(conns.analytics, windowsFor(NOW, 7).current);
expect(campaigns.length).toBe(10);
});
});
describe("ensureAnalyticsSchema", () => {
it("adds the post-hoc columns to a table that predates them, idempotently", async () => {
// The table as production held it before any post-hoc column existed.
// (`created_at` is not in the insert column list — it defaults DB-side —
// so the simulated table has to name it explicitly.)
const oldDb = new Database(":memory:");
const withoutAdded = [
...VISITOR_ANALYTICS_COLUMNS.filter(
(c) => !VISITOR_ANALYTICS_ADDED_COLUMNS.some((a) => a.name === c)
),
"created_at"
];
oldDb.run(
`CREATE TABLE VisitorAnalytics (${withoutAdded.map((c) => `${c} TEXT`).join(", ")})`
);
const oldConn = adapter(oldDb);
await ensureAnalyticsSchema(oldConn);
await ensureAnalyticsSchema(oldConn); // a second run is a no-op
// The writers' full INSERT — stage and host included — now works.
const values = row({ stage: "click", path: NOOK_DOWNLOAD_PATH });
expect(() =>
oldDb
.prepare(VISITOR_ANALYTICS_INSERT)
.run(
...(VISITOR_ANALYTICS_COLUMNS.map((c) => values[c]) as never[])
)
).not.toThrow();
oldDb
.prepare(`UPDATE VisitorAnalytics SET created_at = ? WHERE id = ?`)
.run(THIS_WEEK, values.id as string);
// And the funnel reads its stage split from the migrated table.
const funnel = await readFunnel(
{ analytics: oldConn, nook: conns.nook },
windowsFor(NOW, 7).current
);
expect(funnel.downloadClick).toBe(1);
});
});
describe("pickMover", () => {
const base: FunnelCounts = {
visit: 0,
downloadClick: 0,
dmgServed: 0,
trial: 0,
checkout: 0,
purchase: 0,
activation: 0
};
it("picks the stage with the largest absolute change", () => {
const mover = pickMover(
{ ...base, visit: 61, dmgServed: 156, trial: 2 },
{ ...base, visit: 56 }
);
expect(mover).toEqual({
key: "dmgServed",
label: "DMG served",
now: 156,
before: 0,
delta: 156
});
});
it("breaks ties toward the earlier stage", () => {
const mover = pickMover(
{ ...base, visit: 5, downloadClick: 5 },
{ ...base, visit: 0, downloadClick: 0 }
);
expect(mover?.key).toBe("visit");
});
it("returns null when nothing moved", () => {
expect(pickMover({ ...base, visit: 3 }, { ...base, visit: 3 })).toBeNull();
});
});
describe("getWeeklyReport / renderWeeklyMarkdown", () => {
it("renders seven distinct stages with no merged-label or proxy caveats", async () => {
insertAnalytics({ path: "/", created_at: THIS_WEEK });
insertAnalytics({ path: NOOK_DOWNLOAD_PATH, stage: "click", created_at: THIS_WEEK });
insertAnalytics({
path: NOOK_DOWNLOAD_PATH,
stage: "served",
created_at: THIS_WEEK
});
insertAnalytics({
path: NOOK_DOWNLOAD_PATH,
stage: "served",
created_at: THIS_WEEK
});
insertAnalytics({ path: NOOK_CHECKOUT_PATH, method: "POST", created_at: THIS_WEEK });
nookDb
.prepare(`INSERT INTO trials (fingerprint, started_at) VALUES (?, ?)`)
.run("fp-1", "2026-10-28T20:28:46.806Z");
const report = await getWeeklyReport(conns, { now: NOW, days: 7 });
const markdown = renderWeeklyMarkdown(report);
expect(markdown).toContain(
"Window: **2026-10-22 16:00:00 → 2026-10-29 16:00:00 UTC** (7 days)"
);
expect(markdown).toContain(`| visit (${NOOK_HOST} page views) | 1 | 0 | +1 |`);
expect(markdown).toContain("| download click | 1 | 0 | +1 |");
expect(markdown).toContain("| DMG served | 2 | 0 | +2 |");
expect(markdown).toContain("| checkout started (Stripe session) | 1 | 0 | +1 |");
const order = [
`visit (${NOOK_HOST} page views)`,
"download click",
"DMG served",
"trial started",
"checkout started (Stripe session)",
"purchase (licence issued)",
"activation"
];
for (let i = 1; i < order.length; i += 1) {
expect(markdown.indexOf(order[i - 1]!)).toBeLessThan(
markdown.indexOf(order[i]!)
);
}
// The caveats this issue retired stay retired.
expect(markdown).not.toContain("one label");
expect(markdown).not.toContain("not recorded");
expect(markdown).not.toContain("no host dimension");
expect(markdown).not.toContain("†");
expect(report.mover?.key).toBe("dmgServed");
expect(markdown).toContain("**DMG served: 0 → 2 (+2).**");
});
it("states what pre-instrumentation rows cannot say, computed from the window", async () => {
insertAnalytics({ path: "/", created_at: THIS_WEEK });
// Legacy rows: no host, proxy's address, no stage label.
insertAnalytics({ path: "/", host: null, ip_address: "127.0.0.1", created_at: THIS_WEEK });
insertAnalytics({
path: NOOK_DOWNLOAD_PATH,
stage: null,
host: null,
ip_address: "127.0.0.1",
user_agent: "curl/8.7.1",
created_at: THIS_WEEK
});
const report = await getWeeklyReport(conns, { now: NOW, days: 7 });
const markdown = renderWeeklyMarkdown(report);
expect(markdown).toContain(
"2 of 3 rows in the window predate the FRE-366 instrumentation"
);
expect(markdown).toContain("predate the click/served stage label");
// Legacy loopback is not "the fix is not live".
expect(markdown).not.toContain("still carry a loopback address");
});
it("flags loopback only when rows recorded since the instrumentation carry it", async () => {
// Both spellings a live writer can produce: the raw proxy address, and the
// /24 the privacy floor stores instead of it.
insertAnalytics({ path: "/", ip_address: "127.0.0.1", created_at: THIS_WEEK });
insertAnalytics({ path: "/", ip_address: "127.0.53.0", created_at: THIS_WEEK });
const report = await getWeeklyReport(conns, { now: NOW, days: 7 });
const markdown = renderWeeklyMarkdown(report);
expect(markdown).toContain(
"2 rows recorded since the instrumentation still carry a loopback address"
);
});
it("says nothing moved rather than inventing a winner", async () => {
const report = await getWeeklyReport(conns, { now: NOW, days: 7 });
const markdown = renderWeeklyMarkdown(report);
expect(report.mover).toBeNull();
expect(markdown).toContain("_Nothing moved: every stage held its previous-week value._");
expect(markdown).toContain("_No tagged traffic in the window._");
});
it("rejects a window that is not a positive number of days", async () => {
await expect(getWeeklyReport(conns, { now: NOW, days: 0 })).rejects.toThrow(
"days must be a positive number"
);
});
});