mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 05:12:14 +00:00
260 lines
9.1 KiB
TypeScript
260 lines
9.1 KiB
TypeScript
/**
|
|
* Database-side fixture for the backup/restore journey.
|
|
*
|
|
* Everything the journey WRITES it writes through the browser: the readings,
|
|
* the medication and its dose, the mood entry, the document, the snapshot, the
|
|
* deletions and the restore all ride the app's own routes. What lives here is
|
|
* only what a browser has no way to express — clearing the account back to a
|
|
* known state so the file survives `--repeat-each`, and corrupting one stored
|
|
* copy so the restore path has an authenticated envelope to refuse.
|
|
*
|
|
* Raw SQL through `pg` for the same reason `global-setup.ts` uses it:
|
|
* Playwright's TS loader does not handle the generated Prisma client's
|
|
* `import.meta.url` indirection.
|
|
*/
|
|
import pg from "pg";
|
|
|
|
import { E2E_BACKUP_ADMIN, E2E_BACKUP_DELEGATE } from "./global-setup";
|
|
|
|
/** One row of the account's readings, as the journey needs to compare it. */
|
|
export interface StoredMeasurement {
|
|
id: string;
|
|
type: string;
|
|
value: number;
|
|
deletedAt: string | null;
|
|
}
|
|
|
|
async function withPool<T>(fn: (pool: pg.Pool) => Promise<T>): Promise<T> {
|
|
const connectionString = process.env.DATABASE_URL;
|
|
if (!connectionString) {
|
|
throw new Error(
|
|
"DATABASE_URL is unset — the backup journey needs the same database the app is running against.",
|
|
);
|
|
}
|
|
const pool = new pg.Pool({ connectionString });
|
|
try {
|
|
return await fn(pool);
|
|
} finally {
|
|
await pool.end();
|
|
}
|
|
}
|
|
|
|
/** The journey account's id, resolved by username rather than assumed. */
|
|
export async function backupAccountId(): Promise<string> {
|
|
return withPool(async (pool) => {
|
|
const { rows } = await pool.query<{ id: string }>(
|
|
`SELECT id FROM users WHERE username = $1`,
|
|
[E2E_BACKUP_ADMIN.username],
|
|
);
|
|
if (rows.length !== 1) {
|
|
throw new Error(
|
|
`expected exactly one ${E2E_BACKUP_ADMIN.username} row, found ${rows.length}`,
|
|
);
|
|
}
|
|
return rows[0].id;
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Put the account back where the journey expects to find it.
|
|
*
|
|
* Deletes only what this file's account holds, and only the classes the
|
|
* journey writes. The stored snapshots go too: a row left behind from the
|
|
* previous repetition is indistinguishable from the one this repetition is
|
|
* about to take, and the whole journey turns on picking the right one.
|
|
*
|
|
* The rate buckets are cleared for the same reason `global-setup.ts` clears
|
|
* the login bucket: the manual-run and upload endpoints allow three calls a
|
|
* minute each and the export bucket ten an hour, which are the right
|
|
* production numbers and fewer than three repetitions of this file need.
|
|
*/
|
|
export async function resetBackupJourney(): Promise<void> {
|
|
await withPool(async (pool) => {
|
|
const { rows } = await pool.query<{ id: string }>(
|
|
`SELECT id FROM users WHERE username = ANY($1)`,
|
|
[[E2E_BACKUP_ADMIN.username, E2E_BACKUP_DELEGATE.username]],
|
|
);
|
|
const ids = rows.map((row) => row.id);
|
|
|
|
// Intake events before medications: the child rows carry the foreign key.
|
|
await pool.query(
|
|
`DELETE FROM medication_intake_events WHERE user_id = ANY($1)`,
|
|
[ids],
|
|
);
|
|
await pool.query(`DELETE FROM medications WHERE user_id = ANY($1)`, [ids]);
|
|
await pool.query(`DELETE FROM measurements WHERE user_id = ANY($1)`, [ids]);
|
|
await pool.query(`DELETE FROM mood_entries WHERE user_id = ANY($1)`, [ids]);
|
|
await pool.query(`DELETE FROM inbound_documents WHERE user_id = ANY($1)`, [
|
|
ids,
|
|
]);
|
|
await pool.query(`DELETE FROM data_backups WHERE user_id = ANY($1)`, [ids]);
|
|
|
|
// The delegate is switched back out of the record; the refusal control
|
|
// switches in and a stamp that outlived a repetition would start the next
|
|
// one already inside.
|
|
await pool.query(
|
|
`UPDATE sessions SET acting_as_user_id = NULL WHERE user_id = ANY($1)`,
|
|
[ids],
|
|
);
|
|
|
|
for (const id of ids) {
|
|
await pool.query(
|
|
`DELETE FROM rate_limits
|
|
WHERE key IN ($1, $2, $3)`,
|
|
[
|
|
`admin-backups-run:${id}`,
|
|
`admin-backups-upload:${id}`,
|
|
`export:${id}`,
|
|
],
|
|
);
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Copy one stored snapshot and flip a single byte of its ciphertext.
|
|
*
|
|
* Since v1.39.2 a stored copy is a row in `data_backups` that names its
|
|
* pieces (`chunk_count`, `chunk_stream_id`) and ordered, separately sealed
|
|
* pieces in `data_backup_chunks`. Each piece is `iv | ciphertext | tag`
|
|
* under AES-256-GCM (see `src/lib/export/backup-chunks.ts`), so a byte four
|
|
* fifths of the way into the middle piece sits inside ciphertext its tag
|
|
* covers: what the restore meets is a well-formed copy whose one piece no
|
|
* longer authenticates, not a missing or reordered piece.
|
|
*
|
|
* A COPY rather than the row itself: the journey still needs the good
|
|
* snapshot afterwards, and a control that destroys its own subject can only
|
|
* be run once. The copy keeps the original stream id, which the pieces'
|
|
* sealed headers are bound to, so the only fault in it is the flipped byte.
|
|
*/
|
|
export async function storeTamperedCopy(backupId: string): Promise<string> {
|
|
return withPool(async (pool) => {
|
|
const { rows } = await pool.query<{
|
|
user_id: string;
|
|
chunk_count: number | null;
|
|
chunk_stream_id: string | null;
|
|
}>(
|
|
`SELECT user_id, chunk_count, chunk_stream_id FROM data_backups WHERE id = $1`,
|
|
[backupId],
|
|
);
|
|
if (rows.length !== 1) {
|
|
throw new Error(`no stored backup ${backupId} to copy`);
|
|
}
|
|
const { user_id, chunk_count, chunk_stream_id } = rows[0];
|
|
if (!chunk_count || !chunk_stream_id) {
|
|
throw new Error(`stored backup ${backupId} is not kept in pieces`);
|
|
}
|
|
const { rows: pieces } = await pool.query<{ seq: number; data: Buffer }>(
|
|
`SELECT seq, data FROM data_backup_chunks WHERE backup_id = $1 ORDER BY seq`,
|
|
[backupId],
|
|
);
|
|
if (pieces.length !== chunk_count) {
|
|
throw new Error(
|
|
`stored backup ${backupId} names ${chunk_count} pieces but has ${pieces.length}`,
|
|
);
|
|
}
|
|
|
|
const target = pieces[Math.floor(pieces.length / 2)];
|
|
const at = Math.floor(target.data.length * 0.8);
|
|
const tampered = Buffer.from(target.data);
|
|
tampered[at] ^= 0x01;
|
|
if (tampered.equals(target.data)) {
|
|
throw new Error("the tamper changed nothing");
|
|
}
|
|
|
|
const copyId = `c${Date.now().toString(36)}tampered${Math.floor(Math.random() * 1e6)}`;
|
|
const client = await pool.connect();
|
|
try {
|
|
await client.query("BEGIN");
|
|
await client.query(
|
|
`INSERT INTO data_backups (id, user_id, type, data, chunk_count, chunk_stream_id, created_at)
|
|
VALUES ($1, $2, $3, NULL, $4, $5, now())`,
|
|
[
|
|
copyId,
|
|
user_id,
|
|
`MANUAL_UPLOAD_${Date.now()}`,
|
|
chunk_count,
|
|
chunk_stream_id,
|
|
],
|
|
);
|
|
for (const piece of pieces) {
|
|
await client.query(
|
|
`INSERT INTO data_backup_chunks (id, backup_id, seq, data)
|
|
VALUES ($1, $2, $3, $4)`,
|
|
[
|
|
`${copyId}p${piece.seq}`,
|
|
copyId,
|
|
piece.seq,
|
|
piece.seq === target.seq ? tampered : piece.data,
|
|
],
|
|
);
|
|
}
|
|
await client.query("COMMIT");
|
|
} catch (error) {
|
|
await client.query("ROLLBACK");
|
|
throw error;
|
|
} finally {
|
|
client.release();
|
|
}
|
|
return copyId;
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Drop one stored copy again.
|
|
*
|
|
* The tampered copy the refusal control writes is the journey's own litter: it
|
|
* is not deleted until the next repetition's `beforeAll`, and until then it
|
|
* sits in the console's list for anyone who opens `/admin/backups`.
|
|
*/
|
|
export async function deleteStoredCopy(backupId: string): Promise<void> {
|
|
await withPool(async (pool) => {
|
|
await pool.query(`DELETE FROM data_backups WHERE id = $1`, [backupId]);
|
|
});
|
|
}
|
|
|
|
/** Every reading the account holds, tombstoned ones included. */
|
|
export async function storedMeasurements(): Promise<StoredMeasurement[]> {
|
|
const userId = await backupAccountId();
|
|
return withPool(async (pool) => {
|
|
const { rows } = await pool.query<{
|
|
id: string;
|
|
type: string;
|
|
value: string;
|
|
deleted_at: Date | null;
|
|
}>(
|
|
// `id` after the timestamp: the journey compares two whole arrays to
|
|
// prove a refusal changed nothing, and two readings sharing a timestamp
|
|
// could otherwise come back in either order and present as a change.
|
|
`SELECT id, type, value, deleted_at
|
|
FROM measurements WHERE user_id = $1
|
|
ORDER BY measured_at ASC, id ASC`,
|
|
[userId],
|
|
);
|
|
return rows.map((row) => ({
|
|
id: row.id,
|
|
type: row.type,
|
|
value: Number(row.value),
|
|
deletedAt: row.deleted_at ? row.deleted_at.toISOString() : null,
|
|
}));
|
|
});
|
|
}
|
|
|
|
/** Every dose the account holds, tombstoned ones included. */
|
|
export async function storedIntakeIds(): Promise<
|
|
Array<{ id: string; deletedAt: string | null }>
|
|
> {
|
|
const userId = await backupAccountId();
|
|
return withPool(async (pool) => {
|
|
const { rows } = await pool.query<{ id: string; deleted_at: Date | null }>(
|
|
`SELECT id, deleted_at FROM medication_intake_events
|
|
WHERE user_id = $1 ORDER BY scheduled_for ASC, id ASC`,
|
|
[userId],
|
|
);
|
|
return rows.map((row) => ({
|
|
id: row.id,
|
|
deletedAt: row.deleted_at ? row.deleted_at.toISOString() : null,
|
|
}));
|
|
});
|
|
}
|