mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 22:02:16 +00:00
Merge remote-tracking branch 'origin/main' into fix/v1398-polish
This commit is contained in:
commit
ba426706be
7 files changed
+131
-5
No files matched your search
@@ -64,10 +64,15 @@ const MAX_SAMPLES = 32_768;
|
||||
// any real recording. A DoS ceiling, not a tight bound.
|
||||
const MAX_BODY_BYTES = 2 * 1024 * 1024;
|
||||
|
||||
// One recording per request, so the limit is per recording. A person records
|
||||
// a handful of strips a week; 60/min still drains a full watch history in
|
||||
// minutes and stops a leaked wildcard token from saturating the write path.
|
||||
const INGEST_RATE_LIMIT_MAX = 60;
|
||||
// One recording per request, so the limit is per recording. The iPhone app
|
||||
// sends every recording its sweep has not yet consumed in one go and, on a
|
||||
// 429, stops and starts the same sweep again on its next wake. A window that
|
||||
// held more than the limit could therefore never finish: with 60 per minute
|
||||
// the 61st recording was refused on every sync and the newest ECG never
|
||||
// arrived (#1060). 600 covers more than a decade of weekly strips in one
|
||||
// sweep and still stops a leaked token from saturating the write path; a
|
||||
// request is at most 2 MB and usually about 100 KB.
|
||||
const INGEST_RATE_LIMIT_MAX = 600;
|
||||
const INGEST_RATE_LIMIT_WINDOW_MS = 60 * 1000;
|
||||
|
||||
// Only the three ECG verdicts. `RhythmClassification` in the database has six
|
||||
|
||||
@@ -207,6 +207,42 @@ describe("POST /api/workouts/batch — which credentials it admits", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /api/workouts/batch — rate limit buckets", () => {
|
||||
function lastBucket(): string {
|
||||
const calls = vi.mocked(checkRateLimit).mock.calls;
|
||||
return calls[calls.length - 1][0] as string;
|
||||
}
|
||||
|
||||
it("counts a workouts:write token apart from the phone", async () => {
|
||||
armToken([WORKOUTS_WRITE_SCOPE]);
|
||||
await POST(makeRequest({ workouts: [workout("s-1")] }));
|
||||
const bridge = lastBucket();
|
||||
|
||||
armToken(["*"]);
|
||||
await POST(
|
||||
makeRequest({ workouts: [workout("s-2", { source: "APPLE_HEALTH" })] }),
|
||||
);
|
||||
const phone = lastBucket();
|
||||
|
||||
expect(bridge).not.toBe(phone);
|
||||
expect(bridge).toContain(USER.id);
|
||||
expect(phone).toContain(USER.id);
|
||||
});
|
||||
|
||||
it("keeps a session and a wildcard token in the same bucket", async () => {
|
||||
armSession();
|
||||
await POST(
|
||||
makeRequest({ workouts: [workout("s-1", { source: "APPLE_HEALTH" })] }),
|
||||
);
|
||||
const session = lastBucket();
|
||||
armToken(["*"]);
|
||||
await POST(
|
||||
makeRequest({ workouts: [workout("s-2", { source: "APPLE_HEALTH" })] }),
|
||||
);
|
||||
expect(lastBucket()).toBe(session);
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /api/workouts/batch — what a scoped caller writes", () => {
|
||||
it("attributes its rows to EXTERNAL", async () => {
|
||||
armToken([WORKOUTS_WRITE_SCOPE]);
|
||||
|
||||
@@ -232,8 +232,14 @@ async function postBatch(request: NextRequest): Promise<Response> {
|
||||
// database for every other user on the host. 60 batches/min/user is
|
||||
// generous for healthy iOS sync and tight enough that a misbehaving
|
||||
// client bottoms out within a minute.
|
||||
//
|
||||
// A scoped `workouts:write` token is a bridge, not the phone, so it counts
|
||||
// in a bucket of its own: a bridge stuck in a retry loop must not use up
|
||||
// the allowance the iPhone app needs to sync the same account's workouts.
|
||||
// All bridge tokens of one account share that bucket, so minting more
|
||||
// tokens does not multiply the allowance.
|
||||
const rl = await checkRateLimit(
|
||||
`workouts:batch:${user.id}`,
|
||||
scoped ? `workouts:batch:bridge:${user.id}` : `workouts:batch:${user.id}`,
|
||||
BATCH_RATE_LIMIT_MAX,
|
||||
BATCH_RATE_LIMIT_WINDOW_MS,
|
||||
);
|
||||
|
||||
Reference in new issue
Block a user