mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-07 04:12:04 +00:00
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.
443 lines
14 KiB
TypeScript
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);
|
|
});
|
|
});
|