Merge environment data into release/v1.42.0

# Conflicts:
#	src/__tests__/i18n-reverse-coverage.test.ts
This commit is contained in:
Marc-André Bombeck committed 2026-10-08 10:04:45 +02:00
commit 7bcb6f71d9
73 files changed
+6061 -385

No files matched your search

+67 -42
View File
@@ -53,6 +53,7 @@ process.env.ENCRYPTION_KEY ??=
import type { PrismaClient } from "@/generated/prisma/client";
import { decrypt, decryptBytes, encrypt, encryptBytes } from "@/lib/crypto";
import { decryptFromBytes, encryptToBytes } from "@/lib/ai/coach/bytes-codec";
import { openLocation, sealLocation } from "@/lib/environment/location-cipher";
import { readNote } from "@/lib/crypto/note-cipher";
import {
decryptFactData,
@@ -1386,14 +1387,18 @@ async function seedEveryTwoEndedModel(prisma: PrismaClient): Promise<void> {
// it. The assertion after the restore reads the two back TOGETHER. The
// coordinates are at 1 decimal, the precision the app stores since v1.39.4;
// a restore rounds anything finer, so finer values would not round-trip.
// Since v1.42 the location is stored sealed (`locationEncrypted`), the way
// the writers store it; the readable columns stay empty.
await prisma.environmentTravelLocation.create({
data: {
userId: OWNER_ID,
startDate: "2026-06-10",
endDate: "2026-06-20",
lat: 41.4,
lon: 2.2,
label: "Barcelona",
locationEncrypted: sealLocation({
lat: 41.4,
lon: 2.2,
label: "Barcelona",
}),
},
});
await prisma.environmentContext.createMany({
@@ -1401,9 +1406,11 @@ async function seedEveryTwoEndedModel(prisma: PrismaClient): Promise<void> {
{
userId: OWNER_ID,
date: "2026-06-15",
lat: 41.4,
lon: 2.2,
locationLabel: "Barcelona",
locationEncrypted: sealLocation({
lat: 41.4,
lon: 2.2,
label: "Barcelona",
}),
source: "TRAVEL",
tempMin: 19.4,
tempMax: 28.1,
@@ -1422,9 +1429,11 @@ async function seedEveryTwoEndedModel(prisma: PrismaClient): Promise<void> {
{
userId: OWNER_ID,
date: "2026-07-01",
lat: 52.5,
lon: 13.4,
locationLabel: "Berlin",
locationEncrypted: sealLocation({
lat: 52.5,
lon: 13.4,
label: "Berlin",
}),
source: "HOME",
tempMin: 13.1,
tempMax: 22.7,
@@ -2659,9 +2668,10 @@ describe("every model the plan claims two-ended survives a real restore", () =>
expect(
readings.map((reading) => ({
date: reading.date,
lat: reading.lat,
lon: reading.lon,
locationLabel: reading.locationLabel,
// The sealed location, opened (v1.42).
...(({ lat, lon, label }) => ({ lat, lon, locationLabel: label }))(
openLocation(reading.locationEncrypted!),
),
source: reading.source,
tempMean: reading.tempMean,
pressureDelta: reading.pressureDelta,
@@ -2710,15 +2720,17 @@ describe("every model the plan claims two-ended survives a real restore", () =>
where: { userId: OWNER_ID },
});
const travelDay = readings.find((reading) => reading.source === "TRAVEL")!;
const tripPlace = openLocation(trip.locationEncrypted!);
const dayPlace = openLocation(travelDay.locationEncrypted!);
expect(
{
startDate: trip.startDate,
endDate: trip.endDate,
label: trip.label,
label: tripPlace.label,
covers:
travelDay.date >= trip.startDate && travelDay.date <= trip.endDate,
sameLat: trip.lat === travelDay.lat,
sameLon: trip.lon === travelDay.lon,
sameLat: tripPlace.lat === dayPlace.lat,
sameLon: tripPlace.lon === dayPlace.lon,
},
"the period must still explain the reading it was exported beside",
).toEqual({
@@ -3500,6 +3512,22 @@ const COLUMN_EXCLUSIONS: Readonly<Record<string, string>> = {
"insert stamp of a symptom link; no reader, the day log's own timestamps travel",
"IllnessSymptomLink.createdAt":
"insert stamp of a symptom link; no reader, the illness day log's own timestamps travel",
// v1.42 (#615) — the readable copies of the sealed environment locations.
// The restore seals whatever location a file carries into
// `locationEncrypted` and writes these empty; the location itself is
// compared through the sealed column. They drop in v1.43.
"EnvironmentContext.lat":
"readable copy of the sealed day location; the restore seals it and writes this empty",
"EnvironmentContext.lon":
"readable copy of the sealed day location; the restore seals it and writes this empty",
"EnvironmentContext.locationLabel":
"readable copy of the sealed day location; the restore seals it and writes this empty",
"EnvironmentTravelLocation.lat":
"readable copy of the sealed period location; the restore seals it and writes this empty",
"EnvironmentTravelLocation.lon":
"readable copy of the sealed period location; the restore seals it and writes this empty",
"EnvironmentTravelLocation.label":
"readable copy of the sealed period location; the restore seals it and writes this empty",
};
/**
@@ -3510,35 +3538,9 @@ const COLUMN_EXCLUSIONS: Readonly<Record<string, string>> = {
* starts coming back, so the entry has to go in the change that carries it.
* The list is empty again before the release is tagged.
*/
const ENVIRONMENT_V142 =
"v1.42 (#615): the environment section carries the air-quality columns and the sealed location";
const LABS_V142 =
"v1.42 (#1095): the lab section carries the conversion provenance";
const RELEASE_PENDING_COLUMNS: Readonly<Record<string, string>> = {
"EnvironmentContext.aodMax": ENVIRONMENT_V142,
"EnvironmentContext.apparentMax": ENVIRONMENT_V142,
"EnvironmentContext.aqDomain": ENVIRONMENT_V142,
"EnvironmentContext.aqFetchedAt": ENVIRONMENT_V142,
"EnvironmentContext.aqHours": ENVIRONMENT_V142,
"EnvironmentContext.coMean": ENVIRONMENT_V142,
"EnvironmentContext.dustMax": ENVIRONMENT_V142,
"EnvironmentContext.eaqiMax": ENVIRONMENT_V142,
"EnvironmentContext.no2Mean": ENVIRONMENT_V142,
"EnvironmentContext.o3Max8h": ENVIRONMENT_V142,
"EnvironmentContext.pm10Mean": ENVIRONMENT_V142,
"EnvironmentContext.pm25Max": ENVIRONMENT_V142,
"EnvironmentContext.pm25Mean": ENVIRONMENT_V142,
"EnvironmentContext.pollenAlderMax": ENVIRONMENT_V142,
"EnvironmentContext.pollenBirchMax": ENVIRONMENT_V142,
"EnvironmentContext.pollenGrassMax": ENVIRONMENT_V142,
"EnvironmentContext.pollenMugwortMax": ENVIRONMENT_V142,
"EnvironmentContext.pollenOliveMax": ENVIRONMENT_V142,
"EnvironmentContext.pollenRagweedMax": ENVIRONMENT_V142,
"EnvironmentContext.so2Mean": ENVIRONMENT_V142,
"EnvironmentContext.usaqiMax": ENVIRONMENT_V142,
"EnvironmentContext.uvIndexMax": ENVIRONMENT_V142,
"EnvironmentContext.locationEncrypted": ENVIRONMENT_V142,
"EnvironmentTravelLocation.locationEncrypted": ENVIRONMENT_V142,
"Biomarker.analyteKey": LABS_V142,
"LabResult.sourceValue": LABS_V142,
"LabResult.sourceUnit": LABS_V142,
@@ -3704,6 +3706,8 @@ const BYTE_OPENERS: ReadonlyArray<(value: Uint8Array) => string> = [
decryptFromBytes,
decryptNoteFromBytes,
decryptContextFromBytes,
// v1.42 — the sealed environment locations, under their own label.
(value) => JSON.stringify(openLocation(value)),
(value) => decryptBytes(Buffer.from(value)).toString("base64"),
];
@@ -4045,6 +4049,20 @@ const USER_FILL_OVERRIDES: Readonly<Record<string, unknown>> = {
return bytes;
},
"User.avatarContentType": "image/png",
// v1.42 — a sealed home the way the writers seal it, so it opens.
"User.homeLocationEncrypted": () =>
sealLocation({ lat: 2.5, lon: 2.5, label: "round-trip home" }),
};
/**
* v1.42 (#615) — readable setting columns the restore seals into another
* column and writes empty. The value they held is compared through the sealed
* column (`homeLocationEncrypted`).
*/
const SEALED_ON_RESTORE: Readonly<Record<string, string>> = {
homeLat: "sealed into homeLocationEncrypted",
homeLon: "sealed into homeLocationEncrypted",
homeLabel: "sealed into homeLocationEncrypted",
};
/**
@@ -4183,8 +4201,15 @@ describe("the account's own settings survive a real restore", () => {
})) as unknown as Record<string, unknown>;
const lost = [...settings].filter(
(name) => opened(after[name]) !== opened(source[name]),
(name) =>
!Object.hasOwn(SEALED_ON_RESTORE, name) &&
opened(after[name]) !== opened(source[name]),
);
// The readable home columns come back empty by design; the home itself
// came back sealed, compared above through `homeLocationEncrypted`.
for (const name of Object.keys(SEALED_ON_RESTORE)) {
expect(after[name], name).toBeNull();
}
expect(
lost,
"these settings left with a value and came back without it",
@@ -8,6 +8,10 @@
* coarse;
* - the rounding block of migration 0361 coarsens rows written before it,
* and running it twice changes nothing more.
*
* v1.42 (#615): the locations are stored sealed, so the coarse value is
* asserted on the opened sealed copy, and the readable columns are empty. The
* rounding still happens before sealing; nothing finer is ever stored.
*/
import { readFileSync } from "node:fs";
import { join } from "node:path";
@@ -19,6 +23,7 @@ process.env.ENCRYPTION_KEY ??=
import { PUT as putHome } from "@/app/api/environment/home/route";
import { POST as postTravel } from "@/app/api/environment/travel/route";
import { restoreEnvironmentData } from "@/lib/export/environment-backup";
import { openLocation } from "@/lib/environment/location-cipher";
import { cookieJar, headerJar } from "./mock-next-headers";
import { getPrismaClient, truncateAllTables } from "./setup";
@@ -95,7 +100,16 @@ describe("environment coordinates at one decimal", () => {
const user = await getPrismaClient().user.findUniqueOrThrow({
where: { id: OWNER_ID },
});
expect([user.homeLat, user.homeLon]).toEqual([52.5, 13.4]);
expect([user.homeLat, user.homeLon, user.homeLabel]).toEqual([
null,
null,
null,
]);
expect(openLocation(user.homeLocationEncrypted!)).toEqual({
lat: 52.5,
lon: 13.4,
label: "Berlin, Germany",
});
const travel = await postTravel(
jsonRequest("http://localhost/api/environment/travel", "POST", {
@@ -107,10 +121,16 @@ describe("environment coordinates at one decimal", () => {
}),
);
expect(travel.status).toBe(201);
expect((await travel.json()).data).toMatchObject({ lat: 48.1, lon: 11.6 });
const stored = await getPrismaClient().environmentTravelLocation.findMany({
where: { userId: OWNER_ID },
});
expect(stored.map((t) => [t.lat, t.lon])).toEqual([[48.1, 11.6]]);
expect(stored.map((t) => [t.lat, t.lon, t.label])).toEqual([
[null, null, null],
]);
expect(stored.map((t) => openLocation(t.locationEncrypted!))).toEqual([
{ lat: 48.1, lon: 11.6, label: "Munich, Germany" },
]);
});
it("restores a file written with two-decimal coordinates at one decimal", async () => {
@@ -143,8 +163,15 @@ describe("environment coordinates at one decimal", () => {
const days = await prisma.environmentContext.findMany({
where: { userId: OWNER_ID },
});
expect(travel.map((t) => [t.lat, t.lon])).toEqual([[48.1, 11.6]]);
expect(days.map((d) => [d.lat, d.lon])).toEqual([[48.1, 11.6]]);
expect(travel.map((t) => openLocation(t.locationEncrypted!))).toEqual([
{ lat: 48.1, lon: 11.6, label: "Munich, Germany" },
]);
expect(days.map((d) => openLocation(d.locationEncrypted!))).toEqual([
{ lat: 48.1, lon: 11.6, label: "Munich, Germany" },
]);
expect(days.map((d) => [d.lat, d.lon, d.locationLabel])).toEqual([
[null, null, null],
]);
});
it("coarsens rows written before migration 0361, idempotently", async () => {
@@ -0,0 +1,492 @@
/**
* 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);
});
});
@@ -0,0 +1,154 @@
/**
* v1.42 (#615) — key rotation covers the three sealed environment locations
* (the home, the dated periods, the stored days), against real Postgres.
*
* Rows are sealed under the old key, the registry walk (shared by the in-app
* rotation and `scripts/rotate-encryption-key.ts`) re-seals them under the new
* one with the environment-location label, and they still open once the old
* key is gone. A walk that re-sealed them without the label, or with the
* string codec, would leave values no reader can open.
*/
import { beforeEach, describe, expect, it } from "vitest";
process.env.ENCRYPTION_KEYS = JSON.stringify({
v1: "1".repeat(64),
v2: "2".repeat(64),
});
process.env.ENCRYPTION_ACTIVE_KEY_ID = "v1";
delete process.env.ENCRYPTION_KEY;
import { _resetCryptoCacheForTests, extractKeyIdFromBytes } from "@/lib/crypto";
import {
ENCRYPTED_COLUMNS,
type EncryptedColumn,
} from "@/lib/crypto/encrypted-columns";
import {
rotateColumn,
type CorpusClient,
} from "@/lib/crypto/encryption-corpus";
import { openLocation, sealLocation } from "@/lib/environment/location-cipher";
import { getPrismaClient, truncateAllTables } from "./setup";
const USER_ID = "user-rotation-environment";
function column(model: string, field: string): EncryptedColumn {
const col = ENCRYPTED_COLUMNS.find(
(c) => c.model === model && c.field === field,
);
if (!col) throw new Error(`${model}.${field} is not registered`);
return col;
}
function activate(keyId: string) {
process.env.ENCRYPTION_ACTIVE_KEY_ID = keyId;
_resetCryptoCacheForTests();
}
beforeEach(async () => {
process.env.ENCRYPTION_KEYS = JSON.stringify({
v1: "1".repeat(64),
v2: "2".repeat(64),
});
activate("v1");
await truncateAllTables(getPrismaClient());
});
describe("key rotation over the environment locations", () => {
it("re-seals the home, a period and a day under the active key", async () => {
const prisma = getPrismaClient();
const stamp = new Date("2026-05-04T10:00:00.000Z");
await prisma.user.create({
data: {
id: USER_ID,
username: "rotation-environment",
email: "rotation-environment@example.test",
timezone: "Europe/Berlin",
homeLocationEncrypted: sealLocation({
lat: 51.5,
lon: 7.2,
label: "Bochum, Germany",
}),
},
});
await prisma.environmentTravelLocation.create({
data: {
id: "et-1",
userId: USER_ID,
startDate: "2026-08-01",
endDate: "2026-08-10",
locationEncrypted: sealLocation({
lat: 38.7,
lon: -9.1,
label: "Lisbon, Portugal",
}),
updatedAt: stamp,
},
});
await prisma.environmentContext.create({
data: {
id: "ec-1",
userId: USER_ID,
date: "2026-08-02",
source: "TRAVEL",
locationEncrypted: sealLocation({
lat: 38.7,
lon: -9.1,
label: "Lisbon, Portugal",
}),
},
});
activate("v2");
const client = {
user: prisma.user,
environmentTravelLocation: prisma.environmentTravelLocation,
environmentContext: prisma.environmentContext,
} as unknown as CorpusClient;
for (const [model, field] of [
["User", "homeLocationEncrypted"],
["EnvironmentTravelLocation", "locationEncrypted"],
["EnvironmentContext", "locationEncrypted"],
] as const) {
const result = await rotateColumn(client, column(model, field));
expect(result, `${model}.${field}`).toMatchObject({
scanned: 1,
rotated: 1,
errors: 0,
});
expect((await rotateColumn(client, column(model, field))).rotated).toBe(
0,
);
}
// The old key is gone; every location still opens.
process.env.ENCRYPTION_KEYS = JSON.stringify({ v2: "2".repeat(64) });
_resetCryptoCacheForTests();
const user = await prisma.user.findUniqueOrThrow({
where: { id: USER_ID },
});
const travel = await prisma.environmentTravelLocation.findUniqueOrThrow({
where: { id: "et-1" },
});
const day = await prisma.environmentContext.findUniqueOrThrow({
where: { id: "ec-1" },
});
for (const sealed of [
user.homeLocationEncrypted!,
travel.locationEncrypted!,
day.locationEncrypted!,
]) {
expect(extractKeyIdFromBytes(Buffer.from(sealed))).toBe("v2");
}
expect(openLocation(user.homeLocationEncrypted!).label).toBe(
"Bochum, Germany",
);
expect(openLocation(travel.locationEncrypted!)).toEqual({
lat: 38.7,
lon: -9.1,
label: "Lisbon, Portugal",
});
expect(openLocation(day.locationEncrypted!).lat).toBe(38.7);
// Re-sealing is not an edit: the period keeps its stamp.
expect(travel.updatedAt).toEqual(stamp);
});
});
@@ -133,6 +133,9 @@ describe("free-text encryption backfill (real Postgres)", () => {
routeGeometriesMigrated: 0,
appointmentAddressesCleared: 0,
contactAuditRowsScrubbed: 0,
homeLocationsMigrated: 0,
travelLocationsMigrated: 0,
dayLocationsMigrated: 0,
});
expect(await runFreeTextEncryptionBackfillForUser("ft-b")).toEqual({
conversationTitlesMigrated: 0,
@@ -141,6 +144,9 @@ describe("free-text encryption backfill (real Postgres)", () => {
routeGeometriesMigrated: 0,
appointmentAddressesCleared: 0,
contactAuditRowsScrubbed: 0,
homeLocationsMigrated: 0,
travelLocationsMigrated: 0,
dayLocationsMigrated: 0,
});
const conversations = await prisma.coachConversation.findMany({
@@ -189,6 +195,9 @@ describe("free-text encryption backfill (real Postgres)", () => {
routeGeometriesMigrated: 0,
appointmentAddressesCleared: 0,
contactAuditRowsScrubbed: 0,
homeLocationsMigrated: 0,
travelLocationsMigrated: 0,
dayLocationsMigrated: 0,
});
});
@@ -273,6 +282,9 @@ describe("free-text encryption backfill (real Postgres)", () => {
routeGeometriesMigrated: 0,
appointmentAddressesCleared: 0,
contactAuditRowsScrubbed: 0,
homeLocationsMigrated: 0,
travelLocationsMigrated: 0,
dayLocationsMigrated: 0,
});
expect(await runFreeTextEncryptionBackfillForUser("pc-b")).toEqual({
conversationTitlesMigrated: 0,
@@ -281,6 +293,9 @@ describe("free-text encryption backfill (real Postgres)", () => {
routeGeometriesMigrated: 1,
appointmentAddressesCleared: 0,
contactAuditRowsScrubbed: 0,
homeLocationsMigrated: 0,
travelLocationsMigrated: 0,
dayLocationsMigrated: 0,
});
const practitioners = await prisma.practitioner.findMany({
@@ -341,6 +356,9 @@ describe("free-text encryption backfill (real Postgres)", () => {
routeGeometriesMigrated: 0,
appointmentAddressesCleared: 0,
contactAuditRowsScrubbed: 0,
homeLocationsMigrated: 0,
travelLocationsMigrated: 0,
dayLocationsMigrated: 0,
});
});
@@ -438,6 +456,9 @@ describe("free-text encryption backfill (real Postgres)", () => {
expect(summaryA).toMatchObject({
appointmentAddressesCleared: 1,
contactAuditRowsScrubbed: 1,
homeLocationsMigrated: 0,
travelLocationsMigrated: 0,
dayLocationsMigrated: 0,
});
await runFreeTextEncryptionBackfillForUser("ad-b");
@@ -19,6 +19,10 @@
* schema exactly as it was advertised. A tool the case table does not cover
* fails the run, so a new tool cannot ship without joining it.
*
* v1.42: `get_environment` declares its result field by field, so the
* strict validator here is also what proves it carries no coordinate and no
* place name: an undeclared key would fail the run.
*
* It also pins the live-rows rule on the two presence probes: a measurement
* type whose readings were all deleted is not listed as present.
*/
@@ -37,6 +41,7 @@ import {
} from "@/lib/mcp/rich-reads";
import type { McpAuthContext } from "@/lib/mcp/auth";
import { sealLocation } from "@/lib/environment/location-cipher";
import { getPrismaClient, truncateAllTables } from "./setup";
const DAY = 86_400_000;
@@ -241,6 +246,42 @@ async function seedRecord(): Promise<string> {
},
});
}
// v1.42 — environment days with weather and air quality, one of them not
// yet fetched, so get_environment answers its full shape (and a null
// air-quality part) under the strict validator. The location is sealed.
for (let i = 2; i <= 12; i += 1) {
await prisma.environmentContext.create({
data: {
userId: id,
date: isoDay(daysAgo(i)),
source: i === 5 ? "TRAVEL" : "HOME",
locationEncrypted: sealLocation({
lat: 51.5,
lon: 7.2,
label: "Somewhere",
}),
tempMin: 10 + (i % 4),
tempMax: 20,
tempMean: 15,
apparentMax: 21,
sunshineSec: 7200,
...(i === 3
? {}
: {
pm25Mean: 8,
pm25Max: 16,
o3Max8h: 70,
eaqiMax: 40,
uvIndexMax: 3,
pollenGrassMax: 12,
pollenBirchMax: null,
aqDomain: "cams_europe",
aqHours: 24,
aqFetchedAt: daysAgo(1),
}),
},
});
}
return id;
}
@@ -297,6 +338,7 @@ const CASES: Record<string, Array<Record<string, unknown>>> = {
get_workouts: [{ window: "last30days" }],
get_illness_recovery: [{}],
get_cycle: [{}],
get_environment: [{}, { window: "last7days" }],
get_metrics: [
{ metrics: ["weight", "bp", "steps"], window: "last30days" },
{ metrics: ["hrv", "vo2_max"] },
@@ -439,6 +481,27 @@ describe("MCP tool outputs against their advertised schemas", () => {
}
});
it("get_environment answers with data and no location anywhere", async () => {
const { client, close } = await connect(userId);
try {
const result = await client.callTool({
name: "get_environment",
arguments: {},
});
const out = result.structuredContent as {
present: boolean;
data?: { daily: Array<{ airQuality?: unknown }> };
};
expect(out.present).toBe(true);
expect(out.data!.daily.some((d) => d.airQuality === null)).toBe(true);
const text = JSON.stringify(out);
expect(text).not.toMatch(/"(lat|lon|label|locationLabel)"/);
expect(text).not.toContain("Somewhere");
} finally {
await close();
}
});
it("list_metrics carries availability, and it is declared", async () => {
const { client, close } = await connect(userId);
try {