mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
Fetch the Open-Meteo air-quality feed (CAMS, domains=auto) with the weather: 17 hourly variables folded into local days by fixed rules (means and maxima only with 18 of 24 hours, ozone as the highest 8-hour running mean, UV only with the midday hours), a value the feed did not cover stored as null, never as zero. Requests are cut into 90-day chunks, newest first, and chunks before mid 2022 ask for the eight pollutant and index variables only. An air-quality failure never costs the weather row; the day stays unfetched and the nightly gap fill (up to 90 days per account, newest first) asks for it again. Every Open-Meteo request asks an instance-wide budget kept in Postgres first (500 a minute, 4000 an hour, 8000 a day, weighted by the hosted pricing rule). A refused request is not sent and the run ends as done with budget_blocked. OPENMETEO_AIR_QUALITY_URL and ENVIRONMENT_AIR_QUALITY_DISABLED are honoured; the account switch lives on PATCH /api/environment/preferences. The home, each dated location period and each stored day keep their coarse location sealed as one value under healthlog/environment-location/v1. The writers write the sealed value only, the readers open it and fall back to the readable columns for rows the free-text encryption backfill has not reached, and the backfill now seals all three and clears the readable columns. The backup carries the location readable in a portable file and sealed in a disaster-recovery file; a restore seals either under the receiving key. Three correlation channels join the environment family (PM2.5, the ozone 8-hour high, the day's highest pollen count), read through the averaged same-day window and left empty while air quality is off. The Coach and MCP gain get_environment: module-gated, clamped to the lookback limit, no coordinate or place name anywhere, with provenance and the attribution lines. The settings section gets the switch, the coverage and the newest day's values; the dashboard shows a quiet chip on a day with high pollen, a warm night or very poor air. Copernicus and Open-Meteo are credited next to the values.
493 lines
15 KiB
TypeScript
493 lines
15 KiB
TypeScript
/**
|
|
* v1.42 (#615) — the environment locations sealed at rest, against real
|
|
* Postgres with the migrated schema (0381, 0382).
|
|
*
|
|
* - the writers (home, period) store only the sealed copy, the overview
|
|
* answers readable, and the air-quality switch round-trips through its
|
|
* route;
|
|
* - the boot-time backfill finds the accounts still holding a readable
|
|
* home, period or day, seals each, clears the readable columns, and
|
|
* converges (a second pass and a second discovery find nothing);
|
|
* - the backup carries the location readable in a portable file and sealed
|
|
* in a disaster-recovery file, and both restore to a location that opens
|
|
* to the same coarse place, with the air-quality values of the day;
|
|
* - the data wipe clears the sealed home with the rest.
|
|
*/
|
|
import { Buffer } from "node:buffer";
|
|
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
|
|
import { PUT as putHome } from "@/app/api/environment/home/route";
|
|
import { POST as postTravel } from "@/app/api/environment/travel/route";
|
|
import { PATCH as patchPreferences } from "@/app/api/environment/preferences/route";
|
|
import { GET as getOverview } from "@/app/api/environment/route";
|
|
import { setGlobalBoss } from "@/lib/jobs/boss-instance";
|
|
import {
|
|
enqueueBootTimeFreeTextEncryptionBackfill,
|
|
runFreeTextEncryptionBackfillForUser,
|
|
} from "@/lib/jobs/free-text-encryption-backfill";
|
|
import { openLocation, sealLocation } from "@/lib/environment/location-cipher";
|
|
import {
|
|
buildEnvironmentBackupSection,
|
|
restoreEnvironmentData,
|
|
type EnvironmentRestoreInput,
|
|
} from "@/lib/export/environment-backup";
|
|
|
|
/** A section as a file carries it: through JSON and back. */
|
|
function throughFile(section: unknown): EnvironmentRestoreInput {
|
|
return JSON.parse(JSON.stringify(section)) as EnvironmentRestoreInput;
|
|
}
|
|
|
|
import { cookieJar, headerJar } from "./mock-next-headers";
|
|
import { getPrismaClient, truncateAllTables } from "./setup";
|
|
|
|
vi.mock("next/headers", async () => {
|
|
const { cookieJar, headerJar } = await import("./mock-next-headers");
|
|
return {
|
|
headers: vi.fn(async () => ({
|
|
get: (name: string) => headerJar.get(name.toLowerCase()) ?? null,
|
|
})),
|
|
cookies: vi.fn(async () => ({
|
|
get: (name: string) => {
|
|
const value = cookieJar.get(name);
|
|
return value ? { name, value } : undefined;
|
|
},
|
|
set: (name: string, value: string) => cookieJar.set(name, value),
|
|
delete: (name: string) => cookieJar.delete(name),
|
|
})),
|
|
};
|
|
});
|
|
|
|
vi.mock("@/lib/db-compat", () => ({
|
|
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
|
}));
|
|
|
|
const OWNER = "env-sealed-owner";
|
|
|
|
function jsonRequest(url: string, method: string, body: unknown) {
|
|
return new Request(url, {
|
|
method,
|
|
headers: { "content-type": "application/json" },
|
|
body: JSON.stringify(body),
|
|
}) as never;
|
|
}
|
|
|
|
async function seedUser(id: string) {
|
|
await getPrismaClient().user.create({
|
|
data: {
|
|
id,
|
|
username: id,
|
|
email: `${id}@example.test`,
|
|
timezone: "Europe/Berlin",
|
|
},
|
|
});
|
|
}
|
|
|
|
beforeEach(async () => {
|
|
const prisma = getPrismaClient();
|
|
await truncateAllTables(prisma);
|
|
cookieJar.clear();
|
|
headerJar.clear();
|
|
await seedUser(OWNER);
|
|
const session = await prisma.session.create({
|
|
data: {
|
|
userId: OWNER,
|
|
expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
|
|
mfaVerifiedAt: new Date(),
|
|
},
|
|
});
|
|
cookieJar.set("healthlog_session", session.id);
|
|
});
|
|
|
|
afterEach(() => {
|
|
setGlobalBoss(null as never);
|
|
});
|
|
|
|
describe("environment writers and the overview", () => {
|
|
it("store the sealed copy only and answer readable", async () => {
|
|
const prisma = getPrismaClient();
|
|
expect(
|
|
(
|
|
await putHome(
|
|
jsonRequest("http://localhost/api/environment/home", "PUT", {
|
|
lat: 51.4818,
|
|
lon: 7.2162,
|
|
label: "Bochum, Germany",
|
|
timezone: "Europe/Berlin",
|
|
}),
|
|
)
|
|
).status,
|
|
).toBe(200);
|
|
expect(
|
|
(
|
|
await postTravel(
|
|
jsonRequest("http://localhost/api/environment/travel", "POST", {
|
|
startDate: "2026-08-01",
|
|
endDate: "2026-08-10",
|
|
lat: 38.7223,
|
|
lon: -9.1393,
|
|
label: "Lisbon, Portugal",
|
|
}),
|
|
)
|
|
).status,
|
|
).toBe(201);
|
|
|
|
const user = await prisma.user.findUniqueOrThrow({ where: { id: OWNER } });
|
|
expect([user.homeLat, user.homeLon, user.homeLabel]).toEqual([
|
|
null,
|
|
null,
|
|
null,
|
|
]);
|
|
const travel = await prisma.environmentTravelLocation.findFirstOrThrow({
|
|
where: { userId: OWNER },
|
|
});
|
|
expect([travel.lat, travel.lon, travel.label]).toEqual([null, null, null]);
|
|
// No readable trace of either place anywhere in the two rows.
|
|
expect(
|
|
JSON.stringify({ ...user, homeLocationEncrypted: null }),
|
|
).not.toMatch(/Bochum/);
|
|
|
|
const overview = (await (await getOverview()).json()).data;
|
|
expect(overview.home).toMatchObject({
|
|
lat: 51.5,
|
|
lon: 7.2,
|
|
label: "Bochum, Germany",
|
|
});
|
|
expect(overview.travel).toEqual([
|
|
expect.objectContaining({
|
|
startDate: "2026-08-01",
|
|
lat: 38.7,
|
|
lon: -9.1,
|
|
label: "Lisbon, Portugal",
|
|
}),
|
|
]);
|
|
expect(overview.airQuality).toMatchObject({
|
|
enabled: true,
|
|
operatorDisabled: false,
|
|
days: 0,
|
|
});
|
|
expect(overview.attributions.length).toBe(3);
|
|
});
|
|
|
|
it("turns the air-quality part off and on through its route", async () => {
|
|
const prisma = getPrismaClient();
|
|
const off = await patchPreferences(
|
|
jsonRequest("http://localhost/api/environment/preferences", "PATCH", {
|
|
airQualityEnabled: false,
|
|
}),
|
|
);
|
|
expect(off.status).toBe(200);
|
|
expect((await off.json()).data).toEqual({
|
|
airQualityEnabled: false,
|
|
operatorDisabled: false,
|
|
});
|
|
expect(
|
|
(await prisma.user.findUniqueOrThrow({ where: { id: OWNER } }))
|
|
.environmentAirQualityEnabled,
|
|
).toBe(false);
|
|
|
|
const bad = await patchPreferences(
|
|
jsonRequest("http://localhost/api/environment/preferences", "PATCH", {
|
|
airQualityEnabled: "yes",
|
|
userId: "someone-else",
|
|
}),
|
|
);
|
|
expect(bad.status).toBe(422);
|
|
});
|
|
|
|
it("leaves the air-quality values of a stored day out of the overview while off", async () => {
|
|
const prisma = getPrismaClient();
|
|
await prisma.environmentContext.create({
|
|
data: {
|
|
userId: OWNER,
|
|
date: "2026-10-06",
|
|
source: "HOME",
|
|
locationEncrypted: sealLocation({ lat: 51.5, lon: 7.2, label: "Home" }),
|
|
tempMin: 21,
|
|
pm25Mean: 12,
|
|
eaqiMax: 85,
|
|
pollenGrassMax: 70,
|
|
aqHours: 24,
|
|
aqDomain: "cams_europe",
|
|
aqFetchedAt: new Date(),
|
|
},
|
|
});
|
|
const on = (await (await getOverview()).json()).data;
|
|
expect(on.latestDay).toMatchObject({
|
|
date: "2026-10-06",
|
|
tempMin: 21,
|
|
airQuality: { pm25Mean: 12, eaqiMax: 85, pollen: { grass: 70 } },
|
|
});
|
|
expect(on.airQuality).toMatchObject({ days: 1, domain: "cams_europe" });
|
|
|
|
await prisma.user.update({
|
|
where: { id: OWNER },
|
|
data: { environmentAirQualityEnabled: false },
|
|
});
|
|
const off = (await (await getOverview()).json()).data;
|
|
expect(off.latestDay.airQuality).toBeNull();
|
|
expect(off.attributions.length).toBe(1);
|
|
});
|
|
});
|
|
|
|
describe("the encryption backfill (real Postgres)", () => {
|
|
it("discovers, seals, clears and converges", async () => {
|
|
const prisma = getPrismaClient();
|
|
await seedUser("env-other");
|
|
const stamp = new Date("2026-03-01T10:00:00.000Z");
|
|
await prisma.user.update({
|
|
where: { id: OWNER },
|
|
data: { homeLat: 51.5, homeLon: 7.2, homeLabel: "Bochum, Germany" },
|
|
});
|
|
await prisma.environmentTravelLocation.create({
|
|
data: {
|
|
userId: OWNER,
|
|
startDate: "2026-08-01",
|
|
endDate: "2026-08-10",
|
|
lat: 38.7,
|
|
lon: -9.1,
|
|
label: "Lisbon, Portugal",
|
|
updatedAt: stamp,
|
|
},
|
|
});
|
|
await prisma.environmentContext.create({
|
|
data: {
|
|
userId: OWNER,
|
|
date: "2026-08-02",
|
|
lat: 38.7,
|
|
lon: -9.1,
|
|
locationLabel: "Lisbon, Portugal",
|
|
source: "TRAVEL",
|
|
},
|
|
});
|
|
|
|
const sent: string[] = [];
|
|
setGlobalBoss({
|
|
send: async (_q: string, payload: { userId?: string }) => {
|
|
if (payload.userId) sent.push(payload.userId);
|
|
return "job";
|
|
},
|
|
} as never);
|
|
const discovery = await enqueueBootTimeFreeTextEncryptionBackfill();
|
|
expect(discovery.error).toBeNull();
|
|
expect(sent).toEqual([OWNER]);
|
|
|
|
const summary = await runFreeTextEncryptionBackfillForUser(OWNER);
|
|
expect(summary).toMatchObject({
|
|
homeLocationsMigrated: 1,
|
|
travelLocationsMigrated: 1,
|
|
dayLocationsMigrated: 1,
|
|
});
|
|
|
|
const user = await prisma.user.findUniqueOrThrow({ where: { id: OWNER } });
|
|
expect(user.homeLat).toBeNull();
|
|
expect(openLocation(user.homeLocationEncrypted!)).toEqual({
|
|
lat: 51.5,
|
|
lon: 7.2,
|
|
label: "Bochum, Germany",
|
|
});
|
|
const travel = await prisma.environmentTravelLocation.findFirstOrThrow({
|
|
where: { userId: OWNER },
|
|
});
|
|
expect(travel.lat).toBeNull();
|
|
expect(travel.updatedAt).toEqual(stamp);
|
|
expect(openLocation(travel.locationEncrypted!).label).toBe(
|
|
"Lisbon, Portugal",
|
|
);
|
|
const day = await prisma.environmentContext.findFirstOrThrow({
|
|
where: { userId: OWNER },
|
|
});
|
|
expect([day.lat, day.lon, day.locationLabel]).toEqual([null, null, null]);
|
|
expect(openLocation(day.locationEncrypted!).lat).toBe(38.7);
|
|
|
|
// Converged: nothing readable is left to find.
|
|
expect(await runFreeTextEncryptionBackfillForUser(OWNER)).toMatchObject({
|
|
homeLocationsMigrated: 0,
|
|
travelLocationsMigrated: 0,
|
|
dayLocationsMigrated: 0,
|
|
});
|
|
sent.length = 0;
|
|
await enqueueBootTimeFreeTextEncryptionBackfill();
|
|
expect(sent).toEqual([]);
|
|
const remaining = await prisma.$queryRaw<{ n: bigint }[]>`
|
|
SELECT (SELECT count(*) FROM users WHERE home_lat IS NOT NULL)
|
|
+ (SELECT count(*) FROM environment_travel_locations WHERE lat IS NOT NULL)
|
|
+ (SELECT count(*) FROM environment_contexts WHERE lat IS NOT NULL) AS n`;
|
|
expect(Number(remaining[0].n)).toBe(0);
|
|
});
|
|
});
|
|
|
|
describe("the environment backup with sealed locations", () => {
|
|
async function seedDay() {
|
|
const prisma = getPrismaClient();
|
|
await prisma.environmentTravelLocation.create({
|
|
data: {
|
|
userId: OWNER,
|
|
startDate: "2026-08-01",
|
|
endDate: "2026-08-10",
|
|
locationEncrypted: sealLocation({
|
|
lat: 38.7,
|
|
lon: -9.1,
|
|
label: "Lisbon, Portugal",
|
|
}),
|
|
},
|
|
});
|
|
await prisma.environmentContext.create({
|
|
data: {
|
|
userId: OWNER,
|
|
date: "2026-08-02",
|
|
source: "TRAVEL",
|
|
locationEncrypted: sealLocation({
|
|
lat: 38.7,
|
|
lon: -9.1,
|
|
label: "Lisbon, Portugal",
|
|
}),
|
|
tempMin: 20.5,
|
|
pm25Mean: 7.5,
|
|
o3Max8h: 96,
|
|
pollenOliveMax: null,
|
|
pollenGrassMax: 4,
|
|
aqDomain: "cams_europe",
|
|
aqHours: 24,
|
|
aqFetchedAt: new Date("2026-08-05T02:10:00.000Z"),
|
|
},
|
|
});
|
|
}
|
|
|
|
it("writes the location readable in a portable file and restores it sealed", async () => {
|
|
const prisma = getPrismaClient();
|
|
await seedDay();
|
|
const section = await buildEnvironmentBackupSection(prisma, OWNER, {
|
|
purpose: "portable-export",
|
|
});
|
|
const [day] = section.environmentContexts;
|
|
expect(day).toMatchObject({
|
|
lat: 38.7,
|
|
lon: -9.1,
|
|
locationLabel: "Lisbon, Portugal",
|
|
pm25Mean: 7.5,
|
|
pollenOliveMax: null,
|
|
aqFetchedAt: "2026-08-05T02:10:00.000Z",
|
|
});
|
|
expect(day).not.toHaveProperty("locationEncrypted");
|
|
expect(section.environmentTravelLocations[0]).toMatchObject({
|
|
lat: 38.7,
|
|
label: "Lisbon, Portugal",
|
|
});
|
|
|
|
const parsed = throughFile(section);
|
|
await prisma.$transaction((tx) =>
|
|
restoreEnvironmentData(tx, OWNER, parsed),
|
|
);
|
|
const restored = await prisma.environmentContext.findFirstOrThrow({
|
|
where: { userId: OWNER },
|
|
});
|
|
expect([restored.lat, restored.locationLabel]).toEqual([null, null]);
|
|
expect(openLocation(restored.locationEncrypted!)).toEqual({
|
|
lat: 38.7,
|
|
lon: -9.1,
|
|
label: "Lisbon, Portugal",
|
|
});
|
|
expect(restored).toMatchObject({
|
|
pm25Mean: 7.5,
|
|
o3Max8h: 96,
|
|
pollenGrassMax: 4,
|
|
aqHours: 24,
|
|
aqDomain: "cams_europe",
|
|
});
|
|
expect(restored.aqFetchedAt).toEqual(new Date("2026-08-05T02:10:00.000Z"));
|
|
});
|
|
|
|
it("carries the sealed value verbatim in a disaster-recovery file", async () => {
|
|
const prisma = getPrismaClient();
|
|
await seedDay();
|
|
const before = await prisma.environmentContext.findFirstOrThrow({
|
|
where: { userId: OWNER },
|
|
});
|
|
const section = await buildEnvironmentBackupSection(prisma, OWNER, {
|
|
purpose: "disaster-recovery",
|
|
});
|
|
const [day] = section.environmentContexts;
|
|
expect(day.lat).toBeNull();
|
|
expect(day.locationEncrypted).toBe(
|
|
Buffer.from(before.locationEncrypted!).toString("base64"),
|
|
);
|
|
const parsed = throughFile(section);
|
|
await prisma.$transaction((tx) =>
|
|
restoreEnvironmentData(tx, OWNER, parsed),
|
|
);
|
|
const after = await prisma.environmentContext.findFirstOrThrow({
|
|
where: { userId: OWNER },
|
|
});
|
|
expect(
|
|
Buffer.from(after.locationEncrypted!).equals(
|
|
Buffer.from(before.locationEncrypted!),
|
|
),
|
|
).toBe(true);
|
|
});
|
|
|
|
it("restores a file from before air quality as a day never fetched", async () => {
|
|
const prisma = getPrismaClient();
|
|
await prisma.$transaction((tx) =>
|
|
restoreEnvironmentData(tx, OWNER, {
|
|
environmentTravelLocations: [],
|
|
environmentContexts: [
|
|
{
|
|
date: "2026-01-02",
|
|
lat: 51.5,
|
|
lon: 7.2,
|
|
locationLabel: "Bochum, Germany",
|
|
source: "HOME",
|
|
tempMin: 1,
|
|
},
|
|
],
|
|
}),
|
|
);
|
|
const day = await prisma.environmentContext.findFirstOrThrow({
|
|
where: { userId: OWNER },
|
|
});
|
|
expect(day.aqFetchedAt).toBeNull();
|
|
expect(day.pm25Mean).toBeNull();
|
|
expect(openLocation(day.locationEncrypted!).label).toBe("Bochum, Germany");
|
|
});
|
|
});
|
|
|
|
describe("the data wipe", () => {
|
|
it("clears the sealed home, the periods and the days, and resets the switch", async () => {
|
|
const prisma = getPrismaClient();
|
|
await prisma.user.update({
|
|
where: { id: OWNER },
|
|
data: {
|
|
homeLocationEncrypted: sealLocation({
|
|
lat: 51.5,
|
|
lon: 7.2,
|
|
label: "Bochum, Germany",
|
|
}),
|
|
environmentAirQualityEnabled: false,
|
|
},
|
|
});
|
|
await prisma.environmentContext.create({
|
|
data: {
|
|
userId: OWNER,
|
|
date: "2026-08-02",
|
|
source: "HOME",
|
|
locationEncrypted: sealLocation({ lat: 51.5, lon: 7.2, label: "x" }),
|
|
},
|
|
});
|
|
const { DELETE } = await import("@/app/api/settings/data/route");
|
|
const response = await DELETE(
|
|
new Request("http://localhost/api/settings/data", {
|
|
method: "DELETE",
|
|
body: JSON.stringify({ confirm: "DELETE" }),
|
|
}) as never,
|
|
);
|
|
expect(response.status).toBe(200);
|
|
const user = await prisma.user.findUniqueOrThrow({ where: { id: OWNER } });
|
|
expect(user.homeLocationEncrypted).toBeNull();
|
|
expect(user.environmentAirQualityEnabled).toBe(true);
|
|
expect(
|
|
await prisma.environmentContext.count({ where: { userId: OWNER } }),
|
|
).toBe(0);
|
|
});
|
|
});
|