Files
HealthLog/src/lib/withings/__tests__/ecg-waveform-codec.test.ts
T
Marc-André Bombeck 462948b33b feat(withings): capture the full ECG waveform, not just the AFib verdict
The Heart sync recorded only the device's atrial-fibrillation verdict per
on-device ECG. Fetch each recording's full waveform via the Heart get
endpoint and store the raw micro-volt samples so a real trace can later be
rendered.

The sample array is sensitive health data: it is held AES-256-GCM encrypted
at rest in the new EcgRecording.waveformEncrypted Bytes column (fail-closed
crypto), with the sampling frequency, sample count, duration, lead, average
heart rate, and AFib verdict kept as plaintext descriptors. A re-sync upserts
on (userId, source, externalRecordingId) so a recording is never duplicated;
a waveform fetch failure never fails the verdict ingest. Rendering UI is a
follow-up.
2026-06-21 10:22:29 +02:00

61 lines
2.2 KiB
TypeScript

/**
* v1.19.0 — ECG waveform Bytes-codec round-trip tests.
*
* The raw micro-volt sample array is stored AES-256-GCM encrypted in the
* `ecg_recordings.waveform_encrypted` Bytes column. These tests assert the
* encrypt → Bytes → decrypt round-trip preserves the array exactly, that the
* stored bytes are NOT plaintext (no cleartext sample shows through), and that
* a tampered / non-array payload fails closed.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { _resetCryptoCacheForTests, encrypt } from "@/lib/crypto";
import {
decryptWaveformFromBytes,
encryptWaveformToBytes,
} from "../ecg-waveform-codec";
const KEY = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
beforeEach(() => {
vi.unstubAllEnvs();
vi.stubEnv("ENCRYPTION_KEYS", "");
vi.stubEnv("ENCRYPTION_ACTIVE_KEY_ID", "");
vi.stubEnv("ENCRYPTION_KEY", KEY);
_resetCryptoCacheForTests();
});
afterEach(() => {
vi.unstubAllEnvs();
_resetCryptoCacheForTests();
});
describe("ecg-waveform-codec", () => {
it("round-trips a sample array through encrypt → Bytes → decrypt", () => {
const samples = [0, 12, -47, 1024, -2048, 999999, -1000000, 1];
const bytes = encryptWaveformToBytes(samples);
expect(decryptWaveformFromBytes(bytes)).toEqual(samples);
});
it("round-trips an empty array", () => {
const bytes = encryptWaveformToBytes([]);
expect(decryptWaveformFromBytes(bytes)).toEqual([]);
});
it("stores the waveform encrypted, not as plaintext", () => {
const samples = [424242, -313131];
const bytes = encryptWaveformToBytes(samples);
const asText = Buffer.from(bytes).toString("utf8");
// The ciphertext is `<keyId>.<base64>`; no raw sample value leaks.
expect(asText).not.toContain("424242");
expect(asText).not.toContain("-313131");
expect(asText.startsWith("v1.")).toBe(true);
});
it("fails closed on a payload that does not decode to a number array", () => {
// Hand-encrypt a non-array JSON value through the same active key.
const tampered = Buffer.from(encrypt(JSON.stringify({ not: "an array" })));
expect(() => decryptWaveformFromBytes(tampered)).toThrow(/number array/);
});
});