mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 21:32:18 +00:00
Closes #584. `/insights/pulse` computed hasRestingHr from RESTING_HEART_RATE presence and used it to swap the ENTIRE primary chart and the Coach read strip to that series whenever any resting row existed, while the title, stat strip, capture action, and "show all values" link kept saying PULSE. A user could see a resting-only chart under a page that claimed to be pulse throughout. The chart now always includes PULSE as its first series; when resting data exists, RESTING_HEART_RATE renders as a second, clearly-labelled series (the same multi-series pattern the blood-pressure page uses for systolic/diastolic) instead of replacing it. The Coach read strip stays pinned to PULSE. The page also drops the resting-calibrated target band it used to paint behind the chart — that band is calibrated to steady-state resting readings and would misreport an expected workout spike in raw PULSE data as "out of target"; the personal target belongs on the dedicated resting-pulse page against the clean resting series.
188 lines
6.9 KiB
TypeScript
188 lines
6.9 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
import { writeFileSync, mkdtempSync, readFileSync } from "node:fs";
|
|
import { join } from "node:path";
|
|
import { tmpdir } from "node:os";
|
|
import { deflateRawSync } from "node:zlib";
|
|
import { pipeline } from "node:stream/promises";
|
|
import { Readable } from "node:stream";
|
|
|
|
import {
|
|
createByteCap,
|
|
extractExportXml,
|
|
readCentralDirectory,
|
|
} from "../unzip-export-xml";
|
|
|
|
/**
|
|
* Hand-build a single-entry ZIP archive with the supplied filename +
|
|
* payload. Enough to exercise the central-directory walker + extractor
|
|
* without pulling in a full zip library. `method` selects compression
|
|
* method 8 (deflate, default) or 0 (stored — bytes copied verbatim).
|
|
*/
|
|
function buildMinimalZip(
|
|
filename: string,
|
|
payload: Buffer,
|
|
method: 0 | 8 = 8,
|
|
): Buffer {
|
|
const compressed = method === 8 ? deflateRawSync(payload) : payload;
|
|
const crc32 = (() => {
|
|
// Pre-computed lookup table for CRC-32. node:zlib has crc32 in 22+
|
|
// but we keep the function inline to stay version-agnostic.
|
|
let c: number;
|
|
const table: number[] = [];
|
|
for (let n = 0; n < 256; n++) {
|
|
c = n;
|
|
for (let k = 0; k < 8; k++) {
|
|
c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
|
|
}
|
|
table[n] = c;
|
|
}
|
|
let crc = 0 ^ -1;
|
|
for (let i = 0; i < payload.length; i++) {
|
|
crc = (crc >>> 8) ^ table[(crc ^ payload[i]) & 0xff];
|
|
}
|
|
return (crc ^ -1) >>> 0;
|
|
})();
|
|
|
|
const nameBuf = Buffer.from(filename, "utf8");
|
|
// Local file header
|
|
const localHeader = Buffer.alloc(30);
|
|
localHeader.writeUInt32LE(0x04034b50, 0);
|
|
localHeader.writeUInt16LE(20, 4); // version
|
|
localHeader.writeUInt16LE(0, 6); // flags
|
|
localHeader.writeUInt16LE(method, 8); // method
|
|
localHeader.writeUInt16LE(0, 10); // time
|
|
localHeader.writeUInt16LE(0, 12); // date
|
|
localHeader.writeUInt32LE(crc32, 14);
|
|
localHeader.writeUInt32LE(compressed.length, 18);
|
|
localHeader.writeUInt32LE(payload.length, 22);
|
|
localHeader.writeUInt16LE(nameBuf.length, 26);
|
|
localHeader.writeUInt16LE(0, 28); // extra len
|
|
|
|
// Central directory file header
|
|
const cdh = Buffer.alloc(46);
|
|
cdh.writeUInt32LE(0x02014b50, 0);
|
|
cdh.writeUInt16LE(20, 4); // version made by
|
|
cdh.writeUInt16LE(20, 6); // version needed
|
|
cdh.writeUInt16LE(0, 8); // flags
|
|
cdh.writeUInt16LE(method, 10); // method
|
|
cdh.writeUInt16LE(0, 12); // time
|
|
cdh.writeUInt16LE(0, 14); // date
|
|
cdh.writeUInt32LE(crc32, 16);
|
|
cdh.writeUInt32LE(compressed.length, 20);
|
|
cdh.writeUInt32LE(payload.length, 24);
|
|
cdh.writeUInt16LE(nameBuf.length, 28);
|
|
cdh.writeUInt16LE(0, 30); // extra
|
|
cdh.writeUInt16LE(0, 32); // comment
|
|
cdh.writeUInt16LE(0, 34); // disk num
|
|
cdh.writeUInt16LE(0, 36); // internal attrs
|
|
cdh.writeUInt32LE(0, 38); // external attrs
|
|
cdh.writeUInt32LE(0, 42); // local header offset
|
|
|
|
// End of Central Directory
|
|
const eocd = Buffer.alloc(22);
|
|
eocd.writeUInt32LE(0x06054b50, 0);
|
|
eocd.writeUInt16LE(0, 4); // disk
|
|
eocd.writeUInt16LE(0, 6); // disk-with-cd
|
|
eocd.writeUInt16LE(1, 8); // entries-on-disk
|
|
eocd.writeUInt16LE(1, 10); // total-entries
|
|
eocd.writeUInt32LE(46 + nameBuf.length, 12); // cd size
|
|
// cd offset = local-header + name + data
|
|
eocd.writeUInt32LE(30 + nameBuf.length + compressed.length, 16);
|
|
eocd.writeUInt16LE(0, 20); // comment len
|
|
|
|
return Buffer.concat([localHeader, nameBuf, compressed, cdh, nameBuf, eocd]);
|
|
}
|
|
|
|
describe("readCentralDirectory", () => {
|
|
it("walks a single-entry archive", () => {
|
|
const payload = Buffer.from("<HealthData/>");
|
|
const zip = buildMinimalZip("apple_health_export/export.xml", payload);
|
|
const entries = readCentralDirectory(zip);
|
|
expect(entries).toHaveLength(1);
|
|
expect(entries[0].fileName).toBe("apple_health_export/export.xml");
|
|
expect(entries[0].compressionMethod).toBe(8);
|
|
expect(entries[0].uncompressedSize).toBe(payload.length);
|
|
});
|
|
});
|
|
|
|
describe("extractExportXml", () => {
|
|
it("extracts a deflated export.xml member to a temp file", async () => {
|
|
const payload = Buffer.from(
|
|
`<?xml version="1.0"?><HealthData><ExportDate value="2026-05-15"/></HealthData>`,
|
|
);
|
|
const zip = buildMinimalZip("apple_health_export/export.xml", payload);
|
|
const tmp = mkdtempSync(join(tmpdir(), "healthlog-unzip-"));
|
|
const zipPath = join(tmp, "export.zip");
|
|
writeFileSync(zipPath, zip);
|
|
|
|
const out = await extractExportXml(zipPath);
|
|
expect(out.xmlBytes).toBe(payload.length);
|
|
expect(out.otherMembers).toHaveLength(0);
|
|
|
|
const extracted = readFileSync(out.xmlPath);
|
|
expect(extracted.toString("utf8")).toBe(payload.toString("utf8"));
|
|
});
|
|
|
|
// v1.32.1 (issue #588) — the stored (uncompressed) path now runs
|
|
// through the same streaming pipeline as deflate; a stored member
|
|
// must still round-trip byte-exact through it.
|
|
it("extracts a stored (uncompressed) export.xml member to a temp file", async () => {
|
|
const payload = Buffer.from(
|
|
`<?xml version="1.0"?><HealthData><Record type="HKQuantityTypeIdentifierHeartRate"/></HealthData>`,
|
|
);
|
|
const zip = buildMinimalZip("apple_health_export/export.xml", payload, 0);
|
|
const tmp = mkdtempSync(join(tmpdir(), "healthlog-unzip-"));
|
|
const zipPath = join(tmp, "export.zip");
|
|
writeFileSync(zipPath, zip);
|
|
|
|
const out = await extractExportXml(zipPath);
|
|
expect(out.xmlBytes).toBe(payload.length);
|
|
|
|
const extracted = readFileSync(out.xmlPath);
|
|
expect(extracted.toString("utf8")).toBe(payload.toString("utf8"));
|
|
});
|
|
|
|
it("throws when the export.xml member is missing", async () => {
|
|
const payload = Buffer.from("noop");
|
|
const zip = buildMinimalZip("other-file.txt", payload);
|
|
const tmp = mkdtempSync(join(tmpdir(), "healthlog-unzip-"));
|
|
const zipPath = join(tmp, "export.zip");
|
|
writeFileSync(zipPath, zip);
|
|
|
|
await expect(extractExportXml(zipPath)).rejects.toThrow(/missing/);
|
|
});
|
|
});
|
|
|
|
// v1.32.1 (issue #588) — the streaming inflate Transform has no
|
|
// `maxOutputLength` option, so the zip-bomb defence moved to a
|
|
// hand-rolled byte-counting stage. Exercise it directly at a tiny
|
|
// limit; the real 8 GiB ceiling is not practical to trip from a
|
|
// fixture.
|
|
describe("createByteCap", () => {
|
|
it("passes bytes through unchanged while under the limit", async () => {
|
|
const chunks: Buffer[] = [];
|
|
await pipeline(
|
|
Readable.from([Buffer.from("hello"), Buffer.from("world")]),
|
|
createByteCap(10),
|
|
async function collect(source) {
|
|
for await (const chunk of source) chunks.push(chunk as Buffer);
|
|
},
|
|
);
|
|
expect(Buffer.concat(chunks).toString("utf8")).toBe("helloworld");
|
|
});
|
|
|
|
it("rejects the stream once the running total exceeds the limit", async () => {
|
|
await expect(
|
|
pipeline(
|
|
Readable.from([Buffer.from("hello"), Buffer.from("world")]),
|
|
createByteCap(6),
|
|
async function drain(source) {
|
|
for await (const chunk of source) {
|
|
void chunk;
|
|
}
|
|
},
|
|
),
|
|
).rejects.toThrow(/exceeds the 6-byte cap/);
|
|
});
|
|
});
|