Files
HealthLog/tests/integration/environment-location-encryption.test.ts
Marc-André Bombeck 6e72de1c35 feat(db): add the v1.43 schema and drop the readable location columns
Migrations 0402 to 0410: AI usage levels, biomarker aliases and merges,
medication ingredients (with the supplement label run kind), life
circumstance fact kinds, AI sections and AUDIT-C, Dawarich context, the
pulse day-mean method, intraday hour buckets, the readable location drop
and five relation link tables. Every new column carries its reader or a
pending-consumer marker.

0409 refuses to run while any home, location period or stored day still
holds a readable location, and names the counts. Locations are read from
the sealed copy only; the encryption backfill no longer handles them, and
the demo seed writes sealed locations only.

The new tables are backed up and wiped, the new sealed columns are in the
rotation registry with labels of their own, the AI usage level and the
life-circumstance confirmations travel with the backup, and the wellbeing
score reads the mental-health screeners only.
2026-10-15 21:48:57 +02:00

447 lines
14 KiB
TypeScript

/**
* v1.42 (#615) — the environment locations sealed at rest, against real
* Postgres with the migrated schema (0381, 0382, and 0409, which dropped the
* readable location columns).
*
* - the writers (home, period) store only the sealed copy, the overview
* answers readable, and the air-quality switch round-trips through its
* route;
* - 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 { 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 type { RestoreSkipLog } from "@/lib/export/restore-skips";
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).not.toHaveProperty("homeLat");
const travel = await prisma.environmentTravelLocation.findFirstOrThrow({
where: { userId: OWNER },
});
expect(travel).not.toHaveProperty("lat");
// No readable trace of either place anywhere in the two rows.
expect(
JSON.stringify({ ...user, homeLocationEncrypted: null }),
).not.toMatch(/Bochum/);
expect(JSON.stringify({ ...travel, locationEncrypted: null })).not.toMatch(
/Lisbon/,
);
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 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).not.toHaveProperty("lat");
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("keeps a sealed location this host cannot open sealed, and reports it", async () => {
const prisma = getPrismaClient();
const damaged = Buffer.from([2, 2, 118, 49, ...new Array(40).fill(7)]);
const skips: RestoreSkipLog = [];
await prisma.$transaction((tx) =>
restoreEnvironmentData(
tx,
OWNER,
{
environmentTravelLocations: [
{
startDate: "2026-08-01",
endDate: "2026-08-10",
lat: null,
lon: null,
label: null,
locationEncrypted: damaged.toString("base64"),
},
],
environmentContexts: [
{
date: "2026-08-02",
lat: null,
lon: null,
locationLabel: null,
source: "TRAVEL",
locationEncrypted: damaged.toString("base64"),
tempMin: 20,
},
],
},
skips,
),
);
expect(skips.map((s) => [s.catalogue, s.key]).sort()).toEqual([
["environmentLocationCiphertext", "environmentContexts.2026-08-02"],
[
"environmentLocationCiphertext",
"environmentTravelLocations.2026-08-01..2026-08-10",
],
]);
const period = await prisma.environmentTravelLocation.findFirstOrThrow({
where: { userId: OWNER },
});
expect(Buffer.from(period.locationEncrypted!).equals(damaged)).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);
});
});