Files
HealthLog/tests/integration/environment-location-encryption.test.ts
Marc-André Bombeck b6da360856 refactor(db): drop the runtime schema mirror
ensureDbCompatibility ran ALTER TABLE / CREATE TABLE IF NOT EXISTS on the
login, register, token and session paths, mirroring columns every
migration under prisma/migrations already creates. The entrypoint applies
those migrations before the server listens and stops the boot when one
fails, so the mirror never had work to do, and DDL on a request path
needs the table's owner: a connection that may only read and write rows
fails every login on it. Drop the module, its call sites and the mocks
that stubbed it out.
2026-11-03 20:32:35 +01:00

443 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),
})),
};
});
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);
});
});