mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +00:00
fix(integrations): bound the Dawarich and Nightscout answers while reading
This commit is contained in:
1 parent
1a7050d95d
commit
c777819b33
4 files changed
+94
-7
No files matched your search
@@ -0,0 +1,42 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const safeFetchMock = vi.hoisted(() => vi.fn());
|
||||
vi.mock("@/lib/safe-fetch", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("@/lib/safe-fetch")>()),
|
||||
safeFetch: safeFetchMock,
|
||||
}));
|
||||
|
||||
import { DawarichApiError, probeDawarich } from "../client";
|
||||
|
||||
const CONN = { baseUrl: "https://dawarich.example.com", apiKey: "key" };
|
||||
|
||||
beforeEach(() => {
|
||||
safeFetchMock.mockReset();
|
||||
});
|
||||
|
||||
describe("reading a Dawarich answer", () => {
|
||||
it("parses an honest answer", async () => {
|
||||
safeFetchMock.mockResolvedValue(
|
||||
new Response(JSON.stringify({ user: { id: 1 } }), { status: 200 }),
|
||||
);
|
||||
await expect(probeDawarich(CONN)).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("stops reading an answer that runs past the cap", async () => {
|
||||
// Streamed without a length, so only the running count can catch it.
|
||||
const chunk = new Uint8Array(1024 * 1024).fill(32);
|
||||
let sent = 0;
|
||||
const body = new ReadableStream<Uint8Array>({
|
||||
pull(controller) {
|
||||
sent += 1;
|
||||
if (sent > 64) controller.close();
|
||||
else controller.enqueue(chunk);
|
||||
},
|
||||
});
|
||||
safeFetchMock.mockResolvedValue(new Response(body, { status: 200 }));
|
||||
const failure = await probeDawarich(CONN).catch((e: unknown) => e);
|
||||
expect(failure).toBeInstanceOf(DawarichApiError);
|
||||
expect((failure as Error).message).toMatch(/oversized/);
|
||||
expect(sent).toBeLessThan(64);
|
||||
});
|
||||
});
|
||||
@@ -5,7 +5,9 @@
|
||||
* reads four endpoints, all with `Authorization: Bearer <api key>`:
|
||||
*
|
||||
* GET /api/v1/timeline?start_at&end_at&distance_unit=km
|
||||
* one entry per local day (the instance's own zone) with a `summary`:
|
||||
* one entry per day in the Dawarich account's own zone (its timezone
|
||||
* setting, the instance's `TIME_ZONE` by default; the request cannot
|
||||
* name one), summing only what lies inside the window, with a `summary`:
|
||||
* `total_distance`, `places_visited`, `mode_distances.{driving,train}`
|
||||
* in km to one decimal. At most 31 days per request.
|
||||
* GET /api/v1/visits?start_at&end_at
|
||||
@@ -26,6 +28,7 @@
|
||||
* `DAWARICH_PRIVATE_ORIGINS`. The policy is re-read on every call, so taking
|
||||
* an origin off the list stops the next request.
|
||||
*/
|
||||
import { readBodyCapped } from "@/lib/http/read-capped";
|
||||
import { SafeFetchError, safeFetch } from "@/lib/safe-fetch";
|
||||
|
||||
import {
|
||||
@@ -35,6 +38,14 @@ import {
|
||||
|
||||
const DEFAULT_TIMEOUT_MS = 20_000;
|
||||
|
||||
/**
|
||||
* The most any one answer may hold. A month of timeline with its entries and
|
||||
* a year of residency stay far below it; the cap is what keeps an instance
|
||||
* that streams without end from holding the worker's memory until the
|
||||
* timeout.
|
||||
*/
|
||||
const MAX_BODY_BYTES = 16 * 1024 * 1024;
|
||||
|
||||
/** Dawarich refuses a timeline window longer than 31 days. */
|
||||
export const TIMELINE_MAX_DAYS = 31;
|
||||
|
||||
@@ -118,8 +129,12 @@ async function dawarichGet(
|
||||
if (res.status < 200 || res.status >= 300) {
|
||||
throw new DawarichApiError(`Dawarich responded ${res.status}`, res.status);
|
||||
}
|
||||
const read = await readBodyCapped(res, MAX_BODY_BYTES);
|
||||
if (!read.ok) {
|
||||
throw new DawarichApiError("Dawarich returned an oversized body", null);
|
||||
}
|
||||
try {
|
||||
return await res.json();
|
||||
return JSON.parse(read.text) as unknown;
|
||||
} catch {
|
||||
throw new DawarichApiError("Dawarich returned a non-JSON body", null);
|
||||
}
|
||||
|
||||
@@ -18,11 +18,10 @@ import {
|
||||
} from "../client";
|
||||
|
||||
function jsonResponse(status: number, body: unknown): Response {
|
||||
return {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status,
|
||||
ok: status >= 200 && status < 300,
|
||||
json: async () => body,
|
||||
} as unknown as Response;
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
const SAMPLE: NightscoutSgvEntry[] = [
|
||||
@@ -295,4 +294,27 @@ describe("fetchSgvEntries — SSRF gating", () => {
|
||||
expect(entries).toHaveLength(2);
|
||||
expect(entries[0]!.sgv).toBe(112);
|
||||
});
|
||||
|
||||
it("stops reading an answer that runs past the cap", async () => {
|
||||
// Streamed without a length, so only the running count can catch it.
|
||||
const chunk = new Uint8Array(1024 * 1024).fill(32);
|
||||
let sent = 0;
|
||||
const body = new ReadableStream<Uint8Array>({
|
||||
pull(controller) {
|
||||
sent += 1;
|
||||
if (sent > 64) controller.close();
|
||||
else controller.enqueue(chunk);
|
||||
},
|
||||
});
|
||||
safeFetchMock.mockResolvedValue(new Response(body, { status: 200 }));
|
||||
await expect(
|
||||
fetchSgvEntries({
|
||||
baseUrl: "https://ns.example.com",
|
||||
token: "",
|
||||
count: 10,
|
||||
allowPrivateHost: false,
|
||||
}),
|
||||
).rejects.toThrow(/oversized/);
|
||||
expect(sent).toBeLessThan(64);
|
||||
});
|
||||
});
|
||||
@@ -23,6 +23,7 @@
|
||||
import { createHash } from "node:crypto";
|
||||
|
||||
import type { MeasurementType } from "@/generated/prisma/client";
|
||||
import { readBodyCapped } from "@/lib/http/read-capped";
|
||||
import { safeFetch } from "@/lib/safe-fetch";
|
||||
import {
|
||||
configuredNightscoutPrivateOrigins,
|
||||
@@ -33,6 +34,7 @@ import { SafeFetchError } from "@/lib/safe-fetch";
|
||||
|
||||
/** Default per-request timeout for a Nightscout fetch. */
|
||||
const NIGHTSCOUT_TIMEOUT_MS = 15_000;
|
||||
const NIGHTSCOUT_MAX_BODY_BYTES = 8 * 1024 * 1024;
|
||||
|
||||
/** Canonical glucose storage unit. Nightscout SGV is always mg/dL on the wire. */
|
||||
export const NIGHTSCOUT_GLUCOSE_UNIT = "mg/dL" as const;
|
||||
@@ -259,9 +261,15 @@ export async function fetchSgvEntries(
|
||||
);
|
||||
}
|
||||
|
||||
// Bounded while reading: `count` entries are a few hundred bytes each, so
|
||||
// the cap is far above any honest answer and stops one that never ends.
|
||||
const read = await readBodyCapped(res, NIGHTSCOUT_MAX_BODY_BYTES);
|
||||
if (!read.ok) {
|
||||
throw new NightscoutApiError("Nightscout returned an oversized body", null);
|
||||
}
|
||||
let body: unknown;
|
||||
try {
|
||||
body = await res.json();
|
||||
body = JSON.parse(read.text) as unknown;
|
||||
} catch {
|
||||
throw new NightscoutApiError("Nightscout returned a non-JSON body", null);
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user