merge(FIX-A): v1.20.2 audit fixes

This commit is contained in:
Marc-André Bombeck committed 2026-06-26 09:28:43 +02:00
commit fc39d9fa13
3 files changed
+186 -4

No files matched your search

+20 -2
View File
@@ -160,6 +160,14 @@ async function buildComparisonSnapshotForUser(
ACTIVITY_STEPS: "",
};
const types = Object.keys(typeToSnapshotKey) as MeasurementType[];
// The snapshot only feeds summarize()'s avg30 / avg30LastMonth /
// avg30LastYear. The widest of those reaches back into the [365, 395)-day
// window (ageMs < 395 * DAY in meanOfWindow), so nothing older than 395
// days can change a result. Bound the read at 400 days — a 5-day floor over
// the strict-less-than boundary — instead of scanning the full history,
// which on multi-year accounts is tens of thousands of rows per type on the
// page-blocking generate path and the nightly pregenerate cron.
const sinceMeasuredAt = new Date(Date.now() - 400 * 86_400_000);
const rows = await Promise.all(
types.map(async (type) => {
// SLEEP_DURATION is stored ONE ROW PER STAGE per night; summarising the
@@ -171,7 +179,12 @@ async function buildComparisonSnapshotForUser(
if (type === "SLEEP_DURATION") {
const [sleepRows, sleepTz, sleepPriority] = await Promise.all([
prisma.measurement.findMany({
where: { userId, type, deletedAt: null },
where: {
userId,
type,
deletedAt: null,
measuredAt: { gte: sinceMeasuredAt },
},
orderBy: { measuredAt: "asc" },
select: {
measuredAt: true,
@@ -195,7 +208,12 @@ async function buildComparisonSnapshotForUser(
.map((n) => ({ date: n.measuredAt, value: n.asleepMinutes }));
} else {
const measurements = await prisma.measurement.findMany({
where: { userId, type, deletedAt: null },
where: {
userId,
type,
deletedAt: null,
measuredAt: { gte: sinceMeasuredAt },
},
orderBy: { measuredAt: "asc" },
select: { measuredAt: true, value: true },
});
@@ -0,0 +1,146 @@
/**
* v1.20.1 perf — the comparison-snapshot builder must bound its
* per-type measurement read to a ~400-day window instead of scanning a
* user's entire history. The snapshot only feeds summarize()'s avg30 /
* avg30LastMonth / avg30LastYear, and the widest of those reaches into
* the [365, 395)-day window, so nothing older than 395 days can change a
* result. These tests pin that:
* - every measurement read carries a `measuredAt.gte` lower bound,
* - the bound sits at ~400 days (5-day floor over the 395-day reach),
* - the three averages are unaffected for in-window data.
*
* The read fans out over all seven snapshot types on the page-blocking
* POST /api/insights/generate path AND the nightly pregenerate cron, so
* an unbounded scan is tens of thousands of rows per type on multi-year
* accounts.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
const findUnique = vi.fn();
const measurementFindMany = vi.fn();
vi.mock("@/lib/db", () => ({
prisma: {
user: {
findUnique: (...a: unknown[]) => findUnique(...a),
},
measurement: {
findMany: (...a: unknown[]) => measurementFindMany(...a),
},
},
}));
// SLEEP_DURATION branch helpers — the test never exercises the sleep
// reconstruction maths, but the builder imports these unconditionally.
vi.mock("@/lib/tz/resolver", () => ({
resolveUserTimezone: vi.fn(async () => "Europe/Berlin"),
}));
vi.mock("@/lib/rollups/measurement-read", () => ({
loadUserSourcePriority: vi.fn(async () => []),
}));
import { buildComparisonSnapshotForUser } from "../comprehensive-generate";
const DAY = 86_400_000;
beforeEach(() => {
vi.clearAllMocks();
// Comparison toggle ON → the builder runs its per-type reads. The
// baseline value drives which prior-period mean the snapshot reports.
// `resolveDashboardLayout` only honours `comparisonBaseline` on a blob
// that carries a numeric `version` + a `widgets` array; otherwise it
// clamps to "none".
findUnique.mockResolvedValue({
dashboardWidgetsJson: {
version: 1,
widgets: [],
comparisonBaseline: "lastYear",
},
});
// No measurements by default; individual tests override per call.
measurementFindMany.mockResolvedValue([]);
});
describe("buildComparisonSnapshotForUser — bounded read window", () => {
it("bounds every measurement read with a ~400-day measuredAt.gte floor", async () => {
const before = Date.now();
await buildComparisonSnapshotForUser("u1");
const after = Date.now();
// One findMany per snapshot type (sleep takes a different select but
// the same where shape). Every call must carry the lower bound.
expect(measurementFindMany).toHaveBeenCalled();
for (const call of measurementFindMany.mock.calls) {
const where = (call[0] as { where: Record<string, unknown> }).where;
expect(where).toMatchObject({ userId: "u1", deletedAt: null });
const measuredAt = where.measuredAt as { gte: Date };
expect(measuredAt).toBeDefined();
expect(measuredAt.gte).toBeInstanceOf(Date);
// The floor is anchored on Date.now() - 400 days (allow scheduling
// slack between the snapshot's clock read and the test's bracket).
const gteMs = measuredAt.gte.getTime();
expect(gteMs).toBeLessThanOrEqual(before - 400 * DAY);
expect(gteMs).toBeGreaterThanOrEqual(after - 401 * DAY);
}
});
it("keeps the floor strictly older than the 395-day reach of avg30LastYear", async () => {
const now = Date.now();
await buildComparisonSnapshotForUser("u1");
// avg30LastYear reads points with age < 395 days. The bound must sit
// at least 5 days older so it never clips that window.
for (const call of measurementFindMany.mock.calls) {
const where = (call[0] as { where: { measuredAt: { gte: Date } } }).where;
const ageDays = (now - where.measuredAt.gte.getTime()) / DAY;
expect(ageDays).toBeGreaterThanOrEqual(395);
}
});
it("computes avg30 / avg30LastMonth / avg30LastYear unchanged for in-window data", async () => {
findUnique.mockResolvedValue({
dashboardWidgetsJson: {
version: 1,
widgets: [],
comparisonBaseline: "lastMonth",
},
});
const now = Date.now();
// WEIGHT is the first non-sleep type. Seed three points, one in each
// of the avg30 (current), avg30LastMonth ([30,60)) windows — all well
// inside the 400-day floor.
measurementFindMany.mockImplementation(
async (args: { where: { type: string } }) => {
if (args.where.type !== "WEIGHT") return [];
return [
{ measuredAt: new Date(now - 5 * DAY), value: 80 }, // current
{ measuredAt: new Date(now - 45 * DAY), value: 84 }, // lastMonth
];
},
);
const snapshot = await buildComparisonSnapshotForUser("u1");
expect(snapshot).not.toBeNull();
const weight = snapshot!.metrics.find((m) => m.type === "weight");
expect(weight).toBeDefined();
// avg30 over the single current point.
expect(weight!.currentAvg).toBe(80);
// lastMonth baseline over the single [30,60) point.
expect(weight!.baselineAvg).toBe(84);
expect(weight!.delta).toBe(-4);
});
it("returns null without reading measurements when the comparison toggle is off", async () => {
findUnique.mockResolvedValue({
dashboardWidgetsJson: {
version: 1,
widgets: [],
comparisonBaseline: "none",
},
});
const snapshot = await buildComparisonSnapshotForUser("u1");
expect(snapshot).toBeNull();
expect(measurementFindMany).not.toHaveBeenCalled();
});
});
+20 -2
View File
@@ -328,6 +328,14 @@ export async function buildComparisonSnapshotForUser(
ACTIVITY_STEPS: "",
};
const types = Object.keys(typeToSnapshotKey) as MeasurementType[];
// The snapshot only feeds summarize()'s avg30 / avg30LastMonth /
// avg30LastYear. The widest of those reaches back into the [365, 395)-day
// window (ageMs < 395 * DAY in meanOfWindow), so nothing older than 395
// days can change a result. Bound the read at 400 days — a 5-day floor over
// the strict-less-than boundary — instead of scanning the full history,
// which on multi-year accounts is tens of thousands of rows per type on the
// page-blocking generate path and the nightly pregenerate cron.
const sinceMeasuredAt = new Date(Date.now() - 400 * 86_400_000);
const rows = await Promise.all(
types.map(async (type) => {
// SLEEP_DURATION is stored ONE ROW PER STAGE per night; summarising the
@@ -339,7 +347,12 @@ export async function buildComparisonSnapshotForUser(
if (type === "SLEEP_DURATION") {
const [sleepRows, sleepTz, sleepPriority] = await Promise.all([
prisma.measurement.findMany({
where: { userId, type, deletedAt: null },
where: {
userId,
type,
deletedAt: null,
measuredAt: { gte: sinceMeasuredAt },
},
orderBy: { measuredAt: "asc" },
select: {
measuredAt: true,
@@ -363,7 +376,12 @@ export async function buildComparisonSnapshotForUser(
.map((n) => ({ date: n.measuredAt, value: n.asleepMinutes }));
} else {
const measurements = await prisma.measurement.findMany({
where: { userId, type, deletedAt: null },
where: {
userId,
type,
deletedAt: null,
measuredAt: { gte: sinceMeasuredAt },
},
orderBy: { measuredAt: "asc" },
select: { measuredAt: true, value: true },
});